Skip to content

Commit d2f2dae

Browse files
authored
perf(ios): build structural-identifier suppression child index once per pass (#1938)
collectIosStructuralIdentifierSuppression rebuilt a full node map and walked every candidate's ancestor chain per identifier-only wrapper (O(candidates x n x d) on every iOS snapshot). Collect descendants via one childrenByParent index per pass, preserving exact parent-link semantics for trees without depth fields. collectDescendantsByParentIndex had no remaining callers and is removed.
1 parent 3bf3ff1 commit d2f2dae

3 files changed

Lines changed: 119 additions & 20 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
4+
5+
import { collectIosStructuralIdentifierSuppression } from './noise.ts';
6+
import type { SnapshotTreeRuleContext } from '../tree.ts';
7+
8+
type ReadCounter = { reads: number };
9+
10+
function countingNode(base: RawSnapshotNode, counter: ReadCounter): RawSnapshotNode {
11+
return {
12+
...base,
13+
get index() {
14+
counter.reads += 1;
15+
return base.index;
16+
},
17+
get parentIndex(): number | undefined {
18+
counter.reads += 1;
19+
return base.parentIndex;
20+
},
21+
};
22+
}
23+
24+
function makeStructuralTree(
25+
candidateCount: number,
26+
descendantsPerCandidate: number,
27+
): {
28+
nodes: RawSnapshotNode[];
29+
counter: ReadCounter;
30+
} {
31+
const counter: ReadCounter = { reads: 0 };
32+
const plain: RawSnapshotNode[] = [{ index: 0, type: 'Application', label: 'App' }];
33+
let nextIndex = 1;
34+
for (let candidate = 0; candidate < candidateCount; candidate += 1) {
35+
const candidateIndex = nextIndex;
36+
plain.push({
37+
index: candidateIndex,
38+
parentIndex: 0,
39+
type: 'Other',
40+
identifier: `wrapper-${candidate}`,
41+
});
42+
nextIndex += 1;
43+
for (let descendant = 0; descendant < descendantsPerCandidate; descendant += 1) {
44+
plain.push({
45+
index: nextIndex,
46+
parentIndex: candidateIndex,
47+
type: 'StaticText',
48+
label: `content ${candidate}-${descendant}`,
49+
});
50+
nextIndex += 1;
51+
}
52+
}
53+
return { nodes: plain.map((node) => countingNode(node, counter)), counter };
54+
}
55+
56+
function makeRuleContext(nodes: RawSnapshotNode[]): SnapshotTreeRuleContext {
57+
return {
58+
replacements: new Map(),
59+
semanticRepresentativeIndexes: new Set(),
60+
sourceNodesByIndex: new Map(nodes.map((node) => [node.index, node])),
61+
isSuppressed: () => false,
62+
suppressNode: () => {},
63+
};
64+
}
65+
66+
describe('collectIosStructuralIdentifierSuppression', () => {
67+
test('suppresses structural identifier wrappers, keeping their content published', () => {
68+
const { nodes } = makeStructuralTree(3, 2);
69+
const suppressed: number[] = [];
70+
const context: SnapshotTreeRuleContext = {
71+
...makeRuleContext(nodes),
72+
suppressNode: (source) => suppressed.push(source.index),
73+
};
74+
75+
collectIosStructuralIdentifierSuppression(nodes, context);
76+
77+
expect(suppressed.sort((a, b) => a - b)).toEqual([1, 4, 7]);
78+
});
79+
80+
test('builds the child index once instead of per structural candidate', () => {
81+
const candidateCount = 40;
82+
const descendantsPerCandidate = 25;
83+
const { nodes, counter } = makeStructuralTree(candidateCount, descendantsPerCandidate);
84+
const nodeCount = nodes.length;
85+
86+
collectIosStructuralIdentifierSuppression(nodes, makeRuleContext(nodes));
87+
88+
// The per-candidate implementation rebuilds a full node map and walks an
89+
// ancestor chain for every node once per candidate (~candidates x n
90+
// reads); the child-index implementation reads each node a constant
91+
// number of times plus one subtree pass per candidate. The bound sits far
92+
// above the healthy cost and far below the quadratic one.
93+
expect(counter.reads).toBeLessThan(
94+
4 * nodeCount + 8 * candidateCount * descendantsPerCandidate,
95+
);
96+
});
97+
});

src/daemon/snapshot-presentation/ios/noise.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { normalizeType } from '@agent-device/contracts/snapshot';
1010
import { collectIosScrollIndicatorPresentation } from './scroll.ts';
1111
import {
1212
areRectsApproximatelyEqual,
13-
collectDescendantsByParentIndex,
13+
collectChildrenByParent,
1414
findDescendant,
1515
findLargestViewportRect,
1616
forEachDescendant,
@@ -302,10 +302,11 @@ function suppressOffscreenKeyboardAncestors(
302302
}
303303
}
304304

305-
function collectIosStructuralIdentifierSuppression(
305+
export function collectIosStructuralIdentifierSuppression(
306306
nodes: RawSnapshotNode[],
307307
context: SnapshotTreeRuleContext,
308308
): void {
309+
const childrenByParent = collectChildrenByParent(nodes);
309310
for (const node of nodes) {
310311
if (normalizeType(node.type ?? '') !== 'other') {
311312
continue;
@@ -316,10 +317,28 @@ function collectIosStructuralIdentifierSuppression(
316317
if (!node.identifier?.trim()) {
317318
continue;
318319
}
319-
context.suppressNode(node, collectDescendantsByParentIndex(nodes, node.index));
320+
context.suppressNode(node, collectSubtreeByParentLinks(node, childrenByParent));
320321
}
321322
}
322323

324+
function collectSubtreeByParentLinks(
325+
root: RawSnapshotNode,
326+
childrenByParent: ReadonlyMap<number, RawSnapshotNode[]>,
327+
): RawSnapshotNode[] {
328+
const descendants: RawSnapshotNode[] = [];
329+
const visited = new Set<number>([root.index]);
330+
const pending = [...(childrenByParent.get(root.index) ?? [])];
331+
while (pending.length > 0) {
332+
const current = pending.pop();
333+
if (!current || visited.has(current.index)) continue;
334+
visited.add(current.index);
335+
descendants.push(current);
336+
const children = childrenByParent.get(current.index);
337+
if (children) pending.push(...children);
338+
}
339+
return descendants;
340+
}
341+
323342
function collectIosSearchToolbarSuppression(
324343
nodes: RawSnapshotNode[],
325344
context: SnapshotTreeRuleContext,

src/daemon/snapshot-presentation/tree.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,6 @@ export function collectDescendants(
3535
return nodes.slice(startPosition + 1, endPosition);
3636
}
3737

38-
export function collectDescendantsByParentIndex(
39-
nodes: RawSnapshotNode[],
40-
ancestorIndex: number,
41-
): RawSnapshotNode[] {
42-
const byIndex = new Map(nodes.map((node) => [node.index, node]));
43-
return nodes.filter((node) => {
44-
let parentIndex = node.parentIndex;
45-
const visited = new Set<number>();
46-
while (typeof parentIndex === 'number' && !visited.has(parentIndex)) {
47-
if (parentIndex === ancestorIndex) return true;
48-
visited.add(parentIndex);
49-
parentIndex = byIndex.get(parentIndex)?.parentIndex;
50-
}
51-
return false;
52-
});
53-
}
54-
5538
export function findDescendant(
5639
nodes: RawSnapshotNode[],
5740
startPosition: number,

0 commit comments

Comments
 (0)