mailwoman 7.8.1 → 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 (127) hide show
  1. package/api-engine.ts +7 -3
  2. package/commands/eval/oa-resolver.tsx +1 -1
  3. package/commands/gazetteer/build/fst.tsx +60 -0
  4. package/commands/gazetteer/build/street-morphology.tsx +54 -0
  5. package/commands/geocode.tsx +4 -3
  6. package/commands/parse.tsx +72 -19
  7. package/commands/release/hf.tsx +6 -0
  8. package/commands/situs/interpolation-shard.tsx +23 -1
  9. package/eval-harness/baselines.json +577 -0
  10. package/eval-harness/gates/v0.5.0-bridge.json +33 -0
  11. package/eval-harness/gates/v1.6.0-boundary-stress.json +29 -0
  12. package/eval-harness/gates/v1.7.0-boundary-stress.json +31 -0
  13. package/eval-harness/gates/v2.0.0-width.json +21 -0
  14. package/eval-harness/gates/v2.3.0-nl-postcode.json +34 -0
  15. package/eval-harness/gates/v4.15.0-boundary.json +29 -0
  16. package/eval-harness/gates/v4.2.0-ship.json +21 -0
  17. package/eval-harness/gates/v4.3.0-relabel.json +23 -0
  18. package/eval-harness/gates/v4.4.0-boundary.json +29 -0
  19. package/eval-harness/gates/v5.2.0-nordic.json +30 -0
  20. package/eval-harness/gates/v5.3.0-family.json +33 -0
  21. package/eval-harness/gates/v6.0.0-shipped-baseline.json +37 -0
  22. package/eval-harness/gauntlet/build-regression-db.ts +1 -0
  23. package/eval-harness/gauntlet/cases/regression.ts +301 -2
  24. package/eval-harness/gauntlet/harness.ts +28 -5
  25. package/eval-harness/gauntlet/regression.ts +19 -2
  26. package/eval-harness/gauntlet/schema.ts +5 -1
  27. package/eval-harness/oa-resolver-eval.ts +1 -3
  28. package/eval-harness/parity-corpus.ts +7 -4
  29. package/gazetteer-pipeline/coverage-manifest.ts +163 -0
  30. package/gazetteer-pipeline/fst.ts +213 -0
  31. package/gazetteer-pipeline/index.ts +9 -1
  32. package/gazetteer-pipeline/pair-index-hierarchy-probe.ts +310 -0
  33. package/gazetteer-pipeline/pair-index-hierarchy-verify.ts +278 -0
  34. package/gazetteer-pipeline/street-morphology.ts +94 -0
  35. package/geocode-core.ts +61 -16
  36. package/out/api-engine.d.ts.map +1 -1
  37. package/out/api-engine.js +7 -2
  38. package/out/api-engine.js.map +1 -1
  39. package/out/commands/eval/oa-resolver.d.ts +0 -1
  40. package/out/commands/eval/oa-resolver.d.ts.map +1 -1
  41. package/out/commands/eval/oa-resolver.js +0 -1
  42. package/out/commands/eval/oa-resolver.js.map +1 -1
  43. package/out/commands/gazetteer/build/fst.d.ts +22 -0
  44. package/out/commands/gazetteer/build/fst.d.ts.map +1 -0
  45. package/out/commands/gazetteer/build/fst.js +42 -0
  46. package/out/commands/gazetteer/build/fst.js.map +1 -0
  47. package/out/commands/gazetteer/build/street-morphology.d.ts +22 -0
  48. package/out/commands/gazetteer/build/street-morphology.d.ts.map +1 -0
  49. package/out/commands/gazetteer/build/street-morphology.js +47 -0
  50. package/out/commands/gazetteer/build/street-morphology.js.map +1 -0
  51. package/out/commands/geocode.d.ts.map +1 -1
  52. package/out/commands/geocode.js +4 -3
  53. package/out/commands/geocode.js.map +1 -1
  54. package/out/commands/parse.d.ts.map +1 -1
  55. package/out/commands/parse.js +64 -17
  56. package/out/commands/parse.js.map +1 -1
  57. package/out/commands/release/hf.d.ts +1 -0
  58. package/out/commands/release/hf.d.ts.map +1 -1
  59. package/out/commands/release/hf.js +4 -0
  60. package/out/commands/release/hf.js.map +1 -1
  61. package/out/commands/situs/interpolation-shard.d.ts.map +1 -1
  62. package/out/commands/situs/interpolation-shard.js +21 -1
  63. package/out/commands/situs/interpolation-shard.js.map +1 -1
  64. package/out/eval-harness/gauntlet/build-regression-db.d.ts.map +1 -1
  65. package/out/eval-harness/gauntlet/build-regression-db.js +1 -1
  66. package/out/eval-harness/gauntlet/build-regression-db.js.map +1 -1
  67. package/out/eval-harness/gauntlet/cases/regression.d.ts +2 -0
  68. package/out/eval-harness/gauntlet/cases/regression.d.ts.map +1 -1
  69. package/out/eval-harness/gauntlet/cases/regression.js +296 -2
  70. package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
  71. package/out/eval-harness/gauntlet/harness.d.ts +10 -2
  72. package/out/eval-harness/gauntlet/harness.d.ts.map +1 -1
  73. package/out/eval-harness/gauntlet/harness.js +16 -4
  74. package/out/eval-harness/gauntlet/harness.js.map +1 -1
  75. package/out/eval-harness/gauntlet/regression.d.ts.map +1 -1
  76. package/out/eval-harness/gauntlet/regression.js +18 -2
  77. package/out/eval-harness/gauntlet/regression.js.map +1 -1
  78. package/out/eval-harness/gauntlet/schema.d.ts +4 -2
  79. package/out/eval-harness/gauntlet/schema.d.ts.map +1 -1
  80. package/out/eval-harness/gauntlet/schema.js +2 -0
  81. package/out/eval-harness/gauntlet/schema.js.map +1 -1
  82. package/out/eval-harness/oa-resolver-eval.d.ts +0 -2
  83. package/out/eval-harness/oa-resolver-eval.d.ts.map +1 -1
  84. package/out/eval-harness/oa-resolver-eval.js +1 -1
  85. package/out/eval-harness/oa-resolver-eval.js.map +1 -1
  86. package/out/eval-harness/parity-corpus.d.ts.map +1 -1
  87. package/out/eval-harness/parity-corpus.js +7 -4
  88. package/out/eval-harness/parity-corpus.js.map +1 -1
  89. package/out/gazetteer-pipeline/coverage-manifest.d.ts +59 -0
  90. package/out/gazetteer-pipeline/coverage-manifest.d.ts.map +1 -0
  91. package/out/gazetteer-pipeline/coverage-manifest.js +141 -0
  92. package/out/gazetteer-pipeline/coverage-manifest.js.map +1 -0
  93. package/out/gazetteer-pipeline/fst.d.ts +68 -0
  94. package/out/gazetteer-pipeline/fst.d.ts.map +1 -0
  95. package/out/gazetteer-pipeline/fst.js +165 -0
  96. package/out/gazetteer-pipeline/fst.js.map +1 -0
  97. package/out/gazetteer-pipeline/index.d.ts +3 -1
  98. package/out/gazetteer-pipeline/index.d.ts.map +1 -1
  99. package/out/gazetteer-pipeline/index.js +9 -1
  100. package/out/gazetteer-pipeline/index.js.map +1 -1
  101. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.d.ts +81 -0
  102. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.d.ts.map +1 -0
  103. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.js +224 -0
  104. package/out/gazetteer-pipeline/pair-index-hierarchy-probe.js.map +1 -0
  105. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.d.ts +31 -0
  106. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.d.ts.map +1 -0
  107. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.js +225 -0
  108. package/out/gazetteer-pipeline/pair-index-hierarchy-verify.js.map +1 -0
  109. package/out/gazetteer-pipeline/street-morphology.d.ts +49 -0
  110. package/out/gazetteer-pipeline/street-morphology.d.ts.map +1 -0
  111. package/out/gazetteer-pipeline/street-morphology.js +63 -0
  112. package/out/gazetteer-pipeline/street-morphology.js.map +1 -0
  113. package/out/geocode-core.d.ts +17 -6
  114. package/out/geocode-core.d.ts.map +1 -1
  115. package/out/geocode-core.js +34 -9
  116. package/out/geocode-core.js.map +1 -1
  117. package/out/release-tools/publish-hf.d.ts +3 -2
  118. package/out/release-tools/publish-hf.d.ts.map +1 -1
  119. package/out/release-tools/publish-hf.js +65 -15
  120. package/out/release-tools/publish-hf.js.map +1 -1
  121. package/out/runtime-pipeline.d.ts +14 -12
  122. package/out/runtime-pipeline.d.ts.map +1 -1
  123. package/out/runtime-pipeline.js +79 -2
  124. package/out/runtime-pipeline.js.map +1 -1
  125. package/package.json +26 -24
  126. package/release-tools/publish-hf.ts +77 -18
  127. package/runtime-pipeline.ts +108 -4
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
  }
@@ -44,7 +44,7 @@ const OptionsSchema = zod.object({
44
44
  // scripts/eval shim the probes spawn) keep the original two-key contract.
45
45
  adminCoherenceOff: zod.boolean().default(false).describe("#895 tri-state pin: force adminCoherence OFF"),
46
46
  hierarchyCompletion: zod.boolean().default(false).describe("#405: recover the dual-role-place locality"),
47
- cityStateFallback: zod.boolean().default(false).describe("#387 alias for --hierarchy-completion"),
47
+
48
48
  // coordinate tiers
49
49
  postcodeAnchor: zod.boolean().default(false).describe("Add the neural+anchor row (anchor-centroid coordinate)"),
50
50
  postcodeShards: zod.string().optional().describe("Postcode shards for the anchor rows (comma-separated)"),
@@ -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()
@@ -11,10 +11,10 @@ import { collectProposals, filterByPolicy, InMemoryPolicyRegistry, type PolicyMo
11
11
  import type { ComponentTag, Section } from "@mailwoman/core/types"
12
12
  import { createNeuralProposalClassifier, NeuralAddressClassifier } from "@mailwoman/neural"
13
13
  import { weightsPackageName } from "@mailwoman/neural/weights"
14
- import { createWOFResolver, type Resolver, type ResolverBackend } from "@mailwoman/resolver"
14
+ import { createWOFResolver, type Resolver } from "@mailwoman/resolver"
15
15
  import { Text } from "ink"
16
16
  import { createRuntimePipeline } from "mailwoman"
17
- import React from "react"
17
+ import type React from "react"
18
18
  import zod from "zod"
19
19
 
20
20
  import { type CommandComponent, commandError, useCommandTask } from "../cli-kit/index.ts"
@@ -396,17 +396,27 @@ function serializeTree(
396
396
  }
397
397
 
398
398
  /**
399
- * Encoder-less structural parse (plan 3): the REAL pipeline stages (normalize query-shape locale-gate → kind →
400
- * grouper fast-paths) with no neural classifier. The tree carries what the structural stages can prove (postcode_only /
401
- * locality_only fast-paths populate it; free-form addresses may yield an empty tree). Banner goes to stderr so stdout
402
- * stays machine-parseable.
399
+ * The generic degraded-mode banner (stderr stdout stays machine-parseable). Emitted when the guard hands back a
400
+ * `declined` outcome (interactive "n" / `--degraded` / a failed download) or the explicit `--no-neural` skip paths
401
+ * that never attempt an encoder load, so `tryLoadNeural`'s precise absent-vs-load-error warning didn't fire. The
402
+ * attempted-and-failed case is announced by `tryLoadNeural` instead (see #1108), so this banner is deliberately NOT
403
+ * emitted there (it would double up).
403
404
  */
404
- async function runDegraded(input: string, options: zod.infer<typeof ParseConfigSchema>): Promise<string> {
405
+ function emitDegradedBanner(options: zod.infer<typeof ParseConfigSchema>): void {
405
406
  console.error(
406
407
  "⚠ degraded parse: the neural encoder is not loaded — output carries structural-pipeline results only.\n" +
407
408
  ` Upgrade: npm install ${weightsPackageName(options.locale)} or mailwoman parse --download-weights <address>`
408
409
  )
410
+ }
409
411
 
412
+ /**
413
+ * Encoder-less structural parse (plan 3), WITHOUT the banner: the REAL pipeline stages (normalize → query-shape →
414
+ * locale-gate → kind → grouper fast-paths) with no neural classifier. The tree carries what the structural stages can
415
+ * prove (postcode_only / locality_only fast-paths populate it; free-form addresses may yield an empty tree). The caller
416
+ * owns the degraded notice — either {@link emitDegradedBanner} or the precise absent/load-error warning `tryLoadNeural`
417
+ * already printed — so no path degrades silently, and none double-warns.
418
+ */
419
+ async function runStructuralPipeline(input: string, options: zod.infer<typeof ParseConfigSchema>): Promise<string> {
410
420
  const pipeline = createRuntimePipeline({ poiQueryKind: options.poi })
411
421
  const result = await pipeline(input, { locale: options.locale })
412
422
 
@@ -415,20 +425,37 @@ async function runDegraded(input: string, options: zod.infer<typeof ParseConfigS
415
425
  : serializeTree(result.tree, options.format, { includeAlternatives: false })
416
426
  }
417
427
 
428
+ /**
429
+ * Structural parse fronted by the generic degraded banner — the guard's `declined` / `--no-neural` entry point (the
430
+ * caller here did NOT attempt an encoder load, so it owns the notice).
431
+ */
432
+ async function runDegraded(input: string, options: zod.infer<typeof ParseConfigSchema>): Promise<string> {
433
+ emitDegradedBanner(options)
434
+
435
+ return runStructuralPipeline(input, options)
436
+ }
437
+
418
438
  /**
419
439
  * Default path: runtime pipeline. Lazy-loads the neural classifier + optional resolver. Returns the parsed tree
420
440
  * serialized in the requested format. When the encoder is unavailable, degrades to the structural-pipeline stages
421
441
  * (normalize → query-shape → kind → grouper fast-paths) rather than any rules parser.
422
442
  */
423
443
  async function runPipeline(input: string, options: zod.infer<typeof ParseConfigSchema>): Promise<string> {
424
- const classifier = options.noNeural ? undefined : await tryLoadNeural(options)
425
-
426
- // When the encoder isn't loaded and there's no resolver/debug work to do, the full pipeline can
427
- // only emit QueryShape fast-path structure. Route to the degraded structural path (with its banner)
428
- // so the CLI still produces useful output for the fast-path kinds (postcode_only, locality_only).
429
- // `--debug` stays on the pipeline so the operator gets the requested PipelineResult JSON shape.
444
+ // `tryLoadNeural` emits its own precise (absent vs. corrupt/load-error) stderr warning when the load
445
+ // fails, so an attempted-but-failed encoder load is NEVER silent — including on the --resolve/--debug
446
+ // paths, which don't route through the degraded banner below (#1108). The explicit --no-neural skip
447
+ // attempts no load (and gets no such warning); the degraded banner covers it instead.
448
+ const attemptedNeural = !options.noNeural
449
+ const classifier = attemptedNeural ? await tryLoadNeural(options) : undefined
450
+
451
+ // When the encoder isn't loaded and there's no resolver/debug work to do, the full pipeline can only
452
+ // emit QueryShape fast-path structure. Route to the structural path so the CLI still produces useful
453
+ // output for the fast-path kinds (postcode_only, locality_only). `--debug` stays on the pipeline so
454
+ // the operator gets the requested PipelineResult JSON shape.
430
455
  if (!classifier && !options.resolve && !options.debug) {
431
- return runDegraded(input, options)
456
+ // tryLoadNeural already warned when the load was attempted; only the explicit --no-neural skip
457
+ // still needs the generic degraded banner (it never called tryLoadNeural).
458
+ return attemptedNeural ? runStructuralPipeline(input, options) : runDegraded(input, options)
432
459
  }
433
460
 
434
461
  const wantAlternatives = options.candidates !== undefined
@@ -624,7 +651,18 @@ async function runBenchmark(
624
651
  return lines.join("\n")
625
652
  }
626
653
 
627
- /** Try to load the neural classifier; return undefined (with stderr note) if weights are absent. */
654
+ /**
655
+ * Try to load the neural classifier. NEVER throws — on failure it emits a LOUD one-line stderr warning and returns
656
+ * `undefined` so the caller degrades to the structural pipeline (postcode_only / locality_only fast-paths still
657
+ * resolve; `npx mailwoman parse …` always produces output). Distinguishes two failure modes (#1108) so a consumer can't
658
+ * attribute silently-degraded output to the neural parser:
659
+ *
660
+ * - Weights ABSENT (package not installed / carries no binaries) → an install hint, no scary error text.
661
+ * - Weights present but the encoder FAILED to load (corrupt / partial bundle) → the underlying error is surfaced, not
662
+ * swallowed.
663
+ *
664
+ * The warning goes to STDERR, never STDOUT, so piped stdout parsing is unaffected.
665
+ */
628
666
  async function tryLoadNeural(
629
667
  options: zod.infer<typeof ParseConfigSchema>
630
668
  ): Promise<NeuralAddressClassifier | undefined> {
@@ -634,10 +672,25 @@ async function tryLoadNeural(
634
672
  modelPath: options.model,
635
673
  tokenizerPath: options.tokenizer,
636
674
  })
637
- } catch {
638
- // Graceful degradation: pipeline runs normalize + queryShape + kind + resolver only.
639
- // The caller sees `tree.roots` populated from QueryShape fast-paths (postcode_only,
640
- // locality_only) but nothing from the encoder.
675
+ } catch (error) {
676
+ // Graceful degradation: pipeline runs normalize + queryShape + kind (+ resolver) only. The caller
677
+ // sees `tree.roots` populated from QueryShape fast-paths but nothing from the encoder — so we warn.
678
+ const message = error instanceof Error ? error.message : String(error)
679
+ // "Absent" = the weights package simply isn't installed (the resolver's not-found signal). Every OTHER
680
+ // failure means the weights DID resolve but the encoder couldn't load them — a partial/metadata-only
681
+ // bundle ("missing model files"), a bad explicit --model/--tokenizer path, or a corrupt artifact — so we
682
+ // surface the underlying error verbatim rather than mislabel it "not installed" and swallow the cause.
683
+ const absent = /Could not resolve/iu.test(message)
684
+
685
+ if (absent) {
686
+ console.error(
687
+ `⚠ neural weights not found — running a degraded structural parse; ` +
688
+ `install ${weightsPackageName(options.locale)} for full accuracy.`
689
+ )
690
+ } else {
691
+ console.error(`⚠ neural weights failed to load — running a degraded structural parse. Encoder error: ${message}`)
692
+ }
693
+
641
694
  return undefined
642
695
  }
643
696
  }
@@ -42,6 +42,12 @@ const OptionsSchema = zod.object({
42
42
  .describe(
43
43
  "Comma-separated placetype-pair-index soft-feed binaries (pair-index-gb.bin,…; placetype-pair-prior arc, Task 8)"
44
44
  ),
45
+ fsts: zod
46
+ .string()
47
+ .optional()
48
+ .describe(
49
+ "Comma-separated per-locale FST gazetteers for the npm packages (fst-en-us.bin,fst-fr-fr.bin,fst-en-gb.bin; #1318). Staged flat by lowercase basename; publish.yml fetches these into each weights workspace."
50
+ ),
45
51
  gazetteerLexicon: zod
46
52
  .string()
47
53
  .optional()
@@ -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