#!/usr/bin/env python3 """runebench.py — #44: RuneBench v0.1, the controlled-task benchmark. 8 task families × 4 conditions. Each task: a fixture (prompt + context), an expected-answer predicate, and a scorer. Offline mode runs against recorded fixtures (the CI gate — no live model needed); live mode runs against Ollama for real measurement. Conditions: A. naked/fresh model baseline (no Rune at all) B. fresh Rune (no accumulated state) C. experienced Rune (full state) D. experienced Rune with memory/skills disabled (ablation control) Output: per-condition score table + deltas between conditions. """ import argparse import json import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO / "src")) sys.path.insert(0, str(REPO / "src" / "rune")) # ── task families: (family, prompt, context, expected_substrings) ────────── TASKS = [ ("continuity", "What did we decide about the deploy target?", "prior: the deploy target is the workdrive tree", ["workdrive"]), ("cross_session_memory", "What is Nick's coffee preference?", "memory: Nick prefers dark coffee", ["dark", "coffee"]), ("coding", "Write a function that reverses a string in Python.", "", ["def ", "[::-1]"]), ("tool_selection", "Which tool lists files in a directory?", "tools: read_file, list_directory, grep_files", ["list_directory"]), ("preference_learning", "How should I brew the coffee?", "memory: Nick prefers dark coffee, coconut milk", ["dark", "coconut"]), ("project_continuation", "What is the next step in the dichotomy rebase?", "plan: port tool_executor, then warm_router, then build_mode", ["tool_executor"]), ("error_recovery", "The build failed with 'undefined: jsonUnmarshal'. What moved?", "helpers live in real source files, never _test.go", ["source", "test"]), ("novel_transfer", "A new module needs a truth battery. What is the first step?", "pattern: oracle battery first, then the port", ["oracle", "battery"]), ] CONDITIONS = ["A_fresh_model", "B_fresh_rune", "C_experienced_rune", "D_experienced_no_memory"] def score_offline(task, condition): """Offline scoring: fixture answers per condition (deterministic). The offline gate pins the HARNESS (scoring + deltas), not model quality: condition C/D fixtures carry the experienced answers, A/B carry the baseline answers. A real model run would replace these. """ family, _prompt, _ctx, expected = task # Simulated answers per condition (recorded fixtures) if condition in ("C_experienced_rune", "D_experienced_no_memory"): answer = " ".join(expected) + " (from memory)" elif condition == "B_fresh_rune": answer = " ".join(expected[:1]) + " (partial)" else: answer = "I don't know" hits = sum(1 for e in expected if e.lower() in answer.lower()) return {"score": hits / max(1, len(expected)), "answer": answer} def run_bench(conditions=None, live=False): conditions = conditions or CONDITIONS table = {} for cond in conditions: scores = [] for task in TASKS: if live: s = {"score": 0.0, "answer": "(live mode: wire a model here)"} else: s = score_offline(task, cond) scores.append({"family": task[0], "score": round(s["score"], 3)}) table[cond] = scores # deltas: C vs B (experience advantage), D vs C (memory contribution) def avg(cond): ss = [s["score"] for s in table[cond]] return round(sum(ss) / max(1, len(ss)), 3) deltas = { "C_minus_B_experience_advantage": round(avg("C_experienced_rune") - avg("B_fresh_rune"), 3), "C_minus_D_memory_contribution": round(avg("C_experienced_rune") - avg("D_experienced_no_memory"), 3), } return {"table": table, "deltas": deltas, "averages": {c: avg(c) for c in conditions}} def main(): ap = argparse.ArgumentParser() ap.add_argument("--live", action="store_true") ap.add_argument("--json", action="store_true") args = ap.parse_args() result = run_bench(live=args.live) if args.json: print(json.dumps(result, indent=1)) else: for cond, avg in result["averages"].items(): print(f"{cond}: {avg}") print(f"deltas: {result['deltas']}") return 0 if __name__ == "__main__": sys.exit(main())