mailwoman 8.0.0 → 8.1.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 (67) hide show
  1. package/api-engine.ts +7 -3
  2. package/commands/gazetteer/build/fst.tsx +60 -0
  3. package/commands/gazetteer/build/street-morphology.tsx +54 -0
  4. package/commands/geocode.tsx +4 -3
  5. package/commands/situs/interpolation-shard.tsx +23 -1
  6. package/eval-harness/parity-corpus.ts +7 -4
  7. package/gazetteer-pipeline/coverage-manifest.ts +163 -0
  8. package/gazetteer-pipeline/fst.ts +213 -0
  9. package/gazetteer-pipeline/index.ts +9 -1
  10. package/gazetteer-pipeline/pair-index-hierarchy-probe.ts +310 -0
  11. package/gazetteer-pipeline/pair-index-hierarchy-verify.ts +278 -0
  12. package/gazetteer-pipeline/street-morphology.ts +94 -0
  13. package/geocode-core.ts +43 -15
  14. package/out/api-engine.d.ts.map +1 -1
  15. package/out/api-engine.js +7 -2
  16. package/out/api-engine.js.map +1 -1
  17. package/out/commands/gazetteer/build/fst.d.ts +22 -0
  18. package/out/commands/gazetteer/build/fst.d.ts.map +1 -0
  19. package/out/commands/gazetteer/build/fst.js +42 -0
  20. package/out/commands/gazetteer/build/fst.js.map +1 -0
  21. package/out/commands/gazetteer/build/street-morphology.d.ts +22 -0
  22. package/out/commands/gazetteer/build/street-morphology.d.ts.map +1 -0
  23. package/out/commands/gazetteer/build/street-morphology.js +47 -0
  24. package/out/commands/gazetteer/build/street-morphology.js.map +1 -0
  25. package/out/commands/geocode.d.ts.map +1 -1
  26. package/out/commands/geocode.js +4 -3
  27. package/out/commands/geocode.js.map +1 -1
  28. package/out/commands/situs/interpolation-shard.d.ts.map +1 -1
  29. package/out/commands/situs/interpolation-shard.js +21 -1
  30. package/out/commands/situs/interpolation-shard.js.map +1 -1
  31. package/out/eval-harness/parity-corpus.d.ts.map +1 -1
  32. package/out/eval-harness/parity-corpus.js +7 -4
  33. package/out/eval-harness/parity-corpus.js.map +1 -1
  34. package/out/gazetteer-pipeline/coverage-manifest.d.ts +59 -0
  35. package/out/gazetteer-pipeline/coverage-manifest.d.ts.map +1 -0
  36. package/out/gazetteer-pipeline/coverage-manifest.js +141 -0
  37. package/out/gazetteer-pipeline/coverage-manifest.js.map +1 -0
  38. package/out/gazetteer-pipeline/fst.d.ts +68 -0
  39. package/out/gazetteer-pipeline/fst.d.ts.map +1 -0
  40. package/out/gazetteer-pipeline/fst.js +165 -0
  41. package/out/gazetteer-pipeline/fst.js.map +1 -0
  42. package/out/gazetteer-pipeline/index.d.ts +3 -1
  43. package/out/gazetteer-pipeline/index.d.ts.map +1 -1
  44. package/out/gazetteer-pipeline/index.js +9 -1
  45. package/out/gazetteer-pipeline/index.js.map +1 -1
  46. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.d.ts +81 -0
  47. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.d.ts.map +1 -0
  48. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.js +224 -0
  49. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.js.map +1 -0
  50. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.d.ts +31 -0
  51. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.d.ts.map +1 -0
  52. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.js +225 -0
  53. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.js.map +1 -0
  54. package/out/gazetteer-pipeline/street-morphology.d.ts +49 -0
  55. package/out/gazetteer-pipeline/street-morphology.d.ts.map +1 -0
  56. package/out/gazetteer-pipeline/street-morphology.js +63 -0
  57. package/out/gazetteer-pipeline/street-morphology.js.map +1 -0
  58. package/out/geocode-core.d.ts +17 -6
  59. package/out/geocode-core.d.ts.map +1 -1
  60. package/out/geocode-core.js +19 -8
  61. package/out/geocode-core.js.map +1 -1
  62. package/out/runtime-pipeline.d.ts +3 -2
  63. package/out/runtime-pipeline.d.ts.map +1 -1
  64. package/out/runtime-pipeline.js +17 -11
  65. package/out/runtime-pipeline.js.map +1 -1
  66. package/package.json +23 -23
  67. package/runtime-pipeline.ts +22 -13
package/api-engine.ts CHANGED
@@ -332,12 +332,16 @@ export async function createServeEngine(): Promise<ServeEngine> {
332
332
  ...incomingOpts,
333
333
  defaultCountry: incomingOpts.defaultCountry ?? deps.defaultCountry,
334
334
  ...(addressPoints ? { addressPoints } : {}),
335
+ // #374 calibration ladder: explicit incoming factor (instrument override, survives the spread) →
336
+ // the artifact's own header value (`interpolation.radiusCalibration`, read at shard open — the
337
+ // resolver consumes it directly, nothing passed here) → the in-code per-region table for shards
338
+ // predating the `interp_calibration` metadata table.
335
339
  ...(interpolation
336
340
  ? {
337
341
  interpolation,
338
- interpolationRadiusCalibration:
339
- incomingOpts.interpolationRadiusCalibration ??
340
- interpCalibrationForRegion(INTERP_RADIUS_CALIBRATION, slug),
342
+ ...(incomingOpts.interpolationRadiusCalibration == null && interpolation.radiusCalibration == null
343
+ ? { interpolationRadiusCalibration: interpCalibrationForRegion(INTERP_RADIUS_CALIBRATION, slug) }
344
+ : {}),
341
345
  }
342
346
  : {}),
343
347
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman gazetteer build fst` — per-locale decode-bias FST gazetteers (`fst-<locale>.bin`,
7
+ * #1318) with build-time degenerate-surface curation. Artifacts land in a staging dir BESIDE the
8
+ * shipped `fst-per-locale/` (never overwriting); the swap is operator-gated after the battery. See
9
+ * `mailwoman/gazetteer-pipeline/fst.ts` for the curation policy.
10
+ */
11
+
12
+ import { Text } from "ink"
13
+ import zod from "zod"
14
+
15
+ import { type CommandComponent, useCommandTask } from "../../../cli-kit/index.ts"
16
+
17
+ const OptionsSchema = zod.object({
18
+ locales: zod.string().optional().describe("Comma-separated locales (default: all shipped FST locales)"),
19
+ db: zod.string().optional().describe("WOF admin DB (default: $MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db)"),
20
+ output: zod.string().optional().describe("Output dir (default: $MAILWOMAN_DATA_ROOT/wof/fst-per-locale-curated)"),
21
+ uncurated: zod.boolean().default(false).describe("A/B control build: same DB, no curation"),
22
+ })
23
+
24
+ export { OptionsSchema as options }
25
+
26
+ const GazetteerBuildFST: CommandComponent<typeof OptionsSchema> = ({ options }) => {
27
+ const state = useCommandTask(async () => {
28
+ const { buildLocaleFSTs } = await import("../../../gazetteer-pipeline/fst.ts")
29
+ const built = buildLocaleFSTs({
30
+ locales: options.locales?.split(",").map((s) => s.trim()),
31
+ dbPath: options.db,
32
+ outputDir: options.output,
33
+ uncurated: options.uncurated,
34
+ onProgress: (line) => console.error(line),
35
+ })
36
+
37
+ return built.map(
38
+ (b) =>
39
+ `fst-${b.locale} → ${b.path} (${(b.bytes / 1e6).toFixed(1)} MB, ${b.nameInsertions} insertions, ${b.excludedInsertions} excluded)`
40
+ )
41
+ })
42
+
43
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
44
+
45
+ if (state.status === "done") {
46
+ return (
47
+ <>
48
+ {state.result.map((line, i) => (
49
+ <Text key={i} color="green">
50
+ ✓ {line}
51
+ </Text>
52
+ ))}
53
+ </>
54
+ )
55
+ }
56
+
57
+ return null
58
+ }
59
+
60
+ export default GazetteerBuildFST
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman gazetteer build street-morphology` — the sealed street-morphology FST artifact
7
+ * (`fst-street-morphology.bin`), the #1315 street-context gate's signal source serialized once at
8
+ * build time instead of rebuilt from the libpostal dictionaries per process (and never in the
9
+ * browser). Lands at `$MAILWOMAN_DATA_ROOT/wof/` by default — beside, never inside, the
10
+ * per-locale FST dir. See `mailwoman/gazetteer-pipeline/street-morphology.ts` for the rationale.
11
+ */
12
+
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
+ dictionaries: zod
20
+ .string()
21
+ .optional()
22
+ .describe("libpostal dictionaries root (default: core's bundled data/libpostal/dictionaries)"),
23
+ locales: zod
24
+ .string()
25
+ .optional()
26
+ .describe("Comma-separated locale subfolders (default: every locale with a street_types.txt)"),
27
+ output: zod.string().optional().describe("Output path (default: $MAILWOMAN_DATA_ROOT/wof/fst-street-morphology.bin)"),
28
+ })
29
+
30
+ export { OptionsSchema as options }
31
+
32
+ const GazetteerBuildStreetMorphology: CommandComponent<typeof OptionsSchema> = ({ options }) => {
33
+ const state = useCommandTask(async () => {
34
+ const { buildStreetMorphologyArtifact } = await import("../../../gazetteer-pipeline/street-morphology.ts")
35
+ const built = buildStreetMorphologyArtifact({
36
+ dictionariesDir: options.dictionaries,
37
+ locales: options.locales?.split(",").map((s) => s.trim()),
38
+ output: options.output,
39
+ onProgress: (line) => console.error(line),
40
+ })
41
+
42
+ return `${built.path} (${(built.bytes / 1e3).toFixed(0)} kB, ${built.canonicalCount} canonicals, ${built.variantCount} variants, ${built.localeCount} locales)`
43
+ })
44
+
45
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
46
+
47
+ if (state.status === "done") {
48
+ return <Text color="green">✓ {state.result}</Text>
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ export default GazetteerBuildStreetMorphology
@@ -120,9 +120,10 @@ const OptionsSchema = zod.object({
120
120
  .optional()
121
121
  .describe(
122
122
  "Conformal calibration multiplier for the interpolation tier's reported uncertainty_m (#374). " +
123
- "The raw half-segment radius covers only ~72% of true errors. Default (unset): the per-region " +
124
- "table (#584) selects by parsed region 1.44 (DC) … 3.12 (AZ), 1.95 for unmeasured states — " +
125
- "for a ~90% bound. Pass an explicit number to force a single multiplier everywhere (1 = raw)."
123
+ "The raw half-segment radius covers only ~72% of true errors. Default (unset): the shard's own " +
124
+ "baked value (its interp_calibration metadata table) when it carries one, else the in-code " +
125
+ "per-region table (#584) selected by parsed region 1.44 (DC) 3.12 (AZ), 1.95 for unmeasured " +
126
+ "states — for a ~90% bound. Pass an explicit number to force a single multiplier everywhere (1 = raw)."
126
127
  ),
127
128
  placeCountry: zod
128
129
  .boolean()
@@ -35,6 +35,15 @@ import { Box, Text } from "ink"
35
35
  import zod from "zod"
36
36
 
37
37
  import { type CommandComponent, commandError, useCommandTask } from "../../cli-kit/index.ts"
38
+ import { INTERP_RADIUS_CALIBRATION } from "../../interp-calibration.ts"
39
+
40
+ /**
41
+ * Provenance tag for the baked `interp_calibration` row — the split-conformal multi-region recalibration this build
42
+ * selects its multiplier from (`docs/articles/evals/calibration/2026-06-14-interp-multiregion-recalibration.md`). Bump
43
+ * when the calibration source of record (`interp-calibration.ts` / `data/calibration/interp-radius-conformal.json`) is
44
+ * re-measured.
45
+ */
46
+ const CALIBRATION_METHOD = "split-conformal:2026-06-14"
38
47
 
39
48
  /** State abbreviation → state FIPS prefix, for picking county files out of --edges-dir. */
40
49
  const STATE_FIPS: Record<string, string> = {
@@ -183,7 +192,8 @@ const SitusInterpolationShard: CommandComponent<typeof OptionsSchema> = ({ optio
183
192
  "@duckdb/node-api is not installed — `situs interpolation-shard` is a maintainer-only data command"
184
193
  )
185
194
  }
186
- const { STREET_SEGMENT_COLUMNS, createStreetSegmentTable, createStreetSegmentIndexes } = segmentSchema
195
+ const { STREET_SEGMENT_COLUMNS, createStreetSegmentTable, createStreetSegmentIndexes, writeInterpCalibration } =
196
+ segmentSchema
187
197
  const { canonicalizeRouteKey, normalizeStreetForKey } = streetNormalize
188
198
 
189
199
  const shapefiles = globSync(`${options.edgesDir}/tl_*_${STATE_FIPS[STATE]}???_edges.shp`).sort()
@@ -209,6 +219,17 @@ const SitusInterpolationShard: CommandComponent<typeof OptionsSchema> = ({ optio
209
219
  // producer can't drift. DuckDB below is the raw spatial reader; the hot INSERT stays on `db`.
210
220
  const kdb = new DatabaseClient<StreetSegmentDatabase>({ database: db })
211
221
  await createStreetSegmentTable(kdb)
222
+ // #374 doctrine: the conformal radius multiplier is a property of the calibration set, so it ships IN
223
+ // the artifact — bake the state's factor (or the conservative default for unmeasured states) into the
224
+ // shard's `interp_calibration` metadata table. `StreetInterpolator` reads it at open time; callers
225
+ // stop carrying the number.
226
+ const measuredMultiplier = INTERP_RADIUS_CALIBRATION.byRegion[STATE]
227
+ const calibration = {
228
+ radius_multiplier: measuredMultiplier ?? INTERP_RADIUS_CALIBRATION.default,
229
+ method: CALIBRATION_METHOD,
230
+ region: measuredMultiplier === undefined ? "default" : STATE,
231
+ }
232
+ await writeInterpCalibration(kdb, calibration)
212
233
  const insert = db.prepare(
213
234
  `INSERT INTO street_segment (${STREET_SEGMENT_COLUMNS.join(", ")})
214
235
  VALUES (${STREET_SEGMENT_COLUMNS.map(() => "?").join(", ")})`
@@ -300,6 +321,7 @@ const SitusInterpolationShard: CommandComponent<typeof OptionsSchema> = ({ optio
300
321
  `distinct streets: ${stats.streets} · postcodes: ${stats.postcodes}`,
301
322
  `parity: odd ${parityCounts.odd} · even ${parityCounts.even} · mixed ${parityCounts.mixed}`,
302
323
  `skipped non-numeric ranges: ${skippedNonNumeric}`,
324
+ `baked radius calibration: ×${calibration.radius_multiplier} (${calibration.region}, ${calibration.method})`,
303
325
  ]
304
326
  })
305
327
 
@@ -98,11 +98,14 @@ export async function runParityEval(options: ParityEvalOptions = {}): Promise<Pa
98
98
  let fstStreetMorphology: import("@mailwoman/resolver-wof-sqlite/fst-matcher").FSTMatcher | undefined
99
99
 
100
100
  if (options.streetMorphology) {
101
- const { buildStreetMorphologyFST } = await import("@mailwoman/resolver-wof-sqlite/street-morphology-fst-builder")
102
- const built = buildStreetMorphologyFST({ dictionariesDir: "core/data/libpostal/dictionaries" })
103
- fstStreetMorphology = built.matcher
101
+ // Sealed-artifact-first (static-index candidate 1): the loader's shared ladder — data-root
102
+ // `fst-street-morphology.bin`, degrading to the per-process dictionary build this site used to
103
+ // inline (with a cwd-relative dictionaries path, no less).
104
+ const { loadStreetMorphologyFST } = await import("@mailwoman/resolver-wof-sqlite/street-morphology-fst-loader")
105
+ const loaded = loadStreetMorphologyFST({ onWarn: (message) => console.warn(message) })
106
+ fstStreetMorphology = loaded.matcher
104
107
  console.log(
105
- `street-morphology bias ON: ${built.locales.length} locales, ${built.variantCount} variants (${built.canonicalCount} canonical)`
108
+ `street-morphology bias ON (${loaded.source === "artifact" ? `sealed artifact ${loaded.path}` : "per-process dictionary build"}${loaded.provenance ? `: ${loaded.provenance.placeCount} canonical affixes, ${loaded.provenance.nameInsertions} variant insertions` : ""})`
106
109
  )
107
110
  }
108
111
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The MEASURED COVERAGE RECORD for the candidate gazetteer + its build-time emission (survey
7
+ * candidate #2, 2026-07-26) — the durable home for two sets of facts that used to be hand-grown
8
+ * code constants updated by PR-after-someone-remembers:
9
+ *
10
+ * - The hard-country-filter coverage measurements (#743/#194) behind
11
+ * `HARD_PLACE_COUNTRY_SAFELIST` (`core/pipeline/runtime-pipeline.ts`) — previously a code
12
+ * comment ("US 100, FR 100 … FI 69.5 (out)"), i.e. measurement as trivia.
13
+ * - The guard-B plausibility boxes behind `COUNTRY_BBOX` (`resolver/plausibility.ts`).
14
+ *
15
+ * Doctrine (operator-ratified 2026-07-26): facts ABOUT an artifact live in the artifact's
16
+ * manifest, read at load — so they update at gazetteer REBUILD, not at a code PR. This module is
17
+ * the drawer: it owns the reviewed measurement record ({@link MEASURED_COUNTRY_COVERAGE},
18
+ * {@link MEASURED_COUNTRY_BBOXES} — grow THESE at promotes, like `defaults.ts` owns the build
19
+ * recipe) and the emission step `buildCandidate` runs before sealing. The schema + canonical
20
+ * read/write functions live in `@mailwoman/resolver-wof-sqlite/coverage-manifest-schema` (the
21
+ * fold/build convention: canonical package functions, composed here).
22
+ *
23
+ * The SHIPPED candidate gazetteer is never patched ("never patch databases — rebuild"): an
24
+ * artifact predating the manifest reads `undefined` at open and every consumer falls back to the
25
+ * code constants byte-identically. The meaning-of-zero rule is honored structurally: FI/PL are
26
+ * PRESENT rows with `hardFilterSafe: false` (measured, failed the gate) — distinguishable from a
27
+ * country that was simply never measured (absent row).
28
+ */
29
+
30
+ import { DatabaseSync } from "node:sqlite"
31
+
32
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
33
+ import type { CountryBBoxFact, CountryCoverageFact } from "@mailwoman/core/resolver"
34
+ // resolver-wof-sqlite is an optional peer of mailwoman (the geocode.tsx convention) — runtime
35
+ // imports are DYNAMIC inside the functions; type-only imports are erased and safe at module level.
36
+ import type { GazetteerCoverageDatabase } from "@mailwoman/resolver-wof-sqlite"
37
+
38
+ /** Shared source string for the #743 promote measurements. */
39
+ const OA_PANEL_SOURCE = "#743 OA held-out hard-resolve panel (DeepSeek-advised gate, 2026-06-22)"
40
+
41
+ /** Shared source string for the #928 promote OSM panels. */
42
+ const OSM_PANEL_SOURCE = "#928 promote OSM panel, night 34 (2026-07-06)"
43
+
44
+ /**
45
+ * The reviewed per-country hard-filter coverage record — every promote-gate verdict + measurement that grew (or
46
+ * deliberately kept a country off) the hard-country safelist. This is the structured form of the receipts that lived in
47
+ * the `HARD_PLACE_COUNTRY_SAFELIST` code comment; the derived safelist (`hardFilterSafe === true`) is asserted
48
+ * byte-identical to that constant in `coverage-manifest.test.ts`, so the two cannot drift silently.
49
+ *
50
+ * Grow THIS at promotes (with the panel receipt in `source`); the fact reaches production at the next gazetteer rebuild
51
+ * — the constant in core is only the fallback for artifacts predating the manifest.
52
+ */
53
+ export const MEASURED_COUNTRY_COVERAGE: readonly CountryCoverageFact[] = [
54
+ { country: "US", hardFilterSafe: true, hardResolveRate: 1.0, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
55
+ { country: "FR", hardFilterSafe: true, hardResolveRate: 1.0, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
56
+ { country: "DE", hardFilterSafe: true, hardResolveRate: 1.0, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
57
+ { country: "ES", hardFilterSafe: true, hardResolveRate: 0.998, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
58
+ { country: "NL", hardFilterSafe: true, hardResolveRate: 0.973, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
59
+ { country: "IT", hardFilterSafe: true, hardResolveRate: 0.968, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
60
+ // Measured and FAILED the gate — present rows on purpose (meaning-of-zero: a failed measurement is a
61
+ // first-class negative result, distinguishable from "never measured"). They stay on the soft prior
62
+ // until their gazetteer coverage is filled (#193).
63
+ { country: "FI", hardFilterSafe: false, hardResolveRate: 0.695, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
64
+ { country: "PL", hardFilterSafe: false, hardResolveRate: 0.778, measuredAt: "2026-06-22", source: OA_PANEL_SOURCE },
65
+ // #928 promote (2026-07-06): the postcodeCountryPrior FORMAT signal routes GB/CA confidently (the
66
+ // language placer conflated both with US), and the OSM-panel gates passed with the hard filter ON.
67
+ // Rates here are the panels' RESOLVE rates (1 − unresolved/n): GB 293/300 (271 ok, 7 unresolved),
68
+ // CA 269/300 (200 ok, 31 unresolved) — CA cleared on the format-prior rationale despite the sub-95%
69
+ // panel number, which is exactly why `hardFilterSafe` is a stored VERDICT, not a rate threshold.
70
+ {
71
+ country: "GB",
72
+ hardFilterSafe: true,
73
+ hardResolveRate: 0.977,
74
+ sampleSize: 300,
75
+ measuredAt: "2026-07-06",
76
+ source: OSM_PANEL_SOURCE,
77
+ },
78
+ {
79
+ country: "CA",
80
+ hardFilterSafe: true,
81
+ hardResolveRate: 0.897,
82
+ sampleSize: 300,
83
+ measuredAt: "2026-07-06",
84
+ source: OSM_PANEL_SOURCE,
85
+ },
86
+ // AU added with the #244 AU placer class: 150k-row G-NAF training → AU test-acc 100%, and the hard
87
+ // filter is recall-SAFE on the AU panel (unresolved 4→2 while abroad 43→20). No single-rate number
88
+ // in the receipt → no `hardResolveRate` (never invent a magnitude).
89
+ {
90
+ country: "AU",
91
+ hardFilterSafe: true,
92
+ measuredAt: "2026-07-06",
93
+ source: "#244 AU placer-class promote (2026-07-06): hard filter recall-safe (unresolved 4→2, abroad 43→20)",
94
+ },
95
+ ]
96
+
97
+ /** Shared source string for the guard-B boxes. */
98
+ const BBOX_SOURCE = "2026-07-15 coordinate-parity receipt harness (scratchpad/coord-parity.mjs) — deliberately coarse"
99
+
100
+ const bbox = (country: string, latMin: number, latMax: number, lonMin: number, lonMax: number): CountryBBoxFact => ({
101
+ country,
102
+ latMin,
103
+ latMax,
104
+ lonMin,
105
+ lonMax,
106
+ source: BBOX_SOURCE,
107
+ })
108
+
109
+ /**
110
+ * The reviewed guard-B bounding-box record — the structured form of `COUNTRY_BBOX` (`resolver/plausibility.ts`),
111
+ * asserted byte-identical to that constant in `coverage-manifest.test.ts`. Same growth discipline as
112
+ * {@link MEASURED_COUNTRY_COVERAGE}: add boxes HERE; the constant is only the pre-manifest fallback.
113
+ */
114
+ export const MEASURED_COUNTRY_BBOXES: readonly CountryBBoxFact[] = [
115
+ bbox("US", 18, 72, -180, -66),
116
+ bbox("AU", -44, -10, 112, 154),
117
+ bbox("BR", -34, 6, -74, -34),
118
+ bbox("CZ", 48, 51.5, 12, 19),
119
+ bbox("DE", 47, 55.5, 5.5, 15.5),
120
+ bbox("ES", 35, 44, -10, 5),
121
+ bbox("FR", 41, 51.5, -5.5, 9.8),
122
+ bbox("GB", 49, 61, -8.7, 2),
123
+ bbox("HR", 42, 46.6, 13, 19.5),
124
+ bbox("IN", 6, 36, 68, 98),
125
+ bbox("NL", 50.7, 53.7, 3.3, 7.3),
126
+ bbox("NO", 57, 71.5, 4, 31),
127
+ bbox("PL", 49, 55, 14, 24.2),
128
+ bbox("PT", 36.5, 42.2, -9.6, -6.1),
129
+ bbox("RO", 43.5, 48.3, 20, 30),
130
+ bbox("SE", 55, 69.1, 10.9, 24.2),
131
+ bbox("SK", 47.7, 49.7, 16.8, 22.6),
132
+ bbox("SI", 45.4, 46.9, 13.3, 16.6),
133
+ ]
134
+
135
+ export interface EmitCoverageManifestOptions {
136
+ /** The candidate DB under construction — MUST be pre-seal (a shipped DB is never patched, rebuild instead). */
137
+ dbPath: string
138
+ /** Coverage rows to bake (default {@link MEASURED_COUNTRY_COVERAGE}). */
139
+ coverage?: readonly CountryCoverageFact[]
140
+ /** Guard-B bbox rows to bake (default {@link MEASURED_COUNTRY_BBOXES}). */
141
+ bboxes?: readonly CountryBBoxFact[]
142
+ }
143
+
144
+ /**
145
+ * Bake the coverage manifest into a candidate DB under construction. Called by `buildCandidate` between the candidate
146
+ * build and the seal; standalone use is fine for tests/fixtures (never against a sealed artifact).
147
+ */
148
+ export async function emitCoverageManifest(opts: EmitCoverageManifestOptions): Promise<void> {
149
+ const { writeGazetteerCoverageManifest } = await import("@mailwoman/resolver-wof-sqlite")
150
+
151
+ const db = new DatabaseSync(opts.dbPath)
152
+ const kdb = new DatabaseClient<GazetteerCoverageDatabase>({ database: db })
153
+
154
+ try {
155
+ await writeGazetteerCoverageManifest(kdb, {
156
+ coverage: opts.coverage ?? MEASURED_COUNTRY_COVERAGE,
157
+ bboxes: opts.bboxes ?? MEASURED_COUNTRY_BBOXES,
158
+ })
159
+ } finally {
160
+ // `kdb` wraps the same handle; destroy() owns the close.
161
+ await kdb.destroy()
162
+ }
163
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Per-locale FST gazetteer build (`mailwoman gazetteer build fst`) — the decode-bias FST shipped as
7
+ * `fst-<locale>.bin` in the weights packages (#1318), rebuilt WITH degenerate-surface curation.
8
+ *
9
+ * THE CURATION (the "serialize runtime decisions into static indexes" doctrine): a name whose whole
10
+ * normalized surface is a bare function word ("la" — the case-folded Los Angeles alias colliding
11
+ * with the French article), a bare street-type word ("boulevard", "lane" — real US places that are
12
+ * street vocabulary everywhere else), or a composition of nothing but function words ("de la") is
13
+ * NEVER inserted as a bias key. This is the ASR-contextual-biasing "prune the bias list" discipline
14
+ * and Carmen's index-time token hygiene: the hazard is removed from the artifact rather than
15
+ * guarded at decode time, so it cannot misfire on lowercase, comma-free, any-locale input. The FST
16
+ * is a bias list, not the gazetteer of record — the resolver's candidate tables are untouched, so
17
+ * excluded places stay findable; they just stop nudging the decoder on degenerate keys.
18
+ *
19
+ * Exclusion sources are the SHIPPED libpostal dictionaries (`core/data/libpostal/dictionaries/`):
20
+ * per-language `stopwords.txt` (whole-surface + compositional clauses) and `street_types.txt`
21
+ * (whole-surface clause only — "Avenue Road" is a real name; "de la" is not). The language set is
22
+ * the served Latin-script tiers, uniform across locales (a FR query hits the en-us FST on the
23
+ * default path, so per-locale language scoping would under-curate).
24
+ *
25
+ * Provenance (policy string + excluded-insertion count) is recorded in the artifact trailer.
26
+ * Artifacts are written to --output (default: a `fst-per-locale-curated/` sibling of the shipped
27
+ * `fst-per-locale/` dir) — staged BESIDE, never overwriting; the swap into the shipped path is
28
+ * operator-gated after the battery.
29
+ */
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
32
+ import { join, resolve } from "node:path"
33
+
34
+ import { dataRootPath, repoRootPathBuilder } from "@mailwoman/core/utils"
35
+ import { buildFSTFromWOF } from "@mailwoman/resolver-wof-sqlite/fst-builder"
36
+ import { normalizeTokens } from "@mailwoman/resolver-wof-sqlite/fst-matcher"
37
+ import { serializeFST } from "@mailwoman/resolver-wof-sqlite/fst-serialize"
38
+
39
+ /** The served Latin-script language tiers (see SCOPE.mdx) — uniform curation set for every locale FST. */
40
+ export const CURATION_LANGUAGES = [
41
+ "en",
42
+ "fr",
43
+ "de",
44
+ "nl",
45
+ "es",
46
+ "it",
47
+ "pt",
48
+ "da",
49
+ "nb",
50
+ "sv",
51
+ "fi",
52
+ "pl",
53
+ "cs",
54
+ "sk",
55
+ "sl",
56
+ "hr",
57
+ "hu",
58
+ ] as const
59
+
60
+ export const EXCLUSION_POLICY_ID =
61
+ "degenerate-surface-exclusion v1.1 (libpostal stopwords+street_types, 17 langs, + supplemental)"
62
+
63
+ /**
64
+ * Function-word surfaces the libpostal dictionaries MISS. Each entry carries its justification — this list is curated,
65
+ * not a dumping ground; a candidate belongs here only when it is a common function word in a served language whose
66
+ * libpostal stopword file lacks the bare form.
67
+ */
68
+ export const SUPPLEMENTAL_DEGENERATE_SURFACES: ReadonlySet<string> = new Set([
69
+ // Dutch/Danish preposition ("op de hoek", "op til") — absent from libpostal nl/da stopwords.txt as
70
+ // a bare word. Shipped-index victim: the Overland Park "OP" initialism alias (wof 85945755).
71
+ "op",
72
+ ])
73
+
74
+ /** The shipped per-locale FST set (provenance-recovered country scoping; en-nz deliberately has none). */
75
+ export const FST_LOCALES: ReadonlyMap<string, string[]> = new Map([
76
+ ["en-us", ["US"]],
77
+ ["fr-fr", ["FR"]],
78
+ ["en-gb", ["GB"]],
79
+ ["de-de", ["DE"]],
80
+ ])
81
+
82
+ /** One dictionary line = canonical|variant|variant… — every pipe-separated form is a surface. */
83
+ function surfacesOfLine(line: string): string[] {
84
+ return line
85
+ .split("|")
86
+ .map((s) => s.trim())
87
+ .filter(Boolean)
88
+ }
89
+
90
+ /**
91
+ * Load the degenerate-surface exclusion sets from the shipped libpostal dictionaries. Returns normalized-join keys
92
+ * (`normalizeTokens(surface).join(" ")`) so they compare exactly against the builder's insertion keys.
93
+ */
94
+ export function loadDegenerateSurfaces(languages: readonly string[] = CURATION_LANGUAGES): {
95
+ surfaces: Set<string>
96
+ stopwordTokens: Set<string>
97
+ } {
98
+ const dictionariesDir = String(repoRootPathBuilder("core", "data", "libpostal", "dictionaries"))
99
+ const surfaces = new Set<string>()
100
+ const stopwordTokens = new Set<string>()
101
+
102
+ for (const lang of languages) {
103
+ for (const [file, isStopwords] of [
104
+ ["stopwords.txt", true],
105
+ ["street_types.txt", false],
106
+ ] as const) {
107
+ const path = join(dictionariesDir, lang, file)
108
+
109
+ if (!existsSync(path)) continue
110
+
111
+ for (const line of readFileSync(path, "utf8").split("\n")) {
112
+ for (const surface of surfacesOfLine(line)) {
113
+ const tokens = normalizeTokens(surface)
114
+
115
+ if (tokens.length === 0) continue
116
+ surfaces.add(tokens.join(" "))
117
+
118
+ // Compositional clause sources from stopwords ONLY — single-token entries, so
119
+ // multi-word stopword phrases ("à côté de") never leak their content words in.
120
+ if (isStopwords && tokens.length === 1) {
121
+ stopwordTokens.add(tokens[0]!)
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ for (const s of SUPPLEMENTAL_DEGENERATE_SURFACES) {
129
+ const tokens = normalizeTokens(s)
130
+
131
+ if (tokens.length === 0) continue
132
+ surfaces.add(tokens.join(" "))
133
+
134
+ if (tokens.length === 1) {
135
+ stopwordTokens.add(tokens[0]!)
136
+ }
137
+ }
138
+
139
+ return { surfaces, stopwordTokens }
140
+ }
141
+
142
+ export interface BuildLocaleFSTsOpts {
143
+ /** Locales to build (default: every FST_LOCALES key). */
144
+ locales?: string[]
145
+ /** WOF admin DB (default: `$MAILWOMAN_DATA_ROOT/wof/admin-global-priority.db`). */
146
+ dbPath?: string
147
+ /** Output dir (default: `$MAILWOMAN_DATA_ROOT/wof/fst-per-locale-curated`). Never the shipped dir. */
148
+ outputDir?: string
149
+ /** Skip the curation (an A/B control build with the SAME current DB). */
150
+ uncurated?: boolean
151
+ onProgress?: (line: string) => void
152
+ }
153
+
154
+ export interface BuiltLocaleFST {
155
+ locale: string
156
+ path: string
157
+ bytes: number
158
+ nameInsertions: number
159
+ excludedInsertions: number
160
+ }
161
+
162
+ export function buildLocaleFSTs(opts: BuildLocaleFSTsOpts = {}): BuiltLocaleFST[] {
163
+ const locales = opts.locales ?? [...FST_LOCALES.keys()]
164
+ const dbPath = opts.dbPath ?? String(dataRootPath("wof", "admin-global-priority.db"))
165
+ const outputDir = resolve(opts.outputDir ?? String(dataRootPath("wof", "fst-per-locale-curated")))
166
+ const progress = opts.onProgress ?? (() => {})
167
+
168
+ const exclusion = opts.uncurated ? undefined : loadDegenerateSurfaces()
169
+
170
+ if (exclusion) {
171
+ progress(
172
+ `curation: ${exclusion.surfaces.size} whole surfaces + ${exclusion.stopwordTokens.size} stopword tokens (${EXCLUSION_POLICY_ID})`
173
+ )
174
+ }
175
+ mkdirSync(outputDir, { recursive: true })
176
+
177
+ const built: BuiltLocaleFST[] = []
178
+
179
+ for (const locale of locales) {
180
+ const countries = FST_LOCALES.get(locale)
181
+
182
+ if (!countries) throw new Error(`unknown FST locale ${locale} — add it to FST_LOCALES with its country scope`)
183
+
184
+ progress(`building fst-${locale} (countries=[${countries}]) from ${dbPath}`)
185
+ const { matcher, provenance } = buildFSTFromWOF({
186
+ dbPath,
187
+ countries,
188
+ ...(exclusion
189
+ ? {
190
+ excludeSurfaces: exclusion.surfaces,
191
+ excludeAllTokensOf: exclusion.stopwordTokens,
192
+ exclusionPolicy: EXCLUSION_POLICY_ID,
193
+ }
194
+ : {}),
195
+ onProgress: (phase, detail) => progress(` [${phase}] ${detail ?? ""}`),
196
+ })
197
+ const outPath = join(outputDir, `fst-${locale}${opts.uncurated ? ".uncurated" : ""}.bin`)
198
+ const bytes = serializeFST(matcher, provenance)
199
+ writeFileSync(outPath, bytes)
200
+ built.push({
201
+ locale,
202
+ path: outPath,
203
+ bytes: bytes.length,
204
+ nameInsertions: provenance.nameInsertions,
205
+ excludedInsertions: provenance.excludedInsertions ?? 0,
206
+ })
207
+ progress(
208
+ ` wrote ${outPath} (${(bytes.length / 1e6).toFixed(1)} MB, ${provenance.nameInsertions} insertions, ${provenance.excludedInsertions ?? 0} excluded)`
209
+ )
210
+ }
211
+
212
+ return built
213
+ }
@@ -40,6 +40,7 @@ import type { GeonamesIngestProgress } from "@mailwoman/resolver-wof-sqlite"
40
40
  import type { BuildCandidateResult } from "@mailwoman/resolver-wof-sqlite/build-candidate"
41
41
 
42
42
  import { mailwomanDataRoot } from "../resolver-backend.ts"
43
+ import { emitCoverageManifest } from "./coverage-manifest.ts"
43
44
 
44
45
  /**
45
46
  * The bilingual / alt-name EU set the GeoNames fold lifts (FI hard-resolve 69.5 → 85.8 %). GeoNames `<CC>.txt` dumps
@@ -190,7 +191,8 @@ export interface BuildOptions {
190
191
 
191
192
  /**
192
193
  * Build the byte-range candidate gazetteer from an admin DB + postcode shards. The FTS5-trigram fuzzy index is baked in
193
- * by `buildCandidateTable`. Pure pass-through to the canonical builder.
194
+ * by `buildCandidateTable`; the coverage manifest (survey candidate #2 — the artifact's own hard-filter coverage record
195
+ * + guard-B bboxes, see `coverage-manifest.ts`) is baked in before the seal.
194
196
  */
195
197
  export async function buildCandidate(opts: BuildOptions): Promise<BuildCandidateResult> {
196
198
  const { buildCandidateTable } = await import("@mailwoman/resolver-wof-sqlite/build-candidate")
@@ -201,6 +203,11 @@ export async function buildCandidate(opts: BuildOptions): Promise<BuildCandidate
201
203
  postcodes: [...(opts.postcodeShards ?? resolvePostcodeShards())],
202
204
  onProgress: opts.onProgress,
203
205
  })
206
+ // Coverage manifest (survey candidate #2): facts about the artifact live IN the artifact — bake the
207
+ // measured hard-filter coverage record + guard-B bboxes so consumers read them at open instead of
208
+ // falling back to the code constants. MUST run pre-seal (a shipped DB is never patched — rebuild).
209
+ opts.onProgress?.("coverage-manifest", "baking country coverage + bbox manifest")
210
+ await emitCoverageManifest({ dbPath: opts.out })
204
211
  // The sealed-artifact invariant: a built DB is a read-only asset from the moment it exists.
205
212
  sealDatabase(opts.out)
206
213
 
@@ -314,6 +321,7 @@ export function defaultGazetteerVersion(now: Date, suffix = "a"): string {
314
321
 
315
322
  return `${y}-${m}-${d}${suffix}`
316
323
  }
324
+ export * from "./coverage-manifest.ts"
317
325
  export * from "./defaults.ts"
318
326
  export * from "./fts.ts"
319
327
  export * from "./verify.ts"