#!/usr/bin/env python3 """Build monaco_dev standalone-baseline trajectory shards for the viewer. Reads a `response` JSONL (each row carries the full agent loop in `events` for agentic cells, or a single `answer` string for non-agentic cells 1-3), joins against the matching AML LLM-judge output (with `--deterministic-extract` = the canonical MoNaCo scoring protocol), and writes one JSON shard per qid plus a wrapped `index.json` with summary stats. Divergences from the wiki_opentable variant: - Scoring column is `judge_score` (LLM judge output), not deterministic F1. - We surface `deterministic_exact_answer.items` from the judge as `pred_items` — this is the same list-extraction protocol the judge uses when computing precision/recall. - Accepts both `dataset == "monaco"` and `dataset == "monaco_dev"` on predictions (submitters stamp both flavors). Per-event tool_result content is truncated at 8 KB to keep each shard browser-friendly. Usage: # Baseline A / cell 4 → "structure per q" python scripts/build_trajectories.py \\ --predictions /home/azureuser/projects/information-scaffolds/outputs/monaco_dev/baselines/cell4_agentic_a/named-outputs/response/response \\ --judged /home/azureuser/projects/information-scaffolds/outputs/monaco_dev/judges/c4_agentic_a/named-outputs/judged/judged \\ --out trajectories \\ --label "structure per q (monaco_dev · Baseline A · cell4_agentic_a)" # Baseline B / cell 5 → "structure per ds" python scripts/build_trajectories.py \\ --predictions /home/azureuser/projects/information-scaffolds/outputs/monaco_dev/baselines/cell5_agentic_b/named-outputs/response/response \\ --judged /home/azureuser/projects/information-scaffolds/outputs/monaco_dev/judges/c5_agentic_b/named-outputs/judged/judged \\ --out trajectories_corpus \\ --label "structure per ds (monaco_dev · Baseline B · cell5_agentic_b)" # Baseline C / cell 6 → "DCI" (use the good v2 rerun, not the broken v1) python scripts/build_trajectories.py \\ --predictions /home/azureuser/projects/information-scaffolds/outputs/cell6_v2_reruns/monaco_dev_cell6_v2/named-outputs/response/response \\ --judged /home/azureuser/projects/information-scaffolds/outputs/monaco_dev/judges/c6_agentic_c_v2/named-outputs/judged/judged \\ --out trajectories_rawtext \\ --label "DCI (monaco_dev · Baseline C · cell6_agentic_c v2)" # Cells 1-3 (single-turn, no `events`) also work — no trajectory section # is rendered but scores + full model answer show up. """ from __future__ import annotations import argparse import json import shutil import sys from pathlib import Path from typing import Any, Dict, Iterable, List _CONTENT_CAP = 8000 def _truncate_content(s: str): if s is None: return "", False if len(s) <= _CONTENT_CAP: return s, False head = s[: _CONTENT_CAP // 2] tail = s[-_CONTENT_CAP // 2 :] return f"{head}\n\n… [truncated {len(s) - _CONTENT_CAP} chars] …\n\n{tail}", True def project_event(ev: Dict[str, Any]) -> Dict[str, Any]: content, trunc = _truncate_content(ev.get("content")) out = { "type": ev.get("type"), "name": ev.get("name"), "input": ev.get("input"), "content": content, } if trunc: out["truncated"] = True return out # ─── I/O ────────────────────────────────────────────────────────────────────── def load_predictions(path: Path) -> Dict[str, Dict[str, Any]]: out: Dict[str, Dict[str, Any]] = {} with path.open() as f: for line in f: d = json.loads(line) if d.get("dataset") not in ("monaco", "monaco_dev"): continue out[str(d["qid"])] = d return out def load_judged(path: Path) -> Dict[str, Dict[str, Any]]: """Read judged JSONL: one row per qid with `parsed.judge_score` etc.""" out: Dict[str, Dict[str, Any]] = {} with path.open() as f: for line in f: d = json.loads(line) if d.get("dataset") not in ("monaco", "monaco_dev"): continue out[str(d["qid"])] = d return out def load_gold(path: Path) -> Dict[str, Dict[str, Any]]: """Load gold from unified JSONL or unified/records/*.json.""" out: Dict[str, Dict[str, Any]] = {} if path.is_dir(): for shard in sorted(path.glob("*.json")): with shard.open() as f: d = json.load(f) qid = str(d.get("qid") or shard.stem) out[qid] = d return out with path.open() as f: for line in f: line = line.strip() if not line: continue d = json.loads(line) out[str(d["qid"])] = d return out # ─── Build ──────────────────────────────────────────────────────────────────── def build_record( qid: str, pred: Dict[str, Any], judged: Dict[str, Any] | None, gold_row: Dict[str, Any], ) -> Dict[str, Any]: gold_items = list(gold_row.get("answers") or gold_row.get("answer_list") or []) j = (judged or {}).get("parsed") or {} det = (judged or {}).get("deterministic_exact_answer") or {} pred_items = list(det.get("items") or []) judge_score = float(j.get("judge_score") or 0.0) correct = 1 if judge_score >= 0.99 else 0 events = [project_event(ev) for ev in (pred.get("events") or [])] # cells 1-3 are single-turn — no events, no stop_reason, no turns/tool_calls. n_turns = pred.get("turns") stop_reason = pred.get("stop_reason") or ( pred.get("finish_reason") if not events else None ) return { "qid": qid, "dataset": pred.get("dataset") or "monaco_dev", "dataset_origin": pred.get("dataset"), "question": pred.get("question") or gold_row.get("question"), "gold_answers": gold_items, "model": pred.get("model"), "mode": pred.get("mode"), "system_prompt_file": pred.get("system_prompt_file"), "system_prompt": pred.get("system_prompt"), "user_prompt": pred.get("user_prompt"), "max_turns": pred.get("max_turns"), "max_completion_tokens": pred.get("max_completion_tokens"), "reasoning_effort": pred.get("reasoning_effort"), "stop_reason": stop_reason, "finish_reason": pred.get("finish_reason"), "error": pred.get("error"), "finish_reasons": pred.get("finish_reasons"), "n_turns": n_turns, "tool_call_counts": pred.get("tool_call_counts"), "tokens": pred.get("tokens") or pred.get("usage"), "latency_ms": pred.get("latency_ms"), "attempts": pred.get("attempts"), "timeout_retries": pred.get("timeout_retries"), "model_answer": pred.get("answer"), "pred_items": pred_items, "metrics": { "judge_score": round(judge_score, 4), "correct": correct, "precision": round(float(j.get("precision") or 0.0), 4), "recall": round(float(j.get("recall") or 0.0), 4), "extracted_final_answer": j.get("extracted_final_answer"), }, "judge_model": (judged or {}).get("judge_model"), "events": events, } def build_index_row(rec: Dict[str, Any]) -> Dict[str, Any]: m = rec["metrics"] return { "qid": rec["qid"], "question": rec["question"], "n_turns": rec["n_turns"], "stop_reason": rec["stop_reason"], "has_error": bool(rec["error"]) or (m["judge_score"] == 0 and not rec["pred_items"] and not rec["model_answer"]), "judge_score": m["judge_score"], "correct": m["correct"], "precision": m["precision"], "recall": m["recall"], "gold_answers_length": len(rec["gold_answers"]), } def _counter(it: Iterable[Any]) -> Dict[str, int]: out: Dict[str, int] = {} for x in it: k = str(x) out[k] = out.get(k, 0) + 1 return dict(sorted(out.items(), key=lambda kv: -kv[1])) def main() -> int: ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("--predictions", required=True, type=Path, help="Raw response JSONL from the AML baseline job") ap.add_argument("--judged", required=True, type=Path, help="Judged JSONL from submit_judge_responses_aml.py " "(with --deterministic-extract, auto-on for monaco_dev)") ap.add_argument( "--gold", type=Path, default=Path(__file__).resolve().parent.parent / "unified" / "records", help="Gold source. Defaults to the local unified/records/ dir built by " "scripts/build_unified.py; also accepts the raw unified jsonl.", ) ap.add_argument("--out", required=True, type=Path, help="Output dir (e.g. trajectories/ or trajectories_corpus/)") ap.add_argument("--label", default="standalone agentic run", help="Human label for this run; written to index meta") args = ap.parse_args() preds = load_predictions(args.predictions) judged = load_judged(args.judged) gold = load_gold(args.gold) common = sorted(set(preds) & set(gold)) only_judged_missing = [q for q in common if q not in judged] print(f"qids: predictions={len(preds)}, judged={len(judged)}, gold={len(gold)}, common={len(common)}, missing_judge={len(only_judged_missing)}") if not common: print("ERROR: no overlap between predictions and gold", file=sys.stderr) return 1 out_dir = args.out out_dir.mkdir(parents=True, exist_ok=True) rec_dir = out_dir / "records" if rec_dir.exists(): shutil.rmtree(rec_dir) rec_dir.mkdir(parents=True) index_rows: List[Dict[str, Any]] = [] sum_j = sum_correct = sum_p = sum_r = 0.0 for qid in common: rec = build_record(qid, preds[qid], judged.get(qid), gold[qid]) (rec_dir / f"{qid}.json").write_text(json.dumps(rec, ensure_ascii=False)) index_rows.append(build_index_row(rec)) sum_j += rec["metrics"]["judge_score"] sum_correct += rec["metrics"]["correct"] sum_p += rec["metrics"]["precision"] sum_r += rec["metrics"]["recall"] n = len(common) summary = { "label": args.label, "n": n, "mean_judge_score": round(sum_j / n, 4), "mean_correct": round(sum_correct / n, 4), "mean_precision": round(sum_p / n, 4), "mean_recall": round(sum_r / n, 4), "fraction_error": round( sum(1 for r in index_rows if r["has_error"]) / n, 4 ), "stop_reason_counts": _counter(r["stop_reason"] for r in index_rows), } index_payload = {"meta": summary, "rows": index_rows} (out_dir / "index.json").write_text(json.dumps(index_payload, ensure_ascii=False)) print(f"\n✓ Wrote {out_dir}/index.json + {n} record shards") print(f" mean judge_score = {summary['mean_judge_score']*100:.2f}") print(f" mean correct = {summary['mean_correct']*100:.2f}") print(f" mean precision = {summary['mean_precision']*100:.2f}") print(f" mean recall = {summary['mean_recall']*100:.2f}") print(f" error rate = {summary['fraction_error']*100:.2f}") return 0 if __name__ == "__main__": sys.exit(main())