#!/usr/bin/env python3 """Build full-run and comparison shards for the canonical PhantomWiki split. Run from the viewer repository root: python3 scripts/build_runs.py The response files may contain multiple datasets. They are streamed line by line, only ``dataset == "phantom_wiki"`` records are retained, and c1/c2 recovery rows overlay their original full-run responses. """ from __future__ import annotations import argparse import csv import json import shutil from pathlib import Path from typing import Any, Iterator ROOT = Path(__file__).resolve().parents[1] DATASET = "phantom_wiki" SCOPE = "size5000_seed1" STRING_LIMIT = 8192 EVENTS_LIMIT_BYTES = 256 * 1024 DEFAULT_EVAL = ROOT / "eval_size5000_seed1.json" DEFAULT_SOURCES = { "c1": { "label": "c1 Closed-book", "response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_full/full_closedbook/named-outputs/response/response" ), "recovery_response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_recover/cb_len4x/named-outputs/response/response" ), "judge": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_judge/cb_phantom_len4x/named-outputs/judged/judged" ), }, "c2": { "label": "c2 With-docs", "response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_full/full_openbook/named-outputs/response/response" ), "recovery_response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_recover/ob_len4x/named-outputs/response/response" ), "judge": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_judge/ob_phantom_len4x/named-outputs/judged/judged" ), }, "c6": { "label": "c6 Agentic-DCI", "response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_full/full_dci/named-outputs/response/response" ), "judge": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_judge/dci_phantom/named-outputs/judged/judged" ), }, "naive": { "label": "Naive-search", "response": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_full/full_naive_phantom/named-outputs/response/response" ), "judge": Path( "/home/azureuser/projects/information-scaffolds/outputs/" "herb_phantom_judge/naive_phantom/named-outputs/judged/judged" ), }, "e2e": { "label": "E2E v3", "response": Path( "/home/azureuser/projects/information-scaffolds/outputs/e2e_runs/" "new-datasets-full-20260711/phantom_wiki/named-outputs/" "predictions/predictions" ), "judge": Path( "/home/azureuser/projects/information-scaffolds/outputs/e2e_runs/" "new-datasets-full-20260711/judges/phantom_wiki/named-outputs/" "judged/judged" ), }, "e2e_rawtext": { "label": "E2E v3 + rawtext", "response": Path( "/tmp/viewer-overlay-phantom/predictions/named-outputs/" "predictions/predictions" ), "judge": Path( "/tmp/viewer-overlay-phantom/evaluated/named-outputs/" "canonical_evaluated/evaluated" ), "judge_format": "f1_csv", "score_label": "Mean answer F1", }, } def stream_jsonl(path: Path) -> Iterator[dict[str, Any]]: with path.open(encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): if not line.strip(): continue try: yield json.loads(line) except json.JSONDecodeError as exc: raise ValueError(f"{path}:{line_number}: {exc}") from exc def load_dataset_rows(path: Path) -> dict[str, dict[str, Any]]: rows: dict[str, dict[str, Any]] = {} for row in stream_jsonl(path): if row.get("dataset") != DATASET: continue qid = str(row.get("qid") or "") if not qid: raise ValueError(f"{path}: PhantomWiki row has no qid") if qid in rows: raise ValueError(f"{path}: duplicate PhantomWiki qid {qid}") rows[qid] = row return rows def load_f1_rows(path: Path) -> dict[str, dict[str, Any]]: rows: dict[str, dict[str, Any]] = {} with path.open(encoding="utf-8", newline="") as handle: for row in csv.DictReader(handle): qid = str(row.get("qid") or "") if not qid: raise ValueError(f"{path}: F1 row has no qid") if qid in rows: raise ValueError(f"{path}: duplicate F1 qid {qid}") rows[qid] = { "qid": qid, "precision": float(row["precision"]), "recall": float(row["recall"]), "f1": float(row["f1"]), "n_pred": int(row["n_pred"]), "n_gold": int(row["n_gold"]), "n_hit": int(row["n_hit"]), } return rows def cap_string(value: str, limit: int = STRING_LIMIT) -> str: if len(value) <= limit: return value marker = f"\n… [truncated from {len(value)} chars]" return value[: max(0, limit - len(marker))] + marker def cap_value(value: Any) -> Any: if isinstance(value, str): return cap_string(value) if isinstance(value, list): return [cap_value(item) for item in value] if isinstance(value, tuple): return [cap_value(item) for item in value] if isinstance(value, dict): return {cap_string(str(key)): cap_value(item) for key, item in value.items()} return value def compact_events(events: Any) -> list[dict[str, Any]]: if not isinstance(events, list): return [] compacted: list[dict[str, Any]] = [] used = 2 original_bytes = len( json.dumps(events, ensure_ascii=False, separators=(",", ":")).encode("utf-8") ) for index, raw in enumerate(events): if not isinstance(raw, dict): event = {"type": "event", "content": cap_value(raw)} else: event = { key: cap_value(raw[key]) for key in ("type", "name", "input", "content") if raw.get(key) is not None } encoded = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode( "utf-8" ) if used + len(encoded) + 1 > EVENTS_LIMIT_BYTES: compacted.append( { "type": "truncated", "content": ( f"{len(events) - index} events omitted; original events " f"were {original_bytes} bytes and the compact limit is " f"{EVENTS_LIMIT_BYTES} bytes." ), } ) break compacted.append(event) used += len(encoded) + 1 return compacted def nonempty_text(value: Any) -> str: return value.strip() if isinstance(value, str) else "" def failure_reason(response: dict[str, Any] | None, answered: bool) -> str | None: if answered: return None if not response: return "missing_response" for key in ("stop_reason", "finish_reason"): if response.get(key): return str(response[key]) for event in reversed(response.get("events") or []): if isinstance(event, dict) and event.get("type") == "error": return nonempty_text(event.get("content")) or "error" attempts = response.get("attempt_log") or [] for attempt in reversed(attempts): if not isinstance(attempt, dict): continue if attempt.get("exc"): exc = str(attempt["exc"]).split(":", 1)[0] return f"exc:{exc}" if attempt.get("outcome") and attempt.get("outcome") != "success": return str(attempt["outcome"]) return "unanswered" def normalized_tokens(response: dict[str, Any] | None) -> dict[str, Any]: if not response: return {} tokens = response.get("tokens") if not isinstance(tokens, dict): tokens = response.get("usage") return cap_value(tokens) if isinstance(tokens, dict) else {} def normalize_record( gold: dict[str, Any], response: dict[str, Any] | None, judge: dict[str, Any] | None, ) -> dict[str, Any]: prediction = nonempty_text((response or {}).get("answer")) answered = bool(prediction) is_f1 = isinstance((judge or {}).get("f1"), float) parsed = (judge or {}).get("parsed") parsed = parsed if isinstance(parsed, dict) else {} score = float(judge["f1"]) if is_f1 else None correct = bool( answered and ( (score == 1.0 if score is not None else parsed.get("correct") is True) ) ) tool_counts = (response or {}).get("tool_call_counts") tool_counts = tool_counts if isinstance(tool_counts, dict) else {} metadata = gold.get("meta") if isinstance(gold.get("meta"), dict) else {} return cap_value( { "qid": gold["id"], "question": gold.get("question") or "", "gold": gold.get("answer") or [], "metadata": { "difficulty": metadata.get("Difficulty"), "type": metadata.get("Type"), "template": gold.get("template") or "", "prolog": gold.get("prolog") or [], "prolog_answer": gold.get("prolog_answer") or "", "supporting_titles": gold.get("supporting_titles") or [], }, "prediction": prediction, "extracted_judge_answer": parsed.get("extracted_final_answer"), "answered": answered, "correct": correct, "score": score, "score_kind": "answer_f1" if score is not None else "binary", "score_details": ( { key: judge[key] for key in ("precision", "recall", "f1", "n_pred", "n_gold", "n_hit") } if score is not None else None ), "judge": { "text": ( ( f"precision={judge['precision']:.4f}, " f"recall={judge['recall']:.4f}, f1={judge['f1']:.4f}" ) if score is not None else (judge or {}).get("judge_text") ), "confidence": parsed.get("confidence"), "parse_error": parsed.get("parse_error"), "model": (judge or {}).get("judge_model"), "finish_reason": (judge or {}).get("finish_reason"), }, "stop_reason": (response or {}).get("stop_reason"), "finish_reason": (response or {}).get("finish_reason"), "finish_reasons": (response or {}).get("finish_reasons") or [], "failure_reason": failure_reason(response, answered), "tokens": normalized_tokens(response), "turns": (response or {}).get("turns"), "tool_call_counts": tool_counts, "tool_calls": sum( value for value in tool_counts.values() if isinstance(value, int) ), "model": (response or {}).get("model"), "mode": (response or {}).get("mode"), "events": compact_events((response or {}).get("events")), } ) def status_for(record: dict[str, Any]) -> str: if not record["answered"]: return "missing" if isinstance(record.get("score"), (int, float)) and 0 < record["score"] < 1: return "partial" return "correct" if record["correct"] else "incorrect" def run_summary(record: dict[str, Any]) -> dict[str, Any]: return { "qid": record["qid"], "question": record["question"], "gold": record["gold"], "answered": record["answered"], "correct": record["correct"], "score": record.get("score"), "score_kind": record.get("score_kind"), "status": status_for(record), "failure_reason": record["failure_reason"], } def compare_projection(record: dict[str, Any], slot: str) -> dict[str, Any]: return { key: record[key] for key in ( "prediction", "extracted_judge_answer", "answered", "correct", "score", "score_kind", "score_details", "judge", "stop_reason", "finish_reason", "finish_reasons", "failure_reason", "tokens", "turns", "tool_call_counts", "tool_calls", "model", "mode", ) } | {"events_path": f"runs/{slot}/records/{record['qid']}.json"} def write_json(path: Path, value: Any, *, indent: int | None = None) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: json.dump(value, handle, ensure_ascii=False, indent=indent) def clear_outputs(output_root: Path, compare_root: Path) -> None: if output_root.exists(): shutil.rmtree(output_root) if compare_root.exists(): shutil.rmtree(compare_root) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--eval-file", type=Path, default=DEFAULT_EVAL) parser.add_argument("--output-root", type=Path, default=ROOT / "runs") parser.add_argument("--compare-root", type=Path, default=ROOT / "compare") for slot, source in DEFAULT_SOURCES.items(): parser.add_argument( f"--{slot}-response", type=Path, default=source["response"] ) if source.get("recovery_response"): parser.add_argument( f"--{slot}-recovery-response", type=Path, default=source["recovery_response"], ) parser.add_argument(f"--{slot}-judge", type=Path, default=source["judge"]) return parser.parse_args() def main() -> None: args = parse_args() gold_rows = json.loads(args.eval_file.read_text(encoding="utf-8")) if len(gold_rows) != 500: raise ValueError(f"{args.eval_file}: expected 500 eval rows, got {len(gold_rows)}") qids = [str(row["id"]) for row in gold_rows] if len(qids) != len(set(qids)): raise ValueError(f"{args.eval_file}: duplicate eval qids") qid_set = set(qids) clear_outputs(args.output_root, args.compare_root) records_by_slot: dict[str, dict[str, dict[str, Any]]] = {} manifest_slots = [] for slot, source in DEFAULT_SOURCES.items(): response_path = getattr(args, f"{slot}_response") recovery_response_path = getattr( args, f"{slot}_recovery_response", None ) judge_path = getattr(args, f"{slot}_judge") responses = load_dataset_rows(response_path) if recovery_response_path: responses.update(load_dataset_rows(recovery_response_path)) judges = ( load_f1_rows(judge_path) if source.get("judge_format") == "f1_csv" else load_dataset_rows(judge_path) ) extra_response_qids = set(responses) - qid_set if extra_response_qids: raise ValueError( f"{slot}: response file contains " f"{len(extra_response_qids)} qids outside eval" ) if not set(judges).issubset(qid_set): raise ValueError(f"{slot}: judge file contains qids outside eval") slot_records: dict[str, dict[str, Any]] = {} summaries = [] records_dir = args.output_root / slot / "records" for gold in gold_rows: qid = str(gold["id"]) record = normalize_record(gold, responses.get(qid), judges.get(qid)) slot_records[qid] = record summaries.append(run_summary(record)) write_json(records_dir / f"{qid}.json", record) answered = sum(record["answered"] for record in slot_records.values()) correct = sum(record["correct"] for record in slot_records.values()) canonical_score = sum( record["score"] if isinstance(record.get("score"), (int, float)) else float(record["correct"]) for record in slot_records.values() ) / len(qids) index = { "slot": slot, "label": source["label"], "scope": SCOPE, "total": len(qids), "answered": answered, "correct": correct, "coverage": answered / len(qids), "score": canonical_score, "score_label": source.get("score_label", "Canonical score"), "score_detail": ( f"{correct} perfect · " f"{sum((record.get('score') or 0) > 0 for record in slot_records.values())} " "with answer overlap" if source.get("judge_format") == "f1_csv" else f"{correct} correct" ), "records": summaries, } write_json(args.output_root / slot / "index.json", index) records_by_slot[slot] = slot_records manifest_slots.append( { key: index[key] for key in ( "slot", "label", "scope", "total", "answered", "correct", "coverage", "score", "score_label", "score_detail", ) } | { "index": f"runs/{slot}/index.json", "records": f"runs/{slot}/records", "response_source": str(response_path), "recovery_response_source": ( str(recovery_response_path) if recovery_response_path else None ), "judge_source": str(judge_path), } ) print( f"{slot}: score={canonical_score:.2%}, perfect/correct={correct}/{len(qids)}, " f"answered={answered}/{len(qids)} ({answered / len(qids):.2%})" ) compare_summaries = [] for gold in gold_rows: qid = str(gold["id"]) run_records = { slot: records_by_slot[slot][qid] for slot in DEFAULT_SOURCES } correct_flags = [record["correct"] for record in run_records.values()] answered_flags = [record["answered"] for record in run_records.values()] summary = { "qid": qid, "question": gold.get("question") or "", "correct": { slot: record["correct"] for slot, record in run_records.items() }, "answered": { slot: record["answered"] for slot, record in run_records.items() }, "correct_count": sum(correct_flags), "answered_count": sum(answered_flags), "disagreement": len(set(correct_flags)) > 1, "any_missing": not all(answered_flags), "only_e2e_correct": ( run_records["e2e"]["correct"] and all( not record["correct"] for slot, record in run_records.items() if slot != "e2e" ) ), } compare_summaries.append(summary) compare_record = { "qid": qid, "question": gold.get("question") or "", "gold": gold.get("answer") or [], "metadata": run_records["c1"]["metadata"], "runs": { slot: compare_projection(record, slot) for slot, record in run_records.items() }, } write_json(args.compare_root / "records" / f"{qid}.json", compare_record) write_json( args.compare_root / "index.json", { "scope": SCOPE, "total": len(qids), "slots": list(DEFAULT_SOURCES), "records": compare_summaries, }, ) write_json( args.output_root / "manifest.json", { "dataset": DATASET, "scope": SCOPE, "scope_label": "depth_20_size_5000_seed_1", "eval_source": str(args.eval_file), "total": len(qids), "slots": manifest_slots, "compare_index": "compare/index.json", "scoring": ( "Run-specific canonical score over all 500 eval qids; " "missing or unanswered is zero" ), }, indent=2, ) print(f"compare: {len(compare_summaries)} records") if __name__ == "__main__": main()