#!/usr/bin/env python3
"""Export a phase-matched Target/PPO/SR Kármán vorticity comparison.

CUDA-backed modules are imported only after argument validation.  The exporter
uses the canonical Stage-3 environment and policy constructors for controlled
runs and mirrors ``build_karman_cloak_env`` exactly for the target-only run.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import platform
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence

import numpy as np

REPO_ROOT = Path(__file__).resolve().parents[3]
SRC_ROOT = REPO_ROOT / "src"
for root in (REPO_ROOT, SRC_ROOT):
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

from SR_analysis.configs import FIFO_LEN, LEGACY_CFG_DIR, get_scene  # noqa: E402
from SR_analysis.stage_3_validate import (  # noqa: E402
    DATA_TYPE,
    ValidationPlan,
    build_karman_environment,
    build_policy,
    load_formula_pair,
    prepare_plan,
)
from SR_analysis.utils.provenance import atomic_write_json, hash_file, hash_json  # noqa: E402

STEM = "07_flow_field_comparison_karman_re100"
SCHEMA = "sr-flow-comparison-v2"
CANDIDATE_DTYPE = np.dtype(np.float32)
DEFAULT_PACKAGE = REPO_ROOT / "src/SR_analysis/results/runs/article2-plotting-package-20260721"
DEFAULT_FORMULAS = REPO_ROOT / "src/SR_analysis/results/runs/article-refit-karman-topology-a-20260718/formulas"
DEFAULT_ALIGNMENT = DEFAULT_PACKAGE / "phase_alignment.json"
D_LATTICE = 20.0
SENSOR_LAYOUT = ("upper_ux", "upper_uy", "center_ux", "center_uy", "lower_ux", "lower_uy")


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def git_sha() -> str | None:
    try:
        return subprocess.run(["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"], check=True, capture_output=True, text=True).stdout.strip()
    except (OSError, subprocess.CalledProcessError):
        return None


def wrapped_phase_difference(a: np.ndarray | float, b: np.ndarray | float) -> np.ndarray:
    """Signed shortest angular difference ``a-b`` in [-pi, pi]."""
    delta = np.asarray(a, dtype=np.float64) - np.asarray(b, dtype=np.float64)
    return np.arctan2(np.sin(delta), np.cos(delta))


def standardized_center_phase(trace: np.ndarray, start: int, stop: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Return center-sensor phase from per-trajectory stable-window z scores."""
    sensors = np.asarray(trace, dtype=np.float64)
    if sensors.ndim != 2 or sensors.shape[1] < 4 or not 0 <= start < stop <= len(sensors):
        raise ValueError("trace/window must provide center (ux,uy) channels")
    center = sensors[start:stop, 2:4]
    mean = center.mean(axis=0)
    scale = center.std(axis=0, ddof=0)
    if np.any(~np.isfinite(scale)) or np.any(scale <= np.finfo(float).eps):
        raise ValueError("stable-window center-sensor scale is zero or non-finite")
    z = (center - mean) / scale
    return np.arctan2(z[:, 1], z[:, 0]), mean, scale


def select_joint_phase_match(
    target_trace: np.ndarray,
    ppo_trace: np.ndarray,
    sr_trace: np.ndarray,
    start: int,
    stop: int,
    *,
    temporal_weight: float = 0.001,
) -> dict[str, Any]:
    """Jointly match downstream center-sensor limit-cycle phase.

    Exhaustive stable-window search minimizes the two wrapped phase errors plus
    ``temporal_weight * (|i_ppo-i_target| + |i_sr-i_target|)``.  Requiring the
    same local phase direction rejects branch-reversed phase-portrait matches.
    """
    if temporal_weight < 0:
        raise ValueError("temporal_weight must be non-negative")
    phases, means, scales, directions = [], {}, {}, []
    for label, trace in (("Target", target_trace), ("PPO", ppo_trace), ("SR", sr_trace)):
        theta, mean, scale = standardized_center_phase(trace, start, stop)
        phases.append(theta); means[label] = mean.tolist(); scales[label] = scale.tolist()
        directions.append(np.sign(np.gradient(np.unwrap(theta))))
    best = None
    for ti, target_index in enumerate(range(start, stop)):
        for pi, ppo_index in enumerate(range(start, stop)):
            if directions[1][pi] != directions[0][ti]:
                continue
            for si, sr_index in enumerate(range(start, stop)):
                if directions[2][si] != directions[0][ti]:
                    continue
                ppo_error = abs(float(wrapped_phase_difference(phases[1][pi], phases[0][ti])))
                sr_error = abs(float(wrapped_phase_difference(phases[2][si], phases[0][ti])))
                temporal_distance = abs(ppo_index-target_index) + abs(sr_index-target_index)
                penalty = temporal_weight * temporal_distance
                objective = ppo_error + sr_error + penalty
                key = (objective, temporal_distance, target_index, ppo_index, sr_index)
                if best is None or key < best[0]:
                    best = (key, ti, pi, si, ppo_error, sr_error, penalty)
    if best is None:
        raise ValueError("no same-direction phase match in stable window")
    key, ti, pi, si, ppo_error, sr_error, penalty = best
    indices = {"Target": start+ti, "PPO": start+pi, "SR": start+si}
    return {
        "indices": indices,
        "phase_angles_rad": {"Target": float(phases[0][ti]), "PPO": float(phases[1][pi]), "SR": float(phases[2][si])},
        "wrapped_angle_errors_rad": {"PPO": ppo_error, "SR": sr_error},
        "local_phase_direction": {"Target": int(directions[0][ti]), "PPO": int(directions[1][pi]), "SR": int(directions[2][si])},
        "stable_window_center_mean": means,
        "stable_window_center_std": scales,
        "objective_terms": {"angle_error_sum_rad": ppo_error+sr_error, "temporal_distance_samples": int(key[1]), "temporal_weight_rad_per_sample": temporal_weight, "temporal_penalty_rad": penalty, "objective": float(key[0])},
        "candidate_count_per_trajectory": stop-start,
    }


def orient_vorticity_xy_to_yx(omega_xy: np.ndarray, field_shape: Sequence[int]) -> np.ndarray:
    """Convert vorticity_from_ddf's (NX, NY) result to image (NY, NX)."""
    nx, ny = map(int, field_shape[:2])
    omega = np.asarray(omega_xy)
    if omega.shape != (nx, ny):
        raise ValueError(f"expected vorticity shape {(nx, ny)}, got {omega.shape}")
    return omega.T.copy()


def crop_yx(field_yx: np.ndarray, xlim_d: Sequence[float], ylim_d: Sequence[float], *, d_lattice: float = D_LATTICE, center_y_lattice: float | None = None) -> tuple[np.ndarray, dict[str, Any]]:
    """Crop an image-oriented field using physical x/D and centered y/D."""
    field = np.asarray(field_yx)
    if field.ndim != 2:
        raise ValueError("field must be 2-D in (y, x) order")
    ny, nx = field.shape
    cy = (ny - 1) / 2 if center_y_lattice is None else float(center_y_lattice)
    x0 = max(0, int(np.ceil(float(xlim_d[0]) * d_lattice)))
    x1 = min(nx, int(np.floor(float(xlim_d[1]) * d_lattice)) + 1)
    y0 = max(0, int(np.ceil(cy + float(ylim_d[0]) * d_lattice)))
    y1 = min(ny, int(np.floor(cy + float(ylim_d[1]) * d_lattice)) + 1)
    if x0 >= x1 or y0 >= y1:
        raise ValueError("requested crop does not intersect field")
    extent = ((x0 / d_lattice), ((x1 - 1) / d_lattice), ((y0 - cy) / d_lattice), ((y1 - 1 - cy) / d_lattice))
    return field[y0:y1, x0:x1].copy(), {"x_slice": [x0, x1], "y_slice": [y0, y1], "extent_xD_yD": list(extent)}


def manifest_artifacts(package_dir: Path, repo_root: Path = REPO_ROOT) -> list[dict[str, str]]:
    suffixes = {".csv", ".png", ".pdf", ".md", ".json", ".npz"}
    return [
        {"path": str(path.relative_to(repo_root)), "sha256": hash_file(path)}
        for path in sorted(package_dir.rglob("*"))
        if path.is_file() and path.suffix.lower() in suffixes and path.name != "manifest.json"
    ]


def update_package_manifest(package_dir: Path, repo_root: Path = REPO_ROOT) -> None:
    path = package_dir / "manifest.json"
    old = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
    summary = dict(old.get("summary", {}))
    summary["publication_figures"] = max(5, int(summary.get("publication_figures", 0)))
    summary["flow_field_npz"] = 1
    summary["presentation_pages"] = 2
    atomic_write_json(path, {"schema_version": "sr-plotting-package-v3", "source_policy": old.get("source_policy", "immutable article artifacts; no scientific refit"), "summary": summary, "artifacts": manifest_artifacts(package_dir, repo_root)})


def _runtime_cfd() -> tuple[Any, Any, Any]:
    from LegacyCelerisLab import FlowField
    from SR_analysis.utils.cfd_interface import load_legacy_configs, vorticity_from_ddf
    return FlowField, load_legacy_configs, vorticity_from_ddf


def _candidate_index_array(start: int, stop: int) -> np.ndarray:
    """Return the exact trace indices represented by stable-window fields."""
    if not 0 <= start < stop:
        raise ValueError("candidate window must satisfy 0 <= start < stop")
    return np.arange(start, stop, dtype=np.int64)


def select_exact_candidate_field(
    candidates: np.ndarray,
    candidate_trace_indices: np.ndarray,
    selected_trace_index: int,
    *,
    expected_start: int,
    expected_stop: int,
) -> tuple[np.ndarray, int]:
    """Select a field by its same-run trace index, rejecting mapping drift."""
    fields = np.asarray(candidates)
    indices = np.asarray(candidate_trace_indices)
    expected = _candidate_index_array(expected_start, expected_stop)
    if fields.ndim != 3 or fields.dtype != CANDIDATE_DTYPE:
        raise ValueError("candidate fields must be a float32 (sample,y,x) array")
    if indices.ndim != 1 or not np.issubdtype(indices.dtype, np.integer):
        raise ValueError("candidate trace indices must be a one-dimensional integer array")
    if fields.shape[0] != len(indices):
        raise ValueError("candidate field/index counts differ")
    if not np.array_equal(indices, expected):
        raise ValueError("candidate trace-index mapping does not exactly cover the stable window")
    matches = np.flatnonzero(indices == int(selected_trace_index))
    if len(matches) != 1:
        raise IndexError("selected trace index has no unique same-run candidate field")
    candidate_index = int(matches[0])
    if candidate_index != int(selected_trace_index) - expected_start:
        raise AssertionError("candidate offset and trace index disagree")
    return fields[candidate_index], candidate_index


def _allocate_candidates(field_shape: Sequence[int], start: int, stop: int) -> np.ndarray:
    nx, ny = map(int, field_shape[:2])
    return np.empty((stop - start, ny, nx), dtype=CANDIDATE_DTYPE)


def _store_candidate(
    candidates: np.ndarray,
    trace_index: int,
    start: int,
    omega_xy: np.ndarray,
    field_shape: Sequence[int],
) -> None:
    candidate_index = trace_index - start
    if not 0 <= candidate_index < len(candidates):
        raise IndexError("candidate trace index lies outside allocated stable window")
    field = D_LATTICE * orient_vorticity_xy_to_yx(omega_xy, field_shape)
    candidates[candidate_index] = np.asarray(field, dtype=CANDIDATE_DTYPE)


def _target_trace_and_candidates(
    cfg: Mapping[str, Any], device: int, n_samples: int, candidate_start: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[int, int]]:
    """Run Target once and couple every stable trace sample to its field."""
    FlowField, load_configs, vorticity_from_ddf = _runtime_cfd()
    cuda_cfg, field_cfg = load_configs(LEGACY_CFG_DIR)
    ff = FlowField(field_cfg._replace(viscosity=float(cfg["nu"])), cuda_cfg, device_id=device)
    try:
        cy = (ff.FIELD_SHAPE[1] - 1) / 2
        ff.add_cylinder((10.0 * D_LATTICE, cy, 0.0), D_LATTICE)
        for y_off in (2.0, 0.0, -2.0):
            ff.add_sensor((40.0 * D_LATTICE, cy + y_off * D_LATTICE, 0.0), D_LATTICE / 4.0)
        n_obj = ff.obs.size // 2
        zero = np.zeros(n_obj, dtype=DATA_TYPE)
        ff.run(int(4 * ff.FIELD_SHAPE[0] / float(cfg["u0"])), zero)
        rows: list[np.ndarray] = []
        candidates = _allocate_candidates(ff.FIELD_SHAPE, candidate_start, n_samples)
        candidate_indices = _candidate_index_array(candidate_start, n_samples)
        for index in range(n_samples):
            ff.run(int(cfg["sample_interval"]), zero)
            rows.append(ff.obs.copy()[2:8].astype(np.float64))
            if index >= candidate_start:
                _store_candidate(candidates, index, candidate_start, vorticity_from_ddf(ff, float(cfg["u0"])), ff.FIELD_SHAPE)
        if len(rows) != n_samples or len(candidates) != n_samples - candidate_start:
            raise AssertionError("Target trace/candidate collection is incomplete")
        return np.asarray(rows), candidates, candidate_indices, tuple(map(int, ff.FIELD_SHAPE[:2]))
    finally:
        del ff


def _controlled_trace_and_candidates(
    plan: ValidationPlan, device: int, n_samples: int, candidate_start: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[int, int]]:
    """Run one controller once and couple stable trace samples to fields."""
    env = build_karman_environment(plan, device)
    try:
        policy = build_policy(plan)
        raw = np.asarray(env.current_raw, dtype=np.float64)
        rows: list[np.ndarray] = []
        candidates = _allocate_candidates(env.ff.FIELD_SHAPE, candidate_start, n_samples)
        candidate_indices = _candidate_index_array(candidate_start, n_samples)
        _, _, vorticity_from_ddf = _runtime_cfd()
        for index in range(n_samples):
            omega, _, _ = policy.action(raw, index)
            raw = env.step(omega)
            policy.observe(omega)
            rows.append(raw[:6].copy())
            if index >= candidate_start:
                _store_candidate(candidates, index, candidate_start, vorticity_from_ddf(env.ff, float(plan.cfg["u0"])), env.ff.FIELD_SHAPE)
        if len(rows) != n_samples or len(candidates) != n_samples - candidate_start:
            raise AssertionError("controlled trace/candidate collection is incomplete")
        return np.asarray(rows), candidates, candidate_indices, tuple(map(int, env.ff.FIELD_SHAPE[:2]))
    finally:
        env.close()


def _make_plan(scene: str, mode: str, n_steps: int, pair: Any | None, model_device: str) -> ValidationPlan:
    plan = prepare_plan(scene=scene, mode=mode, n_steps=n_steps, run_id="flow-comparison-in-memory", output_root=REPO_ROOT / ".flow-comparison-unused", formula_pair=pair)
    return ValidationPlan(**{**plan.__dict__, "cfg": {**plan.cfg, "model_device": model_device}})


def _plot(fields: Mapping[str, np.ndarray], crop_meta: Mapping[str, Any], cfg: Mapping[str, Any], output_dir: Path, vmax: float) -> None:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.patches import Circle

    fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.3), sharex=True, sharey=True, constrained_layout=True)
    extent = crop_meta["extent_xD_yD"]
    for ax, label in zip(axes, ("Target", "PPO", "SR")):
        ax.imshow(fields[label], origin="lower", extent=extent, aspect="equal", cmap="RdBu_r", vmin=-vmax, vmax=vmax, interpolation="nearest")
        geometry = [(10.0, 0.0, 1.0, "disturbance")] if label == "Target" else [
            (10.0, 0.0, 1.0, "disturbance"),
            (float(cfg["pinball_front_x"]), 0.0, 0.5, "front"),
            (float(cfg["pinball_rear_x"]), 0.75, 0.5, "upper"),
            (float(cfg["pinball_rear_x"]), -0.75, 0.5, "lower"),
        ]
        for x, y, radius, name in geometry:
            ax.add_patch(Circle((x, y), radius, facecolor="white", edgecolor="black", linewidth=0.8, zorder=4))
        ax.scatter([40.0] * 3, [2.0, 0.0, -2.0], s=12, marker="x", color="black", linewidths=0.8, zorder=5)
        ax.set_title(label)
        ax.set_xlabel(r"$x/D$")
    axes[0].set_ylabel(r"$y/D$")
    for suffix in ("png", "pdf"):
        fig.savefig(output_dir / f"{STEM}.{suffix}", dpi=300 if suffix == "png" else None)
    plt.close(fig)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--scene", default="karman_re100", choices=("karman_re100",), help="explicit canonical scene")
    parser.add_argument("--package-dir", type=Path, default=DEFAULT_PACKAGE)
    parser.add_argument("--alignment-metadata", type=Path, default=DEFAULT_ALIGNMENT)
    parser.add_argument("--formula-front", type=Path, default=DEFAULT_FORMULAS / "joint_front.json")
    parser.add_argument("--formula-rear", type=Path, default=DEFAULT_FORMULAS / "joint_rear_shared_upper.json")
    parser.add_argument("--temporal-weight", type=float, default=0.001, help="phase objective penalty in radians per sample of temporal separation")
    parser.add_argument("--device", type=int, default=0, help="logical CFD device after CUDA_VISIBLE_DEVICES masking")
    parser.add_argument("--model-device", choices=("cpu",), default="cpu")
    parser.add_argument("--xlim", nargs=2, type=float, default=(7.0, 48.0), metavar=("XMIN_D", "XMAX_D"))
    parser.add_argument("--ylim", nargs=2, type=float, default=(-6.0, 6.0), metavar=("YMIN_D", "YMAX_D"))
    parser.add_argument("--replace", action="store_true")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    package = args.package_dir.resolve()
    outputs = [package / f"{STEM}.npz", package / f"{STEM}.json", package / "figures" / f"{STEM}.png", package / "figures" / f"{STEM}.pdf"]
    existing = [p for p in outputs if p.exists()]
    if existing and not args.replace:
        raise FileExistsError("refusing to overwrite: " + ", ".join(map(str, existing)))
    alignment = json.loads(args.alignment_metadata.read_text(encoding="utf-8"))
    start, stop = int(alignment["target_index_start_inclusive"]), int(alignment["target_index_stop_exclusive"])
    cfg = get_scene(args.scene)
    if cfg["scene_id"] != "karman":
        raise ValueError("only the Kármán environment is supported")
    pair = load_formula_pair(args.formula_front, args.formula_rear)
    n_samples = stop
    ppo_plan = _make_plan(args.scene, "ppo", n_samples, None, args.model_device)
    sr_plan = _make_plan(args.scene, "pysr", n_samples, pair, args.model_device)

    # Each condition is initialized and run exactly once. The three float32
    # stable-window buffers remain in host memory until joint phase selection.
    target_trace, target_candidates, target_candidate_indices, shape = _target_trace_and_candidates(
        cfg, args.device, n_samples, start
    )
    ppo_trace, ppo_candidates, ppo_candidate_indices, ppo_shape = _controlled_trace_and_candidates(
        ppo_plan, args.device, n_samples, start
    )
    sr_trace, sr_candidates, sr_candidate_indices, sr_shape = _controlled_trace_and_candidates(
        sr_plan, args.device, n_samples, start
    )
    if shape != ppo_shape or shape != sr_shape:
        raise ValueError("field shapes differ between cases")

    phase_match = select_joint_phase_match(target_trace, ppo_trace, sr_trace, start, stop, temporal_weight=args.temporal_weight)
    selected_indices = phase_match["indices"]
    candidate_sets = {
        "Target": (target_candidates, target_candidate_indices),
        "PPO": (ppo_candidates, ppo_candidate_indices),
        "SR": (sr_candidates, sr_candidate_indices),
    }
    full: dict[str, np.ndarray] = {}
    selected_candidate_indices: dict[str, int] = {}
    for label, (candidates, trace_indices) in candidate_sets.items():
        field, candidate_index = select_exact_candidate_field(
            candidates, trace_indices, selected_indices[label], expected_start=start, expected_stop=stop
        )
        full[label] = field.copy()
        selected_candidate_indices[label] = candidate_index
    candidate_field_bytes = int(sum(candidates.nbytes for candidates, _ in candidate_sets.values()))
    cropped: dict[str, np.ndarray] = {}
    crop_meta = None
    for label, field in full.items():
        cropped[label], meta = crop_yx(field, args.xlim, args.ylim)
        crop_meta = crop_meta or meta
        if meta != crop_meta:
            raise AssertionError("inconsistent crop metadata")
    finite = np.concatenate([np.abs(value[np.isfinite(value)]) for value in cropped.values()])
    if finite.size == 0:
        raise FloatingPointError("captured fields contain no finite vorticity")
    vmax = float(np.percentile(finite, 99.5))
    if not np.isfinite(vmax) or vmax <= 0:
        raise FloatingPointError("invalid shared color normalization")

    package.mkdir(parents=True, exist_ok=True)
    (package / "figures").mkdir(parents=True, exist_ok=True)
    npz_path = outputs[0]
    np.savez_compressed(npz_path, target_vorticity_yx=full["Target"], ppo_vorticity_yx=full["PPO"], sr_vorticity_yx=full["SR"], target_sensors=target_trace, ppo_sensors=ppo_trace, sr_sensors=sr_trace, selected_indices=np.asarray([selected_indices[x] for x in ("Target", "PPO", "SR")]), phase_angles_rad=np.asarray([phase_match["phase_angles_rad"][x] for x in ("Target", "PPO", "SR")]), wrapped_angle_errors_rad=np.asarray([0.0, phase_match["wrapped_angle_errors_rad"]["PPO"], phase_match["wrapped_angle_errors_rad"]["SR"]]), local_phase_direction=np.asarray([phase_match["local_phase_direction"][x] for x in ("Target", "PPO", "SR")]), selected_candidate_indices=np.asarray([selected_candidate_indices[x] for x in ("Target", "PPO", "SR")]), extent_xD_yD=np.asarray(crop_meta["extent_xD_yD"]), crop_x_slice=np.asarray(crop_meta["x_slice"]), crop_y_slice=np.asarray(crop_meta["y_slice"]), field_shape_xy=np.asarray(shape), sensor_layout=np.asarray(SENSOR_LAYOUT))
    _plot(cropped, crop_meta, cfg, package / "figures", vmax)

    dt = float(cfg["control_dt"])
    model_path = Path(ppo_plan.model_path) if ppo_plan.model_path else None
    metadata = {
        "schema_version": SCHEMA, "scene": args.scene, "description": "single deterministic downstream center-sensor limit-cycle phase snapshot; not full-field identity or an ensemble",
        "phase_matching": {"method": "one run per condition with same-sample stable-window field capture; exhaustive joint phase search", "phase_definition": "theta=atan2(z(center_uy), z(center_ux)); each trajectory standardized separately over stable window", "objective_formula": "|wrap(theta_PPO-theta_Target)| + |wrap(theta_SR-theta_Target)| + w*(|i_PPO-i_Target|+|i_SR-i_Target|)", "target_window": [start, stop], "direction_check": "all selected local unwrapped-phase derivatives have the same sign", "figure06_alignment_context_only": alignment, **phase_match},
        "selected": {label: {"index": int(selected_indices[label]), "candidate_index": int(selected_candidate_indices[label]), "t_D_over_U0": float(selected_indices[label] * dt), "phase_angle_rad": float(phase_match["phase_angles_rad"][label]), "wrapped_phase_error_rad": 0.0 if label == "Target" else float(phase_match["wrapped_angle_errors_rad"][label])} for label in ("Target", "PPO", "SR")},
        "sample_coupling": {"contract": "for every condition, the selected field and phase-diagnostic sensor values come from the same CFD sample in the same run", "candidate_trace_index_mapping": "candidate_index = trace_index - stable_window_start; exact contiguous mapping asserted before selection", "candidate_window": [start, stop], "candidate_count_per_condition": stop-start, "candidate_dtype": CANDIDATE_DTYPE.name, "candidate_field_shape_yx": [int(shape[1]), int(shape[0])], "candidate_host_memory_bytes": candidate_field_bytes, "candidate_host_memory_mib": candidate_field_bytes / (1024**2), "disk_contract": "only the three selected full fields and complete sensor traces are stored; stable-window candidates are memory-only"},
        "field": {"quantity": "omega_z D/U0", "source": "vorticity_from_ddf", "source_shape_order": "(NX,NY)", "stored_shape_order": "(NY,NX)", "crop": crop_meta, "shared_symmetric_vmax_percentile": {"percentile": 99.5, "vmax": vmax}, "geometry_D": {"disturbance": [10.0, 0.0, 1.0], "pinball": [[cfg["pinball_front_x"], 0.0, 0.5], [cfg["pinball_rear_x"], 0.75, 0.5], [cfg["pinball_rear_x"], -0.75, 0.5]], "sensors": [[40.0, 2.0], [40.0, 0.0], [40.0, -2.0]]}},
        "contracts": {"target": "exact build_karman_cloak_env geometry, stabilization, and sample interval; target has no pinball", "controlled": "stage_3_validate.prepare_plan/build_karman_environment/build_policy", "ppo_model_device": args.model_device, "cfd_logical_device": args.device},
        "hashes": {"config": hash_json(cfg), "formula_front": hash_file(args.formula_front), "formula_rear": hash_file(args.formula_rear), "formula_pair": pair.pair_hash, "ppo_model": hash_file(model_path) if model_path and model_path.is_file() else None, "alignment_metadata": hash_file(args.alignment_metadata), "npz": hash_file(npz_path), "png": hash_file(outputs[2]), "pdf": hash_file(outputs[3]), "exporter": hash_file(Path(__file__))},
        "paths": {"npz": str(npz_path.relative_to(REPO_ROOT)), "formula_front": str(args.formula_front.resolve()), "formula_rear": str(args.formula_rear.resolve()), "ppo_model": str(model_path) if model_path else None},
        "provenance": {"git_sha": git_sha(), "command": " ".join(sys.argv), "created_utc": datetime.now(timezone.utc).isoformat(), "python": sys.version, "platform": platform.platform(), "numpy": np.__version__, "CUDA_VISIBLE_DEVICES": os.environ.get("CUDA_VISIBLE_DEVICES")},
    }
    metadata["record_hash"] = hash_json(metadata)
    atomic_write_json(outputs[1], metadata)
    update_package_manifest(package)
    print(json.dumps({"outputs": [str(p) for p in outputs], "selected": metadata["selected"], "vmax": vmax}, indent=2))
    return 0


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