From dea79713588d63c38350f67944bc2fbef27c992f Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 00:52:33 +0800 Subject: [PATCH] =?UTF-8?q?test(04-02):=20RED=20=E2=80=94=20golden-corpus?= =?UTF-8?q?=20D-14=20gate=20suite=20(CO1-CO8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- search/test/corpus.test.mjs | 246 ++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 search/test/corpus.test.mjs diff --git a/search/test/corpus.test.mjs b/search/test/corpus.test.mjs new file mode 100644 index 0000000..045fd85 --- /dev/null +++ b/search/test/corpus.test.mjs @@ -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"), + ); +});