@mailwoman/resolver-wof-sqlite 9.3.0 → 9.4.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 (109) hide show
  1. package/lib/address/point-interpolation.ts +1 -2
  2. package/lib/address/point.ts +54 -6
  3. package/lib/ancestry/index.ts +1 -2
  4. package/lib/build-candidate.ts +19 -6
  5. package/lib/candidate/extract-fold.ts +89 -8
  6. package/lib/candidate/own-name.ts +1 -0
  7. package/lib/candidate-lookup.ts +10 -9
  8. package/lib/candidate-schema.ts +1 -1
  9. package/lib/candidate-scoring.ts +1 -1
  10. package/lib/coincident-roles.ts +2 -3
  11. package/lib/coverage-manifest-schema.ts +2 -1
  12. package/lib/fst/builder.ts +1 -1
  13. package/lib/fst/freshness.ts +0 -1
  14. package/lib/interpolation.ts +1 -1
  15. package/lib/lookup.ts +2 -2
  16. package/lib/place-importance-schema.ts +1 -9
  17. package/lib/poi/lookup.ts +1 -2
  18. package/lib/primary-preference.ts +14 -4
  19. package/lib/reverse.ts +1 -2
  20. package/lib/search-fetch.ts +1 -2
  21. package/lib/sqlite-utils.ts +0 -1
  22. package/lib/street/centroid.ts +1 -1
  23. package/lib/street/normalize.ts +42 -1
  24. package/lib/uprn/lookup.ts +1 -2
  25. package/lib/weights-overlay-linker.ts +1 -0
  26. package/out/address/point-interpolation.d.ts +1 -1
  27. package/out/address/point-interpolation.d.ts.map +1 -1
  28. package/out/address/point-interpolation.js.map +1 -1
  29. package/out/address/point.d.ts +3 -1
  30. package/out/address/point.d.ts.map +1 -1
  31. package/out/address/point.js +37 -6
  32. package/out/address/point.js.map +1 -1
  33. package/out/ancestry/index.d.ts.map +1 -1
  34. package/out/ancestry/index.js +1 -1
  35. package/out/ancestry/index.js.map +1 -1
  36. package/out/build-candidate.d.ts +5 -3
  37. package/out/build-candidate.d.ts.map +1 -1
  38. package/out/build-candidate.js +11 -3
  39. package/out/build-candidate.js.map +1 -1
  40. package/out/candidate/extract-fold.d.ts +11 -2
  41. package/out/candidate/extract-fold.d.ts.map +1 -1
  42. package/out/candidate/extract-fold.js +56 -7
  43. package/out/candidate/extract-fold.js.map +1 -1
  44. package/out/candidate/own-name.d.ts.map +1 -1
  45. package/out/candidate/own-name.js +1 -0
  46. package/out/candidate/own-name.js.map +1 -1
  47. package/out/candidate-lookup.d.ts +5 -5
  48. package/out/candidate-lookup.d.ts.map +1 -1
  49. package/out/candidate-lookup.js +8 -7
  50. package/out/candidate-lookup.js.map +1 -1
  51. package/out/candidate-schema.d.ts +1 -1
  52. package/out/candidate-schema.js +1 -1
  53. package/out/candidate-scoring.d.ts.map +1 -1
  54. package/out/candidate-scoring.js +1 -1
  55. package/out/candidate-scoring.js.map +1 -1
  56. package/out/coincident-roles.d.ts +1 -1
  57. package/out/coincident-roles.d.ts.map +1 -1
  58. package/out/coincident-roles.js +1 -1
  59. package/out/coincident-roles.js.map +1 -1
  60. package/out/coverage-manifest-schema.d.ts.map +1 -1
  61. package/out/coverage-manifest-schema.js +2 -1
  62. package/out/coverage-manifest-schema.js.map +1 -1
  63. package/out/fst/builder.d.ts.map +1 -1
  64. package/out/fst/builder.js +1 -1
  65. package/out/fst/builder.js.map +1 -1
  66. package/out/fst/freshness.d.ts +0 -1
  67. package/out/fst/freshness.d.ts.map +1 -1
  68. package/out/fst/freshness.js +0 -1
  69. package/out/fst/freshness.js.map +1 -1
  70. package/out/interpolation.d.ts +1 -1
  71. package/out/interpolation.d.ts.map +1 -1
  72. package/out/lookup.d.ts +1 -1
  73. package/out/lookup.d.ts.map +1 -1
  74. package/out/lookup.js +2 -2
  75. package/out/lookup.js.map +1 -1
  76. package/out/place-importance-schema.d.ts +0 -1
  77. package/out/place-importance-schema.d.ts.map +1 -1
  78. package/out/place-importance-schema.js +1 -2
  79. package/out/place-importance-schema.js.map +1 -1
  80. package/out/poi/lookup.d.ts.map +1 -1
  81. package/out/poi/lookup.js +1 -1
  82. package/out/poi/lookup.js.map +1 -1
  83. package/out/primary-preference.d.ts +2 -2
  84. package/out/primary-preference.d.ts.map +1 -1
  85. package/out/primary-preference.js +12 -4
  86. package/out/primary-preference.js.map +1 -1
  87. package/out/reverse.d.ts.map +1 -1
  88. package/out/reverse.js +1 -1
  89. package/out/reverse.js.map +1 -1
  90. package/out/search-fetch.d.ts.map +1 -1
  91. package/out/search-fetch.js +1 -1
  92. package/out/search-fetch.js.map +1 -1
  93. package/out/sqlite-utils.d.ts +0 -1
  94. package/out/sqlite-utils.d.ts.map +1 -1
  95. package/out/sqlite-utils.js +0 -3
  96. package/out/sqlite-utils.js.map +1 -1
  97. package/out/street/centroid.d.ts +1 -1
  98. package/out/street/centroid.d.ts.map +1 -1
  99. package/out/street/normalize.d.ts +13 -1
  100. package/out/street/normalize.d.ts.map +1 -1
  101. package/out/street/normalize.js +37 -0
  102. package/out/street/normalize.js.map +1 -1
  103. package/out/uprn/lookup.d.ts.map +1 -1
  104. package/out/uprn/lookup.js +1 -1
  105. package/out/uprn/lookup.js.map +1 -1
  106. package/out/weights-overlay-linker.d.ts.map +1 -1
  107. package/out/weights-overlay-linker.js +1 -0
  108. package/out/weights-overlay-linker.js.map +1 -1
  109. package/package.json +9 -9
@@ -33,7 +33,7 @@
33
33
  * Standalone like the segment tier — core wiring rides the Phase 2 ordered `spatialTiers` list.
34
34
  */
35
35
 
36
- import type { InterpolationLookup } from "@mailwoman/resolver"
36
+ import type { InterpolationLookup } from "@mailwoman/core/resolver"
37
37
  import { haversineKm } from "@mailwoman/spatial"
38
38
  import { DatabaseClient } from "@mailwoman/sqlite/client"
39
39
 
@@ -41,7 +41,6 @@ import type { AddressPointDatabase } from "#address/point-schema"
41
41
  import type { InterpolatedHit, InterpolationQuery, StreetInterpolator } from "#interpolation"
42
42
  import { hasTable, prepareAll, type PreparedAll } from "#sqlite-utils"
43
43
  import { canonicalizeRouteKey, type RouteKey, streetKeyVariants } from "#street/normalize"
44
-
45
44
  /**
46
45
  * Extrapolation cap for a single-sided bracket: at most one pair-span beyond the nearest known point (`t = 2`). Past
47
46
  * it, the two-point line carries no evidence about the query number.
@@ -15,13 +15,15 @@
15
15
  * scopes missed). Multiple hits return the first by rowid — unit siblings share the building coord.
16
16
  */
17
17
 
18
- import type { AddressPointHit, AddressPointLookup } from "@mailwoman/resolver"
18
+ import type { AddressPointHit, AddressPointLookup } from "@mailwoman/core/resolver"
19
19
  import { DatabaseClient } from "@mailwoman/sqlite/client"
20
20
 
21
21
  import type { AddressPointDatabase, AddressPointTable } from "#address/point-schema"
22
22
  import { hasTable, prepareGet, type PreparedGet } from "#sqlite-utils"
23
23
  import {
24
+ normalizeHouseNumberForKey,
24
25
  normalizeLocalityForKey,
26
+ normalizeLocalityForKeyLocale,
25
27
  type NameKey,
26
28
  type StreetKey,
27
29
  streetKeyVariants,
@@ -53,6 +55,13 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
53
55
  | PreparedGet<[postcode: string, locality: NameKey, street: StreetKey, number: string], AddressPointRow>
54
56
  | undefined
55
57
  readonly #byLocality: PreparedGet<[locality: NameKey, street: StreetKey, number: string], AddressPointRow> | undefined
58
+ /**
59
+ * The scope key matched by its TAIL — a `zh` query that names the 鄉鎮市區 without its 縣市. Narrowed by the (street,
60
+ * number) index first, so the LIKE walks the handful of rows that share the pair.
61
+ */
62
+ readonly #byLocalityTail:
63
+ | PreparedGet<[street: StreetKey, number: string, tailPattern: string], AddressPointRow>
64
+ | undefined
56
65
  readonly #byBbox:
57
66
  | PreparedGet<
58
67
  [street: StreetKey, number: string, minLat: number, maxLat: number, minLon: number, maxLon: number],
@@ -102,6 +111,12 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
102
111
  `SELECT ${SELECT_COLS} FROM address_point
103
112
  WHERE street_norm = ? AND number = ? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ? LIMIT 1`
104
113
  )
114
+
115
+ this.#byLocalityTail = prepareGet(
116
+ this.#db,
117
+ `SELECT ${SELECT_COLS} FROM address_point
118
+ WHERE street_norm = ? AND number = ? AND locality_norm LIKE ? LIMIT 1`
119
+ )
105
120
  }
106
121
  }
107
122
 
@@ -110,20 +125,33 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
110
125
  number: string
111
126
  postcode?: string
112
127
  locality?: string
128
+ region?: string
129
+ subregion?: string
113
130
  bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
114
131
  }): AddressPointHit | null {
115
132
  if (!this.#byPostcode || !this.#byLocality || !this.#byBbox) return null
116
- const number = query.number.trim().toLowerCase()
133
+ const number = normalizeHouseNumberForKey(query.number, this.#locale)
117
134
 
118
135
  if (!number) return null
119
136
 
137
+ // A `zh` extract scopes a point by 縣市 + 鄉鎮市區, the pair the parse tags `region` + `subregion`; the Taiwanese
138
+ // register carries no postcode and the parse no `locality`. The pair becomes the locality key here, on the
139
+ // reader that was built with the same fold, and a Latin extract never sees the two fields. A line that names
140
+ // only the 鄉鎮市區 (`中和區中興街281號`, 14.7% of the TW board) matches the stored pair by its tail instead.
141
+ const scoped =
142
+ this.#locale === "zh" && !query.locality && query.subregion
143
+ ? query.region
144
+ ? { ...query, locality: `${query.region}${query.subregion}` }
145
+ : { ...query, localityTail: normalizeLocalityForKeyLocale(query.subregion, "zh") }
146
+ : query
147
+
120
148
  // Key-variant ladder (see `streetKeyVariants`): the literal key first, then the doubled-type
121
149
  // collapse and the saint↔st register swap — each variant runs the FULL number ladder below, and
122
150
  // the first variant to answer wins, so an attested literal key is never second-guessed.
123
151
  let row: AddressPointRow | undefined
124
152
 
125
153
  for (const streetNorm of streetKeyVariants(query.street, streetLocaleForSurface(query.street, this.#locale))) {
126
- row = this.#findForKey(streetNorm, number, query)
154
+ row = this.#findForKey(streetNorm, number, scoped)
127
155
 
128
156
  if (row) break
129
157
  }
@@ -152,6 +180,7 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
152
180
  number: string
153
181
  postcode?: string
154
182
  locality?: string
183
+ localityTail?: NameKey
155
184
  bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
156
185
  }
157
186
  ) {
@@ -186,6 +215,19 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
186
215
  }
187
216
  }
188
217
 
218
+ // Sub-number fallback for the Taiwanese register: `14之12` is building 12 off number 14, `30附40` an attached
219
+ // number, stored as number `14` / `30` with the rest in `unit`. A query that kept the pair on the number span
220
+ // falls to the base number — the same adjacent-parcel approximation the letter-suffix rung makes, priced the
221
+ // same: on the 2,000-row served read the rows answered this way sit a median 256 m and at most 1.0 km from the
222
+ // building.
223
+ if (!row && this.#locale === "zh") {
224
+ const base = /^(\d+)(?:[之附]\d+)+$/u.exec(number)?.[1]
225
+
226
+ if (base) {
227
+ row = this.#probe(streetNorm, base, query)
228
+ }
229
+ }
230
+
189
231
  return row
190
232
  }
191
233
 
@@ -199,6 +241,7 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
199
241
  query: {
200
242
  postcode?: string
201
243
  locality?: string
244
+ localityTail?: NameKey
202
245
  bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
203
246
  }
204
247
  ): AddressPointRow | undefined {
@@ -226,6 +269,11 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
226
269
  row = this.#byLocality!(this.#localityKey(query.locality), streetNorm, number)
227
270
  }
228
271
 
272
+ if (!row && query.localityTail && this.#byLocalityTail) {
273
+ // A Han key carries no `%` or `_`, so the tail is a literal suffix pattern.
274
+ row = this.#byLocalityTail(streetNorm, number, `%${query.localityTail}`)
275
+ }
276
+
229
277
  // Bbox fall-through (#247): the point carries no postcode/locality of its own, but its coordinate falls
230
278
  // inside the resolved locality's box. Only reached when the scoped probes missed AND a bbox was supplied.
231
279
  if (!row && query.bbox) {
@@ -248,9 +296,9 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
248
296
  * 13e Arrondissement" and "Paris" both hit; a no-op for every other locale.
249
297
  */
250
298
  #localityKey(locality: string): NameKey {
251
- return this.#locale === "fr"
252
- ? stripArrondissement(normalizeLocalityForKey(locality))
253
- : normalizeLocalityForKey(locality)
299
+ if (this.#locale === "fr") return stripArrondissement(normalizeLocalityForKey(locality))
300
+
301
+ return normalizeLocalityForKeyLocale(locality, this.#locale)
254
302
  }
255
303
 
256
304
  /**
@@ -15,10 +15,9 @@
15
15
  * geocoding always does).
16
16
  */
17
17
 
18
+ import { allRows } from "@mailwoman/core/utils"
18
19
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
19
20
 
20
- import { allRows } from "#sqlite-utils"
21
-
22
21
  /**
23
22
  * WOF placetype → containment depth, coarsest = 1. Higher = finer. Placetypes we never resolve (continent, empire, …)
24
23
  * map to 0 and sort last. NOT the same table as the FST's `PLACETYPE_ORDER` (fst-serialize.ts) — that one is a
@@ -97,9 +97,11 @@ export interface BuildCandidateOptions {
97
97
  /**
98
98
  * Optional LOCALITY extracts (`spr` rows with `placetype='locality'` + real coords, e.g. localities-nz-linz.db — the
99
99
  * #1564 NZ suburb tier) — folded through the same extract loop as the postcode extracts, staged as `locality`
100
- * candidate rows with no region scope and UNMEASURED population (`neg_rank 0`: a extract row ranks behind any
101
- * populated namesake and wins only where its key is the answer). Each extract's `names` table folds as aliases,
102
- * `is_primary = 0`, same as the delivery-city pass.
100
+ * candidate rows with UNMEASURED population (`neg_rank 0`: a extract row ranks behind any populated namesake and wins
101
+ * only where its key is the answer). Each extract's `names` table folds as aliases, `is_primary = 0`, same as the
102
+ * delivery-city pass. An extract whose `ancestors` table names an admin region for a row gives that row the region's
103
+ * scope (`region_id`) plus closure rows for the region and the region's own chain above it; an extract without one
104
+ * stays unscoped, as before.
103
105
  */
104
106
  localities?: string[]
105
107
  /**
@@ -520,6 +522,8 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
520
522
  }
521
523
 
522
524
  let nLocality = 0
525
+ let nLocalityScoped = 0
526
+ let nLocalityAncestor = 0
523
527
 
524
528
  for (const locDB of opts.localities ?? []) {
525
529
  const folded = foldExtract({
@@ -529,14 +533,23 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
529
533
  ccID,
530
534
  ptID,
531
535
  stageRow,
536
+ attrs,
532
537
  progress,
533
538
  })
534
539
 
535
540
  nLocality += folded.primaries
541
+ nLocalityScoped += folded.scoped
542
+ nLocalityAncestor += folded.ancestorRows
536
543
  }
537
544
 
538
545
  if (nLocality > 0) {
539
- progress("localities", `${nLocality.toLocaleString()} extract localities folded`)
546
+ progress(
547
+ "localities",
548
+ `${nLocality.toLocaleString()} extract localities folded` +
549
+ (nLocalityScoped
550
+ ? `; ${nLocalityScoped.toLocaleString()} carry a region scope (${nLocalityAncestor.toLocaleString()} closure rows)`
551
+ : "")
552
+ )
540
553
  }
541
554
 
542
555
  // --- code dictionaries: typed batch inserts via kdb (a few hundred rows — Kysely is clean here) ---
@@ -610,8 +623,8 @@ export async function buildCandidateTable(opts: BuildCandidateOptions): Promise<
610
623
  abbrevs: nAbbr,
611
624
  postcodes: nPostcode,
612
625
  postcodeAliases: nPostcodeAlias,
613
- ancestorRows: sidecar.ancestorRows,
614
- ancestorPlaces: sidecar.ancestorPlaces,
626
+ ancestorRows: sidecar.ancestorRows + nLocalityAncestor,
627
+ ancestorPlaces: sidecar.ancestorPlaces + nLocalityScoped,
615
628
  intervalPlaces: sidecar.intervalPlaces,
616
629
  ...roles,
617
630
  ...(importance ? { importanceScored: importance.matched, importanceFiltered: importance.refused } : {}),
@@ -8,17 +8,51 @@
8
8
  import { DatabaseClient } from "@mailwoman/sqlite/client"
9
9
  import { tableExists } from "@mailwoman/sqlite/introspection"
10
10
 
11
+ import { CANDIDATE_ANCESTOR_COLUMNS, CANDIDATE_ANCESTOR_TABLE } from "#candidate-ancestors-schema"
11
12
  import type { CandidateDatabase } from "#candidate-schema"
12
13
  import type { PlaceAttrs, StageRow } from "#candidate/place-attrs"
13
14
  import type { WOFDatabase } from "#schema"
14
15
  import { normalizeLocalityForKey } from "#street/normalize"
15
16
 
17
+ /**
18
+ * The extract's own region-tier ancestry, when it carries any: extract id → the admin region's id. A locality extract
19
+ * derived from a national register (the Taiwanese 鄉鎮市區 from the civil-affairs address points) knows which 縣市 each row
20
+ * belongs to, and writes that as an `ancestors` row against the admin region's WOF id. Without it a folded row has no
21
+ * region scope, so a `parentID`-scoped probe misses it and the cascade widens to whatever namesake carries the key.
22
+ *
23
+ * Only a region the admin build staged (`attrs`) counts: an id the admin does not know cannot be denormalized into a
24
+ * closure row, and a scope nothing can match would be a silent zero rather than an absence.
25
+ */
26
+ function extractRegionAncestry(pc: DatabaseClient<WOFDatabase>, attrs: Map<number, PlaceAttrs>): Map<number, number> {
27
+ const regionOf = new Map<number, number>()
28
+
29
+ if (!tableExists(pc, "ancestors")) {
30
+ return regionOf
31
+ }
32
+
33
+ for (const r of pc
34
+ .prepare(
35
+ "SELECT id, MIN(ancestor_id) AS ancestor_id FROM ancestors WHERE ancestor_placetype = 'region' GROUP BY id"
36
+ )
37
+ .iterate()) {
38
+ const rid = Number(r.ancestor_id)
39
+
40
+ if (attrs.has(rid)) {
41
+ regionOf.set(Number(r.id), rid)
42
+ }
43
+ }
44
+
45
+ return regionOf
46
+ }
47
+
16
48
  /**
17
49
  * Fold ONE extract (`spr` rows at `extractPlacetype` carrying real coordinates) in, then pass 4b: the alias names
18
50
  * hanging off that same extract's `names` table.
19
51
  *
20
52
  * Self-contained by construction — it shares only the staging statement and the code dictionaries with the admin passes
21
- * above it, and nothing downstream reads anything it produces except the two counters it returns.
53
+ * above it, and nothing downstream reads anything it produces except the counters it returns. The one thing it writes
54
+ * beyond the staging table is a depth-1 closure row for a row whose extract names its region (see
55
+ * {@link extractRegionAncestry}), so the pick's stamped ancestors agree with the scope it was found under.
22
56
  */
23
57
  export function foldExtract(ctx: {
24
58
  /**
@@ -30,8 +64,13 @@ export function foldExtract(ctx: {
30
64
  ccID: (code: string | null) => number
31
65
  ptID: (pt: string | null) => number
32
66
  stageRow: StageRow
67
+ /**
68
+ * The admin places pass 1 staged — what an extract's region ancestry is resolved against. Absent, no extract row
69
+ * takes a region scope (the postcode extracts, which carry none).
70
+ */
71
+ attrs?: Map<number, PlaceAttrs>
33
72
  progress: (phase: string, message: string) => void
34
- }): { primaries: number; aliases: number } {
73
+ }): { primaries: number; aliases: number; scoped: number; ancestorRows: number } {
35
74
  const { out, extractPath, extractPlacetype, ccID, ptID, stageRow, progress } = ctx
36
75
 
37
76
  progress(extractPlacetype === "postalcode" ? "postcodes" : "localities", `reading ${extractPath}`)
@@ -41,8 +80,24 @@ export function foldExtract(ctx: {
41
80
  // Per-extract, not the admin `attrs` map: pass 1 only ever sees the admin DB, so the alias pass
42
81
  // below has nothing to join against unless this primary loop records what it staged.
43
82
  const pcAttrs = new Map<number, PlaceAttrs>()
83
+ const regionOf = ctx.attrs ? extractRegionAncestry(pc, ctx.attrs) : new Map<number, number>()
84
+
85
+ const insAncestor = out.prepare(
86
+ `INSERT OR IGNORE INTO ${CANDIDATE_ANCESTOR_TABLE} VALUES (${CANDIDATE_ANCESTOR_COLUMNS.map(() => "?").join(", ")})`
87
+ )
88
+
89
+ // The region's own closure rows, as the sidecar pass wrote them. A scoped row inherits the whole chain above its
90
+ // region — macroregion, country — not the region alone: the widened-scope refusal reads a pick's stamped ancestors
91
+ // against the parent the walk resolved, and a 縣市 that resolves to its macroregion record (`桃園市`, which WOF names
92
+ // only at that tier) would otherwise contradict a lineage that stops at the region.
93
+ const regionChain = out.prepare(
94
+ `SELECT depth, parent_spr_id, parent_placetype_id, parent_name, parent_name_key FROM ${CANDIDATE_ANCESTOR_TABLE} WHERE spr_id = ? ORDER BY depth`
95
+ )
96
+
44
97
  let primaries = 0
45
98
  let aliases = 0
99
+ let scoped = 0
100
+ let ancestorRows = 0
46
101
 
47
102
  out.exec("BEGIN")
48
103
 
@@ -60,12 +115,15 @@ export function foldExtract(ctx: {
60
115
 
61
116
  const lat = r.latitude as number
62
117
  const lon = r.longitude as number
118
+ const id = Number(r.id)
119
+ const rid = regionOf.get(id) ?? 0
63
120
 
64
- // region_id 0 (a postcode is unique by name+country — no same-name disambiguation); neg_rank 0
65
- // (no population). bbox = the postcode's own min/max (falls back to the centroid point).
121
+ // region_id from the extract's own ancestry when it names one, else 0 (a postcode is unique by
122
+ // name+country — no same-name disambiguation); neg_rank 0 (no population). bbox = the row's own
123
+ // min/max (falls back to the centroid point).
66
124
  const a: PlaceAttrs = {
67
125
  cid: ccID(r.country as string | null),
68
- rid: 0,
126
+ rid,
69
127
  ptid: pcPtid,
70
128
  name,
71
129
  lat,
@@ -83,8 +141,31 @@ export function foldExtract(ctx: {
83
141
  imp: null,
84
142
  }
85
143
 
86
- pcAttrs.set(Number(r.id), a)
87
- stageRow(key, a, Number(r.id), 1)
144
+ pcAttrs.set(id, a)
145
+ stageRow(key, a, id, 1)
146
+
147
+ if (rid !== 0) {
148
+ const region = ctx.attrs!.get(rid)!
149
+
150
+ insAncestor.run(id, 1, rid, region.ptid, region.name, region.pkey)
151
+
152
+ scoped++
153
+
154
+ ancestorRows++
155
+
156
+ for (const link of regionChain.iterate(rid)) {
157
+ insAncestor.run(
158
+ id,
159
+ Number(link.depth) + 1,
160
+ Number(link.parent_spr_id),
161
+ Number(link.parent_placetype_id),
162
+ String(link.parent_name ?? ""),
163
+ String(link.parent_name_key ?? "")
164
+ )
165
+
166
+ ancestorRows++
167
+ }
168
+ }
88
169
 
89
170
  primaries++
90
171
  }
@@ -134,5 +215,5 @@ export function foldExtract(ctx: {
134
215
  progress("postcode-aliases", `${extractPath} has no \`names\` table — no delivery-city aliases to fold`)
135
216
  }
136
217
 
137
- return { primaries, aliases }
218
+ return { primaries, aliases, scoped, ancestorRows }
138
219
  }
@@ -127,6 +127,7 @@ export function romanizeNameKey(key: string): string | null {
127
127
  * Edit similarity between a holder's primary name key and one of its alias keys, both romanized and
128
128
  * abbreviation-expanded. `null` when either side's script is uncovered.
129
129
  */
130
+ // repo-health-ignore export-name-affix -- romanizes and expands abbreviations first; `nameSimilarity` compares as given.
130
131
  export function ownNameSimilarity(primaryKey: string, aliasKey: string): number | null {
131
132
  const primary = romanizeNameKey(primaryKey)
132
133
  const alias = romanizeNameKey(aliasKey)
@@ -4,10 +4,10 @@
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
6
  * Node-side {@link PlaceLookup} over the byte-range CANDIDATE table (`build-candidate.ts`) — the
7
- * SAME gazetteer the browser demo resolves against ({@link WOFCandidateTableLookup} in
8
- * `docs/src/shared/httpvfs-resolver.ts`), but reading a LOCAL `candidate.db` via `node:sqlite`
9
- * instead of sql.js-httpvfs. This is what makes the server/CLI resolver match the demo: one
10
- * lookup surface, one artifact, one ranking.
7
+ * SAME gazetteer the browser runtime resolves against ({@link WOFCandidateTableLookup} in
8
+ * `packages/resolver-wof-wasm/lib/httpvfs/resolver.ts`), but reading a LOCAL `candidate.db` via
9
+ * `node:sqlite` instead of sql.js-httpvfs. This is what makes the server/CLI resolver match the
10
+ * browser: one lookup surface, one artifact, one ranking.
11
11
  *
12
12
  * The query is a single contiguous probe on the `WITHOUT ROWID` B-tree keyed `(name_key,
13
13
  * country_id, region_id, placetype_id, neg_rank, spr_id)`. `name_key` is the SHARED
@@ -23,13 +23,15 @@
23
23
  * `bbox` field on {@link FindPlaceQuery}).
24
24
  */
25
25
 
26
- import { jaroWinkler, levenshteinSimilarity } from "@mailwoman/match/comparators"
27
26
  import {
28
27
  expandPlacetypeFilter,
29
- partitionByContainment,
30
28
  type Ancestor,
31
29
  type GazetteerArtifactCoverage,
32
- } from "@mailwoman/resolver"
30
+ referentialFromPopulation,
31
+ } from "@mailwoman/core/resolver"
32
+ import { allRows } from "@mailwoman/core/utils"
33
+ import { jaroWinkler, levenshteinSimilarity } from "@mailwoman/match/comparators"
34
+ import { partitionByContainment } from "@mailwoman/resolver"
33
35
  import { haversineKm } from "@mailwoman/spatial"
34
36
  import { DatabaseClient } from "@mailwoman/sqlite/client"
35
37
  import type { PathBuilderLike } from "path-ts"
@@ -44,12 +46,11 @@ import {
44
46
  import { CANDIDATE_FTS_TABLE } from "#candidate-fts"
45
47
  import type { CandidateDatabase, CandidateTable, CountryCodeTable, PlacetypeCodeTable } from "#candidate-schema"
46
48
  import { readGazetteerCoverageManifest } from "#coverage-manifest-schema"
47
- import { referentialFromPopulation } from "#place-importance-schema"
48
49
  import { POSTAL_CITY_CANDIDATE_TABLE, type PostalCityCandidateTable } from "#postal/city-candidate-schema"
49
50
  import { rankByPrimaryPreference, type RankedRow, RERANK_FETCH } from "#primary-preference"
50
51
  import { applyProximityRerank } from "#proximity-rerank"
51
52
  import { REGION_CLASS_PLACETYPES, regionQualifierProbeKeys } from "#region-keys"
52
- import { allRows, hasColumn, hasTable } from "#sqlite-utils"
53
+ import { hasColumn, hasTable } from "#sqlite-utils"
53
54
  import { type NameKey, normalizeLocalityForKey, stripLocalityQualifier } from "#street/normalize"
54
55
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "#types"
55
56
 
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Typed schema for the byte-range CANDIDATE gazetteer (`candidate.db`) — the single source of truth
7
7
  * for the columns shared by the BUILDER ({@link buildCandidateTable}) and the READERS (the Node
8
- * {@link WOFCandidateTableLookup} + the browser `httpvfs-resolver.ts`). Before this module each
8
+ * {@link WOFCandidateTableLookup} + the browser `@mailwoman/resolver-wof-wasm/httpvfs/resolver`). Before this module each
9
9
  * side hand-wrote the column list; a rename in one place broke the other at runtime. Now the
10
10
  * contract is a Kysely `Database` interface (`new DatabaseClient<CandidateDatabase>(...)` for
11
11
  * typed inserts) plus the table DDL as strings — so a column change is a compile error on every
@@ -7,12 +7,12 @@
7
7
  * ordered — the weighted-sum score and the exact-match tiering that ranks over it.
8
8
  */
9
9
 
10
+ import { compareReferential, referentialFromPopulation } from "@mailwoman/core/resolver"
10
11
  import { haversineKm } from "@mailwoman/spatial"
11
12
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
12
13
 
13
14
  import { exactMatchIDs, officialNameIDs } from "#exact-match"
14
15
  import { foldQueryText } from "#fts/index"
15
- import { compareReferential, referentialFromPopulation } from "#place-importance-schema"
16
16
  import { populationBoostTerm, type RankingWeights } from "#ranking-weights"
17
17
  import type { RawSearchRow } from "#search-fetch"
18
18
  import type { FindPlaceQuery, PlaceCandidate, WOFPlacetype } from "#types"
@@ -38,14 +38,13 @@
38
38
  * be wired as a post-step of the main `scripts/build-unified-wof.ts`.
39
39
  */
40
40
 
41
- import type { CoincidentLocality } from "@mailwoman/resolver"
41
+ import type { CoincidentLocality } from "@mailwoman/core/resolver"
42
+ import { allRows } from "@mailwoman/core/utils"
42
43
  import { haversineKm } from "@mailwoman/spatial"
43
44
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
44
45
  import { tableExists } from "@mailwoman/sqlite/introspection"
45
46
 
46
47
  import type { WOFDatabase } from "#schema"
47
- import { allRows } from "#sqlite-utils"
48
-
49
48
  /**
50
49
  * Table of places that hold more than one admin role — a locality that is also its county seat. Written by the
51
50
  * gazetteer build, read by the resolver when a coincident locality has to be chosen.
@@ -29,10 +29,11 @@ import {
29
29
  type CountryCoverageFact,
30
30
  type GazetteerArtifactCoverage,
31
31
  } from "@mailwoman/core/resolver"
32
+ import { allRows } from "@mailwoman/core/utils"
32
33
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
33
34
  import { sql, type Kysely } from "kysely"
34
35
 
35
- import { allRows, hasTable } from "#sqlite-utils"
36
+ import { hasTable } from "#sqlite-utils"
36
37
 
37
38
  /**
38
39
  * One country's hard-filter coverage measurement — the storage form of {@link CountryCoverageFact}.
@@ -11,6 +11,7 @@
11
11
  * PlaceEntry at terminals → return FSTMatcher.
12
12
  */
13
13
 
14
+ import { allRows, getRow } from "@mailwoman/core/utils"
14
15
  import { DatabaseClient } from "@mailwoman/sqlite/client"
15
16
  import { resolvePath } from "path-ts"
16
17
 
@@ -20,7 +21,6 @@ import { FSTMatcher, normalizeTokens } from "#fst/matcher"
20
21
  import type { BuildFSTOpts, BuildFSTResult, FSTProvenance, PlaceEntry, PlacetypeID } from "#fst/types"
21
22
  import { loadImportanceSplit } from "#place-importance-schema"
22
23
  import type { WOFDatabase } from "#schema"
23
- import { allRows, getRow } from "#sqlite-utils"
24
24
 
25
25
  const DEFAULT_PLACETYPES: PlacetypeID[] = [
26
26
  "country",
@@ -146,7 +146,6 @@ export async function peekFSTStampFields(path: string): Promise<FSTStampFields |
146
146
  * Re-exported from `@mailwoman/core/hash`. This package's own test imports it from here, and the sidecar convention
147
147
  * below is what it is for.
148
148
  */
149
- export { md5File } from "@mailwoman/core/hash"
150
149
 
151
150
  /**
152
151
  * The source identity an FST build should stamp, or a check should compare against.
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { parseJSONStrict } from "@mailwoman/core/json"
30
- import type { InterpolationLookup } from "@mailwoman/resolver"
30
+ import type { InterpolationLookup } from "@mailwoman/core/resolver"
31
31
  import { clampFraction, haversineKm, pointAlong } from "@mailwoman/spatial"
32
32
  import { DatabaseClient } from "@mailwoman/sqlite/client"
33
33
 
package/lib/lookup.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * See `docs/plan/phases/PHASE_4_2_wof_sqlite.md` for the design rationale.
11
11
  */
12
12
 
13
- import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/resolver"
13
+ import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/core/resolver"
14
+ import { allRows } from "@mailwoman/core/utils"
14
15
  import { haversineKm } from "@mailwoman/spatial"
15
16
  import type { SQLInputValue } from "@mailwoman/sqlite/client"
16
17
  import { DatabaseClient } from "@mailwoman/sqlite/client"
@@ -51,7 +52,6 @@ import { DEFAULT_WEIGHTS, populationScaleTerm, type RankingWeights } from "#rank
51
52
  import type { WOFDatabase } from "#schema"
52
53
  import { fetchSearchRows, type RawSearchRow } from "#search-fetch"
53
54
  import { SqliteConventionSource } from "#sqlite-convention-source"
54
- import { allRows } from "#sqlite-utils"
55
55
  import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "#types"
56
56
 
57
57
  export interface WOFSQLitePlaceLookupOpts {
@@ -32,11 +32,10 @@
32
32
  */
33
33
 
34
34
  import { referentialFromPopulation } from "@mailwoman/core/resolver"
35
+ import { allRows } from "@mailwoman/core/utils"
35
36
  import type { DatabaseClient } from "@mailwoman/sqlite/client"
36
37
  import type { Kysely } from "kysely"
37
38
 
38
- import { allRows } from "#sqlite-utils"
39
-
40
39
  //#region Schema
41
40
 
42
41
  /**
@@ -106,13 +105,6 @@ export async function createPlaceImportanceTable(db: Kysely<PlaceImportanceDatab
106
105
  * (backend-agnostic — it cannot import this package) reads the same number. Re-exported HERE so the schema module stays
107
106
  * the one-stop read for the table: the column and the function that fills it are one hop apart.
108
107
  */
109
- export {
110
- compareReferential,
111
- REFERENTIAL_LOG2_SCALE,
112
- REFERENTIAL_POPULATION_DIVISOR,
113
- REFERENTIAL_SATURATION_POPULATION,
114
- referentialFromPopulation,
115
- } from "@mailwoman/core/resolver"
116
108
 
117
109
  //#endregion
118
110
 
package/lib/poi/lookup.ts CHANGED
@@ -25,13 +25,12 @@
25
25
  * it).
26
26
  */
27
27
 
28
+ import { allRows } from "@mailwoman/core/utils"
28
29
  import { haversineKm, shortCellToInt, type H3Cell } from "@mailwoman/spatial"
29
30
  import { DatabaseClient } from "@mailwoman/sqlite/client"
30
31
  import { gridDisk, latLngToCell } from "h3-js"
31
32
 
32
33
  import type { POICategoryCodeTable, POIDatabase, POITable } from "#poi/schema"
33
- import { allRows } from "#sqlite-utils"
34
-
35
34
  /**
36
35
  * Resolution the `poi` table's `h3_cell` column is keyed at — matches the builder (spec §3.4).
37
36
  */
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Bounded cross-country PRIMARY-NAME preference over a population-ordered candidate row set — the
7
7
  * `is_primary` ranking signal, PURE and platform-free so the Node candidate lookup
8
- * (`candidate-lookup.ts`) and the browser twin (`docs/src/shared/httpvfs-resolver.ts`) rank with the
9
- * SAME function rather than two copies that drift (the #861 server↔demo parity contract).
8
+ * (`candidate-lookup.ts`) and the browser twin (`packages/resolver-wof-wasm/lib/httpvfs/resolver.ts`) rank with
9
+ * the SAME function rather than two copies that drift (the #861 server↔browser parity contract).
10
10
  *
11
11
  * Only type imports and arithmetic live here: anything with a `node:` import stays out.
12
12
  */
@@ -207,17 +207,27 @@ export function rankByPrimaryPreference<R extends PrimaryPreferenceRow>(
207
207
  ? 1
208
208
  : 0
209
209
 
210
+ // 1 for a PRIMARY-name row whose prominence is unmeasured (population 0), 0 for everything else. Two unmeasured
211
+ // rows tie on `neg_rank`, and that tie used to fall through to scan order — the B-tree's `spr_id`, which is no
212
+ // evidence about either place. A row that IS named X ranks ahead of one merely also-known-as X when nothing else
213
+ // separates them: Taiwan's 溪州鄉 township row (its own name, unmeasured) over the village 溪洲 30 km away that
214
+ // carries `溪州鄉` as an alias (unmeasured), which the lower WOF id had been winning. Populated ties are untouched —
215
+ // a populated alias ("NYC") never reaches this term, and two populated rows keep the order they had.
216
+ const unmeasuredPrimary = (r: R): number =>
217
+ r.is_primary === 1 && typeof r.population === "number" && r.population === 0 ? 1 : 0
218
+
210
219
  return (
211
220
  rows
212
221
  .map((r, i) => ({ row: annotate(r), i }))
213
- // Effective rank ASC; ties keep population order, then the seat preference DESC, then original
214
- // index (stable).
222
+ // Effective rank ASC; ties keep population order, then the seat preference DESC, then the primary name
223
+ // among unmeasured rows, then original index (stable).
215
224
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
216
225
  .sort(
217
226
  (a, b) =>
218
227
  a.row.effectiveNegRank - b.row.effectiveNegRank ||
219
228
  a.row.neg_rank - b.row.neg_rank ||
220
229
  seatPreference(b.row) - seatPreference(a.row) ||
230
+ unmeasuredPrimary(b.row) - unmeasuredPrimary(a.row) ||
221
231
  a.i - b.i
222
232
  )
223
233
  .slice(0, limit)
package/lib/reverse.ts CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { tryParsingJSON } from "@mailwoman/core/json"
32
+ import { allRows } from "@mailwoman/core/utils"
32
33
  import { bboxAround, geometryContains, haversineKm, type ParsedGeometry } from "@mailwoman/spatial"
33
34
  import { DatabaseClient } from "@mailwoman/sqlite/client"
34
35
  import { tableExists } from "@mailwoman/sqlite/introspection"
@@ -36,9 +37,7 @@ import { tableExists } from "@mailwoman/sqlite/introspection"
36
37
  import { ancestorLineage, placetypeDepth } from "#ancestry/index"
37
38
  import { PLACE_BBOX_TABLE } from "#fts/index"
38
39
  import type { WOFDatabase } from "#schema"
39
- import { allRows } from "#sqlite-utils"
40
40
  import type { PlaceCandidate, WOFPlacetype } from "#types"
41
-
42
41
  /**
43
42
  * Largest absolute latitude in WGS-84 degrees.
44
43
  */