From 470b44ba8bb76760df8a4d5bec7ab836d6b557c8 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:59:27 +0000 Subject: [PATCH 1/5] Hold a node track on the ADS-B hex that claimed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming asks the same question from scratch every frame: does a transponder fix explain this detection right now? When a node's tags stop and the cached fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S the answer turns to no for an aircraft that has not moved, changed or gone anywhere — its echoes drop into the dark pool, the tracker forms a track, and the dark solver mints a new key beside (or on top of) the aircraft the lane was following a second earlier. But a claim is evidence in its own right: it says this node's echo of this hex sat at that (delay, Doppler). The next frame's echo is one frame of motion away, and the measured Doppler says how far — so the node's own measured track predicts the next observation with no transponder in the loop at all. state.known_track_holds keeps the last two samples per (node, hex), and path H claims against them with the same Hungarian one-to-one the other paths use. It runs after path 1 and before path 2, which is the requirement rather than an ordering detail: once a node track is linked to a hex it must not be peelable to another hex's dead-reckoned fix. No maximum duration — while the track keeps matching, it stays linked — bounded instead by the 8 s frame-time gap it may bridge, by feed_gc for a node that stops sending, and by the ghost-lock guard: a hold may outlive the transponder but never contradict it, so with a fresh fix on file the matched detection must pass path 2's gate too. KNOWN_HOLD_MAX_GAP_S=0 is the rollback lever and is total — the store is not even written, so off is the behaviour that predates path H. Co-Authored-By: Claude Fable 5.1 --- backend/core/state.py | 37 ++++ backend/services/feed_gc.py | 13 ++ backend/services/known_claiming.py | 324 ++++++++++++++++++++++++++++- 3 files changed, 370 insertions(+), 4 deletions(-) diff --git a/backend/core/state.py b/backend/core/state.py index bce84437..5e19af20 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -420,6 +420,30 @@ def _adsb_for_seeding() -> dict[str, dict]: KNOWN_CLAIMS_PER_HEX_MAX = 64 known_claims: dict[str, deque] = {} +# ── Known-track holds: the link a claim leaves behind ───────────────────────── +# Written by services/known_claiming.py on every claim for (node, hex); read +# by the same module's hold path on the next frame from that node. +# node_id -> hex -> {"delay_us", "doppler_hz", "ts_ms", +# "prev_delay_us", "prev_doppler_hz", "prev_ts_ms", +# "fix": the claim's adsb_fix (original fix_ts_ms), +# "world", "n_claims", "n_hold"} +# A claim is evidence that THIS node's echo of THIS hex sits at that +# (delay, Doppler); the transponder fix that first supplied the identity is +# not needed to keep believing it one frame later. So the entry is the node's +# own measured track of the aircraft, and the hold path predicts the next +# frame's observation from it — which is what keeps a linked track linked +# after the tags stop and the cached fix ages out, instead of falling into the +# dark pool as a fresh ghost beside the aircraft it belongs to. +# Two samples, not one: the Doppler rate needs a difference, and a rate from +# ADS-B would re-introduce the dependency the hold exists to drop. +# Same unlocked discipline as known_claims above (single writer per node — the +# frame worker — dict writes atomic under the GIL). Bounded three ways: +# entries older than KNOWN_HOLD_MAX_GAP_S are dropped when that node's next +# frame is processed, feed_gc.prune_stale_stores prunes per hex for a node +# that stopped sending entirely, and a hold is one entry per (node, hex) that +# ever claimed — the same population known_claims is keyed by. +known_track_holds: dict[str, dict[str, dict]] = {} + # ── Track history: rolling position buffer per aircraft hex ─────────────────── # The TRUE frame. Everything internal compares against it — the speed gate's # reference, the arc-motion log, the jump check, routes/test.py's ground-truth @@ -588,6 +612,16 @@ def _adsb_for_seeding() -> dict[str, dict]: # Claiming-stage exceptions absorbed by frame_processor's fail-open guard. # Nonzero means the known lane is broken and silently contributing nothing. known_claims_errors: int = 0 +# Known-track hold (see known_track_holds above and services/known_claiming.py). +# claims counts detections claimed by the hold path — the ones that would have +# fallen into the dark pool as the tags stopped; expired counts hold entries +# dropped for exceeding KNOWN_HOLD_MAX_GAP_S; dropped_disagree counts holds +# discarded because a FRESH ADS-B fix for the same hex contradicted the held +# track (the ghost-lock guard: the hold may outlive the transponder, never +# disagree with it while it is still reporting). +known_hold_claims: int = 0 +known_hold_expired: int = 0 +known_hold_dropped_disagree: int = 0 # Dark track following (DARK_FOLLOW_MODE) — see services/dark_follow.py. # targets is a GAUGE (the size of the current pseudo-state list, assigned on # every rebuild), the other four are since-boot counters. The funnel reads @@ -1044,6 +1078,7 @@ def _reset_for_tests() -> None: global adsb_seed_frames_autotagged, adsb_capture_ts_fallback global known_claims_made, known_claim_contentions, known_claims_bound global known_claims_errors, known_claims_visibility_rejects, known_claims_world_rejects + global known_hold_claims, known_hold_expired, known_hold_dropped_disagree global dark_follow_targets, dark_follow_claims, dark_follow_inputs global dark_follow_published, dark_follow_dropped, dark_bottomup_shadowed global dark_follow_inelig_cooldown, dark_follow_inelig_no_pos @@ -1084,6 +1119,7 @@ def _reset_for_tests() -> None: multinode_tracks, adsb_aircraft, known_claims, + known_track_holds, track_histories, track_histories_public, track_last_emit, @@ -1148,6 +1184,7 @@ def _reset_for_tests() -> None: known_claims_made = known_claim_contentions = known_claims_bound = 0 known_claims_errors = known_claims_visibility_rejects = 0 known_claims_world_rejects = 0 + known_hold_claims = known_hold_expired = known_hold_dropped_disagree = 0 dark_follow_targets = dark_follow_claims = dark_follow_inputs = 0 dark_follow_published = dark_follow_dropped = 0 dark_follow_inelig_cooldown = dark_follow_inelig_no_pos = 0 diff --git a/backend/services/feed_gc.py b/backend/services/feed_gc.py index 7bf88f87..34018bf4 100644 --- a/backend/services/feed_gc.py +++ b/backend/services/feed_gc.py @@ -53,6 +53,19 @@ def prune_stale_stores(now: float) -> None: for h in stale_claims: state.known_claims.pop(h, None) + # 4a-bis. Known-track holds, on the same rule and for the same reason. + # The claiming path expires a node's own entries against ITS frame clock, + # which never ticks again for a node that stopped sending — so a node that + # disconnects mid-claim would pin one entry per hex it ever held. Pruned + # per hex (not per node) so a still-live node keeps the tracks it is still + # holding. + for nid, holds in list(state.known_track_holds.items()): + for h, e in list(holds.items()): + if (now - e.get("ts_ms", 0) / 1000.0) > KNOWN_CLAIMS_STALE_S: + holds.pop(h, None) + if not holds: + state.known_track_holds.pop(nid, None) + # 4b. Prune stale ground-truth trails and track histories to bound # memory and keep resolve_ground_truth_hex O(N) scans cheap. # diff --git a/backend/services/known_claiming.py b/backend/services/known_claiming.py index cf44df71..7d6a3811 100644 --- a/backend/services/known_claiming.py +++ b/backend/services/known_claiming.py @@ -40,10 +40,28 @@ position no radar in either world measured. Entries carry a "world" tag ("sim"/"real") stamped where they are written; claiming skips candidates tagged with the other world and counts them (known_claims_world_rejects). + +THE HOLD (path H). Both ADS-B paths ask the same question every frame from +scratch: does a transponder fix explain this detection right now? When the +tags stop and the cached fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S the answer +becomes "no" for an aircraft that has not moved, changed, or gone anywhere — +its echoes fall into the dark pool, the tracker forms a track, and the dark +solver mints a new key beside (or on top of) the aircraft the lane was +tracking a second earlier. But a claim is evidence in its own right: it says +this node's echo of this hex sat at that (delay, Doppler). The next frame's +echo of the same aircraft is one frame of motion away from it, and Doppler +says how far — so the node's OWN measured track can predict the next +observation without any transponder at all. Path H does exactly that, from +state.known_track_holds, and runs after path 1 and before path 2 so that a +link, once made, cannot be peeled off by another hex's dead-reckoned fix. +It has no maximum duration: as long as the track keeps matching frame after +frame it stays linked. The one thing it may not do is contradict a live +transponder — see the consistency rule in _claim_holds. """ import logging import math +import os from collections import deque import numpy as np @@ -89,6 +107,39 @@ # not tuned to the error, it is large enough that the error cannot reach it. _SCREEN_MARGIN = 1.02 +# ── Hold gates (path H) ────────────────────────────────────────────────────── +# The maximum frame-time gap a hold may bridge, and the feature's rollback +# lever: 0 disables path H entirely and leaves claiming byte-identical to the +# behaviour that predates it. Measured in FRAME time (frame["timestamp"]), +# never wall clock — a replay or a backlogged node must see the same gates the +# live path saw. 8 s: a node's frames arrive ~1 s apart and the simulator's +# SNR-dependent miss rate reaches 40% at the detection threshold, so 2–4 s +# gaps are routine and a bound below that would break the link on ordinary +# misses; past ~8 s the propagated Doppler rate is extrapolation rather than +# measurement. +KNOWN_HOLD_MAX_GAP_S = float(os.getenv("KNOWN_HOLD_MAX_GAP_S", "8")) +# Gate = base + rate * dt, per axis. The bases are set off the measurement +# noise, not off aircraft motion (motion is what the prediction models): +# retina_simulation.world.generate_detections_for_node adds sigma 0.1–0.2 µs +# of delay noise and 2–4 Hz of Doppler noise depending on SNR, and the hold +# compares a prediction built from ONE past sample against a new one, so the +# relevant sigma is sqrt(2) of that — ~0.3 µs and ~5.7 Hz at the noisy end. +# A gate must sit at least 5 sigma above it or ordinary noise breaks the link, +# which puts the bases at 1.5 µs and 20 Hz. The rate terms cover what the +# constant-rate propagation itself cannot: turns and altitude changes, which +# grow with the gap. +KNOWN_HOLD_DELAY_GATE_US = float(os.getenv("KNOWN_HOLD_DELAY_GATE_US", "1.5")) +KNOWN_HOLD_DELAY_RATE_US_PER_S = float(os.getenv("KNOWN_HOLD_DELAY_RATE_US_PER_S", "1.0")) +KNOWN_HOLD_DOPPLER_GATE_HZ = float(os.getenv("KNOWN_HOLD_DOPPLER_GATE_HZ", "20")) +KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S = float(os.getenv("KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S", "10")) +# Ceiling on the propagated Doppler rate, and the maximum age of the sample +# pair it may be derived from. An airliner's bistatic Doppler rate stays well +# inside +-15 Hz/s outside a hard turn; a larger apparent rate is two samples +# of DIFFERENT aircraft (or a miss-riddled pair straddling a manoeuvre), and +# propagating it would walk the prediction off the track it is holding. +KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S = float(os.getenv("KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S", "15")) +KNOWN_HOLD_RATE_MAX_SPAN_S = float(os.getenv("KNOWN_HOLD_RATE_MAX_SPAN_S", "5")) + _logger = logging.getLogger(__name__) # ── services.node_bias (trust slice) — optional at import time ──────────────── @@ -332,6 +383,232 @@ def _claim_dark_follow( return claimed +def _touch_hold( + node_id: str, + hexn: str, + d_meas: float, + f_meas: float, + ts_ms: int, + fix: dict | None, + world: str | None, + is_hold: bool, +) -> None: + """Record this claim as the node's newest measurement of the hex's track. + + Called for EVERY claim — node tag, cached-fix assignment, or hold — because + the store's whole job is to remember that this node's echo of this hex was + here, whichever path established it. Keeps the previous sample beside the + newest one: a Doppler RATE needs a difference, and taking that rate from + ADS-B would put the transponder back in the loop the hold exists to + survive without. + + ``fix`` is None on a hold claim, which is what keeps the ORIGINAL fix (and + its fix_ts_ms) on the entry: downstream readers age the fix to see how long + the aircraft has been silent, and refreshing the timestamp without a new + transponder report would hide exactly that. + """ + if KNOWN_HOLD_MAX_GAP_S <= 0: + # The rollback lever is total: with the feature off nothing is written + # either, so an off backend carries no store and claiming is exactly + # what it was before path H existed. + return + holds = state.known_track_holds.setdefault(node_id, {}) + e = holds.get(hexn) + if e is None: + e = { + "delay_us": d_meas, + "doppler_hz": f_meas, + "ts_ms": ts_ms, + "prev_delay_us": None, + "prev_doppler_hz": None, + "prev_ts_ms": None, + "fix": fix, + "world": world, + "n_claims": 0, + "n_hold": 0, + } + holds[hexn] = e + else: + if ts_ms != e["ts_ms"]: + e["prev_delay_us"] = e["delay_us"] + e["prev_doppler_hz"] = e["doppler_hz"] + e["prev_ts_ms"] = e["ts_ms"] + e["delay_us"] = d_meas + e["doppler_hz"] = f_meas + e["ts_ms"] = ts_ms + if fix is not None: + e["fix"] = fix + e["world"] = world + e["n_claims"] += 1 + if is_hold: + e["n_hold"] += 1 + + +def _hold_predict(entry: dict, fc_hz: float, frame_ts_s: float) -> tuple[float, float, float]: + """(pred_delay_us, pred_doppler_hz, dt_s) for a held track at frame time. + + Delay is propagated from Doppler rather than from a delay difference: + Doppler IS the range rate, measured in this same frame, so it gives a + first-order prediction from a SINGLE past sample (the case that matters — + the frame right after the tags stop) instead of needing two. The bistatic + range closes when the Doppler is positive, so the delay shrinks: + + d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz + + (delay_us = R_km / C_KM_US, dR/dt = -doppler * C_KM_S / fc_hz, and + C_KM_S = C_KM_US * 1e6, so the C_KM_US cancels.) The sign is checked + empirically against the simulator in test_known_track_hold.py rather than + trusted from this derivation — a convention flip anywhere between the + generator and here would double the error instead of cancelling it. + + Doppler is propagated at the rate of the last two samples, clipped, and + only when they are recent enough to be one manoeuvre; otherwise it is held + flat, which is the honest zero-information answer. + """ + dt = frame_ts_s - entry["ts_ms"] / 1000.0 + delay_rate = -entry["doppler_hz"] * 1.0e6 / fc_hz + doppler_rate = 0.0 + prev_ts_ms = entry.get("prev_ts_ms") + if prev_ts_ms is not None: + span = (entry["ts_ms"] - prev_ts_ms) / 1000.0 + if 0.0 < span <= KNOWN_HOLD_RATE_MAX_SPAN_S: + raw = (entry["doppler_hz"] - entry["prev_doppler_hz"]) / span + doppler_rate = max(-KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S, min(KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S, raw)) + return ( + entry["delay_us"] + delay_rate * dt, + entry["doppler_hz"] + doppler_rate * dt, + dt, + ) + + +def _fresh_fix_prediction(hexn: str, geo, frame_ts_s: float, node_world: str) -> tuple[float, float, float] | None: + """Path 2's own prediction for one hex, or None when no fresh usable fix + exists. The consistency rule's reference — see _claim_holds.""" + st = state._adsb_for_seeding().get(hexn) + if st is None: + return None + cand_world = st.get("world") + if cand_world is not None and cand_world != node_world: + return None + age_s = frame_ts_s - st.get("timestamp_ms", 0) / 1000.0 + if abs(age_s) > KNOWN_CLAIM_MAX_FIX_AGE_S: + return None + dr_lat, dr_lon = offset_latlon_m( + st["lat"], + st["lon"], + east_m=st.get("vel_east", 0.0) * age_s, + north_m=st.get("vel_north", 0.0) * age_s, + ) + pred_d, pred_f = predict_observation( + geo, + dr_lat, + dr_lon, + st.get("alt_m", 0.0) / 1000.0, + st.get("vel_east", 0.0), + st.get("vel_north", 0.0), + ) + return pred_d, pred_f, _gate_scale(age_s) + + +def _claim_holds( + node_id: str, + geo, + frame_ts_s: float, + delays: list, + dopplers: list, + free: list[int], + claimed_hexes: set[str], +) -> list[tuple[int, str, dict, float, float, dict]]: + """Path H: claim leftover detections against this node's own held tracks. + + Runs between path 1 and path 2, which is the precedence rule the user's + requirement names: once a node track is linked to a hex it may not be + peeled off to another hex's dead-reckoned fix, and running after path 2 + would let exactly that happen every time a neighbouring aircraft's fix + reached this detection first. Path 1 still outranks it — a node's own + correlation is newer evidence about the same question than yesterday's + match. + + THE CONSISTENCY RULE (the ghost-lock guard). A hold that may never be + contradicted is a self-feeding loop of the kind dark_follow's guard exists + to stop: the hold claims a detection, the claim refreshes the hold, and + nothing can ever disagree because binding mode has already taken the + detection out of the lane that would. So while the transponder is still + reporting, the hold must AGREE with it: if the hex has a fresh cached fix, + the hold-matched detection has to fall inside path 2's gate for that hex + too, or the hold is dropped and the hex falls through to path 2 as it does + today. When the fix is stale or gone there is nothing to disagree with, + and the hold stands on its own — which is the entire point of the feature. + """ + if KNOWN_HOLD_MAX_GAP_S <= 0: + return [] + holds = state.known_track_holds.get(node_id) + if not holds: + return [] + + # Expiry first, on this node's own frame clock, so a held track that has + # gone quiet for longer than the gap can bridge is gone before it can be + # matched (and cannot linger in the store either). + expired = [ + h for h, e in list(holds.items()) if not (0.0 <= frame_ts_s - e["ts_ms"] / 1000.0 <= KNOWN_HOLD_MAX_GAP_S) + ] + for h in expired: + holds.pop(h, None) + if expired: + state.bump_counter("known_hold_expired", len(expired)) + if not free or not holds: + return [] + + cands = [] + for hexn, e in list(holds.items()): + if hexn in claimed_hexes: + continue + pred_d, pred_f, dt = _hold_predict(e, geo.fc_hz, frame_ts_s) + d_gate = KNOWN_HOLD_DELAY_GATE_US + KNOWN_HOLD_DELAY_RATE_US_PER_S * dt + f_gate = KNOWN_HOLD_DOPPLER_GATE_HZ + KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S * dt + cands.append((hexn, e, pred_d, pred_f, d_gate, f_gate, dt)) + if not cands: + return [] + + cost = np.full((len(free), len(cands)), _GATE_INFEASIBLE) + for c, (_hexn, _e, pred_d, pred_f, d_gate, f_gate, _dt) in enumerate(cands): + for r, i in enumerate(free): + d_res = abs(pred_d - float(delays[i])) + f_res = abs(pred_f - float(dopplers[i])) + if d_res > d_gate or f_res > f_gate: + continue + cost[r, c] = d_res / d_gate + f_res / f_gate + rows, cols = linear_sum_assignment(cost) + + node_world = state.node_world(node_id) + out: list[tuple[int, str, dict, float, float, dict]] = [] + for r, c in zip(rows, cols): + if cost[r, c] >= _GATE_INFEASIBLE: + continue + i = free[r] + hexn, e, pred_d, pred_f, _d_gate, _f_gate, dt = cands[c] + ref = _fresh_fix_prediction(hexn, geo, frame_ts_s, node_world) + if ref is not None: + ref_d, ref_f, scale = ref + if ( + abs(ref_d - float(delays[i])) > KNOWN_CLAIM_DELAY_GATE_US * scale + or abs(ref_f - float(dopplers[i])) > KNOWN_CLAIM_DOPPLER_GATE_HZ * scale + ): + holds.pop(hexn, None) + state.bump_counter("known_hold_dropped_disagree") + continue + fix = e.get("fix") + if not isinstance(fix, dict): + # A hold with no fix behind it has nothing to seed the known lane + # with, and every reader of a claim keys on adsb_fix. Cannot + # happen from the paths above; dropped rather than published as a + # half-claim. + continue + out.append((i, hexn, fix, pred_d, pred_f, {"hold": True, "hold_gap_s": round(dt, 3)})) + state.bump_counter("known_hold_claims") + return out + + def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | None = None) -> set[int]: """Run the claiming stage for one frame; return the claimed detection indices. @@ -346,6 +623,11 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No backend never overwrites a node-provided list), so it is not re-gated; the prediction is still computed so the record carries the residual the trust path needs. + H. This node's own HELD tracks (state.known_track_holds): hexes this + node has claimed before, predicted forward from their last measured + (delay, Doppler) rather than from a transponder fix. Ahead of path 2 + so a linked track cannot be peeled off to another hex, and therefore + ahead of path 3 as well. See _claim_holds. 2. Remaining detections × fresh cached ADS-B states whose dead-reckoned position this node can see, global one-to-one via linear_sum_assignment under age-scaled gates. @@ -375,8 +657,8 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No ts_ms = int(frame.get("timestamp", 0)) frame_ts_s = ts_ms / 1000.0 - # (det_idx, hexn, adsb_fix, pred_delay_us, pred_doppler_hz) - claims: list[tuple[int, str, dict, float, float]] = [] + # (det_idx, hexn, adsb_fix, pred_delay_us, pred_doppler_hz, extra) + claims: list[tuple[int, str, dict, float, float, dict]] = [] claimed_idx: set[int] = set() claimed_hexes: set[str] = set() @@ -408,10 +690,25 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No "track": tag.get("track"), "fix_ts_ms": ts_ms, } - claims.append((i, hexn, fix, pred_d, pred_f)) + claims.append((i, hexn, fix, pred_d, pred_f, {})) claimed_idx.add(i) claimed_hexes.add(hexn) + # ── Path H: this node's own held tracks ────────────────────────────────── + # Between path 1 and path 2 on purpose — see _claim_holds. + for hold_claim in _claim_holds( + node_id, + geo, + frame_ts_s, + delays, + dopplers, + [i for i in range(len(delays)) if i not in claimed_idx], + claimed_hexes, + ): + claims.append(hold_claim) + claimed_idx.add(hold_claim[0]) + claimed_hexes.add(hold_claim[1]) + # ── Path 2: assignment over untagged detections × fresh cached states ──── free = [i for i in range(len(delays)) if i not in claimed_idx] if free: @@ -554,6 +851,7 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No }, pred_d, pred_f, + {}, ) ) claimed_idx.add(i) @@ -561,7 +859,8 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No # ── Contention, registry, counters, residual hook ───────────────────────── projections = _dark_global_projections(geo, frame_ts_s) if claims else [] nb = _node_bias() if claims else None - for i, hexn, fix, pred_d, pred_f in claims: + node_world_tag = state.node_world(node_id) if claims else None + for i, hexn, fix, pred_d, pred_f, extra in claims: d_meas = float(delays[i]) f_meas = float(dopplers[i]) # A detection both a known hex and an established dark track can @@ -584,8 +883,25 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No "ts_ms": ts_ms, "adsb_fix": fix, "contested": contested, + # "hold": True / "hold_gap_s" on a path-H claim; absent + # otherwise, so every existing reader is unchanged. + **extra, } ) + # Every claim is a fresh measurement of this node's track of this hex, + # whichever path made it — that is what the hold store holds. A hold + # claim passes fix=None so the stored (older) fix and its fix_ts_ms + # survive, keeping the silence visible downstream. + _touch_hold( + node_id, + hexn, + d_meas, + f_meas, + ts_ms, + None if extra.get("hold") else fix, + node_world_tag, + bool(extra.get("hold")), + ) state.bump_counter("known_claims_made") if contested: state.bump_counter("known_claim_contentions") From 634b82015cd14522973bbc9ee043a362118eac20 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:59:48 +0000 Subject: [PATCH 2/5] Seed a stale-fix known-lane solve from the lane's own last solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A held claim can outlive its transponder by any amount — that is the point of the hold — and _build_solver_input then dead-reckons a position from minutes ago at a heading from minutes ago. The guess drifts kilometres, _attempt measures displacement against it, labels the honest solve a ghost, and the aircraft stops being published exactly when the hold made it visible. Past the fix-age cap the lane's own last mn-adsb- solve is the better prior: a radar measurement, seconds old, of the same aircraft, with a filter velocity behind it. Altitude stays pinned from the last fix — barometric altitude does not go stale the way a position does. s_in and the history record carry seed_source ("kf"/"fix") so the two populations are separable. The display side needed one change and no more. multinode_to_aircraft reads nothing from state.adsb_aircraft, so an mn-adsb entry keeps rendering from its radar solves after the cache entry is pruned (only the callsign lookup in the single-node section degrades, to None). The singly-claimed section, though, draws the fix ITSELF, so a hold whose fix has aged out would paint the aircraft where it was rather than where it is, more wrongly the longer the hold succeeds: those are skipped, and entries carry adsb_stale so the map can say a drawn position is coasting rather than measured. Co-Authored-By: Claude Fable 5.1 --- backend/services/aircraft_feed.py | 20 ++++++++++ backend/services/tasks/known_lane.py | 57 +++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/backend/services/aircraft_feed.py b/backend/services/aircraft_feed.py index db62a082..a6e5f9f5 100644 --- a/backend/services/aircraft_feed.py +++ b/backend/services/aircraft_feed.py @@ -39,6 +39,7 @@ normalize_hex_key, passive_track_hex, ) +from services.known_claiming import KNOWN_CLAIM_MAX_FIX_AGE_S from services.public_location import fuzz_node_cfg from services.solve_uncertainty import solve_sigma_m, velocity_sigma_ms from services.track_gates import ( @@ -309,6 +310,19 @@ def _claimed_single_node_entries(now: float) -> list[dict]: lat, lon = fix.get("lat"), fix.get("lon") if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)): continue + # A HELD claim (known_claiming path H) carries the LAST fix the + # transponder ever gave, however old — the hold is a radar link, not a + # position report. This section draws the fix itself, so once that fix + # is past the claiming path's own freshness cap there is nothing here + # worth drawing: the icon would sit where the aircraft was, not where + # it is, and grow more wrong the longer the hold succeeds. Skipped + # rather than dead-reckoned — one node's claim gives an arc, not a + # position, so there is no honest estimate to put in its place. Two or + # more claiming nodes are unaffected: those are the known lane's, and + # it solves them (see known_lane._build_solver_input's stale-fix seed). + _fix_age_s = (float(newest["ts_ms"]) - float(fix.get("fix_ts_ms") or 0)) / 1000.0 + if newest.get("hold") and _fix_age_s > KNOWN_CLAIM_MAX_FIX_AGE_S: + continue node_id = newest["node_id"] delay_us = float(newest.get("delay_us") or 0.0) @@ -341,6 +355,12 @@ def _claimed_single_node_entries(now: float) -> list[dict]: "seen": round(max(0.0, now - float(newest["ts_ms"]) / 1000.0), 1), "multinode": False, "position_source": "adsb_single_node", + # True when the drawn fix is no longer being refreshed by a + # transponder (a hold still inside the freshness cap, or a + # cached fix that has aged during this claim's own lifetime), + # so the map can say the position is coasting rather than + # measured. Absent-as-false for every claim made today. + "adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")), # Mandatory: the live/owner WS feeds drop any entry whose # node_id is not in the connection's node set. "node_id": node_id, diff --git a/backend/services/tasks/known_lane.py b/backend/services/tasks/known_lane.py index 8e9754c1..bfaaf47c 100644 --- a/backend/services/tasks/known_lane.py +++ b/backend/services/tasks/known_lane.py @@ -69,6 +69,7 @@ from services import dark_follow, track_filter from services.geo import haversine_km, offset_latlon_m from services.id_utils import normalize_hex_key +from services.known_claiming import KNOWN_CLAIM_MAX_FIX_AGE_S # Deliberate one-way dependency: this module reuses the solver worker's # record store, gates, publication lock and smoother so known-lane records @@ -257,6 +258,45 @@ def _build_solver_input(hexn: str, claims: dict[str, dict]) -> dict | None: east_m=vel_east * dt_s, north_m=vel_north * dt_s, ) + seed_source = "fix" + + # A HELD claim (known_claiming path H) can outlive its transponder by any + # amount — that is the point of the hold — and the fix above is then a + # position from minutes ago propagated at a heading from minutes ago. Its + # dead-reckoned guess drifts kilometres, _attempt measures displacement + # against it, labels the honest solve a "ghost", and the aircraft stops + # being published exactly when it stopped being visible any other way. + # Past the fix-age cap the lane's OWN last solve for this hex is the better + # prior: it is a radar measurement, seconds old, of the same aircraft, and + # the filter has a velocity for it. Altitude still comes from the fix — + # a barometric altitude does not go stale the way a position does, and it + # remains the best altitude information anyone has. + if abs(dt_s) > KNOWN_CLAIM_MAX_FIX_AGE_S: + prev = state.multinode_tracks.get(f"mn-adsb-{hexn}") + prev_lat = prev.get("lat") if isinstance(prev, dict) else None + prev_lon = prev.get("lon") if isinstance(prev, dict) else None + prev_ts_ms = _num(prev.get("timestamp_ms"), 0) if isinstance(prev, dict) else 0 + if isinstance(prev_lat, (int, float)) and isinstance(prev_lon, (int, float)) and prev_ts_ms: + learned = track_filter.learned_velocity(f"mn-adsb-{hexn}") + if learned is not None: + seed_ve, seed_vn = float(learned[0]), float(learned[1]) + else: + seed_ve = _num(prev.get("vel_east")) + seed_vn = _num(prev.get("vel_north")) + coast_s = (newest_ts_ms - int(prev_ts_ms)) / 1000.0 + guess_lat, guess_lon = offset_latlon_m( + float(prev_lat), + float(prev_lon), + east_m=seed_ve * coast_s, + north_m=seed_vn * coast_s, + ) + # The velocity seed moves with the guess: seeding a position from + # the radar track and a heading from a stale transponder report + # would be two different aircraft's worth of prior. + vel_east, vel_north = seed_ve, seed_vn + seed_source = "kf" + # No prior solve leaves seed_source "fix" and today's dead-reckoned guess: + # a drifting prior still beats no prior, and the classification says so. return { "initial_guess": { @@ -264,6 +304,7 @@ def _build_solver_input(hexn: str, claims: dict[str, dict]) -> dict | None: "lon": guess_lon, "alt_km": _num(fix.get("alt_baro")) * FT_TO_M / 1000.0, }, + "seed_source": seed_source, "initial_velocity": { "vel_east_ms": vel_east, "vel_north_ms": vel_north, @@ -423,7 +464,13 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non "known_no_converge", s_in, result if isinstance(result, dict) else None, - extra={"known_lane": True, "label": "no_converge", "published": False, **epoch_meta}, + extra={ + "known_lane": True, + "label": "no_converge", + "published": False, + "seed_source": s_in.get("seed_source", "fix"), + **epoch_meta, + }, ) return @@ -463,7 +510,13 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non raw_lat=raw_lat, raw_lon=raw_lon, displacement_km=err_km, - extra={"known_lane": True, "label": label, "published": published, **epoch_meta}, + extra={ + "known_lane": True, + "label": label, + "published": published, + "seed_source": s_in.get("seed_source", "fix"), + **epoch_meta, + }, ) From 429c2526f22718155ad4a0dd434e314bbf5ab794 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:59:48 +0000 Subject: [PATCH 3/5] Expose the hold counters and a live A/B switch GET/PUT /api/test/known-hold sets KNOWN_HOLD_MAX_GAP_S on a running backend so the feature can be A/B'd without a redeploy; 0 turns path H off and clears the store, so "off" is genuinely the pre-feature behaviour rather than a hold that never matches. Admin-gated on put_simulation_config's precedent: it changes which detections leave the dark pool for every node at once. The solver-stats known_claims block gains hold_claims, hold_expired, hold_dropped_disagree and the store's current size, so the funnel is legible: a store that grows while claims does not is holds that never match. Co-Authored-By: Claude Fable 5.1 --- backend/routes/test.py | 45 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/backend/routes/test.py b/backend/routes/test.py index 174481df..5652b5e2 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -14,7 +14,7 @@ from core import state from core.task_registry import get_stale_tasks from core.users import require_admin -from services import dark_follow, track_filter +from services import dark_follow, known_claiming, track_filter from services.frame_processor import resolve_ground_truth_hex from services.geo import haversine_km from services.id_utils import is_transponder_hex, normalize_hex_key @@ -467,6 +467,36 @@ async def get_anomaly_log(): ) +# ── Known-track hold (path H) ───────────────────────────────────────────────── + + +@router.get("/api/test/known-hold") +async def get_known_hold(): + """Current hold window, in seconds of frame time (0 = feature off).""" + return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S} + + +@router.put("/api/test/known-hold") +async def put_known_hold(body: dict = Body(...), _admin=Depends(require_admin)): + """Set the hold window live, so the feature can be A/B'd on a running + backend without a redeploy. 0 turns path H off entirely — the store stops + being written as well, so "off" is the behaviour that predates the hold + rather than a hold that never matches. + + Admin-gated on the same precedent as put_simulation_config: this changes + which detections leave the dark pool for every node at once. + """ + v = body.get("max_gap_s") + if not isinstance(v, (int, float)) or isinstance(v, bool) or not (0 <= v <= 300): + raise HTTPException(400, detail="max_gap_s must be 0-300") + known_claiming.KNOWN_HOLD_MAX_GAP_S = float(v) + if v == 0: + # Off means off: a store left behind would come back the moment the + # window was reopened, holding tracks from before the experiment. + state.known_track_holds.clear() + return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S} + + # ── Simulation physics config ───────────────────────────────────────────────── @@ -1275,6 +1305,9 @@ def _solver_window_stats(minutes: float) -> dict: kc_visibility_rejects = state.known_claims_visibility_rejects kc_world_rejects = state.known_claims_world_rejects kc_errors = state.known_claims_errors + kh_claims = state.known_hold_claims + kh_expired = state.known_hold_expired + kh_disagree = state.known_hold_dropped_disagree # Same one-lock snapshot for the follow lane's funnel and the # per-reason ineligibility tally beside it: the two are only readable # against each other (see the dark_follow block below), so they must @@ -1442,6 +1475,16 @@ def _solver_window_stats(minutes: float) -> dict: "visibility_rejects": kc_visibility_rejects, "world_rejects": kc_world_rejects, "errors": kc_errors, + # Path H (services/known_claiming._claim_holds). claims is the + # detections held onto a hex after its transponder stopped + # explaining them; disagree the ghost-lock guard firing (a fresh + # fix contradicted the held track); holds the CURRENT size of the + # store, a gauge, summed over nodes — read beside claims, since a + # store that grows while claims does not is holds that never match. + "hold_claims": kh_claims, + "hold_expired": kh_expired, + "hold_dropped_disagree": kh_disagree, + "holds": sum(len(h) for h in list(state.known_track_holds.values())), }, # Dark published solves against the node pool their round had for the # same aircraft (see the pooled/shortfalls block above). pct is null From d5f6fd58996209c85b4acec44e761a781a49c305 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:59:48 +0000 Subject: [PATCH 4/5] Test the hold against the simulator, and document path H Ten tests: the tagged-then-silent claim, gap expiry, the ghost-lock guard, the hold beating dark follow to a detection, the zero-gap rollback lever, the known-lane stale-fix seed in all three of its cases, and two that go through retina_simulation rather than a hand-built frame. The physics check is empirical on purpose. d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz falls out of the bistatic definitions, but a sign convention flip anywhere between the generator and the propagation would double the error instead of cancelling it, so the test measures consecutive noise-free frames of the simulator's own moving aircraft and asserts the predicted rate agrees with the measured one in sign and magnitude. The integration test then runs one node for 30 s of tags and 90 s of silence with the simulator's clutter present, and asserts the link holds on >= 85 % of the frames the aircraft was detected in with zero clutter claims. Both seed the RNG: the simulator draws spawns, misses and noise from the global one, so an unseeded rate assertion measures the test run order. test_solver_stats' key-set assertions grow the four new counters. Co-Authored-By: Claude Fable 5.1 --- backend/tests/test_known_track_hold.py | 393 +++++++++++++++++++++++++ backend/tests/test_solver_stats.py | 12 + docs/solverflow.md | 35 +++ 3 files changed, 440 insertions(+) create mode 100644 backend/tests/test_known_track_hold.py diff --git a/backend/tests/test_known_track_hold.py b/backend/tests/test_known_track_hold.py new file mode 100644 index 00000000..135f3326 --- /dev/null +++ b/backend/tests/test_known_track_hold.py @@ -0,0 +1,393 @@ +"""Known-track HOLD (path H) — services/known_claiming._claim_holds, +state.known_track_holds, and known_lane's stale-fix seed. + +Same conventions as test_known_claiming.py: a real associator geometry +registered per test, ADS-B injected through state.adsb_aircraft, claiming +driven frame by frame. + +The requirement under test: once a node track is linked to an ADS-B hex the +link survives the transponder going quiet — subsequent detections in that +track stay claimed, and no other hex's dead-reckoned fix can peel them off. +""" + +import random +import time + +import pytest +from retina_analytics.association import predict_observation +from retina_simulation.world import NodeConfig as SimNodeConfig +from retina_simulation.world import SimulationWorld + +from config.constants import FT_TO_M +from core import state +from services import known_claiming as kc +from services.tasks import known_lane + +_NODE_CFG = { + "rx_lat": 34.85, + "rx_lon": -82.40, + "rx_alt_ft": 1000, + "tx_lat": 34.9412, + "tx_lon": -82.4103, + "tx_alt_ft": 2000, + "fc_hz": 183e6, + "beam_width_deg": 90, + "max_range_km": 60, + "beam_azimuth_deg": 45.0, +} +_NODE_ID = "test-known-hold" +_LAT, _LON = 34.88, -82.35 +_ALT_BARO_FT = 7000.0 / FT_TO_M +_HEX = "abc123" + + +@pytest.fixture(autouse=True) +def _clean(): + state.known_claims.clear() + state.known_track_holds.clear() + state.adsb_aircraft.clear() + state.multinode_tracks.clear() + yield + state.known_claims.clear() + state.known_track_holds.clear() + state.adsb_aircraft.clear() + state.multinode_tracks.clear() + + +def _register(node_id=_NODE_ID): + state.node_associator.register_node(node_id, _NODE_CFG) + return state.node_associator.node_geometries[node_id] + + +def _frame(ts_ms, delays, dopplers, adsb=None): + f = { + "timestamp": ts_ms, + "delay": list(delays), + "doppler": list(dopplers), + "snr": [20.0] * len(delays), + } + if adsb is not None: + f["adsb"] = adsb + return f + + +def _tag(lat=_LAT, lon=_LON, gs=0.0, track=0.0): + return {"hex": _HEX, "lat": lat, "lon": lon, "alt_baro": _ALT_BARO_FT, "gs": gs, "track": track} + + +def _pred(geo, lat=_LAT, lon=_LON, ve=0.0, vn=0.0): + return predict_observation(geo, lat, lon, _ALT_BARO_FT * FT_TO_M / 1000.0, ve, vn) + + +class TestHoldPath: + def test_tagged_frame_then_silence_keeps_the_claim(self): + """1. Path-1 claim on frame 1; frame 2 carries no tag and the cache is + empty — the detection is claimed anyway, marked hold.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + assert kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) == {0} + assert state.known_track_holds[_NODE_ID][_HEX]["delay_us"] == pytest.approx(d) + + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) + assert claimed == {0} + rec = state.known_claims[_HEX][-1] + assert rec["hold"] is True + assert rec["hold_gap_s"] == pytest.approx(1.0) + # The STORED fix, with its original epoch, so the silence is visible. + assert rec["adsb_fix"]["fix_ts_ms"] == ts + + def test_gap_beyond_the_window_expires_the_hold(self): + """2. A gap longer than KNOWN_HOLD_MAX_GAP_S drops the entry.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + before = state.known_hold_expired + + gap_ms = int((kc.KNOWN_HOLD_MAX_GAP_S + 2.0) * 1000) + assert kc.claim_known_targets(_NODE_ID, _frame(ts + gap_ms, [d], [f])) == set() + assert _HEX not in state.known_track_holds.get(_NODE_ID, {}) + assert state.known_hold_expired == before + 1 + + def test_fresh_fix_that_disagrees_drops_the_hold(self): + """3. The ghost-lock guard: a live transponder that puts the aircraft + somewhere else wins, and the hex falls through to path 2.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + before = state.known_hold_dropped_disagree + + # Fresh cached fix for the SAME hex, far from the held track. + moved_lat, moved_lon = 34.94, -82.28 + state.adsb_aircraft[_HEX] = { + "hex": _HEX, + "lat": moved_lat, + "lon": moved_lon, + "alt_baro": _ALT_BARO_FT, + "gs": 0, + "track": 0, + "last_seen_ms": ts + 1000, + } + d2, f2 = _pred(geo, lat=moved_lat, lon=moved_lon) + assert abs(d2 - d) > kc.KNOWN_HOLD_DELAY_GATE_US + kc.KNOWN_HOLD_DELAY_RATE_US_PER_S + + # The detection is where the HOLD predicts, not where the fix does. + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) + assert state.known_hold_dropped_disagree == before + 1 + assert claimed == set() # path 2's gate rejects it too — correctly + assert _HEX not in state.known_track_holds.get(_NODE_ID, {}) + + def test_hold_precedes_dark_follow(self, monkeypatch): + """4. A dark-follow target predicting the same detection does not get + it: path H claims first, and path 3 only ever sees the leftovers.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + + monkeypatch.setattr(kc.dark_follow, "mode", lambda: "binding") + monkeypatch.setattr( + kc.dark_follow, + "follow_targets", + lambda: [ + { + "key": "mn-dark-zzz", + "lat": _LAT, + "lon": _LON, + "alt_m": _ALT_BARO_FT * FT_TO_M, + "vel_east": 0.0, + "vel_north": 0.0, + "timestamp_ms": ts + 1000, + "world": None, + } + ], + ) + followed: set[int] = set() + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f]), follow_claimed=followed) + assert claimed == {0} + assert followed == set() + assert "mn-dark-zzz" not in state.known_claims + + def test_zero_gap_is_the_rollback_lever(self, monkeypatch): + """5. KNOWN_HOLD_MAX_GAP_S = 0 reproduces pre-feature behaviour: no + hold claim, and no store either.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + monkeypatch.setattr(kc, "KNOWN_HOLD_MAX_GAP_S", 0.0) + assert kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) == {0} + assert state.known_track_holds == {} + assert kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) == set() + assert len(state.known_claims[_HEX]) == 1 + + +class TestDelayRatePhysics: + def test_delay_rate_sign_against_the_simulator(self): + """The sign of d(delay)/dt = -doppler * 1e6 / fc, checked against the + simulator's own generator rather than the derivation: a convention flip + anywhere upstream would double the error instead of cancelling it.""" + # Seeded for the same reason the integration test is: the sample count + # this asserts over is the simulator's spawn draw. + random.seed(20260908) + world = SimulationWorld(center_lat=34.0, center_lon=-84.0) + node = SimNodeConfig( + node_id="sim-hold-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=90, + max_range_km=120, + ) + world.add_node(node) + for _ in range(20): + world.step(1.0, mode="adsb") + + # Noise-free consecutive observations of each aircraft, straight from + # the generator's own physics helpers. + from retina_simulation import world as wmod + + rx_alt_km = node.rx_alt_ft * 0.3048 / 1000.0 + tx_enu = wmod._lla_to_enu( + node.tx_lat, node.tx_lon, node.tx_alt_ft * 0.3048 / 1000.0, node.rx_lat, node.rx_lon, rx_alt_km + ) + + def obs(ac): + t_enu = wmod._lla_to_enu(ac.lat, ac.lon, ac.alt_km, node.rx_lat, node.rx_lon, rx_alt_km) + return ( + wmod._bistatic_delay(t_enu, tx_enu, (0.0, 0.0, 0.0)), + wmod._bistatic_doppler( + t_enu, (ac.vel_east, ac.vel_north, ac.vel_up), tx_enu, (0.0, 0.0, 0.0), node.fc_hz + ), + ) + + before = {id(ac): obs(ac) for ac in world.aircraft} + world.step(1.0, mode="adsb") + checked = 0 + for ac in world.aircraft: + if id(ac) not in before: + continue + d0, f0 = before[id(ac)] + d1, _f1 = obs(ac) + measured_rate = d1 - d0 # µs per 1 s step + predicted_rate = -f0 * 1.0e6 / node.fc_hz + if abs(measured_rate) < 0.05: + continue # too slow to have a sign worth asserting + assert measured_rate * predicted_rate > 0, "delay-rate sign disagrees with Doppler" + assert predicted_rate == pytest.approx(measured_rate, abs=0.15 + 0.2 * abs(measured_rate)) + checked += 1 + assert checked >= 3 + + +class TestSimulationIntegration: + def test_link_survives_the_transponder_going_quiet(self): # noqa: C901 + """6. One node, the simulator's own aircraft and clutter: 30 s of tags, + then 90 s of silence. The hold keeps the aircraft's detections claimed + and never claims clutter.""" + # Seeded: the simulator draws spawn poses, misses and noise from the + # global RNG, which any earlier test in the run may have advanced. A + # rate assertion has to be measured against one fixed scenario or it is + # measuring the run order. + random.seed(20260908) + world = SimulationWorld(center_lat=34.85, center_lon=-82.40) + node_id = "sim-hold-node" + world.add_node( + SimNodeConfig( + node_id=node_id, + rx_lat=_NODE_CFG["rx_lat"], + rx_lon=_NODE_CFG["rx_lon"], + tx_lat=_NODE_CFG["tx_lat"], + tx_lon=_NODE_CFG["tx_lon"], + beam_width_deg=120, + max_range_km=120, + ) + ) + state.node_associator.register_node( + node_id, + { + **_NODE_CFG, + "beam_width_deg": 120, + "max_range_km": 120, + "fc_hz": world.nodes[node_id].fc_hz, + "beam_azimuth_deg": world.nodes[node_id].beam_azimuth_deg, + }, + ) + for _ in range(30): + world.step(1.0, mode="adsb") + # One transponder-carrying aircraft the node can see, and only it. + target = None + for ac in world.aircraft: + if ac.has_adsb and world._aircraft_in_detection_cone(ac, world.nodes[node_id]): + target = ac + break + if target is None: + pytest.skip("no ADS-B aircraft in this node's cone") + for ac in world.aircraft: + if ac is not target: + ac.has_adsb = False + + ts = int(time.time() * 1000) + # 30 s with tags. + for k in range(30): + world.step(1.0, mode="adsb") + frame = world.generate_detections_for_node(node_id, ts + k * 1000) + kc.claim_known_targets(node_id, frame) + assert state.known_track_holds.get(node_id, {}).get(target.adsb_hex) is not None + + # 90 s of silence: no tag, no cache updates. + target.has_adsb = False + state.adsb_aircraft.clear() + detected = 0 + held = 0 + clutter_claims = 0 + for k in range(30, 120): + world.step(1.0, mode="adsb") + ts_ms = ts + k * 1000 + frame = world.generate_detections_for_node(node_id, ts_ms) + # Which detection (if any) is the target's, from truth. + truth_i = _truth_index(world, node_id, target, frame) + before = len(state.known_claims.get(target.adsb_hex, ())) + claimed = kc.claim_known_targets(node_id, frame) + after = len(state.known_claims.get(target.adsb_hex, ())) + if truth_i is not None: + detected += 1 + if truth_i in claimed: + held += 1 + if after > before and truth_i not in claimed: + clutter_claims += 1 + + assert detected >= 20, f"target barely detected ({detected} frames)" + assert held / detected >= 0.85, f"held {held}/{detected}" + assert clutter_claims == 0 + + +def _truth_index(world, node_id, target, frame): + """Index of the target's own detection in this frame, from the simulator's + noise-free geometry — the frame carries no identity once has_adsb is off.""" + from retina_simulation import world as wmod + + node = world.nodes[node_id] + if not world._aircraft_in_detection_cone(target, node): + return None + rx_alt_km = node.rx_alt_ft * 0.3048 / 1000.0 + tx_enu = wmod._lla_to_enu( + node.tx_lat, node.tx_lon, node.tx_alt_ft * 0.3048 / 1000.0, node.rx_lat, node.rx_lon, rx_alt_km + ) + t_enu = wmod._lla_to_enu(target.lat, target.lon, target.alt_km, node.rx_lat, node.rx_lon, rx_alt_km) + d = wmod._bistatic_delay(t_enu, tx_enu, (0.0, 0.0, 0.0)) + f = wmod._bistatic_doppler( + t_enu, (target.vel_east, target.vel_north, target.vel_up), tx_enu, (0.0, 0.0, 0.0), node.fc_hz + ) + best, best_i = None, None + for i, (dd, ff) in enumerate(zip(frame["delay"], frame["doppler"])): + score = abs(dd - d) / 1.0 + abs(ff - f) / 15.0 + if abs(dd - d) < 1.0 and abs(ff - f) < 15.0 and (best is None or score < best): + best, best_i = score, i + return best_i + + +class TestKnownLaneStaleSeed: + def _claims(self, fix_age_s, ts_ms): + fix = { + "lat": _LAT, + "lon": _LON, + "alt_baro": _ALT_BARO_FT, + "gs": 0.0, + "track": 0.0, + "fix_ts_ms": ts_ms - int(fix_age_s * 1000), + } + return { + f"n{i}": {"node_id": f"n{i}", "delay_us": 100.0 + i, "doppler_hz": 5.0, "ts_ms": ts_ms, "adsb_fix": fix} + for i in range(2) + } + + def test_fresh_fix_seeds_from_the_fix(self): + ts = int(time.time() * 1000) + s_in = known_lane._build_solver_input(_HEX, self._claims(5.0, ts)) + assert s_in["seed_source"] == "fix" + assert s_in["initial_guess"]["lat"] == pytest.approx(_LAT) + + def test_stale_fix_seeds_from_the_lanes_own_solve(self): + ts = int(time.time() * 1000) + state.multinode_tracks[f"mn-adsb-{_HEX}"] = { + "lat": 34.90, + "lon": -82.30, + "vel_east": 100.0, + "vel_north": 0.0, + "timestamp_ms": ts - 2000, + } + s_in = known_lane._build_solver_input(_HEX, self._claims(600.0, ts)) + assert s_in["seed_source"] == "kf" + assert s_in["initial_guess"]["lat"] == pytest.approx(34.90, abs=1e-4) + # Coasted 2 s east at 100 m/s from the previous solve. + assert s_in["initial_guess"]["lon"] > -82.30 + # Altitude still comes from the fix. + assert s_in["initial_guess"]["alt_km"] == pytest.approx(_ALT_BARO_FT * FT_TO_M / 1000.0) + assert s_in["initial_velocity"]["vel_east_ms"] == pytest.approx(100.0) + + def test_stale_fix_without_a_prior_solve_falls_back(self): + ts = int(time.time() * 1000) + s_in = known_lane._build_solver_input(_HEX, self._claims(600.0, ts)) + assert s_in["seed_source"] == "fix" diff --git a/backend/tests/test_solver_stats.py b/backend/tests/test_solver_stats.py index 19f38c22..3e9d9814 100644 --- a/backend/tests/test_solver_stats.py +++ b/backend/tests/test_solver_stats.py @@ -446,6 +446,10 @@ def test_known_claims_reflects_the_claiming_counters(self): "visibility_rejects": 6, "world_rejects": 3, "errors": 1, + "hold_claims": 0, + "hold_expired": 0, + "hold_dropped_disagree": 0, + "holds": 0, } def test_both_blocks_zero_on_a_fresh_process(self): @@ -459,6 +463,10 @@ def test_both_blocks_zero_on_a_fresh_process(self): "visibility_rejects": 0, "world_rejects": 0, "errors": 0, + "hold_claims": 0, + "hold_expired": 0, + "hold_dropped_disagree": 0, + "holds": 0, } def test_lane_counters_absent_from_state_read_as_zero(self, monkeypatch): @@ -671,6 +679,10 @@ def test_known_lane_and_known_claims_blocks_present(self): "visibility_rejects", "world_rejects", "errors", + "hold_claims", + "hold_expired", + "hold_dropped_disagree", + "holds", } def test_minutes_clamp_low(self): diff --git a/docs/solverflow.md b/docs/solverflow.md index 23549da1..f3fa4b5a 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -143,6 +143,27 @@ load-bearing, not incidental: claiming (2.3) runs **before** ADS-B seeding and both run **before** the tracker (2.5) so that, in `binding` mode, a claimed detection never reaches the dark-lane tracker or association at all — see the ordering comment at the head of `process_one_frame`'s claiming step. + +**Path H (the hold).** Paths 1 and 2 re-ask every frame whether a transponder +fix explains a detection, so when the tags stop and the cached fix ages past +`KNOWN_CLAIM_MAX_FIX_AGE_S` an aircraft that has done nothing unusual falls +into the dark pool and comes back as a freshly-minted `mn-dark-*` ghost beside +itself. A claim is evidence on its own terms — this node's echo of this hex +sat at that (delay, Doppler) — so `state.known_track_holds` keeps the last two +samples per (node, hex) and path H predicts the next frame from them, delay +propagated from the measured Doppler (`d(delay_us)/dt = -doppler_hz * 1e6 / +fc_hz`). It runs **after path 1 and before path 2**, which is the requirement +rather than an implementation detail: a linked track must not be peelable to +another hex's dead-reckoned fix. There is no maximum hold duration — as long +as the track keeps matching, it stays linked — but a hold may never contradict +a live transponder: with a fresh fix on file the matched detection must pass +path 2's gate for that hex too, or the hold is dropped +(`known_hold_dropped_disagree`). Downstream, `known_lane._build_solver_input` +seeds a stale-fix solve from the lane's own last published `mn-adsb-` +solve instead of the drifting dead-reckoned fix (`seed_source: "kf"`), and +`aircraft_feed._claimed_single_node_entries` skips a singly-claimed hold whose +fix has aged out rather than drawing the aircraft at a position nothing +measured. Frame-level gates (A/B/C on TCP, plus the connected-node check on the v1 API) sit ahead of everything else; nothing downstream sees a frame that failed one of them. @@ -167,6 +188,7 @@ flowchart TD entry -->|"no"| dark0["untouched -> dark lane"]:::inert entry -->|"yes, but exception"| failopen["known_claims_errors
FAIL OPEN to dark lane"]:::inert entry -->|"yes"| path1["Path 1: node-tagged
frame['adsb'] index-aligned"] + entry --> pathH["Path H: Hungarian over
this node's HELD tracks
(state.known_track_holds)"] entry --> path2["Path 2: Hungarian over
cached ADS-B (state._adsb_for_seeding)"] path1 --> gP1{"dict, normalizable hex,
hex unclaimed, finite lat/lon"} @@ -185,6 +207,16 @@ flowchart TD gGate -->|"infeasible"| infeasible["cost = 1.0e6, excluded
by linear_sum_assignment"]:::inert gGate -->|"feasible"| claim2["claim recorded
(REPORTED ADS-B position,
not the DR position)"] + pathH --> gGap{"frame-time gap
<= KNOWN_HOLD_MAX_GAP_S 8s"} + gGap -->|"no"| expired["entry dropped,
known_hold_expired"]:::inert + gGap -->|"yes"| predH["predict from the node's OWN
last claim: delay += -doppler*1e6/fc * dt,
doppler += clipped rate * dt"] + predH --> gGateH{"gate = 1.5us + 1.0us/s * dt,
20Hz + 10Hz/s * dt"} + gGateH -->|"infeasible"| infeasible + gGateH -->|"feasible"| gAgree{"fresh cached fix for this hex?
must ALSO pass path 2's gate"} + gAgree -->|"disagrees"| dropH["hold dropped,
known_hold_dropped_disagree;
hex falls through to path 2"]:::inert + gAgree -->|"agrees, or fix stale/absent"| claimH["claim recorded, hold=True,
hold_gap_s, STORED fix
(original fix_ts_ms)"] + + claimH --> contest claim1 --> contest{"Contention check
vs claim_eligible dark tracks
(n_nodes>=3 OR solve_count>=2)"} claim2 --> contest contest -->|"residual within gate"| contested["flagged + counted,
dark track kept"] @@ -286,6 +318,9 @@ LM's SNR weighting maps to a uniform weight of 1.0. | `CLAIM_MAX_DR_AGE_S` (contention DR window) | 30.0 s | `association.py` | | `CLAIM_ELIGIBLE_MIN_N_NODES` / `MIN_SOLVE_COUNT` | 3 / 2 | `association.py` | | `KNOWN_CLAIMS_PER_HEX_MAX` | 64 | `core/state.py` | +| `KNOWN_HOLD_MAX_GAP_S` (path H window; **0 = feature off**, live-settable via `PUT /api/test/known-hold`) | 8.0 s of frame time | `known_claiming.py` | +| Path H gates: `KNOWN_HOLD_DELAY_GATE_US` + `KNOWN_HOLD_DELAY_RATE_US_PER_S` * dt / `KNOWN_HOLD_DOPPLER_GATE_HZ` + `KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S` * dt | 1.5 us + 1.0 us/s / 20 Hz + 10 Hz/s | `known_claiming.py` | +| `KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S` / `KNOWN_HOLD_RATE_MAX_SPAN_S` | 15 Hz/s / 5.0 s | `known_claiming.py` | | `_PASS_MIN_INTERVAL_S` | 2.0 s | `services/tasks/known_lane.py` | | `_CLAIM_MAX_AGE_S` / `_CLAIM_SPREAD_S` | 45.0 s / 5.0 s | `known_lane.py` | | `_ATTEMPT_TTL_S` | 600 s | `known_lane.py` | From 459569eb1d80a6287ee2eff11f89fa15daea5175 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 07:08:30 +0000 Subject: [PATCH 5/5] Carry a live, agreeing fix on a hold claim and refresh the store with it Path H outranks path 2, so on a node without tags every claim after the first is a hold claim. A hold claim always carried the STORED fix, which meant the entry's fix never refreshed while the transponder was live: after 45 s the known lane would read a live aircraft as silent and seed from its own filter, and the single-node display would drop it as stale. When the consistency check finds a fresh fix that agrees, the claim now carries that fix (the same record path 2 builds) and refreshes the hold with it; only a genuinely silent hex keeps the original fix and its epoch. Co-Authored-By: Claude Fable 5.1 --- backend/services/known_claiming.py | 58 ++++++++++++++++---------- backend/tests/test_known_track_hold.py | 28 +++++++++++++ 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/backend/services/known_claiming.py b/backend/services/known_claiming.py index 7d6a3811..aaf696d7 100644 --- a/backend/services/known_claiming.py +++ b/backend/services/known_claiming.py @@ -481,9 +481,27 @@ def _hold_predict(entry: dict, fc_hz: float, frame_ts_s: float) -> tuple[float, ) -def _fresh_fix_prediction(hexn: str, geo, frame_ts_s: float, node_world: str) -> tuple[float, float, float] | None: - """Path 2's own prediction for one hex, or None when no fresh usable fix - exists. The consistency rule's reference — see _claim_holds.""" +def _fix_record(st: dict) -> dict: + """The REPORTED fix a claim carries, built from one cached ADS-B state — + same rule as associate_detections_to_adsb, so a claim and a node tag for + one aircraft carry the same position and downstream consumers need not + know which path produced it.""" + return { + "lat": st["lat"], + "lon": st["lon"], + "alt_baro": st.get("alt_baro"), + "gs": st.get("gs"), + "track": st.get("track"), + "fix_ts_ms": st.get("timestamp_ms", 0), + } + + +def _fresh_fix_prediction( + hexn: str, geo, frame_ts_s: float, node_world: str +) -> tuple[float, float, float, dict] | None: + """Path 2's own prediction for one hex plus the fix record it would carry, + or None when no fresh usable fix exists. The consistency rule's + reference — see _claim_holds.""" st = state._adsb_for_seeding().get(hexn) if st is None: return None @@ -507,7 +525,7 @@ def _fresh_fix_prediction(hexn: str, geo, frame_ts_s: float, node_world: str) -> st.get("vel_east", 0.0), st.get("vel_north", 0.0), ) - return pred_d, pred_f, _gate_scale(age_s) + return pred_d, pred_f, _gate_scale(age_s), _fix_record(st) def _claim_holds( @@ -588,8 +606,9 @@ def _claim_holds( i = free[r] hexn, e, pred_d, pred_f, _d_gate, _f_gate, dt = cands[c] ref = _fresh_fix_prediction(hexn, geo, frame_ts_s, node_world) + extra = {"hold": True, "hold_gap_s": round(dt, 3)} if ref is not None: - ref_d, ref_f, scale = ref + ref_d, ref_f, scale, fresh_fix = ref if ( abs(ref_d - float(delays[i])) > KNOWN_CLAIM_DELAY_GATE_US * scale or abs(ref_f - float(dopplers[i])) > KNOWN_CLAIM_DOPPLER_GATE_HZ * scale @@ -597,14 +616,23 @@ def _claim_holds( holds.pop(hexn, None) state.bump_counter("known_hold_dropped_disagree") continue - fix = e.get("fix") + # The transponder is live and agrees, so the claim carries THAT + # fix, exactly as path 2 would have — and refreshes the hold with + # it (see the caller). Without this, path H outranking path 2 + # every frame would freeze the entry's fix at the first claim on + # a node without tags, and the lane would read a live aircraft + # as 45 s silent. + fix = fresh_fix + extra["fix_refreshed"] = True + else: + fix = e.get("fix") if not isinstance(fix, dict): # A hold with no fix behind it has nothing to seed the known lane # with, and every reader of a claim keys on adsb_fix. Cannot # happen from the paths above; dropped rather than published as a # half-claim. continue - out.append((i, hexn, fix, pred_d, pred_f, {"hold": True, "hold_gap_s": round(dt, 3)})) + out.append((i, hexn, fix, pred_d, pred_f, extra)) state.bump_counter("known_hold_claims") return out @@ -836,19 +864,7 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No ( i, hexn, - { - # REPORTED fix, not the dead-reckoned one — same - # rule as associate_detections_to_adsb, so a claim - # and a node tag for one aircraft carry the same - # position and downstream consumers need not know - # which path produced it. - "lat": st["lat"], - "lon": st["lon"], - "alt_baro": st.get("alt_baro"), - "gs": st.get("gs"), - "track": st.get("track"), - "fix_ts_ms": st.get("timestamp_ms", 0), - }, + _fix_record(st), pred_d, pred_f, {}, @@ -898,7 +914,7 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No d_meas, f_meas, ts_ms, - None if extra.get("hold") else fix, + None if extra.get("hold") and not extra.get("fix_refreshed") else fix, node_world_tag, bool(extra.get("hold")), ) diff --git a/backend/tests/test_known_track_hold.py b/backend/tests/test_known_track_hold.py index 135f3326..01d241e4 100644 --- a/backend/tests/test_known_track_hold.py +++ b/backend/tests/test_known_track_hold.py @@ -97,6 +97,34 @@ def test_tagged_frame_then_silence_keeps_the_claim(self): # The STORED fix, with its original epoch, so the silence is visible. assert rec["adsb_fix"]["fix_ts_ms"] == ts + def test_fresh_fix_that_agrees_refreshes_the_hold(self): + """A live transponder that AGREES with the held track is carried by + the hold claim and refreshes the store: on a node without tags path H + outranks path 2 every frame, and without this the entry's fix would + freeze at the first claim and read a live aircraft as silent.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + state.adsb_aircraft[_HEX] = { + "hex": _HEX, + "lat": _LAT, + "lon": _LON, + "alt_baro": _ALT_BARO_FT, + "gs": 0, + "track": 0, + "timestamp_ms": ts + 1000, + "last_seen_ms": ts + 1000, + } + before = state.known_hold_dropped_disagree + assert kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) == {0} + assert state.known_hold_dropped_disagree == before + rec = state.known_claims[_HEX][-1] + assert rec["hold"] is True + assert rec["fix_refreshed"] is True + assert rec["adsb_fix"]["fix_ts_ms"] == ts + 1000 + assert state.known_track_holds[_NODE_ID][_HEX]["fix"]["fix_ts_ms"] == ts + 1000 + def test_gap_beyond_the_window_expires_the_hold(self): """2. A gap longer than KNOWN_HOLD_MAX_GAP_S drops the entry.""" geo = _register()