#!/usr/bin/env python3 import argparse import json from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parent.parent DEFAULT_BLIND_PACK = ROOT / "reports" / "output_blind_review_pack.json" DEFAULT_ANSWER_KEY = ROOT / "reports" / "output_blind_answer_key.json" DEFAULT_DECISIONS = ROOT / "reports" / "output_review_decisions.json" DEFAULT_OUTPUT_JSON = ROOT / "reports" / "output_review_adjudication.json" DEFAULT_OUTPUT_MD = ROOT / "reports" / "output_review_adjudication.md" def display_path(path: Path) -> str: try: return str(path.resolve().relative_to(ROOT.resolve())) except ValueError: return str(path.resolve()) def load_json(path: Path) -> tuple[dict[str, Any], list[str]]: if not path.exists(): return {}, [f"Missing JSON file: {display_path(path)}"] try: payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: return {}, [f"Invalid JSON file {display_path(path)}: {exc}"] if not isinstance(payload, dict): return {}, [f"JSON root must be an object: {display_path(path)}"] return payload, [] def load_optional_decisions(path: Path) -> tuple[dict[str, Any], list[str]]: if not path.exists(): return {"schema_version": "1.0", "decisions": []}, [] return load_json(path) def normalize_variant(value: Any) -> str: normalized = str(value or "").strip().upper() if normalized in {"VARIANT A", "A"}: return "A" if normalized in {"VARIANT B", "B"}: return "B" return normalized def confidence_value(value: Any) -> tuple[float | None, str | None]: if value is None or value == "": return None, None try: parsed = float(value) except (TypeError, ValueError): return None, f"confidence must be numeric, got {value!r}" if parsed < 0 or parsed > 1: return None, f"confidence must be between 0 and 1, got {parsed}" return round(parsed, 3), None def answer_index(answer_key: dict[str, Any]) -> dict[str, dict[str, Any]]: answers = answer_key.get("answers", []) if not isinstance(answers, list): return {} return {str(item.get("case_id", "")): item for item in answers if isinstance(item, dict) and item.get("case_id")} def pair_index(blind_pack: dict[str, Any]) -> dict[str, dict[str, Any]]: pairs = blind_pack.get("pairs", []) if not isinstance(pairs, list): return {} return {str(item.get("case_id", "")): item for item in pairs if isinstance(item, dict) and item.get("case_id")} def decision_index(decisions_payload: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str]]: decisions = decisions_payload.get("decisions", []) failures: list[str] = [] indexed: dict[str, dict[str, Any]] = {} if not isinstance(decisions, list): return {}, ["decisions must be a list"] for index, item in enumerate(decisions, start=1): if not isinstance(item, dict): failures.append(f"decision #{index} must be an object") continue case_id = str(item.get("case_id", "")).strip() if not case_id: failures.append(f"decision #{index} is missing case_id") continue if case_id in indexed: failures.append(f"duplicate decision for case_id: {case_id}") continue indexed[case_id] = item return indexed, failures def build_decision_template(blind_pack: dict[str, Any]) -> dict[str, Any]: pairs = blind_pack.get("pairs", []) template_decisions = [] if isinstance(pairs, list): for pair in pairs: if not isinstance(pair, dict): continue template_decisions.append( { "case_id": str(pair.get("case_id", "")), "winner_variant": "", "confidence": None, "reason": "", } ) return { "schema_version": "1.0", "reviewer": "", "reviewed_at": "", "decision_contract": { "winner_variant": "Use A or B after reading the blind review pack. Leave blank when pending.", "confidence": "Optional number from 0 to 1.", "reason": "Short reviewer rationale. Do not reveal baseline or with-skill labels before adjudication.", }, "decisions": template_decisions, } def adjudicate_pair( case_id: str, pair: dict[str, Any], answer: dict[str, Any] | None, decision: dict[str, Any] | None, ) -> tuple[dict[str, Any], list[str]]: failures: list[str] = [] expected = normalize_variant(answer.get("expected_winner_variant", "") if answer else "") if expected not in {"A", "B"}: failures.append(f"{case_id}: answer key is missing expected_winner_variant") if decision is None: return ( { "case_id": case_id, "status": "pending", "expected_winner_variant": "", "expected_revealed": False, "reviewer_winner_variant": "", "confidence": None, "reason": "", "prompt": str(pair.get("prompt", "")), }, failures, ) reviewer = normalize_variant( decision.get("winner_variant", decision.get("reviewer_winner_variant", decision.get("winner", ""))) ) confidence, confidence_failure = confidence_value(decision.get("confidence")) reason = str(decision.get("reason", "")).strip() if not reviewer: return ( { "case_id": case_id, "status": "pending", "expected_winner_variant": "", "expected_revealed": False, "reviewer_winner_variant": "", "confidence": confidence, "reason": reason, "prompt": str(pair.get("prompt", "")), }, failures, ) if expected not in {"A", "B"}: status = "invalid" elif reviewer not in {"A", "B"}: failures.append(f"{case_id}: winner_variant must be A or B") status = "invalid" elif confidence_failure: failures.append(f"{case_id}: {confidence_failure}") status = "invalid" else: status = "match" if reviewer == expected else "disagree" expected_revealed = status in {"match", "disagree"} return ( { "case_id": case_id, "status": status, "expected_winner_variant": expected if expected_revealed else "", "expected_revealed": expected_revealed, "reviewer_winner_variant": reviewer, "confidence": confidence, "reason": reason, "prompt": str(pair.get("prompt", "")), }, failures, ) def build_summary(pairs: list[dict[str, Any]], failures: list[str]) -> dict[str, Any]: pair_count = len(pairs) judgment_count = sum(1 for item in pairs if item["status"] in {"match", "disagree"}) pending_count = sum(1 for item in pairs if item["status"] == "pending") agreement_count = sum(1 for item in pairs if item["status"] == "match") disagreement_count = sum(1 for item in pairs if item["status"] == "disagree") invalid_decision_count = sum(1 for item in pairs if item["status"] == "invalid") answer_revealed_count = sum(1 for item in pairs if item.get("expected_revealed")) agreement_rate = round(agreement_count / judgment_count * 100, 2) if judgment_count else None return { "pair_count": pair_count, "judgment_count": judgment_count, "pending_count": pending_count, "agreement_count": agreement_count, "disagreement_count": disagreement_count, "invalid_decision_count": invalid_decision_count, "answer_revealed_count": answer_revealed_count, "pending_answer_hidden_count": sum(1 for item in pairs if item["status"] in {"pending", "invalid"} and not item.get("expected_revealed")), "agreement_rate": agreement_rate, "needs_review": pending_count > 0, "failure_count": len(failures), } def checklist_readiness(pair: dict[str, Any]) -> tuple[str, str]: status = pair.get("status") if status in {"match", "disagree"}: return "adjudicated", "Reviewer decision is valid; answer key is revealed for this case." if status == "invalid": return "fix-decision", "Reviewer decision exists but failed validation; answer key remains hidden." return "awaiting-decision", "Reviewer has not selected A or B yet; answer key remains hidden." def build_reviewer_checklist( pairs: list[dict[str, Any]], blind_pack_path: Path, decisions_path: Path, ) -> list[dict[str, Any]]: checklist = [] for pair in pairs: readiness, blocking_reason = checklist_readiness(pair) checklist.append( { "case_id": pair.get("case_id", ""), "readiness": readiness, "blocking_reason": blocking_reason, "status": pair.get("status", "pending"), "reviewer_winner_variant": pair.get("reviewer_winner_variant", ""), "answer_key_visible": bool(pair.get("expected_revealed")), "prompt": pair.get("prompt", ""), "blind_pack_path": display_path(blind_pack_path), "decisions_path": display_path(decisions_path), "commands": { "prepare_review_kit": "python3 scripts/yao.py output-review-kit", "write_template": "python3 scripts/adjudicate_output_review.py --write-template", "adjudicate": "python3 scripts/yao.py output-review", "refresh_review_studio": "python3 scripts/yao.py review-studio .", }, "required_fields": { "winner_variant": "A or B after reading only the blind review pack.", "confidence": "Optional number from 0 to 1.", "reason": "Short rationale; do not reveal baseline or with-skill labels before adjudication.", }, "privacy_contract": [ "Do not paste raw private user data into the decision reason.", "Do not open the answer key before reviewer choices are recorded.", "Leave winner_variant blank when the reviewer is not ready to decide.", ], } ) return checklist def add_checklist_summary(summary: dict[str, Any], checklist: list[dict[str, Any]]) -> dict[str, Any]: enriched = dict(summary) enriched["reviewer_checklist_count"] = len(checklist) enriched["reviewer_checklist_pending_count"] = sum(1 for item in checklist if item["readiness"] == "awaiting-decision") enriched["reviewer_checklist_invalid_count"] = sum(1 for item in checklist if item["readiness"] == "fix-decision") enriched["reviewer_checklist_ready_count"] = sum(1 for item in checklist if item["readiness"] == "adjudicated") return enriched def render_reviewer_checklist(checklist: list[dict[str, Any]]) -> list[str]: lines = [ "## Reviewer Checklist", "", "| Case | Readiness | Answer key | Decision file |", "| --- | --- | --- | --- |", ] if not checklist: lines.append("| `none` | `adjudicated` | n/a | none |") return lines for item in checklist: answer_key = "visible" if item.get("answer_key_visible") else "hidden" lines.append( f"| `{item['case_id']}` | `{item['readiness']}` | `{answer_key}` | `{item['decisions_path']}` |" ) for item in checklist: lines.extend(["", f"### {item['case_id']}", ""]) lines.append(f"- readiness: `{item['readiness']}`") lines.append(f"- blocking reason: {item['blocking_reason']}") lines.append(f"- answer key visible: `{str(item['answer_key_visible']).lower()}`") lines.append(f"- blind pack: `{item['blind_pack_path']}`") lines.append(f"- decisions: `{item['decisions_path']}`") lines.extend(["", "#### Commands", ""]) for label, command in item.get("commands", {}).items(): lines.append(f"- {label}: `{command}`") lines.extend(["", "#### Required Fields", ""]) for label, description in item.get("required_fields", {}).items(): lines.append(f"- {label}: {description}") lines.extend(["", "#### Privacy Contract", ""]) lines.extend(f"- {contract}" for contract in item.get("privacy_contract", [])) return lines def render_markdown(payload: dict[str, Any]) -> str: summary = payload["summary"] lines = [ "# Output Review Adjudication", "", "This report adjudicates reviewer choices from the blind A/B output review pack against the separate answer key.", "", f"- Pairs: `{summary['pair_count']}`", f"- Judgments: `{summary['judgment_count']}`", f"- Pending: `{summary['pending_count']}`", f"- Agreement rate: `{summary['agreement_rate'] if summary['agreement_rate'] is not None else 'n/a'}`", f"- Invalid decisions: `{summary['invalid_decision_count']}`", f"- Answer keys revealed: `{summary['answer_revealed_count']}`", f"- Pending/invalid answers hidden: `{summary['pending_answer_hidden_count']}`", f"- Reviewer checklist: `{summary['reviewer_checklist_ready_count']}` ready / `{summary['reviewer_checklist_count']}` total", "", ] if summary["judgment_count"] == 0: lines.extend( [ "No reviewer decisions recorded yet.", "", "Generate a template with `--write-template`, fill `winner_variant` with `A` or `B`, then rerun adjudication.", "Expected winners stay hidden until a valid reviewer decision is recorded.", "", ] ) lines.extend( [ "## Case Adjudication", "", "| Case | Reviewer | Expected | Status | Confidence | Reason |", "| --- | --- | --- | --- | ---: | --- |", ] ) for item in payload["pairs"]: confidence = "" if item.get("confidence") is None else str(item["confidence"]) reason = str(item.get("reason", "")).replace("|", "\\|") or "" expected = item.get("expected_winner_variant", "") if item.get("expected_revealed") else "hidden" lines.append( f"| {item['case_id']} | {item.get('reviewer_winner_variant', '') or 'pending'} | " f"{expected} | {item['status']} | {confidence} | {reason} |" ) if payload.get("failures"): lines.extend(["", "## Failures", ""]) for failure in payload["failures"]: lines.append(f"- {failure}") lines.extend(["", *render_reviewer_checklist(payload.get("reviewer_checklist", []))]) lines.extend( [ "", "## Next Fixes", "", "- Keep the blind review pack separate from the answer key until decisions are recorded.", "- Treat disagreement cases as prompts for rubric tuning or output improvement.", "- Add model-executed holdout runs after this human adjudication harness is stable.", ] ) return "\n".join(lines).strip() + "\n" def adjudicate_output_review( blind_pack_path: Path, answer_key_path: Path, decisions_path: Path, output_json: Path, output_md: Path, write_template: bool = False, ) -> dict[str, Any]: blind_pack, failures = load_json(blind_pack_path) answer_key, answer_failures = load_json(answer_key_path) failures.extend(answer_failures) decisions_payload, decision_failures = load_optional_decisions(decisions_path) failures.extend(decision_failures) template_written = False if write_template and blind_pack and not decisions_path.exists(): decisions_path.parent.mkdir(parents=True, exist_ok=True) decisions_path.write_text( json.dumps(build_decision_template(blind_pack), ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) template_written = True pairs_by_id = pair_index(blind_pack) answers_by_id = answer_index(answer_key) decisions_by_id, index_failures = decision_index(decisions_payload) failures.extend(index_failures) for case_id in decisions_by_id: if case_id not in pairs_by_id: failures.append(f"decision references unknown case_id: {case_id}") adjudicated_pairs: list[dict[str, Any]] = [] for case_id, pair in pairs_by_id.items(): adjudicated, pair_failures = adjudicate_pair(case_id, pair, answers_by_id.get(case_id), decisions_by_id.get(case_id)) adjudicated_pairs.append(adjudicated) failures.extend(pair_failures) summary = build_summary(adjudicated_pairs, failures) reviewer_checklist = build_reviewer_checklist(adjudicated_pairs, blind_pack_path, decisions_path) summary = add_checklist_summary(summary, reviewer_checklist) payload = { "schema_version": "1.0", "ok": not failures, "summary": summary, "reviewer": decisions_payload.get("reviewer", ""), "reviewed_at": decisions_payload.get("reviewed_at", ""), "artifacts": { "blind_pack": display_path(blind_pack_path), "answer_key": display_path(answer_key_path), "decisions": display_path(decisions_path), "json": display_path(output_json), "markdown": display_path(output_md), }, "template_written": template_written, "pairs": adjudicated_pairs, "reviewer_checklist": reviewer_checklist, "failures": failures, } output_json.parent.mkdir(parents=True, exist_ok=True) output_md.parent.mkdir(parents=True, exist_ok=True) output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") output_md.write_text(render_markdown(payload), encoding="utf-8") return payload def main() -> None: parser = argparse.ArgumentParser(description="Adjudicate blind A/B output review decisions against the answer key.") parser.add_argument("--blind-pack", default=str(DEFAULT_BLIND_PACK)) parser.add_argument("--answer-key", default=str(DEFAULT_ANSWER_KEY)) parser.add_argument("--decisions", default=str(DEFAULT_DECISIONS)) parser.add_argument("--output-json", default=str(DEFAULT_OUTPUT_JSON)) parser.add_argument("--output-md", default=str(DEFAULT_OUTPUT_MD)) parser.add_argument("--write-template", action="store_true") args = parser.parse_args() payload = adjudicate_output_review( Path(args.blind_pack).resolve(), Path(args.answer_key).resolve(), Path(args.decisions).resolve(), Path(args.output_json).resolve(), Path(args.output_md).resolve(), write_template=args.write_template, ) print(json.dumps(payload, ensure_ascii=False, indent=2)) raise SystemExit(0 if payload["ok"] else 2) if __name__ == "__main__": main()