Skip to content

Adopt pool nodes a dark solve can vouch for - #325

Merged
jehanazad merged 3 commits into
mainfrom
feat/adopt-pool-nodes-main
Sep 7, 2026
Merged

jehanazad merged 3 commits into
mainfrom
feat/adopt-pool-nodes-main

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

Summary

Widen a dark solve with the nodes the association round already paired for it, when the solve itself vouches for them. Clean re-cut against main of the work first landed on the overnight integration branch (#323, absorbed there) plus the config fix it needed.

Why

pool_n_nodes (#319) showed 72–89% of published dark solves are narrower than the round's shared-track pool (mean shortfall 2.3–3.3 nodes), and 390 of 481 rejected n=2 candidates in one window had a pool of 3 or more — a third node was available and the candidate could have been an n=3 solve (which publishes ~80% of the time instead of ~6%). Widening the merge radius was ruled out earlier (contamination rises).

Changes

  • retina-analytics (merged, fix: add external ADS-B fallback to MLAT verification, honest n_solve… #30): every solver input carries pool_measurements — the pool nodes' own measurements that the clustering left out.
  • solver.py _adopt_pool_nodes: after the first successful solve, predict each pool node's delay/Doppler at the solved state (predict_observation), adopt those within SOLVER_ADOPT_DELAY_GATE_US (6.0) / SOLVER_ADOPT_DOPPLER_GATE_HZ (60), re-solve wider, keep the wide solve if rms_delay ≤ 3 µs and it moved ≤ SOLVER_ADOPT_MAX_JUMP_KM (5); one retry dropping the worst adopted node. Runs before trimming and before every gate, so a 2-node candidate that adopts a third node is judged as n=3. Kill switch SOLVER_ADOPT_POOL=0.
  • frame_processor.py configs_for_solver_input also ships the pool nodes' configs — without them the re-solve silently dropped the adopted node (120 of 309 "widened" candidates stayed at two nodes in the first live run).
  • Counters solver_adopt_{eligible,widened,nodes_added,rejected}; adopt_meta / n_nodes_pre_adopt on every history record; pin bump.

Measured (test droplet, 20-min captures vs ground truth)

  • Adoption: 578 eligible → 309 widened (578 nodes added) in 20 min; dark 2-node coverage time with a solve 35% → 51%.
  • Widened solves are more accurate than native ones at every width: n=3 median error 0.40 vs 0.83 km, n=4 0.21 vs 0.30 km, n=5 0.16 vs 0.24 km; contamination 15% vs 22%.
  • Dark ghost share 5.3% (baseline 4.1–5.5%); no task errors; solver latency unchanged (0.15 s avg).

Test coverage

tests/test_solver_pool_adoption.py (10 cases: widen, gate reject, rms reject, jump reject, anchored skip, env off, meta/counters) and a configs_for_solver_input case; lib tests in retina-analytics#30.

Review notes

Gate defaults are first-cut values from the n=2 error budget (2 km ≈ 6.7 µs); they are env-tunable and worth a sweep once the by-outcome adopt_meta accumulates.

🤖 Generated with Claude Code

jehanazad and others added 3 commits September 7, 2026 08:08
The association round routinely pairs more nodes for an aircraft than
the input the solver is handed uses.  pool_n_nodes (the node set of the
shared-track component the input was clustered out of) made that
measurable, and 21 minutes of live instrumentation put 72% of published
dark solves below their pool, mean shortfall 2.27 nodes: published n=2
with pool>=3 was 28 of 45, published n=3 with pool>=4 was 125 of 169.
The expensive half is at the bottom — 390 of 481 rejected n=2
candidates had a pool of 3 or more, and an n=3 candidate publishes 81%
of the time against 6% for n=2.  That is the largest single block of
detections this pipeline throws away.

Merging harder upstream is not the fix: sweeping the position merge
radius to 6 km raised cross-aircraft contamination from 25% to 32%,
because at that radius two aircraft are as close together as one
aircraft's own pairings.  So the solve itself vouches for the extra
node instead.  _adopt_pool_nodes predicts, through the association
stage's own forward model (predict_observation), what each pool node
should have measured at the position and velocity just solved, adopts
the ones whose measured delay agrees within 6.0 µs and Doppler within
60 Hz, re-solves wider from the first solve's position, and keeps the
wider solve only if it passes the usual rms_delay gate and has not
walked more than 5 km from the narrow one.  With two or more adopted
nodes a failed rms gets one retry without the worst-residual adoption,
because the rms is a sum over both and a single contaminated adoption
should not sink a widening the other node carried; beyond that the
narrow solve stands.  Cost is bounded at two extra LM solves per
eligible candidate.

The 6.0 µs default is derived, not tuned: an n=2 dark solve sits ~2 km
from truth and 2 km of range error is 2/c ≈ 6.7 µs of bistatic delay,
so the gate admits a genuine node at the accuracy this stage actually
has while an unrelated target is normally tens of µs away.  A node with
no registered geometry, or a pool measurement whose node cannot be
predicted for, is not adopted rather than adopted unchecked.

It runs immediately after the first successful solve and BEFORE
trimming and before every gate, so a widened candidate is judged as an
n=3 solve throughout — including by the n=2 confirmation gate, which no
longer applies to it.  That is the point rather than a side effect: the
gate exists because two nodes cannot corroborate each other's identity,
and a third node whose measured delay matches what the two-node solve
predicts for it is exactly the corroboration it was demanding.  Only
bottom-up dark inputs qualify; anchored and known-lane inputs carry an
identity and were never clustered, so they have no pool.

Every history record carries adopt_meta (pool size, candidates, adopted
node ids, outcome) and n_nodes_pre_adopt, so adoption's true and false
positives can be read from the dump rather than inferred from four
counters; the counters (solver_adopt_eligible / _widened /
_nodes_added / _rejected) are exposed in the solver-stats block.
SOLVER_ADOPT_POOL=0 disables the stage outright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit d7a941f)
configs_for_solver_input trims the queued configs to the measurement nodes,
so an adopted pool node reached the re-solve with no config and was dropped
by the epoch alignment and the solver's NodeSetups.  Live on the test
droplet 120 of 309 candidates marked widened had solved on their original
two nodes (contributing_node_ids unchanged, rms 0, jump 0).  A pool adds
0-6 configs, not the 50 the trim exists to keep out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit a319c08)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

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


Review: PR #325 — Adopt pool nodes a dark solve can vouch for

  • Gather context (PR description, changed files)
  • Read diff against origin/main (used HEAD~3..HEAD, which matches the PR's stated files exactly)
  • Review solver.py _adopt_pool_nodes logic
  • Review frame_processor.py config changes
  • Review state.py / routes/test.py counter additions
  • Review test coverage
  • Post final review

Summary

Solid, well-contained change. The adoption stage runs in the right place (right after the first successful solve, before trimming and every gate), the node_cfgs plumbing fix in configs_for_solver_input (frame_processor.py:225-228) closes the exact silent-drop bug the PR description calls out, and n_nodes/adopt_meta/n_nodes_pre_adopt are threaded through _process_solver_item consistently so downstream gates (e.g. the n=2 bearing check at solver.py:2911) correctly see a widened candidate as n=3. backend/tests/test_solver_pool_adoption.py covers the interesting cases well: widen, delay-gate reject, Doppler-gate reject, rms reject, jump reject, the one-retry-drop-worst-node path, anchored-input skip, already-as-wide-as-pool skip, kill switch, and provenance (track_ids).

Two things worth a look, neither blocking:

  1. New env vars aren't in backend/.env.example. SOLVER_ADOPT_POOL, SOLVER_ADOPT_DELAY_GATE_US, SOLVER_ADOPT_DOPPLER_GATE_HZ, SOLVER_ADOPT_MAX_JUMP_KM (solver.py:761-768) aren't listed there. CLAUDE.md is explicit: "Add new keys to backend/.env.example so the list stays current." In fairness this repeats an existing gap — most other SOLVER_* tunables (e.g. SOLVER_RMS_DELAY_MAX_US, SOLVER_EPOCH_ALIGN) also aren't documented there — but SOLVER_ADOPT_POOL is the kill switch for this whole stage, which is exactly the kind of knob an operator needs to find fast during an incident.
    Fix this →

  2. Shared threshold constant. _adopt_pool_nodes's accept gate (solver.py:874, rms_delay > _SOLVER_RMS_DELAY_MAX_US) reuses the same module constant that gates the unrelated n≥4 trim-and-resolve path. Tuning SOLVER_RMS_DELAY_MAX_US for one purpose silently retunes the other. Probably fine given they're conceptually the same "is this rms too high" question, but if the two stages ever need independent tuning this will be a surprise — worth a comment or a dedicated var if that day comes.

Everything else checked out:

  • configs_for_solver_input is called before queuing (frame_processor.py:542) and the resulting filtered node_cfgs — now including pool measurement nodes — is exactly what reaches _process_solver_item_adopt_pool_nodes, so the re-solve has configs for any node it adopts.
  • Counter bumps (solver_adopt_eligible/widened/nodes_added/rejected) are mutually exclusive and match state.bump_counter's thread-safe increment; _reset_for_tests() and routes/test.py's window-stats both got the corresponding updates.
  • _s_in_with_adopted is pure (copies s_in/measurements/track_ids_by_node rather than mutating), so a rejected widening correctly leaves the original narrow s_in untouched for the caller.
  • The retry logic (drop the worst adopted node once when ≥2 were adopted and rms fails) is bounded correctly and the final solver_adopt_rejected bump happens exactly once regardless of which break path is taken.
  • libs/retina-analytics pin bump is the only contract-adjacent change, and it isn't a /v1/nodes route/model change, so no contracts/nodes-v1.openapi.yaml regen was needed — correctly skipped.

Not verified: this checkout has no backend/.venv, and network access (git fetch, pip install) isn't available in this job, so I couldn't run pytest or pre-commit run --all-files myself. The diff and existing test file read as correct and consistent with the rest of solver.py's idioms, but please confirm CI is green before merging.

@jehanazad
jehanazad merged commit a56fd2f into main Sep 7, 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.

1 participant