/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * #244 M2 Phase 1 — POST-HOC open-set scoring on the EXISTING (shipped) coarse-placer, NO retrain. * The OA-breadth verdict found the 12-way softmax's own max-prob detector tops out at ~88/88 on * the leave-one-family-out probe (in-map accuracy vs off-map HELDOUT-family caught). This asks: * does a DIFFERENT open-set score, read off the same frozen weights, clear the 90/90 the softmax * can't? * * The 11-way routing (argmax over the in-map classes) is FIXED — the model is unchanged. The only * thing each score changes is the REJECT decision (keep the in-map route vs. abstain → OTHER). We * sweep each score's threshold and trace its (in-map accuracy, heldout-caught) Pareto. * * Scores compared (all functions of the frozen 12 logits / 11 in-map logits `z`): * * - Maxprob : softmax max over the 11 in-map classes (the verdict's baseline detector) * - P_inmap : 1 - P(OTHER) (trust the model's own OTHER head) * - Energy : logsumexp(z) (free-energy; higher = more in-map) * - Maxlogit : max(z) * - Maha : -min_c (z-μ_c)ᵀ Σ⁻¹ (z-μ_c), class-conditional Gaussians (tied Σ) fit on in-map TRAIN * logits (Lee et al. 2018, in the 11-dim in-map-logit space — the linear model's only dense * representation). Higher = closer to the in-map manifold. * * In-map accuracy = of the 11-country test rows, fraction NOT rejected AND argmax-in-map == truth. * heldout caught = of the never-trained off-map families (baltic/oceania/middle-east), fraction * rejected. Both move with the threshold; the Pareto is the whole story. * * Run: `mailwoman placer eval openset [--model ] [--out-md ]` */ /* oxlint-disable sister-software/prefer-region-over-marks -- these markers label steps inside one procedure, not sections of declarations. A region there folds nothing a reader wants folded. */ import { readFileSync, writeFileSync } from "node:fs" import * as path from "node:path" import { JSONSpliterator } from "spliterator" import { parseJSONStrict } from "#objects" import { dataRootPath, repoRootPath } from "#utils" import type { CoarsePlacerMeta } from "../coarse-placer.ts" import { COARSE_CLASSES, featurize } from "../featurize.ts" /** * Steps in the threshold sweep; finer than the reporting precision so the knee is not missed. */ const QUANTILE_SWEEP_STEPS = 200 /** * Percentage both held-out catch and in-map accuracy must clear for an operating point to qualify. */ const TARGET_PERCENT = 90 type ScoreKey = "maxprob" | "p_inmap" | "energy" | "maxlogit" | "maha" interface DataRow { raw: string country: string group?: string srcCountry?: string family?: string } interface ScoredRow { correctRoute: boolean s: Record } interface ParetoPoint { t: number inMapAcc: number heldCaught: number } /** * Options for {@linkcode evalOpenSet}. */ export interface EvalOpenSetOptions { /** * Model artifact dir. Default `$MAILWOMAN_DATA_ROOT/coarse-placer/model`. */ model?: string /** * Dataset dir. Default `/data/coarse-placer`. */ data?: string /** * Mahalanobis fit rows per class. Default 2000. */ fitPerClass?: number /** * Also write the markdown report here. */ outMd?: string } /** * Result of {@linkcode evalOpenSet}. */ export interface EvalOpenSetResult { /** * Best score by the honest dev→test balanced min. */ winner: ScoreKey honestMin: number clears90: boolean markdown: string } function logsumexp(xs: number[]): number { let m = -Infinity for (const x of xs) if (x > m) { m = x } let s = 0 for (const x of xs) { s += Math.exp(x - m) } return m + Math.log(s) } /** * Per-class softmax prob over ALL 12 classes. */ function softmax(z: Float64Array): Float64Array { const m = Math.max(...z) const e = z.map((x) => Math.exp(x - m)) const s = e.reduce((a, b) => a + b, 0) return e.map((x) => x / s) } /** * Invert a symmetric positive-definite matrix via Gauss-Jordan. */ function inverse(M: Float64Array[]): number[][] { const n = M.length const A = M.map((row, i) => { const r = new Float64Array(2 * n) for (let j = 0; j < n; j++) { r[j] = row[j]! } r[n + i] = 1 return r }) for (let col = 0; col < n; col++) { let piv = col for (let r = col + 1; r < n; r++) if (Math.abs(A[r]![col]!) > Math.abs(A[piv]![col]!)) { piv = r } ;[A[col], A[piv]] = [A[piv]!, A[col]!] const d = A[col]![col]! for (let j = 0; j < 2 * n; j++) { A[col]![j] = A[col]![j]! / d } for (let r = 0; r < n; r++) { if (r === col) continue const f = A[r]![col]! for (let j = 0; j < 2 * n; j++) { A[r]![j] = A[r]![j]! - f * A[col]![j]! } } } return A.map((r) => Array.from(r.slice(n))) } /** * Coarse-placer post-hoc open-set score comparison — see the module doc. Emits the markdown report to stdout. */ export async function evalOpenSet( options: EvalOpenSetOptions = {}, report?: (line: string) => void ): Promise { const modelDir = options.model || dataRootPath("coarse-placer", "model") const dataDir = options.data || repoRootPath("data", "coarse-placer") const fitPerClass = options.fitPerClass ?? 2000 const meta = parseJSONStrict(readFileSync(path.join(modelDir, "meta.json"), "utf8")) const weightBytes = readFileSync(path.join(modelDir, "weights.bin")) // Read through the Buffer's own window: `readFileSync` serves files under 4 KiB out of a shared 8 KiB pool, so // `.buffer` alone would start at the pool's origin and run its full length — the wrong floats, and 2048 of them. const W = new Float32Array( weightBytes.buffer, weightBytes.byteOffset, weightBytes.byteLength / Float32Array.BYTES_PER_ELEMENT ) const bias = Float32Array.from(meta.bias) const C = meta.classes.length const D = meta.featureDim const OTHER = meta.classes.indexOf("OTHER") const IN = meta.classes.map((_, i) => i).filter((i) => i !== OTHER) // in-map class indices const nIn = IN.length if (W.length !== C * D) throw new Error(`weights ${W.length} ≠ ${C}×${D}`) /** * Raw logits (PRE-temperature) for the 12 classes. OOD scores use the geometry, not calibration. */ function logits(raw: string): Float64Array { const feats = featurize(raw) const z = new Float64Array(C) for (let c = 0; c < C; c++) { let s = bias[c]! const base = c * D for (const i of feats) { s += W[base + i]! } z[c] = s } return z } /** * In-map logit sub-vector (length nIn), in IN order. */ const inVec = (z: Float64Array): number[] => IN.map((c) => z[c]!) function load(file: string): Promise { return Array.fromAsync(JSONSpliterator.fromAsync(path.join(dataDir, file))) } // Fit the Mahalanobis params on IN-MAP TRAIN logits (no test leak): per-class // mean in the nIn-dim in-map-logit space + a tied (shared) covariance. report?.("fitting Mahalanobis on in-map train logits…") const trainRows = await load("train.jsonl") const byClass = new Map(COARSE_CLASSES.map((c): [string, string[]] => [c, []])) for (const r of trainRows) { if (r.country === "OTHER") continue const arr = byClass.get(r.country) if (arr && arr.length < fitPerClass) { arr.push(r.raw) } } const means = new Map() // country -> Float64Array(nIn) const counts = new Map() // Accumulate per-class means. for (const [country, raws] of byClass) { if (!raws.length) continue const mu = new Float64Array(nIn) for (const raw of raws) { const v = inVec(logits(raw)) for (let k = 0; k < nIn; k++) { mu[k] = mu[k]! + v[k]! } } for (let k = 0; k < nIn; k++) { mu[k] = mu[k]! / raws.length } means.set(country, mu) counts.set(country, raws.length) } // Tied covariance over centered in-map train logits. const Sigma = Array.from({ length: nIn }, () => new Float64Array(nIn)) let nTot = 0 for (const [country, raws] of byClass) { const mu = means.get(country) if (!mu) continue for (const raw of raws) { const v = inVec(logits(raw)) const d = new Float64Array(nIn) for (let k = 0; k < nIn; k++) { d[k] = v[k]! - mu[k]! } for (let a = 0; a < nIn; a++) { for (let b = 0; b < nIn; b++) { Sigma[a]![b] = Sigma[a]![b]! + d[a]! * d[b]! } } nTot++ } } for (let a = 0; a < nIn; a++) { for (let b = 0; b < nIn; b++) { Sigma[a]![b] = Sigma[a]![b]! / nTot } } // Ridge for invertibility. for (let a = 0; a < nIn; a++) { Sigma[a]![a] = Sigma[a]![a]! + 1e-3 } const SigmaInv = inverse(Sigma) /** * -min_c Mahalanobis² to any in-map class mean (higher = closer to the in-map manifold). */ function mahaScore(z: Float64Array): number { const v = inVec(z) let best = Infinity for (const mu of means.values()) { const d = new Float64Array(nIn) for (let k = 0; k < nIn; k++) { d[k] = v[k]! - mu[k]! } let q = 0 for (let a = 0; a < nIn; a++) { let row = 0 for (let b = 0; b < nIn; b++) { row += SigmaInv[a]![b]! * d[b]! } q += d[a]! * row } if (q < best) { best = q } } return -best } // MARK: Score both splits report?.("scoring in-map test + off-map heldout…") const SCORES: ScoreKey[] = ["maxprob", "p_inmap", "energy", "maxlogit", "maha"] /** * All open-set scores for one raw string + whether argmax-in-map routes to `trueCountry`. */ function scoreRow(raw: string, trueCountry: string | undefined): ScoredRow { const z = logits(raw) const probs = softmax(z) const zin = inVec(z) // argmax over in-map classes (the FIXED routing). let amIdx = 0, am = -Infinity for (let k = 0; k < nIn; k++) if (zin[k]! > am) { am = zin[k]! amIdx = k } const routedCountry = COARSE_CLASSES[IN[amIdx]!]! const inmapProbMax = Math.max(...IN.map((c) => probs[c]!)) return { correctRoute: trueCountry !== undefined && routedCountry === trueCountry, s: { maxprob: inmapProbMax, p_inmap: 1 - probs[OTHER]!, energy: logsumexp(zin), maxlogit: Math.max(...zin), maha: mahaScore(z), }, } } const inmapTest = (await load("test.jsonl")).filter((r) => r.country !== "OTHER") // the 11 countries only const heldout = (await load("test-latin-offmap.jsonl")).filter((r) => r.group === "heldout") const inmapScored = inmapTest.map((r) => scoreRow(r.raw, r.country)) const heldoutScored = heldout.map((r) => scoreRow(r.raw, undefined)) // Honest threshold protocol: split each probe 50/50 (deterministic by index parity) into DEV + TEST. // The operating threshold is picked on DEV (maximizing balanced min); the reported point is frozen on // TEST — so the number is a generalization estimate, not a threshold fit to the set it's scored on. const inDev = inmapScored.filter((_, i) => i % 2 === 0) const inTest = inmapScored.filter((_, i) => i % 2 === 1) const heldDev = heldoutScored.filter((_, i) => i % 2 === 0) const heldTest = heldoutScored.filter((_, i) => i % 2 === 1) /** * (inMapAcc, heldCaught) at threshold t over a given in-map/heldout split. */ function pointAt(scoreKey: ScoreKey, t: number, inSplit: ScoredRow[], heldSplit: ScoredRow[]): ParetoPoint { let keepCorrect = 0 for (const o of inSplit) if (o.s[scoreKey] >= t && o.correctRoute) { keepCorrect++ } let caught = 0 for (const o of heldSplit) if (o.s[scoreKey] < t) { caught++ } return { t, inMapAcc: (100 * keepCorrect) / inSplit.length, heldCaught: (100 * caught) / heldSplit.length } } // For each score: KEEP (route in-map) iff score >= threshold; else REJECT (→ OTHER). // in-map accuracy = keep & correctRoute. heldout caught = rejected. function paretoFor(scoreKey: ScoreKey) { const inVals = inmapScored.map((o) => ({ v: o.s[scoreKey], ok: o.correctRoute })) const heldVals = heldoutScored.map((o) => o.s[scoreKey]) // Candidate thresholds: quantiles of the union of scores. const all = [...inVals.map((x) => x.v), ...heldVals].toSorted((a, b) => a - b) const ts: number[] = [] for (let q = 0; q <= QUANTILE_SWEEP_STEPS; q++) { ts.push(all[Math.min(all.length - 1, Math.floor((q / 200) * (all.length - 1)))]!) } const uniq = [...new Set(ts)] const nInVals = inVals.length const nHeld = heldVals.length const pts: ParetoPoint[] = uniq.map((t) => { let keepCorrect = 0 for (const x of inVals) if (x.v >= t && x.ok) { keepCorrect++ } let caught = 0 for (const v of heldVals) if (v < t) { caught++ } return { t, inMapAcc: (100 * keepCorrect) / nInVals, heldCaught: (100 * caught) / nHeld } }) // Summaries. let balanced: { val: number; pt: ParetoPoint | null } = { val: -1, pt: null } // max of min(inMapAcc, heldCaught) on the FULL probe let atHeld90: ParetoPoint | null = null // highest inMapAcc with heldCaught >= 90 let atIn90: ParetoPoint | null = null // highest heldCaught with inMapAcc >= 90 for (const p of pts) { const m = Math.min(p.inMapAcc, p.heldCaught) if (m > balanced.val) { balanced = { val: m, pt: p } } if (p.heldCaught >= TARGET_PERCENT && (!atHeld90 || p.inMapAcc > atHeld90.inMapAcc)) { atHeld90 = p } if (p.inMapAcc >= TARGET_PERCENT && (!atIn90 || p.heldCaught > atIn90.heldCaught)) { atIn90 = p } } // HONEST point: pick t* on DEV (max balanced min), freeze + report on TEST. let devBest: { val: number; t: number | null } = { val: -1, t: null } for (const p of pts) { const d = pointAt(scoreKey, p.t, inDev, heldDev) const m = Math.min(d.inMapAcc, d.heldCaught) if (m > devBest.val) { devBest = { val: m, t: p.t } } } const heldoutTestPt = pointAt(scoreKey, devBest.t!, inTest, heldTest) return { balanced, atHeld90, atIn90, devThreshold: devBest.t, honest: heldoutTestPt, pts } } type Pareto = ReturnType const results = Object.fromEntries(SCORES.map((k) => [k, paretoFor(k)])) as Record // MARK: Report. const f = (x: number | null | undefined): string => (x == null ? "—" : x.toFixed(1)) const lines: string[] = [ `# Coarse-placer M2 Phase 1 — post-hoc open-set score comparison (#244)`, "", `_Frozen shipped model (\`${path.basename(modelDir)}\`), NO retrain. In-map test ${inmapTest.length} rows ` + `(11 countries); off-map HELDOUT ${heldout.length} rows (never-trained families: baltic/oceania/middle-east). ` + `Mahalanobis fit on ≤${fitPerClass}/class in-map train logits. The 11-way routing is fixed; each score only ` + `changes the reject decision._`, "", ] function atStr(p: ParetoPoint | null): string { if (!p) return "— (unreachable)" return `in ${f(p.inMapAcc)} / held ${f(p.heldCaught)}` } lines.push(`## Honest dev→test point (threshold picked on dev, frozen on test)`) lines.push("") lines.push(`| score | TEST in-map | TEST held-caught | min | full-probe balanced |`) lines.push(`|---|---:|---:|---:|---:|`) for (const k of SCORES) { const r = results[k] const h = r.honest lines.push( `| \`${k}\` | ${f(h.inMapAcc)} | ${f(h.heldCaught)} | **${f(Math.min(h.inMapAcc, h.heldCaught))}** | ${f(r.balanced.val)} |` ) } lines.push("") lines.push(`## Full-probe corners (the achievable Pareto), per score`) lines.push("") lines.push(`| score | balanced min(in,held) | in-map @ held≥90 | held @ in-map≥90 |`) lines.push(`|---|---:|---:|---:|`) for (const k of SCORES) { const r = results[k] const bal = r.balanced.pt lines.push( `| \`${k}\` | **${f(r.balanced.val)}** (in ${f(bal?.inMapAcc)}, held ${f(bal?.heldCaught)}) | ` + `${atStr(r.atHeld90)} | ${atStr(r.atIn90)} |` ) } lines.push("") // Winner by the HONEST dev→test balanced min (not the full-probe number). const ranked = SCORES.map((k) => ({ k, honestMin: Math.min(results[k].honest.inMapAcc, results[k].honest.heldCaught), })).toSorted((a, b) => b.honestMin - a.honestMin) const winner = ranked[0]! const clears90 = winner.honestMin >= TARGET_PERCENT lines.push(`## Verdict`) lines.push("") lines.push( `Best score (honest dev→test): **\`${winner.k}\`** at min(in-map, heldout) = **${f(winner.honestMin)}** on the frozen test half. ` + (clears90 ? `**Clears 90/90 post-hoc** — wire it into CoarsePlacer as the open-set reject rule; no retrain needed (Phase 2 reject-head unnecessary).` : `Below the 90/90 bar — the best post-hoc score reaches ${f(winner.honestMin)}. Escalate to Phase 2 (explicit binary reject head).`) ) lines.push("") lines.push(`Ranking (honest dev→test min): ${ranked.map((r) => `\`${r.k}\` ${f(r.honestMin)}`).join(" · ")}`) lines.push("") const md = lines.join("\n") console.log(md) if (options.outMd) { writeFileSync(options.outMd, md) report?.(`\n[written] ${options.outMd}`) } return { winner: winner.k, honestMin: winner.honestMin, clears90, markdown: md } }