import sys, json, time from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F sys.path.insert(0, '/home/maxwelhelp/all/math2nn') from bench import get_dataset, make_model, train_model, evaluate, sweep_baseline, make_report, count_params SEED=2904 EPOCHS=2 BATCH=128 # Union is used identically by both sides; baseline also sweeps method knob weight_decay. GRID=[{'lr':1e-3,'weight_decay':0.0},{'lr':3e-3,'weight_decay':0.0},{'lr':5e-3,'weight_decay':0.0}] class PHGRU(nn.Module): """Port-Hamiltonian recurrent cell; sequence interface matches rnn_small.""" def __init__(self, hidden=4, eps=.03): super().__init__(); self.hidden=hidden; self.eps=eps self.inp=nn.Linear(3,hidden) self.energy_net=nn.Sequential(nn.Linear(hidden,64),nn.Tanh(),nn.Linear(64,1)) self.a_net=nn.Sequential(nn.Linear(hidden,64),nn.Tanh(),nn.Linear(64,hidden*hidden)) self.l_net=nn.Sequential(nn.Linear(hidden,64),nn.Tanh(),nn.Linear(64,hidden*hidden)) self.readout=nn.Linear(hidden,1) self.qraw=nn.Parameter(torch.eye(hidden)*.15) def energy(self,z): q=self.qraw@self.qraw.T + .01*torch.eye(self.hidden,device=z.device) return F.softplus(self.energy_net(z).squeeze(-1)) + .5*((z@q)*z).sum(-1) def vector_field(self,z): # create_graph is needed during training because the learned energy gradient is differentiated with torch.enable_grad(): zz=z.detach().requires_grad_(True); h=self.energy(zz) g=torch.autograd.grad(h.sum(),zz,create_graph=self.training)[0] b=z.shape[0]; A=self.a_net(zz).view(b,self.hidden,self.hidden) L=self.l_net(zz).view(b,self.hidden,self.hidden) J=A-A.transpose(1,2); R=L@L.transpose(1,2)+self.eps*torch.eye(self.hidden,device=z.device) dz=torch.bmm((J-R),g.unsqueeze(-1)).squeeze(-1) return dz, g, J, R def forward(self,x, signature=False): seq=x.view(x.shape[0],-1,3); z=torch.tanh(self.inp(seq[:,0])); for k in range(1,seq.shape[1]): u=seq[:,k] dz,_,_,_=self.vector_field(z) # input port: learned fixed B represented by input projection, preserving causal dynamics z=z+0.12*dz+torch.tanh(self.inp(u))*0.05 z=torch.tanh(z) out=self.readout(z) if signature: return out,z return out def seed_all(s): np.random.seed(s); torch.manual_seed(s) def train_one(kind,cfg,seed,return_model=False): seed_all(seed); ds=get_dataset('dynamics',seed,n_train=100,n_test=60) model=make_model('rnn_small',ds['input_shape'],ds['out_dim']) if kind=='base' else PHGRU(hidden=4) model,metric,hist=train_model(model,ds,epochs=EPOCHS,lr=cfg['lr'],batch=BATCH,weight_decay=cfg['weight_decay'],log=lambda *_:None) if model is None: return (float('nan'), None, ds) if return_model else float('nan') if return_model: return metric,model,ds return metric def main(): # Cheap exact structural and energy identity check before training. seed_all(SEED); m=PHGRU(hidden=6).eval(); z=torch.randn(32,6,requires_grad=True) dz,g,J,R=m.vector_field(z); lhs=(g*dz).sum(1); rhs=-(g.unsqueeze(1)@R@g.unsqueeze(-1)).squeeze(); mathcheck={'max_skew':float((J+J.transpose(1,2)).abs().max()),'min_R_eig':float(torch.linalg.eigvalsh(R).amin()),'max_identity_residual':float((lhs-rhs).abs().max()),'max_dHdt':float(lhs.max())} # Both systems see the exact same grid and paired seeds. Baseline sweep uses 4 seeds then full 8. base=sweep_baseline(lambda cfg: (lambda s: train_one('base',cfg,s)),GRID) full_grid=GRID idea_cfg=min(GRID,key=lambda c: base['sweep'][GRID.index(c)]['mean']) # evaluate idea at all three union configs; report best, while baseline was evaluated at every config. idea_runs=[] for cfg in full_grid: r=evaluate(lambda s,cfg=cfg: train_one('idea',cfg,s)) idea_runs.append({'cfg':cfg,'result':r}) idea_best=min(idea_runs,key=lambda q:q['result']['mean']) # Signature on trained models, measured behavior: observed dissipation identity and drift on NN states. sig=[] for s in range(8): got=train_one('idea',idea_best['cfg'],s,True) metric,model,ds=got if model is None: continue model.eval(); x=ds['xte'][:64].to(next(model.parameters()).device) with torch.enable_grad(): z=torch.tanh(model.inp(x.view(x.shape[0],-1,3)[:,0])); dz,g,J,R=model.vector_field(z) obs=(g*dz).sum(1); theo=-(g.unsqueeze(1)@R@g.unsqueeze(-1)).squeeze() sig.append((float((J+J.transpose(1,2)).abs().max()),float(torch.linalg.eigvalsh(R).amin()),float((obs-theo).abs().max()),float(obs.max()))) signature={'trained_model_samples':len(sig),'max_skew_residual':max(x[0] for x in sig),'min_R_eigenvalue':min(x[1] for x in sig),'max_energy_identity_residual':max(x[2] for x in sig),'max_observed_dHdt':max(x[3] for x in sig),'predicted':{'skew_zero':True,'R_eigenvalue_ge_epsilon':True,'dHdt_le_zero':True},'confirmed':max(x[0] for x in sig)<1e-5 and min(x[1] for x in sig)>=.029 and max(x[2] for x in sig)<1e-4 and max(x[3] for x in sig)<=1e-5} rep=make_report('dynamics','rnn_small',base,idea_best['result'],{'track_match':'stability/control -> dynamics','math_sanity':mathcheck,'mechanism_signature':signature,'idea_sweep':idea_runs,'selected_cfg':idea_best['cfg'],'parameter_counts':{'baseline':count_params(make_model('rnn_small',(8,3),1)),'idea':count_params(PHGRU(hidden=4))}}) Path('bench_report.json').write_text(json.dumps(rep,indent=2)) print(json.dumps(rep,indent=2)) if __name__=='__main__': main()