mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 14:32:22 +02:00
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>
298 lines
16 KiB
Python
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}")
|