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>
This commit is contained in:
Algis Dumbris
2026-04-11 15:12:19 +03:00
co-authored by Claude Opus 4.6
parent e77fd7afdf
commit 02b8548eac
11 changed files with 1629 additions and 0 deletions
+6
View File
@@ -41,6 +41,12 @@ Thumbs.db
__pycache__/
*.pyc
# Benchmark (017) — large datasets, per-run outputs, local venvs
benchmark/data/
benchmark/results/
.venv-bench/
.venv/
# Debug
__debug_bin*
.claude/worktrees/
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
Agent pool for the MuSiQue benchmark.
Two agents:
- haiku-agent (claude-haiku-4-5-20251001)
- sonnet-agent (claude-sonnet-4-6)
Each agent exposes:
- name, model, skill_card
- bid(task) -> {estimated_tokens, confidence, approach}
- execute(task, paragraphs) -> {answer, actual_tokens}
Design notes:
- We use the official ``anthropic`` Python SDK directly (NOT the
Claude Agent SDK). Simpler, no subprocesses, reliable token accounting.
- ``bid()`` is pure Python — it is a cheap heuristic so the marketplace
has something to pick from. Real 016 agents would emit a structured
reply. For MVP, heuristic bids are sufficient to exercise the auction
primitive.
- ``execute()`` is the only thing that actually burns tokens.
- ``--dry-run`` in run.py never calls execute(); it uses stub responses.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
try:
import anthropic # type: ignore
except ImportError: # pragma: no cover
anthropic = None # type: ignore
HAIKU_MODEL = "claude-haiku-4-5-20251001"
SONNET_MODEL = "claude-sonnet-4-6"
HAIKU_SKILL_CARD = """\
# haiku-agent
A fast, cheap agent best for single-hop fact lookups and short
extractive answers. Accepts multi-paragraph context but may miss
subtle bridging entities on 4-hop questions. Very low cost per call.
Domains: factual-lookup, extraction, summarization
"""
SONNET_SKILL_CARD = """\
# sonnet-agent
A deliberate mid-tier agent well-suited to multi-hop reasoning with
explicit chain-of-thought. Handles 4-hop MuSiQue questions with
decomposition when the context fits in one prompt. Higher cost per call
than Haiku but meaningfully better F1 on bridging questions.
Domains: multi-hop-qa, decomposition, reasoning
"""
SYSTEM_PROMPT = """\
You are a careful question-answering agent working on a MuSiQue
multi-hop benchmark. You are given a question and a set of numbered
paragraphs. Only a few of the paragraphs are relevant; the rest are
distractors.
Think step by step and cite the paragraphs you used. Then output a
final line starting with exactly:
ANSWER: <your short final answer>
Your final answer must be a short entity or phrase — not a sentence.
"""
@dataclass
class BidResult:
estimated_tokens: int
confidence: float
approach: str
def to_dict(self) -> dict[str, Any]:
return {
"estimated_tokens": self.estimated_tokens,
"confidence": self.confidence,
"approach": self.approach,
}
@dataclass
class ExecuteResult:
answer: str
actual_tokens: int
raw_text: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"answer": self.answer,
"actual_tokens": self.actual_tokens,
}
class Agent:
name: str
model: str
skill_card: str
def __init__(self, name: str, model: str, skill_card: str) -> None:
self.name = name
self.model = model
self.skill_card = skill_card
# ---- bidding -----------------------------------------------------------
def bid(self, task: dict[str, Any]) -> BidResult:
raise NotImplementedError
# ---- execution ---------------------------------------------------------
def execute(
self,
task: dict[str, Any],
paragraphs: list[str],
*,
dry_run: bool = False,
max_budget_tokens: int = 100_000,
) -> ExecuteResult:
question = task["question"]
prompt = self._build_prompt(question, paragraphs)
if dry_run:
stub = (
"Thinking step by step... [dry-run stub]\n"
f"ANSWER: [stub answer from {self.name}]"
)
# Rough estimate: 1 token ~= 4 characters.
est = max(256, len(prompt) // 4 + 64)
return ExecuteResult(
answer=self._extract_answer(stub),
actual_tokens=est,
raw_text=stub,
)
if anthropic is None:
raise RuntimeError(
"anthropic SDK not installed — pip install anthropic"
)
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError(
"ANTHROPIC_API_KEY not set. Use --dry-run to stub it out."
)
client = anthropic.Anthropic(api_key=api_key)
# Cap max_tokens to min(1024, budget/2) so the worst case is tame.
max_tokens = min(1024, max(128, max_budget_tokens // 2))
msg = client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
)
text_parts: list[str] = []
for block in msg.content:
t = getattr(block, "text", None)
if t:
text_parts.append(t)
text = "\n".join(text_parts).strip()
usage = getattr(msg, "usage", None)
actual = 0
if usage is not None:
actual = (
getattr(usage, "input_tokens", 0)
+ getattr(usage, "output_tokens", 0)
)
return ExecuteResult(
answer=self._extract_answer(text),
actual_tokens=int(actual),
raw_text=text,
)
# ---- helpers -----------------------------------------------------------
def _build_prompt(
self, question: str, paragraphs: list[str]
) -> str:
body = ["Paragraphs:"]
for i, p in enumerate(paragraphs, start=1):
body.append(f"[{i}] {p}")
body.append("")
body.append(f"Question: {question}")
body.append("")
body.append("Think step by step, then output your final ANSWER: line.")
return "\n".join(body)
def _extract_answer(self, text: str) -> str:
if not text:
return ""
for line in reversed(text.splitlines()):
line = line.strip()
if line.upper().startswith("ANSWER:"):
return line.split(":", 1)[1].strip()
# Fallback: last non-empty line.
for line in reversed(text.splitlines()):
line = line.strip()
if line:
return line
return ""
class HaikuAgent(Agent):
def __init__(self) -> None:
super().__init__(
name="haiku-agent",
model=HAIKU_MODEL,
skill_card=HAIKU_SKILL_CARD,
)
def bid(self, task: dict[str, Any]) -> BidResult:
# Cheap, low confidence on multi-hop bridging.
return BidResult(
estimated_tokens=4_000,
confidence=0.45,
approach=(
"Extract candidate entities from the paragraphs and "
"answer directly; may miss 4-hop bridges."
),
)
class SonnetAgent(Agent):
def __init__(self) -> None:
super().__init__(
name="sonnet-agent",
model=SONNET_MODEL,
skill_card=SONNET_SKILL_CARD,
)
def bid(self, task: dict[str, Any]) -> BidResult:
# More expensive, higher confidence on multi-hop.
return BidResult(
estimated_tokens=12_000,
confidence=0.80,
approach=(
"Decompose the question into sub-questions, resolve each "
"sub-answer against the paragraphs, then compose the final "
"bridged answer."
),
)
def default_pool() -> list[Agent]:
return [HaikuAgent(), SonnetAgent()]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Single-agent baseline: one Anthropic API call to claude-sonnet-4-6 with
the question and all 20 distractor paragraphs plus chain-of-thought
instructions. No decomposition, no marketplace, no tools.
Returns {"answer": str, "tokens": int, "raw_text": str}.
"""
from __future__ import annotations
import os
from typing import Any
try:
import anthropic # type: ignore
except ImportError: # pragma: no cover
anthropic = None # type: ignore
BASELINE_MODEL = "claude-sonnet-4-6"
BASELINE_SYSTEM = """\
You are a careful multi-hop QA system. Given a question and a set of
numbered paragraphs (some irrelevant distractors), think step by step
and answer.
Output your reasoning first, then on a final line:
ANSWER: <short final answer>
"""
def _build_prompt(question: str, paragraphs: list[str]) -> str:
parts = ["Paragraphs:"]
for i, p in enumerate(paragraphs, start=1):
parts.append(f"[{i}] {p}")
parts.append("")
parts.append(f"Question: {question}")
parts.append("")
parts.append(
"Work through the reasoning step by step, then give your "
"final ANSWER: line."
)
return "\n".join(parts)
def _extract_answer(text: str) -> str:
if not text:
return ""
for line in reversed(text.splitlines()):
line = line.strip()
if line.upper().startswith("ANSWER:"):
return line.split(":", 1)[1].strip()
for line in reversed(text.splitlines()):
line = line.strip()
if line:
return line
return ""
def run_baseline(
question: str,
paragraphs: list[str],
*,
dry_run: bool = False,
max_output_tokens: int = 1024,
) -> dict[str, Any]:
prompt = _build_prompt(question, paragraphs)
if dry_run:
stub = (
"Step 1: scanning paragraphs... [dry-run stub]\n"
"Step 2: picking the most likely entity...\n"
"ANSWER: [stub baseline answer]"
)
est = max(512, len(prompt) // 4 + 128)
return {
"answer": _extract_answer(stub),
"tokens": est,
"raw_text": stub,
"model": BASELINE_MODEL,
}
if anthropic is None:
raise RuntimeError("anthropic SDK not installed")
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("ANTHROPIC_API_KEY not set")
client = anthropic.Anthropic(api_key=api_key)
msg = client.messages.create(
model=BASELINE_MODEL,
max_tokens=max_output_tokens,
system=BASELINE_SYSTEM,
messages=[{"role": "user", "content": prompt}],
)
text_parts: list[str] = []
for block in msg.content:
t = getattr(block, "text", None)
if t:
text_parts.append(t)
text = "\n".join(text_parts).strip()
usage = getattr(msg, "usage", None)
tokens = 0
if usage is not None:
tokens = (
getattr(usage, "input_tokens", 0)
+ getattr(usage, "output_tokens", 0)
)
return {
"answer": _extract_answer(text),
"tokens": int(tokens),
"raw_text": text,
"model": BASELINE_MODEL,
}
+169
View File
@@ -0,0 +1,169 @@
#!/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())
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
In-process stub of the 016-agent-marketplace primitives.
*** IMPORTANT ***
This module is an in-process stand-in for the SynapBus-hosted 016
marketplace. The follow-up deliverable after this MVP is to replace the
bodies of these functions with calls to the real SynapBus MCP tools
(``post_auction``, ``bid``, ``award``, ``mark_done``, and
``query_reputation``) once 016 lands. The public API here deliberately
mirrors those tool names so the swap is mechanical.
Scope for MVP:
- In-memory auctions, bids, awards, and done records
- Domain-scoped reputation ledger stored in a dict
- No persistence, no concurrency — single process, single thread
- No schema enforcement beyond a couple of shape checks
The harness (``run.py``) holds a single ``Marketplace`` instance.
"""
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Auction:
auction_id: str
task: dict[str, Any]
domain: str
max_budget_tokens: int
posted_at: float
bids: list[dict[str, Any]] = field(default_factory=list)
awarded_to: str | None = None
result: dict[str, Any] | None = None
@dataclass
class ReputationEntry:
agent: str
domain: str
runs: int = 0
correct: int = 0
tokens_spent: int = 0
def score(self) -> float:
if self.runs == 0:
return 0.5 # prior
quality = self.correct / self.runs
avg_tokens = self.tokens_spent / self.runs
# Arbitrary: quality dominates, tokens slightly penalize.
return quality - min(avg_tokens / 200_000.0, 0.3)
class Marketplace:
"""In-process marketplace stub."""
def __init__(self) -> None:
self._auctions: dict[str, Auction] = {}
self._reputation: dict[tuple[str, str], ReputationEntry] = {}
self._counter = itertools.count(1)
# ---- auction lifecycle -------------------------------------------------
def post_auction(
self,
task: dict[str, Any],
domain: str,
max_budget_tokens: int,
) -> str:
auction_id = f"auction-{next(self._counter)}"
self._auctions[auction_id] = Auction(
auction_id=auction_id,
task=dict(task),
domain=domain,
max_budget_tokens=max_budget_tokens,
posted_at=time.time(),
)
return auction_id
def bid(
self,
auction_id: str,
agent: str,
estimated_tokens: int,
confidence: float,
approach: str,
) -> None:
auction = self._auctions[auction_id]
if auction.awarded_to is not None:
raise RuntimeError(f"auction {auction_id} already awarded")
auction.bids.append(
{
"agent": agent,
"estimated_tokens": int(estimated_tokens),
"confidence": float(confidence),
"approach": approach,
"submitted_at": time.time(),
}
)
def list_bids(self, auction_id: str) -> list[dict[str, Any]]:
return list(self._auctions[auction_id].bids)
def score_bid(self, auction_id: str, bid: dict[str, Any]) -> float:
"""
Lower is better (we're minimizing tokens per unit confidence),
but we add a reputation adjustment that rewards agents with a
track record in this domain.
"""
auction = self._auctions[auction_id]
rep = self._reputation.get((bid["agent"], auction.domain))
rep_score = rep.score() if rep else 0.5
conf = max(bid["confidence"], 1e-3)
# Cost per confidence, lightly discounted by reputation.
raw = bid["estimated_tokens"] / conf
return raw * (1.15 - 0.3 * rep_score)
def award(self, auction_id: str) -> dict[str, Any]:
auction = self._auctions[auction_id]
if not auction.bids:
raise RuntimeError(f"auction {auction_id} has no bids")
if auction.awarded_to is not None:
raise RuntimeError(f"auction {auction_id} already awarded")
best = min(
auction.bids,
key=lambda b: self.score_bid(auction_id, b),
)
auction.awarded_to = best["agent"]
return best
def mark_done(
self,
auction_id: str,
answer: str,
actual_tokens: int,
correct: bool,
) -> None:
auction = self._auctions[auction_id]
if auction.awarded_to is None:
raise RuntimeError(f"auction {auction_id} not awarded yet")
auction.result = {
"answer": answer,
"actual_tokens": int(actual_tokens),
"correct": bool(correct),
}
key = (auction.awarded_to, auction.domain)
entry = self._reputation.get(key) or ReputationEntry(
agent=auction.awarded_to, domain=auction.domain
)
entry.runs += 1
entry.tokens_spent += int(actual_tokens)
if correct:
entry.correct += 1
self._reputation[key] = entry
# ---- reputation --------------------------------------------------------
def query_reputation(
self, agent: str, domain: str
) -> dict[str, Any]:
rep = self._reputation.get((agent, domain))
if rep is None:
return {
"agent": agent,
"domain": domain,
"runs": 0,
"correct": 0,
"tokens_spent": 0,
"score": 0.5,
}
return {
"agent": rep.agent,
"domain": rep.domain,
"runs": rep.runs,
"correct": rep.correct,
"tokens_spent": rep.tokens_spent,
"score": rep.score(),
}
def all_reputation(self) -> list[dict[str, Any]]:
return [
self.query_reputation(rep.agent, rep.domain)
for rep in self._reputation.values()
]
def auction(self, auction_id: str) -> Auction:
return self._auctions[auction_id]
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Self-contained HTML report generator.
Renders a single HTML file with inline styles and an inline SVG scatter
plot. No external assets, no CDN calls, nothing to fetch. Safe to open
directly in a browser.
"""
from __future__ import annotations
import html
from pathlib import Path
from typing import Any
def _esc(s: Any) -> str:
return html.escape(str(s if s is not None else ""))
def _scatter_svg(
market_tokens: int,
market_f1: float,
baseline_tokens: int,
baseline_f1: float,
*,
width: int = 520,
height: int = 320,
) -> str:
pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
plot_w = width - pad_l - pad_r
plot_h = height - pad_t - pad_b
max_tokens = max(market_tokens, baseline_tokens, 1)
# Give a little headroom so points aren't on the axis.
max_tokens_axis = max_tokens * 1.15
min_tokens_axis = 0
def sx(tokens: float) -> float:
frac = (tokens - min_tokens_axis) / max(
max_tokens_axis - min_tokens_axis, 1
)
return pad_l + frac * plot_w
def sy(f1: float) -> float:
# y=0 at top of plot, y=1 at bottom -> invert
return pad_t + (1.0 - max(0.0, min(1.0, f1))) * plot_h
axis_color = "#555"
grid_color = "#eee"
market_color = "#2563eb"
baseline_color = "#dc2626"
parts: list[str] = []
parts.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'role="img" aria-label="Pareto scatter: tokens vs F1">'
)
parts.append(
f'<rect x="0" y="0" width="{width}" height="{height}" '
f'fill="white"/>'
)
# Gridlines at F1 = 0, 0.25, 0.5, 0.75, 1.0
for f in (0.0, 0.25, 0.5, 0.75, 1.0):
y = sy(f)
parts.append(
f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width-pad_r}" '
f'y2="{y:.1f}" stroke="{grid_color}" stroke-width="1"/>'
)
parts.append(
f'<text x="{pad_l-8}" y="{y+4:.1f}" font-family="sans-serif" '
f'font-size="11" fill="{axis_color}" text-anchor="end">'
f'{f:.2f}</text>'
)
# X-axis ticks
for frac in (0.0, 0.25, 0.5, 0.75, 1.0):
t_val = frac * max_tokens_axis
x = sx(t_val)
parts.append(
f'<line x1="{x:.1f}" y1="{height-pad_b}" x2="{x:.1f}" '
f'y2="{height-pad_b+4}" stroke="{axis_color}"/>'
)
parts.append(
f'<text x="{x:.1f}" y="{height-pad_b+18}" '
f'font-family="sans-serif" font-size="11" fill="{axis_color}" '
f'text-anchor="middle">{int(t_val)}</text>'
)
# Axis lines
parts.append(
f'<line x1="{pad_l}" y1="{pad_t}" x2="{pad_l}" '
f'y2="{height-pad_b}" stroke="{axis_color}"/>'
)
parts.append(
f'<line x1="{pad_l}" y1="{height-pad_b}" x2="{width-pad_r}" '
f'y2="{height-pad_b}" stroke="{axis_color}"/>'
)
# Axis labels
parts.append(
f'<text x="{width/2:.1f}" y="{height-10}" '
f'font-family="sans-serif" font-size="12" fill="{axis_color}" '
f'text-anchor="middle">tokens</text>'
)
parts.append(
f'<text x="15" y="{height/2:.1f}" font-family="sans-serif" '
f'font-size="12" fill="{axis_color}" text-anchor="middle" '
f'transform="rotate(-90 15 {height/2:.1f})">F1</text>'
)
# Baseline point
bx, by = sx(baseline_tokens), sy(baseline_f1)
parts.append(
f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="7" '
f'fill="{baseline_color}"/>'
)
parts.append(
f'<text x="{bx+10:.1f}" y="{by+4:.1f}" font-family="sans-serif" '
f'font-size="11" fill="{baseline_color}">baseline</text>'
)
# Market point
mx, my = sx(market_tokens), sy(market_f1)
parts.append(
f'<circle cx="{mx:.1f}" cy="{my:.1f}" r="7" '
f'fill="{market_color}"/>'
)
parts.append(
f'<text x="{mx+10:.1f}" y="{my+4:.1f}" font-family="sans-serif" '
f'font-size="11" fill="{market_color}">marketplace</text>'
)
parts.append("</svg>")
return "".join(parts)
CSS = """\
body { font-family: -apple-system, system-ui, sans-serif;
max-width: 960px; margin: 2rem auto; padding: 0 1rem;
color: #1f2937; line-height: 1.55; }
h1, h2, h3 { color: #111827; }
h1 { border-bottom: 2px solid #2563eb; padding-bottom: .4rem; }
.verdict-pass { display: inline-block; background: #dcfce7;
color: #166534; padding: .3rem .8rem; border-radius: 6px;
font-weight: 600; }
.verdict-fail { display: inline-block; background: #fee2e2;
color: #991b1b; padding: .3rem .8rem; border-radius: 6px;
font-weight: 600; }
table { border-collapse: collapse; margin: .8rem 0; width: 100%; }
th, td { border: 1px solid #e5e7eb; padding: .4rem .6rem;
text-align: left; vertical-align: top; }
th { background: #f9fafb; }
pre, code { background: #f3f4f6; border-radius: 4px;
padding: .1rem .4rem; font-size: .9rem; }
pre { padding: .8rem; white-space: pre-wrap; word-break: break-word; }
.card { border: 1px solid #e5e7eb; border-radius: 8px;
padding: 1rem 1.2rem; margin: 1rem 0; background: #fff; }
.kv { display: grid; grid-template-columns: 180px 1fr; gap: .3rem .8rem; }
.small { color: #6b7280; font-size: .88rem; }
"""
def render_report(data: dict[str, Any], out_path: Path) -> None:
verdict = data.get("pareto", {})
is_pass = verdict.get("verdict") == "PASS"
verdict_html = (
'<span class="verdict-pass">PASS — strictly northwest</span>'
if is_pass
else '<span class="verdict-fail">FAIL — not dominating baseline</span>'
)
bids_rows: list[str] = []
for b in data.get("bids", []):
conf_str = "{:.2f}".format(b.get("confidence", 0) or 0)
bids_rows.append(
f"<tr><td>{_esc(b.get('agent'))}</td>"
f"<td>{_esc(b.get('estimated_tokens'))}</td>"
f"<td>{_esc(conf_str)}</td>"
f"<td>{_esc(b.get('approach'))}</td></tr>"
)
bids_table = "\n".join(bids_rows) or (
"<tr><td colspan=4>no bids</td></tr>"
)
decomp_rows: list[str] = []
for i, sub in enumerate(data.get("decomposition", []) or [], start=1):
decomp_rows.append(
f"<tr><td>{i}</td><td>{_esc(sub.get('question'))}</td>"
f"<td>{_esc(sub.get('answer'))}</td></tr>"
)
decomp_table = "\n".join(decomp_rows) or (
"<tr><td colspan=3>(none)</td></tr>"
)
rep_rows: list[str] = []
for rep in data.get("reputation", []) or []:
score_str = "{:.3f}".format(rep.get("score", 0) or 0)
rep_rows.append(
f"<tr><td>{_esc(rep.get('agent'))}</td>"
f"<td>{_esc(rep.get('domain'))}</td>"
f"<td>{_esc(rep.get('runs'))}</td>"
f"<td>{_esc(rep.get('correct'))}</td>"
f"<td>{_esc(rep.get('tokens_spent'))}</td>"
f"<td>{_esc(score_str)}</td></tr>"
)
rep_table = "\n".join(rep_rows) or (
"<tr><td colspan=6>(empty)</td></tr>"
)
svg = _scatter_svg(
market_tokens=int(data.get("market", {}).get("tokens", 0)),
market_f1=float(data.get("market", {}).get("f1", 0.0)),
baseline_tokens=int(data.get("baseline", {}).get("tokens", 0)),
baseline_f1=float(data.get("baseline", {}).get("f1", 0.0)),
)
market = data.get("market", {})
baseline = data.get("baseline", {})
market_f1_str = "{:.3f}".format(verdict.get("market_f1", 0) or 0)
baseline_f1_str = "{:.3f}".format(verdict.get("baseline_f1", 0) or 0)
f1_delta_str = "{:.3f}".format(verdict.get("f1_delta", 0) or 0)
market_run_f1_str = "{:.3f}".format(market.get("f1", 0) or 0)
baseline_run_f1_str = "{:.3f}".format(baseline.get("f1", 0) or 0)
html_doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>MuSiQue MAS Benchmark — {_esc(data.get('question_id', ''))}</title>
<style>{CSS}</style>
</head>
<body>
<h1>MuSiQue MAS Benchmark Report</h1>
<p class="small">
Mode: <code>{_esc(data.get('mode', ''))}</code>
&middot; Question: <code>{_esc(data.get('question_id', ''))}</code>
&middot; Dry-run: <code>{_esc(data.get('dry_run', False))}</code>
</p>
<div class="card">
<h2>Verdict</h2>
<p>{verdict_html}</p>
<div class="kv">
<div>Market tokens</div><div>{_esc(verdict.get('market_tokens'))}</div>
<div>Market F1</div><div>{_esc(market_f1_str)}</div>
<div>Baseline tokens</div><div>{_esc(verdict.get('baseline_tokens'))}</div>
<div>Baseline F1</div><div>{_esc(baseline_f1_str)}</div>
<div>Tokens delta</div><div>{_esc(verdict.get('tokens_delta'))}</div>
<div>F1 delta</div><div>{_esc(f1_delta_str)}</div>
</div>
</div>
<div class="card">
<h2>Pareto plot</h2>
{svg}
<p class="small">
Lower-right = expensive and wrong. Upper-left = cheap and correct.
Marketplace must sit strictly northwest of baseline to pass.
</p>
</div>
<div class="card">
<h2>Question</h2>
<p><strong>{_esc(data.get('question', ''))}</strong></p>
<p>Gold answer: <code>{_esc(data.get('gold_answer', ''))}</code></p>
<h3>Gold decomposition</h3>
<table>
<tr><th>#</th><th>Sub-question</th><th>Sub-answer</th></tr>
{decomp_table}
</table>
</div>
<div class="card">
<h2>Auction</h2>
<p>Domain: <code>{_esc(data.get('domain', ''))}</code>
&middot; Budget: <code>{_esc(data.get('max_budget_tokens', ''))}</code>
&middot; Awarded to: <code>{_esc(data.get('awarded_to', ''))}</code></p>
<h3>Bids received</h3>
<table>
<tr><th>Agent</th><th>Est. tokens</th><th>Confidence</th><th>Approach</th></tr>
{bids_table}
</table>
</div>
<div class="card">
<h2>Marketplace run</h2>
<div class="kv">
<div>Winning agent</div><div>{_esc(market.get('agent'))}</div>
<div>Model</div><div>{_esc(market.get('model'))}</div>
<div>Tokens</div><div>{_esc(market.get('tokens'))}</div>
<div>F1</div><div>{_esc(market_run_f1_str)}</div>
<div>Answer</div><div><code>{_esc(market.get('answer'))}</code></div>
</div>
</div>
<div class="card">
<h2>Single-agent baseline</h2>
<div class="kv">
<div>Model</div><div>{_esc(baseline.get('model'))}</div>
<div>Tokens</div><div>{_esc(baseline.get('tokens'))}</div>
<div>F1</div><div>{_esc(baseline_run_f1_str)}</div>
<div>Answer</div><div><code>{_esc(baseline.get('answer'))}</code></div>
</div>
</div>
<div class="card">
<h2>Reputation ledger (post-run)</h2>
<table>
<tr><th>Agent</th><th>Domain</th><th>Runs</th><th>Correct</th>
<th>Tokens</th><th>Score</th></tr>
{rep_table}
</table>
</div>
<p class="small">
Generated by <code>benchmark/report.py</code>.
Marketplace primitives are currently stubbed in-process — see
<code>benchmark/marketplace.py</code> for the migration plan to the
real 016 SynapBus MCP tools.
</p>
</body>
</html>
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html_doc, encoding="utf-8")
+2
View File
@@ -0,0 +1,2 @@
anthropic>=0.40.0
requests>=2.31.0
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Main entry point for the MuSiQue MAS benchmark.
Usage::
python benchmark/run.py --mode single-shot --question q1
python benchmark/run.py --mode single-shot --question q1 --dry-run
Flow (single-shot):
1. Load trio.jsonl, find the requested question (by short_id).
2. Marketplace run:
a. post_auction(task, domain, max_budget)
b. each agent in the pool submits a bid
c. marketplace awards best bid
d. winner executes (Anthropic call or dry-run stub)
e. marketplace.mark_done records reputation
3. Baseline run: one Sonnet call with all distractors.
4. Score both, compute Pareto verdict.
5. Write results/latest.json and results/latest.html.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any
# Allow running as ``python benchmark/run.py`` from the repo root.
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from agents import default_pool # noqa: E402
from baseline import run_baseline, BASELINE_MODEL # noqa: E402
from marketplace import Marketplace # noqa: E402
from report import render_report # noqa: E402
from score import best_f1_against_aliases, pareto_verdict # noqa: E402
TRIO_FILE = _HERE / "trio.jsonl"
RESULTS_DIR = _HERE / "results"
DEFAULT_DOMAIN = "multi-hop-qa"
DEFAULT_BUDGET = 50_000
def _load_trio() -> list[dict[str, Any]]:
if not TRIO_FILE.exists():
raise SystemExit(
f"[run] trio.jsonl not found at {TRIO_FILE}. "
"Run curate.py first."
)
out: list[dict[str, Any]] = []
with open(TRIO_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
out.append(json.loads(line))
return out
def _pick_question(
trio: list[dict[str, Any]], want: str
) -> dict[str, Any]:
for rec in trio:
if rec.get("short_id") == want or rec.get("id") == want:
return rec
raise SystemExit(
f"[run] question {want!r} not found. Available: "
+ ", ".join(r.get("short_id", r.get("id", "?")) for r in trio)
)
def single_shot(
question: str,
*,
dry_run: bool,
verbose: bool = True,
) -> dict[str, Any]:
trio = _load_trio()
rec = _pick_question(trio, question)
task = {
"question": rec["question"],
"short_id": rec.get("short_id"),
}
paragraphs = rec.get("paragraphs", []) or []
gold_answer = rec.get("answer", "")
aliases = rec.get("answer_aliases", []) or []
market = Marketplace()
pool = default_pool()
if verbose:
print(f"[run] question {rec.get('short_id')}: {rec['question']!r}")
print(
f"[run] agents: "
+ ", ".join(f"{a.name}({a.model})" for a in pool)
)
print(f"[run] paragraphs: {len(paragraphs)}")
# --- Marketplace path ------------------------------------------------
auction_id = market.post_auction(
task=task,
domain=DEFAULT_DOMAIN,
max_budget_tokens=DEFAULT_BUDGET,
)
if verbose:
print(f"[run] posted auction {auction_id}")
for agent in pool:
bid = agent.bid(task)
market.bid(
auction_id=auction_id,
agent=agent.name,
estimated_tokens=bid.estimated_tokens,
confidence=bid.confidence,
approach=bid.approach,
)
if verbose:
print(
f"[run] bid {agent.name}: "
f"est={bid.estimated_tokens} conf={bid.confidence:.2f}"
)
winning_bid = market.award(auction_id)
winner_name = winning_bid["agent"]
winner = next(a for a in pool if a.name == winner_name)
if verbose:
print(f"[run] awarded to {winner_name}")
start = time.time()
result = winner.execute(
task=task,
paragraphs=paragraphs,
dry_run=dry_run,
max_budget_tokens=DEFAULT_BUDGET,
)
market_wall = time.time() - start
market_f1 = best_f1_against_aliases(
result.answer, gold_answer, aliases
)
market.mark_done(
auction_id=auction_id,
answer=result.answer,
actual_tokens=result.actual_tokens,
correct=market_f1 >= 0.5,
)
if verbose:
print(
f"[run] market answer: {result.answer!r} "
f"(tokens={result.actual_tokens}, f1={market_f1:.3f})"
)
# --- Baseline path ---------------------------------------------------
start = time.time()
baseline = run_baseline(
question=rec["question"],
paragraphs=paragraphs,
dry_run=dry_run,
)
baseline_wall = time.time() - start
baseline_f1 = best_f1_against_aliases(
baseline["answer"], gold_answer, aliases
)
if verbose:
print(
f"[run] baseline answer: {baseline['answer']!r} "
f"(tokens={baseline['tokens']}, f1={baseline_f1:.3f})"
)
verdict = pareto_verdict(
market_tokens=result.actual_tokens,
market_f1=market_f1,
baseline_tokens=baseline["tokens"],
baseline_f1=baseline_f1,
)
if verbose:
print(f"[run] PARETO VERDICT: {verdict['verdict']}")
return {
"mode": "single-shot",
"dry_run": dry_run,
"question_id": rec.get("short_id"),
"musique_id": rec.get("id"),
"question": rec["question"],
"gold_answer": gold_answer,
"decomposition": rec.get("decomposition", []),
"domain": DEFAULT_DOMAIN,
"max_budget_tokens": DEFAULT_BUDGET,
"awarded_to": winner_name,
"bids": market.list_bids(auction_id),
"market": {
"agent": winner_name,
"model": winner.model,
"tokens": result.actual_tokens,
"answer": result.answer,
"f1": market_f1,
"wall_seconds": market_wall,
"raw_text": result.raw_text,
},
"baseline": {
"model": baseline.get("model", BASELINE_MODEL),
"tokens": baseline["tokens"],
"answer": baseline["answer"],
"f1": baseline_f1,
"wall_seconds": baseline_wall,
"raw_text": baseline.get("raw_text", ""),
},
"pareto": verdict,
"reputation": market.all_reputation(),
}
def _write_outputs(result: dict[str, Any]) -> tuple[Path, Path]:
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
json_path = RESULTS_DIR / "latest.json"
html_path = RESULTS_DIR / "latest.html"
# Trim raw_text from json to keep it small and readable.
trimmed = dict(result)
for key in ("market", "baseline"):
section = dict(trimmed.get(key, {}))
if "raw_text" in section:
section["raw_text"] = (section["raw_text"] or "")[:2000]
trimmed[key] = section
json_path.write_text(
json.dumps(trimmed, indent=2, ensure_ascii=False), encoding="utf-8"
)
render_report(result, html_path)
return json_path, html_path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="MuSiQue multi-agent benchmark harness"
)
parser.add_argument(
"--mode",
choices=["single-shot"],
default="single-shot",
help="Run mode (only single-shot is implemented in MVP)",
)
parser.add_argument(
"--question",
default="q1",
help="Question short_id from trio.jsonl (q1/q2/q3)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Skip real Anthropic API calls; use stub responses",
)
args = parser.parse_args(argv)
if args.mode != "single-shot":
print(f"[run] mode {args.mode} not implemented in MVP", file=sys.stderr)
return 2
result = single_shot(
question=args.question,
dry_run=args.dry_run,
verbose=True,
)
json_path, html_path = _write_outputs(result)
print(f"[run] wrote {json_path}")
print(f"[run] wrote {html_path}")
print(f"[run] verdict: {result['pareto']['verdict']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Scoring utilities for the MuSiQue benchmark.
- Normalized exact-match F1 (SQuAD-style): lowercase, strip articles,
strip punctuation, collapse whitespace.
- Pareto verdict: the marketplace point is strictly northwest of the
baseline iff it uses fewer tokens AND has F1 >= baseline, with at
least one of those strict.
"""
from __future__ import annotations
import re
import string
from collections import Counter
from typing import Any
_ARTICLE_RE = re.compile(r"\b(a|an|the)\b", re.IGNORECASE)
def normalize(text: str) -> str:
if text is None:
return ""
text = text.lower()
text = _ARTICLE_RE.sub(" ", text)
text = "".join(ch for ch in text if ch not in string.punctuation)
text = " ".join(text.split())
return text
def f1(prediction: str, gold: str) -> float:
pred_tokens = normalize(prediction).split()
gold_tokens = normalize(gold).split()
if not pred_tokens and not gold_tokens:
return 1.0
if not pred_tokens or not gold_tokens:
return 0.0
common = Counter(pred_tokens) & Counter(gold_tokens)
overlap = sum(common.values())
if overlap == 0:
return 0.0
precision = overlap / len(pred_tokens)
recall = overlap / len(gold_tokens)
return 2 * precision * recall / (precision + recall)
def exact_match(prediction: str, gold: str) -> bool:
return normalize(prediction) == normalize(gold)
def best_f1_against_aliases(
prediction: str, gold: str, aliases: list[str] | None = None
) -> float:
candidates = [gold] + list(aliases or [])
return max(f1(prediction, c) for c in candidates if c is not None)
def pareto_verdict(
market_tokens: int,
market_f1: float,
baseline_tokens: int,
baseline_f1: float,
) -> dict[str, Any]:
"""
Strictly northwest of baseline: fewer tokens AND higher-or-equal F1,
with at least one strict inequality.
"""
tokens_better = market_tokens < baseline_tokens
quality_atleast = market_f1 >= baseline_f1
quality_better = market_f1 > baseline_f1
strictly_nw = (
(tokens_better and quality_atleast)
or (quality_better and market_tokens <= baseline_tokens)
)
return {
"verdict": "PASS" if strictly_nw else "FAIL",
"strictly_northwest": strictly_nw,
"market_tokens": int(market_tokens),
"market_f1": float(market_f1),
"baseline_tokens": int(baseline_tokens),
"baseline_f1": float(baseline_f1),
"tokens_delta": int(market_tokens - baseline_tokens),
"f1_delta": float(market_f1 - baseline_f1),
}
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
MuSiQue dataset downloader.
Downloads ``musique_v1.0.zip`` from the canonical source used by the
upstream project (https://github.com/StonyBrookNLP/musique). The zip is
hosted on Google Drive (file id ``1tGdADlNjWFaHLeZZGShh2IRcpO6Lv24h``);
this mirrors the behavior of the project's ``download_data.sh`` which
uses ``gdown`` under the hood.
Idempotent — skips download if the target dev-set jsonl already exists.
Run: ``python benchmark/setup.py``
"""
from __future__ import annotations
import os
import re
import sys
import zipfile
from pathlib import Path
import requests
GDRIVE_FILE_ID = "1tGdADlNjWFaHLeZZGShh2IRcpO6Lv24h"
GDRIVE_URL = "https://docs.google.com/uc?export=download"
DATA_DIR = Path(__file__).resolve().parent / "data"
ZIP_PATH = DATA_DIR / "musique_v1.0.zip"
TARGET_FILE = DATA_DIR / "musique_ans_v1.0_dev.jsonl"
def _write_stream(resp: requests.Response, dest: Path) -> int:
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
f.write(chunk)
downloaded += len(chunk)
if total:
pct = 100.0 * downloaded / total
print(
f"\r downloading: {downloaded/1e6:6.1f} MB "
f"/ {total/1e6:6.1f} MB ({pct:5.1f}%)",
end="",
file=sys.stderr,
)
print("", file=sys.stderr)
return downloaded
def _download_gdrive(file_id: str, dest: Path) -> bool:
"""
Download a large file from Google Drive, handling the virus-scan
confirmation page that Drive injects for anything over ~100 MB.
"""
session = requests.Session()
try:
resp = session.get(
GDRIVE_URL,
params={"id": file_id, "export": "download"},
stream=True,
timeout=60,
)
except requests.RequestException as exc:
print(f" -> request failed: {exc}", file=sys.stderr)
return False
# Case 1: Drive returns the file directly (small file or cached).
ctype = resp.headers.get("Content-Type", "")
if "text/html" not in ctype.lower():
_write_stream(resp, dest)
return dest.exists() and dest.stat().st_size > 0
# Case 2: HTML confirmation page. Extract the confirm token and/or
# the form action URL.
html = resp.text
# Newer Drive flow: a <form ...> with all the params we need.
form_match = re.search(
r'<form[^>]*id="download-form"[^>]*action="([^"]+)"', html
)
if form_match:
action = form_match.group(1).replace("&amp;", "&")
params = dict(
re.findall(
r'name="([^"]+)"[^>]*value="([^"]+)"', html
)
)
try:
resp2 = session.get(action, params=params, stream=True, timeout=120)
if resp2.status_code == 200:
_write_stream(resp2, dest)
return dest.exists() and dest.stat().st_size > 0
except requests.RequestException as exc:
print(f" -> form post failed: {exc}", file=sys.stderr)
return False
# Older flow: confirm cookie token.
token = None
for k, v in session.cookies.items():
if k.startswith("download_warning"):
token = v
break
if token is None:
m = re.search(r'confirm=([0-9A-Za-z_-]+)', html)
if m:
token = m.group(1)
if token:
try:
resp3 = session.get(
GDRIVE_URL,
params={
"id": file_id,
"export": "download",
"confirm": token,
},
stream=True,
timeout=120,
)
if resp3.status_code == 200:
_write_stream(resp3, dest)
return dest.exists() and dest.stat().st_size > 0
except requests.RequestException as exc:
print(f" -> confirm fetch failed: {exc}", file=sys.stderr)
return False
print(" -> could not navigate Google Drive download flow", file=sys.stderr)
return False
def _extract(zip_path: Path, out_dir: Path) -> None:
"""Extract the dev set jsonl from the zip."""
wanted_suffixes = (
"musique_ans_v1.0_dev.jsonl",
"musique_ans_v1.0_train.jsonl",
)
with zipfile.ZipFile(zip_path) as zf:
members = zf.namelist()
extracted_any = False
for m in members:
base = os.path.basename(m)
if base in wanted_suffixes:
with zf.open(m) as src, open(out_dir / base, "wb") as dst:
dst.write(src.read())
print(f" extracted: {base}")
extracted_any = True
if not extracted_any:
# Fall back: extract everything so a human can inspect.
zf.extractall(out_dir)
print(
" could not find canonical filenames; extracted all",
file=sys.stderr,
)
def main() -> int:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if TARGET_FILE.exists():
size = TARGET_FILE.stat().st_size
print(f"[setup] already present: {TARGET_FILE} ({size/1e6:.1f} MB)")
return 0
print(f"[setup] downloading Google Drive file id {GDRIVE_FILE_ID}")
ok = _download_gdrive(GDRIVE_FILE_ID, ZIP_PATH)
if not ok:
print(
"[setup] ERROR: failed to download MuSiQue. Please download "
"manually from "
f"https://drive.google.com/file/d/{GDRIVE_FILE_ID}/view "
f"and place the zip at {ZIP_PATH}",
file=sys.stderr,
)
return 2
print(f"[setup] extracting {ZIP_PATH}")
_extract(ZIP_PATH, DATA_DIR)
if not TARGET_FILE.exists():
print(
f"[setup] WARNING: {TARGET_FILE.name} not found after extract. "
f"Listing {DATA_DIR}:",
file=sys.stderr,
)
for p in sorted(DATA_DIR.iterdir()):
print(f" - {p.name}", file=sys.stderr)
return 3
print(f"[setup] ready: {TARGET_FILE}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because one or more lines are too long