From b9fd39b5bc78099536007f13bf2b4e47dab0fad1 Mon Sep 17 00:00:00 2001 From: jehanazad Date: Wed, 26 Aug 2026 21:10:38 +0000 Subject: [PATCH] Reject non-transponder hexes from the ADS-B world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dark simulated aircraft has no transponder, but the fleet's 1 Hz ADS-B push substitutes the simulator object id (obj-NNNNN) for the missing hex, and both the sim ingest and the solver keying rule took any truthy string. Every dark solve then claimed against its own pseudo-transponder, keyed mn-adsb-obj-*, and the feed marked it adsb_assisted — measured live 2026-08-26 as 15008/15008 multinode samples ADS-B-assisted with the mn-dark-* store (violet lane, anchor honoring, proximity claiming) permanently empty. Three layers, one rule (id_utils.is_transponder_hex — six hex digits, tar1090's ~ prefix allowed for non-ICAO TIS-B): - /api/sim/adsb/push drops non-transponder hexes, reports rejected_hex in the response, and counts them (sim_adsb_push_rejected_hex) so an outdated fleet still pushing dark aircraft is visible. - multinode_key_decision refuses the mn-adsb-* branch for them, so a poisoned adsb_hex can no longer put a dark target in the ADS-B lane. The fleet-side fix (stop pushing dark aircraft at all) lands separately in retina-simulation; this side must hold regardless of fleet version. Co-Authored-By: Claude Fable 5 --- backend/core/state.py | 7 +++++ backend/routes/sim_ingest.py | 15 +++++++-- backend/services/id_utils.py | 17 +++++++++++ backend/services/tasks/solver.py | 9 ++++-- backend/tests/test_id_utils.py | 36 ++++++++++++++++++++++ backend/tests/test_sim_ingest.py | 47 +++++++++++++++++++++++++++++ backend/tests/test_solver_anchor.py | 24 +++++++++++++++ backend/vulture_whitelist.py | 4 +++ 8 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_id_utils.py diff --git a/backend/core/state.py b/backend/core/state.py index eac00f28..9016cc37 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -543,6 +543,11 @@ def _adsb_for_seeding() -> dict[str, dict]: # tracks anomalous — but a rising rate here still means association regressed. position_jump_events: int = 0 +# Sim ADS-B pushes dropped for a non-transponder hex (e.g. a simulator object +# id standing in for a transponder). A nonzero value means an outdated fleet +# is still pushing dark aircraft into the ADS-B path — see routes/sim_ingest. +sim_adsb_push_rejected_hex: int = 0 + # Solver end-to-end latency (seconds from queue submission to solve completion) solver_last_latency_s: float = 0.0 solver_total_latency_s: float = 0.0 @@ -641,6 +646,7 @@ def _reset_for_tests() -> None: global solver_fail_exception, solver_fail_unconverged, solver_fail_rms_delay global solver_fail_rms_doppler, solver_fail_beam, solver_fail_displacement global position_jump_events + global sim_adsb_push_rejected_hex global solver_last_latency_s, solver_total_latency_s, solver_total_solved global peak_connected_nodes @@ -725,6 +731,7 @@ def _reset_for_tests() -> None: solver_fail_exception = solver_fail_unconverged = solver_fail_rms_delay = 0 solver_fail_rms_doppler = solver_fail_beam = solver_fail_displacement = 0 position_jump_events = 0 + sim_adsb_push_rejected_hex = 0 solver_total_solved = 0 solver_last_latency_s = solver_total_latency_s = 0.0 peak_connected_nodes = 0 diff --git a/backend/routes/sim_ingest.py b/backend/routes/sim_ingest.py index 81a7c455..86674ee0 100644 --- a/backend/routes/sim_ingest.py +++ b/backend/routes/sim_ingest.py @@ -20,7 +20,7 @@ # consumers in routes.test. from routes.test import _verify_sim_key from services.geo import valid_latlon -from services.id_utils import normalize_hex_key +from services.id_utils import is_transponder_hex, normalize_hex_key router = APIRouter() @@ -122,10 +122,19 @@ async def sim_push_adsb_positions(body: dict = Body(...), _key=Depends(_verify_s raise HTTPException(status_code=400, detail="aircraft list required") updated = 0 + rejected = 0 for ac in aircraft_list: hex_code = normalize_hex_key(ac.get("hex") or "") if not hex_code: continue + # A dark object has no transponder, so nothing about it belongs in + # state.adsb_aircraft. Older simulators push every aircraft here with + # the object id standing in for the hex; accepting those minted a fake + # transponder per dark target, every dark solve then keyed mn-adsb-* + # and the dark lane was permanently empty. + if not is_transponder_hex(hex_code): + rejected += 1 + continue lat = ac.get("lat") lon = ac.get("lon") if not valid_latlon(lat, lon): @@ -148,5 +157,7 @@ async def sim_push_adsb_positions(body: dict = Body(...), _key=Depends(_verify_s if updated: state.aircraft_dirty = True + if rejected: + state.bump_counter("sim_adsb_push_rejected_hex", rejected) - return {"status": "ok", "updated": updated} + return {"status": "ok", "updated": updated, "rejected_hex": rejected} diff --git a/backend/services/id_utils.py b/backend/services/id_utils.py index 9ab35f2e..a8e4794f 100644 --- a/backend/services/id_utils.py +++ b/backend/services/id_utils.py @@ -1,6 +1,7 @@ """Identifier utilities shared across service modules.""" import hashlib +import re def multinode_hex_from_key(key: str) -> str: @@ -28,3 +29,19 @@ def normalize_hex_key(hex_code) -> str: uppercase key is a silent lookup miss, not an error. """ return str(hex_code or "").strip().lower() + + +_TRANSPONDER_HEX_RE = re.compile(r"~?[0-9a-f]{6}") + + +def is_transponder_hex(hex_code) -> bool: + """True if ``hex_code`` (already normalized) is a plausible transponder id. + + Six hex digits, with tar1090's ``~`` prefix allowed for non-ICAO TIS-B + addresses. Anything else — notably a simulator object id like + ``obj-01373`` — must never enter the ADS-B world: a non-transponder id in + ``state.adsb_aircraft`` (or in a solve's ``adsb_hex``) puts a dark target + in the ADS-B lane, which starves the mn-dark-* track store and with it the + entire dark-claiming path. + """ + return bool(_TRANSPONDER_HEX_RE.fullmatch(str(hex_code or ""))) diff --git a/backend/services/tasks/solver.py b/backend/services/tasks/solver.py index a798a624..8380cba7 100644 --- a/backend/services/tasks/solver.py +++ b/backend/services/tasks/solver.py @@ -26,7 +26,7 @@ # were consolidated into services.geo. from services.geo import bearing_deg, bistatic_differential_km, node_beam_params, offset_latlon_m from services.geo import haversine_km as _haversine_km -from services.id_utils import multinode_hex_from_key, normalize_hex_key +from services.id_utils import is_transponder_hex, multinode_hex_from_key, normalize_hex_key _N_SOLVER_WORKERS = int(os.getenv("SOLVER_WORKERS", "2")) @@ -650,7 +650,12 @@ def multinode_key_decision( associates to it above (by proximity, or by anchor once a claim forms), so it stays stable. """ - if adsb_hex: + # Transponder-shaped ids only. adsb_hex is whatever upstream association + # produced, and a non-transponder id here (a simulator object id, a claim + # against a poisoned adsb_aircraft entry) would put a dark target in the + # ADS-B lane — adsb_assisted=true on the feed, and the mn-dark-* store + # (so the anchor and proximity branches below) starved forever. + if adsb_hex and is_transponder_hex(adsb_hex): return f"mn-adsb-{adsb_hex}", "adsb" lat, lon = result["lat"], result["lon"] diff --git a/backend/tests/test_id_utils.py b/backend/tests/test_id_utils.py new file mode 100644 index 00000000..4786740c --- /dev/null +++ b/backend/tests/test_id_utils.py @@ -0,0 +1,36 @@ +"""services/id_utils.py — the transponder-hex shape rule. + +is_transponder_hex is the single gate that keeps non-transponder ids (above +all the simulator's ``obj-NNNNN`` object ids) out of the ADS-B world: it +guards the sim adsb push (routes/sim_ingest.py) and the mn-adsb-* keying rule +(services/tasks/solver.py:multinode_key_decision). Callers pass values that +already went through normalize_hex_key, so the rule is defined over stripped +lowercase input. +""" + +import pytest + +from services.id_utils import is_transponder_hex + + +class TestIsTransponderHex: + @pytest.mark.parametrize("value", ["a1b2c3", "abcdef", "000001", "~a1b2c3"]) + def test_transponder_shapes_pass(self, value): + assert is_transponder_hex(value) is True + + @pytest.mark.parametrize( + "value", + [ + "obj-01373", # the simulator object id that poisoned the lane + "", + None, + "a1b2c", # too short + "a1b2c3d", # too long + "A1B2C3", # not normalized — callers must normalize first + "g1b2c3", # not hex + "~~a1b2c3", + "mn-adsb-a1b2c3", + ], + ) + def test_everything_else_is_refused(self, value): + assert is_transponder_hex(value) is False diff --git a/backend/tests/test_sim_ingest.py b/backend/tests/test_sim_ingest.py index cf796d57..2e746147 100644 --- a/backend/tests/test_sim_ingest.py +++ b/backend/tests/test_sim_ingest.py @@ -240,3 +240,50 @@ def test_max_range_km_boundary_values_accepted(self, client): assert r.status_code == 200 r = client.put("/api/simulation/config", json={"max_range_km": 400}) assert r.status_code == 200 + + +class TestAdsbPush: + """The transponder-hex gate on /api/sim/adsb/push. + + Older fleets push every aircraft here with the object id standing in for + the hex (orchestrator._push_adsb_live). Accepting those minted a fake + transponder per dark target: every dark solve then claimed against it, + keyed mn-adsb-obj-*, and the dark lane (mn-dark-* store, violet icons, + the whole claiming path) was permanently empty — measured live 2026-08-26 + as 15008/15008 multinode samples adsb_assisted with zero dark tracks. + """ + + @pytest.fixture(autouse=True) + def _clean_adsb(self): + state.adsb_aircraft.clear() + yield + state.adsb_aircraft.clear() + + def _push(self, client, hex_code): + return client.post( + "/api/sim/adsb/push", + headers=_KEY, + json={ + "ts_ms": int(time.time() * 1000), + "aircraft": [{"hex": hex_code, "lat": 34.85, "lon": -82.4, "alt_baro": 31000}], + }, + ) + + def test_icao_hex_accepted(self, client): + r = self._push(client, "A1B2C3") + assert r.status_code == 200 + assert r.json() == {"status": "ok", "updated": 1, "rejected_hex": 0} + assert "a1b2c3" in state.adsb_aircraft + + def test_tisb_tilde_hex_accepted(self, client): + """tar1090's non-ICAO TIS-B addresses (~ prefix) are real transponder + traffic and must keep working.""" + r = self._push(client, "~a1b2c3") + assert r.json()["updated"] == 1 + assert "~a1b2c3" in state.adsb_aircraft + + def test_object_id_rejected(self, client): + r = self._push(client, "obj-01373") + assert r.status_code == 200 + assert r.json() == {"status": "ok", "updated": 0, "rejected_hex": 1} + assert state.adsb_aircraft == {} diff --git a/backend/tests/test_solver_anchor.py b/backend/tests/test_solver_anchor.py index ddfd9bf0..9d1201a2 100644 --- a/backend/tests/test_solver_anchor.py +++ b/backend/tests/test_solver_anchor.py @@ -137,6 +137,30 @@ def test_mints_a_new_key_with_no_anchor_and_no_claimant(self): assert how == "minted" assert key.startswith("mn-dark-1000-") + def test_non_transponder_adsb_hex_cannot_take_the_adsb_branch(self): + """A simulator object id (or any non-transponder string) reaching + adsb_hex must fall through to the dark branches — keying mn-adsb-obj-* + put dark targets in the ADS-B lane and starved mn-dark-* entirely + (observed live 2026-08-26).""" + tracks = {"mn-dark-1": _anchor_track()} + key, how = solver_mod.multinode_key_decision( + tracks, + {"lat": LAT, "lon": LON, "timestamp_ms": int(time.time() * 1000)}, + "obj-01373", + None, + ) + assert how == "proximity" + assert key == "mn-dark-1" + + def test_tisb_tilde_adsb_hex_still_takes_the_adsb_branch(self): + key, how = solver_mod.multinode_key_decision( + {}, + {"lat": LAT, "lon": LON, "timestamp_ms": 1000}, + "~abc123", + None, + ) + assert (key, how) == ("mn-adsb-~abc123", "adsb") + class TestProcessSolverItemAnchorHonoring: def setup_method(self): diff --git a/backend/vulture_whitelist.py b/backend/vulture_whitelist.py index 9db6e585..57f31b58 100644 --- a/backend/vulture_whitelist.py +++ b/backend/vulture_whitelist.py @@ -195,6 +195,10 @@ known_claims_bound known_claims_visibility_rejects +# Same string-keyed bump_counter shape, from routes/sim_ingest.py's +# transponder-hex gate on /api/sim/adsb/push. +sim_adsb_push_rejected_hex + # ── Framework attributes (previously CI --ignore-names) ─────────────────────── # Moved out of the vulture invocation so the reason lives with the name.