Skip to content

Clinical triage: discharge, escalate, or wait for a clinician

An emergency department's triage score says a patient is low risk (can go home with advice) or high risk (needs a monitored bed now). Acting on the score automatically frees nurses for the sickest patients, but a wrong discharge is the most expensive mistake an ED makes: the patient deteriorates at home and comes back worse. This page shows how the library lets a discharge or escalation go ahead automatically only when the department has a large, consistent history of similar presentations, and keeps the patient in intake for a clinician otherwise. It is written for an ED medical director or charge-nurse lead; the maths is in collapsed boxes and linked pages.

At a glance

Decision Discharge (discharge) or move to a monitored bed (escalate).
Owner ED medical director; the charge nurse handles every hold.
Today's rule Score says low_risk: discharge. Score says high_risk: escalate.
What this adds Before acting on the score, check that recent visits with similar vital signs are numerous and closely linked, and that their 72-hour outcomes agree. Otherwise keep the patient in intake and page the charge nurse.
Demo outcome 3 patients: 2 acted on, 1 held (a low-risk score on a presentation the department has barely seen).
Example id clinical_triage (uv run grounded-decision examples clinical_triage, or make demo)

Decision support, not a medical device

This is a worked example on synthetic data. Nothing here has been clinically validated, and the library carries no regulatory clearance. In a real department, an automatic discharge pathway is clinical decision support subject to your governance, validation and local regulation. Treat "act" as "the system may recommend this without a second look", and decide with your clinical safety officer which actions, if any, may run without a clinician.

The decision today

The triage model scores each arrival with the same chief complaint (here, chest pain). Today's rule acts on the score directly.

Mistake What it costs
Discharge a patient who deteriorates A 72-hour return, admission or ICU transfer; patient harm; a serious incident review.
Escalate a patient who was fine A monitored bed and nursing time taken from someone who needed it.
Hold for a clinician The patient waits in intake and the charge nurse spends time on the review (see What happens next).

Where today's rule goes wrong

Take three arrivals against the same 90-day history of chest-pain visits:

  • Patient 1, heart rate 72, SpO₂ 98 %, scored low_risk. The department has seen 17 visits like this; their vitals are close to each other, and none deteriorated within 72 hours. Discharging is well supported.
  • Patient 2, heart rate 128, SpO₂ 90 %, scored high_risk. There are 14 similar visits, closely linked, and 12 of them deteriorated. Escalating is well supported.
  • Patient 3, heart rate 88, SpO₂ 95 %, scored low_risk. Only 5 past visits are anywhere near this presentation, they are loosely related to each other, and their outcomes are mixed. The score may be right, but the department's own history gives no reason to trust it.

Today's rule discharges patient 3 exactly as confidently as patient 1. It never asks whether the score sits on top of experience.

What the system checks, in your terms

The library sees the score as an event (low_risk or high_risk), plus a summary of the patient's related cases: past visits whose heart rate is within 12 bpm and SpO₂ within 3 points of this patient's, with a link between two visits whose vitals are within 8 bpm and 2 points of each other, and each visit's 72-hour outcome. It runs six checks; all six must pass for the proposed action to be carried out. Two check the policy itself and pass on every row (see the six checks). The four that read your data:

Check Question, in ED terms Reads Fails when What a hold means
Neighbour agreement Have we seen enough closely similar visits, with outcomes that agree with each other, that our history says something about this patient? Similar-visit links and each visit's 72-hour outcome Few similar visits, few links between them, or outcomes close to a coin flip "We have not seen enough patients like this one to rely on the score."
Knife-edge Is the cohort clearly one tight presentation (or clearly scattered), rather than on the boundary where adding or removing a couple of visits would change the picture? Cohort size, number of links, your "tight cohort" cut-off (0.15 link density) Link density is within the uncertainty window around the cut-off. Small cohorts have wide windows. "Too few comparable visits to judge."
Unusual cases Does this patient's model score look like the scores of recent patients the model triaged correctly? The model's unusualness score for this patient; scores of the last 40 correctly triaged visits (the calibration set) This patient's score is more extreme than almost all of them "The model has not been right on patients who looked like this recently."
Support Did most similar visits have the outcome the proposed action implies (uneventful for discharge, adverse for escalate)? Each similar visit's 72-hour outcome Fewer than half of them did "The score and the similar visits disagree."

Strong history can point the other way

The neighbour-agreement check asks whether similar visits agree with each other, not whether they agree with the score. A cohort where almost everyone deteriorated is strong evidence for any action, including discharge. The support check is what asks which way: the example passes the outcome each action implies (SUPPORTING_LABEL: discharge needs uneventful visits, escalate needs adverse ones) and holds the action when fewer than half the similar visits had it.

Reproduced with the shipped clinical_triage functions: send low_risk for a patient at heart rate 128, SpO₂ 90 % (patient 2's cohort, where 12 of 14 deteriorated). With the support share (similar_visit_evidence(..., proposed_action="discharge")) the discharge is held: support 0.14 vs minimum 0.50 (does not support the proposed action). Without it, the system returns act discharge, all checks passed: the direction then comes entirely from the triage score.

Data you provide

Input Source system Refresh Owner Minimum / quality bar
Past visits (PastVisit): visit id, triage heart rate, triage SpO₂, 72-hour outcome; same chief complaint, last 90 days EHR encounter table Query at triage time; outcome column filled by a nightly job ED informatics Exclude visits whose 72-hour window has not closed yet (their outcome is unknown, not "uneventful"). Never include the current visit.
Adverse outcome definition (ADVERSE_OUTCOME = 1): unplanned return, admission or ICU transfer within 72 hours Clinical governance Yearly Medical director Agreed and written down before launch.
Cohort windows (±12 bpm, ±3 SpO₂) and link tolerances (8 bpm, 2 points) Clinical judgement, checked against history Quarterly Medical director with ED informatics The ranges a clinician would mean by "I have seen this before".
Model unusualness score for this patient (candidate_model_score) Triage model Per arrival Data science Same scale as the calibration set; low means "looks like patients we got right".
Calibration set: scores of the last 40 correctly triaged visits (RECENT_CORRECTLY_TRIAGED_SCORES) Encounter table joined to model output and 72-hour outcomes Daily Data science At least 9 at the default 10 % level, or the unusual-cases check can never pass.
Tight-cohort cut-off (TIGHT_COHORT_DENSITY_THRESHOLD, 0.15) Your history Quarterly Data science The density at which past cohorts were hardest to classify. See Choose thresholds and calibration.

similar_visit_evidence in clinical_triage.py turns the visit rows and the patient's vitals into the evidence summary.

Worked example

These rows come from uv run grounded-decision examples clinical_triage.

Patient Score Cohort Agreement check Knife-edge check Unusual-cases check Today's rule This system
1: HR 72, SpO₂ 98 low_risk 17 visits, 94 links, 0 adverse Pass: dense, all outcomes the same Pass Pass discharge act discharge
2: HR 128, SpO₂ 90 high_risk 14 visits, 66 links, 12 adverse Pass: dense, outcomes mostly agree Pass Pass escalate act escalate
3: HR 88, SpO₂ 95 low_risk 5 visits, 4 links, 1 adverse Fail: few links, outcomes no better than chance Fail: 5 visits is too few to judge Pass discharge hold; proposed discharge kept on the record

The support check passes on all three: 17 of 17 similar visits were uneventful for patient 1's discharge (1.0), 12 of 14 were adverse for patient 2's escalation (0.8571), and 4 of 5 were uneventful for patient 3's discharge (0.8). Patient 3 is held by the two checks that judge whether five visits are enough to say anything.

The tracking board shows, for patient 3: "keep in intake, page charge nurse (score wanted: discharge)". Over three patients, today's rule acts three times; this system acts twice and holds once (blocked_overconfident_acts: 1 in the --json summary).

Why 17 visits for patient 1 when the history has 16 calm presentations? The cohort window (60–84 bpm, SpO₂ 95–100 %) also catches one in-between visit (heart rate 82, SpO₂ 95 %) that went home uneventfully. Patient 3's five visits are one calm visit (77 bpm) and the four in-between ones.

The numbers behind each check

Neighbour agreement uses \(d\), the average number of links per visit, and \(\lambda\), how much more often linked visits share an outcome than chance (0 = chance, 1 = always). Evidence strength is \(d\lambda^2\); the check passes above \(1 + 0.05 = 1.05\).

Patient 1 Patient 2 Patient 3
visits \(n\) 17 14 5
links per visit \(d\) 11.06 9.43 1.6
agreement \(\lambda\) 1.0 0.606 0
evidence strength \(d\lambda^2\) 11.06 (pass) 3.46 (pass) 0.00 (fail)
link density \(\hat p\) 0.691 0.725 0.400
knife-edge half-width at this \(n\) 0.201 0.231 0.5 (the cap)
distance from the 0.15 cut-off 0.541 (outside: pass) 0.575 (pass) 0.25 (inside: fail)
unusualness p-value 1.0 1.0 1.0
support share (outcome the action implies) 1.0 (pass) 0.8571 (pass) 0.8 (pass)

The half-width is \(\ln(1/(2\varepsilon))/(\ln n)^2\) with \(\varepsilon = 0.1\), capped at 0.5 (computed with uv run python -c; the CLI prints the pass/fail). The window around the cut-off for patient 3 is therefore \(0.15 \pm 0.5\), i.e. every density from 0 to 0.65.

The unusualness p-value is \((1 + \#\{\text{calibration scores} \ge \text{this score}\}) / (1 + 40)\). The demo sends the same score, 0.12 (TYPICAL_PRESENTATION_SCORE), for every patient, below every calibration score (0.30 to 1.08), so this check cannot fail in the demo. With a real per-patient score it can: sending 1.5 for patient 1 gives \(p = 0.024\) and a hold (checked with uv run python -c).

The record's reason for patient 3 reads d λ² = 0.0000 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (…). "KS" is the Kesten–Stigum line at 1; "operational window" is the knife-edge window.

What happens next

flowchart TD
    A[Triage score computed] --> D{All six checks pass?}
    D -- yes --> X[Discharge or escalate pathway]
    D -- no --> H[Hold: patient stays in intake]
    H --> N[Charge nurse paged with proposed action and reason]
    N --> C[Clinician decides]
    C --> O[72-hour outcome recorded nightly]
    O --> Q[Next cohorts and calibration set]
Verdict Who is told What they see Time limit If nobody acts in time Feedback
Act discharge Discharging nurse Tracking board: "carry out: discharge", with the record Normal discharge flow n/a 72-hour outcome joins the history.
Act escalate Bed manager, receiving team "carry out: escalate" Immediate n/a Outcome joins the history.
Hold Charge nurse (paged) "keep in intake, page charge nurse (score wanted: discharge)", the failed checks and reason Set one locally, for example clinician review within your triage reassessment interval. A held patient is still in the department and must stay inside normal re-triage. Escalate to the senior clinician on shift; never default to discharge. The clinician's call and the 72-hour outcome are recorded like any other visit, so the next similar patient has a bigger cohort.
Degrade (not in this demo) Charge nurse and the triage-service owner Same as hold, plus an alert Same as hold Same as hold The evidence pipeline itself broke; fix it.

The module never discharges on a hold. next_step_for_patient in the example is the mapping the tracking board uses.

Tuning and expected workload

Setting Policy or data? How to choose Too tight Too loose
Cohort windows and link tolerances Clinical What a clinician would call "the same presentation" Small cohorts; most patients held Dissimilar patients counted as experience
Tight-cohort cut-off (0.15) Data Density at which past cohorts were hardest to classify More cohorts near it; more holds Loose cohorts treated as tight
Agreement margin (0.05) Policy Keep the default unless reviewers find holds that were obviously safe More holds Weak cohorts act automatically
Unusualness level α (0.10) Policy Share of correctly triaged patients you accept treating as unusual More holds Unusual patients act automatically

Expected holds. Cohort size drives holds more than anything else. The knife-edge half-width is 0.5 (the cap) for cohorts up to about 6 visits, 0.33 at 9, 0.26 at 12, 0.20 at 17 and 0.14 at 30. With the 0.15 cut-off, any cohort under about 7 visits is held whatever its density. Run the pipeline in shadow mode on last month's arrivals to measure the share held: Estimate impact before launch.

Limits, risks and compliance

  • Direction is checked only through support (see the warning above), and only when the evidence carries the support share.
  • The demo's unusual-cases check cannot fail, because it sends a fixed score for everyone.
  • Synthetic data. The 34-visit history is constructed to give one tight calm cohort, one tight unstable cohort and a sparse middle.
  • Two vital signs are not a presentation. The example links visits on heart rate and SpO₂ only. A real similarity measure needs age, ECG findings, troponin and history; agree it with your clinicians.
  • Outcome delay and bias. The 72-hour outcome is only known for patients who were discharged; escalated patients' outcomes are shaped by the treatment they got. Returns to another hospital are missed.
  • Calibration size. Below 9 scores at α = 0.10 the unusual-cases check can never pass; with an empty calibration set it passes unless you set Limits(require_conformal_calibration=True).

Wiring it in

  1. At service start, build the policy once: triage_module = build(). low_risk proposes discharge, high_risk proposes escalate.
  2. Daily, refresh RECENT_CORRECTLY_TRIAGED_SCORES from confirmed 72-hour outcomes.
  3. Per arrival, pull recent visits with the same chief complaint as PastVisit rows and build the evidence:
from grounded_decision.examples.clinical_triage import similar_visit_evidence

evidence = similar_visit_evidence(
    heart_rate_bpm,
    spo2_percent,
    visit_history,
    candidate_model_score=model_score_for_this_patient,
    proposed_action=triage_module.peek_proposed_action(event.name),  # for support
)
record = triage_module.decide(event, evidence)
store_in_encounter_audit_log(record.to_dict())
show_on_tracking_board(next_step_for_patient(record.to_dict()))

store_in_encounter_audit_log and show_on_tracking_board are your own functions.

  1. State. Each patient is independent here. If a patient is re-scored during one visit, store record.extras["state"] with the encounter and send it back through grounded_decision.integrate.handle_request (see fraud review). The module is not thread-safe; see Build a decision service.

Run it yourself

uv run grounded-decision examples clinical_triage
uv run grounded-decision examples clinical_triage --json
make demo

How to read the printed report: Example library: reading a report. HOLD and hold_for_human in the output are this page's "hold" (the record's verdict field says abstain). The last three lines are the tracking-board instructions.

Further reading

Results this case relies on
Check Result Math catalog number Trust level of the check
Neighbour agreement Kesten–Stigum reconstruction 229 proven (lean) that outcomes are recoverable above the line; holding at or below it is policy (paper on a failing row)
Knife-edge Friedgut–Kalai sharp thresholds 186 practical (operational): the proven width (about \(3 \times 10^5\) here) is far wider than 1
Unusual cases Split conformal prediction — published
Support A counting rule with an explicit minimum — practical (operational)
Policy shape and progress Star height; parity and mean-payoff games 134, 104 proven (lean)

See Trust levels and honest limits.