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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user