Files
Algis DumbrisandClaude Opus 4.6 02b8548eac feat(017): MuSiQue benchmark harness — marketplace stub, mixed-tier agents, Pareto scoring, HTML report
MVP implementation of the MuSiQue multi-agent benchmark (spec 017):

- benchmark/setup.py: downloads musique_v1.0.zip from the canonical Google
  Drive source (mirrors upstream download_data.sh). Idempotent.
- benchmark/curate.py: deterministic selection of 3 4-hop questions from
  the dev set sharing a US pivot entity; writes benchmark/trio.jsonl.
- benchmark/marketplace.py: in-process 016-marketplace stub with
  post_auction / bid / award / mark_done / query_reputation and a
  domain-scoped reputation ledger. Designed for mechanical swap to real
  SynapBus MCP tools.
- benchmark/agents.py: HaikuAgent + SonnetAgent, using the official
  anthropic SDK (no Claude Agent SDK, no subprocesses). Models pinned
  to claude-haiku-4-5-20251001 and claude-sonnet-4-6.
- benchmark/baseline.py: single Sonnet call with all 20 distractors
  plus chain-of-thought.
- benchmark/score.py: SQuAD-style normalized F1 + Pareto verdict
  (strictly northwest = PASS).
- benchmark/run.py: main entry. --mode single-shot, --question, --dry-run.
- benchmark/report.py: self-contained HTML with inline SVG scatter plot.
- benchmark/trio.jsonl: curated reproducible trio (all three converge on
  "Treaty of Paris" US territory cession).

Verified with benchmark/run.py --dry-run end-to-end; all 8 files
py_compile clean. Real-token execution is deferred to the user's main
session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:12:19 +03:00

170 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""
Curate a deterministic trio of MuSiQue 4-hop questions that share a
pivot entity. For MVP we pivot on the United States.
Input: benchmark/data/musique_ans_v1.0_dev.jsonl
Output: benchmark/trio.jsonl
Each output record:
{
"id": str,
"question": str,
"answer": str,
"decomposition": [{"question": str, "answer": str}, ...],
"paragraphs": [str, ...] # up to 20 distractor snippets
}
MuSiQue dev records typically look like::
{
"id": "4hop1__...",
"question": "...",
"question_decomposition": [
{"id": N, "question": "...", "answer": "...",
"paragraph_support_idx": int},
...
],
"answer": "...",
"answer_aliases": [...],
"paragraphs": [
{"idx": int, "title": "...", "paragraph_text": "...",
"is_supporting": bool},
...
]
}
The curation rule is deterministic (fixed input ordering; first 3 matches).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
DATA_FILE = Path(__file__).resolve().parent / "data" / "musique_ans_v1.0_dev.jsonl"
OUT_FILE = Path(__file__).resolve().parent / "trio.jsonl"
PIVOT_TOKENS = ("united states", "u.s.", " us ", "america", "american")
N_QUESTIONS = 3
MAX_PARAGRAPHS = 20
def _normalized(s: str) -> str:
return f" {s.lower()} "
def _mentions_pivot(record: dict) -> bool:
blob_parts = [record.get("question", ""), record.get("answer", "")]
for sub in record.get("question_decomposition", []) or []:
blob_parts.append(sub.get("question", ""))
blob_parts.append(sub.get("answer", ""))
blob = _normalized(" ".join(str(x) for x in blob_parts if x))
return any(tok in blob for tok in PIVOT_TOKENS)
def _is_4hop(record: dict) -> bool:
rid = record.get("id", "")
if isinstance(rid, str) and rid.startswith("4hop"):
return True
# Fall back: count decomposition hops.
decomp = record.get("question_decomposition") or []
return len(decomp) == 4
def _trim_paragraphs(record: dict, limit: int) -> list[str]:
out: list[str] = []
for p in record.get("paragraphs", []) or []:
title = (p.get("title") or "").strip()
text = (p.get("paragraph_text") or "").strip()
if not text:
continue
snippet = f"[{title}] {text}" if title else text
out.append(snippet)
if len(out) >= limit:
break
return out
def _simplify_decomp(record: dict) -> list[dict]:
out = []
for sub in record.get("question_decomposition", []) or []:
out.append(
{
"question": sub.get("question", ""),
"answer": sub.get("answer", ""),
}
)
return out
def curate() -> int:
if not DATA_FILE.exists():
print(
f"[curate] ERROR: {DATA_FILE} not found. Run setup.py first.",
file=sys.stderr,
)
return 2
selected: list[dict] = []
total_scanned = 0
total_4hop = 0
total_pivot = 0
with open(DATA_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
total_scanned += 1
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if not _is_4hop(rec):
continue
total_4hop += 1
if not _mentions_pivot(rec):
continue
total_pivot += 1
trio_record = {
"id": rec.get("id", f"q{len(selected)+1}"),
"question": rec.get("question", ""),
"answer": rec.get("answer", ""),
"answer_aliases": rec.get("answer_aliases", []),
"decomposition": _simplify_decomp(rec),
"paragraphs": _trim_paragraphs(rec, MAX_PARAGRAPHS),
}
selected.append(trio_record)
if len(selected) >= N_QUESTIONS:
break
print(
f"[curate] scanned={total_scanned} 4hop={total_4hop} "
f"pivot-matches={total_pivot} kept={len(selected)}"
)
if len(selected) < N_QUESTIONS:
print(
f"[curate] ERROR: wanted {N_QUESTIONS} questions, "
f"found {len(selected)}",
file=sys.stderr,
)
return 3
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(OUT_FILE, "w", encoding="utf-8") as f:
for i, rec in enumerate(selected, start=1):
# Attach a stable short id q1/q2/q3 in addition to MuSiQue's id.
rec["short_id"] = f"q{i}"
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"[curate] wrote {OUT_FILE} ({len(selected)} records)")
return 0
if __name__ == "__main__":
raise SystemExit(curate())