correlation: rules decide, one cheap LLM call only when ambiguous; drop the consensus tiebreak (server-26#299)

The rules engine now commits every confident decision alone. _run_decision
marks a decision ambiguous (new_with_candidates, unfit_tg_candidate,
unverified_unit_link) and only then does upload._correlate make ONE
llm_correlator.decide call; a usable answer is committed, otherwise the rules
decision stands. Removed: _correlate_with_consensus, llm_correlator.tiebreak /
decisions_agree / tiebreak prompt, corr_smart_model, the correlation_smart
health tier, the LLM-orphan gate (_call_is_substanceless, effectively dead:
it could only fire on minor-severity untyped calls) and its sweep budget.
corr_consensus is now rules_only | llm | rules_fallback | rules_guard, with
corr_band naming why; admin debug and replay metrics read the new values.

Section fixes in the same pass:
- #115: no tiebreaker left to manufacture incidents; an LLM orphan of a
  rules "new" is honoured only for untyped, substanceless, routine/minor calls.
- #86: divergence veto bar moved to config (embedding_divergence_threshold);
  the band between it and the match bar is ambiguous, not a silent merge.
- #103: a unit link with no embedding is "unit_overlap_unverified" ->
  ambiguous, instead of skipping the veto and linking as verified.
- #189: unit-only and LLM links refused once an incident is open 90 min;
  unit links on incidents open >30 min are ambiguous; LLM links distance-
  guarded (incident_max_spread_km) and only to shown candidates.
- #190: severity steps down on an explicit stand-down phrase; new
  severity_peak keeps the monotonic max.
- #88: parse_llm_bool fails closed on "false"; scene closures honoured only
  on confident links or the incident the scene opened.
- #27: sweep budget is the scan window (wall clock), not 3 attempts.
- LLM prompt: "Reserve new" bullet no longer contradicts the #189 EXCEPT.
- updated_at never moves backward on a back-dated link.

Unmeasured: no live AI or replay (credits unfunded); band frequency, F1 and
thresholds are unverified. Must be re-scored on both answer keys in Replay
before merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:26:38 -04:00
co-authored by Claude Opus 5.5
parent f4d78aba77
commit 3246fc97b0
18 changed files with 1207 additions and 1062 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ async def _run_ingest(global_correlation, system_ai_flags):
)
with g, sysdoc, \
patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
patch("app.internal.transcription.transcribe_call",
AsyncMock(return_value=("units respond to main street", []))), \
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \
+3 -3
View File
@@ -136,15 +136,15 @@ async def test_transient_failure_alerts_once_threshold_crossed():
async def test_recovery_alerts_once_after_an_alerted_episode():
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
await ai_health.report_degraded(
"correlation_smart", "gemini", "gemini-1.5-pro",
"correlation_cheap", "gemini", "gemini-3.6-flash",
"the Gemini account is out of credit", "top up billing", permanent=True,
)
webhook.reset_mock()
await ai_health.report_healthy("correlation_smart")
await ai_health.report_healthy("correlation_cheap")
webhook.assert_awaited_once()
state = ai_health.snapshot()["correlation_smart"]
state = ai_health.snapshot()["correlation_cheap"]
assert state["degraded"] is False
assert state["alerted"] is False
assert state["consecutive_failures"] == 0
+7 -18
View File
@@ -153,8 +153,10 @@ def test_timeout_messages_classify_as_transient():
def _corr_ctx():
from datetime import datetime, timezone
return {"call_id": "c1", "is_thin_call": False, "recent": [{"incident_id": "i1"}],
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"]}
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"],
"now": datetime.now(timezone.utc)}
@pytest.mark.asyncio
@@ -163,27 +165,14 @@ async def test_gemini_timeout_in_decide_falls_back_to_rules():
raise httpx.ReadTimeout("The read operation timed out")
with patch.object(settings, "gemini_api_key", "test-key"), \
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx: "p"), \
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx, cands: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang):
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == rules_only
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == keep the rules decision
state = ai_health.snapshot()["correlation_cheap"]
assert state["consecutive_failures"] == 1
assert state["permanent"] is False
@pytest.mark.asyncio
async def test_gemini_timeout_in_tiebreak_keeps_the_rules_decision():
def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out")
rules = {"action": "new", "matched_incident": None, "corr_debug": {}}
with patch.object(llm_correlator, "_build_tiebreak_prompt", lambda r, l, c: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang):
out = await llm_correlator.tiebreak(rules, {"action": "orphan"}, _corr_ctx())
assert out is rules
assert ai_health.snapshot()["correlation_smart"]["permanent"] is False
@pytest.mark.asyncio
async def test_gemini_timeout_in_transcript_correction_keeps_raw_text():
def hang(model, prompt):
@@ -233,8 +222,8 @@ def test_worst_case_bound_follows_the_settings():
patch.object(settings, "gemini_timeout_seconds", 20.0), \
patch.object(settings, "stt_retry_on_degenerate", True):
bound = recorrelation_sweep.pipeline_worst_case_seconds(n_scenes=2)
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + decide/tiebreak 2x20 + slack
assert bound == 400 + 20 + 20 + 40 + 40 + recorrelation_sweep._NON_AI_SLACK_SECONDS
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + one correlation call 20 + slack
assert bound == 400 + 20 + 20 + 40 + 20 + recorrelation_sweep._NON_AI_SLACK_SECONDS
def test_hold_back_covers_the_worst_case_and_never_drops_below_old_floor():
+3 -4
View File
@@ -53,12 +53,11 @@ def test_a_unit_never_on_the_incident_cannot_close_it():
def test_clearance_only_call_skips_the_llm():
from app.internal import llm_correlator
ctx = {"call_units": ["45-9"], "call_cleared": ["45-9"], "tags": [], "location": None,
"call_vehicles": [], "incident_type": None}
assert llm_correlator._is_clearance_only(ctx)
assert not llm_correlator._is_clearance_only({**ctx, "tags": ["mva"]})
assert not llm_correlator._is_clearance_only({**ctx, "call_cleared": []})
assert ic.is_clearance_only(ctx)
assert not ic.is_clearance_only({**ctx, "tags": ["mva"]})
assert not ic.is_clearance_only({**ctx, "call_cleared": []})
def test_only_numbered_units_hold_an_incident_open():
-430
View File
@@ -1,430 +0,0 @@
"""
server-26#115 — two consensus-quality fixes.
Fix 1 (routers/upload.py): when the cheap LLM says `orphan`, the rules engine
says `new`, and the call is genuinely SUBSTANCELESS (routine severity, no
vehicle/geocode/tag, and no incident already running on the same talkgroup),
resolve to `orphan` and DO NOT pay for the smart tiebreaker. Radio housekeeping
(unit check-ins, roll call, 10-8/10-98) was being promoted to incidents because
the tiebreaker rubber-stamped the rules `new` ~21/21 of the time
(CORRELATION_REVIEW_0907b.md).
The substance test runs against `ctx` (fully populated at preview time), NOT
against `rules_decision["corr_debug"]` — that dict is EMPTY at preview time for
action=="new" (corr_path:"new" is written at APPLY time), so the first version of
this gate fired on real events (a `major` "extinguishing fire", geocoded calls,
pursuit updates).
Fix 2 (incident_correlator.py): the `location` correlation path linked on a bare
sub-`location_proximity_km` (0.5 km) distance alone, taking whichever incident
came first in an unsorted `recent`. In a dense village two unrelated events
routinely geocode that close. A `location` link now needs unit overlap with the
candidate OR a distance under a tighter bar, and picks the NEAREST qualifying
candidate. A unit-overlap location link is tagged `location_unit_overlap` so it
does not merge into the fast path's bucket in the admin fit-signal histogram.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from app.routers import upload
from app.internal.incident_correlator import _run_decision, has_event_substance
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
# ─────────────────────────────────────────────────────────────────────────────
# Fix 1 — the LLM-orphan gate in _correlate_with_consensus
# ─────────────────────────────────────────────────────────────────────────────
def _preview(action, corr_debug=None, ctx=None):
base_ctx = {"call_id": "call-1"}
if ctx:
base_ctx.update(ctx)
return {
"decision": {
"action": action,
"matched_incident": None,
"incident_type": "other" if action == "new" else None,
"corr_debug": {} if corr_debug is None else dict(corr_debug),
},
"ctx": base_ctx,
}
def _llm(action, reasoning="—"):
md = {"incident_id": "inc-1"} if action == "link" else None
return {"action": action, "matched_incident": md, "reasoning": reasoning}
async def _run_consensus(preview, llm_decision):
tiebreak_result = {
"action": "new", "matched_incident": None, "incident_type": "other",
"corr_debug": {}, "reasoning": "tb",
}
with patch("app.internal.incident_correlator.preview_correlation",
new=AsyncMock(return_value=preview)), \
patch("app.internal.incident_correlator.apply_correlation",
new=AsyncMock(return_value="incident-x")) as m_apply, \
patch("app.internal.llm_correlator.decide",
new=AsyncMock(return_value=llm_decision)), \
patch("app.internal.llm_correlator.tiebreak",
new=AsyncMock(return_value=tiebreak_result)) as m_tiebreak:
await upload._correlate_with_consensus(
call_id="call-1", node_id="n1", system_id="sys-1",
talkgroup_id=9048, talkgroup_name="Dispatch", tags=[],
incident_type=None, location=None, location_coords=None,
)
return m_apply, m_tiebreak
async def test_substanceless_no_recent_same_tg_incident_gates_without_tiebreak():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}), _llm("orphan", "unit check-in, not an incident"),
)
m_tiebreak.assert_not_called()
m_apply.assert_called_once()
gated = m_apply.call_args[0][0]["decision"]
assert gated["action"] == "orphan"
dbg = gated["corr_debug"]
assert dbg["corr_consensus"] == "llm_orphan_gate"
assert dbg["corr_consensus"] != "tiebreak"
assert dbg["corr_rules_action"] == "new"
assert dbg["corr_llm_action"] == "orphan"
assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident"
@pytest.mark.parametrize("severity", ["moderate", "major"])
async def test_moderate_or_major_severity_is_not_gated(severity):
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_severity": severity}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_routine_severity_alone_still_gates():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_severity": "routine"}), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_call_with_coords_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"coords": {"lat": 41.15, "lng": -73.86}}),
_llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_tags_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"tags": ["structure-fire"]}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_vehicles_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_vehicles": ["red sedan"]}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_resolved_incident_type_is_not_gated():
# The creation gate skips has_event_substance when a type resolved, so a
# typed call (fire/medical/…) opens an incident on substance the gate does
# not re-check — it must keep the tiebreak, not be dropped.
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"incident_type": "fire"}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_reassignment_call_is_not_gated():
# reassignment=True is dispatch pulling a unit onto a NEW job (units are
# blanked for exactly that reason) — the strongest new-incident signal.
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"reassignment": True}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_recent_incident_on_same_talkgroup_is_not_gated():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-live",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
"updated_at": (NOW - timedelta(minutes=1)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
# server-26#115 window #3 (CORRELATION_REVIEW_0912.md): the escape hatch used
# to treat ANY same-talkgroup incident inside the 2h correlation_window_hours
# 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), 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 = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-live",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
# 3 min ago — inside tg_dispatch_thin_idle_minutes (5).
"updated_at": (NOW - timedelta(minutes=3)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
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 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,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-stale",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
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,
"talkgroup_name": "Tac 3",
"now": NOW,
"recent": [{
"incident_id": "inc-tac",
"system_ids": ["sys-1"],
"talkgroup_ids": ["383"],
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
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():
# server-26#115: a live measurement window must be able to see *why* an
# llm=orphan/rules=new call escaped the gate without guessing from the raw
# dump (which produced a wrong "confirmed explanation" for 2 window-#3
# misses the first time). corr_gate_veto names the surviving condition.
ctx = {"call_severity": "major"}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
final = m_apply.call_args[0][0]["decision"]
assert final["corr_debug"]["corr_gate_veto"] == "severity"
async def test_gate_veto_reason_is_absent_when_the_disagreement_is_not_orphan_vs_new():
# corr_gate_veto is only meaningful for the llm=orphan/rules=new shape the
# gate targets — it must not appear (or be misleadingly None-vs-absent) on
# an unrelated disagreement shape.
m_apply, m_tiebreak = await _run_consensus(
_preview("link", {}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
final = m_apply.call_args[0][0]["decision"]
assert "corr_gate_veto" not in final["corr_debug"]
async def test_recent_incident_on_a_different_talkgroup_still_gates():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"recent": [{
"incident_id": "inc-other",
"system_ids": ["sys-1"],
"talkgroup_ids": ["1200"],
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_llm_link_vs_rules_new_still_escalates():
m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job"))
m_tiebreak.assert_called_once()
async def test_llm_orphan_vs_rules_link_still_escalates():
# Not the gate condition (gate needs rules=="new"); must fall through.
m_apply, m_tiebreak = await _run_consensus(_preview("link", {}), _llm("orphan"))
m_tiebreak.assert_called_once()
def test_has_event_substance_predicate():
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
assert has_event_substance({"tags": ["fire"]})
assert has_event_substance({"call_vehicles": ["sedan"]})
assert not has_event_substance({})
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
# units and location are NOT substance — nearly every transmission has them.
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
# ─────────────────────────────────────────────────────────────────────────────
# Fix 2 — tighten corr_path=location
# ─────────────────────────────────────────────────────────────────────────────
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
# ~0.13 km north of the call — inside the tight bar.
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
def _inc(incident_id, coords, units):
return {
"incident_id": incident_id,
"system_ids": ["sys-1"],
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
"location_coords": coords,
"units": units,
"tags": [],
"type": "police",
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
"status": "active",
"call_ids": ["c0"],
}
def _loc_ctx(*, incidents, call_units):
return {
"call_id": "call-loc",
"all_active": list(incidents),
"recent": list(incidents),
"call_doc": {},
"call_embedding": None,
"call_units": call_units,
"call_vehicles": [],
"call_cleared": [],
"call_severity": "routine",
"coords": CALL_COORDS,
"is_thin_call": False,
"now": NOW,
"system_id": "sys-1",
"talkgroup_id": 999, # not in inc.talkgroup_ids
"talkgroup_name": "Tactical",
"tags": [],
"incident_type": "police",
"location": "Main St",
"location_coords": CALL_COORDS,
"reassignment": True, # suppress the unit-continuity path
"create_if_new": True,
}
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
with caplog.at_level("INFO", logger="drb-c2-core"):
decision = _run_decision(ctx)
# Reaches, and is rejected by, the new guard (not an earlier path).
assert "location-path skipped" in caplog.text
assert decision["action"] != "link"
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
call_units=["5-Adam"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
def test_location_path_links_on_tight_proximity_without_unit_overlap():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
def test_location_path_picks_nearest_in_radius_candidate():
# `recent` order puts the farther tight-proximity incident first; the guard
# must still select the nearest one.
ctx = _loc_ctx(
incidents=[
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["matched_incident"]["incident_id"] == "inc-near"
assert decision["corr_debug"]["corr_path"] == "location"
@@ -0,0 +1,367 @@
"""
server-26#299 — rules decide confident outcomes; ONE cheap LLM call only in the
ambiguous band; no cheap/smart consensus tiebreak.
Also pins the section fixes that ride on the new shape:
#86 divergence veto bar is a setting, and the band between it and the match
bar is ambiguous rather than a silent merge
#103 a scene with no embedding is "unverified", not verified
#88 LLM yes/no fields fail closed; a closure only counts on a confident link
#189 weak links refused past the span cap; LLM links distance-guarded
#190 severity steps down on an explicit stand-down, the peak never does
#27 sweep budget is wall-clock
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from app.config import settings
from app.internal import incident_correlator as ic
from app.internal import llm_correlator, recorrelation_sweep
from app.routers import upload
NOW = datetime(2026, 10, 27, 15, 0, 0, tzinfo=timezone.utc)
def _inc(incident_id="inc-1", idle=1.0, span=None, **kw) -> dict:
updated = NOW - timedelta(minutes=idle)
started = NOW - timedelta(minutes=span if span is not None else idle)
inc = {
"incident_id": incident_id, "system_ids": ["sys-1"], "talkgroup_ids": ["383"],
"updated_at": updated.isoformat(), "started_at": started.isoformat(),
"status": "active", "call_ids": ["seed"], "type": "police",
}
inc.update(kw)
return inc
def _ctx(incidents=(), **kw) -> dict:
incidents = list(incidents)
base = {
"call_id": "call-1", "all_active": incidents, "recent": incidents,
"call_doc": {}, "call_embedding": None, "call_units": [], "call_vehicles": [],
"call_cleared": [], "call_severity": "routine", "coords": None,
"is_thin_call": False, "now": NOW, "system_id": "sys-1", "talkgroup_id": 383,
"talkgroup_name": "Ch 1", "tags": [], "incident_type": None, "location": None,
"location_coords": None, "reassignment": False, "create_if_new": True,
}
base.update(kw)
return base
def _vec(sim: float) -> tuple[list, list]:
"""Two unit vectors with cosine similarity `sim`."""
import math
return [1.0, 0.0], [sim, math.sqrt(max(0.0, 1 - sim * sim))]
# ── The band ────────────────────────────────────────────────────────────────
def test_fresh_unit_link_is_confident():
d = ic._run_decision(_ctx([_inc(idle=2, units=["6-Adam"])], call_units=["6-Adam"]))
assert d["action"] == "link"
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap"
assert d["ambiguous"] is None
def test_stale_unit_link_without_embedding_is_ambiguous_not_verified():
# #103: no embedding used to skip the veto and link as if verified.
d = ic._run_decision(_ctx([_inc(idle=20, units=["6-Adam"])], call_units=["6-Adam"]))
assert d["action"] == "link"
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap_unverified"
assert d["ambiguous"] == "unverified_unit_link"
def test_young_but_long_running_unit_link_is_ambiguous():
# #189: busy channel keeps idle low; age is what reveals a sink forming.
inc = _inc(idle=1, span=settings.unit_link_confident_max_span_minutes + 5, units=["6-Adam"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
assert d["ambiguous"] == "unverified_unit_link"
@pytest.mark.parametrize("sim,expected", [
(0.80, ("orphan", "content_divergence")), # below the divergence bar → veto
(0.88, ("link", "unit_overlap_unverified")), # between the bars → ambiguous (#86)
(0.95, ("link", "unit_content_match")), # at the match bar → confident
])
def test_divergence_band_between_the_two_thresholds(sim, expected):
call_emb, inc_emb = _vec(sim)
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
fit, signal = ic._call_fits_incident(
inc, ["6-Adam"], [], None, settings.location_proximity_km,
call_embedding=call_emb, now=NOW,
)
assert (("link" if fit else "orphan"), signal) == expected
def test_divergence_bar_is_the_setting_not_a_literal():
call_emb, inc_emb = _vec(0.88)
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
with patch.object(settings, "embedding_divergence_threshold", 0.90):
fit, signal = ic._call_fits_incident(
inc, ["6-Adam"], [], None, settings.location_proximity_km,
call_embedding=call_emb, now=NOW,
)
assert (fit, signal) == (False, "content_divergence")
def test_new_with_live_candidates_is_ambiguous_without_none():
d = ic._run_decision(_ctx([_inc(talkgroup_ids=["999"])], incident_type="police"))
assert (d["action"], d["ambiguous"]) == ("new", "new_with_candidates")
d = ic._run_decision(_ctx([], incident_type="police"))
assert (d["action"], d["ambiguous"]) == ("new", None)
def test_orphan_with_unfit_same_tg_candidate_is_ambiguous():
# Substantive (a location) but untyped, live incident on its talkgroup, no evidence.
d = ic._run_decision(_ctx([_inc(idle=2)], location="Mohegan Park Ave"))
assert (d["action"], d["ambiguous"]) == ("orphan", "unfit_tg_candidate")
def test_thin_sweep_and_clearance_calls_are_never_ambiguous():
inc = _inc(idle=0.2)
assert ic._run_decision(_ctx([inc], is_thin_call=True))["ambiguous"] is None
assert ic._run_decision(_ctx([inc], incident_type="police", create_if_new=False))["ambiguous"] is None
stale = _inc(idle=20, units=["45-9"])
d = ic._run_decision(_ctx([stale], call_units=["45-9"], call_cleared=["45-9"]))
assert d["action"] == "link" and d["ambiguous"] is None
# ── #189: caps on absorption ────────────────────────────────────────────────
def test_weak_link_span_cap_refuses_unit_only_links():
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1, units=["6-Adam"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"], incident_type="accident"))
assert d["action"] == "new"
assert ic.llm_candidates(_ctx([inc])) == []
def test_weak_link_span_cap_also_binds_unit_continuity():
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1,
units=["6-Adam"], talkgroup_ids=["999"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
assert d["action"] == "orphan"
def test_llm_link_distance_guard():
pin = {"lat": 41.0, "lng": -73.8}
inc = _inc(location="Main St", location_coords=pin, location_coords_source="Main St")
near = _ctx(coords={"lat": 41.005, "lng": -73.8})
far = _ctx(coords={"lat": 41.2, "lng": -73.8}) # ~22 km
assert ic.llm_link_allowed(inc, near)
assert not ic.llm_link_allowed(inc, far)
assert ic.llm_link_allowed({**inc, "tags": ["vehicle-pursuit"]}, far)
# ── llm_correlator: one call, validated ─────────────────────────────────────
def test_parse_rejects_links_to_unshown_incidents_and_bad_actions():
shown = [_inc("inc-1")]
ctx = _ctx(shown)
assert llm_correlator._parse_response({"action": "link", "incident_id": "nope"}, ctx, shown) is None
assert llm_correlator._parse_response({"action": "maybe"}, ctx, shown) is None
ok = llm_correlator._parse_response({"action": "link", "incident_id": "inc-1"}, ctx, shown)
assert ok["action"] == "link" and ok["matched_incident"]["incident_id"] == "inc-1"
def test_parse_new_uses_the_rules_creation_gate():
ctx = _ctx(call_severity="minor")
assert llm_correlator._parse_response({"action": "new"}, ctx, [])["incident_type"] == "other"
out = llm_correlator._parse_response({"action": "new"}, _ctx(), [])
assert out["action"] == "orphan"
async def _correlate_with(rules_decision, llm_return, ctx=None):
ctx = ctx or _ctx([_inc()])
preview = {"decision": rules_decision, "ctx": ctx}
with patch("app.internal.incident_correlator.preview_correlation", AsyncMock(return_value=preview)), \
patch("app.internal.incident_correlator.apply_correlation", AsyncMock(return_value="inc-x")) as apply, \
patch("app.internal.llm_correlator.decide", AsyncMock(return_value=llm_return)) as decide:
_, final = await upload._correlate(
call_id="call-1", node_id="n", system_id="sys-1", talkgroup_id=383,
talkgroup_name="Ch 1", tags=[], incident_type=None, location=None,
location_coords=None,
)
return final, decide, apply
def _rules(action, ambiguous=None, inc=None):
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
"corr_debug": {}, "ambiguous": ambiguous}
def _llm(action, inc=None):
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
"corr_debug": {"corr_llm_reasoning": "r"}, "reasoning": "r"}
@pytest.mark.asyncio
async def test_confident_rules_decision_never_calls_the_llm():
final, decide, _ = await _correlate_with(_rules("new"), _llm("orphan"))
decide.assert_not_awaited()
assert final["corr_debug"]["corr_consensus"] == "rules_only"
@pytest.mark.asyncio
async def test_ambiguous_decision_makes_exactly_one_llm_call_and_commits_it():
inc = _inc("inc-2")
final, decide, apply = await _correlate_with(_rules("new", "new_with_candidates"), _llm("link", inc))
decide.assert_awaited_once()
committed = apply.await_args.args[0]["decision"]
assert committed["action"] == "link" and committed["matched_incident"]["incident_id"] == "inc-2"
dbg = final["corr_debug"]
assert dbg["corr_consensus"] == "llm" and dbg["corr_path"] == "llm"
assert dbg["corr_band"] == "new_with_candidates"
assert (dbg["corr_rules_action"], dbg["corr_llm_action"]) == ("new", "link")
@pytest.mark.asyncio
async def test_llm_unavailable_keeps_the_rules_decision():
rules = _rules("new", "new_with_candidates")
final, decide, _ = await _correlate_with(rules, None)
assert final is rules and final["corr_debug"]["corr_consensus"] == "rules_fallback"
@pytest.mark.asyncio
async def test_llm_orphan_cannot_drop_a_major_or_reassignment_call():
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], call_severity="major"))
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()]))
assert final["action"] == "orphan" and final["corr_debug"]["corr_consensus"] == "llm"
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"incident_type": "fire"}, {"tags": ["structure-fire"]},
{"coords": {"lat": 41.0, "lng": -73.8}}, {"reassignment": True}])
async def test_llm_orphan_cannot_drop_a_typed_or_substantive_call(extra):
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], **extra))
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
@pytest.mark.asyncio
async def test_llm_orphan_over_a_rules_link_is_tombstoned_for_the_sweep():
inc = _inc()
rules = _rules("link", "unverified_unit_link", inc)
final, _, _ = await _correlate_with(rules, _llm("orphan"))
assert final["action"] == "orphan" and final["corr_debug"]["corr_path"] == "llm_orphan"
@pytest.mark.asyncio
async def test_back_dated_link_never_moves_updated_at_backward():
inc = _inc(idle=1, span=10)
with patch.object(ic, "fstore") as fs:
fs.doc_set = AsyncMock()
await ic._update_incident(inc, "late-call", 383, "sys-1", [], None, None, ["6-Adam"], [], None,
NOW - timedelta(hours=3))
assert fs.doc_set.await_args.args[2]["updated_at"] == inc["updated_at"]
@pytest.mark.asyncio
async def test_llm_confirming_the_rules_link_keeps_the_rules_path():
inc = _inc()
rules = _rules("link", "unverified_unit_link", inc)
rules["corr_debug"] = {"corr_path": "fast/single", "corr_fit_signal": "unit_overlap_unverified"}
final, _, _ = await _correlate_with(rules, _llm("link", inc))
assert final["corr_debug"]["corr_path"] == "fast/single"
assert final["corr_debug"]["corr_consensus"] == "llm"
def test_no_tiebreak_tier_remains():
assert not hasattr(llm_correlator, "tiebreak")
assert not hasattr(settings, "corr_smart_model")
assert not hasattr(upload, "_correlate_with_consensus")
# ── #88 ─────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("value,expected", [
(True, True), (False, False), ("true", True), ("True", True), ("yes", True),
("false", False), ("False", False), ("no", False), ("", False), (None, False),
("maybe", False), (1, False), ({"x": 1}, False),
])
def test_parse_llm_bool_fails_closed(value, expected):
assert ic.parse_llm_bool(value) is expected
def test_closure_only_honoured_on_a_confident_link_or_own_new_incident():
assert upload._may_resolve({"action": "new", "corr_debug": {"corr_consensus": "llm"}})
assert upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
"corr_path": "fast/single"}})
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
"corr_path": "fast/thin"}})
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "llm",
"corr_path": "llm"}})
assert not upload._may_resolve({"action": "orphan", "corr_debug": {}})
@pytest.mark.asyncio
async def test_quoted_false_does_not_resolve_or_blank_units():
scene = {"tags": [], "incident_type": "police", "location": None, "location_coords": None,
"units": ["6-Adam"], "resolved": "false", "reassignment": "false"}
corr = AsyncMock(return_value=("inc-1", {"action": "new", "corr_debug": {}}))
with patch.object(upload, "_correlate", corr), patch.object(upload, "fstore") as fs:
fs.doc_set = AsyncMock()
await upload._correlate_scenes("call-1", "n", "sys-1", 383, "Ch 1", [scene])
assert corr.await_args.kwargs["units"] == ["6-Adam"]
assert corr.await_args.kwargs["reassignment"] is False
fs.doc_set.assert_not_awaited()
# ── #190 ────────────────────────────────────────────────────────────────────
def test_severity_escalates_immediately_and_keeps_its_peak():
out = ic._severity_after_link({"severity": "minor"}, "major", "shots fired")
assert out["severity"] == "major" and out["severity_peak"] == "major"
def test_calmer_followup_without_stand_down_does_not_deescalate():
out = ic._severity_after_link({"severity": "major"}, "routine", "Engine 5 on scene, 10-4")
assert out["severity"] == "major" and "severity_deescalated_from" not in out
def test_explicit_stand_down_deescalates_but_peak_stays():
inc = {"severity": "major"}
out = ic._severity_after_link(inc, "minor", "Only smoke showing, no flames.")
assert out == {"severity": "minor", "severity_peak": "major", "severity_deescalated_from": "major"}
later = ic._severity_after_link({**inc, **out}, "routine", "copy")
assert later["severity"] == "minor" and later["severity_peak"] == "major"
@pytest.mark.parametrize("text", [
"call was unfounded", "false alarm, resetting the panel", "GOA, nothing found",
"Code 4, you can stand down", "fire is out",
])
def test_stand_down_phrases(text):
assert ic._is_stand_down(text)
@pytest.mark.parametrize("text", ["disregard my last", "code 40", "flames showing",
"primary search all clear", "Code 4", None])
def test_not_stand_down_phrases(text):
assert not ic._is_stand_down(text)
# ── #27 ─────────────────────────────────────────────────────────────────────
def test_sweep_tombstones_only_on_the_last_visible_pass():
scan = settings.recorrelation_scan_minutes
young = {"ended_at": (NOW - timedelta(minutes=20)).isoformat()}
last = {"ended_at": (NOW - timedelta(minutes=scan - 1)).isoformat()}
assert not recorrelation_sweep._last_chance(young, NOW)
assert recorrelation_sweep._last_chance(last, NOW)
assert recorrelation_sweep._last_chance({}, NOW)
@pytest.mark.asyncio
async def test_sweep_keeps_retrying_past_the_old_three_attempt_budget():
call = {"call_id": "c", "started_at": (NOW - timedelta(minutes=30)).isoformat(),
"ended_at": (NOW - timedelta(minutes=30)).isoformat(), "corr_sweep_count": 5}
with patch("app.internal.incident_correlator.correlate_call", AsyncMock(return_value=None)), \
patch.object(recorrelation_sweep, "fstore") as fs:
fs.doc_set = AsyncMock()
assert await recorrelation_sweep._recorrelate_orphan(call, NOW) is False
assert fs.doc_set.await_args.args[2] == {"corr_sweep_count": 6}
@@ -0,0 +1,141 @@
"""
server-26#115 — location-path corroboration, plus the shared substance predicate.
(The consensus LLM-orphan gate that used to be tested here was removed with the
cheap/smart tiebreak in server-26#299; its replacement is tested in
test_correlation_decision_flow.py.)
The `location` correlation path linked on a bare sub-`location_proximity_km`
(0.5 km) distance alone, taking whichever incident came first in an unsorted
`recent`. In a dense village two unrelated events routinely geocode that close.
A `location` link now needs unit overlap with the candidate OR a distance under a
tighter bar, and picks the NEAREST qualifying candidate. A unit-overlap location
link is tagged `location_unit_overlap` so it does not merge into the fast path's
bucket in the admin fit-signal histogram.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.internal.incident_correlator import _run_decision, has_event_substance
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
def test_has_event_substance_predicate():
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
assert has_event_substance({"tags": ["fire"]})
assert has_event_substance({"call_vehicles": ["sedan"]})
assert not has_event_substance({})
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
# units and location are NOT substance — nearly every transmission has them.
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
# ─────────────────────────────────────────────────────────────────────────────
# Fix 2 — tighten corr_path=location
# ─────────────────────────────────────────────────────────────────────────────
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
# ~0.13 km north of the call — inside the tight bar.
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
def _inc(incident_id, coords, units):
return {
"incident_id": incident_id,
"system_ids": ["sys-1"],
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
"location_coords": coords,
"units": units,
"tags": [],
"type": "police",
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
"status": "active",
"call_ids": ["c0"],
}
def _loc_ctx(*, incidents, call_units):
return {
"call_id": "call-loc",
"all_active": list(incidents),
"recent": list(incidents),
"call_doc": {},
"call_embedding": None,
"call_units": call_units,
"call_vehicles": [],
"call_cleared": [],
"call_severity": "routine",
"coords": CALL_COORDS,
"is_thin_call": False,
"now": NOW,
"system_id": "sys-1",
"talkgroup_id": 999, # not in inc.talkgroup_ids
"talkgroup_name": "Tactical",
"tags": [],
"incident_type": "police",
"location": "Main St",
"location_coords": CALL_COORDS,
"reassignment": True, # suppress the unit-continuity path
"create_if_new": True,
}
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
with caplog.at_level("INFO", logger="drb-c2-core"):
decision = _run_decision(ctx)
# Reaches, and is rejected by, the new guard (not an earlier path).
assert "location-path skipped" in caplog.text
assert decision["action"] != "link"
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
call_units=["5-Adam"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
def test_location_path_links_on_tight_proximity_without_unit_overlap():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
def test_location_path_picks_nearest_in_radius_candidate():
# `recent` order puts the farther tight-proximity incident first; the guard
# must still select the nearest one.
ctx = _loc_ctx(
incidents=[
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["matched_incident"]["incident_id"] == "inc-near"
assert decision["corr_debug"]["corr_path"] == "location"
@@ -332,13 +332,18 @@ def test_duration_cap_blocks_the_thin_path_too():
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
def test_incident_just_under_the_duration_cap_still_accepts_calls():
def test_incident_just_under_the_duration_cap_still_accepts_strong_evidence():
# Past the weak-link span cap (server-26#189) shared units alone no longer
# link, but strong evidence (a shared vehicle) still does until the hard cap.
inc = _long_running(settings.incident_max_duration_minutes - 10)
inc["units"] = ["6-Adam"]
inc["vehicles"] = ["red sedan"]
decision = _run_decision(_ctx(
all_active=[inc], recent=[inc], is_thin_call=False, call_units=["6-Adam"],
all_active=[inc], recent=[inc], is_thin_call=False,
call_units=["7-Boy"], call_vehicles=["red sedan"],
))
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_fit_signal"] == "vehicle_overlap"
def test_call_count_cap_forces_a_new_incident():
@@ -29,7 +29,7 @@ async def _run_ingest(skip_reason):
patch("app.internal.firestore.doc_get_cached",
AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \
patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
patch("app.internal.transcription.transcribe_call",
AsyncMock(return_value=("10-8", []))), \
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \