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