@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/lookup.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type ResolvedConvention,
|
|
28
28
|
type Strategy,
|
|
29
29
|
} from "./convention.ts"
|
|
30
|
+
import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.ts"
|
|
30
31
|
import {
|
|
31
32
|
aliasBagExactMatch,
|
|
32
33
|
buildPlaceSearchFTS,
|
|
@@ -37,7 +38,9 @@ import {
|
|
|
37
38
|
placeSearchFTSExists,
|
|
38
39
|
} from "./fts.ts"
|
|
39
40
|
import { bboxAround, haversineKm } from "./geo.ts"
|
|
41
|
+
import { cfNormalize, softNameScore, trigramJaccard } from "./name-score.ts"
|
|
40
42
|
import type { WOFPostalCityAliasLookup } from "./postal-city-alias-lookup.ts"
|
|
43
|
+
import { DEFAULT_WEIGHTS, type RankingWeights } from "./ranking-weights.ts"
|
|
41
44
|
import type { WOFDatabase } from "./schema.ts"
|
|
42
45
|
import {
|
|
43
46
|
pickShardForPlacetype,
|
|
@@ -49,6 +52,13 @@ import {
|
|
|
49
52
|
import { SqliteConventionSource } from "./sqlite-convention-source.ts"
|
|
50
53
|
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
|
|
57
|
+
* abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
|
|
58
|
+
* losing to "New York".
|
|
59
|
+
*/
|
|
60
|
+
const SHORT_QUERY_MAX_LENGTH = 3
|
|
61
|
+
|
|
52
62
|
export interface WOFSqlitePlaceLookupOpts {
|
|
53
63
|
/**
|
|
54
64
|
* Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
|
|
@@ -96,127 +106,6 @@ export interface WOFSqlitePlaceLookupOpts {
|
|
|
96
106
|
postalCityAliases?: WOFPostalCityAliasLookup
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
/**
|
|
100
|
-
* Ranking weights for `findPlace`. Tweakable per-instance but defaults match the values declared in the Phase 4.2 plan
|
|
101
|
-
* doc.
|
|
102
|
-
*/
|
|
103
|
-
export interface RankingWeights {
|
|
104
|
-
/** Boost when the candidate's placetype matches an explicit `placetype` filter. */
|
|
105
|
-
placetypeMatchBoost: number
|
|
106
|
-
/** Boost when the candidate is a locality and no explicit placetype was requested. */
|
|
107
|
-
localityImplicitBoost: number
|
|
108
|
-
/** Boost when the candidate's country matches an explicit `country` filter. */
|
|
109
|
-
countryMatchBoost: number
|
|
110
|
-
/** Boost when the candidate is a direct child of the requested `parentID`. */
|
|
111
|
-
directChildBoost: number
|
|
112
|
-
/** Boost when the candidate is a transitive descendant of the requested `parentID`. */
|
|
113
|
-
descendantBoost: number
|
|
114
|
-
/** Multiplier on the length-penalty term (penalizes much-longer-than-query names). */
|
|
115
|
-
lengthPenaltyWeight: number
|
|
116
|
-
/**
|
|
117
|
-
* Magnitude of the proximity boost when the query carries `near`. The contribution is `proximityBoost / (1 +
|
|
118
|
-
* distanceKm / proximityScaleKm)` — at distance 0 the boost is full magnitude, at `proximityScaleKm` it's half,
|
|
119
|
-
* decaying further with distance. Default tuned so proximity can overcome a typical FTS rank tie but not dominate a
|
|
120
|
-
* strong text match.
|
|
121
|
-
*/
|
|
122
|
-
proximityBoost: number
|
|
123
|
-
/**
|
|
124
|
-
* Magnitude of the bias-hint term inside the exact-tier PROMINENCE sort (the `bias`/viewport path). Deliberately
|
|
125
|
-
* population-scale (default = populationBoost) so a candidate near the map view / the user beats a distant-but-bigger
|
|
126
|
-
* namesake — "the map view wins" is the feature; same-region ties (all candidates far from every hint) still fall to
|
|
127
|
-
* population.
|
|
128
|
-
*/
|
|
129
|
-
biasBoost: number
|
|
130
|
-
/** Distance (km) at which the proximity boost halves. Tune to the typical query radius. */
|
|
131
|
-
proximityScaleKm: number
|
|
132
|
-
/**
|
|
133
|
-
* Magnitude of the population boost when the candidate has a known `wof:population`. The contribution is
|
|
134
|
-
* `populationBoost * log10(1 + population) / populationScaleLog10`, capped at `populationBoost`. WOF only carries
|
|
135
|
-
* population for ~15% of localities (mostly larger ones); places without it get +0 (never a penalty). Default tuned
|
|
136
|
-
* so the famous Springfield, IL (pop ~112k) gets ~0.42 boost — enough to nudge past tiny same-name peers.
|
|
137
|
-
*/
|
|
138
|
-
populationBoost: number
|
|
139
|
-
/**
|
|
140
|
-
* Population (in log10) at which the boost reaches its full magnitude. Default 6 — i.e. a population of 1,000,000
|
|
141
|
-
* gives `populationBoost` exactly. Larger populations cap at the same value (no compounding effect for megacities).
|
|
142
|
-
*/
|
|
143
|
-
populationScaleLog10: number
|
|
144
|
-
/**
|
|
145
|
-
* Tier candidates with an EXACT name/alias match above candidates that only match partially, BEFORE the weighted-sum
|
|
146
|
-
* score is consulted. Default true.
|
|
147
|
-
*
|
|
148
|
-
* Why this is needed (and why it ALIGNS with — rather than overrides — the population/importance signal): the
|
|
149
|
-
* weighted sum adds population as a large additive boost (`populationBoost`, up to +4) so that famous places surface
|
|
150
|
-
* for unambiguous full-name queries. But population is a _prominence prior_ — its job is to break ties among
|
|
151
|
-
* candidates that match the query EQUALLY WELL (e.g. "Springfield" → Springfield IL over Springfield MA, both exact
|
|
152
|
-
* name matches). It was never meant to promote a place that matches the query WORSE. For a 2-letter region
|
|
153
|
-
* abbreviation that backfires: querying "ME" returns Maine (which has the exact alias `ME`) AND Missouri/
|
|
154
|
-
* Michigan/etc. (which do not), and Missouri's larger population (+4) overcomes Maine's bm25 edge — so "Portland, ME"
|
|
155
|
-
* resolves its region to Missouri and the locality then cascades to the wrong state. Tiering restores the intended
|
|
156
|
-
* ordering: **match quality is the primary key, prominence (population) the secondary key WITHIN a tier.**
|
|
157
|
-
* Springfield-IL-over-MA still works (both exact → same tier → population decides); ME→Maine now works (only Maine is
|
|
158
|
-
* exact → higher tier → population never gets to override it). See
|
|
159
|
-
* docs/articles/evals/resolver-geo/2026-05-30-resolver-exact-match.md.
|
|
160
|
-
*
|
|
161
|
-
* Note: tiering re-ranks within the over-fetched candidate window (`limit * 4`); a pathological exact match that
|
|
162
|
-
* falls outside that window is not rescued. For the region-abbrev case the window is comfortably sufficient (a
|
|
163
|
-
* handful of states match a 2-letter query).
|
|
164
|
-
*/
|
|
165
|
-
exactMatchTiering: boolean
|
|
166
|
-
/**
|
|
167
|
-
* #936 option 3 — official-language names ARE names. When true, a candidate holding the query as an OFFICIAL name
|
|
168
|
-
* (`names.official = 1`: a preferred-form name in an official language of its country, stamped at ingest) joins the
|
|
169
|
-
* NAME-exact sub-tier rather than the alias-exact one, provided its population clears {@link officialNameExactFloor}.
|
|
170
|
-
* Fixes unscoped "Åbo" → Turku (its official Swedish name) over a hamlet literally named Åbo; population still orders
|
|
171
|
-
* within the sub-tier, so Paris → Paris FR is untouched.
|
|
172
|
-
*
|
|
173
|
-
* Default true (operator-promoted 2026-07-03 after the pre-registered gate battery: four intended exonym flips —
|
|
174
|
-
* Berne→Bern, Bruges→Brugge, Roma→Rome, Åbo→Turku — with the namesake/abbreviation rows and the US/FI panels
|
|
175
|
-
* byte-identical). Requires a gazetteer carrying the #940 ingest bit — on older DBs without the `official` column the
|
|
176
|
-
* probe fails soft and behavior is identical to the flag being off.
|
|
177
|
-
*/
|
|
178
|
-
officialNameExact: boolean
|
|
179
|
-
/**
|
|
180
|
-
* Minimum population for a candidate's official names to join the name-exact sub-tier. The #936 review's no-floor
|
|
181
|
-
* census measured the boundary: ≥100k holders are the famous-exonym class (757 flips, intent-correct; 7 collisions,
|
|
182
|
-
* none harmful) while 10k–100k holders are junk-dominated (3,481 flips led by short-form mis-tags — Villeneuve-Loubet
|
|
183
|
-
* carrying "villeneuve" would bury five real villages of that name). Rank-time knob: tunable without re-ingest;
|
|
184
|
-
* below-floor official names simply stay alias-tier (today's behavior).
|
|
185
|
-
*/
|
|
186
|
-
officialNameExactFloor: number
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const DEFAULT_WEIGHTS: RankingWeights = {
|
|
190
|
-
placetypeMatchBoost: 0.5,
|
|
191
|
-
localityImplicitBoost: 0.2,
|
|
192
|
-
countryMatchBoost: 0.3,
|
|
193
|
-
directChildBoost: 0.5,
|
|
194
|
-
descendantBoost: 0.2,
|
|
195
|
-
lengthPenaltyWeight: 0.1,
|
|
196
|
-
proximityBoost: 0.8,
|
|
197
|
-
proximityScaleKm: 100,
|
|
198
|
-
biasBoost: 4.0,
|
|
199
|
-
// populationBoost is intentionally large — empirical tuning against real WOF showed BM25 gaps
|
|
200
|
-
// of 1.5-3.0 between famous places and tiny same-name peers (because the famous ones have
|
|
201
|
-
// hundreds of alt-name entries that hurt their FTS document score). To consistently surface
|
|
202
|
-
// "the famous one" for unambiguous queries like "New York" or "Chicago", the population signal
|
|
203
|
-
// needs to dominate. Callers wanting a more conservative balance can drop this in the
|
|
204
|
-
// RankingWeights override.
|
|
205
|
-
//
|
|
206
|
-
// Note: this resolver uses `place_population` directly. The separate `place_importance` table
|
|
207
|
-
// (Wikipedia-derived) is consumed by the FST layer, not here. See
|
|
208
|
-
// docs/articles/concepts/importance-vs-population.md for the two-signal contract.
|
|
209
|
-
populationBoost: 4.0,
|
|
210
|
-
populationScaleLog10: 6,
|
|
211
|
-
// Exact name/alias match outranks partial match before the weighted sum (incl. population) is
|
|
212
|
-
// consulted — keeps population as an intra-tier prominence tiebreaker, not a cross-tier promoter.
|
|
213
|
-
// Fixes the 2-letter-region-abbrev bug ("ME" → Maine, not the more-populous Missouri).
|
|
214
|
-
exactMatchTiering: true,
|
|
215
|
-
// #936 option 3 — promoted default-ON 2026-07-03 (gate battery PASS; see the RankingWeights docstring).
|
|
216
|
-
officialNameExact: true,
|
|
217
|
-
officialNameExactFloor: 100_000,
|
|
218
|
-
}
|
|
219
|
-
|
|
220
109
|
/**
|
|
221
110
|
* Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
|
|
222
111
|
* poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
|
|
@@ -272,67 +161,6 @@ const CF_PC_DECAY_KM = 8
|
|
|
272
161
|
const CF_MISMATCH_KM = 50
|
|
273
162
|
const CF_MISMATCH_DELTA = 0.5
|
|
274
163
|
|
|
275
|
-
/** Case-fold + strip diacritics + collapse punctuation — for the coord-first soft name match. */
|
|
276
|
-
function cfNormalize(s: string): string {
|
|
277
|
-
return s
|
|
278
|
-
.toLowerCase()
|
|
279
|
-
.normalize("NFD")
|
|
280
|
-
.replace(/[\u0300-\u036f]/g, "") // combining diacritical marks
|
|
281
|
-
.replace(/[^a-z0-9]+/g, " ")
|
|
282
|
-
.trim()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** Padded character-trigram set (a leading/trailing space pads short tokens). */
|
|
286
|
-
export function trigrams(s: string): Set<string> {
|
|
287
|
-
const t = ` ${s} `
|
|
288
|
-
const out = new Set<string>()
|
|
289
|
-
|
|
290
|
-
for (let i = 0; i + 3 <= t.length; i++) {
|
|
291
|
-
out.add(t.slice(i, i + 3))
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return out
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Character-trigram Jaccard ∈ [0,1] — tolerant of the swallowed-leading-char fragments ("auen" vs "plauen") and minor
|
|
299
|
-
* misspellings without a heavyweight edit-distance pass. Shared with the candidate backend's FTS5-trigram fuzzy
|
|
300
|
-
* fallback so both lookups rank typos identically.
|
|
301
|
-
*/
|
|
302
|
-
export function trigramJaccard(a: string, b: string): number {
|
|
303
|
-
const A = trigrams(a)
|
|
304
|
-
const B = trigrams(b)
|
|
305
|
-
|
|
306
|
-
if (A.size === 0 || B.size === 0) return 0
|
|
307
|
-
let inter = 0
|
|
308
|
-
|
|
309
|
-
for (const x of A)
|
|
310
|
-
if (B.has(x)) {
|
|
311
|
-
inter++
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return inter / (A.size + B.size - inter)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Soft name-match score ∈ [0,1]: exact (normalized) name/alias → 1, else best trigram-Jaccard. */
|
|
318
|
-
function softNameScore(text: string, name: string, aliases: readonly string[]): number {
|
|
319
|
-
const q = cfNormalize(text)
|
|
320
|
-
|
|
321
|
-
if (!q) return 0
|
|
322
|
-
let best = 0
|
|
323
|
-
|
|
324
|
-
for (const raw of [name, ...aliases]) {
|
|
325
|
-
const n = cfNormalize(raw)
|
|
326
|
-
|
|
327
|
-
if (!n) continue
|
|
328
|
-
|
|
329
|
-
if (n === q) return 1
|
|
330
|
-
best = Math.max(best, trigramJaccard(q, n))
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return best
|
|
334
|
-
}
|
|
335
|
-
|
|
336
164
|
export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
337
165
|
readonly #db: DatabaseSync
|
|
338
166
|
readonly #ownsDB: boolean
|
|
@@ -362,7 +190,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
362
190
|
* override) schema names.
|
|
363
191
|
*/
|
|
364
192
|
readonly #shards: ResolvedShard[]
|
|
365
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* #920: per-schema probed country sets for country-aware shard routing (non-main shards only).
|
|
195
|
+
*/
|
|
366
196
|
readonly #shardCountries: Map<string, ReadonlySet<string>>
|
|
367
197
|
/**
|
|
368
198
|
* The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
|
|
@@ -374,13 +204,17 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
374
204
|
readonly #conventionSource: ConventionSource
|
|
375
205
|
readonly #strategies: Map<string, Strategy>
|
|
376
206
|
readonly #countryWOFIdCache = new Map<string, number | null>()
|
|
377
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* Strategy names already warned about — so an unknown name surfaces once, not once per query.
|
|
209
|
+
*/
|
|
378
210
|
readonly #warnedUnknownStrategies = new Set<string>()
|
|
379
211
|
/**
|
|
380
212
|
* Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
|
|
381
213
|
*/
|
|
382
214
|
#coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
|
|
383
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
|
|
217
|
+
*/
|
|
384
218
|
readonly #ancestorsCache = new Map<number, Ancestor[]>()
|
|
385
219
|
/**
|
|
386
220
|
* Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
|
|
@@ -416,7 +250,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
416
250
|
// ATTACH each non-main shard. Schema names were validated by resolveShards, so safe to
|
|
417
251
|
// interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
|
|
418
252
|
for (const s of shards.slice(1)) {
|
|
419
|
-
this.#db.exec(`ATTACH DATABASE '${s.path.
|
|
253
|
+
this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`)
|
|
420
254
|
}
|
|
421
255
|
}
|
|
422
256
|
|
|
@@ -432,6 +266,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
432
266
|
this.#kysely = new Kysely<WOFDatabase>({
|
|
433
267
|
dialect: new SqliteDialect({ database: this.#db }),
|
|
434
268
|
})
|
|
269
|
+
|
|
435
270
|
this.#weights = { ...DEFAULT_WEIGHTS, ...weights }
|
|
436
271
|
|
|
437
272
|
// Probe each shard's aux-table presence — driven by per-shard table existence in
|
|
@@ -443,6 +278,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
443
278
|
this.#hasBboxIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_BBOX_TABLE))
|
|
444
279
|
this.#hasPopulationIndex.set(s.schemaName, this.#shardHasTable(s.schemaName, PLACE_POPULATION_TABLE))
|
|
445
280
|
}
|
|
281
|
+
|
|
446
282
|
// #920 country-aware shard routing: probe each NON-MAIN shard's country set once at
|
|
447
283
|
// construction (they're small, purpose-built shards — postcode/locality slices; main is the
|
|
448
284
|
// multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
|
|
@@ -457,6 +293,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
457
293
|
const rows = this.#db
|
|
458
294
|
.prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
|
|
459
295
|
.all() as Array<{ country: string }>
|
|
296
|
+
|
|
460
297
|
this.#shardCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)))
|
|
461
298
|
} catch {
|
|
462
299
|
// A shard without spr (or an attach oddity) just doesn't participate in country routing.
|
|
@@ -480,6 +317,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
480
317
|
// strategy is registering it here.
|
|
481
318
|
const conventionShard =
|
|
482
319
|
this.#shards.find((s) => this.#shardHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null
|
|
320
|
+
|
|
483
321
|
this.#conventionSource = opts.conventions
|
|
484
322
|
? "get" in opts.conventions && typeof opts.conventions.get === "function"
|
|
485
323
|
? opts.conventions
|
|
@@ -487,6 +325,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
487
325
|
: conventionShard
|
|
488
326
|
? new SqliteConventionSource(this.#db, conventionShard)
|
|
489
327
|
: new SeedConventionSource()
|
|
328
|
+
|
|
490
329
|
this.#strategies = new Map<string, Strategy>([
|
|
491
330
|
["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
|
|
492
331
|
["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
|
|
@@ -501,6 +340,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
501
340
|
|
|
502
341
|
if (tableName === PLACE_POPULATION_TABLE) return placePopulationExists(this.#db)
|
|
503
342
|
}
|
|
343
|
+
|
|
504
344
|
const row = this.#db
|
|
505
345
|
.prepare(`SELECT name FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ?`)
|
|
506
346
|
.get(tableName) as { name: string } | undefined
|
|
@@ -524,17 +364,20 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
524
364
|
|
|
525
365
|
if (!strategy) {
|
|
526
366
|
this.#warnUnknownStrategy(name)
|
|
367
|
+
|
|
527
368
|
continue
|
|
528
369
|
}
|
|
370
|
+
|
|
529
371
|
const result = await strategy(query, convention)
|
|
530
372
|
|
|
531
373
|
if (result !== null) {
|
|
532
374
|
outcome = result
|
|
375
|
+
|
|
533
376
|
break
|
|
534
377
|
}
|
|
535
378
|
}
|
|
536
379
|
|
|
537
|
-
if (outcome.length
|
|
380
|
+
if (outcome.length) return outcome
|
|
538
381
|
|
|
539
382
|
// #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
|
|
540
383
|
// ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
|
|
@@ -550,13 +393,14 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
550
393
|
/^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())
|
|
551
394
|
) {
|
|
552
395
|
const trimmed = query.text.trim()
|
|
553
|
-
const joined = trimmed.
|
|
396
|
+
const joined = trimmed.replaceAll(/\s+/g, "")
|
|
554
397
|
|
|
555
398
|
if (joined !== trimmed) {
|
|
556
399
|
const full = await this.findPlace({ ...query, text: joined })
|
|
557
400
|
|
|
558
|
-
if (full.length
|
|
401
|
+
if (full.length) return full
|
|
559
402
|
}
|
|
403
|
+
|
|
560
404
|
const stem = trimmed.slice(0, 4)
|
|
561
405
|
|
|
562
406
|
if (stem !== trimmed) return this.findPlace({ ...query, text: stem })
|
|
@@ -613,6 +457,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
613
457
|
population: r.population,
|
|
614
458
|
distanceKm: r.distanceKm,
|
|
615
459
|
}
|
|
460
|
+
|
|
616
461
|
const list = map.get(r.adminID)
|
|
617
462
|
|
|
618
463
|
if (list) {
|
|
@@ -622,6 +467,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
622
467
|
}
|
|
623
468
|
}
|
|
624
469
|
}
|
|
470
|
+
|
|
625
471
|
this.#coincidentRolesCache = map
|
|
626
472
|
}
|
|
627
473
|
|
|
@@ -644,11 +490,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
644
490
|
const cached = this.#ancestorsCache.get(pid)
|
|
645
491
|
|
|
646
492
|
if (cached) return cached
|
|
493
|
+
|
|
647
494
|
const lineage: Ancestor[] = ancestorLineage(this.#db, pid).map((r) => ({
|
|
648
495
|
id: r.id,
|
|
649
496
|
placetype: r.placetype,
|
|
650
497
|
name: r.name,
|
|
651
498
|
}))
|
|
499
|
+
|
|
652
500
|
this.#ancestorsCache.set(pid, lineage)
|
|
653
501
|
|
|
654
502
|
return lineage
|
|
@@ -663,6 +511,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
663
511
|
#warnUnknownStrategy(name: string): void {
|
|
664
512
|
if (this.#warnedUnknownStrategies.has(name)) return
|
|
665
513
|
this.#warnedUnknownStrategies.add(name)
|
|
514
|
+
|
|
666
515
|
console.warn(
|
|
667
516
|
`WOFSqlitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
|
|
668
517
|
`(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
|
|
@@ -690,6 +539,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
690
539
|
*/
|
|
691
540
|
async #fuzzyNameMatch(query: FindPlaceQuery, forceShard?: ResolvedShard): Promise<PlaceCandidate[]> {
|
|
692
541
|
const limit = query.limit ?? 10
|
|
542
|
+
|
|
693
543
|
// Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
|
|
694
544
|
// region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
|
|
695
545
|
// the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
|
|
@@ -699,7 +549,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
699
549
|
// (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
|
|
700
550
|
// postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
|
|
701
551
|
// With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
|
|
702
|
-
const ftsLimit =
|
|
552
|
+
const ftsLimit =
|
|
553
|
+
query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
|
|
703
554
|
|
|
704
555
|
// Expand the placetype filter through the shared equivalence table (core/resolver): a
|
|
705
556
|
// `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
|
|
@@ -734,6 +585,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
734
585
|
for (const sh of matching) {
|
|
735
586
|
pools.push(await this.#fuzzyNameMatch(query, sh))
|
|
736
587
|
}
|
|
588
|
+
|
|
737
589
|
const byID = new Map<PlaceCandidate["id"], PlaceCandidate>()
|
|
738
590
|
|
|
739
591
|
for (const c of pools.flat()) {
|
|
@@ -741,7 +593,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
741
593
|
byID.set(c.id, c)
|
|
742
594
|
}
|
|
743
595
|
}
|
|
596
|
+
|
|
744
597
|
const merged = [...byID.values()]
|
|
598
|
+
|
|
745
599
|
merged.sort(
|
|
746
600
|
(a, b) =>
|
|
747
601
|
Number(b.exactMatch ?? false) - Number(a.exactMatch ?? false) ||
|
|
@@ -752,12 +606,14 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
752
606
|
return merged.slice(0, limit)
|
|
753
607
|
}
|
|
754
608
|
}
|
|
609
|
+
|
|
755
610
|
const shard =
|
|
756
611
|
forceShard ??
|
|
757
612
|
pickShardForPlacetype(this.#shards, firstPlacetype, {
|
|
758
613
|
country: query.country,
|
|
759
614
|
countriesBySchema: this.#shardCountries,
|
|
760
615
|
})
|
|
616
|
+
|
|
761
617
|
const sch = shard.schemaName // bare schema name; safe to interpolate (validated at construction)
|
|
762
618
|
|
|
763
619
|
// Filter out historical / superseded / deprecated places by default — they live in the same
|
|
@@ -768,7 +624,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
768
624
|
const where: string[] = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"]
|
|
769
625
|
const params: SQLInputValue[] = [ftsQuery]
|
|
770
626
|
|
|
771
|
-
if (placetypes && placetypes.length
|
|
627
|
+
if (placetypes && placetypes.length) {
|
|
772
628
|
where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`)
|
|
773
629
|
params.push(...placetypes)
|
|
774
630
|
}
|
|
@@ -793,9 +649,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
793
649
|
if (useBboxJoin) {
|
|
794
650
|
joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
|
|
795
651
|
// AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
|
|
796
|
-
const filterBox = query.bbox
|
|
797
|
-
? query.bbox
|
|
798
|
-
: bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
|
|
652
|
+
const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
|
|
799
653
|
where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
|
|
800
654
|
params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
|
|
801
655
|
}
|
|
@@ -803,9 +657,11 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
803
657
|
// LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
|
|
804
658
|
// just doesn't include the population column; the post-scoring loop treats it as 0.
|
|
805
659
|
const shardHasPopulation = this.#hasPopulationIndex.get(sch) === true
|
|
660
|
+
|
|
806
661
|
const populationSelect = shardHasPopulation
|
|
807
662
|
? `${PLACE_POPULATION_TABLE}.population AS population`
|
|
808
663
|
: `NULL AS population`
|
|
664
|
+
|
|
809
665
|
const populationJoin = shardHasPopulation
|
|
810
666
|
? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
|
|
811
667
|
: ""
|
|
@@ -853,6 +709,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
853
709
|
if (shardHasPopulation) {
|
|
854
710
|
params.push(this.#weights.populationBoost, this.#weights.populationScaleLog10)
|
|
855
711
|
}
|
|
712
|
+
|
|
856
713
|
params.push(ftsLimit)
|
|
857
714
|
|
|
858
715
|
const rawRows = stmt.all(...params) as unknown as RawSearchRow[]
|
|
@@ -883,7 +740,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
883
740
|
ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
|
|
884
741
|
LIMIT ?
|
|
885
742
|
`)
|
|
886
|
-
|
|
743
|
+
|
|
744
|
+
const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
|
|
887
745
|
const seen = new Set(rawRows.map((r) => r.id))
|
|
888
746
|
|
|
889
747
|
for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT) as unknown as RawSearchRow[]) {
|
|
@@ -894,12 +752,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
894
752
|
}
|
|
895
753
|
|
|
896
754
|
const queryLen = query.text.length
|
|
755
|
+
|
|
897
756
|
const candidates = rawRows.map((row): PlaceCandidate => {
|
|
898
757
|
// SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
|
|
899
758
|
// start from a higher-is-better baseline.
|
|
900
759
|
let score = -row.rank
|
|
901
760
|
|
|
902
|
-
if (placetypes && placetypes.length
|
|
761
|
+
if (placetypes && placetypes.length && placetypes.includes(row.placetype as WOFPlacetype)) {
|
|
903
762
|
score += this.#weights.placetypeMatchBoost
|
|
904
763
|
}
|
|
905
764
|
|
|
@@ -912,12 +771,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
912
771
|
}
|
|
913
772
|
|
|
914
773
|
if (query.parentID !== undefined) {
|
|
915
|
-
|
|
916
|
-
score += this.#weights.directChildBoost
|
|
917
|
-
} else {
|
|
918
|
-
score += this.#weights.descendantBoost
|
|
919
|
-
}
|
|
774
|
+
score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost
|
|
920
775
|
}
|
|
776
|
+
|
|
921
777
|
const extraLen = Math.max(0, row.name.length - queryLen - 3)
|
|
922
778
|
score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10
|
|
923
779
|
|
|
@@ -954,6 +810,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
954
810
|
scoreTerm = decay * this.#weights.proximityBoost
|
|
955
811
|
}
|
|
956
812
|
}
|
|
813
|
+
|
|
957
814
|
score += scoreTerm
|
|
958
815
|
}
|
|
959
816
|
|
|
@@ -967,6 +824,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
967
824
|
popTerm = this.#weights.populationBoost * popFraction
|
|
968
825
|
score += popTerm
|
|
969
826
|
}
|
|
827
|
+
|
|
970
828
|
// Combined prominence for the exact-tier sort when proximity hints are present: population
|
|
971
829
|
// and nearness in the SAME additive units, so the map view / the user's location can win a
|
|
972
830
|
// cross-country postcode tie without a hard filter.
|
|
@@ -1020,7 +878,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1020
878
|
// Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
|
|
1021
879
|
// WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
|
|
1022
880
|
// demo cascade / #369 re-rank read.
|
|
1023
|
-
if (this.#weights.exactMatchTiering && candidates.length
|
|
881
|
+
if (this.#weights.exactMatchTiering && candidates.length) {
|
|
1024
882
|
const exactIds = this.#exactMatchIds(
|
|
1025
883
|
sch,
|
|
1026
884
|
candidates.map((c) => c.id as number),
|
|
@@ -1034,7 +892,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1034
892
|
c.exactMatch = exactIds.has(c.id as number)
|
|
1035
893
|
}
|
|
1036
894
|
|
|
1037
|
-
if (exactIds.size
|
|
895
|
+
if (exactIds.size) {
|
|
1038
896
|
// #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
|
|
1039
897
|
// only breaks population ties. Exactness saturates text relevance, and the bm25
|
|
1040
898
|
// residue inside `score` is length-noise (see the fetch-site comment), so letting it
|
|
@@ -1048,8 +906,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1048
906
|
// widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
|
|
1049
907
|
// nothing, so the alias sub-tier still decides there. Population orders within each
|
|
1050
908
|
// sub-tier as before.
|
|
1051
|
-
const norm = (v: string): string => v.toLowerCase().trim().
|
|
909
|
+
const norm = (v: string): string => v.toLowerCase().trim().replaceAll(/\s+/g, " ")
|
|
1052
910
|
const needle = norm(query.text)
|
|
911
|
+
|
|
1053
912
|
// #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
|
|
1054
913
|
// country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
|
|
1055
914
|
// Turku's name, not merely its alias. Floor-gated on the holder's population (see the
|
|
@@ -1066,6 +925,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1066
925
|
query.text
|
|
1067
926
|
)
|
|
1068
927
|
: undefined
|
|
928
|
+
|
|
1069
929
|
const kind = (c: PlaceCandidate): number => {
|
|
1070
930
|
if (!exactIds.has(c.id as number)) return 0
|
|
1071
931
|
|
|
@@ -1073,11 +933,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1073
933
|
|
|
1074
934
|
return officialIds?.has(c.id as number) ? 2 : 1
|
|
1075
935
|
}
|
|
936
|
+
|
|
1076
937
|
// With proximity hints (near/bias), prominence (population + nearness, same units)
|
|
1077
938
|
// replaces raw population as the within-tier key — the 48026 rule: the map view or
|
|
1078
939
|
// the user's location breaks a cross-country postcode tie. Without hints, population
|
|
1079
940
|
// ordering is byte-identical to before.
|
|
1080
941
|
const hasHints = !!query.near || (query.bias?.length ?? 0) > 0
|
|
942
|
+
|
|
1081
943
|
candidates.sort((a, b) => {
|
|
1082
944
|
const ax = kind(a)
|
|
1083
945
|
const bx = kind(b)
|
|
@@ -1093,13 +955,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1093
955
|
return b.score - a.score
|
|
1094
956
|
})
|
|
1095
957
|
|
|
1096
|
-
return
|
|
958
|
+
return candidates.slice(0, limit)
|
|
1097
959
|
}
|
|
1098
960
|
}
|
|
1099
961
|
|
|
1100
962
|
candidates.sort((a, b) => b.score - a.score)
|
|
1101
963
|
|
|
1102
|
-
return
|
|
964
|
+
return candidates.slice(0, limit)
|
|
1103
965
|
}
|
|
1104
966
|
|
|
1105
967
|
#isLocalityQuery(query: FindPlaceQuery): boolean {
|
|
@@ -1137,16 +999,18 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1137
999
|
const cached = this.#countryWOFIdCache.get(code)
|
|
1138
1000
|
|
|
1139
1001
|
if (cached !== undefined) return cached
|
|
1140
|
-
let id: number | null
|
|
1002
|
+
let id: number | null
|
|
1141
1003
|
|
|
1142
1004
|
try {
|
|
1143
1005
|
const row = this.#db
|
|
1144
1006
|
.prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
|
|
1145
1007
|
.get(code) as { id: number } | undefined
|
|
1008
|
+
|
|
1146
1009
|
id = row?.id ?? null
|
|
1147
1010
|
} catch {
|
|
1148
1011
|
id = null
|
|
1149
1012
|
}
|
|
1013
|
+
|
|
1150
1014
|
this.#countryWOFIdCache.set(code, id)
|
|
1151
1015
|
|
|
1152
1016
|
return id
|
|
@@ -1169,6 +1033,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1169
1033
|
const pc = query.postcode!.trim()
|
|
1170
1034
|
const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
|
|
1171
1035
|
const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
|
|
1036
|
+
|
|
1172
1037
|
const pcRows = this.#db
|
|
1173
1038
|
.prepare(
|
|
1174
1039
|
`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
|
|
@@ -1176,7 +1041,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1176
1041
|
)
|
|
1177
1042
|
.all(...pcParams) as unknown as Array<{ id: number; aliases: string | null; dist: number; containing: number }>
|
|
1178
1043
|
|
|
1179
|
-
if (pcRows.length
|
|
1044
|
+
if (!pcRows.length) return null
|
|
1180
1045
|
|
|
1181
1046
|
const limit = query.limit ?? 10
|
|
1182
1047
|
// Name-match candidates via the normal FTS path (postcode cleared → no recursion).
|
|
@@ -1214,6 +1079,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1214
1079
|
for (const c of ftsCands) {
|
|
1215
1080
|
merged.set(c.id as number, c)
|
|
1216
1081
|
}
|
|
1082
|
+
|
|
1217
1083
|
const missing = [...pcInfo.keys()].filter((id) => !merged.has(id))
|
|
1218
1084
|
|
|
1219
1085
|
for (const row of this.#fetchLocalitiesByID(missing)) {
|
|
@@ -1229,14 +1095,16 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1229
1095
|
// (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
|
|
1230
1096
|
// reduces to the original `info?.aliases ?? []` and the score is unchanged.
|
|
1231
1097
|
const wofAliases = info?.aliases ?? []
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1098
|
+
|
|
1099
|
+
const aliases = postalAliasByGeo.size
|
|
1100
|
+
? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
|
|
1101
|
+
: wofAliases
|
|
1102
|
+
|
|
1236
1103
|
const sName = softNameScore(query.text, cand.name, aliases)
|
|
1237
1104
|
const sPop = cand.population && cand.population > 0 ? Math.min(1, Math.log10(1 + cand.population) / 6) : 0
|
|
1238
1105
|
scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 })
|
|
1239
1106
|
}
|
|
1107
|
+
|
|
1240
1108
|
// Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
|
|
1241
1109
|
// coordinate-only candidates, with the soft-score breaking ties WITHIN a tier. This keeps an
|
|
1242
1110
|
// unambiguous city ("Berlin", exact + huge population) ahead of the fine-grained Ortsteil its
|
|
@@ -1256,13 +1124,17 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1256
1124
|
// every locality polygon still anchor to the closest town.
|
|
1257
1125
|
const anchorRow = pcRows
|
|
1258
1126
|
.filter((r) => merged.has(r.id))
|
|
1127
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
1259
1128
|
.sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
|
|
1129
|
+
|
|
1260
1130
|
const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
|
|
1261
1131
|
|
|
1262
|
-
if (
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1132
|
+
if (
|
|
1133
|
+
anchor &&
|
|
1134
|
+
(top.id as number) !== anchorRow!.id &&
|
|
1135
|
+
haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
|
|
1136
|
+
) {
|
|
1137
|
+
top.mismatch = true
|
|
1266
1138
|
}
|
|
1267
1139
|
}
|
|
1268
1140
|
|
|
@@ -1273,13 +1145,16 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1273
1145
|
})
|
|
1274
1146
|
}
|
|
1275
1147
|
|
|
1276
|
-
/**
|
|
1148
|
+
/**
|
|
1149
|
+
* Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
|
|
1150
|
+
*/
|
|
1277
1151
|
#fetchLocalitiesByID(ids: number[]): PlaceCandidate[] {
|
|
1278
|
-
if (ids.length
|
|
1152
|
+
if (!ids.length) return []
|
|
1279
1153
|
const hasPop = this.#hasPopulationIndex.get("main") === true
|
|
1280
1154
|
const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`
|
|
1281
1155
|
const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
|
|
1282
1156
|
const ph = ids.map(() => "?").join(", ")
|
|
1157
|
+
|
|
1283
1158
|
const rows = this.#db
|
|
1284
1159
|
.prepare(
|
|
1285
1160
|
`SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
|
|
@@ -1320,7 +1195,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1320
1195
|
const out = new Set<number>()
|
|
1321
1196
|
const trimmed = text.trim()
|
|
1322
1197
|
|
|
1323
|
-
if (ids.length
|
|
1198
|
+
if (!ids.length || !trimmed) return out
|
|
1324
1199
|
const placeholders = ids.map(() => "?").join(", ")
|
|
1325
1200
|
|
|
1326
1201
|
try {
|
|
@@ -1345,7 +1220,8 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1345
1220
|
`SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`
|
|
1346
1221
|
)
|
|
1347
1222
|
.all(...ids) as Array<{ id: number; name: string | null; alt_names: string | null }>
|
|
1348
|
-
|
|
1223
|
+
|
|
1224
|
+
const norm = (s: string): string => s.toLowerCase().trim().replaceAll(/\s+/g, " ")
|
|
1349
1225
|
const needle = norm(trimmed)
|
|
1350
1226
|
|
|
1351
1227
|
for (const r of rows) {
|
|
@@ -1353,6 +1229,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1353
1229
|
out.add(r.id)
|
|
1354
1230
|
}
|
|
1355
1231
|
}
|
|
1232
|
+
|
|
1356
1233
|
// Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
|
|
1357
1234
|
// per-alias equality check, ungated — matching the `names`-table branch above, where an
|
|
1358
1235
|
// alias match counts as exact regardless of other candidates. Legacy bags (no separator)
|
|
@@ -1383,7 +1260,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1383
1260
|
const out = new Set<number>()
|
|
1384
1261
|
const trimmed = text.trim()
|
|
1385
1262
|
|
|
1386
|
-
if (ids.length
|
|
1263
|
+
if (!ids.length || !trimmed) return out
|
|
1387
1264
|
const placeholders = ids.map(() => "?").join(", ")
|
|
1388
1265
|
|
|
1389
1266
|
try {
|
|
@@ -1417,7 +1294,9 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1417
1294
|
this.close()
|
|
1418
1295
|
}
|
|
1419
1296
|
|
|
1420
|
-
/**
|
|
1297
|
+
/**
|
|
1298
|
+
* Build the FTS5 virtual table from the `names` + `places` tables.
|
|
1299
|
+
*/
|
|
1421
1300
|
#ensureFTS(): void {
|
|
1422
1301
|
buildPlaceSearchFTS(this.#db)
|
|
1423
1302
|
}
|
|
@@ -1431,74 +1310,6 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1431
1310
|
}
|
|
1432
1311
|
}
|
|
1433
1312
|
|
|
1434
|
-
|
|
1435
|
-
if (!p) return null
|
|
1436
|
-
|
|
1437
|
-
return Array.isArray(p) ? p : [p]
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
/**
|
|
1441
|
-
* Make an arbitrary user-typed string safe for FTS5 MATCH.
|
|
1442
|
-
*
|
|
1443
|
-
* FTS5 has its own query syntax (`"phrase"`, `term1 OR term2`, `prefix*`, NEAR/N, etc.). Letting raw user input through
|
|
1444
|
-
* means a user typing `Paris's` or `St. (Petersburg)` causes a syntax error.
|
|
1445
|
-
*
|
|
1446
|
-
* Per-token rules:
|
|
1447
|
-
*
|
|
1448
|
-
* - Strip all punctuation except trailing `*` from each whitespace-separated token.
|
|
1449
|
-
* - **Trailing `*`** is preserved as FTS5 **prefix syntax** — `627*` becomes the literal `627*` (unquoted). The caller
|
|
1450
|
-
* signaled they want a prefix; respect that.
|
|
1451
|
-
* - All other tokens are wrapped in `"..."` as a single-word phrase. Conservative — handles apostrophes, parens, accented
|
|
1452
|
-
* input, etc. safely.
|
|
1453
|
-
* - Multiple tokens join with implicit AND.
|
|
1454
|
-
*
|
|
1455
|
-
* Examples:
|
|
1456
|
-
*
|
|
1457
|
-
* - `"Paris"` → `"Paris"` (phrase)
|
|
1458
|
-
* - `"627*"` → `627*` (prefix)
|
|
1459
|
-
* - `"St. (Petersburg)"` → `"St" "Petersburg"` (two phrases, AND-joined)
|
|
1460
|
-
* - `"Thiron-Gardais"` → `"Thiron" "Gardais"` (intra-token punctuation SPLITS — #945; fusing to `ThironGardais` matched
|
|
1461
|
-
* nothing because the FTS doc tokenizes the hyphenated name as two terms)
|
|
1462
|
-
* - `"110 00"` with `fuseTokens` (postcode-typed) → `"110" "00"` per-token fused — the #920 name law
|
|
1463
|
-
* - `"Pari* TX"` → `Pari* "TX"` (mixed prefix + phrase)
|
|
1464
|
-
* - `"*"` alone → `""` (no body → drop)
|
|
1465
|
-
*/
|
|
1466
|
-
function sanitizeFTSQuery(text: string, opts?: { fuseTokens?: boolean }): string {
|
|
1467
|
-
const out: string[] = []
|
|
1468
|
-
|
|
1469
|
-
for (const rawToken of text.normalize("NFKC").split(/\s+/u)) {
|
|
1470
|
-
const trimmed = rawToken.trim()
|
|
1471
|
-
|
|
1472
|
-
if (!trimmed) continue
|
|
1473
|
-
const hasPrefixStar = trimmed.endsWith("*")
|
|
1474
|
-
|
|
1475
|
-
// #920 name law (postcode-typed queries ONLY): delete intra-token punctuation and FUSE the
|
|
1476
|
-
// remainder — postal names are stored in this collapsed shape ("SW1A" stays one term).
|
|
1477
|
-
if (opts?.fuseTokens) {
|
|
1478
|
-
const body = trimmed.replace(/[^\p{L}\p{N}]/gu, "")
|
|
1479
|
-
|
|
1480
|
-
if (!body) continue
|
|
1481
|
-
out.push(hasPrefixStar ? `${body}*` : `"${body.replace(/"/g, '""')}"`)
|
|
1482
|
-
continue
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// Everything else SPLITS on intra-token punctuation — the behavior the docstring always
|
|
1486
|
-
// promised ("St. (Petersburg)" → two phrases). The old code DELETED punctuation instead,
|
|
1487
|
-
// fusing "Thiron-Gardais" into the unmatchable single term `ThironGardais` while the FTS
|
|
1488
|
-
// doc holds two terms (#945 — the entire hyphenated-name class missed at the raw lookup;
|
|
1489
|
-
// masked for years because pre-splice tokenizers never emitted hyphen-preserved values).
|
|
1490
|
-
const parts = trimmed.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
1491
|
-
|
|
1492
|
-
if (parts.length === 0) continue
|
|
1493
|
-
|
|
1494
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1495
|
-
const body = parts[i]!.replace(/\*/g, "")
|
|
1313
|
+
export { trigramJaccard, trigrams } from "./name-score.ts"
|
|
1496
1314
|
|
|
1497
|
-
|
|
1498
|
-
// The caller's trailing `*` applies to the FINAL part ("Thiron-Gard*" → "Thiron" Gard*).
|
|
1499
|
-
out.push(hasPrefixStar && i === parts.length - 1 ? `${body}*` : `"${body.replace(/"/g, '""')}"`)
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
return out.join(" ")
|
|
1504
|
-
}
|
|
1315
|
+
export type { RankingWeights } from "./ranking-weights.ts"
|