mailwoman 8.5.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.
Files changed (42) hide show
  1. package/commands/gazetteer/granularity.tsx +104 -0
  2. package/commands/gazetteer/pair-index.tsx +22 -1
  3. package/eval-harness/gauntlet/cases/regression.ts +4 -4
  4. package/gazetteer-pipeline/admin/ingest-wof.ts +7 -2
  5. package/gazetteer-pipeline/borough-pairs.ts +0 -0
  6. package/gazetteer-pipeline/defaults.ts +7 -1
  7. package/gazetteer-pipeline/granularity-report.ts +161 -0
  8. package/gazetteer-pipeline/granularity.ts +300 -0
  9. package/gazetteer-pipeline/placetype-census.ts +73 -4
  10. package/out/commands/gazetteer/granularity.d.ts +27 -0
  11. package/out/commands/gazetteer/granularity.d.ts.map +1 -0
  12. package/out/commands/gazetteer/granularity.js +78 -0
  13. package/out/commands/gazetteer/granularity.js.map +1 -0
  14. package/out/commands/gazetteer/pair-index.d.ts.map +1 -1
  15. package/out/commands/gazetteer/pair-index.js +22 -1
  16. package/out/commands/gazetteer/pair-index.js.map +1 -1
  17. package/out/eval-harness/gauntlet/cases/regression.js +4 -4
  18. package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
  19. package/out/gazetteer-pipeline/admin/ingest-wof.d.ts.map +1 -1
  20. package/out/gazetteer-pipeline/admin/ingest-wof.js +4 -0
  21. package/out/gazetteer-pipeline/admin/ingest-wof.js.map +1 -1
  22. package/out/gazetteer-pipeline/borough-pairs.d.ts +6 -0
  23. package/out/gazetteer-pipeline/borough-pairs.d.ts.map +1 -1
  24. package/out/gazetteer-pipeline/borough-pairs.js +0 -0
  25. package/out/gazetteer-pipeline/borough-pairs.js.map +1 -1
  26. package/out/gazetteer-pipeline/defaults.d.ts +2 -2
  27. package/out/gazetteer-pipeline/defaults.d.ts.map +1 -1
  28. package/out/gazetteer-pipeline/defaults.js +7 -1
  29. package/out/gazetteer-pipeline/defaults.js.map +1 -1
  30. package/out/gazetteer-pipeline/granularity-report.d.ts +34 -0
  31. package/out/gazetteer-pipeline/granularity-report.d.ts.map +1 -0
  32. package/out/gazetteer-pipeline/granularity-report.js +131 -0
  33. package/out/gazetteer-pipeline/granularity-report.js.map +1 -0
  34. package/out/gazetteer-pipeline/granularity.d.ts +115 -0
  35. package/out/gazetteer-pipeline/granularity.d.ts.map +1 -0
  36. package/out/gazetteer-pipeline/granularity.js +207 -0
  37. package/out/gazetteer-pipeline/granularity.js.map +1 -0
  38. package/out/gazetteer-pipeline/placetype-census.d.ts +16 -4
  39. package/out/gazetteer-pipeline/placetype-census.d.ts.map +1 -1
  40. package/out/gazetteer-pipeline/placetype-census.js +71 -4
  41. package/out/gazetteer-pipeline/placetype-census.js.map +1 -1
  42. package/package.json +25 -25
@@ -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
@@ -70,7 +70,7 @@ const RUNG3_PRE_FOLD_CENSUS_LINE_COUNT = 19_431
70
70
  * tracks the WOF snapshot alone. A snapshot refresh legitimately moves it; re-anchor the constant deliberately, after
71
71
  * inspecting the diff, rather than relaxing the gate.
72
72
  */
73
- const EXPECTED_US_PAIR_COUNT = 49_033
73
+ const EXPECTED_US_PAIR_COUNT = 47_878
74
74
 
75
75
  /**
76
76
  * Known (child, parent) pairs probed after write as a self-check — PER COUNTRY, keyed by the `--country` code. Probing
@@ -97,6 +97,27 @@ const PROBE_PAIRS_BY_COUNTRY: Readonly<Record<string, ReadonlyArray<readonly [ci
97
97
  ["Park Slope", "Brooklyn"],
98
98
  ["Manhattan", "New York"],
99
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
+ ],
100
121
  // R6: the FR instance. These are LIEUX-DITS under their communes (BAN `nom_ld`), not quartiers —
101
122
  // see gazetteer-pipeline/lieudit-pairs.ts for why the French source differs from the US one.
102
123
  fr: [
@@ -1221,7 +1221,7 @@ export const REGRESSION_CASES: SeedCase[] = [
1221
1221
  postcode: "TW6 2GA",
1222
1222
  },
1223
1223
  addedAt: "2026-08-01",
1224
- note: 'Sub-venue: TERMINAL is absent from Pub 28. Today: locality="Terminal", house_number=5, airport dropped.',
1224
+ note: 'Sub-venue. IMPROVED 2026-08-02 by the WOF venue-structure vocabulary: unit="Terminal 5" is now correct (was locality="Terminal" + house_number=5, airport dropped). Residual: the airport reads as locality and Hounslow as street.',
1225
1225
  },
1226
1226
  {
1227
1227
  id: "gb-subvenue-manchester-gate",
@@ -1237,7 +1237,7 @@ export const REGRESSION_CASES: SeedCase[] = [
1237
1237
  postcode: "M90 1QX",
1238
1238
  },
1239
1239
  addedAt: "2026-08-01",
1240
- note: 'Sub-venue: TWO nested structural levels. Today: locality="Gate", house_number=12.',
1240
+ note: 'Sub-venue, TWO nested structural levels. IMPROVED 2026-08-02: unit="Gate 12" is now correct (was locality="Gate" + house_number=12). Residual: only the inner level is captured.',
1241
1241
  },
1242
1242
  {
1243
1243
  id: "gb-subvenue-st-thomas-wing",
@@ -1254,7 +1254,7 @@ export const REGRESSION_CASES: SeedCase[] = [
1254
1254
  postcode: "SE1 7EH",
1255
1255
  },
1256
1256
  addedAt: "2026-08-01",
1257
- note: "Sub-venue with a TRAILING designator and a directional lead — the designator probe did not move this one.",
1257
+ note: "Sub-venue with a TRAILING designator and a directional lead. UNMOVED by the venue-structure vocabulary: the proposer reads a designator that LEADS its identifier ('Wing B'), not one trailing a modifier ('West Wing'). A named residual.",
1258
1258
  },
1259
1259
  {
1260
1260
  id: "us-subvenue-ohare-concourse",
@@ -1271,7 +1271,7 @@ export const REGRESSION_CASES: SeedCase[] = [
1271
1271
  postcode: "60666",
1272
1272
  },
1273
1273
  addedAt: "2026-08-01",
1274
- note: "Sub-venue with a LETTER rather than a number; the designator probe split this into unit=Concourse + venue=B.",
1274
+ note: "Sub-venue with a LETTER rather than a number. Still splits into unit='Concourse' + venue='B': the designator is recognized, the bare-letter identifier after it is not. The other named residual.",
1275
1275
  },
1276
1276
  {
1277
1277
  id: "us-subvenue-googleplex-building",
@@ -13,6 +13,7 @@ import { readFile } from "node:fs/promises"
13
13
  import type { DatabaseSync } from "node:sqlite"
14
14
 
15
15
  import { isOfficialLanguage } from "@mailwoman/codex/country"
16
+ import type { WOFFeature, WOFProperties } from "@mailwoman/core/resources/whosonfirst"
16
17
  import FastGlob from "fast-glob"
17
18
  import { asyncParallelIterator } from "spliterator"
18
19
 
@@ -60,8 +61,12 @@ interface ParsedFeature {
60
61
  }
61
62
 
62
63
  function parseFeature(text: string, placetypes: ReadonlySet<string>): ParsedFeature | null {
63
- const feature = JSON.parse(text)
64
- const props = feature.properties
64
+ // Typed against the schema `@mailwoman/core/resources/whosonfirst` already owns (`WOFFeature`/`WOFProperties`,
65
+ // admin.ts) rather than reading `any`. This file used to name all seventeen `wof:`/`geom:`/`edtf:`/`mz:` keys as
66
+ // bare strings, which meant a typo — `wof:superceded_by` — would have compiled, read `undefined`, and silently
67
+ // ingested every superseded record in the corpus. A type-only import: erased at build, zero runtime cost.
68
+ const feature = JSON.parse(text) as WOFFeature
69
+ const props: WOFProperties | undefined = feature.properties
65
70
 
66
71
  if (!props) return null
67
72
 
Binary file
@@ -23,6 +23,12 @@ export const DEFAULT_WOF_PRIORITY_COUNTRIES = [
23
23
  "ES",
24
24
  "FR",
25
25
  "GB",
26
+ // Added 2026-08-02 after the granularity probe. `whosonfirst-data-admin-in` carries 189,026 sub-locality nodes —
27
+ // more than Germany's 67,162, the richest tier shipped before this — converting at 98.6% into 186,469 (child,
28
+ // parent) pairs, against Overture-IN's 74,920 nodes. That is 6× the shipped GB pair index, which took eight
29
+ // campaign rungs to assemble. IN moves OUT of DEFAULT_OVERTURE_COUNTRIES in the same change: a country served by
30
+ // both would double up its admin (the #267 warning).
31
+ "IN",
26
32
  "IT",
27
33
  "JP",
28
34
  "KR",
@@ -72,7 +78,7 @@ export const DEFAULT_OVERTURE_COUNTRIES = [
72
78
  "ID",
73
79
  "IE",
74
80
  "IL",
75
- "IN",
81
+ // "IN" moved to DEFAULT_WOF_PRIORITY_COUNTRIES 2026-08-02 — see the note there.
76
82
  "IQ",
77
83
  "IR",
78
84
  "IS",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Markdown renderer for the gazetteer depth scorecard — the committed artifact under
7
+ * `docs/articles/evals/coverage/`, following the `fill-rates.md` precedent.
8
+ *
9
+ * The report declares its own limits inline rather than deferring them to a design doc. These
10
+ * numbers will outlive the conversation that produced them, and a reader six months out must be
11
+ * told IN THE FILE that counts are not quality and that the locality rung is self-comparison for
12
+ * the Overture-backfilled countries.
13
+ *
14
+ * Pure: rows in, markdown out, `buildDate` injected by the caller so the output is deterministic
15
+ * under test.
16
+ */
17
+
18
+ import type { ComponentTag } from "@mailwoman/core/types"
19
+
20
+ import { DEFAULT_WOF_PRIORITY_COUNTRIES } from "./defaults.ts"
21
+ import { LADDER, type CountryGranularity, bottomsOutAt } from "./granularity.ts"
22
+
23
+ /**
24
+ * Which build path supplied a country's rows — the single most important column in this report.
25
+ *
26
+ * A country whose rows are Overture- or GeoNames-sourced had NO WOF GeoJSON repo ingested, so its empty sub-locality
27
+ * rung says nothing whatsoever about WOF's depth there. Only a dozen of the 260 admin repos WOF publishes are in the
28
+ * recipe; the rest of the world arrives via Overture divisions (5 subtypes, none hood-level) or the GeoNames alias
29
+ * fold. Without this column a reader would take "233 countries bottom out at locality" as a finding about WOF rather
30
+ * than about the recipe.
31
+ *
32
+ * Derived from the ARTIFACT (synthetic id ranges on the locality rung), not from the recipe constants. A recipe-derived
33
+ * column silently goes wrong the moment a country is added to `DEFAULT_WOF_PRIORITY_COUNTRIES` and the gazetteer has
34
+ * not been rebuilt yet — it would claim `wof-repo` over rows that are still 100% Overture. Instead the recipe is used
35
+ * only as a CROSS-CHECK: a mismatch renders as `rebuild pending`, which is the honest description of that window.
36
+ */
37
+ function sourceClass(country: CountryGranularity): string {
38
+ const locality = country.rungs.locality
39
+ const recipeSaysWOF = (DEFAULT_WOF_PRIORITY_COUNTRIES as readonly string[]).includes(country.country)
40
+
41
+ if (!locality?.nodes) return recipeSaysWOF ? "wof-repo (no rows)" : "(no rows)"
42
+
43
+ const synthetic = locality.overtureBackfilled + locality.geonamesBackfilled
44
+
45
+ const observed =
46
+ synthetic === 0 ? "wof-repo" : locality.geonamesBackfilled > locality.overtureBackfilled ? "geonames" : "overture"
47
+
48
+ // The recipe promises WOF rows the artifact does not yet carry: a rebuild has not run since the recipe changed.
49
+ if (recipeSaysWOF && observed !== "wof-repo") return `${observed} (rebuild pending)`
50
+
51
+ return observed
52
+ }
53
+
54
+ export interface GranularityReportMeta {
55
+ /**
56
+ * Display path of the measured DB. Use the `$MAILWOMAN_DATA_ROOT`-relative form, never the resolved lab path.
57
+ */
58
+ sourcePath: string
59
+ sourceMD5: string
60
+ /**
61
+ * ISO timestamp, injected rather than read from the clock so the renderer stays deterministic.
62
+ */
63
+ buildDate: string
64
+ floor: number
65
+ }
66
+
67
+ function pct(value: number): string {
68
+ return `${(value * 100).toFixed(1)}%`
69
+ }
70
+
71
+ /**
72
+ * One row's cell for a rung: node count, plus the Overture-backfilled share when any of it is backfilled.
73
+ */
74
+ function rungCell(country: CountryGranularity, rung: ComponentTag): string {
75
+ const measurement = country.rungs[rung]
76
+
77
+ // Absent measurement = never measured. Distinct from a measured zero, which renders as "0".
78
+ if (!measurement) return "—"
79
+
80
+ if (!measurement.nodes) return "0"
81
+
82
+ const synthetic = measurement.overtureBackfilled + measurement.geonamesBackfilled
83
+
84
+ if (!synthetic) return measurement.nodes.toLocaleString()
85
+
86
+ // Label by whichever synthetic source dominates. A single `id >= OVERTURE_ID_BASE` test would call GeoNames rows
87
+ // Overture, which mislabelled every GeoNames-only country in the first run of this report.
88
+ const label = measurement.geonamesBackfilled > measurement.overtureBackfilled ? "gn" : "ovt"
89
+
90
+ return `${measurement.nodes.toLocaleString()} (${pct(synthetic / measurement.nodes)} ${label})`
91
+ }
92
+
93
+ /**
94
+ * Render the scorecard.
95
+ */
96
+ export function renderGranularityReport(rows: CountryGranularity[], meta: GranularityReportMeta): string {
97
+ const header = LADDER.join(" | ")
98
+ const alignment = LADDER.map(() => "--:").join(" | ")
99
+
100
+ const body = rows.map((country) => {
101
+ const bottom = bottomsOutAt(country, meta.floor)
102
+ const cells = LADDER.map((rung) => rungCell(country, rung)).join(" | ")
103
+
104
+ return `| ${country.country} | ${sourceClass(country)} | ${bottom ?? "(none)"} | ${country.localityParents.toLocaleString()} | ${cells} |`
105
+ })
106
+
107
+ const reached = rows.filter((country) => bottomsOutAt(country, meta.floor) === "dependent_locality").length
108
+ const fromWOFRepo = rows.filter((country) => sourceClass(country) === "wof-repo").length
109
+
110
+ return `# Gazetteer depth scorecard
111
+
112
+ Generated by \`mailwoman gazetteer granularity\`. Per-country measurement of where the admin
113
+ gazetteer bottoms out — node counts per containment rung, and for the sub-locality rungs the
114
+ parent-coverage share (the fraction of the country's locality-class nodes carrying at least one
115
+ child projecting onto that rung).
116
+
117
+ - **Source:** \`${meta.sourcePath}\` (md5 \`${meta.sourceMD5}\`)
118
+ - **Built:** ${meta.buildDate}
119
+ - **Parent-coverage floor:** ${pct(meta.floor)} — a sub-locality rung counts as reached only above this.
120
+ - **Countries measured:** ${rows.length.toLocaleString()}
121
+ - **Countries built from a cloned WOF repo:** ${fromWOFRepo.toLocaleString()} — the rest came from Overture divisions or the GeoNames alias fold.
122
+ - **Countries reaching \`dependent_locality\`:** ${reached.toLocaleString()}
123
+
124
+ ## Read the \`source\` column first
125
+
126
+ **This report measures the shipped artifact, not Who's on First.** WOF publishes 260 per-country
127
+ admin repos; \`DEFAULT_WOF_PRIORITY_COUNTRIES\` names ${DEFAULT_WOF_PRIORITY_COUNTRIES.length} of
128
+ them. Every other country's rows come from Overture divisions — whose \`OVERTURE_DIVISION_SUBTYPES\`
129
+ is \`country\`/\`locality\`/\`region\`/\`county\`/\`localadmin\`, with no hood-level subtype — or from
130
+ the GeoNames alias fold.
131
+
132
+ So a \`source\` of \`overture\` or \`geonames\` with an empty \`dependent_locality\` rung is **not a
133
+ finding about WOF's depth in that country**. It is a country we never asked. The sub-locality tier
134
+ appears in exactly the countries whose repo is ingested, and that correspondence is the recipe, not a
135
+ property of the data.
136
+
137
+ The column is read off the **artifact** (synthetic id ranges), not the recipe. \`rebuild pending\`
138
+ means the recipe now names a country the shipped gazetteer has not been rebuilt for — its rows are
139
+ still Overture or GeoNames, and its rungs say nothing about WOF yet.
140
+
141
+ ## What this report does not tell you
142
+
143
+ - **Counts are not quality.** A node count establishes where to look, never that the rows are good.
144
+ - **There is no demand-side grounding below the locality line.** Nothing here proves a sub-locality
145
+ surface appears in real addresses. Overture's \`address_levels\` — the obvious instrument — bottoms
146
+ out at municipality in every country measured, so it cannot see this tier.
147
+ - **The locality rung and above are partly self-comparison.** For the Overture-backfilled country
148
+ set those rows came from Overture, not WOF; the \`ovt\` share in each cell is how much.
149
+ - **An empty rung is coverage, not fact.** Two independent filters produce zeroes here: the country
150
+ recipe above, and \`ADMIN_PLACETYPES\` in \`admin/ingest-wof.ts\`, which allowlists 9 of WOF's 34
151
+ placetypes — so even for a cloned country the build never asked for the other 25. Per the
152
+ **meaning-of-zero** rule a measured-and-empty rung renders as \`0\` and a never-measured rung as
153
+ \`—\`; they are not the same claim.
154
+
155
+ ## Ladder
156
+
157
+ | country | source | bottoms out at | locality parents | ${header} |
158
+ | --- | --- | --- | --: | ${alignment} |
159
+ ${body.join("\n")}
160
+ `
161
+ }