correlator: drop the dispatch/tactical name-guess from the escape hatch, use one window (#115)

Owner correction from direct scanning experience: a talkgroup named tac/tactical only sees materially different traffic during a real incident, and that's rare -- the bulk of traffic on any monitored channel, including high-risk stops and pursuits, runs on the main channel regardless of what it's named. _is_dispatch_channel's string match on the talkgroup name is a naming-convention guess, not a detector of actual channel behavior; trusting it here meant a busy single-channel department not literally named 'dispatch' would silently get the more permissive 15-minute window and could reproduce #115's original bug (the gate never firing on the channels it targets).

Always use tg_dispatch_thin_idle_minutes (5 min) in the escape hatch, regardless of talkgroup name. Does NOT touch incident_correlator.py's own fast/thin idle-window selection, which uses the same dichotomy for a different, decision-changing purpose -- bigger blast radius, left for its own review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
This commit is contained in:
Logan Cusano
2026-09-13 14:07:00 -04:00
co-authored by Claude Sonnet 5
parent 833cfade4e
commit c50bfda8db
2 changed files with 44 additions and 32 deletions
+23 -18
View File
@@ -176,11 +176,11 @@ async def test_recent_incident_on_same_talkgroup_is_not_gated():
# as "recent", which on a busy dispatch channel (3-13 incidents/2h) was
# satisfied almost unconditionally — the gate fired 0/24 times against its own
# target shape. It now only counts an incident as recent within
# settings.tg_dispatch_thin_idle_minutes (5 min) on a dispatch channel, or
# tg_thin_idle_minutes (15 min) on a tactical channel — the same split
# incident_correlator's own fast/thin path uses, selected by the same
# _is_dispatch_channel test, so a retune of one for fast/thin reasons doesn't
# silently move this escape hatch on channels never re-measured for it.
# settings.tg_dispatch_thin_idle_minutes (5 min), applied uniformly regardless
# of the talkgroup's name (owner correction, 2026-09-13 — see
# test_channel_name_does_not_affect_the_window below for why the dichotomy
# this originally had with incident_correlator's fast/thin idle selection was
# removed here).
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
ctx = {
@@ -203,13 +203,9 @@ async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gat
async def test_recent_same_tg_incident_older_than_short_window_now_gates():
# Regression test for the fix: 8 minutes is past the 5-minute DISPATCH
# bound but still inside the 15-minute TACTICAL bound and the OLD 2-hour
# correlation_window_hours lookback — this specifically proves the
# dispatch-channel number is being used here, not just "some window
# shorter than 2h". Before the fix this escaped the gate on any channel;
# after the fix a dispatch channel gates at this age (a tactical channel
# would not — see test_tactical_channel_uses_the_longer_window below).
# Regression test for the fix: 8 minutes is past the 5-minute bound but
# still inside the OLD 2-hour correlation_window_hours lookback. Before
# the fix this escaped the gate on any channel; after the fix it gates.
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
@@ -229,11 +225,19 @@ async def test_recent_same_tg_incident_older_than_short_window_now_gates():
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_tactical_channel_uses_the_longer_window():
# Same 8-minute age as the dispatch test above, but on a channel name that
# does not match _DISPATCH_TG_RE — this must fall back to the 15-minute
# tg_thin_idle_minutes bound, same as incident_correlator's own fast/thin
# selection, and 8 min is still "recent" under that bound.
async def test_channel_name_does_not_affect_the_window():
# Owner correction, 2026-09-13 (direct scanning experience): a talkgroup
# named "tac"/"tactical" only sees materially different traffic during a
# real incident, and that's rare -- the bulk of traffic on any monitored
# channel, including high-risk stops and pursuits, runs on the main
# channel regardless of what it's named. An earlier version of this used
# a longer 15-minute window on anything not literally named "dispatch"/
# "patched"/"primary" (mirroring incident_correlator's fast/thin idle
# selection); that meant a busy single-channel department not literally
# named "dispatch" silently got the more permissive window and could
# reproduce #115's original bug. Same 8-minute age as the dispatch-named
# test above, but on a channel named "Tac 3" -- must gate identically,
# not escape into a longer window just because of the name.
ctx = {
"system_id": "sys-1",
"talkgroup_id": 383,
@@ -249,7 +253,8 @@ async def test_tactical_channel_uses_the_longer_window():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_gate_veto_reason_is_recorded_on_the_escalation_path():