correlator: gate LLM-orphan against rules-new instead of escalating to tiebreak (#115) #125

Merged
logan merged 3 commits from fix/115-consensus-orphan-gate into main 2026-09-11 23:18:13 -04:00
4 changed files with 290 additions and 119 deletions
Showing only changes of commit dd426572fc - Show all commits
@@ -246,6 +246,22 @@ def _matching_units(call_units: Optional[list[str]], inc_units: Optional[list[st
return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys] return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys]
def has_event_substance(ctx: dict) -> bool:
"""
True when the call carries content beyond who-was-speaking-and-where:
a vehicle, a geocode, or a tag.
This is the substance half of the incident-creation gate (see
`_run_decision`, "Severity, not type, decides..."), factored out so the
consensus LLM-orphan gate in routers/upload.py mirrors it exactly and can
never drop a call the creation gate would have opened. `call_units` and
`location` are deliberately excluded — radio protocol puts a unit ID and a
place name in almost every transmission, so counting them as substance
makes the check trivially true.
"""
return bool(ctx.get("call_vehicles") or ctx.get("coords") or ctx.get("tags"))
def _infer_type_from_tags(tags: list[str]) -> Optional[str]: def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
"""Return an incident type inferred from tags, or None if ambiguous.""" """Return an incident type inferred from tags, or None if ambiguous."""
for tag in tags: for tag in tags:
@@ -1180,6 +1196,10 @@ def _run_decision(ctx: dict) -> dict:
# ── 2. Location path: proximity match (time-limited, cross-type) ───────── # ── 2. Location path: proximity match (time-limited, cross-type) ─────────
if not matched_incident and coords: if not matched_incident and coords:
# server-26#115 — score every in-radius candidate and link the NEAREST
# that carries corroboration, rather than whichever incident happened to
# come first in an unsorted `recent`.
loc_candidates: list[tuple] = []
for inc in recent: for inc in recent:
inc_coords = inc.get("location_coords") inc_coords = inc.get("location_coords")
if not inc_coords: if not inc_coords:
@@ -1196,7 +1216,8 @@ def _run_decision(ctx: dict) -> dict:
elapsed_min = max(_incident_idle_minutes(inc, now), 0.1) elapsed_min = max(_incident_idle_minutes(inc, now), 0.1)
if (dist_km / elapsed_min) > _MAX_PURSUIT_SPEED_KM_PER_MIN: if (dist_km / elapsed_min) > _MAX_PURSUIT_SPEED_KM_PER_MIN:
continue # implausible speed — skip this candidate continue # implausible speed — skip this candidate
if dist_km <= radius: if dist_km > radius:
continue
# server-26#115 — a bare sub-radius distance is not enough on its # server-26#115 — a bare sub-radius distance is not enough on its
# own. Require corroboration: unit overlap with the candidate, OR # own. Require corroboration: unit overlap with the candidate, OR
# a much tighter proximity. Pursuit incidents keep their # a much tighter proximity. Pursuit incidents keep their
@@ -1214,8 +1235,16 @@ def _run_decision(ctx: dict) -> dict:
f"(<= {_LOCATION_TIGHT_PROXIMITY_KM}km)" f"(<= {_LOCATION_TIGHT_PROXIMITY_KM}km)"
) )
continue continue
loc_candidates.append((dist_km, unit_overlap, is_pursuit_inc, inc))
if loc_candidates:
loc_candidates.sort(key=lambda c: c[0])
dist_km, unit_overlap, is_pursuit_inc, inc = loc_candidates[0]
matched_incident = inc matched_incident = inc
fit_signal = "unit_overlap" if unit_overlap else "location_proximity" # Distinct from the fast path's "unit_overlap" so the admin
# corr_fit_signal histogram (routers/admin.py) does not merge a
# location-path link into the fast-path bucket (#35).
fit_signal = "location_unit_overlap" if unit_overlap else "location_proximity"
corr_debug = { corr_debug = {
"corr_path": "location", "corr_path": "location",
"corr_distance_km": round(dist_km, 3), "corr_distance_km": round(dist_km, 3),
@@ -1230,7 +1259,6 @@ def _run_decision(ctx: dict) -> dict:
f"Correlator location-path: call {call_id} → {inc['incident_id']} " f"Correlator location-path: call {call_id} → {inc['incident_id']} "
f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc}, signal={fit_signal})" f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc}, signal={fit_signal})"
) )
break
# ── 2.5. Cross-TG path: same department, overlapping units, moderate similarity ── # ── 2.5. Cross-TG path: same department, overlapping units, moderate similarity ──
# #
@@ -1367,7 +1395,7 @@ def _run_decision(ctx: dict) -> dict:
# each. A vehicle, a geocode, or a tag means the extractor found something # each. A vehicle, a geocode, or a tag means the extractor found something
# beyond who was speaking and where they stood. # beyond who was speaking and where they stood.
if not resolved_type: if not resolved_type:
has_substance = bool(call_vehicles or coords or tags) has_substance = has_event_substance(ctx)
if call_severity in ("minor", "moderate", "major") or has_substance: if call_severity in ("minor", "moderate", "major") or has_substance:
resolved_type = "other" resolved_type = "other"
logger.info( logger.info(
@@ -20,6 +20,22 @@ from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.config import settings from app.config import settings
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
MAX_SWEEP_ATTEMPTS = 3
# server-26#115 — a call the consensus LLM-orphan gate parked (llm=orphan vs
# rules=new, no substance) gets a longer budget. The gate fires before any
# incident for the job may exist, so the substantive call that would justify
# linking can land well after the standard ~6 min. Still link-only: a genuinely
# thin call must not mint an incident, and the rules creation gate would re-orphan
# it anyway.
GATED_ORPHAN_SWEEP_ATTEMPTS = 10
def _max_sweep_attempts(call: dict) -> int:
if call.get("corr_consensus") == "llm_orphan_gate":
return GATED_ORPHAN_SWEEP_ATTEMPTS
return MAX_SWEEP_ATTEMPTS
async def recorrelation_loop() -> None: async def recorrelation_loop() -> None:
interval = settings.summary_interval_minutes * 60 interval = settings.summary_interval_minutes * 60
@@ -46,10 +62,9 @@ async def _run_sweep_pass() -> None:
("status", "==", "ended"), ("status", "==", "ended"),
("ended_at", ">=", cutoff), ("ended_at", ">=", cutoff),
]) ])
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures. # corr_path="unlinked" is written after the attempt budget is exhausted.
# Allows a few retries so a welfare-check call can link to an escalation # Allows a few retries so a welfare-check call can link to an escalation
# incident that is created a few minutes later, without sweeping 30× forever. # incident that is created a few minutes later, without sweeping 30× forever.
MAX_SWEEP_ATTEMPTS = 3
orphans = [ orphans = [
c for c in recent_ended c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id") if not c.get("incident_ids") and not c.get("incident_id")
@@ -61,7 +76,7 @@ async def _run_sweep_pass() -> None:
# the thin path minutes later and attached to whatever was most recent — # the thin path minutes later and attached to whatever was most recent —
# a second route into the over-merge the thin fix above addresses. # a second route into the over-merge the thin fix above addresses.
and not c.get("skip_reason") and not c.get("skip_reason")
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS and c.get("corr_sweep_count", 0) < _max_sweep_attempts(c)
] ]
if not orphans: if not orphans:
@@ -120,12 +135,12 @@ async def _recorrelate_orphan(call: dict) -> bool:
) )
return True return True
# Increment the attempt counter. Once MAX_SWEEP_ATTEMPTS is reached the # Increment the attempt counter. Once the budget is reached the orphan filter
# orphan filter above will stop picking this call up, and we write # above will stop picking this call up, and we write corr_path="unlinked" as
# corr_path="unlinked" as a permanent tombstone. # a permanent tombstone.
attempts = call.get("corr_sweep_count", 0) + 1 attempts = call.get("corr_sweep_count", 0) + 1
update: dict = {"corr_sweep_count": attempts} update: dict = {"corr_sweep_count": attempts}
if attempts >= 3: if attempts >= _max_sweep_attempts(call):
update["corr_path"] = "unlinked" update["corr_path"] = "unlinked"
await fstore.doc_set("calls", call_id, update) await fstore.doc_set("calls", call_id, update)
return False return False
+54 -27
View File
@@ -100,30 +100,56 @@ async def upload_call_audio(
return {"url": gcs_uri} return {"url": gcs_uri}
# server-26#115 — a rules "new" only counts as a real "this is an event" verdict # server-26#115 — the consensus LLM-orphan gate only fires when the call is
# when it carries one of these signals. A bare "new" (no link candidate found) # genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]`
# is trivially true for radio housekeeping (check-ins, roll call, 10-8/10-98) and # for a "positive signal", but corr_debug is EMPTY at preview time for
# must not out-vote a cheap-LLM "orphan" that has actually read the transcript. # action=="new" (corr_path:"new" is written at APPLY time), so that test was
_POSITIVE_CORR_PATHS = frozenset({ # always False and the gate dropped real events — a major "extinguishing fire",
"unit-continuity", "location", "fast/disambig", "fast/single", # geocoded calls, pursuit updates. The substance test now runs against `ctx`,
}) # which is fully populated at preview time.
_POSITIVE_FIT_SIGNALS = frozenset({"unit_overlap", "location_proximity"})
def _rules_has_positive_event_signal(rules_decision: dict) -> bool: def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
""" """
True when the rules engine's decision carries a positive "this is an event" True when one of the already-loaded recent incidents is running on this
signal (unit overlap, location proximity, or a continuity/disambiguation call's own system + talkgroup. Covers the "unit dispatched on the dispatch
path) rather than merely "no incident to link to". channel, thin acknowledgement 10-30s later" case: the ack carries no
substance of its own but plainly belongs to the job just opened.
Reads ctx["recent"] — the same window-filtered candidate list the rules
engine already loaded — so this adds no Firestore read.
""" """
dbg = rules_decision.get("corr_debug") or {} tg_id = ctx.get("talkgroup_id")
if dbg.get("corr_fit_signal") in _POSITIVE_FIT_SIGNALS: system_id = ctx.get("system_id")
return True if tg_id is None or not system_id:
if dbg.get("corr_path") in _POSITIVE_CORR_PATHS: return False
tg_str = str(tg_id)
for inc in ctx.get("recent") or []:
if system_id in (inc.get("system_ids") or []) and tg_str in (inc.get("talkgroup_ids") or []):
return True return True
return False return False
def _call_is_substanceless(ctx: dict) -> bool:
"""
True when the call carries nothing that marks it as a real event:
• 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.
"""
from app.internal import incident_correlator
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
return False
if incident_correlator.has_event_substance(ctx):
return False
if _recent_incident_on_same_talkgroup(ctx):
return False
return True
async def _correlate_with_consensus( async def _correlate_with_consensus(
call_id: str, call_id: str,
node_id: str, node_id: str,
@@ -176,22 +202,23 @@ async def _correlate_with_consensus(
return await incident_correlator.apply_correlation(preview) return await incident_correlator.apply_correlation(preview)
# server-26#115 — LLM-orphan gate. # server-26#115 — LLM-orphan gate.
# When the cheap LLM says `orphan` and the rules engine says `new` with NO # When the cheap LLM says `orphan`, the rules engine says `new`, and the call
# positive event signal (i.e. rules only found "nothing to link to" — trivially # is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
# true for radio housekeeping), resolve to `orphan` and DO NOT pay for the # no incident already running on this talkgroup), resolve to `orphan` and DO
# smart tiebreaker. The LLM has read the transcript; a bare rules `new` has # NOT pay for the smart tiebreaker. A bare rules `new` there means only
# not, and the tiebreaker sided with rules ~21/21 of the time on exactly this # "nothing to link to" — trivially true for radio housekeeping (check-ins,
# disagreement (CORRELATION_REVIEW_0907b.md). A genuine event the LLM misreads # roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
# as orphan still escalates, because the rules result then carries a real # time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
# signal (unit overlap, location proximity, unit-continuity / disambig). # signal (severity, coords, tags, a live same-talkgroup incident) still
# escalates, so an event the LLM misreads as orphan is not lost.
if ( if (
llm_decision["action"] == "orphan" llm_decision["action"] == "orphan"
and rules_decision["action"] == "new" and rules_decision["action"] == "new"
and not _rules_has_positive_event_signal(rules_decision) and _call_is_substanceless(ctx)
): ):
logger.info( logger.info(
f"Consensus gate for call {call_id}: llm=orphan vs rules=new with no " f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
f"positive rules signal — resolving orphan, skipping tiebreak" f"is substanceless — resolving orphan, skipping tiebreak"
) )
gated = { gated = {
"action": "orphan", "action": "orphan",
+150 -49
View File
@@ -1,18 +1,27 @@
""" """
server-26#115 — two consensus-quality fixes. server-26#115 — two consensus-quality fixes.
Fix 1 (routers/upload.py): when the cheap LLM says `orphan` and the rules engine Fix 1 (routers/upload.py): when the cheap LLM says `orphan`, the rules engine
says `new` with NO positive event signal, resolve to `orphan` and DO NOT pay for says `new`, and the call is genuinely SUBSTANCELESS (routine severity, no
the smart tiebreaker. Radio housekeeping (unit check-ins, roll call, 10-8/10-98) vehicle/geocode/tag, and no incident already running on the same talkgroup),
was being promoted to incidents because the tiebreaker rubber-stamped the rules resolve to `orphan` and DO NOT pay for the smart tiebreaker. Radio housekeeping
`new` ~21/21 of the time (CORRELATION_REVIEW_0907b.md). A genuine event the LLM (unit check-ins, roll call, 10-8/10-98) was being promoted to incidents because
misreads as orphan still escalates, because the rules result then carries a real the tiebreaker rubber-stamped the rules `new` ~21/21 of the time
signal (unit overlap, location proximity, unit-continuity / disambig). (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 Fix 2 (incident_correlator.py): the `location` correlation path linked on a bare
sub-`location_proximity_km` (0.5 km) distance alone. In a dense village two sub-`location_proximity_km` (0.5 km) distance alone, taking whichever incident
unrelated events routinely geocode that close. A `location` link now needs unit came first in an unsorted `recent`. In a dense village two unrelated events
overlap with the candidate OR a distance under a tighter bar. 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 datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -20,8 +29,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.routers import upload from app.routers import upload
from app.routers.upload import _rules_has_positive_event_signal from app.internal.incident_correlator import _run_decision, has_event_substance
from app.internal.incident_correlator import _run_decision
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc) NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
@@ -30,7 +38,10 @@ NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
# Fix 1 — the LLM-orphan gate in _correlate_with_consensus # Fix 1 — the LLM-orphan gate in _correlate_with_consensus
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _preview(action, corr_debug=None): def _preview(action, corr_debug=None, ctx=None):
base_ctx = {"call_id": "call-1"}
if ctx:
base_ctx.update(ctx)
return { return {
"decision": { "decision": {
"action": action, "action": action,
@@ -38,7 +49,7 @@ def _preview(action, corr_debug=None):
"incident_type": "other" if action == "new" else None, "incident_type": "other" if action == "new" else None,
"corr_debug": {} if corr_debug is None else dict(corr_debug), "corr_debug": {} if corr_debug is None else dict(corr_debug),
}, },
"ctx": {"call_id": "call-1"}, "ctx": base_ctx,
} }
@@ -68,7 +79,7 @@ async def _run_consensus(preview, llm_decision):
return m_apply, m_tiebreak return m_apply, m_tiebreak
async def test_llm_orphan_vs_rules_new_no_signal_gates_to_orphan_without_tiebreak(): async def test_substanceless_no_recent_same_tg_incident_gates_without_tiebreak():
m_apply, m_tiebreak = await _run_consensus( m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}), _llm("orphan", "unit check-in, not an incident"), _preview("new", {}), _llm("orphan", "unit check-in, not an incident"),
) )
@@ -84,41 +95,96 @@ async def test_llm_orphan_vs_rules_new_no_signal_gates_to_orphan_without_tiebrea
assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident" assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident"
@pytest.mark.parametrize("fit_signal", [None, "none", "thin_recency"]) @pytest.mark.parametrize("severity", ["moderate", "major"])
async def test_gate_fires_for_every_non_positive_fit_signal(fit_signal): async def test_moderate_or_major_severity_is_not_gated(severity):
dbg = {} if fit_signal is None else {"corr_fit_signal": fit_signal} m_apply, m_tiebreak = await _run_consensus(
m_apply, m_tiebreak = await _run_consensus(_preview("new", dbg), _llm("orphan")) _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() m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan" assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
@pytest.mark.parametrize("corr_debug", [ async def test_call_with_coords_is_not_gated():
{"corr_fit_signal": "unit_overlap"},
{"corr_fit_signal": "location_proximity"},
{"corr_path": "unit-continuity"},
{"corr_path": "fast/disambig"},
])
async def test_positive_rules_signal_still_escalates_to_tiebreak(corr_debug):
m_apply, m_tiebreak = await _run_consensus( m_apply, m_tiebreak = await _run_consensus(
_preview("new", corr_debug), _llm("orphan"), _preview("new", {}, ctx={"coords": {"lat": 41.15, "lng": -73.86}}),
_llm("orphan"),
) )
m_tiebreak.assert_called_once() 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_recent_incident_on_same_talkgroup_is_not_gated():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"recent": [{
"incident_id": "inc-live",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
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(): async def test_llm_link_vs_rules_new_still_escalates():
m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job")) m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job"))
m_tiebreak.assert_called_once() m_tiebreak.assert_called_once()
def test_rules_has_positive_event_signal_predicate(): async def test_llm_orphan_vs_rules_link_still_escalates():
assert _rules_has_positive_event_signal({"corr_debug": {"corr_fit_signal": "unit_overlap"}}) # Not the gate condition (gate needs rules=="new"); must fall through.
assert _rules_has_positive_event_signal({"corr_debug": {"corr_fit_signal": "location_proximity"}}) m_apply, m_tiebreak = await _run_consensus(_preview("link", {}), _llm("orphan"))
assert _rules_has_positive_event_signal({"corr_debug": {"corr_path": "unit-continuity"}}) m_tiebreak.assert_called_once()
assert _rules_has_positive_event_signal({"corr_debug": {"corr_path": "location"}})
assert not _rules_has_positive_event_signal({"corr_debug": {}})
assert not _rules_has_positive_event_signal({"corr_debug": {"corr_path": "new"}}) def test_has_event_substance_predicate():
assert not _rules_has_positive_event_signal({"corr_debug": {"corr_fit_signal": "thin_recency"}}) assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
assert not _rules_has_positive_event_signal({}) 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"})
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -131,15 +197,18 @@ CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000} FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
# ~0.13 km north of the call — inside the tight bar. # ~0.13 km north of the call — inside the tight bar.
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000} 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 _loc_ctx(*, inc_coords, inc_units, call_units): def _inc(incident_id, coords, units):
inc = { return {
"incident_id": "inc-loc", "incident_id": incident_id,
"system_ids": ["sys-1"], "system_ids": ["sys-1"],
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op "talkgroup_ids": ["100"], # different TGID → fast path is a no-op
"location_coords": inc_coords, "location_coords": coords,
"units": inc_units, "units": units,
"tags": [], "tags": [],
"type": "police", "type": "police",
"updated_at": (NOW - timedelta(minutes=6)).isoformat(), "updated_at": (NOW - timedelta(minutes=6)).isoformat(),
@@ -147,10 +216,13 @@ def _loc_ctx(*, inc_coords, inc_units, call_units):
"status": "active", "status": "active",
"call_ids": ["c0"], "call_ids": ["c0"],
} }
def _loc_ctx(*, incidents, call_units):
return { return {
"call_id": "call-loc", "call_id": "call-loc",
"all_active": [inc], "all_active": list(incidents),
"recent": [inc], "recent": list(incidents),
"call_doc": {}, "call_doc": {},
"call_embedding": None, "call_embedding": None,
"call_units": call_units, "call_units": call_units,
@@ -172,24 +244,53 @@ def _loc_ctx(*, inc_coords, inc_units, call_units):
} }
def test_location_path_shared_area_no_unit_overlap_no_proximity_does_not_link(): def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
ctx = _loc_ctx(inc_coords=FAR_INC_COORDS, inc_units=["7-Adam"], call_units=["3-Boy"]) 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) 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["action"] != "link"
assert (decision.get("corr_debug") or {}).get("corr_path") != "location" assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
def test_location_path_links_on_unit_overlap(): def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
ctx = _loc_ctx(inc_coords=FAR_INC_COORDS, inc_units=["5-Adam"], call_units=["5-Adam"]) ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
call_units=["5-Adam"],
)
decision = _run_decision(ctx) decision = _run_decision(ctx)
assert decision["action"] == "link" assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location" assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "unit_overlap" # 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(): def test_location_path_links_on_tight_proximity_without_unit_overlap():
ctx = _loc_ctx(inc_coords=NEAR_INC_COORDS, inc_units=["7-Adam"], call_units=["3-Boy"]) ctx = _loc_ctx(
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
decision = _run_decision(ctx) decision = _run_decision(ctx)
assert decision["action"] == "link" assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location" assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity" 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"