From 0473e6a5833169b49e84cf8d18b32ed9661bfbb3 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 13 Sep 2026 14:38:57 -0400 Subject: [PATCH 1/3] 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" -- 2.54.0 From b1884852d5d5f012e558e88ddb0720edb2d35fcb Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 13 Sep 2026 14:49:25 -0400 Subject: [PATCH 2/3] correlator: remove is_dispatch and the tactical fit path entirely (#134) Full removal, not a hardcoded flag: _is_dispatch_channel, _DISPATCH_TG_RE, the is_dispatch parameter, and _call_fits_incident's tactical branch are gone. One evaluation path for every channel. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix --- .gitignore | 1 + drb-c2-core/app/config.py | 36 ++-- .../app/internal/incident_correlator.py | 187 ++++++------------ drb-c2-core/app/routers/upload.py | 58 +----- .../tests/test_correlator_merge_caps.py | 62 ++---- 5 files changed, 106 insertions(+), 238 deletions(-) diff --git a/.gitignore b/.gitignore index 94cd450..9613b16 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ Thumbs.db # Out of scope - not a deployed service (server-26#56) drb-telegram-bot/ +.claude/worktrees/ diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index d0c69de..a9baa2a 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -90,31 +90,19 @@ class Settings(BaseSettings): unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated - # Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this. - # Was 10, which is long enough for the channel to have moved on to something else: - # on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train - # meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min. - # 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) 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. (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.) + # Tier-2 thin calls attach to a lone candidate idle < this, on every + # channel (server-26#133/#134 removed the dispatch/tactical split — a + # channel's name doesn't change how much scrutiny it gets). Was 10, which + # is long enough for the channel to have moved on to something else: on + # 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central + # train meet 9.6 min later, and a status check absorbed a records lookup + # at 9.7 min. Every correct thin attach in that dump 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. Also the escape hatch in routers/upload.py's LLM-orphan gate + # (_recent_incident_on_same_talkgroup, server-26#115) — check both call + # sites before retuning this. 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 - # whole 90-minute tg_fast_path_idle_minutes window with no single-candidate - # requirement and no fit test, which is the widest version of the 2026-08-20 - # over-merge. A tactical channel really is dedicated to one scene, so it earns - # a longer window than a dispatch backbone, but not an unbounded one: 15 sits - # inside the 20-minute tactical-default window in _call_fits_incident, so the - # no-evidence thin path is never more permissive than the fit-tested path on - # the same channel. - tg_thin_idle_minutes: int = 15 # ── Hard caps: an incident past either of these stops accepting calls ────── # Enforced on every correlation path (see _incident_at_capacity). Pairwise fit diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 4801d7d..2c908b9 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -98,13 +98,6 @@ _PURSUIT_PROXIMITY_KM = 20.0 # expanded radius for moving incidents # overlap with the candidate OR a distance under this tighter bar. _LOCATION_TIGHT_PROXIMITY_KM = 0.2 -_DISPATCH_TG_RE = re.compile( - r"\bdispatch\b|\bdisp\b" - r"|\bpatched\b" # patched channels aggregate multiple call streams - r"|\bprimary\b", # "Primary" channels serve as shared backbones - re.IGNORECASE, -) - # Matches route/road identifiers in location strings for cross-system parent detection. # Groups: numbered routes (Route 202, NY-9, US-6, I-87, CR-35) and named parkways/highways. _ROAD_RE = re.compile( @@ -494,13 +487,6 @@ def _resolve_incident_title( return {} -def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool: - """True when the talkgroup is a shared dispatch backbone (not a tactical/working channel).""" - if not talkgroup_name: - return False - return bool(_DISPATCH_TG_RE.search(talkgroup_name)) - - def _incident_idle_minutes(inc: dict, now: datetime) -> float: """Minutes since the incident was last updated (or started). Returns 9999 on parse error.""" try: @@ -976,15 +962,10 @@ 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") - # 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}, " - f"resolved from call doc: {effective_talkgroup_name!r} → is_dispatch={is_dispatch}" + f"resolved from call doc: {effective_talkgroup_name!r}" ) tg_matches = [ @@ -1028,10 +1009,7 @@ def _run_decision(ctx: dict) -> dict: # single-candidate requirement and no fit test of any kind. Four # hours is not a bound, and neither is ninety minutes. THIN_CONVERSATIONAL_SECS = 30 - thin_window_min = ( - settings.tg_dispatch_thin_idle_minutes if is_dispatch - else settings.tg_thin_idle_minutes - ) + thin_window_min = settings.tg_dispatch_thin_idle_minutes very_recent = [ inc for inc in tg_recent if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS @@ -1053,8 +1031,7 @@ def _run_decision(ctx: dict) -> dict: if len(thin_pool) > 1: logger.info( f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents " - f"on {'dispatch' if is_dispatch else 'tactical'} channel — " - f"ambiguous, skipping thin call {call_id}" + f"— ambiguous, skipping thin call {call_id}" ) thin_pool = [] @@ -1084,14 +1061,14 @@ def _run_decision(ctx: dict) -> dict: candidate = tg_recent[0] logger.info( f"Correlator fast/single: call {call_id} vs incident {candidate['incident_id']} " - f"tg_name={effective_talkgroup_name!r} is_dispatch={is_dispatch} " + f"tg_name={effective_talkgroup_name!r} " f"idle={round(_incident_idle_minutes(candidate, now), 1)}min " f"call_units={call_units} inc_units={candidate.get('units')} " f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}" ) fit, fit_signal = _call_fits_incident( candidate, call_units, call_vehicles, coords, - settings.location_proximity_km, is_dispatch=is_dispatch, + settings.location_proximity_km, call_embedding=call_embedding, now=now, reassignment=reassignment, ) @@ -1101,13 +1078,12 @@ def _run_decision(ctx: dict) -> dict: "corr_path": "fast/single", "corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1), "corr_fit_signal": fit_signal, - "corr_is_dispatch": is_dispatch, } if fit_signal == "unit_overlap" and call_units: corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units")) logger.info( f"Correlator fast-path: call {call_id} → {candidate['incident_id']} " - f"(signal={fit_signal}, is_dispatch={is_dispatch})" + f"(signal={fit_signal})" ) else: logger.info( @@ -1123,14 +1099,14 @@ def _run_decision(ctx: dict) -> dict: # dispatch channel should create its own incident, not be force-merged. logger.info( f"Correlator fast/disambig: call {call_id} vs incident {candidate['incident_id']} " - f"tg_name={effective_talkgroup_name!r} is_dispatch={is_dispatch} " + f"tg_name={effective_talkgroup_name!r} " f"idle={round(_incident_idle_minutes(candidate, now), 1)}min " f"call_units={call_units} inc_units={candidate.get('units')} " f"call_coords={'yes' if coords else 'no'} inc_coords={'yes' if candidate.get('location_coords') else 'no'}" ) fit, fit_signal = _call_fits_incident( candidate, call_units, call_vehicles, coords, - settings.location_proximity_km, is_dispatch=is_dispatch, + settings.location_proximity_km, call_embedding=call_embedding, now=now, reassignment=reassignment, ) @@ -1141,7 +1117,6 @@ def _run_decision(ctx: dict) -> dict: "corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1), "corr_candidates": len(tg_recent), "corr_fit_signal": fit_signal, - "corr_is_dispatch": is_dispatch, } if fit_signal == "unit_overlap" and call_units: corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units")) @@ -1720,7 +1695,6 @@ def _call_fits_incident( call_vehicles: list[str], call_coords: Optional[dict], proximity_km: float, - is_dispatch: bool = False, call_embedding: Optional[list] = None, now: Optional[datetime] = None, reassignment: bool = False, @@ -1730,48 +1704,24 @@ def _call_fits_incident( the incident; signal names the specific evidence that drove the decision. fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity" - | "time_fallback" | "tactical_default" fits=False signals: "unit_loc_conflict" | "content_divergence" - | "location_conflict" | "no_signal" | "tactical_idle" + | "location_conflict" | "no_signal" - Original docstring (logic unchanged): - Return True if this call plausibly belongs to the given incident. - - Evaluation order for dispatch channels (is_dispatch=True): - ───────────────────────────────────────────────────────── - 1. Unit overlap - Same officer = same call. On dispatch channels, also run a location - conflict guard: if both sides carry geocoded coords and they differ - significantly, the officer has moved to a new scene and the unit match - is a false positive. - When the call has NO geocoded coordinates AND the incident is old - (≥ 15 min), use content divergence as a location proxy: an officer at - a genuinely different scene will be talking about clearly different - things. For recent incidents (< 15 min) we skip this proxy — the - officer may simply be giving an update without mentioning the address. - - 2. Vehicle overlap → True - - 3. Location proximity - Both sides geocoded and close → True. - Both sides geocoded and far apart (no other positive signal) → False. - - 4. No positive signals fired → dispatch fallback - a. Conversational continuity: idle < 2 min → True. - A call arriving within 2 minutes of the last incident activity almost - certainly belongs to the same dispatch thread. "Baker, head over - there too" or "copy that" carries no incident-specific vocabulary but - is unambiguously a response to what was just said. We do not require - embedding similarity here — embeddings capture word meaning, not - conversational context, and short operational messages will always - have lower similarity than the incident's accumulated content. - b. Older incident, no positive signals → False. - A shared dispatch channel must not absorb calls by default. - - Tactical / working channel (is_dispatch=False): - ──────────────────────────────────────────────── - Channel is dedicated to one scene. No evidence of separation ≈ same call. - Default → True. + Evaluation order: + 1. Unit overlap. Same officer = same call. Also runs a location-conflict + guard: geocoded on both sides and clearly different → the officer has + moved to a new scene, false positive. No geocode on the call (or on + the incident, with a call geocode) AND the incident is old (≥ 15 min) + → content divergence as a location proxy (embedding similarity < 0.82 + → different scene). Skipped for recent incidents (< 15 min) — an + update without re-stating the address is normal. + 2. Vehicle overlap → True. + 3. Location proximity. Both geocoded and close → True; far apart with no + other positive signal → False. + 4. No positive signal at all → False. A shared channel must not absorb + calls by default (server-26#134 — this used to default True within + 20 min on any channel not name-matched as "dispatch"; a talkgroup + named tac/tactical is no less scrutinized in practice than any other). Thin calls (no units/vehicles/coords) never reach this function — they are intercepted before it in correlate_call. @@ -1791,44 +1741,43 @@ def _call_fits_incident( inc_units = _unit_keys(inc.get("units")) matched_units = _matching_units(call_units, inc.get("units")) if matched_units: - if is_dispatch: - if call_coords: - # Hard location conflict: geocoded on both sides and clearly different. - inc_coords_u = inc.get("location_coords") - if inc_coords_u: - dist_km = _haversine_km( - call_coords["lat"], call_coords["lng"], - inc_coords_u["lat"], inc_coords_u["lng"], - ) - if dist_km > proximity_km: - logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict") - return False, "unit_loc_conflict" - elif call_embedding and idle_min >= 15: - # Call has geocode but incident doesn't — fall back to content - # divergence as a location proxy. Without this, stale incidents - # that never geocoded absorb unrelated calls purely on unit - # overlap (e.g. a patrol officer working a second scene 70 min - # after the original call). - inc_emb_u = inc.get("embedding") - if inc_emb_u: - sim = _cosine_similarity(call_embedding, inc_emb_u) - if sim < 0.82: - logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence") - return False, "content_divergence" + if call_coords: + # Hard location conflict: geocoded on both sides and clearly different. + inc_coords_u = inc.get("location_coords") + if inc_coords_u: + dist_km = _haversine_km( + call_coords["lat"], call_coords["lng"], + inc_coords_u["lat"], inc_coords_u["lng"], + ) + if dist_km > proximity_km: + logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict") + return False, "unit_loc_conflict" elif call_embedding and idle_min >= 15: - # No geocode available AND old incident: use content divergence as a - # location-proxy veto. After 15+ minutes an officer at a completely - # different scene will be discussing clearly different content. - # Skip this for recent incidents — an officer updating on the same - # scene without re-stating the address is normal and their update - # won't share much vocabulary with the original dispatch. + # Call has geocode but incident doesn't — fall back to content + # divergence as a location proxy. Without this, stale incidents + # that never geocoded absorb unrelated calls purely on unit + # overlap (e.g. a patrol officer working a second scene 70 min + # after the original call). inc_emb_u = inc.get("embedding") if inc_emb_u: sim = _cosine_similarity(call_embedding, inc_emb_u) if sim < 0.82: - logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence") + logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence") return False, "content_divergence" - logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} is_dispatch={is_dispatch} → unit_overlap") + elif call_embedding and idle_min >= 15: + # No geocode available AND old incident: use content divergence as a + # location-proxy veto. After 15+ minutes an officer at a completely + # different scene will be discussing clearly different content. + # Skip this for recent incidents — an officer updating on the same + # scene without re-stating the address is normal and their update + # won't share much vocabulary with the original dispatch. + inc_emb_u = inc.get("embedding") + if inc_emb_u: + sim = _cosine_similarity(call_embedding, inc_emb_u) + if sim < 0.82: + logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence") + return False, "content_divergence" + logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → unit_overlap") return True, "unit_overlap" # ── 2. Vehicle overlap ──────────────────────────────────────────────────── @@ -1882,29 +1831,19 @@ def _call_fits_incident( return False, "location_conflict" # ── 4. No positive signals ──────────────────────────────────────────────── + # Requires at least one positive signal (unit, vehicle, or location match). + # A substantive call with no matching signals is more likely a separate + # incident than a follow-up — two dispatches can arrive within seconds of + # each other on a busy channel. Content-free thin calls are handled before + # this function via the thin path in correlate_call, with a tighter + # 30-second recency window. logger.info( - f" fits[{inc_id}]: no positive signal — is_dispatch={is_dispatch} idle={idle_min:.1f}min " + f" fits[{inc_id}]: no positive signal — idle={idle_min:.1f}min " f"inc_units={list(inc_units)} call_units={call_units} " f"inc_vehicles={list(inc_vehicles)} call_vehicles={call_vehicles} " f"call_coords={call_coords is not None} inc_coords={inc_coords is not None}" ) - if is_dispatch: - # Dispatch channels require at least one positive signal (unit, vehicle, - # or location match). A substantive call with no matching signals is more - # likely a separate incident than a follow-up to the current one — two - # dispatches can arrive within seconds of each other on a busy channel. - # Content-free thin calls are handled before this function via the thin - # path in correlate_call, with a tighter 30-second recency window. - return False, "no_signal" - - # Tactical channel: one scene per channel. - # Within 20 min of the last incident activity, link by default — same - # working channel almost certainly means same scene. - # After 20 min of silence, require at least one positive signal; the same - # frequency can be reused for a new unrelated incident later in the shift. - if idle_min < 20.0: - return True, "tactical_default" - return False, "tactical_idle" + return False, "no_signal" async def _update_incident( diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index 2b02657..212eed6 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -111,55 +111,17 @@ async def upload_call_audio( def _recent_incident_on_same_talkgroup(ctx: dict) -> bool: """ - True when one of the already-loaded recent incidents is running on this - call's own system + talkgroup AND was active within the last - `settings.tg_dispatch_thin_idle_minutes` minutes. Covers the "unit - dispatched on the dispatch channel, thin acknowledgement 10-30s later" - case: the ack carries no substance of its own but plainly belongs to the - job just opened. + True when a recent incident is running on this call's own system + + talkgroup, within `settings.tg_dispatch_thin_idle_minutes` (5 min) — + applied uniformly regardless of the talkgroup's name (server-26#134). + Covers "unit dispatched, thin ack 10-30s later": the ack has no + substance of its own but plainly belongs to the job just opened. - 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`). - Measured live in production (server-26#115, CORRELATION_REVIEW_0912.md, - window #3): on a busy dispatch channel producing 3-13 incidents per 2h, - that condition is satisfied almost unconditionally, so the surrounding - LLM-orphan gate never fired on exactly the channels it exists to - protect (0/24 target-shaped calls gated in a 4h window). The docstring's - own intent was always "10-30 seconds", not "hours" — a few minutes is - the right shape. - - Reads ctx["recent"] — the same window-filtered candidate list the rules - engine already loaded — so this adds no Firestore read. - - Known limitation (server-26#115): ctx["recent"] is derived from - `all_active` in `_build_context` — incidents with `status=="active"` - for the call's org, with over-capacity incidents already dropped by - `_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. A proper - fix needs a dedicated Firestore query that is not status/capacity - filtered — a new read, out of scope for this pass. + Reads ctx["recent"] (the rules engine's own candidate list — no extra + Firestore read). That list is status=="active" incidents only, so an + already-resolved or capacity-capped same-talkgroup incident won't be + seen here even if chronologically recent (server-26#115, unresolved — + would need a dedicated non-status-filtered query). Whether this limitation explains the 2/24 unexplained gate misses in the window #3 measurement is UNANSWERED, not confirmed either way — a prior diff --git a/drb-c2-core/tests/test_correlator_merge_caps.py b/drb-c2-core/tests/test_correlator_merge_caps.py index c59c3ad..c932250 100644 --- a/drb-c2-core/tests/test_correlator_merge_caps.py +++ b/drb-c2-core/tests/test_correlator_merge_caps.py @@ -31,8 +31,10 @@ from app.internal.incident_correlator import ( NOW = datetime(2026, 8, 20, 7, 0, 0, tzinfo=timezone.utc) -# TG 383 from the dump: "Ch 1 (Patched with 155.310)". _DISPATCH_TG_RE matches -# "patched", so this is a shared dispatch backbone carrying the whole department. +# TG 383 from the dump: "Ch 1 (Patched with 155.310)", a shared dispatch +# backbone carrying the whole department. Kept as two distinct fixture names +# for readability even though the channel's name no longer affects behavior +# (server-26#134). DISPATCH_TG = "Ch 1 (Patched with 155.310)" TACTICAL_TG = "Fireground 2" @@ -140,12 +142,9 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_dispatch_channel(): assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan" -def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel(): - """ - The widest version of the bug: non-dispatch talkgroups skipped the tiering - entirely and used the whole 90-minute fast-path window with no - single-candidate requirement, so ANY thin call joined whatever was newest. - """ +def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_named_channel(): + """A channel's name no longer changes anything (server-26#134) — same + assertion as the dispatch-named case above, different fixture name.""" inc = _incident(idle_minutes=40) decision = _run_decision(_ctx( all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG, @@ -154,10 +153,9 @@ def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel(): 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) + """server-26#134: 14 min was inside the old 15-min tactical window; now + every channel uses the 5-min window regardless of name.""" + inc = _incident(idle_minutes=14) decision = _run_decision(_ctx( all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG, )) @@ -234,47 +232,27 @@ def test_back_dated_thin_call_does_not_sail_through_the_recency_gate(): def test_back_dated_call_does_not_bypass_the_content_divergence_veto(monkeypatch): """ - Same `9d376ffe` failure mode, but exercised directly against - `_call_fits_incident` on a dispatch channel: unit overlap plus a - back-dated call (incident updated 45 minutes AFTER the call's own - `started_at`, which the sweep passes as `now`) used to make the signed - idle -45, so `idle_min >= 15` read False and the content-divergence - veto never ran — unit overlap alone forced the merge regardless of - what the call was actually about. With the gate fixed to compare - distance, idle_min is 45 (>= 15), the veto runs, and a divergent - embedding (patched below so the assertion doesn't depend on numpy - being installed in this environment) fails it. + Same `9d376ffe` failure mode, exercised directly against + `_call_fits_incident`: unit overlap plus a back-dated call (incident + updated 45 minutes AFTER the call's own `started_at`, which the sweep + passes as `now`) used to make the signed idle -45, so `idle_min >= 15` + read False and the content-divergence veto never ran — unit overlap + alone forced the merge regardless of what the call was actually about. + With the gate fixed to compare distance, idle_min is 45 (>= 15), the + veto runs, and a divergent embedding (patched below so the assertion + doesn't depend on numpy being installed in this environment) fails it. """ monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0) inc = _incident(idle_minutes=-45, units=["6-Adam"]) inc["embedding"] = [1.0, 0.0] fits, signal = _call_fits_incident( inc, call_units=["6-Adam"], call_vehicles=[], call_coords=None, - proximity_km=settings.location_proximity_km, is_dispatch=True, + proximity_km=settings.location_proximity_km, call_embedding=[0.0, 1.0], now=NOW, ) assert (fits, signal) == (False, "content_divergence") -def test_back_dated_call_on_tactical_channel_does_not_get_the_default(): - """ - Tactical-channel counterpart: no unit/vehicle/location signal, so the - function falls through to step 4's `idle_min < 20.0` default. A - back-dated call (incident updated 45 minutes after the call's own - started_at) used to read idle_min as -45, which is always < 20.0, so - `tactical_default` fired unconditionally no matter how stale the - incident actually was relative to this call. Fixed, idle_min is the - 45-minute distance, which is not < 20.0. - """ - inc = _incident(idle_minutes=-45) - fits, signal = _call_fits_incident( - inc, call_units=[], call_vehicles=[], call_coords=None, - proximity_km=settings.location_proximity_km, is_dispatch=False, - call_embedding=None, now=NOW, - ) - assert (fits, signal) == (False, "tactical_idle") - - # --------------------------------------------------------------------------- # 4. Hard caps — path-independent, because pairwise fit tests can't see shape # --------------------------------------------------------------------------- -- 2.54.0 From 422e9a4dc8c04e573c5e20d306e1e28e5a143714 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 13 Sep 2026 14:54:54 -0400 Subject: [PATCH 3/3] correlator: fix two comments left describing the removed tactical path (#134) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix --- drb-c2-core/app/internal/incident_correlator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 2c908b9..8846314 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -959,8 +959,8 @@ def _run_decision(ctx: dict) -> dict: if talkgroup_id is not None and system_id: tg_str = str(talkgroup_id) # talkgroup_name may be None when the upload form omits it (node sets it - # directly on the Firestore call doc). Fall back to the call doc so that - # dispatch-channel strictness works regardless of how the call arrived. + # directly on the Firestore call doc). Fall back to the call doc so the + # log lines below still name the channel. effective_talkgroup_name = talkgroup_name or call_doc.get("talkgroup_name") if effective_talkgroup_name != talkgroup_name: logger.info( @@ -1730,7 +1730,7 @@ def _call_fits_incident( # signed value: the re-correlation sweep anchors `now` to the call's own # started_at, which can be earlier than the incident's last activity and # send the signed value negative — silently defeating every `idle_min` - # gate below (content-divergence veto and the tactical default alike). + # gate below, the content-divergence veto included. # See `_idle_gate_minutes` docstring. The signed value is reported to # callers separately as `corr_incident_idle_min` (they compute it via # `_incident_idle_minutes` themselves) — nothing here needs it. -- 2.54.0