@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.
Files changed (57) 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/out/poi-lookup.d.ts +14 -2
  27. package/out/poi-lookup.d.ts.map +1 -1
  28. package/out/poi-lookup.js +55 -21
  29. package/out/poi-lookup.js.map +1 -1
  30. package/out/poi-schema.d.ts +9 -0
  31. package/out/poi-schema.d.ts.map +1 -1
  32. package/out/poi-schema.js +16 -0
  33. package/out/poi-schema.js.map +1 -1
  34. package/out/reverse.d.ts +8 -1
  35. package/out/reverse.d.ts.map +1 -1
  36. package/out/reverse.js +10 -1
  37. package/out/reverse.js.map +1 -1
  38. package/package.json +168 -82
  39. package/poi-lookup.ts +375 -0
  40. package/poi-schema.ts +164 -0
  41. package/postal-city-alias-lookup.ts +89 -0
  42. package/postal-city-alias-schema.ts +75 -0
  43. package/postal-city-candidate-schema.ts +81 -0
  44. package/postcode-point-lookup.ts +64 -0
  45. package/reverse.ts +439 -0
  46. package/schema.ts +176 -0
  47. package/sharding.ts +235 -0
  48. package/sqlite-convention-source.ts +61 -0
  49. package/sqlite-utils.ts +25 -0
  50. package/street-centroid-schema.ts +124 -0
  51. package/street-centroid.ts +124 -0
  52. package/street-morphology-fst-builder.ts +230 -0
  53. package/street-name-lookup.ts +101 -0
  54. package/street-normalize.ts +302 -0
  55. package/street-segment-schema.ts +104 -0
  56. package/types.ts +164 -0
  57. package/unified-schema.ts +171 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * SQLite implementation of core's `StreetCentroidLookup` (#1042): the street-level tier BELOW the
7
+ * exact address-point tier and ABOVE admin-centroid resolution. Given a street name (no house
8
+ * number) plus a postcode/commune scope, it returns the street's CENTROID + an honest extent-derived
9
+ * uncertainty from the derived `street-centroids-<cc>.db` roll-up.
10
+ *
11
+ * Query-side normalization is THE shared normalizer (`street-normalize.ts`), selected per the shard's
12
+ * `streetLocale`, so build-side and probe-side keys agree by construction. The commune scope folds
13
+ * through `normalizeLocalityForKey` + `stripArrondissement` (BAN names Paris/Lyon/Marseille per
14
+ * arrondissement; a query names the base commune).
15
+ *
16
+ * Scope order is most-selective first: `postcode`, then the base commune. Each scope WEIGHTED-
17
+ * aggregates (by `point_count`) across the matched rows in SQL, so a commune-scope probe returns the
18
+ * street's grand centroid over every postcode/arrondissement it spans — one row, one hit. Matching is
19
+ * exact-after-normalization only (no fuzzy street matching in this tier).
20
+ */
21
+
22
+ import { DatabaseSync } from "node:sqlite"
23
+
24
+ import type { StreetCentroidHit, StreetCentroidLookup } from "@mailwoman/resolver"
25
+
26
+ import { hasTable } from "./sqlite-utils.ts"
27
+ import {
28
+ normalizeLocalityForKey,
29
+ normalizeStreetForKeyLocale,
30
+ type StreetLocale,
31
+ stripArrondissement,
32
+ } from "./street-normalize.ts"
33
+
34
+ /** The weighted-centroid + extent + provenance an aggregate probe projects. `lat` is null when nothing matched. */
35
+ interface AggRow {
36
+ lat: number | null
37
+ lon: number | null
38
+ min_lat: number | null
39
+ max_lat: number | null
40
+ min_lon: number | null
41
+ max_lon: number | null
42
+ source: string | null
43
+ release: string | null
44
+ }
45
+
46
+ /** Weighted-centroid aggregate over a WHERE-filtered set. `SUM(coord*n)/SUM(n)` reconstructs the grand centroid. */
47
+ const AGG_SELECT =
48
+ "SUM(lat * point_count) / SUM(point_count) AS lat, " +
49
+ "SUM(lon * point_count) / SUM(point_count) AS lon, " +
50
+ "MIN(min_lat) AS min_lat, MAX(max_lat) AS max_lat, MIN(min_lon) AS min_lon, MAX(max_lon) AS max_lon, " +
51
+ "MAX(source) AS source, MAX(release) AS release"
52
+
53
+ /** Half the bbox diagonal, in METERS — an honest coarse radius for a street centroid. */
54
+ function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: number): number {
55
+ const R = 6_371_000
56
+ const toRad = (d: number): number => (d * Math.PI) / 180
57
+ const dLat = toRad(maxLat - minLat)
58
+ const dLon = toRad(maxLon - minLon)
59
+ const midLat = toRad((minLat + maxLat) / 2)
60
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(midLat) ** 2 * Math.sin(dLon / 2) ** 2
61
+ const diag = 2 * R * Math.asin(Math.min(1, Math.sqrt(a)))
62
+
63
+ return Math.round(diag / 2)
64
+ }
65
+
66
+ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
67
+ readonly #db: DatabaseSync
68
+ readonly #locale: StreetLocale
69
+ readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
70
+ readonly #byLocality: ReturnType<DatabaseSync["prepare"]> | undefined
71
+
72
+ /**
73
+ * @param dbPath Shard path.
74
+ * @param opts.streetLocale The street-normalization locale this shard was BUILT with — must match, or every key
75
+ * misses. Defaults to `"fr"` (BAN is the French national register; the tier is FR-only today).
76
+ */
77
+ constructor(dbPath: string, opts: { streetLocale?: StreetLocale } = {}) {
78
+ this.#db = new DatabaseSync(dbPath, { readOnly: true })
79
+ this.#locale = opts.streetLocale ?? "fr"
80
+
81
+ // Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
82
+ // `street_centroid` table this lookup is a no-op miss, not a crash (mirrors the address-point reader).
83
+ if (hasTable(this.#db, "street_centroid")) {
84
+ this.#byPostcode = this.#db.prepare(
85
+ `SELECT ${AGG_SELECT} FROM street_centroid WHERE postcode = ? AND street_norm = ?`
86
+ )
87
+ this.#byLocality = this.#db.prepare(
88
+ `SELECT ${AGG_SELECT} FROM street_centroid WHERE locality_base = ? AND street_norm = ?`
89
+ )
90
+ }
91
+ }
92
+
93
+ find(query: { street: string; postcode?: string; locality?: string }): StreetCentroidHit | null {
94
+ if (!this.#byPostcode || !this.#byLocality) return null
95
+ const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
96
+
97
+ if (!streetNorm) return null
98
+
99
+ let row: AggRow | undefined
100
+
101
+ if (query.postcode?.trim()) {
102
+ row = this.#byPostcode.get(query.postcode.trim(), streetNorm) as AggRow | undefined
103
+ }
104
+
105
+ if ((!row || row.lat == null) && query.locality?.trim()) {
106
+ const base = stripArrondissement(normalizeLocalityForKey(query.locality))
107
+ row = this.#byLocality.get(base, streetNorm) as AggRow | undefined
108
+ }
109
+
110
+ if (!row || row.lat == null || row.lon == null) return null
111
+
112
+ return {
113
+ lat: row.lat,
114
+ lon: row.lon,
115
+ uncertaintyM: extentRadiusM(row.min_lat!, row.max_lat!, row.min_lon!, row.max_lon!),
116
+ source: row.source ?? "",
117
+ release: row.release ?? "",
118
+ }
119
+ }
120
+
121
+ close(): void {
122
+ this.#db.close()
123
+ }
124
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build a street-morphology FST from libpostal's street_types dictionaries. The morphology FST maps
7
+ * street-typing affixes (Street/Avenue/rue/Calle/Straße/...) to a single synthetic placetype
8
+ * `"street_affix"` — distinct from the admin FST in source data, intent, and binary artifact.
9
+ *
10
+ * The morphology FST closes the inference-time vacuum identified by the v0.6.1 postmortem: street
11
+ * tokens have no admin-FST anchor, so synth-street training pushed the model toward over-emitting
12
+ * `dependent_locality` on subcomponents. With the morphology FST, the neural decoder gets
13
+ * positive evidence for street-typing affixes and the adjacent name tokens, plus negative
14
+ * evidence away from `dependent_locality` on the same neighbours.
15
+ *
16
+ * Design rationale + the four-layer street-supplement architecture lives in
17
+ * `docs/articles/concepts/street-supplement-architecture.md`.
18
+ *
19
+ * Source: `core/data/libpostal/dictionaries/{locale}/street_types.txt`. Each line is pipe-delimited
20
+ * surface forms with the canonical form first: avenue|av|ave|aven|avenu|avn|avnu|avnue
21
+ *
22
+ * Output: an `FSTMatcher` ready to serialize via `serializeFST` to e.g.
23
+ * `fst-street-morphology.bin`.
24
+ */
25
+
26
+ import { readdirSync, readFileSync, statSync } from "node:fs"
27
+ import { join } from "node:path"
28
+
29
+ import type { FSTNode } from "./fst-matcher.ts"
30
+ import { FSTMatcher, normalizeTokens } from "./fst-matcher.ts"
31
+ import type { FSTProvenance, PlaceEntry } from "./fst-types.ts"
32
+
33
+ /**
34
+ * Reserved synthetic wofID base for street-morphology entries. 32-bit unsigned, well above any realistic WOF
35
+ * allocation. Reusing the same base across rebuilds keeps IDs stable for any consumer that caches them. See
36
+ * [[project-schema-storage-decision]] for the reserved range policy.
37
+ */
38
+ const STREET_AFFIX_WOFID_BASE = 1_900_000_000
39
+
40
+ const STREET_TYPES_FILENAME = "street_types.txt"
41
+
42
+ export interface BuildStreetMorphologyFSTOpts {
43
+ /** Path to the `core/data/libpostal/dictionaries` directory containing per-locale subfolders. */
44
+ dictionariesDir: string
45
+ /**
46
+ * Optional locale filter — only ingest these locale subfolders. Defaults to all that have a `street_types.txt`.
47
+ */
48
+ locales?: string[]
49
+ /**
50
+ * Minimum length (in characters, post-normalization) of variant surface forms to insert into the trie. Defaults to 3.
51
+ *
52
+ * Rationale: libpostal's street_types dictionaries contain 1-2 character abbreviations (`a`, `b`, `av`, `bd`, `br`,
53
+ * ...) that collide with non-affix tokens at parse time — notably US state abbreviations (`OR`, `CA`, `ND`, `NY`),
54
+ * single-letter unit designators, and arbitrary short tokens. Empirically these collisions push the morphology prior
55
+ * to mis-tag state abbreviations as `street_suffix`. A minimum length of 3 retains useful forms (`ave`, `blvd`,
56
+ * `rue`, `str`) while filtering out the noise.
57
+ */
58
+ minVariantLength?: number
59
+ /** Optional progress callback. */
60
+ onProgress?: (phase: string, detail?: string) => void
61
+ }
62
+
63
+ export interface BuildStreetMorphologyFSTResult {
64
+ matcher: FSTMatcher
65
+ provenance: FSTProvenance
66
+ canonicalCount: number
67
+ variantCount: number
68
+ insertCount: number
69
+ locales: string[]
70
+ }
71
+
72
+ /**
73
+ * Parse one `street_types.txt` line into `{ canonical, variants }`. Canonical is the first token (pre-`|`); variants
74
+ * are all whitespace-stripped non-empty tokens including the canonical.
75
+ *
76
+ * Lines with no `|` are treated as a single-form entry where canonical == variant.
77
+ */
78
+ function parseLine(line: string): { canonical: string; variants: string[] } | null {
79
+ const trimmed = line.trim()
80
+
81
+ if (trimmed.length === 0 || trimmed.startsWith("#")) return null
82
+ const parts = trimmed
83
+ .split("|")
84
+ .map((s) => s.trim())
85
+ .filter((s) => s.length > 0)
86
+
87
+ if (parts.length === 0) return null
88
+
89
+ return { canonical: parts[0]!, variants: parts }
90
+ }
91
+
92
+ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): BuildStreetMorphologyFSTResult {
93
+ const progress = opts.onProgress ?? (() => {})
94
+ const minVariantLength = opts.minVariantLength ?? 3
95
+
96
+ // Discover locales — either provided explicitly, or all directories containing street_types.txt.
97
+ let locales: string[]
98
+
99
+ if (opts.locales && opts.locales.length > 0) {
100
+ locales = opts.locales
101
+ } else {
102
+ locales = readdirSync(opts.dictionariesDir).filter((entry) => {
103
+ const localePath = join(opts.dictionariesDir, entry)
104
+
105
+ if (!statSync(localePath).isDirectory()) return false
106
+
107
+ try {
108
+ statSync(join(localePath, STREET_TYPES_FILENAME))
109
+
110
+ return true
111
+ } catch {
112
+ return false
113
+ }
114
+ })
115
+ }
116
+ progress("discover", `Found ${locales.length} locales with ${STREET_TYPES_FILENAME}`)
117
+
118
+ // Collect canonical → set-of-variants across all locales. Same canonical form may appear in
119
+ // multiple locales (e.g. "avenue" in en/fr); we union the variant sets.
120
+ const canonicalToVariants = new Map<string, Set<string>>()
121
+
122
+ for (const locale of locales) {
123
+ const filePath = join(opts.dictionariesDir, locale, STREET_TYPES_FILENAME)
124
+ const content = readFileSync(filePath, "utf8")
125
+
126
+ for (const line of content.split("\n")) {
127
+ const parsed = parseLine(line)
128
+
129
+ if (!parsed) continue
130
+ const existing = canonicalToVariants.get(parsed.canonical) ?? new Set<string>()
131
+
132
+ for (const variant of parsed.variants) {
133
+ existing.add(variant)
134
+ }
135
+ canonicalToVariants.set(parsed.canonical, existing)
136
+ }
137
+ }
138
+ progress("collect", `Collected ${canonicalToVariants.size} canonical affixes`)
139
+
140
+ // Assign stable synthetic wofIDs. Sort canonicals for determinism.
141
+ const sortedCanonicals = [...canonicalToVariants.keys()].sort()
142
+ const canonicalToWOFID = new Map<string, number>()
143
+
144
+ for (let i = 0; i < sortedCanonicals.length; i++) {
145
+ canonicalToWOFID.set(sortedCanonicals[i]!, STREET_AFFIX_WOFID_BASE + i)
146
+ }
147
+
148
+ // Build the trie. Each variant is inserted as a token sequence pointing to its canonical's
149
+ // PlaceEntry — so all variants of "avenue" (av/ave/aven/...) lead to the same terminal entry.
150
+ const nodes: FSTNode[] = [{ edges: new Map(), places: [] }]
151
+
152
+ function insertName(tokens: string[], entry: PlaceEntry): void {
153
+ if (tokens.length === 0) return
154
+ let stateID = 0
155
+
156
+ for (const t of tokens) {
157
+ const node = nodes[stateID]!
158
+ let next = node.edges.get(t)
159
+
160
+ if (next === undefined) {
161
+ next = nodes.length
162
+ nodes.push({ edges: new Map(), places: [] })
163
+ node.edges.set(t, next)
164
+ }
165
+ stateID = next
166
+ }
167
+ const existing = nodes[stateID]!.places
168
+
169
+ if (!existing.some((p) => p.wofID === entry.wofID && p.placetype === entry.placetype)) {
170
+ existing.push(entry)
171
+ }
172
+ }
173
+
174
+ let insertCount = 0
175
+ let variantCount = 0
176
+
177
+ for (const canonical of sortedCanonicals) {
178
+ const variants = canonicalToVariants.get(canonical)!
179
+ const wofID = canonicalToWOFID.get(canonical)!
180
+ const entry: PlaceEntry = {
181
+ wofID,
182
+ placetype: "street_affix",
183
+ name: canonical,
184
+ parentChain: [],
185
+ // Fixed importance: street affixes are structurally unambiguous (Avenue is almost never
186
+ // anything but street-typing). The morphology prior caps bias separately; this value
187
+ // just feeds the cap formula `importance * cap`.
188
+ importance: 1.0,
189
+ lat: 0,
190
+ lon: 0,
191
+ }
192
+
193
+ for (const variant of variants) {
194
+ const tokens = normalizeTokens(variant)
195
+
196
+ if (tokens.length === 0) continue
197
+ // Filter out collision-prone short surface forms — see `minVariantLength` docstring.
198
+ // We measure against the joined token form (no spaces) since FST keys are token sequences.
199
+ const joined = tokens.join("")
200
+
201
+ if (joined.length < minVariantLength) continue
202
+ insertName(tokens, entry)
203
+ insertCount++
204
+ variantCount++
205
+ }
206
+ }
207
+ progress("trie", `Built trie: ${nodes.length} states, ${insertCount} variant insertions`)
208
+
209
+ const edgeCount = nodes.reduce((sum, n) => sum + n.edges.size, 0)
210
+ const matcher = FSTMatcher.fromNodes(nodes)
211
+ const provenance: FSTProvenance = {
212
+ builtAt: new Date().toISOString(),
213
+ countries: locales, // Reuse `countries` slot for locale provenance — semantics differ from admin FST.
214
+ stateCount: nodes.length,
215
+ placeCount: sortedCanonicals.length,
216
+ edgeCount,
217
+ nameInsertions: insertCount,
218
+ importanceMatches: 0, // No importance scoring for morphology — fixed at 1.0.
219
+ sourceDB: opts.dictionariesDir,
220
+ }
221
+
222
+ return {
223
+ matcher,
224
+ provenance,
225
+ canonicalCount: sortedCanonicals.length,
226
+ variantCount,
227
+ insertCount,
228
+ locales,
229
+ }
230
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * #727 stage-2 phase 4c — the SQLite backend for {@link StreetLocalityEvidence}.
7
+ *
8
+ * Reads a street-name index (the FR instance = BAN `street-centroids-fr.db`, a `street_centroid`
9
+ * table of `street_norm × locality_base × postcode` rows) and answers "does this street surface
10
+ * exist as a name" for the k-best rerank. Sync-by-interface, `readOnly`, prepared statements,
11
+ * graceful-degrade on a tableless shard — the same reader discipline as `AddressPointSqliteLookup`.
12
+ *
13
+ * THE FOLD CONTRACT: the surface is folded with {@link foldStreetSurface} (the shared function),
14
+ * and the DB's `street_norm` column MUST have been built with that SAME fold or every hyphenated /
15
+ * apostrophe'd street silently misses. The current `street-centroids-fr.db` predates the contract
16
+ * fold (it folded without hyphen/apostrophe normalization); it must be REBUILT with
17
+ * `foldStreetSurface` + a `street_norm` index before this backend is wired in production. Until
18
+ * then this class is correct-by-construction against a fixture built with the contract fold, and
19
+ * the production rebuild is a tracked BAN-sdk follow-up.
20
+ */
21
+
22
+ import { DatabaseSync } from "node:sqlite"
23
+
24
+ import { foldStreetSurface, type StreetEvidenceScope, type StreetLocalityEvidence } from "@mailwoman/resolver"
25
+
26
+ function hasTable(db: DatabaseSync, table: string): boolean {
27
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table)
28
+
29
+ return row !== undefined
30
+ }
31
+
32
+ function hasColumn(db: DatabaseSync, table: string, column: string): boolean {
33
+ // `table` is a caller-controlled identifier (default `street_centroid`), not user input — safe to interpolate.
34
+ for (const row of db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>) {
35
+ if (row.name === column) return true
36
+ }
37
+
38
+ return false
39
+ }
40
+
41
+ export interface SQLiteStreetNameLookupOpts {
42
+ /** ISO-2 (upper-case) countries this index answers for. Default `["FR"]` (the BAN street-centroids instance). */
43
+ countries?: Iterable<string>
44
+ /** Table name. Default `street_centroid`. */
45
+ table?: string
46
+ }
47
+
48
+ /**
49
+ * A {@link StreetLocalityEvidence} backed by a street-name SQLite index. Positive evidence only: any doubt (missing
50
+ * table, read miss) returns `false`, so the rerank fails open to the model's ranking.
51
+ */
52
+ export class SQLiteStreetNameLookup implements StreetLocalityEvidence {
53
+ readonly countries: ReadonlySet<string>
54
+ readonly #db: DatabaseSync
55
+ readonly #byName: ReturnType<DatabaseSync["prepare"]> | undefined
56
+ readonly #byNameLocality: ReturnType<DatabaseSync["prepare"]> | undefined
57
+ readonly #byNamePostcode: ReturnType<DatabaseSync["prepare"]> | undefined
58
+
59
+ constructor(dbPath: string, opts: SQLiteStreetNameLookupOpts = {}) {
60
+ this.countries = new Set([...(opts.countries ?? ["FR"])].map((c) => c.toUpperCase()))
61
+ this.#db = new DatabaseSync(dbPath, { readOnly: true })
62
+ const table = opts.table ?? "street_centroid"
63
+
64
+ // Degrade gracefully on an empty/tableless shard — a no-op miss, never a crash (#568 discipline).
65
+ if (hasTable(this.#db, table)) {
66
+ // Prefer the #727 phase-4c `name_key` column (foldStreetSurface, indexed by `idx_sc_name` for a direct seek);
67
+ // fall back to `street_norm` on a pre-rebuild shard (a skip-scan, but correct). The fold used to build
68
+ // `name_key` MUST match `foldStreetSurface` here (the fold-parity contract).
69
+ const keyCol = hasColumn(this.#db, table, "name_key") ? "name_key" : "street_norm"
70
+ this.#byName = this.#db.prepare(`SELECT 1 FROM ${table} WHERE ${keyCol} = ? LIMIT 1`)
71
+ this.#byNameLocality = this.#db.prepare(
72
+ `SELECT 1 FROM ${table} WHERE ${keyCol} = ? AND locality_base = ? LIMIT 1`
73
+ )
74
+ this.#byNamePostcode = this.#db.prepare(`SELECT 1 FROM ${table} WHERE ${keyCol} = ? AND postcode = ? LIMIT 1`)
75
+ }
76
+ }
77
+
78
+ hasStreetName(streetSurface: string, scope?: StreetEvidenceScope): boolean {
79
+ if (!this.#byName) return false
80
+ const norm = foldStreetSurface(streetSurface)
81
+
82
+ if (!norm) return false
83
+
84
+ // Scoped lookups tighten precision when the hypothesis carries a locality/postcode; a scoped MISS falls back to the
85
+ // unscoped probe (index incompleteness in the scope column is not evidence of absence — positive-evidence rule).
86
+ if (scope?.locality && this.#byNameLocality) {
87
+ if (this.#byNameLocality.get(norm, foldStreetSurface(scope.locality)) !== undefined) return true
88
+ }
89
+
90
+ if (scope?.postcode && this.#byNamePostcode) {
91
+ if (this.#byNamePostcode.get(norm, scope.postcode) !== undefined) return true
92
+ }
93
+
94
+ return this.#byName.get(norm) !== undefined
95
+ }
96
+
97
+ /** Close the underlying handle. */
98
+ close(): void {
99
+ this.#db.close()
100
+ }
101
+ }