Skip to content

Commit 384d9ac

Browse files
mcp-tool-shopclaude
andcommitted
feat(kernel): recall-aware matcher scoring + transparency (FT-K1/K3)
scoreEntry blended score max(coverage, matched/ABSOLUTE_K) + patternBonus (ABSOLUTE_K=5): a genuine 2-3 keyword match on a keyword-rich entry is now reachable (was matched/total, which made 30-keyword entries need 7+ hits to clear a 0.3 floor), while single incidental hits stay <=0.2. Additive transparency: optional matchLoadout(task,index,{minScore}), exported DEFAULT_MIN_SCORE, and MatchResult.scoreComponents. Public API back-compat (only an additive optional param). Calibration vs the live 336-entry index: game canon 0.15->0.6-0.8 (recall gap fixed), generic-word noise ~0.4 -> recommended floor re-derives to 0.5 when this ships at republish (live hook unchanged for now, still on published 1.4.3). Tests: kernel 117->126. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4cd6d8 commit 384d9ac

4 files changed

Lines changed: 247 additions & 26 deletions

File tree

packages/kernel/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type {
88
LoadoutIndex,
99
Frontmatter,
1010
MatchResult,
11+
ScoreComponents,
1112
UsageEvent,
1213
MergeConflict,
1314
MergedIndex,
@@ -24,7 +25,8 @@ export { parseFrontmatter, serializeFrontmatter } from "./frontmatter.js";
2425
export { estimateTokens } from "./tokens.js";
2526

2627
// ── Matcher ────────────────────────────────────────────────────
27-
export { matchLoadout, lookupEntry } from "./match.js";
28+
export { matchLoadout, lookupEntry, DEFAULT_MIN_SCORE } from "./match.js";
29+
export type { MatchOptions } from "./match.js";
2830

2931
// ── Validator ──────────────────────────────────────────────────
3032
export { validateIndex } from "./validate.js";

packages/kernel/src/match.ts

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,65 @@
66
*
77
* Matching is deterministic:
88
* - Tokenizes the task into lowercase words
9-
* - Scores each entry by keyword and pattern overlap
9+
* - Scores each entry by keyword and pattern overlap (recall-aware)
1010
* - Returns entries above a minimum score threshold
1111
* - Core entries are always included (score 1.0)
1212
* - Manual entries are never auto-included (require explicit lookup)
1313
*/
1414

15-
import type { LoadoutIndex, LoadoutEntry, MatchResult, LoadMode } from "./types.js";
15+
import type {
16+
LoadoutIndex,
17+
LoadoutEntry,
18+
MatchResult,
19+
LoadMode,
20+
ScoreComponents,
21+
} from "./types.js";
1622

17-
const MIN_SCORE = 0.1; // minimum score to include a domain entry
23+
// Minimum score to include a domain entry. Exported so callers can see
24+
// (and override via matchLoadout opts) the inclusion threshold.
25+
export const DEFAULT_MIN_SCORE = 0.1;
26+
27+
/**
28+
* Recall-aware absolute-hit denominator (FT-K1).
29+
*
30+
* The original matcher scored domain entries by pure coverage
31+
* (matched / entry.keywords.length). On the live index, entries average
32+
* 30.8 keywords each (median 21, p90 71), so a genuine 2-3 keyword
33+
* topical match on a keyword-rich entry scored ~0.07-0.10 — below the
34+
* 0.1 inclusion floor and effectively unreachable — while the noise we
35+
* actually wanted to filter (single incidental hits) sat at 0.1-0.25.
36+
*
37+
* Pure coverage punishes keyword-rich entries for being thorough. The
38+
* fix blends coverage with an *absolute* recall signal: matched / 5.
39+
* ABSOLUTE_K = 5 means "5 matched keywords is a full-confidence match
40+
* regardless of how many keywords the entry declares." The blended base
41+
* is max(coverage, absolute), so:
42+
*
43+
* - A keyword-rich entry can no longer be starved by its own breadth
44+
* (a real 2-3 kw match becomes reachable).
45+
* - A tiny entry still benefits from high coverage (max() keeps the
46+
* stronger of the two signals).
47+
*
48+
* Why 5 (not 3 or 10): with a 0.3 practical "genuine match" expectation
49+
* and the existing 0.1 inclusion floor, ABSOLUTE_K = 5 places the
50+
* thresholds where we want them:
51+
*
52+
* - 1 incidental hit → absolute 0.20 → stays below a 0.3 floor (noise filtered)
53+
* - 2 genuine hits → absolute 0.40 → comfortably reachable
54+
* - 3 genuine hits → absolute 0.60 → clearly a match
55+
* - 5 genuine hits → absolute 1.00 → full confidence
56+
*
57+
* A larger K (e.g. 10) would push 2-3 kw matches back below the floor;
58+
* a smaller K (e.g. 3) would let single incidental hits (0.33) cross a
59+
* 0.3 noise threshold. 5 is the value that keeps genuine multi-keyword
60+
* matches reachable while single incidental hits stay quiet.
61+
*/
62+
const ABSOLUTE_K = 5;
63+
64+
// ── Options for matchLoadout (FT-K3, additive) ─────────────────
65+
export interface MatchOptions {
66+
minScore?: number; // inclusion threshold for domain entries (default DEFAULT_MIN_SCORE)
67+
}
1868

1969
// ── Tokenize a task description into matchable words ───────────
2070
function tokenize(text: string): Set<string> {
@@ -31,7 +81,12 @@ function tokenize(text: string): Set<string> {
3181
function scoreEntry(
3282
entry: LoadoutEntry,
3383
taskTokens: Set<string>,
34-
): { score: number; matchedKeywords: string[]; matchedPatterns: string[] } {
84+
): {
85+
score: number;
86+
matchedKeywords: string[];
87+
matchedPatterns: string[];
88+
scoreComponents?: ScoreComponents;
89+
} {
3590
// Core entries always match
3691
if (entry.priority === "core") {
3792
return { score: 1.0, matchedKeywords: [], matchedPatterns: [] };
@@ -63,38 +118,54 @@ function scoreEntry(
63118
}
64119
}
65120

66-
// Score: proportion of keywords matched + pattern bonus
67-
const keywordScore =
68-
entry.keywords.length > 0
69-
? matchedKeywords.length / entry.keywords.length
70-
: 0;
121+
// FT-K1: recall-aware blend.
122+
// coverage = the old pure metric (matched / declared keyword count).
123+
// absolute = recall signal (matched / ABSOLUTE_K) — independent of how
124+
// many keywords the entry declares.
125+
// base = max(coverage, absolute) — keeps the stronger of the two,
126+
// so keyword-rich entries are not starved by their breadth
127+
// and tiny entries still benefit from high coverage.
128+
const matched = matchedKeywords.length;
129+
const coverage =
130+
entry.keywords.length > 0 ? matched / entry.keywords.length : 0;
131+
const absolute = matched / ABSOLUTE_K;
132+
const base = Math.max(coverage, absolute);
71133

72134
const patternBonus =
73-
entry.patterns.length > 0 && matchedPatterns.length > 0
74-
? 0.2
75-
: 0;
135+
entry.patterns.length > 0 && matchedPatterns.length > 0 ? 0.2 : 0;
136+
137+
const score = Math.min(1.0, base + patternBonus);
76138

77-
const score = Math.min(1.0, keywordScore + patternBonus);
139+
const scoreComponents: ScoreComponents = {
140+
matched,
141+
coverage,
142+
absolute,
143+
base,
144+
patternBonus,
145+
};
78146

79-
return { score, matchedKeywords, matchedPatterns };
147+
return { score, matchedKeywords, matchedPatterns, scoreComponents };
80148
}
81149

82150
// ── Match a task against a loadout index ────────────────────────
83151
// Returns entries that should be loaded, sorted by score (highest first).
152+
//
153+
// FT-K3: `opts.minScore` overrides the inclusion threshold; the 2-arg
154+
// call form is unchanged and defaults to DEFAULT_MIN_SCORE.
84155
export function matchLoadout(
85156
task: string,
86157
index: LoadoutIndex,
158+
opts?: MatchOptions,
87159
): MatchResult[] {
160+
const minScore = opts?.minScore ?? DEFAULT_MIN_SCORE;
88161
const taskTokens = tokenize(task);
89162
const results: MatchResult[] = [];
90163

91164
for (const entry of index.entries) {
92-
const { score, matchedKeywords, matchedPatterns } = scoreEntry(
93-
entry,
94-
taskTokens,
95-
);
165+
const { score, matchedKeywords, matchedPatterns, scoreComponents } =
166+
scoreEntry(entry, taskTokens);
96167

97-
if (score >= MIN_SCORE) {
168+
if (score >= minScore) {
98169
const mode: LoadMode = entry.priority === "manual"
99170
? "manual"
100171
: entry.priority === "core"
@@ -109,7 +180,18 @@ export function matchLoadout(
109180
? `keywords [${matchedKeywords.join(", ")}]`
110181
: `patterns [${matchedPatterns.join(", ")}]`;
111182

112-
results.push({ entry, score, matchedKeywords, matchedPatterns, reason, mode });
183+
const result: MatchResult = {
184+
entry,
185+
score,
186+
matchedKeywords,
187+
matchedPatterns,
188+
reason,
189+
mode,
190+
};
191+
// Additive: only domain entries produce a component breakdown.
192+
if (scoreComponents) result.scoreComponents = scoreComponents;
193+
194+
results.push(result);
113195
}
114196
}
115197

packages/kernel/src/tests/match.test.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
3-
import { matchLoadout, lookupEntry } from "../match.js";
3+
import { matchLoadout, lookupEntry, DEFAULT_MIN_SCORE } from "../match.js";
44
import type { LoadoutIndex } from "../types.js";
55
import { DEFAULT_TRIGGERS } from "../types.js";
66

@@ -69,15 +69,20 @@ describe("matchLoadout", () => {
6969
assert.ok(results[0].reason.includes("keywords"));
7070
});
7171

72-
it("scores by keyword overlap proportion", () => {
72+
it("scores by recall-aware blend (coverage vs absolute)", () => {
7373
const index = makeIndex(
7474
{ id: "narrow", keywords: ["ci", "workflow", "runner", "matrix", "dependabot"] },
7575
{ id: "broad", keywords: ["ci", "workflow"] },
7676
);
7777
const results = matchLoadout("fix the ci workflow", index);
78-
// "broad" has 2/2 match (1.0), "narrow" has 2/5 match (0.4)
78+
// FT-K1: both match 2 keywords.
79+
// broad: coverage 2/2 = 1.0, absolute 2/5 = 0.4 → max = 1.0
80+
// narrow: coverage 2/5 = 0.4, absolute 2/5 = 0.4 → max = 0.4
7981
assert.equal(results[0].entry.id, "broad");
80-
assert.ok(results[0].score > results[1].score);
82+
assert.equal(results[0].score, 1.0);
83+
const narrow = results.find((r) => r.entry.id === "narrow")!;
84+
assert.equal(narrow.score, 0.4);
85+
assert.ok(results[0].score > narrow.score);
8186
});
8287

8388
it("gives pattern bonus", () => {
@@ -86,10 +91,13 @@ describe("matchLoadout", () => {
8691
{ id: "with-pattern", keywords: ["ci", "workflow", "runner"], patterns: ["ci_pipeline"] },
8792
{ id: "without-pattern", keywords: ["ci", "workflow", "runner"] },
8893
);
89-
// Task matches 1/3 keywords (0.33) + pattern bonus (0.2) = 0.53 vs 0.33
94+
// 1 matched keyword: coverage 1/3 = 0.333, absolute 1/5 = 0.2 → base 0.333.
95+
// with-pattern: 0.333 + 0.2 bonus = 0.533; without-pattern: 0.333.
9096
const results = matchLoadout("fix the ci pipeline", index);
9197
const withPattern = results.find((r) => r.entry.id === "with-pattern")!;
9298
const without = results.find((r) => r.entry.id === "without-pattern")!;
99+
assert.ok(Math.abs(withPattern.score - 0.5333333333333333) < 1e-9);
100+
assert.ok(Math.abs(without.score - 0.3333333333333333) < 1e-9);
93101
assert.ok(withPattern.score > without.score);
94102
assert.ok(withPattern.reason.includes("keywords") && withPattern.reason.includes("patterns"));
95103
});
@@ -112,6 +120,123 @@ describe("matchLoadout", () => {
112120
});
113121
});
114122

123+
// Build N filler keywords that will NOT match the test tasks below.
124+
function filler(n: number): string[] {
125+
return Array.from({ length: n }, (_, i) => `filler${i}`);
126+
}
127+
128+
describe("matchLoadout — recall-aware scoring (FT-K1)", () => {
129+
it("(a) a 2-keyword match on a 20+ keyword entry now scores >= 0.4", () => {
130+
// 24-keyword entry; task hits exactly 2 of them.
131+
const index = makeIndex(
132+
{ id: "rich", keywords: ["deploy", "release", ...filler(22)] },
133+
);
134+
const results = matchLoadout("deploy a release", index);
135+
assert.equal(results.length, 1);
136+
assert.equal(results[0].matchedKeywords.length, 2);
137+
// coverage 2/24 = 0.083, absolute 2/5 = 0.4 -> max = 0.4
138+
assert.ok(results[0].score >= 0.4, `score was ${results[0].score}`);
139+
assert.equal(results[0].score, 0.4);
140+
// Transparency: component breakdown is present and explains the score.
141+
assert.ok(results[0].scoreComponents);
142+
assert.equal(results[0].scoreComponents!.matched, 2);
143+
assert.equal(results[0].scoreComponents!.absolute, 0.4);
144+
assert.ok(results[0].scoreComponents!.coverage < 0.1);
145+
assert.equal(results[0].scoreComponents!.base, 0.4);
146+
});
147+
148+
it("(a') a 3-keyword match on a keyword-rich entry scores 0.6", () => {
149+
const index = makeIndex(
150+
{ id: "rich", keywords: ["deploy", "release", "rollback", ...filler(27)] },
151+
);
152+
const results = matchLoadout("deploy release rollback now", index);
153+
assert.equal(results[0].matchedKeywords.length, 3);
154+
// absolute 3/5 = 0.6 dominates coverage 3/30 = 0.1
155+
assert.ok(Math.abs(results[0].score - 0.6) < 1e-9, `score was ${results[0].score}`);
156+
});
157+
158+
it("(b) a single incidental hit on a large entry stays <= 0.2", () => {
159+
// 30-keyword entry; task hits exactly 1 keyword incidentally.
160+
const index = makeIndex(
161+
{ id: "big", keywords: ["deploy", ...filler(29)] },
162+
);
163+
const results = matchLoadout("deploy something", index);
164+
assert.equal(results.length, 1);
165+
assert.equal(results[0].matchedKeywords.length, 1);
166+
// coverage 1/30 = 0.033, absolute 1/5 = 0.2 -> max = 0.2
167+
assert.ok(results[0].score <= 0.2, `score was ${results[0].score}`);
168+
assert.equal(results[0].score, 0.2);
169+
});
170+
171+
it("single hit on a tiny entry keeps high coverage (max picks coverage)", () => {
172+
const index = makeIndex(
173+
{ id: "tiny", keywords: ["deploy", "release", "rollback"] },
174+
);
175+
const results = matchLoadout("deploy something", index);
176+
// coverage 1/3 = 0.333 beats absolute 1/5 = 0.2 -> max = 0.333
177+
assert.ok(Math.abs(results[0].score - 0.3333333333333333) < 1e-9, `score was ${results[0].score}`);
178+
});
179+
});
180+
181+
describe("matchLoadout — minScore option (FT-K3)", () => {
182+
it("(c) minScore option filters out low scores", () => {
183+
// Single incidental hit on a large entry -> score 0.2.
184+
const index = makeIndex(
185+
{ id: "big", keywords: ["deploy", ...filler(29)] },
186+
);
187+
// Default threshold (0.1): the 0.2 entry is included.
188+
const included = matchLoadout("deploy something", index);
189+
assert.equal(included.length, 1);
190+
// Raised threshold (0.3): the 0.2 entry is filtered out as noise.
191+
const filtered = matchLoadout("deploy something", index, { minScore: 0.3 });
192+
assert.equal(filtered.length, 0);
193+
});
194+
195+
it("minScore option does not affect a genuine multi-keyword match", () => {
196+
const index = makeIndex(
197+
{ id: "rich", keywords: ["deploy", "release", ...filler(22)] },
198+
);
199+
// score 0.4 survives a 0.3 threshold.
200+
const results = matchLoadout("deploy a release", index, { minScore: 0.3 });
201+
assert.equal(results.length, 1);
202+
assert.equal(results[0].entry.id, "rich");
203+
});
204+
205+
it("DEFAULT_MIN_SCORE matches the implicit 2-arg behavior", () => {
206+
const index = makeIndex(
207+
{ id: "big", keywords: ["deploy", ...filler(29)] },
208+
);
209+
const implicit = matchLoadout("deploy something", index);
210+
const explicit = matchLoadout("deploy something", index, { minScore: DEFAULT_MIN_SCORE });
211+
assert.equal(implicit.length, explicit.length);
212+
assert.equal(implicit.length, 1);
213+
});
214+
});
215+
216+
describe("matchLoadout — core/manual unchanged (FT-K1 regression)", () => {
217+
it("(d) core entries still score 1.0 with no component breakdown", () => {
218+
const index = makeIndex(
219+
{ id: "core-rule", keywords: ["whatever"], priority: "core" },
220+
);
221+
const results = matchLoadout("anything at all", index);
222+
assert.equal(results.length, 1);
223+
assert.equal(results[0].score, 1.0);
224+
assert.equal(results[0].mode, "eager");
225+
assert.equal(results[0].reason, "core: always loaded");
226+
// Core entries do not get a recall-aware component breakdown.
227+
assert.equal(results[0].scoreComponents, undefined);
228+
});
229+
230+
it("(d) manual entries are still never auto-included", () => {
231+
const index = makeIndex(
232+
{ id: "manual-rule", keywords: ["deploy", "release"], priority: "manual" },
233+
);
234+
// Even with a strong keyword match, manual entries do not auto-load.
235+
const results = matchLoadout("deploy a release now", index);
236+
assert.equal(results.length, 0);
237+
});
238+
});
239+
115240
describe("lookupEntry", () => {
116241
it("finds entry by id", () => {
117242
const index = makeIndex(

packages/kernel/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export interface Frontmatter {
5555
triggers: Triggers;
5656
}
5757

58+
// ── Score components (matcher transparency) ────────────────────
59+
// Additive breakdown of how a domain entry's score was computed,
60+
// so callers can see WHY an entry scored the way it did.
61+
export interface ScoreComponents {
62+
matched: number; // count of matched keywords
63+
coverage: number; // matched / entry.keywords.length (the old pure metric)
64+
absolute: number; // matched / ABSOLUTE_K (recall-aware metric)
65+
base: number; // max(coverage, absolute) — the blended floor
66+
patternBonus: number; // 0 or 0.2 if any pattern matched
67+
}
68+
5869
// ── Match result from the matcher ──────────────────────────────
5970
export interface MatchResult {
6071
entry: LoadoutEntry;
@@ -63,6 +74,7 @@ export interface MatchResult {
6374
matchedPatterns: string[];
6475
reason: string; // human-readable explanation of why this matched
6576
mode: LoadMode; // how this entry should be loaded
77+
scoreComponents?: ScoreComponents; // additive: breakdown of the score (domain entries only)
6678
}
6779

6880
// ── Usage event (append-only log) ─────────────────────────────

0 commit comments

Comments
 (0)