@mailwoman/resolver-wof-sqlite 10.8.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 (54) hide show
  1. package/lib/address/point.ts +44 -1
  2. package/lib/build/slim.ts +0 -19
  3. package/lib/candidate/lookup.ts +9 -4
  4. package/lib/index.ts +0 -1
  5. package/lib/landmark/rows.ts +91 -0
  6. package/lib/landmark.ts +94 -0
  7. package/lib/lookup.ts +1 -21
  8. package/lib/primary-preference.ts +13 -20
  9. package/lib/schema.ts +0 -18
  10. package/lib/street/normalize.ts +91 -0
  11. package/lib/types.ts +7 -2
  12. package/out/address/point.d.ts.map +1 -1
  13. package/out/address/point.js +36 -1
  14. package/out/address/point.js.map +1 -1
  15. package/out/build/slim.d.ts +1 -1
  16. package/out/build/slim.d.ts.map +1 -1
  17. package/out/build/slim.js +0 -12
  18. package/out/build/slim.js.map +1 -1
  19. package/out/candidate/lookup.d.ts.map +1 -1
  20. package/out/candidate/lookup.js +8 -4
  21. package/out/candidate/lookup.js.map +1 -1
  22. package/out/index.d.ts +1 -1
  23. package/out/index.d.ts.map +1 -1
  24. package/out/index.js.map +1 -1
  25. package/out/landmark/rows.d.ts +63 -0
  26. package/out/landmark/rows.d.ts.map +1 -0
  27. package/out/landmark/rows.js +48 -0
  28. package/out/landmark/rows.js.map +1 -0
  29. package/out/landmark.d.ts +40 -0
  30. package/out/landmark.d.ts.map +1 -0
  31. package/out/landmark.js +78 -0
  32. package/out/landmark.js.map +1 -0
  33. package/out/lookup.d.ts +1 -5
  34. package/out/lookup.d.ts.map +1 -1
  35. package/out/lookup.js +0 -17
  36. package/out/lookup.js.map +1 -1
  37. package/out/primary-preference.d.ts +10 -10
  38. package/out/primary-preference.d.ts.map +1 -1
  39. package/out/primary-preference.js +8 -9
  40. package/out/primary-preference.js.map +1 -1
  41. package/out/schema.d.ts +0 -17
  42. package/out/schema.d.ts.map +1 -1
  43. package/out/street/normalize.d.ts +7 -0
  44. package/out/street/normalize.d.ts.map +1 -1
  45. package/out/street/normalize.js +75 -0
  46. package/out/street/normalize.js.map +1 -1
  47. package/out/types.d.ts +6 -2
  48. package/out/types.d.ts.map +1 -1
  49. package/package.json +12 -12
  50. package/lib/coincident-roles.ts +0 -347
  51. package/out/coincident-roles.d.ts +0 -121
  52. package/out/coincident-roles.d.ts.map +0 -1
  53. package/out/coincident-roles.js +0 -222
  54. package/out/coincident-roles.js.map +0 -1
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { AddressPointHit, AddressPointLookup } from "@mailwoman/core/resolver"
9
9
  import { DatabaseClient } from "@mailwoman/sqlite/client"
10
+ import { indexExists } from "@mailwoman/sqlite/introspection"
10
11
  import type { PathBuilderLike } from "path-ts"
11
12
 
12
13
  import type { AddressPointDatabase, AddressPointTable } from "#address/point/schema"
@@ -35,6 +36,27 @@ type AddressPointRow = Pick<AddressPointTable, "lat" | "lon" | "source" | "relea
35
36
  */
36
37
  const SELECT_COLS = "lat, lon, source, release, locality_norm, postcode"
37
38
 
39
+ /**
40
+ * The widest span of numbers a written range covers on one building or block.
41
+ */
42
+ const WIDEST_HOUSE_NUMBER_RANGE = 200
43
+
44
+ /**
45
+ * Returns whether two hyphenated numbers read as one number rather than a range of houses.
46
+ *
47
+ * `8-9 Upper St` is a range, and joining it would answer the different house at number 89.
48
+ * `19-4074` and Queens's `123-45` cannot be ranges, because a range climbs by a short step.
49
+ */
50
+ function isJoinedHyphenNumber(number: string): boolean {
51
+ const parts = /^(\d+)-(\d+)$/.exec(number)
52
+
53
+ if (!parts) return false
54
+
55
+ const span = Number(parts[2]) - Number(parts[1])
56
+
57
+ return span < 0 || span > WIDEST_HOUSE_NUMBER_RANGE
58
+ }
59
+
38
60
  /**
39
61
  * Finds an address point by exact normalized `(street, number)` within a postcode,
40
62
  * a locality, or a bounding box.
@@ -73,6 +95,7 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
73
95
  readonly #byLocalityTail:
74
96
  | PreparedGet<[street: StreetKey, number: string, tailPattern: string], AddressPointRow>
75
97
  | undefined
98
+ readonly #hasStreetIndex: boolean = false
76
99
  readonly #byBbox:
77
100
  | PreparedGet<
78
101
  [street: StreetKey, number: string, minLat: number, maxLat: number, minLon: number, maxLon: number],
@@ -140,6 +163,8 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
140
163
  WHERE locality_norm = ? AND street_norm = ? AND number = ? LIMIT 1`
141
164
  )
142
165
 
166
+ this.#hasStreetIndex = indexExists(this.#db, "idx_ap_street")
167
+
143
168
  this.#byBbox = prepareGet(
144
169
  this.#db,
145
170
  `SELECT ${SELECT_COLS} FROM address_point
@@ -220,6 +245,23 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
220
245
  ) {
221
246
  let row = this.#probe(streetNorm, number, query)
222
247
 
248
+ // A hyphenated number can be one number written two ways: Hawaii's `19-4074`
249
+ // is stored as `194074` by some sources.
250
+ // This runs ahead of the range fallback, because that fallback reads it as house number 19.
251
+ if (!row && isJoinedHyphenNumber(number)) {
252
+ row = this.#probe(streetNorm, number.replace("-", ""), query)
253
+ }
254
+
255
+ // A grid number leads with a compass letter.
256
+ // One writer's `N3971` is another source's `n 3971`.
257
+ if (!row) {
258
+ const grid = /^([nesw])(?:\s+)?(\d+)$/.exec(number)
259
+
260
+ if (grid) {
261
+ row = this.#probe(streetNorm, number.includes(" ") ? `${grid[1]}${grid[2]}` : `${grid[1]} ${grid[2]}`, query)
262
+ }
263
+ }
264
+
223
265
  // The registers store one number per point, so a range such as "385-387" falls back to its low end.
224
266
  if (!row) {
225
267
  const low = /^(\d+[a-z]?)-\d+[a-z]?$/.exec(number)?.[1]
@@ -307,7 +349,8 @@ export class AddressPointSqliteLookup<DB extends AddressPointDatabase = AddressP
307
349
  }
308
350
 
309
351
  // The bounding box serves extracts whose points have no postcode or locality, such as OSM.
310
- if (!row && query.bbox) {
352
+ // A database without the street index skips this search, because each miss would scan the whole table.
353
+ if (!row && query.bbox && this.#hasStreetIndex) {
311
354
  const b = query.bbox
312
355
  const candidate = this.#byBbox!(streetNorm, number, b.minLat, b.maxLat, b.minLon, b.maxLon)
313
356
 
package/lib/build/slim.ts CHANGED
@@ -54,7 +54,6 @@ export type SlimBuildPhase =
54
54
  | "postcode"
55
55
  | "names"
56
56
  | "place_population"
57
- | "coincident_roles"
58
57
  | "place_abbr"
59
58
  | "fts"
60
59
  | "vacuum"
@@ -314,24 +313,6 @@ async function copyFromSource(
314
313
  .onConflict((oc) => oc.doNothing())
315
314
  .execute()
316
315
  }
317
-
318
- // `ancestors` is intentionally not copied, so the derived `coincident_roles`
319
- // table is copied and filtered to surviving spr ids.
320
- const relationSchema = out
321
- .prepare(`SELECT sql FROM src.sqlite_master WHERE type = 'table' AND name = 'coincident_roles'`)
322
- .get() as { sql?: string } | undefined
323
-
324
- if (relationSchema?.sql) {
325
- progress("coincident_roles", `${inputPath}: copying dual-role relation`)
326
- out.exec(relationSchema.sql.replace(/CREATE TABLE/i, "CREATE TABLE IF NOT EXISTS"))
327
-
328
- out.exec(
329
- `INSERT OR IGNORE INTO coincident_roles SELECT * FROM src.coincident_roles
330
- WHERE admin_id IN (SELECT id FROM spr) AND locality_id IN (SELECT id FROM spr)`
331
- )
332
-
333
- out.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON coincident_roles (admin_id)`)
334
- }
335
316
  } finally {
336
317
  out.exec(`DETACH DATABASE src;`)
337
318
  }
@@ -553,16 +553,19 @@ export class WOFCandidateTableLookup extends SQLiteLookup<CandidateDatabase> imp
553
553
  }
554
554
  }
555
555
 
556
- // Typo-tolerant fallback for true name misses only, skipping postcodes and respecting the fuzzy-country scope.
556
+ // Typo-tolerant fallback for true name misses only, skipping postcodes.
557
+ // With no stated country, locale hint or parent to search within,
558
+ // a correction keeps its first letter.
559
+ // `Aucklnad` reaches Auckland, and `Anal Island` stays away from `Capual Island` in the Philippines.
557
560
  const fuzzyCountryID =
558
561
  !query.country && query.fuzzyCountry ? this.#countryToID.get(query.fuzzyCountry.toUpperCase()) : undefined
559
562
 
560
- const fuzzyScopedOut = !query.country && !!query.fuzzyCountry && typeof fuzzyCountryID !== "number"
563
+ const hasCountryEvidence = !!query.country || regionID !== null || typeof fuzzyCountryID === "number"
561
564
 
562
565
  if (
563
566
  !rows.length &&
564
567
  !wantsPostcode &&
565
- !fuzzyScopedOut &&
568
+ !(query.fuzzyCountry && !query.country && typeof fuzzyCountryID !== "number") &&
566
569
  this.#ftsProbe &&
567
570
  this.#nameKeyExistsProbe &&
568
571
  !this.#nameKeyExistsProbe.get(nameKey)
@@ -574,7 +577,7 @@ export class WOFCandidateTableLookup extends SQLiteLookup<CandidateDatabase> imp
574
577
 
575
578
  const ranked = hits
576
579
  .map((h) => ({ nk: String(h.name_key), s: wordFuzzySimilarity(nameKey, String(h.name_key)) }))
577
- .filter((h) => h.s >= WORD_FUZZY_MIN)
580
+ .filter((h) => h.s >= WORD_FUZZY_MIN && (hasCountryEvidence || h.nk[0] === nameKey[0]))
578
581
  // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array. toSorted would double-allocate on a hot path
579
582
  .sort((a, b) => b.s - a.s)
580
583
 
@@ -684,6 +687,8 @@ export class WOFCandidateTableLookup extends SQLiteLookup<CandidateDatabase> imp
684
687
 
685
688
  variantAliasExempted: row.variantExempted ? true : null,
686
689
 
690
+ matchedAlias: row.is_primary === 0 ? true : null,
691
+
687
692
  population: hasPopulation ? row.population : null,
688
693
  referential: hasPopulation ? referentialFromPopulation(row.population) : null,
689
694
  encyclopedic: null,
package/lib/index.ts CHANGED
@@ -8,7 +8,6 @@ export type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "
8
8
 
9
9
  export type {
10
10
  AncestorsTable,
11
- CoincidentRolesTable,
12
11
  ConcordancesTable,
13
12
  GeojsonTable,
14
13
  NamesTable,
@@ -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,7 +7,7 @@
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
12
  import { haversine } from "@mailwoman/spatial/geometries/point/distance"
13
13
  import type { DatabaseClient, SQLInputValue } from "@mailwoman/sqlite/client"
@@ -16,7 +16,6 @@ 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,
@@ -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
  */
@@ -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.
@@ -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,
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
  }
@@ -536,5 +536,96 @@ export function streetKeyVariants(street: string, locale: StreetLocale = "us"):
536
536
  }
537
537
  }
538
538
 
539
+ for (const candidate of routeKeyVariants(primary)) {
540
+ if (!variants.includes(candidate)) {
541
+ variants.push(candidate)
542
+ }
543
+ }
544
+
539
545
  return variants
540
546
  }
547
+
548
+ /**
549
+ * The route designators a numbered road is written with, by the system the query's prefix selects.
550
+ *
551
+ * The forms are the ones US address sources store, measured over the state extracts:
552
+ * `state road 10`, `state highway 42`, `highway 79`, `united states highway 31`, `interstate 35`.
553
+ */
554
+ const ROUTE_DESIGNATORS: Readonly<Record<"us" | "state" | "interstate", readonly string[]>> = {
555
+ us: ["united states highway", "us highway", "us route", "highway", "route"],
556
+ state: ["state road", "state highway", "state route", "highway", "route"],
557
+ interstate: ["interstate"],
558
+ }
559
+
560
+ /**
561
+ * The spellings of a county road, as US address sources store them.
562
+ *
563
+ * Wisconsin leads with `cth`, Texas with `county road`, and Florida with `cr`,
564
+ * and a county road may take letters, as `cth cw` does.
565
+ */
566
+ const COUNTY_ROAD_DESIGNATORS: readonly string[] = ["county road", "county highway", "county", "cth", "co road", "cr"]
567
+
568
+ /**
569
+ * Reads a county road's designator from a query, such as `county rd cw`,
570
+ * `cty hwy 5`, `co rd 12`, `cr 40` or `cth a`.
571
+ *
572
+ * A bare `co 5` stays a Colorado state route.
573
+ */
574
+ const COUNTY_ROAD_QUERY =
575
+ /^(?:(?:county|cty|co) (?:road|rd|highway|hwy|trunk|trunk highway) (\d+[a-z]?|[a-z]{1,3})|(?:county|cty|cth|cr) (\d+[a-z]?|[a-z]{1,2}))$/
576
+
577
+ const COMPASS_WORDS: readonly string[] = ["north", "south", "east", "west"]
578
+
579
+ /**
580
+ * A query's route number and system: `in 245` and `state route 245` read as state routes,
581
+ * `us 93` as a US route, and `i 68` as an interstate.
582
+ *
583
+ * A two-letter prefix is any state's code, because the shape is the same in every state.
584
+ */
585
+ function parseRouteQuery(key: string): { system: keyof typeof ROUTE_DESIGNATORS; route: string } | null {
586
+ const match =
587
+ /^(us|i|interstate|[a-z]{2}|state|sr)(?: (?:road|rd|route|rte|rt|highway|hwy))? (\d+[a-z]?)$/.exec(key) ??
588
+ /^(highway|hwy|route|rte)() ?(\d+[a-z]?)$/.exec(key)
589
+
590
+ if (!match) return null
591
+
592
+ const route = match.at(-1)!
593
+ const prefix = match[1]!
594
+
595
+ if (prefix === "us") return { system: "us", route }
596
+
597
+ if (prefix === "i" || prefix === "interstate") return { system: "interstate", route }
598
+
599
+ return { system: "state", route }
600
+ }
601
+
602
+ /**
603
+ * Lists the stored spellings of a numbered route, with and without the compass word a source may add.
604
+ *
605
+ * `IN-245` is stored as `north state road 245` in Indiana, so a query gives no hint of the spelling.
606
+ * The caller's postcode, locality or box still scopes each probe.
607
+ */
608
+ export function routeKeyVariants(key: StreetKey): StreetKey[] {
609
+ const written = key.replaceAll("-", " ")
610
+ const countyRoad = COUNTY_ROAD_QUERY.exec(written)
611
+
612
+ if (countyRoad) {
613
+ const route = countyRoad[1] ?? countyRoad[2]!
614
+
615
+ return COUNTY_ROAD_DESIGNATORS.map((designator) => `${designator} ${route}` as StreetKey)
616
+ }
617
+
618
+ const parsed = parseRouteQuery(written)
619
+
620
+ if (!parsed) return []
621
+
622
+ return ROUTE_DESIGNATORS[parsed.system].flatMap((designator) => {
623
+ const base = `${designator} ${parsed.route}`
624
+
625
+ return [
626
+ base,
627
+ ...COMPASS_WORDS.map((compass) => `${compass} ${base}`),
628
+ ...COMPASS_WORDS.map((compass) => `${base} ${compass}`),
629
+ ] as StreetKey[]
630
+ })
631
+ }
package/lib/types.ts CHANGED
@@ -109,10 +109,15 @@ export interface PlaceCandidate {
109
109
  regionScopeMiss: boolean | null
110
110
 
111
111
  /**
112
- * Set when the variant exemption spared the candidate from the cross-country
113
- * alias penalty, by the candidate-table backend only.
112
+ * Set when the variant exemption spared the candidate from the alias penalty,
113
+ * by the candidate-table backend only.
114
114
  */
115
115
  variantAliasExempted: true | null
116
+
117
+ /**
118
+ * Set when the input matched an alias of this place, by the candidate-table backend only.
119
+ */
120
+ matchedAlias?: true | null
116
121
  }
117
122
 
118
123
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"point.d.ts","sourceRoot":"","sources":["../../lib/address/point.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAEnF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9C,OAAO,KAAK,EAAE,oBAAoB,EAAqB,MAAM,uBAAuB,CAAA;AAEpF,OAAO,EASN,KAAK,YAAY,EACjB,MAAM,mBAAmB,CAAA;AAc1B;;;;;;;;;;GAUG;AACH,qBAAa,wBAAwB,CAAC,EAAE,SAAS,oBAAoB,GAAG,oBAAoB,CAC3F,YAAW,kBAAkB,EAAE,UAAU;;IAiCzC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,YACC,MAAM,EAAE,eAAe,EACvB,IAAI,GAAE;QACL,YAAY,CAAC,EAAE,YAAY,CAAA;QAC3B,YAAY,CAAC,EAAE,MAAM,GAAG,aAAa,CAAA;QACrC,aAAa,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;KAC7B,EA6CN;IAED,IAAI,CAAC,KAAK,EAAE;QACX,MAAM,EAAE,MAAM,CAAA;QACd,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAC/E,GAAG,eAAe,GAAG,IAAI,CAsCzB;IA4LD,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAEvB;CACD"}
1
+ {"version":3,"file":"point.d.ts","sourceRoot":"","sources":["../../lib/address/point.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAGnF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9C,OAAO,KAAK,EAAE,oBAAoB,EAAqB,MAAM,uBAAuB,CAAA;AAEpF,OAAO,EASN,KAAK,YAAY,EACjB,MAAM,mBAAmB,CAAA;AAmC1B;;;;;;;;;;GAUG;AACH,qBAAa,wBAAwB,CAAC,EAAE,SAAS,oBAAoB,GAAG,oBAAoB,CAC3F,YAAW,kBAAkB,EAAE,UAAU;;IAkCzC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,YACC,MAAM,EAAE,eAAe,EACvB,IAAI,GAAE;QACL,YAAY,CAAC,EAAE,YAAY,CAAA;QAC3B,YAAY,CAAC,EAAE,MAAM,GAAG,aAAa,CAAA;QACrC,aAAa,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;KAC7B,EA+CN;IAED,IAAI,CAAC,KAAK,EAAE;QACX,MAAM,EAAE,MAAM,CAAA;QACd,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAC/E,GAAG,eAAe,GAAG,IAAI,CAsCzB;IA8MD,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAEvB;CACD"}