Files
arah2/docs/BUDGETS.md
T

227 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<token>` + `chat:<chat_id>`), TTL 900 s | 1,000/day | see the daily estimate | `kv-writes` |
| KV writes — location pin (chat flow) | 1 write (`loc:<chat_id>`), TTL 300 s | 1,000/day | see the daily estimate | `kv-writes` |
| KV writes — guidance envelope (POST /api/guidance) | 1 write (`guidance:<id>`), 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: __________________
## 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.