@mailwoman/bdc 8.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 (87) hide show
  1. package/README.md +7 -0
  2. package/index.ts +2 -0
  3. package/out/index.d.ts +3 -0
  4. package/out/index.d.ts.map +1 -0
  5. package/out/index.js +3 -0
  6. package/out/index.js.map +1 -0
  7. package/out/schema.d.ts +138 -0
  8. package/out/schema.d.ts.map +1 -0
  9. package/out/schema.js +88 -0
  10. package/out/schema.js.map +1 -0
  11. package/out/sdk/build-bdc.d.ts +214 -0
  12. package/out/sdk/build-bdc.d.ts.map +1 -0
  13. package/out/sdk/build-bdc.js +500 -0
  14. package/out/sdk/build-bdc.js.map +1 -0
  15. package/out/sdk/buildings.d.ts +84 -0
  16. package/out/sdk/buildings.d.ts.map +1 -0
  17. package/out/sdk/buildings.js +75 -0
  18. package/out/sdk/buildings.js.map +1 -0
  19. package/out/sdk/client.d.ts +237 -0
  20. package/out/sdk/client.d.ts.map +1 -0
  21. package/out/sdk/client.js +430 -0
  22. package/out/sdk/client.js.map +1 -0
  23. package/out/sdk/common.d.ts +279 -0
  24. package/out/sdk/common.d.ts.map +1 -0
  25. package/out/sdk/common.js +162 -0
  26. package/out/sdk/common.js.map +1 -0
  27. package/out/sdk/data-collection.d.ts +244 -0
  28. package/out/sdk/data-collection.d.ts.map +1 -0
  29. package/out/sdk/data-collection.js +50 -0
  30. package/out/sdk/data-collection.js.map +1 -0
  31. package/out/sdk/download.d.ts +36 -0
  32. package/out/sdk/download.d.ts.map +1 -0
  33. package/out/sdk/download.js +76 -0
  34. package/out/sdk/download.js.map +1 -0
  35. package/out/sdk/filing-dates.d.ts +57 -0
  36. package/out/sdk/filing-dates.d.ts.map +1 -0
  37. package/out/sdk/filing-dates.js +50 -0
  38. package/out/sdk/filing-dates.js.map +1 -0
  39. package/out/sdk/filing-landscape.d.ts +124 -0
  40. package/out/sdk/filing-landscape.d.ts.map +1 -0
  41. package/out/sdk/filing-landscape.js +216 -0
  42. package/out/sdk/filing-landscape.js.map +1 -0
  43. package/out/sdk/index.d.ts +15 -0
  44. package/out/sdk/index.d.ts.map +1 -0
  45. package/out/sdk/index.js +15 -0
  46. package/out/sdk/index.js.map +1 -0
  47. package/out/sdk/list-files.d.ts +39 -0
  48. package/out/sdk/list-files.d.ts.map +1 -0
  49. package/out/sdk/list-files.js +26 -0
  50. package/out/sdk/list-files.js.map +1 -0
  51. package/out/sdk/location.d.ts +40 -0
  52. package/out/sdk/location.d.ts.map +1 -0
  53. package/out/sdk/location.js +23 -0
  54. package/out/sdk/location.js.map +1 -0
  55. package/out/sdk/nearest-infrastructure.d.ts +84 -0
  56. package/out/sdk/nearest-infrastructure.d.ts.map +1 -0
  57. package/out/sdk/nearest-infrastructure.js +84 -0
  58. package/out/sdk/nearest-infrastructure.js.map +1 -0
  59. package/out/sdk/parsing.d.ts +57 -0
  60. package/out/sdk/parsing.d.ts.map +1 -0
  61. package/out/sdk/parsing.js +87 -0
  62. package/out/sdk/parsing.js.map +1 -0
  63. package/out/sdk/plausibility.d.ts +256 -0
  64. package/out/sdk/plausibility.d.ts.map +1 -0
  65. package/out/sdk/plausibility.js +334 -0
  66. package/out/sdk/plausibility.js.map +1 -0
  67. package/out/sdk/technologies.d.ts +229 -0
  68. package/out/sdk/technologies.d.ts.map +1 -0
  69. package/out/sdk/technologies.js +217 -0
  70. package/out/sdk/technologies.js.map +1 -0
  71. package/package.json +97 -0
  72. package/schema.ts +172 -0
  73. package/sdk/build-bdc.ts +810 -0
  74. package/sdk/buildings.ts +87 -0
  75. package/sdk/client.ts +643 -0
  76. package/sdk/common.ts +363 -0
  77. package/sdk/data-collection.ts +268 -0
  78. package/sdk/download.ts +91 -0
  79. package/sdk/filing-dates.ts +88 -0
  80. package/sdk/filing-landscape.ts +287 -0
  81. package/sdk/index.ts +14 -0
  82. package/sdk/list-files.ts +72 -0
  83. package/sdk/location.ts +45 -0
  84. package/sdk/nearest-infrastructure.ts +129 -0
  85. package/sdk/parsing.ts +124 -0
  86. package/sdk/plausibility.ts +512 -0
  87. package/sdk/technologies.ts +258 -0
package/sdk/parsing.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file FCC BDC availability CSV byte-level parser.
6
+ *
7
+ * Re-homed from Nexus's `sync/fcc/bdc/parsing.ts` (relicense-by-copy, no provenance headers): the same
8
+ * byte-scanning generator over the FCC's 12-column availability CSV — see the Nexus `RawBSLAvailabilityRow`
9
+ * tuple (`sync/fcc/bdc/block-aggregator.ts`) for the column order this scan assumes: frn, provider_id,
10
+ * brand_name, location_id, technology, max_advertised_download_speed, max_advertised_upload_speed,
11
+ * low_latency, business_residential_code, state_usps, block_geoid, h3_res8_id. Changed only where the
12
+ * pre-registered 2a decisions require: `geoid` (column 10, `block_geoid`) decodes to an ASCII string
13
+ * instead of staying a raw `Uint8Array` slice (decision 3), and `location_id` (column 3) stays a string
14
+ * instead of `parseInt`ing it, preserving leading zeros (decision 1). `business_residential_code` is
15
+ * likewise decoded to its ASCII string here — the Nexus original kept only the field's first raw byte as
16
+ * a bare `number`, which this port's `BDCAvailabilityRow` shape doesn't call for.
17
+ *
18
+ * Columns 0-2 (frn, provider_id, brand_name) and 9, 11 (state_usps, h3_res8_id) are scanned over but
19
+ * never sliced into the output record: `provider_id` comes from the `providerID` parameter instead (the
20
+ * FCC partitions availability files per provider, so the caller already knows it), and FRN/brand/state/H3
21
+ * join concerns are out of scope for 2a (decision 8 — provider identity is a 2c registry-join seam).
22
+ */
23
+
24
+ import type { ProviderID } from "./common.ts"
25
+
26
+ /**
27
+ * Byte values this scanner switches on.
28
+ *
29
+ * Local to this file — no generic newline-delimited-file utility exists elsewhere in the repo to import. Nexus's
30
+ * equivalent (`LineDelimitedCharacter`) lived in `@isp.nexus/sdk/files`, a workspace this port doesn't carry over, and
31
+ * repo convention forbids TS `enum` anyway (`erasableSyntaxOnly`).
32
+ */
33
+ const CSVByte = {
34
+ Newline: 10,
35
+ Comma: 44,
36
+ DoubleQuote: 34,
37
+ One: 49,
38
+ } as const
39
+
40
+ /**
41
+ * A single parsed row of FCC BDC availability data.
42
+ *
43
+ * @see {@linkcode takeAvailabilityLine}
44
+ */
45
+ export interface BDCAvailabilityRow {
46
+ provider_id: number
47
+ /**
48
+ * Kept as a string — the FCC's `location_id` values are zero-padded 10-digit strings; `parseInt`ing would lose
49
+ * leading zeros (2a decision 1).
50
+ */
51
+ location_id: string
52
+ technology_code: number
53
+ max_advertised_download_speed: number
54
+ max_advertised_upload_speed: number
55
+ low_latency: 0 | 1
56
+ business_residential_code: string
57
+ /**
58
+ * Decoded to an ASCII string at the parse boundary (2a decision 3) — joins `TIGERBlockTable.GEOID` (note: uppercase
59
+ * column on that side).
60
+ */
61
+ geoid: string
62
+ }
63
+
64
+ /**
65
+ * Given a buffer containing FCC BDC availability CSV data, yield each data row (the header row is skipped) as a
66
+ * {@linkcode BDCAvailabilityRow}.
67
+ *
68
+ * A byte-level scan, not a general CSV parser: it tracks comma/newline byte positions directly and only toggles
69
+ * quote-awareness via a running double-quote count (an odd count means the scanner is currently inside a quoted field,
70
+ * so a comma there isn't a column delimiter) — matching the Nexus original's approach for this specific, known-shaped
71
+ * 12-column file rather than reaching for a general CSV library.
72
+ */
73
+ export function* takeAvailabilityLine(csvBuffer: Buffer, providerID: ProviderID): Iterable<BDCAvailabilityRow> {
74
+ // Skip the header row.
75
+ let byteIndex = csvBuffer.indexOf(CSVByte.Newline) + 1
76
+ const contentDelimiters = new Uint32Array(12) // 12 columns
77
+ contentDelimiters[0] = byteIndex
78
+ let delimiterIndex = 1
79
+ let doubleQuoteCount = 0
80
+
81
+ while (byteIndex < csvBuffer.length) {
82
+ const byte = csvBuffer[byteIndex]
83
+
84
+ if (byte === CSVByte.DoubleQuote) {
85
+ doubleQuoteCount++
86
+ }
87
+
88
+ if (byte === CSVByte.Comma && doubleQuoteCount % 2 === 0) {
89
+ contentDelimiters[delimiterIndex] = byteIndex
90
+
91
+ delimiterIndex++
92
+ }
93
+
94
+ if (byte === CSVByte.Newline) {
95
+ contentDelimiters[delimiterIndex] = CSVByte.Newline
96
+ const slices: Buffer[] = []
97
+
98
+ // Skip columns 0-2 (frn, provider_id, brand_name) — see the file header for why.
99
+ for (let i = 3; i < contentDelimiters.length; i++) {
100
+ const start = contentDelimiters[i]! + 1
101
+ const end = contentDelimiters[i + 1]
102
+
103
+ slices.push(csvBuffer.subarray(start, end))
104
+ }
105
+
106
+ const record: BDCAvailabilityRow = {
107
+ provider_id: providerID,
108
+ location_id: slices[0]!.toString("ascii"),
109
+ technology_code: Number.parseInt(slices[1]!.toString(), 10),
110
+ max_advertised_download_speed: Number.parseInt(slices[2]!.toString(), 10),
111
+ max_advertised_upload_speed: Number.parseInt(slices[3]!.toString(), 10),
112
+ low_latency: slices[4]![0] === CSVByte.One ? 1 : 0,
113
+ business_residential_code: slices[5]!.toString("ascii"),
114
+ geoid: slices[7]!.toString("ascii"),
115
+ }
116
+
117
+ yield record
118
+ delimiterIndex = 1
119
+ doubleQuoteCount = 0
120
+ }
121
+
122
+ byteIndex++
123
+ }
124
+ }
@@ -0,0 +1,512 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `plausibilityCheck` (2b task 5, spec §3.2/§4) — the heart of the BDC plausibility vertical. Composes
7
+ * `filingLandscape` (2a) + `nearestInfrastructure` (2b task 4) into ONE evidence bundle over a single
8
+ * broadband-service claim, under the registry-backed doctrine's positive-evidence-only invariant (spec
9
+ * §4): a BDC filing or a nearby infrastructure hit can RAISE confidence; their absence can only ever
10
+ * read as "unknown" or "no supporting evidence found, coverage permitting" — NEVER as "implausible."
11
+ * The four §7-2b acceptance gates land in Task 6 as their own test file; this module is designed for
12
+ * them but doesn't assert them itself.
13
+ *
14
+ * **Claim resolution (decision 4).** A claim's spatial key resolves two INDEPENDENT ways:
15
+ *
16
+ * - **Filing evidence's spatial key**: `claim.geoid` wins outright when present — `filingLandscape({
17
+ * geoids: [claim.geoid] })` is the NATIVE, exact path (no h3 approximation needed), so
18
+ * `block_resolution` is `"geoid"`. Otherwise a coordinate is required (`claim.point` directly, or
19
+ * `claim.address` geocoded via `deps.geocode`) and filing evidence goes through
20
+ * `filingLandscape({ h3Cells: [cell] })` — this is the UNSOUND h3-cell approximation decision 4
21
+ * pins (a claim's own res-9 cell can differ from its true block centroid's cell), so
22
+ * `block_resolution` is `"h3_cell_approximation"`. Only one of the two is ever emitted — the union
23
+ * type is deliberately NOT `("geoid" | "h3_cell_approximation")[]`.
24
+ * - **Physical evidence's search center**: independent of the above — `claim.point` (or the geocoded
25
+ * `claim.address`) directly, whenever available. A GEOID-ONLY claim (no point, no address) has NO
26
+ * coordinate to search from: bdc.db stores no public geoid→centroid resolver (deriving one would
27
+ * need the same Fabric-adjacent block-centroid machinery the vertical explicitly keeps out of reach
28
+ * — §2.2's boundary), so physical evidence is skipped entirely for that shape of claim. This is a
29
+ * genuine, documented capability gap, not a missing-layer abstain: `coverage_confidence` still
30
+ * degrades honestly (see below), but no `PlausibilityEvidence` abstain variant fits "no coordinate
31
+ * available," so none is fabricated.
32
+ *
33
+ * **Tech → physical-category mapping (decision 8's "reuse, never re-derive" extended to this table).**
34
+ * {@link PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES}: fiber (`OpticalCarrierFiber`, code 50) implies
35
+ * `telecom_exchange`/`telecom_cabinet`/`data_center`; the three fixed-wireless codes (70/71/72) imply
36
+ * `tower_comms`; every other code (DSL, cable, satellite, power-line, other) maps to `[]` — no physical
37
+ * falsifier is claimed for those technologies, because OSM/Overture carry no plant category that
38
+ * physically falsifies them. A `[]` mapping means the physical-evidence step is skipped as
39
+ * NOT-APPLICABLE (distinct from "layer missing" — see `coverage_confidence` below).
40
+ *
41
+ * **Filing evidence.** Every `ProviderFilingSummary` row `filingLandscape` returns for the resolved
42
+ * block becomes its own `{ type: "filing" }` evidence entry (ANY provider filing there is positive
43
+ * evidence a market exists, informative regardless of tech match — spec §3.2 step 2's "a filing that
44
+ * contradicts it… weak signal, not disproof"). `corroborates` is true only when the filing's
45
+ * `technology_code` matches the claim AND its `speed_bucket` ranks at or above the claimed download
46
+ * speed's own bucket (via the exported {@link speedBucketForDownloadSpeed} + the four bucket consts —
47
+ * decision 8: reused, never re-derived). A same-tech LESSER filing, or a different-tech filing, is
48
+ * still emitted with `corroborates: false` — never treated as disproof of anything.
49
+ *
50
+ * - bdc.db absent entirely → one `{ type: "abstain", reason: "requires_bdc_layer" }` entry (decision
51
+ * 6); `vintage` stays `null` — the ONLY case it does (per the produced type's own doc comment).
52
+ * - bdc.db present but the resolved block/cell itself carries no survey evidence (`unknown_block_count`
53
+ * > 0 for the one queried unit) → `{ type: "abstain", reason: "insufficient_survey_data", layer:
54
+ * "bdc" }`. `vintage` IS still populated here — the LAYER didn't abstain, only this one cell lacks
55
+ * coverage.
56
+ * - bdc.db present, block surveyed, zero filings → the spec's POSITIVE meaning-of-zero case ("a
57
+ * genuine 'surveyed, zero providers here' result" — `filing-landscape.ts`'s own docstring). No
58
+ * `filing` evidence entries are pushed (there's nothing to report), but the filing layer still
59
+ * counts as COVERED for `coverage_confidence` — the absence is informative, not unknown.
60
+ *
61
+ * **Physical evidence.** Symmetric to the above, over `nearestInfrastructure`'s hits — every hit
62
+ * becomes its own `{ type: "physical_plant" }` entry, nearest-first, whatever `nearestInfrastructure`'s
63
+ * own ring/limit budget returns.
64
+ *
65
+ * - The tech maps to `[]` categories (no physical falsifier claimed) → the step is skipped outright;
66
+ * no evidence entry, no abstain, and this axis is excluded from `coverage_confidence` (see below).
67
+ * - The tech implies categories but `deps.poi` is absent → `{ type: "abstain", reason:
68
+ * "requires_build_local_layer", layer: "poi" }` (decision 6 — the poi-executor abstain precedent).
69
+ * - The tech implies categories, `deps.poi` is present, but no coordinate is resolvable (a geoid-only
70
+ * claim) → no evidence entry, no abstain (see the claim-resolution note above); the axis degrades to
71
+ * UNKNOWN for `coverage_confidence` purposes.
72
+ * - Otherwise → `nearestInfrastructure` runs; each hit is emitted, and the searched point's OWN res-6
73
+ * coverage cell (independent of whether any hit was found — a covered-but-empty cell is real
74
+ * evidence the area was surveyed) is read directly via `readLayerCoverage` to determine the layer's
75
+ * coverage state for this claim.
76
+ *
77
+ * **`coverage_confidence` — survey completeness, NOT evidence-found.** This is deliberately orthogonal
78
+ * to whether any evidence was actually found (spec §4 rule 4: "coverage_confidence is mandatory on
79
+ * every answer… the product's honesty is this refusal to guess" — a refusal that has to hold even when
80
+ * the answer turns out to be "nothing found"). Each layer contributes one of `"covered"` / `"unknown"`
81
+ * / `"not_applicable"` (the last only for the physical axis, when the tech maps to no category), and
82
+ * the pair combines per the brief's literal formula: both covered → `"high"`; either UNKNOWN → degrade
83
+ * to `"low"`; both absent/unknown → `"insufficient_survey_data"`.
84
+ *
85
+ * The `"not_applicable"` extension (not literally spelled out in the brief, since the brief's formula
86
+ * assumes a real pair) is this module's own documented decision, deliberately CONSERVATIVE: a tech with
87
+ * no physical falsifier (DSL, cable, satellite, power-line) can never reach `"high"` confidence from
88
+ * filing coverage alone — it degrades to filing-covered → `"low"`, filing-unknown/absent →
89
+ * `"insufficient_survey_data"`. Rationale: spec §4 frames physical co-presence as the vertical's
90
+ * distinguishing "falsifier that only fires positively" and reserves `"high"` for genuine two-channel
91
+ * corroboration (Gate 2 in Task 6: "matching filing + nearby plant in covered cells… high"); a tech
92
+ * that structurally can never offer a second channel should not be able to claim the same top
93
+ * confidence a fiber claim earns by actually having one available. This reading is more conservative
94
+ * than the alternative (treating the missing axis as inert and reporting `"high"` off filing alone) —
95
+ * flagged here for review since Task 6's four gates don't exercise a no-physical-falsifier tech code.
96
+ *
97
+ * **Ledger note (task 4 review; extended task 5 fix round 1, finding 2) — the per-layer coverage-resolution sanity
98
+ * check.** Neither bdc.db's nor poi.db's `layer_manifest` records the COVERAGE-cell h3 resolution (6) that
99
+ * `res9ShortCellToRes6Parent` hardcodes on both sides — only each layer's ROW-spine resolution (9,
100
+ * `spineKeys.h3.resolution`) is ever recorded. A real fix needs a layer-contract schema addition (out
101
+ * of scope for this task; the same follow-up task 4's report already ticketed). What IS practical and
102
+ * cheap: each manifest is a single-row table already read at most once per call here, so whenever a layer is
103
+ * WIRED — `bdcDB`, `poi`, or both, checked independently — {@link assertLayerSpineResolution} compares that one
104
+ * layer's recorded `spineKeys.h3.resolution` directly against the `BDC_H3_RESOLUTION` constant `pointCell` is
105
+ * actually derived from, and throws on a mismatch, catching a layer built at a different spine resolution before it
106
+ * silently mis-joins a coverage cell. This is TWO-SIDED, not gated on both layers being present together: a
107
+ * poi-only call still checks poi's own recorded resolution, since `readLayerCoverage`'s poi-side join key (below)
108
+ * is derived from `BDC_H3_RESOLUTION` regardless of whether `bdcDB` is wired at all (the original one-sided version
109
+ * compared the two manifests to each other, so it silently skipped poi entirely whenever `bdcDB` was absent). It
110
+ * can NOT catch a layer whose row spine is 9 but whose COVERAGE cells were derived at some OTHER resolution than 6
111
+ * — that gap needs the schema addition, not a runtime assertion.
112
+ */
113
+
114
+ import type { DatabaseClient } from "@mailwoman/core/kysley/client"
115
+ import { readLayerCoverage, readLayerManifest, type LayerContractDatabase } from "@mailwoman/core/layers"
116
+ import type { POILookup } from "@mailwoman/resolver-wof-sqlite/poi-lookup"
117
+ import { shortCellToInt, type H3Cell, type PointLiteral } from "@mailwoman/spatial"
118
+ import { latLngToCell } from "h3-js"
119
+ import type { Kysely } from "kysely"
120
+
121
+ import { BDC_H3_RESOLUTION, type BDCDatabase } from "../schema.ts"
122
+ import {
123
+ BDC_SPEED_BUCKET_100_1000,
124
+ BDC_SPEED_BUCKET_25_100,
125
+ BDC_SPEED_BUCKET_GIGABIT,
126
+ BDC_SPEED_BUCKET_UNDER_25,
127
+ filingLandscape,
128
+ res9ShortCellToRes6Parent,
129
+ speedBucketForDownloadSpeed,
130
+ type ProviderFilingSummary,
131
+ } from "./filing-landscape.ts"
132
+ import { nearestInfrastructure, type InfrastructureHit } from "./nearest-infrastructure.ts"
133
+ import {
134
+ BroadbandTechnologyCategory,
135
+ BroadbandTechnologyCategoryToCodeSet,
136
+ BroadbandTechnologyCode,
137
+ } from "./technologies.ts"
138
+
139
+ /**
140
+ * The three fixed-wireless codes (unlicensed/licensed/licensed-by-rule), read off
141
+ * {@link BroadbandTechnologyCategoryToCodeSet} (decision 8: reuse, never re-derive) rather than hand-enumerated here a
142
+ * second time — an FCC code addition to the `FixedWireless` category in `technologies.ts` now flows straight through to
143
+ * {@link PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES} instead of silently missing this table.
144
+ */
145
+ const FIXED_WIRELESS_CODES = BroadbandTechnologyCategoryToCodeSet[BroadbandTechnologyCategory.FixedWireless]
146
+
147
+ /**
148
+ * One claimed broadband-service assertion to check. Exactly one spatial field is expected in practice (`geoid` wins if
149
+ * present — see the module docstring's claim-resolution note); `plausibilityCheck` throws if NONE of
150
+ * `geoid`/`point`/`address` resolves to something usable.
151
+ */
152
+ export interface PlausibilityClaim {
153
+ address?: string
154
+ point?: PointLiteral
155
+ geoid?: string
156
+ technologyCode: number
157
+ claimedDownloadMbps: number
158
+ }
159
+
160
+ /**
161
+ * Reasons `plausibilityCheck` can abstain on one evidence channel — decision 6, the poi-executor abstain precedent
162
+ * (`mailwoman/poi-executor.ts`), extended with the bdc-layer-absent case and the survey-gap-for-this-cell case.
163
+ */
164
+ export type PlausibilityAbstainReason = "requires_build_local_layer" | "requires_bdc_layer" | "insufficient_survey_data"
165
+
166
+ export type PlausibilityEvidence =
167
+ | { type: "filing"; filing: ProviderFilingSummary; vintage: string; corroborates: boolean }
168
+ | { type: "physical_plant"; hit: InfrastructureHit }
169
+ | { type: "abstain"; reason: PlausibilityAbstainReason; layer?: string }
170
+
171
+ /**
172
+ * One evidence channel's survey-completeness state for THIS claim, WITH the reason a non-`"covered"` state applies —
173
+ * task 5 fix round 1 (review finding 1): `coverage_confidence` alone folds several genuinely different situations into
174
+ * the same `"low"`/`"insufficient_survey_data"` verdict (a tech with no physical falsifier at all vs. a real poi survey
175
+ * gap vs. a geoid-only claim with no coordinate to search from), and Task 6's gates need to attribute WHICH one
176
+ * applies. `"not_applicable"` and `"no_coordinate"` are only ever produced for the physical axis; the filing axis only
177
+ * ever reaches `"covered"`, `"layer_missing"`, or `"cell_unsurveyed"`.
178
+ */
179
+ export type PlausibilityCoverageAxisState =
180
+ | "covered"
181
+ /**
182
+ * The required dependency (`deps.bdcDB` for filing, `deps.poi` for physical) was never wired at all — the
183
+ * `requires_bdc_layer` / `requires_build_local_layer` abstain precedent.
184
+ */
185
+ | "layer_missing"
186
+ /**
187
+ * The dependency IS wired, but the specific queried block/cell carries no survey coverage of its own — the
188
+ * `insufficient_survey_data` abstain precedent (filing), or an absent `readLayerCoverage` read (physical).
189
+ */
190
+ | "cell_unsurveyed"
191
+ /**
192
+ * Physical axis only: the claim resolved no coordinate (a geoid-only claim — see the module docstring's
193
+ * claim-resolution note), so no physical-evidence search point exists. A genuine capability gap, not a missing layer
194
+ * — distinct from `"layer_missing"` even though both degrade `coverage_confidence` the same way.
195
+ */
196
+ | "no_coordinate"
197
+ /**
198
+ * Physical axis only: the claimed technology maps to no physical-plant category at all (see
199
+ * {@link PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES}) — there is no applicable second channel for this tech, ever,
200
+ * regardless of layer availability. Distinct from every other state: this claim can never earn `"high"`.
201
+ */
202
+ | "not_applicable"
203
+
204
+ /**
205
+ * Per-axis attribution for {@link PlausibilityBundle.coverage_confidence} — see {@link PlausibilityCoverageAxisState}.
206
+ * Added alongside `coverage_confidence` (kept as-is for compatibility) rather than replacing it.
207
+ */
208
+ export interface PlausibilityCoverageDetail {
209
+ filing: PlausibilityCoverageAxisState
210
+ physical: PlausibilityCoverageAxisState
211
+ }
212
+
213
+ export interface PlausibilityBundle {
214
+ claim: PlausibilityClaim
215
+ evidence_found: PlausibilityEvidence[]
216
+ coverage_confidence: "high" | "low" | "insufficient_survey_data"
217
+ /**
218
+ * Per-axis WHY behind `coverage_confidence` — task 5 fix round 1 (finding 1). ALWAYS present, mirroring
219
+ * `block_resolution`'s always-present discipline.
220
+ */
221
+ coverage_detail: PlausibilityCoverageDetail
222
+ /**
223
+ * `"geoid"` when `claim.geoid` drove the filing-evidence lookup (the exact, native path); otherwise
224
+ * `"h3_cell_approximation"` (decision 4 — the point/address path's unsound-but-flagged h3 cell). ALWAYS present on
225
+ * every returned bundle.
226
+ */
227
+ block_resolution: "geoid" | "h3_cell_approximation"
228
+ /**
229
+ * `null` ONLY when the bdc layer itself abstained (`deps.bdcDB` absent). Populated in every other case, including
230
+ * when the specific queried block/cell is itself unsurveyed.
231
+ */
232
+ vintage: string | null
233
+ }
234
+
235
+ /**
236
+ * Structural mirror of `mailwoman/geocode-core.ts`'s `GeocodeResult` — `@mailwoman/bdc` MUST NOT import from the
237
+ * `mailwoman` workspace (`mailwoman/package.json` already depends on `@mailwoman/bdc`; the reverse edge would be
238
+ * circular). Only the two fields this scorer actually consumes are typed here; a real `GeocodeResult` is structurally
239
+ * assignable to this type without any adapter, so a caller wiring `deps.geocode` at the CLI/MCP layer can pass a thin
240
+ * wrapper over `geocodeAddress` directly.
241
+ */
242
+ export interface GeocodeLike {
243
+ lat: number | null
244
+ lon: number | null
245
+ }
246
+
247
+ /**
248
+ * The already-open infra layer this scorer composes against Task 4's `nearestInfrastructure`. The caller owns BOTH
249
+ * handles' open/dispose lifecycle (mirrors `nearestInfrastructure`'s own `using poiLookup = new POILookup(...)`
250
+ * precedent). `contractDB` is used two ways: passed straight through to `nearestInfrastructure` (per-hit coverage), and
251
+ * read directly here (the whole-cell coverage check this module needs for `coverage_confidence`, independent of whether
252
+ * any hit was actually found).
253
+ */
254
+ export interface PlausibilityPOIDeps {
255
+ lookup: POILookup
256
+ contractDB: DatabaseClient<LayerContractDatabase>
257
+ }
258
+
259
+ export interface PlausibilityDeps {
260
+ bdcDB?: DatabaseClient<BDCDatabase>
261
+ poi?: PlausibilityPOIDeps
262
+ geocode?: (address: string) => Promise<GeocodeLike>
263
+ }
264
+
265
+ /**
266
+ * Tech → physical-plant category mapping (exported per the task brief). Fiber implies the three
267
+ * infrastructure-extension categories a fiber network plausibly touches; the three fixed-wireless codes imply a comms
268
+ * tower; every other code maps to `[]` — no physical falsifier is claimed for it (see
269
+ * {@link physicalCategoriesForTechnology}).
270
+ */
271
+ export const PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES: Readonly<Record<number, readonly string[]>> = {
272
+ [BroadbandTechnologyCode.OpticalCarrierFiber]: ["telecom_exchange", "telecom_cabinet", "data_center"],
273
+ ...(Object.fromEntries([...FIXED_WIRELESS_CODES].map((code) => [code, ["tower_comms"]])) as Record<
274
+ number,
275
+ readonly string[]
276
+ >),
277
+ }
278
+
279
+ /**
280
+ * The poi-taxonomy category ids a physical-plant search should probe for a given BDC technology code, or `[]` when that
281
+ * technology has no physical falsifier in this vertical (see {@link PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES}).
282
+ */
283
+ export function physicalCategoriesForTechnology(technologyCode: number): readonly string[] {
284
+ return PLAUSIBILITY_TECH_PHYSICAL_CATEGORIES[technologyCode] ?? []
285
+ }
286
+
287
+ /**
288
+ * Ordinal rank of each `speed_bucket` label, so "at or above claimed speed" is a numeric comparison rather than a
289
+ * string one. Mirrors the bucket ORDER `filing-landscape.ts` defines (never re-derived — decision 8).
290
+ */
291
+ const SPEED_BUCKET_RANK: Readonly<Record<string, number>> = {
292
+ [BDC_SPEED_BUCKET_UNDER_25]: 0,
293
+ [BDC_SPEED_BUCKET_25_100]: 1,
294
+ [BDC_SPEED_BUCKET_100_1000]: 2,
295
+ [BDC_SPEED_BUCKET_GIGABIT]: 3,
296
+ }
297
+
298
+ /**
299
+ * `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
300
+ * in `DB` — same cast idiom as `filing-landscape.ts`'s own private `asContractDB` (decision 8: reuse, never re-derive;
301
+ * copied rather than imported since the original is module-private).
302
+ */
303
+ function asContractDB(kdb: DatabaseClient<BDCDatabase>): Kysely<LayerContractDatabase> {
304
+ return kdb as unknown as Kysely<LayerContractDatabase>
305
+ }
306
+
307
+ /**
308
+ * `true` when `filing` corroborates the claim: same `technology_code`, AND `filing.speed_bucket` ranks at or above the
309
+ * claimed download speed's own bucket. A different tech, or a same-tech but LESSER filing, is `false` — never disproof,
310
+ * just non-corroborating (spec §3.2 step 2).
311
+ */
312
+ function filingCorroborates(filing: ProviderFilingSummary, claim: PlausibilityClaim): boolean {
313
+ if (filing.technology_code !== claim.technologyCode) return false
314
+
315
+ const filingRank = SPEED_BUCKET_RANK[filing.speed_bucket]
316
+
317
+ // An unrecognized speed_bucket (a corrupted/foreign row) can't corroborate — never guess a rank for it.
318
+ if (filingRank === undefined) return false
319
+
320
+ const claimedRank = SPEED_BUCKET_RANK[speedBucketForDownloadSpeed(claim.claimedDownloadMbps)]!
321
+
322
+ return filingRank >= claimedRank
323
+ }
324
+
325
+ /**
326
+ * Collapse the fine-grained {@link PlausibilityCoverageAxisState} down to the 3-value space `combineCoverage` actually
327
+ * reasons over: `"layer_missing"` and `"cell_unsurveyed"` are both simply UNKNOWN for confidence-combination purposes
328
+ * (the distinction only matters for `coverage_detail`'s attribution, not for the confidence math itself).
329
+ */
330
+ function confidenceStateForAxis(state: PlausibilityCoverageAxisState): "covered" | "unknown" | "not_applicable" {
331
+ if (state === "covered") return "covered"
332
+
333
+ if (state === "not_applicable") return "not_applicable"
334
+
335
+ return "unknown"
336
+ }
337
+
338
+ /**
339
+ * Combine the two layers' coverage states into the bundle's `coverage_confidence` — see the module docstring for the
340
+ * `"not_applicable"` extension's reasoning (deliberately conservative: never `"high"` without a real, applicable
341
+ * two-channel opportunity).
342
+ */
343
+ function combineCoverage(
344
+ filingState: PlausibilityCoverageAxisState,
345
+ physicalState: PlausibilityCoverageAxisState
346
+ ): PlausibilityBundle["coverage_confidence"] {
347
+ const filing = confidenceStateForAxis(filingState)
348
+ const physical = confidenceStateForAxis(physicalState)
349
+
350
+ if (physical === "not_applicable") {
351
+ return filing === "covered" ? "low" : "insufficient_survey_data"
352
+ }
353
+
354
+ if (filing === "covered" && physical === "covered") return "high"
355
+
356
+ if (filing === "unknown" && physical === "unknown") return "insufficient_survey_data"
357
+
358
+ return "low"
359
+ }
360
+
361
+ /**
362
+ * See the module docstring's "ledger note" section. Throws when a WIRED layer's manifest disagrees with
363
+ * `BDC_H3_RESOLUTION` — the single constant `plausibilityCheck` actually uses at runtime to derive both the
364
+ * filing-lookup cell (bdc side, via `pointCell`) and the coverage-cell join key `readLayerCoverage` is read against
365
+ * (poi side, via `res9ShortCellToRes6Parent(pointCell)`).
366
+ *
367
+ * Task 5 fix round 1 (review finding 2): checked independently PER LAYER, whenever THAT layer is wired, rather than
368
+ * only when both `bdcDB` and `poi` are wired together. The original one-sided assertion compared the two manifests to
369
+ * EACH OTHER, which meant a poi-only call (no `bdcDB`) never checked poi's recorded resolution at all — even though
370
+ * `pointCell` (computed unconditionally from `BDC_H3_RESOLUTION`) still drives the poi coverage-cell read below.
371
+ * Comparing each layer directly against the constant is also strictly stronger than the retired manifest-vs-manifest
372
+ * check: it catches a layer built under a since-changed `BDC_H3_RESOLUTION` even when the OTHER layer is absent
373
+ * entirely, not just a disagreement between two present layers.
374
+ */
375
+ async function assertLayerSpineResolution(
376
+ layer: "bdc" | "poi",
377
+ contractDB: Kysely<LayerContractDatabase>,
378
+ expectedResolution: number
379
+ ): Promise<void> {
380
+ const manifest = await readLayerManifest(contractDB)
381
+ const resolution = manifest.spineKeys.h3?.resolution
382
+
383
+ if (resolution === undefined || resolution !== expectedResolution) {
384
+ throw new Error(
385
+ `plausibilityCheck: ${layer}.db's recorded h3 spine resolution (${String(resolution)}) does not match ` +
386
+ `BDC_H3_RESOLUTION (${expectedResolution}) — the res-9→res-6 coverage-cell reconstruction this scorer ` +
387
+ `relies on assumes they match; refusing to compose evidence against a mismatched layer rather than ` +
388
+ `silently mis-joining a coverage cell.`
389
+ )
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Compose filing evidence + physical evidence into one `{ claim, evidence_found, coverage_confidence }` bundle — see
395
+ * the module docstring for the full composition rules. Never emits anything expressible as "implausible": absence of a
396
+ * filing or of nearby plant only ever surfaces as an abstain, an omitted evidence entry, or a degraded
397
+ * `coverage_confidence` — never a negative verdict.
398
+ */
399
+ export async function plausibilityCheck(claim: PlausibilityClaim, deps: PlausibilityDeps): Promise<PlausibilityBundle> {
400
+ // 1. Resolve a coordinate for physical-evidence search (independent of the filing-evidence spatial key below) —
401
+ // claim.point directly, or claim.address geocoded via deps.geocode.
402
+ let point: PointLiteral | undefined = claim.point
403
+
404
+ if (!point && claim.address) {
405
+ if (!deps.geocode) {
406
+ throw new Error("plausibilityCheck: claim.address requires deps.geocode")
407
+ }
408
+
409
+ const geocoded = await deps.geocode(claim.address)
410
+
411
+ if (geocoded.lat == null || geocoded.lon == null) {
412
+ throw new Error(`plausibilityCheck: geocode could not resolve a coordinate for ${JSON.stringify(claim.address)}`)
413
+ }
414
+
415
+ point = { type: "Point", coordinates: [geocoded.lon, geocoded.lat] }
416
+ }
417
+
418
+ if (!claim.geoid && !point) {
419
+ throw new Error("plausibilityCheck: claim must supply one of `geoid`, `point`, or a resolvable `address`")
420
+ }
421
+
422
+ // decision 4: geoid wins outright — the exact, native filing-evidence path. Otherwise the resolved point's own
423
+ // res-9 cell is the (flagged, unsound) approximation.
424
+ const blockResolution: PlausibilityBundle["block_resolution"] = claim.geoid ? "geoid" : "h3_cell_approximation"
425
+
426
+ const pointCell = point
427
+ ? shortCellToInt(latLngToCell(point.coordinates[1], point.coordinates[0], BDC_H3_RESOLUTION) as H3Cell)
428
+ : undefined
429
+
430
+ // Ledger note (task 4 review; task 5 fix round 1 finding 2): cheap, one-time per-layer sanity check — see the
431
+ // module docstring. Runs independently per WIRED layer, not only when both are present — a poi-only call still
432
+ // joins poi's coverage table against a BDC_H3_RESOLUTION-derived cell (below) and must not do so unchecked.
433
+ if (deps.bdcDB) {
434
+ await assertLayerSpineResolution("bdc", asContractDB(deps.bdcDB), BDC_H3_RESOLUTION)
435
+ }
436
+
437
+ if (deps.poi) {
438
+ await assertLayerSpineResolution("poi", deps.poi.contractDB, BDC_H3_RESOLUTION)
439
+ }
440
+
441
+ const evidence: PlausibilityEvidence[] = []
442
+ let vintage: string | null = null
443
+ let filingCoverage: PlausibilityCoverageAxisState = "layer_missing"
444
+
445
+ if (!deps.bdcDB) {
446
+ evidence.push({ type: "abstain", reason: "requires_bdc_layer", layer: "bdc" })
447
+ } else {
448
+ // blockResolution === "geoid" iff claim.geoid is set (see above), so exactly one of these two branches ever
449
+ // runs, and the `pointCell!` assertion below is safe: blockResolution === "h3_cell_approximation" only when
450
+ // claim.geoid is absent, which (per the throw above) means `point` — and therefore `pointCell` — is defined.
451
+ const landscape =
452
+ blockResolution === "geoid"
453
+ ? await filingLandscape(deps.bdcDB, { geoids: [claim.geoid!] })
454
+ : await filingLandscape(deps.bdcDB, { h3Cells: [pointCell!] })
455
+
456
+ vintage = landscape.vintage
457
+
458
+ if (landscape.surveyed_block_count > 0) {
459
+ filingCoverage = "covered"
460
+
461
+ for (const filing of landscape.filings) {
462
+ evidence.push({
463
+ type: "filing",
464
+ filing,
465
+ vintage: landscape.vintage,
466
+ corroborates: filingCorroborates(filing, claim),
467
+ })
468
+ }
469
+ } else {
470
+ filingCoverage = "cell_unsurveyed"
471
+ evidence.push({ type: "abstain", reason: "insufficient_survey_data", layer: "bdc" })
472
+ }
473
+ }
474
+
475
+ const physicalCategories = physicalCategoriesForTechnology(claim.technologyCode)
476
+ let physicalCoverage: PlausibilityCoverageAxisState = "not_applicable"
477
+
478
+ if (physicalCategories.length) {
479
+ if (!deps.poi) {
480
+ physicalCoverage = "layer_missing"
481
+ evidence.push({ type: "abstain", reason: "requires_build_local_layer", layer: "poi" })
482
+ } else if (!point) {
483
+ // Geoid-only claim, no coordinate resolvable — see the module docstring's claim-resolution note. A real
484
+ // capability gap, not a missing-layer abstain: no evidence entry is fabricated, but the axis still
485
+ // degrades honestly for coverage_confidence (and now names ITS OWN reason in `coverage_detail`, distinct
486
+ // from `"layer_missing"`, rather than folding into the same generic "unknown" — task 5 fix round 1
487
+ // finding 1).
488
+ physicalCoverage = "no_coordinate"
489
+ } else {
490
+ const hits = await nearestInfrastructure(deps.poi.lookup, deps.poi.contractDB, {
491
+ center: point,
492
+ categoryIDs: [...physicalCategories],
493
+ })
494
+
495
+ for (const hit of hits) {
496
+ evidence.push({ type: "physical_plant", hit })
497
+ }
498
+
499
+ const coverageCell = await readLayerCoverage(deps.poi.contractDB, res9ShortCellToRes6Parent(pointCell!))
500
+ physicalCoverage = coverageCell ? "covered" : "cell_unsurveyed"
501
+ }
502
+ }
503
+
504
+ return {
505
+ claim,
506
+ evidence_found: evidence,
507
+ coverage_confidence: combineCoverage(filingCoverage, physicalCoverage),
508
+ coverage_detail: { filing: filingCoverage, physical: physicalCoverage },
509
+ block_resolution: blockResolution,
510
+ vintage,
511
+ }
512
+ }