@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.
- package/lib/address/point.ts +44 -1
- package/lib/build/slim.ts +0 -19
- package/lib/candidate/lookup.ts +9 -4
- package/lib/index.ts +0 -1
- package/lib/landmark/rows.ts +91 -0
- package/lib/landmark.ts +94 -0
- package/lib/lookup.ts +1 -21
- package/lib/primary-preference.ts +13 -20
- package/lib/schema.ts +0 -18
- package/lib/street/normalize.ts +91 -0
- package/lib/types.ts +7 -2
- package/out/address/point.d.ts.map +1 -1
- package/out/address/point.js +36 -1
- package/out/address/point.js.map +1 -1
- package/out/build/slim.d.ts +1 -1
- package/out/build/slim.d.ts.map +1 -1
- package/out/build/slim.js +0 -12
- package/out/build/slim.js.map +1 -1
- package/out/candidate/lookup.d.ts.map +1 -1
- package/out/candidate/lookup.js +8 -4
- package/out/candidate/lookup.js.map +1 -1
- package/out/index.d.ts +1 -1
- package/out/index.d.ts.map +1 -1
- package/out/index.js.map +1 -1
- package/out/landmark/rows.d.ts +63 -0
- package/out/landmark/rows.d.ts.map +1 -0
- package/out/landmark/rows.js +48 -0
- package/out/landmark/rows.js.map +1 -0
- package/out/landmark.d.ts +40 -0
- package/out/landmark.d.ts.map +1 -0
- package/out/landmark.js +78 -0
- package/out/landmark.js.map +1 -0
- package/out/lookup.d.ts +1 -5
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +0 -17
- package/out/lookup.js.map +1 -1
- package/out/primary-preference.d.ts +10 -10
- package/out/primary-preference.d.ts.map +1 -1
- package/out/primary-preference.js +8 -9
- package/out/primary-preference.js.map +1 -1
- package/out/schema.d.ts +0 -17
- package/out/schema.d.ts.map +1 -1
- package/out/street/normalize.d.ts +7 -0
- package/out/street/normalize.d.ts.map +1 -1
- package/out/street/normalize.js +75 -0
- package/out/street/normalize.js.map +1 -1
- package/out/types.d.ts +6 -2
- package/out/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/lib/coincident-roles.ts +0 -347
- package/out/coincident-roles.d.ts +0 -121
- package/out/coincident-roles.d.ts.map +0 -1
- package/out/coincident-roles.js +0 -222
- package/out/coincident-roles.js.map +0 -1
package/lib/address/point.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/candidate/lookup.ts
CHANGED
|
@@ -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
|
|
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
|
|
563
|
+
const hasCountryEvidence = !!query.country || regionID !== null || typeof fuzzyCountryID === "number"
|
|
561
564
|
|
|
562
565
|
if (
|
|
563
566
|
!rows.length &&
|
|
564
567
|
!wantsPostcode &&
|
|
565
|
-
!
|
|
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
|
@@ -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
|
+
}
|
package/lib/landmark.ts
ADDED
|
@@ -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
|
|
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"
|
|
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
|
|
19
|
-
*
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
55
|
-
* A dominant alias
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
}
|
package/lib/street/normalize.ts
CHANGED
|
@@ -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
|
|
113
|
-
*
|
|
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;
|
|
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"}
|