| """Diagnostics for horizon-reset procrastination and the K=5 regression.
|
|
|
| Two questions, both answered in the world model's latent space against the
|
| same held-out latents used for validation:
|
|
|
| 1. Under receding-horizon execution, does the closed loop have a fixed point
|
| outside the success radius? Fitting ``D_{n+1} = c*D_n + b`` gives
|
| ``D* = b/(1-c)`` when ``0 < c < 1``. A positive intercept means each
|
| replan removes a *fraction* of the remaining distance but adds a floor, so
|
| the loop stalls short of the goal no matter how long it runs.
|
|
|
| 2. Does refinement keep improving predicted cost past the trained depth K=3
|
| even as real success degrades? If predicted cost improves while success
|
| drops, the extra refinements are exploiting the world model rather than
|
| planning better.
|
|
|
| Question 2 originally measured only the *imagined* side, which cannot separate
|
| "the plan got better" from "the plan left the region where the world model is
|
| trustworthy" — both look like falling predicted cost. The support score
|
| ``r(C, b) = -log beta(b | C) / A`` against the fitted GMM answers that directly:
|
| if the violation fraction rises with k while predicted cost falls, the extra
|
| refinements are buying imagined progress with out-of-distribution actions.
|
| """
|
|
|
| import argparse
|
| import json
|
| import sys
|
| from pathlib import Path
|
|
|
| import numpy as np
|
| import torch
|
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
| from lejepa_control.data import LatentGoalDataset, split_episodes
|
| from lejepa_control.losses import BehaviorDensity
|
| from lejepa_control.rollout import goal_distance, rollout_plan
|
| from lejepa_control.solver import load_controller
|
| from lejepa_control.world_model import load_lewm
|
|
|
|
|
| def parse_args():
|
| p = argparse.ArgumentParser()
|
| p.add_argument('--controller', default='data/runs/controller/controller.pt')
|
| p.add_argument('--latents', default='data/latents')
|
| p.add_argument('--density', default='data/runs/density/density.pt')
|
| p.add_argument('--samples', type=int, default=256)
|
| p.add_argument('--horizon', type=int, default=5)
|
| p.add_argument('--replans', type=int, default=12)
|
| p.add_argument('--max-k', type=int, default=8)
|
| p.add_argument('--tag', default='main')
|
| p.add_argument('--out', default='data/runs/diagnostics')
|
| return p.parse_args()
|
|
|
|
|
| @torch.no_grad()
|
| def closed_loop_trace(controller, model, batch, device, execute, replans):
|
| """Latent goal distance after each replan, simulating in latent space.
|
|
|
| The world model is its own simulator here: whatever the predictor says
|
| the executed blocks caused becomes the next context. That is exactly the
|
| loop the real MPC runs, minus the env, so it isolates the planner's
|
| dynamics from simulator mismatch.
|
| """
|
| ctx = batch['context'].to(device)
|
| past = batch['past_actions'].to(device)
|
| goal = batch['goal'].to(device)
|
| num_context = controller.num_context
|
|
|
| trace = []
|
| for _ in range(replans):
|
| out = controller(model, ctx, past, goal)
|
| plan = out['plans'][-1]
|
|
|
|
|
| pred, frames = rollout_plan(
|
| model, ctx, past, plan[:, :execute], return_frames=True
|
| )
|
| trace.append(goal_distance(pred, goal)[:, execute - 1].cpu().numpy())
|
|
|
| ctx = frames[:, -num_context:]
|
| past = torch.cat([past, plan[:, :execute]], dim=1)[:, -(num_context - 1):]
|
| return np.stack(trace, axis=1)
|
|
|
|
|
| def fit_contraction(trace):
|
| """Least-squares ``D_{n+1} = c*D_n + b`` over all consecutive pairs."""
|
| x = trace[:, :-1].ravel()
|
| y = trace[:, 1:].ravel()
|
| A = np.stack([x, np.ones_like(x)], axis=1)
|
| (c, b), *_ = np.linalg.lstsq(A, y, rcond=None)
|
| resid = y - (c * x + b)
|
| ss = 1 - resid.var() / y.var() if y.var() > 0 else float('nan')
|
| fixed = b / (1 - c) if abs(1 - c) > 1e-9 else float('inf')
|
| return float(c), float(b), float(fixed), float(ss)
|
|
|
|
|
| @torch.no_grad()
|
| def refinement_trace(controller, model, batch, device, max_k,
|
| density=None, c95=None):
|
| """Predicted goal cost after each refinement, out to ``max_k``.
|
|
|
| Also returns the support-violation fraction per refinement when a density
|
| model is supplied — the imagined cost and the violation fraction moving in
|
| opposite directions is the exploitation signature.
|
| """
|
| saved = controller.refinements
|
| try:
|
| controller.refinements = max_k
|
| out = controller(
|
| model,
|
| batch['context'].to(device),
|
| batch['past_actions'].to(device),
|
| batch['goal'].to(device),
|
| )
|
| q = batch['goal_offset'].to(device).clamp(1, controller.horizon)
|
| costs, arrivals, moves, violations, scores = [], [], [], [], []
|
| prev = None
|
| for k, d in enumerate(out['distances']):
|
| costs.append(float(d[:, -1].mean()))
|
| arrivals.append(
|
| float(d.gather(1, (q - 1).unsqueeze(1)).squeeze(1).mean())
|
| )
|
| plan = out['plans'][k]
|
| moves.append(
|
| 0.0 if prev is None else float((plan - prev).abs().mean())
|
| )
|
| prev = plan
|
|
|
| if density is not None and c95 is not None:
|
|
|
|
|
| ctx = out['contexts'][k].flatten(0, 1)
|
| score = density.nll_per_dim(ctx, plan.flatten(0, 1))
|
| violations.append(float((score > c95).float().mean()))
|
| scores.append(float(score.mean()))
|
| return costs, arrivals, moves, violations, scores
|
| finally:
|
| controller.refinements = saved
|
|
|
|
|
| def main():
|
| args = parse_args()
|
| device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
| torch.manual_seed(0)
|
|
|
| stats = json.loads((Path(args.latents) / 'stats.json').read_text())
|
| model = load_lewm(device=device)
|
| controller, ckpt = load_controller(
|
| args.controller, latent_dim=stats['latent_dim'], device=device
|
| )
|
| controller.eval()
|
| print(f'controller step {ckpt["step"]}, K={controller.refinements}')
|
|
|
| _, val_eps = split_episodes(stats['n_episodes'])
|
| val = LatentGoalDataset(
|
| args.latents, max_offset=5, episodes=val_eps, horizon=args.horizon
|
| )
|
| idx = np.random.default_rng(0).choice(len(val), args.samples, replace=False)
|
| batch = {
|
| k: torch.stack([val[int(i)][k] for i in idx])
|
| for k in ('context', 'past_actions', 'goal', 'goal_offset')
|
| }
|
|
|
| density, c95 = None, None
|
| if Path(args.density).exists():
|
| d_ckpt = torch.load(args.density, map_location=device, weights_only=False)
|
| density = BehaviorDensity(
|
| latent_dim=d_ckpt['latent_dim'], components=d_ckpt['components']
|
| )
|
| density.load_state_dict(d_ckpt['state_dict'])
|
| density.to(device).eval().requires_grad_(False)
|
| c95 = float(d_ckpt['c95'])
|
| print(f'support model loaded, c95={c95:.4f}')
|
| else:
|
| print(f'no support model at {args.density} — violation column disabled')
|
|
|
| report = {'tag': args.tag, 'checkpoint': args.controller,
|
| 'step': int(ckpt['step'])}
|
|
|
| print('\n=== 1. closed-loop contraction ===')
|
| print(f'{"execute":>8}{"D_0":>9}{"D_final":>10}{"c":>8}{"b":>9}'
|
| f'{"D*":>9}{"R^2":>7}')
|
| report['contraction'] = {}
|
| for execute in (1, 5):
|
| trace = closed_loop_trace(
|
| controller, model, batch, device, execute, args.replans
|
| )
|
| c, b, fixed, r2 = fit_contraction(trace)
|
| m = trace.mean(axis=0)
|
| print(f'{execute:>8}{m[0]:>9.4f}{m[-1]:>10.4f}'
|
| f'{c:>8.4f}{b:>9.4f}{fixed:>9.4f}{r2:>7.3f}')
|
| report['contraction'][f'exec{execute}'] = {
|
| 'c': c, 'b': b, 'fixed_point': fixed, 'r2': r2,
|
| 'mean_trace': [round(float(v), 5) for v in m],
|
| }
|
|
|
| print('\n=== 2. refinement past trained depth ===')
|
| costs, arrivals, moves, violations, scores = refinement_trace(
|
| controller, model, batch, device, args.max_k, density, c95
|
| )
|
| print(f'{"k":>3}{"terminal":>11}{"arrival":>10}{"dJ":>10}{"|plan chg|":>12}'
|
| f'{"support r":>11}{"viol%":>8}')
|
| for k in range(len(costs)):
|
| dj = '' if k == 0 else f'{costs[k - 1] - costs[k]:+.5f}'
|
| sup = f'{scores[k]:>11.4f}' if scores else f'{"-":>11}'
|
| vio = f'{100 * violations[k]:>8.1f}' if violations else f'{"-":>8}'
|
| print(f'{k:>3}{costs[k]:>11.5f}{arrivals[k]:>10.5f}{dj:>10}'
|
| f'{moves[k]:>12.5f}{sup}{vio}')
|
| report['refinement'] = {
|
| 'terminal': costs, 'arrival': arrivals, 'plan_change': moves,
|
| 'support_score': scores, 'violation': violations, 'c95': c95,
|
| }
|
|
|
| out = Path(args.out)
|
| out.mkdir(parents=True, exist_ok=True)
|
| with (out / 'diagnostics.jsonl').open('a') as f:
|
| f.write(json.dumps(report) + '\n')
|
| print(f'\nwrote {out / "diagnostics.jsonl"}')
|
|
|
|
|
| if __name__ == '__main__':
|
| main()
|
|
|