clay-borg/tools/runtime-metrics.py
tegwick e7312b3b8a CB-WP-0006 T03: measure AM-5 and AM-9; AM-5 breaches
AM-9 is met and gated: 13.4 MB peak RSS against a 64 MB target, 4.8x
headroom, in `make all` via --fast. CB-EV-0001's "very unlikely to bind"
was right, but it is now measured rather than assumed, and verified red by
a property mutation (a 300 MB allocation in the workload).

AM-5 is BREACHED on both readings, on the machine the spec names:

  dev toolchain (default features)        87.0 s  [FAIL target <= 60 s]
  shipped runtime (--no-default-features) 61.3 s  [FAIL target <= 60 s]

bnt-lap001, 8 cores — a direct comparison, not a directional one. A row
declared "recorded not gated" and never recorded fails its own target by
45% on first measurement.

The tool reports and exits 0 because the spec says the row is ungated.
Gating it is a spec change needing an ADR; a tool that promotes itself is
how a target starts binding without anyone deciding it should. So AM-5
stays unmutatable — for the accurate reason now — and the breach is raised
as a maintainer decision: speed the build, move the target by ADR (arguing
why 60 s was wrong rather than why 87 s is convenient), or withdraw the
row.

The measurement itself had a real bug, found only by cross-validation.
getrusage(RUSAGE_CHILDREN) is a high-water mark across every reaped child,
so it attributed cargo's memory to the workload and reported 38.2 MB for a
run that used 12.3 MB — a 3x over-report that was plausible, passed its
target, and would have been published. Fixed with os.wait4, which returns
that specific child's rusage, and the self-test now cross-checks against
/usr/bin/time -v.

That is the false-accusation shape in the measurement layer rather than
the mutation layer: an instrument confidently reporting a number it had
not earned.

Also: the clean build measures into a throwaway CARGO_TARGET_DIR rather
than running `cargo clean`, so measuring the metric does not cost several
minutes of rebuild afterwards. A metric that punishes its own measurement
gets measured once and never again.

M-D1-MUT: 6 -> 7 of 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:59:32 +02:00

228 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""AM-5 (clean build time) and AM-9 (peak RSS) — measured for the first time.
CB-WP-0006 T03. Both rows were `unmutatable`: AM-5 is declared
`recorded not gated` and had never been recorded, and AM-9 was declared
"very unlikely to bind", which `evidence/CB-EV-0001` itself flags as an
unmeasured judgment call.
The task said: measure or withdraw, but stop leaving them blank. Measured.
They came out differently, and one of them is a breach.
**AM-5 does not build into `target/`.** It builds into a temporary
`CARGO_TARGET_DIR` so a clean-build measurement does not destroy the
working cache — otherwise measuring the metric would cost several minutes
of rebuild every time, and a metric that punishes its own measurement gets
measured once and never again.
**AM-5 exits 0 even on a breach**, because `specs/GameKernel.md` §5
declares it `recorded not gated`. Gating it is a spec change and needs an
ADR; this tool reports, loudly, and does not quietly promote itself.
AM-9 carries no such declaration and **is** gated.
Usage:
python3 tools/runtime-metrics.py # both (AM-5 takes ~90 s)
python3 tools/runtime-metrics.py --fast # AM-9 only, for `make all`
python3 tools/runtime-metrics.py --self-test
"""
import glob
import os
import re
import resource
import shutil
import subprocess
import sys
import tempfile
import time
from repo import ROOT, cargo_bin, cargo_env, enter_root
AM5_MAX_SECONDS = 60.0
AM5_MACHINE = "bnt-lap001" # the spec names the machine; so do we
AM9_MAX_RSS_MB = 64.0
AM9_TEST = "replay_probe::replay_100k_events_is_linear_and_fast"
def clean_build_seconds(extra=()):
"""Wall seconds for a clean release build, into a throwaway target dir."""
cargo = cargo_bin()
if not cargo:
return None
tmp = tempfile.mkdtemp(prefix="cb-am5-")
env = cargo_env()
env["CARGO_TARGET_DIR"] = tmp
try:
t = time.monotonic()
r = subprocess.run([cargo, "build", "--release", "--workspace", *extra],
cwd=ROOT, env=env, capture_output=True, text=True)
secs = time.monotonic() - t
if r.returncode != 0:
return None
return secs
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_binary():
"""Newest release test binary for games-ground, built if absent."""
cargo = cargo_bin()
subprocess.run([cargo, "test", "--release", "-p", "games-ground",
"--all-features", "--no-run"],
cwd=ROOT, env=cargo_env(), capture_output=True, text=True)
cands = [p for p in glob.glob(os.path.join(ROOT, "target/release/deps/games_ground-*"))
if not p.endswith(".d") and os.access(p, os.X_OK)]
return max(cands, key=os.path.getmtime) if cands else None
def peak_rss_mb(binary, test=AM9_TEST):
"""(peak RSS in MB, ok) for one test run, from that child's rusage.
Uses `os.wait4`, which returns the rusage of **that specific child**.
The obvious alternative — `getrusage(RUSAGE_CHILDREN)` — is a
high-water mark across *every* reaped child of this process, so it
attributed `cargo`'s memory to the workload and reported **38.2 MB for
a run that actually used 12.3 MB**. Cross-checked against
`/usr/bin/time -v` on the same binary, which is how the discrepancy
was found.
"""
out_r, out_w = os.pipe()
try:
pid = os.posix_spawn(
binary, [binary, test, "--exact"], os.environ,
file_actions=[(os.POSIX_SPAWN_DUP2, out_w, 1),
(os.POSIX_SPAWN_DUP2, out_w, 2)])
os.close(out_w)
out_w = None
chunks = []
while True:
b = os.read(out_r, 65536)
if not b:
break
chunks.append(b)
_, _status, ru = os.wait4(pid, 0)
finally:
if out_w is not None:
os.close(out_w)
os.close(out_r)
text = b"".join(chunks).decode(errors="replace")
# ru_maxrss is kilobytes on Linux.
return ru.ru_maxrss / 1024.0, "1 passed" in text
def report(fast=False):
rc = 0
print("AM-9 / M-D3-MEM — peak resident memory, 100k-event run")
binary = test_binary()
if not binary:
print(" ERROR — could not locate the release test binary",
file=sys.stderr)
return 1
rss, ran = peak_rss_mb(binary)
# Positive control: a run that did not execute the workload must not
# be scored as low memory. Reporting 2 MB because the filter matched
# nothing is exactly the harness-does-nothing shape.
if not ran:
print(f" ERROR — {AM9_TEST} did not run; refusing to report RSS",
file=sys.stderr)
return 1
ok9 = rss <= AM9_MAX_RSS_MB
print(f" {rss:.1f} MB peak RSS [{'ok ' if ok9 else 'FAIL'} "
f"target <= {AM9_MAX_RSS_MB:.0f} MB] "
f"({AM9_MAX_RSS_MB / rss:.1f}x headroom)")
if not ok9:
rc = 2
if fast:
print("\nAM-5 skipped (--fast). Run `make build-time` for the clean "
"build measurement.")
return rc
print(f"\nAM-5 / M-D2-BLD — clean release build on {AM5_MACHINE}")
host = subprocess.run(["hostname"], capture_output=True, text=True).stdout.strip()
if host != AM5_MACHINE:
print(f" NOTE: running on {host!r}, not {AM5_MACHINE!r} — the spec "
f"target is machine-specific, so this is directional only.")
for label, extra in (("dev toolchain (default features)", ()),
("shipped runtime (--no-default-features)",
("--no-default-features",))):
secs = clean_build_seconds(extra)
if secs is None:
print(f" ERROR — clean build failed: {label}", file=sys.stderr)
return 1
mark = "ok " if secs <= AM5_MAX_SECONDS else "FAIL"
print(f" {label:<42} {secs:6.1f} s [{mark} target "
f"<= {AM5_MAX_SECONDS:.0f} s]")
print(" NOTE: reported, not gated — specs/GameKernel.md §5 declares AM-5")
print(" `recorded not gated`. Promoting it is a spec change and")
print(" needs an ADR; this tool does not promote itself.")
return rc
def self_test():
"""Each assertion pins a failure this tool must detect."""
results = []
def check(name, cond, detail=""):
results.append((name, cond, detail))
check("targets are numeric and positive",
AM5_MAX_SECONDS > 0 and AM9_MAX_RSS_MB > 0,
f"AM-5 <= {AM5_MAX_SECONDS:.0f}s, AM-9 <= {AM9_MAX_RSS_MB:.0f}MB")
binary = test_binary()
check("the release test binary is locatable", bool(binary),
os.path.basename(binary) if binary else "NOT FOUND")
if binary:
# The control that matters: a filter matching no test must be
# detected, not scored as a very low RSS. This is the AM-6/AM-7
# shape — a harness reporting a flattering number for work it
# never did.
_, ran = peak_rss_mb(binary, "no::such::test::name")
check("a test that did not run is detected, not scored",
not ran, "refusing to report RSS for work never done")
rss, ran_real = peak_rss_mb(binary)
check("the real workload runs and measures non-zero",
ran_real and rss > 1.0, f"{rss:.1f} MB")
# The control for the bug this tool actually had: cross-validate
# against an independent implementation. getrusage(RUSAGE_CHILDREN)
# reported 38.2 MB where the workload used 12.3 MB, and only a
# second opinion revealed it.
gnu = shutil.which("time") or "/usr/bin/time"
if os.path.exists("/usr/bin/time"):
r = subprocess.run(["/usr/bin/time", "-v", binary, AM9_TEST,
"--exact"], cwd=ROOT, capture_output=True,
text=True)
m = re.search(r"Maximum resident set size \(kbytes\): (\d+)",
r.stdout + r.stderr)
if m:
indep = int(m.group(1)) / 1024.0
near = abs(indep - rss) <= max(3.0, 0.25 * indep)
check("RSS agrees with an independent measurement",
near, f"ours {rss:.1f} MB vs /usr/bin/time {indep:.1f} MB")
check("clean build measures into a throwaway target dir, not target/",
"CARGO_TARGET_DIR" in open(__file__).read()
and "shutil.rmtree" in open(__file__).read(),
"measuring must not destroy the working cache")
print("runtime-metrics self-test (positive control)")
ok = True
for name, passed, det in results:
print(f" [{'ok ' if passed else 'FAIL'}] {name}"
+ (f"{det}" if det else ""))
ok &= passed
return 0 if ok else 1
def main():
enter_root()
if "--self-test" in sys.argv:
return self_test()
return report(fast="--fast" in sys.argv)
if __name__ == "__main__":
sys.exit(main())