Stage 3: reconciliation

Until now one report meant one incident. From stage 3 onwards, expect duplicates, delayed calls, false alarms, red herrings, and incorrect locations, all in the same stream. Something has to decide which calls are real fires, which calls have mistakes, and which calls should be ignored. That something is ordinary code: no model call, no judgement.

Reports matched to incidents by distance and time, with truck vision correcting a wrong match

The store methods you built on Day 1 stay exactly as they are. Reconciliation sits above them and uses IncidentStore.find(near=..., radius=..., incident_type=...), to match reports to incidents.

What needs to be built

Everything lives in reconcile.py, which ships the vocabulary and the signatures and none of the logic:

  1. MATCH_RADIUS and TIME_WINDOW, both None at the top of the module. A report within that many world units and that many ticks of an incident may be the same event. Picking the two numbers is most of the work.
  2. reconcile_reports(reports, incidents, report_store, tick) - fold each new report into the incident store per the decision table below, append every report to report_store, and return one Outcome per report for the log.
  3. reconcile_vision(observations, incidents, tick, coords_of) - apply truck-vision, which is always accurate and outranks any human claim.
  4. The reconcile hole in runner._process_tick - replace Stage 1’s one-incident-per-report with a call to reconcile_reports, which should a. resolve each observation’s buildings to points, and b. call reconcile_vision. (Add the import if necessary.)
  5. Two more belief fields on Incident: a contradiction flag and whatever confidence field you designed in the PRD. Stage 1 already grew the rest of both dataclasses - severity, headcount, headcount_qualifier, and building_id on Incident, and the matching extraction fields on Report - the moment the runner started opening incidents from reports. Check the REC-* scenarios.

NOTE: Reconciliation must be pure and testable without an engine. Call building_to_coords before intake, and accept coords is None when a location cannot be resolved at all.

The decision table

reconcile_reports, per report:

Report type Match found? Action
none no open incident near IGNORED - a denial on its own opens nothing
none open incident near FALSE_ALARM - link the report, keep the incident open
fire or unknown no match OPEN a new incident, link the report
fire or unknown match link the report, then merge

Merging an already-matched report:

  • an unknown incident meeting a fire report - UPDATE the incident type
  • a higher severity band than the belief holds - UPDATE
  • all_out against confirmed_trapped - CONTRADICTION: retain the more cautious belief and flag it
  • a headcount where none was held yet - UPDATE, adopt it
  • anything else - DUPLICATE

The none row is the one people get wrong. A denial should be treated as weak evidence, not absolute proof that nothing is happening.

reconcile_vision, per observed building:

  • seen normal - close a co-located incident, RESOLVED_VISION
  • seen burning - confirm or open one, CONFIRMED_VISION
  • seen collapsed - terminal. The building is lost and there is nothing left to dispatch to, so take no reconcile action.

Tuning the two thresholds

Once you pass the REC-* scenario fixtures, then tune MATCH_RADIUS against the live map. The REC-* scenarios all sit inside a 100-unit box, so almost any radius passes them. Londone is a different shape: neighbouring buildings are only tens of world units apart, while the incidents in stage3-partial@londone are hundreds apart. If you set the radius too low, you may send two trucks to a small fire. If you set the radius too high, you may fuse two genuinely separate fires into one belief, send only one truck to two burning buildings, and lose the second.

Bad merges are normal and realistic. Lean on the truck-vision override to self-correct the bad merge.

The belief-strength PRD

Return to the product brief you wrote on Day 1. When reports disagree, what should the dispatcher be able to understand and decide? What uncertainty must remain visible? When may the system continue, and when should it warn or hold?

Do not repeat the full product interview. Revisit only the new decision introduced by this stage, then write a short PRD for the behaviour the operator needs. Cover how a belief opens, how a corroborating report strengthens it, how a false alarm or a denial weakens it without closing it, and how truthful truck vision overrides it. Only then choose the confidence field or rule that supports that behaviour.

Put your belief-strength rule into the reconciliation code. Write scenarios that show what the operator sees after corroboration, denial, contradiction, and truck vision. You must deliver the rule, the code, and the tests.

Activity: Reconcile deterministically

  1. Write the PRD first. Keep it to one page. Include what the operator sees and may decide, not just how the confidence field changes.

  2. Grow the dataclasses. Add the belief fields your PRD needs to Report and Incident. IncidentStore.open and update already take **fields.

  3. Implement both functions as ordinary code. If you find yourself reaching for a model call, the design has gone wrong: identical inputs and identical incident state must produce identical links, run after run.

  4. Pass the scenarios. uv run behave features/reconcile.feature covers REC-1 to REC-8: open, merge-and-escalate, contradiction, denial-keeps-open, lone denial, vision-closes, vision-opens, and unknown-upgraded-in-place.

  5. Add fixtures of your own for the cases the eight do not cover - a delayed report, a red herring, and a spatially inconsistent exact location - and preserve conflicting claims rather than silently overwriting them. Worth knowing: stage3-partial@londone contains no denial at all, so FALSE_ALARM and IGNORED are only ever exercised by fixtures. Live play will never tell you those two branches are broken.

  6. Run it live and tune the thresholds against what you see:

    cd ../engine && uv run hadr-engine serve   # in its own terminal
    uv run hadr-runner stage3-partial@londone
    

    Both functions return their Outcome list and the runner throws it away, so bind it and print it while you tune. A run full of OPEN means your radius is too small; one incident swallowing the map means it is too large.

  7. Checkpoint. The reconciliation helper-prompt stub is the intermediate checker for this stage - use it to confirm reconciliation really is in code and not smuggled into a prompt.

When you’re done

  1. Open a pull request referencing the REC-* scenario IDs it satisfies, and have a teammate review the thresholds as well as the code. Two numbers with no argument behind them are the most likely thing on this page to be wrong, and the person who did not pick them is the one who will notice.
  2. Post your MATCH_RADIUS and TIME_WINDOW on the Padlet, with the casualties and buildings lost they produced on stage3-partial@londone. Everyone tuned against the same map: if someone’s radius is an order of magnitude off yours and scores better, that is worth more than any amount of arguing about it in the abstract.
  3. Trade fixtures. Post your single best one from step 5 and take a couple from other people into your own suite. Expect at least one adopted fixture to fail on first run, that is the whole point of the trade.