#!/usr/bin/env python3
"""Independently verify the synthetic results-oriented poker evidence bundle."""

from __future__ import annotations

import argparse
import csv
import io
import json
import sys
from decimal import Decimal
from pathlib import Path
from typing import Any


DEFAULT_JSON_PATH = Path(__file__).with_name("results-oriented-poker-cases.json")
DEFAULT_CSV_PATH = Path(__file__).with_name("results-oriented-review-card.csv")
DEFAULT_QUICK_CSV_PATH = Path(__file__).with_name("results-oriented-poker-quick-review.csv")
MODEL_VERSION = "1.0.0"
DISCLOSURE = (
    "Synthetic logical demonstration only; not observed users, poker-player psychology "
    "data, solver output, or a strategy recommendation."
)

REVIEW_CARD_HEADER = [
    "record_kind",
    "case_id",
    "pair_id",
    "lock_completed_before_result",
    "lock_game_format",
    "lock_street",
    "lock_decision",
    "lock_action_taken",
    "lock_pot_before_action_chips",
    "lock_call_cost_chips",
    "lock_equity_estimate_pct",
    "lock_range_or_model_assumption",
    "lock_remaining_rake_chips",
    "lock_tie_probability_pct",
    "lock_future_action_possible",
    "lock_reasoning",
    "audit_reference_type",
    "audit_reference_label",
    "audit_reference_version_or_date",
    "audit_reference_equity_pct",
    "audit_final_pot_if_call_chips",
    "audit_required_equity_pct",
    "audit_call_ev_chips",
    "audit_decision_grade",
    "audit_action_alignment",
    "audit_notes",
    "reveal_unlocked_after_audit",
    "reveal_outcome",
    "reveal_chips_received_chips",
    "reveal_net_chips_from_decision",
    "reveal_did_outcome_change_decision_grade",
    "reveal_process_note",
    "update_target_belief",
    "update_evidence_unit",
    "update_sample_scope",
    "update_direction",
    "update_confidence",
    "update_next_test",
    "update_notes",
    "synthetic_disclosure",
]

QUICK_REVIEW_HEADER = [
    "id",
    "locked_state_and_options",
    "locked_belief_or_range",
    "action_and_reason",
    "confidence",
    "reference_and_mismatch",
    "locked_model_based_action_grade",
    "raw_result",
    "post_reveal_snap_grade_or_change",
    "genuinely_new_evidence",
    "future_model_update",
    "next_comparable_sample",
]

CASE_SPECS = [
    ("positive-ev-win", "positive-ev-call", "positive EV call", Decimal("0.30"), "win"),
    ("positive-ev-loss", "positive-ev-call", "positive EV call", Decimal("0.30"), "loss"),
    ("negative-ev-win", "negative-ev-call", "negative EV call", Decimal("0.15"), "win"),
    ("negative-ev-loss", "negative-ev-call", "negative EV call", Decimal("0.15"), "loss"),
]

ALLOWED_OUTCOME_DIFFERENCES = [
    "caseId",
    "outcomeAxis",
    "resultReveal.realizedOutcome",
    "resultReveal.chipsReceivedChips",
    "resultReveal.realizedNetChipsFromDecision",
]


class VerificationError(AssertionError):
    """Raised when a published artifact fails closed."""


def fail(message: str) -> None:
    raise VerificationError(message)


def require_exact_keys(value: Any, expected: list[str], label: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        fail(f"{label} must be an object")
    actual_keys = set(value)
    expected_keys = set(expected)
    if actual_keys != expected_keys:
        missing = sorted(expected_keys - actual_keys)
        extra = sorted(actual_keys - expected_keys)
        fail(f"{label} schema changed; missing={missing}, extra={extra}")
    return value


def as_decimal(value: Any, label: str) -> Decimal:
    if isinstance(value, bool) or not isinstance(value, (int, Decimal)):
        fail(f"{label} must be a finite JSON number")
    number = Decimal(value)
    if not number.is_finite():
        fail(f"{label} must be finite")
    return number


def require_exact_number(actual: Any, expected: Any, label: str) -> None:
    number = as_decimal(actual, label)
    expected_number = Decimal(expected)
    if number != expected_number:
        fail(f"{label} changed pinned value: expected {expected_number}, received {number}")


def reject_json_constant(token: str) -> None:
    fail(f"JSON contains non-finite number token {token}")


def reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for key, value in pairs:
        if key in result:
            fail(f"JSON contains duplicate object key {key!r}")
        result[key] = value
    return result


def read_lf_text(path: Path, label: str) -> str:
    raw = path.read_bytes()
    if b"\r" in raw:
        fail(f"{label} must use LF line endings")
    if not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
        fail(f"{label} must end with exactly one LF")
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError as error:
        fail(f"{label} must be UTF-8: {error}")


def load_json(path: Path) -> dict[str, Any]:
    text = read_lf_text(path, "JSON artifact")
    try:
        value = json.loads(
            text,
            parse_float=Decimal,
            parse_int=int,
            parse_constant=reject_json_constant,
            object_pairs_hook=reject_duplicate_pairs,
        )
    except (json.JSONDecodeError, VerificationError) as error:
        fail(f"JSON could not be parsed safely: {error}")
    if not isinstance(value, dict):
        fail("JSON root must be an object")
    return value


def expected_decision_time(pair_id: str) -> dict[str, Any]:
    if pair_id == "positive-ev-call":
        equity = Decimal("0.30")
        equity_basis = (
            "Constructed 30% equity input for arithmetic demonstration; not a solver range "
            "or observed frequency."
        )
    elif pair_id == "negative-ev-call":
        equity = Decimal("0.15")
        equity_basis = (
            "Constructed 15% equity input for arithmetic demonstration; not a solver range "
            "or observed frequency."
        )
    else:
        fail(f"unexpected pair id {pair_id}")
    return {
        "gameFormat": "Heads-up no-limit Hold’em cash chips",
        "street": "river",
        "decision": "call-or-fold",
        "actionTaken": "call",
        "potBeforeActionChips": 300,
        "callCostChips": 100,
        "remainingRakeChips": 0,
        "tieProbability": 0,
        "futureActionPossible": False,
        "actionClosesHand": True,
        "decisionTimeLockedBeforeOutcome": True,
        "equityEstimate": equity,
        "equityBasis": equity_basis,
    }


def analyze_decision(decision_time: dict[str, Any]) -> dict[str, Any]:
    pot = as_decimal(decision_time["potBeforeActionChips"], "decisionTime.potBeforeActionChips")
    cost = as_decimal(decision_time["callCostChips"], "decisionTime.callCostChips")
    equity = as_decimal(decision_time["equityEstimate"], "decisionTime.equityEstimate")
    rake = as_decimal(decision_time["remainingRakeChips"], "decisionTime.remainingRakeChips")
    tie_probability = as_decimal(decision_time["tieProbability"], "decisionTime.tieProbability")
    if pot <= 0 or cost <= 0:
        fail("pot and call cost must be positive")
    if not Decimal(0) <= equity <= Decimal(1):
        fail("decisionTime.equityEstimate must be between zero and one")
    if rake != 0 or tie_probability != 0:
        fail("static cases require zero remaining rake and zero tie probability")
    if decision_time["futureActionPossible"] is not False:
        fail("static cases require futureActionPossible=false")
    if decision_time["actionClosesHand"] is not True:
        fail("static cases require actionClosesHand=true")
    if decision_time["decisionTimeLockedBeforeOutcome"] is not True:
        fail("decision-time fields were not declared locked before outcome")

    final_pot = pot + cost
    required_equity = cost / final_pot
    expected_receipt = equity * final_pot
    call_ev = expected_receipt - cost
    grade = "positive-ev-call" if call_ev > 0 else "negative-ev-call" if call_ev < 0 else "break-even-call"
    preferred = "call" if call_ev > 0 else "fold" if call_ev < 0 else "indifferent"
    alignment = "aligned" if preferred == decision_time["actionTaken"] else "not-aligned"
    return {
        "referenceType": "constructed-input deterministic arithmetic",
        "referenceModelId": f"closed-action-river-call/{MODEL_VERSION}",
        "finalPotIfCallChips": final_pot,
        "requiredEquity": required_equity,
        "expectedGrossReceiptChips": expected_receipt,
        "foldEvChips": 0,
        "callEvChips": call_ev,
        "callMinusFoldEvChips": call_ev,
        "decisionGrade": grade,
        "preferredAction": preferred,
        "actionAlignment": alignment,
    }


def realize(decision_time: dict[str, Any], outcome: str, decision_grade: str) -> dict[str, Any]:
    pot = as_decimal(decision_time["potBeforeActionChips"], "decisionTime.potBeforeActionChips")
    cost = as_decimal(decision_time["callCostChips"], "decisionTime.callCostChips")
    if outcome not in {"win", "loss"}:
        fail(f"unexpected outcome {outcome}")
    final_pot = pot + cost
    received = final_pot if outcome == "win" else Decimal(0)
    return {
        "realizedOutcome": outcome,
        "chipsReceivedChips": received,
        "realizedNetChipsFromDecision": received - cost,
        "decisionGradeAfterReveal": decision_grade,
        "outcomeChangedDecisionGrade": False,
    }


def compare_mapping(actual: dict[str, Any], expected: dict[str, Any], label: str) -> None:
    if set(actual) != set(expected):
        fail(f"{label} schema changed")
    for key, expected_value in expected.items():
        actual_value = actual[key]
        if isinstance(expected_value, (int, Decimal)) and not isinstance(expected_value, bool):
            require_exact_number(actual_value, expected_value, f"{label}.{key}")
        elif actual_value != expected_value:
            fail(f"{label}.{key} changed: expected {expected_value!r}, received {actual_value!r}")


def differing_paths(left: Any, right: Any, prefix: str = "") -> list[str]:
    if isinstance(left, dict) and isinstance(right, dict):
        paths: list[str] = []
        for key in sorted(set(left) | set(right)):
            child_path = f"{prefix}.{key}" if prefix else key
            if key not in left or key not in right:
                paths.append(child_path)
            else:
                paths.extend(differing_paths(left[key], right[key], child_path))
        return paths
    return [] if left == right else [prefix]


def verify_cases(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
    cases = data["caseMatrix"]
    if not isinstance(cases, list) or len(cases) != 4:
        fail("caseMatrix must contain exactly four constructed cases")
    actual_order = [case.get("caseId") if isinstance(case, dict) else None for case in cases]
    expected_order = [spec[0] for spec in CASE_SPECS]
    if actual_order != expected_order:
        fail(f"case ids or order changed: {actual_order}")

    by_id: dict[str, dict[str, Any]] = {}
    for case, (case_id, pair_id, quality_axis, pinned_equity, outcome) in zip(cases, CASE_SPECS):
        require_exact_keys(
            case,
            ["caseId", "pairId", "decisionQualityAxis", "outcomeAxis", "decisionTime", "decisionAudit", "resultReveal"],
            case_id,
        )
        if case["caseId"] != case_id or case["pairId"] != pair_id:
            fail(f"{case_id}: case descriptor changed")
        if case["decisionQualityAxis"] != quality_axis or case["outcomeAxis"] != outcome:
            fail(f"{case_id}: matrix-axis label changed")

        decision_time = require_exact_keys(
            case["decisionTime"],
            [
                "gameFormat",
                "street",
                "decision",
                "actionTaken",
                "potBeforeActionChips",
                "callCostChips",
                "remainingRakeChips",
                "tieProbability",
                "futureActionPossible",
                "actionClosesHand",
                "decisionTimeLockedBeforeOutcome",
                "equityEstimate",
                "equityBasis",
            ],
            f"{case_id}.decisionTime",
        )
        compare_mapping(decision_time, expected_decision_time(pair_id), f"{case_id}.decisionTime")
        require_exact_number(decision_time["equityEstimate"], pinned_equity, f"{case_id}.pinned equity")

        audit = require_exact_keys(
            case["decisionAudit"],
            [
                "referenceType",
                "referenceModelId",
                "finalPotIfCallChips",
                "requiredEquity",
                "expectedGrossReceiptChips",
                "foldEvChips",
                "callEvChips",
                "callMinusFoldEvChips",
                "decisionGrade",
                "preferredAction",
                "actionAlignment",
            ],
            f"{case_id}.decisionAudit",
        )
        recomputed_audit = analyze_decision(decision_time)
        compare_mapping(audit, recomputed_audit, f"{case_id}.decisionAudit")

        reveal = require_exact_keys(
            case["resultReveal"],
            [
                "realizedOutcome",
                "chipsReceivedChips",
                "realizedNetChipsFromDecision",
                "decisionGradeAfterReveal",
                "outcomeChangedDecisionGrade",
            ],
            f"{case_id}.resultReveal",
        )
        recomputed_reveal = realize(decision_time, outcome, audit["decisionGrade"])
        compare_mapping(reveal, recomputed_reveal, f"{case_id}.resultReveal")
        by_id[case_id] = case

    require_exact_number(by_id["positive-ev-win"]["decisionAudit"]["callEvChips"], 20, "positive pair call EV")
    require_exact_number(by_id["negative-ev-win"]["decisionAudit"]["callEvChips"], -40, "negative pair call EV")
    return by_id


def verify_pair_invariants(data: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> None:
    expected_pairs = [
        {
            "pairId": "positive-ev-call",
            "caseIds": ["positive-ev-win", "positive-ev-loss"],
            "lockedEqualPaths": ["pairId", "decisionQualityAxis", "decisionTime", "decisionAudit"],
            "allowedOutcomeDifferences": ALLOWED_OUTCOME_DIFFERENCES,
            "expectedDecisionGrade": "positive-ev-call",
            "statement": "The 30% call is +20 chips before either result is revealed; winning or losing changes realized chips, not its decision grade.",
            "verifiedCaseCount": 2,
        },
        {
            "pairId": "negative-ev-call",
            "caseIds": ["negative-ev-win", "negative-ev-loss"],
            "lockedEqualPaths": ["pairId", "decisionQualityAxis", "decisionTime", "decisionAudit"],
            "allowedOutcomeDifferences": ALLOWED_OUTCOME_DIFFERENCES,
            "expectedDecisionGrade": "negative-ev-call",
            "statement": "The 15% call is -40 chips before either result is revealed; winning or losing changes realized chips, not its decision grade.",
            "verifiedCaseCount": 2,
        },
    ]
    if data["pairInvariants"] != expected_pairs:
        fail("pairInvariants metadata changed")

    for pair in expected_pairs:
        first = by_id[pair["caseIds"][0]]
        second = by_id[pair["caseIds"][1]]
        differences = differing_paths(first, second)
        if differences != sorted(ALLOWED_OUTCOME_DIFFERENCES):
            fail(f"{pair['pairId']}: cases differ outside the realized outcome: {differences}")
        if first["decisionTime"] != second["decisionTime"]:
            fail(f"{pair['pairId']}: decision-time inputs are not identical")
        if first["decisionAudit"] != second["decisionAudit"]:
            fail(f"{pair['pairId']}: pre-reveal decision audits are not identical")
        if {first["outcomeAxis"], second["outcomeAxis"]} != {"win", "loss"}:
            fail(f"{pair['pairId']}: pair must contain one win and one loss")
        if first["decisionAudit"]["decisionGrade"] != pair["expectedDecisionGrade"]:
            fail(f"{pair['pairId']}: decision grade changed")


def verify_quadrant_matrix(data: dict[str, Any]) -> None:
    expected = {
        "rowAxis": ["positive EV call", "negative EV call"],
        "columnAxis": ["win", "loss"],
        "cells": [
            {"decisionQuality": quality, "realizedOutcome": outcome, "caseId": case_id}
            for case_id, _pair_id, quality, _equity, outcome in CASE_SPECS
        ],
    }
    if data["quadrantMatrix"] != expected:
        fail("quadrantMatrix changed or is incomplete")


def expected_review_metadata() -> dict[str, Any]:
    return {
        "csvFile": "results-oriented-review-card.csv",
        "encoding": "UTF-8",
        "lineEndings": "LF",
        "templateRowCount": 1,
        "exampleRowCount": 4,
        "totalRowCountExcludingHeader": 5,
        "header": REVIEW_CARD_HEADER,
        "chronologyBoundary": "Column order separates lock/audit from reveal/update, but completion timing is self-reported and cannot be enforced by a static CSV.",
        "stages": [
            {
                "pass": 1,
                "prefix": "lock_",
                "name": "Lock the decision-time record",
                "rule": "Complete these fields before seeing or entering the result.",
            },
            {
                "pass": 1,
                "prefix": "audit_",
                "name": "Audit against a reference or declared model",
                "rule": "Grade the decision from information available at the decision; keep the result hidden.",
            },
            {
                "pass": 2,
                "prefix": "reveal_",
                "name": "Reveal and record the result",
                "rule": "Record realized chips separately and do not overwrite the decision grade.",
            },
            {
                "pass": 2,
                "prefix": "update_",
                "name": "Plan a future-belief update",
                "rule": "Change a future assumption only for identified new evidence and a declared comparison scope.",
            },
        ],
    }


def expected_quick_review_metadata() -> dict[str, Any]:
    return {
        "csvFile": "results-oriented-poker-quick-review.csv",
        "encoding": "UTF-8",
        "lineEndings": "LF",
        "templateRowCount": 1,
        "totalRowCountExcludingHeader": 1,
        "header": QUICK_REVIEW_HEADER,
        "purpose": "A plain chronological audit trail for one reviewed decision.",
        "chronologyBoundary": "Column order separates pre-reveal from post-reveal prompts, but completion timing is self-reported and cannot be enforced by a static CSV.",
        "validationBoundary": "This audit trail is not a validated test of outcome bias, psychology, or decision skill.",
    }


def csv_number(value: Any) -> str:
    number = as_decimal(value, "CSV source number")
    if number == number.to_integral_value():
        return str(int(number))
    return format(number.normalize(), "f")


def blank_csv_row() -> dict[str, str]:
    return {header: "" for header in REVIEW_CARD_HEADER}


def expected_template_row() -> dict[str, str]:
    row = blank_csv_row()
    row.update(
        {
            "record_kind": "template",
            "case_id": "[copy-and-name-this-row]",
            "pair_id": "[optional-comparison-pair]",
            "lock_completed_before_result": "[enter TRUE only if these fields were completed before seeing the result]",
            "lock_game_format": "[format and stakes/units]",
            "lock_street": "[decision street]",
            "lock_decision": "[actions compared]",
            "lock_action_taken": "[action actually taken]",
            "lock_pot_before_action_chips": "[pot before action]",
            "lock_call_cost_chips": "[incremental call cost]",
            "lock_equity_estimate_pct": "[decision-time estimate]",
            "lock_range_or_model_assumption": "[range, read, or model known then]",
            "lock_remaining_rake_chips": "[remaining rake/drop]",
            "lock_tie_probability_pct": "[tie probability]",
            "lock_future_action_possible": "[TRUE/FALSE]",
            "lock_reasoning": "[write before result reveal]",
            "audit_reference_type": "[solver, calculation, database, coach, or none]",
            "audit_reference_label": "[specific reference and scope]",
            "audit_reference_version_or_date": "[version/date checked]",
            "audit_reference_equity_pct": "[reference equity if applicable]",
            "audit_final_pot_if_call_chips": "[audited final pot]",
            "audit_required_equity_pct": "[audited threshold]",
            "audit_call_ev_chips": "[audited incremental EV]",
            "audit_decision_grade": "[positive, negative, break-even, or unresolved]",
            "audit_action_alignment": "[aligned, not-aligned, or unresolved]",
            "audit_notes": "[differences from the locked reasoning]",
            "synthetic_disclosure": "[state provenance; use the synthetic disclosure only for a constructed example]",
        }
    )
    return row


def csv_example_from_case(case: dict[str, Any]) -> dict[str, str]:
    decision = case["decisionTime"]
    audit = case["decisionAudit"]
    reveal = case["resultReveal"]
    equity_pct = as_decimal(decision["equityEstimate"], "equity") * 100
    required_pct = as_decimal(audit["requiredEquity"], "required equity") * 100
    call_ev = as_decimal(audit["callEvChips"], "call EV")
    call_ev_text = csv_number(call_ev)
    row = blank_csv_row()
    row.update(
        {
            "record_kind": "example",
            "case_id": case["caseId"],
            "pair_id": case["pairId"],
            "lock_completed_before_result": "TRUE",
            "lock_game_format": decision["gameFormat"],
            "lock_street": decision["street"],
            "lock_decision": decision["decision"],
            "lock_action_taken": decision["actionTaken"],
            "lock_pot_before_action_chips": csv_number(decision["potBeforeActionChips"]),
            "lock_call_cost_chips": csv_number(decision["callCostChips"]),
            "lock_equity_estimate_pct": csv_number(equity_pct),
            "lock_range_or_model_assumption": decision["equityBasis"],
            "lock_remaining_rake_chips": csv_number(decision["remainingRakeChips"]),
            "lock_tie_probability_pct": csv_number(as_decimal(decision["tieProbability"], "tie") * 100),
            "lock_future_action_possible": "TRUE" if decision["futureActionPossible"] else "FALSE",
            "lock_reasoning": "Compare incremental call EV with folding at 0 chips from this decision point.",
            "audit_reference_type": audit["referenceType"],
            "audit_reference_label": "Closed-action river-call chip-EV arithmetic",
            "audit_reference_version_or_date": audit["referenceModelId"],
            "audit_reference_equity_pct": csv_number(equity_pct),
            "audit_final_pot_if_call_chips": csv_number(audit["finalPotIfCallChips"]),
            "audit_required_equity_pct": csv_number(required_pct),
            "audit_call_ev_chips": call_ev_text,
            "audit_decision_grade": audit["decisionGrade"],
            "audit_action_alignment": audit["actionAlignment"],
            "audit_notes": (
                f"{csv_number(equity_pct)}% × {csv_number(audit['finalPotIfCallChips'])} − "
                f"{csv_number(decision['callCostChips'])} = {'+' if call_ev >= 0 else ''}{call_ev_text} chips."
            ),
            "reveal_unlocked_after_audit": "TRUE",
            "reveal_outcome": reveal["realizedOutcome"],
            "reveal_chips_received_chips": csv_number(reveal["chipsReceivedChips"]),
            "reveal_net_chips_from_decision": csv_number(reveal["realizedNetChipsFromDecision"]),
            "reveal_did_outcome_change_decision_grade": "TRUE" if reveal["outcomeChangedDecisionGrade"] else "FALSE",
            "reveal_process_note": "Record the result without rewriting the locked inputs or audited decision grade.",
            "update_target_belief": "Opponent-range or equity assumption for future comparable river calls",
            "update_evidence_unit": "A defined sample of comparable decisions reviewed with outcomes hidden first",
            "update_sample_scope": "No update from this single synthetic outcome",
            "update_direction": "none",
            "update_confidence": "not-applicable",
            "update_next_test": "Define comparable spots, lock estimates first, then compare them with a dated reference.",
            "update_notes": "One win or loss does not validate the constructed equity input or alter this case’s decision grade.",
            "synthetic_disclosure": DISCLOSURE,
        }
    )
    return row


def read_csv(path: Path) -> list[dict[str, str]]:
    text = read_lf_text(path, "CSV artifact")
    reader = csv.DictReader(io.StringIO(text, newline=""))
    if reader.fieldnames != REVIEW_CARD_HEADER:
        fail("CSV header does not match the pinned review-card schema")
    rows = list(reader)
    if any(None in row for row in rows):
        fail("CSV row has more fields than the header")
    return rows


def verify_csv(data: dict[str, Any], csv_path: Path) -> None:
    if data["reviewCard"] != expected_review_metadata():
        fail("reviewCard JSON metadata changed")
    rows = read_csv(csv_path)
    if len(rows) != 5:
        fail(f"CSV must contain one template plus four examples, received {len(rows)} rows")
    expected_rows = [expected_template_row(), *[csv_example_from_case(case) for case in data["caseMatrix"]]]
    for index, (actual, expected) in enumerate(zip(rows, expected_rows), start=2):
        if actual != expected:
            differing = [header for header in REVIEW_CARD_HEADER if actual.get(header) != expected.get(header)]
            fail(f"CSV parity failed on row {index}; differing columns={differing}")
    template = rows[0]
    if template["lock_completed_before_result"] == "TRUE":
        fail("technical template must not assert that chronology was followed")
    if template["synthetic_disclosure"] == DISCLOSURE:
        fail("technical template must not label a future real review as synthetic")
    for row in rows[1:]:
        if row["lock_completed_before_result"] != "TRUE":
            fail(f"synthetic example {row['case_id']} must retain its generated chronology assertion")
        if row["synthetic_disclosure"] != DISCLOSURE:
            fail(f"synthetic example {row['case_id']} must retain its disclosure")
    example_ids = [row["case_id"] for row in rows if row["record_kind"] == "example"]
    if example_ids != [spec[0] for spec in CASE_SPECS]:
        fail("CSV example ids or order changed")


def expected_quick_review_row() -> dict[str, str]:
    return {
        "id": "[unique review id — audit trail only, not a validated test]",
        "locked_state_and_options": "[before result: format, street, positions, stacks, pot, price, and available actions]",
        "locked_belief_or_range": "[before result: range/read/model, equity estimate, and key assumptions]",
        "action_and_reason": "[action taken + one-sentence reason written before result]",
        "confidence": "[before result: 0–100% or low/medium/high]",
        "reference_and_mismatch": "[while result hidden: dated reference/model + material mismatch; write unresolved if none]",
        "locked_model_based_action_grade": "[while result hidden: positive, negative, break-even, or unresolved]",
        "raw_result": "[reveal only now: win/loss and net chips; do not rewrite fields to the left]",
        "post_reveal_snap_grade_or_change": "[first reaction: any urge to change the locked grade, and by how much?]",
        "genuinely_new_evidence": "[facts learned beyond the win/loss itself; write none if none]",
        "future_model_update": "[future assumption changed, direction, and confidence; none without new evidence]",
        "next_comparable_sample": "[define comparable spots and how many to review before the next update]",
    }


def verify_quick_review_csv(data: dict[str, Any], quick_csv_path: Path) -> None:
    if data["quickReviewCard"] != expected_quick_review_metadata():
        fail("quickReviewCard JSON metadata changed")
    text = read_lf_text(quick_csv_path, "quick-review CSV artifact")
    reader = csv.DictReader(io.StringIO(text, newline=""))
    if reader.fieldnames != QUICK_REVIEW_HEADER:
        fail("quick-review CSV header does not match the pinned plain-language schema")
    rows = list(reader)
    if len(rows) != 1:
        fail(f"quick-review CSV must contain exactly one reusable template row, received {len(rows)}")
    if None in rows[0]:
        fail("quick-review CSV row has more fields than the header")
    if rows[0] != expected_quick_review_row():
        differing = [header for header in QUICK_REVIEW_HEADER if rows[0].get(header) != expected_quick_review_row()[header]]
        fail(f"quick-review template parity failed; differing columns={differing}")


def verify_top_level(data: dict[str, Any]) -> None:
    require_exact_keys(
        data,
        [
            "schemaVersion",
            "modelVersion",
            "published",
            "generatedBy",
            "title",
            "question",
            "hypothesis",
            "evidenceStatus",
            "units",
            "decisionConvention",
            "assumptions",
            "caseMatrix",
            "pairInvariants",
            "quadrantMatrix",
            "reviewCard",
            "quickReviewCard",
            "syntheticDisclosure",
            "interpretationBoundary",
        ],
        "root",
    )
    expected_scalars = {
        "schemaVersion": 1,
        "modelVersion": MODEL_VERSION,
        "published": "2026-09-04",
        "generatedBy": "scripts/generate-results-oriented-poker-data.mjs",
        "title": "Decision quality and realized outcomes are separate variables",
        "question": "Can the same poker decision keep the same EV grade when one constructed instance wins and another loses?",
        "hypothesis": "For a closed-action river call, decision-time EV determines the grade; revealing a win or loss changes realized chips but cannot change that grade.",
        "syntheticDisclosure": DISCLOSURE,
        "interpretationBoundary": "The bundle demonstrates arithmetic and separation of variables under its declared inputs. Verification checks consistency against fixtures embedded in the verifier; the verifier source and pins must themselves be trusted, and a pass does not establish cryptographic provenance. The bundle does not measure outcome bias in poker players, estimate a real range, reproduce solver strategy, show long-run frequencies, or recommend a wager.",
    }
    for key, expected in expected_scalars.items():
        if data[key] != expected:
            fail(f"top-level value changed: {key}")
    expected_status = {
        "kind": "synthetic logical demonstration",
        "observedUserData": False,
        "observedPokerHands": False,
        "pokerPlayerPsychologyData": False,
        "solverOutput": False,
        "strategyRecommendation": False,
        "randomnessUsed": False,
    }
    if data["evidenceStatus"] != expected_status:
        fail("evidenceStatus disclosure changed")
    expected_units = {
        "chips": "Abstract cash-game chips; no currency or monetary return is modeled.",
        "equity": "Decimal probability supplied as a constructed decision-time input.",
        "caseCount": "Four constructed cells in a 2 × 2 logical matrix; not a statistical sample.",
    }
    if data["units"] != expected_units:
        fail("units schema or wording changed")
    expected_convention = {
        "baseline": "EV is measured immediately before Hero chooses call or fold, so folding is zero from this decision point.",
        "potBeforeAction": "The 300-chip pot already includes the opponent’s river bet and excludes Hero’s unmade 100-chip call.",
        "formula": "call EV = equity × (pot before action + call cost) − call cost.",
        "threshold": "required equity = call cost ÷ (pot before action + call cost).",
        "resultAccounting": "A called win receives the 400-chip final pot after paying 100 now, for +300 from the decision point; a called loss is −100.",
        "gradingRule": "Decision grade uses only the locked decision-time inputs and deterministic audit; realized outcome is excluded.",
    }
    if data["decisionConvention"] != expected_convention:
        fail("decisionConvention schema or wording changed")
    expected_assumptions = [
        "Every case is a constructed heads-up no-limit Hold’em cash-chip river decision, not an observed hand.",
        "Hero faces a 100-chip call into a 300-chip pot that already includes the opponent’s bet; calling makes the final pot 400 chips.",
        "Calling closes action. There is no remaining rake or drop, no tie, no side pot, no currency conversion, and no future street or action.",
        "The 30% and 15% equities are declared synthetic inputs selected to sit above and below the 25% break-even threshold.",
        "The two cases in each EV pair have identical decision-time inputs and identical pre-reveal audits; only the case identity and realized-outcome fields differ.",
        "No random sampling is performed, so a random seed and iteration count are not applicable.",
    ]
    if data["assumptions"] != expected_assumptions:
        fail("assumptions changed")


def verify_bundle(json_path: Path, csv_path: Path, quick_csv_path: Path) -> None:
    data = load_json(json_path)
    verify_top_level(data)
    by_id = verify_cases(data)
    verify_pair_invariants(data, by_id)
    verify_quadrant_matrix(data)
    verify_csv(data, csv_path)
    verify_quick_review_csv(data, quick_csv_path)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("json_path", nargs="?", type=Path, default=DEFAULT_JSON_PATH)
    parser.add_argument("csv_path", nargs="?", type=Path, default=DEFAULT_CSV_PATH)
    parser.add_argument("quick_csv_path", nargs="?", type=Path, default=DEFAULT_QUICK_CSV_PATH)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    try:
        verify_bundle(args.json_path, args.csv_path, args.quick_csv_path)
    except (OSError, VerificationError) as error:
        print(f"Verification failed: {error}", file=sys.stderr)
        raise SystemExit(1) from error
    print(
        "Verified 4 synthetic 2x2 cases, +20/-40 chip EV arithmetic, pair invariants, "
        "outcome-independent grades, 5-row technical CSV parity, and the 12-field quick audit trail."
    )


if __name__ == "__main__":
    main()
