/**
* @copyright Sister Software
* @license AGPL-3.0
* @author Teffen Ellis, et al.
*
* Evaluate the #244 coarse-placer: in-distribution accuracy + per-class + calibration (ECE) on the
* held-out test split, AND the abstention story on the multi-script set — off-map scripts
* (Cyrillic, Arabic, Thai, …, none of them in the 11 trained countries) SHOULD draw low
* confidence → abstain, which is the "probably off my loaded map" behavior the design wants.
*
* Run: `mailwoman placer eval in-distribution [--model
] [--abstain 0.5]`
*/
import { readFileSync } from "node:fs"
import * as path from "node:path"
import { JSONSpliterator } from "spliterator"
import { parseJSONStrict } from "#objects"
import { dataRootPath, repoRootPath } from "#utils"
import { CoarsePlacer, type CoarsePlacerMeta, type CoarsePrediction } from "../coarse-placer.ts"
/**
* Confusions below this count are individually uninteresting and are summarised instead.
*/
const MIN_CONFUSION_COUNT = 20
/**
* Off-map misses printed before the list is truncated.
*/
const MAX_LISTED_MISSES = 8
interface TestRow {
raw: string
country: string
}
interface MultiScriptRow {
raw: string
country: string
script: string
}
/**
* Options for {@linkcode evalCoarsePlacer}.
*/
export interface EvalCoarsePlacerOptions {
/**
* Model artifact dir. Default `$MAILWOMAN_DATA_ROOT/coarse-placer/model`.
*/
model?: string
/**
* Abstention threshold. Default 0.5.
*/
abstain?: number
/**
* Dataset dir (`test.jsonl`). Default `/data/coarse-placer`.
*/
data?: string
}
/**
* Result of {@linkcode evalCoarsePlacer}.
*/
export interface EvalCoarsePlacerResult {
n: number
/**
* Overall accuracy in percent.
*/
accuracy: number
/**
* 10-bucket expected calibration error.
*/
ece: number
}
/**
* Coarse-placer in-distribution eval — see the module doc. Emits the report to stdout.
*/
export async function evalCoarsePlacer(options: EvalCoarsePlacerOptions = {}): Promise {
const modelDir = options.model || dataRootPath("coarse-placer", "model")
const abstain = options.abstain ?? 0.5
const dataDir = options.data || repoRootPath("data", "coarse-placer")
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 weights = new Float32Array(
weightBytes.buffer,
weightBytes.byteOffset,
weightBytes.byteLength / Float32Array.BYTES_PER_ELEMENT
)
const placer = new CoarsePlacer({ ...meta, weights }, { abstainBelow: abstain })
// --- In-distribution test: accuracy + per-class + ECE ---
let testN = 0
let correct = 0
const perClass: Record = {} // country → {n, ok}
const confusion: Record> = {} // true → {pred → n}
const buckets = Array.from({ length: 10 }, () => ({ n: 0, ok: 0 }))
// ECE deciles. The split streams: every figure below is an accumulator, so the rows never all need to be resident.
for await (const r of JSONSpliterator.fromAsync(path.join(dataDir, "test.jsonl"))) {
testN++
const p = placer.predict(r.raw)
const pred = p.country ?? "(abstain)"
;(perClass[r.country] ??= { n: 0, ok: 0 }).n++
;(confusion[r.country] ??= {})[pred] = ((confusion[r.country] ??= {})[pred] ?? 0) + 1
const hit = pred === r.country
if (hit) {
correct++
perClass[r.country]!.ok++
}
const b = Math.min(9, Math.floor(p.confidence * 10))
buckets[b]!.n++
if (hit) {
buckets[b]!.ok++
}
}
console.log(`coarse-placer eval — test n=${testN}`)
console.log(` overall accuracy: ${((100 * correct) / testN).toFixed(2)}% (abstain threshold ${abstain})`)
console.log(` per-class recall:`)
for (const c of meta.classes) {
const s = perClass[c]
if (s) {
console.log(` ${c}: ${((100 * s.ok) / s.n).toFixed(1)}% (n=${s.n})`)
}
}
let ece = 0
const N = testN
for (let i = 0; i < 10; i++) {
const bk = buckets[i]!
if (bk.n === 0) continue
const acc = bk.ok / bk.n
const conf = (i + 0.5) / 10
ece += (bk.n / N) * Math.abs(acc - conf)
}
console.log(` ECE (10-bucket): ${ece.toFixed(4)}`)
// Top confusions
const confLines: string[] = []
for (const t of meta.classes) {
for (const [pred, n] of Object.entries(confusion[t] ?? {})) {
if (pred !== t && n >= MIN_CONFUSION_COUNT) {
confLines.push(` ${t}→${pred}: ${n}`)
}
}
}
if (confLines.length) {
console.log(` notable confusions (≥20):`)
console.log(confLines.toSorted().join("\n"))
}
// --- Abstention on the multi-script set (off-map scripts should abstain) ---
const msPath = repoRootPath("data", "eval", "multi-script", "v0.5.0-a0.jsonl")
try {
const TRAINED_SCRIPTS = new Set(["latin", "cjk"]) // the only scripts among the 11 trained countries
// With the OTHER class, an off-map input is HANDLED if it routes to OTHER or abstains — either way
// it's not a confident mis-placement onto a wrong country.
const handled = (p: CoarsePrediction): boolean => p.abstained || p.country === "OTHER"
let msN = 0,
offN = 0,
offOk = 0,
missN = 0,
missOk = 0
const offMiss: string[] = []
for await (const r of JSONSpliterator.fromAsync(msPath)) {
msN++
const p = placer.predict(r.raw)
const offMap = !TRAINED_SCRIPTS.has(r.script)
if (offMap) {
offN++
if (handled(p)) {
offOk++
} else if (offMiss.length < MAX_LISTED_MISSES) {
offMiss.push(
` ${r.script}/${r.country} → ${p.country} @${p.confidence.toFixed(2)} «${r.raw.slice(0, 30)}»`
)
}
} else {
missN++
if (handled(p)) {
missOk++
} // a latin/cjk in-map input mis-routed to OTHER = a false abstention
}
}
console.log(`\nmulti-script off-map handling (n=${msN}):`)
console.log(
` OFF-map scripts (Cyrillic/Arabic/Thai/…) routed to OTHER-or-abstain: ${offOk}/${offN} (${((100 * offOk) / Math.max(1, offN)).toFixed(0)}%) ← want HIGH`
)
console.log(
` ON-map scripts (latin/cjk) wrongly OTHER-or-abstain: ${missOk}/${missN} (${((100 * missOk) / Math.max(1, missN)).toFixed(0)}%) ← want LOW`
)
if (offMiss.length) {
console.log(` off-map still mis-placed (the Latin-off-map residual — needs full off-map addresses, M3):`)
console.log(offMiss.join("\n"))
}
} catch (error) {
console.log(`\n(multi-script set not found at ${msPath}: ${(error as Error).message})`)
}
return { n: testN, accuracy: (100 * correct) / testN, ece }
}