mailwoman 8.3.0 → 8.4.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 (34) hide show
  1. package/commands/gazetteer/build/bdc.tsx +203 -0
  2. package/commands/gazetteer/build/poi.tsx +131 -7
  3. package/eval-harness/gates/v7.0.0-base.json +1 -0
  4. package/eval-harness/gauntlet/cases/regression.ts +909 -0
  5. package/eval-harness/gauntlet/harness.ts +57 -5
  6. package/eval-harness/gauntlet/regression.ts +5 -1
  7. package/eval-harness/promotion-gate.ts +3 -1
  8. package/gazetteer-pipeline/poi/build-poi.ts +114 -20
  9. package/out/commands/gazetteer/build/bdc.d.ts +43 -0
  10. package/out/commands/gazetteer/build/bdc.d.ts.map +1 -0
  11. package/out/commands/gazetteer/build/bdc.js +148 -0
  12. package/out/commands/gazetteer/build/bdc.js.map +1 -0
  13. package/out/commands/gazetteer/build/poi.d.ts +18 -0
  14. package/out/commands/gazetteer/build/poi.d.ts.map +1 -1
  15. package/out/commands/gazetteer/build/poi.js +103 -7
  16. package/out/commands/gazetteer/build/poi.js.map +1 -1
  17. package/out/eval-harness/gauntlet/cases/regression.d.ts.map +1 -1
  18. package/out/eval-harness/gauntlet/cases/regression.js +903 -0
  19. package/out/eval-harness/gauntlet/cases/regression.js.map +1 -1
  20. package/out/eval-harness/gauntlet/harness.d.ts +7 -0
  21. package/out/eval-harness/gauntlet/harness.d.ts.map +1 -1
  22. package/out/eval-harness/gauntlet/harness.js +46 -8
  23. package/out/eval-harness/gauntlet/harness.js.map +1 -1
  24. package/out/eval-harness/gauntlet/regression.d.ts.map +1 -1
  25. package/out/eval-harness/gauntlet/regression.js +5 -1
  26. package/out/eval-harness/gauntlet/regression.js.map +1 -1
  27. package/out/eval-harness/promotion-gate.d.ts.map +1 -1
  28. package/out/eval-harness/promotion-gate.js +3 -1
  29. package/out/eval-harness/promotion-gate.js.map +1 -1
  30. package/out/gazetteer-pipeline/poi/build-poi.d.ts +51 -1
  31. package/out/gazetteer-pipeline/poi/build-poi.d.ts.map +1 -1
  32. package/out/gazetteer-pipeline/poi/build-poi.js +70 -17
  33. package/out/gazetteer-pipeline/poi/build-poi.js.map +1 -1
  34. package/package.json +25 -23
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `mailwoman gazetteer build bdc` — the FCC BDC availability ingest + sealed `bdc.db` layer build
7
+ * (2a Task 8). Thin wiring only: list → download → build lives in `@mailwoman/bdc/sdk`
8
+ * (`buildBDCDatabase`, list-files.ts, download.ts), so it stays unit-testable without Ink/Pastel or
9
+ * network in the loop. Mirrors `poi.tsx`'s progress (stderr) / summary (stdout) split.
10
+ *
11
+ * First cut wires Fixed Broadband provider availability only (the primary wireline dataset) — mobile
12
+ * broadband/voice subcategories are a future flag, not a scope gap in this command's shape.
13
+ *
14
+ * `--provider-list-path` (3a Task 8, decision 6) opts INTO populating `bdc_provider` —
15
+ * `bdc_availability` itself is unaffected either way. When given, `parseProviderList`
16
+ * (`@mailwoman/filer/sdk`) streams the FCC provider-list CSV as `BuildBDCOptions.providers`, and
17
+ * filer.db (`--filer-db-path`, default `<data-root>/filer/filer.db`) is opened read-only to resolve
18
+ * each multi-FRN provider's primary FRN (`readFRNFilingCandidates` + `pickPrimaryFRN`, imported
19
+ * rather than reimplemented — see `build-bdc.ts`'s `BuildBDCOptions.filerDB` docstring). Omitting
20
+ * `--provider-list-path` leaves `bdc_provider` empty, exactly as before Task 8.
21
+ *
22
+ * Both paths are `existsSync`-guarded BEFORE any download/build work starts (review fix round 1,
23
+ * IMPORTANT-2): `populateBDCProviderTable` only runs after `writeLayerManifest`, i.e. at the very END
24
+ * of a full build — an unguarded typo'd `--provider-list-path` would otherwise surface as a raw ENOENT
25
+ * only after a nationwide availability ingest had already finished, discarding hours of work.
26
+ * `--filer-db-path` given without `--provider-list-path` is a loud error, not a silent no-op (MINOR):
27
+ * filer.db is only ever read to resolve a multi-FRN primary FRN, so it does nothing without a provider
28
+ * list to resolve FRNs FOR.
29
+ */
30
+
31
+ import { execFileSync } from "node:child_process"
32
+ import { existsSync } from "node:fs"
33
+ import { DatabaseSync } from "node:sqlite"
34
+
35
+ import {
36
+ BDCFileCategory,
37
+ BDCFilingDataType,
38
+ BDCProviderSubCategory,
39
+ buildBDCDatabase,
40
+ createBDCClient,
41
+ createTIGERBlockCentroidLookup,
42
+ downloadBDCFile,
43
+ resolveLatestVintage,
44
+ retrieveAvailabilityFiles,
45
+ retrieveFilingDates,
46
+ } from "@mailwoman/bdc/sdk"
47
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
48
+ import { dataRootPath } from "@mailwoman/core/utils"
49
+ import type { FilerDatabase } from "@mailwoman/filer"
50
+ import { parseProviderList } from "@mailwoman/filer/sdk"
51
+ import { Box, Text } from "ink"
52
+ import zod from "zod"
53
+
54
+ import { type CommandComponent, useCommandTask } from "../../../cli-kit/index.ts"
55
+ import { artifactSizeMB } from "../../../gazetteer-pipeline/admin/index.ts"
56
+
57
+ const OptionsSchema = zod.object({
58
+ state: zod.string().describe('2-digit FCC state/territory FIPS code (e.g. "06" for California)'),
59
+ asOfDate: zod.string().optional().describe("FCC filing as_of_date (YYYY-MM-DD). Default: latest available"),
60
+ out: zod.string().optional().describe("bdc.db output path. Default <data-root>/bdc/bdc.db"),
61
+ includeLocationIds: zod
62
+ .boolean()
63
+ .default(false)
64
+ .describe("Populate bdc_availability.location_id (opaque BSL join key; NULL by default)"),
65
+ providerListPath: zod
66
+ .string()
67
+ .optional()
68
+ .describe(
69
+ "Path to the FCC BDC provider list CSV (frn/provider_id/holding_company). When given, populates " +
70
+ "bdc_provider (decision 6, 3a task 8); omit to leave bdc_provider empty, as before task 8."
71
+ ),
72
+ filerDbPath: zod
73
+ .string()
74
+ .optional()
75
+ .describe(
76
+ "Path to filer.db, used to resolve a multi-FRN provider's primary FRN when --provider-list-path is given. " +
77
+ "Default <data-root>/filer/filer.db"
78
+ ),
79
+ })
80
+
81
+ export { OptionsSchema as options }
82
+
83
+ const GazetteerBuildBDC: CommandComponent<typeof OptionsSchema> = ({ options }) => {
84
+ const state = useCommandTask(async () => {
85
+ // Fail-fast guards (review fix round 1, IMPORTANT-2 / MINOR) — checked BEFORE any network/download work
86
+ // starts. `populateBDCProviderTable` only runs after the availability ingest AND writeLayerManifest, so
87
+ // without this, a typo'd --provider-list-path would surface only at the very end of a full national
88
+ // build, discarding hours of work for a check that costs microseconds up front.
89
+ if (options.filerDbPath && !options.providerListPath) {
90
+ throw new Error(
91
+ "gazetteer build bdc: --filer-db-path was given without --provider-list-path — filer.db is only read " +
92
+ "to resolve a multi-FRN provider's primary FRN, so it has no effect without a provider list. Pass " +
93
+ "--provider-list-path too, or drop --filer-db-path."
94
+ )
95
+ }
96
+
97
+ let filerDbPath: string | undefined
98
+
99
+ if (options.providerListPath) {
100
+ if (!existsSync(options.providerListPath)) {
101
+ throw new Error(`gazetteer build bdc: --provider-list-path not found: "${options.providerListPath}"`)
102
+ }
103
+
104
+ filerDbPath = options.filerDbPath ?? dataRootPath("filer", "filer.db")
105
+
106
+ if (!existsSync(filerDbPath)) {
107
+ throw new Error(`gazetteer build bdc: filer.db not found at "${filerDbPath}" (--filer-db-path)`)
108
+ }
109
+ }
110
+
111
+ const client = createBDCClient()
112
+
113
+ let asOfDate = options.asOfDate
114
+
115
+ if (!asOfDate) {
116
+ console.error("▸ resolving latest as_of_date...")
117
+
118
+ const dates = await retrieveFilingDates(client, { filingType: BDCFilingDataType.Availability })
119
+ asOfDate = resolveLatestVintage(dates, BDCFilingDataType.Availability)
120
+ }
121
+
122
+ console.error(`▸ as-of-date: ${asOfDate}`)
123
+
124
+ const files = await retrieveAvailabilityFiles(client, {
125
+ asOfDate,
126
+ category: BDCFileCategory.Provider,
127
+ subcategory: BDCProviderSubCategory.FixedBroadband,
128
+ })
129
+
130
+ const stateFiles = files.filter((file) => file.stateCode === options.state)
131
+
132
+ console.error(`▸ ${stateFiles.length} provider file(s) for state ${options.state} @ ${asOfDate}`)
133
+
134
+ const cacheDir = dataRootPath("bdc", "cache", "availability", asOfDate, options.state)
135
+ const csvPaths: string[] = []
136
+
137
+ for (const file of stateFiles) {
138
+ console.error(` downloading ${file.fileName}...`)
139
+
140
+ csvPaths.push(await downloadBDCFile(client, file, cacheDir))
141
+ }
142
+
143
+ const out = options.out ?? dataRootPath("bdc", "bdc.db")
144
+ const buildSHA = execFileSync("git", ["rev-parse", "--short", "HEAD"]).toString().trim()
145
+ const tigerDBPath = dataRootPath("tiger", "tiger.db")
146
+
147
+ console.error(`▸ build: ${out}`)
148
+
149
+ // --provider-list-path (3a Task 8, decision 6) opts into populating bdc_provider — omitted, `providers`/
150
+ // `filerDB` stay undefined and buildBDCDatabase's default (byte-identical) path runs unchanged. Both
151
+ // paths were already existsSync-validated above, so this can't ENOENT.
152
+ let filerDB: DatabaseClient<FilerDatabase> | undefined
153
+
154
+ if (filerDbPath) {
155
+ console.error(`▸ provider list: ${options.providerListPath} (filer.db: ${filerDbPath})`)
156
+
157
+ filerDB = new DatabaseClient<FilerDatabase>({ database: new DatabaseSync(filerDbPath, { readOnly: true }) })
158
+ }
159
+
160
+ try {
161
+ const result = await buildBDCDatabase({
162
+ csvPaths,
163
+ out,
164
+ asOfDate,
165
+ buildSHA,
166
+ includeLocationIDs: options.includeLocationIds,
167
+ blockCentroids: createTIGERBlockCentroidLookup(tigerDBPath),
168
+ providers: options.providerListPath ? parseProviderList(options.providerListPath) : undefined,
169
+ filerDB,
170
+ onProgress: (message) => console.error(` [bdc] ${message}`),
171
+ })
172
+
173
+ return [
174
+ `bdc.db: ${out} (${artifactSizeMB(out)} MB)`,
175
+ `${result.rows.toLocaleString()} rows · ${result.providers} provider(s) · ${result.deduped.toLocaleString()} deduped` +
176
+ ` · ${result.unknownGeoids.toLocaleString()} unknown geoid(s) · ${result.coverageCells.toLocaleString()} coverage cells` +
177
+ (options.providerListPath ? ` · ${result.providersPopulated.toLocaleString()} bdc_provider row(s)` : ""),
178
+ `manifest: name=bdc tier=shipped license=public-domain source=fcc-bdc sourceVintage=${asOfDate} buildSHA=${buildSHA}`,
179
+ ]
180
+ } finally {
181
+ await filerDB?.destroy()
182
+ }
183
+ })
184
+
185
+ if (state.status === "error") return <Text color="red">✗ {state.message}</Text>
186
+
187
+ if (state.status === "done") {
188
+ return (
189
+ <Box flexDirection="column">
190
+ {state.result.map((line, i) => (
191
+ <Text key={i} color={i === 0 ? "green" : undefined}>
192
+ {i === 0 ? "✓ " : " "}
193
+ {line}
194
+ </Text>
195
+ ))}
196
+ </Box>
197
+ )
198
+ }
199
+
200
+ return null // progress streams to stderr until the summary lands
201
+ }
202
+
203
+ export default GazetteerBuildBDC
@@ -8,40 +8,164 @@
8
8
  * materialize/seal (`buildPOIDatabase`) logic lives in `gazetteer-pipeline/poi/build-poi.ts`, so it
9
9
  * stays unit-testable without Ink/Pastel in the loop. Mirrors `overture-ingest.tsx`'s progress
10
10
  * (stderr) / summary (stdout) split.
11
+ *
12
+ * `--source osm` (bdc 2b task 3, decisions 3/5): a second, build-local branch alongside the default
13
+ * Overture path — same command, same `buildPOIDatabase` seam, different inputs. It streams
14
+ * `@mailwoman/osm/sdk`'s `extractOSMPOIs` over a Geofabrik `.osm.pbf` extract (telecom-infrastructure
15
+ * categories only, task 2), stamps the invocation's `--country` onto every row (a bare OSM feature
16
+ * carries no country property — see `extract-poi.ts`'s module docstring), and derives res-6 coverage
17
+ * by polyfilling the extract's declared `--bbox` (`bboxCoverageCells`, decision 5) rather than only
18
+ * the cells rows happened to land in — an infra-only extract is sparse by category, so "no plant in a
19
+ * well-surveyed cell" needs its own zero-observed-rows coverage row, not silent absence. The manifest
20
+ * swaps to `tier: build-local`, `license: ODbL-1.0`, OSM attribution — the default Overture branch
21
+ * below is UNTOUCHED and stays byte-identical when `--source` is omitted.
11
22
  */
12
23
 
13
24
  import { execFileSync } from "node:child_process"
14
25
 
26
+ import { LayerTier } from "@mailwoman/core/layers"
15
27
  import { dataRootPath } from "@mailwoman/core/utils"
16
28
  import { Box, Text } from "ink"
17
29
  import zod from "zod"
18
30
 
19
31
  import { type CommandComponent, useCommandTask } from "../../../cli-kit/index.ts"
20
32
  import { artifactSizeMB } from "../../../gazetteer-pipeline/admin/index.ts"
21
- import { buildPOIDatabase, DEFAULT_RELEASE, ingestPlaces } from "../../../gazetteer-pipeline/poi/build-poi.ts"
33
+ import {
34
+ bboxCoverageCells,
35
+ buildPOIDatabase,
36
+ DEFAULT_RELEASE,
37
+ ingestPlaces,
38
+ type BBox,
39
+ type POISourceRow,
40
+ } from "../../../gazetteer-pipeline/poi/build-poi.ts"
22
41
 
23
42
  const DEFAULT_COUNTRIES = "US,CA,MX,FR"
24
43
 
25
44
  const OptionsSchema = zod.object({
26
- release: zod.string().optional().describe(`Pinned Overture release. Default ${DEFAULT_RELEASE}`),
27
- countries: zod.string().optional().describe(`ISO 3166-1 alpha-2, comma-separated. Default ${DEFAULT_COUNTRIES}`),
28
- out: zod.string().optional().describe("poi.db output path. Default <data-root>/poi/poi.db"),
29
- limit: zod.string().optional().describe("Cap rows per country (debug)"),
45
+ source: zod
46
+ .enum(["overture", "osm"])
47
+ .default("overture")
48
+ .describe("Data source: overture (default, shipped) or osm (build-local, ODbL — requires --pbf/--country/--bbox)"),
49
+ release: zod
50
+ .string()
51
+ .optional()
52
+ .describe(`overture: pinned release, default ${DEFAULT_RELEASE}. osm: the extract's vintage/date tag (required)`),
53
+ countries: zod
54
+ .string()
55
+ .optional()
56
+ .describe(`overture only: ISO 3166-1 alpha-2, comma-separated. Default ${DEFAULT_COUNTRIES}`),
57
+ out: zod
58
+ .string()
59
+ .optional()
60
+ .describe("poi.db output path. Default <data-root>/poi/poi.db (overture) or poi/poi-osm-<cc>.db (osm)"),
61
+ limit: zod.string().optional().describe("overture only: cap rows per country (debug)"),
30
62
  skipIngest: zod
31
63
  .boolean()
32
64
  .default(false)
33
- .describe("Skip the DuckDB/S3 ingest; build from already-materialized per-country Parquet"),
65
+ .describe("overture only: skip the DuckDB/S3 ingest; build from already-materialized per-country Parquet"),
66
+ pbf: zod.string().optional().describe("osm: path to a Geofabrik .osm.pbf extract (required)"),
67
+ country: zod
68
+ .string()
69
+ .optional()
70
+ .describe("osm: ISO 3166-1 alpha-2 country stamped onto every extracted row (required)"),
71
+ bbox: zod
72
+ .string()
73
+ .optional()
74
+ .describe("osm: extract bounding box as 'minLon,minLat,maxLon,maxLat' — polyfills res-6 coverage cells (required)"),
34
75
  })
35
76
 
36
77
  export { OptionsSchema as options }
37
78
 
79
+ /**
80
+ * `--bbox` field count: `minLon,minLat,maxLon,maxLat`.
81
+ */
82
+ const BBOX_FIELD_COUNT = 4
83
+
84
+ /**
85
+ * Parse `--bbox "minLon,minLat,maxLon,maxLat"` into a {@link BBox}. Throws with the raw input echoed back on any
86
+ * shape/finiteness mismatch — a silently-mis-parsed bbox would corrupt coverage silently, so fail loud instead.
87
+ */
88
+ function parseBBoxFlag(raw: string): BBox {
89
+ const parts = raw.split(",").map((s) => Number(s.trim()))
90
+
91
+ if (parts.length !== BBOX_FIELD_COUNT || parts.some((n) => !Number.isFinite(n))) {
92
+ throw new Error(
93
+ `--bbox must be 4 comma-separated numbers "minLon,minLat,maxLon,maxLat", got ${JSON.stringify(raw)}`
94
+ )
95
+ }
96
+
97
+ const [minLon, minLat, maxLon, maxLat] = parts as [number, number, number, number]
98
+
99
+ return { minLon, minLat, maxLon, maxLat }
100
+ }
101
+
38
102
  const GazetteerBuildPOI: CommandComponent<typeof OptionsSchema> = ({ options }) => {
39
103
  const state = useCommandTask(async () => {
104
+ const buildSHA = execFileSync("git", ["rev-parse", "--short", "HEAD"]).toString().trim()
105
+
106
+ if (options.source === "osm") {
107
+ const { pbf, country: rawCountry, release, bbox: bboxFlag } = options
108
+
109
+ if (!pbf || !rawCountry || !release || !bboxFlag) {
110
+ throw new Error(
111
+ "gazetteer build poi --source osm requires --pbf <extract.osm.pbf> --country <cc> --release <vintage> " +
112
+ "--bbox <minLon,minLat,maxLon,maxLat>"
113
+ )
114
+ }
115
+
116
+ const country = rawCountry.trim().toUpperCase()
117
+ const bbox = parseBBoxFlag(bboxFlag)
118
+ const out = options.out ?? dataRootPath("poi", `poi-osm-${country.toLowerCase()}.db`)
119
+
120
+ console.error(`▸ extract: OSM telecom-infrastructure POIs from ${pbf} (country=${country})`)
121
+
122
+ // Buffered (not streamed straight into buildPOIDatabase): telecom-infra extracts are
123
+ // category-sparse — a few thousand rows even for a whole country — and the array is walked
124
+ // twice, once here to derive coverage, once as buildPOIDatabase's `rows` seam.
125
+ const rows: POISourceRow[] = []
126
+
127
+ // DYNAMIC import, load-bearing: @mailwoman/osm is UNPUBLISHED (ODbL counsel sign-off
128
+ // pending — see osm/README.md), and Pastel loads every command module eagerly on --help,
129
+ // so a top-level import breaks the whole published CLI on a clean install (caught by the
130
+ // smoke test's clean-install leg, 2026-07-31). The osm source branch is build-local by
131
+ // design; it may only resolve its SDK when actually invoked.
132
+ const { extractOSMPOIs } = await import("@mailwoman/osm/sdk")
133
+
134
+ for await (const row of extractOSMPOIs(pbf)) {
135
+ // Task 3 hand-off from task 2: extractOSMPOIs yields `country: ""` (a bare OSM feature
136
+ // carries no country property) — stamp the invocation's --country before buildPOIDatabase
137
+ // ever sees the row.
138
+ rows.push({ ...row, country })
139
+ }
140
+
141
+ console.error(`▸ build: ${out}`)
142
+
143
+ const coverageCellsOverride = bboxCoverageCells(bbox, rows)
144
+
145
+ const result = await buildPOIDatabase({
146
+ rows,
147
+ out,
148
+ release,
149
+ buildSHA,
150
+ source: "osm",
151
+ tier: LayerTier.BuildLocal,
152
+ coverageCellsOverride,
153
+ createdAt: new Date().toISOString(),
154
+ onProgress: (phase, message) => console.error(` [${phase}] ${message}`),
155
+ })
156
+
157
+ return [
158
+ `poi.db (osm/${country}): ${out} (${artifactSizeMB(out)} MB)`,
159
+ `${result.rows.toLocaleString()} rows · ${result.categories} categories` +
160
+ ` · ${result.skipped.toLocaleString()} skipped (non-finite coords) · ${result.coverageCells.toLocaleString()} coverage cells`,
161
+ `manifest: name=poi tier=build-local source=osm sourceVintage=${release} buildSHA=${buildSHA}`,
162
+ ]
163
+ }
164
+
40
165
  const release = options.release ?? DEFAULT_RELEASE
41
166
  const countries = (options.countries ?? DEFAULT_COUNTRIES).split(",").map((c) => c.trim().toUpperCase())
42
167
  const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined
43
168
  const out = options.out ?? dataRootPath("poi", "poi.db")
44
- const buildSHA = execFileSync("git", ["rev-parse", "--short", "HEAD"]).toString().trim()
45
169
 
46
170
  let parquetPaths: string[]
47
171
 
@@ -1,4 +1,5 @@
1
1
  {
2
+ "label": "v7.0.0-base",
2
3
  "$comment": "Gate spec for the v7.0.0 base lineage — cut from the v4.0.1-base-60k candidate's SHIP-CONFIG readings (int8, locality lexicon v6 per the G8 DE-fold revert; v7 trained the channel, v6 ships — DE surfaces run the trained absence identity). Philosophy inherited from v6.0.0-shipped-baseline; margins now use each metric's REAL support with WILSON intervals (the normal approximation under-margins boundary readings — 100% at n=36 priced at 1pp where Wilson prices ~10pp; the v6-era 2.0pp fallback class is retired).",
3
4
  "$margin_rationale": "floor = ship-config reading - max(1.0pp, 2 x the downward Wilson 95% half-width at the metric's own support). Supports = each instrument's tp+fn or fixture n, recorded in $supports.",
4
5
  "$supports": {