From 8aac47c2051c4657df5a4a90a55c7de36fc0adbf Mon Sep 17 00:00:00 2001 From: jehanazad Date: Thu, 27 Aug 2026 04:15:56 +0000 Subject: [PATCH 1/2] Wire the world gate into ADS-B seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming's world gate closed one consumer of the mixed sim/real ADS-B cache; this closes the other two. The associator's seed round gets the node-world resolver (retina-analytics #23's node_world_provider) so a tracklet's tag is never verified against a state from the other world, and the frame processor's auto-tag pass — a cache-wide assignment for a node with no receiver, the same decoy exposure claiming had — filters the snapshot to the node's own world before the node-agnostic lib call. node_world moves to core/state.py as the single authority: claiming, the seed gate and the auto-tag filter all key on one resolver, where two could disagree and let one consumer accept what another rejects. The lib's adsb_seed_world_rejects counter joins the adsb_seed block of /api/radar/association/status (getattr with a zero default, so an old lib pin reads as zero rather than breaking the route). Submodule bump to the retina-analytics commit that carries the provider hook. Co-Authored-By: Claude Fable 5 --- backend/core/state.py | 25 ++++++++++++ backend/routes/analytics.py | 1 + backend/services/frame_processor.py | 14 +++++-- backend/services/known_claiming.py | 20 +-------- backend/tests/test_adsb_seed_backend.py | 54 +++++++++++++++++++++++++ backend/tests/test_known_claiming.py | 8 ++-- libs/retina-analytics | 2 +- 7 files changed, 97 insertions(+), 27 deletions(-) diff --git a/backend/core/state.py b/backend/core/state.py index e75c3286..525d5622 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -183,6 +183,27 @@ def adsb_derived_fields(rec: dict) -> dict: } +def node_world(node_id: str) -> str: + """Which world this node's echoes come from: "sim" for synthetic/test + nodes, "real" for hardware. + + The single authority for the question — known_claiming's world gate, the + associator's seed-verification gate (node_world_provider below) and the + frame processor's auto-tag filter all key on it, and two resolvers that + could disagree would let one consumer accept what another rejects. The + CONFIG handshake's verdict (which honours the node's own is_synthetic + claim) wins when the node is registered; a node that never completed the + TCP handshake — HTTP ingest, tests — falls back to the same prefix rule + the handshake defaults to.""" + info = connected_nodes.get(node_id) + if info is not None and "is_synthetic" in info: + return "sim" if info["is_synthetic"] else "real" + # Function-local: tcp_handler imports this module at import time. + from services.tcp_handler import is_synthetic_node + + return "sim" if is_synthetic_node(node_id) else "real" + + def _adsb_for_seeding() -> dict[str, dict]: """Unlocked snapshot of currently-live ADS-B fixes, in the seeding provider contract InterNodeAssociator documents on adsb_provider. @@ -258,6 +279,10 @@ def _adsb_for_seeding() -> dict[str, dict]: max_pairs_per_round=ASSOC_MAX_PAIRS_PER_ROUND, adsb_seed_mode=ADSB_SEED_MODE, adsb_provider=_adsb_for_seeding, + # The provider's snapshot mixes worlds (see node_world above); the + # associator's seed round refuses to verify a node's tag against a state + # from the other world. Counted lib-side as adsb_seed_world_rejects. + node_world_provider=node_world, ) # ── Per-node tracker pipelines (lazy-created per connecting node) ───────────── diff --git a/backend/routes/analytics.py b/backend/routes/analytics.py index 7b247f19..421795d8 100644 --- a/backend/routes/analytics.py +++ b/backend/routes/analytics.py @@ -172,6 +172,7 @@ async def association_status(): "tagged": getattr(_a, "adsb_tracklets_tagged", 0), "no_state": getattr(_a, "adsb_seed_no_state", 0), "gate_rejects": getattr(_a, "adsb_seed_gate_rejects", 0), + "world_rejects": getattr(_a, "adsb_seed_world_rejects", 0), "tracklets_excluded": getattr(_a, "adsb_tracklets_excluded", 0), "inputs_emitted": getattr(_a, "adsb_inputs_emitted", 0), }, diff --git a/backend/services/frame_processor.py b/backend/services/frame_processor.py index e648d873..7cb65fb2 100644 --- a/backend/services/frame_processor.py +++ b/backend/services/frame_processor.py @@ -25,7 +25,7 @@ valid_latlon, ) from services.id_utils import normalize_hex_key as _normalize_hex_key -from services.known_claiming import _node_world, claim_known_targets, strip_claimed_detections +from services.known_claiming import claim_known_targets, strip_claimed_detections from services.storage import archive_detections # ── Archive batching ────────────────────────────────────────────────────────── @@ -370,11 +370,19 @@ def process_one_frame(node_id: str, frame: dict, default_pipeline: PassiveRadarP if state.ADSB_SEED_MODE == "active" and not _pframe.get("adsb"): _geo = state.node_associator.node_geometries.get(node_id) if _geo is not None: + # Own-world states only: this is a cache-wide assignment for a + # node with no receiver, so every other-world entry is a decoy + # its detections can bind to on a delay/Doppler coincidence — + # the same failure known_claiming's world gate closes. The lib + # call is node-agnostic, so the filter lives here with the node + # context. Untagged states pass, matching the gates elsewhere. + _nw = state.node_world(node_id) + _states = {h: s for h, s in state._adsb_for_seeding().items() if s.get("world") in (None, _nw)} _tags = associate_detections_to_adsb( _geo, _pframe.get("delay", []), _pframe.get("doppler", []), - state._adsb_for_seeding(), + _states, _pframe.get("timestamp", 0), ) if _tags is not None: @@ -445,7 +453,7 @@ def process_one_frame(node_id: str, frame: dict, default_pipeline: PassiveRadarP _ts_ms = int(time.time() * 1000) # Same world stamp the TCP fast-path applies — a blah2 node's list is # real traffic, a test frame's is simulated; claiming keys on it. - _world = _node_world(node_id) + _world = state.node_world(node_id) for _ae in _adsb_list: if not isinstance(_ae, dict): continue diff --git a/backend/services/known_claiming.py b/backend/services/known_claiming.py index 8b7ab68f..684d9a16 100644 --- a/backend/services/known_claiming.py +++ b/backend/services/known_claiming.py @@ -120,24 +120,6 @@ def _reset_for_tests() -> None: _node_bias_unavailable = False -def _node_world(node_id: str) -> str: - """Which world this node's echoes come from: "sim" for synthetic/test - nodes, "real" for hardware. - - The CONFIG handshake's verdict (which honours the node's own is_synthetic - claim) wins when the node is registered; a node that never completed the - TCP handshake — HTTP ingest, tests driving process_one_frame directly — - falls back to the same prefix rule the handshake defaults to.""" - info = state.connected_nodes.get(node_id) - if info is not None and "is_synthetic" in info: - return "sim" if info["is_synthetic"] else "real" - # Function-local for the same reason analytics_refresh imports it this - # way: tcp_handler sits above the frame path in the import graph. - from services.tcp_handler import is_synthetic_node - - return "sim" if is_synthetic_node(node_id) else "real" - - def _gate_scale(age_s: float) -> float: """Gate allowance multiplier for a fix age: 1.0 fresh, 2.0 at the age cap. @@ -290,7 +272,7 @@ def claim_known_targets(node_id: str, frame: dict) -> set[int]: cands = [] visibility_rejects = 0 world_rejects = 0 - node_world = _node_world(node_id) + node_world = state.node_world(node_id) # Prescreen constants, hoisted: geo is fixed for the whole loop, and # these cost a haversine and a cos each. See the prescreen below. screen_r0_km = geo.effective_radius_km * _SCREEN_MARGIN diff --git a/backend/tests/test_adsb_seed_backend.py b/backend/tests/test_adsb_seed_backend.py index b918fbb3..cba3bb36 100644 --- a/backend/tests/test_adsb_seed_backend.py +++ b/backend/tests/test_adsb_seed_backend.py @@ -446,6 +446,51 @@ def test_frame_without_adsb_gains_index_aligned_list_in_active_mode(self, monkey assert frame["adsb"][0]["hex"] == "abc123" assert frame["adsb"][1] is None + def test_cross_world_state_is_not_attached(self, monkeypatch): + """The auto-tag pass is a cache-wide assignment for a node with no + receiver, so an other-world entry is a decoy its detections can bind + to on a delay/Doppler coincidence — filtered before the lib call, + which is node-agnostic.""" + node_id = "test-predictive-world" # test-* prefix → sim world + geo = self._register(node_id) + + lat, lon, alt_km, ve, vn = 34.88, -82.35, 7.0, 180.0, -90.0 + d0, f0 = predict_observation(geo, lat, lon, alt_km, ve, vn) + frame = _make_frame() + frame["delay"] = [d0] + frame["doppler"] = [f0] + frame.pop("adsb", None) + + decoy = { + "hex": "a97cf2", + "lat": lat, + "lon": lon, + "alt_m": alt_km * 1000.0, + "vel_east": ve, + "vel_north": vn, + "timestamp_ms": frame["timestamp"], + "world": "real", + } + monkeypatch.setattr(state, "_adsb_for_seeding", lambda: {"a97cf2": decoy}) + monkeypatch.setattr(state, "ADSB_SEED_MODE", "active") + + process_one_frame(node_id, frame, PassiveRadarPipeline(DEFAULT_NODE_CONFIG)) + + assert "adsb" not in frame or frame["adsb"] is None + + # Same state tagged with the node's own world attaches — the filter + # removes decoys, not the capability. + own = dict(decoy, world="sim") + monkeypatch.setattr(state, "_adsb_for_seeding", lambda: {"a97cf2": own}) + frame2 = _make_frame() + frame2["delay"] = [d0] + frame2["doppler"] = [f0] + frame2.pop("adsb", None) + process_one_frame(node_id, frame2, PassiveRadarPipeline(DEFAULT_NODE_CONFIG)) + + assert frame2["adsb"] is not None + assert frame2["adsb"][0]["hex"] == "a97cf2" + def test_existing_adsb_list_never_overwritten(self, monkeypatch): node_id = "test-predictive-existing" self._register(node_id) @@ -499,6 +544,7 @@ def test_association_status_carries_the_adsb_seed_block(self): "tagged", "no_state", "gate_rejects", + "world_rejects", "tracklets_excluded", "inputs_emitted", } @@ -551,3 +597,11 @@ def test_frame_processor_writer_stamps_by_node_class(self): } process_one_frame("blah2-hw-node", frame, PassiveRadarPipeline(DEFAULT_NODE_CONFIG)) assert state.adsb_aircraft["wrld04"]["world"] == "real" + + +class TestSeedWorldWiring: + def test_associator_gets_the_state_world_resolver(self): + """One authority for the world question: the associator's seed gate + must consult the same resolver claiming and the auto-tag filter use, + or one consumer accepts what another rejects.""" + assert state.node_associator.node_world_provider is state.node_world diff --git a/backend/tests/test_known_claiming.py b/backend/tests/test_known_claiming.py index 3d68361c..c99d4ff2 100644 --- a/backend/tests/test_known_claiming.py +++ b/backend/tests/test_known_claiming.py @@ -831,16 +831,16 @@ def test_node_tag_is_not_world_gated(self): def test_handshake_verdict_beats_the_prefix_rule(self): """A node whose CONFIG declared is_synthetic=True is a sim node even without a synthetic id prefix — the handshake honours the node's own - claim, and _node_world must agree with it.""" + claim, and state.node_world must agree with it.""" node_id = "oddly-named-sim-node" with state.connected_nodes_lock: state.connected_nodes[node_id] = {"is_synthetic": True} try: - assert kc._node_world(node_id) == "sim" + assert state.node_world(node_id) == "sim" finally: with state.connected_nodes_lock: state.connected_nodes.pop(node_id, None) def test_unregistered_node_falls_back_to_the_prefix_rule(self): - assert kc._node_world("synth-GVL-0001") == "sim" - assert kc._node_world("radar3-retnode") == "real" + assert state.node_world("synth-GVL-0001") == "sim" + assert state.node_world("radar3-retnode") == "real" diff --git a/libs/retina-analytics b/libs/retina-analytics index c87b1a45..7495ce45 160000 --- a/libs/retina-analytics +++ b/libs/retina-analytics @@ -1 +1 @@ -Subproject commit c87b1a458b05915992b98fa3bf4731640daafc48 +Subproject commit 7495ce45d0f3280f0a7d1c6eba4870e358ed1dbe From d556df36cdee1c5f323ec0ecaa2609651320295e Mon Sep 17 00:00:00 2001 From: jehanazad Date: Thu, 27 Aug 2026 04:41:11 +0000 Subject: [PATCH 2/2] Land the seeding world gate on main and pin merged submodules PR #268 merged into its stacked base branch after #267 had already merged, so the seeding wiring never reached main. This merges it in and bumps both submodule pins to the upstream merge commits: retina-analytics #23 (node_world_provider seed gate) and retina-simulation #12 (--real-adsb opt-in relay gate). Co-Authored-By: Claude Fable 5 --- libs/retina-analytics | 2 +- libs/retina-simulation | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/retina-analytics b/libs/retina-analytics index 7495ce45..14504176 160000 --- a/libs/retina-analytics +++ b/libs/retina-analytics @@ -1 +1 @@ -Subproject commit 7495ce45d0f3280f0a7d1c6eba4870e358ed1dbe +Subproject commit 145041767422723d89d98ec003f843347ddbb880 diff --git a/libs/retina-simulation b/libs/retina-simulation index 97d0753b..754cca11 160000 --- a/libs/retina-simulation +++ b/libs/retina-simulation @@ -1 +1 @@ -Subproject commit 97d0753bffafef5f857c3f1f64ef54305e2d2708 +Subproject commit 754cca114367ce4cc5479d56fbd15f155fb5654e