Harmonic-Mode Branch for Topological Memory / harmonic_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2from pathlib import Path
  3import numpy as np
  4
  5# Harmonic-mode branch: minimal numerical verification of the stated decomposition.
  6# The synthetic PSD operator is equivalent to a Hodge Laplacian in an orthonormal basis:
  7# L = Q diag(0,...,0, positive eigenvalues) Q^T.
  8
  9def make_operator(seed=7, dim=12, harmonic_dim=2):
 10    rng = np.random.default_rng(seed)
 11    q, _ = np.linalg.qr(rng.normal(size=(dim, dim)))
 12    positive = np.array([0.35, 0.8, 1.7, 2.6, 3.9, 5.2, 6.4, 7.5, 9.0, 11.0])[: dim-harmonic_dim]
 13    eig = np.concatenate([np.zeros(harmonic_dim), positive])
 14    L = q @ np.diag(eig) @ q.T
 15    H = q[:, :harmonic_dim]
 16    P_h = H @ H.T
 17    P_p = np.eye(dim) - P_h
 18    return L, H, P_h, P_p, eig, q
 19
 20
 21def fit_log_slope(values, dt):
 22    # Ignore the t=0 point and fit where values are safely above roundoff.
 23    vals = np.asarray(values)
 24    t = np.arange(len(vals)) * dt
 25    keep = (vals > 1e-11) & (np.arange(len(vals)) > 2)
 26    return float(np.polyfit(t[keep], np.log(vals[keep]), 1)[0])
 27
 28
 29def run():
 30    L, H, P_h, P_p, eig, Q = make_operator()
 31    dim, k = L.shape[0], H.shape[1]
 32    lam_pos = float(np.min(eig[eig > 0]))
 33    lam_max = float(np.max(eig))
 34    rng = np.random.default_rng(123)
 35    h0 = H @ np.array([1.25, -0.7])
 36    # Exact slowest dissipative mode makes the continuous-rate prediction sharp.
 37    u0 = Q[:, k] * 1.0
 38    z0 = h0 + u0
 39
 40    # Prediction 1: projected branch has constant harmonic coordinates, while a
 41    # fully damped baseline incorrectly erases them.
 42    nu = 0.9
 43    dt = 0.01
 44    steps = 900
 45    z_split = z0.copy()
 46    z_full = z0.copy()
 47    harmonic_errors = []
 48    for _ in range(steps):
 49        # N is identically zero here; projected nonlinear N would preserve the same invariant.
 50        z_split = z_split + dt * (-nu * (L @ (P_p @ z_split)))
 51        z_split = P_h @ z_split + P_p @ z_split  # explicit numerical projection
 52        z_full = z_full + dt * (-nu * (L @ z_full) - nu * (P_h @ z_full))
 53        harmonic_errors.append(np.linalg.norm(P_h @ (z_split - z0)))
 54    invariant_error = float(max(harmonic_errors))
 55    baseline_memory_retention = float(np.linalg.norm(P_h @ z_full) / np.linalg.norm(h0))
 56    split_memory_retention = float(np.linalg.norm(P_h @ z_split) / np.linalg.norm(h0))
 57
 58    # Prediction 2: dissipative amplitude has log slope -nu*lambda_1^+.
 59    nus = [0.25, 0.5, 0.9, 1.4, 2.0]
 60    slope_rows = []
 61    for n in nus:
 62        x = u0.copy()
 63        amps = []
 64        for _ in range(500):
 65            amps.append(np.linalg.norm(x))
 66            x = x + dt * (-n * (L @ x))
 67        observed = fit_log_slope(amps, dt)
 68        predicted = -n * lam_pos
 69        slope_rows.append({"nu": n, "predicted": predicted, "observed": observed,
 70                           "abs_error": abs(observed - predicted)})
 71
 72    # Prediction 3: explicit Euler is stable iff dt*nu*lambda_max < 2 for PSD L.
 73    n_stab = 1.0
 74    predicted_dt = 2.0 / (n_stab * lam_max)
 75    factors = [0.90, 0.99, 1.01, 1.10]
 76    stability_rows = []
 77    x_init = Q[:, -1]
 78    for f in factors:
 79        d = f * predicted_dt
 80        x = x_init.copy()
 81        norms = []
 82        for _ in range(80):
 83            norms.append(float(np.linalg.norm(x)))
 84            x = x - d * n_stab * (L @ x)
 85        # Stable side remains bounded and decays; unstable side grows geometrically.
 86        observed_stable = max(norms) < 10.0 and norms[-1] <= norms[0]
 87        stability_rows.append({"factor_of_boundary": f, "dt": d,
 88                               "predicted": "stable" if f < 1 else "unstable",
 89                               "observed": "stable" if observed_stable else "unstable",
 90                               "final_norm": norms[-1]})
 91
 92    # Small trajectory comparison: standard/full damping vs topology-preserving split.
 93    # Both use exactly the same operator, timestep, and initialization.
 94    comparison = {
 95        "split_harmonic_retention": split_memory_retention,
 96        "fully_damped_harmonic_retention": baseline_memory_retention,
 97        "split_dissipative_final_norm": float(np.linalg.norm(P_p @ z_split)),
 98        "fully_damped_final_norm": float(np.linalg.norm(z_full)),
 99    }
100    result = {
101        "operator_dim": dim, "harmonic_dim": k, "lambda_1_positive": lam_pos,
102        "lambda_max": lam_max, "predicted_euler_boundary_dt": predicted_dt,
103        "prediction_1_invariant_max_error": invariant_error,
104        "prediction_1_tolerance": 1e-12,
105        "prediction_2_decay_sweep": slope_rows,
106        "prediction_3_stability_sweep": stability_rows,
107        "comparison": comparison,
108        "claims": {
109            "harmonic_invariant": invariant_error < 1e-12 and split_memory_retention > 0.999999,
110            "decay_scaling": max(r["abs_error"] for r in slope_rows) < 0.01,
111            "euler_boundary": all(r["observed"] == r["predicted"] for r in stability_rows),
112        }
113    }
114    Path("results.json").write_text(json.dumps(result, indent=2))
115    print(json.dumps(result, indent=2))
116
117if __name__ == "__main__":
118    run()