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

CORRELATION_REVIEW_0907b.md measured that radio housekeeping (unit
check-ins, roll call, 10-8/10-98 clearings) is being promoted to
incidents. Every case reads corr_llm_action=orphan, corr_rules_action=new,
corr_consensus=tiebreak -> new: the cheap LLM correctly reads "not an
incident", the rules engine says `new` only because there is no incident to
link to, and the smart tiebreaker then sides with rules ~21/21. Reframing
the tiebreaker prompt (#116) did nothing. The fix is a consensus-logic gate,
not another prompt.

Fix 1 (routers/upload.py) - LLM-orphan gate in _correlate_with_consensus:
when the cheap LLM says `orphan` and the rules engine says `new` with NO
positive event signal, resolve to `orphan` and skip the tiebreak call
entirely. "No positive signal" = the rules corr_debug carries neither a
positive corr_path (unit-continuity / location / fast/disambig / fast/single)
nor a positive corr_fit_signal (unit_overlap / location_proximity). When it
does carry one, the existing escalation-to-tiebreak is kept so a genuine
event the LLM misreads as orphan still gets the second look. The resolved
outcome records corr_consensus="llm_orphan_gate" (greppable, distinct from
"tiebreak") and keeps corr_llm_reasoning / corr_rules_action /
corr_llm_action populated.

Fix 2 (incident_correlator.py) - tighten corr_path=location: the location
path linked on a bare sub-location_proximity_km (0.5 km) distance with no
unit or content check, which stitched a vehicle lockout to a station-restroom
slip and merged two different churches an hour apart. A location link now
requires unit overlap with the candidate OR a distance under a tighter bar
(_LOCATION_TIGHT_PROXIMITY_KM = 0.2 km). Pursuit incidents keep their
movement-speed-validated wide radius. A surviving location link now also
writes corr_fit_signal (unit_overlap | location_proximity), consistent with
Fix 1's positive-signal set.

Tests: new tests/test_consensus_gate.py (13 cases) - the gate resolves to
orphan without calling tiebreak on a no-signal disagreement; a unit_overlap /
location_proximity / unit-continuity / fast-disambig rules signal still
escalates; llm=link vs rules=new still escalates; the location path drops a
shared-area candidate with neither unit overlap nor tight proximity, links on
unit overlap, and links on tight proximity alone. Full c2-core suite
309 -> 322 passing.

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:32:52 -04:00
co-authored by Claude Sonnet 5
parent 7f4d684966
commit ca1d8fbdae
3 changed files with 282 additions and 1 deletions
@@ -91,6 +91,13 @@ def _max_severity(current: Optional[str], new: Optional[str]) -> str:
_MAX_PURSUIT_SPEED_KM_PER_MIN = 8.0 # ~300 km/h, intentionally generous
_PURSUIT_PROXIMITY_KM = 20.0 # expanded radius for moving incidents
# server-26#115 — the location path linked on `location_proximity_km` (0.5 km)
# alone, with no unit or content check. In a dense village two unrelated events
# routinely geocode that close (a vehicle lockout stitched to a station-restroom
# slip; two different churches an hour apart). A location link now needs unit
# 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
@@ -1190,15 +1197,38 @@ def _run_decision(ctx: dict) -> dict:
if (dist_km / elapsed_min) > _MAX_PURSUIT_SPEED_KM_PER_MIN:
continue # implausible speed — skip this candidate
if dist_km <= radius:
# server-26#115 — a bare sub-radius distance is not enough on its
# own. Require corroboration: unit overlap with the candidate, OR
# a much tighter proximity. Pursuit incidents keep their
# movement-speed-validated wide radius (they passed the speed
# check above), so they are exempt.
unit_overlap = bool(
_unit_keys(call_units) & _unit_keys(inc.get("units"))
)
tight_proximity = dist_km <= _LOCATION_TIGHT_PROXIMITY_KM
if not (is_pursuit_inc or unit_overlap or tight_proximity):
logger.info(
f"Correlator location-path skipped: call {call_id} vs "
f"{inc['incident_id']} — dist={dist_km:.2f}km within radius "
f"but no unit overlap and not tight-proximity "
f"(<= {_LOCATION_TIGHT_PROXIMITY_KM}km)"
)
continue
matched_incident = inc
fit_signal = "unit_overlap" if unit_overlap else "location_proximity"
corr_debug = {
"corr_path": "location",
"corr_distance_km": round(dist_km, 3),
"corr_pursuit_mode": is_pursuit_inc,
"corr_fit_signal": fit_signal,
}
if unit_overlap and call_units:
corr_debug["corr_matched_units"] = _matching_units(
call_units, inc.get("units")
)
logger.info(
f"Correlator location-path: call {call_id} → {inc['incident_id']} "
f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc})"
f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc}, signal={fit_signal})"
)
break