nisse-dev now runs only the conversational loop. The gated state machine it was built alongside is gone: - Removed control.py, planner.py, executor.py, approvals.py, gates.py (command_form moves to loop/reader.py), publish.py and poller.py, the old assembly in agent.py (its shared helpers move to loop/build.py), the store's state-machine view (get_state, transition), their tests, the old end-to-end and live planning/milestone/gate tests, and specs/orchestrator.qnt with its scenarios. - Roles are driver, helper, expert and classifier; driver is required. loop:, roles.planner, roles.executor and tuning.executor are refused with what to use instead; tuning.driver takes the turn cap and failure limit. - The benchmark and the capability matrix measure the driver: the loop's own system prompt and tools, the task's plan as the agreed restatement. Task plans end with finish_milestone, so earlier runs show as (old); stored runs' executor tuning reads as the driver's. - A delegation on the loop records what its helper's alias resolved to (L9), as the old path's did. - The dump shows a work item's revision and what it waits for. - Behaviour the old tests covered and the loop shares stays covered: driver runs that fail on a model call, stop mid-turn, reset the failure count; go-aheads ignoring case and punctuation; a request too big to fit is never sent; the assembly tests, now on build_loop_agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
171 lines
6.0 KiB
Python
171 lines
6.0 KiB
Python
"""Stored benchmark results, and the report that compares them.
|
|
|
|
Every run is one JSON line. The report is rebuilt from all of them each
|
|
time, so a model benchmarked today is read alongside every model measured
|
|
before it, as long as the task is still the same version.
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import statistics
|
|
from collections import defaultdict
|
|
from collections.abc import Iterable
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from nisse_dev.bench.runner import RunRecord
|
|
from nisse_dev.config import Tuning
|
|
|
|
|
|
def results_file(directory: Path, model: str, when: datetime | None = None) -> Path:
|
|
day = (when or datetime.now(UTC)).strftime("%Y-%m-%d")
|
|
return directory / f"{day}-{model}.jsonl"
|
|
|
|
|
|
def append(path: Path, record: RunRecord) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("a") as out:
|
|
out.write(json.dumps(record.to_json(), sort_keys=True) + "\n")
|
|
|
|
|
|
def load(directory: Path) -> list[dict[str, Any]]:
|
|
records = []
|
|
for path in sorted(directory.glob("*.jsonl")):
|
|
for line in path.read_text().splitlines():
|
|
if line.strip():
|
|
records.append(json.loads(line))
|
|
return records
|
|
|
|
|
|
def wilson(successes: int, runs: int, z: float = 1.96) -> tuple[float, float]:
|
|
"""A 95% confidence interval for a success rate, sound at small counts.
|
|
|
|
Three successes in five runs and five in five overlap here, which is the
|
|
point: small samples of a noisy model do not separate much."""
|
|
if runs == 0:
|
|
return 0.0, 1.0
|
|
p = successes / runs
|
|
denominator = 1 + z * z / runs
|
|
centre = (p + z * z / (2 * runs)) / denominator
|
|
half = z * math.sqrt(p * (1 - p) / runs + z * z / (4 * runs * runs)) / denominator
|
|
return max(0.0, centre - half), min(1.0, centre + half)
|
|
|
|
|
|
@dataclass
|
|
class Row:
|
|
model: str
|
|
note: str
|
|
task: str
|
|
runs: int
|
|
successes: int
|
|
median_turns: float
|
|
mean_wall_s: float # working time: rate-limit waits are left out
|
|
cost_per_run: float
|
|
stale: bool
|
|
rate_limited: int = 0 # runs that waited out the proxy's rate limit
|
|
tuning: str = "" # how the runs' tuning differs from today's defaults
|
|
|
|
@property
|
|
def interval(self) -> tuple[float, float]:
|
|
return wilson(self.successes, self.runs)
|
|
|
|
|
|
def tuning_label(tuning: dict[str, Any]) -> str:
|
|
"""The settings in a run's tuning that differ from today's defaults, as
|
|
"driver.max_turns=24"; empty when there are none. Relative to today's
|
|
defaults so that rows at the current settings stay unlabelled. Runs from
|
|
before M2.8 called the driver's section ``executor``."""
|
|
defaults = asdict(Tuning())
|
|
renamed = {
|
|
("driver" if section == "executor" else section): values
|
|
for section, values in tuning.items()
|
|
}
|
|
return ", ".join(
|
|
f"{section}.{key}={value}"
|
|
for section, values in sorted(renamed.items())
|
|
for key, value in sorted((values or {}).items())
|
|
if defaults.get(section, {}).get(key) != value
|
|
)
|
|
|
|
|
|
def summarise(
|
|
records: Iterable[dict[str, Any]], current_hashes: dict[str, str]
|
|
) -> list[Row]:
|
|
"""One row per (model, how it was served, task, task version, tuning).
|
|
|
|
Tuning is part of the key because a knob changes the result: runs capped
|
|
at 24 turns and runs allowed 40 are not the same measurement."""
|
|
groups: dict[tuple[str, str, str, str, str], list[dict[str, Any]]] = defaultdict(
|
|
list
|
|
)
|
|
for r in records:
|
|
key = (
|
|
r["backing"],
|
|
r.get("note", ""),
|
|
r["task"],
|
|
r["task_hash"],
|
|
tuning_label(r.get("tuning", {})),
|
|
)
|
|
groups[key].append(r)
|
|
rows = []
|
|
for (backing, note, task, task_hash, tuning), runs in sorted(groups.items()):
|
|
rows.append(
|
|
Row(
|
|
model=backing,
|
|
note=note,
|
|
task=task,
|
|
runs=len(runs),
|
|
successes=sum(1 for r in runs if r["success"]),
|
|
median_turns=statistics.median(r["turns"] for r in runs),
|
|
mean_wall_s=statistics.fmean(
|
|
r["wall_s"] - r.get("rate_limit_wait_s", 0.0) for r in runs
|
|
),
|
|
cost_per_run=statistics.fmean(r["cost"] for r in runs),
|
|
stale=current_hashes.get(task) != task_hash,
|
|
rate_limited=sum(1 for r in runs if r.get("rate_limit_wait_s")),
|
|
tuning=tuning,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def render(rows: list[Row]) -> str:
|
|
# A tuning column only when some rows need one, so a report at today's
|
|
# settings looks as it always has.
|
|
tuned = any(row.tuning for row in rows)
|
|
header = (
|
|
f"{'model':<34} {'note':<22} "
|
|
+ (f"{'tuning':<24} " if tuned else "")
|
|
+ f"{'task':<15} {'success':>8} {'95% CI':>11} "
|
|
f"{'turns':>6} {'time':>7} {'$/run':>8} {'429':>4}"
|
|
)
|
|
lines = [header, "-" * len(header)]
|
|
for row in rows:
|
|
low, high = row.interval
|
|
task = row.task + (" (old)" if row.stale else "")
|
|
tuning = f"{row.tuning[:24]:<24} " if tuned else ""
|
|
lines.append(
|
|
f"{row.model[:34]:<34} {row.note[:22]:<22} {tuning}{task[:15]:<15} "
|
|
f"{row.successes:>3}/{row.runs:<4} {low:>4.0%}-{high:<5.0%} "
|
|
f"{row.median_turns:>6.0f} {row.mean_wall_s:>6.0f}s {row.cost_per_run:>8.4f} "
|
|
f"{row.rate_limited or '':>4}"
|
|
)
|
|
if any(row.rate_limited for row in rows):
|
|
lines.append("")
|
|
lines.append(
|
|
"429 = runs that waited out the proxy's rate limit; time leaves the "
|
|
"waits out."
|
|
)
|
|
if tuned:
|
|
lines.append("")
|
|
lines.append("tuning = settings that differ from today's defaults.")
|
|
if any(row.stale for row in rows):
|
|
lines.append("")
|
|
lines.append(
|
|
"(old) = measured against an earlier version of the task; "
|
|
"not comparable with current runs."
|
|
)
|
|
return "\n".join(lines)
|