adsb truth: query one region per node cluster, not one box for the fleet (86cb5p1jy site 1) - #272
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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>
6f48365 to
aad5ea4
Compare
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 finished @Babissimo's task in 5m 21s —— View job Code review
Current HEAD ( Finding: the PR's stated safety mitigation isn't in the diff
The PR description states:
Diffing this function against 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(...)
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 Still open from earlier rounds
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
Everything else reviewed and looked solid
|
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_cacheheld 3 aircraft on production, against roughly 2,990 when the fleet sat in one metro. It is the truth source for verification matching,fresh_adsbdead reckoning,_cross_validate_adsb_reports, and the public endpoint._fetch_external_adsbreduced 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_fetcherreported 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:
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 toblocked: 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, andFalse == 0.0, so a node posting{"adsb_lat": false}through the unvalidated analytics route would otherwise reachhaversine_kmand be charged for the 8,000 km it appears to be off.Deliberately not here
X-Rate-Limit-Retry-After-Seconds(measured at 5,929 s), not the fixed 300 s guess.task_last_successis stamped even on a cycle that left the cache stale.test_mlat_historytimeout 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-filesclean.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