@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/out/lookup.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
13
13
|
import { type Ancestor, type CoincidentLocality } from "@mailwoman/resolver";
|
|
14
14
|
import { type Convention, type ConventionSource } from "./convention.ts";
|
|
15
15
|
import type { WOFPostalCityAliasLookup } from "./postal-city-alias-lookup.ts";
|
|
16
|
+
import { type RankingWeights } from "./ranking-weights.ts";
|
|
16
17
|
import { type ShardConfig } from "./sharding.ts";
|
|
17
18
|
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup } from "./types.ts";
|
|
18
19
|
export interface WOFSqlitePlaceLookupOpts {
|
|
@@ -61,103 +62,6 @@ export interface WOFSqlitePlaceLookupOpts {
|
|
|
61
62
|
*/
|
|
62
63
|
postalCityAliases?: WOFPostalCityAliasLookup;
|
|
63
64
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Ranking weights for `findPlace`. Tweakable per-instance but defaults match the values declared in the Phase 4.2 plan
|
|
66
|
-
* doc.
|
|
67
|
-
*/
|
|
68
|
-
export interface RankingWeights {
|
|
69
|
-
/** Boost when the candidate's placetype matches an explicit `placetype` filter. */
|
|
70
|
-
placetypeMatchBoost: number;
|
|
71
|
-
/** Boost when the candidate is a locality and no explicit placetype was requested. */
|
|
72
|
-
localityImplicitBoost: number;
|
|
73
|
-
/** Boost when the candidate's country matches an explicit `country` filter. */
|
|
74
|
-
countryMatchBoost: number;
|
|
75
|
-
/** Boost when the candidate is a direct child of the requested `parentID`. */
|
|
76
|
-
directChildBoost: number;
|
|
77
|
-
/** Boost when the candidate is a transitive descendant of the requested `parentID`. */
|
|
78
|
-
descendantBoost: number;
|
|
79
|
-
/** Multiplier on the length-penalty term (penalizes much-longer-than-query names). */
|
|
80
|
-
lengthPenaltyWeight: number;
|
|
81
|
-
/**
|
|
82
|
-
* Magnitude of the proximity boost when the query carries `near`. The contribution is `proximityBoost / (1 +
|
|
83
|
-
* distanceKm / proximityScaleKm)` — at distance 0 the boost is full magnitude, at `proximityScaleKm` it's half,
|
|
84
|
-
* decaying further with distance. Default tuned so proximity can overcome a typical FTS rank tie but not dominate a
|
|
85
|
-
* strong text match.
|
|
86
|
-
*/
|
|
87
|
-
proximityBoost: number;
|
|
88
|
-
/**
|
|
89
|
-
* Magnitude of the bias-hint term inside the exact-tier PROMINENCE sort (the `bias`/viewport path). Deliberately
|
|
90
|
-
* population-scale (default = populationBoost) so a candidate near the map view / the user beats a distant-but-bigger
|
|
91
|
-
* namesake — "the map view wins" is the feature; same-region ties (all candidates far from every hint) still fall to
|
|
92
|
-
* population.
|
|
93
|
-
*/
|
|
94
|
-
biasBoost: number;
|
|
95
|
-
/** Distance (km) at which the proximity boost halves. Tune to the typical query radius. */
|
|
96
|
-
proximityScaleKm: number;
|
|
97
|
-
/**
|
|
98
|
-
* Magnitude of the population boost when the candidate has a known `wof:population`. The contribution is
|
|
99
|
-
* `populationBoost * log10(1 + population) / populationScaleLog10`, capped at `populationBoost`. WOF only carries
|
|
100
|
-
* population for ~15% of localities (mostly larger ones); places without it get +0 (never a penalty). Default tuned
|
|
101
|
-
* so the famous Springfield, IL (pop ~112k) gets ~0.42 boost — enough to nudge past tiny same-name peers.
|
|
102
|
-
*/
|
|
103
|
-
populationBoost: number;
|
|
104
|
-
/**
|
|
105
|
-
* Population (in log10) at which the boost reaches its full magnitude. Default 6 — i.e. a population of 1,000,000
|
|
106
|
-
* gives `populationBoost` exactly. Larger populations cap at the same value (no compounding effect for megacities).
|
|
107
|
-
*/
|
|
108
|
-
populationScaleLog10: number;
|
|
109
|
-
/**
|
|
110
|
-
* Tier candidates with an EXACT name/alias match above candidates that only match partially, BEFORE the weighted-sum
|
|
111
|
-
* score is consulted. Default true.
|
|
112
|
-
*
|
|
113
|
-
* Why this is needed (and why it ALIGNS with — rather than overrides — the population/importance signal): the
|
|
114
|
-
* weighted sum adds population as a large additive boost (`populationBoost`, up to +4) so that famous places surface
|
|
115
|
-
* for unambiguous full-name queries. But population is a _prominence prior_ — its job is to break ties among
|
|
116
|
-
* candidates that match the query EQUALLY WELL (e.g. "Springfield" → Springfield IL over Springfield MA, both exact
|
|
117
|
-
* name matches). It was never meant to promote a place that matches the query WORSE. For a 2-letter region
|
|
118
|
-
* abbreviation that backfires: querying "ME" returns Maine (which has the exact alias `ME`) AND Missouri/
|
|
119
|
-
* Michigan/etc. (which do not), and Missouri's larger population (+4) overcomes Maine's bm25 edge — so "Portland, ME"
|
|
120
|
-
* resolves its region to Missouri and the locality then cascades to the wrong state. Tiering restores the intended
|
|
121
|
-
* ordering: **match quality is the primary key, prominence (population) the secondary key WITHIN a tier.**
|
|
122
|
-
* Springfield-IL-over-MA still works (both exact → same tier → population decides); ME→Maine now works (only Maine is
|
|
123
|
-
* exact → higher tier → population never gets to override it). See
|
|
124
|
-
* docs/articles/evals/resolver-geo/2026-05-30-resolver-exact-match.md.
|
|
125
|
-
*
|
|
126
|
-
* Note: tiering re-ranks within the over-fetched candidate window (`limit * 4`); a pathological exact match that
|
|
127
|
-
* falls outside that window is not rescued. For the region-abbrev case the window is comfortably sufficient (a
|
|
128
|
-
* handful of states match a 2-letter query).
|
|
129
|
-
*/
|
|
130
|
-
exactMatchTiering: boolean;
|
|
131
|
-
/**
|
|
132
|
-
* #936 option 3 — official-language names ARE names. When true, a candidate holding the query as an OFFICIAL name
|
|
133
|
-
* (`names.official = 1`: a preferred-form name in an official language of its country, stamped at ingest) joins the
|
|
134
|
-
* NAME-exact sub-tier rather than the alias-exact one, provided its population clears {@link officialNameExactFloor}.
|
|
135
|
-
* Fixes unscoped "Åbo" → Turku (its official Swedish name) over a hamlet literally named Åbo; population still orders
|
|
136
|
-
* within the sub-tier, so Paris → Paris FR is untouched.
|
|
137
|
-
*
|
|
138
|
-
* Default true (operator-promoted 2026-07-03 after the pre-registered gate battery: four intended exonym flips —
|
|
139
|
-
* Berne→Bern, Bruges→Brugge, Roma→Rome, Åbo→Turku — with the namesake/abbreviation rows and the US/FI panels
|
|
140
|
-
* byte-identical). Requires a gazetteer carrying the #940 ingest bit — on older DBs without the `official` column the
|
|
141
|
-
* probe fails soft and behavior is identical to the flag being off.
|
|
142
|
-
*/
|
|
143
|
-
officialNameExact: boolean;
|
|
144
|
-
/**
|
|
145
|
-
* Minimum population for a candidate's official names to join the name-exact sub-tier. The #936 review's no-floor
|
|
146
|
-
* census measured the boundary: ≥100k holders are the famous-exonym class (757 flips, intent-correct; 7 collisions,
|
|
147
|
-
* none harmful) while 10k–100k holders are junk-dominated (3,481 flips led by short-form mis-tags — Villeneuve-Loubet
|
|
148
|
-
* carrying "villeneuve" would bury five real villages of that name). Rank-time knob: tunable without re-ingest;
|
|
149
|
-
* below-floor official names simply stay alias-tier (today's behavior).
|
|
150
|
-
*/
|
|
151
|
-
officialNameExactFloor: number;
|
|
152
|
-
}
|
|
153
|
-
/** Padded character-trigram set (a leading/trailing space pads short tokens). */
|
|
154
|
-
export declare function trigrams(s: string): Set<string>;
|
|
155
|
-
/**
|
|
156
|
-
* Character-trigram Jaccard ∈ [0,1] — tolerant of the swallowed-leading-char fragments ("auen" vs "plauen") and minor
|
|
157
|
-
* misspellings without a heavyweight edit-distance pass. Shared with the candidate backend's FTS5-trigram fuzzy
|
|
158
|
-
* fallback so both lookups rank typos identically.
|
|
159
|
-
*/
|
|
160
|
-
export declare function trigramJaccard(a: string, b: string): number;
|
|
161
65
|
export declare class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
162
66
|
#private;
|
|
163
67
|
constructor(opts: WOFSqlitePlaceLookupOpts, weights?: Partial<RankingWeights>);
|
|
@@ -182,4 +86,6 @@ export declare class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
182
86
|
close(): void;
|
|
183
87
|
[Symbol.dispose](): void;
|
|
184
88
|
}
|
|
89
|
+
export { trigramJaccard, trigrams } from "./name-score.ts";
|
|
90
|
+
export type { RankingWeights } from "./ranking-weights.ts";
|
|
185
91
|
//# sourceMappingURL=lookup.d.ts.map
|
package/out/lookup.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../lookup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAA;AAG9D,OAAO,EAAyB,KAAK,QAAQ,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAKnG,OAAO,EAIN,KAAK,UAAU,EACf,KAAK,gBAAgB,EAGrB,MAAM,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../lookup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAA;AAG9D,OAAO,EAAyB,KAAK,QAAQ,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAKnG,OAAO,EAIN,KAAK,UAAU,EACf,KAAK,gBAAgB,EAGrB,MAAM,iBAAiB,CAAA;AAaxB,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;AAC7E,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE3E,OAAO,EAKN,KAAK,WAAW,EAChB,MAAM,eAAe,CAAA;AAEtB,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,WAAW,EAAgB,MAAM,YAAY,CAAA;AAS3F,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,GAAG,WAAW,CAAC,CAAA;IAC3D;;;OAGG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAA;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IAC3D;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,wBAAwB,CAAA;CAC5C;AAyDD,qBAAa,oBAAqB,YAAW,WAAW,EAAE,UAAU;;gBA6DvD,IAAI,EAAE,wBAAwB,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IA8HvE,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IA6DjE;;;;;OAKG;IACH,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,kBAAkB,EAAE;IA2DvE;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,EAAE;IA6xB1C,KAAK,IAAI,IAAI;IAUb,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAkBxB;AAED,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAE1D,YAAY,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA"}
|
package/out/lookup.js
CHANGED
|
@@ -16,40 +16,19 @@ import { Kysely } from "kysely";
|
|
|
16
16
|
import { ancestorLineage } from "./ancestry.js";
|
|
17
17
|
import { COINCIDENT_ROLES_TABLE, coincidentRolesExists } from "./coincident-roles.js";
|
|
18
18
|
import { ADDRESS_CONVENTION_TABLE, resolveConvention, SeedConventionSource, } from "./convention.js";
|
|
19
|
+
import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.js";
|
|
19
20
|
import { aliasBagExactMatch, buildPlaceSearchFTS, PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE, placeBboxExists, placePopulationExists, placeSearchFTSExists, } from "./fts.js";
|
|
20
21
|
import { bboxAround, haversineKm } from "./geo.js";
|
|
22
|
+
import { cfNormalize, softNameScore, trigramJaccard } from "./name-score.js";
|
|
23
|
+
import { DEFAULT_WEIGHTS } from "./ranking-weights.js";
|
|
21
24
|
import { pickShardForPlacetype, pickShardsForPlacetype, resolveShards, } from "./sharding.js";
|
|
22
25
|
import { SqliteConventionSource } from "./sqlite-convention-source.js";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
lengthPenaltyWeight: 0.1,
|
|
30
|
-
proximityBoost: 0.8,
|
|
31
|
-
proximityScaleKm: 100,
|
|
32
|
-
biasBoost: 4.0,
|
|
33
|
-
// populationBoost is intentionally large — empirical tuning against real WOF showed BM25 gaps
|
|
34
|
-
// of 1.5-3.0 between famous places and tiny same-name peers (because the famous ones have
|
|
35
|
-
// hundreds of alt-name entries that hurt their FTS document score). To consistently surface
|
|
36
|
-
// "the famous one" for unambiguous queries like "New York" or "Chicago", the population signal
|
|
37
|
-
// needs to dominate. Callers wanting a more conservative balance can drop this in the
|
|
38
|
-
// RankingWeights override.
|
|
39
|
-
//
|
|
40
|
-
// Note: this resolver uses `place_population` directly. The separate `place_importance` table
|
|
41
|
-
// (Wikipedia-derived) is consumed by the FST layer, not here. See
|
|
42
|
-
// docs/articles/concepts/importance-vs-population.md for the two-signal contract.
|
|
43
|
-
populationBoost: 4.0,
|
|
44
|
-
populationScaleLog10: 6,
|
|
45
|
-
// Exact name/alias match outranks partial match before the weighted sum (incl. population) is
|
|
46
|
-
// consulted — keeps population as an intra-tier prominence tiebreaker, not a cross-tier promoter.
|
|
47
|
-
// Fixes the 2-letter-region-abbrev bug ("ME" → Maine, not the more-populous Missouri).
|
|
48
|
-
exactMatchTiering: true,
|
|
49
|
-
// #936 option 3 — promoted default-ON 2026-07-03 (gate battery PASS; see the RankingWeights docstring).
|
|
50
|
-
officialNameExact: true,
|
|
51
|
-
officialNameExactFloor: 100_000,
|
|
52
|
-
};
|
|
26
|
+
/**
|
|
27
|
+
* Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
|
|
28
|
+
* abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
|
|
29
|
+
* losing to "New York".
|
|
30
|
+
*/
|
|
31
|
+
const SHORT_QUERY_MAX_LENGTH = 3;
|
|
53
32
|
/**
|
|
54
33
|
* Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
|
|
55
34
|
* poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
|
|
@@ -85,57 +64,6 @@ const CF_PC_DECAY_KM = 8;
|
|
|
85
64
|
*/
|
|
86
65
|
const CF_MISMATCH_KM = 50;
|
|
87
66
|
const CF_MISMATCH_DELTA = 0.5;
|
|
88
|
-
/** Case-fold + strip diacritics + collapse punctuation — for the coord-first soft name match. */
|
|
89
|
-
function cfNormalize(s) {
|
|
90
|
-
return s
|
|
91
|
-
.toLowerCase()
|
|
92
|
-
.normalize("NFD")
|
|
93
|
-
.replace(/[\u0300-\u036f]/g, "") // combining diacritical marks
|
|
94
|
-
.replace(/[^a-z0-9]+/g, " ")
|
|
95
|
-
.trim();
|
|
96
|
-
}
|
|
97
|
-
/** Padded character-trigram set (a leading/trailing space pads short tokens). */
|
|
98
|
-
export function trigrams(s) {
|
|
99
|
-
const t = ` ${s} `;
|
|
100
|
-
const out = new Set();
|
|
101
|
-
for (let i = 0; i + 3 <= t.length; i++) {
|
|
102
|
-
out.add(t.slice(i, i + 3));
|
|
103
|
-
}
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Character-trigram Jaccard ∈ [0,1] — tolerant of the swallowed-leading-char fragments ("auen" vs "plauen") and minor
|
|
108
|
-
* misspellings without a heavyweight edit-distance pass. Shared with the candidate backend's FTS5-trigram fuzzy
|
|
109
|
-
* fallback so both lookups rank typos identically.
|
|
110
|
-
*/
|
|
111
|
-
export function trigramJaccard(a, b) {
|
|
112
|
-
const A = trigrams(a);
|
|
113
|
-
const B = trigrams(b);
|
|
114
|
-
if (A.size === 0 || B.size === 0)
|
|
115
|
-
return 0;
|
|
116
|
-
let inter = 0;
|
|
117
|
-
for (const x of A)
|
|
118
|
-
if (B.has(x)) {
|
|
119
|
-
inter++;
|
|
120
|
-
}
|
|
121
|
-
return inter / (A.size + B.size - inter);
|
|
122
|
-
}
|
|
123
|
-
/** Soft name-match score ∈ [0,1]: exact (normalized) name/alias → 1, else best trigram-Jaccard. */
|
|
124
|
-
function softNameScore(text, name, aliases) {
|
|
125
|
-
const q = cfNormalize(text);
|
|
126
|
-
if (!q)
|
|
127
|
-
return 0;
|
|
128
|
-
let best = 0;
|
|
129
|
-
for (const raw of [name, ...aliases]) {
|
|
130
|
-
const n = cfNormalize(raw);
|
|
131
|
-
if (!n)
|
|
132
|
-
continue;
|
|
133
|
-
if (n === q)
|
|
134
|
-
return 1;
|
|
135
|
-
best = Math.max(best, trigramJaccard(q, n));
|
|
136
|
-
}
|
|
137
|
-
return best;
|
|
138
|
-
}
|
|
139
67
|
export class WOFSqlitePlaceLookup {
|
|
140
68
|
#db;
|
|
141
69
|
#ownsDB;
|
|
@@ -165,7 +93,9 @@ export class WOFSqlitePlaceLookup {
|
|
|
165
93
|
* override) schema names.
|
|
166
94
|
*/
|
|
167
95
|
#shards;
|
|
168
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* #920: per-schema probed country sets for country-aware shard routing (non-main shards only).
|
|
98
|
+
*/
|
|
169
99
|
#shardCountries;
|
|
170
100
|
/**
|
|
171
101
|
* The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
|
|
@@ -177,13 +107,17 @@ export class WOFSqlitePlaceLookup {
|
|
|
177
107
|
#conventionSource;
|
|
178
108
|
#strategies;
|
|
179
109
|
#countryWOFIdCache = new Map();
|
|
180
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Strategy names already warned about — so an unknown name surfaces once, not once per query.
|
|
112
|
+
*/
|
|
181
113
|
#warnedUnknownStrategies = new Set();
|
|
182
114
|
/**
|
|
183
115
|
* Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
|
|
184
116
|
*/
|
|
185
117
|
#coincidentRolesCache = null;
|
|
186
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
|
|
120
|
+
*/
|
|
187
121
|
#ancestorsCache = new Map();
|
|
188
122
|
/**
|
|
189
123
|
* Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
|
|
@@ -216,7 +150,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
216
150
|
// ATTACH each non-main shard. Schema names were validated by resolveShards, so safe to
|
|
217
151
|
// interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
|
|
218
152
|
for (const s of shards.slice(1)) {
|
|
219
|
-
this.#db.exec(`ATTACH DATABASE '${s.path.
|
|
153
|
+
this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`);
|
|
220
154
|
}
|
|
221
155
|
}
|
|
222
156
|
// node:sqlite has no .pragma() helper; pragmas are executed as plain SQL.
|
|
@@ -319,7 +253,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
319
253
|
break;
|
|
320
254
|
}
|
|
321
255
|
}
|
|
322
|
-
if (outcome.length
|
|
256
|
+
if (outcome.length)
|
|
323
257
|
return outcome;
|
|
324
258
|
// #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
|
|
325
259
|
// ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
|
|
@@ -333,10 +267,10 @@ export class WOFSqlitePlaceLookup {
|
|
|
333
267
|
(normalizePlacetypes(query.placetype)?.includes("postalcode") ?? false) &&
|
|
334
268
|
/^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())) {
|
|
335
269
|
const trimmed = query.text.trim();
|
|
336
|
-
const joined = trimmed.
|
|
270
|
+
const joined = trimmed.replaceAll(/\s+/g, "");
|
|
337
271
|
if (joined !== trimmed) {
|
|
338
272
|
const full = await this.findPlace({ ...query, text: joined });
|
|
339
|
-
if (full.length
|
|
273
|
+
if (full.length)
|
|
340
274
|
return full;
|
|
341
275
|
}
|
|
342
276
|
const stem = trimmed.slice(0, 4);
|
|
@@ -456,7 +390,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
456
390
|
// (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
|
|
457
391
|
// postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
|
|
458
392
|
// With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
|
|
459
|
-
const ftsLimit = query.text.trim().length <=
|
|
393
|
+
const ftsLimit = query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4;
|
|
460
394
|
// Expand the placetype filter through the shared equivalence table (core/resolver): a
|
|
461
395
|
// `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
|
|
462
396
|
// (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
|
|
@@ -511,7 +445,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
511
445
|
// the FROM table — required by FTS5 parser, see sharding.ts header comment.
|
|
512
446
|
const where = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"];
|
|
513
447
|
const params = [ftsQuery];
|
|
514
|
-
if (placetypes && placetypes.length
|
|
448
|
+
if (placetypes && placetypes.length) {
|
|
515
449
|
where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`);
|
|
516
450
|
params.push(...placetypes);
|
|
517
451
|
}
|
|
@@ -532,9 +466,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
532
466
|
if (useBboxJoin) {
|
|
533
467
|
joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`;
|
|
534
468
|
// AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
|
|
535
|
-
const filterBox = query.bbox
|
|
536
|
-
? query.bbox
|
|
537
|
-
: bboxAround(query.near.lat, query.near.lon, query.near.maxDistanceKm);
|
|
469
|
+
const filterBox = query.bbox || bboxAround(query.near.lat, query.near.lon, query.near.maxDistanceKm);
|
|
538
470
|
where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?");
|
|
539
471
|
params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon);
|
|
540
472
|
}
|
|
@@ -616,7 +548,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
616
548
|
ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
|
|
617
549
|
LIMIT ?
|
|
618
550
|
`);
|
|
619
|
-
const popParams = params.slice(0,
|
|
551
|
+
const popParams = params.slice(0, -3); // drop the two boost params + ftsLimit
|
|
620
552
|
const seen = new Set(rawRows.map((r) => r.id));
|
|
621
553
|
for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT)) {
|
|
622
554
|
if (!seen.has(row.id)) {
|
|
@@ -629,7 +561,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
629
561
|
// SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
|
|
630
562
|
// start from a higher-is-better baseline.
|
|
631
563
|
let score = -row.rank;
|
|
632
|
-
if (placetypes && placetypes.length
|
|
564
|
+
if (placetypes && placetypes.length && placetypes.includes(row.placetype)) {
|
|
633
565
|
score += this.#weights.placetypeMatchBoost;
|
|
634
566
|
}
|
|
635
567
|
if (!placetypes && row.placetype === "locality") {
|
|
@@ -639,12 +571,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
639
571
|
score += this.#weights.countryMatchBoost;
|
|
640
572
|
}
|
|
641
573
|
if (query.parentID !== undefined) {
|
|
642
|
-
|
|
643
|
-
score += this.#weights.directChildBoost;
|
|
644
|
-
}
|
|
645
|
-
else {
|
|
646
|
-
score += this.#weights.descendantBoost;
|
|
647
|
-
}
|
|
574
|
+
score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost;
|
|
648
575
|
}
|
|
649
576
|
const extraLen = Math.max(0, row.name.length - queryLen - 3);
|
|
650
577
|
score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10;
|
|
@@ -731,7 +658,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
731
658
|
// Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
|
|
732
659
|
// WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
|
|
733
660
|
// demo cascade / #369 re-rank read.
|
|
734
|
-
if (this.#weights.exactMatchTiering && candidates.length
|
|
661
|
+
if (this.#weights.exactMatchTiering && candidates.length) {
|
|
735
662
|
const exactIds = this.#exactMatchIds(sch, candidates.map((c) => c.id), query.text);
|
|
736
663
|
// Stamp the tier onto every candidate (not just when the tiering sort fires) so a downstream
|
|
737
664
|
// re-rank — #369's postcode-anchor country pin in `resolveTree` — can keep the country pin from
|
|
@@ -739,7 +666,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
739
666
|
for (const c of candidates) {
|
|
740
667
|
c.exactMatch = exactIds.has(c.id);
|
|
741
668
|
}
|
|
742
|
-
if (exactIds.size
|
|
669
|
+
if (exactIds.size) {
|
|
743
670
|
// #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
|
|
744
671
|
// only breaks population ties. Exactness saturates text relevance, and the bm25
|
|
745
672
|
// residue inside `score` is length-noise (see the fetch-site comment), so letting it
|
|
@@ -753,7 +680,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
753
680
|
// widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
|
|
754
681
|
// nothing, so the alias sub-tier still decides there. Population orders within each
|
|
755
682
|
// sub-tier as before.
|
|
756
|
-
const norm = (v) => v.toLowerCase().trim().
|
|
683
|
+
const norm = (v) => v.toLowerCase().trim().replaceAll(/\s+/g, " ");
|
|
757
684
|
const needle = norm(query.text);
|
|
758
685
|
// #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
|
|
759
686
|
// country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
|
|
@@ -789,11 +716,11 @@ export class WOFSqlitePlaceLookup {
|
|
|
789
716
|
}
|
|
790
717
|
return b.score - a.score;
|
|
791
718
|
});
|
|
792
|
-
return
|
|
719
|
+
return candidates.slice(0, limit);
|
|
793
720
|
}
|
|
794
721
|
}
|
|
795
722
|
candidates.sort((a, b) => b.score - a.score);
|
|
796
|
-
return
|
|
723
|
+
return candidates.slice(0, limit);
|
|
797
724
|
}
|
|
798
725
|
#isLocalityQuery(query) {
|
|
799
726
|
const pts = normalizePlacetypes(query.placetype);
|
|
@@ -824,7 +751,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
824
751
|
const cached = this.#countryWOFIdCache.get(code);
|
|
825
752
|
if (cached !== undefined)
|
|
826
753
|
return cached;
|
|
827
|
-
let id
|
|
754
|
+
let id;
|
|
828
755
|
try {
|
|
829
756
|
const row = this.#db
|
|
830
757
|
.prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
|
|
@@ -854,7 +781,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
854
781
|
.prepare(`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
|
|
855
782
|
FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`)
|
|
856
783
|
.all(...pcParams);
|
|
857
|
-
if (pcRows.length
|
|
784
|
+
if (!pcRows.length)
|
|
858
785
|
return null;
|
|
859
786
|
const limit = query.limit ?? 10;
|
|
860
787
|
// Name-match candidates via the normal FTS path (postcode cleared → no recursion).
|
|
@@ -898,7 +825,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
898
825
|
// (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
|
|
899
826
|
// reduces to the original `info?.aliases ?? []` and the score is unchanged.
|
|
900
827
|
const wofAliases = info?.aliases ?? [];
|
|
901
|
-
const aliases = postalAliasByGeo.size
|
|
828
|
+
const aliases = postalAliasByGeo.size
|
|
902
829
|
? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
|
|
903
830
|
: wofAliases;
|
|
904
831
|
const sName = softNameScore(query.text, cand.name, aliases);
|
|
@@ -922,12 +849,13 @@ export class WOFSqlitePlaceLookup {
|
|
|
922
849
|
// every locality polygon still anchor to the closest town.
|
|
923
850
|
const anchorRow = pcRows
|
|
924
851
|
.filter((r) => merged.has(r.id))
|
|
852
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
925
853
|
.sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0];
|
|
926
854
|
const anchor = anchorRow ? merged.get(anchorRow.id) : undefined;
|
|
927
|
-
if (anchor &&
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
855
|
+
if (anchor &&
|
|
856
|
+
top.id !== anchorRow.id &&
|
|
857
|
+
haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM) {
|
|
858
|
+
top.mismatch = true;
|
|
931
859
|
}
|
|
932
860
|
}
|
|
933
861
|
return scored.slice(0, limit).map(({ exact, ...c }) => {
|
|
@@ -935,9 +863,11 @@ export class WOFSqlitePlaceLookup {
|
|
|
935
863
|
return c;
|
|
936
864
|
});
|
|
937
865
|
}
|
|
938
|
-
/**
|
|
866
|
+
/**
|
|
867
|
+
* Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
|
|
868
|
+
*/
|
|
939
869
|
#fetchLocalitiesByID(ids) {
|
|
940
|
-
if (ids.length
|
|
870
|
+
if (!ids.length)
|
|
941
871
|
return [];
|
|
942
872
|
const hasPop = this.#hasPopulationIndex.get("main") === true;
|
|
943
873
|
const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`;
|
|
@@ -976,7 +906,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
976
906
|
#exactMatchIds(schemaName, ids, text) {
|
|
977
907
|
const out = new Set();
|
|
978
908
|
const trimmed = text.trim();
|
|
979
|
-
if (ids.length
|
|
909
|
+
if (!ids.length || !trimmed)
|
|
980
910
|
return out;
|
|
981
911
|
const placeholders = ids.map(() => "?").join(", ");
|
|
982
912
|
try {
|
|
@@ -995,7 +925,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
995
925
|
const rows = this.#db
|
|
996
926
|
.prepare(`SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`)
|
|
997
927
|
.all(...ids);
|
|
998
|
-
const norm = (s) => s.toLowerCase().trim().
|
|
928
|
+
const norm = (s) => s.toLowerCase().trim().replaceAll(/\s+/g, " ");
|
|
999
929
|
const needle = norm(trimmed);
|
|
1000
930
|
for (const r of rows) {
|
|
1001
931
|
if (r.name !== null && norm(r.name) === needle) {
|
|
@@ -1029,7 +959,7 @@ export class WOFSqlitePlaceLookup {
|
|
|
1029
959
|
#officialNameIds(schemaName, ids, text) {
|
|
1030
960
|
const out = new Set();
|
|
1031
961
|
const trimmed = text.trim();
|
|
1032
|
-
if (ids.length
|
|
962
|
+
if (!ids.length || !trimmed)
|
|
1033
963
|
return out;
|
|
1034
964
|
const placeholders = ids.map(() => "?").join(", ");
|
|
1035
965
|
try {
|
|
@@ -1056,7 +986,9 @@ export class WOFSqlitePlaceLookup {
|
|
|
1056
986
|
[Symbol.dispose]() {
|
|
1057
987
|
this.close();
|
|
1058
988
|
}
|
|
1059
|
-
/**
|
|
989
|
+
/**
|
|
990
|
+
* Build the FTS5 virtual table from the `names` + `places` tables.
|
|
991
|
+
*/
|
|
1060
992
|
#ensureFTS() {
|
|
1061
993
|
buildPlaceSearchFTS(this.#db);
|
|
1062
994
|
}
|
|
@@ -1066,69 +998,5 @@ export class WOFSqlitePlaceLookup {
|
|
|
1066
998
|
}
|
|
1067
999
|
}
|
|
1068
1000
|
}
|
|
1069
|
-
|
|
1070
|
-
if (!p)
|
|
1071
|
-
return null;
|
|
1072
|
-
return Array.isArray(p) ? p : [p];
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* Make an arbitrary user-typed string safe for FTS5 MATCH.
|
|
1076
|
-
*
|
|
1077
|
-
* FTS5 has its own query syntax (`"phrase"`, `term1 OR term2`, `prefix*`, NEAR/N, etc.). Letting raw user input through
|
|
1078
|
-
* means a user typing `Paris's` or `St. (Petersburg)` causes a syntax error.
|
|
1079
|
-
*
|
|
1080
|
-
* Per-token rules:
|
|
1081
|
-
*
|
|
1082
|
-
* - Strip all punctuation except trailing `*` from each whitespace-separated token.
|
|
1083
|
-
* - **Trailing `*`** is preserved as FTS5 **prefix syntax** — `627*` becomes the literal `627*` (unquoted). The caller
|
|
1084
|
-
* signaled they want a prefix; respect that.
|
|
1085
|
-
* - All other tokens are wrapped in `"..."` as a single-word phrase. Conservative — handles apostrophes, parens, accented
|
|
1086
|
-
* input, etc. safely.
|
|
1087
|
-
* - Multiple tokens join with implicit AND.
|
|
1088
|
-
*
|
|
1089
|
-
* Examples:
|
|
1090
|
-
*
|
|
1091
|
-
* - `"Paris"` → `"Paris"` (phrase)
|
|
1092
|
-
* - `"627*"` → `627*` (prefix)
|
|
1093
|
-
* - `"St. (Petersburg)"` → `"St" "Petersburg"` (two phrases, AND-joined)
|
|
1094
|
-
* - `"Thiron-Gardais"` → `"Thiron" "Gardais"` (intra-token punctuation SPLITS — #945; fusing to `ThironGardais` matched
|
|
1095
|
-
* nothing because the FTS doc tokenizes the hyphenated name as two terms)
|
|
1096
|
-
* - `"110 00"` with `fuseTokens` (postcode-typed) → `"110" "00"` per-token fused — the #920 name law
|
|
1097
|
-
* - `"Pari* TX"` → `Pari* "TX"` (mixed prefix + phrase)
|
|
1098
|
-
* - `"*"` alone → `""` (no body → drop)
|
|
1099
|
-
*/
|
|
1100
|
-
function sanitizeFTSQuery(text, opts) {
|
|
1101
|
-
const out = [];
|
|
1102
|
-
for (const rawToken of text.normalize("NFKC").split(/\s+/u)) {
|
|
1103
|
-
const trimmed = rawToken.trim();
|
|
1104
|
-
if (!trimmed)
|
|
1105
|
-
continue;
|
|
1106
|
-
const hasPrefixStar = trimmed.endsWith("*");
|
|
1107
|
-
// #920 name law (postcode-typed queries ONLY): delete intra-token punctuation and FUSE the
|
|
1108
|
-
// remainder — postal names are stored in this collapsed shape ("SW1A" stays one term).
|
|
1109
|
-
if (opts?.fuseTokens) {
|
|
1110
|
-
const body = trimmed.replace(/[^\p{L}\p{N}]/gu, "");
|
|
1111
|
-
if (!body)
|
|
1112
|
-
continue;
|
|
1113
|
-
out.push(hasPrefixStar ? `${body}*` : `"${body.replace(/"/g, '""')}"`);
|
|
1114
|
-
continue;
|
|
1115
|
-
}
|
|
1116
|
-
// Everything else SPLITS on intra-token punctuation — the behavior the docstring always
|
|
1117
|
-
// promised ("St. (Petersburg)" → two phrases). The old code DELETED punctuation instead,
|
|
1118
|
-
// fusing "Thiron-Gardais" into the unmatchable single term `ThironGardais` while the FTS
|
|
1119
|
-
// doc holds two terms (#945 — the entire hyphenated-name class missed at the raw lookup;
|
|
1120
|
-
// masked for years because pre-splice tokenizers never emitted hyphen-preserved values).
|
|
1121
|
-
const parts = trimmed.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
1122
|
-
if (parts.length === 0)
|
|
1123
|
-
continue;
|
|
1124
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1125
|
-
const body = parts[i].replace(/\*/g, "");
|
|
1126
|
-
if (!body)
|
|
1127
|
-
continue;
|
|
1128
|
-
// The caller's trailing `*` applies to the FINAL part ("Thiron-Gard*" → "Thiron" Gard*).
|
|
1129
|
-
out.push(hasPrefixStar && i === parts.length - 1 ? `${body}*` : `"${body.replace(/"/g, '""')}"`);
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
return out.join(" ");
|
|
1133
|
-
}
|
|
1001
|
+
export { trigramJaccard, trigrams } from "./name-score.js";
|
|
1134
1002
|
//# sourceMappingURL=lookup.js.map
|