Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) ─────────────
Expand Down
1 change: 1 addition & 0 deletions backend/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
14 changes: 11 additions & 3 deletions backend/services/frame_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
20 changes: 1 addition & 19 deletions backend/services/known_claiming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions backend/tests/test_adsb_seed_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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
8 changes: 4 additions & 4 deletions backend/tests/test_known_claiming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion libs/retina-analytics
Loading