mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Links sessions to todo notes (note_claim.sh, todo-images prompts, fuzzy first prompt >= 0.5, handoff chains), dates completions from the status HLC in the first Firebase dump, and adds usd_per_note, note_linked_usd_share, notes_done(_linked) to the snapshot, a WEEKLY section and notes.json. token_dashboard stores the build-class A/B (agent-ab-build.jsonl) as meta ab_build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWWMeapxnDcgk7HSw67
250 lines
8.5 KiB
Python
250 lines
8.5 KiB
Python
"""A/B test of subagents: Sonnet arm vs Opus arm, one verdict per log.
|
|
|
|
Logs: ``agent-ab.jsonl`` (judgment, closed), ``agent-ab-build.jsonl`` (build).
|
|
Assignments join on ``tool_use_id`` to the stored ``Agent`` call: **redo** (a
|
|
later Agent shares >= :data:`REDO_JACCARD` of its words), **follow-up** (a
|
|
``SendMessage`` to it), **error**, **correction** (noisy: next prompt matches
|
|
:data:`CORRECTION`), end tag. Wilson / Newcombe 95% intervals; the banner is
|
|
RED only when, with >= :data:`MIN_N` per arm, Sonnet's redo or correction rate
|
|
is higher and the two Wilson intervals do not overlap.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from python_pkg.token_dashboard import ab_rows
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
|
|
LOG = Path.home() / ".claude" / "logs" / "agent-ab.jsonl"
|
|
BUILD_LOG = LOG.with_name("agent-ab-build.jsonl") # build-class arm, own verdict
|
|
Z = 1.96
|
|
MIN_N = 20
|
|
REDO_JACCARD = 0.6
|
|
ERRORS = frozenset({"error", "aborted", "killed", "failed", "cancelled"})
|
|
CORRECTION = re.compile(
|
|
r"^\W*no\b|\bwrong\b|\bthat'?s not\b|\bthat is not\b|\bincorrect\b"
|
|
r"|\brevert\b|\byou missed\b",
|
|
re.IGNORECASE,
|
|
)
|
|
WORD = re.compile(r"[a-z0-9]+")
|
|
RATES = ("redo", "followup", "error", "correction")
|
|
_UPSERT = "INSERT OR REPLACE INTO meta VALUES (?, ?)"
|
|
|
|
|
|
def wilson(k: int, n: int, z: float = Z) -> tuple[float, float]:
|
|
"""95% Wilson score interval for ``k`` successes in ``n`` trials."""
|
|
if n == 0:
|
|
return 0.0, 1.0
|
|
p = k / n
|
|
centre = p + z * z / (2 * n)
|
|
spread = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
|
denom = 1 + z * z / n
|
|
return max(0.0, (centre - spread) / denom), min(1.0, (centre + spread) / denom)
|
|
|
|
|
|
def diff_ci(k1: int, n1: int, k2: int, n2: int) -> tuple[float, float, float]:
|
|
"""``p1 - p2`` with Newcombe's hybrid-score 95% interval."""
|
|
p1, p2 = (k1 / n1 if n1 else 0.0), (k2 / n2 if n2 else 0.0)
|
|
l1, u1 = wilson(k1, n1)
|
|
l2, u2 = wilson(k2, n2)
|
|
diff = p1 - p2
|
|
low = diff - math.sqrt((p1 - l1) ** 2 + (u2 - p2) ** 2)
|
|
high = diff + math.sqrt((u1 - p1) ** 2 + (p2 - l2) ** 2)
|
|
return diff, low, high
|
|
|
|
|
|
def jaccard(a: str, b: str) -> float:
|
|
"""Word-set Jaccard similarity of two descriptions."""
|
|
left, right = set(WORD.findall(a.lower())), set(WORD.findall(b.lower()))
|
|
union = left | right
|
|
return len(left & right) / len(union) if union else 0.0
|
|
|
|
|
|
def is_correction(text: str) -> bool:
|
|
"""Whether a user prompt reads as correcting the previous answer."""
|
|
return CORRECTION.search(text) is not None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ArmCounts:
|
|
"""What the banner needs from one arm."""
|
|
|
|
n: int
|
|
redo: int
|
|
correction: int
|
|
|
|
|
|
def banner(
|
|
sonnet: ArmCounts, opus: ArmCounts, label: str = "judgment"
|
|
) -> dict[str, str]:
|
|
"""RED / GREEN / GREY verdict for the top of every /tokens view."""
|
|
n = min(sonnet.n, opus.n)
|
|
if n < MIN_N:
|
|
return {"level": "grey", "text": f"A/B {label} agents: collecting (n={n})"}
|
|
for name in ("redo", "correction"):
|
|
ks, ko = getattr(sonnet, name), getattr(opus, name)
|
|
low_s, _ = wilson(ks, sonnet.n)
|
|
_, high_o = wilson(ko, opus.n)
|
|
if ks / sonnet.n > ko / opus.n and low_s > high_o:
|
|
return {
|
|
"level": "red",
|
|
"text": f"QUALITY DROP: Sonnet {name} rate {ks}/{sonnet.n} vs Opus "
|
|
f"{ko}/{opus.n} (95% CIs do not overlap)",
|
|
}
|
|
return {
|
|
"level": "green",
|
|
"text": f"A/B {label} agents: no detectable drop (n={n})",
|
|
}
|
|
|
|
|
|
def _assignments(log: Path) -> list[dict[str, Any]]:
|
|
"""Well-formed assignment lines."""
|
|
if not log.is_file():
|
|
return []
|
|
rows = []
|
|
for line in log.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
try:
|
|
row = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(row, dict) and row.get("tool_use_id") and row.get("arm"):
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def _outcome(conn: sqlite3.Connection, tool_use_id: str) -> dict[str, Any] | None:
|
|
"""The joined call: cost, duration and the four flags, or ``None``."""
|
|
call = conn.execute(
|
|
"SELECT key, agent_id, ts, description, result_ts, status FROM agent_calls"
|
|
" WHERE tool_use_id = ?",
|
|
(tool_use_id,),
|
|
).fetchone()
|
|
if call is None:
|
|
return None
|
|
key, agent_id, ts, desc, result_ts, status = call
|
|
main = key.split("/", 1)[0]
|
|
sub = conn.execute(
|
|
"SELECT usd, wall_s, last_ts FROM subagent_link WHERE sub_key = ?",
|
|
(f"{main}/agent-{agent_id}",),
|
|
).fetchone() or (0.0, (result_ts or ts) - ts, result_ts or ts)
|
|
returned = max(result_ts or ts, sub[2] or 0)
|
|
later = conn.execute(
|
|
"SELECT description FROM agent_calls WHERE key = ? AND ts > ?",
|
|
(key, returned),
|
|
).fetchall()
|
|
followup = conn.execute(
|
|
"SELECT COUNT(*) FROM sent_messages WHERE key = ? AND to_agent = ? AND ts > ?",
|
|
(key, agent_id, returned),
|
|
).fetchone()[0]
|
|
nxt = conn.execute(
|
|
"SELECT text FROM prompts WHERE key = ? AND ts > ? ORDER BY ts LIMIT 1",
|
|
(main, returned),
|
|
).fetchone()
|
|
tag = conn.execute(
|
|
"SELECT final_tag FROM session_summary WHERE key = ?", (main,)
|
|
).fetchone()
|
|
return {
|
|
"key": key,
|
|
"ts": ts,
|
|
"usd": sub[0] or 0.0,
|
|
"duration_s": sub[1] or 0.0,
|
|
"redo": any(jaccard(desc, d) >= REDO_JACCARD for (d,) in later),
|
|
"followup": followup > 0,
|
|
"error": (status or "").lower() in ERRORS,
|
|
"correction": nxt is not None and is_correction(nxt[0]),
|
|
"tag": (tag[0] if tag else None) or "untagged",
|
|
}
|
|
|
|
|
|
def _mean_ci(values: list[float]) -> dict[str, float]:
|
|
"""Mean with a normal-approximation 95% interval."""
|
|
n = len(values)
|
|
if n == 0:
|
|
return {"mean": 0.0, "low": 0.0, "high": 0.0}
|
|
mean = sum(values) / n
|
|
var = sum((v - mean) ** 2 for v in values) / (n - 1) if n > 1 else 0.0
|
|
half = Z * math.sqrt(var / n)
|
|
return {"mean": mean, "low": mean - half, "high": mean + half}
|
|
|
|
|
|
def _arm(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Per-arm summary."""
|
|
n = len(rows)
|
|
out: dict[str, Any] = {"n": n}
|
|
for name in RATES:
|
|
k = sum(1 for r in rows if r[name])
|
|
low, high = wilson(k, n)
|
|
out[name] = {"k": k, "rate": k / n if n else None, "low": low, "high": high}
|
|
out["usd"] = _mean_ci([r["usd"] for r in rows])
|
|
out["duration_s"] = _mean_ci([r["duration_s"] for r in rows])
|
|
tags: dict[str, int] = {}
|
|
for r in rows:
|
|
tags[r["tag"]] = tags.get(r["tag"], 0) + 1
|
|
out["tags"] = tags
|
|
return out
|
|
|
|
|
|
def _summary(
|
|
conn: sqlite3.Connection, log: Path, label: str
|
|
) -> tuple[dict[str, Any], list[tuple[str, str, dict[str, Any]]]]:
|
|
"""Join one log's assignments; the per-arm payload and the joined rows."""
|
|
joined: dict[str, list[dict[str, Any]]] = {"sonnet": [], "opus": []}
|
|
kept: list[tuple[str, str, dict[str, Any]]] = []
|
|
unjoined = 0
|
|
for row in _assignments(log):
|
|
found = _outcome(conn, str(row["tool_use_id"]))
|
|
if found is None or row["arm"] not in joined:
|
|
unjoined += 1
|
|
continue
|
|
joined[row["arm"]].append(found)
|
|
kept.append((row["arm"], str(row["tool_use_id"]), found))
|
|
arms = {name: _arm(rows) for name, rows in joined.items()}
|
|
diffs = {
|
|
name: dict(
|
|
zip(
|
|
("diff", "low", "high"),
|
|
diff_ci(
|
|
arms["sonnet"][name]["k"],
|
|
arms["sonnet"]["n"],
|
|
arms["opus"][name]["k"],
|
|
arms["opus"]["n"],
|
|
),
|
|
strict=True,
|
|
)
|
|
)
|
|
for name in RATES
|
|
}
|
|
counts = {
|
|
name: ArmCounts(
|
|
arms[name]["n"], arms[name]["redo"]["k"], arms[name]["correction"]["k"]
|
|
)
|
|
for name in joined
|
|
}
|
|
payload = {
|
|
"arms": arms,
|
|
"diffs": diffs,
|
|
"unjoined": unjoined,
|
|
"banner": banner(counts["sonnet"], counts["opus"], label),
|
|
}
|
|
return payload, kept
|
|
|
|
|
|
def rebuild(
|
|
conn: sqlite3.Connection, log: Path | None = None, build_log: Path | None = None
|
|
) -> dict[str, Any]:
|
|
"""Store the judgment A/B in ``meta['ab']``, the build A/B in ``ab_build``."""
|
|
payload, kept = _summary(conn, log or LOG, "judgment")
|
|
ab_rows.store(conn, kept)
|
|
build, _ = _summary(conn, build_log or BUILD_LOG, "build")
|
|
for key, value in (("ab", payload), ("ab_build", build)):
|
|
conn.execute(_UPSERT, (key, json.dumps(value)))
|
|
return payload
|