@mailwoman/resolver-wof-sqlite 8.0.0 → 8.2.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 +9 -3
- package/address-point-schema.ts +32 -10
- package/address-point.ts +3 -0
- package/ancestry-backfill.ts +18 -5
- package/ancestry.ts +7 -2
- package/build-candidate.ts +29 -6
- package/build-slim.ts +40 -11
- package/candidate-fts.ts +1 -0
- package/candidate-lookup.ts +49 -15
- package/candidate-schema.ts +35 -11
- package/coincident-roles.ts +28 -6
- package/convention.ts +3 -1
- package/coverage-manifest-schema.ts +243 -0
- package/fst-autocomplete.ts +15 -9
- package/fst-builder.ts +65 -9
- package/fst-deserialize-web.ts +46 -9
- package/fst-matcher.ts +12 -5
- package/fst-serialize.ts +83 -9
- package/fst-types.ts +43 -0
- package/fts-query.ts +84 -0
- package/fts.ts +35 -9
- package/geo.ts +9 -3
- package/geonames-aliases.ts +116 -79
- package/geonames-postal.ts +25 -5
- package/index.ts +19 -0
- package/interpolation.ts +59 -55
- package/lookup.ts +103 -292
- package/name-score.ts +76 -0
- package/out/address-point-interpolation.d.ts.map +1 -1
- package/out/address-point-interpolation.js +4 -2
- package/out/address-point-interpolation.js.map +1 -1
- package/out/address-point-schema.d.ts +30 -10
- package/out/address-point-schema.d.ts.map +1 -1
- package/out/address-point-schema.js +6 -2
- package/out/address-point-schema.js.map +1 -1
- package/out/address-point.d.ts.map +1 -1
- package/out/address-point.js.map +1 -1
- package/out/ancestry-backfill.d.ts +6 -2
- package/out/ancestry-backfill.d.ts.map +1 -1
- package/out/ancestry-backfill.js +7 -3
- package/out/ancestry-backfill.js.map +1 -1
- package/out/ancestry.d.ts +6 -2
- package/out/ancestry.d.ts.map +1 -1
- package/out/ancestry.js +3 -1
- package/out/ancestry.js.map +1 -1
- package/out/build-candidate.d.ts +9 -3
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +5 -3
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +15 -5
- package/out/build-slim.d.ts.map +1 -1
- package/out/build-slim.js +11 -5
- package/out/build-slim.js.map +1 -1
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-lookup.d.ts +16 -3
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +27 -11
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +33 -11
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js.map +1 -1
- package/out/coincident-roles.d.ts +16 -4
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +9 -3
- package/out/coincident-roles.js.map +1 -1
- package/out/convention.d.ts +3 -1
- package/out/convention.d.ts.map +1 -1
- package/out/convention.js.map +1 -1
- package/out/coverage-manifest-schema.d.ts +112 -0
- package/out/coverage-manifest-schema.d.ts.map +1 -0
- package/out/coverage-manifest-schema.js +154 -0
- package/out/coverage-manifest-schema.js.map +1 -0
- package/out/fst-autocomplete.d.ts +1 -1
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +11 -9
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +42 -9
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +34 -9
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-matcher.d.ts +6 -2
- package/out/fst-matcher.d.ts.map +1 -1
- package/out/fst-matcher.js +9 -5
- package/out/fst-matcher.js.map +1 -1
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +62 -9
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +43 -0
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts-query.d.ts +41 -0
- package/out/fts-query.d.ts.map +1 -0
- package/out/fts-query.js +75 -0
- package/out/fts-query.js.map +1 -0
- package/out/fts.d.ts +21 -7
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +10 -4
- package/out/fts.js.map +1 -1
- package/out/geo.d.ts +6 -2
- package/out/geo.d.ts.map +1 -1
- package/out/geo.js +3 -1
- package/out/geo.js.map +1 -1
- package/out/geonames-aliases.d.ts +12 -4
- package/out/geonames-aliases.d.ts.map +1 -1
- package/out/geonames-aliases.js +72 -67
- package/out/geonames-aliases.js.map +1 -1
- package/out/geonames-postal.d.ts +9 -3
- package/out/geonames-postal.d.ts.map +1 -1
- package/out/geonames-postal.js +7 -2
- package/out/geonames-postal.js.map +1 -1
- package/out/index.d.ts +2 -0
- package/out/index.d.ts.map +1 -1
- package/out/index.js +1 -0
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +24 -6
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +32 -40
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +3 -97
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +52 -184
- package/out/lookup.js.map +1 -1
- package/out/name-score.d.ts +28 -0
- package/out/name-score.d.ts.map +1 -0
- package/out/name-score.js +67 -0
- package/out/name-score.js.map +1 -0
- package/out/poi-lookup.d.ts +24 -8
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +27 -13
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +42 -13
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +12 -3
- package/out/poi-schema.js.map +1 -1
- package/out/postal-city-alias-lookup.d.ts +18 -6
- package/out/postal-city-alias-lookup.d.ts.map +1 -1
- package/out/postal-city-alias-lookup.js.map +1 -1
- package/out/postal-city-alias-schema.d.ts +27 -9
- package/out/postal-city-alias-schema.d.ts.map +1 -1
- package/out/postal-city-alias-schema.js +3 -1
- package/out/postal-city-alias-schema.js.map +1 -1
- package/out/postal-city-candidate-schema.d.ts +15 -5
- package/out/postal-city-candidate-schema.d.ts.map +1 -1
- package/out/postal-city-candidate-schema.js +3 -1
- package/out/postal-city-candidate-schema.js.map +1 -1
- package/out/postcode-point-lookup.d.ts +6 -2
- package/out/postcode-point-lookup.d.ts.map +1 -1
- package/out/postcode-point-lookup.js +6 -2
- package/out/postcode-point-lookup.js.map +1 -1
- package/out/ranking-weights.d.ts +118 -0
- package/out/ranking-weights.d.ts.map +1 -0
- package/out/ranking-weights.js +44 -0
- package/out/ranking-weights.js.map +1 -0
- package/out/reverse.d.ts +9 -3
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +20 -6
- package/out/reverse.js.map +1 -1
- package/out/sharding.d.ts +3 -1
- package/out/sharding.d.ts.map +1 -1
- package/out/sharding.js +7 -5
- package/out/sharding.js.map +1 -1
- package/out/sqlite-convention-source.d.ts.map +1 -1
- package/out/sqlite-convention-source.js +3 -1
- package/out/sqlite-convention-source.js.map +1 -1
- package/out/street-centroid-schema.d.ts +33 -11
- package/out/street-centroid-schema.d.ts.map +1 -1
- package/out/street-centroid-schema.js +3 -1
- package/out/street-centroid-schema.js.map +1 -1
- package/out/street-centroid.d.ts.map +1 -1
- package/out/street-centroid.js +6 -2
- package/out/street-centroid.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts +6 -2
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +8 -7
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-morphology-fst-loader.d.ts +67 -0
- package/out/street-morphology-fst-loader.d.ts.map +1 -0
- package/out/street-morphology-fst-loader.js +59 -0
- package/out/street-morphology-fst-loader.js.map +1 -0
- package/out/street-name-lookup.d.ts +9 -3
- package/out/street-name-lookup.d.ts.map +1 -1
- package/out/street-name-lookup.js +9 -7
- package/out/street-name-lookup.js.map +1 -1
- package/out/street-normalize.d.ts +3 -1
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +23 -13
- package/out/street-normalize.js.map +1 -1
- package/out/street-segment-schema.d.ts +68 -13
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js +21 -2
- package/out/street-segment-schema.js.map +1 -1
- package/out/types.d.ts +18 -6
- package/out/types.d.ts.map +1 -1
- package/out/unified-schema.d.ts +1 -1
- package/out/unified-schema.d.ts.map +1 -1
- package/out/unified-schema.js +2 -2
- package/out/unified-schema.js.map +1 -1
- package/package.json +13 -5
- package/poi-lookup.ts +53 -21
- package/poi-schema.ts +43 -13
- package/postal-city-alias-lookup.ts +20 -6
- package/postal-city-alias-schema.ts +28 -9
- package/postal-city-candidate-schema.ts +15 -5
- package/postcode-point-lookup.ts +6 -2
- package/ranking-weights.ts +148 -0
- package/reverse.ts +47 -10
- package/sharding.ts +13 -6
- package/sqlite-convention-source.ts +4 -1
- package/street-centroid-schema.ts +35 -11
- package/street-centroid.ts +10 -3
- package/street-morphology-fst-builder.ts +25 -9
- package/street-morphology-fst-loader.ts +103 -0
- package/street-name-lookup.ts +19 -7
- package/street-normalize.ts +28 -13
- package/street-segment-schema.ts +83 -13
- package/types.ts +18 -6
- package/unified-schema.ts +11 -2
package/poi-lookup.ts
CHANGED
|
@@ -32,7 +32,9 @@ import { gridDisk, latLngToCell } from "h3-js"
|
|
|
32
32
|
|
|
33
33
|
import type { POICategoryCodeTable, POITable } from "./poi-schema.ts"
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4).
|
|
37
|
+
*/
|
|
36
38
|
export const POI_H3_RESOLUTION = 9
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -57,11 +59,15 @@ const DEFAULT_MAX_RINGS = 16
|
|
|
57
59
|
*/
|
|
58
60
|
const BRAND_MAX_DISTANCE_KM = 500
|
|
59
61
|
|
|
60
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Row-count default when a query doesn't specify `limit`.
|
|
64
|
+
*/
|
|
61
65
|
const DEFAULT_LIMIT = 20
|
|
62
66
|
|
|
63
67
|
export interface POISearchQuery {
|
|
64
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Poi-taxonomy category id (string side of the dictionary). Ignored when `brandWikidata` is also set — brand wins.
|
|
70
|
+
*/
|
|
65
71
|
categoryID?: string
|
|
66
72
|
/**
|
|
67
73
|
* Fan-out category ids — the Overture `taxonomy.primary` leaves a single canonical category rolls up into (e.g.
|
|
@@ -70,11 +76,17 @@ export interface POISearchQuery {
|
|
|
70
76
|
* one-element list `[categoryID]` when this is absent). Ignored when `brandWikidata` is set — brand wins.
|
|
71
77
|
*/
|
|
72
78
|
categoryIDs?: string[]
|
|
73
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Wikidata QID for brand-exact search.
|
|
81
|
+
*/
|
|
74
82
|
brandWikidata?: string
|
|
75
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Free-text name (FTS5).
|
|
85
|
+
*/
|
|
76
86
|
name?: string
|
|
77
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Search center. Required for category/brand queries (k-ring expansion).
|
|
89
|
+
*/
|
|
78
90
|
center?: { latitude: number; longitude: number }
|
|
79
91
|
/**
|
|
80
92
|
* Ring budget: how many res-9 k-rings to expand before giving up (default 16 ≈ ~5.4 km). Counts ring 0, so k reaches
|
|
@@ -92,19 +104,27 @@ export interface POISearchHit {
|
|
|
92
104
|
longitude: number
|
|
93
105
|
country: string
|
|
94
106
|
confidence: number
|
|
95
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Overture GERS id — nullable METADATA ONLY, never a key (the #470 rule; see `POITable.gers_id`).
|
|
109
|
+
*/
|
|
96
110
|
gersID: string | null
|
|
97
111
|
distanceM?: number
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
export interface POILookupOpts {
|
|
101
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Path to a `poi.db` built by the (future) POI builder. Opened read-only.
|
|
117
|
+
*/
|
|
102
118
|
databasePath?: string
|
|
103
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
|
|
121
|
+
*/
|
|
104
122
|
database?: DatabaseSync
|
|
105
123
|
}
|
|
106
124
|
|
|
107
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* The `poi` columns every search mode hydrates — a typed projection of the SHARED {@link POITable}.
|
|
127
|
+
*/
|
|
108
128
|
type POIRow = Pick<
|
|
109
129
|
POITable,
|
|
110
130
|
| "name"
|
|
@@ -129,11 +149,17 @@ export class POILookup implements Disposable {
|
|
|
129
149
|
readonly #categoryToID = new Map<string, number>()
|
|
130
150
|
readonly #idToCategory = new Map<number, string>()
|
|
131
151
|
|
|
132
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* `(h3_cell, category_id)` → the cell's category-clustered range, most-confident-first.
|
|
154
|
+
*/
|
|
133
155
|
readonly #categoryCellProbe: ReturnType<DatabaseSync["prepare"]>
|
|
134
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* `brand_wikidata` → ALL of a brand's rows globally (partial-index range-scan); distance-sorted in JS, not SQL.
|
|
158
|
+
*/
|
|
135
159
|
readonly #brandProbe: ReturnType<DatabaseSync["prepare"]>
|
|
136
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* FTS5 `MATCH` over `poi_search`, returning candidate `name_key`s to hydrate.
|
|
162
|
+
*/
|
|
137
163
|
readonly #nameFTSProbe: ReturnType<DatabaseSync["prepare"]>
|
|
138
164
|
|
|
139
165
|
constructor(opts: POILookupOpts) {
|
|
@@ -161,7 +187,9 @@ export class POILookup implements Disposable {
|
|
|
161
187
|
this.#categoryCellProbe = this.#db.prepare(
|
|
162
188
|
`SELECT ${columns} FROM poi WHERE h3_cell = ? AND category_id = ? ORDER BY neg_rank ASC LIMIT ?`
|
|
163
189
|
)
|
|
190
|
+
|
|
164
191
|
this.#brandProbe = this.#db.prepare(`SELECT ${columns} FROM poi WHERE brand_wikidata = ?`)
|
|
192
|
+
|
|
165
193
|
this.#nameFTSProbe = this.#db.prepare(
|
|
166
194
|
"SELECT name_key FROM poi_search WHERE poi_search MATCH ? ORDER BY bm25(poi_search) LIMIT ?"
|
|
167
195
|
)
|
|
@@ -174,7 +202,7 @@ export class POILookup implements Disposable {
|
|
|
174
202
|
return this.#searchByName(query.name, limit, query.center)
|
|
175
203
|
}
|
|
176
204
|
|
|
177
|
-
if (query.categoryID || (query.categoryIDs && query.categoryIDs.length
|
|
205
|
+
if (query.categoryID || (query.categoryIDs && query.categoryIDs.length) || query.brandWikidata) {
|
|
178
206
|
if (!query.center) {
|
|
179
207
|
throw new Error("POILookup.search: category/brand search requires a `center`")
|
|
180
208
|
}
|
|
@@ -208,7 +236,9 @@ export class POILookup implements Disposable {
|
|
|
208
236
|
.map((row) => toHit(row, this.#idToCategory, center))
|
|
209
237
|
}
|
|
210
238
|
|
|
211
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* Category path: k-ring expansion from `query.center`'s res-9 cell, probing each new ring's cells.
|
|
241
|
+
*/
|
|
212
242
|
#searchKRing(query: POISearchQuery, limit: number): POISearchHit[] {
|
|
213
243
|
const center = query.center!
|
|
214
244
|
const maxRings = query.maxRings ?? DEFAULT_MAX_RINGS
|
|
@@ -227,7 +257,7 @@ export class POILookup implements Disposable {
|
|
|
227
257
|
}
|
|
228
258
|
|
|
229
259
|
// No resolvable leaf (every id unknown to the dictionary) can't have rows — a clean miss, not a throw.
|
|
230
|
-
if (categoryIds.length
|
|
260
|
+
if (!categoryIds.length) return []
|
|
231
261
|
|
|
232
262
|
const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION) as H3Cell
|
|
233
263
|
const seenCells = new Set<string>()
|
|
@@ -281,7 +311,7 @@ export class POILookup implements Disposable {
|
|
|
281
311
|
uniqueKeys.push(hit.name_key)
|
|
282
312
|
}
|
|
283
313
|
|
|
284
|
-
if (uniqueKeys.length
|
|
314
|
+
if (!uniqueKeys.length) return []
|
|
285
315
|
|
|
286
316
|
const hydrated = this.#hydrateByNameKeys(uniqueKeys)
|
|
287
317
|
const rowsByKey = new Map<string, POIRow[]>()
|
|
@@ -334,13 +364,15 @@ export class POILookup implements Disposable {
|
|
|
334
364
|
}
|
|
335
365
|
}
|
|
336
366
|
|
|
337
|
-
/**
|
|
367
|
+
/**
|
|
368
|
+
* `poi.h3_cell` is the SHORTENED (48-bit) cell — never the full h3-js cell string.
|
|
369
|
+
*/
|
|
338
370
|
function h3CellToInt(cell: H3Cell): number {
|
|
339
371
|
return Number(BigInt(`0x${shortenH3Cell(cell)}`))
|
|
340
372
|
}
|
|
341
373
|
|
|
342
374
|
function sortByDistance(rows: POIRow[], center: { latitude: number; longitude: number }): POIRow[] {
|
|
343
|
-
return [...rows].
|
|
375
|
+
return [...rows].toSorted(
|
|
344
376
|
(a, b) =>
|
|
345
377
|
haversineKm(center.latitude, center.longitude, a.latitude, a.longitude) -
|
|
346
378
|
haversineKm(center.latitude, center.longitude, b.latitude, b.longitude)
|
|
@@ -378,10 +410,10 @@ function toHit(
|
|
|
378
410
|
*/
|
|
379
411
|
function sanitizePOINameQuery(text: string): string {
|
|
380
412
|
return text
|
|
381
|
-
.
|
|
413
|
+
.replaceAll(/["*:]/g, "")
|
|
382
414
|
.trim()
|
|
383
415
|
.split(/\s+/u)
|
|
384
416
|
.filter(Boolean)
|
|
385
|
-
.map((token) => `"${token.
|
|
417
|
+
.map((token) => `"${token.replaceAll('"', '""')}"`)
|
|
386
418
|
.join(" ")
|
|
387
419
|
}
|
package/poi-schema.ts
CHANGED
|
@@ -17,27 +17,45 @@ import type { DatabaseSync } from "node:sqlite"
|
|
|
17
17
|
import type { LayerContractDatabase } from "@mailwoman/core/layers"
|
|
18
18
|
import { sql, type Kysely } from "kysely"
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* One POI row. Clustered PK: h3_cell → category_id → neg_rank → rowid_key.
|
|
22
|
+
*/
|
|
21
23
|
export interface POITable {
|
|
22
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* 48-bit short H3 cell at res 9 (`latLngToCell` → `shortenH3Cell`).
|
|
26
|
+
*/
|
|
23
27
|
h3_cell: number
|
|
24
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Small int from {@link POICategoryCodeTable}; 0 = uncategorized.
|
|
30
|
+
*/
|
|
25
31
|
category_id: number
|
|
26
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* `-log10(confidence + epsilon)` so ASC = most-confident-first within a cell+category.
|
|
34
|
+
*/
|
|
27
35
|
neg_rank: number
|
|
28
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Uniquifier within the clustered key (builder-assigned monotonic int).
|
|
38
|
+
*/
|
|
29
39
|
rowid_key: number
|
|
30
40
|
name: string | null
|
|
31
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Lowercased, diacritic-flattened probe key for exact name lookups.
|
|
43
|
+
*/
|
|
32
44
|
name_key: string | null
|
|
33
45
|
brand_wikidata: string | null
|
|
34
46
|
latitude: number
|
|
35
47
|
longitude: number
|
|
36
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* ISO 3166-1 alpha-2 (from the Overture partition).
|
|
50
|
+
*/
|
|
37
51
|
country: string
|
|
38
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Overture existence confidence (already filtered ≥ 0.85 at build).
|
|
54
|
+
*/
|
|
39
55
|
confidence: number
|
|
40
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* GERS id — nullable METADATA ONLY, never a key (the #470 rule).
|
|
58
|
+
*/
|
|
41
59
|
gers_id: string | null
|
|
42
60
|
}
|
|
43
61
|
|
|
@@ -60,7 +78,9 @@ export interface POIStageTable {
|
|
|
60
78
|
gers_id: string | null
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* `(id → poi-taxonomy category id)` dictionary, e.g. `3 → "cafe"`.
|
|
83
|
+
*/
|
|
64
84
|
export interface POICategoryCodeTable {
|
|
65
85
|
id: number
|
|
66
86
|
category: string
|
|
@@ -72,7 +92,9 @@ export interface POIDatabase extends LayerContractDatabase {
|
|
|
72
92
|
poi_category_codes: POICategoryCodeTable
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Clustered-key-order column list shared by builder + `INSERT INTO poi SELECT … FROM poi_stage`.
|
|
97
|
+
*/
|
|
76
98
|
export const POI_COLUMNS = [
|
|
77
99
|
"h3_cell",
|
|
78
100
|
"category_id",
|
|
@@ -94,6 +116,7 @@ export async function createPOIStagingTables(db: Kysely<POIDatabase>): Promise<v
|
|
|
94
116
|
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
95
117
|
.addColumn("category", "text", (c) => c.unique())
|
|
96
118
|
.execute()
|
|
119
|
+
|
|
97
120
|
await db.schema
|
|
98
121
|
.createTable("poi_stage")
|
|
99
122
|
.addColumn("h3_cell", "integer")
|
|
@@ -132,7 +155,9 @@ export async function createPOITable(db: Kysely<POIDatabase>): Promise<void> {
|
|
|
132
155
|
.execute()
|
|
133
156
|
}
|
|
134
157
|
|
|
135
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Secondary index for the FTS-hydration path. Builders call this AFTER the bulk materialize (index-after-load).
|
|
160
|
+
*/
|
|
136
161
|
export async function createPOINameKeyIndex(db: Kysely<POIDatabase>): Promise<void> {
|
|
137
162
|
await db.schema.createIndex("poi_name_key").on("poi").column("name_key").execute()
|
|
138
163
|
}
|
|
@@ -154,9 +179,14 @@ export async function createPOIBrandIndex(db: Kysely<POIDatabase>): Promise<void
|
|
|
154
179
|
.execute()
|
|
155
180
|
}
|
|
156
181
|
|
|
182
|
+
/**
|
|
183
|
+
* FTS5 virtual table backing POI name search in poi.db.
|
|
184
|
+
*/
|
|
157
185
|
export const POI_FTS_TABLE = "poi_search"
|
|
158
186
|
|
|
159
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* FTS5 stays raw SQL by project rule (Kysely can't express virtual tables). Content-keyed by name_key.
|
|
189
|
+
*/
|
|
160
190
|
export function createPOISearchFTS(db: DatabaseSync): void {
|
|
161
191
|
db.exec(
|
|
162
192
|
`CREATE VIRTUAL TABLE ${POI_FTS_TABLE} USING fts5(name, name_key UNINDEXED, h3_cell UNINDEXED, tokenize = 'unicode61')`
|
|
@@ -23,19 +23,31 @@ import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
|
23
23
|
import type { PostalCityAliasDatabase } from "./postal-city-alias-schema.ts"
|
|
24
24
|
|
|
25
25
|
export interface WOFPostalCityAliasLookupOpts {
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Path to a `postal-city-alias-<cc>.db` built by `build-postal-city-alias.ts`. Opened read-only.
|
|
28
|
+
*/
|
|
27
29
|
databasePath?: string
|
|
28
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
|
|
32
|
+
*/
|
|
29
33
|
database?: DatabaseSync
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* One divergent alias edge: the postal-system name and the geographic locality it maps to.
|
|
38
|
+
*/
|
|
33
39
|
export interface PostalCityAlias {
|
|
34
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The postal-system surface (what a user types).
|
|
42
|
+
*/
|
|
35
43
|
postalCity: string
|
|
36
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The geographic locality name the postcode sits in (≈ the gazetteer's canonical name).
|
|
46
|
+
*/
|
|
37
47
|
geoLocality: string
|
|
38
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Observed usage count — the evidence weight.
|
|
50
|
+
*/
|
|
39
51
|
n: number
|
|
40
52
|
}
|
|
41
53
|
|
|
@@ -59,6 +71,7 @@ export class WOFPostalCityAliasLookup {
|
|
|
59
71
|
} else {
|
|
60
72
|
throw new Error("WOFPostalCityAliasLookup needs `databasePath` or `database`")
|
|
61
73
|
}
|
|
74
|
+
|
|
62
75
|
// `#kdb` wraps `#db` for the typed query; close() owns the raw handle directly (sync).
|
|
63
76
|
this.#kdb = new DatabaseClient<PostalCityAliasDatabase>({ database: this.#db })
|
|
64
77
|
}
|
|
@@ -71,6 +84,7 @@ export class WOFPostalCityAliasLookup {
|
|
|
71
84
|
const pc = postcode.trim()
|
|
72
85
|
|
|
73
86
|
if (!pc) return []
|
|
87
|
+
|
|
74
88
|
const rows = await this.#kdb
|
|
75
89
|
.selectFrom("postal_city_alias")
|
|
76
90
|
.select(["postal_city", "geo_locality", "n"])
|
|
@@ -23,28 +23,46 @@ import type { Kysely } from "kysely"
|
|
|
23
23
|
* a `MIN_COUNT` floor on `n`, so every row is a non-trivial usage.
|
|
24
24
|
*/
|
|
25
25
|
export interface PostalCityAliasTable {
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The postcode the aggregate is scoped to (the resolver probes by this).
|
|
28
|
+
*/
|
|
27
29
|
postcode: string
|
|
28
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* What the postal system calls the place (the surface a user is likely to type).
|
|
32
|
+
*/
|
|
29
33
|
postal_city: string
|
|
30
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name).
|
|
36
|
+
*/
|
|
31
37
|
geo_locality: string
|
|
32
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Observed row count — the evidence weight behind this alias.
|
|
40
|
+
*/
|
|
33
41
|
n: number
|
|
34
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree.
|
|
44
|
+
*/
|
|
35
45
|
divergent: number
|
|
36
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Provenance: the dataset this aggregate came from (e.g. `overture:US`).
|
|
48
|
+
*/
|
|
37
49
|
source: string
|
|
38
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The pinned data release the aggregate was computed from.
|
|
52
|
+
*/
|
|
39
53
|
release: string
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`.
|
|
58
|
+
*/
|
|
43
59
|
export interface PostalCityAliasDatabase {
|
|
44
60
|
postal_city_alias: PostalCityAliasTable
|
|
45
61
|
}
|
|
46
62
|
|
|
47
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* The `postal_city_alias` column order — the builder's INSERT derives its column list from this.
|
|
65
|
+
*/
|
|
48
66
|
export const POSTAL_CITY_ALIAS_COLUMNS = [
|
|
49
67
|
"postcode",
|
|
50
68
|
"postal_city",
|
|
@@ -70,6 +88,7 @@ export async function createPostalCityAliasTable(db: Kysely<PostalCityAliasDatab
|
|
|
70
88
|
.addColumn("source", "text", (c) => c.notNull())
|
|
71
89
|
.addColumn("release", "text", (c) => c.notNull())
|
|
72
90
|
.execute()
|
|
91
|
+
|
|
73
92
|
await db.schema.createIndex("idx_pca_postcode").on("postal_city_alias").column("postcode").execute()
|
|
74
93
|
await db.schema.createIndex("idx_pca_pair").on("postal_city_alias").columns(["postal_city", "geo_locality"]).execute()
|
|
75
94
|
}
|
|
@@ -25,13 +25,21 @@ import { sql, type Kysely } from "kysely"
|
|
|
25
25
|
* locality directly; the denormalized name/coord avoid a join back to `candidate`.
|
|
26
26
|
*/
|
|
27
27
|
export interface PostalCityCandidateTable {
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key.
|
|
30
|
+
*/
|
|
29
31
|
name_key: string
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The postcode the alias is scoped to (the second half of the exact key).
|
|
34
|
+
*/
|
|
31
35
|
postcode: string
|
|
32
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* WOF id of the geographic locality the postcode sits in (the resolve target).
|
|
38
|
+
*/
|
|
33
39
|
spr_id: number
|
|
34
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The geographic locality's display name.
|
|
42
|
+
*/
|
|
35
43
|
name: string
|
|
36
44
|
latitude: number
|
|
37
45
|
longitude: number
|
|
@@ -49,7 +57,9 @@ export interface PostalCityCandidateDatabase {
|
|
|
49
57
|
*/
|
|
50
58
|
export const POSTAL_CITY_CANDIDATE_TABLE = "postal_city_candidate"
|
|
51
59
|
|
|
52
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Column order for the builder's positional INSERT.
|
|
62
|
+
*/
|
|
53
63
|
export const POSTAL_CITY_CANDIDATE_COLUMNS = [
|
|
54
64
|
"name_key",
|
|
55
65
|
"postcode",
|
package/postcode-point-lookup.ts
CHANGED
|
@@ -37,13 +37,17 @@ export class WOFPostcodeLookup {
|
|
|
37
37
|
readonly #dbs: DatabaseSync[]
|
|
38
38
|
readonly #stmts: ReturnType<DatabaseSync["prepare"]>[]
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Open each shard read-only and prepare its exact-match statement.
|
|
42
|
+
*/
|
|
41
43
|
constructor(dbPaths: readonly string[]) {
|
|
42
44
|
this.#dbs = dbPaths.map((p) => new DatabaseSync(p, { readOnly: true }))
|
|
43
45
|
this.#stmts = this.#dbs.map((db) => db.prepare(LOOKUP_SQL))
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Exact-match the postcode across every shard and union the rows.
|
|
50
|
+
*/
|
|
47
51
|
lookup(postcode: string): PostcodePlace[] {
|
|
48
52
|
const out: PostcodePlace[] = []
|
|
49
53
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The locality-ranking weights and their shipped defaults. Its own module because every value here is
|
|
7
|
+
* a measured tuning decision with its rationale attached — the block reads as a reference table, not
|
|
8
|
+
* as part of the lookup's control flow, and the tests import it directly to pin one lever at a time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ranking weights for `findPlace`. Tweakable per-instance but defaults match the values declared in the Phase 4.2 plan
|
|
13
|
+
* doc.
|
|
14
|
+
*/
|
|
15
|
+
export interface RankingWeights {
|
|
16
|
+
/**
|
|
17
|
+
* Boost when the candidate's placetype matches an explicit `placetype` filter.
|
|
18
|
+
*/
|
|
19
|
+
placetypeMatchBoost: number
|
|
20
|
+
/**
|
|
21
|
+
* Boost when the candidate is a locality and no explicit placetype was requested.
|
|
22
|
+
*/
|
|
23
|
+
localityImplicitBoost: number
|
|
24
|
+
/**
|
|
25
|
+
* Boost when the candidate's country matches an explicit `country` filter.
|
|
26
|
+
*/
|
|
27
|
+
countryMatchBoost: number
|
|
28
|
+
/**
|
|
29
|
+
* Boost when the candidate is a direct child of the requested `parentID`.
|
|
30
|
+
*/
|
|
31
|
+
directChildBoost: number
|
|
32
|
+
/**
|
|
33
|
+
* Boost when the candidate is a transitive descendant of the requested `parentID`.
|
|
34
|
+
*/
|
|
35
|
+
descendantBoost: number
|
|
36
|
+
/**
|
|
37
|
+
* Multiplier on the length-penalty term (penalizes much-longer-than-query names).
|
|
38
|
+
*/
|
|
39
|
+
lengthPenaltyWeight: number
|
|
40
|
+
/**
|
|
41
|
+
* Magnitude of the proximity boost when the query carries `near`. The contribution is `proximityBoost / (1 +
|
|
42
|
+
* distanceKm / proximityScaleKm)` — at distance 0 the boost is full magnitude, at `proximityScaleKm` it's half,
|
|
43
|
+
* decaying further with distance. Default tuned so proximity can overcome a typical FTS rank tie but not dominate a
|
|
44
|
+
* strong text match.
|
|
45
|
+
*/
|
|
46
|
+
proximityBoost: number
|
|
47
|
+
/**
|
|
48
|
+
* Magnitude of the bias-hint term inside the exact-tier PROMINENCE sort (the `bias`/viewport path). Deliberately
|
|
49
|
+
* population-scale (default = populationBoost) so a candidate near the map view / the user beats a distant-but-bigger
|
|
50
|
+
* namesake — "the map view wins" is the feature; same-region ties (all candidates far from every hint) still fall to
|
|
51
|
+
* population.
|
|
52
|
+
*/
|
|
53
|
+
biasBoost: number
|
|
54
|
+
/**
|
|
55
|
+
* Distance (km) at which the proximity boost halves. Tune to the typical query radius.
|
|
56
|
+
*/
|
|
57
|
+
proximityScaleKm: number
|
|
58
|
+
/**
|
|
59
|
+
* Magnitude of the population boost when the candidate has a known `wof:population`. The contribution is
|
|
60
|
+
* `populationBoost * log10(1 + population) / populationScaleLog10`, capped at `populationBoost`. WOF only carries
|
|
61
|
+
* population for ~15% of localities (mostly larger ones); places without it get +0 (never a penalty). Default tuned
|
|
62
|
+
* so the famous Springfield, IL (pop ~112k) gets ~0.42 boost — enough to nudge past tiny same-name peers.
|
|
63
|
+
*/
|
|
64
|
+
populationBoost: number
|
|
65
|
+
/**
|
|
66
|
+
* Population (in log10) at which the boost reaches its full magnitude. Default 6 — i.e. a population of 1,000,000
|
|
67
|
+
* gives `populationBoost` exactly. Larger populations cap at the same value (no compounding effect for megacities).
|
|
68
|
+
*/
|
|
69
|
+
populationScaleLog10: number
|
|
70
|
+
/**
|
|
71
|
+
* Tier candidates with an EXACT name/alias match above candidates that only match partially, BEFORE the weighted-sum
|
|
72
|
+
* score is consulted. Default true.
|
|
73
|
+
*
|
|
74
|
+
* Why this is needed (and why it ALIGNS with — rather than overrides — the population/importance signal): the
|
|
75
|
+
* weighted sum adds population as a large additive boost (`populationBoost`, up to +4) so that famous places surface
|
|
76
|
+
* for unambiguous full-name queries. But population is a _prominence prior_ — its job is to break ties among
|
|
77
|
+
* candidates that match the query EQUALLY WELL (e.g. "Springfield" → Springfield IL over Springfield MA, both exact
|
|
78
|
+
* name matches). It was never meant to promote a place that matches the query WORSE. For a 2-letter region
|
|
79
|
+
* abbreviation that backfires: querying "ME" returns Maine (which has the exact alias `ME`) AND Missouri/
|
|
80
|
+
* Michigan/etc. (which do not), and Missouri's larger population (+4) overcomes Maine's bm25 edge — so "Portland, ME"
|
|
81
|
+
* resolves its region to Missouri and the locality then cascades to the wrong state. Tiering restores the intended
|
|
82
|
+
* ordering: **match quality is the primary key, prominence (population) the secondary key WITHIN a tier.**
|
|
83
|
+
* Springfield-IL-over-MA still works (both exact → same tier → population decides); ME→Maine now works (only Maine is
|
|
84
|
+
* exact → higher tier → population never gets to override it). See
|
|
85
|
+
* docs/articles/evals/resolver-geo/2026-05-30-resolver-exact-match.md.
|
|
86
|
+
*
|
|
87
|
+
* Note: tiering re-ranks within the over-fetched candidate window (`limit * 4`); a pathological exact match that
|
|
88
|
+
* falls outside that window is not rescued. For the region-abbrev case the window is comfortably sufficient (a
|
|
89
|
+
* handful of states match a 2-letter query).
|
|
90
|
+
*/
|
|
91
|
+
exactMatchTiering: boolean
|
|
92
|
+
/**
|
|
93
|
+
* #936 option 3 — official-language names ARE names. When true, a candidate holding the query as an OFFICIAL name
|
|
94
|
+
* (`names.official = 1`: a preferred-form name in an official language of its country, stamped at ingest) joins the
|
|
95
|
+
* NAME-exact sub-tier rather than the alias-exact one, provided its population clears {@link officialNameExactFloor}.
|
|
96
|
+
* Fixes unscoped "Åbo" → Turku (its official Swedish name) over a hamlet literally named Åbo; population still orders
|
|
97
|
+
* within the sub-tier, so Paris → Paris FR is untouched.
|
|
98
|
+
*
|
|
99
|
+
* Default true (operator-promoted 2026-07-03 after the pre-registered gate battery: four intended exonym flips —
|
|
100
|
+
* Berne→Bern, Bruges→Brugge, Roma→Rome, Åbo→Turku — with the namesake/abbreviation rows and the US/FI panels
|
|
101
|
+
* byte-identical). Requires a gazetteer carrying the #940 ingest bit — on older DBs without the `official` column the
|
|
102
|
+
* probe fails soft and behavior is identical to the flag being off.
|
|
103
|
+
*/
|
|
104
|
+
officialNameExact: boolean
|
|
105
|
+
/**
|
|
106
|
+
* Minimum population for a candidate's official names to join the name-exact sub-tier. The #936 review's no-floor
|
|
107
|
+
* census measured the boundary: ≥100k holders are the famous-exonym class (757 flips, intent-correct; 7 collisions,
|
|
108
|
+
* none harmful) while 10k–100k holders are junk-dominated (3,481 flips led by short-form mis-tags — Villeneuve-Loubet
|
|
109
|
+
* carrying "villeneuve" would bury five real villages of that name). Rank-time knob: tunable without re-ingest;
|
|
110
|
+
* below-floor official names simply stay alias-tier (today's behavior).
|
|
111
|
+
*/
|
|
112
|
+
officialNameExactFloor: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The shipped weights. Every value is a measured decision — change one and re-run the resolver eval; the per-field docs
|
|
117
|
+
* on {@link RankingWeights} say what each lever moves and what motivated its current value.
|
|
118
|
+
*/
|
|
119
|
+
export const DEFAULT_WEIGHTS: RankingWeights = {
|
|
120
|
+
placetypeMatchBoost: 0.5,
|
|
121
|
+
localityImplicitBoost: 0.2,
|
|
122
|
+
countryMatchBoost: 0.3,
|
|
123
|
+
directChildBoost: 0.5,
|
|
124
|
+
descendantBoost: 0.2,
|
|
125
|
+
lengthPenaltyWeight: 0.1,
|
|
126
|
+
proximityBoost: 0.8,
|
|
127
|
+
proximityScaleKm: 100,
|
|
128
|
+
biasBoost: 4,
|
|
129
|
+
// populationBoost is intentionally large — empirical tuning against real WOF showed BM25 gaps
|
|
130
|
+
// of 1.5-3.0 between famous places and tiny same-name peers (because the famous ones have
|
|
131
|
+
// hundreds of alt-name entries that hurt their FTS document score). To consistently surface
|
|
132
|
+
// "the famous one" for unambiguous queries like "New York" or "Chicago", the population signal
|
|
133
|
+
// needs to dominate. Callers wanting a more conservative balance can drop this in the
|
|
134
|
+
// RankingWeights override.
|
|
135
|
+
//
|
|
136
|
+
// Note: this resolver uses `place_population` directly. The separate `place_importance` table
|
|
137
|
+
// (Wikipedia-derived) is consumed by the FST layer, not here. See
|
|
138
|
+
// docs/articles/concepts/importance-vs-population.md for the two-signal contract.
|
|
139
|
+
populationBoost: 4,
|
|
140
|
+
populationScaleLog10: 6,
|
|
141
|
+
// Exact name/alias match outranks partial match before the weighted sum (incl. population) is
|
|
142
|
+
// consulted — keeps population as an intra-tier prominence tiebreaker, not a cross-tier promoter.
|
|
143
|
+
// Fixes the 2-letter-region-abbrev bug ("ME" → Maine, not the more-populous Missouri).
|
|
144
|
+
exactMatchTiering: true,
|
|
145
|
+
// #936 option 3 — promoted default-ON 2026-07-03 (gate battery PASS; see the RankingWeights docstring).
|
|
146
|
+
officialNameExact: true,
|
|
147
|
+
officialNameExactFloor: 100_000,
|
|
148
|
+
}
|