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
+63
View File
@@ -0,0 +1,63 @@
/*
* types.ts — the search contracts (D-03, D-07, D-08, D-11).
* --------------------------------------------
* GazetteerEntry / SearchQuery / SearchHit / SearchSource are the seam
* every backend implements: the in-Worker gazetteer (backend #1, this
* phase), the Malaysia-only WASM geocoder (backend #2, D-05, post-v1),
* and Photon-on-VPS (backend #3, D-05, Phase 5). Backend #2/#3 do NOT
* ship in this phase — the interface exists so they can be added without
* a rewrite.
*
* SearchSource.search() returns an EMPTY array as a legitimate,
* load-bearing value: "no match in the KL gazetteer" (D-13's
* out-of-area negatives must be refused honestly, never answered with
* the nearest KL pin).
*
* byId() is separate and SYNCHRONOUS: the alternates callback handler
* and the webview's deep-link reader both need an id -> entry lookup and
* neither should await a network.
*
* Type-only module: no runtime values, no imports, no storage (GEO-03).
*/
/** One curated gazetteer landmark (D-07 typed JSON, D-08 native aliases). */
export interface GazetteerEntry {
/** Stable, slug-shaped: "suria-klcc", "sunway-pyramid". Never renamed once shipped. */
id: string;
/** Canonical display name (the OSM landmark name, e.g. "Suria KLCC"). */
name: string;
/** Locals' shorthand (D-08) — never empty; always includes the canonical name. */
aliases: string[];
lat: number;
lng: number;
/** "mall" | "office-tower" | "park" | "station" | ... (small closed set). */
category: string;
/** Human address for sendVenue's `address` field — shown VERBATIM, never normalized. */
address?: string;
}
/** A search request. */
export interface SearchQuery {
/** Raw user query. */
text: string;
/** Default 5 (1 best + 2-4 alternates, D-02). */
limit?: number;
}
/** One ranked result. */
export interface SearchHit {
entry: GazetteerEntry;
/** 0..1, higher is better. */
score: number;
/** Which alias matched — the corpus report needs this provenance. */
matchedAlias: string;
}
/** The pluggable search seam (D-03). Backends: "gazetteer" | "wasm-geocoder" | "photon". */
export interface SearchSource {
readonly name: string;
/** EMPTY array = honest refusal (D-13) — never a nearest-match pin. */
search(q: SearchQuery): Promise<SearchHit[]>;
/** Synchronous id -> entry, honest null when unknown. */
byId(id: string): GazetteerEntry | null;
}