@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.1

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 (45) hide show
  1. package/address-point-interpolation.ts +207 -0
  2. package/address-point-schema.ts +107 -0
  3. package/address-point.ts +122 -0
  4. package/ancestry-backfill.ts +205 -0
  5. package/ancestry.ts +70 -0
  6. package/build-candidate.ts +351 -0
  7. package/build-slim.ts +394 -0
  8. package/candidate-fts.ts +43 -0
  9. package/candidate-lookup.ts +382 -0
  10. package/candidate-schema.ts +166 -0
  11. package/coincident-roles.ts +240 -0
  12. package/convention.ts +152 -0
  13. package/fst-autocomplete.ts +187 -0
  14. package/fst-builder.ts +291 -0
  15. package/fst-deserialize-web.ts +164 -0
  16. package/fst-matcher.ts +150 -0
  17. package/fst-serialize.ts +311 -0
  18. package/fst-types.ts +78 -0
  19. package/fts.ts +318 -0
  20. package/geo.ts +140 -0
  21. package/geonames-aliases.ts +317 -0
  22. package/geonames-postal.ts +150 -0
  23. package/index.ts +117 -0
  24. package/interpolation.ts +232 -0
  25. package/lookup.ts +1498 -0
  26. package/package.json +168 -82
  27. package/poi-lookup.ts +319 -0
  28. package/poi-schema.ts +147 -0
  29. package/postal-city-alias-lookup.ts +89 -0
  30. package/postal-city-alias-schema.ts +75 -0
  31. package/postal-city-candidate-schema.ts +81 -0
  32. package/postcode-point-lookup.ts +64 -0
  33. package/reverse.ts +429 -0
  34. package/schema.ts +176 -0
  35. package/sharding.ts +235 -0
  36. package/sqlite-convention-source.ts +61 -0
  37. package/sqlite-utils.ts +25 -0
  38. package/street-centroid-schema.ts +124 -0
  39. package/street-centroid.ts +124 -0
  40. package/street-morphology-fst-builder.ts +230 -0
  41. package/street-name-lookup.ts +101 -0
  42. package/street-normalize.ts +302 -0
  43. package/street-segment-schema.ts +104 -0
  44. package/types.ts +164 -0
  45. package/unified-schema.ts +171 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * House-number interpolation (#483): when the exact address-point tier (#476, `address-point.ts`)
7
+ * misses, estimate the coordinate from TIGER street-segment ranges — parity-aware range match,
8
+ * then linear interpolation along the segment polyline. Design:
9
+ * `docs/articles/plan/2026-06-11-interpolation-design.md`.
10
+ *
11
+ * Reads the per-state shard built by `scripts/build-interpolation-shard.ts` (`street_segment`: one
12
+ * row per TIGER edge SIDE — independent left/right ranges, ZIPs, parity). Query-side
13
+ * normalization is THE shared normalizer (`street-normalize.ts`) — identical to build-side, by
14
+ * construction.
15
+ *
16
+ * Every answer is honest about being an estimate: `interpolated: true`, `parityMatched` (false when
17
+ * only the opposite side's range contained the number — usually the right block, wrong side of
18
+ * the street), and `uncertaintyM` (half the matched segment's length — the #483 issue's honest
19
+ * default). Scoping is postcode-first (a given ZIP that scopes to nothing is a MISS — the
20
+ * statewide retry was measured and rejected, see `find()`); without a postcode the statewide name
21
+ * match must agree on a single postcode or the lookup ABSTAINS (a common street name spanning
22
+ * towns is ambiguity, not an answer).
23
+ *
24
+ * Standalone in this slice — core tier wiring (`resolution_tier: "interpolated"` after the
25
+ * exact-point fall-through) is a noted follow-up on #483, so the `find()` shape mirrors
26
+ * `AddressPointLookup.find()` to keep that wiring mechanical.
27
+ */
28
+
29
+ import { DatabaseSync } from "node:sqlite"
30
+
31
+ import type { InterpolationLookup } from "@mailwoman/resolver"
32
+
33
+ import { haversineKm } from "./geo.ts"
34
+ import { hasTable } from "./sqlite-utils.ts"
35
+ import { canonicalizeRouteKey, normalizeStreetForKey } from "./street-normalize.ts"
36
+
37
+ /**
38
+ * How an interpolated answer was computed (#483 Method 2):
39
+ *
40
+ * - `address_point` — bracketed/extrapolated between REAL neighbor points from the #476 shard
41
+ * (`AddressPointInterpolator`), replacing TIGER's uniform-spacing assumption with occupancy.
42
+ * - `tiger_range` — linear position within a TIGER segment's theoretical house-number range (`StreetInterpolator`), the
43
+ * fallback for streets too sparse to bracket.
44
+ */
45
+ export type InterpolationMethod = "address_point" | "tiger_range"
46
+
47
+ /** One interpolated coordinate estimate. Never an exact situs point — see `uncertaintyM`. */
48
+ export interface InterpolatedHit {
49
+ lat: number
50
+ lon: number
51
+ /** Always true — the tier's honesty flag, mirrored into `resolution_tier` when wired. */
52
+ interpolated: true
53
+ /** Which rung answered — see {@link InterpolationMethod}. */
54
+ method: InterpolationMethod
55
+ /**
56
+ * `tiger_range` only. True when the matched segment side's parity agrees with the house number (or the side is
57
+ * `mixed`). False = opposite-side fallback: usually the right block, wrong side of the street.
58
+ */
59
+ parityMatched?: boolean
60
+ /**
61
+ * `address_point` only. `both` = the query number sits between two known neighbor numbers; `single` = neighbors exist
62
+ * on one side only (extrapolated, larger `uncertaintyM`).
63
+ */
64
+ bracket?: "both" | "single"
65
+ /**
66
+ * Honest uncertainty radius in meters: half the matched segment's polyline length (`tiger_range`), half the bracket
67
+ * span (`address_point`/`both`), or the explicitly larger extrapolation penalty (`address_point`/`single`).
68
+ */
69
+ uncertaintyM: number
70
+ /** Provenance, e.g. `"tiger:edges"`. */
71
+ source: string
72
+ /** Pinned data vintage, e.g. `"TIGER2023"`. */
73
+ release: string
74
+ }
75
+
76
+ export interface InterpolationQuery {
77
+ street: string
78
+ number: string
79
+ /** ZIP scope — strongly preferred; without it common street names abstain (see module doc). */
80
+ postcode?: string
81
+ }
82
+
83
+ interface SegmentRow {
84
+ from_hn: number
85
+ to_hn: number
86
+ min_hn: number
87
+ max_hn: number
88
+ parity: string
89
+ postcode: string | null
90
+ geometry: string
91
+ source: string
92
+ release: string
93
+ }
94
+
95
+ export class StreetInterpolator implements InterpolationLookup {
96
+ readonly #db: DatabaseSync
97
+ readonly #ownsDB: boolean
98
+ readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
99
+ readonly #byStreet: ReturnType<DatabaseSync["prepare"]> | undefined
100
+
101
+ constructor(opts: { dbPath?: string; database?: DatabaseSync }) {
102
+ if (opts.database) {
103
+ this.#db = opts.database
104
+ this.#ownsDB = false
105
+ } else if (opts.dbPath) {
106
+ this.#db = new DatabaseSync(opts.dbPath, { readOnly: true })
107
+ this.#ownsDB = true
108
+ } else {
109
+ throw new Error("StreetInterpolator: one of dbPath or database is required")
110
+ }
111
+
112
+ // Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
113
+ // `street_segment` table this interpolator is a no-op miss, not a crash that loses the state (#568).
114
+ if (hasTable(this.#db, "street_segment")) {
115
+ const columns = `from_hn, to_hn, min_hn, max_hn, parity, postcode, geometry, source, release`
116
+ this.#byPostcode = this.#db.prepare(
117
+ `SELECT ${columns} FROM street_segment
118
+ WHERE postcode = ? AND street_norm = ? AND min_hn <= ? AND max_hn >= ?`
119
+ )
120
+ this.#byStreet = this.#db.prepare(
121
+ `SELECT ${columns} FROM street_segment
122
+ WHERE street_norm = ? AND min_hn <= ? AND max_hn >= ?`
123
+ )
124
+ }
125
+ }
126
+
127
+ find(query: InterpolationQuery): InterpolatedHit | null {
128
+ if (!this.#byPostcode || !this.#byStreet) return null
129
+ const streetNorm = canonicalizeRouteKey(normalizeStreetForKey(query.street))
130
+ const numberRaw = query.number.trim()
131
+
132
+ // Strictly-numeric house numbers only — this tier estimates, it doesn't guess at
133
+ // hyphenated/alphanumeric schemes the ranges don't model.
134
+ if (!streetNorm || !/^\d+$/.test(numberRaw)) return null
135
+ const n = Number(numberRaw)
136
+
137
+ let rows: SegmentRow[]
138
+
139
+ if (query.postcode) {
140
+ // A given ZIP that scopes to nothing is a MISS, not a statewide guess: the retry was
141
+ // measured (2026-06-11 VT eval) at +2.3pp coverage for a poisoned tail (p99 1.0 → 20.8
142
+ // km, max 204 km — a unique name statewide can live in a far-away town).
143
+ rows = this.#byPostcode.all(query.postcode.trim(), streetNorm, n, n) as unknown as SegmentRow[]
144
+ } else {
145
+ // No scope given: a name matching ranges across several ZIPs is ambiguous — abstain.
146
+ rows = this.#byStreet.all(streetNorm, n, n) as unknown as SegmentRow[]
147
+ const postcodes = new Set(rows.map((r) => r.postcode ?? ""))
148
+
149
+ if (postcodes.size > 1) return null
150
+ }
151
+
152
+ if (rows.length === 0) return null
153
+
154
+ // Parity preference: exact side first, then 'mixed' (matches either), then the
155
+ // opposite side as a flagged fallback.
156
+ const wantOdd = n % 2 === 1
157
+ const exact = rows.filter((r) => r.parity === (wantOdd ? "odd" : "even"))
158
+ const mixed = rows.filter((r) => r.parity === "mixed")
159
+ const preferred = exact.length > 0 ? exact : mixed
160
+ const pool = preferred.length > 0 ? preferred : rows
161
+ const parityMatched = preferred.length > 0
162
+
163
+ // Tightest range wins — the most specific claim about where this number lives.
164
+ const best = pool.reduce((a, b) => (b.max_hn - b.min_hn < a.max_hn - a.min_hn ? b : a))
165
+
166
+ const polyline = JSON.parse(best.geometry) as [number, number][]
167
+ const span = best.to_hn - best.from_hn
168
+ const t = span === 0 ? 0.5 : clamp01((n - best.from_hn) / span)
169
+ const [lon, lat, lengthKm] = pointAlong(polyline, t)
170
+
171
+ return {
172
+ lat,
173
+ lon,
174
+ interpolated: true,
175
+ method: "tiger_range",
176
+ parityMatched,
177
+ uncertaintyM: Math.round((lengthKm * 1000) / 2),
178
+ source: best.source,
179
+ release: best.release,
180
+ }
181
+ }
182
+
183
+ close(): void {
184
+ if (this.#ownsDB) {
185
+ this.#db.close()
186
+ }
187
+ }
188
+ }
189
+
190
+ function clamp01(t: number): number {
191
+ return t < 0 ? 0 : t > 1 ? 1 : t
192
+ }
193
+
194
+ /**
195
+ * Point at fraction `t` of the polyline's total arc length (haversine), plus the total length in km. `t` is assumed
196
+ * clamped to [0, 1].
197
+ */
198
+ function pointAlong(polyline: readonly [number, number][], t: number): [lon: number, lat: number, lengthKm: number] {
199
+ const legs: number[] = []
200
+ let total = 0
201
+
202
+ for (let i = 1; i < polyline.length; i++) {
203
+ const [aLon, aLat] = polyline[i - 1]!
204
+ const [bLon, bLat] = polyline[i]!
205
+ const d = haversineKm(aLat, aLon, bLat, bLon)
206
+ legs.push(d)
207
+ total += d
208
+ }
209
+
210
+ if (total === 0) {
211
+ const [lon, lat] = polyline[0]!
212
+
213
+ return [lon, lat, 0]
214
+ }
215
+ let remaining = t * total
216
+
217
+ for (let i = 0; i < legs.length; i++) {
218
+ const leg = legs[i]!
219
+
220
+ if (remaining <= leg || i === legs.length - 1) {
221
+ const f = leg === 0 ? 0 : clamp01(remaining / leg)
222
+ const [aLon, aLat] = polyline[i]!
223
+ const [bLon, bLat] = polyline[i + 1]!
224
+
225
+ return [aLon + (bLon - aLon) * f, aLat + (bLat - aLat) * f, total]
226
+ }
227
+ remaining -= leg
228
+ }
229
+ const [lon, lat] = polyline[polyline.length - 1]!
230
+
231
+ return [lon, lat, total]
232
+ }