Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Benchmarks

Published numbers on cited hardware. No number ships without a bench behind it, and no bench with a missed target is left out of this page. How to run the suite: benches/README.md.

Targets and status

Every target the project has set, and where it stands. Do not conflate these into a single threshold — they measure different things.

ScopeGroupTargetStatus
Policy eval alonepolicy_eval/allow_all, policy_eval/multi_rule< 100 µspass
Combined policy + capabilityhot_path/multi_rule< 1 mspass
Warm cell instantiationinstantiation/warm< 0.5 mspass
Cold instantiationinstantiation/cold< 5 msmiss — 39.490 ms, ~7.9× over
ed25519 line signingaudit_signing/sign_outcome_line< 50 µspass
Wrap relay per recorded tools/callwrap_relay/tools_call_recorded0.5–2 ms (informational)miss — 4.371 ms/call, ~2.19× over
Audit emissionaudit_emission/begin_completeno target set (fsync-bound)n/a
Rate-limit path, capability alone, attribution splits—informational onlyn/a

Both misses are stated rather than retired quietly. Cold instantiation is dominated by a Cranelift compile of the component (~29.4 ms), not by engine setup (~1–2% of the median), so the 5 ms target does not survive contact with an AOT compile of this size; Execution Report §7 was amended rather than the measurement reshaped. Wrap’s per-call cost is two sync_all calls under the shipped G3 durability default, which alone cost ~4.2 ms on this filesystem — reaching 2 ms needs a batched-or-deferred-fsync decision, not tuning. Neither number was improved by narrowing a bench or adding private-API-shaped surface (PRD §10).

The three results files follow, verbatim.


Hot-path Criterion results (AEG-16 / OQ-15 T5)

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.96.0 (ac68faa20 2026-05-25)
host: x86_64-unknown-linux-gnu

Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed (plotters backend). Date: 2026-07-09.

Command

cargo bench -p botzr-aegis-policy -p botzr-aegis-capability -p botzr-aegis-runtime

Summary vs targets

GroupMedian (approx.)TargetStatus
policy_eval/allow_all13.4 ns< 100 µspass
policy_eval/multi_rule31.8 ns< 100 µspass
policy_eval/rate_limit183 nsinformational (mutex)n/a
capability_resolve/registered_tool2.43 µsno hard gaten/a — bench id and measured path both changed 2026-08-24; see AILAB-707 below
hot_path/allow_all2.67 µs(floor)n/a — superseded 2026-09-07; see AILAB-846 below
hot_path/multi_rule (stations 1–2 only — not the cost of a call)2.71 µs< 1 mspass — superseded 2026-09-07; see AILAB-846 below

Criterion reports [lower median upper] as time/op; medians above are the middle sample.

What the last two rows are not. hot_path mirrors stations 1–2 and calls neither sandbox, audit nor wasmtime inside b.iter — that non-goal is deliberate and still stands. So 2.71 µs is a component figure, not what an audited call costs, and it must not be quoted as one: the same call against the shipped Volatile sink is roughly an order of magnitude more, and against a Durable sink a further two to three orders on top of that — a span, not a figure, because the Durable arm did not reproduce across two sessions. Those are the audited_call group in cell_and_audit.md, which is where the end-to-end number lives.

Criterion text tables

capability_resolve/registered_tool
                        time:   [2.3959 µs 2.4329 µs 2.4751 µs]

policy_eval/allow_all   time:   [13.240 ns 13.408 ns 13.582 ns]
policy_eval/multi_rule  time:   [31.025 ns 31.775 ns 32.898 ns]
policy_eval/rate_limit  time:   [181.31 ns 182.74 ns 184.38 ns]

hot_path/allow_all      time:   [2.6091 µs 2.6727 µs 2.7417 µs]
hot_path/multi_rule     time:   [2.6453 µs 2.7136 µs 2.7872 µs]

Notes

  • Combined hot path is dominated by capability grant minting (~2.4 µs alone); policy eval is tens of nanoseconds.
  • Rate-limit path is ~6× multi-rule policy eval but still far below 100 µs; kept informational because it takes a Mutex.
  • HTML reports live under target/criterion/ and are not committed.

AILAB-707 update — 2026-08-24

AILAB-707 routed both benchmarks off CapabilityResolver::register, which is #[deprecated] as a cross-crate visibility fence. A published benchmark that suppressed a deprecation to reach a forbidden path was the claim-integrity defect; the suppressions are gone. The table above is not retroactively edited — it records what was measured on 2026-07-09, under the ids that existed then.

What changed in each measured path

  • capability_resolve/registered_tool → capability_resolve/mint_from_manifest. Renamed because the tool is genuinely no longer registered. botzr-aegis-capability cannot reach Runtime::register_tool (runtime depends on capability, so the reverse edge is a dependency cycle), so the bench moved to resolve_manifest, the supported one-off mint route shipped for aegis wrap --confine. Two pieces of work left the measured path: the HashMap<ToolId, ToolManifest> registry lookup, and the ResourceCeiling::combine fold. Ceiling semantics are unchanged — the old call passed ResourceCeiling::default() and the standing ceiling is also default, so combine was a no-op. The number should therefore have moved slightly down.
  • hot_path/allow_all, hot_path/multi_rule — measured work unchanged. The bench lives inside botzr-aegis-runtime, so it now registers through Runtime::register_tool in setup and measures against rt.capabilities(). The closure still runs evaluate → decision.limits → resolve_with_ceiling, the registry is still a one-entry map, and registration is setup, never measured. The fixture manifest’s ToolKind became Host so the handler matches; nothing on the production mint path branches on kind (every ToolKind in capability’s mint.rs / narrow.rs is inside #[cfg(test)], and policy never reads it). These two numbers should therefore not move at all.

Measurements — and why they are not a re-baseline

Hardware / OS as above, except the toolchain: rustc 1.86.0 (05f9846f8 2025-03-31), LLVM 19.1.7, on Linux 6.6.87.2-microsoft-standard-WSL2, AMD Ryzen AI 5 340 (4 CPU / 2 cores), Criterion 0.5.1, plotters backend. The 2026-07-09 table above was taken on rustc 1.96.0, so it is not a valid before-value for this change.

Medians, µs. Pre-change was measured on clean 746ae42 before any edit; runs 1–3 are three consecutive runs of the same post-change binary.

Benchpre-changepost r1post r2post r3
capability_resolve/mint_from_manifest2.57312.64722.58332.6314
hot_path/allow_all2.62383.69662.77642.7480
hot_path/multi_rule2.72862.87553.40962.9367

Between those identical-code runs Criterion reported, in order: +42% “regressed”, −20% “improved”, +12% “regressed”, −10% “improved”, +7% “regressed”, −5.6% “improved” — all at p < 0.05. A benchmark whose measured work provably did not change (hot_path/*) swung by 42 points between runs of the same binary.

Conclusion: no change is attributable to the edit, because this box cannot resolve an effect of the expected size. The run-to-run noise floor here is roughly ±20%, and the predicted effects are one hash lookup and three Option<u64> mins. Note also that capability_resolve moved up ~2% while the work it does strictly decreased — the sign is wrong, which is itself evidence that noise dominates.

The published medians above are therefore left as they are rather than overwritten with these runs: replacing a measurement with a noisier one, taken on a different toolchain, would publish noise as evidence. A clean re-baseline of the whole suite — quiet machine, pinned toolchain, all groups — is owed and is not this ticket’s scope.


AILAB-846 update — canonicalizing at registration, 2026-09-07

What changed, and which of these groups can see it. Capability resolution used to std::fs::canonicalize every path a manifest declared, on every call. It now happens once, when the tool is registered, and resolution reuses the result. Exactly one of the two groups below runs that code:

  • hot_path/* registers through Runtime::register_tool in setup and calls resolve_with_ceiling inside b.iter — the changed path.
  • capability_resolve/mint_from_manifest calls resolve_manifest, which mints from a manifest the caller already holds and has no registration to have prepared. That path still canonicalizes per call, deliberately: it is what aegis wrap --confine uses. It is the control here, not the result.

Hardware / OS as in the block at the top of this file, with the toolchain stated rather than inherited: rustc 1.86.0 (05f9846f8 2025-03-31) — the version rust-toolchain.toml pins — on Linux 6.6.87.2-microsoft-standard-WSL2, AMD Ryzen AI 5 340 (4 CPU / 2 cores), WSL2, Criterion 0.5.1, plotters backend, bench profile. The 2026-07-09 table above was taken on rustc 1.96.0 and is not a valid before-value; the before column here was measured today, from a worktree at 9b1d484, in the same session and on the same toolchain as the after column.

Criterion [lower median upper]:

Benchbefore (9b1d484)aftermedian delta
hot_path/allow_all[2.2905 2.3150 2.3428] µs[221.18 224.61 228.43] ns−90.3% (10.3×)
hot_path/multi_rule[2.6515 2.7314 2.8171] µs[259.60 263.44 267.90] ns−90.4% (10.4×)
capability_resolve/mint_from_manifest (control — unchanged path)[2.2668 2.2997 2.3351] µs[2.4060 2.4457 2.4908] µs+6.3%

Why these are readable when AILAB-707’s were not. That section, above, measured a predicted effect of one hash lookup and three Option<u64> mins against a ±20% run-to-run noise floor, and correctly concluded nothing was attributable. Here the effect is 10×: fifty times the noise floor, and the two arms move in opposite directions in the way the change predicts — the group that stopped touching the filesystem fell by an order of magnitude, and the group that still touches it did not move outside noise. The control’s +6.3% is the wrong sign for a speed-up and is exactly what a control should do. The before-value is also corroborated rather than a single sample: AILAB-707’s pre-change hot_path/multi_rule on this same toolchain was 2.7286 µs, against 2.7314 µs measured today.

What the saving is, and what it is not. It is 2.47 µs off stations 1–2. Stations 1–2 are not a call — that is the label the row above carries and it still applies. Against the end-to-end figures in cell_and_audit.md, 2.47 µs is:

  • 6.6 – 7.5% of an audited call against the shipped Volatile sink (32.9 – 37.4 µs)
  • 0.014 – 0.086% of an audited call against a Durable sink (2.86 – 17.5 ms)

So a call did not get ten times faster and no sentence here should be read that way. The 10× is a component figure for a component that is a small and, on the durable arm, negligible share of what a call costs. This work was queued for the grant-id spelling gap and the process-global counter; the speed-up is a side effect of doing it, not the case for it.

Provisional, like everything else in this directory. These were taken on a box that swings ±20% on identical binaries and was doing other work at the time. The shape — an order of magnitude on the changed path, noise on the control — is what survives; the digits are not a baseline. AILAB-796 is the ticket that re-measures on a quiet machine.


Cell instantiation + audit emission Criterion results (AEG-005 / AILAB-683)

Covers the two AEG-005 rows hot_path.md deliberately does not: wasmtime instantiation (warm and cold) and audit record emission. Policy and capability numbers stay in hot_path.md.

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu

Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed (plotters backend); wasmtime 36.0.13 and wasmtime-wasi 36.0.12 (Cargo.lock); bench profile (optimized). Date: 2026-08-10.

Toolchain differs from hot_path.md. That file cites rustc 1.96.0; this run used the repo-pinned 1.86 (rust-toolchain.toml). Numbers across the two files are not strictly comparable.

Command

cargo bench -p botzr-aegis-sandbox -p botzr-aegis-audit

Summary vs targets

GroupMedianTargetStatus
instantiation/warm49.339 µs< 0.5 mspass (~10× under)
instantiation/cold39.490 ms< 5 msfail (~7.9× over)
instantiation/cold_engine_only456.08 µsinformationaln/a
instantiation/cold_compile_only29.351 msinformationaln/a
audit_emission/begin_complete4.7185 msnone, deliberately — see belown/a
audit_emission/serialize_only468.46 nsinformationaln/a

Criterion reports [lower median upper] as time/op; medians above are the middle sample. All six come from a single process run.

Criterion text tables

instantiation/warm      time:   [48.175 µs 49.339 µs 50.624 µs]
instantiation/cold      time:   [36.838 ms 39.490 ms 42.364 ms]
instantiation/cold_engine_only
                        time:   [438.01 µs 456.08 µs 478.53 µs]
instantiation/cold_compile_only
                        time:   [28.335 ms 29.351 ms 30.455 ms]

audit_emission/begin_complete
                        time:   [4.6635 ms 4.7185 ms 4.7730 ms]
audit_emission/serialize_only
                        time:   [462.98 ns 468.46 ns 474.43 ns]

Stability across runs

This box is noisy for the compile-bound groups, so the verdicts below rest on four consecutive runs rather than the single published one. Blank cells are groups that did not exist yet in that run.

Runwarmcoldcold_engine_onlycold_compile_onlybegin_complete
154.519 µs27.799 ms——4.6620 ms
257.894 µs37.237 ms412.58 µs29.806 ms—
354.493 µs29.700 ms441.35 µs29.601 ms5.0875 ms
4 (published)49.339 µs39.490 ms456.08 µs29.351 ms4.7185 ms

cold swings 27.8–39.5 ms (~±20% around its mean) while its two components are comparatively stable — engine construction 412–456 µs, compile/link 29.4–29.8 ms. In run 4 the components sum to ~29.8 ms against a 39.5 ms cold median, i.e. the splits do not reconcile with cold run-for-run; only the per-component medians are stable enough to attribute with. Both verdicts hold in every run regardless: warm clears 0.5 ms by 8.6–10.1×, cold misses 5 ms by 5.6–7.9×.

What each group actually measures

instantiation/warm — includes a WIT run and a tokio runtime, not instantiation alone. PreparedTool.tool_pre is private and there is deliberately no public instantiate-only API (adding one to make a bench possible is forbidden by PRD §10), so the warm iteration goes through SandboxEngine::execute. That is, per iteration:

  1. block_on runs the guest future on the engine’s tokio runtime (crates/botzr-aegis-sandbox/src/engine.rs). These numbers were measured before AILAB-809, when a fresh current-thread runtime was built per call and was therefore inside the median; the engine now builds one runtime in SandboxEngine::new and reuses it, so this step is amortized and the figure below is an upper bound on today’s cost (re-measuring is AILAB-796’s);
  2. build_store — WASI ctx from the grant, memory limiter, epoch deadline;
  3. tool_pre.instantiate_async — the actual warm instantiation;
  4. the WIT run export.

The fixture is tests/fixtures/echo-tool/echo.wasm, whose run is an identity copy of a 4-byte input, so guest-side work is negligible — but the canonical-ABI lower/lift of list<u8> is inside the median. 49.339 µs is an upper bound on warm re-instantiation, not an isolated instantiate cost, and the four components above were not separated because doing so would require new public API.

The warm cache being measured is PreparedTool { tool_pre } (crates/botzr-aegis-sandbox/src/engine.rs:160-161), populated by linker.instantiate_pre at engine.rs:81 and re-instantiated at engine.rs:286-289. The InstancePre in PreparedFixture is a different, #[cfg(feature = "test-utils")] object and is not what these numbers cover.

instantiation/cold — fresh Engine + compile/link, no execute. SandboxEngine::new() + prepare(ECHO) per iteration. It deliberately does not call execute, so cold-compile cost is never conflated with warm instantiation-from-cache.

Why cold misses 5 ms: it is prepare, not engine setup. Engine construction — config, linker, WASI link, epoch ticker spawn, plus the ticker join that Drop pays — is 456.08 µs, roughly 1–2% of every cold median observed. prepare on an already-built engine is 29.351 ms: that is Component::new (Cranelift compilation of the 62 KB wit-bindgen component) plus linker.instantiate_pre link resolution plus ToolPre::new, dominated by the Cranelift compile but not exclusively it. There is no epoch-ticker thread storm. The 5 ms target does not survive contact with an AOT compile of a component of this size on 4 vCPUs; reaching it needs a different mechanism, not tuning. Execution Report §7 was amended accordingly rather than the measurement being reshaped to fit.

audit_emission/begin_complete — the full two-line durable cycle. CallSession::begin (emits the intent line) → set policy allowed / capability granted / execution success / metrics → complete (emits the outcome line). Two JSONL lines means two sync_all calls (crates/botzr-aegis-audit/src/writer.rs:74); the fsync-per-line is the shipped G3 durability default and was not stripped for the bench. One writer is reused across iterations so TempDir creation is not timed.

The cycle is fsync-bound, and that is measured rather than assumed. serialize_only runs the same two records through the already-public to_json_line with no file write and no fsync: 468.46 ns, or ~0.01% of the 4.7185 ms cycle. Serialization is not a meaningful cost here; essentially the entire median is write + fsync latency on WSL2’s filesystem. Expect it to move by an order of magnitude on a different filesystem, and do not cite it as an Aegis-side overhead figure.

There is no target on that row, and the blank is a decision rather than an omission (AILAB-851). serialize_only settles what the median is made of: 468.46 ns of Aegis-side work inside a 4.7185 ms cycle, so ~99.99% of it is write + fsync latency on WSL2’s filesystem. A number in that column would therefore be a durability target for someone else’s storage stack — it would pass on an NVMe host and fail on a network mount without a line of this repository changing, which is a claim about the hardware wearing a claim about Aegis. The sentence above already forbids citing this figure as Aegis-side overhead; a target would quietly license exactly that. What the row is for is attribution, and it does that job with serialize_only beside it. If a durability target is ever wanted it belongs on a named storage profile, not on this row.

Isolated ed25519 signing (AILAB-620)

Added because the previous section makes the emission cycle unusable as a crypto number: at 4.7185 ms it is a measurement of fsync, and quoting a 50 µs signing target against it would be a claim about the filesystem. This group measures one thing — SigningKey::sign over bytes already in hand.

Hardware / OS / toolchain: identical to the run above (AMD Ryzen AI 5 340, 4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench profile, plotters backend). Date: 2026-08-11.

cargo bench -p botzr-aegis-audit --bench sign
GroupMedianTargetStatus
audit_signing/sign_outcome_line13.765 µs< 50 µs (AILAB-620)pass (~3.6× under)
audit_signing/sign_outcome_line
                        time:   [13.641 µs 13.765 µs 13.907 µs]
Found 4 outliers among 100 measurements (4.00%)
  4 (4.00%) high mild

Per iteration: one ed25519 signature over the canonical signing input of a representative outcome line — the JCS form with signature absent and key_id present (ADR-0003), built once outside the loop. No AuditWriter, no file, no fsync, no canonicalization inside the measured region, so the median is the signature and nothing else. The key comes from insecure_dev_key: signing cost is a property of ed25519, not of which 32 bytes the seed holds, and a fixed seed keeps the bench deterministic. Nothing here reads a key file.

What this does and does not license. 13.765 µs is the cost of signing a line. It is not the cost of emitting one — that is begin_complete above, and it is ~340× larger because of two sync_all calls. Adding signing to the shipped emit path is therefore invisible against fsync; that is a statement about how expensive durability is, not about how cheap ed25519 is. Cite the two numbers together or neither.

An audited call, end to end (AILAB-851)

Added because nothing here or in hot_path.md measured a call. hot_path/multi_rule is stations 1–2 at 2.71 µs; audit_emission/begin_complete is the two-line durable cycle on its own. Neither is what one Runtime::execute_host_call costs, and the two do not add up to it either. This group drives the whole pipeline — POLICY → CAPABILITY → SANDBOX → AUDIT — with a host handler as the executable, so wasmtime compilation stays in instantiation/cold where it belongs.

It lives in this file rather than a fourth one because benches/README.md declares exactly three scopes and all three are included verbatim into the book’s Benchmarks chapter. This file already owns audit record emission; an audited call is that scope one level up.

Two arms, one variable. Both runtimes are built identically and differ only in the sink: durable_sink is a FileChainSink with a provisioned key — the configuration that retains evidence, and the one a Durable sink requires a real key for (ADR-0012) — and volatile_sink is the shipped default, an in-memory Chain that emits, signs and chains every record and then keeps none of it past the process.

EVERY NUMBER BELOW IS PROVISIONAL

This box swings ±20% on identical binaries — the instantiation/cold table above is the evidence — and these were taken while it was doing other work. For this group there is now direct evidence rather than an inherited caution: re-running it in a second independent session on the same box did not reproduce the first session’s durable_sink figures at all. The two sessions’ ranges do not overlap, a 6.1× spread across six runs, and the table below records every one of them. AILAB-796 is the ticket that re-measures on a quiet machine, and until it lands nothing here should be quoted as a median. What is worth reading is the shape: two to three orders of magnitude between the two sinks.

Hardware / OS / toolchain: identical to the first run in this file (AMD Ryzen AI 5 340, 4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench profile, plotters backend). Date: 2026-09-01.

cargo bench -p botzr-aegis-runtime --bench audited_call
GroupRange across six runs (provisional)TargetStatus
audited_call/durable_sink2.86 – 17.5 ms — spans 6.1× across six runs in two sessions; not reproduciblenone — see begin_complete aboven/a
audited_call/volatile_sink32.9 – 37.4 µs — reproduces within 1.14×none setn/a
audited_call/durable_sink
                        time:   [3.0238 ms 3.2586 ms 3.5437 ms]
Found 2 outliers among 20 measurements (10.00%)
audited_call/volatile_sink
                        time:   [34.153 µs 34.432 µs 34.799 µs]
Found 2 outliers among 20 measurements (10.00%)

Stability across six runs in two independent sessions

Two independent sessions on the same box, three runs each. Nothing about the binary, the hardware or the bench parameters differs between them.

RunSessiondurable_sinkvolatile_sinkratio
12026-09-013.2586 ms34.432 µs94.6×
22026-09-012.8585 ms34.791 µs82.2×
32026-09-013.7153 ms32.889 µs113.0×
42026-09-0717.505 ms34.889 µs501.7×
52026-09-079.832 ms37.370 µs263.1×
62026-09-0713.895 ms33.566 µs414.0×

The volatile arm is the only reproducible number here, and the ratio is the least stable thing in the group. volatile_sink holds inside 1.14× across all six runs and both sessions. durable_sink spans 6.12×, and the two sessions do not merely differ — their ranges do not overlap at all, the second session’s fastest run being 2.6× the first session’s slowest. Because the durable term dominates the quotient, the ratio inherits every bit of that variance and spans 82×–502×. So the honest claim is two to three orders of magnitude, not a figure, and no median for the durable arm belongs in this file at all. Anything tighter needs AILAB-796.

What the pair shows

The configuration that is fast is the configuration that retains nothing. Both arms emit two signed, chained records per call; the only difference is whether those records survive the process. That difference spans 82×–502× across the six runs above — and it is almost entirely fsync, for the reason serialize_only established above: serialization is 468 ns against a millisecond-scale cycle. Evidence that outlives the process costs two sync_all calls, and no amount of Aegis-side tuning touches that number.

It also puts hot_path/multi_rule in proportion. Stations 1–2 are 2.71 µs; the same call reaching AUDIT against the unretained default is 34.4 µs, about 13× more; against a retaining sink it is 2.86–17.5 ms, another 82×–502× beyond that. The published 2.71 µs covers well under a percent of a durable call and must not be quoted as its cost.

The cross-check that would not reconcile, and its resolution

An end-to-end audited call appeared to measure less than audit_emission/begin_complete (3.2586 ms against 4.7185 ms) while doing strictly more work: the pipeline emits the same two lines through CallSession::begin and complete (crates/botzr-aegis-runtime/src/pipeline.rs), and adds policy, capability, the request digest and handler dispatch on top. That is not possible if both figures measure the same fsync work under the same conditions, so at least one of them had to be non-comparable.

The six-run table above resolves it, and the split falls exactly on the session boundary. All three runs from 2026-09-01 land below begin_complete’s 4.7185 ms (3.2586, 2.8585, 3.7153 ms); all three from 2026-09-07 land above it (17.505, 9.832, 13.895 ms). Neither figure was wrong when it was taken — the first session simply caught a quiet fsync window. The two numbers are not separable at this measurement precision: the fsync term dominates both, and it varies by more than the gap between them. The earlier explanation that emission.rs writes a larger fixture record than this group’s grant remains true, but it is not needed to account for the discrepancy and cannot be measured apart from the noise.

That this was answerable one session later is entirely because the failure was published instead of omitted — a number that does not reconcile is evidence about the measurement, and hiding it would have left the next reader to rediscover it. AILAB-796 should still re-measure both in one process run.

Why the sample budget is capped

MemoryChainSink holds an Arc<Mutex<Vec<u8>>> and never truncates, so the volatile arm retains every byte it emits for the life of the bench process. At Criterion’s default budget it would append hundreds of megabytes and start measuring the allocator. The group therefore sets a 500 ms warm-up, a 2 s measurement window and 20 samples; peak RSS for the run above was 124 MB. Both arms get the same budget, so the comparison is unaffected — but the sample count is lower than the rest of this file, which is a second reason to treat these as provisional.

Notes

  • The sandbox crate’s benches are compiled with the test-utils feature on, because a pre-existing self dev-dependency (crates/botzr-aegis-sandbox/Cargo.toml:43) enables it for all dev targets so tests/sandbox.rs can reach the fixture API. The bench uses no test-utils item: the feature gates only additive prepare_fixture / execute_fixture / PreparedFixture, none of which is on the measured prepare / execute path.
  • No public API was added to either crate to make these benches possible. serialize_only uses to_json_line, which was already exported.
  • HTML reports live under target/criterion/ and are not committed.

aegis wrap relay overhead Criterion results (AILAB-625)

One number, measured end to end: what it costs to put aegis wrap between an MCP client and a stdio child server. Per iteration a whole run_wrap_with_streams session runs — signing key loaded, AuditWriter opened, child spawned, 50 scripted JSON-RPC lines relayed and answered, child reaped, close line written.

What it deliberately does not cover. This is not a confinement cost: this run sets confinement: None, so wrap records without confining and the capability/sandbox stations never run here. It is not a policy or wasmtime number either — those live in hot_path.md and cell_and_audit.md. It is not a portable figure: a recorded call is two JSONL lines and therefore two sync_all calls, so the median is dominated by this filesystem. And it is not a measure of any real MCP server’s work — the child is the in-repo mirror fixture, which answers from a match.

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu
LLVM version: 19.1.7

Environment notes: WSL2 / Linux; Criterion 0.5.1 (Cargo.lock); Gnuplot not installed (plotters backend); bench profile (optimized); repo-pinned toolchain 1.86 (rust-toolchain.toml). Date: 2026-08-12.

Same box and same toolchain as cell_and_audit.md, so the audit crate’s audit_emission/begin_complete (4.7185 ms, 2026-08-10) is directly comparable and is used below as the reference for the fsync cost sitting inside every recorded call.

Command

cargo bench -p botzr-aegis-wrap

Summary vs targets

Criterion reports [lower median upper] per iteration (50 calls); Throughput::Elements(50) turns the thrpt row into the per-call figure. Per-call medians below are the published iteration median ÷ 50, which agrees with the reciprocal of the thrpt median to four significant figures.

GroupMedian / session (50 calls)Median / callTargetStatus
wrap_relay/tools_call_recorded218.54 ms4.371 ms0.5–2 ms (informational)miss (~2.19× over the 2 ms ceiling)
wrap_relay/ping_relayed_only6.8027 ms136.05 µsinformational (attribution baseline)n/a

Derived attribution — recording, per recorded call: 4.235 ms. (218.54 ms − 6.8027 ms) ÷ 50. Everything the two groups share — key load, AuditWriter::open, child spawn, 50 relayed lines and 50 responses, reap, close — cancels in the subtraction, leaving the per-call intent + outcome pair. That 4.235 ms lands within 11% of the audit crate’s own two-line begin_complete cycle (4.7185 ms), which is the expected result if wrap’s recording is exactly two durable lines and nothing else.

The miss is the honest outcome, not a regression. The 0.5–2 ms target (spec §1.9 / §3.4) is informational and predates the measurement. Two sync_all calls on this filesystem cost ~4.2 ms on their own, so no arrangement of wrap code reaches 2 ms per recorded call while durability stays at the shipped G3 default. Reaching it needs a different mechanism (batched or deferred fsync), which is a durability decision, not a tuning exercise. Nothing was narrowed, stripped, or made private-API-shaped to improve the number (PRD §10).

Criterion text tables

Raw output of the published run, verbatim:

Gnuplot not found, using plotters backend
Benchmarking wrap_relay/tools_call_recorded
Benchmarking wrap_relay/tools_call_recorded: Warming up for 3.0000 s

Warning: Unable to complete 100 samples in 5.0s. You may wish to increase target time to 19.8s, or reduce sample count to 20.
Benchmarking wrap_relay/tools_call_recorded: Collecting 100 samples in estimated 19.757 s (100 iterations)
Benchmarking wrap_relay/tools_call_recorded: Analyzing
wrap_relay/tools_call_recorded
                        time:   [210.32 ms 218.54 ms 227.62 ms]
                        thrpt:  [219.66  elem/s 228.80  elem/s 237.74  elem/s]
Found 11 outliers among 100 measurements (11.00%)
  6 (6.00%) high mild
  5 (5.00%) high severe
Benchmarking wrap_relay/ping_relayed_only
Benchmarking wrap_relay/ping_relayed_only: Warming up for 3.0000 s
Benchmarking wrap_relay/ping_relayed_only: Collecting 100 samples in estimated 5.0924 s (400 iterations)
Benchmarking wrap_relay/ping_relayed_only: Analyzing
wrap_relay/ping_relayed_only
                        time:   [6.3020 ms 6.8027 ms 7.3371 ms]
                        thrpt:  [6.8147 Kelem/s 7.3500 Kelem/s 7.9339 Kelem/s]
Found 18 outliers among 100 measurements (18.00%)
  11 (11.00%) high mild
  7 (7.00%) high severe

The Criterion warning is left in: the recorded group needs ~20 s for 100 samples, and the run was not shortened by cutting the sample count.

Stability across runs

Two consecutive runs of the same command on the same box. Run 1 is published above; run 2 is the immediate repeat, so Criterion also printed its change: comparison against run 1.

Runtools_call_recorded / callping_relayed_only / callderived recording / calloutliers (rec / ping)
1 (published)4.371 ms136.05 µs4.235 ms11% / 18%
24.176 ms136.70 µs4.040 ms3% / 6%

Criterion’s own verdict on run 2 was No change in performance detected for both groups (p = 0.06 and p = 0.92). The published run is the noisier of the two and its median is the slower of the two; the verdict is a miss in both.

What each group actually measures

wrap_relay/tools_call_recorded — the shipped recording path, whole session. Every one of the 50 client lines is a tools/call with a distinct id, so every line takes the full recording route in crates/botzr-aegis-wrap/src/record.rs:59-119: serde_json::from_str on the raw line, RequestDigest::of_request_bytes over the verbatim bytes (record.rs:78), PolicySetHash::of_canonical_bytes over the pass-through constant (record.rs:79), CallSession::begin (record.rs:110) — one intent line, fsynced before the request is relayed (crates/botzr-aegis-wrap/src/relay.rs:199-203) — and then, on the child’s matching response, complete_relayed (record.rs:129-161) with the deny_all grant, the response digest, the metrics and complete() — the second line, and the second fsync (crates/botzr-aegis-audit/src/writer.rs:218). Also inside the iteration and not amortised away: load_signing_key and AuditWriter::open (relay.rs:90-91), the child spawn (relay.rs:93-102), the bounded reap (relay.rs:319-333), the stderr-tee drain (relay.rs:127), and the close line the AuditWriter writes on drop.

wrap_relay/ping_relayed_only — the same session with the recording removed. ping is not tools/call, so observe_client_line returns None at record.rs:66 before any digest, session or audit line exists; the line is relayed with zero interception and the mirror child answers {"result":{"mirrored":"ping"}}. The group therefore carries the identical fixed cost — key, writer open/close, spawn, reap, drain — plus 50 round trips of pure byte relay.

Its per-call figure is an upper bound on byte relay, not a measurement of it. 136.05 µs/call is 6.8027 ms of session cost divided by 50, and most of that session cost is the fixed part (one process spawn, one key load, two fsynced Session lines). This bench does not separate spawn from per-line relay, and no third group was added to try: the only figure it is used for is the subtraction above, where the fixed part cancels.

What is outside the measured region: the TempDir, the signing key generation (generate_signing_key, once, hoisted), and both scripted request bodies — all built before the first iteration. Inside the region but negligible against milliseconds: a Vec<String> argv clone, a PathBuf join, and a few KB of script.to_vec() memcpy per session.

Why the audit file is fresh per iteration. Each session writes to recorded-<n>.jsonl / relayed-<n>.jsonl in one hoisted TempDir. Reusing a single path would make AuditWriter::open recover the previous Session’s tail from an ever-growing file, so the per-iteration cost would climb with the sample number — a non-stationary measurement. File creation is consequently inside the timed region, which is the honest trade and is far below the fsync cost that dominates it.

Notes

  • No public API was added to any crate to make this bench possible (PRD §10). A group isolating the recording work alone — parse + digest + CallSession begin/complete, with no child and no byte relay — was considered and skipped: record::observe_client_line and complete_relayed are pub(crate) and a bench is an external crate, so the only way to reach them would have been to widen botzr-aegis-wrap’s public surface for a measurement. The attribution is obtained by differencing two shipped-path groups instead, and benches/results/cell_and_audit.md’s audit_emission/begin_complete already isolates the durable cycle from the other side.
  • Child-process spawn is amortised across the 50 calls in every group. A per-call figure derived from a one-call-per-process session would be much larger and would not describe how wrap is used — a wrap process lives for a whole client session.
  • The outlier rates (up to 18% in run 1) are consistent with the 20 ms poll granularity of reap (relay.rs:47, relay.rs:319-333) and of drain (relay.rs:303-312) occasionally landing inside an iteration when the child has not yet been reaped by the time the pump breaks. That mechanism is a hypothesis from reading the code, not something this bench measured; per-sample data would be needed to confirm it.
  • Wrap does not meter the child, so nothing in these numbers is the child’s own resource use. CallMetrics::peak_memory_bytes is recorded as 0 = “not measured” (record.rs:152-155).
  • HTML reports live under target/criterion/ and are not committed.