@mailwoman/resolver-wof-sqlite 9.1.0 → 9.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/README.md +28 -9
- package/address-point-interpolation.ts +18 -8
- package/address-point-schema.ts +18 -6
- package/address-point.ts +111 -18
- package/ancestry.ts +9 -6
- package/build-candidate.ts +200 -163
- package/build-slim.ts +3 -3
- package/candidate/alias-bags.ts +54 -0
- package/candidate/ancestors-sidecar.ts +206 -0
- package/candidate/country-display-names.ts +79 -0
- package/candidate/name-roles.ts +237 -0
- package/candidate/own-name.ts +146 -0
- package/candidate/place-attrs.ts +44 -0
- package/candidate/shard-fold.ts +137 -0
- package/candidate-ancestors-schema.ts +195 -0
- package/candidate-fts.ts +4 -2
- package/candidate-importance.ts +2 -1
- package/candidate-lookup.ts +439 -186
- package/candidate-schema.ts +33 -5
- package/candidate-scoring.ts +268 -0
- package/capital-schema.ts +90 -0
- package/capitals.ts +148 -0
- package/coincident-roles.ts +69 -10
- package/convention-schema.ts +72 -0
- package/convention.ts +2 -2
- package/coverage-manifest-schema.ts +7 -7
- package/currency-backfill.ts +249 -0
- package/exact-match.ts +104 -0
- package/fst-autocomplete.ts +91 -119
- package/fst-builder.ts +14 -12
- package/fst-freshness.ts +2 -2
- package/fts-query.ts +1 -1
- package/fts.ts +4 -4
- package/geonames-postal.ts +2 -2
- package/index.ts +18 -14
- package/interpolation.ts +113 -19
- package/lookup.ts +110 -591
- package/name-score.ts +6 -4
- package/out/address-point-interpolation.d.ts.map +1 -1
- package/out/address-point-interpolation.js +13 -7
- package/out/address-point-interpolation.js.map +1 -1
- package/out/address-point-schema.d.ts +16 -6
- package/out/address-point-schema.d.ts.map +1 -1
- package/out/address-point-schema.js.map +1 -1
- package/out/address-point.d.ts.map +1 -1
- package/out/address-point.js +70 -14
- package/out/address-point.js.map +1 -1
- package/out/ancestry.d.ts +2 -2
- package/out/ancestry.d.ts.map +1 -1
- package/out/ancestry.js +5 -6
- package/out/ancestry.js.map +1 -1
- package/out/build-candidate.d.ts +75 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +120 -122
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +1 -1
- package/out/build-slim.js +3 -3
- package/out/build-slim.js.map +1 -1
- package/out/candidate/alias-bags.d.ts +17 -0
- package/out/candidate/alias-bags.d.ts.map +1 -0
- package/out/candidate/alias-bags.js +39 -0
- package/out/candidate/alias-bags.js.map +1 -0
- package/out/candidate/ancestors-sidecar.d.ts +33 -0
- package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
- package/out/candidate/ancestors-sidecar.js +140 -0
- package/out/candidate/ancestors-sidecar.js.map +1 -0
- package/out/candidate/country-display-names.d.ts +35 -0
- package/out/candidate/country-display-names.d.ts.map +1 -0
- package/out/candidate/country-display-names.js +59 -0
- package/out/candidate/country-display-names.js.map +1 -0
- package/out/candidate/name-roles.d.ts +55 -0
- package/out/candidate/name-roles.d.ts.map +1 -0
- package/out/candidate/name-roles.js +165 -0
- package/out/candidate/name-roles.js.map +1 -0
- package/out/candidate/own-name.d.ts +50 -0
- package/out/candidate/own-name.d.ts.map +1 -0
- package/out/candidate/own-name.js +132 -0
- package/out/candidate/own-name.js.map +1 -0
- package/out/candidate/place-attrs.d.ts +43 -0
- package/out/candidate/place-attrs.d.ts.map +1 -0
- package/out/candidate/place-attrs.js +15 -0
- package/out/candidate/place-attrs.js.map +1 -0
- package/out/candidate/shard-fold.d.ts +31 -0
- package/out/candidate/shard-fold.d.ts.map +1 -0
- package/out/candidate/shard-fold.js +104 -0
- package/out/candidate/shard-fold.js.map +1 -0
- package/out/candidate-ancestors-schema.d.ts +150 -0
- package/out/candidate-ancestors-schema.d.ts.map +1 -0
- package/out/candidate-ancestors-schema.js +123 -0
- package/out/candidate-ancestors-schema.js.map +1 -0
- package/out/candidate-fts.d.ts +4 -2
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js +4 -2
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-importance.d.ts.map +1 -1
- package/out/candidate-importance.js +1 -1
- package/out/candidate-importance.js.map +1 -1
- package/out/candidate-lookup.d.ts +22 -45
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +340 -135
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +30 -6
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +3 -0
- package/out/candidate-schema.js.map +1 -1
- package/out/candidate-scoring.d.ts +34 -0
- package/out/candidate-scoring.d.ts.map +1 -0
- package/out/candidate-scoring.js +200 -0
- package/out/candidate-scoring.js.map +1 -0
- package/out/capital-schema.d.ts +51 -0
- package/out/capital-schema.d.ts.map +1 -0
- package/out/capital-schema.js +63 -0
- package/out/capital-schema.js.map +1 -0
- package/out/capitals.d.ts +69 -0
- package/out/capitals.d.ts.map +1 -0
- package/out/capitals.js +98 -0
- package/out/capitals.js.map +1 -0
- package/out/coincident-roles.d.ts +7 -0
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +42 -8
- package/out/coincident-roles.js.map +1 -1
- package/out/convention-schema.d.ts +51 -0
- package/out/convention-schema.d.ts.map +1 -0
- package/out/convention-schema.js +34 -0
- package/out/convention-schema.js.map +1 -0
- package/out/convention.d.ts +1 -1
- package/out/convention.js +2 -2
- package/out/coverage-manifest-schema.js +3 -7
- package/out/coverage-manifest-schema.js.map +1 -1
- package/out/currency-backfill.d.ts +46 -0
- package/out/currency-backfill.d.ts.map +1 -0
- package/out/currency-backfill.js +180 -0
- package/out/currency-backfill.js.map +1 -0
- package/out/exact-match.d.ts +25 -0
- package/out/exact-match.d.ts.map +1 -0
- package/out/exact-match.js +89 -0
- package/out/exact-match.js.map +1 -0
- package/out/fst-autocomplete.d.ts +11 -11
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +82 -99
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +11 -12
- package/out/fst-builder.js.map +1 -1
- package/out/fst-freshness.d.ts +2 -2
- package/out/fst-freshness.js +2 -2
- package/out/fts-query.js +1 -1
- package/out/fts-query.js.map +1 -1
- package/out/fts.d.ts +4 -4
- package/out/fts.js +4 -4
- package/out/geonames-postal.d.ts +2 -2
- package/out/geonames-postal.js +2 -2
- package/out/index.d.ts +3 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +2 -2
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +8 -0
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +91 -19
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +4 -5
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +94 -468
- package/out/lookup.js.map +1 -1
- package/out/name-score.d.ts +0 -10
- package/out/name-score.d.ts.map +1 -1
- package/out/name-score.js +6 -4
- package/out/name-score.js.map +1 -1
- package/out/place-importance-schema.d.ts +42 -5
- package/out/place-importance-schema.d.ts.map +1 -1
- package/out/place-importance-schema.js +54 -8
- package/out/place-importance-schema.js.map +1 -1
- package/out/poi-lookup.d.ts +1 -1
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +12 -13
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +7 -3
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js.map +1 -1
- package/out/polygon-schema.d.ts +37 -0
- package/out/polygon-schema.d.ts.map +1 -0
- package/out/polygon-schema.js +23 -0
- package/out/polygon-schema.js.map +1 -0
- package/out/postal-city-alias-lookup.d.ts +1 -1
- package/out/postal-city-alias-lookup.js +1 -1
- package/out/postal-city-candidate-schema.d.ts +2 -1
- package/out/postal-city-candidate-schema.d.ts.map +1 -1
- package/out/postal-city-candidate-schema.js.map +1 -1
- package/out/postcode-point-lookup.d.ts +1 -1
- package/out/postcode-point-lookup.js +1 -1
- package/out/primary-preference.d.ts +125 -0
- package/out/primary-preference.d.ts.map +1 -0
- package/out/primary-preference.js +138 -0
- package/out/primary-preference.js.map +1 -0
- package/out/proximity-rerank.d.ts +77 -0
- package/out/proximity-rerank.d.ts.map +1 -0
- package/out/proximity-rerank.js +86 -0
- package/out/proximity-rerank.js.map +1 -0
- package/out/region-keys.d.ts +47 -0
- package/out/region-keys.d.ts.map +1 -0
- package/out/region-keys.js +121 -0
- package/out/region-keys.js.map +1 -0
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +6 -9
- package/out/reverse.js.map +1 -1
- package/out/schema.d.ts +1 -1
- package/out/search-fetch.d.ts +57 -0
- package/out/search-fetch.d.ts.map +1 -0
- package/out/search-fetch.js +183 -0
- package/out/search-fetch.js.map +1 -0
- package/out/sharding.d.ts +3 -3
- package/out/sharding.js +1 -1
- package/out/sqlite-convention-source.d.ts +1 -1
- package/out/sqlite-convention-source.js +1 -1
- package/out/sqlite-utils.d.ts +19 -1
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +19 -1
- package/out/sqlite-utils.js.map +1 -1
- package/out/street-centroid-schema.d.ts +7 -2
- package/out/street-centroid-schema.d.ts.map +1 -1
- package/out/street-centroid-schema.js.map +1 -1
- package/out/street-centroid.d.ts.map +1 -1
- package/out/street-centroid.js +7 -7
- package/out/street-centroid.js.map +1 -1
- package/out/street-normalize.d.ts +82 -9
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +175 -9
- package/out/street-normalize.js.map +1 -1
- package/out/street-segment-schema.d.ts +6 -2
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js.map +1 -1
- package/out/types.d.ts +35 -1
- package/out/types.d.ts.map +1 -1
- package/out/unified-schema.d.ts +1 -1
- package/out/unified-schema.js +1 -1
- package/out/uprn-lookup.d.ts +85 -0
- package/out/uprn-lookup.d.ts.map +1 -0
- package/out/uprn-lookup.js +152 -0
- package/out/uprn-lookup.js.map +1 -0
- package/out/uprn-schema.d.ts +93 -0
- package/out/uprn-schema.d.ts.map +1 -0
- package/out/uprn-schema.js +78 -0
- package/out/uprn-schema.js.map +1 -0
- package/out/weights-overlay-linker.d.ts +141 -0
- package/out/weights-overlay-linker.d.ts.map +1 -0
- package/out/weights-overlay-linker.js +259 -0
- package/out/weights-overlay-linker.js.map +1 -0
- package/package.json +288 -16
- package/place-importance-schema.ts +64 -15
- package/poi-lookup.ts +12 -13
- package/poi-schema.ts +8 -3
- package/polygon-schema.ts +47 -0
- package/postal-city-alias-lookup.ts +1 -1
- package/postal-city-candidate-schema.ts +3 -1
- package/postcode-point-lookup.ts +1 -1
- package/primary-preference.ts +207 -0
- package/proximity-rerank.ts +120 -0
- package/region-keys.ts +144 -0
- package/reverse.ts +17 -16
- package/schema.ts +1 -1
- package/search-fetch.ts +256 -0
- package/sharding.ts +3 -3
- package/sqlite-convention-source.ts +1 -1
- package/sqlite-utils.ts +43 -2
- package/street-centroid-schema.ts +8 -2
- package/street-centroid.ts +13 -8
- package/street-normalize.ts +252 -23
- package/street-segment-schema.ts +7 -2
- package/types.ts +35 -1
- package/unified-schema.ts +1 -1
- package/uprn-lookup.ts +210 -0
- package/uprn-schema.ts +124 -0
- package/weights-overlay-linker.ts +377 -0
- package/geo.ts +0 -121
- package/out/geo.d.ts +0 -74
- package/out/geo.d.ts.map +0 -1
- package/out/geo.js +0 -71
- package/out/geo.js.map +0 -1
package/lookup.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* `
|
|
6
|
+
* `WOFSQLitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
|
|
7
7
|
* query layer where the queries are non-trivial, and raw SQL where they aren't (FTS5 MATCH, the
|
|
8
8
|
* FTS index build).
|
|
9
9
|
*
|
|
@@ -14,10 +14,12 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite"
|
|
|
14
14
|
|
|
15
15
|
import { SqliteDialect } from "@mailwoman/core/kysley/dialect"
|
|
16
16
|
import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/resolver"
|
|
17
|
+
import { haversineKm } from "@mailwoman/spatial"
|
|
17
18
|
import { Kysely } from "kysely"
|
|
18
19
|
|
|
19
20
|
import { ancestorLineage } from "./ancestry.ts"
|
|
20
|
-
import {
|
|
21
|
+
import { candidateFromSearchRow, rankCandidates } from "./candidate-scoring.ts"
|
|
22
|
+
import { loadCoincidentLocalities } from "./coincident-roles.ts"
|
|
21
23
|
import {
|
|
22
24
|
ADDRESS_CONVENTION_TABLE,
|
|
23
25
|
resolveConvention,
|
|
@@ -29,20 +31,20 @@ import {
|
|
|
29
31
|
} from "./convention.ts"
|
|
30
32
|
import { normalizePlacetypes, sanitizeFTSQuery } from "./fts-query.ts"
|
|
31
33
|
import {
|
|
32
|
-
aliasBagExactMatch,
|
|
33
34
|
buildPlaceSearchFTS,
|
|
34
35
|
PLACE_BBOX_TABLE,
|
|
35
36
|
PLACE_POPULATION_TABLE,
|
|
37
|
+
PLACE_SEARCH_TABLE,
|
|
36
38
|
placeBboxExists,
|
|
37
39
|
placePopulationExists,
|
|
38
40
|
placeSearchFTSExists,
|
|
39
41
|
} from "./fts.ts"
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import { compareReferential, encyclopedicClauses, referentialFromPopulation } from "./place-importance-schema.ts"
|
|
42
|
+
import { cfNormalize, softNameScore } from "./name-score.ts"
|
|
43
|
+
import { encyclopedicClauses } from "./place-importance-schema.ts"
|
|
43
44
|
import type { WOFPostalCityAliasLookup } from "./postal-city-alias-lookup.ts"
|
|
44
45
|
import { DEFAULT_WEIGHTS, type RankingWeights } from "./ranking-weights.ts"
|
|
45
46
|
import type { WOFDatabase } from "./schema.ts"
|
|
47
|
+
import { fetchSearchRows, type RawSearchRow } from "./search-fetch.ts"
|
|
46
48
|
import {
|
|
47
49
|
pickShardForPlacetype,
|
|
48
50
|
pickShardsForPlacetype,
|
|
@@ -51,16 +53,10 @@ import {
|
|
|
51
53
|
type ShardConfig,
|
|
52
54
|
} from "./sharding.ts"
|
|
53
55
|
import { SqliteConventionSource } from "./sqlite-convention-source.ts"
|
|
56
|
+
import { allRows } from "./sqlite-utils.ts"
|
|
54
57
|
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "./types.ts"
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
* Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
|
|
58
|
-
* abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
|
|
59
|
-
* losing to "New York".
|
|
60
|
-
*/
|
|
61
|
-
const SHORT_QUERY_MAX_LENGTH = 3
|
|
62
|
-
|
|
63
|
-
export interface WOFSqlitePlaceLookupOpts {
|
|
59
|
+
export interface WOFSQLitePlaceLookupOpts {
|
|
64
60
|
/**
|
|
65
61
|
* Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
|
|
66
62
|
*
|
|
@@ -107,49 +103,25 @@ export interface WOFSqlitePlaceLookupOpts {
|
|
|
107
103
|
postalCityAliases?: WOFPostalCityAliasLookup
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
/**
|
|
111
|
-
* Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
|
|
112
|
-
* poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
|
|
113
|
-
* promotes it. 200 comfortably covers every same-abbrev region across the 12-country gazetteer (a 2-letter token
|
|
114
|
-
* matches a few dozen regions at most) while staying a cheap region-placetype fetch. See the `#fuzzyNameMatch`
|
|
115
|
-
* over-fetch comment.
|
|
116
|
-
*/
|
|
117
|
-
const SHORT_QUERY_OVERFETCH = 200
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* How many rows the population-ordered companion fetch (#905) adds to the candidate pool. Small on purpose: its only
|
|
121
|
-
* job is to guarantee the FAMOUS holders of a name enter the pool at all — for "Paris"-class floods the bm25 window is
|
|
122
|
-
* saturated by thousands of tiny same-name rows and no boost inside the bm25-based ORDER BY can rescue a candidate
|
|
123
|
-
* whose bm25 is length-poisoned by ~15 points (see the fetch-site comment).
|
|
124
|
-
*/
|
|
125
|
-
const POPULATION_FETCH_LIMIT = 15
|
|
126
|
-
|
|
127
|
-
interface RawSearchRow {
|
|
128
|
-
id: number
|
|
129
|
-
name: string
|
|
130
|
-
placetype: string
|
|
131
|
-
country: string | null
|
|
132
|
-
parent_id: number | null
|
|
133
|
-
rank: number // BM25 (lower = better in SQLite); we negate to get higher-is-better
|
|
134
|
-
lat: number | null
|
|
135
|
-
lon: number | null
|
|
136
|
-
min_latitude: number | null
|
|
137
|
-
max_latitude: number | null
|
|
138
|
-
min_longitude: number | null
|
|
139
|
-
max_longitude: number | null
|
|
140
|
-
population: number | null // from the place_population aux table; null when missing
|
|
141
|
-
/**
|
|
142
|
-
* From `place_importance.encyclopedic` when the shard's table carries the two-score split columns. NULL means the
|
|
143
|
-
* place has no Wikipedia article, or the shard predates the split — absence either way, and never 0 (ROAD_TO_V9 §2).
|
|
144
|
-
*/
|
|
145
|
-
encyclopedic: number | null
|
|
146
|
-
}
|
|
147
|
-
|
|
148
106
|
/**
|
|
149
107
|
* The coordinate-first candidate table (scripts/build-postcode-locality.ts): postcode → containing
|
|
150
108
|
*
|
|
151
109
|
* - Nearby localities with WOF alt-name aliases.
|
|
152
110
|
*/
|
|
111
|
+
/**
|
|
112
|
+
* The placetypes `pickShardsForPlacetype`'s substring rule can route by name. Not every WOF placetype — only the ones a
|
|
113
|
+
* purpose-built shard is ever named for — so the diagnostic below can say "this name routes nowhere" without claiming
|
|
114
|
+
* to enumerate the gazetteer.
|
|
115
|
+
*/
|
|
116
|
+
const KNOWN_ROUTED_PLACETYPES: ReadonlyArray<string> = [
|
|
117
|
+
"postalcode",
|
|
118
|
+
"locality",
|
|
119
|
+
"region",
|
|
120
|
+
"county",
|
|
121
|
+
"country",
|
|
122
|
+
"venue",
|
|
123
|
+
]
|
|
124
|
+
|
|
153
125
|
const POSTCODE_LOCALITY_TABLE = "postcode_locality"
|
|
154
126
|
|
|
155
127
|
/**
|
|
@@ -165,9 +137,8 @@ const CF_PC_DECAY_KM = 8
|
|
|
165
137
|
* flagged, tight enough to catch a wrong city (hundreds of km).
|
|
166
138
|
*/
|
|
167
139
|
const CF_MISMATCH_KM = 50
|
|
168
|
-
const CF_MISMATCH_DELTA = 0.5
|
|
169
140
|
|
|
170
|
-
export class
|
|
141
|
+
export class WOFSQLitePlaceLookup implements PlaceLookup, Disposable {
|
|
171
142
|
readonly #db: DatabaseSync
|
|
172
143
|
readonly #ownsDB: boolean
|
|
173
144
|
readonly #kysely: Kysely<WOFDatabase>
|
|
@@ -234,13 +205,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
234
205
|
*/
|
|
235
206
|
readonly #postalCityAliases: WOFPostalCityAliasLookup | null
|
|
236
207
|
|
|
237
|
-
constructor(opts:
|
|
208
|
+
constructor(opts: WOFSQLitePlaceLookupOpts, weights?: Partial<RankingWeights>) {
|
|
238
209
|
if (opts.database && opts.databasePath) {
|
|
239
|
-
throw new Error("
|
|
210
|
+
throw new Error("WOFSQLitePlaceLookup: pass either `database` or `databasePath`, not both")
|
|
240
211
|
}
|
|
241
212
|
|
|
242
213
|
if (!opts.database && !opts.databasePath) {
|
|
243
|
-
throw new Error("
|
|
214
|
+
throw new Error("WOFSQLitePlaceLookup: one of `database` or `databasePath` is required")
|
|
244
215
|
}
|
|
245
216
|
|
|
246
217
|
if (opts.database) {
|
|
@@ -293,6 +264,49 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
293
264
|
this.#encyclopedicClauses.set(s.schemaName, encyclopedicClauses(this.#db, s.schemaName))
|
|
294
265
|
}
|
|
295
266
|
|
|
267
|
+
// Every lookup path here reaches `place_search`, and a shard without it fails in one of two ways
|
|
268
|
+
// that are both hard to read: an unroutable name returns zero hits (indistinguishable from "this
|
|
269
|
+
// country has no places") and a routable one throws mid-query from deep inside a SELECT. The
|
|
270
|
+
// unroutable half is the worse of the two — a shard reaches routing only through the name
|
|
271
|
+
// `deriveSchemaName` derives from its FILENAME, so a file spelled one letter off the placetype it
|
|
272
|
+
// serves answers with nothing while holding every row that was asked for.
|
|
273
|
+
//
|
|
274
|
+
// Two independent things bring a shard under the guard, and it needs both. Carrying `spr` is a
|
|
275
|
+
// CLAIM to be a place shard. Carrying a name that routes is an INVITATION to be queried as one, and
|
|
276
|
+
// it is made by the filename alone — so a database with no tables at all still gets picked, still
|
|
277
|
+
// answers no query, and still dies inside a SELECT. Testing only the claim lets an empty or
|
|
278
|
+
// truncated file past construction; testing only the name would exempt a correctly-named build
|
|
279
|
+
// input. A shard needs to fail neither test to be exempt.
|
|
280
|
+
//
|
|
281
|
+
// Exempt by design: `postcode-locality-<cc>.db` carries a relation table and nothing else, matches
|
|
282
|
+
// no routed placetype, and is part of the documented default shard list.
|
|
283
|
+
for (const s of this.#shards) {
|
|
284
|
+
if (s.schemaName === "main") continue
|
|
285
|
+
|
|
286
|
+
const routes = KNOWN_ROUTED_PLACETYPES.some(
|
|
287
|
+
(pt) => s.schemaName === pt || s.schemaName.startsWith(`${pt}_`) || s.schemaName.endsWith(`_${pt}`)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const claimsPlaceShard = this.#shardHasTable(s.schemaName, "spr")
|
|
291
|
+
|
|
292
|
+
if (!routes && !claimsPlaceShard) continue
|
|
293
|
+
|
|
294
|
+
if (this.#shardHasTable(s.schemaName, PLACE_SEARCH_TABLE)) continue
|
|
295
|
+
|
|
296
|
+
throw new Error(
|
|
297
|
+
`WOFSQLitePlaceLookup: ${s.path} ` +
|
|
298
|
+
(claimsPlaceShard
|
|
299
|
+
? `carries "spr" but no "${PLACE_SEARCH_TABLE}" table, so it cannot serve a lookup.`
|
|
300
|
+
: `is named for a routed placetype but carries neither "spr" nor "${PLACE_SEARCH_TABLE}", so every ` +
|
|
301
|
+
`query routed to it would die mid-SELECT. An empty or truncated file reads exactly like this.`) +
|
|
302
|
+
` Build it with the FTS index, or leave it out — it is usable as a BUILD input either way.` +
|
|
303
|
+
(routes
|
|
304
|
+
? ""
|
|
305
|
+
: ` Its schema name "${s.schemaName}" also matches no routed placetype (${KNOWN_ROUTED_PLACETYPES.join(", ")}), ` +
|
|
306
|
+
`so it would never have been queried even with the table — check the filename's spelling.`)
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
296
310
|
// #920 country-aware shard routing: probe each NON-MAIN shard's country set once at
|
|
297
311
|
// construction (they're small, purpose-built shards — postcode/locality slices; main is the
|
|
298
312
|
// multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
|
|
@@ -435,54 +449,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
435
449
|
if (!Number.isFinite(id)) return []
|
|
436
450
|
|
|
437
451
|
if (!this.#coincidentRolesCache) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (coincidentRolesExists(this.#db)) {
|
|
441
|
-
const rows = this.#db
|
|
442
|
-
.prepare(
|
|
443
|
-
`SELECT cr.admin_id AS adminID, s.id AS id, s.name AS name, s.country AS country,
|
|
444
|
-
s.latitude AS lat, s.longitude AS lon,
|
|
445
|
-
cr.relationship_type AS relationshipType, cr.locality_population AS population,
|
|
446
|
-
cr.distance_km AS distanceKm
|
|
447
|
-
FROM ${COINCIDENT_ROLES_TABLE} cr JOIN spr s ON s.id = cr.locality_id`
|
|
448
|
-
)
|
|
449
|
-
.all() as unknown as Array<{
|
|
450
|
-
adminID: number
|
|
451
|
-
id: number
|
|
452
|
-
name: string
|
|
453
|
-
country: string
|
|
454
|
-
lat: number
|
|
455
|
-
lon: number
|
|
456
|
-
relationshipType: string
|
|
457
|
-
population: number
|
|
458
|
-
distanceKm: number
|
|
459
|
-
}>
|
|
460
|
-
|
|
461
|
-
for (const r of rows) {
|
|
462
|
-
const candidate: CoincidentLocality = {
|
|
463
|
-
id: r.id,
|
|
464
|
-
name: r.name,
|
|
465
|
-
placetype: "locality",
|
|
466
|
-
country: r.country,
|
|
467
|
-
lat: r.lat,
|
|
468
|
-
lon: r.lon,
|
|
469
|
-
score: 0,
|
|
470
|
-
relationshipType: r.relationshipType,
|
|
471
|
-
population: r.population,
|
|
472
|
-
distanceKm: r.distanceKm,
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const list = map.get(r.adminID)
|
|
476
|
-
|
|
477
|
-
if (list) {
|
|
478
|
-
list.push(candidate)
|
|
479
|
-
} else {
|
|
480
|
-
map.set(r.adminID, [candidate])
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
this.#coincidentRolesCache = map
|
|
452
|
+
this.#coincidentRolesCache = loadCoincidentLocalities(this.#db)
|
|
486
453
|
}
|
|
487
454
|
|
|
488
455
|
return this.#coincidentRolesCache.get(id) ?? []
|
|
@@ -527,7 +494,7 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
527
494
|
this.#warnedUnknownStrategies.add(name)
|
|
528
495
|
|
|
529
496
|
console.warn(
|
|
530
|
-
`
|
|
497
|
+
`WOFSQLitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
|
|
531
498
|
`(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
|
|
532
499
|
`against a newer code revision, rebuild the asset for this one.`
|
|
533
500
|
)
|
|
@@ -554,18 +521,6 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
554
521
|
async #fuzzyNameMatch(query: FindPlaceQuery, forceShard?: ResolvedShard): Promise<PlaceCandidate[]> {
|
|
555
522
|
const limit = query.limit ?? 10
|
|
556
523
|
|
|
557
|
-
// Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
|
|
558
|
-
// region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
|
|
559
|
-
// the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
|
|
560
|
-
// so under the normal `limit * 4` window it drops OUT of the candidate pool BEFORE tiering can
|
|
561
|
-
// promote it — "NY" then resolves to a token-matching foreign region (Highland, GB) instead of New
|
|
562
|
-
// York. Widen the window for short queries so the exact match is always present to be tiered.
|
|
563
|
-
// (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
|
|
564
|
-
// postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
|
|
565
|
-
// With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
|
|
566
|
-
const ftsLimit =
|
|
567
|
-
query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
|
|
568
|
-
|
|
569
524
|
// Expand the placetype filter through the shared equivalence table (core/resolver): a
|
|
570
525
|
// `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
|
|
571
526
|
// (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
|
|
@@ -628,378 +583,38 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
628
583
|
countriesBySchema: this.#shardCountries,
|
|
629
584
|
})
|
|
630
585
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (query.country) {
|
|
647
|
-
where.push("spr.country = ?")
|
|
648
|
-
params.push(query.country)
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (query.parentID !== undefined) {
|
|
652
|
-
where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`)
|
|
653
|
-
params.push(query.parentID, query.parentID)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Bbox + near-with-radius are SQL-level filters via the R*Tree. We only emit the JOIN when
|
|
657
|
-
// the active shard has the R*Tree; missing-but-requested is silently treated as no-bbox-
|
|
658
|
-
// filter so legacy DBs / shards-without-bbox don't crash.
|
|
659
|
-
const shardHasBbox = this.#hasBboxIndex.get(sch) === true
|
|
660
|
-
const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && shardHasBbox
|
|
661
|
-
let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`
|
|
662
|
-
|
|
663
|
-
if (useBboxJoin) {
|
|
664
|
-
joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
|
|
665
|
-
// AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
|
|
666
|
-
const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
|
|
667
|
-
where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
|
|
668
|
-
params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
|
|
672
|
-
// just doesn't include the population column; the post-scoring loop treats it as 0.
|
|
673
|
-
const shardHasPopulation = this.#hasPopulationIndex.get(sch) === true
|
|
674
|
-
|
|
675
|
-
const populationSelect = shardHasPopulation
|
|
676
|
-
? `${PLACE_POPULATION_TABLE}.population AS population`
|
|
677
|
-
: `NULL AS population`
|
|
678
|
-
|
|
679
|
-
const populationJoin = shardHasPopulation
|
|
680
|
-
? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
|
|
681
|
-
: ""
|
|
682
|
-
|
|
683
|
-
// The encyclopedic score is CARRIED, never ranked on (ROAD_TO_V9 §2, ratified 2026-08-06) — it
|
|
684
|
-
// appears in the SELECT and in no ORDER BY, here or in the companion fetch below. Gated on the
|
|
685
|
-
// split column, so a pre-split shard emits a literal NULL and builds no join at all.
|
|
686
|
-
const { select: encyclopedicSelect, join: encyclopedicJoin } = this.#encyclopedicClauses.get(sch)!
|
|
687
|
-
|
|
688
|
-
// Push the population boost into the ORDER BY when the index is available, so famous places
|
|
689
|
-
// (whose long alt-name lists hurt BM25) actually make it into the over-fetch window. The TS
|
|
690
|
-
// post-scoring will still compute the same boost for the final score; this just ensures the
|
|
691
|
-
// candidate set is right.
|
|
692
|
-
//
|
|
693
|
-
// Formula: rank_adjusted = bm25 - populationBoost * min(1.0, log10(1 + pop) / scaleLog10)
|
|
694
|
-
// Lower rank_adjusted = better (matches SQLite's bm25 convention of "more negative = better").
|
|
695
|
-
//
|
|
696
|
-
// #905 — do NOT reach for bm25 column weights here. Measured falsification (2026-07-02): FTS5's
|
|
697
|
-
// bm25 length normalization is polluted by the row's TOTAL document size, so identical 1-token
|
|
698
|
-
// `name` docs read −16.0 (empty alt_names) vs −0.43 (2.7 KB alt_names) EVEN with the alt_names
|
|
699
|
-
// column weighted to zero — no weighting isolates name relevance in this schema. The famous-
|
|
700
|
-
// holder guarantee lives in the population-ordered companion fetch below instead, and the
|
|
701
|
-
// exact tier breaks ties by population in the post-scoring sort.
|
|
702
|
-
const orderByExpr = shardHasPopulation
|
|
703
|
-
? `(bm25(place_search) - ? * MIN(1.0, COALESCE(log10(1.0 + ${PLACE_POPULATION_TABLE}.population), 0) / ?))`
|
|
704
|
-
: "bm25(place_search)"
|
|
705
|
-
|
|
706
|
-
// Schema-qualified FROM with bare-name MATCH — required syntax for FTS5 on attached schemas.
|
|
707
|
-
// See sharding.ts header for the gotcha that drove this design.
|
|
708
|
-
const stmt = this.#db.prepare(`
|
|
709
|
-
SELECT
|
|
710
|
-
spr.id AS id,
|
|
711
|
-
spr.name,
|
|
712
|
-
spr.placetype,
|
|
713
|
-
spr.country,
|
|
714
|
-
spr.parent_id,
|
|
715
|
-
bm25(place_search) AS rank,
|
|
716
|
-
spr.latitude AS lat,
|
|
717
|
-
spr.longitude AS lon,
|
|
718
|
-
spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
|
|
719
|
-
${populationSelect},
|
|
720
|
-
${encyclopedicSelect}
|
|
721
|
-
FROM ${sch}.place_search
|
|
722
|
-
${joinClause}
|
|
723
|
-
${populationJoin}
|
|
724
|
-
${encyclopedicJoin}
|
|
725
|
-
WHERE ${where.join(" AND ")}
|
|
726
|
-
ORDER BY ${orderByExpr} ASC
|
|
727
|
-
LIMIT ?
|
|
728
|
-
`)
|
|
729
|
-
|
|
730
|
-
if (shardHasPopulation) {
|
|
731
|
-
params.push(this.#weights.populationBoost, this.#weights.populationScaleLog10)
|
|
732
|
-
}
|
|
586
|
+
// bare schema name; safe to interpolate (validated at construction)
|
|
587
|
+
const sch = shard.schemaName
|
|
588
|
+
|
|
589
|
+
const rawRows = fetchSearchRows({
|
|
590
|
+
db: this.#db,
|
|
591
|
+
schemaName: sch,
|
|
592
|
+
query,
|
|
593
|
+
placetypes,
|
|
594
|
+
ftsQuery,
|
|
595
|
+
limit,
|
|
596
|
+
hasBboxIndex: this.#hasBboxIndex,
|
|
597
|
+
hasPopulationIndex: this.#hasPopulationIndex,
|
|
598
|
+
encyclopedicClauses: this.#encyclopedicClauses,
|
|
599
|
+
weights: this.#weights,
|
|
600
|
+
})
|
|
733
601
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
// ("Paris" matches thousands of gap-fill villages) the bm25-based window above cannot admit
|
|
740
|
-
// the famous holder — its bm25 is length-poisoned by the row's alias bulk (measured ~15 pts,
|
|
741
|
-
// vs a +4.0 boost cap), so FR Paris never even reaches post-scoring. This fetch makes the
|
|
742
|
-
// prominent holders of a name pool-complete BY CONSTRUCTION; the exact-tier sort below
|
|
743
|
-
// decides whether they win. Skipped without a population index (nothing to order by).
|
|
744
|
-
if (shardHasPopulation) {
|
|
745
|
-
const popStmt = this.#db.prepare(`
|
|
746
|
-
SELECT
|
|
747
|
-
spr.id AS id,
|
|
748
|
-
spr.name,
|
|
749
|
-
spr.placetype,
|
|
750
|
-
spr.country,
|
|
751
|
-
spr.parent_id,
|
|
752
|
-
bm25(place_search) AS rank,
|
|
753
|
-
spr.latitude AS lat,
|
|
754
|
-
spr.longitude AS lon,
|
|
755
|
-
spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
|
|
756
|
-
${populationSelect},
|
|
757
|
-
${encyclopedicSelect}
|
|
758
|
-
FROM ${sch}.place_search
|
|
759
|
-
${joinClause}
|
|
760
|
-
${populationJoin}
|
|
761
|
-
${encyclopedicJoin}
|
|
762
|
-
WHERE ${where.join(" AND ")}
|
|
763
|
-
ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
|
|
764
|
-
LIMIT ?
|
|
765
|
-
`)
|
|
766
|
-
|
|
767
|
-
const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
|
|
768
|
-
const seen = new Set(rawRows.map((r) => r.id))
|
|
769
|
-
|
|
770
|
-
for (const row of popStmt.all(...popParams, POPULATION_FETCH_LIMIT) as unknown as RawSearchRow[]) {
|
|
771
|
-
if (!seen.has(row.id)) {
|
|
772
|
-
rawRows.push(row)
|
|
773
|
-
}
|
|
774
|
-
}
|
|
602
|
+
const scoring = {
|
|
603
|
+
query,
|
|
604
|
+
placetypes,
|
|
605
|
+
queryLen: query.text.length,
|
|
606
|
+
weights: this.#weights,
|
|
775
607
|
}
|
|
776
608
|
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
const candidates = rawRows.map((row): PlaceCandidate => {
|
|
780
|
-
// SQLite's bm25() returns a lower-is-better score (negative for matches). Negate so we
|
|
781
|
-
// start from a higher-is-better baseline.
|
|
782
|
-
let score = -row.rank
|
|
783
|
-
|
|
784
|
-
if (placetypes && placetypes.length && placetypes.includes(row.placetype as WOFPlacetype)) {
|
|
785
|
-
score += this.#weights.placetypeMatchBoost
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (!placetypes && row.placetype === "locality") {
|
|
789
|
-
score += this.#weights.localityImplicitBoost
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
if (query.country && row.country === query.country) {
|
|
793
|
-
score += this.#weights.countryMatchBoost
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
if (query.parentID !== undefined) {
|
|
797
|
-
score += row.parent_id === query.parentID ? this.#weights.directChildBoost : this.#weights.descendantBoost
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const extraLen = Math.max(0, row.name.length - queryLen - 3)
|
|
801
|
-
score -= (this.#weights.lengthPenaltyWeight * extraLen) / 10
|
|
802
|
-
|
|
803
|
-
// Proximity boost: only applied when the query carries `near` AND the candidate has real
|
|
804
|
-
// coordinates. The formula decays smoothly with distance so close-but-not-exact hits
|
|
805
|
-
// still benefit; tunable via proximityBoost + proximityScaleKm.
|
|
806
|
-
let distanceKm: number | undefined
|
|
807
|
-
// The best decayed-distance term over `near` + every `bias` point (each point's term is
|
|
808
|
-
// scaled by its weight; the MAX wins — a candidate near ANY hint is "nearby"). Carried
|
|
809
|
-
// into the exact-tier prominence sort below when hints are present.
|
|
810
|
-
let proximityTerm = 0
|
|
811
|
-
|
|
812
|
-
if (row.lat !== null && row.lon !== null && !(row.lat === 0 && row.lon === 0)) {
|
|
813
|
-
const hints: Array<{ lat: number; lon: number; weight: number }> = []
|
|
814
|
-
|
|
815
|
-
if (query.near) {
|
|
816
|
-
hints.push({ lat: query.near.lat, lon: query.near.lon, weight: 1 })
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
for (const b of query.bias ?? []) {
|
|
820
|
-
hints.push({ lat: b.lat, lon: b.lon, weight: b.weight ?? 1 })
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
let scoreTerm = 0
|
|
824
|
-
|
|
825
|
-
for (const h of hints) {
|
|
826
|
-
const d = haversineKm(h.lat, h.lon, row.lat, row.lon)
|
|
827
|
-
const decay = h.weight / (1 + d / this.#weights.proximityScaleKm)
|
|
828
|
-
const prom = decay * this.#weights.biasBoost
|
|
829
|
-
|
|
830
|
-
if (prom > proximityTerm) {
|
|
831
|
-
proximityTerm = prom
|
|
832
|
-
distanceKm = d
|
|
833
|
-
scoreTerm = decay * this.#weights.proximityBoost
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
score += scoreTerm
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// Population boost: capped at `populationBoost` magnitude at `10^populationScaleLog10`
|
|
841
|
-
// people. Missing population → no contribution. Never penalizes.
|
|
842
|
-
let popTerm = 0
|
|
843
|
-
|
|
844
|
-
if (row.population !== null && row.population > 0 && this.#weights.populationScaleLog10 > 0) {
|
|
845
|
-
const popLog = Math.log10(1 + row.population)
|
|
846
|
-
const popFraction = Math.min(1, popLog / this.#weights.populationScaleLog10)
|
|
847
|
-
popTerm = this.#weights.populationBoost * popFraction
|
|
848
|
-
score += popTerm
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// Combined prominence for the exact-tier sort when proximity hints are present: population
|
|
852
|
-
// and nearness in the SAME additive units, so the map view / the user's location can win a
|
|
853
|
-
// cross-country postcode tie without a hard filter.
|
|
854
|
-
const prominence = popTerm + proximityTerm
|
|
609
|
+
const candidates = rawRows.map((row) => candidateFromSearchRow(row, scoring))
|
|
855
610
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
country: row.country ?? "",
|
|
862
|
-
lat: row.lat ?? 0,
|
|
863
|
-
lon: row.lon ?? 0,
|
|
864
|
-
parent_id: row.parent_id ?? undefined,
|
|
865
|
-
score,
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
if (distanceKm !== undefined) {
|
|
869
|
-
candidate.distanceKm = distanceKm
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (row.population !== null && row.population > 0) {
|
|
873
|
-
candidate.population = row.population
|
|
874
|
-
// The named ranking key (ROAD_TO_V9 §2). DERIVED, not stored — a pure function of the
|
|
875
|
-
// population already on this row, so it cannot drift from what the ordering uses.
|
|
876
|
-
candidate.referential = referentialFromPopulation(row.population)
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Carried for consumers (annotations / API surfaces). No ranking site reads it.
|
|
880
|
-
if (row.encyclopedic !== null) {
|
|
881
|
-
candidate.encyclopedic = row.encyclopedic
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// Candidate bbox — parity with the WASM lookup (resolver-wof-wasm/lookup.ts), whose
|
|
885
|
-
// consumers (the demo cascade's region constraint) read it. Without this the Node
|
|
886
|
-
// backend's region→bbox constraint is dead and disambiguation falls to population
|
|
887
|
-
// ranking (the Springfield-IL→MO failure the #524 smoke eval caught).
|
|
888
|
-
if (
|
|
889
|
-
row.min_latitude != null &&
|
|
890
|
-
row.max_latitude != null &&
|
|
891
|
-
row.min_longitude != null &&
|
|
892
|
-
row.max_longitude != null
|
|
893
|
-
) {
|
|
894
|
-
candidate.bbox = {
|
|
895
|
-
minLat: row.min_latitude,
|
|
896
|
-
maxLat: row.max_latitude,
|
|
897
|
-
minLon: row.min_longitude,
|
|
898
|
-
maxLon: row.max_longitude,
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
return candidate
|
|
611
|
+
rankCandidates(candidates, {
|
|
612
|
+
db: this.#db,
|
|
613
|
+
schemaName: sch,
|
|
614
|
+
query,
|
|
615
|
+
weights: this.#weights,
|
|
903
616
|
})
|
|
904
617
|
|
|
905
|
-
// Exact-match tiering: a candidate whose name OR any alias equals the query text (case-folded)
|
|
906
|
-
// ranks above any partial match, with the weighted-sum score (incl. population) breaking ties
|
|
907
|
-
// WITHIN a tier. See the RankingWeights.exactMatchTiering docstring for why this aligns the
|
|
908
|
-
// population prior rather than overriding it. One cheap indexed lookup over the candidate ids.
|
|
909
|
-
// Runs even for a SINGLE candidate so `exactMatch` is stamped consistently (parity with the
|
|
910
|
-
// WASM lookup) — a sole alias hit ("New York City" → New York) must still carry the flag the
|
|
911
|
-
// demo cascade / #369 re-rank read.
|
|
912
|
-
if (this.#weights.exactMatchTiering && candidates.length) {
|
|
913
|
-
const exactIds = this.#exactMatchIds(
|
|
914
|
-
sch,
|
|
915
|
-
candidates.map((c) => c.id as number),
|
|
916
|
-
query.text
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
// Stamp the tier onto every candidate (not just when the tiering sort fires) so a downstream
|
|
920
|
-
// re-rank — #369's postcode-anchor country pin in `resolveTree` — can keep the country pin from
|
|
921
|
-
// crossing the exact/partial boundary ("ME" → Maine, not the more-populous Missouri).
|
|
922
|
-
for (const c of candidates) {
|
|
923
|
-
c.exactMatch = exactIds.has(c.id as number)
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
if (exactIds.size) {
|
|
927
|
-
// #905: WITHIN the exact tier, population is the PRIMARY key and the weighted score
|
|
928
|
-
// only breaks population ties. Exactness saturates text relevance, and the bm25
|
|
929
|
-
// residue inside `score` is length-noise (see the fetch-site comment), so letting it
|
|
930
|
-
// order the tier is what sent unscoped "Paris" to an Ohio township. The partial tier
|
|
931
|
-
// keeps score order — text relevance still means something there. This makes the
|
|
932
|
-
// exactMatchTiering docstring literal: match quality primary, prominence within.
|
|
933
|
-
//
|
|
934
|
-
// #912 sub-tier: a NAME-exact candidate (spr.name equals the query) outranks an
|
|
935
|
-
// ALIAS-exact one ('Paris' the place beats 'Paris Township' held via alias 'Paris').
|
|
936
|
-
// The place's own name is a stronger identity claim than an alias — aliases exist to
|
|
937
|
-
// widen recall, not to tie primaries. ME→Maine is untouched: 'ME' name-exact-matches
|
|
938
|
-
// nothing, so the alias sub-tier still decides there. Population orders within each
|
|
939
|
-
// sub-tier as before.
|
|
940
|
-
const norm = (v: string): string => v.toLowerCase().trim().replaceAll(/\s+/g, " ")
|
|
941
|
-
const needle = norm(query.text)
|
|
942
|
-
|
|
943
|
-
// #936 option 3: an OFFICIAL name (preferred form in an official language of the place's
|
|
944
|
-
// country, `names.official = 1`) counts as the place's own name for the sub-tier — "Åbo" is
|
|
945
|
-
// Turku's name, not merely its alias. Floor-gated on the holder's population (see the
|
|
946
|
-
// RankingWeights docstring for the measured 100k boundary). officialIds ⊆ exactIds by
|
|
947
|
-
// construction (official rows are names rows), so only the sub-tier KIND changes.
|
|
948
|
-
const officialIds = this.#weights.officialNameExact
|
|
949
|
-
? this.#officialNameIds(
|
|
950
|
-
sch,
|
|
951
|
-
candidates
|
|
952
|
-
.filter(
|
|
953
|
-
(c) => exactIds.has(c.id as number) && (c.population ?? 0) >= this.#weights.officialNameExactFloor
|
|
954
|
-
)
|
|
955
|
-
.map((c) => c.id as number),
|
|
956
|
-
query.text
|
|
957
|
-
)
|
|
958
|
-
: undefined
|
|
959
|
-
|
|
960
|
-
const kind = (c: PlaceCandidate): number => {
|
|
961
|
-
if (!exactIds.has(c.id as number)) return 0
|
|
962
|
-
|
|
963
|
-
if (norm(String(c.name ?? "")) === needle) return 2
|
|
964
|
-
|
|
965
|
-
return officialIds?.has(c.id as number) ? 2 : 1
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// With proximity hints (near/bias), prominence (population + nearness, same units)
|
|
969
|
-
// replaces raw population as the within-tier key — the 48026 rule: the map view or
|
|
970
|
-
// the user's location breaks a cross-country postcode tie. Without hints, REFERENTIAL
|
|
971
|
-
// ordering decides.
|
|
972
|
-
//
|
|
973
|
-
// ROAD_TO_V9 §2: this is the site that "orders namesakes", so it is the site that has to
|
|
974
|
-
// say what it orders by. `compareReferential` is referential DESC with raw population as
|
|
975
|
-
// the tiebreak, which is provably the SAME ORDER as the `(b.population ?? 0) - (a.population ?? 0)`
|
|
976
|
-
// it replaces — referential is strictly increasing in population below saturation and
|
|
977
|
-
// constant above it, and the tiebreak restores the order in the saturated tail. Measured
|
|
978
|
-
// zero-delta, not assumed: see `place-importance-schema.test.ts` and `resolver-referential-ranking.test.ts`.
|
|
979
|
-
// Encyclopedic importance is not, and must not become, an input here.
|
|
980
|
-
const hasHints = !!query.near || (query.bias?.length ?? 0) > 0
|
|
981
|
-
|
|
982
|
-
candidates.sort((a, b) => {
|
|
983
|
-
const ax = kind(a)
|
|
984
|
-
const bx = kind(b)
|
|
985
|
-
|
|
986
|
-
if (bx !== ax) return bx - ax
|
|
987
|
-
|
|
988
|
-
if (ax >= 1) {
|
|
989
|
-
if (hasHints) return (b.prominence ?? 0) - (a.prominence ?? 0) || b.score - a.score
|
|
990
|
-
|
|
991
|
-
return compareReferential(a, b) || b.score - a.score
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
return b.score - a.score
|
|
995
|
-
})
|
|
996
|
-
|
|
997
|
-
return candidates.slice(0, limit)
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
candidates.sort((a, b) => b.score - a.score)
|
|
1002
|
-
|
|
1003
618
|
return candidates.slice(0, limit)
|
|
1004
619
|
}
|
|
1005
620
|
|
|
@@ -1073,12 +688,13 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1073
688
|
const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
|
|
1074
689
|
const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
|
|
1075
690
|
|
|
1076
|
-
const pcRows =
|
|
1077
|
-
.prepare(
|
|
691
|
+
const pcRows = allRows<{ id: number; aliases: string | null; dist: number; containing: number }>(
|
|
692
|
+
this.#db.prepare(
|
|
1078
693
|
`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
|
|
1079
694
|
FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`
|
|
1080
|
-
)
|
|
1081
|
-
|
|
695
|
+
),
|
|
696
|
+
...pcParams
|
|
697
|
+
)
|
|
1082
698
|
|
|
1083
699
|
if (!pcRows.length) return null
|
|
1084
700
|
|
|
@@ -1194,14 +810,15 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1194
810
|
const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
|
|
1195
811
|
const ph = ids.map(() => "?").join(", ")
|
|
1196
812
|
|
|
1197
|
-
const rows =
|
|
1198
|
-
.prepare(
|
|
813
|
+
const rows = allRows<RawSearchRow>(
|
|
814
|
+
this.#db.prepare(
|
|
1199
815
|
`SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
|
|
1200
816
|
s.latitude AS lat, s.longitude AS lon, s.placetype AS placetype, ${popSelect}
|
|
1201
817
|
FROM main.spr s ${popJoin}
|
|
1202
818
|
WHERE s.id IN (${ph}) AND s.is_current != 0`
|
|
1203
|
-
)
|
|
1204
|
-
|
|
819
|
+
),
|
|
820
|
+
...ids
|
|
821
|
+
)
|
|
1205
822
|
|
|
1206
823
|
return rows.map((row) => {
|
|
1207
824
|
const c: PlaceCandidate = {
|
|
@@ -1223,102 +840,6 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1223
840
|
})
|
|
1224
841
|
}
|
|
1225
842
|
|
|
1226
|
-
/**
|
|
1227
|
-
* Among `ids`, return the subset whose name OR any alias equals `text` case-insensitively — the exact-match tier for
|
|
1228
|
-
* ranking. One indexed query over `<schema>.names`. When the shard has no `names` table (a slim DB built with
|
|
1229
|
-
* `dropNames`, or a postcode-only shard), fall back to the self-contained `place_search` FTS content: its `alt_names`
|
|
1230
|
-
* column is the same alias set joined on the boundary-preserving `ALIAS_SEPARATOR` (#523), so `aliasBagExactMatch`
|
|
1231
|
-
* recovers the exact alias tier ("New York City" → New York) that the dropped `names` table used to provide.
|
|
1232
|
-
*/
|
|
1233
|
-
#exactMatchIds(schemaName: string, ids: number[], text: string): Set<number> {
|
|
1234
|
-
const out = new Set<number>()
|
|
1235
|
-
const trimmed = text.trim()
|
|
1236
|
-
|
|
1237
|
-
if (!ids.length || !trimmed) return out
|
|
1238
|
-
const placeholders = ids.map(() => "?").join(", ")
|
|
1239
|
-
|
|
1240
|
-
try {
|
|
1241
|
-
const rows = this.#db
|
|
1242
|
-
.prepare(
|
|
1243
|
-
`SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND name = ? COLLATE NOCASE`
|
|
1244
|
-
)
|
|
1245
|
-
.all(...ids, trimmed) as Array<{ id: number }>
|
|
1246
|
-
|
|
1247
|
-
for (const r of rows) {
|
|
1248
|
-
out.add(r.id)
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
return out
|
|
1252
|
-
} catch {
|
|
1253
|
-
// No `names` table on this shard — fall through to the place_search alias bag.
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
try {
|
|
1257
|
-
const rows = this.#db
|
|
1258
|
-
.prepare(
|
|
1259
|
-
`SELECT wof_id AS id, name, alt_names FROM ${schemaName}.place_search WHERE wof_id IN (${placeholders})`
|
|
1260
|
-
)
|
|
1261
|
-
.all(...ids) as Array<{ id: number; name: string | null; alt_names: string | null }>
|
|
1262
|
-
|
|
1263
|
-
const norm = (s: string): string => s.toLowerCase().trim().replaceAll(/\s+/g, " ")
|
|
1264
|
-
const needle = norm(trimmed)
|
|
1265
|
-
|
|
1266
|
-
for (const r of rows) {
|
|
1267
|
-
if (r.name !== null && norm(r.name) === needle) {
|
|
1268
|
-
out.add(r.id)
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
// Alias pass via the shared bag parser (#523). Separated bags (built since #523) get a true
|
|
1273
|
-
// per-alias equality check, ungated — matching the `names`-table branch above, where an
|
|
1274
|
-
// alias match counts as exact regardless of other candidates. Legacy bags (no separator)
|
|
1275
|
-
// fall back to padded containment, gated on "no canonical exact in the pool" because their
|
|
1276
|
-
// lost boundaries would otherwise false-promote interior fragments ("York" inside the alias
|
|
1277
|
-
// "New York City") or cross-alias fragments ("York New" across "…York" + "New City…").
|
|
1278
|
-
const anyCanonicalExact = out.size > 0
|
|
1279
|
-
|
|
1280
|
-
for (const r of rows) {
|
|
1281
|
-
if (aliasBagExactMatch(r.alt_names, needle, anyCanonicalExact)) {
|
|
1282
|
-
out.add(r.id)
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
} catch {
|
|
1286
|
-
// Shard without place_search either → no exact-match tier. Falls back to weighted-sum order.
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
return out
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
/**
|
|
1293
|
-
* Among `ids` (already known exact matches), the subset holding `text` as an OFFICIAL name (`names.official = 1`, the
|
|
1294
|
-
* #940 ingest bit). Same COLLATE NOCASE semantics as {@link WOFSqlitePlaceLookup.#exactMatchIds} so the two probes
|
|
1295
|
-
* agree on what "equals the query" means. Fails soft on gazetteers built before #940 (no `official` column) — the
|
|
1296
|
-
* sub-tier then behaves exactly as if `officialNameExact` were off.
|
|
1297
|
-
*/
|
|
1298
|
-
#officialNameIds(schemaName: string, ids: number[], text: string): Set<number> {
|
|
1299
|
-
const out = new Set<number>()
|
|
1300
|
-
const trimmed = text.trim()
|
|
1301
|
-
|
|
1302
|
-
if (!ids.length || !trimmed) return out
|
|
1303
|
-
const placeholders = ids.map(() => "?").join(", ")
|
|
1304
|
-
|
|
1305
|
-
try {
|
|
1306
|
-
const rows = this.#db
|
|
1307
|
-
.prepare(
|
|
1308
|
-
`SELECT DISTINCT id FROM ${schemaName}.names WHERE id IN (${placeholders}) AND official = 1 AND name = ? COLLATE NOCASE`
|
|
1309
|
-
)
|
|
1310
|
-
.all(...ids, trimmed) as Array<{ id: number }>
|
|
1311
|
-
|
|
1312
|
-
for (const r of rows) {
|
|
1313
|
-
out.add(r.id)
|
|
1314
|
-
}
|
|
1315
|
-
} catch {
|
|
1316
|
-
// Pre-#940 gazetteer (no `official` column) or a names-less slim shard — feature inert.
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
return out
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
843
|
close(): void {
|
|
1323
844
|
// Destroying the Kysely instance closes the underlying connection IF we own it. If the caller
|
|
1324
845
|
// passed in a pre-opened DatabaseSync (test fixture), respect their ownership.
|
|
@@ -1343,12 +864,10 @@ export class WOFSqlitePlaceLookup implements PlaceLookup, Disposable {
|
|
|
1343
864
|
#assertFTSExists(): void {
|
|
1344
865
|
if (!placeSearchFTSExists(this.#db)) {
|
|
1345
866
|
throw new Error(
|
|
1346
|
-
"
|
|
867
|
+
"WOFSQLitePlaceLookup: `place_search` FTS5 table is missing. Pass `buildFTS: true` to build it on open, or run `mailwoman gazetteer build fts <path-to-wof.db>` ahead of time (see resolver-wof-sqlite/README.md)."
|
|
1347
868
|
)
|
|
1348
869
|
}
|
|
1349
870
|
}
|
|
1350
871
|
}
|
|
1351
872
|
|
|
1352
|
-
export { trigramJaccard, trigrams } from "./name-score.ts"
|
|
1353
|
-
|
|
1354
873
|
export type { RankingWeights } from "./ranking-weights.ts"
|