Skip to content

Carry each measurement's sample time (t_s) into solver inputs - #27

Merged
jehanazad merged 9 commits into
mainfrom
feat/measurement-epochs
Sep 6, 2026
Merged

jehanazad merged 9 commits into
mainfrom
feat/measurement-epochs

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

What

Every solver input this module emits now carries t_s — the epoch-seconds capture time of the measurement — on each entry of measurements, alongside the existing node_id / delay_us / doppler_hz / snr.

Three emitters, all changed the same way:

  • format_track_pairs_for_solver — via two new TrackPairCandidate fields, t_s_a / t_s_b, set from each track's history[-1]["t_s"]. The per-node max-SNR dedup carries the time with the delay it belongs to.
  • _adsb_seed_round — from m["last"]["t_s"].
  • _claim_round — from m["_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 µs rms_delay gate, 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_s is 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-node fc_hz, which lives in the backend's node configs, so the alignment itself is implemented there (align_measurement_epochs in backend/services/tasks/solver.py, behind SOLVER_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

claude and others added 4 commits September 5, 2026 06:23
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>
jehanazad and others added 5 commits September 5, 2026 08:55
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
jehanazad marked this pull request as ready for review September 6, 2026 02:26
@jehanazad
jehanazad merged commit c58b662 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.

2 participants