Stage 1 reference solution

Four holes: a minimal _finalize (_project_claim ships working and routes through it), the runner’s stage-1 tick (_process_tick), the state message (_state_message), and the system prompt. The stores and the agent loop are Day 1 work and already pass.

Intake: project a structured claim (no model call)

_project_claim ships working; _finalize is what you write. It copies the envelope authority fields (contact_id, reported_location) from the contact - never from the payload - and enforces the report schema, so a none report carries no fire severity or headcount.

def _finalize(fields: dict, contact: dict) -> dict:
    it = fields.get("incident_type")
    if it not in ("fire", "none", "unknown"):
        it = "unknown"
    report = {
        "contact_id": contact["contact_id"],
        "reported_location": contact["reported_location"],
        "incident_type": it,
        "severity": fields.get("severity"),
        "headcount": fields.get("headcount"),
        "event_time": fields.get("event_time"),
        "notes": fields.get("notes") or "",
    }
    if it == "none":
        report["severity"] = None
        report["headcount"] = None
    return report


def _project_claim(contact: dict) -> dict:
    claim = contact["payload"]["claim"]
    fields = {
        "incident_type": claim.get("incident_type"),
        "severity": claim.get("severity"),
        "headcount": claim.get("headcount"),
        "event_time": claim.get("event_time"),
    }
    return _finalize(fields, contact)

_finalize carries most of its weight at Stage 2, against a model that invents fields. The version above is the minimum INT-2/INT-3 need; Stage 2 extends it rather than replacing it.

Runner: one incident per report

Stage 1 has no reconciliation. Each fire/unknown report with a resolvable location opens its own incident and links the contact; denials and unlocated reports open nothing. Truck-vision observations are collected but not yet consumed (Stage 3).

    new_reports: list[Report] = []
    for c in new_human:
        fields, usage = intake_mod.extract_report(c, client=ep.intake_llm)
        ep.intake_tokens.add(usage)
        coords = await ep.location_coords(fields.get("reported_location"))
        new_reports.append(Report(
            contact_id=fields["contact_id"],
            reported_location=fields["reported_location"],
            incident_type=fields["incident_type"],
            severity=fields.get("severity"), headcount=fields.get("headcount"),
            event_time=fields.get("event_time"), source_type=c.get("source_type"),
            coords=coords, tick=tick, notes=fields.get("notes", ""),
        ))

    for r in new_reports:
        ep.reports.add(r)
        if r.incident_type not in ("fire", "unknown") or r.coords is None:
            continue
        loc = r.reported_location
        bid = loc["building_id"] if loc.get("kind") == "building_id" else None
        inc = ep.incidents.open(
            r.incident_type, r.coords, tick=tick, building_id=bid,
            severity=(r.severity or {}).get("value"),
            headcount=(r.headcount or {}).get("value"),
            headcount_qualifier=(r.headcount or {}).get("qualifier"),
        )
        ep.incidents.link(inc.incident_id, r.contact_id, tick=tick)

State message: pre-reconcile for the weak model

The trap here is the truck dict shape. Live engine vehicle observations nest coordinates under position, carry a route that is None while the truck is parked, and keep an incident_id the engine never clears. The DISP-* fixture is built to match, so t["x"] raises there. Read t.get("position", {}) and t.get("incident_id"), and gate coverage on status as well.

Rank open fire/unknown incidents people-first (occupants * severity_weight), mark each COVERED/UNCOVERED by whether a committed truck already serves it, mark trucks FREE/committed, and end with a one-line allocation rule.

    open_inc = [i for i in incidents.find(status="open")
                if i.incident_type in ("fire", "unknown")]
    covered = {t["incident_id"] for t in trucks
               if t.get("incident_id") and t["status"] in ("moving", "firefighting")}

    sev_w = {"high": 3.0, "medium": 2.0, "low": 1.0}
    def occupants(i):
        return 0 if i.headcount_qualifier == "all_out" else (i.headcount or 1)
    def priority(i):
        return occupants(i) * sev_w.get(i.severity, 1.5)

    lines = [f"tick={tick}", "", "OPEN INCIDENTS (highest priority first):"]
    for i in sorted(open_inc, key=priority, reverse=True):
        cover = "COVERED" if i.incident_id in covered else "UNCOVERED"
        lines.append(f"  {i.incident_id} [{cover}] type={i.incident_type} "
                     f"at x={i.coords[0]:.1f} y={i.coords[1]:.1f} "
                     f"priority={priority(i):.1f} severity={i.severity}")
    lines += ["", "TRUCKS:"]
    for t in trucks:
        pos = t.get("position", {})
        # The same status gate as `covered`: an idle truck still carrying the
        # incident_id of a fire it put out is FREE, not committed.
        state = "committed" if t.get("incident_id") in covered else "FREE"
        lines.append(f"  {t['vehicle_id']} [{state}] status={t['status']} "
                     f"at x={pos.get('x', 0):.0f} y={pos.get('y', 0):.0f}")
    lines += ["", "Assign each FREE truck to the highest-priority UNCOVERED "
              "incident it can reach, ONE truck per incident. Do nothing for a "
              "truck if every incident is already covered."]
    return "\n".join(lines)

Stage 3 folds a confidence (belief-strength) factor into priority; at Stage 1 the field does not exist yet, so priority is occupants * severity only. Stage 4 splits committed into two labels - see its reference solution.

System prompt

Fill the five dispatch_system.md TODO sections. The one that changes outcomes is the allocation rule: one free truck to the highest-priority uncovered incident, one truck per incident, do not stack. State truck mechanics (idle/moving/firefighting, retarget on dispatch, coordinates only), the uncertainty framing (incidents are beliefs, truck vision is truthful), and the requirement that every dispatch carries a real incident_id and a one-sentence rationale.

The completed prompt (the confidence factor in Priorities only becomes meaningful once Stage 3 adds the field; before that the model simply never sees one):

You are the dispatch controller for a fire-only emergency simulation. Each tick you receive the current incident beliefs and truck states, and you allocate a small fleet of trucks to save occupants and buildings. You act ONLY through the provided tools. You never call next_tick; the outer runner owns the clock.

## What you control

There are 2 trucks. A truck is `idle`, `moving`, or `firefighting`. Firefighting is positional: a stationary truck automatically fights the nearest burning building within the suppression radius. Suppression is slow, so a truck that reaches a real fire is committed to it for a long time. Dispatching a moving or firefighting truck RETARGETS it. Trucks have no home base; "standing down" is just a dispatch to a point.

Destinations are world coordinates only. Convert a building ID to a point with `building_to_coords` before dispatching. Use `travel_time` to compare how far each truck is from a target.

## The world is uncertain

Incidents are BELIEFS built from noisy human reports, not ground truth. An incident may be a false alarm, a duplicate, or wrong about location or headcount. Truck vision is truthful: once a truck can see a building, its status is certain. Prefer committing trucks to high-confidence, high-stakes incidents; be willing to spend a truck's travel time to verify an uncertain-but-dangerous report only when you can spare it.

## Priorities

Rank open fire/unknown incidents by expected harm:

    priority ~= occupants_at_risk * severity * time_urgency * confidence

- occupants_at_risk: headcount, weighting confirmed_trapped > possibly_trapped > unknown > all_out(0). People come first.
- severity and time_urgency: high severity and fast-growing fires collapse sooner; a fire close to collapse with people inside is the top priority.
- confidence: discount low-confidence or contradicted incidents, but never ignore a high-stakes one outright.
- Do NOT dispatch to `none` incidents or to incidents vision has resolved as normal.

## Committing vs. holding

- Assign each free truck to the highest-priority uncovered incident it can reach.
- Do not pull a truck off a fire it is actively suppressing UNLESS a clearly higher-priority incident (more lives, near collapse) is uncovered and this truck is the best one to take it - then retarget and say why.
- A truck marked REDIRECTABLE is serving an incident that has dropped below the top of the list; it is not a commitment worth protecting. Retarget it to any uncovered incident that outranks the one it serves, even if it is still on the road. Chasing an unconfirmed report to the end while a bigger fire waits is the most expensive mistake available to you.
- If every truck is committed and a new fire appears, decide explicitly: accept the delay, or redirect the least-valuable committed truck. State the tradeoff in the rationale.
- When two trucks could take one fire, send the nearer; keep the other for coverage.
- If there is nothing worth doing, stop without dispatching.

## Rules

- EVERY dispatch MUST carry the `incident_id` it serves and a one-sentence `rationale` naming the incident, the tradeoff, and why this truck. A dispatch without a real filed incident_id is a bug.
- Keep reasoning short. You have a bounded number of tool rounds per tick; spend them on the queries you need, then dispatch and stop.
- Re-dispatching a truck to where it is already heading is wasteful - only issue a command that changes something.

Verified: DISP-1, DISP-2, INT-2, INT-3 pass against the solution (cd starter && uv run --project ../solution behave features/dispatch.feature features/intake.feature); ruff and pyright clean; a live stage1-basic@londone episode (seed 777) resolves at casualties 5 / buildings_lost 1 (intake 0 tokens - structured claims skip the model), improving on the greedy baseline of 40 / 1.