feat(04-01): curated KL gazetteer + lazy three-environment loader + GPL/ODbL licensing

- kl-gazetteer.json: 30 hand-curated KL-metro landmarks (10 required + 20
  more) with locals' aliases as native data (D-08) — canonical OSM names
  as name, shorthand ('KLCC', 'DUKE', 'Sg Wang') as aliases — plus the
  ODbL-1.0 attribution header traveling inside the JSON (D-06/D-07)
- gazetteer.ts: the three laws — lazy memoized index never at module
  scope (Workers startup, T-04-04), static JSON import surviving
  wrangler/Vite/bare-Node (D-07), zero logging/storage (D-15); aliases
  normalized once at index-build (D-09 symmetry)
- source.ts: the D-03 SearchSource — async search() + synchronous byId()
  (slug-validated, honest null); tiered admission (fuzzy only at sim
  >= 0.72) so 'Kuching'/'Penang' refuse as [] — never a fabricated pin
- index.ts: the named-export public API (no default, subpath+extension
  imports per the @araharah/theme precedent)
- LICENSE: the verbatim GPL-3.0 text (D-04); LICENSES.md: the dual-licence
  note (code GPL-3.0, data ODbL-1.0 + OSM attribution); README: corrected
  licence section + search/ in the layout, four-package table, commands

G1-G11 + L1-L3 green (39/39); typecheck clean; esbuild bundle carries the
suria-klcc marker; wrangler dry-run exits 0; bot + web suites unaffected.
This commit is contained in:
araharah-executor
2026-10-24 00:36:03 +08:00
parent f1b31ca7ed
commit 8579da9d71
7 changed files with 1188 additions and 6 deletions
+278
View File
@@ -0,0 +1,278 @@
{
"_license": "ODbL-1.0",
"_attribution": "© OpenStreetMap contributors",
"_notice": "Curated from OpenStreetMap data. The curated gazetteer and any derived index are made available under the Open Database License (ODbL) v1.0 — https://opendatacommons.org/licenses/odbl/1-0/",
"version": 1,
"entries": [
{
"id": "suria-klcc",
"name": "Suria KLCC",
"aliases": ["Suria KLCC", "KLCC", "KLCC Park", "KLCC Mall", "Jalan Ampang"],
"lat": 3.1578,
"lng": 101.7117,
"category": "mall",
"address": "Jalan Ampang, 50088 Kuala Lumpur"
},
{
"id": "sunway-pyramid",
"name": "Sunway Pyramid",
"aliases": ["Sunway Pyramid", "Pyramid", "Sunway", "Sunway Pyramid Shopping Mall"],
"lat": 3.0726,
"lng": 101.6069,
"category": "mall",
"address": "Jalan PJS 11/15, Bandar Sunway, 47500 Subang Jaya"
},
{
"id": "the-duke",
"name": "Lebuhraya DUKE",
"aliases": ["Lebuhraya DUKE", "DUKE", "Duta-Ulu Klang Expressway", "Karak Link"],
"lat": 3.1357,
"lng": 101.6864,
"category": "highway",
"address": "Lebuhraya DUKE, 50000 Kuala Lumpur"
},
{
"id": "pavilion-kl",
"name": "Pavilion Kuala Lumpur",
"aliases": ["Pavilion Kuala Lumpur", "Pavilion", "Pavilion KL", "KL Pavilion"],
"lat": 3.1478,
"lng": 101.695,
"category": "mall",
"address": "Jalan Bukit Bintang, 55100 Kuala Lumpur"
},
{
"id": "ijnp",
"name": "Institut Jantung Negara",
"aliases": ["Institut Jantung Negara", "IJN", "IJN Hospital", "Institut Jantung Negara Malaysia"],
"lat": 3.1636,
"lng": 101.706,
"category": "hospital",
"address": "Jalan Tun Razak, 50400 Kuala Lumpur"
},
{
"id": "pwtc",
"name": "PWTC",
"aliases": ["PWTC", "Putra World Trade Centre"],
"lat": 3.1768,
"lng": 101.698,
"category": "convention",
"address": "Jalan Tun Razak, 50400 Kuala Lumpur"
},
{
"id": "kl-sentral",
"name": "KL Sentral",
"aliases": ["KL Sentral", "Stesen KL Sentral", "KL Central", "Sentral Station", "Stesen Sentral"],
"lat": 3.1343,
"lng": 101.6869,
"category": "station",
"address": "Jalan Stesen Sentral 5, 50470 Kuala Lumpur"
},
{
"id": "bangsar",
"name": "Taman Bangsar",
"aliases": ["Taman Bangsar", "Bangsar", "Bangsar Park"],
"lat": 3.1477,
"lng": 101.6875,
"category": "park",
"address": "Jalan Maarof, 59000 Kuala Lumpur"
},
{
"id": "kampung-ampang",
"name": "Kampung Ampang",
"aliases": ["Kampung Ampang", "Kg Ampang", "Ampang Village"],
"lat": 3.1456,
"lng": 101.712,
"category": "village",
"address": "Jalan Ampang, 68000 Ampang"
},
{
"id": "tun-razak",
"name": "Jalan Tun Razak",
"aliases": ["Jalan Tun Razak", "Tun Razak"],
"lat": 3.156,
"lng": 101.71,
"category": "road",
"address": "Jalan Tun Razak, 50400 Kuala Lumpur"
},
{
"id": "sungei-wang",
"name": "Sungei Wang Plaza",
"aliases": ["Sungei Wang Plaza", "Sungei Wang", "Sg Wang", "Sungai Wang"],
"lat": 3.1483,
"lng": 101.7068,
"category": "mall",
"address": "Jalan Bukit Bintang, 55100 Kuala Lumpur"
},
{
"id": "berjaya-times-square",
"name": "Berjaya Times Square",
"aliases": ["Berjaya Times Square", "Times Square", "BTS"],
"lat": 3.1421,
"lng": 101.7101,
"category": "mall",
"address": "1 Jalan Imbi, 55100 Kuala Lumpur"
},
{
"id": "mid-valley",
"name": "Mid Valley Megamall",
"aliases": ["Mid Valley Megamall", "Mid Valley", "MV"],
"lat": 3.118,
"lng": 101.6779,
"category": "mall",
"address": "Lingkaran Syed Putra, 59200 Kuala Lumpur"
},
{
"id": "sunway-velocity",
"name": "Sunway Velocity",
"aliases": ["Sunway Velocity", "Velocity", "Sunway Velocity Mall"],
"lat": 3.1355,
"lng": 101.729,
"category": "mall",
"address": "Lingkaran SV, Cheras, 55100 Kuala Lumpur"
},
{
"id": "ioi-city-mall",
"name": "IOI City Mall",
"aliases": ["IOI City Mall", "IOI City"],
"lat": 2.9897,
"lng": 101.7376,
"category": "mall",
"address": "Lebuhraya Sultan Iskandar, 62502 Putrajaya"
},
{
"id": "nu-sentral",
"name": "Nu Sentral",
"aliases": ["Nu Sentral", "KL Sentral Mall"],
"lat": 3.1335,
"lng": 101.6864,
"category": "mall",
"address": "201 Jalan Tun Sambanthan, 50470 Kuala Lumpur"
},
{
"id": "merdeka-118",
"name": "Merdeka 118",
"aliases": ["Merdeka 118", "PNB 118"],
"lat": 3.1408,
"lng": 101.7047,
"category": "landmark",
"address": "Jalan Hang Jebat, 50150 Kuala Lumpur"
},
{
"id": "kl-tower",
"name": "Kuala Lumpur Tower",
"aliases": ["Kuala Lumpur Tower", "KL Tower", "Menara Kuala Lumpur"],
"lat": 3.1528,
"lng": 101.7038,
"category": "landmark",
"address": "2 Jalan Punchak, 50250 Kuala Lumpur"
},
{
"id": "petronas-twin-towers",
"name": "Petronas Twin Towers",
"aliases": ["Petronas Twin Towers", "Petronas Towers", "Twin Towers", "KLCC Towers"],
"lat": 3.1579,
"lng": 101.7116,
"category": "landmark",
"address": "Kuala Lumpur City Centre, 50088 Kuala Lumpur"
},
{
"id": "aquaria-klcc",
"name": "Aquaria KLCC",
"aliases": ["Aquaria KLCC", "Aquaria"],
"lat": 3.1532,
"lng": 101.7104,
"category": "landmark",
"address": "Kuala Lumpur Convention Centre, Jalan Pinang, 50088 Kuala Lumpur"
},
{
"id": "central-market",
"name": "Central Market",
"aliases": ["Central Market", "Pasar Seni", "Pasar Seni Kuala Lumpur"],
"lat": 3.1454,
"lng": 101.6953,
"category": "mall",
"address": "Jalan Hang Kasturi, 50050 Kuala Lumpur"
},
{
"id": "petaling-street",
"name": "Petaling Street",
"aliases": ["Petaling Street", "Chi Cheong Kai", "Jalan Petaling"],
"lat": 3.1436,
"lng": 101.6975,
"category": "landmark",
"address": "Jalan Petaling, 50000 Kuala Lumpur"
},
{
"id": "masjid-jamek",
"name": "Masjid Jamek",
"aliases": ["Masjid Jamek", "Jamek Mosque"],
"lat": 3.1484,
"lng": 101.6953,
"category": "landmark",
"address": "Jalan Tun Perak, 50050 Kuala Lumpur"
},
{
"id": "pudu-raya",
"name": "Pudu Sentral",
"aliases": ["Pudu Sentral", "Pudu Raya", "Puduraya"],
"lat": 3.1381,
"lng": 101.7073,
"category": "station",
"address": "Jalan Pudu, 55100 Kuala Lumpur"
},
{
"id": "bukit-bintang",
"name": "Bukit Bintang",
"aliases": ["Bukit Bintang", "BB Park", "Bukit Bintang KL"],
"lat": 3.1466,
"lng": 101.7106,
"category": "landmark",
"address": "Jalan Bukit Bintang, 55100 Kuala Lumpur"
},
{
"id": "chinatown",
"name": "Chinatown Kuala Lumpur",
"aliases": ["Chinatown Kuala Lumpur", "Chinatown", "KL Chinatown"],
"lat": 3.1436,
"lng": 101.6959,
"category": "landmark",
"address": "Jalan Petaling, 50000 Kuala Lumpur"
},
{
"id": "little-india-brickfields",
"name": "Brickfields",
"aliases": ["Brickfields", "Little India", "Little India Brickfields"],
"lat": 3.1306,
"lng": 101.6869,
"category": "landmark",
"address": "Jalan Tun Sambanthan, 50470 Kuala Lumpur"
},
{
"id": "titiwangsa",
"name": "Taman Tasik Titiwangsa",
"aliases": ["Taman Tasik Titiwangsa", "Titiwangsa", "Lake Titiwangsa"],
"lat": 3.1714,
"lng": 101.7016,
"category": "park",
"address": "Jalan Temerloh, 53200 Kuala Lumpur"
},
{
"id": "lake-gardens",
"name": "Perdana Botanical Garden",
"aliases": ["Perdana Botanical Garden", "Lake Gardens", "Taman Perdana"],
"lat": 3.143,
"lng": 101.6917,
"category": "park",
"address": "Jalan Kebun Bunga, 55100 Kuala Lumpur"
},
{
"id": "national-museum",
"name": "National Museum",
"aliases": ["National Museum", "Muzium Negara"],
"lat": 3.1378,
"lng": 101.687,
"category": "landmark",
"address": "Jalan Damansara, 50566 Kuala Lumpur"
}
]
}
+106
View File
@@ -0,0 +1,106 @@
/*
* gazetteer.ts — the dataset loader + alias index (D-06, D-07, D-09).
* ------------------------------------------------------------------
* Three hard laws:
*
* LAW 1 — LAZY, MEMOIZED, NEVER AT MODULE SCOPE (RESEARCH Pitfall 8,
* Workers 1 s startup limit / "avoid expensive work in global scope").
* The index is built inside a function on first access and cached in
* module scope — the same lazy-load shape as bot/test/initdata.test.mjs.
*
* LAW 2 — THE LOADER MUST SURVIVE THREE ENVIRONMENTS: the
* wrangler/esbuild bundle (Worker), the Vite bundle (browser), and bare
* Node 24 (the corpus runner). The mechanism is the STATIC JSON IMPORT
* below — Workers have no fs and a runtime retrieval would be a hard
* failure. esbuild and Vite both inline a static JSON import at build
* time; the `with { type: "json" }` attribute is required by Node ESM
* and harmless under both bundlers (loader-env suite is the arbiter).
*
* LAW 3 — NO LOGGING AND NO STORAGE (D-15): the query text is user
* payload. This module contains no logging at all and touches no
* storage surface.
*
* The index maps each entry's NORMALIZED name and each NORMALIZED alias
* to the entry (the D-09 symmetry law — both sides of every comparison
* are canonical). Normalization happens ONCE per dataset load, never
* per query.
*/
import raw from "./data/kl-gazetteer.json" with { type: "json" };
import { normalize } from "./normalize.ts";
import type { GazetteerEntry } from "./types.ts";
/** The committed dataset's envelope (the ODbL notice travels inside it). */
interface GazetteerFile {
_license: string;
_attribution: string;
_notice: string;
version: number;
entries: GazetteerEntry[];
}
let entriesCache: readonly GazetteerEntry[] | null = null;
let byIdCache: Map<string, GazetteerEntry> | null = null;
let indexCache: Map<string, GazetteerEntry[]> | null = null;
/** The parsed dataset — one V8-native JSON parse, memoized. */
function loadEntries(): readonly GazetteerEntry[] {
if (entriesCache === null) {
const file = raw as GazetteerFile;
entriesCache = Object.freeze([...file.entries]);
}
return entriesCache;
}
/** id -> entry, built lazily. */
function loadById(): Map<string, GazetteerEntry> {
if (byIdCache === null) {
const map = new Map<string, GazetteerEntry>();
for (const entry of loadEntries()) {
map.set(entry.id, entry);
}
byIdCache = map;
}
return byIdCache;
}
/** normalized alias -> entry, built lazily and memoized (LAW 1). */
function loadIndex(): Map<string, GazetteerEntry[]> {
if (indexCache === null) {
const index = new Map<string, GazetteerEntry[]>();
for (const entry of loadEntries()) {
const keys = new Set<string>();
const nameKey = normalize(entry.name);
if (nameKey !== "") keys.add(nameKey);
for (const alias of entry.aliases) {
const aliasKey = normalize(alias);
if (aliasKey !== "") keys.add(aliasKey);
}
for (const key of keys) {
const bucket = index.get(key);
if (bucket === undefined) {
index.set(key, [entry]);
} else {
bucket.push(entry);
}
}
}
indexCache = index;
}
return indexCache;
}
/** All curated entries (frozen — the dataset is a build-time constant). */
export function getEntries(): readonly GazetteerEntry[] {
return loadEntries();
}
/** id -> entry; honest null when the id is unknown (never a fabricated entry). */
export function getEntryById(id: string): GazetteerEntry | null {
return loadById().get(id) ?? null;
}
/** The memoized normalized-alias index (normalized key -> entries). */
export function getIndex(): Map<string, GazetteerEntry[]> {
return loadIndex();
}
+22
View File
@@ -0,0 +1,22 @@
/*
* index.ts — the public API surface of @araharah/search (D-03, D-07).
* -------------------------------------------------------------------
* Named exports ONLY (no default export). The package has NO exports /
* main field (the @araharah/theme precedent), so consumers import the
* EXPLICIT SUBPATH WITH EXTENSION —
* import { createGazetteerSource } from "@araharah/search/src/index.ts";
* (both tsconfigs carry allowImportingTsExtensions + bundler resolution;
* esbuild and Vite compile the .ts source; Node 24 type-stripping handles
* the corpus runner's relative form). A bare "@araharah/search" specifier
* resolves to NOTHING — never use it.
*
* The module reads NO environment, NO secrets, and NO network (D-04/D-15):
* the whole search stack is V8-native and dependency-free (D-07).
*/
export type { GazetteerEntry, SearchQuery, SearchHit, SearchSource } from "./types.ts";
export { normalize } from "./normalize.ts";
export { TIER, FUZZY_ADMIT_SIMILARITY } from "./score.ts";
export { damerau } from "./distance.ts";
export { getEntries, getEntryById, getIndex } from "./gazetteer.ts";
export { createGazetteerSource, search } from "./source.ts";
+76
View File
@@ -0,0 +1,76 @@
/*
* source.ts — the D-03 in-Worker SearchSource implementation.
* -----------------------------------------------------------
* Wraps the lazy gazetteer index in the pluggable `SearchSource` seam
* (D-03): an async search() the bot handler awaits, and a synchronous
* byId() the alternates callback handler and the webview deep-link
* reader use without awaiting a network.
*
* THE REFUSAL CONTRACT IS LOAD-BEARING (D-13/D-14): an EMPTY array is a
* legitimate, load-bearing return value meaning "not in the v1 area".
* Admission is TIERED, never a single floor — the EXACT / ALIAS_EXACT /
* PREFIX / TOKEN_SET bands are always admitted; a FUZZY hit is admitted
* only when its similarity reaches FUZZY_ADMIT_SIMILARITY (0.72):
* "KCLC" vs "KLCC" sim 0.75 -> admitted (score 0.45)
* "Penang" vs "Padang" sim 0.667 -> REFUSED (empty array, never a
* fabricated pin)
* scoreCandidate already applies that floor, so a null here means
* "not in the v1 area" and the bot renders the honest refusal copy
* (plan 04-03).
*
* Ranking: score band DESC (the bands are disjoint, so score order IS
* tier order), then entry id ASC — a deterministic tie-break so corpus
* results are reproducible.
*
* No logging, no storage (D-15): the query text never leaves the caller.
*/
import { getEntries, getEntryById } from "./gazetteer.ts";
import { normalize } from "./normalize.ts";
import { FUZZY_ADMIT_SIMILARITY, TIER, scoreCandidate } from "./score.ts";
import type { GazetteerEntry, SearchHit, SearchQuery, SearchSource } from "./types.ts";
/** Entry ids are slugs — byId rejects anything else before it touches the index. */
const ENTRY_ID_PATTERN = /^[a-z0-9-]{1,64}$/;
/** 1 best + 2-4 alternates (D-02). */
const DEFAULT_LIMIT = 5;
/** Synchronous convenience search over the whole gazetteer — the corpus
* runner (plan 04-02) calls this without awaiting a network. */
export function search(text: string, limit?: number): SearchHit[] {
const queryNorm = normalize(text);
if (queryNorm === "") return [];
const cap = limit ?? DEFAULT_LIMIT;
const hits: SearchHit[] = [];
for (const entry of getEntries()) {
const scored = scoreCandidate(queryNorm, [entry.name, ...entry.aliases]);
if (scored === null) continue; // not admitted — "not in the v1 area"
hits.push({ entry, score: scored.score, matchedAlias: scored.matchedAlias });
}
hits.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score; // tier band DESC (disjoint bands)
if (a.entry.id < b.entry.id) return -1; // deterministic id ASC tie-break
if (a.entry.id > b.entry.id) return 1;
return 0;
});
return hits.slice(0, cap);
}
/** The D-03 gazetteer backend: async search() + synchronous byId(). */
export function createGazetteerSource(): SearchSource {
return {
name: "gazetteer",
async search(q: SearchQuery): Promise<SearchHit[]> {
return search(q.text, q.limit);
},
byId(id: string): GazetteerEntry | null {
if (!ENTRY_ID_PATTERN.test(id)) return null;
return getEntryById(id);
},
};
}
/** Re-exported so the tiered admission rule reads as one story: the
* floor lives in score.ts; this module applies it per D-13. */
export { FUZZY_ADMIT_SIMILARITY };