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¶
- At service start, build the policy once:
triage_module = build().low_riskproposesdischarge,high_riskproposesescalate. - Daily, refresh
RECENT_CORRECTLY_TRIAGED_SCORESfrom confirmed 72-hour outcomes. - Per arrival, pull recent visits with the same chief complaint as
PastVisitrows 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.
- 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 throughgrounded_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¶
- Naive vs grounded: the same pattern across cases.
- Live: emergency-department shift (
live_ed_shift): this decision as a running service over a shift. - Build evidence from your data and Troubleshoot: why did it abstain?.
- Theory: recovering a label from related cases (Kesten–Stigum), knife-edge densities (Friedgut–Kalai), conformal prediction.
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) |