Benchmarks and codebase metrics
Status: source candidate for the first slice (decision):
pnpm metrics, pnpm metrics:compare and the CI metrics job on pushes to
main. Rows marked planned in the catalog are not collected yet; the
roadmap owns their order.
Every change to Maia should show what it did to size, speed and codebase
health, better or worse, and a regression should ask for a decision instead
of slipping in. The codebase measures itself: the catalog below is code
(scripts/metrics/catalog/catalog.mjs), every snapshot is a JSON file, and
the policy that fails or warns is the same locally and in CI.
Commands
pnpm metrics # size, health, startup -> .artifacts/metrics/<sha>.json
pnpm metrics --suite node,observation # the existing Node benchmarks (minutes)
pnpm coverage # line and branch coverage per package (minutes)
pnpm metrics --suite all --build # every suite but terminal, after building
pnpm metrics --suite terminal [--quick] # tmux and GNU screen, Linux (10 minutes; --quick under one)
pnpm metrics:compare # HEAD against its merge base with origin/main, same machine
pnpm metrics:compare --base origin/main~3 --runs 20
pnpm metrics:compare --snapshot <file> # against a stored snapshot, as CI does
pnpm metrics writes .artifacts/metrics/<sha>.json (<sha>-dirty.json
for an uncommitted tree; ignored by Git) and prints a table. A later run of
other suites on the same revision and machine adds to that snapshot.
pnpm metrics:compare builds the base in a temporary worktree under the
system temp directory (installed from the shared pnpm store, built through
this checkout's Turborepo cache), measures both trees on this machine, prints
the comparison, writes compare-<base>-<head>.json beside the snapshots,
and removes the worktree, also on Ctrl-C (--keep-worktree keeps it). Each
side's subprocesses get their own throwaway MAIA_HOME; nothing touches a
running Node or ~/.maia. The command exits 1 when a metric fails its
policy and 130 when interrupted.
Wall time on a busy machine is noisy: both commands say so when the load
average passes 70% of the CPUs. Compare wall time with
pnpm metrics:compare only, never across machines or runs.
What the large projects do, and what we take
| Practice | Seen in | Here |
|---|---|---|
| Gate on metrics that machine load cannot move (instruction counts, bytes, type counts); trend wall time. | rustc-perf, CodSpeed, TypeScript, Deno | exact metrics gate; timing metrics trend in CI. |
| Compare base and head on one machine, interleaved, after a warm-up. | Node.js compare.js, TypeScript perf bot, Effect bundle job | pnpm metrics:compare alternates base-head and head-base rounds. |
| Require significance, and stricter when many metrics are compared. | Node.js (Welch's t-test, two stars), rustc-perf (IQR fence) | Welch's t-test, p < 0.01, a minimum effect per metric, a 95% interval. |
| A threshold per metric, matched to its noise. | rustc-perf triage, Bencher | warn and fail in the catalog. |
| Shared CI runners vary by 10-20%; gate wall time on dedicated hardware or not at all. | github-action-benchmark, rustc-perf deployment, Bun | CI never fails on wall time. |
| Accept a regression explicitly, never by quietly raising a threshold. | rustc (perf-regression-triaged), CodSpeed | A Metrics-Accepted commit trailer. |
| Keep history as plain JSON, without a service. | Deno, github-action-benchmark | JSON snapshots; Actions artifacts in CI. |
| Check every merge after it lands, with a named triage owner. | rustc-perf triage | Every push to main; each metric names an owner. |
| Size budgets per artifact, compressed size included. | size-limit, Effect | Raw and gzip client bundles, standalone and pack sizes, with no new dependency. |
Catalog
Owner names the code that owns the number. Noise: exact values repeat
for the same source on one platform, so any change is real; per platform
values (packs with native binaries, installed packages) compare only on the
same platform; timing values are wall time; trend values come from model
runs, compare only with a run of the same workload and are reported, never
failed. Warn and fail thresholds count
a change when it is worse by both amounts. Wall time warns only when the
change is also significant (p < 0.01) on one machine, and its change never
fails. terminal.node.base.bytes, the Node's idle memory, fails instead when
a run measures more than 10% above the accepted value of
node.idle.memory.bytes, whatever the base measured
(D-177).
Suite size
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
cli.standalone.bytes | Standalone CLI bundle, apps/cli/dist/bin/maia.mjs | apps/cli | bytes | lower | exact | +1 KiB and +0.5% | +16 KiB and +5% |
node.standalone.bytes | Standalone Node, launcher plus bundle, services/node/dist/bin/maia-node.mjs and maia-node-main.mjs | services/node | bytes | lower | exact | +1 KiB and +0.5% | +16 KiB and +5% |
package.pack.bytes per package | npm pack tarball of a public package | release | bytes | lower | exact, per platform | +1 KiB and +0.5% | +16 KiB and +5% |
package.pack.unpacked_bytes per package | Unpacked npm pack contents of a public package | release | bytes | lower | exact, per platform | +1 KiB and +0.5% | +16 KiB and +5% |
package.pack.files per package | Files in the npm pack of a public package | release | count | lower | exact, per platform | +1 | |
client.web.js.bytes | Every web JavaScript bundle of the client export | apps/client | bytes | lower | exact | +4 KiB and +1% | +64 KiB and +5% |
client.web.js.gzip_bytes | The same bundles after gzip level 9 | apps/client | bytes | lower | exact | +2 KiB and +1% | +32 KiB and +5% |
client.web.entry.bytes | The web entry bundle the browser loads first | apps/client | bytes | lower | exact | +4 KiB and +1% | +64 KiB and +5% |
client.web.js.files | Web JavaScript bundles (chunks) of the client export | apps/client | count | lower | exact | +1 | |
protocol.declarations.files per export | Declaration files a protocol export reaches; its build enforces a ceiling | packages/protocol | count | lower | exact | +1 | |
protocol.declarations.bytes per export | Bytes of the declaration graph a protocol export reaches | packages/protocol | bytes | lower | exact | +1 KiB and +1% | +16 KiB and +5% |
repository.install.bytes | Installed package files under node_modules/.pnpm | repository | bytes | lower | exact, per platform | +1 MiB and +1% | +32 MiB and +5% |
repository.lockfile.packages | Package versions in pnpm-lock.yaml | repository | count | lower | exact | +1 | +20 |
Suite health
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
dependencies.production per package | dependencies and optionalDependencies of a package | package owners | count | lower | exact | +1 | +1 |
dependencies.development per package | devDependencies of a package | package owners | count | lower | exact | +1 | +1 |
source.baseline.entries | (rule, path) entries in the source-organization baseline | quality tooling | count | lower | exact | +1 | +1 |
source.baseline.occurrences | Violations the source-organization baseline suppresses | quality tooling | count | lower | exact | +1 | +1 |
lint.baseline.findings | Legacy lint-policy findings in the baseline | quality tooling | count | lower | exact | +1 | +1 |
tests.files per package | Test files of a package | package owners | count | higher | exact | +1 | |
tests.declared per package | Test cases declared in source (test( and it( calls) | package owners | count | higher | exact | +1 | |
tests.skipped per package | Skipped and todo tests declared in source | package owners | count | lower | exact | +1 | |
tests.conditional per package | Tests that run under a condition (runIf, skipIf, skip: <expression>) | package owners | count | lower | exact | +1 | |
tests.flaky.known | Tests named as flaky with their cause (R0b TQ0) (planned) | package owners | count | lower | exact | +1 | |
tests.owned_mocks per package | Owned-module mocks and owned double casts in the test-quality baseline (R0b TQ0, TQ1) | package owners | count | lower | exact | +1 | |
effect.gaps per package | Effect-boundary baseline occurrences outside the adapter allow-list (R0b EF2) | package owners | count | lower | exact | +1 |
Suite startup
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
cli.start.version.ms | One maia --version process, spawn to exit | apps/cli | ms | lower | timing | +2 ms and +5%, p < 0.01 | |
cli.start.help.ms | One maia help process, spawn to exit | apps/cli | ms | lower | timing | +2 ms and +5%, p < 0.01 |
Suite node
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
node.start.bootstrap.p95.ms | Node process start to bootstrap, p95 of 30 starts (startup budget) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
node.start.readiness.p95.ms | Node start to authenticated readiness, p95 of 30 starts | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
node.start.database.p95.ms | The database startup phase (SQLite open and migrate), p95 of 30 starts | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
node.request.status.p95.ms | Authenticated loopback status read through the SDK, p95 | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
node.request.cli_status.p95.ms | maia status against a running Node, p95 | apps/cli | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
node.request.mutation.p95.ms | An accepted authenticated loopback mutation, p95 (planned, RN0) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
port_lease.acquire.p95.ms | S16 port lease grant on a file database, p95 of 1,000 cycles | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.append.p95.ms | D-188 one Session write (command receipt, event, revision) on a file database, p95 of 2,000 | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.read.p95.ms | D-188 events.read of a 100-event page from a fresh cursor on a file database of 4,000 events, p95 of 200 | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.append.bytes_per_event | D-188 allocated-page growth of the file per Session write (receipt, event, revision, indexes), after a WAL checkpoint, mean of 2,000 | services/node | bytes | lower | exact | +8 B and +2%; fails at +32 B and +10% | |
events.row_overhead.bytes | D-188 journal page bytes per row (table plus its three indexes, from dbstat) minus payload bytes, after 2,000 Session writes | services/node | bytes | lower | exact | +8 B and +2%; fails at +32 B and +10% | |
events.prune.batch.p99.ms | EV0e one prune batch of 1,000 rows plus its PASSIVE checkpoint while a separate process appends every 5 ms, p99 (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.batch.max.ms | EV0e the longest prune batch plus PASSIVE checkpoint, contested and with a pinned reader (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.truncate.max.ms | EV0e the longest non-waiting TRUNCATE checkpoint, with a reader pinned and after its release, indicative only (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.append.p99.ms | EV0e append latency seen by a separate issuer during pruning, dispatch to commit over the batches it overlapped, 1,000 ms busy timeout, p99 (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.append.max.ms | EV0e the slowest append the issuer saw over a prune batch, contested and with a pinned reader (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.dispatch.lateness.max.ms | EV0e the longest an append left the issuer after its schedule, queued behind its own blocked append (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.batch.cpu.max.ms | EV0e the most CPU one prune batch used in the test worker, contested and with a pinned reader (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.loop_delay.max.ms | EV0e the longest event-loop delay of the test worker while pruning, 5 ms sampling (opt-in, warn only) | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
events.prune.append.failures | EV0e appends that failed during pruning, such as a busy timeout (opt-in, warn only) | services/node | count | lower | exact | +1 | |
hold.admission.p95.ms | S20 held admission rejection in process, p95 of 30 trials | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
hold.admission_http.p95.ms | S20 held admission rejection over authenticated HTTP from the built CLI, p95 of 20 | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
hold.fence.p95.ms | S20 stop fence including runtime end, p95 of 30 trials | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
agent.worker.cold_start.ms | Managed Pi worker spawn to its initialized event, median of 3 cold starts | packages/agent | ms | lower | timing | +1 ms and +10%, p < 0.01 | |
node.idle.memory.bytes | Node PSS after 3 idle minutes, no Session or terminal (TM0) | services/node | bytes | lower | timing | +10% | |
node.idle.cpu.ms | Node CPU time over 60 idle seconds (planned, RN0) | services/node | ms | lower | timing | +20% | |
node.idle.disk.bytes | Bytes the Node writes over 10 idle minutes (planned, RN0) | services/node | bytes | lower | timing | any growth | |
node.request.throughput.rps | Status reads per second from 8 concurrent SDK clients (planned, RN0) | services/node | rps | higher | timing | -10% | |
node.install.bytes | A server install: runtime, Node and helpers (planned, RN0) | services/node | bytes | lower | exact | +5% |
Node and native-host changes (D-182) are judged on this suite:
- What counts: the TypeScript Node and its complete process-host/helper
tree, never Pi workers. Active-workload comparisons include the hosts they
launch, alongside the idle Node baseline.
- Memory is PSS on Linux. macOS and Windows report their closest
measure, named in the snapshot, as the
terminalsuite does. - Disk is
write_bytesfrom/proc/<pid>/ioover 3 runs.
- Memory is PSS on Linux. macOS and Windows report their closest
measure, named in the snapshot, as the
- Throughput: 30 seconds after a 5-second warm-up, 5 runs. The clients share the host with the Node in both the baseline and candidate.
- Install: the unpacked bytes one platform's server install needs: the Node, its process hosts and helpers, and the Node.js runtime and Pi packages the worker needs.
- Startup: preserve the Node's startup phases,
databaseincluded. - Baseline and comparison:
- TM0 adds
node.idle.memory.bytes. The catalog (scripts/metrics/catalog/catalog.mjs) holds its accepted value, from the development host (Linux x64, 31 October 2026: three runs of 76.2–76.4 MiB on a warm compile cache; 99–102 MiB on 28 October, before the cache). Its warning needs--runs 3: with each side spread over a band about 1 MiB wide, a 10% growth reaches p ≈ 0.04 with two samples a side and p ≈ 0.002 with three. - RN0 adds the remaining planned metrics and records the TypeScript Node's values on the development host as the baseline.
- RN1 and terminal-host changes compare the same TypeScript Node before and after the change, on one machine with the alternating rounds of Comparison. Publish the table and explain each metric past its warning threshold. The terminal suite's failure rules still apply; there is no Rust Node comparison or Node cutover gate.
- TM0 adds
Suite observation
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
observation.commit_to_subscriber.p95.ms | S12a commit to subscriber after idle gaps, p95 of 100 | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
observation.idle_cpu.percent | S12a Node CPU over 60 idle seconds with every observation slot subscribed | services/node | percent | lower | timing | +0.2 percent and +25%, p < 0.01 | |
observation.attach.line.p95.ms | S12b harness delta to rendered CLI line, worst p95 of the direct and Claude harnesses at 1 and 100 Sessions | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
observation.attach.catch_up.cpu.ms | S12b attach catch-up of 1,000 durable records, median CPU of 10 warm attaches | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 | |
observation.attach.stalled_neighbor.p95.ms | S12b delta to CLI line p95 of a viewer beside a stalled subscriber | services/node | ms | lower | timing | +0.5 ms and +10%, p < 0.01 |
S12a's measurement: 100 samples each, on an Apple M4 Pro (12 cores, Node 24.19.0) under parallel test load (load average 5–32):
| Measure | Before S12a | After S12a |
|---|---|---|
| Commit to subscriber, p50 / p95 / max | 3,410 / 4,874 / 4,957 ms | 1.9 / 4.7 / 19.8 ms |
| Idle CPU per 60 s, 8 subscribers | 222 ms (165–279) | 267 ms (207–334) |
| 10,000 commits, 142 forced resumptions | not measured | none lost or doubled |
The idle row is the median and range of six alternating runs each; the spread between runs is wider than the difference.
The latency bound belongs to this benchmark alone: wall time also counts the
moments the machine gives the process no CPU, which a loaded machine stretches
to seconds. pnpm test proves the wake itself instead: its live observation
test freezes the Effect clock, so the heartbeat cannot fire, and requires every
commit to reach an idle subscriber.
S12b's budgets live here too: 100 ms delta-to-line p95 (both harnesses, 1 and
100 Sessions, and beside a stalled subscriber) and 150 ms median catch-up CPU.
test:observation-benchmark exits non-zero above them. pnpm metrics records
the values whatever that exit status, and like every timing metric warns on a
significant regression and never fails. pnpm test runs the same scenarios
and keeps every count, ordering and resync check, but holds latency and CPU to
ten times the budgets. That still catches a hang, an added wait, a viewer held
back by the stalled one or a tenfold regression, and leaves a smaller
regression to the metrics comparison. On the 12-core Linux development host,
11 quiet runs (load 3–7) measured p95 34–93 ms and catch-up CPU medians of
104–149 ms, so the catch-up budget has almost no headroom there.
Suite coverage
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
coverage.lines per package | Line coverage of the files a package's tests load (R0b TQ0) | package owners | percent | higher | exact | +1 percent | |
coverage.branches per package | Branch coverage of the files a package's tests load (R0b TQ0) | package owners | percent | higher | exact | +1 percent |
pnpm coverage builds the measured packages, then runs every package's own
test script with coverage turned on, one package at a time, and records a
snapshot. Each suite writes coverage/lcov.info: Node's built-in coverage
(--experimental-test-coverage, source-mapped for the compiled suites) for
the node:test packages, @vitest/coverage-v8 for protocol and the Node
(decision). A suite that fails reports its package as
unavailable, not as a lower number. No floor gates coverage yet, and CI does
not run it: it costs one full test run per package (3 min 22 s on a 12-core
laptop, build included), so it adds no CI minutes.
Suite dx
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
dx.check_quality.s | pnpm check:quality as run, Turborepo cache included | quality tooling | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.test_tooling.s | pnpm test:tooling | quality tooling | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.typecheck_tooling.s | pnpm typecheck:tooling | quality tooling | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.typecheck.s per package | Typecheck of one package without the Turborepo cache | package owners | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.test.s per package | Test suite of one package (from CI job timings) (planned) | package owners | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.ci.job.minutes per job | Billed minutes of one CI job (Actions API) (planned) | quality tooling | min | lower | timing | +1 min and +10%, p < 0.01 | |
dx.hook.pre_commit.s | The pre-commit hook as developers run it (planned) | quality tooling | s | lower | timing | +1 s and +10%, p < 0.01 | |
dx.hook.pre_push.s | The pre-push hook as developers run it (planned) | quality tooling | s | lower | timing | +1 s and +10%, p < 0.01 |
Suite lab
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
lab.stress.turn.ms | Completed-turn latency, Session create to settled turn; every turn a sample, median as value | quality tooling | ms | lower | timing | +50 ms and +20%, p < 0.01 | |
lab.stress.turns_per_minute | Verified terminal turns (completed, failed or past the deadline) per minute of the run | quality tooling | count | higher | timing | +1 and +20% | |
lab.stress.node.rss.bytes | Peak resident memory of a lab Node | quality tooling | bytes | lower | timing | +16 MiB and +10% | |
lab.stress.node.cpu.ms | CPU time the lab Nodes used during the run | quality tooling | ms | lower | timing | +250 ms and +25% |
pnpm dev:lab scenario stress records this suite
(dev lab); pnpm metrics does not collect
it, because it needs running lab Nodes. Each run writes snapshot.json and
manifest.json beside its results under .artifacts/lab/stress/<run>/.
--baseline <snapshot.json> compares the run with an earlier one in stored
mode, which reports timing metrics as trends and never fails them, and only
when the manifest.json beside the baseline shows the same workload: Node
count, concurrency, turns and injected failures. Another workload is refused.
An unexpected outcome, an unresolved Session or create, a failed ps or
lsof, or a leftover (a process or listening port the run did not start with)
is not a metric: it fails the run itself.
Suite agent
Planned (CE1, D-173). Two kinds of number:
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
agent.context.fixed.bytes | Fixed context per role and executor: instructions, Skill bodies and tool schemas at launch | Node | bytes | lower | per platform | +1% | past the pinned bound |
agent.task.input.tokens | Input tokens per task of the fixed task set, cached and uncached apart | Node | tokens | lower | trend | +10% | |
agent.task.output.tokens | Output and reasoning tokens per task | Node | tokens | lower | trend | +10% | |
agent.task.model_calls | Model calls per task | Node | count | lower | trend | +1 and +20% | |
agent.task.tool_calls | Tool calls per task; the replay evals fail a case over its cap | Node | count | lower | trend | +1 and +20% | over a case's cap (eval) |
agent.idle.cached_share | Cached share of input on the first call after an idle gap | Node | ratio | higher | trend | −10 points | |
agent.task.pass_rate | Tasks whose checks pass | Node | ratio | higher | trend | any drop |
agent.context.fixed.bytes needs no model call: Codex reports its prompt
through codex debug prompt-input, and the other executors' fixed context
comes from their launch options. Token totals and reported model-call counts come from each turn's usage item, tool counts from the item trace and the pass rate from each task's checks, on runs of a fixed task set in the dev lab. A turn reports usage when it completes, so CE1b first qualifies the first-call cache measurement; a measure an executor does not report, such as Claude Code's separate reasoning tokens, is reported as unavailable. A run spends model usage, so the owner approves each one and CI never
runs it; trend values compare only with a run of the same task set, models,
reasoning settings and harness versions, recorded in its manifest, and never
fail.
Suite terminal
Source candidate (TM0, D-175). It compares Maia's terminal multiplexer with other terminal multiplexers on one machine. The owner's target (D-177): in every scenario, each terminal and each pane costs less CPU, memory and disk than in tmux. Maia has no row yet: its rows and their failures against tmux come with its multiplexer (TM2b). Until then only the Node's base can fail.
| Metric | Measures | Owner | Unit | Better | Noise | Warn | Fail |
|---|---|---|---|---|---|---|---|
terminal.cpu.ms | A terminal's CPU time: every process of the tool during a scenario's window | quality tooling | ms | lower | timing | +10 ms and +20% | above tmux (TM2b) |
terminal.memory.bytes | A terminal's peak PSS on Linux, summed across the tool's processes | quality tooling | bytes | lower | timing | +1 MiB and +10% | above tmux (TM2b) |
terminal.disk.bytes | Bytes the tool's processes write to storage during a window | quality tooling | bytes | lower | timing | any growth | above tmux (TM2b) |
terminal.pane.cpu.ms | terminal.cpu.ms over the scenario's panes | quality tooling | ms | lower | timing | +10 ms and +20% | above tmux (TM2b) |
terminal.pane.memory.bytes | terminal.memory.bytes over the scenario's panes | quality tooling | bytes | lower | timing | +1 MiB and +10% | above tmux (TM2b) |
terminal.pane.disk.bytes | terminal.disk.bytes over the scenario's panes | quality tooling | bytes | lower | timing | any growth | above tmux (TM2b) |
terminal.echo.ms | Keystroke to echo through an attached client, p95, local (over SSH: planned) | quality tooling | ms | lower | timing | +1 ms and +20% | |
terminal.attach.ms | Client start until all 10 panes have drawn their first frame, median | quality tooling | ms | lower | timing | +10 ms and +20% | |
terminal.node.base.bytes | The idle Node's memory with no terminal open, as node.idle.memory.bytes measures it | quality tooling | bytes | lower | timing | +10% over the accepted value |
The comparison rules:
- A terminal's cost: the scenario's total across every process of the tool. tmux, GNU screen and other tools run no Node, so their cost is their total. Maia's rows (TM2b) will subtract the same Node idle with no terminal open for as long as the scenario runs, measured for each scenario.
- A pane's cost: that cost divided by the scenario's panes, the
terminal.pane.*rows. They count every pane the scenario opens:print-hiddencounts its visible pane too. - The whole server (MP0): after each tool's scenarios the runner prints, to stderr, one line per scenario with the tool's total across every process, servers and clients, and its cost per pane. Idle-N scenarios show what N panes cost at rest; the others show a loaded tool. It works the same for a tool loaded from outside the repository, adds no snapshot row and fails nothing.
- Against tmux: both costs must be below tmux's in the same run, and tmux counts all its processes. The check comes with Maia's rows (TM2b), and so does the rule that a run fails, whatever its numbers, if any terminal output reaches the disk (TM2a).
- The Node's base: its own line. A full run starts the checkout's built
Node with a throwaway home and profile, leaves it idle with no Session or
terminal for 3 minutes after it is ready and reads its PSS, as
node.idle.memory.bytesdoes in thenodesuite;--quickreports it unavailable. A run fails when it measures more than 10% above the accepted value ofnode.idle.memory.bytes, whatever the base measured.pnpm metrics:compare --basemeasures each checkout's own Node and shows the two side by side, a line that never fails. Every report shows the totals and the base too.
Each row's subject is the tool and the scenario, such as tmux/idle-10
(tmux/quick/idle-10 with --quick). The scenarios, the same on every
tool:
| Scenario | What the panes do | Full run (--quick) | Metrics |
|---|---|---|---|
idle-1, idle-10, idle-50 | Shells at their prompt: one visible pane, the others in windows the client does not show | 20 s window (1 s) | CPU, memory, disk |
print-visible | Colored log lines printed in the visible pane | 100 MiB (4 MiB), then 1 s | CPU, memory, disk |
print-hidden | The same lines in a hidden pane | 100 MiB (4 MiB), then 1 s | CPU, memory, disk |
redraw-4 | Four visible panes each redraw the whole screen 30 times a second, like an agent's interface: alternate screen, synchronized output (DEC 2026), 256 and true color | 20 s (1 s) | CPU, memory, disk |
scrollback | Plain lines into the scrollback | 100,000 lines (10,000), then 1 s | CPU, memory, disk |
echo | Keystrokes typed through the client, echoed by the pane's terminal | 50 keystrokes (10), p95 | terminal.echo.ms |
attach-10 | A second client attaches to ten visible panes | 5 attaches (2), median | terminal.attach.ms |
How it runs:
-
Conditions: a 200x50 client, a scrollback limit of 100,000 lines,
/bin/shwith no rc file and a minimal environment whoseHOMEis the scenario's fresh state directory. Each scenario starts its own server on a private socket in that directory, under~/.cache/maia-os/terminal($XDG_CACHE_HOME), and removes it afterwards; nothing touches another tmux server or screen session. The directory is on disk because writes to tmpfs never reachwrite_bytes. -
Client: one client is attached through every scenario, run by script(1), the PTY util-linux provides; the runner types into it and scans, then drops, its output for markers the workloads print.
-
Processes: a tool's processes run its executable and carry the scenario's
MAIA_TERMINAL_BENCHmarker: servers and clients, never the shells and workloads in its panes or the PTY harness. CPU is, per process, the larger of the live threads'schedstatsum (exact) and the process total instat(10 ms steps), which keeps the time of threads that exited; a process's delta never goes below zero. Memory is the peak of the summed PSS (smaps_rollup, sampled every 250 ms) and disk iswrite_bytes(io). A process that exits inside a window leaves its usage out. A thread that exits inside a window can still read up to one tick low (10 ms), which TM2b's Rust rows must account for. -
Workloads:
scripts/metrics/terminal/workload.mjswrites the same bytes for every tool and needs no network. Each waits on a FIFO, so the window starts when the runner opens it, not when a slower tool gets to it. -
Tools: each multiplexer is a JSON file of command templates that one driver runs:
scripts/metrics/terminal/tmux.jsonandscreen.jsonare complete examples. Its keys:name, andexecutables: paths or names on the tool'sPATH, whose processes count;environmentandfiles: variables, and files written into the state directory, such as a configuration file;serverandready, optional: a long-running server, and a command that succeeds once it answers;open,visiblePaneandhiddenPane: acommandthat adds a pane, whose ID comes fromcapture(a dotted path into the JSON it printed, or""for its whole output) or from apanetemplate;run,attachandstop: type a line in a pane, start a client, and the commands that end the session.
Templates may use
{state},{session},{columns},{rows},{scrollback},{shell}and every captured value;runadds{pane}and{line}; pane steps add{index}(the panes added so far) and, forvisiblePane,{target}and{direction}(rightordown) of a breadth-first split. A placeholder is lowercase letters, so tmux's#{pane_id}stays as written. -
Another multiplexer:
MAIA_TERMINAL_ADAPTER=<file>names such a file kept outside the repository. Its rows go to a file inMAIA_TERMINAL_RESULTS(default:maia-terminal-resultsin the system temp directory), which must be outside the repository; they never reach the snapshot. -
Node base: a started Node holds startup garbage, more than twice its settled size, until a collection about 100 s in: 245 MiB of PSS, then 99–102 MiB, or 76 MiB once the compile cache is warm, on the development host (28 and 31 October 2026). The runner starts the Node once in the same profile first, uncounted, to fill the cache, and prints that cold start (time to ready) on stderr without gating it. Hence the 3 idle minutes, counted from the ready record the Node prints, which each measurement and each round of a comparison adds. The Node gets the tools' minimal environment. A Node that exits early, or is not ready within 60 s, makes the row unavailable with the reason.
-
Planned: Maia's rows (TM2b); echo over SSH.
-
Platforms: Linux. macOS and Windows report the suite unavailable until they get their closest memory measure (physical footprint, private working set), named in the snapshot.
-
Who runs it:
pnpm metricsand--suite alldo not collect it, because it starts other tools; a person or the dev lab names it. CI does not run it.
Screen engines (TM0)
Measured once, for the owner's pick before TM2b
(D-175), on the development host (12 vCPU, Linux
x64, rustc 1.99.1, tmux 3.6), 28–29 October 2026. A harness outside the
repository links each candidate crate into a binary of its own, beside a
baseline binary with no engine, and feeds each the same bytes: the suite's
workloads with the terminal's newline translation applied. No crate joins a
Cargo.toml until TM2b adds the one the owner picks. Each throughput run waited
for a 1-minute load below 4.
| Crate | License | Crates in its tree | mixed, MiB/s | redraw, MiB/s | lines, MiB/s | Memory per terminal | Cell copy, median (p95) | Binary growth | DEC line drawing |
|---|---|---|---|---|---|---|---|---|---|
alacritty_terminal 0.26.0 | Apache-2.0 | 27 | 52.4 | 121.5 | 47.7 | 46.7 MiB | 50 µs (71) | +109,016 B | yes |
avt 0.19.0 | Apache-2.0 | 2 | 30.3 | 65.6 | 29.1 | 32.1 MiB | 33 µs (49) | +114,248 B | yes |
vt100 0.18.1 | MIT | 6 | 24.2 | 88.6 | 24.3 | 62.0 MiB | 155 µs (250) | +89,344 B | no |
| tmux 3.6, for reference | ISC | — | — | — | — | 10.8 MiB | — | — | yes |
- Conditions: a 200 × 50 screen with 10,000 lines of scrollback, D-175's default, fed in 16 KiB reads. Every transitive license is Apache-2.0, MIT, Zlib or Unlicense.
- Throughput: the median of 5 runs, a fresh terminal each run;
mixedis the suite's 100 MiB print,redraw600 full-screen frames on the alternate screen,lines100,000 lines through the scrollback. Engine times are parse-only wall time with 10,000 lines of history: the print takes 1.9 s inalacritty_terminal, 3.3 s inavtand 4.2 s invt100. For context only, tmux spent 8,169 ms of CPU on the same print in the suite's full run, the whole tool with 100,000 lines of history and a client attached. These figures do not establish comparative speed or D-177 compliance. - Memory: PSS growth after filling the scrollback. For
lines, the per-terminal figure rounded identically to 0.1 MiB with 1 and 10 engine instances in one process;mixedwas measured with 1. Dividing by the 2,000,000 cells gives an estimated 24 (alacritty_terminal), 17 (avt) and 32 bytes (vt100) a cell; memory can vary with content, such as combining characters. tmux, measured separately as its server's PSS over an empty pane with no client attached, holds the same 10,000 lines in 10.8 MiB (lines) and 10.5 MiB (mixed), a third to a sixth of the candidates' figures. - Snapshot: copying the 10,000 visible cells (character, colors and
four flags) into a plain array, 1,000 times, after 30 frames. All three
report changed rows:
alacritty_terminalthroughdamage(),avtthrough adirtyobserver,vt100by diffing against a copy of the previous screen. - Binary growth: a stripped release binary (fat LTO,
panic = "abort") minus the baseline's 343,864 bytes.alacritty_terminal's tree includes PTY and event-loop crates no feature turns off. - Correctness: each fixture also ran in tmux (private socket, the same
size and history), and its capture was compared row by row, with the tabs
it keeps expanded to 8-column stops and trailing whitespace removed. All
three engines match its final text rows on colored log lines with wraps
(291 rows), on scrollback (1,001 rows) and on alternate-screen frames with
synchronized output and 256 and true color (50 rows). Colors, attributes
and synchronized presentation were not checked. On a fixture of edge cases (wide
and combining characters, tabs, cursor moves, erase, insert and delete,
saved cursor, a scroll region) they match every visible row but DEC line
drawing: tmux keeps those cells in the line-drawing set, and
alacritty_terminalandavtdraw them as box characters;vt100ignores the character set and draws the letters. Scrollback differs in one case: tmux also saves the lines that scroll out of a region whose top is below the screen's top, and the three engines save only lines leaving a region that starts at the top row.
Snapshot maia.metrics/v1
One measurement run of one revision on one machine, validated with Effect
Schema (scripts/metrics/snapshot/snapshot.mjs). Metric metadata stays in
the catalog, so a snapshot is small and today's catalog reads an old one. A
measurement is exact (value), sampled (samples and their median as
value) or unavailable (status and the reason, with the fix).
{
"schema": "maia.metrics/v1",
"createdAt": "2026-10-02T02:58:07.512Z",
"source": { "revision": "<40 hex>", "dirty": false },
"environment": {
"platform": "darwin",
"arch": "arm64",
"node": "v24.22.1",
"cpuModel": "Apple M2 Max",
"cpuCount": 12,
"memoryBytes": 34359738368,
"loadAverage": [14.1, 16.2, 13.6],
"ci": false
},
"suites": ["size", "health", "startup"],
"measurements": [
{ "metric": "cli.standalone.bytes", "value": 259640 },
{ "metric": "tests.files", "subject": "@maiaos/cli", "value": 51 },
{ "metric": "cli.start.help.ms", "samples": [64.1, 66.0], "value": 65.05 },
{
"metric": "node.start.bootstrap.p95.ms",
"status": "unavailable",
"reason": "test:startup-budget:built wrote no evidence: ..."
}
]
}
A new field or meaning needs maia.metrics/v2.
History
- Locally:
.artifacts/metrics/, ignored by Git, one file per revision. - In CI: each passing
metricsjob uploads its snapshot as themaia-metrics-baselineartifact (kept 90 days). The next run asks the Actions API for the newest such artifact whose commit is an ancestor of HEAD, downloads it and compares. The job checks out the full history (a few megabytes), so the baseline commit and the trailers since it are always present. A failed job uploads no baseline, so its regression stays in scope until a commit fixes or accepts it, as change-scope keeps a failed run's changes in scope. A failed lookup (the API, the token) fails the job; only "no baseline yet" records a snapshot without comparing. - Why artifacts, not a branch or a service: exact metrics can be measured
again from any commit, so Git already holds their full history; a snapshot
only saves the rebuild. Wall time never compares across runs. Artifacts need
no write permission, add nothing to the repository and no service. A
durable
metricsbranch for long-term dashboards is an open decision.
Comparison
- Exact metrics: head minus base;
relativeis that change over the base. A worse change passes a threshold when it reaches both its absolute and its relative amount. - Timing metrics, same machine (
--base): one warm-up of each tree, then--runsrounds that alternate base-head and head-base, so drift in load falls on both sides. Welch's t-test compares the means; the report shows the means, the change, its 95% confidence interval relative to the base, the p-value and Node.js-style stars. A change counts when p < 0.01 and it passes the metric'swarnthreshold; it never fails a run. A suite that samples once by default samples twice per side here;--runs 1givesinsufficient. - Timing metrics, stored snapshot (CI):
trend, never a verdict. - Ceilings: a metric with a
ceilingin the catalog fails a run, in either mode, when the head measures past its margin over the accepted value, whatever the base measured. The row keeps the base beside it.terminal.node.base.bytesfails more than 10% above the accepted value ofnode.idle.memory.bytes(D-177). AMetrics-Acceptedtrailer accepts it for its own commits only: later runs compare with the same accepted value and fail again, so a lasting raise is a reviewed change of that value in the catalog. - Per-platform metrics across platforms:
not comparable. - Lost and new measurements: an exact metric the base measured and the
head cannot fails (
no longer measured), so a broken collector or a missing artifact cannot pass and become the next baseline. A new package is graded from zero on the per-package exact metrics (dependencies, npm pack, tests), so adding a workspace package fails until aMetrics-Acceptedtrailer gives the reason, as any new dependency does. Publishing a private package grows its npm pack the same way. Pull requests do not run this job, so the trailer belongs in the squash commit that publishes it. - Other verdicts:
new(a metric or subject the base did not measure, such as a new protocol export),removed,unavailable(with both reasons) andtrendfor a metric the catalog no longer has.
Regression policy
| Where | Fails (blocks) | Warns | Trends only |
|---|---|---|---|
| pre-commit, pre-push | nothing new: hooks stay fast; the existing absolute budgets keep running where they already run (CLI size locks in its tests, protocol declaration ceilings in its build) | ||
| CI, push to main | an exact metric past fail: a new dependency, a larger lint or source-organization baseline, an artifact past its fail size, 20 more lockfile packages | an exact metric past warn (a GitHub annotation and the job summary) | wall time (CLI startup) |
pnpm metrics:compare | the same as CI and the Node's idle base more than 10% above its accepted value, and the command exits 1 | the same, plus significant wall-time regressions on this machine |
Triage. A failing metrics job turns main red with an annotation per
metric. The metric's owner (catalog) either fixes it or accepts it in a
commit on main that explains why:
build(cli): bundle the coordinator MCP server
Metrics-Accepted: cli.standalone.bytes The MCP server loads on demand (S4c)
Metrics-Accepted: dependencies.production[@maiaos/cli] Approved in the decision log
A key without [subject] accepts every subject of that metric. The trailer
counts in any paragraph of a commit message that holds nothing but
Key: value lines, so a squash merge keeps it; a wrapped reason continues on
an indented line. The next run
compares with the last passing baseline, sees the trailer in the range and
passes; the baseline then moves. A ceiling does not move: later runs fail
again until a reviewed catalog commit changes its accepted value. Accepting a
warning is optional. Thresholds change only in a reviewed catalog commit.
CI job and its cost
The metrics job runs after the scope job, in parallel with the quality job,
on pushes to main whose change selects any package (change-scope's packages
output), so documentation-only pushes skip it; pull requests do not run it
yet. It installs, builds the measured packages (unchanged ones restore from the
Turborepo cache), finds and downloads the baseline, runs size, health and
startup, and uploads the report (30 days) and, when it passes, the next
baseline (90 days).
Estimated from today's CI step timings (pnpm and Node setup about 30 s, install 40-100 s, cache restore a few seconds) plus about 40 s of measurement: 2.5-4 minutes of wall time, billed as 3-4 Linux minutes per run (GitHub rounds each job up to a minute). When the Turborepo cache lacks the client's export, building it adds 1-2 minutes. Re-running the job uploads a report of its own and replaces the baseline. The repository is private, so these count against the plan's minutes at the Linux rate; macOS minutes cost ten times as much, and this job never uses macOS. A full CI run today bills about 55 minutes (the macOS job alone 20), so the job adds about 6%. Running the same steps inside the quality job would bill about 1 minute instead, at the cost of delaying that job by that minute; a separate job keeps CI feedback as fast as before.
Limits
- Test counts are static: a
test.eachtable counts once, a test built in a loop not at all. They trend; they never fail. - Coverage counts only the files a package's tests load, so a module no test imports does not lower it. Node's test coverage is still experimental (stability 1); a Node upgrade can change its numbers.
effect.gapsandtests.owned_mocksread the rule ledgers, which the lint rules keep exact; they cost no extra scan.test:startup-budget:builtkeeps its own wall-time budgets, so a loaded machine can fail it (authenticated readiness p95 above 200 ms) while it still writes the evidence thenodesuite reads. Its Node bundle ceiling is 3,264 KiB, the 3,037,358-byte bundle of 2 October 2026 plus about 10%.- The port-lease and hold benchmarks have no package script; the runner
calls the Node package's Vitest on their scopes (
src/port-leasewith theport lease acquire latencytest name, andsrc/hold) until Node adds scripts for them. - The observation benchmark builds the Node's dependencies itself and takes about four minutes.
repository.install.bytesreadsnode_modules/.pnpmas it is. A long-lived checkout keeps packages an older lockfile installed (pnpm does not prune them on a frozen install), so compare it from fresh installs: CI always installs fresh, and so does the base side ofpnpm metrics:compare.
Open decisions
These need the owner's approval (a new service, permission or dependency):
- Durable history: a
metricsorphan branch that CI appends to, for dashboards past 90 days. Needscontents: writein CI. - Pull-request comments with the comparison. Needs
pull-requests: writeand aworkflow_runworkflow for forks. - Hosted services: CodSpeed (instruction counts in CI; a GitHub App and an npm plugin), Bencher (cloud, or a service to run), RelativeCI or bundlewatch (hosted bundle history).
- New dependencies: size-limit for bundle budgets; Vitest
bench(already installed, but Vitest 5 changed its API) for micro-benchmarks. - A dedicated benchmark runner (self-hosted, tuned like rustc-perf's) to gate wall time.
- A weekly triage owner who reads the trends, as rustc does.
- The screen engine (TM2b): one crate of the measured three, approved when TM2b adds it (D-175).
Imported from Maia OS docs at a2175bb