docs(04): plan revision 1 — fix wave-1 bundle proof + acceptance-criteria corrections
This commit is contained in:
@@ -43,7 +43,7 @@ must_haves:
|
|||||||
- "'Jln Ampang' and 'Jalan Ampang' normalize to the IDENTICAL string; 'Tmn' expands to 'taman'; 'Kg' to 'kampung'; 'Prsn' to 'presint'"
|
- "'Jln Ampang' and 'Jalan Ampang' normalize to the IDENTICAL string; 'Tmn' expands to 'taman'; 'Kg' to 'kampung'; 'Prsn' to 'presint'"
|
||||||
- "A transposition typo ('KCLC') still resolves to the expected entry id, via the Damerau-Levenshtein residual tier"
|
- "A transposition typo ('KCLC') still resolves to the expected entry id, via the Damerau-Levenshtein residual tier"
|
||||||
- "An exact normalized match ALWAYS outranks any fuzzy match — the tier score bands are disjoint and asserted by a test"
|
- "An exact normalized match ALWAYS outranks any fuzzy match — the tier score bands are disjoint and asserted by a test"
|
||||||
- "The gazetteer JSON loads identically in three environments: the wrangler bundle, the Vite bundle, and bare Node 24 (the corpus runner)"
|
- "The gazetteer JSON loads identically in three environments: the Worker bundle (esbuild/wrangler — inline proven on the search module's own esbuild bundle, dry-run as the build guard), the Vite bundle, and bare Node 24 (the corpus runner)"
|
||||||
- "The Worker never builds an index at module scope — the index is built lazily on first search and memoized"
|
- "The Worker never builds an index at module scope — the index is built lazily on first search and memoized"
|
||||||
- "A search over the full gazetteer costs well under the Workers Free 10 ms CPU/request budget"
|
- "A search over the full gazetteer costs well under the Workers Free 10 ms CPU/request budget"
|
||||||
- "The repo carries a GPL-3.0 LICENSE and the gazetteer carries an ODbL-1.0 + OpenStreetMap attribution notice"
|
- "The repo carries a GPL-3.0 LICENSE and the gazetteer carries an ODbL-1.0 + OpenStreetMap attribution notice"
|
||||||
@@ -54,7 +54,7 @@ must_haves:
|
|||||||
contains: "export interface SearchSource"
|
contains: "export interface SearchSource"
|
||||||
- path: "search/src/rules/abbreviations.ts"
|
- path: "search/src/rules/abbreviations.ts"
|
||||||
provides: "the vendored JUPEM-derived bidirectional abbreviation table as DATA (D-09)"
|
provides: "the vendored JUPEM-derived bidirectional abbreviation table as DATA (D-09)"
|
||||||
contains: "ABBREVIATIONS"
|
contains: "ABBREVIATION_ROWS"
|
||||||
- path: "search/src/normalize.ts"
|
- path: "search/src/normalize.ts"
|
||||||
provides: "pure normalize(): NFKD-fold → whitespace/punctuation collapse → whole-token abbreviation expansion → glued-word split (D-09)"
|
provides: "pure normalize(): NFKD-fold → whitespace/punctuation collapse → whole-token abbreviation expansion → glued-word split (D-09)"
|
||||||
- path: "search/src/distance.ts"
|
- path: "search/src/distance.ts"
|
||||||
@@ -82,12 +82,12 @@ must_haves:
|
|||||||
- path: "search/test/gazetteer.test.mjs"
|
- path: "search/test/gazetteer.test.mjs"
|
||||||
provides: "entry schema, unique slug-safe ids, alias presence, ODbL header, LICENSE presence (D-07, D-08)"
|
provides: "entry schema, unique slug-safe ids, alias presence, ODbL header, LICENSE presence (D-07, D-08)"
|
||||||
- path: "search/test/loader-env.test.mjs"
|
- path: "search/test/loader-env.test.mjs"
|
||||||
provides: "the three-environment loader proof: bare Node import, wrangler dry-run bundle, Vite bundle (the phase's top mechanical risk)"
|
provides: "the three-environment loader proof: bare Node import, the esbuild inline-bundle marker + wrangler dry-run build guard, the Vite static-import assertion (the phase's top mechanical risk)"
|
||||||
key_links:
|
key_links:
|
||||||
- from: "search/src/normalize.ts"
|
- from: "search/src/normalize.ts"
|
||||||
to: "search/src/rules/abbreviations.ts"
|
to: "search/src/rules/abbreviations.ts"
|
||||||
via: "the whole-token expansion reads the imported table; a table edit extends behavior without a code change (D-09)"
|
via: "the whole-token expansion reads the imported table; a table edit extends behavior without a code change (D-09)"
|
||||||
pattern: "ABBREVIATIONS"
|
pattern: "ABBREVIATION_ROWS"
|
||||||
- from: "search/src/gazetteer.ts"
|
- from: "search/src/gazetteer.ts"
|
||||||
to: "search/src/data/kl-gazetteer.json"
|
to: "search/src/data/kl-gazetteer.json"
|
||||||
via: "a static JSON import (build-time inlined), never a runtime fetch — Workers have no fs"
|
via: "a static JSON import (build-time inlined), never a runtime fetch — Workers have no fs"
|
||||||
@@ -429,7 +429,7 @@ The index maps each entry's NORMALIZED name AND each NORMALIZED alias → the en
|
|||||||
```typescript
|
```typescript
|
||||||
export type { GazetteerEntry, SearchQuery, SearchHit, SearchSource } from "./types.ts";
|
export type { GazetteerEntry, SearchQuery, SearchHit, SearchSource } from "./types.ts";
|
||||||
export { normalize } from "./normalize.ts";
|
export { normalize } from "./normalize.ts";
|
||||||
export { TIER } from "./score.ts";
|
export { TIER, FUZZY_ADMIT_SIMILARITY } from "./score.ts";
|
||||||
export { damerau } from "./distance.ts";
|
export { damerau } from "./distance.ts";
|
||||||
export { getEntries, getEntryById, getIndex } from "./gazetteer.ts";
|
export { getEntries, getEntryById, getIndex } from "./gazetteer.ts";
|
||||||
export { createGazetteerSource, search } from "./source.ts";
|
export { createGazetteerSource, search } from "./source.ts";
|
||||||
@@ -449,7 +449,7 @@ Ranking: sort by tier band DESC, then by score DESC, then by entry id ASC (a det
|
|||||||
|
|
||||||
<task type="auto" tdd="true">
|
<task type="auto" tdd="true">
|
||||||
<name>task 1: shared workspace scaffold + types + the JUPEM abbreviation table as DATA</name>
|
<name>task 1: shared workspace scaffold + types + the JUPEM abbreviation table as DATA</name>
|
||||||
<files>package.json, search/package.json, search/tsconfig.json, search/src/types.ts, search/src/rules/abbreviations.ts, search/test/normalizer.test.mjs</files>
|
<files>package.json, search/package.json, search/tsconfig.json, search/src/types.ts, search/src/rules/abbreviations.ts, search/src/normalize.ts, search/test/normalizer.test.mjs</files>
|
||||||
<read_first>
|
<read_first>
|
||||||
- `package.json` (root — the `workspaces` array is `["theme","bot","web"]`; add `"search"`; keep every existing script)
|
- `package.json` (root — the `workspaces` array is `["theme","bot","web"]`; add `"search"`; keep every existing script)
|
||||||
- `theme/package.json` (the manifest shape to copy — `name`, `type: "module"`, no `exports`/`main`, so consumers import sub-paths directly)
|
- `theme/package.json` (the manifest shape to copy — `name`, `type: "module"`, no `exports`/`main`, so consumers import sub-paths directly)
|
||||||
@@ -586,7 +586,7 @@ Cases (T-numbered, one per law):
|
|||||||
- `T6 vowel-drop` — `normalize("Prsn 15") === "presint 15"` and `normalize("Presint 15") === "presint 15"`.
|
- `T6 vowel-drop` — `normalize("Prsn 15") === "presint 15"` and `normalize("Presint 15") === "presint 15"`.
|
||||||
- `T7 idempotence` — for every vector `v`: `normalize(normalize(v)) === normalize(v)`.
|
- `T7 idempotence` — for every vector `v`: `normalize(normalize(v)) === normalize(v)`.
|
||||||
- `T8 case/whitespace folding` — `normalize(" SUNWAY PYRAMID ") === "sunway pyramid"`.
|
- `T8 case/whitespace folding` — `normalize(" SUNWAY PYRAMID ") === "sunway pyramid"`.
|
||||||
- `T9 the table is data` — read `search/src/rules/abbreviations.ts` as TEXT (comments stripped) and assert it contains no `function` keyword (the module is data + one builder function, not algorithm code) and that the row count is >= 47.
|
- `T9 the table is data` — read `search/src/rules/abbreviations.ts` as TEXT (comments stripped) and assert that the ONLY `function` in the file is `buildAbbreviationMap` — no other function, and no control-flow keywords (`if`, `for`, `=>`) outside it — so the module is data plus one generic builder, never algorithm code; and assert the row count is >= 47.
|
||||||
|
|
||||||
**`search/src/normalize.ts`** — implement EXACTLY per the `<normalizer_contract>` pipeline. The exported signature is `export function normalize(input: string): string`. Import the table via `import { buildAbbreviationMap } from "./rules/abbreviations.ts";` and build the map ONCE at module scope (the map is ~60 entries of plain data — cheap; the D-09 "lazy" law applies to the 500-entry gazetteer INDEX, not to this table).
|
**`search/src/normalize.ts`** — implement EXACTLY per the `<normalizer_contract>` pipeline. The exported signature is `export function normalize(input: string): string`. Import the table via `import { buildAbbreviationMap } from "./rules/abbreviations.ts";` and build the map ONCE at module scope (the map is ~60 entries of plain data — cheap; the D-09 "lazy" law applies to the 500-entry gazetteer INDEX, not to this table).
|
||||||
|
|
||||||
@@ -645,7 +645,8 @@ Write `search/src/normalize.ts` NOW as part of this task so the suite in this ta
|
|||||||
```
|
```
|
||||||
|
|
||||||
**`search/src/score.ts`** — the disjoint-tier scorer, per the `<scorer_contract>` block. Exports:
|
**`search/src/score.ts`** — the disjoint-tier scorer, per the `<scorer_contract>` block. Exports:
|
||||||
- `export const TIER = { EXACT: 1.00, ALIAS_EXACT: 0.90, PREFIX: 0.75, TOKEN_SET: 0.65, FUZZY: 0.00 } as const;`
|
- `export const TIER = { EXACT: 1.00, ALIAS_EXACT: 0.90, PREFIX: 0.75, TOKEN_SET: 0.65, FUZZY: 0.60 } as const;` — `FUZZY` is the band's CEILING (0.60), NOT a zero multiplier: a fuzzy score is `fuzzySim * TIER.FUZZY`, so the whole tier lands strictly inside `(0, 0.60)` (a similarity of 1.0 would have been caught by the EXACT tier first). Multiplying the similarity by `0.00` would zero the entire residual tier — see the `<scorer_contract>` block's NOTE.
|
||||||
|
- `export const FUZZY_ADMIT_SIMILARITY = 0.72;` — the fuzzy admission floor, defined HERE (the `<scorer_contract>` block places it in score.ts) and imported by `source.ts` (task 3) and re-exported through `index.ts` (the `<index_contract>` block) so the corpus runner (plan 04-02's CO8) reads the floor from the module instead of hardcoding it.
|
||||||
- `export function scoreOne(queryNorm: string, aliasNorm: string): number`
|
- `export function scoreOne(queryNorm: string, aliasNorm: string): number`
|
||||||
- `export function scoreCandidate(queryNorm: string, aliasNorms: readonly string[]): { score: number; matchedAlias: string } | null` — returns the BEST tier hit and WHICH alias matched (the corpus report needs that provenance), or `null` when nothing scores.
|
- `export function scoreCandidate(queryNorm: string, aliasNorms: readonly string[]): { score: number; matchedAlias: string } | null` — returns the BEST tier hit and WHICH alias matched (the corpus report needs that provenance), or `null` when nothing scores.
|
||||||
|
|
||||||
@@ -660,7 +661,7 @@ Implementation notes:
|
|||||||
**`search/test/distance.test.mjs`** — RED-first. One `test(...)` per vector from the `<distance_contract>` block, with the hand-computed expected value in the message. Load the module lazily (task 1's pattern).
|
**`search/test/distance.test.mjs`** — RED-first. One `test(...)` per vector from the `<distance_contract>` block, with the hand-computed expected value in the message. Load the module lazily (task 1's pattern).
|
||||||
|
|
||||||
**`search/test/score.test.mjs`** — RED-first. Cases:
|
**`search/test/score.test.mjs`** — RED-first. Cases:
|
||||||
- `S1 tier disjointness` — for each pair of adjacent tiers, `TIER[a] > TIER[b]`; and `TIER.FUZZY === 0` with every FUZZY score `<= 0.60` and `> 0` for a near miss.
|
- `S1 tier disjointness` — for each pair of adjacent tiers, `TIER[a] > TIER[b]` (including `TIER.TOKEN_SET` 0.65 > `TIER.FUZZY` 0.60); and `TIER.FUZZY === 0.60` (the band's CEILING) with every admitted FUZZY score strictly inside `(0, 0.60)` — `> 0` for a near miss, `< 0.60` because a similarity of 1.0 is caught by the EXACT tier first.
|
||||||
- `S2 min-length guard` — `scoreOne("ab", "abcd") === 0` and `scoreOne("kl", "kl") === 0` (both shorter than 3 → 0, EVEN for an exact match — the guard fires first).
|
- `S2 min-length guard` — `scoreOne("ab", "abcd") === 0` and `scoreOne("kl", "kl") === 0` (both shorter than 3 → 0, EVEN for an exact match — the guard fires first).
|
||||||
- `S3 exact outranks fuzzy` — `scoreOne("klcc", "klcc") > scoreOne("klcc", "klca")` and `scoreOne("klcc", "klcc") > TIER.FUZZY` (an exact match always beats any fuzzy hit).
|
- `S3 exact outranks fuzzy` — `scoreOne("klcc", "klcc") > scoreOne("klcc", "klca")` and `scoreOne("klcc", "klcc") > TIER.FUZZY` (an exact match always beats any fuzzy hit).
|
||||||
- `S4 prefix tier` — `scoreOne("jalan amp", "jalan ampang") === TIER.PREFIX`.
|
- `S4 prefix tier` — `scoreOne("jalan amp", "jalan ampang") === TIER.PREFIX`.
|
||||||
@@ -676,7 +677,7 @@ Implementation notes:
|
|||||||
<acceptance_criteria>
|
<acceptance_criteria>
|
||||||
- `npm run search:test` exits 0 and the output contains `PASS S1` through `PASS S7`
|
- `npm run search:test` exits 0 and the output contains `PASS S1` through `PASS S7`
|
||||||
- `npm run search:typecheck` exits 0
|
- `npm run search:typecheck` exits 0
|
||||||
- `grep -q 'export const TIER' search/src/score.ts` exits 0 AND `grep -q 'EXACT: *1.00' search/src/score.ts` exits 0 AND `grep -q 'ALIAS_EXACT: *0.90' search/src/score.ts` exits 0 AND `grep -q 'PREFIX: *0.75' search/src/score.ts` exits 0 AND `grep -q 'TOKEN_SET: *0.65' search/src/score.ts` exits 0 AND `grep -q 'FUZZY: *0.00' search/src/score.ts` exits 0 (the disjoint bands are exact literals)
|
- `grep -q 'export const TIER' search/src/score.ts` exits 0 AND `grep -q 'EXACT: *1.00' search/src/score.ts` exits 0 AND `grep -q 'ALIAS_EXACT: *0.90' search/src/score.ts` exits 0 AND `grep -q 'PREFIX: *0.75' search/src/score.ts` exits 0 AND `grep -q 'TOKEN_SET: *0.65' search/src/score.ts` exits 0 AND `grep -q 'FUZZY: *0.60' search/src/score.ts` exits 0 (the disjoint bands are exact literals — FUZZY is the 0.60 ceiling, never a 0.00 multiplier) AND `grep -q 'FUZZY_ADMIT_SIMILARITY = 0.72' search/src/score.ts` exits 0 (the admission floor is an exported constant, not an inlined literal)
|
||||||
- `grep -q 'export function damerau' search/src/distance.ts` exits 0
|
- `grep -q 'export function damerau' search/src/distance.ts` exits 0
|
||||||
- `node -e "import('./search/src/distance.ts').then(m=>{const d=m.damerau; const cases=[['ab','ba',1],['a cat','an act',2],['','',0],['','abc',3],['abc','',3],['same','same',0],['klcc','kclc',1],['sunway','sunwya',1],['kitten','sitting',3]]; for(const [a,b,e] of cases){const g=d(a,b); if(g!==e) throw new Error(a+'/'+b+' => '+g+' expected '+e);} console.log('distance OK')})"` exits 0
|
- `node -e "import('./search/src/distance.ts').then(m=>{const d=m.damerau; const cases=[['ab','ba',1],['a cat','an act',2],['','',0],['','abc',3],['abc','',3],['same','same',0],['klcc','kclc',1],['sunway','sunwya',1],['kitten','sitting',3]]; for(const [a,b,e] of cases){const g=d(a,b); if(g!==e) throw new Error(a+'/'+b+' => '+g+' expected '+e);} console.log('distance OK')})"` exits 0
|
||||||
- `node -e "import('./search/src/score.ts').then(m=>{if(m.scoreOne('ab','abcd')!==0) throw new Error('min-length guard'); if(!(m.scoreOne('klcc','klcc')>m.scoreOne('klcc','klca'))) throw new Error('exact must outrank fuzzy'); if(m.scoreOne('taman bangsar','taman bukit')===m.TIER.TOKEN_SET) throw new Error('token-set false positive'); console.log('score OK')})"` exits 0
|
- `node -e "import('./search/src/score.ts').then(m=>{if(m.scoreOne('ab','abcd')!==0) throw new Error('min-length guard'); if(!(m.scoreOne('klcc','klcc')>m.scoreOne('klcc','klca'))) throw new Error('exact must outrank fuzzy'); if(m.scoreOne('taman bangsar','taman bukit')===m.TIER.TOKEN_SET) throw new Error('token-set false positive'); console.log('score OK')})"` exits 0
|
||||||
@@ -715,7 +716,7 @@ If `npm run search:typecheck` or the loader-env suite rejects the import attribu
|
|||||||
|
|
||||||
The index build: for each entry, normalize the entry's `name` and each of its `aliases`, and map each normalized string → the entry. The map is built ONCE, lazily, memoized in module scope (`let index: Map<string, GazetteerEntry[]> | null = null;`).
|
The index build: for each entry, normalize the entry's `name` and each of its `aliases`, and map each normalized string → the entry. The map is built ONCE, lazily, memoized in module scope (`let index: Map<string, GazetteerEntry[]> | null = null;`).
|
||||||
|
|
||||||
**`search/src/source.ts`** — the D-03 `SearchSource` implementation. Exports `createGazetteerSource(): SearchSource` and a synchronous `search(text: string, limit?: number): SearchHit[]` convenience wrapper. Behaviour:
|
**`search/src/source.ts`** — the D-03 `SearchSource` implementation. Exports `createGazetteerSource(): SearchSource` and a synchronous `search(text: string, limit?: number): SearchHit[]` convenience wrapper. The module's HEADER COMMENT states the tiered admission rule with the numbers, verbatim-in-spirit from the `<index_contract>` block: non-fuzzy tiers always admitted; a FUZZY hit only at similarity >= `FUZZY_ADMIT_SIMILARITY` (0.72) — "KCLC" vs "KLCC" is sim 0.75 → admitted (score 0.45), "Penang" vs "Padang" is sim 0.667 → refused (an empty array, never a fabricated pin). Behaviour:
|
||||||
- Normalize the query text.
|
- Normalize the query text.
|
||||||
- For every candidate (each entry's normalized name + each alias), score with `scoreCandidate`.
|
- For every candidate (each entry's normalized name + each alias), score with `scoreCandidate`.
|
||||||
- Keep only ADMITTED hits: EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET always; a FUZZY hit only when its similarity >= `FUZZY_ADMIT_SIMILARITY` (0.72). Below admission, the query is "not in the v1 area" and the result is an EMPTY array, never a nearest-match pin (D-13/D-14).
|
- Keep only ADMITTED hits: EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET always; a FUZZY hit only when its similarity >= `FUZZY_ADMIT_SIMILARITY` (0.72). Below admission, the query is "not in the v1 area" and the result is an EMPTY array, never a nearest-match pin (D-13/D-14).
|
||||||
@@ -741,7 +742,7 @@ The index build: for each entry, normalize the entry's `name` and each of its `a
|
|||||||
**`search/test/loader-env.test.mjs`** — THE PHASE'S TOP MECHANICAL RISK, proven mechanically. Three environments, three assertions:
|
**`search/test/loader-env.test.mjs`** — THE PHASE'S TOP MECHANICAL RISK, proven mechanically. Three environments, three assertions:
|
||||||
- `L1 bare Node (type-stripping)` — `await import("../src/index.ts")` works and `getEntries().length >= 30`; the runner file itself IS this environment (Node 24 strips types by default).
|
- `L1 bare Node (type-stripping)` — `await import("../src/index.ts")` works and `getEntries().length >= 30`; the runner file itself IS this environment (Node 24 strips types by default).
|
||||||
- `L2 the Vite bundle inlines the gazetteer` — the web bundle is built by `npm run build -w theme && npm run build -w web` and the emitted `web/dist/assets/*.js` contains a gazetteer id marker (e.g. `"suria-klcc"`) — proving the JSON was inlined at BUILD time, not fetched at runtime. (The web workspace imports `@araharah/search` starting in plan 04-04; until then this case asserts the SEARCH workspace's own module can be bundled — assert it against the search workspace's own esbuild-free check: `node -e "import('./search/src/index.ts')"` PLUS a static-text assertion that `search/src/gazetteer.ts` contains a STATIC import of the JSON, not a `fetch(` or `readFileSync`. If plan 04-04 has landed by the time this suite runs, ALSO assert the web bundle marker; otherwise skip that half with an explicit `test.skip`-style guard that still asserts the static-import half. Make the skip visible in the runner output.)
|
- `L2 the Vite bundle inlines the gazetteer` — the web bundle is built by `npm run build -w theme && npm run build -w web` and the emitted `web/dist/assets/*.js` contains a gazetteer id marker (e.g. `"suria-klcc"`) — proving the JSON was inlined at BUILD time, not fetched at runtime. (The web workspace imports `@araharah/search` starting in plan 04-04; until then this case asserts the SEARCH workspace's own module can be bundled — assert it against the search workspace's own esbuild-free check: `node -e "import('./search/src/index.ts')"` PLUS a static-text assertion that `search/src/gazetteer.ts` contains a STATIC import of the JSON, not a `fetch(` or `readFileSync`. If plan 04-04 has landed by the time this suite runs, ALSO assert the web bundle marker; otherwise skip that half with an explicit `test.skip`-style guard that still asserts the static-import half. Make the skip visible in the runner output.)
|
||||||
- `L3 the Worker bundle inlines the gazetteer` — `cd bot && npx wrangler deploy --dry-run` (wrangler 4.151.3 supports `--dry-run`; `wrangler dev` does NOT) must succeed, and the dry-run output must show the gazetteer id marker in the bundled Worker output. Capture the dry-run output to a temp file and grep it for `suria-klcc`. `wrangler deploy --dry-run` prints the bundled worker size and (with `--outdir`) writes the bundle — use `--outdir` to a temp directory and grep the emitted JS for the marker, which is the deterministic proof.
|
- `L3 the esbuild/Worker bundler inlines the gazetteer` — TWO halves. **The inline half (provable at wave 1):** bundle the search module directly with esbuild — the same bundler family wrangler uses (esbuild 0.28.2 is hoisted at `node_modules/.bin/esbuild`): `node_modules/.bin/esbuild search/src/index.ts --bundle --outfile=/tmp/opencode/araharah-search-bundle/index.js`, then grep the emitted JS for the `suria-klcc` marker — a static JSON import MUST be inlined at build time, never fetched at runtime. **The Worker dry-run half (build regression guard ONLY):** `cd bot && npx wrangler deploy --dry-run --outdir=<tmp>` (wrangler 4.151.3 supports `--dry-run`; `wrangler dev` does NOT) must EXIT 0. Do NOT grep the dry-run bundle for the marker in this plan: at wave 1 no bot source imports `@araharah/search` (`bot.ts` gains `createGazetteerSource()` only in plan 04-03 task 2), so the Worker bundle cannot contain the marker yet and a marker grep here would red-light the whole suite. The Worker-bundle marker grep is plan 04-03 task 2's acceptance criterion. If plan 04-03 has already landed when this suite runs, ALSO assert the Worker-bundle marker — guarded by the same explicit skip-guard style L2 uses for the web bundle, with the skip made visible in the runner output.
|
||||||
|
|
||||||
**`LICENSE`** — the VERBATIM GPL-3.0 text from https://www.gnu.org/licenses/gpl-3.0.txt. Do NOT paraphrase, do NOT summarize, do NOT truncate the "how to apply" section. The file must contain the strings "GNU GENERAL PUBLIC LICENSE", "Version 3, 29 June 2007", and "TERMS AND CONDITIONS".
|
**`LICENSE`** — the VERBATIM GPL-3.0 text from https://www.gnu.org/licenses/gpl-3.0.txt. Do NOT paraphrase, do NOT summarize, do NOT truncate the "how to apply" section. The file must contain the strings "GNU GENERAL PUBLIC LICENSE", "Version 3, 29 June 2007", and "TERMS AND CONDITIONS".
|
||||||
|
|
||||||
@@ -782,16 +783,16 @@ to include the `search/` workspace line:
|
|||||||
search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src/gazetteer.ts, src/data/kl-gazetteer.json
|
search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src/gazetteer.ts, src/data/kl-gazetteer.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**Acceptance:** `npm run search:test` exits 0 (including `PASS G1`…`PASS G11` and `PASS L1`…`PASS L3`); `npm run search:typecheck` exits 0; `npx wrangler deploy --dry-run` in `bot/` succeeds with the gazetteer marker inlined.
|
**Acceptance:** `npm run search:test` exits 0 (including `PASS G1`…`PASS G11` and `PASS L1`…`PASS L3`); `npm run search:typecheck` exits 0; the esbuild bundle of the search module contains the `suria-klcc` marker; `npx wrangler deploy --dry-run` in `bot/` exits 0 (the Worker bundle builds — its bundle-marker grep is plan 04-03 task 2's criterion, after `bot.ts` imports the source).
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>cd /home/C-Fu/araharah && npm run search:test 2>&1 | tail -40 ; npm run search:typecheck 2>&1 | tail -5 ; cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun 2>&1 | tail -5 ; grep -c 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/index.js 2>/dev/null || grep -rc 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ 2>/dev/null | head -1</automated>
|
<automated>cd /home/C-Fu/araharah && npm run search:test 2>&1 | tail -40 ; npm run search:typecheck 2>&1 | tail -5 ; node_modules/.bin/esbuild search/src/index.ts --bundle --outfile=/tmp/opencode/araharah-search-bundle/index.js 2>&1 | tail -3 ; grep -c 'suria-klcc' /tmp/opencode/araharah-search-bundle/index.js ; cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun 2>&1 | tail -3</automated>
|
||||||
</verify>
|
</verify>
|
||||||
<acceptance_criteria>
|
<acceptance_criteria>
|
||||||
- `npm run search:test` exits 0 and the output contains `PASS G1` through `PASS G11` and `PASS L1` through `PASS L3`
|
- `npm run search:test` exits 0 and the output contains `PASS G1` through `PASS G11` and `PASS L1` through `PASS L3`
|
||||||
- `npm run search:typecheck` exits 0
|
- `npm run search:typecheck` exits 0
|
||||||
- `cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun` exits 0 (the Worker bundle builds — the L3 half of the three-environment proof)
|
- `cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun` exits 0 (the Worker bundle builds — a build regression guard; the bundle-marker grep is plan 04-03 task 2's criterion, where `bot.ts` actually imports the source)
|
||||||
- `grep -rl 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ | wc -l` returns >= 1 (the gazetteer JSON is INLINED into the Worker bundle — a runtime fetch would be a hard failure in a Worker)
|
- `node_modules/.bin/esbuild search/src/index.ts --bundle --outfile=/tmp/opencode/araharah-search-bundle/index.js` exits 0 AND `grep -c 'suria-klcc' /tmp/opencode/araharah-search-bundle/index.js` returns >= 1 (esbuild — the bundler family wrangler uses — INLINES the static JSON import at build time; a runtime fetch would be a hard failure in a Worker. This is the wave-1-provable inline proof, since no bot source imports `@araharah/search` until plan 04-03 task 2)
|
||||||
- `node -e "const g=require('./search/src/data/kl-gazetteer.json'); if(g.entries.length<30) throw new Error('seed too small: '+g.entries.length); if(!g._license||!g._license.includes('ODbL')) throw new Error('no ODbL header'); if(!g._attribution||!g._attribution.includes('OpenStreetMap')) throw new Error('no OSM attribution'); console.log('gazetteer OK '+g.entries.length+' entries')"` exits 0
|
- `node -e "const g=require('./search/src/data/kl-gazetteer.json'); if(g.entries.length<30) throw new Error('seed too small: '+g.entries.length); if(!g._license||!g._license.includes('ODbL')) throw new Error('no ODbL header'); if(!g._attribution||!g._attribution.includes('OpenStreetMap')) throw new Error('no OSM attribution'); console.log('gazetteer OK '+g.entries.length+' entries')"` exits 0
|
||||||
- `node -e "const g=require('./search/src/data/kl-gazetteer.json'); const ids=g.entries.map(e=>e.id); if(new Set(ids).size!==ids.length) throw new Error('duplicate ids'); for(const e of g.entries){ if(!/^[a-z0-9-]{1,64}$/.test(e.id)) throw new Error('bad id '+e.id); if(!e.aliases||e.aliases.length===0) throw new Error('no aliases '+e.id); if(!Number.isFinite(e.lat)||!Number.isFinite(e.lng)) throw new Error('bad coords '+e.id); if(e.lat<2.9||e.lat>3.4||e.lng<101.4||e.lng>102.0) throw new Error('out of KL bbox '+e.id);} console.log('entries OK')"` exits 0
|
- `node -e "const g=require('./search/src/data/kl-gazetteer.json'); const ids=g.entries.map(e=>e.id); if(new Set(ids).size!==ids.length) throw new Error('duplicate ids'); for(const e of g.entries){ if(!/^[a-z0-9-]{1,64}$/.test(e.id)) throw new Error('bad id '+e.id); if(!e.aliases||e.aliases.length===0) throw new Error('no aliases '+e.id); if(!Number.isFinite(e.lat)||!Number.isFinite(e.lng)) throw new Error('bad coords '+e.id); if(e.lat<2.9||e.lat>3.4||e.lng<101.4||e.lng>102.0) throw new Error('out of KL bbox '+e.id);} console.log('entries OK')"` exits 0
|
||||||
- `test -f LICENSE && grep -c 'GNU GENERAL PUBLIC LICENSE' LICENSE | head -1` returns >= 1 AND `grep -q 'Version 3, 29 June 2007' LICENSE` exits 0 (the VERBATIM GPL-3.0 text — D-04)
|
- `test -f LICENSE && grep -c 'GNU GENERAL PUBLIC LICENSE' LICENSE | head -1` returns >= 1 AND `grep -q 'Version 3, 29 June 2007' LICENSE` exits 0 (the VERBATIM GPL-3.0 text — D-04)
|
||||||
@@ -803,7 +804,7 @@ search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src
|
|||||||
- `node -e "import('./search/src/index.ts').then(m=>{const hits=m.search('KLCC',5); if(!hits.length) throw new Error('KLCC found nothing'); if(hits[0].entry.id!=='suria-klcc') throw new Error('KLCC => '+hits[0].entry.id); if(m.search('Kuching',3).length!==0) throw new Error('negative leaked'); if(m.search('Penang',3).length!==0) throw new Error('negative leaked'); console.log('search OK: '+hits[0].entry.id)})"` exits 0 (SRCH-01 lands; the D-13 negatives are refused honestly)
|
- `node -e "import('./search/src/index.ts').then(m=>{const hits=m.search('KLCC',5); if(!hits.length) throw new Error('KLCC found nothing'); if(hits[0].entry.id!=='suria-klcc') throw new Error('KLCC => '+hits[0].entry.id); if(m.search('Kuching',3).length!==0) throw new Error('negative leaked'); if(m.search('Penang',3).length!==0) throw new Error('negative leaked'); console.log('search OK: '+hits[0].entry.id)})"` exits 0 (SRCH-01 lands; the D-13 negatives are refused honestly)
|
||||||
- `node -e "import('./search/src/index.ts').then(m=>{const h=m.search('Jln Ampang',5); if(!h.some(x=>x.entry.id==='suria-klcc')) throw new Error('Jln Ampang missed KLCC'); const t=m.search('KCLC',5); if(!t.some(x=>x.entry.id==='suria-klcc')) throw new Error('typo missed'); console.log('SRCH-02 OK')})"` exits 0
|
- `node -e "import('./search/src/index.ts').then(m=>{const h=m.search('Jln Ampang',5); if(!h.some(x=>x.entry.id==='suria-klcc')) throw new Error('Jln Ampang missed KLCC'); const t=m.search('KCLC',5); if(!t.some(x=>x.entry.id==='suria-klcc')) throw new Error('typo missed'); console.log('SRCH-02 OK')})"` exits 0
|
||||||
</acceptance_criteria>
|
</acceptance_criteria>
|
||||||
<done>The curated KL gazetteer ships as committed, ODbL-attributed JSON with locals' aliases; the loader parses it lazily and memoizes a normalized index that survives all three bundling environments with the JSON INLINED (proven by the Worker dry-run bundle containing the id marker); the repo carries the verbatim GPL-3.0 LICENSE and the ODbL data notice; and the README no longer says "all rights reserved".</done>
|
<done>The curated KL gazetteer ships as committed, ODbL-attributed JSON with locals' aliases; the loader parses it lazily and memoizes a normalized index that survives all three bundling environments with the JSON INLINED (proven at wave 1 by the esbuild bundle of the search module containing the id marker; the wrangler dry-run is asserted as an exit-0 build guard, with its bundle-marker grep landing in plan 04-03 task 2 once `bot.ts` imports the source); the repo carries the verbatim GPL-3.0 LICENSE and the ODbL data notice; and the README no longer says "all rights reserved".</done>
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
@@ -826,7 +827,7 @@ search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src
|
|||||||
| T-04-02 | Information Disclosure | Query text reaching a storage surface | mitigate | The module imports no storage API and calls none (G10 bans `localStorage`/`sessionStorage`/`indexedDB`/`document.cookie` in `search/src/*.ts`). The gazetteer is a build-time constant, not user data. |
|
| T-04-02 | Information Disclosure | Query text reaching a storage surface | mitigate | The module imports no storage API and calls none (G10 bans `localStorage`/`sessionStorage`/`indexedDB`/`document.cookie` in `search/src/*.ts`). The gazetteer is a build-time constant, not user data. |
|
||||||
| T-04-03 | Denial of Service | Hostile query text — long string, pathological repetition — forcing an unbounded scan or allocation | mitigate | The query is bounded BEFORE this module is reached (plan 04-03 caps the raw command argument at 128 chars and rejects over-cap input). Inside the module the normalized query is tokenized once and compared against a finite, pre-built alias index; the fuzzy tier runs only on survivors of the token/prefix filters and only for tokens >= 3 characters, so the per-query work is bounded by the index size, not by the query length. The scorer refuses to score any side shorter than 3 characters. |
|
| T-04-03 | Denial of Service | Hostile query text — long string, pathological repetition — forcing an unbounded scan or allocation | mitigate | The query is bounded BEFORE this module is reached (plan 04-03 caps the raw command argument at 128 chars and rejects over-cap input). Inside the module the normalized query is tokenized once and compared against a finite, pre-built alias index; the fuzzy tier runs only on survivors of the token/prefix filters and only for tokens >= 3 characters, so the per-query work is bounded by the index size, not by the query length. The scorer refuses to score any side shorter than 3 characters. |
|
||||||
| T-04-04 | Denial of Service | Index construction at module scope blowing the Workers 1 s startup budget (RESEARCH Pitfall 8) | mitigate | LAW 1 in the loader: the index is built lazily inside `loadIndex()` and memoized in module scope — never at module top level (G11 asserts the top level contains no `new Map(` and no JSON traversal). The same pattern as `bot/test/initdata.test.mjs`'s lazy validator load. |
|
| T-04-04 | Denial of Service | Index construction at module scope blowing the Workers 1 s startup budget (RESEARCH Pitfall 8) | mitigate | LAW 1 in the loader: the index is built lazily inside `loadIndex()` and memoized in module scope — never at module top level (G11 asserts the top level contains no `new Map(` and no JSON traversal). The same pattern as `bot/test/initdata.test.mjs`'s lazy validator load. |
|
||||||
| T-04-05 | Denial of Service | Naive full-scan Damerau over ~2500 alias pairs costing ~8 ms/query against a 10 ms Workers Free CPU limit (RESEARCH §Verification A1 — REPRODUCED at 8.243 ms) | mitigate | The scorer is TIERED: the EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET tiers are string comparisons against a pre-built hash index (O(1) per candidate), and the Damerau FUZZY tier runs only on candidates that survive the cheap tiers AND have both sides >= 3 characters. The index maps each normalized alias directly to its entry, so an exact alias hit never touches the fuzzy tier at all. Plan 04-04's timing assertion measures the whole query path. |
|
| T-04-05 | Denial of Service | Naive full-scan Damerau over ~2500 alias pairs costing ~8 ms/query against a 10 ms Workers Free CPU limit (RESEARCH §Verification A1 — REPRODUCED at 8.243 ms) | mitigate | The scorer is TIERED and the per-query cost is bounded by CANDIDATE COUNT, not query length: the EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET tiers are comparisons against a pre-built hash index (O(1) per candidate), so a query with any cheap-tier hit never reaches the fuzzy scan. A pure-typo query (zero cheap-tier survivors) DOES pay a full fuzzy scan — that is the honest worst case, REPRODUCED at ~8.2 ms for ~2000 alias pairs — and every fuzzy comparison stays bounded: both sides >= 3 chars, OSA is O(len^2) on short strings, and the scan runs over a FIXED committed dataset. SCALING DECISION (RESEARCH directive #1, stated explicitly rather than hand-waved): the ~30-entry seed and D-07's 500-entry ceiling keep the full-scan worst case inside the 10 ms limit, so the trigram/blocking-key index is documented as the NEXT step, not built now; `docs/SEARCH-QUALITY.md`'s "Known limitations" (plan 04-02) records the trigger (top-up scale approaching the CPU limit) and the fix (a first-letter + length-band blocking key in front of the OSA tier), so the escalation is a recorded decision, not a surprise Error-1102. |
|
||||||
| T-04-06 | Tampering | A malformed committed entry (duplicate id, non-finite coordinate, empty alias list) reaching a venue card as a fabricated or broken pin | mitigate | G1/G2/G5 assert the dataset's invariants as a gate: unique slug-safe ids, non-empty aliases, finite coordinates inside the KL bbox. A non-finite coordinate would surface as `NaN` on the venue card — the gate makes that impossible to ship. |
|
| T-04-06 | Tampering | A malformed committed entry (duplicate id, non-finite coordinate, empty alias list) reaching a venue card as a fabricated or broken pin | mitigate | G1/G2/G5 assert the dataset's invariants as a gate: unique slug-safe ids, non-empty aliases, finite coordinates inside the KL bbox. A non-finite coordinate would surface as `NaN` on the venue card — the gate makes that impossible to ship. |
|
||||||
| T-04-07 | Tampering | An out-of-area query answered with the nearest KL pin (a fabricated location) — an honesty failure (RESEARCH §Security V8, D-13/D-14) | mitigate | The tiered admission rule: EXACT/ALIAS_EXACT/PREFIX/TOKEN_SET always admitted; a FUZZY hit only at similarity >= `FUZZY_ADMIT_SIMILARITY` (0.72). "Kuching" and "Penang" must yield `[]` — asserted in this plan's acceptance criteria and re-asserted by the corpus runner in plan 04-02. An empty shortlist is a legitimate, load-bearing return value of the `SearchSource` contract, and the bot renders the honest refusal copy (plan 04-03). |
|
| T-04-07 | Tampering | An out-of-area query answered with the nearest KL pin (a fabricated location) — an honesty failure (RESEARCH §Security V8, D-13/D-14) | mitigate | The tiered admission rule: EXACT/ALIAS_EXACT/PREFIX/TOKEN_SET always admitted; a FUZZY hit only at similarity >= `FUZZY_ADMIT_SIMILARITY` (0.72). "Kuching" and "Penang" must yield `[]` — asserted in this plan's acceptance criteria and re-asserted by the corpus runner in plan 04-02. An empty shortlist is a legitimate, load-bearing return value of the `SearchSource` contract, and the bot renders the honest refusal copy (plan 04-03). |
|
||||||
| T-04-08 | Tampering | The rule table growing a code-shaped entry point — a rule that changes behavior via code rather than a table edit (D-09's "extends by table edit, not code change" law) | mitigate | T9 asserts the rules file contains no algorithm and the row count is >= 47; the table is a `ReadonlyArray<readonly [string, string]>` of pairs, inverted once by a single generic builder. Any new abbreviation is a row, not a branch. |
|
| T-04-08 | Tampering | The rule table growing a code-shaped entry point — a rule that changes behavior via code rather than a table edit (D-09's "extends by table edit, not code change" law) | mitigate | T9 asserts the rules file contains no algorithm and the row count is >= 47; the table is a `ReadonlyArray<readonly [string, string]>` of pairs, inverted once by a single generic builder. Any new abbreviation is a row, not a branch. |
|
||||||
@@ -838,7 +839,7 @@ search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src
|
|||||||
<verification>
|
<verification>
|
||||||
- `npm run search:test` green — 9 normalizer vectors + 9 distance vectors + 7 score vectors + 11 gazetteer/license vectors + 3 loader-environment vectors, all named
|
- `npm run search:test` green — 9 normalizer vectors + 9 distance vectors + 7 score vectors + 11 gazetteer/license vectors + 3 loader-environment vectors, all named
|
||||||
- `npm run search:typecheck` green (`tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`)
|
- `npm run search:typecheck` green (`tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`)
|
||||||
- `cd bot && npx wrangler deploy --dry-run` exits 0 AND the emitted bundle contains `suria-klcc` — the Worker bundle INLINES the dataset (never a runtime fetch)
|
- the esbuild bundle of the search module (`node_modules/.bin/esbuild search/src/index.ts --bundle`) contains `suria-klcc` — esbuild (the bundler family wrangler uses) INLINES the dataset at build time, never a runtime fetch; `cd bot && npx wrangler deploy --dry-run` exits 0 as a build regression guard (its bundle-marker grep is asserted in plan 04-03 task 2, after `bot.ts` imports `createGazetteerSource()`)
|
||||||
- `npm test -w bot` and `npm test -w web` still green (this plan adds no bot/web source, so the existing suites must be unaffected)
|
- `npm test -w bot` and `npm test -w web` still green (this plan adds no bot/web source, so the existing suites must be unaffected)
|
||||||
- `node -e "import('./search/src/index.ts')..."` proves `search('KLCC')` → `suria-klcc` at rank 1 and `search('Kuching')` → `[]`
|
- `node -e "import('./search/src/index.ts')..."` proves `search('KLCC')` → `suria-klcc` at rank 1 and `search('Kuching')` → `[]`
|
||||||
- `git status` shows no build output staged (`search/` is buildless — nothing under `search/dist/` may exist)
|
- `git status` shows no build output staged (`search/` is buildless — nothing under `search/dist/` may exist)
|
||||||
@@ -852,7 +853,7 @@ search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src
|
|||||||
- D-03: the `SearchSource` seam exists with an async `search()` and a synchronous `byId()`, so backend #2 (WASM geocoder) and #3 (Photon) are format changes rather than rewrites
|
- D-03: the `SearchSource` seam exists with an async `search()` and a synchronous `byId()`, so backend #2 (WASM geocoder) and #3 (Photon) are format changes rather than rewrites
|
||||||
- D-15: the shared module contains no logging and no storage surface at all
|
- D-15: the shared module contains no logging and no storage surface at all
|
||||||
- D-04: the repo carries the verbatim GPL-3.0 LICENSE, `LICENSES.md` records the ODbL-1.0 + OpenStreetMap notice for the gazetteer, and the README no longer claims "all rights reserved"
|
- D-04: the repo carries the verbatim GPL-3.0 LICENSE, `LICENSES.md` records the ODbL-1.0 + OpenStreetMap notice for the gazetteer, and the README no longer claims "all rights reserved"
|
||||||
- The three-environment loader risk is closed mechanically: bare Node, the Vite bundle, and the Worker dry-run bundle all resolve the dataset with the JSON INLINED at build time
|
- The three-environment loader risk is closed mechanically: bare Node resolves the dataset, the esbuild/Worker bundler family provably INLINES it (the search module's own esbuild bundle carries the id marker), and the Vite assertion follows L2's explicit skip-guard; the Worker dry-run exits 0 as a build guard, and its bundle-marker grep completes in plan 04-03 task 2 once `bot.ts` imports the source
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ must_haves:
|
|||||||
truths:
|
truths:
|
||||||
- "The golden corpus contains at least 60 hand-written cases across 6 categories: golden landmarks, abbreviations, glued words, typos, aliases, and deliberate out-of-KL negatives"
|
- "The golden corpus contains at least 60 hand-written cases across 6 categories: golden landmarks, abbreviations, glued words, typos, aliases, and deliberate out-of-KL negatives"
|
||||||
- "At least 15 corpus queries are NOT string-equal to any alias in the gazetteer — the corpus measures something real, it is not tautological"
|
- "At least 15 corpus queries are NOT string-equal to any alias in the gazetteer — the corpus measures something real, it is not tautological"
|
||||||
- "The corpus runner exits 0 only when top-3 >= 90% on positives AND every negative returned an empty shortlist"
|
- "The corpus runner exits 0 only when top-3 >= 90% on positives AND every negative returned an empty shortlist with its best fuzzy similarity below the 0.72 admission line (the margin is asserted, not hoped for)"
|
||||||
- "The corpus runner reports per-category rates so a regression is diagnosable, not a diffuse number"
|
- "The corpus runner reports per-category rates so a regression is diagnosable, not a diffuse number"
|
||||||
- "The Overpass top-up script produces a valid-shaped proposal file from a COMMITTED FIXTURE, and treats 502/429/timeout as a normal retryable outcome that EXITS 0"
|
- "The Overpass top-up script produces a valid-shaped proposal file from a COMMITTED FIXTURE, and treats 502/429/timeout as a normal retryable outcome that EXITS 0"
|
||||||
- "The Overpass script sends an identifying User-Agent (a request without one gets HTTP 406) and makes exactly ONE request per run"
|
- "The Overpass script sends an identifying User-Agent (a request without one gets HTTP 406) and makes exactly ONE request per run"
|
||||||
@@ -162,11 +162,11 @@ Rules:
|
|||||||
- `landmark` — >= 12 (the golden landmarks and other well-known KL places: "KLCC", "Sunway Pyramid", "DUKE", "Pavilion", "Mid Valley", "Berjaya Times Square", "IOI City Mall", "Merdeka 118", "The Intermark", "KL Sentral", "Nu Sentral", "PWTC", "IJN", "Taman Bangsar")
|
- `landmark` — >= 12 (the golden landmarks and other well-known KL places: "KLCC", "Sunway Pyramid", "DUKE", "Pavilion", "Mid Valley", "Berjaya Times Square", "IOI City Mall", "Merdeka 118", "The Intermark", "KL Sentral", "Nu Sentral", "PWTC", "IJN", "Taman Bangsar")
|
||||||
- `abbreviation` — >= 15 ("Jln Ampang", "Jalan Ampang", "Tmn Bangsar", "Taman Bangsar", "Kg Ampang", "Kampung Ampang", "Bdr Baru", "Bandar Baru", "Lbh Ampang", "Lebuh Ampang", "Psrn KL", "Persiaran KL", "Sg Wang", "Sungai Wang", "Blk A")
|
- `abbreviation` — >= 15 ("Jln Ampang", "Jalan Ampang", "Tmn Bangsar", "Taman Bangsar", "Kg Ampang", "Kampung Ampang", "Bdr Baru", "Bandar Baru", "Lbh Ampang", "Lebuh Ampang", "Psrn KL", "Persiaran KL", "Sg Wang", "Sungai Wang", "Blk A")
|
||||||
- `glued` — >= 6 ("tamanbangsar", "jalankanpar", "kampungbarra", "bandarbaru", "sungaiwang", "bukitbintang")
|
- `glued` — >= 6 ("tamanbangsar", "jalankanpar", "kampungbarra", "bandarbaru", "sungaiwang", "bukitbintang")
|
||||||
- `typo` — >= 12 ("KCLC", "KLCC" transposed, "Sunwya Pyarmid", "Pavillion", "Mid Vally", "Berjaya Time Square", "IJN" → "IJNP", "Sentral" → "Sentrl", "Merdeka 118" → "Merdeka 118", "Tmn Bangsr", "Pavilon", "Intermrk")
|
- `typo` — >= 12 ("KCLC", "KLCC" transposed, "Sunwya Pyarmid", "Pavillion", "Mid Vally", "Berjaya Time Square", "IJN" → "IJNP", "Sentral" → "Sentrl", "Tmn Bangsr", "Pavilon", "Intermrk", "Sunway Velocity" → "Sunway Vellocity") — every typo case is a NON-string-equal misspelling; the bucket resolves through plan 04-01's FUZZY residual tier (each illustrative's similarity is >= `FUZZY_ADMIT_SIMILARITY` 0.72), never through alias edits
|
||||||
- `alias` — >= 12 ("IJN", "PWTC", "Stesen KL Sentral", "KL Central", "BTS", "MV", "IOI", "Sunway Velocity", "Velocity", "Cheras South", "Pudu", "Intermark")
|
- `alias` — >= 12 ("IJN", "PWTC", "Stesen KL Sentral", "KL Central", "BTS", "MV", "IOI", "Sunway Velocity", "Velocity", "Cheras South", "Pudu", "Intermark")
|
||||||
- `negative` — >= 6 ("Kuching", "Penang", "Johor Bahru", "Kota Kinabalu", "Singapore", "London") — these MUST be refused with an empty shortlist, never a fabricated pin
|
- `negative` — >= 6 ("Kuching", "Penang", "Johor Bahru", "Kota Kinabalu", "Singapore", "London") — these MUST be refused with an empty shortlist, never a fabricated pin; the bucket MUST ALSO include at least one NEAR-MISS negative — a genuinely out-of-KL query whose best fuzzy similarity to some gazetteer alias lands in `[0.55, FUZZY_ADMIT_SIMILARITY)` (illustrative: "Kampung Cina" vs the `kampung-ampang` alias "Kampung Ampang" — the "kampung" token matches exactly, "cina" drags the token-level mean to ~0.6) — a query a LOWERED admission threshold would wrongly admit. CO8 pins this margin mechanically
|
||||||
|
|
||||||
**TAUTOLOGY LAW (D-13 / RESEARCH Pitfall 7):** the corpus must contain queries that are NOT string-equal to any alias in the gazetteer. The typo and glued buckets are exactly those queries — and at least 15 cases overall must not equal any alias. The runner ASSERTS this (`cases.filter(c => c.kind !== 'negative' && !aliases.has(normalize(c.q))).length >= 15`), so a future regeneration that makes the corpus tautological fails the suite.
|
**TAUTOLOGY LAW (D-13 / RESEARCH Pitfall 7):** the corpus must contain queries that are NOT string-equal to any alias in the gazetteer. The typo and glued buckets are exactly those queries — and at least 15 cases overall must not equal any alias. The runner ASSERTS this (`cases.filter(c => c.kind !== 'negative' && !aliases.has(normalize(c.q))).length >= 15`), so a future regeneration that makes the corpus tautological fails the suite. JOINT ARITHMETIC with the D-14 escalation ladder: the typo bucket resolves through plan 04-01's FUZZY residual tier (`FUZZY_ADMIT_SIMILARITY` 0.72 × the 0.60 ceiling — "KCLC" sim 0.75 → score 0.45), so lever 1 is NEVER needed for a typo. Adding a typo string as an alias would both defeat the case's purpose and erode the tautology floor below CO3's 15 — reserve lever 1 for genuine alias gaps (a missing landmark, a missing local shorthand). With the typo bucket served by the residual net, the ~12 typo cases plus the alias-bucket queries that are not literal alias strings keep the non-tautological count comfortably above 15.
|
||||||
|
|
||||||
**Every `expectId` MUST match `/^[A-Za-z0-9_-]{1,64}$/`** — the start_param-safe charset (RESEARCH: "The corpus runner should assert every corpus `expectId` matches `/^[A-Za-z0-9_-]{1,64}$/`").
|
**Every `expectId` MUST match `/^[A-Za-z0-9_-]{1,64}$/`** — the start_param-safe charset (RESEARCH: "The corpus runner should assert every corpus `expectId` matches `/^[A-Za-z0-9_-]{1,64}$/`").
|
||||||
|
|
||||||
@@ -183,9 +183,9 @@ Behaviour:
|
|||||||
4. For each positive: `search(c.q, 3)` → record whether `hits[0].entry.id === c.expectId` (top-1) and whether `hits.map(h => h.entry.id).includes(c.expectId)` (top-3). Record the miss as `"<query> -> [<ids or —>] (expected <id>)"`.
|
4. For each positive: `search(c.q, 3)` → record whether `hits[0].entry.id === c.expectId` (top-1) and whether `hits.map(h => h.entry.id).includes(c.expectId)` (top-3). Record the miss as `"<query> -> [<ids or —>] (expected <id>)"`.
|
||||||
5. For each negative: `search(c.q, 3)` → if `hits.length > 0`, record the leak as `"<query> -> <first id>"`.
|
5. For each negative: `search(c.q, 3)` → if `hits.length > 0`, record the leak as `"<query> -> <first id>"`.
|
||||||
6. Print a report to stdout:
|
6. Print a report to stdout:
|
||||||
- the overall line: `top-1 <n>/<N> top-3 <n>/<N> (<pct>%)`
|
- the overall line: `top-1 <n>/<N> top-3 <n>/<N> (<pct>%) <avg> ms/query` — the per-query average gives the 10 ms Workers CPU-budget claim a measuring hook at every run
|
||||||
- a PER-CATEGORY breakdown: one line per `kind` with that category's top-3 rate
|
- a PER-CATEGORY breakdown: one line per `kind` with that category's top-3 rate
|
||||||
- `negatives refused <n>/<N>`
|
- `negatives refused <n>/<N>` — followed by the refusal-margin line: `refusal margin <best negative sim> < 0.72` (the highest fuzzy similarity any negative reached against any alias, and its gap to `FUZZY_ADMIT_SIMILARITY` — the margin that makes the refusals honest is VISIBLE, not accidental)
|
||||||
- the `MISSED:` block (one line per miss) and the `LEAKED (must refuse):` block (one line per leak)
|
- the `MISSED:` block (one line per miss) and the `LEAKED (must refuse):` block (one line per leak)
|
||||||
- the threshold line: `threshold top3 >= 90.0% — PASS|FAIL`
|
- the threshold line: `threshold top3 >= 90.0% — PASS|FAIL`
|
||||||
7. Exit code: `0` only when `top3Rate >= threshold.top3_hit_rate` AND `leaked.length === 0`; `1` otherwise.
|
7. Exit code: `0` only when `top3Rate >= threshold.top3_hit_rate` AND `leaked.length === 0`; `1` otherwise.
|
||||||
@@ -228,7 +228,7 @@ and exits **0** (never a stack trace, never a non-zero code). Nothing in the rep
|
|||||||
|
|
||||||
**LAW 5 — it NEVER writes to `kl-gazetteer.json`.** Output is a PRINTED candidate list (or a proposal file at an explicit `--out` path that the maintainer diffs and merges by hand). The script writes nothing to the curated dataset under any flag. The word "proposal" must appear in the output header.
|
**LAW 5 — it NEVER writes to `kl-gazetteer.json`.** Output is a PRINTED candidate list (or a proposal file at an explicit `--out` path that the maintainer diffs and merges by hand). The script writes nothing to the curated dataset under any flag. The word "proposal" must appear in the output header.
|
||||||
|
|
||||||
**LAW 6 — the source and the endpoint are overridable.** `--source=overpass|nominatim|fixture` selects the candidate source (default `overpass`); `--endpoint=<url>` and the `OVERPASS_ENDPOINT` env var override the Overpass endpoint (default `https://overpass-api.de/api/interpreter`); `--fixture=<path>` names the fixture file for `--source=fixture`. `--source=nominatim --q=<term>` queries the reachable Nominatim search endpoint bounded to the KL viewbox instead (Nominatim was live-verified reachable 2026-10-23 while the Overpass mirrors were not). This is the resolved offline posture: the committed fixture is the deterministic test path, Nominatim is the alternative live path, and neither is ever a runtime dependency.
|
**LAW 6 — the source and the endpoint are overridable.** `--source=overpass|nominatim|fixture` selects the candidate source (default `overpass`); `--endpoint=<url>` and the `OVERPASS_ENDPOINT` env var override the Overpass endpoint (default `https://overpass-api.de/api/interpreter`); `--fixture=<path>` names the fixture file for `--source=fixture`. `--source=nominatim --q=<term>` queries the reachable Nominatim search endpoint bounded to the KL viewbox instead (Nominatim was live-verified reachable 2026-10-23 while the Overpass mirrors were not). This is the resolved offline posture: the committed fixture is the deterministic test path, Nominatim is the alternative live path, and neither is ever a runtime dependency. **Both live paths (Overpass and Nominatim) MUST be served by ONE shared `fetch` helper**, so LAW 2's exactly-one-request invariant is structural — exactly ONE `fetch(` call site in the source, whichever source is selected (this is what TP7 asserts).
|
||||||
|
|
||||||
**The query shape** — a NARROW, single-tag-family query bounded to the KL bbox (Overpass bbox order is `s,w,n,e` = south, west, north, east):
|
**The query shape** — a NARROW, single-tag-family query bounded to the KL bbox (Overpass bbox order is `s,w,n,e` = south, west, north, east):
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ The bbox MUST be present (RESEARCH §Anti-Patterns: "An Overpass query with no b
|
|||||||
|
|
||||||
```
|
```
|
||||||
PROPOSAL — 7 candidate(s) from tourism=attraction in bbox 3.05,101.55,3.25,101.80
|
PROPOSAL — 7 candidate(s) from tourism=attraction in bbox 3.05,101.55,3.25,101.80
|
||||||
Review and hand-merge into search/src/data/kl-gazetteer.json — this script NEVER writes it.
|
Review and hand-merge into the curated dataset file — this script NEVER writes it.
|
||||||
name=Merdeka 118 osm=way/12345678 lat=3.1418 lon=101.6943 category=landmark
|
name=Merdeka 118 osm=way/12345678 lat=3.1418 lon=101.6943 category=landmark
|
||||||
name=National Monument osm=node/87654321 lat=3.1489 lon=101.6936 category=landmark
|
name=National Monument osm=node/87654321 lat=3.1489 lon=101.6936 category=landmark
|
||||||
```
|
```
|
||||||
@@ -260,7 +260,7 @@ Review and hand-merge into search/src/data/kl-gazetteer.json — this script NEV
|
|||||||
- `TP4 the script exits 0 on a busy instance` — run the script with `--fixture=` pointing at a fixture whose content is NOT an Overpass response (e.g. a JSON object with `"error"`), assert the process exit code is 0 and stdout contains "Overpass instance busy".
|
- `TP4 the script exits 0 on a busy instance` — run the script with `--fixture=` pointing at a fixture whose content is NOT an Overpass response (e.g. a JSON object with `"error"`), assert the process exit code is 0 and stdout contains "Overpass instance busy".
|
||||||
- `TP5 the script produces a valid-shaped proposal from the committed fixture` — run the script with `--fixture=search/test/fixtures/overpass-response.json`, assert exit 0 and that stdout contains "PROPOSAL", at least one `name=` line, and the literal text "NEVER writes".
|
- `TP5 the script produces a valid-shaped proposal from the committed fixture` — run the script with `--fixture=search/test/fixtures/overpass-response.json`, assert exit 0 and that stdout contains "PROPOSAL", at least one `name=` line, and the literal text "NEVER writes".
|
||||||
- `TP6 the script never writes the curated file` — assert the script source contains no `writeFileSync` targeting `kl-gazetteer.json` (the only permitted write is an explicit `--out` proposal path, and that path must not be the curated file — assert the source does not contain the literal `kl-gazetteer.json` at all).
|
- `TP6 the script never writes the curated file` — assert the script source contains no `writeFileSync` targeting `kl-gazetteer.json` (the only permitted write is an explicit `--out` proposal path, and that path must not be the curated file — assert the source does not contain the literal `kl-gazetteer.json` at all).
|
||||||
- `TP7 exactly one request per run` — assert the script source contains exactly ONE `fetch(` call.
|
- `TP7 exactly one request per run` — the Overpass and Nominatim live paths (LAW 6) share ONE `fetch` helper, so the source contains exactly ONE `fetch(` call site regardless of the selected source; assert that count is exactly 1.
|
||||||
|
|
||||||
**`search/scripts/json-to-sqlite.mjs`** — the D-05 backend-#2 format converter. It reads `search/src/data/kl-gazetteer.json` and emits a SQLite file with the SAME ids, so backend #2's dataset format is reachable today (D-05: "The JSON↔SQLite converter script keeps its dataset format reachable from day one"). It is a STUB in the sense that the SQLite WRITER is a minimal, dependency-free implementation (Node 24 has `node:sqlite` behind a flag — check whether `node:sqlite` is available in Node 24.18.0; if it is, use it; if not, emit a deterministic, documented, plain-text `.sql` DUMP file plus a JSON sidecar and say so in the header — do NOT add a dependency).
|
**`search/scripts/json-to-sqlite.mjs`** — the D-05 backend-#2 format converter. It reads `search/src/data/kl-gazetteer.json` and emits a SQLite file with the SAME ids, so backend #2's dataset format is reachable today (D-05: "The JSON↔SQLite converter script keeps its dataset format reachable from day one"). It is a STUB in the sense that the SQLite WRITER is a minimal, dependency-free implementation (Node 24 has `node:sqlite` behind a flag — check whether `node:sqlite` is available in Node 24.18.0; if it is, use it; if not, emit a deterministic, documented, plain-text `.sql` DUMP file plus a JSON sidecar and say so in the header — do NOT add a dependency).
|
||||||
|
|
||||||
@@ -307,6 +307,13 @@ Review and hand-merge into search/src/data/kl-gazetteer.json — this script NEV
|
|||||||
- The corpus is hand-written and small; it measures the documented local shorthand, not every
|
- The corpus is hand-written and small; it measures the documented local shorthand, not every
|
||||||
phrasing a KL driver might use.
|
phrasing a KL driver might use.
|
||||||
- Out-of-KL queries are refused, not routed — there is no backend #2 or #3 in v1.
|
- Out-of-KL queries are refused, not routed — there is no backend #2 or #3 in v1.
|
||||||
|
- The fuzzy tier performs a full-candidate OSA scan when a query has no cheap-tier hit (the
|
||||||
|
pure-typo path) — measured at ~8.2 ms/query against ~2 000 alias pairs (RESEARCH §Verification
|
||||||
|
A1), inside the 10 ms Workers Free CPU limit at the seed scale. This is a RECORDED scaling
|
||||||
|
decision, not an oversight (RESEARCH directive #1): if a future top-up pushes the dataset toward
|
||||||
|
D-07's 500-entry ceiling and CPU approaches the limit, the documented next step is a cheap
|
||||||
|
blocking key (first-letter + length band) in front of the OSA tier — measured against this
|
||||||
|
corpus before it ships.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Acceptance:** `npm run search:corpus` exits 0 with top-3 >= 90% and zero negative leaks; `npm run search:test` green; `docs/SEARCH-QUALITY.md` records the measured numbers and the lever.
|
**Acceptance:** `npm run search:corpus` exits 0 with top-3 >= 90% and zero negative leaks; `npm run search:test` green; `docs/SEARCH-QUALITY.md` records the measured numbers and the lever.
|
||||||
@@ -352,10 +359,11 @@ and to the root `package.json`:
|
|||||||
- `CO5 every expectId matches the start_param-safe charset` — `/^[A-Za-z0-9_-]{1,64}$/`.
|
- `CO5 every expectId matches the start_param-safe charset` — `/^[A-Za-z0-9_-]{1,64}$/`.
|
||||||
- `CO6 the corpus meets its size and category floors` — >= 60 cases; landmark >= 12, abbreviation >= 15, glued >= 6, typo >= 12, alias >= 12, negative >= 6.
|
- `CO6 the corpus meets its size and category floors` — >= 60 cases; landmark >= 12, abbreviation >= 15, glued >= 6, typo >= 12, alias >= 12, negative >= 6.
|
||||||
- `CO7 every category's top-3 rate is reported` — the suite prints (not asserts) the per-category rates so the runner output and the suite output agree.
|
- `CO7 every category's top-3 rate is reported` — the suite prints (not asserts) the per-category rates so the runner output and the suite output agree.
|
||||||
|
- `CO8 the refusal margin is pinned` — for every negative, compute its best fuzzy similarity against every gazetteer alias using the shared `damerau` and the SAME two-level (whole-string + token) shape the scorer uses; assert ALL negatives sit strictly below `FUZZY_ADMIT_SIMILARITY` (0.72), and assert at least one near-miss negative sits in `[0.55, 0.72)` (the corpus contract's near-miss requirement) — so lowering the admission threshold would leak a real negative and fail BOTH CO2 and CO8. The floor is pinned by test, not by hope.
|
||||||
|
|
||||||
The suite imports the shared module and the corpus directly (in-process), so `npm run search:test` fails on a corpus regression.
|
The suite imports the shared module and the corpus directly (in-process), so `npm run search:test` fails on a corpus regression.
|
||||||
|
|
||||||
**Acceptance:** `npm run search:corpus` exits 0; `npm run search:test` green with `PASS CO1`…`PASS CO7`.
|
**Acceptance:** `npm run search:corpus` exits 0; `npm run search:test` green with `PASS CO1`…`PASS CO8`.
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>cd /home/C-Fu/araharah && npm run search:corpus 2>&1 | tail -30 ; npm run search:test 2>&1 | grep -E 'PASS CO|runner' ; node -e "const c=require('./search/test/fixtures/golden-corpus.json'); if(c.cases.length<60) throw new Error('corpus too small: '+c.cases.length); console.log('corpus OK '+c.cases.length+' cases')"</automated>
|
<automated>cd /home/C-Fu/araharah && npm run search:corpus 2>&1 | tail -30 ; npm run search:test 2>&1 | grep -E 'PASS CO|runner' ; node -e "const c=require('./search/test/fixtures/golden-corpus.json'); if(c.cases.length<60) throw new Error('corpus too small: '+c.cases.length); console.log('corpus OK '+c.cases.length+' cases')"</automated>
|
||||||
@@ -364,7 +372,7 @@ The suite imports the shared module and the corpus directly (in-process), so `np
|
|||||||
- `npm run search:corpus` exits 0
|
- `npm run search:corpus` exits 0
|
||||||
- `npm run search:corpus` output contains a `top-3` line with a percentage >= 90.0% and a `negatives refused` line showing all negatives refused
|
- `npm run search:corpus` output contains a `top-3` line with a percentage >= 90.0% and a `negatives refused` line showing all negatives refused
|
||||||
- `npm run search:corpus` output contains one `PASS`/`FAIL` threshold line naming the 90.0% gate
|
- `npm run search:corpus` output contains one `PASS`/`FAIL` threshold line naming the 90.0% gate
|
||||||
- `npm run search:test` exits 0 and the output contains `PASS CO1` through `PASS CO7`
|
- `npm run search:test` exits 0 and the output contains `PASS CO1` through `PASS CO8`
|
||||||
- `node -e "const c=require('./search/test/fixtures/golden-corpus.json'); const g=require('./search/src/data/kl-gazetteer.json'); const ids=new Set(g.entries.map(e=>e.id)); const kinds={}; let bad=0; for(const x of c.cases){ kinds[x.kind]=(kinds[x.kind]||0)+1; if(x.expectId!==null && !ids.has(x.expectId)) bad++; if(x.expectId!==null && !/^[A-Za-z0-9_-]{1,64}$/.test(x.expectId)) bad++; } if(c.cases.length<60) throw new Error('size'); if(bad) throw new Error('dangling ids'); console.log(JSON.stringify(kinds))"` exits 0 and prints all six `kind` keys with landmark>=12, abbreviation>=15, glued>=6, typo>=12, alias>=12, negative>=6
|
- `node -e "const c=require('./search/test/fixtures/golden-corpus.json'); const g=require('./search/src/data/kl-gazetteer.json'); const ids=new Set(g.entries.map(e=>e.id)); const kinds={}; let bad=0; for(const x of c.cases){ kinds[x.kind]=(kinds[x.kind]||0)+1; if(x.expectId!==null && !ids.has(x.expectId)) bad++; if(x.expectId!==null && !/^[A-Za-z0-9_-]{1,64}$/.test(x.expectId)) bad++; } if(c.cases.length<60) throw new Error('size'); if(bad) throw new Error('dangling ids'); console.log(JSON.stringify(kinds))"` exits 0 and prints all six `kind` keys with landmark>=12, abbreviation>=15, glued>=6, typo>=12, alias>=12, negative>=6
|
||||||
- `grep -q '"search:corpus": "node search/scripts/gazetteer-corpus.mjs"' package.json` exits 0 AND `grep -q '"corpus": "node scripts/gazetteer-corpus.mjs"' search/package.json` exits 0
|
- `grep -q '"search:corpus": "node search/scripts/gazetteer-corpus.mjs"' package.json` exits 0 AND `grep -q '"corpus": "node scripts/gazetteer-corpus.mjs"' search/package.json` exits 0
|
||||||
- `grep -q 'D-14 threshold not met' search/scripts/gazetteer-corpus.mjs` exits 0 AND `grep -q 'backend #2' search/scripts/gazetteer-corpus.mjs` exits 0 AND `grep -q 'Photon-on-VPS' search/scripts/gazetteer-corpus.mjs` exits 0 (the fixed escalation ladder is printed verbatim on failure)
|
- `grep -q 'D-14 threshold not met' search/scripts/gazetteer-corpus.mjs` exits 0 AND `grep -q 'backend #2' search/scripts/gazetteer-corpus.mjs` exits 0 AND `grep -q 'Photon-on-VPS' search/scripts/gazetteer-corpus.mjs` exits 0 (the fixed escalation ladder is printed verbatim on failure)
|
||||||
@@ -399,7 +407,7 @@ Header contract block (copy `set-webhook.mjs`'s shape — purpose, inputs, what
|
|||||||
* it succeeding. The Worker only ever reads the COMMITTED curated JSON.
|
* it succeeding. The Worker only ever reads the COMMITTED curated JSON.
|
||||||
*
|
*
|
||||||
* Output is a PROPOSAL — a printed candidate list (or a file at an explicit --out
|
* Output is a PROPOSAL — a printed candidate list (or a file at an explicit --out
|
||||||
* path) that a human diffs and hand-merges into search/src/data/kl-gazetteer.json.
|
* path) that a human diffs and hand-merges into the curated dataset file.
|
||||||
* THIS SCRIPT NEVER WRITES THE CURATED FILE.
|
* THIS SCRIPT NEVER WRITES THE CURATED FILE.
|
||||||
*
|
*
|
||||||
* Exit codes: 0 on success AND on a busy instance (Overpass capacity saturation
|
* Exit codes: 0 on success AND on a busy instance (Overpass capacity saturation
|
||||||
@@ -459,7 +467,7 @@ and `process.exit(0)`.
|
|||||||
- `cd search && node scripts/overpass-topup.mjs --fixture=<a non-Overpass fixture>` exits 0 and its stdout contains exactly the line `Overpass instance busy — retry later`
|
- `cd search && node scripts/overpass-topup.mjs --fixture=<a non-Overpass fixture>` exits 0 and its stdout contains exactly the line `Overpass instance busy — retry later`
|
||||||
- `grep -q 'User-Agent: araharah-gazetteer-topup/1.0' search/scripts/overpass-topup.mjs` exits 0 (HTTP 406 without it — live-verified)
|
- `grep -q 'User-Agent: araharah-gazetteer-topup/1.0' search/scripts/overpass-topup.mjs` exits 0 (HTTP 406 without it — live-verified)
|
||||||
- `grep -q '\-\-source' search/scripts/overpass-topup.mjs` exits 0 (the source flag exists: overpass | nominatim | fixture — the resolved-question posture)
|
- `grep -q '\-\-source' search/scripts/overpass-topup.mjs` exits 0 (the source flag exists: overpass | nominatim | fixture — the resolved-question posture)
|
||||||
- `grep -c 'await fetch(\|fetch(' search/scripts/overpass-topup.mjs | head -1` returns 1 (EXACTLY ONE request per run)
|
- `grep -c 'await fetch(\|fetch(' search/scripts/overpass-topup.mjs | head -1` returns 1 (EXACTLY ONE request per run — the Overpass and Nominatim paths share this single fetch helper, per LAW 6)
|
||||||
- `grep -c 'kl-gazetteer.json' search/scripts/overpass-topup.mjs | head -1` returns 0 (the script never names the curated file — it cannot write it)
|
- `grep -c 'kl-gazetteer.json' search/scripts/overpass-topup.mjs | head -1` returns 0 (the script never names the curated file — it cannot write it)
|
||||||
- `grep -rl 'overpass' bot/src/ web/src/ 2>/dev/null | wc -l` returns 0 (the script is never a runtime dependency — RESEARCH Pitfall 6)
|
- `grep -rl 'overpass' bot/src/ web/src/ 2>/dev/null | wc -l` returns 0 (the script is never a runtime dependency — RESEARCH Pitfall 6)
|
||||||
- `grep -rl 'sqlite\|json-to-sqlite' bot/src/ web/src/ 2>/dev/null | wc -l` returns 0
|
- `grep -rl 'sqlite\|json-to-sqlite' bot/src/ web/src/ 2>/dev/null | wc -l` returns 0
|
||||||
@@ -470,7 +478,7 @@ and `process.exit(0)`.
|
|||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>task 3: run the bake-off, close it, and record the decision</name>
|
<name>task 3: run the bake-off, close it, and record the decision</name>
|
||||||
<files>docs/SEARCH-QUALITY.md, search/test/fixtures/golden-corpus.json, search/src/data/kl-gazetteer.json, search/src/rules/abbreviations.ts</files>
|
<files>docs/SEARCH-QUALITY.md, search/test/fixtures/golden-corpus.json, search/src/data/kl-gazetteer.json, search/src/rules/abbreviations.ts, search/test/normalizer.test.mjs</files>
|
||||||
<read_first>
|
<read_first>
|
||||||
- `search/scripts/gazetteer-corpus.mjs` (task 1's output — the instrument that measures the bake-off)
|
- `search/scripts/gazetteer-corpus.mjs` (task 1's output — the instrument that measures the bake-off)
|
||||||
- `search/test/fixtures/golden-corpus.json` (task 1's output — the pinned corpus being measured)
|
- `search/test/fixtures/golden-corpus.json` (task 1's output — the pinned corpus being measured)
|
||||||
|
|||||||
@@ -446,8 +446,8 @@ When `origin` is empty or blank: skip the OPEN button, send the card with the al
|
|||||||
- `grep -c '/cari — ' bot/src/i18n/strings.ts | head -1` returns 2 (the /cari helpMenu line in BOTH locales — the " — " marker is load-bearing for `menuEntries` AND `menuFor`)
|
- `grep -c '/cari — ' bot/src/i18n/strings.ts | head -1` returns 2 (the /cari helpMenu line in BOTH locales — the " — " marker is load-bearing for `menuEntries` AND `menuFor`)
|
||||||
- `node --input-type=module -e "const {strings}=await import('./bot/src/i18n/strings.ts'); const en=Object.keys(strings.en).sort(), ms=Object.keys(strings.ms).sort(); if(JSON.stringify(en)!==JSON.stringify(ms)) throw new Error('locale key-sets differ'); if(!strings.ms.searchNoMatch||strings.ms.searchNoMatch===strings.en.searchNoMatch) throw new Error('ms refusal copy untranslated'); if(strings.en.osmAttribution!=='© OpenStreetMap contributors') throw new Error('attribution'); console.log('strings OK')"` exits 0 (CS1's locale parity holds with the new fields)
|
- `node --input-type=module -e "const {strings}=await import('./bot/src/i18n/strings.ts'); const en=Object.keys(strings.en).sort(), ms=Object.keys(strings.ms).sort(); if(JSON.stringify(en)!==JSON.stringify(ms)) throw new Error('locale key-sets differ'); if(!strings.ms.searchNoMatch||strings.ms.searchNoMatch===strings.en.searchNoMatch) throw new Error('ms refusal copy untranslated'); if(strings.en.osmAttribution!=='© OpenStreetMap contributors') throw new Error('attribution'); console.log('strings OK')"` exits 0 (CS1's locale parity holds with the new fields)
|
||||||
- `grep -q 'export function registerCari' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("cari"' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("search"' bot/src/bot/commands/cari.ts` exits 0 (D-01's /cari plus its bilingual twin)
|
- `grep -q 'export function registerCari' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("cari"' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("search"' bot/src/bot/commands/cari.ts` exits 0 (D-01's /cari plus its bilingual twin)
|
||||||
- `grep -c 'pergi_ke' bot/src/bot/commands/cari.ts | head -1` returns 0 (`/pergi_ke` stays reserved for Phase 5 — D-01)
|
- `grep -c 'bot\.command("pergi_ke"' bot/src/bot/commands/cari.ts | head -1` returns 0 (`/pergi_ke` stays UNREGISTERED — reserved for Phase 5, D-01; the header comment may still document the reservation, so the ban is on the registration shape, not the token)
|
||||||
- `grep -q 'replyWithVenue' bot/src/bot/commands/cari.ts` exits 0 AND `grep -c 'ctx.reply(' bot/src/bot/commands/cari.ts | head -1` is <= 2 (exactly ONE venue send for the card; the other `ctx.reply` calls are the refusal/usage lines)
|
- `grep -q 'replyWithVenue' bot/src/bot/commands/cari.ts` exits 0 AND `grep -c 'replyWithVenue' bot/src/bot/commands/cari.ts | head -1` returns 1 (exactly ONE venue send builds the card) AND `grep -c 'ctx.reply(' bot/src/bot/commands/cari.ts | head -1` returns 3 (the THREE plain-reply sites: empty-query usage, over-cap, no-match)
|
||||||
- `grep -q 'MAX_QUERY_CHARS = 128' bot/src/bot/commands/cari.ts` exits 0 (V5's length cap)
|
- `grep -q 'MAX_QUERY_CHARS = 128' bot/src/bot/commands/cari.ts` exits 0 (V5's length cap)
|
||||||
- `grep -q 'MINI_APP_URL' bot/src/config.ts` exits 0 AND `grep -q 'MINI_APP_URL' bot/wrangler.jsonc` exits 0 AND `grep -q 'MINI_APP_URL' bot/.dev.vars.example` exits 0 AND `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 (the public origin is configured, documented in code, in the example file, and in the configuration docs)
|
- `grep -q 'MINI_APP_URL' bot/src/config.ts` exits 0 AND `grep -q 'MINI_APP_URL' bot/wrangler.jsonc` exits 0 AND `grep -q 'MINI_APP_URL' bot/.dev.vars.example` exits 0 AND `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 (the public origin is configured, documented in code, in the example file, and in the configuration docs)
|
||||||
- `grep -q '"vars"' bot/wrangler.jsonc` exits 0 (the origin is a var, NOT a secret)
|
- `grep -q '"vars"' bot/wrangler.jsonc` exits 0 (the origin is a var, NOT a secret)
|
||||||
@@ -531,7 +531,7 @@ Update the file's header comment to name the two new surfaces and D-01/D-02/D-10
|
|||||||
**Acceptance:** `npm run bot:typecheck` exits 0. `npm test -w bot` will still FAIL on CS4/CS5/CS6 until task 3 updates them — that is expected and is task 3's job.
|
**Acceptance:** `npm run bot:typecheck` exits 0. `npm test -w bot` will still FAIL on CS4/CS5/CS6 until task 3 updates them — that is expected and is task 3's job.
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>cd /home/C-Fu/araharah && npm run bot:typecheck 2>&1 | tail -10 ; grep -c 'answerCallbackQuery' bot/src/bot/handlers/search-alternate.ts ; grep -c 'registerCari\|registerSearchAlternate' bot/src/bot/bot.ts ; npm test -w bot 2>&1 | tail -6</automated>
|
<automated>cd /home/C-Fu/araharah && npm run bot:typecheck 2>&1 | tail -10 ; grep -c 'answerCallbackQuery' bot/src/bot/handlers/search-alternate.ts ; grep -c 'registerCari\|registerSearchAlternate' bot/src/bot/bot.ts ; npm test -w bot 2>&1 | tail -6 ; cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun 2>&1 | tail -3 ; grep -rl 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ | wc -l</automated>
|
||||||
</verify>
|
</verify>
|
||||||
<acceptance_criteria>
|
<acceptance_criteria>
|
||||||
- `npm run bot:typecheck` exits 0
|
- `npm run bot:typecheck` exits 0
|
||||||
@@ -544,6 +544,7 @@ Update the file's header comment to name the two new surfaces and D-01/D-02/D-10
|
|||||||
- `grep -q 'export async function sendVenueCard' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -q 'sendVenueCard' bot/src/bot/commands/cari.ts` exits 0 (ONE card builder, two callers — the first card and the swapped card are structurally identical)
|
- `grep -q 'export async function sendVenueCard' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -q 'sendVenueCard' bot/src/bot/commands/cari.ts` exits 0 (ONE card builder, two callers — the first card and the swapped card are structurally identical)
|
||||||
- `grep -q 'registerCari' bot/src/bot/bot.ts` exits 0 AND `grep -q 'registerSearchAlternate' bot/src/bot/bot.ts` exits 0 (both surfaces are wired into the factory)
|
- `grep -q 'registerCari' bot/src/bot/bot.ts` exits 0 AND `grep -q 'registerSearchAlternate' bot/src/bot/bot.ts` exits 0 (both surfaces are wired into the factory)
|
||||||
- `grep -c 'createGazetteerSource()' bot/src/bot/bot.ts | head -1` returns 1 (ONE source instance shared by both registrations — a single memoized index)
|
- `grep -c 'createGazetteerSource()' bot/src/bot/bot.ts | head -1` returns 1 (ONE source instance shared by both registrations — a single memoized index)
|
||||||
|
- `cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun` exits 0 AND `grep -rl 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ | wc -l` returns >= 1 (the Worker bundle INLINES the gazetteer now that `bot.ts` imports `createGazetteerSource()` — this completes the L3 bundle-marker half deferred from plan 04-01, where no bot source imported the module yet)
|
||||||
- `grep -q 'replyWithVenue' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -c 'editMessageText' bot/src/bot/handlers/search-alternate.ts | head -1` returns 0 (the swap sends a NEW card; `editMessageText` would destroy the venue payload)
|
- `grep -q 'replyWithVenue' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -c 'editMessageText' bot/src/bot/handlers/search-alternate.ts | head -1` returns 0 (the swap sends a NEW card; `editMessageText` would destroy the venue payload)
|
||||||
</acceptance_criteria>
|
</acceptance_criteria>
|
||||||
<done>The alternates surface is live: a scoped `alt:` callback matcher that answers within 30 s, validates the id before lookup, swaps the card with a new venue message through the ONE shared card builder, and a catch-all that answers every other callback so Telegram never shows a spinner.</done>
|
<done>The alternates surface is live: a scoped `alt:` callback matcher that answers within 30 s, validates the id before lookup, swaps the card with a new venue message through the ONE shared card builder, and a catch-all that answers every other callback so Telegram never shows a spinner.</done>
|
||||||
@@ -644,7 +645,7 @@ function stripComments(src) {
|
|||||||
- `npm test -w web` green (unaffected by this plan)
|
- `npm test -w web` green (unaffected by this plan)
|
||||||
- `grep -rl 'createHmac\|crypto\.subtle' bot/src/bot/commands/cari.ts bot/src/bot/handlers/search-alternate.ts` returns NOTHING — the deep-link path carries no crypto (D-11)
|
- `grep -rl 'createHmac\|crypto\.subtle' bot/src/bot/commands/cari.ts bot/src/bot/handlers/search-alternate.ts` returns NOTHING — the deep-link path carries no crypto (D-11)
|
||||||
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' bot/src/bot/commands/cari.ts | grep -o 'console\.log("[^"]*"' | grep -v '\[search\] rejected: [a-z-]*"' | wc -l` returns 0 — every log line is a category-only literal (D-15)
|
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' bot/src/bot/commands/cari.ts | grep -o 'console\.log("[^"]*"' | grep -v '\[search\] rejected: [a-z-]*"' | wc -l` returns 0 — every log line is a category-only literal (D-15)
|
||||||
- `grep -c 'pergi_ke' bot/src/bot/commands/cari.ts bot/scripts/register-commands.mjs | head -1` returns 0 for both files — the Phase-5 command stays reserved (D-01)
|
- `grep -c 'bot\.command("pergi_ke"' bot/src/bot/commands/cari.ts | head -1` returns 0 AND `grep -c 'pergi_ke' bot/scripts/register-commands.mjs | head -1` returns 0 — the Phase-5 command stays unregistered (D-01; cari.ts's ban is on the registration shape, since its header comment documents the reservation)
|
||||||
- `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 — the new configuration value is documented where a maintainer looks
|
- `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 — the new configuration value is documented where a maintainer looks
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
|
|||||||
@@ -1077,32 +1077,41 @@ process.exitCode = top3Rate >= 0.9 && leaked.length === 0 ? 0 : 1;
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
|
> **STATUS: ALL RESOLVED (2026-10-23, during plan-phase).** Each question below carries its
|
||||||
|
> `(RESOLVED — …)` decision line; the decisions are binding and are implemented in plans
|
||||||
|
> 04-01…04-04. Where a plan CORRECTED the original recommendation, the correction is stated.
|
||||||
|
|
||||||
1. **Which deep-link carrier does Phase 4 ship?** (D-11 says `?startapp=dest_<id>`; the docs put `startapp` on direct links, not inline buttons.)
|
1. **Which deep-link carrier does Phase 4 ship?** (D-11 says `?startapp=dest_<id>`; the docs put `startapp` on direct links, not inline buttons.)
|
||||||
- **What we know:** `WebAppInfo.url` is an arbitrary HTTPS URL; community reports say query params survive; `startapp` is documented for `t.me` links and `startattach` for attachment-menu links; the `web_app` inline button is a distinct launch surface.
|
- **What we know:** `WebAppInfo.url` is an arbitrary HTTPS URL; community reports say query params survive; `startapp` is documented for `t.me` links and `startattach` for attachment-menu links; the `web_app` inline button is a distinct launch surface.
|
||||||
- **What's unclear:** whether Telegram's own clients preserve the query string on a `web_app` button URL across Android/iOS/Desktop.
|
- **What's unclear:** whether Telegram's own clients preserve the query string on a `web_app` button URL across Android/iOS/Desktop.
|
||||||
- **Recommendation:** implement **Carrier A** (`web_app` button, `?dest=<id>` in the URL) as the primary, keep **Carrier B** (`t.me?startapp=`) for the ONBD-03 home-screen path, and **write the payload reader to accept both** (`location.search` first, then `initDataUnsafe.start_param`). Validate on a real device at the exit gate. **Flag to the user**, because it touches a locked decision's wording.
|
- **Recommendation:** implement **Carrier A** (`web_app` button, `?dest=<id>` in the URL) as the primary, keep **Carrier B** (`t.me?startapp=`) for the ONBD-03 home-screen path, and **write the payload reader to accept both** (`location.search` first, then `initDataUnsafe.start_param`). Validate on a real device at the exit gate. **Flag to the user**, because it touches a locked decision's wording.
|
||||||
|
- **(RESOLVED — Carrier A (`?dest=<id>` in the `web_app` button's URL, read from `location.search`) is primary; Carrier B is the signed `initData`'s `start_param` field (the direct-link / home-screen path), NOT `initDataUnsafe` — the recommendation's `initDataUnsafe` reading was corrected: only the SIGNED `initData` is parsed. Both carriers share one validation. Device validation happens at 04-04's blocking gate. Plans 04-03/04-04.)**
|
||||||
|
|
||||||
2. **Is the venue card one message or two?**
|
2. **Is the venue card one message or two?**
|
||||||
- **What we know:** `sendVenue` has no `text` parameter; D-02 wants one tap to proceed; CONTEXT's "specific ideas" mention a one-line clinical confirmation.
|
- **What we know:** `sendVenue` has no `text` parameter; D-02 wants one tap to proceed; CONTEXT's "specific ideas" mention a one-line clinical confirmation.
|
||||||
- **What's unclear:** whether "one line confirming the destination" is a separate `sendMessage` before the card, or a property of the card's own `title`/`address`.
|
- **What's unclear:** whether "one line confirming the destination" is a separate `sendMessage` before the card, or a property of the card's own `title`/`address`.
|
||||||
- **Recommendation:** **one `sendVenue`** whose `title` is the place name and `address` is the curated address — the native card *is* the confirmation, and this honours D-02's single-message shape. If the user wants a separate clinical line, it costs one extra message and must be added to the copy gate.
|
- **Recommendation:** **one `sendVenue`** whose `title` is the place name and `address` is the curated address — the native card *is* the confirmation, and this honours D-02's single-message shape. If the user wants a separate clinical line, it costs one extra message and must be added to the copy gate.
|
||||||
|
- **(RESOLVED — exactly one `sendVenue` per card; `sendVenue` has no `text` param, so the card's own `title` (canonical name) + `address` (curated, verbatim) IS the confirmation. Enforced by CR4's request-count gate. Plan 04-03.)**
|
||||||
|
|
||||||
3. **What is the exact corpus size and the exact per-query expectation?**
|
3. **What is the exact corpus size and the exact per-query expectation?**
|
||||||
- **What we know:** D-13 says ~60–100 queries with the listed categories; D-14 sets ≥90% top-3 on positives.
|
- **What we know:** D-13 says ~60–100 queries with the listed categories; D-14 sets ≥90% top-3 on positives.
|
||||||
- **What's unclear:** whether the 90% is measured over *all* positives or over positives *excluding* the typo bucket (typos are the hardest and the fuzzy tier's raison d'être).
|
- **What's unclear:** whether the 90% is measured over *all* positives or over positives *excluding* the typo bucket (typos are the hardest and the fuzzy tier's raison d'être).
|
||||||
- **Recommendation:** measure and report **per-category** rates (exact / abbreviation / glued / typo / alias), and set the D-14 gate on the **all-positives** figure, with per-category figures reported alongside so a regression is diagnosable. This is OpenCode's Discretion; state it in the plan.
|
- **Recommendation:** measure and report **per-category** rates (exact / abbreviation / glued / typo / alias), and set the D-14 gate on the **all-positives** figure, with per-category figures reported alongside so a regression is diagnosable. This is OpenCode's Discretion; state it in the plan.
|
||||||
|
- **(RESOLVED — the D-14 gate measures the ALL-POSITIVES top-3 rate (≥ 90%, threshold `0.9` pinned in the corpus file); per-category rates are REPORTED alongside and carry authoring floors (landmark ≥ 12, abbreviation ≥ 15, glued ≥ 6, typo ≥ 12, alias ≥ 12, negative ≥ 6). Plan 04-02, corpus format + CO7.)**
|
||||||
|
|
||||||
4. **Which `SearchSource` seam shape?**
|
4. **Which `SearchSource` seam shape?**
|
||||||
- **What we know:** D-03 requires a pluggable interface with the gazetteer as implementation #1, WASM geocoder as #2, Photon as #3.
|
- **What we know:** D-03 requires a pluggable interface with the gazetteer as implementation #1, WASM geocoder as #2, Photon as #3.
|
||||||
- **What's unclear:** whether the interface is synchronous or async, and whether it lives in the shared package or in `bot/src/search/`.
|
- **What's unclear:** whether the interface is synchronous or async, and whether it lives in the shared package or in `bot/src/search/`.
|
||||||
- **Recommendation:** `async search(): Promise<SearchHit[]>` (a network-backed backend #3 will need it), defined in the **shared package** so the webview can use the same types. The `byId(id)` lookup — needed by the callback handler and the webview — should be a **separate, synchronous** method on the interface, because both the callback handler and the deep-link reader need it and neither should await a network.
|
- **Recommendation:** `async search(): Promise<SearchHit[]>` (a network-backed backend #3 will need it), defined in the **shared package** so the webview can use the same types. The `byId(id)` lookup — needed by the callback handler and the webview — should be a **separate, synchronous** method on the interface, because both the callback handler and the deep-link reader need it and neither should await a network.
|
||||||
|
- **(RESOLVED — `SearchSource` lives in the shared `search/` workspace (`search/src/types.ts`): `async search(q): Promise<SearchHit[]>` plus a SEPARATE SYNCHRONOUS `byId(id)`. The package also exports a synchronous `search(text, limit?)` convenience wrapper for the corpus runner. Plans 04-01 (contract), 04-03/04-04 (consumers).)**
|
||||||
|
|
||||||
5. **Is the Overpass script required to have a *verified* run in this phase?**
|
5. **Is the Overpass script required to have a *verified* run in this phase?**
|
||||||
- **What we know:** `overpass-api.de` returns **406 without a `User-Agent` and 200 with one** (verification pass, same host/same paths); `overpass.kumi.systems` and `overpass.private.coffee` are unreachable from this network (HTTP 000); `overpass.osm.ch` returns 400 (Switzerland-only); Nominatim and Geofabrik work.
|
- **What we know:** `overpass-api.de` returns **406 without a `User-Agent` and 200 with one** (verification pass, same host/same paths); `overpass.kumi.systems` and `overpass.private.coffee` are unreachable from this network (HTTP 000); `overpass.osm.ch` returns 400 (Switzerland-only); Nominatim and Geofabrik work.
|
||||||
- **What's unclear:** whether the phase's exit gate requires the Overpass path to have been exercised against live OSM.
|
- **What's unclear:** whether the phase's exit gate requires the Overpass path to have been exercised against live OSM.
|
||||||
- **Recommendation:** the plan should require the script to **run and produce a valid-shaped proposal file** (verified against a **committed fixture** of a real Overpass response, committed to the repo so the test is deterministic), and to document the live-source URL in the script's header. **The `User-Agent` header is load-bearing and must be asserted by a test** — a missing UA produces a 406 on every endpoint and looks like a dead service. The curated entries themselves should be coordinate-verified against **Nominatim** (reachable) — which satisfies D-06's "hand-curated seed" with real coordinates. **Do not plan around a blocked Overpass**: the live path works from this machine once the header is set; the fixture is for *determinism*, not for working around an outage.
|
- **Recommendation:** the plan should require the script to **run and produce a valid-shaped proposal file** (verified against a **committed fixture** of a real Overpass response, committed to the repo so the test is deterministic), and to document the live-source URL in the script's header. **The `User-Agent` header is load-bearing and must be asserted by a test** — a missing UA produces a 406 on every endpoint and looks like a dead service. The curated entries themselves should be coordinate-verified against **Nominatim** (reachable) — which satisfies D-06's "hand-curated seed" with real coordinates. **Do not plan around a blocked Overpass**: the live path works from this machine once the header is set; the fixture is for *determinism*, not for working around an outage.
|
||||||
|
- **(RESOLVED — no live-Overpass obligation: the script is proven offline against the COMMITTED fixture (`--source=fixture`), with `--source=overpass|nominatim` as the maintainer's live paths; the identifying `User-Agent` is mandatory and TP2-asserted; a busy instance soft-exits 0 (TP4); curated coordinates are spot-verified against Nominatim and recorded in `docs/SEARCH-QUALITY.md`. Plan 04-02.)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -42,16 +42,16 @@ updated: 2026-10-23
|
|||||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
| 04-01-T1 (workspace + types + JUPEM table + normalizer) | 01 | 1 | SRCH-02, D-09 | T-04-01/02/08 | no logging/storage in the module; table is DATA; symmetric normalization | unit | `npm run search:test` | ❌ W0 | ⬜ pending |
|
| 04-01-T1 (workspace + types + JUPEM table + normalizer) | 01 | 1 | SRCH-02, D-09 | T-04-01/02/08 | no logging/storage in the module; table is DATA; symmetric normalization | unit | `npm run search:test` | ❌ W0 | ⬜ pending |
|
||||||
| 04-01-T2 (OSA distance + tiered scorer) | 01 | 1 | SRCH-02 | T-04-03/05 | min-length guard; disjoint tiers; fuzzy admission ≥ 0.72 | unit | `npm run search:test` | ❌ W0 | ⬜ pending |
|
| 04-01-T2 (OSA distance + tiered scorer) | 01 | 1 | SRCH-02 | T-04-03/05 | min-length guard; disjoint tiers; fuzzy admission ≥ 0.72 | unit | `npm run search:test` | ❌ W0 | ⬜ pending |
|
||||||
| 04-01-T3 (gazetteer + loader + source + LICENSE/ODbL) | 01 | 1 | SRCH-01, D-04/06/07/15 | T-04-04/06/07/09/11 | honest refusal (`[]`); dataset invariants; GPL+ODbL notices; lazy index; 3-environment inline proof | unit + bundle | `npm run search:test` + `npx wrangler deploy --dry-run` (in `bot/`) | ❌ W0 | ⬜ pending |
|
| 04-01-T3 (gazetteer + loader + source + LICENSE/ODbL) | 01 | 1 | SRCH-01, D-04/06/07/15 | T-04-04/06/07/09/11 | honest refusal (`[]`); dataset invariants; GPL+ODbL notices; lazy index; 3-environment loader proof (esbuild search-module bundle marker at wave 1; wrangler dry-run = exit-0 build guard) | unit + bundle | `npm run search:test` + `node_modules/.bin/esbuild search/src/index.ts --bundle` marker grep + `npx wrangler deploy --dry-run` exit 0 (in `bot/`) | ❌ W0 | ⬜ pending |
|
||||||
| 04-02-T1 (golden corpus + D-14 runner + Nyquist gate) | 02 | 2 | SRCH-01/02, D-13/14 | T-04-12/13/19 | pinned corpus; threshold 0.9 un-editable; zero negative leaks | corpus | `npm run search:corpus` + `npm run search:test` | ❌ W0 | ⬜ pending |
|
| 04-02-T1 (golden corpus + D-14 runner + Nyquist gate) | 02 | 2 | SRCH-01/02, D-13/14 | T-04-12/13/19 | pinned corpus; threshold 0.9 un-editable; zero negative leaks | corpus | `npm run search:corpus` + `npm run search:test` | ❌ W0 | ⬜ pending |
|
||||||
| 04-02-T2 (Overpass top-up + JSON→SQLite converter) | 02 | 2 | D-05/06 | T-04-14/15/16/17/20 | repo tools never runtime deps; UA mandatory; soft-exit on busy; id set preserved | fixture-driven | `npm run search:test` | ❌ W0 | ⬜ pending |
|
| 04-02-T2 (Overpass top-up + JSON→SQLite converter) | 02 | 2 | D-05/06 | T-04-14/15/16/17/20 | repo tools never runtime deps; UA mandatory; soft-exit on busy; id set preserved | fixture-driven | `npm run search:test` | ❌ W0 | ⬜ pending |
|
||||||
| 04-02-T3 (bake-off close + SEARCH-QUALITY.md + coord verification) | 02 | 2 | D-14 | T-04-13 | measured, recorded, honest decision; one named lever | record | `npm run search:corpus` | ❌ | ⬜ pending |
|
| 04-02-T3 (bake-off close + SEARCH-QUALITY.md + coord verification) | 02 | 2 | D-14 | T-04-13 | measured, recorded, honest decision; one named lever | record | `npm run search:corpus` | ❌ | ⬜ pending |
|
||||||
| 04-03-T1 (/cari + venue card + OPEN deep link + MINI_APP_URL) | 03 | 2 | SRCH-03, D-01/02/10/11/15 | T-04-21/22/24/30 | 128-char cap; category-only logs; configured origin | source-gate + typecheck | `npm run bot:typecheck` + `npm test -w bot` | ❌ W0 | ⬜ pending |
|
| 04-03-T1 (/cari + venue card + OPEN deep link + MINI_APP_URL) | 03 | 2 | SRCH-03, D-01/02/10/11/15 | T-04-21/22/24/30 | 128-char cap; category-only logs; configured origin | source-gate + typecheck | `npm run bot:typecheck` + `npm test -w bot` | ❌ W0 | ⬜ pending |
|
||||||
| 04-03-T2 (alternates callback handler + bot factory wiring) | 03 | 2 | SRCH-03, D-02 | T-04-23/27/28 | id validated before lookup; every callback answered; no editMessageText | integration | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
| 04-03-T2 (alternates callback handler + bot factory wiring) | 03 | 2 | SRCH-03, D-02 | T-04-23/27/28 | id validated before lookup; every callback answered; no editMessageText; Worker-bundle marker grep (deferred from 04-01-T3 — `bot.ts` now imports the source) | integration | `npm test -w bot` + `npx wrangler deploy --dry-run` marker grep (in `bot/`) | ❌ W0 | ⬜ pending |
|
||||||
| 04-03-T3 (commands.test.mjs landmines + cari.test.mjs gates) | 03 | 2 | SRCH-03, D-11/15 | T-04-25/26/29 | no crypto in deep-link path; no-match refusal honest; no query in logs | gates | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
| 04-03-T3 (commands.test.mjs landmines + cari.test.mjs gates) | 03 | 2 | SRCH-03, D-11/15 | T-04-25/26/29 | no crypto in deep-link path; no-match refusal honest; no query in logs | gates | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
||||||
| 04-04-T1 (two-carrier destination resolver + mock + strings) | 04 | 3 | ONBD-02, D-11 | T-04-32/33/34/35 | both carriers one validation; honest null; memory-only; no crypto | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
| 04-04-T1 (two-carrier destination resolver + mock + strings) | 04 | 3 | ONBD-02, D-11 | T-04-32/33/34/35 | both carriers one validation; honest null; memory-only; no crypto | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||||
| 04-04-T2 (ONBD-03 hooks + boot wiring + `?dest=` profile) | 04 | 3 | ONBD-03, D-12 | T-04-36/37/38 | Bot API 8.0 guard; "unknown" ≠ error; no browser install APIs | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
| 04-04-T2 (ONBD-03 hooks + boot wiring + `?dest=` profile) | 04 | 3 | ONBD-03, D-12 | T-04-36/37/38 | Bot API 8.0 guard; "unknown" ≠ error; no browser install APIs | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||||
| 04-04-T3 (BA7/BA8 drift gates + destination-preload capture) | 04 | 3 | ONBD-02 | T-04-39/40/41/42 | gazetteer inlined in bundle; no crypto shipped; capture asserts state=resolved + card text | integration + capture | `npm test -w web` + `npm run web:capture` | ✅ extend | ⬜ pending |
|
| 04-04-T3 (BA7/BA8 drift gates + destination-preload capture) | 04 | 3 | ONBD-02 | T-04-39/40/41/42 | gazetteer inlined in bundle; no crypto shipped; capture asserts state=resolved + card text | integration + capture | `npm test -w web` + `npm run capture -w web` | ✅ extend | ⬜ pending |
|
||||||
| 04-04-T4 (device gate — BLOCKING checkpoint) | 04 | 3 | ONBD-02/03 | — | real-device deep-link + home-screen verification; honest UNVERIFIED if no device | manual (checkpoint) | automated half: `npm test -w bot && npm test -w web && npm run search:corpus` | ❌ exit gate | ⬜ pending |
|
| 04-04-T4 (device gate — BLOCKING checkpoint) | 04 | 3 | ONBD-02/03 | — | real-device deep-link + home-screen verification; honest UNVERIFIED if no device | manual (checkpoint) | automated half: `npm test -w bot && npm test -w web && npm run search:corpus` | ❌ exit gate | ⬜ pending |
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
@@ -67,7 +67,7 @@ All new this phase (the repo has no search infrastructure today). The owning tas
|
|||||||
- [ ] `search/test/distance.test.mjs` — hand-computed OSA vectors (04-01-T2)
|
- [ ] `search/test/distance.test.mjs` — hand-computed OSA vectors (04-01-T2)
|
||||||
- [ ] `search/test/score.test.mjs` — tier disjointness, min-length guard, exact-outranks-fuzzy, admission boundary (04-01-T2)
|
- [ ] `search/test/score.test.mjs` — tier disjointness, min-length guard, exact-outranks-fuzzy, admission boundary (04-01-T2)
|
||||||
- [ ] `search/test/gazetteer.test.mjs` — entry schema, unique slug ids, KL bbox, ODbL header, LICENSE presence, no-logging/no-storage, lazy loader (04-01-T3)
|
- [ ] `search/test/gazetteer.test.mjs` — entry schema, unique slug ids, KL bbox, ODbL header, LICENSE presence, no-logging/no-storage, lazy loader (04-01-T3)
|
||||||
- [ ] `search/test/loader-env.test.mjs` — bare-Node import + static-import assertion + `wrangler deploy --dry-run` bundle marker (04-01-T3)
|
- [ ] `search/test/loader-env.test.mjs` — bare-Node import + static-import assertion + esbuild search-module bundle marker (`suria-klcc`); the `wrangler deploy --dry-run` Worker-bundle marker grep lands in 04-03-T2 once `bot.ts` imports the source (04-01-T3 + 04-03-T2)
|
||||||
- [ ] `search/test/fixtures/golden-corpus.json` — the pinned D-13 corpus, 60+ hand-written cases (04-02-T1)
|
- [ ] `search/test/fixtures/golden-corpus.json` — the pinned D-13 corpus, 60+ hand-written cases (04-02-T1)
|
||||||
- [ ] `search/scripts/gazetteer-corpus.mjs` + `search/test/corpus.test.mjs` — the D-14 runner and its in-suite Nyquist gate (04-02-T1)
|
- [ ] `search/scripts/gazetteer-corpus.mjs` + `search/test/corpus.test.mjs` — the D-14 runner and its in-suite Nyquist gate (04-02-T1)
|
||||||
- [ ] `search/scripts/overpass-topup.mjs` + `search/test/topup.test.mjs` + `search/test/fixtures/overpass-response.json` — the D-06 tool, proven offline (04-02-T2)
|
- [ ] `search/scripts/overpass-topup.mjs` + `search/test/topup.test.mjs` + `search/test/fixtures/overpass-response.json` — the D-06 tool, proven offline (04-02-T2)
|
||||||
|
|||||||
Reference in New Issue
Block a user