Skip to content

Node sites: a lone receiver within 150 m of a site joins it - #359

Merged
jehanazad merged 1 commit into
mainfrom
feat/node-site-proximity-gate
Sep 11, 2026
Merged

jehanazad merged 1 commit into
mainfrom
feat/node-site-proximity-gate

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

Why

services/node_sites.py (#286) publishes co-located receivers at one point, but only when their configured coordinates are equal at 6 decimals. The fleet has two real cases the rule cannot see: a fourth receiver configured 56 m from three at one address, and a receiver 16 m from a pair at another. The co-location audit logs both every cycle. Each publishes its own donut draw, so the map shows a second marker ~1 km away (two independent draws, not the true 56 m) and an attacker holds two samples of one address: 2.36 km² of consistent region becomes 0.69 km².

Aligning the configs (the log's advice) edits the physics input and would solve the node against a wrong position. This fixes it at the publication edge instead.

What

Two rules, in order, in node_sites._cluster():

  1. Exact equality (unchanged) — formed first, anchors frozen. No site already published at one point can be re-anchored.
  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), else becomes one. Greedy over sorted ids, not transitive: every member is within the radius of its own anchor, four houses 100 m apart in a line become two sites, and connection order is irrelevant.

A joined node is published from the anchor's configured position (site_position), not from its own with a shared offset — sharing only the offset would leave two markers 56 m apart with the true baseline between the receivers on the wire. translate_polygon and public_point_delta carry the join shift as well as the offset (_public_delta_deg), so arcs, trails and coverage stay rigid around the marker. location_uncertainty_km(node_id) widens by the radius for every member of a site with a joined member, so the single published point carries one honest radius.

colocation_report() gains proximity_joins (the nodes this moved, with their true gap) and near_misses becomes the residual band up to 2× the radius. Radius 0 restores exact equality alone. NODE_FUZZ_SITE_AUDIT_KM is still read as the older name.

What moves on deploy

Only nodes the audit was already reporting: on test, exactly the 56 m and 16 m cases. Lone nodes and exact-equality sites hash and publish exactly as before (pinned by tests). Before deploying to staging or prod, grep that environment's node_sites: WARN lines — the near-miss list is exactly the set of nodes that will move. NODE_FUZZ_SALT, _ORIGINAL_FRAME and the lone-node identity are untouched.

Design note: docs/design-notes/2026-09-11-node-site-proximity-gate.md. Pairs with #358 (site markers): that PR groups markers by exact published coordinate, which now holds for joined nodes too.

Verification

  • tests/test_node_sites.py + tests/test_public_location.py: 91 passed (new: 56 m join, 16 m join with a lower-id newcomer not re-anchoring the pair, published point coincides, raw-vs-rounded anchor, artefact rigidity, widened uncertainty, no chaining, order independence, nearest-site choice, beyond-radius stays lone, anchor disconnect does not move the joiner, radius 0, both env names).
  • Full backend pytest: green. pre-commit run on changed files: passed.
  • Live verification owed after deploy to test: /api/radar/nodes should show retf1144fcd at the Greenville trio's point and ret72b1909e at the Atlanta pair's; location_uncertainty_km 1.15 at both sites; one node_sites: INFO line per join and no WARN.

🤖 Generated with Claude Code

Two receivers on one roof are routinely two independently typed fixes
tens of metres apart, so the exact-equality site rule (#286) left them
as two sites publishing two samples of one address — a 56 m and a 16 m
case are live in the fleet, each logged as a near miss every cycle.

A node alone at its coordinates now joins the nearest existing anchor
within NODE_FUZZ_SITE_KM (150 m, the audit distance) and is published
FROM the anchor's position with the site's offset, so the markers
coincide and the true baseline between the receivers is not on the wire.
Exact-equality sites are formed first with frozen anchors, so no site
already published at one point re-fuzzes; the join is a greedy pass in
id order, so chains do not merge a street and connection order is
irrelevant. Coverage polygons, arcs and trails move by the same total
delta; location_uncertainty_km widens by the radius for a site with a
joined member. Radius 0 restores exact equality alone; the older
NODE_FUZZ_SITE_AUDIT_KM name is still read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 4m 48s —— View job


Review: PR #359 — Node sites: proximity gate

  • Read CLAUDE.md / ONBOARDING for repo conventions
  • Read changed files (node_sites.py, public_location.py, tests, design note, constants, .env.example, analytics_refresh.py)
  • Review diff against origin/main
  • Check test coverage claims against actual tests
  • Attempt to run pytest / pre-commit locally
  • Post review feedback

Note: I could not execute pytest or pre-commit in this sandbox — the shell tool required approval for any Python invocation and none was available in this non-interactive run. Everything below is from static review of the diff (git diff origin/main...HEAD) plus reading the full implementation and test files.

Summary

This is a careful, well-documented change. I traced the core logic in backend/services/node_sites.py:225-277 (_cluster) against every claim in the PR description and design note, and they all check out:

  • Greedy, not transitiveanchors only ever contains true anchors (exact-equality groups + singles promoted to anchor status), never joined members. A single joined via proximity can't become a stepping stone for a farther node, which is exactly what test_a_chain_of_near_neighbours_does_not_become_one_site (backend/tests/test_node_sites.py:283) verifies. Traced the 4-houses-in-a-line case by hand — it does produce two sites of two, as documented.
  • Order independence — singles are processed in sorted-id order derived from the remembered position map, not connection/insertion order, so test_the_join_does_not_depend_on_connection_order (:299) is a real property, not a coincidence of the test's specific inputs.
  • Anchor freezing — Pass 1 (exact equality) groups are computed first and appended to anchors before Pass 2 ever runs, so a lower-id proximity newcomer can join an existing exact-equality anchor but can never re-anchor it. test_a_receiver_16_m_from_a_pair_joins_it_even_with_a_lower_id (:222) confirms this directly.
  • Publish-from-anchor correctnesssite_position/site_shift_deg in node_sites.py:338-371 and _public_delta_deg in public_location.py:316-330 correctly compose the join shift with the fuzz offset so translate_polygon/public_point_delta stay rigid. The anchor's raw configured position (not the rounded equality key) is what gets published from — verified by test_the_joined_node_lands_on_the_anchor_as_configured_not_as_rounded (:246), which is a legitimately subtle case (a value that rounds differently at 6 vs. 4 decimals) and is nice to see explicitly tested.
  • Widened uncertainty is a correct upper boundlocation_uncertainty_km adds the merge radius (not 2×) to the donut max, which is the correct triangle-inequality bound on distance(true joined position, published point). Both call sites (public_location.py:427, analytics_refresh.py:308) were updated to pass node_id; I grepped for any other caller and found none missed.
  • Config: node_fuzz_site_km() (constants.py:561) correctly falls back to the older NODE_FUZZ_SITE_AUDIT_KM name, and radius 0 disables the proximity pass entirely (radius_km > 0 guard in _cluster), matching test_a_zero_radius_is_the_exact_rule_alone.
  • No route under /v1/nodes or node_config.py-bound config changed, so correctly no contracts/nodes-v1.openapi.yaml regeneration was needed here.

Minor observations (not blocking)

  1. node_sites.py:410-417colocation_report() re-reads node_fuzz_site_km() independently of the radius baked into the currently-cached _identities/_anchor_positions (computed at the last _refresh_locked() call, up to 30s stale per _TTL_S). If the env var changes at runtime, there's a narrow window where the report's displayed merge_radius_km/audit_threshold_km disagree with the radius that actually produced the cached clustering, self-healing at the next refresh. Very low practical impact (env vars don't typically change without a restart) — flagging only because a reader might expect the two to always agree.
  2. Stale anchor positions never expire (_positions dict, node_sites.py:119) — this predates this PR (from Publish co-located receivers at one point, not two #286) but the proximity rule increases its relevance: once a node has ever reported a position, that position (and its ability to anchor a site) lives for the process lifetime even after the node is fully decommissioned. Documented and intentional ("Positions are remembered, never dropped"), just noting it's now load-bearing for a second rule.

Verification claims

The PR's own numbers (91 passed, pre-commit clean) look consistent with the diff — the new TestProximityJoin class alone accounts for 12 new tests, and the existing TestAudit/TestSiteIdentity tests were updated in place rather than duplicated. I did not independently execute the suite (see note above), so this is corroboration by reading, not a green run.

No correctness issues found. This is ready from a code-review standpoint; the live-verification steps the author already flagged (checking /api/radar/nodes and the node_sites: log lines on deploy) remain the right way to confirm behavior against the real fleet data.

@jehanazad
jehanazad merged commit d91598b into main Sep 11, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant