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
+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();
}