arborist/bench/cross_model_selfplay.py
russell@unturf.com d3b78025ff
bench: teacher-model-judge end-to-end — cross-judge with chat_template_kwargs reasoning disable
New bench/teacher_judge.py reads judge_input.jsonl, cross-judges
each (question, model, answer) using a DIFFERENT model than the
answerer (default: hermes-answers → qwen judges, qwen-answers →
hermes judges). Writes verdicts.jsonl + markdown summary including
a false-STRICT highlight section.

Key implementation details:
- Reuses _judge_prompt from cross_model_selfplay so iterating on
  the prompt template doesn't require re-running benches.
- Sends chat_template_kwargs:{enable_thinking:false} on every call
  — required for qwen.ai.unturf.com (llama.cpp deepseek-reasoning
  format) where reasoning eats max_tokens before producing content.
  vLLM (hermes) silently ignores the unknown kwarg.
- Prompt template tightened: previously said "Reply in format
  VERDICT: <rationale>" which qwen took literally, replying with
  the word VERDICT instead of the verdict token. Now explicit:
  "write the chosen verdict word itself".

Live cross-judge result on 151-row 76-question bench:
  hermes (judged by qwen): 44 CORRECT, 23 WRONG, 9 PARTIAL → 58% accuracy
  qwen (judged by hermes): 45 CORRECT, 6 WRONG, 11 PARTIAL → 73% accuracy
Qwen is materially more accurate despite costing 1.8× more — matches
fox's "qwen slightly outperforms" intuition with hard numbers.

8 false-STRICTs surfaced including Q12 (hermes conflated Roman
Empire with HRE), Q48 (hermes answered with Niger River info on a
Nile question), and Q72 (both models missed the Game of Thrones
reference in "winter is coming").
2026-05-31 12:43:18 -04:00

520 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cross-model self-play bench — same question, multiple models, compare.
The pattern: the uncloseai-cli harness can call arborist twice per
question with different models to get two opinions. This script bakes
that pattern in as a benchmark. For each question in a fixture, run
arborist once per configured model, then tabulate:
* audit_mode per model (EVIDENCE-WARRANTED → ANCHOR-WARRANTED →
POINTER-LINKED → UNGROUNDED; HYBRID variants flatten to base)
* agreement on primary source URI
* grounding rate per model
* implied spend in fox's natural unit: $/1000 grounded answers
(Hermes $0.09, Qwen $0.16 by default — override via --baseline)
* latency per call
The COGS unit is **dollars per 1000 grounded answers**, an outcome
metric — failed/ungrounded calls are already amortized into the
baseline. A model's baseline × grounded_count / 1000 = implied spend
for those grounded answers.
Outputs:
bench/cross_model_results/<utc-iso>.jsonl one row per (question, model)
bench/cross_model_results/<utc-iso>.md markdown summary
Usage:
make bench-cross-model # smoke fixture, Hermes + Qwen
make bench-cross-model BENCH_CM_QUESTIONS=bench/qa_questions.txt
# custom baseline (dollars per 1000 grounded answers):
python3 bench/cross_model_selfplay.py --baseline 'hermes=0.09,qwen=0.16'
# custom model list (name=endpoint:model_id, comma-separated):
python3 bench/cross_model_selfplay.py \\
--models 'hermes=https://hermes.ai.unturf.com/v1:adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic,qwen=https://qwen.ai.unturf.com/v1:Qwen3.6-27B-UD-Q4_K_XL.gguf'
# emit teacher-judge prompts for downstream correctness grading:
python3 bench/cross_model_selfplay.py --teacher-model-judge
# → produces <ts>.judge_input.jsonl with one row per (question, model);
# feed to a teacher (claude -p, remote API, etc.) to detect false STRICTs
# — answers that audit STRICT but are factually wrong.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ARBORIST = REPO / ".venv" / "bin" / "arborist"
DEFAULT_MODELS = [
("hermes", "https://hermes.ai.unturf.com/v1",
"adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"),
("qwen", "https://qwen.ai.unturf.com/v1",
"Qwen3.6-27B-UD-Q4_K_XL.gguf"),
]
# Per fox 2026-05-31 — baseline COGS in dollars per 1000 grounded
# answers. Hermes (8B) on 3090, Qwen (30B) on 4090. The baseline
# already amortizes ungrounded calls — it's an outcome metric, not a
# per-call price. Override via --baseline.
DEFAULT_DOLLARS_PER_1K_GROUNDED = {"hermes": 0.09, "qwen": 0.16}
# audit_mode → numeric rank for "stronger" comparison. Strips
# "-PARTIAL" suffix that HYBRID modes carry.
_AUDIT_RANK = {
"EVIDENCE-WARRANTED": 3,
"ANCHOR-WARRANTED": 2,
"POINTER-LINKED": 1,
"UNGROUNDED": 0,
"STRICT": 2, # legacy alias for ANCHOR-WARRANTED
"HYBRID": 1, # legacy alias for POINTER-LINKED
}
def _audit_rank(mode: str | None) -> int:
if not mode:
return 0
base = mode.replace("-PARTIAL", "")
return _AUDIT_RANK.get(base, 0)
def _is_grounded(mode: str | None) -> bool:
"""Anything not UNGROUNDED counts as grounded for cost accounting."""
if not mode:
return False
return not mode.startswith("UNGROUNDED")
def _read_questions(path: Path) -> list[str]:
out: list[str] = []
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
out.append(line)
return out
def _parse_models(spec: str) -> list[tuple[str, str, str]]:
"""Parse name=endpoint:model_id,... into a list of tuples."""
out = []
for chunk in spec.split(","):
chunk = chunk.strip()
if not chunk:
continue
if "=" not in chunk or ":" not in chunk.split("=", 1)[1]:
raise SystemExit(
f"--models entry must be name=endpoint:model_id, got: {chunk!r}"
)
name, rest = chunk.split("=", 1)
# split on the LAST colon — model_id may contain colons (rare)
# but endpoint always ends at the path boundary, so split on
# the colon BEFORE a non-slash. Simpler: assume model_id
# follows the first colon AFTER the endpoint scheme://host[:port]/path.
# We split on " :" sentinel — but easiest: rsplit once on `:`
# because vLLM model_ids don't contain colons in practice.
endpoint, model_id = rest.rsplit(":", 1)
out.append((name.strip(), endpoint.strip(), model_id.strip()))
return out
def _parse_baseline(spec: str | None) -> dict[str, float]:
"""Parse name=dollars_per_1k_grounded,... into a dict."""
if not spec:
return dict(DEFAULT_DOLLARS_PER_1K_GROUNDED)
out = dict(DEFAULT_DOLLARS_PER_1K_GROUNDED)
for chunk in spec.split(","):
if "=" not in chunk:
continue
name, dollars = chunk.split("=", 1)
try:
out[name.strip()] = float(dollars.strip())
except ValueError:
pass
return out
def _run_one(
question: str, *, shards_dir: Path | None, endpoint: str, model: str,
top_k: int, timeout_s: int, burn: bool,
) -> dict:
cmd = [str(ARBORIST)]
if shards_dir:
cmd += ["--shards-dir", str(shards_dir)]
cmd += [
"query", "--json",
"--top-k", str(top_k),
"--endpoint", endpoint,
"--model", model,
]
if burn:
cmd.append("--burn")
cmd.append(question)
t0 = time.time()
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout_s,
env={**os.environ, "ARBORIST_PROGRESS": "0"},
)
except subprocess.TimeoutExpired:
return {"_error": f"timeout after {timeout_s}s",
"_elapsed_s": float(timeout_s)}
elapsed = round(time.time() - t0, 2)
if proc.returncode not in (0, 1):
# Exit 1 is "ungrounded" — still has JSON. Other codes are real errors.
return {"_error": f"exit {proc.returncode}",
"_stderr": (proc.stderr or "")[:500],
"_elapsed_s": elapsed}
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError as e:
return {"_error": f"parse: {e}",
"_stdout_head": proc.stdout[:200],
"_elapsed_s": elapsed}
data["_elapsed_s"] = elapsed
data["_exit_code"] = proc.returncode
return data
def _primary_uri(result: dict) -> str:
srcs = result.get("sources") or []
primary = next(
(s for s in srcs if s.get("source_role") == "primary_answer_source"),
srcs[0] if srcs else {},
)
return primary.get("document_uri") or ""
def _stronger_model(per_model: dict[str, dict]) -> str:
"""Return the name of the model with the higher audit_mode rank
(ties broken by alpha for stability). Empty string if all ungrounded."""
best = ""
best_rank = -1
for name, res in sorted(per_model.items()):
r = _audit_rank(res.get("audit_mode"))
if r > best_rank:
best, best_rank = name, r
return best if best_rank > 0 else ""
def _judge_prompt(question: str, model_name: str, audit_mode: str, answer: str) -> str:
"""Build the prompt a teacher model receives to grade one (question, answer)
pair. Asks for a single verdict token + a one-sentence rationale.
Designed to be model-agnostic — any teacher (Opus via `claude -p`, a remote
API, a Hermes/Qwen self-judge run) can consume it. The teacher should reply
starting with one of CORRECT/WRONG/PARTIAL/UNCERTAIN followed by a colon
and a one-sentence rationale.
"""
return (
"You are a hermetic judge grading the factual correctness of one answer "
"to one question. Use your own parametric knowledge as ground truth. "
"Ignore the answer's stylistic quality and whether it cites sources — "
"judge only whether the factual claim is true.\n\n"
f"Question: {question}\n\n"
f"Answer: {answer}\n\n"
"Reply with exactly one word from the set "
"{CORRECT, WRONG, PARTIAL, UNCERTAIN}, followed by a colon and a "
"one-sentence rationale. Do not write the literal word VERDICT — "
"write the chosen verdict word itself.\n\n"
"Meanings:\n"
" CORRECT — the answer's main factual claim is true\n"
" WRONG — the answer's main factual claim is false\n"
" PARTIAL — main claim correct but missing required detail "
"(or correct on part, wrong on part)\n"
" UNCERTAIN — you don't know the domain well enough to judge\n\n"
"Examples of valid replies:\n"
" CORRECT: Paris is the capital of France.\n"
" WRONG: The Nile begins at Lake Victoria, not the Niger Highlands.\n"
" PARTIAL: Names some members but misses Maggie Simpson.\n"
" UNCERTAIN: I do not know this obscure regional fact.\n"
)
def _emit_judge_inputs(
rows: list[dict], out_path: Path,
) -> int:
"""Write one JSONL row per (question, model) ready for a teacher to grade.
Skips rows that errored or returned no answer text. Returns count written."""
written = 0
with out_path.open("w") as f:
for r in rows:
res = r.get("result") or {}
if "_error" in res:
continue
answer = (res.get("answer_text") or "").strip()
if not answer:
continue
audit = res.get("audit_mode") or "UNKNOWN"
payload = {
"question": r["question"],
"model_name": r["model_name"],
"audit_mode": audit,
"answer_text": answer,
"judge_prompt": _judge_prompt(
r["question"], r["model_name"], audit, answer,
),
}
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
written += 1
return written
def _summarize(
rows: list[dict], models: list[tuple[str, str, str]],
baseline_per_1k: dict[str, float],
) -> str:
"""Build the markdown summary. COGS unit: $ per 1k grounded answers."""
by_q: dict[str, dict[str, dict]] = defaultdict(dict)
for r in rows:
by_q[r["question"]][r["model_name"]] = r["result"]
n_q = len(by_q)
model_names = [m[0] for m in models]
out = []
out.append(f"# Cross-model self-play bench — {_dt.datetime.now(_dt.timezone.utc).isoformat()}")
out.append("")
out.append(f"Questions: {n_q}. Models: {', '.join(model_names)}.")
out.append("")
out.append("COGS unit: **dollars per 1000 grounded answers** (fox's natural unit). "
"Implied spend uses each model's baseline × grounded_count / 1000.")
out.append("")
# Per-model summary
out.append("## Per-model summary")
out.append("")
out.append("| model | grounded | rate | avg latency | baseline $/1k grounded | implied spend |")
out.append("|---|---:|---:|---:|---:|---:|")
for name in model_names:
grounded = sum(
1 for q in by_q if _is_grounded(by_q[q].get(name, {}).get("audit_mode"))
)
total = sum(1 for q in by_q if name in by_q[q] and "_error" not in by_q[q][name])
rate = (grounded / total) if total else 0.0
avg_lat = (
sum(by_q[q].get(name, {}).get("_elapsed_s", 0) for q in by_q) / max(total, 1)
)
base = baseline_per_1k.get(name, 0.0)
spend = grounded * base / 1000
out.append(
f"| {name} | {grounded}/{total} | {rate:.0%} "
f"| {avg_lat:.1f}s | ${base:.2f} | ${spend:.6f} |"
)
out.append("")
# Agreement
out.append("## Per-question results")
out.append("")
header = ["question"] + model_names + ["stronger", "primary agree"]
out.append("| " + " | ".join(header) + " |")
out.append("|" + "|".join(["---"] * len(header)) + "|")
for q in by_q:
cells = [q[:64] + ("" if len(q) > 64 else "")]
uris = set()
for name in model_names:
res = by_q[q].get(name, {})
audit = res.get("audit_mode") or ""
if "_error" in res:
audit = f"ERR ({res['_error'][:30]})"
cells.append(audit)
uri = _primary_uri(res)
if uri:
uris.add(uri)
cells.append(_stronger_model(by_q[q]) or "")
cells.append("" if len(uris) == 1 else ("" if len(uris) > 1 else ""))
out.append("| " + " | ".join(cells) + " |")
out.append("")
# Cost analysis
out.append("## Cost analysis (north star: $/1k grounded)")
out.append("")
total_spend = 0.0
total_grounded = 0
for name in model_names:
base = baseline_per_1k.get(name, 0.0)
n_g = sum(1 for q in by_q if _is_grounded(by_q[q].get(name, {}).get("audit_mode")))
spent = n_g * base / 1000
total_spend += spent
total_grounded += n_g
out.append(f"* **{name}** — {n_g} grounded × ${base:.2f}/1k = ${spent:.6f}")
out.append("")
out.append(f"**Total implied spend**: ${total_spend:.6f}. **Total grounded answers**: {total_grounded}.")
if total_grounded:
# Effective $/1k = total_spend × (1000 / total_grounded)
out.append(f"**Effective COGS**: ${total_spend * 1000 / total_grounded:.4f} per 1k grounded "
f"(weighted across models in this run).")
out.append("")
# Routing hint — cheap-first cascade
out.append("## Cheap-first cascade (try cheapest, escalate on UNGROUNDED)")
out.append("")
ordered = sorted(model_names, key=lambda n: baseline_per_1k.get(n, 0))
# Per-q: which model ultimately grounded? Count by source.
cascade_grounded_by_model: dict[str, int] = defaultdict(int)
cascade_total_grounded = 0
for q in by_q:
for name in ordered:
res = by_q[q].get(name, {})
if "_error" in res:
continue
if _is_grounded(res.get("audit_mode")):
cascade_grounded_by_model[name] += 1
cascade_total_grounded += 1
break
# else fall through to next model in cascade
cascade_spend = sum(
cnt * baseline_per_1k.get(name, 0) / 1000
for name, cnt in cascade_grounded_by_model.items()
)
if cascade_total_grounded:
breakdown = ", ".join(
f"{cnt} from {name}" for name, cnt in cascade_grounded_by_model.items()
)
cascade_cogs = cascade_spend * 1000 / cascade_total_grounded
out.append(
f"Cascade ({''.join(ordered)}): {cascade_total_grounded}/{n_q} grounded "
f"({breakdown}). Implied spend ${cascade_spend:.6f}"
f"**${cascade_cogs:.4f}/1k grounded**."
)
# Compare to always-most-expensive (defensive baseline)
most_expensive = ordered[-1]
max_base = baseline_per_1k.get(most_expensive, 0)
always_grounded = sum(
1 for q in by_q
if _is_grounded(by_q[q].get(most_expensive, {}).get("audit_mode"))
)
always_spend = always_grounded * max_base / 1000
if always_grounded:
always_cogs = always_spend * 1000 / always_grounded
delta_pct = (cascade_cogs / always_cogs - 1) * 100 if always_cogs else 0
sign = "+" if delta_pct >= 0 else ""
out.append("")
out.append(
f"vs always-{most_expensive}: {always_grounded}/{n_q} grounded, "
f"${always_spend:.6f} spend, ${always_cogs:.4f}/1k. "
f"Cascade delta: **{sign}{delta_pct:.1f}%**."
)
out.append("")
out.append("Caveat: cascade math credits each grounded answer to its source model's baseline. "
"Real-world failed-call overhead (an UNGROUNDED hermes call that escalates to qwen) "
"is not separately accounted — it's amortized into hermes's baseline by definition.")
out.append("")
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--questions", type=Path,
default=Path("bench/qa_questions_smoke.txt"),
)
ap.add_argument("--shards-dir", type=Path, default=None)
ap.add_argument(
"--models", default=None,
help="comma-separated name=endpoint:model_id; defaults to hermes + qwen",
)
ap.add_argument(
"--baseline", default=None,
help="comma-separated name=dollars per 1k grounded answers; "
"defaults to hermes=0.09,qwen=0.16 (fox's COGS unit)",
)
ap.add_argument("--top-k", type=int, default=8)
# 600s default — queue depth on shared endpoints (e.g. hermes
# under contention from another agent) can push a single call
# past 3 minutes even when the model is healthy. 180s was too
# aggressive against real-world infrastructure load.
ap.add_argument("--timeout", type=int, default=600)
ap.add_argument(
"--burn", action="store_true",
help="force fresh inference (skip cache) on every call",
)
ap.add_argument("--limit", type=int, default=0, help="cap questions (0 = no cap)")
ap.add_argument(
"--out-dir", type=Path, default=Path("bench/cross_model_results"),
)
ap.add_argument(
"--teacher-model-judge", action="store_true",
help="emit <ts>.judge_input.jsonl alongside results — one row per "
"(question, model) with a teacher-ready prompt for grading. "
"Feed to any teacher (claude -p, remote API, Hermes self-judge) "
"to get correctness verdicts. STRICT audit ≠ correct — this is "
"how you measure false-STRICTs.",
)
args = ap.parse_args()
questions = _read_questions(args.questions)
if args.limit:
questions = questions[: args.limit]
if not questions:
print(f"no questions in {args.questions}", file=sys.stderr)
return 1
if args.models:
models = _parse_models(args.models)
else:
models = list(DEFAULT_MODELS)
baseline = _parse_baseline(args.baseline)
if not ARBORIST.exists():
print(f"arborist binary not found: {ARBORIST}", file=sys.stderr)
return 2
args.out_dir.mkdir(parents=True, exist_ok=True)
ts = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
jsonl_path = args.out_dir / f"{ts}.jsonl"
md_path = args.out_dir / f"{ts}.md"
rows: list[dict] = []
print(f"# {len(questions)} questions × {len(models)} models = "
f"{len(questions) * len(models)} calls", file=sys.stderr)
with jsonl_path.open("w") as f:
for q in questions:
for name, endpoint, model_id in models:
print(f" [{name}] {q[:80]}", file=sys.stderr)
res = _run_one(
q, shards_dir=args.shards_dir,
endpoint=endpoint, model=model_id,
top_k=args.top_k, timeout_s=args.timeout, burn=args.burn,
)
row = {
"question": q,
"model_name": name,
"model_id": model_id,
"endpoint": endpoint,
"result": res,
}
rows.append(row)
f.write(json.dumps(row, ensure_ascii=False) + "\n")
f.flush()
audit = res.get("audit_mode") or res.get("_error") or "?"
elapsed = res.get("_elapsed_s", "?")
print(f"{audit} ({elapsed}s)", file=sys.stderr)
md_path.write_text(_summarize(rows, models, baseline))
print(f"\nJSONL: {jsonl_path}", file=sys.stderr)
print(f"Summary: {md_path}", file=sys.stderr)
if args.teacher_model_judge:
judge_input_path = args.out_dir / f"{ts}.judge_input.jsonl"
n = _emit_judge_inputs(rows, judge_input_path)
print(f"Teacher-judge inputs: {judge_input_path} ({n} rows)", file=sys.stderr)
print(f" Feed to your teacher of choice (e.g. `claude -p` per row) "
f"to grade. Verdicts are CORRECT|WRONG|PARTIAL|UNCERTAIN.",
file=sys.stderr)
print()
print(md_path.read_text())
return 0
if __name__ == "__main__":
sys.exit(main())