#!/usr/bin/env python3
"""Build a provenance-explicit CPU/offline package for retained V5 artifacts."""
from __future__ import annotations

import argparse
import csv
import json
import re
import sys
from pathlib import Path
from typing import Any

_SRC = Path(__file__).resolve().parents[2]
if str(_SRC) not in sys.path:
    sys.path.insert(0, str(_SRC))

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image

from drl_pinball.train.case_registry import CASE_REGISTRY
from drl_pinball.train import plot_scratch_results as trainplot

C = {
    "blue": "#2474B5", "orange": "#D67A1F", "green": "#278A62",
    "red": "#C8463A", "purple": "#7356A5", "gray": "#68717A",
    "black": "#202428", "light": "#E7EAED",
}
L = {
    "kar_re60": "Re60", "kar_re100": "Re100", "kar_re200": "Re200",
    "kar_re400": "Re400", "kar_d075": "d/L₀=0.75", "kar_d15": "d/L₀=1.5",
    "kar_d2": "d/L₀=2.0", "ill_075L": "Dₜ/L₀=0.75", "ill_1L": "Dₜ/L₀=1.0",
    "ill_15L": "Dₜ/L₀=1.5", "ill_2L": "Dₜ/L₀=2.0",
}
GROUPS = [
    ("Cross-Re", ["kar_re60", "kar_re100", "kar_re200", "kar_re400"]),
    ("Variable disturbance", ["kar_d075", "kar_re100", "kar_d15", "kar_d2"]),
    ("Illusion", ["ill_075L", "ill_1L", "ill_15L", "ill_2L"]),
]
ACTION_NAMES = ("front", "top", "bottom")
LOG_BEST = re.compile(
    r"Ep\s+(?P<iteration>\d+): reward=(?P<reward>[0-9.]+).*?"
    r"r_cd=(?P<r_cd>[0-9.]+) r_cl=(?P<r_cl>[0-9.]+) r_sim=(?P<r_sim>[0-9.]+)"
)
TB_REWARD_ATOL = 5e-5
REPORTED_TB_AUDIT = {
    "ill_075L": {"best_reward": 0.7154, "best_iteration": 470},
    "ill_1L": {"best_reward": 0.6570, "best_iteration": 290},
    "ill_15L": {"best_reward": 0.6865, "best_iteration": 491},
    "ill_2L": {"best_reward": 0.7882, "best_iteration": 484},
}


def save(fig: plt.Figure, out: Path, stem: str, pdf: bool = True) -> None:
    out.mkdir(parents=True, exist_ok=True)
    fig.savefig(out / f"{stem}.png", dpi=300, bbox_inches="tight", facecolor="white")
    if pdf:
        fig.savefig(out / f"{stem}.pdf", bbox_inches="tight", facecolor="white")
    plt.close(fig)


def family(case_id: str, scene_type: str) -> str:
    if scene_type == "illusion":
        return "Illusion"
    return "Cross-Re" if case_id.startswith("kar_re") else "Variable disturbance"


def parse_best_log(log_path: Path, best_iteration: int) -> dict[str, float] | None:
    matches = [m.groupdict() for m in LOG_BEST.finditer(log_path.read_text())]
    exact = [m for m in matches if int(m["iteration"]) == best_iteration]
    if not exact:
        return None
    item = exact[-1]
    return {key: float(item[key]) for key in ("reward", "r_cd", "r_cl", "r_sim")}


def load_training(runs: list[trainplot.RunData]) -> list[dict[str, Any]]:
    records = []
    for run in runs:
        meta_path = run.path / "meta.json"
        log_path = run.path / "train.log"
        if not meta_path.exists() or not log_path.exists():
            raise FileNotFoundError(f"Required training provenance missing for {run.name}")
        meta = json.loads(meta_path.read_text())
        tb = run.summary()
        meta_reward = float(meta["best_reward"])
        meta_iteration = int(meta["best_episode"])
        reward_delta = float(tb["best_reward"] - meta_reward)
        iteration_delta = int(tb["best_episode"] - meta_iteration)
        consistent = abs(reward_delta) <= TB_REWARD_ATOL and iteration_delta == 0
        log_components = parse_best_log(log_path, meta_iteration)
        record: dict[str, Any] = {
            "run": run.name,
            "case_id": run.case,
            "label": L[run.case],
            "family": family(run.case, CASE_REGISTRY[run.case].scene_type),
            "seed": run.seed,
            "training_iterations": int(meta["total_episodes"]),
            "authoritative_best_reward": meta_reward,
            "authoritative_best_iteration": meta_iteration,
            "learning_rate": float(meta["lr"]),
            "authoritative_source": "meta.json; same-iteration components from train.log when present",
            "tensorboard_best_reward": float(tb["best_reward"]),
            "tensorboard_best_iteration": int(tb["best_episode"]),
            "tensorboard_reward_delta": reward_delta,
            "tensorboard_iteration_delta": iteration_delta,
            "tensorboard_consistent": consistent,
            "tensorboard_tolerance_abs": TB_REWARD_ATOL,
            "tensorboard_status": "validated" if consistent else "mismatch_not_used_for_scientific_values",
            "best_components_from_log": log_components,
            "reported_audit_disagreement": None,
        }
        if run.case in REPORTED_TB_AUDIT:
            audited = REPORTED_TB_AUDIT[run.case]
            record["reported_audit_disagreement"] = {
                "reported_tensorboard_best_reward": audited["best_reward"],
                "reported_tensorboard_best_iteration": audited["best_iteration"],
                "authoritative_meta_best_reward": meta_reward,
                "authoritative_meta_best_iteration": meta_iteration,
                "resolution": (
                    "reported mismatched values are not used; current on-disk TensorBoard "
                    "stream was independently re-read and agrees with meta within tolerance"
                ),
            }
        if consistent and run.case == "kar_re100":
            record["validated_tensorboard_retention"] = {
                "last50_mean": tb["last50_mean"],
                "last50_median": tb["last50_median"],
                "last50_std": tb["last50_std"],
                "final_reward": tb["final_reward"],
                "retention_ratio": tb["retention_ratio"],
                "source": "validated TensorBoard stream; 500 training iterations",
            }
        records.append(record)
    return records


def validate_eval_artifacts(case_dir: Path) -> None:
    required = [case_dir / "metrics.json", case_dir / "all_seeds.json", case_dir / "signals.npz"]
    required.extend(case_dir / f"vorticity_{state}.png" for state in ("target", "controlled", "zero"))
    missing = [str(path) for path in required if not path.exists()]
    if missing:
        raise FileNotFoundError("Missing required retained evaluation artifacts: " + ", ".join(missing))


def load_eval(root: Path) -> tuple[list[dict[str, Any]], dict[str, dict[str, np.ndarray]]]:
    rows, signals = [], {}
    for case_id, spec in CASE_REGISTRY.items():
        case_dir = root / case_id
        validate_eval_artifacts(case_dir)
        metrics = json.loads((case_dir / "metrics.json").read_text())
        seeds = json.loads((case_dir / "all_seeds.json").read_text())
        selected = max(seeds, key=lambda item: float(item["reward"]))
        if str(selected["seed"]) != str(metrics["best_seed"]):
            raise ValueError(f"{case_id}: metrics best_seed does not maximize all_seeds reward")
        with np.load(case_dir / "signals.npz") as archive:
            signals[case_id] = {key: np.asarray(archive[key]) for key in archive.files}
        actions = signals[case_id]["actions"]
        if actions.ndim != 2 or actions.shape[1] != 3 or len(actions) < 180:
            raise ValueError(f"{case_id}: expected actions with shape (>=180, 3), got {actions.shape}")
        tail = actions[-180:]
        row: dict[str, Any] = {
            "case_id": case_id, "label": L[case_id], "group": family(case_id, spec.scene_type),
            "selected_seed": int(metrics["best_seed"]), "seed_count": len(seeds),
            "evaluation_scope": "multi-seed selection" if len(seeds) > 1 else "single-seed demonstration",
            "eval_reward_mean": float(metrics["reward_mean"]),
            "eval_r_cd_mean": float(metrics["r_cd_mean"]),
            "eval_r_cl_mean": float(metrics["r_cl_mean"]),
            "eval_r_sim_mean": float(metrics["r_sim_mean"]),
            "dtw_similarity": float(metrics["dtw_sim_v5"]),
            "artifact_lineage": "retained canonical historical-policy evaluation artifact",
            "policy_normalization_contract": "legacy-policy-v1 compatibility path",
            "not_retraining_evidence": True,
        }
        for index, name in enumerate(ACTION_NAMES):
            row[f"action_{name}_mean"] = float(tail[:, index].mean())
            row[f"action_{name}_rms"] = float(np.sqrt(np.mean(tail[:, index] ** 2)))
            row[f"action_{name}_saturation_fraction"] = float(np.mean(np.abs(tail[:, index]) >= 0.95))
        row["action_any_saturation_fraction"] = float(np.mean(np.any(np.abs(tail) >= 0.95, axis=1)))
        rows.append(row)
    return rows, signals


def plot_abstract(out: Path) -> None:
    fig, ax = plt.subplots(figsize=(13.33, 7.5))
    ax.axis("off")
    ax.text(.03, .94, "How to read retained V5 scratch/evaluation evidence", fontsize=22, weight="bold", va="top")
    titles = ["1  Training provenance", "2  Validate stream", "3  Retained evaluation", "4  Independent evidence"]
    bodies = [
        "15 historical scratch runs\nmeta.json + train.log authoritative\ntransfer excluded",
        "TB checked against meta\nRe100 validated\nmismatches never used scientifically",
        "360 recorded steps\nlegacy-policy-v1 compatibility\nnot a current-v2 retraining claim",
        "reward components + DTW\nactions + signals + vorticity\ndifferent scientific questions",
    ]
    for x, title, body in zip([.04, .275, .51, .745], titles, bodies):
        ax.add_patch(plt.Rectangle((x, .57), .205, .23, fc="white", ec=C["blue"], lw=1.5))
        ax.text(x + .012, .76, title, fontsize=11.5, weight="bold", color=C["blue"], va="top")
        ax.text(x + .012, .71, body, fontsize=9.5, va="top", linespacing=1.5)
    for x in [.252, .487, .722]:
        ax.annotate("", xy=(x + .012, .685), xytext=(x - .012, .685), arrowprops={"arrowstyle": "->", "color": C["gray"]})
    notes = [
        (.45, "Training unit: one PPO learn chunk + deterministic evaluation; call it training iteration, not Gym episode.", 12, "black"),
        (.37, "Higher reward/components are better. DTW is signal similarity—not physical field error.", 11, "black"),
        (.29, "Target = desired reference; Controlled = policy outcome; Zero = no-rotation baseline.", 11, "black"),
        (.21, "Retained actions/vorticity predate the calibration-v2/native-normalization source contract.", 11, "red"),
        (.13, "d/L₀=0.75 and 2.0 used lr=1e-4; most runs used 3e-4. Geometry is not a pure one-factor ablation.", 11, "red"),
        (.055, "Only Re100 has n=5 replication; all other points are n=1 demonstrations.", 11, "black"),
    ]
    for y, text, size, color in notes:
        ax.text(.04, y, text, fontsize=size, color=C[color], weight="bold" if color == "red" or y == .055 else None)
    save(fig, out, "fig00_graphical_abstract_reading_guide")


def plot_authoritative_training(training: list[dict[str, Any]], evaluation: list[dict[str, Any]], out: Path) -> None:
    eval_by = {row["case_id"]: row for row in evaluation}
    by_case: dict[str, list[dict[str, Any]]] = {}
    for record in training:
        by_case.setdefault(record["case_id"], []).append(record)
    fig, axes = plt.subplots(1, 3, figsize=(12, 4.4), sharey=True)
    for ax, (title, cases) in zip(axes, GROUPS):
        train_values = [np.median([r["authoritative_best_reward"] for r in by_case[c]]) for c in cases]
        eval_values = [eval_by[c]["eval_reward_mean"] for c in cases]
        x = np.arange(len(cases)); width = .36
        ax.bar(x - width / 2, train_values, width, label="Authoritative training best", color=C["blue"])
        ax.bar(x + width / 2, eval_values, width, label="Retained deterministic eval", color=C["green"])
        ax.set_xticks(x, [L[c] for c in cases], rotation=25, ha="right")
        ax.set_title(title); ax.set_ylim(0, 1.05); ax.grid(axis="y")
        if title == "Cross-Re": ax.set_ylabel("Reward [0–1]")
    axes[0].legend(frameon=False, fontsize=7)
    fig.suptitle("Authoritative best-checkpoint metadata versus retained deterministic evaluation", fontsize=14)
    fig.text(.5, .01, "Training: meta.json (Re100 median across n=5; all others n=1). Eval: compatibility-path historical policy artifacts. Values are distinct evidence streams.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .04, 1, .95)); save(fig, out, "fig04_authoritative_training_vs_eval")

    order = list(CASE_REGISTRY); x = np.arange(len(order))
    best_iterations = [np.median([r["authoritative_best_iteration"] for r in by_case[c]]) for c in order]
    gaps = [eval_by[c]["eval_reward_mean"] - np.median([r["authoritative_best_reward"] for r in by_case[c]]) for c in order]
    fig, axes = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
    axes[0].bar(x, best_iterations, color=C["purple"])
    axes[0].set_ylabel("Best training iteration"); axes[0].set_title("Best-checkpoint location from authoritative run metadata")
    axes[1].bar(x, gaps, color=[C["green"] if value >= 0 else C["red"] for value in gaps])
    axes[1].axhline(0, color=C["black"], lw=.8); axes[1].set_ylabel("Eval − training-best reward"); axes[1].set_xlabel("Canonical case")
    axes[1].set_xticks(x, [L[c] for c in order], rotation=28, ha="right")
    for ax in axes: ax.grid(axis="y")
    fig.text(.5, .01, "Metadata and retained eval are not paired measurements under one normalization contract; the gap is descriptive, not a generalization estimate. d0.75/d2 use lr=1e-4.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .04, 1, 1)); save(fig, out, "fig05_authoritative_iteration_and_eval_gap")


def plot_eval(rows: list[dict[str, Any]], out: Path) -> None:
    by = {row["case_id"]: row for row in rows}
    metrics = [("eval_reward_mean", "Deterministic eval reward", C["blue"]), ("eval_r_cd_mean", r"Drag component $r_{CD}$", C["green"]), ("dtw_similarity", "DTW signal similarity", C["purple"])]
    fig, axes = plt.subplots(3, 3, figsize=(12, 9), sharey="row")
    for column, (group, cases) in enumerate(GROUPS):
        for row_index, (key, ylabel, color) in enumerate(metrics):
            ax = axes[row_index, column]; values = [by[case][key] for case in cases]
            bars = ax.bar(range(4), values, color=color, width=.68)
            ax.set_xticks(range(4), [L[case] for case in cases], rotation=25, ha="right"); ax.set_ylim(0, 1.04); ax.grid(axis="y")
            if row_index == 0: ax.set_title(group, weight="bold")
            if column == 0: ax.set_ylabel(ylabel)
            for bar, value in zip(bars, values): ax.text(bar.get_x() + bar.get_width() / 2, value + .015, f"{value:.2f}", ha="center", fontsize=7)
    fig.suptitle("Retained canonical deterministic evaluations (historical-policy compatibility path)", fontsize=14)
    fig.text(.5, .01, "Source: metrics.json for reward-maximizing seed in all_seeds.json. Retained artifact evidence, not proof of current calibration-v2/native retraining. DTW is signal similarity.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .035, 1, .96)); save(fig, out, "fig07_eval_cross_scene_metrics")
    order = list(CASE_REGISTRY); x = np.arange(len(order)); width = .19; fig, ax = plt.subplots(figsize=(11, 4.8))
    for index, (key, label, color) in enumerate([("eval_reward_mean", "Reward", C["black"]), ("eval_r_cd_mean", r"$r_{CD}$", C["blue"]), ("eval_r_cl_mean", r"$r_{CL}$", C["orange"]), ("eval_r_sim_mean", r"$r_{sim}$", C["purple"])]):
        ax.bar(x + (index - 1.5) * width, [by[c][key] for c in order], width, label=label, color=color)
    ax.set_xticks(x, [L[c] for c in order], rotation=28, ha="right"); ax.set_ylim(0, 1.05); ax.set_ylabel("Mean component value"); ax.set_title("Retained deterministic-evaluation reward components"); ax.grid(axis="y"); ax.legend(ncol=4, frameon=False)
    fig.text(.5, .01, "Historical-policy compatibility path; components do not establish current-contract retraining or physical field agreement.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .04, 1, 1)); save(fig, out, "fig08_eval_reward_components")


def plot_actions(rows: list[dict[str, Any]], signals: dict[str, dict[str, np.ndarray]], out: Path) -> None:
    order = list(CASE_REGISTRY); by = {row["case_id"]: row for row in rows}; x = np.arange(len(order)); width = .23
    fig, axes = plt.subplots(2, 1, figsize=(11, 7.2), sharex=True)
    for index, (name, color) in enumerate(zip(ACTION_NAMES, [C["red"], C["orange"], C["green"]])):
        axes[0].bar(x + (index - 1) * width, [by[c][f"action_{name}_mean"] for c in order], width, label=name.title(), color=color)
        axes[1].bar(x + (index - 1) * width, [by[c][f"action_{name}_saturation_fraction"] for c in order], width, label=name.title(), color=color)
    axes[0].axhline(0, color=C["black"], lw=.8); axes[0].set_ylabel("Tail-180 action mean"); axes[0].set_title("Retained action bias and saturation diagnostics")
    axes[1].set_ylabel("Saturation fraction"); axes[1].set_xticks(x, [L[c] for c in order], rotation=28, ha="right")
    for ax in axes: ax.grid(axis="y"); ax.legend(ncol=3, frameon=False)
    fig.text(.5, .01, "Action layout verified from active env/symmetry code: [front, top, bottom]. Saturation |a|≥0.95 over tail 180. Retained compatibility-path artifacts.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .04, 1, 1)); save(fig, out, "fig09_action_profile_saturation")
    reps = ["kar_re100", "kar_re400", "ill_1L"]; fig, axes = plt.subplots(3, 2, figsize=(11, 7), sharex=True)
    for row_index, case in enumerate(reps):
        signal = signals[case]; time = np.arange(len(signal["rewards"]))
        for action_index, label, color in [(0, "Front", C["red"]), (1, "Top", C["orange"]), (2, "Bottom", C["green"])]:
            axes[row_index, 0].plot(time, signal["actions"][:, action_index], lw=.7, label=label, color=color)
        axes[row_index, 0].axvspan(len(time) - 180, len(time) - 1, color=C["light"], alpha=.45); axes[row_index, 0].set_ylim(-1.05, 1.05); axes[row_index, 0].set_ylabel(L[case] + "\nAction")
        ax = axes[row_index, 1]; ax.plot(time, signal["sensors"][:, 3], lw=.8, color=C["blue"]); ax2 = ax.twinx(); ax2.plot(time, signal["rewards"], lw=.7, color=C["green"]); ax2.tick_params(axis="y", labelcolor=C["green"])
    axes[0, 0].legend(frameon=False, ncol=3, fontsize=7); axes[0, 0].set_title("Policy actions (shaded: reported tail)"); axes[0, 1].set_title("Sensor uy (blue) and reward (green, right axis)")
    for ax in axes[-1]: ax.set_xlabel("Recorded evaluation step")
    fig.suptitle("Representative retained deterministic signal/action diagnostics", fontsize=14)
    fig.text(.5, .01, "Historical-policy compatibility artifacts; not evidence of current-contract retraining. Forces remain unaggregated across scene semantics.", ha="center", fontsize=8, color=C["gray"])
    fig.tight_layout(rect=(0, .035, 1, .96)); save(fig, out, "fig10_representative_signal_diagnostics")


def plot_scope(rows: list[dict[str, Any]], out: Path) -> None:
    order = list(CASE_REGISTRY); by = {row["case_id"]: row for row in rows}; counts = [by[c]["seed_count"] for c in order]
    fig, ax = plt.subplots(figsize=(10.5, 3.9)); bars = ax.bar(range(11), counts, color=[C["blue"] if n > 1 else C["gray"] for n in counts])
    ax.set_xticks(range(11), [L[c] for c in order], rotation=28, ha="right"); ax.set_yticks(range(6)); ax.set_ylim(0, 5.7); ax.set_ylabel("Retained evaluated scratch seeds, n"); ax.set_title("Evidence scope: replication exists only for Re100"); ax.grid(axis="y")
    for bar, n in zip(bars, counts): ax.text(bar.get_x() + bar.get_width() / 2, n + .12, f"n={n}", ha="center", weight="bold")
    fig.text(.5, .01, "n=1 points are demonstrations. Retained historical artifacts do not establish current-contract retraining.", ha="center", color=C["red"])
    fig.tight_layout(rect=(0, .05, 1, 1)); save(fig, out, "fig11_evidence_scope")


def contact_sheet(root: Path, out: Path, title: str, cases: list[str], stem: str) -> None:
    fig, axes = plt.subplots(len(cases), 3, figsize=(12, 2.25 * len(cases)))
    for row_index, case in enumerate(cases):
        for column, state in enumerate(("target", "controlled", "zero")):
            path = root / case / f"vorticity_{state}.png"
            if not path.exists(): raise FileNotFoundError(path)
            with Image.open(path) as image: axes[row_index, column].imshow(image.convert("RGB"))
            axes[row_index, column].axis("off")
            if row_index == 0: axes[row_index, column].set_title(state.title(), weight="bold")
            if column == 0: axes[row_index, column].text(-.02, .5, L[case], transform=axes[row_index, column].transAxes, ha="right", va="center", weight="bold")
    fig.suptitle(title + " — retained compatibility-path artifacts", fontsize=14)
    fig.tight_layout(rect=(.04, 0, 1, .96)); save(fig, out, stem, False)


def write_data(rows: list[dict[str, Any]], training: list[dict[str, Any]], tables: Path) -> None:
    tables.mkdir(parents=True, exist_ok=True)
    with (tables / "latest_eval_summary.csv").open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(rows[0])); writer.writeheader(); writer.writerows(rows)
    payload = {
        "schema": "drl-pinball-latest-results-v2",
        "scope": {"canonical_cases": 11, "scratch_runs": 15, "transfer_excluded": True, "eval_steps": 360, "action_tail_steps": 180, "saturation_threshold_abs": .95, "action_layout": ["front", "top", "bottom"]},
        "lineage": {"training_authority": "run meta.json and train.log", "tensorboard_role": "validated auxiliary stream; mismatch values not authoritative", "evaluation_authority": "retained canonical artifacts evaluated through legacy-policy-v1 compatibility", "current_contract_caveat": "artifacts predate and do not prove complete retraining under calibration-v2/native policy normalization"},
        "confounders": [{"cases": ["kar_d075", "kar_d2"], "learning_rate": 1e-4, "comparison_warning": "variable-disturbance geometry sweep is not a pure one-factor ablation; most runs use 3e-4"}],
        "training": training,
        "re100_validated_tensorboard_retention": [record for record in training if record["case_id"] == "kar_re100"],
        "evaluation": rows,
    }
    (tables / "latest_results_summary.json").write_text(json.dumps(payload, indent=2) + "\n")


def write_guide(root: Path) -> None:
    text = """# 最新 Train 保留证据图包阅读指南

## 最重要的证据谱系
本图包使用 15 个历史 scratch run 和 11 个 canonical eval 目录中的**保留 artifacts**。它们早于当前 calibration-v2/native policy normalization 源码契约，不能被表述为“修复后已完整重训”。历史 PPO/VecNormalize 评估使用明确的 `legacy-policy-v1` compatibility path。

训练最佳值以每个 run 的 `meta.json` 为权威，并用 `train.log` 提取同一 training iteration 的分量（若存在）。TensorBoard 仅作为被校验的辅助流；若与 meta 不一致，不使用其科学数值。一个所谓“episode”实际是 PPO learn chunk 加确定性评估，本图包称为 **training iteration**，不是终止型 Gym episode。

## 证据边界与混杂
只有 `kar_re100` 有 5 个种子；其余点均为 n=1 演示，不能估计种子不确定性。`kar_d075` 和 `kar_d2` 使用学习率 `1e-4`，多数 run 使用 `3e-4`，因此 variable-disturbance geometry 比较不是纯粹单因素消融。

## 两条不可混淆的证据链
1. **训练期：** authoritative best reward/iteration 来自 `meta.json`；同 iteration 分量来自 `train.log`。仅 Re100 的 retention/final 使用经 meta 校验一致的 TensorBoard。
2. **保留确定性评估：** `metrics.json` 对应 `all_seeds.json` 中 reward 最高 seed；signals/vorticity 是历史 policy compatibility-path artifacts，不等同于训练 reward，也不证明当前契约重训。

## 指标、动作与图像
Reward、`r_CD`、`r_CL`、`r_sim`、DTW similarity 均越高越好；DTW 是时间信号相似度，不是物理场误差。Target/Controlled/Zero 用于涡量结构对照。动作顺序已由 active env 与 symmetry code 核实为 `[front, top, bottom]`。动作均值接近零可能是振荡抵消；饱和定义为 `|a| >= 0.95`，窗口为最后 180 步。

## 图号与实际文件
- `fig00_graphical_abstract_reading_guide`：谱系与阅读规则。
- 仅 Re100、且 TB 已经 meta 校验：`fig01_re100_multiseed_learning`、`fig02_re100_discovery_drawdown`、`fig02_re100_policy_retention`、`fig03_re100_reward_components`。
- 全 case authoritative/meta 与 retained eval：`fig04_authoritative_training_vs_eval`、`fig05_authoritative_iteration_and_eval_gap`。本包没有 `fig06`。
- retained eval：`fig07_eval_cross_scene_metrics`、`fig08_eval_reward_components`、`fig09_action_profile_saturation`、`fig10_representative_signal_diagnostics`、`fig11_evidence_scope`。
- retained vorticity：`fig12_vorticity_cross_re`、`fig13_vorticity_disturbance`、`fig14_vorticity_illusion`。

## 复现
```bash
conda run -n pycuda_3_10 python src/drl_pinball/train/plot_latest_results.py
```
脚本要求 signals 和三类 vorticity PNG 全部存在，否则失败；不加载 fields.npz、不重跑 CFD。
"""
    (root / "HOW_TO_READ.md").write_text(text)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__); here = Path(__file__).resolve().parent
    parser.add_argument("--train-root", type=Path, default=here / "output")
    parser.add_argument("--eval-root", type=Path, default=here.parent / "eval" / "output" / "train")
    parser.add_argument("--output-dir", type=Path, default=here / "results" / "latest")
    args = parser.parse_args(); figures = args.output_dir / "figures"; tables = args.output_dir / "tables"
    figures.mkdir(parents=True, exist_ok=True); trainplot.configure_style()
    runs = trainplot.discover_runs(args.train_root)
    if len(runs) != 15 or {run.case for run in runs} != set(CASE_REGISTRY): raise ValueError("Expected 15 runs across 11 canonical cases")
    training = load_training(runs); rows, signals = load_eval(args.eval_root)
    plot_abstract(figures)
    re100_runs = [run for run in runs if run.case == "kar_re100"]
    if not all(record["tensorboard_consistent"] for record in training if record["case_id"] == "kar_re100"): raise ValueError("Re100 TensorBoard is not consistent with metadata")
    trainplot.plot_multiseed(re100_runs, figures, 20); trainplot.plot_exploration_drawdown(re100_runs, figures); trainplot.plot_retention(re100_runs, figures); trainplot.plot_components(re100_runs, figures, 20)
    plot_authoritative_training(training, rows, figures); plot_eval(rows, figures); plot_actions(rows, signals, figures); plot_scope(rows, figures)
    contact_sheet(args.eval_root, figures, "Cross-Re vorticity evidence", GROUPS[0][1], "fig12_vorticity_cross_re")
    contact_sheet(args.eval_root, figures, "Variable-disturbance vorticity evidence", GROUPS[1][1], "fig13_vorticity_disturbance")
    contact_sheet(args.eval_root, figures, "Illusion vorticity evidence", GROUPS[2][1], "fig14_vorticity_illusion")
    write_data(rows, training, tables); write_guide(args.output_dir)
    mismatches = sum(not record["tensorboard_consistent"] for record in training)
    print(f"Generated {len(rows)} retained cases / {len(training)} runs; TensorBoard mismatches recorded: {mismatches} -> {args.output_dir}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
