diff --git a/backend/.env.example b/backend/.env.example index 5c7b4a7d..f4c45a79 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -228,16 +228,19 @@ MENDER_PAT= # EVERY NODE ON THE MAP — treat it as a secret, back it up with the volume, and # do not rotate it casually. # NODE_FUZZ_MODE=on -# Receivers configured at the SAME coordinates are one site: they share a -# single offset and are published at one point, because two independent offsets -# around one house are two samples of it and an attacker intersects them -# (services/node_sites.py). Nothing to configure for that — it follows from the -# coordinates. NODE_FUZZ_SITE_AUDIT_KM is only how close two nodes with -# DIFFERENT coordinates have to be before the log says they look like one site -# described twice; align their configs to group them. +# Receivers at one site share a single offset and are published at one point, +# because two independent offsets around one house are two samples of it and +# an attacker intersects them (services/node_sites.py). Receivers configured at +# the SAME coordinates are always one site. A receiver alone at its coordinates +# but within NODE_FUZZ_SITE_KM of an existing site joins it and is published +# from that site's position — two fixes typed for one roof are rarely equal. +# Pairs up to twice that distance that did not merge are logged as near +# misses; align their configs, or widen the radius, to group them. 0 keeps the +# exact-equality rule only. (NODE_FUZZ_SITE_AUDIT_KM is the older name for the +# same value and is still read.) # NODE_FUZZ_MIN_KM=0.5 # NODE_FUZZ_MAX_KM=1.0 -# NODE_FUZZ_SITE_AUDIT_KM=0.15 +# NODE_FUZZ_SITE_KM=0.15 # NODE_FUZZ_SALT= # Uncertainty disc around multinode solves (services/solve_uncertainty.py). diff --git a/backend/config/constants.py b/backend/config/constants.py index b509e3ce..8587c382 100644 --- a/backend/config/constants.py +++ b/backend/config/constants.py @@ -544,21 +544,32 @@ def node_fuzz_max_km() -> float: return max(value, node_fuzz_min_km()) -# Two receivers closer than this that are NOT configured at the same -# coordinates are almost certainly one site entered twice. services/ -# node_sites.py groups on exact equality and reports these instead of merging -# them — see that module on why proximity must not decide a node's offset. -# 150 m is comfortably wider than the scatter between two typed-in fixes for -# one roof and far narrower than the gap between two operators' houses. -NODE_FUZZ_SITE_AUDIT_KM_DEFAULT = 0.15 - - -def node_fuzz_site_audit_km() -> float: - """Distance under which two differently-configured nodes are flagged (km).""" +# A receiver alone at its coordinates but within this distance of an existing +# receive site is that site: it is published from the site's position with the +# site's offset (services/node_sites.py, which explains the two-pass rule and +# why a greedy join is stable). Two receivers on one roof are routinely two +# independently typed fixes tens of metres apart — the fleet has a 56 m and a +# 16 m case — and published separately they are two samples of one address. +# 150 m is comfortably wider than that scatter and far narrower than the gap +# between two operators' houses. It is also well under NODE_FUZZ_MIN_KM, so +# the snap never brings a published point nearer the truth than the donut +# floor. 0 disables the proximity rule and leaves exact equality alone. +# Pairs up to twice this distance that did not merge are logged. +NODE_FUZZ_SITE_KM_DEFAULT = 0.15 + + +def node_fuzz_site_km() -> float: + """Radius within which a lone receiver joins an existing receive site (km). + + Also read from NODE_FUZZ_SITE_AUDIT_KM, the variable's name while the + distance only drove a log line, so a deployment that tuned it keeps its + value. + """ + raw = os.getenv("NODE_FUZZ_SITE_KM") or os.getenv("NODE_FUZZ_SITE_AUDIT_KM") try: - return float(os.getenv("NODE_FUZZ_SITE_AUDIT_KM") or NODE_FUZZ_SITE_AUDIT_KM_DEFAULT) + return max(0.0, float(raw)) if raw else NODE_FUZZ_SITE_KM_DEFAULT except ValueError: - return NODE_FUZZ_SITE_AUDIT_KM_DEFAULT + return NODE_FUZZ_SITE_KM_DEFAULT def node_fuzz_salt() -> str: diff --git a/backend/services/node_sites.py b/backend/services/node_sites.py index 8957cce0..e3736633 100644 --- a/backend/services/node_sites.py +++ b/backend/services/node_sites.py @@ -1,10 +1,10 @@ """Which nodes sit at one receive site, so the fuzz can move them as one. -Two receivers can share an address: one roof, one operator, two illuminators, -configured at the same coordinates on purpose. Fuzzing them independently -publishes two points drawn from the same true position, and two samples are -worth far more to an attacker than one. Each published point says "the -receiver is somewhere in this annulus", and the annuli intersect: +Two receivers can share an address: one roof, one operator, two illuminators. +Fuzzing them independently publishes two points drawn from the same true +position, and two samples are worth far more to an attacker than one. Each +published point says "the receiver is somewhere in this annulus", and the +annuli intersect: =============== ====================================================== co-located region consistent with EVERY published point, at the @@ -21,29 +21,54 @@ published at coincident coordinates, an attacker sees one sample, and the annulus stays the annulus. -**The rule is exact equality of the configured position.** Sites are entered, -not measured — a second receiver at an existing site is registered with that -site's coordinates — so equality is what a shared site actually looks like in -the data, and it makes a node's identity a function of its own configuration -alone. Nothing about which other nodes are online, or in what order they -connected, can move a node that is alone at its position: it keys on its own -node id exactly as it always has, so adopting this file re-fuzzes nobody except -the co-located nodes it is for. - -**Near-but-not-equal is audited, not merged.** Two receivers 30 m apart with -independently typed coordinates are the same site in every sense that matters -and this will not group them. Grouping by proximity instead would make a -node's offset depend on its neighbours, and then a neighbour connecting, -disconnecting or being retired would move a node that did not move — trading a -leak we can see for one we cannot. ``colocation_report()`` names those pairs -instead, so the case surfaces as a warning an operator can fix by aligning the -two configurations, rather than as silence. +**Two rules decide a site, in this order.** + +1. *Exact equality of the configured position.* A second receiver registered + with an existing site's coordinates carries the same number, at 6 decimals, + and the group keys on its lowest node id. These groups are formed first and + their anchors are frozen: nothing in the second rule can re-anchor one, so a + site that is already published at one point keeps that point. + +2. *Proximity to an existing anchor, within NODE_FUZZ_SITE_KM.* Sites are + entered, not measured, and in practice two receivers on one roof are often + two independently typed fixes a few tens of metres apart. The fleet has + produced exactly that: a fourth receiver 56 m from three at one address, + and a receiver 16 m from a pair at another, each publishing a second sample + of a site the first rule had already protected. So a node that is alone at + its coordinates joins the nearest existing anchor within the radius, and is + published *at the anchor's position* plus the anchor's offset — not at its + own position plus a shared offset, which would publish the true baseline + between the two receivers as the gap between their markers. + +**Why the second rule is safe to apply.** The objection to proximity grouping +was that a node's offset would come to depend on its neighbours, so a +neighbour connecting or disconnecting could move a node that did not move. +Two properties close that off. Positions are remembered, never dropped +(``_positions``), so a disconnect changes nothing. And the join is a +deterministic greedy pass over node ids in sorted order, not a transitive +closure: every member is within the radius of its own anchor, a chain of +near neighbours cannot pull a whole street into one site, and the result does +not depend on connection order. What remains is the case the exact rule +always had — a newcomer with a lower id at an existing site becomes its +anchor — and that is a one-off re-fuzz of a site that was going to publish a +second sample anyway. + +**What a lone node keeps.** A node with no anchor within the radius keys on +its own id and its own position, exactly as it did before this module existed. +Adopting either rule re-fuzzes nobody except the nodes it is for. **What sharing gives up.** Nodes at one site are published at one point, so -the map cannot distinguish them there, and any true separation smaller than the -audit threshold is not represented. That is the intended trade: their -separation was never the interesting fact, and publishing it independently was -what leaked the site. +the map cannot distinguish them there, and a true separation up to the merge +radius is not represented. The declared ``location_uncertainty_km`` for a +site with a joined member is widened by the radius so the disc stays honest. +Their separation was never the interesting fact; publishing it independently +was what leaked the site. + +**What is still only audited.** Two nodes closer than twice the radius that +did not merge — because each is nearer another anchor, or because they sit +just outside it — are reported by ``colocation_report()`` and logged, so the +residual case surfaces as a warning an operator can fix by aligning the two +configurations or widening NODE_FUZZ_SITE_KM. """ from __future__ import annotations @@ -54,9 +79,10 @@ import threading import time -from config.constants import node_fuzz_site_audit_km +from config.constants import node_fuzz_site_km from core import state from core.runtime_config import runtime_path +from services.geo import KM_PER_DEG_LAT, haversine_km logger = logging.getLogger(__name__) @@ -73,7 +99,10 @@ # Coordinates are compared at 6 decimals, about 0.11 m. This is canonical # formatting rather than a tolerance: two configurations of the same site carry # the same number, and 6 decimals is the precision the wire contract and the -# config files already use. +# config files already use. Only the comparison is rounded — the position a +# joined node is published from is the anchor's value as configured, so it +# goes through the same arithmetic as the anchor's own published point and +# lands on the identical coordinate. _SITE_DECIMALS = 6 # The runtime files that define nodes this deployment did not register: a @@ -83,22 +112,29 @@ _NODE_FILES = ("blah2_nodes.json", "nodes_config.json") _lock = threading.Lock() -# node_id -> (lat, lon), last known. Positions are overwritten, never dropped: -# a node that goes offline must not dissolve the site it shares, or its -# site-mate would move on the map every time it disconnected. Bounded by the -# number of nodes this process has ever seen. +# node_id -> (lat, lon) as configured, last known. Positions are overwritten, +# never dropped: a node that goes offline must not dissolve the site it shares, +# or its site-mate would move on the map every time it disconnected. Bounded +# by the number of nodes this process has ever seen. _positions: dict[str, tuple[float, float]] = {} # node_id -> the id its offset is keyed on. Rebuilt whenever _positions is. _identities: dict[str, str] = {} +# node_id -> the anchor's position, for nodes joined by proximity only. A node +# absent here is published from its own coordinates. +_anchor_positions: dict[str, tuple[float, float]] = {} +# Anchor ids of sites with at least one proximity-joined member. +_snapped_sites: frozenset[str] = frozenset() _expires_at: float = 0.0 def _reset_for_tests() -> None: """Drop the snapshot. Tests only.""" - global _expires_at + global _expires_at, _identities, _anchor_positions, _snapped_sites with _lock: _positions.clear() - _identities.clear() + _identities = {} + _anchor_positions = {} + _snapped_sites = frozenset() _expires_at = 0.0 @@ -107,9 +143,15 @@ def _is_num(v) -> bool: def _site_of(lat, lon) -> tuple[float, float] | None: + """A usable configured position, or None.""" if not _is_num(lat) or not _is_num(lon): return None - return (round(float(lat), _SITE_DECIMALS), round(float(lon), _SITE_DECIMALS)) + return (float(lat), float(lon)) + + +def _site_key(position: tuple[float, float]) -> tuple[float, float]: + """The equality key for a position: the same site carries the same number.""" + return (round(position[0], _SITE_DECIMALS), round(position[1], _SITE_DECIMALS)) def _positions_from_live() -> dict[str, tuple[float, float]]: @@ -176,6 +218,65 @@ def _positions_from_db() -> dict[str, tuple[float, float]]: return out +def _km_between(a: tuple[float, float], b: tuple[float, float]) -> float: + return haversine_km(a[0], a[1], b[0], b[1]) + + +def _cluster( + positions: dict[str, tuple[float, float]], radius_km: float +) -> tuple[dict[str, str], dict[str, tuple[float, float]]]: + """Resolve every node to its anchor: (identities, anchor position of joined nodes). + + Pure, so the two rules in the module docstring can be read and tested as + one function. Pass one places every exact-equality group of two or more + (equal at ``_SITE_DECIMALS``) as an anchor, lowest id first, and nothing + later can move them. Pass two walks the remaining nodes in id order: each + joins the nearest anchor within ``radius_km`` — an earlier-placed anchor + wins a tie — or becomes one. A radius of zero (or less) is the exact rule + alone. + """ + by_site: dict[tuple[float, float], list[str]] = {} + for node_id, position in positions.items(): + by_site.setdefault(_site_key(position), []).append(node_id) + + identities: dict[str, str] = {} + joined: dict[str, tuple[float, float]] = {} + # Anchors in placement order; a tie in distance goes to the earlier one. + anchors: list[tuple[str, tuple[float, float]]] = [] + + # An anchor's position is its own configured value, not the rounded key: + # a joined node is published from it, and it must be the number the + # anchor's own published point is computed from. + groups = sorted(((min(ids), ids) for ids in by_site.values() if len(ids) > 1), key=lambda g: g[0]) + for anchor, ids in groups: + anchors.append((anchor, positions[anchor])) + for node_id in ids: + identities[node_id] = anchor + + # A candidate anchor further than this in latitude alone is out of range, + # which keeps the pass linear-ish for a fleet of hundreds without a spatial + # index. The 1.01 covers the haversine/flat-earth difference at this scale. + lat_gate = (radius_km / KM_PER_DEG_LAT) * 1.01 if radius_km > 0 else -1.0 + singles = sorted(ids[0] for ids in by_site.values() if len(ids) == 1) + for node_id in singles: + position = positions[node_id] + best: tuple[float, str, tuple[float, float]] | None = None + if radius_km > 0: + for anchor, anchor_position in anchors: + if abs(anchor_position[0] - position[0]) > lat_gate: + continue + gap = _km_between(position, anchor_position) + if gap <= radius_km and (best is None or gap < best[0]): + best = (gap, anchor, anchor_position) + if best is None: + anchors.append((node_id, position)) + identities[node_id] = node_id + else: + identities[node_id] = best[1] + joined[node_id] = best[2] + return identities, joined + + def _refresh_locked() -> None: """Fold every source into _positions and rebuild the identity map. @@ -183,6 +284,7 @@ def _refresh_locked() -> None: live configuration wins: it is the one the pipeline is solving against and therefore the one whose coordinates are being published. """ + global _identities, _anchor_positions, _snapped_sites for source in (_positions_from_db, _positions_from_files, _positions_from_live): try: _positions.update(source()) @@ -192,43 +294,36 @@ def _refresh_locked() -> None: # the previous snapshot, which is a position map, not a wrong one. logger.exception("node_sites: position source %s failed", source.__name__) - sites: dict[tuple[float, float], list[str]] = {} - for node_id, site in _positions.items(): - sites.setdefault(site, []).append(node_id) - - _identities.clear() - for node_ids in sites.values(): - # The lowest node id at the site, which for a site of one is the node - # itself — so a node alone at its coordinates keys exactly as it did - # before this module existed. - anchor = min(node_ids) - for node_id in node_ids: - _identities[node_id] = anchor + identities, joined = _cluster(_positions, node_fuzz_site_km()) + # Swapped in whole rather than mutated in place, so a reader between two + # statements never sees a node keyed on a site it has not finished joining. + _identities = identities + _anchor_positions = joined + _snapped_sites = frozenset(identities[node_id] for node_id in joined) -def _snapshot() -> dict[str, str]: +def _ensure_fresh() -> None: global _expires_at - now = time.monotonic() - if now < _expires_at: - return _identities + if time.monotonic() < _expires_at: + return with _lock: if time.monotonic() < _expires_at: - return _identities + return try: _refresh_locked() _expires_at = time.monotonic() + _TTL_S except Exception: logger.exception("node_sites: refresh failed, serving the previous snapshot") _expires_at = time.monotonic() + _ERROR_RETRY_S - return _identities def site_identity(node_id: str) -> str: """The id this node's public offset is keyed on. - Its own, unless it shares its configured coordinates with another node, in - which case the lowest id at that site — so every node there is displaced by - one offset and published at one point. + Its own, unless it shares a site with another node — configured at the same + coordinates, or within NODE_FUZZ_SITE_KM of that site's anchor — in which + case the anchor's id, so every node there is displaced by one offset and + published at one point. A node whose position this deployment does not know keys on itself, which is both the old behaviour and the safe one: the unknown case must not @@ -236,52 +331,116 @@ def site_identity(node_id: str) -> str: """ if not node_id: return node_id - return _snapshot().get(node_id, node_id) + _ensure_fresh() + return _identities.get(node_id, node_id) + + +def site_position(node_id: str | None) -> tuple[float, float] | None: + """The position this node is published FROM, when it is not its own. + + Only a node joined to a site by proximity answers: its published point is + its anchor's configured position plus the site's offset, so the marker + coincides with its site-mates' and the gap between the two receivers is + not on the wire. ``None`` for every other node — a lone node and a member + of an exact-equality site are both published from their own coordinates, + exactly as before. + """ + if not node_id: + return None + _ensure_fresh() + return _anchor_positions.get(node_id) + + +def site_shift_deg(node_id: str | None) -> tuple[float, float]: + """The (dlat, dlon) from this node's own position to the one it is published from. + + The proximity join's half of the public displacement; the fuzz offset is + the other half. Anything derived from the node's true position — a + coverage polygon, an arc, a trail — has to move by this as well as by the + offset, or it would land around the node's own position while the marker + sits at the anchor's. (0.0, 0.0) for a node published from its own + coordinates. + """ + if not node_id: + return (0.0, 0.0) + _ensure_fresh() + anchor = _anchor_positions.get(node_id) + own = _positions.get(node_id) + if anchor is None or own is None: + return (0.0, 0.0) + return (anchor[0] - own[0], anchor[1] - own[1]) + + +def site_is_snapped(node_id: str | None) -> bool: + """Whether this node's site has a member published from a position not its own. + + Such a site's true receivers are up to NODE_FUZZ_SITE_KM further from the + published point than the fuzz alone allows for, so the uncertainty a + client is told has to grow by that much — for every member, so the site's + single published point carries a single honest radius. + """ + if not node_id: + return False + _ensure_fresh() + return _identities.get(node_id, node_id) in _snapped_sites def shared_sites() -> dict[str, list[str]]: """{anchor node id: every node id at that site}, sites of two or more only.""" - identities = _snapshot() + _ensure_fresh() grouped: dict[str, list[str]] = {} - for node_id, anchor in identities.items(): + for node_id, anchor in _identities.items(): grouped.setdefault(anchor, []).append(node_id) return {anchor: sorted(ids) for anchor, ids in grouped.items() if len(ids) > 1} def colocation_report() -> dict: - """Sites being shared, and pairs that look like a site but are not grouped. - - ``near_misses`` is the operational half: two nodes closer than - NODE_FUZZ_SITE_AUDIT_KM whose configured coordinates are not equal are - almost certainly one site described twice, and they are being published as - two independent samples of it. Aligning the two configurations to the same - coordinates is what fixes it, and this is how anyone finds out there is - something to fix. + """Sites being shared, how each member got there, and the pairs still apart. + + ``proximity_joins`` lists every node published from an anchor's position + rather than its own, with the true gap: that is the set of nodes this + module has moved, and therefore what to read before changing the radius. + + ``near_misses`` is the residual audit: two nodes at different sites closer + than twice NODE_FUZZ_SITE_KM. Each is fuzzed independently, so if they are + one site described twice they are publishing two samples of it. Aligning + the two configurations, or widening the radius, is what fixes it, and this + is how anyone finds out there is something to fix. """ - _snapshot() + _ensure_fresh() with _lock: positions = dict(_positions) + identities = dict(_identities) + joined = dict(_anchor_positions) shared = shared_sites() - threshold_km = node_fuzz_site_audit_km() + radius_km = node_fuzz_site_km() + audit_km = 2.0 * radius_km + joins = sorted( + ( + {"node": node_id, "anchor": identities[node_id], "km": round(_km_between(positions[node_id], anchor), 4)} + for node_id, anchor in joined.items() + ), + key=lambda entry: (entry["km"], entry["node"]), + ) + node_ids = sorted(positions) near: list[dict] = [] for i, first in enumerate(node_ids): for second in node_ids[i + 1 :]: - a, b = positions[first], positions[second] - if a == b: + if identities.get(first, first) == identities.get(second, second): continue - gap_km = _km_between(a, b) - if gap_km <= threshold_km: + gap_km = _km_between(positions[first], positions[second]) + if gap_km <= audit_km: near.append({"nodes": [first, second], "km": round(gap_km, 4)}) near.sort(key=lambda entry: entry["km"]) - return {"shared_sites": shared, "near_misses": near, "audit_threshold_km": threshold_km} - - -def _km_between(a: tuple[float, float], b: tuple[float, float]) -> float: - from services.geo import haversine_km - - return haversine_km(a[0], a[1], b[0], b[1]) + return { + "shared_sites": shared, + "proximity_joins": joins, + "near_misses": near, + "merge_radius_km": radius_km, + "audit_threshold_km": audit_km, + } _last_logged: tuple | None = None @@ -297,6 +456,7 @@ def log_colocation_audit() -> dict: report = colocation_report() fingerprint = ( tuple(sorted((k, tuple(v)) for k, v in report["shared_sites"].items())), + tuple((entry["node"], entry["anchor"]) for entry in report["proximity_joins"]), tuple(tuple(entry["nodes"]) for entry in report["near_misses"]), ) if fingerprint == _last_logged: @@ -304,13 +464,24 @@ def log_colocation_audit() -> dict: _last_logged = fingerprint for node_ids in report["shared_sites"].values(): logger.info("node_sites: %s share one receive site and one fuzz offset", ", ".join(node_ids)) + for entry in report["proximity_joins"]: + logger.info( + "node_sites: %s is configured %.0f m from %s and is published from that site's position, " + "so the pair is one sample, not two (NODE_FUZZ_SITE_KM=%.3f)", + entry["node"], + entry["km"] * 1000.0, + entry["anchor"], + report["merge_radius_km"], + ) for entry in report["near_misses"]: logger.warning( - "node_sites: %s and %s are %.0f m apart but configured at different coordinates, so each " - "is fuzzed independently and the pair publishes two samples of one site. Align their " - "configured rx_lat/rx_lon to group them.", + "node_sites: %s and %s are %.0f m apart at different sites — outside the %.0f m merge radius " + "but close enough to be one site described twice — so each is fuzzed independently and the " + "pair publishes two samples. Align their configured rx_lat/rx_lon, or widen NODE_FUZZ_SITE_KM, " + "to group them.", entry["nodes"][0], entry["nodes"][1], entry["km"] * 1000.0, + report["merge_radius_km"] * 1000.0, ) return report diff --git a/backend/services/public_location.py b/backend/services/public_location.py index 635bffb6..bd487ffc 100644 --- a/backend/services/public_location.py +++ b/backend/services/public_location.py @@ -6,10 +6,13 @@ receiver position into the one an unauthenticated client is allowed to see. **One offset per site, not per node.** The identity hashed is the node's -site: receivers configured at the same coordinates share an offset and are -published at one point, because two independent offsets around one house are -two samples of it and an attacker intersects them. services/node_sites.py -resolves that identity and explains what the intersection costs. +site: receivers configured at the same coordinates, or within NODE_FUZZ_SITE_KM +of a site's anchor, share an offset and are published at one point, because +two independent offsets around one house are two samples of it and an attacker +intersects them. A receiver that joined a site by proximity is published from +the anchor's position, not its own, so the gap between the two receivers is +not on the wire either. services/node_sites.py resolves the identity and the +position and explains what the intersection costs. **Where this belongs.** Call it at the boundary where bytes leave for a public client (a JSON payload, a websocket entry, an archive row), never upstream of @@ -68,10 +71,11 @@ node_fuzz_min_km, node_fuzz_mode, node_fuzz_salt, + node_fuzz_site_km, ) from core.runtime_config import RUNTIME_DIR, runtime_path, write_runtime_file from services.geo import KM_PER_DEG_LAT, km_per_deg_lon, offset_latlon -from services.node_sites import site_identity +from services.node_sites import site_identity, site_is_snapped, site_position, site_shift_deg logger = logging.getLogger(__name__) @@ -122,14 +126,24 @@ def fuzz_enabled() -> bool: return node_fuzz_mode() != "off" -def location_uncertainty_km() -> float: +def location_uncertainty_km(node_id: str | None = None) -> float: """Radius a client should draw to represent an honest published position. The outer edge of the donut: the true receiver is somewhere within this distance of the coordinate served, and the client is told so rather than left to infer a precision that is not there. + + Wider by NODE_FUZZ_SITE_KM for a node at a site with a member published + from the anchor's position rather than its own: that member's receiver can + be up to the merge radius further out than the donut alone allows, and the + site publishes one point, so every member declares the one radius that is + honest for all of them. Without a node id the plain donut edge is + returned, which is right for any node the caller cannot name. """ - return node_fuzz_max_km() + radius = node_fuzz_max_km() + if node_id and site_is_snapped(node_id): + radius += node_fuzz_site_km() + return radius def _persisted_salt() -> str: @@ -243,6 +257,15 @@ def public_latlon(lat, lon, node_id: str | None) -> tuple[float, float]: if not _is_num(lat) or not _is_num(lon): return (lat, lon) + # A node that joined a site by proximity is published from the anchor's + # configured position — the same input its site-mates are published from — + # so the rounding below lands every member on the identical coordinate. + # Substituted outright rather than shifted: adding a shift to the caller's + # unrounded value could straddle a 4-decimal boundary the anchor does not. + anchor = site_position(node_id) + if anchor is not None: + lat, lon = anchor + east_km, north_km = public_offset_km(node_id) fuzzed_lat, fuzzed_lon = offset_latlon(float(lat), float(lon), east_km, north_km) return (round(fuzzed_lat, _PUBLIC_DECIMALS), round(fuzzed_lon, _PUBLIC_DECIMALS)) @@ -286,12 +309,27 @@ def translate_polygon( if not _is_num(anchor_lat): return verts - east_km, north_km = public_offset_km(node_id) - dlat = north_km / KM_PER_DEG_LAT - dlon = east_km / km_per_deg_lon(float(anchor_lat)) + dlat, dlon = _public_delta_deg(float(anchor_lat), node_id) return [[v[0] + dlat, v[1] + dlon] for v in verts] +def _public_delta_deg(lat: float, node_id: str | None) -> tuple[float, float]: + """The whole (dlat, dlon) from a node's true frame to its public one. + + Two parts, applied together so every artefact of a node moves rigidly with + its marker: the site's fuzz offset, and — for a node published from its + anchor's position rather than its own — the shift from the node's own + configured position to the anchor's. A node published from its own + coordinates has a zero shift and gets the offset alone, as before. + """ + east_km, north_km = public_offset_km(node_id) + shift_lat, shift_lon = site_shift_deg(node_id) + return ( + shift_lat + north_km / KM_PER_DEG_LAT, + shift_lon + east_km / km_per_deg_lon(lat), + ) + + def public_point_delta(lat, node_id: str | None) -> tuple[float, float]: """The (dlat, dlon) that carries a point from the true frame to the public one. @@ -317,9 +355,7 @@ def public_point_delta(lat, node_id: str | None) -> tuple[float, float]: """ if not fuzz_enabled() or not _is_num(lat): return (0.0, 0.0) - - east_km, north_km = public_offset_km(node_id) - return (north_km / KM_PER_DEG_LAT, east_km / km_per_deg_lon(float(lat))) + return _public_delta_deg(float(lat), node_id) def fuzz_node_cfg(node_cfg: dict | None) -> dict | None: @@ -388,7 +424,7 @@ def public_node_summary(node_id: str | None, summary): **rx, "lat": pub_lat, "lon": pub_lon, - "location_uncertainty_km": location_uncertainty_km(), + "location_uncertainty_km": location_uncertainty_km(node_id), } out = {**out, "detection_area": area_out} diff --git a/backend/services/tasks/analytics_refresh.py b/backend/services/tasks/analytics_refresh.py index c4d73948..df80b939 100644 --- a/backend/services/tasks/analytics_refresh.py +++ b/backend/services/tasks/analytics_refresh.py @@ -305,7 +305,7 @@ def _public_location_block(node_id: str, cfg: dict) -> dict: "tx_lat": cfg.get("tx_lat"), "tx_lon": cfg.get("tx_lon"), "tx_alt_ft": cfg.get("tx_alt_ft"), - "location_uncertainty_km": location_uncertainty_km() if fuzz_enabled() else 0.0, + "location_uncertainty_km": location_uncertainty_km(node_id) if fuzz_enabled() else 0.0, } diff --git a/backend/tests/test_node_sites.py b/backend/tests/test_node_sites.py index f8ecc74f..b7f18b3a 100644 --- a/backend/tests/test_node_sites.py +++ b/backend/tests/test_node_sites.py @@ -172,19 +172,20 @@ def test_a_shared_site_is_reported(self): assert report["shared_sites"] == {"example-node-a": ["example-node-a", "example-node-b"]} assert report["near_misses"] == [] - def test_a_near_miss_is_reported_not_merged(self): - """The case the exact-equality rule cannot catch, made visible. + def test_a_near_miss_beyond_the_radius_is_reported_not_merged(self): + """Two nodes just outside the merge radius, made visible. - 30 m apart is one roof described twice, and these two are publishing - two samples of it. The module will not group them — a node's offset - must not depend on its neighbours — so the audit has to name them. + 200 m apart could still be one site described twice, so the audit + names them, but the module will not group them — at that distance it + is at least as likely to be two houses. """ _connect("roof-a", _SITE_LAT, _SITE_LON) - _connect("roof-b", _SITE_LAT + 0.00027, _SITE_LON) # ~30 m north + _connect("roof-b", _SITE_LAT + 0.0018, _SITE_LON) # ~200 m north report = ns.colocation_report() assert report["shared_sites"] == {} + assert report["proximity_joins"] == [] assert [entry["nodes"] for entry in report["near_misses"]] == [["roof-a", "roof-b"]] - assert report["near_misses"][0]["km"] == pytest.approx(0.030, abs=0.005) + assert report["near_misses"][0]["km"] == pytest.approx(0.200, abs=0.005) assert ns.site_identity("roof-b") == "roof-b" def test_ordinary_neighbours_are_not_near_misses(self): @@ -192,24 +193,221 @@ def test_ordinary_neighbours_are_not_near_misses(self): _connect("house-b", _SITE_LAT + 0.02, _SITE_LON) # ~2.2 km north assert ns.colocation_report()["near_misses"] == [] - def test_the_audit_threshold_is_configurable(self, monkeypatch): + def test_the_radius_is_configurable_under_both_names(self, monkeypatch): + _connect("roof-a", _SITE_LAT, _SITE_LON) + _connect("roof-b", _SITE_LAT + 0.00027, _SITE_LON) # ~30 m north + monkeypatch.setenv("NODE_FUZZ_SITE_KM", "0.02") # merge to 20 m, audit to 40 m + ns._reset_for_tests() + report = ns.colocation_report() + assert report["proximity_joins"] == [] + assert [entry["nodes"] for entry in report["near_misses"]] == [["roof-a", "roof-b"]] + assert ns.site_identity("roof-b") == "roof-b" + + monkeypatch.delenv("NODE_FUZZ_SITE_KM") + monkeypatch.setenv("NODE_FUZZ_SITE_AUDIT_KM", "0.02") # the older name still counts + ns._reset_for_tests() + assert ns.colocation_report()["proximity_joins"] == [] + + def test_a_zero_radius_is_the_exact_rule_alone(self, monkeypatch): + monkeypatch.setenv("NODE_FUZZ_SITE_KM", "0") _connect("roof-a", _SITE_LAT, _SITE_LON) _connect("roof-b", _SITE_LAT + 0.00027, _SITE_LON) - monkeypatch.setenv("NODE_FUZZ_SITE_AUDIT_KM", "0.01") + _connect("roof-c", _SITE_LAT, _SITE_LON) + assert ns.site_identity("roof-b") == "roof-b" + assert ns.site_identity("roof-c") == "roof-a" assert ns.colocation_report()["near_misses"] == [] def test_the_audit_logs_once_per_change(self, caplog): _connect("roof-a", _SITE_LAT, _SITE_LON) _connect("roof-b", _SITE_LAT + 0.00027, _SITE_LON) - with caplog.at_level("WARNING"): + with caplog.at_level("INFO"): ns.log_colocation_audit() - first = len([r for r in caplog.records if "different coordinates" in r.message]) + first = len([r for r in caplog.records if "is published from that site's position" in r.message]) ns.log_colocation_audit() - second = len([r for r in caplog.records if "different coordinates" in r.message]) + second = len([r for r in caplog.records if "is published from that site's position" in r.message]) assert first == 1 assert second == 1 +class TestProximityJoin: + """The second rule: a lone receiver within the radius of a site joins it. + + The live fleet motivated this — a fourth receiver 56 m from three at one + address, a receiver 16 m from a pair at another — so the distances below + are those. Invented coordinates, as everywhere in this file. + """ + + # Degrees of latitude for a given number of metres north. + _M = 1.0 / 111_195.0 + + def test_a_receiver_56_m_from_a_site_joins_it(self): + for node_id in ("example-node-a", "example-node-b", "example-node-c"): + _connect(node_id, _SITE_LAT, _SITE_LON) + _connect("example-node-d", _SITE_LAT + 56 * self._M, _SITE_LON) + assert ns.site_identity("example-node-d") == "example-node-a" + report = ns.colocation_report() + assert report["shared_sites"] == { + "example-node-a": ["example-node-a", "example-node-b", "example-node-c", "example-node-d"] + } + assert [(j["node"], j["anchor"]) for j in report["proximity_joins"]] == [("example-node-d", "example-node-a")] + assert report["proximity_joins"][0]["km"] == pytest.approx(0.056, abs=0.002) + assert report["near_misses"] == [] + + def test_a_receiver_16_m_from_a_pair_joins_it_even_with_a_lower_id(self): + """Exact-equality sites are anchored first, whatever the ids say. + + The pair is already published at one point. A newcomer with a lower + id must join *it*, not become its anchor — or shipping this rule would + re-fuzz a site the exact rule had already protected. + """ + _connect("radar-a", _SITE_LAT, _SITE_LON) + _connect("radar-b", _SITE_LAT, _SITE_LON) + pair_before = pl.public_latlon(_SITE_LAT, _SITE_LON, "radar-b") + + _connect("aaa-newcomer", _SITE_LAT + 16 * self._M, _SITE_LON) + assert ns.site_identity("aaa-newcomer") == "radar-a" + assert pl.public_latlon(_SITE_LAT, _SITE_LON, "radar-b") == pair_before + + def test_the_joined_node_is_published_at_the_site_point(self): + """The whole point: one marker, and the receivers' true gap not on the wire. + + Sharing only the offset would publish the joiner 56 m from its + site-mates — a second marker, and the exact baseline between the two + receivers as the distance between them. + """ + _connect("example-node-a", _SITE_LAT, _SITE_LON) + _connect("example-node-b", _SITE_LAT, _SITE_LON) + own_lat = _SITE_LAT + 56 * self._M + _connect("example-node-d", own_lat, _SITE_LON) + assert pl.public_latlon(own_lat, _SITE_LON, "example-node-d") == pl.public_latlon( + _SITE_LAT, _SITE_LON, "example-node-a" + ) + + def test_the_joined_node_lands_on_the_anchor_as_configured_not_as_rounded(self): + """The anchor's published point comes from its raw configured value. + + Publishing the joiner from the 6-decimal equality key instead would, + one time in a few hundred, straddle a 4-decimal rounding boundary the + anchor does not and put the two markers 11 m apart. + """ + anchor_lat, anchor_lon = _SITE_LAT + 0.00004999999, _SITE_LON + 0.00004999999 + _connect("example-node-a", anchor_lat, anchor_lon) + _connect("example-node-b", anchor_lat, anchor_lon) + own_lat = anchor_lat + 56 * self._M + _connect("example-node-d", own_lat, anchor_lon) + assert ns.site_position("example-node-d") == (anchor_lat, anchor_lon) + assert pl.public_latlon(own_lat, anchor_lon, "example-node-d") == pl.public_latlon( + anchor_lat, anchor_lon, "example-node-a" + ) + + def test_the_joined_nodes_artefacts_move_with_its_marker(self): + """Arcs, trails and polygons of a joined node land around the site point. + + public_point_delta and translate_polygon carry the join's shift as + well as the offset: a coverage polygon whose apex is the joiner's own + receiver has to end up with its apex on the published site point, not + 56 m away from it. + """ + _connect("example-node-a", _SITE_LAT, _SITE_LON) + _connect("example-node-b", _SITE_LAT, _SITE_LON) + own_lat = _SITE_LAT + 56 * self._M + _connect("example-node-d", own_lat, _SITE_LON) + + published = pl.public_latlon(own_lat, _SITE_LON, "example-node-d") + dlat, dlon = pl.public_point_delta(own_lat, "example-node-d") + assert (own_lat + dlat, _SITE_LON + dlon) == pytest.approx(published, abs=1e-4) + + apex = pl.translate_polygon([[own_lat, _SITE_LON], [own_lat + 0.1, _SITE_LON + 0.1]], "example-node-d")[0] + assert tuple(apex) == pytest.approx(published, abs=1e-4) + # The polygon is still rigid: both vertices moved by the same amount. + moved = pl.translate_polygon([[own_lat, _SITE_LON], [own_lat + 0.1, _SITE_LON + 0.1]], "example-node-d") + assert moved[1][0] - moved[0][0] == pytest.approx(0.1, abs=1e-9) + assert moved[1][1] - moved[0][1] == pytest.approx(0.1, abs=1e-9) + + def test_the_declared_uncertainty_widens_for_the_whole_site(self): + """One published point, one honest radius, for every member.""" + _connect("example-node-a", _SITE_LAT, _SITE_LON) + _connect("example-node-b", _SITE_LAT, _SITE_LON) + assert pl.location_uncertainty_km("example-node-a") == pl.location_uncertainty_km() + + _connect("example-node-d", _SITE_LAT + 56 * self._M, _SITE_LON) + widened = pl.location_uncertainty_km() + 0.15 + assert pl.location_uncertainty_km("example-node-d") == pytest.approx(widened) + assert pl.location_uncertainty_km("example-node-a") == pytest.approx(widened) + assert pl.location_uncertainty_km("example-node-b") == pytest.approx(widened) + + def test_two_lone_receivers_within_the_radius_become_one_site(self): + _connect("roof-b", _SITE_LAT + 30 * self._M, _SITE_LON) + _connect("roof-a", _SITE_LAT, _SITE_LON) + assert ns.site_identity("roof-a") == "roof-a" + assert ns.site_identity("roof-b") == "roof-a" + assert pl.public_latlon(_SITE_LAT + 30 * self._M, _SITE_LON, "roof-b") == pl.public_latlon( + _SITE_LAT, _SITE_LON, "roof-a" + ) + + def test_a_chain_of_near_neighbours_does_not_become_one_site(self): + """Greedy, not transitive: every member is within the radius of ITS anchor. + + Four receivers 100 m apart in a line span 300 m. Single-linkage would + make them one site and publish a house 300 m away at its neighbour's + point; this rule makes two sites of two. + """ + for i, node_id in enumerate(("house-a", "house-b", "house-c", "house-d")): + _connect(node_id, _SITE_LAT + 100 * i * self._M, _SITE_LON) + assert ns.site_identity("house-a") == "house-a" + assert ns.site_identity("house-b") == "house-a" + assert ns.site_identity("house-c") == "house-c" + assert ns.site_identity("house-d") == "house-c" + # And the pair across the seam is the audit's remaining business. + assert ns.colocation_report()["near_misses"][0]["nodes"] == ["house-b", "house-c"] + + def test_the_join_does_not_depend_on_connection_order(self): + def build(order): + state.connected_nodes.clear() + for node_id in order: + _connect(node_id, *positions[node_id]) + return {n: ns.site_identity(n) for n in positions} + + positions = { + "n1": (_SITE_LAT, _SITE_LON), + "n2": (_SITE_LAT + 40 * self._M, _SITE_LON), + "n3": (_SITE_LAT + 120 * self._M, _SITE_LON), + "n4": (_SITE_LAT, _SITE_LON), + } + first = build(["n1", "n2", "n3", "n4"]) + second = build(["n3", "n4", "n2", "n1"]) + assert first == second == {"n1": "n1", "n2": "n1", "n3": "n1", "n4": "n1"} + + def test_a_lone_receiver_joins_the_nearest_site(self): + _connect("east-a", _SITE_LAT, _SITE_LON + 0.0020) + _connect("east-b", _SITE_LAT, _SITE_LON + 0.0020) + _connect("west-a", _SITE_LAT, _SITE_LON - 0.0020) + _connect("west-b", _SITE_LAT, _SITE_LON - 0.0020) + _connect("between", _SITE_LAT, _SITE_LON + 0.0006) # ~130 m from east, ~240 m from west + assert ns.site_identity("between") == "east-a" + + def test_a_lone_receiver_beyond_the_radius_keys_on_itself(self): + _connect("example-node-a", _SITE_LAT, _SITE_LON) + _connect("example-node-b", _SITE_LAT, _SITE_LON) + _connect("far", _SITE_LAT + 160 * self._M, _SITE_LON) + assert ns.site_identity("far") == "far" + assert ns.site_position("far") is None + assert ns.site_shift_deg("far") == (0.0, 0.0) + assert pl.location_uncertainty_km("far") == pl.location_uncertainty_km() + + def test_a_disconnected_anchor_does_not_move_the_joined_node(self): + _connect("example-node-a", _SITE_LAT, _SITE_LON) + _connect("example-node-b", _SITE_LAT, _SITE_LON) + own_lat = _SITE_LAT + 56 * self._M + _connect("example-node-d", own_lat, _SITE_LON) + before = pl.public_latlon(own_lat, _SITE_LON, "example-node-d") + + del state.connected_nodes["example-node-a"] + del state.connected_nodes["example-node-b"] + ns._expires_at = 0.0 + assert pl.public_latlon(own_lat, _SITE_LON, "example-node-d") == before + + class TestSources: def test_a_file_defined_node_is_a_site(self, monkeypatch, tmp_path): """The blah2 bridge nodes live in a runtime file, not in the database.""" diff --git a/docs/design-notes/2026-09-11-node-site-proximity-gate.md b/docs/design-notes/2026-09-11-node-site-proximity-gate.md new file mode 100644 index 00000000..23b4b36c --- /dev/null +++ b/docs/design-notes/2026-09-11-node-site-proximity-gate.md @@ -0,0 +1,87 @@ +# Node sites: a receiver near a site is that site + +Date: 2026-09-11. Branch `feat/node-site-proximity-gate`. + +## Problem + +`services/node_sites.py` (#286) publishes co-located receivers at one point by +hashing the *site* rather than the node, where a site was defined as exact +equality of the configured rx coordinates at 6 decimals. The rule was chosen +over proximity on purpose: a node's offset must not depend on its neighbours. + +The fleet then produced the case the rule cannot see. On the test droplet the +co-location audit has logged, every cycle, a fourth receiver configured 56 m +from three at one address, and a receiver 16 m from a pair at another. Each is +a separate site under exact equality, so each publishes its own donut draw: +the map shows a second marker about 1 km from the first (the difference of two +independent draws, not the true 56 m), and an attacker holds two samples of one +address. At the shipped [0.5, 1.0] km donut that shrinks the consistent region +from 2.36 km² to 0.69 km² — the loss the module exists to prevent. + +The audit's own advice, "align the configured rx_lat/rx_lon", fixes the +symptom by editing the physics input: the node would then be solved against a +position tens of metres wrong. The fix belongs at the publication edge. + +## Decision + +Two rules, applied in order, resolve every node to a site: + +1. **Exact equality**, unchanged: nodes configured at the same coordinates + form a site anchored on the lowest node id. These sites are formed first + and their anchors are frozen. +2. **Proximity**: a node alone at its coordinates joins the nearest existing + anchor within `NODE_FUZZ_SITE_KM` (150 m, the distance the audit already + used), or becomes an anchor itself. The pass is greedy over node ids in + sorted order, not a transitive closure. + +A node joined by rule 2 is **published from the anchor's position**, not from +its own with a shared offset. Sharing only the offset would leave two markers +56 m apart and put the true baseline between the receivers on the wire. +Everything derived from the node's true position — coverage polygon, +ambiguity arc, single-node track trail — moves by the same total delta (join +shift plus fuzz offset), so the published artefacts stay rigid around the +published marker. + +`location_uncertainty_km` widens by the radius for every member of a site that +has a joined member. A joined receiver can be up to 150 m further from the +published point than the donut allows, the site publishes one point, and one +honest radius belongs to it. + +## Why the original objection no longer holds + +*A neighbour disconnecting would move a node.* Positions are remembered, never +dropped, since #286; a disconnect changes nothing. + +*A chain of neighbours would merge a street.* Greedy assignment bounds every +member to within the radius of its own anchor. Four houses 100 m apart in a +line become two sites of two, not one site spanning 300 m. + +*Connection order would decide the anchor.* The pass runs over sorted ids from +the remembered position map, so the result is a function of configuration +alone. What remains is the case exact equality always had: a newcomer with a +lower id at an existing site would become its anchor and move it. Rule 1 +runs first precisely so that a site already published at one point cannot be +re-anchored by a lower-id near neighbour; only a site that was already +publishing two samples can be moved, once, to publish one. + +## What changes on deploy + +Only nodes the audit was already reporting as near misses move: on the test +droplet, exactly the 56 m and 16 m cases. Every node alone at its coordinates +hashes its own id from its own position as before, and every exact-equality +site keeps its anchor and its point. Before deploying to staging or +production, read that environment's `node_sites:` log lines: the near-miss +list is exactly the set of nodes that will move. + +Rejected alternatives: quantising coordinates to a grid (boundary splits, and +strangers in one cell merge for no reason); frontend-only clustering (hides +the marker, leaves both samples in every API and the permanent archive); a +persistent site table (the right eventual shape, but `node_sites` has no write +path today and the stability it buys is what the frozen first pass already +gives). An owner-declared "same site as node X" remains the intended override +for the case no rule can decide — two receivers 60 m apart that really are two +houses — and is not blocked by this change. + +`NODE_FUZZ_SITE_AUDIT_KM` is still read as the older name of the radius. +Setting the radius to 0 restores exact equality alone. `NODE_FUZZ_SALT`, +`_ORIGINAL_FRAME` and the lone-node identity are untouched. diff --git a/docs/design-notes/README.md b/docs/design-notes/README.md index 5443611b..8818b5df 100644 --- a/docs/design-notes/README.md +++ b/docs/design-notes/README.md @@ -21,4 +21,5 @@ date in the filename. | [2026-08-26-detection-mirror.md](2026-08-26-detection-mirror.md) | Shipped (#270). Production forwards every accepted v1 detection frame to the test droplet's existing bulk ingest, so real node data reaches that pipeline without the test droplet holding any node lifecycle. Explains why the Cloudflare Worker tee was rejected, and what the bulk endpoint had to learn about configuration. | | [2026-09-05-solve-uncertainty-disc.md](2026-09-05-solve-uncertainty-disc.md) | Shipped. The calibrated position-uncertainty model behind the disc drawn around multinode solves on the live map: why the LM Jacobian sigma is a lower bound, the 944-solve fit of the per-node-count floors, the growth-with-age term, and the two feed fields (`pos_sigma_m`, `pos_sigma_vel_ms`) the frontend consumes. | | [2026-09-10-node-location-privacy.md](2026-09-10-node-location-privacy.md) | Shipped. The two-state node location policy: private (nothing that locates the receiver is published, the node still contributes to multinode solves) or public (the deterministic donut fuzz). Adds the owner/admin override table and routes, the owner's view of their own private node, and the contract wording; rejects a per-node fuzz opt-out. | +| [2026-09-11-node-site-proximity-gate.md](2026-09-11-node-site-proximity-gate.md) | Shipped. A receiver configured within 150 m of an existing receive site is published from that site's position with its offset, so two independently typed fixes for one roof stop publishing two samples of it. Explains the frozen exact-equality pass, the greedy non-transitive join, why the anchor position (not just the offset) is shared, and which nodes move on deploy. | | [2026-05-20-spectrum-analyser-experiment.patch](2026-05-20-spectrum-analyser-experiment.patch) | Abandoned experiment, kept as a raw patch. An RF-profile endpoint and spectral-measurement handling for tower ranking (May 2026, never PR'd). Its branch was deleted in the August 2026 pruning; this file is now the only copy. Apply with `git apply` onto a commit near its date if it's ever revisited. |