|
3 | 3 | // expectations in utils/gloss_golden.json are resolved through the LIVE defs of |
4 | 4 | // utils/build_glosses.cjs (same eval trick as _probe_one.cjs) — no 6-minute build. |
5 | 5 | // |
| 6 | +// The L&S source is the COMMITTED fixture utils/ls_golden_fixture.json (the 30MB |
| 7 | +// utils/ext_tmp/ dump is gitignored, so CI has no L&S data), giving the suite |
| 8 | +// IDENTICAL semantics in CI and locally. Regenerate the fixture after golden |
| 9 | +// edits with `npm run build:gloss-fixture` and commit it alongside. When |
| 10 | +// utils/ext_tmp/ IS present locally, a drift guard re-runs against the full dump |
| 11 | +// and fails if the fixture no longer reproduces it (see utils/build_ls_fixture.cjs). |
| 12 | +// |
6 | 13 | // The wordlist parse (macrons.txt, 812k lines) is cached in |
7 | 14 | // utils/gloss_lemma_cache.json.gz so a run is ~1-2s, not ~4s+3.3s. |
8 | 15 | // |
@@ -41,9 +48,37 @@ for (const line of fs.readFileSync("macronizer/macrons.txt","utf8").split("\\n") |
41 | 48 | } |
42 | 49 | `; |
43 | 50 | let defs = head.replace(WL_BLOCK, "var rows = new Map(), formSets = new Map(), formSetsTag = new Map(), lemmaPosCount = new Map();\n"); |
44 | | -defs = defs.replace(/^const \{ createEngine \} = .*$/gm, "").replace(/^const (fs|path|zlib|engine) = .*$/gm, "").replace(/^const engine = .*$/gm, ""); |
45 | | -defs = defs.replace(/\bconst\s+/g, "var ").replace(/\blet\s+/g, "var "); |
46 | | -eval(defs); |
| 51 | +const FIXTURE = "utils/ls_golden_fixture.json"; |
| 52 | +const hasFullLs = fs.existsSync(path.join("utils", "ext_tmp")); |
| 53 | +// ---- L&S source = the committed fixture (see build_ls_fixture.cjs) ---- |
| 54 | +const LS_BLOCK_RE = /\/\/ ---- L&S index ----\nconst lsByKey = new Map\(\), lsByBase = new Map\(\);\n[\s\S]*?\n\}\n/; |
| 55 | +const LS_FIXTURE_BLOCK = `// ---- L&S index (CI fixture: utils/ls_golden_fixture.json) ---- |
| 56 | +var lsByKey = new Map(), lsByBase = new Map(); |
| 57 | +for (const e of JSON.parse(fs.readFileSync("utils/ls_golden_fixture.json", "utf8"))) { |
| 58 | + if (!e || !e.key) continue; |
| 59 | + const key = String(e.key).toLowerCase(); |
| 60 | + lsByKey.set(key, e); |
| 61 | + const base = key.replace(/\\d+$/, ""); |
| 62 | + if (!lsByBase.has(base)) lsByBase.set(base, []); |
| 63 | + lsByBase.get(base).push(e); |
| 64 | +} |
| 65 | +`; |
| 66 | +function makeDefs(useFixture) { |
| 67 | + let d = head.replace(WL_BLOCK, "var rows = new Map(), formSets = new Map(), formSetsTag = new Map(), lemmaPosCount = new Map();\n"); |
| 68 | + if (useFixture) { |
| 69 | + if (!LS_BLOCK_RE.test(d)) throw new Error("build_glosses.cjs L&S load changed — update LS_BLOCK_RE in " + __filename); |
| 70 | + d = d.replace(LS_BLOCK_RE, LS_FIXTURE_BLOCK); |
| 71 | + } |
| 72 | + d = d.replace(/^const \{ createEngine \} = .*$/gm, "").replace(/^const (fs|path|zlib|engine) = .*$/gm, "").replace(/^const engine = .*$/gm, ""); |
| 73 | + d = d.replace(/\bconst\s+/g, "var ").replace(/\blet\s+/g, "var "); |
| 74 | + return d; |
| 75 | +} |
| 76 | +if (!fs.existsSync(FIXTURE)) { |
| 77 | + console.error(`Missing ${FIXTURE} — run \`npm run build:gloss-fixture\` locally (needs utils/ext_tmp/) and commit it.`); |
| 78 | + process.exit(1); |
| 79 | +} |
| 80 | +// Authoritative pass always uses the fixture → identical semantics in CI and locally. |
| 81 | +eval(makeDefs(true)); |
47 | 82 |
|
48 | 83 | // ---- wordlist cache ---- |
49 | 84 | if (process.argv.includes("--rebuild-cache") || !fs.existsSync(CACHE)) { |
@@ -72,60 +107,90 @@ if (process.argv.includes("--rebuild-cache") || !fs.existsSync(CACHE)) { |
72 | 107 | console.log(`cached ${Object.keys(out).length} lemmas`); |
73 | 108 | } |
74 | 109 |
|
75 | | -const cache = JSON.parse(zlib.gunzipSync(fs.readFileSync(CACHE))); |
76 | | -for (const [lem, e] of Object.entries(cache)) { |
77 | | - if (e.forms) formSets.set(lem, new Set(e.forms)); |
78 | | - if (e.formsTag) formSetsTag.set(lem, new Set(e.formsTag)); |
| 110 | +function loadWordlistCache() { |
| 111 | + const cache = JSON.parse(zlib.gunzipSync(fs.readFileSync(CACHE))); |
| 112 | + for (const [lem, e] of Object.entries(cache)) { |
| 113 | + if (e.forms) formSets.set(lem, new Set(e.forms)); |
| 114 | + if (e.formsTag) formSetsTag.set(lem, new Set(e.formsTag)); |
| 115 | + } |
| 116 | + // dominantPos reads lemmaPosCount — rebuild it faithfully, then override dominantPos |
| 117 | + for (const [lem, e] of Object.entries(cache)) lemmaPosCount.set(lem, e.pos); |
| 118 | + const origDominantPos = dominantPos; |
| 119 | + dominantPos = function (l) { |
| 120 | + const cnt = lemmaPosCount.get(l); |
| 121 | + if (!cnt) return origDominantPos(l); |
| 122 | + let best = null, bestN = 0; |
| 123 | + for (const [p, n] of Object.entries(cnt)) if (n > bestN) { best = p; bestN = n; } |
| 124 | + return best || "N"; |
| 125 | + }; |
| 126 | + return cache; |
79 | 127 | } |
80 | | -// dominantPos reads lemmaPosCount — rebuild it faithfully, then override dominantPos |
81 | | -for (const [lem, e] of Object.entries(cache)) lemmaPosCount.set(lem, e.pos); |
82 | | -const origDominantPos = dominantPos; |
83 | | -dominantPos = function (l) { |
84 | | - const cnt = lemmaPosCount.get(l); |
85 | | - if (!cnt) return origDominantPos(l); |
86 | | - let best = null, bestN = 0; |
87 | | - for (const [p, n] of Object.entries(cnt)) if (n > bestN) { best = p; bestN = n; } |
88 | | - return best || "N"; |
89 | | -}; |
| 128 | +const cache = loadWordlistCache(); |
90 | 129 |
|
91 | 130 | // ---- golden suite ---- |
92 | 131 | const golden = JSON.parse(fs.readFileSync(GOLDEN, "utf8")); |
93 | | -let pass = 0, fail = 0; |
94 | | -const fails = []; |
| 132 | +const norm = (s) => (s || "").toLowerCase().replace(/[.,;]$/, "").replace(/\s+/g, " ").trim(); |
95 | 133 | let engine = null; |
96 | 134 | // CORE_GLOSS keys must each have a golden row — a curated override with no |
97 | 135 | // regression gate would silently rot (monotone rule: every override gets a golden |
98 | 136 | // row in the same commit). No-op when utils/core_gloss.json doesn't exist yet. |
99 | 137 | const coreGlossPath = "utils/core_gloss.json"; |
| 138 | +const coreFails = []; |
100 | 139 | if (fs.existsSync(coreGlossPath)) { |
101 | 140 | const core = JSON.parse(fs.readFileSync(coreGlossPath, "utf8")); |
102 | 141 | const covered = new Set(golden.map(r => r.lemma.toLowerCase())); |
103 | 142 | for (const k of Object.keys(core)) { |
104 | | - if (!covered.has(k)) { fail++; fails.push({ lemma: k, expect: { contains: core[k] }, got: "(no golden row)", note: "core_gloss override missing from golden suite" }); } |
| 143 | + if (!covered.has(k)) coreFails.push({ lemma: k, expect: { contains: core[k] }, got: "(no golden row)", note: "core_gloss override missing from golden suite" }); |
105 | 144 | } |
106 | 145 | for (const row of golden) { |
107 | 146 | if (core[row.lemma] && row.expect !== null && !evalExpect(row.expect, core[row.lemma], (s)=>(s||"").toLowerCase())) { |
108 | | - fail++; fails.push({ lemma: row.lemma, expect: row.expect, got: core[row.lemma], note: "core_gloss override contradicts golden expectation" }); |
| 147 | + coreFails.push({ lemma: row.lemma, expect: row.expect, got: core[row.lemma], note: "core_gloss override contradicts golden expectation" }); |
109 | 148 | } |
110 | 149 | } |
111 | 150 | } |
112 | | -for (const row of golden) { |
113 | | - const { lemma, expect, note, wordsOnly } = row; |
114 | | - let got = null; |
115 | | - try { |
116 | | - const pos = dominantPos(lemma.toLowerCase()); |
117 | | - got = resolve(lemma, pos); |
118 | | - if (!got && wordsOnly) { |
119 | | - // WORDS-only fallback path (gender-tolerant): lazily init the engine. |
120 | | - if (!engine) engine = require("whitakers-words/node").createEngine(); |
121 | | - got = wGloss(lemma, pos, ""); |
| 151 | +function runGoldenSuite() { |
| 152 | + const fails = []; |
| 153 | + let pass = 0; |
| 154 | + for (const row of golden) { |
| 155 | + const { lemma, expect, note, wordsOnly } = row; |
| 156 | + let got = null; |
| 157 | + try { |
| 158 | + const pos = dominantPos(lemma.toLowerCase()); |
| 159 | + got = resolve(lemma, pos); |
| 160 | + if (!got && wordsOnly) { |
| 161 | + // WORDS-only fallback path (gender-tolerant): lazily init the engine. |
| 162 | + if (!engine) engine = require("whitakers-words/node").createEngine(); |
| 163 | + got = wGloss(lemma, pos, ""); |
| 164 | + } |
| 165 | + } catch (e) { got = "ERROR: " + e.message; } |
| 166 | + if (evalExpect(expect, got, norm)) pass++; |
| 167 | + else fails.push({ lemma, expect, got, note }); |
| 168 | + } |
| 169 | + return { pass, fails }; |
| 170 | +} |
| 171 | +// The authoritative run resolves through the fixture (identical in CI and locally). |
| 172 | +const main = runGoldenSuite(); |
| 173 | + |
| 174 | +// ---- drift guard (local only): the full 30MB dump must reproduce the fixture ---- |
| 175 | +// Re-eval with the full dump whenever it exists (so --tier=full below still |
| 176 | +// resolves against real L&S); compare against the fixture only in normal mode. |
| 177 | +if (hasFullLs) { |
| 178 | + eval(makeDefs(false)); |
| 179 | + loadWordlistCache(); |
| 180 | + if (!process.argv.includes("--tier=full")) { |
| 181 | + const full = runGoldenSuite(); |
| 182 | + const stale = main.fails.filter(f => !full.fails.some(g => g.lemma === f.lemma)); |
| 183 | + if (stale.length) { |
| 184 | + console.error(`\nL&S fixture STALE: ${stale.length} rows pass with utils/ext_tmp/ but not with ${FIXTURE}.`); |
| 185 | + console.error(`Run \`npm run build:gloss-fixture\` and commit the regenerated fixture.`); |
| 186 | + for (const s of stale) console.error(` ${s.lemma}: expect ${JSON.stringify(s.expect)} got ${JSON.stringify(s.got)}`); |
122 | 187 | } |
123 | | - } catch (e) { got = "ERROR: " + e.message; } |
124 | | - const norm = (s) => (s || "").toLowerCase().replace(/[.,;]$/, "").replace(/\s+/g, " ").trim(); |
125 | | - const ok = evalExpect(expect, got, norm); |
126 | | - if (ok) { pass++; } |
127 | | - else { fail++; fails.push({ lemma, expect, got, note }); } |
| 188 | + } |
128 | 189 | } |
| 190 | + |
| 191 | +const fails = [...coreFails, ...main.fails]; |
| 192 | +const fail = fails.length; |
| 193 | +const pass = golden.length - main.fails.length; |
129 | 194 | console.log(`\n${pass} passed, ${fail} failed (${golden.length} total)`); |
130 | 195 | for (const f of fails) { |
131 | 196 | console.log(`\nFAIL ${f.lemma} (${f.note || ""})`); |
|
0 commit comments