Skip to content

20260925 - Adopt node ingest v1.6.1: ADS-B position tags and the node's tracks - #23

Merged
Purple10101 merged 8 commits into
mainfrom
20260925-adopt-spec-v161
Sep 26, 2026
Merged

Purple10101 merged 8 commits into
mainfrom
20260925-adopt-spec-v161

Conversation

@Purple10101

Copy link
Copy Markdown
Collaborator

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 /frame route this reads) and offworldlabs/retina-node#45 (passes RETINA_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.yml is byte-identical to retina-server's contracts/nodes-v1.openapi.yaml at 6d37aa7 (blob cdf5ea97), which production and staging serve. Everything added since 1.4.0 is optional, so a 0.4.0 node stays conformant. NodeConfig.beam_width_deg survived, nullable as agreed.

What it sends now

  • adsb tags, and no adsb_hex. Jehan's Send each association's position alongside its hex (contract 1.5.0) #19 sent both; 1.5.0 then deprecated adsb_hex and 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 as null rather than a bare hex, which nothing on the server reads.
  • tracker and tracks, read from the tracker's GET /frame for the frame just polled, paired by timestamp. Three rules the contract forces, all in wire/tracks.py:
    • hit is 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.
    • A track that ends is sent deleted once, even when latest-wins skipped the frame it died on. TrackLedger remembers what this process sent alive. A new tracker.run forgets instead, because the old ids mean something else under it.
    • At most 32: active first, then coasting; a deletion that does not fit waits a frame.
  • versions.retina_tracker from RETINA_TRACKER_V.

Two things that are not mechanical

  • A third enum titled State (Track.state) would have generated as State1. It gets a named entry in normalise_spec.py, as the table there asks.
  • to_wire now reaches into lists. Track has two required-and-nullable fields (hit, adsb_hex) inside a list. The visible side effect: an AdsbTag's unreported gs or track is now absent rather than null, as optional fields are everywhere else. The server drops nulls on filing, so it reads the same.

Evidence

  • tools/check.sh --tracked, after rebasing onto main with 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 /frame put an entry in errors[] 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_MODE off: 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:

    frames paired with the tracker's answer for that exact timestamp 2,200 of 2,200
    states sent 279 active, 219 coasting, 9 deleted, across 11 tracks
    active hits checked against the tracker's own events.jsonl 279 correct, 0 wrong
    errors[] empty throughout, so production accepted every frame

    One track was bound to a92361, and its max_velocity_ms of 228 matched the aircraft's 443.9 kt.

What has no evidence, or is unexplained

  • jonathan-node-1 rebooted at about 14:33Z, cause unknown. No shutdown record, no surviving journal, no Mender deployment. The monitor's last line at 14:28 was healthy, which fits a hang then a watchdog or power reset. The test added no measurable load, but that is an argument, not proof. When the server stopped receiving this node's frames would tell a hang (about 14:28) from an abrupt stop (about 14:33).
  • The live run used the code before the rebase onto 20260923 - Offer the claim address again after a release #22. The difference is the claim path, which does not touch detections.
  • TRACKS=relay (real detections through a side tracker) has not been run.

Open with the server author, not blocking

No version bump here; that has been its own commit.

🤖 Generated with Claude Code

jehanazad and others added 8 commits September 26, 2026 11:04
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>
@Purple10101
Purple10101 merged commit 5809762 into main Sep 26, 2026
2 checks passed
@Purple10101
Purple10101 deleted the 20260925-adopt-spec-v161 branch September 26, 2026 11:29
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.

2 participants