Claim the re-solve slot on publication, not on admission - #294
Conversation
The suppression rule reads "this aircraft is already on the map at this width, at every track it is built from". _claim_resolve_slot recorded the claim BEFORE the solve, so a candidate that never reached the map still made that statement — and never released it. Two consequences, both live: * A rejected candidate blacked out its own identical twin for the full 12 s. 24% of dark attempts are rejected, and the retry that would have published was suppressed by the failure. * Tracker track ids are shared across the association candidates of DIFFERENT aircraft (74 of 178 ids in a 6 min window appeared in solves of more than one ground-truth aircraft — the finding that forced _supersession_match's spatial guard in #290). So a contaminated superset that the gates sank also suppressed the clean subsets behind it, including its neighbour's only candidate. Measured on the test droplet: ~1,537 skips against 646 dark attempts per 30 min. The mechanism refused more than twice as many candidates as it solved, for aircraft it had put nowhere. _claim_resolve_slot splits into a pure _resolve_slot_covered (read-only, run before the solve, also returning the blocking claims for the skip record) and _record_resolve_slot, called only on the publish path with the POST-TRIM survivors — result["source_track_ids"], which _filter_s_in_to_nodes rebuilds from the surviving track_ids_by_node. A trimmed node's track is deliberately left unclaimed: it contributed nothing to the published position and was probably another aircraft's, so claiming it would suppress that aircraft on the strength of a measurement this solve threw away. The rule itself is unchanged: every track covered at no fewer nodes within _SOLVER_RESOLVE_INTERVAL_S, widest claim wins, same pruning sweep. No negative claim for rejects — measure first. The cost is that the check no longer claims under the same lock, so two workers can both solve duplicates that arrived together. That is one extra solve, arbitrated downstream by keying and supersession, against the starvation above. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…/resolve-slot-claim-on-publish
…/resolve-slot-claim-on-publish
…urement-epochs state.py / routes/test.py / solver.py / test_solver_stats.py: adjacent additions from both sides kept (epoch-align flag + counters next to the free-altitude flags, resolve_skips_dark and altitude_mode stamps). libs/retina-analytics: analytics main. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Claude finished @jehanazad's task in 1m 37s —— View job Code review: #294 — Claim the re-solve slot on publication, not on admissionTodo list:
SummaryThis
No correctness issues found. The same minor, non-blocking observations from the prior pass still stand and remain optional:
I was not able to execute the test suite in this sandbox (no backend Overall: unchanged from the prior review — looks good to merge on its own merits. |
|
Note for history readers: this PR's base was |
Stacked on #293 (
feat/dark-solver-observability). Review that first; this PR's diff is the resolve-slot change alone.The bug
_claim_resolve_slotrecorded_RECENT_SOLVES[tid] = (now, n)before the solve, under the same lock as the test, and never released it. But the statement a claim makes is "this aircraft is already on the map at this width" — and only a publish puts it there. A candidate that the gate stack then rejected, or one that was a contaminated superset trimmed into nothing, still held the slot for the wholeSOLVER_RESOLVE_INTERVAL_S(12 s).Two live consequences:
_supersession_match's spatial guard in Require a spatial or identical-inputs match before superseding a multinode track #290. So a contaminated superset the gates sank also took down every clean subset behind it, including a neighbour's only candidate.Live baseline (test droplet, 30 min):
solver_resolve_skips≈ 1 537 against 646 dark attempts — the mechanism refused more than twice as many candidates as the lane solved, for aircraft it had put nowhere.The change
_claim_resolve_slotsplits in two:_resolve_slot_covered(s_in, now_s) -> (covered, blocking)— pure, read-only, run before the solve. Returns the blocking claims for Instrument the dark lane: per-lane history, resolve-slot skips, live contamination #293's skip record; an admitted candidate leaves no trace at all._record_resolve_slot(track_ids, n_nodes, now_s)— called only on the publish path, after the archive append and outside_MN_TRACKS_LOCK(so_RECENT_SOLVES_LOCKis never nested inside it), with the post-trim survivors:result["source_track_ids"], which_filter_s_in_to_nodesrebuilds from the survivingtrack_ids_by_node. All three producers inassociation.pyemit that field, so the survivors are genuine, and there is a test asserting a trimmed node's track is left unclaimed. That is deliberate: a node dropped for a bad residual contributed nothing to the published position and its track was probably another aircraft's — claiming it would suppress that aircraft on the strength of a measurement this solve threw away.The rule is otherwise unchanged: every track covered at no fewer nodes inside the 12 s window, widest claim wins, same
_sweep_recent_solvespruning.state.bump_counter("solver_resolve_skips")and #293's skip deque stay at the check site. No negative claim for rejects — measure first, as the plan says.Accepted cost
The check no longer claims under the same lock, so two workers can now both solve duplicates of one aircraft that arrived together. That is one extra solve, arbitrated downstream by keying and supersession (which handle exactly this case), against the starvation above. Documented in the block comment and in
docs/solverflow.md§5.Expected post-deploy change
resolve_skips / attempts(theresolve_skips.attempts_ratio#293 adds) should fall from ~0.96–2.4 toward ≤ 0.5, withattemptsrising and darkpublishedrising with it, and no growth in thesolver_fail_rms_delayshare of rejects. Solver load rises — pool headroom on the test droplet is fine (queue depth ≈ 0).Tests
The existing
_claim_resolve_slotcases become check/record cases (_resolve_slot_covered/_record_resolve_slot), plus:test_the_check_alone_claims_nothing— the whole point of the split.test_a_rejected_candidate_does_not_block_an_identical_twin— end-to-end through_process_solver_item: reject on rms_delay, then the twin solves and publishes,solver_resolve_skips == 0.test_a_subset_for_another_aircraft_survives_a_rejected_superset.test_only_the_published_width_is_claimed.TestTrimmedTracksAreNotClaimed(intest_solver_trimming.py) —_RECENT_SOLVESholds exactly the survivors, and a neighbour built on the dropped track is still admitted.Checked for other callers: none outside tests (
known_lanesetssource_track_ids = []and does not go through_process_solver_item);test_feed_multinode.pyis unaffected and passes.How verified
tests/test_solver_worker.py tests/test_solver_trimming.py tests/test_mlat_history.py tests/test_solver_stats.py tests/test_feed_multinode.py: 252 passed.-n 2 -m "not external").pre-commit run --all-files: all five hooks Passed.Not deployed.
🤖 Generated with Claude Code