Skip to content

Split contaminated position clusters instead of picking the loud track - #26

Merged
jehanazad merged 4 commits into
mainfrom
fix/cluster-contamination
Sep 6, 2026
Merged

jehanazad merged 4 commits into
mainfrom
fix/cluster-contamination

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What

format_track_pairs_for_solver merged every pairing within 6 km into one solver input, and where a node turned up in that cluster with two different tracks it kept the one with the higher SNR. A node's tracker gives one track per aircraft, so that case is two aircraft — and resolving it by loudness hands one aircraft's candidate a measurement belonging to the other.

Worse, the union-find that builds the cluster is transitive, so "within 6 km" chains. On the 15-node bench scene the baseline emitted 208 solver inputs spanning 19–23 single-node tracks. An aircraft has one track per node, so a 23-track input at 10 nodes was describing at least three aircraft as one.

Four changes, each separately gated and separately measured on backend/scripts/association_bench.py --mode track --cv-fit deferred (production's shape: cv_fit=None, so the chi2 exclusivity in _pair_tracks stage 2 never runs live):

  1. _partition_cluster replaces the SNR pick. Every connected group is partitioned into sub-clusters that are consistent by construction, and membership is tested against every pairing already in a sub-cluster rather than one of them: same node-to-track assignment, within merge_dist_km, implied-velocity consistent. That is what bounds each emitted input's diameter instead of letting it grow with the chain. All sub-clusters are emitted — this stage cannot tell which aircraft is real, and suppressing the runners-up is how the SNR pick went wrong. New counter cluster_splits.
  2. _drop_velocity_conflicts: pair-level exclusivity on the Doppler-implied velocity, gated on cv_fit is None. Not a standalone test — the implied speed measured 0% power against real cross pairings — but two pairings that share a track and imply velocities that cannot both be true are competing claims, and the smaller coarse delay residual wins. It abstains wherever either side has no inference, so it cannot cost the recall the earlier delay-residual assignment cost. Losers counted as track_pairs_superseded.
  3. Merge criterion _MERGE_DIST_KM 6.0 to 3.0 — one association grid step, not two — and a union edge now also requires implied-velocity agreement. Swept 6.0 / 4.5 / 3.0 on the bench; monotone across the whole range with the real-track count identical at every point, so there is no trade to make.
  4. chi2_per_dof stays None when nothing in the cluster was fitted — which on the deferred path is always.

Why

Read-only audit on the live test droplet, 156 GT-matched dark records:

  • 58–65% of dark solver candidates carried a node that could not see the aircraft; 83/129 of those foreign nodes were in the cone of another aircraft.
  • 31 of the 34 candidates with pre-trim rms > 3 µs were contaminated.
  • The solver trimmed 547/1136 successes, and 33 of 69 trimmed nodes were legitimate.
  • 45% of published dark solves still carried a foreign node; position error p90 4–5 km.
  • Candidate node count was about equal to in-cone node count (5.7 vs 5.9) — contamination substituted wrong nodes for right ones rather than adding them.

Measured

15-node ring scene, 6 seeds, --mode track --cv-fit deferred. Full per-seed tables are in the retina-server PR that pins this commit.

baseline partition only, 6 km, no velocity + pair-velocity, 6 km + merge-velocity, 6 km merge 4.5 km shipped, 3.0 km
published contamination, per-seed mean 33.3% 32.7% 32.0% 30.2% 28.2% 25.1%
foreign nodes per published solve 0.80 0.80 0.76 0.70 0.63 0.52
ghost by track (pooled) 54.9% 56.8% 55.6% 54.9% 54.3% 50.0%
ghost by solve (mean of seeds) 5.95% 5.90% 5.93% 4.58% 4.23% 2.85%
real tracks per seed 3 4 6 6 6 7 3 4 6 6 6 7 3 4 6 6 6 7 3 4 6 6 6 7 3 4 6 6 6 7 3 4 6 6 6 7
widest solver input 23 tracks 10 10 10 10 10

Ghost rate by track and by solve is better or equal on every seed; the real-track count is identical on every seed at every leg; median matched error is flat (mean over seeds 1.61 → 1.62 km). The partition alone (column 2) is not a win — it only becomes one combined with the velocity work and the smaller radius.

On the 50-node scatter (droplet-like) scene: published contamination 12.7% → 7.6% pooled, foreign nodes per published solve 0.14 → 0.08, real tracks identical on every seed; two seeds regress on track-level ghost rate (0% → 17%, 11% → 20%, i.e. 1 and 2 extra false tracks).

The acceptance target (candidate contamination at or below 15%) is not met — it moves 31.9% → 28.7% on the ring scene and 20.3% → 20.0% on the scatter scene. The retina-server PR explains why: that metric's denominator is the number of candidates association emits, and breaking one 23-track cluster into its constituent targets plus the false pairings it was hiding emits several inputs where there was one, each of the latter scoring as 100% contaminated.

Verification

libs/retina-analytics suite: 425 passed. New tests: TestClusterPartition, TestVelocityExclusivity, TestVelocityConflict in tests/test_track_association.py.

Intentionally changed behaviour

TestFormatTrackPairsForSolver::test_cluster_reports_its_worst_fit used two pairings a1/b1 and a2/b2 at the same position to make one cluster. That is now (correctly) two targets, so the worst-fit rule would never be reached; the fixture becomes a1/b1 and a1/c1 — the same aircraft on three nodes, which is the case the rule is actually about.

🤖 Generated with Claude Code

format_track_pairs_for_solver merged every pairing within 6 km into one
solver input and, where a node turned up with two different tracks in that
cluster, kept the one with the higher SNR.  A node's tracker gives one
track per aircraft, so that case is two aircraft, and resolving it by
loudness silently hands one aircraft's candidate a measurement belonging to
the other.  Measured on the live test droplet against 156 GT-matched dark
records: 58-65% of dark solver candidates carried a node that could not see
the aircraft, 83 of 129 such nodes were in the cone of ANOTHER aircraft,
31 of the 34 candidates with pre-trim rms > 3 us were contaminated, and 45%
of PUBLISHED dark solves still carried a foreign node after the solver's
trim (which dropped 33 legitimate nodes out of 69).  Candidate node count
tracked in-cone node count (5.7 vs 5.9), so contamination was substituting
wrong nodes for right ones, not adding them.

Three changes, each measured separately on backend/scripts/association_bench
in --mode track --cv-fit deferred (production's shape: cv_fit=None, so the
existing chi2 exclusivity in _pair_tracks stage 2 never runs live):

- A cluster holding two tracks of one node is partitioned into
  node-consistent sub-clusters, all of them emitted.  The solver's gates and
  the resolve slot arbitrate downstream; this stage cannot tell which
  aircraft is real and should not pretend to.  Counted as cluster_splits.

- Pair-level exclusivity for the deferred path, on the Doppler-implied
  level-flight velocity the coarse grid match already produces.  Not a
  standalone test — the implied speed measured 0% power against real cross
  pairings — but two pairings that share a track and imply velocities that
  cannot both be true are competing claims, and the smaller coarse delay
  residual wins.  Abstains wherever either side has no inference, so it
  cannot cost the recall the earlier delay-residual assignment cost.

- The merge criterion is 4.5 km (1.5x the 3 km grid step) rather than 6.0,
  and a union edge now also requires the two pairings' implied velocities to
  agree.

chi2_per_dof stays None when nothing in the cluster was fitted, which on the
deferred path is always.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jehanazad and others added 3 commits September 5, 2026 07:46
The union-find that groups pairings into a solver input is transitive, so
"within the merge distance" chains: three aircraft strung out over 12 km
arrive as one connected group and became one candidate.  Splitting only on
same-node track conflicts left that alone -- on the 15-node bench scene the
baseline emitted inputs spanning up to 23 single-node tracks, and an
aircraft has one track per node, so a 23-track input at 10 nodes was
describing at least three aircraft as one.

_partition_cluster now runs on every group, not only conflicting ones, and
tests membership against EVERY pairing already in a sub-cluster rather than
against one of them: same node/track assignment, within merge_dist_km, and
implied-velocity consistent.  That bounds each emitted input's diameter at
the merge distance instead of letting it grow with the chain.

Measured on the 15-node ring scene, seeds 2-3, --mode track --cv-fit
deferred (production's shape):

                       baseline   conflict-split only   this
  published contam.    44% / 26%       39% / 31%      32% / 26%
  foreign nodes/solve      0.80            0.86           0.32
  ghost by solve       6.8% / 5.0%     7.4% / 5.1%    6.7% / 3.8%
  real tracks               4, 6            4, 6           4, 6
  widest input          23 tracks       11 tracks      10 tracks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Swept 6.0 / 4.5 / 3.0 km on the offline bench (15-node ring, 6 seeds,
--mode track --cv-fit deferred -- production's shape).  Every metric is
monotone in the radius and the real-track count is identical at all three
points, so there is no trade to make:

                published contam.   foreign nodes/solve   ghost by track
        6.0            32.0%              0.76               55.6%
        4.5            28.2%              0.63               54.3%
        3.0            25.1%              0.52               50.0%

with ghost-by-solve 6.0 -> 3.0 falling 5.9% -> 2.9% (mean over seeds) and
real tracks 3, 4, 6, 6, 6, 7 at every point.

3.0 is the floor of what was swept, not a measured optimum.  It is also the
association grid step, which is the natural stopping point for now: below it
two pairings of the SAME aircraft start landing in cells that can no longer
reach each other, and that failure would cost real tracks rather than ghosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The blank line before a nested def after a multi-line assignment.  The
retina-server pre-commit run does not reach this file -- pre-commit
enumerates through git ls-files, which sees a submodule as a gitlink -- so
this repo lints on its own gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jehanazad pushed a commit that referenced this pull request Sep 6, 2026
Stacks this PR on #25 and #26 so the three merge cleanly in order.
association.py / test_track_association.py resolved as on the
deploy/dark-solver-stack integration branch: candidates keep t_s_a/t_s_b
and the candidate helper carries t_s through the cluster split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jehanazad
jehanazad marked this pull request as ready for review September 6, 2026 02:26
@jehanazad
jehanazad merged commit 1169e4e into main Sep 6, 2026
1 check 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