Files
openswarm/e2e/browser-v3/bench.py
T
ciregenzandClaude Opus 5 3c50359146 [eric] browser-v3: fix the instrument first, then the two composer bugs it could finally see
Six of the eleven defects here were in the MEASUREMENT, not the product, and they
were wrong in both directions.

Harness, all of which silently produced wrong numbers:
- coverage.py preflight refused every sweep on a box holding exactly one backend:
  stack.sh's supervisor is a `bash -c` quoting the whole uvicorn line, so it carries
  both "-m uvicorn backend.main" AND the venv python path. Discriminate on POSITION.
- stack.sh status reported 2 backends over 1 and 0 webpack over a live dev server
  (webpack retitles its process). A status check whose job is preventing a second
  stack, failing in the direction that lets one land.
- c7_run.sh/c8_run.sh slice r6_be.log while stack.sh names logs by TAG: a stack under
  any other tag hands every trial an empty slice and the sweep reports a confident
  0/108. Now refuses loudly; it caught this exact mistake on first use.
- "Browser command timed out" was bucketed infra. It is ONE command blowing its own
  budget, not a dead webview: all 4 such rows were BrowserFindComposer at exactly its
  30s cap, every run completed after, zero card-gone markers in the whole log. Filed
  as infra it read as 11.8% flake AND lifted holdout reach 70% -> 84%.
- api_retry / rate_limit_error now grade as infra. A provider 429 storm turned clean
  15-21s exclusions into 188s product_no_composer rows.
- bench.py prints reach BOTH ways when a row is UNVERIFIED. An exclusion resting on
  the agent's own word quietly flatters the score, and coverage.py's own instruction
  to confirm it by hand goes unread (I quoted a 100% that excluded onlinegdb).

Timing was measuring 0.2% of the run: prestage completes BEFORE metrics_started_at,
so other_ms was 25ms of a 12700ms median while prestage (4146ms, ~61%) sat in no
bucket at all. prestage_ms/task_ms are now recorded; total_ms is deliberately NOT
redefined, which would invalidate every before/after already taken against it.

Product:
- find_composer rejected ACE/CodeMirror-5/Monaco composers. Their input is a ~1x1
  offscreen textarea that paints into a sibling div, so it can never pass a size
  gate. Accept it when a VISIBLE ancestor is composer-sized; honeypots stay out
  because the input itself must not be display:none/visibility:hidden/opacity:0.
  anon reach 80% -> 100%, holdout 89% -> 90%, p95 38.6s -> 9.7s.
- the composer poll slept a blind 0+1.2+1.4 = 2.6s whenever prestage staged nothing,
  which is nearly every run, and it was the whole of other_ms's suspicious constancy
  (2610-2613ms regardless of tools_ms). Stop when two reads are identical, the rule
  the opener poll 40 lines below already applies. other_ms -53.7%, tools_ms flat.
- prestage no longer navigates to the page it is already on, nor sleeps 0.35s before
  its first settle probe.
- is_replay_boundary reasoned from the NAME alone, so x.com's composer textbox named
  "Post text" was ruled an irreversible send and truncated its replay to a bare
  navigate. Excluded by ROLE; first_unsafe_step now passes role through at all.

Measured on this box: reach 100% (83% if onlinegdb's unverified exclusion is bogus),
0 false successes in ~155 runs, prestage tier-0/1 2702ms, other_ms -53.7%, infra
flake 0/158, holdout 18/20. Criteria 2/4/9 need live writes and are untouched.
Full evidence, including what did NOT work, in e2e/browser-v3/RESULTS_2026-08-06.md.

Also drops the tracked electron/node_modules symlink pointing at another machine's
Downloads folder; it is dangling on every other checkout and re-breaks the install on
any stash or checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 23:21:05 -07:00

298 lines
16 KiB
Python

"""v3 benchmark: N trials per site, structured traces, honest denominators.
Two things this does that the old sweep did not.
1. N trials. One run per site is noise: on 2026-08-02 x went 2-of-3 and instagram 1-of-3, and a
single-run number would have called either one whatever the last run happened to be.
2. It separates INFRASTRUCTURE failure from PRODUCT failure and publishes both. A router death is
not a coverage miss, and hiding it inflates the score; calling a real bug "infrastructure" is the
mistake that hid the surrogate crash for a whole session. Every attempt lands in the raw file.
Timings come from browser_metrics/tasks.jsonl (the wall/llm/tools/other split), not from log
scraping, because the wall clock cannot tell "our code got faster" from "the model took fewer turns".
Grading is imported from coverage.py, never reimplemented. Two graders would drift and the harness
would start lying again in a new way.
"""
import json
import os
import statistics
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# coverage.py sits BESIDE this file. Pointing one directory higher was a live trap rather than a
# typo: PyPI ships a package called `coverage` (the code-coverage tool), so on any machine that has
# it installed `import coverage` would quietly bind to that instead of the grader, and every verdict
# would come from a module that has never heard of a composer. Insert this directory FIRST.
sys.path.insert(0, HERE)
import coverage as C # noqa: E402 the ONE grader
assert hasattr(C, "TASKS"), f"imported the wrong 'coverage' module: {C.__file__}"
ROOT = os.path.dirname(os.path.dirname(HERE))
METRICS = os.path.join(ROOT, "backend/data/browser_metrics/tasks.jsonl")
# Frozen 2026-08-02 at HEAD 1508e03c, before any site-specific v3 work. See HOLDOUT_FROZEN.md.
# Reach only, dry run, never submitted: these are not the user's accounts.
HOLDOUT = {
"bsky": 'Go to bsky.app and create a post with exactly this text: "coverage probe alpha"',
"mastodon": 'Go to mastodon.social and create a post with exactly this text: "coverage probe alpha"',
"devto": 'Go to dev.to and start a new post with the body "coverage probe alpha"',
"lobsters": 'Go to lobste.rs and start a new story with the text "coverage probe alpha"',
"pastebin": 'Go to pastebin.com and create a new paste with the text "coverage probe alpha"',
"discourse": 'Go to meta.discourse.org and start a new topic with the body "coverage probe alpha"',
}
HOLDOUT_PAGE = {
"bsky": r"bsky\.app",
"mastodon": r"mastodon\.social",
"devto": r"dev\.to/new",
"lobsters": r"lobste\.rs/stories/new",
"pastebin": r"pastebin\.com",
"discourse": r"discourse\.org",
}
# Addendum, frozen at HEAD ab898964 before its first run. The six above are mostly signed out and
# therefore unscoreable; these publish a composer to anonymous users, so a failure is always OUR
# failure and never a session state. 4 plain textareas + 2 contenteditable rich editors, because a
# holdout of only rich editors would flatter a suite whose wins are nearly all contenteditable.
HOLDOUT.update({
"rentry": 'Go to rentry.co and create a paste with the text "coverage probe alpha"',
"dpaste": 'Go to dpaste.org and create a paste with the text "coverage probe alpha"',
"controlc": 'Go to controlc.com and create a paste with the text "coverage probe alpha"',
"txti": 'Go to txti.es and create a page with the text "coverage probe alpha"',
"justpaste": 'Go to justpaste.it and create a note with the text "coverage probe alpha"',
"telegraph": 'Go to telegra.ph and create a page with the text "coverage probe alpha"',
})
HOLDOUT_PAGE.update({
"rentry": r"rentry\.co",
"dpaste": r"dpaste\.org",
"controlc": r"controlc\.com",
"txti": r"txti\.es",
"justpaste": r"justpaste\.it",
"telegraph": r"telegra\.ph",
})
# Infrastructure, not coverage. Kept separate and PUBLISHED, never folded into either column.
INFRA = (
# The backend itself. It has died mid-sweep on a clean SIGTERM with nothing in its log
# (2026-08-05), and an unnoticed death turns every following trial into a fake product failure.
# `BACKEND RESTARTED` is written by the supervisor in stack.sh; BACKEND_DOWN by the harness when
# it cannot reach :8326 at all.
("infra_backend", (C.BACKEND_DOWN, "[stack] BACKEND RESTARTED")),
# 'api_retry' is the SDK backing off against the provider, and it belongs here for the same reason a router death does: the run measured the lane, not the write path. Measured 2026-08-06, a cloud-proxy wobble put six retries in front of every dispatch and rows that had been 'not_measurable, signed out' at 15-21s all sweep came back at 180-188s graded product_no_composer, including bsky, mastodon and codemirror. Nothing about the product changed; the sweep simply started grading an outage.
("infra_router", ("9Router watchdog", "9Router process died", "No AI provider connected",
"'subtype': 'api_retry'", "rate_limit_error")),
# The webview is gone or wedged. "card is unavailable" was in this list and appears NOWHERE in
# the codebase, so a third of this bucket could never match; these are P_CARD_GONE_MARKERS
# (browser_loop.py) plus the renderer's own read-timeout wording, all grep-verified.
# "Browser command timed out" was in this tuple and did not belong: ONE command blowing its own budget is not the webview being gone. Measured 2026-08-06, all 4 "infra" rows were BrowserFindComposer at exactly its 30s cap on dpaste/disqus, every run COMPLETED after, the next card opened fine, and the 34-run log had zero card-gone markers.
# Filed as infra it read as 11.8% flake AND shrank criterion 8's denominator 23 -> 19, lifting reach 70% -> 84%: bench.py's own documented sin, calling a real bug infrastructure. A genuinely wedged card still trips the markers below, and those still win.
("infra_browser", ("too busy to read", "not an electron webview",
"no dashboard is connected", "page unresponsive")),
# No renderer attached. Its own bucket because it is neither the router nor the page: Electron
# launched before webpack was serving, hit a dead URL and quit, and 44 straight runs then failed
# with "dispatch refused: no dashboard". Filed as generic harness trouble that read as a mystery;
# named, it says exactly which process to restart.
("infra_no_renderer", ("dispatch refused: no dashboard", "no OpenSwarm window is connected")),
)
# Editor-shape addendum, frozen at HEAD e445ca3e before its first run. Coverage should be counted per
# EDITOR LIBRARY, not per famous site: the web's writing surfaces cluster into ~8 shapes and most
# sites adopt one. These five fill the two holes the popularity-picked suites left entirely empty,
# iframe-embedded composers and the classic CMS editors. All public demos, so no login gates them.
# Hosts that have gone OFFLINE since the holdout was frozen. A dead site cannot measure our code, so
# it leaves the denominator exactly as a signed-out one does. Listed with its evidence rather than
# deleted from HOLDOUT, because an exclusion is a claim that the product was not on trial and that
# claim has to survive being read out loud. Confirmed by fetching the page directly, never on the
# agent's say-so -- same rule as every other exclusion here. Grading a site's own shutdown notice as
# "no composer" charges our code for someone else's decision: txti alone cost 2 rows and dragged
# holdout reach from 82% to 75%.
RETIRED = {
"txti": "txti.es retired; the page reads 'Txti has retired' (direct fetch, 2026-08-06)",
"dpaste": "dpaste.org offline; 'dpaste has temporarily halted its operation as a public "
"pastebin' (direct fetch, 2026-08-06)",
}
HOLDOUT.update({
"disqus": 'Go to https://blog.disqus.com/ and open the first blog post, then write a '
'comment "coverage probe alpha" in the Disqus comment box at the bottom',
"quill": 'Go to quilljs.com/playground and write "coverage probe alpha" in the editor',
"tinymce": 'Go to tiny.cloud and write "coverage probe alpha" in the demo editor',
"ckeditor": 'Go to ckeditor.com/ckeditor-5/demo and write "coverage probe alpha" in the editor',
"codemirror": 'Go to codepen.io/pen and write "coverage probe alpha" in the HTML pane',
})
HOLDOUT_PAGE.update({
"disqus": r"disqus\.com", "quill": r"quilljs\.com", "tinymce": r"tiny\.cloud",
"ckeditor": r"ckeditor\.com", "codemirror": r"codepen\.io",
})
# Buckets that leave the reach denominator. Defined ONCE: the two call sites used to list them by
# hand and a new bucket added to only one of them silently changes the score.
EXCLUDED = ("not_measurable", "infra_backend", "infra_router", "infra_browser",
"infra_harness", "infra_no_renderer")
def classify(v, slice_, site=""):
"""One bucket per trial. Unknowns and timeouts are failures, never silently dropped."""
# A host that no longer exists is an unmeasurable row, not a coverage miss. Checked FIRST so a dead site's error page cannot be graded as anything else.
if site in RETIRED:
return "not_measurable"
for name, needles in INFRA:
if any(n in slice_ for n in needles):
return name
if v.get("invalid"):
return "infra_harness"
if v.get("unmeasurable"):
return "not_measurable"
if v["composer"]:
return "ok"
d = v["detail"]
if "FILL DIED" in d:
return "product_fill_died"
if "WRONG SURFACE" in d:
return "product_wrong_surface"
# A command that blew its own budget while the card kept serving. Named rather than folded into
# product_no_composer: "we never found a composer" and "we looked for 30s and gave up" point at
# different fixes, and the second one is a latency bug wearing a coverage bug's clothes.
if "Browser command timed out" in slice_:
return "product_command_timeout"
return "product_no_composer"
def metrics_between(t0, t1):
"""The browser sub-agent's own timing rows for this trial's window."""
out = []
try:
with open(METRICS, encoding="utf-8") as f:
for line in f:
try:
r = json.loads(line)
except ValueError:
continue
if t0 <= float(r.get("ts", 0)) <= t1 and r.get("llm_ms") is not None:
out.append(r)
except OSError:
pass
return out
def trial(site, task, n_idx):
t0 = time.time()
slice_, wall = C.run(site, task)
t1 = time.time()
v = C.verdict(site, slice_)
v["site"], v["trial"] = site, n_idx
bucket = classify(v, slice_, site)
if site in RETIRED:
v["detail"] = f"NOT MEASURABLE: {RETIRED[site]}"
rows = metrics_between(t0 - 2, t1 + 2)
best = max(rows, key=lambda r: r.get("total_ms", 0)) if rows else {}
rec = {
"site": site, "trial": n_idx, "bucket": bucket, "wall_s": wall,
"detail": v["detail"], "composer": bool(v["composer"]), "submit": bool(v["submit"]),
"total_ms": best.get("total_ms"), "llm_ms": best.get("llm_ms"),
"tools_ms": best.get("tools_ms"), "other_ms": best.get("other_ms"),
# prestage_ms/task_ms are the buckets total_ms never contained; criterion 6 is meaningless without them, since other_ms measured 25ms of a 12700ms run on the frozen baseline.
"prestage_ms": best.get("prestage_ms"), "task_ms": best.get("task_ms"),
"turns": best.get("turns"),
}
art = os.path.join(HERE, "results", f"{site}_{n_idx}.log")
os.makedirs(os.path.dirname(art), exist_ok=True)
with open(art, "w", encoding="utf-8") as f:
f.write(slice_)
rec["artifact"] = os.path.relpath(art, HERE)
return rec
def report(recs, label):
print(f"\n=== {label}: {len(recs)} attempts ===")
by_site = {}
for r in recs:
by_site.setdefault(r["site"], []).append(r)
print(f" {'site':<11}{'reach':>8} buckets")
for site, rs in by_site.items():
meas = [r for r in rs if r["bucket"] not in EXCLUDED]
ok = sum(r["bucket"] == "ok" for r in rs)
rate = f"{ok}/{len(meas)}" if meas else "0/0"
seen = {}
for r in rs:
seen[r["bucket"]] = seen.get(r["bucket"], 0) + 1
print(f" {site:<11}{rate:>8} {seen}")
meas = [r for r in recs if r["bucket"] not in EXCLUDED]
ok = [r for r in recs if r["bucket"] == "ok"]
infra = [r for r in recs if r["bucket"].startswith("infra")]
print(f"\n REACH (product-measurable denominator): {len(ok)}/{len(meas)} "
f"= {round(100 * len(ok) / len(meas)) if meas else 0}%")
# An UNVERIFIED exclusion rests on the agent's own word with no page evidence, and coverage.py says outright: confirm by hand before quoting any number that leaves them out. Nobody does, including me -- I quoted a 100% that silently excluded onlinegdb on exactly this basis. Print the pessimistic figure alongside so the optimistic one can never be read alone.
p_unv = [r for r in recs if "UNVERIFIED" in str(r.get("detail", ""))]
if p_unv:
p_d2 = len(meas) + len(p_unv)
print(f" REACH if those UNVERIFIED exclusions are really OUR misses: {len(ok)}/{p_d2} "
f"= {round(100 * len(ok) / p_d2)}% <- {sorted({r['site'] for r in p_unv})}")
print(f" not measurable (signed out or offline): "
f"{sum(r['bucket'] == 'not_measurable' for r in recs)}")
# Named individually, never folded into the count: an offline host is the one exclusion a reader cannot re-derive from the page evidence in the row, so it has to be stated outright.
for p_site in sorted({r["site"] for r in recs if r["site"] in RETIRED}):
print(f" EXCLUDED, host offline: {p_site} -- {RETIRED[p_site]}")
print(f" INFRASTRUCTURE failures: {len(infra)}/{len(recs)} "
f"= {round(100 * len(infra) / len(recs), 1) if recs else 0}% {[r['bucket'] for r in infra]}")
lat = [r for r in ok if r.get("total_ms")]
if lat:
for key in ("task_ms", "prestage_ms", "total_ms", "llm_ms", "tools_ms", "other_ms"):
vals = sorted(r[key] for r in lat if r.get(key) is not None)
if vals:
p95 = vals[min(len(vals) - 1, int(0.95 * len(vals)))]
print(f" {key:<10} median={round(statistics.median(vals))}ms p95={p95}ms n={len(vals)}")
else:
print(" no successful runs carried a timing row")
USAGE = """usage: bench.py [known|anon|holdout] [N] [site ...]
known the known suite (default)
anon popular hosts with an anonymous composer, see anon_suite.py
holdout the frozen holdout, see HOLDOUT_FROZEN.md
N trials per site (default 5)
site ... restrict to these sites
Needs the isolated stack up: e2e/browser-v3/stack.sh up dry
"""
if __name__ == "__main__":
# An unrecognised first argument used to be treated as a SUITE NAME, so `bench.py --help`
# quietly started firing real trials at a backend that was not there. The first thing anyone
# new types is --help, so refuse anything that is not a real suite.
if len(sys.argv) > 1 and sys.argv[1] not in ("known", "holdout", "anon"):
print(USAGE)
sys.exit(0 if sys.argv[1] in ("-h", "--help", "help") else 2)
suite = sys.argv[1] if len(sys.argv) > 1 else "known"
n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
only = sys.argv[3:]
if suite == "holdout":
tasks, C.ON_PAGE = HOLDOUT, {**C.ON_PAGE, **HOLDOUT_PAGE}
C.PUBLIC = C.PUBLIC + tuple(HOLDOUT)
elif suite == "anon":
# Popular hosts serving a composer with no account, so criterion 1 is scoreable on a box with no live sessions. Complements the known suite, never replaces it: C.TASKS is untouched.
import anon_suite
anon_suite.assert_disjoint_from_holdout(HOLDOUT)
tasks, C.ON_PAGE = anon_suite.ANON, {**C.ON_PAGE, **anon_suite.ANON_PAGE}
C.PUBLIC = C.PUBLIC + tuple(anon_suite.ANON)
else:
tasks = C.TASKS
if only:
tasks = {k: v for k, v in tasks.items() if k in only}
recs = []
out = os.path.join(HERE, "results", f"{suite}_raw.jsonl")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "a", encoding="utf-8") as fh:
for i in range(1, n + 1):
for site, task in tasks.items():
r = trial(site, task, i)
recs.append(r)
fh.write(json.dumps(r) + "\n")
fh.flush()
print(f" [{i}/{n}] {site:<11}{r['bucket']:<22}{r['wall_s']:>6.1f}s {r['detail'][:62]}",
flush=True)
report(recs, f"{suite} suite, N={n}")