Hysteretic Multiscale Sequence Router / verify_router.py

Failed on benchmark

Raw ⬇ ZIP
  1import json
  2from pathlib import Path
  3import numpy as np
  4
  5SEED = 1111
  6rng = np.random.default_rng(SEED)
  7
  8class HystereticRouter:
  9    def __init__(self, eta_on=0.6, eta_off=0.4, active=0):
 10        self.on, self.off, self.active = eta_on, eta_off, active
 11        self.switches = []
 12    def step(self, h, t):
 13        # A switch is permitted only after the current attractor has exited.
 14        if h[self.active] <= self.off:
 15            candidates = [i for i in range(len(h)) if i != self.active and h[i] >= self.on]
 16            if candidates:
 17                old = self.active
 18                self.active = int(candidates[np.argmax(h[candidates])])
 19                self.switches.append((t, old, self.active))
 20        return self.active
 21
 22def dwell_sweep():
 23    # Scores are deliberately generated with a known bounded derivative.
 24    # Candidate rises from off to on, then the old score falls; traversal takes Delta/L.
 25    L = 0.017
 26    rows = []
 27    for delta in [0.04, 0.08, 0.14, 0.22]:
 28        off, on = 0.40, 0.40 + delta
 29        dt = 0.01
 30        n = 30000
 31        t = np.arange(n) * dt
 32        # candidate 1 rises while active score 0 falls, each slope <= L
 33        h0 = np.clip(0.75 - L*t, 0, 1)
 34        h1 = np.clip(0.20 + L*t, 0, 1)
 35        router = HystereticRouter(on, off)
 36        for k in range(n): router.step(np.array([h0[k], h1[k]]), t[k])
 37        assert router.switches
 38        first = router.switches[0][0]
 39        # Now explicitly measure the candidate's time from off to on.
 40        expected = delta / L
 41        observed = expected # continuous ramp identity, independently verified below
 42        # finite-difference derivative and actual threshold crossing times
 43        rise_on = np.where(h1 >= on)[0][0] * dt
 44        rise_off = np.where(h1 >= off)[0][0] * dt
 45        observed = rise_on - rise_off
 46        max_deriv = np.max(np.abs(np.diff(h1) / dt))
 47        rows.append({'delta': delta, 'predicted_dwell': expected,
 48                     'observed_dwell': observed, 'bound_ratio': observed / expected,
 49                     'max_fd_derivative': float(max_deriv), 'switch_time': float(first)})
 50    return rows
 51
 52def chatter_sweep():
 53    # A sinusoidal score of amplitude A around the threshold. Without hysteresis,
 54    # crossings occur repeatedly. If Delta > 2A, the score cannot traverse both thresholds.
 55    dt, duration, A, period = 0.01, 40.0, 0.08, 2.0
 56    t = np.arange(0, duration, dt)
 57    s = 0.5 + A*np.sin(2*np.pi*t/period)
 58    rows = []
 59    for delta in [0.00, 0.04, 0.10, 0.16, 0.20]:
 60        off, on = 0.5-delta/2, 0.5+delta/2
 61        # use complementary scores, which makes each threshold crossing meaningful
 62        r = HystereticRouter(on, off)
 63        for k, v in enumerate(s): r.step(np.array([1-v, v]), t[k])
 64        observed = len(r.switches)
 65        predicted_zero = delta > 2*A
 66        rows.append({'delta': delta, 'noise_amplitude': A,
 67                     'predicted_zero_switch': predicted_zero,
 68                     'switches': observed})
 69    return rows
 70
 71def tau_sweep():
 72    # First-order h update h'=(s-h)/tau. For sinusoidal score, larger tau attenuates
 73    # fluctuations; measured RMS gain should follow 1/sqrt(1+(omega*tau)^2).
 74    dt, duration, A, period = 0.001, 20.0, 0.1, 1.0
 75    t = np.arange(0, duration, dt)
 76    omega = 2*np.pi/period
 77    s = 0.5 + A*np.sin(omega*t)
 78    rows=[]
 79    for tau in [0.005, 0.02, 0.1, 0.5]:
 80        h=0.5; vals=[]
 81        for v in s:
 82            h += dt/tau*(v-h)
 83            vals.append(h)
 84        vals=np.asarray(vals); trim=t > 5
 85        gain=(np.std(vals[trim])/A)
 86        predicted=1/np.sqrt(2*(1+(omega*tau)**2))
 87        rows.append({'tau_h': tau, 'predicted_rms_gain': predicted,
 88                     'observed_gain': float(gain), 'relative_error': float(abs(gain-predicted)/predicted)})
 89    return rows
 90
 91def regime_prediction_demo():
 92    # Same observed score is used by both methods. Hysteresis suppresses rapid regime
 93    # changes caused by score noise; soft routing averages incompatible dynamics.
 94    rng=np.random.default_rng(SEED+7); T=6000
 95    true=(np.arange(T)//30)%2
 96    # regimes have opposite slopes, noisy observations make scores ambiguous at boundaries
 97    x=np.zeros(T); x[0]=0.2
 98    for t in range(1,T): x[t]=0.92*x[t-1] + (0.18 if true[t] else -0.18) + 0.04*rng.normal()
 99    score=np.clip(true + 0.22*rng.normal(size=T),0,1)
100    # one-step model uses known regime-specific intercepts, but route is noisy
101    pred_soft=0.92*x[:-1] + 0.18*(2*score[1:]-1)
102    soft_mse=float(np.mean((pred_soft-x[1:])**2))
103    r=HystereticRouter(0.68,0.32); chosen=[]
104    for t in range(T): chosen.append(r.step(np.array([1-score[t],score[t]]),t))
105    chosen=np.asarray(chosen)
106    pred_h=0.92*x[:-1] + 0.18*(2*chosen[1:]-1)
107    hyst_mse=float(np.mean((pred_h-x[1:])**2))
108    return {'soft_mse':soft_mse, 'hysteretic_mse':hyst_mse,
109            'soft_switch_proxy':int(np.sum(np.diff((score>0.5).astype(int))!=0)),
110            'hysteretic_switches':len(r.switches), 'improvement_fraction':(soft_mse-hyst_mse)/soft_mse}
111
112def main():
113    dwell=dwell_sweep(); chatter=chatter_sweep(); tau=tau_sweep(); demo=regime_prediction_demo()
114    result={'seed':SEED, 'dwell_bound':dwell, 'chatter_threshold':chatter,
115            'timescale_lowpass':tau, 'regime_demo':demo}
116    Path('results.json').write_text(json.dumps(result, indent=2))
117    print(json.dumps(result, indent=2))
118    # core acceptance checks
119    assert min(x['bound_ratio'] for x in dwell) >= 0.99
120    assert chatter[-1]['switches'] == 0
121    assert max(x['relative_error'] for x in tau) < 0.02
122if __name__ == '__main__': main()