mailwoman 8.4.0 → 8.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.
- package/commands/filer/linkage-eval.tsx +57 -0
- package/commands/gazetteer/build/bdc.tsx +7 -0
- package/commands/gazetteer/census.tsx +190 -0
- package/commands/gazetteer/granularity.tsx +104 -0
- package/commands/gazetteer/pair-index.tsx +179 -24
- package/eval-harness/gauntlet/cases/regression.ts +205 -5
- package/eval-harness/gauntlet/harness.ts +4 -0
- package/eval-harness/gauntlet/regression.ts +7 -1
- package/gazetteer-pipeline/admin/ingest-wof.ts +7 -2
- package/gazetteer-pipeline/borough-pairs.ts +217 -0
- package/gazetteer-pipeline/defaults.ts +7 -1
- package/gazetteer-pipeline/granularity-report.ts +161 -0
- package/gazetteer-pipeline/granularity.ts +300 -0
- package/gazetteer-pipeline/lieudit-pairs.ts +0 -0
- package/gazetteer-pipeline/pair-index.ts +8 -0
- package/gazetteer-pipeline/placetype-census.ts +260 -0
- package/geocode-core.ts +15 -0
- package/out/commands/filer/linkage-eval.d.ts +20 -0
- package/out/commands/filer/linkage-eval.d.ts.map +1 -0
- package/out/commands/filer/linkage-eval.js +40 -0
- package/out/commands/filer/linkage-eval.js.map +1 -0
- package/out/commands/gazetteer/build/bdc.d.ts.map +1 -1
- package/out/commands/gazetteer/build/bdc.js +6 -1
- package/out/commands/gazetteer/build/bdc.js.map +1 -1
- package/out/commands/gazetteer/census.d.ts +35 -0
- package/out/commands/gazetteer/census.d.ts.map +1 -0
- package/out/commands/gazetteer/census.js +137 -0
- package/out/commands/gazetteer/census.js.map +1 -0
- package/out/commands/gazetteer/granularity.d.ts +27 -0
- package/out/commands/gazetteer/granularity.d.ts.map +1 -0
- package/out/commands/gazetteer/granularity.js +78 -0
- package/out/commands/gazetteer/granularity.js.map +1 -0
- package/out/commands/gazetteer/pair-index.d.ts +3 -0
- package/out/commands/gazetteer/pair-index.d.ts.map +1 -1
- package/out/commands/gazetteer/pair-index.js +150 -23
- package/out/commands/gazetteer/pair-index.js.map +1 -1
- package/out/eval-harness/gauntlet/cases/regression.d.ts.map +1 -1
- package/out/eval-harness/gauntlet/cases/regression.js +205 -5
- package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
- package/out/eval-harness/gauntlet/harness.d.ts +2 -0
- package/out/eval-harness/gauntlet/harness.d.ts.map +1 -1
- package/out/eval-harness/gauntlet/harness.js +2 -0
- package/out/eval-harness/gauntlet/harness.js.map +1 -1
- package/out/eval-harness/gauntlet/regression.d.ts.map +1 -1
- package/out/eval-harness/gauntlet/regression.js +7 -1
- package/out/eval-harness/gauntlet/regression.js.map +1 -1
- package/out/gazetteer-pipeline/admin/ingest-wof.d.ts.map +1 -1
- package/out/gazetteer-pipeline/admin/ingest-wof.js +4 -0
- package/out/gazetteer-pipeline/admin/ingest-wof.js.map +1 -1
- package/out/gazetteer-pipeline/borough-pairs.d.ts +39 -0
- package/out/gazetteer-pipeline/borough-pairs.d.ts.map +1 -0
- package/out/gazetteer-pipeline/borough-pairs.js +180 -0
- package/out/gazetteer-pipeline/borough-pairs.js.map +1 -0
- package/out/gazetteer-pipeline/defaults.d.ts +2 -2
- package/out/gazetteer-pipeline/defaults.d.ts.map +1 -1
- package/out/gazetteer-pipeline/defaults.js +7 -1
- package/out/gazetteer-pipeline/defaults.js.map +1 -1
- package/out/gazetteer-pipeline/granularity-report.d.ts +34 -0
- package/out/gazetteer-pipeline/granularity-report.d.ts.map +1 -0
- package/out/gazetteer-pipeline/granularity-report.js +131 -0
- package/out/gazetteer-pipeline/granularity-report.js.map +1 -0
- package/out/gazetteer-pipeline/granularity.d.ts +115 -0
- package/out/gazetteer-pipeline/granularity.d.ts.map +1 -0
- package/out/gazetteer-pipeline/granularity.js +207 -0
- package/out/gazetteer-pipeline/granularity.js.map +1 -0
- package/out/gazetteer-pipeline/lieudit-pairs.d.ts +58 -0
- package/out/gazetteer-pipeline/lieudit-pairs.d.ts.map +1 -0
- package/out/gazetteer-pipeline/lieudit-pairs.js +0 -0
- package/out/gazetteer-pipeline/lieudit-pairs.js.map +1 -0
- package/out/gazetteer-pipeline/pair-index.d.ts +5 -0
- package/out/gazetteer-pipeline/pair-index.d.ts.map +1 -1
- package/out/gazetteer-pipeline/pair-index.js +7 -0
- package/out/gazetteer-pipeline/pair-index.js.map +1 -1
- package/out/gazetteer-pipeline/placetype-census.d.ts +82 -0
- package/out/gazetteer-pipeline/placetype-census.d.ts.map +1 -0
- package/out/gazetteer-pipeline/placetype-census.js +209 -0
- package/out/gazetteer-pipeline/placetype-census.js.map +1 -0
- package/out/geocode-core.d.ts +13 -0
- package/out/geocode-core.d.ts.map +1 -1
- package/out/geocode-core.js +2 -0
- package/out/geocode-core.js.map +1 -1
- package/package.json +25 -25
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `mailwoman filer linkage-eval` — the 3b Task 4 corporate-family recovery eval. Geocode-free,
|
|
7
|
+
* database-free (builds its own scratch `filer.db` artifacts from an embedded synthetic corpus); emits
|
|
8
|
+
* the markdown scorecard to stdout and, when `--out-md` is given, to that path too. `--date` overrides
|
|
9
|
+
* the report's dated H1 so the committed scorecard can be regenerated without editing code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { filerLinkageEval } from "@mailwoman/filer/tools"
|
|
13
|
+
import { Text } from "ink"
|
|
14
|
+
import zod from "zod"
|
|
15
|
+
|
|
16
|
+
import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
|
|
17
|
+
|
|
18
|
+
const OptionsSchema = zod.object({
|
|
19
|
+
outMd: zod.string().optional().describe("Also write the markdown report here"),
|
|
20
|
+
date: zod.string().optional().describe("Report date (YYYY-MM-DD) for the H1 — defaults to today"),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export { OptionsSchema as options }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `null` renders as `N/A`, never as `0.000` — the withheld run makes no positive call, so its precision and F1 are
|
|
27
|
+
* undefined rather than zero (see `linkage-metrics.ts`).
|
|
28
|
+
*/
|
|
29
|
+
function formatScoreValue(value: number | null): string {
|
|
30
|
+
return value === null ? "N/A" : value.toFixed(3)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FilerLinkageEval: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
34
|
+
const state = useCommandTask(() =>
|
|
35
|
+
filerLinkageEval({ outMd: options.outMd, date: options.date }, (line) => console.error(line))
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
|
|
39
|
+
|
|
40
|
+
if (state.status === "done") {
|
|
41
|
+
const { withheld, control } = state.result
|
|
42
|
+
|
|
43
|
+
// Both rows, always: the withheld number is the measurement and the control is what makes it falsifiable.
|
|
44
|
+
return (
|
|
45
|
+
<Text color="green">
|
|
46
|
+
linkage-eval · withheld: recall {formatScoreValue(withheld.score.recall)} ({withheld.score.truePositivePairs}/
|
|
47
|
+
{withheld.score.truthPositivePairs} pairs, F1 {formatScoreValue(withheld.score.f1)}) · control: F1{" "}
|
|
48
|
+
{formatScoreValue(control.score.f1)} ({control.score.truePositivePairs}/{control.score.truthPositivePairs}{" "}
|
|
49
|
+
pairs)
|
|
50
|
+
</Text>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default FilerLinkageEval
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
createBDCClient,
|
|
41
41
|
createTIGERBlockCentroidLookup,
|
|
42
42
|
downloadBDCFile,
|
|
43
|
+
formatBDCThrottleStats,
|
|
43
44
|
resolveLatestVintage,
|
|
44
45
|
retrieveAvailabilityFiles,
|
|
45
46
|
retrieveFilingDates,
|
|
@@ -140,6 +141,12 @@ const GazetteerBuildBDC: CommandComponent<typeof OptionsSchema> = ({ options })
|
|
|
140
141
|
csvPaths.push(await downloadBDCFile(client, file, cacheDir))
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
// The FCC caps this API at ten requests per minute — six seconds a call — so a national run is
|
|
145
|
+
// throttle-bound by construction and the interesting number is how much of the wall clock went to
|
|
146
|
+
// waiting rather than transferring. Printed once the network phase is over, on stderr with the rest
|
|
147
|
+
// of the progress stream, so a rate change can be assessed against a measurement.
|
|
148
|
+
console.error(`▸ ${formatBDCThrottleStats(client.throttleStats())}`)
|
|
149
|
+
|
|
143
150
|
const out = options.out ?? dataRootPath("bdc", "bdc.db")
|
|
144
151
|
const buildSHA = execFileSync("git", ["rev-parse", "--short", "HEAD"]).toString().trim()
|
|
145
152
|
const tigerDBPath = dataRootPath("tiger", "tiger.db")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `mailwoman gazetteer census` — build the PCN1 placetype census (hierarchy campaign R4c) from the
|
|
7
|
+
* shipped WOF admin DB. Counts each locality-class parent's children through the projection table
|
|
8
|
+
* (`gazetteer-pipeline/placetype-census.ts`), folds parent surfaces with the SAME `normalizeFSTToken`
|
|
9
|
+
* the PIX1 pair index uses — so a consumer folds once and probes both artifacts — and writes
|
|
10
|
+
* `placetype-census-<country>.bin`.
|
|
11
|
+
*
|
|
12
|
+
* Fold collisions SUM. Two distinct raw parents that fold together ("St Helens" / "St. Helens") are
|
|
13
|
+
* one census node whose counts are the union of both; the serializer refuses duplicate parents, so a
|
|
14
|
+
* merge bug surfaces as a throw rather than a silently halved count.
|
|
15
|
+
*
|
|
16
|
+
* `--delta` is deliberately OPTIONAL and unset by default, unlike the pair index's required one: R4c
|
|
17
|
+
* ships the census as data + loader + offline probe with NO decode wiring. A calibrated delta is a
|
|
18
|
+
* later rung's output; writing one now would put an unmeasured bias into a shipped artifact.
|
|
19
|
+
*
|
|
20
|
+
* Self-verifying (the sealed-artifact spirit): after writing, the command re-reads its own bytes
|
|
21
|
+
* through a fresh `PlacetypeCensusResolver` and probes known parents, printing PROBE OK/MISS with the
|
|
22
|
+
* node's dependent-locality share and lift rather than trusting the write.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, writeFileSync } from "node:fs"
|
|
26
|
+
import { join } from "node:path"
|
|
27
|
+
|
|
28
|
+
import type { ComponentTag } from "@mailwoman/core/types"
|
|
29
|
+
import { dataRootPath, md5File } from "@mailwoman/core/utils"
|
|
30
|
+
import { normalizeFSTToken } from "@mailwoman/neural/fst-prior"
|
|
31
|
+
import {
|
|
32
|
+
PlacetypeCensusResolver,
|
|
33
|
+
serializePlacetypeCensus,
|
|
34
|
+
type PlacetypeCensusHeader,
|
|
35
|
+
type PlacetypeCensusNode,
|
|
36
|
+
} from "@mailwoman/neural/placetype-census"
|
|
37
|
+
import { Box, Text } from "ink"
|
|
38
|
+
import zod from "zod"
|
|
39
|
+
|
|
40
|
+
import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
|
|
41
|
+
import { buildPlacetypeCensus, toBaseRates } from "../../gazetteer-pipeline/placetype-census.ts"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Known parents probed after write, PER COUNTRY. Probing another country's names against a freshly built census prints
|
|
45
|
+
* reassuring-looking misses that verify nothing (the lesson the pair-index command's en-nz first build taught).
|
|
46
|
+
*/
|
|
47
|
+
const PROBE_PARENTS_BY_COUNTRY: Readonly<Record<string, readonly string[]>> = {
|
|
48
|
+
gb: ["London", "Manchester", "Birmingham"],
|
|
49
|
+
us: ["New York", "Chicago", "Springfield"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const OptionsSchema = zod.object({
|
|
53
|
+
out: zod.string().default("docs/static/mailwoman").describe("Output dir for placetype-census-<country>.bin"),
|
|
54
|
+
country: zod.string().default("gb").describe("ISO country code this census is built for"),
|
|
55
|
+
source: zod.string().optional().describe("WOF admin DB. Default <data-root>/wof/admin-global-priority.db"),
|
|
56
|
+
delta: zod
|
|
57
|
+
.number()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
"OPTIONAL soft-prior magnitude written into the header. Omit (the default) for an un-wired census — R4c " +
|
|
61
|
+
"ships data + loader + offline probe only; a calibration rung supplies the real value."
|
|
62
|
+
),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export { OptionsSchema as options }
|
|
66
|
+
|
|
67
|
+
const GazetteerCensus: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
68
|
+
const state = useCommandTask(async () => {
|
|
69
|
+
const country = options.country.toLowerCase()
|
|
70
|
+
const sourcePath = options.source ?? String(dataRootPath("wof", "admin-global-priority.db"))
|
|
71
|
+
|
|
72
|
+
if (!existsSync(sourcePath)) {
|
|
73
|
+
throw new Error(`census: source WOF admin DB not found: ${sourcePath}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const built = buildPlacetypeCensus(sourcePath, country.toUpperCase())
|
|
77
|
+
|
|
78
|
+
if (built.unmappedPlacetypes.length) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`census: source carries placetypes absent from PLACETYPE_PROJECTION: ${built.unmappedPlacetypes.join(", ")} — ` +
|
|
81
|
+
`extend the projection table (and plan/reference/placetype-evidence.mdx) before building.`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fold, merging collisions by SUMMING counts — the serializer throws on duplicates, so a merge bug is loud.
|
|
86
|
+
const folded = new Map<string, PlacetypeCensusNode>()
|
|
87
|
+
let collisions = 0
|
|
88
|
+
|
|
89
|
+
for (const node of built.nodes) {
|
|
90
|
+
const parent = normalizeFSTToken(node.parent)
|
|
91
|
+
|
|
92
|
+
if (!parent) continue
|
|
93
|
+
|
|
94
|
+
const existing = folded.get(parent)
|
|
95
|
+
|
|
96
|
+
if (!existing) {
|
|
97
|
+
folded.set(parent, { parent, counts: { ...node.counts }, total: node.total })
|
|
98
|
+
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
collisions++
|
|
103
|
+
|
|
104
|
+
for (const [tag, n] of Object.entries(node.counts) as Array<[ComponentTag, number]>) {
|
|
105
|
+
existing.counts[tag] = (existing.counts[tag] ?? 0) + n
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
existing.total += node.total
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const nodes = [...folded.values()]
|
|
112
|
+
const baseRates = toBaseRates(built.countryTotals)
|
|
113
|
+
const sourceMD5 = await md5File(sourcePath)
|
|
114
|
+
|
|
115
|
+
const header: PlacetypeCensusHeader = {
|
|
116
|
+
country,
|
|
117
|
+
schemaVersion: 1,
|
|
118
|
+
foldVersion: 1,
|
|
119
|
+
sourceMD5s: [sourceMD5],
|
|
120
|
+
buildDate: new Date().toISOString(),
|
|
121
|
+
baseRates,
|
|
122
|
+
...(options.delta !== undefined ? { delta: options.delta } : {}),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bytes = serializePlacetypeCensus(header, nodes)
|
|
126
|
+
const outPath = join(options.out, `placetype-census-${country}.bin`)
|
|
127
|
+
|
|
128
|
+
writeFileSync(outPath, bytes)
|
|
129
|
+
|
|
130
|
+
// Self-verifying readback over the bytes just written, not the in-memory nodes.
|
|
131
|
+
const resolver = new PlacetypeCensusResolver(bytes)
|
|
132
|
+
const probeParents = PROBE_PARENTS_BY_COUNTRY[country]
|
|
133
|
+
|
|
134
|
+
if (!probeParents) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`census: no self-check probe parents registered for country "${country}" — add an entry to ` +
|
|
137
|
+
`PROBE_PARENTS_BY_COUNTRY (probing another country's names verifies nothing).`
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const probeLines = probeParents.map((raw) => {
|
|
142
|
+
const parent = normalizeFSTToken(raw)
|
|
143
|
+
const node = resolver.probe(parent)
|
|
144
|
+
|
|
145
|
+
if (!node) return `PROBE MISS: fold("${raw}") → "${parent}" → (no node)`
|
|
146
|
+
|
|
147
|
+
const depLoc = node.counts.dependent_locality ?? 0
|
|
148
|
+
const share = resolver.share(parent, "dependent_locality")
|
|
149
|
+
const lift = resolver.lift(parent, "dependent_locality")
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
`PROBE OK: "${parent}" → ${node.total.toLocaleString()} children, ` +
|
|
153
|
+
`${depLoc.toLocaleString()} dependent_locality (share ${(share * 100).toFixed(1)}%, lift ${lift.toFixed(1)}×)`
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const baseRateLines = (Object.entries(baseRates) as Array<[ComponentTag, number]>)
|
|
158
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
159
|
+
.map(([tag, rate]) => ` ${tag}: ${(rate * 100).toFixed(2)}%`)
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
`placetype-census-${country}.bin → ${outPath} (${bytes.length.toLocaleString()} bytes)`,
|
|
163
|
+
`header: ${options.delta !== undefined ? `delta=${options.delta}` : "(no delta — un-wired census)"}`,
|
|
164
|
+
`nodes: ${nodes.length.toLocaleString()} (${built.nodes.length.toLocaleString()} pre-fold, ${collisions.toLocaleString()} fold collisions merged)`,
|
|
165
|
+
`child links counted: ${built.links.toLocaleString()}`,
|
|
166
|
+
"country base rates:",
|
|
167
|
+
...baseRateLines,
|
|
168
|
+
...probeLines,
|
|
169
|
+
]
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
|
|
173
|
+
|
|
174
|
+
if (state.status === "done") {
|
|
175
|
+
return (
|
|
176
|
+
<Box flexDirection="column">
|
|
177
|
+
{state.result.map((line, i) => (
|
|
178
|
+
<Text key={i} color={i === 0 ? "green" : undefined}>
|
|
179
|
+
{i === 0 ? "✓ " : " "}
|
|
180
|
+
{line}
|
|
181
|
+
</Text>
|
|
182
|
+
))}
|
|
183
|
+
</Box>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default GazetteerCensus
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `mailwoman gazetteer granularity` — the per-country gazetteer depth scorecard.
|
|
7
|
+
*
|
|
8
|
+
* Answers "where does the admin gazetteer bottom out?" for every country it knows, which nobody
|
|
9
|
+
* had measured: the shipped artifact stocks 9 of WOF's 34 placetypes and carries a
|
|
10
|
+
* `dependent_locality` tier in 11 of 244 countries. Read-only, no network, no model — three
|
|
11
|
+
* grouped queries over `spr`/`ancestors` and a markdown render.
|
|
12
|
+
*
|
|
13
|
+
* The report is a COMMITTED artifact (the `fill-rates.md` precedent), so the source md5 and the
|
|
14
|
+
* parent-coverage floor are pinned in its header. Re-running against a rebuilt gazetteer and
|
|
15
|
+
* diffing the report is the intended workflow.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { dirname } from "node:path"
|
|
20
|
+
|
|
21
|
+
import { dataRootPath, md5File } from "@mailwoman/core/utils"
|
|
22
|
+
import { Box, Text } from "ink"
|
|
23
|
+
import zod from "zod"
|
|
24
|
+
|
|
25
|
+
import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
|
|
26
|
+
import { renderGranularityReport } from "../../gazetteer-pipeline/granularity-report.ts"
|
|
27
|
+
import { DEFAULT_COVERAGE_FLOOR, bottomsOutAt, buildGranularityLadder } from "../../gazetteer-pipeline/granularity.ts"
|
|
28
|
+
|
|
29
|
+
const OptionsSchema = zod.object({
|
|
30
|
+
out: zod
|
|
31
|
+
.string()
|
|
32
|
+
.default("docs/articles/evals/coverage/gazetteer-depth-scorecard.md")
|
|
33
|
+
.describe("Output path for the markdown scorecard"),
|
|
34
|
+
source: zod.string().optional().describe("WOF admin DB. Default $MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db"),
|
|
35
|
+
floor: zod
|
|
36
|
+
.number()
|
|
37
|
+
.default(DEFAULT_COVERAGE_FLOOR)
|
|
38
|
+
.describe(
|
|
39
|
+
`Parent-coverage floor for crediting a sub-locality rung. Default ${DEFAULT_COVERAGE_FLOOR} — set low on ` +
|
|
40
|
+
"purpose, to catch thin-but-real tiers rather than certify them."
|
|
41
|
+
),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export { OptionsSchema as options }
|
|
45
|
+
|
|
46
|
+
const GazetteerGranularity: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
47
|
+
const state = useCommandTask(async () => {
|
|
48
|
+
const sourcePath = options.source ?? String(dataRootPath("wof", "admin-global-priority.db"))
|
|
49
|
+
const rows = buildGranularityLadder(sourcePath)
|
|
50
|
+
|
|
51
|
+
if (!rows.length) {
|
|
52
|
+
throw new Error(`granularity: no countries measured from ${sourcePath} — is this an admin DB?`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const markdown = renderGranularityReport(rows, {
|
|
56
|
+
// Display the portable form; never bake the resolved lab path into a committed artifact.
|
|
57
|
+
sourcePath: "$MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db",
|
|
58
|
+
sourceMD5: await md5File(sourcePath),
|
|
59
|
+
buildDate: new Date().toISOString(),
|
|
60
|
+
floor: options.floor,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
mkdirSync(dirname(options.out), { recursive: true })
|
|
64
|
+
writeFileSync(options.out, markdown)
|
|
65
|
+
|
|
66
|
+
const byBottom = new Map<string, number>()
|
|
67
|
+
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const bottom = bottomsOutAt(row, options.floor) ?? "(none)"
|
|
70
|
+
|
|
71
|
+
byBottom.set(bottom, (byBottom.get(bottom) ?? 0) + 1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const distribution = [...byBottom.entries()]
|
|
75
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
76
|
+
.map(([rung, n]) => ` ${rung}: ${n.toLocaleString()} countries`)
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
`gazetteer depth scorecard → ${options.out}`,
|
|
80
|
+
`countries measured: ${rows.length.toLocaleString()} (floor ${(options.floor * 100).toFixed(1)}%)`,
|
|
81
|
+
"bottoms out at:",
|
|
82
|
+
...distribution,
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
|
|
87
|
+
|
|
88
|
+
if (state.status === "done") {
|
|
89
|
+
return (
|
|
90
|
+
<Box flexDirection="column">
|
|
91
|
+
{state.result.map((line, i) => (
|
|
92
|
+
<Text key={i} color={i === 0 ? "green" : undefined}>
|
|
93
|
+
{i === 0 ? "✓ " : " "}
|
|
94
|
+
{line}
|
|
95
|
+
</Text>
|
|
96
|
+
))}
|
|
97
|
+
</Box>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default GazetteerGranularity
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* count table) — this sizes the word-span window the decode-side prior (Task 4) walks.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { createReadStream, existsSync, writeFileSync } from "node:fs"
|
|
27
|
+
import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
28
28
|
import { join } from "node:path"
|
|
29
29
|
|
|
30
30
|
import { dataRootPath, md5File } from "@mailwoman/core/utils"
|
|
@@ -38,6 +38,8 @@ import { CSVSpliterator } from "spliterator"
|
|
|
38
38
|
import zod from "zod"
|
|
39
39
|
|
|
40
40
|
import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
|
|
41
|
+
import { extractBoroughPairs } from "../../gazetteer-pipeline/borough-pairs.ts"
|
|
42
|
+
import { extractLieuDitPairs } from "../../gazetteer-pipeline/lieudit-pairs.ts"
|
|
41
43
|
import { PairIndexBuilder, applyPairIndexHoldout } from "../../gazetteer-pipeline/pair-index.ts"
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -61,6 +63,15 @@ const EXPECTED_GB_PAIR_COUNT = 19_209
|
|
|
61
63
|
*/
|
|
62
64
|
const RUNG3_PRE_FOLD_CENSUS_LINE_COUNT = 19_431
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The US instance's cross-check (hierarchy campaign R5), measured 2026-08-01 against the shipped
|
|
68
|
+
* `admin-global-priority.db`. Unlike GB there is no postal register in the mix — every pair is WOF-sourced (borough +
|
|
69
|
+
* neighbourhood children under locality/localadmin/borough parents, see `PAIR_PLACETYPES_BY_COUNTRY`), so this number
|
|
70
|
+
* tracks the WOF snapshot alone. A snapshot refresh legitimately moves it; re-anchor the constant deliberately, after
|
|
71
|
+
* inspecting the diff, rather than relaxing the gate.
|
|
72
|
+
*/
|
|
73
|
+
const EXPECTED_US_PAIR_COUNT = 47_878
|
|
74
|
+
|
|
64
75
|
/**
|
|
65
76
|
* Known (child, parent) pairs probed after write as a self-check — PER COUNTRY, keyed by the `--country` code. Probing
|
|
66
77
|
* another country's names against a freshly built index prints reassuring-looking `PROBE MISS` lines that verify
|
|
@@ -78,6 +89,53 @@ const PROBE_PAIRS_BY_COUNTRY: Readonly<Record<string, ReadonlyArray<readonly [ci
|
|
|
78
89
|
// The repeated-name convention's identity pair — the (x,x) evidence the segment rule keys on.
|
|
79
90
|
["Mangawhai", "Mangawhai"],
|
|
80
91
|
],
|
|
92
|
+
// R5 (hierarchy campaign): the US instance's probes. Unlike GB/NZ these are NOT postal-format
|
|
93
|
+
// dependent localities — USPS routes city/state/ZIP — they are the borough/neighbourhood class
|
|
94
|
+
// the schema's umbrella term covers, sourced from WOF rather than a postal register.
|
|
95
|
+
us: [
|
|
96
|
+
["Astoria", "Queens"],
|
|
97
|
+
["Park Slope", "Brooklyn"],
|
|
98
|
+
["Manhattan", "New York"],
|
|
99
|
+
],
|
|
100
|
+
// R10: the IN instance. Indian addresses carry an area/locality line above the city
|
|
101
|
+
// ("Indiranagar, Bengaluru"), which is the dependent-locality slot.
|
|
102
|
+
in: [
|
|
103
|
+
["Indiranagar", "Bangalore"],
|
|
104
|
+
["Mulund East", "Mumbai"],
|
|
105
|
+
],
|
|
106
|
+
// R11: the ES + IT instances. Spanish barrios and Italian quartieri under their municipio/comune.
|
|
107
|
+
es: [
|
|
108
|
+
["Aravaca", "Madrid"],
|
|
109
|
+
["Triana", "Sevilla"],
|
|
110
|
+
],
|
|
111
|
+
it: [
|
|
112
|
+
["Barona", "Milano"],
|
|
113
|
+
["Trastevere", "Roma"],
|
|
114
|
+
],
|
|
115
|
+
// R9: the DE instance. Ortsteile/Stadtteile under their Gemeinde — the line German addresses
|
|
116
|
+
// carry when they carry one at all (the Ortsteil sits above "PLZ Stadt", never inside it).
|
|
117
|
+
de: [
|
|
118
|
+
["Nippes", "Köln"],
|
|
119
|
+
["Schwabing", "München"],
|
|
120
|
+
],
|
|
121
|
+
// R6: the FR instance. These are LIEUX-DITS under their communes (BAN `nom_ld`), not quartiers —
|
|
122
|
+
// see gazetteer-pipeline/lieudit-pairs.ts for why the French source differs from the US one.
|
|
123
|
+
fr: [
|
|
124
|
+
["Pinsonnac", "Montpeyroux"],
|
|
125
|
+
["Line", "Salignac-Eyvigues"],
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Split a comma-separated path list, tolerating whitespace and an absent value. Each secondary source stays its OWN
|
|
131
|
+
* file rather than being pre-merged into a blob, so every one keeps a distinct provenance md5 in the header — which is
|
|
132
|
+
* what lets a freshness guard notice that exactly one of them changed.
|
|
133
|
+
*/
|
|
134
|
+
function splitPathList(value: string | undefined): string[] {
|
|
135
|
+
return (value ?? "")
|
|
136
|
+
.split(",")
|
|
137
|
+
.map((path) => path.trim())
|
|
138
|
+
.filter(Boolean)
|
|
81
139
|
}
|
|
82
140
|
|
|
83
141
|
const OptionsSchema = zod.object({
|
|
@@ -110,6 +168,22 @@ const OptionsSchema = zod.object({
|
|
|
110
168
|
"against pairs the index was never trained/built on. Default 0 = a normal, complete build. NEVER pass a " +
|
|
111
169
|
"nonzero value for a shipped artifact build."
|
|
112
170
|
),
|
|
171
|
+
pairsJsonl: zod
|
|
172
|
+
.string()
|
|
173
|
+
.optional()
|
|
174
|
+
.describe(
|
|
175
|
+
"Secondary pairs JSONL ({child, parent} per line) merged through the same fold — hierarchy campaign R3 (ONSPD London wards etc.)"
|
|
176
|
+
),
|
|
177
|
+
boroughDb: zod
|
|
178
|
+
.string()
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("WOF admin DB path — merge borough (child, parent) pairs for the country (hierarchy campaign R2)"),
|
|
181
|
+
banDir: zod
|
|
182
|
+
.string()
|
|
183
|
+
.optional()
|
|
184
|
+
.describe(
|
|
185
|
+
"BAN adresses-<dept>.csv directory — merge FR (lieu-dit, commune) pairs through ban/sdk's cleanLieuDit filter (hierarchy campaign R6)"
|
|
186
|
+
),
|
|
113
187
|
holdoutSeed: zod
|
|
114
188
|
.number()
|
|
115
189
|
.default(42)
|
|
@@ -121,12 +195,23 @@ export { OptionsSchema as options }
|
|
|
121
195
|
const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options }) => {
|
|
122
196
|
const state = useCommandTask(async () => {
|
|
123
197
|
const country = options.country.toLowerCase()
|
|
124
|
-
|
|
198
|
+
// The PPD tuples CSV is a GB national register — there is no equivalent for the US instance (USPS routes
|
|
199
|
+
// city/state/ZIP, so no postal source carries dependent localities). A country whose pairs come entirely from
|
|
200
|
+
// the WOF/secondary sources below runs with NO CSV rather than being handed an empty one.
|
|
201
|
+
const sourcePath =
|
|
202
|
+
options.source ?? (country === "gb" ? String(dataRootPath("ppd", "2026-07-22", "gb-tuples.csv")) : undefined)
|
|
125
203
|
|
|
126
|
-
if (!existsSync(sourcePath)) {
|
|
204
|
+
if (sourcePath && !existsSync(sourcePath)) {
|
|
127
205
|
throw new Error(`pair-index: source CSV not found: ${sourcePath}`)
|
|
128
206
|
}
|
|
129
207
|
|
|
208
|
+
if (!sourcePath && !options.boroughDb && !options.pairsJsonl && !options.banDir) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`pair-index: country "${country}" has no PPD default — pass --source, --borough-db, --pairs-jsonl or --ban-dir, ` +
|
|
211
|
+
`or the build would write an empty index.`
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
130
215
|
const builder = new PairIndexBuilder()
|
|
131
216
|
let header: string[] | null = null
|
|
132
217
|
let cityIx = -1
|
|
@@ -135,24 +220,82 @@ const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options })
|
|
|
135
220
|
// Same CSVSpliterator idiom as `corpus/src/shard-recipes/locale.ts`'s `readTuples`: array mode, no header
|
|
136
221
|
// row consumed by the parser (`header: false`), so we build the column index off the first yielded row
|
|
137
222
|
// ourselves and skip forward from there.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
header
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
223
|
+
if (sourcePath) {
|
|
224
|
+
for await (const cells of CSVSpliterator.fromAsync<string[]>(createReadStream(sourcePath), {
|
|
225
|
+
mode: "array",
|
|
226
|
+
header: false,
|
|
227
|
+
enableQuoteHandling: true,
|
|
228
|
+
})) {
|
|
229
|
+
if (header === null) {
|
|
230
|
+
header = cells.map((h) => h.trim().toUpperCase())
|
|
231
|
+
cityIx = header.indexOf("CITY")
|
|
232
|
+
districtIx = header.indexOf("DISTRICT")
|
|
233
|
+
|
|
234
|
+
if (cityIx < 0 || districtIx < 0) {
|
|
235
|
+
throw new Error(`pair-index: source header is missing CITY/DISTRICT: ${header.join(",")}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
continue
|
|
150
239
|
}
|
|
151
240
|
|
|
152
|
-
|
|
241
|
+
builder.addRow(cells[cityIx] ?? "", cells[districtIx] ?? "")
|
|
153
242
|
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// R2 (hierarchy campaign): borough pairs from the WOF admin DB, through the SAME fold/dedupe
|
|
246
|
+
// as the CSV rows (boroughs project onto dependent_locality — plan/reference/placetype-evidence).
|
|
247
|
+
let boroughsAdded = 0
|
|
248
|
+
|
|
249
|
+
if (options.boroughDb) {
|
|
250
|
+
const before = builder.distinctCount
|
|
251
|
+
|
|
252
|
+
for (const pair of extractBoroughPairs(options.boroughDb, country.toUpperCase())) {
|
|
253
|
+
builder.addRow(pair.child, pair.parent)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
boroughsAdded = builder.distinctCount - before
|
|
154
257
|
|
|
155
|
-
|
|
258
|
+
console.error(`pair-index: +${boroughsAdded} distinct borough pairs (WOF admin DB)`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// R6: FR lieu-dit pairs from the raw BAN dump. Streams ~26M rows, so it is the slowest source by far —
|
|
262
|
+
// deliberately opt-in per build rather than a default.
|
|
263
|
+
if (options.banDir) {
|
|
264
|
+
const before = builder.distinctCount
|
|
265
|
+
const { pairs, rowsWithLieuDit, filesRead } = await extractLieuDitPairs(options.banDir)
|
|
266
|
+
|
|
267
|
+
for (const pair of pairs) {
|
|
268
|
+
builder.addRow(pair.child, pair.parent)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
boroughsAdded += builder.distinctCount - before
|
|
272
|
+
|
|
273
|
+
console.error(
|
|
274
|
+
`pair-index: +${builder.distinctCount - before} distinct lieu-dit pairs ` +
|
|
275
|
+
`(${filesRead} départements, ${rowsWithLieuDit.toLocaleString()} rows with a clean lieu-dit)`
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// R3: generic secondary pairs (ONSPD-derived London ward pairs; future NI/IE sources) — the
|
|
280
|
+
// same fold/dedupe path, counted into the cross-check delta alongside the boroughs.
|
|
281
|
+
if (options.pairsJsonl) {
|
|
282
|
+
for (const path of splitPathList(options.pairsJsonl)) {
|
|
283
|
+
const before = builder.distinctCount
|
|
284
|
+
|
|
285
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
286
|
+
if (!line.trim()) continue
|
|
287
|
+
|
|
288
|
+
const pair = JSON.parse(line) as { child: string; parent: string }
|
|
289
|
+
|
|
290
|
+
builder.addRow(pair.child, pair.parent)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
boroughsAdded += builder.distinctCount - before
|
|
294
|
+
|
|
295
|
+
console.error(
|
|
296
|
+
`pair-index: +${builder.distinctCount - before} distinct secondary pairs (${path.split("/").pop()})`
|
|
297
|
+
)
|
|
298
|
+
}
|
|
156
299
|
}
|
|
157
300
|
|
|
158
301
|
const built = builder.finish()
|
|
@@ -167,7 +310,13 @@ const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options })
|
|
|
167
310
|
options.holdoutSeed
|
|
168
311
|
)
|
|
169
312
|
|
|
170
|
-
|
|
313
|
+
// Provenance covers every source that contributed rows, in the order they were folded in — a US build has no
|
|
314
|
+
// CSV at all, so an unconditional single-element array would have claimed a source the artifact never read.
|
|
315
|
+
const sourceMD5s = [
|
|
316
|
+
...(sourcePath ? [await md5File(sourcePath)] : []),
|
|
317
|
+
...(options.boroughDb ? [await md5File(options.boroughDb)] : []),
|
|
318
|
+
...(await Promise.all(splitPathList(options.pairsJsonl).map((path) => md5File(path)))),
|
|
319
|
+
]
|
|
171
320
|
|
|
172
321
|
// `transitionBeta` is spread conditionally so an omitted flag writes NO header key at all (a
|
|
173
322
|
// beta-less binary, byte-shape-identical to every pre-TRANSITION-BETA artifact) — not a null/0.
|
|
@@ -176,7 +325,7 @@ const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options })
|
|
|
176
325
|
delta: options.delta,
|
|
177
326
|
schemaVersion: 1,
|
|
178
327
|
foldVersion: 1,
|
|
179
|
-
sourceMD5s
|
|
328
|
+
sourceMD5s,
|
|
180
329
|
buildDate: new Date().toISOString(),
|
|
181
330
|
...(options.transitionBeta !== undefined ? { transitionBeta: options.transitionBeta } : {}),
|
|
182
331
|
}
|
|
@@ -228,11 +377,17 @@ const GazetteerPairIndex: CommandComponent<typeof OptionsSchema> = ({ options })
|
|
|
228
377
|
const gateLine =
|
|
229
378
|
country === "gb"
|
|
230
379
|
? options.holdoutFraction > 0
|
|
231
|
-
? `(cross-check skipped under --holdout-fraction; ${preHoldoutCount.toLocaleString()} distinct pairs before holdout, expects ${EXPECTED_GB_PAIR_COUNT.toLocaleString()})${preFoldSuffix}`
|
|
232
|
-
: preHoldoutCount === EXPECTED_GB_PAIR_COUNT
|
|
233
|
-
? `CROSS-CHECK PASS: ${preHoldoutCount.toLocaleString()} distinct pairs (
|
|
234
|
-
: `CROSS-CHECK BLOCKED: ${preHoldoutCount.toLocaleString()} distinct pairs !=
|
|
235
|
-
:
|
|
380
|
+
? `(cross-check skipped under --holdout-fraction; ${preHoldoutCount.toLocaleString()} distinct pairs before holdout, expects ${(EXPECTED_GB_PAIR_COUNT + boroughsAdded).toLocaleString()})${preFoldSuffix}`
|
|
381
|
+
: preHoldoutCount === EXPECTED_GB_PAIR_COUNT + boroughsAdded
|
|
382
|
+
? `CROSS-CHECK PASS: ${preHoldoutCount.toLocaleString()} distinct pairs (baseline ${EXPECTED_GB_PAIR_COUNT.toLocaleString()} + ${boroughsAdded} borough)${preFoldSuffix}`
|
|
383
|
+
: `CROSS-CHECK BLOCKED: ${preHoldoutCount.toLocaleString()} distinct pairs != baseline ${EXPECTED_GB_PAIR_COUNT.toLocaleString()} + ${boroughsAdded} borough — investigate fold divergence before trusting this artifact${preFoldSuffix}`
|
|
384
|
+
: country === "us"
|
|
385
|
+
? options.holdoutFraction > 0
|
|
386
|
+
? `(cross-check skipped under --holdout-fraction; ${preHoldoutCount.toLocaleString()} distinct pairs before holdout, expects ${EXPECTED_US_PAIR_COUNT.toLocaleString()})`
|
|
387
|
+
: preHoldoutCount === EXPECTED_US_PAIR_COUNT
|
|
388
|
+
? `CROSS-CHECK PASS: ${preHoldoutCount.toLocaleString()} distinct pairs (all WOF-sourced; no postal register)`
|
|
389
|
+
: `CROSS-CHECK BLOCKED: ${preHoldoutCount.toLocaleString()} distinct pairs != ${EXPECTED_US_PAIR_COUNT.toLocaleString()} — a WOF snapshot refresh moves this number, so re-anchor the constant DELIBERATELY (with the diff inspected) rather than trusting the artifact`
|
|
390
|
+
: `(cross-check only registered for gb/us; ${preHoldoutCount.toLocaleString()} distinct pairs)`
|
|
236
391
|
|
|
237
392
|
const holdoutLine =
|
|
238
393
|
options.holdoutFraction > 0
|