mailwoman 7.4.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -0
  2. package/api-engine.ts +4 -1
  3. package/commands/corpus/shard/index.tsx +14 -0
  4. package/commands/doctor.tsx +120 -0
  5. package/commands/eval/invariance.tsx +64 -0
  6. package/commands/gazetteer/pair-index.tsx +237 -0
  7. package/commands/release/hf.tsx +6 -0
  8. package/doctor/checks.ts +363 -0
  9. package/doctor/runner.ts +277 -0
  10. package/eval-harness/gauntlet/cases/regression.ts +1 -1
  11. package/eval-harness/invariance/command.ts +59 -0
  12. package/eval-harness/invariance/compare.ts +106 -0
  13. package/eval-harness/invariance/runner.ts +375 -0
  14. package/eval-harness/invariance/transforms.ts +322 -0
  15. package/gazetteer-pipeline/admin/index.ts +3 -15
  16. package/gazetteer-pipeline/pair-index.ts +196 -0
  17. package/out/api-engine.d.ts.map +1 -1
  18. package/out/api-engine.js +4 -1
  19. package/out/api-engine.js.map +1 -1
  20. package/out/commands/corpus/shard/index.d.ts +3 -0
  21. package/out/commands/corpus/shard/index.d.ts.map +1 -1
  22. package/out/commands/corpus/shard/index.js +12 -0
  23. package/out/commands/corpus/shard/index.js.map +1 -1
  24. package/out/commands/doctor.d.ts +28 -0
  25. package/out/commands/doctor.d.ts.map +1 -0
  26. package/out/commands/doctor.js +80 -0
  27. package/out/commands/doctor.js.map +1 -0
  28. package/out/commands/eval/invariance.d.ts +34 -0
  29. package/out/commands/eval/invariance.d.ts.map +1 -0
  30. package/out/commands/eval/invariance.js +52 -0
  31. package/out/commands/eval/invariance.js.map +1 -0
  32. package/out/commands/gazetteer/pair-index.d.ts +39 -0
  33. package/out/commands/gazetteer/pair-index.d.ts.map +1 -0
  34. package/out/commands/gazetteer/pair-index.js +189 -0
  35. package/out/commands/gazetteer/pair-index.js.map +1 -0
  36. package/out/commands/release/hf.d.ts +1 -0
  37. package/out/commands/release/hf.d.ts.map +1 -1
  38. package/out/commands/release/hf.js +4 -0
  39. package/out/commands/release/hf.js.map +1 -1
  40. package/out/doctor/checks.d.ts +159 -0
  41. package/out/doctor/checks.d.ts.map +1 -0
  42. package/out/doctor/checks.js +216 -0
  43. package/out/doctor/checks.js.map +1 -0
  44. package/out/doctor/runner.d.ts +74 -0
  45. package/out/doctor/runner.d.ts.map +1 -0
  46. package/out/doctor/runner.js +192 -0
  47. package/out/doctor/runner.js.map +1 -0
  48. package/out/eval-harness/gauntlet/cases/regression.js +1 -1
  49. package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
  50. package/out/eval-harness/invariance/command.d.ts +29 -0
  51. package/out/eval-harness/invariance/command.d.ts.map +1 -0
  52. package/out/eval-harness/invariance/command.js +36 -0
  53. package/out/eval-harness/invariance/command.js.map +1 -0
  54. package/out/eval-harness/invariance/compare.d.ts +42 -0
  55. package/out/eval-harness/invariance/compare.d.ts.map +1 -0
  56. package/out/eval-harness/invariance/compare.js +82 -0
  57. package/out/eval-harness/invariance/compare.js.map +1 -0
  58. package/out/eval-harness/invariance/runner.d.ts +96 -0
  59. package/out/eval-harness/invariance/runner.d.ts.map +1 -0
  60. package/out/eval-harness/invariance/runner.js +247 -0
  61. package/out/eval-harness/invariance/runner.js.map +1 -0
  62. package/out/eval-harness/invariance/transforms.d.ts +35 -0
  63. package/out/eval-harness/invariance/transforms.d.ts.map +1 -0
  64. package/out/eval-harness/invariance/transforms.js +273 -0
  65. package/out/eval-harness/invariance/transforms.js.map +1 -0
  66. package/out/gazetteer-pipeline/admin/index.d.ts.map +1 -1
  67. package/out/gazetteer-pipeline/admin/index.js +3 -12
  68. package/out/gazetteer-pipeline/admin/index.js.map +1 -1
  69. package/out/gazetteer-pipeline/pair-index.d.ts +94 -0
  70. package/out/gazetteer-pipeline/pair-index.d.ts.map +1 -0
  71. package/out/gazetteer-pipeline/pair-index.js +129 -0
  72. package/out/gazetteer-pipeline/pair-index.js.map +1 -0
  73. package/out/release-tools/publish-hf.d.ts +3 -0
  74. package/out/release-tools/publish-hf.d.ts.map +1 -1
  75. package/out/release-tools/publish-hf.js +23 -0
  76. package/out/release-tools/publish-hf.js.map +1 -1
  77. package/package.json +25 -25
  78. package/release-tools/publish-hf.ts +27 -0
package/README.md CHANGED
@@ -76,6 +76,10 @@ mailwoman registry --sources config.json --out entities.geojson
76
76
 
77
77
  # Interactive TUI
78
78
  mailwoman parse --tui
79
+
80
+ # Diagnose the install — what works, what's missing, and the one command to fix each gap
81
+ mailwoman doctor # human-readable checklist (exit 1 if a core check fails)
82
+ mailwoman doctor --json # machine-readable { checks: [{ id, label, status, detail, fix?, core }], exitCode }
79
83
  ```
80
84
 
81
85
  ## Library API
package/api-engine.ts CHANGED
@@ -88,7 +88,10 @@ function buildPreflightMessage(): string {
88
88
  ` curl -fSL https://public.sister.software/mailwoman/gazetteer/2026-07-07a/candidate.db \\`,
89
89
  ` -o ${conventionCandidate}`,
90
90
  "",
91
- " Then re-run `serve` (the file is auto-detected at that path), or point at your own:",
91
+ " Then point `$MAILWOMAN_CANDIDATE_DB` at that file and re-run `serve`:",
92
+ ` export MAILWOMAN_CANDIDATE_DB=${conventionCandidate}`,
93
+ "",
94
+ " Or point at your own gazetteer:",
92
95
  " $MAILWOMAN_WOF_DB=<path1,path2,...> (admin WOF SQLite shard(s) — the FTS backend)",
93
96
  " $MAILWOMAN_CANDIDATE_DB=<path> (candidate-table gazetteer — population-first, demo-parity)",
94
97
  "",
@@ -43,9 +43,20 @@ const OptionsSchema = zod.object({
43
43
  edgesDir: zod.string().optional().describe("intersection: TIGER EDGES dir"),
44
44
  country: zod.string().optional().describe("locale: target country"),
45
45
  intlFraction: zod.string().optional().describe("german/locale: international-order fraction"),
46
+ countryFraction: zod
47
+ .string()
48
+ .optional()
49
+ .describe("locale: fraction of rows that append an explicit country surface form. Default 0"),
50
+ districtAsLocality: zod
51
+ .boolean()
52
+ .optional()
53
+ .describe(
54
+ "locale: override the per-part districtAsLocality mapping (--no-district-as-locality to force off). Unset leaves each part's own value untouched"
55
+ ),
46
56
  bareProb: zod.string().optional().describe("street-bare: P(bare street)"),
47
57
  hnProb: zod.string().optional().describe("street-bare: P(house number)"),
48
58
  communes: zod.string().optional().describe("fr-admin-split: communes source"),
59
+ banDir: zod.string().optional().describe("fr-lieudit: BAN adresses-<dept>.csv directory"),
49
60
  excludeSurfaces: zod
50
61
  .string()
51
62
  .optional()
@@ -97,9 +108,12 @@ const CorpusShard: CommandComponent<typeof OptionsSchema, typeof ArgumentsSchema
97
108
  edgesDir: options.edgesDir,
98
109
  country: options.country,
99
110
  intlFraction: num(options.intlFraction),
111
+ countryFraction: num(options.countryFraction),
112
+ districtAsLocality: options.districtAsLocality,
100
113
  bareProb: num(options.bareProb),
101
114
  hnProb: num(options.hnProb),
102
115
  communes: options.communes,
116
+ banDir: options.banDir,
103
117
  excludeSurfaces: options.excludeSurfaces,
104
118
  multilocaleCount: num(options.multilocaleCount),
105
119
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman doctor [--json]` — the out-of-box diagnostic. A consumer who just ran `npm i mailwoman`
7
+ * runs this and learns exactly what works, what's missing, and the ONE command that fixes each gap.
8
+ * Six checks: model weights, the fr-fr locale overlay (informational), the data root, the admin
9
+ * gazetteer, the POI layer, and the Node + ONNX runtime.
10
+ *
11
+ * Exit-code contract (meaning-of-zero, per `checks.ts`):
12
+ *
13
+ * - 0 when every CORE check (weights + runtime) is `ok` — parse is ready, even with no data layers.
14
+ * - 1 when any core check is not `ok`.
15
+ *
16
+ * Data layers (data root, gazetteer, POI) are OPTIONAL: a gap is reported as missing/degraded with a
17
+ * fix hint, never a hard failure — parse runs without them. All verdict logic is pure and unit-tested
18
+ * in `doctor/checks.ts`; the IO seams live in `doctor/runner.ts`.
19
+ */
20
+
21
+ import { Box, Text } from "ink"
22
+ import type * as React from "react"
23
+ import zod from "zod"
24
+
25
+ import { type CommandComponent, useCommandTask } from "../cli-kit/index.ts"
26
+ import { CheckStatus, type DoctorCheck, type DoctorReport } from "../doctor/checks.ts"
27
+ import { runDoctor } from "../doctor/runner.ts"
28
+
29
+ const OptionsSchema = zod.object({
30
+ json: zod
31
+ .boolean()
32
+ .optional()
33
+ .default(false)
34
+ .describe(
35
+ "Emit the report as JSON instead of a checklist: { checks: [{ id, label, status, detail, fix?, core }], exitCode } " +
36
+ "— a superset of { id, status, detail, fix? } (label + core aid machine consumers)."
37
+ ),
38
+ })
39
+
40
+ export { OptionsSchema as options }
41
+
42
+ /** The status glyph + ink color for a check outcome. */
43
+ function statusGlyph(status: CheckStatus): { glyph: string; color: string } {
44
+ switch (status) {
45
+ case CheckStatus.OK:
46
+ return { glyph: "✓", color: "green" }
47
+ case CheckStatus.Degraded:
48
+ return { glyph: "⚠", color: "yellow" }
49
+ case CheckStatus.Missing:
50
+ return { glyph: "✗", color: "red" }
51
+ }
52
+ }
53
+
54
+ /** One check row: the glyph + label + detail, plus an indented `fix:` line and an `(optional)` tag for non-core gaps. */
55
+ function CheckRow({ check }: { check: DoctorCheck }): React.ReactElement {
56
+ const { glyph, color } = statusGlyph(check.status)
57
+ const optional = !check.core && check.status !== CheckStatus.OK ? " (optional)" : ""
58
+
59
+ return (
60
+ <Box flexDirection="column">
61
+ <Text color={color}>
62
+ {glyph} {check.label}: {check.detail}
63
+ {optional}
64
+ </Text>
65
+ {check.fix ? <Text color="gray">{` fix: ${check.fix}`}</Text> : null}
66
+ </Box>
67
+ )
68
+ }
69
+
70
+ /** The rendered checklist + PASS/FAIL summary. */
71
+ function Report({ report }: { report: DoctorReport }): React.ReactElement {
72
+ const pass = report.exitCode === 0
73
+
74
+ return (
75
+ <Box flexDirection="column">
76
+ <Text bold>mailwoman doctor</Text>
77
+ <Text> </Text>
78
+ {report.checks.map((check) => (
79
+ <CheckRow key={check.id} check={check} />
80
+ ))}
81
+ <Text> </Text>
82
+ <Text color={pass ? "green" : "red"} bold>
83
+ {pass ? "PASS — core checks ok (weights + runtime); parse is ready" : "FAIL — a core check is not ok"}
84
+ </Text>
85
+ </Box>
86
+ )
87
+ }
88
+
89
+ const DoctorCommand: CommandComponent<typeof OptionsSchema> = ({ options }) => {
90
+ // `--json` writes the report straight to stdout via `console.log` (mirroring `mailwoman openapi`) and
91
+ // renders NOTHING through Ink — Ink's `<Text>` hard-wraps to the terminal width, which would inject
92
+ // newlines mid-string and corrupt the JSON when piped. The checklist form renders through Ink normally.
93
+ const state = useCommandTask<DoctorReport>(
94
+ async () => {
95
+ const report = await runDoctor()
96
+
97
+ if (options.json) {
98
+ console.log(JSON.stringify(report, null, 2))
99
+ }
100
+
101
+ return report
102
+ },
103
+ (report) => report.exitCode
104
+ )
105
+
106
+ if (state.status === "error") {
107
+ return <Text color="red">{state.message}</Text>
108
+ }
109
+
110
+ // JSON payload already emitted in the task; give Ink nothing to draw (transient frames would pollute stdout).
111
+ if (options.json) return null
112
+
113
+ if (state.status !== "done") {
114
+ return <Text color="gray">running diagnostics…</Text>
115
+ }
116
+
117
+ return <Report report={state.result} />
118
+ }
119
+
120
+ export default DoctorCommand
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman eval invariance` — the INVARIANCE MINI-SUITE: a standing, seconds-cheap
7
+ * metamorphic-invariance check (comma-drop / abbreviation-swap / case-fold / lowercase /
8
+ * whitespace-jitter / trailing-punct / idempotence) over `eval-harness/invariance/suite.jsonl`, meant
9
+ * to run in EVERY probe grade so distribution-shift collateral surfaces at 2k-probe cost instead of
10
+ * ship-prep cost. Compares decoded PARSE COMPONENTS only (no resolver) — cheap by construction. Exit
11
+ * nonzero on any LOST pair, or when the DEGRADED count exceeds `--max-degraded`. `--baseline` switches
12
+ * to regression mode: a violation the baseline ALSO exhibits on the same pair is reported but doesn't
13
+ * fail the gate — the shape probe grading uses to diff a candidate against v385.
14
+ */
15
+
16
+ import { Text } from "ink"
17
+ import zod from "zod"
18
+
19
+ import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
20
+ import { runInvarianceCommand } from "../../eval-harness/invariance/command.ts"
21
+
22
+ export const description =
23
+ "Metamorphic invariance mini-suite (comma-drop/abbrev/case/idempotence) — standing probe guard"
24
+
25
+ const OptionsSchema = zod.object({
26
+ suite: zod.string().optional().describe("Alternate suite.jsonl path (default: the shipped fixture)"),
27
+ model: zod.string().optional().describe("Candidate ONNX (requires --tokenizer + --model-card)"),
28
+ tokenizer: zod.string().optional().describe("Candidate tokenizer path"),
29
+ modelCard: zod.string().optional().describe("Candidate model-card path"),
30
+ weightsCache: zod
31
+ .string()
32
+ .optional()
33
+ .describe(
34
+ "Package-shaped candidate weights dir (<root>/node_modules/@mailwoman/neural-weights-<locale>) — #718-safe, alternative to --model"
35
+ ),
36
+ locale: zod.string().optional().describe("Locale tag for weights-package resolution (default en-US)"),
37
+ maxDegraded: zod.number().optional().describe("Fail if the NEW-violation DEGRADED count exceeds this (default 0)"),
38
+ baseline: zod
39
+ .string()
40
+ .optional()
41
+ .describe("Baseline ONNX for regression mode (requires --baseline-tokenizer + --baseline-model-card)"),
42
+ baselineTokenizer: zod.string().optional().describe("Baseline tokenizer path"),
43
+ baselineModelCard: zod.string().optional().describe("Baseline model-card path"),
44
+ baselineWeightsCache: zod
45
+ .string()
46
+ .optional()
47
+ .describe("Package-shaped baseline weights dir — alternative to --baseline + the two flags above"),
48
+ })
49
+
50
+ export { OptionsSchema as options }
51
+
52
+ const EvalInvariance: CommandComponent<typeof OptionsSchema> = ({ options }) => {
53
+ const state = useCommandTask(
54
+ () => runInvarianceCommand(options),
55
+ (exitCode) => exitCode
56
+ )
57
+
58
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
59
+
60
+ // The runner narrates its own report + verdict lines — rendering anything here would duplicate it.
61
+ return null
62
+ }
63
+
64
+ export default EvalInvariance
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman gazetteer pair-index` — build the PIX1 placetype-pair index (placetype-pair-prior
7
+ * arc, Task 3) from the HM Land Registry PPD tuples CSV (`corpus/src/tools/fetch/ppd.ts`'s
8
+ * `gb-tuples.csv`; columns `NUMBER,STREET,CITY,DISTRICT,REGION,POSTCODE`). Streams the CSV
9
+ * (CSVSpliterator, the `corpus/src/shard-recipes/locale.ts` `readTuples` idiom), folds
10
+ * child=CITY/parent=DISTRICT through `normalizeFSTToken` and tags every pair `dependent_locality`
11
+ * (`PairIndexBuilder`, `gazetteer-pipeline/pair-index.ts` — the extracted, unit-tested fold/dedupe/
12
+ * skip logic), then writes `pair-index-<country>.bin` via `serializePairIndex`.
13
+ *
14
+ * `--delta` is REQUIRED with no default: it's the soft-prior bias magnitude a probe hit will
15
+ * contribute at decode time, and the calibration task (not this one) owns the real value — a
16
+ * silent default here would let an uncalibrated number ship unnoticed.
17
+ *
18
+ * Self-verifying (the sealed-artifact spirit — see AGENTS.md's database section, which this
19
+ * mirrors for a flat binary): after writing, the command re-reads the bytes through a fresh
20
+ * `PairIndexResolver` and probes a few known (child, parent) pairs, printing `PROBE OK`/`PROBE
21
+ * MISS` lines rather than trusting the write silently succeeded.
22
+ *
23
+ * Also prints the raw (pre-fold) CITY word-length distribution (p50/p90/p99/max + a per-length
24
+ * count table) — this sizes the word-span window the decode-side prior (Task 4) walks.
25
+ */
26
+
27
+ import { createReadStream, existsSync, writeFileSync } from "node:fs"
28
+ import { join } from "node:path"
29
+
30
+ import { dataRootPath, md5File } from "@mailwoman/core/utils"
31
+ // @mailwoman/neural's fst-prior and pair-index-resolver subpaths are self-contained (fst-prior only
32
+ // type-imports from a sibling module; pair-index-resolver only imports core/types) — safe value
33
+ // imports at module level, no heavy ONNX runtime pulled in (mirrors postcode-binary.tsx's comment).
34
+ import { normalizeFSTToken } from "@mailwoman/neural/fst-prior"
35
+ import { PairIndexResolver, serializePairIndex, type PairIndexHeader } from "@mailwoman/neural/pair-index-resolver"
36
+ import { Box, Text } from "ink"
37
+ import { CSVSpliterator } from "spliterator"
38
+ import zod from "zod"
39
+
40
+ import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
41
+ import { PairIndexBuilder, applyPairIndexHoldout } from "../../gazetteer-pipeline/pair-index.ts"
42
+
43
+ /**
44
+ * The GB source's adjudicated production distinct-pair count — the cross-check this build must reproduce. Re-anchored
45
+ * (final-review fix) from the rung-3 census's raw 19,431 lines (`scratchpad/gb-probe-grade/census-gb-pairs.jsonl`) down
46
+ * to 19,209. This is not a bug fix — a real Task-3 cross-check rebuild fired the gate at exactly this delta and it was
47
+ * adjudicated as the production fold correctly MERGING punctuation-variant duplicates the raw census counted separately
48
+ * (e.g. "St Helens" vs "St. Helens" fold to the SAME `(child, parent)` key). The collision receipt: 221 merge groups —
49
+ * 220 groups where 2 raw census lines collapse to 1 production entry (220 × 1 collapsed line = 220) plus 1 group where
50
+ * 3 raw lines collapse to 1 entry (1 × 2 collapsed lines = 2) — 220 + 2 = 222 raw lines absorbed; 19,431 − 222 =
51
+ * 19,209. See `.superpowers/sdd/progress.md`'s Task 3 entry for the adjudication record. A mismatch AGAINST 19,209 on a
52
+ * real rebuild means this build's fold diverged from the adjudicated baseline, not that 19,209 is wrong; investigate
53
+ * before trusting the artifact.
54
+ */
55
+ const EXPECTED_GB_PAIR_COUNT = 19_209
56
+
57
+ /**
58
+ * The raw rung-3 census's pre-fold line count (`scratchpad/gb-probe-grade/census-gb-pairs.jsonl`) — retained as a named
59
+ * constant for provenance/debugging (e.g. diffing a future source refresh against this cycle's raw count), NOT the
60
+ * cross-check target. See {@link EXPECTED_GB_PAIR_COUNT}'s doc comment for why the production target is lower.
61
+ */
62
+ const RUNG3_PRE_FOLD_CENSUS_LINE_COUNT = 19_431
63
+
64
+ /** Known (child, parent) pairs from the rung-3 GB census, probed after write as a self-check. */
65
+ const GB_PROBE_PAIRS: ReadonlyArray<readonly [city: string, district: string]> = [
66
+ ["Fishburn", "Stockton-on-Tees"],
67
+ ["Shoreditch", "London"],
68
+ ["Sedgefield", "Stockton-on-Tees"],
69
+ ]
70
+
71
+ const OptionsSchema = zod.object({
72
+ out: zod.string().default("docs/static/mailwoman").describe("Output dir for pair-index-<country>.bin"),
73
+ country: zod.string().default("gb").describe("ISO country code this shard is built for"),
74
+ source: zod.string().optional().describe("Source PPD tuples CSV. Default <data-root>/ppd/2026-07-22/gb-tuples.csv"),
75
+ delta: zod
76
+ .number()
77
+ .describe(
78
+ "REQUIRED, no default — the soft-prior bias magnitude a probe hit contributes at decode time. " +
79
+ "The calibration task supplies the real value; this command refuses to default it silently."
80
+ ),
81
+ holdoutFraction: zod
82
+ .number()
83
+ .min(0)
84
+ .max(1)
85
+ .default(0)
86
+ .describe(
87
+ "DEV-ONLY falsifier flag (placetype-pair-prior arc, Task 6) — withhold this fraction of deduplicated pairs " +
88
+ "from the build (seeded, deterministic; see --holdout-seed), for measuring decode-layer degradation " +
89
+ "against pairs the index was never trained/built on. Default 0 = a normal, complete build. NEVER pass a " +
90
+ "nonzero value for a shipped artifact build."
91
+ ),
92
+ holdoutSeed: zod
93
+ .number()
94
+ .default(42)
95
+ .describe("Seed for --holdout-fraction's deterministic withholding. Ignored when --holdout-fraction is 0."),
96
+ })
97
+
98
+ export { OptionsSchema as options }
99
+
100
+ const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options }) => {
101
+ const state = useCommandTask(async () => {
102
+ const country = options.country.toLowerCase()
103
+ const sourcePath = options.source ?? dataRootPath("ppd", "2026-07-22", "gb-tuples.csv")
104
+
105
+ if (!existsSync(sourcePath)) {
106
+ throw new Error(`pair-index: source CSV not found: ${sourcePath}`)
107
+ }
108
+
109
+ const builder = new PairIndexBuilder()
110
+ let header: string[] | null = null
111
+ let cityIx = -1
112
+ let districtIx = -1
113
+
114
+ // Same CSVSpliterator idiom as `corpus/src/shard-recipes/locale.ts`'s `readTuples`: array mode, no header
115
+ // row consumed by the parser (`header: false`), so we build the column index off the first yielded row
116
+ // ourselves and skip forward from there.
117
+ for await (const cells of CSVSpliterator.fromAsync<string[]>(createReadStream(sourcePath), {
118
+ mode: "array",
119
+ header: false,
120
+ enableQuoteHandling: true,
121
+ })) {
122
+ if (header === null) {
123
+ header = cells.map((h) => h.trim().toUpperCase())
124
+ cityIx = header.indexOf("CITY")
125
+ districtIx = header.indexOf("DISTRICT")
126
+
127
+ if (cityIx < 0 || districtIx < 0) {
128
+ throw new Error(`pair-index: source header is missing CITY/DISTRICT: ${header.join(",")}`)
129
+ }
130
+
131
+ continue
132
+ }
133
+
134
+ builder.addRow(cells[cityIx] ?? "", cells[districtIx] ?? "")
135
+ }
136
+
137
+ const built = builder.finish()
138
+ const { rowsKept, rowsSkipped, distribution } = built
139
+
140
+ // Task 6 falsifier-board dev flag: withhold a deterministic fraction of pairs from the build so a
141
+ // downstream eval can measure decode-layer degradation on pairs the index never saw. A no-op
142
+ // (`entries === built.entries` by value) when --holdout-fraction is the 0 default.
143
+ const { kept: entries, heldOut } = applyPairIndexHoldout(
144
+ built.entries,
145
+ options.holdoutFraction,
146
+ options.holdoutSeed
147
+ )
148
+ const sourceMD5 = await md5File(sourcePath)
149
+
150
+ const pairIndexHeader: PairIndexHeader = {
151
+ country,
152
+ delta: options.delta,
153
+ schemaVersion: 1,
154
+ foldVersion: 1,
155
+ sourceMD5s: [sourceMD5],
156
+ buildDate: new Date().toISOString(),
157
+ }
158
+
159
+ const bytes = serializePairIndex(pairIndexHeader, entries)
160
+ const outPath = join(options.out, `pair-index-${country}.bin`)
161
+
162
+ writeFileSync(outPath, bytes)
163
+
164
+ // Self-verifying readback: construct a fresh resolver over the bytes we just wrote (not the in-memory
165
+ // `entries`) and probe known pairs, rather than trusting the write silently succeeded.
166
+ const resolver = new PairIndexResolver(bytes)
167
+ const probeLines = GB_PROBE_PAIRS.map(([city, district]) => {
168
+ const child = normalizeFSTToken(city)
169
+ const parent = normalizeFSTToken(district)
170
+ const tag = resolver.probe(child, parent)
171
+
172
+ return tag
173
+ ? `PROBE OK: fold("${city}")/fold("${district}") → "${child}"/"${parent}" → ${tag}`
174
+ : `PROBE MISS: fold("${city}")/fold("${district}") → "${child}"/"${parent}" → (no entry)`
175
+ })
176
+
177
+ const distLines = [
178
+ `CITY word-length distribution (raw, pre-fold; n=${distribution.totalRows.toLocaleString()}): ` +
179
+ `p50=${distribution.p50} p90=${distribution.p90} p99=${distribution.p99} max=${distribution.max}`,
180
+ ...distribution.counts.map(
181
+ (b) => ` ${b.words} word${b.words === 1 ? "" : "s"}: ${b.rows.toLocaleString()} rows`
182
+ ),
183
+ ]
184
+
185
+ // The rung-3 cross-check assumes a COMPLETE build — a nonzero --holdout-fraction deliberately produces a
186
+ // smaller `entries.length` by design, so the strict count-match gate is meaningless (and would misreport
187
+ // "BLOCKED") under holdout. Gate against `built.entries.length` (pre-holdout) instead in that case.
188
+ const preHoldoutCount = built.entries.length
189
+ // Pre-fold context suffix — the raw rung-3 census line count, for provenance/debugging (e.g. diffing a
190
+ // future source refresh against this cycle's raw count). Not the cross-check target; see
191
+ // `EXPECTED_GB_PAIR_COUNT`'s doc comment for the 221-group collision receipt that separates the two numbers.
192
+ const preFoldSuffix = ` (pre-fold rung-3 census: ${RUNG3_PRE_FOLD_CENSUS_LINE_COUNT.toLocaleString()} lines)`
193
+ const gateLine =
194
+ country === "gb"
195
+ ? options.holdoutFraction > 0
196
+ ? `(cross-check skipped under --holdout-fraction; ${preHoldoutCount.toLocaleString()} distinct pairs before holdout, expects ${EXPECTED_GB_PAIR_COUNT.toLocaleString()})${preFoldSuffix}`
197
+ : preHoldoutCount === EXPECTED_GB_PAIR_COUNT
198
+ ? `CROSS-CHECK PASS: ${preHoldoutCount.toLocaleString()} distinct pairs (production baseline expects ${EXPECTED_GB_PAIR_COUNT.toLocaleString()})${preFoldSuffix}`
199
+ : `CROSS-CHECK BLOCKED: ${preHoldoutCount.toLocaleString()} distinct pairs != the production baseline's ${EXPECTED_GB_PAIR_COUNT.toLocaleString()} — investigate fold divergence before trusting this artifact${preFoldSuffix}`
200
+ : `(cross-check only registered for gb; ${preHoldoutCount.toLocaleString()} distinct pairs)`
201
+
202
+ const holdoutLine =
203
+ options.holdoutFraction > 0
204
+ ? `HOLDOUT: withheld ${heldOut.length.toLocaleString()}/${preHoldoutCount.toLocaleString()} pairs ` +
205
+ `(fraction=${options.holdoutFraction}, seed=${options.holdoutSeed}) — ${entries.length.toLocaleString()} pairs written`
206
+ : undefined
207
+
208
+ return [
209
+ `pair-index-${country}.bin → ${outPath} (${bytes.length.toLocaleString()} bytes)`,
210
+ `rows kept ${rowsKept.toLocaleString()} / skipped ${rowsSkipped.toLocaleString()} (empty CITY)`,
211
+ `distinct pairs: ${entries.length.toLocaleString()}`,
212
+ ...(holdoutLine ? [holdoutLine] : []),
213
+ gateLine,
214
+ ...distLines,
215
+ ...probeLines,
216
+ ]
217
+ })
218
+
219
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
220
+
221
+ if (state.status === "done") {
222
+ return (
223
+ <Box flexDirection="column">
224
+ {state.result.map((line, i) => (
225
+ <Text key={i} color={i === 0 ? "green" : undefined}>
226
+ {i === 0 ? "✓ " : " "}
227
+ {line}
228
+ </Text>
229
+ ))}
230
+ </Box>
231
+ )
232
+ }
233
+
234
+ return null
235
+ }
236
+
237
+ export default GazetteerPairIndex
@@ -36,6 +36,12 @@ const OptionsSchema = zod.object({
36
36
  modelSize: zod.string().optional().describe("Override the displayed model size (default: derived from --model)"),
37
37
  steps: zod.number().optional().describe("Training steps recorded in releases.json (default 100000)"),
38
38
  postcodes: zod.string().optional().describe("Comma-separated postcode soft-feed binaries (postcode-us.bin,…)"),
39
+ pairIndexes: zod
40
+ .string()
41
+ .optional()
42
+ .describe(
43
+ "Comma-separated placetype-pair-index soft-feed binaries (pair-index-gb.bin,…; placetype-pair-prior arc, Task 8)"
44
+ ),
39
45
  gazetteerLexicon: zod
40
46
  .string()
41
47
  .optional()