/**
* @copyright Sister Software
* @license AGPL-3.0
* @author Teffen Ellis, et al.
*
* Measure the #244 coarse-placer's handling of the Latin-script off-map residual (milestone 3). A
* Latin off-map address is HANDLED when the model routes it to OTHER or abstains — anything else
* is a confident mis-placement onto a wrong (trained) country. Reports handled-rate overall, by
* group (indist = held-out rows of trained-OTHER countries; heldout = countries never trained),
* and by source country, plus where the misses land. Run baseline (current model) and the M3
* retrain through this to read the before/after.
*
* Run: `mailwoman placer eval latin-offmap --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, formatPercent } from "#utils"
import { CoarsePlacer, type CoarsePlacerMeta, type CoarsePrediction } from "../coarse-placer.ts"
/**
* Samples a bucket needs before its off-map rate is reported rather than folded into the tail.
*/
const MIN_REPORTABLE_SAMPLES = 8
interface OffMapRow {
raw: string
country: string
group: string
srcCountry: string
}
/**
* Options for {@linkcode evalLatinOffmap}.
*/
export interface EvalLatinOffmapOptions {
/**
* Model artifact dir. Default `$MAILWOMAN_DATA_ROOT/coarse-placer/model`.
*/
model?: string
/**
* Abstention threshold. Default 0.5.
*/
abstain?: number
/**
* Dataset dir (`test-latin-offmap.jsonl`). Default `/data/coarse-placer`.
*/
data?: string
}
/**
* Result of {@linkcode evalLatinOffmap}.
*/
export interface EvalLatinOffmapResult {
n: number
handled: number
}
/**
* Coarse-placer Latin off-map handling eval — see the module doc. Emits the report to stdout.
*/
export async function evalLatinOffmap(options: EvalLatinOffmapOptions = {}): 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 buf = readFileSync(path.join(modelDir, "weights.bin"))
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
let weights: Float32Array
if (meta.quantization === "int8-per-row") {
const int8 = new Int8Array(ab)
const C = meta.classes.length
const dim = meta.featureDim
const scales = meta.scales!
weights = new Float32Array(C * dim)
for (let c = 0; c < C; c++) {
const s = scales[c]!
const base = c * dim
for (let i = 0; i < dim; i++) {
weights[base + i] = int8[base + i]! * s
}
}
} else {
weights = new Float32Array(ab)
}
const placer = new CoarsePlacer({ ...meta, weights }, { abstainBelow: abstain })
const rows = await Array.fromAsync(
JSONSpliterator.fromAsync(path.join(dataDir, "test-latin-offmap.jsonl"))
)
const handled = (p: CoarsePrediction): boolean => p.abstained || p.country === "OTHER"
const by: Record = {} // key → {n, ok}
const missTo: Record = {} // wrong country → count
const bump = (k: string): { n: number; ok: number } => (by[k] ??= { n: 0, ok: 0 })
let n = 0
let ok = 0
const samples: string[] = []
for (const r of rows) {
const p = placer.predict(r.raw)
const h = handled(p)
n++
if (h) {
ok++
}
bump(`group:${r.group}`).n++
bump(`cc:${r.srcCountry}`).n++
if (h) {
bump(`group:${r.group}`).ok++
bump(`cc:${r.srcCountry}`).ok++
} else {
missTo[p.country!] = (missTo[p.country!] ?? 0) + 1
if (samples.length < MIN_REPORTABLE_SAMPLES) {
samples.push(` ${r.srcCountry} → ${p.country} @${p.confidence.toFixed(2)} «${r.raw.slice(0, 38)}»`)
}
}
}
console.log(`Latin off-map handling — model ${path.basename(modelDir)} (abstain ${abstain}, n=${n})`)
console.log(` OVERALL handled (OTHER-or-abstain): ${ok}/${n} (${formatPercent(ok, n)}) ← want ≥90%`)
console.log(` by group:`)
for (const k of Object.keys(by)
.filter((key) => key.startsWith("group:"))
.toSorted()) {
console.log(` ${k.slice(6).padEnd(8)} ${formatPercent(by[k]!.ok, by[k]!.n)} (n=${by[k]!.n})`)
}
console.log(` by source country:`)
for (const k of Object.keys(by)
.filter((key) => key.startsWith("cc:"))
.toSorted()) {
console.log(` ${k.slice(3).padEnd(4)} ${formatPercent(by[k]!.ok, by[k]!.n)} (n=${by[k]!.n})`)
}
const misses = Object.entries(missTo).toSorted((a, b) => b[1] - a[1])
if (misses.length) {
console.log(` misses land on: ${misses.map(([c, m]) => `${c}:${m}`).join(", ")}`)
}
if (samples.length) {
console.log(` sample misplacements:\n${samples.join("\n")}`)
}
return { n, handled: ok }
}