From e30d594eeab08a7d23303e39f40305bbc4cd2fd9 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Fri, 28 Aug 2026 02:51:07 -0400 Subject: [PATCH] Stop a back-dated call from silently disabling every recency gate (server-26#74) _call_fits_incident measured incident idle with the signed helper while every other recency gate in the file uses the unsigned one. On the re-correlation sweep, `now` is the call's own started_at, which can precede the incident's last activity, so the value went negative. Negative idle made `idle_min >= 15` false, which meant the content-divergence veto never ran and unit overlap was accepted unconditionally -- on a shared dispatch backbone that is the feedback loop that lets one incident absorb a whole talkgroup. It also 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 this is a one-line change at the source. The signed value is untouched where it belongs: callers still compute corr_incident_idle_min themselves, so debug output keeps its meaning. Direction is toward more splitting, on the sweep path only, which is the point -- the bug was suppressing an over-merge veto. Forward-dated calls and anything inside the thresholds behave exactly as before. Two tests added alongside the existing idle-gate cases; both fail on the old line and pass on the new one. 266 pass, 0 fail. Refs server-26#74, #5, #80. Co-Authored-By: Claude Opus 5 --- .../app/internal/incident_correlator.py | 10 +++- .../tests/test_correlator_merge_caps.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index c8c779d..00e5284 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -1598,7 +1598,15 @@ def _call_fits_incident( Thin calls (no units/vehicles/coords) never reach this function — they are intercepted before it in correlate_call. """ - idle_min = _incident_idle_minutes(inc, now) if now is not None else 9999.0 + # Gate comparisons in this function must use the unsigned distance, not the + # signed value: the re-correlation sweep anchors `now` to the call's own + # started_at, which can be earlier than the incident's last activity and + # send the signed value negative — silently defeating every `idle_min` + # gate below (content-divergence veto and the tactical default alike). + # See `_idle_gate_minutes` docstring. The signed value is reported to + # callers separately as `corr_incident_idle_min` (they compute it via + # `_incident_idle_minutes` themselves) — nothing here needs it. + idle_min = _idle_gate_minutes(inc, now) if now is not None else 9999.0 inc_id = inc.get("incident_id", "?") # ── 1. Unit overlap ─────────────────────────────────────────────────────── diff --git a/drb-c2-core/tests/test_correlator_merge_caps.py b/drb-c2-core/tests/test_correlator_merge_caps.py index c0beadc..f3b80c7 100644 --- a/drb-c2-core/tests/test_correlator_merge_caps.py +++ b/drb-c2-core/tests/test_correlator_merge_caps.py @@ -23,9 +23,10 @@ Three defects combined to produce it, and each has cases below: import pytest from datetime import datetime, timedelta, timezone from app.config import settings +import app.internal.incident_correlator as correlator_mod from app.internal.incident_correlator import ( _run_decision, _is_thin_call, _idle_gate_minutes, - _incident_at_capacity, _incident_span_minutes, + _incident_at_capacity, _incident_span_minutes, _call_fits_incident, ) NOW = datetime(2026, 8, 20, 7, 0, 0, tzinfo=timezone.utc) @@ -221,6 +222,49 @@ def test_back_dated_thin_call_does_not_sail_through_the_recency_gate(): assert _run_decision(_ctx(all_active=[future], recent=[future]))["action"] == "orphan" +def test_back_dated_call_does_not_bypass_the_content_divergence_veto(monkeypatch): + """ + Same `9d376ffe` failure mode, but exercised directly against + `_call_fits_incident` on a dispatch channel: unit overlap plus a + back-dated call (incident updated 45 minutes AFTER the call's own + `started_at`, which the sweep passes as `now`) used to make the signed + idle -45, so `idle_min >= 15` read False and the content-divergence + veto never ran — unit overlap alone forced the merge regardless of + what the call was actually about. With the gate fixed to compare + distance, idle_min is 45 (>= 15), the veto runs, and a divergent + embedding (patched below so the assertion doesn't depend on numpy + being installed in this environment) fails it. + """ + monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0) + inc = _incident(idle_minutes=-45, units=["6-Adam"]) + inc["embedding"] = [1.0, 0.0] + fits, signal = _call_fits_incident( + inc, call_units=["6-Adam"], call_vehicles=[], call_coords=None, + proximity_km=settings.location_proximity_km, is_dispatch=True, + call_embedding=[0.0, 1.0], now=NOW, + ) + assert (fits, signal) == (False, "content_divergence") + + +def test_back_dated_call_on_tactical_channel_does_not_get_the_default(): + """ + Tactical-channel counterpart: no unit/vehicle/location signal, so the + function falls through to step 4's `idle_min < 20.0` default. A + back-dated call (incident updated 45 minutes after the call's own + started_at) used to read idle_min as -45, which is always < 20.0, so + `tactical_default` fired unconditionally no matter how stale the + incident actually was relative to this call. Fixed, idle_min is the + 45-minute distance, which is not < 20.0. + """ + inc = _incident(idle_minutes=-45) + fits, signal = _call_fits_incident( + inc, call_units=[], call_vehicles=[], call_coords=None, + proximity_km=settings.location_proximity_km, is_dispatch=False, + call_embedding=None, now=NOW, + ) + assert (fits, signal) == (False, "tactical_idle") + + # --------------------------------------------------------------------------- # 4. Hard caps — path-independent, because pairwise fit tests can't see shape # ---------------------------------------------------------------------------