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>
170 lines
4.8 KiB
Python
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())
|