Instrument the dark lane: per-lane history, resolve-slot skips, live contamination - #293
Conversation
Bumps libs/retina-analytics to the branch commit that stops register_node and rebuild_zones_for building an overlap zone between two nodes in different worlds (offworldlabs/retina-analytics#25). This fleet is the reason: 50 synthetic nodes and 8 receivers share one footprint over Greenville, so the associator held a zone for all 400 sim/real pairs — 39 of them a live adjacency edge — and a pairing drawn from one of those grids can only ever match a simulated tracklet against a real echo. Real node ids showed up in 48 of 156 synthetic dark solver records over a 7-minute window. node_world is already the single authority for the question and is already injected as node_world_provider, so nothing new is wired here. /api/radar/association/status gains assoc_world_skipped_pairs next to overlap_zones: without it a fleet whose cross-world pairs are being refused reads exactly like a fleet whose pairs never overlapped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two independent changes to the same enqueue-and-solve path. 1. SOLVER_ALT_MODE (core/state.py, default "sweep" — nothing changes until it is set). The n>=3 altitude sweep solves once per fixed layer and keeps the lowest rms_delay. Its layers are 2 km apart, so the altitude it pins is systematically up to 1 km wrong, and on noise-free replay of this fleet's geometry that quantisation ALONE left rms_delay at a 1.76 us median against the 3.0 us reject gate — most of the gate's budget spent on an altitude the measurements themselves determine, and the residual left over then blamed on nodes, so _trim_and_resolve drops measurements that were never the problem. In "free" mode _solve_best_altitude makes ONE pool call to the geolocator's solve_multinode_multistart with three start layers (the one nearest the association guess and its two neighbours), altitude solved as a sixth unknown — against the sweep's six round trips, each of which pickles the node configs for a child. n=2 is untouched: altitude is unobservable there, so _solve_best_altitude_n2 keeps its single pinned solve and the geolocator pins regardless of the flag. Trimming re-solves through _solve_best_altitude, so a trim round inherits the mode its first solve used — mixing a swept altitude with a free one would make the rms it compares between rounds a different quantity. Both modes stamp altitude_mode on every history record, published or rejected, and free adds the per-start residuals and z_saturated. That is the whole comparison channel: one deploy of each, read off /api/test/mlat-history. There is deliberately no shadow mode — the two produce the same shape of result, so running both would double the solver's cost to learn what a deploy of each already says. 2. The frame path shipped ALL 58 node configs with every solver input. The pool is a spawn pool, so that whole set is pickled per solve while a candidate carries 2-8 measurements. configs_for_solver_input restricts it to the measurement node ids; nothing downstream needs the rest — the solver builds NodeSetups from the measurements, trimming and consensus only ever narrow that set, the beam gate iterates contributing_node_ids, and cv_epochs is built from the same matched nodes in all three input shapes association emits. The known lane fetches its own configs and is unaffected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three of the signals the dark-lane recovery work is judged on could not be read from a running server, so every judgement about it has been offline replay. This adds no behaviour: nothing about which candidates solve, which solves publish, or what reaches the map changes. 1. /api/test/mlat-history caps per lane. The known lane writes ~16x the dark lane's volume, so the flat records[:1000] answered a 30 min request with the newest ~6 min of dark records and the rest of the window read as a quiet period — the same failure the shared deque had before #289 split it, moved into the reader. ?lane= narrows to one lane, ?limit= (max 5 000) is applied per lane, and lane_counts is reported pre-cap so truncation stays legible. 2. Resolve-slot skips are recorded, not just counted. Live the rule refuses ~1 537 candidates per 646 dark attempts per 30 min, and nothing said WHICH claim did it — so a skip that suppressed a duplicate and one that suppressed a different aircraft (tracker track ids are shared across candidates) were indistinguishable. A separate 500-entry deque carries the blocking claims; solver-stats windows it as resolve_skips, and ?kind=resolve_skips dumps it. Its own deque and not the solve history: skips outrun dark records two to one and would evict them. 3. Contamination is measured live. A dark record matched to ground truth now says which of its own contributing nodes could not see that aircraft (foreign_node_ids/contaminated), using the associator's own visibility predicate — the same gate known-lane claiming uses, so the two cannot drift apart. The GT trail lookup already happened; this costs one cone test per node. Records nothing could be asked about stay out of the denominator rather than counting as clean. 4. NODE_FRAME_MIN_INTERVAL_S drops are counted. frames_dropped is published but is the queue-saturation counter and reads zero throughout; the frames the per-node rate limiter refuses were uncounted entirely, so "the tracker sees what this node sent" looked true from every metric. docs/solverflow.md's file:line references are replaced with file + symbol — every one of them had drifted — and gain a section on reading these endpoints. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…r more The free mode shipped with three start altitudes on the reasoning that freeing z removes the ladder's quantisation but not the LM's locality, so several starts are still what keeps a solve off the wrong side of a bistatic ellipse. On this fleet's geometry there was almost nothing for them to keep it off. Over a 20-minute window on test, 1019 free-mode solves: the three starts' rms_delay differed by more than 0.1 us in 13 of them (1.3%), and the nearest-layer start — the one the sweep would have pinned at — was more than 0.5 us worse than the best start in 2. So the extra two starts bought ~0.2% of solves a rounding error at three times the solver CPU, and solver CPU is now the constraint: ~1.7 attempts/s against a 2.0 s average latency on two pool workers. The count becomes SOLVER_FREE_ALT_STARTS, default 1, read per call beside the mode flag and clamped into [1, len(layers)] by _free_alt_starts against the ladder that module owns. One start is the layer nearest the association guess, or the guess altitude itself when the input carries a non-layer one (ADS-B) — the same splice the sweep does, so the one exact altitude a candidate has is still what it starts from. Above 1 the window is unchanged, so 3 reproduces what was measured; the knob stays because locality is a property of the geometry, not of this fleet, and a deployment whose nodes sit nearer an ellipse should not need a code change to buy the starts back. alt_starts_km / alt_start_rms_us keep being recorded — a one-element list rather than three — so the live comparison channel is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r-observability docs/solverflow.md: keep this branch's symbol-named 'Defined in' column and add #292's SOLVER_ALT_MODE / SOLVER_FREE_ALT_STARTS rows and section in it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is merged there Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r-observability
…ix/cluster-contamination test_analytics_routes.py: keep both new status tests (live counters from this branch, world-skipped pairs from #291). libs/retina-analytics: point at analytics main (#25 world gate + #26 cluster split + #27 epochs merged). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 3m 11s —— View job Claude's review of PR #293Todo list
SummaryThis PR's own contribution — the dark-lane observability work described in the PR body — is unchanged in substance since the last review and still holds up: per-lane cap/filter in Note on this diff vs. the PR descriptionThe PR is now 16 files / 1538 additions, up from the 8 files the previous review covered — but that growth isn't new instrumentation work. Still open (non-blocking, from previous review)
No behavior-affecting bugs found in this PR's own code. |
What
Instrumentation only. No behaviour change to which candidates solve, which solves publish, or what reaches the map — every change here is a counter, a record field, or a query parameter.
Phase 0 of the dark-lane recovery plan. The three signals the later phases are judged on are today unreadable from a running server, so every judgement about the dark lane has had to be offline replay of captured history.
1.
/api/test/mlat-history: per-lane cap and lane filterThe known lane writes ~16x the dark lane's record volume. The flat
records[:1000]therefore answered a 30 min request with the newest ~6 min of dark records — and the rest of the window read as a quiet dark lane rather than as a truncated answer. That is the same failure the shared deque had before #289 split it, just moved into the reader.?lane=dark|known|adsb|all(defaultall), classified by the existing_record_lane.?limit=(default 1000, max 5000), applied per lane (_cap_per_lane), so a known-lane burst can never evict dark records from a response.lane_countsin the payload is reported pre-cap, so a truncatedrecordslist stays legible.?hex=/?all=1answer exactly as before at the defaults.2. Resolve-slot skips become visible
Live the suppression rule refuses ~1 537 candidates per 646 dark attempts per 30 min, and
solver_resolve_skipswas the only trace. Nothing said which claim blocked a candidate — and since tracker track ids are shared across the candidates of different aircraft (#290's own finding), a skip that suppressed a duplicate and one that suppressed a different aircraft looked identical.state.solver_resolve_skips_recent, a separate 500-entry deque (not the solve-history deque: skips outrun dark records roughly two to one and would evict exactly the solves an investigation needs). Each entry carriests_ms,lane,track_ids,n_nodes,blocking: [{track_id, held_ts, held_n}],guess_lat,guess_lon.solver_resolve_skips_darkcounter (lane by the existing_is_dark_solver_input), beside the unchangedsolver_resolve_skips./api/test/solver-statsgainsresolve_skips: {total, dark, attempts_ratio, window_effective_minutes}, windowed from the deque.attempts_ratiois all-lane skips over dark attempts — the shape the Phase 1 acceptance target is quoted in.window_effective_minutesis there because 500 entries against ~50 skips/min means a long window is truncated here even when the solve stores cover it./api/test/mlat-history?kind=resolve_skipsdumps the deque.3. Live contamination metric
A DARK history record that matched ground truth now carries
foreign_node_ids— the contributing nodes (including ones_trim_and_resolvedropped) for which the matched aircraft was not visible at solve time — pluscontaminated: bool.The verdict is the associator's own visibility predicate applied whole (
retina_analytics.association._point_in_beamagainst the registeredNodeGeometry), which is the gate known-lane claiming uses; claiming and the dark lane have to mean the same thing by "this node can see there", so no new geometric rule is introduced. Two documented consequences: it is a ground-projected test with no altitude term, and underFOV_MODE=activeit is the learned FOV rather than the theoretical wedge — both being exactly what the rest of the pipeline believes about coverage.Cost: no extra trail lookup (the GT stamp already resolved the truth point), one cone test per contributing node.
/api/test/solver-statsgainscontamination: {records_with_gt, contaminated, pct, foreign_nodes_per_record}, dark-only and windowed. Records nothing could be asked about — no GT match, or no registered geometry for any contributing node — are left unstamped and out of the denominator, so an abstention never reads as innocence.4. Frame drops
state.frames_droppedis already exposed (/api/admin/metrics), so it is not re-exposed here. But it countsframe_queuesaturation and reads zero throughout — the framesNODE_FRAME_MIN_INTERVAL_Srefuses per node were counted nowhere at all, so "the tracker sees what this node sent" looked true from every published metric. Newnode_frames_rate_limitedcounter at that gate, surfaced insolver-stats'countersblock.5.
docs/solverflow.mdEvery
file.py:NNNreference replaced with file + symbol; all ~85 had drifted (frame_processor.py:294for "process_one_frameentry" now lands mid-confirmed_track_views, for instance). New §7 documents the endpoints above and how to read the new blocks.How verified
tests/test_mlat_history.py tests/test_solver_stats.py tests/test_solver_worker.py: 204 passed (new: contamination stamp incl. the one-node-in-cone/one-out unit case, trimmed nodes judged, unstamped-abstention; lane filter and per-lane cap incl. the known-evicts-dark regression at 1/500 scale; skip deque, lane split, and "a skip is not an attempt or a reject").-n 2 -m "not external").pre-commit run --all-files: all five hooks Passed.Not deployed.
🤖 Generated with Claude Code