20260925 - Adopt node ingest v1.6.1: ADS-B position tags and the node's tracks - #23
Merged
Merged
Conversation
blah2-api's enrichment reports where the node saw the aircraft it matched a detection to — lat, lon, altitude, ground speed, track, and its own delay and Doppler residuals — and until now the wire reduced all of that to the hex. Contract 1.5.0 adds an optional `adsb` column to DetectionFrame: an AdsbTag or null per detection, parallel to `adsb_hex` and agreeing with it entry for entry. The server files the position for the detection itself, so it, and every environment it mirrors the frame to, can claim the detection and calibrate the node's coverage without a position source of its own. build_detection_frame fills the column from the same associations `adsb_hex` reads: a tag where the hex passed and a finite lat/lon is present, null where either is missing, the whole column omitted when association is off. A tag the spec refuses (a latitude past 90) costs that one tag, the same trade the other per-entry rules make, and a detection dropped for a non-finite value takes its tag with it. docs/node-ingest-v1.yml is the server's 1.5.0 contract in full, byte-identical to contracts/nodes-v1.openapi.yaml at blob 160ba4e and verified by hash rather than by eye. It originally carried a hand-assembled subset, because regenerating from the whole document renamed the generated node-state enum and broke the service tests. tools/normalise_spec.py has handled that collision since 1.4.0 was adopted, so there is nothing left to work around and the checked-in contract is what the server actually sent again. Rebased onto main at 0.4.0. The conflict was the spec, and taking the subset would have dropped the claim endpoints and the three claim_* response fields that 1.3.0 and 1.4.0 added, breaking the feature merged in #21. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contract 1.5.0 settled after this branch was written: `adsb_hex` is optional and `deprecated: true`, and `adsb` carries the association on its own. The server author's reasoning, on the PR, is that with the tags every match went out twice and nothing on the server reads the frame's hex column. The tracker and path 1 claiming both read the per-detection `adsb` record. So the frame now carries one association column, never both. When association is on it is `adsb`, a tag or null per detection. When it is off the frame carries neither column, which is how a node says it matched nothing. The one thing this costs is a match with no usable position, which used to travel as a bare hex and now travels as null. Signed off by the server author on the same grounds: nothing downstream reads a bare hex. `_tag` now reads and validates the hex from the association itself. It took it from the `adsb_hex` column at the same index, so the two columns could not disagree, which was sound while both shipped and meaningless once one of them does not. `_adsb_hex` went with the column it built; `_hex` stays, because the tag needs exactly the same ICAO check applied to its own entry. `to_wire` needed a fix to go with it, and this one reaches production rather than the tests. `_prune` reads every field of every payload through `getattr`, which on a deprecated field trips pydantic's warning machinery once per field per frame, on the hot path, for a field it is about to drop. It reads out of the instance dict instead. `test_nothing_is_serialised_through_a_deprecated_field` pins it, because the next deprecation will not announce itself. The mock skipped the new column entirely: its parallel-array rule named the four arrays by hand, so with `adsb_hex` gone a misaligned `adsb` would have passed the one check that exists for it. It now covers both, and skips absent columns so that an unassociated frame is not read as a mismatch. `probe_wire.py` and `summarise_run.py` both read `frame.adsb_hex` and would have failed on the first real run. They report placed positions now, which is more use than a list of hexes was. Tests: the ones that asserted on the hex column now assert on the tag, the two that the position tags had made exact duplicates are deleted, and `test_the_deprecated_hex_column_is_never_sent` guards the headline behaviour. `test_adsb_hex_is_not_a_false_positive` asserted that field was required and not nullable, which 1.5.0 made false twice over; the rule it guards is now asserted directly, since no field in the contract has that shape any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… sends The fixture carried eight of blah2-api's ten keys, and a test read the two missing ones and called them unreported: "blah2-api did not report them". That is not true. `bestMatch` in blah2-arm/api/server.js:362-372 passes `ac.gs` and `ac.track` straight through, and since contract 1.5.0 both go on the wire. Nothing was broken by it, because the tag builder maps wire name to wire name and never enumerated the fixture. But a fixture thinner than the real thing is how a mapping bug hides, and this one was one field rename away from sending silent nulls for two values the server's own description says a node reports. docs/data-sources.md had the same gap, which is worse: it is the file that exists so nobody re-derives these facts. It now lists all ten keys and cites the line that builds them. It also records something the contract and the node disagree about. blah2-api sends `alt_geom ?? alt_baro`, so `alt` is geometric altitude wherever the aircraft reports one. `AdsbTag`'s description says the field is barometric feet. Both are feet so nothing is mis-scaled, but they are different datums and they differ by hundreds of feet routinely. Raised with the server author rather than resolved here, since the contract is theirs. The nested-null property the fixture change displaced keeps its own test now: optional nulls inside a tag ride along, because `to_wire` prunes at the top level only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/node-ingest-v1.yml is now the server's contract at 1.6.1, byte-identical to contracts/nodes-v1.openapi.yaml at retina-server 6d37aa7 (blob cdf5ea9), checked by hash rather than by eye. It is also what api.retina.fm and staging serve today. Two revisions arrive together. 1.6.0 lets a detection frame carry the node tracker's confirmed tracks (DetectionFrame.tracker and .tracks, with the new Track and TrackerRun schemas) and adds versions.retina_tracker to the heartbeat. Every addition is optional, so a node that sends neither stays conformant. 1.6.1 is prose only: PublicationChoice no longer promises a dashboard override, because there is none, so the choice sent at registration stands until the node registers again. 1.6.0 also introduces a third inline enum titled State, on Track.state. The generator keeps the first it meets and numbers the rest, so the track state came out as State1. That is the collision normalise_spec.py's NAMED_ENUMS exists for, and it gets a line there, TrackState, as the table asks, rather than code importing whatever number the generator handed out this time. The test that pinned the table to one entry now pins it to both. NodeConfig.beam_width_deg survived, nullable as agreed. Checked, as every adoption must. Nothing sends the new fields yet. That follows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contract 1.6.0 lets a detection frame carry the tracks retina-tracker holds after that frame, each active one naming the detection it took by index into the frame's arrays. The server wants them because it currently runs its own copy of the tracker per node and means to retire it. Until now this service had declared tracks out of scope. The spec now has room for them, so they are in. The tracker had no output that could fill the field. events.jsonl writes nothing for a coasting track and nothing at all when one dies, and names detections by timestamp rather than index. So retina-tracker grows GET /frame?timestamp=<ms> (branch 20260925-serve-each-frames-tracks there), and this reads it. Stage 1, collect/tracker.py. blah2-api forwards the tracker the same bytes it serves us, so the poll loop asks the tracker for the frame it just polled, by timestamp. The two race, so a 404 is retried for up to 0.3 s, but only while the tracker says its latest frame is behind ours: one past it has evicted the frame, and one fed nothing (tracker_forward off) will never have it. An unreachable tracker is "no tracker", and its error reaches errors[] by exception type rather than text, because requests puts an object address in the message and every frame would otherwise add a new entry. Required fields are never substituted: a missing max_velocity_ms refuses the tracks rather than becoming zero. Stage 2, wire/tracks.py. Three things the spec's rules force: - hit is renumbered from blah2-api's index to the one this frame sends, past the non-finite filter and the 512 cap. An active track whose detection did not go out is left out of that frame, not demoted to coasting, which would be false. - A track that ends is sent once more as deleted. Latest-wins can skip the frame it died on, so TrackLedger remembers what this process has sent alive and sends deleted for any the tracker no longer holds. A new tracker.run forgets them instead, because the old run's ids mean something else now. - At most 32 tracks: active first, then coasting. A deletion that does not fit stays remembered and goes on the next frame. With no tracker a frame carries neither field. With a tracker holding nothing for this frame it carries tracker alone, which the server reads the same as tracks: null. Track has two required-and-nullable fields, hit and adsb_hex, inside a list. to_wire's rule reached into nested models but not into lists, so it learns to, RootModel items staying leaves. One consequence is visible: an AdsbTag's unreported gs or track is now absent rather than null, as an optional field is everywhere else. The inventory in test_serialise.py goes from 14 to 16 and walks Track, AdsbTag and TrackerRun too. The heartbeat reports versions.retina_tracker from RETINA_TRACKER_V, the compose variable that pins the tracker's image, passed through in retina-node 20260925-report-the-tracker-version. The mock refuses tracks that do not add up with the server's own words, copied from _tracks_name_this_frames_detections in retina-server, and every frame the ledger builds in its tests goes through that check. Verified across both repos as well: the real tracker branch, fed 80 synthetic frames with a non-finite detection on every other one and every fifth frame skipped, gave 64 frames that all pass the server's rules, every active hit naming the right detection, and the aircraft that stopped going coasting and then deleted exactly once. Not yet run on a node, and it needs a retina-tracker release carrying /frame before it sends anything: against today's tracker image, /frame is a 404 without a run, which reads as no tracker, and frames go out untracked exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found live on owl-debb, against the node's own retina-tracker v0.3.0. Its control server predates /frame and answers any unknown route with a bare 404, so the reader logged a warning and added "tracker answered 404 without a run" to errors[] on every frame. The frames themselves went out correctly, untracked. That is the state of every node from the moment this release lands until a tracker carrying /frame reaches it. It is an expected state of the rollout, not a fault, and errors[] is now stored by the server for its operators to read, so the whole fleet would have reported a problem that is not one for the length of the rollout. A 404 with no run in it is now recognised as that case: no tracker, nothing in errors[], and one INFO line in the log rather than a warning a frame. Any other answer missing a run is still an error. Re-run on owl-debb afterwards: one INFO line, 32 untracked frames, errors[] empty. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
live-service.sh could only exercise tracks against the node's running retina-tracker, which has no /frame until a release carries it, so the tracks a frame now carries could not be seen on a node at all. owl-debb also has no SDR attached today, so there were no detections to track either. TRACKS=synthetic or TRACKS=relay adds two throwaway containers on loopback ports nothing else uses. One is a tracker: the node's own retina-tracker image with a local checkout's package mounted over it (TRACKER_SRC, the sibling repo by default). The other is tools/frame_source.py, which stands in for blah2-api's detection path the way blah2-api does it: store the frame, serve it, forward the same bytes to the tracker. `synthetic` makes frames from the node's own centre frequency and span, with targets that move consistently, one that is born and dies, clutter, and the order shuffled every frame so a track's index moves. `relay` passes blah2-api's real frames on. The node's own blah2-api and tracker are never written to, and both containers are removed on exit. TRACKER_URL overrides which tracker is asked, which is how the old-tracker path was checked. versions.retina_tracker comes from the running tracker's image tag, as compose would pass it. REQUESTS_OUT keeps what the mock received, bodies included. The summary gains a tracks section that re-checks every frame against the server's track rules and the generated model, since the mock does not record what it answered and a refused frame would otherwise look like an accepted one. On owl-debb (ret4c844c20), synthetic, 120 s: 133 frames, every one carrying the side tracker's result for its own timestamp, 3 tracks, 244 active and 10 coasting entries, the target that stops deleted exactly once, every frame passing the server's rules, errors[] empty. A 50 s run kept its requests: all 110 active hits named an 18 dB target rather than the weaker clutter, 73 of them at an index other than 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tracks and ADS-B tags were run against api.retina.fm on jonathan-node-1 (ret3773656d) on 2026-09-25, and CLAUDE.md still said they had not been run on a node at all. docs/data-sources.md's section on the tracker said the same. Both now say what was run and what it showed. The branch code was mounted into the node's own two containers through a compose override passed with -f, so nothing outside the test could pick it up. Over about 23 monitored minutes, 2,200 frames were paired with the tracker's answer for their own timestamp, none missed, and all 279 active hits named the detection the tracker's own events.jsonl recorded for that track. errors[] stayed empty, so production accepted every frame. One track was bound to an aircraft, a92361, and its reported top speed matched the aircraft's ground speed. The node rebooted at about 14:33Z with no shutdown record and no surviving log, so the cause is unknown. It is recorded as that, alongside why a hang and reset fits the evidence better than a crash, rather than being explained away. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adopts node ingest 1.6.1 and sends everything new in it: ADS-B position tags (1.5.0) and the node tracker's confirmed tracks (1.6.0). Supersedes #19: Jehan's commit is here unchanged in code, rebased, with the follow-ups below on top.
Companions: offworldlabs/retina-tracker#36 (the
/frameroute this reads) and offworldlabs/retina-node#45 (passesRETINA_TRACKER_V). This is safe to merge in any order with them: against a tracker without/frame, frames go out without tracks and the node logs one line.What moved
docs/node-ingest-v1.ymlis byte-identical to retina-server'scontracts/nodes-v1.openapi.yamlat 6d37aa7 (blobcdf5ea97), which production and staging serve. Everything added since 1.4.0 is optional, so a 0.4.0 node stays conformant.NodeConfig.beam_width_degsurvived, nullable as agreed.What it sends now
adsbtags, and noadsb_hex. Jehan's Send each association's position alongside its hex (contract 1.5.0) #19 sent both; 1.5.0 then deprecatedadsb_hexand the server author agreed to drop it, so a frame carries one association column or none. A match with a hex but no usable position now travels asnullrather than a bare hex, which nothing on the server reads.trackerandtracks, read from the tracker'sGET /framefor the frame just polled, paired by timestamp. Three rules the contract forces, all inwire/tracks.py:hitis renumbered from blah2-api's index to this frame's, past the non-finite filter and the 512 cap. An active track whose detection did not go out is left out of that frame, not called coasting.deletedonce, even when latest-wins skipped the frame it died on.TrackLedgerremembers what this process sent alive. A newtracker.runforgets instead, because the old ids mean something else under it.versions.retina_trackerfromRETINA_TRACKER_V.Two things that are not mechanical
State(Track.state) would have generated asState1. It gets a named entry innormalise_spec.py, as the table there asks.to_wirenow reaches into lists.Trackhas two required-and-nullable fields (hit,adsb_hex) inside a list. The visible side effect: anAdsbTag's unreportedgsortrackis now absent rather thannull, as optional fields are everywhere else. The server drops nulls on filing, so it reads the same.Evidence
tools/check.sh --tracked, after rebasing ontomainwith 20260923 - Offer the claim address again after a release #22.Unit and mock: the mock refuses tracks that do not add up with the server's own words, copied from
_tracks_name_this_frames_detections, and every frame the ledger builds in tests goes through that check.Across both repos locally: the real tracker branch fed 80 synthetic frames with a non-finite detection on every other one and every fifth frame skipped. All frames passed the server's rules, and every hit named the right detection.
On owl-debb against the mock (no SDR attached that day, so synthetic frames through
TRACKS=synthetic tools/live-service.sh): 133 of 133 frames tracked, and all 110 active hits named a real target over the clutter. Against the node's real v0.3.0 tracker it found a bug, fixed here: a tracker without/frameput an entry inerrors[]on every frame, which the whole fleet would have reported until the tracker release reached it.Against production on jonathan-node-1, 2026-09-25, with the server engineer watching and
NODE_TRACKS_MODEoff: this branch and the tracker's, mounted into the node's own containers. From 14:04:57Z to the monitor's last reading at 14:28:14Z:events.jsonlerrors[]One track was bound to
a92361, and itsmax_velocity_msof 228 matched the aircraft's 443.9 kt.What has no evidence, or is unexplained
TRACKS=relay(real detections through a side tracker) has not been run.Open with the server author, not blocking
AdsbTag.expected_delayanddelay_residualtravel in km, as blah2-api computes them, whiledelaytravels in µs. The spec gives them no unit.avg_snrreads high on young tracks (see 20260925 - Serve each frame's confirmed tracks by timestamp retina-tracker#36).No version bump here; that has been its own commit.
🤖 Generated with Claude Code