@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.
- package/README.md +7 -0
- package/index.ts +2 -0
- package/out/index.d.ts +3 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +3 -0
- package/out/index.js.map +1 -0
- package/out/schema.d.ts +138 -0
- package/out/schema.d.ts.map +1 -0
- package/out/schema.js +88 -0
- package/out/schema.js.map +1 -0
- package/out/sdk/build-bdc.d.ts +214 -0
- package/out/sdk/build-bdc.d.ts.map +1 -0
- package/out/sdk/build-bdc.js +500 -0
- package/out/sdk/build-bdc.js.map +1 -0
- package/out/sdk/buildings.d.ts +84 -0
- package/out/sdk/buildings.d.ts.map +1 -0
- package/out/sdk/buildings.js +75 -0
- package/out/sdk/buildings.js.map +1 -0
- package/out/sdk/client.d.ts +237 -0
- package/out/sdk/client.d.ts.map +1 -0
- package/out/sdk/client.js +430 -0
- package/out/sdk/client.js.map +1 -0
- package/out/sdk/common.d.ts +279 -0
- package/out/sdk/common.d.ts.map +1 -0
- package/out/sdk/common.js +162 -0
- package/out/sdk/common.js.map +1 -0
- package/out/sdk/data-collection.d.ts +244 -0
- package/out/sdk/data-collection.d.ts.map +1 -0
- package/out/sdk/data-collection.js +50 -0
- package/out/sdk/data-collection.js.map +1 -0
- package/out/sdk/download.d.ts +36 -0
- package/out/sdk/download.d.ts.map +1 -0
- package/out/sdk/download.js +76 -0
- package/out/sdk/download.js.map +1 -0
- package/out/sdk/filing-dates.d.ts +57 -0
- package/out/sdk/filing-dates.d.ts.map +1 -0
- package/out/sdk/filing-dates.js +50 -0
- package/out/sdk/filing-dates.js.map +1 -0
- package/out/sdk/filing-landscape.d.ts +124 -0
- package/out/sdk/filing-landscape.d.ts.map +1 -0
- package/out/sdk/filing-landscape.js +216 -0
- package/out/sdk/filing-landscape.js.map +1 -0
- package/out/sdk/index.d.ts +15 -0
- package/out/sdk/index.d.ts.map +1 -0
- package/out/sdk/index.js +15 -0
- package/out/sdk/index.js.map +1 -0
- package/out/sdk/list-files.d.ts +39 -0
- package/out/sdk/list-files.d.ts.map +1 -0
- package/out/sdk/list-files.js +26 -0
- package/out/sdk/list-files.js.map +1 -0
- package/out/sdk/location.d.ts +40 -0
- package/out/sdk/location.d.ts.map +1 -0
- package/out/sdk/location.js +23 -0
- package/out/sdk/location.js.map +1 -0
- package/out/sdk/nearest-infrastructure.d.ts +84 -0
- package/out/sdk/nearest-infrastructure.d.ts.map +1 -0
- package/out/sdk/nearest-infrastructure.js +84 -0
- package/out/sdk/nearest-infrastructure.js.map +1 -0
- package/out/sdk/parsing.d.ts +57 -0
- package/out/sdk/parsing.d.ts.map +1 -0
- package/out/sdk/parsing.js +87 -0
- package/out/sdk/parsing.js.map +1 -0
- package/out/sdk/plausibility.d.ts +256 -0
- package/out/sdk/plausibility.d.ts.map +1 -0
- package/out/sdk/plausibility.js +334 -0
- package/out/sdk/plausibility.js.map +1 -0
- package/out/sdk/technologies.d.ts +229 -0
- package/out/sdk/technologies.d.ts.map +1 -0
- package/out/sdk/technologies.js +217 -0
- package/out/sdk/technologies.js.map +1 -0
- package/package.json +97 -0
- package/schema.ts +172 -0
- package/sdk/build-bdc.ts +810 -0
- package/sdk/buildings.ts +87 -0
- package/sdk/client.ts +643 -0
- package/sdk/common.ts +363 -0
- package/sdk/data-collection.ts +268 -0
- package/sdk/download.ts +91 -0
- package/sdk/filing-dates.ts +88 -0
- package/sdk/filing-landscape.ts +287 -0
- package/sdk/index.ts +14 -0
- package/sdk/list-files.ts +72 -0
- package/sdk/location.ts +45 -0
- package/sdk/nearest-infrastructure.ts +129 -0
- package/sdk/parsing.ts +124 -0
- package/sdk/plausibility.ts +512 -0
- 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
|
+
}
|