feat(04-01): search contracts + JUPEM abbreviation table as DATA + symmetric normalizer

- types.ts: GazetteerEntry / SearchQuery / SearchHit / SearchSource (D-03/D-07/D-08)
  — byId() is separate and synchronous; empty search() array = honest refusal
- rules/abbreviations.ts: the vendored JUPEM table (49 rows incl. Presint→Prsn
  vowel-drop and MS 2039 A3 Kpg forms) as a ReadonlyArray of pairs, inverted once
  by a single generic builder — extends by file edit, never a code change (D-09)
- normalize.ts: NFKD fold → punctuation collapse (keeps '/') → whole-token
  expansion (canonical = long form) → longest-first glued split (remainder >= 3)

T1-T9 green; typecheck clean.
This commit is contained in:
araharah-executor
2026-10-24 00:23:37 +08:00
parent ebcd542643
commit a9f9ab380c
3 changed files with 294 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
/*
* abbreviations.ts — the Malay abbreviation table, as DATA (D-09).
* --------------------------------------------------------------
* PRIMARY SOURCE: JUPEM Toponymic Guidelines — "Abbreviations of
* Geographical Terms and Other Words Used in Maps"
* (Malaysia, 2017 ed.) — the normative national list.
* TRANSCRIPTION: https://github.com/osm-search/Nominatim/pull/4056
* (settings/icu-rules/variants-ms.yaml, merged 2026-04-02)
* SECONDARY: https://wiki.openstreetmap.org/wiki/Malaysia/Abbreviations
*
* D-09's law: this table extends by FILE EDIT, never by code change.
* Add a row, add a vector to test/normalizer.test.mjs, done.
*
* "Presint → Prsn" is an explicit vowel-drop expansion (Putrajaya
* precincts are written both ways in live addresses: "JALAN P8G
* PRESINT 8", "PRSN RESIDEN DESA PARKCI"). "Prsn" is NOT a prefix
* abbreviation — it drops an interior "e". Do NOT generalize this into
* a generic vowel-drop rule (it over-expands legitimate names).
*
* "Kpg" is added to the Kampung row beyond the Nominatim transcription:
* MS 2039:2006 Annex A3 lists "Kampung/Kampong → Kg, Kpg", and the plan's
* normalizer contract pins the vector "Kpg Ampang" -> "kampung ampang".
*
* City codes (PJ, JB, KB, KK) are deliberately ABSENT: they are place
* identities, not address-word forms, so they live in the gazetteer's
* alias table (D-08), not here.
*
* Format: [fullForm, "comma,separated,abbreviations"]. The canonical form
* is ALWAYS the long form — buildAbbreviationMap inverts the table so
* every abbreviation reaches its full form and every full form reaches
* itself ("Jln Ampang" and "Jalan Ampang" both normalize to
* "jalan ampang" — the D-09 symmetry law).
*/
export const ABBREVIATION_ROWS: ReadonlyArray<readonly [string, string]> = [
// — shared section (the JUPEM core, via Nominatim variants-ms.yaml) —
["Jalan", "Jln"],
["Lorong", "Lrg,Lor"],
["Lebuh", "Lbh"],
["Simpang", "Spg"],
["Kampung", "Kg,Kpg"],
["Kampong", "Kg"],
["Taman", "Tmn"],
["Bangunan", "Bgn"],
["Bukit", "Bkt"],
["Batu", "Bt"],
["Sungai", "Sg"],
["Pulau", "P,Pl"],
["Tanjung", "Tg"],
["Tanjong", "Tg"],
["Teluk", "Tlk"],
["Telok", "Tlk"],
["Gunung", "G,Gn"],
["Gunong", "G,Gn"],
["Haji", "Hj"],
["Hajjah", "Hjh"],
["Abdul", "Abd"],
// — country: my section —
["Persiaran", "Psrn"],
["Lebuhraya", "Lbh.Raya"],
["Bandar", "Bdr"],
["Kondominium", "Kondo"],
["Bahagian", "Bhg"],
["Blok", "Blk"],
["Kuala", "K,Kl"],
["Alur", "Alr"],
["Bakau", "Bak"],
["Hulu", "H"],
["Hilir", "Hr"],
["Jeram", "J,Jr"],
["Lembah", "Lmbh"],
["Padang", "Pdg"],
["Parit", "Pt"],
["Permatang", "Pmtg"],
["Pelabuhan", "Plbh"],
["Kepulauan", "Kep"],
["Ladang", "Ldg"],
["Lapangan", "Lpg"],
["Gosong", "Gsg"],
["Sekolah kebangsaan", "SK"],
["Sekolah menengah", "SM"],
["Sekolah menengah kebangsaan", "SMK"],
["Sekolah rendah", "SR"],
["Hospital", "Hosp"],
["Dispensari", "Disp"],
// — the explicit D-09 vowel-drop row (Putrajaya precincts) —
["Presint", "Prsn"],
];
/**
* The BIDIRECTIONAL map the normalizer consumes: every lowercased
* abbreviation maps to its full form(s) in table order, and every full
* form maps to itself — so the canonical form is always the long form.
* Where one abbreviation covers several historical spellings ("kg" ->
* kampung/kampong), the list keeps them in table order and consumers
* take the FIRST form; the fuzzy-residual tier resolves what the table
* cannot. Built once per process; the rows themselves stay pure DATA.
*/
export function buildAbbreviationMap(): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const row of ABBREVIATION_ROWS) {
const fullForm = row[0];
const abbreviations = row[1];
const full = fullForm.toLowerCase();
for (const piece of abbreviations.split(",")) {
const abbr = piece.trim().toLowerCase();
if (abbr === "") {
continue;
}
const existing = map.get(abbr);
if (existing === undefined) {
map.set(abbr, [full]);
} else if (!existing.includes(full)) {
existing.push(full);
}
}
const selfList = map.get(full);
if (selfList === undefined) {
map.set(full, [full]);
} else if (!selfList.includes(full)) {
selfList.push(full);
}
}
return map;
}