801 lines
31 KiB
Python
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
|
|
endpoint (echoapi.alwisp.com) 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())
|