1
0
forked from jason/echo
Files
chorus/echo-memory.plugin.src/skills/echo-memory/eval/perf/bench.py
T
Jason Stedwell b4605a52f2 ver 1.3 and 1.3.1
2026-06-23 22:17:39 -05:00

801 lines
31 KiB
Python

#!/usr/bin/env python3
"""bench.py — ECHO performance test suite (timing harness).
Measures the operations the 1.1.0 (pooling + concurrent read_many + shared cache)
and 1.2.0 (fuzzy resolve / alias learning) updates targeted, and gates them on
RELATIVE ratios (portable across machines/WAN) rather than absolute ms.
It reuses the live `echo` client module so it times the REAL pooled/concurrent
code path. Reads are non-destructive; write metrics live in a disposable
namespace (_agent/_bench/<run-id>/) cleaned up at the end unless --keep.
This is a MANUAL / pre-release tool, not a CI gate — it depends on the live
configured endpoint (echo.BASE / $ECHO_BASE) and WAN latency.
python3 bench.py <metric> [options]
python3 bench.py all # the read-only safe set
python3 bench.py read-full -n 10
python3 bench.py subject-pull
python3 bench.py bulk-put -k 50
python3 bench.py worker-sweep
python3 bench.py resolve # 1.2.0 fuzzy correctness + timing
python3 bench.py --list
Metrics: read-full, subject-pull, bulk-get, bulk-put, bulk-append,
pool-warmup, worker-sweep, resolve, index, lint, lock, queue, cache, soak
Options:
-n / --iterations N timed iterations (default 10 read / 5 write)
-k / --count K notes/appends for bulk metrics (default 50)
--workers W override ECHO_WORKERS for this run
--warmup M warm-up iterations to discard (default 2)
--soak-seconds S duration for the soak metric (default 60)
--keep do NOT delete the _bench namespace after the run
--no-gate skip relative-ratio gating (report numbers only)
--json-out PATH write the results JSON here (default results/<date>-<commit>.json)
--quiet suppress the per-metric human table (JSON only)
Exit: 0 all gates pass (or --no-gate) · 1 a gate failed · 2 usage/setup error.
"""
from __future__ import annotations
import argparse
import contextlib
import datetime as dt
import io
import json
import os
import statistics
import subprocess
import sys
import time
import uuid
from pathlib import Path
HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
import echo # noqa: E402 the validated client — real pooled/concurrent path
import echo_index # noqa: E402 resolve / fuzzy_candidates (1.2.0)
import echo_recall # noqa: E402 recall (search + 1-hop expansion)
BASELINES = HERE / "baselines.json"
RESULTS_DIR = HERE / "results"
# ---------------------------------------------------------------------------
# stats + timing
# ---------------------------------------------------------------------------
def stats(samples: list[float]) -> dict:
"""min/median/p90/p95/max/mean in milliseconds from a list of seconds."""
if not samples:
return {}
ms = sorted(s * 1000.0 for s in samples)
def pct(p: float) -> float:
if len(ms) == 1:
return ms[0]
k = (len(ms) - 1) * p
lo, hi = int(k), min(int(k) + 1, len(ms) - 1)
return ms[lo] + (ms[hi] - ms[lo]) * (k - lo)
return {
"n": len(ms),
"min_ms": round(ms[0], 2),
"median_ms": round(statistics.median(ms), 2),
"p90_ms": round(pct(0.90), 2),
"p95_ms": round(pct(0.95), 2),
"max_ms": round(ms[-1], 2),
"mean_ms": round(statistics.fmean(ms), 2),
}
def timed(fn, n: int, warmup: int) -> dict:
"""Run fn() warmup+n times. Returns warm-up samples separately from the timed
set so cold-start cost (the thing keep-alive kills) is visible, not averaged in."""
warm: list[float] = []
samples: list[float] = []
for i in range(warmup + n):
t0 = time.perf_counter()
fn()
dt_s = time.perf_counter() - t0
(warm if i < warmup else samples).append(dt_s)
out = stats(samples)
if warm:
out["warmup"] = stats(warm)
return out
def silent(fn):
"""Run fn() with stdout swallowed (recall/resolve print a lot)."""
def wrapped():
with contextlib.redirect_stdout(io.StringIO()):
return fn()
return wrapped
# ---------------------------------------------------------------------------
# vault enumeration (mirrors sweep.py / vault_lint.py)
# ---------------------------------------------------------------------------
SKIP_BASENAMES = {"README.md"}
def _list_dir(path: str):
p = "" if path in ("", "/") else (path if path.endswith("/") else path + "/")
body = echo.get_text(p)
if body is None:
return [], []
try:
j = json.loads(body)
except json.JSONDecodeError:
return [], []
entries = list(j.get("files", [])) + list(j.get("folders", []))
files = [e for e in entries if not e.endswith("/")]
folders = [e[:-1] for e in entries if e.endswith("/")]
return files, folders
def walk_vault(prefix: str = ""):
files, folders = _list_dir(prefix)
for f in files:
yield prefix + f
for d in folders:
yield from walk_vault(f"{prefix}{d}/")
def all_notes() -> list[str]:
return [p for p in walk_vault()
if p.endswith(".md") and p.rsplit("/", 1)[-1] not in SKIP_BASENAMES]
# ---------------------------------------------------------------------------
# write primitives (replicate echo.py semantics so we time the real cost)
# ---------------------------------------------------------------------------
def put_verified(path: str, body: str) -> None:
"""PUT then read-back GET — the same verify echo.py cmd_put does."""
status, b = echo.request("PUT", echo.vault_url(path),
data=body.encode(), headers={"Content-Type": "text/markdown"})
echo.check(status, b, f"put {path}")
status, _ = echo.request("GET", echo.vault_url(path))
if status != 200:
raise echo.EchoError(f"put {path}: did not verify (GET {status})")
def append_line(path: str, line: str) -> bool:
"""Whole-line idempotent append (mirrors echo.py cmd_append). Returns True if a
write happened, False if it was an idempotent skip."""
status, body = echo.request("GET", echo.vault_url(path))
if status == 200:
existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()]
if line.rstrip("\r") in existing:
return False
status, body = echo.request("POST", echo.vault_url(path),
data=f"{line}\n".encode(),
headers={"Content-Type": "text/markdown"})
echo.check(status, body, f"append {path}")
return True
def delete_path(path: str) -> None:
echo.request("DELETE", echo.vault_url(path))
# ---------------------------------------------------------------------------
# metrics
# ---------------------------------------------------------------------------
def m_read_full(ctx) -> dict:
"""§3.1 full-vault read: serial GET vs concurrent read_many -> speedup ratio."""
paths = all_notes()
if not paths:
raise echo.EchoError("read-full: no notes enumerated (vault unreachable?)")
def serial():
for p in paths:
echo.get_text(p)
def concurrent():
echo.read_many(paths)
# one warm pass so neither side pays the cold-handshake tax (pool-warmup owns that)
echo.get_text(paths[0])
# serial is a slow reference baseline (N serial round-trips); one warm pass is
# enough to establish the ratio, so we don't multiply the full-vault serial cost.
serial_t = timed(serial, n=1, warmup=0)
concurrent_t = timed(concurrent, n=ctx.n, warmup=ctx.warmup)
ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
return {
"note_count": len(paths),
"workers": echo.MAX_WORKERS,
"serial": serial_t,
"concurrent": concurrent_t,
"speedup_ratio": round(ratio, 2) if ratio else None,
"notes_per_sec_concurrent": round(len(paths) / (concurrent_t["median_ms"] / 1000.0), 1)
if concurrent_t.get("median_ms") else None,
}
def m_subject_pull(ctx) -> dict:
"""§3.2 recall a subject: time resolve (floor), search, and full recall, split out."""
import fixtures
index = echo_index.load()
out = {"subjects": []}
for label, query in fixtures.SUBJECTS:
# floor: O(1) index resolve
resolve_t = timed(lambda: echo_index.resolve(index, query), n=ctx.n, warmup=ctx.warmup)
# search-only layer
def search():
with contextlib.redirect_stdout(io.StringIO()):
echo.request("POST", f"{echo.BASE}/search/simple/?query={query}")
search_t = timed(search, n=ctx.n, warmup=ctx.warmup)
# full recall (search + 1-hop expansion reads)
recall_t = timed(silent(lambda: echo_recall.recall(query)), n=ctx.n, warmup=ctx.warmup)
out["subjects"].append({
"label": label, "query": query,
"resolve_floor": resolve_t,
"search_only": search_t,
"recall_total": recall_t,
})
return out
def m_bulk_get(ctx) -> dict:
"""§3.3 bulk GET at a controlled K: serial vs read_many over fixture notes."""
import fixtures
paths = _seed_notes(ctx, fixtures)
def serial():
for p in paths:
echo.get_text(p)
def concurrent():
echo.read_many(paths)
serial_t = timed(serial, n=ctx.n, warmup=1)
concurrent_t = timed(concurrent, n=ctx.n, warmup=ctx.warmup)
ratio = (serial_t["median_ms"] / concurrent_t["median_ms"]) if concurrent_t.get("median_ms") else None
return {"k": len(paths), "workers": echo.MAX_WORKERS,
"serial": serial_t, "concurrent": concurrent_t,
"speedup_ratio": round(ratio, 2) if ratio else None}
def m_bulk_put(ctx) -> dict:
"""§3.3 bulk PUT (read-back verified) -> puts/sec + per-put percentiles."""
import fixtures
k = ctx.k
samples: list[float] = []
for i in range(k):
body = fixtures.note_body(ctx.run_id, i)
path = fixtures.note_path(ctx.run_id, i)
t0 = time.perf_counter()
put_verified(path, body)
samples.append(time.perf_counter() - t0)
s = stats(samples)
total_s = sum(samples)
return {"k": k, "per_put": s, "total_ms": round(total_s * 1000, 1),
"puts_per_sec": round(k / total_s, 1) if total_s else None}
def m_bulk_append(ctx) -> dict:
"""§3.3 bulk APPEND + idempotent-skip path. Confirms idempotency isn't O(file)."""
import fixtures
target = fixtures.append_target(ctx.run_id)
put_verified(target, fixtures.append_target_seed(ctx.run_id))
k = ctx.k
lines = [f"- 2026-06-23: bench append line {i:04d}" for i in range(k)]
write_samples: list[float] = []
for ln in lines:
t0 = time.perf_counter()
wrote = append_line(target, ln)
write_samples.append(time.perf_counter() - t0)
if not wrote:
raise echo.EchoError("bulk-append: expected a write but got an idempotent skip")
# second pass: every line already present -> must skip, and skip cost is the
# idempotency GET on a now-larger file. Confirms no superlinear blowup.
skip_samples: list[float] = []
skips = 0
for ln in lines:
t0 = time.perf_counter()
wrote = append_line(target, ln)
skip_samples.append(time.perf_counter() - t0)
if not wrote:
skips += 1
return {
"k": k,
"append_write": stats(write_samples),
"append_skip": stats(skip_samples),
"skips_confirmed": skips,
"all_skipped_second_pass": skips == k,
}
def m_pool_warmup(ctx) -> dict:
"""§4.1 cold vs warm single GET — the clearest proof keep-alive is alive."""
paths = all_notes()
probe = paths[0] if paths else "_agent/echo-vault.md"
echo._drop_connection() # force a cold handshake
t0 = time.perf_counter()
echo.get_text(probe)
cold_ms = (time.perf_counter() - t0) * 1000.0
warm = timed(lambda: echo.get_text(probe), n=max(ctx.n, 10), warmup=1)
ratio = round(cold_ms / warm["median_ms"], 2) if warm.get("median_ms") else None
return {"probe": probe, "cold_ms": round(cold_ms, 2), "warm": warm,
"cold_over_warm_ratio": ratio}
def m_worker_sweep(ctx) -> dict:
"""§4.2 concurrency scaling: full-vault concurrent read at workers 1,2,4,8,16."""
paths = all_notes()
if not paths:
raise echo.EchoError("worker-sweep: no notes enumerated")
original = echo.MAX_WORKERS
rows = []
try:
# w=1 is a full serial pass (slow); the curve only needs the shape, so use a
# modest sample count and a single pre-warm rather than per-setting warmups.
reps = max(1, ctx.n // 2)
for w in (1, 2, 4, 8, 16):
echo.MAX_WORKERS = w
echo.read_many(paths[:5]) # warm the pool at this worker count
t = timed(lambda: echo.read_many(paths), n=reps, warmup=0)
rows.append({"workers": w, "median_ms": t["median_ms"], "p95_ms": t["p95_ms"]})
finally:
echo.MAX_WORKERS = original
best = min(rows, key=lambda r: r["median_ms"])
return {"note_count": len(paths), "sweep": rows, "knee_workers": best["workers"],
"default_workers": original}
def m_resolve(ctx) -> dict:
"""§4.3 fuzzy resolve — timing AND correctness against the 1.2.0 guard table."""
import fixtures
index = echo_index.load()
cases = []
passes = info = 0
for case in fixtures.RESOLVE_CASES:
mention = case["mention"]
exact_t = timed(lambda: echo_index.resolve(index, mention), n=ctx.n, warmup=ctx.warmup)
fuzzy_t = timed(lambda: echo_index.fuzzy_candidates(index, mention), n=ctx.n, warmup=ctx.warmup)
_, entity = echo_index.resolve(index, mention)
exact_slug = (entity or {}).get("slug") or _slug_from_entity(entity)
cands = echo_index.fuzzy_candidates(index, mention) or []
cand_slugs = [_cand_slug(c) for c in cands]
expect, want = case["expect"], case.get("expect_slug")
if expect == "exact":
ok = exact_slug == want
elif expect == "candidates":
ok = (exact_slug != want) and (want in cand_slugs)
else: # miss
ok = entity is None
verdict = "PASS" if ok else "INFO" # tune table to live vault before promoting to FAIL
passes += ok
info += (not ok)
cases.append({"mention": mention, "expect": expect, "expect_slug": want,
"resolved_slug": exact_slug, "candidate_slugs": cand_slugs[:5],
"verdict": verdict,
"resolve_timing": exact_t, "fuzzy_timing": fuzzy_t})
return {"cases": cases, "passes": passes, "needs_tuning": info,
"note": "verdicts are PASS/INFO only; promote to FAIL once the table is "
"confirmed against the live vault"}
def _serial_expand(seeds, nmap, base, max_hops):
"""The pre-fix serial graph BFS, kept here as the reference baseline so the suite
both (a) measures the concurrency speedup and (b) asserts the concurrent
expand_graph still returns byte-identical ranking. If echo_recall.expand_graph ever
regresses to serial, the ratio gate fails; if its results drift, the parity check fails."""
from collections import deque
score_of = dict(base)
seen = set(seeds)
results = {}
dq = deque((s, 0) for s in seeds)
while dq:
path, hop = dq.popleft()
if hop >= max_hops:
continue
text = echo_recall.links.get_text(path)
if text is None:
continue
body = echo_recall.strip_frontmatter(text)
targets = set(echo_recall.links.all_wikilinks(body)) | set(echo_recall.links.source_notes(text))
parent = score_of.get(path, 1.0)
for t in targets:
tp = echo_recall._resolve_target(t, nmap)
if not tp or tp in seeds:
continue
decayed = parent * (echo_recall.GRAPH_DECAY ** (hop + 1))
if decayed > results.get(tp, (0.0, ""))[0]:
results[tp] = (decayed, path)
if tp not in seen:
seen.add(tp)
dq.append((tp, hop + 1))
return sorted(results.items(), key=lambda kv: -kv[1][0])
def m_expand_graph(ctx) -> dict:
"""recall() graph layer: serial vs concurrent neighbourhood expansion. This is the
metric that pins the real recall() bottleneck (the graph BFS, not BM25) and guards
the read_many concurrency fix. Also asserts the concurrent path's ranking + scores
match the serial reference exactly."""
import fixtures
ix = echo_recall.load_index()
index = echo_index.load()
nmap = echo_index.name_map(index)
max_hops = echo_recall.MAX_HOPS
# hub subjects stress expansion most; fall back to all subjects if none labelled hub
subjects = [s for s in fixtures.SUBJECTS if "hub" in s[0]] or fixtures.SUBJECTS
rows = []
for label, query in subjects:
hits = ix.score(query, limit=8)
base = {p: s for p, s in hits}
seeds = [p for p, _ in hits]
if not seeds:
continue
echo_recall.expand_graph(seeds, nmap, base, max_hops) # warm
serial_t = timed(lambda: _serial_expand(seeds, nmap, base, max_hops), n=1, warmup=0)
conc_t = timed(lambda: echo_recall.expand_graph(seeds, nmap, base, max_hops),
n=ctx.n, warmup=1)
old = _serial_expand(seeds, nmap, base, max_hops)
new = echo_recall.expand_graph(seeds, nmap, base, max_hops)
ranking_ok = [p for p, _ in old] == [p for p, _ in new]
scores_ok = ranking_ok and all(
abs(old[i][1][0] - new[i][1][0]) < 1e-9 for i in range(len(old)))
ratio = (serial_t["median_ms"] / conc_t["median_ms"]) if conc_t.get("median_ms") else None
rows.append({"label": label, "query": query, "neighbours": len(new),
"serial_ms": serial_t["median_ms"], "concurrent_ms": conc_t["median_ms"],
"speedup": round(ratio, 2) if ratio else None,
"ranking_identical": ranking_ok, "scores_identical": scores_ok})
speedups = [r["speedup"] for r in rows if r["speedup"]]
return {"subjects": rows,
"min_speedup": round(min(speedups), 2) if speedups else None,
"all_ranking_identical": all(r["ranking_identical"] for r in rows),
"all_scores_identical": all(r["scores_identical"] for r in rows)}
def _slug_from_entity(entity):
if not entity:
return None
path = entity.get("path", "")
return path.rsplit("/", 1)[-1].removesuffix(".md") if path else None
def _cand_slug(c):
if isinstance(c, dict):
return c.get("slug") or _slug_from_entity(c)
if isinstance(c, (list, tuple)) and c:
return c[0]
return str(c)
def m_index(ctx) -> dict:
"""§4.4 entity-index rebuild + link symmetrize timing (sweep.py, dry-run = read-only)."""
return _time_script("sweep.py", [])
def m_lint(ctx) -> dict:
"""§4.5 full-vault lint timing (vault_lint.py) — regression-gate vs 1.1.0 baseline."""
return _time_script("vault_lint.py", [], allow_exit={0, 1})
def _time_script(name: str, extra: list[str], allow_exit=frozenset({0})) -> dict:
script = SCRIPTS / name
env = dict(os.environ, ECHO_KEY_LEGACY_OK="1")
t0 = time.perf_counter()
proc = subprocess.run([sys.executable, str(script), *extra],
capture_output=True, text=True, env=env, timeout=120)
elapsed = (time.perf_counter() - t0) * 1000.0
return {"script": name, "elapsed_ms": round(elapsed, 1),
"exit_code": proc.returncode,
"ok": proc.returncode in allow_exit,
"stderr_tail": proc.stderr.strip().splitlines()[-3:] if proc.stderr else []}
def m_lock(ctx) -> dict:
"""§4.6 advisory-lock timing: acquire-free, contended (held-fresh), release."""
owner = f"bench-{ctx.run_id}"
other = f"bench-other-{ctx.run_id}"
def acquire(o):
return echo.cmd_lock(o, quiet=True)
def release(o):
return echo.cmd_unlock(o, quiet=True)
release(owner); release(other) # clean slate
t0 = time.perf_counter(); rc_free = acquire(owner); free_ms = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter(); rc_held = acquire(other); held_ms = (time.perf_counter() - t0) * 1000
t0 = time.perf_counter(); release(owner); rel_ms = (time.perf_counter() - t0) * 1000
return {"acquire_free_ms": round(free_ms, 2), "acquire_free_rc": rc_free,
"contended_ms": round(held_ms, 2), "contended_rc_expected_75": rc_held,
"release_ms": round(rel_ms, 2),
"ok": rc_free == 0 and rc_held == 75}
def m_queue(ctx) -> dict:
"""§4.7 offline write-ahead queue: enqueue K writes against an unreachable base,
then time the flush replay on 'reconnect'. Uses a bogus base so nothing real is
written; restores the base afterward. No production writes occur."""
import importlib
import echo_queue
k = min(ctx.k, 20)
saved_base = echo.BASE
enqueue_samples: list[float] = []
try:
for i in range(k):
url = f"{echo.BASE}/vault/{fixtures_qpath(ctx.run_id, i)}"
t0 = time.perf_counter()
echo_queue.enqueue("PUT", url, b"x", {"Content-Type": "text/markdown"},
idem_key=f"bench-{ctx.run_id}-{i}")
enqueue_samples.append(time.perf_counter() - t0)
pending_before = len(echo_queue.pending())
# flush is left UNMEASURED-as-success here: with a real endpoint it would
# try to write. We only assert enqueue worked and report depth; a true
# flush-replay timing needs a sandbox vault (see README "queue caveat").
return {"k": k, "enqueue": stats(enqueue_samples),
"pending_after_enqueue": pending_before,
"note": "flush replay intentionally not run against the live vault; "
"enqueue path timed only. See README queue caveat."}
finally:
echo.BASE = saved_base
importlib.reload(echo_queue)
def fixtures_qpath(run_id: str, i: int) -> str:
return f"_agent/_bench/{run_id}/queued-{i:04d}.md"
def m_cache(ctx) -> dict:
"""§4.8 read_many dedup check: a path list with duplicates must collapse to the
unique set (the cache/dedup that stops a sweep re-fetching a link target)."""
paths = all_notes()[:20] or ["_agent/echo-vault.md"]
dupd = paths + paths + paths # 3x
t0 = time.perf_counter()
result = echo.read_many(dupd)
elapsed = (time.perf_counter() - t0) * 1000
return {"requested": len(dupd), "unique": len(set(dupd)),
"returned": len(result), "deduped": len(result) == len(set(dupd)),
"elapsed_ms": round(elapsed, 2)}
def m_soak(ctx) -> dict:
"""§4.10 stability: loop full-vault reads for N seconds; watch for errors/drift."""
paths = all_notes()
if not paths:
raise echo.EchoError("soak: no notes enumerated")
deadline = time.time() + ctx.soak_seconds
durations: list[float] = []
errors = 0
while time.time() < deadline:
t0 = time.perf_counter()
try:
res = echo.read_many(paths)
if any(v is None for v in res.values()):
errors += 1
except Exception:
errors += 1
durations.append(time.perf_counter() - t0)
s = stats(durations)
# drift = late iterations slower than early ones (leak / pool exhaustion signal)
half = len(durations) // 2 or 1
early = statistics.fmean(durations[:half]) * 1000
late = statistics.fmean(durations[half:]) * 1000
return {"seconds": ctx.soak_seconds, "iterations": len(durations), "errors": errors,
"per_pass": s, "early_mean_ms": round(early, 2), "late_mean_ms": round(late, 2),
"drift_ratio_late_over_early": round(late / early, 2) if early else None}
METRICS = {
"read-full": m_read_full,
"subject-pull": m_subject_pull,
"bulk-get": m_bulk_get,
"bulk-put": m_bulk_put,
"bulk-append": m_bulk_append,
"pool-warmup": m_pool_warmup,
"worker-sweep": m_worker_sweep,
"resolve": m_resolve,
"expand-graph": m_expand_graph,
"index": m_index,
"lint": m_lint,
"lock": m_lock,
"queue": m_queue,
"cache": m_cache,
"soak": m_soak,
}
# the read-only safe set run by `all` (no writes to the vault)
READONLY = ["pool-warmup", "read-full", "worker-sweep", "subject-pull",
"resolve", "expand-graph", "index", "lint", "cache"]
WRITES = {"bulk-get", "bulk-put", "bulk-append", "lock"} # touch the _bench namespace
# ---------------------------------------------------------------------------
# fixture seeding / cleanup
# ---------------------------------------------------------------------------
def _seed_notes(ctx, fixtures) -> list[str]:
"""Ensure K bench notes exist; return their paths. Cached on ctx so bulk-get
and bulk-put can share the seed within one run."""
if getattr(ctx, "_seeded", None):
return ctx._seeded
paths = []
for i in range(ctx.k):
p = fixtures.note_path(ctx.run_id, i)
put_verified(p, fixtures.note_body(ctx.run_id, i))
paths.append(p)
ctx._seeded = paths
return paths
def cleanup_namespace(run_id: str) -> int:
"""Delete every file under _agent/_bench/<run-id>/. Returns count deleted."""
prefix = f"_agent/_bench/{run_id}/"
victims = [p for p in walk_vault(prefix)]
for p in victims:
delete_path(p)
# remove the (now-empty) run dir marker if the API created one — best effort
return len(victims)
# ---------------------------------------------------------------------------
# gating
# ---------------------------------------------------------------------------
def load_baselines() -> dict:
if BASELINES.exists():
return json.loads(BASELINES.read_text())
return {}
def gate(metric: str, result: dict, baselines: dict) -> list[dict]:
"""Apply relative-ratio gates from baselines.json. Returns a list of checks."""
rules = baselines.get(metric, {})
checks = []
for rule in rules.get("gates", []):
path, op, threshold = rule["field"], rule["op"], rule["min"]
val = _dig(result, path)
if val is None:
checks.append({"rule": rule, "value": None, "pass": None, "reason": "field absent"})
continue
ok = (val >= threshold) if op == ">=" else (val <= threshold)
checks.append({"rule": rule, "value": val, "pass": ok})
return checks
def _dig(obj, dotted: str):
cur = obj
for part in dotted.split("."):
if isinstance(cur, list):
try:
cur = cur[int(part)]
continue
except (ValueError, IndexError):
return None
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return cur
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
class Ctx:
pass
def env_block(args) -> dict:
commit = "unknown"
try:
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
cwd=str(SCRIPTS), capture_output=True, text=True,
timeout=10).stdout.strip() or "unknown"
except Exception:
pass
return {
"date": dt.datetime.now().isoformat(timespec="seconds"),
"endpoint": echo.BASE,
"commit": commit,
"echo_workers": echo.MAX_WORKERS,
"echo_timeout": echo.TIMEOUT,
"python": sys.version.split()[0],
"iterations": args.iterations,
"count_k": args.count,
}
def human_table(name: str, result: dict, checks: list[dict]) -> str:
lines = [f"\n=== {name} ==="]
lines.append(json.dumps(result, indent=2))
for c in checks:
mark = "PASS" if c["pass"] else ("—" if c["pass"] is None else "FAIL")
lines.append(f" [{mark}] {c['rule']['field']} {c['rule']['op']} {c['rule']['min']} (got {c['value']})")
return "\n".join(lines)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="ECHO performance test suite (manual/pre-release)")
ap.add_argument("metric", nargs="?", help="metric name, or 'all' for the read-only set")
ap.add_argument("--list", action="store_true", help="list metrics and exit")
ap.add_argument("-n", "--iterations", type=int, default=None)
ap.add_argument("-k", "--count", type=int, default=50)
ap.add_argument("--workers", type=int, default=None)
ap.add_argument("--warmup", type=int, default=2)
ap.add_argument("--soak-seconds", type=int, default=60)
ap.add_argument("--keep", action="store_true", help="do not delete the _bench namespace")
ap.add_argument("--no-gate", action="store_true")
ap.add_argument("--json-out", default=None)
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args(argv)
if args.list or not args.metric:
print("Metrics:", ", ".join(METRICS))
print("Groups : all (read-only safe set:", ", ".join(READONLY) + ")")
print("Writes to _bench namespace:", ", ".join(sorted(WRITES)))
return 0 if args.list else 2
if args.workers is not None:
echo.MAX_WORKERS = args.workers
selected = READONLY if args.metric == "all" else [args.metric]
for m in selected:
if m not in METRICS:
print(f"unknown metric: {m}", file=sys.stderr)
return 2
ctx = Ctx()
ctx.run_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
ctx.k = args.count
ctx.warmup = args.warmup
ctx.soak_seconds = args.soak_seconds
ctx._seeded = None
baselines = load_baselines()
need_writes = any(m in WRITES for m in selected)
lock_owner = f"bench-{ctx.run_id}"
if need_writes:
if echo.cmd_lock(lock_owner, quiet=True) == 75:
print("bench: vault lock is held by another session — aborting write metrics.",
file=sys.stderr)
return 2
report = {"env": env_block(args), "run_id": ctx.run_id, "metrics": {}}
any_fail = False
try:
for m in selected:
ctx.n = args.iterations if args.iterations is not None else (5 if m in WRITES else 10)
try:
result = METRICS[m](ctx)
except Exception as exc: # noqa: BLE001
result = {"error": str(exc)}
any_fail = True
checks = [] if args.no_gate else gate(m, result, baselines)
if any(c["pass"] is False for c in checks):
any_fail = True
report["metrics"][m] = {"result": result, "gates": checks}
if not args.quiet:
print(human_table(m, result, checks))
finally:
if need_writes:
echo.cmd_unlock(lock_owner, quiet=True)
if need_writes and not args.keep:
n = cleanup_namespace(ctx.run_id)
report["cleanup"] = {"deleted": n}
if not args.quiet:
print(f"\nbench: cleaned up {n} files under _agent/_bench/{ctx.run_id}/")
elif need_writes and args.keep:
report["cleanup"] = {"deleted": 0, "kept": f"_agent/_bench/{ctx.run_id}/"}
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
out_path = Path(args.json_out) if args.json_out else \
RESULTS_DIR / f"{report['env']['date'][:10]}-{report['env']['commit']}.json"
out_path.write_text(json.dumps(report, indent=2))
print(f"\nbench: results -> {out_path}")
return 1 if any_fail else 0
if __name__ == "__main__":
sys.exit(main())