diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index fc183b3..2b02657 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -118,14 +118,25 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool: case: the ack carries no substance of its own but plainly belongs to the job just opened. - The window mirrors whatever the fast/thin path would use for this same - channel — `tg_dispatch_thin_idle_minutes` (5 min) on a dispatch backbone, - `tg_thin_idle_minutes` (15 min) on a tactical/working channel, selected via - the same `_is_dispatch_channel` test incident_correlator.py uses at its own - fast/thin idle-window selection (~:1005-1007). Using the dispatch constant - unconditionally would be wrong off dispatch — a retune of one for fast/thin - reasons would then silently widen or narrow this gate too, on channels - window #3 never measured. + Always uses `settings.tg_dispatch_thin_idle_minutes` (5 min), regardless + of what the talkgroup is named. An earlier version of this branched on + `_is_dispatch_channel` (mirroring incident_correlator.py's fast/thin idle- + window selection) to use a longer 15-minute window on anything not + literally named "dispatch"/"patched"/"primary" — owner correction, + 2026-09-13, from direct scanning experience: a talkgroup named "tac"/ + "tactical" genuinely does see materially different traffic only during a + real incident, and that's rare; the overwhelming majority 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 is a naming-convention guess, not a detector of actual + channel behavior, and trusting it here meant a busy single-channel + department not literally named "dispatch" would silently get the more + permissive window and reproduce #115's original bug. One constant, + applied uniformly, is the safer default; a real low-volume channel is the + rare case, not the norm, so erring toward the tighter window costs little. + NOT applied to incident_correlator.py's own fast/thin selection (out of + scope for this pass — a bigger, decision-changing surface, worth its own + review rather than changing under this fix). This used to be a plain "does any recent incident exist on this talkgroup" check against a 2-hour window (`correlation_window_hours`). @@ -160,7 +171,7 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool: # status, no capacity filter) if a future measurement window pins a real # gate miss on a resolved/capped same-talkgroup incident. """ - from app.internal.incident_correlator import _idle_gate_minutes, _is_dispatch_channel + from app.internal.incident_correlator import _idle_gate_minutes tg_id = ctx.get("talkgroup_id") system_id = ctx.get("system_id") @@ -168,11 +179,7 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool: return False tg_str = str(tg_id) now = ctx.get("now") or datetime.now(timezone.utc) - idle_limit = ( - settings.tg_dispatch_thin_idle_minutes - if _is_dispatch_channel(ctx.get("talkgroup_name")) - else settings.tg_thin_idle_minutes - ) + idle_limit = settings.tg_dispatch_thin_idle_minutes for inc in ctx.get("recent") or []: if system_id not in (inc.get("system_ids") or []): continue diff --git a/drb-c2-core/tests/test_consensus_gate.py b/drb-c2-core/tests/test_consensus_gate.py index 7227d85..1eeab8e 100644 --- a/drb-c2-core/tests/test_consensus_gate.py +++ b/drb-c2-core/tests/test_consensus_gate.py @@ -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():