@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/out/poi-lookup.d.ts +14 -2
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +55 -21
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +9 -0
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +16 -0
- package/out/poi-schema.js.map +1 -1
- package/out/reverse.d.ts +8 -1
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +10 -1
- package/out/reverse.js.map +1 -1
- package/package.json +168 -82
- package/poi-lookup.ts +375 -0
- package/poi-schema.ts +164 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +439 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- package/unified-schema.ts +171 -0
package/interpolation.ts
ADDED
|
@@ -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
|
+
}
|