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
2 changes: 1 addition & 1 deletion TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ The heart of the project. C1 and B1 live here.
**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.

### [x] 4.2 Structural matching
**Failure modes:** A1, A3, A12
**Failure modes:** A1, A3, A12 — fixtures: `a1-no-static-text`, `a3-api-text`, `a12-non-text` (structure-only matches capped at medium confidence — honest graceful degradation)
**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
8 changes: 8 additions & 0 deletions eval/fixtures/a12-non-text/app/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function FilterBar() {
return (
<div className="filters">
<input type="text" />
<input type="text" />
</div>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/a12-non-text/app/MetricsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// A chart with icon-only controls — a canvas and two unlabeled buttons. There
// is nothing to text-match; only structure gives a (low-confidence) candidate.
export function MetricsChart() {
return (
<div className="chart">
<canvas />
<button />
<button />
</div>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/a12-non-text/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"failureMode": "A12",
"note": "Non-text UI (charts, canvases, icon buttons): nothing to text-match. A text query declines rather than guessing; a structure descriptor returns a candidate at medium confidence — graceful degradation, not a confident wrong answer.",
"expect": {
"components": [
{ "name": "MetricsChart", "instances": 0 },
{ "name": "FilterBar", "instances": 0 }
],
"queries": [
{ "terms": ["Revenue over time"], "status": "declined" },
{ "terms": [], "structure": { "buttons": 2 }, "status": "ok", "top": "MetricsChart", "confidence": "medium" }
]
}
}
10 changes: 10 additions & 0 deletions eval/fixtures/a3-api-text/app/ApiForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function ApiForm({ onSubmit }: { onSubmit: () => void }) {
// Labels/placeholders are all CMS-driven — no literals in source.
return (
<form onSubmit={onSubmit}>
<input type="text" />
<input type="text" />
<button type="submit" />
</form>
);
}
26 changes: 26 additions & 0 deletions eval/fixtures/a3-api-text/app/ApiTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface Row {
id: string;
}

// All visible text comes from the API/CMS at runtime — the source has zero
// literals, so text matching finds nothing. Only the structure identifies it.
export function ApiTable({ columns, rows }: { columns: string[]; rows: Row[] }) {
return (
<table>
<thead>
<tr>
<th>{columns[0]}</th>
<th>{columns[1]}</th>
<th>{columns[2]}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
</tr>
))}
</tbody>
</table>
);
}
15 changes: 15 additions & 0 deletions eval/fixtures/a3-api-text/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"failureMode": "A3",
"note": "Text served by API/CMS: components render only {data.*}, so text matching finds nothing (a text query honestly declines). Structural matching still identifies them — but at medium confidence, never high, because shape alone can't be certain.",
"expect": {
"components": [
{ "name": "ApiTable", "instances": 0 },
{ "name": "ApiForm", "instances": 0 }
],
"queries": [
{ "terms": [], "structure": { "table": true, "columns": 3 }, "status": "ok", "top": "ApiTable", "confidence": "medium" },
{ "terms": [], "structure": { "form": true, "inputs": 2 }, "status": "ok", "top": "ApiForm", "confidence": "medium" },
{ "terms": ["Team members"], "status": "declined" }
]
}
}
5 changes: 5 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ export function runChecks(
passed = missing.length === 0;
if (!passed) detail = `expected context [${query.context.join(", ")}], got [${ctx.join(", ")}]`;
}
if (passed && query.confidence !== undefined) {
const level = result.candidates[0]?.confidence.level;
passed = level === query.confidence;
if (!passed) detail = `expected confidence ${query.confidence}, got ${level ?? "none"}`;
}
}
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 @@ -42,6 +42,8 @@ export interface GoldenQuery {
top?: string;
/** Ancestor names the top match must list as `context` (step 4.3). */
context?: string[];
/** When set, the top candidate's confidence level must equal this (A3/A12 honesty). */
confidence?: "high" | "medium" | "low";
/**
* 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
22 changes: 22 additions & 0 deletions packages/core/src/matching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ describe("matchComponentsByText scorer (TRACKER 4.1, A4/A10)", () => {
it("declines when nothing matches", () => {
expect(matchComponentsByText(graph(forms), ["Purchase history"]).status).toBe("declined");
});

it("caps a structure-only match at medium confidence, never high (A3/A12)", () => {
const chart: ComponentNode = {
...component("MetricsChart", []),
structure: {
table: 0,
columns: 0,
form: 0,
input: 0,
button: 2,
link: 0,
image: 0,
heading: 0,
list: 0,
repeated: 0,
},
};
const result = matchComponents(graph([chart]), { structure: { buttons: 2 } });
expect(result.status).toBe("ok");
expect(result.candidates[0]?.value.component.name).toBe("MetricsChart");
expect(result.candidates[0]?.confidence.level).toBe("medium");
});
});

describe("alias glossary & corrections (TRACKER 4.6, E2/G4)", () => {
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,16 @@ export function matchComponents(
}
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 candidates: Candidate<ComponentMatch>[] = winners.map((s) => {
const conf = confidenceFromScore(s.coverage);
// Structure-only matches (no text, alias, or correction evidence — A3/A12)
// are an honest fallback, never "high": shape alone can't be certain.
const confidence =
s.covered.size === 0 && conf.level === "high"
? { score: conf.score, level: "medium" as const }
: conf;
return { value: s.match, confidence, evidence: s.evidence };
});

const top = winners[0];
const tied =
Expand Down
Loading