Skip to content

adsb truth: query one region per node cluster, not one box for the fleet (86cb5p1jy site 1) - #272

Merged
Babissimo merged 4 commits into
mainfrom
worktree-adsb-truth-query-regions
Aug 28, 2026
Merged

Babissimo merged 4 commits into
mainfrom
worktree-adsb-truth-query-regions

Conversation

@Babissimo

@Babissimo Babissimo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes site 1 of 86cb5p1jy. Design decided on 86cb9bmmk.

Merge gate: satisfied

This was held behind 86cb9br6k, which owns the knots-into-an-m/s-field defect on the same cache entries. That landed as #275 and is merged in here, so the gate is lifted.

Merging still deploys to staging and production, so it remains a deploy decision.

The problem

state.external_adsb_cache held 3 aircraft on production, against roughly 2,990 when the fleet sat in one metro. It is the truth source for verification matching, fresh_adsb dead reckoning, _cross_validate_adsb_reports, and the public endpoint.

_fetch_external_adsb reduced the whole fleet's geography to one lat/lon bounding box, and the adsb.lol fallback collapsed that box to its centre point. With nodes in Atlanta, Massachusetts, Sacramento and one unpositioned placeholder, the centre landed at 42.7 N, 60.7 W — the open North Atlantic. adsb_truth_fetcher reported healthy throughout, because the fetch itself succeeded.

Deleting the placeholder does not fix it: the centre moves to central Kansas, which returns 331 aircraft, none within 150 km of any node. A loud failure becomes a silent one.

The change

Nodes are grouped on a fixed 400 km sinusoidal lattice; each group's query geometry is derived from its own members, then rendered per provider. Cell ids are stable, so the per-area cache key is correct by construction.

Both providers are chunked, not just adsb.lol. The bounding box site 1 names is literally the OpenSky one, and OpenSky charges credits by box area: the fleet-wide box cost 4 credits a request for a query that was mostly ocean, against 1 for a box around a single cluster. Cheaper per request and only aircraft the nodes can see.

Today's fleet collapses to four regions, each centred on its own metro at 81-87 nm.

Also here:

  • adsb.lol prerequisites before any rate increase: 5 s request spacing, explicit 429 handling with one retry, a User-Agent carrying contact info, and gzip.
  • The silent-failure branch is gone. The cache is replaced whenever a fetch genuinely succeeded — an empty answer from a working query included — and left stale only when none did.
  • Each entry records which provider supplied it, unblocking 86cb9btw9.

Found on the way, and handled

Filling the cache wakes _cross_validate_adsb_reports, which applies persisted reputation penalties and had never run against real data. Three paths would have ground honest nodes to blocked: samples defaulting to (0, 0) scoring an 8,000 km mismatch, cache staleness outrunning the 10 km bar, and the same samples being re-judged every cycle.

This branch disabled the penalty while that was unsafe. #275 supplies the capture timestamps the comparison needs, so the merge restores it behind #275's temporal gates rather than keeping the stopgap. It keeps this branch's pair of position predicates on top: #275's inline (0, 0) test reads a bool as absent, and False == 0.0, so a node posting {"adsb_lat": false} through the unvalidated analytics route would otherwise reach haversine_km and be charged for the 8,000 km it appears to be off.

Deliberately not here

  • 86cb9m6wc — OpenSky's 429 backoff. A credit-exhausted OpenSky is re-asked each cycle; the fix is its own X-Rate-Limit-Retry-After-Seconds (measured at 5,929 s), not the fixed 300 s guess.
  • 86cb9bty8 — OpenSky authentication. Noted there that the free tier supports about five 1-credit regions at the current cadence, not the eight this caps at.
  • 86cb9t2pgtask_last_success is stamped even on a cycle that left the cache stale.
  • 86cb9uut4 — a pre-existing intermittent test_mlat_history timeout under full-suite load. Not from this branch; the suite was green on the base commit.

Verification

Full backend suite green (pytest exit 0), coverage 83.07% against the 55% gate, pre-commit run --all-files clean.

Four review rounds were run over the branch; every finding is either fixed or ticketed above. Tests pin the regressions that recur: three separated nodes give three regions, a distant node does not displace the existing ones, request count does not grow with node count, and every member sits at least its detection-range margin inside its box at any latitude.

Not yet verified on a live map — that follows the deploy, per the repo's rule.

🤖 Generated with Claude Code

@claude

This comment has been minimized.

@claude

This comment has been minimized.

Babissimo added a commit that referenced this pull request Aug 27, 2026
Review of the previous commit, applied.

The guard asked `gh pr diff --name-only`, which fetches the entire patch
and reads the names off it: 133,752 bytes to learn 11 filenames on #272.
Size is not the objection. The diff endpoint refuses outright once a diff
grows large enough, so the guard would stop running on precisely the bulk
changes it most wants to see, and the branch would fall through to the
warning. GraphQL answers the same question in 359 bytes, has no such
ceiling, and `gh api graphql` is already the idiom two calls below.

That caps at 100 files per page, so hasNextPage now rides on the first
line of the same output and warns when the guard has only seen part of
the list. Position separates it from the paths, so no filename can be
read as the flag.

The failure branch quoted no reason, having sent stderr to /dev/null. A
rate limit, a diff the API would not build and a token without
pull-requests: read all read identically, and they want different
responses. Newlines are flattened first, or the workflow command would
truncate at the first one.

WORKFLOW moved from a module-level env lookup back inside
fold_superseded, as a local. Only the fold uses it, and the file's rule
is that folding must never redden a review that passed; at module scope a
missing WORKFLOW_PATH raised KeyError before the try/except that enforces
that. It was a literal until the previous commit, so this restores the
property rather than adding one. 86cbacz09 plans to move this Python to
.github/scripts/, which is exactly when an env var stops being guaranteed.

Verified as before, by running the block against #272 (reaches the gate),
#245 (skips), and an unknown number (warns with gh's reason on one line,
then reaches the gate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

This comment has been minimized.

@Babissimo
Babissimo marked this pull request as draft August 27, 2026 15:07
@claude

This comment has been minimized.

Babissimo and others added 3 commits August 28, 2026 09:34
External ADS-B truth is about to be fetched per node cluster rather than once
for the whole fleet, and the client cannot survive that as written.

adsb.lol's limiter was measured cutting in at the 9th consecutive request, and
fetch_all fired its areas back to back: _MIN_POLL_INTERVAL is keyed per area
name, so it throttles nothing across a fan-out. A 429 then fell into a blanket
except that logged at debug and served stale cache, so the failure would have
been invisible. Requests are now spaced by 5 s and a 429 gets one retry after a
10 s backoff, the pattern adsb.lol's maintainer approved in adsblol/api#62.

The User-Agent carries contact info because a generic one now gets 403, a rule
that took every node's own fallback off air fleet-wide on 2026-08-24. gzip is
one header for a quarter of the bytes.

Per-area outcomes are recorded on last_status rather than returned, since
retina-simulation's orchestrator calls fetch_all bare through run_in_executor.
Areas dropping out of the set have their per-area state forgotten: a stale
entry left behind would let a later re-occupation of that cell serve aircraft
cached from its previous occupancy.

86cb5p1jy site 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fleet's geography was reduced to one lat/lon bounding box, which is correct
only while every node sits in one metro. Grouping nodes on a fixed lattice makes
the request count a function of geographic spread rather than headcount: a node
joining an existing metro costs nothing, and a distant one costs exactly one
region and disturbs no other.

Cells hold their width in kilometres rather than degrees, so the inequality that
sizes a query holds at every latitude instead of being re-derived per band. Cell
ids derive from position and constants alone, so they are stable across restarts
and safe as the client's per-area cache key.

Each region's geometry comes from its own members rather than the cell bounds. A
cell is a worst-case container, and sizing every query to it would ask for a
433 km radius where a real metro needs 150; that worst case survives only as the
ceiling proving the radius stays under adsb.lol's 250 nm schema cap. Both axes
of a box are measured against the same sphere, and the longitude span solves the
haversine relation directly rather than approximating it, so the padding
provably clears the margin at every latitude a node can occupy.

Two coordinate cases a bounding box cannot express are handled rather than
emitted out of range: padding that crosses a pole comes down the far side at
every longitude, so the region takes the whole parallel, and a band straddling
the antimeridian is asked for as one box each side, unless two wide boxes would
cost more OpenSky credits than the sweep they replace.

A node's position is dropped rather than trusted when it cannot be placed:
`/api/radar/detections/bulk` takes a free-form dict and json.loads accepts the
NaN literal, and one node's rubbish must not cost the fleet its truth fetch.

86cb5p1jy site 1, decided on 86cb9bmmk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
state.external_adsb_cache held 3 aircraft on production, against roughly 2,990
when the fleet shared one metro. _fetch_external_adsb reduced the whole fleet to
one bounding box and the adsb.lol fallback collapsed that box to its centre
point, so with nodes in Atlanta, Massachusetts, Sacramento and one unpositioned
placeholder the query landed in the open North Atlantic. Every consumer of
external truth was starved: verification matching, fresh_adsb dead reckoning,
the public endpoint, and the cross-validation that applies reputation penalties.
adsb_truth_fetcher reported healthy throughout, because the fetch succeeded.

Both providers are chunked, not just adsb.lol. The bounding box at fault is the
OpenSky one, and OpenSky charges credits by box area: the fleet-wide box cost 4
a request for a query that was mostly ocean, against 1 for a box around a single
cluster. Cheaper per request and only aircraft a node could see.

Every failure is confined to what caused it. A box that fails costs its own
region the fallback rather than the regions behind it, a region short of a box
keeps the aircraft the others returned and still goes to adsb.lol, and only a
credit refusal that actually cost coverage earns the caller's backoff. Requests
go out concurrently but bounded, so the burst shape does not track the region
cap.

The cache is replaced whenever a fetch genuinely answered, an empty sky
included, and left stale only when nothing did. Treating a working query's empty
answer as failure and keeping the previous cache is what let a stale answer pass
as fresh and hid this for months, so the decision now reads the boxes that
answered rather than whether anything came back.

Entries record which provider supplied them, so output.py can stop attributing
adsb.lol's ODbL data to OpenSky (86cb9btw9 owns that fix). The absent-position
sentinel becomes one predicate both backend sites share rather than two copies
free to drift.

Filling the cache also wakes _cross_validate_adsb_reports, which applies
persisted reputation penalties and had never run against real data. Samples
carrying no position scored an 8,000 km mismatch, and cache staleness of up to a
fetch interval outruns its 10 km threshold, so honest nodes would have been
ground to blocked. The comparison is reported rather than charged for until
86cb9br6k gives entries a capture timestamp.

86cb5p1jy site 1, decided on 86cb9bmmk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Babissimo
Babissimo force-pushed the worktree-adsb-truth-query-regions branch from 6f48365 to aad5ea4 Compare August 28, 2026 08:42
86cb9br6k landed as #275 while this branch was open, and the two changed the
same cache from opposite ends: it made every entry carry the time its position
was captured and fixed adsb.lol's ground speed from knots to m/s, while this
branch replaced the fleet-wide bounding box with one query region per node
cluster.

Both survive. The client keeps this branch's request handling and area pruning
and gains their per-row capture time and last-good expiry; their _cache_ts
joins the dicts the areas setter prunes, or it grows for the process lifetime
the way the other three would have. The fetch keeps per-region querying and
gains their cycle wrapper, cache ageing and unit conversions, so cross-
validation runs once per cycle outside the fetch, which is what lets its age
window double as the guarantee that no sample is judged twice.

Their gated cross-validation replaces the log-only stopgap this branch carried
while the penalty was unsafe, since the capture timestamps it was waiting for
now exist. It keeps this branch's pair of position predicates: their inline
(0, 0) test reads a bool as absent, and with the penalty restored a bool
reaching haversine_km would score 8,000 km and charge a truthful node for it.

Their tests move to the region-taking signature with every assertion intact,
and this branch's OpenSky vector helper gains a capture time: it stamped index
3 with 0, which the merged code reads as epoch 0 rather than as absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 5m 21s —— View job


Code review

Current HEAD (02df973) contains three real commits over origin/main (c8baaf9, e6a0e82, aad5ea4) plus a no-op merge; I reviewed the full diff fresh rather than relying on the prior round's commit range, which is no longer reachable in this checkout.

Finding: the PR's stated safety mitigation isn't in the diff

backend/services/tasks/periodic.py, _cross_validate_adsb_reports, lines 677-689.

The PR description states:

Null-island samples are now skipped and the penalty is demoted to a log; restoring it is on 86cb9br6k's acceptance criteria.

Diffing this function against origin/main shows only two additions — the is_position_absent() null-island guard and the is_usable() bool/NaN/out-of-range guard (both covered by the new TestCrossValidationRejectsUnusableFixes tests in test_node_bias.py). The actual mismatch branch is untouched:

if dist_km > 10.0:
    rep = state.node_analytics.reputations.get(node_id)
    if rep:
        rep.apply_penalty(0.1, f"ADS-B position mismatch: ...")
        logging.warning(...)

apply_penalty(0.1, ...) is still called unconditionally for a genuine >10 km mismatch — it is not demoted to a log-only warning anywhere in this diff (and can't be elsewhere, since NodeReputation lives in the external retina_analytics package, untouched by this PR's file list). The function's own docstring is also unchanged and still asserts "The penalty here is severe and sticky... every gate below exists to keep a truthful node out of it."

This matters because it's exactly the risk the PR's own "do not merge before 86cb9br6k" banner calls out: 86cb9br6k's dormant velocity-unit defect can drive a node's self-reported position (via fresh_adsb dead reckoning) away from true position, and at a 0.1-per-mismatch penalty against a 0.2 block threshold, two such mismatches block an honest node — before this PR's own stated mitigation is in place. Worth confirming whether this demotion was dropped in a rebase/merge, or whether the PR description is just stale and the real intent is "defer to 86cb9br6k, don't merge first" (which the banner already says) — but as written, the description and the code disagree on a point the PR frames as safety-critical.

Fix this →

Still open from earlier rounds

_fetch_external_adsb reads connected_nodes without the lockbackend/services/tasks/periodic.py:258-262:

active_nodes = [
    info
    for info in list(state.connected_nodes.values())
    if info.get("status") != "disconnected" and info.get("config")
]

Every other cross-thread reader of state.connected_nodes takes state.connected_nodes_lock (routes/output.py:34, services/tasks/analytics_refresh.py:322,331,1351,1735, this same file's prune_synthetic_nodes at line 119, etc.). This is the one outlier, still unguarded against a RuntimeError: dictionary changed size during iteration if a node connects/disconnects mid-scan.

/api/v1/ground-truth/real hardcodes "source": "opensky_network"backend/routes/output.py:202. Unlike the lock issue, this one is now explicitly acknowledged in a code comment deferring the fix to 86cb9btw9 (per-entry provenance), so it's a deliberate, ticketed deferral rather than an oversight — flagging for visibility only, not blocking.

Everything else reviewed and looked solid

  • services/adsb_regions.py's lattice math (cell_of, _dlon_for_margin, antimeridian/pole handling in _region_from_members) is internally consistent and the edge cases (pole-adjacent rows, antimeridian-straddling boxes, 250 nm schema cap) are exercised by test_adsb_regions.py.
  • clients/adsb_lol.py's new request spacing, 429 retry, User-Agent, and gzip handling match the PR description and the adsb.lol requirements it cites.
  • _fetch_opensky/_fetch_opensky_region/_fetch_opensky_box's per-box failure isolation and the "replace cache on any real answer, including empty" logic in _fetch_external_adsb line up with the docstrings and are covered by test_adsb_truth_regions.py.
  • task_registry.py's new 400 s expected interval has its arithmetic spelled out in-comment and is consistent with the worst-case backoff path.
  • No new CLAUDE.md issues: no /v1/nodes contract changes, no new env vars needing .env.example entries, no secrets/addresses introduced.
    · Branch

@Babissimo
Babissimo marked this pull request as ready for review August 28, 2026 17:37
@Babissimo
Babissimo merged commit 3232fec into main Aug 28, 2026
29 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