correlator: mirror the dispatch/tactical idle split in the escape hatch, record the gate-veto reason (#115)

Review of #126 found: (1) the escape hatch applied tg_dispatch_thin_idle_minutes (5 min) unconditionally, but incident_correlator's own fast/thin path only uses that on dispatch channels and 15 min on tactical ones via _is_dispatch_channel -- mirrored the same selection here, plus a config.py note flagging the second consumer. (2) the docstring claimed a 'confirmed explanation' for 2 window-3 gate misses that was actually wrong (self-contradictory in its own text); replaced the guess with corr_gate_veto, written into corr_debug on every llm=orphan/rules=new disagreement that escalates, so window #4 can see *why* each one escaped instead of reconstructing it from the raw dump.

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-12 04:42:14 -04:00
co-authored by Claude Sonnet 5
parent 400b74b519
commit 598054746a
3 changed files with 124 additions and 40 deletions
+54 -32
View File
@@ -118,11 +118,14 @@ 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.
That window intentionally reuses `tg_dispatch_thin_idle_minutes` (5 min)
rather than inventing a new constant — it's the same recency bound the
fast/thin path already uses for this exact "dispatch, thin ack" scenario
(see its tuning note above in config.py), so both places agree on what
"just happened on this channel" means.
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.
This used to be a plain "does any recent incident exist on this
talkgroup" check against a 2-hour window (`correlation_window_hours`).
@@ -143,22 +146,20 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
`_drop_capped` — not a full scan of the `incidents` collection. A
same-talkgroup incident that has already auto-resolved (no longer
"active") or hit `incident_max_calls`/`incident_max_duration_minutes`
will NOT appear here even though it is chronologically recent. This is
the confirmed explanation for 2/24 gate misses in the window #3
measurement where a naive full-collection timestamp scan found no
same-talkgroup candidate either once status/capacity are accounted for
— i.e. the check here was already correct for those two calls; some
other `_call_is_substanceless` condition (severity/substance/type) must
have been true instead. A proper fix for the truncation case (an
incident that WAS same-talkgroup-recent by clock time but is invisible
here because it resolved or capped) needs a dedicated Firestore query
that is not status/capacity filtered — a new read, out of scope for
this pass.
will NOT appear here even though it is chronologically recent. A proper
fix needs a dedicated Firestore query that is not status/capacity
filtered — a new read, out of scope for this pass.
This does NOT explain the 2/24 unexplained gate misses in the window #3
measurement — re-review found a code-level explanation for both instead
(see `_call_is_substanceless`'s `incident_type`/`reassignment` branch and
the scene-level severity/tags fields the call-doc alone doesn't show), so
this limitation is believed inactive so far, not a live loose end.
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
# status, no capacity filter) if this escape hatch ever needs to see
# resolved/capped incidents rather than just the active candidate pool.
# 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
from app.internal.incident_correlator import _idle_gate_minutes, _is_dispatch_channel
tg_id = ctx.get("talkgroup_id")
system_id = ctx.get("system_id")
@@ -166,24 +167,39 @@ 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
)
for inc in ctx.get("recent") or []:
if system_id not in (inc.get("system_ids") or []):
continue
if tg_str not in (inc.get("talkgroup_ids") or []):
continue
if _idle_gate_minutes(inc, now) <= settings.tg_dispatch_thin_idle_minutes:
if _idle_gate_minutes(inc, now) <= idle_limit:
return True
return False
def _call_is_substanceless(ctx: dict) -> bool:
def _call_is_substanceless(ctx: dict) -> tuple[bool, Optional[str]]:
"""
True when the call carries nothing that marks it as a real event:
• no resolved incident_type and not a reassignment, AND
• severity is not moderate/major, AND
• no vehicle, geocode or tag (incident_correlator.has_event_substance —
the same predicate the incident-creation gate uses), AND
• no recent incident already running on the same talkgroup.
Only then may the LLM-orphan gate drop the call without a tiebreak.
Returns (substanceless, veto_reason). veto_reason names whichever
condition kept the tiebreak alive ("type" | "reassignment" | "severity" |
"substance" | "recent_tg"), or None when the call is substanceless. The
caller writes this into corr_debug on the escalation path so a live
measurement window can see *why* each llm=orphan/rules=new call escaped
the gate instead of inferring it after the fact from the raw dump —
exactly the guesswork that produced a wrong "confirmed explanation" for
2 window-#3 misses on the first pass of this fix.
"""
from app.internal import incident_correlator
@@ -193,15 +209,17 @@ def _call_is_substanceless(ctx: dict) -> bool:
# re-check here. reassignment=True is dispatch pulling a unit onto a NEW
# job (units are blanked at :296 for exactly that reason): the strongest
# new-incident signal in the pipeline. Either one means "keep the tiebreak".
if ctx.get("incident_type") or ctx.get("reassignment"):
return False
if ctx.get("incident_type"):
return False, "type"
if ctx.get("reassignment"):
return False, "reassignment"
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
return False
return False, "severity"
if incident_correlator.has_event_substance(ctx):
return False
return False, "substance"
if _recent_incident_on_same_talkgroup(ctx):
return False
return True
return False, "recent_tg"
return True, None
async def _correlate_with_consensus(
@@ -265,11 +283,9 @@ async def _correlate_with_consensus(
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
# signal (severity, coords, tags, a live same-talkgroup incident) still
# escalates, so an event the LLM misreads as orphan is not lost.
if (
llm_decision["action"] == "orphan"
and rules_decision["action"] == "new"
and _call_is_substanceless(ctx)
):
is_orphan_vs_new = llm_decision["action"] == "orphan" and rules_decision["action"] == "new"
substanceless, gate_veto_reason = _call_is_substanceless(ctx) if is_orphan_vs_new else (False, None)
if is_orphan_vs_new and substanceless:
logger.info(
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
f"is substanceless — resolving orphan, skipping tiebreak"
@@ -297,6 +313,12 @@ async def _correlate_with_consensus(
final["corr_debug"]["corr_consensus"] = "tiebreak"
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
if is_orphan_vs_new:
# server-26#115 — record *why* the llm=orphan/rules=new gate stood
# down instead of leaving a future measurement window to guess it
# from the raw dump (which produced a wrong "confirmed explanation"
# for 2/24 misses the first time around).
final["corr_debug"]["corr_gate_veto"] = gate_veto_reason
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})