Carry each measurement's sample time (t_s) into solver inputs - #27
Merged
Merged
Conversation
The world gate added for ADS-B claiming and seeding stops a node verifying its tag against the other world's fix, but bottom-up pairing was untouched: register_node and rebuild_zones_for build a grid for every positioned pair, so a synthetic fleet sharing a footprint with real hardware keeps a zone for every sim/real pair. Two nodes in different worlds can never see the same echo, so those grids have no true pairing to find — every candidate they produce is a simulated tracklet matched against a real one. On the test droplet (50 synthetic nodes and 8 receivers over Greenville) that is 400 of 1653 zones, 39 of them with a real overlap, and real node ids duly appear in 48 of 156 synthetic dark solver records over a 7-minute window. Both zone-building paths now consult node_world_provider and skip the pair, under the same fail-open rule the seed gate uses: only two known, different worlds are refused, and an unknown world on either side (no provider, a node the provider does not know) still pairs with everything. The rule moves into _worlds_compatible so the two gates cannot drift apart; that also makes an empty tag read as unknown rather than as a third world matching nothing. Both paths drop rather than merely skip, because a node can change world after its grids were built — a late CONFIG handshake, a reused node id — and a rebuild is exactly where the grids it built against the world it left have to go. assoc_world_skipped_pairs counts the refusals, so a fleet whose pairs are being gated can be told apart from one whose pairs never overlapped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
The three builders that emit solver inputs — bottom-up track pairs, ADS-B seeded rounds, and top-down claimed rounds — all hand the solver each track's newest history sample and say nothing about when it was taken. The solver's residual model evaluates every measurement against one target state, so it assumes they were simultaneous; nodes sample on independent free-running cadences, and on the fleet that spread reaches a frame interval. At 250 m/s the resulting delay error is up to ~1 us per second of skew, which is a third of the whole rms_delay budget. t_s is already present on every history sample the builders read (all three already dereference it for span or newest-sample selection), so this only stops throwing it away. Nothing here consumes it — the alignment lives in the backend solver worker, which is the only place that has the node configs the correction needs. 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>
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>
…rge (ruff F841) 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
Every solver input this module emits now carries
t_s— the epoch-seconds capture time of the measurement — on each entry ofmeasurements, alongside the existingnode_id/delay_us/doppler_hz/snr.Three emitters, all changed the same way:
format_track_pairs_for_solver— via two newTrackPairCandidatefields,t_s_a/t_s_b, set from each track'shistory[-1]["t_s"]. The per-node max-SNR dedup carries the time with the delay it belongs to._adsb_seed_round— fromm["last"]["t_s"]._claim_round— fromm["_last"]["t_s"].Why
The multinode solver's residual model evaluates every measurement against one target state, i.e. it assumes the measurement set is simultaneous. It is not: each node samples on its own free-running cadence (~0.74–1 Hz on the Greenville fleet), and association hands over each track's newest history sample regardless of when it was taken.
Bistatic delay rate is
d(delay_us)/dt = -doppler_hz × 1e6 / fc_hz, so a 250 m/s target accrues up to ~1 µs of delay error per second of skew — measured ~0.3 µs rms at 2 s skew on the fleet. That is charged in full to the backend's 3 µsrms_delaygate, where it is indistinguishable from a contaminated node and drives the trim stage to discard legitimately in-cone nodes. (Live measurement, retina-test: ~20% of clean dark candidates show pre-trim rms > 3 µs and drop to ~0.1–2 µs after trimming a single, usually legitimate, node.)t_sis already present on every history sample all three builders read — each one already dereferences it for span computation or newest-sample selection — so this change only stops discarding it.Scope
Nothing in this library consumes
t_s. The correction needs per-nodefc_hz, which lives in the backend's node configs, so the alignment itself is implemented there (align_measurement_epochsinbackend/services/tasks/solver.py, behindSOLVER_EPOCH_ALIGN=on|off, default on). Consumers that ignore the extra key are unaffected.Pinned by retina-server PR (draft): branch
feat/measurement-epochs.🤖 Generated with Claude Code