Hold node tracks on the ADS-B hex that claimed them - #333
Conversation
Claiming asks the same question from scratch every frame: does a transponder fix explain this detection right now? When a node's tags stop and the cached fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S the answer turns to no for an aircraft that has not moved, changed or gone anywhere — its echoes drop into the dark pool, the tracker forms a track, and the dark solver mints a new key beside (or on top of) the aircraft the lane was following a second earlier. But a claim is evidence in its own right: it says this node's echo of this hex sat at that (delay, Doppler). The next frame's echo is one frame of motion away, and the measured Doppler says how far — so the node's own measured track predicts the next observation with no transponder in the loop at all. state.known_track_holds keeps the last two samples per (node, hex), and path H claims against them with the same Hungarian one-to-one the other paths use. It runs after path 1 and before path 2, which is the requirement rather than an ordering detail: once a node track is linked to a hex it must not be peelable to another hex's dead-reckoned fix. No maximum duration — while the track keeps matching, it stays linked — bounded instead by the 8 s frame-time gap it may bridge, by feed_gc for a node that stops sending, and by the ghost-lock guard: a hold may outlive the transponder but never contradict it, so with a fresh fix on file the matched detection must pass path 2's gate too. KNOWN_HOLD_MAX_GAP_S=0 is the rollback lever and is total — the store is not even written, so off is the behaviour that predates path H. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A held claim can outlive its transponder by any amount — that is the point of
the hold — and _build_solver_input then dead-reckons a position from minutes
ago at a heading from minutes ago. The guess drifts kilometres, _attempt
measures displacement against it, labels the honest solve a ghost, and the
aircraft stops being published exactly when the hold made it visible.
Past the fix-age cap the lane's own last mn-adsb-<hex> solve is the better
prior: a radar measurement, seconds old, of the same aircraft, with a filter
velocity behind it. Altitude stays pinned from the last fix — barometric
altitude does not go stale the way a position does. s_in and the history
record carry seed_source ("kf"/"fix") so the two populations are separable.
The display side needed one change and no more. multinode_to_aircraft reads
nothing from state.adsb_aircraft, so an mn-adsb entry keeps rendering from its
radar solves after the cache entry is pruned (only the callsign lookup in the
single-node section degrades, to None). The singly-claimed section, though,
draws the fix ITSELF, so a hold whose fix has aged out would paint the aircraft
where it was rather than where it is, more wrongly the longer the hold
succeeds: those are skipped, and entries carry adsb_stale so the map can say a
drawn position is coasting rather than measured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET/PUT /api/test/known-hold sets KNOWN_HOLD_MAX_GAP_S on a running backend so the feature can be A/B'd without a redeploy; 0 turns path H off and clears the store, so "off" is genuinely the pre-feature behaviour rather than a hold that never matches. Admin-gated on put_simulation_config's precedent: it changes which detections leave the dark pool for every node at once. The solver-stats known_claims block gains hold_claims, hold_expired, hold_dropped_disagree and the store's current size, so the funnel is legible: a store that grows while claims does not is holds that never match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ten tests: the tagged-then-silent claim, gap expiry, the ghost-lock guard, the hold beating dark follow to a detection, the zero-gap rollback lever, the known-lane stale-fix seed in all three of its cases, and two that go through retina_simulation rather than a hand-built frame. The physics check is empirical on purpose. d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz falls out of the bistatic definitions, but a sign convention flip anywhere between the generator and the propagation would double the error instead of cancelling it, so the test measures consecutive noise-free frames of the simulator's own moving aircraft and asserts the predicted rate agrees with the measured one in sign and magnitude. The integration test then runs one node for 30 s of tags and 90 s of silence with the simulator's clutter present, and asserts the link holds on >= 85 % of the frames the aircraft was detected in with zero clutter claims. Both seed the RNG: the simulator draws spawns, misses and noise from the global one, so an unseeded rate assertion measures the test run order. test_solver_stats' key-set assertions grow the four new counters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Path H outranks path 2, so on a node without tags every claim after the first is a hold claim. A hold claim always carried the STORED fix, which meant the entry's fix never refreshed while the transponder was live: after 45 s the known lane would read a live aircraft as silent and seed from its own filter, and the single-node display would drop it as stale. When the consistency check finds a fresh fix that agrees, the claim now carries that fix (the same record path 2 builds) and refreshes the hold with it; only a genuinely silent hex keeps the original fix and its epoch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 4m 58s —— View job Code reviewTodo list
ReviewThe previous review pass on this PR (07:00 UTC) covered commits through Bug:
_fix_age_s = (float(newest["ts_ms"]) - float(fix.get("fix_ts_ms") or 0)) / 1000.0
...
"adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")),This field was added in That's the opposite of the flag's own stated purpose ("True when the drawn fix is no longer being refreshed by a transponder … so the map can say the position is coasting rather than measured" — Impact today: low — Suggested fix: "adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")) and not newest.get("fix_refreshed"),Everything else re-checked in this pass held up: the ghost-lock guard's gate ( No other issues found in this delta. Everything from the prior review (path ordering, rollback lever, GC pruning, state reset, counters/contract exemptions) still holds against the current HEAD. |
The console has only ever had the light palette, which makes it the odd one out next to the map — the map has themed since it borrowed dash's whole vocabulary, and the data explorer has answered the OS preference all along. Someone working across the three at night meets a white page on two of them. The mechanism is the map's, with the two themes the other way round. The map carries dark on its base selector and light behind the attribute, because dark is its default and the default must never be the theme that flashes on first paint; light is the default here, so light stays on the bare selector and dark is what the attribute buys. The palette is the map's dark column wholesale rather than a new one: the brand guide already records the two as counterparts, and the estate's own §10 says drift is its main failure mode. The control has three states rather than the map's two. `system` stamps no attribute and lets a prefers-color-scheme block answer, so the OS preference is honoured with no JavaScript and keeps working when the OS changes its mind mid-session; light and dark override it. That also means the data explorer, which has behaved that way since it was written, can gain a switch without regressing anyone. Three things the diff does not explain on its own: - Recharts paints from props, not from the cascade, so `var()` cannot reach it. The chart chrome moves into a second, hand-kept copy of the tokens, and a test asserts the two agree. AnomalyPage was already passing `stroke="var(--border)"` into SVG presentation attributes, where CSS does not resolve var() at all — those axes were drawing at the initial value, and now draw. - Dark's accent is a bright sky blue, so the white that inks the primary button and the sidebar mark drops to about 1.8:1 there. Both now take `--accent-ink`, near-black on dark. The map still inks `#fff` on the same fill; that is its bug to fix, not one to copy. - The pre-paint script is a file, not an inline tag. Every page vhost sends `script-src 'self'`, which does not cover inline code, while the dev server sends no CSP — an inline version works in every local check and silently never runs once deployed. A colour written into a page is pinned to one theme, and no token block covers a chart's props or an inline style, so the pages get their own sweep. It catches both shapes this change had: an Area whose stroke follows the palette while its fill does not, and a `color: "white"` sitting on a .btn-primary, where an inline style outranks the class rule and quietly undoes --accent-ink. Leaflet needs three rules rather than one. Its tiles take a filter, since OSM does not theme; its container takes the sunk tier; and its own attribution and zoom controls are hardcoded white and #333 in leaflet.css, so on a dark card they read as two bright chips stuck to the corners until they are told otherwise. Verified in a browser across all three settings: system follows the OS and follows it changing, an explicit choice overrides it and survives a reload, and the tooltip, grid and axes take their themed values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console has only ever had the light palette, which makes it the odd one out next to the map — the map has themed since it borrowed dash's whole vocabulary, and the data explorer has answered the OS preference all along. Someone working across the three at night meets a white page on two of them. The mechanism is the map's, with the two themes the other way round. The map carries dark on its base selector and light behind the attribute, because dark is its default and the default must never be the theme that flashes on first paint; light is the default here, so light stays on the bare selector and dark is what the attribute buys. The palette is the map's dark column wholesale rather than a new one: the brand guide already records the two as counterparts, and the estate's own §10 says drift is its main failure mode. The control has three states rather than the map's two. `system` stamps no attribute and lets a prefers-color-scheme block answer, so the OS preference is honoured with no JavaScript and keeps working when the OS changes its mind mid-session; light and dark override it. That also means the data explorer, which has behaved that way since it was written, can gain a switch without regressing anyone. Three things the diff does not explain on its own: - Recharts paints from props, not from the cascade, so `var()` cannot reach it. The chart chrome moves into a second, hand-kept copy of the tokens, and a test asserts the two agree. AnomalyPage was already passing `stroke="var(--border)"` into SVG presentation attributes, where CSS does not resolve var() at all — those axes were drawing at the initial value, and now draw. - Dark's accent is a bright sky blue, so the white that inks the primary button and the sidebar mark drops to about 1.8:1 there. Both now take `--accent-ink`, near-black on dark. The map still inks `#fff` on the same fill; that is its bug to fix, not one to copy. - The pre-paint script is a file, not an inline tag. Every page vhost sends `script-src 'self'`, which does not cover inline code, while the dev server sends no CSP — an inline version works in every local check and silently never runs once deployed. A colour written into a page is pinned to one theme, and no token block covers a chart's props or an inline style, so the pages get their own sweep. It catches both shapes this change had: an Area whose stroke follows the palette while its fill does not, and a `color: "white"` sitting on a .btn-primary, where an inline style outranks the class rule and quietly undoes --accent-ink. Leaflet needs three rules rather than one. Its tiles take a filter, since OSM does not theme; its container takes the sunk tier; and its own attribution and zoom controls are hardcoded white and #333 in leaflet.css, so on a dark card they read as two bright chips stuck to the corners until they are told otherwise. The switch is three icons rather than three words: Feather's monitor, sun and moon, hand-inlined in the same house style as the sidebar's set (24-unit box, no fill, 2-unit round-capped stroke in currentColor) since no icon package is installed and three glyphs do not justify adding one. Drawing in currentColor is what lets the active segment tint itself from the same rule that sets its ink. A glyph names nothing on its own, so each button carries an aria-label and a matching title, and the svg is aria-hidden so the name is not read twice. Verified in a browser across all three settings: system follows the OS and follows it changing, an explicit choice overrides it and survives a reload, and the tooltip, grid and axes take their themed values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console has only ever had the light palette, which makes it the odd one out next to the map — the map has themed since it borrowed dash's whole vocabulary, and the data explorer has answered the OS preference all along. Someone working across the three at night meets a white page on two of them. The mechanism is the map's, with the two themes the other way round. The map carries dark on its base selector and light behind the attribute, because dark is its default and the default must never be the theme that flashes on first paint; light is the default here, so light stays on the bare selector and dark is what the attribute buys. The palette is the map's dark column wholesale rather than a new one: the brand guide already records the two as counterparts, and the estate's own §10 says drift is its main failure mode. The control has three states rather than the map's two. `system` stamps no attribute and lets a prefers-color-scheme block answer, so the OS preference is honoured with no JavaScript and keeps working when the OS changes its mind mid-session; light and dark override it. That also means the data explorer, which has behaved that way since it was written, can gain a switch without regressing anyone. Three things the diff does not explain on its own: - Recharts paints from props, not from the cascade, so `var()` cannot reach it. The chart chrome moves into a second, hand-kept copy of the tokens, and a test asserts the two agree. AnomalyPage was already passing `stroke="var(--border)"` into SVG presentation attributes, where CSS does not resolve var() at all — those axes were drawing at the initial value, and now draw. - Dark's accent is a bright sky blue, so the white that inks the primary button and the sidebar mark drops to about 1.8:1 there. Both now take `--accent-ink`, near-black on dark. The map still inks `#fff` on the same fill; that is its bug to fix, not one to copy. - The pre-paint script is a file, not an inline tag. Every page vhost sends `script-src 'self'`, which does not cover inline code, while the dev server sends no CSP — an inline version works in every local check and silently never runs once deployed. A colour written into a page is pinned to one theme, and no token block covers a chart's props or an inline style, so the pages get their own sweep. It catches both shapes this change had: an Area whose stroke follows the palette while its fill does not, and a `color: "white"` sitting on a .btn-primary, where an inline style outranks the class rule and quietly undoes --accent-ink. Leaflet needs three rules rather than one. Its tiles take a filter, since OSM does not theme; its container takes the sunk tier; and its own attribution and zoom controls are hardcoded white and #333 in leaflet.css, so on a dark card they read as two bright chips stuck to the corners until they are told otherwise. The switch is three icons rather than three words: Feather's sun, monitor and moon, ordered light to dark with the neutral between them, hand-inlined in the same house style as the sidebar's set (24-unit box, no fill, 2-unit round-capped stroke in currentColor) since no icon package is installed and three glyphs do not justify adding one. Drawing in currentColor is what lets the active segment tint itself from the same rule that sets its ink. A glyph names nothing on its own, so each button carries an aria-label and a matching title, and the svg is aria-hidden so the name is not read twice. Verified in a browser across all three settings: system follows the OS and follows it changing, an explicit choice overrides it and survives a reload, and the tooltip, grid and axes take their themed values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console has only ever had the light palette, which makes it the odd one out next to the map — the map has themed since it borrowed dash's whole vocabulary, and the data explorer has answered the OS preference all along. Someone working across the three at night meets a white page on two of them. The mechanism is the map's, with the two themes the other way round. The map carries dark on its base selector and light behind the attribute, because dark is its default and the default must never be the theme that flashes on first paint; light is the default here, so light stays on the bare selector and dark is what the attribute buys. The palette is the map's dark column wholesale rather than a new one: the brand guide already records the two as counterparts, and the estate's own §10 says drift is its main failure mode. The control has three states rather than the map's two. `system` stamps no attribute and lets a prefers-color-scheme block answer, so the OS preference is honoured with no JavaScript and keeps working when the OS changes its mind mid-session; light and dark override it. That also means the data explorer, which has behaved that way since it was written, can gain a switch without regressing anyone. Three things the diff does not explain on its own: - Recharts paints from props, not from the cascade, so `var()` cannot reach it. The chart chrome moves into a second, hand-kept copy of the tokens, and a test asserts the two agree. AnomalyPage was already passing `stroke="var(--border)"` into SVG presentation attributes, where CSS does not resolve var() at all — those axes were drawing at the initial value, and now draw. - Dark's accent is a bright sky blue, so the white that inks the primary button and the sidebar mark drops to about 1.8:1 there. Both now take `--accent-ink`, near-black on dark. The map still inks `#fff` on the same fill; that is its bug to fix, not one to copy. - The pre-paint script is a file, not an inline tag. Every page vhost sends `script-src 'self'`, which does not cover inline code, while the dev server sends no CSP — an inline version works in every local check and silently never runs once deployed. A colour written into a page is pinned to one theme, and no token block covers a chart's props or an inline style, so the pages get their own sweep. It catches both shapes this change had: an Area whose stroke follows the palette while its fill does not, and a `color: "white"` sitting on a .btn-primary, where an inline style outranks the class rule and quietly undoes --accent-ink. Leaflet needs three rules rather than one. Its tiles take a filter, since OSM does not theme; its container takes the sunk tier; and its own attribution and zoom controls are hardcoded white and #333 in leaflet.css, so on a dark card they read as two bright chips stuck to the corners until they are told otherwise. The switch is three icons rather than three words: Feather's sun, monitor and moon, ordered light to dark with the neutral between them, hand-inlined in the same house style as the sidebar's set (24-unit box, no fill, 2-unit round-capped stroke in currentColor) since no icon package is installed and three glyphs do not justify adding one. Drawing in currentColor is what lets the active segment tint itself from the same rule that sets its ink. A glyph names nothing on its own, so each button carries an aria-label and a matching title, and the svg is aria-hidden so the name is not read twice. Choosing role=radiogroup over three independent toggles is what obliges the keyboard half: the set is announced as one control with three options, so it is one tab stop rather than three (a roving tabIndex on whichever is checked) and the arrows move within it, selecting as they go. Home and End reach the ends. Everything else passes through — without preventDefault the arrows scroll the dropdown while the selection moves underneath. Verified in a browser across all three settings: system follows the OS and follows it changing, an explicit choice overrides it and survives a reload, and the tooltip, grid and axes take their themed values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Once a node track is linked to an ADS-B hex, it stays linked. Today claiming re-asks from scratch every frame whether a transponder fix explains a detection; when the node's tags stop and the cached fix ages past
KNOWN_CLAIM_MAX_FIX_AGE_S(45 s) the answer flips to no for an aircraft that has done nothing unusual, its echoes fall into the dark pool, and the dark solver mints anmn-dark-*ghost beside the aircraft the lane was following a second earlier.A claim is evidence on its own terms — this node's echo of this hex sat at that (delay, Doppler) — so the node's own measured track can predict the next frame with no transponder in the loop. That is path H, and it runs after path 1 and before path 2 so a linked track cannot be peeled off by another hex's dead-reckoned fix. There is no maximum hold duration.
Changes
state.known_track_holds(node_id -> hex -> entry): the last two claim samples per (node, hex) plus the last fix, written on every claim from every path. Expired against frame time (KNOWN_HOLD_MAX_GAP_S, 8 s) when that node's next frame arrives, and pruned per hex byfeed_gc.prune_stale_storesso a node that stops sending cannot pin memory.known_claiming._claim_holds): predicts the frame's observation from the held track —d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz, Doppler propagated at the clipped two-sample rate — and assigns Hungarian one-to-one under1.5 µs + 1.0 µs/s · dt/20 Hz + 10 Hz/s · dtgates (bases set 5σ above the sim's own measurement noise, differenced). Claims recordhold: true,hold_gap_s, and the stored fix with its originalfix_ts_ms.known_hold_dropped_disagree) and the hex falls through to path 2 unchanged._build_solver_inputseeds from the lane's own lastmn-adsb-<hex>solve (filter velocity when available), dead-reckoned to the newest claim epoch, altitude still pinned from the last fix.seed_source("kf"/"fix") rides ons_inand the history record.adsb_stale.KNOWN_HOLD_MAX_GAP_S=0disables the feature and the store, andGET/PUT /api/test/known-hold(admin) sets it live. New counters in the solver-statsknown_claimsblock:hold_claims,hold_expired,hold_dropped_disagree,holds.Test coverage
backend/tests/test_known_track_hold.py, 10 tests: tagged-then-silent claim, gap expiry, the ghost-lock guard, path H beating dark follow to a detection, the zero-gap rollback lever, and the known-lane seed in its fresh / stale-with-prior / stale-without-prior cases.Two go through
retina_simulation. The delay-rate sign is verified empirically — consecutive noise-free frames of the simulator's own moving aircraft — rather than trusted from the derivation, because a convention flip anywhere upstream would double the error instead of cancelling it. The integration test runs one node for 30 s of tags then 90 s of silence with the simulator's clutter present, asserting the link holds on >= 85 % of the frames the aircraft was detected in with zero clutter claims. Both seed the RNG: the simulator draws spawns, misses and noise from the global one.Full backend suite: 3087 passed, 2 skipped. (Four
probe_helperssubprocess tests fail in this working tree for an unrelated environmental reason — the child interpreter gets noPYTHONPATHfor the in-treelibs/, so it imports an older installedretina_analyticswhoseInterNodeAssociatorsignature differs. Untouched by this branch.)Review notes
dark_follow's two-strike guard exists for, resolved differently because here a live transponder is available to disagree.multinode_to_aircraftreads nothing fromstate.adsb_aircraft, so anmn-adsbentry already keeps rendering from its radar solves after the cache entry is pruned — only the callsign lookup in the single-node section degrades (toNone). The single-node stale-fix skip is the only display change needed.Live verification (test fleet, simulator transponder outages)
A/B on the same build with the hold switched off then on through
PUT /api/test/known-hold, 15-minute captures each,frac_adsb_outage0.5 (retina-simulation #13 / #332),frac_dark0 so every dark-lane key is a ghost of an ADS-B aircraft.Two remaining mechanisms, both outside this PR and addressed in a follow-up stacked on it: the filter's velocity keeps reading the stale ADS-B heading (a turning aircraft's kf-seeded solve drifts to a "ghost" label and never re-anchors), and nodes that newly acquire the silent aircraft have no hold, so their detections still mint a twin.
Also in this PR since the first review: a hold claim that agrees with a live fix now carries and refreshes that fix, so path H outranking path 2 on a node without tags cannot freeze the fix at the first claim.
🤖 Generated with Claude Code