Stage 3 reference solution

Three parts: add a belief-strength field, implement deterministic reconciliation, and swap the runner’s naive one-incident-per-report loop for it.

Belief strength

Add a confidence field (belief strength in [0, 1]) to the Incident dataclass - the starter ships none.

    confidence: float = 0.5  # belief strength in [0, 1]

A fire opens at 0.6, an unknown at 0.4; corroboration adds 0.15, a nearby denial subtracts 0.25 (never below 0, never auto-closing), and truthful truck vision pins it to 1.0 or closes the incident.

Thresholds

MATCH_RADIUS: float = 60.0
TIME_WINDOW: int = 12
OPEN_CONFIDENCE = {"fire": 0.6, "unknown": 0.4}
CORROBORATE = 0.15
FALSE_ALARM_PENALTY = 0.25

These are a tuning choice, not a given: any values that keep the deterministic fixtures separate work for the tests, but the live stage3-partial@londone map spaces buildings hundreds to thousands of world units apart, so pick a radius sane for the map and let the vision override catch a wrong merge rather than a radius that always does.

reconcile_reports

Per the decision table in reconcile.py’s docstring. A denial never opens an incident; if one sits near an open belief it links as a possible false alarm and demotes confidence, never overwriting a live belief. A fire/unknown report opens a new incident or merges into the nearest compatible one (nearest-first, within radius and time window).

def _reconcile_one(r, incidents, tick):
    if r.coords is None:
        return Outcome(IGNORED, None, r.contact_id, "no resolvable location")
    if r.incident_type == "none":
        match = _nearest_open(r.coords, incidents, tick)
        if match is None:
            return Outcome(IGNORED, None, r.contact_id, "no fire, no incident")
        incidents.link(match.incident_id, r.contact_id, tick)
        incidents.update(match.incident_id, tick=tick,
                         confidence=max(0.0, match.confidence - FALSE_ALARM_PENALTY))
        return Outcome(FALSE_ALARM, match.incident_id, r.contact_id, "denial near a suspected incident")
    match = _best_match(r, incidents, tick)
    if match is None:
        inc = incidents.open(incident_type=r.incident_type, coords=r.coords, tick=tick,
                             building_id=_building_id(r), severity=_sev_value(r),
                             headcount=_head_value(r), headcount_qualifier=_head_qualifier(r),
                             confidence=OPEN_CONFIDENCE.get(r.incident_type, 0.4))
        incidents.link(inc.incident_id, r.contact_id, tick)
        return Outcome(OPEN, inc.incident_id, r.contact_id)
    incidents.link(match.incident_id, r.contact_id, tick)
    return _merge(match, r, incidents, tick)

_merge upgrades an unknown belief to fire, keeps the max severity band, and on an all_out-vs-trapped headcount clash flags a contradiction while retaining the MORE cautious belief (never silently overwriting). It also adopts a headcount claim when the incident holds none yet, and nudges confidence up by CORROBORATE.

reconcile_vision (ground truth)

A building seen normal closes any co-located incident (RESOLVED_VISION); burning confirms or opens one at confidence 1.0 (CONFIRMED_VISION). collapsed is terminal - the building is lost, nothing left to serve - and the reference takes no reconcile action on it.

Runner

Replace the naive stage-1 loop: reconcile_mod.reconcile_reports(new_reports, ep.incidents, ep.reports, tick), then resolve each observation building to a point and call reconcile_mod.reconcile_vision(...) so reconciliation stays pure.

Verified: REC-1..8 green against the solution (cd starter && uv run --project ../solution behave features/reconcile.feature); ruff/pyright clean; reconciliation is deterministic (identical inputs -> identical links). A live stage3-partial@londone episode resolves at casualties 0 / buildings_lost 0, improving on the greedy baseline of 26 / 1.