@mailwoman/resolver-wof-sqlite 9.1.0 → 9.3.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 +36 -20
- package/lib/address/index.ts +9 -0
- package/{address-point-interpolation.ts → lib/address/point-interpolation.ts} +40 -28
- package/{address-point-schema.ts → lib/address/point-schema.ts} +34 -8
- package/lib/address/point.ts +280 -0
- package/{ancestry-backfill.ts → lib/ancestry/backfill.ts} +14 -19
- package/{ancestry.ts → lib/ancestry/index.ts} +13 -8
- package/{build-candidate.ts → lib/build-candidate.ts} +243 -199
- package/{build-slim.ts → lib/build-slim.ts} +153 -168
- package/lib/candidate/alias-bags.ts +56 -0
- package/lib/candidate/ancestors-sidecar.ts +204 -0
- package/lib/candidate/country-display-names.ts +79 -0
- package/lib/candidate/extract-fold.ts +138 -0
- package/lib/candidate/name-roles.ts +238 -0
- package/lib/candidate/own-name.ts +146 -0
- package/lib/candidate/place-attrs.ts +44 -0
- package/lib/candidate-ancestors-schema.ts +195 -0
- package/{candidate-fts.ts → lib/candidate-fts.ts} +7 -5
- package/{candidate-importance.ts → lib/candidate-importance.ts} +54 -57
- package/lib/candidate-lookup.ts +976 -0
- package/{candidate-schema.ts → lib/candidate-schema.ts} +34 -6
- package/lib/candidate-scoring.ts +261 -0
- package/lib/capital-schema.ts +89 -0
- package/lib/capitals.ts +148 -0
- package/{coincident-roles.ts → lib/coincident-roles.ts} +75 -19
- package/{convention.ts → lib/convention/index.ts} +5 -5
- package/lib/convention/schema.ts +72 -0
- package/{coverage-manifest-schema.ts → lib/coverage-manifest-schema.ts} +15 -16
- package/lib/currency-backfill.ts +334 -0
- package/lib/env.ts +50 -0
- package/lib/exact-match.ts +113 -0
- package/{sharding.ts → lib/extracts.ts} +63 -60
- package/lib/fst/autocomplete.ts +176 -0
- package/{fst-builder.ts → lib/fst/builder.ts} +30 -27
- package/{fst-deserialize-web.ts → lib/fst/deserialize-web.ts} +27 -87
- package/lib/fst/format.ts +106 -0
- package/{fst-freshness.ts → lib/fst/freshness.ts} +42 -75
- package/lib/fst/index.ts +14 -0
- package/{fst-matcher.ts → lib/fst/matcher.ts} +1 -1
- package/{fst-serialize.ts → lib/fst/serialize.ts} +37 -115
- package/{fst-types.ts → lib/fst/types.ts} +4 -2
- package/{fts.ts → lib/fts/index.ts} +28 -23
- package/{fts-query.ts → lib/fts/query.ts} +2 -2
- package/{geonames-aliases.ts → lib/geonames/aliases.ts} +13 -13
- package/lib/geonames/index.ts +8 -0
- package/{geonames-postal.ts → lib/geonames/postal.ts} +16 -15
- package/{index.ts → lib/index.ts} +52 -48
- package/{interpolation.ts → lib/interpolation.ts} +137 -41
- package/lib/lookup.ts +861 -0
- package/{name-score.ts → lib/name-score.ts} +6 -4
- package/lib/nsul/index.ts +8 -0
- package/lib/nsul/lookup.ts +144 -0
- package/lib/nsul/schema.ts +130 -0
- package/{place-importance-schema.ts → lib/place-importance-schema.ts} +70 -22
- package/lib/poi/index.ts +8 -0
- package/{poi-lookup.ts → lib/poi/lookup.ts} +32 -39
- package/{poi-schema.ts → lib/poi/schema.ts} +10 -6
- package/lib/polygon-schema.ts +47 -0
- package/{postal-city-alias-lookup.ts → lib/postal/city-alias-lookup.ts} +15 -21
- package/{postal-city-candidate-schema.ts → lib/postal/city-candidate-schema.ts} +4 -2
- package/lib/postal/index.ts +9 -0
- package/{postcode-point-lookup.ts → lib/postcode-point-lookup.ts} +17 -15
- package/lib/primary-preference.ts +226 -0
- package/lib/proximity-rerank.ts +120 -0
- package/{ranking-weights.ts → lib/ranking-weights.ts} +30 -4
- package/lib/region-keys.ts +144 -0
- package/{reverse.ts → lib/reverse.ts} +45 -56
- package/{schema.ts → lib/schema.ts} +11 -1
- package/lib/search-fetch.ts +257 -0
- package/{sqlite-convention-source.ts → lib/sqlite-convention-source.ts} +9 -10
- package/lib/sqlite-utils.ts +83 -0
- package/{street-centroid-schema.ts → lib/street/centroid-schema.ts} +12 -6
- package/{street-centroid.ts → lib/street/centroid.ts} +26 -28
- package/lib/street/index.ts +13 -0
- package/{street-morphology-fst-builder.ts → lib/street/morphology-fst-builder.ts} +24 -14
- package/{street-morphology-fst-loader.ts → lib/street/morphology-fst-loader.ts} +14 -12
- package/{street-name-lookup.ts → lib/street/name-lookup.ts} +14 -30
- package/lib/street/normalize.ts +572 -0
- package/{street-segment-schema.ts → lib/street/segment-schema.ts} +13 -8
- package/{types.ts → lib/types.ts} +49 -9
- package/{unified-schema.ts → lib/unified-schema.ts} +21 -25
- package/lib/uprn/existence.ts +84 -0
- package/lib/uprn/index.ts +9 -0
- package/lib/uprn/lookup.ts +206 -0
- package/lib/uprn/schema.ts +124 -0
- package/lib/weights-overlay-linker.ts +878 -0
- package/out/address/index.d.ts +9 -0
- package/out/address/index.d.ts.map +1 -0
- package/out/address/index.js +9 -0
- package/out/address/index.js.map +1 -0
- package/out/{address-point-interpolation.d.ts → address/point-interpolation.d.ts} +11 -10
- package/out/address/point-interpolation.d.ts.map +1 -0
- package/out/{address-point-interpolation.js → address/point-interpolation.js} +29 -23
- package/out/address/point-interpolation.js.map +1 -0
- package/out/{address-point-schema.d.ts → address/point-schema.d.ts} +30 -10
- package/out/address/point-schema.d.ts.map +1 -0
- package/out/{address-point-schema.js → address/point-schema.js} +7 -3
- package/out/address/point-schema.js.map +1 -0
- package/out/{address-point.d.ts → address/point.d.ts} +16 -8
- package/out/address/point.d.ts.map +1 -0
- package/out/address/point.js +191 -0
- package/out/address/point.js.map +1 -0
- package/out/{ancestry-backfill.d.ts → ancestry/backfill.d.ts} +7 -5
- package/out/ancestry/backfill.d.ts.map +1 -0
- package/out/{ancestry-backfill.js → ancestry/backfill.js} +12 -16
- package/out/ancestry/backfill.js.map +1 -0
- package/out/{ancestry.d.ts → ancestry/index.d.ts} +6 -5
- package/out/ancestry/index.d.ts.map +1 -0
- package/out/{ancestry.js → ancestry/index.js} +7 -7
- package/out/ancestry/index.js.map +1 -0
- package/out/build-candidate.d.ts +88 -7
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +145 -149
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +5 -5
- package/out/build-slim.d.ts.map +1 -1
- package/out/build-slim.js +127 -142
- package/out/build-slim.js.map +1 -1
- package/out/candidate/alias-bags.d.ts +19 -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 +32 -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/extract-fold.d.ts +32 -0
- package/out/candidate/extract-fold.d.ts.map +1 -0
- package/out/candidate/extract-fold.js +104 -0
- package/out/candidate/extract-fold.js.map +1 -0
- package/out/candidate/name-roles.d.ts +57 -0
- package/out/candidate/name-roles.d.ts.map +1 -0
- package/out/candidate/name-roles.js +166 -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-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 +7 -5
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js +5 -3
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-importance.d.ts +16 -16
- package/out/candidate-importance.d.ts.map +1 -1
- package/out/candidate-importance.js +47 -52
- package/out/candidate-importance.js.map +1 -1
- package/out/candidate-lookup.d.ts +31 -52
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +380 -172
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +31 -7
- 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 +196 -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 +12 -4
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +43 -11
- package/out/coincident-roles.js.map +1 -1
- package/out/{convention.d.ts → convention/index.d.ts} +5 -5
- package/out/convention/index.d.ts.map +1 -0
- package/out/{convention.js → convention/index.js} +4 -4
- package/out/convention/index.js.map +1 -0
- 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/coverage-manifest-schema.d.ts +8 -8
- package/out/coverage-manifest-schema.d.ts.map +1 -1
- package/out/coverage-manifest-schema.js +6 -10
- package/out/coverage-manifest-schema.js.map +1 -1
- package/out/currency-backfill.d.ts +90 -0
- package/out/currency-backfill.d.ts.map +1 -0
- package/out/currency-backfill.js +196 -0
- package/out/currency-backfill.js.map +1 -0
- package/out/env.d.ts +43 -0
- package/out/env.d.ts.map +1 -0
- package/out/env.js +48 -0
- package/out/env.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 +88 -0
- package/out/exact-match.js.map +1 -0
- package/out/extracts.d.ts +114 -0
- package/out/extracts.d.ts.map +1 -0
- package/out/{sharding.js → extracts.js} +38 -38
- package/out/extracts.js.map +1 -0
- package/out/{fst-autocomplete.d.ts → fst/autocomplete.d.ts} +13 -13
- package/out/fst/autocomplete.d.ts.map +1 -0
- package/out/fst/autocomplete.js +113 -0
- package/out/fst/autocomplete.js.map +1 -0
- package/out/{fst-builder.d.ts → fst/builder.d.ts} +5 -5
- package/out/fst/builder.d.ts.map +1 -0
- package/out/{fst-builder.js → fst/builder.js} +24 -24
- package/out/fst/builder.js.map +1 -0
- package/out/{fst-deserialize-web.d.ts → fst/deserialize-web.d.ts} +3 -3
- package/out/fst/deserialize-web.d.ts.map +1 -0
- package/out/{fst-deserialize-web.js → fst/deserialize-web.js} +10 -74
- package/out/fst/deserialize-web.js.map +1 -0
- package/out/fst/format.d.ts +80 -0
- package/out/fst/format.d.ts.map +1 -0
- package/out/fst/format.js +91 -0
- package/out/fst/format.js.map +1 -0
- package/out/{fst-freshness.d.ts → fst/freshness.d.ts} +11 -14
- package/out/fst/freshness.d.ts.map +1 -0
- package/out/{fst-freshness.js → fst/freshness.js} +42 -74
- package/out/fst/freshness.js.map +1 -0
- package/out/fst/index.d.ts +14 -0
- package/out/fst/index.d.ts.map +1 -0
- package/out/fst/index.js +14 -0
- package/out/fst/index.js.map +1 -0
- package/out/{fst-matcher.d.ts → fst/matcher.d.ts} +2 -2
- package/out/fst/matcher.d.ts.map +1 -0
- package/out/{fst-matcher.js → fst/matcher.js} +1 -1
- package/out/fst/matcher.js.map +1 -0
- package/out/{fst-serialize.d.ts → fst/serialize.d.ts} +5 -10
- package/out/fst/serialize.d.ts.map +1 -0
- package/out/{fst-serialize.js → fst/serialize.js} +18 -98
- package/out/fst/serialize.js.map +1 -0
- package/out/{fst-types.d.ts → fst/types.d.ts} +4 -3
- package/out/fst/types.d.ts.map +1 -0
- package/out/{fst-types.js → fst/types.js} +1 -1
- package/out/fst/types.js.map +1 -0
- package/out/{fts.d.ts → fts/index.d.ts} +20 -13
- package/out/fts/index.d.ts.map +1 -0
- package/out/{fts.js → fts/index.js} +20 -15
- package/out/fts/index.js.map +1 -0
- package/out/{fts-query.d.ts → fts/query.d.ts} +2 -2
- package/out/fts/query.d.ts.map +1 -0
- package/out/{fts-query.js → fts/query.js} +2 -2
- package/out/fts/query.js.map +1 -0
- package/out/{geonames-aliases.d.ts → geonames/aliases.d.ts} +8 -6
- package/out/geonames/aliases.d.ts.map +1 -0
- package/out/{geonames-aliases.js → geonames/aliases.js} +7 -7
- package/out/geonames/aliases.js.map +1 -0
- package/out/geonames/index.d.ts +8 -0
- package/out/geonames/index.d.ts.map +1 -0
- package/out/geonames/index.js +8 -0
- package/out/geonames/index.js.map +1 -0
- package/out/{geonames-postal.d.ts → geonames/postal.d.ts} +12 -10
- package/out/geonames/postal.d.ts.map +1 -0
- package/out/{geonames-postal.js → geonames/postal.js} +11 -11
- package/out/geonames/postal.js.map +1 -0
- package/out/index.d.ts +32 -31
- package/out/index.d.ts.map +1 -1
- package/out/index.js +24 -24
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +18 -9
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +108 -36
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +25 -25
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +174 -558
- 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/nsul/index.d.ts +8 -0
- package/out/nsul/index.d.ts.map +1 -0
- package/out/nsul/index.js +8 -0
- package/out/nsul/index.js.map +1 -0
- package/out/nsul/lookup.d.ts +83 -0
- package/out/nsul/lookup.d.ts.map +1 -0
- package/out/nsul/lookup.js +80 -0
- package/out/nsul/lookup.js.map +1 -0
- package/out/nsul/schema.d.ts +100 -0
- package/out/nsul/schema.d.ts.map +1 -0
- package/out/nsul/schema.js +78 -0
- package/out/nsul/schema.js.map +1 -0
- package/out/place-importance-schema.d.ts +47 -10
- package/out/place-importance-schema.d.ts.map +1 -1
- package/out/place-importance-schema.js +56 -10
- package/out/place-importance-schema.js.map +1 -1
- package/out/poi/index.d.ts +8 -0
- package/out/poi/index.d.ts.map +1 -0
- package/out/poi/index.js +8 -0
- package/out/poi/index.js.map +1 -0
- package/out/{poi-lookup.d.ts → poi/lookup.d.ts} +8 -10
- package/out/poi/lookup.d.ts.map +1 -0
- package/out/{poi-lookup.js → poi/lookup.js} +24 -29
- package/out/poi/lookup.js.map +1 -0
- package/out/{poi-schema.d.ts → poi/schema.d.ts} +10 -6
- package/out/poi/schema.d.ts.map +1 -0
- package/out/{poi-schema.js → poi/schema.js} +1 -1
- package/out/poi/schema.js.map +1 -0
- 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 → postal/city-alias-lookup.d.ts} +7 -6
- package/out/postal/city-alias-lookup.d.ts.map +1 -0
- package/out/{postal-city-alias-lookup.js → postal/city-alias-lookup.js} +12 -16
- package/out/postal/city-alias-lookup.js.map +1 -0
- package/out/{postal-city-alias-schema.d.ts → postal/city-alias-schema.d.ts} +1 -1
- package/out/{postal-city-alias-schema.d.ts.map → postal/city-alias-schema.d.ts.map} +1 -1
- package/out/{postal-city-alias-schema.js → postal/city-alias-schema.js} +1 -1
- package/out/{postal-city-alias-schema.js.map → postal/city-alias-schema.js.map} +1 -1
- package/out/{postal-city-candidate-schema.d.ts → postal/city-candidate-schema.d.ts} +4 -3
- package/out/postal/city-candidate-schema.d.ts.map +1 -0
- package/out/{postal-city-candidate-schema.js → postal/city-candidate-schema.js} +2 -2
- package/out/{postal-city-candidate-schema.js.map → postal/city-candidate-schema.js.map} +1 -1
- package/out/postal/index.d.ts +9 -0
- package/out/postal/index.d.ts.map +1 -0
- package/out/postal/index.js +9 -0
- package/out/postal/index.js.map +1 -0
- package/out/postcode-point-lookup.d.ts +10 -10
- package/out/postcode-point-lookup.d.ts.map +1 -1
- package/out/postcode-point-lookup.js +13 -13
- package/out/postcode-point-lookup.js.map +1 -1
- package/out/primary-preference.d.ts +133 -0
- package/out/primary-preference.d.ts.map +1 -0
- package/out/primary-preference.js +146 -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/ranking-weights.d.ts +15 -3
- package/out/ranking-weights.d.ts.map +1 -1
- package/out/ranking-weights.js +21 -3
- package/out/ranking-weights.js.map +1 -1
- 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 +6 -6
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +23 -35
- package/out/reverse.js.map +1 -1
- package/out/schema.d.ts +10 -1
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js.map +1 -1
- package/out/search-fetch.d.ts +59 -0
- package/out/search-fetch.d.ts.map +1 -0
- package/out/search-fetch.js +184 -0
- package/out/search-fetch.js.map +1 -0
- package/out/sqlite-convention-source.d.ts +7 -7
- package/out/sqlite-convention-source.d.ts.map +1 -1
- package/out/sqlite-convention-source.js +5 -5
- package/out/sqlite-convention-source.js.map +1 -1
- package/out/sqlite-utils.d.ts +24 -6
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +24 -7
- package/out/sqlite-utils.js.map +1 -1
- package/out/{street-centroid-schema.d.ts → street/centroid-schema.d.ts} +12 -7
- package/out/street/centroid-schema.d.ts.map +1 -0
- package/out/{street-centroid-schema.js → street/centroid-schema.js} +5 -5
- package/out/{street-centroid-schema.js.map → street/centroid-schema.js.map} +1 -1
- package/out/{street-centroid.d.ts → street/centroid.d.ts} +6 -6
- package/out/street/centroid.d.ts.map +1 -0
- package/out/{street-centroid.js → street/centroid.js} +18 -24
- package/out/street/centroid.js.map +1 -0
- package/out/street/index.d.ts +13 -0
- package/out/street/index.d.ts.map +1 -0
- package/out/street/index.js +13 -0
- package/out/street/index.js.map +1 -0
- package/out/{street-morphology-fst-builder.d.ts → street/morphology-fst-builder.d.ts} +4 -4
- package/out/street/morphology-fst-builder.d.ts.map +1 -0
- package/out/{street-morphology-fst-builder.js → street/morphology-fst-builder.js} +18 -13
- package/out/street/morphology-fst-builder.js.map +1 -0
- package/out/{street-morphology-fst-loader.d.ts → street/morphology-fst-loader.d.ts} +5 -5
- package/out/street/morphology-fst-loader.d.ts.map +1 -0
- package/out/{street-morphology-fst-loader.js → street/morphology-fst-loader.js} +11 -10
- package/out/street/morphology-fst-loader.js.map +1 -0
- package/out/{street-name-lookup.d.ts → street/name-lookup.d.ts} +4 -7
- package/out/street/name-lookup.d.ts.map +1 -0
- package/out/{street-name-lookup.js → street/name-lookup.js} +9 -23
- package/out/street/name-lookup.js.map +1 -0
- package/out/street/normalize.d.ts +179 -0
- package/out/street/normalize.d.ts.map +1 -0
- package/out/street/normalize.js +457 -0
- package/out/street/normalize.js.map +1 -0
- package/out/{street-segment-schema.d.ts → street/segment-schema.d.ts} +13 -9
- package/out/street/segment-schema.d.ts.map +1 -0
- package/out/{street-segment-schema.js → street/segment-schema.js} +4 -4
- package/out/{street-segment-schema.js.map → street/segment-schema.js.map} +1 -1
- package/out/types.d.ts +49 -9
- package/out/types.d.ts.map +1 -1
- package/out/types.js.map +1 -1
- package/out/unified-schema.d.ts +6 -5
- package/out/unified-schema.d.ts.map +1 -1
- package/out/unified-schema.js +16 -19
- package/out/unified-schema.js.map +1 -1
- package/out/uprn/existence.d.ts +53 -0
- package/out/uprn/existence.d.ts.map +1 -0
- package/out/uprn/existence.js +61 -0
- package/out/uprn/existence.js.map +1 -0
- package/out/uprn/index.d.ts +9 -0
- package/out/uprn/index.d.ts.map +1 -0
- package/out/uprn/index.js +9 -0
- package/out/uprn/index.js.map +1 -0
- package/out/uprn/lookup.d.ts +85 -0
- package/out/uprn/lookup.d.ts.map +1 -0
- package/out/uprn/lookup.js +149 -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 +299 -0
- package/out/weights-overlay-linker.d.ts.map +1 -0
- package/out/weights-overlay-linker.js +511 -0
- package/out/weights-overlay-linker.js.map +1 -0
- package/package.json +622 -154
- package/address-point.ts +0 -125
- package/candidate-lookup.ts +0 -720
- package/fst-autocomplete.ts +0 -204
- package/geo.ts +0 -121
- package/lookup.ts +0 -1354
- package/out/address-point-interpolation.d.ts.map +0 -1
- package/out/address-point-interpolation.js.map +0 -1
- package/out/address-point-schema.d.ts.map +0 -1
- package/out/address-point-schema.js.map +0 -1
- package/out/address-point.d.ts.map +0 -1
- package/out/address-point.js +0 -84
- package/out/address-point.js.map +0 -1
- package/out/ancestry-backfill.d.ts.map +0 -1
- package/out/ancestry-backfill.js.map +0 -1
- package/out/ancestry.d.ts.map +0 -1
- package/out/ancestry.js.map +0 -1
- package/out/convention.d.ts.map +0 -1
- package/out/convention.js.map +0 -1
- package/out/fst-autocomplete.d.ts.map +0 -1
- package/out/fst-autocomplete.js +0 -130
- package/out/fst-autocomplete.js.map +0 -1
- package/out/fst-builder.d.ts.map +0 -1
- package/out/fst-builder.js.map +0 -1
- package/out/fst-deserialize-web.d.ts.map +0 -1
- package/out/fst-deserialize-web.js.map +0 -1
- package/out/fst-freshness.d.ts.map +0 -1
- package/out/fst-freshness.js.map +0 -1
- package/out/fst-matcher.d.ts.map +0 -1
- package/out/fst-matcher.js.map +0 -1
- package/out/fst-serialize.d.ts.map +0 -1
- package/out/fst-serialize.js.map +0 -1
- package/out/fst-types.d.ts.map +0 -1
- package/out/fst-types.js.map +0 -1
- package/out/fts-query.d.ts.map +0 -1
- package/out/fts-query.js.map +0 -1
- package/out/fts.d.ts.map +0 -1
- package/out/fts.js.map +0 -1
- 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/out/geonames-aliases.d.ts.map +0 -1
- package/out/geonames-aliases.js.map +0 -1
- package/out/geonames-postal.d.ts.map +0 -1
- package/out/geonames-postal.js.map +0 -1
- package/out/poi-lookup.d.ts.map +0 -1
- package/out/poi-lookup.js.map +0 -1
- package/out/poi-schema.d.ts.map +0 -1
- package/out/poi-schema.js.map +0 -1
- package/out/postal-city-alias-lookup.d.ts.map +0 -1
- package/out/postal-city-alias-lookup.js.map +0 -1
- package/out/postal-city-candidate-schema.d.ts.map +0 -1
- package/out/sharding.d.ts +0 -114
- package/out/sharding.d.ts.map +0 -1
- package/out/sharding.js.map +0 -1
- package/out/street-centroid-schema.d.ts.map +0 -1
- package/out/street-centroid.d.ts.map +0 -1
- package/out/street-centroid.js.map +0 -1
- package/out/street-morphology-fst-builder.d.ts.map +0 -1
- package/out/street-morphology-fst-builder.js.map +0 -1
- package/out/street-morphology-fst-loader.d.ts.map +0 -1
- package/out/street-morphology-fst-loader.js.map +0 -1
- package/out/street-name-lookup.d.ts.map +0 -1
- package/out/street-name-lookup.js.map +0 -1
- package/out/street-normalize.d.ts +0 -95
- package/out/street-normalize.d.ts.map +0 -1
- package/out/street-normalize.js +0 -272
- package/out/street-normalize.js.map +0 -1
- package/out/street-segment-schema.d.ts.map +0 -1
- package/sqlite-utils.ts +0 -46
- package/street-normalize.ts +0 -318
- /package/{postal-city-alias-schema.ts → lib/postal/city-alias-schema.ts} +0 -0
package/lib/lookup.ts
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `WOFSQLitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
|
|
7
|
+
* query layer where the queries are non-trivial, and raw SQL where they aren't (FTS5 MATCH, the
|
|
8
|
+
* FTS index build).
|
|
9
|
+
*
|
|
10
|
+
* See `docs/plan/phases/PHASE_4_2_wof_sqlite.md` for the design rationale.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/resolver"
|
|
14
|
+
import { haversineKm } from "@mailwoman/spatial"
|
|
15
|
+
import type { SQLInputValue } from "@mailwoman/sqlite/client"
|
|
16
|
+
import { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
17
|
+
|
|
18
|
+
import { ancestorLineage } from "#ancestry/index"
|
|
19
|
+
import { candidateFromSearchRow, rankCandidates } from "#candidate-scoring"
|
|
20
|
+
import { loadCoincidentLocalities } from "#coincident-roles"
|
|
21
|
+
import {
|
|
22
|
+
ADDRESS_CONVENTION_TABLE,
|
|
23
|
+
resolveConvention,
|
|
24
|
+
SeedConventionSource,
|
|
25
|
+
type Convention,
|
|
26
|
+
type ConventionSource,
|
|
27
|
+
type ResolvedConvention,
|
|
28
|
+
type Strategy,
|
|
29
|
+
} from "#convention/index"
|
|
30
|
+
import {
|
|
31
|
+
pickExtractForPlacetype,
|
|
32
|
+
pickExtractsForPlacetype,
|
|
33
|
+
resolveExtracts,
|
|
34
|
+
type ResolvedExtract,
|
|
35
|
+
type ExtractConfig,
|
|
36
|
+
} from "#extracts"
|
|
37
|
+
import {
|
|
38
|
+
buildPlaceSearchFTS,
|
|
39
|
+
PLACE_BBOX_TABLE,
|
|
40
|
+
PLACE_POPULATION_TABLE,
|
|
41
|
+
PLACE_SEARCH_TABLE,
|
|
42
|
+
placeBboxExists,
|
|
43
|
+
placePopulationExists,
|
|
44
|
+
placeSearchFTSExists,
|
|
45
|
+
} from "#fts/index"
|
|
46
|
+
import { normalizePlacetypes, sanitizeFTSQuery } from "#fts/query"
|
|
47
|
+
import { cfNormalize, softNameScore } from "#name-score"
|
|
48
|
+
import { encyclopedicClauses } from "#place-importance-schema"
|
|
49
|
+
import type { WOFPostalCityAliasLookup } from "#postal/city-alias-lookup"
|
|
50
|
+
import { DEFAULT_WEIGHTS, populationScaleTerm, type RankingWeights } from "#ranking-weights"
|
|
51
|
+
import type { WOFDatabase } from "#schema"
|
|
52
|
+
import { fetchSearchRows, type RawSearchRow } from "#search-fetch"
|
|
53
|
+
import { SqliteConventionSource } from "#sqlite-convention-source"
|
|
54
|
+
import { allRows } from "#sqlite-utils"
|
|
55
|
+
import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "#types"
|
|
56
|
+
|
|
57
|
+
export interface WOFSQLitePlaceLookupOpts {
|
|
58
|
+
/**
|
|
59
|
+
* Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
|
|
60
|
+
*
|
|
61
|
+
* **Single string** — opens that one DB as the main extract.
|
|
62
|
+
*
|
|
63
|
+
* **Array** — opens the first entry as main, then ATTACHes each subsequent entry as a separate SQLite schema. Schema
|
|
64
|
+
* names are derived from the filename (`whosonfirst-data-postalcode- us-latest.db` → `postalcode_us`); override with
|
|
65
|
+
* `ExtractConfig.schemaName` when the filename doesn't follow WOF convention. See `extracts.ts` for the derivation
|
|
66
|
+
* rules.
|
|
67
|
+
*
|
|
68
|
+
* Routing: queries with a `placetype` matching a extract's name (or explicit `placetypes` hint) are sent to that
|
|
69
|
+
* extract; everything else hits main. Cross-extract UNION is NOT done — BM25 isn't comparable across
|
|
70
|
+
* separately-indexed corpora.
|
|
71
|
+
*/
|
|
72
|
+
databasePath?: string | ReadonlyArray<string | ExtractConfig>
|
|
73
|
+
/**
|
|
74
|
+
* Pre-opened connection — primarily for tests against an inline fixture DB. Mutually exclusive with `databasePath`.
|
|
75
|
+
* Multi-extract requires `databasePath` (so the lookup owns the ATTACH).
|
|
76
|
+
*/
|
|
77
|
+
database?: DatabaseClient<WOFDatabase>
|
|
78
|
+
/**
|
|
79
|
+
* If true, build the FTS5 `place_search` virtual table on construction if it doesn't already exist. The upstream WOF
|
|
80
|
+
* distribution does NOT ship FTS5, so callers either set this once on first open or pre-build it via the
|
|
81
|
+
* operator-side CLI documented in the README. Default false — the resolver assumes the index already exists and
|
|
82
|
+
* errors loudly if it doesn't.
|
|
83
|
+
*
|
|
84
|
+
* With multi-extract, `buildFTS: true` builds the index on the **main** extract only. Other extracts must be
|
|
85
|
+
* pre-built via `mailwoman gazetteer build fts` — operator script for predictable cost.
|
|
86
|
+
*/
|
|
87
|
+
buildFTS?: boolean
|
|
88
|
+
/**
|
|
89
|
+
* Geographic Rule Engine convention source (Direction E, #289). Per-WOF-polygon resolution profiles, either as a
|
|
90
|
+
* ready `ConventionSource` or a plain `{ wofID: Convention }` seed map. Default empty — every query rides
|
|
91
|
+
* `WORLD_DEFAULT` (the EU coordinate-first behavior). JP/KR/TW add rows; #290 wires a build-from-source sqlite-backed
|
|
92
|
+
* source here.
|
|
93
|
+
*/
|
|
94
|
+
conventions?: ConventionSource | Record<number, Convention>
|
|
95
|
+
/**
|
|
96
|
+
* Opt-in postal-city alias reader (#475). When supplied, the coordinate-first locality scorer treats an observed
|
|
97
|
+
* `postal_city` ("Antioch", postcode 37013) as a name-match alias for the geographic locality the postcode sits in
|
|
98
|
+
* ("Nashville"), recovering the chronic postal-vs- geographic-city mismatch. Absent (the default), the resolver is
|
|
99
|
+
* byte-identical — every alias code path is conditioned on this being non-null, so an unprovided reader changes no
|
|
100
|
+
* score.
|
|
101
|
+
*/
|
|
102
|
+
postalCityAliases?: WOFPostalCityAliasLookup
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The coordinate-first candidate table (scripts/build-postcode-locality.ts): postcode → containing
|
|
107
|
+
*
|
|
108
|
+
* - Nearby localities with WOF alt-name aliases.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* The placetypes `pickExtractsForPlacetype`'s substring rule can route by name. Not every WOF placetype — only the ones
|
|
112
|
+
* a purpose-built extract is ever named for — so the diagnostic below can say "this name routes nowhere" without
|
|
113
|
+
* claiming to enumerate the gazetteer.
|
|
114
|
+
*/
|
|
115
|
+
const KNOWN_ROUTED_PLACETYPES: ReadonlyArray<string> = [
|
|
116
|
+
"postalcode",
|
|
117
|
+
"locality",
|
|
118
|
+
"region",
|
|
119
|
+
"county",
|
|
120
|
+
"country",
|
|
121
|
+
"venue",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const POSTCODE_LOCALITY_TABLE = "postcode_locality"
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Tunables for the coordinate-first locality soft-score `Score = pc·S_pc + name·S_name + pop·S_pop` (each S in [0,1]).
|
|
128
|
+
* The pc/name/pop WEIGHTS now come from the resolved convention's `scoringWeights` (`WORLD_DEFAULT` = 0.6/0.3/0.1 — the
|
|
129
|
+
* EU values), so a locale can retune them as data. PC_DECAY_KM sets how fast S_pc falls with distance.
|
|
130
|
+
*/
|
|
131
|
+
const CF_PC_DECAY_KM = 8
|
|
132
|
+
/**
|
|
133
|
+
* The chosen locality must be within this distance of the postcode's containing locality, else the postcode and the
|
|
134
|
+
* parsed city name are judged to disagree (a transposed / wrong-for-the-city postcode) and the `mismatch` flag fires.
|
|
135
|
+
* Generous enough that a city-state Ortsteil (~15km from the city centroid) and an abutting town (~few km) are NOT
|
|
136
|
+
* flagged, tight enough to catch a wrong city (hundreds of km).
|
|
137
|
+
*/
|
|
138
|
+
const CF_MISMATCH_KM = 50
|
|
139
|
+
|
|
140
|
+
export class WOFSQLitePlaceLookup implements PlaceLookup, Disposable {
|
|
141
|
+
readonly #db: DatabaseClient<WOFDatabase>
|
|
142
|
+
/**
|
|
143
|
+
* Resources this instance opened. A connection handed in by a caller is NOT in here, so disposal cannot reach it —
|
|
144
|
+
* ownership is membership rather than a flag a later branch has to check.
|
|
145
|
+
*/
|
|
146
|
+
readonly #resources = new DisposableStack()
|
|
147
|
+
readonly #weights: RankingWeights
|
|
148
|
+
/**
|
|
149
|
+
* Cached at construction so we don't `sqlite_master` query on every findPlace call. Bbox + near- with-radius queries
|
|
150
|
+
* fall back to no-filter when this is false, preserving compatibility with DBs that were FTS-built before the R*Tree
|
|
151
|
+
* shipped.
|
|
152
|
+
*
|
|
153
|
+
* Per-extract: a extract is only considered to have the bbox index if its own R*Tree table exists.
|
|
154
|
+
*/
|
|
155
|
+
readonly #hasBboxIndex: Map<string, boolean>
|
|
156
|
+
/**
|
|
157
|
+
* Per-extract probe for the `place_population` aux table. When false, the LEFT JOIN is omitted from the SELECT and
|
|
158
|
+
* population boost is 0 for every row — preserves compatibility with DBs built before this feature shipped.
|
|
159
|
+
*/
|
|
160
|
+
readonly #hasPopulationIndex: Map<string, boolean>
|
|
161
|
+
/**
|
|
162
|
+
* Per-extract SELECT term + LEFT JOIN for the two-score split's `encyclopedic` carry (ROAD_TO_V9 §2 R1), probed and
|
|
163
|
+
* built once at construction. Degrades to `NULL AS encyclopedic` with no join on a pre-split extract — every shipped
|
|
164
|
+
* extract today. See {@link encyclopedicClauses} for why the probe is a column and not a table.
|
|
165
|
+
*/
|
|
166
|
+
readonly #encyclopedicClauses: Map<string, { select: string; join: string }>
|
|
167
|
+
/**
|
|
168
|
+
* Per-extract probe for the `postcode_locality` table (the coordinate-first candidate table, built by
|
|
169
|
+
* scripts/build-postcode-locality.ts). Cached at construction; null'd out when absent so the coord-first path
|
|
170
|
+
* silently no-ops on a deployment that didn't ship the table.
|
|
171
|
+
*/
|
|
172
|
+
readonly #postcodeLocalityExtract: string | null
|
|
173
|
+
/**
|
|
174
|
+
* Resolved extract list. Always at least one entry; first is `main`. Multi-extract adds extras with their own derived
|
|
175
|
+
* (or override) schema names.
|
|
176
|
+
*/
|
|
177
|
+
readonly #extracts: ResolvedExtract[]
|
|
178
|
+
/**
|
|
179
|
+
* #920: per-schema probed country sets for country-aware extract routing (non-main extracts only).
|
|
180
|
+
*/
|
|
181
|
+
readonly #extractCountries: Map<string, ReadonlySet<string>>
|
|
182
|
+
/**
|
|
183
|
+
* The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
|
|
184
|
+
* `#strategies` is the named-primitive registry the merged convention dispatches. Empty source → every query resolves
|
|
185
|
+
* to `WORLD_DEFAULT` → byte-identical to the pre-engine coordinate-first path. `#countryWOFIdCache` memoizes the
|
|
186
|
+
* country-code → country-WOF-id lookup that seeds the convention ancestor chain (one query per country, then
|
|
187
|
+
* cached).
|
|
188
|
+
*/
|
|
189
|
+
readonly #conventionSource: ConventionSource
|
|
190
|
+
readonly #strategies: Map<string, Strategy>
|
|
191
|
+
readonly #countryWOFIdCache = new Map<string, number | null>()
|
|
192
|
+
/**
|
|
193
|
+
* Strategy names already warned about — so an unknown name surfaces once, not once per query.
|
|
194
|
+
*/
|
|
195
|
+
readonly #warnedUnknownStrategies = new Set<string>()
|
|
196
|
+
/**
|
|
197
|
+
* Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
|
|
198
|
+
*/
|
|
199
|
+
#coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
|
|
200
|
+
/**
|
|
201
|
+
* Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
|
|
202
|
+
*/
|
|
203
|
+
readonly #ancestorsCache = new Map<number, Ancestor[]>()
|
|
204
|
+
/**
|
|
205
|
+
* Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
|
|
206
|
+
* is conditioned on this, so the default resolver is byte-identical.
|
|
207
|
+
*/
|
|
208
|
+
readonly #postalCityAliases: WOFPostalCityAliasLookup | null
|
|
209
|
+
|
|
210
|
+
constructor(opts: WOFSQLitePlaceLookupOpts, weights?: Partial<RankingWeights>) {
|
|
211
|
+
if (opts.database && opts.databasePath) {
|
|
212
|
+
throw new Error("WOFSQLitePlaceLookup: pass either `database` or `databasePath`, not both")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!opts.database && !opts.databasePath) {
|
|
216
|
+
throw new Error("WOFSQLitePlaceLookup: one of `database` or `databasePath` is required")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (opts.database) {
|
|
220
|
+
this.#db = opts.database
|
|
221
|
+
this.#extracts = [{ path: ":memory:", schemaName: "main", placetypes: [] }]
|
|
222
|
+
} else {
|
|
223
|
+
const extracts = resolveExtracts(opts.databasePath!)
|
|
224
|
+
this.#extracts = extracts
|
|
225
|
+
// Read-only by default — shipped gazetteer extracts are sealed 0444 and Docker `:ro` mounts
|
|
226
|
+
// forbid write-mode opens, so a writable open fails there. The only code path that writes to
|
|
227
|
+
// the main extract is `#ensureFTS()` (FTS5 index build), conditioned on `opts.buildFTS`; open writable
|
|
228
|
+
// ONLY when that build was explicitly requested. Every read query (FTS5 MATCH, the aux-table
|
|
229
|
+
// SELECTs, ATTACH, and the `busy_timeout` PRAGMA) works read-only. See the docker read-only
|
|
230
|
+
// mount limitation (#1213).
|
|
231
|
+
this.#db = this.#resources.use(new DatabaseClient<WOFDatabase>(extracts[0]!.path, { readOnly: !opts.buildFTS }))
|
|
232
|
+
|
|
233
|
+
// ATTACH each non-main extract. Schema names were validated by resolveExtracts, so safe to
|
|
234
|
+
// interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
|
|
235
|
+
for (const s of extracts.slice(1)) {
|
|
236
|
+
this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// node:sqlite has no .pragma() helper; pragmas are executed as plain SQL.
|
|
241
|
+
this.#db.exec("PRAGMA busy_timeout = 5000")
|
|
242
|
+
|
|
243
|
+
if (opts.buildFTS) {
|
|
244
|
+
this.#ensureFTS()
|
|
245
|
+
} else {
|
|
246
|
+
this.#assertFTSExists()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.#weights = { ...DEFAULT_WEIGHTS, ...weights }
|
|
250
|
+
|
|
251
|
+
// Probe each extract's aux-table presence — driven by per-extract table existence in
|
|
252
|
+
// sqlite_master. Cached at construction so findPlace doesn't query sqlite_master per call.
|
|
253
|
+
this.#hasBboxIndex = new Map()
|
|
254
|
+
this.#hasPopulationIndex = new Map()
|
|
255
|
+
this.#encyclopedicClauses = new Map()
|
|
256
|
+
|
|
257
|
+
for (const s of this.#extracts) {
|
|
258
|
+
this.#hasBboxIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_BBOX_TABLE))
|
|
259
|
+
this.#hasPopulationIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_POPULATION_TABLE))
|
|
260
|
+
this.#encyclopedicClauses.set(s.schemaName, encyclopedicClauses(this.#db, s.schemaName))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Every lookup path here reaches `place_search`, and a extract without it fails in one of two ways
|
|
264
|
+
// that are both hard to read: an unroutable name returns zero hits (indistinguishable from "this
|
|
265
|
+
// country has no places") and a routable one throws mid-query from deep inside a SELECT. The
|
|
266
|
+
// unroutable half is the worse of the two — a extract reaches routing only through the name
|
|
267
|
+
// `deriveSchemaName` derives from its FILENAME, so a file spelled one letter off the placetype it
|
|
268
|
+
// serves answers with nothing while holding every row that was asked for.
|
|
269
|
+
//
|
|
270
|
+
// Two independent things bring a extract under the guard, and it needs both. Carrying `spr` is a
|
|
271
|
+
// CLAIM to be a place extract. Carrying a name that routes is an INVITATION to be queried as one, and
|
|
272
|
+
// it is made by the filename alone — so a database with no tables at all still gets picked, still
|
|
273
|
+
// answers no query, and still dies inside a SELECT. Testing only the claim lets an empty or
|
|
274
|
+
// truncated file past construction; testing only the name would exempt a correctly-named build
|
|
275
|
+
// input. A extract needs to fail neither test to be exempt.
|
|
276
|
+
//
|
|
277
|
+
// Exempt by design: `postcode-locality-<cc>.db` carries a relation table and nothing else, matches
|
|
278
|
+
// no routed placetype, and is part of the documented default extract list.
|
|
279
|
+
for (const s of this.#extracts) {
|
|
280
|
+
if (s.schemaName === "main") continue
|
|
281
|
+
|
|
282
|
+
const routes = KNOWN_ROUTED_PLACETYPES.some(
|
|
283
|
+
(pt) => s.schemaName === pt || s.schemaName.startsWith(`${pt}_`) || s.schemaName.endsWith(`_${pt}`)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const claimsPlaceExtract = this.#extractHasTable(s.schemaName, "spr")
|
|
287
|
+
|
|
288
|
+
if (!routes && !claimsPlaceExtract) continue
|
|
289
|
+
|
|
290
|
+
if (this.#extractHasTable(s.schemaName, PLACE_SEARCH_TABLE)) continue
|
|
291
|
+
|
|
292
|
+
throw new Error(
|
|
293
|
+
`WOFSQLitePlaceLookup: ${s.path} ` +
|
|
294
|
+
(claimsPlaceExtract
|
|
295
|
+
? `carries "spr" but no "${PLACE_SEARCH_TABLE}" table, so it cannot serve a lookup.`
|
|
296
|
+
: `is named for a routed placetype but carries neither "spr" nor "${PLACE_SEARCH_TABLE}", so every ` +
|
|
297
|
+
`query routed to it would die mid-SELECT. An empty or truncated file reads exactly like this.`) +
|
|
298
|
+
` Build it with the FTS index, or leave it out — it is usable as a BUILD input either way.` +
|
|
299
|
+
(routes
|
|
300
|
+
? ""
|
|
301
|
+
: ` Its schema name "${s.schemaName}" also matches no routed placetype (${KNOWN_ROUTED_PLACETYPES.join(", ")}), ` +
|
|
302
|
+
`so it would never have been queried even with the table — check the filename's spelling.`)
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// #920 country-aware extract routing: probe each NON-MAIN extract's country set once at
|
|
307
|
+
// construction (they're small, purpose-built extracts — postcode/locality slices; main is the
|
|
308
|
+
// multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
|
|
309
|
+
// pickExtractForPlacetype so two postcode extracts (postalcode-us + postalcode-geonames-tail)
|
|
310
|
+
// route by the query's country instead of first-match starving the second extract.
|
|
311
|
+
this.#extractCountries = new Map()
|
|
312
|
+
|
|
313
|
+
for (const sh of this.#extracts) {
|
|
314
|
+
if (sh.schemaName === "main") continue
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const rows = this.#db
|
|
318
|
+
.prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
|
|
319
|
+
.all() as Array<{ country: string }>
|
|
320
|
+
|
|
321
|
+
this.#extractCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)))
|
|
322
|
+
} catch {
|
|
323
|
+
// A extract without spr (or an attach oddity) just doesn't participate in country routing.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The postcode_locality table can live on any attached extract (typically its own
|
|
328
|
+
// `postcode-locality-<cc>.db`). Find the first extract that has it; null = coord-first disabled.
|
|
329
|
+
this.#postcodeLocalityExtract =
|
|
330
|
+
this.#extracts.find((s) => this.#extractHasTable(s.schemaName, POSTCODE_LOCALITY_TABLE))?.schemaName ?? null
|
|
331
|
+
|
|
332
|
+
// Opt-in postal-city alias reader (#475). Construction-time present-or-not is the check: null
|
|
333
|
+
// keeps the coordinate-first scorer byte-identical to pre-#475.
|
|
334
|
+
this.#postalCityAliases = opts.postalCityAliases ?? null
|
|
335
|
+
|
|
336
|
+
// The Geographic Rule Engine convention source. Precedence: an explicit `opts.conventions`
|
|
337
|
+
// (a ready source or a seed map) wins; else the build-from-source convention asset if one is
|
|
338
|
+
// attached (auto-detected, like the postcode_locality extract — adding conventions.db to
|
|
339
|
+
// databasePath enables it; queried on demand, not paged into memory); else empty, so EU rides
|
|
340
|
+
// WORLD_DEFAULT. The registry binds strategy NAMES to the SQL-bound primitives — adding a
|
|
341
|
+
// strategy is registering it here.
|
|
342
|
+
const conventionExtract =
|
|
343
|
+
this.#extracts.find((s) => this.#extractHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null
|
|
344
|
+
|
|
345
|
+
this.#conventionSource = opts.conventions
|
|
346
|
+
? "get" in opts.conventions && typeof opts.conventions.get === "function"
|
|
347
|
+
? opts.conventions
|
|
348
|
+
: new SeedConventionSource(opts.conventions as Record<number, Convention>)
|
|
349
|
+
: conventionExtract
|
|
350
|
+
? new SqliteConventionSource(this.#db, conventionExtract)
|
|
351
|
+
: new SeedConventionSource()
|
|
352
|
+
|
|
353
|
+
this.#strategies = new Map<string, Strategy>([
|
|
354
|
+
["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
|
|
355
|
+
["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
|
|
356
|
+
])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#extractHasTable(schemaName: string, tableName: string): boolean {
|
|
360
|
+
// For main, the existing helpers work directly. For attached extracts we have to ask via the
|
|
361
|
+
// schema-qualified `sqlite_master` view.
|
|
362
|
+
if (schemaName === "main") {
|
|
363
|
+
if (tableName === PLACE_BBOX_TABLE) return placeBboxExists(this.#db)
|
|
364
|
+
|
|
365
|
+
if (tableName === PLACE_POPULATION_TABLE) return placePopulationExists(this.#db)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const row = this.#db
|
|
369
|
+
.prepare(`SELECT name FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ?`)
|
|
370
|
+
.get(tableName) as { name: string } | undefined
|
|
371
|
+
|
|
372
|
+
return Boolean(row)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async findPlace(query: FindPlaceQuery): Promise<PlaceCandidate[]> {
|
|
376
|
+
// Geographic Rule Engine dispatch (#289). Resolve the effective convention for this query
|
|
377
|
+
// (WORLD_DEFAULT for the EU locales — the seed source is empty) and run its candidate strategies
|
|
378
|
+
// in order; the first to return a non-null result wins. The default list,
|
|
379
|
+
// [postcode_area_resolution, fallback_fuzzy_name_match], reproduces the pre-engine coordinate-
|
|
380
|
+
// first → FTS fall-through exactly. Unknown strategy names are skipped, so a convention may name
|
|
381
|
+
// a primitive a future phase will register.
|
|
382
|
+
const convention = this.#conventionFor(query)
|
|
383
|
+
|
|
384
|
+
let outcome: PlaceCandidate[] = []
|
|
385
|
+
|
|
386
|
+
for (const name of convention.candidateStrategies) {
|
|
387
|
+
const strategy = this.#strategies.get(name)
|
|
388
|
+
|
|
389
|
+
if (!strategy) {
|
|
390
|
+
this.#warnUnknownStrategy(name)
|
|
391
|
+
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const result = await strategy(query, convention)
|
|
396
|
+
|
|
397
|
+
if (result !== null) {
|
|
398
|
+
outcome = result
|
|
399
|
+
|
|
400
|
+
break
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (outcome.length) return outcome
|
|
405
|
+
|
|
406
|
+
// #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
|
|
407
|
+
// ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
|
|
408
|
+
// ('1012 LG') — two FTS tokens that can never match the one-token doc (the #920 name law,
|
|
409
|
+
// resurfacing in a WOF-built extract). On a postcode-typed NL-shape miss, retry ONCE with the
|
|
410
|
+
// whitespace-joined form (block-level precision when the full-code row exists), then the
|
|
411
|
+
// 4-digit stem (area-level). Country-restricted to NL — the same digits+letters shape elsewhere
|
|
412
|
+
// must not silently coarsen to a different system's code. Each retry only fires when its
|
|
413
|
+
// text differs from the current one, so the ladder terminates by construction.
|
|
414
|
+
if (
|
|
415
|
+
query.country?.toUpperCase() === "NL" &&
|
|
416
|
+
(normalizePlacetypes(query.placetype)?.includes("postalcode") ?? false) &&
|
|
417
|
+
/^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())
|
|
418
|
+
) {
|
|
419
|
+
const trimmed = query.text.trim()
|
|
420
|
+
const joined = trimmed.replaceAll(/\s+/g, "")
|
|
421
|
+
|
|
422
|
+
if (joined !== trimmed) {
|
|
423
|
+
const full = await this.findPlace({ ...query, text: joined })
|
|
424
|
+
|
|
425
|
+
if (full.length) return full
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const stem = trimmed.slice(0, 4)
|
|
429
|
+
|
|
430
|
+
if (stem !== trimmed) return this.findPlace({ ...query, text: stem })
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return outcome
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Dual-role localities coincident with an admin id, from the precomputed `coincident_roles` relation (#403). Backs
|
|
438
|
+
* {@link ResolveOpts.hierarchyCompletion} (#405): O(1) once the relation is loaded. Returns `[]` when the relation
|
|
439
|
+
* table is absent (older DB) or the admin isn't a dual-role place, so completion degrades gracefully. The relation +
|
|
440
|
+
* `spr` join is loaded once and memoized.
|
|
441
|
+
*/
|
|
442
|
+
coincidentLocalitiesFor(adminID: number | string): CoincidentLocality[] {
|
|
443
|
+
const id = typeof adminID === "number" ? adminID : Number(adminID)
|
|
444
|
+
|
|
445
|
+
if (!Number.isFinite(id)) return []
|
|
446
|
+
|
|
447
|
+
if (!this.#coincidentRolesCache) {
|
|
448
|
+
this.#coincidentRolesCache = loadCoincidentLocalities(this.#db)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return this.#coincidentRolesCache.get(id) ?? []
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The ancestor lineage of a place — its containment chain joined with `spr` for canonical names, ordered
|
|
456
|
+
* NEAREST-FIRST (localadmin → county → region → … → country). Backs {@link ResolveOpts.includeAncestors} (#404). Self
|
|
457
|
+
* is excluded; memoized per id. Returns `[]` when the place has no recorded ancestry.
|
|
458
|
+
*
|
|
459
|
+
* The walk itself lives in `ancestry.ts` (shared with the reverse geocoder, #484); the ordering is its
|
|
460
|
+
* `PLACETYPE_DEPTH` table — same ranking as the previous inline SQL CASE, extended below `localadmin` so
|
|
461
|
+
* locality/neighbourhood ancestors order correctly instead of sorting last.
|
|
462
|
+
*/
|
|
463
|
+
ancestors(id: number | string): Ancestor[] {
|
|
464
|
+
const pid = typeof id === "number" ? id : Number(id)
|
|
465
|
+
|
|
466
|
+
if (!Number.isFinite(pid)) return []
|
|
467
|
+
const cached = this.#ancestorsCache.get(pid)
|
|
468
|
+
|
|
469
|
+
if (cached) return cached
|
|
470
|
+
|
|
471
|
+
const lineage: Ancestor[] = ancestorLineage(this.#db, pid).map((r) => ({
|
|
472
|
+
id: r.id,
|
|
473
|
+
placetype: r.placetype,
|
|
474
|
+
name: r.name,
|
|
475
|
+
}))
|
|
476
|
+
|
|
477
|
+
this.#ancestorsCache.set(pid, lineage)
|
|
478
|
+
|
|
479
|
+
return lineage
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Surface an unknown strategy name LOUDLY (once per name) rather than swallowing it silently — an invisible no-op is
|
|
484
|
+
* exactly the hidden-dependency failure mode we avoid (see the provenance-first design value). We warn rather than
|
|
485
|
+
* throw so a convention asset built against a newer code revision (one that adds a strategy) degrades gracefully on
|
|
486
|
+
* an older build instead of taking down resolution.
|
|
487
|
+
*/
|
|
488
|
+
#warnUnknownStrategy(name: string): void {
|
|
489
|
+
if (this.#warnedUnknownStrategies.has(name)) return
|
|
490
|
+
this.#warnedUnknownStrategies.add(name)
|
|
491
|
+
|
|
492
|
+
console.warn(
|
|
493
|
+
`WOFSQLitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
|
|
494
|
+
`(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
|
|
495
|
+
`against a newer code revision, rebuild the asset for this one.`
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Strategy `postcode_area_resolution` — the coordinate-first locality path, strictly conditioned (a sibling postcode
|
|
501
|
+
* AND a postcode_locality table AND a locality query). Returns `null` — so the dispatcher falls through to the next
|
|
502
|
+
* strategy — when the condition is unmet or the postcode isn't in the table; otherwise the soft-scored postcode∪name
|
|
503
|
+
* candidate set.
|
|
504
|
+
*/
|
|
505
|
+
#postcodeAreaResolution(query: FindPlaceQuery, convention: ResolvedConvention): Promise<PlaceCandidate[] | null> {
|
|
506
|
+
if (!(query.postcode && this.#postcodeLocalityExtract && this.#isLocalityQuery(query))) {
|
|
507
|
+
return Promise.resolve(null)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return this.#findLocalityCoordFirst(query, this.#postcodeLocalityExtract, convention)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Strategy `fallback_fuzzy_name_match` — the BM25 FTS name-match over the gazetteer, the universal fallback. Always
|
|
515
|
+
* returns an array (never null), so it terminates the dispatch chain.
|
|
516
|
+
*/
|
|
517
|
+
async #fuzzyNameMatch(query: FindPlaceQuery, forceExtract?: ResolvedExtract): Promise<PlaceCandidate[]> {
|
|
518
|
+
const limit = query.limit ?? 10
|
|
519
|
+
|
|
520
|
+
// Expand the placetype filter through the shared equivalence table (core/resolver): a
|
|
521
|
+
// `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
|
|
522
|
+
// (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
|
|
523
|
+
// fuzzy "Brooklyn Park, MN" won instead. Order-preserving: the FIRST entry stays the
|
|
524
|
+
// requested placetype, which is what extract routing keys off below.
|
|
525
|
+
const placetypes = expandPlacetypeFilter(normalizePlacetypes(query.placetype)) as WOFPlacetype[] | null
|
|
526
|
+
// Postcode-typed queries keep the #920 fused name-law shape; everything else splits on
|
|
527
|
+
// intra-token punctuation so hyphenated names reach the FTS as their real terms (#945).
|
|
528
|
+
const ftsQuery = sanitizeFTSQuery(query.text, { fuseTokens: placetypes?.includes("postalcode") ?? false })
|
|
529
|
+
|
|
530
|
+
if (!ftsQuery) return []
|
|
531
|
+
|
|
532
|
+
// Pick the extract for this query. Multi-extract routing is placetype-driven; a query without
|
|
533
|
+
// `placetype` always goes to main. (Mixed-placetype queries with multiple extracts aren't
|
|
534
|
+
// supported in v1 — caller can issue two findPlace calls and merge in TS if needed.)
|
|
535
|
+
const firstPlacetype = placetypes?.[0]
|
|
536
|
+
|
|
537
|
+
// Bias fan-out (#58/proximity-bias): a country-less query WITH proximity hints must see the
|
|
538
|
+
// cross-extract ambiguity the hints exist to resolve — "48026" lives in postalcode-us AND
|
|
539
|
+
// postalcode-intl, and single-extract routing would hide one side. Query every matching extract
|
|
540
|
+
// (self-recursion with a extract pin), merge by id, and re-sort by the same (exact, prominence)
|
|
541
|
+
// keys the per-extract tier sort used. Bounded: hints + no country + >1 matching extract only.
|
|
542
|
+
const hasBiasHints = !!query.near || (query.bias?.length ?? 0) > 0
|
|
543
|
+
|
|
544
|
+
if (!forceExtract && hasBiasHints && !query.country) {
|
|
545
|
+
const matching = pickExtractsForPlacetype(this.#extracts, firstPlacetype)
|
|
546
|
+
|
|
547
|
+
if (matching.length > 1) {
|
|
548
|
+
const pools: PlaceCandidate[][] = []
|
|
549
|
+
|
|
550
|
+
for (const sh of matching) {
|
|
551
|
+
pools.push(await this.#fuzzyNameMatch(query, sh))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const byID = new Map<PlaceCandidate["id"], PlaceCandidate>()
|
|
555
|
+
|
|
556
|
+
for (const c of pools.flat()) {
|
|
557
|
+
if (!byID.has(c.id)) {
|
|
558
|
+
byID.set(c.id, c)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const merged = [...byID.values()]
|
|
563
|
+
|
|
564
|
+
merged.sort(
|
|
565
|
+
(a, b) =>
|
|
566
|
+
Number(b.exactMatch ?? false) - Number(a.exactMatch ?? false) ||
|
|
567
|
+
(b.prominence ?? 0) - (a.prominence ?? 0) ||
|
|
568
|
+
b.score - a.score
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
return merged.slice(0, limit)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const extract =
|
|
576
|
+
forceExtract ??
|
|
577
|
+
pickExtractForPlacetype(this.#extracts, firstPlacetype, {
|
|
578
|
+
country: query.country,
|
|
579
|
+
countriesBySchema: this.#extractCountries,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// bare schema name; safe to interpolate (validated at construction)
|
|
583
|
+
const sch = extract.schemaName
|
|
584
|
+
|
|
585
|
+
const rawRows = fetchSearchRows({
|
|
586
|
+
db: this.#db,
|
|
587
|
+
schemaName: sch,
|
|
588
|
+
query,
|
|
589
|
+
placetypes,
|
|
590
|
+
ftsQuery,
|
|
591
|
+
limit,
|
|
592
|
+
hasBboxIndex: this.#hasBboxIndex,
|
|
593
|
+
hasPopulationIndex: this.#hasPopulationIndex,
|
|
594
|
+
encyclopedicClauses: this.#encyclopedicClauses,
|
|
595
|
+
weights: this.#weights,
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
const scoring = {
|
|
599
|
+
query,
|
|
600
|
+
placetypes,
|
|
601
|
+
queryLen: query.text.length,
|
|
602
|
+
weights: this.#weights,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const candidates = rawRows.map((row) => candidateFromSearchRow(row, scoring))
|
|
606
|
+
|
|
607
|
+
rankCandidates(candidates, {
|
|
608
|
+
db: this.#db,
|
|
609
|
+
schemaName: sch,
|
|
610
|
+
query,
|
|
611
|
+
weights: this.#weights,
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
return candidates.slice(0, limit)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
#isLocalityQuery(query: FindPlaceQuery): boolean {
|
|
618
|
+
const pts = normalizePlacetypes(query.placetype)
|
|
619
|
+
|
|
620
|
+
return !pts || pts.includes("locality")
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolve the effective convention for a query (the Geographic Rule Engine entry point). The ancestor chain is keyed
|
|
625
|
+
* by WOF polygon id; for #289 it carries just the country level — resolved from `query.country` via the cached
|
|
626
|
+
* code→WOF-id lookup — so the EU locales, which have no override rows, resolve to `WORLD_DEFAULT` and dispatch is
|
|
627
|
+
* byte-identical to the pre-engine path. E4 (JP) extends the chain with the resolved locality's `ancestors` row, so a
|
|
628
|
+
* region/locality-level convention (e.g. Sapporo's grid) deep-merges over the country one.
|
|
629
|
+
*/
|
|
630
|
+
#conventionFor(query: FindPlaceQuery): ResolvedConvention {
|
|
631
|
+
const chain: number[] = []
|
|
632
|
+
|
|
633
|
+
if (query.country) {
|
|
634
|
+
const cid = this.#countryWOFId(query.country)
|
|
635
|
+
|
|
636
|
+
if (cid !== null) {
|
|
637
|
+
chain.push(cid)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return resolveConvention(this.#conventionSource, chain)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Country ISO code → its WOF polygon id (the coarsest convention key). Cached — one indexed `spr` query per distinct
|
|
646
|
+
* country, then memoized (including a not-found `null`) so findPlace never pays for it twice.
|
|
647
|
+
*/
|
|
648
|
+
#countryWOFId(code: string): number | null {
|
|
649
|
+
const cached = this.#countryWOFIdCache.get(code)
|
|
650
|
+
|
|
651
|
+
if (cached !== undefined) return cached
|
|
652
|
+
let id: number | null
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
const row = this.#db
|
|
656
|
+
.prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
|
|
657
|
+
.get(code) as { id: number } | undefined
|
|
658
|
+
|
|
659
|
+
id = row?.id ?? null
|
|
660
|
+
} catch {
|
|
661
|
+
id = null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
this.#countryWOFIdCache.set(code, id)
|
|
665
|
+
|
|
666
|
+
return id
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Coordinate-first locality resolution. The postcode_locality table maps the sibling postcode to the locality whose
|
|
671
|
+
* polygon contains the postcode centroid (+ a few nearby ones for the abutting- postcode case). We union those
|
|
672
|
+
* COORDINATE candidates with the FTS NAME candidates and soft-score the union `0.6·S_pc + 0.3·S_name + 0.1·S_pop` —
|
|
673
|
+
* so a small town the name-match never finds is recovered by the postcode, while an unambiguous name (Berlin) still
|
|
674
|
+
* wins on name + population. Returns null when the postcode isn't in the table (→ caller falls back to the FTS
|
|
675
|
+
* path).
|
|
676
|
+
*/
|
|
677
|
+
async #findLocalityCoordFirst(
|
|
678
|
+
query: FindPlaceQuery,
|
|
679
|
+
sch: string,
|
|
680
|
+
convention: ResolvedConvention
|
|
681
|
+
): Promise<PlaceCandidate[] | null> {
|
|
682
|
+
const w = convention.scoringWeights
|
|
683
|
+
const pc = query.postcode!.trim()
|
|
684
|
+
const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
|
|
685
|
+
const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
|
|
686
|
+
|
|
687
|
+
const pcRows = allRows<{ id: number; aliases: string | null; dist: number; containing: number }>(
|
|
688
|
+
this.#db.prepare(
|
|
689
|
+
`SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
|
|
690
|
+
FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`
|
|
691
|
+
),
|
|
692
|
+
...pcParams
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if (!pcRows.length) return null
|
|
696
|
+
|
|
697
|
+
const limit = query.limit ?? 10
|
|
698
|
+
// Name-match candidates via the normal FTS path (postcode cleared → no recursion).
|
|
699
|
+
const ftsCands = await this.findPlace({ ...query, postcode: undefined, limit: Math.max(limit, 10) })
|
|
700
|
+
|
|
701
|
+
const pcInfo = new Map<number, { dist: number; containing: boolean; aliases: string[] }>()
|
|
702
|
+
|
|
703
|
+
for (const r of pcRows) {
|
|
704
|
+
pcInfo.set(r.id, { dist: r.dist, containing: r.containing === 1, aliases: r.aliases ? r.aliases.split("|") : [] })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// #475 (opt-in): observed postal-city aliases for this postcode, keyed by the geographic
|
|
708
|
+
// locality name they map to. A user-typed postal city ("Antioch", 37013) becomes a name-match
|
|
709
|
+
// alias for the geographic locality the postcode sits in ("Nashville"). Empty when the reader
|
|
710
|
+
// isn't supplied → the scoring loop below is byte-identical to pre-#475.
|
|
711
|
+
const postalAliasByGeo = new Map<string, string[]>()
|
|
712
|
+
|
|
713
|
+
if (this.#postalCityAliases) {
|
|
714
|
+
for (const a of await this.#postalCityAliases.getDivergentAliases(pc)) {
|
|
715
|
+
const key = cfNormalize(a.geoLocality)
|
|
716
|
+
|
|
717
|
+
if (!key) continue
|
|
718
|
+
const bag = postalAliasByGeo.get(key)
|
|
719
|
+
|
|
720
|
+
if (bag) {
|
|
721
|
+
bag.push(a.postalCity)
|
|
722
|
+
} else {
|
|
723
|
+
postalAliasByGeo.set(key, [a.postalCity])
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const merged = new Map<number, PlaceCandidate>()
|
|
729
|
+
|
|
730
|
+
for (const c of ftsCands) {
|
|
731
|
+
merged.set(c.id as number, c)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const missing = [...pcInfo.keys()].filter((id) => !merged.has(id))
|
|
735
|
+
|
|
736
|
+
for (const row of this.#fetchLocalitiesByID(missing)) {
|
|
737
|
+
merged.set(row.id, row)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const scored: Array<PlaceCandidate & { exact: boolean }> = []
|
|
741
|
+
|
|
742
|
+
for (const cand of merged.values()) {
|
|
743
|
+
const info = pcInfo.get(cand.id as number)
|
|
744
|
+
const sPc = info ? (info.containing ? 1 : Math.exp(-info.dist / CF_PC_DECAY_KM)) : 0
|
|
745
|
+
// Fold any postal-city aliases for this candidate's geographic name into the soft name match
|
|
746
|
+
// (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
|
|
747
|
+
// reduces to the original `info?.aliases ?? []` and the score is unchanged.
|
|
748
|
+
const wofAliases = info?.aliases ?? []
|
|
749
|
+
|
|
750
|
+
const aliases = postalAliasByGeo.size
|
|
751
|
+
? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
|
|
752
|
+
: wofAliases
|
|
753
|
+
|
|
754
|
+
const sName = softNameScore(query.text, cand.name, aliases)
|
|
755
|
+
const sPop = populationScaleTerm(cand.population, this.#weights)
|
|
756
|
+
scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 })
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
|
|
760
|
+
// coordinate-only candidates, with the soft-score breaking ties WITHIN a tier. This keeps an
|
|
761
|
+
// unambiguous city ("Berlin", exact + huge population) ahead of the fine-grained Ortsteil its
|
|
762
|
+
// postcode centroid lands in, while a small town the name-match never finds (no exact tier) is
|
|
763
|
+
// still recovered by its postcode's containing locality.
|
|
764
|
+
scored.sort((a, b) => Number(b.exact) - Number(a.exact) || b.score - a.score)
|
|
765
|
+
|
|
766
|
+
// Conflict flag: if the chosen locality is NOT the postcode's containing locality and sits far
|
|
767
|
+
// from it, the postcode and the city name disagree (a transposed / wrong-for-the-city postcode).
|
|
768
|
+
// We keep the name-chosen locality but flag it — the falsehood signal a BM25 geocoder can't give.
|
|
769
|
+
const top = scored[0]
|
|
770
|
+
|
|
771
|
+
if (top) {
|
|
772
|
+
// The postcode's geographic anchor: among the postcode's candidate localities that actually
|
|
773
|
+
// resolved (some — e.g. unnamed Ortsteile — are in the postcode table but not the admin DB),
|
|
774
|
+
// prefer the containing one, else the nearest. Postcodes whose centroid falls just outside
|
|
775
|
+
// every locality polygon still anchor to the closest town.
|
|
776
|
+
const anchorRow = pcRows
|
|
777
|
+
.filter((r) => merged.has(r.id))
|
|
778
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
|
|
779
|
+
.sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
|
|
780
|
+
|
|
781
|
+
const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
|
|
782
|
+
|
|
783
|
+
if (
|
|
784
|
+
anchor &&
|
|
785
|
+
(top.id as number) !== anchorRow!.id &&
|
|
786
|
+
haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
|
|
787
|
+
) {
|
|
788
|
+
top.mismatch = true
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return scored.slice(0, limit).map(({ exact, ...c }) => {
|
|
793
|
+
void exact
|
|
794
|
+
|
|
795
|
+
return c
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
|
|
801
|
+
*/
|
|
802
|
+
#fetchLocalitiesByID(ids: number[]): PlaceCandidate[] {
|
|
803
|
+
if (!ids.length) return []
|
|
804
|
+
const hasPop = this.#hasPopulationIndex.get("main") === true
|
|
805
|
+
const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`
|
|
806
|
+
const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
|
|
807
|
+
const ph = ids.map(() => "?").join(", ")
|
|
808
|
+
|
|
809
|
+
const rows = allRows<RawSearchRow>(
|
|
810
|
+
this.#db.prepare(
|
|
811
|
+
`SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
|
|
812
|
+
s.latitude AS lat, s.longitude AS lon, s.placetype AS placetype, ${popSelect}
|
|
813
|
+
FROM main.spr s ${popJoin}
|
|
814
|
+
WHERE s.id IN (${ph}) AND s.is_current != 0`
|
|
815
|
+
),
|
|
816
|
+
...ids
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
return rows.map((row) => {
|
|
820
|
+
const c: PlaceCandidate = {
|
|
821
|
+
id: row.id,
|
|
822
|
+
name: row.name,
|
|
823
|
+
placetype: row.placetype as WOFPlacetype,
|
|
824
|
+
country: row.country ?? "",
|
|
825
|
+
lat: row.lat ?? 0,
|
|
826
|
+
lon: row.lon ?? 0,
|
|
827
|
+
parent_id: row.parent_id ?? undefined,
|
|
828
|
+
score: 0,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (row.population !== null && row.population > 0) {
|
|
832
|
+
c.population = row.population
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return c
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
[Symbol.dispose](): void {
|
|
840
|
+
// Only when we opened it. A caller who passed a pre-opened client keeps using it after this returns — the FTS
|
|
841
|
+
// build this lookup performed lives on their connection, and closing it would take that with us.
|
|
842
|
+
this.#resources[Symbol.dispose]()
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Build the FTS5 virtual table from the `names` + `places` tables.
|
|
847
|
+
*/
|
|
848
|
+
#ensureFTS(): void {
|
|
849
|
+
buildPlaceSearchFTS(this.#db)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#assertFTSExists(): void {
|
|
853
|
+
if (!placeSearchFTSExists(this.#db)) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
"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)."
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export type { RankingWeights } from "#ranking-weights"
|