#!/usr/bin/env python3 """Verify recorded PPO actions by replaying the policy on causal recorded states.""" from __future__ import annotations import argparse import json from pathlib import Path from typing import Any, Sequence import numpy as np from SR_analysis.configs import get_scene, model_path_for_scene from SR_analysis.stage_1_infer import illusion_observation, load_existing_norm, normalize_raw_observation from SR_analysis.utils.cfd_interface import load_ppo_model from SR_analysis.utils.provenance import atomic_write_json, hash_file SCHEMA_VERSION = "sr-policy-replay-parity-v1" def replay(scene: str, trajectory: Path, *, model_device: str = "cpu") -> dict[str, Any]: cfg = get_scene(scene) norm, norm_path = load_existing_norm(scene, cfg) model_path = model_path_for_scene(scene) if model_path is None: raise FileNotFoundError(f"no PPO model configured for {scene}") model = load_ppo_model(model_path, device=model_device, s_dim=int(cfg["s_dim"])) with np.load(trajectory, allow_pickle=False) as data: sensors = np.asarray(data["sensors"], dtype=np.float64) forces = np.asarray(data["forces"], dtype=np.float64) actions = np.asarray(data["actions_norm" if "actions_norm" in data else "actions"], dtype=np.float64) targets = np.asarray(data["target_forces"], dtype=np.float64) if "target_forces" in data else None if len(actions) < 2: raise ValueError("policy replay requires at least two recorded actions") raw = np.column_stack((sensors, forces)) predicted = [] for index in range(1, len(actions)): state = raw[index - 1] if cfg["scene_id"] == "illusion": if targets is None: raise ValueError("Illusion policy replay requires target_forces") observation = illusion_observation(state, norm, targets[index]) else: observation = normalize_raw_observation(state, norm) action, _ = model.predict(observation, deterministic=True) predicted.append(np.asarray(action, dtype=np.float64).reshape(3)) predicted_array = np.asarray(predicted) recorded = actions[1:] residual = predicted_array - recorded max_abs = np.max(np.abs(residual), axis=0) rmse = np.sqrt(np.mean(residual**2, axis=0)) tolerance = 2e-6 return { "schema_version": SCHEMA_VERSION, "scene": scene, "status": "passed" if float(np.max(max_abs)) <= tolerance else "failed", "semantics": "recorded post-state i-1 is replayed to predict recorded normalized action i; first action is excluded because its pre-state is not stored", "tolerance": tolerance, "n_compared": int(len(recorded)), "action_layout": list(cfg["action_layout"]), "max_abs_error": max_abs.tolist(), "rmse": rmse.tolist(), "sources": { "trajectory": {"path": str(trajectory), "sha256": hash_file(trajectory)}, "model": {"path": str(model_path), "sha256": hash_file(Path(model_path))}, "norm": {"path": str(norm_path), "sha256": hash_file(norm_path)}, }, "interpretation": "This checks policy observation/action wiring without requiring separately initialized CFD trajectories to be pointwise identical.", } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--scene", required=True) parser.add_argument("--trajectory", type=Path, required=True) parser.add_argument("--model-device", default="cpu") parser.add_argument("--output", type=Path, required=True) return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) report = replay(args.scene, args.trajectory.resolve(), model_device=args.model_device) atomic_write_json(args.output.resolve(), report) print(json.dumps(report, indent=2, sort_keys=True)) return 0 if report["status"] == "passed" else 1 if __name__ == "__main__": raise SystemExit(main())