Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 4 — Matching engine
- **Next step:** 4.3Most-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.4Screenshot 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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions eval/fixtures/a6-composed-page/app/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { RevenueSection } from "./RevenueSection";
import { SettingsSection } from "./SettingsSection";

export function DashboardPage() {
return (
<main>
<h1>Analytics Dashboard</h1>
<RevenueSection />
<SettingsSection />
</main>
);
}
8 changes: 8 additions & 0 deletions eval/fixtures/a6-composed-page/app/RevenueCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function RevenueCard() {
return (
<div className="card">
<h3>Total revenue</h3>
<p>vs last month</p>
</div>
);
}
10 changes: 10 additions & 0 deletions eval/fixtures/a6-composed-page/app/RevenueSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RevenueCard } from "./RevenueCard";

export function RevenueSection() {
return (
<section>
<h2>Revenue overview</h2>
<RevenueCard />
</section>
);
}
7 changes: 7 additions & 0 deletions eval/fixtures/a6-composed-page/app/SettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function SettingsSection() {
return (
<section>
<h2>Workspace settings</h2>
</section>
);
}
25 changes: 25 additions & 0 deletions eval/fixtures/a6-composed-page/golden.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
}
5 changes: 5 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
107 changes: 93 additions & 14 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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<string, string>();
for (const e of graph.edges) if (e.kind === "instance-of") instanceDef.set(e.from, e.to);
const childDefs = new Map<string, Set<string>>();
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<string, Set<string>>();
const descendants = (id: string): Set<string> => {
const cached = descendantsMemo.get(id);
if (cached) return cached;
const out = new Set<string>();
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<string>;
evidence: Evidence[];
}> = [];
}
const scored: Scored[] = [];

for (const component of components) {
const subtree = [component.id, ...descendants(component.id)];
const matched: string[] = [];
const covered = new Set<string>();
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) {
Expand All @@ -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<string>, b: Set<string>): 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<ComponentMatch>[] = scored.map((s) => ({
const collapsed = new Set<string>();
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<ComponentMatch>[] = 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(
Expand Down
32 changes: 32 additions & 0 deletions packages/parser-react/src/structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading