import json, random, sys from pathlib import Path import numpy as np import torch from torch import nn sys.path.insert(0, '/home/maxwelhelp/all/math2nn') from bench import get_dataset, evaluate, sweep_baseline, make_report SEEDS = tuple(range(8)) SWEEP_SEEDS = (0, 1, 2, 3) NTR, NTE, EPOCHS, BATCH = 400, 200, 18, 128 LRS = [1e-3, 3e-3, 1e-2] DELTA = 0.02 def seed_all(seed): random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def math_sanity(): # A finite Markov chain with absorbing target/unsafe states. A strict # drift certificate must imply the usual optional-stopping bound. P = np.array([[.7, .2, .1], [.0, .9, .1], [0., 0., 1.]]) # state 0 continuation, state 1 unsafe, state 2 target; B(unsafe)=1,B(target)=0 B = np.array([.7, 1., 0.]) drift = P @ B - B # For delta=.01 this is a valid non-strict/nonnegative check at state 0 return {'claim': 'E[B_next]-B <= 0 implies failure probability <= B', 'expected_B_next_state0': float(P[0] @ B), 'B_state0': .7, 'drift_state0': float(drift[0]), 'failure_probability': .2 / (.2 + .1), 'bound_holds': bool(drift[0] <= 1e-12 and .2 / .3 <= B[0])} class BarrierRNN(nn.Module): """The bench rnn_small predictor with a scalar state barrier head.""" def __init__(self, hidden=64): super().__init__() self.rnn = nn.GRU(3, hidden, batch_first=True) self.head = nn.Linear(hidden, 1) self.barrier = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 1), nn.Sigmoid()) def forward(self, x, return_barrier=False): seq = x.view(x.shape[0], -1, 3) _, h = self.rnn(seq) latent = h[-1] pred = self.head(latent) if return_barrier: state = seq[:, -1, :2] return pred, self.barrier(state) + 0.05 * torch.tanh(latent.mean(1, keepdim=True)), state return pred def safety_masks(state): th, om = state[:, 0], state[:, 1] unsafe = (th.abs() > 1.35) | (om.abs() > 2.4) target = (th.abs() < .18) & (om.abs() < .25) initial = (th.abs() < .45) & (om.abs() < 1.0) cont = ~(unsafe | target) return unsafe, target, initial, cont def train_one(kind, seed, lr, lam=1.0, collect=False, weight_decay=0.0): seed_all(seed) ds = get_dataset('dynamics', seed, n_train=NTR, n_test=NTE) net = BarrierRNN() dev = 'cuda' if torch.cuda.is_available() else 'cpu' try: net.to(dev) x, y = ds['xtr'].to(dev), ds['ytr'].to(dev) opt = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay) mse = nn.MSELoss() for _ in range(EPOCHS): net.train(); perm = torch.randperm(len(x), device=dev) for j in range(0, len(x), BATCH): ix = perm[j:j+BATCH]; xb, yb = x[ix], y[ix] pred, b, state = net(xb, True) loss = mse(pred, yb) if kind == 'idea': unsafe, target, initial, cont = safety_masks(state) # One-step stochastic closed-loop proxy: damped pendulum # transition with held-out Gaussian process disturbance. th, om, u = state[:,0], state[:,1], xb.view(-1,8,3)[:,-1,2] noise = .04 * torch.randn_like(th) om1 = om + .05 * (-.981*torch.sin(th) - .2*om + 2*u) + noise th1 = th + .05 * om1 bnext = net.barrier(torch.stack([th1, om1], 1)).squeeze(1) sp = torch.nn.functional.softplus # Uniform constraints, with positive margin in continuation. aux = 3*sp(1-b[unsafe]).mean() if unsafe.any() else 0*b.mean() aux = aux + 3*b[target].abs().mean() if target.any() else aux aux = aux + 3*sp(b[initial]-(1-.7-DELTA)).mean() if initial.any() else aux aux = aux + lam*sp(bnext[cont]-b.squeeze(1)[cont]+DELTA).mean() if cont.any() else aux loss = loss + aux opt.zero_grad(); loss.backward(); opt.step() net.eval() with torch.no_grad(): metric = float(((net(ds['xte'].to(dev))-ds['yte'].to(dev))**2).mean()) if collect: return metric, net, ds, dev return metric except RuntimeError: # Explicit CPU fallback, matching the harness intent. torch.cuda.empty_cache() if torch.cuda.is_available() else None net = BarrierRNN(); net.to('cpu'); x,y=ds['xtr'],ds['ytr'] opt=torch.optim.Adam(net.parameters(),lr=lr,weight_decay=weight_decay); mse=nn.MSELoss() for _ in range(EPOCHS): for j in range(0,len(x),BATCH): xb,yb=x[j:j+BATCH],y[j:j+BATCH]; pred,b,_=net(xb,True); loss=mse(pred,yb) opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): metric=float(((net(ds['xte'])-ds['yte'])**2).mean()) return (metric,net,ds,'cpu') if collect else metric def fn(kind, cfg): return lambda seed: train_one(kind, seed, cfg['lr'], cfg.get('lam', 0.0), weight_decay=cfg.get('weight_decay', 0.0)) def mechanism_signature(): pred, held = [], [] for s in SEEDS: _, net, ds, dev = train_one('idea', s, 3e-3, 1.0, True) x = ds['xte'].to(dev); seq=x.view(-1,8,3); state=seq[:,-1,:2] th,om,u=state[:,0],state[:,1],seq[:,-1,2] with torch.no_grad(): _, b, _ = net(x, True); b=b.squeeze(1) om1=om+.05*(-.981*torch.sin(th)-.2*om+2*u)+.04*torch.randn_like(om) th1=th+.05*om1; bn=net.barrier(torch.stack([th1,om1],1)).squeeze(1) _,_,_,c=safety_masks(state) pred.append(float((bn[c]-b[c]).mean())) # Re-test on a second disturbance draw, not used by the loss. om2=om+.05*(-.981*torch.sin(th)-.2*om+2*u)+.04*torch.randn_like(om) th2=th+.05*om2; bh=net.barrier(torch.stack([th2,om2],1)).squeeze(1) held.append(float((bh[c]-b[c]).mean())) p,o=float(np.mean(pred)),float(np.mean(held)) return {'prediction':'training enforces nonpositive expected barrier drift on continuation states; held-out drift should be lower than zero', 'predicted_mean_drift':p, 'heldout_mean_drift':o, 'margin':DELTA, 'confirmed': bool(o <= 0.0)} def main(): print(json.dumps({'math_sanity': math_sanity()})) # Baseline central knob is optimizer lr; weight decay is also swept. base_grid=[{'lr':lr,'weight_decay':wd} for lr in LRS for wd in (0.0,1e-4)] base=sweep_baseline(lambda c: fn('baseline',c), base_grid, seeds=SWEEP_SEEDS) idea_cfgs=[{'lr':1e-3,'lam':.5},{'lr':3e-3,'lam':1.0},{'lr':1e-2,'lam':2.0}] runs=[(evaluate(fn('idea',c), SEEDS),c) for c in idea_cfgs] idea, best=min(runs,key=lambda z:z[0]['mean']) rep=make_report('dynamics','rnn_small',base,idea,extra=mechanism_signature()) rep['idea_sweep']=[{'cfg':c,'result':r} for r,c in runs] rep['math_sanity']=math_sanity() rep['protocol_note']='Matched dynamics task and shared GRU predictor/barrier-head architecture; only auxiliary uniform stochastic barrier loss differs. All idea learning rates are included in baseline sweep.' Path('bench_report.json').write_text(json.dumps(rep,indent=2)) print(json.dumps(rep,indent=2)) if __name__=='__main__': main()