@mailwoman/resolver-wof-sqlite 10.7.0 → 11.0.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.
Files changed (189) hide show
  1. package/lib/address/point/interpolation.ts +4 -4
  2. package/lib/address/point/spine-schema.ts +59 -0
  3. package/lib/address/point.ts +44 -1
  4. package/lib/ancestry/backfill.ts +3 -25
  5. package/lib/build/candidate.ts +22 -5
  6. package/lib/build/slim.ts +0 -19
  7. package/lib/candidate/alias-bags.ts +32 -14
  8. package/lib/candidate/bare-admin-names.ts +54 -0
  9. package/lib/candidate/importance.ts +2 -2
  10. package/lib/candidate/lookup.ts +63 -30
  11. package/lib/candidate/own-name.ts +1 -5
  12. package/lib/candidate/scoring.ts +4 -4
  13. package/lib/capitals.ts +2 -2
  14. package/lib/currency-backfill.ts +4 -4
  15. package/lib/fst/autocomplete.ts +1 -1
  16. package/lib/fst/binary-matcher.ts +379 -0
  17. package/lib/fst/builder.ts +1 -1
  18. package/lib/fst/deserialize-web.ts +14 -143
  19. package/lib/fst/matcher.ts +3 -3
  20. package/lib/fst/serialize.ts +1 -1
  21. package/lib/fst.ts +1 -0
  22. package/lib/geonames/aliases.ts +2 -2
  23. package/lib/index.ts +0 -1
  24. package/lib/interpolation.ts +3 -2
  25. package/lib/landmark/rows.ts +91 -0
  26. package/lib/landmark.ts +94 -0
  27. package/lib/lookup.ts +7 -27
  28. package/lib/nsul/lookup.ts +2 -1
  29. package/lib/nsul/schema.ts +2 -8
  30. package/lib/place-importance-schema.ts +1 -1
  31. package/lib/poi/lookup.ts +14 -14
  32. package/lib/primary-preference.ts +13 -20
  33. package/lib/proximity-rerank.ts +2 -2
  34. package/lib/reverse.ts +8 -5
  35. package/lib/schema.ts +0 -18
  36. package/lib/search-fetch.ts +3 -3
  37. package/lib/sqlite/convention-source.ts +1 -1
  38. package/lib/street/centroid.ts +2 -2
  39. package/lib/street/morphology/fst/builder.ts +3 -3
  40. package/lib/street/morphology/fst/loader.ts +3 -3
  41. package/lib/street/normalize.ts +150 -1
  42. package/lib/types.ts +15 -2
  43. package/lib/uprn/existence.ts +0 -1
  44. package/lib/uprn/lookup.ts +5 -5
  45. package/lib/uprn/schema.ts +3 -4
  46. package/lib/weights-overlay-linker.ts +4 -4
  47. package/out/address/point/interpolation.js +4 -4
  48. package/out/address/point/interpolation.js.map +1 -1
  49. package/out/address/point/spine-schema.d.ts +40 -0
  50. package/out/address/point/spine-schema.d.ts.map +1 -0
  51. package/out/address/point/spine-schema.js +40 -0
  52. package/out/address/point/spine-schema.js.map +1 -0
  53. package/out/address/point.d.ts.map +1 -1
  54. package/out/address/point.js +36 -1
  55. package/out/address/point.js.map +1 -1
  56. package/out/ancestry/backfill.d.ts.map +1 -1
  57. package/out/ancestry/backfill.js +3 -22
  58. package/out/ancestry/backfill.js.map +1 -1
  59. package/out/build/candidate.d.ts.map +1 -1
  60. package/out/build/candidate.js +17 -5
  61. package/out/build/candidate.js.map +1 -1
  62. package/out/build/slim.d.ts +1 -1
  63. package/out/build/slim.d.ts.map +1 -1
  64. package/out/build/slim.js +0 -12
  65. package/out/build/slim.js.map +1 -1
  66. package/out/candidate/alias-bags.d.ts +8 -0
  67. package/out/candidate/alias-bags.d.ts.map +1 -1
  68. package/out/candidate/alias-bags.js +20 -11
  69. package/out/candidate/alias-bags.js.map +1 -1
  70. package/out/candidate/bare-admin-names.d.ts +26 -0
  71. package/out/candidate/bare-admin-names.d.ts.map +1 -0
  72. package/out/candidate/bare-admin-names.js +38 -0
  73. package/out/candidate/bare-admin-names.js.map +1 -0
  74. package/out/candidate/importance.js +2 -2
  75. package/out/candidate/importance.js.map +1 -1
  76. package/out/candidate/lookup.d.ts.map +1 -1
  77. package/out/candidate/lookup.js +43 -23
  78. package/out/candidate/lookup.js.map +1 -1
  79. package/out/candidate/own-name.d.ts.map +1 -1
  80. package/out/candidate/own-name.js +1 -5
  81. package/out/candidate/own-name.js.map +1 -1
  82. package/out/candidate/scoring.js +4 -4
  83. package/out/candidate/scoring.js.map +1 -1
  84. package/out/capitals.js +2 -2
  85. package/out/capitals.js.map +1 -1
  86. package/out/currency-backfill.js +4 -4
  87. package/out/currency-backfill.js.map +1 -1
  88. package/out/env.d.ts +3 -0
  89. package/out/env.d.ts.map +1 -1
  90. package/out/fst/autocomplete.js +1 -1
  91. package/out/fst/autocomplete.js.map +1 -1
  92. package/out/fst/binary-matcher.d.ts +34 -0
  93. package/out/fst/binary-matcher.d.ts.map +1 -0
  94. package/out/fst/binary-matcher.js +294 -0
  95. package/out/fst/binary-matcher.js.map +1 -0
  96. package/out/fst/builder.js +1 -1
  97. package/out/fst/builder.js.map +1 -1
  98. package/out/fst/deserialize-web.d.ts +11 -6
  99. package/out/fst/deserialize-web.d.ts.map +1 -1
  100. package/out/fst/deserialize-web.js +12 -94
  101. package/out/fst/deserialize-web.js.map +1 -1
  102. package/out/fst/matcher.js +3 -3
  103. package/out/fst/matcher.js.map +1 -1
  104. package/out/fst/serialize.js +1 -1
  105. package/out/fst/serialize.js.map +1 -1
  106. package/out/fst.d.ts +1 -0
  107. package/out/fst.d.ts.map +1 -1
  108. package/out/fst.js +1 -0
  109. package/out/fst.js.map +1 -1
  110. package/out/geonames/aliases.js +2 -2
  111. package/out/geonames/aliases.js.map +1 -1
  112. package/out/index.d.ts +1 -1
  113. package/out/index.d.ts.map +1 -1
  114. package/out/index.js.map +1 -1
  115. package/out/interpolation.d.ts.map +1 -1
  116. package/out/interpolation.js +3 -2
  117. package/out/interpolation.js.map +1 -1
  118. package/out/landmark/rows.d.ts +63 -0
  119. package/out/landmark/rows.d.ts.map +1 -0
  120. package/out/landmark/rows.js +48 -0
  121. package/out/landmark/rows.js.map +1 -0
  122. package/out/landmark.d.ts +40 -0
  123. package/out/landmark.d.ts.map +1 -0
  124. package/out/landmark.js +78 -0
  125. package/out/landmark.js.map +1 -0
  126. package/out/lookup.d.ts +3 -7
  127. package/out/lookup.d.ts.map +1 -1
  128. package/out/lookup.js +4 -21
  129. package/out/lookup.js.map +1 -1
  130. package/out/nsul/lookup.d.ts +1 -1
  131. package/out/nsul/lookup.d.ts.map +1 -1
  132. package/out/nsul/lookup.js +1 -1
  133. package/out/nsul/lookup.js.map +1 -1
  134. package/out/nsul/schema.d.ts +2 -5
  135. package/out/nsul/schema.d.ts.map +1 -1
  136. package/out/nsul/schema.js +2 -7
  137. package/out/nsul/schema.js.map +1 -1
  138. package/out/place-importance-schema.js +1 -1
  139. package/out/place-importance-schema.js.map +1 -1
  140. package/out/poi/lookup.d.ts +3 -3
  141. package/out/poi/lookup.d.ts.map +1 -1
  142. package/out/poi/lookup.js +9 -9
  143. package/out/poi/lookup.js.map +1 -1
  144. package/out/primary-preference.d.ts +10 -10
  145. package/out/primary-preference.d.ts.map +1 -1
  146. package/out/primary-preference.js +8 -9
  147. package/out/primary-preference.js.map +1 -1
  148. package/out/proximity-rerank.js +2 -2
  149. package/out/proximity-rerank.js.map +1 -1
  150. package/out/reverse.d.ts.map +1 -1
  151. package/out/reverse.js +7 -5
  152. package/out/reverse.js.map +1 -1
  153. package/out/schema.d.ts +0 -17
  154. package/out/schema.d.ts.map +1 -1
  155. package/out/search-fetch.js +3 -3
  156. package/out/search-fetch.js.map +1 -1
  157. package/out/sqlite/convention-source.js +1 -1
  158. package/out/sqlite/convention-source.js.map +1 -1
  159. package/out/street/centroid.js +2 -2
  160. package/out/street/centroid.js.map +1 -1
  161. package/out/street/morphology/fst/builder.d.ts +2 -2
  162. package/out/street/morphology/fst/builder.js +2 -2
  163. package/out/street/morphology/fst/builder.js.map +1 -1
  164. package/out/street/morphology/fst/loader.d.ts +2 -2
  165. package/out/street/morphology/fst/loader.js +2 -2
  166. package/out/street/morphology/fst/loader.js.map +1 -1
  167. package/out/street/normalize.d.ts +23 -0
  168. package/out/street/normalize.d.ts.map +1 -1
  169. package/out/street/normalize.js +126 -1
  170. package/out/street/normalize.js.map +1 -1
  171. package/out/types.d.ts +13 -2
  172. package/out/types.d.ts.map +1 -1
  173. package/out/uprn/existence.d.ts.map +1 -1
  174. package/out/uprn/existence.js.map +1 -1
  175. package/out/uprn/lookup.d.ts.map +1 -1
  176. package/out/uprn/lookup.js +3 -3
  177. package/out/uprn/lookup.js.map +1 -1
  178. package/out/uprn/schema.d.ts +1 -1
  179. package/out/uprn/schema.d.ts.map +1 -1
  180. package/out/uprn/schema.js +1 -1
  181. package/out/uprn/schema.js.map +1 -1
  182. package/out/weights-overlay-linker.js +4 -4
  183. package/out/weights-overlay-linker.js.map +1 -1
  184. package/package.json +16 -13
  185. package/lib/coincident-roles.ts +0 -347
  186. package/out/coincident-roles.d.ts +0 -121
  187. package/out/coincident-roles.d.ts.map +0 -1
  188. package/out/coincident-roles.js +0 -222
  189. package/out/coincident-roles.js.map +0 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The landmark table's read, shared by the native lookup and the range-read lookup a Worker uses. It holds no database
7
+ * client, so a Worker bundle can import it.
8
+ */
9
+
10
+ import type { LandmarkHit, LandmarkKind } from "@mailwoman/core/resolver"
11
+
12
+ import { normalizeLocalityForKey } from "#street/normalize"
13
+
14
+ /**
15
+ * One way of writing one place.
16
+ */
17
+ export interface LandmarkNameTable {
18
+ name_key: string
19
+ qid: string
20
+ /**
21
+ * The place's own display name.
22
+ */
23
+ name: string
24
+ kind: LandmarkKind
25
+ country: string | null
26
+ latitude: number
27
+ longitude: number
28
+ sitelinks: number
29
+ /**
30
+ * The upper-case spelling of a code alias such as `DTW`, or `null` for an ordinary name.
31
+ *
32
+ * A code answers only when written in that case, so `Lax` and `Sea` stay words.
33
+ */
34
+ code: string | null
35
+ /**
36
+ * 1 when this is the place's own name in English or its country's language, 0 for a translation or alias.
37
+ */
38
+ is_primary: number
39
+ /**
40
+ * The importance Photon's index holds for an OpenStreetMap place, or `null` for a Wikidata place.
41
+ */
42
+ importance: number | null
43
+ /**
44
+ * The place's short H3 cell, the spatial key an OpenStreetMap layer's manifest names,
45
+ * or `null` for a Wikidata place.
46
+ */
47
+ h3_cell: number | null
48
+ }
49
+
50
+ /**
51
+ * The read of every row under one name key, best first.
52
+ */
53
+ export const LANDMARK_BY_NAME_SQL =
54
+ "SELECT * FROM landmark_name WHERE name_key = ? ORDER BY is_primary DESC, sitelinks DESC, importance DESC, qid"
55
+
56
+ /**
57
+ * Returns the key a written name is stored and read under.
58
+ *
59
+ * Canonical Unicode form, a typographic apostrophe and a sentence's closing period do not change
60
+ * which place a name means, so `St George’s.` and `St George's` read one row.
61
+ */
62
+ export function landmarkNameKey(written: string): string {
63
+ return normalizeLocalityForKey(written.normalize("NFC").replaceAll("’", "'").trim().replace(/\.+$/u, ""))
64
+ }
65
+
66
+ /**
67
+ * Turns the rows under a name's key into hits for that name as it was written.
68
+ *
69
+ * A code written in its own case is an identifier, so it ranks ahead of every word spelled the same.
70
+ * A code row reads only in that case.
71
+ */
72
+ export function landmarkHitsFromRows(rows: readonly LandmarkNameTable[], name: string): LandmarkHit[] {
73
+ const written = name.trim()
74
+ const isCode = (row: LandmarkNameTable) => row.code === written
75
+
76
+ return rows
77
+ .filter((row) => !row.code || isCode(row))
78
+ .toSorted((a, b) => Number(isCode(b)) - Number(isCode(a)))
79
+ .map((row) => ({
80
+ code: isCode(row),
81
+ qid: row.qid,
82
+ name: row.name,
83
+ kind: row.kind,
84
+ country: row.country,
85
+ lat: row.latitude,
86
+ lon: row.longitude,
87
+ sitelinks: row.sitelinks,
88
+ primary: row.is_primary === 1,
89
+ importance: row.importance ?? null,
90
+ }))
91
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The landmark layer: Wikidata places keyed by every name and alias they are written with, ranked by the number of
7
+ * Wikipedia language editions that describe them. The source is CC0, so the database may ship.
8
+ */
9
+
10
+ import type { LandmarkHit, LandmarkLookup } from "@mailwoman/core/resolver"
11
+ import { DatabaseClient } from "@mailwoman/sqlite/client"
12
+ import { sql, type Kysely } from "kysely"
13
+ import type { PathBuilderLike } from "path-ts"
14
+
15
+ import { LANDMARK_BY_NAME_SQL, landmarkHitsFromRows, landmarkNameKey, type LandmarkNameTable } from "#landmark/rows"
16
+ import { prepareAll, type PreparedAll } from "#sqlite-utils"
17
+
18
+ export interface LandmarkDatabase {
19
+ landmark_name: LandmarkNameTable
20
+ }
21
+
22
+ /**
23
+ * The column order the build's positional insert writes.
24
+ */
25
+ export const LANDMARK_NAME_COLUMNS = [
26
+ "name_key",
27
+ "qid",
28
+ "name",
29
+ "kind",
30
+ "country",
31
+ "latitude",
32
+ "longitude",
33
+ "sitelinks",
34
+ "code",
35
+ "is_primary",
36
+ "importance",
37
+ "h3_cell",
38
+ ] as const satisfies ReadonlyArray<keyof LandmarkNameTable>
39
+
40
+ /**
41
+ * Creates the table.
42
+ *
43
+ * Rows cluster by name, because every read is a probe of one name.
44
+ */
45
+ export async function createLandmarkTables(db: Kysely<LandmarkDatabase>): Promise<void> {
46
+ await db.schema
47
+ .createTable("landmark_name")
48
+ .addColumn("name_key", "text", (c) => c.notNull())
49
+ .addColumn("qid", "text", (c) => c.notNull())
50
+ .addColumn("name", "text", (c) => c.notNull())
51
+ .addColumn("kind", "text", (c) => c.notNull())
52
+ .addColumn("country", "text")
53
+ .addColumn("latitude", "real", (c) => c.notNull())
54
+ .addColumn("longitude", "real", (c) => c.notNull())
55
+ .addColumn("sitelinks", "integer", (c) => c.notNull())
56
+ .addColumn("code", "text")
57
+ .addColumn("is_primary", "integer", (c) => c.notNull())
58
+ .addColumn("importance", "real")
59
+ .addColumn("h3_cell", "integer")
60
+ .addPrimaryKeyConstraint("landmark_name_pk", ["name_key", "qid"])
61
+ .modifyEnd(sql`without rowid`)
62
+ .execute()
63
+ }
64
+
65
+ /**
66
+ * Returns whether an alias is an identifier code rather than a word:
67
+ * two to five upper-case letters or digits.
68
+ */
69
+ export function isLandmarkCode(alias: string): boolean {
70
+ return /^[\p{Lu}\d]{2,5}$/u.test(alias)
71
+ }
72
+
73
+ /**
74
+ * Reads a sealed landmark database.
75
+ */
76
+ export class LandmarkSqliteLookup implements LandmarkLookup, Disposable {
77
+ readonly #db: DatabaseClient<LandmarkDatabase>
78
+
79
+ readonly #byName: PreparedAll<[nameKey: string], LandmarkNameTable>
80
+
81
+ constructor(path: PathBuilderLike) {
82
+ this.#db = new DatabaseClient<LandmarkDatabase>(path, { readOnly: true })
83
+
84
+ this.#byName = prepareAll(this.#db, LANDMARK_BY_NAME_SQL)
85
+ }
86
+
87
+ find(name: string): LandmarkHit[] {
88
+ return landmarkHitsFromRows(this.#byName(landmarkNameKey(name)), name)
89
+ }
90
+
91
+ [Symbol.dispose](): void {
92
+ this.#db[Symbol.dispose]()
93
+ }
94
+ }
package/lib/lookup.ts CHANGED
@@ -7,16 +7,15 @@
7
7
  */
8
8
 
9
9
  import { expandPlacetypeFilter } from "@mailwoman/codex/placetype-map"
10
- import type { Ancestor, CoincidentLocality } from "@mailwoman/core/resolver"
10
+ import type { Ancestor } from "@mailwoman/core/resolver"
11
11
  import { allRows } from "@mailwoman/core/utils"
12
- import { haversineKm } from "@mailwoman/spatial"
12
+ import { haversine } from "@mailwoman/spatial/geometries/point/distance"
13
13
  import type { DatabaseClient, SQLInputValue } from "@mailwoman/sqlite/client"
14
14
  import { SQLiteLookup } from "@mailwoman/sqlite/lookup"
15
15
  import type { PathBuilderLike } from "path-ts"
16
16
 
17
17
  import { ancestorLineage } from "#ancestry"
18
18
  import { candidateFromSearchRow, rankCandidates } from "#candidate/scoring"
19
- import { loadCoincidentLocalities } from "#coincident-roles"
20
19
  import {
21
20
  ADDRESS_CONVENTION_TABLE,
22
21
  resolveConvention,
@@ -81,7 +80,7 @@ export interface WOFSQLitePlaceLookupOpts {
81
80
  *
82
81
  * Absent, every alias code path is skipped and the resolver is byte-identical.
83
82
  */
84
- postalCityAliases?: WOFPostalCityAliasLookup
83
+ postalCityAliases?: WOFPostalCityAliasLookup | null
85
84
  }
86
85
 
87
86
  /**
@@ -155,10 +154,6 @@ export class WOFSQLitePlaceLookup extends SQLiteLookup<WOFDatabase> implements P
155
154
  * Strategy names already warned about, so an unknown name surfaces once.
156
155
  */
157
156
  readonly #warnedUnknownStrategies = new Set<string>()
158
- /**
159
- * Lazily-built `admin_id → coincident localities` map, null until first use.
160
- */
161
- #coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
162
157
  /**
163
158
  * Per-id memoized ancestor lineages, queried once per chain.
164
159
  */
@@ -168,7 +163,7 @@ export class WOFSQLitePlaceLookup extends SQLiteLookup<WOFDatabase> implements P
168
163
  */
169
164
  readonly #postalCityAliases: WOFPostalCityAliasLookup | null
170
165
 
171
- constructor(opts: WOFSQLitePlaceLookupOpts, weights?: Partial<RankingWeights>) {
166
+ constructor(opts: WOFSQLitePlaceLookupOpts, weights?: Partial<RankingWeights> | null) {
172
167
  if (opts.database && opts.databasePath) {
173
168
  throw new Error("WOFSQLitePlaceLookup: pass either `database` or `databasePath`, not both")
174
169
  }
@@ -355,21 +350,6 @@ export class WOFSQLitePlaceLookup extends SQLiteLookup<WOFDatabase> implements P
355
350
  return found
356
351
  }
357
352
 
358
- /**
359
- * Dual-role localities coincident with an admin id, or `[]` when the relation table is absent.
360
- */
361
- coincidentLocalitiesFor(adminID: number | string): CoincidentLocality[] {
362
- const id = typeof adminID === "number" ? adminID : Number(adminID)
363
-
364
- if (!Number.isFinite(id)) return []
365
-
366
- if (!this.#coincidentRolesCache) {
367
- this.#coincidentRolesCache = loadCoincidentLocalities(this.database)
368
- }
369
-
370
- return this.#coincidentRolesCache.get(id) ?? []
371
- }
372
-
373
353
  /**
374
354
  * The ancestor lineage of a place, ordered nearest-first with self excluded,
375
355
  * or `[]` when it has no recorded ancestry.
@@ -549,7 +529,7 @@ export class WOFSQLitePlaceLookup extends SQLiteLookup<WOFDatabase> implements P
549
529
  #countryWOFId(code: string): number | null {
550
530
  const cached = this.#countryWOFIdCache.get(code)
551
531
 
552
- if (cached !== undefined) return cached
532
+ if (cached) return cached
553
533
  let id: number | null
554
534
 
555
535
  try {
@@ -666,12 +646,12 @@ export class WOFSQLitePlaceLookup extends SQLiteLookup<WOFDatabase> implements P
666
646
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array. toSorted would double-allocate on a hot path
667
647
  .sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
668
648
 
669
- const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
649
+ const anchor = anchorRow ? merged.get(anchorRow.id) : null
670
650
 
671
651
  if (
672
652
  anchor &&
673
653
  (top.id as number) !== anchorRow!.id &&
674
- haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
654
+ haversine(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
675
655
  ) {
676
656
  top.mismatch = true
677
657
  }
@@ -25,9 +25,10 @@
25
25
  */
26
26
 
27
27
  import type { UkCountryCode } from "@mailwoman/codex/gb/country"
28
+ import { compactPostcode } from "@mailwoman/postcode/display-form"
28
29
  import { SQLiteLookup, type SQLiteLookupOptions } from "@mailwoman/sqlite/lookup"
29
30
 
30
- import { compactPostcode, type NSULDatabase } from "#nsul/schema"
31
+ import type { NSULDatabase } from "#nsul/schema"
31
32
  import { prepareAll, prepareGet } from "#sqlite-utils"
32
33
 
33
34
  /**
@@ -70,13 +70,6 @@ export interface NSULDatabase extends layerschemadatabase {
70
70
  nsul_meta: NSULMetaTable
71
71
  }
72
72
 
73
- /**
74
- * The compact form of a unit postcode, every space removed and upper-cased.
75
- */
76
- export function compactPostcode(pcds: string): string {
77
- return pcds.replaceAll(/\s+/g, "").toUpperCase()
78
- }
79
-
80
73
  export async function createUPRNPostcodeTable(db: Kysely<NSULDatabase>): Promise<void> {
81
74
  await db.schema
82
75
  .createTable("uprn_postcode")
@@ -102,7 +95,8 @@ export async function createNSULMetaTable(db: Kysely<NSULDatabase>): Promise<voi
102
95
  /**
103
96
  * The `pcds_compact` index the `uprnsForPostcode` probe reads.
104
97
  *
105
- * There is no index on the spaced `pcds` because it is derivable through {@link compactPostcode}.
98
+ * There is no index on the spaced `pcds` because `compactPostcode` from
99
+ * `@mailwoman/postcode/display-form` derives the compact column from it.
106
100
  */
107
101
  export async function createNSULIndexes(db: Kysely<NSULDatabase>): Promise<void> {
108
102
  await db.schema.createIndex("uprn_postcode_pcds_compact").on("uprn_postcode").column("pcds_compact").execute()
@@ -262,7 +262,7 @@ export function loadImportanceSplit<DB>(db: DatabaseClient<DB>): ImportanceSplit
262
262
  for (const row of rows) {
263
263
  const split = splitLegacyImportance(row.importance, population.get(row.id))
264
264
 
265
- if (split.encyclopedic === undefined) {
265
+ if (!split.encyclopedic) {
266
266
  legacyFallbackRows++
267
267
  } else {
268
268
  encyclopedic.set(row.id, split.encyclopedic)
package/lib/poi/lookup.ts CHANGED
@@ -7,16 +7,16 @@
7
7
  * `without rowid` B-tree `poi-schema.ts` builds. Category, brand and name searches share one
8
8
  * artifact. Each path has a method that returns its details.
9
9
  *
10
- * `latLngToCell` and `gridDisk` come from `h3-js`. The 48-bit short-cell packing that turns a raw
10
+ * `latLngToCell` and `gridDisk` come from `@sister.software/h3`. The 48-bit short-cell packing that turns a raw
11
11
  * H3 cell into the integer `poi.h3_cell` stores is `@mailwoman/spatial`'s `shortCellToInt`, and
12
12
  * that math is never reimplemented here.
13
13
  */
14
14
 
15
15
  import { allRows } from "@mailwoman/core/utils"
16
- import { haversineKm, shortCellToInt, type H3Cell } from "@mailwoman/spatial"
16
+ import { haversine, shortCellToInt } from "@mailwoman/spatial"
17
17
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
18
18
  import { SQLiteLookup, type SQLiteLookupOptions } from "@mailwoman/sqlite/lookup"
19
- import { gridDisk, latLngToCell } from "h3-js"
19
+ import { gridDisk, latLngToCell } from "@sister.software/h3"
20
20
 
21
21
  import type { POICategoryCodeTable, POIDatabase, POITable } from "#poi/schema"
22
22
  /**
@@ -79,7 +79,7 @@ export interface POISearchQuery {
79
79
  *
80
80
  * Required for category/brand queries (k-ring expansion).
81
81
  */
82
- center?: { latitude: number; longitude: number }
82
+ center?: { latitude: number; longitude: number } | null
83
83
  /**
84
84
  * Ring budget: the count of res-9 k-rings to expand before giving up.
85
85
  *
@@ -88,7 +88,7 @@ export interface POISearchQuery {
88
88
  * @defaultValue `16` ≈ ~5.4 km
89
89
  */
90
90
  maxRings?: number
91
- limit?: number
91
+ limit?: number | null
92
92
  }
93
93
 
94
94
  export interface POISearchHit {
@@ -183,7 +183,7 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
183
183
  const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT)
184
184
 
185
185
  if (query.name) {
186
- return this.#searchByName(query.name, limit, query.center)
186
+ return this.#searchByName(query.name, limit, query.center ?? undefined)
187
187
  }
188
188
 
189
189
  if (query.categoryIDs?.length || query.brandWikidata) {
@@ -216,7 +216,7 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
216
216
 
217
217
  return sortByDistance(rows, center)
218
218
  .filter(
219
- (row) => haversineKm(center.latitude, center.longitude, row.latitude, row.longitude) <= BRAND_MAX_DISTANCE_KM
219
+ (row) => haversine(center.latitude, center.longitude, row.latitude, row.longitude) <= BRAND_MAX_DISTANCE_KM
220
220
  )
221
221
  .slice(0, limit)
222
222
  .map((row) => toHit(row, this.#idToCategory, center))
@@ -235,7 +235,7 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
235
235
  for (const id of query.categoryIDs ?? []) {
236
236
  const resolved = this.#categoryToID.get(id)
237
237
 
238
- if (resolved !== undefined) {
238
+ if (resolved) {
239
239
  categoryIDs.push(resolved)
240
240
  }
241
241
  }
@@ -244,7 +244,7 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
244
244
  // This is a clean miss instead of a throw.
245
245
  if (!categoryIDs.length) return []
246
246
 
247
- const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION) as H3Cell
247
+ const origin = latLngToCell(center.latitude, center.longitude, POI_H3_RESOLUTION)
248
248
  const seenCells = new Set<string>()
249
249
  let rows: POIRow[] = []
250
250
 
@@ -252,12 +252,12 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
252
252
  for (let ring = 0; ring < maxRings; ring++) {
253
253
  // gridDisk(origin, ring) returns the whole disk out to `ring`, so diffing against
254
254
  // the already-probed set derives only this ring's new cells.
255
- const diskCells = gridDisk(origin, ring) as string[]
255
+ const diskCells = gridDisk(origin, ring)
256
256
  const newCells = diskCells.filter((cell) => !seenCells.has(cell))
257
257
 
258
258
  for (const cell of newCells) {
259
259
  seenCells.add(cell)
260
- const shortCell = shortCellToInt(cell as H3Cell)
260
+ const shortCell = shortCellToInt(cell)
261
261
 
262
262
  for (const categoryID of categoryIDs) {
263
263
  rows.push(...allRows<POIRow>(this.#categoryCellProbe, shortCell, categoryID, limit))
@@ -344,8 +344,8 @@ export class POILookup<DB extends POIDatabase = POIDatabase> extends SQLiteLooku
344
344
  function sortByDistance(rows: POIRow[], center: { latitude: number; longitude: number }): POIRow[] {
345
345
  return [...rows].toSorted(
346
346
  (a, b) =>
347
- haversineKm(center.latitude, center.longitude, a.latitude, a.longitude) -
348
- haversineKm(center.latitude, center.longitude, b.latitude, b.longitude)
347
+ haversine(center.latitude, center.longitude, a.latitude, a.longitude) -
348
+ haversine(center.latitude, center.longitude, b.latitude, b.longitude)
349
349
  )
350
350
  }
351
351
 
@@ -363,7 +363,7 @@ function toHit(
363
363
  country: row.country,
364
364
  confidence: row.confidence,
365
365
  gersID: row.gers_id,
366
- distanceM: center ? haversineKm(center.latitude, center.longitude, row.latitude, row.longitude) * 1000 : null,
366
+ distanceM: center ? haversine(center.latitude, center.longitude, row.latitude, row.longitude) * 1000 : null,
367
367
  }
368
368
  }
369
369
 
@@ -11,14 +11,15 @@ import type { CandidateTable } from "#candidate/schema"
11
11
  *
12
12
  * `is_primary` is optional, so an older artifact without the column skips the re-rank without an error.
13
13
  */
14
- export type PrimaryPreferenceRow = Pick<CandidateTable, "neg_rank" | "country_id"> &
14
+ export type PrimaryPreferenceRow = Pick<CandidateTable, "neg_rank"> &
15
15
  Partial<Pick<CandidateTable, "is_primary" | "placetype_id" | "population" | "name_role">>
16
16
 
17
17
  /**
18
- * The log10-population penalty applied to an alias row when the top primary-name
19
- * row for the same key is in another country.
18
+ * The log10-population penalty applied to an alias row when another row holds
19
+ * the same key as its primary name.
20
20
  *
21
- * An alias at least 10 times as populous still wins, so alternate names such as "LA" keep working.
21
+ * An alias at least 10 times as populous still wins, so alternate names such as "LA"
22
+ * and misspellings of a large city such as "Dehli" keep working.
22
23
  */
23
24
  export const PRIMARY_PREFERENCE_LOG10 = 1
24
25
 
@@ -37,7 +38,7 @@ export const RERANK_FETCH = 64
37
38
  */
38
39
  export type RankedRow<R> = R & {
39
40
  /**
40
- * `neg_rank` plus any cross-country alias penalty.
41
+ * `neg_rank` plus any alias penalty.
41
42
  *
42
43
  * Rows are ordered by this value.
43
44
  * The emitted `prominence` derives from it.
@@ -47,12 +48,11 @@ export type RankedRow<R> = R & {
47
48
  effectiveNegRank: number
48
49
 
49
50
  /**
50
- * True when this row is a cross-country alias that the penalty moved behind
51
- * the top primary for the same key.
51
+ * True when this row is an alias that the penalty moved behind the top primary for the same key.
52
52
  *
53
53
  * A demoted row loses `exactMatch`, so the resolver's country pin cannot promote
54
- * a coincidental foreign exonym back over the primary.
55
- * A dominant alias and a same-country alias are never demoted.
54
+ * a coincidental exonym back over the primary.
55
+ * A dominant alias is never demoted.
56
56
  */
57
57
  demoted: boolean
58
58
 
@@ -82,7 +82,7 @@ export type RankedRow<R> = R & {
82
82
  /**
83
83
  * Re-ranks population-ordered candidate rows.
84
84
  *
85
- * A cross-country alias must beat the top primary by `delta` in log10 population.
85
+ * An alias must beat the top primary by `delta` in log10 population, in any country.
86
86
  * The function returns the top `limit` rows.
87
87
  *
88
88
  * When `placetypes` is given, an exact tie prefers a populated `locality`.
@@ -103,20 +103,13 @@ export function rankByPrimaryPreference<R extends PrimaryPreferenceRow>(
103
103
  }
104
104
  }
105
105
 
106
- const topCountry = topPrimary?.country_id
107
-
108
- const wouldPenalize = (r: R): boolean =>
109
- typeof topCountry === "number" && r.is_primary !== 1 && r.country_id !== topCountry
110
-
111
- const isCrossCountryAlias = (r: R): boolean =>
112
- wouldPenalize(r) && !(exemptVariantAliases && r.name_role === "variant")
106
+ const isAliasOfPrimary = (r: R): boolean => topPrimary != null && r.is_primary !== 1
113
107
 
114
108
  const annotate = (r: R): RankedRow<R> => {
115
- const penalized = isCrossCountryAlias(r)
109
+ const exempted = exemptVariantAliases && r.name_role === "variant" && isAliasOfPrimary(r)
110
+ const penalized = isAliasOfPrimary(r) && !exempted
116
111
  const effectiveNegRank = r.neg_rank + (penalized ? delta : 0)
117
112
 
118
- const exempted = !penalized && exemptVariantAliases && r.name_role === "variant" && wouldPenalize(r)
119
-
120
113
  return {
121
114
  ...r,
122
115
  effectiveNegRank,
@@ -23,7 +23,7 @@
23
23
  * ordering silently discarded downstream.
24
24
  */
25
25
 
26
- import { haversineKm } from "@mailwoman/spatial"
26
+ import { haversine } from "@mailwoman/spatial/geometries/point/distance"
27
27
 
28
28
  /**
29
29
  * Full magnitude of the nearness term at distance 0, before decay.
@@ -92,7 +92,7 @@ export function combinedProminence(candidate: ProximityRerankable, bias: readonl
92
92
  // A 0,0 pair interpreted as a real coordinate would receive an enormous score.
93
93
  if (!(candidate.lat === 0 && candidate.lon === 0)) {
94
94
  for (const b of bias) {
95
- const d = haversineKm(b.lat, b.lon, candidate.lat, candidate.lon)
95
+ const d = haversine(b.lat, b.lon, candidate.lat, candidate.lon)
96
96
  const term = (BIAS_BOOST * (b.weight ?? 1)) / (1 + d / PROX_SCALE_KM)
97
97
 
98
98
  if (term > proxTerm) {
package/lib/reverse.ts CHANGED
@@ -24,7 +24,10 @@
24
24
 
25
25
  import { tryParsingJSON } from "@mailwoman/core/json"
26
26
  import { allRows } from "@mailwoman/core/utils"
27
- import { bboxAround, geometryContains, haversineKm, type ParsedGeometry } from "@mailwoman/spatial"
27
+ import type { ParsedGeometry } from "@mailwoman/spatial"
28
+ import { bboxAround } from "@mailwoman/spatial/bbox"
29
+ import { haversine } from "@mailwoman/spatial/geometries/point/distance"
30
+ import { geometryContains } from "@mailwoman/spatial/geometries/polygon"
28
31
  import { DatabaseClient } from "@mailwoman/sqlite/client"
29
32
  import { tableExists } from "@mailwoman/sqlite/introspection"
30
33
  import type { PathBuilderLike } from "path-ts"
@@ -311,7 +314,7 @@ export class WOFReverseGeocoder implements Disposable {
311
314
  let bestKm = Infinity
312
315
 
313
316
  for (const c of pointOnly) {
314
- const km = haversineKm(lat, lon, c.lat, c.lon)
317
+ const km = haversine(lat, lon, c.lat, c.lon)
315
318
 
316
319
  if (km < bestKm) {
317
320
  bestKm = km
@@ -325,7 +328,7 @@ export class WOFReverseGeocoder implements Disposable {
325
328
  // Approximate descent into finer tiers than the winner.
326
329
  let current = winner
327
330
  let currentConfirmed = winnerConfirmed
328
- let currentDistanceKm = currentConfirmed ? null : haversineKm(lat, lon, current.lat, current.lon)
331
+ let currentDistanceKm = currentConfirmed ? null : haversine(lat, lon, current.lat, current.lon)
329
332
 
330
333
  for (const tier of DESCENT_TIERS) {
331
334
  if (placetypeDepth(tier) <= placetypeDepth(current.placetype)) continue
@@ -349,7 +352,7 @@ export class WOFReverseGeocoder implements Disposable {
349
352
  }
350
353
 
351
354
  if (contains === false) continue // known not-here, polygon rejected
352
- const km = haversineKm(lat, lon, k.lat, k.lon)
355
+ const km = haversine(lat, lon, k.lat, k.lon)
353
356
 
354
357
  if (km <= maxApproximateKm && km < bestKm) {
355
358
  bestKm = km
@@ -494,7 +497,7 @@ export class WOFReverseGeocoder implements Disposable {
494
497
  if (!this.#polygons) return null
495
498
  const cached = this.#geometryCache.get(id)
496
499
 
497
- if (cached !== undefined) return cached
500
+ if (cached) return cached
498
501
 
499
502
  if (this.#geometryCache.size >= WOFReverseGeocoder.#GEOMETRY_CACHE_CAP) {
500
503
  this.#geometryCache.clear()
package/lib/schema.ts CHANGED
@@ -170,23 +170,6 @@ export interface ConcordancesTable {
170
170
  lastmodified: number
171
171
  }
172
172
 
173
- /**
174
- * `coincident_roles`.
175
- *
176
- * The dual-role relation, where a place is both an admin region and a locality (Berlin the city-state).
177
- *
178
- * One row per (admin, locality) pair the resolver can complete a hierarchy with.
179
- * Surfaced by {@link MailwomanLookupLike.coincidentRolesFor}.
180
- */
181
- export interface CoincidentRolesTable {
182
- admin_id: number
183
- locality_id: number
184
- relationship_type: string
185
- admin_placetype: string
186
- distance_km: number
187
- locality_population: number
188
- }
189
-
190
173
  /**
191
174
  * The full schema we hand to `Kysely<WOFDatabase>` / `new DatabaseClient<WOFDatabase>(...)`.
192
175
  *
@@ -205,5 +188,4 @@ export interface WOFDatabase {
205
188
  place_population: PlacePopulationTable
206
189
  place_abbr: PlaceAbbrTable
207
190
  concordances: ConcordancesTable
208
- coincident_roles: CoincidentRolesTable
209
191
  }
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { allRows } from "@mailwoman/core/utils"
16
- import { bboxAround } from "@mailwoman/spatial"
16
+ import { bboxAround } from "@mailwoman/spatial/bbox"
17
17
  import type { DatabaseClient, SQLInputValue } from "@mailwoman/sqlite/client"
18
18
 
19
19
  import { PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE } from "#fts"
@@ -142,7 +142,7 @@ export function fetchSearchRows<DB>(options: {
142
142
  params.push(query.country)
143
143
  }
144
144
 
145
- if (query.parentID !== undefined) {
145
+ if (query.parentID) {
146
146
  where.push(`(spr.parent_id = ? OR spr.id IN (SELECT id FROM ${sch}.ancestors WHERE ancestor_id = ?))`)
147
147
  params.push(query.parentID, query.parentID)
148
148
  }
@@ -152,7 +152,7 @@ export function fetchSearchRows<DB>(options: {
152
152
  // A requested filter on a legacy extract is silently treated as no bbox filter
153
153
  // so the query does not crash.
154
154
  const extractHasBbox = hasBboxIndex.get(sch) === true
155
- const useBboxJoin = (query.bbox || query.near?.maxDistanceKm !== undefined) && extractHasBbox
155
+ const useBboxJoin = (query.bbox || query.near?.maxDistanceKm) && extractHasBbox
156
156
  let joinClause = `JOIN ${sch}.spr ON spr.id = place_search.wof_id`
157
157
 
158
158
  if (useBboxJoin) {
@@ -35,7 +35,7 @@ export class SqliteConventionSource<DB> implements ConventionSource {
35
35
  get(wofID: number): Convention | null {
36
36
  const cached = this.#cache.get(wofID)
37
37
 
38
- if (cached !== undefined) return cached
38
+ if (cached) return cached
39
39
  let value: Convention | null = null
40
40
 
41
41
  try {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { StreetCentroidHit, StreetCentroidLookup } from "@mailwoman/core/resolver"
9
- import { haversineKm } from "@mailwoman/spatial"
9
+ import { haversine } from "@mailwoman/spatial/geometries/point/distance"
10
10
  import { DatabaseClient } from "@mailwoman/sqlite/client"
11
11
  import type { PathBuilderLike } from "path-ts"
12
12
 
@@ -51,7 +51,7 @@ const AGG_SELECT =
51
51
  * Half the bounding-box diagonal in meters, used as a coarse uncertainty radius for a street centroid.
52
52
  */
53
53
  function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: number): number {
54
- return Math.round(haversineKm(minLat, minLon, maxLat, maxLon) * 500)
54
+ return Math.round(haversine(minLat, minLon, maxLat, maxLon) * 500)
55
55
  }
56
56
 
57
57
  /**