151 lines
6.5 KiB
Python
151 lines
6.5 KiB
Python
"""Run the task suite and score it, so an agent change can be compared.
|
|||
|
|
|
||
|
|
Success is decided by the phone's own settings and screen, not by the
|
||
|
|
transcript: an agent that says it turned Wi-Fi on and left it off fails here,
|
||
|
|
which is exactly the failure this model exhibits.
|
||
|
|
|
||
|
|
Two things changed after the plan review:
|
||
|
|
|
||
|
|
* ``--repeat N`` now walks the task's **variants** rather than re-running one
|
||
|
|
fixture. At temperature 0 against fixed fixtures, repeats measure
|
||
|
|
repeatability; they only become trials if the phone differs between them.
|
||
|
|
The summary reports a pass rate per task with the variants named, so a build
|
||
|
|
comparison rests on more than one cell.
|
||
|
|
* ``--device adb`` scores a real phone. Each run resets the device to the
|
||
|
|
task's declared starting state first, and a task that cannot be scored from
|
||
|
|
a phone (because its predicate needs flip history) is skipped and reported
|
||
|
|
as skipped, rather than silently scored from final state.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import statistics
|
||
|
|
import time
|
||
|
|
from collections import defaultdict
|
||
|
|
|
||
|
|
from agent import AgentConfig, run_episode, wait_for_server
|
||
|
|
from phone_state import read_state
|
||
|
|
from tasks import SUITE, SUITE_BY_ID
|
||
|
|
|
||
|
|
|
||
|
|
def _reset_real_device(device, task) -> None:
|
||
|
|
if not task.reset_adb:
|
||
|
|
raise SystemExit(
|
||
|
|
f"task {task.task_id!r} declares no reset_adb, so a phone run would "
|
||
|
|
f"be scored against an unknown starting state. Add one or exclude "
|
||
|
|
f"the task with --tasks."
|
||
|
|
)
|
||
|
|
for cmd in task.reset_adb:
|
||
|
|
device._run(*cmd)
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
ap = argparse.ArgumentParser()
|
||
|
|
ap.add_argument("--base-url", default="http://127.0.0.1:8077/v1")
|
||
|
|
ap.add_argument("--model", default="spark")
|
||
|
|
ap.add_argument("--tasks", default="all",
|
||
|
|
help="comma-separated task ids, or 'all'")
|
||
|
|
ap.add_argument("--repeat", type=int, default=1,
|
||
|
|
help="runs per task; each walks the next variant")
|
||
|
|
ap.add_argument("--device", choices=("sim", "adb"), default="sim")
|
||
|
|
ap.add_argument("--serial", default=None, help="adb device serial")
|
||
|
|
ap.add_argument("--no-think", action="store_true")
|
||
|
|
ap.add_argument("--max-tokens", type=int, default=1024)
|
||
|
|
ap.add_argument("--label", default="")
|
||
|
|
ap.add_argument("--out", default=None)
|
||
|
|
a = ap.parse_args()
|
||
|
|
|
||
|
|
tasks = SUITE if a.tasks == "all" else [SUITE_BY_ID[t] for t in a.tasks.split(",")]
|
||
|
|
cfg = AgentConfig(base_url=a.base_url, model=a.model,
|
||
|
|
think=not a.no_think, max_tokens=a.max_tokens)
|
||
|
|
|
||
|
|
phone = None
|
||
|
|
if a.device == "adb":
|
||
|
|
from device import AdbDevice
|
||
|
|
phone = AdbDevice(serial=a.serial)
|
||
|
|
skipped = [t.task_id for t in tasks if t.needs]
|
||
|
|
if skipped:
|
||
|
|
print(f"skipping on a real phone (needs flip history): "
|
||
|
|
f"{', '.join(skipped)}")
|
||
|
|
tasks = [t for t in tasks if not t.needs]
|
||
|
|
|
||
|
|
if not wait_for_server(cfg, timeout_s=600):
|
||
|
|
raise SystemExit(f"no model server at {a.base_url}")
|
||
|
|
|
||
|
|
rows, t0 = [], time.perf_counter()
|
||
|
|
for task in tasks:
|
||
|
|
for run in range(a.repeat):
|
||
|
|
if phone is not None:
|
||
|
|
_reset_real_device(phone, task)
|
||
|
|
device = phone
|
||
|
|
else:
|
||
|
|
device = task.build(run)
|
||
|
|
cfg.max_steps = task.max_steps
|
||
|
|
episode = run_episode(device, task.prompt, cfg)
|
||
|
|
score = task.score(read_state(device), episode.done, run)
|
||
|
|
score.update(
|
||
|
|
run=run + 1, steps=episode.n_steps,
|
||
|
|
stop_reason=episode.stop_reason,
|
||
|
|
recovered_steps=sum(1 for s in episode.steps
|
||
|
|
if s.parsed_via not in ("tool_calls", "none")),
|
||
|
|
median_latency_s=round(
|
||
|
|
sorted(s.latency_s for s in episode.steps)[episode.n_steps // 2], 2)
|
||
|
|
if episode.n_steps else None,
|
||
|
|
)
|
||
|
|
rows.append(score)
|
||
|
|
mark = "PASS" if score["success"] else "fail"
|
||
|
|
detail = []
|
||
|
|
if not score.get("scorable", True):
|
||
|
|
mark, detail = "skip", [score.get("reason", "")]
|
||
|
|
else:
|
||
|
|
if score["reached_goal"] and not score["declared_done"]:
|
||
|
|
detail.append("goal reached but never stopped")
|
||
|
|
if score["declared_done"] and not score["reached_goal"]:
|
||
|
|
detail.append("claimed done, goal NOT reached")
|
||
|
|
if score["collateral_damage"]:
|
||
|
|
detail.append("collateral damage")
|
||
|
|
print(f" {mark} {task.task_id:18s} {score['variant']:10s} "
|
||
|
|
f"steps={score['steps']:2d} "
|
||
|
|
f"{score['stop_reason'][:34]:36s} {'; '.join(detail)}", flush=True)
|
||
|
|
|
||
|
|
wall = time.perf_counter() - t0
|
||
|
|
scored = [r for r in rows if r.get("scorable", True)]
|
||
|
|
n_pass = sum(1 for r in scored if r["success"])
|
||
|
|
reached = sum(1 for r in scored if r["reached_goal"])
|
||
|
|
claimed = sum(1 for r in scored if r["declared_done"] and not r["reached_goal"])
|
||
|
|
|
||
|
|
by_task: dict[str, list[dict]] = defaultdict(list)
|
||
|
|
for r in scored:
|
||
|
|
by_task[r["task_id"]].append(r)
|
||
|
|
print("\npass rate per task")
|
||
|
|
for task_id, rs in by_task.items():
|
||
|
|
passes = sum(1 for r in rs if r["success"])
|
||
|
|
failed = [r["variant"] for r in rs if not r["success"]]
|
||
|
|
print(f" {task_id:18s} {passes}/{len(rs)}"
|
||
|
|
+ (f" failed on: {', '.join(failed)}" if failed else ""))
|
||
|
|
|
||
|
|
rates = [sum(1 for r in rs if r["success"]) / len(rs) for rs in by_task.values()]
|
||
|
|
spread = (f"{min(rates):.0%}-{max(rates):.0%}" if rates else "-")
|
||
|
|
print(f"\n{n_pass}/{len(scored)} passed in {wall:.0f}s"
|
||
|
|
f" (goal reached {reached}/{len(scored)}; "
|
||
|
|
f"falsely claimed done {claimed}; per-task rate {spread}"
|
||
|
|
+ (f"; median {statistics.median(rates):.0%}" if rates else "") + ")")
|
||
|
|
if len(scored) and len(by_task) and max(len(rs) for rs in by_task.values()) < 3:
|
||
|
|
print(" NOTE: fewer than 3 runs per task -- enough to show a failure "
|
||
|
|
"reproduces, not to rank builds.")
|
||
|
|
|
||
|
|
if a.out:
|
||
|
|
with open(a.out, "w") as f:
|
||
|
|
json.dump({"label": a.label, "base_url": a.base_url,
|
||
|
|
"model": a.model, "think": cfg.think,
|
||
|
|
"device": a.device, "repeat": a.repeat,
|
||
|
|
"passed": n_pass, "total": len(scored),
|
||
|
|
"skipped": len(rows) - len(scored),
|
||
|
|
"wall_s": round(wall, 1), "rows": rows}, f, indent=2)
|
||
|
|
print(f"wrote {a.out}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|