Core concepts

A TriageSim run is a small, auditable simulation loop. This page explains the pieces and how control flows between them, which makes the rest of the documentation much easier to read.

The hidden ground truth

Every run starts from a structured clinical vignette:

ground_truth = {
    "chiefcomplaint": "Syncope",
    "vitals": {"temperature": 99.1, "heartrate": 112, "resprate": 26,
               "o2sat": 91, "sbp": 98},
    "acuity": 2,
    "pain": 7,
}

Neither agent receives this dictionary. The patient agent is given only chiefcomplaint and pain, and must express them in character. The nurse agent is given nothing at all — it starts from an empty transcript.

Vitals are released by the environment, not spoken by the patient: when the nurse takes a check_vital action, the environment reads the true value out of ground_truth["vitals"] and appends it to the transcript as a system event. acuity is never released; it exists purely so the run can be scored afterwards.

This separation is what makes the data useful. The dialogue is generated, but the label it should have produced is known exactly.

The environment

TriageEnv (in triagesim.core.environment) is the single source of truth. It owns the belief graph, applies nurse actions, releases vitals, tracks red flags, and persists everything through a StateStore. Its three methods are reset(ground_truth) -> run_id, observe(run_id), and step(run_id, actor, action).

TriageRunner deliberately contains no clinical logic. It only decides whose turn it is. Everything that could affect a triage outcome lives in the environment, where it is schema-validated and recorded.

Structured actions, not free text

The nurse model does not act directly. It emits a NurseOutput — a Pydantic model — which map_nurse_output_to_action converts into exactly one executable action:

Nurse actionExecutable actionEffect
utteranceNurseUtteranceActionAppends the nurse's line to the transcript and hands control to the patient.
check_vitalNurseCheckVitalActionReleases one vital from the ground truth as a system event.
log_red_flagNurseLogRedFlagActionRecords clinical red flags.
endNurseEndActionTerminates the run with a final triage level.

Invalid combinations are rejected rather than guessed at: an utterance with no text, or a log_red_flag with no flags, raises ActionMappingError. This mapping layer is what prevents a model from taking an action the environment never sanctioned.

Why an intermediate representation

NurseOutput is what the model is good at producing; the action classes are what the environment can safely execute. Keeping them separate means a malformed generation fails loudly at the boundary instead of corrupting the run state.

Vitals

Only five vitals exist, defined as ALLOWED_VITALS:

temperature, heartrate, resprate, o2sat, sbp

Which one the nurse asked for is inferred from its utterance by keyword matching — "pulse" and "heart rate" both resolve to heartrate, "bp" and "blood pressure" to sbp, "oxygen" and "saturation" to o2sat, and so on. If several are mentioned, the first match wins; if none match, the vital resolves to None.

Setting ENABLE_LLM_DETECTORS = True in triagesim.config adds a model-based fallback when the keyword rules find nothing. See Configuration.

The belief graph

BeliefGraph is the environment's record of what the nurse has actually learned. It is provenance-aware: each slot value carries the turn it was acquired on, whether it came from the patient, the nurse or a system vital, and whether it was explicit, inferred, or suspected.

Updates are merged deterministically even though extraction is probabilistic: None values are dropped, exact (slot, value) duplicates are ignored, and genuinely different values for the same slot are both kept so ambiguity survives rather than being silently resolved.

Two things are tracked as special sets: vitals_known (which vitals have been released) and red_flags_logged (flags the nurse explicitly logged).

to_observation() flattens the graph into the lossy, provenance-free view that agents and metrics consume — this is what appears under the belief key of a run artifact.

Turn structure

The loop is asymmetric, and this catches people out:

The nurse may act several times before the patient responds. Checking a vital or logging a red flag is a micro-turn — it does not advance the turn counter or hand over control. Only a nurse utterance does that.

turn 0  nurse: check_vital  -> system releases heartrate = 112   (micro-turn)
turn 0  nurse: log_red_flag -> "tachycardia" recorded            (micro-turn)
turn 0  nurse: utterance    -> "Did you hit your head?"          (yields)
turn 0  patient: utterance  -> "No, my partner caught me."       (turn -> 1)

The turn counter advances only on a patient action, so max_turns counts patient responses, not model calls. After each patient utterance the nurse infers belief updates from what was said, and the environment merges them.

A run ends when the turn counter reaches max_turns, or when the nurse takes the end action. A hard safety cap of max_turns * 4 total steps guarantees termination even if an agent misbehaves.

Red-flag logging is deduplicated: flags are normalised to lowercase, whitespace-collapsed strings, repeats within a turn and across earlier turns are discarded, and at most five new flags are accepted per turn.

Triage algorithms

The nurse agent reasons with one of two protocols, selected at construction:

nurse = NurseAgent(llm=nurse_llm, persona=persona, algorithm="esi")

The Emergency Severity Index, used widely in the United States. Level 1 is the most acute.

nurse = NurseAgent(llm=nurse_llm, persona=persona, algorithm="ats")

The Australasian Triage Scale, used in Australia and New Zealand. Level 1 is the most acute.

Both scales run from 1 to 5 and NurseOutput.triage is constrained to that range, but the reasoning prompt differs substantially. Because lower means more urgent on both, predicting a number below the true acuity is over-triage — a convention that matters when reading the metrics.

Persistence

All state lives behind the StateStore interface, under keys namespaced by run:

triage:{run_id}:state
triage:{run_id}:belief_graph
triage:{run_id}:history
triage:{run_id}:trace
triage:{run_id}:red_flags

InMemoryStateStore keeps these in a dictionary; RedisStateStore keeps them in Redis so runs outlive the Python process. Both implement the same five operations, so switching is a one-line config change. See Running a simulation.

Putting it together

flowchart TD
    GT[Ground truth vignette] -->|hidden| ENV[TriageEnv]
    ENV -->|transcript| NA[NurseAgent]
    NA -->|NurseOutput| MAP[map_nurse_output_to_action]
    MAP -->|executable action| ENV
    ENV -->|vital released| ENV
    ENV -->|transcript| PA[PatientAgent]
    PA -->|PatientOutput| ENV
    ENV --> BG[(BeliefGraph)]
    ENV --> SS[(StateStore)]
    SS --> ART[Run artifact]

Next: Personas covers how the two agents are conditioned, and Run artifacts covers what comes out the other end.