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
33 changes: 31 additions & 2 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5)
- **Next step:** ship **0.4.0** to the tester for a re-validation round; then 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — the fixture's custom-wrapper shape already passes; get a failing test file from the tester or ship the defensive `coverage-unmapped` half only)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.8 · **release 0.4.0 prepared** (all packages bumped, changelog + version strings, `npm pack` → ui-lineage-0.4.0.tgz verified, publish pending)
- **Next step:** 6F.10 HTML-entity rendered-text noise (self-found on Grafana — ` `→"nbsp", `"`→"34" create spurious matches; decode/strip entities in the parser's text extraction, failure mode A16); then 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — Grafana produced 1,009 covered-by edges, so detection works on real code; the fixture's custom-wrapper shape already passes)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.9 · **released 0.4.0** (tag v0.4.0, PR #50 to main; publish to npm pending). **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run.
- **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) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP)

## What CodeRadar is
Expand Down Expand Up @@ -537,6 +537,35 @@ round-trips, dangling-edge drop, `</script>` breakout guard, self-contained asse
README documents the command. eval unaffected (290/0/0/0). **Gate 6F extractor criteria met;
only 6F.6 (coverage, data-blocked) remains before the gate fully closes.**

### [x] 6F.9 Stopword & rare-literal scoring noise
**Failure modes:** A15 (new), A4, D6
**Build:** self-found while validating 0.4.0 on Grafana's frontend — `find "Find silences by
matcher"` ranked `OrderBySection` (renders a bare `BY`) top-1 over `SilencesFilter`, because a
lone stopword is a *rare literal* with high IDF; and `find "The"` returned confident matches on
"the". New `isLowSignal(normalized)` in core/text.ts (extends the A14 `hasMatchSignal` guard):
true when a string is empty, punctuation-only, or entirely stopwords (folded through
`foldPlural` so it compares to normalized text). `matchComponents` now drops stopword-only query
terms and skips stopword-only rendered-text targets, so the exact-phrase component wins and a
stopword-only query declines `no-signal`.
**Accept:** new fixture `a15-stopword-noise` (a `BY` component, an exact-phrase component, a
`the`-rendering component): the phrase query ranks the exact-phrase component top-1; a stopword
query declines; a stopword mixed with a real term ignores the stopword-rendering component.
**Done:** implemented + 5 unit tests (2 text, 3 matcher) + fixture. **Verified on the real
Grafana graph:** `Find silences by matcher` → `SilencesFilter` top-1 (`OrderBySection` gone),
`The` → `no-signal`, and `resolve` on a stopword-heavy ticket went from confidently-wrong to an
honest `no-signal`. eval 297/0/0/0, gate OK, all metrics 1.000. A16 (HTML-entity rendered text)
spun out to 6F.10 — out of scope for a scoring PR.

### [ ] 6F.10 HTML-entity rendered-text noise
**Failure modes:** A16 (new)
**Build:** self-found on Grafana — rendered text that is an HTML entity (`&nbsp;`, `&#34;`,
`&gt;`) normalizes to a junk token (`nbsp`, `34`, `gt`); numeric entities make gibberish
containing those digits match. Decode/strip HTML entities in the parser's text-extraction pass
(parser-react), or treat entity-only rendered text as low-signal. Not a scoring fix — belongs in
extraction, hence a separate step from 6F.9.
**Accept:** fixture with entity-only rendered text: it produces no match target; gibberish that
shares digits with a numeric entity declines. eval green.

**Gate 6F:** field-patterns fixture fully green (skip list empty ✅) · instance resolution ≥ 95%
(✅ 100%) · RTK data sources > 0 (✅) · route nodes > 0 (✅) · gibberish queries decline
`no-signal` (✅) · `pnpm eval` green end-to-end (✅ 290/0/0/0). Remaining before the gate is
Expand Down
2 changes: 2 additions & 0 deletions docs/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ every downstream agent. When in doubt, return ranked candidates with evidence, o
| A12 | **Non-text UI** — charts, icons, canvases | Nothing to text-match | Graceful degradation: structural candidates + explicit low confidence | 4 |
| A13 | **Third-party embeds** — Intercom, iframes | Content isn't ours | Detect and report "not in this codebase" | 4 |
| A14 | **Empty-normalizing rendered text** — components rendering bare `\|` / `/` / `-` | Normalization strips punctuation → empty target treated as a substring of every query; matcher becomes a universal wildcard (field-found in v0.3.0) | Discard targets that normalize to empty; minimum alphanumeric token length; `no-signal` decline when only such targets match | 6F |
| A15 | **Stopword & rare-literal noise** — a component renders a bare `BY`; a ticket says "The …" | A stopword is a rare literal with high IDF, so it outranks the exact-phrase match; stopword-only queries return confident junk (field-found on Grafana 0.4.0) | Drop stopword-only query terms; reject stopword-only rendered-text targets (extends A14's low-signal guard) | 6F |
| A16 | **HTML-entity rendered text** — a component renders `&nbsp;` / `&#34;` / `&gt;` | Entities normalize to junk tokens (`nbsp`, `34`, `gt`); numeric entities let gibberish sharing those digits match (self-found on Grafana 0.4.0) | Decode/strip HTML entities in the text-extraction pass, or treat entity-only rendered text as low-signal | 6F |

## B. User-journey extraction

Expand Down
3 changes: 3 additions & 0 deletions eval/fixtures/a15-stopword-noise/app/AckPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function AckPanel() {
return <p>Before you continue, acknowledge the following</p>;
}
8 changes: 8 additions & 0 deletions eval/fixtures/a15-stopword-noise/app/OrderBySection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function OrderBySection() {
return (
<div>
<label>Sort</label>
<span>BY</span>
</div>
);
}
7 changes: 7 additions & 0 deletions eval/fixtures/a15-stopword-noise/app/SilencesFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function SilencesFilter() {
return (
<div>
<input placeholder="Find silences by matcher" />
</div>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/a15-stopword-noise/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"failureMode": "A15",
"note": "Stopword & rare-literal noise (field-found on Grafana, 0.4.0): a component rendering a bare stopword ('BY') is a rare literal whose high IDF let it outrank the component that renders the whole phrase, and a stopword-only query ('The') returned confident matches. Fix (6F.9): drop stopword-only query terms and reject stopword-only rendered-text targets, so the exact-phrase component wins and a stopword query declines no-signal.",
"expect": {
"components": [
{ "name": "OrderBySection", "instances": 0 },
{ "name": "SilencesFilter", "instances": 0 },
{ "name": "AckPanel", "instances": 0 }
],
"queries": [
{ "terms": ["Find silences by matcher"], "status": "ok", "top": "SilencesFilter" },
{ "terms": ["silences"], "status": "ok", "top": "SilencesFilter" },
{ "terms": ["The"], "status": "declined" },
{ "terms": ["the", "acknowledge"], "status": "ok", "top": "AckPanel" }
]
}
}
33 changes: 33 additions & 0 deletions packages/core/src/matching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,36 @@ describe("identifier affinity & top-line score (TRACKER 6F.7)", () => {
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
});
});

describe("stopword & rare-literal noise (TRACKER 6F.9, A15)", () => {
// Grafana field-found: `find "Find silences by matcher"` ranked OrderBySection
// (renders bare "BY") top-1 over SilencesFilter, because "BY" is a rare
// literal with huge IDF; and `find "The"` matched components on "the".
const g = graph([
component("OrderBySection", ["BY"]),
component("SilencesFilter", ["Find silences by matcher"]),
component("AckPanel", ["acknowledge the following"]),
]);

it("the exact-phrase component outranks a rare stopword literal", () => {
const result = matchComponentsByText(g, ["Find silences by matcher"]);
expect(result.candidates[0]?.value.component.name).toBe("SilencesFilter");
const names = result.candidates.map((c) => c.value.component.name);
expect(names).not.toContain("OrderBySection");
});

it("a stopword-only query declines no-signal", () => {
const result = matchComponentsByText(g, ["The"]);
expect(result.status).toBe("declined");
expect(result.declineReason).toBe("no-signal");
});

it("stopwords in a real query don't pull in stopword-rendering components", () => {
// Word-level terms as a ticket extractor produces them: "the" is dropped,
// "silences" resolves — AckPanel (renders "the") must not surface.
const result = matchComponentsByText(g, ["the", "silences"]);
expect(result.status).toBe("ok");
expect(result.candidates[0]?.value.component.name).toBe("SilencesFilter");
expect(result.candidates.map((c) => c.value.component.name)).not.toContain("AckPanel");
});
});
13 changes: 10 additions & 3 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
ok,
type QueryResult,
} from "./result.js";
import { fuzzyTokenMatch, normalizeText, textMatches, tokenize } from "./text.js";
import { fuzzyTokenMatch, isLowSignal, normalizeText, textMatches, tokenize } from "./text.js";
import type {
ComponentNode,
DataSourceNode,
Expand Down Expand Up @@ -128,7 +128,9 @@ export function matchComponents(
graph: LineageGraph,
query: MatchQuery,
): QueryResult<ComponentMatch> {
const queryTerms = (query.terms ?? []).map((t) => normalizeText(t)).filter((t) => t.length > 1);
const queryTerms = (query.terms ?? [])
.map((t) => normalizeText(t))
.filter((t) => t.length > 1 && !isLowSignal(t));
const descriptor = query.structure;
if (queryTerms.length === 0 && descriptor === undefined) return declined("no-signal");

Expand Down Expand Up @@ -162,7 +164,12 @@ export function matchComponents(
const termTokens = tokenize(term);
if (termTokens.length === 0) return null;
for (const entry of component.renderedText) {
if (textMatches(normalizeText(entry.text), term)) return entry;
const normEntry = normalizeText(entry.text);
// A stopword-only / punctuation-only entry ("BY", "|") is not a real
// target: it matches too much and, being a rare literal, its high IDF
// would let it outrank an exact-phrase match (A14/A15).
if (isLowSignal(normEntry)) continue;
if (textMatches(normEntry, term)) return entry;
if (containsPhrase(tokenize(entry.text), termTokens)) return entry;
}
return null;
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/text.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { hasMatchSignal, normalizeText, textMatches } from "./text.js";
import { hasMatchSignal, isLowSignal, normalizeText, textMatches } from "./text.js";

describe("normalizeText", () => {
it("lowercases, strips punctuation, collapses whitespace", () => {
Expand Down Expand Up @@ -60,3 +60,20 @@ describe("hasMatchSignal", () => {
expect(hasMatchSignal("ok")).toBe(true);
});
});

describe("isLowSignal (A15 — stopword/punctuation targets)", () => {
it("flags empty, punctuation-only, and stopword-only strings", () => {
expect(isLowSignal("")).toBe(true);
expect(isLowSignal(normalizeText("|"))).toBe(true);
expect(isLowSignal(normalizeText("BY"))).toBe(true); // rare literal → no signal
expect(isLowSignal(normalizeText("The"))).toBe(true);
expect(isLowSignal(normalizeText("of the"))).toBe(true);
expect(isLowSignal(normalizeText("This"))).toBe(true); // folds to "thi"
});

it("keeps strings with at least one discriminating token", () => {
expect(isLowSignal(normalizeText("silences"))).toBe(false);
expect(isLowSignal(normalizeText("Find silences by matcher"))).toBe(false);
expect(isLowSignal(normalizeText("the invoice"))).toBe(false);
});
});
33 changes: 33 additions & 0 deletions packages/core/src/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,39 @@ export function textMatches(haystack: string, needle: string): boolean {
return haystack.includes(needle) || needle.includes(haystack);
}

/**
* Common English function words that carry no discriminating match signal.
* Folded through `foldPlural` at load so they compare equal to normalized text
* (which is also folded) — e.g. "this" → "thi", "does" → "doe".
*/
const STOPWORDS = new Set(
[
"a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by",
"can", "could", "did", "do", "does", "for", "from", "had", "has", "have",
"he", "her", "his", "i", "if", "in", "into", "is", "it", "its", "just",
"may", "me", "might", "must", "my", "no", "not", "of", "on", "only", "or",
"our", "out", "over", "per", "she", "should", "so", "such", "than", "that",
"the", "their", "them", "then", "these", "they", "this", "those", "to",
"up", "us", "via", "was", "we", "were", "will", "with", "would", "you",
"your",
].map(foldPlural),
);

/**
* True when a normalized string carries no *discriminating* signal: it is
* empty, punctuation-only (A14), or nothing but stopwords ("the", "by").
* Such a string is both a poor query term (matches everything) and a poor
* match target — a component rendering a bare "BY" is a rare literal whose
* high IDF would otherwise let it outrank the component that renders the whole
* phrase (failure mode A15, field-found on Grafana). `matchComponents` drops
* these from query terms and rejects them as rendered-text targets.
*/
export function isLowSignal(normalized: string): boolean {
if (!hasMatchSignal(normalized)) return true;
const tokens = normalized.split(" ").filter((t) => t.length > 0 && t !== "*");
return tokens.length > 0 && tokens.every((t) => STOPWORDS.has(t));
}

function foldPlural(token: string): string {
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
Expand Down
Loading