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.2Structural matching
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1
- **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
- **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 @@ -226,7 +226,7 @@ The heart of the project. C1 and B1 live here.
- Combination bonus: co-occurrence of multiple terms in one instance subtree outweighs scattered singles.
**Accept:** `a4-generic-text` green ("Save" alone → `ambiguous`; "Save" + "invoice details" → correct top-1); noisy-term fixture `a10-ocr-noise` (misspelled terms) top-3 correct.

### [ ] 4.2 Structural matching
### [x] 4.2 Structural matching
**Failure modes:** A1, A3, A12
**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.
Expand Down
14 changes: 14 additions & 0 deletions eval/fixtures/a1-no-static-text/app/ImageGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface Photo {
id: string;
src: string;
}

export function ImageGallery({ photos }: { photos: Photo[] }) {
return (
<div>
{photos.map((p) => (
<img key={p.id} src={p.src} />
))}
</div>
);
}
9 changes: 9 additions & 0 deletions eval/fixtures/a1-no-static-text/app/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function LoginForm({ onSubmit }: { onSubmit: () => void }) {
return (
<form onSubmit={onSubmit}>
<input type="email" />
<input type="password" />
<button type="submit" />
</form>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/a1-no-static-text/app/SettingsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface Option {
id: string;
name: string;
}

export function SettingsList({ options }: { options: Option[] }) {
return (
<ul>
{options.map((o) => (
<li key={o.id}>{o.name}</li>
))}
</ul>
);
}
42 changes: 42 additions & 0 deletions eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
interface Stat {
id: string;
label: string;
value: number;
}
interface Row {
id: string;
}

// A dashboard with zero static text — a card grid over a 4-column table. It can
// only be matched by structure (Phase 4.2).
export function StatsDashboard({ stats, rows }: { stats: Stat[]; rows: Row[] }) {
return (
<div>
<div>
{stats.map((s) => (
<article key={s.id}>
<span>{s.label}</span>
<strong>{s.value}</strong>
</article>
))}
</div>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/a1-no-static-text/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"failureMode": "A1",
"note": "No static text: a dashboard, form, gallery and list carry zero literals, so text matching finds nothing. Structural signatures (table with N columns, form with M inputs, card/image grid) let a vision-style structure descriptor still rank the right component top-1.",
"expect": {
"components": [
{ "name": "StatsDashboard", "instances": 0 },
{ "name": "LoginForm", "instances": 0 },
{ "name": "ImageGallery", "instances": 0 },
{ "name": "SettingsList", "instances": 0 }
],
"queries": [
{ "terms": [], "structure": { "table": true, "columns": 4, "cards": 2 }, "status": "ok", "top": "StatsDashboard", "topK": 3 },
{ "terms": [], "structure": { "form": true, "inputs": 2, "buttons": 1 }, "status": "ok", "top": "LoginForm", "topK": 3 },
{ "terms": [], "structure": { "table": true }, "status": "ok", "top": "StatsDashboard" }
]
}
}
6 changes: 3 additions & 3 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import {
journeys,
type LineageGraph,
matchComponentsByText,
matchComponents,
traceLineage,
} from "@coderadar/core";

Expand Down Expand Up @@ -217,8 +217,8 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
}

for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+")}`;
const result = matchComponentsByText(graph, query.terms);
const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
const result = matchComponents(graph, { terms: query.terms, structure: query.structure });
let passed = result.status === query.status;
let detail: string | undefined;
if (!passed) {
Expand Down
2 changes: 2 additions & 0 deletions eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface GoldenForbidden {

export interface GoldenQuery {
terms: string[];
/** Optional structural descriptor (Phase 4.2), e.g. { table: true, columns: 4 }. */
structure?: import("@coderadar/core").StructureDescriptor;
status: "ok" | "ambiguous" | "declined";
/** Required top-1 component name when status is "ok". */
top?: string;
Expand Down
68 changes: 62 additions & 6 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type {
RouteNode,
SourceLocation,
StateNode,
StructuralSignature,
StructureDescriptor,
} from "./types.js";

export interface ComponentMatch {
Expand All @@ -54,12 +56,36 @@ export interface ComponentMatch {
* Returns `ambiguous` when the leaders tie on rarity-weighted score (a lone
* generic term is honestly ambiguous), `declined("no-signal")` on no match.
*/
/** A screenshot/ticket query: visible text, a structure descriptor, or both. */
export interface MatchQuery {
terms?: string[];
structure?: StructureDescriptor;
}

/** Weight of a full structural match relative to a rare matched term. */
const STRUCTURE_WEIGHT = 3;

/** Back-compat text-only entry point. */
export function matchComponentsByText(
graph: LineageGraph,
terms: string[],
): QueryResult<ComponentMatch> {
const queryTerms = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1);
if (queryTerms.length === 0) return declined("no-signal");
return matchComponents(graph, { terms });
}

/**
* Match components by rendered text (rarity-weighted, fuzzy — step 4.1) and/or
* structural shape (step 4.2). Text and structure scores combine into one
* ranking, so a dashboard with no static text still matches on "a table with
* four columns and a card grid".
*/
export function matchComponents(
graph: LineageGraph,
query: MatchQuery,
): QueryResult<ComponentMatch> {
const queryTerms = (query.terms ?? []).map((t) => normalizeText(t)).filter((t) => t.length > 1);
const descriptor = query.structure;
if (queryTerms.length === 0 && descriptor === undefined) return declined("no-signal");

const instancesByDefinition = groupInstances(graph);
const components = graph.nodes.filter((n): n is ComponentNode => n.kind === "component");
Expand Down Expand Up @@ -129,16 +155,27 @@ export function matchComponentsByText(
loc: component.loc,
});
}
if (matched.length === 0) continue;
const combination = 1 + 0.5 * (matched.length - 1);
const textScore = matched.length > 0 ? weight * (1 + 0.5 * (matched.length - 1)) : 0;

const structureFit = descriptor !== undefined ? structureScore(component.structure, descriptor) : 0;
if (structureFit > 0) {
evidence.push({
kind: "structure",
detail: `structural shape matched the descriptor (fit ${structureFit.toFixed(2)})`,
loc: component.loc,
});
}

if (matched.length === 0 && structureFit === 0) continue;
const coverage = queryTerms.length > 0 ? matched.length / queryTerms.length : structureFit;
scored.push({
match: {
component,
instances: instancesByDefinition.get(component.id) ?? [],
matchedText: matched,
},
score: weight * combination,
coverage: matched.length / queryTerms.length,
score: textScore + structureFit * STRUCTURE_WEIGHT,
coverage: Math.max(coverage, structureFit),
evidence,
});
}
Expand Down Expand Up @@ -536,6 +573,25 @@ export function journeys(
return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]);
}

/**
* Fraction (0–1) of a structure descriptor's specified expectations that a
* component's signature satisfies. Counts and columns match with tolerance so
* OCR/vision miscounts still land.
*/
function structureScore(sig: StructuralSignature, desc: StructureDescriptor): number {
const checks: boolean[] = [];
if (desc.table !== undefined) checks.push(desc.table === sig.table > 0);
if (desc.form !== undefined) checks.push(desc.form === sig.form > 0);
if (desc.list !== undefined) checks.push(desc.list === (sig.list > 0 || sig.repeated > 0));
if (desc.columns !== undefined) checks.push(Math.abs(sig.columns - desc.columns) <= 1);
if (desc.inputs !== undefined) checks.push(sig.input >= desc.inputs - 1);
if (desc.buttons !== undefined) checks.push(sig.button >= desc.buttons - 1);
if (desc.images !== undefined) checks.push(sig.image >= desc.images - 1);
if (desc.cards !== undefined) checks.push(sig.repeated >= Math.max(1, desc.cards - 1));
if (checks.length === 0) return 0;
return checks.filter(Boolean).length / checks.length;
}

/** True when `phrase` tokens appear as a contiguous, in-order, fuzzy run in `haystack`. */
function containsPhrase(haystack: string[], phrase: string[]): boolean {
if (phrase.length === 0 || phrase.length > haystack.length) return false;
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ export interface RenderedText {
template?: boolean;
}

/**
* Counts of the structural elements a component renders — the signal for
* matching a screenshot with little or no static text (TRACKER step 4.2,
* failure modes A1/A3/A12). Raw DOM tags plus common design-system aliases
* (`<Table>`, `<TextField>`, …) are folded into these buckets.
*/
export interface StructuralSignature {
table: number;
/** Column headers — the max `<th>` (or design-system column) count in a table. */
columns: number;
form: number;
input: number;
button: number;
link: number;
image: number;
heading: number;
/** `<ul>`/`<ol>`/`<li>` groupings. */
list: number;
/** `.map(...)` JSX renders — repeated cards/rows/grid items. */
repeated: number;
}

/** A React component definition — the code, not a usage. */
export interface ComponentNode extends BaseNode {
kind: "component";
Expand All @@ -82,6 +104,25 @@ export interface ComponentNode extends BaseNode {
renderedText: RenderedText[];
/** Names of components this component renders in its JSX (deduplicated). */
rendersComponents: string[];
/** Structural fingerprint of the rendered subtree (Phase 4.2). */
structure: StructuralSignature;
}

/**
* A structural query, e.g. from vision output "a table with columns Name,
* Email, Actions". Only the specified fields are scored against a signature.
*/
export interface StructureDescriptor {
table?: boolean;
/** Expected column count (matched within ±1). */
columns?: number;
form?: boolean;
inputs?: number;
buttons?: number;
images?: number;
list?: boolean;
/** Expected count of repeated items (cards/rows), matched with tolerance. */
cards?: number;
}

/**
Expand Down
Loading
Loading