Split contaminated position clusters instead of picking the loud track - #26
Merged
Merged
Conversation
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>
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
marked this pull request as ready for review
September 6, 2026 02:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
format_track_pairs_for_solvermerged 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_tracksstage 2 never runs live):_partition_clusterreplaces 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, withinmerge_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 countercluster_splits._drop_velocity_conflicts: pair-level exclusivity on the Doppler-implied velocity, gated oncv_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 astrack_pairs_superseded._MERGE_DIST_KM6.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.chi2_per_dofstaysNonewhen 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:
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.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-analyticssuite: 425 passed. New tests:TestClusterPartition,TestVelocityExclusivity,TestVelocityConflictintests/test_track_association.py.Intentionally changed behaviour
TestFormatTrackPairsForSolver::test_cluster_reports_its_worst_fitused two pairingsa1/b1anda2/b2at the same position to make one cluster. That is now (correctly) two targets, so the worst-fit rule would never be reached; the fixture becomesa1/b1anda1/c1— the same aircraft on three nodes, which is the case the rule is actually about.🤖 Generated with Claude Code