mailwoman 7.2.1 → 7.3.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 (53) hide show
  1. package/commands/eval/poi-board.tsx +93 -0
  2. package/commands/gazetteer/build/poi-brands.tsx +91 -0
  3. package/commands/parse.tsx +8 -1
  4. package/commands/poi.tsx +16 -2
  5. package/default-reverse-geocoder.ts +64 -0
  6. package/eval-harness/poi-board.ts +563 -0
  7. package/gazetteer-pipeline/poi/build-brands.ts +243 -0
  8. package/gazetteer-pipeline/poi/build-poi.ts +3 -1
  9. package/out/commands/eval/poi-board.d.ts +32 -0
  10. package/out/commands/eval/poi-board.d.ts.map +1 -0
  11. package/out/commands/eval/poi-board.js +80 -0
  12. package/out/commands/eval/poi-board.js.map +1 -0
  13. package/out/commands/gazetteer/build/poi-brands.d.ts +27 -0
  14. package/out/commands/gazetteer/build/poi-brands.d.ts.map +1 -0
  15. package/out/commands/gazetteer/build/poi-brands.js +62 -0
  16. package/out/commands/gazetteer/build/poi-brands.js.map +1 -0
  17. package/out/commands/parse.d.ts.map +1 -1
  18. package/out/commands/parse.js +6 -1
  19. package/out/commands/parse.js.map +1 -1
  20. package/out/commands/poi.d.ts.map +1 -1
  21. package/out/commands/poi.js +14 -1
  22. package/out/commands/poi.js.map +1 -1
  23. package/out/default-reverse-geocoder.d.ts +35 -0
  24. package/out/default-reverse-geocoder.d.ts.map +1 -0
  25. package/out/default-reverse-geocoder.js +58 -0
  26. package/out/default-reverse-geocoder.js.map +1 -0
  27. package/out/eval-harness/poi-board.d.ts +192 -0
  28. package/out/eval-harness/poi-board.d.ts.map +1 -0
  29. package/out/eval-harness/poi-board.js +362 -0
  30. package/out/eval-harness/poi-board.js.map +1 -0
  31. package/out/gazetteer-pipeline/poi/build-brands.d.ts +119 -0
  32. package/out/gazetteer-pipeline/poi/build-brands.d.ts.map +1 -0
  33. package/out/gazetteer-pipeline/poi/build-brands.js +166 -0
  34. package/out/gazetteer-pipeline/poi/build-brands.js.map +1 -0
  35. package/out/gazetteer-pipeline/poi/build-poi.d.ts.map +1 -1
  36. package/out/gazetteer-pipeline/poi/build-poi.js +3 -2
  37. package/out/gazetteer-pipeline/poi/build-poi.js.map +1 -1
  38. package/out/poi-executor.d.ts +23 -0
  39. package/out/poi-executor.d.ts.map +1 -1
  40. package/out/poi-executor.js +42 -8
  41. package/out/poi-executor.js.map +1 -1
  42. package/out/poi-intent.d.ts +10 -1
  43. package/out/poi-intent.d.ts.map +1 -1
  44. package/out/poi-intent.js +60 -12
  45. package/out/poi-intent.js.map +1 -1
  46. package/out/runtime-pipeline.d.ts +11 -7
  47. package/out/runtime-pipeline.d.ts.map +1 -1
  48. package/out/runtime-pipeline.js +54 -14
  49. package/out/runtime-pipeline.js.map +1 -1
  50. package/package.json +23 -22
  51. package/poi-executor.ts +70 -10
  52. package/poi-intent.ts +72 -14
  53. package/runtime-pipeline.ts +77 -22
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman eval poi-board` — the curated POI query board (spec §3.6, exotic-POI arc). Runs the
7
+ * real `createRuntimePipeline({ poiQueryKind: { poiDatabasePath } })` surface against every
8
+ * committed fixture and grades the ASSEMBLED answer (matched category + coordinate), not label F1.
9
+ *
10
+ * Floors (spec §3.6, set off the v1 baseline): `overall ≥ 90%`, `abstain = 100%`, `address = 100%`.
11
+ * They are graded and printed on EVERY run. Pass `--enforce` to turn a breach into a non-zero exit
12
+ * (the CI-gate mode). Without `--enforce` the command stays report-only — it exits 0 on case
13
+ * failures, and a non-zero exit means the HARNESS broke (missing fixtures, missing db, a pipeline
14
+ * construction error), never a graded case failing.
15
+ */
16
+
17
+ import { Text } from "ink"
18
+ import zod from "zod"
19
+
20
+ import { type CommandComponent, useCommandTask } from "../../cli-kit/index.ts"
21
+ import { runPoiBoard } from "../../eval-harness/poi-board.ts"
22
+
23
+ export const description = "POI query board (spec §3.6) — graded on the assembled answer, v1 report-only"
24
+
25
+ const OptionsSchema = zod.object({
26
+ locale: zod
27
+ .string()
28
+ .optional()
29
+ .default("en-US")
30
+ .describe("Weights package locale for the classifier (default en-US)"),
31
+ weightsCache: zod
32
+ .string()
33
+ .optional()
34
+ .describe("Package-shaped candidate weights dir (mirrors eval parity --weights-cache)"),
35
+ fixtures: zod.string().optional().describe("Fixture JSONL override (default: the committed poi-board fixtures)"),
36
+ db: zod
37
+ .string()
38
+ .optional()
39
+ .describe("Sealed poi.db to query (default <data-root>/poi/poi.db — the gazetteer build poi default)"),
40
+ resolveDb: zod
41
+ .string()
42
+ .optional()
43
+ .describe("WOF admin shard path(s) for anchor resolution, comma-separated (same as `mailwoman poi --resolve-db`)"),
44
+ candidateDb: zod
45
+ .string()
46
+ .optional()
47
+ .describe(
48
+ "Byte-range candidate.db for anchor resolution (same as `mailwoman poi --candidate-db`; wins over --resolve-db)"
49
+ ),
50
+ json: zod
51
+ .boolean()
52
+ .optional()
53
+ .default(false)
54
+ .describe("Print the full report as JSON instead of the human-readable table"),
55
+ enforce: zod
56
+ .boolean()
57
+ .optional()
58
+ .default(false)
59
+ .describe("Exit non-zero if any pre-registered floor is breached (overall ≥ 90%, abstain/address = 100%)"),
60
+ })
61
+
62
+ export { OptionsSchema as options }
63
+
64
+ const EvalPoiBoard: CommandComponent<typeof OptionsSchema> = ({ options }) => {
65
+ const state = useCommandTask(
66
+ async () => {
67
+ const { report, exitCode } = await runPoiBoard({
68
+ locale: options.locale,
69
+ weightsCacheRoot: options.weightsCache,
70
+ fixturesPath: options.fixtures,
71
+ db: options.db,
72
+ resolveDb: options.resolveDb,
73
+ candidateDb: options.candidateDb,
74
+ quiet: options.json,
75
+ enforce: options.enforce,
76
+ })
77
+
78
+ return { report, exitCode }
79
+ },
80
+ ({ exitCode }) => exitCode
81
+ )
82
+
83
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
84
+
85
+ if (options.json && state.status === "done") {
86
+ return <Text>{JSON.stringify(state.result.report, null, 2)}</Text>
87
+ }
88
+
89
+ // Non-json mode: the runner narrates its table on stdout directly.
90
+ return null
91
+ }
92
+
93
+ export default EvalPoiBoard
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman gazetteer build poi-brands` — the POI brand lexicon builder, part 1 of 2 (part 2 wires
7
+ * `lookupPOIBrand` into the runtime pipeline; no pipeline wiring here). Thin wiring only: the read +
8
+ * aggregate + write logic lives in `gazetteer-pipeline/poi/build-brands.ts`, mirroring `build/poi.tsx`'s
9
+ * thin-command style. Reads a BUILT `poi.db` READ-ONLY — never writes one.
10
+ *
11
+ * `writeBrandTable`'s plain `JSON.stringify` doesn't collapse short primitive arrays onto one line the
12
+ * way `oxfmt` does (e.g. `"aliases": ["Foo"]` vs a 3-line array) — the process-y bit (shelling out, like
13
+ * `poi.tsx`'s own `git rev-parse`) belongs at the command layer, not in the pure/testable builder. Runs
14
+ * `oxfmt` on the output here so the emitted file is commit-ready without a manual format pass.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process"
18
+
19
+ import { Box, Text } from "ink"
20
+ import zod from "zod"
21
+
22
+ import { type CommandComponent, useCommandTask } from "../../../cli-kit/index.ts"
23
+ import {
24
+ buildBrandTable,
25
+ DEFAULT_DOMINANCE,
26
+ DEFAULT_MIN_ROWS,
27
+ defaultBrandTableOutPath,
28
+ defaultPOIDatabasePath,
29
+ writeBrandTable,
30
+ } from "../../../gazetteer-pipeline/poi/build-brands.ts"
31
+
32
+ const OptionsSchema = zod.object({
33
+ db: zod.string().optional().describe("Built poi.db to read. Default <data-root>/poi/poi.db"),
34
+ out: zod.string().optional().describe("brands.json output path. Default poi-taxonomy/data/brands.json"),
35
+ minRows: zod.string().optional().describe(`Minimum total rows to keep a brand. Default ${DEFAULT_MIN_ROWS}`),
36
+ dominance: zod
37
+ .string()
38
+ .optional()
39
+ .describe(
40
+ `Minimum fraction of a QID's total rows its modal name must cover to qualify — below this the QID is ` +
41
+ `dropped entirely (systematic mistagging). Default ${DEFAULT_DOMINANCE}`
42
+ ),
43
+ })
44
+
45
+ export { OptionsSchema as options }
46
+
47
+ const GazetteerBuildPOIBrands: CommandComponent<typeof OptionsSchema> = ({ options }) => {
48
+ const state = useCommandTask(async () => {
49
+ const dbPath = options.db ?? defaultPOIDatabasePath()
50
+ const out = options.out ?? defaultBrandTableOutPath()
51
+ const minRows = options.minRows ? Number.parseInt(options.minRows, 10) : DEFAULT_MIN_ROWS
52
+ const dominance = options.dominance ? Number.parseFloat(options.dominance) : DEFAULT_DOMINANCE
53
+
54
+ console.error(`▸ reading ${dbPath}`)
55
+ const table = await buildBrandTable({ dbPath, minRows, dominance })
56
+
57
+ console.error(`▸ writing ${out}`)
58
+ writeBrandTable(table, out)
59
+ execFileSync("yarn", ["oxfmt", out])
60
+
61
+ const top5 = table.brands
62
+ .slice(0, 5)
63
+ .map((b, i) => ` ${i + 1}. ${b.name} (${b.wikidata}) — ${b.rows.toLocaleString()} rows`)
64
+
65
+ return [
66
+ `brands.json: ${out} (${table.brands.length.toLocaleString()} brands, min-rows=${minRows}, dominance=${dominance})`,
67
+ `source: ${table.sourceLayer.name} ${table.sourceLayer.version} (vintage ${table.sourceLayer.sourceVintage})`,
68
+ "top 5 by rows:",
69
+ ...top5,
70
+ ]
71
+ })
72
+
73
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
74
+
75
+ if (state.status === "done") {
76
+ return (
77
+ <Box flexDirection="column">
78
+ {state.result.map((line, i) => (
79
+ <Text key={i} color={i === 0 ? "green" : undefined}>
80
+ {i === 0 ? "✓ " : " "}
81
+ {line}
82
+ </Text>
83
+ ))}
84
+ </Box>
85
+ )
86
+ }
87
+
88
+ return null // progress streams to stderr until the summary lands
89
+ }
90
+
91
+ export default GazetteerBuildPOIBrands
@@ -62,7 +62,14 @@ const ParseConfigSchema = zod.object({
62
62
  .optional()
63
63
  .default(false)
64
64
  .describe("In pipeline mode, skip the neural classifier (run normalize + queryShape + kind + resolver only)."),
65
- poi: zod.boolean().optional().default(false).describe("Enable poi_query detection (poiQueryKind flag)."),
65
+ poi: zod
66
+ .boolean()
67
+ .optional()
68
+ .default(true)
69
+ .describe(
70
+ "poi_query detection (poiQueryKind flag). DEFAULT-ON since 2026-07-20 (promotion battery: 0/4507 golden " +
71
+ "misroutes, 6/6 demo presets byte-identical). Pass --no-poi to restore the pre-flag address-only kind classification."
72
+ ),
66
73
  downloadWeights: zod
67
74
  .boolean()
68
75
  .optional()
package/commands/poi.tsx CHANGED
@@ -28,7 +28,7 @@
28
28
  import { existsSync } from "node:fs"
29
29
 
30
30
  import { Spinner } from "@inkjs/ui"
31
- import type { POIIntent, POIIntentOutcome } from "@mailwoman/core/pipeline"
31
+ import type { POIIntent, POIIntentOutcome, POIResult } from "@mailwoman/core/pipeline"
32
32
  import { NeuralAddressClassifier } from "@mailwoman/neural"
33
33
  import { getPOICategory } from "@mailwoman/poi-taxonomy"
34
34
  import { createWOFResolver, type Resolver } from "@mailwoman/resolver"
@@ -168,6 +168,20 @@ function formatOverpassBlock(intent: POIIntent): string {
168
168
  return emitOverpassQL(intent)
169
169
  }
170
170
 
171
+ /**
172
+ * Compact ancestry suffix, e.g. "· Springfield, IL, US" — locality/region/country entries, coarsest-last (the
173
+ * hierarchy's own deepest-first order reversed), skipping other placetypes (county, neighbourhood, …) to keep the table
174
+ * narrow. Empty string when `ancestry` is absent (no reverse geocoder wired) or carries none of those three tiers (e.g.
175
+ * open-ocean/approximate misses).
176
+ */
177
+ function formatAncestrySuffix(ancestry: POIResult["ancestry"]): string {
178
+ if (!ancestry || ancestry.length === 0) return ""
179
+ const byPlacetype = new Map(ancestry.map((a) => [a.placetype, a.name]))
180
+ const parts = ["locality", "region", "country"].map((t) => byPlacetype.get(t)).filter((name) => name !== undefined)
181
+
182
+ return parts.length > 0 ? ` · ${parts.join(", ")}` : ""
183
+ }
184
+
171
185
  function formatResultsTable(results: NonNullable<Extract<POIIntentOutcome, { type: "intent" }>["results"]>): string[] {
172
186
  if (results.length === 0) return ["(no results)"]
173
187
 
@@ -184,7 +198,7 @@ function formatResultsTable(results: NonNullable<Extract<POIIntentOutcome, { typ
184
198
  (r.distanceM !== undefined ? String(Math.round(r.distanceM)) : "-").padStart(10),
185
199
  r.latitude.toFixed(6).padStart(12),
186
200
  r.longitude.toFixed(6).padStart(12),
187
- ].join(" ")
201
+ ].join(" ") + formatAncestrySuffix(r.ancestry)
188
202
  )
189
203
  }
190
204
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The default read-time WOF reverse geocoder for POI ancestry — the poiQueryKind register row's second
7
+ * debt payment (runtime-flags.mdx): `createRuntimePipeline({ poiQueryKind: { poiDatabasePath } })`
8
+ * lazily loads this once per process and wires a SYNCHRONOUS adapter into `createPOIExecutor`'s
9
+ * `reverseGeocode` dep (the executor's return type carries no `Promise` — see `poi-intent.ts`'s
10
+ * `deps.execute`, called synchronously). `WOFReverseGeocoder.reverseGeocodeSync` (added alongside this
11
+ * feature) is the real synchronous core the async `reverseGeocode` already wrapped — see
12
+ * resolver-wof-sqlite/reverse.ts.
13
+ *
14
+ * `@mailwoman/resolver-wof-sqlite` is an optional peer dep and the admin gazetteer (with its
15
+ * `place_bbox` R*Tree, `mailwoman gazetteer build fts`) is a multi-GB build artifact that may not be on
16
+ * disk — either gap yields `null` and POI results simply carry no `ancestry` key at all (house
17
+ * meaning-of-zero: absence, never an empty array). The polygon sidecar (`wof-polygons.db`) is OPTIONAL
18
+ * too; without it every ancestry chain still resolves, just `containment: "approximate"`
19
+ * (`WOFReverseGeocoder`'s own centroid-descent fallback).
20
+ *
21
+ * Admin DB resolution mirrors `resolver-backend.ts`'s `wofShardPaths()` — the SAME default
22
+ * `@mailwoman/photon`'s and `@mailwoman/nominatim`'s `serve` commands use for their own
23
+ * `WOFReverseGeocoder` (`photon/cli.ts`, `nominatim/cli.ts`): first existing shard in the list wins
24
+ * (`admin-global-priority.db` first). The polygon sidecar is read from `$MAILWOMAN_WOF_POLYGONS_DB` —
25
+ * the same env var `mailwoman reverse` reads (`commands/reverse.tsx`).
26
+ */
27
+
28
+ import { existsSync } from "node:fs"
29
+
30
+ import { $public } from "@mailwoman/core/env"
31
+ import type { WOFReverseGeocoder as WOFReverseGeocoderType } from "@mailwoman/resolver-wof-sqlite"
32
+
33
+ import { wofShardPaths } from "./resolver-backend.ts"
34
+
35
+ let cached: Promise<WOFReverseGeocoderType | null> | null = null
36
+
37
+ /**
38
+ * Lazy-load + cache the default `WOFReverseGeocoder`. `null` when no admin shard with a `place_bbox` R*Tree is on disk,
39
+ * or `@mailwoman/resolver-wof-sqlite` can't be resolved (stripped install) — the pipeline then wires no
40
+ * `reverseGeocode` fn at all, so POI results degrade to no `ancestry` (byte-stable pre-feature behavior). Cached for
41
+ * the process lifetime (one handle, reused).
42
+ */
43
+ export function loadDefaultReverseGeocoder(): Promise<WOFReverseGeocoderType | null> {
44
+ if (!cached) {
45
+ cached = (async (): Promise<WOFReverseGeocoderType | null> => {
46
+ try {
47
+ const adminDBPath = wofShardPaths().filter(existsSync)[0]
48
+
49
+ if (!adminDBPath) return null
50
+ const { WOFReverseGeocoder } = await import("@mailwoman/resolver-wof-sqlite")
51
+ const polygonDBPath = $public.MAILWOMAN_WOF_POLYGONS_DB
52
+
53
+ return new WOFReverseGeocoder({
54
+ adminDBPath,
55
+ ...(polygonDBPath && existsSync(polygonDBPath) ? { polygonDBPath } : {}),
56
+ })
57
+ } catch {
58
+ return null
59
+ }
60
+ })()
61
+ }
62
+
63
+ return cached
64
+ }