@mailwoman/resolver-wof-sqlite 9.0.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 +287 -157
- 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 +228 -0
- package/candidate-lookup.ts +564 -174
- package/candidate-schema.ts +60 -3
- 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 +105 -122
- package/fst-builder.ts +39 -47
- package/fst-deserialize-web.ts +43 -7
- package/fst-freshness.ts +2 -2
- package/fst-serialize.ts +68 -12
- package/fst-types.ts +35 -1
- package/fts-query.ts +1 -1
- package/fts.ts +16 -4
- package/geonames-postal.ts +2 -2
- package/index.ts +26 -14
- package/interpolation.ts +113 -19
- package/lookup.ts +118 -560
- 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 +108 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +151 -120
- 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 +132 -0
- package/out/candidate-importance.d.ts.map +1 -0
- package/out/candidate-importance.js +174 -0
- package/out/candidate-importance.js.map +1 -0
- package/out/candidate-lookup.d.ts +22 -37
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +446 -132
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +52 -4
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +8 -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 +24 -14
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +84 -100
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +32 -40
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +36 -7
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-freshness.d.ts +2 -2
- package/out/fst-freshness.js +2 -2
- package/out/fst-serialize.d.ts +14 -4
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +60 -12
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +35 -1
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts-query.js +1 -1
- package/out/fts-query.js.map +1 -1
- package/out/fts.d.ts +15 -4
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +15 -4
- package/out/fts.js.map +1 -1
- package/out/geonames-postal.d.ts +2 -2
- package/out/geonames-postal.js +2 -2
- package/out/index.d.ts +4 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -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 +102 -444
- 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 +226 -0
- package/out/place-importance-schema.d.ts.map +1 -0
- package/out/place-importance-schema.js +288 -0
- package/out/place-importance-schema.js.map +1 -0
- 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 +31 -1
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +38 -0
- 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-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +5 -4
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-normalize.d.ts +83 -9
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +177 -10
- 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 +74 -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 +296 -16
- package/place-importance-schema.ts +402 -0
- 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 +63 -1
- package/street-centroid-schema.ts +8 -2
- package/street-centroid.ts +13 -8
- package/street-morphology-fst-builder.ts +5 -4
- package/street-normalize.ts +254 -24
- package/street-segment-schema.ts +7 -2
- package/types.ts +74 -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/search-fetch.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The FTS5 candidate fetch behind the fuzzy name match: the schema-qualified `place_search`
|
|
7
|
+
* MATCH, its population-ordered companion fetch, and the raw row shape both of them return.
|
|
8
|
+
*
|
|
9
|
+
* Bbox and near-with-radius narrow at the SQL level through SQLite's built-in `rtree`, whose index name and schema
|
|
10
|
+
* live in `fts.ts` beside the FTS5 build. That is why this package pulls neither SpatiaLite nor turf: the R*Tree does
|
|
11
|
+
* the narrowing, and the passes downstream of it operate on ≤ a few hundred candidates per query rather than the
|
|
12
|
+
* whole corpus, so an exact haversine over the survivors is cheap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DatabaseSync, SQLInputValue } from "node:sqlite"
|
|
16
|
+
|
|
17
|
+
import { bboxAround } from "@mailwoman/spatial"
|
|
18
|
+
|
|
19
|
+
import { PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE } from "./fts.ts"
|
|
20
|
+
import type { RankingWeights } from "./ranking-weights.ts"
|
|
21
|
+
import { allRows } from "./sqlite-utils.ts"
|
|
22
|
+
import type { FindPlaceQuery, WOFPlacetype } from "./types.ts"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Query length at or below which the FTS window is widened. A two- or three-character query is almost always a region
|
|
26
|
+
* abbreviation, where the exact match can otherwise fall outside the window behind higher-bm25 partial hits — "NY"
|
|
27
|
+
* losing to "New York".
|
|
28
|
+
*/
|
|
29
|
+
const SHORT_QUERY_MAX_LENGTH = 3
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Over-fetch floor for SHORT (≤3-char) queries — region abbreviations like "NY"/"VT". An exact-abbrev holder's BM25 is
|
|
33
|
+
* poor (long multilingual alt-name document), so the normal `limit * 4` window can drop it before `exactMatchTiering`
|
|
34
|
+
* promotes it. 200 comfortably covers every same-abbrev region across the 12-country gazetteer (a 2-letter token
|
|
35
|
+
* matches a few dozen regions at most) while staying a cheap region-placetype fetch. See the `#fuzzyNameMatch`
|
|
36
|
+
* over-fetch comment.
|
|
37
|
+
*/
|
|
38
|
+
const SHORT_QUERY_OVERFETCH = 200
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How many rows the population-ordered companion fetch (#905) adds to the candidate pool. Small on purpose: its only
|
|
42
|
+
* job is to guarantee the FAMOUS holders of a name enter the pool at all — for "Paris"-class floods the bm25 window is
|
|
43
|
+
* saturated by thousands of tiny same-name rows and no boost inside the bm25-based ORDER BY can rescue a candidate
|
|
44
|
+
* whose bm25 is length-poisoned by ~15 points (see the fetch-site comment).
|
|
45
|
+
*/
|
|
46
|
+
const POPULATION_FETCH_LIMIT = 15
|
|
47
|
+
|
|
48
|
+
export interface RawSearchRow {
|
|
49
|
+
id: number
|
|
50
|
+
name: string
|
|
51
|
+
placetype: string
|
|
52
|
+
country: string | null
|
|
53
|
+
parent_id: number | null
|
|
54
|
+
rank: number // BM25 (lower = better in SQLite); we negate to get higher-is-better
|
|
55
|
+
lat: number | null
|
|
56
|
+
lon: number | null
|
|
57
|
+
min_latitude: number | null
|
|
58
|
+
max_latitude: number | null
|
|
59
|
+
min_longitude: number | null
|
|
60
|
+
max_longitude: number | null
|
|
61
|
+
population: number | null // from the place_population aux table; null when missing
|
|
62
|
+
/**
|
|
63
|
+
* From `place_importance.encyclopedic` when the shard's table carries the two-score split columns. NULL means the
|
|
64
|
+
* place has no Wikipedia article, or the shard predates the split — absence either way, and never 0 (ROAD_TO_V9 §2).
|
|
65
|
+
*/
|
|
66
|
+
encyclopedic: number | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fetch the raw candidate rows for a name match on one shard: the BM25-ordered window over `place_search` (widened for
|
|
71
|
+
* short queries), plus the population-ordered companion fetch that keeps the prominent holders of a name pool-complete.
|
|
72
|
+
* `schemaName` is the routed shard's bare schema name — validated at construction, so it is interpolated directly.
|
|
73
|
+
*/
|
|
74
|
+
export function fetchSearchRows(options: {
|
|
75
|
+
db: DatabaseSync
|
|
76
|
+
schemaName: string
|
|
77
|
+
query: FindPlaceQuery
|
|
78
|
+
placetypes: WOFPlacetype[] | null
|
|
79
|
+
ftsQuery: string
|
|
80
|
+
limit: number
|
|
81
|
+
hasBboxIndex: ReadonlyMap<string, boolean>
|
|
82
|
+
hasPopulationIndex: ReadonlyMap<string, boolean>
|
|
83
|
+
encyclopedicClauses: ReadonlyMap<string, { select: string; join: string }>
|
|
84
|
+
weights: RankingWeights
|
|
85
|
+
}): RawSearchRow[] {
|
|
86
|
+
const {
|
|
87
|
+
db,
|
|
88
|
+
schemaName: sch,
|
|
89
|
+
query,
|
|
90
|
+
placetypes,
|
|
91
|
+
ftsQuery,
|
|
92
|
+
limit,
|
|
93
|
+
hasBboxIndex,
|
|
94
|
+
hasPopulationIndex,
|
|
95
|
+
encyclopedicClauses,
|
|
96
|
+
weights,
|
|
97
|
+
} = options
|
|
98
|
+
|
|
99
|
+
// Over-fetch so post-scoring + exact-match tiering have room to re-rank. SHORT queries (a 2–3-char
|
|
100
|
+
// region abbreviation like "NY"/"VT") are the danger case the `exactMatchTiering` docstring flags:
|
|
101
|
+
// the exact-abbrev holder's BM25 is poor (its long multilingual alt-name document tanks the score),
|
|
102
|
+
// so under the normal `limit * 4` window it drops OUT of the candidate pool BEFORE tiering can
|
|
103
|
+
// promote it — "NY" then resolves to a token-matching foreign region (Highland, GB) instead of New
|
|
104
|
+
// York. Widen the window for short queries so the exact match is always present to be tiered.
|
|
105
|
+
// (Cross-country abbrev collisions — "VT" is BOTH Vermont and Viterbo — still need a country/
|
|
106
|
+
// postcode signal to disambiguate; this only rescues the window-drop class, not genuine ambiguity.
|
|
107
|
+
// With a `country` hint every abbrev resolves; bare + no-context lifts 7→10/15 US states.)
|
|
108
|
+
const ftsLimit =
|
|
109
|
+
query.text.trim().length <= SHORT_QUERY_MAX_LENGTH ? Math.max(limit * 4, SHORT_QUERY_OVERFETCH) : limit * 4
|
|
110
|
+
|
|
111
|
+
// Filter out historical / superseded / deprecated places by default — they live in the same
|
|
112
|
+
// spr table but should never win a contemporary lookup. `is_current = 0` is the only WOF
|
|
113
|
+
// value that means "not current"; both `-1` (modern) and `1` (legacy) mean current. See #91.
|
|
114
|
+
// Note: with schema-qualified FROM the bare `place_search` reference in MATCH resolves to
|
|
115
|
+
// the FROM table — required by FTS5 parser, see sharding.ts header comment.
|
|
116
|
+
const where: string[] = ["place_search MATCH ?", "spr.is_current != 0", "spr.is_deprecated = 0"]
|
|
117
|
+
const params: SQLInputValue[] = [ftsQuery]
|
|
118
|
+
|
|
119
|
+
if (placetypes && placetypes.length) {
|
|
120
|
+
where.push(`spr.placetype IN (${placetypes.map(() => "?").join(", ")})`)
|
|
121
|
+
params.push(...placetypes)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (query.country) {
|
|
125
|
+
where.push("spr.country = ?")
|
|
126
|
+
params.push(query.country)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (query.parentID !== undefined) {
|
|
130
|
+
where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`)
|
|
131
|
+
params.push(query.parentID, query.parentID)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Bbox + near-with-radius are SQL-level filters via the R*Tree. We only emit the JOIN when
|
|
135
|
+
// the active shard has the R*Tree; missing-but-requested is silently treated as no-bbox-
|
|
136
|
+
// filter so legacy DBs / shards-without-bbox don't crash.
|
|
137
|
+
const shardHasBbox = hasBboxIndex.get(sch) === true
|
|
138
|
+
const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && shardHasBbox
|
|
139
|
+
let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`
|
|
140
|
+
|
|
141
|
+
if (useBboxJoin) {
|
|
142
|
+
joinClause += ` JOIN ${sch}.${PLACE_BBOX_TABLE} bbox ON bbox.id = spr.id`
|
|
143
|
+
// AABB intersection — both bbox sides must overlap. R*Tree handles this in O(log n).
|
|
144
|
+
const filterBox = query.bbox || bboxAround(query.near!.lat, query.near!.lon, query.near!.maxDistanceKm!)
|
|
145
|
+
where.push("bbox.min_lat <= ? AND bbox.max_lat >= ?", "bbox.min_lon <= ? AND bbox.max_lon >= ?")
|
|
146
|
+
params.push(filterBox.maxLat, filterBox.minLat, filterBox.maxLon, filterBox.minLon)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// LEFT JOIN the population aux table when present. Missing-on-this-shard means the SELECT
|
|
150
|
+
// just doesn't include the population column; the post-scoring loop treats it as 0.
|
|
151
|
+
const shardHasPopulation = hasPopulationIndex.get(sch) === true
|
|
152
|
+
|
|
153
|
+
const populationSelect = shardHasPopulation
|
|
154
|
+
? `${PLACE_POPULATION_TABLE}.population AS population`
|
|
155
|
+
: `NULL AS population`
|
|
156
|
+
|
|
157
|
+
const populationJoin = shardHasPopulation
|
|
158
|
+
? `LEFT JOIN ${sch}.${PLACE_POPULATION_TABLE} ON ${PLACE_POPULATION_TABLE}.id = spr.id`
|
|
159
|
+
: ""
|
|
160
|
+
|
|
161
|
+
// The encyclopedic score is CARRIED, never ranked on (ROAD_TO_V9 §2, ratified 2026-08-06) — it
|
|
162
|
+
// appears in the SELECT and in no ORDER BY, here or in the companion fetch below. Gated on the
|
|
163
|
+
// split column, so a pre-split shard emits a literal NULL and builds no join at all.
|
|
164
|
+
const { select: encyclopedicSelect, join: encyclopedicJoin } = encyclopedicClauses.get(sch)!
|
|
165
|
+
|
|
166
|
+
// Push the population boost into the ORDER BY when the index is available, so famous places
|
|
167
|
+
// (whose long alt-name lists hurt BM25) actually make it into the over-fetch window. The TS
|
|
168
|
+
// post-scoring will still compute the same boost for the final score; this just ensures the
|
|
169
|
+
// candidate set is right.
|
|
170
|
+
//
|
|
171
|
+
// Formula: rank_adjusted = bm25 - populationBoost * min(1.0, log10(1 + pop) / scaleLog10)
|
|
172
|
+
// Lower rank_adjusted = better (matches SQLite's bm25 convention of "more negative = better").
|
|
173
|
+
//
|
|
174
|
+
// #905 — do NOT reach for bm25 column weights here. Measured falsification (2026-07-02): FTS5's
|
|
175
|
+
// bm25 length normalization is polluted by the row's TOTAL document size, so identical 1-token
|
|
176
|
+
// `name` docs read −16.0 (empty alt_names) vs −0.43 (2.7 KB alt_names) EVEN with the alt_names
|
|
177
|
+
// column weighted to zero — no weighting isolates name relevance in this schema. The famous-
|
|
178
|
+
// holder guarantee lives in the population-ordered companion fetch below instead, and the
|
|
179
|
+
// exact tier breaks ties by population in the post-scoring sort.
|
|
180
|
+
const orderByExpr = shardHasPopulation
|
|
181
|
+
? `(bm25(place_search) - ? * MIN(1.0, COALESCE(log10(1.0 + ${PLACE_POPULATION_TABLE}.population), 0) / ?))`
|
|
182
|
+
: "bm25(place_search)"
|
|
183
|
+
|
|
184
|
+
// Schema-qualified FROM with bare-name MATCH — required syntax for FTS5 on attached schemas.
|
|
185
|
+
// See sharding.ts header for the gotcha that drove this design.
|
|
186
|
+
const stmt = db.prepare(`
|
|
187
|
+
SELECT
|
|
188
|
+
spr.id AS id,
|
|
189
|
+
spr.name,
|
|
190
|
+
spr.placetype,
|
|
191
|
+
spr.country,
|
|
192
|
+
spr.parent_id,
|
|
193
|
+
bm25(place_search) AS rank,
|
|
194
|
+
spr.latitude AS lat,
|
|
195
|
+
spr.longitude AS lon,
|
|
196
|
+
spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
|
|
197
|
+
${populationSelect},
|
|
198
|
+
${encyclopedicSelect}
|
|
199
|
+
FROM ${sch}.place_search
|
|
200
|
+
${joinClause}
|
|
201
|
+
${populationJoin}
|
|
202
|
+
${encyclopedicJoin}
|
|
203
|
+
WHERE ${where.join(" AND ")}
|
|
204
|
+
ORDER BY ${orderByExpr} ASC
|
|
205
|
+
LIMIT ?
|
|
206
|
+
`)
|
|
207
|
+
|
|
208
|
+
if (shardHasPopulation) {
|
|
209
|
+
params.push(weights.populationBoost, weights.populationScaleLog10)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
params.push(ftsLimit)
|
|
213
|
+
|
|
214
|
+
const rawRows = allRows<RawSearchRow>(stmt, ...params)
|
|
215
|
+
|
|
216
|
+
// #905 companion fetch: the same MATCH, ordered by population alone. For name floods
|
|
217
|
+
// ("Paris" matches thousands of gap-fill villages) the bm25-based window above cannot admit
|
|
218
|
+
// the famous holder — its bm25 is length-poisoned by the row's alias bulk (measured ~15 pts,
|
|
219
|
+
// vs a +4.0 boost cap), so FR Paris never even reaches post-scoring. This fetch makes the
|
|
220
|
+
// prominent holders of a name pool-complete BY CONSTRUCTION; the exact-tier sort below
|
|
221
|
+
// decides whether they win. Skipped without a population index (nothing to order by).
|
|
222
|
+
if (shardHasPopulation) {
|
|
223
|
+
const popStmt = db.prepare(`
|
|
224
|
+
SELECT
|
|
225
|
+
spr.id AS id,
|
|
226
|
+
spr.name,
|
|
227
|
+
spr.placetype,
|
|
228
|
+
spr.country,
|
|
229
|
+
spr.parent_id,
|
|
230
|
+
bm25(place_search) AS rank,
|
|
231
|
+
spr.latitude AS lat,
|
|
232
|
+
spr.longitude AS lon,
|
|
233
|
+
spr.min_latitude, spr.max_latitude, spr.min_longitude, spr.max_longitude,
|
|
234
|
+
${populationSelect},
|
|
235
|
+
${encyclopedicSelect}
|
|
236
|
+
FROM ${sch}.place_search
|
|
237
|
+
${joinClause}
|
|
238
|
+
${populationJoin}
|
|
239
|
+
${encyclopedicJoin}
|
|
240
|
+
WHERE ${where.join(" AND ")}
|
|
241
|
+
ORDER BY COALESCE(${PLACE_POPULATION_TABLE}.population, 0) DESC
|
|
242
|
+
LIMIT ?
|
|
243
|
+
`)
|
|
244
|
+
|
|
245
|
+
const popParams = params.slice(0, -3) // drop the two boost params + ftsLimit
|
|
246
|
+
const seen = new Set(rawRows.map((r) => r.id))
|
|
247
|
+
|
|
248
|
+
for (const row of allRows<RawSearchRow>(popStmt, ...popParams, POPULATION_FETCH_LIMIT)) {
|
|
249
|
+
if (!seen.has(row.id)) {
|
|
250
|
+
rawRows.push(row)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return rawRows
|
|
256
|
+
}
|
package/sharding.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* Multi-shard support for `
|
|
6
|
+
* Multi-shard support for `WOFSQLitePlaceLookup` — opens multiple WOF SQLite distributions on one
|
|
7
7
|
* connection via `ATTACH DATABASE`, and routes queries to the right shard based on placetype.
|
|
8
8
|
*
|
|
9
9
|
* ## The FTS5 syntax rule that drove this design
|
|
@@ -78,7 +78,7 @@ export interface ShardConfig {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Resolved post-derivation: paired path + chosen schema name + (possibly empty) placetypes hint. Used internally by
|
|
81
|
-
* `
|
|
81
|
+
* `WOFSQLitePlaceLookup` so the routing logic operates on uniform structures.
|
|
82
82
|
*/
|
|
83
83
|
export interface ResolvedShard {
|
|
84
84
|
path: string
|
|
@@ -200,7 +200,7 @@ export function pickShardForPlacetype(
|
|
|
200
200
|
*/
|
|
201
201
|
country?: string
|
|
202
202
|
/**
|
|
203
|
-
* Per-schema probed country sets (see `
|
|
203
|
+
* Per-schema probed country sets (see `WOFSQLitePlaceLookup`'s construction probe).
|
|
204
204
|
*/
|
|
205
205
|
countriesBySchema?: ReadonlyMap<string, ReadonlySet<string>>
|
|
206
206
|
}
|
|
@@ -32,7 +32,7 @@ export class SqliteConventionSource implements ConventionSource {
|
|
|
32
32
|
/**
|
|
33
33
|
* @param db An open handle to a DB that has the convention asset attached (or is it).
|
|
34
34
|
* @param schema The schema name the `address_convention` table lives under (`main` or an ATTACHed shard name —
|
|
35
|
-
* `
|
|
35
|
+
* `WOFSQLitePlaceLookup` auto-detects which shard carries the table).
|
|
36
36
|
*/
|
|
37
37
|
constructor(db: DatabaseSync, schema: string) {
|
|
38
38
|
this.#db = db
|
package/sqlite-utils.ts
CHANGED
|
@@ -6,7 +6,48 @@
|
|
|
6
6
|
* Small shared helpers for the SQLite-backed lookups.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { DatabaseSync } from "node:sqlite"
|
|
9
|
+
import type { DatabaseSync, SQLInputValue } from "node:sqlite"
|
|
10
|
+
|
|
11
|
+
import { allRows, getRow } from "@mailwoman/core/utils"
|
|
12
|
+
|
|
13
|
+
// The row-shape assertion itself lives in `core` so the readers that cannot depend on this package reach the same
|
|
14
|
+
// seam; re-exported here because this module is where this package's readers already look for it.
|
|
15
|
+
export { allRows, getRow } from "@mailwoman/core/utils"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A prepared single-row query whose parameter tuple remains visible to TypeScript. `StatementSync` accepts only the
|
|
19
|
+
* broad `SQLInputValue[]`, which otherwise erases tagged key types before they reach SQLite.
|
|
20
|
+
*/
|
|
21
|
+
export type PreparedGet<Parameters extends SQLInputValue[], Row> = (...parameters: Parameters) => Row | undefined
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Prepare a single-row query while preserving its exact parameter tuple at every call site.
|
|
25
|
+
*/
|
|
26
|
+
export function prepareGet<Parameters extends SQLInputValue[], Row>(
|
|
27
|
+
db: DatabaseSync,
|
|
28
|
+
sql: string
|
|
29
|
+
): PreparedGet<Parameters, Row> {
|
|
30
|
+
const statement = db.prepare(sql)
|
|
31
|
+
|
|
32
|
+
return (...parameters) => getRow<Row>(statement, ...parameters)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Multi-row counterpart to {@link PreparedGet}.
|
|
37
|
+
*/
|
|
38
|
+
export type PreparedAll<Parameters extends SQLInputValue[], Row> = (...parameters: Parameters) => Row[]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Prepare a multi-row query while preserving its exact parameter tuple at every call site.
|
|
42
|
+
*/
|
|
43
|
+
export function prepareAll<Parameters extends SQLInputValue[], Row>(
|
|
44
|
+
db: DatabaseSync,
|
|
45
|
+
sql: string
|
|
46
|
+
): PreparedAll<Parameters, Row> {
|
|
47
|
+
const statement = db.prepare(sql)
|
|
48
|
+
|
|
49
|
+
return (...parameters) => allRows<Row>(statement, ...parameters)
|
|
50
|
+
}
|
|
10
51
|
|
|
11
52
|
/**
|
|
12
53
|
* True when `name` is a table in the open database. The street-level lookups use this to degrade gracefully on an
|
|
@@ -23,3 +64,24 @@ export function hasTable(db: DatabaseSync, name: string): boolean {
|
|
|
23
64
|
return false
|
|
24
65
|
}
|
|
25
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when `table` exists in the open database AND carries `column`.
|
|
70
|
+
*
|
|
71
|
+
* The column-level sibling of {@link hasTable}, and it exists for the same reason one layer down: an artifact built
|
|
72
|
+
* before a column was added is still a VALID artifact, and a reader that unconditionally names the new column in its
|
|
73
|
+
* `SELECT` turns "this gazetteer is a build behind" into `no such column` at the first keystroke. Probe once at
|
|
74
|
+
* construction and shape the query — `table_info` is a PRAGMA, so it must not sit on a per-query path.
|
|
75
|
+
*
|
|
76
|
+
* Note the interpolation: PRAGMA does not take bound parameters, so `table` is spliced. Every caller passes a
|
|
77
|
+
* module-level constant; never pass user input.
|
|
78
|
+
*/
|
|
79
|
+
export function hasColumn(db: DatabaseSync, table: string, column: string): boolean {
|
|
80
|
+
try {
|
|
81
|
+
const rows = allRows<{ name: string }>(db.prepare(`PRAGMA table_info(${table})`))
|
|
82
|
+
|
|
83
|
+
return rows.some((r) => String(r.name) === column)
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
import type { Kysely } from "kysely"
|
|
24
24
|
|
|
25
|
+
import type { NameKey, StreetKey } from "./street-normalize.ts"
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* One street roll-up. `(street_norm, postcode, locality_base)` is unique. `lat`/`lon` are the UNWEIGHTED mean of the
|
|
27
29
|
* group's member address points (each source row = one point), so a cross-group weighted mean (`SUM(lat*point_count) /
|
|
@@ -32,7 +34,7 @@ export interface StreetCentroidTable {
|
|
|
32
34
|
/**
|
|
33
35
|
* Shared `normalizeStreetForKeyLocale` of the street — the build/query-consistent probe key.
|
|
34
36
|
*/
|
|
35
|
-
street_norm:
|
|
37
|
+
street_norm: StreetKey
|
|
36
38
|
/**
|
|
37
39
|
* The 5-digit postcode of this group, or null when the source row carried none.
|
|
38
40
|
*/
|
|
@@ -40,7 +42,7 @@ export interface StreetCentroidTable {
|
|
|
40
42
|
/**
|
|
41
43
|
* Arrondissement-stripped commune (`stripArrondissement(normalizeLocalityForKey(commune))`) — the fallback scope.
|
|
42
44
|
*/
|
|
43
|
-
locality_base:
|
|
45
|
+
locality_base: NameKey
|
|
44
46
|
/**
|
|
45
47
|
* Weighted-mean centroid latitude of the street's member points.
|
|
46
48
|
*/
|
|
@@ -75,6 +77,10 @@ export interface StreetCentroidTable {
|
|
|
75
77
|
* name-evidence rerank folds the model's street surface with the SAME `foldStreetSurface` used to build this column
|
|
76
78
|
* (the fold-parity contract), so it must not drift from `street_norm`'s richer normalizer. Indexed (`idx_sc_name`)
|
|
77
79
|
* for a direct seek.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately UNBRANDED despite the `name_key` column name: `foldStreetSurface` is a different fold from the
|
|
82
|
+
* {@link NameKey} one every other `name_key` column carries, and giving it that brand would invite exactly the
|
|
83
|
+
* cross-fold probe the brands exist to stop.
|
|
78
84
|
*/
|
|
79
85
|
name_key: string
|
|
80
86
|
}
|
package/street-centroid.ts
CHANGED
|
@@ -23,10 +23,13 @@ import { DatabaseSync } from "node:sqlite"
|
|
|
23
23
|
|
|
24
24
|
import type { StreetCentroidHit, StreetCentroidLookup } from "@mailwoman/resolver"
|
|
25
25
|
|
|
26
|
-
import { hasTable } from "./sqlite-utils.ts"
|
|
26
|
+
import { hasTable, prepareGet, type PreparedGet } from "./sqlite-utils.ts"
|
|
27
27
|
import {
|
|
28
28
|
normalizeLocalityForKey,
|
|
29
29
|
normalizeStreetForKeyLocale,
|
|
30
|
+
type NameKey,
|
|
31
|
+
type StreetKey,
|
|
32
|
+
streetLocaleForSurface,
|
|
30
33
|
type StreetLocale,
|
|
31
34
|
stripArrondissement,
|
|
32
35
|
} from "./street-normalize.ts"
|
|
@@ -72,8 +75,8 @@ function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: n
|
|
|
72
75
|
export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
|
|
73
76
|
readonly #db: DatabaseSync
|
|
74
77
|
readonly #locale: StreetLocale
|
|
75
|
-
readonly #byPostcode:
|
|
76
|
-
readonly #byLocality:
|
|
78
|
+
readonly #byPostcode: PreparedGet<[postcode: string, street: StreetKey], AggRow> | undefined
|
|
79
|
+
readonly #byLocality: PreparedGet<[locality: NameKey, street: StreetKey], AggRow> | undefined
|
|
77
80
|
|
|
78
81
|
/**
|
|
79
82
|
* @param dbPath Shard path.
|
|
@@ -87,11 +90,13 @@ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
|
|
|
87
90
|
// Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
|
|
88
91
|
// `street_centroid` table this lookup is a no-op miss, not a crash (mirrors the address-point reader).
|
|
89
92
|
if (hasTable(this.#db, "street_centroid")) {
|
|
90
|
-
this.#byPostcode =
|
|
93
|
+
this.#byPostcode = prepareGet(
|
|
94
|
+
this.#db,
|
|
91
95
|
`SELECT ${AGG_SELECT} FROM street_centroid WHERE postcode = ? AND street_norm = ?`
|
|
92
96
|
)
|
|
93
97
|
|
|
94
|
-
this.#byLocality =
|
|
98
|
+
this.#byLocality = prepareGet(
|
|
99
|
+
this.#db,
|
|
95
100
|
`SELECT ${AGG_SELECT} FROM street_centroid WHERE locality_base = ? AND street_norm = ?`
|
|
96
101
|
)
|
|
97
102
|
}
|
|
@@ -99,19 +104,19 @@ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
|
|
|
99
104
|
|
|
100
105
|
find(query: { street: string; postcode?: string; locality?: string }): StreetCentroidHit | null {
|
|
101
106
|
if (!this.#byPostcode || !this.#byLocality) return null
|
|
102
|
-
const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
|
|
107
|
+
const streetNorm = normalizeStreetForKeyLocale(query.street, streetLocaleForSurface(query.street, this.#locale))
|
|
103
108
|
|
|
104
109
|
if (!streetNorm) return null
|
|
105
110
|
|
|
106
111
|
let row: AggRow | undefined
|
|
107
112
|
|
|
108
113
|
if (query.postcode?.trim()) {
|
|
109
|
-
row = this.#byPostcode
|
|
114
|
+
row = this.#byPostcode(query.postcode.trim(), streetNorm)
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
if ((!row || row.lat == null) && query.locality?.trim()) {
|
|
113
118
|
const base = stripArrondissement(normalizeLocalityForKey(query.locality))
|
|
114
|
-
row = this.#byLocality
|
|
119
|
+
row = this.#byLocality(base, streetNorm)
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
if (!row || row.lat == null || row.lon == null) return null
|
|
@@ -196,10 +196,11 @@ export function buildStreetMorphologyFST(opts: BuildStreetMorphologyFSTOpts): Bu
|
|
|
196
196
|
placetype: "street_affix",
|
|
197
197
|
name: canonical,
|
|
198
198
|
parentChain: [],
|
|
199
|
-
// Fixed
|
|
200
|
-
// anything but street-typing). The morphology prior caps bias separately; this value
|
|
201
|
-
// just feeds the cap formula `
|
|
202
|
-
|
|
199
|
+
// Fixed referential score: street affixes are structurally unambiguous (Avenue is almost
|
|
200
|
+
// never anything but street-typing). The morphology prior caps bias separately; this value
|
|
201
|
+
// just feeds the cap formula `referential * cap`. No encyclopedic field — a street affix is
|
|
202
|
+
// not a place and has no article; absence here is the correct statement.
|
|
203
|
+
referential: 1,
|
|
203
204
|
lat: 0,
|
|
204
205
|
lon: 0,
|
|
205
206
|
}
|