Skip to content

Follow a silent known aircraft onto new nodes and re-anchor after a stale prior - #334

Merged
jehanazad merged 6 commits into
mainfrom
feat/known-track-follow
Sep 8, 2026
Merged

jehanazad merged 6 commits into
mainfrom
feat/known-track-follow

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to the known-track HOLD (#333, this PR's base). The hold keeps a silent ADS-B aircraft claimed on the nodes that already had it, but a 15-minute A/B with transponder outages on half the fleet showed it is still shown as itself only ~54% of the time after 45 s of silence. Three causes, one per commit:

  1. A stale heading steers the filter. track_filter._adsb_velocity read gs/track off the cached ADS-B entry with no age check, so during a silence the filter's velocity kept being corrected toward the pre-silence course. When the aircraft turns, the lane's dead-reckoned prior drifts along the old heading (traced: 0.26 -> 1.0 km) while every solve stays within 0.4 km of truth.
  2. A drifted prior is permanent. Once displacement passes _MAX_DISPLACEMENT_KM the solve is labelled ghost, ghosts are not published, so the prior stops being refreshed and the gap only grows: 25 consecutive ghosts, all within 0.6 km of truth, against a prior 13 km away.
  3. Nodes that acquire the aircraft mid-silence have nothing to claim with. No tag, no fresh fix, no hold -> their detections reach the dark pool; in the capture four such nodes minted a dark twin 141 s in.

Changes

  • A. Stale-fix guard (services/track_filter.py, services/tasks/solver.py): _adsb_velocity takes the epoch the velocity is wanted for (the solve's own timestamp_ms, never wall clock) and returns "no ADS-B velocity" past ADSB_SEED_MAX_DR_AGE_S (45 s, the constant KNOWN_CLAIM_MAX_FIX_AGE_S aliases). Imported from the library, not from known_claiming, which reaches track_filter through dark_follow. solver._ewma_smooth_track was the other unaged reader in a dead-reckoning path and now shares the helper. solver._gt_stamp's ADS-B fallback already ages its fix (_MLAT_HISTORY_ADSB_MAX_DT_S) and scores rather than steers, so it is unchanged.
  • B. Re-anchor (services/tasks/known_lane.py): _reanchor keeps the last kf-seeded ghost per hex (60 s TTL, cleared by _reset_for_tests). KNOWN_LANE_REANCHOR_STREAK (default 2) consecutive kf-seeded ghosts that agree with EACH OTHER to within _MAX_DISPLACEMENT_KM are published as label reanchored and counted in known_lane_reanchored. A fix-seeded ghost never re-anchors: a live transponder stays the lane's truth gate. 0 disables.
  • C. Follow (services/known_claiming.py): _follow_states builds synthetic path-2 candidates from published mn-adsb-<hex> entries for hexes whose fix is stale or gone — at most KNOWN_FOLLOW_MAX_AGE_S (20 s) old, KNOWN_FOLLOW_MIN_SOLVES (3) solves behind them, velocity from learned_velocity, world tag from the cache while it lasts. They enter the existing path-2 machinery unchanged (visibility gate, range prescreen, age-scaled gates, Hungarian). The claim carries the hex's ORIGINAL stale fix, taken from any node's hold, so the lane still reads the aircraft as silent; plus follow: True and the hold every claim creates, so the new node holds the track from the next frame. Counter known_follow_claims; KNOWN_FOLLOW_MAX_AGE_S=0 disables. Path H precedence and the consistency rule are untouched, and dark_follow.py is not modified.
  • Both counters exported in the solver-stats known_lane / known_claims blocks; docs/solverflow.md extended beside the path-H paragraph, with the new constants in the table.

Test coverage

New backend/tests/test_known_track_follow.py (12 tests): a 60 s-old eastbound fix does not stop the filter learning a southward velocity while a fresh one still seeds it; two self-consistent kf-seeded ghosts publish as reanchored (counter, history record, feed entry) while a fix-seeded streak and two mutually inconsistent ghosts do not; a second node with no hold and no fresh fix claims a detection predicted from a 5 s-old 3-solve entry, carries follow=True and the original fix, and gains a hold — and nothing is offered for a too-old entry, a below-threshold solve count, a fresh fix, or with the path switched off.

Review notes

  • Backend only; no library, frontend or submodule-pin changes.
  • A follow candidate is offered to path 2, never to path H, so a hold always outranks it and a linked track still cannot be peeled off.
  • _adsb_velocity still uses an entry that carries no timestamp at all: no live writer produces one, and rejecting it would disable the velocity seed for pre-timestamp records rather than address the stale-heading case.
  • Re-anchor and follow are independently switchable (KNOWN_LANE_REANCHOR_STREAK=0, KNOWN_FOLLOW_MAX_AGE_S=0) on top of the hold's own KNOWN_HOLD_MAX_GAP_S=0.

Live verification (test fleet, simulator transponder outages, 15-minute captures)

Same scene as the #333 A/B (frac_adsb_outage 0.5, frac_dark 0 so every dark-lane key is a ghost of an ADS-B aircraft), third column is this branch on top of #333:

hold off hold on (#333) hold + this PR
dark-lane ghost solves 1392 224 47
dark solves on transponder-silent aircraft (distinct keys) 267 (23) 184 (13) 37 (4)
new dark keys minted 36 12 4
silent aircraft-frames with a dark twin within 5 km 19 % 14 % 5 %
shown as itself after 45 s of silence (in coverage at outage start) 44 % 54 % 66 %

The stale-velocity guard is what moved the last two rows: with it, every filter-seeded known-lane solve in the window was a truth match (45 of 45) and the "ghost forever" loop did not occur, so the re-anchor never had to fire (known_lane.reanchored 0). The follow path also recorded 0 claims in this window — its precondition (a published entry under 20 s old plus a node newly acquiring the silent aircraft) did not arise in 15 minutes with about three silent aircraft at a time — so it is covered by its unit tests only; the counter known_claims.follow_claims will show it working when it does. No claiming errors, bench-neutral paths untouched.

🤖 Generated with Claude Code

jehanazad and others added 4 commits September 8, 2026 07:58
state.adsb_aircraft keeps an entry long after the transponder stops
reporting, and _adsb_velocity read gs/track off it with no age check.  For
an aircraft the known lane is deliberately still solving from radar alone
that is a heading from before the silence: when it turns, the filter's
velocity keeps being corrected toward the OLD course (the ADS-B velocity is
a recurring measurement update, sigma 5 m/s), the lane's dead-reckoned prior
walks off along it, and the honest solves that follow are measured against
that prior and labelled ghosts.  Traced on one aircraft in a 15-min capture:
the guess drifted 0.26 -> 1.0 km through a turn while every solve stayed
within 0.4 km of truth.

Both smoothers now age the fix against the SOLVE's own epoch (never wall
clock, so a replay sees the same verdict) and fall back to the solved
velocity past the same 45 s cap claiming uses.  The constant comes from the
library rather than from services.known_claiming, which reaches this module
through services.dark_follow and would close an import cycle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The kf seed is a prior, not a measurement: the lane's own last published
position dead-reckoned forward.  When the aircraft turns during its silence
the prior keeps flying the old heading, displacement crosses the ghost
threshold, and because a ghost is never published the prior stops being
refreshed — so the gap grows without limit and every later solve is judged
against a position the aircraft left minutes ago.  A capture shows 25
consecutive ghosts, all within 0.6 km of truth, against a prior 13 km away;
the aircraft never comes back.

What separates a moved prior from a wrong solve is repeatability — a wrong
solve is wrong somewhere new each time.  So KNOWN_LANE_REANCHOR_STREAK
(default 2) consecutive kf-seeded ghosts that agree with EACH OTHER to
within the same displacement cap are published as label "reanchored".  A
fix-seeded ghost never re-anchors: a live transponder stays the lane's truth
gate, and re-anchoring away from it would publish a position the aircraft's
own fix contradicts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hold only helps the nodes that already had the aircraft.  A node that
ACQUIRES one during its silence has no tag, no fresh fix and no hold, so its
detections go straight to the dark pool: in the capture, four such nodes
minted a dark twin 141 s into an outage, beside the entry the known lane was
still publishing.

The lane's own published position is the candidate those nodes are missing —
a radar measurement of that aircraft, seconds old, on a key every node can
read.  _follow_states offers each mn-adsb-<hex> entry (at most
KNOWN_FOLLOW_MAX_AGE_S old, KNOWN_FOLLOW_MIN_SOLVES solves behind it,
velocity from the filter, world tag from the cache while it lasts) to path 2
as an ordinary cached state, so it goes through the existing machinery
unchanged: visibility gate, range prescreen, age-scaled gates, Hungarian.
Hexes with a FRESH fix are skipped — path 2 already has the better candidate
and the same aircraft must not enter the assignment twice.

The claim carries the hex's ORIGINAL stale fix (from any node's hold, since
the fix is the aircraft's and not the node's) so the lane still reads the
aircraft as silent and seeds from its own solve; plus follow: True, and the
hold every claim creates, which is the point — from the next frame the new
node holds the track on its own measurements.

Counters known_follow_claims and known_lane_reanchored ride the solver-stats
known_claims / known_lane blocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two follow-ups from the full suite.  The velocity guard rejected a fix whose
timestamp sits AHEAD of the solve epoch as well as behind it, which is not a
stale heading — it is a fresher one, and it is what clock skew between a node
and the ADS-B feed (or a fixture whose solve timestamps are small synthetic
numbers) actually produces.  Only lateness is guarded now.

And the two known_* solver-stats blocks are asserted key-for-key, so the new
reanchored / follow_claims entries have to be named there.

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

This comment has been minimized.

@jehanazad
jehanazad changed the base branch from feat/known-track-hold to main September 8, 2026 08:15
@claude

This comment has been minimized.

…the fix

A follow candidate without a world tag passes the world gate on every
node, and real traffic flies over the simulated fleet's footprint: the
decoy case that gate exists for. Every hold stamps the claiming node's
world, so a silent hex whose cache entry has been pruned still carries one.

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

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 2m 18s —— View job


Reviewing PR #334

  • Gather context (PR description, diff, changed files)
  • Read changed files in detail
  • Review each change (stale-fix guard, re-anchor, follow)
  • Check test coverage
  • Post review feedback

This sync added one new commit since the previous review: 60109cc — "Give a follow candidate the hold's world tag when the cache has lost the fix" (backend/services/known_claiming.py, +13/-1). Focused this pass on that delta; the three items from the prior review (known_lane_reanchored missing from health.py's untrusted set, _follow_states redundant per-node-per-frame work, new env vars missing from .env.example) are all still unaddressed as of this commit, so they stand.


New commit review

The fix is correct and narrow: _follow_states (backend/services/known_claiming.py:634-645) now falls back to the claiming node's known_track_holds[...][hexn]["world"] when the cache entry has aged out and carries no world tag, closing the "untagged candidate passes every world gate" hole for a hex whose ADS-B cache entry was pruned before a node picked it up mid-silence. _touch_hold (known_claiming.py:434-460) always stamps world from state.node_world(node_id), which is a non-None "sim"/"real" string (backend/core/state.py:252-266), so the fallback is guaranteed to find a real tag on any hex that has ever been held — consistent with the existing _follow_fix_record fallback pattern (known_claiming.py:566-569) this mirrors.

Two minor items:

  1. Stale comment right below the new code. The docstring-style comment on the "world": world line (known_claiming.py:653-656) still reads "...once the entry ages out there is nothing to tag with, and an untagged candidate passes every world" — that was true before this commit but is now half wrong, since the whole point of the new code three lines above is to find something to tag with in that case. Worth a one-line update so the comment matches the code it sits next to.

    Fix this →

  2. No test exercises the new fallback branch. backend/tests/test_known_track_follow.py wasn't touched by this commit. The one existing test that puts a hold in place before a follow claim, test_c_the_original_stale_fix_is_carried_when_a_hold_has_one (backend/tests/test_known_track_follow.py:277-308), sets the hold's "world": None explicitly (line 300), so it can't exercise the new code path — with that value, _follow_states's fallback loop finds nothing (the is not None check fails) and world stays None, same as before this commit. There's no test asserting that a follow candidate's world field actually picks up a non-None value from a hold when the cache entry is stale/missing, which is the exact scenario this commit's message describes (decoy traffic on the wrong world). Worth adding a case with a real world value on the hold and a stale/absent cache entry, asserting the resulting claim's adsb_fix/candidate carries that world.

    Fix this →

Everything else in the commit — the is not None guard, the break on first match, reuse of list(state.known_track_holds.values()) iteration already used by _follow_fix_record — is correct. It does add a second full scan over all node holds for candidates whose cache entry has no world (on top of _follow_fix_record's own scan for the fix), which compounds finding 2 from the earlier review rather than introducing a new problem.

Branch: feat/known-track-follow

@jehanazad
jehanazad merged commit 86ca23f into main Sep 8, 2026
22 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