correlator: fix consensus orphan-gate to test call substance, not empty corr_debug (#115)

The gate added in ca1d8fb checked rules_decision["corr_debug"] for a positive
signal, but that dict is empty at preview time for action=="new" (corr_path is
written at apply time). The check was always False, so the gate fired on real
events — replayed against corr_dump_9-7_pm.json it dropped ~36 linked calls
including a major "extinguishing fire", a moderate fire-alarm, geocoded calls
and pursuit updates.

Gate now runs against ctx (fully populated at preview time). It fires ONLY when
the call is substanceless: routine severity, no vehicle/geocode/tag, and no
incident already running on the same talkgroup. Any of those escalates to the
tiebreak instead. The substance predicate (has_event_substance) is factored out
of incident_correlator's creation gate and shared, so the two cannot diverge.

recorrelation_sweep: a call the gate parked gets a longer link-only retry budget
(10 vs 3) — the gate fires before any incident for the job exists, so the
substantive call that justifies linking can land after the standard ~6 min.
Still create_if_new=False.

incident_correlator location path: evaluate every in-radius candidate and link
the nearest that carries corroboration, instead of the first in an unsorted
`recent`. A unit-overlap location link is now tagged "location_unit_overlap" so
it stops merging into the fast path's bucket in the admin fit-signal histogram.

tests/test_consensus_gate.py: replaced the corr_debug-signal cases with ctx
substance cases (severity, coords, tags, vehicles, same-tg incident); added a
nearest-wins location test; the two location guard tests now assert they reach
the new guard. Full drb-c2-core suite 322 -> 325.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
This commit is contained in:
Logan Cusano
2026-09-07 23:50:41 -04:00
co-authored by Claude Sonnet 5
parent ca1d8fbdae
commit dd426572fc
4 changed files with 290 additions and 119 deletions
+55 -28
View File
@@ -100,30 +100,56 @@ async def upload_call_audio(
return {"url": gcs_uri}
# server-26#115 — a rules "new" only counts as a real "this is an event" verdict
# when it carries one of these signals. A bare "new" (no link candidate found)
# is trivially true for radio housekeeping (check-ins, roll call, 10-8/10-98) and
# must not out-vote a cheap-LLM "orphan" that has actually read the transcript.
_POSITIVE_CORR_PATHS = frozenset({
"unit-continuity", "location", "fast/disambig", "fast/single",
})
_POSITIVE_FIT_SIGNALS = frozenset({"unit_overlap", "location_proximity"})
# server-26#115 — the consensus LLM-orphan gate only fires when the call is
# genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]`
# for a "positive signal", but corr_debug is EMPTY at preview time for
# action=="new" (corr_path:"new" is written at APPLY time), so that test was
# always False and the gate dropped real events — a major "extinguishing fire",
# geocoded calls, pursuit updates. The substance test now runs against `ctx`,
# which is fully populated at preview time.
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"
signal (unit overlap, location proximity, or a continuity/disambiguation
path) rather than merely "no incident to link to".
True when one of the already-loaded recent incidents is running on this
call's own system + talkgroup. 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.
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 {}
if dbg.get("corr_fit_signal") in _POSITIVE_FIT_SIGNALS:
return True
if dbg.get("corr_path") in _POSITIVE_CORR_PATHS:
return True
tg_id = ctx.get("talkgroup_id")
system_id = ctx.get("system_id")
if tg_id is None or not system_id:
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 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(
call_id: str,
node_id: str,
@@ -176,22 +202,23 @@ async def _correlate_with_consensus(
return await incident_correlator.apply_correlation(preview)
# server-26#115 — LLM-orphan gate.
# When the cheap LLM says `orphan` and the rules engine says `new` with NO
# positive event signal (i.e. rules only found "nothing to link to" — trivially
# true for radio housekeeping), resolve to `orphan` and DO NOT pay for the
# smart tiebreaker. The LLM has read the transcript; a bare rules `new` has
# not, and the tiebreaker sided with rules ~21/21 of the time on exactly this
# disagreement (CORRELATION_REVIEW_0907b.md). A genuine event the LLM misreads
# as orphan still escalates, because the rules result then carries a real
# signal (unit overlap, location proximity, unit-continuity / disambig).
# 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 this talkgroup), resolve to `orphan` and DO
# NOT pay for the smart tiebreaker. A bare rules `new` there means only
# "nothing to link to" — trivially true for radio housekeeping (check-ins,
# roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
# signal (severity, coords, tags, a live same-talkgroup incident) still
# escalates, so an event the LLM misreads as orphan is not lost.
if (
llm_decision["action"] == "orphan"
and rules_decision["action"] == "new"
and not _rules_has_positive_event_signal(rules_decision)
and _call_is_substanceless(ctx)
):
logger.info(
f"Consensus gate for call {call_id}: llm=orphan vs rules=new with no "
f"positive rules signal — resolving orphan, skipping tiebreak"
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
f"is substanceless — resolving orphan, skipping tiebreak"
)
gated = {
"action": "orphan",