minhhien0811's picture
Publish code and Fair Protocol 500 results
ec1ecb9 verified
Raw
History Blame Contribute Delete
20.3 kB
"""CLI for corpus preparation, retrieval-only evaluation, reports, and trace replay."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
import httpx
import typer
from . import __version__
from .config import AppConfig, load_config, load_yaml_mapping
from .data.combined import sha256_file, write_benchmark_manifests, write_combined_corpus
from .data.corpus import write_documents
from .data.hotpotqa import HotpotQAAdapter
from .data.musique import MuSiQueAdapter
from .data.schemas import BenchmarkExample
from .data.twowiki import TwoWikiAdapter
from .evaluation.aggregate import aggregate_records, latest_records
from .evaluation.report import write_report
from .evaluation.runner import BenchmarkRunner
from .exceptions import ConfigurationError
from .logging import configure_logging, get_logger, redact
from .retrieval import DenseRetriever, DocumentStore, SparseRetriever
from .tracing.replay import frozen_replay
app = typer.Typer(
name="agentic-search",
help="Retrieval-only benchmark: FIXED vs Agentic vs iterative Search-as-Code.",
invoke_without_command=True,
no_args_is_help=False,
)
data_app = typer.Typer(help="Collect all benchmarks and build a shared offline corpus.", no_args_is_help=True)
index_app = typer.Typer(help="Build auditable sparse or dense index metadata.", no_args_is_help=True)
run_app = typer.Typer(help="Run one retrieval query and show retrieved context.", no_args_is_help=True)
benchmark_app = typer.Typer(help="Run a resumable retrieval-only benchmark.", no_args_is_help=True)
report_app = typer.Typer(help="Aggregate retrieval-only result records.", no_args_is_help=True)
replay_app = typer.Typer(help="Read a frozen trajectory without model or index calls.", no_args_is_help=True)
ui_app = typer.Typer(help="Terminal trace viewer for inspectable benchmark demos.", no_args_is_help=True)
config_app = typer.Typer(help="Inspect and validate project configuration.", no_args_is_help=True)
DEFAULT_HOTPOT_PARQUET_URL = (
"https://huggingface.co/datasets/hotpotqa/hotpot_qa/resolve/"
"refs%2Fconvert%2Fparquet/distractor/validation/0000.parquet"
)
DEFAULT_TWOWIKI_PARQUET_URL = (
"https://huggingface.co/datasets/framolfese/2WikiMultihopQA/resolve/main/"
"data/validation-00000-of-00001.parquet"
)
DEFAULT_MUSIQUE_JSON_URL = "https://huggingface.co/datasets/fladhak/musique/resolve/main/dev.json"
def _public_config(config: AppConfig) -> dict[str, object]:
output = config.model_dump(mode="json")
output["model"]["base_url_configured"] = bool(config.resolved_model_base_urls())
output["model"]["replica_count"] = len(config.resolved_model_base_urls())
output["model"]["api_key_configured"] = config.resolved_model_api_key() is not None
return redact(output)
def _examples(path: Path, max_examples: int) -> list[BenchmarkExample]:
if path.suffix == ".jsonl":
examples = [
BenchmarkExample.model_validate_json(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
else:
examples = list(HotpotQAAdapter(path).load_examples())
return examples[:max_examples] if max_examples > 0 else examples
def _download_stream(url: str, output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(output.suffix + ".tmp")
with httpx.stream("GET", url, timeout=300, follow_redirects=True) as response:
response.raise_for_status()
with temporary.open("wb") as destination:
for chunk in response.iter_bytes():
destination.write(chunk)
temporary.replace(output)
@data_app.command("download-hotpot")
def download_hotpot(
output: Annotated[Path, typer.Option("--output", help="Destination HotpotQA JSON file.")] = Path("data/raw/hotpotqa/dev_distractor.json"),
parquet_url: Annotated[str, typer.Option("--parquet-url", help="Hugging Face validation parquet URL.")] = DEFAULT_HOTPOT_PARQUET_URL,
limit: Annotated[int, typer.Option("--limit", help="0 fetches the complete validation split.")] = 0,
) -> None:
"""Download the official HF HotpotQA distractor validation split as original-style JSON."""
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
parquet_path = output.with_suffix(".parquet.tmp")
with httpx.stream("GET", parquet_url, timeout=180, follow_redirects=True) as response:
response.raise_for_status()
with parquet_path.open("wb") as destination:
for chunk in response.iter_bytes():
destination.write(chunk)
try:
import pyarrow.parquet as parquet
except ImportError as error:
raise RuntimeError("pyarrow is required to normalize the official HotpotQA parquet") from error
rows = parquet.read_table(parquet_path).to_pylist()
parquet_path.unlink(missing_ok=True)
if limit:
rows = rows[:limit]
records = [
{
"_id": row["id"],
"question": row["question"],
"answer": row["answer"],
"type": row.get("type"),
"level": row.get("level"),
"supporting_facts": [
(title, sent_id)
for title, sent_id in zip(
row["supporting_facts"]["title"], row["supporting_facts"]["sent_id"], strict=True
)
],
"context": [
(title, sentences)
for title, sentences in zip(
row["context"]["title"], row["context"]["sentences"], strict=True
)
],
}
for row in rows
]
temporary = output.with_suffix(output.suffix + ".tmp")
temporary.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
temporary.replace(output)
typer.echo(json.dumps({"output": str(output), "examples": len(records), "parquet_url": parquet_url}))
@data_app.command("build-hotpot-corpus")
def build_hotpot_corpus(
source: Annotated[Path, typer.Option("--source", help="Raw HotpotQA JSON file.")],
output: Annotated[Path, typer.Option("--output", help="Corpus JSONL output.")] = Path("data/processed/hotpotqa_context_union.jsonl"),
) -> None:
"""Build a stable corpus from the union of offline HotpotQA contexts."""
manifest = write_documents(HotpotQAAdapter(source).build_corpus(), output.resolve())
manifest_path = output.resolve().with_suffix(".manifest.json")
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
typer.echo(json.dumps({**manifest, "manifest": str(manifest_path)}, ensure_ascii=False))
@data_app.command("download-2wiki")
def download_twowiki(
output: Annotated[Path, typer.Option("--output", help="2Wiki validation parquet destination.")] = Path(
"data/raw/2wikimultihopqa/validation.parquet"
),
parquet_url: Annotated[str, typer.Option("--parquet-url")] = DEFAULT_TWOWIKI_PARQUET_URL,
) -> None:
"""Download the HotpotQA-schema-compatible 2WikiMultiHopQA validation split."""
output = output.resolve()
_download_stream(parquet_url, output)
typer.echo(json.dumps({"output": str(output), "sha256": sha256_file(output), "url": parquet_url}))
@data_app.command("download-musique")
def download_musique(
output: Annotated[Path, typer.Option("--output", help="MuSiQue dev JSON destination.")] = Path(
"data/raw/musique/dev.json"
),
json_url: Annotated[str, typer.Option("--json-url")] = DEFAULT_MUSIQUE_JSON_URL,
) -> None:
"""Download the MuSiQue development set with paragraph-level supporting labels."""
output = output.resolve()
_download_stream(json_url, output)
typer.echo(json.dumps({"output": str(output), "sha256": sha256_file(output), "url": json_url}))
@data_app.command("download-all")
def download_all() -> None:
"""Collect every retrieval benchmark source used by the combined corpus."""
hotpot = Path("data/raw/hotpotqa/dev_distractor.json")
if not hotpot.is_file():
download_hotpot(output=hotpot)
twowiki = Path("data/raw/2wikimultihopqa/validation.parquet")
if not twowiki.is_file():
download_twowiki(output=twowiki)
musique = Path("data/raw/musique/dev.json")
if not musique.is_file():
download_musique(output=musique)
typer.echo(
json.dumps(
{
"hotpotqa": str(hotpot.resolve()),
"2wikimultihopqa": str(twowiki.resolve()),
"musique": str(musique.resolve()),
}
)
)
@data_app.command("build-combined-corpus")
def build_combined_corpus(
hotpot: Annotated[Path, typer.Option("--hotpot")]=Path("data/raw/hotpotqa/dev_distractor.json"),
twowiki: Annotated[Path, typer.Option("--twowiki")]=Path("data/raw/2wikimultihopqa/validation.parquet"),
musique: Annotated[Path, typer.Option("--musique")]=Path("data/raw/musique/dev.json"),
output: Annotated[Path, typer.Option("--output")]=Path("data/processed/multihop_context_union.jsonl"),
) -> None:
"""Build one global deduplicated corpus from all three benchmark context collections."""
inputs = {"hotpotqa": hotpot.resolve(), "2wikimultihopqa": twowiki.resolve(), "musique": musique.resolve()}
missing = [str(path) for path in inputs.values() if not path.is_file()]
if missing:
raise typer.BadParameter(f"missing benchmark sources: {', '.join(missing)}")
manifest = write_combined_corpus(
[HotpotQAAdapter(inputs["hotpotqa"]), TwoWikiAdapter(inputs["2wikimultihopqa"]), MuSiQueAdapter(inputs["musique"])],
output.resolve(),
)
manifest["source_checksums"] = {dataset: sha256_file(path) for dataset, path in inputs.items()}
manifest_path = output.resolve().with_suffix(".manifest.json")
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
typer.echo(json.dumps({**manifest, "manifest": str(manifest_path)}, ensure_ascii=False))
@data_app.command("build-benchmark-manifests")
def build_benchmark_manifests(
hotpot: Annotated[Path, typer.Option("--hotpot")] = Path("data/raw/hotpotqa/dev_distractor.json"),
twowiki: Annotated[Path, typer.Option("--twowiki")] = Path("data/raw/2wikimultihopqa/validation.parquet"),
musique: Annotated[Path, typer.Option("--musique")] = Path("data/raw/musique/dev.json"),
output_dir: Annotated[Path, typer.Option("--output-dir")] = Path("data/processed/benchmarks"),
) -> None:
"""Export source-partitioned questions and gold IDs for later retrieval-only evaluation."""
inputs = {"hotpotqa": hotpot.resolve(), "2wikimultihopqa": twowiki.resolve(), "musique": musique.resolve()}
missing = [str(path) for path in inputs.values() if not path.is_file()]
if missing:
raise typer.BadParameter(f"missing benchmark sources: {', '.join(missing)}")
manifest = write_benchmark_manifests(
[
HotpotQAAdapter(inputs["hotpotqa"]),
TwoWikiAdapter(inputs["2wikimultihopqa"]),
MuSiQueAdapter(inputs["musique"]),
],
output_dir.resolve(),
)
manifest["source_checksums"] = {dataset: sha256_file(path) for dataset, path in inputs.items()}
manifest_path = output_dir.resolve() / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
typer.echo(json.dumps(manifest, ensure_ascii=False))
@index_app.command("sparse")
def build_sparse_index(
corpus: Annotated[Path, typer.Option("--corpus", help="Corpus JSONL path.")],
output: Annotated[Path, typer.Option("--output", help="Sparse index metadata JSON.")] = Path("data/indices/sparse.metadata.json"),
) -> None:
"""Build and audit the portable BM25 index statistics."""
retriever = SparseRetriever(DocumentStore.from_jsonl(corpus).all())
retriever.save_metadata(output.resolve())
typer.echo(str(output.resolve()))
@index_app.command("dense")
def build_dense_index(
corpus: Annotated[Path, typer.Option("--corpus", help="Corpus JSONL path.")],
index_dir: Annotated[Path | None, typer.Option("--index-dir", help="Persistent BGE/FAISS index directory.")] = None,
config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None,
) -> None:
"""Encode every corpus passage with BGE and persist FAISS, vectors, and doc-id mapping."""
config = load_config(config_path)
documents = DocumentStore.from_jsonl(corpus).all()
if config.retrieval.dense.backend != "sentence_transformers":
raise typer.BadParameter("index dense requires dense.backend=sentence_transformers")
output = (index_dir or config.retrieval.dense.index_dir).resolve()
manifest = DenseRetriever.build_persisted_index(
documents,
model_name=config.retrieval.dense.model_name,
device=config.retrieval.dense.device,
batch_size=config.retrieval.dense.batch_size,
output_dir=output,
)
typer.echo(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
@run_app.command("query")
def run_query(
question: Annotated[str, typer.Argument(help="Question to retrieve evidence for.")],
corpus: Annotated[Path, typer.Option("--corpus")],
system: Annotated[str, typer.Option("--system", help="fixed, agentic, sac, or a controller baseline.")] = "fixed",
config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None,
) -> None:
"""Run one system and print only its retrieved contexts plus cost/latency."""
runner = BenchmarkRunner(load_config(config_path), corpus_path=corpus.resolve())
try:
record = runner.run_one(BenchmarkExample(example_id="adhoc", question=question, answers=[]), system)
typer.echo(json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True))
finally:
runner.close()
@benchmark_app.command("run")
def benchmark_run(
corpus: Annotated[Path, typer.Option("--corpus", help="Offline corpus JSONL.")],
dataset: Annotated[
Path | None,
typer.Option("--dataset", help="Legacy raw HotpotQA JSON input; prefer --examples."),
] = None,
examples: Annotated[
Path | None,
typer.Option("--examples", help="Exported source-partitioned BenchmarkExample JSONL."),
] = None,
experiment: Annotated[Path, typer.Option("--experiment", "-e", help="Experiment YAML.")] = Path("configs/experiments/smoke.yaml"),
config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None,
run_id: Annotated[str | None, typer.Option("--run-id", help="Resume this run ID when supplied.")] = None,
systems_override: Annotated[
str | None,
typer.Option("--systems", help="Comma-separated systems; overrides the experiment matrix."),
] = None,
max_examples: Annotated[int | None, typer.Option("--max-examples")] = None,
concurrency: Annotated[int | None, typer.Option("--concurrency")] = None,
warmup_examples: Annotated[
int,
typer.Option("--warmup-examples", help="Untimed examples per system used only to warm models."),
] = 0,
use_cache: Annotated[
bool | None,
typer.Option("--cache/--no-cache", help="Override configured cache behavior for this run."),
] = None,
force: Annotated[bool, typer.Option("--force", help="Re-run completed example/system pairs.")] = False,
) -> None:
"""Evaluate context coverage, retrieval evidence, latency, and token cost only."""
if (dataset is None) == (examples is None):
raise typer.BadParameter("provide exactly one of --dataset or --examples")
config = load_config(config_path)
if use_cache is not None:
config = config.model_copy(
update={"cache": config.cache.model_copy(update={"enabled": use_cache})}
)
experiment_values = load_yaml_mapping(experiment)
settings = experiment_values.get("experiment", {})
systems = (
[item.strip() for item in systems_override.split(",") if item.strip()]
if systems_override is not None
else [str(item) for item in settings.get("systems", ["fixed", "agentic", "sac"])]
)
if not systems:
raise typer.BadParameter("--systems must name at least one system")
limit = max_examples if max_examples is not None else int(settings.get("max_examples", 20))
workers = concurrency if concurrency is not None else int(settings.get("concurrency", config.model.request_concurrency))
runner = BenchmarkRunner(config, corpus_path=corpus.resolve(), run_id=run_id)
try:
source = (examples or dataset).resolve()
examples_for_run = _examples(source, limit)
runner.warmup(examples_for_run, systems, count=warmup_examples)
records_path, records = runner.run(examples_for_run, systems, concurrency=workers, force=force)
report_rows = aggregate_records(
records,
bootstrap_samples=config.evaluation.bootstrap_samples,
seed=config.project.seed,
)
report_dir = config.project.artifacts_dir / "reports" / runner.run_id
json_path, csv_path = write_report(report_rows, report_dir)
typer.echo(json.dumps({"run_id": runner.run_id, "records": str(records_path), "report_json": str(json_path), "report_csv": str(csv_path)}, indent=2))
finally:
runner.close()
@report_app.command("build")
def build_report(
records: Annotated[Path, typer.Option("--records", help="results.jsonl path.")],
output_dir: Annotated[Path, typer.Option("--output-dir")],
config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None,
) -> None:
"""Rebuild a report from recorded results without rerunning retrieval or model calls."""
config = load_config(config_path)
rows = latest_records(
[json.loads(line) for line in records.read_text(encoding="utf-8").splitlines() if line]
)
report = aggregate_records(rows, bootstrap_samples=config.evaluation.bootstrap_samples, seed=config.project.seed)
json_path, csv_path = write_report(report, output_dir)
typer.echo(json.dumps({"json": str(json_path), "csv": str(csv_path)}))
@replay_app.command("show")
def replay_show(trace: Annotated[Path, typer.Option("--trace", help="Leaf trace directory.")]) -> None:
"""Display persisted events exactly as recorded, without live model calls."""
typer.echo(json.dumps(frozen_replay(trace), ensure_ascii=False, indent=2))
@ui_app.command("trace")
def ui_trace(trace: Annotated[Path, typer.Option("--trace", help="Leaf trace directory.")]) -> None:
"""Render a concise terminal trace viewer for demos and failure inspection."""
for event in frozen_replay(trace):
typer.echo(
f"{event['step_id']} {event['component']}:{event['event_type']} "
f"latency_ms={event['latency_ms']:.2f}"
)
@config_app.command("show")
def show_config(
config_path: Annotated[Path | None, typer.Option("--config", "-c", help="YAML config path.")] = None,
) -> None:
"""Print resolved configuration without exposing secret values."""
try:
typer.echo(json.dumps(_public_config(load_config(config_path)), ensure_ascii=False, indent=2, sort_keys=True))
except ConfigurationError as error:
typer.echo(str(error), err=True)
raise typer.Exit(code=2) from error
@app.callback()
def main_callback(
context: typer.Context,
version: Annotated[bool, typer.Option("--version", help="Print package version.")] = False,
log_level: Annotated[str | None, typer.Option("--log-level", help="Logging severity.")] = None,
) -> None:
configure_logging(log_level)
if version:
typer.echo(__version__)
raise typer.Exit()
if context.invoked_subcommand is None:
typer.echo(context.get_help())
app.add_typer(data_app, name="data")
app.add_typer(index_app, name="index")
app.add_typer(run_app, name="run")
app.add_typer(benchmark_app, name="benchmark")
app.add_typer(report_app, name="report")
app.add_typer(replay_app, name="replay")
app.add_typer(ui_app, name="ui")
app.add_typer(config_app, name="config")
def main() -> None:
get_logger("cli").debug("starting CLI", extra={"fields": {"version": __version__}})
app()