Lumbridge Bench
This commit is contained in:
+646
@@ -0,0 +1,646 @@
|
||||
"""kbench command line."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from . import tasks as task_catalog
|
||||
from .compare import compare as compare_runs, direction
|
||||
from .sweep import analyse, point_from_run
|
||||
from .registry import load_registry
|
||||
from .results import RESULTS_DIR, Run
|
||||
from .run import build_run, run_perf, run_quality
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
help="Lumbridge Bench -- private eval and serving-performance evidence for self-hosted and API models",
|
||||
)
|
||||
console = Console()
|
||||
|
||||
|
||||
def _parse_concurrencies(value: str) -> tuple[int, ...]:
|
||||
return tuple(int(x) for x in value.split(",") if x.strip())
|
||||
|
||||
|
||||
@app.command("targets")
|
||||
def list_targets() -> None:
|
||||
"""List benchmarkable targets."""
|
||||
registry = load_registry()
|
||||
table = Table(title="targets", header_style="bold")
|
||||
for col in ("id", "tier", "host", "quant", "params", "engine", "aliases"):
|
||||
table.add_column(col)
|
||||
for t in registry.targets.values():
|
||||
params = f"{t.params_b:g}B" if t.params_b else "-"
|
||||
if t.active_params_b:
|
||||
params += f" ({t.active_params_b:g}B active)"
|
||||
table.add_row(
|
||||
t.id, t.tier, t.host, t.quant or "-", params,
|
||||
t.serving.engine, ", ".join(t.aliases) or "-",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command("tasks")
|
||||
def list_tasks() -> None:
|
||||
"""List benchmark tasks."""
|
||||
table = Table(title="tasks", header_style="bold")
|
||||
for col in ("name", "tier", "samples", "judge", "inspect task"):
|
||||
table.add_column(col)
|
||||
for spec in task_catalog.CATALOG.values():
|
||||
table.add_row(
|
||||
spec.name, spec.tier, str(spec.dataset_samples or "-"),
|
||||
"yes" if spec.judge_required else "no", spec.inspect_task,
|
||||
)
|
||||
console.print(table)
|
||||
console.print(
|
||||
"\n[dim]reference = public, likely contaminated, calibration only\n"
|
||||
"signal = private, ours, the actual measurement[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@app.command("snapshot")
|
||||
def snapshot(host: str) -> None:
|
||||
"""Print the live serving flags on a host, for pasting into models.yaml.
|
||||
|
||||
Results are only comparable between targets with identical flags, so the
|
||||
registry has to record what the server was ACTUALLY started with rather
|
||||
than what we believe we started it with.
|
||||
"""
|
||||
registry = load_registry()
|
||||
h = registry.host(host)
|
||||
if not h.ssh:
|
||||
raise typer.BadParameter(f"host {host!r} has no ssh address")
|
||||
|
||||
proc = subprocess.run(
|
||||
["ssh", h.ssh, "ps -eo args | grep -E '[v]llm serve|[s]glang|[l]lama-server'"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
# A host with no serving process is an ordinary outcome, not an
|
||||
# exception -- it is reported below.
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
console.print(f"[yellow]no serving process found on {host}[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
for line in proc.stdout.strip().splitlines():
|
||||
parts = shlex.split(line)
|
||||
console.print(f"\n[bold]{parts[0] if parts else '?'}[/bold]")
|
||||
flags: dict[str, object] = {}
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
if parts[i].startswith("--"):
|
||||
key = parts[i][2:].replace("-", "_")
|
||||
if i + 1 < len(parts) and not parts[i + 1].startswith("--"):
|
||||
flags[key] = parts[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
flags[key] = True
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
for k, v in flags.items():
|
||||
console.print(f" {k}: {v}")
|
||||
|
||||
|
||||
|
||||
def _load_prompts(path: Path | None) -> list[str] | None:
|
||||
"""Read representative prompts: one per line, or a JSONL with an `input` field.
|
||||
|
||||
Measuring speculative decoding on the synthetic prompt is measuring the wrong thing — the
|
||||
draft model's acceptance rate depends on how predictable the text is, and lorem with a
|
||||
random nonce is not the text you serve.
|
||||
"""
|
||||
if path is None:
|
||||
return None
|
||||
lines = [ln.strip() for ln in path.read_text().splitlines() if ln.strip()]
|
||||
prompts = []
|
||||
for ln in lines:
|
||||
if ln.startswith("{"):
|
||||
try:
|
||||
prompts.append(json.loads(ln)["input"])
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
prompts.append(ln)
|
||||
if not prompts:
|
||||
raise typer.BadParameter(f"{path} contains no prompts")
|
||||
return prompts
|
||||
|
||||
|
||||
@app.command("perf")
|
||||
def perf_cmd(
|
||||
target: str,
|
||||
concurrency: str = typer.Option("1,8,32", help="comma-separated concurrency levels"),
|
||||
input_tokens: int = typer.Option(1024),
|
||||
output_tokens: int = typer.Option(256),
|
||||
save: bool = typer.Option(True, help="write a results JSON"),
|
||||
prompt_file: Path | None = typer.Option(
|
||||
None, help="representative prompts (one per line, or JSONL with an `input` field)"
|
||||
),
|
||||
natural_stop: bool = typer.Option(
|
||||
False,
|
||||
help="let the model stop on its own instead of pinning output length. Required for an "
|
||||
"honest speculative-decoding measurement; weakens comparability across points.",
|
||||
),
|
||||
) -> None:
|
||||
"""Run a serving performance sweep only (no quality tasks)."""
|
||||
registry = load_registry()
|
||||
t = registry.target(target)
|
||||
result = run_perf(
|
||||
t, _parse_concurrencies(concurrency), input_tokens, output_tokens,
|
||||
prompts=_load_prompts(prompt_file), pin_output=not natural_stop,
|
||||
)
|
||||
run = build_run(registry, t, quality=[], perf=result)
|
||||
_print_card(run)
|
||||
if save:
|
||||
path = run.save()
|
||||
console.print(f"\n[green]saved[/green] {path.relative_to(Path.cwd())}")
|
||||
|
||||
|
||||
@app.command("run")
|
||||
def run_cmd(
|
||||
target: str,
|
||||
tasks: str = typer.Option("ifeval", help="comma-separated task names, or 'all'"),
|
||||
limit: int | None = typer.Option(None, help="cap samples per task (recorded in output)"),
|
||||
max_connections: int = typer.Option(16, help="concurrent requests to the model"),
|
||||
epochs: int = typer.Option(
|
||||
1,
|
||||
help="run each sample N times and average. Sampling is not reproducible on a "
|
||||
"batching server even with a fixed seed, so one epoch is a draw, not a rate.",
|
||||
),
|
||||
concurrency: str = typer.Option("1,8,32"),
|
||||
input_tokens: int = typer.Option(1024),
|
||||
output_tokens: int = typer.Option(256),
|
||||
skip_perf: bool = typer.Option(False),
|
||||
skip_quality: bool = typer.Option(False),
|
||||
) -> None:
|
||||
"""Full score card: quality tasks + perf sweep -> one results JSON."""
|
||||
registry = load_registry()
|
||||
t = registry.target(target)
|
||||
|
||||
names = list(task_catalog.CATALOG) if tasks == "all" else [
|
||||
n.strip() for n in tasks.split(",") if n.strip()
|
||||
]
|
||||
specs = [task_catalog.get(n) for n in names]
|
||||
|
||||
quality = []
|
||||
if not skip_quality:
|
||||
quality = run_quality(
|
||||
t, specs, limit=limit, max_connections=max_connections, epochs=epochs
|
||||
)
|
||||
|
||||
perf = None
|
||||
if not skip_perf:
|
||||
perf = run_perf(t, _parse_concurrencies(concurrency), input_tokens, output_tokens)
|
||||
|
||||
run = build_run(registry, t, quality=quality, perf=perf)
|
||||
_print_card(run)
|
||||
path = run.save()
|
||||
console.print(f"\n[green]saved[/green] {path.relative_to(Path.cwd())}")
|
||||
|
||||
|
||||
@app.command("sweep")
|
||||
def sweep_cmd(
|
||||
targets: list[str] = typer.Argument(..., help="two or more target ids to compare"),
|
||||
tasks: str = typer.Option("ifeval", help="comma-separated task names, or 'all'"),
|
||||
limit: int | None = typer.Option(None, help="cap samples per task"),
|
||||
max_connections: int = typer.Option(16),
|
||||
concurrency: str = typer.Option("1,8,32"),
|
||||
input_tokens: int = typer.Option(1024),
|
||||
output_tokens: int = typer.Option(256),
|
||||
skip_perf: bool = typer.Option(False),
|
||||
skip_quality: bool = typer.Option(False),
|
||||
) -> None:
|
||||
"""Run the same tasks against several targets and reduce them to a frontier.
|
||||
|
||||
Sequential on purpose: on a unified-memory box two targets cannot both be resident, so
|
||||
running them together would measure contention rather than either one. Each target still
|
||||
produces an ordinary score card in results/, so compare and card keep working on them.
|
||||
"""
|
||||
if len(targets) < 2:
|
||||
raise typer.BadParameter("a sweep needs at least two targets")
|
||||
|
||||
registry = load_registry()
|
||||
names = list(task_catalog.CATALOG) if tasks == "all" else [
|
||||
n.strip() for n in tasks.split(",") if n.strip()
|
||||
]
|
||||
specs = [task_catalog.get(n) for n in names]
|
||||
|
||||
points, hosts = [], []
|
||||
for i, target_id in enumerate(targets, start=1):
|
||||
console.rule(f"[bold]{i}/{len(targets)} {target_id}[/bold]")
|
||||
try:
|
||||
t = registry.target(target_id)
|
||||
hosts.append(t.host)
|
||||
quality = [] if skip_quality else run_quality(
|
||||
t, specs, limit=limit, max_connections=max_connections
|
||||
)
|
||||
perf = None if skip_perf else run_perf(
|
||||
t, _parse_concurrencies(concurrency), input_tokens, output_tokens
|
||||
)
|
||||
run = build_run(registry, t, quality=quality, perf=perf)
|
||||
path = run.save()
|
||||
console.print(f"[green]saved[/green] {path.name}")
|
||||
points.append(point_from_run(run.to_dict(), str(path.name)))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# One bad target must not throw away the targets already measured — a sweep is
|
||||
# hours of GPU time and re-running the good ones to reach the next is wasteful.
|
||||
console.print(f"[red]failed[/red] {target_id}: {exc}")
|
||||
points.append(
|
||||
point_from_run({"target_id": target_id, "verdict": {}})
|
||||
)
|
||||
points[-1].error = str(exc)
|
||||
|
||||
result = analyse(points, hosts=hosts)
|
||||
|
||||
console.print()
|
||||
console.rule("[bold]frontier[/bold]")
|
||||
table = Table(header_style="bold")
|
||||
for col in ("", "target", "signal", "peak tok/s", "tok/s @1", "verdict"):
|
||||
table.add_column(col)
|
||||
for point in sorted(
|
||||
result.points, key=lambda x: (x.error is not None, -(x.throughput_tps or 0))
|
||||
):
|
||||
if point.error:
|
||||
mark, verdict = "[red]![/red]", f"[red]{point.error[:44]}[/red]"
|
||||
elif point.target_id in result.dominated:
|
||||
mark = "[dim]·[/dim]"
|
||||
verdict = f"[dim]beaten by {result.dominated[point.target_id]}[/dim]"
|
||||
else:
|
||||
mark, verdict = "[green]✓[/green]", "[green]worth running[/green]"
|
||||
table.add_row(
|
||||
mark,
|
||||
point.target_id,
|
||||
f"{point.quality:.3f}" if point.quality is not None else "-",
|
||||
f"{point.throughput_tps:.1f}" if point.throughput_tps else "-",
|
||||
f"{point.single_stream_tps:.1f}" if point.single_stream_tps else "-",
|
||||
verdict,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
for warning in result.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
|
||||
if result.best:
|
||||
console.print(
|
||||
f"\n[bold]{len(result.best)} of {len(result.points)} "
|
||||
f"worth considering:[/bold] " + ", ".join(result.best)
|
||||
)
|
||||
if result.dominated:
|
||||
console.print(
|
||||
"[dim]The rest are beaten on every measured axis at once — not a close call, "
|
||||
"a strictly worse option.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@app.command("card")
|
||||
def card_cmd(path: Path) -> None:
|
||||
"""Render a saved results JSON as a score card."""
|
||||
data = Run.load(path)
|
||||
_print_card_dict(data)
|
||||
|
||||
|
||||
def _print_card(run: Run) -> None:
|
||||
_print_card_dict(run.to_dict())
|
||||
|
||||
|
||||
def _print_card_dict(data: dict) -> None:
|
||||
tgt = data["target"]
|
||||
console.print()
|
||||
console.rule(f"[bold]{tgt.get('display_name', data['target_id'])}[/bold]")
|
||||
console.print(
|
||||
f"[dim]{data['target_id']} | {data['host'].get('hardware', '?')} | "
|
||||
f"{data['timestamp']}[/dim]"
|
||||
)
|
||||
|
||||
quality = data.get("quality") or []
|
||||
if quality:
|
||||
table = Table(title="quality", header_style="bold", title_justify="left")
|
||||
for col in ("task", "tier", "n", "accuracy", "notes"):
|
||||
table.add_column(col)
|
||||
for q in quality:
|
||||
if q.get("error"):
|
||||
table.add_row(q["task"], q["tier"], "-", "[red]ERROR[/red]", q["error"][:60])
|
||||
continue
|
||||
acc = q["metrics"].get("accuracy")
|
||||
limit = q["metrics"].get("_limit")
|
||||
note = f"limited to {int(limit)} samples" if limit else ""
|
||||
if q["tier"] == "reference":
|
||||
note = (note + "; " if note else "") + "calibration only"
|
||||
table.add_row(
|
||||
q["task"], q["tier"], str(q["n_samples"]),
|
||||
f"{acc:.3f}" if acc is not None else "-", note,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
perf = data.get("perf")
|
||||
if perf and perf.get("points"):
|
||||
table = Table(title="serving perf", header_style="bold", title_justify="left")
|
||||
points = data["perf"].get("points") or []
|
||||
# Only widen the table when there is something to put in the column — a permanent
|
||||
# "accept: -" on every non-speculative target is noise.
|
||||
show_accept = any(pt.get("spec_acceptance_rate") is not None for pt in points)
|
||||
cols = ["conc", "tok/s total", "tok/s /stream", "TTFT p50", "TTFT p95", "TPOT p50"]
|
||||
if show_accept:
|
||||
cols.append("accept")
|
||||
cols.append("failed")
|
||||
for col in cols:
|
||||
table.add_column(col, justify="right")
|
||||
for p in perf["points"]:
|
||||
table.add_row(
|
||||
str(p["concurrency"]),
|
||||
f"{p['output_tps_total']:.1f}",
|
||||
f"{p['output_tps_per_stream']:.1f}",
|
||||
f"{p['ttft_p50_ms']:.0f}ms" if p.get("ttft_p50_ms") else "-",
|
||||
f"{p['ttft_p95_ms']:.0f}ms" if p.get("ttft_p95_ms") else "-",
|
||||
f"{p['tpot_p50_ms']:.1f}ms" if p.get("tpot_p50_ms") else "-",
|
||||
*(
|
||||
[
|
||||
f"{p['spec_acceptance_rate']:.0%}"
|
||||
if p.get("spec_acceptance_rate") is not None
|
||||
else "-"
|
||||
]
|
||||
if show_accept
|
||||
else []
|
||||
),
|
||||
f"[red]{p['failed']}[/red]" if p["failed"] else "0",
|
||||
)
|
||||
console.print(table)
|
||||
if any(pt.get("natural_stop") for pt in points):
|
||||
spread = next(
|
||||
(pt["output_token_stdev"] for pt in points if pt.get("output_token_stdev")), None
|
||||
)
|
||||
console.print(
|
||||
" [dim]natural stop: output length was not pinned"
|
||||
+ (f", ±{spread:.0f} tokens" if spread else "")
|
||||
+ ". Required for an honest speculative-decoding number, and it means these"
|
||||
" points are not directly comparable to pinned-length runs.[/dim]"
|
||||
)
|
||||
|
||||
verdict = data.get("verdict") or {}
|
||||
if verdict:
|
||||
# Contamination first and loud. It is the one result that invalidates every other
|
||||
# number on the card, so it cannot be a row someone scrolls past.
|
||||
state = verdict.get("contamination")
|
||||
if state == "detected":
|
||||
console.print()
|
||||
console.print(
|
||||
"[bold white on red] CONTAMINATED [/bold white on red] "
|
||||
f"the model reproduced {verdict.get('canary_score', 0):.0%} of the canary probes."
|
||||
)
|
||||
console.print(
|
||||
" [red]Quality scores for this target are void — it has seen the eval set.[/red]"
|
||||
)
|
||||
if verdict.get("signal_score_unverified") is not None:
|
||||
console.print(
|
||||
f" [dim]withheld signal score: {verdict['signal_score_unverified']:.3f} "
|
||||
"(recorded for forensics, not usable as a result)[/dim]"
|
||||
)
|
||||
console.print(" [dim]Regenerate the private set before trusting anything here.[/dim]")
|
||||
elif state == "unverified":
|
||||
console.print(
|
||||
"\n[yellow]![/yellow] no contamination probe ran — quality scores are unverified"
|
||||
)
|
||||
|
||||
console.print("\n[bold]verdict[/bold]")
|
||||
for k, v in verdict.items():
|
||||
if v is None or k in ("contamination", "signal_score_unverified"):
|
||||
continue
|
||||
console.print(f" {k}: {v}")
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def add_cmd(
|
||||
family: str,
|
||||
input_text: str | None = typer.Option(None, "--input", help="prompt text"),
|
||||
input_file: Path | None = typer.Option(None, help="read the prompt from a file"),
|
||||
target: str | None = typer.Option(None, help="expected answer (or regex)"),
|
||||
scorer: str = typer.Option("includes", help="exact | includes | regex | rubric"),
|
||||
rubric: str | None = typer.Option(None, help="grading instructions for --scorer rubric"),
|
||||
split: str = typer.Option("test", help="train | dev | test -- never change this later"),
|
||||
tier: str = typer.Option("private", help="private | public"),
|
||||
source: str = typer.Option("manual", help="where this sample came from"),
|
||||
tags: str = typer.Option("", help="comma-separated"),
|
||||
) -> None:
|
||||
"""Append a sample to a family, with id, canary, and validation applied.
|
||||
|
||||
Do not hand-edit the JSONL. This assigns a stable non-reused id (per-sample
|
||||
regression tracking keys on it) and stamps a canary GUID used later to
|
||||
prove contamination.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from .tasks.signal import family_path, validate_record
|
||||
|
||||
if input_file:
|
||||
input_text = input_file.read_text().strip()
|
||||
if not input_text:
|
||||
raise typer.BadParameter("provide --input or --input-file")
|
||||
|
||||
path = family_path(family, tier)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing = [
|
||||
json.loads(line)
|
||||
for line in (path.read_text().splitlines() if path.exists() else [])
|
||||
if line.strip() and not line.startswith("//")
|
||||
]
|
||||
# Highest existing number + 1, so ids are never reused even after deletions.
|
||||
next_n = 1 + max(
|
||||
(int(r["id"].rsplit("/", 1)[-1]) for r in existing if "/" in r.get("id", "")),
|
||||
default=0,
|
||||
)
|
||||
|
||||
record = {
|
||||
"id": f"{family}/{next_n:04d}",
|
||||
"family": family,
|
||||
"split": split,
|
||||
"canary": f"KBENCH-CANARY-{uuid.uuid4().hex[:8]}",
|
||||
"input": input_text,
|
||||
"target": target,
|
||||
"scorer": scorer,
|
||||
"rubric": rubric,
|
||||
"metadata": {
|
||||
"source": source,
|
||||
"authored": __import__("datetime").date.today().isoformat(),
|
||||
"tags": [t.strip() for t in tags.split(",") if t.strip()],
|
||||
},
|
||||
}
|
||||
validate_record(record, "new sample")
|
||||
|
||||
with path.open("a") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
console.print(f"[green]added[/green] {record['id']} -> {path}")
|
||||
console.print(
|
||||
"[yellow]scrub check:[/yellow] no keys, tokens, tailscale addresses, "
|
||||
"or customer data in that sample?"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_run(ref: str) -> Path:
|
||||
"""Accept a path, a bare filename, or a run-id prefix.
|
||||
|
||||
Nobody types a full run id. Being strict about it just means every comparison starts with
|
||||
an `ls`.
|
||||
"""
|
||||
direct = Path(ref)
|
||||
if direct.is_file():
|
||||
return direct
|
||||
candidates = sorted(RESULTS_DIR.glob(f"*{ref}*.json"))
|
||||
if not candidates:
|
||||
raise typer.BadParameter(f"no result matching {ref!r} in {RESULTS_DIR}")
|
||||
if len(candidates) > 1:
|
||||
names = "\n ".join(c.name for c in candidates)
|
||||
raise typer.BadParameter(f"{ref!r} matches several results:\n {names}")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _fmt(value: float | None, places: int = 1) -> str:
|
||||
return "-" if value is None else f"{value:.{places}f}"
|
||||
|
||||
|
||||
def _delta_cell(d, places: int = 1) -> str:
|
||||
"""A delta with its sign, its percent, and — crucially — whether that is good."""
|
||||
if d.absolute is None:
|
||||
return "[dim]-[/dim]"
|
||||
verdict = direction(d.name.split(".")[-1], d)
|
||||
colour = {"better": "green", "worse": "red", "flat": "dim"}[verdict]
|
||||
pct = "" if d.percent is None else f" ({d.percent:+.1f}%)"
|
||||
return f"[{colour}]{d.absolute:+.{places}f}{pct}[/{colour}]"
|
||||
|
||||
|
||||
@app.command("compare")
|
||||
def compare_cmd(
|
||||
before: str = typer.Argument(..., help="Baseline run: path, filename, or run-id fragment"),
|
||||
after: str = typer.Argument(..., help="Run to compare against the baseline"),
|
||||
show_flips: int = typer.Option(20, help="How many changed samples to list (0 for all)"),
|
||||
) -> None:
|
||||
"""Diff two score cards: what changed, and whether the comparison is honest."""
|
||||
before_path, after_path = _resolve_run(before), _resolve_run(after)
|
||||
b, a = Run.load(before_path), Run.load(after_path)
|
||||
result = compare_runs(b, a)
|
||||
|
||||
console.print()
|
||||
console.rule("[bold]compare[/bold]")
|
||||
console.print(f"[dim]before[/dim] {before_path.name}")
|
||||
console.print(f"[dim]after [/dim] {after_path.name}\n")
|
||||
|
||||
# What actually differs about the two targets. Printed FIRST: without it the reader has
|
||||
# no idea what the numbers below are attributable to.
|
||||
if result.target_deltas or result.host_changed:
|
||||
t = Table(title="what changed about the target", header_style="bold")
|
||||
for col in ("axis", "before", "after"):
|
||||
t.add_column(col, overflow="fold")
|
||||
for d in result.target_deltas:
|
||||
t.add_row(d.field, str(d.before), str(d.after))
|
||||
if result.host_changed:
|
||||
h = result.host_changed
|
||||
t.add_row("[yellow]host[/yellow]", str(h.before), str(h.after))
|
||||
console.print(t)
|
||||
|
||||
for warning in result.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
if result.warnings:
|
||||
console.print()
|
||||
|
||||
if result.quality:
|
||||
t = Table(title="quality", header_style="bold")
|
||||
for col in ("metric", "before", "after", "delta"):
|
||||
t.add_column(col)
|
||||
for d in result.quality:
|
||||
t.add_row(d.name, _fmt(d.before, 3), _fmt(d.after, 3), _delta_cell(d, 3))
|
||||
console.print(t)
|
||||
|
||||
if result.flips:
|
||||
shown = result.flips if show_flips == 0 else result.flips[:show_flips]
|
||||
# When nothing about the measurement changed -- same target, same host, same eval
|
||||
# set, same sampling -- a flipped sample did not regress or get fixed. It is the
|
||||
# same question answered twice with different luck. Calling it "regressed" sends
|
||||
# someone hunting a cause that does not exist, which is worse than saying nothing.
|
||||
same_setup = (
|
||||
not result.target_deltas
|
||||
and not result.host_changed
|
||||
and not any(
|
||||
"edited between runs" in w or "Sampling changed" in w
|
||||
for w in result.warnings
|
||||
)
|
||||
)
|
||||
t = Table(
|
||||
title=(
|
||||
f"samples that changed ({len(result.flips)})"
|
||||
+ (" — same setup, so this is instability, not change" if same_setup else "")
|
||||
),
|
||||
header_style="bold",
|
||||
)
|
||||
for col in ("task", "sample", "before", "after", ""):
|
||||
t.add_column(col, overflow="fold")
|
||||
for f in shown:
|
||||
if same_setup:
|
||||
mark = "[yellow]unstable[/yellow]"
|
||||
else:
|
||||
mark = {
|
||||
"fail": "[red]regressed[/red]",
|
||||
"pass": "[green]fixed[/green]",
|
||||
"changed": "[dim]score[/dim]",
|
||||
}[f.became]
|
||||
t.add_row(f.task, f.sample_id, f"{f.before:.2f}", f"{f.after:.2f}", mark)
|
||||
console.print(t)
|
||||
if len(shown) < len(result.flips):
|
||||
console.print(f"[dim]… {len(result.flips) - len(shown)} more; --show-flips 0 for all[/dim]")
|
||||
elif result.quality:
|
||||
console.print("[dim]no sample outcomes changed[/dim]")
|
||||
|
||||
if result.perf:
|
||||
t = Table(title="serving performance", header_style="bold")
|
||||
for col in ("concurrency", "metric", "before", "after", "delta"):
|
||||
t.add_column(col)
|
||||
for concurrency in sorted(result.perf):
|
||||
for i, d in enumerate(result.perf[concurrency]):
|
||||
t.add_row(
|
||||
str(concurrency) if i == 0 else "",
|
||||
d.name.split(".")[-1],
|
||||
_fmt(d.before),
|
||||
_fmt(d.after),
|
||||
_delta_cell(d),
|
||||
)
|
||||
console.print(t)
|
||||
|
||||
if not result.quality and not result.perf:
|
||||
console.print("[yellow]neither run carries quality or perf results[/yellow]")
|
||||
|
||||
|
||||
@app.command("results")
|
||||
def results_cmd() -> None:
|
||||
"""List saved score cards."""
|
||||
files = sorted(RESULTS_DIR.glob("*.json"))
|
||||
if not files:
|
||||
console.print("[dim]no results yet[/dim]")
|
||||
return
|
||||
table = Table(title="results", header_style="bold")
|
||||
for col in ("date", "target", "signal", "reference", "tok/s @1", "peak tok/s", "file"):
|
||||
table.add_column(col)
|
||||
for f in files:
|
||||
d = json.loads(f.read_text())
|
||||
v = d.get("verdict") or {}
|
||||
table.add_row(
|
||||
d["timestamp"][:10],
|
||||
d["target_id"],
|
||||
f"{v['signal_score']:.3f}" if v.get("signal_score") is not None else "-",
|
||||
f"{v['reference_score']:.3f}" if v.get("reference_score") is not None else "-",
|
||||
f"{v['single_stream_tps']:.1f}" if v.get("single_stream_tps") else "-",
|
||||
f"{v['peak_throughput_tps']:.1f}" if v.get("peak_throughput_tps") else "-",
|
||||
f.name,
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Diff two runs.
|
||||
|
||||
This is the verb the bench exists for. A single score card tells you what one target did
|
||||
once; it cannot tell you whether a quantisation cost you anything, whether a checkpoint
|
||||
regressed, or whether a serving flag was worth it. Those are the questions people actually
|
||||
have, and all of them are differences.
|
||||
|
||||
Two design decisions carry most of the weight here:
|
||||
|
||||
1. PER-SAMPLE DIFFS, NOT AGGREGATE DIFFS. "0.81 → 0.78" tells you something got worse and
|
||||
nothing about what. Listing the samples that flipped tells you *which capability broke*,
|
||||
which is the difference between a leaderboard and a debugging tool. The result schema
|
||||
stores per-sample outcomes precisely so this is possible.
|
||||
|
||||
2. COMPARABILITY IS CHECKED, NOT ASSUMED. Two runs are only honestly comparable when the
|
||||
thing you did not change actually did not change. Comparing a quant against a different
|
||||
quant on a different host tells you nothing, but it renders just as confidently as a
|
||||
clean comparison. So the diff states what differs about the targets themselves, and
|
||||
warns when more than one axis moved at once.
|
||||
|
||||
Perf and quality are diffed independently: a run may have one, both, or neither, and a
|
||||
target with no signal-tier score is still worth comparing on throughput.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Below this, a rate difference is not worth reading as a change. Applied to rates only: a
|
||||
# single sample flipping is always worth showing.
|
||||
#
|
||||
# Calibrate this against your own hardware before trusting it. The two runs committed here are
|
||||
# the same target on the same box on the same day, and throughput between them differs by ~10%
|
||||
# — so on that machine, anything under 10% is indistinguishable from running it twice. The
|
||||
# floor below is deliberately lower than that observed spread, because suppressing a real
|
||||
# regression is worse than showing noise the reader can dismiss; the identical-target warning
|
||||
# is what tells them which they are looking at.
|
||||
PERF_NOISE_FLOOR_PCT = 5.0
|
||||
|
||||
# The axes that make a target a target. Comparing across more than one at a time produces a
|
||||
# number that cannot be attributed to anything.
|
||||
TARGET_AXES = ("model", "quantization", "checkpoint", "serving_config", "engine")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldDelta:
|
||||
"""One axis on which two targets differ."""
|
||||
|
||||
field: str
|
||||
before: Any
|
||||
after: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleFlip:
|
||||
"""A sample whose outcome changed between runs."""
|
||||
|
||||
sample_id: str
|
||||
task: str
|
||||
before: float
|
||||
after: float
|
||||
became: str # "pass" | "fail" | "changed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricDelta:
|
||||
name: str
|
||||
before: float | None
|
||||
after: float | None
|
||||
|
||||
@property
|
||||
def absolute(self) -> float | None:
|
||||
if self.before is None or self.after is None:
|
||||
return None
|
||||
return self.after - self.before
|
||||
|
||||
@property
|
||||
def percent(self) -> float | None:
|
||||
if self.before in (None, 0) or self.after is None:
|
||||
return None
|
||||
return (self.after - self.before) / abs(self.before) * 100.0
|
||||
|
||||
@property
|
||||
def significant(self) -> bool:
|
||||
"""Whether a rate moved enough to be worth reading."""
|
||||
pct = self.percent
|
||||
return pct is not None and abs(pct) >= PERF_NOISE_FLOOR_PCT
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comparison:
|
||||
before_id: str
|
||||
after_id: str
|
||||
target_deltas: list[FieldDelta] = field(default_factory=list)
|
||||
host_changed: FieldDelta | None = None
|
||||
quality: list[MetricDelta] = field(default_factory=list)
|
||||
flips: list[SampleFlip] = field(default_factory=list)
|
||||
perf: dict[int, list[MetricDelta]] = field(default_factory=dict)
|
||||
only_in_before: list[str] = field(default_factory=list)
|
||||
only_in_after: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def confounded(self) -> bool:
|
||||
"""More than one axis moved, so no difference below can be attributed to any of them."""
|
||||
return len(self.target_deltas) + (1 if self.host_changed else 0) > 1
|
||||
|
||||
|
||||
def _target_field(target: dict[str, Any], name: str) -> Any:
|
||||
value = target.get(name)
|
||||
# serving_config is a dict; render it deterministically so a reordered YAML load does not
|
||||
# read as a change.
|
||||
if isinstance(value, dict):
|
||||
return tuple(sorted((str(k), str(v)) for k, v in value.items()))
|
||||
return value
|
||||
|
||||
|
||||
def _diff_targets(before: dict, after: dict) -> list[FieldDelta]:
|
||||
deltas = []
|
||||
for axis in TARGET_AXES:
|
||||
b, a = _target_field(before, axis), _target_field(after, axis)
|
||||
if b != a:
|
||||
deltas.append(FieldDelta(axis, b, a))
|
||||
return deltas
|
||||
|
||||
|
||||
def _sample_index(run: dict) -> dict[tuple[str, str], dict]:
|
||||
"""(task, sample_id) -> outcome. Keyed on both because ids are only unique within a task."""
|
||||
index = {}
|
||||
for quality in run.get("quality") or []:
|
||||
task = quality.get("task", "?")
|
||||
for sample in quality.get("samples") or []:
|
||||
index[(task, sample["sample_id"])] = sample
|
||||
return index
|
||||
|
||||
|
||||
def _perf_index(run: dict) -> dict[int, dict]:
|
||||
perf = run.get("perf") or {}
|
||||
return {p["concurrency"]: p for p in perf.get("points") or []}
|
||||
|
||||
|
||||
def compare(before: dict, after: dict) -> Comparison:
|
||||
"""Diff two loaded result dicts. Neither is mutated."""
|
||||
result = Comparison(
|
||||
before_id=before.get("run_id", "?"),
|
||||
after_id=after.get("run_id", "?"),
|
||||
target_deltas=_diff_targets(before.get("target") or {}, after.get("target") or {}),
|
||||
)
|
||||
|
||||
b_host = (before.get("host") or {}).get("id") or (before.get("host") or {}).get("name")
|
||||
a_host = (after.get("host") or {}).get("id") or (after.get("host") or {}).get("name")
|
||||
if b_host != a_host:
|
||||
result.host_changed = FieldDelta("host", b_host, a_host)
|
||||
|
||||
# A contaminated run has no comparable quality half, and diffing against one produces a
|
||||
# delta that looks exactly like a real regression or gain. Say so before anything else.
|
||||
for label, run in (("before", before), ("after", after)):
|
||||
state = (run.get("verdict") or {}).get("contamination")
|
||||
if state == "detected":
|
||||
result.warnings.append(
|
||||
f"The {label} run is CONTAMINATED — it reproduced canary probes. Its quality "
|
||||
"numbers are memorisation, not capability, and no quality delta below means "
|
||||
"anything."
|
||||
)
|
||||
elif state == "unverified":
|
||||
result.warnings.append(
|
||||
f"The {label} run has no contamination probe, so its quality scores are "
|
||||
"unverified."
|
||||
)
|
||||
|
||||
if not result.target_deltas and not result.host_changed:
|
||||
# Same target, same box: any difference is run-to-run variance, which is worth knowing
|
||||
# because it sets the floor below which no other comparison means anything.
|
||||
result.warnings.append(
|
||||
"Identical target and host — this measures run-to-run variance, not a change."
|
||||
)
|
||||
if result.confounded:
|
||||
moved = [d.field for d in result.target_deltas] + (["host"] if result.host_changed else [])
|
||||
result.warnings.append(
|
||||
f"{len(moved)} axes changed at once ({', '.join(moved)}) — no difference below "
|
||||
"can be attributed to any one of them."
|
||||
)
|
||||
|
||||
# ---- quality -----------------------------------------------------------------
|
||||
b_metrics = {q["task"]: q.get("metrics", {}) for q in before.get("quality") or []}
|
||||
a_metrics = {q["task"]: q.get("metrics", {}) for q in after.get("quality") or []}
|
||||
for task in sorted(set(b_metrics) | set(a_metrics)):
|
||||
keys = set(b_metrics.get(task, {})) | set(a_metrics.get(task, {}))
|
||||
for key in sorted(keys):
|
||||
result.quality.append(
|
||||
MetricDelta(
|
||||
f"{task}.{key}",
|
||||
b_metrics.get(task, {}).get(key),
|
||||
a_metrics.get(task, {}).get(key),
|
||||
)
|
||||
)
|
||||
|
||||
b_samples, a_samples = _sample_index(before), _sample_index(after)
|
||||
for key in sorted(b_samples.keys() & a_samples.keys()):
|
||||
b, a = b_samples[key], a_samples[key]
|
||||
if b.get("passed") == a.get("passed") and b.get("score") == a.get("score"):
|
||||
continue
|
||||
if b.get("passed") and not a.get("passed"):
|
||||
became = "fail"
|
||||
elif a.get("passed") and not b.get("passed"):
|
||||
became = "pass"
|
||||
else:
|
||||
became = "changed"
|
||||
result.flips.append(
|
||||
SampleFlip(key[1], key[0], b.get("score", 0.0), a.get("score", 0.0), became)
|
||||
)
|
||||
# Regressions first: a capability that broke is the reason anyone runs this.
|
||||
result.flips.sort(key=lambda f: ({"fail": 0, "changed": 1, "pass": 2}[f.became], f.sample_id))
|
||||
|
||||
# A sample present in only one run is not a flip — it is a changed eval set, which
|
||||
# silently moves an aggregate. Surfaced separately so it cannot be mistaken for signal.
|
||||
result.only_in_before = sorted(f"{t}/{s}" for t, s in b_samples.keys() - a_samples.keys())
|
||||
result.only_in_after = sorted(f"{t}/{s}" for t, s in a_samples.keys() - b_samples.keys())
|
||||
if not b_samples or not a_samples:
|
||||
# Not a changed eval set — one side simply never measured quality. Saying "100 samples
|
||||
# added" here would be a confusing way to report "the baseline is perf-only".
|
||||
if b_samples or a_samples:
|
||||
missing = "before" if not b_samples else "after"
|
||||
result.warnings.append(
|
||||
f"The {missing} run carries no quality results, so only serving performance "
|
||||
"is comparable."
|
||||
)
|
||||
elif result.only_in_before or result.only_in_after:
|
||||
result.warnings.append(
|
||||
f"The eval set changed: {len(result.only_in_before)} sample(s) gone, "
|
||||
f"{len(result.only_in_after)} added. Aggregate scores are not comparable."
|
||||
)
|
||||
|
||||
# An EDITED sample keeps its id, so the add/remove diff above sees nothing. Rewording a
|
||||
# prompt or loosening a regex changes what the score means while every id still lines up,
|
||||
# which is the most dangerous shape of eval drift: the comparison looks clean. The
|
||||
# fingerprint hashes (id, input, target), so it moves when the content does.
|
||||
b_fp = {q["task"]: q.get("dataset_fingerprint") for q in before.get("quality") or []}
|
||||
a_fp = {q["task"]: q.get("dataset_fingerprint") for q in after.get("quality") or []}
|
||||
for task in sorted(b_fp.keys() & a_fp.keys()):
|
||||
bf, af = b_fp[task], a_fp[task]
|
||||
if bf and af and bf != af:
|
||||
result.warnings.append(
|
||||
f"Task {task!r} was edited between runs ({bf} -> {af}): same sample ids, "
|
||||
"different content. The scores measure different questions."
|
||||
)
|
||||
elif not bf or not af:
|
||||
result.warnings.append(
|
||||
f"Task {task!r} has no dataset fingerprint on one side, so an edit to the "
|
||||
"sample text cannot be ruled out. Re-run to get a comparable pair."
|
||||
)
|
||||
|
||||
# Sampling is part of the identity of a quality result. The same model at temperature
|
||||
# 1 and at 0.6 is two different measurements, and unpinned sampling moved this bench's
|
||||
# score by 27% of its samples between identical runs -- far more than most real
|
||||
# regressions. A card that predates sampling being recorded cannot be ruled comparable
|
||||
# either, so say so rather than diff it silently.
|
||||
b_s, a_s = before.get("sampling") or {}, after.get("sampling") or {}
|
||||
if b_samples and a_samples:
|
||||
if not b_s or not a_s:
|
||||
result.warnings.append(
|
||||
"One run does not record how the model was sampled, so an unpinned "
|
||||
"temperature cannot be ruled out. Quality deltas below may be noise."
|
||||
)
|
||||
else:
|
||||
changed = sorted(
|
||||
k for k in set(b_s) | set(a_s) if b_s.get(k) != a_s.get(k)
|
||||
)
|
||||
# Epoch count is not a sampling change. Both sides estimate the same quantity;
|
||||
# more epochs just estimates it better. Reporting that as "not comparable"
|
||||
# overstates it, and a warning that overstates is one people learn to skip --
|
||||
# which costs you the ones that matter.
|
||||
decode = [k for k in changed if k != "epochs"]
|
||||
if decode:
|
||||
result.warnings.append(
|
||||
f"Sampling changed between runs ({', '.join(decode)}): "
|
||||
f"{ {k: b_s.get(k) for k in decode} } -> { {k: a_s.get(k) for k in decode} }. "
|
||||
"Quality scores are not comparable across different sampling."
|
||||
)
|
||||
if "epochs" in changed:
|
||||
b_e, a_e = b_s.get("epochs", 1), a_s.get("epochs", 1)
|
||||
result.warnings.append(
|
||||
f"Epochs differ ({b_e} -> {a_e}). Both estimate the same score; the "
|
||||
f"{'before' if (b_e or 1) < (a_e or 1) else 'after'} run is the noisier "
|
||||
"of the two, so read small deltas with that in mind."
|
||||
)
|
||||
|
||||
# ---- perf --------------------------------------------------------------------
|
||||
b_perf, a_perf = _perf_index(before), _perf_index(after)
|
||||
for concurrency in sorted(b_perf.keys() & a_perf.keys()):
|
||||
b, a = b_perf[concurrency], a_perf[concurrency]
|
||||
result.perf[concurrency] = [
|
||||
MetricDelta(name, b.get(name), a.get(name))
|
||||
for name in (
|
||||
"output_tps_total",
|
||||
"output_tps_per_stream",
|
||||
"ttft_p50_ms",
|
||||
"ttft_p95_ms",
|
||||
"tpot_p50_ms",
|
||||
# Higher is better, so the default direction rule is already right. Diffing it
|
||||
# is the point of an MTP comparison: throughput can move for many reasons,
|
||||
# acceptance moves only because drafting got better or worse.
|
||||
"spec_acceptance_rate",
|
||||
)
|
||||
if b.get(name) is not None or a.get(name) is not None
|
||||
]
|
||||
modes = {bool(p.get("natural_stop")) for p in list(b_perf.values()) + list(a_perf.values())}
|
||||
if len(modes) > 1:
|
||||
result.warnings.append(
|
||||
"One run pinned output length and the other let the model stop naturally. "
|
||||
"Throughput is not comparable across those two modes."
|
||||
)
|
||||
|
||||
dropped = sorted(b_perf.keys() - a_perf.keys())
|
||||
added = sorted(a_perf.keys() - b_perf.keys())
|
||||
if dropped or added:
|
||||
result.warnings.append(
|
||||
f"Concurrency sweep differs: {dropped or 'none'} dropped, {added or 'none'} added. "
|
||||
"Peak throughput is only comparable across a shared sweep."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Metrics where a smaller number is the better one, so a fall is an improvement.
|
||||
LOWER_IS_BETTER = ("ttft_p50_ms", "ttft_p95_ms", "tpot_p50_ms")
|
||||
|
||||
|
||||
def direction(metric: str, delta: MetricDelta) -> str:
|
||||
""""better" | "worse" | "flat" — meaning, not just sign."""
|
||||
if delta.absolute is None or not delta.significant:
|
||||
return "flat"
|
||||
improved = delta.absolute < 0 if metric in LOWER_IS_BETTER else delta.absolute > 0
|
||||
return "better" if improved else "worse"
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
"""Serving performance measurement.
|
||||
|
||||
A self-contained async load generator rather than a wrapper around
|
||||
`vllm bench serve`. Reasons:
|
||||
|
||||
* It works against ANY OpenAI-compatible endpoint -- vLLM, SGLang,
|
||||
llama.cpp, ollama, or a submitted model served however we choose to serve
|
||||
it. Submissions are the point of the site; we cannot assume vLLM.
|
||||
* It needs nothing installed on the target box. The runner talks HTTP.
|
||||
* We control the metric definitions, so numbers stay comparable across
|
||||
engines rather than inheriting each engine's benchmarking conventions.
|
||||
|
||||
TWO MEASUREMENT TRAPS THIS CODE AVOIDS -- both silently produce numbers that
|
||||
look great and mean nothing:
|
||||
|
||||
1. PREFIX CACHING. vLLM caches shared prompt prefixes. If every concurrent
|
||||
request sends the same prompt, prefill is nearly free after the first and
|
||||
throughput is wildly overstated. Every request here gets a unique nonce
|
||||
prefix so each one actually does its own prefill.
|
||||
|
||||
2. VARIABLE OUTPUT LENGTH. If the model decides when to stop, concurrency
|
||||
levels finish at different token counts and tok/s is not comparable
|
||||
between runs. We send `ignore_eos` so every request emits exactly
|
||||
`output_tokens` tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from .results import PerfPoint, PerfResult
|
||||
from .specdec import read_counters
|
||||
|
||||
# Filler vocabulary for synthetic prompts. Ordinary words rather than repeated
|
||||
# junk so tokenization stays close to what real traffic looks like.
|
||||
_FILLER = (
|
||||
"the quick brown fox jumps over a lazy dog while distant thunder rolls "
|
||||
"across open water and the harbour lights flicker against low cloud cover "
|
||||
"as fishing boats return with the morning tide carrying nets and crates "
|
||||
).split()
|
||||
|
||||
|
||||
def _synthetic_prompt(approx_tokens: int, nonce: str) -> str:
|
||||
"""Build a prompt of roughly `approx_tokens` tokens, unique per request.
|
||||
|
||||
The nonce leads so it lands in the first block and defeats prefix-cache
|
||||
reuse across concurrent requests.
|
||||
"""
|
||||
# ~0.75 tokens per word for ordinary English text.
|
||||
n_words = max(1, int(approx_tokens / 0.75))
|
||||
body = " ".join(_FILLER[i % len(_FILLER)] for i in range(n_words))
|
||||
return f"[{nonce}] {body}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestResult:
|
||||
ok: bool
|
||||
ttft_ms: float | None = None
|
||||
total_s: float | None = None
|
||||
output_tokens: int = 0
|
||||
input_tokens: int = 0
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def tpot_ms(self) -> float | None:
|
||||
"""Inter-token latency: decode time divided by tokens after the first."""
|
||||
if self.ttft_ms is None or self.total_s is None or self.output_tokens < 2:
|
||||
return None
|
||||
decode_ms = self.total_s * 1000 - self.ttft_ms
|
||||
return decode_ms / (self.output_tokens - 1)
|
||||
|
||||
|
||||
async def _one_request(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
prompt: str | None = None,
|
||||
pin_output: bool = True,
|
||||
) -> RequestResult:
|
||||
nonce = uuid.uuid4().hex[:12]
|
||||
# A supplied prompt still gets the nonce, because prefix-cache defeat is orthogonal to
|
||||
# whether the text is representative — reusing one real prompt across a concurrency level
|
||||
# would let the engine serve most of it from cache and report an inflated number.
|
||||
content = f"[{nonce}] {prompt}" if prompt else _synthetic_prompt(input_tokens, nonce)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": output_tokens,
|
||||
"temperature": 0.0,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
if pin_output:
|
||||
# vLLM/SGLang extension: force exactly max_tokens of output so runs are
|
||||
# comparable. Harmless (ignored) on engines that do not support it.
|
||||
#
|
||||
# Turned OFF for speculative-decoding measurement. Forcing generation past a natural
|
||||
# stop produces degenerate continuation, and a draft model's acceptance rate on
|
||||
# degenerate text is not its acceptance rate on real work — so pinning the length here
|
||||
# buys comparability at the cost of measuring the wrong thing.
|
||||
payload["ignore_eos"] = True
|
||||
|
||||
start = time.perf_counter()
|
||||
ttft: float | None = None
|
||||
counted = 0
|
||||
usage_in = 0
|
||||
usage_out = 0
|
||||
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", f"{base_url}/chat/completions", json=payload
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
body = (await resp.aread()).decode()[:200]
|
||||
return RequestResult(ok=False, error=f"HTTP {resp.status_code}: {body}")
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if usage := chunk.get("usage"):
|
||||
usage_in = usage.get("prompt_tokens", 0)
|
||||
usage_out = usage.get("completion_tokens", 0)
|
||||
|
||||
for choice in chunk.get("choices") or []:
|
||||
delta = choice.get("delta") or {}
|
||||
# Engines disagree on where reasoning text lands: vLLM with
|
||||
# --reasoning-parser emits `reasoning`, others use
|
||||
# `reasoning_content`. Count all of them -- reasoning tokens
|
||||
# cost the same decode time and the user waits for them
|
||||
# either way. Checking only `content` would report TTFT as
|
||||
# the time to the *answer*, which on a reasoning model is
|
||||
# thousands of tokens late.
|
||||
if any(
|
||||
isinstance(delta.get(k), str) and delta[k]
|
||||
for k in ("content", "reasoning", "reasoning_content")
|
||||
):
|
||||
# The opening chunk carries role + empty content; it is
|
||||
# the stream opening, not a token, and is excluded by
|
||||
# the emptiness check above.
|
||||
if ttft is None:
|
||||
ttft = (time.perf_counter() - start) * 1000
|
||||
counted += 1
|
||||
except Exception as exc: # noqa: BLE001 - report, do not abort the sweep
|
||||
return RequestResult(ok=False, error=f"{type(exc).__name__}: {exc}")
|
||||
|
||||
total = time.perf_counter() - start
|
||||
return RequestResult(
|
||||
ok=True,
|
||||
ttft_ms=ttft,
|
||||
total_s=total,
|
||||
# Prefer server-reported usage; fall back to counted chunks.
|
||||
output_tokens=usage_out or counted,
|
||||
input_tokens=usage_in,
|
||||
)
|
||||
|
||||
|
||||
async def _sweep_point(
|
||||
base_url: str,
|
||||
model: str,
|
||||
concurrency: int,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
timeout_s: float,
|
||||
prompts: list[str] | None = None,
|
||||
pin_output: bool = True,
|
||||
) -> PerfPoint:
|
||||
limits = httpx.Limits(max_connections=concurrency + 8)
|
||||
async with httpx.AsyncClient(timeout=timeout_s, limits=limits) as client:
|
||||
# Warm up so the first measured request does not absorb graph capture,
|
||||
# weight paging, or connection setup.
|
||||
await _one_request(client, base_url, model, 32, 8)
|
||||
|
||||
# Bracket the measured window. These are lifetime counters, so the rate for THIS point
|
||||
# is the delta — reading the total would fold in warm-up and every earlier point.
|
||||
before = await read_counters(client, base_url)
|
||||
|
||||
start = time.perf_counter()
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
_one_request(
|
||||
client,
|
||||
base_url,
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
# Cycle rather than repeat: every concurrent request in a point gets a
|
||||
# different prompt, so one unusually easy or hard example cannot set the
|
||||
# whole level's number.
|
||||
prompt=prompts[i % len(prompts)] if prompts else None,
|
||||
pin_output=pin_output,
|
||||
)
|
||||
for i in range(concurrency)
|
||||
)
|
||||
)
|
||||
wall = time.perf_counter() - start
|
||||
after = await read_counters(client, base_url)
|
||||
|
||||
ok = [r for r in results if r.ok]
|
||||
failed = [r for r in results if not r.ok]
|
||||
|
||||
total_out = sum(r.output_tokens for r in ok)
|
||||
ttfts = [r.ttft_ms for r in ok if r.ttft_ms is not None]
|
||||
tpots = [r.tpot_ms for r in ok if r.tpot_ms is not None]
|
||||
per_stream = [
|
||||
r.output_tokens / r.total_s for r in ok if r.total_s and r.output_tokens
|
||||
]
|
||||
|
||||
def pct(values: list[float], p: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
s = sorted(values)
|
||||
idx = min(len(s) - 1, round(p * (len(s) - 1)))
|
||||
return round(s[idx], 2)
|
||||
|
||||
# Prefill rate: input tokens processed per second, inferred from TTFT.
|
||||
# At concurrency > 1 this is per-stream and includes queueing, so it reads
|
||||
# low; treat the concurrency-1 value as the true prefill capability.
|
||||
prefill = None
|
||||
if ttfts and ok:
|
||||
reported = [r.input_tokens for r in ok if r.input_tokens]
|
||||
mean_in = statistics.mean(reported) if reported else input_tokens
|
||||
mean_ttft_s = statistics.mean(ttfts) / 1000
|
||||
if mean_ttft_s > 0:
|
||||
prefill = round(mean_in / mean_ttft_s, 1)
|
||||
|
||||
# Delta across the measured window only. `None` when the engine exposes no counters, which
|
||||
# is an ordinary outcome (no speculative decoding, or metrics disabled) and not an error.
|
||||
spec = (after - before) if (before and after) else None
|
||||
acceptance = spec.acceptance_rate if spec else None
|
||||
drafted = int(spec.draft) if spec and spec.draft > 0 else None
|
||||
|
||||
# With a natural stop, requests no longer emit identical token counts, so the spread is
|
||||
# itself a caveat on comparability and has to travel with the number.
|
||||
out_counts = [r.output_tokens for r in ok if r.output_tokens]
|
||||
stdev = (
|
||||
round(statistics.stdev(out_counts), 2)
|
||||
if not pin_output and len(out_counts) > 1
|
||||
else None
|
||||
)
|
||||
|
||||
return PerfPoint(
|
||||
concurrency=concurrency,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
n_requests=concurrency,
|
||||
completed=len(ok),
|
||||
failed=len(failed),
|
||||
duration_s=round(wall, 3),
|
||||
output_tps_total=round(total_out / wall, 2) if wall else 0.0,
|
||||
output_tps_per_stream=round(statistics.mean(per_stream), 2) if per_stream else 0.0,
|
||||
ttft_p50_ms=pct(ttfts, 0.50),
|
||||
ttft_p95_ms=pct(ttfts, 0.95),
|
||||
tpot_p50_ms=pct(tpots, 0.50),
|
||||
prefill_tps=prefill,
|
||||
spec_acceptance_rate=acceptance,
|
||||
spec_draft_tokens=drafted,
|
||||
natural_stop=not pin_output,
|
||||
output_token_stdev=stdev,
|
||||
error=failed[0].error if failed else None,
|
||||
)
|
||||
|
||||
|
||||
async def run_sweep(
|
||||
base_url: str,
|
||||
model: str,
|
||||
concurrencies: tuple[int, ...] = (1, 8, 32),
|
||||
input_tokens: int = 1024,
|
||||
output_tokens: int = 256,
|
||||
timeout_s: float = 600.0,
|
||||
engine: str = "unknown",
|
||||
progress: bool = True,
|
||||
prompts: list[str] | None = None,
|
||||
pin_output: bool = True,
|
||||
) -> PerfResult:
|
||||
"""Run a concurrency sweep and return a PerfResult.
|
||||
|
||||
`prompts` and `pin_output=False` together give the mode a speculative-decoding target needs:
|
||||
representative text, stopping naturally. Both defaults stay as they were, so an ordinary
|
||||
throughput sweep is unchanged and remains directly comparable to every result already
|
||||
committed.
|
||||
"""
|
||||
points: list[PerfPoint] = []
|
||||
for c in concurrencies:
|
||||
if progress:
|
||||
print(f" concurrency {c:>3} ...", end="", flush=True)
|
||||
point = await _sweep_point(
|
||||
base_url, model, c, input_tokens, output_tokens, timeout_s,
|
||||
prompts=prompts, pin_output=pin_output,
|
||||
)
|
||||
points.append(point)
|
||||
if progress:
|
||||
status = f"{point.output_tps_total:>8.1f} tok/s total"
|
||||
if point.spec_acceptance_rate is not None:
|
||||
status += f" accept {point.spec_acceptance_rate:.0%}"
|
||||
status += f" | {point.output_tps_per_stream:>6.1f} /stream"
|
||||
if point.ttft_p50_ms is not None:
|
||||
status += f" | TTFT p50 {point.ttft_p50_ms:>7.0f}ms"
|
||||
if point.failed:
|
||||
status += f" | {point.failed} FAILED"
|
||||
print(status, flush=True)
|
||||
|
||||
return PerfResult(engine=engine, points=points)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Model registry.
|
||||
|
||||
A *target* is not a model. It is a specific way of serving a specific model:
|
||||
|
||||
(model × host × quantization × serving config × checkpoint)
|
||||
|
||||
This is the central design decision of the bench and it cannot be retrofitted
|
||||
later without invalidating every result already recorded. On unified-memory
|
||||
boxes like the DGX Spark, serving flags move throughput more than a model swap
|
||||
does -- `--enforce-eager` alone can cost 30%+, and `--gpu-memory-utilization`
|
||||
determines how much KV cache exists, which sets the concurrency ceiling. A
|
||||
registry keyed on model name alone produces numbers nobody can reproduce.
|
||||
|
||||
`checkpoint` is first-class for the same reason: once we fine-tune our own
|
||||
models, we will compare `karti-7b@step2000` against `karti-7b@step4000` far
|
||||
more often than we compare Qwen against Llama.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REGISTRY_PATH = Path(__file__).parent / "models.yaml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Host:
|
||||
"""A machine that can serve models."""
|
||||
|
||||
id: str
|
||||
ssh: str | None = None
|
||||
hardware: str | None = None
|
||||
memory_gb: int | None = None
|
||||
memory_bandwidth_gbs: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Serving:
|
||||
"""How a target is served. Part of the identity of a result."""
|
||||
|
||||
engine: str # vllm | sglang | llama.cpp | anthropic | openai | ...
|
||||
model_name: str # value sent as "model" in the API request
|
||||
base_url: str | None = None
|
||||
service: str | None = None # inspect openai-api service prefix
|
||||
max_model_len: int | None = None
|
||||
flags: dict[str, Any] = field(default_factory=dict)
|
||||
# How to sample this model during quality runs. Part of the identity of a result: the
|
||||
# same model at temperature 1 and at temperature 0.6 is two different measurements.
|
||||
#
|
||||
# Not a global constant, because the right value is a property of the model. Greedy
|
||||
# decoding looks like the obvious choice for reproducibility and is actively wrong for
|
||||
# reasoning models -- Qwen3 in thinking mode degenerates into repetition at
|
||||
# temperature 0, and a 2-sample probe that took two minutes ran past twelve without
|
||||
# terminating. Reproducibility comes from pinning the seed, not from killing entropy.
|
||||
sampling: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
return self.engine in {"vllm", "sglang", "llama.cpp", "ollama"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""One benchmarkable configuration."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
serving: Serving
|
||||
host: str
|
||||
family: str | None = None
|
||||
params_b: float | None = None
|
||||
active_params_b: float | None = None # MoE: params active per token
|
||||
quant: str | None = None
|
||||
checkpoint: str | None = None
|
||||
tier: str = "candidate" # production | candidate | reference | baseline
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
notes: str | None = None
|
||||
|
||||
@property
|
||||
def inspect_model(self) -> str:
|
||||
"""The model string Inspect uses to address this target."""
|
||||
s = self.serving
|
||||
if s.engine in {"anthropic", "openai", "google", "openrouter", "grok"}:
|
||||
return f"{s.engine}/{s.model_name}"
|
||||
if s.is_local:
|
||||
if not s.service:
|
||||
raise ValueError(f"target {self.id}: local serving requires a 'service'")
|
||||
# Inspect convention: openai-api/<service>/<model>, with base_url and
|
||||
# api key read from <SERVICE>_BASE_URL / <SERVICE>_API_KEY.
|
||||
return f"openai-api/{s.service}/{s.model_name}"
|
||||
raise ValueError(f"target {self.id}: unsupported engine {s.engine!r}")
|
||||
|
||||
@property
|
||||
def env(self) -> dict[str, str]:
|
||||
"""Environment variables Inspect needs to reach this target."""
|
||||
s = self.serving
|
||||
if not s.is_local or not s.service:
|
||||
return {}
|
||||
prefix = s.service.upper().replace("-", "_")
|
||||
env = {f"{prefix}_API_KEY": os.environ.get(f"{prefix}_API_KEY", "no-key-required")}
|
||||
if s.base_url:
|
||||
env[f"{prefix}_BASE_URL"] = s.base_url
|
||||
return env
|
||||
|
||||
def apply_env(self) -> None:
|
||||
"""Export this target's env vars into the current process."""
|
||||
os.environ.update(self.env)
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
"""Filesystem-safe identifier used in results filenames."""
|
||||
return self.id.replace("/", "_").replace("@", "__")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Registry:
|
||||
hosts: dict[str, Host]
|
||||
targets: dict[str, Target]
|
||||
|
||||
def target(self, ident: str) -> Target:
|
||||
"""Look up a target by id or alias."""
|
||||
if ident in self.targets:
|
||||
return self.targets[ident]
|
||||
for t in self.targets.values():
|
||||
if ident in t.aliases:
|
||||
return t
|
||||
known = ", ".join(sorted(self.targets))
|
||||
raise KeyError(f"unknown target {ident!r}. known targets: {known}")
|
||||
|
||||
def host(self, ident: str) -> Host:
|
||||
if ident not in self.hosts:
|
||||
raise KeyError(f"unknown host {ident!r}")
|
||||
return self.hosts[ident]
|
||||
|
||||
def by_tier(self, tier: str) -> list[Target]:
|
||||
return [t for t in self.targets.values() if t.tier == tier]
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> Registry:
|
||||
raw = yaml.safe_load((path or REGISTRY_PATH).read_text())
|
||||
|
||||
hosts = {
|
||||
hid: Host(id=hid, **(spec or {}))
|
||||
for hid, spec in (raw.get("hosts") or {}).items()
|
||||
}
|
||||
|
||||
targets: dict[str, Target] = {}
|
||||
for spec in raw.get("targets") or []:
|
||||
spec = dict(spec)
|
||||
serving = Serving(**spec.pop("serving"))
|
||||
target = Target(serving=serving, **spec)
|
||||
if target.host not in hosts:
|
||||
raise ValueError(f"target {target.id}: unknown host {target.host!r}")
|
||||
if target.id in targets:
|
||||
raise ValueError(f"duplicate target id {target.id!r}")
|
||||
targets[target.id] = target
|
||||
|
||||
return Registry(hosts=hosts, targets=targets)
|
||||
|
||||
|
||||
__all__ = ["Host", "Registry", "Serving", "Target", "load_registry"]
|
||||
@@ -0,0 +1,109 @@
|
||||
# Lumbridge Bench target registry
|
||||
#
|
||||
# A target = (model x host x quant x serving config x checkpoint).
|
||||
# See kbench/registry/__init__.py for why the key is this wide.
|
||||
#
|
||||
# `serving.flags` records the ACTUAL flags the server was started with. Snapshot
|
||||
# them from a live host with: kbench snapshot <host>
|
||||
# Results are only comparable between targets with identical flags.
|
||||
|
||||
hosts:
|
||||
your-node:
|
||||
ssh: your-node
|
||||
hardware: NVIDIA GB10 Grace Blackwell (DGX Spark)
|
||||
memory_gb: 121
|
||||
memory_bandwidth_gbs: 273
|
||||
notes: >
|
||||
Unified LPDDR5X. Memory bandwidth is the binding constraint on decode
|
||||
throughput, not compute -- large dense models are bandwidth-starved here
|
||||
while MoE models with small active-param counts do comparatively well.
|
||||
|
||||
metal:
|
||||
ssh: your-second-node
|
||||
hardware: TBD
|
||||
notes: Secondary inference box. Not yet benchmarked.
|
||||
|
||||
your-second-node:
|
||||
ssh: root@your-second-node
|
||||
hardware: TBD
|
||||
notes: Storage origin box, 761GB. Not yet benchmarked.
|
||||
|
||||
api:
|
||||
hardware: hosted
|
||||
notes: Frontier APIs. Used as quality ceiling and as judge models.
|
||||
|
||||
targets:
|
||||
# ---------------------------------------------------------------------
|
||||
# PRODUCTION -- currently serving live traffic, so regressions here matter
|
||||
# ---------------------------------------------------------------------
|
||||
- id: qwen3.6-35b-a3b-nvfp4@your-node
|
||||
display_name: Qwen3.6-35B-A3B (NVFP4)
|
||||
family: qwen3.6
|
||||
params_b: 35
|
||||
active_params_b: 3
|
||||
quant: nvfp4
|
||||
host: your-node
|
||||
tier: production
|
||||
aliases: [brain, spark]
|
||||
notes: >
|
||||
Live behind the `brain` and `local-moe` aliases. This is the
|
||||
model the bench exists to interrogate: it was deployed without
|
||||
measurement.
|
||||
|
||||
Flags below were snapshotted from the live process on 2026-08-03 and had
|
||||
drifted badly from what was recorded here: gpu_memory_utilization was
|
||||
0.25 not 0.55, the MoE backend was flashinfer_cutlass not flashinfer_b12x,
|
||||
and MTP speculative decoding was running but undocumented. Since target
|
||||
identity IS the serving config, score cards taken against the old entry
|
||||
would have been mislabeled. Re-snapshot before trusting a comparison.
|
||||
|
||||
FLAGS OF CONCERN, in the order worth testing:
|
||||
(1) --max-num-seqs 4 caps concurrency at four sequences, so a throughput
|
||||
sweep past 4 measures queueing, not the server.
|
||||
(2) --gpu-memory-utilization 0.25 leaves ~75% of unified memory unused,
|
||||
capping KV cache and therefore concurrency.
|
||||
(3) --enforce-eager disables CUDA graphs, which costs throughput.
|
||||
(4) MTP is on; report spec_acceptance_rate, since a low acceptance rate
|
||||
means the draft tokens are wasted work.
|
||||
serving:
|
||||
engine: vllm
|
||||
service: spark
|
||||
base_url: http://your-node:8001/v1
|
||||
model_name: brain
|
||||
max_model_len: 65536
|
||||
# Qwen3's documented values for thinking mode. Do NOT set temperature 0 here: this
|
||||
# model degenerates into endless repetition under greedy decoding, and a two-sample
|
||||
# probe that normally finishes in two minutes ran past twelve without terminating.
|
||||
# The seed is what makes a re-run a re-run.
|
||||
sampling:
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
top_k: 20
|
||||
seed: 20260804
|
||||
max_tokens: 4096
|
||||
flags:
|
||||
async_scheduling: true
|
||||
enforce_eager: true
|
||||
gpu_memory_utilization: 0.25
|
||||
kv_cache_dtype: fp8
|
||||
max_num_batched_tokens: 4096
|
||||
max_num_seqs: 4
|
||||
moe_backend: flashinfer_cutlass
|
||||
reasoning_parser: qwen3
|
||||
speculative_config: '{"method":"qwen3_5_mtp","num_speculative_tokens":2}'
|
||||
tool_call_parser: qwen3_coder
|
||||
enable_auto_tool_choice: true
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# BASELINE -- frontier APIs. Quality ceiling, and the judge for graded tasks.
|
||||
# Uncomment once the corresponding API key is in .env.
|
||||
# ---------------------------------------------------------------------
|
||||
# - id: claude-opus-5@api
|
||||
# display_name: Claude Opus 5
|
||||
# family: claude
|
||||
# host: api
|
||||
# tier: baseline
|
||||
# aliases: [opus]
|
||||
# serving:
|
||||
# engine: anthropic
|
||||
# model_name: claude-opus-5
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Result schema.
|
||||
|
||||
One run produces one JSON file in results/, committed to git. The commit
|
||||
history IS the longitudinal record -- there is no database to migrate or rot.
|
||||
|
||||
Two properties of this schema are load-bearing and expensive to retrofit:
|
||||
|
||||
1. PER-SAMPLE OUTCOMES, not just aggregates. An aggregate tells you a
|
||||
checkpoint got worse. Per-sample diffs tell you *which capability broke*.
|
||||
That difference is what separates a leaderboard from a debugging tool, and
|
||||
it is the entire reason this bench is useful for fine-tuning.
|
||||
|
||||
2. THE FULL TARGET IS SNAPSHOTTED INTO THE RESULT. Registry entries change.
|
||||
A result that merely references a target id becomes unreproducible the
|
||||
first time someone edits models.yaml. Every run carries its own copy of
|
||||
the serving flags it actually ran against.
|
||||
|
||||
Output storage policy: we store the score plus a short excerpt per sample.
|
||||
Full model outputs stay in the raw Inspect .eval log, which is gitignored --
|
||||
they are large, and for signal-tier tasks they can echo sensitive content
|
||||
from the prompts they were derived from. Deep debugging uses the local log;
|
||||
git carries the shape of the failure, not the whole transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
RESULTS_DIR = Path(__file__).resolve().parent.parent / "results"
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""Map non-finite floats to None, recursively.
|
||||
|
||||
Python's json emits bare `NaN`/`Infinity`, which its own loader accepts and almost
|
||||
nothing else does — JSON.parse, Go, and serde all reject them. A score card is meant
|
||||
to be a portable, durable record, so a card that only Python can read is a broken one.
|
||||
An ungraded sample surfaced as NaN here and made the first committed card unparseable
|
||||
by the website that has to render it.
|
||||
|
||||
None means "no score", which is exactly what a non-finite score meant anyway.
|
||||
"""
|
||||
if isinstance(value, float):
|
||||
# NaN != NaN, and inf fails the range check; both are unrepresentable in JSON.
|
||||
return None if value != value or value in (float("inf"), float("-inf")) else value
|
||||
if isinstance(value, dict):
|
||||
return {k: _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(v) for v in value]
|
||||
return value
|
||||
|
||||
# How much of each model output to keep in the committed record.
|
||||
EXCERPT_CHARS = 400
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleOutcome:
|
||||
"""One sample's result. The unit of regression debugging."""
|
||||
|
||||
sample_id: str
|
||||
score: float
|
||||
passed: bool
|
||||
excerpt: str | None = None # truncated model output; full text in .eval log
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityResult:
|
||||
"""One task's scores against one target."""
|
||||
|
||||
task: str
|
||||
tier: str # "reference" (public, contaminated, calibration only) | "signal"
|
||||
dataset_version: str | None = None
|
||||
# Content hash of the samples actually run. dataset_version is inspect's static
|
||||
# task_version and does not move when the data changes; this does. Two cards are
|
||||
# only comparable if their fingerprints match.
|
||||
dataset_fingerprint: str | None = None
|
||||
n_samples: int = 0
|
||||
metrics: dict[str, float] = field(default_factory=dict)
|
||||
samples: list[SampleOutcome] = field(default_factory=list)
|
||||
duration_s: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerfPoint:
|
||||
"""Serving performance at one concurrency level."""
|
||||
|
||||
concurrency: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
n_requests: int
|
||||
completed: int
|
||||
failed: int
|
||||
duration_s: float
|
||||
# Aggregate throughput across all streams -- the number that matters for
|
||||
# multi-user serving, and the one single-stream reviews miss entirely.
|
||||
output_tps_total: float
|
||||
# Per-stream decode rate -- what a single user actually feels.
|
||||
output_tps_per_stream: float
|
||||
ttft_p50_ms: float | None = None
|
||||
ttft_p95_ms: float | None = None
|
||||
tpot_p50_ms: float | None = None # time per output token, inter-token latency
|
||||
prefill_tps: float | None = None
|
||||
# Speculative decoding, when the engine reports it. Throughput alone cannot say whether
|
||||
# drafting is working: a draft rejected almost every time still emits tokens, having paid
|
||||
# for both passes. `None` means the engine exposed no counters, not that the rate was zero.
|
||||
spec_acceptance_rate: float | None = None
|
||||
spec_draft_tokens: int | None = None
|
||||
# True when output length was NOT pinned with ignore_eos. Comparability across points is
|
||||
# weaker in that mode, and a reader has to know which they are looking at.
|
||||
natural_stop: bool = False
|
||||
output_token_stdev: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerfResult:
|
||||
engine: str
|
||||
points: list[PerfPoint] = field(default_factory=list)
|
||||
peak_memory_gb: float | None = None
|
||||
idle_memory_gb: float | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Run:
|
||||
"""A complete score card: one target, one point in time."""
|
||||
|
||||
run_id: str
|
||||
timestamp: str
|
||||
target_id: str
|
||||
target: dict[str, Any] # full snapshot -- see module docstring
|
||||
host: dict[str, Any]
|
||||
quality: list[QualityResult] = field(default_factory=list)
|
||||
perf: PerfResult | None = None
|
||||
verdict: dict[str, Any] = field(default_factory=dict)
|
||||
runner: dict[str, Any] = field(default_factory=dict)
|
||||
# How the model was sampled. Two cards are only comparable if this matches: a score
|
||||
# taken at temperature 1 is a draw from a distribution, and differs run to run by more
|
||||
# than most real regressions do. Recorded rather than assumed, because the quality half
|
||||
# sampled unpinned for its entire life and nothing in the card said so.
|
||||
sampling: dict[str, Any] = field(default_factory=dict)
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
|
||||
# ---------------------------------------------------------------- io --
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
date = self.timestamp[:10]
|
||||
slug = self.target.get("slug") or self.target_id.replace("@", "__")
|
||||
return f"{date}__{slug}__{self.run_id[:8]}.json"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return _json_safe(asdict(self))
|
||||
|
||||
def save(self, results_dir: Path | None = None) -> Path:
|
||||
d = results_dir or RESULTS_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / self.filename
|
||||
# allow_nan=False so this raises instead of emitting a token that is not JSON.
|
||||
# to_dict has already mapped the non-finite values to null; if a new one appears,
|
||||
# the write should fail loudly rather than commit an unparseable score card.
|
||||
path.write_text(
|
||||
json.dumps(self.to_dict(), indent=2, sort_keys=False, allow_nan=False) + "\n"
|
||||
)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def load(path: Path) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text())
|
||||
|
||||
|
||||
def runner_info() -> dict[str, Any]:
|
||||
"""Provenance of the machine that drove the benchmark (not the one serving)."""
|
||||
return {
|
||||
"host": platform.node(),
|
||||
"python": platform.python_version(),
|
||||
"platform": platform.platform(),
|
||||
}
|
||||
|
||||
|
||||
def truncate(text: str | None, limit: int = EXCERPT_CHARS) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
text = text.strip()
|
||||
return text if len(text) <= limit else text[:limit] + f"... [+{len(text) - limit} chars]"
|
||||
|
||||
|
||||
def compute_verdict(quality: list[QualityResult], perf: PerfResult | None) -> dict[str, Any]:
|
||||
"""Derive a self-hosting verdict from the raw numbers.
|
||||
|
||||
Deliberately simple and explicit rather than clever. The thresholds encode
|
||||
what makes a model usable in our own stack, and they live in code (and
|
||||
therefore in git history) so that a changed verdict is always traceable to
|
||||
a changed rule rather than to drift.
|
||||
"""
|
||||
verdict: dict[str, Any] = {}
|
||||
|
||||
signal = [q for q in quality if q.tier == "signal" and q.error is None]
|
||||
reference = [q for q in quality if q.tier == "reference" and q.error is None]
|
||||
|
||||
def mean_accuracy(results: list[QualityResult]) -> float | None:
|
||||
vals = [q.metrics.get("accuracy") for q in results]
|
||||
vals = [v for v in vals if v is not None]
|
||||
return round(sum(vals) / len(vals), 4) if vals else None
|
||||
|
||||
verdict["signal_score"] = mean_accuracy(signal)
|
||||
verdict["reference_score"] = mean_accuracy(reference)
|
||||
|
||||
# Contamination gate. A probe nobody reads is the same as no probe, so this does not just
|
||||
# record a number — when a probe fires it takes `signal_score` away.
|
||||
#
|
||||
# Nulling it is deliberate. Anything downstream that reads `verdict["signal_score"]` —
|
||||
# `kbench results`, a comparison, a published card — would otherwise happily render a
|
||||
# memorised score next to a small warning, and the number is what people quote. The raw
|
||||
# value is preserved under a name nobody reads by accident, so nothing is lost for
|
||||
# forensics and nothing is quotable by mistake.
|
||||
canary = [q for q in quality if q.tier == "canary" and q.error is None]
|
||||
canary_score = mean_accuracy(canary)
|
||||
verdict["canary_score"] = canary_score
|
||||
if canary_score is None:
|
||||
# No probe ran. The signal score stands, but it is unverified, and saying so is the
|
||||
# difference between "clean" and "nobody checked".
|
||||
verdict["contamination"] = "unverified"
|
||||
elif canary_score > 0:
|
||||
verdict["contamination"] = "detected"
|
||||
verdict["signal_score_unverified"] = verdict["signal_score"]
|
||||
verdict["signal_score"] = None
|
||||
else:
|
||||
verdict["contamination"] = "clean"
|
||||
|
||||
if perf and perf.points:
|
||||
single = next((p for p in perf.points if p.concurrency == 1), None)
|
||||
best = max(perf.points, key=lambda p: p.output_tps_total)
|
||||
if single:
|
||||
verdict["single_stream_tps"] = round(single.output_tps_per_stream, 1)
|
||||
# Below ~15 tok/s a model reads slower than a person, which rules
|
||||
# out anything interactive regardless of how good its answers are.
|
||||
verdict["interactive_viable"] = single.output_tps_per_stream >= 15.0
|
||||
verdict["peak_throughput_tps"] = round(best.output_tps_total, 1)
|
||||
verdict["peak_throughput_concurrency"] = best.concurrency
|
||||
|
||||
return verdict
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
"""Run orchestration: target + tasks + perf sweep -> one committed score card."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import tasks as task_catalog
|
||||
from .perf import run_sweep
|
||||
from .registry import Registry, Target
|
||||
from .results import (
|
||||
PerfResult,
|
||||
QualityResult,
|
||||
Run,
|
||||
SampleOutcome,
|
||||
compute_verdict,
|
||||
runner_info,
|
||||
truncate,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
|
||||
# Inspect's canonical scalar score values.
|
||||
_CORRECTNESS = {"C": 1.0, "I": 0.0, "P": 0.5, "N": 0.0}
|
||||
|
||||
|
||||
def _normalize_sample_score(value: Any, primary_key: str | None) -> tuple[float, bool, dict]:
|
||||
"""Reduce a score of any shape to (score, passed, extra_metadata).
|
||||
|
||||
Inspect scorers return scalars, correctness letters, or dicts depending on
|
||||
the eval. IFEval returns a dict; a simple match scorer returns "C"/"I".
|
||||
Normalizing here keeps the results schema uniform, which is what makes
|
||||
cross-task and cross-checkpoint comparison possible at all.
|
||||
"""
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
extra = dict(value)
|
||||
if primary_key and primary_key in value:
|
||||
inner = value[primary_key]
|
||||
else:
|
||||
# Fall back to the first bool/numeric entry so an unconfigured task
|
||||
# still produces something rather than silently scoring zero.
|
||||
inner = next(
|
||||
(v for v in value.values() if isinstance(v, (bool, int, float))),
|
||||
0.0,
|
||||
)
|
||||
return _normalize_sample_score(inner, None)[0], bool(inner), extra
|
||||
|
||||
if isinstance(value, bool):
|
||||
return (1.0 if value else 0.0), value, extra
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
# A non-finite score means the sample was never graded — a rubric sample with no
|
||||
# judge configured arrives as NaN. `NaN > 0.0` is False, so it used to be recorded
|
||||
# as an ordinary failure, which is a different claim: "the model got this wrong"
|
||||
# rather than "nobody scored this". The mean already skips it; this makes the
|
||||
# per-sample record say so too.
|
||||
if isinstance(value, float) and value != value:
|
||||
return value, False, {**extra, "ungraded": True}
|
||||
return float(value), float(value) > 0.0, extra
|
||||
|
||||
if isinstance(value, str):
|
||||
score = _CORRECTNESS.get(value.strip().upper()[:1], 0.0)
|
||||
return score, score >= 1.0, {"raw": value}
|
||||
|
||||
return 0.0, False, {"raw": repr(value)}
|
||||
|
||||
|
||||
def _quality_from_log(log: Any, spec: task_catalog.TaskSpec) -> QualityResult:
|
||||
"""Extract a QualityResult (including per-sample outcomes) from an EvalLog."""
|
||||
if log.status != "success":
|
||||
err = getattr(log, "error", None)
|
||||
return QualityResult(
|
||||
task=spec.name,
|
||||
tier=spec.tier,
|
||||
error=str(err) if err else f"eval status: {log.status}",
|
||||
)
|
||||
|
||||
metrics: dict[str, float] = {}
|
||||
for scorer in (log.results.scores if log.results else []):
|
||||
for key, metric in scorer.metrics.items():
|
||||
metrics[key] = round(float(metric.value), 6)
|
||||
|
||||
# Promote the task's headline number to a uniform "accuracy" key so the
|
||||
# leaderboard and verdict logic never need per-task special cases.
|
||||
if spec.primary_metric in metrics:
|
||||
metrics["accuracy"] = metrics[spec.primary_metric]
|
||||
|
||||
samples: list[SampleOutcome] = []
|
||||
for s in (log.samples or []):
|
||||
for scorer_name, score in (s.scores or {}).items():
|
||||
value, passed, extra = _normalize_sample_score(
|
||||
score.value, spec.primary_sample_key
|
||||
)
|
||||
samples.append(
|
||||
SampleOutcome(
|
||||
sample_id=str(s.id),
|
||||
score=value,
|
||||
passed=passed,
|
||||
excerpt=truncate(getattr(s.output, "completion", None)),
|
||||
metadata={
|
||||
"scorer": scorer_name,
|
||||
"epoch": s.epoch,
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
stats = getattr(log, "stats", None)
|
||||
duration = None
|
||||
if stats and getattr(stats, "started_at", None) and getattr(stats, "completed_at", None):
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
duration = (
|
||||
datetime.fromisoformat(stats.completed_at)
|
||||
- datetime.fromisoformat(stats.started_at)
|
||||
).total_seconds()
|
||||
except Exception: # noqa: BLE001 - duration is nice-to-have, never fatal
|
||||
duration = None
|
||||
|
||||
return QualityResult(
|
||||
task=spec.name,
|
||||
tier=spec.tier,
|
||||
dataset_version=getattr(log.eval, "task_version", None) and str(log.eval.task_version),
|
||||
dataset_fingerprint=_dataset_fingerprint(log.samples or []),
|
||||
n_samples=len(log.samples or []),
|
||||
metrics=metrics,
|
||||
samples=samples,
|
||||
duration_s=duration,
|
||||
)
|
||||
|
||||
|
||||
def _dataset_fingerprint(samples: list[Any]) -> str | None:
|
||||
"""Content hash of the samples actually run.
|
||||
|
||||
`dataset_version` is inspect's static task_version -- it stays "2" whether the family
|
||||
has twelve samples or a rewritten scorer. Editing a prompt, tightening a regex, or
|
||||
dropping a sample therefore produced two cards that claimed the same dataset version
|
||||
and were not comparable, with nothing to reveal it. Comparing those is the exact error
|
||||
this bench exists to prevent, so the identity has to come from the content.
|
||||
|
||||
Hashed over (id, input, target) because those are what determine whether a score means
|
||||
the same thing. Sorted, so sample ordering does not change the fingerprint.
|
||||
"""
|
||||
if not samples:
|
||||
return None
|
||||
h = hashlib.sha256()
|
||||
for key in sorted(
|
||||
f"{s.id}\x1f{s.input}\x1f{s.target}" for s in samples
|
||||
):
|
||||
h.update(key.encode())
|
||||
h.update(b"\x1e")
|
||||
return f"sha256:{h.hexdigest()[:16]}"
|
||||
|
||||
|
||||
def preflight(specs: list[task_catalog.TaskSpec]) -> None:
|
||||
"""Fetch scorer prerequisites before burning GPU time on a doomed run.
|
||||
|
||||
Scorers often touch their corpora only at scoring time, so a missing
|
||||
dependency surfaces after the whole dataset has been generated. Checking
|
||||
up front converts a 20-minute failure into a 2-second one.
|
||||
"""
|
||||
needed = {res for spec in specs for res in spec.nltk_resources}
|
||||
if not needed:
|
||||
return
|
||||
|
||||
import nltk
|
||||
|
||||
for resource in sorted(needed):
|
||||
try:
|
||||
nltk.data.find(resource)
|
||||
except LookupError:
|
||||
name = resource.rsplit("/", 1)[-1]
|
||||
print(f"[preflight] fetching nltk resource {name}")
|
||||
nltk.download(name, quiet=True)
|
||||
nltk.data.find(resource) # raise loudly if it still is not there
|
||||
|
||||
|
||||
# Fallback sampling for a target that declares none. Deliberately NOT greedy: temperature 0
|
||||
# looks like the reproducible choice and breaks reasoning models, which repeat forever
|
||||
# without it. Reproducibility comes from the fixed seed. A target should override this in
|
||||
# the registry with the values its model card documents.
|
||||
DEFAULT_SAMPLING = {
|
||||
"temperature": 0.6,
|
||||
"top_p": 0.95,
|
||||
"seed": 20260804,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
|
||||
def resolve_sampling(target: Target) -> dict[str, Any]:
|
||||
"""The sampling settings for a target, registry first, defaults second.
|
||||
|
||||
Returned rather than read inline so the score card records exactly what the run used:
|
||||
the two cannot drift, because they are the same call.
|
||||
"""
|
||||
return {**DEFAULT_SAMPLING, **(target.serving.sampling or {})}
|
||||
|
||||
|
||||
def run_quality(
|
||||
target: Target,
|
||||
specs: list[task_catalog.TaskSpec],
|
||||
limit: int | None = None,
|
||||
max_connections: int = 16,
|
||||
log_dir: Path | None = None,
|
||||
epochs: int = 1,
|
||||
) -> list[QualityResult]:
|
||||
"""Run quality tasks against a target via Inspect."""
|
||||
from inspect_ai import eval as inspect_eval
|
||||
|
||||
preflight(specs)
|
||||
target.apply_env()
|
||||
results: list[QualityResult] = []
|
||||
sampling = resolve_sampling(target)
|
||||
print(f" sampling: {sampling} | epochs: {epochs}")
|
||||
if epochs == 1:
|
||||
print(
|
||||
" NOTE: one epoch. Sampling is not reproducible on this server even with a\n"
|
||||
" fixed seed -- continuous batching changes the logits -- so a single\n"
|
||||
" epoch is one draw, not a rate. Use --epochs for a signal-tier number."
|
||||
)
|
||||
|
||||
for spec in specs:
|
||||
print(f"\n[quality] {spec.name} ({spec.tier}) -> {target.id}")
|
||||
logs = inspect_eval(
|
||||
tasks=spec.inspect_task,
|
||||
model=target.inspect_model,
|
||||
limit=limit,
|
||||
epochs=epochs,
|
||||
log_dir=str(log_dir or LOG_DIR),
|
||||
display="plain",
|
||||
# Greedy decoding. The perf harness has always pinned temperature 0; the
|
||||
# quality half pinned nothing and inherited the server's default, so it
|
||||
# sampled. Three runs over the same twelve samples scored 4, 5 and 7 of 11
|
||||
# -- 27% of samples flipped -- and a score card that cannot reproduce its
|
||||
# own number is not evidence, it is a draw from a distribution nobody
|
||||
# recorded. Sampling also makes `compare` meaningless: a per-sample flip
|
||||
# caused by temperature is indistinguishable from a real regression.
|
||||
#
|
||||
# These MUST go through config=GenerateConfig. inspect_eval takes **kwargs,
|
||||
# so passing temperature= directly is accepted silently and does nothing --
|
||||
# the run would have looked pinned and still sampled.
|
||||
# Loose kwargs ARE the interface: inspect_eval collects everything it does
|
||||
# not consume into a GenerateConfig itself, so `config=` is rejected as an
|
||||
# unknown field and `max_connections` must ride along here rather than as a
|
||||
# sibling argument. Verified by running it, not by reading the signature --
|
||||
# these names do not appear in it.
|
||||
max_connections=max_connections,
|
||||
**sampling,
|
||||
)
|
||||
for log in logs:
|
||||
qr = _quality_from_log(log, spec)
|
||||
# Record the cap explicitly. A limited run that looks like a full
|
||||
# run is the single easiest way to publish a misleading number.
|
||||
if limit is not None:
|
||||
qr.metrics["_limit"] = float(limit)
|
||||
results.append(qr)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_perf(
|
||||
target: Target,
|
||||
concurrencies: tuple[int, ...] = (1, 8, 32),
|
||||
input_tokens: int = 1024,
|
||||
output_tokens: int = 256,
|
||||
prompts: list[str] | None = None,
|
||||
pin_output: bool = True,
|
||||
) -> PerfResult:
|
||||
"""Run a serving performance sweep against a target."""
|
||||
base_url = target.serving.base_url
|
||||
if not base_url:
|
||||
raise ValueError(f"target {target.id} has no base_url; cannot run perf sweep")
|
||||
|
||||
print(f"\n[perf] {target.id} @ {base_url}")
|
||||
|
||||
# A sweep past the server's own concurrency ceiling measures the queue, not the server.
|
||||
# vLLM admits max_num_seqs sequences per step and queues the rest, so those points show
|
||||
# flat total throughput and inflating TTFT — which reads as saturation and is really just
|
||||
# waiting. your-node runs max_num_seqs=4 against a default sweep of 1,8,32, so two of three
|
||||
# points were destined to be misread. Warn rather than refuse: measuring the queue is a
|
||||
# legitimate thing to want, as long as nobody mistakes it for the engine's limit.
|
||||
max_seqs = (target.serving.flags or {}).get("max_num_seqs")
|
||||
if isinstance(max_seqs, int):
|
||||
beyond = [c for c in concurrencies if c > max_seqs]
|
||||
if beyond:
|
||||
print(
|
||||
f" WARNING: max_num_seqs={max_seqs}, so concurrency {beyond} exceeds what "
|
||||
f"the server admits per step.\n"
|
||||
f" Those points measure queueing, not serving capacity. "
|
||||
f"Consider --concurrency 1,{max(2, max_seqs // 2)},{max_seqs}."
|
||||
)
|
||||
|
||||
mode = []
|
||||
if prompts:
|
||||
mode.append(f"{len(prompts)} representative prompts")
|
||||
if not pin_output:
|
||||
mode.append("natural stop (output length not pinned)")
|
||||
print(f" input={input_tokens} output={output_tokens} tokens"
|
||||
+ (f" [{'; '.join(mode)}]" if mode else ""))
|
||||
return asyncio.run(
|
||||
run_sweep(
|
||||
base_url=base_url,
|
||||
model=target.serving.model_name,
|
||||
concurrencies=concurrencies,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
engine=target.serving.engine,
|
||||
prompts=prompts,
|
||||
pin_output=pin_output,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _epochs_of(quality: list[QualityResult]) -> int:
|
||||
"""How many times each sample was actually run, read back from the outcomes.
|
||||
|
||||
Taken from the results rather than the argument so the card cannot claim an averaging
|
||||
it did not do -- the same reason resolve_sampling feeds the run and the card from one
|
||||
call.
|
||||
"""
|
||||
per_sample: dict[str, int] = {}
|
||||
for q in quality:
|
||||
for s in q.samples:
|
||||
per_sample[s.sample_id] = per_sample.get(s.sample_id, 0) + 1
|
||||
return max(per_sample.values()) if per_sample else 1
|
||||
|
||||
|
||||
def build_run(
|
||||
registry: Registry,
|
||||
target: Target,
|
||||
quality: list[QualityResult],
|
||||
perf: PerfResult | None,
|
||||
) -> Run:
|
||||
host = registry.host(target.host)
|
||||
target_snapshot = asdict(target)
|
||||
target_snapshot["slug"] = target.slug
|
||||
target_snapshot["inspect_model"] = target.inspect_model
|
||||
|
||||
return Run(
|
||||
run_id=uuid.uuid4().hex,
|
||||
timestamp=utc_now(),
|
||||
target_id=target.id,
|
||||
target=target_snapshot,
|
||||
host=asdict(host),
|
||||
quality=quality,
|
||||
perf=perf,
|
||||
verdict=compute_verdict(quality, perf),
|
||||
runner=runner_info(),
|
||||
sampling={**resolve_sampling(target), "epochs": _epochs_of(quality)},
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The sample schema and its validation. Standard library only, on purpose.
|
||||
|
||||
This used to live in `kbench/tasks/signal.py`, which imports `inspect_ai` at module scope —
|
||||
so checking whether a JSONL record was well-formed required the whole eval framework to be
|
||||
installed. That is backwards: the schema is ours and the runner is swappable, so the thing
|
||||
that defines what a sample *is* must not depend on the thing that happens to execute it.
|
||||
|
||||
Practically it means authoring tools, CI checks and editors can validate data without
|
||||
resolving a heavy dependency tree, and that swapping the execution backend later touches
|
||||
`tasks/`, not this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
VALID_SPLITS = {"train", "dev", "test"}
|
||||
VALID_SCORERS = {"exact", "includes", "regex", "rubric"}
|
||||
|
||||
|
||||
def family_path(family: str, tier: str = "private") -> Path:
|
||||
return DATA_DIR / tier / f"{family}.jsonl"
|
||||
|
||||
|
||||
def validate_record(rec: dict[str, Any], where: str) -> None:
|
||||
"""Fail loudly on a malformed sample.
|
||||
|
||||
A silently-skipped sample shrinks the eval set without changing the score's appearance,
|
||||
which is the worst possible failure mode for a benchmark.
|
||||
"""
|
||||
for field in ("id", "input", "split", "scorer"):
|
||||
if not rec.get(field):
|
||||
raise ValueError(f"{where}: missing required field {field!r}")
|
||||
if rec["split"] not in VALID_SPLITS:
|
||||
raise ValueError(f"{where}: split must be one of {sorted(VALID_SPLITS)}")
|
||||
if rec["scorer"] not in VALID_SCORERS:
|
||||
raise ValueError(f"{where}: scorer must be one of {sorted(VALID_SCORERS)}")
|
||||
if rec["scorer"] != "rubric" and not rec.get("target"):
|
||||
raise ValueError(f"{where}: scorer {rec['scorer']!r} requires a target")
|
||||
if rec["scorer"] == "rubric" and not (rec.get("rubric") or rec.get("target")):
|
||||
raise ValueError(f"{where}: rubric scorer requires a rubric or a target")
|
||||
|
||||
|
||||
def read_records(path: Path) -> list[tuple[int, dict[str, Any]]]:
|
||||
"""Parse and validate a family file. Returns (line number, record) pairs.
|
||||
|
||||
Line numbers are carried so an error names the line a human has to open.
|
||||
"""
|
||||
import json
|
||||
|
||||
out: list[tuple[int, dict[str, Any]]] = []
|
||||
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//"):
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
validate_record(rec, f"{path.name}:{lineno}")
|
||||
out.append((lineno, rec))
|
||||
return out
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Scorers for signal-tier tasks.
|
||||
|
||||
Inspect attaches scorers to a Task, not to a sample. Our families are mixed --
|
||||
one `agent_ops` file legitimately contains samples graded by exact match, by
|
||||
substring, and by rubric. Splitting a family into one Task per scorer would
|
||||
fragment the results and make family-level scores meaningless.
|
||||
|
||||
So we dispatch per sample instead, reading `scorer` from sample metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from inspect_ai.scorer import (
|
||||
CORRECT,
|
||||
INCORRECT,
|
||||
Score,
|
||||
Scorer,
|
||||
Target,
|
||||
accuracy,
|
||||
model_graded_qa,
|
||||
scorer,
|
||||
stderr,
|
||||
)
|
||||
from inspect_ai.solver import TaskState
|
||||
|
||||
DEFAULT_RUBRIC = """You are grading a model's answer against a reference.
|
||||
|
||||
Grade CORRECT if the answer is substantively equivalent to the reference --
|
||||
matching facts, decisions, and any required steps. Ignore differences in
|
||||
wording, formatting, or verbosity.
|
||||
|
||||
Grade INCORRECT if it contradicts the reference, omits something the reference
|
||||
treats as required, or adds a claim the reference does not support.
|
||||
|
||||
Be strict. A partially correct answer is INCORRECT."""
|
||||
|
||||
|
||||
def _text(state: TaskState) -> str:
|
||||
return (state.output.completion or "").strip()
|
||||
|
||||
|
||||
@scorer(metrics=[accuracy(), stderr()])
|
||||
def dispatching(judge_model: str | None = None) -> Scorer:
|
||||
"""Grade each sample by the method named in its own metadata.
|
||||
|
||||
Falls back to `includes` when unspecified, which is the most forgiving
|
||||
deterministic option -- a sample that silently scores zero because of a
|
||||
missing metadata field would look like a model failure.
|
||||
"""
|
||||
graded = model_graded_qa(instructions=DEFAULT_RUBRIC, model=judge_model)
|
||||
|
||||
async def score(state: TaskState, target: Target) -> Score:
|
||||
method = (state.metadata or {}).get("scorer") or "includes"
|
||||
answer = _text(state)
|
||||
expected = target.text or ""
|
||||
|
||||
if method == "rubric":
|
||||
rubric = (state.metadata or {}).get("rubric")
|
||||
if rubric:
|
||||
custom = model_graded_qa(instructions=rubric, model=judge_model)
|
||||
return await custom(state, target)
|
||||
return await graded(state, target)
|
||||
|
||||
if method == "exact":
|
||||
ok = answer == expected.strip()
|
||||
elif method == "regex":
|
||||
try:
|
||||
ok = re.search(expected, answer, re.IGNORECASE | re.DOTALL) is not None
|
||||
except re.error as exc:
|
||||
return Score(
|
||||
value=INCORRECT,
|
||||
answer=answer,
|
||||
explanation=f"invalid regex in sample target: {exc}",
|
||||
)
|
||||
elif method == "includes":
|
||||
ok = expected.strip().lower() in answer.lower()
|
||||
else:
|
||||
return Score(
|
||||
value=INCORRECT,
|
||||
answer=answer,
|
||||
explanation=f"unknown scorer {method!r} -- fix the sample, not the model",
|
||||
)
|
||||
|
||||
return Score(
|
||||
value=CORRECT if ok else INCORRECT,
|
||||
answer=answer,
|
||||
explanation=f"scorer={method}",
|
||||
)
|
||||
|
||||
return score
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_RUBRIC", "dispatching"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Speculative-decoding metrics, read from the engine rather than inferred.
|
||||
|
||||
Throughput alone cannot tell you whether speculative decoding is working. A draft model that
|
||||
is rejected almost every time still produces tokens — just slowly, having paid for the draft
|
||||
pass as well as the verify pass — and on a synthetic benchmark that can even look like a
|
||||
modest win. The number that says whether it is working is the **acceptance rate**: of the
|
||||
tokens the draft proposed, how many survived verification.
|
||||
|
||||
vLLM exposes this on its Prometheus endpoint as counters, so a rate for one sweep point is a
|
||||
delta across that point rather than the process-lifetime total. Reading the total instead would
|
||||
mix in every request since the server started, including warm-up.
|
||||
|
||||
Parsing is pure standard library and `httpx` is only needed to fetch — the import is deferred
|
||||
so the parser can be tested, and reasoned about, without the HTTP client installed. Same rule as
|
||||
`kbench/schema.py`: the logic that decides what a number means must not depend on the machinery
|
||||
that fetches it.
|
||||
|
||||
This is deliberately best-effort. An engine without these counters, or with metrics disabled,
|
||||
gets `None` and the sweep continues — an absent metric must never fail a benchmark run.
|
||||
|
||||
Why it matters for this bench specifically: `perf.py` sends a random nonce prefix and
|
||||
`ignore_eos`, both correct for defeating prefix caching and pinning output length, and both
|
||||
adversarial to speculative decoding. Forced continuation past a natural stop is degenerate
|
||||
text, and a draft model's acceptance on degenerate text is not its acceptance on real work. So
|
||||
a speculative target measured with the synthetic prompt needs its acceptance rate reported
|
||||
beside the throughput, or the throughput will be read as if it transfers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import httpx
|
||||
|
||||
# vLLM V1 names. The older V0 engine used vllm:spec_decode_* with the same meaning; both are
|
||||
# accepted so a mixed fleet does not silently report nothing.
|
||||
ACCEPTED = ("vllm:spec_decode_num_accepted_tokens_total",)
|
||||
DRAFT = ("vllm:spec_decode_num_draft_tokens_total",)
|
||||
|
||||
_SAMPLE = re.compile(r"^(?P<name>[a-zA-Z_:][\w:]*)(?P<labels>\{[^}]*\})?\s+(?P<value>[-+0-9.eE]+)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecCounters:
|
||||
accepted: float
|
||||
draft: float
|
||||
|
||||
def __sub__(self, other: "SpecCounters") -> "SpecCounters":
|
||||
return SpecCounters(self.accepted - other.accepted, self.draft - other.draft)
|
||||
|
||||
@property
|
||||
def acceptance_rate(self) -> float | None:
|
||||
"""Accepted / proposed over this window. None when nothing was proposed."""
|
||||
if self.draft <= 0:
|
||||
return None
|
||||
return round(self.accepted / self.draft, 4)
|
||||
|
||||
|
||||
def parse_metrics(text: str) -> SpecCounters | None:
|
||||
"""Pull the speculative counters out of a Prometheus exposition payload."""
|
||||
totals: dict[str, float] = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _SAMPLE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
name = m.group("name")
|
||||
if name in ACCEPTED or name in DRAFT:
|
||||
try:
|
||||
# Counters are per-label-set (one per model); sum them. A single-model server
|
||||
# has one series, and summing is still correct there.
|
||||
totals[name] = totals.get(name, 0.0) + float(m.group("value"))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
accepted = next((totals[n] for n in ACCEPTED if n in totals), None)
|
||||
draft = next((totals[n] for n in DRAFT if n in totals), None)
|
||||
if accepted is None or draft is None:
|
||||
return None
|
||||
return SpecCounters(accepted=accepted, draft=draft)
|
||||
|
||||
|
||||
def metrics_url(base_url: str) -> str:
|
||||
"""/metrics sits at the server root, not under the OpenAI /v1 prefix."""
|
||||
root = base_url.rstrip("/")
|
||||
for suffix in ("/v1", "/openai/v1"):
|
||||
if root.endswith(suffix):
|
||||
root = root[: -len(suffix)]
|
||||
break
|
||||
return f"{root}/metrics"
|
||||
|
||||
|
||||
async def read_counters(client: "httpx.AsyncClient", base_url: str) -> SpecCounters | None:
|
||||
"""Best-effort read. Any failure means "no data", never an exception into the sweep."""
|
||||
try:
|
||||
resp = await client.get(metrics_url(base_url), timeout=5.0)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return parse_metrics(resp.text)
|
||||
except Exception: # noqa: BLE001 - a missing metrics endpoint is an ordinary outcome
|
||||
return None
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Sweep a matrix of targets and reduce it to a decision.
|
||||
|
||||
`run` answers "what did this target do". `compare` answers "what changed between two". Neither
|
||||
answers the question people actually start with, which is **which of these should I run**, and
|
||||
that one is a matrix: the same model at three quantisations, or one quantisation under four sets
|
||||
of serving flags, all on the box you actually own.
|
||||
|
||||
Three things make this more than a shell loop:
|
||||
|
||||
**It holds the box constant.** A sweep varies configuration and nothing else. Varying the host
|
||||
too produces numbers with two causes, and the tempting reading — "fp8 is faster" — is then
|
||||
unsupported by the data that appears to show it.
|
||||
|
||||
**It runs sequentially, and that is not a simplification.** On a unified-memory machine two
|
||||
targets cannot both be resident; that constraint is the entire premise of the stack this bench
|
||||
was built for. Running them concurrently would measure contention between them rather than
|
||||
either one.
|
||||
|
||||
**It reduces to a frontier, not a leaderboard.** Self-hosting is a trade: quality against
|
||||
throughput, under a memory ceiling. A single ranking hides that, so the output is the set of
|
||||
targets that are not beaten on both axes at once — plus, explicitly, the ones that are, because
|
||||
a target that is worse at everything is the one useful thing a matrix can tell you.
|
||||
|
||||
Every point is an ordinary score card saved to `results/`. There is no sweep-shaped result
|
||||
format, so `compare`, `card` and the git history all keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepPoint:
|
||||
"""One target's outcome within a sweep."""
|
||||
|
||||
target_id: str
|
||||
quality: float | None
|
||||
throughput_tps: float | None
|
||||
single_stream_tps: float | None = None
|
||||
contamination: str | None = None
|
||||
error: str | None = None
|
||||
run_path: str | None = None
|
||||
|
||||
@property
|
||||
def usable(self) -> bool:
|
||||
"""Whether this point can take part in the trade-off at all."""
|
||||
return self.error is None and self.throughput_tps is not None
|
||||
|
||||
@property
|
||||
def quality_comparable(self) -> bool:
|
||||
"""A contaminated or unmeasured quality score cannot be traded against anything."""
|
||||
return self.quality is not None and self.contamination != "detected"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepResult:
|
||||
points: list[SweepPoint] = field(default_factory=list)
|
||||
best: list[str] = field(default_factory=list)
|
||||
dominated: dict[str, str] = field(default_factory=dict) # target -> what beats it
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def failed(self) -> list[SweepPoint]:
|
||||
return [p for p in self.points if p.error]
|
||||
|
||||
|
||||
def _dominates(a: SweepPoint, b: SweepPoint, use_quality: bool) -> bool:
|
||||
"""True when `a` is at least as good as `b` everywhere and better somewhere.
|
||||
|
||||
Strict domination is the only claim worth making from a small matrix. "Higher throughput
|
||||
and slightly lower quality" is a trade the operator has to make with knowledge this tool
|
||||
does not have — how much quality their workload can spare — so it is deliberately left to
|
||||
them rather than collapsed into a score.
|
||||
"""
|
||||
if not (a.usable and b.usable):
|
||||
return False
|
||||
better_anywhere = a.throughput_tps > b.throughput_tps
|
||||
at_least_equal = a.throughput_tps >= b.throughput_tps
|
||||
if use_quality and a.quality_comparable and b.quality_comparable:
|
||||
at_least_equal = at_least_equal and a.quality >= b.quality
|
||||
better_anywhere = better_anywhere or a.quality > b.quality
|
||||
return at_least_equal and better_anywhere
|
||||
|
||||
|
||||
def analyse(points: list[SweepPoint], hosts: list[str] | None = None) -> SweepResult:
|
||||
"""Reduce a set of measured targets to a frontier and a set of dominated options."""
|
||||
result = SweepResult(points=list(points))
|
||||
|
||||
if hosts and len(set(hosts)) > 1:
|
||||
result.warnings.append(
|
||||
f"Targets ran on more than one host ({', '.join(sorted(set(hosts)))}). A sweep is "
|
||||
"meant to hold the machine constant — these differences have two causes and "
|
||||
"neither can be isolated."
|
||||
)
|
||||
|
||||
usable = [p for p in points if p.usable]
|
||||
if not usable:
|
||||
result.warnings.append("No target produced a usable measurement.")
|
||||
return result
|
||||
|
||||
with_quality = [p for p in usable if p.quality_comparable]
|
||||
use_quality = len(with_quality) == len(usable)
|
||||
if not use_quality:
|
||||
missing = sorted(p.target_id for p in usable if not p.quality_comparable)
|
||||
result.warnings.append(
|
||||
f"Ranking on throughput alone: {', '.join(missing)} has no comparable quality score "
|
||||
"(never measured, or voided by a contamination probe)."
|
||||
)
|
||||
|
||||
for candidate in usable:
|
||||
beaten_by = next(
|
||||
(other.target_id for other in usable
|
||||
if other.target_id != candidate.target_id and _dominates(other, candidate, use_quality)),
|
||||
None,
|
||||
)
|
||||
if beaten_by:
|
||||
result.dominated[candidate.target_id] = beaten_by
|
||||
else:
|
||||
result.best.append(candidate.target_id)
|
||||
|
||||
# Present the frontier the way the decision is made: fastest first, quality breaking ties.
|
||||
order = {p.target_id: p for p in usable}
|
||||
result.best.sort(
|
||||
key=lambda t: (
|
||||
-(order[t].throughput_tps or 0),
|
||||
-(order[t].quality if order[t].quality_comparable else 0),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def point_from_run(run: dict[str, Any], path: str | None = None) -> SweepPoint:
|
||||
"""Read a saved score card into a sweep point.
|
||||
|
||||
Reads `signal_score` rather than any raw value: a contaminated run has had that nulled by
|
||||
compute_verdict, which is exactly the behaviour a sweep should inherit — a memorised score
|
||||
must not win a frontier.
|
||||
"""
|
||||
verdict = run.get("verdict") or {}
|
||||
return SweepPoint(
|
||||
target_id=run.get("target_id", "?"),
|
||||
quality=verdict.get("signal_score"),
|
||||
throughput_tps=verdict.get("peak_throughput_tps"),
|
||||
single_stream_tps=verdict.get("single_stream_tps"),
|
||||
contamination=verdict.get("contamination"),
|
||||
run_path=path,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Task catalog.
|
||||
|
||||
Two tiers, and the distinction is not cosmetic:
|
||||
|
||||
REFERENCE -- public benchmarks pulled from inspect_evals. These are NOT here to
|
||||
rank models. They exist to (a) prove the harness is wired correctly, (b) give
|
||||
a calibration anchor -- if our number lands far from published values, the
|
||||
harness is broken, not the model, and (c) let an outside reader locate this
|
||||
bench against numbers they already know. A bench made only of private tasks
|
||||
is unfalsifiable to everyone including its author. Reference scores are
|
||||
displayed with a caveat: public, likely contaminated, calibration only.
|
||||
|
||||
SIGNAL -- our own private tasks, derived from real workloads. This is the
|
||||
actual product. These never become public: the moment they do they enter the
|
||||
next training corpus and stop measuring anything. See docs/DECISIONS.md#d3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskSpec:
|
||||
"""One benchmark task and how to read its numbers."""
|
||||
|
||||
name: str
|
||||
inspect_task: str
|
||||
tier: str # "reference" | "signal" | "canary" | "example"
|
||||
# Which key in the scorer's metrics is the headline accuracy.
|
||||
primary_metric: str
|
||||
# Sample scores may be dict-valued (IFEval) rather than scalar. If so, this
|
||||
# names the key that decides pass/fail for a single sample.
|
||||
primary_sample_key: str | None = None
|
||||
dataset_samples: int | None = None
|
||||
judge_required: bool = False
|
||||
# NLTK corpora the scorer needs. Checked (and fetched) before the eval
|
||||
# starts: IFEval's scorer needs punkt_tab but only touches it at scoring
|
||||
# time, so a missing corpus kills the run ~20 minutes of GPU time in.
|
||||
# Preflight turns that into a two-second failure.
|
||||
nltk_resources: tuple[str, ...] = ()
|
||||
notes: str = ""
|
||||
|
||||
|
||||
CATALOG: dict[str, TaskSpec] = {
|
||||
"ifeval": TaskSpec(
|
||||
name="ifeval",
|
||||
inspect_task="inspect_evals/ifeval",
|
||||
tier="reference",
|
||||
primary_metric="final_acc",
|
||||
primary_sample_key="prompt_level_strict",
|
||||
dataset_samples=541,
|
||||
judge_required=False,
|
||||
nltk_resources=("tokenizers/punkt_tab",),
|
||||
notes=(
|
||||
"Verifiable instruction following -- 'write exactly 3 paragraphs', "
|
||||
"'do not use the letter e'. Chosen as the first reference eval "
|
||||
"because its scorer is deterministic (no judge model, no cost, no "
|
||||
"judge drift between runs) and because instruction adherence is "
|
||||
"the property that actually decides whether a small local model "
|
||||
"can hold a system prompt in production."
|
||||
),
|
||||
),
|
||||
"agent_ops": TaskSpec(
|
||||
name="agent_ops",
|
||||
inspect_task="kbench/tasks/signal.py@agent_ops",
|
||||
tier="signal",
|
||||
primary_metric="accuracy",
|
||||
dataset_samples=12,
|
||||
# One rubric sample in twelve, so a judge is needed but the drift it
|
||||
# introduces is bounded to a twelfth of the score.
|
||||
judge_required=True,
|
||||
notes=(
|
||||
"Operating a Lumbridge Compute node: admission arithmetic against "
|
||||
"both the declared budget and the observed pool, watchdog policy, "
|
||||
"why a Scene naming ids rather than commands cannot introduce "
|
||||
"code, transactional rollback, and process identity under PID "
|
||||
"reuse. Derived from the decisions the MCP server exists to let an "
|
||||
"agent make, which is what makes it unfakeable by a general "
|
||||
"benchmark -- nobody else measures competence at running this."
|
||||
),
|
||||
),
|
||||
"contamination": TaskSpec(
|
||||
name="contamination",
|
||||
inspect_task="kbench/tasks/canary.py@contamination",
|
||||
# Its own tier because its score is an alarm, not a capability, and
|
||||
# compute_verdict() reads it to decide whether the signal score is
|
||||
# usable at all. Registering it is load-bearing: an unregistered probe
|
||||
# never runs, so the gate reports "unverified" forever -- which is the
|
||||
# same shape of bug as the canary that was never embedded in a prompt.
|
||||
tier="canary",
|
||||
primary_metric="accuracy",
|
||||
dataset_samples=3,
|
||||
judge_required=False,
|
||||
notes=(
|
||||
"Contamination probe. One carrier puts the GUID into the inference "
|
||||
"traffic; two detectors ask for it cold. Scoring is inverted -- 0 "
|
||||
"is the healthy result, and anything above 0 voids every quality "
|
||||
"number for the target. See data/README.md."
|
||||
),
|
||||
),
|
||||
"example": TaskSpec(
|
||||
name="example",
|
||||
inspect_task="kbench/tasks/signal.py@example",
|
||||
# Tier "example" deliberately: it exercises the signal machinery but
|
||||
# measures nothing, so it must not contribute to the signal score in
|
||||
# compute_verdict(). Real families are registered with tier="signal".
|
||||
tier="example",
|
||||
primary_metric="accuracy",
|
||||
dataset_samples=4,
|
||||
judge_required=True,
|
||||
notes=(
|
||||
"Public demonstration of the signal-task format -- one sample per "
|
||||
"scorer type. Lives in data/public/. Real signal families live in "
|
||||
"data/private/ and never ship."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get(name: str) -> TaskSpec:
|
||||
if name not in CATALOG:
|
||||
known = ", ".join(sorted(CATALOG))
|
||||
raise KeyError(f"unknown task {name!r}. known tasks: {known}")
|
||||
return CATALOG[name]
|
||||
|
||||
|
||||
def by_tier(tier: str) -> list[TaskSpec]:
|
||||
return [t for t in CATALOG.values() if t.tier == tier]
|
||||
|
||||
|
||||
__all__ = ["CATALOG", "TaskSpec", "by_tier", "get"]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Contamination probes.
|
||||
|
||||
D3 specifies `data/canary/` as "GUID strings embedded in samples". The tier existed and was
|
||||
empty, and nothing loaded it — while `kbench add` stamped a `canary` GUID into every signal
|
||||
record that was never embedded in the prompt, never sent to a model, and never checked. A
|
||||
canary only works if it is *in the text that could be trained on*, so as written the control
|
||||
could not fire.
|
||||
|
||||
**Scoring here is inverted, and that is the whole point.**
|
||||
|
||||
A canary probe contains a GUID that exists nowhere else and asks the model to reproduce it.
|
||||
A model that has never seen this repo's data cannot possibly answer, so:
|
||||
|
||||
score 0.0 = clean. The expected, healthy result.
|
||||
score > 0 = this model has seen our eval data. Every signal number for it is void.
|
||||
|
||||
Read `canary_score` as a contamination alarm, never as a capability. It is reported separately
|
||||
from `signal_score` for exactly that reason.
|
||||
|
||||
**What this actually detects.** The private set never leaves the repo, so the realistic leak
|
||||
vector is not a scraper — it is the authoring rules' own admission: prompts get sent to
|
||||
third-party judge and baseline models regardless of how private the repo is. If a provider
|
||||
trains on inference traffic, these GUIDs go with it. That is the leak this catches, and it is
|
||||
the one that would otherwise be invisible.
|
||||
|
||||
A probe firing does not tell you *which* provider learned it. It tells you the set is burned
|
||||
and needs regenerating, which is the actionable part.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect_ai import Task, task
|
||||
from inspect_ai.dataset import MemoryDataset
|
||||
from inspect_ai.solver import generate
|
||||
|
||||
# Absolute for the same reason as signal.py: inspect-ai loads this file by path, outside
|
||||
# the package, so relative imports raise "beyond top-level package".
|
||||
from kbench.scorers import dispatching
|
||||
from kbench.tasks.signal import load_samples
|
||||
|
||||
CANARY_TIER = "canary"
|
||||
|
||||
|
||||
def canary_task(family: str = "contamination") -> Task:
|
||||
"""Build the contamination probe task.
|
||||
|
||||
Probes are `split: test` like everything else — they are not training data and must never
|
||||
be filtered out by the split discipline that protects the signal set.
|
||||
"""
|
||||
samples = load_samples(family, tier=CANARY_TIER, splits=("test",))
|
||||
if not samples:
|
||||
raise ValueError(
|
||||
f"canary family {family!r} is empty. A bench with no contamination probe cannot "
|
||||
"tell a real score from a memorised one."
|
||||
)
|
||||
|
||||
# Carriers must be loaded and then NOT scored. The carrier states the GUID in its own
|
||||
# prompt and asks for it back, so every model repeats it — that is instruction-following,
|
||||
# not memorisation. Scoring it pinned the canary at >= 1/n for every target alive, which
|
||||
# reads as CONTAMINATED and nulls signal_score, voiding the whole quality half.
|
||||
#
|
||||
# The data already carried `tags: [contamination, carrier]` and a note saying "it always
|
||||
# passes"; nothing read it. Hence the assertions below: this file now fails loudly if the
|
||||
# split it depends on is missing, rather than silently scoring the wrong set.
|
||||
carriers = [s for s in samples if "carrier" in (s.metadata or {}).get("tags", [])]
|
||||
detectors = [s for s in samples if "detector" in (s.metadata or {}).get("tags", [])]
|
||||
|
||||
unlabelled = [s for s in samples if s not in carriers and s not in detectors]
|
||||
if unlabelled:
|
||||
raise ValueError(
|
||||
f"canary family {family!r} has samples tagged neither 'carrier' nor 'detector': "
|
||||
f"{[s.id for s in unlabelled]}. Every probe must declare its role, because the "
|
||||
"two are scored differently."
|
||||
)
|
||||
if not carriers:
|
||||
raise ValueError(
|
||||
f"canary family {family!r} has no carrier. Without one the GUID never enters any "
|
||||
"corpus, so the detectors are unanswerable by construction and would report "
|
||||
"'clean' against a model that is in fact contaminated."
|
||||
)
|
||||
if not detectors:
|
||||
raise ValueError(
|
||||
f"canary family {family!r} has no detector. The carrier alone detects nothing."
|
||||
)
|
||||
|
||||
return Task(
|
||||
name=f"canary/{family}",
|
||||
dataset=MemoryDataset(samples=detectors, name=f"canary-{family}"),
|
||||
solver=generate(),
|
||||
scorer=dispatching(),
|
||||
)
|
||||
|
||||
|
||||
def interpret(score: float | None) -> str:
|
||||
"""Turn a canary score into the sentence a reader needs."""
|
||||
if score is None:
|
||||
return "no contamination probe was run — signal scores are unverified"
|
||||
if score <= 0.0:
|
||||
return "clean: the model could not reproduce any probe GUID"
|
||||
return (
|
||||
f"CONTAMINATED: the model reproduced {score:.0%} of the probe GUIDs. "
|
||||
"Signal scores for this target are void; regenerate the eval set."
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def contamination() -> Task:
|
||||
"""Registered so `kbench` can run the probe like any other task."""
|
||||
return canary_task()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Signal-tier tasks: our own private evals, loaded from JSONL.
|
||||
|
||||
One family (`agent_ops`, `repo_edit`, ...) becomes one Inspect Task. Samples
|
||||
within a family may be graded differently; `kbench.scorers.dispatching` handles
|
||||
that per sample.
|
||||
|
||||
Split discipline is enforced here rather than left to convention: by default
|
||||
only `test` samples are evaluated. If a `train` sample ever reaches the eval
|
||||
path, fine-tuning on it silently invalidates every number that follows and
|
||||
there is no way to detect it after the fact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from inspect_ai import Task, task
|
||||
from inspect_ai.dataset import MemoryDataset, Sample
|
||||
from inspect_ai.solver import generate
|
||||
|
||||
# Absolute, not relative. inspect-ai loads a task file by path as a standalone module, so
|
||||
# it is not a member of the `kbench` package at load time and `from ..scorers` raises
|
||||
# "attempted relative import beyond top-level package". kbench is an installed package, so
|
||||
# the absolute form resolves under both import styles.
|
||||
from kbench.scorers import dispatching
|
||||
|
||||
# The schema lives in kbench/schema.py, which imports nothing beyond the standard library —
|
||||
# validating a JSONL record must not require the eval framework. Re-exported here so existing
|
||||
# callers of signal.family_path / signal.validate_record keep working.
|
||||
from kbench.schema import ( # noqa: F401
|
||||
DATA_DIR,
|
||||
VALID_SCORERS,
|
||||
VALID_SPLITS,
|
||||
family_path,
|
||||
validate_record,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def load_samples(
|
||||
family: str,
|
||||
tier: str = "private",
|
||||
splits: tuple[str, ...] = ("test",),
|
||||
) -> list[Sample]:
|
||||
path = family_path(family, tier)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"no data file for family {family!r} at {path}. "
|
||||
f"create samples with: kbench add {family}"
|
||||
)
|
||||
|
||||
samples: list[Sample] = []
|
||||
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//"):
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
validate_record(rec, f"{path.name}:{lineno}")
|
||||
if rec["split"] not in splits:
|
||||
continue
|
||||
samples.append(
|
||||
Sample(
|
||||
id=rec["id"],
|
||||
input=rec["input"],
|
||||
target=rec.get("target") or "",
|
||||
metadata={
|
||||
"scorer": rec["scorer"],
|
||||
"rubric": rec.get("rubric"),
|
||||
"family": rec.get("family", family),
|
||||
"split": rec["split"],
|
||||
**(rec.get("metadata") or {}),
|
||||
},
|
||||
)
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
def signal_task(
|
||||
family: str,
|
||||
tier: str = "private",
|
||||
splits: tuple[str, ...] = ("test",),
|
||||
judge_model: str | None = None,
|
||||
) -> Task:
|
||||
"""Build an Inspect Task for one signal family."""
|
||||
samples = load_samples(family, tier=tier, splits=splits)
|
||||
if not samples:
|
||||
raise ValueError(
|
||||
f"family {family!r} has no samples in splits {splits}. "
|
||||
f"refusing to run an empty eval -- it would report a score of 0/0."
|
||||
)
|
||||
return Task(
|
||||
name=f"signal/{family}",
|
||||
dataset=MemoryDataset(samples=samples, name=family),
|
||||
solver=generate(),
|
||||
scorer=dispatching(judge_model=judge_model),
|
||||
)
|
||||
|
||||
|
||||
# --- registered example task -------------------------------------------------
|
||||
# Demonstrates the machinery against the shipped public examples. Real families
|
||||
# live in data/private/ and are registered the same way.
|
||||
|
||||
|
||||
@task
|
||||
def example() -> Task:
|
||||
"""Public example family -- shows the format, measures nothing important."""
|
||||
return signal_task("example", tier="public", splits=("test",))
|
||||
|
||||
|
||||
@task
|
||||
def agent_ops() -> Task:
|
||||
"""Operating a Compute node: admission, watchdog policy, Scene semantics, process identity.
|
||||
|
||||
Only the `test` split runs, which is the split discipline this module enforces rather than
|
||||
documents: a `train` sample reaching the eval path silently invalidates every number after
|
||||
it, and there is no way to detect that afterwards.
|
||||
"""
|
||||
return signal_task("agent_ops", tier="private", splits=("test",))
|
||||
Reference in New Issue
Block a user