From dedcdcdbc5f9107a5ea66d4ae8156027d9a98c13 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 11:24:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(07-06):=20launch-gate=20decisions=20?= =?UTF-8?q?=E2=80=94=20KV=20cron=20retune=20to=20720/day=20(the=20$0=20law?= =?UTF-8?q?=20holds),=20brand=20sweep=20araharah=E2=86=92arah=C2=B2=20acro?= =?UTF-8?q?ss=20user-facing=20copy,=20menu=20button=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/scripts/set-menu-button.mjs | 2 +- bot/src/guidance/cron.ts | 2 +- bot/src/i18n/strings.ts | 24 ++++++++++---------- bot/test/guidance-cron.test.mjs | 40 +++++++++++++++++---------------- bot/wrangler.jsonc | 13 ++++++----- docs/BUDGETS.md | 9 ++++++++ docs/KNOWN-LIMITATIONS.md | 8 +++---- web/index.html | 2 +- web/src/i18n/strings.ts | 4 ++-- web/src/main.ts | 2 +- 10 files changed, 60 insertions(+), 46 deletions(-) diff --git a/bot/scripts/set-menu-button.mjs b/bot/scripts/set-menu-button.mjs index aa2cd88..f067cc2 100644 --- a/bot/scripts/set-menu-button.mjs +++ b/bot/scripts/set-menu-button.mjs @@ -50,7 +50,7 @@ const res = await fetch(`https://api.telegram.org/bot${token}/setChatMenuButton` method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - menu_button: { type: "web_app", text: "araharah", web_app: { url: appUrl } }, + menu_button: { type: "web_app", text: "arah²", web_app: { url: appUrl } }, }), }); const body = await res.json(); diff --git a/bot/src/guidance/cron.ts b/bot/src/guidance/cron.ts index 6dce35a..c7fb2e7 100644 --- a/bot/src/guidance/cron.ts +++ b/bot/src/guidance/cron.ts @@ -52,7 +52,7 @@ import { strings } from "../i18n/strings.ts"; /** The chat card's cadence (D-11/D-13): one card window every 3 minutes; * the window's first tick is the due tick. */ -export const CARD_CADENCE_S = 180; +export const CARD_CADENCE_S = 240; /** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */ const SCAN_TICK_S = 60; diff --git a/bot/src/i18n/strings.ts b/bot/src/i18n/strings.ts index d9e1376..0e278dd 100644 --- a/bot/src/i18n/strings.ts +++ b/bot/src/i18n/strings.ts @@ -7,7 +7,7 @@ * copy anywhere in the command tree. * * Locales: English default ("en") + Bahasa Melayu ("ms"). Tone is clinical - * + honest (D-01): one sentence for what araharah is, plain statements of + * + honest (D-01): one sentence for what arah² is, plain statements of * what is stored and what is not — no emoji, no marketing flourishes. * * Claim-surface honesty (D-17 / PRIV-01): `privacyStored` names the exact @@ -19,7 +19,7 @@ */ export interface BotStrings { - /** D-01: what araharah is, in one sentence. */ + /** D-01: what arah² is, in one sentence. */ startIntro: string; /** PRIV-01: exactly what is stored — must mirror the D-08 claim fields and the TTL. */ privacyStored: string; @@ -86,18 +86,18 @@ export type Locale = "en" | "ms"; export const strings: Record = { en: { startIntro: - "araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.", + "arah² — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means.", privacyStored: - "What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.", + "What arah² keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone.", privacyNotStored: - "What araharah never keeps: your location, your routes, your trip history, any profile about you.", + "What arah² never keeps: your location, your routes, your trip history, any profile about you.", helpMenu: - "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores\n/cari — Search for a destination in the KL metro\nKnown limits: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md", + "Commands:\n/help — How arah² works and what it keeps\n/privacy — What arah² stores and never stores\n/cari — Search for a destination in the KL metro\nKnown limits: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md", awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.", shareLocationButton: "Share my location", pinReceived: "Got it — your starting point is set.", searchUsage: "/cari — search for a destination in the KL metro.", - searchNoMatch: "Not in the v1 KL area. araharah only knows Kuala Lumpur and the Klang Valley for now.", + searchNoMatch: "Not in the v1 KL area. arah² only knows Kuala Lumpur and the Klang Valley for now.", searchQueryTooLong: "That query is too long. Send just the place name.", searchOpenButton: "OPEN IN ARAHARAH", osmAttribution: "© OpenStreetMap contributors", @@ -124,18 +124,18 @@ export const strings: Record = { }, ms: { startIntro: - "araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.", + "arah² — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya.", privacyStored: - "Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.", + "Yang arah² simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang.", privacyNotStored: - "Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", + "Yang arah² tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", helpMenu: - "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL\nHad panduan: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md", + "Arahan:\n/help — Cara arah² berfungsi dan apa yang ia simpan\n/privacy — Apa yang arah² simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL\nHad panduan: http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md", awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.", shareLocationButton: "Kongsi lokasi saya", pinReceived: "Baik — titik mula anda sudah ditetapkan.", searchUsage: "/cari — cari destinasi dalam kawasan KL.", - searchNoMatch: "Bukan dalam kawasan KL v1. araharah hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini.", + searchNoMatch: "Bukan dalam kawasan KL v1. arah² hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini.", searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.", searchOpenButton: "OPEN IN ARAHARAH", osmAttribution: "© OpenStreetMap contributors", diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs index 02a4410..d94870d 100644 --- a/bot/test/guidance-cron.test.mjs +++ b/bot/test/guidance-cron.test.mjs @@ -10,7 +10,9 @@ * Pure-function block (task 1): * GC1 projection math — T+300 of a 900 s / 10 km snapshot → the 90° left * carded at register T-01, "667 M", ETA 13:15 · 6.7 KM LEFT - * GC2 cadence table — CARD_CADENCE_S 180; due on the first tick of each + * GC2 cadence table — CARD_CADENCE_S 240 (launch-gate retune, 2025-10-25: + * 2-minute cron ticks x 1 list-op = 720/day, under the 1,000/day free + * ceiling — the $0 law); due on the first tick of each * 180 s window (nowSec % 180 < 60); non-due ticks carry no card * GC3 dead trip — elapsed ≥ duration (progress clamped ≥ distanceM) → * due false, no card: the cron never cards a dead trip @@ -96,11 +98,11 @@ function envelope({ points, distanceM, durationS, startedAtSec, chatId = 101 }) /* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */ -test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 KM, ETA 13:15 · 8.0 KM LEFT", async () => { +test("GC1 projection: elapsed 180 s at the retuned due tick (900 s total) → the 90° left carded at T-01, 2.0 KM, ETA 13:16 · 8.0 KM LEFT", async () => { const cron = await import("../src/guidance/cron.ts"); - const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); - assert.equal(card.due, true, "T0 + 180 % 180 === 0 → the window's first tick is due"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 }); + const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail"); + assert.equal(card.due, true, "T0 + 240 % 240 === 0 → the window's first tick is due (the 240 s retune)"); assert.equal(card.glyph, "left", "the first classified turn ahead (progress 2000 < along 4000) is the 90° left"); assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)"); const lines = card.caption.split("\n"); @@ -111,20 +113,20 @@ test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 "2.0 KM — TURN LEFT ONTO JALAN SULTAN ISMAIL", "maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)", ); - assert.equal(lines[2], "ETA 13:15 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s"); + assert.equal(lines[2], "ETA 13:16 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s"); assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)"); }); /* ————— GC2 — the cadence table ————— */ -test("GC2 cadence: CARD_CADENCE_S 180; due true on the window's first ticks, false after, no card on a non-due tick", async () => { +test("GC2 cadence: CARD_CADENCE_S 240 (*/2-tick retune); due true on the window's first ticks, false after, no card on a non-due tick", async () => { const cron = await import("../src/guidance/cron.ts"); - assert.equal(cron.CARD_CADENCE_S, 180, "the 3-minute cadence is an exported named constant"); + assert.equal(cron.CARD_CADENCE_S, 240, "the 4-minute card cadence is an exported named constant (launch-gate retune: */2 cron + 240 s = 720 list-ops/day, under the 1,000 ceiling)"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3780, true]]; + const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3840, true], [3899, true], [3900, false], [4079, false], [4080, true]]; for (const [nowSec, expectedDue] of table) { const card = cron.projectTurnCards(env, nowSec); - assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 180 < 60 → ${expectedDue}`); + assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 240 < 60 → ${expectedDue}`); if (!expectedDue) { assert.equal(card.caption, null, "a non-due tick produces no card"); assert.equal(card.glyph, null, "a non-due tick produces no glyph"); @@ -136,7 +138,7 @@ test("GC2 cadence: CARD_CADENCE_S 180; due true on the window's first ticks, fal test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron never cards a dead trip", async () => { const cron = await import("../src/guidance/cron.ts"); - const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 }); for (const nowSec of [T0 + 900, T0 + 901, T0 + 5000]) { const card = cron.projectTurnCards(env, nowSec); assert.equal(card.due, false, `elapsed ${nowSec - T0} s ≥ duration 900 s → the trip would have ended`); @@ -152,7 +154,7 @@ test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron ne test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => { const cron = await import("../src/guidance/cron.ts"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); + const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail"); assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars"); assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim"); assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}"); @@ -164,8 +166,8 @@ test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, n test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => { const cron = await import("../src/guidance/cron.ts"); - const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 180); // no streetAt + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 }); + const card = cron.projectTurnCards(env, T0 + 240); // no streetAt assert.equal(card.due, true); assert.equal(card.glyph, "left"); const lines = card.caption.split("\n"); @@ -175,8 +177,8 @@ test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-li test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => { const cron = await import("../src/guidance/cron.ts"); - const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 180, () => null); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 }); + const card = cron.projectTurnCards(env, T0 + 240, () => null); const lines = card.caption.split("\n"); assert.equal(lines[1], "2.0 KM — TURN LEFT", "null street → the bare-verb degradation"); }); @@ -186,8 +188,8 @@ test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn aut const p0 = { lat: KL_LAT, lng: KL_LNG }; const p1 = { lat: KL_LAT, lng: KL_LNG + 2500 / M_PER_DEG_LNG }; // east, bearing 90 const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn - const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); + const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 + 60 }); + const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail"); assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn"); assert.equal(card.maneuverIdx, 1); assert.equal( @@ -434,7 +436,7 @@ async function sentPhotos(calls) { return out; } -const TICK_SEC = T0 + 180; // % 180 === 0 → a due tick +const TICK_SEC = T0 + 240; // % 240 === 0 → a due tick (the launch-gate retune) test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-bearing from the mini-graph R2 fake", async () => { const mod = await import("../src/webhook.ts"); diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index 3737bcd..84222b4 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -28,10 +28,13 @@ // (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public // configuration, never a secret. "r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }], - // The repo's FIRST Cron Trigger (06-07, D-11): 60 s scan; card cadence - // decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window. The tick - // is STATELESS (zero KV writes — the free-plan budget law). Local test: - // curl "http://localhost:8787/cdn-cgi/local/scheduled" against `wrangler dev`. - "triggers": { "crons": ["*/1 * * * *"] }, + // The repo's FIRST Cron Trigger (06-07, D-11): */2 scan (LAUNCH-GATE decision + // 2025-10-25 — the $0 law holds: 2-min ticks × 1 list-op = 720/day, inside the + // free plan's 1,000/day KV-list ceiling; card cadence is 240 s in code + // (CARD_CADENCE_S). The tick is STATELESS (zero KV writes — the budget law). + // Local test: curl "http://localhost:8787/cdn-cgi/local/scheduled" under + // `wrangler dev`. 90 s granularity is NOT cron-expressible — */2 is the + // coarsest cadence that stays under the ceiling while honoring D-11's spirit. + "triggers": { "crons": ["*/2 * * * *"] }, "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } diff --git a/docs/BUDGETS.md b/docs/BUDGETS.md index 1027778..bf17deb 100644 --- a/docs/BUDGETS.md +++ b/docs/BUDGETS.md @@ -215,3 +215,12 @@ The launch gate (07-06) signs this record after the redeploy re-run: - Maintainer: __________________ - Date: __________________ + +## RESOLUTION (launch-gate decision, 2025-10-25) + +**KV-list over-ceiling: RESOLVED via the $0-safe retune.** Cron retuned `*/1` → `*/2` +(2-minute ticks) + CARD_CADENCE_S 180 → 240 (4-minute card cadence): +**720 list-ops/day — under the 1,000/day free ceiling.** The $0 law holds; no paid +tier. Consequence: turn-cards arrive at most every ~4 min while minimized (D-13's +best-effort framing absorbs this). Tests retuned (GC1/GC2/GC4/GC5*/GC-C timeline); +126/126 green. diff --git a/docs/KNOWN-LIMITATIONS.md b/docs/KNOWN-LIMITATIONS.md index 4801b00..42818d9 100644 --- a/docs/KNOWN-LIMITATIONS.md +++ b/docs/KNOWN-LIMITATIONS.md @@ -2,17 +2,17 @@ # Known limitations -araharah is a turn-by-turn navigator for the Kuala Lumpur metro, delivered inside +arah² is a turn-by-turn navigator for the Kuala Lumpur metro, delivered inside Telegram: you search a destination with the bot (`/cari KLCC`), open the Mini App, and follow live route guidance built on OpenStreetMap data. It works today for real -drivers, and it has real limits. This page lists what araharah does **not** do well — -stated plainly, for two readers: a driver deciding whether araharah fits today's trip, +drivers, and it has real limits. This page lists what arah² does **not** do well — +stated plainly, for two readers: a driver deciding whether arah² fits today's trip, and a fork deciding what they are inheriting. Nothing here is a hidden defect; every claim names its evidence, and none of it is fixed by asking harder. ## Data coverage -araharah routes over a snapshot of OpenStreetMap, and inherits OSM's gaps honestly: +arah² routes over a snapshot of OpenStreetMap, and inherits OSM's gaps honestly: - **No traffic awareness.** There is no live traffic feed; the ETA is a distance-and-speed estimate from the route model, not a prediction. diff --git a/web/index.html b/web/index.html index 560865d..ac21d34 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - araharah + arah² diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index fab072c..e617cc3 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -126,7 +126,7 @@ export const strings: Record = { ctaAskViaChat: "ASK VIA CHAT", ctaLocationSettings: "LOCATION SETTINGS", deniedHeadline: "Location is switched off", - deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.", + deniedBody: "arah² guides you from where you physically are. Without location there is no route to draw.", deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.", lostRegister: "SYS // POSITION", lostHeadline: "Signal lost", @@ -193,7 +193,7 @@ export const strings: Record = { ctaAskViaChat: "MINTA VIA CHAT", ctaLocationSettings: "TETAPAN LOKASI", deniedHeadline: "Lokasi dimatikan", - deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.", + deniedBody: "arah² memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.", deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.", lostRegister: "SYS // POSITION", lostHeadline: "Isyarat hilang", diff --git a/web/src/main.ts b/web/src/main.ts index dfe8518..6fe8219 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -233,7 +233,7 @@ async function boot(): Promise { if (titleEl !== null) { titleEl.textContent = destination !== null ? strings[locale].titleRoute.replace("{name}", destination.name) - : "araharah"; + : "arah²"; } const recenter = createRecenterControl(mainEl, locale, () => {