From bf53dee110f885b28a7783db432085e7d75f15ff Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 14:43:27 -0700 Subject: [PATCH 01/31] Restart SFM audit with full-H continuation labels --- FRESH_RESTART.md | 104 ++++ .../test_sfm_b1_full_episode_audit.py | 76 +++ .../analysis/test_sfm_b1_verifier.py | 17 +- .../sfm_b1_full_episode_audit.py | 459 ++++++++++++++++++ .../sfm_b1_full_episode_viz.py | 290 +++++++++++ overnight_run_07_12_sfm/sfm_metrics2.py | 30 +- 6 files changed, 957 insertions(+), 19 deletions(-) create mode 100644 FRESH_RESTART.md create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_full_episode_audit.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_full_episode_viz.py diff --git a/FRESH_RESTART.md b/FRESH_RESTART.md new file mode 100644 index 0000000..1f8417f --- /dev/null +++ b/FRESH_RESTART.md @@ -0,0 +1,104 @@ +# Double-shift SFM fresh restart + +## Frozen scientific timepoint + +The quoted double-shift OOD statistics were produced from the clean source +commit `ca7f0d718f8d70cf74833b1c75157caf7f1b13f2` on July 20, 2026. The +authenticated environment-and-visualization child commit is +`b27df76fe461fd7f7e86ecf87e80cbf52e7f01d5`; this is the restart base. + +The benchmark contract is: + +- scene: `double_density_velocity_ood`; +- pedestrians: 40; +- pedestrian desired-speed range: 1.0--2.0 m/s; +- episode bank: 250000--250099; +- evaluation: raw temperature 1, NFE 8, M=100 per gamma; +- pretrained checkpoint SHA-256: + `1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215`; +- historical A-r10 checkpoint SHA-256: + `bf6f521dd2dd6de4cffcce672a8ce4adbf00bb14e71dd9fd27704d205f65744c`. + +The authenticated pooled OOD results were: + +| Method | SR | CR | Successful clearance | Successful time | +|---|---:|---:|---:|---:| +| Hp10 r0 raw | 70.00% | 30.00% | 0.131 m | 8.69 s | +| historical A-r10 raw | 69.43% | 30.29% | 0.128 m | 8.79 s | +| Kazuki default | 81.29% | 18.71% | 0.181 m | 4.39 s | +| Kazuki goal-stress | 75.29% | 24.71% | 0.163 m | 3.81 s | + +The original metrics artifact is +`/home/dohyun/projects/sfm_hp10_b1_runs/ca7f0d7_preexp_double_shift/double_shift_ood/metrics.json` +on Helios. Its local Mac copy is +`/Users/dhl/Documents/SFM_HP10_DOUBLE_SHIFT_PREEXP_b27df76/double_shift_ood/metrics.json` +with SHA-256 +`566ac3fc87b727ad0957b837aca68a1fdd24777040584791bd372eb25e3b8977`. + +## Repository relationship + +`DHLeexpress/safe_flow_expansion_SFM` has a standalone packaging history, so +Git cannot express it as being a number of commits ahead of this historical +safeMPPI branch. Its current source snapshot records safeMPPI commit +`e5ab47b`, which is eight safeMPPI source commits after `b27df76` and changes +34 SFM files. Therefore this restart is published on isolated archive/restart +branches; `master` is not reset or force-pushed. + +## One deliberate correction after the timepoint + +The historical model, scene, exact K=16 moving-obstacle SOCP, and checkpoint are +preserved. One user-approved semantic correction is applied before new +experiments: every queried action window is certified over all H=10 +transitions. Crossing the goal does not truncate a queried window. Goal reach +only terminates the closed-loop episode after the selected first action. + +Thus new source should be described as **b27df76 plus the full-H10 correction**, +not as bitwise historical b27df76. + +## Pre-expansion full-episode diagnostic + +`sfm_b1_full_episode_audit.py` is an isolated diagnostic. It does not modify +the historical fail-closed trainer or enter any sample into D, D+, the GP, or +gradient replay. + +For three fixed episodes and all seven gamma values it starts with the +pretrained generator and the round-1 B1 mechanism: + +1. generate K=16 windows; +2. select B=4 using the empty-buffer RBF acquisition with pending-point + conditioning; +3. run the exact full-H10 verifier on B; +4. if an admissible B query exists, execute the max-one-step-Hp-margin action; +5. on finite-B NVP, independently sample and verify one raw temperature-1 + window, execute its first action, and continue the offline simulator; +6. stop only at realized collision, goal reach, or T=180 timeout. + +Step 5 is deliberately **not** a certified controller. It exists only to +observe post-NVP states that fail-closed gathering would hide. + +Labels remain separate: + +- `verifier_positive` / `verifier_negative`: exact safety label of the + actually executed H=10 window; +- `finite_B_NVP`: B=4 failed to contain an admissible candidate; it is not + itself a verifier-negative label; +- `trap`: displacement over the last ten executed transitions is below 0.2 m; +- `collision`: realized simulator outcome; it does not retroactively relabel + earlier safe windows. + +Consequently, NVP, trap, and collision must not be pooled blindly into the +negative verifier loss. They can support a later, separately defined +continuation/viability label. + +The companion `sfm_b1_full_episode_viz.py` renders: + +- K=16 generated paths in gray; +- B=4 queried paths in orange; +- verifier-positive/rejected B paths in green/red; +- the complete executed trail in blue/red according to the exact H=10 label; +- the executed positive candidate's exact K=16 verifier polytope and H=1..10 + level sets in green; +- finite-B NVP, first trap entry, and collision as separate markers. + +The resulting movie is a **pretrained-generator round-1 gathering diagnostic**, +not a pure raw-policy rollout and not a safety-certified deployment. diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_full_episode_audit.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_full_episode_audit.py new file mode 100644 index 0000000..2e481a2 --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_full_episode_audit.py @@ -0,0 +1,76 @@ +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import sfm_b1_full_episode_audit as A +import sfm_b1_full_episode_viz as V +import sfm_b1_viz as BV + + +def test_result_label_requires_a_resolved_full_h_positive(): + assert A._result_label(dict(resolved=False)) == "verifier_error" + assert A._result_label(dict(resolved=True, y=0)) == "verifier_negative" + assert A._result_label( + dict(resolved=True, y=1, full_h=True, terminal_step=10) + ) == "verifier_positive" + with pytest.raises(RuntimeError, match="full-H=10"): + A._result_label( + dict(resolved=True, y=1, full_h=False, terminal_step=3) + ) + + +def test_trap_is_a_separate_exact_ten_transition_event(): + short = [np.array([0.0, 0.0, 0.0, 0.0])] * 10 + assert not A._trap(short) + stationary = [np.array([0.0, 0.0, 0.0, 0.0])] * 11 + assert A._trap(stationary) + moving = [ + np.array([0.03 * step, 0.0, 0.0, 0.0]) for step in range(11) + ] + assert not A._trap(moving) + + +def test_nvp_does_not_remove_later_steps_from_the_trace_index(): + traces = [ + dict(scenario_id=7, gamma=.5, step=0, nvp_context=True), + dict(scenario_id=7, gamma=.5, step=1, nvp_context=False), + ] + index = V._index(traces) + assert sorted(index[(7, .5)]) == [0, 1] + with pytest.raises(ValueError, match="duplicate trace key"): + V._index(traces + [dict(traces[1])]) + + +def test_executed_color_is_the_verifier_label_not_the_nvp_event(): + assert V._executed_color( + dict(executed_label="verifier_positive", nvp_context=True) + ) == BV.BLUE + assert V._executed_color( + dict(executed_label="verifier_negative", nvp_context=False) + ) == BV.RED + + +def test_verifier_geometry_is_drawn_only_for_positive_executed_window(monkeypatch): + calls = [] + monkeypatch.setattr( + V.DV, "checked_verifier_levels", + lambda trace, query, H: calls.append(H) or dict( + polygons=[], outer_polygon=None + ), + ) + monkeypatch.setattr(V.DV, "_draw_verifier_geometry", lambda axis, audit: None) + base = dict( + state=np.zeros(4), next_state=np.zeros(4), + executed_controls=np.zeros((10, 2)), + executed_result=dict(segment=np.zeros((11, 2))), + ) + figure, axis = plt.subplots() + V._draw_executed( + axis, dict(base, executed_label="verifier_negative") + ) + assert calls == [] + V._draw_executed( + axis, dict(base, executed_label="verifier_positive") + ) + assert calls == [10] + plt.close(figure) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py index cddd370..a7ec598 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py @@ -13,13 +13,24 @@ def test_label_is_only_task_collision_and_moving_certificate(): assert "progress" not in result and "cost" not in result -def test_early_goal_is_prefix_only_and_not_replay_eligible(): +def test_predicted_goal_crossing_still_certifies_full_h(): + state = np.array([5.4, 6.0, 1.0, 0.0], np.float32) + result = M.verify_query( + state, np.zeros((10, 2)), np.zeros((0, 2)), np.zeros((0, 2)), .5 + ) + assert np.min(np.linalg.norm(result["segment"] - M.SS.GOAL[None], axis=1)) < .5 + assert result["resolved"] and result["terminal_step"] == 10 + assert result["full_h"] and result["y"] == 1 and result["train_eligible"] + + +def test_post_goal_tail_violation_rejects_the_full_window(): state = np.array([5.7, 6.0, 2.0, 0.0], np.float32) result = M.verify_query( state, np.zeros((10, 2)), np.zeros((0, 2)), np.zeros((0, 2)), .5 ) - assert result["resolved"] and result["terminal_step"] < 10 - assert not result["full_h"] and not result["train_eligible"] + assert np.min(np.linalg.norm(result["segment"] - M.SS.GOAL[None], axis=1)) < .5 + assert result["resolved"] and result["terminal_step"] == 10 and result["full_h"] + assert result["y"] == 0 and not result["taskspace"] and not result["train_eligible"] def test_worker_contract_has_no_legacy_theta_grid_argument(): diff --git a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py new file mode 100644 index 0000000..1299ea3 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py @@ -0,0 +1,459 @@ +"""Diagnostic-only full-episode B1 gathering with explicit post-NVP continuation. + +This module does not alter the fail-closed B1 trainer. It starts from the +pretrained policy, runs the ordinary K=16/B=4 RBF acquisition and max-margin +selector, and records every resolved query. When the selected B queries contain +no admissible action, an independently sampled raw temperature-one window is +verified and its first action is executed so the simulator can continue. + +That post-NVP transition is evidence gathering, not certified deployment. The +trace keeps the full-H verifier label, nominal-Hp gate, NVP event, progress/trap +event, and episode outcome separate. +""" +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +from concurrent.futures import ProcessPoolExecutor +import copy +import hashlib +import json +import os +import subprocess + +import numpy as np +import torch + +import _paths # noqa: F401 +import grid_policy_sfm as GPS +import sfm_b1_cost as BC +import sfm_b1_eval as BE +import sfm_b1_expand as BX +import sfm_b1_rbf as BR +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +DEFAULT_SCENARIOS = (250_001, 250_003, 250_007) +DEFAULT_ELL = 0.24210826720721101 +DEFAULT_SAMPLE_SEED = 700_000 +DEFAULT_AUDIT_SEED = 20260723 +TRAP_HORIZON = 10 +TRAP_DISPLACEMENT = 0.2 + + +def _sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path, payload): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + temporary = os.fspath(path) + ".tmp" + with open(temporary, "w") as stream: + json.dump(payload, stream, indent=2, sort_keys=True) + os.replace(temporary, path) + + +def _save_torch(path, payload): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + temporary = os.fspath(path) + ".tmp" + torch.save(payload, temporary) + os.replace(temporary, path) + + +def _source(): + root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=root, text=True + ).strip() + dirty = bool(subprocess.check_output( + ["git", "status", "--porcelain"], cwd=root, text=True + ).strip()) + return dict(commit=commit, tracked_worktree_clean=not dirty) + + +def _raw_windows(policy, live, batch, generators): + """One raw latent per live cell, preserving raw-evaluator per-cell streams.""" + context = policy.ctx_from(batch["hp10"], batch["low"], batch["hist"]) + latents = torch.stack([ + torch.randn( + policy.d, generator=generators[(replica.scenario_id, replica.gamma)], + device=context.device, dtype=context.dtype, + ) + for replica in live + ]) + return BE.integrate_latents(policy, latents, context, nfe=8) + + +def _trap(states, *, horizon=TRAP_HORIZON, displacement=TRAP_DISPLACEMENT): + if len(states) <= int(horizon): + return False + start = np.asarray(states[-int(horizon) - 1], float)[:2] + end = np.asarray(states[-1], float)[:2] + return bool(np.linalg.norm(end - start) < float(displacement)) + + +def _result_label(result): + if not result.get("resolved"): + return "verifier_error" + if int(result.get("y", 0)) == 0: + return "verifier_negative" + if not bool(result.get("full_h")) or int(result.get("terminal_step", -1)) != SP.H: + raise RuntimeError("the full-episode audit requires full-H=10 verifier semantics") + return "verifier_positive" + + +def _post_action_terminal(replica): + ped_xy, _ = SS.collect_humans(replica.humans) + clearance = float( + np.linalg.norm(ped_xy - replica.state[:2][None], axis=1).min() - SS.R_PED + ) + replica.minimum_clearance = min(replica.minimum_clearance, clearance) + collision = clearance < 0.0 + success = bool( + not collision and float(np.linalg.norm(replica.state[:2] - SS.GOAL)) < 0.5 + ) + if collision: + replica.alive = False + replica.status = "collision" + elif success: + replica.alive = False + replica.status = "success" + return collision, success, clearance + + +def collect( + checkpoint, *, scenarios=DEFAULT_SCENARIOS, gammas=SS.GAMMAS, + scene_profile="double_density_velocity_ood", device="cuda", + verifier_workers=32, sample_seed=DEFAULT_SAMPLE_SEED, + audit_seed=DEFAULT_AUDIT_SEED, ell=DEFAULT_ELL, T=SP.T, outdir, +): + """Collect a fixed scenario-by-gamma full-episode diagnostic bundle.""" + scenarios = tuple(map(int, scenarios)) + gammas = tuple(map(float, gammas)) + if len(scenarios) != 3 or len(set(scenarios)) != 3: + raise ValueError("the requested audit requires exactly three distinct scenarios") + if gammas != tuple(map(float, SS.GAMMAS)): + raise ValueError(f"the requested audit requires all gammas={SS.GAMMAS}") + if scene_profile != "double_density_velocity_ood": + raise ValueError("this audit is pinned to the authenticated double-shift OOD") + if os.path.exists(outdir): + raise FileExistsError(f"refusing to reuse audit output: {outdir}") + + environment = SS.scene_profile(scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + + replicas = [ + BX.Replica( + scenario, gamma, n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario in scenarios for gamma in gammas + ] + cfg = BX.ArmConfig( + name="A", selector="margin", alpha=0.0, rounds=1, + scene_profile=scene_profile, verifier_workers=int(verifier_workers), + seed=int(audit_seed), + ).validate() + gp = BR.RBFGP(float(ell), cfg.gp_lam) + beta, calibrated_ess = BX._initial_beta( + phi_policy, gp, replicas, cfg, device, int(audit_seed) + 1009, + ) + audit_generator = torch.Generator(device=device).manual_seed(int(audit_seed) + 2003) + raw_generators = { + (replica.scenario_id, replica.gamma): + torch.Generator(device=device).manual_seed( + int(sample_seed) + replica.scenario_id * 1000 + ) + for replica in replicas + } + + traces = [] + counts = Counter() + ess_values = [] + sigma_pool, sigma_selected = [], [] + trap_active = defaultdict(bool) + with ProcessPoolExecutor(max_workers=int(verifier_workers)) as executor: + for step in range(int(T)): + live = [replica for replica in replicas if replica.alive] + live, batch = BX._stack_prepared(live, device) + if not live: + break + with torch.no_grad(): + audit_windows = BE.generate_windows( + policy, batch["hp10"], batch["low"], batch["hist"], + K=cfg.K, nfe=cfg.nfe, temp=cfg.temp, + generator=audit_generator, + ) + raw_windows = _raw_windows(policy, live, batch, raw_generators) + features = BX._features(phi_policy, audit_windows, batch, cfg.phi_s) + + selected_by_context = [] + acquisition_by_context = [] + for context_index in range(len(live)): + selected, acquisition = gp.sequential_acquire( + features[context_index], cfg.B, beta, generator=audit_generator, + ) + selected_by_context.append(selected) + acquisition_by_context.append(acquisition) + sigma_pool.extend(map( + float, gp.acquisition_sigma(features[context_index]).detach().cpu() + )) + sigma_selected.extend(float(row["chosen_sigma"]) for row in acquisition) + ess_values.extend(float(row["ess_norm"]) for row in acquisition) + + tasks = [] + for context_index, replica in enumerate(live): + prepared = replica.prepared + for candidate_id in selected_by_context[context_index]: + tasks.append(( + context_index, candidate_id, prepared["state"], + audit_windows[context_index, candidate_id].detach().cpu().numpy(), + prepared["ped_xy"], prepared["ped_vel"], replica.gamma, + )) + results = list(executor.map(SM.verify_in_worker, tasks)) + by_context = defaultdict(dict) + for context_index, candidate_id, result in results: + by_context[int(context_index)][int(candidate_id)] = result + + prepared_contexts = [] + raw_tasks = [] + for context_index, replica in enumerate(live): + prepared = replica.prepared + pedestrian_prediction = SM.predict_pedestrians( + prepared["ped_xy"], prepared["ped_vel"], cfg.H, + ) + all_rows = [] + for candidate_id in range(cfg.K): + controls = audit_windows[ + context_index, candidate_id + ].detach().cpu().numpy() + segment = SM.rollout_positions(prepared["state"], controls) + all_rows.append(dict( + candidate_id=candidate_id, controls=controls, segment=segment, + mode=BE.classify_candidate(segment, pedestrian_prediction), + )) + query_rows = [] + for acquisition_step, candidate_id in enumerate( + selected_by_context[context_index]): + result = by_context[context_index][candidate_id] + query_rows.append(dict( + candidate_id=int(candidate_id), + controls=all_rows[candidate_id]["controls"], + result=result, mode=all_rows[candidate_id]["mode"], + acquisition_step=int(acquisition_step), + sigma=float( + acquisition_by_context[context_index][ + acquisition_step + ]["chosen_sigma"] + ), + )) + counts[f"B_{_result_label(result)}"] += 1 + chosen = BC.select_admissible( + query_rows, selector="margin", state=prepared["state"], + ped_xy=prepared["ped_xy"], ped_vel=prepared["ped_vel"], + gamma=replica.gamma, + ) + prepared_contexts.append((all_rows, query_rows, chosen)) + if chosen is None: + raw_tasks.append(( + context_index, -1, prepared["state"], + raw_windows[context_index].detach().cpu().numpy(), + prepared["ped_xy"], prepared["ped_vel"], replica.gamma, + )) + + for context_index, candidate_id, result in executor.map( + SM.verify_in_worker, raw_tasks): + by_context[int(context_index)][int(candidate_id)] = result + + for context_index, replica in enumerate(live): + prepared = replica.prepared + all_rows, query_rows, chosen = prepared_contexts[context_index] + + raw_controls = raw_windows[context_index].detach().cpu().numpy() + nvp_context = chosen is None + if chosen is None: + raw_result = by_context[context_index][-1] + raw_margin, raw_hp_old, raw_hp_new = BC.nominal_hp_margin( + prepared["state"], raw_controls[0], prepared["ped_xy"], + replica.gamma, + ) + raw_admissible = bool( + raw_result.get("resolved") + and int(raw_result.get("y", 0)) == 1 + and bool(raw_result.get("full_h")) + and raw_margin >= -1.0e-9 + ) + executed_controls = raw_controls + executed_result = raw_result + executed_id = None + execution_source = ( + "certified_raw_rescue" if raw_admissible + else "uncertified_raw_continuation" + ) + counts["B_NVP_context"] += 1 + counts[f"raw_continuation_{_result_label(raw_result)}"] += 1 + raw_candidate = dict( + controls=np.asarray(raw_controls, np.float32), + result=raw_result, hp_margin=float(raw_margin), + hp_old=float(raw_hp_old), hp_new=float(raw_hp_new), + admissible=raw_admissible, + ) + else: + executed_controls = chosen["controls"] + executed_result = chosen["result"] + executed_id = int(chosen["candidate_id"]) + execution_source = "verified_max_margin" + raw_candidate = None + executed_label = _result_label(executed_result) + counts[f"executed_{executed_label}"] += 1 + counts[f"source_{execution_source}"] += 1 + + before = prepared["state"].copy() + BX._advance(replica, executed_controls[0]) + trap_event = _trap(replica.states) + trap_key = (replica.scenario_id, replica.gamma) + trap_entry = bool(trap_event and not trap_active[trap_key]) + trap_active[trap_key] = bool(trap_event) + collision_after, success_after, clearance_after = _post_action_terminal( + replica + ) + if trap_entry: + counts["trap_entries"] += 1 + if collision_after: + counts["collision_events"] += 1 + if success_after: + counts["success_events"] += 1 + + negative_reasons = [] + if nvp_context: + negative_reasons.append("finite_B_NVP") + if executed_label == "verifier_negative": + negative_reasons.append("executed_full_H_rejected") + elif executed_label == "verifier_error": + negative_reasons.append("executed_verifier_error") + if ( + executed_label == "verifier_positive" + and execution_source != "verified_max_margin" + ): + if raw_margin < -1.0e-9: + negative_reasons.append("executed_nominal_Hp_gate_failure") + if trap_event: + negative_reasons.append("ten_step_progress_below_0p2m") + if collision_after: + negative_reasons.append("collision") + + traces.append(dict( + round=1, step=int(step), scenario_id=replica.scenario_id, + gamma=replica.gamma, state=before, + next_state=replica.state.copy(), + ped_xy=prepared["ped_xy"], ped_vel=prepared["ped_vel"], + all_K=all_rows, + selected_ids=list(map(int, selected_by_context[context_index])), + query_rows=query_rows, acquisition=acquisition_by_context[context_index], + executed_id=executed_id, + executed_controls=np.asarray(executed_controls, np.float32), + executed_result=executed_result, + executed_label=executed_label, + execution_source=execution_source, + nvp_context=bool(nvp_context), + raw_candidate=raw_candidate, + trap_event=bool(trap_event), + trap_entry=bool(trap_entry), + collision_after_action=bool(collision_after), + success_after_action=bool(success_after), + clearance_after_action=float(clearance_after), + negative_reasons=negative_reasons, + )) + + for replica in replicas: + if replica.alive: + replica.alive = False + replica.status = "timeout" + outcomes = [dict( + scenario_id=replica.scenario_id, gamma=replica.gamma, + status=replica.status, steps=len(replica.controls), + success=replica.status == "success", + collision=replica.status == "collision", + timeout=replica.status == "timeout", + minimum_clearance=float(replica.minimum_clearance), + ) for replica in replicas] + counts.update(f"outcome_{row['status']}" for row in outcomes) + + source = _source() + bundle = dict( + version=1, status="SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE", + diagnostic_only=True, enters_training_or_gp=False, + certified_deployment=False, + continuation_semantics=( + "verified max-margin B action when available; otherwise independently " + "sampled raw temp=1 action is executed after being labeled, even when " + "uncertified, solely to continue the offline simulator diagnostic" + ), + label_semantics=dict( + safety="executed_label is the independent exact full-H=10 verifier result", + nvp="finite B=4 acquisition event; not itself a verifier-negative label", + trap=( + f"separate performance event: displacement over {TRAP_HORIZON} " + f"executed actions is below {TRAP_DISPLACEMENT} m" + ), + collision="episode event; never retroactively relabels earlier windows", + ), + source=source, checkpoint=os.path.abspath(checkpoint), + checkpoint_sha256=_sha256_file(checkpoint), + environment=environment, scenarios=list(scenarios), gammas=list(gammas), + sample_seed=int(sample_seed), audit_seed=int(audit_seed), + protocol=dict( + K=cfg.K, B=cfg.B, H=cfg.H, T=int(T), selector="margin", + ell=float(ell), gp_buffer=0, beta=float(beta), + calibrated_ess_over_K=float(calibrated_ess), + realized_ess_over_K=float(np.mean(ess_values)), + acquisition=BR.acquisition_diagnostics(sigma_pool, sigma_selected), + ), + counts=dict(counts), outcomes=outcomes, traces=traces, + ) + os.makedirs(outdir) + trace_path = os.path.join(outdir, "full_episode_label_audit.pt") + _save_torch(trace_path, bundle) + manifest = { + key: value for key, value in bundle.items() if key != "traces" + } + manifest["trace_path"] = os.path.abspath(trace_path) + manifest["trace_sha256"] = _sha256_file(trace_path) + _write_json(os.path.join(outdir, "full_episode_label_audit.json"), manifest) + return manifest + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--scenarios", nargs=3, type=int, default=DEFAULT_SCENARIOS) + parser.add_argument("--scene-profile", default="double_density_velocity_ood") + parser.add_argument("--device", default="cuda") + parser.add_argument("--verifier-workers", type=int, default=32) + parser.add_argument("--sample-seed", type=int, default=DEFAULT_SAMPLE_SEED) + parser.add_argument("--audit-seed", type=int, default=DEFAULT_AUDIT_SEED) + parser.add_argument("--ell", type=float, default=DEFAULT_ELL) + parser.add_argument("--T", type=int, default=SP.T) + args = parser.parse_args(argv) + collect( + args.checkpoint, scenarios=args.scenarios, + scene_profile=args.scene_profile, device=args.device, + verifier_workers=args.verifier_workers, + sample_seed=args.sample_seed, audit_seed=args.audit_seed, + ell=args.ell, T=args.T, outdir=args.outdir, + ) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_full_episode_viz.py b/overnight_run_07_12_sfm/sfm_b1_full_episode_viz.py new file mode 100644 index 0000000..825a702 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_full_episode_viz.py @@ -0,0 +1,290 @@ +"""Render the diagnostic full-episode B1 label audit. + +Blue/red trajectory segments are exact full-H verifier labels of the action +window that was actually executed. A red NVP ring is a separate finite-B +context event. Green H=10 levels are drawn only for an executed verifier +positive; post-NVP uncertified raw continuations never acquire green geometry. +""" +from __future__ import annotations + +import argparse +from collections import defaultdict +import hashlib +import json +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.animation as animation +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +import numpy as np +import torch + +import _paths # noqa: F401 +import sfm_b1_density_viz as DV +import sfm_b1_viz as BV +import sfm_scene as SS + + +def _sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path, payload): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + temporary = os.fspath(path) + ".tmp" + with open(temporary, "w") as stream: + json.dump(payload, stream, indent=2, sort_keys=True) + os.replace(temporary, path) + + +def _index(traces): + output = defaultdict(dict) + for trace in traces: + key = (int(trace["scenario_id"]), round(float(trace["gamma"]), 8)) + step = int(trace["step"]) + if step in output[key]: + raise ValueError(f"duplicate trace key {key + (step,)}") + output[key][step] = trace + return output + + +def _executed_color(trace): + if trace["executed_label"] == "verifier_positive": + return BV.BLUE + if trace["executed_label"] == "verifier_negative": + return BV.RED + return BV.GRAY + + +def _draw_history(axis, rows, step): + for index in sorted(value for value in rows if value <= int(step)): + trace = rows[index] + before = np.asarray(trace["state"], float)[:2] + after = np.asarray(trace["next_state"], float)[:2] + color = _executed_color(trace) + axis.plot( + [before[0], after[0]], [before[1], after[1]], + color=color, lw=2.2, marker=".", ms=2.0, alpha=.94, zorder=6, + ) + + +def _draw_candidates(axis, trace): + selected = set(map(int, trace["selected_ids"])) + for row in trace["all_K"]: + path = np.asarray(row["segment"], float) + axis.plot( + path[:, 0], path[:, 1], color=BV.GRAY, lw=.38, + marker=".", ms=1.0, alpha=.22, zorder=3, + ) + for candidate_id in sorted(selected): + row = BV._trace_candidate(trace, candidate_id) + path = np.asarray(row["segment"], float) + axis.plot( + path[:, 0], path[:, 1], color=BV.ORANGE, lw=.72, + marker=".", ms=1.3, alpha=.92, zorder=4, + ) + for candidate_id in sorted(selected): + status, query = BV._candidate_status(trace, candidate_id) + if status not in ("positive", "negative"): + continue + path = np.asarray(BV._trace_candidate(trace, candidate_id)["segment"], float) + color = BV.GREEN if status == "positive" else BV.RED + axis.plot( + path[:, 0], path[:, 1], color=color, lw=1.0, + marker=".", ms=1.45, alpha=.96, zorder=5, + ) + if status == "negative": + axis.plot( + path[-1, 0], path[-1, 1], "x", color=BV.RED, + ms=3.5, mew=.9, zorder=8, + ) + + +def _draw_executed(axis, trace): + result = trace["executed_result"] + path = np.asarray(result.get( + "segment", + trace["executed_controls"], + ), float) + if path.shape != (11, 2): + path = np.asarray([ + np.asarray(trace["state"], float)[:2], + np.asarray(trace["next_state"], float)[:2], + ]) + color = _executed_color(trace) + axis.plot(path[:, 0], path[:, 1], color=color, lw=.75, alpha=.62, zorder=6) + axis.plot(path[:2, 0], path[:2, 1], color=color, lw=3.0, zorder=9) + axis.annotate( + "", xy=path[1], xytext=path[0], + arrowprops=dict(arrowstyle="->", color=color, lw=2.3), + ) + if trace["executed_label"] == "verifier_positive": + query = dict(result=result) + audit = DV.checked_verifier_levels(trace, query, H=10) + DV._draw_verifier_geometry(axis, audit) + + +def draw_cell(axis, rows, step): + available = [value for value in rows if value <= int(step)] + current_step = max(available) if available else min(rows) + trace = rows[current_step] + BV._draw_common(axis, trace, nominal_levels=False) + _draw_history(axis, rows, current_step) + _draw_candidates(axis, trace) + _draw_executed(axis, trace) + position = np.asarray(trace["state"], float)[:2] + if trace["nvp_context"]: + axis.plot( + position[0], position[1], marker="o", ms=10, mfc="none", + mec=BV.RED, mew=1.6, zorder=12, + ) + if trace["trap_entry"]: + axis.plot(position[0], position[1], marker="s", ms=6, + mfc="none", mec=BV.RED, mew=1.2, zorder=12) + if trace["collision_after_action"]: + after = np.asarray(trace["next_state"], float)[:2] + axis.plot(after[0], after[1], marker="x", ms=8, + color=BV.RED, mew=1.8, zorder=13) + DV._set_clean_axis(axis) + return trace + + +def _legend(): + return [ + Line2D([], [], color=BV.GRAY, lw=.7, label="K=16 generated"), + Line2D([], [], color=BV.ORANGE, lw=1.1, label="B=4 RBF queried"), + Line2D([], [], color=BV.GREEN, lw=1.4, label="B full-H positive"), + Line2D([], [], color=BV.RED, lw=1.4, marker="x", label="B full-H rejected"), + Line2D([], [], color=BV.BLUE, lw=2.7, label="executed window: full-H positive"), + Line2D([], [], color=BV.RED, lw=2.7, label="executed window: full-H rejected"), + Line2D([], [], color=BV.GREEN, lw=.7, label="executed verifier levels h=1..10"), + Line2D([], [], marker="o", ms=8, mfc="none", mec=BV.RED, lw=0, + label="finite-B NVP context"), + Line2D([], [], marker="s", ms=6, mfc="none", mec=BV.RED, lw=0, + label="first entry: 10-step progress < 0.2 m"), + ] + + +def render(trace_path, output_mp4, output_png, output_json, *, fps=5, frame_stride=2): + if int(fps) <= 0 or int(frame_stride) <= 0: + raise ValueError("fps and frame_stride must be positive") + bundle = torch.load(trace_path, map_location="cpu", weights_only=False) + if bundle.get("status") != "SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE": + raise ValueError("input is not a completed full-episode audit") + scenarios = tuple(map(int, bundle["scenarios"])) + gammas = tuple(map(float, bundle["gammas"])) + if len(scenarios) != 3 or gammas != tuple(map(float, SS.GAMMAS)): + raise ValueError("renderer requires three scenarios and all seven gammas") + index = _index(bundle["traces"]) + missing = [ + (scenario, gamma) for scenario in scenarios for gamma in gammas + if (scenario, round(gamma, 8)) not in index + ] + if missing: + raise ValueError(f"missing audit cells: {missing}") + + maximum = max(max(rows) for rows in index.values()) + frames = list(range(0, maximum + 1, int(frame_stride))) + if frames[-1] != maximum: + frames.append(maximum) + figure, axes = plt.subplots(3, 7, figsize=(23.2, 10.1)) + figure.subplots_adjust( + left=.035, right=.815, bottom=.025, top=.94, wspace=.025, hspace=.04, + ) + for column, gamma in enumerate(gammas): + figure.text( + .035 + (.78 / 7) * (column + .5), .965, f"$\\gamma={gamma:g}$", + ha="center", va="center", fontsize=10, + ) + for row, scenario in enumerate(scenarios): + figure.text( + .012, .94 - (.915 / 3) * (row + .5), f"episode\n{scenario}", + ha="center", va="center", rotation=90, fontsize=9, + ) + figure.legend( + handles=_legend(), loc="center left", bbox_to_anchor=(.825, .58), + frameon=False, fontsize=8, + ) + figure.text( + .825, .25, + "Offline diagnostic only\n" + "NVP does not stop this simulator trace.\n" + "After NVP, a separately sampled raw\n" + "temperature-1 first action advances it.\n" + "Red continuation is not certified safety.", + ha="left", va="top", fontsize=8, + ) + + final_cells = {} + + def update(step): + final_cells.clear() + for row, scenario in enumerate(scenarios): + for column, gamma in enumerate(gammas): + axis = axes[row, column] + axis.clear() + trace = draw_cell( + axis, index[(scenario, round(gamma, 8))], int(step) + ) + final_cells[f"{scenario}:{gamma:g}"] = dict( + rendered_step=int(trace["step"]), + execution_source=trace["execution_source"], + executed_label=trace["executed_label"], + nvp_context=bool(trace["nvp_context"]), + ) + return [] + + for path in (output_mp4, output_png, output_json): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + movie = animation.FuncAnimation( + figure, update, frames=frames, interval=1000 / int(fps), blit=False, + ) + movie.save( + output_mp4, writer=animation.FFMpegWriter(fps=int(fps), bitrate=4200), + dpi=105, + ) + update(maximum) + figure.savefig(output_png, dpi=165, bbox_inches="tight") + plt.close(figure) + + report = dict( + status="SFM_B1_FULL_EPISODE_LABEL_VIZ_COMPLETE", + diagnostic_only=True, trace_path=os.path.abspath(trace_path), + trace_sha256=_sha256(trace_path), scenarios=list(scenarios), + gammas=list(gammas), frame_stride=int(frame_stride), fps=int(fps), + frames=frames, mp4=os.path.abspath(output_mp4), + mp4_sha256=_sha256(output_mp4), png=os.path.abspath(output_png), + png_sha256=_sha256(output_png), + color_semantics=( + "blue/red trail is exact full-H label of the actually executed " + "window; NVP/trap/collision remain separate event markers" + ), + final_cells=dict(final_cells), + ) + _write_json(output_json, report) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--trace", required=True) + parser.add_argument("--output-mp4", required=True) + parser.add_argument("--output-png", required=True) + parser.add_argument("--output-json", required=True) + parser.add_argument("--fps", type=int, default=5) + parser.add_argument("--frame-stride", type=int, default=2) + args = parser.parse_args(argv) + render( + args.trace, args.output_mp4, args.output_png, args.output_json, + fps=args.fps, frame_stride=args.frame_stride, + ) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_metrics2.py b/overnight_run_07_12_sfm/sfm_metrics2.py index c3762ed..5facd41 100644 --- a/overnight_run_07_12_sfm/sfm_metrics2.py +++ b/overnight_run_07_12_sfm/sfm_metrics2.py @@ -163,14 +163,15 @@ def certify_moving_window(segment, pedestrians, gamma, *, K=ARTIFICIAL_FACES, robot_c = robot - center radius = max(float(SS.R_SENSE), float(r_pad) * float(np.linalg.norm(robot_c, axis=1).max())) if len(robot) == 1: - # Reaching the goal at the current state creates an absorbing empty - # verification horizon. It is valid but never replay-eligible. + # This generic helper also audits the zero-transition tail of an + # already executed trajectory. Queried B1 plans never take this path: + # verify_query below requires and certifies all H=10 transitions. return True, [], dict( solver="exact_2d_angular_interval_socp", angular_grid=False, slack=float("inf"), worst_t=0, R_eff=float(radius), n_real=0, n_real_feasible=0, n_artificial=0, n_artificial_feasible=0, K_artificial=ARTIFICIAL_FACES, - empty_terminal_prefix=True, + empty_executed_tail=True, ) alpha = (1.0 - float(gamma)) ** np.arange(len(robot), dtype=float) beta = 1.0 - alpha @@ -201,31 +202,28 @@ def certify_moving_window(segment, pedestrians, gamma, *, K=ARTIFICIAL_FACES, ) -def verify_query(state, controls, ped_xy, ped_vel, gamma, *, reach=0.5): - """Resolve y without performance/cost terms; errors are explicit and non-storable.""" +def verify_query(state, controls, ped_xy, ped_vel, gamma): + """Certify every queried plan over all H=10 transitions. + + Goal reach is a closed-loop episode trigger after the selected first action; + it never truncates a candidate window or changes its verifier label. + """ try: controls = np.asarray(controls, np.float32).reshape(-1, 2) if len(controls) != 10: raise ValueError("B1 verifier requires H=10") robot = rollout_positions(state, controls) pedestrian = predict_pedestrians(ped_xy, ped_vel, H=len(controls)) - goal_distance = np.linalg.norm(robot - SS.GOAL[None], axis=1) - reached = np.flatnonzero(goal_distance < float(reach)) - terminal_step = int(reached[0]) if len(reached) else len(controls) - # A goal hit defines an absorbing terminal prefix. Post-goal repeats are not verified or replayed. - prefix_robot = robot[:terminal_step + 1] - prefix_pedestrian = pedestrian[:terminal_step + 1] - task = taskspace_ok(prefix_robot) - collision = collision_free_time_indexed(prefix_robot, prefix_pedestrian) + task = taskspace_ok(robot) + collision = collision_free_time_indexed(robot, pedestrian) certificate, faces, diagnostics = certify_moving_window( - prefix_robot, prefix_pedestrian, gamma, + robot, pedestrian, gamma, ) y = bool(task and collision and certificate) return dict( resolved=True, error=None, y=int(y), taskspace=bool(task), collision_free=bool(collision), certificate=bool(certificate), - full_h=bool(terminal_step == len(controls)), terminal_step=terminal_step, - train_eligible=bool(y and terminal_step == len(controls)), + full_h=True, terminal_step=len(controls), train_eligible=bool(y), segment=robot, pedestrian_prediction=pedestrian, faces=faces, diagnostics=diagnostics, ) From 830f5a868564d1a56768c84a616cff7fbaa444d3 Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 14:55:11 -0700 Subject: [PATCH 02/31] Record authenticated full-episode audit --- FRESH_RESTART.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/FRESH_RESTART.md b/FRESH_RESTART.md index 1f8417f..d6635e2 100644 --- a/FRESH_RESTART.md +++ b/FRESH_RESTART.md @@ -102,3 +102,40 @@ The companion `sfm_b1_full_episode_viz.py` renders: The resulting movie is a **pretrained-generator round-1 gathering diagnostic**, not a pure raw-policy rollout and not a safety-certified deployment. + +## Authenticated round-1 diagnostic result + +The diagnostic was run from clean commit +`bf53dee110f885b28a7783db432085e7d75f15ff` on Helios GPU 3 using episodes +250001, 250003, and 250007 for every gamma. Collection took 2 min 57 s. + +- B queries: 5,198 verifier-positive and 2,410 verifier-negative; +- executed windows: 1,459 verifier-positive and 443 verifier-negative; +- finite-B NVP contexts: 454; +- NVP continuations: 11 certified raw rescues and 443 uncertified raw actions; +- episode outcomes: 18 success and 3 collision; +- first ten-step trap entries: 3. + +This is the central pre-expansion observation: a fail-closed controller would +have hidden 454 post-NVP states. Only 11 independently sampled raw windows at +those states were both full-H positive and nominal-Hp admissible. Continuing +the other 443 states exposes unsafe data, but does not make it certified data. + +Because this is round 1, the GP history buffer is empty. The RBF length scale +does not change the equal marginal prior variance; it affects only +pending-point conditioning among the K candidates. Therefore the reported +negative selected-versus-marginal uplift is not a round-2 novelty result and +must not be used to judge the historical RBF buffer. + +Server artifacts: + +`/data3/research1/sfm_fresh_b27df76_full_episode_audit_bf53dee` + +Mac artifacts: + +`/Users/dhl/Documents/SFM_HP10_FRESH_RESTART_B27DF76_BF53DEE` + +The video is H.264, 2436x1060, 75 frames, 15 s. Its SHA-256 is +`e2abc447ced326ebdfe6be155989ac2dae3f0fb856e6ba426c59fbd9163319d8`. +The full trace SHA-256 is +`eba53f8d389e6caf30569b31749ab4bb66e88d7e805e831623bf8f8589a392bb`. From 58ec896f87a5859149a39f5f7796560cd53da518 Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 15:40:31 -0700 Subject: [PATCH 03/31] Add two-round SFM alpha replay sweep --- .../analysis/test_run_sfm_b1_r2_9arm.py | 124 +++ .../analysis/test_sfm_b1_r2_alpha_replay.py | 183 ++++ .../analysis/test_sfm_b1_r2_eval.py | 135 +++ overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py | 644 ++++++++++++++ .../sfm_b1_r2_alpha_replay.py | 561 ++++++++++++ overnight_run_07_12_sfm/sfm_b1_r2_eval.py | 833 ++++++++++++++++++ 6 files changed, 2480 insertions(+) create mode 100644 overnight_run_07_12_sfm/analysis/test_run_sfm_b1_r2_9arm.py create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_alpha_replay.py create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_eval.py create mode 100644 overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_r2_alpha_replay.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_r2_eval.py diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_r2_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_r2_9arm.py new file mode 100644 index 0000000..5b4951d --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_r2_9arm.py @@ -0,0 +1,124 @@ +import hashlib +import json +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import run_sfm_b1_r2_9arm as R + + +def _gpus(n=4): + return [ + R.GPU( + index=str(index), uuid=f"GPU-{index}", name="H100", memory_total_mib=95830, + memory_used_mib=20, utilization_percent=0, pci_bus_id=f"0000:{index:02x}:00.0", + ) + for index in range(n) + ] + + +def test_four_gpu_assignment_is_declared_three_two_two_two(): + allocation = R.assign_arms(list(R.arm_grid()), _gpus()) + by_index = { + gpu.index: [arm.name for arm in allocation[gpu.uuid]] for gpu in _gpus() + } + assert [len(by_index[str(index)]) for index in range(4)] == [3, 2, 2, 2] + assert {arm.replay_epochs for arm in allocation["GPU-0"]} == {1} + for index in ("1", "2", "3"): + assert { + arm.replay_epochs for arm in allocation[f"GPU-{index}"] + } == {10, 100} + assert sorted(sum(by_index.values(), [])) == sorted(arm.name for arm in R.arm_grid()) + + +def test_assignment_fails_when_capacity_is_insufficient(): + with pytest.raises(RuntimeError, match="exceed"): + R.assign_arms(list(R.arm_grid()), _gpus(2), max_arms_per_gpu=3) + + +def test_explicit_busy_gpu_fails_closed(): + gpus = _gpus(2) + with pytest.raises(RuntimeError, match="not idle"): + R.select_idle_gpus( + gpus, [{"gpu_uuid": "GPU-1"}], "0,1", + max_memory_mib=1024, max_utilization=5, + ) + selected = R.select_idle_gpus( + gpus, [{"gpu_uuid": "GPU-1"}], "auto", + max_memory_mib=1024, max_utilization=5, + ) + assert [gpu.index for gpu in selected] == ["0"] + + +def test_complete_marker_requires_contract_and_checkpoint_hashes(tmp_path): + arm = R.Arm(0.01, 10) + arm_dir = tmp_path / arm.name + arm_dir.mkdir() + history = [] + for round_i in range(3): + path = arm_dir / f"round_{round_i:02d}.pt" + path.write_bytes(f"round-{round_i}".encode()) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + (arm_dir / f"round_{round_i:02d}.pt.COMPLETE.json").write_text(json.dumps( + dict(status="COMPLETE", path=str(path), sha256=digest) + )) + if round_i: + history.append(dict(round=round_i, checkpoint_sha256=digest)) + contract = R._expected_arm_contract( + arm, checkpoint_sha256="source", scene_profile="double_density_velocity_ood", + ell=R.ELL, cap=R.CAP, seed=7, verifier_workers=8, + ) + marker = dict( + status=R.ARM_STATUS, experiment=arm.name, + recipe=dict( + alpha=contract["alpha"], + replay_epochs=contract["replay_epochs"], + rounds=contract["rounds"], scene_profile=contract["scene_profile"], + seed=contract["seed"], verifier_workers=contract["verifier_workers"], + lr=contract["lr"], + ), + constants=dict(ell=contract["ell"], cap=contract["cap"]), + source_checkpoint_sha256="source", history=history, + ) + (arm_dir / "COMPLETE.json").write_text(json.dumps(marker)) + result = R.validate_complete_arm( + arm_dir, arm, checkpoint_sha256="source", + scene_profile="double_density_velocity_ood", + ell=R.ELL, cap=R.CAP, seed=7, verifier_workers=8, + ) + assert [row["round"] for row in result["checkpoints"]] == [0, 1, 2] + (arm_dir / "round_01.pt").write_bytes(b"changed") + with pytest.raises(RuntimeError, match="mismatch"): + R.validate_complete_arm( + arm_dir, arm, checkpoint_sha256="source", + scene_profile="double_density_velocity_ood", + ell=R.ELL, cap=R.CAP, seed=7, verifier_workers=8, + ) + + +def test_incomplete_nonempty_arm_is_not_overwritten(tmp_path): + arm = R.Arm(0.0, 1) + arm_dir = tmp_path / arm.name + arm_dir.mkdir() + (arm_dir / "partial.log").write_text("preserve") + with pytest.raises(RuntimeError, match="incomplete nonempty"): + R.validate_complete_arm( + arm_dir, arm, checkpoint_sha256="source", + scene_profile="double_density_velocity_ood", + ell=R.ELL, cap=R.CAP, seed=7, verifier_workers=8, + ) + + +def test_trainer_command_uses_complete_replay_epoch_cli(tmp_path): + checkpoint = tmp_path / "checkpoint.pt" + checkpoint.write_bytes(b"checkpoint") + args = type("Args", (), dict( + checkpoint=str(checkpoint), verifier_workers=8, seed=17, + ))() + command = R._trainer_command(args, R.Arm(0.1, 100), tmp_path / "arm") + assert command[command.index("--alpha") + 1] == "0.1" + assert command[command.index("--replay-epochs") + 1] == "100" + assert command[command.index("--verifier-workers") + 1] == "8" + assert "--adam-steps" not in command diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_alpha_replay.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_alpha_replay.py new file mode 100644 index 0000000..d083b7f --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_alpha_replay.py @@ -0,0 +1,183 @@ +import copy + +import numpy as np +import pytest +import torch + +import grid_policy_sfm as GPS +import sfm_b1_r2_alpha_replay as R +import sfm_b1_store as BS + + +def _verifier_result(y): + return dict( + resolved=True, y=int(y), taskspace=bool(y), + collision_free=bool(y), certificate=bool(y), full_h=True, + terminal_step=10, train_eligible=bool(y), + segment=np.zeros((11, 2), np.float32), + pedestrian_prediction=np.zeros((11, 1, 2), np.float32), + diagnostics={}, + ) + + +def _recent(tmp_path): + rng = np.random.RandomState(71) + recent = BS.RecentRounds(tmp_path) + for round_i in (1, 2): + shard = BS.RoundShard(round_i) + for gamma in (0.1, 1.0): + context_id = shard.add_context( + scenario_id=100 + round_i, gamma=gamma, step=0, + state=np.zeros(4, np.float32), + hp10=rng.randn(10, 16, 12).astype(np.float32), + low5=rng.randn(5).astype(np.float32), + hist=rng.randn(16, 2).astype(np.float32), + ped_xy=np.zeros((1, 2), np.float32), + ped_vel=np.zeros((1, 2), np.float32), + ) + for candidate_id, y in enumerate((1, 1, 0)): + shard.add_resolved_query( + context_id, candidate_id, + rng.randn(10, 2).astype(np.float32), + sigma=0.4, result=_verifier_result(y), + acquisition_step=candidate_id, + ) + recent.append_and_save(shard) + return recent + + +def _negative_only_recent(tmp_path): + rng = np.random.RandomState(81) + recent = BS.RecentRounds(tmp_path) + shard = BS.RoundShard(1) + context_id = shard.add_context( + scenario_id=300, gamma=0.5, step=0, + state=np.zeros(4, np.float32), + hp10=rng.randn(10, 16, 12).astype(np.float32), + low5=rng.randn(5).astype(np.float32), + hist=rng.randn(16, 2).astype(np.float32), + ped_xy=np.zeros((1, 2), np.float32), + ped_vel=np.zeros((1, 2), np.float32), + ) + for candidate_id in range(2): + shard.add_resolved_query( + context_id, candidate_id, rng.randn(10, 2).astype(np.float32), + sigma=0.5, result=_verifier_result(0), + acquisition_step=candidate_id, + ) + recent.append_and_save(shard) + return recent + + +def test_declared_grid_is_exact_and_other_knobs_fail_closed(): + names = set() + for alpha in R.ALPHAS: + for epochs in R.REPLAY_EPOCHS: + cfg = R.ExperimentConfig(alpha=alpha, replay_epochs=epochs) + assert cfg.validate() is cfg + names.add(cfg.arm_name) + assert len(names) == 9 + with pytest.raises(ValueError): + R.ExperimentConfig(alpha=0.001, replay_epochs=1).validate() + with pytest.raises(ValueError): + R.ExperimentConfig(alpha=0.0, replay_epochs=4).validate() + with pytest.raises(ValueError): + R.ExperimentConfig(alpha=0.0, replay_epochs=1, lr=1e-5).validate() + required_by_gather = ( + "K", "B", "T", "H", "nfe", "temp", "phi_s", "selector", + ) + assert all(hasattr(R.ExperimentConfig(0.0, 1), key) for key in required_by_gather) + + +def test_fixed_probe_is_deterministic(tmp_path): + recent = _recent(tmp_path) + policy = GPS.build_sfm_policy(width=16, res_dropout=0.0) + positives = recent.positive_records() + left = R._fixed_probe_loss( + policy, positives, batch=3, device="cpu", seed=123, + ) + right = R._fixed_probe_loss( + policy, positives, batch=3, device="cpu", seed=123, + ) + assert left == right + + +def test_alpha_zero_never_reads_negative_and_one_epoch_is_complete(tmp_path, monkeypatch): + torch.manual_seed(17) + recent = _recent(tmp_path) + policy = GPS.build_sfm_policy(width=16, res_dropout=0.0) + BS.configure_expansion_trainability(policy) + optimizer = torch.optim.Adam( + [parameter for parameter in policy.parameters() if parameter.requires_grad], + lr=R.LEARNING_RATE, + ) + monkeypatch.setattr( + recent, "negative_records", + lambda: (_ for _ in ()).throw(AssertionError("alpha=0 read D-")), + ) + cfg = R.ExperimentConfig(alpha=0.0, replay_epochs=1) + report = R.repeat_complete_replay( + policy, optimizer, recent, cfg, device="cpu", round_i=1, + ) + assert report["optimizer_steps"] == 1 + assert report["positive_total_visits"] == report["positive_eligible"] + assert report["negative_eligible"] == 0 + assert report["negative_total_visits"] == 0 + assert not report["negative_used_for_training"] + assert report["fixed_probe"]["before"]["negative"] is None + assert report["fixed_probe"]["after"]["negative"] is None + assert report["epochs"][0]["positive_coverage"]["exact_once"] + assert report["visual_encoder_sha_before"] == report["visual_encoder_sha_after"] + assert report["module_relative_parameter_drift"]["E_g"] == 0.0 + + +def test_signed_replay_repeats_complete_support_per_epoch(tmp_path): + torch.manual_seed(19) + recent = _recent(tmp_path) + policy = GPS.build_sfm_policy(width=16, res_dropout=0.0) + initial = copy.deepcopy(policy) + BS.configure_expansion_trainability(policy) + optimizer = torch.optim.Adam( + [parameter for parameter in policy.parameters() if parameter.requires_grad], + lr=R.LEARNING_RATE, + ) + cfg = R.ExperimentConfig(alpha=0.1, replay_epochs=10) + report = R.repeat_complete_replay( + policy, optimizer, recent, cfg, device="cpu", round_i=2, + ) + assert report["optimizer_steps"] == 10 + assert report["positive_total_visits"] == 10 * report["positive_eligible"] + assert report["negative_total_visits"] == 10 * report["negative_eligible"] + assert all(row["positive_coverage"]["exact_once"] for row in report["epochs"]) + assert all(row["negative_coverage"]["exact_once"] for row in report["epochs"]) + assert all(row["gradient_cosine"] is not None for row in report["epochs"]) + assert report["module_relative_parameter_drift"]["E_g"] == 0.0 + assert report["visual_encoder_sha_before"] == report["visual_encoder_sha_after"] + assert any( + not torch.equal(value, initial.state_dict()[name]) + for name, value in policy.state_dict().items() + if not name.startswith("enc_grid.") + ) + + +def test_signed_negative_only_audits_every_record_and_takes_no_step(tmp_path): + torch.manual_seed(29) + recent = _negative_only_recent(tmp_path) + policy = GPS.build_sfm_policy(width=16, res_dropout=0.0) + BS.configure_expansion_trainability(policy) + initial = copy.deepcopy(policy.state_dict()) + optimizer = torch.optim.Adam( + [parameter for parameter in policy.parameters() if parameter.requires_grad], + lr=R.LEARNING_RATE, + ) + cfg = R.ExperimentConfig(alpha=0.1, replay_epochs=10) + report = R.repeat_complete_replay( + policy, optimizer, recent, cfg, device="cpu", round_i=1, + ) + assert report["positive_eligible"] == 0 + assert report["optimizer_steps"] == 0 + assert report["negative_total_visits"] == 10 * report["negative_eligible"] + assert all(row["path"] == "signed_no_positive" for row in report["epochs"]) + assert all(row["negative_coverage"]["exact_once"] for row in report["epochs"]) + for name, value in policy.state_dict().items(): + assert torch.equal(value, initial[name]) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_eval.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_eval.py new file mode 100644 index 0000000..c3cf09f --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_eval.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import sfm_b1_r2_eval as E +import sfm_protocol as SP + + +def _row(episode, gamma, *, status, clearance=None, time=None, v_safe=False): + return { + "episode": int(episode), + "gamma": float(gamma), + "status": status, + "success": status == "success", + "collision": status == "collision", + "timeout": status == "timeout", + "successful_clearance": clearance, + "time_to_goal": time, + "v_safe": bool(v_safe), + "verifier_errors": 0, + "certified_windows": 10, + } + + +def test_archive_reference_is_separate_and_m50_bank_is_disjoint(): + E._assert_disjoint_from_archive(260_000) + with pytest.raises(ValueError, match="disjoint"): + E._assert_disjoint_from_archive(250_050) + assert E.ARCHIVED_M100_REFERENCE["M_per_gamma"] == 100 + assert E.M_PER_GAMMA == 50 + assert "not_a_curve_point" in E.ARCHIVED_M100_REFERENCE["role"] + + +def test_checkpoint_specs_require_unique_increasing_round_labels(tmp_path): + checkpoints = [] + for name in ("a.pt", "b.pt"): + path = tmp_path / name + path.write_bytes(b"x") + checkpoints.append(str(path)) + specs = E._checkpoint_specs(checkpoints, ["r0", "r1"]) + assert [spec["round"] for spec in specs] == [0, 1] + with pytest.raises(ValueError, match="form"): + E._checkpoint_specs(checkpoints, ["pretrained", "r1"]) + with pytest.raises(ValueError, match="increasing"): + E._checkpoint_specs(checkpoints, ["r1", "r0"]) + + +def test_summarize_uses_actual_collision_and_success_only_continuous_metrics(): + rows = [] + for gamma in SP.GAMMAS: + rows.extend([ + _row( + 260_000, gamma, status="success", clearance=0.2, + time=9.0, v_safe=True, + ), + _row( + 260_001, gamma, status="timeout", clearance=None, + time=None, v_safe=True, + ), + _row( + 260_002, gamma, status="collision", clearance=None, + time=None, v_safe=False, + ), + ]) + summary = E.summarize(rows, seed=7) + pooled = summary["pooled"] + assert pooled["SR"] == pytest.approx(1 / 3) + assert pooled["CR"] == pytest.approx(1 / 3) + assert pooled["timeout"] == pytest.approx(1 / 3) + assert pooled["V_safe"] == pytest.approx(2 / 3) + assert pooled["successful_clearance"]["mean"] == pytest.approx(0.2) + assert pooled["successful_clearance"]["n"] == len(SP.GAMMAS) + assert pooled["successful_time_to_goal"]["mean"] == pytest.approx(9.0) + assert pooled["successful_time_to_goal"]["n"] == len(SP.GAMMAS) + + +def test_noise_bank_is_deterministic_and_checkpoint_common(): + first, first_meta = E._noise_bank(ep0=260_000, d=20, seed=123) + second, second_meta = E._noise_bank(ep0=260_000, d=20, seed=123) + assert first.shape == (len(SP.GAMMAS), 50, E.T, 20) + assert first.dtype == np.float32 + assert np.array_equal(first, second) + assert first_meta == second_meta + assert first_meta["temperature"] == 1.0 + assert first_meta["NFE"] == 8 + + +def test_render_writes_paper_style_png_and_pdf(tmp_path): + records = [] + for round_i in (0, 1, 2): + per_gamma = {} + rows = [] + for gamma in SP.GAMMAS: + cell_rows = [ + _row( + 260_000, gamma, status="success", + clearance=0.1 + round_i * 0.01, + time=9.0 + round_i, v_safe=True, + ), + _row( + 260_001, gamma, status="collision", + clearance=None, time=None, v_safe=False, + ), + ] + rows.extend(cell_rows) + per_gamma[str(gamma)] = E._summarize_one(cell_rows, round_i + 1) + pooled = E._summarize_one(rows, round_i + 100) + for metric, key in ( + ("SR", "success"), ("CR", "collision"), + ("timeout", "timeout"), ("V_safe", "v_safe"), + ): + pooled[f"{metric}_cluster_bootstrap95"] = ( + E._cluster_bootstrap_interval(rows, key, seed=round_i + 200) + ) + pooled["successful_clearance"]["cluster_bootstrap95"] = ( + E._cluster_bootstrap_interval( + rows, "successful_clearance", seed=round_i + 300 + ) + ) + pooled["successful_time_to_goal"]["cluster_bootstrap95"] = ( + E._cluster_bootstrap_interval( + rows, "time_to_goal", seed=round_i + 301 + ) + ) + records.append({ + "label": f"r{round_i}", + "round": round_i, + "cell": {"summary": {"pooled": pooled, "per_gamma": per_gamma}}, + }) + outputs = E.render(records, str(tmp_path)) + assert {Path(path).suffix for path in outputs} == {".png", ".pdf"} + assert all(Path(path).stat().st_size > 0 for path in outputs) diff --git a/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py new file mode 100644 index 0000000..098ddd7 --- /dev/null +++ b/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python3 +"""Fail-closed launcher for the two-round SFM B1 alpha/replay sweep. + +The launcher deliberately knows only the narrow CLI contract of +``sfm_b1_r2_alpha_replay.py``: + + --checkpoint PATH --outdir ABSENT_DIR + --alpha FLOAT --replay-epochs INT + --verifier-workers INT --seed INT --device cuda:0 + +Each arm must atomically write ``COMPLETE.json`` with status +``R2_ALPHA_REPLAY_COMPLETE`` and authenticated ``round_00.pt`` through +``round_02.pt`` sidecars. This launcher does not import training code and +does not evaluate checkpoints. Once all arms validate, it writes a compact +``CHECKPOINT_INDEX.json`` for a separate common-bank evaluator. +""" +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +TRAINER = HERE / "sfm_b1_r2_alpha_replay.py" +ALPHAS = (0.0, 0.01, 0.1) +REPLAY_EPOCHS = (1, 10, 100) +ROUNDS = 2 +ARM_STATUS = "R2_ALPHA_REPLAY_COMPLETE" +MAX_VERIFIER_WORKERS = 8 +ELL = 0.24210826720721101 +CAP = 256 +SCENE_PROFILE = "double_density_velocity_ood" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def sha256_file(path: str | os.PathLike[str]) -> str: + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _sha256_json(value) -> str: + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":"), allow_nan=False, + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _write_json(path: str | os.PathLike[str], payload) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + with temporary.open("w") as stream: + json.dump(payload, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +@dataclass(frozen=True) +class Arm: + alpha: float + replay_epochs: int + + @property + def name(self) -> str: + # Match ExperimentConfig.arm_name without importing the training module. + alpha = str(float(self.alpha)).replace(".", "p") + return f"margin_alpha{alpha}_epochs{self.replay_epochs:03d}" + + +def arm_grid() -> tuple[Arm, ...]: + return tuple( + Arm(alpha, epochs) for alpha in ALPHAS for epochs in REPLAY_EPOCHS + ) + + +@dataclass(frozen=True) +class GPU: + index: str + uuid: str + name: str + memory_total_mib: int + memory_used_mib: int + utilization_percent: int + pci_bus_id: str + + +def _nvidia_lines(arguments: list[str]) -> list[str]: + try: + result = subprocess.run( + ["nvidia-smi", *arguments], check=True, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as error: + raise RuntimeError(f"nvidia-smi query failed: {error}") from error + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def gpu_snapshot() -> tuple[list[GPU], list[dict], str]: + rows = _nvidia_lines([ + "--query-gpu=index,uuid,name,memory.total,memory.used,utilization.gpu,pci.bus_id", + "--format=csv,noheader,nounits", + ]) + gpus = [] + for row in rows: + values = [value.strip() for value in row.split(",")] + if len(values) != 7: + raise RuntimeError(f"unexpected nvidia-smi GPU row: {row}") + gpus.append(GPU( + index=values[0], uuid=values[1], name=values[2], + memory_total_mib=int(values[3]), memory_used_mib=int(values[4]), + utilization_percent=int(values[5]), pci_bus_id=values[6], + )) + processes = [] + for row in _nvidia_lines([ + "--query-compute-apps=gpu_uuid,pid,process_name,used_memory", + "--format=csv,noheader,nounits", + ]): + values = [value.strip() for value in row.split(",")] + if len(values) == 4: + processes.append(dict( + gpu_uuid=values[0], pid=int(values[1]), process_name=values[2], + used_memory_mib=int(values[3]), + )) + try: + topology = subprocess.run( + ["nvidia-smi", "topo", "-m"], check=True, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ).stdout + except (FileNotFoundError, subprocess.CalledProcessError): + topology = "" + return gpus, processes, topology + + +def select_idle_gpus(gpus: list[GPU], processes: list[dict], requested: str, + *, max_memory_mib: int, max_utilization: int) -> list[GPU]: + if requested == "auto": + candidates = list(gpus) + else: + indices = [value.strip() for value in requested.split(",") if value.strip()] + if len(indices) != len(set(indices)) or not indices: + raise ValueError("--gpu-indices must be 'auto' or unique comma-separated indices") + by_index = {gpu.index: gpu for gpu in gpus} + missing = [index for index in indices if index not in by_index] + if missing: + raise RuntimeError(f"requested GPU indices are unavailable: {missing}") + candidates = [by_index[index] for index in indices] + active = {row["gpu_uuid"] for row in processes} + busy = [ + gpu for gpu in candidates + if (gpu.uuid in active or gpu.memory_used_mib > int(max_memory_mib) + or gpu.utilization_percent > int(max_utilization)) + ] + if requested != "auto" and busy: + detail = [ + dict(index=gpu.index, uuid=gpu.uuid, memory_used_mib=gpu.memory_used_mib, + utilization_percent=gpu.utilization_percent, + compute_process=gpu.uuid in active) + for gpu in busy + ] + raise RuntimeError(f"explicitly requested GPUs are not idle: {detail}") + selected = [gpu for gpu in candidates if gpu not in busy] + if not selected: + raise RuntimeError("no idle GPU satisfies the launch contract") + return selected + + +def assign_arms(arms: list[Arm], gpus: list[GPU], + *, max_arms_per_gpu: int = 3) -> dict[str, list[Arm]]: + """Create a deterministic balanced allocation. + + With four GPUs and the declared grid this places all three one-step arms + together and one 10/100 pair on each remaining GPU, yielding 3/2/2/2. + For other GPU counts, a least-loaded greedy allocation is used. + """ + if not gpus: + raise ValueError("at least one GPU is required") + if int(max_arms_per_gpu) < 1: + raise ValueError("max_arms_per_gpu must be positive") + if len(arms) > len(gpus) * int(max_arms_per_gpu): + raise RuntimeError( + f"{len(arms)} arms exceed {len(gpus)} GPUs x " + f"{int(max_arms_per_gpu)} arms/GPU" + ) + ordered_gpus = sorted(gpus, key=lambda gpu: int(gpu.index)) + allocation = {gpu.uuid: [] for gpu in ordered_gpus} + if len(arms) == 9 and len(ordered_gpus) == 4 and set(arms) == set(arm_grid()): + by_steps = { + steps: sorted( + [arm for arm in arms if arm.replay_epochs == steps], + key=lambda arm: arm.alpha, + ) + for steps in REPLAY_EPOCHS + } + allocation[ordered_gpus[0].uuid].extend(by_steps[1]) + for gpu, ten, hundred in zip( + ordered_gpus[1:], by_steps[10], by_steps[100]): + allocation[gpu.uuid].extend((ten, hundred)) + return allocation + # The fallback cost is dominated by gathering, so arm count is the primary + # balance term; replay work is only a deterministic tie-break. + for arm in sorted( + arms, key=lambda value: (-value.replay_epochs, value.alpha)): + eligible = [ + gpu for gpu in ordered_gpus + if len(allocation[gpu.uuid]) < int(max_arms_per_gpu) + ] + gpu = min( + eligible, + key=lambda value: ( + len(allocation[value.uuid]), + sum(item.replay_epochs for item in allocation[value.uuid]), + int(value.index), + ), + ) + allocation[gpu.uuid].append(arm) + return allocation + + +def source_provenance() -> dict: + environment = os.environ.copy() + environment.pop("LD_LIBRARY_PATH", None) + + def git(*arguments: str) -> str: + try: + return subprocess.check_output( + ["git", *arguments], cwd=ROOT, text=True, env=environment, + ).strip() + except subprocess.CalledProcessError as error: + raise RuntimeError(f"git {' '.join(arguments)} failed") from error + + status = git("status", "--porcelain") + if status: + raise RuntimeError("source worktree must be clean before launch") + branch = git("branch", "--show-current") + if not branch: + raise RuntimeError("a named pushed branch is required") + head = git("rev-parse", "HEAD") + try: + remote = subprocess.check_output( + ["git", "ls-remote", "--heads", "origin", branch], + cwd=ROOT, text=True, env=environment, + ).split() + except subprocess.CalledProcessError as error: + raise RuntimeError("cannot authenticate origin branch") from error + if not remote or remote[0] != head: + raise RuntimeError("source HEAD is not the pushed origin branch head") + return dict(branch=branch, commit=head, remote_commit=remote[0]) + + +def _cpu_affinity() -> list[int]: + if hasattr(os, "sched_getaffinity"): + return sorted(os.sched_getaffinity(0)) + return list(range(os.cpu_count() or 1)) + + +def allocate_cpu_pools(arms: list[Arm], workers: int) -> dict[str, list[int]]: + cpus = _cpu_affinity() + needed = len(arms) * int(workers) + if len(cpus) < needed: + raise RuntimeError( + f"{len(arms)} arms x {workers} verifier workers require {needed} " + f"available CPUs, only {len(cpus)} are in the launcher affinity" + ) + return { + arm.name: cpus[index * int(workers):(index + 1) * int(workers)] + for index, arm in enumerate(arms) + } + + +def _expected_arm_contract(arm: Arm, *, checkpoint_sha256: str, scene_profile: str, + ell: float, cap: int, seed: int, + verifier_workers: int, rounds: int = ROUNDS) -> dict: + return dict( + alpha=float(arm.alpha), replay_epochs=int(arm.replay_epochs), + rounds=int(rounds), checkpoint_sha256=str(checkpoint_sha256), + scene_profile=str(scene_profile), ell=float(ell), cap=int(cap), + seed=int(seed), verifier_workers=int(verifier_workers), lr=1.0e-4, + ) + + +def validate_complete_arm(arm_dir: str | os.PathLike[str], arm: Arm, *, + checkpoint_sha256: str, scene_profile: str, + ell: float, cap: int, seed: int, + verifier_workers: int) -> dict | None: + arm_dir = Path(arm_dir) + marker = arm_dir / "COMPLETE.json" + if not marker.exists(): + if arm_dir.exists() and any(arm_dir.iterdir()): + raise RuntimeError(f"incomplete nonempty arm directory: {arm_dir}") + return None + with marker.open() as stream: + payload = json.load(stream) + if payload.get("status") != ARM_STATUS: + raise RuntimeError(f"invalid arm completion status: {marker}") + if payload.get("experiment") != arm.name: + raise RuntimeError(f"arm name mismatch in {marker}") + expected = _expected_arm_contract( + arm, checkpoint_sha256=checkpoint_sha256, + scene_profile=scene_profile, ell=ell, cap=cap, seed=seed, + verifier_workers=verifier_workers, + ) + recipe = payload.get("recipe", {}) + constants = payload.get("constants", {}) + contract = dict( + alpha=recipe.get("alpha"), replay_epochs=recipe.get("replay_epochs"), + rounds=recipe.get("rounds"), + checkpoint_sha256=payload.get("source_checkpoint_sha256"), + scene_profile=recipe.get("scene_profile"), + ell=constants.get("ell"), cap=constants.get("cap"), + seed=recipe.get("seed"), verifier_workers=recipe.get("verifier_workers"), + lr=recipe.get("lr"), + ) + if contract != expected: + raise RuntimeError( + f"arm completion contract mismatch for {arm.name}: " + f"{contract!r} != {expected!r}" + ) + history = payload.get("history") + if not isinstance(history, list) or len(history) != ROUNDS: + raise RuntimeError(f"{marker} must contain two round-history records") + history_by_round = {int(row.get("round", -1)): row for row in history} + validated = [] + for round_i in range(ROUNDS + 1): + path = (arm_dir / f"round_{round_i:02d}.pt").resolve() + expected_name = f"round_{round_i:02d}.pt" + if path.name != expected_name or not path.is_file(): + raise RuntimeError(f"missing expected checkpoint: {path}") + observed = sha256_file(path) + sidecar = Path(str(path) + ".COMPLETE.json") + if not sidecar.is_file(): + raise RuntimeError(f"missing checkpoint completion sidecar: {sidecar}") + with sidecar.open() as stream: + sidecar_payload = json.load(stream) + if (sidecar_payload.get("status") != "COMPLETE" + or sidecar_payload.get("sha256") != observed): + raise RuntimeError(f"checkpoint sidecar mismatch: {sidecar}") + if round_i > 0 and history_by_round.get(round_i, {}).get( + "checkpoint_sha256") != observed: + raise RuntimeError(f"checkpoint hash mismatch: {path}") + validated.append(dict( + round=round_i, path=str(path), sha256=observed, + complete_sidecar=str(sidecar.resolve()), + complete_sidecar_sha256=sha256_file(sidecar), + )) + return dict(marker=str(marker.resolve()), marker_sha256=sha256_file(marker), + checkpoints=validated, payload=payload) + + +def _trainer_command(args, arm: Arm, arm_dir: Path) -> list[str]: + return [ + sys.executable, str(TRAINER), + "--checkpoint", str(Path(args.checkpoint).resolve()), + "--outdir", str(arm_dir.resolve()), + "--alpha", str(arm.alpha), + "--replay-epochs", str(arm.replay_epochs), + "--verifier-workers", str(args.verifier_workers), + "--seed", str(args.seed), + "--device", "cuda:0", + ] + + +def _child_environment(gpu: GPU) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + CUDA_DEVICE_ORDER="PCI_BUS_ID", + CUDA_VISIBLE_DEVICES=gpu.uuid, + OMP_NUM_THREADS="1", + MKL_NUM_THREADS="1", + OPENBLAS_NUM_THREADS="1", + NUMEXPR_NUM_THREADS="1", + TORCH_NUM_THREADS="1", + PYTHONPATH=str(HERE) + os.pathsep + environment.get("PYTHONPATH", ""), + ) + return environment + + +def _launch_pending(jobs: list[dict], log_dir: Path) -> list[str]: + taskset = shutil.which("taskset") + running = [] + log_dir.mkdir(parents=True, exist_ok=True) + try: + for job in jobs: + log_path = log_dir / f"{job['arm'].name}.log" + stream = log_path.open("w") + command = list(job["command"]) + if taskset: + command = [ + taskset, "-c", ",".join(map(str, job["cpu_pool"])), *command, + ] + process = subprocess.Popen( + command, cwd=ROOT, env=_child_environment(job["gpu"]), + stdout=stream, stderr=subprocess.STDOUT, text=True, + start_new_session=True, + ) + running.append(dict( + process=process, stream=stream, log_path=str(log_path.resolve()), + arm=job["arm"], + )) + while running: + failure = None + for item in running: + code = item["process"].poll() + if code not in (None, 0): + failure = (item["arm"].name, code, item["log_path"]) + break + if failure is not None: + for item in running: + if item["process"].poll() is None: + os.killpg(item["process"].pid, signal.SIGTERM) + deadline = time.monotonic() + 10.0 + for item in running: + remaining = max(0.0, deadline - time.monotonic()) + try: + item["process"].wait(timeout=remaining) + except subprocess.TimeoutExpired: + os.killpg(item["process"].pid, signal.SIGKILL) + item["process"].wait() + raise RuntimeError( + f"arm {failure[0]} failed with code {failure[1]}; " + f"all peers were stopped; log={failure[2]}" + ) + finished = [item for item in running if item["process"].poll() == 0] + for item in finished: + item["stream"].close() + running.remove(item) + if running: + time.sleep(0.25) + except BaseException: + for item in running: + if item["process"].poll() is None: + os.killpg(item["process"].pid, signal.SIGTERM) + item["stream"].close() + raise + finally: + for item in running: + if not item["stream"].closed: + item["stream"].close() + return [job["log_path"] for job in jobs] + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--expected-checkpoint-sha256", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--scene-profile", default=SCENE_PROFILE, choices=(SCENE_PROFILE,)) + parser.add_argument("--ell", type=float, default=ELL) + parser.add_argument("--cap", type=int, default=CAP) + parser.add_argument("--seed", type=int, default=20260723) + parser.add_argument("--verifier-workers", type=int, default=8) + parser.add_argument("--gpu-indices", default="auto") + parser.add_argument("--max-arms-per-gpu", type=int, default=3) + parser.add_argument("--idle-memory-mib", type=int, default=1024) + parser.add_argument("--idle-utilization-percent", type=int, default=5) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def run(args) -> dict: + if not (1 <= int(args.verifier_workers) <= MAX_VERIFIER_WORKERS): + raise ValueError( + f"--verifier-workers must be in [1,{MAX_VERIFIER_WORKERS}]" + ) + if float(args.ell) != ELL or int(args.cap) != CAP: + raise ValueError(f"trainer fixes ell={ELL} and cap={CAP}") + checkpoint = Path(args.checkpoint).resolve() + if not checkpoint.is_file(): + raise FileNotFoundError(checkpoint) + observed_checkpoint_sha = sha256_file(checkpoint) + if observed_checkpoint_sha != args.expected_checkpoint_sha256: + raise RuntimeError( + f"checkpoint SHA-256 mismatch: {observed_checkpoint_sha} != " + f"{args.expected_checkpoint_sha256}" + ) + if not TRAINER.is_file(): + raise FileNotFoundError( + f"training module is not present at the frozen source path: {TRAINER}" + ) + source = source_provenance() + arms = list(arm_grid()) + outdir = Path(args.outdir).resolve() + declaration_contract = dict( + version=1, source_commit=source["commit"], + trainer_sha256=sha256_file(TRAINER), + checkpoint=str(checkpoint), checkpoint_sha256=observed_checkpoint_sha, + scene_profile=args.scene_profile, rounds=ROUNDS, + alphas=list(ALPHAS), replay_epochs=list(REPLAY_EPOCHS), + ell=float(args.ell), cap=int(args.cap), seed=int(args.seed), + verifier_workers=int(args.verifier_workers), + ) + declaration = dict( + status="SFM_B1_R2_9ARM_DECLARED", + contract=declaration_contract, + contract_sha256=_sha256_json(declaration_contract), + ) + declaration_path = outdir / "RUN_DECLARATION.json" + if declaration_path.exists(): + with declaration_path.open() as stream: + existing = json.load(stream) + if existing != declaration: + raise RuntimeError(f"existing run declaration differs: {declaration_path}") + elif outdir.exists() and any(outdir.iterdir()): + raise RuntimeError(f"nonempty output root lacks a matching declaration: {outdir}") + + completed, pending_arms = {}, [] + for arm in arms: + arm_dir = outdir / "arms" / arm.name + complete = validate_complete_arm( + arm_dir, arm, checkpoint_sha256=observed_checkpoint_sha, + scene_profile=args.scene_profile, ell=args.ell, cap=args.cap, + seed=args.seed, verifier_workers=args.verifier_workers, + ) + if complete is not None: + completed[arm.name] = complete + continue + pending_arms.append(arm) + gpus, compute_processes, topology = gpu_snapshot() + if pending_arms: + selected_gpus = select_idle_gpus( + gpus, compute_processes, args.gpu_indices, + max_memory_mib=args.idle_memory_mib, + max_utilization=args.idle_utilization_percent, + ) + allocation = assign_arms( + pending_arms, selected_gpus, max_arms_per_gpu=args.max_arms_per_gpu, + ) + by_uuid = {gpu.uuid: gpu for gpu in selected_gpus} + cpu_pools = allocate_cpu_pools(pending_arms, args.verifier_workers) + arm_gpu = { + arm: by_uuid[uuid] + for uuid, values in allocation.items() for arm in values + } + else: + selected_gpus, allocation, cpu_pools, arm_gpu = [], {}, {}, {} + pending = [ + dict( + arm=arm, gpu=arm_gpu[arm], arm_dir=outdir / "arms" / arm.name, + cpu_pool=cpu_pools[arm.name], + command=_trainer_command(args, arm, outdir / "arms" / arm.name), + ) + for arm in pending_arms + ] + plan = dict( + status="SFM_B1_R2_9ARM_DRY_RUN" if args.dry_run else "SFM_B1_R2_9ARM_PLAN", + generated_at=_utc_now(), source=source, declaration=declaration, + all_gpus=[asdict(gpu) for gpu in gpus], + selected_gpus=[asdict(gpu) for gpu in selected_gpus], + compute_processes=compute_processes, topology=topology, + allocation={ + gpu.index: [arm.name for arm in allocation[gpu.uuid]] + for gpu in selected_gpus + }, + completed_arms=sorted(completed), + pending=[dict( + arm=item["arm"].name, alpha=item["arm"].alpha, + replay_epochs=item["arm"].replay_epochs, + gpu_index=item["gpu"].index, + gpu_uuid=item["gpu"].uuid, cpu_pool=item["cpu_pool"], + command=item["command"], + ) for item in pending], + ) + if args.dry_run: + print(json.dumps(plan, indent=2, allow_nan=False)) + return plan + + outdir.mkdir(parents=True, exist_ok=True) + _write_json(declaration_path, declaration) + _write_json(outdir / "GPU_PROVENANCE.json", plan) + started = time.perf_counter() + jobs = [] + for item in pending: + item["log_path"] = str( + (outdir / "logs" / f"{item['arm'].name}.log").resolve() + ) + jobs.append(item) + logs = _launch_pending(jobs, outdir / "logs") if jobs else [] + + index_rows = [] + for arm in arms: + complete = validate_complete_arm( + outdir / "arms" / arm.name, arm, + checkpoint_sha256=observed_checkpoint_sha, + scene_profile=args.scene_profile, ell=args.ell, cap=args.cap, + seed=args.seed, verifier_workers=args.verifier_workers, + ) + if complete is None: + raise RuntimeError(f"arm returned without COMPLETE.json: {arm.name}") + index_rows.append(dict( + arm=arm.name, alpha=arm.alpha, + replay_epochs=arm.replay_epochs, + complete_marker=complete["marker"], + complete_marker_sha256=complete["marker_sha256"], + checkpoints=complete["checkpoints"], + )) + checkpoint_index = dict( + status="SFM_B1_R2_CHECKPOINT_INDEX_COMPLETE", + created_at=_utc_now(), source=source, + run_contract=declaration_contract, + common_evaluation_requirement=( + "Evaluate the unique r0 checkpoint once and every arm r1/r2 checkpoint " + "on one predeclared raw temp=1 M50/gamma common-noise bank; do not " + "force the M50 r0 estimate to equal the archival M100 statistic." + ), + arms=index_rows, + ) + index_path = outdir / "CHECKPOINT_INDEX.json" + _write_json(index_path, checkpoint_index) + complete = dict( + status="SFM_B1_R2_9ARM_TRAINING_COMPLETE", + finished_at=_utc_now(), wall_seconds=time.perf_counter() - started, + source=source, declaration_sha256=sha256_file(declaration_path), + gpu_provenance_sha256=sha256_file(outdir / "GPU_PROVENANCE.json"), + checkpoint_index=str(index_path), checkpoint_index_sha256=sha256_file(index_path), + resumed_arms=sorted(completed), launched_arms=[item["arm"].name for item in jobs], + logs=logs, + ) + _write_json(outdir / "TRAINING_COMPLETE.json", complete) + print(json.dumps(complete, indent=2, allow_nan=False)) + return complete + + +def main(argv=None) -> None: + run(_parser().parse_args(argv)) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_r2_alpha_replay.py b/overnight_run_07_12_sfm/sfm_b1_r2_alpha_replay.py new file mode 100644 index 0000000..a960f6a --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_r2_alpha_replay.py @@ -0,0 +1,561 @@ +"""Isolated two-round max-margin B1 alpha/replay-epoch experiment. + +This module intentionally does not change the authenticated Arm-A runner. It +keeps the B1 gather path fixed and varies only + + alpha in {0, 0.01, 0.1} + complete W=2 replay epochs in {1, 10, 100}. + +One replay epoch visits every eligible record exactly once, accumulates the +hierarchically weighted objective over minibatches, and then takes one Adam +step. Consequently ``replay_epochs`` is also the number of optimizer steps +per macro-round whenever positive support is non-empty. +""" +from __future__ import annotations + +import argparse +import copy +from dataclasses import asdict, dataclass +import json +import os +import random +import time +from concurrent.futures import ProcessPoolExecutor + +import numpy as np +import torch + +import _paths # noqa: F401 +import grid_policy_sfm as GPS +import sfm_b1_expand as BX +import sfm_b1_store as BS +import sfm_protocol as SP +import sfm_scene as SS +import sfm_metrics2 as SM + + +EXPECTED_CHECKPOINT_SHA256 = "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" +ELL = 0.24210826720721101 +CAP = 256 +GP_LAMBDA = 1.0e-2 +LEARNING_RATE = 1.0e-4 +ROUNDS = 2 +ALPHAS = (0.0, 0.01, 0.1) +REPLAY_EPOCHS = (1, 10, 100) + + +@dataclass(frozen=True) +class ExperimentConfig: + alpha: float + replay_epochs: int + rounds: int = ROUNDS + K: int = 16 + B: int = 4 + T: int = 180 + H: int = 10 + W: int = 2 + batch: int = 128 + lr: float = LEARNING_RATE + ess_target: float = 0.5 + nfe: int = 8 + temp: float = 1.0 + phi_s: float = 0.9 + gp_lam: float = GP_LAMBDA + selector: str = "margin" + verifier_workers: int = 32 + seed: int = 20260723 + scene_profile: str = "double_density_velocity_ood" + smoke: bool = False + + def validate(self): + if float(self.alpha) not in ALPHAS: + raise ValueError(f"alpha must be one of {ALPHAS}") + if int(self.replay_epochs) not in REPLAY_EPOCHS: + raise ValueError(f"replay_epochs must be one of {REPLAY_EPOCHS}") + expected = ( + ROUNDS, 16, 4, 180, 10, 2, 128, LEARNING_RATE, 0.5, 8, 1.0, + 0.9, GP_LAMBDA, "margin", "double_density_velocity_ood", False, + ) + actual = ( + self.rounds, self.K, self.B, self.T, self.H, self.W, self.batch, + self.lr, self.ess_target, self.nfe, self.temp, self.phi_s, + self.gp_lam, self.selector, self.scene_profile, self.smoke, + ) + if actual != expected: + raise ValueError("fixed two-round alpha/replay experiment contract changed") + if int(self.verifier_workers) < 1: + raise ValueError("verifier_workers must be positive") + return self + + @property + def arm_name(self): + alpha = str(float(self.alpha)).replace(".", "p") + return f"margin_alpha{alpha}_epochs{int(self.replay_epochs):03d}" + + +def _set_update_seed(seed): + """Seed every RNG used by CFM noise, dropout, and replay ordering.""" + seed = int(seed) + random.seed(seed) + np.random.seed(seed % (2 ** 32)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def _records(recent, alpha): + positives = [ + (shard, query) + for shard, query in recent.positive_records() + if query["train_eligible"] + ] + # Preserve the established alpha=0 contract exactly: D- is not read, + # including for diagnostics. Its negative fixed-probe loss is therefore + # explicitly reported as null in the alpha=0 arm. + negatives = [] if float(alpha) == 0.0 else list(recent.negative_records()) + return positives, negatives + + +def _coverage(visited, eligible): + identities = list(visited) + return dict( + eligible=int(eligible), + visited=len(identities), + unique_visited=len(set(identities)), + exact_once=bool( + len(identities) == int(eligible) + and len(set(identities)) == int(eligible) + ), + ) + + +def _compact_mass(accounting): + """Keep exact mass checks without serializing every cell/context key.""" + gamma = dict(accounting.get("gamma", {})) + gamma_values = list(map(float, gamma.values())) + return dict( + total=float(accounting.get("total", 0.0)), + gamma=gamma, + gamma_spread=( + max(gamma_values) - min(gamma_values) if gamma_values else 0.0 + ), + cells=len(accounting.get("cells", {})), + contexts=len(accounting.get("contexts", {})), + ) + + +def _gradient_cosine(left, right): + dot = torch.zeros((), dtype=torch.float64) + left_sq = torch.zeros((), dtype=torch.float64) + right_sq = torch.zeros((), dtype=torch.float64) + for name in set(left) | set(right): + lvalue = left.get(name) + rvalue = right.get(name) + if lvalue is not None: + left_sq += lvalue.to(torch.float64).square().sum().cpu() + if rvalue is not None: + right_sq += rvalue.to(torch.float64).square().sum().cpu() + if lvalue is not None and rvalue is not None: + dot += (lvalue.to(torch.float64) * rvalue.to(torch.float64)).sum().cpu() + denominator = float((left_sq * right_sq).sqrt()) + return None if denominator <= 0.0 else float(dot) / denominator + + +def _group_gradient_norms(policy, snapshot): + parameter_names = {id(parameter): name for name, parameter in policy.named_parameters()} + result = {} + for group_name, module in policy.module_groups().items(): + squared = torch.zeros((), dtype=torch.float64) + for parameter in module.parameters(): + value = snapshot.get(parameter_names[id(parameter)]) + if value is not None: + squared += value.to(torch.float64).square().sum().cpu() + result[group_name] = float(squared.sqrt()) + return result + + +def _module_snapshot(policy): + return { + group: { + name: value.detach().cpu().clone() + for name, value in module.state_dict().items() + } + for group, module in policy.module_groups().items() + } + + +def _module_relative_drift(before, after, eps=1.0e-12): + result = {} + for group in before: + delta_sq = torch.zeros((), dtype=torch.float64) + base_sq = torch.zeros((), dtype=torch.float64) + for name, initial in before[group].items(): + final = after[group][name] + delta_sq += (final.to(torch.float64) - initial.to(torch.float64)).square().sum() + base_sq += initial.to(torch.float64).square().sum() + result[group] = float(delta_sq.sqrt() / base_sq.sqrt().clamp_min(float(eps))) + return result + + +@torch.no_grad() +def _fixed_probe_loss(policy, records, *, batch, device, seed): + """Evaluate deterministic, dropout-disabled CFM loss on complete support.""" + if not records: + return None + mass, _ = BS.hierarchy_mass(records) + was_training = policy.training + policy.eval() + generator = torch.Generator(device=device).manual_seed(int(seed)) + total = 0.0 + for values in BS._batches(records, int(batch)): + grid, low, hist, controls = BS._tensor_batch(values, device) + context = policy.ctx_from(grid, low, hist) + count = len(values) + x1 = (controls / policy.u_max).reshape(count, policy.d) + x0 = torch.randn( + x1.shape, dtype=x1.dtype, device=x1.device, generator=generator, + ) + tau = torch.rand( + count, dtype=x1.dtype, device=x1.device, generator=generator, + ).clamp(1.0e-4, 1.0) + x_tau = (1.0 - tau)[:, None] * x0 + tau[:, None] * x1 + target = x1 - x0 + prediction = policy.forward(x_tau, tau, context) + per = (prediction - target).square().mean(dim=1) + weights = torch.as_tensor( + [mass[(id(shard), int(query["query_id"]))] for shard, query in values], + dtype=per.dtype, device=per.device, + ) + total += float((per * weights).sum()) + policy.train(was_training) + return float(total) + + +def _positive_epoch(policy, optimizer, positives, *, batch, device, seed, path): + ordered = BS.hierarchical_order(positives, seed) + mass, accounting = BS.hierarchy_mass(ordered) + optimizer.zero_grad(set_to_none=True) + if not ordered: + return dict( + path=path, optimizer_steps=0, positive_loss=0.0, + positive_norm=0.0, positive_group_norms={}, + positive_coverage=_coverage([], 0), + positive_mass=_compact_mass(accounting), + negative_coverage=_coverage([], 0), + ) + loss, visited = BS._accumulate_objective(policy, ordered, mass, batch, device) + gradient = BS._gradient_snapshot(policy) + norm = BS._gradient_norm(gradient) + group_norms = _group_gradient_norms(policy, gradient) + coverage = _coverage(visited, len(ordered)) + if not coverage["exact_once"]: + raise RuntimeError("positive replay did not cover every eligible record exactly once") + optimizer.step() + return dict( + path=path, optimizer_steps=1, positive_loss=float(loss), + positive_norm=float(norm), positive_group_norms=group_norms, + positive_coverage=coverage, positive_mass=_compact_mass(accounting), + negative_coverage=_coverage([], 0), + ) + + +def _signed_epoch(policy, optimizer, positives, negatives, *, alpha, batch, device, seed): + if float(alpha) == 0.0: + return _positive_epoch( + policy, optimizer, positives, batch=batch, device=device, + seed=seed, path="positive_only", + ) + if not negatives: + return _positive_epoch( + policy, optimizer, positives, batch=batch, device=device, + seed=seed, path="positive_fallback_no_negative", + ) + + positive_order = BS.hierarchical_order(positives, seed) + negative_order = BS.hierarchical_order(negatives, seed + 1) + positive_mass, positive_accounting = BS.hierarchy_mass(positive_order) + negative_mass, negative_accounting = BS.hierarchy_mass(negative_order) + if not positive_order: + optimizer.zero_grad(set_to_none=True) + negative_loss, negative_visited = BS._accumulate_objective( + policy, negative_order, negative_mass, batch, device, + ) + negative_gradient = BS._gradient_snapshot(policy) + optimizer.zero_grad(set_to_none=True) + negative_coverage = _coverage(negative_visited, len(negative_order)) + if not negative_coverage["exact_once"]: + raise RuntimeError("negative-only replay coverage failure") + return dict( + path="signed_no_positive", optimizer_steps=0, alpha=float(alpha), + rho=0.0, gradient_cosine=None, positive_loss=0.0, + negative_loss=float(negative_loss), positive_norm=0.0, + negative_norm=BS._gradient_norm(negative_gradient), + positive_group_norms={}, + negative_group_norms=_group_gradient_norms(policy, negative_gradient), + positive_coverage=_coverage([], 0), + negative_coverage=negative_coverage, + positive_mass=_compact_mass(positive_accounting), + negative_mass=_compact_mass(negative_accounting), + ) + + optimizer.zero_grad(set_to_none=True) + positive_loss, positive_visited = BS._accumulate_objective( + policy, positive_order, positive_mass, batch, device, + ) + positive_gradient = BS._gradient_snapshot(policy) + positive_norm = BS._gradient_norm(positive_gradient) + optimizer.zero_grad(set_to_none=True) + negative_loss, negative_visited = BS._accumulate_objective( + policy, negative_order, negative_mass, batch, device, + ) + negative_gradient = BS._gradient_snapshot(policy) + negative_norm = BS._gradient_norm(negative_gradient) + rho = float(alpha) * positive_norm / (negative_norm + 1.0e-12) + for name, parameter in policy.named_parameters(): + if not parameter.requires_grad: + continue + positive = positive_gradient.get(name) + negative = negative_gradient.get(name) + if positive is None and negative is None: + parameter.grad = None + elif positive is None: + parameter.grad = -rho * negative + elif negative is None: + parameter.grad = positive + else: + parameter.grad = positive - rho * negative + + positive_coverage = _coverage(positive_visited, len(positive_order)) + negative_coverage = _coverage(negative_visited, len(negative_order)) + if not positive_coverage["exact_once"] or not negative_coverage["exact_once"]: + raise RuntimeError("signed replay did not cover complete support exactly once") + optimizer.step() + return dict( + path="signed", optimizer_steps=1, alpha=float(alpha), rho=float(rho), + gradient_cosine=_gradient_cosine(positive_gradient, negative_gradient), + positive_loss=float(positive_loss), negative_loss=float(negative_loss), + positive_norm=float(positive_norm), negative_norm=float(negative_norm), + positive_group_norms=_group_gradient_norms(policy, positive_gradient), + negative_group_norms=_group_gradient_norms(policy, negative_gradient), + positive_coverage=positive_coverage, negative_coverage=negative_coverage, + positive_mass=_compact_mass(positive_accounting), + negative_mass=_compact_mass(negative_accounting), + ) + + +def _numeric_summary(rows, key): + values = [float(row[key]) for row in rows if row.get(key) is not None] + if not values: + return None + return dict( + first=values[0], last=values[-1], mean=float(np.mean(values)), + minimum=min(values), maximum=max(values), + ) + + +def repeat_complete_replay(policy, optimizer, recent, cfg, *, device, round_i): + positives, negatives = _records(recent, cfg.alpha) + probe_seed = cfg.seed + int(round_i) * 1_000_003 + fixed_probe_before = dict( + positive=_fixed_probe_loss( + policy, positives, batch=cfg.batch, device=device, seed=probe_seed, + ), + negative=_fixed_probe_loss( + policy, negatives, batch=cfg.batch, device=device, seed=probe_seed + 1, + ), + ) + modules_before = _module_snapshot(policy) + encoder_before = BS.module_sha256(policy.enc_grid) + epoch_rows = [] + for epoch_i in range(int(cfg.replay_epochs)): + epoch_seed = cfg.seed + int(round_i) * 100_000 + epoch_i + _set_update_seed(epoch_seed) + row = _signed_epoch( + policy, optimizer, positives, negatives, alpha=cfg.alpha, + batch=cfg.batch, device=device, seed=epoch_seed, + ) + epoch_rows.append(dict(epoch=epoch_i + 1, seed=epoch_seed, **row)) + encoder_after = BS.module_sha256(policy.enc_grid) + if encoder_after != encoder_before: + raise RuntimeError("visual encoder changed during isolated replay") + fixed_probe_after = dict( + positive=_fixed_probe_loss( + policy, positives, batch=cfg.batch, device=device, seed=probe_seed, + ), + negative=_fixed_probe_loss( + policy, negatives, batch=cfg.batch, device=device, seed=probe_seed + 1, + ), + ) + modules_after = _module_snapshot(policy) + optimizer_steps = sum(int(row["optimizer_steps"]) for row in epoch_rows) + expected_steps = int(cfg.replay_epochs) if positives else 0 + if optimizer_steps != expected_steps: + raise RuntimeError( + f"expected {expected_steps} optimizer steps, observed {optimizer_steps}" + ) + if any(not row["positive_coverage"]["exact_once"] for row in epoch_rows if positives): + raise RuntimeError("an epoch omitted or duplicated positive support") + if ( + float(cfg.alpha) > 0.0 + and negatives + and any(not row["negative_coverage"]["exact_once"] for row in epoch_rows) + ): + raise RuntimeError("an epoch omitted or duplicated negative support") + summary_keys = ( + "positive_loss", "negative_loss", "positive_norm", "negative_norm", + "rho", "gradient_cosine", + ) + return dict( + alpha=float(cfg.alpha), replay_epochs=int(cfg.replay_epochs), + optimizer_steps=optimizer_steps, + positive_eligible=len(positives), negative_eligible=len(negatives), + positive_total_visits=sum( + row["positive_coverage"]["visited"] for row in epoch_rows + ), + negative_total_visits=sum( + row["negative_coverage"]["visited"] for row in epoch_rows + ), + negative_used_for_training=bool(float(cfg.alpha) > 0.0 and negatives), + exact_complete_replay=True, + fixed_probe=dict(before=fixed_probe_before, after=fixed_probe_after), + module_relative_parameter_drift=_module_relative_drift( + modules_before, modules_after, + ), + visual_encoder_sha_before=encoder_before, + visual_encoder_sha_after=encoder_after, + summaries={key: _numeric_summary(epoch_rows, key) for key in summary_keys}, + epochs=epoch_rows, + ) + + +def run(checkpoint, outdir, cfg, *, device): + cfg.validate() + checkpoint = os.path.abspath(checkpoint) + outdir = os.path.abspath(outdir) + if not os.path.isfile(checkpoint): + raise FileNotFoundError(checkpoint) + checkpoint_sha = BS.sha256_file(checkpoint) + if checkpoint_sha != EXPECTED_CHECKPOINT_SHA256: + raise ValueError( + f"checkpoint SHA mismatch: expected {EXPECTED_CHECKPOINT_SHA256}, got {checkpoint_sha}" + ) + if os.path.exists(outdir): + raise FileExistsError(f"refusing to reuse output directory: {outdir}") + os.makedirs(outdir) + environment = SS.scene_profile(cfg.scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + frozen_parameters = BS.configure_expansion_trainability(policy) + visual_encoder_sha = BS.module_sha256(policy.enc_grid) + optimizer = torch.optim.Adam( + [parameter for parameter in policy.parameters() if parameter.requires_grad], + lr=cfg.lr, + ) + recent = BS.RecentRounds(os.path.join(outdir, "round_shards"), cfg.W) + proposal_generator = torch.Generator(device=device).manual_seed(cfg.seed) + BX._save_checkpoint(policy, os.path.join(outdir, "round_00.pt"), dict( + round=0, experiment=cfg.arm_name, source_checkpoint=checkpoint, + source_sha256=checkpoint_sha, encoder_sha256=visual_encoder_sha, + recipe=asdict(cfg), + )) + history = [] + with ProcessPoolExecutor(max_workers=cfg.verifier_workers) as executor: + for round_i in range(1, cfg.rounds + 1): + round_start = time.perf_counter() + scenarios = SP.expansion_scenarios(round_i, smoke=False) + replicas = [ + BX.Replica( + scenario_id, gamma, n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id in scenarios for gamma in SP.GAMMAS + ] + if len(replicas) != 56: + raise RuntimeError("isolated experiment requires 56 macro-round replicas") + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + gp, gp_ids = BX.gp_from_recent( + phi_policy, recent, ell=ELL, cap=CAP, lam=cfg.gp_lam, + phi_s=cfg.phi_s, device=device, seed=cfg.seed + round_i * 101, + ) + beta, calibrated_ess = BX._initial_beta( + phi_policy, gp, replicas, cfg, device, cfg.seed + round_i * 1009, + ) + shard = BS.RoundShard(round_i) + gather = BX.gather_macro_round( + policy, phi_policy, gp, beta, replicas, cfg, shard, device, + executor, proposal_generator, + ) + gather.pop("traces", None) + shard_manifest = recent.append_and_save(shard) + replay_start = time.perf_counter() + replay = repeat_complete_replay( + policy, optimizer, recent, cfg, device=device, round_i=round_i, + ) + gather["timers"]["replay"] = time.perf_counter() - replay_start + if BS.module_sha256(policy.enc_grid) != visual_encoder_sha: + raise RuntimeError("visual encoder SHA changed") + checkpoint_path = os.path.join(outdir, f"round_{round_i:02d}.pt") + BX._save_checkpoint(policy, checkpoint_path, dict( + round=round_i, experiment=cfg.arm_name, + source_checkpoint=checkpoint, source_sha256=checkpoint_sha, + encoder_sha256=visual_encoder_sha, recipe=asdict(cfg), + ell=ELL, cap=CAP, beta=float(beta), + )) + record = dict( + round=round_i, experiment=cfg.arm_name, + scenarios=list(scenarios), environment=environment, + beta=float(beta), calibrated_ess_over_K=float(calibrated_ess), + verifier=SM.verifier_manifest(), gp_buffer_ids=gp_ids, + gp=gp.diagnostics(), gather=gather, replay=replay, + shard=shard_manifest, checkpoint=os.path.abspath(checkpoint_path), + checkpoint_sha256=BS.sha256_file(checkpoint_path), + wall_seconds=time.perf_counter() - round_start, + ) + history.append(record) + with open(os.path.join(outdir, "metrics.jsonl"), "a") as stream: + stream.write(json.dumps(record) + "\n") + print(json.dumps({ + "round": round_i, "experiment": cfg.arm_name, + "beta": float(beta), "wall_seconds": record["wall_seconds"], + }), flush=True) + manifest = dict( + status="R2_ALPHA_REPLAY_COMPLETE", experiment=cfg.arm_name, + recipe=asdict(cfg), constants=dict( + ell=ELL, cap=CAP, gp_lambda=GP_LAMBDA, + expected_checkpoint_sha256=EXPECTED_CHECKPOINT_SHA256, + ), + source_checkpoint=checkpoint, source_checkpoint_sha256=checkpoint_sha, + environment=environment, frozen_parameters=frozen_parameters, + visual_encoder_sha=visual_encoder_sha, history=history, + ) + complete_path = os.path.join(outdir, "COMPLETE.json") + temporary_complete = complete_path + ".tmp" + with open(temporary_complete, "w") as stream: + json.dump(manifest, stream, indent=2) + os.replace(temporary_complete, complete_path) + return manifest + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--alpha", type=float, choices=ALPHAS, required=True) + parser.add_argument( + "--replay-epochs", type=int, choices=REPLAY_EPOCHS, required=True, + ) + parser.add_argument("--device", default="cuda") + parser.add_argument("--seed", type=int, default=20260723) + parser.add_argument("--verifier-workers", type=int, default=32) + args = parser.parse_args() + cfg = ExperimentConfig( + alpha=args.alpha, replay_epochs=args.replay_epochs, + seed=args.seed, verifier_workers=args.verifier_workers, + ) + run(args.checkpoint, args.outdir, cfg, device=args.device) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_r2_eval.py b/overnight_run_07_12_sfm/sfm_b1_r2_eval.py new file mode 100644 index 0000000..b95195f --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_r2_eval.py @@ -0,0 +1,833 @@ +"""Canonical raw temperature-one evaluation for SFM rounds 0, 1, and 2. + +This module is intentionally independent of expansion-time acquisition. It +uses one fixed M=50/scenario/gamma bank and one fixed latent-noise bank for +every checkpoint, samples one raw flow window per context at temperature one, +and executes its first action. No RBF tilt, verifier selection, fallback, +guidance, or temperature search is present. + +The archived double-shift M100 baseline is carried only as a labeled reference +in the result and figure footer. It is never inserted as an evaluation point +and is never used to alter a measured M50 value. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field +import hashlib +import json +import math +import multiprocessing as mp +import os +import re +from typing import Any + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch + +import _paths # noqa: F401 +import grid_feats as GF +import grid_policy_sfm as GPS +import sfm_b1_eval as BE +import sfm_hp_history as HH +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +VERSION = "sfm_b1_r2_raw_m50_v1" +M_PER_GAMMA = 50 +T = int(SP.T) +H = int(SP.H) +NFE = 8 +TEMPERATURE = 1.0 +DEFAULT_EP0 = 260_000 +DEFAULT_NOISE_SEED = 2_026_072_3 + +# Historical reference only. These values were measured with M=100/gamma on +# scenarios 250000:250099 and are not a target for the disjoint M50 run. +ARCHIVED_M100_REFERENCE = { + "role": "separate_historical_reference_not_a_curve_point", + "scene_profile": "double_density_velocity_ood", + "ep0": 250_000, + "M_per_gamma": 100, + "checkpoint_sha256": ( + "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" + ), + "source_commit": "ca7f0d718f8d70cf74833b1c75157caf7f1b13f2", + "SR": 0.7000000000, + "CR": 0.3000000000, + "successful_clearance": 0.1310315136398588, + "successful_time_to_goal": 8.692857142857143, + "note": ( + "Archived raw temp=1/NFE=8 M100 result. It is reported separately and " + "must never be substituted for an independently measured M50 value." + ), +} + + +def _sha256_file(path: str | os.PathLike[str]) -> str: + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _sha256_json(payload: Any) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _write_json(path: str | os.PathLike[str], payload: Any) -> None: + path = os.path.abspath(os.fspath(path)) + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = path + ".tmp" + with open(temporary, "w") as stream: + json.dump(payload, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def _checkpoint_specs(checkpoints: list[str], labels: list[str]) -> list[dict]: + if len(checkpoints) != len(labels) or not checkpoints: + raise ValueError("--checkpoints and --labels must have the same nonzero length") + if len(labels) != len(set(labels)): + raise ValueError("checkpoint labels must be unique") + specs = [] + for checkpoint, label in zip(checkpoints, labels): + match = re.fullmatch(r"r([0-9]+)", str(label)) + if match is None: + raise ValueError(f"checkpoint label {label!r} must have form r0, r1, ...") + path = os.path.abspath(checkpoint) + if not os.path.isfile(path): + raise FileNotFoundError(path) + specs.append(dict(label=str(label), round=int(match.group(1)), checkpoint=path)) + rounds = [spec["round"] for spec in specs] + if rounds != sorted(rounds) or len(rounds) != len(set(rounds)): + raise ValueError("checkpoint labels must be unique and increasing") + return specs + + +def _assert_disjoint_from_archive(ep0: int) -> None: + current = set(range(int(ep0), int(ep0) + M_PER_GAMMA)) + archived = set(range( + int(ARCHIVED_M100_REFERENCE["ep0"]), + int(ARCHIVED_M100_REFERENCE["ep0"]) + + int(ARCHIVED_M100_REFERENCE["M_per_gamma"]), + )) + if current & archived: + raise ValueError( + "the M50 qualification bank must remain disjoint from the archived M100 bank" + ) + + +def _noise_bank(*, ep0: int, d: int, seed: int) -> tuple[np.ndarray, dict]: + contract = { + "version": VERSION, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "gammas": list(map(float, SP.GAMMAS)), + "T": T, + "d": int(d), + "seed": int(seed), + "temperature": TEMPERATURE, + "NFE": NFE, + } + generator = np.random.default_rng(int(seed)) + values = generator.standard_normal( + (len(SP.GAMMAS), M_PER_GAMMA, T, int(d)), dtype=np.float32, + ) + metadata = { + **contract, + "dtype": "float32", + "shape": list(values.shape), + "sha256": hashlib.sha256(values.tobytes(order="C")).hexdigest(), + "CRN": ( + "same (gamma,scenario,step) latent across checkpoints; paired scenario " + "IDs across gamma, independent latent slices across gamma" + ), + } + return values, metadata + + +@dataclass +class _Episode: + gamma_index: int + rollout_index: int + episode: int + gamma: float + humans: list + state: np.ndarray = field(default_factory=lambda: np.zeros(4, np.float32)) + history: HH.HpHistory = field(default_factory=HH.HpHistory) + controls: list[np.ndarray] = field(default_factory=list) + states: list[np.ndarray] = field( + default_factory=lambda: [np.zeros(4, np.float32)] + ) + context_states: list[np.ndarray] = field(default_factory=list) + planned_controls: list[np.ndarray] = field(default_factory=list) + ped_xy: list[np.ndarray] = field(default_factory=list) + ped_vel: list[np.ndarray] = field(default_factory=list) + status: str | None = None + minimum_clearance: float = float("inf") + + +def _clearance(state: np.ndarray, ped_xy: np.ndarray) -> float: + if not len(ped_xy): + return float("inf") + return float( + np.linalg.norm(ped_xy - state[:2][None], axis=1).min() - SS.R_PED + ) + + +def _terminal_check(episode: _Episode, ped_xy: np.ndarray) -> bool: + clearance = _clearance(episode.state, ped_xy) + episode.minimum_clearance = min(episode.minimum_clearance, clearance) + if clearance < 0.0: + episode.status = "collision" + elif float(np.linalg.norm(episode.state[:2] - SS.GOAL)) < 0.5: + episode.status = "success" + return episode.status is not None + + +@torch.no_grad() +def run_batched_raw( + policy, + *, + scene_profile: str, + ep0: int, + noise: np.ndarray, + device: str, +) -> list[dict]: + """Evaluate all 7xM cells with one flow batch per closed-loop tick.""" + environment = SS.scene_profile(scene_profile) + expected = (len(SP.GAMMAS), M_PER_GAMMA, T, int(policy.d)) + if tuple(noise.shape) != expected or noise.dtype != np.float32: + raise ValueError(f"noise bank {noise.shape}/{noise.dtype} != {expected}/float32") + episodes = [ + _Episode( + gamma_index=gamma_index, + rollout_index=rollout_index, + episode=int(ep0) + rollout_index, + gamma=float(gamma), + humans=SS.make_humans( + int(ep0) + rollout_index, + 0, + environment["n_ped"], + tuple(environment["ped_speed_range"]), + ), + ) + for gamma_index, gamma in enumerate(SP.GAMMAS) + for rollout_index in range(M_PER_GAMMA) + ] + + for step in range(T): + active, hp10, lows, histories, latents = [], [], [], [], [] + for episode in episodes: + if episode.status is not None: + continue + ped_xy, ped_vel = SS.collect_humans(episode.humans) + if _terminal_check(episode, ped_xy): + continue + obstacles = np.concatenate([ + ped_xy, + np.full((len(ped_xy), 1), SS.R_PED, np.float32), + ], axis=1) + raw_grid = torch.as_tensor( + GF.axis_grid( + episode.state[:2], + obstacles, + 0.0, + R=SS.R_SENSE, + sensing=SS.R_SENSE, + ) + ) + active.append((episode, ped_xy.copy(), ped_vel.copy())) + hp10.append(episode.history.append(raw_grid)) + lows.append(torch.as_tensor( + GF.low5(episode.state, SS.GOAL, episode.gamma) + )) + histories.append(torch.as_tensor(GF.hist_pad( + np.asarray(episode.controls[-16:]) + if episode.controls else np.zeros((0, 2)), + 16, + ))) + latents.append(noise[ + episode.gamma_index, + episode.rollout_index, + step, + ]) + if not active: + break + + hp10_tensor = torch.stack(hp10).to(device) + low_tensor = torch.stack(lows).to(device) + history_tensor = torch.stack(histories).to(device) + context = policy.ctx_from(hp10_tensor, low_tensor, history_tensor) + windows = BE.integrate_latents( + policy, + torch.as_tensor(np.asarray(latents), device=device), + context, + nfe=NFE, + ).reshape(len(active), H, 2) + windows = windows.detach().cpu().numpy().astype(np.float32) + + for (episode, ped_xy, ped_vel), window in zip(active, windows): + if tuple(window.shape) != (H, 2): + raise RuntimeError(f"generated plan {window.shape} != {(H, 2)}") + episode.context_states.append(episode.state.copy()) + episode.planned_controls.append(window.copy()) + episode.ped_xy.append(ped_xy) + episode.ped_vel.append(ped_vel) + action = window[0].copy() + episode.controls.append(action) + episode.state = BE._step(episode.state, action) + episode.states.append(episode.state.copy()) + SS.advance_humans(episode.humans, episode.state) + + rows = [] + for episode in episodes: + if episode.status is None: + ped_xy, _ = SS.collect_humans(episode.humans) + if not _terminal_check(episode, ped_xy): + episode.status = "timeout" + success = episode.status == "success" + rows.append({ + "episode": episode.episode, + "gamma": episode.gamma, + "status": episode.status, + "success": success, + "collision": episode.status == "collision", + "timeout": episode.status == "timeout", + "steps": len(episode.controls), + "time_to_goal": ( + len(episode.controls) * SS.DT if success else None + ), + "min_clearance": float(episode.minimum_clearance), + "successful_clearance": ( + float(episode.minimum_clearance) if success else None + ), + "states": np.asarray(episode.states, np.float32), + "context_states": np.asarray(episode.context_states, np.float32), + "planned_controls": np.asarray(episode.planned_controls, np.float32), + "ped_xy": np.asarray(episode.ped_xy, np.float32), + "ped_vel": np.asarray(episode.ped_vel, np.float32), + }) + return rows + + +def _verify_episode(row: dict) -> dict: + n_steps = int(row["steps"]) + states = np.asarray(row["states"], np.float32) + context_states = np.asarray(row["context_states"], np.float32) + planned_controls = np.asarray(row["planned_controls"], np.float32) + ped_xy = np.asarray(row["ped_xy"], np.float32) + ped_vel = np.asarray(row["ped_vel"], np.float32) + expected_lengths = ( + len(states) == n_steps + 1 + and len(context_states) == n_steps + and len(planned_controls) == n_steps + and len(ped_xy) == n_steps + and len(ped_vel) == n_steps + ) + if not expected_lengths or ( + n_steps and tuple(planned_controls.shape[1:]) != (H, 2) + ): + return {"v_safe": False, "verifier_errors": 1, "certified_windows": 0} + + physical_safe = ( + not bool(row["collision"]) + and SM.taskspace_ok(states[:, :2]) + and n_steps > 0 + ) + if not physical_safe: + return {"v_safe": False, "verifier_errors": 0, "certified_windows": 0} + + certified_windows = 0 + for state, controls, current_xy, current_vel in zip( + context_states, planned_controls, ped_xy, ped_vel + ): + result = SM.verify_query( + state, controls, current_xy, current_vel, float(row["gamma"]) + ) + if not result.get("resolved", False): + return { + "v_safe": False, + "verifier_errors": 1, + "certified_windows": certified_windows, + } + if not result.get("full_h", False) or int(result.get("terminal_step", -1)) != H: + return { + "v_safe": False, + "verifier_errors": 1, + "certified_windows": certified_windows, + } + certified_windows += 1 + if not bool(result["y"]): + return { + "v_safe": False, + "verifier_errors": 0, + "certified_windows": certified_windows, + } + return { + "v_safe": True, + "verifier_errors": 0, + "certified_windows": certified_windows, + } + + +def _attach_validity(rows: list[dict], executor) -> list[dict]: + futures = [executor.submit(_verify_episode, row) for row in rows] + compact = [] + omitted = { + "states", "context_states", "planned_controls", "ped_xy", "ped_vel", + } + for row, future in zip(rows, futures): + value = {key: item for key, item in row.items() if key not in omitted} + value.update(future.result()) + compact.append(value) + return compact + + +def _cluster_bootstrap_interval( + rows: list[dict], + key: str, + *, + seed: int, + draws: int = 2_000, +) -> list[float | None]: + episode_ids = sorted({int(row["episode"]) for row in rows}) + sums, counts = [], [] + for episode in episode_ids: + values = [row.get(key) for row in rows if int(row["episode"]) == episode] + finite = [ + float(value) for value in values + if value is not None and math.isfinite(float(value)) + ] + sums.append(sum(finite)) + counts.append(len(finite)) + if not episode_ids or not sum(counts): + return [None, None] + generator = np.random.default_rng(int(seed)) + indices = generator.integers( + 0, len(episode_ids), size=(int(draws), len(episode_ids)) + ) + numerator = np.asarray(sums, float)[indices].sum(axis=1) + denominator = np.asarray(counts, float)[indices].sum(axis=1) + samples = numerator[denominator > 0] / denominator[denominator > 0] + if not len(samples): + return [None, None] + return list(map(float, np.quantile(samples, [.025, .975]))) + + +def _summarize_one(rows: list[dict], seed: int) -> dict: + n = len(rows) + if n < 1: + raise ValueError("cannot summarize an empty cell") + successes = sum(bool(row["success"]) for row in rows) + collisions = sum(bool(row["collision"]) for row in rows) + timeouts = sum(bool(row["timeout"]) for row in rows) + valid = sum(bool(row["v_safe"]) for row in rows) + if successes + collisions + timeouts != n: + raise RuntimeError("success, collision, and timeout must partition a cell") + return { + "n": n, + "SR": successes / n, + "SR_wilson95": BE.wilson(successes, n), + "CR": collisions / n, + "CR_wilson95": BE.wilson(collisions, n), + "timeout": timeouts / n, + "timeout_wilson95": BE.wilson(timeouts, n), + "V_safe": valid / n, + "V_safe_wilson95": BE.wilson(valid, n), + "successful_clearance": BE.bootstrap_mean( + [row["successful_clearance"] for row in rows], seed=seed + ), + "successful_time_to_goal": BE.bootstrap_mean( + [row["time_to_goal"] for row in rows], seed=seed + 1 + ), + "verifier_errors": sum(int(row["verifier_errors"]) for row in rows), + "certified_windows": sum(int(row["certified_windows"]) for row in rows), + } + + +def summarize(rows: list[dict], *, seed: int) -> dict: + per_gamma = { + str(gamma): _summarize_one( + [row for row in rows if float(row["gamma"]) == float(gamma)], + seed + index * 10, + ) + for index, gamma in enumerate(SP.GAMMAS) + } + pooled = _summarize_one(rows, seed + 100) + for metric, key in ( + ("SR", "success"), + ("CR", "collision"), + ("timeout", "timeout"), + ("V_safe", "v_safe"), + ): + pooled[f"{metric}_cluster_bootstrap95"] = _cluster_bootstrap_interval( + rows, key, seed=seed + 200 + len(metric) + ) + pooled["successful_clearance"]["cluster_bootstrap95"] = ( + _cluster_bootstrap_interval( + rows, "successful_clearance", seed=seed + 300 + ) + ) + pooled["successful_time_to_goal"]["cluster_bootstrap95"] = ( + _cluster_bootstrap_interval( + rows, "time_to_goal", seed=seed + 301 + ) + ) + pooled["ci_method"] = ( + "scenario-cluster bootstrap across seven paired gamma rows" + ) + return {"pooled": pooled, "per_gamma": per_gamma} + + +def _assert_zero_verifier_errors(summary: dict) -> None: + cells = [summary["pooled"], *summary["per_gamma"].values()] + if any(int(cell["verifier_errors"]) != 0 for cell in cells): + raise RuntimeError("evaluation contains verifier errors") + + +def _cell_key( + *, + checkpoint_sha256: str, + scene_profile: str, + ep0: int, + noise_meta: dict, +) -> str: + return _sha256_json({ + "version": VERSION, + "evaluator_sha256": _sha256_file(__file__), + "checkpoint_sha256": checkpoint_sha256, + "scene_profile": scene_profile, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "noise_bank": noise_meta, + "temperature": TEMPERATURE, + "NFE": NFE, + "T": T, + "H": H, + "verifier": SM.verifier_manifest(), + }) + + +def _evaluate_checkpoint( + checkpoint: str, + *, + scene_profile: str, + ep0: int, + noise: np.ndarray, + noise_meta: dict, + device: str, + cache_dir: str, + executor, +) -> dict: + checkpoint_sha = _sha256_file(checkpoint) + key = _cell_key( + checkpoint_sha256=checkpoint_sha, + scene_profile=scene_profile, + ep0=ep0, + noise_meta=noise_meta, + ) + cache_path = os.path.join( + cache_dir, f"cell_{checkpoint_sha[:12]}_{key[:12]}.json" + ) + if os.path.isfile(cache_path): + with open(cache_path) as stream: + payload = json.load(stream) + if ( + payload.get("status") != "SFM_B1_R2_RAW_CELL_COMPLETE" + or payload.get("cell_key") != key + ): + raise RuntimeError(f"stale evaluation cache: {cache_path}") + _assert_zero_verifier_errors(payload["summary"]) + return payload + + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + policy.eval() + if int(policy.d) != int(noise.shape[-1]): + raise ValueError("checkpoint latent dimension does not match the fixed noise bank") + rows = run_batched_raw( + policy, + scene_profile=scene_profile, + ep0=ep0, + noise=noise, + device=device, + ) + del policy + if str(device).startswith("cuda"): + torch.cuda.empty_cache() + compact = _attach_validity(rows, executor) + summary = summarize( + compact, + seed=int(ep0) + int(checkpoint_sha[:8], 16) % 100_000, + ) + _assert_zero_verifier_errors(summary) + payload = { + "status": "SFM_B1_R2_RAW_CELL_COMPLETE", + "cell_key": key, + "checkpoint": os.path.abspath(checkpoint), + "checkpoint_sha256": checkpoint_sha, + "scene_profile": scene_profile, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "summary": summary, + "rows": compact, + "metric_semantics": { + "policy": ( + "canonical unguided raw flow, temperature=1, NFE=8, one " + "generated H=10 window per context, execute first action" + ), + "V_safe": ( + "episode is physically collision/task-space safe and every " + "generated plan at every executed context passes the exact " + "full-H=10 moving-pedestrian verifier" + ), + "clearance": ( + "mean of each successful trajectory's minimum pedestrian " + "clearance; failures are excluded" + ), + "time": "successful trajectories only", + "outcome_partition": "SR + CR + timeout = 1", + }, + } + _write_json(cache_path, payload) + return payload + + +def _metric_value(cell: dict, metric: str) -> float: + if metric in ("CR", "V_safe"): + return float(cell[metric]) + key = ( + "successful_clearance" + if metric == "clearance" + else "successful_time_to_goal" + ) + value = cell[key]["mean"] + return float("nan") if value is None else float(value) + + +def _pooled_interval(cell: dict, metric: str) -> list[float]: + if metric in ("CR", "V_safe"): + value = cell[f"{metric}_cluster_bootstrap95"] + else: + key = ( + "successful_clearance" + if metric == "clearance" + else "successful_time_to_goal" + ) + value = cell[key]["cluster_bootstrap95"] + return [ + float("nan") if item is None else float(item) + for item in value + ] + + +def render(records: list[dict], output_dir: str) -> list[str]: + """Render the four requested metrics in the B1 paper-curve style.""" + colors = plt.get_cmap("plasma")( + np.linspace(0.08, 0.92, len(SP.GAMMAS)) + ) + specs = ( + ("CR", "Collision rate"), + ("V_safe", r"$V_{\mathrm{safe}}$"), + ("clearance", "Successful min. clearance [m]"), + ("time", "Successful time-to-goal [s]"), + ) + rounds = [int(record["round"]) for record in records] + plt.rcParams.update({ + "font.family": "serif", + "mathtext.fontset": "cm", + "font.serif": ["cmr10", "Computer Modern Roman", "DejaVu Serif"], + "axes.unicode_minus": False, + "axes.formatter.use_mathtext": True, + "axes.titlesize": 18, + "axes.labelsize": 16, + "xtick.labelsize": 13, + "ytick.labelsize": 13, + }) + figure, axes = plt.subplots(2, 2, figsize=(14.5, 9)) + for axis, (metric, title) in zip(axes.flat, specs): + for gamma, color in zip(SP.GAMMAS, colors): + cells = [ + record["cell"]["summary"]["per_gamma"][str(gamma)] + for record in records + ] + axis.plot( + rounds, + [_metric_value(cell, metric) for cell in cells], + color=color, + lw=1.5, + marker="o", + ms=5, + alpha=0.72, + label=rf"$\gamma={gamma:g}$", + ) + pooled = [ + record["cell"]["summary"]["pooled"] for record in records + ] + values = [_metric_value(cell, metric) for cell in pooled] + intervals = [_pooled_interval(cell, metric) for cell in pooled] + axis.plot( + rounds, + values, + color="black", + lw=3.0, + marker="o", + ms=6, + label=r"pooled ($7\gamma$)", + zorder=4, + ) + axis.fill_between( + rounds, + [value[0] for value in intervals], + [value[1] for value in intervals], + color="black", + alpha=0.11, + lw=0, + zorder=1, + ) + axis.set_title(title, pad=8) + axis.set_xlabel("expansion round") + axis.set_xticks(rounds) + axis.grid(alpha=0.25) + axis.set_xlim(min(rounds) - 0.15, max(rounds) + 0.15) + if metric in ("CR", "V_safe"): + axis.set_ylim(-0.03, 1.03) + + handles, labels = axes[0, 0].get_legend_handles_labels() + figure.legend( + handles, + labels, + loc="upper center", + ncol=4, + frameon=False, + bbox_to_anchor=(0.5, 0.995), + ) + reference = ARCHIVED_M100_REFERENCE + figure.text( + 0.5, + 0.012, + ( + "Separate archived M100 reference (not plotted): " + f"SR {reference['SR']:.3f}, CR {reference['CR']:.3f}, " + f"successful clearance {reference['successful_clearance']:.3f} m, " + f"successful time {reference['successful_time_to_goal']:.3f} s." + ), + ha="center", + va="bottom", + fontsize=11, + color="0.35", + ) + figure.tight_layout(rect=(0.02, 0.055, 0.98, 0.91)) + os.makedirs(output_dir, exist_ok=True) + outputs = [] + for suffix in ("png", "pdf"): + path = os.path.join(output_dir, f"raw_m50_r0_r2_curves.{suffix}") + figure.savefig(path, dpi=300, bbox_inches="tight") + outputs.append(path) + plt.close(figure) + return outputs + + +def run(args) -> dict: + specs = _checkpoint_specs(args.checkpoints, args.labels) + _assert_disjoint_from_archive(args.ep0) + output_dir = os.path.abspath(args.output_dir) + cache_dir = os.path.abspath(args.cache_dir or os.path.join(output_dir, "cache")) + os.makedirs(output_dir, exist_ok=True) + os.makedirs(cache_dir, exist_ok=True) + + probe, _ = GPS.load_sfm_policy(specs[0]["checkpoint"], device="cpu") + noise, noise_meta = _noise_bank( + ep0=args.ep0, d=int(probe.d), seed=args.noise_seed + ) + del probe + records = [] + context = mp.get_context("spawn") + with ProcessPoolExecutor( + max_workers=int(args.workers), mp_context=context + ) as executor: + for spec in specs: + cell = _evaluate_checkpoint( + spec["checkpoint"], + scene_profile=args.scene_profile, + ep0=args.ep0, + noise=noise, + noise_meta=noise_meta, + device=args.device, + cache_dir=cache_dir, + executor=executor, + ) + records.append({ + "label": spec["label"], + "round": spec["round"], + "cell": cell, + }) + + outputs = render(records, output_dir) + result = { + "status": "SFM_B1_R2_RAW_M50_COMPLETE", + "version": VERSION, + "scene_profile": args.scene_profile, + "environment": SS.scene_profile(args.scene_profile), + "bank": { + "ep0": int(args.ep0), + "M_per_gamma": M_PER_GAMMA, + "scenario_ids": list(range( + int(args.ep0), int(args.ep0) + M_PER_GAMMA + )), + "same_scenario_ids_for_every_gamma": True, + "disjoint_from_archived_M100": True, + }, + "noise_bank": noise_meta, + "records": records, + "archived_M100_reference": ARCHIVED_M100_REFERENCE, + "reference_policy": ( + "The archived M100 result is provenance only. No measured M50 " + "value is replaced, shifted, selected, or calibrated against it." + ), + "outputs": outputs, + } + result_path = os.path.join(output_dir, "raw_m50_r0_r2_metrics.json") + _write_json(result_path, result) + result["metrics_json"] = result_path + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoints", nargs="+", required=True) + parser.add_argument("--labels", nargs="+", required=True) + parser.add_argument( + "--scene-profile", + default="double_density_velocity_ood", + choices=SS.SCIENTIFIC_EVAL_PROFILES, + ) + parser.add_argument("--ep0", type=int, default=DEFAULT_EP0) + parser.add_argument("--noise-seed", type=int, default=DEFAULT_NOISE_SEED) + parser.add_argument("--device", default="cuda") + parser.add_argument("--workers", type=int, default=32) + parser.add_argument("--cache-dir") + parser.add_argument("--output-dir", required=True) + return parser + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + result = run(args) + print(result["metrics_json"]) + for path in result["outputs"]: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2522c2cdf7b67106166598c42c16a800a1e69b97 Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 16:38:59 -0700 Subject: [PATCH 04/31] Aggregate two-round SFM factorial results --- .../analysis/test_sfm_b1_r2_aggregate.py | 43 +++ .../sfm_b1_r2_aggregate.py | 341 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_aggregate.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_r2_aggregate.py diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_aggregate.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_aggregate.py new file mode 100644 index 0000000..cd0f3f6 --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_r2_aggregate.py @@ -0,0 +1,43 @@ +import pytest + +import sfm_b1_r2_aggregate as A + + +def test_arm_grid_names_are_unique(): + names = { + A.arm_name(alpha, epochs) + for alpha in A.ALPHAS + for epochs in A.REPLAY_EPOCHS + } + assert len(names) == 9 + assert "margin_alpha0p1_epochs100" in names + + +def test_selection_is_safety_first(): + safe = { + "CR": 0.2, "SR": 0.7, "clearance": 0.1, "time": 10.0, + "round": 2, "alpha": 0.1, "replay_epochs": 100, + } + fast = { + "CR": 0.3, "SR": 0.9, "clearance": 0.2, "time": 5.0, + "round": 1, "alpha": 0.0, "replay_epochs": 1, + } + assert min((safe, fast), key=A._post_expansion_key) is safe + + +def test_paired_cluster_delta_respects_episode_pairing(): + baseline, candidate = [], [] + for episode in (1, 2): + for gamma in (0.1, 1.0): + baseline.append({ + "episode": episode, "gamma": gamma, "collision": episode == 1, + }) + candidate.append({ + "episode": episode, "gamma": gamma, "collision": False, + }) + value = A.paired_cluster_delta( + baseline, candidate, "collision", seed=1, draws=1000, + ) + assert value["estimate"] == pytest.approx(-0.5) + assert value["paired_scenarios"] == 2 + assert value["paired_gamma_cells"] == 4 diff --git a/overnight_run_07_12_sfm/sfm_b1_r2_aggregate.py b/overnight_run_07_12_sfm/sfm_b1_r2_aggregate.py new file mode 100644 index 0000000..8a07f20 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_r2_aggregate.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Aggregate the completed two-round SFM alpha/replay factorial. + +The per-arm evaluator remains the source of scientific metrics. This module +only validates their shared contracts, renders pooled comparisons, and +computes paired scenario-bootstrap changes against the common r0 checkpoint. +""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import math +import os +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + + +ALPHAS = (0.0, 0.01, 0.1) +REPLAY_EPOCHS = (1, 10, 100) +ROUNDS = (0, 1, 2) + + +def arm_name(alpha: float, epochs: int) -> str: + return f"margin_alpha{str(float(alpha)).replace('.', 'p')}_epochs{int(epochs):03d}" + + +def _sha256_file(path: str | os.PathLike[str]) -> str: + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path: Path, value: dict) -> None: + temporary = path.with_name(path.name + ".tmp") + with temporary.open("w") as stream: + json.dump(value, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def _metric(cell: dict, key: str) -> float | None: + if key in ("SR", "CR", "timeout", "V_safe"): + return float(cell[key]) + entry = ( + cell["successful_clearance"] + if key == "clearance" + else cell["successful_time_to_goal"] + ) + return None if entry["mean"] is None else float(entry["mean"]) + + +def _post_expansion_key(row: dict) -> tuple: + """Safety-first deterministic selection among measured r1/r2 cells.""" + clearance = -math.inf if row["clearance"] is None else float(row["clearance"]) + time = math.inf if row["time"] is None else float(row["time"]) + return ( + float(row["CR"]), + -float(row["SR"]), + -clearance, + time, + int(row["round"]), + float(row["alpha"]), + int(row["replay_epochs"]), + ) + + +def paired_cluster_delta( + baseline_rows: list[dict], + candidate_rows: list[dict], + key: str, + *, + seed: int = 20260723, + draws: int = 20_000, +) -> dict: + baseline = { + (int(row["episode"]), float(row["gamma"])): float(bool(row[key])) + for row in baseline_rows + } + candidate = { + (int(row["episode"]), float(row["gamma"])): float(bool(row[key])) + for row in candidate_rows + } + if set(baseline) != set(candidate): + raise ValueError("paired evaluator rows do not share the same scenario/gamma keys") + episode_ids = sorted({episode for episode, _ in baseline}) + per_episode = np.asarray([ + np.mean([ + candidate[(episode, gamma)] - baseline[(episode, gamma)] + for current_episode, gamma in baseline if current_episode == episode + ]) + for episode in episode_ids + ], dtype=float) + generator = np.random.default_rng(int(seed)) + indices = generator.integers( + 0, len(per_episode), size=(int(draws), len(per_episode)) + ) + samples = per_episode[indices].mean(axis=1) + return { + "estimate": float(per_episode.mean()), + "scenario_cluster_bootstrap95": list( + map(float, np.quantile(samples, (0.025, 0.975))) + ), + "paired_scenarios": len(episode_ids), + "paired_gamma_cells": len(baseline), + } + + +def _load(run_root: Path) -> tuple[list[dict], dict, str]: + rows = [] + reference_payload = None + noise_sha = None + baseline_cell_key = None + for alpha in ALPHAS: + for epochs in REPLAY_EPOCHS: + arm = arm_name(alpha, epochs) + training_path = run_root / "arms" / arm / "COMPLETE.json" + evaluation_path = ( + run_root / "evaluation" / arm / "raw_m50_r0_r2_metrics.json" + ) + with training_path.open() as stream: + training = json.load(stream) + with evaluation_path.open() as stream: + evaluation = json.load(stream) + if training.get("status") != "R2_ALPHA_REPLAY_COMPLETE": + raise RuntimeError(f"incomplete training arm: {training_path}") + if evaluation.get("status") != "SFM_B1_R2_RAW_M50_COMPLETE": + raise RuntimeError(f"incomplete evaluation arm: {evaluation_path}") + current_noise = evaluation["noise_bank"]["sha256"] + if noise_sha is None: + noise_sha = current_noise + reference_payload = evaluation["archived_M100_reference"] + elif current_noise != noise_sha: + raise RuntimeError("arm evaluations do not share one CRN bank") + records = evaluation["records"] + if [int(record["round"]) for record in records] != list(ROUNDS): + raise RuntimeError(f"{arm} does not contain r0/r1/r2") + if baseline_cell_key is None: + baseline_cell_key = records[0]["cell"]["cell_key"] + elif records[0]["cell"]["cell_key"] != baseline_cell_key: + raise RuntimeError("arm evaluations do not reuse the same r0 cell") + history = {int(item["round"]): item for item in training["history"]} + for record in records: + round_i = int(record["round"]) + cell = record["cell"]["summary"]["pooled"] + item = { + "arm": arm, + "alpha": float(alpha), + "replay_epochs": int(epochs), + "round": round_i, + "SR": _metric(cell, "SR"), + "CR": _metric(cell, "CR"), + "timeout": _metric(cell, "timeout"), + "V_safe": _metric(cell, "V_safe"), + "clearance": _metric(cell, "clearance"), + "time": _metric(cell, "time"), + "cell_key": record["cell"]["cell_key"], + "checkpoint_sha256": record["cell"]["checkpoint_sha256"], + "evaluation_rows": record["cell"]["rows"], + "training": None if round_i == 0 else history[round_i], + } + rows.append(item) + assert reference_payload is not None and noise_sha is not None + return rows, reference_payload, noise_sha + + +def _render(rows: list[dict], outdir: Path, best: dict) -> list[str]: + specs = ( + ("CR", "Collision rate"), + ("V_safe", r"$V_{\mathrm{safe}}$"), + ("clearance", "Successful min. clearance [m]"), + ("time", "Successful time-to-goal [s]"), + ) + colors = {1: "#0072B2", 10: "#E69F00", 100: "#CC79A7"} + linestyles = {0.0: "-", 0.01: "--", 0.1: ":"} + plt.rcParams.update({ + "font.family": "serif", + "mathtext.fontset": "cm", + "font.serif": ["cmr10", "Computer Modern Roman", "DejaVu Serif"], + "axes.unicode_minus": False, + "axes.formatter.use_mathtext": True, + }) + figure, axes = plt.subplots(2, 2, figsize=(14.5, 9)) + for axis, (metric, title) in zip(axes.flat, specs): + for alpha in ALPHAS: + for epochs in REPLAY_EPOCHS: + values = sorted( + [ + row for row in rows + if row["alpha"] == alpha + and row["replay_epochs"] == epochs + ], + key=lambda row: row["round"], + ) + axis.plot( + [row["round"] for row in values], + [ + np.nan if row[metric] is None else row[metric] + for row in values + ], + color=colors[epochs], + linestyle=linestyles[alpha], + marker="o", + lw=2, + alpha=0.85, + ) + axis.scatter( + [best["round"]], + [best[metric]], + marker="*", + s=210, + c="#009E73", + edgecolors="black", + zorder=8, + ) + axis.set( + title=title, + xlabel="expansion round", + xticks=ROUNDS, + ) + axis.grid(alpha=0.25) + if metric in ("CR", "V_safe"): + axis.set_ylim(-0.03, 1.03) + handles = [ + plt.Line2D([0], [0], color=colors[value], lw=2.5, label=f"{value} epochs") + for value in REPLAY_EPOCHS + ] + handles.extend([ + plt.Line2D( + [0], [0], color="black", linestyle=linestyles[value], lw=2, + label=rf"$\alpha={value:g}$", + ) + for value in ALPHAS + ]) + handles.append(plt.Line2D( + [0], [0], marker="*", color="none", markerfacecolor="#009E73", + markeredgecolor="black", markersize=14, label="best post-expansion cell", + )) + figure.legend( + handles=handles, loc="upper center", ncol=7, frameon=False, + bbox_to_anchor=(0.5, 0.995), + ) + figure.tight_layout(rect=(0.02, 0.02, 0.98, 0.92)) + outputs = [] + for suffix in ("png", "pdf"): + path = outdir / f"factorial_pooled_curves.{suffix}" + figure.savefig(path, dpi=300, bbox_inches="tight") + outputs.append(str(path)) + plt.close(figure) + return outputs + + +def run(run_root: str) -> dict: + root = Path(run_root).resolve() + rows, archived, noise_sha = _load(root) + baseline = next( + row for row in rows + if row["round"] == 0 + and row["alpha"] == 0.0 + and row["replay_epochs"] == 1 + ) + candidates = [row for row in rows if row["round"] > 0] + best = min(candidates, key=_post_expansion_key) + output_dir = root / "evaluation" / "aggregate" + output_dir.mkdir(parents=True, exist_ok=True) + csv_path = output_dir / "factorial_pooled_metrics.csv" + fields = ( + "arm", "alpha", "replay_epochs", "round", + "SR", "CR", "timeout", "V_safe", "clearance", "time", + ) + with csv_path.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + for row in sorted( + rows, key=lambda item: ( + item["alpha"], item["replay_epochs"], item["round"] + ) + ): + writer.writerow({key: row[key] for key in fields}) + outputs = _render(rows, output_dir, best) + summary = { + "status": "SFM_B1_R2_FACTORIAL_AGGREGATE_COMPLETE", + "run_root": str(root), + "training_source_commit": "58ec896f87a5859149a39f5f7796560cd53da518", + "noise_bank_sha256": noise_sha, + "common_r0_cell_key": baseline["cell_key"], + "baseline": {key: baseline[key] for key in fields}, + "best_post_expansion": {key: best[key] for key in fields}, + "paired_changes_best_minus_r0": { + key: paired_cluster_delta( + baseline["evaluation_rows"], + best["evaluation_rows"], + key, + seed=20260723 + index, + ) + for index, key in enumerate(("success", "collision", "timeout", "v_safe")) + }, + "archived_M100_reference": archived, + "selection_rule": ( + "post-expansion cells only; lower raw CR, then higher raw SR, then " + "higher successful-only clearance, then lower successful-only time" + ), + "artifacts": { + "csv": str(csv_path), + "csv_sha256": _sha256_file(csv_path), + "figures": [ + {"path": path, "sha256": _sha256_file(path)} + for path in outputs + ], + "best_per_gamma_png": str( + root / "evaluation" / best["arm"] / "raw_m50_r0_r2_curves.png" + ), + }, + } + summary_path = output_dir / "factorial_summary.json" + _write_json(summary_path, summary) + return summary + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--run-root", required=True) + result = run(parser.parse_args().run_root) + print(json.dumps({ + "status": result["status"], + "baseline": result["baseline"], + "best_post_expansion": result["best_post_expansion"], + "paired_changes": result["paired_changes_best_minus_r0"], + "artifacts": result["artifacts"], + }, indent=2)) + + +if __name__ == "__main__": + main() From 041d982be146690a985fda3c6b53c1da46cc9e2a Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 19:15:08 -0700 Subject: [PATCH 05/31] Add offline executed-window SFM expansion sweep --- .../analysis/test_run_sfm_b1_offline_9arm.py | 135 +++ .../analysis/test_sfm_b1_offline_eval.py | 169 ++++ .../test_sfm_b1_offline_store_replay.py | 302 ++++++ .../analysis/test_sfm_b1_verifier.py | 35 + .../run_sfm_b1_offline_9arm.py | 955 ++++++++++++++++++ .../sfm_b1_offline_eval.py | 815 +++++++++++++++ .../sfm_b1_offline_exec.py | 842 +++++++++++++++ .../sfm_b1_offline_replay.py | 295 ++++++ .../sfm_b1_offline_store.py | 223 ++++ overnight_run_07_12_sfm/sfm_metrics2.py | 54 +- 10 files changed, 3811 insertions(+), 14 deletions(-) create mode 100644 overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval.py create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py create mode 100644 overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_offline_eval.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_offline_exec.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_offline_replay.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_offline_store.py diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py new file mode 100644 index 0000000..ea447cf --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import pytest + + +HERE = Path(__file__).resolve().parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import run_sfm_b1_offline_9arm as L # noqa: E402 + + +def _gpu(index: int) -> L.BASE.GPU: + return L.BASE.GPU( + index=str(index), + uuid=f"GPU-{index}", + name="test", + memory_total_mib=100, + memory_used_mib=0, + utilization_percent=0, + pci_bus_id=f"0000:0{index}:00.0", + ) + + +def test_arm_grid_and_four_gpu_allocation(): + arms = list(L.arm_grid()) + assert len(arms) == 9 + assert len({arm.name for arm in arms}) == 9 + assert { + (arm.alpha, arm.exposure_epochs) for arm in arms + } == { + (alpha, epochs) + for alpha in L.ALPHAS + for epochs in L.EXPOSURE_EPOCHS + } + allocation = L.allocate_arms(arms, [_gpu(i) for i in range(4)]) + assert sorted(map(len, allocation.values())) == [2, 2, 2, 3] + assert set().union(*map(set, allocation.values())) == set(arms) + assert { + arm.exposure_epochs for arm in allocation["GPU-0"] + } == {1} + + +def test_output_root_must_be_new_and_under_research1(tmp_path, monkeypatch): + root = tmp_path / "research1" + root.mkdir() + monkeypatch.setattr(L, "RESEARCH_ROOT", root) + target = root / "new-study" + assert L._validated_output_root(target) == target.resolve() + target.mkdir() + with pytest.raises(FileExistsError): + L._validated_output_root(target) + with pytest.raises(ValueError): + L._validated_output_root(tmp_path / "elsewhere") + + +def test_commands_cover_declared_rounds_and_raw_common_bank(tmp_path): + checkpoint = tmp_path / "checkpoint.pt" + checkpoint.write_bytes(b"x") + args = type("Args", (), { + "checkpoint": str(checkpoint), + "verifier_workers": 8, + "seed": 20260724, + "eval_ep0": 260000, + "eval_noise_seed": 20260723, + })() + arm = L.Arm(0.01, 10) + train = L._trainer_command(args, arm, tmp_path / "train") + assert train[train.index("--rounds") + 1] == "10" + assert train[train.index("--exposure-epochs") + 1] == "10" + evaluate = L._evaluation_command( + args, + arm, + tmp_path / "train", + tmp_path / "eval", + cache_dir=tmp_path / "common_cache", + ) + checkpoints_start = evaluate.index("--checkpoints") + 1 + checkpoints_end = evaluate.index("--labels") + assert evaluate[checkpoints_start] == str(checkpoint.resolve()) + assert evaluate[checkpoints_start + 1].endswith("round_01.pt") + labels_start = evaluate.index("--labels") + 1 + labels_end = evaluate.index("--scene-profile") + assert evaluate[labels_start:labels_end] == [ + f"r{round_i}" for round_i in range(11) + ] + assert evaluate[evaluate.index("--ep0") + 1] == "260000" + assert evaluate[evaluate.index("--device") + 1] == "cuda:0" + assert evaluate[evaluate.index("--cache-dir") + 1] == str( + (tmp_path / "common_cache").resolve() + ) + common = L._common_r0_command(args, tmp_path / "common") + assert common[common.index("--labels") + 1] == "r0" + assert common[common.index("--checkpoints") + 1] == str( + checkpoint.resolve() + ) + + +def test_screening_key_is_safety_first(): + base = { + "CR": 0.1, + "Validity": 0.5, + "SR": 0.8, + "clearance": 0.1, + "time_to_goal": 9.0, + "round": 1, + "exposure_epochs": 1, + "alpha": 0.0, + } + lower_collision = {**base, "CR": 0.09, "Validity": 0.0} + higher_validity = {**base, "Validity": 0.6, "SR": 0.0} + assert L._screening_key(lower_collision) < L._screening_key(base) + assert L._screening_key(higher_validity) < L._screening_key(base) + + +def test_validate_sidecar_authenticates_digest(tmp_path): + artifact = tmp_path / "round_00.pt" + artifact.write_bytes(b"checkpoint") + sidecar = Path(str(artifact) + ".COMPLETE.json") + sidecar.write_text(json.dumps({ + "status": "COMPLETE", + "sha256": L.BASE.sha256_file(artifact), + })) + observed = L._validate_sidecar(artifact) + assert observed["sha256"] == L.BASE.sha256_file(artifact) + sidecar.write_text(json.dumps({ + "status": "COMPLETE", + "sha256": "0" * 64, + })) + with pytest.raises(RuntimeError): + L._validate_sidecar(artifact) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval.py new file mode 100644 index 0000000..532f96f --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +import sfm_b1_offline_eval as E +import sfm_protocol as SP + + +def _trajectory(n_steps=3, *, collision=False): + controls = np.arange(n_steps * 2, dtype=np.float32).reshape(n_steps, 2) + return { + "episode": 1, + "gamma": 0.5, + "status": "collision" if collision else "success", + "success": not collision, + "collision": collision, + "timeout": False, + "steps": n_steps, + "time_to_goal": 1.0 if not collision else None, + "successful_clearance": 0.2 if not collision else None, + "states": np.zeros((n_steps + 1, 4), np.float32), + "controls": controls, + "ped_xy": np.zeros((n_steps, 0, 2), np.float32), + "ped_vel": np.zeros((n_steps, 0, 2), np.float32), + } + + +def _compact_row(episode, gamma, validity): + evaluated = 10 + valid = int(round(float(validity) * evaluated)) + return { + "episode": int(episode), + "gamma": float(gamma), + "status": "success", + "success": True, + "collision": False, + "timeout": False, + "time_to_goal": 9.0, + "successful_clearance": 0.2, + "validity": float(validity), + "valid_windows": valid, + "evaluated_windows": evaluated, + "verifier_errors": 0, + } + + +def test_terminal_windows_use_actual_executed_controls_and_all_starts(monkeypatch): + row = _trajectory(12) + calls = [] + + def fake(state, controls, ped_xy, ped_vel, gamma): + calls.append(np.asarray(controls).copy()) + return { + "resolved": True, + "y": 1, + "window_horizon": len(controls), + } + + monkeypatch.setattr(E.SM, "verify_executed_window", fake) + result = E._verify_executed_episode(row) + + assert [len(controls) for controls in calls] == [ + 10, 10, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + ] + assert np.array_equal(calls[0], row["controls"][:10]) + assert np.array_equal(calls[-1], row["controls"][-1:]) + assert result == { + "validity": 1.0, + "valid_windows": 12, + "evaluated_windows": 12, + "verifier_errors": 0, + } + + +def test_validity_is_fractional_and_does_not_stop_at_first_negative(monkeypatch): + row = _trajectory(3, collision=True) + outcomes = iter((1, 0, 1)) + + def fake(state, controls, ped_xy, ped_vel, gamma): + return { + "resolved": True, + "y": next(outcomes), + "window_horizon": len(controls), + } + + monkeypatch.setattr(E.SM, "verify_executed_window", fake) + result = E._verify_executed_episode(row) + assert result["validity"] == pytest.approx(2 / 3) + assert result["valid_windows"] == 2 + assert result["evaluated_windows"] == 3 + assert result["verifier_errors"] == 0 + + +def test_verifier_error_is_not_silently_counted_as_negative(monkeypatch): + row = _trajectory(3) + calls = 0 + + def fake(state, controls, ped_xy, ped_vel, gamma): + nonlocal calls + calls += 1 + if calls == 2: + return {"resolved": False, "error": "solver failed"} + return {"resolved": True, "y": 1, "window_horizon": len(controls)} + + monkeypatch.setattr(E.SM, "verify_executed_window", fake) + result = E._verify_executed_episode(row) + assert result["verifier_errors"] == 1 + assert result["evaluated_windows"] == 1 + + +def test_zero_transition_trajectory_has_defined_zero_validity(): + result = E._verify_executed_episode(_trajectory(0)) + assert result == { + "validity": 0.0, + "valid_windows": 0, + "evaluated_windows": 0, + "verifier_errors": 0, + } + + +def test_summary_uses_mean_of_per_trajectory_fractions(): + rows = [ + _compact_row(1, .5, 1.0), + _compact_row(2, .5, .5), + ] + summary = E._summarize_one(rows, seed=7) + assert summary["Validity"]["mean"] == pytest.approx(.75) + assert summary["Validity"]["valid_windows"] == 15 + assert summary["Validity"]["evaluated_windows"] == 20 + assert summary["Validity"]["window_weighted_fraction"] == pytest.approx(.75) + assert "V_safe" not in summary + + +def test_render_uses_ball_style_validity_name_and_writes_manifest(tmp_path): + records = [] + for round_i in (0, 1, 2): + rows = [ + _compact_row( + episode, + gamma, + validity=min(1.0, .4 + .1 * round_i), + ) + for gamma in SP.GAMMAS + for episode in (1, 2) + ] + summary = E.summarize(rows, seed=round_i + 10) + records.append({ + "label": f"r{round_i}", + "round": round_i, + "cell": {"summary": summary}, + }) + + outputs = E.render(records, str(tmp_path)) + assert {Path(path).suffix for path in outputs} == {".png", ".pdf", ".json"} + assert all(Path(path).stat().st_size > 0 for path in outputs) + manifest_path = next(Path(path) for path in outputs if path.endswith(".json")) + manifest = json.loads(manifest_path.read_text()) + assert "Validity" in manifest["claim"] + assert "V_safe" not in manifest["claim"] + assert [title for _, title, _ in E.PLOT_SPECS] == [ + "Collision rate", + "Validity", + "Min. clearance [m]", + "Time-to-goal [s]", + ] diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py new file mode 100644 index 0000000..30dd5ec --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py @@ -0,0 +1,302 @@ +import json +import math + +import numpy as np +import pytest +import torch + +import sfm_b1_offline_exec as OE +import sfm_b1_offline_replay as OR +import sfm_b1_offline_store as OS + + +def _result(y, *, resolved=True, full_h=True): + if not resolved: + return dict(resolved=False, error="solver") + return dict( + resolved=True, + y=int(y), + taskspace=bool(y), + collision_free=bool(y), + certificate=bool(y), + full_h=bool(full_h), + terminal_step=10 if full_h else 4, + diagnostics={"margin": 0.25}, + ) + + +def _context(shard, *, scenario, gamma, step): + return shard.add_context( + scenario_id=scenario, + gamma=gamma, + step=step, + state=np.zeros(4, np.float32), + hp10=np.zeros((10, 16, 12), np.float32), + low5=np.zeros(5, np.float32), + hist=np.zeros((16, 2), np.float32), + ped_xy=np.zeros((1, 2), np.float32), + ped_vel=np.zeros((1, 2), np.float32), + ) + + +def _add_window(shard, *, scenario, gamma, step, y): + context_id = _context( + shard, scenario=scenario, gamma=gamma, step=step, + ) + controls = np.full((10, 2), scenario + step / 100.0, np.float32) + shard.add_executed_window( + context_id, + controls, + _result(y), + execution_source="selected_B" if y else "raw_continuation", + nvp_context=not bool(y), + candidate_id=0 if y else None, + acquisition_step=0 if y else None, + sigma=0.4 if y else None, + hp_margin=0.2, + mode="U" if scenario % 2 else "R", + ) + return context_id + + +def _mixed_shard(positive=7, negative=3): + shard = OS.ExecutedRoundShard(1) + gammas = (0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0) + for index in range(positive + negative): + _add_window( + shard, + scenario=100 + index, + gamma=gammas[index % len(gammas)], + step=index, + y=index < positive, + ) + return shard + + +class _TinyPolicy(torch.nn.Module): + """Minimal policy surface needed by the offline replay implementation.""" + + def __init__(self): + super().__init__() + self.enc_grid = torch.nn.Linear(1, 1, bias=False) + self.head = torch.nn.Linear(20, 20, bias=False) + self.d = 20 + self.u_max = 2.0 + + def ctx_from(self, grid, low, hist): + del grid, hist + return low[:, :1] + + def forward(self, value, tau, context): + del tau, context + return self.head(value) + + def cfm_loss(self, controls, context, weights=None): + del context + value = controls.reshape(len(controls), self.d) / self.u_max + per = (self.head(value) - value).square().mean(dim=1) + if weights is None: + return per.mean() + return (per * weights).sum() / weights.sum() + + def module_groups(self): + return {"E_g": self.enc_grid, "head": self.head} + + +def _trainable(policy): + for parameter in policy.parameters(): + parameter.requires_grad_(True) + for parameter in policy.enc_grid.parameters(): + parameter.requires_grad_(False) + return [ + parameter for parameter in policy.parameters() + if parameter.requires_grad + ] + + +def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path): + shard = OS.ExecutedRoundShard(3) + positive_context = _add_window( + shard, scenario=11, gamma=0.1, step=2, y=1, + ) + _add_window(shard, scenario=12, gamma=1.0, step=3, y=0) + + with pytest.raises(ValueError, match="at most one executed window"): + shard.add_executed_window( + positive_context, + np.zeros((10, 2), np.float32), + _result(1), + execution_source="selected_B", + nvp_context=False, + ) + with pytest.raises(ValueError, match="exact full-H=10"): + context_id = _context( + shard, scenario=13, gamma=0.5, step=4, + ) + shard.add_executed_window( + context_id, + np.zeros((10, 2), np.float32), + _result(1, full_h=False), + execution_source="selected_B", + nvp_context=False, + ) + with pytest.raises(ValueError, match=r"finite controls \[10,2\]"): + context_id = _context( + shard, scenario=14, gamma=0.5, step=5, + ) + shard.add_executed_window( + context_id, + np.zeros((9, 2), np.float32), + _result(1), + execution_source="selected_B", + nvp_context=False, + ) + + assert len(shard.D) == 2 + assert [row["y"] for row in shard.Dplus] == [1] + assert [row["y"] for row in shard.Dminus] == [0] + assert {row["window_id"] for row in shard.D} == {0, 1} + assert {row["context_id"] for row in shard.D} == {0, 1} + assert shard.validate() == { + "round": 3, + "contexts": 4, + "D": 2, + "Dplus": 1, + "Dminus": 1, + "errors": 0, + "unresolved_contexts": 2, + } + + path = tmp_path / "round_003.pt" + manifest = shard.save(path) + assert manifest["D"] == manifest["Dplus"] + manifest["Dminus"] == 2 + with open(str(path) + ".COMPLETE.json") as stream: + marker = json.load(stream) + assert marker["status"] == "OFFLINE_EXECUTED_ROUND_SHARD_COMPLETE" + assert marker["sha256"] == OS.sha256_file(path) + + restored = OS.ExecutedRoundShard.load(path) + assert restored.validate() == shard.validate() + assert [row["execution_source"] for row in restored.D] == [ + "selected_B", "raw_continuation", + ] + np.testing.assert_array_equal( + restored.Dminus[0]["controls"], shard.Dminus[0]["controls"], + ) + + +def test_stratified_batches_are_deterministic_and_exact_once(): + shard = _mixed_shard() + left, left_positive, left_negative = OR.stratified_batches( + shard, batch=4, seed=73, + ) + right, _, _ = OR.stratified_batches(shard, batch=4, seed=73) + left_ids = [ + (record[0].round_i, record[1]["window_id"]) + for batch in left for record in batch + ] + right_ids = [ + (record[0].round_i, record[1]["window_id"]) + for batch in right for record in batch + ] + assert left_ids == right_ids + assert len(left_ids) == len(set(left_ids)) == len(shard.D) + assert len(left_positive) == len(shard.Dplus) == 7 + assert len(left_negative) == len(shard.Dminus) == 3 + assert len(left) == math.ceil(len(shard.D) / 4) + assert all(any(record[1]["y"] == 1 for record in batch) for batch in left) + + +@pytest.mark.parametrize("exposure_epochs", (1, 10, 100)) +def test_replay_exact_exposure_counts_and_adam_step_formula(exposure_epochs): + torch.manual_seed(14) + shard = _mixed_shard() + policy = _TinyPolicy() + optimizer = torch.optim.Adam(_trainable(policy), lr=1.0e-4) + report = OR.replay( + policy, + optimizer, + shard, + alpha=0.01, + exposure_epochs=exposure_epochs, + batch=4, + device="cpu", + seed=101, + ) + steps_per_epoch = math.ceil(len(shard.D) / 4) + assert report["batches_per_epoch"] == steps_per_epoch + assert report["optimizer_steps"] == steps_per_epoch * exposure_epochs + assert report["positive_total_visits"] == len(shard.Dplus) * exposure_epochs + assert report["negative_total_visits"] == len(shard.Dminus) * exposure_epochs + assert all(row["positive_visits"] == len(shard.Dplus) for row in report["epochs"]) + assert all(row["negative_visits"] == len(shard.Dminus) for row in report["epochs"]) + assert report["exact_once_per_exposure_epoch"] + assert report["negative_used_for_training"] + assert report["visual_encoder_sha_before"] == report["visual_encoder_sha_after"] + + +def test_alpha_zero_retains_and_counts_Dminus_but_never_uses_negative_gradient( + monkeypatch, +): + torch.manual_seed(15) + shard = _mixed_shard() + policy = _TinyPolicy() + optimizer = torch.optim.Adam(_trainable(policy), lr=1.0e-4) + original = OR._weighted_loss + negative_loss_calls = [] + + def audit_weighted_loss(policy, records, mass, population, device): + if records and all(int(record[1]["y"]) == 0 for record in records): + negative_loss_calls.append(len(records)) + return original(policy, records, mass, population, device) + + monkeypatch.setattr(OR, "_weighted_loss", audit_weighted_loss) + report = OR.replay( + policy, + optimizer, + shard, + alpha=0.0, + exposure_epochs=1, + batch=4, + device="cpu", + seed=102, + ) + assert len(shard.Dminus) == report["negative_eligible"] == 3 + assert report["negative_total_visits"] == 3 + assert not report["negative_used_for_training"] + assert negative_loss_calls == [] + assert report["optimizer_steps"] == math.ceil(len(shard.D) / 4) + assert all(row["negative_loss"] is None for row in report["epochs"]) + + +def test_gp_cap_512_has_equal_gamma_quota_and_rotating_extra(): + shard = OS.ExecutedRoundShard(1) + for gamma_index, gamma in enumerate(OE.SP.GAMMAS): + for sample_index in range(74): + _add_window( + shard, + scenario=1_000 + gamma_index, + gamma=gamma, + step=sample_index, + y=1, + ) + + selected_round_2, report_round_2 = OE._gamma_balanced_records( + shard, cap=512, round_i=2, seed=20, + ) + selected_round_3, report_round_3 = OE._gamma_balanced_records( + shard, cap=512, round_i=3, seed=20, + ) + + assert len(selected_round_2) == len(selected_round_3) == 512 + assert report_round_2["quota"] == report_round_3["quota"] == 73 + assert report_round_2["unique"] and report_round_3["unique"] + assert report_round_2["rotating_extra_gamma"] == 0.1 + assert report_round_3["rotating_extra_gamma"] == 0.2 + assert report_round_2["per_gamma"]["0.1"] == 74 + assert report_round_3["per_gamma"]["0.2"] == 74 + for gamma in OE.SP.GAMMAS: + expected_round_2 = 74 if gamma == 0.1 else 73 + expected_round_3 = 74 if gamma == 0.2 else 73 + assert report_round_2["per_gamma"][str(gamma)] == expected_round_2 + assert report_round_3["per_gamma"][str(gamma)] == expected_round_3 diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py index a7ec598..6e2edb8 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_verifier.py @@ -40,3 +40,38 @@ def test_worker_contract_has_no_legacy_theta_grid_argument(): assert (context, candidate) == (3, 7) assert result["diagnostics"]["solver"] == "exact_2d_angular_interval_socp" assert result["diagnostics"]["K_artificial"] == 16 + + +def test_executed_window_api_accepts_terminal_truncation_only(): + state = np.zeros(4, np.float32) + no_pedestrians = np.zeros((0, 2), np.float32) + for horizon in (1, 10): + result = M.verify_executed_window( + state, np.zeros((horizon, 2), np.float32), + no_pedestrians, no_pedestrians, .5, + ) + assert result["resolved"] and result["y"] == 1 + assert result["window_horizon"] == horizon + assert "full_h" not in result + assert "train_eligible" not in result + for horizon in (0, 11): + result = M.verify_executed_window( + state, np.zeros((horizon, 2), np.float32), + no_pedestrians, no_pedestrians, .5, + ) + assert not result["resolved"] + + +def test_full_h_query_contract_remains_exactly_ten(): + no_pedestrians = np.zeros((0, 2), np.float32) + short = M.verify_query( + np.zeros(4), np.zeros((9, 2)), + no_pedestrians, no_pedestrians, .5, + ) + full = M.verify_query( + np.zeros(4), np.zeros((10, 2)), + no_pedestrians, no_pedestrians, .5, + ) + assert not short["resolved"] + assert full["resolved"] and full["full_h"] + assert full["terminal_step"] == 10 and full["train_eligible"] diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py new file mode 100644 index 0000000..a264962 --- /dev/null +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -0,0 +1,955 @@ +#!/usr/bin/env python3 +"""Frozen end-to-end launcher for the offline executed-window 9-arm study. + +The two phases are deliberately separate: + +1. train alpha {0,.01,.1} x exposure epochs {1,10,100} for ten rounds; +2. evaluate every r0--r10 checkpoint with the same raw temperature-one + M=50/gamma bank and terminal-truncated executed-window Validity. + +All nine jobs in a phase start concurrently on four exclusive GPUs with a +deterministic 3/2/2/2 allocation. Any child failure stops its peers. The +output root must not exist, so a partial study can never be mistaken for a +resumed or complete scientific run. +""" +from __future__ import annotations + +import argparse +import csv +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import json +import math +import os +from pathlib import Path +import sys +import time + +import run_sfm_b1_r2_9arm as BASE + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +TRAINER = HERE / "sfm_b1_offline_exec.py" +EVALUATOR = HERE / "sfm_b1_offline_eval.py" +ALPHAS = (0.0, 0.01, 0.1) +EXPOSURE_EPOCHS = (1, 10, 100) +ROUNDS = 10 +ARM_STATUS = "SFM_B1_OFFLINE_EXEC_COMPLETE" +EVAL_STATUS = "SFM_B1_OFFLINE_RAW_M50_COMPLETE" +CHECKPOINT_SHA256 = ( + "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" +) +SCENE_PROFILE = "double_density_velocity_ood" +ELL = 0.24210826720721101 +CAP = 512 +GP_LAMBDA = 1.0e-2 +K = 16 +B = 4 +T = 180 +H = 10 +BATCH = 128 +LR = 1.0e-4 +ESS_TARGET = 0.5 +RESEARCH_ROOT = Path("/data3/research1") + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _write_json(path: str | os.PathLike[str], payload) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + with temporary.open("w") as stream: + json.dump(payload, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def _sha256_json(payload) -> str: + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), allow_nan=False, + ).encode() + import hashlib + + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class Arm: + alpha: float + exposure_epochs: int + + @property + def name(self) -> str: + alpha = str(float(self.alpha)).replace(".", "p") + return ( + f"offline_exec_alpha{alpha}_" + f"exposures{int(self.exposure_epochs):03d}" + ) + + +@dataclass(frozen=True) +class PhaseName: + name: str + + +def arm_grid() -> tuple[Arm, ...]: + return tuple( + Arm(alpha, epochs) + for alpha in ALPHAS + for epochs in EXPOSURE_EPOCHS + ) + + +def _validated_output_root(value: str | os.PathLike[str]) -> Path: + path = Path(value).resolve() + research_root = RESEARCH_ROOT.resolve() + try: + path.relative_to(research_root) + except ValueError as error: + raise ValueError( + f"--outdir must be below {research_root}, got {path}" + ) from error + if path.exists(): + raise FileExistsError( + f"scientific output root must not already exist: {path}" + ) + return path + + +def allocate_arms( + arms: list[Arm], gpus: list[BASE.GPU], +) -> dict[str, list[Arm]]: + """Use all four GPUs with the intended 3/2/2/2 workload split.""" + if len(gpus) != 4: + raise RuntimeError(f"exactly four idle GPUs are required, got {len(gpus)}") + if set(arms) != set(arm_grid()): + raise ValueError("offline launcher requires the complete declared arm grid") + ordered_gpus = sorted(gpus, key=lambda gpu: int(gpu.index)) + by_epochs = { + epochs: sorted( + [arm for arm in arms if arm.exposure_epochs == epochs], + key=lambda arm: arm.alpha, + ) + for epochs in EXPOSURE_EPOCHS + } + allocation = {gpu.uuid: [] for gpu in ordered_gpus} + allocation[ordered_gpus[0].uuid].extend(by_epochs[1]) + for gpu, ten, hundred in zip( + ordered_gpus[1:], by_epochs[10], by_epochs[100] + ): + allocation[gpu.uuid].extend((ten, hundred)) + counts = sorted(len(values) for values in allocation.values()) + if counts != [2, 2, 2, 3] or any(not values for values in allocation.values()): + raise RuntimeError(f"invalid four-GPU allocation: {counts}") + return allocation + + +def _trainer_command(args, arm: Arm, output: Path) -> list[str]: + return [ + sys.executable, + str(TRAINER), + "--checkpoint", + str(Path(args.checkpoint).resolve()), + "--outdir", + str(output.resolve()), + "--alpha", + str(arm.alpha), + "--exposure-epochs", + str(arm.exposure_epochs), + "--rounds", + str(ROUNDS), + "--verifier-workers", + str(args.verifier_workers), + "--seed", + str(args.seed), + "--device", + "cuda:0", + ] + + +def _evaluation_command( + args, arm: Arm, arm_dir: Path, output: Path, *, cache_dir: Path, +) -> list[str]: + # Use the one promoted source file for r0. Per-arm round_00 containers + # embed arm-specific recipe metadata and therefore have different file + # hashes despite identical model tensors. + checkpoints = [str(Path(args.checkpoint).resolve())] + [ + str((arm_dir / f"round_{round_i:02d}.pt").resolve()) + for round_i in range(1, ROUNDS + 1) + ] + labels = [f"r{round_i}" for round_i in range(ROUNDS + 1)] + return [ + sys.executable, + str(EVALUATOR), + "--checkpoints", + *checkpoints, + "--labels", + *labels, + "--scene-profile", + SCENE_PROFILE, + "--ep0", + str(args.eval_ep0), + "--noise-seed", + str(args.eval_noise_seed), + "--device", + "cuda:0", + "--workers", + str(args.verifier_workers), + "--cache-dir", + str(cache_dir.resolve()), + "--output-dir", + str(output.resolve()), + ] + + +def _common_r0_command(args, output: Path) -> list[str]: + return [ + sys.executable, + str(EVALUATOR), + "--checkpoints", + str(Path(args.checkpoint).resolve()), + "--labels", + "r0", + "--scene-profile", + SCENE_PROFILE, + "--ep0", + str(args.eval_ep0), + "--noise-seed", + str(args.eval_noise_seed), + "--device", + "cuda:0", + "--workers", + str(args.verifier_workers), + "--cache-dir", + str((output / "cache").resolve()), + "--output-dir", + str(output.resolve()), + ] + + +def _validate_sidecar(path: Path) -> dict: + if not path.is_file(): + raise RuntimeError(f"missing artifact: {path}") + digest = BASE.sha256_file(path) + sidecar = Path(str(path) + ".COMPLETE.json") + if not sidecar.is_file(): + raise RuntimeError(f"missing artifact sidecar: {sidecar}") + with sidecar.open() as stream: + payload = json.load(stream) + if payload.get("sha256") != digest: + raise RuntimeError(f"artifact sidecar digest mismatch: {sidecar}") + return { + "path": str(path.resolve()), + "sha256": digest, + "sidecar": str(sidecar.resolve()), + "sidecar_sha256": BASE.sha256_file(sidecar), + "sidecar_payload": payload, + } + + +def validate_training_arm( + arm_dir: Path, + arm: Arm, + *, + source_commit: str, + checkpoint_sha256: str, + seed: int, + verifier_workers: int, +) -> dict: + marker = arm_dir / "COMPLETE.json" + if not marker.is_file(): + raise RuntimeError(f"missing arm completion marker: {marker}") + with marker.open() as stream: + payload = json.load(stream) + if payload.get("status") != ARM_STATUS: + raise RuntimeError(f"invalid arm status: {marker}") + if payload.get("experiment") != arm.name: + raise RuntimeError(f"arm identity mismatch: {marker}") + expected_recipe = { + "alpha": float(arm.alpha), + "exposure_epochs": int(arm.exposure_epochs), + "rounds": ROUNDS, + "K": K, + "B": B, + "T": T, + "H": H, + "batch": BATCH, + "lr": LR, + "ess_target": ESS_TARGET, + "nfe": 8, + "temp": 1.0, + "phi_s": 0.9, + "gp_lam": GP_LAMBDA, + "verifier_workers": int(verifier_workers), + "seed": int(seed), + "scene_profile": SCENE_PROFILE, + "smoke": False, + } + if payload.get("recipe") != expected_recipe: + raise RuntimeError(f"training recipe mismatch: {marker}") + expected_constants = { + "ell": ELL, + "gp_buffer_cap": CAP, + "gp_lambda": GP_LAMBDA, + "expected_checkpoint_sha256": CHECKPOINT_SHA256, + "replay_window_rounds": 1, + "gp_quota_semantics": ( + "73 executed D+ rows per gamma plus one rotating extra when " + "support permits; any support shortage is logged and the " + "unused capacity is deterministically redistributed" + ), + "ess_target_semantics": ( + "mean normalized ESS over each sequential remaining pool" + ), + } + if payload.get("constants") != expected_constants: + raise RuntimeError(f"training constants mismatch: {marker}") + if payload.get("source_checkpoint_sha256") != checkpoint_sha256: + raise RuntimeError(f"source checkpoint mismatch: {marker}") + source = payload.get("source", {}) + if ( + source.get("commit") != source_commit + or source.get("tracked_worktree_clean") is not True + ): + raise RuntimeError(f"trainer source provenance mismatch: {marker}") + if payload.get("scientific_role") != ( + "offline_expansion_data_collector_not_safe_controller" + ): + raise RuntimeError(f"collector role mismatch: {marker}") + + checkpoints = [] + for round_i in range(ROUNDS + 1): + checkpoint = _validate_sidecar( + arm_dir / f"round_{round_i:02d}.pt" + ) + if checkpoint["sidecar_payload"].get("status") != "COMPLETE": + raise RuntimeError(f"invalid checkpoint sidecar: {checkpoint['sidecar']}") + checkpoints.append({"round": round_i, **checkpoint}) + + history = payload.get("history") + if not isinstance(history, list) or [ + int(row.get("round", -1)) for row in history + ] != list(range(1, ROUNDS + 1)): + raise RuntimeError(f"arm must contain rounds 1--{ROUNDS}: {marker}") + rounds = [] + for row in history: + round_i = int(row["round"]) + if row.get("experiment") != arm.name: + raise RuntimeError(f"round experiment mismatch: {marker}") + if row.get("checkpoint_sha256") != checkpoints[round_i]["sha256"]: + raise RuntimeError(f"round checkpoint digest mismatch: {marker}") + gp_selection = row.get("gp_selection", {}) + expected_gp_count = ( + 0 if round_i == 1 + else min(CAP, int(history[round_i - 2]["shard"]["Dplus"])) + ) + per_gamma_gp = gp_selection.get("per_gamma", {}) + if ( + int(gp_selection.get("requested_cap", -1)) != CAP + or int(gp_selection.get("quota", -1)) != CAP // 7 + or int(gp_selection.get("selected", -1)) != expected_gp_count + or sum(int(value) for value in per_gamma_gp.values()) + != expected_gp_count + or len(row.get("gp_buffer_ids", [])) != expected_gp_count + or len({ + tuple(identity) for identity in row.get("gp_buffer_ids", []) + }) != expected_gp_count + ): + raise RuntimeError(f"previous-round GP contract mismatch in round {round_i}") + if round_i > 1 and gp_selection.get("unique") is not True: + raise RuntimeError(f"GP buffer is not unique in round {round_i}") + if "outcomes" in row: + raise RuntimeError("outcomes must be stored only inside gather") + shard = row.get("shard", {}) + shard_path = Path(shard.get("path", "")) + shard_artifact = _validate_sidecar(shard_path) + if shard_artifact["sidecar_payload"].get("status") != ( + "OFFLINE_EXECUTED_ROUND_SHARD_COMPLETE" + ): + raise RuntimeError(f"invalid executed shard sidecar: {shard_path}") + if shard_artifact["sha256"] != shard.get("sha256"): + raise RuntimeError(f"executed shard digest mismatch: {shard_path}") + gather = row.get("gather", {}) + if len(gather.get("outcomes", [])) != 56: + raise RuntimeError(f"round {round_i} must contain 56 episode outcomes") + counts = gather.get("counts", {}) + summary = gather.get("shard", {}) + contexts = int(counts.get("contexts", -1)) + if int(counts.get("B_queries", -1)) != contexts * B: + raise RuntimeError(f"B query accounting mismatch in round {round_i}") + if ( + int(summary.get("contexts", -1)) != contexts + or int(summary.get("D", -1)) != contexts + or int(summary.get("Dplus", -1)) + + int(summary.get("Dminus", -1)) != contexts + or int(summary.get("errors", -1)) != 0 + or int(summary.get("unresolved_contexts", -1)) != 0 + ): + raise RuntimeError(f"executed D partition mismatch in round {round_i}") + replay = row.get("replay", {}) + dplus = int(summary["Dplus"]) + dminus = int(summary["Dminus"]) + expected_batches = math.ceil((dplus + dminus) / BATCH) + expected_steps = expected_batches * int(arm.exposure_epochs) + if ( + replay.get("exact_once_per_exposure_epoch") is not True + or int(replay.get("positive_eligible", -1)) != dplus + or int(replay.get("negative_eligible", -1)) != dminus + or int(replay.get("positive_total_visits", -1)) + != dplus * int(arm.exposure_epochs) + or int(replay.get("negative_total_visits", -1)) + != dminus * int(arm.exposure_epochs) + or int(replay.get("optimizer_steps", -1)) != expected_steps + or bool(replay.get("negative_used_for_training")) + != bool(float(arm.alpha) > 0.0 and dminus) + ): + raise RuntimeError(f"offline replay accounting mismatch in round {round_i}") + if replay.get("visual_encoder_sha_before") != replay.get( + "visual_encoder_sha_after" + ): + raise RuntimeError(f"visual encoder changed in round {round_i}") + rounds.append({ + "round": round_i, + "D": contexts, + "Dplus": dplus, + "Dminus": dminus, + "optimizer_steps": expected_steps, + "shard": shard_artifact, + }) + return { + "arm": arm.name, + "alpha": arm.alpha, + "exposure_epochs": arm.exposure_epochs, + "marker": str(marker.resolve()), + "marker_sha256": BASE.sha256_file(marker), + "checkpoints": checkpoints, + "rounds": rounds, + } + + +def validate_evaluation( + output: Path, + arm: Arm, + training: dict, + *, + eval_ep0: int, + eval_noise_seed: int, +) -> dict: + metrics = output / "raw_m50_offline_metrics.json" + if not metrics.is_file(): + raise RuntimeError(f"missing evaluation metrics: {metrics}") + with metrics.open() as stream: + payload = json.load(stream) + if ( + payload.get("status") != EVAL_STATUS + or payload.get("scene_profile") != SCENE_PROFILE + or int(payload.get("bank", {}).get("ep0", -1)) != int(eval_ep0) + or int(payload.get("bank", {}).get("M_per_gamma", -1)) != 50 + or int(payload.get("noise_bank", {}).get("seed", -1)) + != int(eval_noise_seed) + or float(payload.get("noise_bank", {}).get("temperature", -1)) + != 1.0 + ): + raise RuntimeError(f"evaluation contract mismatch: {metrics}") + records = payload.get("records") + if not isinstance(records, list) or [ + int(row.get("round", -1)) for row in records + ] != list(range(ROUNDS + 1)): + raise RuntimeError(f"evaluation must contain r0--r{ROUNDS}: {metrics}") + expected_hashes = [CHECKPOINT_SHA256] + [ + row["sha256"] for row in training["checkpoints"][1:] + ] + for record, expected_hash in zip(records, expected_hashes): + cell = record.get("cell", {}) + if ( + cell.get("status") != "SFM_B1_OFFLINE_RAW_CELL_COMPLETE" + or cell.get("checkpoint_sha256") != expected_hash + or int(cell.get("M_per_gamma", -1)) != 50 + or int(cell.get("summary", {}).get("pooled", {}).get( + "verifier_errors", -1 + )) != 0 + ): + raise RuntimeError(f"invalid evaluation cell: {metrics}") + pooled = cell["summary"]["pooled"] + if not math.isclose( + float(pooled["SR"]) + float(pooled["CR"]) + + float(pooled["timeout"]), + 1.0, + rel_tol=0.0, + abs_tol=1.0e-12, + ): + raise RuntimeError(f"evaluation outcomes do not partition: {metrics}") + expected_outputs = [ + output / "raw_m50_offline_curves.png", + output / "raw_m50_offline_curves.pdf", + output / "raw_m50_offline_curves.figure.json", + ] + artifacts = [ + {"path": str(path.resolve()), "sha256": BASE.sha256_file(path)} + for path in [metrics, *expected_outputs] + if path.is_file() + ] + if len(artifacts) != 4: + raise RuntimeError(f"missing evaluation presentation artifact: {output}") + return { + "arm": arm.name, + "metrics": str(metrics.resolve()), + "metrics_sha256": BASE.sha256_file(metrics), + "records": records, + "noise_bank_sha256": payload["noise_bank"]["sha256"], + "r0_cell_key": records[0]["cell"]["cell_key"], + "artifacts": artifacts, + } + + +def _cell_row(arm: Arm, record: dict) -> dict: + pooled = record["cell"]["summary"]["pooled"] + clearance = pooled["successful_clearance"]["mean"] + time_to_goal = pooled["successful_time_to_goal"]["mean"] + return { + "arm": arm.name, + "alpha": float(arm.alpha), + "exposure_epochs": int(arm.exposure_epochs), + "round": int(record["round"]), + "SR": float(pooled["SR"]), + "CR": float(pooled["CR"]), + "timeout": float(pooled["timeout"]), + "Validity": float(pooled["Validity"]["mean"]), + "clearance": None if clearance is None else float(clearance), + "time_to_goal": None if time_to_goal is None else float(time_to_goal), + } + + +def _screening_key(row: dict) -> tuple: + clearance = ( + -float(row["clearance"]) + if row["clearance"] is not None else float("inf") + ) + time_to_goal = ( + float(row["time_to_goal"]) + if row["time_to_goal"] is not None else float("inf") + ) + return ( + float(row["CR"]), + -float(row["Validity"]), + -float(row["SR"]), + clearance, + time_to_goal, + int(row["round"]), + int(row["exposure_epochs"]), + float(row["alpha"]), + ) + + +def _render_aggregate(rows: list[dict], output: Path) -> list[dict]: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + colors = {1: "#0072B2", 10: "#E69F00", 100: "#CC79A7"} + linestyles = {0.0: "-", 0.01: "--", 0.1: ":"} + specs = ( + ("CR", "Collision rate", (-0.03, 1.03)), + ("Validity", "Validity", (-0.03, 1.03)), + ("clearance", "Min. clearance [m]", None), + ("time_to_goal", "Time-to-goal [s]", None), + ) + figure, axes = plt.subplots(2, 2, figsize=(14.5, 10.0), squeeze=False) + for axis, (key, title, ylim) in zip(axes.flat, specs): + for arm in arm_grid(): + values = [ + row for row in rows if row["arm"] == arm.name + ] + values.sort(key=lambda row: int(row["round"])) + axis.plot( + [row["round"] for row in values], + [ + float("nan") if row[key] is None else row[key] + for row in values + ], + color=colors[arm.exposure_epochs], + linestyle=linestyles[arm.alpha], + linewidth=1.8, + alpha=0.85, + ) + axis.set_title(title) + axis.set_xlabel("Expansion round") + axis.set_xticks(range(ROUNDS + 1)) + axis.grid(alpha=0.25) + if ylim is not None: + axis.set_ylim(*ylim) + handles = [ + plt.Line2D( + [0], [0], color=colors[epochs], lw=2.5, + label=f"{epochs} exposure epochs", + ) + for epochs in EXPOSURE_EPOCHS + ] + handles.extend( + plt.Line2D( + [0], [0], color="black", linestyle=linestyles[alpha], + lw=2.0, label=rf"$\alpha={alpha:g}$", + ) + for alpha in ALPHAS + ) + figure.legend( + handles=handles, ncol=6, loc="upper center", frameon=False + ) + figure.tight_layout(rect=(0, 0, 1, 0.93)) + artifacts = [] + for suffix in ("png", "pdf"): + path = output / f"factorial_raw_m50_pooled.{suffix}" + figure.savefig(path, dpi=300, bbox_inches="tight") + artifacts.append({ + "path": str(path.resolve()), + "sha256": BASE.sha256_file(path), + }) + plt.close(figure) + return artifacts + + +def aggregate(evaluations: dict[str, dict], output: Path) -> dict: + output.mkdir(parents=True, exist_ok=False) + rows = [] + for arm in arm_grid(): + rows.extend( + _cell_row(arm, record) + for record in evaluations[arm.name]["records"] + ) + csv_path = output / "factorial_raw_m50_metrics.csv" + fields = ( + "arm", "alpha", "exposure_epochs", "round", + "SR", "CR", "timeout", "Validity", "clearance", "time_to_goal", + ) + with csv_path.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + candidates = [row for row in rows if int(row["round"]) > 0] + best = min(candidates, key=_screening_key) + figures = _render_aggregate(rows, output) + result = { + "status": "SFM_B1_OFFLINE_9ARM_AGGREGATE_COMPLETE", + "selection_role": ( + "exploratory common-bank M50 screening only; not an independent " + "confirmation or a probabilistic safety guarantee" + ), + "selection_rule": ( + "post-expansion only: lower CR, higher window Validity, higher SR, " + "higher successful-only clearance, lower successful-only time, " + "then earlier round/lower exposure/lower alpha" + ), + "best_screening_cell": best, + "rows": rows, + "artifacts": [ + { + "path": str(csv_path.resolve()), + "sha256": BASE.sha256_file(csv_path), + }, + *figures, + ], + } + path = output / "AGGREGATE_COMPLETE.json" + _write_json(path, result) + result["marker"] = str(path.resolve()) + result["marker_sha256"] = BASE.sha256_file(path) + return result + + +def _select_exactly_four_gpus(args): + gpus, processes, topology = BASE.gpu_snapshot() + selected = BASE.select_idle_gpus( + gpus, + processes, + args.gpu_indices, + max_memory_mib=args.idle_memory_mib, + max_utilization=args.idle_utilization_percent, + ) + if len(selected) != 4: + raise RuntimeError( + f"the declared study requires four exclusive GPUs, got " + f"{[gpu.index for gpu in selected]}" + ) + return gpus, processes, topology, selected + + +def _phase_jobs(args, arms, selected, allocation, pools, outdir, phase): + by_uuid = {gpu.uuid: gpu for gpu in selected} + arm_gpu = { + arm: by_uuid[uuid] + for uuid, values in allocation.items() + for arm in values + } + jobs = [] + for arm in arms: + if phase == "training": + target = outdir / "arms" / arm.name + command = _trainer_command(args, arm, target) + elif phase == "evaluation": + target = outdir / "evaluation" / arm.name + command = _evaluation_command( + args, + arm, + outdir / "arms" / arm.name, + target, + cache_dir=outdir / "evaluation" / "common_r0" / "cache", + ) + else: + raise ValueError(phase) + jobs.append({ + "arm": arm, + "gpu": arm_gpu[arm], + "cpu_pool": pools[arm.name], + "command": command, + "target": str(target.resolve()), + }) + return jobs + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", required=True) + parser.add_argument( + "--expected-checkpoint-sha256", default=CHECKPOINT_SHA256, + ) + parser.add_argument("--outdir", required=True) + parser.add_argument("--gpu-indices", default="0,1,2,3") + parser.add_argument("--verifier-workers", type=int, default=8) + parser.add_argument("--seed", type=int, default=20260724) + parser.add_argument("--eval-ep0", type=int, default=260000) + parser.add_argument("--eval-noise-seed", type=int, default=20260723) + parser.add_argument("--idle-memory-mib", type=int, default=1024) + parser.add_argument("--idle-utilization-percent", type=int, default=5) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def run(args) -> dict: + if not 1 <= int(args.verifier_workers) <= 8: + raise ValueError("--verifier-workers must be in [1,8]") + checkpoint = Path(args.checkpoint).resolve() + if not checkpoint.is_file(): + raise FileNotFoundError(checkpoint) + observed_checkpoint_sha = BASE.sha256_file(checkpoint) + if ( + args.expected_checkpoint_sha256 != CHECKPOINT_SHA256 + or observed_checkpoint_sha != CHECKPOINT_SHA256 + ): + raise RuntimeError( + f"checkpoint SHA mismatch: {observed_checkpoint_sha} != " + f"{CHECKPOINT_SHA256}" + ) + for module in (TRAINER, EVALUATOR): + if not module.is_file(): + raise FileNotFoundError(module) + outdir = _validated_output_root(args.outdir) + source = BASE.source_provenance() + arms = list(arm_grid()) + all_gpus, processes, topology, selected = _select_exactly_four_gpus(args) + allocation = allocate_arms(arms, selected) + pools = BASE.allocate_cpu_pools(arms, int(args.verifier_workers)) + training_jobs = _phase_jobs( + args, arms, selected, allocation, pools, outdir, "training", + ) + contract = { + "version": 1, + "source": source, + "launcher_sha256": BASE.sha256_file(__file__), + "trainer_sha256": BASE.sha256_file(TRAINER), + "evaluator_sha256": BASE.sha256_file(EVALUATOR), + "checkpoint": str(checkpoint), + "checkpoint_sha256": observed_checkpoint_sha, + "scene_profile": SCENE_PROFILE, + "rounds": ROUNDS, + "alphas": list(ALPHAS), + "exposure_epochs": list(EXPOSURE_EPOCHS), + "K": K, + "B": B, + "T": T, + "H": H, + "ell": ELL, + "cap": CAP, + "gp_lambda": GP_LAMBDA, + "batch": BATCH, + "lr": LR, + "ess_target": ESS_TARGET, + "seed": int(args.seed), + "eval_ep0": int(args.eval_ep0), + "eval_noise_seed": int(args.eval_noise_seed), + "eval_M_per_gamma": 50, + "eval_temperature": 1.0, + "verifier_workers_per_arm": int(args.verifier_workers), + "gpu_indices": [gpu.index for gpu in selected], + "gpu_uuids": [gpu.uuid for gpu in selected], + } + declaration = { + "status": "SFM_B1_OFFLINE_9ARM_DECLARED", + "created_at": _utc_now(), + "contract": contract, + "contract_sha256": _sha256_json(contract), + "all_gpus": [asdict(gpu) for gpu in all_gpus], + "compute_processes": processes, + "topology": topology, + "allocation": { + gpu.index: [arm.name for arm in allocation[gpu.uuid]] + for gpu in selected + }, + "training_jobs": [ + { + "arm": job["arm"].name, + "gpu_index": job["gpu"].index, + "gpu_uuid": job["gpu"].uuid, + "cpu_pool": job["cpu_pool"], + "command": job["command"], + "target": job["target"], + } + for job in training_jobs + ], + } + if args.dry_run: + print(json.dumps(declaration, indent=2, allow_nan=False)) + return declaration + + outdir.mkdir(parents=True) + declaration_path = outdir / "RUN_DECLARATION.json" + _write_json(declaration_path, declaration) + started = time.perf_counter() + for job in training_jobs: + job["log_path"] = str( + ( + outdir / "logs" / "training" + / f"{job['arm'].name}.log" + ).resolve() + ) + BASE._launch_pending(training_jobs, outdir / "logs" / "training") + training = { + arm.name: validate_training_arm( + outdir / "arms" / arm.name, + arm, + source_commit=source["commit"], + checkpoint_sha256=observed_checkpoint_sha, + seed=args.seed, + verifier_workers=args.verifier_workers, + ) + for arm in arms + } + training_marker = outdir / "TRAINING_COMPLETE.json" + _write_json(training_marker, { + "status": "SFM_B1_OFFLINE_9ARM_TRAINING_COMPLETE", + "finished_at": _utc_now(), + "source": source, + "declaration_sha256": BASE.sha256_file(declaration_path), + "arms": training, + }) + + # Recheck exclusivity between phases. A foreign job that appeared while + # training ran must not be silently shared with the common-bank evaluator. + _, _, _, evaluation_gpus = _select_exactly_four_gpus(args) + if [gpu.uuid for gpu in evaluation_gpus] != [ + gpu.uuid for gpu in selected + ]: + raise RuntimeError("GPU identity changed between training and evaluation") + evaluation_allocation = allocate_arms(arms, evaluation_gpus) + common_r0_dir = outdir / "evaluation" / "common_r0" + BASE._launch_pending( + [{ + "arm": PhaseName("common_r0"), + "gpu": evaluation_gpus[0], + "cpu_pool": next(iter(pools.values())), + "command": _common_r0_command(args, common_r0_dir), + "target": str(common_r0_dir.resolve()), + }], + outdir / "logs" / "evaluation_common_r0", + ) + common_r0_metrics = common_r0_dir / "raw_m50_offline_metrics.json" + if not common_r0_metrics.is_file(): + raise RuntimeError("common r0 evaluation did not produce its metrics") + with common_r0_metrics.open() as stream: + common_r0_payload = json.load(stream) + common_records = common_r0_payload.get("records", []) + if ( + common_r0_payload.get("status") != EVAL_STATUS + or len(common_records) != 1 + or int(common_records[0].get("round", -1)) != 0 + or common_records[0].get("cell", {}).get("checkpoint_sha256") + != CHECKPOINT_SHA256 + ): + raise RuntimeError("common r0 evaluation contract mismatch") + evaluation_jobs = _phase_jobs( + args, + arms, + evaluation_gpus, + evaluation_allocation, + pools, + outdir, + "evaluation", + ) + for job in evaluation_jobs: + job["log_path"] = str( + ( + outdir / "logs" / "evaluation" + / f"{job['arm'].name}.log" + ).resolve() + ) + BASE._launch_pending(evaluation_jobs, outdir / "logs" / "evaluation") + evaluations = { + arm.name: validate_evaluation( + outdir / "evaluation" / arm.name, + arm, + training[arm.name], + eval_ep0=args.eval_ep0, + eval_noise_seed=args.eval_noise_seed, + ) + for arm in arms + } + r0_cell_keys = {value["r0_cell_key"] for value in evaluations.values()} + noise_hashes = { + value["noise_bank_sha256"] for value in evaluations.values() + } + if len(r0_cell_keys) != 1 or len(noise_hashes) != 1: + raise RuntimeError( + "the nine evaluations do not share an identical r0/common bank" + ) + aggregate_result = aggregate( + evaluations, outdir / "evaluation" / "aggregate", + ) + manifest = { + "status": "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE", + "finished_at": _utc_now(), + "wall_seconds": time.perf_counter() - started, + "source": source, + "contract": contract, + "declaration": str(declaration_path.resolve()), + "declaration_sha256": BASE.sha256_file(declaration_path), + "training_marker": str(training_marker.resolve()), + "training_marker_sha256": BASE.sha256_file(training_marker), + "training": training, + "evaluations": evaluations, + "common_r0_metrics": str(common_r0_metrics.resolve()), + "common_r0_metrics_sha256": BASE.sha256_file(common_r0_metrics), + "common_r0_cell_key": next(iter(r0_cell_keys)), + "common_noise_bank_sha256": next(iter(noise_hashes)), + "aggregate": aggregate_result, + } + delivery = outdir / "DELIVERY_COMPLETE.json" + _write_json(delivery, manifest) + print(json.dumps({ + "status": manifest["status"], + "wall_seconds": manifest["wall_seconds"], + "best_screening_cell": aggregate_result["best_screening_cell"], + "delivery": str(delivery.resolve()), + }, indent=2, allow_nan=False)) + return manifest + + +def main(argv=None) -> int: + run(_parser().parse_args(argv)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_eval.py b/overnight_run_07_12_sfm/sfm_b1_offline_eval.py new file mode 100644 index 0000000..70c0205 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_offline_eval.py @@ -0,0 +1,815 @@ +"""Raw SFM evaluation with terminal-truncated executed-window Validity. + +Every checkpoint uses one fixed M=50/scenario/gamma seed and latent bank. The +controller is the unguided raw flow at temperature one: it samples one H=10 +plan per context and executes only its first action. Acquisition, verifier +selection, fallback, guidance, and temperature search are absent. + +For an executed trajectory with ``N_tau`` controls, Validity is the mean of +the ``N_tau`` exact GREEN-verifier indicators. The window at start ``t`` uses +the actions actually executed from ``t`` onward and has +``H_t=min(10, N_tau-t)``. Terminal tails are neither dropped nor padded. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field +import hashlib +import json +import math +import multiprocessing as mp +import os +import re +from typing import Any + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch + +import _paths # noqa: F401 +import grid_feats as GF +import grid_policy_sfm as GPS +import sfm_b1_eval as BE +import sfm_hp_history as HH +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +VERSION = "sfm_b1_offline_executed_window_v1" +M_PER_GAMMA = 50 +T = int(SP.T) +H = int(SP.H) +NFE = 8 +TEMPERATURE = 1.0 +DEFAULT_EP0 = 260_000 +DEFAULT_NOISE_SEED = 2_026_072_3 +Z95 = 1.959963984540054 +PLOT_SPECS = ( + ("CR", "Collision rate", (-0.03, 1.03)), + ("Validity", "Validity", (-0.03, 1.03)), + ("clearance", "Min. clearance [m]", None), + ("time", "Time-to-goal [s]", None), +) + + +def _sha256_file(path: str | os.PathLike[str]) -> str: + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _sha256_json(payload: Any) -> str: + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":") + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _write_json(path: str | os.PathLike[str], payload: Any) -> None: + path = os.path.abspath(os.fspath(path)) + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = path + ".tmp" + with open(temporary, "w") as stream: + json.dump(payload, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def _checkpoint_specs(checkpoints: list[str], labels: list[str]) -> list[dict]: + if len(checkpoints) != len(labels) or not checkpoints: + raise ValueError("--checkpoints and --labels need the same nonzero length") + if len(labels) != len(set(labels)): + raise ValueError("checkpoint labels must be unique") + specs = [] + for checkpoint, label in zip(checkpoints, labels): + match = re.fullmatch(r"r([0-9]+)", str(label)) + if match is None: + raise ValueError(f"checkpoint label {label!r} must have form r0, r1, ...") + path = os.path.abspath(checkpoint) + if not os.path.isfile(path): + raise FileNotFoundError(path) + specs.append(dict(label=str(label), round=int(match.group(1)), checkpoint=path)) + rounds = [spec["round"] for spec in specs] + if rounds != sorted(rounds) or len(rounds) != len(set(rounds)): + raise ValueError("checkpoint labels must be unique and increasing") + return specs + + +def _noise_bank(*, ep0: int, d: int, seed: int) -> tuple[np.ndarray, dict]: + contract = { + "version": VERSION, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "gammas": list(map(float, SP.GAMMAS)), + "T": T, + "d": int(d), + "seed": int(seed), + "temperature": TEMPERATURE, + "NFE": NFE, + } + generator = np.random.default_rng(int(seed)) + values = generator.standard_normal( + (len(SP.GAMMAS), M_PER_GAMMA, T, int(d)), dtype=np.float32, + ) + metadata = { + **contract, + "dtype": "float32", + "shape": list(values.shape), + "sha256": hashlib.sha256(values.tobytes(order="C")).hexdigest(), + "CRN": ( + "same (gamma,scenario,step) latent across checkpoints; paired " + "scenario IDs across gamma" + ), + } + return values, metadata + + +@dataclass +class _Episode: + gamma_index: int + rollout_index: int + episode: int + gamma: float + humans: list + state: np.ndarray = field(default_factory=lambda: np.zeros(4, np.float32)) + history: HH.HpHistory = field(default_factory=HH.HpHistory) + controls: list[np.ndarray] = field(default_factory=list) + states: list[np.ndarray] = field( + default_factory=lambda: [np.zeros(4, np.float32)] + ) + ped_xy: list[np.ndarray] = field(default_factory=list) + ped_vel: list[np.ndarray] = field(default_factory=list) + status: str | None = None + minimum_clearance: float = float("inf") + + +def _clearance(state: np.ndarray, ped_xy: np.ndarray) -> float: + if not len(ped_xy): + return float("inf") + return float( + np.linalg.norm(ped_xy - state[:2][None], axis=1).min() - SS.R_PED + ) + + +def _terminal_check(episode: _Episode, ped_xy: np.ndarray) -> bool: + clearance = _clearance(episode.state, ped_xy) + episode.minimum_clearance = min(episode.minimum_clearance, clearance) + if clearance < 0.0: + episode.status = "collision" + elif float(np.linalg.norm(episode.state[:2] - SS.GOAL)) < 0.5: + episode.status = "success" + return episode.status is not None + + +@torch.no_grad() +def run_batched_raw( + policy, + *, + scene_profile: str, + ep0: int, + noise: np.ndarray, + device: str, +) -> list[dict]: + """Evaluate all 7xM cells and retain the controls actually executed.""" + environment = SS.scene_profile(scene_profile) + expected = (len(SP.GAMMAS), M_PER_GAMMA, T, int(policy.d)) + if tuple(noise.shape) != expected or noise.dtype != np.float32: + raise ValueError(f"noise bank {noise.shape}/{noise.dtype} != {expected}/float32") + episodes = [ + _Episode( + gamma_index=gamma_index, + rollout_index=rollout_index, + episode=int(ep0) + rollout_index, + gamma=float(gamma), + humans=SS.make_humans( + int(ep0) + rollout_index, + 0, + environment["n_ped"], + tuple(environment["ped_speed_range"]), + ), + ) + for gamma_index, gamma in enumerate(SP.GAMMAS) + for rollout_index in range(M_PER_GAMMA) + ] + + for step in range(T): + active, hp10, lows, histories, latents = [], [], [], [], [] + for episode in episodes: + if episode.status is not None: + continue + ped_xy, ped_vel = SS.collect_humans(episode.humans) + if _terminal_check(episode, ped_xy): + continue + obstacles = np.concatenate([ + ped_xy, + np.full((len(ped_xy), 1), SS.R_PED, np.float32), + ], axis=1) + raw_grid = torch.as_tensor(GF.axis_grid( + episode.state[:2], + obstacles, + 0.0, + R=SS.R_SENSE, + sensing=SS.R_SENSE, + )) + active.append((episode, ped_xy.copy(), ped_vel.copy())) + hp10.append(episode.history.append(raw_grid)) + lows.append(torch.as_tensor( + GF.low5(episode.state, SS.GOAL, episode.gamma) + )) + histories.append(torch.as_tensor(GF.hist_pad( + np.asarray(episode.controls[-16:]) + if episode.controls else np.zeros((0, 2)), + 16, + ))) + latents.append(noise[ + episode.gamma_index, + episode.rollout_index, + step, + ]) + if not active: + break + + hp10_tensor = torch.stack(hp10).to(device) + low_tensor = torch.stack(lows).to(device) + history_tensor = torch.stack(histories).to(device) + context = policy.ctx_from(hp10_tensor, low_tensor, history_tensor) + windows = BE.integrate_latents( + policy, + torch.as_tensor(np.asarray(latents), device=device), + context, + nfe=NFE, + ).reshape(len(active), H, 2) + windows = windows.detach().cpu().numpy().astype(np.float32) + + for (episode, ped_xy, ped_vel), window in zip(active, windows): + if tuple(window.shape) != (H, 2): + raise RuntimeError(f"generated plan {window.shape} != {(H, 2)}") + action = window[0].copy() + episode.ped_xy.append(ped_xy) + episode.ped_vel.append(ped_vel) + episode.controls.append(action) + episode.state = BE._step(episode.state, action) + episode.states.append(episode.state.copy()) + SS.advance_humans(episode.humans, episode.state) + + rows = [] + for episode in episodes: + if episode.status is None: + ped_xy, _ = SS.collect_humans(episode.humans) + if not _terminal_check(episode, ped_xy): + episode.status = "timeout" + success = episode.status == "success" + rows.append({ + "episode": episode.episode, + "gamma": episode.gamma, + "status": episode.status, + "success": success, + "collision": episode.status == "collision", + "timeout": episode.status == "timeout", + "steps": len(episode.controls), + "time_to_goal": len(episode.controls) * SS.DT if success else None, + "min_clearance": float(episode.minimum_clearance), + "successful_clearance": ( + float(episode.minimum_clearance) if success else None + ), + "states": np.asarray(episode.states, np.float32), + "controls": np.asarray(episode.controls, np.float32), + "ped_xy": np.asarray(episode.ped_xy, np.float32), + "ped_vel": np.asarray(episode.ped_vel, np.float32), + }) + return rows + + +def _verify_executed_episode(row: dict) -> dict: + """Return the fractional GREEN validity of all executed window starts.""" + n_steps = int(row["steps"]) + states = np.asarray(row["states"], np.float32) + controls = np.asarray(row["controls"], np.float32) + ped_xy = np.asarray(row["ped_xy"], np.float32) + ped_vel = np.asarray(row["ped_vel"], np.float32) + expected = ( + len(states) == n_steps + 1 + and len(controls) == n_steps + and len(ped_xy) == n_steps + and len(ped_vel) == n_steps + ) + if not expected or (n_steps and tuple(controls.shape[1:]) != (2,)): + return { + "validity": 0.0, + "valid_windows": 0, + "evaluated_windows": 0, + "verifier_errors": 1, + } + if n_steps == 0: + return { + "validity": 0.0, + "valid_windows": 0, + "evaluated_windows": 0, + "verifier_errors": 0, + } + + valid_windows = 0 + for start in range(n_steps): + stop = min(start + H, n_steps) + result = SM.verify_executed_window( + states[start], + controls[start:stop], + ped_xy[start], + ped_vel[start], + float(row["gamma"]), + ) + if not result.get("resolved", False): + return { + "validity": valid_windows / n_steps, + "valid_windows": valid_windows, + "evaluated_windows": start, + "verifier_errors": 1, + } + if int(result.get("window_horizon", -1)) != stop - start: + return { + "validity": valid_windows / n_steps, + "valid_windows": valid_windows, + "evaluated_windows": start, + "verifier_errors": 1, + } + valid_windows += int(bool(result["y"])) + return { + "validity": valid_windows / n_steps, + "valid_windows": valid_windows, + "evaluated_windows": n_steps, + "verifier_errors": 0, + } + + +def _attach_validity(rows: list[dict], executor) -> list[dict]: + futures = [executor.submit(_verify_executed_episode, row) for row in rows] + compact = [] + omitted = {"states", "controls", "ped_xy", "ped_vel"} + for row, future in zip(rows, futures): + value = {key: item for key, item in row.items() if key not in omitted} + value.update(future.result()) + compact.append(value) + return compact + + +def _cluster_bootstrap_interval( + rows: list[dict], + key: str, + *, + seed: int, + draws: int = 2_000, +) -> list[float | None]: + episode_ids = sorted({int(row["episode"]) for row in rows}) + sums, counts = [], [] + for episode in episode_ids: + values = [ + row.get(key) for row in rows if int(row["episode"]) == episode + ] + finite = [ + float(value) for value in values + if value is not None and math.isfinite(float(value)) + ] + sums.append(sum(finite)) + counts.append(len(finite)) + if not episode_ids or not sum(counts): + return [None, None] + generator = np.random.default_rng(int(seed)) + indices = generator.integers( + 0, len(episode_ids), size=(int(draws), len(episode_ids)) + ) + numerator = np.asarray(sums, float)[indices].sum(axis=1) + denominator = np.asarray(counts, float)[indices].sum(axis=1) + samples = numerator[denominator > 0] / denominator[denominator > 0] + if not len(samples): + return [None, None] + return list(map(float, np.quantile(samples, [.025, .975]))) + + +def _summarize_one(rows: list[dict], seed: int) -> dict: + n = len(rows) + if n < 1: + raise ValueError("cannot summarize an empty cell") + successes = sum(bool(row["success"]) for row in rows) + collisions = sum(bool(row["collision"]) for row in rows) + timeouts = sum(bool(row["timeout"]) for row in rows) + if successes + collisions + timeouts != n: + raise RuntimeError("success, collision, and timeout must partition a cell") + validity = BE.bootstrap_mean( + [row["validity"] for row in rows], seed=seed + 2 + ) + valid_windows = sum(int(row["valid_windows"]) for row in rows) + evaluated_windows = sum(int(row["evaluated_windows"]) for row in rows) + validity.update( + valid_windows=valid_windows, + evaluated_windows=evaluated_windows, + window_weighted_fraction=( + valid_windows / evaluated_windows if evaluated_windows else 0.0 + ), + ) + return { + "n": n, + "SR": successes / n, + "SR_wilson95": BE.wilson(successes, n), + "CR": collisions / n, + "CR_wilson95": BE.wilson(collisions, n), + "timeout": timeouts / n, + "timeout_wilson95": BE.wilson(timeouts, n), + "Validity": validity, + "successful_clearance": BE.bootstrap_mean( + [row["successful_clearance"] for row in rows], seed=seed + ), + "successful_time_to_goal": BE.bootstrap_mean( + [row["time_to_goal"] for row in rows], seed=seed + 1 + ), + "verifier_errors": sum(int(row["verifier_errors"]) for row in rows), + } + + +def summarize(rows: list[dict], *, seed: int) -> dict: + per_gamma = { + str(gamma): _summarize_one( + [row for row in rows if float(row["gamma"]) == float(gamma)], + seed + index * 10, + ) + for index, gamma in enumerate(SP.GAMMAS) + } + pooled = _summarize_one(rows, seed + 100) + for metric, key in ( + ("SR", "success"), + ("CR", "collision"), + ("timeout", "timeout"), + ): + pooled[f"{metric}_cluster_bootstrap95"] = _cluster_bootstrap_interval( + rows, key, seed=seed + 200 + len(metric) + ) + pooled["Validity"]["cluster_bootstrap95"] = _cluster_bootstrap_interval( + rows, "validity", seed=seed + 208 + ) + pooled["successful_clearance"]["cluster_bootstrap95"] = ( + _cluster_bootstrap_interval( + rows, "successful_clearance", seed=seed + 300 + ) + ) + pooled["successful_time_to_goal"]["cluster_bootstrap95"] = ( + _cluster_bootstrap_interval( + rows, "time_to_goal", seed=seed + 301 + ) + ) + pooled["ci_method"] = ( + "scenario-cluster bootstrap across seven paired gamma rows" + ) + return {"pooled": pooled, "per_gamma": per_gamma} + + +def _assert_zero_verifier_errors(summary: dict) -> None: + cells = [summary["pooled"], *summary["per_gamma"].values()] + if any(int(cell["verifier_errors"]) != 0 for cell in cells): + raise RuntimeError("evaluation contains verifier errors") + + +def _cell_key( + *, + checkpoint_sha256: str, + scene_profile: str, + ep0: int, + noise_meta: dict, +) -> str: + return _sha256_json({ + "version": VERSION, + "evaluator_sha256": _sha256_file(__file__), + "checkpoint_sha256": checkpoint_sha256, + "scene_profile": scene_profile, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "noise_bank": noise_meta, + "temperature": TEMPERATURE, + "NFE": NFE, + "T": T, + "H": H, + "validity": "executed sliding windows H_t=min(10,N_tau-t)", + "verifier": SM.verifier_manifest(), + }) + + +def _evaluate_checkpoint( + checkpoint: str, + *, + scene_profile: str, + ep0: int, + noise: np.ndarray, + noise_meta: dict, + device: str, + cache_dir: str, + executor, +) -> dict: + checkpoint_sha = _sha256_file(checkpoint) + key = _cell_key( + checkpoint_sha256=checkpoint_sha, + scene_profile=scene_profile, + ep0=ep0, + noise_meta=noise_meta, + ) + cache_path = os.path.join( + cache_dir, f"offline_cell_{checkpoint_sha[:12]}_{key[:12]}.json" + ) + if os.path.isfile(cache_path): + with open(cache_path) as stream: + payload = json.load(stream) + if ( + payload.get("status") != "SFM_B1_OFFLINE_RAW_CELL_COMPLETE" + or payload.get("cell_key") != key + ): + raise RuntimeError(f"stale evaluation cache: {cache_path}") + _assert_zero_verifier_errors(payload["summary"]) + return payload + + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + policy.eval() + if int(policy.d) != int(noise.shape[-1]): + raise ValueError("checkpoint latent dimension does not match the noise bank") + rows = run_batched_raw( + policy, + scene_profile=scene_profile, + ep0=ep0, + noise=noise, + device=device, + ) + del policy + if str(device).startswith("cuda"): + torch.cuda.empty_cache() + compact = _attach_validity(rows, executor) + summary = summarize( + compact, + seed=int(ep0) + int(checkpoint_sha[:8], 16) % 100_000, + ) + _assert_zero_verifier_errors(summary) + payload = { + "status": "SFM_B1_OFFLINE_RAW_CELL_COMPLETE", + "cell_key": key, + "checkpoint": os.path.abspath(checkpoint), + "checkpoint_sha256": checkpoint_sha, + "scene_profile": scene_profile, + "ep0": int(ep0), + "M_per_gamma": M_PER_GAMMA, + "summary": summary, + "rows": compact, + "metric_semantics": { + "policy": ( + "canonical unguided raw flow, temperature=1, NFE=8, one " + "generated H=10 window per context, execute first action" + ), + "Validity": ( + "mean per-trajectory fraction of all executed window starts " + "whose terminal-truncated actual-action window is task-space " + "valid, collision-free, and exact GREEN-certified; " + "H_t=min(10,N_tau-t)" + ), + "clearance": ( + "mean of each successful trajectory's minimum pedestrian " + "clearance; failures are excluded" + ), + "time": "successful trajectories only", + "outcome_partition": "SR + CR + timeout = 1", + }, + } + _write_json(cache_path, payload) + return payload + + +def _metric_value(cell: dict, metric: str) -> float: + if metric == "CR": + return float(cell[metric]) + key = { + "Validity": "Validity", + "clearance": "successful_clearance", + "time": "successful_time_to_goal", + }[metric] + value = cell[key]["mean"] + return float("nan") if value is None else float(value) + + +def _metric_interval(cell: dict, metric: str, *, pooled: bool) -> list[float]: + if metric == "CR": + value = ( + cell["CR_cluster_bootstrap95"] + if pooled else cell["CR_wilson95"] + ) + else: + key = { + "Validity": "Validity", + "clearance": "successful_clearance", + "time": "successful_time_to_goal", + }[metric] + value = ( + cell[key]["cluster_bootstrap95"] + if pooled else cell[key]["interval95"] + ) + return [ + float("nan") if item is None else float(item) + for item in value + ] + + +def render(records: list[dict], output_dir: str) -> list[str]: + """Render the four metrics in the ball-evaluator paper style.""" + colors = { + gamma: plt.get_cmap("plasma")( + 0.08 + 0.84 * index / max(len(SP.GAMMAS) - 1, 1) + ) + for index, gamma in enumerate(SP.GAMMAS) + } + rounds = [int(record["round"]) for record in records] + plt.rcParams.update({ + "font.family": "serif", + "mathtext.fontset": "cm", + "font.serif": ["cmr10", "Computer Modern Roman", "DejaVu Serif"], + "axes.titlesize": 24, + "axes.labelsize": 20, + "xtick.labelsize": 17, + "ytick.labelsize": 17, + "legend.fontsize": 16, + "axes.unicode_minus": False, + "axes.formatter.use_mathtext": True, + }) + figure, axes = plt.subplots(2, 2, figsize=(14.6, 10.8), squeeze=False) + for axis, (metric, title, ylim) in zip(axes.flat, PLOT_SPECS): + for gamma in SP.GAMMAS: + cells = [ + record["cell"]["summary"]["per_gamma"][str(gamma)] + for record in records + ] + values = [_metric_value(cell, metric) for cell in cells] + intervals = [ + _metric_interval(cell, metric, pooled=False) for cell in cells + ] + axis.plot( + rounds, values, color=colors[gamma], lw=1.35, alpha=0.75 + ) + axis.fill_between( + rounds, + [value[0] for value in intervals], + [value[1] for value in intervals], + color=colors[gamma], + alpha=0.18, + linewidth=0, + ) + pooled = [ + record["cell"]["summary"]["pooled"] for record in records + ] + values = [_metric_value(cell, metric) for cell in pooled] + intervals = [ + _metric_interval(cell, metric, pooled=True) for cell in pooled + ] + axis.plot(rounds, values, color="black", lw=3.0) + axis.fill_between( + rounds, + [value[0] for value in intervals], + [value[1] for value in intervals], + color="black", + alpha=0.14, + linewidth=0, + ) + axis.set_title(title) + axis.grid(alpha=0.25) + axis.set_xlim(rounds[0] - 0.4, rounds[-1] + 0.4) + if ylim is not None: + axis.set_ylim(*ylim) + axis.set_xlabel("Expansion round") + + handles = [ + plt.Line2D( + [0], [0], color=colors[gamma], lw=2.2, + label=rf"$\gamma={gamma:g}$", + ) + for gamma in SP.GAMMAS + ] + handles.append( + plt.Line2D([0], [0], color="black", lw=3.0, label="pooled") + ) + figure.legend( + handles=handles, ncol=5, loc="upper center", frameon=False + ) + figure.tight_layout(rect=(0, 0, 1, 0.90)) + os.makedirs(output_dir, exist_ok=True) + outputs = [] + for suffix in ("png", "pdf"): + path = os.path.join(output_dir, f"raw_m50_offline_curves.{suffix}") + figure.savefig(path, dpi=300, bbox_inches="tight") + outputs.append(path) + plt.close(figure) + manifest = os.path.join(output_dir, "raw_m50_offline_curves.figure.json") + _write_json(manifest, { + "status": "SFM_B1_OFFLINE_FIGURE_COMPLETE", + "rounds": rounds, + "gammas": list(map(float, SP.GAMMAS)), + "claim": ( + "fixed raw temperature-1 rollouts; Validity is the trajectory-mean " + "fraction over every executed window start; terminal horizons use " + "H_t=min(10,N_tau-t); every indicator requires task-space bounds, " + "time-indexed collision avoidance, and the exact GREEN certificate" + ), + "confidence_bands": ( + "per-gamma Wilson for CR and trajectory bootstrap for continuous " + "metrics; pooled scenario-cluster bootstrap" + ), + "style_source": "safeMPPI_demo_3d/scripts/evaluate_ball_expansion.py", + }) + outputs.append(manifest) + return outputs + + +def run(args) -> dict: + specs = _checkpoint_specs(args.checkpoints, args.labels) + output_dir = os.path.abspath(args.output_dir) + cache_dir = os.path.abspath( + args.cache_dir or os.path.join(output_dir, "cache") + ) + os.makedirs(output_dir, exist_ok=True) + os.makedirs(cache_dir, exist_ok=True) + + probe, _ = GPS.load_sfm_policy(specs[0]["checkpoint"], device="cpu") + noise, noise_meta = _noise_bank( + ep0=args.ep0, d=int(probe.d), seed=args.noise_seed + ) + del probe + records = [] + context = mp.get_context("spawn") + with ProcessPoolExecutor( + max_workers=int(args.workers), mp_context=context + ) as executor: + for spec in specs: + cell = _evaluate_checkpoint( + spec["checkpoint"], + scene_profile=args.scene_profile, + ep0=args.ep0, + noise=noise, + noise_meta=noise_meta, + device=args.device, + cache_dir=cache_dir, + executor=executor, + ) + records.append({ + "label": spec["label"], + "round": spec["round"], + "cell": cell, + }) + + outputs = render(records, output_dir) + result = { + "status": "SFM_B1_OFFLINE_RAW_M50_COMPLETE", + "version": VERSION, + "scene_profile": args.scene_profile, + "environment": SS.scene_profile(args.scene_profile), + "bank": { + "ep0": int(args.ep0), + "M_per_gamma": M_PER_GAMMA, + "scenario_ids": list(range( + int(args.ep0), int(args.ep0) + M_PER_GAMMA + )), + "same_scenario_ids_for_every_gamma": True, + }, + "noise_bank": noise_meta, + "records": records, + "outputs": outputs, + } + result_path = os.path.join(output_dir, "raw_m50_offline_metrics.json") + _write_json(result_path, result) + result["metrics_json"] = result_path + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoints", nargs="+", required=True) + parser.add_argument("--labels", nargs="+", required=True) + parser.add_argument( + "--scene-profile", + default="double_density_velocity_ood", + choices=SS.SCIENTIFIC_EVAL_PROFILES, + ) + parser.add_argument("--ep0", type=int, default=DEFAULT_EP0) + parser.add_argument("--noise-seed", type=int, default=DEFAULT_NOISE_SEED) + parser.add_argument("--device", default="cuda") + parser.add_argument("--workers", type=int, default=32) + parser.add_argument("--cache-dir") + parser.add_argument("--output-dir", required=True) + return parser + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + result = run(args) + print(result["metrics_json"]) + for path in result["outputs"]: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py new file mode 100644 index 0000000..565a95b --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -0,0 +1,842 @@ +"""Offline executed-window SFM expansion. + +B=4 exact verifier queries are used only to search for the next action. One +H=10 plan per context enters D: the plan whose first action was executed. At a +finite-B NVP context, an independent raw temperature-one plan is exact-verified +and executed even when negative so that the offline simulator can continue. + +This is an offline data collector, not a certified deployment controller. +""" +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +from concurrent.futures import ProcessPoolExecutor +import copy +from dataclasses import asdict, dataclass +import hashlib +import json +import math +import os +import subprocess +import time + +import numpy as np +import torch + +import _paths # noqa: F401 +import grid_policy_sfm as GPS +import sfm_b1_cost as BC +import sfm_b1_eval as BE +import sfm_b1_expand as BX +import sfm_b1_full_episode_audit as FA +import sfm_b1_offline_replay as OR +import sfm_b1_offline_store as OS +import sfm_b1_rbf as BR +import sfm_b1_store as BS +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +EXPECTED_CHECKPOINT_SHA256 = ( + "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" +) +ELL = 0.24210826720721101 +CAP = 512 +GP_LAMBDA = 1.0e-2 +ALPHAS = (0.0, 0.01, 0.1) +EXPOSURE_EPOCHS = (1, 10, 100) +SCENE_PROFILE = "double_density_velocity_ood" + + +@dataclass(frozen=True) +class OfflineConfig: + alpha: float + exposure_epochs: int + rounds: int = 10 + K: int = 16 + B: int = 4 + T: int = 180 + H: int = 10 + batch: int = 128 + lr: float = 1.0e-4 + ess_target: float = 0.5 + nfe: int = 8 + temp: float = 1.0 + phi_s: float = 0.9 + gp_lam: float = GP_LAMBDA + verifier_workers: int = 8 + seed: int = 20260724 + scene_profile: str = SCENE_PROFILE + smoke: bool = False + + def validate(self): + if float(self.alpha) not in ALPHAS: + raise ValueError(f"alpha must be one of {ALPHAS}") + if int(self.exposure_epochs) not in EXPOSURE_EPOCHS: + raise ValueError(f"exposure_epochs must be one of {EXPOSURE_EPOCHS}") + if ( + int(self.K), int(self.B), int(self.T), int(self.H), + int(self.batch), float(self.lr), float(self.ess_target), + float(self.gp_lam), self.scene_profile, + ) != ( + 16, 4, 180, 10, 128, 1.0e-4, 0.5, + GP_LAMBDA, SCENE_PROFILE, + ): + raise ValueError("offline executed-window scientific contract changed") + expected_rounds = 1 if self.smoke else 10 + if int(self.rounds) != expected_rounds: + raise ValueError( + f"rounds must be {expected_rounds} when smoke={self.smoke}" + ) + if int(self.verifier_workers) < 1: + raise ValueError("verifier_workers must be positive") + return self + + @property + def arm_name(self): + alpha = str(float(self.alpha)).replace(".", "p") + return ( + f"offline_exec_alpha{alpha}_" + f"exposures{int(self.exposure_epochs):03d}" + ) + + +def _write_json(path, payload): + path = os.path.abspath(os.fspath(path)) + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = path + ".tmp" + with open(temporary, "w") as stream: + json.dump(payload, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def _source(): + root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=root, text=True, + ).strip() + dirty = bool(subprocess.check_output( + ["git", "status", "--porcelain"], cwd=root, text=True, + ).strip()) + return dict(commit=commit, tracked_worktree_clean=not dirty) + + +def _keyed_seed(base, *parts): + payload = json.dumps( + [int(base), *parts], separators=(",", ":"), sort_keys=False, + ).encode() + return int.from_bytes(hashlib.sha256(payload).digest()[:8], "little") % ( + 2 ** 63 - 1 + ) + + +@torch.no_grad() +def _keyed_windows( + policy, live, batch, *, K, round_i, step, source, seed, nfe, temp, +): + contexts = policy.ctx_from(batch["hp10"], batch["low"], batch["hist"]) + latent_parts = [] + for replica in live: + generator = np.random.default_rng(_keyed_seed( + seed, int(round_i), int(replica.scenario_id), + f"{float(replica.gamma):.8f}", int(step), str(source), + )) + latent_parts.append(generator.standard_normal( + (int(K), int(policy.d)), dtype=np.float32, + )) + latents = torch.as_tensor( + np.stack(latent_parts), + device=contexts.device, + dtype=contexts.dtype, + ) * float(temp) + expanded = contexts.repeat_interleave(int(K), dim=0) + windows = BE.integrate_latents( + policy, latents.reshape(-1, policy.d), expanded, nfe=int(nfe), + ) + return windows.reshape( + len(live), int(K), int(policy.H_pred), 2, + ), contexts + + +def _gamma_balanced_records(previous, *, cap, round_i, seed): + if previous is None: + return [], dict( + requested_cap=int(cap), selected=0, quota=int(cap) // len(SP.GAMMAS), + rotating_extra_gamma=None, per_gamma={str(gamma): 0 for gamma in SP.GAMMAS}, + shortfall={str(gamma): int(cap) // len(SP.GAMMAS) for gamma in SP.GAMMAS}, + ) + groups = {} + for gamma_index, gamma in enumerate(SP.GAMMAS): + records = [ + (previous, row) + for row in previous.Dplus + if round( + float(previous.contexts[int(row["context_id"])]["gamma"]), 8 + ) == round(float(gamma), 8) + ] + groups[float(gamma)] = BS.hierarchical_order( + records, int(seed) + gamma_index, + ) + quota = int(cap) // len(SP.GAMMAS) + selected = [] + remaining = {} + shortfall = {} + for gamma in SP.GAMMAS: + values = groups[float(gamma)] + take = min(quota, len(values)) + selected.extend(values[:take]) + remaining[float(gamma)] = values[take:] + shortfall[str(gamma)] = quota - take + rotation = (int(round_i) - 2) % len(SP.GAMMAS) + order = [ + float(SP.GAMMAS[(rotation + offset) % len(SP.GAMMAS)]) + for offset in range(len(SP.GAMMAS)) + ] + while len(selected) < int(cap) and any(remaining.values()): + progressed = False + for gamma in order: + if remaining[gamma] and len(selected) < int(cap): + selected.append(remaining[gamma].pop(0)) + progressed = True + if not progressed: + break + per_gamma = Counter( + str(previous.contexts[int(row["context_id"])]["gamma"]) + for _, row in selected + ) + identities = [ + (int(shard.round_i), int(row["window_id"])) for shard, row in selected + ] + if len(identities) != len(set(identities)): + raise RuntimeError("GP buffer selection contains duplicates") + expected = min(int(cap), len(previous.Dplus)) + if len(selected) != expected: + raise RuntimeError( + f"expected {expected} GP records, selected {len(selected)}" + ) + return selected, dict( + requested_cap=int(cap), + selected=len(selected), + quota=quota, + rotating_extra_gamma=float(order[0]), + per_gamma={str(gamma): int(per_gamma[str(gamma)]) for gamma in SP.GAMMAS}, + shortfall=shortfall, + unique=True, + ) + + +@torch.no_grad() +def gp_from_previous( + phi_policy, previous, *, round_i, ell, cap, lam, phi_s, device, seed, +): + selected, selection = _gamma_balanced_records( + previous, cap=cap, round_i=round_i, seed=seed, + ) + gp = BR.RBFGP(float(ell), float(lam)) + if selected: + feature_parts = [] + for start in range(0, len(selected), 256): + hp10, low, hist, controls = BX._record_batch( + selected[start:start + 256], device, + ) + feature_parts.append(phi_policy.phi_s( + controls, + phi_policy.ctx_from(hp10, low, hist), + s=float(phi_s), + )) + gp.set_buffer(torch.cat(feature_parts)) + identities = [ + (int(shard.round_i), int(row["window_id"])) for shard, row in selected + ] + return gp, identities, selection + + +@torch.no_grad() +def _calibrate_beta( + phi_policy, gp, replicas, cfg, device, *, round_i, +): + live, batch = BX._stack_prepared(replicas, device) + windows, _ = _keyed_windows( + phi_policy, live, batch, K=cfg.K, round_i=round_i, step=-1, + source="beta_calibration", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, + ) + features = BX._features(phi_policy, windows, batch, cfg.phi_s) + vectors = [] + for index, (replica, values) in enumerate(zip(live, features)): + generator = torch.Generator(device=values.device).manual_seed( + _keyed_seed( + cfg.seed, round_i, replica.scenario_id, + f"{replica.gamma:.8f}", "beta_order", + ) + ) + order = torch.randperm( + len(values), generator=generator, device=values.device, + ) + vectors.extend(gp.sequential_score_vectors(values, order, cfg.B)) + beta, ess = BR.solve_beta(vectors, target=cfg.ess_target) + return float(beta), float(ess) + + +def _finalize_alive(replicas): + for replica in replicas: + if not replica.alive: + continue + terminal_xy, _ = SS.collect_humans(replica.humans) + clearance = float( + np.linalg.norm( + terminal_xy - replica.state[:2][None], axis=1, + ).min() - SS.R_PED + ) + replica.minimum_clearance = min(replica.minimum_clearance, clearance) + if clearance < 0.0: + replica.status = "collision" + elif float(np.linalg.norm(replica.state[:2] - SS.GOAL)) < 0.5: + replica.status = "success" + else: + replica.status = "timeout" + replica.alive = False + + +def gather_offline_round( + policy, phi_policy, gp, beta, replicas, cfg, shard, device, executor, + *, round_i, +): + timers = Counter() + counts = Counter() + sigma_all, sigma_selected, ess_values = [], [], [] + modes = {key: Counter() for key in ("all_K", "selected_B", "Dplus", "Dminus")} + bounded_traces = [] + trap_active = defaultdict(bool) + policy_hash = BX.policy_sha256(policy) + + for step in range(int(cfg.T)): + start = time.perf_counter() + live = [replica for replica in replicas if replica.alive] + live, batch = BX._stack_prepared(live, device) + timers["sfm_stepping"] += time.perf_counter() - start + if not live: + break + counts["contexts"] += len(live) + + start = time.perf_counter() + with torch.no_grad(): + windows, contexts = _keyed_windows( + policy, live, batch, K=cfg.K, round_i=round_i, step=step, + source="K", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows, _ = _keyed_windows( + policy, live, batch, K=1, round_i=round_i, step=step, + source="raw_continuation", seed=cfg.seed, + nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows = raw_windows[:, 0] + windows_np = windows.detach().cpu().numpy() + raw_windows_np = raw_windows.detach().cpu().numpy() + timers["flow_proposal"] += time.perf_counter() - start + + start = time.perf_counter() + with torch.no_grad(): + features = BX._features(phi_policy, windows, batch, cfg.phi_s) + raw_features = BR.l2_normalize(phi_policy.phi_s( + raw_windows, contexts, s=cfg.phi_s, + )) + selected_by_context = [] + acquisitions = [] + for context_index, replica in enumerate(live): + generator = torch.Generator(device=features.device).manual_seed( + _keyed_seed( + cfg.seed, round_i, replica.scenario_id, + f"{replica.gamma:.8f}", step, "acquisition", + ) + ) + selected, trace = gp.sequential_acquire( + features[context_index], cfg.B, beta, generator=generator, + ) + selected_by_context.append(selected) + acquisitions.append(trace) + sigma_all.extend( + float(value) + for value in trace[0]["scores"].clamp_min(0.0).sqrt() + ) + sigma_selected.extend(float(row["chosen_sigma"]) for row in trace) + ess_values.extend(float(row["ess_norm"]) for row in trace) + timers["phi_rbf"] += time.perf_counter() - start + + tasks = [] + for context_index, replica in enumerate(live): + prepared = replica.prepared + for candidate_id in selected_by_context[context_index]: + tasks.append(( + context_index, + candidate_id, + prepared["state"], + windows_np[context_index, candidate_id], + prepared["ped_xy"], + prepared["ped_vel"], + replica.gamma, + )) + start = time.perf_counter() + results = list(executor.map(SM.verify_in_worker, tasks)) + timers["verifier"] += time.perf_counter() - start + counts["B_queries"] += len(tasks) + by_context = defaultdict(dict) + for context_index, candidate_id, result in results: + by_context[int(context_index)][int(candidate_id)] = result + + prepared_rows = [] + raw_tasks = [] + for context_index, replica in enumerate(live): + prepared = replica.prepared + prediction = SM.predict_pedestrians( + prepared["ped_xy"], prepared["ped_vel"], cfg.H, + ) + all_rows = [] + for candidate_id in range(cfg.K): + controls = windows_np[context_index, candidate_id] + segment = SM.rollout_positions(prepared["state"], controls) + mode = BE.classify_candidate(segment, prediction) + modes["all_K"][mode] += 1 + all_rows.append(dict( + candidate_id=int(candidate_id), + controls=controls, + mode=mode, + )) + query_rows = [] + for acquisition_step, candidate_id in enumerate( + selected_by_context[context_index] + ): + result = by_context[context_index][candidate_id] + label = FA._result_label(result) + counts[f"B_{label}"] += 1 + mode = all_rows[candidate_id]["mode"] + modes["selected_B"][mode] += 1 + if result.get("resolved"): + query_rows.append(dict( + candidate_id=int(candidate_id), + acquisition_step=int(acquisition_step), + controls=all_rows[candidate_id]["controls"], + result=result, + mode=mode, + sigma=float( + acquisitions[context_index][ + acquisition_step + ]["chosen_sigma"] + ), + )) + chosen = BC.select_admissible( + query_rows, + selector="margin", + state=prepared["state"], + ped_xy=prepared["ped_xy"], + ped_vel=prepared["ped_vel"], + gamma=replica.gamma, + ) + prepared_rows.append((all_rows, query_rows, chosen)) + if chosen is None: + raw_tasks.append(( + context_index, + -1, + prepared["state"], + raw_windows_np[context_index], + prepared["ped_xy"], + prepared["ped_vel"], + replica.gamma, + )) + + start = time.perf_counter() + raw_results = list(executor.map(SM.verify_in_worker, raw_tasks)) + timers["verifier"] += time.perf_counter() - start + counts["raw_continuation_queries"] += len(raw_tasks) + for context_index, candidate_id, result in raw_results: + if not result.get("resolved"): + raise RuntimeError( + "executed raw continuation verifier failed; " + "aborting instead of executing or omitting an unlabeled context: " + f"{result.get('error')}" + ) + by_context[int(context_index)][int(candidate_id)] = result + + start = time.perf_counter() + for context_index, replica in enumerate(live): + prepared = replica.prepared + all_rows, query_rows, chosen = prepared_rows[context_index] + context_id = shard.add_context( + scenario_id=replica.scenario_id, + gamma=replica.gamma, + step=step, + state=prepared["state"], + hp10=prepared["hp10"].numpy(), + low5=prepared["low"].numpy(), + hist=prepared["hist"].numpy(), + ped_xy=prepared["ped_xy"], + ped_vel=prepared["ped_vel"], + ) + nvp_context = chosen is None + if nvp_context: + controls = raw_windows_np[context_index] + result = by_context[context_index][-1] + margin, _, _ = BC.nominal_hp_margin( + prepared["state"], controls[0], prepared["ped_xy"], + replica.gamma, + ) + raw_admissible = bool( + result.get("resolved") + and int(result.get("y", 0)) == 1 + and bool(result.get("full_h")) + and float(margin) >= -1.0e-9 + ) + execution_source = ( + "certified_raw_rescue" + if raw_admissible else "uncertified_raw_continuation" + ) + candidate_id = None + acquisition_step = None + sigma = float(gp.acquisition_sigma( + raw_features[context_index:context_index + 1], + )[0]) + prediction = SM.predict_pedestrians( + prepared["ped_xy"], prepared["ped_vel"], cfg.H, + ) + mode = BE.classify_candidate( + SM.rollout_positions(prepared["state"], controls), + prediction, + ) + counts["NVP_contexts"] += 1 + else: + controls = np.asarray(chosen["controls"], np.float32) + result = chosen["result"] + margin = float(chosen["hp_margin"]) + execution_source = "verified_max_margin" + candidate_id = int(chosen["candidate_id"]) + acquisition_step = int(chosen["acquisition_step"]) + sigma = float(chosen["sigma"]) + mode = chosen["mode"] + + label = FA._result_label(result) + if label == "verifier_error": + raise RuntimeError( + "resolved executed-window partition requires an exact " + "full-H10 binary verifier label" + ) + counts[f"executed_{label}"] += 1 + counts[f"source_{execution_source}"] += 1 + window_id = shard.add_executed_window( + context_id, + controls, + result, + execution_source=execution_source, + nvp_context=nvp_context, + candidate_id=candidate_id, + acquisition_step=acquisition_step, + sigma=sigma, + hp_margin=margin, + mode=mode, + ) + modes["Dplus" if int(result["y"]) == 1 else "Dminus"][mode] += 1 + + BX._advance(replica, controls[0]) + trap_event = FA._trap(replica.states) + trap_key = (replica.scenario_id, replica.gamma) + trap_entry = bool(trap_event and not trap_active[trap_key]) + trap_active[trap_key] = bool(trap_event) + collision, success, _ = FA._post_action_terminal(replica) + counts["trap_steps"] += int(trap_event) + counts["trap_entries"] += int(trap_entry) + counts["collision_events"] += int(collision) + counts["success_events"] += int(success) + if window_id is not None: + stored = shard.windows[int(window_id)] + stored.update( + trap_event=bool(trap_event), + trap_entry=bool(trap_entry), + collision_after_action=bool(collision), + success_after_action=bool(success), + ) + if len(bounded_traces) < 64 or ( + nvp_context and len(bounded_traces) < 128 + ): + bounded_traces.append(dict( + step=int(step), + scenario_id=int(replica.scenario_id), + gamma=float(replica.gamma), + selected_ids=list(map( + int, selected_by_context[context_index], + )), + B_labels=[ + FA._result_label( + by_context[context_index][candidate] + ) + for candidate in selected_by_context[context_index] + ], + execution_source=execution_source, + executed_label=label, + nvp_context=bool(nvp_context), + window_id=window_id, + trap_event=bool(trap_event), + collision_after_action=bool(collision), + success_after_action=bool(success), + )) + timers["sfm_stepping"] += time.perf_counter() - start + + _finalize_alive(replicas) + if BX.policy_sha256(policy) != policy_hash: + raise RuntimeError("policy changed during frozen offline macro-round") + shard_summary = shard.validate() + if ( + int(shard_summary["D"]) != int(shard_summary["contexts"]) + or int(shard_summary["errors"]) != 0 + or int(shard_summary["unresolved_contexts"]) != 0 + ): + raise RuntimeError( + "completed offline round must exactly partition every context " + "into D+ or D-" + ) + if int(counts["B_queries"]) != int(counts["contexts"]) * int(cfg.B): + raise RuntimeError("B verifier query accounting mismatch") + return dict( + collector_role="offline_expansion_data_collector_not_safe_controller", + continuation_semantics=( + "verified max-margin B action when available; otherwise an " + "independent raw temperature-one H10 plan is exact-verified and " + "its first action is executed even when y=0" + ), + label_semantics=( + "D contains one resolved executed proposal per context; " + "D+=exact full-H10 y=1; D-=exact full-H10 y=0; " + "NVP/trap/collision are metadata and never retroactively relabel y" + ), + timers=dict(timers), + counts=dict(counts), + shard=shard_summary, + beta=float(beta), + realized_normalized_ess_over_remaining=float(np.mean(ess_values)), + sigma=BR.acquisition_diagnostics(sigma_all, sigma_selected), + modes={key: dict(value) for key, value in modes.items()}, + trace_examples=bounded_traces, + outcomes=[dict( + scenario_id=replica.scenario_id, + gamma=replica.gamma, + status=replica.status, + success=replica.status == "success", + collision=replica.status == "collision", + timeout=replica.status == "timeout", + steps=len(replica.controls), + min_clearance=float(replica.minimum_clearance), + ) for replica in replicas], + ) + + +def run(checkpoint, outdir, cfg, *, device): + cfg.validate() + checkpoint = os.path.abspath(checkpoint) + outdir = os.path.abspath(outdir) + if not os.path.isfile(checkpoint): + raise FileNotFoundError(checkpoint) + checkpoint_sha = OS.sha256_file(checkpoint) + if checkpoint_sha != EXPECTED_CHECKPOINT_SHA256: + raise ValueError( + f"checkpoint SHA mismatch: expected {EXPECTED_CHECKPOINT_SHA256}, " + f"got {checkpoint_sha}" + ) + if os.path.exists(outdir): + raise FileExistsError(f"refusing to reuse output directory: {outdir}") + os.makedirs(outdir) + environment = SS.scene_profile(cfg.scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + frozen_parameters = BS.configure_expansion_trainability(policy) + visual_encoder_sha = BS.module_sha256(policy.enc_grid) + optimizer = torch.optim.Adam( + [ + parameter for parameter in policy.parameters() + if parameter.requires_grad + ], + lr=cfg.lr, + ) + BX._save_checkpoint(policy, os.path.join(outdir, "round_00.pt"), dict( + round=0, + experiment=cfg.arm_name, + source_checkpoint=checkpoint, + source_sha256=checkpoint_sha, + encoder_sha256=visual_encoder_sha, + recipe=asdict(cfg), + )) + history = [] + previous_shard = None + with ProcessPoolExecutor(max_workers=cfg.verifier_workers) as executor: + for round_i in range(1, cfg.rounds + 1): + round_start = time.perf_counter() + scenarios = SP.expansion_scenarios(round_i, smoke=cfg.smoke) + replicas = [ + BX.Replica( + scenario_id, + gamma, + n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id in scenarios for gamma in SP.GAMMAS + ] + if len(replicas) != 56: + raise RuntimeError("offline macro-round requires 56 episodes") + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + gp, gp_ids, gp_selection = gp_from_previous( + phi_policy, + previous_shard, + round_i=round_i, + ell=ELL, + cap=CAP, + lam=cfg.gp_lam, + phi_s=cfg.phi_s, + device=device, + seed=cfg.seed + round_i * 101, + ) + beta, calibrated_ess = _calibrate_beta( + phi_policy, gp, replicas, cfg, device, round_i=round_i, + ) + shard = OS.ExecutedRoundShard(round_i) + gather = gather_offline_round( + policy, + phi_policy, + gp, + beta, + replicas, + cfg, + shard, + device, + executor, + round_i=round_i, + ) + shard_path = os.path.join( + outdir, "round_shards", f"round_{round_i:02d}.pt", + ) + shard_manifest = shard.save(shard_path) + replay_start = time.perf_counter() + replay = OR.replay( + policy, + optimizer, + shard, + alpha=cfg.alpha, + exposure_epochs=cfg.exposure_epochs, + batch=cfg.batch, + device=device, + seed=cfg.seed + round_i * 1_000_003, + ) + gather["timers"]["replay"] = time.perf_counter() - replay_start + if BS.module_sha256(policy.enc_grid) != visual_encoder_sha: + raise RuntimeError("visual encoder SHA changed") + checkpoint_path = os.path.join( + outdir, f"round_{round_i:02d}.pt", + ) + BX._save_checkpoint(policy, checkpoint_path, dict( + round=round_i, + experiment=cfg.arm_name, + source_checkpoint=checkpoint, + source_sha256=checkpoint_sha, + encoder_sha256=visual_encoder_sha, + recipe=asdict(cfg), + ell=ELL, + cap=CAP, + beta=float(beta), + )) + record = dict( + round=round_i, + experiment=cfg.arm_name, + scenarios=list(map(int, scenarios)), + environment=environment, + beta=float(beta), + calibrated_normalized_ess_over_remaining=float(calibrated_ess), + verifier=SM.verifier_manifest(), + gp_buffer_ids=gp_ids, + gp_selection=gp_selection, + gp=gp.diagnostics(), + gather=gather, + replay=replay, + shard=shard_manifest, + checkpoint=os.path.abspath(checkpoint_path), + checkpoint_sha256=OS.sha256_file(checkpoint_path), + wall_seconds=time.perf_counter() - round_start, + ) + history.append(record) + with open(os.path.join(outdir, "metrics.jsonl"), "a") as stream: + stream.write(json.dumps(record, allow_nan=False) + "\n") + print(json.dumps(dict( + round=round_i, + experiment=cfg.arm_name, + D=shard_manifest["D"], + Dplus=shard_manifest["Dplus"], + Dminus=shard_manifest["Dminus"], + beta=float(beta), + ess_over_remaining=float( + gather["realized_normalized_ess_over_remaining"] + ), + Adam_steps=int(replay["optimizer_steps"]), + wall_seconds=record["wall_seconds"], + )), flush=True) + previous_shard = shard + + manifest = dict( + status="SFM_B1_OFFLINE_EXEC_COMPLETE", + experiment=cfg.arm_name, + scientific_role="offline_expansion_data_collector_not_safe_controller", + recipe=asdict(cfg), + constants=dict( + ell=ELL, + gp_buffer_cap=CAP, + gp_lambda=GP_LAMBDA, + expected_checkpoint_sha256=EXPECTED_CHECKPOINT_SHA256, + replay_window_rounds=1, + gp_quota_semantics=( + "73 executed D+ rows per gamma plus one rotating extra when " + "support permits; any support shortage is logged and the " + "unused capacity is deterministically redistributed" + ), + ess_target_semantics=( + "mean normalized ESS over each sequential remaining pool" + ), + ), + source=_source(), + source_checkpoint=checkpoint, + source_checkpoint_sha256=checkpoint_sha, + environment=environment, + frozen_parameters=frozen_parameters, + visual_encoder_sha=visual_encoder_sha, + history=history, + ) + _write_json(os.path.join(outdir, "COMPLETE.json"), manifest) + return manifest + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--alpha", type=float, choices=ALPHAS, required=True) + parser.add_argument( + "--exposure-epochs", + type=int, + choices=EXPOSURE_EPOCHS, + required=True, + ) + parser.add_argument("--rounds", type=int, default=10) + parser.add_argument("--verifier-workers", type=int, default=8) + parser.add_argument("--seed", type=int, default=20260724) + parser.add_argument("--device", default="cuda") + parser.add_argument("--smoke", action="store_true") + args = parser.parse_args(argv) + cfg = OfflineConfig( + alpha=args.alpha, + exposure_epochs=args.exposure_epochs, + rounds=args.rounds, + verifier_workers=args.verifier_workers, + seed=args.seed, + smoke=args.smoke, + ) + run(args.checkpoint, args.outdir, cfg, device=args.device) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_replay.py b/overnight_run_07_12_sfm/sfm_b1_offline_replay.py new file mode 100644 index 0000000..607bb63 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_offline_replay.py @@ -0,0 +1,295 @@ +"""Minibatch replay for one offline executed-window round. + +One exposure epoch visits every resolved executed positive and negative exactly +once. Adam steps after each mixed minibatch, rather than after accumulating a +single full-dataset gradient. +""" +from __future__ import annotations + +import math +import random + +import numpy as np +import torch + +import sfm_b1_r2_alpha_replay as R2 +import sfm_b1_store as BS +import sfm_b1_offline_store as OS + + +def _set_seed(seed): + seed = int(seed) + random.seed(seed) + np.random.seed(seed % (2 ** 32)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def _identity(record): + shard, window = record + return int(shard.round_i), int(window["window_id"]) + + +def _proportional_interleave(positive, negative): + """Spread both deterministic sign orders over the complete epoch.""" + positive = list(positive) + negative = list(negative) + p_index = n_index = 0 + merged = [] + while p_index < len(positive) or n_index < len(negative): + p_progress = ( + (p_index + 1) / len(positive) + if p_index < len(positive) else float("inf") + ) + n_progress = ( + (n_index + 1) / len(negative) + if n_index < len(negative) else float("inf") + ) + if p_progress <= n_progress: + merged.append(positive[p_index]) + p_index += 1 + else: + merged.append(negative[n_index]) + n_index += 1 + return merged + + +def stratified_batches(shard, *, batch, seed): + positives = BS.hierarchical_order(OS.positive_records(shard), int(seed)) + negatives = BS.hierarchical_order(OS.negative_records(shard), int(seed) + 1) + merged = _proportional_interleave(positives, negatives) + batches = [ + merged[start:start + int(batch)] + for start in range(0, len(merged), int(batch)) + ] + identities = [_identity(record) for values in batches for record in values] + expected = [_identity(record) for record in positives + negatives] + if len(identities) != len(set(identities)) or set(identities) != set(expected): + raise RuntimeError("offline minibatch replay duplicated or omitted support") + return batches, positives, negatives + + +def _weighted_loss(policy, records, mass, population, device): + if not records: + return None + grid, low, hist, controls = BS._tensor_batch(records, device) + context = policy.ctx_from(grid, low, hist) + weights = torch.as_tensor([ + int(population) * mass[(id(shard), int(window["query_id"]))] + for shard, window in records + ], dtype=controls.dtype, device=controls.device) + return policy.cfm_loss(controls, context, weights=weights) + + +def _finite_trainable(policy): + return all( + bool(torch.isfinite(parameter).all()) + for parameter in policy.parameters() + if parameter.requires_grad + ) + + +def _one_batch( + policy, optimizer, values, *, positive_mass, negative_mass, + positive_population, negative_population, alpha, device, seed, +): + positive = [record for record in values if int(record[1]["y"]) == 1] + negative = [record for record in values if int(record[1]["y"]) == 0] + _set_seed(seed) + optimizer.zero_grad(set_to_none=True) + positive_loss = _weighted_loss( + policy, positive, positive_mass, positive_population, device, + ) + if positive_loss is None: + return dict( + stepped=False, positive_loss=None, negative_loss=None, rho=0.0, + positive_norm=0.0, negative_norm=0.0, gradient_cosine=None, + positive=len(positive), negative=len(negative), + ) + if not bool(torch.isfinite(positive_loss)): + raise FloatingPointError("non-finite positive CFM loss") + positive_loss.backward() + positive_gradient = BS._gradient_snapshot(policy) + positive_norm = BS._gradient_norm(positive_gradient) + + negative_loss = None + negative_gradient = {} + negative_norm = 0.0 + rho = 0.0 + cosine = None + if float(alpha) > 0.0 and negative: + optimizer.zero_grad(set_to_none=True) + negative_loss = _weighted_loss( + policy, negative, negative_mass, negative_population, device, + ) + if not bool(torch.isfinite(negative_loss)): + raise FloatingPointError("non-finite negative CFM loss") + negative_loss.backward() + negative_gradient = BS._gradient_snapshot(policy) + negative_norm = BS._gradient_norm(negative_gradient) + rho = float(alpha) * positive_norm / (negative_norm + 1.0e-12) + cosine = R2._gradient_cosine(positive_gradient, negative_gradient) + + for name, parameter in policy.named_parameters(): + if not parameter.requires_grad: + continue + pos = positive_gradient.get(name) + neg = negative_gradient.get(name) + if pos is None and neg is None: + parameter.grad = None + elif pos is None: + parameter.grad = -rho * neg + elif neg is None: + parameter.grad = pos + else: + parameter.grad = pos - rho * neg + if parameter.grad is not None and not bool(torch.isfinite(parameter.grad).all()): + raise FloatingPointError(f"non-finite gradient in {name}") + optimizer.step() + if not _finite_trainable(policy): + raise FloatingPointError("optimizer produced non-finite parameters") + return dict( + stepped=True, + positive_loss=float(positive_loss.detach()), + negative_loss=( + None if negative_loss is None else float(negative_loss.detach()) + ), + rho=float(rho), + positive_norm=float(positive_norm), + negative_norm=float(negative_norm), + gradient_cosine=cosine, + positive=len(positive), + negative=len(negative), + ) + + +def _summary(values): + finite = [float(value) for value in values if value is not None] + if not finite: + return None + return dict( + mean=float(np.mean(finite)), + first=finite[0], + last=finite[-1], + minimum=min(finite), + maximum=max(finite), + ) + + +def replay( + policy, optimizer, shard, *, alpha, exposure_epochs, batch, device, seed, +): + if float(alpha) not in (0.0, 0.01, 0.1): + raise ValueError("alpha must be one of {0,0.01,0.1}") + if int(exposure_epochs) not in (1, 10, 100): + raise ValueError("exposure_epochs must be one of {1,10,100}") + policy.train() + positives = OS.positive_records(shard) + negatives = OS.negative_records(shard) + positive_mass, positive_mass_accounting = BS.hierarchy_mass(positives) + negative_mass, negative_mass_accounting = BS.hierarchy_mass(negatives) + probe_seed = int(seed) + 9_000_001 + fixed_probe_before = dict( + positive=R2._fixed_probe_loss( + policy, positives, batch=batch, device=device, seed=probe_seed, + ), + negative=R2._fixed_probe_loss( + policy, negatives, batch=batch, device=device, seed=probe_seed + 1, + ), + ) + module_before = R2._module_snapshot(policy) + encoder_before = BS.module_sha256(policy.enc_grid) + epoch_rows = [] + total_steps = 0 + for epoch_i in range(int(exposure_epochs)): + epoch_seed = int(seed) + epoch_i * 100_003 + batches, positive_order, negative_order = stratified_batches( + shard, batch=batch, seed=epoch_seed, + ) + batch_rows = [] + for batch_i, values in enumerate(batches): + row = _one_batch( + policy, optimizer, values, + positive_mass=positive_mass, + negative_mass=negative_mass, + positive_population=len(positives), + negative_population=len(negatives), + alpha=alpha, + device=device, + seed=epoch_seed + batch_i, + ) + batch_rows.append(row) + total_steps += int(row["stepped"]) + positive_visits = sum(row["positive"] for row in batch_rows) + negative_visits = sum(row["negative"] for row in batch_rows) + if positive_visits != len(positive_order): + raise RuntimeError("positive exposure count mismatch") + if negative_visits != len(negative_order): + raise RuntimeError("negative exposure count mismatch") + epoch_rows.append(dict( + epoch=epoch_i + 1, + seed=epoch_seed, + batches=len(batches), + optimizer_steps=sum(int(row["stepped"]) for row in batch_rows), + positive_visits=positive_visits, + negative_visits=negative_visits, + positive_loss=_summary(row["positive_loss"] for row in batch_rows), + negative_loss=_summary(row["negative_loss"] for row in batch_rows), + rho=_summary(row["rho"] for row in batch_rows), + positive_norm=_summary(row["positive_norm"] for row in batch_rows), + negative_norm=_summary(row["negative_norm"] for row in batch_rows), + gradient_cosine=_summary( + row["gradient_cosine"] for row in batch_rows + ), + )) + + encoder_after = BS.module_sha256(policy.enc_grid) + if encoder_after != encoder_before: + raise RuntimeError("visual encoder changed during offline replay") + fixed_probe_after = dict( + positive=R2._fixed_probe_loss( + policy, positives, batch=batch, device=device, seed=probe_seed, + ), + negative=R2._fixed_probe_loss( + policy, negatives, batch=batch, device=device, seed=probe_seed + 1, + ), + ) + expected_batches = ( + math.ceil((len(positives) + len(negatives)) / int(batch)) + if positives or negatives else 0 + ) + if any(row["batches"] != expected_batches for row in epoch_rows): + raise RuntimeError("unexpected minibatch count") + expected_steps = expected_batches * int(exposure_epochs) if positives else 0 + if total_steps != expected_steps: + raise RuntimeError( + f"expected {expected_steps} Adam steps, observed {total_steps}" + ) + policy.eval() + return dict( + alpha=float(alpha), + exposure_epochs=int(exposure_epochs), + positive_eligible=len(positives), + negative_eligible=len(negatives), + total_eligible=len(positives) + len(negatives), + batches_per_epoch=expected_batches, + optimizer_steps=total_steps, + positive_total_visits=len(positives) * int(exposure_epochs), + negative_total_visits=len(negatives) * int(exposure_epochs), + negative_used_for_training=bool(float(alpha) > 0.0 and negatives), + alpha_zero_semantics=( + "D- remains stored and occupies its deterministic mixed-minibatch " + "slots, but contributes exactly zero gradient when alpha=0" + ), + fixed_probe=dict(before=fixed_probe_before, after=fixed_probe_after), + module_relative_parameter_drift=R2._module_relative_drift( + module_before, R2._module_snapshot(policy), + ), + visual_encoder_sha_before=encoder_before, + visual_encoder_sha_after=encoder_after, + positive_mass=R2._compact_mass(positive_mass_accounting), + negative_mass=R2._compact_mass(negative_mass_accounting), + exact_once_per_exposure_epoch=True, + epochs=epoch_rows, + ) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_store.py b/overnight_run_07_12_sfm/sfm_b1_offline_store.py new file mode 100644 index 0000000..1c81c72 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_offline_store.py @@ -0,0 +1,223 @@ +"""Round-sharded store for offline executed-window SFM expansion. + +Each context contributes at most one resolved H=10 plan: the plan whose first +action was physically executed. The unexecuted B-1 verifier queries are search +diagnostics and never enter this store. +""" +from __future__ import annotations + +import hashlib +import json +import os + +import numpy as np +import torch + + +class ExecutedRoundShard: + VERSION = 1 + + def __init__(self, round_i): + self.round_i = int(round_i) + self.contexts = [] + self.windows = [] + self.errors = [] + self._context_keys = set() + self._window_contexts = set() + + def add_context( + self, *, scenario_id, gamma, step, state, hp10, low5, hist, + ped_xy, ped_vel, + ): + key = (int(scenario_id), round(float(gamma), 8), int(step)) + if key in self._context_keys: + raise ValueError(f"context already stored: {key}") + self._context_keys.add(key) + context_id = len(self.contexts) + self.contexts.append(dict( + context_id=context_id, + round=self.round_i, + scenario_id=int(scenario_id), + gamma=float(gamma), + step=int(step), + state=np.asarray(state, np.float32), + hp10=np.asarray(hp10, np.float32), + low5=np.asarray(low5, np.float32), + hist=np.asarray(hist, np.float32), + ped_xy=np.asarray(ped_xy, np.float32), + ped_vel=np.asarray(ped_vel, np.float32), + )) + return context_id + + def add_executed_window( + self, context_id, controls, result, *, execution_source, nvp_context, + candidate_id=None, acquisition_step=None, sigma=None, hp_margin=None, + mode=None, + ): + if not bool(result.get("resolved")): + raise ValueError("unresolved executed verifier result cannot enter D") + if int(result.get("y", -1)) not in (0, 1): + raise ValueError("resolved executed window needs binary y") + if not bool(result.get("full_h")) or int(result.get("terminal_step", -1)) != 10: + raise ValueError("offline training D requires exact full-H=10 labels") + controls = np.asarray(controls, np.float32) + if tuple(controls.shape) != (10, 2) or not np.isfinite(controls).all(): + raise ValueError("offline training D requires finite controls [10,2]") + components = ( + bool(result.get("taskspace")), + bool(result.get("collision_free")), + bool(result.get("certificate")), + ) + if int(result["y"]) != int(all(components)): + raise ValueError("verifier y disagrees with its exact components") + context_id = int(context_id) + if not 0 <= context_id < len(self.contexts): + raise IndexError("unknown context") + if context_id in self._window_contexts: + raise ValueError("a context can contribute at most one executed window") + self._window_contexts.add(context_id) + window_id = len(self.windows) + self.windows.append(dict( + window_id=window_id, + query_id=window_id, + context_id=context_id, + controls=controls, + y=int(result["y"]), + taskspace=bool(result["taskspace"]), + collision_free=bool(result["collision_free"]), + certificate=bool(result["certificate"]), + full_h=True, + terminal_step=10, + train_eligible=bool(result["y"]), + execution_source=str(execution_source), + nvp_context=bool(nvp_context), + candidate_id=None if candidate_id is None else int(candidate_id), + acquisition_step=( + None if acquisition_step is None else int(acquisition_step) + ), + sigma=None if sigma is None else float(sigma), + hp_margin=None if hp_margin is None else float(hp_margin), + mode=mode, + verifier_diagnostics=dict(result["diagnostics"]), + )) + return window_id + + def add_error(self, *, context_id, candidate_id, execution_source, error): + self.errors.append(dict( + context_id=int(context_id), + candidate_id=None if candidate_id is None else int(candidate_id), + execution_source=str(execution_source), + error=str(error), + )) + + @property + def D(self): + return list(self.windows) + + @property + def Dplus(self): + return [row for row in self.windows if row["y"] == 1] + + @property + def Dminus(self): + return [row for row in self.windows if row["y"] == 0] + + def validate(self): + for expected, context in enumerate(self.contexts): + if int(context["context_id"]) != expected: + raise AssertionError("context IDs are not dense") + seen = set() + for expected, window in enumerate(self.windows): + if ( + int(window["window_id"]) != expected + or int(window["query_id"]) != expected + ): + raise AssertionError("window IDs are not dense") + context_id = int(window["context_id"]) + if not 0 <= context_id < len(self.contexts): + raise AssertionError("window references missing context") + if context_id in seen: + raise AssertionError("multiple training windows share one context") + seen.add(context_id) + if not window["full_h"] or int(window["terminal_step"]) != 10: + raise AssertionError("non-H10 window entered training D") + if len(self.Dplus) + len(self.Dminus) != len(self.D): + raise AssertionError("D+/D- must exactly partition resolved executed D") + return dict( + round=self.round_i, + contexts=len(self.contexts), + D=len(self.D), + Dplus=len(self.Dplus), + Dminus=len(self.Dminus), + errors=len(self.errors), + unresolved_contexts=len(self.contexts) - len(self.D), + ) + + def save(self, path): + path = os.path.abspath(os.fspath(path)) + summary = self.validate() + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = path + ".tmp" + torch.save(dict( + version=self.VERSION, + round=self.round_i, + contexts=self.contexts, + windows=self.windows, + errors=self.errors, + summary=summary, + ), temporary) + os.replace(temporary, path) + digest = sha256_file(path) + complete = path + ".COMPLETE.json" + with open(complete + ".tmp", "w") as stream: + json.dump(dict( + status="OFFLINE_EXECUTED_ROUND_SHARD_COMPLETE", + file=path, + sha256=digest, + **summary, + ), stream, indent=2) + os.replace(complete + ".tmp", complete) + return dict(path=path, sha256=digest, complete=complete, **summary) + + @classmethod + def load(cls, path): + payload = torch.load(path, map_location="cpu", weights_only=False) + if int(payload["version"]) != cls.VERSION: + raise ValueError("unsupported executed-round shard version") + value = cls(payload["round"]) + value.contexts = payload["contexts"] + value.windows = payload["windows"] + value.errors = payload["errors"] + value._context_keys = { + ( + int(row["scenario_id"]), + round(float(row["gamma"]), 8), + int(row["step"]), + ) + for row in value.contexts + } + value._window_contexts = { + int(row["context_id"]) for row in value.windows + } + value.validate() + return value + + +def context_for(shard, window): + return shard.contexts[int(window["context_id"])] + + +def positive_records(shard): + return [(shard, row) for row in shard.Dplus] + + +def negative_records(shard): + return [(shard, row) for row in shard.Dminus] + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/overnight_run_07_12_sfm/sfm_metrics2.py b/overnight_run_07_12_sfm/sfm_metrics2.py index 5facd41..5efc946 100644 --- a/overnight_run_07_12_sfm/sfm_metrics2.py +++ b/overnight_run_07_12_sfm/sfm_metrics2.py @@ -202,6 +202,41 @@ def certify_moving_window(segment, pedestrians, gamma, *, K=ARTIFICIAL_FACES, ) +def _verify_window(state, controls, ped_xy, ped_vel, gamma): + robot = rollout_positions(state, controls) + pedestrian = predict_pedestrians(ped_xy, ped_vel, H=len(controls)) + task = taskspace_ok(robot) + collision = collision_free_time_indexed(robot, pedestrian) + certificate, faces, diagnostics = certify_moving_window( + robot, pedestrian, gamma, + ) + y = bool(task and collision and certificate) + return dict( + resolved=True, error=None, y=int(y), taskspace=bool(task), + collision_free=bool(collision), certificate=bool(certificate), + segment=robot, pedestrian_prediction=pedestrian, faces=faces, + diagnostics=diagnostics, + ) + + +def verify_executed_window(state, controls, ped_xy, ped_vel, gamma): + """Certify one terminal-truncated executed window of length 1 through 10. + + This API is for offline trajectory evaluation. A short window is complete + relative to the executed trajectory; it is not a B1 terminal-prefix query + and therefore deliberately has no ``full_h`` or ``train_eligible`` field. + """ + try: + controls = np.asarray(controls, np.float32).reshape(-1, 2) + if not 1 <= len(controls) <= 10: + raise ValueError("executed-window verifier requires 1 <= H_t <= 10") + result = _verify_window(state, controls, ped_xy, ped_vel, gamma) + result.update(window_horizon=len(controls)) + return result + except Exception as error: + return dict(resolved=False, error=f"{type(error).__name__}: {error}") + + def verify_query(state, controls, ped_xy, ped_vel, gamma): """Certify every queried plan over all H=10 transitions. @@ -212,21 +247,12 @@ def verify_query(state, controls, ped_xy, ped_vel, gamma): controls = np.asarray(controls, np.float32).reshape(-1, 2) if len(controls) != 10: raise ValueError("B1 verifier requires H=10") - robot = rollout_positions(state, controls) - pedestrian = predict_pedestrians(ped_xy, ped_vel, H=len(controls)) - task = taskspace_ok(robot) - collision = collision_free_time_indexed(robot, pedestrian) - certificate, faces, diagnostics = certify_moving_window( - robot, pedestrian, gamma, - ) - y = bool(task and collision and certificate) - return dict( - resolved=True, error=None, y=int(y), taskspace=bool(task), - collision_free=bool(collision), certificate=bool(certificate), - full_h=True, terminal_step=len(controls), train_eligible=bool(y), - segment=robot, pedestrian_prediction=pedestrian, faces=faces, - diagnostics=diagnostics, + result = _verify_window(state, controls, ped_xy, ped_vel, gamma) + result.update( + full_h=True, terminal_step=len(controls), + train_eligible=bool(result["y"]), ) + return result except Exception as error: # worker boundary: a failed solver/query enters no store. return dict(resolved=False, error=f"{type(error).__name__}: {error}") From 9a6595e4cfe723df0ce18ebcb785781825852d6a Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 19:20:19 -0700 Subject: [PATCH 06/31] Queue offline SFM sweep after exclusive GPU smoke --- .../run_sfm_b1_offline_queue.sh | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100755 overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh new file mode 100755 index 0000000..68966c8 --- /dev/null +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 CHECKPOINT SMOKE_OUTDIR FULL_OUTDIR" >&2 + exit 2 +fi + +CHECKPOINT="$(realpath "$1")" +SMOKE_OUTDIR="$(realpath -m "$2")" +FULL_OUTDIR="$(realpath -m "$3")" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="${PYTHON:-python}" +EXPECTED_SHA="1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" +POLL_SECONDS="${POLL_SECONDS:-20}" +IDLE_POLLS_REQUIRED="${IDLE_POLLS_REQUIRED:-3}" +LOG="${QUEUE_LOG:-${FULL_OUTDIR}.queue.log}" + +mkdir -p "$(dirname "$LOG")" +exec >>"$LOG" 2>&1 + +echo "$(date -Is) QUEUE_START" +echo "source=$(git -C "$HERE/.." rev-parse HEAD)" +echo "checkpoint=$CHECKPOINT" +echo "smoke_outdir=$SMOKE_OUTDIR" +echo "full_outdir=$FULL_OUTDIR" + +if [[ ! -f "$CHECKPOINT" ]]; then + echo "checkpoint does not exist: $CHECKPOINT" >&2 + exit 1 +fi +if [[ "$(sha256sum "$CHECKPOINT" | awk '{print $1}')" != "$EXPECTED_SHA" ]]; then + echo "checkpoint SHA-256 mismatch" >&2 + exit 1 +fi +if [[ -e "$SMOKE_OUTDIR" || -e "$FULL_OUTDIR" ]]; then + echo "output roots must both be absent" >&2 + exit 1 +fi + +if [[ -n "${CONDA_PREFIX:-}" && -d "$CONDA_PREFIX/lib" ]]; then + export LD_LIBRARY_PATH="$CONDA_PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi +export PYTHONPATH="$HERE${PYTHONPATH:+:$PYTHONPATH}" +export CUDA_DEVICE_ORDER=PCI_BUS_ID + +idle_polls=0 +while (( idle_polls < IDLE_POLLS_REQUIRED )); do + process_count="$( + nvidia-smi --query-compute-apps=pid --format=csv,noheader | + sed '/^[[:space:]]*$/d' | wc -l + )" + bad_gpu_count="$( + nvidia-smi \ + --query-gpu=memory.used,utilization.gpu \ + --format=csv,noheader,nounits | + awk -F, '{if ($1+0 > 1024 || $2+0 > 5) bad++} END {print bad+0}' + )" + gpu_count="$( + nvidia-smi --query-gpu=index --format=csv,noheader,nounits | wc -l + )" + if [[ "$gpu_count" -eq 4 && "$process_count" -eq 0 && "$bad_gpu_count" -eq 0 ]]; then + idle_polls=$((idle_polls + 1)) + echo "$(date -Is) IDLE_CONFIRMATION ${idle_polls}/${IDLE_POLLS_REQUIRED}" + else + idle_polls=0 + echo "$(date -Is) GPU_BUSY processes=$process_count bad_gpus=$bad_gpu_count" + fi + if (( idle_polls < IDLE_POLLS_REQUIRED )); then + sleep "$POLL_SECONDS" + fi +done + +cd "$HERE" +echo "$(date -Is) SMOKE_START" +CUDA_VISIBLE_DEVICES=0 "$PYTHON" sfm_b1_offline_exec.py \ + --checkpoint "$CHECKPOINT" \ + --outdir "$SMOKE_OUTDIR" \ + --alpha 0.01 \ + --exposure-epochs 1 \ + --rounds 1 \ + --verifier-workers 32 \ + --seed 20260724 \ + --device cuda:0 \ + --smoke + +"$PYTHON" - "$SMOKE_OUTDIR" <<'PY' +import json +import math +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +with (root / "COMPLETE.json").open() as stream: + payload = json.load(stream) +assert payload["status"] == "SFM_B1_OFFLINE_EXEC_COMPLETE" +assert payload["source"]["tracked_worktree_clean"] is True +assert len(payload["history"]) == 1 +row = payload["history"][0] +summary = row["gather"]["shard"] +assert summary["D"] == summary["contexts"] +assert summary["D"] == summary["Dplus"] + summary["Dminus"] +assert summary["errors"] == summary["unresolved_contexts"] == 0 +assert row["gather"]["counts"]["B_queries"] == 4 * summary["contexts"] +assert row["replay"]["optimizer_steps"] == math.ceil(summary["D"] / 128) +assert row["replay"]["positive_total_visits"] == summary["Dplus"] +assert row["replay"]["negative_total_visits"] == summary["Dminus"] +print(json.dumps({ + "status": "SMOKE_VALIDATED", + "contexts": summary["contexts"], + "Dplus": summary["Dplus"], + "Dminus": summary["Dminus"], + "NVP": row["gather"]["counts"].get("NVP_contexts", 0), + "optimizer_steps": row["replay"]["optimizer_steps"], + "wall_seconds": row["wall_seconds"], +}, sort_keys=True)) +PY + +echo "$(date -Is) SMOKE_VALIDATED_FULL_START" +"$PYTHON" run_sfm_b1_offline_9arm.py \ + --checkpoint "$CHECKPOINT" \ + --expected-checkpoint-sha256 "$EXPECTED_SHA" \ + --outdir "$FULL_OUTDIR" \ + --gpu-indices 0,1,2,3 \ + --verifier-workers 8 \ + --seed 20260724 \ + --eval-ep0 260000 \ + --eval-noise-seed 20260723 +echo "$(date -Is) FULL_DELIVERY_COMPLETE" From 3abd25874c77008a4a6307346983a3dcb0716f8b Mon Sep 17 00:00:00 2001 From: dohyun Date: Thu, 23 Jul 2026 19:24:18 -0700 Subject: [PATCH 07/31] Enforce strict gamma-balanced GP support --- .../test_sfm_b1_offline_store_replay.py | 18 +++++++ .../run_sfm_b1_offline_9arm.py | 22 +++++--- .../sfm_b1_offline_exec.py | 50 +++++++++---------- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py index 30dd5ec..9a13a51 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py @@ -300,3 +300,21 @@ def test_gp_cap_512_has_equal_gamma_quota_and_rotating_extra(): expected_round_3 = 74 if gamma == 0.2 else 73 assert report_round_2["per_gamma"][str(gamma)] == expected_round_2 assert report_round_3["per_gamma"][str(gamma)] == expected_round_3 + + +def test_gp_quota_fails_closed_instead_of_redistributing_gamma_shortfall(): + shard = OS.ExecutedRoundShard(1) + for gamma_index, gamma in enumerate(OE.SP.GAMMAS): + count = 72 if gamma == 0.1 else 74 + for sample_index in range(count): + _add_window( + shard, + scenario=2_000 + gamma_index, + gamma=gamma, + step=sample_index, + y=1, + ) + with pytest.raises(RuntimeError, match="strict gamma-balanced GP quota"): + OE._gamma_balanced_records( + shard, cap=512, round_i=2, seed=21, + ) diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index a264962..488187e 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -297,9 +297,8 @@ def validate_training_arm( "expected_checkpoint_sha256": CHECKPOINT_SHA256, "replay_window_rounds": 1, "gp_quota_semantics": ( - "73 executed D+ rows per gamma plus one rotating extra when " - "support permits; any support shortage is logged and the " - "unused capacity is deterministically redistributed" + "exactly 73 executed D+ rows per gamma plus one rotating " + "extra; any support shortage aborts the scientific round" ), "ess_target_semantics": ( "mean normalized ESS over each sequential remaining pool" @@ -342,10 +341,7 @@ def validate_training_arm( if row.get("checkpoint_sha256") != checkpoints[round_i]["sha256"]: raise RuntimeError(f"round checkpoint digest mismatch: {marker}") gp_selection = row.get("gp_selection", {}) - expected_gp_count = ( - 0 if round_i == 1 - else min(CAP, int(history[round_i - 2]["shard"]["Dplus"])) - ) + expected_gp_count = 0 if round_i == 1 else CAP per_gamma_gp = gp_selection.get("per_gamma", {}) if ( int(gp_selection.get("requested_cap", -1)) != CAP @@ -361,6 +357,18 @@ def validate_training_arm( raise RuntimeError(f"previous-round GP contract mismatch in round {round_i}") if round_i > 1 and gp_selection.get("unique") is not True: raise RuntimeError(f"GP buffer is not unique in round {round_i}") + if round_i > 1: + extra_gamma = ( + 0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0 + )[(round_i - 2) % 7] + expected_per_gamma = { + str(gamma): 73 + int(gamma == extra_gamma) + for gamma in (0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0) + } + if per_gamma_gp != expected_per_gamma: + raise RuntimeError( + f"strict gamma GP quota mismatch in round {round_i}" + ) if "outcomes" in row: raise RuntimeError("outcomes must be stored only inside gather") shard = row.get("shard", {}) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py index 565a95b..ef81c2d 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -180,28 +180,26 @@ def _gamma_balanced_records(previous, *, cap, round_i, seed): records, int(seed) + gamma_index, ) quota = int(cap) // len(SP.GAMMAS) - selected = [] - remaining = {} - shortfall = {} - for gamma in SP.GAMMAS: - values = groups[float(gamma)] - take = min(quota, len(values)) - selected.extend(values[:take]) - remaining[float(gamma)] = values[take:] - shortfall[str(gamma)] = quota - take rotation = (int(round_i) - 2) % len(SP.GAMMAS) - order = [ - float(SP.GAMMAS[(rotation + offset) % len(SP.GAMMAS)]) - for offset in range(len(SP.GAMMAS)) + extra_gamma = float(SP.GAMMAS[rotation]) + required = { + float(gamma): quota + int(float(gamma) == extra_gamma) + for gamma in SP.GAMMAS + } + shortfall = { + str(gamma): max(0, required[float(gamma)] - len(groups[float(gamma)])) + for gamma in SP.GAMMAS + } + if any(shortfall.values()): + raise RuntimeError( + "strict gamma-balanced GP quota is unavailable; " + f"required={required}, shortfall={shortfall}" + ) + selected = [ + record + for gamma in SP.GAMMAS + for record in groups[float(gamma)][:required[float(gamma)]] ] - while len(selected) < int(cap) and any(remaining.values()): - progressed = False - for gamma in order: - if remaining[gamma] and len(selected) < int(cap): - selected.append(remaining[gamma].pop(0)) - progressed = True - if not progressed: - break per_gamma = Counter( str(previous.contexts[int(row["context_id"])]["gamma"]) for _, row in selected @@ -211,16 +209,15 @@ def _gamma_balanced_records(previous, *, cap, round_i, seed): ] if len(identities) != len(set(identities)): raise RuntimeError("GP buffer selection contains duplicates") - expected = min(int(cap), len(previous.Dplus)) - if len(selected) != expected: + if len(selected) != int(cap): raise RuntimeError( - f"expected {expected} GP records, selected {len(selected)}" + f"expected {cap} GP records, selected {len(selected)}" ) return selected, dict( requested_cap=int(cap), selected=len(selected), quota=quota, - rotating_extra_gamma=float(order[0]), + rotating_extra_gamma=extra_gamma, per_gamma={str(gamma): int(per_gamma[str(gamma)]) for gamma in SP.GAMMAS}, shortfall=shortfall, unique=True, @@ -790,9 +787,8 @@ def run(checkpoint, outdir, cfg, *, device): expected_checkpoint_sha256=EXPECTED_CHECKPOINT_SHA256, replay_window_rounds=1, gp_quota_semantics=( - "73 executed D+ rows per gamma plus one rotating extra when " - "support permits; any support shortage is logged and the " - "unused capacity is deterministically redistributed" + "exactly 73 executed D+ rows per gamma plus one rotating " + "extra; any support shortage aborts the scientific round" ), ess_target_semantics=( "mean normalized ESS over each sequential remaining pool" From 4c8b41d99500a6920afc4a1f0995eb3cea1cb002 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 03:13:27 -0700 Subject: [PATCH 08/31] Preserve proposal base noise in SFM uncertainty --- .../test_sfm_b1_offline_store_replay.py | 48 ++++- .../run_sfm_b1_offline_9arm.py | 26 ++- .../sfm_b1_d_branch_viz.py | 203 ++++++++++++++++++ .../sfm_b1_offline_exec.py | 117 ++++++++-- .../sfm_b1_offline_store.py | 11 +- overnight_run_today/src/flow_policy.py | 15 ++ 6 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py index 9a13a51..6b2e743 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py @@ -44,9 +44,11 @@ def _add_window(shard, *, scenario, gamma, step, y): shard, scenario=scenario, gamma=gamma, step=step, ) controls = np.full((10, 2), scenario + step / 100.0, np.float32) + x0 = np.full(20, scenario - step / 100.0, np.float32) shard.add_executed_window( context_id, controls, + x0, _result(y), execution_source="selected_B" if y else "raw_continuation", nvp_context=not bool(y), @@ -125,6 +127,7 @@ def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path) shard.add_executed_window( positive_context, np.zeros((10, 2), np.float32), + np.zeros(20, np.float32), _result(1), execution_source="selected_B", nvp_context=False, @@ -136,6 +139,7 @@ def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path) shard.add_executed_window( context_id, np.zeros((10, 2), np.float32), + np.zeros(20, np.float32), _result(1, full_h=False), execution_source="selected_B", nvp_context=False, @@ -147,6 +151,19 @@ def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path) shard.add_executed_window( context_id, np.zeros((9, 2), np.float32), + np.zeros(20, np.float32), + _result(1), + execution_source="selected_B", + nvp_context=False, + ) + with pytest.raises(ValueError, match=r"original x0 \[20\]"): + context_id = _context( + shard, scenario=15, gamma=0.5, step=6, + ) + shard.add_executed_window( + context_id, + np.zeros((10, 2), np.float32), + np.zeros(19, np.float32), _result(1), execution_source="selected_B", nvp_context=False, @@ -159,12 +176,12 @@ def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path) assert {row["context_id"] for row in shard.D} == {0, 1} assert shard.validate() == { "round": 3, - "contexts": 4, + "contexts": 5, "D": 2, "Dplus": 1, "Dminus": 1, "errors": 0, - "unresolved_contexts": 2, + "unresolved_contexts": 3, } path = tmp_path / "round_003.pt" @@ -183,6 +200,33 @@ def test_executed_store_has_one_window_per_context_and_exact_partition(tmp_path) np.testing.assert_array_equal( restored.Dminus[0]["controls"], shard.Dminus[0]["controls"], ) + np.testing.assert_array_equal( + restored.Dminus[0]["x0"], shard.Dminus[0]["x0"], + ) + + +def test_phi_s_from_x0_is_the_exact_noised_representation(): + from flow_policy import FlowPolicy + + torch.manual_seed(8) + policy = FlowPolicy(T=10, ctx_dim=3, width=12, depth=2, u_max=2.0) + controls = torch.randn(2, 10, 2) + context = torch.randn(2, 3) + x0 = torch.randn(2, 20) + s = 0.9 + + actual = policy.phi_s_from_x0(controls, context, x0, s=s) + x1 = (controls / policy.u_max).reshape(2, 20) + expected = policy.features( + (1 - s) * x0 + s * x1, + torch.full((2,), s), + context, + ) + torch.testing.assert_close(actual, expected) + assert not torch.equal( + actual, + policy.phi_s_from_x0(controls, context, x0.flip(0), s=s), + ) def test_stratified_batches_are_deterministic_and_exact_once(): diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index 488187e..9e361da 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -41,7 +41,7 @@ "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" ) SCENE_PROFILE = "double_density_velocity_ood" -ELL = 0.24210826720721101 +ELL_MULTIPLIER = 0.5 CAP = 512 GP_LAMBDA = 1.0e-2 K = 16 @@ -290,8 +290,24 @@ def validate_training_arm( } if payload.get("recipe") != expected_recipe: raise RuntimeError(f"training recipe mismatch: {marker}") + constants = payload.get("constants", {}) + ell0 = float(constants.get("ell0", -1.0)) + ell = float(constants.get("ell", -1.0)) + if ( + not math.isfinite(ell0) + or ell0 <= 0.0 + or not math.isclose( + ell, ell0 * ELL_MULTIPLIER, rel_tol=1.0e-12, abs_tol=1.0e-12, + ) + or constants.get("ell_preflight", {}).get("count") != 50 + or constants.get("ell_preflight", {}).get("representation") + != "stored proposal x0 at s=0.9" + ): + raise RuntimeError(f"invalid x0-aware ell preflight: {marker}") expected_constants = { - "ell": ELL, + "ell": ell, + "ell0": ell0, + "ell_preflight": constants["ell_preflight"], "gp_buffer_cap": CAP, "gp_lambda": GP_LAMBDA, "expected_checkpoint_sha256": CHECKPOINT_SHA256, @@ -779,7 +795,11 @@ def run(args) -> dict: "B": B, "T": T, "H": H, - "ell": ELL, + "ell_initialization": { + "count": 50, + "multiplier": ELL_MULTIPLIER, + "representation": "stored proposal x0 at s=0.9", + }, "cap": CAP, "gp_lambda": GP_LAMBDA, "batch": BATCH, diff --git a/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py new file mode 100644 index 0000000..e62cbe3 --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py @@ -0,0 +1,203 @@ +"""Render the one planned H=10 D sample attached to every executed context. + +Each thin blue/red branch is the exact H=10 window whose first action advanced +the offline collector at that context. The thick black path joins those first +actions. Current K/B queries and the current executed-window verifier geometry +remain visible so the branch origin can be audited. +""" +from __future__ import annotations + +import argparse +import json +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.animation as animation +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +import numpy as np +import torch + +import _paths # noqa: F401 +import sfm_b1_density_viz as DV +import sfm_b1_full_episode_viz as FV +import sfm_b1_viz as BV +import sfm_metrics2 as SM +import sfm_scene as SS + + +def _branch(trace): + result = trace["executed_result"] + path = np.asarray(result.get("segment", ()), float) + if path.shape != (11, 2): + path = SM.rollout_positions( + np.asarray(trace["state"], float), + np.asarray(trace["executed_controls"], float), + ) + return path + + +def _draw_D_branches(axis, rows, step): + available = sorted(value for value in rows if value <= int(step)) + for context_step in available: + trace = rows[context_step] + path = _branch(trace) + color = FV._executed_color(trace) + is_current = context_step == available[-1] + axis.plot( + path[:, 0], path[:, 1], + color=color, + lw=1.25 if is_current else .55, + marker=".", ms=1.3 if is_current else .75, + alpha=.9 if is_current else .32, + zorder=7 if is_current else 3, + ) + axis.plot( + path[0, 0], path[0, 1], marker=".", color=color, + ms=2.7 if is_current else 1.5, zorder=8, + ) + + +def _draw_executed_trajectory(axis, rows, step): + available = sorted(value for value in rows if value <= int(step)) + if not available: + return + states = [np.asarray(rows[value]["state"], float)[:2] for value in available] + states.append(np.asarray(rows[available[-1]]["next_state"], float)[:2]) + states = np.asarray(states) + axis.plot( + states[:, 0], states[:, 1], color="#111111", lw=2.8, + marker=".", ms=2.1, alpha=.97, zorder=11, + ) + axis.annotate( + "", xy=states[-1], xytext=states[-2], + arrowprops=dict(arrowstyle="->", color="#111111", lw=2.2), + zorder=12, + ) + + +def draw_cell(axis, rows, step): + available = [value for value in rows if value <= int(step)] + current_step = max(available) if available else min(rows) + trace = rows[current_step] + BV._draw_common(axis, trace, nominal_levels=False) + _draw_D_branches(axis, rows, current_step) + FV._draw_candidates(axis, trace) + FV._draw_executed(axis, trace) + _draw_executed_trajectory(axis, rows, current_step) + DV._set_clean_axis(axis) + return trace + + +def _legend(): + return [ + Line2D([], [], color=BV.BLUE, lw=1.2, label=r"$D^+$ planned H10 branch"), + Line2D([], [], color=BV.RED, lw=1.2, label=r"$D^-$ planned H10 branch"), + Line2D([], [], color="#111111", lw=2.8, label="executed first-action trajectory"), + Line2D([], [], color=BV.GRAY, lw=.7, label="current K=16 generated"), + Line2D([], [], color=BV.ORANGE, lw=1.1, label="current B=4 RBF queried"), + Line2D([], [], color=BV.GREEN, lw=1.2, label="current B full-H positive"), + Line2D([], [], color=BV.RED, lw=1.2, marker="x", label="current B full-H rejected"), + Line2D([], [], color=BV.GREEN, lw=.7, label="executed verifier levels h=1..10"), + ] + + +def render(trace_path, output_mp4, output_png, output_json, *, fps=5, frame_stride=2): + bundle = torch.load(trace_path, map_location="cpu", weights_only=False) + if bundle.get("status") != "SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE": + raise ValueError("input is not a completed full-episode audit") + scenarios = tuple(map(int, bundle["scenarios"])) + gammas = tuple(map(float, bundle["gammas"])) + if len(scenarios) != 3 or gammas != tuple(map(float, SS.GAMMAS)): + raise ValueError("renderer requires three scenarios and all seven gammas") + index = FV._index(bundle["traces"]) + maximum = max(max(rows) for rows in index.values()) + frames = list(range(0, maximum + 1, int(frame_stride))) + if frames[-1] != maximum: + frames.append(maximum) + + figure, axes = plt.subplots(3, 7, figsize=(23.2, 10.1)) + figure.subplots_adjust( + left=.035, right=.815, bottom=.025, top=.94, wspace=.025, hspace=.04, + ) + for column, gamma in enumerate(gammas): + figure.text( + .035 + (.78 / 7) * (column + .5), .965, f"$\\gamma={gamma:g}$", + ha="center", va="center", fontsize=10, + ) + for row, scenario in enumerate(scenarios): + figure.text( + .012, .94 - (.915 / 3) * (row + .5), f"episode\n{scenario}", + ha="center", va="center", rotation=90, fontsize=9, + ) + figure.legend( + handles=_legend(), loc="center left", bbox_to_anchor=(.825, .59), + frameon=False, fontsize=8, + ) + figure.text( + .825, .26, + "One D sample per context.\n" + "Each branch is the complete planned H10 window;\n" + "only its first action advances the black trajectory.\n" + "Finite-B NVP does not stop this offline collector.", + ha="left", va="top", fontsize=8, + ) + + def update(step): + for row, scenario in enumerate(scenarios): + for column, gamma in enumerate(gammas): + axis = axes[row, column] + axis.clear() + draw_cell(axis, index[(scenario, round(gamma, 8))], int(step)) + return [] + + for path in (output_mp4, output_png, output_json): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + movie = animation.FuncAnimation( + figure, update, frames=frames, interval=1000 / int(fps), blit=False, + ) + movie.save( + output_mp4, writer=animation.FFMpegWriter(fps=int(fps), bitrate=4600), + dpi=105, + ) + update(maximum) + figure.savefig(output_png, dpi=165, bbox_inches="tight") + plt.close(figure) + + report = dict( + status="SFM_B1_D_BRANCH_VIZ_COMPLETE", + trace_path=os.path.abspath(trace_path), + scenarios=list(scenarios), + gammas=list(gammas), + D_semantics=( + "one exact full-H10 planned window per executed context; blue y=1, " + "red y=0; thick black joins executed first actions" + ), + frames=frames, + mp4=os.path.abspath(output_mp4), + png=os.path.abspath(output_png), + ) + with open(output_json + ".tmp", "w") as stream: + json.dump(report, stream, indent=2) + os.replace(output_json + ".tmp", output_json) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--trace", required=True) + parser.add_argument("--output-mp4", required=True) + parser.add_argument("--output-png", required=True) + parser.add_argument("--output-json", required=True) + parser.add_argument("--fps", type=int, default=5) + parser.add_argument("--frame-stride", type=int, default=2) + args = parser.parse_args(argv) + render( + args.trace, args.output_mp4, args.output_png, args.output_json, + fps=args.fps, frame_stride=args.frame_stride, + ) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py index ef81c2d..ba730d9 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -42,7 +42,7 @@ EXPECTED_CHECKPOINT_SHA256 = ( "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" ) -ELL = 0.24210826720721101 +ELL_MULTIPLIER = 0.5 CAP = 512 GP_LAMBDA = 1.0e-2 ALPHAS = (0.0, 0.01, 0.1) @@ -79,10 +79,10 @@ def validate(self): if ( int(self.K), int(self.B), int(self.T), int(self.H), int(self.batch), float(self.lr), float(self.ess_target), - float(self.gp_lam), self.scene_profile, + float(self.gp_lam), float(self.temp), self.scene_profile, ) != ( 16, 4, 180, 10, 128, 1.0e-4, 0.5, - GP_LAMBDA, SCENE_PROFILE, + GP_LAMBDA, 1.0, SCENE_PROFILE, ): raise ValueError("offline executed-window scientific contract changed") expected_rounds = 1 if self.smoke else 10 @@ -146,18 +146,71 @@ def _keyed_windows( latent_parts.append(generator.standard_normal( (int(K), int(policy.d)), dtype=np.float32, )) - latents = torch.as_tensor( + x0 = torch.as_tensor( np.stack(latent_parts), device=contexts.device, dtype=contexts.dtype, - ) * float(temp) + ) + latents = x0 * float(temp) expanded = contexts.repeat_interleave(int(K), dim=0) windows = BE.integrate_latents( policy, latents.reshape(-1, policy.d), expanded, nfe=int(nfe), ) return windows.reshape( len(live), int(K), int(policy.H_pred), 2, - ), contexts + ), contexts, x0 + + +@torch.no_grad() +def _features_from_x0(phi_policy, windows, contexts, x0, s): + if windows.shape[:2] != x0.shape[:2]: + raise ValueError("window and x0 candidate axes disagree") + K = int(windows.shape[1]) + controls = windows.reshape(-1, windows.shape[-2], 2) + expanded_contexts = contexts.repeat_interleave(K, dim=0) + features = phi_policy.phi_s_from_x0( + controls, + expanded_contexts, + x0.reshape(-1, phi_policy.d), + s=float(s), + ) + return BR.l2_normalize(features).reshape(len(contexts), K, -1) + + +@torch.no_grad() +def _initial_lengthscale(policy, replicas, cfg, device): + """Mean pairwise distance of 50 balanced pretrained proposals.""" + live, batch = BX._stack_prepared(replicas, device) + windows, contexts, x0 = _keyed_windows( + policy, live, batch, K=1, round_i=0, step=-2, + source="ell_preflight", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, + ) + features = _features_from_x0( + policy, windows, contexts, x0, cfg.phi_s, + )[:, 0] + groups = { + float(gamma): [ + index for index, replica in enumerate(live) + if round(float(replica.gamma), 8) == round(float(gamma), 8) + ] + for gamma in SP.GAMMAS + } + selected = [ + index + for gamma in SP.GAMMAS + for index in groups[float(gamma)][:7] + ] + selected.append(groups[float(SP.GAMMAS[0])][7]) + if len(selected) != 50 or len(set(selected)) != 50: + raise RuntimeError("ell preflight requires 50 unique balanced proposals") + ell0 = BR.mean_pairwise_lengthscale(features[selected]) + return float(ell0), float(ell0 * ELL_MULTIPLIER), dict( + count=50, + balance="7 per gamma plus one extra gamma=0.1", + proposal_source="pretrained policy at round-1 initial OOD contexts", + representation="stored proposal x0 at s=0.9", + multiplier=ELL_MULTIPLIER, + ) def _gamma_balanced_records(previous, *, cap, round_i, seed): @@ -238,9 +291,13 @@ def gp_from_previous( hp10, low, hist, controls = BX._record_batch( selected[start:start + 256], device, ) - feature_parts.append(phi_policy.phi_s( + x0 = torch.as_tensor(np.stack([ + row["x0"] for _, row in selected[start:start + 256] + ]), device=device).float() + feature_parts.append(phi_policy.phi_s_from_x0( controls, phi_policy.ctx_from(hp10, low, hist), + x0, s=float(phi_s), )) gp.set_buffer(torch.cat(feature_parts)) @@ -255,11 +312,13 @@ def _calibrate_beta( phi_policy, gp, replicas, cfg, device, *, round_i, ): live, batch = BX._stack_prepared(replicas, device) - windows, _ = _keyed_windows( + windows, contexts, x0 = _keyed_windows( phi_policy, live, batch, K=cfg.K, round_i=round_i, step=-1, source="beta_calibration", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, ) - features = BX._features(phi_policy, windows, batch, cfg.phi_s) + features = _features_from_x0( + phi_policy, windows, contexts, x0, cfg.phi_s, + ) vectors = [] for index, (replica, values) in enumerate(zip(live, features)): generator = torch.Generator(device=values.device).manual_seed( @@ -319,25 +378,30 @@ def gather_offline_round( start = time.perf_counter() with torch.no_grad(): - windows, contexts = _keyed_windows( + windows, contexts, x0 = _keyed_windows( policy, live, batch, K=cfg.K, round_i=round_i, step=step, source="K", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, ) - raw_windows, _ = _keyed_windows( + raw_windows, _, raw_x0 = _keyed_windows( policy, live, batch, K=1, round_i=round_i, step=step, source="raw_continuation", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, ) raw_windows = raw_windows[:, 0] + raw_x0 = raw_x0[:, 0] windows_np = windows.detach().cpu().numpy() raw_windows_np = raw_windows.detach().cpu().numpy() + x0_np = x0.detach().cpu().numpy() + raw_x0_np = raw_x0.detach().cpu().numpy() timers["flow_proposal"] += time.perf_counter() - start start = time.perf_counter() with torch.no_grad(): - features = BX._features(phi_policy, windows, batch, cfg.phi_s) - raw_features = BR.l2_normalize(phi_policy.phi_s( - raw_windows, contexts, s=cfg.phi_s, + features = _features_from_x0( + phi_policy, windows, contexts, x0, cfg.phi_s, + ) + raw_features = BR.l2_normalize(phi_policy.phi_s_from_x0( + raw_windows, contexts, raw_x0, s=cfg.phi_s, )) selected_by_context = [] acquisitions = [] @@ -473,6 +537,7 @@ def gather_offline_round( nvp_context = chosen is None if nvp_context: controls = raw_windows_np[context_index] + selected_x0 = raw_x0_np[context_index] result = by_context[context_index][-1] margin, _, _ = BC.nominal_hp_margin( prepared["state"], controls[0], prepared["ped_xy"], @@ -503,6 +568,7 @@ def gather_offline_round( counts["NVP_contexts"] += 1 else: controls = np.asarray(chosen["controls"], np.float32) + selected_x0 = x0_np[context_index, int(chosen["candidate_id"])] result = chosen["result"] margin = float(chosen["hp_margin"]) execution_source = "verified_max_margin" @@ -522,6 +588,7 @@ def gather_offline_round( window_id = shard.add_executed_window( context_id, controls, + selected_x0, result, execution_source=execution_source, nvp_context=nvp_context, @@ -661,6 +728,19 @@ def run(checkpoint, outdir, cfg, *, device): )) history = [] previous_shard = None + preflight_scenarios = SP.expansion_scenarios(1, smoke=cfg.smoke) + preflight_replicas = [ + BX.Replica( + scenario_id, + gamma, + n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id in preflight_scenarios for gamma in SP.GAMMAS + ] + ell0, ell, ell_preflight = _initial_lengthscale( + policy, preflight_replicas, cfg, device, + ) with ProcessPoolExecutor(max_workers=cfg.verifier_workers) as executor: for round_i in range(1, cfg.rounds + 1): round_start = time.perf_counter() @@ -684,7 +764,7 @@ def run(checkpoint, outdir, cfg, *, device): phi_policy, previous_shard, round_i=round_i, - ell=ELL, + ell=ell, cap=CAP, lam=cfg.gp_lam, phi_s=cfg.phi_s, @@ -735,7 +815,8 @@ def run(checkpoint, outdir, cfg, *, device): source_sha256=checkpoint_sha, encoder_sha256=visual_encoder_sha, recipe=asdict(cfg), - ell=ELL, + ell=ell, + ell0=ell0, cap=CAP, beta=float(beta), )) @@ -781,7 +862,9 @@ def run(checkpoint, outdir, cfg, *, device): scientific_role="offline_expansion_data_collector_not_safe_controller", recipe=asdict(cfg), constants=dict( - ell=ELL, + ell=ell, + ell0=ell0, + ell_preflight=ell_preflight, gp_buffer_cap=CAP, gp_lambda=GP_LAMBDA, expected_checkpoint_sha256=EXPECTED_CHECKPOINT_SHA256, diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_store.py b/overnight_run_07_12_sfm/sfm_b1_offline_store.py index 1c81c72..de90c2f 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_store.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_store.py @@ -15,7 +15,7 @@ class ExecutedRoundShard: - VERSION = 1 + VERSION = 2 def __init__(self, round_i): self.round_i = int(round_i) @@ -50,7 +50,7 @@ def add_context( return context_id def add_executed_window( - self, context_id, controls, result, *, execution_source, nvp_context, + self, context_id, controls, x0, result, *, execution_source, nvp_context, candidate_id=None, acquisition_step=None, sigma=None, hp_margin=None, mode=None, ): @@ -63,6 +63,9 @@ def add_executed_window( controls = np.asarray(controls, np.float32) if tuple(controls.shape) != (10, 2) or not np.isfinite(controls).all(): raise ValueError("offline training D requires finite controls [10,2]") + x0 = np.asarray(x0, np.float32) + if tuple(x0.shape) != (20,) or not np.isfinite(x0).all(): + raise ValueError("offline training D requires finite original x0 [20]") components = ( bool(result.get("taskspace")), bool(result.get("collision_free")), @@ -82,6 +85,7 @@ def add_executed_window( query_id=window_id, context_id=context_id, controls=controls, + x0=x0, y=int(result["y"]), taskspace=bool(result["taskspace"]), collision_free=bool(result["collision_free"]), @@ -141,6 +145,9 @@ def validate(self): seen.add(context_id) if not window["full_h"] or int(window["terminal_step"]) != 10: raise AssertionError("non-H10 window entered training D") + x0 = np.asarray(window.get("x0"), np.float32) + if tuple(x0.shape) != (20,) or not np.isfinite(x0).all(): + raise AssertionError("window is missing its finite original x0 [20]") if len(self.Dplus) + len(self.Dminus) != len(self.D): raise AssertionError("D+/D- must exactly partition resolved executed D") return dict( diff --git a/overnight_run_today/src/flow_policy.py b/overnight_run_today/src/flow_policy.py index 40110bd..39066ea 100644 --- a/overnight_run_today/src/flow_policy.py +++ b/overnight_run_today/src/flow_policy.py @@ -97,6 +97,21 @@ def sample(self, n: int, ctx: torch.Tensor, nfe: int = 12, U = (x.reshape(n, self.T, 2) * self.u_max).clamp(-self.u_max, self.u_max) return U + @torch.no_grad() + def phi_s_from_x0( + self, U_controls: torch.Tensor, ctx: torch.Tensor, + x0: torch.Tensor, s: float = 0.9, + ) -> torch.Tensor: + """Noised-flow representation using each proposal's original base noise.""" + B = U_controls.shape[0] + x1 = (U_controls / self.u_max).reshape(B, self.d) + if tuple(x0.shape) != (B, self.d): + raise ValueError(f"x0 shape {tuple(x0.shape)} != {(B, self.d)}") + x0 = x0.to(device=x1.device, dtype=x1.dtype) + x_s = (1 - float(s)) * x0 + float(s) * x1 + tau = torch.full((B,), float(s), device=x1.device) + return self.features(x_s, tau, self._expand_ctx(ctx, B)) + @torch.no_grad() def phi_s(self, U_controls: torch.Tensor, ctx: torch.Tensor, s: float = 0.9) -> torch.Tensor: """Noised-flow representation at level s, averaged over fixed noise templates -> [B, width].""" From f36393b56d49b83b50c6c37eef781c986fbef581 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 13:19:30 -0700 Subject: [PATCH 09/31] Fix signed replay batching and target GPUs 1 and 3 --- .../analysis/test_run_sfm_b1_offline_9arm.py | 12 +++--- .../test_sfm_b1_offline_store_replay.py | 17 ++++++++ .../run_sfm_b1_offline_9arm.py | 43 ++++++++----------- .../run_sfm_b1_offline_queue.sh | 12 +++--- .../sfm_b1_offline_replay.py | 41 +++++++++++++++--- 5 files changed, 83 insertions(+), 42 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index ea447cf..3e8abc9 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -26,7 +26,7 @@ def _gpu(index: int) -> L.BASE.GPU: ) -def test_arm_grid_and_four_gpu_allocation(): +def test_arm_grid_and_two_gpu_allocation(): arms = list(L.arm_grid()) assert len(arms) == 9 assert len({arm.name for arm in arms}) == 9 @@ -37,12 +37,12 @@ def test_arm_grid_and_four_gpu_allocation(): for alpha in L.ALPHAS for epochs in L.EXPOSURE_EPOCHS } - allocation = L.allocate_arms(arms, [_gpu(i) for i in range(4)]) - assert sorted(map(len, allocation.values())) == [2, 2, 2, 3] + allocation = L.allocate_arms(arms, [_gpu(1), _gpu(3)]) + assert sorted(map(len, allocation.values())) == [4, 5] assert set().union(*map(set, allocation.values())) == set(arms) - assert { - arm.exposure_epochs for arm in allocation["GPU-0"] - } == {1} + assert {arm.exposure_epochs for arm in allocation["GPU-1"]} == { + 1, 10, 100, + } def test_output_root_must_be_new_and_under_research1(tmp_path, monkeypatch): diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py index 6b2e743..81b20d5 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_store_replay.py @@ -251,6 +251,23 @@ def test_stratified_batches_are_deterministic_and_exact_once(): assert all(any(record[1]["y"] == 1 for record in batch) for batch in left) +def test_stratified_batches_prevent_negative_only_tail(): + shard = _mixed_shard(positive=5, negative=8) + batches, positives, negatives = OR.stratified_batches( + shard, batch=4, seed=74, + ) + assert len(batches) == math.ceil((len(positives) + len(negatives)) / 4) + assert all(len(values) <= 4 for values in batches) + assert all(any(record[1]["y"] == 1 for record in values) for values in batches) + assert sum(len(values) for values in batches) == len(shard.D) + + +def test_stratified_batches_fail_when_sign_safe_partition_is_impossible(): + shard = _mixed_shard(positive=1, negative=8) + with pytest.raises(RuntimeError, match="positive in every"): + OR.stratified_batches(shard, batch=4, seed=75) + + @pytest.mark.parametrize("exposure_epochs", (1, 10, 100)) def test_replay_exact_exposure_counts_and_adam_step_formula(exposure_epochs): torch.manual_seed(14) diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index 9e361da..625cdeb 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -7,8 +7,8 @@ 2. evaluate every r0--r10 checkpoint with the same raw temperature-one M=50/gamma bank and terminal-truncated executed-window Validity. -All nine jobs in a phase start concurrently on four exclusive GPUs with a -deterministic 3/2/2/2 allocation. Any child failure stops its peers. The +All nine jobs in a phase start concurrently on two exclusive GPUs with a +deterministic 5/4 allocation. Any child failure stops its peers. The output root must not exist, so a partial study can never be mistaken for a resumed or complete scientific run. """ @@ -122,28 +122,21 @@ def _validated_output_root(value: str | os.PathLike[str]) -> Path: def allocate_arms( arms: list[Arm], gpus: list[BASE.GPU], ) -> dict[str, list[Arm]]: - """Use all four GPUs with the intended 3/2/2/2 workload split.""" - if len(gpus) != 4: - raise RuntimeError(f"exactly four idle GPUs are required, got {len(gpus)}") + """Use both requested GPUs with a deterministic 5/4 workload split.""" + if len(gpus) != 2: + raise RuntimeError(f"exactly two idle GPUs are required, got {len(gpus)}") if set(arms) != set(arm_grid()): raise ValueError("offline launcher requires the complete declared arm grid") ordered_gpus = sorted(gpus, key=lambda gpu: int(gpu.index)) - by_epochs = { - epochs: sorted( - [arm for arm in arms if arm.exposure_epochs == epochs], - key=lambda arm: arm.alpha, - ) - for epochs in EXPOSURE_EPOCHS - } allocation = {gpu.uuid: [] for gpu in ordered_gpus} - allocation[ordered_gpus[0].uuid].extend(by_epochs[1]) - for gpu, ten, hundred in zip( - ordered_gpus[1:], by_epochs[10], by_epochs[100] - ): - allocation[gpu.uuid].extend((ten, hundred)) + ordered_arms = sorted( + arms, key=lambda arm: (-arm.exposure_epochs, arm.alpha), + ) + for index, arm in enumerate(ordered_arms): + allocation[ordered_gpus[index % 2].uuid].append(arm) counts = sorted(len(values) for values in allocation.values()) - if counts != [2, 2, 2, 3] or any(not values for values in allocation.values()): - raise RuntimeError(f"invalid four-GPU allocation: {counts}") + if counts != [4, 5] or any(not values for values in allocation.values()): + raise RuntimeError(f"invalid two-GPU allocation: {counts}") return allocation @@ -684,7 +677,7 @@ def aggregate(evaluations: dict[str, dict], output: Path) -> dict: return result -def _select_exactly_four_gpus(args): +def _select_exactly_two_gpus(args): gpus, processes, topology = BASE.gpu_snapshot() selected = BASE.select_idle_gpus( gpus, @@ -693,9 +686,9 @@ def _select_exactly_four_gpus(args): max_memory_mib=args.idle_memory_mib, max_utilization=args.idle_utilization_percent, ) - if len(selected) != 4: + if len(selected) != 2: raise RuntimeError( - f"the declared study requires four exclusive GPUs, got " + f"the declared study requires two exclusive GPUs, got " f"{[gpu.index for gpu in selected]}" ) return gpus, processes, topology, selected @@ -741,7 +734,7 @@ def _parser() -> argparse.ArgumentParser: "--expected-checkpoint-sha256", default=CHECKPOINT_SHA256, ) parser.add_argument("--outdir", required=True) - parser.add_argument("--gpu-indices", default="0,1,2,3") + parser.add_argument("--gpu-indices", default="1,3") parser.add_argument("--verifier-workers", type=int, default=8) parser.add_argument("--seed", type=int, default=20260724) parser.add_argument("--eval-ep0", type=int, default=260000) @@ -773,7 +766,7 @@ def run(args) -> dict: outdir = _validated_output_root(args.outdir) source = BASE.source_provenance() arms = list(arm_grid()) - all_gpus, processes, topology, selected = _select_exactly_four_gpus(args) + all_gpus, processes, topology, selected = _select_exactly_two_gpus(args) allocation = allocate_arms(arms, selected) pools = BASE.allocate_cpu_pools(arms, int(args.verifier_workers)) training_jobs = _phase_jobs( @@ -876,7 +869,7 @@ def run(args) -> dict: # Recheck exclusivity between phases. A foreign job that appeared while # training ran must not be silently shared with the common-bank evaluator. - _, _, _, evaluation_gpus = _select_exactly_four_gpus(args) + _, _, _, evaluation_gpus = _select_exactly_two_gpus(args) if [gpu.uuid for gpu in evaluation_gpus] != [ gpu.uuid for gpu in selected ]: diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh index 68966c8..f150d5b 100755 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh @@ -47,19 +47,19 @@ export CUDA_DEVICE_ORDER=PCI_BUS_ID idle_polls=0 while (( idle_polls < IDLE_POLLS_REQUIRED )); do process_count="$( - nvidia-smi --query-compute-apps=pid --format=csv,noheader | + nvidia-smi -i 1,3 --query-compute-apps=pid --format=csv,noheader | sed '/^[[:space:]]*$/d' | wc -l )" bad_gpu_count="$( - nvidia-smi \ + nvidia-smi -i 1,3 \ --query-gpu=memory.used,utilization.gpu \ --format=csv,noheader,nounits | awk -F, '{if ($1+0 > 1024 || $2+0 > 5) bad++} END {print bad+0}' )" gpu_count="$( - nvidia-smi --query-gpu=index --format=csv,noheader,nounits | wc -l + nvidia-smi -i 1,3 --query-gpu=index --format=csv,noheader,nounits | wc -l )" - if [[ "$gpu_count" -eq 4 && "$process_count" -eq 0 && "$bad_gpu_count" -eq 0 ]]; then + if [[ "$gpu_count" -eq 2 && "$process_count" -eq 0 && "$bad_gpu_count" -eq 0 ]]; then idle_polls=$((idle_polls + 1)) echo "$(date -Is) IDLE_CONFIRMATION ${idle_polls}/${IDLE_POLLS_REQUIRED}" else @@ -73,7 +73,7 @@ done cd "$HERE" echo "$(date -Is) SMOKE_START" -CUDA_VISIBLE_DEVICES=0 "$PYTHON" sfm_b1_offline_exec.py \ +CUDA_VISIBLE_DEVICES=1 "$PYTHON" sfm_b1_offline_exec.py \ --checkpoint "$CHECKPOINT" \ --outdir "$SMOKE_OUTDIR" \ --alpha 0.01 \ @@ -121,7 +121,7 @@ echo "$(date -Is) SMOKE_VALIDATED_FULL_START" --checkpoint "$CHECKPOINT" \ --expected-checkpoint-sha256 "$EXPECTED_SHA" \ --outdir "$FULL_OUTDIR" \ - --gpu-indices 0,1,2,3 \ + --gpu-indices 1,3 \ --verifier-workers 8 \ --seed 20260724 \ --eval-ep0 260000 \ diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_replay.py b/overnight_run_07_12_sfm/sfm_b1_offline_replay.py index 607bb63..37ee82b 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_replay.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_replay.py @@ -56,17 +56,48 @@ def _proportional_interleave(positive, negative): def stratified_batches(shard, *, batch, seed): + batch = int(batch) + if batch < 1: + raise ValueError("batch must be positive") positives = BS.hierarchical_order(OS.positive_records(shard), int(seed)) negatives = BS.hierarchical_order(OS.negative_records(shard), int(seed) + 1) - merged = _proportional_interleave(positives, negatives) - batches = [ - merged[start:start + int(batch)] - for start in range(0, len(merged), int(batch)) - ] + total = len(positives) + len(negatives) + batch_count = math.ceil(total / batch) if total else 0 + if positives and len(positives) < batch_count: + raise RuntimeError( + "cannot place a positive in every fixed-capacity minibatch: " + f"{len(positives)} positives for {batch_count} batches" + ) + if not positives: + batches = [ + negatives[start:start + batch] + for start in range(0, len(negatives), batch) + ] + else: + # Signed replay needs a positive objective in every Adam step. Seed + # every fixed-capacity batch with one positive, then distribute the + # remaining deterministic sign orders without duplicating support. + batches = [[positives[index]] for index in range(batch_count)] + remaining = _proportional_interleave( + positives[batch_count:], negatives, + ) + batch_index = 0 + for record in remaining: + while len(batches[batch_index]) >= batch: + batch_index = (batch_index + 1) % batch_count + batches[batch_index].append(record) + batch_index = (batch_index + 1) % batch_count identities = [_identity(record) for values in batches for record in values] expected = [_identity(record) for record in positives + negatives] if len(identities) != len(set(identities)) or set(identities) != set(expected): raise RuntimeError("offline minibatch replay duplicated or omitted support") + if positives and any( + not any(int(record[1]["y"]) == 1 for record in values) + for values in batches + ): + raise RuntimeError("offline replay produced a positive-free minibatch") + if any(len(values) > batch for values in batches): + raise RuntimeError("offline replay exceeded the fixed minibatch capacity") return batches, positives, negatives From c63ac4357a1e478e261a29a41d2e7b8b220e02c1 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 13:26:01 -0700 Subject: [PATCH 10/31] Add x0-faithful partial branch comparison --- .../test_sfm_b1_branch_compare_viz.py | 23 +++ .../sfm_b1_branch_compare_viz.py | 163 ++++++++++++++++++ .../sfm_b1_full_episode_audit.py | 143 +++++++++++---- 3 files changed, 300 insertions(+), 29 deletions(-) create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_branch_compare_viz.py diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py new file mode 100644 index 0000000..be01897 --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py @@ -0,0 +1,23 @@ +import sfm_b1_branch_compare_viz as V + + +def test_summary_separates_window_labels_from_episode_outcomes(): + bundle = { + "traces": [ + {"executed_label": "verifier_positive"}, + {"executed_label": "verifier_negative"}, + {"executed_label": "verifier_positive"}, + ], + "outcomes": [ + {"success": True, "collision": False, "timeout": False}, + {"success": False, "collision": True, "timeout": False}, + ], + } + report = V.summarize(bundle) + assert report["contexts"] == 3 + assert report["executed_positive"] == 2 + assert report["executed_negative"] == 1 + assert report["executed_positive_fraction"] == 2 / 3 + assert (report["success"], report["collision"], report["timeout"]) == ( + 1, 1, 0, + ) diff --git a/overnight_run_07_12_sfm/sfm_b1_branch_compare_viz.py b/overnight_run_07_12_sfm/sfm_b1_branch_compare_viz.py new file mode 100644 index 0000000..96a514f --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_branch_compare_viz.py @@ -0,0 +1,163 @@ +"""Compare final planned-window branch forests for two SFM checkpoints.""" +from __future__ import annotations + +import argparse +import json +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import torch + +import sfm_b1_d_branch_viz as DB +import sfm_b1_full_episode_viz as FV + + +STATUS = "SFM_B1_BRANCH_COMPARISON_COMPLETE" + + +def summarize(bundle): + traces = list(bundle["traces"]) + outcomes = list(bundle["outcomes"]) + positive = sum( + row["executed_label"] == "verifier_positive" for row in traces + ) + negative = sum( + row["executed_label"] == "verifier_negative" for row in traces + ) + resolved = positive + negative + return { + "contexts": len(traces), + "executed_positive": positive, + "executed_negative": negative, + "executed_positive_fraction": ( + float(positive / resolved) if resolved else None + ), + "success": sum(bool(row["success"]) for row in outcomes), + "collision": sum(bool(row["collision"]) for row in outcomes), + "timeout": sum(bool(row["timeout"]) for row in outcomes), + "episodes": len(outcomes), + } + + +def _load(path): + bundle = torch.load(path, map_location="cpu", weights_only=False) + if bundle.get("status") != "SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE": + raise ValueError(f"not a completed branch audit: {path}") + return bundle + + +def render( + pretrained_trace, expanded_trace, output_png, output_json, + *, expanded_label="partial expanded", +): + pretrained = _load(pretrained_trace) + expanded = _load(expanded_trace) + for key in ("scenarios", "gammas", "environment", "sample_seed", "audit_seed"): + if pretrained[key] != expanded[key]: + raise ValueError(f"comparison contract differs at {key}") + scenarios = tuple(map(int, pretrained["scenarios"])) + gammas = tuple(map(float, pretrained["gammas"])) + if len(scenarios) != 3 or len(gammas) != 7: + raise ValueError("comparison requires three scenarios and seven gammas") + + models = (("pretrained", pretrained), (expanded_label, expanded)) + figure, axes = plt.subplots(6, 7, figsize=(23.4, 18.0)) + figure.subplots_adjust( + left=.055, right=.82, bottom=.025, top=.96, wspace=.025, hspace=.04, + ) + for column, gamma in enumerate(gammas): + figure.text( + .055 + (.765 / 7) * (column + .5), .975, + f"$\\gamma={gamma:g}$", ha="center", va="center", fontsize=10, + ) + + reports = {} + for model_index, (label, bundle) in enumerate(models): + index = FV._index(bundle["traces"]) + reports[label] = summarize(bundle) + for scenario_index, scenario in enumerate(scenarios): + row = model_index * len(scenarios) + scenario_index + figure.text( + .018, .96 - (.935 / 6) * (row + .5), + f"{label}\nepisode {scenario}", + ha="center", va="center", rotation=90, fontsize=8, + ) + for column, gamma in enumerate(gammas): + rows = index[(scenario, round(gamma, 8))] + DB.draw_cell(axes[row, column], rows, max(rows)) + + figure.legend( + handles=DB._legend(), loc="center left", bbox_to_anchor=(.835, .66), + frameon=False, fontsize=8, + ) + text = [] + for label, report in reports.items(): + text.extend([ + label, + f"verified D: {report['executed_positive']}/" + f"{report['contexts']} " + f"({report['executed_positive_fraction']:.1%})", + f"outcomes S/C/T: {report['success']}/" + f"{report['collision']}/{report['timeout']}", + "", + ]) + text.extend([ + "Fixed scenarios, gamma values, and", + "proposal x0 streams are shared.", + "Contexts diverge after the first", + "closed-loop action.", + ]) + figure.text( + .835, .42, "\n".join(text), ha="left", va="top", fontsize=8, + ) + + os.makedirs(os.path.dirname(os.path.abspath(output_png)), exist_ok=True) + figure.savefig(output_png, dpi=165, bbox_inches="tight") + plt.close(figure) + report = { + "status": STATUS, + "pretrained_trace": os.path.abspath(pretrained_trace), + "expanded_trace": os.path.abspath(expanded_trace), + "expanded_label": expanded_label, + "comparison_contract": { + "scenarios": list(scenarios), + "gammas": list(gammas), + "sample_seed": pretrained["sample_seed"], + "audit_seed": pretrained["audit_seed"], + "closed_loop_caveat": ( + "proposal x0 streams match by cell and step, but checkpoint-" + "dependent first actions make later contexts different" + ), + }, + "models": reports, + "png": os.path.abspath(output_png), + } + os.makedirs(os.path.dirname(os.path.abspath(output_json)), exist_ok=True) + temporary = os.path.abspath(output_json) + ".tmp" + with open(temporary, "w") as stream: + json.dump(report, stream, indent=2, allow_nan=False) + os.replace(temporary, os.path.abspath(output_json)) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--pretrained-trace", required=True) + parser.add_argument("--expanded-trace", required=True) + parser.add_argument("--expanded-label", default="partial expanded") + parser.add_argument("--output-png", required=True) + parser.add_argument("--output-json", required=True) + args = parser.parse_args(argv) + render( + args.pretrained_trace, + args.expanded_trace, + args.output_png, + args.output_json, + expanded_label=args.expanded_label, + ) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py index 1299ea3..44a68af 100644 --- a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py +++ b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py @@ -77,17 +77,84 @@ def _source(): return dict(commit=commit, tracked_worktree_clean=not dirty) -def _raw_windows(policy, live, batch, generators): - """One raw latent per live cell, preserving raw-evaluator per-cell streams.""" - context = policy.ctx_from(batch["hp10"], batch["low"], batch["hist"]) - latents = torch.stack([ - torch.randn( - policy.d, generator=generators[(replica.scenario_id, replica.gamma)], - device=context.device, dtype=context.dtype, +def _keyed_seed(base, *parts): + payload = json.dumps( + [int(base), *parts], separators=(",", ":"), sort_keys=False, + ).encode() + return int.from_bytes(hashlib.sha256(payload).digest()[:8], "little") % ( + 2 ** 63 - 1 + ) + + +@torch.no_grad() +def _keyed_windows( + policy, live, batch, *, K, round_i, step, source, seed, nfe, temp, +): + """Generate proposals and retain their exact Gaussian flow bases.""" + contexts = policy.ctx_from(batch["hp10"], batch["low"], batch["hist"]) + latent_parts = [] + for replica in live: + generator = np.random.default_rng(_keyed_seed( + seed, int(round_i), int(replica.scenario_id), + f"{float(replica.gamma):.8f}", int(step), str(source), + )) + latent_parts.append(generator.standard_normal( + (int(K), int(policy.d)), dtype=np.float32, + )) + x0 = torch.as_tensor( + np.stack(latent_parts), + device=contexts.device, + dtype=contexts.dtype, + ) + windows = BE.integrate_latents( + policy, + (x0 * float(temp)).reshape(-1, policy.d), + contexts.repeat_interleave(int(K), dim=0), + nfe=int(nfe), + ) + return ( + windows.reshape(len(live), int(K), int(policy.H_pred), 2), + contexts, + x0, + ) + + +@torch.no_grad() +def _features_from_x0(phi_policy, windows, contexts, x0, s): + K = int(windows.shape[1]) + features = phi_policy.phi_s_from_x0( + windows.reshape(-1, windows.shape[-2], 2), + contexts.repeat_interleave(K, dim=0), + x0.reshape(-1, phi_policy.d), + s=float(s), + ) + return BR.l2_normalize(features).reshape(len(contexts), K, -1) + + +@torch.no_grad() +def _calibrate_empty_gp_beta(phi_policy, gp, replicas, cfg, device): + live, batch = BX._stack_prepared(replicas, device) + windows, contexts, x0 = _keyed_windows( + phi_policy, live, batch, K=cfg.K, round_i=1, step=-1, + source="beta_calibration", seed=cfg.seed, + nfe=cfg.nfe, temp=cfg.temp, + ) + features = _features_from_x0( + phi_policy, windows, contexts, x0, cfg.phi_s, + ) + vectors = [] + for replica, values in zip(live, features): + generator = torch.Generator(device=values.device).manual_seed( + _keyed_seed( + cfg.seed, 1, replica.scenario_id, + f"{replica.gamma:.8f}", "beta_order", + ) ) - for replica in live - ]) - return BE.integrate_latents(policy, latents, context, nfe=8) + order = torch.randperm( + len(values), generator=generator, device=values.device, + ) + vectors.extend(gp.sequential_score_vectors(values, order, cfg.B)) + return BR.solve_beta(vectors, target=cfg.ess_target) def _trap(states, *, horizon=TRAP_HORIZON, displacement=TRAP_DISPLACEMENT): @@ -165,17 +232,9 @@ def collect( seed=int(audit_seed), ).validate() gp = BR.RBFGP(float(ell), cfg.gp_lam) - beta, calibrated_ess = BX._initial_beta( - phi_policy, gp, replicas, cfg, device, int(audit_seed) + 1009, + beta, calibrated_ess = _calibrate_empty_gp_beta( + phi_policy, gp, replicas, cfg, device, ) - audit_generator = torch.Generator(device=device).manual_seed(int(audit_seed) + 2003) - raw_generators = { - (replica.scenario_id, replica.gamma): - torch.Generator(device=device).manual_seed( - int(sample_seed) + replica.scenario_id * 1000 - ) - for replica in replicas - } traces = [] counts = Counter() @@ -189,19 +248,34 @@ def collect( if not live: break with torch.no_grad(): - audit_windows = BE.generate_windows( - policy, batch["hp10"], batch["low"], batch["hist"], - K=cfg.K, nfe=cfg.nfe, temp=cfg.temp, - generator=audit_generator, + audit_windows, contexts, x0 = _keyed_windows( + policy, live, batch, K=cfg.K, round_i=1, step=step, + source="K", seed=int(sample_seed), + nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows, _, raw_x0 = _keyed_windows( + policy, live, batch, K=1, round_i=1, step=step, + source="raw_continuation", seed=int(sample_seed), + nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows = raw_windows[:, 0] + raw_x0 = raw_x0[:, 0] + features = _features_from_x0( + phi_policy, audit_windows, contexts, x0, cfg.phi_s, ) - raw_windows = _raw_windows(policy, live, batch, raw_generators) - features = BX._features(phi_policy, audit_windows, batch, cfg.phi_s) selected_by_context = [] acquisition_by_context = [] - for context_index in range(len(live)): + for context_index, replica in enumerate(live): + acquisition_generator = torch.Generator( + device=features.device, + ).manual_seed(_keyed_seed( + int(audit_seed), 1, replica.scenario_id, + f"{replica.gamma:.8f}", step, "acquisition", + )) selected, acquisition = gp.sequential_acquire( - features[context_index], cfg.B, beta, generator=audit_generator, + features[context_index], cfg.B, beta, + generator=acquisition_generator, ) selected_by_context.append(selected) acquisition_by_context.append(acquisition) @@ -240,6 +314,7 @@ def collect( segment = SM.rollout_positions(prepared["state"], controls) all_rows.append(dict( candidate_id=candidate_id, controls=controls, segment=segment, + x0=x0[context_index, candidate_id].detach().cpu().numpy(), mode=BE.classify_candidate(segment, pedestrian_prediction), )) query_rows = [] @@ -280,6 +355,7 @@ def collect( all_rows, query_rows, chosen = prepared_contexts[context_index] raw_controls = raw_windows[context_index].detach().cpu().numpy() + raw_base = raw_x0[context_index].detach().cpu().numpy() nvp_context = chosen is None if chosen is None: raw_result = by_context[context_index][-1] @@ -304,16 +380,20 @@ def collect( counts[f"raw_continuation_{_result_label(raw_result)}"] += 1 raw_candidate = dict( controls=np.asarray(raw_controls, np.float32), + x0=np.asarray(raw_base, np.float32), result=raw_result, hp_margin=float(raw_margin), hp_old=float(raw_hp_old), hp_new=float(raw_hp_new), admissible=raw_admissible, ) else: executed_controls = chosen["controls"] + executed_x0 = all_rows[int(chosen["candidate_id"])]["x0"] executed_result = chosen["result"] executed_id = int(chosen["candidate_id"]) execution_source = "verified_max_margin" raw_candidate = None + if chosen is None: + executed_x0 = raw_base executed_label = _result_label(executed_result) counts[f"executed_{executed_label}"] += 1 counts[f"source_{execution_source}"] += 1 @@ -362,6 +442,7 @@ def collect( query_rows=query_rows, acquisition=acquisition_by_context[context_index], executed_id=executed_id, executed_controls=np.asarray(executed_controls, np.float32), + executed_x0=np.asarray(executed_x0, np.float32), executed_result=executed_result, executed_label=executed_label, execution_source=execution_source, @@ -391,7 +472,7 @@ def collect( source = _source() bundle = dict( - version=1, status="SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE", + version=2, status="SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE", diagnostic_only=True, enters_training_or_gp=False, certified_deployment=False, continuation_semantics=( @@ -415,6 +496,10 @@ def collect( protocol=dict( K=cfg.K, B=cfg.B, H=cfg.H, T=int(T), selector="margin", ell=float(ell), gp_buffer=0, beta=float(beta), + representation=( + "normalize(phi_theta((1-s)*x0+s*U/u_max,s,c)); " + "stored proposal-specific x0; s=0.9" + ), calibrated_ess_over_K=float(calibrated_ess), realized_ess_over_K=float(np.mean(ess_values)), acquisition=BR.acquisition_diagnostics(sigma_pool, sigma_selected), From db80dfc7a1a226c441f1c78d475fdc065bd51858 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 14:54:45 -0700 Subject: [PATCH 11/31] Add native SafeMPPI-cost offline selector sweep --- .../analysis/test_run_sfm_b1_offline_9arm.py | 9 + .../test_sfm_b1_branch_compare_viz.py | 20 ++ .../test_sfm_b1_offline_18arm_compare.py | 46 +++++ .../run_sfm_b1_offline_9arm.py | 41 +++- .../run_sfm_b1_offline_queue.sh | 17 +- .../sfm_b1_d_branch_viz.py | 37 +++- .../sfm_b1_full_episode_audit.py | 33 +-- .../sfm_b1_offline_18arm_compare.py | 191 +++++++++++++++++ .../sfm_b1_offline_exec.py | 21 +- .../sfm_b1_selector_compare_viz.py | 194 ++++++++++++++++++ 10 files changed, 567 insertions(+), 42 deletions(-) create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py create mode 100644 overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index 3e8abc9..d5e05e8 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -43,6 +43,10 @@ def test_arm_grid_and_two_gpu_allocation(): assert {arm.exposure_epochs for arm in allocation["GPU-1"]} == { 1, 10, 100, } + cost_arms = list(L.arm_grid("safemppi_cost")) + assert len(cost_arms) == 9 + assert all("safemppi_cost" in arm.name for arm in cost_arms) + assert all(arm.selector == "safemppi_cost" for arm in cost_arms) def test_output_root_must_be_new_and_under_research1(tmp_path, monkeypatch): @@ -72,6 +76,11 @@ def test_commands_cover_declared_rounds_and_raw_common_bank(tmp_path): train = L._trainer_command(args, arm, tmp_path / "train") assert train[train.index("--rounds") + 1] == "10" assert train[train.index("--exposure-epochs") + 1] == "10" + assert train[train.index("--selector") + 1] == "margin" + cost_train = L._trainer_command( + args, L.Arm(0.01, 10, "safemppi_cost"), tmp_path / "cost", + ) + assert cost_train[cost_train.index("--selector") + 1] == "safemppi_cost" evaluate = L._evaluation_command( args, arm, diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py index be01897..f88c6a2 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py @@ -1,4 +1,5 @@ import sfm_b1_branch_compare_viz as V +import sfm_b1_selector_compare_viz as S def test_summary_separates_window_labels_from_episode_outcomes(): @@ -21,3 +22,22 @@ def test_summary_separates_window_labels_from_episode_outcomes(): assert (report["success"], report["collision"], report["timeout"]) == ( 1, 1, 0, ) + + +def test_selector_pair_requires_same_checkpoint_and_bank(): + common = { + "scenarios": [1, 2, 3], + "gammas": [.1, .2, .3, .4, .5, .7, 1.], + "environment": {"name": "test"}, + "sample_seed": 8, + "audit_seed": 9, + "checkpoint_sha256": "a" * 64, + } + S._validate_pair(common, dict(common)) + different = dict(common, checkpoint_sha256="b" * 64) + try: + S._validate_pair(common, different) + except ValueError as error: + assert "one pretrained checkpoint" in str(error) + else: + raise AssertionError("checkpoint mismatch must be rejected") diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py new file mode 100644 index 0000000..f88eb1c --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py @@ -0,0 +1,46 @@ +import json + +import sfm_b1_offline_18arm_compare as C + + +def _delivery(root, selector): + aggregate = root / "evaluation" / "aggregate" + aggregate.mkdir(parents=True) + rows = [] + prefix = "offline_exec" if selector == "margin" else "offline_exec_safemppi_cost" + for alpha in (0.0, 0.01, 0.1): + for exposure in (1, 10, 100): + arm = ( + f"{prefix}_alpha{str(alpha).replace('.', 'p')}_" + f"exposures{exposure:03d}" + ) + for round_i in range(11): + rows.append({ + "selector": selector, + "arm": arm, + "alpha": alpha, + "exposure_epochs": exposure, + "round": round_i, + "SR": .5, "CR": .5, "timeout": 0., + "Validity": .4, "clearance": .1, "time_to_goal": 9., + }) + (root / "DELIVERY_COMPLETE.json").write_text(json.dumps({ + "status": "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE", + "contract": {"execution_selector": selector}, + })) + (aggregate / "AGGREGATE_COMPLETE.json").write_text(json.dumps({ + "status": "SFM_B1_OFFLINE_9ARM_AGGREGATE_COMPLETE", + "rows": rows, + })) + + +def test_compare_requires_and_combines_paired_99_row_sweeps(tmp_path): + margin = tmp_path / "margin" + cost = tmp_path / "cost" + _delivery(margin, "margin") + _delivery(cost, "safemppi_cost") + result = C.compare(margin, cost, tmp_path / "comparison") + assert result["status"] == C.STATUS + assert result["rows"] == 198 + assert result["paired_r0"]["CR"] == .5 + assert (tmp_path / "comparison" / "paired_18arm_raw_m50.png").is_file() diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index 625cdeb..02a0fa5 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -80,12 +80,18 @@ def _sha256_json(payload) -> str: class Arm: alpha: float exposure_epochs: int + selector: str = "margin" @property def name(self) -> str: alpha = str(float(self.alpha)).replace(".", "p") + prefix = ( + "offline_exec" + if self.selector == "margin" + else "offline_exec_safemppi_cost" + ) return ( - f"offline_exec_alpha{alpha}_" + f"{prefix}_alpha{alpha}_" f"exposures{int(self.exposure_epochs):03d}" ) @@ -95,9 +101,11 @@ class PhaseName: name: str -def arm_grid() -> tuple[Arm, ...]: +def arm_grid(selector="margin") -> tuple[Arm, ...]: + if selector not in ("margin", "safemppi_cost"): + raise ValueError(f"unknown execution selector: {selector}") return tuple( - Arm(alpha, epochs) + Arm(alpha, epochs, selector) for alpha in ALPHAS for epochs in EXPOSURE_EPOCHS ) @@ -125,7 +133,8 @@ def allocate_arms( """Use both requested GPUs with a deterministic 5/4 workload split.""" if len(gpus) != 2: raise RuntimeError(f"exactly two idle GPUs are required, got {len(gpus)}") - if set(arms) != set(arm_grid()): + selectors = {arm.selector for arm in arms} + if len(selectors) != 1 or set(arms) != set(arm_grid(next(iter(selectors)))): raise ValueError("offline launcher requires the complete declared arm grid") ordered_gpus = sorted(gpus, key=lambda gpu: int(gpu.index)) allocation = {gpu.uuid: [] for gpu in ordered_gpus} @@ -152,6 +161,8 @@ def _trainer_command(args, arm: Arm, output: Path) -> list[str]: str(arm.alpha), "--exposure-epochs", str(arm.exposure_epochs), + "--selector", + arm.selector, "--rounds", str(ROUNDS), "--verifier-workers", @@ -264,6 +275,7 @@ def validate_training_arm( expected_recipe = { "alpha": float(arm.alpha), "exposure_epochs": int(arm.exposure_epochs), + "selector": arm.selector, "rounds": ROUNDS, "K": K, "B": B, @@ -561,7 +573,9 @@ def _screening_key(row: dict) -> tuple: ) -def _render_aggregate(rows: list[dict], output: Path) -> list[dict]: +def _render_aggregate( + rows: list[dict], output: Path, *, selector: str, +) -> list[dict]: import matplotlib matplotlib.use("Agg") @@ -577,7 +591,7 @@ def _render_aggregate(rows: list[dict], output: Path) -> list[dict]: ) figure, axes = plt.subplots(2, 2, figsize=(14.5, 10.0), squeeze=False) for axis, (key, title, ylim) in zip(axes.flat, specs): - for arm in arm_grid(): + for arm in arm_grid(selector): values = [ row for row in rows if row["arm"] == arm.name ] @@ -629,10 +643,12 @@ def _render_aggregate(rows: list[dict], output: Path) -> list[dict]: return artifacts -def aggregate(evaluations: dict[str, dict], output: Path) -> dict: +def aggregate( + evaluations: dict[str, dict], output: Path, *, selector: str, +) -> dict: output.mkdir(parents=True, exist_ok=False) rows = [] - for arm in arm_grid(): + for arm in arm_grid(selector): rows.extend( _cell_row(arm, record) for record in evaluations[arm.name]["records"] @@ -648,7 +664,7 @@ def aggregate(evaluations: dict[str, dict], output: Path) -> dict: writer.writerows(rows) candidates = [row for row in rows if int(row["round"]) > 0] best = min(candidates, key=_screening_key) - figures = _render_aggregate(rows, output) + figures = _render_aggregate(rows, output, selector=selector) result = { "status": "SFM_B1_OFFLINE_9ARM_AGGREGATE_COMPLETE", "selection_role": ( @@ -735,6 +751,9 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument("--outdir", required=True) parser.add_argument("--gpu-indices", default="1,3") + parser.add_argument( + "--selector", choices=("margin", "safemppi_cost"), default="margin", + ) parser.add_argument("--verifier-workers", type=int, default=8) parser.add_argument("--seed", type=int, default=20260724) parser.add_argument("--eval-ep0", type=int, default=260000) @@ -765,7 +784,7 @@ def run(args) -> dict: raise FileNotFoundError(module) outdir = _validated_output_root(args.outdir) source = BASE.source_provenance() - arms = list(arm_grid()) + arms = list(arm_grid(args.selector)) all_gpus, processes, topology, selected = _select_exactly_two_gpus(args) allocation = allocate_arms(arms, selected) pools = BASE.allocate_cpu_pools(arms, int(args.verifier_workers)) @@ -781,6 +800,7 @@ def run(args) -> dict: "checkpoint": str(checkpoint), "checkpoint_sha256": observed_checkpoint_sha, "scene_profile": SCENE_PROFILE, + "execution_selector": args.selector, "rounds": ROUNDS, "alphas": list(ALPHAS), "exposure_epochs": list(EXPOSURE_EPOCHS), @@ -937,6 +957,7 @@ def run(args) -> dict: ) aggregate_result = aggregate( evaluations, outdir / "evaluation" / "aggregate", + selector=args.selector, ) manifest = { "status": "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE", diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh index f150d5b..68ada4f 100755 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_queue.sh @@ -14,6 +14,9 @@ PYTHON="${PYTHON:-python}" EXPECTED_SHA="1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215" POLL_SECONDS="${POLL_SECONDS:-20}" IDLE_POLLS_REQUIRED="${IDLE_POLLS_REQUIRED:-3}" +GPU_INDICES="${GPU_INDICES:-1,3}" +SMOKE_GPU="${GPU_INDICES%%,*}" +EXECUTION_SELECTOR="${EXECUTION_SELECTOR:-margin}" LOG="${QUEUE_LOG:-${FULL_OUTDIR}.queue.log}" mkdir -p "$(dirname "$LOG")" @@ -24,6 +27,8 @@ echo "source=$(git -C "$HERE/.." rev-parse HEAD)" echo "checkpoint=$CHECKPOINT" echo "smoke_outdir=$SMOKE_OUTDIR" echo "full_outdir=$FULL_OUTDIR" +echo "gpu_indices=$GPU_INDICES" +echo "execution_selector=$EXECUTION_SELECTOR" if [[ ! -f "$CHECKPOINT" ]]; then echo "checkpoint does not exist: $CHECKPOINT" >&2 @@ -47,17 +52,17 @@ export CUDA_DEVICE_ORDER=PCI_BUS_ID idle_polls=0 while (( idle_polls < IDLE_POLLS_REQUIRED )); do process_count="$( - nvidia-smi -i 1,3 --query-compute-apps=pid --format=csv,noheader | + nvidia-smi -i "$GPU_INDICES" --query-compute-apps=pid --format=csv,noheader | sed '/^[[:space:]]*$/d' | wc -l )" bad_gpu_count="$( - nvidia-smi -i 1,3 \ + nvidia-smi -i "$GPU_INDICES" \ --query-gpu=memory.used,utilization.gpu \ --format=csv,noheader,nounits | awk -F, '{if ($1+0 > 1024 || $2+0 > 5) bad++} END {print bad+0}' )" gpu_count="$( - nvidia-smi -i 1,3 --query-gpu=index --format=csv,noheader,nounits | wc -l + nvidia-smi -i "$GPU_INDICES" --query-gpu=index --format=csv,noheader,nounits | wc -l )" if [[ "$gpu_count" -eq 2 && "$process_count" -eq 0 && "$bad_gpu_count" -eq 0 ]]; then idle_polls=$((idle_polls + 1)) @@ -73,11 +78,12 @@ done cd "$HERE" echo "$(date -Is) SMOKE_START" -CUDA_VISIBLE_DEVICES=1 "$PYTHON" sfm_b1_offline_exec.py \ +CUDA_VISIBLE_DEVICES="$SMOKE_GPU" "$PYTHON" sfm_b1_offline_exec.py \ --checkpoint "$CHECKPOINT" \ --outdir "$SMOKE_OUTDIR" \ --alpha 0.01 \ --exposure-epochs 1 \ + --selector "$EXECUTION_SELECTOR" \ --rounds 1 \ --verifier-workers 32 \ --seed 20260724 \ @@ -121,7 +127,8 @@ echo "$(date -Is) SMOKE_VALIDATED_FULL_START" --checkpoint "$CHECKPOINT" \ --expected-checkpoint-sha256 "$EXPECTED_SHA" \ --outdir "$FULL_OUTDIR" \ - --gpu-indices 1,3 \ + --gpu-indices "$GPU_INDICES" \ + --selector "$EXECUTION_SELECTOR" \ --verifier-workers 8 \ --seed 20260724 \ --eval-ep0 260000 \ diff --git a/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py index e62cbe3..8923a63 100644 --- a/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py +++ b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py @@ -38,7 +38,7 @@ def _branch(trace): return path -def _draw_D_branches(axis, rows, step): +def _draw_D_branches(axis, rows, step, *, line_scale=1.0): available = sorted(value for value in rows if value <= int(step)) for context_step in available: trace = rows[context_step] @@ -48,18 +48,22 @@ def _draw_D_branches(axis, rows, step): axis.plot( path[:, 0], path[:, 1], color=color, - lw=1.25 if is_current else .55, - marker=".", ms=1.3 if is_current else .75, + lw=(1.25 if is_current else .55) * float(line_scale), + marker=".", + ms=(1.3 if is_current else .75) * np.sqrt(float(line_scale)), alpha=.9 if is_current else .32, zorder=7 if is_current else 3, ) axis.plot( path[0, 0], path[0, 1], marker=".", color=color, - ms=2.7 if is_current else 1.5, zorder=8, + ms=(2.7 if is_current else 1.5) * np.sqrt(float(line_scale)), + zorder=8, ) -def _draw_executed_trajectory(axis, rows, step): +def _draw_executed_trajectory( + axis, rows, step, *, linewidth=2.8, marker_size=2.1, +): available = sorted(value for value in rows if value <= int(step)) if not available: return @@ -67,25 +71,36 @@ def _draw_executed_trajectory(axis, rows, step): states.append(np.asarray(rows[available[-1]]["next_state"], float)[:2]) states = np.asarray(states) axis.plot( - states[:, 0], states[:, 1], color="#111111", lw=2.8, - marker=".", ms=2.1, alpha=.97, zorder=11, + states[:, 0], states[:, 1], color="#111111", lw=float(linewidth), + marker=".", ms=float(marker_size), alpha=.97, zorder=11, ) axis.annotate( "", xy=states[-1], xytext=states[-2], - arrowprops=dict(arrowstyle="->", color="#111111", lw=2.2), + arrowprops=dict( + arrowstyle="->", color="#111111", + lw=max(.8, .78 * float(linewidth)), + ), zorder=12, ) -def draw_cell(axis, rows, step): +def draw_cell( + axis, rows, step, *, branch_line_scale=1.0, + trajectory_linewidth=2.8, trajectory_marker_size=2.1, +): available = [value for value in rows if value <= int(step)] current_step = max(available) if available else min(rows) trace = rows[current_step] BV._draw_common(axis, trace, nominal_levels=False) - _draw_D_branches(axis, rows, current_step) + _draw_D_branches( + axis, rows, current_step, line_scale=branch_line_scale, + ) FV._draw_candidates(axis, trace) FV._draw_executed(axis, trace) - _draw_executed_trajectory(axis, rows, current_step) + _draw_executed_trajectory( + axis, rows, current_step, linewidth=trajectory_linewidth, + marker_size=trajectory_marker_size, + ) DV._set_clean_axis(axis) return trace diff --git a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py index 44a68af..21c61ec 100644 --- a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py +++ b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py @@ -1,10 +1,11 @@ """Diagnostic-only full-episode B1 gathering with explicit post-NVP continuation. This module does not alter the fail-closed B1 trainer. It starts from the -pretrained policy, runs the ordinary K=16/B=4 RBF acquisition and max-margin -selector, and records every resolved query. When the selected B queries contain -no admissible action, an independently sampled raw temperature-one window is -verified and its first action is executed so the simulator can continue. +pretrained policy, runs the ordinary K=16/B=4 RBF acquisition and the requested +execution selector, and records every resolved query. When the selected B +queries contain no admissible action, an independently sampled raw +temperature-one window is verified and its first action is executed so the +simulator can continue. That post-NVP transition is evidence gathering, not certified deployment. The trace keeps the full-H verifier label, nominal-Hp gate, NVP event, progress/trap @@ -198,7 +199,8 @@ def collect( checkpoint, *, scenarios=DEFAULT_SCENARIOS, gammas=SS.GAMMAS, scene_profile="double_density_velocity_ood", device="cuda", verifier_workers=32, sample_seed=DEFAULT_SAMPLE_SEED, - audit_seed=DEFAULT_AUDIT_SEED, ell=DEFAULT_ELL, T=SP.T, outdir, + audit_seed=DEFAULT_AUDIT_SEED, ell=DEFAULT_ELL, T=SP.T, + selector="margin", outdir, ): """Collect a fixed scenario-by-gamma full-episode diagnostic bundle.""" scenarios = tuple(map(int, scenarios)) @@ -209,6 +211,8 @@ def collect( raise ValueError(f"the requested audit requires all gammas={SS.GAMMAS}") if scene_profile != "double_density_velocity_ood": raise ValueError("this audit is pinned to the authenticated double-shift OOD") + if selector not in ("margin", "safemppi_cost"): + raise ValueError(f"unknown execution selector: {selector}") if os.path.exists(outdir): raise FileExistsError(f"refusing to reuse audit output: {outdir}") @@ -227,7 +231,7 @@ def collect( for scenario in scenarios for gamma in gammas ] cfg = BX.ArmConfig( - name="A", selector="margin", alpha=0.0, rounds=1, + name="diagnostic", selector=selector, alpha=0.0, rounds=1, scene_profile=scene_profile, verifier_workers=int(verifier_workers), seed=int(audit_seed), ).validate() @@ -334,7 +338,7 @@ def collect( )) counts[f"B_{_result_label(result)}"] += 1 chosen = BC.select_admissible( - query_rows, selector="margin", state=prepared["state"], + query_rows, selector=selector, state=prepared["state"], ped_xy=prepared["ped_xy"], ped_vel=prepared["ped_vel"], gamma=replica.gamma, ) @@ -390,7 +394,7 @@ def collect( executed_x0 = all_rows[int(chosen["candidate_id"])]["x0"] executed_result = chosen["result"] executed_id = int(chosen["candidate_id"]) - execution_source = "verified_max_margin" + execution_source = f"verified_{selector}" raw_candidate = None if chosen is None: executed_x0 = raw_base @@ -423,7 +427,7 @@ def collect( negative_reasons.append("executed_verifier_error") if ( executed_label == "verifier_positive" - and execution_source != "verified_max_margin" + and not execution_source.startswith("verified_") ): if raw_margin < -1.0e-9: negative_reasons.append("executed_nominal_Hp_gate_failure") @@ -476,7 +480,7 @@ def collect( diagnostic_only=True, enters_training_or_gp=False, certified_deployment=False, continuation_semantics=( - "verified max-margin B action when available; otherwise independently " + f"verified {selector} B action when available; otherwise independently " "sampled raw temp=1 action is executed after being labeled, even when " "uncertified, solely to continue the offline simulator diagnostic" ), @@ -494,7 +498,7 @@ def collect( environment=environment, scenarios=list(scenarios), gammas=list(gammas), sample_seed=int(sample_seed), audit_seed=int(audit_seed), protocol=dict( - K=cfg.K, B=cfg.B, H=cfg.H, T=int(T), selector="margin", + K=cfg.K, B=cfg.B, H=cfg.H, T=int(T), selector=selector, ell=float(ell), gp_buffer=0, beta=float(beta), representation=( "normalize(phi_theta((1-s)*x0+s*U/u_max,s,c)); " @@ -530,13 +534,18 @@ def main(argv=None): parser.add_argument("--audit-seed", type=int, default=DEFAULT_AUDIT_SEED) parser.add_argument("--ell", type=float, default=DEFAULT_ELL) parser.add_argument("--T", type=int, default=SP.T) + parser.add_argument( + "--selector", + choices=("margin", "safemppi_cost"), + default="margin", + ) args = parser.parse_args(argv) collect( args.checkpoint, scenarios=args.scenarios, scene_profile=args.scene_profile, device=args.device, verifier_workers=args.verifier_workers, sample_seed=args.sample_seed, audit_seed=args.audit_seed, - ell=args.ell, T=args.T, outdir=args.outdir, + ell=args.ell, T=args.T, selector=args.selector, outdir=args.outdir, ) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py b/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py new file mode 100644 index 0000000..51d52af --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py @@ -0,0 +1,191 @@ +"""Paired four-metric comparison of margin and SafeMPPI-cost 9-arm sweeps.""" +from __future__ import annotations + +import argparse +import csv +import json +import os +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +import run_sfm_b1_offline_9arm as RUN + + +STATUS = "SFM_B1_OFFLINE_18ARM_COMPARISON_COMPLETE" + + +def _read_json(path): + with open(path) as stream: + return json.load(stream) + + +def _load(root, selector): + root = Path(root).resolve() + delivery = _read_json(root / "DELIVERY_COMPLETE.json") + if delivery.get("status") != "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE": + raise ValueError(f"incomplete 9-arm delivery: {root}") + contract = dict(delivery["contract"]) + observed = contract.get("execution_selector", "margin") + if observed != selector: + raise ValueError( + f"expected {selector} sweep, observed {observed} at {root}" + ) + aggregate = _read_json( + root / "evaluation" / "aggregate" / "AGGREGATE_COMPLETE.json" + ) + rows = list(aggregate["rows"]) + if len(rows) != 9 * (RUN.ROUNDS + 1): + raise ValueError(f"expected 99 aggregate rows at {root}, got {len(rows)}") + for row in rows: + row["selector"] = selector + return root, delivery, contract, aggregate, rows + + +def _paired_r0(rows): + fields = ("SR", "CR", "timeout", "Validity", "clearance", "time_to_goal") + values = [ + tuple(row[field] for field in fields) + for row in rows if int(row["round"]) == 0 + ] + if not values or any(value != values[0] for value in values[1:]): + raise ValueError("all 18 arms must share an identical raw-M50 r0 cell") + return dict(zip(fields, values[0])) + + +def _plot(rows, output): + combinations = [ + (float(alpha), int(exposure)) + for alpha in RUN.ALPHAS for exposure in RUN.EXPOSURE_EPOCHS + ] + colors = plt.get_cmap("tab10") + color_for = { + combination: colors(index) + for index, combination in enumerate(combinations) + } + linestyles = {"margin": "-", "safemppi_cost": "--"} + specs = ( + ("CR", "Collision rate", (-.03, 1.03)), + ("Validity", "Validity", (-.03, 1.03)), + ("clearance", "Min. clearance [m]", None), + ("time_to_goal", "Time-to-goal [s]", None), + ) + figure, axes = plt.subplots(2, 2, figsize=(15.5, 10.5)) + for axis, (key, title, ylim) in zip(axes.flat, specs): + for selector in ("margin", "safemppi_cost"): + for alpha, exposure in combinations: + values = [ + row for row in rows + if row["selector"] == selector + and float(row["alpha"]) == alpha + and int(row["exposure_epochs"]) == exposure + ] + values.sort(key=lambda row: int(row["round"])) + axis.plot( + [row["round"] for row in values], + [ + float("nan") if row[key] is None else float(row[key]) + for row in values + ], + color=color_for[(alpha, exposure)], + linestyle=linestyles[selector], + linewidth=1.55, alpha=.9, + ) + axis.set_title(title) + axis.set_xlabel("Expansion round") + axis.set_xticks(range(RUN.ROUNDS + 1)) + axis.grid(alpha=.24) + if ylim is not None: + axis.set_ylim(*ylim) + handles = [ + plt.Line2D( + [0], [0], color=color_for[(alpha, exposure)], lw=2.6, + label=rf"$\alpha={alpha:g}$, exposure={exposure}", + ) + for alpha, exposure in combinations + ] + handles.extend([ + plt.Line2D( + [0], [0], color="black", lw=2.4, linestyle="-", + label="max one-step margin", + ), + plt.Line2D( + [0], [0], color="black", lw=2.4, linestyle="--", + label="native SafeMPPI cost", + ), + ]) + figure.legend( + handles=handles, ncol=4, loc="upper center", + frameon=False, fontsize=8, + ) + figure.tight_layout(rect=(0, 0, 1, .89)) + artifacts = [] + for suffix in ("png", "pdf"): + path = output / f"paired_18arm_raw_m50.{suffix}" + figure.savefig(path, dpi=300, bbox_inches="tight") + artifacts.append(str(path.resolve())) + plt.close(figure) + return artifacts + + +def compare(margin_root, cost_root, output_dir): + output = Path(output_dir).resolve() + output.mkdir(parents=True, exist_ok=False) + loaded = ( + _load(margin_root, "margin"), + _load(cost_root, "safemppi_cost"), + ) + rows = [row for item in loaded for row in item[-1]] + r0 = _paired_r0(rows) + csv_path = output / "paired_18arm_raw_m50.csv" + fields = ( + "selector", "arm", "alpha", "exposure_epochs", "round", + "SR", "CR", "timeout", "Validity", "clearance", "time_to_goal", + ) + with csv_path.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + writer.writerows({key: row[key] for key in fields} for row in rows) + figures = _plot(rows, output) + best_by_selector = {} + for selector in ("margin", "safemppi_cost"): + candidates = [ + row for row in rows + if row["selector"] == selector and int(row["round"]) > 0 + ] + best_by_selector[selector] = min(candidates, key=RUN._screening_key) + report = { + "status": STATUS, + "comparison_role": ( + "paired common-bank raw-M50 screening; selector is the only " + "factor added to the existing alpha x exposure grid" + ), + "margin_root": str(loaded[0][0]), + "safemppi_cost_root": str(loaded[1][0]), + "paired_r0": r0, + "best_screening_cell_by_selector": best_by_selector, + "rows": len(rows), + "csv": str(csv_path.resolve()), + "figures": figures, + } + marker = output / "COMPARISON_COMPLETE.json" + temporary = marker.with_suffix(".json.tmp") + with temporary.open("w") as stream: + json.dump(report, stream, indent=2, allow_nan=False) + os.replace(temporary, marker) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--margin-root", required=True) + parser.add_argument("--safemppi-cost-root", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args(argv) + compare(args.margin_root, args.safemppi_cost_root, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py index ba730d9..2309989 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -48,12 +48,14 @@ ALPHAS = (0.0, 0.01, 0.1) EXPOSURE_EPOCHS = (1, 10, 100) SCENE_PROFILE = "double_density_velocity_ood" +EXECUTION_SELECTORS = ("margin", "safemppi_cost") @dataclass(frozen=True) class OfflineConfig: alpha: float exposure_epochs: int + selector: str = "margin" rounds: int = 10 K: int = 16 B: int = 4 @@ -72,6 +74,8 @@ class OfflineConfig: smoke: bool = False def validate(self): + if self.selector not in EXECUTION_SELECTORS: + raise ValueError(f"selector must be one of {EXECUTION_SELECTORS}") if float(self.alpha) not in ALPHAS: raise ValueError(f"alpha must be one of {ALPHAS}") if int(self.exposure_epochs) not in EXPOSURE_EPOCHS: @@ -97,8 +101,13 @@ def validate(self): @property def arm_name(self): alpha = str(float(self.alpha)).replace(".", "p") + prefix = ( + "offline_exec" + if self.selector == "margin" + else "offline_exec_safemppi_cost" + ) return ( - f"offline_exec_alpha{alpha}_" + f"{prefix}_alpha{alpha}_" f"exposures{int(self.exposure_epochs):03d}" ) @@ -488,7 +497,7 @@ def gather_offline_round( )) chosen = BC.select_admissible( query_rows, - selector="margin", + selector=cfg.selector, state=prepared["state"], ped_xy=prepared["ped_xy"], ped_vel=prepared["ped_vel"], @@ -571,7 +580,7 @@ def gather_offline_round( selected_x0 = x0_np[context_index, int(chosen["candidate_id"])] result = chosen["result"] margin = float(chosen["hp_margin"]) - execution_source = "verified_max_margin" + execution_source = f"verified_{cfg.selector}" candidate_id = int(chosen["candidate_id"]) acquisition_step = int(chosen["acquisition_step"]) sigma = float(chosen["sigma"]) @@ -662,7 +671,7 @@ def gather_offline_round( return dict( collector_role="offline_expansion_data_collector_not_safe_controller", continuation_semantics=( - "verified max-margin B action when available; otherwise an " + f"verified {cfg.selector} B action when available; otherwise an " "independent raw temperature-one H10 plan is exact-verified and " "its first action is executed even when y=0" ), @@ -894,6 +903,9 @@ def main(argv=None): parser.add_argument("--checkpoint", required=True) parser.add_argument("--outdir", required=True) parser.add_argument("--alpha", type=float, choices=ALPHAS, required=True) + parser.add_argument( + "--selector", choices=EXECUTION_SELECTORS, default="margin", + ) parser.add_argument( "--exposure-epochs", type=int, @@ -909,6 +921,7 @@ def main(argv=None): cfg = OfflineConfig( alpha=args.alpha, exposure_epochs=args.exposure_epochs, + selector=args.selector, rounds=args.rounds, verifier_workers=args.verifier_workers, seed=args.seed, diff --git a/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py b/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py new file mode 100644 index 0000000..0f54b4a --- /dev/null +++ b/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py @@ -0,0 +1,194 @@ +"""Compare pretrained max-margin and native-SafeMPPI-cost data acquisition.""" +from __future__ import annotations + +import argparse +import json +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.animation as animation +import matplotlib.pyplot as plt +import torch + +import sfm_b1_branch_compare_viz as BC +import sfm_b1_d_branch_viz as DB +import sfm_b1_full_episode_viz as FV + + +STATUS = "SFM_B1_SELECTOR_COMPARISON_COMPLETE" +SELECTORS = ("margin", "safemppi_cost") + + +def _load(path, selector): + bundle = torch.load(path, map_location="cpu", weights_only=False) + if bundle.get("status") != "SFM_B1_FULL_EPISODE_LABEL_AUDIT_COMPLETE": + raise ValueError(f"not a completed branch audit: {path}") + observed = bundle.get("protocol", {}).get("selector") + if observed != selector: + raise ValueError( + f"expected selector={selector}, observed {observed} in {path}" + ) + return bundle + + +def _validate_pair(margin, cost): + for key in ( + "scenarios", "gammas", "environment", "sample_seed", "audit_seed", + ): + if margin[key] != cost[key]: + raise ValueError(f"selector comparison contract differs at {key}") + if margin.get("checkpoint_sha256") != cost.get("checkpoint_sha256"): + raise ValueError("selector comparison requires one pretrained checkpoint") + if len(margin["scenarios"]) != 3 or len(margin["gammas"]) != 7: + raise ValueError("selector comparison requires 3 episodes x 7 gammas") + + +def _layout(margin, cost): + scenarios = tuple(map(int, margin["scenarios"])) + gammas = tuple(map(float, margin["gammas"])) + bundles = (("max one-step margin", margin), ("SafeMPPI cost", cost)) + figure, axes = plt.subplots(6, 7, figsize=(23.5, 18.0)) + figure.subplots_adjust( + left=.055, right=.82, bottom=.025, top=.96, wspace=.025, hspace=.04, + ) + for column, gamma in enumerate(gammas): + figure.text( + .055 + (.765 / 7) * (column + .5), .975, + f"$\\gamma={gamma:g}$", ha="center", va="center", fontsize=10, + ) + indices = {} + for selector_index, (label, bundle) in enumerate(bundles): + index = FV._index(bundle["traces"]) + indices[label] = index + for scenario_index, scenario in enumerate(scenarios): + row = selector_index * len(scenarios) + scenario_index + figure.text( + .018, .96 - (.935 / 6) * (row + .5), + f"{label}\nepisode {scenario}", + ha="center", va="center", rotation=90, fontsize=8, + ) + return figure, axes, scenarios, gammas, bundles, indices + + +def _draw(axes, scenarios, gammas, bundles, indices, step): + for selector_index, (label, _) in enumerate(bundles): + index = indices[label] + for scenario_index, scenario in enumerate(scenarios): + row = selector_index * len(scenarios) + scenario_index + for column, gamma in enumerate(gammas): + axis = axes[row, column] + axis.clear() + DB.draw_cell( + axis, index[(scenario, round(gamma, 8))], int(step), + branch_line_scale=2.7, + trajectory_linewidth=1.05, + trajectory_marker_size=1.25, + ) + + +def render( + margin_trace, cost_trace, output_png, output_mp4, output_json, + *, fps=5, frame_stride=2, +): + margin = _load(margin_trace, "margin") + cost = _load(cost_trace, "safemppi_cost") + _validate_pair(margin, cost) + ( + figure, axes, scenarios, gammas, bundles, indices, + ) = _layout(margin, cost) + + reports = {label: BC.summarize(bundle) for label, bundle in bundles} + maximum = max( + max(max(rows) for rows in index.values()) + for index in indices.values() + ) + frames = list(range(0, maximum + 1, int(frame_stride))) + if frames[-1] != maximum: + frames.append(maximum) + + figure.legend( + handles=DB._legend(), loc="center left", bbox_to_anchor=(.835, .72), + frameon=False, fontsize=8, + ) + summary = [] + for label, report in reports.items(): + summary.extend([ + label, + f"positive D: {report['executed_positive']}/{report['contexts']}", + f"S/C/T: {report['success']}/" + f"{report['collision']}/{report['timeout']}", + "", + ]) + summary.extend([ + "Same pretrained checkpoint, episodes,", + "gammas, proposal-noise contract.", + "Only the admissible-B execution rank differs.", + "Branches: planned H10 D samples.", + "Thin black: executed first-action path.", + ]) + figure.text( + .835, .47, "\n".join(summary), ha="left", va="top", fontsize=8, + ) + + for path in (output_png, output_mp4, output_json): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + _draw(axes, scenarios, gammas, bundles, indices, maximum) + figure.savefig(output_png, dpi=165, bbox_inches="tight") + + def update(step): + _draw(axes, scenarios, gammas, bundles, indices, int(step)) + return [] + + movie = animation.FuncAnimation( + figure, update, frames=frames, interval=1000 / int(fps), blit=False, + ) + movie.save( + output_mp4, writer=animation.FFMpegWriter( + fps=int(fps), bitrate=5200, + ), dpi=105, + ) + plt.close(figure) + + report = { + "status": STATUS, + "margin_trace": os.path.abspath(margin_trace), + "safemppi_cost_trace": os.path.abspath(cost_trace), + "checkpoint_sha256": margin.get("checkpoint_sha256"), + "scenarios": list(scenarios), + "gammas": list(gammas), + "comparison": reports, + "controlled_difference": ( + "rank the same SOCP-positive and nominal-Hp-admissible B queries " + "by max one-step Hp margin versus minimum frozen native SafeMPPI cost" + ), + "frames": frames, + "png": os.path.abspath(output_png), + "mp4": os.path.abspath(output_mp4), + } + temporary = os.path.abspath(output_json) + ".tmp" + with open(temporary, "w") as stream: + json.dump(report, stream, indent=2, allow_nan=False) + os.replace(temporary, os.path.abspath(output_json)) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--margin-trace", required=True) + parser.add_argument("--safemppi-cost-trace", required=True) + parser.add_argument("--output-png", required=True) + parser.add_argument("--output-mp4", required=True) + parser.add_argument("--output-json", required=True) + parser.add_argument("--fps", type=int, default=5) + parser.add_argument("--frame-stride", type=int, default=2) + args = parser.parse_args(argv) + render( + args.margin_trace, args.safemppi_cost_trace, + args.output_png, args.output_mp4, args.output_json, + fps=args.fps, frame_stride=args.frame_stride, + ) + + +if __name__ == "__main__": + main() From 9ed6b4c46b9519e2e9ae362927446cb4c03bc452 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 15:18:38 -0700 Subject: [PATCH 12/31] Add safety-performance rank selector diagnostic --- .../analysis/test_run_sfm_b1_offline_9arm.py | 3 + .../test_sfm_b1_branch_compare_viz.py | 13 +++- .../analysis/test_sfm_b1_cost.py | 32 ++++++++ .../run_sfm_b1_offline_9arm.py | 17 +++-- overnight_run_07_12_sfm/sfm_b1_cost.py | 29 +++++++- .../sfm_b1_d_branch_viz.py | 73 +++++++++++++++++++ .../sfm_b1_full_episode_audit.py | 4 +- .../sfm_b1_offline_exec.py | 2 +- .../sfm_b1_selector_compare_viz.py | 69 ++++++++++++------ 9 files changed, 207 insertions(+), 35 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index d5e05e8..ab615c8 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -47,6 +47,9 @@ def test_arm_grid_and_two_gpu_allocation(): assert len(cost_arms) == 9 assert all("safemppi_cost" in arm.name for arm in cost_arms) assert all(arm.selector == "safemppi_cost" for arm in cost_arms) + balanced = list(L.arm_grid("balanced_rank")) + assert len(balanced) == 9 + assert all("balanced_rank" in arm.name for arm in balanced) def test_output_root_must_be_new_and_under_research1(tmp_path, monkeypatch): diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py index f88c6a2..99aa880 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_branch_compare_viz.py @@ -1,5 +1,7 @@ import sfm_b1_branch_compare_viz as V +import sfm_b1_d_branch_viz as D import sfm_b1_selector_compare_viz as S +import numpy as np def test_summary_separates_window_labels_from_episode_outcomes(): @@ -33,11 +35,18 @@ def test_selector_pair_requires_same_checkpoint_and_bank(): "audit_seed": 9, "checkpoint_sha256": "a" * 64, } - S._validate_pair(common, dict(common)) + S._validate_bundles((("margin", common), ("cost", dict(common)))) different = dict(common, checkpoint_sha256="b" * 64) try: - S._validate_pair(common, different) + S._validate_bundles((("margin", common), ("cost", different))) except ValueError as error: assert "one pretrained checkpoint" in str(error) else: raise AssertionError("checkpoint mismatch must be rejected") + + +def test_robot_frame_uses_velocity_direction(): + trace = {"state": np.array([2., 3., 0., 2.])} + path = np.array([[2., 3.], [2., 4.], [3., 4.]]) + local = D._robot_frame(path, trace) + np.testing.assert_allclose(local, [[0., 0.], [1., 0.], [1., -1.]]) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_cost.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_cost.py index 9b9d1d3..59e0524 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_cost.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_cost.py @@ -35,3 +35,35 @@ def test_gate_precedes_selector_and_nvp_when_none(): rows, selector="margin", state=np.zeros(4), ped_xy=np.array([[3., 3.]]), ped_vel=np.zeros((1, 2)), gamma=.5, ) is None + + +def test_balanced_rank_uses_rank_sum_with_safety_first_tie(monkeypatch): + rows = [] + for candidate_id in range(3): + controls = np.zeros((10, 2), np.float32) + controls[0, 0] = candidate_id + 1 + rows.append({ + "candidate_id": candidate_id, + "controls": controls, + "result": {"resolved": True, "y": 1}, + }) + monkeypatch.setattr( + C, "nominal_hp_margin", + lambda state, action, ped_xy, gamma: ( + 4.0 - float(action[0]), 1.0, 1.0, + ), + ) + monkeypatch.setattr( + C, "safemppi_proposal_cost", + lambda state, controls, goal, ped_xy, ped_vel: torch.tensor( + [4.0 - float(value) for value in controls[:, 0, 0]] + ), + ) + chosen = C.select_admissible( + rows, selector="balanced_rank", state=np.zeros(4), + ped_xy=np.array([[3., 3.]]), ped_vel=np.zeros((1, 2)), gamma=.5, + ) + assert chosen["candidate_id"] == 0 + assert chosen["safety_rank"] == 1 + assert chosen["performance_rank"] == 3 + assert chosen["rank_sum"] == 4 diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index 02a0fa5..f3e079b 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -85,11 +85,12 @@ class Arm: @property def name(self) -> str: alpha = str(float(self.alpha)).replace(".", "p") - prefix = ( - "offline_exec" - if self.selector == "margin" - else "offline_exec_safemppi_cost" - ) + prefixes = { + "margin": "offline_exec", + "safemppi_cost": "offline_exec_safemppi_cost", + "balanced_rank": "offline_exec_balanced_rank", + } + prefix = prefixes[self.selector] return ( f"{prefix}_alpha{alpha}_" f"exposures{int(self.exposure_epochs):03d}" @@ -102,7 +103,7 @@ class PhaseName: def arm_grid(selector="margin") -> tuple[Arm, ...]: - if selector not in ("margin", "safemppi_cost"): + if selector not in ("margin", "safemppi_cost", "balanced_rank"): raise ValueError(f"unknown execution selector: {selector}") return tuple( Arm(alpha, epochs, selector) @@ -752,7 +753,9 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--outdir", required=True) parser.add_argument("--gpu-indices", default="1,3") parser.add_argument( - "--selector", choices=("margin", "safemppi_cost"), default="margin", + "--selector", + choices=("margin", "safemppi_cost", "balanced_rank"), + default="margin", ) parser.add_argument("--verifier-workers", type=int, default=8) parser.add_argument("--seed", type=int, default=20260724) diff --git a/overnight_run_07_12_sfm/sfm_b1_cost.py b/overnight_run_07_12_sfm/sfm_b1_cost.py index 8821fbf..550e5dc 100644 --- a/overnight_run_07_12_sfm/sfm_b1_cost.py +++ b/overnight_run_07_12_sfm/sfm_b1_cost.py @@ -111,13 +111,38 @@ def select_admissible(query_rows, *, selector, state, ped_xy, ped_vel, gamma): return None if selector == "margin": return max(admissible, key=lambda row: (row["hp_margin"], -int(row["candidate_id"]))) - if selector != "safemppi_cost": + if selector not in ("safemppi_cost", "balanced_rank"): raise ValueError(f"unknown selector: {selector}") controls = torch.as_tensor(np.stack([row["controls"] for row in admissible]), dtype=torch.float32) costs = safemppi_proposal_cost(state, controls, SS.GOAL, ped_xy, ped_vel).cpu().numpy() for row, cost in zip(admissible, costs): row["expert_cost"] = float(cost) - return min(admissible, key=lambda row: (row["expert_cost"], int(row["candidate_id"]))) + if selector == "safemppi_cost": + return min(admissible, key=lambda row: (row["expert_cost"], int(row["candidate_id"]))) + safety_order = sorted( + admissible, + key=lambda row: (-row["hp_margin"], int(row["candidate_id"])), + ) + performance_order = sorted( + admissible, + key=lambda row: (row["expert_cost"], int(row["candidate_id"])), + ) + for rank, row in enumerate(safety_order, start=1): + row["safety_rank"] = rank + for rank, row in enumerate(performance_order, start=1): + row["performance_rank"] = rank + for row in admissible: + row["rank_sum"] = row["safety_rank"] + row["performance_rank"] + return min( + admissible, + key=lambda row: ( + row["rank_sum"], + row["safety_rank"], + -row["hp_margin"], + row["expert_cost"], + int(row["candidate_id"]), + ), + ) def scorer_manifest(): diff --git a/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py index 8923a63..46edec9 100644 --- a/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py +++ b/overnight_run_07_12_sfm/sfm_b1_d_branch_viz.py @@ -84,9 +84,80 @@ def _draw_executed_trajectory( ) +def _robot_frame(path, trace): + position = np.asarray(trace["state"], float)[:2] + direction = np.asarray(trace["state"], float)[2:4] + if np.linalg.norm(direction) < 1.0e-6: + direction = np.asarray(SS.GOAL, float)[:2] - position + direction = direction / max(np.linalg.norm(direction), 1.0e-12) + normal = np.array([-direction[1], direction[0]]) + delta = np.asarray(path, float) - position + return np.stack([delta @ direction, delta @ normal], axis=1) + + +def _draw_candidate_inset(axis, trace): + for child in list(axis.child_axes): + if getattr(child, "_sfm_candidate_inset", False): + child.remove() + inset = axis.inset_axes((.035, .675, .30, .29), zorder=30) + inset._sfm_candidate_inset = True + inset.set_facecolor((1., 1., 1., .68)) + selected_id = trace.get("executed_id") + local_paths = [] + for query_index, row in enumerate(trace["query_rows"], start=1): + path = np.asarray( + BV._trace_candidate(trace, int(row["candidate_id"]))["segment"], + float, + ) + local = _robot_frame(path, trace) + local_paths.append(local) + status, _ = BV._candidate_status(trace, int(row["candidate_id"])) + color = ( + BV.GREEN if status == "positive" + else BV.RED if status == "negative" + else BV.GRAY + ) + is_selected = ( + selected_id is not None + and int(row["candidate_id"]) == int(selected_id) + ) + if is_selected: + inset.plot( + local[:, 0], local[:, 1], color="#111111", + lw=3.3, alpha=.82, zorder=3, + ) + inset.plot( + local[:, 0], local[:, 1], color=color, + lw=2.25 if is_selected else 1.05, + alpha=.98 if is_selected else .72, + zorder=4 if is_selected else 2, + ) + inset.text( + local[-1, 0], local[-1, 1], str(query_index), + fontsize=4.8, color=color, ha="center", va="center", zorder=5, + ) + inset.plot(0., 0., marker=">", color="#111111", ms=3.2, zorder=6) + if local_paths: + joined = np.concatenate(local_paths) + span = max(.18, 1.08 * float(np.max(np.abs(joined)))) + inset.set_xlim(-.08 * span, span) + inset.set_ylim(-span, span) + inset.set_aspect("equal") + inset.set_xticks([]) + inset.set_yticks([]) + inset.set_title( + "robot-frame B=4" if selected_id is not None else "robot-frame B=4 · NVP", + fontsize=5.1, pad=1.2, + ) + for spine in inset.spines.values(): + spine.set_alpha(.34) + spine.set_linewidth(.55) + + def draw_cell( axis, rows, step, *, branch_line_scale=1.0, trajectory_linewidth=2.8, trajectory_marker_size=2.1, + candidate_inset=False, ): available = [value for value in rows if value <= int(step)] current_step = max(available) if available else min(rows) @@ -101,6 +172,8 @@ def draw_cell( axis, rows, current_step, linewidth=trajectory_linewidth, marker_size=trajectory_marker_size, ) + if candidate_inset: + _draw_candidate_inset(axis, trace) DV._set_clean_axis(axis) return trace diff --git a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py index 21c61ec..3c21ff0 100644 --- a/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py +++ b/overnight_run_07_12_sfm/sfm_b1_full_episode_audit.py @@ -211,7 +211,7 @@ def collect( raise ValueError(f"the requested audit requires all gammas={SS.GAMMAS}") if scene_profile != "double_density_velocity_ood": raise ValueError("this audit is pinned to the authenticated double-shift OOD") - if selector not in ("margin", "safemppi_cost"): + if selector not in ("margin", "safemppi_cost", "balanced_rank"): raise ValueError(f"unknown execution selector: {selector}") if os.path.exists(outdir): raise FileExistsError(f"refusing to reuse audit output: {outdir}") @@ -536,7 +536,7 @@ def main(argv=None): parser.add_argument("--T", type=int, default=SP.T) parser.add_argument( "--selector", - choices=("margin", "safemppi_cost"), + choices=("margin", "safemppi_cost", "balanced_rank"), default="margin", ) args = parser.parse_args(argv) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py index 2309989..b07bda0 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -48,7 +48,7 @@ ALPHAS = (0.0, 0.01, 0.1) EXPOSURE_EPOCHS = (1, 10, 100) SCENE_PROFILE = "double_density_velocity_ood" -EXECUTION_SELECTORS = ("margin", "safemppi_cost") +EXECUTION_SELECTORS = ("margin", "safemppi_cost", "balanced_rank") @dataclass(frozen=True) diff --git a/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py b/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py index 0f54b4a..6196200 100644 --- a/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py +++ b/overnight_run_07_12_sfm/sfm_b1_selector_compare_viz.py @@ -17,7 +17,7 @@ STATUS = "SFM_B1_SELECTOR_COMPARISON_COMPLETE" -SELECTORS = ("margin", "safemppi_cost") +SELECTORS = ("margin", "safemppi_cost", "balanced_rank") def _load(path, selector): @@ -32,23 +32,32 @@ def _load(path, selector): return bundle -def _validate_pair(margin, cost): - for key in ( - "scenarios", "gammas", "environment", "sample_seed", "audit_seed", - ): - if margin[key] != cost[key]: - raise ValueError(f"selector comparison contract differs at {key}") - if margin.get("checkpoint_sha256") != cost.get("checkpoint_sha256"): - raise ValueError("selector comparison requires one pretrained checkpoint") - if len(margin["scenarios"]) != 3 or len(margin["gammas"]) != 7: +def _validate_bundles(bundles): + reference = bundles[0][1] + for _, bundle in bundles[1:]: + for key in ( + "scenarios", "gammas", "environment", "sample_seed", "audit_seed", + ): + if reference[key] != bundle[key]: + raise ValueError( + f"selector comparison contract differs at {key}" + ) + if ( + reference.get("checkpoint_sha256") + != bundle.get("checkpoint_sha256") + ): + raise ValueError( + "selector comparison requires one pretrained checkpoint" + ) + if len(reference["scenarios"]) != 3 or len(reference["gammas"]) != 7: raise ValueError("selector comparison requires 3 episodes x 7 gammas") -def _layout(margin, cost): - scenarios = tuple(map(int, margin["scenarios"])) - gammas = tuple(map(float, margin["gammas"])) - bundles = (("max one-step margin", margin), ("SafeMPPI cost", cost)) - figure, axes = plt.subplots(6, 7, figsize=(23.5, 18.0)) +def _layout(bundles): + scenarios = tuple(map(int, bundles[0][1]["scenarios"])) + gammas = tuple(map(float, bundles[0][1]["gammas"])) + rows = len(bundles) * len(scenarios) + figure, axes = plt.subplots(rows, 7, figsize=(23.5, 3.0 * rows)) figure.subplots_adjust( left=.055, right=.82, bottom=.025, top=.96, wspace=.025, hspace=.04, ) @@ -64,7 +73,7 @@ def _layout(margin, cost): for scenario_index, scenario in enumerate(scenarios): row = selector_index * len(scenarios) + scenario_index figure.text( - .018, .96 - (.935 / 6) * (row + .5), + .018, .96 - (.935 / rows) * (row + .5), f"{label}\nepisode {scenario}", ha="center", va="center", rotation=90, fontsize=8, ) @@ -84,19 +93,29 @@ def _draw(axes, scenarios, gammas, bundles, indices, step): branch_line_scale=2.7, trajectory_linewidth=1.05, trajectory_marker_size=1.25, + candidate_inset=True, ) def render( margin_trace, cost_trace, output_png, output_mp4, output_json, - *, fps=5, frame_stride=2, + *, balanced_trace=None, fps=5, frame_stride=2, ): margin = _load(margin_trace, "margin") cost = _load(cost_trace, "safemppi_cost") - _validate_pair(margin, cost) + bundles = [ + ("max one-step margin", margin), + ("SafeMPPI cost", cost), + ] + if balanced_trace is not None: + bundles.append(( + "balanced safety + performance rank", + _load(balanced_trace, "balanced_rank"), + )) + _validate_bundles(bundles) ( figure, axes, scenarios, gammas, bundles, indices, - ) = _layout(margin, cost) + ) = _layout(tuple(bundles)) reports = {label: BC.summarize(bundle) for label, bundle in bundles} maximum = max( @@ -123,7 +142,7 @@ def render( summary.extend([ "Same pretrained checkpoint, episodes,", "gammas, proposal-noise contract.", - "Only the admissible-B execution rank differs.", + "Only the admissible-B execution ranking differs.", "Branches: planned H10 D samples.", "Thin black: executed first-action path.", ]) @@ -154,13 +173,19 @@ def update(step): "status": STATUS, "margin_trace": os.path.abspath(margin_trace), "safemppi_cost_trace": os.path.abspath(cost_trace), + "balanced_rank_trace": ( + None if balanced_trace is None + else os.path.abspath(balanced_trace) + ), "checkpoint_sha256": margin.get("checkpoint_sha256"), "scenarios": list(scenarios), "gammas": list(gammas), "comparison": reports, "controlled_difference": ( "rank the same SOCP-positive and nominal-Hp-admissible B queries " - "by max one-step Hp margin versus minimum frozen native SafeMPPI cost" + "by max one-step Hp margin, minimum frozen native SafeMPPI cost, " + "or the sum of ordinal safety/performance ranks with safety-first " + "tie-breaking" ), "frames": frames, "png": os.path.abspath(output_png), @@ -177,6 +202,7 @@ def main(argv=None): parser = argparse.ArgumentParser() parser.add_argument("--margin-trace", required=True) parser.add_argument("--safemppi-cost-trace", required=True) + parser.add_argument("--balanced-rank-trace") parser.add_argument("--output-png", required=True) parser.add_argument("--output-mp4", required=True) parser.add_argument("--output-json", required=True) @@ -186,6 +212,7 @@ def main(argv=None): render( args.margin_trace, args.safemppi_cost_trace, args.output_png, args.output_mp4, args.output_json, + balanced_trace=args.balanced_rank_trace, fps=args.fps, frame_stride=args.frame_stride, ) From 5fb90b02d9c0d5ca3afb00e4b82f88d459938a7c Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 15:21:47 -0700 Subject: [PATCH 13/31] Allow balanced selector in shared B1 config --- .../analysis/test_sfm_b1_protocol.py | 3 +++ overnight_run_07_12_sfm/sfm_b1_expand.py | 2 +- overnight_run_07_12_sfm/sfm_b1_offline_exec.py | 11 ++++++----- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_protocol.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_protocol.py index 0501c16..1356aeb 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_protocol.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_protocol.py @@ -17,6 +17,9 @@ def test_frozen_arm_matrix_and_macro_round_ids(): assert len(first) == len(set(first)) == 8 assert set(first).isdisjoint(second) assert 8 * len(P.GAMMAS) == 56 + X.ArmConfig( + name="diagnostic", selector="balanced_rank", alpha=0.0, + ).validate() def test_expansion_has_no_forbidden_legacy_or_expert_path(): diff --git a/overnight_run_07_12_sfm/sfm_b1_expand.py b/overnight_run_07_12_sfm/sfm_b1_expand.py index be7d6f7..9f53695 100644 --- a/overnight_run_07_12_sfm/sfm_b1_expand.py +++ b/overnight_run_07_12_sfm/sfm_b1_expand.py @@ -55,7 +55,7 @@ def validate(self): if (self.K, self.B, self.T, self.H, self.W, self.batch, self.lr, self.ess_target) != ( 16, 4, 180, 10, 2, 128, 1.0e-5, 0.5): raise ValueError("scientific B1 knobs differ from the frozen protocol") - if self.selector not in ("margin", "safemppi_cost"): + if self.selector not in ("margin", "safemppi_cost", "balanced_rank"): raise ValueError("invalid arm selector") if self.scene_profile not in ( "legacy_velocity_ood", "requested_ood", "density_ood", diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py index b07bda0..2a72f5a 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_exec.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_exec.py @@ -101,11 +101,12 @@ def validate(self): @property def arm_name(self): alpha = str(float(self.alpha)).replace(".", "p") - prefix = ( - "offline_exec" - if self.selector == "margin" - else "offline_exec_safemppi_cost" - ) + prefixes = { + "margin": "offline_exec", + "safemppi_cost": "offline_exec_safemppi_cost", + "balanced_rank": "offline_exec_balanced_rank", + } + prefix = prefixes[self.selector] return ( f"{prefix}_alpha{alpha}_" f"exposures{int(self.exposure_epochs):03d}" From 97630c6fa2db3c8be972c2041d974af17db5f8b6 Mon Sep 17 00:00:00 2001 From: dohyun Date: Fri, 24 Jul 2026 16:04:37 -0700 Subject: [PATCH 14/31] Extend paired comparison to balanced selector --- .../test_sfm_b1_offline_18arm_compare.py | 22 ++++++- .../sfm_b1_offline_18arm_compare.py | 59 +++++++++++++------ 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py index f88eb1c..9ead484 100644 --- a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_18arm_compare.py @@ -7,7 +7,11 @@ def _delivery(root, selector): aggregate = root / "evaluation" / "aggregate" aggregate.mkdir(parents=True) rows = [] - prefix = "offline_exec" if selector == "margin" else "offline_exec_safemppi_cost" + prefix = { + "margin": "offline_exec", + "safemppi_cost": "offline_exec_safemppi_cost", + "balanced_rank": "offline_exec_balanced_rank", + }[selector] for alpha in (0.0, 0.01, 0.1): for exposure in (1, 10, 100): arm = ( @@ -44,3 +48,19 @@ def test_compare_requires_and_combines_paired_99_row_sweeps(tmp_path): assert result["rows"] == 198 assert result["paired_r0"]["CR"] == .5 assert (tmp_path / "comparison" / "paired_18arm_raw_m50.png").is_file() + + +def test_compare_accepts_third_balanced_selector_sweep(tmp_path): + margin = tmp_path / "margin" + cost = tmp_path / "cost" + balanced = tmp_path / "balanced" + _delivery(margin, "margin") + _delivery(cost, "safemppi_cost") + _delivery(balanced, "balanced_rank") + result = C.compare( + margin, cost, tmp_path / "comparison", + balanced_root=balanced, + ) + assert result["rows"] == 297 + assert result["balanced_rank_root"] == str(balanced.resolve()) + assert (tmp_path / "comparison" / "paired_27arm_raw_m50.png").is_file() diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py b/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py index 51d52af..f5bda2c 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_18arm_compare.py @@ -14,7 +14,8 @@ import run_sfm_b1_offline_9arm as RUN -STATUS = "SFM_B1_OFFLINE_18ARM_COMPARISON_COMPLETE" +STATUS = "SFM_B1_OFFLINE_SELECTOR_COMPARISON_COMPLETE" +SELECTOR_ORDER = ("margin", "safemppi_cost", "balanced_rank") def _read_json(path): @@ -56,6 +57,10 @@ def _paired_r0(rows): def _plot(rows, output): + selectors = tuple( + selector for selector in SELECTOR_ORDER + if any(row["selector"] == selector for row in rows) + ) combinations = [ (float(alpha), int(exposure)) for alpha in RUN.ALPHAS for exposure in RUN.EXPOSURE_EPOCHS @@ -65,7 +70,11 @@ def _plot(rows, output): combination: colors(index) for index, combination in enumerate(combinations) } - linestyles = {"margin": "-", "safemppi_cost": "--"} + linestyles = { + "margin": "-", + "safemppi_cost": "--", + "balanced_rank": ":", + } specs = ( ("CR", "Collision rate", (-.03, 1.03)), ("Validity", "Validity", (-.03, 1.03)), @@ -74,7 +83,7 @@ def _plot(rows, output): ) figure, axes = plt.subplots(2, 2, figsize=(15.5, 10.5)) for axis, (key, title, ylim) in zip(axes.flat, specs): - for selector in ("margin", "safemppi_cost"): + for selector in selectors: for alpha, exposure in combinations: values = [ row for row in rows @@ -106,16 +115,18 @@ def _plot(rows, output): ) for alpha, exposure in combinations ] - handles.extend([ - plt.Line2D( - [0], [0], color="black", lw=2.4, linestyle="-", - label="max one-step margin", - ), + selector_labels = { + "margin": "max one-step margin", + "safemppi_cost": "native SafeMPPI cost", + "balanced_rank": "balanced safety + performance rank", + } + handles.extend( plt.Line2D( - [0], [0], color="black", lw=2.4, linestyle="--", - label="native SafeMPPI cost", - ), - ]) + [0], [0], color="black", lw=2.4, + linestyle=linestyles[selector], label=selector_labels[selector], + ) + for selector in selectors + ) figure.legend( handles=handles, ncol=4, loc="upper center", frameon=False, fontsize=8, @@ -123,23 +134,25 @@ def _plot(rows, output): figure.tight_layout(rect=(0, 0, 1, .89)) artifacts = [] for suffix in ("png", "pdf"): - path = output / f"paired_18arm_raw_m50.{suffix}" + path = output / f"paired_{9 * len(selectors)}arm_raw_m50.{suffix}" figure.savefig(path, dpi=300, bbox_inches="tight") artifacts.append(str(path.resolve())) plt.close(figure) return artifacts -def compare(margin_root, cost_root, output_dir): +def compare(margin_root, cost_root, output_dir, *, balanced_root=None): output = Path(output_dir).resolve() output.mkdir(parents=True, exist_ok=False) - loaded = ( + loaded = [ _load(margin_root, "margin"), _load(cost_root, "safemppi_cost"), - ) + ] + if balanced_root is not None: + loaded.append(_load(balanced_root, "balanced_rank")) rows = [row for item in loaded for row in item[-1]] r0 = _paired_r0(rows) - csv_path = output / "paired_18arm_raw_m50.csv" + csv_path = output / f"paired_{9 * len(loaded)}arm_raw_m50.csv" fields = ( "selector", "arm", "alpha", "exposure_epochs", "round", "SR", "CR", "timeout", "Validity", "clearance", "time_to_goal", @@ -150,7 +163,8 @@ def compare(margin_root, cost_root, output_dir): writer.writerows({key: row[key] for key in fields} for row in rows) figures = _plot(rows, output) best_by_selector = {} - for selector in ("margin", "safemppi_cost"): + selectors = tuple(item[-1][0]["selector"] for item in loaded) + for selector in selectors: candidates = [ row for row in rows if row["selector"] == selector and int(row["round"]) > 0 @@ -164,6 +178,9 @@ def compare(margin_root, cost_root, output_dir): ), "margin_root": str(loaded[0][0]), "safemppi_cost_root": str(loaded[1][0]), + "balanced_rank_root": ( + None if len(loaded) == 2 else str(loaded[2][0]) + ), "paired_r0": r0, "best_screening_cell_by_selector": best_by_selector, "rows": len(rows), @@ -182,9 +199,13 @@ def main(argv=None): parser = argparse.ArgumentParser() parser.add_argument("--margin-root", required=True) parser.add_argument("--safemppi-cost-root", required=True) + parser.add_argument("--balanced-rank-root") parser.add_argument("--output-dir", required=True) args = parser.parse_args(argv) - compare(args.margin_root, args.safemppi_cost_root, args.output_dir) + compare( + args.margin_root, args.safemppi_cost_root, args.output_dir, + balanced_root=args.balanced_rank_root, + ) if __name__ == "__main__": From 3f80746cad7736c6150560f20b644f88768452c9 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sat, 25 Jul 2026 10:57:31 -0700 Subject: [PATCH 15/31] Recover completed SFM offline evaluations --- .../analysis/test_run_sfm_b1_offline_9arm.py | 29 ++ .../recover_sfm_b1_offline_evaluation.py | 279 ++++++++++++++++++ overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py | 4 +- 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index ab615c8..c63aca3 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -3,6 +3,7 @@ import json from pathlib import Path import sys +from types import SimpleNamespace import pytest @@ -12,6 +13,7 @@ sys.path.insert(0, str(HERE)) import run_sfm_b1_offline_9arm as L # noqa: E402 +import recover_sfm_b1_offline_evaluation as R # noqa: E402 def _gpu(index: int) -> L.BASE.GPU: @@ -145,3 +147,30 @@ def test_validate_sidecar_authenticates_digest(tmp_path): })) with pytest.raises(RuntimeError): L._validate_sidecar(artifact) + + +def test_launch_pending_returns_generated_log_path(tmp_path): + job = { + "arm": L.PhaseName("common_r0"), + "gpu": _gpu(1), + "cpu_pool": [0], + "command": [sys.executable, "-c", "print('ok')"], + } + logs = L.BASE._launch_pending([job], tmp_path / "logs") + assert logs == [str((tmp_path / "logs" / "common_r0.log").resolve())] + assert Path(logs[0]).read_text().strip() == "ok" + + +def test_recovery_refuses_delivery_overwrite(tmp_path, monkeypatch): + root = tmp_path / "research1" + run_root = root / "run" + run_root.mkdir(parents=True) + (run_root / "DELIVERY_COMPLETE.json").write_text("{}") + monkeypatch.setattr(L, "RESEARCH_ROOT", root) + with pytest.raises(FileExistsError): + R.recover(SimpleNamespace( + run_root=str(run_root), + gpu_indices="1,3", + idle_memory_mib=1024, + idle_utilization_percent=5, + )) diff --git a/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py b/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py new file mode 100644 index 0000000..6694960 --- /dev/null +++ b/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Recover only the raw-M50 phase of a completed offline 9-arm sweep.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +from types import SimpleNamespace +import time + +import run_sfm_b1_offline_9arm as RUN +import run_sfm_b1_r2_9arm as BASE + + +def _read_json(path: Path) -> dict: + if not path.is_file(): + raise RuntimeError(f"missing required artifact: {path}") + with path.open() as stream: + return json.load(stream) + + +def _validate_common_r0(path: Path) -> dict: + payload = _read_json(path) + records = payload.get("records", []) + if ( + payload.get("status") != RUN.EVAL_STATUS + or payload.get("scene_profile") != RUN.SCENE_PROFILE + or len(records) != 1 + or int(records[0].get("round", -1)) != 0 + or records[0].get("cell", {}).get("checkpoint_sha256") + != RUN.CHECKPOINT_SHA256 + ): + raise RuntimeError(f"common r0 evaluation contract mismatch: {path}") + return payload + + +def _load_frozen_training(run_root: Path, recovery_source: dict) -> tuple: + declaration_path = run_root / "RUN_DECLARATION.json" + training_path = run_root / "TRAINING_COMPLETE.json" + declaration = _read_json(declaration_path) + training_marker = _read_json(training_path) + if declaration.get("status") != "SFM_B1_OFFLINE_9ARM_DECLARED": + raise RuntimeError(f"invalid declaration status: {declaration_path}") + if training_marker.get("status") != ( + "SFM_B1_OFFLINE_9ARM_TRAINING_COMPLETE" + ): + raise RuntimeError(f"training is not complete: {training_path}") + if training_marker.get("declaration_sha256") != BASE.sha256_file( + declaration_path + ): + raise RuntimeError("training marker does not authenticate declaration") + + contract = declaration.get("contract", {}) + if declaration.get("contract_sha256") != RUN._sha256_json(contract): + raise RuntimeError("declaration contract digest mismatch") + selector = contract.get("execution_selector", "margin") + expected = { + "checkpoint_sha256": RUN.CHECKPOINT_SHA256, + "scene_profile": RUN.SCENE_PROFILE, + "rounds": RUN.ROUNDS, + "alphas": list(RUN.ALPHAS), + "exposure_epochs": list(RUN.EXPOSURE_EPOCHS), + "K": RUN.K, + "B": RUN.B, + "T": RUN.T, + "H": RUN.H, + "cap": RUN.CAP, + "gp_lambda": RUN.GP_LAMBDA, + "batch": RUN.BATCH, + "lr": RUN.LR, + "ess_target": RUN.ESS_TARGET, + "eval_M_per_gamma": 50, + "eval_temperature": 1.0, + } + for key, value in expected.items(): + if contract.get(key) != value: + raise RuntimeError( + f"frozen contract mismatch for {key}: " + f"{contract.get(key)!r} != {value!r}" + ) + if selector not in ("margin", "safemppi_cost", "balanced_rank"): + raise RuntimeError(f"unsupported frozen selector: {selector}") + if contract.get("evaluator_sha256") != BASE.sha256_file(RUN.EVALUATOR): + raise RuntimeError( + "recovery evaluator differs from the frozen evaluator" + ) + checkpoint = Path(contract["checkpoint"]).resolve() + if BASE.sha256_file(checkpoint) != RUN.CHECKPOINT_SHA256: + raise RuntimeError("frozen pretrained checkpoint digest mismatch") + + training_source = training_marker.get("source", {}) + if ( + training_source != contract.get("source") + or training_source.get("commit") is None + or recovery_source.get("commit") is None + ): + raise RuntimeError("training source provenance mismatch") + arms = list(RUN.arm_grid(selector)) + verifier_workers = int(contract["verifier_workers_per_arm"]) + seed = int(contract["seed"]) + training = { + arm.name: RUN.validate_training_arm( + run_root / "arms" / arm.name, + arm, + source_commit=training_source["commit"], + checkpoint_sha256=RUN.CHECKPOINT_SHA256, + seed=seed, + verifier_workers=verifier_workers, + ) + for arm in arms + } + return ( + declaration_path, + training_path, + contract, + training_source, + selector, + arms, + training, + checkpoint, + ) + + +def recover(args) -> dict: + started = time.perf_counter() + run_root = Path(args.run_root).resolve() + try: + run_root.relative_to(RUN.RESEARCH_ROOT.resolve()) + except ValueError as error: + raise ValueError( + f"--run-root must be below {RUN.RESEARCH_ROOT.resolve()}" + ) from error + delivery_path = run_root / "DELIVERY_COMPLETE.json" + if delivery_path.exists(): + raise FileExistsError(f"delivery already exists: {delivery_path}") + + recovery_source = BASE.source_provenance() + ( + declaration_path, + training_path, + contract, + training_source, + selector, + arms, + training, + checkpoint, + ) = _load_frozen_training(run_root, recovery_source) + runtime = SimpleNamespace( + checkpoint=str(checkpoint), + verifier_workers=int(contract["verifier_workers_per_arm"]), + seed=int(contract["seed"]), + eval_ep0=int(contract["eval_ep0"]), + eval_noise_seed=int(contract["eval_noise_seed"]), + gpu_indices=args.gpu_indices, + idle_memory_mib=int(args.idle_memory_mib), + idle_utilization_percent=int(args.idle_utilization_percent), + ) + _, _, _, gpus = RUN._select_exactly_two_gpus(runtime) + allocation = RUN.allocate_arms(arms, gpus) + pools = BASE.allocate_cpu_pools( + arms, int(contract["verifier_workers_per_arm"]) + ) + + common_r0_dir = run_root / "evaluation" / "common_r0" + common_r0_metrics = common_r0_dir / "raw_m50_offline_metrics.json" + if common_r0_metrics.is_file(): + common_payload = _validate_common_r0(common_r0_metrics) + else: + if common_r0_dir.exists(): + raise RuntimeError( + f"refusing to overwrite partial common-r0 output: " + f"{common_r0_dir}" + ) + BASE._launch_pending( + [{ + "arm": RUN.PhaseName("common_r0"), + "gpu": gpus[0], + "cpu_pool": next(iter(pools.values())), + "command": RUN._common_r0_command(runtime, common_r0_dir), + "target": str(common_r0_dir), + }], + run_root / "logs" / "evaluation_recovery_common_r0", + ) + common_payload = _validate_common_r0(common_r0_metrics) + + jobs = RUN._phase_jobs( + runtime, arms, gpus, allocation, pools, run_root, "evaluation", + ) + pending = [] + for job in jobs: + target = Path(job["target"]) + metrics = target / "raw_m50_offline_metrics.json" + if metrics.is_file(): + continue + if target.exists(): + raise RuntimeError( + f"refusing to overwrite partial arm evaluation: {target}" + ) + pending.append(job) + if pending: + BASE._launch_pending( + pending, run_root / "logs" / "evaluation_recovery", + ) + + evaluations = { + arm.name: RUN.validate_evaluation( + run_root / "evaluation" / arm.name, + arm, + training[arm.name], + eval_ep0=runtime.eval_ep0, + eval_noise_seed=runtime.eval_noise_seed, + ) + for arm in arms + } + r0_keys = {value["r0_cell_key"] for value in evaluations.values()} + noise_hashes = { + value["noise_bank_sha256"] for value in evaluations.values() + } + if len(r0_keys) != 1 or len(noise_hashes) != 1: + raise RuntimeError("recovered evaluations do not share one raw-M50 bank") + if next(iter(r0_keys)) != ( + common_payload["records"][0]["cell"]["cell_key"] + ): + raise RuntimeError("recovered arm r0 differs from common r0") + + aggregate_dir = run_root / "evaluation" / "aggregate" + if aggregate_dir.exists(): + raise RuntimeError( + f"refusing to overwrite existing aggregate: {aggregate_dir}" + ) + aggregate_result = RUN.aggregate( + evaluations, aggregate_dir, selector=selector, + ) + manifest = { + "status": "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE", + "finished_at": datetime.now(timezone.utc).isoformat(), + "wall_seconds": time.perf_counter() - started, + "source": training_source, + "recovery_source": recovery_source, + "recovery_role": ( + "evaluation-only recovery; authenticated training checkpoints " + "were not modified or regenerated" + ), + "contract": contract, + "declaration": str(declaration_path), + "declaration_sha256": BASE.sha256_file(declaration_path), + "training_marker": str(training_path), + "training_marker_sha256": BASE.sha256_file(training_path), + "training": training, + "evaluations": evaluations, + "common_r0_metrics": str(common_r0_metrics), + "common_r0_metrics_sha256": BASE.sha256_file(common_r0_metrics), + "common_r0_cell_key": next(iter(r0_keys)), + "common_noise_bank_sha256": next(iter(noise_hashes)), + "aggregate": aggregate_result, + } + RUN._write_json(delivery_path, manifest) + print(json.dumps({ + "status": manifest["status"], + "selector": selector, + "wall_seconds": manifest["wall_seconds"], + "best_screening_cell": aggregate_result["best_screening_cell"], + "delivery": str(delivery_path), + }, indent=2, allow_nan=False)) + return manifest + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-root", required=True) + parser.add_argument("--gpu-indices", default="1,3") + parser.add_argument("--idle-memory-mib", type=int, default=1024) + parser.add_argument("--idle-utilization-percent", type=int, default=5) + return parser + + +if __name__ == "__main__": + recover(_parser().parse_args()) diff --git a/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py index 098ddd7..763b6fa 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_r2_9arm.py @@ -394,10 +394,12 @@ def _child_environment(gpu: GPU) -> dict[str, str]: def _launch_pending(jobs: list[dict], log_dir: Path) -> list[str]: taskset = shutil.which("taskset") running = [] + log_paths = [] log_dir.mkdir(parents=True, exist_ok=True) try: for job in jobs: log_path = log_dir / f"{job['arm'].name}.log" + log_paths.append(str(log_path.resolve())) stream = log_path.open("w") command = list(job["command"]) if taskset: @@ -452,7 +454,7 @@ def _launch_pending(jobs: list[dict], log_dir: Path) -> list[str]: for item in running: if not item["stream"].closed: item["stream"].close() - return [job["log_path"] for job in jobs] + return log_paths def _parser() -> argparse.ArgumentParser: From 5811bb6f100cf40848ec6af7a903e07103eeddd1 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sat, 25 Jul 2026 11:00:03 -0700 Subject: [PATCH 16/31] Accept authenticated legacy margin recipes in recovery --- .../analysis/test_run_sfm_b1_offline_9arm.py | 9 +++++++++ .../run_sfm_b1_offline_9arm.py | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index c63aca3..102ba9b 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -174,3 +174,12 @@ def test_recovery_refuses_delivery_overwrite(tmp_path, monkeypatch): idle_memory_mib=1024, idle_utilization_percent=5, )) + + +def test_legacy_margin_recipe_normalization_is_margin_only(): + recipe = {"alpha": 0.0} + assert L._normalized_training_recipe(recipe, "margin") == { + "alpha": 0.0, + "selector": "margin", + } + assert L._normalized_training_recipe(recipe, "safemppi_cost") == recipe diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py index f3e079b..ba53bb4 100644 --- a/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_9arm.py @@ -255,6 +255,16 @@ def _validate_sidecar(path: Path) -> dict: } +def _normalized_training_recipe(recipe, selector: str): + if ( + selector == "margin" + and isinstance(recipe, dict) + and "selector" not in recipe + ): + return {**recipe, "selector": "margin"} + return recipe + + def validate_training_arm( arm_dir: Path, arm: Arm, @@ -294,7 +304,10 @@ def validate_training_arm( "scene_profile": SCENE_PROFILE, "smoke": False, } - if payload.get("recipe") != expected_recipe: + observed_recipe = _normalized_training_recipe( + payload.get("recipe"), arm.selector, + ) + if observed_recipe != expected_recipe: raise RuntimeError(f"training recipe mismatch: {marker}") constants = payload.get("constants", {}) ell0 = float(constants.get("ell0", -1.0)) From 1e5ee41d6d951f236381cfb8fbc5d24c60305dd4 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sat, 25 Jul 2026 11:02:46 -0700 Subject: [PATCH 17/31] Allow single-GPU offline evaluation recovery --- .../analysis/test_run_sfm_b1_offline_9arm.py | 8 +++++ .../recover_sfm_b1_offline_evaluation.py | 29 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py index 102ba9b..f086e51 100644 --- a/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py +++ b/overnight_run_07_12_sfm/analysis/test_run_sfm_b1_offline_9arm.py @@ -183,3 +183,11 @@ def test_legacy_margin_recipe_normalization_is_margin_only(): "selector": "margin", } assert L._normalized_training_recipe(recipe, "safemppi_cost") == recipe + + +def test_recovery_allocation_supports_one_or_two_idle_gpus(): + arms = list(L.arm_grid("balanced_rank")) + one = R._recovery_allocation(arms, [_gpu(1)]) + assert one == {"GPU-1": arms} + two = R._recovery_allocation(arms, [_gpu(1), _gpu(3)]) + assert sorted(map(len, two.values())) == [4, 5] diff --git a/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py b/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py index 6694960..b20f561 100644 --- a/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py +++ b/overnight_run_07_12_sfm/recover_sfm_b1_offline_evaluation.py @@ -122,6 +122,31 @@ def _load_frozen_training(run_root: Path, recovery_source: dict) -> tuple: ) +def _select_recovery_gpus(args): + gpus, processes, topology = BASE.gpu_snapshot() + selected = BASE.select_idle_gpus( + gpus, + processes, + args.gpu_indices, + max_memory_mib=args.idle_memory_mib, + max_utilization=args.idle_utilization_percent, + ) + if len(selected) not in (1, 2): + raise RuntimeError( + "evaluation recovery requires one or two exclusive GPUs, got " + f"{[gpu.index for gpu in selected]}" + ) + return gpus, processes, topology, selected + + +def _recovery_allocation(arms, gpus): + if len(gpus) == 2: + return RUN.allocate_arms(arms, gpus) + if len(gpus) == 1: + return {gpus[0].uuid: list(arms)} + raise RuntimeError("evaluation recovery requires one or two GPUs") + + def recover(args) -> dict: started = time.perf_counter() run_root = Path(args.run_root).resolve() @@ -156,8 +181,8 @@ def recover(args) -> dict: idle_memory_mib=int(args.idle_memory_mib), idle_utilization_percent=int(args.idle_utilization_percent), ) - _, _, _, gpus = RUN._select_exactly_two_gpus(runtime) - allocation = RUN.allocate_arms(arms, gpus) + _, _, _, gpus = _select_recovery_gpus(runtime) + allocation = _recovery_allocation(arms, gpus) pools = BASE.allocate_cpu_pools( arms, int(contract["verifier_workers_per_arm"]) ) From f8d098ed00933c666c0047b01720f6c3d30a600b Mon Sep 17 00:00:00 2001 From: dohyun Date: Sat, 25 Jul 2026 16:55:00 -0700 Subject: [PATCH 18/31] Add staged offline evaluation funnel --- .../test_sfm_b1_offline_eval_funnel.py | 68 +++ .../run_sfm_b1_offline_eval_funnel.py | 506 ++++++++++++++++++ .../sfm_b1_offline_eval.py | 34 +- 3 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval_funnel.py create mode 100644 overnight_run_07_12_sfm/run_sfm_b1_offline_eval_funnel.py diff --git a/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval_funnel.py b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval_funnel.py new file mode 100644 index 0000000..11f9069 --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_sfm_b1_offline_eval_funnel.py @@ -0,0 +1,68 @@ +from pathlib import Path +import sys +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import run_sfm_b1_offline_eval_funnel as FUNNEL # noqa: E402 +import sfm_b1_offline_eval as EVAL # noqa: E402 + + +def _row(name, round_index, *, sr, cr, validity): + return { + "arm": name, + "round": round_index, + "checkpoint": f"/tmp/{name}_{round_index}.pt", + "checkpoint_sha256": f"{name}-{round_index}", + "SR": sr, + "CR": cr, + "timeout": 1.0 - sr - cr, + "Validity": validity, + "clearance": 0.1, + "time_to_goal": 10.0, + } + + +def test_selection_rejects_zero_success_low_collision_collapse(): + r0 = _row("pretrained", 0, sr=0.6, cr=0.4, validity=0.5) + collapsed = _row("a", 2, sr=0.0, cr=0.0, validity=0.9) + viable = _row("b", 1, sr=0.7, cr=0.2, validity=0.7) + selected, contract = FUNNEL.choose_candidates( + [collapsed, viable], r0, top_k=1 + ) + assert selected == [viable] + assert contract["r0_SR_gate"] == 0.6 + assert not contract["fallback_used"] + + +def test_selection_fallback_is_highest_success(): + r0 = _row("pretrained", 0, sr=0.8, cr=0.2, validity=0.5) + first = _row("a", 1, sr=0.4, cr=0.1, validity=0.9) + second = _row("b", 2, sr=0.7, cr=0.3, validity=0.6) + selected, contract = FUNNEL.choose_candidates( + [first, second], r0, top_k=1 + ) + assert selected == [second] + assert contract["fallback_used"] + + +def test_evaluator_artifacts_follow_requested_m(tmp_path): + previous = EVAL.M_PER_GAMMA + try: + EVAL.M_PER_GAMMA = 10 + assert EVAL._artifact_prefix() == "raw_m10_offline" + assert EVAL._status() == "SFM_B1_OFFLINE_RAW_M10_COMPLETE" + finally: + EVAL.M_PER_GAMMA = previous + + +def test_evaluator_rejects_nonpositive_m_before_checkpoint_loading(): + args = SimpleNamespace(m_per_gamma=0) + try: + EVAL.run(args) + except ValueError as error: + assert "--m-per-gamma must be positive" in str(error) + else: + raise AssertionError("nonpositive M must fail") diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_eval_funnel.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_eval_funnel.py new file mode 100644 index 0000000..a3c7718 --- /dev/null +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_eval_funnel.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Evaluate a completed selector sweep with M10 -> M50 staging. + +Training artifacts are immutable. All arm/round checkpoints first share one +raw temperature-one M10 bank. The top liveness-preserving screening cells are +then evaluated on a disjoint M50 bank. A later cross-selector job is +responsible for the final disjoint M100 confirmation. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + + +HERE = Path(__file__).resolve().parent +EVALUATOR = HERE / "sfm_b1_offline_eval.py" +GAMMAS = 7 + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def sha256_json(value: Any) -> str: + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":") + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def read_json(path: Path) -> dict: + if not path.is_file(): + raise FileNotFoundError(path) + with path.open() as stream: + return json.load(stream) + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + with temporary.open("w") as stream: + json.dump(value, stream, indent=2, allow_nan=False) + os.replace(temporary, path) + + +def validate_training(run_root: Path) -> tuple[dict, dict]: + declaration_path = run_root / "RUN_DECLARATION.json" + training_path = run_root / "TRAINING_COMPLETE.json" + declaration = read_json(declaration_path) + training = read_json(training_path) + if declaration.get("status") != "SFM_B1_OFFLINE_9ARM_DECLARED": + raise RuntimeError("invalid run declaration") + if training.get("status") != "SFM_B1_OFFLINE_9ARM_TRAINING_COMPLETE": + raise RuntimeError("training is not complete") + if training.get("declaration_sha256") != sha256_file(declaration_path): + raise RuntimeError("training marker does not authenticate declaration") + contract = declaration.get("contract", {}) + if declaration.get("contract_sha256") != sha256_json(contract): + raise RuntimeError("declaration contract digest mismatch") + checkpoint = Path(contract["checkpoint"]).resolve() + if sha256_file(checkpoint) != contract["checkpoint_sha256"]: + raise RuntimeError("pretrained checkpoint digest mismatch") + arms = training.get("arms", {}) + if len(arms) != 9: + raise RuntimeError(f"expected 9 trained arms, got {len(arms)}") + for name, arm in arms.items(): + checkpoints = arm.get("checkpoints", []) + if [row.get("round") for row in checkpoints] != list(range(11)): + raise RuntimeError(f"{name}: incomplete round checkpoints") + for row in checkpoints: + path = Path(row["path"]) + if sha256_file(path) != row["sha256"]: + raise RuntimeError(f"{name}: checkpoint digest mismatch: {path}") + return declaration, training + + +def pooled_row(arm: str, record: dict) -> dict: + pooled = record["cell"]["summary"]["pooled"] + clearance = pooled["successful_clearance"]["mean"] + time_to_goal = pooled["successful_time_to_goal"]["mean"] + return { + "arm": arm, + "round": int(record["round"]), + "checkpoint": record["cell"]["checkpoint"], + "checkpoint_sha256": record["cell"]["checkpoint_sha256"], + "SR": float(pooled["SR"]), + "CR": float(pooled["CR"]), + "timeout": float(pooled["timeout"]), + "Validity": float(pooled["Validity"]["mean"]), + "clearance": None if clearance is None else float(clearance), + "time_to_goal": ( + None if time_to_goal is None else float(time_to_goal) + ), + } + + +def safety_key(row: dict) -> tuple: + clearance = ( + -float(row["clearance"]) + if row["clearance"] is not None else float("inf") + ) + time_to_goal = ( + float(row["time_to_goal"]) + if row["time_to_goal"] is not None else float("inf") + ) + return ( + float(row["CR"]), + -float(row["Validity"]), + -float(row["SR"]), + clearance, + time_to_goal, + int(row["round"]), + str(row["arm"]), + ) + + +def fallback_key(row: dict) -> tuple: + return ( + -float(row["SR"]), + float(row["CR"]), + -float(row["Validity"]), + int(row["round"]), + str(row["arm"]), + ) + + +def choose_candidates( + rows: list[dict], r0: dict, *, top_k: int +) -> tuple[list[dict], dict]: + post = [row for row in rows if int(row["round"]) > 0] + eligible = [ + row for row in post if float(row["SR"]) >= float(r0["SR"]) + ] + ordered = sorted(eligible, key=safety_key) + fallback_used = False + if len(ordered) < top_k: + fallback_used = True + seen = { + (row["arm"], row["round"], row["checkpoint_sha256"]) + for row in ordered + } + for row in sorted(post, key=fallback_key): + key = (row["arm"], row["round"], row["checkpoint_sha256"]) + if key not in seen: + ordered.append(row) + seen.add(key) + if len(ordered) >= top_k: + break + return ordered[:top_k], { + "rule": ( + "among post-expansion cells with SR >= common-r0 SR, minimize CR, " + "then maximize window Validity and SR; if fewer than top-k pass " + "the liveness gate, supplement by highest SR" + ), + "r0_SR_gate": float(r0["SR"]), + "eligible_cells": len(eligible), + "fallback_used": fallback_used, + } + + +def evaluator_command( + checkpoints: list[str], + labels: list[str], + *, + scene_profile: str, + ep0: int, + noise_seed: int, + m_per_gamma: int, + workers: int, + cache_dir: Path, + output_dir: Path, +) -> list[str]: + return [ + sys.executable, + str(EVALUATOR), + "--checkpoints", + *checkpoints, + "--labels", + *labels, + "--scene-profile", + scene_profile, + "--ep0", + str(ep0), + "--noise-seed", + str(noise_seed), + "--m-per-gamma", + str(m_per_gamma), + "--device", + "cuda:0", + "--workers", + str(workers), + "--cache-dir", + str(cache_dir), + "--output-dir", + str(output_dir), + ] + + +def run_job( + name: str, + command: list[str], + *, + gpu_index: int, + cpu_start: int, + cpu_count: int, + log_dir: Path, +) -> dict: + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{name}.log" + environment = os.environ.copy() + environment["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" + environment["CUDA_VISIBLE_DEVICES"] = str(gpu_index) + environment["PYTHONPATH"] = str(HERE) + cpu_end = cpu_start + cpu_count - 1 + launched = [ + "taskset", "-c", f"{cpu_start}-{cpu_end}", *command + ] + with log_path.open("w") as stream: + completed = subprocess.run( + launched, + cwd=HERE, + env=environment, + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + ) + if completed.returncode: + raise RuntimeError( + f"{name} failed with {completed.returncode}; see {log_path}" + ) + return { + "name": name, + "command": launched, + "log": str(log_path), + "log_sha256": sha256_file(log_path), + } + + +def run_parallel( + jobs: list[dict], + *, + gpu_index: int, + cpu_start: int, + cpu_count: int, + log_dir: Path, +) -> list[dict]: + results = [] + with ThreadPoolExecutor(max_workers=len(jobs)) as executor: + futures = { + executor.submit( + run_job, + job["name"], + job["command"], + gpu_index=gpu_index, + cpu_start=cpu_start + index * cpu_count, + cpu_count=cpu_count, + log_dir=log_dir, + ): job["name"] + for index, job in enumerate(jobs) + } + for future in as_completed(futures): + result = future.result() + print(f"COMPLETE {result['name']}", flush=True) + results.append(result) + return sorted(results, key=lambda row: row["name"]) + + +def run(args) -> dict: + started_at = datetime.now(timezone.utc) + run_root = Path(args.run_root).resolve() + output_root = Path(args.output_dir).resolve() + if output_root.exists(): + raise FileExistsError(output_root) + output_root.mkdir(parents=True) + declaration, training = validate_training(run_root) + contract = declaration["contract"] + selector = str(contract["execution_selector"]) + checkpoint = str(Path(contract["checkpoint"]).resolve()) + scene_profile = str(contract["scene_profile"]) + + screen_root = output_root / "screening_m10" + screen_cache = screen_root / "cache" + common_dir = screen_root / "common_r0" + common_command = evaluator_command( + [checkpoint], + ["r0"], + scene_profile=scene_profile, + ep0=args.screen_ep0, + noise_seed=args.screen_noise_seed, + m_per_gamma=args.screen_m, + workers=args.workers, + cache_dir=screen_cache, + output_dir=common_dir, + ) + common_job = run_job( + "screen_common_r0", + common_command, + gpu_index=args.gpu_index, + cpu_start=args.cpu_start, + cpu_count=args.workers, + log_dir=output_root / "logs", + ) + common_metrics = read_json( + common_dir / f"raw_m{args.screen_m}_offline_metrics.json" + ) + r0 = pooled_row("pretrained", common_metrics["records"][0]) + + screen_jobs = [] + for arm_name, arm in sorted(training["arms"].items()): + checkpoints = [checkpoint] + [ + row["path"] for row in arm["checkpoints"] if row["round"] > 0 + ] + labels = ["r0"] + [ + f"r{row['round']}" + for row in arm["checkpoints"] if row["round"] > 0 + ] + output = screen_root / arm_name + screen_jobs.append({ + "name": f"screen_{arm_name}", + "output": output, + "command": evaluator_command( + checkpoints, + labels, + scene_profile=scene_profile, + ep0=args.screen_ep0, + noise_seed=args.screen_noise_seed, + m_per_gamma=args.screen_m, + workers=args.workers, + cache_dir=screen_cache, + output_dir=output, + ), + }) + screen_logs = run_parallel( + screen_jobs, + gpu_index=args.gpu_index, + cpu_start=args.cpu_start, + cpu_count=args.workers, + log_dir=output_root / "logs", + ) + screening_rows = [] + for job in screen_jobs: + payload = read_json( + job["output"] + / f"raw_m{args.screen_m}_offline_metrics.json" + ) + screening_rows.extend( + pooled_row(job["name"].removeprefix("screen_"), record) + for record in payload["records"] + if int(record["round"]) > 0 + ) + candidates, selection = choose_candidates( + screening_rows, r0, top_k=args.top_k + ) + write_json(output_root / "SCREENING_COMPLETE.json", { + "status": "SFM_B1_OFFLINE_M10_SCREENING_COMPLETE", + "selector": selector, + "bank": { + "M_per_gamma": args.screen_m, + "ep0": args.screen_ep0, + "noise_seed": args.screen_noise_seed, + }, + "common_r0": r0, + "selection": selection, + "selected_candidates": candidates, + "rows": screening_rows, + "logs": [common_job, *screen_logs], + }) + + confirm_root = output_root / "confirmation_m50" + confirm_cache = confirm_root / "cache" + confirm_jobs = [] + for index, candidate in enumerate(candidates): + name = f"candidate_{index:02d}_{candidate['arm']}_r{candidate['round']}" + output = confirm_root / name + confirm_jobs.append({ + "name": name, + "candidate": candidate, + "output": output, + "command": evaluator_command( + [checkpoint, candidate["checkpoint"]], + ["r0", f"r{candidate['round']}"], + scene_profile=scene_profile, + ep0=args.confirm_ep0, + noise_seed=args.confirm_noise_seed, + m_per_gamma=args.confirm_m, + workers=args.workers, + cache_dir=confirm_cache, + output_dir=output, + ), + }) + confirm_logs = run_parallel( + confirm_jobs, + gpu_index=args.gpu_index, + cpu_start=args.cpu_start, + cpu_count=args.workers, + log_dir=output_root / "logs", + ) + confirmation_rows = [] + r0_confirm = None + for job in confirm_jobs: + payload = read_json( + job["output"] + / f"raw_m{args.confirm_m}_offline_metrics.json" + ) + if r0_confirm is None: + r0_confirm = pooled_row("pretrained", payload["records"][0]) + confirmation_rows.append( + pooled_row(job["candidate"]["arm"], payload["records"][1]) + ) + winner_rows, confirmation_selection = choose_candidates( + confirmation_rows, r0_confirm, top_k=1 + ) + winner = winner_rows[0] + completed_at = datetime.now(timezone.utc) + result = { + "status": "SFM_B1_OFFLINE_SELECTOR_FUNNEL_COMPLETE", + "selector": selector, + "role": ( + "M10 common-bank screening followed by disjoint M50 selector " + "confirmation; no final claim or M100 confirmation" + ), + "source_commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=HERE, text=True + ).strip(), + "training_root": str(run_root), + "training_marker_sha256": sha256_file( + run_root / "TRAINING_COMPLETE.json" + ), + "checkpoint_sha256": contract["checkpoint_sha256"], + "gpu_index": args.gpu_index, + "cpu_range": [ + args.cpu_start, + args.cpu_start + 9 * args.workers - 1, + ], + "screening": { + "bank": { + "M_per_gamma": args.screen_m, + "ep0": args.screen_ep0, + "noise_seed": args.screen_noise_seed, + }, + "common_r0": r0, + "selection": selection, + "selected_candidates": candidates, + }, + "confirmation": { + "bank": { + "M_per_gamma": args.confirm_m, + "ep0": args.confirm_ep0, + "noise_seed": args.confirm_noise_seed, + }, + "common_r0": r0_confirm, + "rows": confirmation_rows, + "selection": confirmation_selection, + "selector_winner": winner, + }, + "logs": confirm_logs, + "started_at": started_at.isoformat(), + "completed_at": completed_at.isoformat(), + "wall_seconds": (completed_at - started_at).total_seconds(), + "next_step": ( + "compare selector winners and run exactly one disjoint raw-M100 " + "confirmation on a new scenario/noise bank" + ), + } + marker = output_root / "SELECTOR_FUNNEL_COMPLETE.json" + write_json(marker, result) + print(json.dumps({ + "status": result["status"], + "selector": selector, + "winner": winner, + "marker": str(marker), + }, indent=2, allow_nan=False)) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-root", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--gpu-index", type=int, required=True) + parser.add_argument("--cpu-start", type=int, required=True) + parser.add_argument("--workers", type=int, default=8) + parser.add_argument("--top-k", type=int, default=3) + parser.add_argument("--screen-m", type=int, default=10) + parser.add_argument("--screen-ep0", type=int, default=260_000) + parser.add_argument("--screen-noise-seed", type=int, default=2_026_072_3) + parser.add_argument("--confirm-m", type=int, default=50) + parser.add_argument("--confirm-ep0", type=int, default=270_000) + parser.add_argument("--confirm-noise-seed", type=int, default=2_026_072_4) + return parser + + +if __name__ == "__main__": + run(build_parser().parse_args()) diff --git a/overnight_run_07_12_sfm/sfm_b1_offline_eval.py b/overnight_run_07_12_sfm/sfm_b1_offline_eval.py index 70c0205..2e8be0d 100644 --- a/overnight_run_07_12_sfm/sfm_b1_offline_eval.py +++ b/overnight_run_07_12_sfm/sfm_b1_offline_eval.py @@ -1,6 +1,6 @@ """Raw SFM evaluation with terminal-truncated executed-window Validity. -Every checkpoint uses one fixed M=50/scenario/gamma seed and latent bank. The +Every checkpoint uses one fixed M/scenario/gamma seed and latent bank. The controller is the unguided raw flow at temperature one: it samples one H=10 plan per context and executes only its first action. Acquisition, verifier selection, fallback, guidance, and temperature search are absent. @@ -41,7 +41,8 @@ VERSION = "sfm_b1_offline_executed_window_v1" -M_PER_GAMMA = 50 +DEFAULT_M_PER_GAMMA = 50 +M_PER_GAMMA = DEFAULT_M_PER_GAMMA T = int(SP.T) H = int(SP.H) NFE = 8 @@ -57,6 +58,14 @@ ) +def _artifact_prefix() -> str: + return f"raw_m{M_PER_GAMMA}_offline" + + +def _status() -> str: + return f"SFM_B1_OFFLINE_RAW_M{M_PER_GAMMA}_COMPLETE" + + def _sha256_file(path: str | os.PathLike[str]) -> str: digest = hashlib.sha256() with open(path, "rb") as stream: @@ -699,11 +708,15 @@ def render(records: list[dict], output_dir: str) -> list[str]: os.makedirs(output_dir, exist_ok=True) outputs = [] for suffix in ("png", "pdf"): - path = os.path.join(output_dir, f"raw_m50_offline_curves.{suffix}") + path = os.path.join( + output_dir, f"{_artifact_prefix()}_curves.{suffix}" + ) figure.savefig(path, dpi=300, bbox_inches="tight") outputs.append(path) plt.close(figure) - manifest = os.path.join(output_dir, "raw_m50_offline_curves.figure.json") + manifest = os.path.join( + output_dir, f"{_artifact_prefix()}_curves.figure.json" + ) _write_json(manifest, { "status": "SFM_B1_OFFLINE_FIGURE_COMPLETE", "rounds": rounds, @@ -725,6 +738,10 @@ def render(records: list[dict], output_dir: str) -> list[str]: def run(args) -> dict: + global M_PER_GAMMA + M_PER_GAMMA = int(args.m_per_gamma) + if M_PER_GAMMA <= 0: + raise ValueError("--m-per-gamma must be positive") specs = _checkpoint_specs(args.checkpoints, args.labels) output_dir = os.path.abspath(args.output_dir) cache_dir = os.path.abspath( @@ -762,7 +779,7 @@ def run(args) -> dict: outputs = render(records, output_dir) result = { - "status": "SFM_B1_OFFLINE_RAW_M50_COMPLETE", + "status": _status(), "version": VERSION, "scene_profile": args.scene_profile, "environment": SS.scene_profile(args.scene_profile), @@ -778,7 +795,9 @@ def run(args) -> dict: "records": records, "outputs": outputs, } - result_path = os.path.join(output_dir, "raw_m50_offline_metrics.json") + result_path = os.path.join( + output_dir, f"{_artifact_prefix()}_metrics.json" + ) _write_json(result_path, result) result["metrics_json"] = result_path return result @@ -795,6 +814,9 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--ep0", type=int, default=DEFAULT_EP0) parser.add_argument("--noise-seed", type=int, default=DEFAULT_NOISE_SEED) + parser.add_argument( + "--m-per-gamma", type=int, default=DEFAULT_M_PER_GAMMA + ) parser.add_argument("--device", default="cuda") parser.add_argument("--workers", type=int, default=32) parser.add_argument("--cache-dir") From f06e8ddc11fc7a1f5bada2cb2a587bff3ba4e424 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sat, 25 Jul 2026 17:01:13 -0700 Subject: [PATCH 19/31] Automate disjoint final M100 confirmation --- .../run_sfm_b1_offline_final_confirmation.py | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 overnight_run_07_12_sfm/run_sfm_b1_offline_final_confirmation.py diff --git a/overnight_run_07_12_sfm/run_sfm_b1_offline_final_confirmation.py b/overnight_run_07_12_sfm/run_sfm_b1_offline_final_confirmation.py new file mode 100644 index 0000000..ae59030 --- /dev/null +++ b/overnight_run_07_12_sfm/run_sfm_b1_offline_final_confirmation.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Wait for three selector studies, then run one disjoint raw-M100 result.""" +from __future__ import annotations + +import argparse +import csv +from datetime import datetime, timezone +import json +from pathlib import Path +import time + +import run_sfm_b1_offline_eval_funnel as FUNNEL + + +def wait_for(path: Path, poll_seconds: int) -> dict: + while not path.is_file(): + print(f"WAITING {path}", flush=True) + time.sleep(poll_seconds) + return FUNNEL.read_json(path) + + +def margin_rows(run_root: Path) -> tuple[list[dict], dict]: + training = FUNNEL.read_json(run_root / "TRAINING_COMPLETE.json") + checkpoints = { + (name, int(row["round"])): row + for name, arm in training["arms"].items() + for row in arm["checkpoints"] + } + csv_path = ( + run_root / "evaluation" / "aggregate" + / "factorial_raw_m50_metrics.csv" + ) + rows = [] + with csv_path.open(newline="") as stream: + for source in csv.DictReader(stream): + round_index = int(source["round"]) + if round_index == 0: + continue + checkpoint = checkpoints[(source["arm"], round_index)] + rows.append({ + "arm": source["arm"], + "round": round_index, + "checkpoint": checkpoint["path"], + "checkpoint_sha256": checkpoint["sha256"], + "SR": float(source["SR"]), + "CR": float(source["CR"]), + "timeout": float(source["timeout"]), + "Validity": float(source["Validity"]), + "clearance": ( + None if not source["clearance"] + else float(source["clearance"]) + ), + "time_to_goal": ( + None if not source["time_to_goal"] + else float(source["time_to_goal"]) + ), + }) + common = FUNNEL.read_json( + run_root / "evaluation" / "common_r0" + / "raw_m50_offline_metrics.json" + ) + r0 = FUNNEL.pooled_row("pretrained", common["records"][0]) + return rows, r0 + + +def evaluate_candidates( + candidates: list[dict], + *, + checkpoint: str, + output_root: Path, + gpu_index: int, + cpu_start: int, + workers: int, + ep0: int, + noise_seed: int, +) -> tuple[list[dict], dict, list[dict]]: + cache = output_root / "cache" + jobs = [] + for index, candidate in enumerate(candidates): + name = f"margin_{index:02d}_{candidate['arm']}_r{candidate['round']}" + output = output_root / name + jobs.append({ + "name": name, + "candidate": candidate, + "output": output, + "command": FUNNEL.evaluator_command( + [checkpoint, candidate["checkpoint"]], + ["r0", f"r{candidate['round']}"], + scene_profile="double_density_velocity_ood", + ep0=ep0, + noise_seed=noise_seed, + m_per_gamma=50, + workers=workers, + cache_dir=cache, + output_dir=output, + ), + }) + logs = FUNNEL.run_parallel( + jobs, + gpu_index=gpu_index, + cpu_start=cpu_start, + cpu_count=workers, + log_dir=output_root / "logs", + ) + rows = [] + r0 = None + for job in jobs: + payload = FUNNEL.read_json( + job["output"] / "raw_m50_offline_metrics.json" + ) + if r0 is None: + r0 = FUNNEL.pooled_row("pretrained", payload["records"][0]) + rows.append( + FUNNEL.pooled_row( + job["candidate"]["arm"], payload["records"][1] + ) + ) + return rows, r0, logs + + +def run(args) -> dict: + output_root = Path(args.output_dir).resolve() + if output_root.exists(): + raise FileExistsError(output_root) + output_root.mkdir(parents=True) + margin_root = Path(args.margin_root).resolve() + cost_marker = Path(args.cost_marker).resolve() + balanced_marker = Path(args.balanced_marker).resolve() + margin_delivery = wait_for( + margin_root / "DELIVERY_COMPLETE.json", args.poll_seconds + ) + cost = wait_for(cost_marker, args.poll_seconds) + balanced = wait_for(balanced_marker, args.poll_seconds) + if margin_delivery.get("status") != ( + "SFM_B1_OFFLINE_9ARM_DELIVERY_COMPLETE" + ): + raise RuntimeError("invalid margin delivery") + for payload, selector in ( + (cost, "safemppi_cost"), + (balanced, "balanced_rank"), + ): + if ( + payload.get("status") + != "SFM_B1_OFFLINE_SELECTOR_FUNNEL_COMPLETE" + or payload.get("selector") != selector + ): + raise RuntimeError(f"invalid {selector} funnel marker") + + margin_all, margin_r0_screen = margin_rows(margin_root) + margin_candidates, margin_screen_selection = FUNNEL.choose_candidates( + margin_all, margin_r0_screen, top_k=args.top_k + ) + checkpoint = str(Path( + FUNNEL.read_json(margin_root / "RUN_DECLARATION.json") + ["contract"]["checkpoint"] + ).resolve()) + margin_confirm, shared_r0, margin_logs = evaluate_candidates( + margin_candidates, + checkpoint=checkpoint, + output_root=output_root / "margin_confirmation_m50", + gpu_index=args.gpu_index, + cpu_start=args.cpu_start, + workers=args.workers, + ep0=args.selector_ep0, + noise_seed=args.selector_noise_seed, + ) + margin_winners, margin_confirm_selection = FUNNEL.choose_candidates( + margin_confirm, shared_r0, top_k=1 + ) + selector_rows = [ + margin_winners[0], + cost["confirmation"]["selector_winner"], + balanced["confirmation"]["selector_winner"], + ] + global_winners, global_selection = FUNNEL.choose_candidates( + selector_rows, shared_r0, top_k=1 + ) + global_winner = global_winners[0] + + m100_root = output_root / "final_m100" + m100_command = FUNNEL.evaluator_command( + [checkpoint, global_winner["checkpoint"]], + ["r0", f"r{global_winner['round']}"], + scene_profile="double_density_velocity_ood", + ep0=args.final_ep0, + noise_seed=args.final_noise_seed, + m_per_gamma=100, + workers=args.workers, + cache_dir=m100_root / "cache", + output_dir=m100_root, + ) + m100_log = FUNNEL.run_job( + "final_m100", + m100_command, + gpu_index=args.gpu_index, + cpu_start=args.cpu_start, + cpu_count=args.workers, + log_dir=output_root / "logs", + ) + m100 = FUNNEL.read_json( + m100_root / "raw_m100_offline_metrics.json" + ) + r0_m100 = FUNNEL.pooled_row("pretrained", m100["records"][0]) + winner_m100 = FUNNEL.pooled_row( + global_winner["arm"], m100["records"][1] + ) + result = { + "status": "SFM_B1_OFFLINE_FINAL_M100_COMPLETE", + "role": ( + "three selectors compared on one shared disjoint M50 bank; " + "exactly one global winner confirmed on a further disjoint M100 " + "raw temperature-one bank" + ), + "source_commit": FUNNEL.subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=FUNNEL.HERE, text=True + ).strip(), + "margin_screening": { + "bank": {"M_per_gamma": 50, "ep0": 260_000}, + "selection": margin_screen_selection, + "candidates": margin_candidates, + }, + "shared_selector_confirmation": { + "bank": { + "M_per_gamma": 50, + "ep0": args.selector_ep0, + "noise_seed": args.selector_noise_seed, + }, + "common_r0": shared_r0, + "margin_rows": margin_confirm, + "margin_selection": margin_confirm_selection, + "selector_winners": selector_rows, + "global_selection": global_selection, + "global_winner": global_winner, + }, + "final_confirmation": { + "bank": { + "M_per_gamma": 100, + "ep0": args.final_ep0, + "noise_seed": args.final_noise_seed, + }, + "pretrained_r0": r0_m100, + "winner": winner_m100, + }, + "artifacts": { + "margin_logs": margin_logs, + "m100_log": m100_log, + }, + "completed_at": datetime.now(timezone.utc).isoformat(), + } + marker = output_root / "FINAL_M100_COMPLETE.json" + FUNNEL.write_json(marker, result) + print(json.dumps(result, indent=2, allow_nan=False)) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--margin-root", required=True) + parser.add_argument("--cost-marker", required=True) + parser.add_argument("--balanced-marker", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--gpu-index", type=int, default=3) + parser.add_argument("--cpu-start", type=int, default=144) + parser.add_argument("--workers", type=int, default=8) + parser.add_argument("--top-k", type=int, default=3) + parser.add_argument("--poll-seconds", type=int, default=60) + parser.add_argument("--selector-ep0", type=int, default=270_000) + parser.add_argument("--selector-noise-seed", type=int, default=2_026_072_4) + parser.add_argument("--final-ep0", type=int, default=280_000) + parser.add_argument("--final-noise-seed", type=int, default=2_026_072_5) + return parser + + +if __name__ == "__main__": + run(build_parser().parse_args()) From 779d2de2e3c7788744b8174311110f5efb71d6d9 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 02:49:18 -0700 Subject: [PATCH 20/31] Claude recipe study: opt-in replay interventions (hard A/B populations + exact-certified deterministic recovery C), extended trainer (lr/ESS/rounds/replay-mode knobs, immutable core reused), Stage-A branch diagnostics, locked-Kazuki fixed-bank evaluator with executed-window Validity; predeclared banks + research log; 6 new tests green, default-OFF bitwise equivalence asserted Co-Authored-By: Claude Fable 5 --- .../analysis/test_claude_offline_aug.py | 229 +++++++++ overnight_run_07_12_sfm/claude_kazuki_eval.py | 171 +++++++ overnight_run_07_12_sfm/claude_offline_aug.py | 373 ++++++++++++++ .../claude_offline_exec_ext.py | 286 +++++++++++ .../claude_recipe_study/EPISODE_BANKS.json | 39 ++ .../claude_recipe_study/RESEARCH_LOG.md | 32 ++ overnight_run_07_12_sfm/claude_stageA.py | 459 ++++++++++++++++++ 7 files changed, 1589 insertions(+) create mode 100644 overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py create mode 100644 overnight_run_07_12_sfm/claude_kazuki_eval.py create mode 100644 overnight_run_07_12_sfm/claude_offline_aug.py create mode 100644 overnight_run_07_12_sfm/claude_offline_exec_ext.py create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md create mode 100644 overnight_run_07_12_sfm/claude_stageA.py diff --git a/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py new file mode 100644 index 0000000..a73c236 --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py @@ -0,0 +1,229 @@ +import copy + +import numpy as np +import torch + +import claude_offline_aug as AUG +import sfm_b1_offline_replay as OR +import sfm_b1_offline_store as OS +import sfm_metrics2 as SM + + +def _result(y): + return dict( + resolved=True, y=int(y), taskspace=bool(y), collision_free=bool(y), + certificate=bool(y), full_h=True, terminal_step=10, + diagnostics={"margin": 0.25}, + ) + + +def _context(shard, *, scenario, gamma, step, state, ped_xy, ped_vel): + return shard.add_context( + scenario_id=scenario, gamma=gamma, step=step, + state=np.asarray(state, np.float32), + hp10=np.zeros((10, 16, 12), np.float32), + low5=np.zeros(5, np.float32), + hist=np.zeros((16, 2), np.float32), + ped_xy=np.asarray(ped_xy, np.float32).reshape(-1, 2), + ped_vel=np.asarray(ped_vel, np.float32).reshape(-1, 2), + ) + + +def _add(shard, *, scenario, gamma, step, y, state, ped_xy, ped_vel, + controls, collision_after=False, trap=False, result=None): + context_id = _context( + shard, scenario=scenario, gamma=gamma, step=step, state=state, + ped_xy=ped_xy, ped_vel=ped_vel, + ) + window_id = shard.add_executed_window( + context_id, np.asarray(controls, np.float32), + np.zeros(20, np.float32), result or _result(y), + execution_source="selected_B" if y else "raw_continuation", + nvp_context=not bool(y), candidate_id=0 if y else None, + acquisition_step=0 if y else None, sigma=0.4 if y else None, + hp_margin=0.2, mode="U", + ) + shard.windows[window_id].update( + collision_after_action=bool(collision_after), trap_event=bool(trap), + ) + return window_id + + +FAST = np.full((10, 2), 1.2, np.float32) # strong forward motion +STILL = np.zeros((10, 2), np.float32) # no displacement +FAR_PED = [[5.5, 0.5]] +NEAR_PED = [[0.35, 0.12]] +ZERO_VEL = [[0.0, 0.0]] + + +class _TinyPolicy(torch.nn.Module): + def __init__(self): + super().__init__() + self.enc_grid = torch.nn.Linear(1, 1, bias=False) + self.head = torch.nn.Linear(20, 20, bias=False) + self.d = 20 + self.u_max = 2.0 + + def ctx_from(self, grid, low, hist): + del grid, hist + return low[:, :1] + + def forward(self, value, tau, context): + del tau, context + return self.head(value) + + def cfm_loss(self, controls, context, weights=None): + del context + value = controls.reshape(len(controls), self.d) / self.u_max + per = (self.head(value) - value).square().mean(dim=1) + if weights is None: + return per.mean() + return (per * weights).sum() / weights.sum() + + def module_groups(self): + return {"E_g": self.enc_grid, "head": self.head} + + +def _freeze_encoder(policy): + for parameter in policy.enc_grid.parameters(): + parameter.requires_grad_(False) + + +def _mixed_shard(): + shard = OS.ExecutedRoundShard(1) + gammas = (0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0) + for index in range(10): + _add( + shard, scenario=100 + index, gamma=gammas[index % 7], step=index, + y=index < 7, state=[0.5, 0.5, 0.5, 0.5], + ped_xy=NEAR_PED if index % 2 else FAR_PED, ped_vel=ZERO_VEL, + controls=FAST if index < 7 else STILL, + collision_after=(index == 8), trap=(index == 9), + ) + return shard + + +class _InlineExecutor: + def map(self, fn, tasks): + return [fn(task) for task in tasks] + + +def test_original_mode_returns_untouched_shard(): + shard = _mixed_shard() + view, report = AUG.build_replay_view(shard, "original") + assert view is shard + assert report["mode"] == "original" + + +def test_original_mode_replay_is_bitwise_identical_to_control(): + shard = _mixed_shard() + torch.manual_seed(0) + policy_a = _TinyPolicy() + policy_b = copy.deepcopy(policy_a) + _freeze_encoder(policy_a) + _freeze_encoder(policy_b) + opt_a = torch.optim.Adam( + [p for p in policy_a.parameters() if p.requires_grad], lr=1e-3, + ) + opt_b = torch.optim.Adam( + [p for p in policy_b.parameters() if p.requires_grad], lr=1e-3, + ) + control = OR.replay( + policy_a, opt_a, shard, alpha=0.01, exposure_epochs=1, batch=4, + device="cpu", seed=7, + ) + treated = AUG.replay_with_mode( + policy_b, opt_b, shard, mode="original", alpha=0.01, + exposure_epochs=1, batch=4, device="cpu", seed=7, + ) + for key, value in policy_a.state_dict().items(): + assert torch.equal(value, policy_b.state_dict()[key]), key + assert control["optimizer_steps"] == treated["optimizer_steps"] + + +def test_population_tagging_follows_declared_rules(): + shard = OS.ExecutedRoundShard(2) + # near + moving certified positive -> pop A + _add(shard, scenario=1, gamma=0.5, step=0, y=1, + state=[0, 0, 1.0, 0], ped_xy=[[0.8, 0.6]], ped_vel=ZERO_VEL, + controls=FAST) + # far certified positive -> excluded from pop A + _add(shard, scenario=2, gamma=0.5, step=0, y=1, + state=[0, 0, 1.0, 0], ped_xy=FAR_PED, ped_vel=ZERO_VEL, + controls=np.full((10, 2), 0.4, np.float32)) + # certified but still (slow) positive -> excluded from A, NEVER in B + _add(shard, scenario=3, gamma=0.5, step=0, y=1, + state=[0, 0, 0, 0], ped_xy=[[0.8, 0.6]], ped_vel=ZERO_VEL, + controls=STILL) + # negative with actual collision -> pop B + _add(shard, scenario=4, gamma=0.5, step=0, y=0, + state=[0, 0, 1.0, 0], ped_xy=NEAR_PED, ped_vel=ZERO_VEL, + controls=FAST, collision_after=True) + # negative, no collision flags, but no displacement -> pop B (no progress) + _add(shard, scenario=5, gamma=0.5, step=0, y=0, + state=[0, 0, 0, 0], ped_xy=FAR_PED, ped_vel=ZERO_VEL, + controls=STILL) + pop_a, pop_b, stats = AUG.tag_populations(shard) + assert [w["context_id"] for w in pop_a] == [0] + assert sorted(w["context_id"] for w in pop_b) == [3, 4] + assert stats["popA"] == 1 and stats["popB"] == 2 + # the certified-slow window is not relabeled + assert all(int(w["y"]) == 0 for w in pop_b) + + +def test_recovery_candidates_are_deterministic_and_bounded(): + state = [1.0, 1.0, 1.5, -0.5] + first = AUG.recovery_candidates(state) + second = AUG.recovery_candidates(state) + assert len(first) == 1 + len(AUG.BRAKE_STEPS) * AUG.K_DIR * len(AUG.ACCELS) + for (controls_a, prov_a), (controls_b, prov_b) in zip(first, second): + assert np.array_equal(controls_a, controls_b) + assert prov_a == prov_b + assert controls_a.shape == (10, 2) + assert float(np.abs(controls_a).max()) <= 2.0 + 1e-6 + + +def test_recovery_records_are_exactly_certified_with_provenance(): + shard = OS.ExecutedRoundShard(3) + _add(shard, scenario=9, gamma=0.5, step=4, y=0, + state=[2.0, 2.0, 0.8, 0.0], ped_xy=[[2.9, 2.0]], + ped_vel=[[-0.5, 0.0]], controls=FAST, collision_after=True) + records, audit = AUG.build_recovery_records( + shard, shard.Dminus, _InlineExecutor(), + ) + assert records, "an escapable context must yield certified recovery" + assert len(records) <= AUG.RECOVERY_KEEP + assert audit["certified_kept"] == len(records) + for record in records: + context = shard.contexts[record["context_id"]] + recheck = SM.verify_query( + context["state"], record["controls"], context["ped_xy"], + context["ped_vel"], context["gamma"], + ) + assert recheck["resolved"] and int(recheck["y"]) == 1 + assert record["execution_source"] == "synthetic_certified_recovery" + prov = record["recovery_provenance"] + assert prov["parent_context_id"] == record["context_id"] + assert "generator" in prov and "objective_goal_distance" in prov + + +def test_hard_recovery_replay_respects_exact_once_accounting(): + shard = _mixed_shard() + policy = _TinyPolicy() + _freeze_encoder(policy) + optimizer = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], lr=1e-3, + ) + result = AUG.replay_with_mode( + policy, optimizer, shard, mode="hard_recovery", alpha=0.01, + exposure_epochs=1, batch=4, device="cpu", seed=11, + executor=_InlineExecutor(), + ) + report = result["replay_intervention"] + assert report["mode"] == "hard_recovery" + assert "recovery_audit" in report + view = report["view"] + assert view["D"] == view["Dplus"] + view["Dminus"] + assert result["positive_eligible"] == view["Dplus"] + assert result["negative_eligible"] == view["Dminus"] + assert result["exact_once_per_exposure_epoch"] is True diff --git a/overnight_run_07_12_sfm/claude_kazuki_eval.py b/overnight_run_07_12_sfm/claude_kazuki_eval.py new file mode 100644 index 0000000..46e6961 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_kazuki_eval.py @@ -0,0 +1,171 @@ +"""Locked Kazuki comparator on a fixed M/gamma bank with executed-window Validity. + +Additive evaluation-only script: it changes nothing in the B1 pipeline. The +comparator is the existing generate-guide-refine ``kazuki_sfm_deploy`` with the +locked configuration (safe_coefs=(0.3,), goal_coef=0.5, zero gamma spans, +sample_seed=700000) on the same Hp10 pretrained prior. Episodes are seeded with +``SS.make_humans(episode, 0, n_ped, speed_range)`` exactly as the raw offline +evaluator, so a shared ep0 gives the same pedestrian bank. Validity is the +identical terminal-truncated executed-window metric from +``sfm_b1_offline_eval._verify_executed_episode``. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import json +import multiprocessing as mp +import os + +import numpy as np + +import _paths # noqa: F401 + + +LOCKED = dict(safe_coef=0.3, goal_coef=0.5, sample_seed=700_000) + + +def _rollout_gamma(payload): + """Worker: run every episode of one gamma cell and attach validity.""" + (checkpoint, scene_profile, ep0, m_per_gamma, gamma, device) = payload + import torch # noqa: F401 (worker-local import keeps spawn cheap to reason about) + import grid_policy_sfm as GPS + import sfm_b1_offline_eval as OE + import sfm_kazuki as KZ + import sfm_protocol as SP + import sfm_scene as SS + + environment = SS.scene_profile(scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + policy.eval() + config = KZ.KazukiConfig( + safe_coefs=(float(LOCKED["safe_coef"]),), + goal_coef=float(LOCKED["goal_coef"]), + ).validate() + rows = [] + for episode in range(int(ep0), int(ep0) + int(m_per_gamma)): + rollout = KZ.kazuki_sfm_deploy( + policy, episode, float(gamma), cfg=config, + n_ped=environment["n_ped"], T=SP.T, device=device, + ped_speed_range=tuple(environment["ped_speed_range"]), + sample_seed=int(LOCKED["sample_seed"]), collect_diagnostics=False, + ) + success = bool(rollout["success"]) + collision = bool(rollout["collision"]) + steps = int(rollout["steps"]) + row = { + "episode": int(episode), + "gamma": float(gamma), + "status": ( + "success" if success + else "collision" if collision else "timeout" + ), + "success": success, + "collision": collision, + "timeout": bool(not success and not collision), + "steps": steps, + "time_to_goal": steps * SS.DT if success else None, + "min_clearance": float(rollout["min_clear"]), + "successful_clearance": ( + float(rollout["min_clear"]) if success else None + ), + "states": np.asarray(rollout["states"], np.float32), + "controls": np.asarray(rollout["controls"], np.float32), + "ped_xy": np.asarray(rollout["peds"], np.float32), + "ped_vel": np.asarray(rollout["ped_vels"], np.float32), + } + validity = OE._verify_executed_episode(row) + for key in ("states", "controls", "ped_xy", "ped_vel"): + row.pop(key) + row.update(validity) + rows.append(row) + return rows + + +def run(args) -> dict: + import sfm_b1_offline_eval as OE + import sfm_kazuki as KZ + import sfm_protocol as SP + import sfm_scene as SS + + output_dir = os.path.abspath(args.output_dir) + os.makedirs(output_dir, exist_ok=True) + checkpoint = os.path.abspath(args.checkpoint) + checkpoint_sha = OE._sha256_file(checkpoint) + payloads = [ + ( + checkpoint, args.scene_profile, int(args.ep0), + int(args.m_per_gamma), float(gamma), args.device, + ) + for gamma in SP.GAMMAS + ] + context = mp.get_context("spawn") + with ProcessPoolExecutor( + max_workers=min(len(payloads), int(args.workers)), + mp_context=context, + ) as executor: + cell_rows = list(executor.map(_rollout_gamma, payloads)) + rows = [row for cell in cell_rows for row in cell] + summary = OE.summarize( + rows, seed=int(args.ep0) + int(checkpoint_sha[:8], 16) % 100_000, + ) + OE._assert_zero_verifier_errors(summary) + result = { + "status": "CLAUDE_KAZUKI_FIXED_BANK_COMPLETE", + "method": "default Kazuki generate-guide-refine (locked)", + "kazuki_config": dict(LOCKED), + "kazuki_config_full": { + key: (list(value) if isinstance(value, tuple) else value) + for key, value in vars(KZ.KazukiConfig( + safe_coefs=(float(LOCKED["safe_coef"]),), + goal_coef=float(LOCKED["goal_coef"]), + ).validate()).items() + if isinstance(value, (int, float, str, bool, tuple, type(None))) + }, + "checkpoint": checkpoint, + "checkpoint_sha256": checkpoint_sha, + "scene_profile": args.scene_profile, + "environment": SS.scene_profile(args.scene_profile), + "bank": { + "ep0": int(args.ep0), + "M_per_gamma": int(args.m_per_gamma), + "same_scenario_ids_for_every_gamma": True, + "pedestrian_seeding": "SS.make_humans(episode, 0, n_ped, speed_range)", + }, + "summary": summary, + "rows": rows, + "metric_semantics": { + "Validity": ( + "identical executed sliding-window metric as " + "sfm_b1_offline_eval (H_t=min(10,N_tau-t), exact GREEN verifier)" + ), + "comparator_semantics": ( + "learned prior plus reward guidance and MPPI refinement; " + "no retuning; no external shield or fallback" + ), + }, + } + path = os.path.join( + output_dir, f"kazuki_m{int(args.m_per_gamma)}_metrics.json" + ) + OE._write_json(path, result) + print(path) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", required=True) + parser.add_argument( + "--scene-profile", default="double_density_velocity_ood", + ) + parser.add_argument("--ep0", type=int, required=True) + parser.add_argument("--m-per-gamma", type=int, required=True) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--workers", type=int, default=7) + parser.add_argument("--output-dir", required=True) + return parser + + +if __name__ == "__main__": + run(build_parser().parse_args()) diff --git a/overnight_run_07_12_sfm/claude_offline_aug.py b/overnight_run_07_12_sfm/claude_offline_aug.py new file mode 100644 index 0000000..b09e110 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_offline_aug.py @@ -0,0 +1,373 @@ +"""Opt-in replay-data interventions for offline executed-window SFM expansion. + +Everything in this module is additive and OFF by default: with +``replay_mode="original"`` the caller uses ``sfm_b1_offline_replay.replay`` on +the untouched :class:`ExecutedRoundShard` and no code here runs. The exact +B1 gathering, GP acquisition, B querying, verifier, and raw evaluation are +never modified here; this module only re-composes which resolved executed +records (plus optional exact-certified synthetic recovery records) the replay +step trains on. + +Declared population rules (fixed BEFORE evaluating their effect): + +Population A - near-obstacle verified positives. An executed window with +``y=1`` whose plan-time constant-velocity predicted minimum time-indexed +pedestrian clearance is at most ``NEAR_CLEARANCE_MAX`` metres and whose +10-step displacement is at least ``MIN_DISPLACEMENT`` metres (successful +avoidance = certified, close to a constraint boundary, and actually moving; +the displacement floor equals the existing declared trap displacement). + +Population B - collision/trap/no-progress negatives. An executed window with +``y=0`` satisfying at least one of: actual collision after the executed +action (``collision_after_action``); plan-time predicted window collision +(``collision_free`` false); the existing declared trap rule fired at this +step (``trap_event``: closed-loop displacement < 0.2 m over the last 10 +executed steps); or window displacement below ``MIN_DISPLACEMENT`` (the same +declared 10-step/0.2 m insufficient-progress rule applied to the executed +window). A certified-but-slow window (y=1) is never relabeled negative. + +Population C - deterministic certified recovery positives. For each hard +context (the context of a population-B window), solve the deterministic +control problem written out in :func:`recovery_candidates`: + + minimize J(u) = || p_10(u) - GOAL ||_2 + over the fixed finite deterministic family below + subject to |u_t|_inf <= U_MAX, double-integrator dynamics with DT, + and the EXACT full-H10 verifier certificate y=1 + (task-space bounds, time-indexed CV collision avoidance, + GREEN moving-window SOCP certificate). + +Family: closed-loop brake steps b in {0,2,4} (u_t = clip(-v_t/DT)) followed +by bang-bang steering u = a*(cos t_k, sin t_k) for the first half of the +remaining steps and -a*(...) for the second half, over K_DIR=16 world +directions and magnitudes a in {0.7, 1.4, 2.0}; plus the pure 10-step brake. +Candidates are pre-filtered by the cheap exact numpy task-space and +CV-collision checks, ranked by J, and at most ``PREVERIFY_CAP`` are submitted +to the exact SOCP verifier; the first ``RECOVERY_KEEP`` certified candidates +(in increasing J) enter the positive set. A failed candidate is discarded, +never relabeled. Recovery rows join the positive replay population at their +parent context, so the existing hierarchical mass (gamma -> episode -> +context -> query) automatically splits the parent context's mass across +them; they carry ``x0 = zeros(20)`` for schema compatibility and are NEVER +eligible for the GP buffer (the GP reads only the real ExecutedRoundShard). +The recovery controller itself is never deployed at evaluation time. +""" +from __future__ import annotations + +import math + +import numpy as np + +import _paths # noqa: F401 +import sfm_b1_offline_replay as OR +import sfm_b1_offline_store as OS +import sfm_metrics2 as SM +import sfm_scene as SS + +NEAR_CLEARANCE_MAX = 0.35 +MIN_DISPLACEMENT = 0.2 +BRAKE_STEPS = (0, 2, 4) +K_DIR = 16 +ACCELS = (0.7, 1.4, 2.0) +PREVERIFY_CAP = 24 +RECOVERY_KEEP = 2 +REPLAY_MODES = ("original", "hard", "hard_recovery") + + +def declared_rules(): + return dict( + population_A=dict( + requires="y=1", + predicted_min_clearance_max=NEAR_CLEARANCE_MAX, + window_displacement_min=MIN_DISPLACEMENT, + ), + population_B=dict( + requires="y=0", + any_of=[ + "collision_after_action", + "not collision_free (plan-time predicted window collision)", + "trap_event (declared closed-loop 10-step/0.2m rule)", + f"window displacement < {MIN_DISPLACEMENT} m over H=10", + ], + ), + population_C=dict( + objective="min ||p_10(u) - GOAL||_2 over the fixed family", + family=dict( + brake_steps=list(BRAKE_STEPS), k_dir=K_DIR, + accels=list(ACCELS), plus="pure 10-step closed-loop brake", + ), + preverify_cap=PREVERIFY_CAP, + keep_per_context=RECOVERY_KEEP, + certificate="exact full-H10 SM.verify_query y=1 only", + ), + ) + + +def _window_geometry(context, controls): + segment = SM.rollout_positions(context["state"], controls) + prediction = SM.predict_pedestrians( + context["ped_xy"], context["ped_vel"], H=len(controls), + ) + clearance = float( + np.linalg.norm(segment[:, None, :] - prediction, axis=2).min() + - SS.R_PED + ) + displacement = float(np.linalg.norm(segment[-1] - segment[0])) + return clearance, displacement + + +def tag_populations(shard): + """Classify every executed window; returns (popA, popB, stats).""" + pop_a, pop_b = [], [] + reasons = dict(actual_collision=0, predicted_collision=0, trap=0, + no_progress=0) + for window in shard.windows: + context = shard.contexts[int(window["context_id"])] + clearance, displacement = _window_geometry( + context, window["controls"], + ) + if int(window["y"]) == 1: + if ( + clearance <= NEAR_CLEARANCE_MAX + and displacement >= MIN_DISPLACEMENT + ): + pop_a.append(window) + else: + actual = bool(window.get("collision_after_action")) + predicted = not bool(window["collision_free"]) + trap = bool(window.get("trap_event")) + slow = displacement < MIN_DISPLACEMENT + if actual or predicted or trap or slow: + pop_b.append(window) + reasons["actual_collision"] += int(actual) + reasons["predicted_collision"] += int(predicted) + reasons["trap"] += int(trap) + reasons["no_progress"] += int(slow) + stats = dict( + D=len(shard.windows), Dplus=len(shard.Dplus), + Dminus=len(shard.Dminus), popA=len(pop_a), popB=len(pop_b), + popB_reasons=reasons, rules=declared_rules(), + ) + return pop_a, pop_b, stats + + +def _closed_loop_brake(state, steps): + """Deterministic max-effort brake controls for ``steps`` steps.""" + velocity = np.asarray(state, np.float32).reshape(4)[2:4].copy() + controls = [] + for _ in range(steps): + action = np.clip(-velocity / SS.DT, -SS.U_MAX, SS.U_MAX) + controls.append(action.astype(np.float32)) + velocity = velocity + SS.DT * action + return controls, velocity + + +def recovery_candidates(state): + """The fixed deterministic candidate family for one context.""" + candidates = [] + full_brake, _ = _closed_loop_brake(state, 10) + candidates.append(( + np.asarray(full_brake, np.float32), + dict(kind="brake10", brake=10, theta=None, accel=None), + )) + for brake in BRAKE_STEPS: + prefix, _ = _closed_loop_brake(state, brake) + remaining = 10 - brake + forward = math.ceil(remaining / 2) + for k in range(K_DIR): + theta = 2.0 * math.pi * k / K_DIR + direction = np.array( + [math.cos(theta), math.sin(theta)], np.float32, + ) + for accel in ACCELS: + steer = [accel * direction] * forward + steer += [-accel * direction] * (remaining - forward) + controls = np.asarray(prefix + steer, np.float32) + if controls.shape != (10, 2): + raise AssertionError("recovery candidate must be H=10") + candidates.append(( + np.clip(controls, -SS.U_MAX, SS.U_MAX), + dict(kind="brake_steer", brake=brake, + theta=round(theta, 6), accel=accel), + )) + return candidates + + +def _prefilter(context, controls): + """Cheap exact numpy feasibility check + objective J.""" + segment = SM.rollout_positions(context["state"], controls) + if not SM.taskspace_ok(segment): + return None + prediction = SM.predict_pedestrians( + context["ped_xy"], context["ped_vel"], H=10, + ) + if not SM.collision_free_time_indexed(segment, prediction): + return None + return float(np.linalg.norm(segment[-1] - SS.GOAL)) + + +def build_recovery_records(shard, hard_windows, executor): + """Exact-certified recovery positives for the given hard windows. + + Returns (records, audit). Every returned record passed the exact + full-H10 verifier inside ``executor`` (the same worker pool and + ``SM.verify_in_worker`` entry as B1 queries). + """ + context_ids = sorted({int(w["context_id"]) for w in hard_windows}) + tasks, meta = [], [] + per_context_pool = {} + for context_id in context_ids: + context = shard.contexts[context_id] + scored = [] + for controls, provenance in recovery_candidates(context["state"]): + objective = _prefilter(context, controls) + if objective is not None: + scored.append((objective, controls, provenance)) + scored.sort(key=lambda row: (row[0], str(row[2]))) + pool = scored[:PREVERIFY_CAP] + per_context_pool[context_id] = len(pool) + for rank, (objective, controls, provenance) in enumerate(pool): + tasks.append(( + context_id, rank, context["state"], controls, + context["ped_xy"], context["ped_vel"], context["gamma"], + )) + meta.append((context_id, rank, objective, controls, provenance)) + results = list(executor.map(SM.verify_in_worker, tasks)) + verified = {} + for (context_id, rank, result), (_, _, objective, controls, provenance) \ + in zip(results, meta): + verified.setdefault(int(context_id), []).append( + (int(rank), float(objective), controls, provenance, result), + ) + records, audit_rows = [], [] + certified_total = queried_total = 0 + for context_id in context_ids: + rows = sorted(verified.get(context_id, []), key=lambda r: r[0]) + queried_total += len(rows) + kept = 0 + for rank, objective, controls, provenance, result in rows: + if kept >= RECOVERY_KEEP: + break + if not result.get("resolved"): + continue + if int(result.get("y", 0)) != 1 or not bool(result.get("full_h")): + continue + certified_total += 1 + kept += 1 + records.append(dict( + window_id=None, query_id=None, + context_id=int(context_id), + controls=np.asarray(controls, np.float32), + x0=np.zeros(20, np.float32), + y=1, taskspace=True, collision_free=True, certificate=True, + full_h=True, terminal_step=10, train_eligible=True, + execution_source="synthetic_certified_recovery", + nvp_context=False, candidate_id=None, acquisition_step=None, + sigma=None, hp_margin=None, mode="recovery", + verifier_diagnostics=dict(result["diagnostics"]), + recovery_provenance=dict( + parent_round=int(shard.round_i), + parent_context_id=int(context_id), + generator=provenance, + objective_goal_distance=float(objective), + prefilter_rank=int(rank), + ), + )) + audit_rows.append(dict( + context_id=int(context_id), rank=int(rank), + generator=provenance, J=float(objective), + verifier=dict( + y=int(result["y"]), taskspace=bool(result["taskspace"]), + collision_free=bool(result["collision_free"]), + certificate=bool(result["certificate"]), + slack=float(result["diagnostics"]["slack"]), + ), + )) + audit = dict( + hard_contexts=len(context_ids), + exact_verifier_queries=queried_total, + certified_kept=len(records), + certified_total_seen=certified_total, + keep_per_context=RECOVERY_KEEP, + preverify_cap=PREVERIFY_CAP, + per_context_preverified_pool_mean=( + float(np.mean(list(per_context_pool.values()))) + if per_context_pool else 0.0 + ), + rows=audit_rows, + ) + return records, audit + + +class ShardView: + """Duck-typed shard exposing a re-composed training population. + + Shares the parent shard's contexts; windows are the selected subset plus + optional synthetic records, re-indexed with dense window/query ids so the + untouched ``sfm_b1_offline_replay.replay`` machinery (hierarchy mass, + stratified batches, exact-once accounting) applies verbatim. + """ + + def __init__(self, parent, windows): + self.round_i = int(parent.round_i) + self.contexts = parent.contexts + self.windows = [] + for index, window in enumerate(windows): + row = dict(window) + row["window_id"] = index + row["query_id"] = index + self.windows.append(row) + + @property + def D(self): + return list(self.windows) + + @property + def Dplus(self): + return [row for row in self.windows if row["y"] == 1] + + @property + def Dminus(self): + return [row for row in self.windows if row["y"] == 0] + + +def build_replay_view(shard, mode, executor=None): + """Compose the replay population for ``mode``; returns (view, report).""" + if mode not in REPLAY_MODES: + raise ValueError(f"replay mode must be one of {REPLAY_MODES}") + if mode == "original": + return shard, dict(mode=mode, note="untouched ExecutedRoundShard") + pop_a, pop_b, stats = tag_populations(shard) + report = dict(mode=mode, populations=stats) + windows = list(pop_a) + list(pop_b) + if mode == "hard_recovery": + if executor is None: + raise ValueError("hard_recovery needs the verifier executor") + recovery, audit = build_recovery_records(shard, pop_b, executor) + report["recovery_audit"] = audit + windows = windows + recovery + if not any(int(row["y"]) == 1 for row in windows): + # Fail open to the untouched population rather than training on a + # positive-free set (the replay contract requires positives). + report["fallback"] = "no positives in composed set; using original" + return shard, report + view = ShardView(shard, windows) + report["view"] = dict( + D=len(view.windows), Dplus=len(view.Dplus), Dminus=len(view.Dminus), + ) + return view, report + + +def replay_with_mode( + policy, optimizer, shard, *, mode, alpha, exposure_epochs, batch, + device, seed, executor=None, +): + """Opt-in wrapper: ``original`` delegates verbatim to OR.replay.""" + view, report = build_replay_view(shard, mode, executor=executor) + result = OR.replay( + policy, optimizer, view, + alpha=alpha, exposure_epochs=exposure_epochs, batch=batch, + device=device, seed=seed, + ) + result["replay_intervention"] = report + return result diff --git a/overnight_run_07_12_sfm/claude_offline_exec_ext.py b/overnight_run_07_12_sfm/claude_offline_exec_ext.py new file mode 100644 index 0000000..a6c15f0 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_offline_exec_ext.py @@ -0,0 +1,286 @@ +"""Extended offline executed-window expansion runner (opt-in knobs). + +This is an additive experiment driver. It reuses the immutable core of +``sfm_b1_offline_exec`` verbatim — ``gather_offline_round`` (56 lineages, +K=16, B=4 exact verifier queries, executed-window-only store, NVP +continuation), ``gp_from_previous``, ``_calibrate_beta``, +``_initial_lengthscale`` — and differs ONLY in the declared recipe knobs: + +- ``lr`` (optimizer learning rate; control 1e-4), +- ``ess_target`` (acquisition ESS calibration target; control 0.5), +- ``rounds`` (number of macro-rounds), +- ``replay_mode`` in {original, hard, hard_recovery} + (see ``claude_offline_aug``; ``original`` delegates verbatim to + ``sfm_b1_offline_replay.replay``), +- ``alpha`` / ``exposure_epochs`` restricted to the original replay + contract sets {0, 0.01, 0.1} and {1, 10, 100}. + +With (lr=1e-4, ess_target=0.5, rounds=10, replay_mode="original") the run is +behaviourally identical to ``sfm_b1_offline_exec`` (same keyed seeds, same +calls); this equivalence is asserted by comparing round-1 shard digests +against the archived control run. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import copy +from dataclasses import asdict, dataclass +import json +import os +import time + +import torch + +import _paths # noqa: F401 +import claude_offline_aug as AUG +import grid_policy_sfm as GPS +import sfm_b1_expand as BX +import sfm_b1_offline_exec as OE +import sfm_b1_offline_store as OS +import sfm_b1_store as BS +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +@dataclass(frozen=True) +class ExtConfig: + alpha: float + exposure_epochs: int + selector: str = "margin" + rounds: int = 10 + lr: float = 1.0e-4 + ess_target: float = 0.5 + replay_mode: str = "original" + K: int = 16 + B: int = 4 + T: int = 180 + H: int = 10 + batch: int = 128 + nfe: int = 8 + temp: float = 1.0 + phi_s: float = 0.9 + gp_lam: float = OE.GP_LAMBDA + verifier_workers: int = 8 + seed: int = 20260724 + scene_profile: str = OE.SCENE_PROFILE + smoke: bool = False + tag: str = "ext" + + def validate(self): + if self.selector not in OE.EXECUTION_SELECTORS: + raise ValueError(f"selector must be one of {OE.EXECUTION_SELECTORS}") + if float(self.alpha) not in OE.ALPHAS: + raise ValueError(f"alpha must be one of {OE.ALPHAS}") + if int(self.exposure_epochs) not in OE.EXPOSURE_EPOCHS: + raise ValueError( + f"exposure_epochs must be one of {OE.EXPOSURE_EPOCHS}" + ) + if self.replay_mode not in AUG.REPLAY_MODES: + raise ValueError(f"replay_mode must be one of {AUG.REPLAY_MODES}") + if not 0.05 <= float(self.ess_target) <= 0.95: + raise ValueError("ess_target out of the studied range") + if not 0.0 < float(self.lr) <= 1.0e-3: + raise ValueError("lr out of the studied range") + if not 1 <= int(self.rounds) <= 20: + raise ValueError("rounds out of the studied range") + # The immutable scientific core is pinned exactly as in the control. + if ( + int(self.K), int(self.B), int(self.T), int(self.H), + int(self.batch), float(self.gp_lam), float(self.temp), + self.scene_profile, int(self.nfe), float(self.phi_s), + ) != (16, 4, 180, 10, 128, OE.GP_LAMBDA, 1.0, OE.SCENE_PROFILE, 8, 0.9): + raise ValueError("immutable offline executed-window core changed") + if int(self.verifier_workers) < 1: + raise ValueError("verifier_workers must be positive") + return self + + @property + def arm_name(self): + alpha = str(float(self.alpha)).replace(".", "p") + lr = f"{self.lr:.0e}".replace("-", "m") + ess = str(float(self.ess_target)).replace(".", "p") + return ( + f"{self.tag}_{self.selector}_a{alpha}_e{int(self.exposure_epochs):03d}" + f"_lr{lr}_ess{ess}_{self.replay_mode}" + ) + + +def run(checkpoint, outdir, cfg, *, device): + cfg.validate() + checkpoint = os.path.abspath(checkpoint) + outdir = os.path.abspath(outdir) + if not os.path.isfile(checkpoint): + raise FileNotFoundError(checkpoint) + checkpoint_sha = OS.sha256_file(checkpoint) + if checkpoint_sha != OE.EXPECTED_CHECKPOINT_SHA256: + raise ValueError( + f"checkpoint SHA mismatch: expected " + f"{OE.EXPECTED_CHECKPOINT_SHA256}, got {checkpoint_sha}" + ) + if os.path.exists(outdir): + raise FileExistsError(f"refusing to reuse output directory: {outdir}") + os.makedirs(outdir) + environment = SS.scene_profile(cfg.scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + frozen_parameters = BS.configure_expansion_trainability(policy) + visual_encoder_sha = BS.module_sha256(policy.enc_grid) + optimizer = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], lr=cfg.lr, + ) + BX._save_checkpoint(policy, os.path.join(outdir, "round_00.pt"), dict( + round=0, experiment=cfg.arm_name, source_checkpoint=checkpoint, + source_sha256=checkpoint_sha, encoder_sha256=visual_encoder_sha, + recipe=asdict(cfg), + )) + history = [] + previous_shard = None + preflight_scenarios = SP.expansion_scenarios(1, smoke=cfg.smoke) + preflight_replicas = [ + BX.Replica( + scenario_id, gamma, + n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id in preflight_scenarios for gamma in SP.GAMMAS + ] + ell0, ell, ell_preflight = OE._initial_lengthscale( + policy, preflight_replicas, cfg, device, + ) + with ProcessPoolExecutor(max_workers=cfg.verifier_workers) as executor: + for round_i in range(1, cfg.rounds + 1): + round_start = time.perf_counter() + scenarios = SP.expansion_scenarios(round_i, smoke=cfg.smoke) + replicas = [ + BX.Replica( + scenario_id, gamma, + n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id in scenarios for gamma in SP.GAMMAS + ] + if len(replicas) != 56: + raise RuntimeError("offline macro-round requires 56 episodes") + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + gp, gp_ids, gp_selection = OE.gp_from_previous( + phi_policy, previous_shard, round_i=round_i, ell=ell, + cap=OE.CAP, lam=cfg.gp_lam, phi_s=cfg.phi_s, device=device, + seed=cfg.seed + round_i * 101, + ) + beta, calibrated_ess = OE._calibrate_beta( + phi_policy, gp, replicas, cfg, device, round_i=round_i, + ) + shard = OS.ExecutedRoundShard(round_i) + gather = OE.gather_offline_round( + policy, phi_policy, gp, beta, replicas, cfg, shard, device, + executor, round_i=round_i, + ) + shard_path = os.path.join( + outdir, "round_shards", f"round_{round_i:02d}.pt", + ) + shard_manifest = shard.save(shard_path) + replay_start = time.perf_counter() + replay = AUG.replay_with_mode( + policy, optimizer, shard, + mode=cfg.replay_mode, alpha=cfg.alpha, + exposure_epochs=cfg.exposure_epochs, batch=cfg.batch, + device=device, seed=cfg.seed + round_i * 1_000_003, + executor=executor, + ) + gather["timers"]["replay"] = time.perf_counter() - replay_start + if BS.module_sha256(policy.enc_grid) != visual_encoder_sha: + raise RuntimeError("visual encoder SHA changed") + checkpoint_path = os.path.join(outdir, f"round_{round_i:02d}.pt") + BX._save_checkpoint(policy, checkpoint_path, dict( + round=round_i, experiment=cfg.arm_name, + source_checkpoint=checkpoint, source_sha256=checkpoint_sha, + encoder_sha256=visual_encoder_sha, recipe=asdict(cfg), + ell=ell, ell0=ell0, cap=OE.CAP, beta=float(beta), + )) + record = dict( + round=round_i, experiment=cfg.arm_name, + scenarios=list(map(int, scenarios)), + environment=environment, beta=float(beta), + calibrated_normalized_ess_over_remaining=float(calibrated_ess), + verifier=SM.verifier_manifest(), + gp_buffer_ids=gp_ids, gp_selection=gp_selection, + gp=gp.diagnostics(), gather=gather, replay=replay, + shard=shard_manifest, + checkpoint=os.path.abspath(checkpoint_path), + checkpoint_sha256=OS.sha256_file(checkpoint_path), + wall_seconds=time.perf_counter() - round_start, + ) + history.append(record) + with open(os.path.join(outdir, "metrics.jsonl"), "a") as stream: + stream.write(json.dumps(record, allow_nan=False) + "\n") + print(json.dumps(dict( + round=round_i, experiment=cfg.arm_name, + D=shard_manifest["D"], Dplus=shard_manifest["Dplus"], + Dminus=shard_manifest["Dminus"], beta=float(beta), + replay_mode=cfg.replay_mode, + Adam_steps=int(replay["optimizer_steps"]), + wall_seconds=record["wall_seconds"], + )), flush=True) + previous_shard = shard + + manifest = dict( + status="CLAUDE_SFM_B1_OFFLINE_EXT_COMPLETE", + experiment=cfg.arm_name, + scientific_role="offline_expansion_data_collector_not_safe_controller", + recipe=asdict(cfg), + replay_rules=AUG.declared_rules(), + constants=dict( + ell=ell, ell0=ell0, ell_preflight=ell_preflight, + gp_buffer_cap=OE.CAP, gp_lambda=OE.GP_LAMBDA, + expected_checkpoint_sha256=OE.EXPECTED_CHECKPOINT_SHA256, + replay_window_rounds=1, + ), + source=OE._source(), + source_checkpoint=checkpoint, + source_checkpoint_sha256=checkpoint_sha, + environment=environment, + frozen_parameters=frozen_parameters, + visual_encoder_sha=visual_encoder_sha, + history=history, + ) + OE._write_json(os.path.join(outdir, "COMPLETE.json"), manifest) + return manifest + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--alpha", type=float, required=True) + parser.add_argument("--exposure-epochs", type=int, required=True) + parser.add_argument( + "--selector", choices=OE.EXECUTION_SELECTORS, default="margin", + ) + parser.add_argument("--rounds", type=int, default=10) + parser.add_argument("--lr", type=float, default=1.0e-4) + parser.add_argument("--ess-target", type=float, default=0.5) + parser.add_argument( + "--replay-mode", choices=AUG.REPLAY_MODES, default="original", + ) + parser.add_argument("--verifier-workers", type=int, default=8) + parser.add_argument("--seed", type=int, default=20260724) + parser.add_argument("--device", default="cuda") + parser.add_argument("--smoke", action="store_true") + parser.add_argument("--tag", default="ext") + args = parser.parse_args(argv) + cfg = ExtConfig( + alpha=args.alpha, exposure_epochs=args.exposure_epochs, + selector=args.selector, rounds=args.rounds, lr=args.lr, + ess_target=args.ess_target, replay_mode=args.replay_mode, + verifier_workers=args.verifier_workers, seed=args.seed, + smoke=args.smoke, tag=args.tag, + ) + run(args.checkpoint, args.outdir, cfg, device=args.device) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json b/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json new file mode 100644 index 0000000..2481f8a --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json @@ -0,0 +1,39 @@ +{ + "status": "PREDECLARED_BEFORE_ANY_NEW_RUN", + "declared_at": "2026-07-26T02:33:00-07:00", + "declared_by": "claude agent/claude-sfm-best-recipe-20260726 @ f06e8ddc11fc7a1f5bada2cb2a587bff3ba4e424", + "scene_profile": "double_density_velocity_ood", + "environment": {"n_ped": 40, "ped_speed_range": [1.0, 2.0]}, + "gammas": [0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0], + "raw_evaluation": {"temperature": 1.0, "NFE": 8, "T": 180, "H": 10}, + "historical_banks_not_reused_for_selection": { + "expansion_training": {"ep0": 20000, "note": "8 scenarios/round; rounds 1-10 use 20000-20079; PRESERVED as the training expansion bank for all new training runs"}, + "codex_m10_screen": {"ep0": 260000, "noise_seed": 20260723, "role": "historical"}, + "codex_m50_selector_confirm": {"ep0": 270000, "noise_seed": 20260724, "role": "historical"}, + "codex_m100_final": {"ep0": 280000, "noise_seed": 20260725, "role": "historical; also reused here ONLY as the pre-registered baseline-reproduction bank (section 1 of the task), never for tuning or selection of the new recipe"} + }, + "new_private_banks_mutually_disjoint": { + "local_diagnosis": { + "ep0": 300000, "m_per_gamma": 8, "noise_seed": 20260726, + "role": "Stage A hard-episode mining and before/after single-update local checks (gathering-side); plus round-1 expansion episodes 20000-20007 which are training data" + }, + "anchor_raw": { + "ep0": 305000, "m_per_gamma": 12, "noise_seed": 20260727, + "role": "Stage A anchor bank: small fixed raw bank to detect global regressions caused by local repairs; never used for final selection" + }, + "qualification_raw": { + "ep0": 310000, "m_per_gamma": 25, "noise_seed": 20260728, + "role": "Stage B short-qualification fixed raw screening bank; candidate recipes are compared ONLY on this bank's raw policy metrics" + }, + "m50_checkpoint_selection": { + "ep0": 320000, "m_per_gamma": 50, "noise_seed": 20260729, + "role": "Stage D per-round fixed raw M50 CRN bank for the frozen final recipe; checkpoint selection uses ONLY this bank via the predeclared SELECTION_RULE.json" + }, + "m100_final_confirmation": { + "ep0": 330000, "m_per_gamma": 100, "noise_seed": 20260730, + "role": "Stage E disjoint confirmation of r0 vs selected expanded checkpoint vs locked Kazuki; never read before the checkpoint is frozen" + } + }, + "disjointness_note": "All new ep0 ranges (300000-330099) are disjoint from every historical bank listed in sfm_protocol.py (12000, 20000+, 50000, 80000, 90000, 110000, 130000, 150000, 170000, 190000, 210000, 230000, 250000) and from the codex funnel banks (260000, 270000, 280000). Noise seeds 20260726-20260730 are new.", + "rule": "No episode or noise bank used to choose hyperparameters appears in final confirmation." +} diff --git a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md new file mode 100644 index 0000000..4e2ccdd --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md @@ -0,0 +1,32 @@ +# Claude SFM Safe Flow Expansion — best fixed recipe study + +- Private worktree: `/home/dohyun/projects/safeMPPI-claude-sfm-recipe-f06e8dd` +- Branch: `agent/claude-sfm-best-recipe-20260726` from `f06e8ddc11fc7a1f5bada2cb2a587bff3ba4e424` (origin/agent/sfm-b1-offline-eval-funnel) +- Output root: `/data3/research1/claude_sfm_best_recipe_f06e8dd` +- r0 checkpoint: `/home/dohyun/projects/sfm_hp10_b1_runs/103476d/pretrained_hp10.pt` + - SHA-256 verified `1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215` (matches `safe_flow_expansion_SFM@491e478:checkpoints/hp10_pretrained_r0.pt` blob hash) +- Plotting contract checkout: `/home/dohyun/projects/safe_flow_expansion-claude-plot-87063d3` @ 87063d3 (read-only) +- GPUs: physical 1 and 3 (verified idle at claim time: 18–19 MiB, 0% util). Exact SOCP verifier stays CPU. +- Python: conda env `cfm_mppi` (Python 3.11). + +## 2026-07-26 02:30 — starting state inherited from Codex + +Prior completed work (all on `double_density_velocity_ood`, 40 peds, 1.0–2.0 m/s): + +- 27 trained arms (3 selectors × α∈{0,.01,.1} × exposure∈{1,10,100}), rounds 0–10, lr 1e-4, ESS 0.5, batch 128, seed 20260724, expansion bank ep 20000+: + - margin: `/data3/research1/sfm_b1_offline_exec_9arm_f36393b` + - safemppi_cost: `/data3/research1/sfm_b1_offline_cost_9arm_db80dfc` + - balanced_rank: `/data3/research1/sfm_b1_offline_balanced_9arm_97630c6` +- Funnel + final M100 (completed 2026-07-26 05:49 UTC): `/data3/research1/sfm_b1_offline_final_funnel_f06e8dd` + - M100 (ep0 280000, seed 20260725): r0 SR .660 CR .337 V .595 clr .118 t 8.65 + - global winner (cost α.01 exp10 r8): SR .690 CR .310 V .531 clr .117 t 7.52 — CR −2.7pt but Validity −6.4pt: **not** a genuine fixed-recipe win. + +### Key measured phenomenon driving this study + +Per-round fixed-raw M50 curves (margin 9-arm factorial CSV) show **SR collapse to 0.00 by round 3–4 in every margin arm** while Validity rises to ~.85 — a monotone slowdown (successful time 8.7→10.7→13→16.4 s) until nothing reaches the goal inside T=180. Balanced_rank arms collapse identically (M10 screening). Cost-selector arms avoid the collapse but mostly degrade (CR rises to .4–.6 in 6/9 arms). Only round-1/2 checkpoints ever beat r0, modestly (best M50: margin α.01 exp100 r1 = SR .694, CR .28, V .74, clr .128, t 10.7). + +Working hypothesis (to be tested in Stage A): replay trains the flow toward the *gathering controller's* executed-window distribution, which is verifier-gated and conservative; each round compounds the slowdown. The failure is data composition, not just step size. + +## Plan + +Stages A–E per task spec; banks in `EPISODE_BANKS.json`. Interventions implemented as opt-in modules, default OFF, original behavior preserved with existing tests. diff --git a/overnight_run_07_12_sfm/claude_stageA.py b/overnight_run_07_12_sfm/claude_stageA.py new file mode 100644 index 0000000..d4a423e --- /dev/null +++ b/overnight_run_07_12_sfm/claude_stageA.py @@ -0,0 +1,459 @@ +"""Stage A local failure diagnosis for the SFM B1 offline recipe study. + +Diagnostic-only tooling; trains nothing unless the ``update`` subcommand is +invoked, and never touches raw evaluation semantics. + +Subcommands +----------- +``mine`` — classify failure contexts of an archived ExecutedRoundShard. +``branch`` — instrumented closed-loop branch trace of declared episodes: + every one of the K=16 flow candidates is exact-verified (a + diagnostic superset of the B=4 budget), B-selection replicates + the round-1 acquisition (empty GP buffer, calibrated beta), + both execution selectors are evaluated, and the episode + advances with the B1 executed action (chosen selector, + raw-continuation at NVP) exactly as the offline collector. +``update`` — apply exactly one replay update (declared knobs, opt-in data + intervention) to the exact r0 checkpoint using an archived + round-1 shard, and save the updated checkpoint. +``compare`` — before/after tables from two ``branch`` traces. +""" +from __future__ import annotations + +import argparse +from collections import Counter +from concurrent.futures import ProcessPoolExecutor +import copy +import json +import os + +import numpy as np +import torch + +import _paths # noqa: F401 +import claude_offline_aug as AUG +import grid_policy_sfm as GPS +import sfm_b1_cost as BC +import sfm_b1_expand as BX +import sfm_b1_eval as BE +import sfm_b1_full_episode_audit as FA +import sfm_b1_offline_exec as OE +import sfm_b1_offline_store as OS +import sfm_b1_rbf as BR +import sfm_b1_store as BS +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + + +def _write_json(path, payload): + OE._write_json(path, payload) + + +# ---------------------------------------------------------------- mine ---- + +def mine(args): + shard = OS.ExecutedRoundShard.load(args.shard) + pop_a, pop_b, stats = AUG.tag_populations(shard) + nvp = [w for w in shard.windows if w.get("nvp_context")] + collisions = [w for w in shard.windows if w.get("collision_after_action")] + traps = [w for w in shard.windows if w.get("trap_event")] + by_gamma = Counter( + str(shard.contexts[w["context_id"]]["gamma"]) for w in pop_b + ) + payload = dict( + shard=os.path.abspath(args.shard), + stats=stats, + NVP_contexts=len(nvp), + collision_windows=len(collisions), + trap_windows=len(traps), + popB_by_gamma=dict(by_gamma), + examples=dict( + nvp=[_ctx_key(shard, w) for w in nvp[:20]], + collision=[_ctx_key(shard, w) for w in collisions[:20]], + trap=[_ctx_key(shard, w) for w in traps[:20]], + ), + ) + _write_json(args.out, payload) + print(json.dumps({k: payload[k] for k in ( + "NVP_contexts", "collision_windows", "trap_windows")}, indent=1)) + + +def _ctx_key(shard, window): + context = shard.contexts[int(window["context_id"])] + return dict( + scenario=int(context["scenario_id"]), gamma=float(context["gamma"]), + step=int(context["step"]), + ) + + +# -------------------------------------------------------------- branch ---- + +@torch.no_grad() +def branch(args): + device = args.device + policy, _ = GPS.load_sfm_policy(args.checkpoint, device=device) + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + cfg = OE.OfflineConfig(alpha=0.0, exposure_epochs=1, rounds=1, smoke=True) + environment = SS.scene_profile(cfg.scene_profile) + pairs = [ + (int(s), float(g)) + for s in args.scenarios for g in args.gammas + ] + replicas = [ + BX.Replica( + scenario_id, gamma, n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + for scenario_id, gamma in pairs + ] + # Round-1 acquisition state: empty GP buffer + calibrated beta, + # replicated exactly as the collector does at round 1. + gp = BR.RBFGP(float(args.ell), float(cfg.gp_lam)) + beta, ess = OE._calibrate_beta( + phi_policy, gp, replicas, cfg, device, round_i=1, + ) + traces = [] + outcomes = [] + with ProcessPoolExecutor(max_workers=args.workers) as executor: + for step in range(int(cfg.T)): + live, batch = BX._stack_prepared( + [r for r in replicas if r.alive], device, + ) + if not live: + break + windows, contexts, x0 = OE._keyed_windows( + policy, live, batch, K=cfg.K, round_i=1, step=step, + source="K", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows, _, raw_x0 = OE._keyed_windows( + policy, live, batch, K=1, round_i=1, step=step, + source="raw_continuation", seed=cfg.seed, nfe=cfg.nfe, + temp=cfg.temp, + ) + raw_windows = raw_windows[:, 0] + windows_np = windows.detach().cpu().numpy() + raw_np = raw_windows.detach().cpu().numpy() + features = OE._features_from_x0( + phi_policy, windows, contexts, x0, cfg.phi_s, + ) + selected_by_context, sigmas = [], [] + for index, replica in enumerate(live): + generator = torch.Generator(device=features.device) + generator.manual_seed(OE._keyed_seed( + cfg.seed, 1, replica.scenario_id, + f"{replica.gamma:.8f}", step, "acquisition", + )) + selected, trace = gp.sequential_acquire( + features[index], cfg.B, beta, generator=generator, + ) + selected_by_context.append(list(map(int, selected))) + sigmas.append([float(r["chosen_sigma"]) for r in trace]) + # Diagnostic superset: verify ALL K candidates + the raw plan. + tasks = [] + for index, replica in enumerate(live): + prepared = replica.prepared + for k in range(cfg.K): + tasks.append(( + index, k, prepared["state"], windows_np[index, k], + prepared["ped_xy"], prepared["ped_vel"], + replica.gamma, + )) + tasks.append(( + index, -1, prepared["state"], raw_np[index], + prepared["ped_xy"], prepared["ped_vel"], replica.gamma, + )) + results = list(executor.map(SM.verify_in_worker, tasks)) + by_context = {} + for index, k, result in results: + by_context.setdefault(int(index), {})[int(k)] = result + + for index, replica in enumerate(live): + prepared = replica.prepared + rows = [] + for k in range(cfg.K): + result = by_context[index][k] + margin, _, _ = BC.nominal_hp_margin( + prepared["state"], windows_np[index, k][0], + prepared["ped_xy"], replica.gamma, + ) + rows.append(dict( + candidate_id=k, + y=int(result.get("y", 0)) if result.get("resolved") + else None, + resolved=bool(result.get("resolved")), + hp_margin=float(margin), + in_B=k in selected_by_context[index], + controls=windows_np[index, k], + result=result, + )) + # B1 execution semantics restricted to the B queried rows. + query_rows = [ + dict( + candidate_id=row["candidate_id"], + acquisition_step=selected_by_context[index].index( + row["candidate_id"], + ), + controls=row["controls"], + result=row["result"], + mode=None, + sigma=sigmas[index][ + selected_by_context[index].index( + row["candidate_id"], + ) + ], + ) + for row in rows if row["in_B"] and row["resolved"] + ] + chosen = {} + for selector in ("margin", "safemppi_cost"): + chosen[selector] = BC.select_admissible( + [dict(r) for r in query_rows], selector=selector, + state=prepared["state"], ped_xy=prepared["ped_xy"], + ped_vel=prepared["ped_vel"], gamma=replica.gamma, + ) + execute = chosen[args.selector] + raw_result = by_context[index][-1] + if execute is None: + controls = raw_np[index] + executed_y = ( + int(raw_result.get("y", 0)) + if raw_result.get("resolved") else None + ) + source = "raw_continuation" + else: + controls = np.asarray(execute["controls"], np.float32) + executed_y = int(execute["result"]["y"]) + source = f"verified_{args.selector}" + k_positive = sum(1 for r in rows if r["y"] == 1) + b_positive = sum( + 1 for r in rows if r["in_B"] and r["y"] == 1 + ) + b_admissible = sum( + 1 for r in rows + if r["in_B"] and r["y"] == 1 and r["hp_margin"] >= -1e-9 + ) + clearance, displacement = AUG._window_geometry( + dict( + state=prepared["state"], ped_xy=prepared["ped_xy"], + ped_vel=prepared["ped_vel"], + ), + controls, + ) + disagree = ( + chosen["margin"] is not None + and chosen["safemppi_cost"] is not None + and int(chosen["margin"]["candidate_id"]) + != int(chosen["safemppi_cost"]["candidate_id"]) + ) + traces.append(dict( + scenario=int(replica.scenario_id), + gamma=float(replica.gamma), step=int(step), + K_positive=int(k_positive), + B_positive=int(b_positive), + B_admissible=int(b_admissible), + NVP=execute is None, + K_pos_but_B_none=bool(k_positive > 0 and b_admissible == 0), + selector_disagreement=bool(disagree), + executed_source=source, + executed_y=executed_y, + executed_clearance=float(clearance), + executed_displacement=float(displacement), + sigma_selected=sigmas[index], + )) + BX._advance(replica, controls[0]) + FA._post_action_terminal(replica) + OE._finalize_alive(replicas) + for replica in replicas: + outcomes.append(dict( + scenario=int(replica.scenario_id), gamma=float(replica.gamma), + status=replica.status, steps=len(replica.controls), + min_clearance=float(replica.minimum_clearance), + )) + aggregate = dict( + contexts=len(traces), + NVP=sum(t["NVP"] for t in traces), + K_pos_but_B_none=sum(t["K_pos_but_B_none"] for t in traces), + selector_disagreement=sum(t["selector_disagreement"] for t in traces), + mean_K_positive=float(np.mean([t["K_positive"] for t in traces])), + mean_B_positive_fraction=float(np.mean([ + t["B_positive"] / cfg.B for t in traces + ])), + outcomes=Counter(o["status"] for o in outcomes), + beta=float(beta), calibrated_ess=float(ess), + ) + payload = dict( + checkpoint=os.path.abspath(args.checkpoint), + checkpoint_sha256=OS.sha256_file(args.checkpoint), + selector=args.selector, ell=float(args.ell), + scenarios=list(map(int, args.scenarios)), + gammas=list(map(float, args.gammas)), + aggregate={ + **{k: v for k, v in aggregate.items() if k != "outcomes"}, + "outcomes": dict(aggregate["outcomes"]), + }, + outcomes=outcomes, + traces=traces, + ) + torch.save(payload, args.out) + _write_json( + args.out + ".summary.json", + {k: payload[k] for k in ( + "checkpoint", "checkpoint_sha256", "selector", "aggregate", + "outcomes", + )}, + ) + print(json.dumps(payload["aggregate"], indent=1)) + + +# -------------------------------------------------------------- update ---- + +def update(args): + policy, _ = GPS.load_sfm_policy(args.checkpoint, device=args.device) + sha = OS.sha256_file(args.checkpoint) + if sha != OE.EXPECTED_CHECKPOINT_SHA256: + raise ValueError("update must start from the exact r0 checkpoint") + BS.configure_expansion_trainability(policy) + encoder_sha = BS.module_sha256(policy.enc_grid) + optimizer = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], lr=args.lr, + ) + shard = OS.ExecutedRoundShard.load(args.shard) + with ProcessPoolExecutor(max_workers=args.workers) as executor: + replay = AUG.replay_with_mode( + policy, optimizer, shard, mode=args.replay_mode, + alpha=args.alpha, exposure_epochs=args.exposure_epochs, + batch=128, device=args.device, seed=args.seed, + executor=executor, + ) + if BS.module_sha256(policy.enc_grid) != encoder_sha: + raise RuntimeError("visual encoder changed") + BX._save_checkpoint(policy, args.out, dict( + role="stageA_single_update", source_sha256=sha, + shard=os.path.abspath(args.shard), lr=args.lr, alpha=args.alpha, + exposure_epochs=args.exposure_epochs, replay_mode=args.replay_mode, + seed=args.seed, + )) + compact = { + k: replay.get(k) for k in ( + "positive_eligible", "negative_eligible", "optimizer_steps", + "module_relative_parameter_drift", "fixed_probe", + ) + } + compact["replay_intervention"] = { + k: v for k, v in replay.get("replay_intervention", {}).items() + if k != "recovery_audit" + } + audit = replay.get("replay_intervention", {}).get("recovery_audit") + if audit is not None: + compact["recovery_audit_counts"] = { + k: v for k, v in audit.items() if k != "rows" + } + _write_json(args.out + ".recovery_audit.json", audit) + _write_json(args.out + ".replay.json", dict( + replay={k: v for k, v in replay.items() if k != "epochs"}, + compact=compact, + )) + print(json.dumps(compact, indent=1, default=str)) + + +# ------------------------------------------------------------- compare ---- + +def compare(args): + before = torch.load(args.before, map_location="cpu", weights_only=False) + after = torch.load(args.after, map_location="cpu", weights_only=False) + rows = [] + outcomes_b = { + (o["scenario"], o["gamma"]): o for o in before["outcomes"] + } + outcomes_a = { + (o["scenario"], o["gamma"]): o for o in after["outcomes"] + } + for key in sorted(outcomes_b): + b, a = outcomes_b[key], outcomes_a.get(key) + traces_b = [ + t for t in before["traces"] + if (t["scenario"], t["gamma"]) == key + ] + traces_a = [ + t for t in after["traces"] + if (t["scenario"], t["gamma"]) == key + ] + rows.append(dict( + scenario=key[0], gamma=key[1], + status_before=b["status"], status_after=a and a["status"], + steps_before=b["steps"], steps_after=a and a["steps"], + NVP_before=sum(t["NVP"] for t in traces_b), + NVP_after=a and sum(t["NVP"] for t in traces_a), + B_pos_frac_before=float(np.mean([ + t["B_positive"] / 4 for t in traces_b + ])) if traces_b else None, + B_pos_frac_after=float(np.mean([ + t["B_positive"] / 4 for t in traces_a + ])) if traces_a else None, + )) + payload = dict( + before=dict( + checkpoint=before["checkpoint"], + aggregate=before["aggregate"], + ), + after=dict( + checkpoint=after["checkpoint"], aggregate=after["aggregate"], + ), + episodes=rows, + ) + _write_json(args.out, payload) + print(json.dumps(dict( + before=before["aggregate"], after=after["aggregate"], + ), indent=1)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + m = sub.add_parser("mine") + m.add_argument("--shard", required=True) + m.add_argument("--out", required=True) + + b = sub.add_parser("branch") + b.add_argument("--checkpoint", required=True) + b.add_argument("--scenarios", type=int, nargs="+", required=True) + b.add_argument("--gammas", type=float, nargs="+", required=True) + b.add_argument("--selector", default="margin", + choices=("margin", "safemppi_cost")) + b.add_argument("--ell", type=float, required=True, + help="round-1 lengthscale from the control run manifest") + b.add_argument("--workers", type=int, default=16) + b.add_argument("--device", default="cuda:0") + b.add_argument("--out", required=True) + + u = sub.add_parser("update") + u.add_argument("--checkpoint", required=True) + u.add_argument("--shard", required=True) + u.add_argument("--lr", type=float, default=1e-4) + u.add_argument("--alpha", type=float, default=0.01) + u.add_argument("--exposure-epochs", type=int, default=10) + u.add_argument("--replay-mode", default="original", + choices=AUG.REPLAY_MODES) + u.add_argument("--seed", type=int, default=20260724 + 1_000_003) + u.add_argument("--workers", type=int, default=16) + u.add_argument("--device", default="cuda:0") + u.add_argument("--out", required=True) + + c = sub.add_parser("compare") + c.add_argument("--before", required=True) + c.add_argument("--after", required=True) + c.add_argument("--out", required=True) + + args = parser.parse_args(argv) + dict(mine=mine, branch=branch, update=update, compare=compare)[args.cmd]( + args, + ) + + +if __name__ == "__main__": + main() From 2f4b68e8844ed66be73266a99e1fcdc721784a1a Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 04:01:17 -0700 Subject: [PATCH 21/31] Stage A results + orig_plus_recovery mode (declared pre-evaluation): full D+/D- plus appended certified recovery positives; 7 tests green Co-Authored-By: Claude Fable 5 --- .../analysis/test_claude_offline_aug.py | 16 +++ overnight_run_07_12_sfm/claude_offline_aug.py | 14 ++- .../claude_paper_trends.py | 105 ++++++++++++++++++ .../claude_recipe_study/RESEARCH_LOG.md | 17 +++ 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 overnight_run_07_12_sfm/claude_paper_trends.py diff --git a/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py index a73c236..e23ff5b 100644 --- a/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py +++ b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py @@ -207,6 +207,22 @@ def test_recovery_records_are_exactly_certified_with_provenance(): assert "generator" in prov and "objective_goal_distance" in prov +def test_orig_plus_recovery_keeps_full_population_and_appends(): + shard = _mixed_shard() + view, report = AUG.build_replay_view( + shard, "orig_plus_recovery", executor=_InlineExecutor(), + ) + added = report["recovery_audit"]["certified_kept"] + assert len(view.windows) == len(shard.windows) + added + assert len(view.Dminus) == len(shard.Dminus) + assert len(view.Dplus) == len(shard.Dplus) + added + synthetic = [ + w for w in view.windows + if w["execution_source"] == "synthetic_certified_recovery" + ] + assert len(synthetic) == added + + def test_hard_recovery_replay_respects_exact_once_accounting(): shard = _mixed_shard() policy = _TinyPolicy() diff --git a/overnight_run_07_12_sfm/claude_offline_aug.py b/overnight_run_07_12_sfm/claude_offline_aug.py index b09e110..5fe1d35 100644 --- a/overnight_run_07_12_sfm/claude_offline_aug.py +++ b/overnight_run_07_12_sfm/claude_offline_aug.py @@ -71,7 +71,7 @@ ACCELS = (0.7, 1.4, 2.0) PREVERIFY_CAP = 24 RECOVERY_KEEP = 2 -REPLAY_MODES = ("original", "hard", "hard_recovery") +REPLAY_MODES = ("original", "hard", "hard_recovery", "orig_plus_recovery") def declared_rules(): @@ -339,7 +339,17 @@ def build_replay_view(shard, mode, executor=None): return shard, dict(mode=mode, note="untouched ExecutedRoundShard") pop_a, pop_b, stats = tag_populations(shard) report = dict(mode=mode, populations=stats) - windows = list(pop_a) + list(pop_b) + if mode == "orig_plus_recovery": + # Declared BEFORE evaluation (Stage-A log 2026-07-26): keep the FULL + # original positive and negative populations and only APPEND the + # exact-certified recovery positives at their parent (hard) contexts. + if executor is None: + raise ValueError("orig_plus_recovery needs the verifier executor") + recovery, audit = build_recovery_records(shard, pop_b, executor) + report["recovery_audit"] = audit + windows = list(shard.windows) + recovery + else: + windows = list(pop_a) + list(pop_b) if mode == "hard_recovery": if executor is None: raise ValueError("hard_recovery needs the verifier executor") diff --git a/overnight_run_07_12_sfm/claude_paper_trends.py b/overnight_run_07_12_sfm/claude_paper_trends.py new file mode 100644 index 0000000..f78c188 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_paper_trends.py @@ -0,0 +1,105 @@ +"""Convert raw offline evaluator metrics into the paper trends contract. + +Reads one or more ``raw_m{M}_offline_metrics.json`` files produced by +``sfm_b1_offline_eval.py`` (each containing per-round records with the full +per-episode rows) and writes the per-(round,gamma) JSONL consumed by +``safe_flow_expansion@87063d3:scripts/paper_b1_margin50_trends.py``: + + {"round": r, "gamma": g, "m": M, "temp": 1.0, + "CR": {"mean": .., "se": ..}, "v_safe": {"mean": .., "se": ..}, + "clearance": {"mean": .., "se": ..}, "time": {"mean": .., "se": ..}} + +Standard errors are computed numerically from the stored rows (binomial SE +for CR; sample SE of per-trajectory validity fractions; sample SE over +successful episodes for clearance and time) and stored alongside the means, +as the study contract requires. The band semantics of the paper script are +unchanged: it applies Wilson intervals to CR/v_safe from (mean, m) and +mean +/- 1.96*se to clearance/time. +""" +from __future__ import annotations + +import argparse +import json +import math +import os +import subprocess +import sys + + +def _se_binomial(p, n): + return math.sqrt(max(p * (1.0 - p), 0.0) / n) if n else float("nan") + + +def _mean_se(values): + finite = [float(v) for v in values if v is not None] + if not finite: + return None, None + mean = sum(finite) / len(finite) + if len(finite) < 2: + return mean, 0.0 + var = sum((v - mean) ** 2 for v in finite) / (len(finite) - 1) + return mean, math.sqrt(var / len(finite)) + + +def convert(metrics_paths, jsonl_path): + rows_out = [] + for path in metrics_paths: + with open(path) as stream: + payload = json.load(stream) + for record in payload["records"]: + cell = record["cell"] + for gamma_key in cell["summary"]["per_gamma"]: + gamma = float(gamma_key) + rows = [ + row for row in cell["rows"] + if float(row["gamma"]) == gamma + ] + m = len(rows) + cr = sum(bool(r["collision"]) for r in rows) / m + v_mean, v_se = _mean_se([r["validity"] for r in rows]) + c_mean, c_se = _mean_se( + [r["successful_clearance"] for r in rows], + ) + t_mean, t_se = _mean_se([r["time_to_goal"] for r in rows]) + rows_out.append(dict( + round=int(record["round"]), gamma=gamma, m=m, temp=1.0, + CR=dict(mean=cr, se=_se_binomial(cr, m)), + v_safe=dict(mean=v_mean, se=v_se), + clearance=dict(mean=c_mean, se=c_se), + time=dict(mean=t_mean, se=t_se), + )) + rows_out.sort(key=lambda r: (r["round"], r["gamma"])) + os.makedirs(os.path.dirname(os.path.abspath(jsonl_path)), exist_ok=True) + with open(jsonl_path, "w") as stream: + for row in rows_out: + stream.write(json.dumps(row, allow_nan=False) + "\n") + return rows_out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metrics", nargs="+", required=True, + help="raw_m*_offline_metrics.json files (rounds merge)") + parser.add_argument("--label", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--stem", default="b1_margin50_metric_trends") + parser.add_argument( + "--paper-script", + default=("/home/dohyun/projects/safe_flow_expansion-claude-plot-" + "87063d3/scripts/paper_b1_margin50_trends.py"), + ) + args = parser.parse_args(argv) + outdir = os.path.abspath(args.outdir) + jsonl_path = os.path.join(outdir, f"{args.label}_trends_rows.jsonl") + rows = convert(args.metrics, jsonl_path) + print(f"{len(rows)} rows -> {jsonl_path}") + command = [ + sys.executable, args.paper_script, + "--arm", f"{args.label}={jsonl_path}", + "--outdir", outdir, "--stem", args.stem, + ] + subprocess.run(command, check=True) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md index 4e2ccdd..f473100 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md +++ b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md @@ -30,3 +30,20 @@ Working hypothesis (to be tested in Stage A): replay trains the flow toward the ## Plan Stages A–E per task spec; banks in `EPISODE_BANKS.json`. Interventions implemented as opt-in modules, default OFF, original behavior preserved with existing tests. + +## 2026-07-26 03:20 — modules, tests, baselines launched + +- New additive modules (commit 779d2de): `claude_offline_aug.py` (declared pop-A/B rules + deterministic certified-recovery generator; family = 145 candidates/context, prefilter cap 24, keep ≤2, exact `SM.verify_query` gate), `claude_offline_exec_ext.py` (opt-in lr/ESS/rounds/replay-mode; immutable core reused), `claude_stageA.py` (mine/branch/update/compare), `claude_kazuki_eval.py` (locked comparator + executed-window Validity), `claude_paper_trends.py` (evaluator → paper plot contract). +- Existing tests: 43 passed. New tests: 6 passed, incl. bitwise default-OFF equivalence of `replay_with_mode("original")` vs `OR.replay`, no-relabel guarantee, and independent exact recertification of every synthetic recovery record. +- Baselines launched on the pre-registered codex M100 bank (ep0 280000, seed 20260725): raw r0 + margin winner (α.01 exp100 r1) + cost winner (α.01 exp10 r8) on GPU1; locked Kazuki on GPU3. +- Stage A mining (codex round-1 shards): margin arm — 1034/4014 NVP contexts, 22 collision windows, 310 trap windows; cost arm — 1102 NVP, 19 collisions, only 32 traps. D+ plan-geometry drift r1→r3 (median displacement 0.95→0.70 m) supports the composition-drift hypothesis. +- Stage A single-update candidates U1–U8 launched (one replay round on r0 from archived round-1 shards; margin + cost shards × {control, lr1e-5, exp1, hard, hard_recovery, lowdose-hardrec}). + +## PREDECLARED Stage-B qualification rule (written before any Stage-B run) + +- Bank: qualification raw bank ep0 310000, noise seed 20260728, M=25/γ, temperature 1.0, via `sfm_b1_offline_eval.py` only. No gathering-controller SR, no training loss. +- Every candidate arm trains rounds 1–4 from the exact r0 checkpoint (seed 20260724, expansion bank ep 20000+, identical gather semantics). +- Eligibility per round r ∈ {1..4}: SR(r) ≥ SR(r0) − 0.02 AND timeout(r) ≤ timeout(r0) + 0.05 on the qualification bank (liveness gate; r0 evaluated on the same bank/noise). +- Arm score = its best eligible round ordered by (min CR, then max Validity, then max successful clearance, then min successful time-to-goal). Arms with no eligible round are disqualified (collapse). +- Stability tie-break: among arms whose best-round CR are within 0.03 of the leader, prefer the arm whose round-4 checkpoint is still eligible; among those, the better round-4 CR. Rationale: the frozen Stage-C/D recipe must hold 10 round-invariant macro-rounds. +- The frozen recipe = the winning arm's knobs verbatim; final study rounds fixed at 10; Stage-D checkpoint selection governed solely by SELECTION_RULE.json on the disjoint M50 bank (ep0 320000). From 1b161fe0e6868ccaed74c2f32e8579009507426e Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 08:39:13 -0700 Subject: [PATCH 22/31] Freeze Stage-C recipe (B2 cost/hard by predeclared rule) + SELECTION_RULE + Stage-A/B results in log Co-Authored-By: Claude Fable 5 --- .../claude_recipe_study/FIXED_RECIPE.json | 39 +++++++++++++++ .../claude_recipe_study/RESEARCH_LOG.md | 48 +++++++++++++++++++ .../claude_recipe_study/SELECTION_RULE.json | 35 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/FIXED_RECIPE.json create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/SELECTION_RULE.json diff --git a/overnight_run_07_12_sfm/claude_recipe_study/FIXED_RECIPE.json b/overnight_run_07_12_sfm/claude_recipe_study/FIXED_RECIPE.json new file mode 100644 index 0000000..540d778 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/FIXED_RECIPE.json @@ -0,0 +1,39 @@ +{ + "status": "FROZEN_BEFORE_FINAL_STUDY", + "declared_at": "2026-07-26T09:35:00-07:00", + "selected_by": "predeclared Stage-B qualification rule on the private M25 bank (ep0 310000, seed 20260728); winner = arm B2 best eligible round; no gathering-controller SR or training loss used", + "recipe": { + "name": "cost_hard_a0p01_e010_lr1em04_ess0p5", + "execution_selector": "safemppi_cost", + "replay_mode": "hard", + "replay_mode_semantics": "positives = population A only (certified windows with predicted min clearance <= 0.35 m AND window displacement >= 0.2 m); negatives = population B only (y=0 with actual collision, predicted window collision, declared trap rule, or displacement < 0.2 m); rules fixed in claude_offline_aug.py and declared before evaluation", + "alpha": 0.01, + "exposure_epochs": 10, + "lr": 1e-4, + "ess_target": 0.5, + "batch": 128, + "rounds": 10, + "seed": 20260724, + "K": 16, "B": 4, "T": 180, "H": 10, "nfe": 8, "temperature_gathering": 1.0, + "phi_s": 0.9, "gp_lambda": 0.01, "gp_cap": 512, + "scene_profile": "double_density_velocity_ood", + "expansion_bank_ep0": 20000, + "source_checkpoint_sha256": "1b5179c935d3eeff8824967d707d64cc9bab273949ee1f0e4f190172bab1b215", + "trainer": "claude_offline_exec_ext.py (reuses immutable gather/GP/beta core of sfm_b1_offline_exec.py)" + }, + "round_invariant": true, + "synthetic_recovery_mixture": "none in the frozen recipe (population C evaluated in Stage A/B: it repairs local NVP/collision contexts and raises Validity but does not preserve the liveness gate at any tested dose; B0-vs-B9 matched-round contrast showed no measurable marginal effect on top of margin/e100)", + "hard_data_mixture": "population A positives + population B negatives only, per replay_mode above", + "stage_b_evidence": { + "qual_bank_r0": {"SR": 0.669, "CR": 0.320, "Validity": 0.576, "clearance": 0.106, "time": 8.58}, + "winner_cell_B2_r1": {"SR": 0.714, "CR": 0.286, "Validity": 0.586, "clearance": 0.109, "time": 8.94}, + "eligible_runner_ups": [ + {"arm": "B3 cost/hard_recovery r1", "CR": 0.32}, + {"arm": "B8 cost/orig_plus_recovery ess.3 r1", "CR": 0.32}, + {"arm": "B1 cost/original r2", "CR": 0.33}, + {"arm": "B6 margin/hard_recovery lowdose r2", "CR": 0.34} + ], + "ineligible_high_validity_direction": "margin/original|orig_plus_recovery e100: V .69-.82, clearance up to .18, but SR fails the liveness gate at r1 and collapses to timeout from r2-r3 on the clean bank; reported as Pareto alternative, not selected" + }, + "checkpoint_selection": "SELECTION_RULE.json on the disjoint M50 bank (ep0 320000, seed 20260729) ONLY" +} diff --git a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md index f473100..01c0bfd 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md +++ b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md @@ -47,3 +47,51 @@ Stages A–E per task spec; banks in `EPISODE_BANKS.json`. Interventions impleme - Arm score = its best eligible round ordered by (min CR, then max Validity, then max successful clearance, then min successful time-to-goal). Arms with no eligible round are disqualified (collapse). - Stability tie-break: among arms whose best-round CR are within 0.03 of the leader, prefer the arm whose round-4 checkpoint is still eligible; among those, the better round-4 CR. Rationale: the frozen Stage-C/D recipe must hold 10 round-invariant macro-rounds. - The frozen recipe = the winning arm's knobs verbatim; final study rounds fixed at 10; Stage-D checkpoint selection governed solely by SELECTION_RULE.json on the disjoint M50 bank (ep0 320000). + +## 2026-07-26 05:30 — Stage A results (complete) + +**Mechanism (r0 branch trace, 24 gathering lineages, all-K exact verification):** 2117 contexts, 456 NVP (21.5%); only 49 (2.3%) had a positive in K that B missed → the flow itself lacks certifiable support at hard contexts; B=4 is not binding. NVP concentrates at episode start (43–51% in steps 0–39 → ~0 after step 60): origin-corner congestion with 40 fast pedestrians. Every collision episode dies through a terminal run of K+=0 NVP contexts with negative predicted clearance (uncertified raw execution). Selector disagreement at 59% of contexts. Certified deterministic escapes exist at ~53% of hard contexts (269/506 margin shard, 202/501 cost shard). + +**Single-update raw reads (diag M8 + anchor M12, n=140/ckpt):** r0 SR .614 / CR .386 / V .571. +- U6 cost-shard original: SR .714 / CR .286 / V .566, faster — only arm improving SR/CR/time; Validity flat. +- U1–U5 margin-shard arms: Validity +.08–.13 (U5 hard+recovery best, .698) but SR −.03–.07 and slower — conservative drift visible after ONE update. +- U7 cost-shard hard-only: WORSE than U6 across the board — discarding the goal-directed mass hurts. +- U8 lowdose hardrec: mild moves, dose too small per round. + +**Local repair (before/after branch traces, identical keyed latents):** U5 hard+recovery cut NVP 456→341 (−25%), raised B-positive fraction .712→.844, repaired the three hardest collision lineages (s20005 γ.1/γ.5, s20004 γ.5 → success), introduced slowdown timeouts elsewhere. U6 sped the policy up but *lowered* gathering certifiability (B+ frac .592, NVP 483). Conclusion: recovery data provides certifiable support exactly where the flow lacks it; the composition question (keep goal-seeking mass + add recovery) is what Stage B arms B4/B5/B8 test (`orig_plus_recovery`, declared before evaluation). + +**Kazuki locked baseline (M100 ep0 280000):** SR .779 / CR .217 / **Validity .350** / clearance .181 / time 4.36 s — fast and lower-CR than r0 but far below r0 on exact-certificate Validity (.35 vs .59). + +## Baseline reproduction complete (M100, ep0 280000, seed 20260725) + +| method | SR | CR | timeout | Validity | succ. clearance | succ. time | +|---|---:|---:|---:|---:|---:|---:| +| r0 raw (repro) | .6586 | .3386 | .0029 | .5944 | .1176 | 8.664 | +| r0 raw (codex funnel) | .6600 | .3371 | .0029 | .5945 | .1179 | 8.646 | +| B1 control: margin α.01 e100 **r1** | .7314 | .2371 | .0314 | .7390 | .1336 | 10.733 | +| B1 control: cost α.01 e10 **r8** (codex global winner) | .6900 | .3100 | .0000 | .5311 | .1169 | 7.520 | +| locked Kazuki (.3/.5) | .7786 | .2171 | .0043 | .3495 | .1809 | 4.355 | + +r0 reproduces codex within 1–2 flipped episodes (GPU FP nondeterminism). The margin-r1 control dominates the codex-selected cost-r8 on this bank — but it is a pre-collapse snapshot (SR→0 by r3–4 in that arm). Bar for the new fixed recipe: margin-r1-level CR/Validity gains with multi-round stability. + +## Stage B launched 05:20 — 8 arms × 4 rounds from exact r0 + +B1 cost/original, B2 cost/hard, B3 cost/hard_recovery, B4 cost/orig_plus_recovery (all α.01 e10 lr1e-4 ess.5); B5 cost/orig_plus_recovery lowdose (e1 lr1e-5); B6 margin/hard_recovery lowdose; B7 cost/original ess.3; B8 cost/orig_plus_recovery ess.3. Qualification: predeclared rule on M25 bank ep0 310000 (see above). + +### Stage B qualification (M25, ep0 310000; r0 = SR .669 / CR .320 / V .576 / clr .106 / t 8.58) + +Per arm r1..r4 (SR/CR/V): +- B1 cost orig: .60/.39/.54, .67/.33/.53, .64/.35/.53, .64/.36/.51 — no gain, V drifts down +- B2 cost hard: .71/.29/.59 then degrades to .62/.38/.52 +- B3 cost hardrec: .68/.32/.60 then degrades to .55/.45/.55 +- B4 cost origrec: ≈flat (.60–.66 SR, V .57–.59) +- B5 cost origrec lowdose: flat +- B6 margin hardrec lowdose: V .58→.65 climbing, CR .34–.40 (no CR gain), t 8.9→10.2 — slow conservative drift +- B7 cost orig ess.3: worse than B1 (lower-ESS acquisition does not help) +- B8 cost origrec ess.3: ≈B4 +Verdict: no cost-selector composition materially improves CR or Validity on this bank; the lower ESS target (0.3) is not beneficial. The strongest known pattern (margin/original/e100 — codex arm, r1 CR .237/V .739 on the M100 280k baseline) was absent from the set. + +### Stage B extension (declared 07:55 before reading its results) + +- B0: codex margin/original/α.01/e100 checkpoints r1–r4 evaluated on the SAME M25 qual bank (matched-round control; identical recipe lineage, same commit and seeds). +- B9: margin/orig_plus_recovery/α.01/e100/lr1e-4/ess.5, rounds 1–4 from exact r0 — tests the marginal contribution of certified recovery positives ON TOP of the strongest known recipe. Comparison B0 vs B9 at matched rounds on the same bank is the pre-registered arm-2/arm-3 style contrast for the final freeze decision; freeze criterion remains the predeclared Stage-B rule. diff --git a/overnight_run_07_12_sfm/claude_recipe_study/SELECTION_RULE.json b/overnight_run_07_12_sfm/claude_recipe_study/SELECTION_RULE.json new file mode 100644 index 0000000..8a5dfa6 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/SELECTION_RULE.json @@ -0,0 +1,35 @@ +{ + "status": "PREDECLARED_BEFORE_FINAL_STUDY", + "declared_at": "2026-07-26T05:05:00-07:00", + "applies_to": "Stage D fixed-recipe per-round raw M50 evaluation ONLY", + "bank": {"ep0": 320000, "noise_seed": 20260729, "m_per_gamma": 50, "temperature": 1.0, "scene_profile": "double_density_velocity_ood"}, + "evaluator": "sfm_b1_offline_eval.py (executed sliding-window Validity, exact GREEN verifier)", + "rule": { + "candidates": "rounds r >= 1 of the single frozen recipe run", + "liveness_gate": [ + "pooled SR(r) >= pooled SR(r0) - 0.02 on the same M50 CRN bank", + "pooled timeout(r) <= pooled timeout(r0) + 0.05" + ], + "objective_order_among_eligible": [ + "1. minimize pooled CR", + "2. maximize pooled window Validity (mean of per-trajectory fractions)", + "3. maximize pooled successful minimum clearance", + "4. minimize pooled successful time-to-goal", + "5. smallest round index" + ], + "four_user_metrics_operationalization": { + "collision_rate": "objective 1 (primary)", + "validity": "objective 2", + "successful_min_clearance": "objective 3", + "time_to_goal": "objective 4; a slower checkpoint is acceptable only if it strictly wins an earlier objective, which is exactly what the lexicographic order encodes" + }, + "if_no_round_passes_gate": "report honestly that the fixed recipe did not produce an eligible improvement; publish the full Pareto frontier over (CR, Validity, clearance, time) for all rounds and do NOT promote a collapsed checkpoint", + "pareto_reporting": "alongside the winner, all non-dominated rounds on (CR down, Validity up, clearance up, time down) are listed in the delivery" + }, + "prohibitions": [ + "no per-gamma or global temperature tuning (temperature fixed 1.0)", + "no gathering-controller SR or training-loss in selection", + "no checkpoint selection from the M100 confirmation bank", + "no recipe or Kazuki changes after reading confirmation" + ] +} From 511c1209085c4f047e667ecd0985e9f7771ce4b4 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 13:08:24 -0700 Subject: [PATCH 23/31] Recovery family v2 (goal-directed dodge-then-cruise, declared pre-evaluation) + mechanism snapshot/episode viz + M100 paired-difference analysis; 9 tests green Co-Authored-By: Claude Fable 5 --- .../analysis/test_claude_offline_aug.py | 49 +- .../claude_confirm_analysis.py | 160 ++++++ .../claude_mechanism_viz.py | 498 ++++++++++++++++++ overnight_run_07_12_sfm/claude_offline_aug.py | 144 ++++- 4 files changed, 840 insertions(+), 11 deletions(-) create mode 100644 overnight_run_07_12_sfm/claude_confirm_analysis.py create mode 100644 overnight_run_07_12_sfm/claude_mechanism_viz.py diff --git a/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py index e23ff5b..4a8c75c 100644 --- a/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py +++ b/overnight_run_07_12_sfm/analysis/test_claude_offline_aug.py @@ -204,7 +204,8 @@ def test_recovery_records_are_exactly_certified_with_provenance(): assert record["execution_source"] == "synthetic_certified_recovery" prov = record["recovery_provenance"] assert prov["parent_context_id"] == record["context_id"] - assert "generator" in prov and "objective_goal_distance" in prov + assert "generator" in prov and "objective" in prov + assert prov["family"] == "v1" def test_orig_plus_recovery_keeps_full_population_and_appends(): @@ -223,6 +224,52 @@ def test_orig_plus_recovery_keeps_full_population_and_appends(): assert len(synthetic) == added +def test_v2_family_is_deterministic_goal_directed_and_certifiable(): + state = [1.0, 1.0, 0.4, -0.6] + first = AUG.recovery_candidates_v2(state) + second = AUG.recovery_candidates_v2(state) + assert len(first) == len(second) == ( + len(AUG.CRUISE_SPEEDS) + + 2 * AUG.K_DIR * len(AUG.V2_ACCELS) * len(AUG.CRUISE_SPEEDS) + ) + import sfm_metrics2 as SM2 + for (ca, pa), (cb, pb) in zip(first, second): + assert np.array_equal(ca, cb) and pa == pb + assert ca.shape == (10, 2) + assert float(np.abs(ca).max()) <= 2.0 + 1e-6 + # pure-cruise candidate ends moving toward the goal + controls = first[0][0] + seg = SM2.rollout_positions(state, controls) + velocity = np.asarray(state, np.float32)[2:4].copy() + for action in controls: + velocity = velocity + 0.1 * action + toward = velocity @ ((np.array([6.0, 6.0]) - seg[-1]) + / np.linalg.norm(np.array([6.0, 6.0]) - seg[-1])) + assert toward > 0.5 + + +def test_v2_recovery_records_certified_and_tagged(): + shard = OS.ExecutedRoundShard(4) + # pedestrian behind the robot, receding: a goal-directed certified + # escape clearly exists + _add(shard, scenario=9, gamma=0.5, step=4, y=0, + state=[2.0, 2.0, 0.6, 0.6], ped_xy=[[1.2, 2.0]], + ped_vel=[[-0.6, 0.0]], controls=STILL, trap=True) + records, audit = AUG.build_recovery_records( + shard, shard.Dminus, _InlineExecutor(), family="v2", + ) + assert audit["family"] == "v2" + assert records + for record in records: + assert record["recovery_provenance"]["family"] == "v2" + context = shard.contexts[record["context_id"]] + recheck = SM.verify_query( + context["state"], record["controls"], context["ped_xy"], + context["ped_vel"], context["gamma"], + ) + assert recheck["resolved"] and int(recheck["y"]) == 1 + + def test_hard_recovery_replay_respects_exact_once_accounting(): shard = _mixed_shard() policy = _TinyPolicy() diff --git a/overnight_run_07_12_sfm/claude_confirm_analysis.py b/overnight_run_07_12_sfm/claude_confirm_analysis.py new file mode 100644 index 0000000..3cdbff6 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_confirm_analysis.py @@ -0,0 +1,160 @@ +"""Paired-difference confirmation analysis for the Stage-E M100 comparison. + +Inputs: the raw-evaluator metrics JSON (r0 + selected checkpoint on one CRN +bank) and the locked-Kazuki metrics JSON on the same episode bank. All three +methods share the same scenario ids per gamma (CRN pedestrian banks); r0 and +the selected checkpoint additionally share the same latent bank. + +For each pair (selected - r0, selected - Kazuki, Kazuki - r0) and each of the +four study metrics we report the mean difference with a 95% scenario-cluster +bootstrap interval: episodes are grouped by scenario id (keeping all seven +paired gamma rows together) and clusters are resampled with replacement. +Collision and Validity use all episodes; clearance and time-to-goal are +success-conditioned, so each bootstrap draw recomputes the per-method mean +over its successful episodes inside the resampled clusters (a paired +difference of success-conditioned means, not a per-episode paired delta). +""" +from __future__ import annotations + +import argparse +import json + +import numpy as np + + +METRICS = ("CR", "validity", "successful_clearance", "time_to_goal") + + +def _rows(path, record_index=None, expect_label=None): + with open(path) as stream: + payload = json.load(stream) + if "records" in payload: + record = payload["records"][record_index] + if expect_label is not None and record["label"] != expect_label: + raise ValueError( + f"{path}: expected label {expect_label}, got {record['label']}" + ) + return payload, record["cell"]["rows"] + return payload, payload["rows"] + + +def _by_scenario(rows): + grouped = {} + for row in rows: + grouped.setdefault(int(row["episode"]), []).append(row) + return grouped + + +def _metric_values(rows, metric): + if metric == "CR": + return [float(bool(row["collision"])) for row in rows] + if metric == "validity": + return [float(row["validity"]) for row in rows] + key = ( + "successful_clearance" if metric == "successful_clearance" + else "time_to_goal" + ) + return [ + float(row[key]) for row in rows if row[key] is not None + ] + + +def _cluster_mean(grouped, scenarios, metric): + values = [] + for scenario in scenarios: + values.extend(_metric_values(grouped[scenario], metric)) + return float(np.mean(values)) if values else float("nan") + + +def paired_difference(rows_a, rows_b, *, seed, draws=10_000): + grouped_a, grouped_b = _by_scenario(rows_a), _by_scenario(rows_b) + scenarios = sorted(set(grouped_a) & set(grouped_b)) + if set(grouped_a) != set(grouped_b): + raise ValueError("methods do not share the scenario bank") + generator = np.random.default_rng(seed) + out = {} + for metric in METRICS: + point = ( + _cluster_mean(grouped_a, scenarios, metric) + - _cluster_mean(grouped_b, scenarios, metric) + ) + samples = [] + for _ in range(draws): + resample = generator.choice(scenarios, size=len(scenarios)) + samples.append( + _cluster_mean(grouped_a, resample, metric) + - _cluster_mean(grouped_b, resample, metric) + ) + finite = [s for s in samples if np.isfinite(s)] + low, high = np.quantile(finite, [0.025, 0.975]) + out[metric] = dict( + difference=point, ci95=[float(low), float(high)], + draws=len(finite), + ) + return out + + +def summarize_method(rows): + n = len(rows) + values = {m: _metric_values(rows, m) for m in METRICS} + return dict( + n=n, + SR=float(np.mean([bool(r["success"]) for r in rows])), + CR=float(np.mean(values["CR"])), + timeout=float(np.mean([bool(r["timeout"]) for r in rows])), + Validity=float(np.mean(values["validity"])), + successful_clearance=float(np.mean(values["successful_clearance"])), + successful_time_to_goal=float(np.mean(values["time_to_goal"])), + successes=int(sum(bool(r["success"]) for r in rows)), + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--raw-metrics", required=True, + help="raw M100 metrics json containing r0 + selected") + parser.add_argument("--kazuki-metrics", required=True) + parser.add_argument("--selected-label", required=True) + parser.add_argument("--seed", type=int, default=20260731) + parser.add_argument("--out", required=True) + args = parser.parse_args(argv) + + raw_payload, r0_rows = _rows(args.raw_metrics, 0, "r0") + _, selected_rows = _rows(args.raw_metrics, 1, args.selected_label) + kazuki_payload, kazuki_rows = _rows(args.kazuki_metrics) + + result = dict( + status="CLAUDE_M100_CONFIRMATION_ANALYSIS", + bank=raw_payload.get("bank"), + kazuki_config=kazuki_payload.get("kazuki_config"), + methods=dict( + r0=summarize_method(r0_rows), + selected=summarize_method(selected_rows), + kazuki=summarize_method(kazuki_rows), + ), + paired_differences=dict( + selected_minus_r0=paired_difference( + selected_rows, r0_rows, seed=args.seed, + ), + selected_minus_kazuki=paired_difference( + selected_rows, kazuki_rows, seed=args.seed + 1, + ), + kazuki_minus_r0=paired_difference( + kazuki_rows, r0_rows, seed=args.seed + 2, + ), + ), + semantics=( + "scenario-cluster bootstrap (10k draws) keeping the seven paired " + "gamma rows per scenario together; clearance/time are " + "success-conditioned means recomputed inside each draw" + ), + ) + with open(args.out, "w") as stream: + json.dump(result, stream, indent=2, allow_nan=False) + print(json.dumps(result["methods"], indent=1)) + print(json.dumps(result["paired_differences"]["selected_minus_r0"], + indent=1)) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_mechanism_viz.py b/overnight_run_07_12_sfm/claude_mechanism_viz.py new file mode 100644 index 0000000..2539d22 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_mechanism_viz.py @@ -0,0 +1,498 @@ +"""Mechanism visualizations for the SFM recipe study (diagnostic-only). + +``snapshot``: for one stored hard context (from an archived ExecutedRoundShard) +render, per checkpoint, the K=16 flow candidates regenerated with the EXACT +keyed gathering latents and labeled by the exact full-H10 verifier; overlay the +deterministic certified recovery escapes (family v1 and, when available, v2); +and show the frozen visual-encoder input (Hp10 polar stack) plus low5/history +conditioning for that context. + +``episode``: closed-loop replay of one (scenario, gamma) gathering lineage +with a given checkpoint (margin-selector B1 semantics, round-1 acquisition +state), recording the trajectory, per-step K-positive counts and NVP flags; +``render-episodes`` overlays two replays (e.g. r0 vs a treated checkpoint). + +These figures are explanatory evidence only; claims use fixed-bank metrics. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import copy +import json +import os + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch + +import _paths # noqa: F401 +import claude_offline_aug as AUG +import grid_policy_sfm as GPS +import sfm_b1_cost as BC +import sfm_b1_eval as BE +import sfm_b1_expand as BX +import sfm_b1_full_episode_audit as FA +import sfm_b1_offline_exec as OE +import sfm_b1_offline_store as OS +import sfm_b1_rbf as BR +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + +SEED = 20260724 + + +def _find_context(shard, scenario, gamma, step): + for context in shard.contexts: + if ( + int(context["scenario_id"]) == int(scenario) + and round(float(context["gamma"]), 8) == round(float(gamma), 8) + and int(context["step"]) == int(step) + ): + return context + raise KeyError(f"context not stored: s{scenario} g{gamma} step{step}") + + +def _executed_window(shard, context): + for window in shard.windows: + if int(window["context_id"]) == int(context["context_id"]): + return window + return None + + +@torch.no_grad() +def _k_candidates(policy, context, device): + hp10 = torch.as_tensor(context["hp10"], device=device)[None] + low = torch.as_tensor(context["low5"], device=device)[None] + hist = torch.as_tensor(context["hist"], device=device)[None] + ctx = policy.ctx_from(hp10.float(), low.float(), hist.float()) + generator = np.random.default_rng(OE._keyed_seed( + SEED, 1, int(context["scenario_id"]), + f"{float(context['gamma']):.8f}", int(context["step"]), "K", + )) + x0 = generator.standard_normal((16, int(policy.d)), dtype=np.float32) + windows = BE.integrate_latents( + policy, + torch.as_tensor(x0, device=device), + ctx.repeat_interleave(16, dim=0), + nfe=8, + ).reshape(16, 10, 2).cpu().numpy() + return windows + + +def _verify_many(context, control_sets, workers=8): + tasks = [ + (index, 0, context["state"], controls, context["ped_xy"], + context["ped_vel"], context["gamma"]) + for index, controls in enumerate(control_sets) + ] + with ProcessPoolExecutor(max_workers=workers) as executor: + results = list(executor.map(SM.verify_in_worker, tasks)) + ordered = [None] * len(control_sets) + for index, _, result in results: + ordered[index] = result + return ordered + + +def _scene(axis, context, title): + state = np.asarray(context["state"], np.float32) + ped_xy = np.asarray(context["ped_xy"], np.float32) + ped_vel = np.asarray(context["ped_vel"], np.float32) + prediction = SM.predict_pedestrians(ped_xy, ped_vel, H=10) + for j in range(len(ped_xy)): + axis.add_patch(plt.Circle( + ped_xy[j], SS.R_PED, color="#c2554f", alpha=.75, lw=0, zorder=3, + )) + axis.plot( + prediction[:, j, 0], prediction[:, j, 1], + color="#c2554f", lw=.7, ls=":", alpha=.55, zorder=2, + ) + axis.plot(*SS.GOAL, marker="*", ms=17, color="#e6b422", mec="k", + zorder=6) + axis.plot(state[0], state[1], marker="o", ms=9, color="#1450a3", + mec="k", zorder=6) + axis.annotate( + "", xy=state[:2] + 0.5 * state[2:4], xytext=state[:2], + arrowprops=dict(arrowstyle="->", color="#1450a3", lw=2), zorder=6, + ) + axis.add_patch(plt.Rectangle( + (SS.TASK_LO, SS.TASK_LO), SS.TASK_HI - SS.TASK_LO, + SS.TASK_HI - SS.TASK_LO, fill=False, ec="k", lw=.8, alpha=.6, + )) + axis.add_patch(plt.Circle( + state[:2], SS.R_SENSE, fill=False, ec="#1450a3", lw=.6, ls="--", + alpha=.5, + )) + pad = 2.35 + axis.set_xlim(state[0] - pad, state[0] + pad) + axis.set_ylim(state[1] - pad, state[1] + pad) + axis.set_aspect("equal") + axis.set_title(title, fontsize=11) + axis.grid(alpha=.2) + + +def _draw_windows(axis, context, windows, labels, *, executed=None): + n_pos = 0 + for controls, result in zip(windows, labels): + segment = SM.rollout_positions(context["state"], controls) + positive = bool(result.get("resolved")) and int(result.get("y", 0)) == 1 + n_pos += int(positive) + axis.plot( + segment[:, 0], segment[:, 1], + color="#1f8a4c" if positive else "#b22222", + lw=1.7 if positive else 0.9, + alpha=.95 if positive else .5, + zorder=5 if positive else 4, + ) + if executed is not None: + segment = SM.rollout_positions(context["state"], executed) + axis.plot(segment[:, 0], segment[:, 1], color="#550000", lw=3.2, + alpha=.95, zorder=5.5, label="executed (uncertified)") + return n_pos + + +def snapshot(args): + shard = OS.ExecutedRoundShard.load(args.shard) + context = _find_context(shard, args.scenario, args.gamma, args.step) + executed = _executed_window(shard, context) + specs = [spec.split("=", 1) for spec in args.checkpoints] + families = dict(v1="v1", v2="v2") if hasattr(AUG, "recovery_candidates_v2") \ + else dict(v1="v1") + n_ckpt = len(specs) + n_cols = n_ckpt + len(families) + figure, axes = plt.subplots( + 2, max(n_cols, 3), figsize=(4.9 * max(n_cols, 3), 9.6), + ) + summary = dict( + scenario=int(args.scenario), gamma=float(args.gamma), + step=int(args.step), + ) + + for column, (name, path) in enumerate(specs): + policy, _ = GPS.load_sfm_policy(path, device=args.device) + policy.eval() + windows = _k_candidates(policy, context, args.device) + results = _verify_many(context, list(windows), args.workers) + axis = axes[0][column] + _scene(axis, context, "") + n_pos = _draw_windows( + axis, context, windows, results, + executed=None if executed is None or column else + np.asarray(executed["controls"], np.float32), + ) + axis.set_title( + f"{name}: K=16 flow candidates\n" + f"exact-verifier positives: {n_pos}/16", fontsize=11, + ) + summary[f"K_positive_{name}"] = int(n_pos) + del policy + + for offset, family in enumerate(sorted(families)): + candidates = ( + AUG.recovery_candidates(context["state"]) if family == "v1" + else AUG.recovery_candidates_v2( + context["state"], context["ped_xy"], context["ped_vel"], + ) + ) + scored = [] + for controls, provenance in candidates: + objective = AUG._prefilter(context, controls) + if objective is not None: + scored.append((objective, controls, provenance)) + scored.sort(key=lambda row: (row[0], str(row[2]))) + pool = scored[:AUG.PREVERIFY_CAP] + results = _verify_many(context, [row[1] for row in pool], args.workers) + axis = axes[0][n_ckpt + offset] + _scene(axis, context, "") + certified = 0 + best_drawn = False + for (objective, controls, provenance), result in zip(pool, results): + segment = SM.rollout_positions(context["state"], controls) + ok = bool(result.get("resolved")) and int(result.get("y", 0)) == 1 + if ok: + certified += 1 + axis.plot( + segment[:, 0], segment[:, 1], + color="#0b6fa4" if family == "v1" else "#e07b00", + lw=3.0 if not best_drawn else 1.6, + alpha=.95 if not best_drawn else .7, zorder=5.4, + ) + if not best_drawn: + summary[f"recovery_{family}_best"] = dict( + J=float(objective), generator=provenance, + slack=float(result["diagnostics"]["slack"]), + end_speed=float(np.linalg.norm( + BC.rollout_states( + context["state"], controls[None], + )[0, -1, 2:4].numpy() + )), + ) + best_drawn = True + else: + axis.plot(segment[:, 0], segment[:, 1], color="#888888", + lw=.7, alpha=.4, zorder=3.5) + axis.set_title( + f"certified deterministic recovery ({family})\n" + f"{certified}/{len(pool)} exact-certified", fontsize=11, + ) + summary[f"recovery_{family}_certified"] = int(certified) + summary[f"recovery_{family}_pool"] = int(len(pool)) + + hp10 = np.asarray(context["hp10"], np.float32) + axis = axes[1][0] + image = axis.imshow( + hp10[-1].T, origin="lower", aspect="auto", cmap="RdBu", + vmin=-1, vmax=1, + extent=(-180, 180, 0, SS.R_SENSE), + ) + axis.set_title("frozen encoder input: newest H_P frame\n" + "(clipped nominal polytope, polar)", fontsize=11) + axis.set_xlabel("bearing [deg]") + axis.set_ylabel("range [m]") + plt.colorbar(image, ax=axis, fraction=.04) + for column in range(1, min(3, axes.shape[1])): + axis = axes[1][column] + if column == 1: + mosaic = np.concatenate([hp10[i].T for i in range(10)], axis=1) + axis.imshow( + mosaic, origin="lower", aspect="auto", cmap="RdBu", + vmin=-1, vmax=1, + ) + axis.set_title("Hp10 stack: 10 most recent H_P frames " + "(oldest left)", fontsize=11) + axis.set_xticks([]) + axis.set_yticks([]) + elif column == 2: + axis.axis("off") + low = np.asarray(context["low5"], np.float32) + lines = [ + f"scenario {args.scenario} gamma {args.gamma} " + f"step {args.step}", + f"low5: relgoal=({low[0]:.2f},{low[1]:.2f}) " + f"v=({low[2]:.2f},{low[3]:.2f}) gamma={low[4]:.2f}", + "encoder enc_grid: FROZEN during expansion (SHA-checked)", + "gradient flows into trunk/GRU/enc_low conditioned on", + "these frozen grid features", + ] + if executed is not None: + lines.append( + f"executed window: y={executed['y']} " + f"source={executed['execution_source']}" + ) + for key in sorted(summary): + if key.startswith(("K_positive", "recovery")): + value = summary[key] + if isinstance(value, dict): + value = { + k: (round(v, 3) if isinstance(v, float) else v) + for k, v in value.items() if k != "generator" + } + lines.append(f"{key}: {value}") + axis.text(0.01, 0.98, "\n".join(str(l) for l in lines), + va="top", ha="left", fontsize=9, family="monospace", + transform=axis.transAxes, wrap=True) + for column in range(n_cols, axes.shape[1]): + axes[0][column].axis("off") + for column in range(3, axes.shape[1]): + axes[1][column].axis("off") + figure.suptitle( + f"Hard-context mechanism: s{args.scenario} γ={args.gamma} " + f"step {args.step} (exact verifier everywhere)", fontsize=13, + ) + figure.tight_layout(rect=(0, 0, 1, 0.96)) + figure.savefig(args.out, dpi=170, bbox_inches="tight") + plt.close(figure) + OE._write_json(args.out + ".json", summary) + print(json.dumps({k: v for k, v in summary.items() + if not isinstance(v, dict)}, indent=1)) + + +@torch.no_grad() +def episode(args): + device = args.device + policy, _ = GPS.load_sfm_policy(args.checkpoint, device=device) + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + cfg = OE.OfflineConfig(alpha=0.0, exposure_epochs=1, rounds=1, smoke=True) + environment = SS.scene_profile(cfg.scene_profile) + replica = BX.Replica( + int(args.scenario), float(args.gamma), + n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"]), + ) + gp = BR.RBFGP(float(args.ell), float(cfg.gp_lam)) + beta, _ = OE._calibrate_beta( + phi_policy, gp, [replica], cfg, device, round_i=1, + ) + frames = [] + with ProcessPoolExecutor(max_workers=args.workers) as executor: + for step in range(int(cfg.T)): + live, batch = BX._stack_prepared([replica], device) + if not live: + break + windows, contexts, x0 = OE._keyed_windows( + policy, live, batch, K=cfg.K, round_i=1, step=step, + source="K", seed=cfg.seed, nfe=cfg.nfe, temp=cfg.temp, + ) + raw_windows, _, _ = OE._keyed_windows( + policy, live, batch, K=1, round_i=1, step=step, + source="raw_continuation", seed=cfg.seed, nfe=cfg.nfe, + temp=cfg.temp, + ) + windows_np = windows[0].cpu().numpy() + raw_np = raw_windows[0, 0].cpu().numpy() + features = OE._features_from_x0( + phi_policy, windows, contexts, x0, cfg.phi_s, + ) + generator = torch.Generator(device=features.device) + generator.manual_seed(OE._keyed_seed( + cfg.seed, 1, replica.scenario_id, + f"{replica.gamma:.8f}", step, "acquisition", + )) + selected, trace = gp.sequential_acquire( + features[0], cfg.B, beta, generator=generator, + ) + prepared = replica.prepared + results = _verify_many( + dict(state=prepared["state"], ped_xy=prepared["ped_xy"], + ped_vel=prepared["ped_vel"], gamma=replica.gamma), + list(windows_np), args.workers, + ) + query_rows = [ + dict(candidate_id=int(k), acquisition_step=j, + controls=windows_np[k], result=results[k], mode=None, + sigma=float(trace[j]["chosen_sigma"])) + for j, k in enumerate(map(int, selected)) + if results[k].get("resolved") + ] + chosen = BC.select_admissible( + query_rows, selector="margin", state=prepared["state"], + ped_xy=prepared["ped_xy"], ped_vel=prepared["ped_vel"], + gamma=replica.gamma, + ) + controls = raw_np if chosen is None else np.asarray( + chosen["controls"], np.float32, + ) + frames.append(dict( + step=int(step), + state=prepared["state"].tolist(), + ped_xy=prepared["ped_xy"].tolist(), + K_positive=int(sum( + int(r.get("y", 0)) == 1 for r in results + if r.get("resolved") + )), + NVP=chosen is None, + )) + BX._advance(replica, controls[0]) + FA._post_action_terminal(replica) + OE._finalize_alive([replica]) + payload = dict( + checkpoint=os.path.abspath(args.checkpoint), + scenario=int(args.scenario), gamma=float(args.gamma), + status=replica.status, steps=len(replica.controls), + min_clearance=float(replica.minimum_clearance), + states=[s.tolist() for s in replica.states], + frames=frames, + ) + OE._write_json(args.out, payload) + print(json.dumps(dict(status=replica.status, + steps=len(replica.controls)), indent=1)) + + +def render_episodes(args): + runs = [] + for spec in args.runs: + name, path = spec.split("=", 1) + with open(path) as stream: + runs.append((name, json.load(stream))) + n = len(runs) + figure, axes = plt.subplots(1, n, figsize=(6.4 * n, 6.4)) + if n == 1: + axes = [axes] + for axis, (name, run) in zip(axes, runs): + states = np.asarray(run["states"], np.float32) + nvp_steps = {f["step"] for f in run["frames"] if f["NVP"]} + final = run["frames"][-1] + ped = np.asarray(final["ped_xy"], np.float32) + for j in range(len(ped)): + axis.add_patch(plt.Circle( + ped[j], SS.R_PED, color="#c2554f", alpha=.5, lw=0, + )) + for t in range(len(states) - 1): + color = "#d95f02" if t in nvp_steps else "#1450a3" + axis.plot(states[t:t + 2, 0], states[t:t + 2, 1], color=color, + lw=2.6 if t in nvp_steps else 1.8, zorder=5) + axis.plot(*SS.GOAL, marker="*", ms=17, color="#e6b422", mec="k") + axis.plot(states[0, 0], states[0, 1], marker="s", ms=8, + color="#1450a3", mec="k") + marker = dict(collision="X", success="*", timeout="P")[run["status"]] + axis.plot(states[-1, 0], states[-1, 1], marker=marker, ms=14, + color={"collision": "#b22222", "success": "#1f8a4c", + "timeout": "#888888"}[run["status"]], mec="k", + zorder=7) + axis.add_patch(plt.Rectangle( + (SS.TASK_LO, SS.TASK_LO), SS.TASK_HI - SS.TASK_LO, + SS.TASK_HI - SS.TASK_LO, fill=False, ec="k", lw=.8, alpha=.6, + )) + nvp_count = len(nvp_steps) + axis.set_title( + f"{name}: {run['status']} in {run['steps']} steps\n" + f"NVP steps (orange): {nvp_count}; min clearance " + f"{run['min_clearance']:.3f} m", fontsize=12, + ) + axis.set_aspect("equal") + axis.set_xlim(SS.TASK_LO - .2, SS.TASK_HI + .2) + axis.set_ylim(SS.TASK_LO - .2, SS.TASK_HI + .2) + axis.grid(alpha=.2) + figure.suptitle( + f"Closed-loop gathering lineage s{runs[0][1]['scenario']} " + f"γ={runs[0][1]['gamma']} — final pedestrian frame shown", + fontsize=13, + ) + figure.tight_layout(rect=(0, 0, 1, 0.94)) + figure.savefig(args.out, dpi=170, bbox_inches="tight") + plt.close(figure) + print(args.out) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("snapshot") + s.add_argument("--shard", required=True) + s.add_argument("--scenario", type=int, required=True) + s.add_argument("--gamma", type=float, required=True) + s.add_argument("--step", type=int, required=True) + s.add_argument("--checkpoints", nargs="+", required=True, + help="NAME=PATH ...") + s.add_argument("--workers", type=int, default=8) + s.add_argument("--device", default="cpu") + s.add_argument("--out", required=True) + + e = sub.add_parser("episode") + e.add_argument("--checkpoint", required=True) + e.add_argument("--scenario", type=int, required=True) + e.add_argument("--gamma", type=float, required=True) + e.add_argument("--ell", type=float, required=True) + e.add_argument("--workers", type=int, default=8) + e.add_argument("--device", default="cuda:0") + e.add_argument("--out", required=True) + + r = sub.add_parser("render-episodes") + r.add_argument("--runs", nargs="+", required=True, help="NAME=JSON ...") + r.add_argument("--out", required=True) + + args = parser.parse_args(argv) + dict(snapshot=snapshot, episode=episode, + render_episodes=render_episodes)[args.cmd.replace("-", "_")](args) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_offline_aug.py b/overnight_run_07_12_sfm/claude_offline_aug.py index 5fe1d35..d46f874 100644 --- a/overnight_run_07_12_sfm/claude_offline_aug.py +++ b/overnight_run_07_12_sfm/claude_offline_aug.py @@ -71,7 +71,29 @@ ACCELS = (0.7, 1.4, 2.0) PREVERIFY_CAP = 24 RECOVERY_KEEP = 2 -REPLAY_MODES = ("original", "hard", "hard_recovery", "orig_plus_recovery") +REPLAY_MODES = ( + "original", "hard", "hard_recovery", "orig_plus_recovery", + "orig_plus_recovery_v2", +) + +# --- Recovery family v2 ("dodge-then-cruise"), declared 2026-07-26 before +# any evaluation of its effect. Motivation (user hypothesis + Stage-A +# measurement): the v1 brake/bang-bang family certifies CONSERVATIVE escapes +# that end near-stationary; training on them creates slowdown. v2 candidates +# end moving TOWARD the goal at cruise speed: +# phase 1 (d in {0,2,3} steps): dodge with u = a*(cos t_k, sin t_k), +# a in {1.4, 2.0}, t_k over K_DIR world directions (d=0 skips the dodge); +# phase 2 (remaining steps): deterministic saturated velocity servo +# u_t = clip(KP_CRUISE * (v_des(p_t) - v_t), +/-U_MAX), +# v_des(p) = v_c * unit(GOAL - p), v_c in {1.0, 1.5}. +# Objective (v2): J2 = ||p_10 - GOAL|| - 0.5 * (v_10 . unit(GOAL - p_10)) — +# prefer end states that are close to AND moving toward the goal. The same +# cheap exact prefilter, PREVERIFY_CAP, RECOVERY_KEEP, and the exact full-H10 +# SOCP certificate gate apply unchanged. +DODGE_STEPS = (0, 2, 3) +CRUISE_SPEEDS = (1.0, 1.5) +KP_CRUISE = 4.0 +V2_ACCELS = (1.4, 2.0) def declared_rules(): @@ -193,6 +215,88 @@ def recovery_candidates(state): return candidates +def _cruise_controls(position, velocity, steps, v_cruise): + """Deterministic saturated velocity servo toward the goal.""" + position = np.asarray(position, np.float32).copy() + velocity = np.asarray(velocity, np.float32).copy() + controls = [] + for _ in range(steps): + offset = SS.GOAL - position + norm = float(np.linalg.norm(offset)) + v_des = ( + v_cruise * offset / norm if norm > 1e-6 + else np.zeros(2, np.float32) + ) + action = np.clip( + KP_CRUISE * (v_des - velocity), -SS.U_MAX, SS.U_MAX, + ).astype(np.float32) + controls.append(action) + position = position + SS.DT * velocity + 0.5 * SS.DT ** 2 * action + velocity = velocity + SS.DT * action + return controls + + +def recovery_candidates_v2(state, ped_xy=None, ped_vel=None): + """Goal-directed dodge-then-cruise family (state-only, deterministic).""" + del ped_xy, ped_vel # verifier inputs; unused by this state-only family + state = np.asarray(state, np.float32).reshape(4) + candidates = [] + for v_cruise in CRUISE_SPEEDS: + controls = _cruise_controls(state[:2], state[2:4], 10, v_cruise) + candidates.append(( + np.asarray(controls, np.float32), + dict(kind="cruise", dodge=0, theta=None, accel=None, + v_cruise=v_cruise), + )) + for dodge in DODGE_STEPS: + if dodge == 0: + continue + for k in range(K_DIR): + theta = 2.0 * math.pi * k / K_DIR + direction = np.array( + [math.cos(theta), math.sin(theta)], np.float32, + ) + for accel in V2_ACCELS: + prefix = [ + np.clip(accel * direction, -SS.U_MAX, SS.U_MAX) + .astype(np.float32) + ] * dodge + position = np.asarray(state[:2], np.float32).copy() + velocity = np.asarray(state[2:4], np.float32).copy() + for action in prefix: + position = ( + position + SS.DT * velocity + + 0.5 * SS.DT ** 2 * action + ) + velocity = velocity + SS.DT * action + for v_cruise in CRUISE_SPEEDS: + controls = prefix + _cruise_controls( + position, velocity, 10 - dodge, v_cruise, + ) + controls = np.asarray(controls, np.float32) + if controls.shape != (10, 2): + raise AssertionError("v2 candidate must be H=10") + candidates.append(( + controls, + dict(kind="dodge_cruise", dodge=dodge, + theta=round(theta, 6), accel=accel, + v_cruise=v_cruise), + )) + return candidates + + +def _objective_v2(context, controls): + segment = SM.rollout_positions(context["state"], controls) + state = np.asarray(context["state"], np.float32).reshape(4) + velocity = state[2:4].copy() + for action in np.asarray(controls, np.float32): + velocity = velocity + SS.DT * action + offset = SS.GOAL - segment[-1] + norm = float(np.linalg.norm(offset)) + toward = float(velocity @ (offset / norm)) if norm > 1e-6 else 0.0 + return norm - 0.5 * toward + + def _prefilter(context, controls): """Cheap exact numpy feasibility check + objective J.""" segment = SM.rollout_positions(context["state"], controls) @@ -206,23 +310,37 @@ def _prefilter(context, controls): return float(np.linalg.norm(segment[-1] - SS.GOAL)) -def build_recovery_records(shard, hard_windows, executor): +def build_recovery_records(shard, hard_windows, executor, family="v1"): """Exact-certified recovery positives for the given hard windows. Returns (records, audit). Every returned record passed the exact full-H10 verifier inside ``executor`` (the same worker pool and - ``SM.verify_in_worker`` entry as B1 queries). + ``SM.verify_in_worker`` entry as B1 queries). ``family`` selects the + declared deterministic candidate family and ranking objective: + v1 = brake/bang-bang, J = final goal distance; + v2 = dodge-then-cruise, J2 = goal distance - 0.5 * toward-goal speed. """ + if family not in ("v1", "v2"): + raise ValueError("recovery family must be v1 or v2") context_ids = sorted({int(w["context_id"]) for w in hard_windows}) tasks, meta = [], [] per_context_pool = {} for context_id in context_ids: context = shard.contexts[context_id] + generator_fn = ( + recovery_candidates if family == "v1" + else recovery_candidates_v2 + ) scored = [] - for controls, provenance in recovery_candidates(context["state"]): - objective = _prefilter(context, controls) - if objective is not None: - scored.append((objective, controls, provenance)) + for controls, provenance in generator_fn(context["state"]): + feasible = _prefilter(context, controls) + if feasible is None: + continue + objective = ( + feasible if family == "v1" + else _objective_v2(context, controls) + ) + scored.append((objective, controls, provenance)) scored.sort(key=lambda row: (row[0], str(row[2]))) pool = scored[:PREVERIFY_CAP] per_context_pool[context_id] = len(pool) @@ -268,8 +386,9 @@ def build_recovery_records(shard, hard_windows, executor): recovery_provenance=dict( parent_round=int(shard.round_i), parent_context_id=int(context_id), + family=str(family), generator=provenance, - objective_goal_distance=float(objective), + objective=float(objective), prefilter_rank=int(rank), ), )) @@ -294,6 +413,7 @@ def build_recovery_records(shard, hard_windows, executor): float(np.mean(list(per_context_pool.values()))) if per_context_pool else 0.0 ), + family=str(family), rows=audit_rows, ) return records, audit @@ -339,13 +459,17 @@ def build_replay_view(shard, mode, executor=None): return shard, dict(mode=mode, note="untouched ExecutedRoundShard") pop_a, pop_b, stats = tag_populations(shard) report = dict(mode=mode, populations=stats) - if mode == "orig_plus_recovery": + if mode in ("orig_plus_recovery", "orig_plus_recovery_v2"): # Declared BEFORE evaluation (Stage-A log 2026-07-26): keep the FULL # original positive and negative populations and only APPEND the # exact-certified recovery positives at their parent (hard) contexts. + # The _v2 variant uses the declared dodge-then-cruise family. if executor is None: raise ValueError("orig_plus_recovery needs the verifier executor") - recovery, audit = build_recovery_records(shard, pop_b, executor) + recovery, audit = build_recovery_records( + shard, pop_b, executor, + family="v2" if mode.endswith("_v2") else "v1", + ) report["recovery_audit"] = audit windows = list(shard.windows) + recovery else: From 06abdca110a89a226714cfb09581c85e57438b53 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 13:24:10 -0700 Subject: [PATCH 24/31] Stage D complete: frozen cost/hard recipe = honest null (rule-selected r7: CR -.006, V -.067 vs r0); paper-contract plot rendered; iteration-2 pre-registration (R_A=B9 knobs vs R_B=v2 family, freeze criterion declared before U10-12 reads; fresh M50 bank 340000; M100 330000 untouched) Co-Authored-By: Claude Fable 5 --- .../claude_recipe_study/EPISODE_BANKS.json | 5 +++ .../ITERATION2_PREREGISTRATION.json | 21 ++++++++++++ .../STAGE_D_SELECTION.json | 32 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/STAGE_D_SELECTION.json diff --git a/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json b/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json index 2481f8a..5151d4c 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json +++ b/overnight_run_07_12_sfm/claude_recipe_study/EPISODE_BANKS.json @@ -32,6 +32,11 @@ "m100_final_confirmation": { "ep0": 330000, "m_per_gamma": 100, "noise_seed": 20260730, "role": "Stage E disjoint confirmation of r0 vs selected expanded checkpoint vs locked Kazuki; never read before the checkpoint is frozen" + }, + "iteration2_m50_selection": { + "ep0": 340000, "m_per_gamma": 50, "noise_seed": 20260732, + "declared_at": "2026-07-26T13:15:00-07:00", + "role": "iteration-2 M50 checkpoint selection (see ITERATION2_PREREGISTRATION.json); declared because the iteration-1 selection bank (320000) was read for two declared diagnostic companion cells (B9 r1/r2) and is therefore retired for selection purposes" } }, "disjointness_note": "All new ep0 ranges (300000-330099) are disjoint from every historical bank listed in sfm_protocol.py (12000, 20000+, 50000, 80000, 90000, 110000, 130000, 150000, 170000, 190000, 210000, 230000, 250000) and from the codex funnel banks (260000, 270000, 280000). Noise seeds 20260726-20260730 are new.", diff --git a/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json new file mode 100644 index 0000000..9801f09 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json @@ -0,0 +1,21 @@ +{ + "status": "PREREGISTERED_BEFORE_READING_U10_U12", + "declared_at": "2026-07-26T13:15:00-07:00", + "motivation": "Iteration-1 frozen recipe (cost/hard) is a null result under its own predeclared rule (STAGE_D_SELECTION.json). The diagnostic companion cells on the M50 selection bank show B9-r1 (margin selector, original-plus-certified-recovery-v1 data, alpha .01, exposures 100, lr 1e-4, ESS .5) dominating r0 on CR/Validity/clearance while passing the liveness gate. Iteration 2 pre-registers that direction with fresh, unread banks.", + "candidate_recipes": { + "R_A": {"selector": "margin", "replay_mode": "orig_plus_recovery", "recovery_family": "v1", "alpha": 0.01, "exposure_epochs": 100, "lr": 1e-4, "ess_target": 0.5, "rounds": 4, "note": "checkpoints r1-r4 already exist (arm B9, trained from exact r0 BEFORE any selection-bank read)"}, + "R_B": {"selector": "margin", "replay_mode": "orig_plus_recovery_v2", "recovery_family": "v2 dodge-then-cruise (declared in claude_offline_aug.py)", "alpha": 0.01, "exposure_epochs": 100, "lr": 1e-4, "ess_target": 0.5, "rounds": 4} + }, + "freeze_criterion_declared_before_reading_U10_U12": "Compare single-update mirrors on the combined diag (ep0 300000, M8) + anchor (ep0 305000, M12) tuning banks: U10 = one r0 update with orig_plus_recovery_v2/e100/lr1e-4; U12 = identical with v1. Choose R_B iff U10 improves BOTH pooled SR and pooled successful time-to-goal versus U12 AND does not raise pooled CR by more than 0.01; otherwise choose R_A. Rationale: the v2 family exists to remove the conservative-escape slowdown; if it does not show that signature at matched dose on tuning banks, the extra novelty is not justified.", + "rounds_rationale": "rounds=4 is part of the recipe (a declared recipe variable): every e100 margin arm measured so far collapses to timeout from round 2-3; rounds beyond 4 are known-dominated and cost compute without adding selectable checkpoints.", + "selection": { + "bank": {"ep0": 340000, "noise_seed": 20260732, "m_per_gamma": 50, "role": "iteration-2 M50 checkpoint selection; NEVER read before this declaration"}, + "rule": "SELECTION_RULE.json applied verbatim (liveness gate SR >= SR(r0)-0.02, timeout <= timeout(r0)+0.05; then min CR, max Validity, max clearance, min time, smallest round) over r0..r4 of the chosen recipe" + }, + "confirmation": { + "bank": {"ep0": 330000, "noise_seed": 20260730, "m_per_gamma": 100, "role": "UNTOUCHED final confirmation: r0 + iteration-2 selected checkpoint + locked Kazuki; paired scenario-cluster CIs via claude_confirm_analysis.py"}, + "no_changes_after_reading": true + }, + "contamination_note": "The iteration-1 M50 bank (ep0 320000) was read for B9 r1/r2 as declared diagnostic companions and is therefore NOT used for iteration-2 selection. Banks 300000/305000/310000 are tuning banks. Bank 330000 has never been read by anything.", + "iteration_1_disposition": "reported in full as the rule-selected null result (frozen cost/hard recipe, winner r7), with its four-metric paper plot and all checkpoints; nothing about iteration 1 is hidden or reselected" +} diff --git a/overnight_run_07_12_sfm/claude_recipe_study/STAGE_D_SELECTION.json b/overnight_run_07_12_sfm/claude_recipe_study/STAGE_D_SELECTION.json new file mode 100644 index 0000000..1dd092a --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/STAGE_D_SELECTION.json @@ -0,0 +1,32 @@ +{ + "status": "STAGE_D_SELECTION_APPLIED", + "applied_at": "2026-07-26T13:05:00-07:00", + "rule": "SELECTION_RULE.json (predeclared 2026-07-26 05:05 PT, before the final run)", + "bank": {"ep0": 320000, "noise_seed": 20260729, "m_per_gamma": 50}, + "recipe": "FIXED_RECIPE.json (cost/hard, alpha .01, exposures 10, lr 1e-4, ESS .5, rounds 10)", + "r0": {"SR": 0.720, "CR": 0.269, "timeout": 0.011, "Validity": 0.638, "clearance": 0.134, "time": 8.31}, + "per_round_pooled": { + "r1": {"SR": 0.720, "CR": 0.280, "timeout": 0.000, "Validity": 0.603, "clearance": 0.132, "time": 8.51}, + "r2": {"SR": 0.683, "CR": 0.317, "timeout": 0.000, "Validity": 0.593, "clearance": 0.139, "time": 8.06}, + "r3": {"SR": 0.683, "CR": 0.317, "timeout": 0.000, "Validity": 0.573, "clearance": 0.137, "time": 7.66}, + "r4": {"SR": 0.683, "CR": 0.317, "timeout": 0.000, "Validity": 0.571, "clearance": 0.138, "time": 7.53}, + "r5": {"SR": 0.686, "CR": 0.314, "timeout": 0.000, "Validity": 0.577, "clearance": 0.138, "time": 7.45}, + "r6": {"SR": 0.677, "CR": 0.323, "timeout": 0.000, "Validity": 0.564, "clearance": 0.144, "time": 7.29}, + "r7": {"SR": 0.737, "CR": 0.263, "timeout": 0.000, "Validity": 0.571, "clearance": 0.140, "time": 7.35}, + "r8": {"SR": 0.694, "CR": 0.306, "timeout": 0.000, "Validity": 0.558, "clearance": 0.137, "time": 7.27}, + "r9": {"SR": 0.634, "CR": 0.366, "timeout": 0.000, "Validity": 0.560, "clearance": 0.144, "time": 7.35}, + "r10": {"SR": 0.603, "CR": 0.397, "timeout": 0.000, "Validity": 0.559, "clearance": 0.143, "time": 7.44} + }, + "liveness_gate": {"SR_min": 0.700, "timeout_max": 0.061}, + "eligible_rounds": ["r1", "r7"], + "selected_round": "r7", + "selected_checkpoint": "/data3/research1/claude_sfm_best_recipe_f06e8dd/stageD/final_cost_hard/round_07.pt", + "honest_assessment": "The rule-selected r7 lowers CR by only 0.006 versus r0 while losing 0.067 Validity; the frozen recipe does NOT deliver the desired joint improvement (substantially lower CR + higher Validity + higher clearance). This is reported as a null result for iteration 1.", + "pareto_frontier_on_m50_bank": [ + {"cell": "r0", "why": "highest Validity among liveness-eligible cells (.638), CR .269"}, + {"cell": "frozen r7", "why": "lowest CR among eligible frozen-recipe rounds (.263), fastest time (7.35), but Validity .571"}, + {"cell": "companion B9-r1 (margin + orig-plus-recovery-v1, e100) — diagnostic cells, not the frozen recipe", "why": "SR .729 / CR .246 / Validity .750 / clearance .162 / time 10.51: dominates r0 on CR, Validity, clearance while passing the liveness gate; motivates the pre-registered iteration 2"}, + {"cell": "companion B9-r2", "why": "Validity .816 / clearance .151 / CR .249 but SR .643 fails the gate (timeout collapse begins)"} + ], + "paper_plot": "/data3/research1/claude_sfm_best_recipe_f06e8dd/stageD/paper_trends/b1_margin50_metric_trends.{png,pdf} via safe_flow_expansion@87063d3 contract; per-cell SEs stored in cost_hard_frozen_trends_rows.jsonl" +} From 9d2bc9ea25c3605ab21ba4e6c4d6d7c4b92594db Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 13:55:00 -0700 Subject: [PATCH 25/31] Iteration-2 freeze applied per declared criterion: R_A (B9 knobs, v1 recovery); v2 documented as follow-up; M50 selection launched on fresh bank 340000 Co-Authored-By: Claude Fable 5 --- .../ITERATION2_PREREGISTRATION.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json index 9801f09..c6de1f3 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json +++ b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_PREREGISTRATION.json @@ -17,5 +17,17 @@ "no_changes_after_reading": true }, "contamination_note": "The iteration-1 M50 bank (ep0 320000) was read for B9 r1/r2 as declared diagnostic companions and is therefore NOT used for iteration-2 selection. Banks 300000/305000/310000 are tuning banks. Bank 330000 has never been read by anything.", - "iteration_1_disposition": "reported in full as the rule-selected null result (frozen cost/hard recipe, winner r7), with its four-metric paper plot and all checkpoints; nothing about iteration 1 is hidden or reselected" + "iteration_1_disposition": "reported in full as the rule-selected null result (frozen cost/hard recipe, winner r7), with its four-metric paper plot and all checkpoints; nothing about iteration 1 is hidden or reselected", + "freeze_criterion_applied": { + "applied_at": "2026-07-26T13:55:00-07:00", + "U_reads_combined_diag_anchor_n140": { + "r0": {"SR": 0.614, "CR": 0.386, "Validity": 0.571, "clearance": 0.129, "time": 8.19}, + "U12_v1_e100": {"SR": 0.614, "CR": 0.350, "Validity": 0.706, "clearance": 0.104, "time": 10.92}, + "U10_v2_e100": {"SR": 0.679, "CR": 0.264, "Validity": 0.723, "clearance": 0.109, "time": 11.22}, + "U11_v2_e10": {"SR": 0.564, "CR": 0.393, "Validity": 0.663, "clearance": 0.109, "time": 10.17} + }, + "evaluation": "U10 vs U12: SR improved (+.065) and CR improved (-.086), but successful time did NOT improve (+0.30 s). The declared criterion required BOTH SR and time improvements; v2 did not show its designed no-slowdown signature. Individual deltas are also within 95% noise at n=140.", + "decision": "R_A frozen (B9 knobs; existing checkpoints r1-r4 trained from exact r0 before any selection-bank read)", + "v2_disposition": "documented as a promising follow-up (goal-directed certified escapes raised single-update SR/CR at matched dose on tuning banks) — not selected, not confirmed, not claimed" + } } From 0b40054f40b12c01d9546e907f9648c7fb89efc5 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 14:32:16 -0700 Subject: [PATCH 26/31] Iteration-2 M50 selection: r1 only eligible round, dCR -.123 dV +.141 dSR +.091 vs r0 on fresh bank 340000; paper plot rendered; M100 confirmation launched on untouched 330000 Co-Authored-By: Claude Fable 5 --- .../ITERATION2_SELECTION.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_SELECTION.json diff --git a/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_SELECTION.json b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_SELECTION.json new file mode 100644 index 0000000..280e392 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/ITERATION2_SELECTION.json @@ -0,0 +1,22 @@ +{ + "status": "ITERATION2_SELECTION_APPLIED", + "applied_at": "2026-07-26T15:05:00-07:00", + "recipe": "R_A frozen per ITERATION2_PREREGISTRATION.json: margin selector, orig_plus_recovery (family v1), alpha 0.01, exposure_epochs 100, lr 1e-4, ess_target 0.5, rounds 4, seed 20260724, expansion bank ep 20000+", + "bank": {"ep0": 340000, "noise_seed": 20260732, "m_per_gamma": 50}, + "rule": "SELECTION_RULE.json applied verbatim", + "per_round_pooled": { + "r0": {"SR": 0.563, "CR": 0.434, "timeout": 0.003, "Validity": 0.574, "clearance": 0.117, "time": 8.46}, + "r1": {"SR": 0.654, "CR": 0.311, "timeout": 0.034, "Validity": 0.715, "clearance": 0.117, "time": 10.82}, + "r2": {"SR": 0.537, "CR": 0.300, "timeout": 0.163, "Validity": 0.789, "clearance": 0.132, "time": 13.17}, + "r3": {"SR": 0.020, "CR": 0.329, "timeout": 0.651, "Validity": 0.816, "clearance": 0.209, "time": 14.46}, + "r4": {"SR": 0.000, "CR": 0.331, "timeout": 0.669, "Validity": 0.821, "clearance": null, "time": null} + }, + "liveness_gate": {"SR_min": 0.543, "timeout_max": 0.053}, + "eligible_rounds": ["r1"], + "selected_round": "r1", + "selected_checkpoint": "/data3/research1/claude_sfm_best_recipe_f06e8dd/stageB/B9_margin_origrec_e100/round_01.pt", + "selected_vs_r0_on_selection_bank": {"dSR": 0.091, "dCR": -0.123, "dValidity": 0.141, "dclearance": 0.000, "dtime": 2.36}, + "stability_note": "r2 fails the gate (timeout .163) and r3-r4 collapse: the recipe's gains are a one-round phenomenon; this is reported plainly (not claimed as monotonic learning).", + "paper_plot": "/data3/research1/claude_sfm_best_recipe_f06e8dd/iteration2/paper_trends/b1_margin50_metric_trends.{png,pdf} + numeric SEs in margin_origrec_e100_trends_rows.jsonl", + "next": "M100 confirmation on untouched bank 330000 (r0 + selected r1 + locked Kazuki); no changes after reading" +} From faceb80452ad95977e12fd5705553edf75657270 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 14:47:41 -0700 Subject: [PATCH 27/31] MPC distillation study scaffolding: privileged Codex pool import (read-only @0e0eca2), separate per-round D_MPC+ (never in D+/GP/acquisition), intersection filter (privileged SFM look-ahead AND exact H10 SOCP) ranked by native SafeMPPI cost, dedicated distillation blocks with fresh CFM bases, before/after local+M10 audits; 3 tests green incl. exact prefix-replay reconstruction Co-Authored-By: Claude Fable 5 --- .../analysis/test_claude_mpc_pool.py | 129 ++++++ overnight_run_07_12_sfm/claude_mpc_exec.py | 426 ++++++++++++++++++ overnight_run_07_12_sfm/claude_mpc_pool.py | 373 +++++++++++++++ 3 files changed, 928 insertions(+) create mode 100644 overnight_run_07_12_sfm/analysis/test_claude_mpc_pool.py create mode 100644 overnight_run_07_12_sfm/claude_mpc_exec.py create mode 100644 overnight_run_07_12_sfm/claude_mpc_pool.py diff --git a/overnight_run_07_12_sfm/analysis/test_claude_mpc_pool.py b/overnight_run_07_12_sfm/analysis/test_claude_mpc_pool.py new file mode 100644 index 0000000..2f737ec --- /dev/null +++ b/overnight_run_07_12_sfm/analysis/test_claude_mpc_pool.py @@ -0,0 +1,129 @@ +import numpy as np +import torch + +import claude_mpc_pool as MP +import sfm_b1_offline_store as OS +import sfm_scene as SS + + +def _result_positive(): + return dict( + resolved=True, y=1, taskspace=True, collision_free=True, + certificate=True, full_h=True, terminal_step=10, + diagnostics={"slack": 0.1}, + ) + + +def _record_episode_shard(scenario=123, gamma=0.5, steps=4, n_ped=6): + """Simulate a short episode and store its exact contexts/windows.""" + speed_range = (1.0, 2.0) + humans = SS.make_humans(scenario, 0, n_ped, speed_range) + state = np.zeros(4, np.float32) + shard = OS.ExecutedRoundShard(1) + rng = np.random.default_rng(0) + for step in range(steps): + ped_xy, ped_vel = SS.collect_humans(humans) + context_id = shard.add_context( + scenario_id=scenario, gamma=gamma, step=step, state=state.copy(), + hp10=np.zeros((10, 16, 12), np.float32), + low5=np.zeros(5, np.float32), + hist=np.zeros((16, 2), np.float32), + ped_xy=ped_xy.copy(), ped_vel=ped_vel.copy(), + ) + controls = np.tile( + rng.uniform(-0.5, 0.5, size=(1, 2)).astype(np.float32), (10, 1), + ) + shard.add_executed_window( + context_id, controls, np.zeros(20, np.float32), + _result_positive(), execution_source="selected_B", + nvp_context=False, candidate_id=0, acquisition_step=0, + sigma=0.1, hp_margin=0.1, mode="U", + ) + action = controls[0] + state[:2] = state[:2] + SS.DT * state[2:4] + 0.5 * SS.DT ** 2 * action + state[2:4] = state[2:4] + SS.DT * action + SS.advance_humans(humans, state) + return shard + + +def test_prefix_replay_reconstructs_stored_context_exactly(): + shard = _record_episode_shard() + environment = dict(n_ped=6, ped_speed_range=(1.0, 2.0)) + for target_step in (0, 2, 3): + context = next( + c for c in shard.contexts if int(c["step"]) == target_step + ) + humans, state = MP.replay_prefix_humans( + shard, 123, 0.5, target_step, environment, + ) + ped_xy, ped_vel = SS.collect_humans(humans) + assert np.allclose(ped_xy, context["ped_xy"], atol=1e-6) + assert np.allclose(ped_vel, context["ped_vel"], atol=1e-6) + assert np.allclose(state, context["state"], atol=1e-6) + + +def test_privileged_config_matches_codex_source(): + cfg = MP.privileged_sfm_config() + assert cfg.exact_sfm_step_filter is True + assert cfg.step_filter_margin == 0.22 + assert cfg.step_filter_goal_plans == 12 + assert cfg.step_filter_avoid_plans == 18 + assert cfg.safe_coef_by_gamma == (1.0, 0.3, 1.0, 0.3, 0.3, 0.3, 0.1) + + +class _TinyPolicy(torch.nn.Module): + def __init__(self): + super().__init__() + self.enc_grid = torch.nn.Linear(1, 1, bias=False) + self.head = torch.nn.Linear(20, 20, bias=False) + self.d = 20 + self.u_max = 2.0 + + def ctx_from(self, grid, low, hist): + del grid, hist + return low[:, :1] + + def forward(self, value, tau, context): + del tau, context + return self.head(value) + + def cfm_loss(self, controls, context, weights=None): + del context + value = controls.reshape(len(controls), self.d) / self.u_max + per = (self.head(value) - value).square().mean(dim=1) + if weights is None: + return per.mean() + return (per * weights).sum() / weights.sum() + + +def test_distill_block_trains_only_on_mpc_records_and_moves_params(): + shard = _record_episode_shard(steps=3) + records = [ + dict( + context_id=int(c["context_id"]), y=1, query_id=i, + controls=np.full((10, 2), 0.3, np.float32), + source="codex_privileged_mpc_pool", rank=0, + privileged_clearance=0.3, privileged_margin=0.22, + safemppi_cost=1.0, verifier_diagnostics={}, + ) + for i, c in enumerate(shard.contexts) + ] + policy = _TinyPolicy() + for parameter in policy.enc_grid.parameters(): + parameter.requires_grad_(False) + optimizer = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], lr=1e-3, + ) + before = {k: v.clone() for k, v in policy.state_dict().items()} + result = MP.distill_block( + policy, optimizer, shard, records, epochs=2, batch=2, seed=5, + ) + assert result["steps"] == 2 * 2 # ceil(3/2)=2 batches x 2 epochs + assert result["records"] == 3 + assert not torch.equal(before["head.weight"], policy.state_dict()["head.weight"]) + assert torch.equal(before["enc_grid.weight"], policy.state_dict()["enc_grid.weight"]) + # empty buffer is a no-op + empty = MP.distill_block( + policy, optimizer, shard, [], epochs=2, batch=2, seed=5, + ) + assert empty["steps"] == 0 diff --git a/overnight_run_07_12_sfm/claude_mpc_exec.py b/overnight_run_07_12_sfm/claude_mpc_exec.py new file mode 100644 index 0000000..b054381 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_mpc_exec.py @@ -0,0 +1,426 @@ +"""Ordinary B1 expansion + dedicated per-round D_MPC+ distillation blocks. + +Per macro-round: +1. ordinary gather (immutable ``sfm_b1_offline_exec.gather_offline_round``) + and ordinary replay (untouched ``sfm_b1_offline_replay.replay``); +2. save the post-ordinary checkpoint (``round_XX_pre_block.pt``); +3. harvest this round's ``D_MPC+`` (``claude_mpc_pool``): privileged Codex + pool ∧ exact full-H10 SOCP, ranked by native SafeMPPI cost, per-round + fresh, gamma/context-balanced through the standard hierarchy mass; +4. audit BEFORE the block, run the dedicated distillation block (its own + Adam, swept lr/epochs), audit AFTER, save ``round_XX.pt``. + +Audits per block: (a) local MPC-context raw sampling — SOCP-positive rate, +mean predicted clearance and goal progress of 16 raw temperature-1 samples +per held audit context, and target recovery (min normalized L2 distance from +samples to the stored MPC target); (b) fixed raw temperature-1 M10/gamma +evaluation (CR, executed-window Validity, successful clearance and time) on +the declared MPC study bank. D_MPC+ never touches D/D+, the GP, or +acquisition; the privileged controller is never used at evaluation. +""" +from __future__ import annotations + +import argparse +from concurrent.futures import ProcessPoolExecutor +import copy +from dataclasses import asdict, dataclass +import json +import os +import time + +import numpy as np +import torch + +import _paths # noqa: F401 +import claude_mpc_pool as MP +import claude_offline_aug as AUG +import grid_policy_sfm as GPS +import sfm_b1_eval as BE +import sfm_b1_expand as BX +import sfm_b1_offline_exec as OE +import sfm_b1_offline_replay as OR +import sfm_b1_offline_store as OS +import sfm_b1_store as BS +import sfm_metrics2 as SM +import sfm_protocol as SP +import sfm_scene as SS + +M10_EP0 = 350_000 +M10_NOISE_SEED = 20_260_733 +AUDIT_CONTEXTS = 64 +AUDIT_SAMPLES = 16 +MAX_HARVEST_CONTEXTS = 400 + + +@dataclass(frozen=True) +class MPCConfig: + lr_dedicated: float + epochs_dedicated: int + selector: str = "margin" + alpha: float = 0.01 + exposure_epochs: int = 10 + lr: float = 1.0e-4 + ess_target: float = 0.5 + rounds: int = 4 + K: int = 16 + B: int = 4 + T: int = 180 + H: int = 10 + batch: int = 128 + nfe: int = 8 + temp: float = 1.0 + phi_s: float = 0.9 + gp_lam: float = OE.GP_LAMBDA + verifier_workers: int = 8 + seed: int = 20260724 + scene_profile: str = OE.SCENE_PROFILE + smoke: bool = False + tag: str = "mpc" + + def validate(self): + if not 0.0 < float(self.lr_dedicated) <= 1.0e-3: + raise ValueError("dedicated lr out of range") + if not 1 <= int(self.epochs_dedicated) <= 32: + raise ValueError("dedicated epochs out of range") + if ( + int(self.K), int(self.B), int(self.T), int(self.H), + int(self.batch), float(self.gp_lam), float(self.temp), + self.scene_profile, int(self.nfe), float(self.phi_s), + ) != (16, 4, 180, 10, 128, OE.GP_LAMBDA, 1.0, OE.SCENE_PROFILE, 8, 0.9): + raise ValueError("immutable offline core changed") + return self + + @property + def arm_name(self): + lr = f"{self.lr_dedicated:.0e}".replace("-", "m") + return f"{self.tag}_lrd{lr}_epd{int(self.epochs_dedicated):02d}" + + +@torch.no_grad() +def local_mpc_audit(policy, shard, records, *, device, executor, seed_tag): + """Raw-sampling audit at gamma-balanced held D_MPC+ contexts.""" + if not records: + return dict(contexts=0) + by_gamma = {} + for record in records: + gamma = round(float(shard.contexts[record["context_id"]]["gamma"]), 8) + by_gamma.setdefault(gamma, []).append(record) + chosen = [] + quota = max(1, AUDIT_CONTEXTS // max(len(by_gamma), 1)) + for gamma in sorted(by_gamma): + rows = sorted(by_gamma[gamma], key=lambda r: ( + r["context_id"], r["rank"], + )) + seen = set() + for row in rows: + if row["context_id"] in seen: + continue + seen.add(row["context_id"]) + chosen.append(row) + if len(seen) >= quota: + break + positive = clearance = progress = recovery = support = 0.0 + for row in chosen: + context = shard.contexts[row["context_id"]] + hp10 = torch.as_tensor(context["hp10"], device=device)[None].float() + low = torch.as_tensor(context["low5"], device=device)[None].float() + hist = torch.as_tensor(context["hist"], device=device)[None].float() + ctx = policy.ctx_from(hp10, low, hist) + generator = np.random.default_rng(OE._keyed_seed( + 20260724, 99, int(context["scenario_id"]), + f"{float(context['gamma']):.8f}", int(context["step"]), + f"mpc_audit_{seed_tag}", + )) + x0 = generator.standard_normal( + (AUDIT_SAMPLES, int(policy.d)), dtype=np.float32, + ) + windows = BE.integrate_latents( + policy, torch.as_tensor(x0, device=device), + ctx.repeat_interleave(AUDIT_SAMPLES, dim=0), nfe=8, + ).reshape(AUDIT_SAMPLES, 10, 2).cpu().numpy() + tasks = [ + (k, 0, context["state"], windows[k], context["ped_xy"], + context["ped_vel"], context["gamma"]) + for k in range(AUDIT_SAMPLES) + ] + results = {k: r for k, _, r in executor.map(SM.verify_in_worker, tasks)} + n_pos = sum( + 1 for r in results.values() + if r.get("resolved") and int(r.get("y", 0)) == 1 + ) + positive += n_pos / AUDIT_SAMPLES + support += float(n_pos > 0) + geometry = [ + AUG._window_geometry(context, windows[k]) + for k in range(AUDIT_SAMPLES) + ] + clearance += float(np.mean([g[0] for g in geometry])) + state = np.asarray(context["state"], np.float32) + goal_now = float(np.linalg.norm(state[:2] - SS.GOAL)) + segs = [SM.rollout_positions(state, windows[k])[-1] + for k in range(AUDIT_SAMPLES)] + progress += float(np.mean([ + goal_now - float(np.linalg.norm(seg - SS.GOAL)) for seg in segs + ])) + target = np.asarray(row["controls"], np.float32) + distances = [ + float(np.linalg.norm(windows[k] - target) / np.sqrt(target.size)) + for k in range(AUDIT_SAMPLES) + ] + recovery += min(distances) + n = max(len(chosen), 1) + return dict( + contexts=len(chosen), + socp_positive_rate=positive / n, + support_fraction=support / n, + mean_sample_clearance=clearance / n, + mean_sample_progress=progress / n, + target_recovery_rmse=recovery / n, + ) + + +def m10_eval(checkpoint, label, *, outdir, cache_dir, workers, device): + import sfm_b1_offline_eval as EV + args = argparse.Namespace( + checkpoints=[checkpoint], labels=[label], + scene_profile=OE.SCENE_PROFILE, ep0=M10_EP0, + noise_seed=M10_NOISE_SEED, m_per_gamma=10, device=device, + workers=int(workers), cache_dir=cache_dir, output_dir=outdir, + ) + result = EV.run(args) + pooled = result["records"][0]["cell"]["summary"]["pooled"] + return dict( + SR=float(pooled["SR"]), CR=float(pooled["CR"]), + timeout=float(pooled["timeout"]), + Validity=float(pooled["Validity"]["mean"]), + clearance=pooled["successful_clearance"]["mean"], + time=pooled["successful_time_to_goal"]["mean"], + per_gamma={ + gamma: dict( + CR=cell["CR"], + Validity=float(cell["Validity"]["mean"]), + clearance=cell["successful_clearance"]["mean"], + time=cell["successful_time_to_goal"]["mean"], + ) + for gamma, cell in + result["records"][0]["cell"]["summary"]["per_gamma"].items() + }, + ) + + +def run(checkpoint, outdir, cfg, *, device): + cfg.validate() + checkpoint = os.path.abspath(checkpoint) + outdir = os.path.abspath(outdir) + checkpoint_sha = OS.sha256_file(checkpoint) + if checkpoint_sha != OE.EXPECTED_CHECKPOINT_SHA256: + raise ValueError("MPC study must start from the exact r0 checkpoint") + if os.path.exists(outdir): + raise FileExistsError(outdir) + os.makedirs(outdir) + environment = SS.scene_profile(cfg.scene_profile) + policy, _ = GPS.load_sfm_policy(checkpoint, device=device) + BS.configure_expansion_trainability(policy) + encoder_sha = BS.module_sha256(policy.enc_grid) + optimizer = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], lr=cfg.lr, + ) + optimizer_dedicated = torch.optim.Adam( + [p for p in policy.parameters() if p.requires_grad], + lr=cfg.lr_dedicated, + ) + BX._save_checkpoint(policy, os.path.join(outdir, "round_00.pt"), dict( + round=0, experiment=cfg.arm_name, source_sha256=checkpoint_sha, + recipe=asdict(cfg), + )) + preflight = [ + BX.Replica(s, g, n_ped=environment["n_ped"], + ped_speed_range=tuple(environment["ped_speed_range"])) + for s in SP.expansion_scenarios(1, smoke=cfg.smoke) + for g in SP.GAMMAS + ] + ell0, ell, _ = OE._initial_lengthscale(policy, preflight, cfg, device) + history = [] + previous_shard = None + eval_cache = os.path.join(outdir, "m10_cache") + with ProcessPoolExecutor(max_workers=cfg.verifier_workers) as executor: + for round_i in range(1, cfg.rounds + 1): + start = time.perf_counter() + replicas = [ + BX.Replica(s, g, n_ped=environment["n_ped"], + ped_speed_range=tuple( + environment["ped_speed_range"])) + for s in SP.expansion_scenarios(round_i, smoke=cfg.smoke) + for g in SP.GAMMAS + ] + policy.eval() + phi_policy = copy.deepcopy(policy).eval() + for parameter in phi_policy.parameters(): + parameter.requires_grad_(False) + gp, gp_ids, gp_selection = OE.gp_from_previous( + phi_policy, previous_shard, round_i=round_i, ell=ell, + cap=OE.CAP, lam=cfg.gp_lam, phi_s=cfg.phi_s, device=device, + seed=cfg.seed + round_i * 101, + ) + beta, ess = OE._calibrate_beta( + phi_policy, gp, replicas, cfg, device, round_i=round_i, + ) + shard = OS.ExecutedRoundShard(round_i) + gather = OE.gather_offline_round( + policy, phi_policy, gp, beta, replicas, cfg, shard, device, + executor, round_i=round_i, + ) + shard.save(os.path.join( + outdir, "round_shards", f"round_{round_i:02d}.pt", + )) + replay = OR.replay( + policy, optimizer, shard, alpha=cfg.alpha, + exposure_epochs=cfg.exposure_epochs, batch=cfg.batch, + device=device, seed=cfg.seed + round_i * 1_000_003, + ) + pre_path = os.path.join( + outdir, f"round_{round_i:02d}_pre_block.pt", + ) + BX._save_checkpoint(policy, pre_path, dict( + round=round_i, phase="post_ordinary_pre_block", + experiment=cfg.arm_name, recipe=asdict(cfg), + )) + + # ---- D_MPC+ harvest (separate buffer; never enters D/GP) ---- + pop_a, pop_b, pop_stats = AUG.tag_populations(shard) + hard = {int(w["window_id"]): w for w in pop_b} + for window in shard.windows: + if window.get("nvp_context"): + hard[int(window["window_id"])] = window + policy.eval() + records, harvest_audit = MP.harvest_round( + policy, shard, list(hard.values()), executor, + device=device, environment=environment, + max_contexts=MAX_HARVEST_CONTEXTS, + ) + torch.save( + dict(round=round_i, records=records, audit=harvest_audit), + os.path.join(outdir, f"d_mpc_plus_round_{round_i:02d}.pt"), + ) + + audit_before = dict( + local=local_mpc_audit( + policy, shard, records, device=device, + executor=executor, seed_tag="fixed", + ), + m10=m10_eval( + pre_path, f"r{2 * round_i - 1}", + outdir=os.path.join( + outdir, "m10", f"round_{round_i:02d}_pre", + ), + cache_dir=eval_cache, workers=cfg.verifier_workers, + device=device, + ), + ) + block = MP.distill_block( + policy, optimizer_dedicated, shard, records, + epochs=cfg.epochs_dedicated, batch=cfg.batch, + seed=cfg.seed + round_i * 7_000_003, + ) + if BS.module_sha256(policy.enc_grid) != encoder_sha: + raise RuntimeError("visual encoder changed") + post_path = os.path.join(outdir, f"round_{round_i:02d}.pt") + BX._save_checkpoint(policy, post_path, dict( + round=round_i, phase="post_block", + experiment=cfg.arm_name, recipe=asdict(cfg), + )) + audit_after = dict( + local=local_mpc_audit( + policy, shard, records, device=device, + executor=executor, seed_tag="fixed", + ), + m10=m10_eval( + post_path, f"r{2 * round_i}", + outdir=os.path.join( + outdir, "m10", f"round_{round_i:02d}_post", + ), + cache_dir=eval_cache, workers=cfg.verifier_workers, + device=device, + ), + ) + record = dict( + round=round_i, experiment=cfg.arm_name, + beta=float(beta), calibrated_ess=float(ess), + gather_counts=gather["counts"], + outcomes=gather["outcomes"], + replay=dict( + optimizer_steps=replay["optimizer_steps"], + positive=replay["positive_eligible"], + negative=replay["negative_eligible"], + ), + populations=pop_stats, + harvest=dict( + counts=harvest_audit["counts"], + per_gamma=harvest_audit["per_gamma"], + ), + distill_block=block, + audit_before=audit_before, + audit_after=audit_after, + checkpoints=dict(pre=pre_path, post=post_path), + wall_seconds=time.perf_counter() - start, + ) + history.append(record) + with open(os.path.join(outdir, "metrics.jsonl"), "a") as stream: + stream.write(json.dumps(record, allow_nan=False) + "\n") + print(json.dumps(dict( + round=round_i, arm=cfg.arm_name, + kept=harvest_audit["counts"]["kept"], + block_steps=block["steps"], + m10_CR_before=audit_before["m10"]["CR"], + m10_CR_after=audit_after["m10"]["CR"], + m10_V_before=audit_before["m10"]["Validity"], + m10_V_after=audit_after["m10"]["Validity"], + socp_rate_before=audit_before["local"].get( + "socp_positive_rate"), + socp_rate_after=audit_after["local"].get( + "socp_positive_rate"), + wall=record["wall_seconds"], + )), flush=True) + previous_shard = shard + + OE._write_json(os.path.join(outdir, "COMPLETE.json"), dict( + status="CLAUDE_MPC_DISTILL_COMPLETE", + experiment=cfg.arm_name, recipe=asdict(cfg), + source_checkpoint_sha256=checkpoint_sha, + environment=environment, + m10_bank=dict(ep0=M10_EP0, noise_seed=M10_NOISE_SEED, m_per_gamma=10), + constants=dict( + ell=ell, ell0=ell0, keep_per_context=MP.KEEP_PER_CONTEXT, + max_harvest_contexts=MAX_HARVEST_CONTEXTS, + separation=( + "D_MPC+ is per-round, never enters D/D+/GP/acquisition; " + "privileged controller never used at evaluation" + ), + ), + history=history, + )) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--lr-dedicated", type=float, required=True) + parser.add_argument("--epochs-dedicated", type=int, required=True) + parser.add_argument("--rounds", type=int, default=4) + parser.add_argument("--verifier-workers", type=int, default=8) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--smoke", action="store_true") + parser.add_argument("--tag", default="mpc") + args = parser.parse_args(argv) + cfg = MPCConfig( + lr_dedicated=args.lr_dedicated, + epochs_dedicated=args.epochs_dedicated, + rounds=args.rounds, verifier_workers=args.verifier_workers, + smoke=args.smoke, tag=args.tag, + ) + run(args.checkpoint, args.outdir, cfg, device=args.device) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_mpc_pool.py b/overnight_run_07_12_sfm/claude_mpc_pool.py new file mode 100644 index 0000000..36ce38b --- /dev/null +++ b/overnight_run_07_12_sfm/claude_mpc_pool.py @@ -0,0 +1,373 @@ +"""Privileged MPC candidate-pool harvesting into a separate D_MPC+ buffer. + +Imports the original Codex privileged candidate-pool logic (read-only source: +``agent/sfm-adhoc-controller-overlay-20260726`` @ 0e0eca2, +``sfm_adhoc_controller_compare.privileged_sfm_config`` and the pool machinery +in ``sfm_kazuki``) without modifying either. At declared hard contexts of an +ordinary B1 gathering round this module: + +1. reconstructs the LIVE reactive SFM crowd by deterministic prefix replay of + the episode's executed actions (verified against the stored context); +2. regenerates the original Codex MPC pool at that context: guided flow + sampling (n_sample=200) + MPPI refinement + brake/goal/avoidance templates + + 25 constant-acceleration escapes, exactly as + ``exact_sfm_horizon_filter_action`` constructs it; +3. labels every candidate with BOTH the privileged exact-SFM look-ahead + feasibility (recoverable AND horizon clearance >= the per-gamma hard + margin) and our canonical exact full-H10 SOCP verifier; +4. keeps only the intersection, ranks it by the native frozen SafeMPPI + proposal cost, and stores the top ``KEEP_PER_CONTEXT`` (context, U) pairs + in a fresh per-round ``D_MPC+`` buffer. + +``D_MPC+`` NEVER enters the ordinary D/D+, the GP buffer, or acquisition. +Records carry no x0: distillation uses ``policy.cfm_loss`` which draws fresh +Gaussian CFM bases at every step. The privileged controller itself is never +used at evaluation time. +""" +from __future__ import annotations + +import numpy as np +import torch + +import _paths # noqa: F401 +import sfm_b1_cost as BC +import sfm_b1_store as BS +import sfm_kazuki as KZ +import sfm_metrics2 as SM +import sfm_scene as SS + +KEEP_PER_CONTEXT = 2 +POOL_SEED = 700_000 +H = 10 + + +def privileged_sfm_config(): + """Verbatim import of the historical v3 wrapper recipe. + + Source: agent/sfm-adhoc-controller-overlay-20260726 @ 0e0eca2, + sfm_adhoc_controller_compare.privileged_sfm_config (read-only). + """ + gammas = tuple(map(float, SS.GAMMAS)) + return KZ.KazukiConfig( + safe_coefs=(0.3,), + goal_coef=0.5, + n_sample=200, + n_elite=10, + n_copy=200, + exact_sfm_step_filter=True, + step_filter_margin=0.22, + step_filter_horizon=10, + step_filter_goal_plans=12, + step_filter_avoid_plans=18, + step_filter_always_select=True, + step_filter_min_progress=0.05, + step_filter_goal_score_weight=1.0, + step_filter_clearance_weight=0.05, + step_filter_escape_patience=5, + step_filter_escape_burst=3, + step_filter_viability_lookahead=20, + step_filter_viability_band=0.05, + step_filter_viability_escalate=True, + step_filter_viability_escalation_band=1.0, + step_filter_viability_escalation_min_progress=2.0, + step_filter_viability_escalation_entry_progress=5.0, + step_filter_viability_escalation_burst=40, + step_filter_stagnation_gamma_max=0.1, + step_filter_stagnation_window=20, + step_filter_stagnation_progress=0.1, + step_filter_stagnation_horizon=20, + step_filter_stagnation_burst=4, + controller_gammas=gammas, + safe_coef_by_gamma=(1.0, 0.3, 1.0, 0.3, 0.3, 0.3, 0.1), + goal_coef_by_gamma=(2.0, 0.5, 2.0, 0.5, 0.5, 0.5, 3.0), + step_filter_margin_by_gamma=(0.24, 0.22, 0.24, 0.22, 0.22, 0.22, 0.22), + step_filter_goal_score_weight_by_gamma=(2.0, 1.0, 2.0, 1.0, 1.0, 6.0, 2.0), + step_filter_clearance_weight_by_gamma=(0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.0), + step_filter_clearance_target_weight_by_gamma=(0.0,) * len(gammas), + ).validate() + + +def replay_prefix_humans(shard, scenario, gamma, upto_step, environment): + """Deterministically reconstruct the live SFM crowd at a stored context.""" + windows_by_step = {} + for window in shard.windows: + context = shard.contexts[int(window["context_id"])] + if ( + int(context["scenario_id"]) == int(scenario) + and round(float(context["gamma"]), 8) == round(float(gamma), 8) + ): + windows_by_step[int(context["step"])] = window + humans = SS.make_humans( + int(scenario), 0, int(environment["n_ped"]), + tuple(environment["ped_speed_range"]), + ) + state = np.zeros(4, np.float32) + for step in range(int(upto_step)): + window = windows_by_step.get(step) + if window is None: + raise KeyError( + f"missing executed window for s{scenario} g{gamma} step {step}" + ) + action = np.asarray(window["controls"], np.float32)[0] + state[:2] = state[:2] + SS.DT * state[2:4] + 0.5 * SS.DT ** 2 * action + state[2:4] = state[2:4] + SS.DT * action + SS.advance_humans(humans, state) + return humans, state + + +def _recoverable(inside, terminal, reach_step, horizon): + stop_hi = terminal[:, :2] + np.maximum(terminal[:, 2:4], 0.0) ** 2 / (2.0 * SS.U_MAX) + stop_lo = terminal[:, :2] - np.maximum(-terminal[:, 2:4], 0.0) ** 2 / (2.0 * SS.U_MAX) + reached = reach_step <= horizon + return inside & ( + reached + | ((stop_hi <= SS.TASK_HI).all(axis=1) & (stop_lo >= SS.TASK_LO).all(axis=1)) + ) + + +@torch.no_grad() +def build_codex_pool(policy, context, humans, *, device, seed_step): + """Regenerate the original Codex MPC pool at one stored context.""" + gamma = float(context["gamma"]) + base = privileged_sfm_config() + cfg = KZ._gamma_controller_config(base, gamma).validate() + guidance_cfg = KZ._gamma_guidance_config(cfg, gamma) + state = np.asarray(context["state"], np.float32) + ped_xy = np.asarray(context["ped_xy"], np.float32) + ped_vel = np.asarray(context["ped_vel"], np.float32) + hp10 = torch.as_tensor(context["hp10"], device=device)[None].float() + low = torch.as_tensor(context["low5"], device=device)[None].float() + hist = torch.as_tensor(context["hist"], device=device)[None].float() + ctx = policy.ctx_from(hp10, low, hist) + goal = torch.tensor(SS.GOAL, dtype=torch.float32, device=device) + torch.manual_seed( + POOL_SEED + int(context["scenario_id"]) * 1000 + int(seed_step) + ) + z = torch.randn(int(cfg.n_sample), int(policy.d), device=device) + taus = torch.tensor(cfg.ode_times, dtype=torch.float32, device=device) + ped_pred = KZ.predict_pedestrians_t(ped_xy, ped_vel, H, SS.DT, device) + ped_vel_t = torch.tensor(ped_vel, dtype=torch.float32, device=device) + z1, _, _ = KZ.guided_generate( + policy, ctx, state, goal, ped_pred, ped_vel_t, + SS.R_PED + cfg.collision_margin, z, taus, guidance_cfg, + collect_diagnostics=False, + ) + u_gen = torch.clamp( + z1.reshape(int(cfg.n_sample), H, 2) * float(policy.u_max), + -float(policy.u_max), float(policy.u_max), + ) + u_best, refine_diag = KZ.flow_mppi_refine( + policy, state, goal, ped_pred, SS.R_PED + cfg.collision_margin, + u_gen, None, guidance_cfg, collect_diagnostics=True, + ) + refined_pool = refine_diag.pop("_refined_controls") + nominal = u_best.detach().cpu().numpy().astype(np.float32) + plans = [nominal] + plans.extend(np.asarray(refined_pool, np.float32)) + plans.append(KZ._brake_control_plan(state, H)) + plans.extend(KZ._goal_control_plans( + state, int(cfg.step_filter_goal_plans), H, + )) + plans.extend(KZ._avoidance_control_plans( + humans, state, int(cfg.step_filter_avoid_plans), H, + )) + for ux in np.linspace(-SS.U_MAX, SS.U_MAX, 5): + for uy in np.linspace(-SS.U_MAX, SS.U_MAX, 5): + plans.append(np.repeat(np.array([[ux, uy]], np.float32), H, axis=0)) + unique = [] + for plan in plans: + plan = KZ._extend_plan_with_goal(state, plan, H) + plan = np.clip(np.asarray(plan, np.float32)[:H], -SS.U_MAX, SS.U_MAX) + if not any(np.allclose(plan, old, atol=1e-7) for old in unique): + unique.append(plan) + stacked = np.stack(unique) + clear, inside, terminal, _, reach_step = KZ._simulate_sfm_plans( + humans, state, stacked, H, + ) + margin = KZ._adaptive_step_filter_margin(cfg, gamma) + privileged = _recoverable(inside, terminal, reach_step, H) & ( + clear >= float(margin) + ) + return dict( + plans=stacked, + privileged_feasible=privileged, + privileged_clearance=clear, + margin=float(margin), + pool_manifest=dict( + nominal=1, refined=len(refined_pool), + brake=1, goal_plans=int(cfg.step_filter_goal_plans), + avoid_plans=int(cfg.step_filter_avoid_plans), + const_accel=25, unique=len(unique), + ), + ) + + +def harvest_round( + policy, shard, hard_windows, executor, *, device, environment, + max_contexts=None, +): + """Build the per-round D_MPC+ from the round's declared hard contexts.""" + by_lineage = {} + for window in hard_windows: + context = shard.contexts[int(window["context_id"])] + key = (int(context["scenario_id"]), round(float(context["gamma"]), 8)) + by_lineage.setdefault(key, []).append(int(window["context_id"])) + records, audit_rows = [], [] + counts = dict( + hard_contexts=0, replay_mismatch=0, pool_candidates=0, + privileged_feasible=0, socp_positive=0, intersection=0, kept=0, + ) + context_ids_all = sorted( + cid for ids in by_lineage.values() for cid in ids + ) + if max_contexts is not None and len(context_ids_all) > int(max_contexts): + stride = len(context_ids_all) / float(max_contexts) + context_ids_all = [ + context_ids_all[int(i * stride)] for i in range(int(max_contexts)) + ] + chosen = set(context_ids_all) + for (scenario, gamma), context_ids in sorted(by_lineage.items()): + for context_id in sorted(context_ids): + if context_id not in chosen: + continue + context = shard.contexts[context_id] + counts["hard_contexts"] += 1 + humans, replay_state = replay_prefix_humans( + shard, scenario, gamma, int(context["step"]), environment, + ) + ped_xy_live, _ = SS.collect_humans(humans) + if not np.allclose( + ped_xy_live, np.asarray(context["ped_xy"], np.float32), + atol=1e-4, + ) or not np.allclose( + replay_state, np.asarray(context["state"], np.float32), + atol=1e-4, + ): + counts["replay_mismatch"] += 1 + continue + pool = build_codex_pool( + policy, context, humans, device=device, + seed_step=int(context["step"]), + ) + plans = pool["plans"] + counts["pool_candidates"] += len(plans) + privileged = pool["privileged_feasible"] + counts["privileged_feasible"] += int(privileged.sum()) + tasks = [ + (index, 0, context["state"], plans[index], + context["ped_xy"], context["ped_vel"], gamma) + for index in range(len(plans)) if privileged[index] + ] + results = {i: r for i, _, r in executor.map( + SM.verify_in_worker, tasks, + )} + certified = [ + index for index in results + if results[index].get("resolved") + and int(results[index].get("y", 0)) == 1 + ] + counts["socp_positive"] += len(certified) + counts["intersection"] += len(certified) + if not certified: + continue + controls = torch.as_tensor( + np.stack([plans[i] for i in certified]), dtype=torch.float32, + ) + costs = BC.safemppi_proposal_cost( + context["state"], controls, SS.GOAL, + context["ped_xy"], context["ped_vel"], + ).cpu().numpy() + order = sorted( + range(len(certified)), key=lambda j: (float(costs[j]), j), + ) + for rank, j in enumerate(order[:KEEP_PER_CONTEXT]): + index = certified[j] + counts["kept"] += 1 + records.append(dict( + context_id=int(context_id), + controls=np.asarray(plans[index], np.float32), + y=1, + query_id=len(records), + source="codex_privileged_mpc_pool", + privileged_clearance=float( + pool["privileged_clearance"][index], + ), + privileged_margin=pool["margin"], + safemppi_cost=float(costs[j]), + rank=int(rank), + verifier_diagnostics=dict( + results[index]["diagnostics"], + ), + )) + audit_rows.append(dict( + context_id=int(context_id), scenario=int(scenario), + gamma=float(gamma), step=int(context["step"]), + rank=int(rank), cost=float(costs[j]), + privileged_clearance=float( + pool["privileged_clearance"][index], + ), + socp_slack=float( + results[index]["diagnostics"]["slack"], + ), + )) + per_gamma = {} + for record in records: + gamma = str(shard.contexts[record["context_id"]]["gamma"]) + per_gamma[gamma] = per_gamma.get(gamma, 0) + 1 + return records, dict(counts=counts, per_gamma=per_gamma, rows=audit_rows) + + +class MPCView: + """Duck-typed positive-only view over D_MPC+ for the mass machinery.""" + + def __init__(self, shard, records): + self.round_i = int(shard.round_i) + self.contexts = shard.contexts + self.windows = [dict(record) for record in records] + for index, row in enumerate(self.windows): + row["window_id"] = index + row["query_id"] = index + + @property + def Dplus(self): + return list(self.windows) + + +def distill_block(policy, optimizer, shard, records, *, epochs, batch, seed): + """Dedicated CFM distillation on D_MPC+ only (fresh Gaussian bases).""" + if not records: + return dict(steps=0, records=0, losses=None) + view = MPCView(shard, records) + pairs = [(view, row) for row in view.windows] + mass, accounting = BS.hierarchy_mass(pairs) + policy.train() + losses = [] + steps = 0 + generator = np.random.default_rng(int(seed)) + device = next(policy.parameters()).device + for epoch in range(int(epochs)): + order = list(generator.permutation(len(pairs))) + for start in range(0, len(order), int(batch)): + chunk = [pairs[i] for i in order[start:start + int(batch)]] + grid, low, hist, controls = BS._tensor_batch(chunk, device) + context = policy.ctx_from(grid, low, hist) + weights = torch.as_tensor([ + len(pairs) * mass[(id(view), int(row["query_id"]))] + for _, row in chunk + ], dtype=controls.dtype, device=device) + torch.manual_seed(int(seed) + epoch * 100_003 + start) + loss = policy.cfm_loss(controls, context, weights=weights) + if not bool(torch.isfinite(loss)): + raise FloatingPointError("non-finite MPC distillation loss") + optimizer.zero_grad(set_to_none=True) + loss.backward() + optimizer.step() + losses.append(float(loss.detach())) + steps += 1 + policy.eval() + return dict( + steps=steps, records=len(pairs), epochs=int(epochs), + loss_first=losses[0], loss_last=losses[-1], + loss_mean=float(np.mean(losses)), + mass_gamma=accounting["gamma"], + ) From 0c629057312985275ef8851174b53b1d8bd6b0fb Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 14:48:15 -0700 Subject: [PATCH 28/31] MPC study pre-registration: arms, banks (M10 350000 audit+select, M50 360000, M100 370000), selection rule, separation guarantees Co-Authored-By: Claude Fable 5 --- .../MPC_STUDY_PREREGISTRATION.json | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_PREREGISTRATION.json diff --git a/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_PREREGISTRATION.json b/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_PREREGISTRATION.json new file mode 100644 index 0000000..5864e21 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_PREREGISTRATION.json @@ -0,0 +1,27 @@ +{ + "status": "PREDECLARED_BEFORE_ANY_MPC_ARM_RUN", + "declared_at": "2026-07-26T16:20:00-07:00", + "branch": "agent/claude-sfm-mpc-distill-20260726 (Codex branch agent/sfm-adhoc-controller-overlay-20260726@0e0eca2 kept read-only; nothing written to it)", + "pipeline": "claude_mpc_pool.py + claude_mpc_exec.py: ordinary B1 expansion round (margin selector, original replay, alpha .01, exposures 10, lr 1e-4, ESS .5) followed by a dedicated D_MPC+ distillation block per round", + "d_mpc_plus": { + "separation": "per-round buffer; never enters D/D+, GP buffer, or acquisition; privileged controller never used at evaluation; no x0 stored (cfm_loss draws fresh Gaussian bases)", + "hard_contexts": "population-B windows (declared rules in claude_offline_aug.py) UNION all NVP contexts of the round shard; capped at 400/round by even stride", + "pool": "original Codex privileged pool regenerated at the stored context after exact prefix replay of the live SFM crowd (fail-closed on reconstruction mismatch): guided flow (n_sample 200) + MPPI refinement + brake + 12 goal plans + 18 avoidance plans + 25 constant-acceleration escapes", + "filter": "privileged exact-SFM look-ahead feasible (recoverable AND clearance >= per-gamma hard margin .22-.24) AND exact full-H10 SOCP y=1; intersection ranked by frozen native SafeMPPI proposal cost; keep top 2 per context" + }, + "sweep_arms_rounds_4_each": { + "M1": {"lr_dedicated": 1e-4, "epochs_dedicated": 2}, + "M2": {"lr_dedicated": 1e-4, "epochs_dedicated": 8}, + "M3": {"lr_dedicated": 1e-5, "epochs_dedicated": 8}, + "M4": {"lr_dedicated": 3e-4, "epochs_dedicated": 2} + }, + "control": "the identical ordinary recipe WITHOUT distillation = codex arm offline_exec_alpha0p01_exposures010 (same seeds, rounds 1-4 checkpoints already archived); its cells are evaluated on the same M10 bank for the comparison", + "banks": { + "m10_audit_and_selection": {"ep0": 350000, "noise_seed": 20260733, "m_per_gamma": 10, "role": "before/after audit of every dedicated block AND checkpoint/arm selection"}, + "m50_confirmation": {"ep0": 360000, "noise_seed": 20260734, "m_per_gamma": 50, "role": "disjoint confirmation of the single M10-selected winner vs r0"}, + "m100_final": {"ep0": 370000, "noise_seed": 20260735, "m_per_gamma": 100, "role": "disjoint final confirmation of the M50-confirmed winner vs r0; only if M50 confirms a real effect"} + }, + "selection_rule": "on the M10 bank over all saved checkpoints (pre-block and post-block, every arm): liveness gate SR >= SR(r0)-0.02 and timeout <= timeout(r0)+0.05; among eligible minimize CR, then maximize Validity, then clearance, then minimize time; the winner must be a POST-BLOCK checkpoint to count as a distillation effect, and its pre-block sibling is always reported alongside for the marginal-attribution claim", + "primary_question": "does the dedicated D_MPC+ block produce a VISIBLE raw-policy change (SOCP-positive rate / progress / clearance / target recovery at MPC contexts, and fixed-bank M10 deltas) beyond lowering its own training loss?", + "prohibitions": ["no temperature tuning", "no test-bank tuning", "no hidden fallback", "no silent rollback (every pre/post checkpoint kept and reported)", "no modification of any Codex artifact"] +} From d42e91376193b2ebdc9e7defeeff2280e15da005 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 16:15:20 -0700 Subject: [PATCH 29/31] Recipe study DELIVERY: M100 confirmation all-CI-clean (dCR -.100, dV +.133, dclr +.014, dt +2.08s vs r0), per-gamma tables, 33-artifact SHA manifest, DELIVERY_COMPLETE.json; MPC follow-up log Co-Authored-By: Claude Fable 5 --- .../DELIVERY_COMPLETE.json | 54 +++++++++++++++++++ .../claude_recipe_study/RESEARCH_LOG.md | 20 +++++++ 2 files changed, 74 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/DELIVERY_COMPLETE.json diff --git a/overnight_run_07_12_sfm/claude_recipe_study/DELIVERY_COMPLETE.json b/overnight_run_07_12_sfm/claude_recipe_study/DELIVERY_COMPLETE.json new file mode 100644 index 0000000..84349f6 --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/DELIVERY_COMPLETE.json @@ -0,0 +1,54 @@ +{ + "status": "CLAUDE_SFM_BEST_RECIPE_DELIVERY_COMPLETE", + "completed_at": "2026-07-26T18:20:00-07:00", + "headline": "A fixed Safe Flow Expansion recipe genuinely beat r0 on the disjoint M100 confirmation: CR -.100 [-.157,-.043], window Validity +.133 [+.114,+.153], successful clearance +.014 [+.005,+.023], SR +.080, at time +2.08 s [1.84,2.32] (paired scenario-cluster 95% CIs, 700 CRN rollouts/method, temperature 1.0, no tilt/fallback).", + "selected_recipe": { + "name": "margin_origrec_e100 (iteration 2, R_A)", + "execution_selector": "margin (max one-step nominal H_P margin among exact-certified B queries)", + "replay": "orig_plus_recovery: full executed D+ and D- plus exact-certified deterministic recovery positives (family v1) appended at hard contexts", + "alpha": 0.01, "exposure_epochs": 100, "lr": 1e-4, "ess_target": 0.5, + "rounds": 4, "seed": 20260724, + "selected_round": 1, + "selected_checkpoint": "/data3/research1/claude_sfm_best_recipe_f06e8dd/stageB/B9_margin_origrec_e100/round_01.pt", + "selected_checkpoint_sha256_prefix": "141e4ae6592bf73f" + }, + "provenance_chain": [ + "iteration 1 (frozen by predeclared Stage-B rule): cost/hard — honest NULL on its own M50 bank (STAGE_D_SELECTION.json); fully reported, nothing hidden", + "iteration 2 pre-registered BEFORE reading its selection bank (ITERATION2_PREREGISTRATION.json); recipe R_A chosen over v2 family by the declared U10-vs-U12 criterion", + "M50 selection on fresh bank 340000: r1 the only eligible round (ITERATION2_SELECTION.json)", + "M100 confirmation on untouched bank 330000: r0 / selected / locked Kazuki (stageE/)" + ], + "m100_confirmation_pooled": { + "r0": {"SR": 0.643, "CR": 0.353, "timeout": 0.004, "Validity": 0.603, "clearance": 0.108, "time": 8.76}, + "selected": {"SR": 0.723, "CR": 0.253, "timeout": 0.024, "Validity": 0.736, "clearance": 0.122, "time": 10.84}, + "kazuki": {"SR": 0.827, "CR": 0.173, "timeout": 0.000, "Validity": 0.353, "clearance": 0.168, "time": 4.17} + }, + "kazuki_note": "locked comparator (safe .3 / goal .5, Hp10 prior, native MPPI, no retuning): lower CR and faster via guidance+refinement, but its executed windows satisfy the exact GREEN certificate only 35% of the time versus 74% for our raw selected policy; it is a guided controller, not a raw flow", + "gamma_trend": "gamma=0.1 keeps the largest successful clearance (.148) and the longest time (13.4 s) for the selected checkpoint; mid-gamma flat within noise (per-gamma table stageE/m100_per_gamma_table.csv)", + "stability": "the recipe's gain is a round-1 phenomenon: round 2 fails the liveness gate (timeout .163 on the selection bank) and rounds 3-4 collapse to timeout; reported plainly, no monotonic-learning claim", + "local_repairs": "certified-recovery data demonstrably repairs the targeted failure modes: NVP contexts -25% and B-positive fraction .71->.84 on identical keyed latents; both diagnosed collision lineages became closed-loop successes under the selected checkpoint (mechanism/ figures)", + "integrity": [ + "temperature 1.0 everywhere, no per-gamma or global temperature tuning", + "no deterministic controller executed at evaluation; recovery data is replay-only", + "Kazuki untouched after declaration; checkpoint never selected from M100", + "iteration-1 null result and all failed arms reported (Stage-B table, Stage-D curve)", + "every synthetic positive carries an exact-verifier certificate audit row (iteration2/recovery_certificate_audit_B9.json)" + ], + "artifacts": { + "four_metric_plot_selected_recipe": "iteration2/paper_trends/b1_margin50_metric_trends.{png,pdf} (+ numeric SEs in margin_origrec_e100_trends_rows.jsonl)", + "four_metric_plot_iteration1": "stageD/paper_trends/b1_margin50_metric_trends.{png,pdf}", + "m50_selection": "iteration2/m50/", "m100_confirmation": "stageE/", + "paired_cis": "stageE/CONFIRMATION_ANALYSIS.json", + "per_gamma_tables": "stageE/m100_per_gamma_table.{csv,json}", + "population_counts": "iteration2/population_counts_B9.json", + "certificate_audit": "iteration2/recovery_certificate_audit_B9.json", + "mechanism_figures": "mechanism/ (+ artifact https://claude.ai/code/artifact/3c16fe2a-1450-4a8c-addf-466beaea1111)", + "baselines": "baselines/", + "manifest": "SHA256_MANIFEST.json (33 rehashed artifacts)", + "all_checkpoints": "stageB/B9_margin_origrec_e100/ (r0-r4), stageD/final_cost_hard/ (r0-r10)" + }, + "branches": { + "recipe_study": "agent/claude-sfm-best-recipe-20260726 (pushed)", + "mpc_distillation_followup": "agent/claude-sfm-mpc-distill-20260726 (superset; pushed; study running)" + } +} diff --git a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md index 01c0bfd..a3c4117 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md +++ b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md @@ -91,6 +91,26 @@ Per arm r1..r4 (SR/CR/V): - B8 cost origrec ess.3: ≈B4 Verdict: no cost-selector composition materially improves CR or Validity on this bank; the lower ESS target (0.3) is not beneficial. The strongest known pattern (margin/original/e100 — codex arm, r1 CR .237/V .739 on the M100 280k baseline) was absent from the set. +## 2026-07-26 13:00–15:00 — Stage D result, mechanism deep-dive, iteration 2 + +**Stage D (frozen cost/hard recipe, M50 bank 320000): honest null.** Rule-selected r7: CR .263 vs r0 .269 but Validity .571 vs .638. Full 11-round curve + paper-contract plot at `stageD/paper_trends/`. Recorded in STAGE_D_SELECTION.json with Pareto frontier. + +**Mechanism figures** (user request; artifact https://claude.ai/code/artifact/3c16fe2a-1450-4a8c-addf-466beaea1111, PNGs in `~/claude_sfm_figs/` and `mechanism/`): the closing certifiability window quantified on stored contexts — onset−2: 10–13/16 flow candidates certify, escapes 14–24/24; onset: flow 1–2/16, escapes 5–15/24; onset+4–7: 0 everywhere including both deterministic families. Closed-loop replays: B9-r1 converts both collision lineages to successes (s20005 γ.1 clearance .236; s20004 γ.5 clearance .139). + +**Recovery family v2** (user hypothesis: v1 escapes too conservative): dodge-then-cruise family implemented + declared; single-update head-to-head at matched dose (n=140): U10 v2 SR .679/CR .264/V .723/t 11.22 vs U12 v1 SR .614/CR .350/V .706/t 10.92. v2 did NOT remove the slowdown (the declared signature), so per the pre-registered criterion iteration 2 froze R_A (v1); the v2 SR/CR edge (within noise) is documented as follow-up. + +**Iteration 2 (pre-registered):** recipe = margin/orig_plus_recovery-v1/α.01/e100/lr1e-4/ess.5/rounds4 (B9; checkpoints trained from exact r0 before any selection-bank read). Fresh M50 selection bank 340000: r0 = SR .563/CR .434/V .574; **r1 = only eligible round: SR .654 (+.091), CR .311 (−.123), V .715 (+.141), clearance flat, time +2.36 s**; r2 fails gate (timeout .163), r3–r4 collapse. Selected checkpoint: B9 round_01.pt. Population counts + per-row certificate audits in `iteration2/population_counts_B9.json` and `iteration2/recovery_certificate_audit_B9.json` (280–407 exact-certified recovery positives/round from 8.3–12.3k exact queries). + +**Stage E launched** on untouched M100 bank 330000: r0 + selected + locked Kazuki. + +## 2026-07-26 18:20 — FINAL: Stage E confirmation + delivery + +M100 confirmation (untouched bank 330000, 700 CRN rollouts/method): r0 SR .643/CR .353/V .603/clr .108/t 8.76 → **selected (margin+orig∪recovery-v1, e100, round 1): SR .723 / CR .253 / V .736 / clr .122 / t 10.84**; locked Kazuki SR .827/CR .173/**V .353**/clr .168/t 4.17. Paired scenario-cluster 95% CIs (selected − r0): ΔCR −.100 [−.157,−.043], ΔV +.133 [+.114,+.153], Δclr +.014 [+.005,+.023], Δt +2.08 [1.84,2.32] — all exclude zero. γ=0.1 keeps the largest clearance (.148) and longest time (13.4 s). Stability honestly reported: round-1 phenomenon; r2+ collapse. Full record in DELIVERY_COMPLETE.json; 33-artifact SHA manifest. + +## 2026-07-26 16:20+ — MPC distillation follow-up study (separate branch) + +Pre-registered in MPC_STUDY_PREREGISTRATION.json (banks M10 350000 / M50 360000 / M100 370000). Smoke round: 172 D_MPC+ records; dedicated block doubled the local SOCP-positive rate at MPC contexts (.161→.313) — a visible raw-policy change; M10 CR moved adversely at that dose (smoke bank). 4-arm sweep (lr_d × epochs_d) running. + ### Stage B extension (declared 07:55 before reading its results) - B0: codex margin/original/α.01/e100 checkpoints r1–r4 evaluated on the SAME M25 qual bank (matched-round control; identical recipe lineage, same commit and seeds). From 123c2b91f4ad1efc449bae9f3602fc1363df038a Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 20:13:13 -0700 Subject: [PATCH 30/31] MPC distillation study COMPLETE (fail-closed at M10): visible raw-policy change confirmed (SOCP-rate up 15/16 blocks, target-recovery down 16/16, present even with rising loss) but 0/32 cells pass the pre-registered liveness gate; no-distill control collapses identically; M50/M100 not triggered; all pre/post checkpoints + audits kept Co-Authored-By: Claude Fable 5 --- overnight_run_07_12_sfm/claude_mpc_select.py | 130 ++++++++++++++++++ .../MPC_STUDY_DELIVERY.json | 38 +++++ .../claude_recipe_study/RESEARCH_LOG.md | 2 + 3 files changed, 170 insertions(+) create mode 100644 overnight_run_07_12_sfm/claude_mpc_select.py create mode 100644 overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_DELIVERY.json diff --git a/overnight_run_07_12_sfm/claude_mpc_select.py b/overnight_run_07_12_sfm/claude_mpc_select.py new file mode 100644 index 0000000..b691a5a --- /dev/null +++ b/overnight_run_07_12_sfm/claude_mpc_select.py @@ -0,0 +1,130 @@ +"""Apply the pre-registered MPC-study M10 selection rule. + +Collects every saved checkpoint cell (pre-block and post-block, every arm and +round) from the sweep's per-arm ``m10/`` audit directories, plus the common r0 +and the no-distillation control cells, and applies the rule declared in +MPC_STUDY_PREREGISTRATION.json verbatim: + + liveness gate: SR >= SR(r0) - 0.02 and timeout <= timeout(r0) + 0.05; + among eligible: min CR, then max Validity, then max successful clearance, + then min successful time-to-goal; + the winner must be a POST-BLOCK checkpoint to count as a distillation + effect; its pre-block sibling is always reported alongside. +""" +from __future__ import annotations + +import argparse +import glob +import json +import os + + +def _pooled(path): + with open(path) as stream: + payload = json.load(stream) + out = [] + for record in payload["records"]: + cell = record["cell"] + p = cell["summary"]["pooled"] + out.append(dict( + label=record["label"], + checkpoint=cell["checkpoint"], + checkpoint_sha256=cell["checkpoint_sha256"], + SR=float(p["SR"]), CR=float(p["CR"]), + timeout=float(p["timeout"]), + Validity=float(p["Validity"]["mean"]), + clearance=p["successful_clearance"]["mean"], + time=p["successful_time_to_goal"]["mean"], + )) + return out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sweep-root", required=True) + parser.add_argument("--arms", nargs="+", required=True) + parser.add_argument("--r0-control-metrics", required=True) + parser.add_argument("--out", required=True) + args = parser.parse_args(argv) + + baseline = _pooled(args.r0_control_metrics) + r0 = next(row for row in baseline if row["label"] == "r0") + control_rows = [ + dict(row, arm="control_no_distill", phase="control", + round=int(row["label"][1:])) + for row in baseline if row["label"] != "r0" + ] + cells = [] + for arm in args.arms: + for path in sorted(glob.glob(os.path.join( + args.sweep_root, arm, "m10", "round_*_*", + "raw_m10_offline_metrics.json", + ))): + phase = "post" if path.split(os.sep)[-2].endswith("_post") else "pre" + round_i = int(path.split(os.sep)[-2].split("_")[1]) + for row in _pooled(path): + cells.append(dict( + row, arm=arm, phase=phase, round=round_i, + )) + gate_sr = r0["SR"] - 0.02 + gate_to = r0["timeout"] + 0.05 + eligible = [ + c for c in cells + if c["SR"] >= gate_sr and c["timeout"] <= gate_to + and c["clearance"] is not None and c["time"] is not None + ] + + def key(c): + return ( + c["CR"], -c["Validity"], + -(c["clearance"] if c["clearance"] is not None else -1), + c["time"] if c["time"] is not None else 1e9, + c["round"], c["arm"], + ) + + ordered = sorted(eligible, key=key) + post_ordered = [c for c in ordered if c["phase"] == "post"] + winner = post_ordered[0] if post_ordered else None + sibling = None + if winner: + sibling = next( + (c for c in cells + if c["arm"] == winner["arm"] and c["round"] == winner["round"] + and c["phase"] == "pre"), + None, + ) + payload = dict( + status="MPC_M10_SELECTION_APPLIED", + rule="MPC_STUDY_PREREGISTRATION.json verbatim", + bank=dict(ep0=350000, noise_seed=20260733, m_per_gamma=10), + r0=r0, + liveness_gate=dict(SR_min=gate_sr, timeout_max=gate_to), + n_cells=len(cells), n_eligible=len(eligible), + winner_post_block=winner, + pre_block_sibling=sibling, + top_eligible=ordered[:8], + control_no_distill=control_rows, + all_cells=sorted( + cells + control_rows, + key=lambda c: (c["arm"], c["round"], c.get("phase", "")), + ), + ) + with open(args.out, "w") as stream: + json.dump(payload, stream, indent=1, allow_nan=False) + print(json.dumps(dict( + r0={k: r0[k] for k in ("SR", "CR", "Validity")}, + winner=None if winner is None else { + k: winner[k] + for k in ("arm", "round", "phase", "SR", "CR", "Validity", + "clearance", "time") + }, + sibling=None if sibling is None else { + k: sibling[k] + for k in ("SR", "CR", "Validity", "clearance", "time") + }, + eligible=len(eligible), + ), indent=1)) + + +if __name__ == "__main__": + main() diff --git a/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_DELIVERY.json b/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_DELIVERY.json new file mode 100644 index 0000000..7ba1e1c --- /dev/null +++ b/overnight_run_07_12_sfm/claude_recipe_study/MPC_STUDY_DELIVERY.json @@ -0,0 +1,38 @@ +{ + "status": "MPC_DISTILLATION_STUDY_COMPLETE_FAIL_CLOSED_AT_M10", + "completed_at": "2026-07-26T21:35:00-07:00", + "branch": "agent/claude-sfm-mpc-distill-20260726 (Codex branch @0e0eca2 untouched, read-only)", + "primary_question": "does dedicated D_MPC+ distillation produce a VISIBLE raw-policy change, rather than merely lowering its training loss?", + "answer": "YES — visible and consistent, but not globally beneficial at any swept dose.", + "evidence_visible_change": { + "socp_positive_rate_at_mpc_contexts": "rose after 15 of 16 dedicated blocks (e.g. .106->.457 at lr 1e-4 x8 epochs, .106->.482 at lr 3e-4 x2; only M4 round-4 fell .319->.278), measured by 16 fresh temperature-1 raw samples per held gamma-balanced MPC context against the exact full-H10 SOCP verifier", + "target_recovery_rmse": "fell after 16 of 16 blocks (policy samples move toward the stored MPC targets)", + "loss_vs_policy": "distill_block_loss_vs_policy_change.json: policy-side movement occurs even in blocks where the CFM loss barely moved or rose (M1 r1: loss 1.248->1.266 while SOCP rate .106->.216), so the change is not a loss artifact" + }, + "evidence_not_beneficial": { + "m10_selection": "MPC_M10_SELECTION.json: 0 of 32 pre/post cells pass the pre-registered liveness gate (SR >= r0-0.02 = .637, timeout <= .079); r0 on the bank: SR .657 / CR .314 / V .628", + "immediate_liveness_cost": "round-1 post-block SR: .629 -> .471 (lr1e-4x2), .257 (lr1e-4x8), .243 (lr3e-4x2), .600 (lr1e-5x8 — gentlest, also smallest local gain)", + "no_collapse_rescue": "the base ordinary recipe (margin/original/e10) collapses by rounds 2-3 with or without distillation (control_no_distill: SR .686 -> .457 -> .000)", + "funnel": "per the pre-registration, the M50 (360000) and M100 (370000) confirmations are NOT triggered because M10 selects no eligible winner; stopping fail-closed rather than promoting an ineligible cell" + }, + "dedicated_recipe_sweep_reported": { + "M1": {"lr_dedicated": 1e-4, "epochs": 2}, "M2": {"lr_dedicated": 1e-4, "epochs": 8}, + "M3": {"lr_dedicated": 1e-5, "epochs": 8}, "M4": {"lr_dedicated": 3e-4, "epochs": 2}, + "rounds": 4, "base_ordinary_recipe": "margin selector, original replay, alpha .01, exposures 10, lr 1e-4, ESS .5, seed 20260724", + "checkpoints": "every pre-block and post-block checkpoint kept under mpc_distill/M*/", + "exact_choice": "none selected — no cell eligible under the pre-registered rule" + }, + "d_mpc_plus_accounting": { + "kept_per_round_range": [61, 222], + "intersection_semantics": "privileged exact-SFM look-ahead feasible AND exact full-H10 SOCP y=1, ranked by frozen native SafeMPPI cost, top-2 per hard context", + "separation_verified": "records live in per-round d_mpc_plus_round_*.pt only; ordinary D/D+/GP/acquisition untouched (code path in claude_mpc_exec.py); prefix-replay reconstruction fail-closed (0 mismatches in all arms)" + }, + "synthesis_with_recipe_study": "Across three target families (deterministic v1 escapes, goal-directed v2, privileged Codex MPC pool), concentrated hard-context-only distillation always trades global liveness for local certifiable support. The confirmed winning recipe embedded certified hard-context targets INSIDE the full original replay mixture (~5% mass) — composition, not target quality, is the binding constraint.", + "artifacts": { + "selection": "mpc_distill/MPC_M10_SELECTION.json (all 37 cells incl. control and r0)", + "audits": "mpc_distill/M*/metrics.jsonl (per-block before/after local + M10 audits)", + "loss_vs_change": "mpc_distill/distill_block_loss_vs_policy_change.json", + "buffers": "mpc_distill/M*/d_mpc_plus_round_*.pt (with per-record provenance and verifier diagnostics)", + "control": "mpc_distill/m10_r0_and_control/" + } +} diff --git a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md index a3c4117..69a90c7 100644 --- a/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md +++ b/overnight_run_07_12_sfm/claude_recipe_study/RESEARCH_LOG.md @@ -111,6 +111,8 @@ M100 confirmation (untouched bank 330000, 700 CRN rollouts/method): r0 SR .643/C Pre-registered in MPC_STUDY_PREREGISTRATION.json (banks M10 350000 / M50 360000 / M100 370000). Smoke round: 172 D_MPC+ records; dedicated block doubled the local SOCP-positive rate at MPC contexts (.161→.313) — a visible raw-policy change; M10 CR moved adversely at that dose (smoke bank). 4-arm sweep (lr_d × epochs_d) running. +**COMPLETE (fail-closed at M10), 21:35** — Answer to the primary question: **YES, D_MPC+ distillation visibly changes the raw policy** (SOCP-positive rate at MPC contexts up in 15/16 blocks, up to .106→.482; target-recovery RMSE down in 16/16; movement present even when the block's CFM loss rose) — **but no swept dose is globally beneficial**: 0/32 pre/post cells pass the pre-registered liveness gate on the M10 bank (r0 SR .657; post-block round-1 SR .243–.600), the base margin/e10 recipe collapses by r2–3 with or without distillation (no-distill control confirms), and per the pre-registration the M50/M100 confirmations are not triggered. Full record: MPC_STUDY_DELIVERY.json, mpc_distill/MPC_M10_SELECTION.json (all 37 cells), per-block audits in mpc_distill/M*/metrics.jsonl, every pre/post checkpoint kept. Synthesis: across deterministic v1/v2 escapes and the privileged MPC pool alike, hard-context-only distillation trades global liveness for local certifiable support; the confirmed winning recipe worked by EMBEDDING certified targets in the full replay mixture (~5% mass) — composition, not target quality, is the binding constraint. + ### Stage B extension (declared 07:55 before reading its results) - B0: codex margin/original/α.01/e100 checkpoints r1–r4 evaluated on the SAME M25 qual bank (matched-round control; identical recipe lineage, same commit and seeds). From 455fd5858f05637905305c0737b7cf9f9be73fe6 Mon Sep 17 00:00:00 2001 From: dohyun Date: Sun, 26 Jul 2026 20:14:02 -0700 Subject: [PATCH 31/31] MPC pool: allow autograd through Codex guidance (deploy-exact ctx squeeze + ode_times tuple already in); this is the smoke-validated version used by the sweep Co-Authored-By: Claude Fable 5 --- overnight_run_07_12_sfm/claude_mpc_pool.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/overnight_run_07_12_sfm/claude_mpc_pool.py b/overnight_run_07_12_sfm/claude_mpc_pool.py index 36ce38b..39ea5bf 100644 --- a/overnight_run_07_12_sfm/claude_mpc_pool.py +++ b/overnight_run_07_12_sfm/claude_mpc_pool.py @@ -125,9 +125,14 @@ def _recoverable(inside, terminal, reach_step, horizon): ) -@torch.no_grad() def build_codex_pool(policy, context, humans, *, device, seed_step): - """Regenerate the original Codex MPC pool at one stored context.""" + """Regenerate the original Codex MPC pool at one stored context. + + Deliberately NOT wrapped in ``torch.no_grad``: the Codex guidance + differentiates its CBF/goal rewards with respect to the latent inside + ``guided_generate`` (policy weights are only evaluated under its own + internal ``no_grad`` and are never modified here). + """ gamma = float(context["gamma"]) base = privileged_sfm_config() cfg = KZ._gamma_controller_config(base, gamma).validate() @@ -138,13 +143,13 @@ def build_codex_pool(policy, context, humans, *, device, seed_step): hp10 = torch.as_tensor(context["hp10"], device=device)[None].float() low = torch.as_tensor(context["low5"], device=device)[None].float() hist = torch.as_tensor(context["hist"], device=device)[None].float() - ctx = policy.ctx_from(hp10, low, hist) + ctx = policy.ctx_from(hp10, low, hist).squeeze(0) goal = torch.tensor(SS.GOAL, dtype=torch.float32, device=device) torch.manual_seed( POOL_SEED + int(context["scenario_id"]) * 1000 + int(seed_step) ) z = torch.randn(int(cfg.n_sample), int(policy.d), device=device) - taus = torch.tensor(cfg.ode_times, dtype=torch.float32, device=device) + taus = cfg.ode_times ped_pred = KZ.predict_pedestrians_t(ped_xy, ped_vel, H, SS.DT, device) ped_vel_t = torch.tensor(ped_vel, dtype=torch.float32, device=device) z1, _, _ = KZ.guided_generate(