#Scope contract
FILE_ISOLATION: Modify only files inside the scope the task names. Reading elsewhere is allowed; writing outside it is not, and a needed out-of-scope change is reported, not made.
SIGNATURE_PINNING: Before implementing, write the exact signatures you will add or change (name, parameters, return type). Implement to those signatures; if one must change, say so before changing it.
TYPE_CONTRACTS: Every public function carries explicit parameter and return types. No any, untyped dict, or interface{} at a module boundary.
#Purpose
Rules for turning "it should be fast" into numbers the build enforces. A performance regression that is not measured on every change is found by a customer, weeks later, in a version nobody can bisect. A budget is a committed table of limits, a script that reproduces the measurement, and a CI step that fails when a number crosses its line.
This standard covers the process. What to optimise once a budget fails is
Performance/optimization, Performance/queries and Performance/memory.
#Budgets are a table in the repo, not a wiki page
yaml# perf/budget.yaml — the contract. Reviewed like code.
scenarios:
api-search:
command: node perf/bench.js api-search
p50_ms: 45
p95_ms: 180
rss_mb: 220
render-dashboard:
command: node perf/bench.js render-dashboard
p50_ms: 120
p95_ms: 400
rss_mb: 350
tolerance_pct: 5 # noise allowance before a breach counts
runs: 5 # per scenario, per CI job; median is compared
- Every scenario declares
p50_ms,p95_msandrss_mb. Latency without a memory cap lets a change trade RSS for speed until the pod is OOM-killed. - p95 is the user's experience; p50 is the trend. Both are required — a flat p50 with a doubling p95 is a lock contention bug, not noise.
- Budgets are set from a measured baseline with headroom, not from hope. A budget nobody has ever met is ignored within a week.
- Raising a budget is a code change with a reviewer, a reason in the commit and a link to what got slower. "CI was red" is not a reason.
#The benchmark script is reproducible or it is decoration
js// perf/bench.js — same corpus, same warm-up, same output shape every run
import { performance } from "node:perf_hooks";
import { scenarios } from "./scenarios.js";
const [name] = process.argv.slice(2);
const run = scenarios[name];
const samples = [];
for (let i = 0; i < 20; i++) await run(); // warm-up: JIT, caches, pools
for (let i = 0; i < 200; i++) {
const t = performance.now();
await run();
samples.push(performance.now() - t);
}
samples.sort((a, b) => a - b);
const pct = (p) => samples[Math.floor(samples.length * p)];
console.log(JSON.stringify({
scenario: name,
p50_ms: +pct(0.5).toFixed(2),
p95_ms: +pct(0.95).toFixed(2),
rss_mb: +(process.memoryUsage().rss / 1048576).toFixed(1),
node: process.version, commit: process.env.GITHUB_SHA ?? "local",
}));
- Fixed corpus committed under
perf/corpus/(or generated by a seeded script,perf/corpus.js --seed 42). A benchmark that reads production data measures the data, not the code. - Warm-up before sampling; discard it. The first request pays for JIT, connection pools and cold caches and belongs to no percentile.
- Output is one JSON line per scenario with the fields the budget names,
plus
nodeandcommit. Prose output cannot be compared by a script. - Never sample with
Date.now(); useperformance.now()orprocess.hrtime.bigint(). - Measure RSS after the run, not before, and after a forced
gc()when the runtime allows it (node --expose-gc), so the number is retained memory rather than garbage the collector had not reached yet.
#Baseline: committed, dated, from a named machine
json{
"captured": "2026-09-13",
"machine": "ci-runner ubuntu-24.04 4 vCPU 16 GB",
"commit": "a1b2c3d",
"scenarios": {
"api-search": { "p50_ms": 41.3, "p95_ms": 162.0, "rss_mb": 204.5 },
"render-dashboard": { "p50_ms": 108.9, "p95_ms": 355.2, "rss_mb": 312.0 }
}
}
perf/baseline.jsonis the median of at least three full runs on the machine class CI uses. A laptop baseline compared against a CI runner fails on every push, so the gate gets disabled.- Record
captured,machineandcommit. A number without its origin cannot be re-derived when the runner image changes. - Re-capture the baseline deliberately (
npm run perf:baseline) when the runner changes or a budget is raised — never as a side effect of a green build.
#The gate fails the build
yaml# .github/workflows/perf.yml
- run: npm ci
- run: node --expose-gc perf/bench.js api-search > perf/out/api-search.json
- run: node --expose-gc perf/bench.js render-dashboard > perf/out/render-dashboard.json
- run: node perf/compare.js perf/budget.yaml perf/baseline.json perf/out/ # exit 1 on breach
js// perf/compare.js — the only logic: number vs limit, with tolerance
for (const [name, limit] of Object.entries(budget.scenarios)) {
const got = results[name];
for (const key of ["p50_ms", "p95_ms", "rss_mb"]) {
const cap = limit[key] * (1 + budget.tolerance_pct / 100);
if (got[key] > cap) fail(`${name}.${key} ${got[key]} > ${limit[key]} (+${budget.tolerance_pct}%)`);
}
}
- Two comparisons, both required: against the budget (absolute cap) and
against the baseline (relative regression, for example more than
tolerance_pctslower). The budget catches slow drift; the baseline catches the single bad commit. tolerance_pctabsorbs runner noise. Measure the noise first: run the benchmark ten times on an unchanged commit and set tolerance above the spread. A tolerance guessed at 1% fails randomly and gets ignored.- The gate blocks merge. A
continue-on-errorperf job is a dashboard, and dashboards are not read. - Post the table to the PR (
p50/p95/rss, baseline, delta) so a reviewer sees the cost of a change without opening CI logs.
#Scenarios cover what users pay for
| Scenario type | Example | Why it earns a budget |
|---|---|---|
| Hot request | GET /v1/search with the 95th-percentile query | Most traffic, most exposure |
| Heavy request | Report export with 50k rows | Where memory caps are breached |
| Cold start | Process boot to first successful response | Autoscaling and deploys pay it |
| Long session | 500 sequential operations, RSS sampled each 50 | Finds leaks a single run hides |
| Client render | Time to interactive on the main dashboard | The number users actually feel |
- Three to six scenarios. A budget file with forty scenarios is maintained by nobody, and the important ones drown.
- Each scenario has an owner named in
budget.yaml; a breach pages a person, not a channel. - Long-session scenarios compare RSS at the end against the start; growth
above the cap fails even when latency is fine. →
Performance/memory
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Budget only in the wiki | Nothing enforces it | perf/budget.yaml, gate in CI |
| Latency budget without RSS | Speed bought with memory until OOM | rss_mb on every scenario |
| Benchmark against live data | Measures the data, not the code | Committed or seeded corpus |
| No warm-up | p50 includes JIT and cold caches | Discard warm-up runs |
| Single run compared | Noise reads as regression or masks one | Median of runs, tolerance_pct |
| Baseline from a laptop | Fails on CI every time, gate disabled | Capture on the CI machine class |
continue-on-error: true on perf job | Red is decorative | Gate blocks merge |
| Raising the budget to go green | The contract becomes whatever the code does | Reason + reviewer per raise |
| Prose benchmark output | Nothing can compare it | One JSON line per scenario |
| Forty scenarios | Unmaintained, important ones drown | Three to six, each owned |
Date.now() for timing | Millisecond granularity, clock jumps | performance.now() |
#Checklist
-
perf/budget.yamlcommitted withp50_ms,p95_msandrss_mbper scenario -
perf/bench.jsuses a committed or seeded corpus, warms up, and prints one JSON line per scenario -
perf/baseline.jsonrecordscaptured,machineandcommit, median of three runs on the CI machine class -
tolerance_pctset above measured runner noise, not guessed -
perf/compare.jschecks results against both budget and baseline and exits non-zero on breach - The perf job blocks merge; no
continue-on-error - Results table posted to the PR with baseline and delta
- Three to six scenarios, each with a named owner
- At least one long-session scenario compares end RSS against start
- Every budget raise is a reviewed commit with a stated reason