Directed-Path Synchronization Coupling / directed_sync_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1"""Directed-path synchronization coupling: math checks and a tiny experiment.
  2
  3Convention: C has C[i,j]=a_ij for j != i and C[i,i]=-sum_j a_ij,
  4so xdot = f(x) - eta*C_pos?  We instead construct the positive
  5row Laplacian B with B[i,i]=sum a and B[i,j]=-a, and use
  6xdot = f(x) - eta B x.  Every non-root node receives its parent.
  7"""
  8import json
  9import numpy as np
 10
 11SEED = 2470
 12rng = np.random.default_rng(SEED)
 13
 14
 15def directed_star(n, weight=1.0):
 16    # n-1 directed parent paths (root 0 -> each child), represented as
 17    # receiver rows: child receives the root state.
 18    B = np.zeros((n, n))
 19    for i in range(1, n):
 20        B[i, i] = weight
 21        B[i, 0] = -weight
 22    return B
 23
 24
 25def dense_symmetric(n, weight=1.0):
 26    # Complete balanced diffusive graph, same row-sum normalization.
 27    B = np.full((n, n), -weight / (n - 1))
 28    np.fill_diagonal(B, weight)
 29    return B
 30
 31
 32def chain(n, weights):
 33    # Imbalanced directed path: each node receives only its predecessor.
 34    B = np.zeros((n, n))
 35    for i, w in enumerate(weights, start=1):
 36        B[i, i] = w
 37        B[i, i - 1] = -w
 38    return B
 39
 40
 41def graph_gain(B, tol=1e-9):
 42    """Conservative spectral gain: smallest positive real Laplacian mode."""
 43    ev = np.linalg.eigvals(B)
 44    positive = [z.real for z in ev if z.real > tol and abs(z.imag) < 1e-7]
 45    if not positive:
 46        # For general directed matrices, use the minimum positive real part.
 47        positive = [z.real for z in ev if z.real > tol]
 48    return float(min(positive))
 49
 50
 51def simulate(B, lam, eta, dt=0.002, T=5.0, x0=None):
 52    n = B.shape[0]
 53    steps = int(T / dt)
 54    x = np.array(x0 if x0 is not None else rng.normal(size=n), dtype=float)
 55    # For directed graphs use the left-null weighted consensus coordinate.
 56    left = np.linalg.svd(B.T)[2][-1]
 57    left = left / np.sum(left)
 58    V = []
 59    for _ in range(steps + 1):
 60        mean = np.sum(left * x)
 61        V.append(0.5 * np.sum((x - mean) ** 2))
 62        x = x + dt * (lam * x - eta * (B @ x))
 63    return np.asarray(V)
 64
 65
 66def slope(V, dt, tail_fraction=0.6):
 67    k = np.arange(len(V))
 68    first = int(len(V) * (1-tail_fraction))
 69    y = np.log(np.maximum(V, 1e-300))
 70    return float(np.polyfit(k[first:] * dt, y[first:], 1)[0])
 71
 72
 73def threshold_sweep(B, lam, gamma, etas):
 74    # Stable means final V is below its initial V, with a substantial margin.
 75    x0 = np.linspace(-1.0, 1.0, B.shape[0])
 76    rows = []
 77    for eta in etas:
 78        V = simulate(B, lam, eta, x0=x0)
 79        rows.append({"eta": float(eta), "final_over_initial": float(V[-1]/V[0]),
 80                     "slope": slope(V, .002)})
 81    return rows
 82
 83
 84def main():
 85    n = 8
 86    lam = 0.8                 # exact Lipschitz constant of f(x)=lam*x
 87    star = directed_star(n)
 88    dense = dense_symmetric(n)
 89    imbalanced = chain(n, [0.35, 0.55, 0.75, 0.95, 1.15, 1.35, 1.55])
 90    graphs = {"directed_n_minus_1_star": star, "dense_symmetric": dense,
 91              "severely_imbalanced_path": imbalanced}
 92    gains = {name: graph_gain(B) for name, B in graphs.items()}
 93    # Prediction 1: boundary eta_c = L/gamma.
 94    boundary = {name: lam/g for name, g in gains.items()}
 95    etas = np.array([0.3, 0.6, 0.79, 0.81, 1.0, 1.4, 2.0])
 96    sweeps = {name: threshold_sweep(B, lam, gains[name], etas)
 97              for name, B in graphs.items()}
 98
 99    # Prediction 2: rate d log(V)/dt approximately -2(eta*gamma-L).
100    rate_rows = []
101    for eta in [1.0, 1.4, 2.0]:
102        V = simulate(star, lam, eta, x0=np.linspace(-1, 1, n))
103        observed = slope(V, .002)
104        predicted = -2*(eta*gains["directed_n_minus_1_star"] - lam)
105        rate_rows.append({"eta": eta, "predicted": predicted, "observed": observed,
106                          "relative_error": abs(observed-predicted)/max(abs(predicted),1e-9)})
107
108    # Prediction 3: threshold scales linearly with L (gamma fixed).
109    scaling = []
110    for L in [0.2, 0.5, 0.8, 1.2]:
111        g = gains["directed_n_minus_1_star"]
112        candidate = L/g
113        # Estimate the threshold from the first negative fitted log-V slope.
114        candidates = np.linspace(max(.02, candidate*.5), candidate*1.5, 61)
115        found = None
116        for eta in candidates:
117            V = simulate(star, L, eta, x0=np.linspace(-1, 1, n))
118            if slope(V, .002) < 0:
119                found = float(eta); break
120        scaling.append({"L": L, "predicted_eta_c": candidate,
121                        "observed_negative_slope_eta": found,
122                        "relative_boundary_error": (abs(found-candidate)/candidate
123                            if found is not None else None)})
124
125    # Standard baselines at the same lambda and eta.
126    x0 = np.linspace(-1, 1, n)
127    baseline = simulate(np.zeros((n,n)), lam, 1.0, x0=x0)
128    idea = simulate(star, lam, 1.0, x0=x0)
129    dense_run = simulate(dense, lam, 1.0, x0=x0)
130    out = {"seed": SEED, "L": lam, "graph_gains": gains,
131           "predicted_thresholds": boundary, "threshold_sweep": sweeps,
132           "rate_test": rate_rows, "L_scaling": scaling,
133           "comparison_eta_1": {
134               "uncoupled_final_over_initial": float(baseline[-1]/baseline[0]),
135               "directed_path_final_over_initial": float(idea[-1]/idea[0]),
136               "dense_symmetric_final_over_initial": float(dense_run[-1]/dense_run[0]),
137               "uncoupled_slope": slope(baseline,.002),
138               "directed_path_slope": slope(idea,.002),
139               "dense_symmetric_slope": slope(dense_run,.002)}}
140    print(json.dumps(out, indent=2))
141
142if __name__ == "__main__":
143    main()