From 4d527129b3605287ae0e64f6f127760b688de493 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 02:08:16 +0530 Subject: [PATCH] feat(match): most-specific-subtree resolution with ancestor context (A6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When matches nest — a Page renders a Section renders a Card — resolve to the smallest subtree that covers the query (TRACKER step 4.3): - Coverage is now subtree-aware: a component covers a term if the term appears in its own text or any descendant's (via renders → instance-of edges). - A full-page term set (title + section + card term) ranks the page top, since only its subtree covers all terms. - A card-specific term set collapses the equal-coverage ancestors into the match's new `context` field and returns the deepest node (the card) — ancestors are context, not competing candidates. Non-nested matching is unchanged (subtree = self), so every existing fixture stays green. ComponentMatch gains `context`; eval GoldenQuery gains `context`. Fixture a6-composed-page. 36 core + 100 parser tests pass; eval green. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../a6-composed-page/app/DashboardPage.tsx | 12 ++ .../a6-composed-page/app/RevenueCard.tsx | 8 ++ .../a6-composed-page/app/RevenueSection.tsx | 10 ++ .../a6-composed-page/app/SettingsSection.tsx | 7 ++ eval/fixtures/a6-composed-page/golden.json | 25 ++++ eval/src/checks.ts | 5 + eval/src/golden.ts | 2 + packages/core/src/query.ts | 107 +++++++++++++++--- packages/parser-react/src/structure.test.ts | 32 ++++++ 10 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 eval/fixtures/a6-composed-page/app/DashboardPage.tsx create mode 100644 eval/fixtures/a6-composed-page/app/RevenueCard.tsx create mode 100644 eval/fixtures/a6-composed-page/app/RevenueSection.tsx create mode 100644 eval/fixtures/a6-composed-page/app/SettingsSection.tsx create mode 100644 eval/fixtures/a6-composed-page/golden.json diff --git a/TRACKER.md b/TRACKER.md index 2da687a..749edfe 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 4 — Matching engine -- **Next step:** 4.3 — Most-specific-subtree resolution -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1–4.2 +- **Next step:** 4.4 — Screenshot adapter (@coderadar/vision) +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1–4.3 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) ## What CodeRadar is @@ -231,7 +231,7 @@ The heart of the project. C1 and B1 live here. **Build:** structural signature per instance subtree (child element kinds/counts: table with N columns, form with M inputs, card grid). Query side accepts a structure descriptor (from vision output: "a table with columns Name, Email, Actions") and scores against signatures. Text and structure scores combine into one ranking. **Accept:** fixture `a1-no-static-text` (dashboard, zero literals) top-3 correct via structure alone. -### [ ] 4.3 Most-specific-subtree resolution +### [x] 4.3 Most-specific-subtree resolution **Failure modes:** A6 **Build:** when matches nest (Page > Section > Card all match), return the deepest instance covering the matched term/structure set; ancestors listed as `context`, not competing candidates. **Accept:** fixture `a6-composed-page` green: full-page term set → page node; card-specific terms → card instance with page as context. diff --git a/eval/fixtures/a6-composed-page/app/DashboardPage.tsx b/eval/fixtures/a6-composed-page/app/DashboardPage.tsx new file mode 100644 index 0000000..2a73494 --- /dev/null +++ b/eval/fixtures/a6-composed-page/app/DashboardPage.tsx @@ -0,0 +1,12 @@ +import { RevenueSection } from "./RevenueSection"; +import { SettingsSection } from "./SettingsSection"; + +export function DashboardPage() { + return ( +
+

Analytics Dashboard

+ + +
+ ); +} diff --git a/eval/fixtures/a6-composed-page/app/RevenueCard.tsx b/eval/fixtures/a6-composed-page/app/RevenueCard.tsx new file mode 100644 index 0000000..38a6acb --- /dev/null +++ b/eval/fixtures/a6-composed-page/app/RevenueCard.tsx @@ -0,0 +1,8 @@ +export function RevenueCard() { + return ( +
+

Total revenue

+

vs last month

+
+ ); +} diff --git a/eval/fixtures/a6-composed-page/app/RevenueSection.tsx b/eval/fixtures/a6-composed-page/app/RevenueSection.tsx new file mode 100644 index 0000000..806b6a0 --- /dev/null +++ b/eval/fixtures/a6-composed-page/app/RevenueSection.tsx @@ -0,0 +1,10 @@ +import { RevenueCard } from "./RevenueCard"; + +export function RevenueSection() { + return ( +
+

Revenue overview

+ +
+ ); +} diff --git a/eval/fixtures/a6-composed-page/app/SettingsSection.tsx b/eval/fixtures/a6-composed-page/app/SettingsSection.tsx new file mode 100644 index 0000000..c9ef24d --- /dev/null +++ b/eval/fixtures/a6-composed-page/app/SettingsSection.tsx @@ -0,0 +1,7 @@ +export function SettingsSection() { + return ( +
+

Workspace settings

+
+ ); +} diff --git a/eval/fixtures/a6-composed-page/golden.json b/eval/fixtures/a6-composed-page/golden.json new file mode 100644 index 0000000..0aece58 --- /dev/null +++ b/eval/fixtures/a6-composed-page/golden.json @@ -0,0 +1,25 @@ +{ + "failureMode": "A6", + "note": "Composed page: DashboardPage renders RevenueSection (→ RevenueCard) and SettingsSection, so several nested components 'cover' a query via their subtree. A full-page term set resolves to the page (smallest subtree covering all terms); a card-specific term set resolves to the deepest node (RevenueCard) with its ancestors listed as context, not competing candidates.", + "expect": { + "components": [ + { "name": "DashboardPage", "instances": 0 }, + { "name": "RevenueSection", "instances": 1 }, + { "name": "RevenueCard", "instances": 1 }, + { "name": "SettingsSection", "instances": 1 } + ], + "queries": [ + { + "terms": ["Analytics Dashboard", "Total revenue", "Workspace settings"], + "status": "ok", + "top": "DashboardPage" + }, + { + "terms": ["Total revenue", "vs last month"], + "status": "ok", + "top": "RevenueCard", + "context": ["RevenueSection", "DashboardPage"] + } + ] + } +} diff --git a/eval/src/checks.ts b/eval/src/checks.ts index a003d81..ca77cfc 100644 --- a/eval/src/checks.ts +++ b/eval/src/checks.ts @@ -232,6 +232,11 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph): k > 1 ? `expected ${query.top} in top ${k}, got [${topNames.join(", ")}]` : `expected top ${query.top}, got ${topNames[0] ?? "none"}`; + } else if (query.context !== undefined) { + const ctx = (result.candidates[0]?.value.context ?? []).map((c) => c.name); + const missing = query.context.filter((c) => !ctx.includes(c)); + passed = missing.length === 0; + if (!passed) detail = `expected context [${query.context.join(", ")}], got [${ctx.join(", ")}]`; } } finalize("queries", id, passed, query.expectedFail, detail); diff --git a/eval/src/golden.ts b/eval/src/golden.ts index cefd07d..808fc5b 100644 --- a/eval/src/golden.ts +++ b/eval/src/golden.ts @@ -40,6 +40,8 @@ export interface GoldenQuery { status: "ok" | "ambiguous" | "declined"; /** Required top-1 component name when status is "ok". */ top?: string; + /** Ancestor names the top match must list as `context` (step 4.3). */ + context?: string[]; /** * When set, `top` need only appear within the first `topK` candidates rather * than at rank 1 — the honest bar for OCR-noisy input (failure mode A10). diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 87f2718..076606e 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -38,6 +38,13 @@ export interface ComponentMatch { /** Call sites of this component, when known. */ instances: InstanceNode[]; matchedText: string[]; + /** + * Ancestor components that also cover the matched terms but are less specific + * than `component` (TRACKER step 4.3). Present when the match resolved to the + * deepest node in a Page > Section > Card nesting — the ancestors are context, + * not competing candidates. + */ + context?: ComponentNode[]; } /** @@ -126,36 +133,81 @@ export function matchComponents( const termWeight = (term: string): number => tokenize(term).reduce((sum, t) => sum + idf(t), 0); - const scored: Array<{ + // Render tree (definition level): A renders B when A has a `renders` edge to + // an instance whose `instance-of` points at B. Used to collapse nested + // matches to the most specific one (step 4.3). + const byId = new Map(components.map((c) => [c.id, c])); + const instanceDef = new Map(); + for (const e of graph.edges) if (e.kind === "instance-of") instanceDef.set(e.from, e.to); + const childDefs = new Map>(); + for (const e of graph.edges) { + if (e.kind !== "renders") continue; + const childDef = instanceDef.get(e.to); + if (childDef === undefined || !byId.has(e.from) || !byId.has(childDef)) continue; + (childDefs.get(e.from) ?? childDefs.set(e.from, new Set()).get(e.from)!).add(childDef); + } + const descendantsMemo = new Map>(); + const descendants = (id: string): Set => { + const cached = descendantsMemo.get(id); + if (cached) return cached; + const out = new Set(); + descendantsMemo.set(id, out); // set first to break cycles + for (const child of childDefs.get(id) ?? []) { + if (out.has(child)) continue; + out.add(child); + for (const d of descendants(child)) out.add(d); + } + return out; + }; + + interface Scored { match: ComponentMatch; score: number; coverage: number; + covered: Set; evidence: Evidence[]; - }> = []; + } + const scored: Scored[] = []; for (const component of components) { + const subtree = [component.id, ...descendants(component.id)]; const matched: string[] = []; + const covered = new Set(); const evidence: Evidence[] = []; let weight = 0; for (const term of queryTerms) { - const hit = matchingEntry(term, component); + let hit: RenderedText | null = null; + let where = component; + for (const id of subtree) { + const node = byId.get(id); + if (node === undefined) continue; + const found = matchingEntry(term, node); + if (found !== null) { + hit = found; + where = node; + break; + } + } if (hit === null) continue; const w = termWeight(term); - matched.push(term); + covered.add(term); + if (where.id === component.id) matched.push(term); weight += w; const provenance = hit.source === "i18n" ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` : hit.branch !== undefined ? ` (renders only when ${hit.branch})` - : ""; + : where.id !== component.id + ? ` (in descendant ${where.name})` + : ""; evidence.push({ kind: "text-match", detail: `"${term}" matched rendered text "${hit.text}"${provenance} — rarity weight ${w.toFixed(2)}`, - loc: component.loc, + loc: where.loc, }); } - const textScore = matched.length > 0 ? weight * (1 + 0.5 * (matched.length - 1)) : 0; + const textScore = covered.size > 0 ? weight * (1 + 0.5 * (covered.size - 1)) : 0; const structureFit = descriptor !== undefined ? structureScore(component.structure, descriptor) : 0; if (structureFit > 0) { @@ -166,34 +218,61 @@ export function matchComponents( }); } - if (matched.length === 0 && structureFit === 0) continue; - const coverage = queryTerms.length > 0 ? matched.length / queryTerms.length : structureFit; + if (covered.size === 0 && structureFit === 0) continue; + const coverage = queryTerms.length > 0 ? covered.size / queryTerms.length : structureFit; scored.push({ match: { component, instances: instancesByDefinition.get(component.id) ?? [], - matchedText: matched, + matchedText: matched.length > 0 ? matched : [...covered], }, score: textScore + structureFit * STRUCTURE_WEIGHT, coverage: Math.max(coverage, structureFit), + covered, evidence, }); } if (scored.length === 0) return declined("no-signal"); + const sameCovered = (a: Set, b: Set): boolean => + a.size === b.size && [...a].every((t) => b.has(t)); + + // Most-specific-subtree collapse: when an ancestor and a descendant cover the + // same term set (equal score), keep the deepest and fold ancestors into its + // `context`, so a Card that a Page renders wins with the Page as context. + const subtreeSize = (s: Scored): number => descendants(s.match.component.id).size; scored.sort( - (a, b) => b.score - a.score || a.match.component.name.localeCompare(b.match.component.name), + (a, b) => + b.score - a.score || + subtreeSize(a) - subtreeSize(b) || + a.match.component.name.localeCompare(b.match.component.name), ); - const candidates: Candidate[] = scored.map((s) => ({ + const collapsed = new Set(); + for (const s of scored) { + if (collapsed.has(s.match.component.id)) continue; + const context: ComponentNode[] = []; + for (const other of scored) { + if (other === s || collapsed.has(other.match.component.id)) continue; + const otherOwnsS = descendants(other.match.component.id).has(s.match.component.id); + if (otherOwnsS && sameCovered(other.covered, s.covered)) { + context.push(other.match.component); + collapsed.add(other.match.component.id); + } + } + if (context.length > 0) s.match.context = context; + } + const winners = scored.filter((s) => !collapsed.has(s.match.component.id)); + + const candidates: Candidate[] = winners.map((s) => ({ value: s.match, confidence: confidenceFromScore(s.coverage), evidence: s.evidence, })); - const top = scored[0]; + const top = winners[0]; const tied = - top === undefined ? [] : scored.filter((s) => Math.abs(s.score - top.score) < 1e-9); + top === undefined ? [] : winners.filter((s) => Math.abs(s.score - top.score) < 1e-9); if (tied.length > 1) { const names = tied.map((s) => s.match.component.name).join(", "); return ambiguous( diff --git a/packages/parser-react/src/structure.test.ts b/packages/parser-react/src/structure.test.ts index f3d8129..d7f5b77 100644 --- a/packages/parser-react/src/structure.test.ts +++ b/packages/parser-react/src/structure.test.ts @@ -47,3 +47,35 @@ describe("structural signatures (a1 fixture, TRACKER 4.2)", () => { expect(result.candidates[0]?.value.component.name).toBe("LoginForm"); }); }); + +const a6 = resolveHookEdges( + scanReact({ + root: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures/a6-composed-page/app", + ), + }), +); + +describe("most-specific-subtree resolution (a6 fixture, TRACKER 4.3)", () => { + it("a full-page term set resolves to the page (smallest subtree covering all)", () => { + const result = matchComponents(a6, { + terms: ["Analytics Dashboard", "Total revenue", "Workspace settings"], + }); + expect(result.candidates[0]?.value.component.name).toBe("DashboardPage"); + }); + + it("card-specific terms resolve to the deepest node with ancestors as context", () => { + const result = matchComponents(a6, { terms: ["Total revenue", "vs last month"] }); + const top = result.candidates[0]?.value; + expect(top?.component.name).toBe("RevenueCard"); + expect((top?.context ?? []).map((c) => c.name)).toEqual(["RevenueSection", "DashboardPage"]); + }); + + it("collapses ancestors instead of returning them as competing candidates", () => { + const result = matchComponents(a6, { terms: ["Total revenue", "vs last month"] }); + const names = result.candidates.map((c) => c.value.component.name); + expect(names).not.toContain("DashboardPage"); + expect(names).not.toContain("RevenueSection"); + }); +});