test(04-02): RED — golden-corpus D-14 gate suite (CO1-CO8)
- CO1 top-3 >= corpus threshold (D-14), measured in-process via the runner's measureCorpus - CO2 zero negative leaks (honest refusal, D-13) - CO3 tautology floor: >= 15 non-negative queries not string-equal to any alias/name - CO4/CO5 every expectId exists in the gazetteer + start_param-safe charset - CO6 size >= 60 + per-category floors (landmark 12 / abbreviation 15 / glued 6 / typo 12 / alias 12 / negative 6) - CO7 per-category rates printed so suite and runner output agree - CO8 refusal margin pinned: every negative's best two-level fuzzy sim < 0.72, one near-miss in [0.55, 0.72) All 8 cases verified failing (ENOENT corpus fixture) before GREEN.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* corpus.test.mjs — the D-14 Nyquist gate over the pinned golden corpus.
|
||||
* ----------------------------------------------------------------------
|
||||
* The suite re-runs the corpus runner's measurements IN-PROCESS (it imports
|
||||
* the shared module and the corpus directly — it never spawns the script),
|
||||
* so `npm run search:test` fails whenever the corpus regresses: the corpus
|
||||
* must be >= threshold at every wave merge, not just at the end.
|
||||
*
|
||||
* Cases:
|
||||
* CO1 top-3 hit rate meets the D-14 threshold (corpus.threshold.top3_hit_rate)
|
||||
* CO2 every negative is refused (zero leaks — never a fabricated pin)
|
||||
* CO3 the corpus is not tautological (>= 15 non-negative cases whose
|
||||
* normalized query is NOT string-equal to any alias or name — Pitfall 7)
|
||||
* CO4 every positive's expectId exists in the gazetteer (no dangling ids)
|
||||
* CO5 every expectId matches the start_param-safe charset
|
||||
* CO6 the corpus meets its size and per-category floors
|
||||
* CO7 every category's top-3 rate is reported (printed, so the suite
|
||||
* output and the runner output agree)
|
||||
* CO8 the refusal margin is pinned — every negative's best fuzzy
|
||||
* similarity sits strictly below FUZZY_ADMIT_SIMILARITY, and at
|
||||
* least one near-miss negative sits in [0.55, FUZZY_ADMIT_SIMILARITY)
|
||||
* so lowering the admission line would leak a real negative and fail
|
||||
* BOTH CO2 and CO8. The floor is pinned by test, not by hope.
|
||||
*
|
||||
* Run: npm run search:test (exit 0 = all green)
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "./harness.mjs";
|
||||
|
||||
// Lazy shared-module load — a missing module => per-case ERR_MODULE_NOT_FOUND
|
||||
// (the RED state), one FAIL per law instead of a lost suite load.
|
||||
let searchModPromise = null;
|
||||
function loadSearch() {
|
||||
if (!searchModPromise) {
|
||||
searchModPromise = import("../src/index.ts");
|
||||
}
|
||||
return searchModPromise;
|
||||
}
|
||||
|
||||
// The corpus runner's measurement core (the script exports measureCorpus
|
||||
// precisely so this suite measures the SAME thing the CLI reports).
|
||||
let runnerPromise = null;
|
||||
function loadRunner() {
|
||||
if (!runnerPromise) {
|
||||
runnerPromise = import("../scripts/gazetteer-corpus.mjs");
|
||||
}
|
||||
return runnerPromise;
|
||||
}
|
||||
|
||||
function loadCorpus() {
|
||||
return JSON.parse(
|
||||
readFileSync(new URL("./fixtures/golden-corpus.json", import.meta.url), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
/** The scorer's similarity shape: 1 - damerau/maxLen. */
|
||||
function similarity(a, b, damerau) {
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
if (maxLen === 0) return 1;
|
||||
return 1 - damerau(a, b) / maxLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SAME two-level (whole-string + token) fuzzy shape scoreOne uses —
|
||||
* CO8 replicates it here (it is not exported from the module) so the
|
||||
* refusal margin is asserted against exactly what the admission floor
|
||||
* admits on: max(wholeStringSim, tokenLevelSim) where the token level is
|
||||
* the mean of per-query-token bests scaled by the found fraction, and
|
||||
* sub-3-char tokens are skipped (contribute 0, not found, still counted
|
||||
* in the denominator).
|
||||
*/
|
||||
function fuzzySimOf(queryNorm, candidateNorm, damerau) {
|
||||
const whole = similarity(queryNorm, candidateNorm, damerau);
|
||||
const queryTokens = queryNorm.split(" ");
|
||||
const candidateTokens = candidateNorm.split(" ");
|
||||
let sum = 0;
|
||||
let found = 0;
|
||||
for (const queryToken of queryTokens) {
|
||||
if (queryToken.length < 3) continue;
|
||||
let best = 0;
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken.length < 3) continue;
|
||||
const sim = similarity(queryToken, candidateToken, damerau);
|
||||
if (sim > best) best = sim;
|
||||
}
|
||||
if (best > 0) found++;
|
||||
sum += best;
|
||||
}
|
||||
const tokenLevel =
|
||||
queryTokens.length > 0 ? (sum / queryTokens.length) * (found / queryTokens.length) : 0;
|
||||
return Math.max(whole, tokenLevel);
|
||||
}
|
||||
|
||||
/* ————— CO1 — the D-14 threshold ————— */
|
||||
|
||||
test("CO1 top-3 hit rate meets the D-14 threshold", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { measureCorpus } = await loadRunner();
|
||||
const results = measureCorpus(corpus);
|
||||
const rate = results.top3 / results.positiveCount;
|
||||
assert.ok(
|
||||
rate >= corpus.threshold.top3_hit_rate,
|
||||
`D-14 threshold not met: top-3 ${results.top3}/${results.positiveCount} ` +
|
||||
`(${(rate * 100).toFixed(1)}%) < ${(corpus.threshold.top3_hit_rate * 100).toFixed(1)}%. ` +
|
||||
`Escalation lever 1 is alias/normalizer table edits in search/ — ` +
|
||||
`record any lever pulled in docs/SEARCH-QUALITY.md. MISSED:\n${results.missed.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CO2 — honest refusal ————— */
|
||||
|
||||
test("CO2 every negative is refused", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { measureCorpus } = await loadRunner();
|
||||
const results = measureCorpus(corpus);
|
||||
assert.equal(
|
||||
results.leaked.length,
|
||||
0,
|
||||
`negatives MUST be refused with an empty shortlist (D-13), never a ` +
|
||||
`fabricated pin. LEAKED:\n${results.leaked.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CO3 — the tautology floor (Pitfall 7) ————— */
|
||||
|
||||
test("CO3 the corpus is not tautological — >= 15 queries are not string-equal to any alias", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { normalize, getEntries } = await loadSearch();
|
||||
const aliases = new Set();
|
||||
for (const entry of getEntries()) {
|
||||
aliases.add(normalize(entry.name));
|
||||
for (const alias of entry.aliases) aliases.add(normalize(alias));
|
||||
}
|
||||
const nonTautological = corpus.cases.filter(
|
||||
(c) => c.kind !== "negative" && !aliases.has(normalize(c.q)),
|
||||
);
|
||||
assert.ok(
|
||||
nonTautological.length >= 15,
|
||||
`tautology floor: only ${nonTautological.length} non-negative cases are not ` +
|
||||
`string-equal to any gazetteer alias/name (need >= 15) — a corpus that only ` +
|
||||
`repeats aliases measures nothing (RESEARCH Pitfall 7)`,
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CO4 — no dangling ids ————— */
|
||||
|
||||
test("CO4 every positive's expectId exists in the gazetteer", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { getEntries } = await loadSearch();
|
||||
const ids = new Set(getEntries().map((e) => e.id));
|
||||
const dangling = corpus.cases
|
||||
.filter((c) => c.expectId !== null && !ids.has(c.expectId))
|
||||
.map((c) => `${c.q} -> ${c.expectId}`);
|
||||
assert.equal(
|
||||
dangling.length,
|
||||
0,
|
||||
`corpus bug (not a search bug): expectId has no entry — fix the corpus:\n${dangling.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CO5 — start_param-safe charset ————— */
|
||||
|
||||
test("CO5 every expectId matches the start_param-safe charset", () => {
|
||||
const corpus = loadCorpus();
|
||||
const bad = corpus.cases
|
||||
.filter((c) => c.expectId !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(c.expectId))
|
||||
.map((c) => c.expectId);
|
||||
assert.equal(
|
||||
bad.length,
|
||||
0,
|
||||
`expectId must match /^[A-Za-z0-9_-]{1,64}$/ (the start_param charset): ${bad.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— CO6 — size + per-category floors ————— */
|
||||
|
||||
test("CO6 the corpus meets its size and category floors", () => {
|
||||
const corpus = loadCorpus();
|
||||
assert.ok(corpus.cases.length >= 60, `corpus must have >= 60 cases, has ${corpus.cases.length}`);
|
||||
const floors = { landmark: 12, abbreviation: 15, glued: 6, typo: 12, alias: 12, negative: 6 };
|
||||
const kinds = {};
|
||||
for (const c of corpus.cases) kinds[c.kind] = (kinds[c.kind] ?? 0) + 1;
|
||||
for (const [kind, floor] of Object.entries(floors)) {
|
||||
assert.ok(
|
||||
(kinds[kind] ?? 0) >= floor,
|
||||
`category "${kind}" has ${kinds[kind] ?? 0} cases, floor is ${floor}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* ————— CO7 — per-category reporting (printed, not asserted) ————— */
|
||||
|
||||
test("CO7 every category's top-3 rate is reported", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { measureCorpus } = await loadRunner();
|
||||
const results = measureCorpus(corpus);
|
||||
console.log("[corpus] per-category top-3 rates:");
|
||||
for (const cat of results.perCategory) {
|
||||
if (cat.kind === "negative") {
|
||||
console.log(
|
||||
`[corpus] ${cat.kind.padEnd(12)} refused ${cat.refused}/${cat.total} (${((cat.refused / cat.total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[corpus] ${cat.kind.padEnd(12)} top-3 ${cat.top3}/${cat.total} (${((cat.top3 / cat.total) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ————— CO8 — the refusal margin, pinned ————— */
|
||||
|
||||
test("CO8 the refusal margin is pinned — every negative below the admission line, one near-miss above 0.55", async () => {
|
||||
const corpus = loadCorpus();
|
||||
const { damerau, normalize, getEntries, FUZZY_ADMIT_SIMILARITY } = await loadSearch();
|
||||
const negatives = corpus.cases.filter((c) => c.expectId === null);
|
||||
const margins = negatives.map((c) => {
|
||||
const queryNorm = normalize(c.q);
|
||||
let best = 0;
|
||||
for (const entry of getEntries()) {
|
||||
for (const candidate of [entry.name, ...entry.aliases]) {
|
||||
const sim = fuzzySimOf(queryNorm, normalize(candidate), damerau);
|
||||
if (sim > best) best = sim;
|
||||
}
|
||||
}
|
||||
return { q: c.q, best };
|
||||
});
|
||||
const above = margins.filter((m) => m.best >= FUZZY_ADMIT_SIMILARITY);
|
||||
assert.equal(
|
||||
above.length,
|
||||
0,
|
||||
`a negative reaches the fuzzy admission line — lowering the threshold or ` +
|
||||
`admitting it would fabricate a pin (D-13). Offenders:\n` +
|
||||
above.map((m) => ` ${m.q} sim ${m.best.toFixed(3)} >= ${FUZZY_ADMIT_SIMILARITY}`).join("\n"),
|
||||
);
|
||||
const nearMisses = margins.filter((m) => m.best >= 0.55 && m.best < FUZZY_ADMIT_SIMILARITY);
|
||||
assert.ok(
|
||||
nearMisses.length >= 1,
|
||||
`the corpus must contain at least one NEAR-MISS negative whose best fuzzy ` +
|
||||
`similarity lands in [0.55, ${FUZZY_ADMIT_SIMILARITY}) — a query a LOWERED ` +
|
||||
`admission threshold would wrongly admit. None found; margins:\n` +
|
||||
margins.map((m) => ` ${m.q} ${m.best.toFixed(3)}`).join("\n"),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user