Skip to content

tracker: clamp frame dt and reject non-positive-definite gates (86cb9bq7p) - #24

Merged
Babissimo merged 1 commit into
mainfrom
fix/tracker-dt-and-gating
Aug 27, 2026
Merged

Babissimo merged 1 commit into
mainfrom
fix/tracker-dt-and-gating

Conversation

@Babissimo

@Babissimo Babissimo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Ticket: 86cb9bq7p (the dt and gating halves; the per-node executor half stays open there)
Parent: 86cb9bp4q, pipeline accuracy audit of 2026-08-24

What this closes

One chain, three links, with no symptom at any of them.

An out-of-order frame makes dt negative. A negative dt makes F P Fᵀ + Q non-positive-definite. The gate tested only that S was invertible, so such a track kept gating. Its Mahalanobis distances then came out negative, and a negative cost wins a minimisation outright.

The corrupted track takes detections from every healthy track it can reach, with no exception, no log and no counter. In a 40-frame run it absorbs another target's detections permanently and ends up ACTIVE, positive definite and indistinguishable from healthy, reporting the wrong aircraft under the original track ID.

Four changes

dt is clamped to [0, 60] and clamps are counted in n_dt_clamped.

Zero rather than a nominal step for the lower bound: at dt = 0 the predict is a bitwise no-op (F = I, Q = 0), so the update runs from the last good state. An out-of-order frame carries no usable time relationship, and substituting a nominal +0.5 s would advance the prediction in the wrong temporal direction while inflating P, loosening the gate at exactly the wrong moment.

Sixty for the upper bound, as a cost trade rather than a safe point. Q grows as dt³, so the gate already spans the whole observed delay axis by about 23 s and no tolerable bound prevents that. What the bound decides is which failure you take: too tight and the clamp under-predicts while injecting only the truncated process noise. At 10 s, tracking dies once a real gap exceeds ~20 s, and empty frames are dropped upstream so multi-second droughts are ordinary. That would cause by another route the very behaviour change 86cb9bqj3 defers.

A non-finite timestamp is rejected at entry to process_frame, before anything reads it.

min(max(nan, 0.0), 60.0) returns nan, because Python's min and max yield the first operand whenever the comparison is false, and every comparison against a NaN is. So the derived dt needed a guard either way. Guarding the dt and the clock alone was not sufficient: the raw stamp also reaches track IDs, death_timestamp and the merge-window cutoff, and a NaN at each of those does separate damage.

  • mark_missed(nan) sets death_timestamp on every coasting track, so get_quality_score() returns nan.
  • The cutoff becomes nan, so every death_timestamp >= cutoff is false and all_tracks drains into completed_tracks in a single frame, emptying the merge working set.
  • On the frame a tentative track promotes, datetime.fromtimestamp raises straight out of process_frame: ValueError for a NaN, OverflowError for an infinity.

One rejection at entry closes all three and keeps the guard in one place rather than in each consumer of the stamp.

Rejections are counted in n_frames_rejected, not folded into n_dt_clamped. An unusable frame and an out-of-order but usable one are different failures, and a single counter for both cannot tell an operator which is happening.

The clock is a high-water mark that resyncs after MAX_BACKWARDS_RUN non-advancing frames.

Without the high-water mark, a clamped late frame still rewinds the clock and the next frame computes the whole excursion as its dt, inside the bounds so neither clamped nor counted.

But a bare ratchet is a failure of its own. Barring an infinity from the mark is not enough: any implausible yet finite future stamp pins it just as well, and every later frame then clamps to dt = 0, so the filter never predicts again. Nothing upstream stops one arriving, since the TCP ingest path checks only that a timestamp is present, never its value. Measured over 60 ordinary frames after a single stamp a day ahead:

clock recovers live tracks frames clamped
bare ratchet no 10 61 of 61
with the run bound yes 1 4

Three rather than one, because at one this is just deleting the mark, and because two interleaved node clocks alternate rather than run, so they keep it. An interleaved pair 30 s apart still gives 0 resyncs.

The gate applies Sylvester's criterion, not the sign of the determinant.

This deviates from the audit, which prescribed det_S > 1e-15. That is necessary but not sufficient: H P Hᵀ is symmetric and neither F nor Q couples delay to Doppler, so b = c = 0 and det_S = a*d exactly. Two negative diagonal entries multiply to a positive determinant, and that, rather than an indefinite matrix, is what a late frame actually produces on a converged track inside the bridge's ±10 s staleness window. Measured: corruption from dt = -6 s at snr 10, from -3 s at snr 20.

test_corrupt_track_does_not_steal_a_healthy_track_detection fails with the determinant form and passes with a > 0 added. That single assertion is the whole argument.

A lower bound on the accepted cost, as prescribed. Costs are a Mahalanobis distance times a strictly positive SNR weight, so a negative one is never legitimate. It cannot be reached through _associate while the gate holds, and is kept as a second layer.

Test isolation

The suite had none. set_config writes a module global that every accessor reads at call time, and with no conftest.py a file that set it supplied the config for every later file in collection order: 74 of the 75 existing tests ran under a config inherited from somewhere else. The new fixture snapshots and restores it, reading the private global rather than get_config(), which assigns when the value is None and would corrupt the state it is meant to save.

Expected effect

Association counts shift slightly. Detections that previously received a garbage negative distance are now excluded rather than preferred. That is the intent, not a regression.

Verification

96 tests (was 59), pre-commit run --all-files green. Reverting the entry rejection fails 11 tests; reverting the resync fails 3; reverting the counter split fails 3; reverting the clamp, the gate term and the high-water mark fails 9, 1 and 4 respectively. The cost bound stays green by construction, which is documented rather than hidden.

Nothing in the dt, gating or non-finite paths had coverage before: every pre-existing frame in the suite steps 500 ms, so negative dt, large dt, the det_S sign, negative costs and every non-finite case were all unexercised.

Merge order

This merges first. A follow-up PR then bumps the submodule pointer in retina-server, which is currently pinned at 04ec70b (v0.2.1).

Branched from main (784d68d) rather than the pin, deliberately: retina_tracker/tracker.py is byte-identical across that range, so there is no conflict risk and the audit's line numbers hold verbatim, while the pin predates .pre-commit-config.yaml and so has no local gate at all. The bump will therefore carry six intervening commits: four ruff and dead-code tooling changes, and an events.jsonl rotation the server never reaches, since it defines its own InMemoryEventWriter.

🤖 Generated with Claude Code

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 3m 16s —— View job


Claude finished the code review

Task list

  • Gather context (PR description, changed files, full diff)
  • Understand the request (code review of PR tracker: clamp frame dt and reject non-positive-definite gates (86cb9bq7p) #24)
  • Trace the math: confirm b = c = 0 is a real invariant, not an assumption
  • Trace dt clamp + high-water-mark logic against edge cases (NaN, inf, duplicate/negative timestamps)
  • Check lint/style (line length, unused imports) by inspection
  • Post review findings

Summary

This is a solid, well-reasoned fix. I traced the three changes against the actual code paths rather than just the PR description, and they hold up:

dt clamp + high-water mark (tracker.py:64-73, 191-194)

  • dt = min(max(raw_dt, 0.0), MAX_FRAME_DT) if math.isfinite(raw_dt) else 0.0 correctly handles NaN/inf (Python's min/max do not filter NaN — min(max(nan, 0), 60) is nan, so the explicit isfinite guard is load-bearing, not redundant).
  • The high-water mark (timestamp > self.last_timestamp) correctly prevents a clamped late frame from rewinding the clock, which would otherwise let the next frame absorb the whole excursion as an unclamped, uncounted dt — confirmed this is a real failure mode of a naive clamp-only fix.
  • self.last_timestamp is only ever assigned a finite value, so raw_dt computed from it can only go non-finite via a non-finite incoming timestamp, and that's caught by the same isfinite check.

Sylvester's criterion (tracker.py:229-234)

  • Checking only a > 0 (not also d > 0) is mathematically sound given that b = c = 0 structurally. I verified this invariant against track.py and kalman.py: F, Q, and H never couple the delay and Doppler subspaces, all three self.covariance = assignments in track.py (init, ADS-B init, predict, update) start or stay block-diagonal, and _merge_track_pair just copies one track's (block-diagonal) covariance onto another. So det_S = a*d exactly, and a > 0 ∧ det_S > 1e-15 ⟹ d > 0 follows algebraically — the missing d > 0 check isn't a gap.
  • This replaces np.abs(det_S) > 1e-15 with det_S > 1e-15, which correctly stops accepting a positive determinant produced by two negative diagonal entries — this is the actual mechanism of the reported bug (verified by hand: a, d < 0 and b = c = 0 gives mahal = (d·nu0² + a·nu1²) / det_S < 0, which then wins the cost minimization).

Cost lower bound (tracker.py:269) — straightforward, correctly documented as unreachable-but-kept defense in depth.

Tests (tests/test_frame_dt_and_gating.py) — good coverage, and the tests are written to actually pin behavior rather than just assert "no crash": test_a_long_gap_predicts_exactly_the_clamped_interval pins the exact bound via a reference computation rather than a magic number, and test_corrupt_track_does_not_steal_a_healthy_track_detection reproduces the full multi-track scenario from the audit rather than just unit-testing the gate in isolation.

No correctness bugs found. One non-blocking observation:

  • n_dt_clamped is tracked but never surfaced (not in to_dict(), no log line, no event). That's consistent with the PR's stated scope (dt/gating halves only, per-node executor stays open on the linked ticket), but if operators want visibility into out-of-order frames in production, this counter is currently only reachable by direct attribute access on the Tracker instance. Not required for this PR.

Verification note

I was not able to execute ruff or pytest in this environment — python3/ruff/pytest invocations require approval that isn't available in this non-interactive review context, and the tools aren't on PATH outside a project venv. I instead verified manually: no lines over 120 chars in either changed file, math import is used (not dead), and traced the logic/tests by hand as described above. The PR body's claims (75 tests, mutation testing, pre-commit run --all-files green, cross-repo suite passing) could not be independently re-verified here — recommend confirming via the repo's CI run for this PR.

@Babissimo

Copy link
Copy Markdown
Contributor Author

Noting the one observation so a human reviewer does not re-raise it: n_dt_clamped is deliberately not surfaced in this PR, and it is tracked as 86cb9t7pw.

The review is right that a counter reachable only by direct attribute access on the Tracker instance is not much use to an operator, and that is the whole point of the ticket. Surfacing it means a new server-side metric route in retina-server, which needs a design decision (per-node or fleet total) and a deploy to verify, so it would have widened this PR into the other repo without being checkable here. Tracker.frame_count has the same problem and predates this work, so whatever surface gets built should carry both.

On the verification note: CI ran lint-and-test green on this PR, which covers the 75 tests and the lint claims. The mutation testing and the cross-repo run against retina-server's suite were local and are not reproducible from CI here, so treat those as reported rather than independently confirmed.

Thanks for tracing the b = c = 0 invariant through _merge_track_pair — that path was not in my own verification, and it is the one that would have broken the argument if a merge could introduce off-diagonal coupling.

@Babissimo
Babissimo marked this pull request as draft August 25, 2026 16:53

@Babissimo Babissimo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through and ran it. The approach is right; one point below should be settled before this lands.

The approach

The diagnosis holds at the level that matters. F P Fᵀ is PSD by congruence for any real F, so it cannot be the source, while Q carries q·dt³/3 and q·dt on its diagonal, both negative for dt < 0, making each block negative definite with a positive determinant. That is exactly the state described, so the invalid input is dt itself and clamping at the boundary is the minimal fix rather than a workaround.

Sylvester over the audit's prescribed det_S > 1e-15 is a genuine improvement on what was asked for, and the justification checks out independently: both init paths are np.diag(...) (track.py:646, :650), F and Q are block-diagonal and (I − KH)P preserves that, so b = c = 0 structurally and det_S = a·d exactly.

The high-water mark needs a bound

math.isfinite shows the unpassable-mark hazard was seen, but it is bounded at infinity rather than at anything reachable. A finite implausible timestamp pins the clock permanently, every later frame clamps to dt = 0, and the filter never predicts again. Sixty ordinary frames after a single frame stamped a day ahead:

main      clock back on the real base: yes    live tracks: 1
this PR   clock back on the real base: no     live tracks: 10    dt clamped: 61 of 61

That is a trade rather than a plain regression, transient severe corruption exchanged for permanent silent degradation, but permanent and silent is the worse of the two to operate. It is reachable: tcp_handler.py:513 checks only that timestamp is present, never its value.

The bridge solves the same shape one layer up. In retina-server#235, ts_ms > last_ts is safe only because _convert_frame's abs(age_s) > STALE_THRESHOLD_S (blah2_bridge.py:248) sits above it, and the comment there says so. The tracker cannot use that gate, being a pure library driven by synthetic timestamps, so it wants the in-band equivalent: a run of backwards frames is the mark being wrong rather than the frames.

MAX_BACKWARDS_RUN = 3
...
self.n_backwards = self.n_backwards + 1 if not raw_dt > 0 else 0   # `not >` so NaN counts
...
resync = self.n_backwards >= MAX_BACKWARDS_RUN
if math.isfinite(timestamp) and (self.last_timestamp is None or timestamp > self.last_timestamp or resync):
    if resync:
        self.n_clock_resyncs += 1
        self.n_backwards = 0
    self.last_timestamp = timestamp

Measured against that case, and against two clocks 30 s apart interleaved into one tracker, which is what nodes sharing default_pipeline produce:

                      wedge: on base / tracks      interleaved: tracks / clamped / resyncs
this PR               no  / 10                     2 / 60 / n/a
with the run bound    yes / 1                      2 / 60 / 0

Full recovery, four frames of frozen prediction instead of forever, and the interleaved case is untouched because alternating clocks reset the counter rather than accumulate it. All 75 existing tests pass unchanged, test_frame_after_a_clamped_one_gets_the_ordinary_dt included, which is the property the mark was added for.

A run of three rather than one is load-bearing: at one this is just deleting the mark, and the next frame computes the whole excursion as its dt, inside the bounds so neither clamped nor counted.

Capping how far the mark may advance per step is the other candidate and is worse. After a genuine long outage it crawls forward 60 s per frame, with dt pinned at the clamp and the gate at its widest the whole way.

Worth counting resyncs separately from clamps: clamping says frames are arriving out of order, resyncing says a node changed time base.

Smaller

MAX_FRAME_DT's comment rests on empty frames being dropped upstream, which is the audit's §3 defect. Once empty frames pass through, the tracker sees roughly 1 s cadence and 60 s stops being routine, so the comment reads as wrong from that point on. Worth pointing it at 86cb9bqj3 so it is revisited rather than surviving as folklore.

n_dt_clamped has no consumer in either repo yet. Tracked separately, noting it here only so it is not assumed covered by #235, which fixes the bridge guard but does not read the counter.

For the record, I checked whether the bogus timestamp also poisons the merge working set (all_tracks drains on death_timestamp) or the track ID date prefix. Neither reproduces: that frame's track stays tentative and never gets an ID. The clock wedge is the whole of it.

@Babissimo
Babissimo force-pushed the fix/tracker-dt-and-gating branch from 93c736f to 7146667 Compare August 26, 2026 11:43
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 3m 1s —— View job


Claude's review of PR #24

Task list

  • Gather context (PR description, full diff vs origin/main, prior review thread)
  • Read retina_tracker/tracker.py in full and trace the updated logic
  • Read the new/updated test files (test_frame_dt_and_gating.py, test_timestamp_validation.py, conftest.py)
  • Post findings

Summary

This push resolves the one substantive concern raised in Babissimo's own review comment (the unbounded high-water mark) and goes further by hardening non-finite-timestamp handling. Both changes are correct and well tested.

MAX_BACKWARDS_RUN resync (tracker.py:26-30, 70-90, 207-215)

This is exactly the mechanism proposed in the review thread: n_backwards counts consecutive non-advancing frames (raw_dt <= 0), and once it reaches MAX_BACKWARDS_RUN (3) the clock mark resyncs onto the incoming frame instead of staying pinned forever. I traced test_a_far_future_frame_does_not_pin_the_clock_for_good by hand against the code: a day-ahead frame pins the mark, the next three frames each compute a negative raw_dt against the stale mark (clamped to 0, counted), the third crosses MAX_BACKWARDS_RUN and resyncs (n_clock_resyncs → 1, mark now base+3000), and every frame after that is ordinary. The math checks out and matches the assertions.

The two edge cases the reviewer's own writeup called out are both covered and correct:

  • Interleaved clocks (test_interleaved_node_clocks_do_not_resync): alternating streams 30s apart never accumulate a run, since each stream's own frame resets n_backwards to 0 (raw_dt > 0 relative to the other stream's last mark). Confirmed n_clock_resyncs == 0 here is the correct outcome, not a false negative.
  • Non-finite frames don't count toward the run (test_a_non_finite_frame_does_not_count_toward_the_run): they return before n_backwards is touched, which is right — a dropped frame says nothing about which clock is correct.

Non-finite timestamp now rejects the whole frame (tracker.py:73-78)

This is a bigger change than the "clamp dt to 0" approach the original review verified — it now rejects the frame before computing dt at all, so a NaN/inf timestamp never reaches Track._generate_id (which calls datetime.fromtimestamp), death_timestamp on missed tracks, or the merge-window cutoff (timestamp - MERGE_WINDOW_MS, which previously would have gone NaN and silently drained all_tracks in one frame — a real bug the old dt-only guard didn't cover). test_a_nan_frame_does_not_drain_the_merge_working_set pins exactly that failure mode. This is a correct and more complete fix than what shipped in the version the first review pass looked at.

One consequence worth being aware of (not a bug, just noting it's deliberate): a rejected non-finite frame still increments frame_count but skips mark_missed() for every track, so it's treated as "frame didn't happen" rather than "frame happened and detections were missing." That matches the test suite's intent (track.n_frames doesn't advance) and seems like the right call for a corrupt frame.

Minor: n_dt_clamped now conflates two different events

process_frame increments n_dt_clamped both when dt is actually clamped to [0, 60] and when a frame is rejected outright for a non-finite timestamp (line 77). These are different failure modes (out-of-order-but-usable vs. unusable frame), and conflating them in one counter will make it harder to reason about n_dt_clamped when it's eventually surfaced (tracked in 86cb9t7pw per the discussion above). Worth a separate counter (or at minimum a comment noting the overlap) when that surfacing work happens — not a blocker for this PR.

No correctness issues found in the updated logic. The test coverage in test_timestamp_validation.py directly exercises the scenario the reviewer's own comment demonstrated (far-future timestamp wedging the clock) and the two edge cases that make a naive "any single backwards frame resyncs" fix wrong (interleaved clocks, duplicate timestamps).

Verification note

As with the prior review pass, I could not execute ruff or pytest in this environment (no approval available for python3/ruff/pytest invocations here). I verified by reading: line lengths in the diff, import usage, and traced the n_backwards/resync arithmetic by hand against the new tests rather than running them. Recommend confirming green CI for this push before merging.
· branch

@Babissimo
Babissimo force-pushed the fix/tracker-dt-and-gating branch from 7146667 to c4955d2 Compare August 26, 2026 15:48
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 2m 17s —— View job


Claude's review of PR #24

Task list

  • Gather context (PR description, full diff vs origin/main, prior review thread)
  • Read retina_tracker/tracker.py in full and re-verify the diff line by line
  • Check whether the previously-flagged n_dt_clamped/rejection conflation was addressed
  • Check CI status for this push
  • Post findings

Summary

This push resolves the one non-blocking observation from the prior review pass. No new issues found.

n_dt_clamped / n_frames_rejected are now separate counters (tracker.py:44-45, 67-68, 79)

The previous review noted that a non-finite-timestamp rejection and an out-of-order-but-usable clamp were both being folded into n_dt_clamped, which would make the counter ambiguous once surfaced. That's fixed here: rejection now increments its own n_frames_rejected (line 79), leaving n_dt_clamped to mean only "a usable dt was clamped to [0, 60]." This is directly pinned by a new test, test_a_rejected_frame_is_counted_apart_from_a_clamped_dt (test_timestamp_validation.py:158-167), which asserts the two counters move independently. Both counters are also reset correctly in reset() (tracker.py:67-70) and covered by test_the_resync_counters_reset_with_the_tracker.

I re-traced the rest of the logic against this exact commit (c4955d2) and it's unchanged from what was already verified in the prior pass:

  • dt clamp + non-finite rejection (tracker.py:72-91): the isfinite guard at entry correctly precedes any use of timestamp, so a NaN/inf stamp never reaches Track._generate_id, death_timestamp, or the merge-window cutoff.
  • High-water mark with MAX_BACKWARDS_RUN resync (tracker.py:209-217): n_backwards counts consecutive raw_dt <= 0 frames and resyncs the mark after 3, which I hand-traced against test_a_far_future_frame_does_not_pin_the_clock_for_good and test_a_duplicate_timestamp_counts_toward_the_run — both match the code exactly, including the duplicate-timestamp edge case (raw_dt == 0 counts toward the run but is never itself clamped, since dt is already 0).
  • Sylvester's criterion (tracker.py:252-257): valid = (a > 0) & (det_S > 1e-15) is sound given the block-diagonal structural invariant (b = c = 0) that was independently verified against track.py/kalman.py in the prior pass — no coupling introduced by _merge_track_pair either.
  • Cost lower bound (tracker.py:290): correctly defense-in-depth, unreachable given the gate but cheap insurance.

No correctness issues found in this diff.

Verification note

As with prior passes, I could not execute ruff/pytest directly in this environment (command execution requires interactive approval that isn't available here). However, CI for this exact push is green: CI run #32986873021success. I verified the logic by reading the diff (git diff origin/main...HEAD) and hand-tracing it against the new/updated tests in tests/test_timestamp_validation.py and tests/test_frame_dt_and_gating.py, which line up with the code exactly.
· branch

…bq7p)

One chain, three links, no symptom at any of them: an out-of-order frame
makes dt negative, a negative dt makes the innovation covariance
non-positive-definite, the gate check tested the wrong property so such a
track kept gating, and its Mahalanobis distances came out negative --
which wins a minimisation outright. The corrupted track then took
detections from every healthy track it could reach, with no exception, no
log and no counter. In a 40-frame run it absorbs another target's
detections permanently and ends up ACTIVE, positive definite and
indistinguishable from healthy, reporting the wrong aircraft.

dt is clamped to [0, 60] and clamps are counted in `n_dt_clamped`.

Zero rather than a nominal step for the lower bound: at dt=0, F is the
identity and Q is zero, so the predict is a bitwise no-op and the update
runs from the last good state. An out-of-order frame carries no usable
time relationship, and substituting a nominal +0.5 s would advance the
prediction in the wrong temporal direction while inflating P, loosening
the gate at exactly the wrong moment. Repeated zero-dt cycles decay P
harmonically rather than geometrically and one ordinary frame reinflates
it, so there is no collapse risk. Nothing divides by this dt; the four
anomaly detectors compute their own from raw timestamps and already guard
`dt > 0`.

Sixty for the upper bound, which is a cost trade rather than a safe
point. Q grows as dt cubed, so the gate already spans the whole observed
delay axis by about 23 s and no tolerable bound prevents that -- only
wall-clock track ageing does, and that is a ticket of its own. What the
bound decides is which failure you take: the clamp under-predicts by the
truncated interval while injecting only the truncated process noise, so
too tight a bound is its own regression. At 10 s tracking dies once a
real gap exceeds ~20 s, and since empty frames are dropped upstream,
multi-second droughts are ordinary -- that would cause by another route
the very behaviour change the empty-frame ticket defers. Sixty keeps
drought recovery to ~120 s.

The clock now advances as a high-water mark rather than to whatever the
last frame happened to carry, because clamping alone closes only half the
path. A frame 6 s late is clamped to 0 and counted, but still leaves
`last_timestamp` at T-6 s, so the next legitimate frame at T+1 computes
7 s. That is inside the bounds, so it is neither clamped nor counted, and
Q[0,0] comes out 343x too large with a gate to match: one out-of-order
frame corrupted two frames while the counter owned up to one. Nothing
depends on the old semantics -- `last_timestamp` is read only to form
this dt and as the clock in tests, and no other repo touches it.

A non-finite timestamp is rejected outright, at the top of
process_frame, before anything reads it. `min(max(nan, 0.0), 60.0)`
returns nan, because Python's min and max yield the first operand
whenever the comparison is false and every comparison against a NaN is,
so the derived dt needed a guard either way. Guarding the dt and the
clock alone was not enough: the raw stamp also reaches track IDs,
`death_timestamp` and the merge-window cutoff. A NaN there sets
`death_timestamp` on every coasting track, so `get_quality_score` returns
nan; it makes the cutoff nan, so every comparison against it is false and
`all_tracks` drains into `completed_tracks` in a single frame, emptying
the merge working set; and on the frame a tentative track promotes,
`datetime.fromtimestamp` raises straight out of `process_frame`,
ValueError for a NaN and OverflowError for an infinity. One rejection at
entry closes all three, and keeps the guard in one place rather than in
each consumer of the stamp. Rejections are counted in
`n_frames_rejected` rather than folded into `n_dt_clamped`: an unusable
frame and an out-of-order but usable one are different failures, and a
single counter for both cannot tell an operator which is happening.

The mark yields after MAX_BACKWARDS_RUN consecutive non-advancing frames,
because a one-way ratchet is a failure of its own. Barring an infinity
from the mark is not sufficient: any implausible but finite future stamp
pins it just as well, and every later frame then clamps to dt=0, so the
filter never predicts again. Nothing upstream stops one arriving; the TCP
ingest path checks only that a timestamp is present, never its value.
Measured over 60 ordinary frames after a single stamp a day ahead: with
the bare ratchet the clock stays pinned, 61 of 61 frames clamp and one
aircraft fragments into 10 tracks, permanently; with the run bound it
resyncs once and recovers to a single track. Three rather than one,
because at one this is just deleting the mark, and because two
interleaved node clocks alternate rather than run, so they keep it.

The rationale above lives here rather than beside the code, because
`CLAUDE.md` and `.claude/rules/code-style.md` both bar comments outright and
ask names and structure to carry the intent. So `MAX_FRAME_DT` gains its unit
as `MAX_FRAME_DT_S`, `MAX_BACKWARDS_RUN` says what it triggers as
`BACKWARDS_RUN_BEFORE_RESYNC`, and the gate's mask is `pos_def` rather than
`valid`. Both constants are new here, so neither rename can reach a consumer.

Two things the deleted comments had been carrying are now carried by code
instead. The gate omits the `d > 0` term of Sylvester's criterion, which is
sound only while S is diagonal, so `TestGateStructuralInvariant` pins that the
covariance stays block-diagonal through predict and update rather than leaving
the precondition to prose: injecting a 1e-9 cross term into P fails it. And
the entry guard is widened to `numbers.Real`, because `math.isfinite` raises
off a real number rather than returning False, and `process_streaming_frame`
reads the stamp out of an external frame without checking its type, so a node
sending an ISO-8601 string crashed the frame rather than having it dropped.

Test isolation goes in alongside, the suite having had none. `set_config`
writes a module global that every accessor reads at call time, and with
no conftest a file that set it supplied the config for every later file
in collection order: 74 of the 75 tests ran under a config inherited from
somewhere else. The fixture snapshots and restores it, reading the
private global rather than `get_config()`, which assigns when the value
is None and would corrupt the state it is meant to save.

The gate now applies Sylvester's criterion rather than the sign of the
determinant alone. The audit prescribed `det_S > 1e-15`, which is
necessary but not sufficient: H P Hᵀ is symmetric and neither F nor Q
couples delay to Doppler, so b = c = 0 and det_S = a*d exactly. Two
negative diagonal entries therefore multiply to a positive determinant,
and that -- not an indefinite matrix -- is what a late frame actually
produces on a converged track inside the bridge's ±10 s staleness window.
The determinant test cannot see it. The last test here fails with
`det_S > 1e-15` and passes with `a > 0` added, which is the whole
argument in one assertion.

The cost lower bound goes in as prescribed. Costs are a Mahalanobis
distance times a strictly positive SNR weight, so a negative cost is
never legitimate however it arose. It cannot be reached through
`_associate` while the gate holds, since a positive-definite S cannot
yield a negative distance, and mutation-testing confirms as much:
reverting it alone leaves the whole suite green. No test can protect it,
so the comment beside it states the constraint instead: a distance times
a positive weight is never negative.

Association counts will shift slightly: detections that previously
received a garbage negative distance are now excluded rather than
preferred. That is the intent, not a regression.

None of this had test coverage -- every existing frame steps 500 ms -- so
the behaviours are pinned here rather than left to be rediscovered. Every
production change is mutation-tested except the cost bound, which is the
one the gate makes unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Babissimo
Babissimo force-pushed the fix/tracker-dt-and-gating branch from c4955d2 to 42d897c Compare August 27, 2026 11:50
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 6m 23s —— View job


Claude's review of PR #24

Task list

  • Gather context (PR description, full diff vs origin/main, all three prior review passes)
  • Re-read retina_tracker/tracker.py, track.py, kalman.py and re-trace the dt clamp, entry rejection, resync, and Sylvester-gate logic against this commit (42d897c)
  • Read both new test files in full
  • Check CI status for this exact commit
  • Post findings

Summary

This is the fourth review pass on this PR, and the code at 42d897c matches what the third pass (c4955d2) already verified — no functional diff between them, just a rebase/squash onto a fresh SHA. I re-traced the logic independently rather than trusting the prior passes, and it holds up. No correctness issues found.

dt clamp + non-finite rejection (tracker.py:66-91)isinstance(timestamp, numbers.Real) and math.isfinite(timestamp) correctly rejects NaN/inf/non-numeric types before timestamp is used anywhere, which matters because min(max(nan, 0.0), 60.0) silently evaluates to nan (Python's min/max return the first operand on a false comparison, and every NaN comparison is false). test_a_non_numeric_timestamp_drops_the_frame (test_timestamp_validation.py:161-173) specifically exercises why numbers.Real is needed and not just math.isfinite: the latter raises TypeError on a string/list/dict rather than rejecting cleanly.

High-water mark + resync (tracker.py:82, 202-207)n_backwards counts consecutive raw_dt <= 0 frames and resyncs the mark after BACKWARDS_RUN_BEFORE_RESYNC (3). Hand-traced this against test_a_far_future_frame_does_not_pin_the_clock_for_good and test_interleaved_node_clocks_do_not_resync — both match exactly, including the non-obvious case that a duplicate timestamp (raw_dt == 0) counts toward the run without itself being clamped.

Sylvester's criterion (tracker.py:243)pos_def = (a > 0) & (det_S > 1e-15). I did an independent algebraic check beyond what prior passes wrote up: for a symmetric 2×2 matrix, a > 0 ∧ ad − b² > 1e-15 ⟹ d > 0 always holds (if d ≤ 0 then ad ≤ 0, so det_S ≤ −b² ≤ 0, contradiction) — symmetry alone would be enough, without needing block-diagonality. But S = H P Hᵀ + R is only symmetric in exact arithmetic; (I − KH)P in kalman.py:101 isn't a Joseph-form update, so floating-point roundoff could in principle make the code's b and c (tracker.py:240-241) unequal even though they're mathematically supposed to match — and if b ≠ c, a large negative b·c can make det_S positive even with d < 0. The PR sidesteps this entirely by relying on the stronger invariant that b = c = 0 exactly (structural: F, Q, H never couple the delay/Doppler subspaces, confirmed against kalman.py:26-40 and every self.covariance = assignment in track.py, including _merge_track_pair). That's the right invariant to lean on, and it's pinned directly by test_the_covariance_stays_block_diagonal / test_the_innovation_base_therefore_has_no_off_diagonal (test_frame_dt_and_gating.py:288-300) rather than left implicit.

Cost lower bound (tracker.py:274) — correctly redundant given the gate (0 ≤ cost follows algebraically once a, d, det_S > 0), kept as defense in depth per the PR description.

Tests — both new files are well-targeted at the exact failure modes in the PR body rather than generic coverage: e.g. test_frame_after_a_clamped_one_gets_the_ordinary_dt pins the "clamp alone still lets the clock rewind" failure mode with a reference-covariance comparison, and test_corrupt_track_does_not_steal_a_healthy_track_detection reproduces the full multi-track scenario from the audit.

CI — green for this exact commit: CI run #33069157586 (lint-and-test, which runs pre-commit run --all-files covering ruff lint/format, plus the full pytest suite). As with prior passes, I could not execute ruff/pytest/python3 directly in this environment (command execution requires approval unavailable here), so this CI result is the authoritative confirmation rather than something I re-ran locally.

Carried forward from prior passes (non-blocking, already acknowledged by the author): n_dt_clamped/n_frames_rejected/n_clock_resyncs are tracked but not surfaced outside direct attribute access — deliberately out of scope for this PR per 86cb9t7pw.

No new issues found in this pass. The PR looks ready to merge on its technical merits.
· branch

@Babissimo
Babissimo marked this pull request as ready for review August 27, 2026 11:53
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 2m 13s —— View job


Claude's review of PR #24

Task list

  • Gather context (PR description, diff vs origin/main, prior review threads)
  • Run /code-review skill against this PR per repo custom instructions
  • Post findings

Summary

Re-verified the current HEAD (42d897c) independently rather than assuming the prior three review passes still apply — the diff is unchanged from what was already reviewed and confirmed correct (tracker.py:1-425, tests/conftest.py, tests/test_frame_dt_and_gating.py, tests/test_timestamp_validation.py). No new issues found.

Traced by hand against the code, not just the tests:

  • Rejection guard (tracker.py:73-75): isinstance(timestamp, numbers.Real) catches non-numeric stamps (None, str, list, dict — all in NON_NUMERIC in the tests) before math.isfinite would raise on them; math.isfinite then catches NaN/Inf. Correctly precedes every other read of timestamp (track ID generation, death_timestamp, merge cutoff), so none of those three consumers ever sees a bad value.
  • Clamp + backwards-run resync (tracker.py:77-82, 202-207): n_backwards increments only inside the last_timestamp is not None branch and only for raw_dt <= 0, resets to 0 on any positive raw_dt, and the mark only advances on timestamp > self.last_timestamp or on hitting BACKWARDS_RUN_BEFORE_RESYNC. Hand-traced the far-future-pin scenario: 3 clamped/frozen frames, then resync onto the 3rd frame's own (real) timestamp, then normal cadence resumes — matches test_a_far_future_frame_does_not_pin_the_clock_for_good and test_the_filter_predicts_again_after_a_resync exactly. Interleaved-clock and duplicate-timestamp edge cases both check out against tracker.py:82.
  • Sylvester's criterion (tracker.py:243): confirmed the structural invariant this depends on — kalman.py:40 (R = np.diag(...)), track.py:646/650 (both covariance inits are np.diag(...)) — so b = c = 0 by construction and det_S = a·d exactly, making a > 0 ∧ det_S > 1e-15 ⟹ d > 0. pos_def = (a > 0) & (det_S > 1e-15) is therefore sound, and is the fix for the actual bug (two negative diagonal entries giving a spuriously positive determinant under the old abs(det_S) > 1e-15 check).
  • Cost floor (tracker.py:274): correct defense-in-depth, unreachable while the gate holds.

Test coverage is thorough and pins behavior rather than just "doesn't crash" — e.g. test_a_rejected_frame_is_counted_apart_from_a_clamped_dt and test_a_duplicate_timestamp_counts_toward_the_run in tests/test_timestamp_validation.py:175,237 exercise exactly the distinctions the PR description calls out.

Verification

  • CI is green on this exact commit: CI run #33069157586success.
  • I could not execute ruff/pytest directly in this sandboxed environment (command execution requires approval not available here), so I relied on the passing CI run plus manual tracing through tracker.py, kalman.py, and track.py for the invariants above.
  • The one prior open item (n_dt_clamped not yet surfaced to an operator) is explicitly out of scope for this PR and tracked separately as 86cb9t7pw, per Babissimo's comment above — not re-raising it.

No blocking issues. This is ready from a code-review standpoint.
· branch

@Babissimo
Babissimo merged commit 79db48c into main Aug 27, 2026
3 checks passed
Babissimo added a commit to offworldlabs/retina-server that referenced this pull request Aug 27, 2026
An out-of-order frame made dt negative, which made F P Fᵀ + Q
non-positive-definite. The association gate tested only that S was
invertible, so such a track kept gating and its Mahalanobis distances came
out negative, which wins a minimisation outright: the corrupted track took
detections from every healthy track it could reach, with no exception, no
log and no counter, and ended up reporting the wrong aircraft under the
original track ID.

This repo is where that bites. blah2_bridge's duplicate guard admitted an
older frame, and FRAME_WORKERS drain a single queue with no per-node
keying, so backwards timestamps reach a node's tracker under ordinary
operation rather than as an edge case.

The bump carries six intervening commits besides the fix. Five are tooling
and formatting (ruff configuration, the vulture dead-code gate, running
both through pre-commit) and one adds events.jsonl rotation, which this
repo never reaches since it supplies its own InMemoryEventWriter. The fix
is the only behavioural change that lands: across the range track.py sees
import reordering plus one `if/return` pair collapsed to `return expr`,
and config.py gains an additive `output` section with no existing default
altered.

offworldlabs/retina-tracker#24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Purple10101
Purple10101 deleted the fix/tracker-dt-and-gating branch September 6, 2026 15:51
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