From 0473e6a5833169b49e84cf8d18b32ed9661bfbb3 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 13 Sep 2026 14:38:57 -0400 Subject: [PATCH] correlator: always run the dispatch-strict fit test, not name-guessed (#134) is_dispatch was computed from _is_dispatch_channel(talkgroup_name) and picked between two _call_fits_incident evaluation orders: dispatch (requires a positive signal, runs location-conflict/content-divergence vetoes on unit overlap) vs tactical (skips both vetoes, defaults to True on no signal at all within 20 min). Per #133's reasoning, a name not literally containing dispatch/patched/primary got the unvetoed, default-True path solely because of its label. Hardcoded is_dispatch=True at its one real call site; the tactical branch and its own tests stay in place, unreached. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix --- drb-c2-core/app/config.py | 10 ++++++---- drb-c2-core/app/internal/incident_correlator.py | 6 +++++- drb-c2-core/tests/test_correlator_merge_caps.py | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index fc5ba0e..d0c69de 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -97,11 +97,13 @@ class Settings(BaseSettings): # Across that dump every correct thin attach was <= 3.4 min idle and every wrong # one was >= 8.2, so 5 separates them with room on both sides. Genuine # back-and-forth is handled by the 30-second tier-1 path above this. - # Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate escape - # hatch (_recent_incident_on_same_talkgroup) reuses this same value, selected - # the same way (dispatch vs tactical) via _is_dispatch_channel. Retuning this + # Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate + # escape hatch (_recent_incident_on_same_talkgroup) always uses this same + # value now — no dispatch/tactical branch there since #133. Retuning this # for fast/thin reasons moves that gate's behavior too — check both call - # sites before changing it. + # sites before changing it. (tg_thin_idle_minutes below is now unused in + # production — is_dispatch is hardcoded True at its only real call site, + # incident_correlator.py's fast-path — see server-26#134.) tg_dispatch_thin_idle_minutes: int = 5 # Every other channel: tier-2 thin calls attach to a lone candidate idle < this. # Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 3ea816a..4801d7d 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -976,7 +976,11 @@ def _run_decision(ctx: dict) -> dict: # directly on the Firestore call doc). Fall back to the call doc so that # dispatch-channel strictness works regardless of how the call arrived. effective_talkgroup_name = talkgroup_name or call_doc.get("talkgroup_name") - is_dispatch = _is_dispatch_channel(effective_talkgroup_name) + # server-26#134: always dispatch-strict now, not name-guessed — a + # channel labeled "tac"/"tactical" is rare and no less scrutinized in + # practice than any other. _call_fits_incident's tactical branch is + # kept, unreached, in case that's ever wrong. + is_dispatch = True if effective_talkgroup_name != talkgroup_name: logger.info( f"Correlator: talkgroup_name missing from request for call {call_id}, " diff --git a/drb-c2-core/tests/test_correlator_merge_caps.py b/drb-c2-core/tests/test_correlator_merge_caps.py index f3b80c7..c59c3ad 100644 --- a/drb-c2-core/tests/test_correlator_merge_caps.py +++ b/drb-c2-core/tests/test_correlator_merge_caps.py @@ -153,12 +153,22 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel(): assert decision["action"] == "orphan" -def test_tactical_thin_call_still_attaches_inside_its_own_window(): - """Bounded, not removed — a "10-4" on a working channel is still context.""" +def test_tactical_named_channel_uses_the_dispatch_window_now(): + """server-26#134: is_dispatch is hardcoded True — a channel's name no + longer changes the thin window. 14 min (the old tactical bound minus 1, + inside the old 15-min window) is now past the 5-min dispatch window.""" inc = _incident(idle_minutes=settings.tg_thin_idle_minutes - 1) decision = _run_decision(_ctx( all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG, )) + assert decision["action"] == "orphan" + + +def test_tactical_named_channel_still_attaches_inside_the_dispatch_window(): + inc = _incident(idle_minutes=settings.tg_dispatch_thin_idle_minutes - 1) + decision = _run_decision(_ctx( + all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG, + )) assert decision["action"] == "link" assert decision["corr_debug"]["corr_path"] == "fast/thin"