Merge pull request 'correlator: drop the dispatch/tactical name-guess from the escape hatch (#115)' (#133) from fix/115-drop-dispatch-tactical-split into main
This commit was merged in pull request #133.
This commit is contained in:
@@ -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
|
case: the ack carries no substance of its own but plainly belongs to the
|
||||||
job just opened.
|
job just opened.
|
||||||
|
|
||||||
The window mirrors whatever the fast/thin path would use for this same
|
Always uses `settings.tg_dispatch_thin_idle_minutes` (5 min), regardless
|
||||||
channel — `tg_dispatch_thin_idle_minutes` (5 min) on a dispatch backbone,
|
of what the talkgroup is named. An earlier version of this branched on
|
||||||
`tg_thin_idle_minutes` (15 min) on a tactical/working channel, selected via
|
`_is_dispatch_channel` (mirroring incident_correlator.py's fast/thin idle-
|
||||||
the same `_is_dispatch_channel` test incident_correlator.py uses at its own
|
window selection) to use a longer 15-minute window on anything not
|
||||||
fast/thin idle-window selection (~:1005-1007). Using the dispatch constant
|
literally named "dispatch"/"patched"/"primary" — owner correction,
|
||||||
unconditionally would be wrong off dispatch — a retune of one for fast/thin
|
2026-09-13, from direct scanning experience: a talkgroup named "tac"/
|
||||||
reasons would then silently widen or narrow this gate too, on channels
|
"tactical" genuinely does see materially different traffic only during a
|
||||||
window #3 never measured.
|
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
|
This used to be a plain "does any recent incident exist on this
|
||||||
talkgroup" check against a 2-hour window (`correlation_window_hours`).
|
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
|
# status, no capacity filter) if a future measurement window pins a real
|
||||||
# gate miss on a resolved/capped same-talkgroup incident.
|
# 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")
|
tg_id = ctx.get("talkgroup_id")
|
||||||
system_id = ctx.get("system_id")
|
system_id = ctx.get("system_id")
|
||||||
@@ -168,11 +179,7 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
tg_str = str(tg_id)
|
tg_str = str(tg_id)
|
||||||
now = ctx.get("now") or datetime.now(timezone.utc)
|
now = ctx.get("now") or datetime.now(timezone.utc)
|
||||||
idle_limit = (
|
idle_limit = settings.tg_dispatch_thin_idle_minutes
|
||||||
settings.tg_dispatch_thin_idle_minutes
|
|
||||||
if _is_dispatch_channel(ctx.get("talkgroup_name"))
|
|
||||||
else settings.tg_thin_idle_minutes
|
|
||||||
)
|
|
||||||
for inc in ctx.get("recent") or []:
|
for inc in ctx.get("recent") or []:
|
||||||
if system_id not in (inc.get("system_ids") or []):
|
if system_id not in (inc.get("system_ids") or []):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
|
# 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
|
# satisfied almost unconditionally — the gate fired 0/24 times against its own
|
||||||
# target shape. It now only counts an incident as recent within
|
# target shape. It now only counts an incident as recent within
|
||||||
# settings.tg_dispatch_thin_idle_minutes (5 min) on a dispatch channel, or
|
# settings.tg_dispatch_thin_idle_minutes (5 min), applied uniformly regardless
|
||||||
# tg_thin_idle_minutes (15 min) on a tactical channel — the same split
|
# of the talkgroup's name (owner correction, 2026-09-13 — see
|
||||||
# incident_correlator's own fast/thin path uses, selected by the same
|
# test_channel_name_does_not_affect_the_window below for why the dichotomy
|
||||||
# _is_dispatch_channel test, so a retune of one for fast/thin reasons doesn't
|
# this originally had with incident_correlator's fast/thin idle selection was
|
||||||
# silently move this escape hatch on channels never re-measured for it.
|
# removed here).
|
||||||
|
|
||||||
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
|
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
|
||||||
ctx = {
|
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():
|
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
|
# Regression test for the fix: 8 minutes is past the 5-minute bound but
|
||||||
# bound but still inside the 15-minute TACTICAL bound and the OLD 2-hour
|
# still inside the OLD 2-hour correlation_window_hours lookback. Before
|
||||||
# correlation_window_hours lookback — this specifically proves the
|
# the fix this escaped the gate on any channel; after the fix it gates.
|
||||||
# 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).
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"system_id": "sys-1",
|
"system_id": "sys-1",
|
||||||
"talkgroup_id": 9048,
|
"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"
|
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
async def test_tactical_channel_uses_the_longer_window():
|
async def test_channel_name_does_not_affect_the_window():
|
||||||
# Same 8-minute age as the dispatch test above, but on a channel name that
|
# Owner correction, 2026-09-13 (direct scanning experience): a talkgroup
|
||||||
# does not match _DISPATCH_TG_RE — this must fall back to the 15-minute
|
# named "tac"/"tactical" only sees materially different traffic during a
|
||||||
# tg_thin_idle_minutes bound, same as incident_correlator's own fast/thin
|
# real incident, and that's rare -- the bulk of traffic on any monitored
|
||||||
# selection, and 8 min is still "recent" under that bound.
|
# 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 = {
|
ctx = {
|
||||||
"system_id": "sys-1",
|
"system_id": "sys-1",
|
||||||
"talkgroup_id": 383,
|
"talkgroup_id": 383,
|
||||||
@@ -249,7 +253,8 @@ async def test_tactical_channel_uses_the_longer_window():
|
|||||||
m_apply, m_tiebreak = await _run_consensus(
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
_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():
|
async def test_gate_veto_reason_is_recorded_on_the_escalation_path():
|
||||||
|
|||||||
Reference in New Issue
Block a user