Skip to content

Commit ccfd234

Browse files
Merge pull request #48 from officialCodeWork/build/phase-6f/step-6f.7-scoring-polish
feat(core): identifier-affinity ranking + top-line candidate score (6F.7)
2 parents 3667e7f + 91486d1 commit ccfd234

5 files changed

Lines changed: 79 additions & 6 deletions

File tree

TRACKER.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5)
8-
- **Next step:** 6F.6 test-coverage detection hardening (needs the field's actual failing variant) · 6F.7 scoring polish · 6F.8 visualizer
9-
- **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
8+
- **Next step:** 6F.8 galaxy visualizer · 6F.6 test-coverage hardening (blocked: needs the field's actual failing variant — fixture's wrapper shape already passes)
9+
- **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
1010
- **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)
1111

1212
## What CodeRadar is
@@ -489,7 +489,7 @@ through to the JSX argument), test files importing through the same alias/barrel
489489
coverage graph emits `coverage-unmapped` instead of per-component `untested`; enable the 6F.2
490490
checks.
491491

492-
### [ ] 6F.7 Scoring & result-surface polish
492+
### [x] 6F.7 Scoring & result-surface polish
493493
**Failure modes:** A4, D2, D6
494494
**Build:** weight matches by term specificity against the candidate's own identifiers (name,
495495
props, file path), not global character rarity alone — gibberish must never outscore a real
@@ -498,6 +498,15 @@ term (field: 6.50 for gibberish vs 6.09 for "calendar"). Expose a top-line `scor
498498
to misread) — core envelope, CLI output, MCP result schema.
499499
**Accept:** ranking fixture: identifier-specific terms beat rarity-only scores; `score` present
500500
at candidate top level across CLI + MCP (schemas regenerated, drift gate green).
501+
**Done:** `matchComponents` applies `IDENTIFIER_AFFINITY` (×1.5) to a matched term that also
502+
names the candidate — its name, props, or file basename, camelCase-split and tokenized
503+
(memoized per component, fuzzy-tolerant) — with the evidence line noting "(also names the
504+
component)". Genuinely-tied generic terms stay tied, so ambiguity honesty is unchanged
505+
(1.000). `Candidate` gains an optional top-level `score` (raw ranking score, larger = stronger
506+
within one result; `confidence` stays the calibrated signal) set by `matchComponents` and
507+
printed by the CLI (`score=… confidence=…`); MCP inherits it through the envelope JSON.
508+
Candidate isn't part of the generated schemas, so no schema change (drift gate confirms).
509+
2 new core tests (211 total); eval 290/0/0/0, gate OK, metrics 1.000.
501510

502511
### [ ] 6F.8 Galaxy visualizer (`coderadar visualize`)
503512
**Failure modes:** — (user-requested feature, 2026-07-15)

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ function printMatchCandidate(candidate: Candidate<ComponentMatch>): void {
313313
const match = candidate.value;
314314
console.log(
315315
`${match.component.name} (${match.component.loc.file}:${match.component.loc.line}) ` +
316+
`score=${candidate.score?.toFixed(2) ?? "—"} ` +
316317
`confidence=${candidate.confidence.level} (${candidate.confidence.score.toFixed(2)})`,
317318
);
318319
console.log(` matched: ${match.matchedText.join(" | ")}`);

packages/core/src/matching.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,28 @@ describe("empty-normalizing rendered text is never a wildcard (TRACKER 6F.1, A14
202202
expect(result.candidates[0]?.value.component.name).toBe("CalendarPanel");
203203
});
204204
});
205+
206+
describe("identifier affinity & top-line score (TRACKER 6F.7)", () => {
207+
// "Calendar" is rendered by both, but only CalendarPanel is NAMED for it —
208+
// global character rarity alone can't tell these apart (field complaint).
209+
const g = graph([
210+
component("CalendarPanel", ["Calendar", "Upcoming meetings"]),
211+
component("Dashboard", ["Calendar", "Revenue overview"]),
212+
]);
213+
214+
it("a term that also names the component beats the same text elsewhere", () => {
215+
const result = matchComponentsByText(g, ["Calendar"]);
216+
expect(result.status).toBe("ok");
217+
expect(result.candidates[0]?.value.component.name).toBe("CalendarPanel");
218+
expect(
219+
result.candidates[0]?.evidence.some((e) => e.detail.includes("also names the component")),
220+
).toBe(true);
221+
});
222+
223+
it("exposes the raw ranking score at the candidate top level, best-first", () => {
224+
const result = matchComponentsByText(g, ["Calendar"]);
225+
const scores = result.candidates.map((c) => c.score ?? 0);
226+
expect(scores[0]).toBeGreaterThan(0);
227+
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
228+
});
229+
});

packages/core/src/query.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ export interface MatchQuery {
9898

9999
/** Weight of a full structural match relative to a rare matched term. */
100100
const STRUCTURE_WEIGHT = 3;
101+
/**
102+
* Multiplier when a matched term also names the component itself — its name,
103+
* props, or file (6F.7). "calendar" matching CalendarPanel outranks the same
104+
* text rendered incidentally elsewhere; global character rarity alone can't
105+
* tell them apart.
106+
*/
107+
const IDENTIFIER_AFFINITY = 1.5;
101108
/** A glossary alias hit outweighs any text/structure evidence (Phase 4.6). */
102109
const ALIAS_WEIGHT = 10;
103110
/** A recorded human correction is the strongest signal of all. */
@@ -170,6 +177,28 @@ export function matchComponents(
170177
return base * (boosts.get(term) ?? 1);
171178
};
172179

180+
// A component's own identifiers — name, props, file basename — split on
181+
// camelCase and separators. Terms that also NAME the component score higher
182+
// than the same text rendered incidentally elsewhere (6F.7).
183+
const identifierMemo = new Map<string, Set<string>>();
184+
const identifierTokens = (component: ComponentNode): Set<string> => {
185+
const cached = identifierMemo.get(component.id);
186+
if (cached) return cached;
187+
const camelSplit = (s: string): string => s.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
188+
const basename = component.loc.file.split("/").pop()?.replace(/\.[a-z]+$/i, "") ?? "";
189+
const out = new Set(
190+
tokenize(
191+
[component.name, ...component.props, basename].map(camelSplit).join(" "),
192+
),
193+
);
194+
identifierMemo.set(component.id, out);
195+
return out;
196+
};
197+
const namesComponent = (term: string, component: ComponentNode): boolean => {
198+
const ids = identifierTokens(component);
199+
return tokenize(term).some((t) => ids.has(t) || [...ids].some((id) => fuzzyTokenMatch(id, t)));
200+
};
201+
173202
// Glossary aliases + recorded corrections (Phase 4.6). Both are authority
174203
// signals — a phrase resolves even when it appears nowhere in the code.
175204
const aliasEntries = Object.entries(query.aliases ?? {});
@@ -233,7 +262,8 @@ export function matchComponents(
233262
}
234263
}
235264
if (hit === null) continue;
236-
const w = termWeight(term);
265+
const affine = namesComponent(term, component);
266+
const w = termWeight(term) * (affine ? IDENTIFIER_AFFINITY : 1);
237267
covered.add(term);
238268
if (where.id === component.id) matched.push(term);
239269
weight += w;
@@ -247,7 +277,9 @@ export function matchComponents(
247277
: "";
248278
evidence.push({
249279
kind: "text-match",
250-
detail: `"${term}" matched rendered text "${hit.text}"${provenance} — rarity weight ${w.toFixed(2)}`,
280+
detail:
281+
`"${term}" matched rendered text "${hit.text}"${provenance} — rarity weight ${w.toFixed(2)}` +
282+
(affine ? " (also names the component)" : ""),
251283
loc: where.loc,
252284
});
253285
}
@@ -347,7 +379,7 @@ export function matchComponents(
347379
s.covered.size === 0 && conf.level === "high"
348380
? { score: conf.score, level: "medium" as const }
349381
: conf;
350-
return { value: s.match, confidence, evidence: s.evidence };
382+
return { value: s.match, confidence, evidence: s.evidence, score: s.score };
351383
});
352384

353385
const top = winners[0];

packages/core/src/result.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export interface Candidate<T> {
1919
value: T;
2020
confidence: Confidence;
2121
evidence: Evidence[];
22+
/**
23+
* Raw ranking score, exposed top-level so agents don't misread the nested
24+
* calibration score as match strength (6F.7). Larger is stronger; only
25+
* comparable within one result. `confidence` stays the calibrated signal.
26+
*/
27+
score?: number;
2228
}
2329

2430
export interface QueryResult<T> {

0 commit comments

Comments
 (0)