Skip to content

Instrument the dark lane: per-lane history, resolve-slot skips, live contamination - #293

Merged
jehanazad merged 8 commits into
mainfrom
feat/dark-solver-observability
Sep 6, 2026
Merged

jehanazad merged 8 commits into
mainfrom
feat/dark-solver-observability

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 filter

The 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 (default all), 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_counts in the payload is reported pre-cap, so a truncated records list stays legible.
  • Existing response shape and fields are unchanged; ?hex=/?all=1 answer 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_skips was 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.

  • New 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 carries ts_ms, lane, track_ids, n_nodes, blocking: [{track_id, held_ts, held_n}], guess_lat, guess_lon.
  • New solver_resolve_skips_dark counter (lane by the existing _is_dark_solver_input), beside the unchanged solver_resolve_skips.
  • /api/test/solver-stats gains resolve_skips: {total, dark, attempts_ratio, window_effective_minutes}, windowed from the deque. attempts_ratio is all-lane skips over dark attempts — the shape the Phase 1 acceptance target is quoted in. window_effective_minutes is 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_skips dumps the deque.
  • Skips are still not counted as attempts or rejects in the funnel.

3. Live contamination metric

A DARK history record that matched ground truth now carries foreign_node_ids — the contributing nodes (including ones _trim_and_resolve dropped) for which the matched aircraft was not visible at solve time — plus contaminated: bool.

The verdict is the associator's own visibility predicate applied whole (retina_analytics.association._point_in_beam against the registered NodeGeometry), 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 under FOV_MODE=active it 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-stats gains contamination: {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_dropped is already exposed (/api/admin/metrics), so it is not re-exposed here. But it counts frame_queue saturation and reads zero throughout — the frames NODE_FRAME_MIN_INTERVAL_S refuses per node were counted nowhere at all, so "the tracker sees what this node sent" looked true from every published metric. New node_frames_rate_limited counter at that gate, surfaced in solver-stats' counters block.

5. docs/solverflow.md

Every file.py:NNN reference replaced with file + symbol; all ~85 had drifted (frame_processor.py:294 for "process_one_frame entry" 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").
  • Full backend suite: 2805 passed, 2 skipped in 226.59s (-n 2 -m "not external").
  • pre-commit run --all-files: all five hooks Passed.

Not deployed.

🤖 Generated with Claude Code

claude and others added 3 commits September 5, 2026 06:25
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>
@claude

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>
jehanazad pushed a commit that referenced this pull request Sep 6, 2026
…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

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 3m 11s —— View job


Claude's review of PR #293

Todo list

  • Gather context (PR body, diff, CLAUDE.md)
  • Understand request (re-review after synchronize)
  • Review the diff across all changed files
  • Post final review findings

Summary

This 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 _cap_per_lane (backend/routes/test.py), the separate solver_resolve_skips_recent deque and _record_resolve_skip/_resolve_slot_blockers (backend/services/tasks/solver.py), the contamination stamp _stamp_foreign_nodes reusing _point_in_beam/NodeGeometry, node_frames_rate_limited at the correct gate in tcp_handler.py, and the docs/solverflow.md file:line → file:symbol rewrite. No new issues found in that code.

Note on this diff vs. the PR description

The PR is now 16 files / 1538 additions, up from the 8 files the previous review covered — but that growth isn't new instrumentation work. feat/dark-solver-observability is stacked on PR #292 (feat/free-altitude-solve, still open against main) and, transitively, #291 (feat/assoc-world-gate-overlap). Since neither has merged to main yet, GitHub's diff for #293 (computed against main) necessarily includes their commits too: SOLVER_ALT_MODE/SOLVER_FREE_ALT_STARTS, the free-altitude multi-start solve path, configs_for_solver_input (config trimming for the pool), and backend/tests/test_solver_alt_mode.py all belong to #292, not to this PR — I confirmed none of it exists on origin/main yet. That's expected stacked-PR behavior, not scope creep in this branch, but it's worth flagging so reviewers don't read the "no behaviour change" claim in this PR's body against code that #293 didn't actually introduce. Once #292/#291 merge and this branch rebases onto main, the diff should shrink back to the instrumentation-only set described above.

Still open (non-blocking, from previous review)

backend/services/tasks/solver.py_record_resolve_skip (and the pre-existing _record_solve_history) still use ig.get("lat")/ig.get("lon") truthiness, so a guess sitting exactly on the equator or prime meridian (0.0) records as None instead of 0.0. Not a new bug, very unlikely to matter for this network's coverage area, not fixed in this push — still just a nit.

No behavior-affecting bugs found in this PR's own code.

@jehanazad
jehanazad marked this pull request as ready for review September 6, 2026 02:38
@jehanazad
jehanazad merged commit ac66b84 into main Sep 6, 2026
15 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.

2 participants