diff --git a/src/retina_analytics/association.py b/src/retina_analytics/association.py index 6b5752c..d98e756 100644 --- a/src/retina_analytics/association.py +++ b/src/retina_analytics/association.py @@ -929,6 +929,19 @@ def _has_receiver_position(config: dict) -> bool: return not (_coord(config, "rx_lat") == 0.0 and _coord(config, "rx_lon") == 0.0) +def _worlds_compatible(world_a, world_b) -> bool: + """Whether two world tags may be associated with each other. + + Fail-open in both directions, which is the rule the ADS-B seed gate already + applies: an unknown world on either side — no provider, a node the provider + does not know, an untagged state — is compatible with everything, and only + two *known* and different tags are refused. A tag that is present but empty + is not a world; it reads as unknown rather than as a third world that + matches nothing. + """ + return not world_a or not world_b or world_a == world_b + + def _merge_epochs_multi(histories: list[tuple[str, list[dict]]]) -> list: """N-node generalisation of _merge_epochs, same output shape. @@ -1230,6 +1243,15 @@ def __init__( # hex collision, a mis-tag) whatever its residuals say. Optional and # fail-open in both directions: no provider, or an unknown world on # either side, changes nothing. + # + # The same reasoning applies one level lower, to the overlap zones + # bottom-up pairing is drawn from: two nodes in different worlds can + # never see the same echo, so a grid between them only ever pairs a + # synthetic tracklet with a real one. On the test fleet that is 400 + # of 1653 zones (50 synthetic × 8 hardware nodes), 39 of them with a + # real overlap, and the real node ids duly turned up in a third of + # the synthetic dark solver records. register_node and + # rebuild_zones_for therefore skip cross-world pairs outright. self.node_world_provider = node_world_provider # Counters — plain += like the claiming ones above. self.adsb_seed_rounds: int = 0 @@ -1239,6 +1261,12 @@ def __init__( self.adsb_seed_world_rejects: int = 0 self.adsb_tracklets_excluded: int = 0 self.adsb_inputs_emitted: int = 0 + # Node pairs registration or a rebuild declined to build a grid for + # because the two nodes are in different worlds. Counted per pair + # considered, so it rises by O(N) on every registration in a mixed + # fleet — the useful reading is that it is non-zero at all, and + # against overlap_zones, how much of the pair space it is removing. + self.assoc_world_skipped_pairs: int = 0 def register_node(self, node_id: str, config: dict): """Register a node and pre-compute overlap zones with all existing nodes. @@ -1250,7 +1278,9 @@ def register_node(self, node_id: str, config: dict): as long as their geometry (RX/TX position) hasn't changed. A node whose config carries no receiver position is registered but takes - no part in overlap — see _has_receiver_position. + no part in overlap — see _has_receiver_position. A pair whose two nodes + are in known and different worlds gets no zone either — see + node_world_provider and _worlds_compatible. """ positioned = _has_receiver_position(config) rx_alt_km = (config.get("rx_alt_ft") or 0) * 0.3048 / 1000.0 @@ -1310,10 +1340,19 @@ def register_node(self, node_id: str, config: dict): # Pre-compute overlap zones with existing nodes (serialised to avoid # RuntimeError: dictionary changed size during iteration when multiple # nodes register concurrently from a thread-pool executor). + my_world = self._node_world(node_id) for existing_id, existing_geo in list(self.node_geometries.items()): if not self._is_positioned(existing_id): continue pair_key = tuple(sorted([node_id, existing_id])) + if not _worlds_compatible(my_world, self._node_world(existing_id)): + # Re-registration can be what moved this node between + # worlds, so drop rather than merely skip: a grid built + # while the pair was compatible must not survive the + # change that made it cross-world. + self._drop_pair(pair_key) + self.assoc_world_skipped_pairs += 1 + continue zone = compute_overlap_zone( geo if pair_key[0] == node_id else existing_geo, existing_geo if pair_key[0] == node_id else geo, @@ -1360,6 +1399,7 @@ def _reset_for_tests(self) -> None: "adsb_seed_world_rejects", "adsb_tracklets_excluded", "adsb_inputs_emitted", + "assoc_world_skipped_pairs", ): setattr(self, name, 0) @@ -1367,6 +1407,27 @@ def _is_positioned(self, node_id: str) -> bool: """Whether a registered node has a receiver position to pair against.""" return _has_receiver_position(self.node_configs.get(node_id, {})) + def _node_world(self, node_id: str): + """This node's world, or None when nothing can say. + + No provider means no world gate at all, which is what every caller + that never injects one gets. + """ + if self.node_world_provider is None: + return None + return self.node_world_provider(node_id) + + def _drop_pair(self, pair_key: tuple[str, str]) -> None: + """Remove one pair's overlap zone and both adjacency entries. + + Caller holds _register_lock. Both nodes stay registered and stay + paired with everyone else; only this edge goes. + """ + a_id, b_id = pair_key + self.overlap_zones.pop(pair_key, None) + self._neighbors.get(a_id, set()).discard(b_id) + self._neighbors.get(b_id, set()).discard(a_id) + def _drop_zones_for(self, node_id: str) -> int: """Remove every overlap zone and adjacency entry naming this node. @@ -1427,10 +1488,18 @@ def rebuild_zones_for(self, node_id: str) -> int: geo.fov = self.fov_provider(node_id) rebuilt = 0 with self._register_lock: + my_world = self._node_world(node_id) for other_id, other_geo in list(self.node_geometries.items()): if other_id == node_id or not self._is_positioned(other_id): continue pair_key = tuple(sorted([node_id, other_id])) + if not _worlds_compatible(my_world, self._node_world(other_id)): + # Dropped, not skipped: a rebuild is exactly where a node + # that has since changed world sheds the grids it built + # against the world it left. + self._drop_pair(pair_key) + self.assoc_world_skipped_pairs += 1 + continue a, b = (geo, other_geo) if pair_key[0] == node_id else (other_geo, geo) zone = compute_overlap_zone( a, @@ -1518,9 +1587,7 @@ def _adsb_seed_round( if st is None or st.get("lat") is None or st.get("lon") is None: self.adsb_seed_no_state += 1 continue - st_world = st.get("world") - node_world = world_of.get(nid) - if st_world is not None and node_world is not None and st_world != node_world: + if not _worlds_compatible(st.get("world"), world_of.get(nid)): self.adsb_seed_world_rejects += 1 continue dt = float(last["t_s"]) - st.get("timestamp_ms", 0) / 1000.0 diff --git a/tests/test_world_overlap_gate.py b/tests/test_world_overlap_gate.py new file mode 100644 index 0000000..9cf8694 --- /dev/null +++ b/tests/test_world_overlap_gate.py @@ -0,0 +1,122 @@ +"""Bottom-up pairing does not cross worlds. + +The ADS-B gate added a world check to seeding and claiming, but the overlap +zones pairing draws its candidates from were still built between every pair of +positioned nodes. A synthetic fleet sharing a footprint with real hardware — +the test droplet's 50 simulated nodes over the same city as 8 receivers — +therefore kept a grid for every sim/real pair, and real node ids turned up +inside synthetic dark solves. Two nodes in different worlds can never see the +same echo, so those grids have no true pairing to find. + +Same fail-open rule as the seed gate: only two known, different worlds are +refused. +""" + +from retina_analytics.association import InterNodeAssociator + +# Two receivers a few km apart on the same illuminator: a real, non-empty +# overlap, so "no zone" below is the world gate and not the geometry. +_CFG_A = dict(rx_lat=34.85, rx_lon=-82.40, tx_lat=34.90, tx_lon=-82.30, max_range_km=50, max_bistatic_range_km=50) +_CFG_B = dict(rx_lat=34.86, rx_lon=-82.36, tx_lat=34.90, tx_lon=-82.30, max_range_km=50, max_bistatic_range_km=50) + +_PAIR = ("a", "b") + + +def _register_pair(**kwargs) -> InterNodeAssociator: + assoc = InterNodeAssociator(grid_step_km=5.0, **kwargs) + assoc.register_node("a", dict(_CFG_A)) + assoc.register_node("b", dict(_CFG_B)) + return assoc + + +class TestOverlapZoneWorldGate: + def test_cross_world_pair_gets_no_zone(self): + worlds = {"a": "sim", "b": "real"} + assoc = _register_pair(node_world_provider=worlds.get) + + assert _PAIR not in assoc.overlap_zones + assert assoc.overlap_zones == {} + assert assoc._neighbors.get("a", set()) == set() + assert assoc._neighbors.get("b", set()) == set() + assert assoc.assoc_world_skipped_pairs == 1 + + def test_same_world_pair_is_unchanged(self): + assoc = _register_pair(node_world_provider=lambda nid: "sim") + + assert assoc.overlap_zones[_PAIR].delay_pairs + assert assoc._neighbors["a"] == {"b"} + assert assoc.assoc_world_skipped_pairs == 0 + + def test_one_untagged_node_still_pairs(self): + """Fail-open: an unknown world is compatible with every world, which is + what a node the provider has not seen yet has.""" + worlds = {"a": "real"} + assoc = _register_pair(node_world_provider=worlds.get) + + assert assoc.overlap_zones[_PAIR].delay_pairs + assert assoc._neighbors["a"] == {"b"} + assert assoc.assoc_world_skipped_pairs == 0 + + def test_an_empty_world_tag_is_not_a_world(self): + worlds = {"a": "", "b": "real"} + assoc = _register_pair(node_world_provider=worlds.get) + + assert assoc.overlap_zones[_PAIR].delay_pairs + assert assoc.assoc_world_skipped_pairs == 0 + + def test_no_provider_pairs_everything(self): + assoc = _register_pair() + + assert assoc.overlap_zones[_PAIR].delay_pairs + assert assoc._neighbors["a"] == {"b"} + assert assoc.assoc_world_skipped_pairs == 0 + + def test_a_node_that_changes_world_loses_its_zone_on_rebuild(self): + """The gate has to be able to take a grid away, not only decline to + build one: registration order and a late handshake both mean a pair can + be compatible when it is first built and cross-world afterwards.""" + worlds = {"a": "sim", "b": "sim"} + assoc = _register_pair(node_world_provider=worlds.get) + assert assoc.overlap_zones[_PAIR].delay_pairs + + worlds["b"] = "real" + rebuilt = assoc.rebuild_zones_for("b") + + assert rebuilt == 0 + assert _PAIR not in assoc.overlap_zones + assert assoc._neighbors.get("a", set()) == set() + assert assoc.assoc_world_skipped_pairs == 1 + + def test_rebuild_keeps_same_world_zones(self): + assoc = _register_pair(node_world_provider=lambda nid: "sim") + + rebuilt = assoc.rebuild_zones_for("b") + + assert rebuilt == 1 + assert assoc.overlap_zones[_PAIR].delay_pairs + assert assoc._neighbors["a"] == {"b"} + assert assoc.assoc_world_skipped_pairs == 0 + + def test_a_re_registration_that_changes_world_drops_the_zone(self): + """register_node's own drop path, which rebuild_zones_for cannot cover: + a reconnecting node re-registers with a moved receiver.""" + worlds = {"a": "sim", "b": "sim"} + assoc = _register_pair(node_world_provider=worlds.get) + assert _PAIR in assoc.overlap_zones + + worlds["b"] = "real" + moved = dict(_CFG_B, rx_lat=34.87) + assoc.register_node("b", moved) + + assert _PAIR not in assoc.overlap_zones + assert assoc._neighbors.get("a", set()) == set() + assert assoc.assoc_world_skipped_pairs == 1 + + def test_the_counter_resets_with_the_others(self): + worlds = {"a": "sim", "b": "real"} + assoc = _register_pair(node_world_provider=worlds.get) + assert assoc.assoc_world_skipped_pairs == 1 + + assoc._reset_for_tests() + + assert assoc.assoc_world_skipped_pairs == 0