aurasync-engine 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ """AuraSync V4: Detect -> Correct -> Recover.
2
+
3
+ Analogy: a ship's navigator (the neural observer) dead-reckons and reads the
4
+ instruments; a lookout (EventGeometry) shouts bearings to the mast whenever he can
5
+ see it. Small disagreements are averaged in. A large, *repeated*, *self-consistent*
6
+ disagreement means the navigator has lost the plot, so his position is reset to the
7
+ lookout's bearing. If the lookout is unsure (fog, spray), nobody resets anything;
8
+ the captain is simply told the position is uncertain.
9
+
10
+ Module A ConsistencyDetector per-tick anomaly score (CUSUM of the geometric
11
+ innovation), alarm + timestamp, reliability, reasons
12
+ Module B V4Pipeline correction gated Kalman fusion (theta, x) / confirmed
13
+ re-initialisation / uncertainty inflation
14
+ Module C covariance after every step is the posterior of the fusion, and is
15
+ inflated when the evidence contradicts it without
16
+ allowing a correction
17
+ No ground truth is used: inputs are the sensor surface, the controls and the
18
+ observer's own estimate.
19
+ """
20
+ from collections import deque
21
+ import numpy as np
22
+ import torch
23
+
24
+ from ..scheduler import TriggeredEventPipeline, FRESH
25
+ from .geometry import EventGeometry
26
+
27
+ DT = 1e-3
28
+
29
+
30
+ class ConsistencyDetector:
31
+ """Per-tick consistency check between the estimate and independent event geometry."""
32
+
33
+ def __init__(self, cusum_k=4.0, cusum_h=40.0, sign_deg=3.0, stale_ticks=30, motion_speed=0.3,
34
+ burst_factor=4.0, l=0.25):
35
+ self.k, self.h = cusum_k, cusum_h
36
+ self.sign = np.radians(sign_deg)
37
+ self.stale_ticks, self.motion_speed, self.burst_factor, self.l = stale_ticks, motion_speed, burst_factor, l
38
+ self.reset()
39
+
40
+ def reset(self):
41
+ self.g = 0.0
42
+ self.alarm = False
43
+ self.t_detect = None
44
+ self.last_valid = 0
45
+ self.rel = 0.0
46
+ self.ev_baseline = None
47
+ self.reasons = []
48
+
49
+ def update(self, k, s_hat, Sigma, m, n_fresh):
50
+ reasons = []
51
+ d2 = None
52
+ # running estimate of the normal fresh-event count (for burst detection)
53
+ self.ev_baseline = n_fresh if self.ev_baseline is None else 0.99 * self.ev_baseline + 0.01 * n_fresh
54
+ burst = n_fresh > self.burst_factor * max(self.ev_baseline, 5.0)
55
+ if m is not None and m["valid"]:
56
+ nu = m["theta"] - s_hat[2]
57
+ d2 = nu ** 2 / (Sigma[2, 2] + m["sigma_theta"] ** 2)
58
+ # CUSUM on the normalised innovation; each tick adds at most 16 - k (= 12), so a single
59
+ # outlier can never raise the alarm alone (>= 4 consecutive strong disagreements needed)
60
+ self.g = max(0.0, self.g + min(d2, 16.0) - self.k)
61
+ self.last_valid = k
62
+ self.rel = 0.9 * self.rel + 0.1 * m["quality"]
63
+ if d2 > 9:
64
+ reasons.append("angle_mismatch")
65
+ if np.sign(m["theta"]) != np.sign(s_hat[2]) and min(abs(m["theta"]), abs(s_hat[2])) > self.sign:
66
+ reasons.append("sign_mismatch")
67
+ else:
68
+ self.rel *= 0.98
69
+ v_top = s_hat[1] + 2 * self.l * s_hat[3] * np.cos(s_hat[2])
70
+ if abs(v_top) > self.motion_speed and n_fresh < 5:
71
+ reasons.append("missing_events") # estimate says motion, sensor sees none
72
+ if k - self.last_valid > self.stale_ticks:
73
+ reasons.append("stale_evidence")
74
+ if burst:
75
+ reasons.append("noise_burst")
76
+ self.alarm = self.g > self.h
77
+ if self.alarm and self.t_detect is None:
78
+ self.t_detect = k
79
+ if not self.alarm and self.g == 0.0:
80
+ self.t_detect = None
81
+ self.reasons = reasons
82
+ return dict(score=self.g / self.h, alarm=self.alarm, t_detect=self.t_detect, reliability=self.rel,
83
+ reasons=reasons, d2=d2)
84
+
85
+
86
+ class V4Pipeline(TriggeredEventPipeline):
87
+ """V3 observer + event geometry + detector + gated correction.
88
+
89
+ correct apply gated fusion and confirmed re-initialisation (False -> detection only)
90
+ inflate inflate the covariance when the detector alarms without a usable correction
91
+ hidden_reset 'zero' | 'keep': what happens to the observer's SSM memory on re-initialisation
92
+ fuse_x also fuse the event-measured pivot position
93
+ """
94
+
95
+ def __init__(self, models, mode="triggered", correct=True, inflate=True, hidden_reset="zero", fuse_x=True,
96
+ gate=9.0, confirm_n=4, confirm_window=12, confirm_spread_deg=3.0, cooldown=40,
97
+ inflate_sigma_deg=10.0, sigma_x_floor=0.004, neural_fusion="overwrite", min_gap=0,
98
+ residual_trigger=None, name=None, **kw):
99
+ super().__init__(models, mode=mode, **kw)
100
+ self.min_gap = int(min_gap) # Module E: hard compute budget (>= min_gap ticks between corrections)
101
+ # Module E: also trigger on the event-consistency residual (default: only when a budget is set,
102
+ # so the unbudgeted V4 is exactly the design selected on validation)
103
+ self.residual_trigger = (min_gap > 0) if residual_trigger is None else residual_trigger
104
+ self._last_d2 = None
105
+ self.neural_fusion = neural_fusion # 'overwrite' (V3 semantics) | 'kalman' (full-state measurement)
106
+ # | 'kalman_pos' (measurement of x and theta only; velocities come
107
+ # from physics + the measurement stream via the cross-covariance)
108
+ self.geo = EventGeometry()
109
+ self.det = ConsistencyDetector()
110
+ self.correct_on, self.inflate_on, self.hidden_reset, self.fuse_x = correct, inflate, hidden_reset, fuse_x
111
+ self.gate, self.confirm_n, self.confirm_window = gate, confirm_n, confirm_window
112
+ self.confirm_spread = np.radians(confirm_spread_deg)
113
+ self.cooldown, self.inflate_sigma, self.sigma_x_floor = cooldown, np.radians(inflate_sigma_deg), sigma_x_floor
114
+ tag = ("" if correct else "-nocorr") + ("" if inflate else "-noinfl") + ("" if hidden_reset == "zero" else "-keeph")
115
+ self.name = name or f"v4-{mode}{tag}"
116
+
117
+ def reset(self, s0):
118
+ super().reset(s0)
119
+ self.geo.rng = np.random.default_rng(self.seed + 1)
120
+ self.det.reset()
121
+ self.buf = deque(maxlen=32) # (k, theta_meas, sigma, x_pivot) of recent valid measurements
122
+ self.last_reinit = -10 ** 9
123
+ self.cap_state = None # state at the capture of the in-flight neural job
124
+ self.ev_log = [] # correction / reinit / inflation events (diagnostics)
125
+ self.det_log = []
126
+
127
+ # ---- neural job bookkeeping: apply a finished correction as a DELTA ---------------------
128
+ def _trigger(self, surface):
129
+ """V3 triggers (burst, doubt, safety margin, max gap) + the consistency residual,
130
+ all subject to a hard budget of one correction per `min_gap` ticks."""
131
+ if self.min_gap and self.k - self.last_capture < self.min_gap:
132
+ return None
133
+ why = super()._trigger(surface)
134
+ if why is None and self.residual_trigger and self._last_d2 is not None and self._last_d2 > self.gate:
135
+ self.log["triggers"]["residual"] = self.log["triggers"].get("residual", 0)
136
+ why = "residual"
137
+ return why
138
+
139
+ def _correct(self, surface, s_pred, gap):
140
+ out = super()._correct(surface, s_pred, gap)
141
+ self.cap_state = np.array(s_pred, float)
142
+ return out
143
+
144
+ def _finish_job(self, k):
145
+ """Like V3, but the neural correction is applied as a delta on top of the current
146
+ (possibly event-corrected) state; identical to V3 when no event correction happened."""
147
+ cap_k, done_k, s_c, S_c, h_new = self.job
148
+ if done_k - cap_k > self.deadline and k >= cap_k + self.deadline:
149
+ self.log["misses"] += 1; self.job = None
150
+ return False
151
+ if k < done_k:
152
+ self.log["busy_ticks"] += 1
153
+ return False
154
+ s_base = self.cap_state.copy()
155
+ for uu in list(self.u_hist)[-(k - cap_k):] if k > cap_k else []:
156
+ s_c, S_c = self._propagate(s_c, S_c, uu)
157
+ s_base, _ = self._propagate(s_base, S_c, uu)
158
+ if self.neural_fusion.startswith("kalman"):
159
+ self._fuse_neural(s_c, S_c)
160
+ self.h = h_new
161
+ else:
162
+ self.s = self.s + (s_c - s_base)
163
+ self.Sigma, self.h = S_c, h_new
164
+ self.job = None
165
+ return True
166
+
167
+ def _fuse_neural(self, s_c, S_c):
168
+ """Treat the neural observer's output as a measurement with its calibrated (diagonal)
169
+ covariance, weighted against the current fused estimate. 'kalman_pos' uses only the
170
+ position components: the network's velocity outputs were found (validation) to be
171
+ confidently wrong after large hits."""
172
+ idx = [0, 2] if self.neural_fusion == "kalman_pos" else [0, 1, 2, 3]
173
+ H = np.eye(4)[idx]
174
+ R = np.diag(np.clip(np.diag(S_c)[idx], 1e-12, None))
175
+ S = H @ self.Sigma @ H.T + R
176
+ K = np.linalg.solve(S, H @ self.Sigma).T
177
+ self.s = self.s + K @ (s_c[idx] - self.s[idx])
178
+ I_KH = np.eye(4) - K @ H
179
+ self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
180
+
181
+ def update(self, k, t, s_true, surface, u):
182
+ self.k = k
183
+ self.u_hist.append(u)
184
+ corrected = False
185
+ if self.mode == "dense":
186
+ s_pred, S_pred = self._propagate(self.s, self.Sigma, u)
187
+ s_c, S_c, h_new = self._correct(surface, s_pred, 1)
188
+ if self.neural_fusion.startswith("kalman"):
189
+ self.s, self.Sigma = s_pred, S_pred
190
+ self._fuse_neural(s_c, S_c); self.h = h_new
191
+ else:
192
+ self.s, self.Sigma, self.h = s_c, S_c, h_new
193
+ self.log["calls"] += 1
194
+ corrected = True
195
+ else:
196
+ self.s, self.Sigma = self._propagate(self.s, self.Sigma, u)
197
+ if self.job is not None:
198
+ corrected = self._finish_job(k)
199
+ if self.job is None:
200
+ why = self._trigger(surface)
201
+ if why is not None:
202
+ gap = max(1, k - self.last_capture)
203
+ s_c, S_c, h_new = self._correct(surface, self.s, gap)
204
+ lat_ticks = int(np.ceil(self.rng.choice(self.lat) / DT))
205
+ self.job = (k, k + lat_ticks, s_c, S_c, h_new)
206
+ self.last_capture = k
207
+ self.log["calls"] += 1; self.log["triggers"][why] += 1
208
+ # ---- Module A: independent event geometry + consistency detection ------------------
209
+ m = self.geo.measure(surface, self.s[0])
210
+ n_fresh = int(((surface[0] >= FRESH) | (surface[1] >= FRESH)).sum())
211
+ d = self.det.update(k, self.s, self.Sigma, m, n_fresh)
212
+ self._last_d2 = d["d2"]
213
+ if m is not None and m["valid"] and "noise_burst" not in d["reasons"]:
214
+ self.buf.append((k, m["theta"], m["sigma_theta"], m["x_pivot"]))
215
+ action = None
216
+ # ---- Module B: correction ------------------------------------------------------------
217
+ if self.correct_on and m is not None and m["valid"] and "noise_burst" not in d["reasons"]:
218
+ if d["d2"] is not None and d["d2"] <= self.gate:
219
+ self._fuse(m); action = "fuse"
220
+ elif d["alarm"] and k - self.last_reinit >= self.cooldown and self._confirmed(k):
221
+ self._reinit(k); action = "reinit"
222
+ # ---- Module C: inflate when contradicted but not correctable --------------------------
223
+ if action is None and self.inflate_on and d["alarm"]:
224
+ if self.Sigma[2, 2] < self.inflate_sigma ** 2:
225
+ self.Sigma[2, 2] = self.inflate_sigma ** 2
226
+ self.Sigma[3, 3] = max(self.Sigma[3, 3], 4.0)
227
+ action = "inflate"
228
+ if action in ("reinit", "inflate"):
229
+ self.ev_log.append(dict(k=k, action=action, reasons=list(d["reasons"]), score=d["score"]))
230
+ self.det_log.append((d["score"], d["alarm"], action == "fuse", action == "reinit", m is not None and m["valid"]))
231
+ self.out.append((self.s.copy(), self.Sigma.copy()))
232
+ self.log["sigma_th"].append(np.sqrt(self.Sigma[2, 2])); self.log["corrected"].append(corrected)
233
+
234
+ def _fuse(self, m):
235
+ """Kalman update with the event measurement of theta (and the pivot x)."""
236
+ H = [np.array([0, 0, 1.0, 0])]; z = [m["theta"]]; R = [m["sigma_theta"] ** 2]
237
+ if self.fuse_x:
238
+ H.append(np.array([1.0, 0, 0, 0])); z.append(m["x_pivot"])
239
+ R.append(max(self.sigma_x_floor, 0.5 / self.geo.S / np.sqrt(m["n_inliers"])) ** 2)
240
+ H, z, R = np.stack(H), np.array(z), np.diag(R)
241
+ S = H @ self.Sigma @ H.T + R
242
+ K = np.linalg.solve(S, H @ self.Sigma).T
243
+ self.s = self.s + K @ (z - H @ self.s)
244
+ I_KH = np.eye(4) - K @ H
245
+ self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
246
+
247
+ def _confirmed(self, k):
248
+ """Re-initialise only on several recent, mutually consistent measurements."""
249
+ recent = [b for b in self.buf if k - b[0] <= self.confirm_window]
250
+ if len(recent) < self.confirm_n:
251
+ return False
252
+ th = np.array([b[1] for b in recent])
253
+ return float(np.abs(th - np.median(th)).max()) <= self.confirm_spread
254
+
255
+ def _reinit(self, k):
256
+ recent = [b for b in self.buf if k - b[0] <= self.confirm_window]
257
+ ks = np.array([b[0] for b in recent], float); th = np.array([b[1] for b in recent])
258
+ before = self.s.copy()
259
+ theta = float(np.median(th))
260
+ if ks.max() - ks.min() >= 6: # rate from the measurement history
261
+ slope = float(np.polyfit((ks - ks.max()) * DT, th, 1)[0])
262
+ else:
263
+ slope = self.s[3]
264
+ self.s[2], self.s[3] = theta, slope
265
+ if self.fuse_x:
266
+ self.s[0] = float(np.median([b[3] for b in recent]))
267
+ sig_m = float(np.median([b[2] for b in recent]))
268
+ self.Sigma = np.diag([max(self.Sigma[0, 0], 1e-4), max(self.Sigma[1, 1], 0.04), sig_m ** 2, 1.0])
269
+ if self.hidden_reset == "zero":
270
+ self.h = [m.init_hidden(1) for m in self.models]
271
+ self.job = None # in-flight correction is based on the old state
272
+ self.last_reinit = k
273
+ self.det.g = 0.0
274
+ self.ev_log.append(dict(k=k, action="reinit_detail", before=before.tolist(), after=self.s.tolist()))
File without changes
@@ -0,0 +1,269 @@
1
+ """AuraSync V4.1: geometry-first, evidence-aware estimation with selective neural assistance.
2
+
3
+ Analogy: a surveyor with a theodolite (event geometry) does the work. Before trusting a
4
+ reading he asks what kind of silence or noise he is facing:
5
+ QUIET nothing moves, the instrument is alive, and nothing *should* move
6
+ -> the last fix still holds; uncertainty stays bounded
7
+ UNOBSERVED something is moving (activity in the pole region) or should be moving
8
+ (the estimate predicts motion), but no usable fit -> uncertainty grows
9
+ with the full process noise: absence of evidence is NOT proof of stillness
10
+ CONTRADICTORY a good-looking fit disagrees with the estimate -> wait for corroboration
11
+ (re-initialise after several consistent fits), meanwhile widen the
12
+ uncertainty so a real change cannot be locked out
13
+ CORRUPT burst of events far above the background, or fits that disagree with
14
+ each other -> ignore the sensor for now, widen uncertainty
15
+ Only when the theodolite is not enough (UNOBSERVED long enough, unresolved contradiction,
16
+ corruption, or high uncertainty) does he radio the expensive helper (the V3 neural
17
+ observer), within a budget, with a periodic check-in at most `max_interval` ticks apart.
18
+ The helper's answer is accepted only through a gate, and only for positions (x, theta):
19
+ velocities stay with the physics + geometry filter.
20
+
21
+ neural=None gives the network-free variant ("D-ev": Baseline D + the evidence fix).
22
+ No ground truth is used: inputs are the time surface, the applied controls and the
23
+ estimator's own state.
24
+ """
25
+ from collections import deque
26
+ import numpy as np
27
+ import torch
28
+
29
+ from ..physics import PlantParams, rk4_step, linearise
30
+ from ..estimator import SCALE
31
+ from ..scheduler import FRESH
32
+ from ..v4.geometry import EventGeometry
33
+
34
+ DT = 1e-3
35
+ SCALE_NP = SCALE.numpy().astype(np.float64)
36
+ QUIET, UNOBSERVED, FUSED, CONTRADICTORY, CORRUPT, REINIT = range(6)
37
+ STATE_NAMES = ["quiet", "unobserved", "fused", "contradictory", "corrupt", "reinit"]
38
+
39
+
40
+ class EvidencePipeline:
41
+ def __init__(self, Q, neural=None, temp=None, latency_samples=(0.002,), deadline=6,
42
+ # measurement model (calibrated on validation seeds, results/v41/geometry_calibration.json)
43
+ meas_inflate=1.4, low_support_inflate=1.6, sigma_x_floor=0.004, gate=9.0,
44
+ # evidence classification
45
+ roi_quiet=4, v_quiet=0.15, alive_window=10, sigma_quiet_deg=3.0, sigma_dquiet=0.5,
46
+ burst_factor=4.0, burst_min=40, consistency_deg=15.0,
47
+ # contradictions
48
+ confirm_n=4, confirm_window=12, confirm_spread_deg=3.0, cooldown=40, contra_growth=1.3,
49
+ corrupt_q_deg=1.0,
50
+ # selective neural assistance (Module E)
51
+ min_gap=10, max_interval=50, unobs_ticks=3, sigma_req_deg=3.0, neural_gate=9.0,
52
+ Q_quiet=None, plant=None, seed=0, name=None):
53
+ self.p = plant or PlantParams()
54
+ self.A, _ = linearise(self.p, DT)
55
+ self.Q = Q
56
+ self.Q_quiet = np.diag([1e-9, 1e-5, 1e-8, 1e-4]) if Q_quiet is None else Q_quiet
57
+ self.geo = EventGeometry(seed=seed + 1)
58
+ self.models = None if neural is None else [m.eval() for m in (neural if isinstance(neural, (list, tuple)) else [neural])]
59
+ self.temp = np.ones(4) if temp is None else np.asarray(temp, float)
60
+ self.lat = np.asarray(latency_samples, float)
61
+ self.deadline = deadline
62
+ self.mi, self.mi_low, self.sx, self.gate = meas_inflate, low_support_inflate, sigma_x_floor, gate
63
+ self.roi_quiet, self.v_quiet, self.alive_window = roi_quiet, v_quiet, alive_window
64
+ self.sig_quiet, self.sig_dquiet = np.radians(sigma_quiet_deg), sigma_dquiet
65
+ self.burst_factor, self.burst_min, self.consistency = burst_factor, burst_min, np.radians(consistency_deg)
66
+ self.confirm_n, self.confirm_window, self.confirm_spread = confirm_n, confirm_window, np.radians(confirm_spread_deg)
67
+ self.cooldown, self.contra_growth, self.q_corrupt = cooldown, contra_growth, np.radians(corrupt_q_deg) ** 2
68
+ self.min_gap, self.max_interval, self.unobs_ticks = min_gap, max_interval, unobs_ticks
69
+ self.sigma_req, self.neural_gate = np.radians(sigma_req_deg), neural_gate
70
+ self.seed = seed
71
+ self.name = name or ("v41" if neural is not None else "geo-ev")
72
+ self.delay_ticks = 0
73
+
74
+ # ---- interface --------------------------------------------------------------------------
75
+ def reset(self, s0):
76
+ self.rng = np.random.default_rng(self.seed)
77
+ self.geo.rng = np.random.default_rng(self.seed + 1)
78
+ self.s = np.asarray(s0, float).copy()
79
+ self.Sigma = np.diag((0.05 * SCALE_NP) ** 2)
80
+ self.k = -1
81
+ self.u_hist = deque(maxlen=256)
82
+ self.buf = deque(maxlen=32) # recent valid fits (k, theta, sigma, x)
83
+ self.cbuf = deque(maxlen=32) # recent fits that CONTRADICTED the estimate
84
+ self.last_alive = -10 ** 9 # last tick with any background/scene activity
85
+ self.ev_base = None
86
+ self.unobs_run = 0
87
+ self.last_reinit = -10 ** 9
88
+ self.last_call = -10 ** 9
89
+ self.job = None # (capture_k, done_k, s_corr, Sigma_corr, h_new)
90
+ self.h = None if self.models is None else [m.init_hidden(1) for m in self.models]
91
+ self.state = QUIET
92
+ self.log = dict(calls=0, misses=0, accepted=0, rejected=0, states=[0] * 6, triggers=dict(
93
+ unobserved=0, contradictory=0, corrupt=0, doubt=0, check=0), sigma_th=[], state_seq=[])
94
+
95
+ def estimate(self):
96
+ return self.s
97
+
98
+ def covariance(self):
99
+ return self.Sigma
100
+
101
+ def age_ticks(self):
102
+ return 0
103
+
104
+ # ---- neural helper (identical computation to the V3 observer) ----------------------------
105
+ @torch.inference_mode()
106
+ def _neural(self, surface, s_pred, gap):
107
+ ts = torch.from_numpy(np.ascontiguousarray(surface, dtype=np.float32))[None]
108
+ sp = torch.tensor(s_pred, dtype=torch.float32)[None]
109
+ g = torch.tensor([float(gap)])
110
+ outs, vars_, hs = [], [], []
111
+ for m, h in zip(self.models, self.h):
112
+ z, act = m.encode(ts)
113
+ s_new, h_new, _, lv = m.correct(z, act, h, sp, g)
114
+ outs.append(s_new[0].double().numpy()); hs.append(h_new)
115
+ vars_.append((np.exp(lv[0].double().numpy()) * SCALE_NP ** 2) * self.temp ** 2)
116
+ return np.mean(outs, 0), np.diag(np.mean(vars_, 0)), hs
117
+
118
+ def _propagate(self, s, Sigma, u, Q):
119
+ return rk4_step(s, u, DT, self.p), self.A @ Sigma @ self.A.T + Q
120
+
121
+ # ---- Kalman helpers ---------------------------------------------------------------------
122
+ def _kf(self, idx, z, R):
123
+ H = np.eye(4)[idx]
124
+ S = H @ self.Sigma @ H.T + R
125
+ K = np.linalg.solve(S, H @ self.Sigma).T
126
+ self.s = self.s + K @ (z - H @ self.s)
127
+ I_KH = np.eye(4) - K @ H
128
+ self.Sigma = I_KH @ self.Sigma @ I_KH.T + K @ R @ K.T
129
+
130
+ def _cap(self, i, sig):
131
+ """Bound the std of state i at sig by scaling its row/column (keeps Sigma PSD)."""
132
+ v = self.Sigma[i, i]
133
+ if v > sig ** 2:
134
+ f = sig / np.sqrt(v)
135
+ self.Sigma[i, :] *= f; self.Sigma[:, i] *= f
136
+
137
+ def _widen(self, i, var):
138
+ self.Sigma[i, i] += var
139
+
140
+ # ---- one tick ---------------------------------------------------------------------------
141
+ def update(self, k, t, s_true, surface, u):
142
+ self.k = k
143
+ self.u_hist.append(u)
144
+ # evidence counts (no model, no truth)
145
+ ev = self.geo.events(surface)
146
+ roi = (self.geo.wy >= self.geo.y_lo) & (self.geo.wy <= self.geo.y_hi) & (np.abs(self.geo.wx - self.s[0]) <= self.geo.band)
147
+ n_glob, n_roi = int(ev.sum()), int((ev & roi).sum())
148
+ if n_glob > 0:
149
+ self.last_alive = k
150
+ burst = self.ev_base is not None and n_glob > max(self.burst_factor * self.ev_base, self.burst_min)
151
+ self.ev_base = n_glob if self.ev_base is None else (0.995 * self.ev_base + 0.005 * min(n_glob, 3 * self.ev_base + 5))
152
+ alive = k - self.last_alive <= self.alive_window
153
+ v_hat = abs(self.s[1] + 2 * self.geo.l * self.s[3] * np.cos(self.s[2]))
154
+
155
+ m = None if burst else self.geo.measure(surface, self.s[0])
156
+ valid = m is not None and m["valid"]
157
+ # classify the evidence BEFORE propagation decides how much process noise to add
158
+ if burst:
159
+ state = CORRUPT
160
+ elif valid:
161
+ recent = [b[1] for b in self.buf if k - b[0] <= 5]
162
+ if recent and abs(m["theta"] - np.median(recent)) > self.consistency and len(recent) >= 3:
163
+ state = CORRUPT # fits disagree with each other
164
+ else:
165
+ state = FUSED # provisional; gate decides below
166
+ elif n_roi < self.roi_quiet and v_hat < self.v_quiet and alive:
167
+ state = QUIET
168
+ else:
169
+ state = UNOBSERVED
170
+
171
+ # 1. physics prediction: full process noise unless the scene is verified quiet
172
+ self.s, self.Sigma = self._propagate(self.s, self.Sigma, u, self.Q_quiet if state == QUIET else self.Q)
173
+ if state == QUIET:
174
+ self._cap(2, self.sig_quiet); self._cap(3, self.sig_dquiet)
175
+
176
+ # 2. finished neural job -> gated, position-only fusion
177
+ if self.job is not None:
178
+ self._finish_job(k)
179
+
180
+ # 3. geometry
181
+ if state in (FUSED,):
182
+ sig = m["sigma_theta"] * (self.mi_low if (m["span"] < 0.6 or m["n_inliers"] < 12) else self.mi)
183
+ sx = max(self.sx, 0.5 / self.geo.S / np.sqrt(m["n_inliers"])) * self.mi
184
+ nu = m["theta"] - self.s[2]
185
+ d2 = nu ** 2 / (self.Sigma[2, 2] + sig ** 2)
186
+ self.buf.append((k, m["theta"], sig, m["x_pivot"]))
187
+ if d2 <= self.gate:
188
+ self._kf([2, 0], np.array([m["theta"], m["x_pivot"]]), np.diag([sig ** 2, sx ** 2]))
189
+ self.cbuf.clear()
190
+ else:
191
+ state = CONTRADICTORY
192
+ self.cbuf.append((k, m["theta"], sig, m["x_pivot"]))
193
+ if k - self.last_reinit >= self.cooldown and self._confirmed(k):
194
+ self._reinit(k); state = REINIT
195
+ else: # covariance matching, bounded growth
196
+ target = nu ** 2 / self.gate - sig ** 2
197
+ self.Sigma[2, 2] = max(self.Sigma[2, 2], min(target, self.Sigma[2, 2] * self.contra_growth ** 2))
198
+ elif state == CORRUPT:
199
+ self._widen(2, self.q_corrupt)
200
+ self.unobs_run = self.unobs_run + 1 if state == UNOBSERVED else 0
201
+
202
+ # 4. selective neural assistance
203
+ if self.models is not None and self.job is None and k - self.last_call >= self.min_gap:
204
+ why = None
205
+ if state == UNOBSERVED and self.unobs_run >= self.unobs_ticks:
206
+ why = "unobserved"
207
+ elif state == CONTRADICTORY:
208
+ why = "contradictory"
209
+ elif state == CORRUPT:
210
+ why = "corrupt"
211
+ elif np.sqrt(self.Sigma[2, 2]) > self.sigma_req:
212
+ why = "doubt"
213
+ elif k - self.last_call >= self.max_interval:
214
+ why = "check"
215
+ if why is not None:
216
+ gap = max(1, k - self.last_call) if self.last_call > -10 ** 8 else 1
217
+ s_c, S_c, h_new = self._neural(surface, self.s, min(gap, 1000))
218
+ lat = int(np.ceil(self.rng.choice(self.lat) / DT))
219
+ self.job = (k, k + lat, s_c, S_c, h_new, self.s.copy())
220
+ self.last_call = k
221
+ self.log["calls"] += 1; self.log["triggers"][why] += 1
222
+
223
+ self.state = state
224
+ self.log["states"][state] += 1
225
+ self.log["state_seq"].append(state)
226
+ self.log["sigma_th"].append(np.sqrt(self.Sigma[2, 2]))
227
+
228
+ def _finish_job(self, k):
229
+ cap_k, done_k, s_c, S_c, h_new, s_cap = self.job
230
+ if done_k - cap_k > self.deadline and k >= cap_k + self.deadline:
231
+ self.log["misses"] += 1; self.job = None
232
+ return
233
+ if k < done_k:
234
+ return
235
+ Sc = S_c.copy()
236
+ for uu in list(self.u_hist)[-(k - cap_k):] if k > cap_k else []:
237
+ s_c, Sc = self._propagate(s_c, Sc, uu, self.Q)
238
+ self.h = h_new
239
+ R = np.diag(np.clip(np.diag(Sc)[[0, 2]], 1e-12, None))
240
+ nu = s_c[2] - self.s[2]
241
+ d2 = nu ** 2 / (self.Sigma[2, 2] + R[1, 1])
242
+ if d2 <= self.neural_gate:
243
+ self._kf([0, 2], s_c[[0, 2]], R) # positions only; velocities via cross-covariance
244
+ self.log["accepted"] += 1
245
+ else:
246
+ self.log["rejected"] += 1
247
+ self.job = None
248
+
249
+ def _confirmed(self, k):
250
+ recent = [b for b in self.cbuf if k - b[0] <= self.confirm_window]
251
+ if len(recent) < self.confirm_n:
252
+ return False
253
+ th = np.array([b[1] for b in recent])
254
+ return float(np.abs(th - np.median(th)).max()) <= self.confirm_spread
255
+
256
+ def _reinit(self, k):
257
+ recent = [b for b in self.cbuf if k - b[0] <= self.confirm_window]
258
+ ks = np.array([b[0] for b in recent], float); th = np.array([b[1] for b in recent])
259
+ self.s[2] = float(np.median(th))
260
+ if ks.max() - ks.min() >= 6:
261
+ self.s[3] = float(np.polyfit((ks - ks.max()) * DT, th, 1)[0])
262
+ self.s[0] = float(np.median([b[3] for b in recent]))
263
+ sig_m = float(np.median([b[2] for b in recent]))
264
+ self.Sigma = np.diag([max(self.Sigma[0, 0], 1e-4), max(self.Sigma[1, 1], 0.04), sig_m ** 2, 1.0])
265
+ if self.models is not None:
266
+ self.h = [m.init_hidden(1) for m in self.models]
267
+ self.job = None
268
+ self.last_reinit = k
269
+ self.cbuf.clear()