_call_fits_incident still uses signed idle, so the sweep path skips both over-merge vetoes on back-dated calls #74

Closed
opened 2026-08-25 21:30:50 -04:00 by logan · 2 comments
Owner

Found by the standing correlation pass on the 2026-08-25 evening unattended run. Static review only — not yet observed in production data.

The defect

_call_fits_incident (app/internal/incident_correlator.py:1601) still computes idle with the signed _incident_idle_minutes. Commit 33a247d moved every correlation gate onto _idle_gate_minutes (:478), which is distance-based and cannot go negative. _call_fits_incident was missed.

Why the sign matters

recorrelation_sweep.py:104 anchors now to the call's own started_at, not wall clock. A back-dated call therefore sees idle_min < 0, and every comparison of the form idle_min >= N is silently false:

  • content-divergence veto at :1620 — skipped
  • content-divergence veto at :1632 (idle_min >= 15) — skipped
  • tactical link at :1719 (idle_min < 20.0) — always true

Failure scenario

An orphaned call from 02:46 is swept against an incident last updated 02:50. Unit overlap fires. With no location proxy and both divergence vetoes bypassed, the call links unconditionally — one incident absorbing unrelated traffic on the same talkgroup. This is the over-merge mechanism, reachable on precisely the one code path that can produce a negative idle.

Why this is not #22

Closed #22 added the fit check itself. This is a sign bug inside that check, introduced later by the 33a247d gate refactor, and it defeats the check on the sweep path only.

Test gap

test_idle_gate_uses_distance_not_sign covers the gates. _call_fits_incident has no equivalent coverage. A regression test should sweep a back-dated call against a newer incident and assert the divergence vetoes still fire.

Note on shipping this

incident_correlator.py is one of the four files board minutes #62 decision 9 froze for autonomous push to main until the deploy rollback (#65) lands. Fix on a branch, or wait for #65.

Refs #22, #27, #65, #62.

Found by the standing correlation pass on the 2026-08-25 evening unattended run. Static review only — not yet observed in production data. ## The defect `_call_fits_incident` (`app/internal/incident_correlator.py:1601`) still computes idle with the **signed** `_incident_idle_minutes`. Commit `33a247d` moved every correlation *gate* onto `_idle_gate_minutes` (`:478`), which is distance-based and cannot go negative. `_call_fits_incident` was missed. ## Why the sign matters `recorrelation_sweep.py:104` anchors `now` to the **call's own `started_at`**, not wall clock. A back-dated call therefore sees `idle_min < 0`, and every comparison of the form `idle_min >= N` is silently false: - content-divergence veto at `:1620` — skipped - content-divergence veto at `:1632` (`idle_min >= 15`) — skipped - tactical link at `:1719` (`idle_min < 20.0`) — **always true** ## Failure scenario An orphaned call from 02:46 is swept against an incident last updated 02:50. Unit overlap fires. With no location proxy and both divergence vetoes bypassed, the call links unconditionally — one incident absorbing unrelated traffic on the same talkgroup. This is the over-merge mechanism, reachable on precisely the one code path that can produce a negative idle. ## Why this is not #22 Closed #22 added the fit check itself. This is a sign bug *inside* that check, introduced later by the `33a247d` gate refactor, and it defeats the check on the sweep path only. ## Test gap `test_idle_gate_uses_distance_not_sign` covers the **gates**. `_call_fits_incident` has no equivalent coverage. A regression test should sweep a back-dated call against a newer incident and assert the divergence vetoes still fire. ## Note on shipping this `incident_correlator.py` is one of the four files board minutes #62 decision 9 froze for autonomous push to `main` until the deploy rollback (#65) lands. Fix on a branch, or wait for #65. Refs #22, #27, #65, #62.
Author
Owner

Still open — confirmed half-fixed by the standing correlation pass, unattended run 2026-08-27. Do not close.

_call_fits_incident (incident_correlator.py:1601) still reads the signed _incident_idle_minutes rather than the _idle_gate_minutes helper written for exactly this case (:478-493).

Every gate inside that function consumes the signed value:

  • the two idle_min >= 15 content-divergence vetoes (:1622, :1633)
  • idle_min < 20.0 -> tactical_default (:1720)

On the re-correlation sweep, now is anchored to the call's own started_at, so idle_min goes negative. Both divergence vetoes are therefore skipped, unit_overlap returns True unconditionally, and tactical channels link by default.

Direction: this pushes over-merging, and it is unintended. It is the sweep path specifically — ingest is unaffected because now is real there.

Not fixed this run; the run's engineering item was the AI-flag enforcement pair (#75/#76) and this is a separate change to the merge gates, which is not something to land untested alongside unrelated work.

**Still open — confirmed half-fixed by the standing correlation pass, unattended run 2026-08-27. Do not close.** `_call_fits_incident` (`incident_correlator.py:1601`) still reads the **signed** `_incident_idle_minutes` rather than the `_idle_gate_minutes` helper written for exactly this case (`:478-493`). Every gate inside that function consumes the signed value: - the two `idle_min >= 15` content-divergence vetoes (`:1622`, `:1633`) - `idle_min < 20.0 -> tactical_default` (`:1720`) On the re-correlation sweep, `now` is anchored to the call's own `started_at`, so `idle_min` goes **negative**. Both divergence vetoes are therefore skipped, `unit_overlap` returns True unconditionally, and tactical channels link by default. **Direction: this pushes over-merging, and it is unintended.** It is the sweep path specifically — ingest is unaffected because `now` is real there. Not fixed this run; the run's engineering item was the AI-flag enforcement pair (#75/#76) and this is a separate change to the merge gates, which is not something to land untested alongside unrelated work.
Author
Owner

Fixed, verified and live at e30d594. Closing.

_call_fits_incident now measures incident idle with the unsigned gate helper, matching every other recency gate in the file. Commit e30d594.

Confirmed still-broken at HEAD by the standing correlation pass before the fix, and the failure mode was worse than the title suggests: on the sweep path now is the call's own started_at, so the signed value went negative, idle_min >= 15 was false, and the content-divergence veto never ran at all — unit overlap was accepted unconditionally. On a shared dispatch backbone that veto is the only thing bounding the feedback loop that lets one incident absorb a whole talkgroup. The same negative value made idle_min < 20.0 true at any back-dating, so a tactical channel returned tactical_default for every swept orphan out to the 90-minute bound.

One variable feeds all four gates in the function, so the correction is a single line at the source rather than four separate comparisons.

Two tests added beside the existing idle-gate cases, both confirmed failing on the old line and passing on the new one: a back-dated call no longer bypasses the divergence veto, and a back-dated call on a tactical channel no longer gets the default. Suite: 266 pass, 0 fail.

Direction: toward more splitting, on the sweep path only. That is the intent — the bug was suppressing an over-merge veto — but it is worth naming, because it compounds the under-linking in #27: an orphan whose incident moved on 15-90 minutes after the call ended will now be back-linked less often. #27's retry budget (wall-clock rather than 3 attempts) is the natural follow-on to this, not a separate concern.

Post-deploy watch: 12 minutes of c2-core logs after the deploy, zero exceptions, zero errors. No revert.

Refs #5, #27, #80.

**Fixed, verified and live at `e30d594`. Closing.** `_call_fits_incident` now measures incident idle with the unsigned gate helper, matching every other recency gate in the file. Commit `e30d594`. Confirmed still-broken at HEAD by the standing correlation pass before the fix, and the failure mode was worse than the title suggests: on the sweep path `now` is the call's own `started_at`, so the signed value went negative, `idle_min >= 15` was false, and **the content-divergence veto never ran at all** — unit overlap was accepted unconditionally. On a shared dispatch backbone that veto is the only thing bounding the feedback loop that lets one incident absorb a whole talkgroup. The same negative value made `idle_min < 20.0` true at any back-dating, so a tactical channel returned `tactical_default` for every swept orphan out to the 90-minute bound. One variable feeds all four gates in the function, so the correction is a single line at the source rather than four separate comparisons. Two tests added beside the existing idle-gate cases, both confirmed failing on the old line and passing on the new one: a back-dated call no longer bypasses the divergence veto, and a back-dated call on a tactical channel no longer gets the default. Suite: **266 pass, 0 fail**. **Direction: toward more splitting, on the sweep path only.** That is the intent — the bug was suppressing an over-merge veto — but it is worth naming, because it compounds the under-linking in **#27**: an orphan whose incident moved on 15-90 minutes after the call ended will now be back-linked less often. #27's retry budget (wall-clock rather than 3 attempts) is the natural follow-on to this, not a separate concern. Post-deploy watch: 12 minutes of `c2-core` logs after the deploy, zero exceptions, zero errors. No revert. Refs #5, #27, #80.
logan closed this issue 2026-08-28 03:10:09 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#74