Causal E/I Micro-Event Cell / causal_ei_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2import math
  3from dataclasses import dataclass
  4from pathlib import Path
  5import numpy as np
  6
  7
  8@dataclass
  9class EventCell:
 10    theta: float = 1.0
 11    reset: float = 0.0
 12    leak: float = 1.0
 13
 14    def run(self, x, events):
 15        """Process (normalized_time, signed_mass) events in stable time order."""
 16        u = self.leak * float(x)
 17        spikes = []
 18        # Python's stable sort preserves input order for tied timestamps.
 19        for tau, delta, *rest in sorted(events, key=lambda e: e[0]):
 20            u += float(delta)
 21            fired = int(u >= self.theta)
 22            spikes.append(fired)
 23            if fired:
 24                u = self.reset
 25        return int(any(spikes)), u, spikes
 26
 27    def aggregate(self, x, events):
 28        """Baseline: one signed current and one threshold/reset per macro-step."""
 29        u = self.leak * float(x) + sum(float(e[1]) for e in events)
 30        fired = int(u >= self.theta)
 31        if fired:
 32            u = self.reset
 33        return fired, u
 34
 35
 36def order_condition(x, a, b, theta=1.0):
 37    return (x + a - b < theta) and (x + a >= theta)
 38
 39
 40def analytic_fraction(a, b, theta=1.0, x_low=0.0, x_high=1.0):
 41    # x must lie in [theta-a, theta-a+b), intersected with the sampling interval.
 42    lo = max(x_low, theta - a)
 43    hi = min(x_high, theta - a + b)
 44    return max(0.0, hi - lo) / (x_high - x_low)
 45
 46
 47def toy_verification(rng):
 48    theta = 1.0
 49    n = 500_000
 50    x = rng.uniform(0.0, theta, n)
 51
 52    # Prediction 1: unsafe/order-sensitive fraction is the interval length.
 53    a = 0.65
 54    b_grid = np.array([0.05, 0.15, 0.30, 0.50, 0.80])
 55    p_obs, p_pred = [], []
 56    for b in b_grid:
 57        p_obs.append(np.mean((x + a - b < theta) & (x + a >= theta)))
 58        p_pred.append(analytic_fraction(a, b, theta))
 59
 60    # Prediction 2: for fixed b, the fraction grows linearly with a until
 61    # clipping at the available x interval.
 62    b = 0.30
 63    a_grid = np.array([0.10, 0.30, 0.50, 0.70, 0.90, 1.10])
 64    pa_obs, pa_pred = [], []
 65    for aa in a_grid:
 66        pa_obs.append(np.mean((x + aa - b < theta) & (x + aa >= theta)))
 67        pa_pred.append(analytic_fraction(aa, b, theta))
 68
 69    # Prediction 3: conditioned on the strict margin interval, random E/I
 70    # ordering gives a 1/2 expected firing discrepancy.
 71    m = 0.10
 72    x0 = theta - a + b / 2.0  # strictly inside condition for all listed b>=.15
 73    b0 = 0.50
 74    # Explicitly use many random orderings of two events.
 75    trials = 200_000
 76    fires = []
 77    cell = EventCell(theta=theta)
 78    for _ in range(trials):
 79        if rng.random() < 0.5:
 80            ev = [(0.1, a), (0.9, -b0)]
 81        else:
 82            ev = [(0.1, -b0), (0.9, a)]
 83        fires.append(cell.run(x0, ev)[0])
 84    random_order_firing = float(np.mean(fires))
 85    # E-first is one, I-first is zero; baseline is always zero here.
 86    discrepancy_from_half = abs(random_order_firing - 0.5)
 87
 88    return {
 89        "prediction_1_interval_fraction": {
 90            "parameter": "b", "a": a,
 91            "rows": [{"b": float(bb), "predicted": float(pp), "observed": float(po),
 92                      "abs_error": float(abs(pp-po))}
 93                     for bb, pp, po in zip(b_grid, p_pred, p_obs)]
 94        },
 95        "prediction_2_interval_scaling": {
 96            "parameter": "a", "b": b,
 97            "rows": [{"a": float(aa), "predicted": float(pp), "observed": float(po),
 98                      "abs_error": float(abs(pp-po))}
 99                     for aa, pp, po in zip(a_grid, pa_pred, pa_obs)]
100        },
101        "prediction_3_random_order_half": {
102            "predicted": 0.5, "observed": random_order_firing,
103            "abs_error": discrepancy_from_half, "x": x0, "a": a, "b": b0,
104            "margin": min(theta-(x0+a-b0), x0+a-theta)
105        }
106    }
107
108
109def mini_experiment(rng):
110    # Each example has one excitatory and one inhibitory arrival. The reference
111    # is causal processing; the baseline collapses both into one signed pulse.
112    cell = EventCell(theta=1.0, reset=0.0)
113    n = 100_000
114    x = rng.uniform(0.0, 1.0, n)
115    a = rng.uniform(0.05, 0.95, n)
116    b = rng.uniform(0.05, 0.95, n)
117    # Generate both causal orderings, equally often.
118    order = rng.integers(0, 2, n)
119    micro = np.empty(n, dtype=np.int8)
120    base = np.empty(n, dtype=np.int8)
121    unsafe = np.zeros(n, dtype=bool)
122    margins = np.full(n, np.nan)
123    for i in range(n):
124        if order[i] == 0:  # E first
125            events = [(0.1, a[i]), (0.9, -b[i])]
126        else:
127            events = [(0.1, -b[i]), (0.9, a[i])]
128        micro[i] = cell.run(x[i], events)[0]
129        base[i] = cell.aggregate(x[i], events)[0]
130        unsafe[i] = order_condition(x[i], a[i], b[i])
131        if unsafe[i]:
132            margins[i] = min(1.0-(x[i]+a[i]-b[i]), x[i]+a[i]-1.0)
133
134    # In this setup aggregate is exactly the net-current model; compare it to
135    # the causal reference and expose the predicted 1/2 split on unsafe cases.
136    disagreement = float(np.mean(micro != base))
137    unsafe_rate = float(np.mean(unsafe))
138    unsafe_disagreement = float(np.mean((micro != base)[unsafe])) if unsafe.any() else 0.0
139    micro_rate = float(np.mean(micro))
140    base_rate = float(np.mean(base))
141    return {
142        "n": n,
143        "unsafe_fraction": unsafe_rate,
144        "mean_margin_on_unsafe": float(np.nanmean(margins)),
145        "baseline_spike_rate": base_rate,
146        "micro_event_spike_rate": micro_rate,
147        "overall_disagreement": disagreement,
148        "disagreement_conditioned_on_unsafe": unsafe_disagreement,
149        "predicted_conditioned_disagreement": 0.5,
150        "order_balance": float(np.mean(order == 0))
151    }
152
153
154def main():
155    rng = np.random.default_rng(2411)
156    toy = toy_verification(rng)
157    mini = mini_experiment(rng)
158    result = {"seed": 2411, "toy_verification": toy, "mini_experiment": mini}
159    Path("results.json").write_text(json.dumps(result, indent=2))
160    print(json.dumps(result, indent=2))
161
162
163if __name__ == "__main__":
164    main()