From 887b446955575e53900128de7a4b1e67b8d5f4be Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 05:06:30 +0800 Subject: [PATCH] =?UTF-8?q?docs(07-04):=20the=20checked-in=20BUDGET=20RECO?= =?UTF-8?q?RD=20=E2=80=94=20real=20measured=20numbers=20beside=20the=20fre?= =?UTF-8?q?e-plan=20ceilings=20(D-06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the full ceilings table (Cloudflare Workers limits / KV limits / pricing, sourced) - current measurements: A* p95 83.989 ms vs 8 ms ladder budget (the accepted c3 class, negative headroom printed), BA9 79,815/204,800 gz, app bundle 479,178/500,000 gz, KV write classes (cron = 0, source-proven), golden corpora exit 0 - the per-request-class subrequest inventory (source-derived, not a live Workers Logs metric) - the DISCOVERED finding: KV list rate 1,440/day (60 s cron x 1 list/tick) OVER the 1,000/day ceiling — documented with options, decision deferred to the 07-06 launch gate - the c3 cross-metro escalation restated; honest Statement; unsigned signature block --- docs/BUDGETS.md | 217 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/BUDGETS.md diff --git a/docs/BUDGETS.md b/docs/BUDGETS.md new file mode 100644 index 0000000..1027778 --- /dev/null +++ b/docs/BUDGETS.md @@ -0,0 +1,217 @@ +# Budgets + +arah² runs on the Cloudflare **free plan** — the $0 law is a product constraint, not an +aspiration. This is the checked-in budget record — the BUDGET RECORD (07-04, D-06): every +current number +MEASURED (or honestly marked `not measured` / `not measured outside --live`) and set +beside its documented free-plan ceiling. Re-derive it with: + +```bash +npm run budget-audit # the five required gates + the comparison table +npm run budget-audit -- --live # + the live cache-header probe (exit 2 without a live origin) +``` + +The measuring gates live in `scripts/lib/budget-gates.mjs`; the orchestrator is +`scripts/budget-audit.mjs`. Nothing in any `src` tree imports either — both are +maintainer-time repo tools. + +| Field | Value | +| --- | --- | +| Measurement date | 2026-10-24 | +| Commit SHA | `bbf003f` (audit run against this tree) | +| Measurement host | the maintainer's Linux dev box (shared — documented run-to-run CPU variance ≈ 2x) | +| Command | `npm run budget-audit` — `BUDGET AUDIT CLEAN`, exit 0 | + +--- + +## Free-plan ceilings + +Every ceiling below is a documented Cloudflare limit — the SOURCE column cites the page +(`Cloudflare Workers limits` / `Cloudflare KV limits` / `Cloudflare Workers pricing`): +developers.cloudflare.com/workers/platform/limits · developers.cloudflare.com/kv/platform/limits · +developers.cloudflare.com/workers/platform/pricing. + +| Resource | Free-plan ceiling | Source | +| --- | --- | --- | +| Worker HTTP CPU | 10 ms/invocation | Workers limits | +| Cron CPU | 10 ms/invocation | Workers limits | +| Worker subrequests | 50/request | Workers limits | +| Worker requests | 100,000/day | Workers pricing | +| Worker memory | 128 MB/isolate | Workers limits | +| Worker bundle | 64 MiB uncompressed | Workers limits | +| Static assets | free and unlimited requests; 20,000 files/version; 25 MiB/file | Workers limits | +| KV reads | 100,000/day | KV limits | +| KV writes to different keys | 1,000/day | KV limits | +| KV writes to same key | 1/second | KV limits | +| KV list requests | 1,000/day | KV limits | +| KV storage | 1 GB/account and namespace | KV limits | +| KV operations/invocation | 1,000 | KV limits | +| Workers Logs Free | 200,000 events/day; 3-day retention | Workers pricing | + +## Current measurements + +`Headroom = budget − current`. A negative headroom is printed as measured — never +rounded away, never hidden in a footnote. + +| Measurement | Current | Budget | Headroom | Evidence | +| --- | --- | --- | --- | --- | +| A* CPU p95 (local ladder, full 25-pair sample) | 83.989 ms (runs today: 67.5–84.0 ms; documented band 38–93 ms) | 8 ms ladder budget (10 ms free ceiling) | **−76.0 ms** — the accepted c3 class (see below) | `route-cpu` | +| Route payload (BA9 gz sum: fixtures + routing/route/guidance sources) | 79,815 gz bytes | 204,800 gz bytes (200 KiB) | +124,985 | `bundle-budget` | +| Whole-app bundle (`web/dist/assets/*.js`, gz-summed) | 479,178 gz bytes (3 hashed assets) | 500,000 gz bytes | +20,822 | `bundle-budget` | +| Worker bundle (uncompressed) | not measured — `bot/dist` absent (wrangler builds in memory on deploy) | 64 MiB | n/a | `bundle-budget` | +| KV writes — session mint (POST /api/session) | 2 writes (`session:` + `chat:`), TTL 900 s | 1,000/day | see the daily estimate | `kv-writes` | +| KV writes — location pin (chat flow) | 1 write (`loc:`), TTL 300 s | 1,000/day | see the daily estimate | `kv-writes` | +| KV writes — guidance envelope (POST /api/guidance) | 1 write (`guidance:`), TTL 7,200 s | 1,000/day | see the daily estimate | `kv-writes` | +| KV writes — **cron guidance tick** | **0 writes** (source-proven) | 1,000/day | full | `kv-writes` | +| KV writes — daily estimate (derived) | ≈ 4 writes per guided chat-session (2 mint + 1 envelope + 1 pin share; deletes excluded) — 1,000/day ≈ **250 guided chat-sessions/day**; private beta (≈ 5–10 drivers) is ≪ the ceiling | 1,000/day | large | derived (formula stated) | +| KV list requests | **1,440/day derived** (60 s cron × 1 `list` per tick) | 1,000/day | **−440/day — OVER** (discovered finding; see below) | source derivation | +| Static asset cache headers (live) | not measured in this run — the deployed origin serves a bundle older than `web/dist` (the probe reads HTTP status first and reports deployment drift); the HTML entry observed revalidating: `cache-control: public, max-age=0, must-revalidate` | the `_headers` policy | — | `cache-headers` (--live) | +| Subrequests per request class | see the dedicated table below | 50/request | ≥ +48 per class | `subrequests` | +| Golden route corpus (`routing:corpus`) | exit 0 — corpus gate PASS, gz 16,576 B / 102,400 B budget | green (SC4.a) | — | `golden-corpus` | +| Golden search corpus (`search:corpus`) | exit 0 — top3 ≥ 90% PASS, refusal margin 0.643 < 0.72 | green (SC4.b) | — | `golden-corpus` | + +## Subrequests per request class + +The 50-subrequests-per-request ceiling has a per-request-class inventory beside it. The +counts are a **source-derived inventory, not a live Workers Logs metric**; where the live +metric cannot be produced, the row reads `not measured outside --live` and no number is +substituted. KV operations are **NOT subrequests** — Cloudflare counts them against the +separate KV-operations ceiling (1,000/invocation); they are tracked in the KV-write rows above. + +| Request class | External subrequests | Budget (50/request) | Headroom | Source | +| --- | --- | --- | --- | --- | +| POST /telegram/webhook (grammY update) | ≤ 2 per update (reply / photo-with-text-fallback) — update-dependent; not measured live | 50 | ≥ +48 | `bot/src/bot/commands/start.ts` (`ctx.reply` / `replyWithPhoto` fallback) | +| POST /api/session | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleSession` — HMAC validation is local; KV ops are not subrequests | +| GET /api/location | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleLocation` — KV reads only | +| POST /api/location-fallback | 1 external subrequest (Telegram sendMessage) | 50 | +49 | `bot/src/bot/handlers/location-fallback.ts` (`bot.api.sendMessage`) | +| POST/GET/DELETE /api/guidance | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleGuidance` — KV only | +| POST /api/route | **0 external subrequests** | 50 | +50 | `bot/src/routing/worker-router.ts` — the road graph comes from the bound R2/KV binding, never fetched; the A* search is pure CPU | +| scheduled (cron guidance tick) | 1 external subrequest **per DUE envelope** (Telegram sendPhoto) — the per-tick total depends on the number of live due envelopes: **not measured** | 50 | ≥ +49 per envelope | `bot/src/webhook.ts` `runGuidanceCronTick` (`api.sendPhoto`) — **the ONLY recurring external-subrequest class** | + +The enforcement point is `runSubrequestClassGate`: it derives the route table FROM +`bot/src/webhook.ts` and FAILS the audit when a route class has no inventory row — the +table must follow the code, not the reverse. + +## The cron writes zero + +The guidance cron tick (`scheduled()`, every minute) performs **zero KV writes** — not +"we believe", but a source-proven law: `runKvWriteClassGate` extracts the +`runGuidanceCronTick` function body from `bot/src/webhook.ts` and asserts the body +contains no `.put(` and no `.delete(`; the evidence prints the count explicitly +(`cron write count: 0`). The tick **lists** the `guidance:` prefix, **reads** each +envelope defensively, projects the due turn-cards (pure CPU), and **sends** to Telegram — +and does nothing else. This matters because the cron is the one code path that runs +without a user request: a stray write there would burn the free plan's 1,000-writes/day +budget with no user watching (T-07-18). The structural pin also lives in +`bot/test/guidance-cron.test.mjs` (the zero-write invariant) — the audit gate is the +budget-side re-check of the same law. + +## Golden corpora + +The ONE `npm run budget-audit` command also re-runs the golden corpora — they are never +re-implemented, only spawned as child processes by the `golden-corpus` gate, so the budget +record and the corpora can never disagree (the VALIDATION map names this gate as the +enforcement point for SC4.a / SC4.b): + +- `routing:corpus` — the golden route pairs (RT1–RT10 classes): **exit 0 in this run** + (corpus gate PASS; gz 16,576 B / 102,400 B budget; multi-route pairs 8 / floor 4). +- `search:corpus` — the golden search queries (SG vectors + negatives): **exit 0 in this + run** (threshold top3 ≥ 90.0% PASS; refusal margin 0.643 < 0.72; negatives refused 7/7). + +A failing corpus FAILS the gate carrying that child's own failure output verbatim — the +record then cannot claim the corpora are green, because the gate just re-proved them. + +## The KV list budget — a DISCOVERED over-ceiling finding (07-04) + +The audit's source derivation found one over-ceiling number that the launch gate must see: + +- **Current:** the cron trigger is `*/1 * * * *` (60 s scan, D-11's window) and every tick + makes exactly one `env.chat_sessions.list(...)` call ⇒ **1,440 list requests/day derived**. +- **Ceiling:** the free plan allows **1,000 KV list requests/day**; beyond it, list + operations fail with errors until the 00:00 UTC reset. +- **Consequence:** every UTC day after ~16 h 40 m, the cron's KV list fails — the tick + degrades (the list error surfaces as a waitUntil rejection; no cards fire until UTC + midnight). Benign for a morning driver, not benign for an evening one. +- **Why this plan does not auto-fix it:** the tick spacing is coupled into the due-card + math — `projectTurnCards`' due rule is `nowSec % CARD_CADENCE_S < SCAN_TICK_S` with + `CARD_CADENCE_S = 180` and `SCAN_TICK_S = 60`. Moving the trigger to 120 s (D-11's + window upper edge, 720 lists/day) breaks the divisibility (`180 % 120 ≠ 0`) and retimes + the designed 3-minute card cadence — a product-timing decision, not a config-only + change. +- **The maintainer's options:** (i) retune the pair (e.g. `CARD_CADENCE_S = 240` with a + 120 s tick ⇒ 720 lists/day, cards every 4 min) — a deliberate product decision with + test updates in `bot/test/guidance-cron.test.mjs`; (ii) accept the documented + degradation; (iii) Workers Paid — breaks the $0 law, requires sign-off. This is + recorded as an open decision for the 07-06 launch gate. + +## Cross-metro CPU degradation (the c3 class) + +The honest escalation from Phase 6 (plan 06-04), restated as the record's CPU row: + +- Short product routes (2–8 km) measure **2–8 ms** on a quiet isolate — inside the 8 ms + ladder budget. The full 25-pair sample (the corpus demo-script pairs plus 10 + quadrant-sampled cross-city worst cases) measures **p95 38–93 ms** across runs — over + budget, and this box's shared-load variance is ≈ 2x. +- The mitigation ladder was exhausted WITHIN the $0 law: (a) tighten `DRIVABLE_CLASSES` + + rebuild — **rejected on correctness** (12/25 pairs unreachable; the dropped classes are + the fine mesh tying landmarks to the arterials), reverted; (b) bidirectional A* — + **kept** (provably optimal, no worse on the p95 class, better on short product routes); + (c) Workers Paid — **rejected** ($5/mo breaks the $0 law; needs maintainer sign-off). +- The DECIDED posture (c3, now): cross-metro CPU exhaust → the Worker's 1102 path → the + client's fail-honest null → the **router-pending state** — designed degradation, never + a fabricated route. The committed upgrade lever is the **ALT landmark preprocessing** + (build-time, still $0 at runtime) — a NEXT-MILESTONE item, not this phase. +- Evidence: `routing/scripts/measure-route-cpu.mjs`'s LADDER HISTORY (in-file) and the + 06-04 plan summary (`.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md`). +- The `route-cpu` gate therefore compares the re-run p95 against a 200 ms DRIFT GUARD + (≈ 2x the worst documented run): inside the band = the documented accepted state (the + 8 ms comparison is still printed, headroom negative); beyond it = uncharted regression + and the gate FAILS. + +## Static assets + +The cache policy is `web/public/_headers`, consumed verbatim by the Worker's +`[assets]` binding (wrangler `directory: "../web/dist"`; Vite copies `public/` into +`dist/`, so the file lands at the served root — verified in this plan): + +```text +/assets/* + Cache-Control: public, max-age=31556952, immutable + +/ + Cache-Control: public, max-age=0, must-revalidate +``` + +- `/assets/*` are Vite's content-hashed outputs — a hashed URL's bytes can never change, + so they are immutable for a year (315,569,52 s = 365 days). +- The HTML entry is NOT hashed (it points at the current hashed assets) — it must + revalidate on every visit, or a stale entry would point at deleted asset files. +- The glyphs/logo (`/glyphs/*`, public copies) are NOT hashed — they stay on the default + policy; only `/assets/*` is immutable. +- **Observed live headers: not measured in this run** — the deployed origin serves an + older bundle, so the probe reports deployment drift (exit-2 class) instead of + interpreting a 404's absence of headers as a violation. `CF-Cache-Status` is + probabilistic per Cloudflare's own footnote — when observed, it is quoted verbatim, + never interpreted as a guarantee. Re-run at the launch gate after the redeploy. + +## Statement + +Measured **in this run**: the A* CPU ladder (local, 25 pairs — a LOCAL measurement; the +live Workers Logs CPU number is a DIFFERENT measurement and neither substitutes for the +other), both bundle ceilings (BA9 route payload, whole-app app bundle), the KV write +classes with the cron's zero-write count, the static-asset file count, and both golden +corpora. **Derived from source** (not live): the per-class subrequest inventory, the KV +daily write estimate, and the KV list rate (1,440/day — over ceiling; open decision). +**Not measured**: the live Worker CPU metric, cron CPU, worker memory, the Worker bundle +(`bot/dist` absent), KV reads/writes/storage against their daily ceilings, KV +operations/invocation, Workers Logs volume, and the live cache headers on a CURRENT +deploy (deployment drift). Nothing is estimated optimistically; every negative headroom +is printed where it was found. The record is re-derived at the launch gate (07-06) after +the redeploy, when the live header probe can observe the shipped `_headers` policy. + +## Signature + +The launch gate (07-06) signs this record after the redeploy re-run: + +- Maintainer: __________________ +- Date: __________________