Conservative Parallel-Edge Decomposition / conservative_edge_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2from pathlib import Path
3import numpy as np
4
5SEED = 2870
6rng = np.random.default_rng(SEED)
7
8
9def make_incidence(n=7, edges=None):
10 if edges is None:
11 edges = [(0,1),(1,2),(2,3),(3,4),(4,5),(5,6),(0,6),(1,5),(2,6)]
12 B = np.zeros((n, len(edges)), dtype=np.float64)
13 for j, (tail, head) in enumerate(edges):
14 B[tail, j] = -1.0
15 B[head, j] = 1.0
16 return B, edges
17
18
19def core_sweeps():
20 B, edges = make_incidence()
21 n, m = B.shape
22 ones = np.ones(n)
23 # Prediction 1: arbitrary parallel channels remain exactly conservative.
24 channel_rows = []
25 for k in [1, 2, 4, 8, 16, 32]:
26 Pk = rng.normal(size=(m, k))
27 P = Pk.sum(axis=1)
28 residual = float(abs(ones @ (B @ P)))
29 channel_rows.append({"K": k, "abs_total_internal_residual": residual})
30
31 # Prediction 2: if an unconstrained construction adds leakage with amplitude
32 # lambda, the mass residual is linear in lambda. Here r is fixed, so this is
33 # a clean quantitative slope test rather than a noisy training result.
34 P = rng.normal(size=m)
35 r = rng.normal(size=n)
36 lambdas = [0.0, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0]
37 leakage_rows = []
38 for lam in lambdas:
39 q_unconstrained = B @ P + lam * r
40 observed = float(abs(ones @ q_unconstrained))
41 predicted = float(lam * abs(ones @ r))
42 leakage_rows.append({"lambda": lam, "observed_abs_residual": observed,
43 "predicted_abs_residual": predicted})
44 nonzero = [(x["lambda"], x["observed_abs_residual"]) for x in leakage_rows if x["lambda"] > 0]
45 slope = float(sum(a*b for a,b in nonzero) / sum(a*a for a,b in nonzero))
46 expected_slope = float(abs(ones @ r))
47
48 # Prediction 3: with internal-only dynamics, conservative Euler updates
49 # preserve total mass for every rollout length; constant leakage accumulates
50 # linearly in the number of steps.
51 dt, eps = 0.05, 0.02
52 x0 = rng.normal(size=n)
53 flow = rng.normal(size=m)
54 q_internal = B @ flow
55 steps_list = [1, 2, 5, 10, 20, 40, 80]
56 drift_rows = []
57 for steps in steps_list:
58 xc = x0.copy()
59 xu = x0.copy()
60 for _ in range(steps):
61 xc += dt * q_internal
62 xu += dt * (q_internal + eps * np.ones(n))
63 cdrift = float(abs(ones @ (xc - x0)))
64 udrift = float(abs(ones @ (xu - x0)))
65 predicted_u = float(steps * dt * eps * n)
66 drift_rows.append({"steps": steps, "conservative_abs_mass_drift": cdrift,
67 "unconstrained_abs_mass_drift": udrift,
68 "predicted_unconstrained_drift": predicted_u})
69 return {
70 "incidence_shape": [n, m],
71 "incidence_column_sum_max_abs": float(np.max(np.abs(ones @ B))),
72 "channel_sweep": channel_rows,
73 "leakage_sweep": leakage_rows,
74 "leakage_fitted_slope": slope,
75 "leakage_predicted_slope": expected_slope,
76 "rollout_sweep": drift_rows,
77 }
78
79
80def toy_model_comparison():
81 # Small supervised graph-flow task: true interaction is a sum of two
82 # domain-specific edge channels. The conservative model has two scalar
83 # channel regressors and aggregates only through B. The baseline predicts
84 # both endpoint updates independently from the composite edge features.
85 try:
86 import torch
87 torch.manual_seed(SEED)
88 np.random.seed(SEED)
89 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
90 B_np, edges = make_incidence(n=6, edges=[(0,1),(1,2),(2,3),(3,4),(4,5),(0,5),(1,4)])
91 B = torch.tensor(B_np, dtype=torch.float32, device=device)
92 n, m = B_np.shape
93 N = 1200
94 x = torch.randn(N, n, device=device)
95 u = torch.randn(N, m, 2, device=device)
96 tail = torch.tensor([e[0] for e in edges], device=device)
97 head = torch.tensor([e[1] for e in edges], device=device)
98 d = x[:, tail] - x[:, head]
99 target_p = 0.8*torch.tanh(d) + 0.25*u[:,:,0] + 0.35*torch.sin(d*u[:,:,1])
100 target_q = torch.einsum('nm,bm->bn', B, target_p)
101 train = slice(0, 900); test = slice(900, 1200)
102
103 class Conservative(torch.nn.Module):
104 def __init__(self):
105 super().__init__()
106 self.c0 = torch.nn.Sequential(torch.nn.Linear(3, 16), torch.nn.Tanh(), torch.nn.Linear(16, 1))
107 self.c1 = torch.nn.Sequential(torch.nn.Linear(3, 16), torch.nn.Tanh(), torch.nn.Linear(16, 1))
108 def forward(self, xx, uu):
109 dd = xx[:,tail] - xx[:,head]
110 z = torch.stack((dd, uu[:,:,0], uu[:,:,1]), dim=-1)
111 p = self.c0(z).squeeze(-1) + self.c1(z).squeeze(-1)
112 return torch.einsum('nm,bm->bn', B, p)
113
114 class Unconstrained(torch.nn.Module):
115 def __init__(self):
116 super().__init__()
117 self.edge = torch.nn.Sequential(torch.nn.Linear(4, 24), torch.nn.Tanh(), torch.nn.Linear(24, 2))
118 def forward(self, xx, uu):
119 dd = xx[:,tail] - xx[:,head]
120 z = torch.stack((xx[:,tail], xx[:,head], uu[:,:,0], uu[:,:,1]), dim=-1)
121 msg = self.edge(z)
122 out = torch.zeros((xx.shape[0], n), device=xx.device)
123 out.index_add_(1, tail, msg[:,:,0])
124 out.index_add_(1, head, msg[:,:,1])
125 return out
126
127 models = {"conservative": Conservative().to(device), "unconstrained": Unconstrained().to(device)}
128 results = {}
129 for name, model in models.items():
130 opt = torch.optim.Adam(model.parameters(), lr=3e-3)
131 for step in range(500):
132 opt.zero_grad()
133 pred = model(x[train], u[train])
134 loss = ((pred-target_q[train])**2).mean()
135 loss.backward(); opt.step()
136 with torch.no_grad():
137 pred = model(x[test], u[test])
138 err = ((pred-target_q[test])**2).mean().sqrt().item()
139 residual = (pred.sum(dim=1)).abs().mean().item()
140 # internal conservation is exact for conservative architecture;
141 # baseline residual is measured independently of task error.
142 results[name] = {"test_q_rmse": err, "mean_abs_mass_residual": residual,
143 "parameters": sum(p.numel() for p in model.parameters())}
144 return {"device": str(device), "results": results}
145 except Exception as exc:
146 return {"device": "cpu-fallback", "error": repr(exc)}
147
148
149def main():
150 out = {"seed": SEED, "core_math": core_sweeps(), "toy_model": toy_model_comparison()}
151 Path("results.json").write_text(json.dumps(out, indent=2))
152 print(json.dumps(out, indent=2))
153
154if __name__ == "__main__":
155 main()