You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan 007: Index snapshot topology once when ranking find matches
Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. A reviewer maintains the plan index for this
execution; do not create or edit plans/README.md.
Drift check (run first): git diff --stat 13bc70f24..HEAD -- src/daemon/handlers/find.ts src/daemon/handlers/find-match-ranking.ts src/daemon/handlers/__tests__/find-match-ranking.test.ts src/core/actionable-touch-topology.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.ts src/core/interaction-targeting.test.ts src/snapshot/snapshot-processing.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.
Status
Priority: P2
Effort: M
Risk: MED
Depends on: none
Category: perf
Planned at: commit 13bc70f24, 2026-08-08
Why this matters
A mutating find ... click|fill|focus|type ranks every candidate before it
narrows with --first/--last or returns AMBIGUOUS_MATCH. Each candidate
currently triggers multiple full-snapshot traversals: descendant discovery
filters all nodes, ancestor lookup rebuilds the index map, and viewport-root
resolution filters all nodes again. With m matches in an n-node capture,
those paths perform O(m × n) full-tree work before the action or refusal. This
plan builds one immutable topology index per multi-match ranking pass and
threads it through the existing actionability policy without changing match
ordering, candidate details, ambiguity behavior, or single-target callers.
Current state
Relevant files and their roles:
src/daemon/handlers/find.ts — mutating-find orchestration and current
on-screen preference. It is already 624 lines, so scoring must be extracted
into a focused sibling; this file retains the small per-ranking-pass wrapper
that builds and passes one topology.
src/daemon/handlers/find-match-ranking.ts — new focused module to own
actionability scoring, stable tie-breaking, and root interaction-container
classification.
src/core/actionable-touch-topology.ts — new concrete one-pass topology
builder shared by ranking and actionability resolution. It is an internal
source module, not a package/public API or a generic adapter abstraction.
src/core/interaction-targeting.ts — canonical touch-target promotion
policy. It must accept an optional prebuilt topology while preserving the
current no-topology call shape for ordinary one-target interactions.
src/snapshot/snapshot-processing.ts — canonical nearest-ancestor helper;
it currently builds its node map internally on every call.
src/daemon/handlers/__tests__/find.test.ts — existing end-to-end handler
tests for candidate ordering, ambiguity, and --first. It is 1,232 lines;
run it, but do not add more cases to it.
Ranking occurs before ambiguity rejection or explicit positional narrowing:
// src/daemon/handlers/find.ts:345-365functionrankInteractiveMatches(matches: SnapshotState['nodes'],nodes: SnapshotState['nodes'],): SnapshotState['nodes']{if(matches.length<2)returnmatches;returnmatches.map((node,index)=>({ node, index,score: interactiveMatchScore(node,nodes)})).sort(/* score, rect area, original index */).map((entry)=>entry.node);}functioninteractiveMatchScore(node,nodes): number{constresolution=resolveActionableTouchResolution(nodes,node);// ...}
There are three distinct per-candidate full-tree paths. All three must use the
same prebuilt topology; optimizing only the first two leaves the Android/
nonsemantic ancestor path quadratic.
// src/core/interaction-targeting.ts:49-69,77-103exportfunctionresolveActionableTouchResolution(nodes,node){// ...constdescendant=findPreferredActionableDescendant(nodes,node);// ...constancestor=findNearestHittableAncestor(nodes,node);// ...}functionfindPreferredActionableDescendant(nodes,node){// For every step in a same-rect chain:constsameRectChildren=nodes.filter((candidate)=>{if(candidate.parentIndex!==current.index||!candidate.hittable/* ... */)returnfalse;// ...});}// src/core/interaction-targeting.ts:127-169functionisOverlyBroadAncestor(node,ancestor,nodes): boolean{// ...constrootViewportRect=resolveRootViewportRect(nodes,nodeRect);// ...}functionresolveRootViewportRect(nodes,targetRect): Rect|null{constviewportRects=nodes.filter(isViewportRootNode).map((node)=>normalizeRect(node.rect)).filter((rect): rect is Rect=>rect!==null);// ...}
Ancestor lookup constructs another full map per candidate:
Build parent adjacency in one pass, matching src/daemon/snapshot-presentation/ios/web.ts:93-101: a Map keyed by parentIndex, append each child once, and do not filter the whole tree per parent.
Node identity and ancestors are keyed by node.index, not array position. packages/contracts/src/snapshot-tree.ts:3-31 supplies buildSnapshotNodeMap/findSnapshotAncestor and preserves an array-offset
fallback plus cycle protection. Do not weaken that behavior.
Viewport roots must continue to use the canonical isViewportRootNode
predicate. This plan only precomputes its candidates. It must not change the
current resolveRootViewportRect selection algorithm or add the separately
known Android largest-containing-rect fallback.
src/daemon/handlers/__tests__/find.test.ts:667-722 pins the public contract:
ambiguous selector-shaped finds refuse with candidates, while explicit --first selects the first element in the established ranked order.
docs/agents/testing.md:68-75 requires a regression test to be observed red
against the pre-fix production path. For performance work, use operation
counts and a real production builder spy; do not use a wall-clock threshold.
Do not add a test-only DI parameter. preferOnscreenMatches remains the real
production wrapper called by resolveFindMatch; exporting that wrapper gives
the focused regression a direct policy seam without adding injectable state.
exit 0; bounded dependents, gates, and command owners are printed
Focused tests
pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts
all selected files pass
Fast static gate
pnpm check:quick
exit 0; lint and typecheck pass
Format
pnpm format
exit 0; only intended in-scope source/test formatting changes remain
Affected gate
pnpm check:affected --run
exit 0; selected local gates pass and CI-owned gates are reported
Broad deterministic gate
pnpm check
exit 0; tooling, fallow, unit, and smoke aggregates pass
The repository requires pnpm 11.17.0 and Node >=22.12. Do not create or
restore package-lock.json. No native/Apple build is required because all
in-scope changes are TypeScript; follow pnpm check:affected --run if the
actual diff selects an additional authoritative lane.
Scope
In scope (the only implementation/test files to modify or create):
Out of scope (do NOT touch, even though they are related):
src/daemon/handlers/__tests__/find.test.ts — this legacy 1,232-line file
remains authoritative handler coverage but must not grow in this plan.
Selector parsing/resolution, fuzzy locator scoring, findAct policy, buildAmbiguousMatchError, candidate payload rendering, --first/--last
semantics, and public response shapes.
packages/contracts/src/snapshot-visibility.ts and Android viewport fallback
behavior. The known resolver divergence is not part of this performance fix.
Package/public barrels and exports. Import the new internal module directly;
do not add it to the npm package surface.
A generic repository-wide tree-index abstraction. This plan has one concrete
actionability topology with real consumers in find ranking and touch resolution.
README, website, CLI help, or skill behavior guidance. No user-facing behavior
or command surface is intended to change.
Keep the behavior-preserving ranking extraction separate from the indexed
implementation if the operator wants reviewable commits.
Suggested commit messages:
refactor(find): isolate match ranking policy
perf(find): index snapshot topology during match ranking
Commit messages follow the repository's conventional prefixes. Do not push
or open a PR unless the operator explicitly instructs it.
Steps
Step 1: Extract ranking without changing behavior
Create src/daemon/handlers/find-match-ranking.ts and move the coherent ranking
question out of the already oversized handler:
rankInteractiveMatches
interactiveMatchScore
resolvedTouchScore
rectArea
isRootInteractionContainer
rectsMatch
Export rankInteractiveMatches and isRootInteractionContainer because find.ts is their real production caller. Keep the other moved helpers private.
Retain preferOnscreenMatches in find.ts, export it as the focused production
wrapper, and have it call the extracted rankInteractiveMatches. Remove imports
used only by the moved code. Do not move locator matching, ambiguity-response
construction, node fetching, or action dispatch. At the end of this step, the
extracted ranking must still call resolveActionableTouchResolution(nodes, node)
with no topology, so this is a pure move that establishes a testable seam before
optimization.
Run the existing handler and targeting tests before proceeding. Candidate order,
candidate rendering, AMBIGUOUS_MATCH, and explicit --first must be identical.
Verify: pnpm exec vitest run src/daemon/handlers/__tests__/find.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts
→ all pass with the behavior-preserving extraction only.
Step 2: Add the concrete one-pass topology builder
Create src/core/actionable-touch-topology.ts with a narrow production type and
builder. Use durable domain names such as:
Construct all three collections in one for...of pass:
add every node by node.index;
append nodes with numeric parentIndex to that parent's children array; and
append nodes satisfying canonical isViewportRootNode to viewportRootNodes.
The topology is immutable by convention for the duration of one ranking pass;
do not mutate snapshot nodes or cache it on a session. Do not use nodes.filter
or nodes.map inside this builder — the regression test will instrument those
operations to prove the full tree is not rescanned per candidate. No package
barrel export is needed.
Create src/core/actionable-touch-topology.test.ts. Using makeSnapshotState
from the shared test-utils barrel, assert one mixed tree produces the expected:
node-by-index entries, including non-contiguous indexes;
child lists for two different parents in input order; and
viewport-root list using type, role, or subrole through the canonical predicate.
Keep this test structural to the builder's real production output; do not add a
test-only counter or injectable builder.
Verify: pnpm exec vitest run src/core/actionable-touch-topology.test.ts
→ the new builder tests pass.
Step 3: Add a deterministic red proof for one build and zero full rescans
Create src/daemon/handlers/__tests__/find-match-ranking.test.ts. Test the
production export preferOnscreenMatches; do not reach a private helper or add
test-only DI.
Use Vitest's module mocking to wrap the real buildActionableTouchTopology export with vi.fn(actualBuilder). The mock must
delegate to the real builder so the test exercises production topology data,
not a test double. Import preferOnscreenMatches from ../find.ts only after
the hoisted/partial mock is declared, following existing vi.mock(..., importOriginal) patterns in the repository. Import the mocked
builder export too so each case can clear and inspect the real wrapper's call
history; do not add a production counter or DI argument.
Build a duplicate-heavy but small deterministic tree:
one hittable Application root with a finite viewport rect;
at least 32 nonsemantic, nonhittable rectangular children that all qualify as
matches and therefore require descendant, ancestor, and overly-broad-root
checks in the old implementation; and
a distinct matches array so its legitimate on-screen .filter is not
confused with a full-tree scan.
Wrap only the full nodes array in a Proxy that counts calls to its filter
and map methods, then call preferOnscreenMatches(matches, observedNodes).
Assert:
the real topology builder was called exactly once for the multi-match
ranking pass;
the observed full tree had zero .filter calls after the one-pass builder;
the observed full tree had zero .map calls after the one-pass builder; and
the ranked result contains the same refs in the same stable order expected
from the current scoring/tie-break rules.
The test is intentionally about operation counts, not elapsed milliseconds.
Before Step 4, the behavior-preserving extracted ranking does not call the
builder and still performs per-candidate full-tree filter/map operations.
Run it now and record the exact failing counts. It must fail on the builder
count and/or scan-count assertions, never on a timeout.
Also add a one-candidate case that asserts the builder is not called: the
existing fast return for fewer than two matches must remain allocation-free.
Verify (red, before topology wiring): pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts
→ exits nonzero on the expected builder/full-tree operation-count assertions;
record the failing numbers in the PR or handoff.
Step 4: Thread one topology through every candidate score
Wire the production topology without changing existing required call shapes:
In find.ts, keep the on-screen preference in preferOnscreenMatches, then
return immediately when the preferred set has fewer than two nodes. For a
real multi-match ranking pass, call buildActionableTouchTopology(nodes)
exactly once and pass it to rankInteractiveMatches. This is the only
topology build site for the pass. Preserve the existing no-viewport early
return too: when nodes[0]?.rect is absent, return matches without ranking
or building an index, exactly as today.
In find-match-ranking.ts, accept that topology as a third argument and
pass the same object to every interactiveMatchScore and then every resolveActionableTouchResolution call. Permit undefined at this internal
seam so the unindexed behavior remains directly comparable and the focused
red proof can disable only the caller wiring; the final production caller
must always supply the topology for two or more preferred matches.
In interaction-targeting.ts, add an optional third ActionableTouchTopology argument to resolveActionableTouchResolution.
Keep resolveActionableTouchNode(nodes, node) and all existing two-argument
callers valid and behaviorally unchanged.
Update findPreferredActionableDescendant to read only topology.childrenByParentIndex.get(current.index) when topology is present.
Preserve its exact hittable, blocked, same-rect, unique-child, and cycle
rules. When topology is absent, retain the current scan path for ordinary
one-off callers rather than building a full topology unconditionally.
Extend findNearestHittableAncestor/findNearestAncestor in snapshot-processing.ts with an optional prebuilt nodesByIndex argument
(placed so existing call sites remain source-compatible). Use the supplied
map when present; otherwise build the map exactly as today. Pass topology.nodesByIndex from actionability resolution.
Thread the optional topology into isOverlyBroadAncestor and resolveRootViewportRect. When present, derive normalized viewport rects
from topology.viewportRootNodes; do not run nodes.filter(isViewportRootNode).
When absent, retain the current implementation.
Do not cache a topology outside the call. The tree is a fresh operational
observation, and an index that survives into another capture would be stale.
Do not change isViewportRootNode, viewport selection, root-container
classification, score values, area tie-breaking, original-index tie-breaking,
or covered-node handling.
Verify: pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts
→ all pass; multi-match ranking builds once and performs no instrumented
full-tree filter/map, while the one-match case builds zero times.
Step 5: Prove indexed and unindexed policy parity
Extend src/core/interaction-targeting.test.ts with one focused parity test.
Create a mixed tree containing:
a same-rect actionable descendant;
a semantic touch target;
a nonhittable leaf under a hittable ancestor;
a scrolling or viewport-sized overly broad ancestor;
a covered node; and
a node with no usable target.
Build one topology, resolve every relevant node once without the optional
argument and once with it, and deep-compare the complete { node, reason }
results. This is a behavior pin, not a second implementation of score rules.
Existing property/example tests remain untouched.
After the full change is green, mechanically prove the new ranking test is
load-bearing: temporarily disable only the production topology-build/pass-through
hunk in find.ts (pass undefined to the extracted ranking exactly as the
pre-wiring path did), rerun the new ranking test, and record the failure showing
the builder count changed from one to zero and the full-tree scan counts became
nonzero. Do not change find-match-ranking.ts or the topology builder for this
proof. Restore the find.ts hunk and rerun green. Do not use a timing assertion
as substitute evidence.
Verify: pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts
→ all pass after restoring the optimized production hunk.
Step 6: Tighten and run repository gates
Review the final diff for duplicated tree maps, unused exports, stale imports,
or ranking logic left behind in find.ts. Confirm the new topology module
answers only the concrete indexing question and is imported directly rather
than added to a package/internal barrel. Confirm no fallback, viewport, or
candidate-order behavior was opportunistically changed.
Run formatting, focused tests, fast static checks, the affected selector, and
the broad deterministic gate. If formatting touches unrelated files, do not
include that drift.
Verify:
pnpm format
pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts
pnpm check:quick
pnpm check:affected --run
pnpm check
→ every command exits 0. git diff --name-only lists only the in-scope
implementation/test files plus an authorized plan-status update.
Test plan
src/core/actionable-touch-topology.test.ts:
indexes non-contiguous node indexes;
groups children by parent in input order;
collects canonical viewport roots from emitted root vocabulary.
multi-match ranking calls the real topology builder exactly once;
single-match ranking calls it zero times;
the full snapshot array sees no per-candidate filter or map calls;
stable score/area/input-order ranking is unchanged.
src/core/interaction-targeting.test.ts:
indexed and unindexed actionability resolution produce identical nodes and reasons
across every current decision branch.
Existing find.test.ts remains the handler-level contract for ambiguous
candidates and explicit --first; run it but do not add to the oversized file.
Existing src/utils/__tests__/snapshot-processing.test.ts and src/daemon/__tests__/snapshot-processing.test.ts remain the
ancestor/index and nearest-hittable behavior pins and must pass with the
optional map signature.
Red-before-fix proof: after extraction and before wiring, run the new ranking
test and record its non-timing count failures. After green, revert only the
topology wiring hunk, reproduce red, restore, and rerun green.
Done criteria
ALL must hold:
The focused ranking regression was observed red without the production
topology wiring, and exact builder/operation counts are recorded in the
PR or handoff.
A multi-match ranking pass calls buildActionableTouchTopology exactly once.
A zero/one-match pass does not build the topology.
The duplicate-heavy regression observes zero full-tree .filter and .map calls during candidate scoring; no elapsed-time threshold is used.
Descendant lookup, ancestor lookup, and viewport-root candidate lookup
all consume the same per-pass topology when it is present.
Indexed and unindexed actionability resolutions are deeply equal for the
mixed policy fixture.
Existing ambiguous-candidate and --first handler tests pass unchanged.
No source/test files outside the Scope list are modified.
No package barrel, selector policy, public response, viewport fallback,
README, website, CLI-help, or skill behavior file is changed.
Docs/skills are intentionally unchanged because this is an internal
complexity improvement with no intended user-facing semantic change.
plans/README.md and every other plan file are unchanged by the executor.
STOP conditions
Stop and report back; do not improvise if:
Any in-scope current-state excerpt no longer matches after the drift check.
The behavior-preserving extraction changes ranked refs, candidate details,
ambiguity refusal, explicit --first/--last, or action target selection.
A deterministic red proof cannot intercept the real topology builder through
the production preferOnscreenMatches wrapper without adding test-only DI.
Do not replace it with a wall-clock performance assertion or expose the
helper through a package/public barrel.
Eliminating the full scans appears to require changing selector resolution,
locator scoring, semantic touch roles, occlusion, or viewport fallback policy.
The optimized root lookup would add the Android largest-containing-rect
fallback or otherwise differ from the current resolveRootViewportRect result.
The topology would need to persist on SessionState, cross a capture boundary,
or mutate snapshot nodes. It must be per ranking pass and read-only.
Preserving findSnapshotAncestor's non-contiguous-index fallback or cycle
behavior requires a packages/contracts change.
A new generic abstraction or package export has only this one concrete use.
A verification command fails twice after one reasonable, in-scope correction.
Completion appears to require any source/test file outside Scope.
Maintenance notes
Build the topology from the exact fresh node array being ranked and discard it
with that pass. Snapshot freshness is an observable contract; never reuse an
index across captures merely because the session name is unchanged.
Future batch consumers of resolveActionableTouchResolution should build one
topology and pass it to every resolution. One-off interaction callers may
continue omitting it to avoid an unnecessary whole-tree allocation.
viewportRootNodes must remain derived from canonical isViewportRootNode;
new backend root vocabulary should update the canonical predicate, not add a
ranking-only spelling.
Reviewers should scrutinize the operation-count test, the no-build fast path,
and indexed/unindexed parity. A builder call that is ignored by one of the
three old scan sites is not a complete fix.
Sorting remains O(m log m), and actionability still walks relevant parent or
unique-child chains. This plan removes repeated full-tree scans; it does not
introduce memoization for adversarial deeply nested same-rect chains. Treat
that as a separate measured finding rather than expanding this scope.
No docs or skills update is expected because public find semantics and output
remain unchanged.
Plan 007: Index snapshot topology once when ranking find matches
Status
13bc70f24, 2026-08-08Why this matters
A mutating
find ... click|fill|focus|typeranks every candidate before itnarrows with
--first/--lastor returnsAMBIGUOUS_MATCH. Each candidatecurrently triggers multiple full-snapshot traversals: descendant discovery
filters all nodes, ancestor lookup rebuilds the index map, and viewport-root
resolution filters all nodes again. With
mmatches in ann-node capture,those paths perform O(m × n) full-tree work before the action or refusal. This
plan builds one immutable topology index per multi-match ranking pass and
threads it through the existing actionability policy without changing match
ordering, candidate details, ambiguity behavior, or single-target callers.
Current state
Relevant files and their roles:
src/daemon/handlers/find.ts— mutating-find orchestration and currenton-screen preference. It is already 624 lines, so scoring must be extracted
into a focused sibling; this file retains the small per-ranking-pass wrapper
that builds and passes one topology.
src/daemon/handlers/find-match-ranking.ts— new focused module to ownactionability scoring, stable tie-breaking, and root interaction-container
classification.
src/core/actionable-touch-topology.ts— new concrete one-pass topologybuilder shared by ranking and actionability resolution. It is an internal
source module, not a package/public API or a generic adapter abstraction.
src/core/interaction-targeting.ts— canonical touch-target promotionpolicy. It must accept an optional prebuilt topology while preserving the
current no-topology call shape for ordinary one-target interactions.
src/snapshot/snapshot-processing.ts— canonical nearest-ancestor helper;it currently builds its node map internally on every call.
src/daemon/handlers/__tests__/find.test.ts— existing end-to-end handlertests for candidate ordering, ambiguity, and
--first. It is 1,232 lines;run it, but do not add more cases to it.
Ranking occurs before ambiguity rejection or explicit positional narrowing:
Every candidate is scored independently:
There are three distinct per-candidate full-tree paths. All three must use the
same prebuilt topology; optimizing only the first two leaves the Android/
nonsemantic ancestor path quadratic.
Ancestor lookup constructs another full map per candidate:
Repository patterns and constraints to preserve:
src/daemon/snapshot-presentation/ios/web.ts:93-101: aMapkeyed byparentIndex, append each child once, and do not filter the whole tree per parent.node.index, not array position.packages/contracts/src/snapshot-tree.ts:3-31suppliesbuildSnapshotNodeMap/findSnapshotAncestorand preserves an array-offsetfallback plus cycle protection. Do not weaken that behavior.
isViewportRootNodepredicate. This plan only precomputes its candidates. It must not change the
current
resolveRootViewportRectselection algorithm or add the separatelyknown Android largest-containing-rect fallback.
src/daemon/handlers/__tests__/find.test.ts:667-722pins the public contract:ambiguous selector-shaped finds refuse with candidates, while explicit
--firstselects the first element in the established ranked order.docs/agents/testing.md:68-75requires a regression test to be observed redagainst the pre-fix production path. For performance work, use operation
counts and a real production builder spy; do not use a wall-clock threshold.
preferOnscreenMatchesremains the realproduction wrapper called by
resolveFindMatch; exporting that wrapper givesthe focused regression a direct policy seam without adding injectable state.
Commands you will need
pnpm depgraph affected src/daemon/handlers/find.ts --limit 25pnpm depgraph affected src/core/interaction-targeting.ts --limit 25pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.tspnpm check:quickpnpm formatpnpm check:affected --runpnpm checkThe repository requires pnpm 11.17.0 and Node >=22.12. Do not create or
restore
package-lock.json. No native/Apple build is required because allin-scope changes are TypeScript; follow
pnpm check:affected --runif theactual diff selects an additional authoritative lane.
Scope
In scope (the only implementation/test files to modify or create):
src/daemon/handlers/find.tssrc/daemon/handlers/find-match-ranking.ts(create)src/daemon/handlers/__tests__/find-match-ranking.test.ts(create)src/core/actionable-touch-topology.ts(create)src/core/actionable-touch-topology.test.ts(create)src/core/interaction-targeting.tssrc/core/interaction-targeting.test.tssrc/snapshot/snapshot-processing.tsOut of scope (do NOT touch, even though they are related):
src/daemon/handlers/__tests__/find.test.ts— this legacy 1,232-line fileremains authoritative handler coverage but must not grow in this plan.
findActpolicy,buildAmbiguousMatchError, candidate payload rendering,--first/--lastsemantics, and public response shapes.
packages/contracts/src/snapshot-visibility.tsand Android viewport fallbackbehavior. The known resolver divergence is not part of this performance fix.
ref-frame admission, replay guards, capture freshness, or snapshot caching.
do not add it to the npm package surface.
actionability topology with real consumers in find ranking and touch resolution.
or command surface is intended to change.
plans/README.mdand every other plan file.Git workflow
advisor/007-index-find-ranking-topologyimplementation if the operator wants reviewable commits.
refactor(find): isolate match ranking policyperf(find): index snapshot topology during match rankingor open a PR unless the operator explicitly instructs it.
Steps
Step 1: Extract ranking without changing behavior
Create
src/daemon/handlers/find-match-ranking.tsand move the coherent rankingquestion out of the already oversized handler:
rankInteractiveMatchesinteractiveMatchScoreresolvedTouchScorerectAreaisRootInteractionContainerrectsMatchExport
rankInteractiveMatchesandisRootInteractionContainerbecausefind.tsis their real production caller. Keep the other moved helpers private.Retain
preferOnscreenMatchesinfind.ts, export it as the focused productionwrapper, and have it call the extracted
rankInteractiveMatches. Remove importsused only by the moved code. Do not move locator matching, ambiguity-response
construction, node fetching, or action dispatch. At the end of this step, the
extracted ranking must still call
resolveActionableTouchResolution(nodes, node)with no topology, so this is a pure move that establishes a testable seam before
optimization.
Run the existing handler and targeting tests before proceeding. Candidate order,
candidate rendering,
AMBIGUOUS_MATCH, and explicit--firstmust be identical.Verify:
pnpm exec vitest run src/daemon/handlers/__tests__/find.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts→ all pass with the behavior-preserving extraction only.
Step 2: Add the concrete one-pass topology builder
Create
src/core/actionable-touch-topology.tswith a narrow production type andbuilder. Use durable domain names such as:
Construct all three collections in one
for...ofpass:node.index;parentIndexto that parent's children array; andisViewportRootNodetoviewportRootNodes.The topology is immutable by convention for the duration of one ranking pass;
do not mutate snapshot nodes or cache it on a session. Do not use
nodes.filteror
nodes.mapinside this builder — the regression test will instrument thoseoperations to prove the full tree is not rescanned per candidate. No package
barrel export is needed.
Create
src/core/actionable-touch-topology.test.ts. UsingmakeSnapshotStatefrom the shared test-utils barrel, assert one mixed tree produces the expected:
type,role, orsubrolethrough the canonical predicate.Keep this test structural to the builder's real production output; do not add a
test-only counter or injectable builder.
Verify:
pnpm exec vitest run src/core/actionable-touch-topology.test.ts→ the new builder tests pass.
Step 3: Add a deterministic red proof for one build and zero full rescans
Create
src/daemon/handlers/__tests__/find-match-ranking.test.ts. Test theproduction export
preferOnscreenMatches; do not reach a private helper or addtest-only DI.
Use Vitest's module mocking to wrap the real
buildActionableTouchTopologyexport withvi.fn(actualBuilder). The mock mustdelegate to the real builder so the test exercises production topology data,
not a test double. Import
preferOnscreenMatchesfrom../find.tsonly afterthe hoisted/partial mock is declared, following existing
vi.mock(..., importOriginal)patterns in the repository. Import the mockedbuilder export too so each case can clear and inspect the real wrapper's call
history; do not add a production counter or DI argument.
Build a duplicate-heavy but small deterministic tree:
Applicationroot with a finite viewport rect;matches and therefore require descendant, ancestor, and overly-broad-root
checks in the old implementation; and
matchesarray so its legitimate on-screen.filteris notconfused with a full-tree scan.
Wrap only the full
nodesarray in aProxythat counts calls to itsfilterand
mapmethods, then callpreferOnscreenMatches(matches, observedNodes).Assert:
ranking pass;
.filtercalls after the one-pass builder;.mapcalls after the one-pass builder; andfrom the current scoring/tie-break rules.
The test is intentionally about operation counts, not elapsed milliseconds.
Before Step 4, the behavior-preserving extracted ranking does not call the
builder and still performs per-candidate full-tree
filter/mapoperations.Run it now and record the exact failing counts. It must fail on the builder
count and/or scan-count assertions, never on a timeout.
Also add a one-candidate case that asserts the builder is not called: the
existing fast return for fewer than two matches must remain allocation-free.
Verify (red, before topology wiring):
pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts→ exits nonzero on the expected builder/full-tree operation-count assertions;
record the failing numbers in the PR or handoff.
Step 4: Thread one topology through every candidate score
Wire the production topology without changing existing required call shapes:
find.ts, keep the on-screen preference inpreferOnscreenMatches, thenreturn immediately when the preferred set has fewer than two nodes. For a
real multi-match ranking pass, call
buildActionableTouchTopology(nodes)exactly once and pass it to
rankInteractiveMatches. This is the onlytopology build site for the pass. Preserve the existing no-viewport early
return too: when
nodes[0]?.rectis absent, returnmatcheswithout rankingor building an index, exactly as today.
find-match-ranking.ts, accept that topology as a third argument andpass the same object to every
interactiveMatchScoreand then everyresolveActionableTouchResolutioncall. Permitundefinedat this internalseam so the unindexed behavior remains directly comparable and the focused
red proof can disable only the caller wiring; the final production caller
must always supply the topology for two or more preferred matches.
interaction-targeting.ts, add an optional thirdActionableTouchTopologyargument toresolveActionableTouchResolution.Keep
resolveActionableTouchNode(nodes, node)and all existing two-argumentcallers valid and behaviorally unchanged.
findPreferredActionableDescendantto read onlytopology.childrenByParentIndex.get(current.index)when topology is present.Preserve its exact hittable, blocked, same-rect, unique-child, and cycle
rules. When topology is absent, retain the current scan path for ordinary
one-off callers rather than building a full topology unconditionally.
findNearestHittableAncestor/findNearestAncestorinsnapshot-processing.tswith an optional prebuiltnodesByIndexargument(placed so existing call sites remain source-compatible). Use the supplied
map when present; otherwise build the map exactly as today. Pass
topology.nodesByIndexfrom actionability resolution.isOverlyBroadAncestorandresolveRootViewportRect. When present, derive normalized viewport rectsfrom
topology.viewportRootNodes; do not runnodes.filter(isViewportRootNode).When absent, retain the current implementation.
Do not cache a topology outside the call. The tree is a fresh operational
observation, and an index that survives into another capture would be stale.
Do not change
isViewportRootNode, viewport selection, root-containerclassification, score values, area tie-breaking, original-index tie-breaking,
or covered-node handling.
Verify:
pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts→ all pass; multi-match ranking builds once and performs no instrumented
full-tree
filter/map, while the one-match case builds zero times.Step 5: Prove indexed and unindexed policy parity
Extend
src/core/interaction-targeting.test.tswith one focused parity test.Create a mixed tree containing:
Build one topology, resolve every relevant node once without the optional
argument and once with it, and deep-compare the complete
{ node, reason }results. This is a behavior pin, not a second implementation of score rules.
Existing property/example tests remain untouched.
After the full change is green, mechanically prove the new ranking test is
load-bearing: temporarily disable only the production topology-build/pass-through
hunk in
find.ts(passundefinedto the extracted ranking exactly as thepre-wiring path did), rerun the new ranking test, and record the failure showing
the builder count changed from one to zero and the full-tree scan counts became
nonzero. Do not change
find-match-ranking.tsor the topology builder for thisproof. Restore the
find.tshunk and rerun green. Do not use a timing assertionas substitute evidence.
Verify:
pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts→ all pass after restoring the optimized production hunk.
Step 6: Tighten and run repository gates
Review the final diff for duplicated tree maps, unused exports, stale imports,
or ranking logic left behind in
find.ts. Confirm the new topology moduleanswers only the concrete indexing question and is imported directly rather
than added to a package/internal barrel. Confirm no fallback, viewport, or
candidate-order behavior was opportunistically changed.
Run formatting, focused tests, fast static checks, the affected selector, and
the broad deterministic gate. If formatting touches unrelated files, do not
include that drift.
Verify:
pnpm format pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.ts pnpm check:quick pnpm check:affected --run pnpm check→ every command exits 0.
git diff --name-onlylists only the in-scopeimplementation/test files plus an authorized plan-status update.
Test plan
src/core/actionable-touch-topology.test.ts:src/daemon/handlers/__tests__/find-match-ranking.test.ts:filterormapcalls;src/core/interaction-targeting.test.ts:across every current decision branch.
find.test.tsremains the handler-level contract for ambiguouscandidates and explicit
--first; run it but do not add to the oversized file.src/utils/__tests__/snapshot-processing.test.tsandsrc/daemon/__tests__/snapshot-processing.test.tsremain theancestor/index and nearest-hittable behavior pins and must pass with the
optional map signature.
test and record its non-timing count failures. After green, revert only the
topology wiring hunk, reproduce red, restore, and rerun green.
Done criteria
ALL must hold:
topology wiring, and exact builder/operation counts are recorded in the
PR or handoff.
buildActionableTouchTopologyexactly once..filterand.mapcalls during candidate scoring; no elapsed-time threshold is used.all consume the same per-pass topology when it is present.
mixed policy fixture.
--firsthandler tests pass unchanged.pnpm exec vitest run src/daemon/handlers/__tests__/find-match-ranking.test.ts src/daemon/handlers/__tests__/find.test.ts src/core/actionable-touch-topology.test.ts src/core/interaction-targeting.test.ts src/utils/__tests__/snapshot-processing.test.ts src/daemon/__tests__/snapshot-processing.test.tsexits 0.
pnpm check:quickexits 0.pnpm check:affected --runexits 0.pnpm checkexits 0.README, website, CLI-help, or skill behavior file is changed.
complexity improvement with no intended user-facing semantic change.
plans/README.mdand every other plan file are unchanged by the executor.STOP conditions
Stop and report back; do not improvise if:
ambiguity refusal, explicit
--first/--last, or action target selection.the production
preferOnscreenMatcheswrapper without adding test-only DI.Do not replace it with a wall-clock performance assertion or expose the
helper through a package/public barrel.
locator scoring, semantic touch roles, occlusion, or viewport fallback policy.
fallback or otherwise differ from the current
resolveRootViewportRectresult.SessionState, cross a capture boundary,or mutate snapshot nodes. It must be per ranking pass and read-only.
findSnapshotAncestor's non-contiguous-index fallback or cyclebehavior requires a packages/contracts change.
Maintenance notes
with that pass. Snapshot freshness is an observable contract; never reuse an
index across captures merely because the session name is unchanged.
resolveActionableTouchResolutionshould build onetopology and pass it to every resolution. One-off interaction callers may
continue omitting it to avoid an unnecessary whole-tree allocation.
viewportRootNodesmust remain derived from canonicalisViewportRootNode;new backend root vocabulary should update the canonical predicate, not add a
ranking-only spelling.
and indexed/unindexed parity. A builder call that is ignored by one of the
three old scan sites is not a complete fix.
unique-child chains. This plan removes repeated full-tree scans; it does not
introduce memoization for adversarial deeply nested same-rect chains. Treat
that as a separate measured finding rather than expanding this scope.
remain unchanged.