diff --git a/TRACKER.md b/TRACKER.md
index 2e3be31..a6426e9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -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.
diff --git a/eval/fixtures/a12-non-text/app/FilterBar.tsx b/eval/fixtures/a12-non-text/app/FilterBar.tsx
new file mode 100644
index 0000000..7bd0ac8
--- /dev/null
+++ b/eval/fixtures/a12-non-text/app/FilterBar.tsx
@@ -0,0 +1,8 @@
+export function FilterBar() {
+ return (
+
+
+
+
+ );
+}
diff --git a/eval/fixtures/a12-non-text/app/MetricsChart.tsx b/eval/fixtures/a12-non-text/app/MetricsChart.tsx
new file mode 100644
index 0000000..d6f5280
--- /dev/null
+++ b/eval/fixtures/a12-non-text/app/MetricsChart.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/eval/fixtures/a12-non-text/golden.json b/eval/fixtures/a12-non-text/golden.json
new file mode 100644
index 0000000..b5b88fd
--- /dev/null
+++ b/eval/fixtures/a12-non-text/golden.json
@@ -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" }
+ ]
+ }
+}
diff --git a/eval/fixtures/a3-api-text/app/ApiForm.tsx b/eval/fixtures/a3-api-text/app/ApiForm.tsx
new file mode 100644
index 0000000..fe56937
--- /dev/null
+++ b/eval/fixtures/a3-api-text/app/ApiForm.tsx
@@ -0,0 +1,10 @@
+export function ApiForm({ onSubmit }: { onSubmit: () => void }) {
+ // Labels/placeholders are all CMS-driven — no literals in source.
+ return (
+
+ );
+}
diff --git a/eval/fixtures/a3-api-text/app/ApiTable.tsx b/eval/fixtures/a3-api-text/app/ApiTable.tsx
new file mode 100644
index 0000000..dbacf64
--- /dev/null
+++ b/eval/fixtures/a3-api-text/app/ApiTable.tsx
@@ -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 (
+
+
+
+
{columns[0]}
+
{columns[1]}
+
{columns[2]}
+
+
+
+ {rows.map((r) => (
+
+
{r.id}
+
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/a3-api-text/golden.json b/eval/fixtures/a3-api-text/golden.json
new file mode 100644
index 0000000..330efa1
--- /dev/null
+++ b/eval/fixtures/a3-api-text/golden.json
@@ -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" }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index 6755acc..bd0e202 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -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);
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index c2de573..22ef187 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -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).
diff --git a/packages/core/src/matching.test.ts b/packages/core/src/matching.test.ts
index 29537c0..9907130 100644
--- a/packages/core/src/matching.test.ts
+++ b/packages/core/src/matching.test.ts
@@ -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)", () => {
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 17e11de..c474e2d 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -338,11 +338,16 @@ export function matchComponents(
}
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 candidates: Candidate[] = 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 =