Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

322 lines
11 KiB

"""Reproducible analysis of a single eval_harness run.
Usage:
python scripts/analyze_eval_results.py # latest finished run
python scripts/analyze_eval_results.py --run-id <uuid>
python scripts/analyze_eval_results.py --compare <base> <new>
Prints: verdict distribution, wrong_speaker-by-complexity, bad-pct per tool,
citations-per-paragraph split, good-vs-bad source counts, top llm_events,
and a few sample wrong_speaker paragraphs with rationale + mismatch.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT))
from dotenv import load_dotenv
load_dotenv()
from postgres_client import pg
def _latest_run_id() -> Optional[str]:
rows = pg.execute(
"SELECT id::text FROM eval_runs ORDER BY started_at DESC LIMIT 1"
)
return rows[0]["id"] if rows else None
def _fmt_row(row: Dict[str, Any], cols: List[str]) -> str:
return " | ".join(f"{c}={row.get(c)}" for c in cols)
def _print_rows(header: str, rows: List[Dict[str, Any]], cols: Optional[List[str]] = None) -> None:
print(f"\n--- {header} ---")
if not rows:
print("(no rows)")
return
if cols is None:
cols = list(rows[0].keys())
widths = {c: max(len(str(c)), *(len(str(r.get(c))) for r in rows)) for c in cols}
print(" ".join(c.ljust(widths[c]) for c in cols))
print(" ".join("-" * widths[c] for c in cols))
for r in rows:
print(" ".join(str(r.get(c)).ljust(widths[c]) for c in cols))
def run_summary(run_id: str) -> None:
meta = pg.execute(
"""SELECT id::text, label, config->>'smart_model' AS smart_model,
config->>'judge_model' AS judge_model, config->>'git_sha' AS git_sha,
started_at::text, finished_at::text, num_questions
FROM eval_runs WHERE id = %s""",
(run_id,),
)
if not meta:
print(f"No run found with id {run_id}")
return
m = meta[0]
print(f"\n=== Run {m['id']} ===")
print(f"label: {m['label']}")
print(f"smart: {m['smart_model']} judge: {m['judge_model']}")
print(f"git_sha: {m['git_sha']}")
print(f"started: {m['started_at']}")
print(f"finished: {m['finished_at']}")
print(f"questions: {m['num_questions']}")
def verdict_distribution(run_id: str) -> None:
rows = pg.execute(
"""SELECT j.verdict, COUNT(*) AS n,
ROUND(100.0*COUNT(*)/SUM(COUNT(*)) OVER (), 1) AS pct
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id = %s
GROUP BY j.verdict
ORDER BY n DESC""",
(run_id,),
)
_print_rows("Verdict distribution", rows, ["verdict", "n", "pct"])
def by_complexity(run_id: str) -> None:
rows = pg.execute(
"""SELECT q.complexity,
COUNT(*) AS paras,
ROUND(100.0*COUNT(*) FILTER (WHERE j.verdict='supported')/COUNT(*), 1) AS supported_pct,
ROUND(100.0*COUNT(*) FILTER (WHERE j.verdict='wrong_speaker')/COUNT(*), 1) AS wrong_speaker_pct,
ROUND(100.0*COUNT(*) FILTER (WHERE j.verdict='unsupported')/COUNT(*), 1) AS unsupported_pct,
AVG(q.num_iterations)::numeric(10,1) AS avg_iters,
AVG(q.duration_ms/1000.0)::numeric(10,1) AS avg_sec
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id = %s
GROUP BY q.complexity
ORDER BY q.complexity""",
(run_id,),
)
_print_rows(
"By complexity", rows,
["complexity", "paras", "supported_pct", "wrong_speaker_pct", "unsupported_pct", "avg_iters", "avg_sec"],
)
def tool_bad_pct(run_id: str) -> None:
rows = pg.execute(
"""SELECT tool, COUNT(*) FILTER (WHERE bad) AS bad, COUNT(*) AS total,
ROUND(100.0 * COUNT(*) FILTER (WHERE bad) / COUNT(*), 1) AS bad_pct
FROM (
SELECT jsonb_array_elements(q.tool_trace)->>'tool' AS tool,
EXISTS (
SELECT 1 FROM eval_judgments j
WHERE j.question_id = q.id
AND j.verdict IN ('wrong_speaker','unsupported','wrong_attribution')
) AS bad
FROM eval_questions q
WHERE q.run_id = %s
) t
WHERE tool IS NOT NULL
GROUP BY tool
ORDER BY bad_pct DESC""",
(run_id,),
)
_print_rows("Bad-pct by tool", rows, ["tool", "bad", "total", "bad_pct"])
def citations_per_paragraph(run_id: str) -> None:
rows = pg.execute(
"""SELECT j.verdict,
COUNT(*) AS n,
AVG(array_length(j.cited_indices, 1))::numeric(10,2) AS avg_cites,
COUNT(*) FILTER (WHERE array_length(j.cited_indices, 1) >= 2) AS multi_cite,
COUNT(*) FILTER (WHERE array_length(j.cited_indices, 1) = 1) AS single_cite
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id = %s
GROUP BY j.verdict
ORDER BY n DESC""",
(run_id,),
)
_print_rows(
"Citations per paragraph",
rows,
["verdict", "n", "avg_cites", "single_cite", "multi_cite"],
)
def good_vs_bad_sources(run_id: str) -> None:
rows = pg.execute(
"""SELECT (CASE WHEN bad_count > 0 THEN 'bad' ELSE 'good' END) AS class,
COUNT(*) AS qs,
AVG(num_iters)::numeric(10,1) AS avg_iters,
AVG(n_sources)::numeric(10,1) AS avg_sources
FROM (
SELECT q.id, q.num_iterations AS num_iters,
jsonb_array_length(q.sources) AS n_sources,
(SELECT COUNT(*) FROM eval_judgments j
WHERE j.question_id = q.id
AND j.verdict IN ('wrong_speaker','unsupported','wrong_attribution')
) AS bad_count
FROM eval_questions q
WHERE q.run_id = %s AND q.answer IS NOT NULL
) t
GROUP BY class
ORDER BY class""",
(run_id,),
)
_print_rows("Good vs bad questions", rows, ["class", "qs", "avg_iters", "avg_sources"])
def latency_summary(run_id: str) -> None:
rows = pg.execute(
"""SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY duration_ms)::int AS p50_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms)::int AS p95_ms,
AVG(num_iterations)::numeric(10,1) AS avg_iters,
MAX(num_iterations) AS max_iters,
COUNT(*) FILTER (WHERE error IS NOT NULL) AS errors,
COUNT(*) AS total
FROM eval_questions
WHERE run_id = %s""",
(run_id,),
)
_print_rows("Latency / iterations", rows, ["total", "errors", "avg_iters", "max_iters", "p50_ms", "p95_ms"])
def llm_events(run_id: str) -> None:
rows = pg.execute(
"""SELECT event_type, COUNT(*) AS n
FROM llm_events
WHERE detail->>'eval_run_id' = %s
GROUP BY event_type
ORDER BY n DESC
LIMIT 15""",
(run_id,),
)
_print_rows("LLM events during run", rows, ["event_type", "n"])
def metadata_mismatch_parity(run_id: str) -> None:
rows = pg.execute(
"""SELECT j.verdict,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE j.metadata_mismatch IS NOT NULL) AS det_caught,
ROUND(100.0 * COUNT(*) FILTER (WHERE j.metadata_mismatch IS NOT NULL) / COUNT(*), 1) AS det_pct
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id = %s
GROUP BY j.verdict
ORDER BY total DESC""",
(run_id,),
)
_print_rows("Deterministic metadata_mismatch parity", rows,
["verdict", "total", "det_caught", "det_pct"])
def sample_wrong_speaker(run_id: str, limit: int = 3) -> None:
rows = pg.execute(
"""SELECT j.paragraph_text, j.cited_indices, j.rationale, j.metadata_mismatch
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id = %s AND j.verdict = 'wrong_speaker'
ORDER BY j.metadata_mismatch IS NULL, random()
LIMIT %s""",
(run_id, limit),
)
print(f"\n--- Sample wrong_speaker paragraphs (n={len(rows)}) ---")
for i, r in enumerate(rows, 1):
print(f"\n[{i}] CITED: {r['cited_indices']}")
print(f" PARA: {(r['paragraph_text'] or '')[:300]}")
print(f" RATIONALE:{(r['rationale'] or '')[:220]}")
print(f" MISMATCH: {r['metadata_mismatch'] or '(none — judge-only catch)'}")
def compare_runs(run_ids: List[str]) -> None:
placeholders = ", ".join(["%s"] * len(run_ids))
rows = pg.execute(
f"""SELECT j.verdict, q.run_id::text AS run_id, COUNT(*) AS n
FROM eval_judgments j
JOIN eval_questions q ON q.id = j.question_id
WHERE q.run_id IN ({placeholders})
GROUP BY j.verdict, q.run_id""",
tuple(run_ids),
)
# Fetch labels for readable headers
label_rows = pg.execute(
f"SELECT id::text, label FROM eval_runs WHERE id IN ({placeholders})",
tuple(run_ids),
)
labels = {r["id"]: r["label"] or r["id"][:8] for r in label_rows}
by_run: Dict[str, Dict[str, int]] = {rid: {} for rid in run_ids}
for r in rows:
by_run[r["run_id"]][r["verdict"]] = r["n"]
verdicts = sorted({v for d in by_run.values() for v in d.keys()})
totals = {rid: sum(by_run[rid].values()) or 1 for rid in run_ids}
# Header
print("\n--- Verdict comparison ---")
base_id = run_ids[0]
header = f"{'verdict':22s}"
for rid in run_ids:
col = f"{labels[rid][:14]} n"
header += f" {col:>9s} {'%':>6s}"
header += f" {'Δpp vs base':>11s}" * (len(run_ids) - 1)
print(header)
print("-" * len(header))
for v in verdicts:
line = f"{v:22s}"
base_pct = 100.0 * by_run[base_id].get(v, 0) / totals[base_id]
for rid in run_ids:
n = by_run[rid].get(v, 0)
pct = 100.0 * n / totals[rid]
line += f" {n:9d} {pct:5.1f}%"
for rid in run_ids[1:]:
pct = 100.0 * by_run[rid].get(v, 0) / totals[rid]
line += f" {pct - base_pct:+10.1f}"
print(line)
# Totals row
line = f"{'TOTAL':22s}"
for rid in run_ids:
line += f" {totals[rid]:9d} {'100.0':>6s}"
print(line)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--run-id", help="Run UUID; defaults to most recent run")
ap.add_argument("--compare", nargs="+", metavar="RUN_ID",
help="Compare verdict distributions of two or more runs (first is the base)")
args = ap.parse_args()
if args.compare:
compare_runs(args.compare)
return
run_id = args.run_id or _latest_run_id()
if not run_id:
print("No runs found.")
return
run_summary(run_id)
verdict_distribution(run_id)
by_complexity(run_id)
tool_bad_pct(run_id)
citations_per_paragraph(run_id)
good_vs_bad_sources(run_id)
latency_summary(run_id)
metadata_mismatch_parity(run_id)
llm_events(run_id)
sample_wrong_speaker(run_id)
if __name__ == "__main__":
main()