From 8fbcc5db0d73498632371a2b39e22c53f5bfdce4 Mon Sep 17 00:00:00 2001 From: Amrit Krishnan Date: Fri, 11 Sep 2026 07:32:06 -0400 Subject: [PATCH 1/6] Share the single-patient streaming loop and add per-patient helpers Three readers streamed one patient through the model with their own copy of the same PackedLaneSampler loop. It now lives once in odyssey/inference/patient_stream.py (stream_patient, risk_within), and case_study and counterfactual use it. Behaviour is unchanged: the existing case-study and counterfactual tests pass unmodified. Also added, each with tests: - extract_patient_case reports risk at 8/24/72 h (event_risk_by_horizon); event_risk_24h is kept for the report that reads it. - ordered_sequence_rows: the exact row order build_patient_sequence tokenizes, so a position maps back to its raw row. - load_meds_subject: one subject from one shard, filter pushed into the scan. - code_metadata.load_code_descriptions, split out of steering so readers can name codes without importing steering. - occlude_codes: target-agnostic occlusion with a progress callback; occlusion_attribution is now a thin wrapper, plus event_occlusion_attribution for an event's risk. - concept_display_name moves from make_readout_table into concepts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012zuuVqnBfTsozfaj5F4y3r --- odyssey/data/code_metadata.py | 47 ++++ odyssey/data/concepts.py | 19 ++ odyssey/data/sequences.py | 74 ++++-- odyssey/inference/case_study.py | 57 ++--- odyssey/inference/concept_edit_attribution.py | 189 ++++++++++++---- odyssey/inference/counterfactual.py | 32 +-- odyssey/inference/patient_stream.py | 109 +++++++++ odyssey/inference/steering.py | 17 +- odyssey/training/data.py | 17 ++ scripts/make_readout_table.py | 14 +- tests/odyssey/data/test_code_metadata.py | 37 +++ tests/odyssey/data/test_sequence_row_order.py | 97 ++++++++ .../odyssey/inference/test_event_occlusion.py | 213 ++++++++++++++++++ .../odyssey/inference/test_patient_stream.py | 170 ++++++++++++++ .../training/test_load_meds_subject.py | 50 ++++ 15 files changed, 997 insertions(+), 145 deletions(-) create mode 100644 odyssey/data/code_metadata.py create mode 100644 odyssey/inference/patient_stream.py create mode 100644 tests/odyssey/data/test_code_metadata.py create mode 100644 tests/odyssey/data/test_sequence_row_order.py create mode 100644 tests/odyssey/inference/test_event_occlusion.py create mode 100644 tests/odyssey/inference/test_patient_stream.py create mode 100644 tests/odyssey/training/test_load_meds_subject.py diff --git a/odyssey/data/code_metadata.py b/odyssey/data/code_metadata.py new file mode 100644 index 00000000..5bdf33bf --- /dev/null +++ b/odyssey/data/code_metadata.py @@ -0,0 +1,47 @@ +"""Human-readable descriptions of MEDS codes, from ``metadata/codes.parquet``. + +A MEDS extraction ships a code dictionary alongside its data shards +(``/metadata/codes.parquet``: ``code``, ``description``, +``parent_codes``). On MIMIC-IV it names labs, vitals, infusions, diagnoses +and procedures (``LAB//220045//bpm`` -> "Heart Rate"); medications and +structural codes carry readable text in the code itself and have no entry. +""" + +import logging +from pathlib import Path + +import polars as pl + + +logger = logging.getLogger(__name__) + +CODES_FILENAME = "codes.parquet" + + +def load_code_descriptions(metadata_dir: str | Path | None) -> dict[str, str]: + """Return ``code -> description`` for every code with a non-empty description. + + Returns an empty mapping (and logs why) when ``metadata_dir`` is + ``None``, has no ``codes.parquet``, or the file has no ``description`` + column, so callers can always fall back to structural formatting. + """ + if metadata_dir is None: + return {} + path = Path(metadata_dir) / CODES_FILENAME + if not path.exists(): + logger.warning("[code_metadata] no %s; codes stay as codes", path) + return {} + if "description" not in pl.read_parquet_schema(path): + logger.warning( + "[code_metadata] %s has no description column; codes stay as codes", path + ) + return {} + frame = pl.read_parquet(path, columns=["code", "description"]).filter( + pl.col("description").is_not_null() & (pl.col("description") != "") + ) + return dict( + zip(frame["code"].to_list(), frame["description"].to_list(), strict=True) + ) + + +__all__ = ["CODES_FILENAME", "load_code_descriptions"] diff --git a/odyssey/data/concepts.py b/odyssey/data/concepts.py index a11112ab..ad96f705 100644 --- a/odyssey/data/concepts.py +++ b/odyssey/data/concepts.py @@ -847,6 +847,25 @@ def canonical_concept_name(name: str) -> str: return LEGACY_CONCEPT_NAMES.get(name, name) +#: Display names for concepts whose registry name understates a threshold. +CONCEPT_DISPLAY_NAMES: dict[str, str] = { + "sustained_hypotension_map": "sustained hypotension (MAP)", + "anemia": "severe anemia", + "hypokalemia": "severe hypokalemia", +} + + +def concept_display_name(name: str) -> str: + """Human-readable name for a concept (legacy names mapped first). + + Registry names read as prose with underscores replaced, except where + the bare name would understate the rule's threshold (``anemia`` is + Hb < 7 g/dL, i.e. severe anemia). + """ + canon = canonical_concept_name(name) + return CONCEPT_DISPLAY_NAMES.get(canon, canon.replace("_", " ")) + + # "v3" adds structurally-derived electrolyte/metabolic/hematologic concepts # (Track B item 11) -- see their CANONICAL_CONCEPTS entries for thresholds # and sources. v1/v2 are untouched by this addition (concepts_for_source diff --git a/odyssey/data/sequences.py b/odyssey/data/sequences.py index 87bf0fc0..331523bc 100644 --- a/odyssey/data/sequences.py +++ b/odyssey/data/sequences.py @@ -220,6 +220,56 @@ def _assign_visits( return visit_orders, visit_segments +@dataclass(frozen=True) +class OrderedRows: + """One subject's raw rows in the exact order they become tokens.""" + + rows: pl.DataFrame + """Row ``i`` is sequence position ``i``: static facts first (stamped with + the first timed event's time and visit), then timed events in a stable + time sort. ``MEDS_BIRTH`` is excluded. Every input column is kept, so a + caller can carry extra columns (a row index, units) through to the + positions they belong to.""" + n_static: int + birth_time: object | None + """The ``MEDS_BIRTH`` timestamp, or ``None`` if the subject has none.""" + + +def ordered_sequence_rows(events: pl.DataFrame) -> OrderedRows: + """Return one subject's rows in the order they become tokens. + + This is the order :func:`build_patient_sequence` uses, and the single + source of truth for which raw row sits at which sequence + position: :func:`build_patient_sequence` tokenizes exactly these rows, + so a reader that needs a position's raw value or unit reads it from + here instead of re-deriving the order. + """ + static = events.filter(pl.col("time").is_null() & (pl.col("code") != BIRTH_CODE)) + timed = events.filter(pl.col("time").is_not_null()) + birth_rows = timed.filter(pl.col("code") == BIRTH_CODE) + birth_time = birth_rows["time"][0] if birth_rows.height > 0 else None + timed = timed.filter(pl.col("code") != BIRTH_CODE).sort("time", maintain_order=True) + if static.height == 0 or timed.height == 0: + # A static-only subject has no timeline and yields nothing. + return OrderedRows(rows=timed, n_static=0, birth_time=birth_time) + # Static facts lead the sequence at the first timed event's instant + # and visit. + first = timed.head(1) + static = static.with_columns( + pl.lit(first["time"][0]).alias("time"), + *( + [pl.lit(first["hadm_id"][0]).alias("hadm_id")] + if "hadm_id" in timed.columns + else [] + ), + ).select(timed.columns) + return OrderedRows( + rows=pl.concat([static, timed], how="vertical_relaxed"), + n_static=static.height, + birth_time=birth_time, + ) + + def build_patient_sequence( events: pl.DataFrame, vocabulary: Vocabulary, @@ -264,28 +314,8 @@ def build_patient_sequence( f"{n_subjects} distinct subject_ids" ) - static = events.filter(pl.col("time").is_null() & (pl.col("code") != BIRTH_CODE)) - events = events.filter(pl.col("time").is_not_null()) - birth_rows = events.filter(pl.col("code") == BIRTH_CODE) - birth_time = birth_rows["time"][0] if birth_rows.height > 0 else None - events = events.filter(pl.col("code") != BIRTH_CODE).sort( - "time", maintain_order=True - ) - n_static = 0 - if static.height > 0 and events.height > 0: - # Static facts lead the sequence at the first timed event's instant - # and visit; a static-only subject has no timeline and yields nothing. - first = events.head(1) - static = static.with_columns( - pl.lit(first["time"][0]).alias("time"), - *( - [pl.lit(first["hadm_id"][0]).alias("hadm_id")] - if "hadm_id" in events.columns - else [] - ), - ).select(events.columns) - events = pl.concat([static, events], how="vertical_relaxed") - n_static = static.height + ordered = ordered_sequence_rows(events) + events, n_static, birth_time = ordered.rows, ordered.n_static, ordered.birth_time subject_id = int(events["subject_id"][0]) if events.height > 0 else -1 codes = events["code"].to_list() diff --git a/odyssey/inference/case_study.py b/odyssey/inference/case_study.py index b2857d96..26172b94 100644 --- a/odyssey/inference/case_study.py +++ b/odyssey/inference/case_study.py @@ -39,19 +39,20 @@ from odyssey.data.history_recap import maybe_history_recap from odyssey.data.sequences import PatientSequence, build_patient_sequence from odyssey.data.sidecars import activate_sidecars -from odyssey.data.streaming import NO_SUBJECT, PackedLaneSampler from odyssey.data.value_binning import add_value_tokens from odyssey.data.vocabulary import Vocabulary from odyssey.inference.legacy_concept_pins import resolve_concepts_for_run +from odyssey.inference.patient_stream import risk_within, stream_patient from odyssey.inference.run_inference import load_run from odyssey.models.sequence_model import ConceptBottleneckSequenceModel -from odyssey.models.time_to_event import probability_within from odyssey.training.data import build_concept_label_dicts, load_meds_shards -from odyssey.training.train import _move_chunk_to_device logger = logging.getLogger(__name__) +#: Alert horizons of :attr:`PatientCaseTrace.event_risk_by_horizon`. +RISK_HORIZONS_HOURS: tuple[float, ...] = (8.0, 24.0, 72.0) + @dataclass(frozen=True) class PatientCaseTrace: @@ -93,6 +94,11 @@ class PatientCaseTrace: """Per position, per alert event: the head's P(event within 24h) -- the alert curve a clinician would watch over the stay.""" + event_risk_by_horizon: dict[str, list[list[float]]] = field(default_factory=dict) + """Horizon key (``"8h"``, ``"24h"``, ``"72h"``) -> per position, per + alert event, the head's P(event within that horizon). The 24h entry + equals :attr:`event_risk_24h`; empty when the model has no hazard heads.""" + def extract_patient_case( model: ConceptBottleneckSequenceModel, @@ -105,20 +111,19 @@ def extract_patient_case( device: str = "cuda", top_k: int = 5, chunk_size: int = 256, + horizons: Sequence[float] = RISK_HORIZONS_HOURS, ) -> PatientCaseTrace: """Trace one patient position-by-position under the streaming regime. Streams the sequence through the model exactly the way training and :func:`~odyssey.inference.run_inference.run_streaming_inference` do -- ``chunk_size``-token windows with carried recurrent state, one - lane, no synthetic resets -- so every per-position probability in - the trace comes from the operating regime the model was actually - trained in. Pass the training run's own ``chunk_size``. + lane, no synthetic resets (:func:`~odyssey.inference.patient_stream.stream_patient`) + -- so every per-position probability in the trace comes from the + operating regime the model was actually trained in. Pass the training + run's own ``chunk_size``. """ model.eval() - sampler = PackedLaneSampler( - iter([seq]), num_lanes=1, chunk_size=chunk_size, reset_prob=0.0 - ) predicted_top_k: list[list[tuple[str, float]]] = [] true_next_code: list[str | None] = [] @@ -127,32 +132,26 @@ def extract_patient_case( observability_probs: list[list[float]] = [] event_heads = getattr(model, "event_heads", None) event_risk: list[list[float]] = [] + risk_by_horizon: dict[str, list[list[float]]] = ( + {f"{h:g}h": [] for h in horizons} if event_heads is not None else {} + ) - state = None with torch.no_grad(): - for chunk in sampler: - chunk = _move_chunk_to_device(chunk, device) # noqa: PLW2901 - fwd = model.forward_with_features( - chunk.batch, state=state, reset_mask=chunk.reset_mask - ) - logits, bottleneck_out, state = fwd.logits, fwd.bottleneck, fwd.state + for span in stream_patient(model, seq, device=device, chunk_size=chunk_size): + fwd, n_real = span.fwd, span.n_real + bottleneck_out = fwd.bottleneck # Case traces read concept/observability probabilities, which # only the bottleneck variant produces (fwd.bottleneck is None # for the baseline model). assert bottleneck_out is not None, ( # noqa: S101 "extract_patient_case requires a concept-bottleneck model" ) - # One lane, one patient, no resets: real input positions are a - # contiguous prefix (padding only where the lane runs out). - input_real = chunk.subject_ids[0] != NO_SUBJECT - n_real = int(input_real.sum().item()) - assert bool(input_real[:n_real].all()) # noqa: S101 - probs = torch.softmax(logits[0, :n_real], dim=-1) # (n_real, vocab) + probs = torch.softmax(fwd.logits[0, :n_real], dim=-1) # (n_real, vocab) top_k_probs, top_k_ids = probs.topk(top_k, dim=-1) - # real_mask means "has a valid next-token target": false only - # at the final position of the whole sequence. - has_target = chunk.real_mask[0, :n_real] - targets = chunk.targets[0, :n_real] + # has_target is false only at the final position of the whole + # sequence (no next token to predict there). + has_target = span.has_target + targets = span.targets for i in range(n_real): if not bool(has_target[i]): predicted_top_k.append([]) @@ -179,8 +178,11 @@ def extract_patient_case( if event_heads is not None: hazards = event_heads(fwd.features[0, :n_real]) event_risk.extend( - probability_within(hazards, event_heads.edges, 24.0).tolist() + risk_within(hazards, event_heads.edges, (24.0,))[..., 0].tolist() ) + by_horizon = risk_within(hazards, event_heads.edges, horizons) + for j, key in enumerate(risk_by_horizon): + risk_by_horizon[key].extend(by_horizon[..., j].tolist()) assert len(concept_probs) == len(seq) # noqa: S101 -- every position, once @@ -203,6 +205,7 @@ def extract_patient_case( list(event_heads.event_names) if event_heads is not None else [] ), event_risk_24h=event_risk, + event_risk_by_horizon=risk_by_horizon, ) diff --git a/odyssey/inference/concept_edit_attribution.py b/odyssey/inference/concept_edit_attribution.py index 68f45c6c..1ad4319c 100644 --- a/odyssey/inference/concept_edit_attribution.py +++ b/odyssey/inference/concept_edit_attribution.py @@ -53,7 +53,7 @@ from __future__ import annotations import logging -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass import polars as pl @@ -62,6 +62,7 @@ from odyssey.data.value_binning import QuantileBinner from odyssey.data.vocabulary import Vocabulary from odyssey.inference.counterfactual import ( + HORIZONS_HOURS, ForecastReadout, ValueEdit, apply_value_edits, @@ -112,21 +113,25 @@ class CodeEdit: @dataclass(frozen=True) class CodeAttribution: - """One candidate code's effect on a concept's probability when removed.""" + """One candidate code's effect on a target readout when removed. + + The target is a concept's probability (:func:`occlusion_attribution`) + or an event's risk at one horizon (:func:`event_occlusion_attribution`). + """ code: str n_rows: int """Readings of this code removed from the window.""" baseline: float - """The concept's probability with nothing removed.""" + """The target's value with nothing removed.""" occluded: float - """The concept's probability with this code's readings removed.""" + """The target's value with this code's readings removed.""" @property def delta(self) -> float: """``occluded - baseline``. - Negative means this code was pushing the concept up; positive + Negative means this code was pushing the target up; positive means it was suppressing it. """ return self.occluded - self.baseline @@ -229,44 +234,53 @@ def score_with_codes_removed( return readout, total_touched -def occlusion_attribution( +def occlude_codes( model: SequenceModel, vocab: Vocabulary, binner: QuantileBinner | None, raw_subject_events: pl.DataFrame, *, index_time: object, - concept_name: str, + value_of: Callable[[ForecastReadout], float], concept_names: Sequence[str], lookback_hours: float = 24.0, candidate_codes: Sequence[str] | None = None, source: str = "mimic_iv", device: str = "cpu", chunk_size: int = 256, + horizons: Sequence[float] = HORIZONS_HOURS, + on_progress: Callable[[int, int], None] | None = None, ) -> list[CodeAttribution]: - """Rank codes in the lookback window by their effect on one concept. - - ``candidate_codes`` restricts the search (e.g. to a curated pool); by - default every distinct code with a reading in the window is tried, one - at a time, by exact-match removal. Returned sorted by ``abs(delta)`` - descending; codes with zero readings in the window (nothing removed) - are silently excluded rather than reported as a zero-effect result. + """Rank codes in the lookback window by their effect on ``value_of(readout)``. + + The target-agnostic core of :func:`occlusion_attribution` and + :func:`event_occlusion_attribution`. ``candidate_codes`` restricts the + search (e.g. to a curated pool); by default every distinct code with a + reading in the window is tried, one at a time, by exact-match removal. + Returned sorted by ``abs(delta)`` descending; codes with zero readings + in the window (nothing removed) are silently excluded rather than + reported as a zero-effect result. ``on_progress(done, total)`` is + called after each candidate, for callers reporting progress on a long + search (one full re-score per candidate). """ - baseline_readout = score_record_at( - model, - vocab, - binner, - raw_subject_events, - index_time=index_time, - concept_names=concept_names, - source=source, - device=device, - chunk_size=chunk_size, - ) - if concept_name not in baseline_readout.concept_probs: - raise ValueError(f"{concept_name!r} not in concept_names {list(concept_names)}") - baseline = baseline_readout.concept_probs[concept_name] + def _score(events: pl.DataFrame) -> float: + return value_of( + score_record_at( + model, + vocab, + binner, + events, + index_time=index_time, + concept_names=concept_names, + source=source, + device=device, + chunk_size=chunk_size, + horizons=horizons, + ) + ) + + baseline = _score(raw_subject_events) codes = ( list(candidate_codes) if candidate_codes is not None @@ -275,36 +289,117 @@ def occlusion_attribution( ) ) results: list[CodeAttribution] = [] - for code in codes: + for done, code in enumerate(codes, start=1): edited, touched = remove_code_exact( raw_subject_events, code, index_time=index_time, window_hours=lookback_hours, ) - if touched == 0: - continue - occluded_readout = score_record_at( - model, - vocab, - binner, - edited, - index_time=index_time, - concept_names=concept_names, - source=source, - device=device, - chunk_size=chunk_size, - ) - occluded = occluded_readout.concept_probs.get(concept_name, baseline) - results.append( - CodeAttribution( - code=code, n_rows=touched, baseline=baseline, occluded=occluded + if touched > 0: + results.append( + CodeAttribution( + code=code, + n_rows=touched, + baseline=baseline, + occluded=_score(edited), + ) ) - ) + if on_progress is not None: + on_progress(done, len(codes)) results.sort(key=lambda r: abs(r.delta), reverse=True) return results +def occlusion_attribution( + model: SequenceModel, + vocab: Vocabulary, + binner: QuantileBinner | None, + raw_subject_events: pl.DataFrame, + *, + index_time: object, + concept_name: str, + concept_names: Sequence[str], + lookback_hours: float = 24.0, + candidate_codes: Sequence[str] | None = None, + source: str = "mimic_iv", + device: str = "cpu", + chunk_size: int = 256, +) -> list[CodeAttribution]: + """Rank codes in the lookback window by their effect on one concept. + + See :func:`occlude_codes` for the search; the target is the concept's + probability at the index position. + """ + if concept_name not in concept_names: + raise ValueError(f"{concept_name!r} not in concept_names {list(concept_names)}") + return occlude_codes( + model, + vocab, + binner, + raw_subject_events, + index_time=index_time, + value_of=lambda readout: readout.concept_probs[concept_name], + concept_names=concept_names, + lookback_hours=lookback_hours, + candidate_codes=candidate_codes, + source=source, + device=device, + chunk_size=chunk_size, + ) + + +def event_occlusion_attribution( + model: SequenceModel, + vocab: Vocabulary, + binner: QuantileBinner | None, + raw_subject_events: pl.DataFrame, + *, + index_time: object, + event: str, + horizon_hours: float, + concept_names: Sequence[str], + lookback_hours: float = 24.0, + candidate_codes: Sequence[str] | None = None, + source: str = "mimic_iv", + device: str = "cpu", + chunk_size: int = 256, + on_progress: Callable[[int, int], None] | None = None, +) -> list[CodeAttribution]: + """Rank codes in the lookback window by their effect on one event's risk. + + See :func:`occlude_codes` for the search; the target is the hazard + head's ``P(event within horizon_hours)`` at the index position. + ``horizon_hours`` should be a hazard bin edge (8, 24, 72, ...) for an + exact probability. + + Raises + ------ + ValueError + If the model has no hazard head named ``event``. + """ + key = f"{horizon_hours:g}h" + event_names = list(getattr(getattr(model, "event_heads", None), "event_names", [])) + if event not in event_names: + raise ValueError(f"{event!r} is not a hazard head of this model: {event_names}") + return occlude_codes( + model, + vocab, + binner, + raw_subject_events, + index_time=index_time, + value_of=lambda readout: readout.event_risk[event][key], + concept_names=concept_names, + lookback_hours=lookback_hours, + candidate_codes=candidate_codes, + source=source, + device=device, + chunk_size=chunk_size, + horizons=(horizon_hours,), + on_progress=on_progress, + ) + + def auto_edit_from_attribution( attributions: Sequence[CodeAttribution], *, diff --git a/odyssey/inference/counterfactual.py b/odyssey/inference/counterfactual.py index fa94e13c..c36c7ba8 100644 --- a/odyssey/inference/counterfactual.py +++ b/odyssey/inference/counterfactual.py @@ -45,13 +45,11 @@ from odyssey.data.history_recap import maybe_history_recap from odyssey.data.sequences import BIRTH_CODE, build_patient_sequence from odyssey.data.signal_panel import SIGNAL_PANEL -from odyssey.data.streaming import NO_SUBJECT, PackedLaneSampler from odyssey.data.value_binning import QuantileBinner, add_value_tokens from odyssey.data.vocabulary import Vocabulary +from odyssey.inference.patient_stream import risk_within, stream_patient from odyssey.models.sequence_model import SequenceModel -from odyssey.models.time_to_event import probability_within from odyssey.training.data import load_meds_shards -from odyssey.training.train import _move_chunk_to_device logger = logging.getLogger(__name__) @@ -250,23 +248,14 @@ def score_record_at( raise ValueError("index_time precedes the record's first event") index_pos = positions[-1] - sampler = PackedLaneSampler( - iter([seq]), num_lanes=1, chunk_size=chunk_size, reset_prob=0.0 - ) event_heads = getattr(model, "event_heads", None) - state = None - offset = 0 - for chunk in sampler: - chunk = _move_chunk_to_device(chunk, device) # noqa: PLW2901 - fwd = model.forward_with_features( - chunk.batch, state=state, reset_mask=chunk.reset_mask - ) - state = fwd.state - n_real = int((chunk.subject_ids[0] != NO_SUBJECT).sum().item()) - if offset + n_real <= index_pos: - offset += n_real + for span in stream_patient( + model, seq, device=device, chunk_size=chunk_size, stop_after=index_pos + ): + if span.start + span.n_real <= index_pos: continue - i = index_pos - offset + fwd = span.fwd + i = index_pos - span.start probs = torch.softmax(fwd.logits[0, i], dim=-1) top_p, top_i = probs.topk(min(top_k, probs.numel())) top_next = [ @@ -276,12 +265,11 @@ def score_record_at( risk: dict[str, dict[str, float]] = {} if event_heads is not None: hz = event_heads(fwd.features[0, i : i + 1]) # (1, E, B) + within = risk_within(hz[0], event_heads.edges, horizons) # (E, H) for e_idx, name in enumerate(event_heads.event_names): risk[name] = { - f"{h:g}h": float( - probability_within(hz[:, e_idx], event_heads.edges, h)[0] - ) - for h in horizons + f"{h:g}h": float(within[e_idx, h_idx]) + for h_idx, h in enumerate(horizons) } concepts: dict[str, float] = {} if fwd.bottleneck is not None: diff --git a/odyssey/inference/patient_stream.py b/odyssey/inference/patient_stream.py new file mode 100644 index 00000000..ffe47c4a --- /dev/null +++ b/odyssey/inference/patient_stream.py @@ -0,0 +1,109 @@ +"""Stream one patient through a trained model, position by position. + +Every per-patient reader (qualitative case traces, counterfactual re-scoring, +the clinician demo) needs the same loop: one lane, one patient, no synthetic +resets, ``chunk_size``-token windows with the recurrent state carried +between them -- the regime training and quantitative evaluation use, so +every per-position output is evidence about deployed behavior. That loop +lives here once instead of being copied into each reader. + +A hybrid backbone's attention is chunk-local, so ``chunk_size`` must be the +training run's own value (``config.chunk_size``); a different value +changes the numbers, not just the speed. +""" + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass + +import torch + +from odyssey.data.sequences import PatientSequence +from odyssey.data.streaming import NO_SUBJECT, PackedLaneSampler +from odyssey.models.sequence_model import ForwardWithFeatures, SequenceModel +from odyssey.models.time_to_event import probability_within +from odyssey.training.train import _move_chunk_to_device + + +@dataclass(frozen=True) +class StreamSpan: + """One chunk's real positions of a single-patient stream. + + ``fwd`` holds the full lane-0 chunk outputs; positions ``[:n_real]`` of + it are real, and they are absolute sequence positions + ``[start, start + n_real)``. + """ + + start: int + n_real: int + fwd: ForwardWithFeatures + has_target: torch.Tensor + """``(n_real,)`` bool: the position has a next-token target (false only at + the sequence's final position).""" + targets: torch.Tensor + """``(n_real,)`` next-token ids.""" + + +def stream_patient( + model: SequenceModel, + seq: PatientSequence, + *, + device: str, + chunk_size: int, + stop_after: int | None = None, +) -> Iterator[StreamSpan]: + """Yield the model's outputs over ``seq``, one chunk at a time. + + ``stop_after`` ends the stream once position ``stop_after`` has been + yielded, so a caller that only needs a prefix (an index time, one visit) + does not pay for the rest of the record. Forward passes run under + ``torch.no_grad``; callers that compute on the yielded tensors should + do the same. + """ + sampler = PackedLaneSampler( + iter([seq]), num_lanes=1, chunk_size=chunk_size, reset_prob=0.0 + ) + state = None + offset = 0 + for raw_chunk in sampler: + chunk = _move_chunk_to_device(raw_chunk, device) + with torch.no_grad(): + fwd = model.forward_with_features( + chunk.batch, state=state, reset_mask=chunk.reset_mask + ) + state = fwd.state + # One lane, one patient, no resets: real input positions are a + # contiguous prefix (padding only where the lane runs out). + input_real = chunk.subject_ids[0] != NO_SUBJECT + n_real = int(input_real.sum().item()) + assert bool(input_real[:n_real].all()) # noqa: S101 + yield StreamSpan( + start=offset, + n_real=n_real, + fwd=fwd, + has_target=chunk.real_mask[0, :n_real], + targets=chunk.targets[0, :n_real], + ) + offset += n_real + if stop_after is not None and offset > stop_after: + return + + +def risk_within( + hazard_logits: torch.Tensor, edges: Sequence[float], horizons: Sequence[float] +) -> torch.Tensor: + """``P(event within h)`` for each horizon, stacked on a new last axis. + + ``hazard_logits`` is ``(..., num_bins)`` (e.g. ``(n, E, B)`` from + :class:`~odyssey.models.time_to_event.EventHazardHeads`); the result is + ``(..., len(horizons))``. Horizons should be bin edges for an exact + answer (see :func:`~odyssey.models.time_to_event.probability_within`). + No horizons gives an empty last axis rather than an error. + """ + if not horizons: + return hazard_logits.new_zeros((*hazard_logits.shape[:-1], 0)) + return torch.stack( + [probability_within(hazard_logits, edges, h) for h in horizons], dim=-1 + ) + + +__all__ = ["StreamSpan", "risk_within", "stream_patient"] diff --git a/odyssey/inference/steering.py b/odyssey/inference/steering.py index 724c9d70..40986ee0 100644 --- a/odyssey/inference/steering.py +++ b/odyssey/inference/steering.py @@ -75,6 +75,7 @@ all_event_times, hazard_events_for, ) +from odyssey.data.code_metadata import load_code_descriptions from odyssey.data.code_normalization import maybe_normalize from odyssey.data.concepts import canonical_concept_name from odyssey.data.history_recap import maybe_history_recap @@ -603,21 +604,7 @@ def token_descriptions( description, or when no metadata is given, map to themselves. """ names: dict[str, str] = {t: t for t in tokens} - if metadata_dir is None: - return names - path = Path(metadata_dir) / "codes.parquet" - if not path.exists(): - logger.warning("[steering] no %s; tokens stay as codes", path) - return names - codes = pl.read_parquet(path) - if "description" not in codes.columns: - logger.warning( - "[steering] %s has no description column; tokens stay as codes", path - ) - return names - lookup = dict( - zip(codes["code"].to_list(), codes["description"].to_list(), strict=True) - ) + lookup = load_code_descriptions(metadata_dir) for token in tokens: code, _, suffix = token.partition("::") description = lookup.get(code) diff --git a/odyssey/training/data.py b/odyssey/training/data.py index 4a9f00b4..1ef984f3 100644 --- a/odyssey/training/data.py +++ b/odyssey/training/data.py @@ -106,6 +106,23 @@ def load_meds_shard(path: str | Path) -> pl.DataFrame: return _load_meds_paths([Path(path)]) +def load_meds_subject(path: str | Path, subject_id: int) -> pl.DataFrame: + """Load one subject's rows from one MEDS shard. + + Columns are projected like :func:`load_meds_shards`. The subject + filter is pushed into the Parquet scan, so row groups that + cannot hold the subject are skipped rather than read and discarded -- + what makes per-patient lookups cheap for interactive readers. Row order + within the subject is the shard's own, which is what + :func:`~odyssey.data.sequences.build_patient_sequence`'s stable sort + relies on for same-timestamp events. + """ + lf = pl.scan_parquet(Path(path)) + available = set(lf.collect_schema().names()) + columns = [c for c in _MEDS_EVENT_COLUMNS if c in available] + return lf.select(columns).filter(pl.col("subject_id") == subject_id).collect() + + def _shuffle_buffered( items: Iterator[_T], *, buffer_size: int, rng: random.Random ) -> Iterator[_T]: diff --git a/scripts/make_readout_table.py b/scripts/make_readout_table.py index 48729cf1..9956aa3e 100644 --- a/scripts/make_readout_table.py +++ b/scripts/make_readout_table.py @@ -38,22 +38,12 @@ from pathlib import Path from typing import Any -from odyssey.data.concepts import canonical_concept_name +from odyssey.data.concepts import canonical_concept_name, concept_display_name logger = logging.getLogger(__name__) -#: Display names for concepts whose registry name understates a threshold. -_DISPLAY = { - "sustained_hypotension_map": "sustained hypotension (MAP)", - "anemia": "severe anemia", - "hypokalemia": "severe hypokalemia", -} - - -def _display(name: str) -> str: - canon = canonical_concept_name(name) - return _DISPLAY.get(canon, canon.replace("_", " ")) +_display = concept_display_name def _readouts(path: Path) -> dict[str, float]: diff --git a/tests/odyssey/data/test_code_metadata.py b/tests/odyssey/data/test_code_metadata.py new file mode 100644 index 00000000..381a80e2 --- /dev/null +++ b/tests/odyssey/data/test_code_metadata.py @@ -0,0 +1,37 @@ +"""load_code_descriptions: codes.parquet -> readable names, with safe fallbacks.""" + +from pathlib import Path + +import polars as pl + +from odyssey.data.code_metadata import load_code_descriptions + + +def test_reads_non_empty_descriptions(tmp_path: Path) -> None: + pl.DataFrame( + { + "code": ["LAB//220045//bpm", "MEDICATION//x", "LAB//y"], + "description": ["Heart Rate", None, ""], + } + ).write_parquet(tmp_path / "codes.parquet") + assert load_code_descriptions(tmp_path) == {"LAB//220045//bpm": "Heart Rate"} + + +def test_accepts_a_string_path_and_ignores_extra_columns(tmp_path: Path) -> None: + pl.DataFrame( + { + "code": ["DIAGNOSIS//ICD//10//I5021"], + "description": ["Acute systolic heart failure"], + "parent_codes": [["ICD10CM/I50.21"]], + } + ).write_parquet(tmp_path / "codes.parquet") + assert load_code_descriptions(str(tmp_path)) == { + "DIAGNOSIS//ICD//10//I5021": "Acute systolic heart failure" + } + + +def test_missing_inputs_give_an_empty_mapping(tmp_path: Path) -> None: + assert load_code_descriptions(None) == {} + assert load_code_descriptions(tmp_path) == {} + pl.DataFrame({"code": ["A"]}).write_parquet(tmp_path / "codes.parquet") + assert load_code_descriptions(tmp_path) == {} diff --git a/tests/odyssey/data/test_sequence_row_order.py b/tests/odyssey/data/test_sequence_row_order.py new file mode 100644 index 00000000..3856a3aa --- /dev/null +++ b/tests/odyssey/data/test_sequence_row_order.py @@ -0,0 +1,97 @@ +"""ordered_sequence_rows: the raw row behind every sequence position.""" + +from datetime import datetime, timedelta + +import polars as pl + +from odyssey.data.sequences import build_patient_sequence, ordered_sequence_rows +from odyssey.data.vocabulary import Vocabulary + + +T0 = datetime(2024, 1, 1) + + +def _events() -> pl.DataFrame: + rows = [ + (1, None, "GENDER//F", None, None), + (1, T0 - timedelta(days=365 * 60), "MEDS_BIRTH", None, None), + (1, T0 + timedelta(hours=2), "LAB//B//", 2.0, 11), + (1, T0, "HOSPITAL_ADMISSION//EW", None, 11), + # a same-timestamp bundle: shard order must survive + (1, T0 + timedelta(hours=1), "LAB//Z//", 3.0, 11), + (1, T0 + timedelta(hours=1), "LAB//A//", 4.0, 11), + (1, None, "RACE//WHITE", None, None), + ] + return pl.DataFrame( + rows, + schema={ + "subject_id": pl.Int64, + "time": pl.Datetime("us"), + "code": pl.Utf8, + "numeric_value": pl.Float32, + "hadm_id": pl.Int64, + }, + orient="row", + ) + + +def test_rows_are_static_first_then_stable_time_order_without_birth() -> None: + ordered = ordered_sequence_rows(_events()) + assert ordered.n_static == 2 + assert ordered.birth_time == T0 - timedelta(days=365 * 60) + assert ordered.rows["code"].to_list() == [ + "GENDER//F", + "RACE//WHITE", + "HOSPITAL_ADMISSION//EW", + "LAB//Z//", + "LAB//A//", + "LAB//B//", + ] + # static rows take the first timed event's instant and visit + assert ordered.rows["time"][0] == T0 and ordered.rows["hadm_id"][0] == 11 + + +def test_rows_align_one_to_one_with_sequence_positions() -> None: + events = _events().with_row_index("row") + ordered = ordered_sequence_rows(events) + vocab = Vocabulary.build(events["code"].to_list(), min_count=1) + seq = build_patient_sequence(events, vocab) + assert len(seq) == ordered.rows.height + assert [vocab.decode(t) for t in seq.concept_ids] == ordered.rows["code"].to_list() + assert seq.static_mask == [True, True, False, False, False, False] + # extra columns ride along, so a position maps back to its source row + assert ordered.rows["row"].to_list() == [0, 6, 3, 4, 5, 2] + + +def test_static_only_subject_has_no_rows() -> None: + only_static = _events().filter(pl.col("time").is_null()) + ordered = ordered_sequence_rows(only_static) + assert ordered.rows.height == 0 and ordered.n_static == 0 + + +def test_empty_frame_gives_empty_rows_and_no_birth() -> None: + ordered = ordered_sequence_rows(_events().head(0)) + assert ordered.rows.height == 0 + assert ordered.n_static == 0 and ordered.birth_time is None + + +def test_no_birth_and_no_static_rows() -> None: + timed = _events().filter( + pl.col("time").is_not_null() & (pl.col("code") != "MEDS_BIRTH") + ) + ordered = ordered_sequence_rows(timed) + assert ordered.birth_time is None and ordered.n_static == 0 + assert ordered.rows["code"][0] == "HOSPITAL_ADMISSION//EW" + + +def test_frame_without_hadm_id_column_still_orders_static_first() -> None: + no_visit = _events().drop("hadm_id") + ordered = ordered_sequence_rows(no_visit) + assert "hadm_id" not in ordered.rows.columns + assert ordered.rows["code"].to_list()[:2] == ["GENDER//F", "RACE//WHITE"] + assert ordered.rows["time"][0] == T0 + + +def test_birth_row_never_becomes_a_position_even_when_it_is_the_earliest() -> None: + ordered = ordered_sequence_rows(_events()) + assert "MEDS_BIRTH" not in ordered.rows["code"].to_list() diff --git a/tests/odyssey/inference/test_event_occlusion.py b/tests/odyssey/inference/test_event_occlusion.py new file mode 100644 index 00000000..a480410c --- /dev/null +++ b/tests/odyssey/inference/test_event_occlusion.py @@ -0,0 +1,213 @@ +"""Occlusion aimed at an event's risk, and the progress hook of the shared core.""" + +from datetime import datetime, timedelta + +import polars as pl +import pytest +import torch + +from odyssey.data.concepts import concept_display_name +from odyssey.data.value_binning import add_value_tokens +from odyssey.data.vocabulary import Vocabulary +from odyssey.inference.concept_edit_attribution import ( + event_occlusion_attribution, + occlude_codes, +) +from odyssey.inference.counterfactual import score_record_at +from odyssey.models.backbones.tiny_gru import TinyGRUBackbone +from odyssey.models.sequence_model import ConceptBottleneckSequenceModel +from odyssey.models.time_to_event import DEFAULT_TIME_BIN_EDGES_HOURS + + +T0 = datetime(2024, 1, 1) +SBP = "LAB//220179//mmHg" +CREAT = "LAB//RESULT//50912//mg/dL" +CONCEPTS = ["hypotension", "tachycardia"] + + +def _events() -> pl.DataFrame: + rows: list[tuple[int, str, datetime, float | None, int]] = [ + (1, "HOSPITAL_ADMISSION//EMERGENCY", T0, None, 101) + ] + for h in range(1, 30): + rows.append((1, SBP, T0 + timedelta(hours=h), 120.0, 101)) + if h % 6 == 0: + rows.append((1, CREAT, T0 + timedelta(hours=h), 1.0, 101)) + return pl.DataFrame( + rows, + schema={ + "subject_id": pl.Int64, + "code": pl.Utf8, + "time": pl.Datetime("us"), + "numeric_value": pl.Float32, + "hadm_id": pl.Int64, + }, + orient="row", + ) + + +def _setup() -> tuple[ConceptBottleneckSequenceModel, Vocabulary, pl.DataFrame]: + events = _events() + vocab = Vocabulary.build(add_value_tokens(events)["code"].to_list(), min_count=1) + torch.manual_seed(0) + model = ConceptBottleneckSequenceModel( + backbone=TinyGRUBackbone( + vocab_size=len(vocab), hidden_size=8, num_layers=1, padding_idx=0 + ), + vocab_size=len(vocab), + num_concepts=len(CONCEPTS), + embedding_dim=4, + padding_idx=0, + time_bin_edges=DEFAULT_TIME_BIN_EDGES_HOURS, + event_names=["vasopressor_start", "death"], + ) + return model, vocab, events + + +def test_event_target_baseline_is_the_scored_risk_and_progress_reports() -> None: + model, vocab, events = _setup() + index = T0 + timedelta(hours=20) + progress: list[tuple[int, int]] = [] + results = event_occlusion_attribution( + model, + vocab, + None, + events, + index_time=index, + event="death", + horizon_hours=24.0, + concept_names=CONCEPTS, + chunk_size=16, + on_progress=lambda done, total: progress.append((done, total)), + ) + expected = score_record_at( + model, + vocab, + None, + events, + index_time=index, + concept_names=CONCEPTS, + chunk_size=16, + ).event_risk["death"]["24h"] + assert {r.code for r in results} == {SBP, CREAT, "HOSPITAL_ADMISSION//EMERGENCY"} + assert all(r.baseline == pytest.approx(expected) for r in results) + assert progress[-1] == (3, 3) and len(progress) == 3 + assert results == sorted(results, key=lambda r: abs(r.delta), reverse=True) + + +def test_unknown_event_is_refused_before_any_scoring() -> None: + model, vocab, events = _setup() + with pytest.raises(ValueError, match="not a hazard head"): + event_occlusion_attribution( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + event="readmission_30d", + horizon_hours=24.0, + concept_names=CONCEPTS, + ) + + +def test_generic_core_accepts_any_readout_target() -> None: + model, vocab, events = _setup() + results = occlude_codes( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + value_of=lambda r: r.concept_probs["tachycardia"], + concept_names=CONCEPTS, + candidate_codes=[SBP, "NOT//PRESENT"], + chunk_size=16, + ) + assert [r.code for r in results] == [SBP] + + +def test_empty_candidate_pool_scores_only_the_baseline() -> None: + model, vocab, events = _setup() + progress: list[tuple[int, int]] = [] + results = occlude_codes( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + value_of=lambda r: r.event_risk["death"]["24h"], + concept_names=CONCEPTS, + candidate_codes=[], + chunk_size=16, + on_progress=lambda done, total: progress.append((done, total)), + ) + assert results == [] and progress == [] + + +def test_progress_counts_candidates_that_had_nothing_to_remove() -> None: + model, vocab, events = _setup() + progress: list[tuple[int, int]] = [] + results = occlude_codes( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + value_of=lambda r: r.event_risk["death"]["24h"], + concept_names=CONCEPTS, + candidate_codes=["NOT//PRESENT", SBP], + chunk_size=16, + on_progress=lambda done, total: progress.append((done, total)), + ) + assert progress == [(1, 2), (2, 2)] + assert [r.code for r in results] == [SBP] + + +def test_lookback_window_limits_event_candidates() -> None: + """Creatinine is drawn at hours 6, 12, 18; (18.5, 20] holds only SBP at 19, 20.""" + model, vocab, events = _setup() + results = event_occlusion_attribution( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + event="vasopressor_start", + horizon_hours=8.0, + concept_names=CONCEPTS, + lookback_hours=1.5, + chunk_size=16, + ) + assert {r.code for r in results} == {SBP} + assert results[0].n_rows == 2 + + +def test_model_without_hazard_heads_is_refused() -> None: + events = _events() + vocab = Vocabulary.build(add_value_tokens(events)["code"].to_list(), min_count=1) + model = ConceptBottleneckSequenceModel( + backbone=TinyGRUBackbone(vocab_size=len(vocab), hidden_size=8, padding_idx=0), + vocab_size=len(vocab), + num_concepts=len(CONCEPTS), + embedding_dim=4, + padding_idx=0, + ) + with pytest.raises(ValueError, match="not a hazard head"): + event_occlusion_attribution( + model, + vocab, + None, + events, + index_time=T0 + timedelta(hours=20), + event="death", + horizon_hours=24.0, + concept_names=CONCEPTS, + ) + + +def test_concept_display_names_read_as_prose() -> None: + assert concept_display_name("hypokalemia") == "severe hypokalemia" + assert concept_display_name("sepsis3") == "sepsis3" + assert concept_display_name("anemia") == "severe anemia" + assert concept_display_name("shock") == "sustained hypotension (MAP)" + assert concept_display_name("acute_kidney_injury") == "acute kidney injury" diff --git a/tests/odyssey/inference/test_patient_stream.py b/tests/odyssey/inference/test_patient_stream.py new file mode 100644 index 00000000..aa2e861d --- /dev/null +++ b/tests/odyssey/inference/test_patient_stream.py @@ -0,0 +1,170 @@ +"""The shared single-lane streaming loop and its multi-horizon risk readout.""" + +import pytest +import torch + +from odyssey.data.sequences import PatientSequence +from odyssey.data.vocabulary import Vocabulary +from odyssey.inference.case_study import extract_patient_case +from odyssey.inference.patient_stream import risk_within, stream_patient +from odyssey.models.backbones.tiny_gru import TinyGRUBackbone +from odyssey.models.sequence_model import ConceptBottleneckSequenceModel +from odyssey.models.time_to_event import ( + DEFAULT_TIME_BIN_EDGES_HOURS, + probability_within, +) + + +VOCAB_SIZE = 30 +EVENTS = ["vasopressor_start", "death"] + + +def _model() -> ConceptBottleneckSequenceModel: + torch.manual_seed(0) + return ConceptBottleneckSequenceModel( + backbone=TinyGRUBackbone( + vocab_size=VOCAB_SIZE, hidden_size=8, num_layers=1, padding_idx=0 + ), + vocab_size=VOCAB_SIZE, + num_concepts=3, + embedding_dim=4, + padding_idx=0, + time_bin_edges=DEFAULT_TIME_BIN_EDGES_HOURS, + event_names=EVENTS, + ) + + +def _vocab() -> Vocabulary: + tokens = {"[PAD]": 0, "[UNK]": 1} + tokens.update({f"LAB//{i}//": i for i in range(2, VOCAB_SIZE)}) + return Vocabulary(tokens) + + +def _sequence(n: int) -> PatientSequence: + return PatientSequence( + subject_id=7, + concept_ids=[2 + (i % (VOCAB_SIZE - 2)) for i in range(n)], + type_ids=[1] * n, + time_stamps=[float(i) for i in range(n)], + ages=[50.0] * n, + visit_orders=[0] * n, + visit_segments=[0] * n, + ) + + +def test_spans_tile_the_sequence_exactly_once() -> None: + spans = list(stream_patient(_model(), _sequence(23), device="cpu", chunk_size=8)) + starts = [s.start for s in spans] + assert starts == [0, *(s.start + s.n_real for s in spans[:-1])] + assert sum(s.n_real for s in spans) == 23 + # only the final position lacks a next-token target + flags = torch.cat([s.has_target for s in spans]).tolist() + assert flags == [True] * 22 + [False] + + +def test_stop_after_ends_once_the_position_is_covered() -> None: + spans = list( + stream_patient( + _model(), _sequence(40), device="cpu", chunk_size=8, stop_after=10 + ) + ) + last = spans[-1] + assert last.start <= 10 < last.start + last.n_real + + +def test_empty_sequence_yields_no_spans() -> None: + assert ( + list(stream_patient(_model(), _sequence(0), device="cpu", chunk_size=8)) == [] + ) + + +def test_single_token_sequence_is_one_span_without_a_target() -> None: + (span,) = stream_patient(_model(), _sequence(1), device="cpu", chunk_size=8) + assert (span.start, span.n_real) == (0, 1) + assert span.has_target.tolist() == [False] + + +@pytest.mark.parametrize("stop_after", [0, 7, 8, 22, 500]) +def test_stop_after_never_drops_the_requested_position(stop_after: int) -> None: + n = 23 + spans = list( + stream_patient( + _model(), _sequence(n), device="cpu", chunk_size=8, stop_after=stop_after + ) + ) + covered = spans[-1].start + spans[-1].n_real + assert covered > min(stop_after, n - 1) + # stops at the first span that covers it, not later + assert len(spans) == 1 or spans[-2].start + spans[-2].n_real <= stop_after + + +def test_prefix_outputs_do_not_depend_on_stop_after() -> None: + """Stopping early must not change what was already computed (causality).""" + model, seq = _model().eval(), _sequence(30) # eval: no dropout noise + full = list(stream_patient(model, seq, device="cpu", chunk_size=8)) + early = list(stream_patient(model, seq, device="cpu", chunk_size=8, stop_after=9)) + for a, b in zip(early, full): + assert torch.equal(a.fwd.features[0, : a.n_real], b.fwd.features[0, : b.n_real]) + + +def test_risk_within_with_no_horizons_is_an_empty_axis() -> None: + hazards = torch.zeros(4, 2, len(DEFAULT_TIME_BIN_EDGES_HOURS) + 2) + assert risk_within(hazards, DEFAULT_TIME_BIN_EDGES_HOURS, ()).shape == (4, 2, 0) + + +def test_case_trace_without_hazard_heads_has_no_horizon_risk() -> None: + torch.manual_seed(0) + model = ConceptBottleneckSequenceModel( + backbone=TinyGRUBackbone(vocab_size=VOCAB_SIZE, hidden_size=8, padding_idx=0), + vocab_size=VOCAB_SIZE, + num_concepts=3, + embedding_dim=4, + padding_idx=0, + ) + trace = extract_patient_case( + model, _sequence(10), _vocab(), ["a", "b", "c"], device="cpu", chunk_size=4 + ) + assert trace.event_risk_by_horizon == {} and trace.event_risk_24h == [] + + +def test_risk_within_matches_probability_within_per_horizon() -> None: + torch.manual_seed(1) + hazards = torch.randn(5, 2, len(DEFAULT_TIME_BIN_EDGES_HOURS) + 2) + stacked = risk_within(hazards, DEFAULT_TIME_BIN_EDGES_HOURS, (8.0, 24.0, 72.0)) + assert stacked.shape == (5, 2, 3) + for j, h in enumerate((8.0, 24.0, 72.0)): + assert torch.equal( + stacked[..., j], + probability_within(hazards, DEFAULT_TIME_BIN_EDGES_HOURS, h), + ) + + +def test_case_trace_reports_every_horizon_and_24h_matches_legacy_field() -> None: + trace = extract_patient_case( + _model(), _sequence(23), _vocab(), ["a", "b", "c"], device="cpu", chunk_size=8 + ) + assert set(trace.event_risk_by_horizon) == {"8h", "24h", "72h"} + assert trace.event_risk_names == EVENTS + assert trace.event_risk_by_horizon["24h"] == trace.event_risk_24h + for key in ("8h", "24h", "72h"): + assert len(trace.event_risk_by_horizon[key]) == 23 + # cumulative risk cannot fall as the horizon lengthens + for p8, p72 in zip( + trace.event_risk_by_horizon["8h"], trace.event_risk_by_horizon["72h"] + ): + assert all(a <= b + 1e-7 for a, b in zip(p8, p72)) + + +def test_multi_horizon_risk_is_chunk_size_invariant_for_a_recurrence() -> None: + model, seq, vocab = _model(), _sequence(23), _vocab() + small = extract_patient_case( + model, seq, vocab, ["a", "b", "c"], device="cpu", chunk_size=5 + ) + big = extract_patient_case( + model, seq, vocab, ["a", "b", "c"], device="cpu", chunk_size=64 + ) + for key in ("8h", "24h", "72h"): + for a, b in zip( + small.event_risk_by_horizon[key], big.event_risk_by_horizon[key] + ): + assert a == pytest.approx(b, abs=1e-6) diff --git a/tests/odyssey/training/test_load_meds_subject.py b/tests/odyssey/training/test_load_meds_subject.py new file mode 100644 index 00000000..befca599 --- /dev/null +++ b/tests/odyssey/training/test_load_meds_subject.py @@ -0,0 +1,50 @@ +"""load_meds_subject: one subject from one shard, projected and in shard order.""" + +from datetime import datetime +from pathlib import Path + +import polars as pl +import pytest + +from odyssey.training.data import load_meds_shard, load_meds_subject + + +def test_returns_only_that_subject_in_shard_order(tmp_path: Path) -> None: + t = datetime(2024, 1, 1) + shard = pl.DataFrame( + { + "subject_id": [1, 2, 1, 2, 1], + "time": [t, t, t, t, t], + "code": ["A", "B", "C", "D", "E"], + "numeric_value": [None, 1.0, 2.0, None, 3.0], + "hadm_id": [10, 20, 10, 20, 10], + "unused": ["x"] * 5, + } + ) + path = tmp_path / "0.parquet" + shard.write_parquet(path) + one = load_meds_subject(path, 1) + assert one["code"].to_list() == ["A", "C", "E"] + assert one.columns == load_meds_shard(path).columns + assert "unused" not in one.columns + assert load_meds_subject(path, 99).height == 0 + + +def test_shard_without_hadm_id_loads_the_columns_it_has(tmp_path: Path) -> None: + path = tmp_path / "3.parquet" + pl.DataFrame( + { + "subject_id": [5, 5], + "time": [datetime(2024, 1, 1), None], + "code": ["A", "GENDER//M"], + "numeric_value": [1.0, None], + } + ).write_parquet(path) + one = load_meds_subject(path, 5) + assert one.columns == ["subject_id", "time", "code", "numeric_value"] + assert one.height == 2 + + +def test_missing_shard_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + load_meds_subject(tmp_path / "nope.parquet", 1) From 27afa06cec98a98feb400d523e743d0c1facc9a6 Mon Sep 17 00:00:00 2001 From: Amrit Krishnan Date: Fri, 11 Sep 2026 07:32:06 -0400 Subject: [PATCH 2/6] Add the clinician demo app (apps/clinician_demo) A web app that replays one admission and shows, moment by moment, the model's risk of ICU admission, vasopressors, AKI, Sepsis-3 and death against an alert line, what it thinks is going on, what it expects next, what-if value edits, occlusion evidence, and a scorecard against the tuned GBM. Runbook: docs/clinician_demo.md. - Standard library HTTP only (the GPU host's environment is pinned); loopback bind, Host allowlist, custom API header, no CORS, no-store, same-origin CSP, static path containment. - Two data modes: credentialed (held-out MIMIC-IV) and open (MIMIC-IV Clinical Database Demo). Every chart says whether the model trained on that patient. - Honest by construction: the gallery shows misses and false alarms with their rates; lead time counts from the alert episode still on at the event; risk is hidden after onset; readmission, steering, label overrides and rollouts are never shown (a test enforces the imports). - Vanilla ES modules, no build step, no third-party JS. - 200+ CPU tests on synthetic data: every module, server security over a real socket, and spies proving the run's chunk_size reaches every model call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012zuuVqnBfTsozfaj5F4y3r --- apps/clinician_demo/__init__.py | 16 + apps/clinician_demo/__main__.py | 122 +++ apps/clinician_demo/codebook.py | 276 ++++++ apps/clinician_demo/config.py | 85 ++ apps/clinician_demo/evidence.py | 193 ++++ apps/clinician_demo/forecast.py | 603 +++++++++++++ apps/clinician_demo/patient_store.py | 230 +++++ apps/clinician_demo/schemas.py | 402 +++++++++ apps/clinician_demo/scorecard.py | 218 +++++ apps/clinician_demo/server.py | 342 ++++++++ apps/clinician_demo/service.py | 822 ++++++++++++++++++ apps/clinician_demo/showcase.py | 316 +++++++ apps/clinician_demo/static/index.html | 36 + apps/clinician_demo/static/js/api.js | 73 ++ apps/clinician_demo/static/js/app.js | 113 +++ .../static/js/charts/concept_strip.js | 135 +++ .../static/js/charts/risk_chart.js | 250 ++++++ apps/clinician_demo/static/js/dom.js | 103 +++ apps/clinician_demo/static/js/format.js | 150 ++++ apps/clinician_demo/static/js/meta.js | 37 + apps/clinician_demo/static/js/state.js | 31 + apps/clinician_demo/static/js/theme.js | 48 + .../static/js/views/evidence.js | 139 +++ .../clinician_demo/static/js/views/gallery.js | 115 +++ .../clinician_demo/static/js/views/patient.js | 50 ++ apps/clinician_demo/static/js/views/replay.js | 444 ++++++++++ .../static/js/views/scorecard.js | 125 +++ apps/clinician_demo/static/js/views/whatif.js | 181 ++++ apps/clinician_demo/static/styles.css | 513 +++++++++++ apps/clinician_demo/thresholds.py | 196 +++++ apps/clinician_demo/whatif.py | 308 +++++++ docs/clinician_demo.md | 84 ++ pyproject.toml | 2 +- tests/apps/__init__.py | 1 + tests/apps/clinician_demo/__init__.py | 1 + tests/apps/clinician_demo/conftest.py | 236 +++++ tests/apps/clinician_demo/test_codebook.py | 217 +++++ .../clinician_demo/test_config_and_schemas.py | 110 +++ tests/apps/clinician_demo/test_evidence.py | 150 ++++ tests/apps/clinician_demo/test_forecast.py | 357 ++++++++ .../clinician_demo/test_main_and_layering.py | 129 +++ .../apps/clinician_demo/test_patient_store.py | 190 ++++ tests/apps/clinician_demo/test_scorecard.py | 170 ++++ tests/apps/clinician_demo/test_server.py | 276 ++++++ tests/apps/clinician_demo/test_service.py | 330 +++++++ tests/apps/clinician_demo/test_showcase.py | 286 ++++++ tests/apps/clinician_demo/test_thresholds.py | 151 ++++ tests/apps/clinician_demo/test_whatif.py | 149 ++++ 48 files changed, 9510 insertions(+), 1 deletion(-) create mode 100644 apps/clinician_demo/__init__.py create mode 100644 apps/clinician_demo/__main__.py create mode 100644 apps/clinician_demo/codebook.py create mode 100644 apps/clinician_demo/config.py create mode 100644 apps/clinician_demo/evidence.py create mode 100644 apps/clinician_demo/forecast.py create mode 100644 apps/clinician_demo/patient_store.py create mode 100644 apps/clinician_demo/schemas.py create mode 100644 apps/clinician_demo/scorecard.py create mode 100644 apps/clinician_demo/server.py create mode 100644 apps/clinician_demo/service.py create mode 100644 apps/clinician_demo/showcase.py create mode 100644 apps/clinician_demo/static/index.html create mode 100644 apps/clinician_demo/static/js/api.js create mode 100644 apps/clinician_demo/static/js/app.js create mode 100644 apps/clinician_demo/static/js/charts/concept_strip.js create mode 100644 apps/clinician_demo/static/js/charts/risk_chart.js create mode 100644 apps/clinician_demo/static/js/dom.js create mode 100644 apps/clinician_demo/static/js/format.js create mode 100644 apps/clinician_demo/static/js/meta.js create mode 100644 apps/clinician_demo/static/js/state.js create mode 100644 apps/clinician_demo/static/js/theme.js create mode 100644 apps/clinician_demo/static/js/views/evidence.js create mode 100644 apps/clinician_demo/static/js/views/gallery.js create mode 100644 apps/clinician_demo/static/js/views/patient.js create mode 100644 apps/clinician_demo/static/js/views/replay.js create mode 100644 apps/clinician_demo/static/js/views/scorecard.js create mode 100644 apps/clinician_demo/static/js/views/whatif.js create mode 100644 apps/clinician_demo/static/styles.css create mode 100644 apps/clinician_demo/thresholds.py create mode 100644 apps/clinician_demo/whatif.py create mode 100644 docs/clinician_demo.md create mode 100644 tests/apps/__init__.py create mode 100644 tests/apps/clinician_demo/__init__.py create mode 100644 tests/apps/clinician_demo/conftest.py create mode 100644 tests/apps/clinician_demo/test_codebook.py create mode 100644 tests/apps/clinician_demo/test_config_and_schemas.py create mode 100644 tests/apps/clinician_demo/test_evidence.py create mode 100644 tests/apps/clinician_demo/test_forecast.py create mode 100644 tests/apps/clinician_demo/test_main_and_layering.py create mode 100644 tests/apps/clinician_demo/test_patient_store.py create mode 100644 tests/apps/clinician_demo/test_scorecard.py create mode 100644 tests/apps/clinician_demo/test_server.py create mode 100644 tests/apps/clinician_demo/test_service.py create mode 100644 tests/apps/clinician_demo/test_showcase.py create mode 100644 tests/apps/clinician_demo/test_thresholds.py create mode 100644 tests/apps/clinician_demo/test_whatif.py diff --git a/apps/clinician_demo/__init__.py b/apps/clinician_demo/__init__.py new file mode 100644 index 00000000..69c3e349 --- /dev/null +++ b/apps/clinician_demo/__init__.py @@ -0,0 +1,16 @@ +"""Clinician demo: replay a patient's chart and watch the model's risk forecasts. + +A small web app that runs a trained Odyssey checkpoint on one patient at a +time and shows, in plain clinical language, how its risks (vasopressors, +ICU admission, AKI, Sepsis-3, death) evolve through an admission, when +they would have raised an alert, what the model thinks is going on, what +would move the forecast, and how good the model is. Runs on the GPU host, +bound to loopback, viewed through an SSH tunnel: patient-level data never +leaves the host. See ``docs/clinician_demo.md``. + +Layers, torch only below the service: ``schemas`` (JSON contracts) -> +``codebook`` / ``patient_store`` / ``thresholds`` / ``showcase`` / +``scorecard`` (pure data) -> ``forecast`` / ``whatif`` / ``evidence`` +(model) -> ``service`` (orchestration, GPU lock, caches) -> ``server`` +(HTTP) -> ``static/`` (rendering only). +""" diff --git a/apps/clinician_demo/__main__.py b/apps/clinician_demo/__main__.py new file mode 100644 index 00000000..0ba5b366 --- /dev/null +++ b/apps/clinician_demo/__main__.py @@ -0,0 +1,122 @@ +"""Start the clinician demo (or run its self-check). + +Usage, on the GPU host from the repository root:: + + .venv/bin/python -m apps.clinician_demo \\ + --run-dir ~/runs/full_run_v10 \\ + --data-dir ~/data/mimiciv_3.1_v1/data/held_out \\ + --metadata-dir ~/data/mimiciv_3.1_v1/metadata \\ + --splits ~/data/mimiciv_3.1_v1/metadata/subject_splits.parquet + +then, on the laptop, open a tunnel and browse to http://localhost:8765:: + + gcloud compute ssh --zone --project \\ + --tunnel-through-iap -- -N -L 8765:localhost:8765 + +``--data-mode open --data-dir ~/data/mimiciv_demo_meds/data`` serves the +open MIMIC-IV demo instead. ``--self-check`` loads everything, measures one +case end to end, prints a JSON report and exits non-zero on failure. +""" + +import argparse +import json +import logging +import sys +from pathlib import Path + +from apps.clinician_demo.config import DATA_MODES, DemoConfig + + +logger = logging.getLogger(__name__) + + +def parse_args(argv: list[str] | None = None) -> tuple[DemoConfig, bool]: + """Parse the command line into a config and the self-check flag.""" + parser = argparse.ArgumentParser( + prog="python -m apps.clinician_demo", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument( + "--data-dir", type=Path, required=True, help="MEDS shard directory" + ) + parser.add_argument( + "--metadata-dir", type=Path, default=None, help="MEDS metadata/ (codes.parquet)" + ) + parser.add_argument( + "--splits", type=Path, default=None, help="the model's subject_splits.parquet" + ) + parser.add_argument("--data-mode", choices=DATA_MODES, default="credentialed") + parser.add_argument("--checkpoint", default="checkpoint_best.pt") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--alert-rate", type=float, default=0.05) + parser.add_argument("--max-shards", type=int, default=None) + parser.add_argument("--cache-dir", type=Path, default=None) + parser.add_argument("--device", default="cuda") + parser.add_argument("--no-warmup", action="store_true") + parser.add_argument("--self-check", action="store_true") + args = parser.parse_args(argv) + try: + config = DemoConfig( + run_dir=args.run_dir.expanduser(), + data_dir=args.data_dir.expanduser(), + metadata_dir=args.metadata_dir.expanduser() if args.metadata_dir else None, + splits_path=args.splits.expanduser() if args.splits else None, + data_mode=args.data_mode, + checkpoint=args.checkpoint, + port=args.port, + alert_rate=args.alert_rate, + max_shards=args.max_shards, + cache_dir=args.cache_dir.expanduser() if args.cache_dir else None, + device=args.device, + warmup=not args.no_warmup, + ) + except ValueError as exc: + parser.error(str(exc)) + return config, args.self_check + + +def main(argv: list[str] | None = None) -> int: + """Run the demo server, or the self-check; return the process exit code.""" + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + config, self_check = parse_args(argv) + # Deferred: loading torch and the model is the slow part, and argument + # errors should surface before it. + from apps.clinician_demo.server import make_server # noqa: PLC0415 + from apps.clinician_demo.service import DemoService # noqa: PLC0415 + + service = DemoService.from_config(config) + if self_check: + try: + print(json.dumps(service.self_check(), indent=2, default=str)) + except Exception: + logger.exception("[self-check] FAILED") + return 1 + finally: + service.shutdown() + return 0 + if config.warmup: + service.warm_up() + server = make_server(service, config.host, config.port) + logger.info( + "serving %s (%s mode) on http://%s:%d -- open an SSH tunnel to this port", + config.run_name, + config.data_mode, + config.host, + config.port, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("stopping") + finally: + server.server_close() + service.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/clinician_demo/codebook.py b/apps/clinician_demo/codebook.py new file mode 100644 index 00000000..624e4664 --- /dev/null +++ b/apps/clinician_demo/codebook.py @@ -0,0 +1,276 @@ +"""Turn MEDS codes and model tokens into plain clinical language. + +A token is a MEDS code plus an optional value-bin suffix +(``LAB//220045//bpm::HIGH``). The code names *what* was recorded; the +suffix says how the value compared with the clinical range the concept +rules use (``LOW``/``NORMAL``/``HIGH``/``CRITICAL``) or, for signals +without a curated range, which fifth of the training distribution it fell +in (``Q1``..``Q5``). + +Names come from the extraction's own ``metadata/codes.parquet`` where it +has them (labs, vitals, infusions, diagnoses, procedures); everything else +(medications after normalization, admissions, transfers) already carries +readable text in the code and is formatted structurally. Nothing here +invents a name the data does not contain. +""" + +import math +import re +from collections.abc import Mapping +from pathlib import Path + +from apps.clinician_demo.schemas import TimelineEntry +from odyssey.data.code_metadata import load_code_descriptions + + +BIN_SEPARATOR = "::" +UNKNOWN_TOKEN = "[UNK]" +FLAG_BINS = frozenset({"LOW", "HIGH", "CRITICAL"}) +_CLINICAL_BIN_WORDS = { + "LOW": "low", + "NORMAL": "normal", + "HIGH": "high", + "CRITICAL": "critical", +} +_QUANTILE_BIN_WORDS = { + "Q1": "bottom fifth", + "Q2": "2nd fifth", + "Q3": "middle fifth", + "Q4": "4th fifth", + "Q5": "top fifth", +} +_CARE_KINDS = { + "HOSPITAL_ADMISSION": "Hospital admission", + "HOSPITAL_DISCHARGE": "Discharge", + "ICU_ADMISSION": "ICU admission", + "ICU_DISCHARGE": "ICU discharge", + "TRANSFER_TO": "Transfer", + "ED_REGISTRATION": "ED registration", + "ED_OUT": "Left ED", +} +_DEMOGRAPHIC_KINDS = { + "GENDER": "Sex", + "RACE": "Race", + "LANGUAGE": "Language", + "INSURANCE": "Insurance", + "MARITAL_STATUS": "Marital status", +} +#: Categories that mark a change in where/how the patient is cared for. +MARKER_CATEGORIES = frozenset({"care", "death"}) + + +def split_bin(token: str) -> tuple[str, str | None]: + """Split ``code::BIN`` into ``(code, BIN)``; ``BIN`` is ``None`` if absent.""" + code, sep, suffix = token.partition(BIN_SEPARATOR) + return code, (suffix or None) if sep else None + + +def bin_flag(bin_label: str | None) -> str | None: + """Return the flag a bin implies: ``LOW``/``HIGH``/``CRITICAL`` or ``None``.""" + return bin_label if bin_label in FLAG_BINS else None + + +def bin_words(bin_label: str | None) -> str | None: + """Return plain words for a bin label, or ``None`` for no / unknown bin.""" + if bin_label is None: + return None + return _CLINICAL_BIN_WORDS.get(bin_label) or _QUANTILE_BIN_WORDS.get(bin_label) + + +def category(code: str) -> str: # noqa: PLR0911 -- one return per family + """Return the coarse event family used for grouping and colour.""" + parts = code.split("//") + kind = parts[0] + if kind == "LAB": + if len(parts) > 1 and parts[1] == "RESULT": + return "lab" + if len(parts) > 1 and parts[1] == "SPECIMEN_COLLECTED": + return "order" + return "vital" + if kind == "MEDICATION": + return "medication" + if kind in ("INFUSION_START", "INFUSION_END", "SUBJECT_WEIGHT_AT_INFUSION"): + return "infusion" + if kind == "SUBJECT_FLUID_OUTPUT": + return "output" + if kind == "DIAGNOSIS": + return "diagnosis" + if kind in ("PROCEDURE", "HCPCS"): + return "procedure" + if kind in _CARE_KINDS: + return "care" + if kind == "MEDS_DEATH": + return "death" + if kind in _DEMOGRAPHIC_KINDS: + return "demographic" + if kind == "DRG": + return "billing" + return "other" + + +#: MIMIC-IV admission types in plain words. +_ADMISSION_TYPES = { + "EW EMER.": "Emergency", + "DIRECT EMER.": "Direct emergency", + "URGENT": "Urgent", + "ELECTIVE": "Elective", + "SURGICAL SAME DAY ADMISSION": "Same-day surgery", + "OBSERVATION ADMIT": "Observation", + "EU OBSERVATION": "Emergency observation", + "DIRECT OBSERVATION": "Direct observation", + "AMBULATORY OBSERVATION": "Ambulatory observation", +} +# A LOINC long name is " [] in by "; +# a clinician wants the analyte. +_LOINC_TAIL = re.compile(r" \[| (?:in|of|by) (?=[A-Z])") + + +def _short_description(description: str, family: str) -> str: + """Trim LOINC-style long names for labs ("Creatinine [Mass/volume] in ...").""" + if family in ("lab", "order"): + return _LOINC_TAIL.split(description, maxsplit=1)[0] + return description + + +_ACRONYMS = frozenset({"PACU", "ICU", "ED", "SNF", "OR"}) + + +def _place(text: str) -> str: + """``"TRANSFER FROM SKILLED NURSING FACILITY"`` in lower case, acronyms kept.""" + words = text.split() + return " ".join( + w if w in _ACRONYMS and len(words) > 1 or w == "PACU" else w.lower() + for w in words + ) + + +def admission_label(code: str) -> str: + """Name a ``HOSPITAL_ADMISSION////`` code in plain words.""" + parts = [p for p in code.split("//")[1:] if p] + if not parts: + return "Admission" + kind = _ADMISSION_TYPES.get(parts[0].upper(), _pretty(parts[0]).capitalize()) + if len(parts) < 2: + return f"{kind} admission" + place = _place(parts[1]) + if place.startswith("transfer from "): + return ( + f"{kind} admission, transferred from {place.removeprefix('transfer from ')}" + ) + return f"{kind} admission, from {place}" + + +def _pretty(text: str) -> str: + """``"SURGICAL SAME DAY ADMISSION"`` -> ``"Surgical same day admission"``.""" + text = text.replace("_", " ").strip() + return text[:1].upper() + text[1:].lower() if text.isupper() else text + + +class Codebook: + """Readable labels, categories, units and flags for one extraction's codes.""" + + def __init__(self, descriptions: Mapping[str, str] | None = None) -> None: + """Wrap a ``code -> description`` mapping (may be empty).""" + self._descriptions = dict(descriptions or {}) + + @classmethod + def from_metadata_dir(cls, metadata_dir: str | Path | None) -> "Codebook": + """Build from an extraction's ``metadata/`` directory (``codes.parquet``).""" + return cls(load_code_descriptions(metadata_dir)) + + def __len__(self) -> int: + """Return the number of codes with a dictionary description.""" + return len(self._descriptions) + + def label(self, code: str) -> str: + """Return the readable name of a raw code (a value-bin suffix is ignored).""" + code, _ = split_bin(code) + family = category(code) + description = self._descriptions.get(code) + if description: + short = _short_description(description, family) + return f"{short} (sample sent)" if family == "order" else short + return self._structural_label(code) + + def unit(self, code: str) -> str | None: + """Return the measurement unit carried in a lab/vital/output code, if any.""" + code, _ = split_bin(code) + parts = code.split("//") + if parts[0] in ("LAB", "SUBJECT_FLUID_OUTPUT", "SUBJECT_WEIGHT_AT_INFUSION"): + unit = parts[-1] if len(parts) >= 2 else "" + return None if unit in ("", "UNK") or unit.isdigit() else unit + return None + + def token_label(self, token: str) -> str: + """Return the readable name of a model token, with its value bin in words.""" + if token == UNKNOWN_TOKEN: + return "An uncommon event (outside the model's vocabulary)" + code, bin_label = split_bin(token) + words = bin_words(bin_label) + name = self.label(code) + return f"{name} ({words})" if words else name + + def entry(self, token: str, t: float, value: float | None) -> TimelineEntry: + """Build one timeline row: what was recorded, its value+unit and flag.""" + code, bin_label = split_bin(token) + shown: str | None = None + if value is not None and not math.isnan(value): + unit = self.unit(code) + shown = f"{value:.4g}" + (f" {unit}" if unit else "") + return TimelineEntry( + t=t, + category=category(code), + label=self.label(code), + value=shown, + flag=bin_flag(bin_label), + ) + + @staticmethod + def _structural_label(code: str) -> str: # noqa: PLR0911, PLR0912 -- one branch per family + parts = code.split("//") + kind, rest = parts[0], [p for p in parts[1:] if p] + if kind == "MEDICATION": + if rest and rest[0] in ("START", "STOP") and len(rest) >= 2: + verb = "Started" if rest[0] == "START" else "Stopped" + return f"{verb} {rest[1]}" + if len(rest) >= 2: + return f"{_pretty(rest[0]).capitalize()} ({rest[1].lower()})" + return _pretty(rest[0]).capitalize() if rest else "Medication" + if kind in _CARE_KINDS: + base = _CARE_KINDS[kind] + if kind == "HOSPITAL_ADMISSION": + return admission_label(code) + if kind == "TRANSFER_TO" and rest: + return f"Transfer to {rest[-1]}" + if kind == "HOSPITAL_DISCHARGE" and rest: + return f"Discharge to {_pretty(rest[-1]).lower()}" + return f"{base}: {rest[-1]}" if rest else base + if kind == "MEDS_DEATH": + return "Death" + if kind == "SUBJECT_WEIGHT_AT_INFUSION": + return "Weight at infusion" + if kind in _DEMOGRAPHIC_KINDS: + return f"{_DEMOGRAPHIC_KINDS[kind]}: {rest[0]}" if rest else kind + if kind in ("DIAGNOSIS", "PROCEDURE") and len(rest) == 3 and rest[0] == "ICD": + return f"{kind.title()} ICD-{rest[1]} {rest[2]}" + if kind in ("INFUSION_START", "INFUSION_END"): + verb = ( + "Infusion started" if kind == "INFUSION_START" else "Infusion stopped" + ) + return f"{verb} (item {rest[0]})" if rest else verb + if kind == "LAB" and rest: + return f"Lab item {rest[-2] if len(rest) >= 2 else rest[0]}" + return " · ".join([_pretty(kind), *rest]) if rest else _pretty(kind) + + +__all__ = [ + "BIN_SEPARATOR", + "FLAG_BINS", + "MARKER_CATEGORIES", + "Codebook", + "admission_label", + "bin_flag", + "bin_words", + "category", + "split_bin", +] diff --git a/apps/clinician_demo/config.py b/apps/clinician_demo/config.py new file mode 100644 index 00000000..a4c137bd --- /dev/null +++ b/apps/clinician_demo/config.py @@ -0,0 +1,85 @@ +"""Runtime configuration for the clinician demo.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + + +DataMode = Literal["credentialed", "open"] +DATA_MODES: tuple[DataMode, ...] = ("credentialed", "open") +LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + +#: Alert horizons shown everywhere in the demo (hazard bin edges, so exact). +HORIZONS_HOURS: tuple[float, ...] = (8.0, 24.0, 72.0) + + +@dataclass(frozen=True) +class DemoConfig: + """Everything the demo needs to start; built by ``__main__`` from the CLI. + + ``data_mode`` decides what patients are shown and to whom: + + - ``credentialed``: held-out MIMIC-IV patients of the run's own + extraction. Only for viewers holding PhysioNet MIMIC-IV credentials. + - ``open``: the openly licensed MIMIC-IV Clinical Database Demo (100 + patients). Safe to show any clinician; most of these patients were + in the model's training split, which the UI states per patient. + """ + + run_dir: Path + data_dir: Path + """Directory of MEDS shards (searched recursively for ``*.parquet``).""" + metadata_dir: Path | None = None + """MEDS ``metadata/`` dir holding ``codes.parquet`` (readable labels).""" + splits_path: Path | None = None + """The model's own ``subject_splits.parquet``, to flag patients it trained on.""" + data_mode: DataMode = "credentialed" + checkpoint: str = "checkpoint_best.pt" + host: str = "127.0.0.1" + port: int = 8765 + alert_rate: float = 0.05 + """Share of at-risk moments the alert line flags (sets its threshold).""" + max_shards: int | None = None + cache_dir: Path | None = None + """Where derived caches go; defaults to ``/demo_cache``. Holds + patient-level data, so it must stay on the host.""" + device: str = "cuda" + warmup: bool = True + horizons: tuple[float, ...] = field(default=HORIZONS_HOURS) + + def __post_init__(self) -> None: + """Validate the fields that would otherwise fail late or unsafely.""" + if self.data_mode not in DATA_MODES: + raise ValueError( + f"data_mode must be one of {DATA_MODES}, got {self.data_mode!r}" + ) + if self.host not in LOOPBACK_HOSTS: + raise ValueError( + f"host must be a loopback address {sorted(LOOPBACK_HOSTS)}; the demo " + f"serves patient data and is reached through an SSH tunnel, got {self.host!r}" + ) + if not 0.0 < self.alert_rate < 1.0: + raise ValueError(f"alert_rate must be in (0, 1), got {self.alert_rate}") + if not 0 <= self.port <= 65535: + raise ValueError(f"port must be in [0, 65535], got {self.port}") + if self.max_shards is not None and self.max_shards < 1: + raise ValueError(f"max_shards must be >= 1, got {self.max_shards}") + if not self.horizons or any(h <= 0 for h in self.horizons): + raise ValueError(f"horizons must be positive, got {self.horizons}") + + @property + def resolved_cache_dir(self) -> Path: + """The cache directory, defaulting under the run directory.""" + return ( + self.cache_dir + if self.cache_dir is not None + else self.run_dir / "demo_cache" + ) + + @property + def run_name(self) -> str: + """Short run label shown in the UI's provenance line.""" + return self.run_dir.name + + +__all__ = ["DATA_MODES", "HORIZONS_HOURS", "LOOPBACK_HOSTS", "DataMode", "DemoConfig"] diff --git a/apps/clinician_demo/evidence.py b/apps/clinician_demo/evidence.py new file mode 100644 index 00000000..33f593a4 --- /dev/null +++ b/apps/clinician_demo/evidence.py @@ -0,0 +1,193 @@ +"""'Why': which recorded items the forecast leans on, found by occlusion. + +For a chosen moment and target (an event's risk or a concept's belief), +each candidate code recorded in the lookback window is removed in turn and +the record re-scored (:func:`odyssey.inference.concept_edit_attribution.occlude_codes`). +Codes whose removal moves the target most are what the forecast rests on. + +One re-score per candidate makes this slow (seconds to a minute), so it +runs as a background job the UI polls, one job at a time on the GPU. +Removing a normal reading pushes a forecast toward the population +average, so the size of a change is more trustworthy than its direction. +""" + +import itertools +import logging +import threading +from collections import Counter, OrderedDict +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field, replace + +import polars as pl + +from apps.clinician_demo.schemas import EvidenceItem, EvidenceJob + + +logger = logging.getLogger(__name__) + +MAX_CANDIDATES = 40 +TOP_ITEMS = 8 +ProgressFn = Callable[[int, int], None] +EvidenceWork = Callable[[ProgressFn], list[EvidenceItem]] + + +def candidate_codes( + raw_events: pl.DataFrame, + *, + index_time: object, + lookback_hours: float, + limit: int = MAX_CANDIDATES, +) -> list[str]: + """Return the most frequent codes in ``(index_time - lookback, index_time]``. + + Ordered by count, then code, so the choice is deterministic; the cap + bounds the job's cost (one re-score per candidate). + """ + window = raw_events.filter( + pl.col("time").is_not_null() + & (pl.col("time") <= pl.lit(index_time)) + & (pl.col("time") > pl.lit(index_time) - pl.duration(hours=lookback_hours)) + ) + if window.height == 0: + return [] + counts = ( + window.group_by("code").len().sort(["len", "code"], descending=[True, False]) + ) + return counts["code"].head(limit).to_list() + + +@dataclass +class _Job: + job_id: str + target: str + note: str + status: str = "pending" + done: int = 0 + total: int = 0 + result: list[EvidenceItem] = field(default_factory=list) + error: str | None = None + + def snapshot(self) -> EvidenceJob: + return EvidenceJob( + job_id=self.job_id, + status=self.status, + done=self.done, + total=self.total, + target=self.target, + result=list(self.result), + error=self.error, + note=self.note, + ) + + +def disambiguate(items: list[EvidenceItem]) -> list[EvidenceItem]: + """Suffix repeated labels with their code's last part so rows stay distinct. + + Two different recorded items can share a dictionary name (two infusion + items both called "Dextrose 5%"); without a suffix they read as a + duplicated row. + """ + counts = Counter(i.label for i in items) + return [ + replace(i, label=f"{i.label} ({i.code.rsplit('//', 1)[-1]})") + if counts[i.label] > 1 + else i + for i in items + ] + + +class EvidenceRunner: + """Runs evidence searches one at a time in a background thread.""" + + def __init__(self, gpu_lock: threading.Lock, *, max_jobs: int = 64) -> None: + """Share ``gpu_lock`` with every other model call of the service.""" + self._gpu_lock = gpu_lock + self._max_jobs = max_jobs + self._jobs: OrderedDict[str, _Job] = OrderedDict() + self._lock = threading.Lock() + self._ids = itertools.count(1) + self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="evidence") + + def submit(self, target: str, note: str, work: EvidenceWork) -> EvidenceJob: + """Queue ``work`` (called with a progress callback) and return its job.""" + with self._lock: + job = _Job(job_id=f"ev{next(self._ids)}", target=target, note=note) + self._jobs[job.job_id] = job + self._evict() + snapshot = job.snapshot() + self._pool.submit(self._run, job, work) + return snapshot + + def get(self, job_id: str) -> EvidenceJob: + """Return the current state of a job. + + Raises + ------ + KeyError + If the job is unknown (never submitted, or evicted). + """ + with self._lock: + return self._jobs[job_id].snapshot() + + def shutdown(self) -> None: + """Stop accepting work and wait for the running job.""" + self._pool.shutdown(wait=True, cancel_futures=True) + + def _evict(self) -> None: + finished = [k for k, j in self._jobs.items() if j.status in ("done", "error")] + while len(self._jobs) > self._max_jobs and finished: + self._jobs.pop(finished.pop(0)) + + def _progress(self, job: _Job) -> ProgressFn: + def update(done: int, total: int) -> None: + with self._lock: + job.done, job.total = done, total + + return update + + def _run(self, job: _Job, work: EvidenceWork) -> None: + with self._lock: + job.status = "running" + try: + with self._gpu_lock: + items = work(self._progress(job)) + except Exception as exc: # noqa: BLE001 -- reported to the UI, logged here + logger.exception("[evidence] job %s failed", job.job_id) + with self._lock: + job.status, job.error = "error", f"{type(exc).__name__}: {exc}" + return + with self._lock: + job.result = disambiguate(items[:TOP_ITEMS]) + job.done = max(job.done, job.total) + job.status = "done" + + +def cached_or_submit( + cache: dict[tuple[object, ...], str], + key: tuple[object, ...], + runner: EvidenceRunner, + submit: Callable[[], EvidenceJob], +) -> EvidenceJob: + """Reuse the live job for ``key``; resubmit after an error or eviction.""" + job_id = cache.get(key) + if job_id is not None: + try: + job = runner.get(job_id) + except KeyError: + job = None + if job is not None and job.status != "error": + return job + job = submit() + cache[key] = job.job_id + return replace(job) + + +__all__ = [ + "MAX_CANDIDATES", + "TOP_ITEMS", + "EvidenceRunner", + "cached_or_submit", + "disambiguate", + "candidate_codes", +] diff --git a/apps/clinician_demo/forecast.py b/apps/clinician_demo/forecast.py new file mode 100644 index 00000000..478603fe --- /dev/null +++ b/apps/clinician_demo/forecast.py @@ -0,0 +1,603 @@ +"""Run the model over one patient and shape its outputs for the replay view. + +:func:`trace_patient` is the only function here that touches the model: +it tokenizes a patient's record exactly as training did and streams it +through the model once (:func:`~odyssey.inference.patient_stream.stream_patient`, +the run's own ``chunk_size``), keeping per position the risk of every +displayed event at every horizon, the 29 concept beliefs and the top +next-event predictions. Everything else is a pure function of that trace: + +- the replay shows moments at the END of a same-time bundle of events (a + lab panel is one moment, not ten), where the model has seen the whole + bundle; +- risk is hidden at and after an event's onset, where "will it happen" + has no meaning (the published metrics exclude those moments too); +- the alert crossing is the first bundle end whose 24 h risk reaches the + event's alert line (:mod:`apps.clinician_demo.thresholds`). +""" + +import math +from collections.abc import Callable, Collection, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime + +import numpy as np +import numpy.typing as npt +import polars as pl +import torch + +from apps.clinician_demo.codebook import MARKER_CATEGORIES, Codebook +from apps.clinician_demo.schemas import ( + AlertCrossing, + BankedPoint, + NextEvent, + OperatingPoint, + TimelineEntry, + VisitSummary, + VisitTrace, +) +from apps.clinician_demo.showcase import VISIT_STATS_SCHEMA +from apps.clinician_demo.thresholds import horizon_key +from odyssey.data.alert_events import AlertEvent, all_event_times +from odyssey.data.sequences import build_patient_sequence, ordered_sequence_rows +from odyssey.data.value_binning import QuantileBinner, add_value_tokens +from odyssey.data.vocabulary import Vocabulary +from odyssey.inference.patient_stream import risk_within, stream_patient +from odyssey.models.sequence_model import SequenceModel + + +FloatArray = npt.NDArray[np.float32] +IntArray = npt.NDArray[np.int64] + +TOP_K = 5 +MAX_POINTS = 1500 +MAX_TIMELINE = 5000 +ALERT_HORIZON_HOURS = 24.0 +UNKNOWN_TOKEN = "[UNK]" +HIDDEN_TIMELINE_CATEGORIES = frozenset({"order", "billing", "demographic"}) + + +@dataclass(frozen=True) +class RunContext: + """The loaded run: model, tokenization artifacts and what to display.""" + + model: SequenceModel + vocab: Vocabulary + binner: QuantileBinner | None + source: str + task_set: str + chunk_size: int + device: str + concept_names: tuple[str, ...] + alerts: tuple[AlertEvent, ...] + """Displayed alert events, in display order.""" + head_index: tuple[int, ...] + """Index of each displayed event in ``model.event_heads.event_names``.""" + horizons: tuple[float, ...] + + @property + def events(self) -> tuple[str, ...]: + """Displayed event names, in display order.""" + return tuple(a.name for a in self.alerts) + + +def displayed_alerts( + alerts: Sequence[AlertEvent], + head_names: Sequence[str], + order: Sequence[str] = (), +) -> tuple[tuple[AlertEvent, ...], tuple[int, ...]]: + """Select the events worth showing, with their hazard-head indices. + + Keeps events the model has a head for, drops next-visit events + (30-day readmission, which is scored only at discharge and is the + model's weakest head), and sorts by ``order`` where given (unlisted + events keep their registry order after the listed ones). + """ + names = list(head_names) + kept = [a for a in alerts if not a.next_visit and a.name in names] + rank = {name: i for i, name in enumerate(order)} + kept.sort(key=lambda a: rank.get(a.name, len(rank))) + return tuple(kept), tuple(names.index(a.name) for a in kept) + + +@dataclass(frozen=True) +class PatientTrace: + """The model's outputs over one patient's record, one row per position.""" + + subject_id: int + tokens: list[str] + """The value-binned token at each position (before vocabulary lookup).""" + values: list[float | None] + times: list[float] + """Hours since the patient's first recorded event.""" + timestamps: list[datetime] + visit_ids: list[int] + n_static: int + risk: FloatArray + """``(N, events, horizons)`` P(event within horizon).""" + concepts: FloatArray + """``(N, concepts)`` running concept beliefs.""" + top_ids: IntArray + top_probs: FloatArray + n_unknown: int + """Positions whose token the model's vocabulary does not know.""" + + @property + def n_positions(self) -> int: + """Positions traced.""" + return int(self.risk.shape[0]) + + +def trace_patient(ctx: RunContext, raw_events: pl.DataFrame) -> PatientTrace: + """Stream one patient's normalized record through the model. + + ``raw_events`` must already be normalized the way the run's training + was (:class:`~apps.clinician_demo.patient_store.PatientStore` does + this); binning uses the run's own train-fit binner. + + Raises + ------ + ValueError + If the record is empty or the model has no concept bottleneck or + hazard heads. + """ + event_heads = getattr(ctx.model, "event_heads", None) + if event_heads is None: + raise ValueError("the demo needs a model with event hazard heads") + binned = add_value_tokens(raw_events, ctx.binner, source=ctx.source) + ordered = ordered_sequence_rows(binned) + seq = build_patient_sequence(binned, ctx.vocab) + if len(seq) == 0: + raise ValueError("subject has no timed events to trace") + if len(seq) != ordered.rows.height: # the alignment invariant + raise RuntimeError("sequence positions and raw rows are misaligned") + + idx = torch.tensor(ctx.head_index, dtype=torch.long, device=ctx.device) + risk_parts: list[torch.Tensor] = [] + concept_parts: list[torch.Tensor] = [] + id_parts: list[torch.Tensor] = [] + prob_parts: list[torch.Tensor] = [] + ctx.model.eval() + with torch.no_grad(): + for span in stream_patient( + ctx.model, seq, device=ctx.device, chunk_size=ctx.chunk_size + ): + n, fwd = span.n_real, span.fwd + if fwd.bottleneck is None: + raise ValueError("the demo needs a concept-bottleneck model") + hazards = event_heads(fwd.features[0, :n]).index_select(-2, idx) + risk_parts.append( + risk_within(hazards, event_heads.edges, ctx.horizons).float().cpu() + ) + concept_parts.append(fwd.bottleneck.concept_probs[0, :n].float().cpu()) + probs = torch.softmax(fwd.logits[0, :n].float(), dim=-1) + top_p, top_i = probs.topk(min(TOP_K, probs.shape[-1]), dim=-1) + id_parts.append(top_i.cpu()) + prob_parts.append(top_p.cpu()) + + rows = ordered.rows + values = ( + rows["numeric_value"].to_list() + if "numeric_value" in rows.columns + else [None] * rows.height + ) + unknown_id = ctx.vocab.token_to_id.get(UNKNOWN_TOKEN) + return PatientTrace( + subject_id=seq.subject_id, + tokens=rows["code"].to_list(), + values=[None if v is None or math.isnan(v) else float(v) for v in values], + times=list(seq.time_stamps), + timestamps=rows["time"].to_list(), + visit_ids=list(seq.visit_ids), + n_static=ordered.n_static, + risk=torch.cat(risk_parts).numpy(), + concepts=torch.cat(concept_parts).numpy(), + top_ids=torch.cat(id_parts).numpy().astype(np.int64), + top_probs=torch.cat(prob_parts).numpy(), + n_unknown=sum(1 for c in seq.concept_ids if c == unknown_id), + ) + + +# --------------------------------------------------------------------------- +# Pure helpers over a trace +# --------------------------------------------------------------------------- + + +def bundle_ends(times: Sequence[float]) -> list[int]: + """Return the positions that end a same-timestamp bundle (last of each run).""" + n = len(times) + return [i for i in range(n) if i == n - 1 or times[i + 1] != times[i]] + + +def visit_window( + visit_ids: Sequence[int], visit_id: int, n_static: int +) -> tuple[int, int]: + """Return the first and last non-static position carrying ``visit_id``. + + Raises + ------ + LookupError + If no timed position belongs to the visit. + """ + hits = [i for i, v in enumerate(visit_ids) if v == visit_id and i >= n_static] + if not hits: + raise LookupError(f"visit {visit_id} has no events in this record") + return hits[0], hits[-1] + + +def mask_after_onset( + values: Sequence[float], times: Sequence[float], onset: float | None +) -> list[float | None]: + """Replace ``values`` with ``None`` wherever ``time >= onset`` (if any).""" + if onset is None: + return [float(v) for v in values] + return [None if t >= onset else float(v) for v, t in zip(values, times)] + + +def first_crossing( + times: Sequence[float], values: Sequence[float | None], threshold: float +) -> float | None: + """Return the first time a (non-masked) value reaches ``threshold``.""" + for t, v in zip(times, values): + if v is not None and v >= threshold: + return t + return None + + +def alert_episode_start( + times: Sequence[float], + values: Sequence[float | None], + threshold: float, + end: float | None = None, +) -> float | None: + """Return when the alert that was still on at ``end`` switched on. + + Looks at the moments before ``end`` (all moments when ``end`` is + ``None``) that carry a value. If the last of them is at or above + ``threshold``, walks back while the value stays there and returns the + time the run began; otherwise no alert was on at ``end`` and the + answer is ``None``. This is the clinically meaningful lead time: how + long the alarm had been sounding when the event happened, not when the + risk first brushed the line days earlier. + """ + seen = [ + (t, v) + for t, v in zip(times, values) + if v is not None and (end is None or t < end) + ] + if not seen or seen[-1][1] < threshold: + return None + start = seen[-1][0] + for t, v in reversed(seen): + if v < threshold: + break + start = t + return start + + +def downsample( + times: Sequence[float], + max_points: int, + keep: Collection[int] = (), +) -> list[int]: + """Return ~``max_points`` evenly spread moment indices, plus ``keep``. + + Splits the time span into ``max_points`` equal bins and keeps the last + moment of each (the most up-to-date forecast in that bin); indices in + ``keep`` (onsets, alert crossings, care transitions) always survive. + """ + n = len(times) + if n <= max_points: + return list(range(n)) + if max_points < 1: + return sorted(i for i in keep if 0 <= i < n) + t0, t1 = times[0], times[-1] + width = (t1 - t0) / max_points or 1.0 + last_in_bin: dict[int, int] = {} + for i, t in enumerate(times): + last_in_bin[min(int((t - t0) / width), max_points - 1)] = i + return sorted(set(last_in_bin.values()) | {i for i in keep if 0 <= i < n}) + + +def onsets_for( + raw_events: pl.DataFrame, + alerts: Sequence[AlertEvent], + *, + source: str, + task_set: str, + subject_id: int, + visit_id: int, +) -> dict[str, float | None]: + """Return each event's onset for this visit, in hours since the first event. + + Subject-scoped events (death) have one onset for the whole record; it + is returned even when it falls after this visit, so the caller can say + "not during this admission" rather than silently dropping it. + """ + times = all_event_times(raw_events, list(alerts), source, task_set=task_set) + out: dict[str, float | None] = {} + for alert in alerts: + et = times[alert.name] + key = (subject_id, -1) if et.subject_scoped else (subject_id, visit_id) + out[alert.name] = et.onset.get(key) + return out + + +def _pct(value: float | None) -> str: + return "n/a" if value is None else f"{100 * value:.0f}%" + + +def _pct_fine(value: float) -> str: + return f"{100 * value:.1f}%" if value < 0.1 else f"{100 * value:.0f}%" + + +def callout_text( + name: str, + *, + cross: float | None, + alert_start: float | None, + onset: float | None, +) -> str: + """Say, in plain words, what the alert line did on this visit. + + All times are visit-relative hours. ``alert_start`` is when the alert + that was still on at the event's onset came on (see + :func:`alert_episode_start`); ``cross`` is the first time the risk ever + reached the line; ``onset`` is ``None`` when the event did not happen + during the visit. + """ + if onset is not None: + began = f"{name} began at hour {onset:.0f}." + if alert_start is not None: + lead = onset - alert_start + if lead < 1: + return f"{began} The alert came on just before it." + return ( + f"{began} The alert had been on since hour {alert_start:.0f}: " + f"{lead:.0f} h of warning." + ) + if cross is not None: + return ( + f"{began} The alert came on at hour {cross:.0f} " + "but was off again by then." + ) + return f"{began} The risk never reached the alert line: a miss." + if cross is not None: + return ( + f"No {name} during this stay, but the alert came on at hour " + f"{cross:.0f}: a false alarm." + ) + return f"No {name} during this stay, and the risk stayed below the alert line." + + +def alert_detail(name: str, point: OperatingPoint | None) -> str: + """Summarize how the alert line performs across held-out patients.""" + if point is None: + return "" + return ( + f"Alert line: {_pct_fine(point.threshold)} risk within " + f"{ALERT_HORIZON_HOURS:g} h. Across held-out patients it is on for " + f"{_pct(point.alert_rate)} of moments, covers {_pct(point.sensitivity)} " + f"of the moments in the day before {name}, and {_pct(point.ppv)} of the " + f"moments it is on are followed by {name} within a day." + ) + + +def _alert_index(horizons: Sequence[float]) -> int: + return ( + list(horizons).index(ALERT_HORIZON_HOURS) + if ALERT_HORIZON_HOURS in horizons + else 0 + ) + + +def _thin_timeline(entries: list[TimelineEntry], limit: int) -> list[TimelineEntry]: + """Keep every flagged/non-vital entry; thin routine vitals evenly to fit.""" + if len(entries) <= limit: + return entries + keep = [e for e in entries if e.flag is not None or e.category != "vital"] + routine = [e for e in entries if e.flag is None and e.category == "vital"] + room = max(limit - len(keep), 0) + step = max(len(routine) // room, 1) if room else len(routine) + 1 + kept = keep + routine[::step][:room] + return sorted(kept, key=lambda e: e.t) + + +def visit_view( # noqa: PLR0913 -- the view composes many independent inputs + trace: PatientTrace, + visit: VisitSummary, + *, + events: Sequence[str], + horizons: Sequence[float], + onsets: Mapping[str, float | None], + points: Mapping[str, OperatingPoint], + codebook: Codebook, + decode: Callable[[int], str], + display: Mapping[str, str], + seen_in_training: bool, + banked: Sequence[BankedPoint] = (), + max_points: int = MAX_POINTS, +) -> VisitTrace: + """Shape one visit of a trace for the replay view (visit-relative hours). + + ``points`` maps each event to its 24 h operating point (alert line). + """ + first, last = visit_window(trace.visit_ids, visit.visit_id, trace.n_static) + start = visit.start_hours + ends = [i for i in bundle_ends(trace.times) if first <= i <= last] + end_times = [trace.times[i] for i in ends] + h_alert = _alert_index(horizons) + + alerts: list[AlertCrossing] = [] + keep: set[int] = set() + onsets_rel: dict[str, float | None] = {} + for j, event in enumerate(events): + onset = onsets.get(event) + in_visit = ( + onset is not None and trace.times[first] <= onset <= trace.times[last] + ) + onsets_rel[event] = onset - start if in_visit and onset is not None else None + series = mask_after_onset( + trace.risk[ends, j, h_alert].tolist(), end_times, onset + ) + point = points.get(event) + cross = first_crossing(end_times, series, point.threshold) if point else None + alert_start = ( + alert_episode_start(end_times, series, point.threshold, onset) + if point and in_visit + else None + ) + for moment in (cross, alert_start): + if moment is not None: + keep.add(end_times.index(moment)) + if in_visit and onset is not None: + keep.add(max(0, int(np.searchsorted(end_times, onset)) - 1)) + name = display.get(event, event) + alerts.append( + AlertCrossing( + event=event, + horizon_hours=horizons[h_alert], + threshold=point.threshold if point else float("nan"), + first_cross_hours=None if cross is None else cross - start, + onset_hours=onsets_rel[event], + lead_hours=( + onset - alert_start + if onset is not None and alert_start is not None + else None + ), + callout=callout_text( + name, + cross=None if cross is None else cross - start, + alert_start=None if alert_start is None else alert_start - start, + onset=onsets_rel[event], + ), + alert_start_hours=( + None if alert_start is None else alert_start - start + ), + detail=alert_detail(name, point), + ) + ) + + timeline: list[TimelineEntry] = [] + for i in range(first, last + 1): + entry = codebook.entry(trace.tokens[i], trace.times[i] - start, trace.values[i]) + if entry.category not in HIDDEN_TIMELINE_CATEGORIES: + timeline.append(entry) + markers = [e for e in timeline if e.category in MARKER_CATEGORIES] + marker_times = {m.t + start for m in markers} + keep |= {k for k, t in enumerate(end_times) if t in marker_times} + + chosen = downsample(end_times, max_points, keep) + positions = [ends[k] for k in chosen] + times = [trace.times[p] for p in positions] + risk: dict[str, dict[str, list[float | None]]] = {} + for j, event in enumerate(events): + risk[event] = { + horizon_key(h): mask_after_onset( + trace.risk[positions, j, hi].tolist(), times, onsets.get(event) + ) + for hi, h in enumerate(horizons) + } + return VisitTrace( + subject_id=trace.subject_id, + visit=visit, + seen_in_training=seen_in_training, + times=[t - start for t in times], + risk=risk, + concepts=trace.concepts[positions].tolist(), + top_next=[ + [ + NextEvent( + label=codebook.token_label(decode(int(tid))), probability=float(p) + ) + for tid, p in zip(trace.top_ids[pos], trace.top_probs[pos]) + ] + for pos in positions + ], + timeline=_thin_timeline(timeline, MAX_TIMELINE), + markers=markers, + onsets=onsets_rel, + alerts=alerts, + banked=list(banked), + ) + + +def visit_stats_from_trace( + trace: PatientTrace, + visits: Sequence[VisitSummary], + onsets_by_visit: Mapping[int, Mapping[str, float | None]], + *, + events: Sequence[str], + horizons: Sequence[float], + thresholds: Mapping[str, float], +) -> pl.DataFrame: + """Build gallery stats (``VISIT_STATS_SCHEMA`` rows) from a trace. + + Exact onsets, so lead times are exact (unlike the landmark-row + estimate). Visits with no timed events are skipped. + """ + h_alert = _alert_index(horizons) + ends_all = bundle_ends(trace.times) + records: list[dict[str, object]] = [] + for visit in visits: + try: + first, last = visit_window(trace.visit_ids, visit.visit_id, trace.n_static) + except LookupError: + continue + ends = [i for i in ends_all if first <= i <= last] + end_times = [trace.times[i] for i in ends] + for j, event in enumerate(events): + if event not in thresholds: + continue + onset = onsets_by_visit.get(visit.visit_id, {}).get(event) + positive = ( + onset is not None and trace.times[first] <= onset <= trace.times[last] + ) + series = mask_after_onset( + trace.risk[ends, j, h_alert].tolist(), end_times, onset + ) + seen = [v for v in series if v is not None] + if not seen: + continue # onset at the visit's first moment: never at risk + records.append( + { + "subject_id": trace.subject_id, + "visit_id": visit.visit_id, + "event": event, + "positive": positive, + "first_cross_hours": first_crossing( + end_times, series, thresholds[event] + ), + "alert_start_hours": ( + alert_episode_start(end_times, series, thresholds[event], onset) + if positive + else None + ), + "end_hours": onset if positive else end_times[-1], + "start_hours": trace.times[first], + "max_risk": max(seen), + "threshold": thresholds[event], + } + ) + return pl.DataFrame(records, schema=VISIT_STATS_SCHEMA) + + +__all__ = [ + "ALERT_HORIZON_HOURS", + "MAX_POINTS", + "PatientTrace", + "RunContext", + "alert_detail", + "alert_episode_start", + "bundle_ends", + "callout_text", + "displayed_alerts", + "downsample", + "first_crossing", + "mask_after_onset", + "onsets_for", + "trace_patient", + "visit_stats_from_trace", + "visit_view", + "visit_window", +] diff --git a/apps/clinician_demo/patient_store.py b/apps/clinician_demo/patient_store.py new file mode 100644 index 00000000..754f421c --- /dev/null +++ b/apps/clinician_demo/patient_store.py @@ -0,0 +1,230 @@ +"""Find and load one patient's record from a directory of MEDS shards. + +Loading a whole split (37 held-out shards, tens of millions of rows) to +show one patient is wasteful. Instead the store reads only the +``subject_id`` column of every shard once, keeps a ``subject -> shard`` +index, and loads a subject's rows on demand with a filtered scan. Loaded +records are normalized exactly as the run's training pipeline did +(medication normalization, history recap) and kept in a small LRU cache. +""" + +import logging +import threading +from collections import OrderedDict +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path + +import polars as pl + +from apps.clinician_demo.schemas import PatientSummary, VisitSummary +from odyssey.data.alert_events import visit_envelope +from odyssey.data.code_normalization import maybe_normalize +from odyssey.data.history_recap import maybe_history_recap +from odyssey.data.sequences import BIRTH_CODE, HOURS_PER_YEAR +from odyssey.training.data import load_meds_subject, shard_sort_key + + +logger = logging.getLogger(__name__) + +TRAINED_SPLITS = frozenset({"train", "tuning"}) +_ADMISSION_PREFIX = "HOSPITAL_ADMISSION//" + + +class UnknownPatientError(KeyError): + """The subject is not in any loaded shard.""" + + +def discover_shards( + data_dir: str | Path, *, max_shards: int | None = None +) -> list[Path]: + """Find all ``*.parquet`` shards under ``data_dir``, in a stable order. + + Searches recursively, so a MEDS ``data/`` root with ``train/``, + ``tuning/`` and ``held_out/`` beneath it works as well as one split + directory; hidden directories (extractor working state) are skipped. + Ordered by (directory, numeric shard index). + + Raises + ------ + FileNotFoundError + If no shard is found. + """ + root = Path(data_dir) + shards = [ + p + for p in root.rglob("*.parquet") + if not any(part.startswith(".") for part in p.relative_to(root).parts) + ] + shards.sort(key=lambda p: (str(p.parent), shard_sort_key(p))) + if max_shards is not None: + shards = shards[:max_shards] + if not shards: + raise FileNotFoundError(f"no .parquet shards under {root}") + return shards + + +def build_shard_index(shards: Sequence[Path]) -> dict[int, Path]: + """Map ``subject_id -> shard`` by reading only the ``subject_id`` column. + + Raises + ------ + ValueError + If a subject appears in two shards (MEDS shards partition subjects; + a duplicate means the directory mixes extractions). + """ + index: dict[int, Path] = {} + for shard in shards: + ids = pl.scan_parquet(shard).select("subject_id").unique().collect() + for sid in ids["subject_id"].to_list(): + if sid in index: + raise ValueError(f"subject {sid} is in both {index[sid]} and {shard}") + index[int(sid)] = shard + return index + + +def load_splits(path: str | Path | None) -> dict[int, str]: + """Map ``subject_id -> split`` from ``subject_splits.parquet`` (empty if none).""" + if path is None or not Path(path).exists(): + return {} + frame = pl.read_parquet(path, columns=["subject_id", "split"]) + return dict( + zip(frame["subject_id"].to_list(), frame["split"].to_list(), strict=True) + ) + + +class PatientStore: + """Per-subject access to normalized raw MEDS events, with an LRU cache.""" + + def __init__( + self, + index: Mapping[int, Path], + *, + source: str, + normalize_medications: bool, + history_recap: bool = False, + splits: Mapping[int, str] | None = None, + describe: Callable[[str], str] = str, + cache_size: int = 32, + ) -> None: + """Wrap a shard index; ``describe`` turns an admission code into a label.""" + if cache_size < 1: + raise ValueError(f"cache_size must be >= 1, got {cache_size}") + self._index = dict(index) + self._source = source + self._normalize = normalize_medications + self._recap = history_recap + self._splits = dict(splits or {}) + self._describe = describe + self._cache_size = cache_size + self._cache: OrderedDict[int, pl.DataFrame] = OrderedDict() + self._lock = threading.Lock() + + @property + def subject_ids(self) -> list[int]: + """Every subject in the loaded shards, ascending.""" + return sorted(self._index) + + def __contains__(self, subject_id: object) -> bool: + """Check whether ``subject_id`` is in a loaded shard.""" + return subject_id in self._index + + def __len__(self) -> int: + """Return the number of subjects indexed.""" + return len(self._index) + + def split(self, subject_id: int) -> str | None: + """Return the model's training split for this subject, if known.""" + return self._splits.get(subject_id) + + def seen_in_training(self, subject_id: int) -> bool: + """Tell whether the model was trained or tuned on this subject.""" + return self._splits.get(subject_id) in TRAINED_SPLITS + + def raw_events(self, subject_id: int) -> pl.DataFrame: + """Return the subject's events, normalized the way the run's training was. + + Raises + ------ + UnknownPatientError + If the subject is in no loaded shard. + """ + with self._lock: + cached = self._cache.get(subject_id) + if cached is not None: + self._cache.move_to_end(subject_id) + return cached + shard = self._index.get(subject_id) + if shard is None: + raise UnknownPatientError(subject_id) + events = load_meds_subject(shard, subject_id) + events = maybe_normalize(events, enabled=self._normalize, source=self._source) + events = maybe_history_recap(events, enabled=self._recap) + with self._lock: + self._cache[subject_id] = events + self._cache.move_to_end(subject_id) + while len(self._cache) > self._cache_size: + self._cache.popitem(last=False) + return events + + def visits(self, subject_id: int) -> list[VisitSummary]: + """Return the subject's admissions in time order (hours since first event).""" + events = self.raw_events(subject_id) + spans = visit_envelope(events) + with_visit = events.filter(pl.col("hadm_id").is_not_null()) + counts = dict( + with_visit.group_by("hadm_id").len().iter_rows() # (hadm_id, n) + ) + admissions = ( + with_visit.filter(pl.col("code").str.starts_with(_ADMISSION_PREFIX)) + .sort("time", maintain_order=True) + .group_by("hadm_id", maintain_order=True) + .agg(pl.col("code").first()) + ) + admission_code = dict(admissions.iter_rows()) + out = [ + VisitSummary( + visit_id=vid, + start_hours=start, + end_hours=end, + admission=( + self._describe(admission_code[vid]) + if vid in admission_code + else "Admission" + ), + n_events=int(counts.get(vid, 0)), + ) + for (sid, vid), (start, end) in spans.items() + if sid == subject_id + ] + return sorted(out, key=lambda v: (v.start_hours, v.visit_id)) + + def summary(self, subject_id: int) -> PatientSummary: + """Return header facts (sex, age at first event, training split) and visits.""" + events = self.raw_events(subject_id) + sex_rows = events.filter(pl.col("code").str.starts_with("GENDER//")) + sex = sex_rows["code"][0].split("//", 1)[1] if sex_rows.height else None + timed = events.filter(pl.col("time").is_not_null()) + birth = timed.filter(pl.col("code") == BIRTH_CODE) + first = timed.filter(pl.col("code") != BIRTH_CODE)["time"].min() + age = None + if birth.height and first is not None: + seconds = (first - birth["time"][0]).total_seconds() + age = seconds / 3600.0 / HOURS_PER_YEAR + return PatientSummary( + subject_id=subject_id, + split=self.split(subject_id), + seen_in_training=self.seen_in_training(subject_id), + sex=sex, + age_years=age, + visits=self.visits(subject_id), + ) + + +__all__ = [ + "TRAINED_SPLITS", + "PatientStore", + "UnknownPatientError", + "build_shard_index", + "discover_shards", + "load_splits", +] diff --git a/apps/clinician_demo/schemas.py b/apps/clinician_demo/schemas.py new file mode 100644 index 00000000..57af260a --- /dev/null +++ b/apps/clinician_demo/schemas.py @@ -0,0 +1,402 @@ +"""JSON contracts between the demo's backend and its browser UI. + +Every API response is one of these frozen dataclasses passed through +:func:`to_jsonable`, so the wire format is defined in exactly one place and +the static JS never has to guess at shapes. Times are hours since the +visit's first event unless a field says otherwise; probabilities are +floats in [0, 1]; ``None`` means "not available" (never NaN, which JSON +cannot carry). +""" + +import dataclasses +import math +from dataclasses import dataclass, field +from typing import Any + + +FLOAT_DIGITS = 5 + + +# --------------------------------------------------------------------------- +# Shared building blocks +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EventInfo: + """One forecast event the demo displays.""" + + name: str + display: str + short: str + definition: str + + +@dataclass(frozen=True) +class ConceptInfo: + """One named concept of the bottleneck.""" + + name: str + display: str + description: str + readout_auroc: float | None + + +@dataclass(frozen=True) +class OperatingPoint: + """The alert line for one (event, horizon), with its measured quality. + + Computed over the run's own at-risk landmark rows: flagging every + moment whose risk is at or above ``threshold`` flags ``alert_rate`` of + them, catching ``sensitivity`` of the moments followed by the event + within the horizon, with ``ppv`` of flags being right. The tuned GBM + flagging the same share of moments is reported alongside. + """ + + event: str + horizon_hours: float + threshold: float + alert_rate: float + """Measured share of at-risk moments flagged (ties can push it above target).""" + sensitivity: float | None + """``None`` when no at-risk moment was followed by the event.""" + ppv: float | None + base_rate: float + n_rows: int + gbm_sensitivity: float | None + gbm_ppv: float | None + + +@dataclass(frozen=True) +class Meta: + """Static facts the UI needs once at start-up.""" + + run_name: str + checkpoint: str + data_mode: str + chunk_size: int + horizons: list[float] + events: list[EventInfo] + concepts: list[ConceptInfo] + operating_points: list[OperatingPoint] + disclaimers: dict[str, str] + searchable: bool + """Whether patients can be looked up by id (off in open mode, where + the gallery lists every patient anyway).""" + + +# --------------------------------------------------------------------------- +# Gallery and patient lookup +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class GalleryCase: + """One curated visit in the gallery.""" + + subject_id: int + visit_id: int + kind: str + """``early_warning``, ``quiet``, ``miss``, ``false_alarm`` or ``other``.""" + event: str | None + headline: str + lead_hours: float | None + los_hours: float + seen_in_training: bool + lead_approximate: bool = False + """The lead is a lower bound from 4-hourly landmarks, not an exact onset.""" + + +@dataclass(frozen=True) +class GallerySection: + """A titled group of gallery cases with a one-line honest summary.""" + + kind: str + title: str + summary: str + cases: list[GalleryCase] + + +@dataclass(frozen=True) +class Gallery: + """The gallery page.""" + + sections: list[GallerySection] + + +@dataclass(frozen=True) +class VisitSummary: + """One admission of a patient.""" + + visit_id: int + start_hours: float + """Hours since the patient's first recorded event.""" + end_hours: float + admission: str + n_events: int + + +@dataclass(frozen=True) +class PatientSummary: + """A patient's header facts and their admissions.""" + + subject_id: int + split: str | None + seen_in_training: bool + sex: str | None + age_years: float | None + visits: list[VisitSummary] + + +# --------------------------------------------------------------------------- +# Chart replay +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class TimelineEntry: + """One recorded event, in plain language.""" + + t: float + category: str + label: str + value: str | None = None + flag: str | None = None + """``LOW``, ``HIGH``, ``CRITICAL`` or ``None`` (normal / not assessed).""" + + +@dataclass(frozen=True) +class NextEvent: + """One of the model's most likely next events.""" + + label: str + probability: float + + +@dataclass(frozen=True) +class AlertCrossing: + """When the risk first crossed its alert line before the event.""" + + event: str + horizon_hours: float + threshold: float + first_cross_hours: float | None + onset_hours: float | None + lead_hours: float | None + callout: str + alert_start_hours: float | None = None + """Start of the alert episode still on when the event began (visit hours).""" + detail: str = "" + """How this alert line performs across held-out patients.""" + + +@dataclass(frozen=True) +class BankedPoint: + """One landmark row from the run's own evaluation (credentialed mode only).""" + + t: float + event: str + hazard_24h: float | None + gbm_24h: float | None + outcome_24h: float | None + + +@dataclass(frozen=True) +class VisitTrace: + """Everything the replay view draws for one visit. + + ``times`` indexes every per-point series: ``risk[event][horizon_key][i]``, + ``concepts[i][c]`` and ``top_next[i]`` all describe the moment + ``times[i]`` (the end of a same-time bundle of events). Risk is ``None`` + at and after the event's onset, where the forecast has no meaning. + """ + + subject_id: int + visit: VisitSummary + seen_in_training: bool + times: list[float] + risk: dict[str, dict[str, list[float | None]]] + concepts: list[list[float]] + top_next: list[list[NextEvent]] + timeline: list[TimelineEntry] + markers: list[TimelineEntry] + onsets: dict[str, float | None] + alerts: list[AlertCrossing] + banked: list[BankedPoint] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# What-if, evidence, scorecard +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class WhatIfPreset: + """One what-if control the UI offers.""" + + id: str + label: str + signal: str + mode: str + value: float + min: float + max: float + step: float + unit: str + window_hours: float + description: str + + +@dataclass(frozen=True) +class Readout: + """The forecast at one moment: risk per event and horizon, concept beliefs.""" + + risk: dict[str, dict[str, float]] + concepts: dict[str, float] + + +@dataclass(frozen=True) +class WhatIfResult: + """Factual vs edited forecast at one moment.""" + + t_hours: float + rows_edited: int + warnings: list[str] + factual: Readout + counterfactual: Readout + delta: Readout + + +@dataclass(frozen=True) +class EvidenceItem: + """One recorded code and how much removing it moves the target.""" + + code: str + label: str + n_rows: int + baseline: float + occluded: float + delta: float + + +@dataclass(frozen=True) +class EvidenceJob: + """A long-running evidence search, polled by the UI.""" + + job_id: str + status: str + """``pending``, ``running``, ``done`` or ``error``.""" + done: int + total: int + target: str + result: list[EvidenceItem] + error: str | None + note: str + + +@dataclass(frozen=True) +class CalibrationBin: + """One decile of the calibration curve.""" + + predicted: float + observed: float + n: int + + +@dataclass(frozen=True) +class ScoreCell: + """How well one (event, horizon) is predicted, against the tuned GBM.""" + + event: str + horizon_hours: float + n_at_risk: int + n_positive: int + base_rate: float + hazard_auroc: float | None + hazard_ci: list[float] | None + gbm_auroc: float | None + gbm_ci: list[float] | None + delta: float | None + delta_ci: list[float] | None + separated: bool | None + calibration: list[CalibrationBin] + + +@dataclass(frozen=True) +class Scorecard: + """The model's report card, from the run's banked held-out evaluation.""" + + headline: str + cells: list[ScoreCell] + concepts: list[ConceptInfo] + notes: list[str] + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +def _clean(value: Any) -> Any: # noqa: ANN401 -- recursive JSON walk + if isinstance(value, bool) or value is None or isinstance(value, (int, str)): + return value + if isinstance(value, float): + return None if not math.isfinite(value) else round(value, FLOAT_DIGITS) + if isinstance(value, dict): + return {str(k): _clean(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_clean(v) for v in value] + if isinstance(value, type): + raise TypeError(f"cannot serialize the class {value.__name__} to JSON") + if dataclasses.is_dataclass(value): + return { + f.name: _clean(getattr(value, f.name)) for f in dataclasses.fields(value) + } + # numpy / torch scalars: unwrap to the Python number they hold + item = getattr(value, "item", None) + if callable(item): + return _clean(item()) + raise TypeError(f"cannot serialize {type(value).__name__} to JSON") + + +def to_jsonable(value: Any) -> Any: # noqa: ANN401 -- returns plain JSON types + """Convert a schema object (or nested plain data) to JSON-safe builtins. + + Floats are rounded to :data:`FLOAT_DIGITS` places (the UI never shows + more, and it shrinks traces several-fold); NaN and infinities become + ``None``; dataclasses become dicts; tuples become lists. + + Raises + ------ + TypeError + For a value with no JSON form, rather than silently stringifying it. + """ + return _clean(value) + + +__all__ = [ + "FLOAT_DIGITS", + "AlertCrossing", + "BankedPoint", + "CalibrationBin", + "ConceptInfo", + "EventInfo", + "EvidenceItem", + "EvidenceJob", + "Gallery", + "GalleryCase", + "GallerySection", + "Meta", + "NextEvent", + "OperatingPoint", + "PatientSummary", + "Readout", + "ScoreCell", + "Scorecard", + "TimelineEntry", + "VisitSummary", + "VisitTrace", + "WhatIfPreset", + "WhatIfResult", + "to_jsonable", +] diff --git a/apps/clinician_demo/scorecard.py b/apps/clinician_demo/scorecard.py new file mode 100644 index 00000000..490e2e3f --- /dev/null +++ b/apps/clinician_demo/scorecard.py @@ -0,0 +1,218 @@ +"""The model's report card, built from the run's banked held-out evaluation. + +Reads three aggregate files the evaluation chain already wrote into the +run directory and turns them into plain statements a clinician can check: + +- ``alerts.json``: per (event, horizon, scorer) AUROC, Brier, calibration + deciles and at-risk counts, for the hazard heads and the tuned GBM; +- ``alerts_cis.json``: subject-clustered bootstrap intervals, including + the paired hazard-minus-GBM difference and whether it is separated; +- ``inference_results.json``: per-concept readout AUROC. + +The headline is computed from those files, never written by hand, so it +cannot drift from the numbers beneath it. +""" + +import json +import logging +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from apps.clinician_demo.schemas import ( + CalibrationBin, + ConceptInfo, + Scorecard, + ScoreCell, +) +from apps.clinician_demo.thresholds import horizon_key + + +logger = logging.getLogger(__name__) + +ALERTS_FILENAME = "alerts.json" +CIS_FILENAME = "alerts_cis.json" +INFERENCE_FILENAME = "inference_results.json" +HAZARD_SCORER = "hazard" +GBM_SCORER = "baseline_gbm" + +NOTES = [ + "AUROC: how well the risk ranks patients who go on to have the event above " + "those who do not; 0.5 is chance, 1.0 is perfect.", + "Every number comes from held-out patients the model never trained on, " + "scored every 4 hours of each admission while the event had not yet happened.", + "The comparison model is a gradient-boosted classifier tuned on 609 " + "hand-built features from the same records. It is the stronger alert " + "model on most cells; this model's value is that its forecast is " + "decomposed into named clinical concepts.", + "Intervals are 95% subject-clustered bootstrap intervals. 'Separated' " + "means the paired difference's interval excludes zero.", +] + + +def read_json(path: Path) -> Any | None: # noqa: ANN401 -- arbitrary JSON + """Parse ``path`` or return ``None`` (logged) if it is missing or unreadable.""" + if not path.exists(): + logger.warning("[scorecard] missing %s", path) + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as exc: + logger.warning("[scorecard] unreadable %s: %s", path, exc) + return None + + +def _interval(block: Mapping[str, Any] | None) -> list[float] | None: + if not block or block.get("ci_low") is None or block.get("ci_high") is None: + return None + return [float(block["ci_low"]), float(block["ci_high"])] + + +def _find( + rows: Sequence[Mapping[str, Any]], event: str, h: float, scorer: str +) -> Mapping[str, Any] | None: + for row in rows: + if ( + row.get("event") == event + and row.get("scorer") == scorer + and float(row.get("horizon_hours", -1)) == h + ): + return row + return None + + +def score_cells( + alerts: Sequence[Mapping[str, Any]], + cis: Mapping[str, Any] | None, + events: Sequence[str], + horizons: Sequence[float], +) -> list[ScoreCell]: + """Build one cell per (event, horizon) that has a hazard row in ``alerts``.""" + cells: list[ScoreCell] = [] + ci_cells: Mapping[str, Any] = (cis or {}).get("cells", {}) + for event in events: + for h in horizons: + hazard = _find(alerts, event, h, HAZARD_SCORER) + if hazard is None: + continue + gbm = _find(alerts, event, h, GBM_SCORER) + ci = ci_cells.get(f"{event}@{horizon_key(h)}", {}) + scorers = ci.get("scorers", {}) + delta_block = ( + ci.get("paired_deltas", {}).get("hazard_minus_gbm", {}).get("auroc") + ) + n_at_risk = int(hazard.get("n_at_risk") or 0) + n_positive = int(hazard.get("n_positive") or 0) + hazard_auroc = hazard.get("auroc") + gbm_auroc = gbm.get("auroc") if gbm else None + delta = ( + float(delta_block["point"]) + if delta_block and delta_block.get("point") is not None + else ( + hazard_auroc - gbm_auroc + if hazard_auroc is not None and gbm_auroc is not None + else None + ) + ) + cells.append( + ScoreCell( + event=event, + horizon_hours=h, + n_at_risk=n_at_risk, + n_positive=n_positive, + base_rate=n_positive / n_at_risk if n_at_risk else 0.0, + hazard_auroc=hazard_auroc, + hazard_ci=_interval(scorers.get("hazard", {}).get("auroc")), + gbm_auroc=gbm_auroc, + gbm_ci=_interval(scorers.get("gbm", {}).get("auroc")), + delta=delta, + delta_ci=_interval(delta_block), + separated=delta_block.get("separated") if delta_block else None, + calibration=[ + CalibrationBin( + predicted=float(b["predicted"]), + observed=float(b["observed"]), + n=int(b["n"]), + ) + for b in hazard.get("calibration") or [] + if b.get("predicted") is not None + and b.get("observed") is not None + ], + ) + ) + return cells + + +def headline(cells: Sequence[ScoreCell]) -> str: + """Write one computed sentence comparing the model with the tuned GBM.""" + compared = [c for c in cells if c.delta is not None] + if not compared: + return "No GBM comparison is available for this run." + gbm_ahead = [c for c in compared if c.delta is not None and c.delta < 0] + model_ahead = [c for c in compared if c.delta is not None and c.delta > 0] + sep_gbm = sum(1 for c in gbm_ahead if c.separated) + sep_model = sum(1 for c in model_ahead if c.separated) + return ( + f"Against the tuned GBM on {len(compared)} event-horizon cells: the GBM ranks " + f"better on {len(gbm_ahead)} ({sep_gbm} clearly), this model on " + f"{len(model_ahead)} ({sep_model} clearly)." + ) + + +def concept_readouts( + inference: Mapping[str, Any] | None, concepts: Sequence[ConceptInfo] +) -> list[ConceptInfo]: + """Fill ``readout_auroc`` of ``concepts`` from ``inference_results.json``.""" + by_name: dict[str, float] = {} + for metric in (inference or {}).get("concept_metrics", []) or []: + if metric.get("auroc") is not None: + by_name[str(metric["name"])] = float(metric["auroc"]) + return [ + ConceptInfo( + name=c.name, + display=c.display, + description=c.description, + readout_auroc=by_name.get(c.name, c.readout_auroc), + ) + for c in concepts + ] + + +def build_scorecard( + run_dir: str | Path, + events: Sequence[str], + horizons: Sequence[float], + concepts: Sequence[ConceptInfo], +) -> Scorecard: + """Assemble the report card from the run directory's banked JSON files.""" + run_dir = Path(run_dir) + alerts = read_json(run_dir / ALERTS_FILENAME) or [] + cis = read_json(run_dir / CIS_FILENAME) + inference = read_json(run_dir / INFERENCE_FILENAME) + cells = score_cells(alerts, cis, events, horizons) + notes = list(NOTES) + if not alerts: + notes.insert(0, "This run has no banked alert evaluation; no cells to show.") + elif cis is None: + notes.insert( + 0, + "No bootstrap intervals are banked for this run; differences are point estimates.", + ) + return Scorecard( + headline=headline(cells), + cells=cells, + concepts=concept_readouts(inference, concepts), + notes=notes, + ) + + +__all__ = [ + "ALERTS_FILENAME", + "CIS_FILENAME", + "INFERENCE_FILENAME", + "build_scorecard", + "concept_readouts", + "headline", + "read_json", + "score_cells", +] diff --git a/apps/clinician_demo/server.py b/apps/clinician_demo/server.py new file mode 100644 index 00000000..76576aee --- /dev/null +++ b/apps/clinician_demo/server.py @@ -0,0 +1,342 @@ +"""A small, locked-down HTTP adapter over the demo service. + +Standard library only (the GPU host's environment is pinned; no web +framework is installed there). The server serves patient-level data, so it +is deliberately strict: + +- binds to loopback only and is reached through an SSH tunnel; +- rejects any ``Host`` header other than the loopback names it was bound + for (defends against DNS rebinding from a page in another tab); +- API calls must carry ``X-Odyssey-Demo: 1``, which a cross-site form + cannot set, and no CORS headers are ever sent; +- every response is ``Cache-Control: no-store`` (patient JSON must not land + in the laptop's browser cache) with a same-origin CSP; +- static files are served from an allowlisted directory with path + containment, nothing else on disk is reachable. +""" + +import json +import logging +import re +from collections.abc import Callable +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Protocol +from urllib.parse import unquote, urlsplit + +from apps.clinician_demo.config import LOOPBACK_HOSTS +from apps.clinician_demo.schemas import to_jsonable + + +logger = logging.getLogger(__name__) + +STATIC_DIR = Path(__file__).parent / "static" +API_HEADER = "X-Odyssey-Demo" +MAX_BODY_BYTES = 64 * 1024 +CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json", + ".png": "image/png", + ".ico": "image/x-icon", +} +SECURITY_HEADERS = { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": ( + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" + ), +} + + +class DemoAPI(Protocol): + """What the server needs from the service (a fake implements it in tests).""" + + def meta(self) -> object: + """Return the static facts the UI needs.""" + + def gallery(self) -> object: + """Return the curated gallery.""" + + def patient(self, subject_id: int) -> object: + """Return one patient's header facts and admissions.""" + + def trace(self, subject_id: int, visit_id: int) -> object: + """Return the replay view of one visit.""" + + def presets(self) -> object: + """Return the what-if controls.""" + + def whatif(self, subject_id: int, visit_id: int, body: dict[str, Any]) -> object: + """Compare the factual and edited forecast.""" + + def evidence(self, subject_id: int, visit_id: int, body: dict[str, Any]) -> object: + """Start or reuse an evidence job.""" + + def job(self, job_id: str) -> object: + """Return an evidence job's state.""" + + def scorecard(self) -> object: + """Return the model's report card.""" + + +class HttpError(Exception): + """An error with an HTTP status and a message safe to show the user.""" + + def __init__(self, status: HTTPStatus, message: str) -> None: + """Carry the status and message.""" + super().__init__(message) + self.status = status + self.message = message + + +Handler = Callable[[DemoAPI, re.Match[str], dict[str, Any]], tuple[HTTPStatus, object]] + + +def _ids(match: re.Match[str]) -> tuple[int, int]: + return int(match["sid"]), int(match["vid"]) + + +ROUTES: list[tuple[str, re.Pattern[str], Handler]] = [ + ("GET", re.compile(r"/api/meta"), lambda api, m, b: (HTTPStatus.OK, api.meta())), + ( + "GET", + re.compile(r"/api/gallery"), + lambda api, m, b: (HTTPStatus.OK, api.gallery()), + ), + ( + "GET", + re.compile(r"/api/whatif/presets"), + lambda api, m, b: (HTTPStatus.OK, api.presets()), + ), + ( + "GET", + re.compile(r"/api/scorecard"), + lambda api, m, b: (HTTPStatus.OK, api.scorecard()), + ), + ( + "GET", + re.compile(r"/api/patients/(?P\d{1,18})"), + lambda api, m, b: (HTTPStatus.OK, api.patient(int(m["sid"]))), + ), + ( + "GET", + re.compile(r"/api/patients/(?P\d{1,18})/visits/(?P-?\d{1,18})/trace"), + lambda api, m, b: (HTTPStatus.OK, api.trace(*_ids(m))), + ), + ( + "POST", + re.compile( + r"/api/patients/(?P\d{1,18})/visits/(?P-?\d{1,18})/whatif" + ), + lambda api, m, b: (HTTPStatus.OK, api.whatif(*_ids(m), b)), + ), + ( + "POST", + re.compile( + r"/api/patients/(?P\d{1,18})/visits/(?P-?\d{1,18})/evidence" + ), + lambda api, m, b: (HTTPStatus.ACCEPTED, api.evidence(*_ids(m), b)), + ), + ( + "GET", + re.compile(r"/api/jobs/(?P[A-Za-z0-9_-]{1,32})"), + lambda api, m, b: (HTTPStatus.OK, api.job(m["job"])), + ), +] + + +def resolve_static(path: str, root: Path = STATIC_DIR) -> Path | None: + """Return the file under ``root`` a URL path names, or ``None`` if unservable. + + ``/`` is ``index.html``; everything else must live under ``/static/``, + resolve inside ``root`` (no ``..`` or symlink escapes), exist, and have + an allowlisted extension. + """ + if path in ("/", "/index.html"): + relative = "index.html" + elif path.startswith("/static/"): + relative = unquote(path[len("/static/") :]) + else: + return None + root = root.resolve() + candidate = (root / relative).resolve() + if not candidate.is_relative_to(root) or not candidate.is_file(): + return None + return candidate if candidate.suffix in CONTENT_TYPES else None + + +def allowed_hosts(port: int) -> frozenset[str]: + """Return the ``Host`` header values a loopback server on ``port`` accepts.""" + names = {"localhost", "127.0.0.1", "[::1]"} + return frozenset({*names, *(f"{n}:{port}" for n in names)}) + + +class DemoRequestHandler(BaseHTTPRequestHandler): + """Route requests to the API or the static directory.""" + + server_version = "OdysseyDemo" + sys_version = "" + api: DemoAPI + static_root: Path = STATIC_DIR + + def do_GET(self) -> None: # noqa: N802 -- stdlib hook name + """Handle GET.""" + self._dispatch("GET") + + def do_POST(self) -> None: # noqa: N802 -- stdlib hook name + """Handle POST.""" + self._dispatch("POST") + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- stdlib signature + """Route access logs through :mod:`logging` (they stay on the host).""" + logger.info("%s %s", self.address_string(), format % args) + + def _dispatch(self, method: str) -> None: + try: + port = int(getattr(self.server, "server_port", 0)) + if self.headers.get("Host", "") not in allowed_hosts(port): + raise HttpError(HTTPStatus.FORBIDDEN, "unexpected Host header") + path = urlsplit(self.path).path + if path.startswith("/api/"): + self._api(method, path) + elif method == "GET": + self._static(path) + else: + raise HttpError(HTTPStatus.METHOD_NOT_ALLOWED, "method not allowed") + except HttpError as err: + self._send_json(err.status, {"error": err.message}) + + def _api(self, method: str, path: str) -> None: + if self.headers.get(API_HEADER) != "1": + raise HttpError(HTTPStatus.FORBIDDEN, f"missing {API_HEADER} header") + path_matched = False + for route_method, pattern, handler in ROUTES: + match = pattern.fullmatch(path) + if match is None: + continue + path_matched = True + if route_method != method: + continue + body = self._read_body() if method == "POST" else {} + status, payload = self._call(handler, match, body) + self._send_json(status, to_jsonable(payload)) + return + if path_matched: + raise HttpError(HTTPStatus.METHOD_NOT_ALLOWED, "method not allowed") + raise HttpError(HTTPStatus.NOT_FOUND, "no such endpoint") + + def _call( + self, handler: Handler, match: re.Match[str], body: dict[str, Any] + ) -> tuple[HTTPStatus, object]: + # Imported here only for the exception types, to keep this module + # importable (and testable) without torch. + from apps.clinician_demo.service import ( # noqa: PLC0415 + BadRequestError, + NotFoundError, + ) + + try: + return handler(self.api, match, body) + except NotFoundError as exc: + raise HttpError(HTTPStatus.NOT_FOUND, str(exc)) from exc + except BadRequestError as exc: + raise HttpError(HTTPStatus.BAD_REQUEST, str(exc)) from exc + except Exception as exc: + logger.exception("[server] %s failed", self.path) + raise HttpError( + HTTPStatus.INTERNAL_SERVER_ERROR, "internal error; see the server log" + ) from exc + + def _read_body(self) -> dict[str, Any]: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError as exc: + raise HttpError(HTTPStatus.BAD_REQUEST, "bad Content-Length") from exc + if length <= 0: + return {} + if length > MAX_BODY_BYTES: + raise HttpError( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "request body too large" + ) + try: + body = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise HttpError(HTTPStatus.BAD_REQUEST, "body must be JSON") from exc + if not isinstance(body, dict): + raise HttpError(HTTPStatus.BAD_REQUEST, "body must be a JSON object") + return body + + def _static(self, path: str) -> None: + file = resolve_static(path, self.static_root) + if file is None: + raise HttpError(HTTPStatus.NOT_FOUND, "not found") + data = file.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", CONTENT_TYPES[file.suffix]) + self.send_header("Content-Length", str(len(data))) + self._security_headers() + self.end_headers() + self.wfile.write(data) + + def _send_json(self, status: HTTPStatus, payload: object) -> None: + data = json.dumps(payload, allow_nan=False, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self._security_headers() + self.end_headers() + self.wfile.write(data) + + def _security_headers(self) -> None: + for name, value in SECURITY_HEADERS.items(): + self.send_header(name, value) + + +def make_server( + api: DemoAPI, + host: str = "127.0.0.1", + port: int = 8765, + *, + static_root: Path = STATIC_DIR, +) -> ThreadingHTTPServer: + """Create a threaded loopback server for ``api`` and ``static_root``. + + Raises + ------ + ValueError + If ``host`` is not a loopback address. + """ + if host not in LOOPBACK_HOSTS: + raise ValueError( + f"refusing to bind {host!r}: the demo serves patient data on loopback only" + ) + handler = type( + "BoundDemoHandler", + (DemoRequestHandler,), + {"api": api, "static_root": static_root}, + ) + server = ThreadingHTTPServer((host, port), handler) + server.daemon_threads = True + return server + + +__all__ = [ + "API_HEADER", + "CONTENT_TYPES", + "ROUTES", + "SECURITY_HEADERS", + "STATIC_DIR", + "DemoAPI", + "DemoRequestHandler", + "HttpError", + "allowed_hosts", + "make_server", + "resolve_static", +] diff --git a/apps/clinician_demo/service.py b/apps/clinician_demo/service.py new file mode 100644 index 00000000..f0a6e2a8 --- /dev/null +++ b/apps/clinician_demo/service.py @@ -0,0 +1,822 @@ +"""The demo's application layer: one method per API endpoint. + +Owns the loaded run, the patient store, caches and the single GPU lock +every model call goes through (one card, one job at a time). It speaks +schema objects, never HTTP: :mod:`apps.clinician_demo.server` is a thin +adapter over it, and tests drive it directly with a tiny CPU model. +""" + +import logging +import statistics +import threading +import time +from collections import OrderedDict +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import polars as pl + +from apps.clinician_demo.codebook import Codebook, admission_label +from apps.clinician_demo.config import DemoConfig +from apps.clinician_demo.evidence import ( + EvidenceRunner, + cached_or_submit, + candidate_codes, +) +from apps.clinician_demo.forecast import ( + ALERT_HORIZON_HOURS, + PatientTrace, + RunContext, + bundle_ends, + displayed_alerts, + onsets_for, + trace_patient, + visit_stats_from_trace, + visit_view, + visit_window, +) +from apps.clinician_demo.patient_store import ( + PatientStore, + UnknownPatientError, + build_shard_index, + discover_shards, + load_splits, +) +from apps.clinician_demo.schemas import ( + BankedPoint, + ConceptInfo, + EventInfo, + EvidenceItem, + EvidenceJob, + Gallery, + GalleryCase, + GallerySection, + Meta, + OperatingPoint, + PatientSummary, + Scorecard, + VisitSummary, + VisitTrace, + WhatIfPreset, + WhatIfResult, +) +from apps.clinician_demo.scorecard import build_scorecard +from apps.clinician_demo.showcase import ( + build_gallery, + empty_visit_stats, + visit_stats_from_alert_rows, +) +from apps.clinician_demo.thresholds import ( + ALERTS_ROWS_FILENAME, + horizon_key, + load_or_compute_operating_points, +) +from apps.clinician_demo.whatif import PRESETS, parse_edit_requests, run_whatif +from odyssey.data.alert_events import alert_events_for +from odyssey.data.concepts import canonical_concept_name, concept_display_name +from odyssey.data.sidecars import activate_sidecars +from odyssey.inference.concept_edit_attribution import occlude_codes +from odyssey.inference.legacy_concept_pins import resolve_concepts_for_run +from odyssey.inference.run_inference import load_run +from odyssey.models.sequence_model import ConceptBottleneckSequenceModel + + +logger = logging.getLogger(__name__) + +#: Display order and plain-language definitions of the forecast events. +EVENT_TEXT: dict[str, tuple[str, str, str]] = { + "icu_admission": ( + "ICU admission", + "ICU", + "First transfer into an intensive care unit during the admission.", + ), + "vasopressor_start": ( + "Vasopressors", + "Pressors", + "First dose of a vasopressor (norepinephrine, epinephrine, vasopressin, " + "phenylephrine, dopamine or angiotensin II) during the admission.", + ), + "acute_kidney_injury": ( + "Acute kidney injury", + "AKI", + "KDIGO stage 1 or worse: creatinine up 0.3 mg/dL within 48 h or to 1.5x " + "baseline within 7 days, or urine output under 0.5 mL/kg/h for 6 h.", + ), + "sepsis3": ( + "Sepsis-3", + "Sepsis", + "Suspected infection (a culture plus antibiotics) with an acute rise in " + "SOFA score of 2 or more.", + ), + "death": ("Death", "Death", "Death, during or after the admission."), +} +EVENT_ORDER = tuple(EVENT_TEXT) + +DISCLAIMERS: dict[str, str] = { + "banner": "Research prototype on retrospective data. Not for clinical use.", + "credentialed": ( + "Held-out MIMIC-IV patients the model never trained on. Only for viewers " + "holding PhysioNet MIMIC-IV credentials (data use agreement)." + ), + "open": ( + "Open MIMIC-IV Clinical Database Demo (100 patients). Most of these patients " + "were in the model's training data; each chart says whether it was." + ), + "whatif": ( + "Shows how the model's forecast responds to a changed record: model " + "sensitivity, not the effect of a treatment. The edit method was validated " + "on an earlier model version." + ), + "evidence": ( + "Recorded items the forecast leans on, found by removing each one and " + "re-scoring. Removing a normal reading pushes the forecast toward the " + "population average, so trust the size of a change more than its direction." + ), + "concepts": ( + "The model's running belief that each condition occurs during this " + "admission. A model reading, not a diagnosis." + ), + "risk": ( + "Chance the event first happens within the horizon, from this moment. " + "Hidden once the event has happened." + ), +} +TRACE_CACHE_SIZE = 16 +BANKED_CACHE_SIZE = 64 +MAX_LOOKBACK_HOURS = 72.0 + + +#: Clinical acronyms and casing the registry's snake_case names lose. +CONCEPT_LABELS: dict[str, str] = { + "sirs": "SIRS", + "qsofa": "qSOFA", + "sepsis3": "Sepsis-3", + "acute_kidney_injury": "AKI (any stage)", + "aki_stage_2": "AKI stage 2", + "aki_stage_3": "AKI stage 3", +} + + +def concept_label(name: str) -> str: + """Name a concept the way a clinician would write it.""" + canon = canonical_concept_name(name) + if canon in CONCEPT_LABELS: # hand-written: keep its exact casing (qSOFA) + return CONCEPT_LABELS[canon] + text = concept_display_name(canon) + return text[:1].upper() + text[1:] + + +class NotFoundError(LookupError): + """The requested patient, visit or job does not exist.""" + + +class BadRequestError(ValueError): + """The request is malformed or out of bounds.""" + + +def event_infos(events: tuple[str, ...]) -> list[EventInfo]: + """Return display names and definitions for ``events`` (fallback: raw name).""" + out = [] + for name in events: + display, short, definition = EVENT_TEXT.get( + name, (name.replace("_", " "), name, "") + ) + out.append( + EventInfo(name=name, display=display, short=short, definition=definition) + ) + return out + + +def _number( + body: Mapping[str, Any], + key: str, + lo: float, + hi: float, + default: float | None = None, +) -> float: + raw = body.get(key, default) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise BadRequestError(f"{key} must be a number") + value = float(raw) + if not lo <= value <= hi: + raise BadRequestError(f"{key} must be within [{lo:g}, {hi:g}]") + return value + + +@dataclass(frozen=True) +class Components: + """Everything a :class:`DemoService` is built from (injectable for tests).""" + + config: DemoConfig + ctx: RunContext + store: PatientStore + codebook: Codebook + operating_points: list[OperatingPoint] + scorecard: Scorecard + concepts: list[ConceptInfo] + banked_rows_path: Path | None + + +class DemoService: + """Serve the demo's API from a loaded run and a patient store.""" + + def __init__(self, parts: Components) -> None: + """Wire the service; call :meth:`prepare_gallery` before serving.""" + self.config = parts.config + self.ctx = parts.ctx + self.store = parts.store + self.codebook = parts.codebook + self.concepts = parts.concepts + self.events = event_infos(parts.ctx.events) + self._display = {e.name: e.display for e in self.events} + self._points = parts.operating_points + self._alert_points = { + p.event: p + for p in parts.operating_points + if p.horizon_hours == ALERT_HORIZON_HOURS + } + self._scorecard = parts.scorecard + self._banked_path = parts.banked_rows_path + self._gpu_lock = threading.Lock() + self._cache_lock = threading.Lock() + self._traces: OrderedDict[int, PatientTrace] = OrderedDict() + self._banked: OrderedDict[tuple[int, int], list[BankedPoint]] = OrderedDict() + self._evidence = EvidenceRunner(self._gpu_lock) + self._evidence_jobs: dict[tuple[object, ...], str] = {} + self._gallery = Gallery(sections=[]) + + # -- construction ----------------------------------------------------- + + @classmethod + def from_config(cls, config: DemoConfig) -> "DemoService": + """Load the run, index the data and precompute the aggregates.""" + model, vocab, binner, run_config = load_run( + config.run_dir, + device=config.device, + checkpoint_path=config.run_dir / config.checkpoint, + ) + heads = getattr(model, "event_heads", None) + if not isinstance(model, ConceptBottleneckSequenceModel) or heads is None: + raise ValueError( + "the demo needs a concept-bottleneck model with event hazard heads" + ) + source = getattr(run_config, "source", "mimic_iv") + task_set = getattr(run_config, "task_set", "v1") + definitions = resolve_concepts_for_run(str(config.run_dir), source, task_set) + alerts, head_index = displayed_alerts( + alert_events_for(task_set, source=source), heads.event_names, EVENT_ORDER + ) + ctx = RunContext( + model=model, + vocab=vocab, + binner=binner, + source=source, + task_set=task_set, + chunk_size=int(run_config.chunk_size), + device=config.device, + concept_names=tuple(d.name for d in definitions), + alerts=alerts, + head_index=head_index, + horizons=config.horizons, + ) + activate_sidecars(config.data_dir) + codebook = Codebook.from_metadata_dir(config.metadata_dir) + store = PatientStore( + build_shard_index( + discover_shards(config.data_dir, max_shards=config.max_shards) + ), + source=source, + normalize_medications=bool( + getattr(run_config, "normalize_medications", False) + ), + history_recap=bool(getattr(run_config, "history_recap", False)), + splits=load_splits(config.splits_path), + describe=admission_label, + ) + rows_path = config.run_dir / ALERTS_ROWS_FILENAME + points = ( + load_or_compute_operating_points( + rows_path, + config.resolved_cache_dir / "thresholds.json", + ctx.events, + config.horizons, + config.alert_rate, + ) + if rows_path.exists() + else [] + ) + concepts = [ + ConceptInfo(d.name, concept_label(d.name), d.description, None) + for d in definitions + ] + scorecard = build_scorecard( + config.run_dir, ctx.events, config.horizons, concepts + ) + service = cls( + Components( + config=config, + ctx=ctx, + store=store, + codebook=codebook, + operating_points=points, + scorecard=scorecard, + concepts=scorecard.concepts or concepts, + banked_rows_path=rows_path + if rows_path.exists() and config.data_mode == "credentialed" + else None, + ) + ) + service.prepare_gallery() + return service + + # -- gallery ---------------------------------------------------------- + + def prepare_gallery(self) -> Gallery: + """Build the gallery: from banked rows (credentialed) or by tracing (open).""" + thresholds = {e: p.threshold for e, p in self._alert_points.items()} + if self.config.data_mode == "credentialed" and self._banked_path is not None: + stats = self._stats_from_banked(thresholds) + gallery = build_gallery( + stats, + display=self._display, + seen_in_training=self.store.seen_in_training, + ) + else: + stats = self._stats_from_traces(thresholds) + gallery = build_gallery( + stats, + display=self._display, + seen_in_training=self.store.seen_in_training, + approximate_leads=False, + ) + gallery = Gallery( + sections=[*gallery.sections, self._all_patients_section()] + ) + self._gallery = gallery + return gallery + + def _stats_from_banked(self, thresholds: Mapping[str, float]) -> pl.DataFrame: + assert self._banked_path is not None # noqa: S101 -- checked by caller + key = horizon_key(ALERT_HORIZON_HOURS) + columns = [ + "subject_id", + "visit_id", + "time_hours", + "event", + f"hazard@{key}", + f"y@{key}", + ] + rows = ( + pl.scan_parquet(self._banked_path) + .select(columns) + .filter(pl.col("subject_id").cast(pl.Int64).is_in(self.store.subject_ids)) + .collect() + ) + return ( + visit_stats_from_alert_rows(rows, thresholds) + if rows.height + else empty_visit_stats() + ) + + def _stats_from_traces(self, thresholds: Mapping[str, float]) -> pl.DataFrame: + frames = [empty_visit_stats()] + for sid in self.store.subject_ids: + try: + trace = self._trace(sid) + visits = self.store.visits(sid) + raw = self.store.raw_events(sid) + except ValueError as exc: + logger.warning("[gallery] skipping subject %s: %s", sid, exc) + continue + onsets = { + v.visit_id: onsets_for( + raw, + self.ctx.alerts, + source=self.ctx.source, + task_set=self.ctx.task_set, + subject_id=sid, + visit_id=v.visit_id, + ) + for v in visits + } + frames.append( + visit_stats_from_trace( + trace, + visits, + onsets, + events=self.ctx.events, + horizons=self.ctx.horizons, + thresholds=thresholds, + ) + ) + return pl.concat(frames) + + def _all_patients_section(self) -> GallerySection: + cases = [] + for sid in self.store.subject_ids: + for visit in self.store.visits(sid): + cases.append( + GalleryCase( + subject_id=sid, + visit_id=visit.visit_id, + kind="other", + event=None, + headline=visit.admission, + lead_hours=None, + los_hours=visit.end_hours - visit.start_hours, + seen_in_training=self.store.seen_in_training(sid), + ) + ) + return GallerySection( + kind="other", + title="All patients", + summary=f"{len(cases)} admissions of {len(self.store)} patients", + cases=cases, + ) + + # -- endpoints -------------------------------------------------------- + + def meta(self) -> Meta: + """Return the static facts the UI needs.""" + mode = self.config.data_mode + return Meta( + run_name=self.config.run_name, + checkpoint=self.config.checkpoint, + data_mode=mode, + chunk_size=self.ctx.chunk_size, + horizons=list(self.ctx.horizons), + events=self.events, + concepts=self.concepts, + operating_points=self._points, + disclaimers={ + k: v + for k, v in DISCLAIMERS.items() + if k not in ("credentialed", "open") or k == mode + }, + searchable=mode == "credentialed", + ) + + def gallery(self) -> Gallery: + """Return the curated gallery (built at start-up).""" + return self._gallery + + def patient(self, subject_id: int) -> PatientSummary: + """Return the header facts and admissions of one patient.""" + try: + return self.store.summary(subject_id) + except UnknownPatientError as exc: + raise NotFoundError( + f"patient {subject_id} is not in the loaded data" + ) from exc + + def trace(self, subject_id: int, visit_id: int) -> VisitTrace: + """Return the replay view of one visit.""" + visit = self._visit(subject_id, visit_id) + trace = self._trace(subject_id) + onsets = onsets_for( + self.store.raw_events(subject_id), + self.ctx.alerts, + source=self.ctx.source, + task_set=self.ctx.task_set, + subject_id=subject_id, + visit_id=visit_id, + ) + return visit_view( + trace, + visit, + events=self.ctx.events, + horizons=self.ctx.horizons, + onsets=onsets, + points=self._alert_points, + codebook=self.codebook, + decode=self.ctx.vocab.decode, + display=self._display, + seen_in_training=self.store.seen_in_training(subject_id), + banked=self._banked_points(subject_id, visit_id, visit.start_hours), + ) + + def presets(self) -> list[WhatIfPreset]: + """Return the what-if controls.""" + return list(PRESETS) + + def whatif( + self, subject_id: int, visit_id: int, body: Mapping[str, Any] + ) -> WhatIfResult: + """Compare the factual and edited forecast at a moment of the visit.""" + t_hours = _number(body, "t_hours", -1e6, 1e6) + try: + requests = parse_edit_requests(body.get("edits")) + except ValueError as exc: + raise BadRequestError(str(exc)) from exc + index_time, t_snapped = self._moment(subject_id, visit_id, t_hours) + raw = self.store.raw_events(subject_id) + with self._gpu_lock: + return run_whatif( + self.ctx, raw, requests, index_time=index_time, t_hours=t_snapped + ) + + def evidence( + self, subject_id: int, visit_id: int, body: Mapping[str, Any] + ) -> EvidenceJob: + """Start (or reuse) an evidence search for a target at a moment.""" + t_hours = _number(body, "t_hours", -1e6, 1e6) + lookback = _number( + body, "lookback_hours", 1.0, MAX_LOOKBACK_HOURS, default=24.0 + ) + target = body.get("target") + if not isinstance(target, dict): + raise BadRequestError("target must be an object") + kind, name = target.get("kind"), str(target.get("name")) + if kind == "event": + if name not in self.ctx.events: + raise BadRequestError(f"unknown event {name!r}") + horizon = _number( + target, "horizon_hours", 0.0, 1e4, default=ALERT_HORIZON_HOURS + ) + if horizon not in self.ctx.horizons: + raise BadRequestError( + f"horizon must be one of {list(self.ctx.horizons)}" + ) + label = f"{self._display.get(name, name)} risk within {horizon:g} h" + key_h = horizon_key(horizon) + + def value_of(r: Any) -> float: # noqa: ANN401 -- ForecastReadout + return float(r.event_risk[name][key_h]) + + elif kind == "concept": + if name not in self.ctx.concept_names: + raise BadRequestError(f"unknown concept {name!r}") + label = f"belief in {concept_label(name)}" + + def value_of(r: Any) -> float: # noqa: ANN401 -- ForecastReadout + return float(r.concept_probs[name]) + + else: + raise BadRequestError("target.kind must be 'event' or 'concept'") + + index_time, t_snapped = self._moment(subject_id, visit_id, t_hours) + raw = self.store.raw_events(subject_id) + candidates = candidate_codes( + raw, index_time=index_time, lookback_hours=lookback + ) + + def work(progress: Any) -> list[EvidenceItem]: # noqa: ANN401 -- ProgressFn + ranked = occlude_codes( + self.ctx.model, + self.ctx.vocab, + self.ctx.binner, + raw, + index_time=index_time, + value_of=value_of, + concept_names=self.ctx.concept_names, + lookback_hours=lookback, + candidate_codes=candidates, + source=self.ctx.source, + device=self.ctx.device, + chunk_size=self.ctx.chunk_size, + horizons=self.ctx.horizons, + on_progress=progress, + ) + return [ + EvidenceItem( + code=a.code, + label=self.codebook.label(a.code), + n_rows=a.n_rows, + baseline=a.baseline, + occluded=a.occluded, + delta=a.delta, + ) + for a in ranked + ] + + note = ( + f"{label} at hour {t_snapped:.1f}; {len(candidates)} most frequent items of the " + f"last {lookback:g} h each removed in turn." + ) + key = ( + subject_id, + visit_id, + round(t_snapped, 4), + kind, + name, + key_h if kind == "event" else None, + lookback, + ) + return cached_or_submit( + self._evidence_jobs, + key, + self._evidence, + lambda: self._evidence.submit(label, note, work), + ) + + def job(self, job_id: str) -> EvidenceJob: + """Poll an evidence job.""" + try: + return self._evidence.get(job_id) + except KeyError as exc: + raise NotFoundError(f"no job {job_id!r}") from exc + + def scorecard(self) -> Scorecard: + """Return the model's report card.""" + return self._scorecard + + def shutdown(self) -> None: + """Release background workers.""" + self._evidence.shutdown() + + # -- internals ---------------------------------------------------------- + + def _visit(self, subject_id: int, visit_id: int) -> VisitSummary: + for visit in self.patient(subject_id).visits: + if visit.visit_id == visit_id: + return visit + raise NotFoundError(f"patient {subject_id} has no visit {visit_id}") + + def _trace(self, subject_id: int) -> PatientTrace: + with self._cache_lock: + cached = self._traces.get(subject_id) + if cached is not None: + self._traces.move_to_end(subject_id) + return cached + try: + raw = self.store.raw_events(subject_id) + except UnknownPatientError as exc: + raise NotFoundError( + f"patient {subject_id} is not in the loaded data" + ) from exc + with self._gpu_lock: + with self._cache_lock: # another request may have traced it meanwhile + cached = self._traces.get(subject_id) + if cached is not None: + return cached + trace = trace_patient(self.ctx, raw) + with self._cache_lock: + self._traces[subject_id] = trace + while len(self._traces) > TRACE_CACHE_SIZE: + self._traces.popitem(last=False) + return trace + + def _moment( + self, subject_id: int, visit_id: int, t_hours: float + ) -> tuple[object, float]: + """Return the timestamp of the last bundle end at or before ``t_hours``. + + Snaps to a moment the replay actually shows, and returns the exact + timestamp of that row (not a float round trip), so re-scoring reads + the same position the replay drew. + """ + visit = self._visit(subject_id, visit_id) + trace = self._trace(subject_id) + first, last = visit_window(trace.visit_ids, visit_id, trace.n_static) + ends = [i for i in bundle_ends(trace.times) if first <= i <= last] + target = visit.start_hours + t_hours + eligible = [i for i in ends if trace.times[i] <= target + 1e-9] + pos = eligible[-1] if eligible else ends[0] + return trace.timestamps[pos], trace.times[pos] - visit.start_hours + + def _banked_points( + self, subject_id: int, visit_id: int, start: float + ) -> list[BankedPoint]: + if self._banked_path is None: + return [] + key = (subject_id, visit_id) + with self._cache_lock: + if key in self._banked: + return self._banked[key] + h = horizon_key(ALERT_HORIZON_HOURS) + rows = ( + pl.scan_parquet(self._banked_path) + .select( + [ + "subject_id", + "visit_id", + "time_hours", + "event", + f"hazard@{h}", + f"gbm@{h}", + f"y@{h}", + ] + ) + .filter( + (pl.col("subject_id") == float(subject_id)) + & (pl.col("visit_id") == float(visit_id)) + ) + .filter(pl.col("event").is_in(list(self.ctx.events))) + .sort("time_hours") + .collect() + ) + points = [ + BankedPoint( + t=float(r["time_hours"]) - start, + event=str(r["event"]), + hazard_24h=r[f"hazard@{h}"], + gbm_24h=r[f"gbm@{h}"], + outcome_24h=r[f"y@{h}"], + ) + for r in rows.iter_rows(named=True) + ] + with self._cache_lock: + self._banked[key] = points + while len(self._banked) > BANKED_CACHE_SIZE: + self._banked.popitem(last=False) + return points + + # -- operations --------------------------------------------------------- + + def warm_up(self) -> threading.Thread: + """Trace every gallery patient in the background so first clicks are fast.""" + subjects = sorted( + {c.subject_id for s in self._gallery.sections for c in s.cases} + ) + + def run() -> None: + for sid in subjects: + try: + self._trace(sid) + except Exception: # noqa: BLE001 -- warm-up must never kill the server + logger.exception("[warm-up] subject %s failed", sid) + logger.info("[warm-up] traced %d gallery patients", len(subjects)) + + thread = threading.Thread(target=run, name="warm-up", daemon=True) + thread.start() + return thread + + def self_check(self) -> dict[str, Any]: + """Measure one gallery case end to end; raise on a broken invariant.""" + report: dict[str, Any] = { + "run": self.config.run_name, + "data_mode": self.config.data_mode, + "chunk_size": self.ctx.chunk_size, + "events": list(self.ctx.events), + "subjects": len(self.store), + "codebook_entries": len(self.codebook), + "operating_points": len(self._points), + "gallery": {s.kind: len(s.cases) for s in self._gallery.sections}, + } + if "readmission_30d" in self.ctx.events: + raise AssertionError("readmission must never be displayed") + case = next((c for s in self._gallery.sections for c in s.cases), None) + if case is None: + report["warning"] = "gallery is empty; nothing to trace" + return report + started = time.perf_counter() + view = self.trace(case.subject_id, case.visit_id) + report["trace_seconds"] = round(time.perf_counter() - started, 2) + trace = self._trace(case.subject_id) + report["trace_positions"] = trace.n_positions + report["unknown_token_share"] = round( + trace.n_unknown / max(trace.n_positions, 1), 4 + ) + report["replay_points"] = len(view.times) + if view.banked: + report["banked_gap_24h"] = self._banked_gap( + case.subject_id, case.visit_id, view.banked + ) + t_mid = view.times[len(view.times) // 2] + started = time.perf_counter() + result = self.whatif( + case.subject_id, + case.visit_id, + {"t_hours": t_mid, "edits": [{"preset": "sbp"}]}, + ) + report["whatif_seconds"] = round(time.perf_counter() - started, 2) + report["whatif_rows_edited"] = result.rows_edited + return report + + def _banked_gap( + self, subject_id: int, visit_id: int, banked: list[BankedPoint] + ) -> dict[str, float]: + """Compare demo and banked 24 h risk at the banked landmark moments.""" + trace = self._trace(subject_id) + visit = self._visit(subject_id, visit_id) + h_index = list(self.ctx.horizons).index(ALERT_HORIZON_HOURS) + gaps: list[float] = [] + for point in banked: + if point.hazard_24h is None or point.event not in self.ctx.events: + continue + t_abs = point.t + visit.start_hours + positions = [i for i, t in enumerate(trace.times) if abs(t - t_abs) < 1e-6] + if positions: + j = self.ctx.events.index(point.event) + gaps.append( + abs(float(trace.risk[positions[0], j, h_index]) - point.hazard_24h) + ) + if not gaps: + return {"n": 0} + return {"n": len(gaps), "median": statistics.median(gaps), "max": max(gaps)} + + +__all__ = [ + "DISCLAIMERS", + "EVENT_ORDER", + "EVENT_TEXT", + "CONCEPT_LABELS", + "BadRequestError", + "Components", + "DemoService", + "NotFoundError", + "concept_label", + "event_infos", +] diff --git a/apps/clinician_demo/showcase.py b/apps/clinician_demo/showcase.py new file mode 100644 index 00000000..26bd3e77 --- /dev/null +++ b/apps/clinician_demo/showcase.py @@ -0,0 +1,316 @@ +"""Pick the gallery: visits that show what the model does well AND badly. + +A gallery of only early warnings would overclaim. Every gallery therefore +has four sections, chosen by fixed deterministic rules from a visit-level +stats table: + +- **early warnings**: the event happened, and the 24 h alert had been on + continuously for at least ``MIN_LEAD_HOURS`` when it did; +- **quiet stays**: no event, and the risk stayed far below every line + (the model does not cry wolf on everyone); +- **misses**: the event happened with no alert on; +- **false alarms**: the alert came on and the event never followed. + +Lead time is measured from the start of the alert episode that was still +on when the event began, not from the first time the risk ever touched +the line: a line brushed on day 1 of a two-week stay is not a warning for +an event on day 12. Featured warnings are further limited to +``MIN_LEAD_HOURS``..``MAX_FEATURED_LEAD_HOURS`` (clinically actionable), +and every section states its rate over ALL eligible stays, so a +hand-picked case is never mistaken for the norm. + +The stats table has one row per (subject, visit, event); times are hours +since the subject's first event. ``end_hours`` is the event's onset for +positives and the last at-risk moment otherwise; ``first_cross_hours`` is +the first time the risk reached the line; ``alert_start_hours`` is the +start of the alert episode live at ``end_hours`` (positives only). +Credentialed mode builds the table from the run's banked landmark rows +(:func:`visit_stats_from_alert_rows`, no model run); open mode builds it +from traced patients. +""" + +from collections.abc import Callable, Mapping, Sequence + +import polars as pl + +from apps.clinician_demo.schemas import Gallery, GalleryCase, GallerySection +from apps.clinician_demo.thresholds import horizon_key + + +VISIT_STATS_SCHEMA: dict[str, pl.DataType] = { + "subject_id": pl.Int64(), + "visit_id": pl.Int64(), + "event": pl.Utf8(), + "positive": pl.Boolean(), + "first_cross_hours": pl.Float64(), + "alert_start_hours": pl.Float64(), + "end_hours": pl.Float64(), + "start_hours": pl.Float64(), + "max_risk": pl.Float64(), + "threshold": pl.Float64(), +} +MIN_LEAD_HOURS = 6.0 +MAX_FEATURED_LEAD_HOURS = 72.0 +MAX_STAY_HOURS = 14 * 24.0 +MIN_QUIET_STAY_HOURS = 48.0 +QUIET_FRACTION = 0.25 +LANDMARK_HOURS = 4.0 +_NEVER = -1e18 + + +def empty_visit_stats() -> pl.DataFrame: + """Return an empty stats table with the right schema.""" + return pl.DataFrame(schema=VISIT_STATS_SCHEMA) + + +def visit_stats_from_alert_rows( + rows: pl.DataFrame, + thresholds: Mapping[str, float], + horizon_hours: float = 24.0, +) -> pl.DataFrame: + """Build visit-level stats from banked landmark rows, one per (visit, event). + + ``rows`` are ``alerts_rows.parquet`` rows (``subject_id``/``visit_id`` + stored as floats, ``time_hours``, ``event``, ``hazard@{h}h``, + ``y@{h}h``). Landmark rows exist only while the patient is at risk, so + for a positive visit the onset lies within one landmark interval after + the last row; ``end_hours`` is set to that last row, which makes the + derived lead time a LOWER bound (the UI marks it approximate until the + patient is traced and the exact onset is known). + """ + key = horizon_key(horizon_hours) + hazard, outcome = f"hazard@{key}", f"y@{key}" + frame = rows.filter(pl.col("event").is_in(list(thresholds))).with_columns( + pl.col("subject_id").cast(pl.Int64), + pl.col("visit_id").cast(pl.Int64), + pl.col("event") + .replace_strict(thresholds, return_dtype=pl.Float64) + .alias("_thr"), + ) + if frame.height == 0: + return empty_visit_stats() + on = pl.col("_on") + time = pl.col("time_hours") + last_off = time.filter(~on).max().fill_null(_NEVER) + return ( + frame.with_columns((pl.col(hazard) >= pl.col("_thr")).alias("_on")) + .sort("time_hours") + .group_by("subject_id", "visit_id", "event", maintain_order=True) + .agg( + (pl.col(outcome) == 1).any().alias("positive"), + time.filter(on).min().alias("first_cross_hours"), + pl.when(on.last()) + .then(time.filter(on & (time > last_off)).min()) + .otherwise(None) + .alias("alert_start_hours"), + time.max().alias("end_hours"), + time.min().alias("start_hours"), + pl.col(hazard).max().alias("max_risk"), + pl.col("_thr").first().alias("threshold"), + ) + .with_columns( + pl.col("positive").fill_null(value=False), + pl.when(pl.col("positive").fill_null(value=False)) + .then(pl.col("alert_start_hours")) + .otherwise(None) + .alias("alert_start_hours"), + ) + .select(list(VISIT_STATS_SCHEMA)) + .cast(VISIT_STATS_SCHEMA) # type: ignore[arg-type] + ) + + +def _with_derived(stats: pl.DataFrame) -> pl.DataFrame: + return stats.with_columns( + (pl.col("end_hours") - pl.col("alert_start_hours")).alias("lead_hours"), + (pl.col("end_hours") - pl.col("start_hours")).alias("stay_hours"), + ) + + +def _pick( + frame: pl.DataFrame, n: int, sort_by: Sequence[str], descending: Sequence[bool] +) -> pl.DataFrame: + """Top ``n`` rows by ``sort_by``, one per subject, ties broken by ids.""" + ordered = frame.sort( + [*sort_by, "subject_id", "visit_id"], descending=[*descending, False, False] + ) + return ordered.unique("subject_id", keep="first", maintain_order=True).head(n) + + +def _pct(k: int, n: int) -> str: + return f"{round(100 * k / n)}%" if n else "n/a" + + +def _lead(kind: str, lead: object) -> float | None: + """Return an early warning's lead time (``None`` for other kinds).""" + if kind != "early_warning" or not isinstance(lead, (int, float)): + return None + return float(lead) + + +def build_gallery( # noqa: PLR0913 -- every knob is a documented selection rule + stats: pl.DataFrame, + *, + display: Mapping[str, str], + seen_in_training: Callable[[int], bool] = lambda _sid: False, + per_event: int = 3, + n_quiet: int = 3, + n_misses: int = 2, + n_false_alarms: int = 2, + approximate_leads: bool = True, +) -> Gallery: + """Build the four-section gallery from a visit-stats table. + + ``display`` maps event names to readable names and also fixes which + events are shown and in what order. ``approximate_leads`` marks lead + times as lower bounds (banked landmark rows) rather than exact. + """ + frame = _with_derived(stats.filter(pl.col("event").is_in(list(display)))) + approx = "≈" if approximate_leads else "" + + def _case(row: Mapping[str, object], kind: str, headline: str) -> GalleryCase: + sid = int(row["subject_id"]) # type: ignore[call-overload] + return GalleryCase( + subject_id=sid, + visit_id=int(row["visit_id"]), # type: ignore[call-overload] + kind=kind, + event=str(row["event"]) if row.get("event") is not None else None, + headline=headline, + lead_hours=_lead(kind, row.get("lead_hours")), + los_hours=float(row["stay_hours"]), # type: ignore[arg-type] + seen_in_training=seen_in_training(sid), + lead_approximate=approximate_leads and kind == "early_warning", + ) + + warning_cases: list[GalleryCase] = [] + rates: list[str] = [] + for event, name in display.items(): + positives = frame.filter((pl.col("event") == event) & pl.col("positive")) + if positives.height == 0: + continue + warned = positives.filter(pl.col("lead_hours") >= MIN_LEAD_HOURS) + rates.append( + f"{name} {warned.height} of {positives.height} " + f"({_pct(warned.height, positives.height)})" + ) + featured = warned.filter( + (pl.col("lead_hours") <= MAX_FEATURED_LEAD_HOURS) + & (pl.col("stay_hours") <= MAX_STAY_HOURS) + ) + warning_cases += [ + _case( + r, + "early_warning", + f"{name}: alert on {approx}{r['lead_hours']:.0f} h before it began", + ) + for r in _pick(featured, per_event, ["lead_hours"], [True]).iter_rows( + named=True + ) + ] + sections = [ + GallerySection( + kind="early_warning", + title="Early warnings", + summary=( + "Stays where the alert was already on at least " + f"{MIN_LEAD_HOURS:g} h when the event began: " + "; ".join(rates) + if rates + else "No stay in this data had one of the events." + ), + cases=warning_cases, + ) + ] + + by_visit = frame.group_by("subject_id", "visit_id").agg( + pl.col("positive").any().alias("any_positive"), + (pl.col("max_risk") < QUIET_FRACTION * pl.col("threshold")) + .all() + .alias("all_quiet"), + pl.col("stay_hours").max(), + pl.col("event").n_unique().alias("n_events"), + ) + quiet = by_visit.filter( + ~pl.col("any_positive") + & pl.col("all_quiet") + & (pl.col("n_events") == len(display)) + & (pl.col("stay_hours") >= MIN_QUIET_STAY_HOURS) + ).with_columns(pl.lit(None, dtype=pl.Utf8).alias("event")) + sections.append( + GallerySection( + kind="quiet", + title="Quiet stays", + summary=( + f"{quiet.height} stays had none of the events and stayed far below " + "every alert line" + ), + cases=[ + _case( + r, + "quiet", + f"No event; risk stayed low for {r['stay_hours'] / 24:.1f} days", + ) + for r in _pick(quiet, n_quiet, ["stay_hours"], [True]).iter_rows( + named=True + ) + ], + ) + ) + + positives_all = frame.filter(pl.col("positive")) + misses = positives_all.filter(pl.col("alert_start_hours").is_null()) + sections.append( + GallerySection( + kind="miss", + title="Misses", + summary=( + f"{misses.height} of {positives_all.height} events " + f"({_pct(misses.height, positives_all.height)}) began with no alert on" + ), + cases=[ + _case( + r, + "miss", + f"{display[str(r['event'])]} began with no alert on", + ) + for r in _pick(misses, n_misses, ["max_risk"], [False]).iter_rows( + named=True + ) + ], + ) + ) + + negatives = frame.filter(~pl.col("positive")) + alarms = negatives.filter(pl.col("first_cross_hours").is_not_null()) + sections.append( + GallerySection( + kind="false_alarm", + title="False alarms", + summary=( + f"In {alarms.height} of {negatives.height} cases " + f"({_pct(alarms.height, negatives.height)}) an event's alert came " + "on at least once and that event never happened during the stay" + ), + cases=[ + _case( + r, + "false_alarm", + f"{display[str(r['event'])]} alert came on; no event followed", + ) + for r in _pick(alarms, n_false_alarms, ["max_risk"], [True]).iter_rows( + named=True + ) + ], + ) + ) + return Gallery(sections=sections) + + +__all__ = [ + "LANDMARK_HOURS", + "MAX_FEATURED_LEAD_HOURS", + "MIN_LEAD_HOURS", + "VISIT_STATS_SCHEMA", + "build_gallery", + "empty_visit_stats", + "visit_stats_from_alert_rows", +] diff --git a/apps/clinician_demo/static/index.html b/apps/clinician_demo/static/index.html new file mode 100644 index 00000000..0a10e58b --- /dev/null +++ b/apps/clinician_demo/static/index.html @@ -0,0 +1,36 @@ + + + + + + Odyssey · Bedside Forecast + + + + + + +
+
+ +
+
Odyssey · Bedside Forecast
+
Loading the model…
+
+
+ + + +
+ +
+
+ + diff --git a/apps/clinician_demo/static/js/api.js b/apps/clinician_demo/static/js/api.js new file mode 100644 index 00000000..ac06b78e --- /dev/null +++ b/apps/clinician_demo/static/js/api.js @@ -0,0 +1,73 @@ +/** + * The only module that talks to the server. Every request carries the + * demo header the server requires, so a page on another origin cannot + * drive the API through the viewer's browser. + */ + +const DEMO_HEADER = { 'X-Odyssey-Demo': '1' }; + +/** An API failure carrying the server's message and HTTP status. */ +export class ApiError extends Error { + /** + * @param {string} message + * @param {number} status + */ + constructor(message, status) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +/** + * Send one JSON request. + * @param {string} path + * @param {{method?: string, body?: *}} [opts] + * @returns {Promise<*>} the parsed JSON body + */ +export async function request(path, { method = 'GET', body } = {}) { + const headers = { ...DEMO_HEADER, Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + let response; + try { + response = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + cache: 'no-store', + credentials: 'same-origin', + }); + } catch { + throw new ApiError('Cannot reach the demo server. Is the SSH tunnel still open?', 0); + } + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + if (!response.ok) { + const message = payload && typeof payload.error === 'string' + ? payload.error + : `The server answered ${response.status}.`; + throw new ApiError(message, response.status); + } + return payload; +} + +const enc = encodeURIComponent; + +/** Typed shortcuts for every endpoint the UI uses. */ +export const api = { + meta: () => request('/api/meta'), + gallery: () => request('/api/gallery'), + patient: (sid) => request(`/api/patients/${enc(sid)}`), + trace: (sid, vid) => request(`/api/patients/${enc(sid)}/visits/${enc(vid)}/trace`), + presets: () => request('/api/whatif/presets'), + whatif: (sid, vid, body) => + request(`/api/patients/${enc(sid)}/visits/${enc(vid)}/whatif`, { method: 'POST', body }), + evidence: (sid, vid, body) => + request(`/api/patients/${enc(sid)}/visits/${enc(vid)}/evidence`, { method: 'POST', body }), + job: (jobId) => request(`/api/jobs/${enc(jobId)}`), + scorecard: () => request('/api/scorecard'), +}; diff --git a/apps/clinician_demo/static/js/app.js b/apps/clinician_demo/static/js/app.js new file mode 100644 index 00000000..69b7a195 --- /dev/null +++ b/apps/clinician_demo/static/js/app.js @@ -0,0 +1,113 @@ +/** + * Entry point: load model metadata, fill the page chrome, and route + * between views by URL hash. + * + * #/ gallery + * #/scorecard report card + * #/p/{sid} one patient's admissions + * #/p/{sid}/v/{vid}?t= replay one admission (optionally at hour t) + */ + +import { api } from './api.js'; +import { el, errorBlock, loadingBlock } from './dom.js'; +import { store } from './state.js'; +import { renderGallery } from './views/gallery.js'; +import { renderPatient } from './views/patient.js'; +import { renderReplay } from './views/replay.js'; +import { renderScorecard } from './views/scorecard.js'; + +const main = document.getElementById('app'); +let cleanup = () => {}; +let renderToken = 0; + +/** + * Parse a location hash into a route. + * @param {string} hash + * @returns {{name: string, sid?: string, vid?: string, t?: number|null}} + */ +export function parseRoute(hash) { + const raw = hash.replace(/^#/, '') || '/'; + const [path, query = ''] = raw.split('?'); + const params = new URLSearchParams(query); + const tRaw = params.get('t'); + const t = tRaw != null && tRaw !== '' && !Number.isNaN(Number(tRaw)) ? Number(tRaw) : null; + let m = path.match(/^\/p\/(\d+)\/v\/(\d+)\/?$/); + if (m) return { name: 'replay', sid: m[1], vid: m[2], t }; + m = path.match(/^\/p\/(\d+)\/?$/); + if (m) return { name: 'patient', sid: m[1] }; + if (path === '/scorecard') return { name: 'scorecard' }; + if (path === '/' || path === '') return { name: 'gallery' }; + return { name: 'notfound' }; +} + +function markNav(route) { + const active = route.name === 'scorecard' ? 'scorecard' : 'gallery'; + document.querySelectorAll('[data-nav]').forEach((a) => { + a.classList.toggle('is-active', a.dataset.nav === active); + if (a.dataset.nav === active) a.setAttribute('aria-current', 'page'); + else a.removeAttribute('aria-current'); + }); +} + +async function render() { + const token = ++renderToken; + cleanup(); + cleanup = () => {}; + const meta = store.get().meta; + const route = parseRoute(window.location.hash); + store.set({ route }); + markNav(route); + window.scrollTo({ top: 0 }); + const views = { + gallery: () => renderGallery(main, { meta }), + scorecard: () => renderScorecard(main, { meta }), + patient: () => renderPatient(main, route), + replay: () => renderReplay(main, { meta, ...route }), + }; + const view = views[route.name]; + if (!view) { + main.replaceChildren(errorBlock('That page does not exist.'), el('p', {}, el('a', { href: '#/', text: 'Back to patients' }))); + return; + } + const done = await view(); + if (token === renderToken) cleanup = done; + else done(); + main.focus({ preventScroll: true }); +} + +function fillChrome(meta) { + document.getElementById('provenance').textContent = + `Model ${meta.run_name} · ${meta.checkpoint} · forecasts ${meta.events.length} events at ${meta.horizons.join(' / ')} h`; + const badge = document.getElementById('mode-badge'); + badge.hidden = false; + badge.textContent = meta.data_mode === 'open' ? 'Open demo data' : 'Credentialed data · PhysioNet DUA'; + badge.className = `mode-badge mode-badge--${meta.data_mode}`; + const banner = document.getElementById('banner'); + banner.textContent = [meta.disclaimers.banner, meta.disclaimers[meta.data_mode]].filter(Boolean).join(' '); + document.getElementById('footer').textContent = + 'Times are hours since the start of each admission. Dates are never shown.'; + const search = document.getElementById('search'); + search.hidden = !meta.searchable; + search.addEventListener('submit', (event) => { + event.preventDefault(); + const id = new FormData(search).get('patient')?.toString().trim() ?? ''; + if (/^\d+$/.test(id)) window.location.hash = `#/p/${id}`; + }); +} + +async function start() { + main.replaceChildren(loadingBlock('Loading the model…')); + try { + const meta = await api.meta(); + store.set({ meta }); + fillChrome(meta); + } catch (err) { + main.replaceChildren(errorBlock(err)); + document.getElementById('provenance').textContent = 'Model unavailable'; + return; + } + window.addEventListener('hashchange', render); + render(); +} + +start(); diff --git a/apps/clinician_demo/static/js/charts/concept_strip.js b/apps/clinician_demo/static/js/charts/concept_strip.js new file mode 100644 index 00000000..33fc9d39 --- /dev/null +++ b/apps/clinician_demo/static/js/charts/concept_strip.js @@ -0,0 +1,135 @@ +/** + * Concept heat strip (canvas): one row per named concept, time left to + * right, darker = the model believes the concept is more likely present + * this visit. Row labels are HTML so they stay crisp and readable by + * screen readers. + */ + +import { el } from '../dom.js'; +import { clock, pct, nearestIndex } from '../format.js'; +import { cssVar, hexToRgb } from '../theme.js'; + +const ROW_HEIGHT = 14; + +/** + * Create the strip inside a container. + * @param {HTMLElement} container + * @param {{concepts: object[], onScrub: (index: number) => void}} opts + * @returns {{update: (data: {times: number[], values: number[][]}) => void, + * setCursor: (i: number) => void, redraw: () => void, destroy: () => void}} + */ +export function createConceptStrip(container, { concepts, onScrub }) { + const labels = el( + 'ul', + { class: 'concept-strip__labels', 'aria-hidden': 'true' }, + concepts.map((c) => el('li', { title: c.description, text: c.display })), + ); + const canvas = el('canvas', { + role: 'img', + 'aria-label': 'How strongly the model believes each clinical concept is present, over time', + }); + const cursorLine = el('div', { class: 'concept-strip__cursor' }); + const tip = el('div', { class: 'chart-tip', hidden: true }); + const plot = el('div', { class: 'concept-strip__plot' }, [canvas, cursorLine, tip]); + const wrap = el('div', { class: 'concept-strip' }, [labels, plot]); + container.append(wrap); + + const height = concepts.length * ROW_HEIGHT; + let data = null; + let cursor = 0; + + function columnEdges(width) { + const { times } = data; + const t0 = times[0]; + const t1 = times[times.length - 1] > t0 ? times[times.length - 1] : t0 + 1; + const x = (t) => ((t - t0) / (t1 - t0)) * width; + return { x, t0, t1 }; + } + + function draw() { + if (!data || !data.times.length) return; + const width = Math.max(200, plot.clientWidth || 600); + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.setProperty('height', `${height}px`); + const ctx = canvas.getContext('2d'); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.fillStyle = cssVar('--surface-2', '#ECF1F2'); + ctx.fillRect(0, 0, width, height); + const [r, g, b] = hexToRgb(cssVar('--accent', '#0B6E77')); + const { times, values } = data; + const { x } = columnEdges(width); + for (let i = 0; i < times.length; i += 1) { + const left = i === 0 ? 0 : (x(times[i - 1]) + x(times[i])) / 2; + const right = i === times.length - 1 ? width : (x(times[i]) + x(times[i + 1])) / 2; + const w = Math.max(1, right - left + 0.5); + const row = values[i] ?? []; + for (let c = 0; c < concepts.length; c += 1) { + const v = row[c]; + if (v == null || v < 0.02) continue; + // Squared so a 0.9 belief reads clearly darker than a 0.6 one. + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${(0.06 + 0.94 * v * v).toFixed(3)})`; + ctx.fillRect(left, c * ROW_HEIGHT + 1, w, ROW_HEIGHT - 2); + } + } + positionCursor(); + } + + function positionCursor() { + if (!data || !data.times.length) return; + const width = plot.clientWidth || 600; + const { x } = columnEdges(width); + cursorLine.style.setProperty('left', `${x(data.times[Math.min(cursor, data.times.length - 1)]) - 1}px`); + cursorLine.style.setProperty('height', `${height}px`); + const row = data.values[cursor] ?? []; + labels.querySelectorAll('li').forEach((li, c) => li.classList.toggle('is-hot', (row[c] ?? 0) >= 0.5)); + } + + function locate(event) { + const rect = canvas.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + const { t0, t1 } = columnEdges(rect.width); + const i = nearestIndex(data.times, t0 + (px / rect.width) * (t1 - t0)); + const c = Math.min(concepts.length - 1, Math.max(0, Math.floor(py / ROW_HEIGHT))); + return { i, c, px, py }; + } + + canvas.addEventListener('mousemove', (event) => { + if (!data) return; + const { i, c, px, py } = locate(event); + const concept = concepts[c]; + tip.replaceChildren( + el('div', { class: 'chart-tip__time', text: `${concept.display} · ${pct(data.values[i]?.[c])}` }), + el('div', { class: 'muted', text: concept.description }), + el('div', { class: 'faint', text: clock(data.times[i]) }), + ); + tip.hidden = false; + const maxLeft = plot.clientWidth - tip.offsetWidth - 4; + tip.style.setProperty('left', `${Math.min(px + 14, maxLeft)}px`); + tip.style.setProperty('top', `${py + 12}px`); + }); + canvas.addEventListener('mouseleave', () => { tip.hidden = true; }); + canvas.addEventListener('click', (event) => { + if (data) onScrub(locate(event).i); + }); + const observer = new ResizeObserver(() => draw()); + observer.observe(plot); + + return { + update(next) { + data = next; + draw(); + }, + setCursor(i) { + cursor = i; + positionCursor(); + }, + redraw: draw, + destroy() { + observer.disconnect(); + wrap.remove(); + }, + }; +} diff --git a/apps/clinician_demo/static/js/charts/risk_chart.js b/apps/clinician_demo/static/js/charts/risk_chart.js new file mode 100644 index 00000000..a9b7874e --- /dev/null +++ b/apps/clinician_demo/static/js/charts/risk_chart.js @@ -0,0 +1,250 @@ +/** + * Risk-over-time chart (SVG): one line per event, its dashed alert line, + * onset markers, care-transition ticks, an optional GBM overlay, a scrub + * cursor, a hover tooltip and click-to-scrub. + */ + +import { el, svg } from '../dom.js'; +import { clock, pct, nearestIndex } from '../format.js'; + +const HEIGHT = 300; +const MARGIN = { left: 50, right: 18, top: 26, bottom: 30 }; +const Y_STEPS = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.25]; + +function niceMax(maxValue) { + const target = Math.min(1, Math.max(0.02, maxValue * 1.15)); + for (const step of Y_STEPS) { + const top = Math.ceil(target / step) * step; + if (top / step <= 5) return { top: Math.min(1, top), step }; + } + return { top: 1, step: 0.2 }; +} + +function xTickStep(spanHours) { + if (spanHours <= 36) return 6; + if (spanHours <= 96) return 12; + if (spanHours <= 24 * 10) return 24; + return 48; +} + +function xTickLabel(t, step) { + if (step >= 24) return `Day ${Math.floor(t / 24) + 1}`; + const hh = String(Math.round(t % 24)).padStart(2, '0'); + return `D${Math.floor(t / 24) + 1} ${hh}:00`; +} + +function linePath(times, values, x, y) { + let d = ''; + let open = false; + for (let i = 0; i < times.length; i += 1) { + const v = values[i]; + if (v == null) { + open = false; + continue; + } + d += `${open ? 'L' : 'M'}${x(times[i]).toFixed(1)},${y(v).toFixed(1)}`; + open = true; + } + return d; +} + +/** + * Create the chart inside a container. + * @param {HTMLElement} container + * @param {{onScrub: (index: number) => void, label?: string}} opts + * @returns {{update: (data: object) => void, setCursor: (i: number) => void, + * redraw: () => void, destroy: () => void}} + */ +export function createRiskChart(container, { onScrub, label = 'Risk over the admission' }) { + const root = svg('svg', { class: 'risk-chart__svg', role: 'img', 'aria-label': label }); + const tip = el('div', { class: 'chart-tip', hidden: true }); + const wrap = el('div', { class: 'risk-chart' }, [root, tip]); + container.append(wrap); + + let data = null; + let cursor = 0; + let scale = null; + let cursorLine = null; + + function draw() { + root.replaceChildren(); + if (!data || !data.times.length) return; + const width = Math.max(320, wrap.clientWidth || 800); + root.setAttribute('viewBox', `0 0 ${width} ${HEIGHT}`); + root.setAttribute('width', String(width)); + root.setAttribute('height', String(HEIGHT)); + const { times, series } = data; + const t0 = times[0]; + const t1 = times[times.length - 1] > t0 ? times[times.length - 1] : t0 + 1; + const plotW = width - MARGIN.left - MARGIN.right; + const plotH = HEIGHT - MARGIN.top - MARGIN.bottom; + let maxValue = 0; + for (const s of series) { + for (const v of s.values) if (v != null && v > maxValue) maxValue = v; + if (s.threshold != null && s.threshold > maxValue) maxValue = s.threshold; + } + const { top, step } = niceMax(maxValue); + const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0)) * plotW; + const y = (v) => MARGIN.top + plotH - (Math.min(v, top) / top) * plotH; + scale = { x, t0, t1, plotW }; + + const grid = svg('g', { class: 'grid' }); + const axis = svg('g', { class: 'axis' }); + for (let v = 0; v <= top + 1e-9; v += step) { + grid.append(svg('line', { x1: MARGIN.left, x2: width - MARGIN.right, y1: y(v), y2: y(v) })); + axis.append(svg('text', { x: MARGIN.left - 8, y: y(v) + 4, 'text-anchor': 'end', text: pct(v) })); + } + const xStep = xTickStep(t1 - t0); + for (let t = Math.ceil(t0 / xStep) * xStep; t <= t1; t += xStep) { + grid.append(svg('line', { x1: x(t), x2: x(t), y1: MARGIN.top, y2: MARGIN.top + plotH })); + const nearRight = x(t) > width - MARGIN.right - 30; + axis.append(svg('text', { + x: nearRight ? width - MARGIN.right : x(t), + y: HEIGHT - 10, + 'text-anchor': nearRight ? 'end' : 'middle', + text: xTickLabel(t, xStep), + })); + } + root.append(grid, axis); + + const markers = svg('g', { class: 'marker' }); + for (const m of data.markers ?? []) { + if (m.t < t0 || m.t > t1) continue; + markers.append( + svg('line', { x1: x(m.t), x2: x(m.t), y1: MARGIN.top, y2: MARGIN.top + plotH }, [ + svg('title', { text: `${m.label} · ${clock(m.t)}` }), + ]), + ); + } + root.append(markers); + + for (const s of series) { + if (s.threshold == null) continue; + root.append( + svg('line', { + x1: MARGIN.left, x2: width - MARGIN.right, y1: y(s.threshold), y2: y(s.threshold), + stroke: s.color, 'stroke-width': 1, 'stroke-dasharray': '5 4', opacity: 0.55, + }, [svg('title', { text: `${s.label}: alert line ${pct(s.threshold)}` })]), + ); + } + + // Onset labels are stacked in rows so events that begin close together + // never print on top of each other; a label near the right edge flips + // to the left of its line. + const onsets = svg('g', { class: 'onset' }); + const placed = []; + const visible = (data.onsets ?? []) + .filter((o) => o.t != null && o.t >= t0 && o.t <= t1) + .sort((a, b) => a.t - b.t); + for (const o of visible) { + const px = x(o.t); + const text = `${o.label} began`; + const w = text.length * 6.6 + 10; + const flip = px + w > width - MARGIN.right; + const span = flip ? [px - w, px] : [px, px + w]; + let row = 0; + while (placed.some((p) => p.row === row && span[0] < p.span[1] && span[1] > p.span[0])) row += 1; + placed.push({ row, span }); + onsets.append( + svg('line', { + x1: px, x2: px, y1: MARGIN.top, y2: MARGIN.top + plotH, + stroke: o.color, 'stroke-width': 2, 'stroke-dasharray': '1 3', + }), + svg('text', { + x: flip ? px - 4 : px + 4, + y: MARGIN.top + 12 + row * 14, + 'text-anchor': flip ? 'end' : 'start', + fill: o.color, + text, + }), + ); + } + root.append(onsets); + + if (data.overlay && data.overlay.points.length) { + const pts = data.overlay.points.filter((p) => p.v != null && p.t >= t0 && p.t <= t1); + const g = svg('g', { opacity: 0.85 }); + g.append(svg('path', { + d: linePath(pts.map((p) => p.t), pts.map((p) => p.v), x, y), + fill: 'none', stroke: data.overlay.color, 'stroke-width': 1.2, 'stroke-dasharray': '3 3', + })); + for (const p of pts) g.append(svg('circle', { cx: x(p.t), cy: y(p.v), r: 2.2, fill: data.overlay.color })); + g.append(svg('title', { text: data.overlay.label })); + root.append(g); + } + + for (const s of series) { + root.append(svg('path', { + d: linePath(times, s.values, x, y), fill: 'none', stroke: s.color, + 'stroke-width': 2.2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', + })); + } + + cursorLine = svg('line', { class: 'cursor', y1: MARGIN.top - 4, y2: MARGIN.top + plotH }); + root.append(cursorLine); + const hit = svg('rect', { + x: MARGIN.left, y: MARGIN.top, width: plotW, height: plotH, fill: 'transparent', + }); + root.append(hit); + positionCursor(); + } + + function positionCursor() { + if (!cursorLine || !scale || !data) return; + const t = data.times[Math.min(cursor, data.times.length - 1)]; + cursorLine.setAttribute('x1', String(scale.x(t))); + cursorLine.setAttribute('x2', String(scale.x(t))); + } + + function indexAt(event) { + if (!scale || !data) return null; + const rect = root.getBoundingClientRect(); + const px = ((event.clientX - rect.left) / rect.width) * Number(root.getAttribute('width')); + const t = scale.t0 + ((px - MARGIN.left) / scale.plotW) * (scale.t1 - scale.t0); + return nearestIndex(data.times, t); + } + + function showTip(event) { + const i = indexAt(event); + if (i == null) return; + const rows = data.series.map((s) => + el('div', { class: 'chart-tip__row' }, [ + el('span', { class: 'dot', style: { background: s.color } }), + el('span', { text: s.label }), + el('strong', { text: s.values[i] == null ? '—' : pct(s.values[i]) }), + ]), + ); + tip.replaceChildren(el('div', { class: 'chart-tip__time', text: clock(data.times[i]) }), ...rows); + tip.hidden = false; + const wrapRect = wrap.getBoundingClientRect(); + const left = event.clientX - wrapRect.left + 14; + const maxLeft = wrapRect.width - tip.offsetWidth - 4; + tip.style.setProperty('left', `${Math.min(left, maxLeft)}px`); + tip.style.setProperty('top', `${Math.max(0, event.clientY - wrapRect.top - 20)}px`); + } + + root.addEventListener('mousemove', showTip); + root.addEventListener('mouseleave', () => { tip.hidden = true; }); + root.addEventListener('click', (event) => { + const i = indexAt(event); + if (i != null) onScrub(i); + }); + const observer = new ResizeObserver(() => draw()); + observer.observe(wrap); + + return { + update(next) { + data = next; + draw(); + }, + setCursor(i) { + cursor = i; + positionCursor(); + }, + redraw: draw, + destroy() { + observer.disconnect(); + wrap.remove(); + }, + }; +} diff --git a/apps/clinician_demo/static/js/dom.js b/apps/clinician_demo/static/js/dom.js new file mode 100644 index 00000000..fc27eef0 --- /dev/null +++ b/apps/clinician_demo/static/js/dom.js @@ -0,0 +1,103 @@ +/** + * Small DOM helpers. Everything is built with createElement and CSSOM + * writes, never with inline style/handler attributes, so the page works + * under a strict Content-Security-Policy. + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function appendChildren(node, children) { + for (const child of [children].flat(Infinity)) { + if (child == null || child === false) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } +} + +/** + * Create an HTML element. + * @param {string} tag + * @param {Object} [props] class, text, dataset, style (object of + * CSS properties), on* listeners, or plain attributes. + * @param {Array|Node|string} [children] + * @returns {HTMLElement} + */ +export function el(tag, props = {}, children = []) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props)) { + if (value == null || value === false) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = value; + else if (key === 'dataset') Object.assign(node.dataset, value); + else if (key === 'style') { + for (const [prop, v] of Object.entries(value)) node.style.setProperty(prop, v); + } else if (key.startsWith('on') && typeof value === 'function') { + node.addEventListener(key.slice(2).toLowerCase(), value); + } else node.setAttribute(key, value === true ? '' : String(value)); + } + appendChildren(node, children); + return node; +} + +/** + * Create an SVG element with attributes. + * @param {string} tag + * @param {Object} [attrs] + * @param {Array|Node|string} [children] + * @returns {SVGElement} + */ +export function svg(tag, attrs = {}, children = []) { + const node = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attrs)) { + if (value == null || value === false) continue; + if (key === 'text') node.textContent = value; + else if (key === 'class') node.setAttribute('class', value); + else node.setAttribute(key, String(value)); + } + appendChildren(node, children); + return node; +} + +/** + * A centered "working on it" message. + * @param {string} message + * @returns {HTMLElement} + */ +export function loadingBlock(message) { + return el('div', { class: 'state-msg', role: 'status' }, [el('div', { class: 'spinner' }), message]); +} + +/** + * A centered error message. + * @param {Error|string} err + * @returns {HTMLElement} + */ +export function errorBlock(err) { + const text = err instanceof Error ? err.message : String(err); + return el('div', { class: 'state-msg state-msg--error', role: 'alert' }, text || 'Something went wrong.'); +} + +/** + * A centered "nothing to show" message. + * @param {string} message + * @returns {HTMLElement} + */ +export function emptyBlock(message) { + return el('div', { class: 'state-msg' }, message); +} + +/** + * A titled card. + * @param {string|Node} title + * @param {Array|Node} children + * @param {{sub?: string, actions?: Array|Node, className?: string}} [opts] + * @returns {HTMLElement} + */ +export function card(title, children, opts = {}) { + const head = el('div', { class: 'card__head' }, [ + typeof title === 'string' ? el('h2', { text: title }) : title, + opts.sub ? el('span', { class: 'card__sub', text: opts.sub }) : null, + opts.actions ? el('span', { class: 'spacer' }) : null, + opts.actions ?? null, + ]); + return el('section', { class: `card ${opts.className ?? ''}`.trim() }, [head, children]); +} diff --git a/apps/clinician_demo/static/js/format.js b/apps/clinician_demo/static/js/format.js new file mode 100644 index 00000000..9f01fd17 --- /dev/null +++ b/apps/clinician_demo/static/js/format.js @@ -0,0 +1,150 @@ +/** + * Formatting for clinicians: percentages, visit clock times, durations, + * "times typical" wording and trend arrows. Pure functions, no DOM. + */ + +/** + * A probability as a percentage: 0.031 -> "3.1%", 0.45 -> "45%". + * @param {number|null|undefined} p + * @returns {string} + */ +export function pct(p) { + if (p == null || Number.isNaN(p)) return '—'; + const v = p * 100; + if (v > 0 && v < 0.1) return '<0.1%'; + return v < 10 ? `${v.toFixed(1)}%` : `${Math.round(v)}%`; +} + +/** + * A probability difference in percentage points: 0.021 -> "+2.1 pts". + * @param {number|null|undefined} d + * @returns {string} + */ +export function points(d) { + if (d == null || Number.isNaN(d)) return '—'; + const v = d * 100; + if (Math.abs(v) < 0.05) return '±0.0 pts'; + return `${v > 0 ? '+' : '−'}${Math.abs(v).toFixed(1)} pts`; +} + +/** + * Visit-relative hours as a clock: 30.5 -> "Day 2 · 06:30" (hour 0 = admission). + * @param {number|null|undefined} h + * @returns {string} + */ +export function clock(h) { + if (h == null || Number.isNaN(h)) return '—'; + if (h < 0) return `${duration(-h)} before admission`; + let day = Math.floor(h / 24); + let minutes = Math.round((h - day * 24) * 60); + if (minutes >= 1440) { + minutes -= 1440; + day += 1; + } + const hh = String(Math.floor(minutes / 60)).padStart(2, '0'); + const mm = String(minutes % 60).padStart(2, '0'); + return `Day ${day + 1} · ${hh}:${mm}`; +} + +/** + * A duration in hours as words: 0.5 -> "30 min", 14 -> "14 h", 60 -> "2.5 days". + * @param {number|null|undefined} h + * @returns {string} + */ +export function duration(h) { + if (h == null || Number.isNaN(h)) return '—'; + if (h < 1) return `${Math.round(h * 60)} min`; + if (h < 48) return `${Math.round(h)} h`; + return `${(h / 24).toFixed(1)} days`; +} + +/** + * How a risk compares with the typical at-risk patient: "3.4× typical". + * @param {number|null|undefined} p + * @param {number|null|undefined} base + * @returns {string|null} + */ +export function timesTypical(p, base) { + if (p == null || !base) return null; + const ratio = p / base; + if (ratio < 0.1) return 'far below typical'; + if (ratio < 0.95) return `${ratio.toFixed(1)}× typical (lower)`; + if (ratio <= 1.05) return 'about typical'; + return ratio >= 10 ? `${Math.round(ratio)}× typical` : `${ratio.toFixed(1)}× typical`; +} + +/** + * Direction of change between two risks, with a short label. + * @param {number|null|undefined} now + * @param {number|null|undefined} before + * @param {number} hours window the change is over + * @returns {{arrow: string, label: string, dir: -1|0|1}} + */ +export function trend(now, before, hours) { + if (now == null || before == null) return { arrow: '', label: '', dir: 0 }; + const d = now - before; + const rel = before > 0 ? Math.abs(d) / before : Math.abs(d) > 0 ? Infinity : 0; + if (Math.abs(d) < 0.002 || rel < 0.1) return { arrow: '→', label: `steady over ${hours} h`, dir: 0 }; + return d > 0 + ? { arrow: '↑', label: `${points(d)} in ${hours} h`, dir: 1 } + : { arrow: '↓', label: `${points(d)} in ${hours} h`, dir: -1 }; +} + +/** + * An AUROC to three decimals. + * @param {number|null|undefined} x + * @returns {string} + */ +export function auroc(x) { + return x == null ? '—' : x.toFixed(3); +} + +/** + * A 95% interval "0.867–0.874". + * @param {number[]|null|undefined} pair + * @returns {string} + */ +export function interval(pair) { + return pair ? `${pair[0].toFixed(3)}–${pair[1].toFixed(3)}` : ''; +} + +/** + * Index of the last time at or before t (0 if t precedes them all). + * @param {number[]} times ascending + * @param {number} t + * @returns {number} + */ +export function indexAtOrBefore(times, t) { + let lo = 0; + let hi = times.length - 1; + if (hi < 0 || t <= times[0]) return 0; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (times[mid] <= t) lo = mid; + else hi = mid - 1; + } + return lo; +} + +/** + * Index of the time closest to t. + * @param {number[]} times ascending + * @param {number} t + * @returns {number} + */ +export function nearestIndex(times, t) { + const i = indexAtOrBefore(times, t); + if (i + 1 < times.length && Math.abs(times[i + 1] - t) < Math.abs(times[i] - t)) return i + 1; + return i; +} + +/** + * Clamp a number into [lo, hi]. + * @param {number} x + * @param {number} lo + * @param {number} hi + * @returns {number} + */ +export function clamp(x, lo, hi) { + return Math.min(hi, Math.max(lo, x)); +} diff --git a/apps/clinician_demo/static/js/meta.js b/apps/clinician_demo/static/js/meta.js new file mode 100644 index 00000000..b125dfe6 --- /dev/null +++ b/apps/clinician_demo/static/js/meta.js @@ -0,0 +1,37 @@ +/** + * Lookups over the /api/meta payload: events, operating points, concepts. + */ + +/** + * The alert line for (event, horizon). + * @param {object} meta + * @param {string} event + * @param {number} horizonHours + * @returns {object|null} + */ +export function operatingPoint(meta, event, horizonHours) { + return ( + meta.operating_points.find( + (op) => op.event === event && Number(op.horizon_hours) === Number(horizonHours), + ) ?? null + ); +} + +/** + * The event record by name. + * @param {object} meta + * @param {string} name + * @returns {object} + */ +export function eventInfo(meta, name) { + return meta.events.find((e) => e.name === name) ?? { name, display: name, short: name, definition: '' }; +} + +/** + * Horizon key used by trace payloads: 24 -> "24h". + * @param {number} hours + * @returns {string} + */ +export function horizonKey(hours) { + return `${Number(hours)}h`; +} diff --git a/apps/clinician_demo/static/js/state.js b/apps/clinician_demo/static/js/state.js new file mode 100644 index 00000000..ac72590f --- /dev/null +++ b/apps/clinician_demo/static/js/state.js @@ -0,0 +1,31 @@ +/** + * A tiny observable store for app-wide state (model metadata, the current + * route). View-local state such as the scrub position lives in the view. + * Nothing here is persisted: patient data never touches browser storage. + */ + +/** + * Create a store. + * @template T + * @param {T} initial + * @returns {{get: () => T, set: (patch: Partial) => void, + * subscribe: (fn: (state: T) => void) => () => void}} + */ +export function createStore(initial) { + let state = { ...initial }; + const listeners = new Set(); + return { + get: () => state, + set(patch) { + state = { ...state, ...patch }; + for (const fn of listeners) fn(state); + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; +} + +/** The app's shared store. */ +export const store = createStore({ meta: null, route: null }); diff --git a/apps/clinician_demo/static/js/theme.js b/apps/clinician_demo/static/js/theme.js new file mode 100644 index 00000000..c152a40d --- /dev/null +++ b/apps/clinician_demo/static/js/theme.js @@ -0,0 +1,48 @@ +/** + * Read theme colours from CSS custom properties, so charts drawn in SVG + * and canvas follow the light/dark palette defined in styles.css. + */ + +/** + * The current value of a CSS custom property on :root. + * @param {string} name e.g. "--accent" + * @param {string} [fallback] + * @returns {string} + */ +export function cssVar(name, fallback = '') { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +/** + * The colour assigned to a forecast event (falls back to the accent). + * @param {string} event + * @returns {string} + */ +export function eventColor(event) { + return cssVar(`--ev-${event}`, cssVar('--accent', '#0B6E77')); +} + +/** + * Parse "#RRGGBB" (or "#RGB") into [r, g, b]. + * @param {string} hex + * @returns {number[]} + */ +export function hexToRgb(hex) { + let h = hex.replace('#', '').trim(); + if (h.length === 3) h = [...h].map((c) => c + c).join(''); + const n = Number.parseInt(h, 16); + if (Number.isNaN(n) || h.length !== 6) return [11, 110, 119]; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** + * Run fn whenever the OS light/dark preference changes. + * @param {() => void} fn + * @returns {() => void} unsubscribe + */ +export function onThemeChange(fn) { + const query = window.matchMedia('(prefers-color-scheme: dark)'); + query.addEventListener('change', fn); + return () => query.removeEventListener('change', fn); +} diff --git a/apps/clinician_demo/static/js/views/evidence.js b/apps/clinician_demo/static/js/views/evidence.js new file mode 100644 index 00000000..666fb292 --- /dev/null +++ b/apps/clinician_demo/static/js/views/evidence.js @@ -0,0 +1,139 @@ +/** + * "Why?" panel: which recent recorded events the forecast leans on. The + * server removes each recent code in turn and re-runs the model; this + * panel starts that job, shows progress, and ranks the results. + */ + +import { api } from '../api.js'; +import { el, errorBlock, emptyBlock } from '../dom.js'; +import { clock, pct, points } from '../format.js'; + +const POLL_MS = 1000; +const LOOKBACK_HOURS = 24; + +function resultRow(item, maxAbs) { + const effect = -item.delta; // how much this code raised the target + const width = maxAbs > 0 ? (Math.abs(effect) / maxAbs) * 50 : 0; + const raises = effect > 0; + return el('li', { class: 'evidence-row' }, [ + el('div', { class: 'evidence-row__label', title: item.code }, [ + el('strong', { text: item.label }), + el('span', { class: 'faint', text: ` · ${item.n_rows} reading${item.n_rows === 1 ? '' : 's'}` }), + ]), + el('div', { class: 'diverge', 'aria-hidden': 'true' }, [ + el('div', { + class: `diverge__fill ${raises ? 'diverge__fill--up' : 'diverge__fill--down'}`, + style: { width: `${width}%` }, + }), + ]), + el('div', { class: raises ? 'delta-up' : 'delta-down', text: `${raises ? 'Raises' : 'Lowers'} ${points(Math.abs(effect)).replace('+', '')}` }), + ]); +} + +function doneView(job, targetLabel) { + const items = job.result; + if (!items.length) return emptyBlock('No recent recorded event moved this forecast.'); + const maxAbs = Math.max(...items.map((i) => Math.abs(i.delta))); + return el('div', { class: 'panel' }, [ + el('div', { class: 'muted' }, [ + `${targetLabel}: ${pct(items[0].baseline)} as recorded. `, + 'Each bar shows how the forecast would change if that event had not been recorded.', + ]), + job.note ? el('div', { class: 'faint', text: job.note }) : null, + el('ol', { class: 'evidence-list' }, items.map((i) => resultRow(i, maxAbs))), + ]); +} + +/** + * Create the evidence panel. + * @param {HTMLElement} container + * @param {{meta: object, sid: string, vid: string, getT: () => number, events: object[], + * onsets?: Object}} ctx onsets: visit hours each event happened + * @returns {{destroy: () => void}} + */ +export function createEvidencePanel(container, { meta, sid, vid, getT, events, onsets = {} }) { + let destroyed = false; + let timer = null; + const select = el('select', { 'aria-label': 'What to explain' }, [ + el('optgroup', { label: 'Risk within 24 h' }, events.map((e) => el('option', { value: `event:${e.name}`, text: e.display }))), + el('optgroup', { label: 'What the model thinks is going on' }, meta.concepts.map((c) => el('option', { value: `concept:${c.name}`, text: c.display }))), + ]); + const runBtn = el('button', { class: 'btn', type: 'button', text: 'Explain this moment' }); + const out = el('div'); + const panel = el('div', { class: 'panel' }, [ + el('div', { class: 'note', text: meta.disclaimers.evidence }), + el('div', { class: 'panel__controls' }, [select, runBtn, el('span', { class: 'faint', text: `Looks at the last ${LOOKBACK_HOURS} h.` })]), + out, + ]); + container.replaceChildren(panel); + + function progress(job) { + const share = job.total ? job.done / job.total : 0; + const bar = el('div', { class: 'progress__bar' }); + bar.style.setProperty('width', `${Math.round(share * 100)}%`); + return el('div', { class: 'panel', role: 'status' }, [ + el('div', { class: 'muted', text: job.total ? `Re-running the model without each event: ${job.done} of ${job.total}.` : 'Starting…' }), + el('div', { class: 'progress' }, [bar]), + ]); + } + + async function poll(jobId, label) { + if (destroyed) return; + try { + const job = await api.job(jobId); + if (destroyed) return; + if (job.status === 'done') { + out.replaceChildren(doneView(job, label)); + runBtn.disabled = false; + } else if (job.status === 'error') { + out.replaceChildren(errorBlock(job.error || 'The explanation failed.')); + runBtn.disabled = false; + } else { + out.replaceChildren(progress(job)); + timer = setTimeout(() => poll(jobId, label), POLL_MS); + } + } catch (err) { + if (!destroyed) { + out.replaceChildren(errorBlock(err)); + runBtn.disabled = false; + } + } + } + + runBtn.addEventListener('click', async () => { + const [kind, name] = select.value.split(':'); + const label = select.selectedOptions[0]?.textContent ?? name; + const target = kind === 'event' ? { kind, name, horizon_hours: 24 } : { kind, name }; + const t = getT(); + if (kind === 'event' && onsets[name] != null && onsets[name] <= t) { + clearTimeout(timer); + out.replaceChildren(el('div', { + class: 'note', + text: `${label} had already happened at ${clock(onsets[name])}. Move to an earlier moment to see what the forecast leaned on.`, + })); + return; + } + runBtn.disabled = true; + clearTimeout(timer); + out.replaceChildren(progress({ done: 0, total: 0 })); + try { + const job = await api.evidence(sid, vid, { t_hours: t, target, lookback_hours: LOOKBACK_HOURS }); + if (destroyed) return; + out.prepend(el('div', { class: 'faint', text: `Explaining ${label} at ${clock(t)}.` })); + poll(job.job_id, label); + } catch (err) { + if (!destroyed) { + out.replaceChildren(errorBlock(err)); + runBtn.disabled = false; + } + } + }); + + return { + destroy() { + destroyed = true; + clearTimeout(timer); + panel.remove(); + }, + }; +} diff --git a/apps/clinician_demo/static/js/views/gallery.js b/apps/clinician_demo/static/js/views/gallery.js new file mode 100644 index 00000000..810406fd --- /dev/null +++ b/apps/clinician_demo/static/js/views/gallery.js @@ -0,0 +1,115 @@ +/** + * Gallery: curated visits, grouped into early warnings, quiet stays, + * misses and false alarms. Every section shows its honest summary line. + */ + +import { api } from '../api.js'; +import { el, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; +import { duration } from '../format.js'; +import { eventInfo } from '../meta.js'; +import { eventColor } from '../theme.js'; + +const INTRO = { + early_warning: 'The event happened, and the alert had already been on for hours when it began.', + quiet: 'No event happened, and the risk stayed low. The model does not flag everyone.', + miss: 'The event happened with no alert on at that moment.', + false_alarm: 'The alert came on, but the event never happened during the stay.', + other: 'Every patient in this dataset.', +}; + +/** + * Badge saying whether the model saw this patient in training. + * @param {boolean} seen + * @returns {HTMLElement} + */ +export function trainingBadge(seen) { + return seen + ? el('span', { + class: 'pill pill--training', + title: 'The model saw this patient during training. Its forecasts here are not a fair test.', + text: 'Seen in training', + }) + : el('span', { class: 'pill pill--heldout', title: 'The model never saw this patient.', text: 'Unseen patient' }); +} + +function caseHref(c) { + return `#/p/${c.subject_id}/v/${c.visit_id}`; +} + +function caseCard(meta, c) { + const color = c.event ? eventColor(c.event) : null; + const chips = [ + c.event + ? el('span', { class: 'pill pill--event', style: { background: color }, text: eventInfo(meta, c.event).short }) + : null, + c.lead_hours != null ? el('span', { class: 'pill', text: `${c.lead_approximate ? '≈' : ''}${Math.round(c.lead_hours)} h of warning` }) : null, + el('span', { text: `Stay ${duration(c.los_hours)}` }), + trainingBadge(c.seen_in_training), + ]; + return el( + 'a', + { class: 'card case-card', href: caseHref(c), style: color ? { '--case-color': color } : {} }, + [ + el('div', { class: 'case-card__headline', text: c.headline }), + el('div', { class: 'case-card__meta' }, chips), + el('div', { class: 'case-card__id', text: `Patient ${c.subject_id} · visit ${c.visit_id}` }), + ], + ); +} + +function compactList(c) { + return el('li', {}, [ + el('a', { href: caseHref(c), text: `Patient ${c.subject_id}` }), + ' ', + el('span', { class: 'muted', text: `· ${c.headline} · ${duration(c.los_hours)}` }), + c.seen_in_training ? el('span', { class: 'faint', text: ' · seen in training' }) : null, + ]); +} + +function section(meta, s) { + const body = !s.cases.length + ? emptyBlock('No visit matched this rule.') + : s.kind === 'other' + ? el('ul', { class: 'compact-list' }, s.cases.map(compactList)) + : el('div', { class: 'case-grid' }, s.cases.map((c) => caseCard(meta, c))); + return el('section', { class: 'section', 'aria-labelledby': `sec-${s.kind}` }, [ + el('div', { class: 'section__head' }, [ + el('h2', { id: `sec-${s.kind}`, text: s.title }), + el('span', { class: 'section__kind', text: `${s.cases.length} shown` }), + ]), + el('p', { class: 'section__summary' }, [INTRO[s.kind] ? `${INTRO[s.kind]} ` : '', el('strong', { text: s.summary })]), + body, + ]); +} + +/** + * Render the gallery page. + * @param {HTMLElement} root + * @param {{meta: object}} ctx + * @returns {Promise<() => void>} cleanup + */ +export async function renderGallery(root, { meta }) { + root.replaceChildren(loadingBlock('Choosing patients…')); + let gallery; + try { + gallery = await api.gallery(); + } catch (err) { + root.replaceChildren(errorBlock(err)); + return () => {}; + } + const head = el('div', { class: 'page-head' }, [ + el('div', {}, [ + el('h1', { text: 'Replay a real admission' }), + el('p', { + text: + 'Pick a stay. The model reads the chart one event at a time, as it was written, and forecasts ' + + 'what happens next. It never sees the future. We show the hits and the misses.', + }), + ]), + ]); + const sections = gallery.sections.length + ? gallery.sections.map((s) => section(meta, s)) + : [emptyBlock('No patients are available.')]; + root.replaceChildren(head, ...sections); + return () => {}; +} diff --git a/apps/clinician_demo/static/js/views/patient.js b/apps/clinician_demo/static/js/views/patient.js new file mode 100644 index 00000000..c368e15f --- /dev/null +++ b/apps/clinician_demo/static/js/views/patient.js @@ -0,0 +1,50 @@ +/** + * Patient page: header facts and the list of admissions to replay. + */ + +import { api } from '../api.js'; +import { el, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; +import { duration } from '../format.js'; +import { trainingBadge } from './gallery.js'; + +/** + * Render one patient's visit list. + * @param {HTMLElement} root + * @param {{sid: string}} ctx + * @returns {Promise<() => void>} cleanup + */ +export async function renderPatient(root, { sid }) { + root.replaceChildren(loadingBlock('Finding the patient…')); + let patient; + try { + patient = await api.patient(sid); + } catch (err) { + root.replaceChildren(errorBlock(err)); + return () => {}; + } + const facts = [ + patient.age_years != null ? `${Math.round(patient.age_years)} years` : null, + patient.sex ? `Sex ${patient.sex}` : null, + `${patient.visits.length} admission${patient.visits.length === 1 ? '' : 's'}`, + ].filter(Boolean); + const rows = patient.visits.map((v) => + el('a', { class: 'card visit-row', href: `#/p/${patient.subject_id}/v/${v.visit_id}` }, [ + el('div', { class: 'grow' }, [ + el('div', { class: 'case-card__headline', text: v.admission }), + el('div', { class: 'muted', text: `Stay ${duration(v.end_hours - v.start_hours)} · ${v.n_events} recorded events` }), + ]), + el('span', { class: 'btn btn--small btn--ghost', text: 'Replay →' }), + ]), + ); + root.replaceChildren( + el('div', { class: 'page-head' }, [ + el('div', {}, [ + el('h1', { text: `Patient ${patient.subject_id}` }), + el('p', { text: facts.join(' · ') }), + ]), + trainingBadge(patient.seen_in_training), + ]), + rows.length ? el('div', { class: 'visit-list' }, rows) : emptyBlock('This patient has no admissions.'), + ); + return () => {}; +} diff --git a/apps/clinician_demo/static/js/views/replay.js b/apps/clinician_demo/static/js/views/replay.js new file mode 100644 index 00000000..b80256f2 --- /dev/null +++ b/apps/clinician_demo/static/js/views/replay.js @@ -0,0 +1,444 @@ +/** + * Replay: the hero screen. Scrub (or play) through one admission and see, + * at every moment, the model's risks, its alert crossings, what it thinks + * is going on, what it expects next, and what was just recorded. + */ + +import { api } from '../api.js'; +import { el, card, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; +import { clamp, clock, duration, indexAtOrBefore, pct, timesTypical, trend } from '../format.js'; +import { eventInfo, horizonKey, operatingPoint } from '../meta.js'; +import { eventColor, onThemeChange } from '../theme.js'; +import { createRiskChart } from '../charts/risk_chart.js'; +import { createConceptStrip } from '../charts/concept_strip.js'; +import { trainingBadge } from './gallery.js'; +import { createWhatIfPanel } from './whatif.js'; +import { createEvidencePanel } from './evidence.js'; + +const TREND_HOURS = 6; +const RECENT_HOURS = 6; +const RECENT_MAX = 40; +const PLAY_MS = 300; +const PLAY_TICKS = 150; // a whole visit plays in about 45 s +const URL_THROTTLE_MS = 400; + +function level(p, threshold) { + if (p == null || threshold == null) return 'none'; + const r = p / threshold; + if (r >= 1) return 'alert'; + return r >= 0.5 ? 'watch' : 'low'; +} + +function buildCard(meta, event) { + const nodes = { + value: el('div', { class: 'risk-card__value' }), + context: el('div', { class: 'risk-card__context' }), + minor: el('div', { class: 'risk-card__minor' }), + line: el('div', { class: 'risk-card__line' }), + }; + const info = eventInfo(meta, event); + const root = el('article', { + class: 'card risk-card', + style: { '--card-color': eventColor(event) }, + 'aria-live': 'polite', + title: info.definition, + }, [ + el('div', { class: 'risk-card__name' }, [el('span', { class: 'dot', style: { background: eventColor(event) } }), info.display]), + nodes.value, nodes.context, nodes.minor, nodes.line, + ]); + return { root, nodes, event }; +} + +function recentEntries(timeline, t) { + const out = []; + for (let i = timeline.length - 1; i >= 0 && out.length < RECENT_MAX; i -= 1) { + const e = timeline[i]; + if (e.t > t) continue; + if (e.t < t - RECENT_HOURS) break; + out.push(e); + } + return out; +} + +/** + * Render the replay view. + * @param {HTMLElement} root + * @param {{meta: object, sid: string, vid: string, t: number|null}} ctx + * @returns {Promise<() => void>} cleanup + */ +export async function renderReplay(root, { meta, sid, vid, t }) { + root.replaceChildren(loadingBlock('Reading the chart and running the model. This can take a few seconds…')); + let patient; + let trace; + try { + [patient, trace] = await Promise.all([api.patient(sid), api.trace(sid, vid)]); + } catch (err) { + root.replaceChildren(errorBlock(err)); + return () => {}; + } + const { times } = trace; + if (!times.length) { + root.replaceChildren(emptyBlock('This admission has no moments the model could score.')); + return () => {}; + } + + const cleanups = []; + const events = meta.events.filter((e) => trace.risk[e.name]); + const timeline = [...trace.timeline].sort((a, b) => a.t - b.t); + let index = t != null ? indexAtOrBefore(times, t) : 0; + let horizon = 24; + let hidden = new Set(); + let overlayEvent = ''; + let playTimer = null; + let urlTimer = null; + + // ---- header + const visit = trace.visit; + const facts = [ + patient.age_years != null ? ['Age', `${Math.round(patient.age_years)}`] : null, + patient.sex ? ['Sex', patient.sex] : null, + ['Admission', visit.admission], + ['Stay', duration(visit.end_hours - visit.start_hours)], + ['Recorded events', String(visit.n_events)], + ].filter(Boolean); + const header = el('div', { class: 'patient-head' }, [ + el('a', { href: '#/', class: 'btn btn--ghost btn--small', text: '← Patients' }), + el('h1', { text: `Patient ${trace.subject_id}` }), + el('div', { class: 'patient-head__facts' }, facts.map(([k, v]) => el('span', {}, [`${k} `, el('strong', { text: v })]))), + trainingBadge(trace.seen_in_training), + ]); + + // ---- moment + cards + const momentTime = el('span', { class: 'moment__time' }); + const momentNote = el('span', { class: 'muted' }); + const cards = events.map((e) => buildCard(meta, e.name)); + const cardsRow = el('div', { class: 'risk-cards' }, cards.map((c) => c.root)); + + // ---- callouts + const alerts = [...trace.alerts].sort((a, b) => (b.lead_hours ?? -1) - (a.lead_hours ?? -1)); + const calloutItems = alerts.map((a) => + el('li', { + class: `callout ${a.lead_hours != null ? 'callout--lead' : ''}`, + style: { '--callout-color': eventColor(a.event) }, + dataset: { event: a.event }, + }, [ + el('span', { class: 'callout__tag', text: eventInfo(meta, a.event).short }), + el('span', { class: 'callout__body' }, [ + el('span', { text: a.callout }), + a.detail ? el('span', { class: 'callout__detail', text: a.detail }) : null, + ]), + ]), + ); + const calloutCard = card('Alerts in this stay', calloutItems.length + ? el('ul', { class: 'callouts' }, calloutItems) + : el('div', { class: 'muted', text: 'No alert line was crossed during this admission.' }), { + sub: 'Outlined red while that alert is on at the moment shown', + }); + + // ---- chart + const chartHost = el('div'); + const legend = el('div', { class: 'legend', role: 'group', 'aria-label': 'Show or hide events' }); + const segmented = el('div', { class: 'segmented', role: 'group', 'aria-label': 'Forecast horizon' }); + const overlaySelect = trace.banked?.length + ? el('select', { 'aria-label': 'Compare with the tuned GBM' }, [ + el('option', { value: '', text: 'No GBM overlay' }), + ...events.map((e) => el('option', { value: e.name, text: `GBM: ${e.display}` })), + ]) + : null; + const chartTitle = el('h2'); + const chartCard = card(chartTitle, [el('div', { class: 'card__head' }, [legend, el('span', { class: 'spacer' }), overlaySelect]), chartHost], { + actions: segmented, + }); + + // ---- scrubber + const range = el('input', { + type: 'range', min: 0, max: times.length - 1, step: 1, value: index, + 'aria-label': 'Time in the admission. Use the arrow keys to step; hold Shift for bigger steps.', + }); + const playBtn = el('button', { class: 'btn play-btn', type: 'button', text: '▶ Play' }); + const scrubLabel = el('span', { class: 'scrubber__label', text: `${clock(times[0])} → ${clock(times[times.length - 1])}` }); + const scrubber = el('div', { class: 'card scrubber' }, [playBtn, range, scrubLabel]); + + // ---- concepts + const conceptHost = el('div'); + const conceptChips = el('div', { class: 'concept-chips', 'aria-live': 'polite' }); + const conceptCard = card('What the model thinks is going on', [ + el('div', { class: 'faint', text: meta.disclaimers.concepts }), + conceptChips, + conceptHost, + ], { sub: 'Darker = more likely during this admission' }); + + // ---- tabs + const tabBody = el('div'); + const tabWhatIf = el('button', { type: 'button', role: 'tab', 'aria-selected': 'true', text: 'What if…' }); + const tabWhy = el('button', { type: 'button', role: 'tab', 'aria-selected': 'false', text: 'Why?' }); + const tabsCard = el('section', { class: 'card' }, [el('div', { class: 'tabs', role: 'tablist' }, [tabWhatIf, tabWhy]), tabBody]); + + // ---- sidebar + const nextList = el('ul', { class: 'side-list' }); + const recentList = el('ul', { class: 'side-list side-scroll' }); + const side = el('aside', { class: 'replay__side' }, [ + card('What the model expects next', nextList, { sub: 'Most likely next events' }), + card('Recently recorded', recentList, { sub: `Last ${RECENT_HOURS} h` }), + ]); + + const main = el('div', { class: 'replay__main' }, [ + el('section', { class: 'card' }, [ + el('div', { class: 'moment' }, [momentTime, momentNote]), + el('div', { class: 'faint', text: meta.disclaimers.risk }), + ]), + cardsRow, calloutCard, chartCard, scrubber, conceptCard, tabsCard, + ]); + root.replaceChildren(header, el('div', { class: 'replay' }, [main, side])); + + // ---- charts + const chart = createRiskChart(chartHost, { onScrub: (i) => setIndex(i) }); + const strip = createConceptStrip(conceptHost, { + concepts: meta.concepts, + onScrub: (i) => setIndex(i), + }); + cleanups.push(() => chart.destroy(), () => strip.destroy()); + strip.update({ times, values: trace.concepts }); + + function chartData() { + const key = horizonKey(horizon); + return { + times, + series: events + .filter((e) => !hidden.has(e.name)) + .map((e) => ({ + event: e.name, + label: e.short, + color: eventColor(e.name), + values: trace.risk[e.name][key] ?? [], + threshold: operatingPoint(meta, e.name, horizon)?.threshold ?? null, + })), + onsets: events + .filter((e) => trace.onsets[e.name] != null) + .map((e) => ({ t: trace.onsets[e.name], label: e.short, color: eventColor(e.name) })), + markers: trace.markers, + overlay: overlayEvent && horizon === 24 + ? { + color: eventColor(overlayEvent), + label: 'Tuned GBM, 24 h risk, at 4-hourly landmarks', + points: trace.banked + .filter((b) => b.event === overlayEvent) + .map((b) => ({ t: b.t, v: b.gbm_24h })), + } + : null, + }; + } + + function renderLegend() { + legend.replaceChildren(...events.map((e) => { + const on = !hidden.has(e.name); + return el('button', { + type: 'button', 'aria-pressed': String(on), title: e.definition, + onClick: () => { + hidden = new Set(hidden); + if (on) hidden.add(e.name); + else hidden.delete(e.name); + renderLegend(); + chart.update(chartData()); + }, + }, [el('span', { class: 'dot', style: { background: eventColor(e.name) } }), e.short]); + })); + } + + function renderSegmented() { + segmented.replaceChildren(...meta.horizons.map((h) => el('button', { + type: 'button', 'aria-pressed': String(h === horizon), text: `${h} h`, + onClick: () => { + horizon = h; + renderSegmented(); + chart.update(chartData()); + }, + }))); + chartTitle.textContent = `Chance of each event within ${horizon} hours`; + } + + function updateCards() { + const now = times[index]; + const before = indexAtOrBefore(times, now - TREND_HOURS); + for (const c of cards) { + const series = trace.risk[c.event]; + const onset = trace.onsets[c.event]; + const op = operatingPoint(meta, c.event, 24); + const p24 = series['24h']?.[index]; + if (onset != null && now >= onset) { + c.root.dataset.level = 'done'; + c.nodes.value.replaceChildren('Happened'); + c.nodes.context.textContent = `at ${clock(onset)}`; + c.nodes.minor.textContent = ''; + c.nodes.line.textContent = 'Forecast stops at the event'; + continue; + } + const lvl = level(p24, op?.threshold); + c.root.dataset.level = lvl; + c.nodes.value.replaceChildren(pct(p24), el('small', { text: 'in 24 h' })); + const tr = trend(p24, series['24h']?.[before], TREND_HOURS); + c.nodes.context.replaceChildren( + timesTypical(p24, op?.base_rate) ?? '', + tr.arrow ? el('span', { class: `trend ${tr.dir > 0 ? 'trend--up' : tr.dir < 0 ? 'trend--down' : ''}`, text: ` ${tr.arrow} `, title: tr.label }) : '', + ); + c.nodes.minor.textContent = `8 h ${pct(series['8h']?.[index])} · 72 h ${pct(series['72h']?.[index])}`; + const lineText = { + alert: 'Alert on: above the line', + watch: `Near the alert line (${pct(op?.threshold)})`, + }; + c.nodes.line.textContent = op ? lineText[lvl] ?? `Alert line ${pct(op.threshold)}` : ''; + } + } + + // An alert is "on" exactly when the 24 h risk at this moment is at or + // above its line (risk is null once the event has happened). + function updateCallouts() { + calloutItems.forEach((item, i) => { + const a = alerts[i]; + const p = trace.risk[a.event]?.['24h']?.[index]; + item.classList.toggle('is-live', p != null && a.threshold != null && p >= a.threshold); + }); + } + + function updateSide() { + const next = trace.top_next[index] ?? []; + nextList.replaceChildren(...(next.length ? next.map((n) => { + const fill = el('div', { class: 'bar__fill' }); + fill.style.setProperty('width', `${Math.round(n.probability * 100)}%`); + return el('li', { class: 'next-row' }, [ + el('span', { class: 'next-row__label', title: n.label, text: n.label }), + el('span', { class: 'next-row__p', text: pct(n.probability) }), + el('div', { class: 'bar' }, [fill]), + ]); + }) : [el('li', { class: 'faint', text: 'Nothing to forecast here.' })])); + const recent = recentEntries(timeline, times[index]); + recentList.replaceChildren(...(recent.length ? recent.map((e) => el('li', { class: `event-row event-row--${e.category}` }, [ + el('span', { class: 'event-row__t', text: clock(e.t).replace('Day ', 'D') }), + el('span', { class: 'event-row__body' }, [ + el('span', { class: `cat-dot cat-dot--${e.category}`, title: e.category }), + el('span', { class: 'event-row__label', text: e.label }), + e.value ? el('span', { class: 'event-row__value', text: e.value }) : null, + e.flag ? el('span', { class: `pill pill--${e.flag}`, text: e.flag.toLowerCase() }) : null, + ]), + ])) : [el('li', { class: 'faint', text: 'Nothing recorded in this window.' })])); + } + + function updateConcepts() { + const row = trace.concepts[index] ?? []; + const top = meta.concepts + .map((c, i) => ({ c, v: row[i] ?? 0 })) + .filter((x) => x.v >= 0.5) + .sort((a, b) => b.v - a.v) + .slice(0, 6); + conceptChips.replaceChildren(...(top.length + ? top.map((x) => el('span', { class: 'concept-chip', title: x.c.description }, [x.c.display, el('span', { class: 'num', text: pct(x.v) })])) + : [el('span', { class: 'faint', text: 'Nothing stands out yet.' })])); + } + + function syncUrl() { + clearTimeout(urlTimer); + urlTimer = setTimeout(() => { + history.replaceState(null, '', `#/p/${sid}/v/${vid}?t=${times[index].toFixed(2)}`); + }, URL_THROTTLE_MS); + } + + function setIndex(i) { + index = clamp(Math.round(i), 0, times.length - 1); + range.value = String(index); + momentTime.textContent = clock(times[index]); + momentNote.textContent = `${Math.round(times[index])} h after admission · moment ${index + 1} of ${times.length}`; + chart.setCursor(index); + strip.setCursor(index); + updateCards(); + updateCallouts(); + updateSide(); + updateConcepts(); + syncUrl(); + } + + function stopPlay() { + clearInterval(playTimer); + playTimer = null; + playBtn.textContent = '▶ Play'; + } + + function togglePlay() { + if (playTimer) { + stopPlay(); + return; + } + if (index >= times.length - 1) setIndex(0); + const step = Math.max(1, Math.round(times.length / PLAY_TICKS)); + playBtn.textContent = '❚❚ Pause'; + playTimer = setInterval(() => { + if (index >= times.length - 1) stopPlay(); + else setIndex(index + step); + }, PLAY_MS); + } + + // ---- wiring + range.addEventListener('input', () => setIndex(Number(range.value))); + playBtn.addEventListener('click', togglePlay); + if (overlaySelect) { + overlaySelect.addEventListener('change', () => { + overlayEvent = overlaySelect.value; + chart.update(chartData()); + }); + } + const onKey = (event) => { + const tag = event.target?.tagName; + const typing = tag === 'SELECT' || tag === 'TEXTAREA' || (tag === 'INPUT' && event.target.type !== 'range'); + if (typing || event.metaKey || event.ctrlKey || event.altKey) return; + const stride = event.shiftKey ? 10 : 1; + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + if (event.target === range) return; // the slider handles its own arrows + event.preventDefault(); + setIndex(index + (event.key === 'ArrowRight' ? stride : -stride)); + } else if (event.key === ' ' && event.target === document.body) { + event.preventDefault(); + togglePlay(); + } else if (event.key === 'Home') { + setIndex(0); + } else if (event.key === 'End') { + setIndex(times.length - 1); + } + }; + document.addEventListener('keydown', onKey); + cleanups.push(() => document.removeEventListener('keydown', onKey)); + range.addEventListener('keydown', (event) => { + if (event.shiftKey && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { + event.preventDefault(); + setIndex(index + (event.key === 'ArrowRight' ? 10 : -10)); + } + }); + cleanups.push(onThemeChange(() => { + for (const c of cards) c.root.style.setProperty('--card-color', eventColor(c.event)); + renderLegend(); + chart.update(chartData()); + strip.redraw(); + })); + + let panel = null; + const panelCtx = { meta, sid, vid, events, onsets: trace.onsets, getT: () => times[index] }; + function showTab(which) { + panel?.destroy(); + tabWhatIf.setAttribute('aria-selected', String(which === 'whatif')); + tabWhy.setAttribute('aria-selected', String(which === 'why')); + panel = which === 'whatif' ? createWhatIfPanel(tabBody, panelCtx) : createEvidencePanel(tabBody, panelCtx); + } + tabWhatIf.addEventListener('click', () => showTab('whatif')); + tabWhy.addEventListener('click', () => showTab('why')); + showTab('whatif'); + cleanups.push(() => panel?.destroy()); + + renderLegend(); + renderSegmented(); + chart.update(chartData()); + setIndex(index); + + return () => { + stopPlay(); + clearTimeout(urlTimer); + for (const fn of cleanups) fn(); + }; +} diff --git a/apps/clinician_demo/static/js/views/scorecard.js b/apps/clinician_demo/static/js/views/scorecard.js new file mode 100644 index 00000000..2c12517b --- /dev/null +++ b/apps/clinician_demo/static/js/views/scorecard.js @@ -0,0 +1,125 @@ +/** + * Scorecard: how well the model ranks patients for each event and + * horizon, against the tuned GBM, plus calibration and concept readouts. + */ + +import { api } from '../api.js'; +import { el, svg, card, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; +import { auroc, interval, pct } from '../format.js'; +import { eventInfo } from '../meta.js'; +import { eventColor } from '../theme.js'; + +function verdict(cell) { + if (cell.delta == null) return null; + if (!cell.separated) return el('span', { class: 'verdict verdict--tie', text: 'No clear difference' }); + return cell.delta < 0 + ? el('span', { class: 'verdict verdict--gbm', text: 'GBM clearly better' }) + : el('span', { class: 'verdict verdict--model', text: 'Model clearly better' }); +} + +function calibration(bins) { + if (!bins.length) return null; + const size = 64; + const top = Math.max(0.01, ...bins.flatMap((b) => [b.predicted, b.observed])); + const s = (v) => (v / top) * (size - 6) + 3; + const pts = bins.map((b) => `${s(b.predicted).toFixed(1)},${(size - s(b.observed)).toFixed(1)}`); + return svg('svg', { class: 'calib', width: size, height: size, viewBox: `0 0 ${size} ${size}`, role: 'img', + 'aria-label': 'Calibration: predicted against observed risk by decile' }, [ + svg('title', { text: 'Predicted (across) against observed (up), by decile. On the dashed line = well calibrated.' }), + svg('line', { class: 'diag', x1: 3, y1: size - 3, x2: size - 3, y2: 3 }), + svg('polyline', { class: 'curve', points: pts.join(' ') }), + ...bins.map((b) => svg('circle', { class: 'pt', cx: s(b.predicted), cy: size - s(b.observed), r: 1.8 })), + ]); +} + +function cellView(cell) { + const line = (who, value, ci) => + el('div', { class: 'score-cell__line' }, [ + el('span', { class: 'score-cell__who', text: who }), + el('span', { class: 'score-cell__auroc', text: auroc(value) }), + el('span', { class: 'score-cell__ci', text: interval(ci) }), + ]); + return el('div', { class: 'score-cell' }, [ + line('Model', cell.hazard_auroc, cell.hazard_ci), + line('GBM', cell.gbm_auroc, cell.gbm_ci), + verdict(cell), + el('div', { class: 'faint', text: `${pct(cell.base_rate)} of ${cell.n_at_risk.toLocaleString()} moments` }), + ]); +} + +function table(meta, cells) { + const horizons = [...new Set(cells.map((c) => c.horizon_hours))].sort((a, b) => a - b); + const events = [...new Set(cells.map((c) => c.event))]; + const find = (e, h) => cells.find((c) => c.event === e && c.horizon_hours === h); + return el('div', { class: 'card' }, [ + el('table', { class: 'score-table' }, [ + el('thead', {}, el('tr', {}, [ + el('th', { text: 'Event' }), + ...horizons.map((h) => el('th', { text: `Within ${h} h` })), + el('th', { text: 'Calibration (24 h)' }), + ])), + el('tbody', {}, events.map((e) => { + const info = eventInfo(meta, e); + const c24 = find(e, 24); + return el('tr', {}, [ + el('td', {}, [ + el('div', { class: 'risk-card__name' }, [el('span', { class: 'dot', style: { background: eventColor(e) } }), info.display]), + el('div', { class: 'faint', text: info.definition }), + ]), + ...horizons.map((h) => el('td', {}, find(e, h) ? cellView(find(e, h)) : '—')), + el('td', {}, c24 ? calibration(c24.calibration) : '—'), + ]); + })), + ]), + ]); +} + +function conceptBars(concepts) { + const scored = concepts.filter((c) => c.readout_auroc != null).sort((a, b) => b.readout_auroc - a.readout_auroc); + if (!scored.length) return emptyBlock('No concept readouts are banked for this run.'); + return el('div', { class: 'auroc-bars' }, scored.map((c) => { + const fill = el('div', { class: 'auroc-bar__fill' }); + fill.style.setProperty('width', `${Math.max(0, (c.readout_auroc - 0.5) / 0.5) * 100}%`); + return el('div', { class: 'auroc-bar', title: c.description }, [ + el('span', { text: c.display }), + el('div', { class: 'auroc-bar__track' }, [fill]), + el('span', { class: 'num', text: auroc(c.readout_auroc) }), + ]); + })); +} + +/** + * Render the scorecard page. + * @param {HTMLElement} root + * @param {{meta: object}} ctx + * @returns {Promise<() => void>} cleanup + */ +export async function renderScorecard(root, { meta }) { + root.replaceChildren(loadingBlock('Loading the report card…')); + let sc; + try { + sc = await api.scorecard(); + } catch (err) { + root.replaceChildren(errorBlock(err)); + return () => {}; + } + root.replaceChildren( + el('div', { class: 'page-head' }, [ + el('div', {}, [ + el('h1', { text: 'How good is it?' }), + el('p', { text: 'Measured on held-out patients the model never trained on. Bars start at 0.5, which is chance.' }), + ]), + ]), + el('div', { class: 'card headline-card', text: sc.headline }), + el('div', { class: 'two-col' }, [ + el('div', {}, [sc.cells.length ? table(meta, sc.cells) : emptyBlock('No alert evaluation is banked for this run.')]), + el('div', { class: 'replay__main' }, [ + card('Reading the chart: concept accuracy', conceptBars(sc.concepts), { + sub: 'AUROC of each named concept against its rule', + }), + card('How to read this', el('ul', { class: 'notes' }, sc.notes.map((n) => el('li', { text: n })))), + ]), + ]), + ); + return () => {}; +} diff --git a/apps/clinician_demo/static/js/views/whatif.js b/apps/clinician_demo/static/js/views/whatif.js new file mode 100644 index 00000000..103eed6e --- /dev/null +++ b/apps/clinician_demo/static/js/views/whatif.js @@ -0,0 +1,181 @@ +/** + * "What if…" panel: change up to three recent readings and see how the + * model's forecast at the current moment moves. This shows the model's + * sensitivity, not the effect of a treatment. + */ + +import { api } from '../api.js'; +import { el, errorBlock, loadingBlock } from '../dom.js'; +import { clock, pct, points } from '../format.js'; +import { eventColor } from '../theme.js'; + +const MAX_EDITS = 3; +let presetsPromise = null; + +function loadPresets() { + presetsPromise ??= api.presets().catch((err) => { + presetsPromise = null; + throw err; + }); + return presetsPromise; +} + +function formatValue(preset, value) { + if (preset.mode === 'scale') return `×${Number(value).toFixed(1)}`; + const sign = preset.mode === 'add' && value > 0 ? '+' : ''; + return `${sign}${Number(value)}${preset.unit ? ` ${preset.unit}` : ''}`; +} + +function deltaClass(d) { + if (d == null || Math.abs(d) < 0.0005) return 'delta-flat'; + return d > 0 ? 'delta-up' : 'delta-down'; +} + +function resultView(result, events, onsets) { + const happened = (e) => onsets?.[e.name] != null && onsets[e.name] <= result.t_hours; + const open = events.filter((e) => result.factual.risk[e.name] && !happened(e)); + const max = Math.max( + 0.01, + ...open.flatMap((e) => [result.factual.risk[e.name]['24h'] ?? 0, result.counterfactual.risk[e.name]?.['24h'] ?? 0]), + ); + const done = events + .filter(happened) + .map((e) => el('div', { class: 'compare-row faint' }, [ + el('strong', { text: e.display }), + el('span', { text: `Already happened at ${clock(onsets[e.name])}. Nothing left to forecast.` }), + el('span'), + ])); + const rows = open + .map((e) => { + const before = result.factual.risk[e.name]['24h']; + const after = result.counterfactual.risk[e.name]['24h']; + const d = result.delta.risk[e.name]?.['24h']; + const color = eventColor(e.name); + const minor = ['8h', '72h'] + .map((k) => `${k.replace('h', ' h')} ${points(result.delta.risk[e.name]?.[k])}`) + .join(' · '); + return el('div', { class: 'compare-row' }, [ + el('div', {}, [el('strong', { text: e.display }), el('div', { class: 'faint', text: minor })]), + el('div', { class: 'compare-bars', 'aria-label': `${e.display}: ${pct(before)} now, ${pct(after)} with the change` }, [ + el('div', { class: 'compare-bar' }, [ + el('div', { class: 'compare-bar__fill compare-bar__fill--before', style: { width: `${(before / max) * 100}%` } }), + ]), + el('div', { class: 'compare-bar' }, [ + el('div', { class: 'compare-bar__fill', style: { width: `${(after / max) * 100}%`, background: color } }), + ]), + ]), + el('div', { class: `compare-row__delta ${deltaClass(d)}` }, [ + `${pct(before)} → ${pct(after)}`, + el('div', { text: points(d) }), + ]), + ]); + }); + return el('div', { class: 'panel' }, [ + el('div', { class: 'muted' }, [ + `Forecast at ${clock(result.t_hours)} · ${result.rows_edited} reading${result.rows_edited === 1 ? '' : 's'} changed. `, + 'Grey bar: the chart as recorded. Coloured bar: with your change. Risk within 24 h.', + ]), + ...result.warnings.map((w) => el('div', { class: 'note', text: w })), + result.rows_edited > 0 ? el('div', { class: 'compare' }, [...rows, ...done]) : null, + ]); +} + +/** + * Create the what-if panel. + * @param {HTMLElement} container + * @param {{meta: object, sid: string, vid: string, getT: () => number, events: object[], + * onsets?: Object}} ctx onsets: visit hours each event happened + * @returns {{destroy: () => void}} + */ +export function createWhatIfPanel(container, { meta, sid, vid, getT, events, onsets = {} }) { + let destroyed = false; + const edits = []; + const rowsBox = el('div', { class: 'panel' }); + const resultBox = el('div'); + const select = el('select', { 'aria-label': 'Choose a reading to change' }); + const addBtn = el('button', { class: 'btn btn--ghost btn--small', type: 'button', text: 'Add change' }); + const runBtn = el('button', { class: 'btn', type: 'button', text: 'Show the new forecast', disabled: true }); + const controls = el('div', { class: 'panel__controls' }, [select, addBtn, el('span', { class: 'spacer' }), runBtn]); + const panel = el('div', { class: 'panel' }, [ + el('div', { class: 'note', text: meta.disclaimers.whatif }), + controls, + rowsBox, + resultBox, + ]); + container.replaceChildren(panel); + let presets = []; + + function renderRows() { + rowsBox.replaceChildren( + ...edits.map((edit, i) => { + const valueLabel = el('span', { class: 'edit-row__value', text: formatValue(edit.preset, edit.value) }); + const slider = el('input', { + type: 'range', min: edit.preset.min, max: edit.preset.max, step: edit.preset.step, + value: edit.value, 'aria-label': `${edit.preset.label} value`, + }); + slider.addEventListener('input', () => { + edit.value = Number(slider.value); + valueLabel.textContent = formatValue(edit.preset, edit.value); + }); + return el('div', { class: 'edit-row' }, [ + el('div', {}, [ + el('div', { class: 'edit-row__name', text: edit.preset.label }), + el('div', { class: 'edit-row__hint', text: edit.preset.description }), + ]), + slider, + valueLabel, + el('button', { + class: 'btn btn--ghost btn--icon', type: 'button', 'aria-label': `Remove ${edit.preset.label}`, text: '✕', + onClick: () => { edits.splice(i, 1); renderRows(); }, + }), + ]); + }), + ); + if (!edits.length) rowsBox.append(el('div', { class: 'faint', text: 'Add a change to begin. Up to three.' })); + addBtn.disabled = edits.length >= MAX_EDITS || !presets.length; + runBtn.disabled = !edits.length; + } + + addBtn.addEventListener('click', () => { + const preset = presets.find((p) => p.id === select.value); + if (!preset || edits.length >= MAX_EDITS) return; + edits.push({ preset, value: preset.value }); + renderRows(); + }); + + runBtn.addEventListener('click', async () => { + const t = getT(); + runBtn.disabled = true; + resultBox.replaceChildren(loadingBlock(`Re-running the model at ${clock(t)}…`)); + try { + const result = await api.whatif(sid, vid, { + t_hours: t, + edits: edits.map((e) => ({ preset: e.preset.id, value: e.value })), + }); + if (!destroyed) resultBox.replaceChildren(resultView(result, events, onsets)); + } catch (err) { + if (!destroyed) resultBox.replaceChildren(errorBlock(err)); + } finally { + runBtn.disabled = !edits.length; + } + }); + + loadPresets() + .then((list) => { + if (destroyed) return; + presets = list; + select.replaceChildren(...list.map((p) => el('option', { value: p.id, text: p.label, title: p.description }))); + renderRows(); + }) + .catch((err) => { + if (!destroyed) rowsBox.replaceChildren(errorBlock(err)); + }); + renderRows(); + + return { + destroy() { + destroyed = true; + panel.remove(); + }, + }; +} diff --git a/apps/clinician_demo/static/styles.css b/apps/clinician_demo/static/styles.css new file mode 100644 index 00000000..16bca6df --- /dev/null +++ b/apps/clinician_demo/static/styles.css @@ -0,0 +1,513 @@ +/* Odyssey · Bedside Forecast + * + * Design tokens are copied from + * odyssey/reporting/concept_bottleneck_report_template.html (:root and its + * dark-mode block) so the demo and the research report look like one + * product. Keep the two in step when the palette changes. + */ + +:root { + --bg: #F5F7F8; + --surface: #FFFFFF; + --surface-2: #ECF1F2; + --ink: #14212B; + --ink-muted: #566873; + --ink-faint: #8598A1; + --border: #D8E0E3; + --border-strong: #C1CDD1; + --accent: #0B6E77; + --accent-strong: #084F56; + --accent-soft: #DCEEF0; + --good: #2E8B57; + --good-soft: #E1F1E7; + --warn: #A6720A; + --warn-soft: #F5ECD8; + --critical: #B23A3A; + --critical-soft: #F5E1E1; + --shadow: 0 1px 2px rgba(20, 33, 43, 0.06), 0 4px 16px rgba(20, 33, 43, 0.05); + + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* Event hues: Okabe-Ito based, colour-blind friendly, tuned per theme. */ + --ev-icu_admission: #0072B2; + --ev-vasopressor_start: #D55E00; + --ev-acute_kidney_injury: #009E73; + --ev-sepsis3: #B4508F; + --ev-death: #3C4650; + + --radius: 10px; + --radius-small: 6px; + --gap: 16px; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0B1416; + --surface: #101B1E; + --surface-2: #162427; + --ink: #E7EEF0; + --ink-muted: #93A6AC; + --ink-faint: #64777D; + --border: #223236; + --border-strong: #2C4045; + --accent: #3FC1C9; + --accent-strong: #6FDEE3; + --accent-soft: #123338; + --good: #4CAF7D; + --good-soft: #123324; + --warn: #D9A441; + --warn-soft: #332A14; + --critical: #E07272; + --critical-soft: #331717; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 20px rgba(0, 0, 0, 0.35); + + --ev-icu_admission: #56B4E9; + --ev-vasopressor_start: #F08A4B; + --ev-acute_kidney_injury: #3CC9A0; + --ev-sepsis3: #E0A3C8; + --ev-death: #C9D2D6; + } +} + +:root[data-theme="dark"] { + --bg: #0B1416; + --surface: #101B1E; + --surface-2: #162427; + --ink: #E7EEF0; + --ink-muted: #93A6AC; + --ink-faint: #64777D; + --border: #223236; + --border-strong: #2C4045; + --accent: #3FC1C9; + --accent-strong: #6FDEE3; + --accent-soft: #123338; + --good: #4CAF7D; + --good-soft: #123324; + --warn: #D9A441; + --warn-soft: #332A14; + --critical: #E07272; + --critical-soft: #331717; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 20px rgba(0, 0, 0, 0.35); + + --ev-icu_admission: #56B4E9; + --ev-vasopressor_start: #F08A4B; + --ev-acute_kidney_injury: #3CC9A0; + --ev-sepsis3: #E0A3C8; + --ev-death: #C9D2D6; +} + +/* ---------------------------------------------------------------- base */ + +* { box-sizing: border-box; } + +html, body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +h1, h2, h3 { margin: 0; line-height: 1.25; } +h1 { font-size: 22px; font-weight: 650; } +h2 { font-size: 17px; font-weight: 650; } +h3 { font-size: 14px; font-weight: 650; } + +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +.skip-link { + position: absolute; left: -999px; top: 8px; z-index: 50; + background: var(--surface); padding: 8px 12px; border-radius: var(--radius-small); +} +.skip-link:focus { left: 8px; } + +.muted { color: var(--ink-muted); } +.faint { color: var(--ink-faint); } +.mono { font-family: var(--font-mono); } +.num { font-variant-numeric: tabular-nums; } + +/* -------------------------------------------------------------- chrome */ + +.topbar { + display: flex; align-items: center; gap: 24px; + padding: 12px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 20; +} +.topbar__brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.brand-mark { + width: 28px; height: 28px; border-radius: 8px; flex: none; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + box-shadow: inset 0 0 0 5px var(--surface), inset 0 0 0 7px var(--accent); +} +.brand-name { font-weight: 700; letter-spacing: 0.01em; } +.brand-sub { font-size: 12px; color: var(--ink-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.topbar__nav { display: flex; gap: 4px; margin-left: 8px; } +.topbar__nav a { + color: var(--ink-muted); padding: 6px 12px; border-radius: 999px; font-weight: 550; +} +.topbar__nav a:hover { text-decoration: none; background: var(--surface-2); color: var(--ink); } +.topbar__nav a.is-active { background: var(--accent-soft); color: var(--accent-strong); } + +.search { display: flex; gap: 6px; margin-left: auto; } +.search input { + width: 170px; padding: 6px 10px; border-radius: var(--radius-small); + border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); + font: inherit; font-size: 14px; +} +.mode-badge { + font-size: 12px; font-weight: 650; padding: 4px 10px; border-radius: 999px; + white-space: nowrap; +} +.search[hidden] + .mode-badge { margin-left: auto; } +.mode-badge--open { background: var(--good-soft); color: var(--good); } +.mode-badge--credentialed { background: var(--critical-soft); color: var(--critical); } + +.banner { + padding: 8px 24px; font-size: 13px; + background: var(--warn-soft); color: var(--ink); + border-bottom: 1px solid var(--border); +} + +.app { max-width: 1480px; margin: 0 auto; padding: 20px 24px 48px; outline: none; } +.footer { max-width: 1480px; margin: 0 auto; padding: 0 24px 32px; font-size: 12px; color: var(--ink-faint); } + +/* ------------------------------------------------------------- blocks */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; + min-width: 0; +} +.card__head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } +.card__head .spacer { flex: 1; } +.card__sub { font-size: 13px; color: var(--ink-muted); } + +.btn { + font: inherit; font-weight: 600; font-size: 14px; + padding: 8px 14px; border-radius: var(--radius-small); + border: 1px solid var(--accent); background: var(--accent); color: #fff; + cursor: pointer; +} +.btn:hover { background: var(--accent-strong); border-color: var(--accent-strong); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn--ghost { background: transparent; color: var(--accent); } +.btn--ghost:hover { background: var(--accent-soft); color: var(--accent-strong); } +.btn--small { padding: 5px 10px; font-size: 13px; } +.btn--icon { padding: 4px 8px; line-height: 1; } + +select, input[type="number"] { + font: inherit; font-size: 14px; color: var(--ink); + background: var(--surface); border: 1px solid var(--border-strong); + border-radius: var(--radius-small); padding: 6px 8px; +} + +.segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; } +.segmented button { + font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; + border: 0; background: transparent; color: var(--ink-muted); cursor: pointer; +} +.segmented button[aria-pressed="true"] { background: var(--accent); color: #fff; } + +.pill { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; font-weight: 700; letter-spacing: 0.03em; + padding: 1px 7px; border-radius: 999px; white-space: nowrap; + background: var(--surface-2); color: var(--ink-muted); +} +.pill--LOW { background: var(--accent-soft); color: var(--accent-strong); } +.pill--HIGH { background: var(--warn-soft); color: var(--warn); } +.pill--CRITICAL { background: var(--critical-soft); color: var(--critical); } +.pill--training { background: var(--warn-soft); color: var(--warn); cursor: help; } +.pill--heldout { background: var(--good-soft); color: var(--good); } +.pill--event { color: #fff; } + +.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; } + +.note { + font-size: 13px; color: var(--ink-muted); + background: var(--surface-2); border-radius: var(--radius-small); + padding: 8px 12px; border-left: 3px solid var(--warn); +} + +.state-msg { padding: 48px 16px; text-align: center; color: var(--ink-muted); } +.state-msg--error { color: var(--critical); } +.spinner { + width: 22px; height: 22px; margin: 0 auto 12px; border-radius: 50%; + border: 3px solid var(--accent-soft); border-top-color: var(--accent); + animation: spin 0.9s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.skeleton { + border-radius: var(--radius); min-height: 96px; + background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface) 50%, var(--surface-2) 75%); + background-size: 200% 100%; animation: shimmer 1.4s ease infinite; +} +@keyframes shimmer { to { background-position: -200% 0; } } + +.progress { height: 8px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.progress__bar { height: 100%; width: 0; background: var(--accent); transition: width 0.3s ease; } + +/* ------------------------------------------------------------ gallery */ + +.page-head { display: flex; align-items: flex-end; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; } +.page-head p { margin: 4px 0 0; color: var(--ink-muted); max-width: 760px; } + +.section { margin-bottom: 28px; } +.section__head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; } +.section__summary { font-size: 13px; color: var(--ink-muted); margin: 0 0 12px; } +.section__kind { + font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--ink-faint); +} + +.case-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } +.case-card { + display: flex; flex-direction: column; gap: 8px; + color: var(--ink); padding: 14px 14px 12px; + border-left: 4px solid var(--case-color, var(--accent)); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.case-card:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 6px 20px rgba(20, 33, 43, 0.12); } +.case-card__headline { font-weight: 600; } +.case-card__meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 12px; color: var(--ink-muted); } +.case-card__id { font-family: var(--font-mono); font-size: 12px; color: var(--ink-faint); } + +.compact-list { list-style: none; margin: 0; padding: 0; columns: 3 260px; column-gap: 16px; } +.compact-list li { break-inside: avoid; padding: 3px 0; font-size: 13px; } + +.visit-list { display: grid; gap: 10px; max-width: 760px; } +.visit-row { display: flex; gap: 16px; align-items: center; color: var(--ink); } +.visit-row:hover { text-decoration: none; border-color: var(--accent); } +.visit-row .grow { flex: 1; } + +/* ------------------------------------------------------------- replay */ + +.patient-head { + display: flex; align-items: center; gap: 12px 20px; flex-wrap: wrap; + margin-bottom: 14px; +} +.patient-head__facts { display: flex; gap: 6px 18px; flex-wrap: wrap; color: var(--ink-muted); font-size: 14px; } +.patient-head__facts strong { color: var(--ink); font-weight: 600; } + +.replay { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: var(--gap); align-items: start; } +.replay__main, .replay__side { display: grid; gap: var(--gap); min-width: 0; } +.replay__side { position: sticky; top: 72px; } + +.moment { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.moment__time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; } + +.risk-cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; } +.risk-card { + position: relative; padding: 12px 12px 10px; + border-top: 4px solid var(--card-color, var(--accent)); + transition: background-color 0.25s ease; +} +.risk-card[data-level="watch"] { box-shadow: inset 0 0 0 1px var(--warn); } +.risk-card[data-level="alert"] { background: var(--critical-soft); } +.risk-card[data-level="done"] { background: var(--surface-2); } +.risk-card__name { font-size: 13px; font-weight: 650; display: flex; align-items: center; gap: 6px; } +.risk-card__value { font-size: 30px; font-weight: 750; font-variant-numeric: tabular-nums; line-height: 1.1; margin-top: 6px; } +.risk-card__value small { font-size: 12px; font-weight: 500; color: var(--ink-muted); margin-left: 4px; } +.risk-card__context { font-size: 12px; color: var(--ink-muted); min-height: 18px; } +.risk-card__minor { font-size: 12px; color: var(--ink-muted); margin-top: 4px; font-variant-numeric: tabular-nums; } +.risk-card__line { font-size: 11px; margin-top: 6px; color: var(--ink-faint); } +.risk-card[data-level="alert"] .risk-card__line { color: var(--critical); font-weight: 700; } +.trend { font-weight: 700; } +.trend--up { color: var(--critical); } +.trend--down { color: var(--good); } + +.callouts { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.callout { + display: flex; gap: 10px; align-items: flex-start; + padding: 8px 10px; border-radius: var(--radius-small); + border-left: 4px solid var(--callout-color, var(--accent)); + background: var(--surface-2); font-size: 14px; + transition: box-shadow 0.25s ease, background-color 0.25s ease; +} +.callout--lead { font-weight: 550; } +.callout__body { display: grid; gap: 3px; } +.callout__detail { font-size: 12px; font-weight: 400; color: var(--ink-muted); } +.onset text { font-size: 11px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; stroke-linejoin: round; } +.callout.is-live { background: var(--critical-soft); box-shadow: 0 0 0 2px var(--critical) inset; } +.callout__tag { font-size: 11px; font-weight: 700; color: var(--ink-faint); white-space: nowrap; margin-top: 2px; } + +.legend { display: flex; gap: 6px; flex-wrap: wrap; } +.legend button { + font: inherit; font-size: 12px; font-weight: 600; + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); +} +.legend button[aria-pressed="false"] { opacity: 0.4; } + +.risk-chart { position: relative; width: 100%; } +.risk-chart__svg { display: block; width: 100%; height: 300px; cursor: crosshair; user-select: none; } +.risk-chart .axis text { fill: var(--ink-faint); font-size: 11px; } +.risk-chart .grid line { stroke: var(--border); } +.risk-chart .cursor { stroke: var(--ink); stroke-width: 1.5; } +.risk-chart .marker line { stroke: var(--ink-faint); stroke-dasharray: 2 3; } +.risk-chart .marker text, .risk-chart .onset text { font-size: 10px; font-weight: 700; } +.risk-chart .marker text { fill: var(--ink-muted); } + +.chart-tip { + position: absolute; pointer-events: none; z-index: 5; + background: var(--surface); border: 1px solid var(--border-strong); + border-radius: var(--radius-small); box-shadow: var(--shadow); + padding: 8px 10px; font-size: 12px; min-width: 170px; +} +.chart-tip__time { font-weight: 700; margin-bottom: 4px; } +.chart-tip__row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; } +.chart-tip__row span:nth-child(2) { flex: 1; } + +.scrubber { display: flex; align-items: center; gap: 12px; } +.scrubber input[type="range"] { flex: 1; accent-color: var(--accent); height: 24px; } +.scrubber__label { font-size: 12px; color: var(--ink-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +.play-btn { min-width: 92px; } + +.concept-strip { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 8px; } +.concept-strip__labels { list-style: none; margin: 0; padding: 0; } +.concept-strip__labels li { + height: 14px; line-height: 14px; font-size: 11px; color: var(--ink-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; +} +.concept-strip__labels li.is-hot { color: var(--ink); font-weight: 700; } +.concept-strip__plot { position: relative; } +.concept-strip__plot canvas { display: block; width: 100%; cursor: crosshair; border-radius: 4px; } +.concept-strip__cursor { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--ink); pointer-events: none; } +.concept-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } +.concept-chip { + font-size: 12px; padding: 3px 9px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; +} +.concept-chip .num { font-weight: 500; margin-left: 4px; } + +.side-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.next-row { display: grid; grid-template-columns: minmax(0, 1fr) 46px; gap: 2px 8px; font-size: 13px; } +.next-row__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.next-row__p { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-muted); } +.bar { grid-column: 1 / -1; height: 5px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.bar__fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; } + +.event-row { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; font-size: 13px; } +.event-row__t { color: var(--ink-faint); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; } +.event-row__body { display: flex; flex-wrap: nowrap; gap: 6px; align-items: flex-start; } +.event-row__body > .event-row__label { flex: 1 1 auto; min-width: 0; } +.event-row__body > .event-row__value, .event-row__body > .pill { flex: none; } +.event-row__value { font-variant-numeric: tabular-nums; color: var(--ink-muted); } +.event-row--care .event-row__label, .event-row--death .event-row__label { font-weight: 700; } +.cat-dot { width: 7px; height: 7px; border-radius: 2px; background: var(--ink-faint); display: inline-block; flex: none; margin-top: 6px; } +.cat-dot--lab { background: var(--accent); } +.cat-dot--vital { background: var(--good); } +.cat-dot--medication, .cat-dot--infusion { background: var(--warn); } +.cat-dot--care, .cat-dot--death { background: var(--critical); } +.side-scroll { max-height: 360px; overflow: auto; padding-right: 4px; } + +/* ---------------------------------------------------- what-if / why */ + +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin: -4px 0 14px; } +.tabs button { + font: inherit; font-weight: 650; font-size: 14px; + padding: 8px 14px; border: 0; background: transparent; color: var(--ink-muted); cursor: pointer; + border-bottom: 3px solid transparent; margin-bottom: -1px; +} +.tabs button[aria-selected="true"] { color: var(--accent-strong); border-bottom-color: var(--accent); } + +.panel { display: grid; gap: 12px; } +.panel__controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.edit-row { + display: grid; grid-template-columns: 200px minmax(0, 1fr) 110px auto; gap: 10px; align-items: center; + padding: 8px 10px; background: var(--surface-2); border-radius: var(--radius-small); +} +.edit-row__name { font-weight: 600; font-size: 14px; } +.edit-row__hint { font-size: 12px; color: var(--ink-muted); } +.edit-row input[type="range"] { width: 100%; accent-color: var(--accent); } +.edit-row__value { font-variant-numeric: tabular-nums; font-weight: 650; text-align: right; } + +.compare { display: grid; gap: 8px; } +.compare-row { display: grid; grid-template-columns: 170px minmax(0, 1fr) 140px; gap: 12px; align-items: center; font-size: 13px; } +.compare-bars { display: grid; gap: 3px; } +.compare-bar { height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } +.compare-bar__fill { height: 100%; border-radius: 999px; width: 0; transition: width 0.4s ease; } +.compare-bar__fill--before { background: var(--ink-faint); } +.compare-row__delta { font-variant-numeric: tabular-nums; text-align: right; } +.delta-up { color: var(--critical); font-weight: 700; } +.delta-down { color: var(--good); font-weight: 700; } +.delta-flat { color: var(--ink-muted); } + +.evidence-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.evidence-row { display: grid; grid-template-columns: minmax(0, 1fr) 240px 120px; gap: 12px; align-items: center; font-size: 13px; } +.evidence-row__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.diverge { position: relative; height: 12px; background: var(--surface-2); border-radius: 3px; } +.diverge::after { content: ""; position: absolute; left: 50%; top: -2px; bottom: -2px; width: 1px; background: var(--border-strong); } +.diverge__fill { position: absolute; top: 0; bottom: 0; border-radius: 3px; } +.diverge__fill--up { left: 50%; background: var(--critical); } +.diverge__fill--down { right: 50%; background: var(--good); } + +/* ---------------------------------------------------------- scorecard */ + +.headline-card { font-size: 17px; font-weight: 600; border-left: 4px solid var(--accent); } +.score-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.score-table th, .score-table td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +.score-table th { font-size: 12px; color: var(--ink-muted); font-weight: 650; } +.score-cell { display: grid; gap: 3px; } +.score-cell__line { display: flex; flex-wrap: wrap; gap: 0 6px; align-items: baseline; font-variant-numeric: tabular-nums; } +.score-cell__who { width: 44px; color: var(--ink-muted); font-size: 12px; } +.score-cell__auroc { font-weight: 700; } +.score-cell__ci { color: var(--ink-faint); font-size: 11px; white-space: nowrap; } +.verdict { font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 999px; justify-self: start; } +.verdict--gbm { background: var(--warn-soft); color: var(--warn); } +.verdict--model { background: var(--good-soft); color: var(--good); } +.verdict--tie { background: var(--surface-2); color: var(--ink-muted); } +.calib { display: block; } +.calib .diag { stroke: var(--border-strong); stroke-dasharray: 2 2; } +.calib .curve { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.calib .pt { fill: var(--accent); } +.auroc-bars { display: grid; gap: 4px; } +.auroc-bar { display: grid; grid-template-columns: 220px minmax(0, 1fr) 52px; gap: 10px; align-items: center; font-size: 13px; } +.auroc-bar__track { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.auroc-bar__fill { height: 100%; background: var(--accent); border-radius: 999px; } +.notes { margin: 0; padding-left: 18px; color: var(--ink-muted); font-size: 13px; display: grid; gap: 6px; } + +.two-col { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: var(--gap); align-items: start; } + +/* --------------------------------------------------------- responsive */ + +@media (max-width: 1240px) { + .risk-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .replay { grid-template-columns: minmax(0, 1fr) 290px; } +} +@media (max-width: 1000px) { + .replay, .two-col { grid-template-columns: minmax(0, 1fr); } + .replay__side { position: static; } + .topbar { flex-wrap: wrap; gap: 12px; } + .edit-row { grid-template-columns: minmax(0, 1fr); } + .evidence-row, .compare-row { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 640px) { + .risk-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .concept-strip { grid-template-columns: 120px minmax(0, 1fr); } + .app { padding: 16px 12px 40px; } +} +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} diff --git a/apps/clinician_demo/thresholds.py b/apps/clinician_demo/thresholds.py new file mode 100644 index 00000000..df8975f7 --- /dev/null +++ b/apps/clinician_demo/thresholds.py @@ -0,0 +1,196 @@ +"""Alert lines: the risk threshold per (event, horizon) and what it buys. + +A risk number alone does not tell a clinician whether to act. The demo +draws an alert line instead: the threshold at which the model would flag a +fixed share of at-risk moments (default 5%), measured on the run's own +held-out landmark rows (``alerts_rows.parquet``) -- the same rows its +published AUROCs come from. For that line it reports how many of the +moments later followed by the event were flagged (sensitivity), how many +flags were right (PPV), and the tuned GBM's sensitivity/PPV when it flags +the same share of moments, so the comparison is like for like. + +Only aggregates leave this module; the row file is patient-level and is +read in place on the host. +""" + +import json +import logging +from collections.abc import Sequence +from pathlib import Path + +import polars as pl + +from apps.clinician_demo.schemas import OperatingPoint, to_jsonable + + +logger = logging.getLogger(__name__) + +ALERTS_ROWS_FILENAME = "alerts_rows.parquet" +CACHE_VERSION = 1 + + +def horizon_key(horizon_hours: float) -> str: + """Format a horizon the way the banked files do: ``24.0`` -> ``"24h"``.""" + return f"{horizon_hours:g}h" + + +def _rate(numerator: int, denominator: int) -> float | None: + return numerator / denominator if denominator else None + + +def flag_threshold(scores: pl.Series, alert_rate: float) -> float: + """Return the score at or above which ``alert_rate`` of ``scores`` are flagged. + + Uses the upper quantile (``interpolation="higher"``) so the threshold is + an observed score; ties at the threshold can make the flagged share a + little larger than ``alert_rate``, which callers report as measured. + """ + value = scores.quantile(1.0 - alert_rate, interpolation="higher") + if value is None: + raise ValueError("cannot set a threshold on an empty score column") + return float(value) + + +def _flag_stats( + scores: pl.Series, outcome: pl.Series, threshold: float +) -> tuple[float, float | None, float | None]: + """Return ``(flagged share, sensitivity, PPV)`` for ``scores >= threshold``.""" + flagged = scores >= threshold + positive = outcome == 1 + n_flagged = int(flagged.sum()) + n_pos = int(positive.sum()) + hits = int((flagged & positive).sum()) + return n_flagged / len(scores), _rate(hits, n_pos), _rate(hits, n_flagged) + + +def operating_point( + rows: pl.DataFrame, event: str, horizon_hours: float, alert_rate: float +) -> OperatingPoint | None: + """Compute the alert line for one (event, horizon) over at-risk rows. + + ``rows`` needs ``event``, ``hazard@{h}h``, ``y@{h}h`` and optionally + ``gbm@{h}h``. Rows whose outcome is null (not at risk, or censored + before the horizon) are excluded, as in the published metrics. Returns + ``None`` when no at-risk row exists. + """ + key = horizon_key(horizon_hours) + hazard, outcome, gbm = f"hazard@{key}", f"y@{key}", f"gbm@{key}" + at_risk = rows.filter( + (pl.col("event") == event) + & pl.col(outcome).is_not_null() + & pl.col(hazard).is_not_null() + ) + if at_risk.height == 0: + return None + threshold = flag_threshold(at_risk[hazard], alert_rate) + share, sensitivity, ppv = _flag_stats(at_risk[hazard], at_risk[outcome], threshold) + gbm_sens: float | None = None + gbm_ppv: float | None = None + if gbm in at_risk.columns: + scored = at_risk.filter(pl.col(gbm).is_not_null()) + if scored.height: + gbm_threshold = flag_threshold(scored[gbm], alert_rate) + _, gbm_sens, gbm_ppv = _flag_stats( + scored[gbm], scored[outcome], gbm_threshold + ) + return OperatingPoint( + event=event, + horizon_hours=horizon_hours, + threshold=threshold, + alert_rate=share, + sensitivity=sensitivity, + ppv=ppv, + base_rate=float(at_risk[outcome].mean() or 0.0), # type: ignore[arg-type] + n_rows=at_risk.height, + gbm_sensitivity=gbm_sens, + gbm_ppv=gbm_ppv, + ) + + +def compute_operating_points( + rows_path: str | Path, + events: Sequence[str], + horizons: Sequence[float], + alert_rate: float, +) -> list[OperatingPoint]: + """Compute operating points for every (event, horizon) from a row dump. + + Reads only the columns needed, one event at a time, so the multi-million + row dump is never held whole in memory. + """ + lf = pl.scan_parquet(rows_path) + available = set(lf.collect_schema().names()) + points: list[OperatingPoint] = [] + for event in events: + for h in horizons: + key = horizon_key(h) + columns = [ + c + for c in ("event", f"hazard@{key}", f"y@{key}", f"gbm@{key}") + if c in available + ] + if f"hazard@{key}" not in columns or f"y@{key}" not in columns: + logger.warning("[thresholds] %s has no %s columns", rows_path, key) + continue + rows = lf.select(columns).filter(pl.col("event") == event).collect() + point = operating_point(rows, event, h, alert_rate) + if point is not None: + points.append(point) + return points + + +def _signature( + rows_path: Path, events: Sequence[str], horizons: Sequence[float], alert_rate: float +) -> dict[str, object]: + stat = rows_path.stat() + return { + "version": CACHE_VERSION, + "rows": str(rows_path.resolve()), + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "events": list(events), + "horizons": list(horizons), + "alert_rate": alert_rate, + } + + +def load_or_compute_operating_points( + rows_path: str | Path, + cache_path: str | Path, + events: Sequence[str], + horizons: Sequence[float], + alert_rate: float, +) -> list[OperatingPoint]: + """Run :func:`compute_operating_points`, cached in a JSON file of aggregates. + + The cache is reused only when the row file (path, size, mtime) and the + request (events, horizons, alert rate) match exactly; anything else + recomputes and rewrites it. + """ + rows_path, cache_path = Path(rows_path), Path(cache_path) + signature = _signature(rows_path, events, horizons, alert_rate) + if cache_path.exists(): + try: + cached = json.loads(cache_path.read_text()) + if cached.get("signature") == signature: + return [OperatingPoint(**p) for p in cached["points"]] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + logger.warning( + "[thresholds] ignoring unreadable cache %s: %s", cache_path, exc + ) + points = compute_operating_points(rows_path, events, horizons, alert_rate) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps({"signature": signature, "points": to_jsonable(points)}, indent=1) + ) + return points + + +__all__ = [ + "ALERTS_ROWS_FILENAME", + "compute_operating_points", + "flag_threshold", + "horizon_key", + "load_or_compute_operating_points", + "operating_point", +] diff --git a/apps/clinician_demo/whatif.py b/apps/clinician_demo/whatif.py new file mode 100644 index 00000000..7658d1c3 --- /dev/null +++ b/apps/clinician_demo/whatif.py @@ -0,0 +1,308 @@ +"""What-if: change recent readings in the record and re-score the forecast. + +Built on :mod:`odyssey.inference.counterfactual`: each preset rewrites one +panel signal's numeric readings inside a window before the chosen moment +(set to a value, add, or scale), re-tokenizes with the run's own binner, +and streams the factual and edited records through the frozen model. + +What it measures is how the model's CURRENT forecast responds to a +different record -- model sensitivity, not the effect of a treatment. An +edit can only change readings that exist: if no reading of the signal +falls in the window, nothing changes and the result says so instead of +spending GPU time. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +import polars as pl + +from apps.clinician_demo.forecast import RunContext +from apps.clinician_demo.schemas import Readout, WhatIfPreset, WhatIfResult +from odyssey.inference.counterfactual import ( + ForecastReadout, + ValueEdit, + apply_value_edits, + counterfactual_forecast, +) + + +MAX_EDITS = 3 + +PRESETS: tuple[WhatIfPreset, ...] = ( + WhatIfPreset( + "sbp", + "Systolic BP", + "sbp_noninvasive", + "set", + 80.0, + 50.0, + 180.0, + 5.0, + "mmHg", + 6.0, + "Set every systolic reading (cuff or arterial line) in the last 6 h to this value.", + ), + WhatIfPreset( + "map", + "Mean arterial pressure", + "map_noninvasive", + "set", + 60.0, + 40.0, + 120.0, + 5.0, + "mmHg", + 6.0, + "Set every MAP reading (cuff or arterial line) in the last 6 h to this value.", + ), + WhatIfPreset( + "heart_rate", + "Heart rate", + "heart_rate", + "set", + 130.0, + 30.0, + 190.0, + 5.0, + "bpm", + 6.0, + "Set every heart-rate reading in the last 6 h to this value.", + ), + WhatIfPreset( + "resp_rate", + "Respiratory rate", + "resp_rate", + "set", + 28.0, + 6.0, + 45.0, + 1.0, + "/min", + 6.0, + "Set every respiratory-rate reading in the last 6 h to this value.", + ), + WhatIfPreset( + "spo2", + "SpO2", + "spo2", + "set", + 86.0, + 70.0, + 100.0, + 1.0, + "%", + 6.0, + "Set every SpO2 reading in the last 6 h to this value.", + ), + WhatIfPreset( + "lactate", + "Lactate (multiply)", + "lactate", + "scale", + 3.0, + 0.25, + 5.0, + 0.25, + "x", + 12.0, + "Multiply every lactate result in the last 12 h by this factor.", + ), + WhatIfPreset( + "creatinine", + "Creatinine (add)", + "creatinine", + "add", + 1.0, + -1.5, + 4.0, + 0.1, + "mg/dL", + 24.0, + "Add this amount to every creatinine result in the last 24 h.", + ), + WhatIfPreset( + "potassium", + "Potassium", + "potassium", + "set", + 6.5, + 2.0, + 8.0, + 0.1, + "mEq/L", + 24.0, + "Set every potassium result in the last 24 h to this value.", + ), + WhatIfPreset( + "platelets", + "Platelets", + "platelets", + "set", + 40.0, + 5.0, + 500.0, + 5.0, + "K/uL", + 24.0, + "Set every platelet count in the last 24 h to this value.", + ), +) +PRESETS_BY_ID: dict[str, WhatIfPreset] = {p.id: p for p in PRESETS} +#: Presets that edit more than one panel signal: blood pressure is charted +#: by cuff on the ward and by arterial line in the ICU, and a clinician +#: asking "what if the BP were 80" means both. +EXTRA_SIGNALS: dict[str, tuple[str, ...]] = { + "sbp": ("sbp_arterial",), + "map": ("map_arterial",), +} + + +@dataclass(frozen=True) +class EditRequest: + """One requested edit: a preset and the value chosen on its control.""" + + preset_id: str + value: float + + +def parse_edit_requests(payload: object) -> list[EditRequest]: + """Validate the ``edits`` list of a what-if request body. + + Each item is ``{"preset": id, "value": number}`` (``value`` defaults to + the preset's own). At most :data:`MAX_EDITS`, each preset at most once, + every value inside its preset's bounds. + + Raises + ------ + ValueError + On any malformed, unknown, duplicate or out-of-range edit. + """ + if not isinstance(payload, list) or not payload: + raise ValueError("edits must be a non-empty list") + if len(payload) > MAX_EDITS: + raise ValueError(f"at most {MAX_EDITS} edits at once") + out: list[EditRequest] = [] + for item in payload: + if not isinstance(item, dict): + raise ValueError("each edit must be an object") + preset = PRESETS_BY_ID.get(str(item.get("preset"))) + if preset is None: + raise ValueError(f"unknown preset {item.get('preset')!r}") + raw = item.get("value", preset.value) + if isinstance(raw, bool) or not isinstance(raw, (int, float)): + raise ValueError(f"{preset.id}: value must be a number") + value = float(raw) + if not preset.min <= value <= preset.max: + raise ValueError( + f"{preset.id}: value {value:g} outside [{preset.min:g}, {preset.max:g}]" + ) + if any(r.preset_id == preset.id for r in out): + raise ValueError(f"{preset.id}: listed twice") + out.append(EditRequest(preset.id, value)) + return out + + +def to_value_edits(request: EditRequest) -> list[ValueEdit]: + """Return the counterfactual-module edits a request stands for.""" + preset = PRESETS_BY_ID[request.preset_id] + return [ + ValueEdit( + signal=signal, + mode=preset.mode, # type: ignore[arg-type] + value=request.value, + window_hours=preset.window_hours, + ) + for signal in (preset.signal, *EXTRA_SIGNALS.get(preset.id, ())) + ] + + +def readout(forecast: ForecastReadout, events: Sequence[str]) -> Readout: + """Restrict a forecast to the displayed events.""" + return Readout( + risk={ + e: dict(forecast.event_risk[e]) for e in events if e in forecast.event_risk + }, + concepts=dict(forecast.concept_probs), + ) + + +def untouched_warnings( + raw_events: pl.DataFrame, + requests: Sequence[EditRequest], + *, + index_time: object, + source: str, +) -> tuple[int, list[str]]: + """Count touched rows and warn about each edit that touches none.""" + total = 0 + warnings: list[str] = [] + for request in requests: + _, touched = apply_value_edits( + raw_events, to_value_edits(request), index_time=index_time, source=source + ) + total += touched + if touched == 0: + preset = PRESETS_BY_ID[request.preset_id] + warnings.append( + f"No {preset.label} readings in the {preset.window_hours:g} h before this " + "moment, so that edit changed nothing." + ) + return total, warnings + + +def run_whatif( + ctx: RunContext, + raw_events: pl.DataFrame, + requests: Sequence[EditRequest], + *, + index_time: object, + t_hours: float, +) -> WhatIfResult: + """Compare the factual and edited forecast at ``index_time`` (a record time).""" + touched, warnings = untouched_warnings( + raw_events, requests, index_time=index_time, source=ctx.source + ) + if touched == 0: + empty = Readout(risk={}, concepts={}) + return WhatIfResult(t_hours, 0, warnings, empty, empty, empty) + result = counterfactual_forecast( + ctx.model, + ctx.vocab, + ctx.binner, + raw_events, + [edit for r in requests for edit in to_value_edits(r)], + index_time=index_time, + concept_names=ctx.concept_names, + source=ctx.source, + device=ctx.device, + chunk_size=ctx.chunk_size, + ) + factual = readout(result.factual, ctx.events) + counterfactual = readout(result.counterfactual, ctx.events) + delta = Readout( + risk={ + e: {h: counterfactual.risk[e][h] - p for h, p in hs.items()} + for e, hs in factual.risk.items() + }, + concepts={ + c: counterfactual.concepts[c] - p for c, p in factual.concepts.items() + }, + ) + return WhatIfResult( + t_hours, result.rows_edited, warnings, factual, counterfactual, delta + ) + + +__all__ = [ + "MAX_EDITS", + "PRESETS", + "PRESETS_BY_ID", + "EditRequest", + "parse_edit_requests", + "readout", + "run_whatif", + "EXTRA_SIGNALS", + "to_value_edits", + "untouched_warnings", +] diff --git a/docs/clinician_demo.md b/docs/clinician_demo.md new file mode 100644 index 00000000..b2b0f9a1 --- /dev/null +++ b/docs/clinician_demo.md @@ -0,0 +1,84 @@ +# Clinician demo ("Odyssey · Bedside Forecast") + +A web app that replays one patient's admission and shows, moment by moment, +what the trained model forecasts: the chance of ICU admission, vasopressors, +acute kidney injury, Sepsis-3 and death within 8, 24 and 72 hours; when that +risk would have crossed an alert line; what the model thinks is going on (its +29 concept beliefs); what would move the forecast (what-if); what the forecast +leans on (evidence); and how good the model is (scorecard, against the tuned +GBM). Code: `apps/clinician_demo/`. Tests: `tests/apps/clinician_demo/`. + +## Data modes and who may see them + +| Mode | Data | Who may view | +| --- | --- | --- | +| `credentialed` | held-out MIMIC-IV 3.1 patients of the run's own extraction | only people holding PhysioNet MIMIC-IV credentials | +| `open` | MIMIC-IV Clinical Database Demo (100 patients, open licence) | anyone | + +In open mode, 90 of the 100 demo patients were in the model's training or +tuning split (measured 2026-09-11 against `subject_splits.parquet`: 83 train, +7 tuning, 10 held-out). Every chart says whether the model saw the patient. +Only the 10 held-out ones are a fair test. + +Patient-level data never leaves the GPU host. The server binds to loopback and +is viewed through an SSH tunnel. Do not screenshot credentialed patients into +chat tools, slides or LLM services; use open mode for anything shared. + +## Run it (GPU host, repository root) + +```bash +# credentialed mode, port 8765 +.venv/bin/python -m apps.clinician_demo \ + --run-dir ~/runs/full_run_v10 \ + --data-dir ~/data/mimiciv_3.1_v1/data/held_out \ + --metadata-dir ~/data/mimiciv_3.1_v1/metadata \ + --splits ~/data/mimiciv_3.1_v1/metadata/subject_splits.parquet + +# open mode, port 8766 +.venv/bin/python -m apps.clinician_demo --data-mode open --port 8766 \ + --run-dir ~/runs/full_run_v10 \ + --data-dir ~/data/mimiciv_demo_meds/data \ + --metadata-dir ~/data/mimiciv_demo_meds/metadata \ + --splits ~/data/mimiciv_3.1_v1/metadata/subject_splits.parquet +``` + +Add `--self-check` to load everything, run one case end to end (trace, +what-if, gap to the banked landmark scores) and print a JSON report. Launch +long-running servers with `setsid nohup ... & disown`. + +The open demo extraction is made once with +`PATH=$HOME/odyssey/.venv/bin:$PATH meds-extract-run spec=MIMIC-IV output_dir=~/data/mimiciv_demo_meds dataset_key=demo` +(the pipeline shells out to `MEDS_transform-stage`, so the venv must be on +`PATH`). + +## View it (laptop) + +```bash +gcloud compute ssh odyssey-cbm-a100 --zone us-central1-f \ + --project agentic-ai-evaluation-bootcamp --tunnel-through-iap -- \ + -N -L 8765:localhost:8765 -L 8766:localhost:8766 +``` + +Then open http://localhost:8765 (credentialed) or http://localhost:8766 (open). + +## What is shown, and what is deliberately not + +Shown: the hazard heads' risk (hidden at and after the event's onset), alert +lines set on the run's own held-out landmark rows (default: flag 5% of at-risk +moments; sensitivity and PPV, and the GBM's at the same flag rate, are shown +beside each line), concept beliefs, next-event forecast, what-if value edits +(`odyssey.inference.counterfactual`), occlusion evidence, and the banked +scorecard. + +Not shown, on purpose: 30-day readmission (AUROC 0.59, the model's weakest +head), concept steering (does not apply to the mixture bottleneck of this +run), label overrides (wrong sign on this run), and generated future +timelines (untested on the hybrid backbone). A test enforces that the app +never imports those modules. + +## Security + +Loopback bind only; `Host` header allowlist (DNS rebinding); API calls need +`X-Odyssey-Demo: 1` (blocks cross-site requests); no CORS; `Cache-Control: +no-store`; same-origin CSP; static files from an allowlisted directory with +path containment. diff --git a/pyproject.toml b/pyproject.toml index 98cad28c..a8ac3b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,7 +329,7 @@ markers = [ [tool.coverage] [tool.coverage.run] - source=["odyssey"] + source=["odyssey", "apps"] omit=["tests/*", "*__init__.py"] [build-system] diff --git a/tests/apps/__init__.py b/tests/apps/__init__.py new file mode 100644 index 00000000..3f40b6a6 --- /dev/null +++ b/tests/apps/__init__.py @@ -0,0 +1 @@ +"""Tests for the top-level apps/ packages.""" diff --git a/tests/apps/clinician_demo/__init__.py b/tests/apps/clinician_demo/__init__.py new file mode 100644 index 00000000..1d418ddd --- /dev/null +++ b/tests/apps/clinician_demo/__init__.py @@ -0,0 +1 @@ +"""Tests for the clinician demo (CPU, synthetic data only).""" diff --git a/tests/apps/clinician_demo/conftest.py b/tests/apps/clinician_demo/conftest.py new file mode 100644 index 00000000..28320c0e --- /dev/null +++ b/tests/apps/clinician_demo/conftest.py @@ -0,0 +1,236 @@ +"""Shared fixtures: a synthetic MEDS cohort and a tiny CPU model (no real data).""" + +from collections.abc import Callable, Iterator +from datetime import datetime, timedelta +from pathlib import Path + +import polars as pl +import pytest +import torch + +from apps.clinician_demo.codebook import Codebook +from apps.clinician_demo.config import DemoConfig +from apps.clinician_demo.forecast import RunContext, displayed_alerts +from apps.clinician_demo.patient_store import ( + PatientStore, + build_shard_index, + discover_shards, +) +from apps.clinician_demo.schemas import ConceptInfo, OperatingPoint, Scorecard +from apps.clinician_demo.service import EVENT_ORDER, Components, DemoService +from apps.clinician_demo.whatif import PRESETS, EditRequest, to_value_edits +from odyssey.data.alert_events import alert_events_for +from odyssey.data.code_normalization import maybe_normalize +from odyssey.data.concepts import concepts_for_source +from odyssey.data.value_binning import add_value_tokens +from odyssey.data.vocabulary import Vocabulary +from odyssey.inference.counterfactual import apply_value_edits +from odyssey.models.backbones.tiny_gru import TinyGRUBackbone +from odyssey.models.sequence_model import ConceptBottleneckSequenceModel +from odyssey.models.time_to_event import DEFAULT_TIME_BIN_EDGES_HOURS + + +T0 = datetime(2150, 3, 1, 8, 0) +SBP = "LAB//220179//mmHg" +HR = "LAB//220045//bpm" +CREAT = "LAB//RESULT//50912//mg/dL" +ICU = "ICU_ADMISSION//Medical Intensive Care Unit (MICU)" +HEAD_NAMES = [ + "vasopressor_start", + "icu_admission", + "acute_kidney_injury", + "death", + "sepsis3", + "readmission_30d", +] +CHUNK = 16 # deliberately not any API's default (256), so a dropped value shows +SCHEMA = { + "subject_id": pl.Int64, + "time": pl.Datetime("us"), + "code": pl.Utf8, + "numeric_value": pl.Float32, + "hadm_id": pl.Int64, +} + +Row = tuple[int, datetime | None, str, float | None, int | None] + + +def cohort_rows() -> list[Row]: + """Subject 1: an ICU transfer at hour 20 of visit 10, death 10 h into visit 11. + + Subject 2: one uneventful 60 h visit. Every hour of visit 10 holds a + same-time bundle (SBP + HR); creatinine is drawn every 6 h. + """ + rows: list[Row] = [ + (1, None, "GENDER//M", None, None), + (1, T0 - timedelta(days=365.25 * 65), "MEDS_BIRTH", None, None), + (1, T0, "HOSPITAL_ADMISSION//EW EMER.//EMERGENCY ROOM", None, 10), + ] + for h in range(1, 41): + t = T0 + timedelta(hours=h) + rows.append((1, t, SBP, 125.0 - 1.5 * h, 10)) + rows.append((1, t, HR, 80.0 + h, 10)) + if h % 6 == 0: + rows.append((1, t, CREAT, 1.0 + 0.1 * (h // 6), 10)) + rows.append((1, T0 + timedelta(hours=20), ICU, None, 10)) + rows.append((1, T0 + timedelta(hours=41), "HOSPITAL_DISCHARGE//HOME", None, 10)) + second = T0 + timedelta(days=30) + rows.append( + (1, second, "HOSPITAL_ADMISSION//URGENT//TRANSFER FROM HOSPITAL", None, 11) + ) + for h in range(1, 9): + rows.append((1, second + timedelta(hours=h), SBP, 90.0, 11)) + # an in-hospital death: MIMIC records it with a DIED discharge at the same time + rows.append((1, second + timedelta(hours=10), "HOSPITAL_DISCHARGE//DIED", None, 11)) + rows.append((1, second + timedelta(hours=10), "MEDS_DEATH", None, None)) + + rows += [ + (2, None, "GENDER//F", None, None), + (2, T0, "HOSPITAL_ADMISSION//ELECTIVE//PHYSICIAN REFERRAL", None, 20), + ] + for h in range(1, 61): + rows.append((2, T0 + timedelta(hours=h), SBP, 120.0, 20)) + rows.append((2, T0 + timedelta(hours=61), "HOSPITAL_DISCHARGE//HOME", None, 20)) + return rows + + +def cohort_frame() -> pl.DataFrame: + """Return the synthetic cohort as a MEDS frame.""" + return pl.DataFrame(cohort_rows(), schema=SCHEMA, orient="row") + + +@pytest.fixture +def cohort_dir(tmp_path: Path) -> Path: + """Two held-out shards, one subject each.""" + data = tmp_path / "held_out" + data.mkdir() + frame = cohort_frame() + frame.filter(pl.col("subject_id") == 1).write_parquet(data / "0.parquet") + frame.filter(pl.col("subject_id") == 2).write_parquet(data / "1.parquet") + return data + + +def _vocabulary() -> Vocabulary: + """Every token the cohort and its what-if edits can produce.""" + raw = maybe_normalize(cohort_frame(), enabled=True, source="mimic_iv") + codes = add_value_tokens(raw)["code"].to_list() + for preset in PRESETS: + for value in (preset.min, preset.value, preset.max): + edited, _ = apply_value_edits( + raw, + to_value_edits(EditRequest(preset.id, value)), + index_time=T0 + timedelta(days=60), + ) + codes += add_value_tokens(edited)["code"].to_list() + return Vocabulary.build(codes, min_count=1) + + +CONCEPT_NAMES = tuple(c.name for c in concepts_for_source("mimic_iv", task_set="v3")) + + +@pytest.fixture(scope="session") +def vocab() -> Vocabulary: + """Return the cohort's vocabulary.""" + return _vocabulary() + + +@pytest.fixture(scope="session") +def model(vocab: Vocabulary) -> ConceptBottleneckSequenceModel: + """Build a tiny deterministic bottleneck model with the real event heads.""" + torch.manual_seed(0) + return ConceptBottleneckSequenceModel( + backbone=TinyGRUBackbone( + vocab_size=len(vocab), hidden_size=8, num_layers=1, padding_idx=0 + ), + vocab_size=len(vocab), + num_concepts=len(CONCEPT_NAMES), + embedding_dim=4, + padding_idx=0, + time_bin_edges=DEFAULT_TIME_BIN_EDGES_HOURS, + event_names=HEAD_NAMES, + ).eval() + + +@pytest.fixture +def ctx(model: ConceptBottleneckSequenceModel, vocab: Vocabulary) -> RunContext: + """Build the run context the service would build for this model.""" + alerts, head_index = displayed_alerts( + alert_events_for("v3", source="mimic_iv"), HEAD_NAMES, EVENT_ORDER + ) + return RunContext( + model=model, + vocab=vocab, + binner=None, + source="mimic_iv", + task_set="v3", + chunk_size=CHUNK, + device="cpu", + concept_names=CONCEPT_NAMES, + alerts=alerts, + head_index=head_index, + horizons=(8.0, 24.0, 72.0), + ) + + +@pytest.fixture +def store(cohort_dir: Path) -> PatientStore: + """Build the cohort's store; subject 1 is in the model's training split.""" + return PatientStore( + build_shard_index(discover_shards(cohort_dir)), + source="mimic_iv", + normalize_medications=True, + splits={1: "train", 2: "held_out"}, + ) + + +def points_for(events: tuple[str, ...], threshold: float) -> list[OperatingPoint]: + """One 24 h operating point per event at ``threshold``.""" + return [ + OperatingPoint(e, 24.0, threshold, 0.05, 0.5, 0.2, 0.01, 1000, 0.6, 0.25) + for e in events + ] + + +ServiceFactory = Callable[..., DemoService] + + +@pytest.fixture +def make_service( + tmp_path: Path, cohort_dir: Path, ctx: RunContext, store: PatientStore +) -> Iterator[ServiceFactory]: + """Build a service over the cohort; ``threshold`` sets every alert line.""" + built: list[DemoService] = [] + + def factory( + *, + data_mode: str = "open", + threshold: float = 0.0, + banked_rows_path: Path | None = None, + prepare: bool = True, + ) -> DemoService: + config = DemoConfig( + run_dir=tmp_path / "run", + data_dir=cohort_dir, + data_mode=data_mode, + device="cpu", # type: ignore[arg-type] + ) + service = DemoService( + Components( + config=config, + ctx=ctx, + store=store, + codebook=Codebook(), + operating_points=points_for(ctx.events, threshold), + scorecard=Scorecard(headline="h", cells=[], concepts=[], notes=[]), + concepts=[ConceptInfo(n, n, "", None) for n in ctx.concept_names], + banked_rows_path=banked_rows_path, + ) + ) + if prepare: + service.prepare_gallery() + built.append(service) + return service + + yield factory + for service in built: + service.shutdown() diff --git a/tests/apps/clinician_demo/test_codebook.py b/tests/apps/clinician_demo/test_codebook.py new file mode 100644 index 00000000..f3951a22 --- /dev/null +++ b/tests/apps/clinician_demo/test_codebook.py @@ -0,0 +1,217 @@ +"""Codebook: MEDS codes and model tokens in plain clinical language.""" + +from pathlib import Path + +import polars as pl +import pytest + +from apps.clinician_demo.codebook import ( + Codebook, + admission_label, + bin_flag, + bin_words, + category, + split_bin, +) + + +DESCRIPTIONS = { + "LAB//220045//bpm": "Heart Rate", + "LAB//RESULT//50912//mg/dL": "Creatinine [Mass/volume] in Serum or Plasma", + "INFUSION_START//221906": "Norepinephrine", + "DIAGNOSIS//ICD//10//I5021": "Acute systolic (congestive) heart failure", +} + + +@pytest.fixture +def book() -> Codebook: + return Codebook(DESCRIPTIONS) + + +@pytest.mark.parametrize( + ("token", "expected"), + [ + ("LAB//220045//bpm::HIGH", ("LAB//220045//bpm", "HIGH")), + ("LAB//220045//bpm", ("LAB//220045//bpm", None)), + ("LAB//x::", ("LAB//x", None)), + ("MEDS_DEATH", ("MEDS_DEATH", None)), + ], +) +def test_split_bin(token: str, expected: tuple[str, str | None]) -> None: + assert split_bin(token) == expected + + +def test_flags_only_for_abnormal_clinical_bins() -> None: + assert [bin_flag(b) for b in ("LOW", "HIGH", "CRITICAL", "NORMAL", "Q5", None)] == [ + "LOW", + "HIGH", + "CRITICAL", + None, + None, + None, + ] + assert bin_words("Q1") == "bottom fifth" and bin_words("CRITICAL") == "critical" + assert bin_words(None) is None and bin_words("Z9") is None + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("LAB//RESULT//50912//mg/dL", "lab"), + ("LAB//SPECIMEN_COLLECTED//50912//mg/dL", "order"), + ("LAB//220045//bpm", "vital"), + ("MEDICATION//norepinephrine//Administered", "medication"), + ("INFUSION_START//221906", "infusion"), + ("SUBJECT_FLUID_OUTPUT//226559//mL", "output"), + ("DIAGNOSIS//ICD//10//I5021", "diagnosis"), + ("PROCEDURE//START//225792", "procedure"), + ("HOSPITAL_ADMISSION//EW EMER.//EMERGENCY ROOM", "care"), + ("TRANSFER_TO//transfer//Medical Intensive Care Unit (MICU)", "care"), + ("MEDS_DEATH", "death"), + ("GENDER//F", "demographic"), + ("DRG//HCFA//123", "billing"), + ("BMI (kg/m2)", "other"), + ], +) +def test_category(code: str, expected: str) -> None: + assert category(code) == expected + + +def test_dictionary_labels_win_and_long_lab_names_are_trimmed(book: Codebook) -> None: + assert book.label("LAB//220045//bpm::HIGH") == "Heart Rate" + assert book.label("LAB//RESULT//50912//mg/dL") == "Creatinine" + assert ( + book.label("DIAGNOSIS//ICD//10//I5021") + == "Acute systolic (congestive) heart failure" + ) + assert len(book) == 4 + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("MEDICATION//norepinephrine//Administered", "Norepinephrine (administered)"), + ("MEDICATION//START//vancomycin", "Started vancomycin"), + ("MEDICATION//STOP//vancomycin", "Stopped vancomycin"), + ( + "HOSPITAL_ADMISSION//EW EMER.//EMERGENCY ROOM", + "Emergency admission, from emergency room", + ), + ("HOSPITAL_DISCHARGE//HOME", "Discharge to home"), + ("TRANSFER_TO//transfer//MICU", "Transfer to MICU"), + ("ICU_ADMISSION//MICU", "ICU admission: MICU"), + ("ED_REGISTRATION", "ED registration"), + ("MEDS_DEATH", "Death"), + ("GENDER//F", "Sex: F"), + ("DIAGNOSIS//ICD//10//I50", "Diagnosis ICD-10 I50"), + ("INFUSION_START//999", "Infusion started (item 999)"), + ("LAB//RESULT//12345//mg/dL", "Lab item 12345"), + ("SOMETHING_ELSE//a//b", "Something else · a · b"), + ("BMI (kg/m2)", "BMI (kg/m2)"), + ], +) +def test_structural_labels_without_a_dictionary(code: str, expected: str) -> None: + assert Codebook().label(code) == expected + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("LAB//220045//bpm", "bpm"), + ("LAB//RESULT//50912//mg/dL::HIGH", "mg/dL"), + ("LAB//220739//UNK", None), + ("SUBJECT_FLUID_OUTPUT//226559//mL", "mL"), + ("MEDICATION//x//Administered", None), + ("LAB//12345", None), + ], +) +def test_units(code: str, expected: str | None) -> None: + assert Codebook().unit(code) == expected + + +def test_token_label_puts_the_bin_in_words(book: Codebook) -> None: + assert book.token_label("LAB//220045//bpm::HIGH") == "Heart Rate (high)" + assert book.token_label("LAB//RESULT//50912//mg/dL::Q4") == "Creatinine (4th fifth)" + assert book.token_label("MEDS_DEATH") == "Death" + + +def test_timeline_entry_formats_value_unit_and_flag(book: Codebook) -> None: + entry = book.entry("LAB//RESULT//50912//mg/dL::CRITICAL", 3.5, 4.2) + assert (entry.t, entry.category, entry.label) == (3.5, "lab", "Creatinine") + assert entry.value == "4.2 mg/dL" and entry.flag == "CRITICAL" + assert book.entry("LAB//220045//bpm::NORMAL", 0.0, 72.0).flag is None + assert book.entry("MEDS_DEATH", 9.0, None).value is None + assert book.entry("LAB//220045//bpm", 0.0, float("nan")).value is None + assert book.entry("LAB//220045//bpm", 0.0, 1234567.0).value == "1.235e+06 bpm" + + +def test_from_metadata_dir_reads_codes_parquet(tmp_path: Path) -> None: + pl.DataFrame( + {"code": ["LAB//220045//bpm"], "description": ["Heart Rate"]} + ).write_parquet(tmp_path / "codes.parquet") + assert ( + Codebook.from_metadata_dir(tmp_path).label("LAB//220045//bpm") == "Heart Rate" + ) + assert len(Codebook.from_metadata_dir(None)) == 0 + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ( + "HOSPITAL_ADMISSION//EW EMER.//EMERGENCY ROOM", + "Emergency admission, from emergency room", + ), + ( + "HOSPITAL_ADMISSION//SURGICAL SAME DAY ADMISSION//PHYSICIAN REFERRAL", + "Same-day surgery admission, from physician referral", + ), + ("HOSPITAL_ADMISSION//EU OBSERVATION", "Emergency observation admission"), + ( + "HOSPITAL_ADMISSION//SOMETHING NEW//CLINIC", + "Something new admission, from clinic", + ), + ("HOSPITAL_ADMISSION", "Admission"), + ( + "HOSPITAL_ADMISSION//URGENT//TRANSFER FROM HOSPITAL", + "Urgent admission, transferred from hospital", + ), + ("HOSPITAL_ADMISSION//ELECTIVE//PACU", "Elective admission, from PACU"), + ( + "HOSPITAL_ADMISSION//URGENT//TRANSFER FROM SKILLED NURSING FACILITY", + "Urgent admission, transferred from skilled nursing facility", + ), + ], +) +def test_admission_labels_are_plain(code: str, expected: str) -> None: + assert admission_label(code) == expected + + +def test_loinc_names_are_cut_to_the_analyte_and_samples_are_marked() -> None: + book = Codebook( + { + "LAB//RESULT//51221//%": "Hematocrit [Volume Fraction] of Blood by Automated count", + "LAB//RESULT//51146//%": "Basophils/100 leukocytes in Blood by Automated count", + "LAB//SPECIMEN_COLLECTED//50912//mg/dL": "Creatinine [Mass/volume] in Serum or Plasma", + "LAB//220045//bpm": "Heart Rate in Bed", # vitals are never cut + } + ) + assert book.label("LAB//RESULT//51221//%") == "Hematocrit" + assert book.label("LAB//RESULT//51146//%") == "Basophils/100 leukocytes" + assert ( + book.label("LAB//SPECIMEN_COLLECTED//50912//mg/dL") + == "Creatinine (sample sent)" + ) + assert book.label("LAB//220045//bpm") == "Heart Rate in Bed" + + +def test_unknown_and_weight_tokens_are_named_for_a_clinician() -> None: + book = Codebook() + assert ( + book.token_label("[UNK]") + == "An uncommon event (outside the model's vocabulary)" + ) + assert ( + book.token_label("SUBJECT_WEIGHT_AT_INFUSION//KG::Q2") + == "Weight at infusion (2nd fifth)" + ) diff --git a/tests/apps/clinician_demo/test_config_and_schemas.py b/tests/apps/clinician_demo/test_config_and_schemas.py new file mode 100644 index 00000000..979c5a39 --- /dev/null +++ b/tests/apps/clinician_demo/test_config_and_schemas.py @@ -0,0 +1,110 @@ +"""DemoConfig validation and the JSON contract serializer.""" + +import json +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pytest +import torch + +from apps.clinician_demo.config import DemoConfig +from apps.clinician_demo.schemas import ( + FLOAT_DIGITS, + NextEvent, + TimelineEntry, + VisitSummary, + to_jsonable, +) + + +def _config(**overrides: object) -> DemoConfig: + return DemoConfig(run_dir=Path("/runs/r"), data_dir=Path("/data"), **overrides) # type: ignore[arg-type] + + +def test_defaults_are_safe_and_cache_lives_under_the_run() -> None: + config = _config() + assert config.host == "127.0.0.1" and config.data_mode == "credentialed" + assert config.resolved_cache_dir == Path("/runs/r/demo_cache") + assert config.run_name == "r" + assert _config(cache_dir=Path("/tmp/c")).resolved_cache_dir == Path("/tmp/c") + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("host", "0.0.0.0", "loopback"), + ("host", "10.0.0.5", "loopback"), + ("data_mode", "public", "data_mode"), + ("alert_rate", 0.0, "alert_rate"), + ("alert_rate", 1.0, "alert_rate"), + ("port", 70000, "port"), + ("port", -1, "port"), + ("max_shards", 0, "max_shards"), + ("horizons", (), "horizons"), + ("horizons", (24.0, -8.0), "horizons"), + ], +) +def test_unsafe_or_invalid_fields_are_refused( + field: str, value: object, message: str +) -> None: + with pytest.raises(ValueError, match=message): + _config(**{field: value}) + + +@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"]) +def test_every_loopback_host_is_accepted(host: str) -> None: + assert _config(host=host).host == host + + +def test_floats_are_rounded_and_non_finite_become_null() -> None: + payload = {"a": 1 / 3, "b": math.nan, "c": math.inf, "d": -math.inf, "e": 2} + out = to_jsonable(payload) + assert out == { + "a": round(1 / 3, FLOAT_DIGITS), + "b": None, + "c": None, + "d": None, + "e": 2, + } + json.dumps(out, allow_nan=False) # must be strict-JSON clean + + +def test_dataclasses_nest_and_tuples_become_lists() -> None: + visit = VisitSummary(1, 0.0, 48.123456, "Admission", 10) + out = to_jsonable( + {"visit": visit, "pair": (1, 2.0), "entries": [TimelineEntry(1.0, "lab", "Na")]} + ) + assert out["visit"]["end_hours"] == 48.12346 + assert out["pair"] == [1, 2.0] + assert out["entries"][0] == { + "t": 1.0, + "category": "lab", + "label": "Na", + "value": None, + "flag": None, + } + + +def test_numpy_and_torch_scalars_unwrap_and_bools_stay_bools() -> None: + out = to_jsonable( + [np.float32(0.5), np.int64(3), torch.tensor(0.25), True, np.bool_(False)] + ) + assert out == [0.5, 3, 0.25, True, False] + assert isinstance(out[1], int) + + +def test_unserializable_values_raise_instead_of_stringifying() -> None: + with pytest.raises(TypeError, match="cannot serialize"): + to_jsonable({"x": object()}) + with pytest.raises(TypeError, match="class"): + to_jsonable(NextEvent) + + +def test_dict_keys_become_strings() -> None: + @dataclass(frozen=True) + class Holder: + by_id: dict[int, float] + + assert to_jsonable(Holder({7: 0.1})) == {"by_id": {"7": 0.1}} diff --git a/tests/apps/clinician_demo/test_evidence.py b/tests/apps/clinician_demo/test_evidence.py new file mode 100644 index 00000000..d72b196b --- /dev/null +++ b/tests/apps/clinician_demo/test_evidence.py @@ -0,0 +1,150 @@ +"""Evidence: candidate choice and the background job runner.""" + +import threading +import time +from datetime import datetime, timedelta + +import polars as pl +import pytest + +from apps.clinician_demo.evidence import ( + TOP_ITEMS, + EvidenceRunner, + cached_or_submit, + candidate_codes, + disambiguate, +) +from apps.clinician_demo.schemas import EvidenceItem, EvidenceJob + + +T = datetime(2150, 1, 1, 12, 0) + + +def _wait(runner: EvidenceRunner, job_id: str, timeout: float = 5.0) -> EvidenceJob: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + job = runner.get(job_id) + if job.status in ("done", "error"): + return job + time.sleep(0.01) + raise AssertionError(f"job {job_id} did not finish") + + +def _item(code: str, delta: float) -> EvidenceItem: + return EvidenceItem(code, code, 1, 0.5, 0.5 + delta, delta) + + +def test_candidates_are_the_most_frequent_codes_inside_the_window() -> None: + events = pl.DataFrame( + { + "time": [T - timedelta(hours=h) for h in (0, 0, 1, 2, 2, 2, 5, 30)] + + [None], + "code": ["B", "A", "A", "C", "C", "C", "D", "OLD", "GENDER//F"], + } + ) + assert candidate_codes(events, index_time=T, lookback_hours=24.0) == [ + "C", + "A", + "B", + "D", + ] + assert candidate_codes(events, index_time=T, lookback_hours=24.0, limit=2) == [ + "C", + "A", + ] + # the window is (T - lookback, T]: a row exactly lookback hours back is out + assert candidate_codes(events, index_time=T, lookback_hours=5.0) == ["C", "A", "B"] + assert ( + candidate_codes(events, index_time=T - timedelta(days=5), lookback_hours=1.0) + == [] + ) + + +def test_a_job_runs_under_the_gpu_lock_reports_progress_and_keeps_the_top_items() -> ( + None +): + lock = threading.Lock() + runner = EvidenceRunner(lock) + seen_locked: list[bool] = [] + + def work(progress): # type: ignore[no-untyped-def] + seen_locked.append(lock.locked()) + for i in range(1, 4): + progress(i, 3) + return [_item(f"C{i}", -i / 100) for i in range(TOP_ITEMS + 5)] + + job = runner.submit("target", "note", work) + assert job.status == "pending" and job.target == "target" and job.note == "note" + done = _wait(runner, job.job_id) + assert done.status == "done" and (done.done, done.total) == (3, 3) + assert len(done.result) == TOP_ITEMS + assert seen_locked == [True] + runner.shutdown() + + +def test_a_failing_job_reports_the_error_and_the_runner_keeps_going() -> None: + runner = EvidenceRunner(threading.Lock()) + + def boom(progress): # type: ignore[no-untyped-def] + raise RuntimeError("out of memory") + + failed = _wait(runner, runner.submit("t", "n", boom).job_id) + assert failed.status == "error" and failed.error == "RuntimeError: out of memory" + ok = _wait(runner, runner.submit("t", "n", lambda p: []).job_id) + assert ok.status == "done" and ok.result == [] + runner.shutdown() + + +def test_unknown_jobs_raise_and_old_finished_jobs_are_evicted() -> None: + runner = EvidenceRunner(threading.Lock(), max_jobs=2) + with pytest.raises(KeyError): + runner.get("ev999") + ids = [runner.submit("t", "n", lambda p: []).job_id for _ in range(4)] + _wait(runner, ids[-1]) + runner.submit("t", "n", lambda p: []) # triggers eviction of finished jobs + with pytest.raises(KeyError): + runner.get(ids[0]) + runner.shutdown() + + +def test_cached_or_submit_reuses_live_jobs_and_retries_failed_or_evicted_ones() -> None: + runner = EvidenceRunner(threading.Lock(), max_jobs=1) + cache: dict[tuple[object, ...], str] = {} + calls: list[int] = [] + + def submit_ok() -> EvidenceJob: + calls.append(1) + return runner.submit("t", "n", lambda p: []) + + first = cached_or_submit(cache, ("k",), runner, submit_ok) + _wait(runner, first.job_id) + again = cached_or_submit(cache, ("k",), runner, submit_ok) + assert again.job_id == first.job_id and len(calls) == 1 + + def submit_fail() -> EvidenceJob: + calls.append(1) + return runner.submit("t", "n", lambda p: (_ for _ in ()).throw(ValueError("x"))) + + failed = cached_or_submit(cache, ("f",), runner, submit_fail) + _wait(runner, failed.job_id) + retried = cached_or_submit(cache, ("f",), runner, submit_ok) + assert retried.job_id != failed.job_id + + cache[("gone",)] = "ev-evicted" + fresh = cached_or_submit(cache, ("gone",), runner, submit_ok) + assert fresh.job_id != "ev-evicted" and cache[("gone",)] == fresh.job_id + runner.shutdown() + + +def test_repeated_labels_get_their_item_number_and_unique_ones_stay() -> None: + items = [ + EvidenceItem("INFUSION_START//220949", "Dextrose 5%", 7, 0.3, 0.29, -0.01), + EvidenceItem("INFUSION_START//225823", "Dextrose 5%", 9, 0.3, 0.31, 0.01), + EvidenceItem("LAB//220045//bpm", "Heart Rate", 27, 0.3, 0.27, -0.03), + ] + assert [i.label for i in disambiguate(items)] == [ + "Dextrose 5% (220949)", + "Dextrose 5% (225823)", + "Heart Rate", + ] + assert disambiguate([]) == [] diff --git a/tests/apps/clinician_demo/test_forecast.py b/tests/apps/clinician_demo/test_forecast.py new file mode 100644 index 00000000..c6a70789 --- /dev/null +++ b/tests/apps/clinician_demo/test_forecast.py @@ -0,0 +1,357 @@ +"""Forecast: the model trace and the pure helpers that shape the replay.""" + +from datetime import timedelta + +import pytest + +from apps.clinician_demo.codebook import Codebook +from apps.clinician_demo.forecast import ( + RunContext, + alert_detail, + alert_episode_start, + bundle_ends, + callout_text, + displayed_alerts, + onsets_for, + trace_patient, + visit_stats_from_trace, + visit_view, +) +from apps.clinician_demo.patient_store import PatientStore +from apps.clinician_demo.schemas import OperatingPoint +from odyssey.data.alert_events import alert_events_for +from odyssey.inference.counterfactual import score_record_at +from odyssey.models.backbones.tiny_gru import TinyGRUBackbone +from odyssey.models.sequence_model import ConceptBottleneckSequenceModel +from tests.apps.clinician_demo.conftest import CHUNK, HEAD_NAMES, ICU, T0 + + +# -- pure helpers -------------------------------------------------------------- + + +def test_callout_text_covers_every_outcome() -> None: + assert callout_text("ICU admission", cross=4.0, alert_start=6.0, onset=18.0) == ( + "ICU admission began at hour 18. The alert had been on since hour 6: " + "12 h of warning." + ) + assert callout_text("ICU admission", cross=17.6, alert_start=17.6, onset=18.0) == ( + "ICU admission began at hour 18. The alert came on just before it." + ) + assert "but was off again by then" in callout_text( + "ICU admission", cross=4.0, alert_start=None, onset=18.0 + ) + assert callout_text("Death", cross=None, alert_start=None, onset=5.0).endswith( + "never reached the alert line: a miss." + ) + assert "a false alarm" in callout_text( + "Death", cross=5.0, alert_start=None, onset=None + ) + assert callout_text("Death", cross=None, alert_start=None, onset=None) == ( + "No Death during this stay, and the risk stayed below the alert line." + ) + + +def test_alert_detail_states_the_line_and_its_measured_quality() -> None: + point = OperatingPoint("death", 24.0, 0.013, 0.05, 0.78, 0.08, 0.004, 100, 0.8, 0.1) + detail = alert_detail("Death", point) + assert detail.startswith("Alert line: 1.3% risk within 24 h.") + assert "on for 5% of moments" in detail and "covers 78%" in detail + assert "8% of the moments it is on are followed by Death" in detail + assert alert_detail("Death", None) == "" + undefined = OperatingPoint("e", 24.0, 0.2, 0.05, None, None, 0.0, 10, None, None) + assert "covers n/a" in alert_detail("X", undefined) + + +@pytest.mark.parametrize( + ("values", "end", "expected"), + [ + ([0.1, 0.5, 0.6, 0.7], None, 1.0), # on from t=1 to the end + ([0.5, 0.1, 0.6, 0.7], None, 2.0), # an earlier episode does not count + ([0.5, 0.6, 0.1], None, None), # off at the end: no live alert + ([0.1, 0.5, 0.6, 0.1], 3.0, 1.0), # only moments before `end` count + ([0.5, None, 0.6], None, 0.0), # masked moments are skipped, not "off" + ([0.2, 0.2], None, 0.0), # equality counts as on + ([], None, None), + ([0.9, 0.9], 0.0, None), # nothing before `end` + ], +) +def test_alert_episode_start( + values: list[float | None], end: float | None, expected: float | None +) -> None: + times = [float(i) for i in range(len(values))] + assert alert_episode_start(times, values, 0.2, end) == expected + + +def test_displayed_alerts_drop_readmission_and_follow_the_display_order() -> None: + alerts, index = displayed_alerts( + alert_events_for("v3", source="mimic_iv"), + HEAD_NAMES, + ("death", "icu_admission"), + ) + names = [a.name for a in alerts] + assert "readmission_30d" not in names + assert names[:2] == ["death", "icu_admission"] + assert [HEAD_NAMES[i] for i in index] == names + only, _ = displayed_alerts(alert_events_for("v3", source="mimic_iv"), ["death"], ()) + assert [a.name for a in only] == ["death"] + + +# -- the model trace ----------------------------------------------------------- + + +def test_trace_rows_align_with_the_record_and_hold_every_output( + ctx: RunContext, store: PatientStore +) -> None: + trace = trace_patient(ctx, store.raw_events(1)) + n = trace.n_positions + assert trace.risk.shape == (n, len(ctx.events), 3) + assert trace.concepts.shape == (n, len(ctx.concept_names)) + assert trace.top_ids.shape == (n, 5) and trace.top_probs.shape == (n, 5) + assert ( + len(trace.tokens) + == len(trace.times) + == len(trace.timestamps) + == len(trace.visit_ids) + == n + ) + assert trace.n_static == 1 and trace.tokens[0] == "GENDER//M" + assert trace.n_unknown == 0 + assert (trace.risk >= 0).all() and (trace.risk <= 1).all() + # cumulative risk: 8 h <= 24 h <= 72 h at every position + assert (trace.risk[..., 0] <= trace.risk[..., 1] + 1e-6).all() + assert (trace.risk[..., 1] <= trace.risk[..., 2] + 1e-6).all() + assert ( + trace.values[ + trace.tokens.index( + next(t for t in trace.tokens if t.startswith("LAB//220179")) + ) + ] + == 123.5 + ) + + +def test_trace_at_a_bundle_end_equals_score_record_at( + ctx: RunContext, store: PatientStore +) -> None: + """The replay and what-if must read the same forecast for the same moment.""" + raw = store.raw_events(1) + trace = trace_patient(ctx, raw) + for pos in [bundle_ends(trace.times)[k] for k in (3, 17, 40)]: + readout = score_record_at( + ctx.model, + ctx.vocab, + ctx.binner, + raw, + index_time=trace.timestamps[pos], + concept_names=ctx.concept_names, + chunk_size=CHUNK, + ) + assert readout.position == pos + for j, event in enumerate(ctx.events): + for h, key in enumerate(("8h", "24h", "72h")): + assert trace.risk[pos, j, h] == pytest.approx( + readout.event_risk[event][key], abs=1e-5 + ) + for c, name in enumerate(ctx.concept_names): + assert trace.concepts[pos, c] == pytest.approx( + readout.concept_probs[name], abs=1e-5 + ) + + +def test_unknown_tokens_are_counted(ctx: RunContext, store: PatientStore) -> None: + raw = store.raw_events(2) + shrunk = ctx.vocab.__class__({"[PAD]": 0, "[UNK]": 1}) + small_ctx = RunContext(**{**ctx.__dict__, "vocab": shrunk}) + trace = trace_patient(small_ctx, raw) + assert trace.n_unknown == trace.n_positions + + +def test_trace_refuses_models_and_records_it_cannot_show( + ctx: RunContext, store: PatientStore +) -> None: + no_heads = ConceptBottleneckSequenceModel( + TinyGRUBackbone(vocab_size=len(ctx.vocab), hidden_size=8), + vocab_size=len(ctx.vocab), + num_concepts=29, + embedding_dim=4, + ) + with pytest.raises(ValueError, match="hazard heads"): + trace_patient( + RunContext(**{**ctx.__dict__, "model": no_heads}), store.raw_events(1) + ) + static_only = store.raw_events(1).filter(store.raw_events(1)["time"].is_null()) + with pytest.raises(ValueError, match="no timed events"): + trace_patient(ctx, static_only) + + +# -- onsets and the visit view ------------------------------------------------- + + +def test_onsets_are_visit_scoped_except_death( + ctx: RunContext, store: PatientStore +) -> None: + raw = store.raw_events(1) + first = onsets_for( + raw, ctx.alerts, source="mimic_iv", task_set="v3", subject_id=1, visit_id=10 + ) + second = onsets_for( + raw, ctx.alerts, source="mimic_iv", task_set="v3", subject_id=1, visit_id=11 + ) + assert first["icu_admission"] == pytest.approx(20.0) + assert second["icu_admission"] is None + death_hours = 30 * 24 + 10.0 + assert first["death"] == pytest.approx(death_hours) and second[ + "death" + ] == pytest.approx(death_hours) + assert set(first) == set(ctx.events) + + +def _view( + ctx: RunContext, + store: PatientStore, + visit_id: int, + threshold: float, + **kwargs: object, +): # type: ignore[no-untyped-def] + raw = store.raw_events(1) + trace = trace_patient(ctx, raw) + visit = next(v for v in store.visits(1) if v.visit_id == visit_id) + onsets = onsets_for( + raw, + ctx.alerts, + source="mimic_iv", + task_set="v3", + subject_id=1, + visit_id=visit_id, + ) + points = { + e: OperatingPoint(e, 24.0, threshold, 0.05, 0.5, 0.2, 0.01, 10, None, None) + for e in ctx.events + } + return visit_view( + trace, + visit, + events=ctx.events, + horizons=ctx.horizons, + onsets=onsets, + points=points, + codebook=Codebook(), + decode=ctx.vocab.decode, + display={e: e for e in ctx.events}, + seen_in_training=True, + **kwargs, # type: ignore[arg-type] + ) + + +def test_visit_view_masks_after_onset_and_reports_the_crossing( + ctx: RunContext, store: PatientStore +) -> None: + view = _view(ctx, store, 10, threshold=0.0) + assert view.times[0] == pytest.approx(0.0) and view.times == sorted(view.times) + icu = view.risk["icu_admission"]["24h"] + assert all(v is None for v, t in zip(icu, view.times) if t >= 20.0) + assert all(v is not None for v, t in zip(icu, view.times) if t < 20.0) + assert view.onsets["icu_admission"] == pytest.approx(20.0) + assert view.onsets["death"] is None # died in the NEXT admission + alert = next(a for a in view.alerts if a.event == "icu_admission") + assert alert.first_cross_hours == pytest.approx(0.0) # threshold 0: crosses at once + assert ( + alert.lead_hours == pytest.approx(20.0) and "20 h of warning" in alert.callout + ) + assert [m.label for m in view.markers if m.category == "care"][:2] == [ + "Emergency admission, from emergency room", + "ICU admission: Medical Intensive Care Unit (MICU)", + ] + assert all(e.category != "demographic" for e in view.timeline) + assert len(view.concepts) == len(view.times) and len(view.concepts[0]) == len( + ctx.concept_names + ) + assert all(len(nxt) == 5 for nxt in view.top_next) + assert view.seen_in_training + + +def test_a_line_never_reached_is_reported_as_a_miss( + ctx: RunContext, store: PatientStore +) -> None: + view = _view(ctx, store, 10, threshold=2.0) + alert = next(a for a in view.alerts if a.event == "icu_admission") + assert alert.first_cross_hours is None and alert.lead_hours is None + assert alert.callout.endswith("never reached the alert line: a miss.") + assert alert.alert_start_hours is None and "Alert line:" in alert.detail + + +def test_visit_view_respects_the_point_cap_but_keeps_the_onset( + ctx: RunContext, store: PatientStore +) -> None: + view = _view(ctx, store, 10, threshold=2.0, max_points=5) + assert len(view.times) <= 12 # 5 bins plus kept onset/marker moments + assert any(19.0 <= t < 20.0 for t in view.times), ( + "the moment just before ICU must survive" + ) + + +def test_visit_stats_from_trace_marks_positives_and_skips_unthresholded_events( + ctx: RunContext, store: PatientStore +) -> None: + raw = store.raw_events(1) + trace = trace_patient(ctx, raw) + visits = store.visits(1) + onsets = { + v.visit_id: onsets_for( + raw, + ctx.alerts, + source="mimic_iv", + task_set="v3", + subject_id=1, + visit_id=v.visit_id, + ) + for v in visits + } + stats = visit_stats_from_trace( + trace, + visits, + onsets, + events=ctx.events, + horizons=ctx.horizons, + thresholds={"icu_admission": 0.0, "death": 2.0}, + ) + assert set(stats["event"].to_list()) == {"icu_admission", "death"} + icu10 = stats.filter( + (stats["visit_id"] == 10) & (stats["event"] == "icu_admission") + ).row(0, named=True) + assert icu10["positive"] and icu10["end_hours"] == pytest.approx(20.0) + assert icu10["first_cross_hours"] == pytest.approx(icu10["start_hours"]) + death11 = stats.filter((stats["visit_id"] == 11) & (stats["event"] == "death")).row( + 0, named=True + ) + assert ( + death11["positive"] and death11["first_cross_hours"] is None + ) # threshold 2.0: a miss + empty = visit_stats_from_trace( + trace, [], {}, events=ctx.events, horizons=ctx.horizons, thresholds={} + ) + assert empty.height == 0 and empty.columns == stats.columns + + +def test_icu_at_the_first_moment_leaves_nothing_at_risk( + ctx: RunContext, store: PatientStore +) -> None: + raw = store.raw_events(1) + trace = trace_patient(ctx, raw) + visit = next(v for v in store.visits(1) if v.visit_id == 10) + first_t = trace.times[trace.n_static] + stats = visit_stats_from_trace( + trace, + [visit], + {10: {"icu_admission": first_t}}, + events=ctx.events, + horizons=ctx.horizons, + thresholds={"icu_admission": 0.5}, + ) + assert stats.height == 0 + assert ( + T0 + timedelta(hours=20) + in store.raw_events(1) + .filter(store.raw_events(1)["code"] == ICU)["time"] + .to_list() + ) diff --git a/tests/apps/clinician_demo/test_main_and_layering.py b/tests/apps/clinician_demo/test_main_and_layering.py new file mode 100644 index 00000000..6a11d91e --- /dev/null +++ b/tests/apps/clinician_demo/test_main_and_layering.py @@ -0,0 +1,129 @@ +"""The CLI, and the architecture rules the demo must keep.""" + +import ast +import subprocess +import sys +from pathlib import Path + +import pytest + +from apps.clinician_demo.__main__ import parse_args + + +REPO = Path(__file__).resolve().parents[3] +APP = REPO / "apps" / "clinician_demo" +BANNED_IN_APP = { + "odyssey.inference.steering", # a mixture model cannot be steered; would overclaim + "odyssey.inference.interventions", # label overrides have the wrong sign on v10 + "odyssey.inference.rollouts", # untested on the hybrid backbone +} +TORCH_FREE = [ + "config", + "schemas", + "server", + "codebook", + "thresholds", + "showcase", + "scorecard", +] + + +def test_defaults_and_path_expansion(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOME", "/home/me") + config, self_check = parse_args(["--run-dir", "~/runs/r", "--data-dir", "~/d"]) + assert config.run_dir == Path("/home/me/runs/r") and config.data_dir == Path( + "/home/me/d" + ) + assert (config.data_mode, config.port, config.alert_rate, config.checkpoint) == ( + "credentialed", + 8765, + 0.05, + "checkpoint_best.pt", + ) + assert config.metadata_dir is None and config.splits_path is None and config.warmup + assert not self_check + + +def test_every_flag_reaches_the_config() -> None: + config, self_check = parse_args( + [ + "--run-dir", "/r", "--data-dir", "/d", "--metadata-dir", "/m", "--splits", "/s.parquet", + "--data-mode", "open", "--checkpoint", "checkpoint_final.pt", "--port", "9000", + "--alert-rate", "0.1", "--max-shards", "2", "--cache-dir", "/c", "--device", "cpu", + "--no-warmup", "--self-check", + ] + ) # fmt: skip + assert (config.metadata_dir, config.splits_path, config.cache_dir) == ( + Path("/m"), + Path("/s.parquet"), + Path("/c"), + ) + assert (config.data_mode, config.checkpoint, config.port, config.alert_rate) == ( + "open", + "checkpoint_final.pt", + 9000, + 0.1, + ) + assert (config.max_shards, config.device, config.warmup, self_check) == ( + 2, + "cpu", + False, + True, + ) + + +@pytest.mark.parametrize( + "argv", + [ + ["--data-dir", "/d"], + ["--run-dir", "/r", "--data-dir", "/d", "--data-mode", "public"], + ["--run-dir", "/r", "--data-dir", "/d", "--alert-rate", "1.5"], + ["--run-dir", "/r", "--data-dir", "/d", "--max-shards", "0"], + ["--run-dir", "/r", "--data-dir", "/d", "--port", "99999"], + ], +) +def test_bad_arguments_exit_before_loading_anything(argv: list[str]) -> None: + with pytest.raises(SystemExit): + parse_args(argv) + + +def _imports(path: Path) -> set[str]: + tree = ast.parse(path.read_text()) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found |= {alias.name for alias in node.names} + elif isinstance(node, ast.ImportFrom) and node.module: + found.add(node.module) + return found + + +def test_the_research_package_never_imports_the_app() -> None: + offenders = [ + str(p.relative_to(REPO)) + for p in (REPO / "odyssey").rglob("*.py") + if any(m == "apps" or m.startswith("apps.") for m in _imports(p)) + ] + assert offenders == [] + + +def test_the_app_never_imports_what_it_must_not_show() -> None: + offenders = { + str(p.relative_to(REPO)): sorted(_imports(p) & BANNED_IN_APP) + for p in APP.rglob("*.py") + if _imports(p) & BANNED_IN_APP + } + assert offenders == {} + + +@pytest.mark.parametrize("module", TORCH_FREE) +def test_light_modules_import_without_torch(module: str) -> None: + code = f"import sys, apps.clinician_demo.{module}; print('torch' in sys.modules)" + out = subprocess.run( + [sys.executable, "-c", code], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ) + assert out.stdout.strip() == "False", f"{module} pulls in torch" diff --git a/tests/apps/clinician_demo/test_patient_store.py b/tests/apps/clinician_demo/test_patient_store.py new file mode 100644 index 00000000..0c30a181 --- /dev/null +++ b/tests/apps/clinician_demo/test_patient_store.py @@ -0,0 +1,190 @@ +"""PatientStore: shard index, lazy per-subject loads, visits and header facts.""" + +from datetime import datetime, timedelta +from pathlib import Path + +import polars as pl +import pytest + +from apps.clinician_demo.patient_store import ( + PatientStore, + UnknownPatientError, + build_shard_index, + discover_shards, + load_splits, +) + + +T0 = datetime(2150, 3, 1, 8, 0) +SCHEMA = { + "subject_id": pl.Int64, + "time": pl.Datetime("us"), + "code": pl.Utf8, + "numeric_value": pl.Float32, + "hadm_id": pl.Int64, +} + + +def _subject(sid: int, *, visits: int = 2) -> list[tuple[object, ...]]: + rows: list[tuple[object, ...]] = [ + (sid, None, "GENDER//F", None, None), + (sid, T0 - timedelta(days=365.25 * 70), "MEDS_BIRTH", None, None), + ] + for v in range(visits): + start = T0 + timedelta(days=30 * v) + hadm = sid * 10 + v + rows += [ + ( + sid, + start, + "HOSPITAL_ADMISSION//URGENT//TRANSFER FROM HOSPITAL", + None, + hadm, + ), + (sid, start + timedelta(hours=1), "LAB//220045//bpm", 88.0, hadm), + ( + sid, + start + timedelta(hours=2), + "MEDICATION//Norepinephrine 4mg//Administered", + None, + hadm, + ), + (sid, start + timedelta(hours=30), "HOSPITAL_DISCHARGE//HOME", None, hadm), + ] + return rows + + +def _write(tmp_path: Path) -> Path: + data = tmp_path / "data" + (data / "held_out").mkdir(parents=True) + (data / "train").mkdir() + (data / ".meds_extract_run").mkdir() + pl.DataFrame( + _subject(1) + _subject(2, visits=1), schema=SCHEMA, orient="row" + ).write_parquet(data / "held_out" / "0.parquet") + pl.DataFrame(_subject(3), schema=SCHEMA, orient="row").write_parquet( + data / "held_out" / "10.parquet" + ) + pl.DataFrame(_subject(4), schema=SCHEMA, orient="row").write_parquet( + data / "train" / "2.parquet" + ) + # extractor working state must never be mistaken for a shard + pl.DataFrame(_subject(9), schema=SCHEMA, orient="row").write_parquet( + data / ".meds_extract_run" / "0.parquet" + ) + return data + + +def _store(data: Path, **kwargs: object) -> PatientStore: + return PatientStore( + build_shard_index(discover_shards(data)), + source="mimic_iv", + normalize_medications=True, + **kwargs, # type: ignore[arg-type] + ) + + +def test_discovery_is_recursive_ordered_and_skips_hidden_dirs(tmp_path: Path) -> None: + data = _write(tmp_path) + shards = discover_shards(data) + assert [p.relative_to(data).as_posix() for p in shards] == [ + "held_out/0.parquet", + "held_out/10.parquet", + "train/2.parquet", + ] + assert len(discover_shards(data, max_shards=1)) == 1 + with pytest.raises(FileNotFoundError): + discover_shards(tmp_path / "empty_does_not_exist") + + +def test_index_maps_every_subject_to_its_shard_and_rejects_duplicates( + tmp_path: Path, +) -> None: + data = _write(tmp_path) + index = build_shard_index(discover_shards(data)) + assert {sid: p.name for sid, p in index.items()} == { + 1: "0.parquet", + 2: "0.parquet", + 3: "10.parquet", + 4: "2.parquet", + } + dup = data / "train" / "3.parquet" + pl.DataFrame(_subject(1), schema=SCHEMA, orient="row").write_parquet(dup) + with pytest.raises(ValueError, match="subject 1 is in both"): + build_shard_index(discover_shards(data)) + + +def test_raw_events_load_one_subject_normalized_and_cached(tmp_path: Path) -> None: + store = _store(_write(tmp_path), cache_size=1) + events = store.raw_events(1) + assert set(events["subject_id"].to_list()) == {1} + meds = events.filter(pl.col("code").str.starts_with("MEDICATION"))["code"].to_list() + assert meds and all(c.startswith("MEDICATION//norepinephrine") for c in meds), meds + assert store.raw_events(1) is events # cached + store.raw_events(2) # evicts subject 1 (cache_size=1) + assert store.raw_events(1) is not events + assert 1 in store and 99 not in store and len(store) == 4 + assert store.subject_ids == [1, 2, 3, 4] + with pytest.raises(UnknownPatientError): + store.raw_events(99) + + +def test_cache_size_must_be_positive(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="cache_size"): + _store(_write(tmp_path), cache_size=0) + + +def test_visits_are_time_ordered_with_readable_admission_and_counts( + tmp_path: Path, +) -> None: + store = _store(_write(tmp_path), describe=lambda code: code.split("//")[1].title()) + visits = store.visits(1) + assert [v.visit_id for v in visits] == [10, 11] + assert visits[0].admission == "Urgent" + assert visits[0].n_events == 4 + assert visits[0].start_hours == pytest.approx(0.0) + assert visits[0].end_hours == pytest.approx(30.0) + assert visits[1].start_hours == pytest.approx(30 * 24.0) + + +def test_visit_without_an_admission_code_gets_a_generic_label(tmp_path: Path) -> None: + data = tmp_path / "d" + data.mkdir() + rows = [ + (5, T0, "LAB//220045//bpm", 90.0, 50), + (5, T0 + timedelta(hours=3), "LAB//220045//bpm", 95.0, 50), + ] + pl.DataFrame(rows, schema=SCHEMA, orient="row").write_parquet(data / "0.parquet") + store = _store(data) + (visit,) = store.visits(5) + assert visit.admission == "Admission" and visit.end_hours == pytest.approx(3.0) + + +def test_summary_reports_sex_age_split_and_training_flag(tmp_path: Path) -> None: + splits = tmp_path / "splits.parquet" + pl.DataFrame( + {"subject_id": [1, 2, 3], "split": ["train", "tuning", "held_out"]} + ).write_parquet(splits) + store = _store(_write(tmp_path), splits=load_splits(splits)) + s1 = store.summary(1) + assert s1.sex == "F" and s1.age_years == pytest.approx(70.0, abs=0.01) + assert s1.split == "train" and s1.seen_in_training + assert store.summary(2).seen_in_training # tuning counts as seen + assert not store.summary(3).seen_in_training + s4 = store.summary(4) + assert s4.split is None and not s4.seen_in_training + + +def test_summary_without_birth_or_sex_is_none(tmp_path: Path) -> None: + data = tmp_path / "d" + data.mkdir() + pl.DataFrame( + [(6, T0, "LAB//220045//bpm", 90.0, 60)], schema=SCHEMA, orient="row" + ).write_parquet(data / "0.parquet") + summary = _store(data).summary(6) + assert summary.sex is None and summary.age_years is None + + +def test_load_splits_handles_missing_files(tmp_path: Path) -> None: + assert load_splits(None) == {} + assert load_splits(tmp_path / "nope.parquet") == {} diff --git a/tests/apps/clinician_demo/test_scorecard.py b/tests/apps/clinician_demo/test_scorecard.py new file mode 100644 index 00000000..b9b3f9fe --- /dev/null +++ b/tests/apps/clinician_demo/test_scorecard.py @@ -0,0 +1,170 @@ +"""Scorecard: cells, intervals, computed headline and missing-file handling.""" + +import json +from pathlib import Path + +import pytest + +from apps.clinician_demo.schemas import ConceptInfo, ScoreCell +from apps.clinician_demo.scorecard import ( + build_scorecard, + concept_readouts, + headline, + read_json, + score_cells, +) + + +def _alert( + event: str, h: float, scorer: str, auroc: float | None, **extra: object +) -> dict[str, object]: + row: dict[str, object] = { + "event": event, + "horizon_hours": h, + "scorer": scorer, + "auroc": auroc, + "n_at_risk": 1000, + "n_positive": 25, + "calibration": [ + {"predicted": 0.01, "observed": 0.02, "n": 100}, + {"predicted": None, "observed": 0.1, "n": 5}, + ], + } + row.update(extra) + return row + + +ALERTS = [ + _alert("death", 24.0, "hazard", 0.95), + _alert("death", 24.0, "baseline_gbm", 0.93), + _alert("death", 24.0, "concept", 0.6), + _alert("acute_kidney_injury", 24.0, "hazard", 0.82), + _alert("acute_kidney_injury", 24.0, "baseline_gbm", 0.88), + _alert("acute_kidney_injury", 8.0, "hazard", 0.87), + _alert("readmission_30d", 24.0, "hazard", 0.58), +] +CIS = { + "cells": { + "death@24h": { + "scorers": { + "hazard": {"auroc": {"point": 0.95, "ci_low": 0.94, "ci_high": 0.96}}, + "gbm": {"auroc": {"point": 0.93, "ci_low": 0.92, "ci_high": 0.94}}, + }, + "paired_deltas": { + "hazard_minus_gbm": { + "auroc": { + "point": 0.02, + "ci_low": 0.01, + "ci_high": 0.03, + "separated": True, + } + } + }, + } + } +} + + +def test_cells_pair_the_model_with_the_gbm_and_skip_other_scorers() -> None: + cells = score_cells(ALERTS, CIS, ["death", "acute_kidney_injury"], [8.0, 24.0]) + assert [(c.event, c.horizon_hours) for c in cells] == [ + ("death", 24.0), + ("acute_kidney_injury", 8.0), + ("acute_kidney_injury", 24.0), + ] + death = cells[0] + assert (death.hazard_auroc, death.gbm_auroc) == (0.95, 0.93) + assert death.hazard_ci == [0.94, 0.96] and death.gbm_ci == [0.92, 0.94] + assert ( + death.delta == 0.02 + and death.delta_ci == [0.01, 0.03] + and death.separated is True + ) + assert death.base_rate == pytest.approx(0.025) + assert len(death.calibration) == 1 # the bin with a null prediction is dropped + + +def test_without_intervals_the_delta_is_the_point_difference_and_unseparated_unknown() -> ( + None +): + aki = score_cells(ALERTS, None, ["acute_kidney_injury"], [24.0])[0] + assert ( + aki.delta == pytest.approx(-0.06) + and aki.delta_ci is None + and aki.separated is None + ) + only_model = score_cells(ALERTS, None, ["acute_kidney_injury"], [8.0])[0] + assert only_model.gbm_auroc is None and only_model.delta is None + + +def test_readmission_is_only_shown_if_asked_for() -> None: + assert all( + c.event != "readmission_30d" + for c in score_cells(ALERTS, CIS, ["death"], [24.0]) + ) + + +def test_zero_at_risk_gives_a_zero_base_rate() -> None: + rows = [_alert("death", 8.0, "hazard", 0.9, n_at_risk=0, n_positive=0)] + assert score_cells(rows, None, ["death"], [8.0])[0].base_rate == 0.0 + + +def _cell(delta: float | None, separated: bool | None) -> ScoreCell: + return ScoreCell( + "e", 24.0, 1, 0, 0.0, 0.9, None, 0.9, None, delta, None, separated, [] + ) + + +def test_headline_counts_wins_and_clear_wins_each_way() -> None: + text = headline( + [_cell(-0.05, True), _cell(-0.01, False), _cell(0.02, True), _cell(None, None)] + ) + assert text == ( + "Against the tuned GBM on 3 event-horizon cells: the GBM ranks better on 2 " + "(1 clearly), this model on 1 (1 clearly)." + ) + assert headline([]) == "No GBM comparison is available for this run." + + +def test_concept_readouts_fill_aurocs_by_slot_name() -> None: + concepts = [ + ConceptInfo("shock", "sustained hypotension (MAP)", "d", None), + ConceptInfo("fever", "fever", "d", 0.5), + ] + filled = concept_readouts( + { + "concept_metrics": [ + {"name": "shock", "auroc": 0.81}, + {"name": "x", "auroc": None}, + ] + }, + concepts, + ) + assert [c.readout_auroc for c in filled] == [0.81, 0.5] + assert concept_readouts(None, concepts) == concepts + + +def test_build_scorecard_from_a_run_directory(tmp_path: Path) -> None: + (tmp_path / "alerts.json").write_text(json.dumps(ALERTS)) + (tmp_path / "alerts_cis.json").write_text(json.dumps(CIS)) + (tmp_path / "inference_results.json").write_text( + json.dumps({"concept_metrics": [{"name": "fever", "auroc": 0.9}]}) + ) + card = build_scorecard( + tmp_path, ["death"], [24.0], [ConceptInfo("fever", "fever", "", None)] + ) + assert len(card.cells) == 1 and card.concepts[0].readout_auroc == 0.9 + assert card.headline.startswith("Against the tuned GBM on 1") + assert not any("no banked" in n.lower() for n in card.notes) + + +def test_missing_or_broken_files_degrade_with_a_note(tmp_path: Path) -> None: + empty = build_scorecard(tmp_path, ["death"], [24.0], []) + assert empty.cells == [] and "no banked alert evaluation" in empty.notes[0] + (tmp_path / "alerts.json").write_text(json.dumps(ALERTS)) + (tmp_path / "alerts_cis.json").write_text("{broken") + no_ci = build_scorecard(tmp_path, ["death"], [24.0], []) + assert ( + "No bootstrap intervals" in no_ci.notes[0] and no_ci.cells[0].hazard_ci is None + ) + assert read_json(tmp_path / "alerts_cis.json") is None diff --git a/tests/apps/clinician_demo/test_server.py b/tests/apps/clinician_demo/test_server.py new file mode 100644 index 00000000..fca26181 --- /dev/null +++ b/tests/apps/clinician_demo/test_server.py @@ -0,0 +1,276 @@ +"""Server: routing, status codes and every security rule, over a real socket.""" + +import http.client +import json +import threading +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from apps.clinician_demo.server import ( + API_HEADER, + MAX_BODY_BYTES, + SECURITY_HEADERS, + allowed_hosts, + make_server, + resolve_static, +) +from apps.clinician_demo.service import BadRequestError, NotFoundError + + +@dataclass(frozen=True) +class Echo: + """A dataclass payload, to check schema serialization.""" + + value: float + + +class FakeAPI: + """Implements the DemoAPI protocol with canned answers and failures.""" + + def __init__(self) -> None: + self.bodies: list[dict[str, Any]] = [] + + def meta(self) -> object: + """Return a payload holding a NaN.""" + return {"ok": True, "nan": float("nan")} + + def gallery(self) -> object: + """Return an empty gallery.""" + return {"sections": []} + + def patient(self, subject_id: int) -> object: + """Return a patient, or fail on the magic ids 404 and 500.""" + if subject_id == 404: + raise NotFoundError("patient 404 is not in the loaded data") + if subject_id == 500: + raise RuntimeError("secret internal detail") + return {"subject_id": subject_id} + + def trace(self, subject_id: int, visit_id: int) -> object: + """Return a dataclass payload.""" + return Echo(value=visit_id + 0.123456) + + def presets(self) -> object: + """Return no presets.""" + return [] + + def whatif(self, subject_id: int, visit_id: int, body: dict[str, Any]) -> object: + """Echo the body, or fail when it asks to.""" + self.bodies.append(body) + if body.get("bad"): + raise BadRequestError("t_hours must be a number") + return {"echo": body} + + def evidence(self, subject_id: int, visit_id: int, body: dict[str, Any]) -> object: + """Return a job.""" + return {"job_id": "ev1"} + + def job(self, job_id: str) -> object: + """Echo the job id.""" + return {"job_id": job_id} + + def scorecard(self) -> object: + """Return an empty scorecard.""" + return {"cells": []} + + +@pytest.fixture +def static_root(tmp_path: Path) -> Path: + root = tmp_path / "static" + (root / "js").mkdir(parents=True) + (root / "index.html").write_text("demo") + (root / "js" / "app.js").write_text("export {};") + (root / "notes.txt").write_text("not allowlisted") + (tmp_path / "secret.json").write_text('{"secret": 1}') + (root / "escape.json").symlink_to(tmp_path / "secret.json") + return root + + +@pytest.fixture +def served(static_root: Path) -> Iterator[tuple[int, FakeAPI]]: + api = FakeAPI() + server = make_server(api, "127.0.0.1", 0, static_root=static_root) # type: ignore[arg-type] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port, api + finally: + server.shutdown() + server.server_close() + + +def _request( + port: int, + method: str, + path: str, + *, + body: bytes | None = None, + api_header: bool = True, + host: str | None = None, + extra: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + headers = {"Host": host or f"127.0.0.1:{port}"} + if api_header: + headers[API_HEADER] = "1" + if body is not None: + headers["Content-Type"] = "application/json" + headers.update(extra or {}) + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + data = response.read() + conn.close() + return response.status, dict(response.getheaders()), data + + +def test_api_success_serializes_through_the_schema_layer( + served: tuple[int, FakeAPI], +) -> None: + port, _ = served + status, headers, data = _request(port, "GET", "/api/meta") + assert status == 200 and headers["Content-Type"] == "application/json" + assert json.loads(data) == {"ok": True, "nan": None} + status, _, data = _request(port, "GET", "/api/patients/7/visits/-3/trace") + assert status == 200 and json.loads(data) == {"value": -2.87654} + + +def test_every_response_carries_the_security_headers( + served: tuple[int, FakeAPI], +) -> None: + port, _ = served + for path, api in [ + ("/api/meta", True), + ("/", False), + ("/api/nope", True), + ("/api/meta", False), + ]: + _, headers, _ = _request(port, "GET", path, api_header=api) + for name, value in SECURITY_HEADERS.items(): + assert headers.get(name) == value, (path, name) + assert "Access-Control-Allow-Origin" not in headers + + +@pytest.mark.parametrize( + "host", ["evil.example", "evil.example:80", "127.0.0.1:1", "localhost.evil.com"] +) +def test_foreign_host_headers_are_refused_everywhere( + served: tuple[int, FakeAPI], host: str +) -> None: + port, _ = served + assert _request(port, "GET", "/api/meta", host=host)[0] == 403 + assert _request(port, "GET", "/", host=host)[0] == 403 + + +def test_loopback_host_variants_are_accepted(served: tuple[int, FakeAPI]) -> None: + port, _ = served + for host in (f"localhost:{port}", f"127.0.0.1:{port}", "localhost"): + assert _request(port, "GET", "/api/meta", host=host)[0] == 200 + assert allowed_hosts(8765) >= {"localhost:8765", "[::1]:8765", "127.0.0.1"} + + +def test_api_calls_without_the_custom_header_are_refused( + served: tuple[int, FakeAPI], +) -> None: + port, _ = served + status, _, data = _request(port, "GET", "/api/meta", api_header=False) + assert status == 403 and "X-Odyssey-Demo" in json.loads(data)["error"] + assert ( + _request(port, "GET", "/api/meta", extra={API_HEADER: "yes"}, api_header=False)[ + 0 + ] + == 403 + ) + + +def test_status_codes_for_errors(served: tuple[int, FakeAPI]) -> None: + port, _ = served + assert _request(port, "GET", "/api/patients/404")[0] == 404 + status, _, data = _request(port, "GET", "/api/patients/500") + assert status == 500 and "secret" not in data.decode() + assert _request(port, "GET", "/api/patients/abc")[0] == 404 + assert _request(port, "GET", "/api/nothing")[0] == 404 + assert _request(port, "POST", "/api/meta", body=b"{}")[0] == 405 + assert _request(port, "GET", "/api/patients/1/visits/2/whatif")[0] == 405 + assert _request(port, "DELETE", "/api/meta")[0] == 501 # stdlib: unsupported method + + +def test_post_bodies_are_validated(served: tuple[int, FakeAPI]) -> None: + port, api = served + path = "/api/patients/1/visits/2/whatif" + status, _, data = _request( + port, "POST", path, body=json.dumps({"t_hours": 3}).encode() + ) + assert status == 200 and json.loads(data) == {"echo": {"t_hours": 3}} + assert _request(port, "POST", path, body=b"not json")[0] == 400 + assert _request(port, "POST", path, body=b"[1, 2]")[0] == 400 + assert _request(port, "POST", path, body=b"\xff\xfe")[0] == 400 + status, _, data = _request( + port, "POST", path, body=json.dumps({"bad": True}).encode() + ) + assert status == 400 and json.loads(data)["error"] == "t_hours must be a number" + big = b'{"x": "' + b"a" * MAX_BODY_BYTES + b'"}' + assert _request(port, "POST", path, body=big)[0] == 413 + assert ( + _request(port, "POST", path, body=b"", extra={"Content-Length": "0"})[0] == 200 + ) + assert ( + _request(port, "POST", "/api/patients/1/visits/2/evidence", body=b"{}")[0] + == 202 + ) + assert api.bodies[0] == {"t_hours": 3} + + +def test_static_files_are_served_with_the_right_types( + served: tuple[int, FakeAPI], +) -> None: + port, _ = served + status, headers, data = _request(port, "GET", "/", api_header=False) + assert ( + status == 200 + and headers["Content-Type"].startswith("text/html") + and b"demo" in data + ) + status, headers, _ = _request(port, "GET", "/static/js/app.js", api_header=False) + assert status == 200 and headers["Content-Type"].startswith("text/javascript") + assert _request(port, "GET", "/static/notes.txt", api_header=False)[0] == 404 + assert _request(port, "GET", "/static/../secret.json", api_header=False)[0] == 404 + assert ( + _request(port, "GET", "/static/%2e%2e/secret.json", api_header=False)[0] == 404 + ) + assert _request(port, "GET", "/static/escape.json", api_header=False)[0] == 404 + assert _request(port, "GET", "/etc/passwd", api_header=False)[0] == 404 + assert _request(port, "POST", "/", body=b"{}", api_header=False)[0] == 405 + + +def test_resolve_static_containment(static_root: Path) -> None: + assert resolve_static("/", static_root) == (static_root / "index.html").resolve() + assert ( + resolve_static("/index.html", static_root) + == (static_root / "index.html").resolve() + ) + assert resolve_static("/static/js/app.js", static_root) is not None + for bad in ( + "/static/", + "/static/js", + "/static/../secret.json", + "/static/escape.json", + "/other.js", + "/static/missing.js", + ): + assert resolve_static(bad, static_root) is None, bad + + +@pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.10", "example.com"]) +def test_the_server_refuses_to_bind_off_loopback(host: str) -> None: + with pytest.raises(ValueError, match="loopback"): + make_server(FakeAPI(), host, 0) # type: ignore[arg-type] + + +def test_the_packaged_static_dir_has_an_index() -> None: + from apps.clinician_demo.server import STATIC_DIR # noqa: PLC0415 + + assert (STATIC_DIR / "index.html").is_file() diff --git a/tests/apps/clinician_demo/test_service.py b/tests/apps/clinician_demo/test_service.py new file mode 100644 index 00000000..5886deda --- /dev/null +++ b/tests/apps/clinician_demo/test_service.py @@ -0,0 +1,330 @@ +"""DemoService end to end on the synthetic cohort with a tiny CPU model.""" + +import time +from pathlib import Path + +import polars as pl +import pytest + +import apps.clinician_demo.forecast as forecast_module +import odyssey.inference.counterfactual as counterfactual_module +from apps.clinician_demo.service import ( + DISCLAIMERS, + BadRequestError, + DemoService, + NotFoundError, + concept_label, + event_infos, +) +from tests.apps.clinician_demo.conftest import CHUNK, ServiceFactory + + +def _wait_job(service: DemoService, job_id: str) -> object: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + job = service.job(job_id) + if job.status in ("done", "error"): + return job + time.sleep(0.02) + raise AssertionError("evidence job did not finish") + + +def test_meta_shows_five_events_and_only_the_active_mode_disclaimer( + make_service: ServiceFactory, +) -> None: + meta = make_service().meta() + assert [e.name for e in meta.events] == [ + "icu_admission", + "vasopressor_start", + "acute_kidney_injury", + "sepsis3", + "death", + ] + assert "readmission_30d" not in {e.name for e in meta.events} + assert meta.data_mode == "open" and not meta.searchable + assert "open" in meta.disclaimers and "credentialed" not in meta.disclaimers + assert meta.chunk_size == CHUNK and meta.horizons == [8.0, 24.0, 72.0] + assert set(DISCLAIMERS) - {"credentialed", "open"} <= set(meta.disclaimers) + + +def test_open_mode_gallery_is_built_by_tracing_and_lists_every_admission( + make_service: ServiceFactory, +) -> None: + gallery = make_service(threshold=0.0).gallery() + kinds = [s.kind for s in gallery.sections] + assert kinds == ["early_warning", "quiet", "miss", "false_alarm", "other"] + everyone = gallery.sections[-1] + assert {(c.subject_id, c.visit_id) for c in everyone.cases} == { + (1, 10), + (1, 11), + (2, 20), + } + warnings = gallery.sections[0].cases + assert any( + c.event == "icu_admission" and c.lead_hours == pytest.approx(20.0) + for c in warnings + ) + assert all("≈" not in c.headline for c in warnings) # exact onsets in open mode + seen = {c.subject_id: c.seen_in_training for c in everyone.cases} + assert seen == {1: True, 2: False} + + +def test_patient_and_trace_endpoints(make_service: ServiceFactory) -> None: + service = make_service() + patient = service.patient(1) + assert [v.visit_id for v in patient.visits] == [10, 11] and patient.seen_in_training + view = service.trace(1, 10) + assert view.visit.visit_id == 10 and view.onsets["icu_admission"] == pytest.approx( + 20.0 + ) + assert view.banked == [] # open mode never reads banked rows + with pytest.raises(NotFoundError, match="patient 99"): + service.patient(99) + with pytest.raises(NotFoundError, match="no visit 12"): + service.trace(1, 12) + + +def test_traces_are_cached_per_patient( + make_service: ServiceFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + service = make_service(prepare=False) + calls: list[int] = [] + real = forecast_module.trace_patient + + def counting(ctx, raw): # type: ignore[no-untyped-def] + calls.append(1) + return real(ctx, raw) + + monkeypatch.setattr("apps.clinician_demo.service.trace_patient", counting) + service.trace(1, 10) + service.trace(1, 11) + service.whatif(1, 10, {"t_hours": 5, "edits": [{"preset": "sbp"}]}) + assert len(calls) == 1 + + +def test_the_runs_chunk_size_reaches_every_model_call( + make_service: ServiceFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + """A dropped chunk_size would silently fall back to 256 and change every number.""" + seen: list[int] = [] + real = forecast_module.stream_patient + + def spy(*args, **kwargs): # type: ignore[no-untyped-def] + seen.append(kwargs["chunk_size"]) + return real(*args, **kwargs) + + monkeypatch.setattr(forecast_module, "stream_patient", spy) + monkeypatch.setattr(counterfactual_module, "stream_patient", spy) + service = make_service(prepare=False) + service.trace(1, 10) + service.whatif(1, 10, {"t_hours": 12, "edits": [{"preset": "sbp", "value": 70}]}) + job = service.evidence( + 1, + 10, + { + "t_hours": 12, + "target": {"kind": "event", "name": "death"}, + "lookback_hours": 2, + }, + ) + assert _wait_job(service, job.job_id).status == "done" # type: ignore[attr-defined] + assert len(seen) > 3 and set(seen) == {CHUNK} + + +def test_whatif_snaps_to_a_shown_moment_and_validates_input( + make_service: ServiceFactory, +) -> None: + service = make_service(prepare=False) + result = service.whatif( + 1, 10, {"t_hours": 12.4, "edits": [{"preset": "sbp", "value": 70}]} + ) + assert result.t_hours == pytest.approx(12.0) and result.rows_edited == 6 + early = service.whatif(1, 10, {"t_hours": -5, "edits": [{"preset": "sbp"}]}) + assert early.t_hours == pytest.approx(0.0) # before the visit: its first moment + for body, message in [ + ({"edits": [{"preset": "sbp"}]}, "t_hours"), + ({"t_hours": "x", "edits": [{"preset": "sbp"}]}, "t_hours"), + ({"t_hours": 1, "edits": []}, "non-empty"), + ({"t_hours": 1, "edits": [{"preset": "zzz"}]}, "unknown preset"), + ]: + with pytest.raises(BadRequestError, match=message): + service.whatif(1, 10, body) + + +def test_evidence_for_events_and_concepts_and_its_cache( + make_service: ServiceFactory, +) -> None: + service = make_service(prepare=False) + body = { + "t_hours": 12, + "target": {"kind": "event", "name": "icu_admission", "horizon_hours": 8}, + "lookback_hours": 3, + } + job = service.evidence(1, 10, body) + done = _wait_job(service, job.job_id) + assert done.status == "done" and done.result # type: ignore[attr-defined] + assert all( + abs(a.delta) >= abs(b.delta) for a, b in zip(done.result, done.result[1:]) + ) # type: ignore[attr-defined] + assert service.evidence(1, 10, body).job_id == job.job_id # cached + concept = service.evidence( + 1, 10, {"t_hours": 12, "target": {"kind": "concept", "name": "hypotension"}} + ) + assert _wait_job(service, concept.job_id).status == "done" # type: ignore[attr-defined] + + +@pytest.mark.parametrize( + ("body", "message"), + [ + ({"t_hours": 1, "target": "death"}, "target must be an object"), + ( + {"t_hours": 1, "target": {"kind": "event", "name": "readmission_30d"}}, + "unknown event", + ), + ( + { + "t_hours": 1, + "target": {"kind": "event", "name": "death", "horizon_hours": 12}, + }, + "horizon", + ), + ( + {"t_hours": 1, "target": {"kind": "concept", "name": "nope"}}, + "unknown concept", + ), + ({"t_hours": 1, "target": {"kind": "code", "name": "x"}}, "target.kind"), + ( + { + "t_hours": 1, + "target": {"kind": "concept", "name": "fever"}, + "lookback_hours": 0.5, + }, + "lookback_hours", + ), + ( + { + "t_hours": 1, + "target": {"kind": "concept", "name": "fever"}, + "lookback_hours": 100, + }, + "lookback_hours", + ), + ], +) +def test_evidence_rejects_bad_targets( + make_service: ServiceFactory, body: dict[str, object], message: str +) -> None: + with pytest.raises(BadRequestError, match=message): + make_service(prepare=False).evidence(1, 10, body) + + +def test_unknown_job_is_not_found(make_service: ServiceFactory) -> None: + with pytest.raises(NotFoundError): + make_service(prepare=False).job("ev404") + + +def _banked_rows(path: Path) -> Path: + frame = pl.DataFrame( + { + "subject_id": [1.0, 1.0, 1.0, 2.0], + "visit_id": [10.0, 10.0, 10.0, 20.0], + "time_hours": [4.0, 8.0, 12.0, 4.0], + "event": ["icu_admission", "icu_admission", "icu_admission", "death"], + "hazard@24h": [0.1, 0.3, 0.5, 0.0], + "gbm@24h": [0.2, 0.2, 0.2, 0.0], + "y@24h": [0.0, 1.0, 1.0, 0.0], + } + ) + frame.write_parquet(path) + return path + + +def test_credentialed_mode_builds_the_gallery_from_banked_rows_without_the_model( + make_service: ServiceFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _no_model(*_a: object, **_k: object) -> None: + raise AssertionError("credentialed gallery must not trace anyone") + + monkeypatch.setattr("apps.clinician_demo.service.trace_patient", _no_model) + service = make_service( + data_mode="credentialed", + threshold=0.25, + banked_rows_path=_banked_rows(tmp_path / "rows.parquet"), + ) + assert service.meta().searchable + kinds = [s.kind for s in service.gallery().sections] + assert kinds == ["early_warning", "quiet", "miss", "false_alarm"] + warnings = service.gallery().sections[0].cases + assert warnings == [] or all("≈" in c.headline for c in warnings) + + +def test_credentialed_trace_carries_the_banked_overlay( + make_service: ServiceFactory, tmp_path: Path +) -> None: + service = make_service( + data_mode="credentialed", + banked_rows_path=_banked_rows(tmp_path / "rows.parquet"), + ) + view = service.trace(1, 10) + assert [(b.t, b.hazard_24h, b.gbm_24h) for b in view.banked] == [ + (4.0, 0.1, 0.2), + (8.0, 0.3, 0.2), + (12.0, 0.5, 0.2), + ] + assert service.trace(2, 20).banked[0].event == "death" + + +def test_self_check_reports_the_invariants( + make_service: ServiceFactory, tmp_path: Path +) -> None: + service = make_service( + data_mode="credentialed", + banked_rows_path=_banked_rows(tmp_path / "rows.parquet"), + threshold=0.0, + ) + service._gallery = service.prepare_gallery() # noqa: SLF001 + report = service.self_check() + assert report["chunk_size"] == CHUNK and report["subjects"] == 2 + assert "readmission_30d" not in report["events"] + if "trace_seconds" in report: + assert report["unknown_token_share"] == 0.0 + assert report["whatif_rows_edited"] >= 0 + + +def test_self_check_on_an_empty_gallery_says_so(make_service: ServiceFactory) -> None: + service = make_service(prepare=False) + assert "gallery is empty" in service.self_check()["warning"] + + +def test_warm_up_traces_gallery_patients_in_the_background( + make_service: ServiceFactory, +) -> None: + service = make_service(threshold=0.0) + service.warm_up().join(timeout=60) + assert len(service._traces) == 2 # noqa: SLF001 + + +def test_event_infos_fall_back_for_unknown_events() -> None: + (info,) = event_infos(("something_new",)) + assert (info.display, info.short, info.definition) == ( + "something new", + "something_new", + "", + ) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("qsofa", "qSOFA"), + ("sirs", "SIRS"), + ("sepsis3", "Sepsis-3"), + ("acute_kidney_injury", "AKI (any stage)"), + ("aki_stage_3", "AKI stage 3"), + ("shock", "Sustained hypotension (MAP)"), # legacy slot name + ("anemia", "Severe anemia"), + ("hypoxia", "Hypoxia"), + ], +) +def test_concept_labels_read_like_clinical_writing(name: str, expected: str) -> None: + assert concept_label(name) == expected diff --git a/tests/apps/clinician_demo/test_showcase.py b/tests/apps/clinician_demo/test_showcase.py new file mode 100644 index 00000000..276fb112 --- /dev/null +++ b/tests/apps/clinician_demo/test_showcase.py @@ -0,0 +1,286 @@ +"""Gallery selection: deterministic, episode-based leads, honest about misses.""" + +import polars as pl +import pytest + +from apps.clinician_demo.schemas import Gallery, GallerySection +from apps.clinician_demo.showcase import ( + MAX_FEATURED_LEAD_HOURS, + MAX_STAY_HOURS, + VISIT_STATS_SCHEMA, + build_gallery, + empty_visit_stats, + visit_stats_from_alert_rows, +) + + +DISPLAY = {"icu_admission": "ICU admission", "death": "Death"} + + +def _stats(rows: list[dict[str, object]]) -> pl.DataFrame: + return pl.DataFrame(rows, schema=VISIT_STATS_SCHEMA) + + +def _row( # noqa: PLR0913 -- one keyword per stats column + sid: int, + vid: int, + event: str, + *, + positive: bool, + cross: float | None, + alert: float | None = None, + end: float, + start: float = 0.0, + max_risk: float = 0.5, + thr: float = 0.2, +) -> dict[str, object]: + return { + "subject_id": sid, + "visit_id": vid, + "event": event, + "positive": positive, + "first_cross_hours": cross, + "alert_start_hours": alert, + "end_hours": end, + "start_hours": start, + "max_risk": max_risk, + "threshold": thr, + } + + +def _section(gallery: Gallery, kind: str) -> GallerySection: + return next(s for s in gallery.sections if s.kind == kind) + + +# -- stats from banked landmark rows ------------------------------------------ + + +def _rows(event: str, hazards: list[float], outcome: float = 1.0) -> pl.DataFrame: + n = len(hazards) + return pl.DataFrame( + { + "subject_id": [1.0] * n, + "visit_id": [10.0] * n, + "time_hours": [4.0 * i for i in range(n)], + "event": [event] * n, + "hazard@24h": hazards, + "y@24h": [0.0] * (n - 1) + [outcome], + } + ) + + +def test_episode_start_is_the_run_still_on_at_the_last_row() -> None: + # on at 0, off at 4, back on from 8 to the last row (16): the episode began at 8 + stats = visit_stats_from_alert_rows( + _rows("icu_admission", [0.5, 0.1, 0.3, 0.4, 0.6]), {"icu_admission": 0.2} + ) + row = stats.row(0, named=True) + assert row["positive"] and row["first_cross_hours"] == 0.0 + assert row["alert_start_hours"] == 8.0 and row["end_hours"] == 16.0 + + +def test_no_episode_when_the_alert_was_off_at_the_last_row() -> None: + stats = visit_stats_from_alert_rows( + _rows("icu_admission", [0.5, 0.6, 0.1]), {"icu_admission": 0.2} + ) + row = stats.row(0, named=True) + assert row["first_cross_hours"] == 0.0 and row["alert_start_hours"] is None + + +def test_an_alert_on_throughout_starts_at_the_first_row() -> None: + stats = visit_stats_from_alert_rows( + _rows("icu_admission", [0.3, 0.3, 0.3]), {"icu_admission": 0.2} + ) + assert stats.row(0, named=True)["alert_start_hours"] == 0.0 + + +def test_negatives_never_carry_an_episode() -> None: + stats = visit_stats_from_alert_rows( + _rows("death", [0.5, 0.5], outcome=0.0), {"death": 0.2} + ) + row = stats.row(0, named=True) + assert not row["positive"] and row["alert_start_hours"] is None + assert row["first_cross_hours"] == 0.0 + + +def test_stats_cast_ids_and_ignore_unthresholded_events() -> None: + rows = pl.concat( + [_rows("icu_admission", [0.1, 0.4]), _rows("readmission_30d", [0.9, 0.9])] + ) + stats = visit_stats_from_alert_rows(rows, {"icu_admission": 0.2}) + assert stats.schema == pl.Schema(VISIT_STATS_SCHEMA) + assert stats["event"].to_list() == ["icu_admission"] + assert (stats["subject_id"][0], stats["visit_id"][0]) == (1, 10) + assert visit_stats_from_alert_rows(rows, {}).height == 0 + + +# -- the gallery ---------------------------------------------------------------- + + +def test_early_warnings_use_the_live_episode_not_the_first_touch() -> None: + stats = _stats( + [ + # touched the line at hour 1, but the live alert began at 40: lead 20 + _row( + 1, 10, "icu_admission", positive=True, cross=1.0, alert=40.0, end=60.0 + ), + _row( + 2, 20, "icu_admission", positive=True, cross=0.0, alert=10.0, end=14.0 + ), + _row(3, 30, "icu_admission", positive=True, cross=0.0, alert=0.0, end=12.0), + ] + ) + section = _section(build_gallery(stats, display=DISPLAY), "early_warning") + assert [(c.subject_id, c.lead_hours) for c in section.cases] == [ + (1, 20.0), + (3, 12.0), + ] + assert section.cases[0].headline == "ICU admission: alert on ≈20 h before it began" + assert section.cases[0].lead_approximate + assert "ICU admission 2 of 3 (67%)" in section.summary + assert "Death" not in section.summary # no Death events: left out, not "0 of 0" + + +def test_featured_leads_are_capped_but_still_counted() -> None: + long_lead = MAX_FEATURED_LEAD_HOURS + 10 + stats = _stats( + [ + _row(1, 10, "death", positive=True, cross=0.0, alert=0.0, end=long_lead), + _row(2, 20, "death", positive=True, cross=0.0, alert=0.0, end=30.0), + _row( + 3, + 30, + "death", + positive=True, + cross=0.0, + alert=0.0, + end=MAX_STAY_HOURS + 1, + ), + ] + ) + section = _section(build_gallery(stats, display=DISPLAY), "early_warning") + assert [c.subject_id for c in section.cases] == [2] + assert "Death 3 of 3 (100%)" in section.summary + + +def test_exact_leads_drop_the_approximation_mark_and_per_event_caps_apply() -> None: + stats = _stats( + [ + _row(i, i, "death", positive=True, cross=0.0, alert=0.0, end=10.0 + i) + for i in range(1, 6) + ] + ) + gallery = build_gallery( + stats, display=DISPLAY, per_event=2, approximate_leads=False + ) + cases = _section(gallery, "early_warning").cases + assert [c.subject_id for c in cases] == [5, 4] + assert "≈" not in cases[0].headline and not cases[0].lead_approximate + + +def test_quiet_stays_need_every_event_low_no_positive_and_two_days() -> None: + def quiet( + sid: int, *, stay: float = 60.0, risk: float = 0.01, positive: bool = False + ) -> list[dict[str, object]]: + return [ + _row( + sid, + sid, + e, + positive=positive and e == "death", + cross=None, + end=stay, + max_risk=risk, + ) + for e in DISPLAY + ] + + stats = _stats( + quiet(1) + + quiet(2, stay=30.0) # too short + + quiet(3, risk=0.06) # 0.06 >= 0.25 * 0.2 + + quiet(4, positive=True) + + [_row(5, 5, "death", positive=False, cross=None, end=90.0, max_risk=0.0)] + + quiet(6, stay=100.0) + ) + section = _section(build_gallery(stats, display=DISPLAY), "quiet") + assert [c.subject_id for c in section.cases] == [6, 1] + assert section.cases[0].event is None and section.cases[0].headline.endswith( + "4.2 days" + ) + assert section.summary.startswith("2 stays had none of the events") + assert all(not c.lead_approximate for c in section.cases) + + +def test_misses_are_events_with_no_alert_on_and_false_alarms_count_pairs() -> None: + stats = _stats( + [ + _row(1, 1, "death", positive=True, cross=None, end=10.0, max_risk=0.05), + # crossed earlier but the alert was off again at onset: still a miss + _row( + 2, + 2, + "death", + positive=True, + cross=2.0, + alert=None, + end=10.0, + max_risk=0.3, + ), + _row(3, 3, "death", positive=True, cross=2.0, alert=2.0, end=10.0), + _row( + 4, 4, "icu_admission", positive=False, cross=3.0, end=10.0, max_risk=0.9 + ), + _row(5, 5, "icu_admission", positive=False, cross=None, end=10.0), + ] + ) + gallery = build_gallery( + stats, display=DISPLAY, seen_in_training=lambda sid: sid == 2 + ) + misses = _section(gallery, "miss") + assert [c.subject_id for c in misses.cases] == [1, 2] # lowest peak risk first + assert misses.cases[1].seen_in_training and not misses.cases[0].seen_in_training + assert misses.summary == "2 of 3 events (67%) began with no alert on" + assert misses.cases[0].headline == "Death began with no alert on" + alarms = _section(gallery, "false_alarm") + assert [c.subject_id for c in alarms.cases] == [4] and alarms.cases[ + 0 + ].lead_hours is None + assert alarms.summary.startswith("In 1 of 2 cases (50%) an event's alert came on") + + +def test_empty_stats_give_four_empty_sections_without_dividing_by_zero() -> None: + gallery = build_gallery(empty_visit_stats(), display=DISPLAY) + assert [s.kind for s in gallery.sections] == [ + "early_warning", + "quiet", + "miss", + "false_alarm", + ] + assert all(s.cases == [] for s in gallery.sections) + assert _section(gallery, "early_warning").summary == ( + "No stay in this data had one of the events." + ) + assert "n/a" in _section(gallery, "miss").summary + + +def test_events_outside_the_display_map_are_ignored_and_selection_is_deterministic() -> ( + None +): + stats = _stats( + [ + _row(i, i, "sepsis3", positive=True, cross=0.0, alert=0.0, end=20.0) + for i in range(3) + ] + + [_row(9, 9, "death", positive=True, cross=0.0, alert=0.0, end=20.0)] + ) + one = build_gallery(stats, display=DISPLAY) + two = build_gallery(stats.reverse(), display=DISPLAY) + assert one == two + assert all(c.event != "sepsis3" for s in one.sections for c in s.cases) + + +@pytest.mark.parametrize("kind", ["early_warning", "quiet", "miss", "false_alarm"]) +def test_every_section_has_a_title_and_summary(kind: str) -> None: + section = _section(build_gallery(empty_visit_stats(), display=DISPLAY), kind) + assert section.title and section.summary diff --git a/tests/apps/clinician_demo/test_thresholds.py b/tests/apps/clinician_demo/test_thresholds.py new file mode 100644 index 00000000..41d54af6 --- /dev/null +++ b/tests/apps/clinician_demo/test_thresholds.py @@ -0,0 +1,151 @@ +"""Alert lines: threshold, sensitivity, PPV and the like-for-like GBM line.""" + +import json +from pathlib import Path + +import polars as pl +import pytest + +from apps.clinician_demo.thresholds import ( + compute_operating_points, + flag_threshold, + horizon_key, + load_or_compute_operating_points, + operating_point, +) + + +def _rows() -> pl.DataFrame: + # 20 at-risk rows for AKI at 24 h: hazard 0.00..0.19, the top 4 are positives + # except one; the GBM ranks perfectly. Two rows are not at risk (y null). + hazard = [i / 100 for i in range(20)] + outcome: list[float | None] = [0.0] * 20 + for i in (19, 18, 17, 5): + outcome[i] = 1.0 + gbm = [1.0 if y == 1.0 else 0.0 for y in outcome] + frame = pl.DataFrame( + { + "event": ["acute_kidney_injury"] * 20, + "hazard@24h": hazard, + "y@24h": outcome, + "gbm@24h": gbm, + } + ) + extra = pl.DataFrame( + { + "event": ["acute_kidney_injury", "death"], + "hazard@24h": [0.99, 0.5], + "y@24h": [None, 1.0], + "gbm@24h": [0.5, 0.5], + }, + schema=frame.schema, + ) + return pl.concat([frame, extra]) + + +def test_horizon_key_formats_like_the_banked_files() -> None: + assert [horizon_key(h) for h in (8.0, 24.0, 72.0, 0.5)] == [ + "8h", + "24h", + "72h", + "0.5h", + ] + + +def test_threshold_is_an_observed_upper_quantile() -> None: + scores = pl.Series([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]) + assert flag_threshold(scores, 0.2) == 0.9 + assert flag_threshold(scores, 0.05) == 1.0 + with pytest.raises(ValueError, match="empty"): + flag_threshold(pl.Series([], dtype=pl.Float64), 0.1) + + +def test_operating_point_counts_only_at_risk_rows_and_scores_the_gbm_alike() -> None: + point = operating_point(_rows(), "acute_kidney_injury", 24.0, 0.2) + assert point is not None + assert point.n_rows == 20 # the null-outcome row is excluded + assert point.threshold == pytest.approx(0.16) # 4 of 20 rows flagged + assert point.alert_rate == pytest.approx(0.2) + assert point.sensitivity == pytest.approx(3 / 4) # rows 17-19 flagged, row 5 missed + assert point.ppv == pytest.approx(3 / 4) # row 16 is a false flag + assert point.base_rate == pytest.approx(4 / 20) + assert point.gbm_sensitivity == pytest.approx( + 1.0 + ) and point.gbm_ppv == pytest.approx(1.0) + + +def test_ties_are_reported_as_the_measured_flag_rate() -> None: + rows = pl.DataFrame( + {"event": ["e"] * 4, "hazard@8h": [0.5] * 4, "y@8h": [0.0, 1.0, 0.0, 0.0]} + ) + point = operating_point(rows, "e", 8.0, 0.25) + assert point is not None + assert point.alert_rate == 1.0 and point.sensitivity == 1.0 and point.ppv == 0.25 + assert point.gbm_sensitivity is None and point.gbm_ppv is None # no gbm column + + +def test_no_positives_gives_undefined_sensitivity_and_empty_event_gives_none() -> None: + rows = pl.DataFrame( + {"event": ["e"] * 3, "hazard@8h": [0.1, 0.2, 0.3], "y@8h": [0.0, 0.0, 0.0]} + ) + point = operating_point(rows, "e", 8.0, 0.34) + assert point is not None and point.sensitivity is None and point.ppv == 0.0 + assert operating_point(rows, "other", 8.0, 0.1) is None + + +def test_gbm_nulls_are_dropped_from_the_gbm_line_only() -> None: + rows = _rows().with_columns( + pl.when(pl.col("hazard@24h") < 0.1) + .then(None) + .otherwise(pl.col("gbm@24h")) + .alias("gbm@24h") + ) + point = operating_point(rows, "acute_kidney_injury", 24.0, 0.2) + assert point is not None and point.n_rows == 20 + + +def test_compute_reads_only_available_horizons_from_disk(tmp_path: Path) -> None: + path = tmp_path / "alerts_rows.parquet" + _rows().write_parquet(path) + points = compute_operating_points( + path, ["acute_kidney_injury", "death", "absent"], [24.0, 72.0], 0.2 + ) + assert [(p.event, p.horizon_hours) for p in points] == [ + ("acute_kidney_injury", 24.0), + ("death", 24.0), + ] + + +def test_cache_is_reused_only_for_an_identical_request(tmp_path: Path) -> None: + rows_path, cache = ( + tmp_path / "alerts_rows.parquet", + tmp_path / "c" / "thresholds.json", + ) + _rows().write_parquet(rows_path) + first = load_or_compute_operating_points( + rows_path, cache, ["acute_kidney_injury"], [24.0], 0.2 + ) + assert cache.exists() + # tamper with the cached numbers: an identical request must return them + doc = json.loads(cache.read_text()) + doc["points"][0]["threshold"] = 0.123 + cache.write_text(json.dumps(doc)) + again = load_or_compute_operating_points( + rows_path, cache, ["acute_kidney_injury"], [24.0], 0.2 + ) + assert again[0].threshold == 0.123 + # a different alert rate invalidates the cache + other = load_or_compute_operating_points( + rows_path, cache, ["acute_kidney_injury"], [24.0], 0.1 + ) + assert other[0].threshold != 0.123 and first[0].threshold == pytest.approx(0.16) + + +def test_unreadable_cache_is_ignored_and_rewritten(tmp_path: Path) -> None: + rows_path, cache = tmp_path / "alerts_rows.parquet", tmp_path / "thresholds.json" + _rows().write_parquet(rows_path) + cache.write_text("{not json") + points = load_or_compute_operating_points( + rows_path, cache, ["acute_kidney_injury"], [24.0], 0.2 + ) + assert len(points) == 1 and json.loads(cache.read_text())["points"] diff --git a/tests/apps/clinician_demo/test_whatif.py b/tests/apps/clinician_demo/test_whatif.py new file mode 100644 index 00000000..def19f3c --- /dev/null +++ b/tests/apps/clinician_demo/test_whatif.py @@ -0,0 +1,149 @@ +"""What-if: request validation, preset sanity, and the no-GPU short cut.""" + +from datetime import timedelta + +import pytest + +import apps.clinician_demo.whatif as whatif_module +from apps.clinician_demo.forecast import RunContext +from apps.clinician_demo.patient_store import PatientStore +from apps.clinician_demo.whatif import ( + MAX_EDITS, + PRESETS, + PRESETS_BY_ID, + EditRequest, + parse_edit_requests, + run_whatif, + to_value_edits, + untouched_warnings, +) +from tests.apps.clinician_demo.conftest import T0 + + +@pytest.mark.parametrize("preset", PRESETS, ids=lambda p: p.id) +def test_every_preset_resolves_and_is_unit_safe_on_mimic(preset) -> None: # type: ignore[no-untyped-def] + assert preset.min <= preset.value <= preset.max and preset.step > 0 + assert 1.0 <= preset.window_hours <= 72.0 + for value in (preset.min, preset.value, preset.max): + for edit in to_value_edits(EditRequest(preset.id, value)): + prefixes = edit.prefixes("mimic_iv") + assert prefixes, f"{edit.signal} resolves to no MIMIC code" + for prefix in prefixes: + edit.value_for( + prefix, "mimic_iv" + ) # must not raise on a unit-tagged prefix + + +def test_preset_ids_are_unique() -> None: + assert len(PRESETS_BY_ID) == len(PRESETS) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + (None, "non-empty list"), + ([], "non-empty list"), + ({"preset": "sbp"}, "non-empty list"), + ([{"preset": "sbp"}] * (MAX_EDITS + 1), "at most"), + (["sbp"], "must be an object"), + ([{"preset": "nope"}], "unknown preset"), + ([{}], "unknown preset"), + ([{"preset": "sbp", "value": "80"}], "must be a number"), + ([{"preset": "sbp", "value": True}], "must be a number"), + ([{"preset": "sbp", "value": None}], "must be a number"), + ([{"preset": "sbp", "value": 49.9}], "outside"), + ([{"preset": "sbp", "value": 180.1}], "outside"), + ([{"preset": "sbp", "value": float("nan")}], "outside"), + ([{"preset": "sbp"}, {"preset": "sbp", "value": 90}], "listed twice"), + ], +) +def test_bad_requests_are_refused(payload: object, message: str) -> None: + with pytest.raises(ValueError, match=message): + parse_edit_requests(payload) + + +def test_defaults_and_bounds_are_accepted() -> None: + parsed = parse_edit_requests( + [ + {"preset": "sbp"}, + {"preset": "lactate", "value": 0.25}, + {"preset": "creatinine", "value": 4}, + ] + ) + assert parsed == [ + EditRequest("sbp", PRESETS_BY_ID["sbp"].value), + EditRequest("lactate", 0.25), + EditRequest("creatinine", 4.0), + ] + + +def test_value_edit_carries_the_preset_mode_and_window() -> None: + (edit,) = to_value_edits(EditRequest("lactate", 2.0)) + assert (edit.signal, edit.mode, edit.value, edit.window_hours) == ( + "lactate", + "scale", + 2.0, + 12.0, + ) + + +def test_blood_pressure_presets_cover_cuff_and_arterial_line() -> None: + signals = [e.signal for e in to_value_edits(EditRequest("sbp", 80.0))] + assert signals == ["sbp_noninvasive", "sbp_arterial"] + assert [e.signal for e in to_value_edits(EditRequest("map", 60.0))] == [ + "map_noninvasive", + "map_arterial", + ] + + +def test_untouched_edits_warn_without_touching_the_gpu( + ctx: RunContext, store: PatientStore, monkeypatch: pytest.MonkeyPatch +) -> None: + def _boom(*_a: object, **_k: object) -> None: + raise AssertionError("no rows changed: the model must not run") + + monkeypatch.setattr(whatif_module, "counterfactual_forecast", _boom) + result = run_whatif( + ctx, + store.raw_events(1), + [ + EditRequest("lactate", 3.0), + EditRequest("spo2", 85.0), + ], # never charted in the cohort + index_time=T0 + timedelta(hours=10), + t_hours=10.0, + ) + assert result.rows_edited == 0 and len(result.warnings) == 2 + assert result.factual.risk == {} and result.delta.concepts == {} + + +def test_a_real_edit_moves_the_forecast_and_deltas_are_consistent( + ctx: RunContext, store: PatientStore +) -> None: + result = run_whatif( + ctx, + store.raw_events(1), + [EditRequest("sbp", 60.0), EditRequest("lactate", 3.0)], + index_time=T0 + timedelta(hours=12), + t_hours=12.0, + ) + assert result.rows_edited == 6 # SBP at hours 7..12 + assert len(result.warnings) == 1 and "Lactate" in result.warnings[0] + assert set(result.factual.risk) == set(ctx.events) + assert "readmission_30d" not in result.counterfactual.risk + for event, by_h in result.delta.risk.items(): + for h, d in by_h.items(): + assert d == pytest.approx( + result.counterfactual.risk[event][h] - result.factual.risk[event][h] + ) + assert any(abs(d) > 0 for hs in result.delta.risk.values() for d in hs.values()) + + +def test_untouched_warnings_count_rows_per_edit(store: PatientStore) -> None: + total, warnings = untouched_warnings( + store.raw_events(1), + [EditRequest("heart_rate", 100.0)], + index_time=T0 + timedelta(hours=3), + source="mimic_iv", + ) + assert total == 3 and warnings == [] From 834822681af681eda29bcb26f0c0c83ea95f0e90 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:32:47 +0000 Subject: [PATCH 3/6] [pre-commit.ci] Add auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json | 2 +- .../gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json | 2 +- .../gemini/out/evals/gemini_full_v10_15c_counterfactual.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json | 2 +- .../gemini/out/evals/gemini_full_v10_15c_intervention_cis.json | 2 +- scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json index 6a6b2a3d..a6adb1bc 100644 --- a/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json @@ -129,4 +129,4 @@ "n_positive_gaps": 3639349 }, "tail_slice": null -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json index 84211bd8..d110a460 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] \ No newline at end of file +] diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json index efcf285e..be67255b 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] \ No newline at end of file +] diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json index 66eedf2b..96da3caa 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json @@ -13011,4 +13011,4 @@ "mean_activation": 0.4445817383871991 } ] -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json index f8b0699c..2a5636d7 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json @@ -543,4 +543,4 @@ }, "value_metrics": null, "tail_slice": null -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json index 41e55272..634f6db7 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json @@ -14428,4 +14428,4 @@ "sign_agreement": 1.0 } ] -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json index 7b04a88c..4c6dc770 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] \ No newline at end of file +] diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json index e559497c..80b3f7df 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json @@ -704,4 +704,4 @@ } } } -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json b/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json index 39b70901..0d486be5 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json @@ -219,4 +219,4 @@ } } } -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json index 2fb9ffb2..caf52962 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json @@ -383,4 +383,4 @@ }, "value_metrics": null, "tail_slice": null -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json b/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json index 7debd16e..033ff473 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json @@ -111,4 +111,4 @@ "n_boot": 2000 } } -} \ No newline at end of file +} diff --git a/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json b/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json index 6eba1956..d9622d0c 100644 --- a/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json @@ -129,4 +129,4 @@ "n_positive_gaps": 3639349 }, "tail_slice": null -} \ No newline at end of file +} From ab3368bc56dda0cfa537a741b3822755f8bf49c8 Mon Sep 17 00:00:00 2001 From: Amrit Krishnan Date: Fri, 11 Sep 2026 08:00:57 -0400 Subject: [PATCH 4/6] Fix CI: CodeQL path alerts, typos, and hooks on verbatim files - server: static files are served from a map built once at startup (static_files); a request is a dictionary lookup, so no filesystem path is ever built from request data. Clears the three CodeQL "uncontrolled data used in path expression" alerts. Symlinks out of the root and non-allowlisted extensions are still excluded, now at map-build time; tests cover both. - showcase: rename the `_thr` column and `thr` test argument, which the typos hook reads as "the". - pre-commit: no hook rewrites scripts/gemini/out/ (pre-commit.ci's end-of-file fixer had edited 12 verbatim GEMINI exports on this PR), and mypy/typos skip the two cohort producers committed verbatim as they ran. pyproject's mypy exclude never applied to them because pre-commit passes files by name; this is why main's code check has failed since 0de877f. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012zuuVqnBfTsozfaj5F4y3r --- .pre-commit-config.yaml | 12 +++-- _typos.toml | 8 +++- apps/clinician_demo/server.py | 53 ++++++++++++++-------- apps/clinician_demo/showcase.py | 6 +-- tests/apps/clinician_demo/test_server.py | 25 ++++++---- tests/apps/clinician_demo/test_showcase.py | 4 +- 6 files changed, 70 insertions(+), 38 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef7b5d02..757cd061 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,6 @@ -# Third-party LaTeX template files under paper/ are kept verbatim. -exclude: ^paper/(sn-jnl\.cls|sn-nature\.bst|template-.*|main\.pdf)$ +# Kept verbatim, never rewritten by hooks: third-party LaTeX template files +# under paper/, and scripts/gemini/out/ (GEMINI's own exported reports). +exclude: ^(paper/(sn-jnl\.cls|sn-nature\.bst|template-.*|main\.pdf)|scripts/gemini/out/.*)$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 # Use the ref you want to point at @@ -42,7 +43,10 @@ repos: entry: python3 -m mypy --config-file pyproject.toml language: system types: [python] - exclude: "tests" + # tests, plus the cohort producers committed verbatim as they ran + # (scripts/cohort/README.md); pyproject's mypy exclude does not apply + # to files pre-commit passes by name. + exclude: ^(tests/|scripts/cohort/cohort_check_(mimic|eicu)\.py$) - repo: https://github.com/crate-ci/typos rev: v1 @@ -56,7 +60,7 @@ repos: # etc.), not ours to rename; see _typos.toml for the same exclusion # (this one is what actually stops the hook, since pre-commit's own # file filtering runs before typos ever sees its own config) - exclude: ^(docs/experiments\.md|odyssey/data/resources/.*\.csv|scripts/gemini/out/.*)$ + exclude: ^(docs/experiments\.md|odyssey/data/resources/.*\.csv|scripts/gemini/out/.*|scripts/cohort/cohort_check_(mimic|eicu)\.py)$ - repo: https://github.com/nbQA-dev/nbQA rev: 1.9.1 diff --git a/_typos.toml b/_typos.toml index 7aa8f26a..b83c2498 100644 --- a/_typos.toml +++ b/_typos.toml @@ -37,4 +37,10 @@ get_rolling_window_indicies = "get_rolling_window_indicies" # ours to rename and will keep showing up as new false positives every time # a fresh report is committed, so the whole directory is excluded rather # than growing an extend-words entry per column. -extend-exclude = ["scripts/gemini/out/"] +# scripts/cohort/cohort_check_*.py are committed verbatim as they ran +# (scripts/cohort/README.md); their `evn` (eICU visit number) stays. +extend-exclude = [ + "scripts/gemini/out/", + "scripts/cohort/cohort_check_mimic.py", + "scripts/cohort/cohort_check_eicu.py", +] diff --git a/apps/clinician_demo/server.py b/apps/clinician_demo/server.py index 76576aee..e94d925e 100644 --- a/apps/clinician_demo/server.py +++ b/apps/clinician_demo/server.py @@ -18,12 +18,12 @@ import json import logging import re -from collections.abc import Callable +from collections.abc import Callable, Mapping from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Protocol -from urllib.parse import unquote, urlsplit +from urllib.parse import urlsplit from apps.clinician_demo.config import LOOPBACK_HOSTS from apps.clinician_demo.schemas import to_jsonable @@ -152,24 +152,36 @@ def _ids(match: re.Match[str]) -> tuple[int, int]: ] -def resolve_static(path: str, root: Path = STATIC_DIR) -> Path | None: - """Return the file under ``root`` a URL path names, or ``None`` if unservable. +def static_files(root: Path = STATIC_DIR) -> dict[str, Path]: + """Map every servable URL path to its file, built once from ``root``. - ``/`` is ``index.html``; everything else must live under ``/static/``, - resolve inside ``root`` (no ``..`` or symlink escapes), exist, and have - an allowlisted extension. + ``/`` and ``/index.html`` are ``index.html``; every other file is + ``/static/``. Only regular files with an + allowlisted extension that resolve inside ``root`` are included, so a + symlink pointing elsewhere is never served. Requests are answered by + looking a URL up in this map: no filesystem path is ever built from + request data. """ - if path in ("/", "/index.html"): - relative = "index.html" - elif path.startswith("/static/"): - relative = unquote(path[len("/static/") :]) - else: - return None root = root.resolve() - candidate = (root / relative).resolve() - if not candidate.is_relative_to(root) or not candidate.is_file(): - return None - return candidate if candidate.suffix in CONTENT_TYPES else None + files: dict[str, Path] = {} + for file in sorted(root.rglob("*")): + resolved = file.resolve() + if ( + not file.is_file() + or not resolved.is_relative_to(root) + or file.suffix not in CONTENT_TYPES + ): + continue + files[f"/static/{file.relative_to(root).as_posix()}"] = resolved + index = files.get("/static/index.html") + if index is not None: + files["/"] = files["/index.html"] = index + return files + + +def resolve_static(path: str, files: Mapping[str, Path]) -> Path | None: + """Return the file a URL path names, or ``None`` if it is not servable.""" + return files.get(path) def allowed_hosts(port: int) -> frozenset[str]: @@ -184,7 +196,7 @@ class DemoRequestHandler(BaseHTTPRequestHandler): server_version = "OdysseyDemo" sys_version = "" api: DemoAPI - static_root: Path = STATIC_DIR + static_files: Mapping[str, Path] def do_GET(self) -> None: # noqa: N802 -- stdlib hook name """Handle GET.""" @@ -274,7 +286,7 @@ def _read_body(self) -> dict[str, Any]: return body def _static(self, path: str) -> None: - file = resolve_static(path, self.static_root) + file = resolve_static(path, self.static_files) if file is None: raise HttpError(HTTPStatus.NOT_FOUND, "not found") data = file.read_bytes() @@ -320,7 +332,7 @@ def make_server( handler = type( "BoundDemoHandler", (DemoRequestHandler,), - {"api": api, "static_root": static_root}, + {"api": api, "static_files": static_files(static_root)}, ) server = ThreadingHTTPServer((host, port), handler) server.daemon_threads = True @@ -339,4 +351,5 @@ def make_server( "allowed_hosts", "make_server", "resolve_static", + "static_files", ] diff --git a/apps/clinician_demo/showcase.py b/apps/clinician_demo/showcase.py index 26bd3e77..a7dc11ca 100644 --- a/apps/clinician_demo/showcase.py +++ b/apps/clinician_demo/showcase.py @@ -85,7 +85,7 @@ def visit_stats_from_alert_rows( pl.col("visit_id").cast(pl.Int64), pl.col("event") .replace_strict(thresholds, return_dtype=pl.Float64) - .alias("_thr"), + .alias("_threshold"), ) if frame.height == 0: return empty_visit_stats() @@ -93,7 +93,7 @@ def visit_stats_from_alert_rows( time = pl.col("time_hours") last_off = time.filter(~on).max().fill_null(_NEVER) return ( - frame.with_columns((pl.col(hazard) >= pl.col("_thr")).alias("_on")) + frame.with_columns((pl.col(hazard) >= pl.col("_threshold")).alias("_on")) .sort("time_hours") .group_by("subject_id", "visit_id", "event", maintain_order=True) .agg( @@ -106,7 +106,7 @@ def visit_stats_from_alert_rows( time.max().alias("end_hours"), time.min().alias("start_hours"), pl.col(hazard).max().alias("max_risk"), - pl.col("_thr").first().alias("threshold"), + pl.col("_threshold").first().alias("threshold"), ) .with_columns( pl.col("positive").fill_null(value=False), diff --git a/tests/apps/clinician_demo/test_server.py b/tests/apps/clinician_demo/test_server.py index fca26181..225befc3 100644 --- a/tests/apps/clinician_demo/test_server.py +++ b/tests/apps/clinician_demo/test_server.py @@ -17,6 +17,7 @@ allowed_hosts, make_server, resolve_static, + static_files, ) from apps.clinician_demo.service import BadRequestError, NotFoundError @@ -247,21 +248,29 @@ def test_static_files_are_served_with_the_right_types( def test_resolve_static_containment(static_root: Path) -> None: - assert resolve_static("/", static_root) == (static_root / "index.html").resolve() - assert ( - resolve_static("/index.html", static_root) - == (static_root / "index.html").resolve() - ) - assert resolve_static("/static/js/app.js", static_root) is not None + files = static_files(static_root) + index = (static_root / "index.html").resolve() + assert resolve_static("/", files) == index + assert resolve_static("/index.html", files) == index + assert resolve_static("/static/index.html", files) == index + assert resolve_static("/static/js/app.js", files) is not None for bad in ( "/static/", "/static/js", "/static/../secret.json", - "/static/escape.json", + "/static/escape.json", # symlink out of the root + "/static/notes.txt", # extension not allowlisted "/other.js", "/static/missing.js", ): - assert resolve_static(bad, static_root) is None, bad + assert resolve_static(bad, files) is None, bad + + +def test_static_map_is_empty_without_an_index(tmp_path: Path) -> None: + (tmp_path / "a.css").write_text("x") + files = static_files(tmp_path) + assert set(files) == {"/static/a.css"} + assert resolve_static("/", files) is None @pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.10", "example.com"]) diff --git a/tests/apps/clinician_demo/test_showcase.py b/tests/apps/clinician_demo/test_showcase.py index 276fb112..d827e08c 100644 --- a/tests/apps/clinician_demo/test_showcase.py +++ b/tests/apps/clinician_demo/test_showcase.py @@ -32,7 +32,7 @@ def _row( # noqa: PLR0913 -- one keyword per stats column end: float, start: float = 0.0, max_risk: float = 0.5, - thr: float = 0.2, + threshold: float = 0.2, ) -> dict[str, object]: return { "subject_id": sid, @@ -44,7 +44,7 @@ def _row( # noqa: PLR0913 -- one keyword per stats column "end_hours": end, "start_hours": start, "max_risk": max_risk, - "threshold": thr, + "threshold": threshold, } From 35b5e9a5a5ee2a7666bc6c63da60a02da446ad29 Mon Sep 17 00:00:00 2001 From: Amrit Krishnan Date: Fri, 11 Sep 2026 08:01:15 -0400 Subject: [PATCH 5/6] Revert pre-commit.ci's edits to verbatim GEMINI exports This reverts commit 834822681af681eda29bcb26f0c0c83ea95f0e90, which added a trailing newline to 12 files under scripts/gemini/out/evals/. Those files are GEMINI's exported reports, kept byte-for-byte; the previous commit stops the hooks from touching them again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012zuuVqnBfTsozfaj5F4y3r --- scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json | 2 +- .../gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json | 2 +- scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json | 2 +- .../gemini/out/evals/gemini_full_v10_15c_counterfactual.json | 2 +- scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json | 2 +- .../gemini/out/evals/gemini_full_v10_15c_intervention_cis.json | 2 +- scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json index a6adb1bc..6a6b2a3d 100644 --- a/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_14m_v1_eval_forecast.json @@ -129,4 +129,4 @@ "n_positive_gaps": 3639349 }, "tail_slice": null -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json index d110a460..84211bd8 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] +] \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json index be67255b..efcf285e 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_allshards_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] +] \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json index 96da3caa..66eedf2b 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_concept_atlas.json @@ -13011,4 +13011,4 @@ "mean_activation": 0.4445817383871991 } ] -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json index 2a5636d7..f8b0699c 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_eval_forecast.json @@ -543,4 +543,4 @@ }, "value_metrics": null, "tail_slice": null -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json b/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json index 634f6db7..41e55272 100644 --- a/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json +++ b/scripts/gemini/out/evals/gemini_full_DEC_v12_steering_full.json @@ -14428,4 +14428,4 @@ "sign_agreement": 1.0 } ] -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json index 4c6dc770..7b04a88c 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts.json @@ -1913,4 +1913,4 @@ }, "landmark_protocol_version": 4 } -] +] \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json index 80b3f7df..e559497c 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_alerts_cis.json @@ -704,4 +704,4 @@ } } } -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json b/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json index 0d486be5..39b70901 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_counterfactual.json @@ -219,4 +219,4 @@ } } } -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json b/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json index caf52962..2fb9ffb2 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_eval_forecast.json @@ -383,4 +383,4 @@ }, "value_metrics": null, "tail_slice": null -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json b/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json index 033ff473..7debd16e 100644 --- a/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json +++ b/scripts/gemini/out/evals/gemini_full_v10_15c_intervention_cis.json @@ -111,4 +111,4 @@ "n_boot": 2000 } } -} +} \ No newline at end of file diff --git a/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json b/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json index d9622d0c..6eba1956 100644 --- a/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json +++ b/scripts/gemini/out/evals/gemini_smoke_2_eval_forecast.json @@ -129,4 +129,4 @@ "n_positive_gaps": 3639349 }, "tail_slice": null -} +} \ No newline at end of file From c4015246bd43a1f5101753fa57a21499c2e804af Mon Sep 17 00:00:00 2001 From: Amrit Krishnan Date: Fri, 11 Sep 2026 08:40:24 -0400 Subject: [PATCH 6/6] Redesign the clinician demo UI around a clinician's questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front end only; the API and Python are unchanged. - Light theme by default, dark via a footer toggle. One accent colour; event colours only on lines and dots. - Replay: patient one-liner, sticky "now" bar (play, scrub, clock time), five risk tiles with a status word (Low / Watch / Alert on / Happened) and "n× the average patient", then the chart, what happened in the stay, what the model believes now, and the what-if / evidence tools. - The chart fades everything after "now". - Alert stories are composed client-side in clock time ("Began Day 1, 22:53. The alert had been on since Day 1, 01:16: 22 h of warning"). - The 29-row concept strip, alert-line statistics and next-token list sit behind disclosures; model name and checkpoint move to the footer. - Gallery cards carry one key line; the full admission list is a collapsed per-patient list with a note on how many were in training. - Scorecard cells show one number per model, intervals on hover. - Fix: class display rules overrode the browser's [hidden] rule, so the search box showed in open mode and "Alert on now" showed on every row. - Runbook: static files can be copied over a running deployment. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QebToqtFMVakRkx3PhF3ZX --- apps/clinician_demo/static/index.html | 19 +- apps/clinician_demo/static/js/app.js | 15 +- .../static/js/charts/risk_chart.js | 114 +++-- apps/clinician_demo/static/js/format.js | 32 +- apps/clinician_demo/static/js/theme.js | 48 +- .../static/js/views/evidence.js | 14 +- .../clinician_demo/static/js/views/gallery.js | 132 +++--- .../clinician_demo/static/js/views/patient.js | 26 +- apps/clinician_demo/static/js/views/replay.js | 322 +++++++------ .../static/js/views/scorecard.js | 23 +- apps/clinician_demo/static/js/views/whatif.js | 10 +- apps/clinician_demo/static/styles.css | 428 +++++++++--------- docs/clinician_demo.md | 19 + 13 files changed, 690 insertions(+), 512 deletions(-) diff --git a/apps/clinician_demo/static/index.html b/apps/clinician_demo/static/index.html index 0a10e58b..83055c47 100644 --- a/apps/clinician_demo/static/index.html +++ b/apps/clinician_demo/static/index.html @@ -11,26 +11,27 @@
- + Odyssey + Bedside Forecast +
-
+ diff --git a/apps/clinician_demo/static/js/app.js b/apps/clinician_demo/static/js/app.js index 69b7a195..17caaa88 100644 --- a/apps/clinician_demo/static/js/app.js +++ b/apps/clinician_demo/static/js/app.js @@ -11,6 +11,7 @@ import { api } from './api.js'; import { el, errorBlock, loadingBlock } from './dom.js'; import { store } from './state.js'; +import { initTheme, isDark, toggleTheme } from './theme.js'; import { renderGallery } from './views/gallery.js'; import { renderPatient } from './views/patient.js'; import { renderReplay } from './views/replay.js'; @@ -77,15 +78,14 @@ async function render() { function fillChrome(meta) { document.getElementById('provenance').textContent = - `Model ${meta.run_name} · ${meta.checkpoint} · forecasts ${meta.events.length} events at ${meta.horizons.join(' / ')} h`; + `Model ${meta.run_name} (${meta.checkpoint}), forecasting ${meta.events.length} events at ` + + `${meta.horizons.join(', ')} hours. Times are hours since the start of each admission; dates are never shown.`; const badge = document.getElementById('mode-badge'); badge.hidden = false; badge.textContent = meta.data_mode === 'open' ? 'Open demo data' : 'Credentialed data · PhysioNet DUA'; badge.className = `mode-badge mode-badge--${meta.data_mode}`; const banner = document.getElementById('banner'); banner.textContent = [meta.disclaimers.banner, meta.disclaimers[meta.data_mode]].filter(Boolean).join(' '); - document.getElementById('footer').textContent = - 'Times are hours since the start of each admission. Dates are never shown.'; const search = document.getElementById('search'); search.hidden = !meta.searchable; search.addEventListener('submit', (event) => { @@ -95,7 +95,16 @@ function fillChrome(meta) { }); } +function wireTheme() { + const button = document.getElementById('theme-toggle'); + const label = () => { button.textContent = isDark() ? 'Light mode' : 'Dark mode'; }; + button.addEventListener('click', () => { toggleTheme(); label(); }); + label(); +} + async function start() { + initTheme(); + wireTheme(); main.replaceChildren(loadingBlock('Loading the model…')); try { const meta = await api.meta(); diff --git a/apps/clinician_demo/static/js/charts/risk_chart.js b/apps/clinician_demo/static/js/charts/risk_chart.js index a9b7874e..99be2f77 100644 --- a/apps/clinician_demo/static/js/charts/risk_chart.js +++ b/apps/clinician_demo/static/js/charts/risk_chart.js @@ -1,15 +1,18 @@ /** - * Risk-over-time chart (SVG): one line per event, its dashed alert line, - * onset markers, care-transition ticks, an optional GBM overlay, a scrub - * cursor, a hover tooltip and click-to-scrub. + * Risk-over-time chart (SVG): one line per event with its dashed alert + * line, onset markers, care-transition ticks, an optional GBM overlay, a + * scrub cursor, a hover tooltip and click-to-scrub. What lies after the + * cursor is drawn faded, so the eye stays on "now" and what led to it. */ import { el, svg } from '../dom.js'; import { clock, pct, nearestIndex } from '../format.js'; -const HEIGHT = 300; -const MARGIN = { left: 50, right: 18, top: 26, bottom: 30 }; +const HEIGHT = 280; +const MARGIN = { left: 48, right: 16, top: 26, bottom: 28 }; const Y_STEPS = [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.25]; +const FUTURE_OPACITY = 0.22; +let clipCounter = 0; function niceMax(maxValue) { const target = Math.min(1, Math.max(0.02, maxValue * 1.15)); @@ -60,11 +63,50 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm const tip = el('div', { class: 'chart-tip', hidden: true }); const wrap = el('div', { class: 'risk-chart' }, [root, tip]); container.append(wrap); + const clipId = `risk-clip-${++clipCounter}`; let data = null; let cursor = 0; let scale = null; let cursorLine = null; + let clipRect = null; + + function drawSeries(group, x, y, times, series, onsets, width) { + // Onset labels are stacked in rows so events that begin close together + // never print on top of each other; a label near the right edge flips + // to the left of its line. + const placed = []; + for (const o of onsets) { + const px = x(o.t); + const text = `${o.label} began`; + const w = text.length * 6.6 + 10; + const flip = px + w > width - MARGIN.right; + const span = flip ? [px - w, px] : [px, px + w]; + let row = 0; + while (placed.some((p) => p.row === row && span[0] < p.span[1] && span[1] > p.span[0])) row += 1; + placed.push({ row, span }); + group.append( + svg('line', { + x1: px, x2: px, y1: MARGIN.top, y2: HEIGHT - MARGIN.bottom, + stroke: o.color, 'stroke-width': 2, 'stroke-dasharray': '1 3', + }), + svg('text', { + class: 'onset-label', + x: flip ? px - 4 : px + 4, + y: MARGIN.top + 12 + row * 14, + 'text-anchor': flip ? 'end' : 'start', + fill: o.color, + text, + }), + ); + } + for (const s of series) { + group.append(svg('path', { + d: linePath(times, s.values, x, y), fill: 'none', stroke: s.color, + 'stroke-width': 2.2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', + })); + } + } function draw() { root.replaceChildren(); @@ -88,6 +130,9 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm const y = (v) => MARGIN.top + plotH - (Math.min(v, top) / top) * plotH; scale = { x, t0, t1, plotW }; + clipRect = svg('rect', { x: MARGIN.left, y: 0, width: 0, height: HEIGHT }); + root.append(svg('defs', {}, [svg('clipPath', { id: clipId }, [clipRect])])); + const grid = svg('g', { class: 'grid' }); const axis = svg('g', { class: 'axis' }); for (let v = 0; v <= top + 1e-9; v += step) { @@ -100,7 +145,7 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm const nearRight = x(t) > width - MARGIN.right - 30; axis.append(svg('text', { x: nearRight ? width - MARGIN.right : x(t), - y: HEIGHT - 10, + y: HEIGHT - 8, 'text-anchor': nearRight ? 'end' : 'middle', text: xTickLabel(t, xStep), })); @@ -123,43 +168,10 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm root.append( svg('line', { x1: MARGIN.left, x2: width - MARGIN.right, y1: y(s.threshold), y2: y(s.threshold), - stroke: s.color, 'stroke-width': 1, 'stroke-dasharray': '5 4', opacity: 0.55, - }, [svg('title', { text: `${s.label}: alert line ${pct(s.threshold)}` })]), - ); - } - - // Onset labels are stacked in rows so events that begin close together - // never print on top of each other; a label near the right edge flips - // to the left of its line. - const onsets = svg('g', { class: 'onset' }); - const placed = []; - const visible = (data.onsets ?? []) - .filter((o) => o.t != null && o.t >= t0 && o.t <= t1) - .sort((a, b) => a.t - b.t); - for (const o of visible) { - const px = x(o.t); - const text = `${o.label} began`; - const w = text.length * 6.6 + 10; - const flip = px + w > width - MARGIN.right; - const span = flip ? [px - w, px] : [px, px + w]; - let row = 0; - while (placed.some((p) => p.row === row && span[0] < p.span[1] && span[1] > p.span[0])) row += 1; - placed.push({ row, span }); - onsets.append( - svg('line', { - x1: px, x2: px, y1: MARGIN.top, y2: MARGIN.top + plotH, - stroke: o.color, 'stroke-width': 2, 'stroke-dasharray': '1 3', - }), - svg('text', { - x: flip ? px - 4 : px + 4, - y: MARGIN.top + 12 + row * 14, - 'text-anchor': flip ? 'end' : 'start', - fill: o.color, - text, - }), + stroke: s.color, 'stroke-width': 1, 'stroke-dasharray': '5 4', opacity: 0.5, + }, [svg('title', { text: `${s.label}: alert line at ${pct(s.threshold)}` })]), ); } - root.append(onsets); if (data.overlay && data.overlay.points.length) { const pts = data.overlay.points.filter((p) => p.v != null && p.t >= t0 && p.t <= t1); @@ -173,12 +185,14 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm root.append(g); } - for (const s of series) { - root.append(svg('path', { - d: linePath(times, s.values, x, y), fill: 'none', stroke: s.color, - 'stroke-width': 2.2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', - })); - } + const onsets = (data.onsets ?? []) + .filter((o) => o.t != null && o.t >= t0 && o.t <= t1) + .sort((a, b) => a.t - b.t); + const future = svg('g', { class: 'onset', opacity: FUTURE_OPACITY }); + const past = svg('g', { class: 'onset', 'clip-path': `url(#${clipId})` }); + drawSeries(future, x, y, times, series, onsets, width); + drawSeries(past, x, y, times, series, onsets, width); + root.append(future, past); cursorLine = svg('line', { class: 'cursor', y1: MARGIN.top - 4, y2: MARGIN.top + plotH }); root.append(cursorLine); @@ -192,8 +206,10 @@ export function createRiskChart(container, { onScrub, label = 'Risk over the adm function positionCursor() { if (!cursorLine || !scale || !data) return; const t = data.times[Math.min(cursor, data.times.length - 1)]; - cursorLine.setAttribute('x1', String(scale.x(t))); - cursorLine.setAttribute('x2', String(scale.x(t))); + const px = scale.x(t); + cursorLine.setAttribute('x1', String(px)); + cursorLine.setAttribute('x2', String(px)); + clipRect.setAttribute('width', String(Math.max(0, px - MARGIN.left + 1))); } function indexAt(event) { diff --git a/apps/clinician_demo/static/js/format.js b/apps/clinician_demo/static/js/format.js index 9f01fd17..e9af743e 100644 --- a/apps/clinician_demo/static/js/format.js +++ b/apps/clinician_demo/static/js/format.js @@ -1,6 +1,6 @@ /** * Formatting for clinicians: percentages, visit clock times, durations, - * "times typical" wording and trend arrows. Pure functions, no DOM. + * "times the average patient" wording and trend arrows. Pure functions. */ /** @@ -28,7 +28,7 @@ export function points(d) { } /** - * Visit-relative hours as a clock: 30.5 -> "Day 2 · 06:30" (hour 0 = admission). + * Visit-relative hours as a clock: 30.5 -> "Day 2, 06:30" (hour 0 = admission). * @param {number|null|undefined} h * @returns {string} */ @@ -43,7 +43,7 @@ export function clock(h) { } const hh = String(Math.floor(minutes / 60)).padStart(2, '0'); const mm = String(minutes % 60).padStart(2, '0'); - return `Day ${day + 1} · ${hh}:${mm}`; + return `Day ${day + 1}, ${hh}:${mm}`; } /** @@ -59,7 +59,18 @@ export function duration(h) { } /** - * How a risk compares with the typical at-risk patient: "3.4× typical". + * "57-year-old man" from age and sex, with graceful gaps. + * @param {number|null|undefined} age + * @param {string|null|undefined} sex + * @returns {string} + */ +export function ageSex(age, sex) { + const who = { M: 'man', F: 'woman' }[String(sex ?? '').toUpperCase()] ?? (sex ? `sex ${sex}` : 'patient'); + return age != null ? `${Math.round(age)}-year-old ${who}` : who; +} + +/** + * How a risk compares with the average at-risk patient: "3.4× the average patient". * @param {number|null|undefined} p * @param {number|null|undefined} base * @returns {string|null} @@ -67,10 +78,9 @@ export function duration(h) { export function timesTypical(p, base) { if (p == null || !base) return null; const ratio = p / base; - if (ratio < 0.1) return 'far below typical'; - if (ratio < 0.95) return `${ratio.toFixed(1)}× typical (lower)`; - if (ratio <= 1.05) return 'about typical'; - return ratio >= 10 ? `${Math.round(ratio)}× typical` : `${ratio.toFixed(1)}× typical`; + if (ratio < 0.5) return 'below the average patient'; + if (ratio <= 1.5) return 'about the average patient'; + return `${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)}× the average patient`; } /** @@ -84,10 +94,10 @@ export function trend(now, before, hours) { if (now == null || before == null) return { arrow: '', label: '', dir: 0 }; const d = now - before; const rel = before > 0 ? Math.abs(d) / before : Math.abs(d) > 0 ? Infinity : 0; - if (Math.abs(d) < 0.002 || rel < 0.1) return { arrow: '→', label: `steady over ${hours} h`, dir: 0 }; + if (Math.abs(d) < 0.002 || rel < 0.1) return { arrow: '→', label: `Steady over the last ${hours} h`, dir: 0 }; return d > 0 - ? { arrow: '↑', label: `${points(d)} in ${hours} h`, dir: 1 } - : { arrow: '↓', label: `${points(d)} in ${hours} h`, dir: -1 }; + ? { arrow: '↑', label: `Up ${points(d).slice(1)} in the last ${hours} h`, dir: 1 } + : { arrow: '↓', label: `Down ${points(d).slice(1)} in the last ${hours} h`, dir: -1 }; } /** diff --git a/apps/clinician_demo/static/js/theme.js b/apps/clinician_demo/static/js/theme.js index c152a40d..93ccdf24 100644 --- a/apps/clinician_demo/static/js/theme.js +++ b/apps/clinician_demo/static/js/theme.js @@ -1,8 +1,12 @@ /** - * Read theme colours from CSS custom properties, so charts drawn in SVG - * and canvas follow the light/dark palette defined in styles.css. + * Theme: light by default (the look clinicians know from the chart), dark + * on request. Charts drawn in SVG and canvas read their colours from the + * CSS custom properties defined in styles.css, so they follow the theme. */ +const STORAGE_KEY = 'odyssey-demo-theme'; +const EVENT = 'odyssey-themechange'; + /** * The current value of a CSS custom property on :root. * @param {string} name e.g. "--accent" @@ -36,13 +40,45 @@ export function hexToRgb(hex) { return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } +/** @returns {boolean} whether the dark theme is on */ +export function isDark() { + return document.documentElement.dataset.theme === 'dark'; +} + +function apply(dark) { + if (dark) document.documentElement.dataset.theme = 'dark'; + else delete document.documentElement.dataset.theme; + document.dispatchEvent(new CustomEvent(EVENT)); +} + +/** Restore the viewer's theme choice (only the choice is stored, never data). */ +export function initTheme() { + let stored = null; + try { + stored = localStorage.getItem(STORAGE_KEY); + } catch { + stored = null; + } + apply(stored === 'dark'); +} + +/** Switch between light and dark and remember the choice. */ +export function toggleTheme() { + const dark = !isDark(); + apply(dark); + try { + localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); + } catch { + /* storage may be unavailable; the theme still switches for this page */ + } +} + /** - * Run fn whenever the OS light/dark preference changes. + * Run fn whenever the theme changes. * @param {() => void} fn * @returns {() => void} unsubscribe */ export function onThemeChange(fn) { - const query = window.matchMedia('(prefers-color-scheme: dark)'); - query.addEventListener('change', fn); - return () => query.removeEventListener('change', fn); + document.addEventListener(EVENT, fn); + return () => document.removeEventListener(EVENT, fn); } diff --git a/apps/clinician_demo/static/js/views/evidence.js b/apps/clinician_demo/static/js/views/evidence.js index 666fb292..4c937c10 100644 --- a/apps/clinician_demo/static/js/views/evidence.js +++ b/apps/clinician_demo/static/js/views/evidence.js @@ -32,14 +32,14 @@ function resultRow(item, maxAbs) { function doneView(job, targetLabel) { const items = job.result; - if (!items.length) return emptyBlock('No recent recorded event moved this forecast.'); + if (!items.length) return emptyBlock('Nothing charted recently moved this forecast.'); const maxAbs = Math.max(...items.map((i) => Math.abs(i.delta))); return el('div', { class: 'panel' }, [ el('div', { class: 'muted' }, [ `${targetLabel}: ${pct(items[0].baseline)} as recorded. `, - 'Each bar shows how the forecast would change if that event had not been recorded.', + 'Each bar shows how the forecast would change if that item had not been charted.', ]), - job.note ? el('div', { class: 'faint', text: job.note }) : null, + job.note ? el('div', { class: 'muted', text: job.note }) : null, el('ol', { class: 'evidence-list' }, items.map((i) => resultRow(i, maxAbs))), ]); } @@ -55,14 +55,14 @@ export function createEvidencePanel(container, { meta, sid, vid, getT, events, o let destroyed = false; let timer = null; const select = el('select', { 'aria-label': 'What to explain' }, [ - el('optgroup', { label: 'Risk within 24 h' }, events.map((e) => el('option', { value: `event:${e.name}`, text: e.display }))), + el('optgroup', { label: 'Risk in the next 24 h' }, events.map((e) => el('option', { value: `event:${e.name}`, text: e.display }))), el('optgroup', { label: 'What the model thinks is going on' }, meta.concepts.map((c) => el('option', { value: `concept:${c.name}`, text: c.display }))), ]); - const runBtn = el('button', { class: 'btn', type: 'button', text: 'Explain this moment' }); + const runBtn = el('button', { class: 'btn', type: 'button', text: 'Explain' }); const out = el('div'); const panel = el('div', { class: 'panel' }, [ el('div', { class: 'note', text: meta.disclaimers.evidence }), - el('div', { class: 'panel__controls' }, [select, runBtn, el('span', { class: 'faint', text: `Looks at the last ${LOOKBACK_HOURS} h.` })]), + el('div', { class: 'panel__controls' }, [select, runBtn, el('span', { class: 'muted', text: `Looks at what was charted in the last ${LOOKBACK_HOURS} h.` })]), out, ]); container.replaceChildren(panel); @@ -119,7 +119,7 @@ export function createEvidencePanel(container, { meta, sid, vid, getT, events, o try { const job = await api.evidence(sid, vid, { t_hours: t, target, lookback_hours: LOOKBACK_HOURS }); if (destroyed) return; - out.prepend(el('div', { class: 'faint', text: `Explaining ${label} at ${clock(t)}.` })); + out.prepend(el('div', { class: 'muted', text: `Explaining ${label} at ${clock(t)}.` })); poll(job.job_id, label); } catch (err) { if (!destroyed) { diff --git a/apps/clinician_demo/static/js/views/gallery.js b/apps/clinician_demo/static/js/views/gallery.js index 810406fd..f100e1d4 100644 --- a/apps/clinician_demo/static/js/views/gallery.js +++ b/apps/clinician_demo/static/js/views/gallery.js @@ -1,6 +1,8 @@ /** - * Gallery: curated visits, grouped into early warnings, quiet stays, - * misses and false alarms. Every section shows its honest summary line. + * Gallery: curated admissions grouped by what the model did (warned in + * time, stayed quiet, missed, or raised a false alarm), then the full + * patient list behind a disclosure. Every section says how often its + * pattern happens, so a hand-picked case is never mistaken for the norm. */ import { api } from '../api.js'; @@ -10,11 +12,10 @@ import { eventInfo } from '../meta.js'; import { eventColor } from '../theme.js'; const INTRO = { - early_warning: 'The event happened, and the alert had already been on for hours when it began.', - quiet: 'No event happened, and the risk stayed low. The model does not flag everyone.', - miss: 'The event happened with no alert on at that moment.', - false_alarm: 'The alert came on, but the event never happened during the stay.', - other: 'Every patient in this dataset.', + early_warning: 'The event happened, and the alert had been on for hours before it began.', + quiet: 'Nothing happened, and the risk stayed low the whole stay.', + miss: 'The event happened while no alert was on.', + false_alarm: 'The alert came on, but the event never happened.', }; /** @@ -26,8 +27,8 @@ export function trainingBadge(seen) { return seen ? el('span', { class: 'pill pill--training', - title: 'The model saw this patient during training. Its forecasts here are not a fair test.', - text: 'Seen in training', + title: 'This patient was in the model’s training data, so its forecasts here are not a fair test.', + text: 'In training data', }) : el('span', { class: 'pill pill--heldout', title: 'The model never saw this patient.', text: 'Unseen patient' }); } @@ -36,52 +37,77 @@ function caseHref(c) { return `#/p/${c.subject_id}/v/${c.visit_id}`; } -function caseCard(meta, c) { - const color = c.event ? eventColor(c.event) : null; - const chips = [ - c.event - ? el('span', { class: 'pill pill--event', style: { background: color }, text: eventInfo(meta, c.event).short }) - : null, - c.lead_hours != null ? el('span', { class: 'pill', text: `${c.lead_approximate ? '≈' : ''}${Math.round(c.lead_hours)} h of warning` }) : null, - el('span', { text: `Stay ${duration(c.los_hours)}` }), - trainingBadge(c.seen_in_training), - ]; - return el( - 'a', - { class: 'card case-card', href: caseHref(c), style: color ? { '--case-color': color } : {} }, - [ - el('div', { class: 'case-card__headline', text: c.headline }), - el('div', { class: 'case-card__meta' }, chips), - el('div', { class: 'case-card__id', text: `Patient ${c.subject_id} · visit ${c.visit_id}` }), - ], - ); +function keyLine(c) { + const lead = c.lead_hours != null ? `${c.lead_approximate ? 'About ' : ''}${Math.round(c.lead_hours)} h of warning` : ''; + switch (c.kind) { + case 'early_warning': return lead || 'Alert on before it began'; + case 'quiet': return `Risk stayed low for ${duration(c.los_hours)}`; + case 'miss': return 'Began with no alert on'; + case 'false_alarm': return 'Alert came on; it never happened'; + default: return c.headline; + } } -function compactList(c) { - return el('li', {}, [ - el('a', { href: caseHref(c), text: `Patient ${c.subject_id}` }), - ' ', - el('span', { class: 'muted', text: `· ${c.headline} · ${duration(c.los_hours)}` }), - c.seen_in_training ? el('span', { class: 'faint', text: ' · seen in training' }) : null, +function caseCard(meta, c) { + const info = c.event ? eventInfo(meta, c.event) : null; + return el('a', { class: 'case', href: caseHref(c), title: c.headline }, [ + el('div', { class: 'case__event' }, [ + info ? el('span', { class: 'dot', style: { background: eventColor(c.event) } }) : null, + info ? info.display : 'No event', + ]), + el('div', { class: 'case__key', text: keyLine(c) }), + el('div', { class: 'case__meta' }, [ + `Patient ${c.subject_id} · ${duration(c.los_hours)} stay`, + c.seen_in_training ? null : el('span', { class: 'case__unseen', text: ' · unseen patient' }), + ]), ]); } function section(meta, s) { const body = !s.cases.length - ? emptyBlock('No visit matched this rule.') - : s.kind === 'other' - ? el('ul', { class: 'compact-list' }, s.cases.map(compactList)) - : el('div', { class: 'case-grid' }, s.cases.map((c) => caseCard(meta, c))); + ? el('p', { class: 'muted', text: 'No admission in this data matched.' }) + : el('div', { class: 'case-grid' }, s.cases.map((c) => caseCard(meta, c))); return el('section', { class: 'section', 'aria-labelledby': `sec-${s.kind}` }, [ el('div', { class: 'section__head' }, [ el('h2', { id: `sec-${s.kind}`, text: s.title }), - el('span', { class: 'section__kind', text: `${s.cases.length} shown` }), + el('span', { class: 'section__intro', text: INTRO[s.kind] ?? '' }), ]), - el('p', { class: 'section__summary' }, [INTRO[s.kind] ? `${INTRO[s.kind]} ` : '', el('strong', { text: s.summary })]), body, + el('p', { class: 'section__rate', text: `How often: ${s.summary}.` }), ]); } +function allPatients(s) { + const byPatient = new Map(); + for (const c of s.cases) { + const entry = byPatient.get(c.subject_id) ?? { sid: c.subject_id, n: 0, seen: c.seen_in_training }; + entry.n += 1; + byPatient.set(c.subject_id, entry); + } + const rows = [...byPatient.values()].sort((a, b) => a.sid - b.sid); + return el('details', { class: 'section all-patients' }, [ + el('summary', {}, [ + el('span', { class: 'all-patients__title', text: `All ${rows.length} patients` }), + el('span', { class: 'muted', text: ` · ${s.cases.length} admissions` }), + ]), + el('ul', { class: 'patient-list' }, rows.map((r) => el('li', {}, [ + el('a', { href: `#/p/${r.sid}`, text: `Patient ${r.sid}` }), + el('span', { class: 'muted', text: ` · ${r.n} admission${r.n === 1 ? '' : 's'}` }), + r.seen ? null : el('span', { class: 'case__unseen', text: ' · unseen' }), + ]))), + ]); +} + +function trainingNote(other) { + if (!other) return null; + const subjects = new Map(other.cases.map((c) => [c.subject_id, c.seen_in_training])); + const seen = [...subjects.values()].filter(Boolean).length; + if (!seen) return null; + return el('p', { class: 'note', text: + `${seen} of the ${subjects.size} patients here were in the model’s training data. ` + + 'Only the ones marked "unseen" are a fair test of the model.' }); +} + /** * Render the gallery page. * @param {HTMLElement} root @@ -97,19 +123,21 @@ export async function renderGallery(root, { meta }) { root.replaceChildren(errorBlock(err)); return () => {}; } + const other = gallery.sections.find((s) => s.kind === 'other'); + const curated = gallery.sections.filter((s) => s.kind !== 'other'); const head = el('div', { class: 'page-head' }, [ - el('div', {}, [ - el('h1', { text: 'Replay a real admission' }), - el('p', { - text: - 'Pick a stay. The model reads the chart one event at a time, as it was written, and forecasts ' + - 'what happens next. It never sees the future. We show the hits and the misses.', - }), - ]), + el('h1', { text: 'Pick an admission to replay' }), + el('p', { + text: + 'The model reads the chart one entry at a time, as it was written, and forecasts what happens next. ' + + 'It never sees the future. Hits and misses are both shown.', + }), + trainingNote(other), ]); - const sections = gallery.sections.length - ? gallery.sections.map((s) => section(meta, s)) - : [emptyBlock('No patients are available.')]; - root.replaceChildren(head, ...sections); + root.replaceChildren( + head, + ...(curated.length ? curated.map((s) => section(meta, s)) : [emptyBlock('No patients are available.')]), + other ? allPatients(other) : null, + ); return () => {}; } diff --git a/apps/clinician_demo/static/js/views/patient.js b/apps/clinician_demo/static/js/views/patient.js index c368e15f..e7538f66 100644 --- a/apps/clinician_demo/static/js/views/patient.js +++ b/apps/clinician_demo/static/js/views/patient.js @@ -1,10 +1,10 @@ /** - * Patient page: header facts and the list of admissions to replay. + * Patient page: who they are and the list of admissions to replay. */ import { api } from '../api.js'; import { el, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; -import { duration } from '../format.js'; +import { ageSex, duration } from '../format.js'; import { trainingBadge } from './gallery.js'; /** @@ -22,27 +22,21 @@ export async function renderPatient(root, { sid }) { root.replaceChildren(errorBlock(err)); return () => {}; } - const facts = [ - patient.age_years != null ? `${Math.round(patient.age_years)} years` : null, - patient.sex ? `Sex ${patient.sex}` : null, - `${patient.visits.length} admission${patient.visits.length === 1 ? '' : 's'}`, - ].filter(Boolean); + const n = patient.visits.length; const rows = patient.visits.map((v) => - el('a', { class: 'card visit-row', href: `#/p/${patient.subject_id}/v/${v.visit_id}` }, [ + el('a', { class: 'visit-row', href: `#/p/${patient.subject_id}/v/${v.visit_id}` }, [ el('div', { class: 'grow' }, [ - el('div', { class: 'case-card__headline', text: v.admission }), - el('div', { class: 'muted', text: `Stay ${duration(v.end_hours - v.start_hours)} · ${v.n_events} recorded events` }), + el('div', { class: 'visit-row__title', text: v.admission }), + el('div', { class: 'muted', text: `${duration(v.end_hours - v.start_hours)} stay · ${v.n_events.toLocaleString()} chart entries` }), ]), - el('span', { class: 'btn btn--small btn--ghost', text: 'Replay →' }), + el('span', { class: 'btn btn--small btn--ghost', text: 'Replay' }), ]), ); root.replaceChildren( + el('a', { href: '#/', class: 'backlink', text: '← All patients' }), el('div', { class: 'page-head' }, [ - el('div', {}, [ - el('h1', { text: `Patient ${patient.subject_id}` }), - el('p', { text: facts.join(' · ') }), - ]), - trainingBadge(patient.seen_in_training), + el('h1', {}, [`Patient ${patient.subject_id}`, ' ', trainingBadge(patient.seen_in_training)]), + el('p', { text: `${ageSex(patient.age_years, patient.sex)} · ${n} admission${n === 1 ? '' : 's'}` }), ]), rows.length ? el('div', { class: 'visit-list' }, rows) : emptyBlock('This patient has no admissions.'), ); diff --git a/apps/clinician_demo/static/js/views/replay.js b/apps/clinician_demo/static/js/views/replay.js index b80256f2..ab05b80d 100644 --- a/apps/clinician_demo/static/js/views/replay.js +++ b/apps/clinician_demo/static/js/views/replay.js @@ -1,12 +1,14 @@ /** * Replay: the hero screen. Scrub (or play) through one admission and see, - * at every moment, the model's risks, its alert crossings, what it thinks - * is going on, what it expects next, and what was just recorded. + * at every moment, the risk of each event, whether an alert is on, what + * the model thinks is going on, and what was just charted. Answers a + * clinician's questions in order: how is this patient right now, how did + * we get here, what happened in the end, and why does the model say so. */ import { api } from '../api.js'; import { el, card, loadingBlock, errorBlock, emptyBlock } from '../dom.js'; -import { clamp, clock, duration, indexAtOrBefore, pct, timesTypical, trend } from '../format.js'; +import { ageSex, clamp, clock, duration, indexAtOrBefore, pct, timesTypical, trend } from '../format.js'; import { eventInfo, horizonKey, operatingPoint } from '../meta.js'; import { eventColor, onThemeChange } from '../theme.js'; import { createRiskChart } from '../charts/risk_chart.js'; @@ -21,6 +23,16 @@ const RECENT_MAX = 40; const PLAY_MS = 300; const PLAY_TICKS = 150; // a whole visit plays in about 45 s const URL_THROTTLE_MS = 400; +const ALERT_HORIZON = 24; +const CONCEPT_SHOW = 0.5; +const CONCEPT_MAX = 8; + +const STATUS = { + alert: 'Alert on', + watch: 'Watch', + low: 'Low', + none: '', +}; function level(p, threshold) { if (p == null || threshold == null) return 'none'; @@ -29,22 +41,21 @@ function level(p, threshold) { return r >= 0.5 ? 'watch' : 'low'; } -function buildCard(meta, event) { +function buildTile(meta, event) { + const info = eventInfo(meta, event); const nodes = { - value: el('div', { class: 'risk-card__value' }), - context: el('div', { class: 'risk-card__context' }), - minor: el('div', { class: 'risk-card__minor' }), - line: el('div', { class: 'risk-card__line' }), + value: el('div', { class: 'tile__value' }), + status: el('div', { class: 'tile__status' }), + context: el('div', { class: 'tile__context' }), }; - const info = eventInfo(meta, event); const root = el('article', { - class: 'card risk-card', - style: { '--card-color': eventColor(event) }, + class: 'tile', + style: { '--tile-color': eventColor(event) }, 'aria-live': 'polite', title: info.definition, }, [ - el('div', { class: 'risk-card__name' }, [el('span', { class: 'dot', style: { background: eventColor(event) } }), info.display]), - nodes.value, nodes.context, nodes.minor, nodes.line, + el('div', { class: 'tile__name' }, [el('span', { class: 'dot', style: { background: eventColor(event) } }), info.display]), + nodes.value, nodes.status, nodes.context, ]); return { root, nodes, event }; } @@ -60,6 +71,25 @@ function recentEntries(timeline, t) { return out; } +/** Plain words for what the alert line did on this visit, in visit clock time. */ +function alertStory(a) { + const onset = a.onset_hours; + const start = a.alert_start_hours; + const cross = a.first_cross_hours; + if (onset != null) { + const began = `Began ${clock(onset)}.`; + if (start != null) { + const lead = onset - start; + if (lead < 1) return { text: `${began} The alert came on just before it.`, kind: 'warned' }; + return { text: `${began} The alert had been on since ${clock(start)}: ${Math.round(lead)} h of warning.`, kind: 'warned' }; + } + if (cross != null) return { text: `${began} The alert came on at ${clock(cross)} but was off again by then.`, kind: 'miss' }; + return { text: `${began} The risk never reached the alert line: a miss.`, kind: 'miss' }; + } + if (cross != null) return { text: `Did not happen. The alert came on at ${clock(cross)}: a false alarm.`, kind: 'false' }; + return { text: 'Did not happen, and the risk stayed below the alert line.', kind: 'quiet' }; +} + /** * Render the replay view. * @param {HTMLElement} root @@ -85,8 +115,9 @@ export async function renderReplay(root, { meta, sid, vid, t }) { const cleanups = []; const events = meta.events.filter((e) => trace.risk[e.name]); const timeline = [...trace.timeline].sort((a, b) => a.t - b.t); + const last = times[times.length - 1]; let index = t != null ? indexAtOrBefore(times, t) : 0; - let horizon = 24; + let horizon = ALERT_HORIZON; let hidden = new Set(); let overlayEvent = ''; let playTimer = null; @@ -94,102 +125,126 @@ export async function renderReplay(root, { meta, sid, vid, t }) { // ---- header const visit = trace.visit; - const facts = [ - patient.age_years != null ? ['Age', `${Math.round(patient.age_years)}`] : null, - patient.sex ? ['Sex', patient.sex] : null, - ['Admission', visit.admission], - ['Stay', duration(visit.end_hours - visit.start_hours)], - ['Recorded events', String(visit.n_events)], - ].filter(Boolean); const header = el('div', { class: 'patient-head' }, [ - el('a', { href: '#/', class: 'btn btn--ghost btn--small', text: '← Patients' }), - el('h1', { text: `Patient ${trace.subject_id}` }), - el('div', { class: 'patient-head__facts' }, facts.map(([k, v]) => el('span', {}, [`${k} `, el('strong', { text: v })]))), - trainingBadge(trace.seen_in_training), + el('a', { href: '#/', class: 'backlink', text: '← All patients' }), + el('h1', {}, [`Patient ${trace.subject_id}`, ' ', trainingBadge(trace.seen_in_training)]), + el('p', { class: 'patient-head__facts', text: + `${ageSex(patient.age_years, patient.sex)} · ${visit.admission} · ` + + `${duration(visit.end_hours - visit.start_hours)} stay · ${visit.n_events.toLocaleString()} chart entries` }), ]); - // ---- moment + cards - const momentTime = el('span', { class: 'moment__time' }); - const momentNote = el('span', { class: 'muted' }); - const cards = events.map((e) => buildCard(meta, e.name)); - const cardsRow = el('div', { class: 'risk-cards' }, cards.map((c) => c.root)); - - // ---- callouts - const alerts = [...trace.alerts].sort((a, b) => (b.lead_hours ?? -1) - (a.lead_hours ?? -1)); - const calloutItems = alerts.map((a) => - el('li', { - class: `callout ${a.lead_hours != null ? 'callout--lead' : ''}`, - style: { '--callout-color': eventColor(a.event) }, - dataset: { event: a.event }, - }, [ - el('span', { class: 'callout__tag', text: eventInfo(meta, a.event).short }), - el('span', { class: 'callout__body' }, [ - el('span', { text: a.callout }), - a.detail ? el('span', { class: 'callout__detail', text: a.detail }) : null, - ]), - ]), - ); - const calloutCard = card('Alerts in this stay', calloutItems.length - ? el('ul', { class: 'callouts' }, calloutItems) - : el('div', { class: 'muted', text: 'No alert line was crossed during this admission.' }), { - sub: 'Outlined red while that alert is on at the moment shown', + // ---- "now" bar + const nowTime = el('span', { class: 'now__time' }); + const nowSub = el('span', { class: 'now__sub' }); + const range = el('input', { + type: 'range', min: 0, max: times.length - 1, step: 1, value: index, + 'aria-label': 'Time in the admission. Use the arrow keys to step; hold Shift for bigger steps.', }); + const playBtn = el('button', { class: 'btn play-btn', type: 'button', text: '▶ Play' }); + const nowBar = el('div', { class: 'now' }, [ + playBtn, + el('div', { class: 'now__label' }, [el('span', { class: 'now__caption', text: 'Now' }), nowTime, nowSub]), + range, + el('span', { class: 'now__end', text: `Discharge ${clock(last)}` }), + ]); + + // ---- risk tiles + const tiles = events.map((e) => buildTile(meta, e.name)); + const segmented = el('div', { class: 'segmented', role: 'group', 'aria-label': 'Forecast horizon' }); + const tilesSection = el('section', { class: 'tiles' }, [ + el('div', { class: 'tiles__head' }, [ + el('h2', { text: 'Risk in the next' }), + segmented, + el('span', { class: 'info', title: meta.disclaimers.risk, text: 'ⓘ' }), + ]), + el('div', { class: 'tile-row' }, tiles.map((c) => c.root)), + ]); // ---- chart const chartHost = el('div'); const legend = el('div', { class: 'legend', role: 'group', 'aria-label': 'Show or hide events' }); - const segmented = el('div', { class: 'segmented', role: 'group', 'aria-label': 'Forecast horizon' }); const overlaySelect = trace.banked?.length - ? el('select', { 'aria-label': 'Compare with the tuned GBM' }, [ - el('option', { value: '', text: 'No GBM overlay' }), + ? el('select', { class: 'select--small', 'aria-label': 'Compare with the tuned GBM' }, [ + el('option', { value: '', text: 'Compare with the tuned GBM…' }), ...events.map((e) => el('option', { value: e.name, text: `GBM: ${e.display}` })), ]) : null; const chartTitle = el('h2'); - const chartCard = card(chartTitle, [el('div', { class: 'card__head' }, [legend, el('span', { class: 'spacer' }), overlaySelect]), chartHost], { - actions: segmented, + const chartCard = card(chartTitle, chartHost, { + sub: 'Dashed lines are the alert lines. What comes after "now" is faded.', + actions: el('span', { class: 'legend-wrap' }, [legend, overlaySelect]), }); - // ---- scrubber - const range = el('input', { - type: 'range', min: 0, max: times.length - 1, step: 1, value: index, - 'aria-label': 'Time in the admission. Use the arrow keys to step; hold Shift for bigger steps.', + // ---- what happened + const alerts = [...trace.alerts].sort((a, b) => { + const ao = a.onset_hours ?? Infinity; + const bo = b.onset_hours ?? Infinity; + return ao - bo || (b.lead_hours ?? -1) - (a.lead_hours ?? -1); }); - const playBtn = el('button', { class: 'btn play-btn', type: 'button', text: '▶ Play' }); - const scrubLabel = el('span', { class: 'scrubber__label', text: `${clock(times[0])} → ${clock(times[times.length - 1])}` }); - const scrubber = el('div', { class: 'card scrubber' }, [playBtn, range, scrubLabel]); - - // ---- concepts + const liveTags = new Map(); + const storyRows = alerts.map((a) => { + const story = alertStory(a); + const live = el('span', { class: 'pill pill--alert', text: 'Alert on now', hidden: true }); + liveTags.set(a.event, live); + return el('li', { class: `story story--${story.kind}` }, [ + el('span', { class: 'story__event' }, [ + el('span', { class: 'dot', style: { background: eventColor(a.event) } }), + eventInfo(meta, a.event).display, + ]), + el('span', { class: 'story__text', text: story.text }), + live, + ]); + }); + const reliability = alerts.filter((a) => a.detail).map((a) => el('li', {}, [ + el('strong', { text: `${eventInfo(meta, a.event).display}. ` }), a.detail, + ])); + const storyCard = card('What happened in this stay', [ + storyRows.length + ? el('ul', { class: 'stories' }, storyRows) + : el('p', { class: 'muted', text: 'No alert line was crossed during this admission.' }), + reliability.length + ? el('details', { class: 'more' }, [ + el('summary', { text: 'How reliable are these alert lines?' }), + el('ul', { class: 'notes' }, reliability), + ]) + : null, + ], { sub: `Alert lines are set on the ${ALERT_HORIZON}-hour risk` }); + + // ---- what the model thinks is going on + const conceptList = el('ul', { class: 'belief-list', 'aria-live': 'polite' }); const conceptHost = el('div'); - const conceptChips = el('div', { class: 'concept-chips', 'aria-live': 'polite' }); const conceptCard = card('What the model thinks is going on', [ - el('div', { class: 'faint', text: meta.disclaimers.concepts }), - conceptChips, - conceptHost, - ], { sub: 'Darker = more likely during this admission' }); + conceptList, + el('details', { class: 'more' }, [ + el('summary', { text: `All ${meta.concepts.length} conditions over the stay` }), + el('p', { class: 'muted', text: 'One row per condition, time left to right. Darker means the model believes it more.' }), + conceptHost, + ]), + ], { sub: meta.disclaimers.concepts }); - // ---- tabs + // ---- ask the model const tabBody = el('div'); - const tabWhatIf = el('button', { type: 'button', role: 'tab', 'aria-selected': 'true', text: 'What if…' }); - const tabWhy = el('button', { type: 'button', role: 'tab', 'aria-selected': 'false', text: 'Why?' }); - const tabsCard = el('section', { class: 'card' }, [el('div', { class: 'tabs', role: 'tablist' }, [tabWhatIf, tabWhy]), tabBody]); + const tabWhatIf = el('button', { type: 'button', role: 'tab', 'aria-selected': 'true', text: 'What if a value were different?' }); + const tabWhy = el('button', { type: 'button', role: 'tab', 'aria-selected': 'false', text: 'What is driving this forecast?' }); + const askCard = el('section', { class: 'card' }, [ + el('h2', { class: 'card__title', text: 'Ask the model' }), + el('div', { class: 'tabs', role: 'tablist' }, [tabWhatIf, tabWhy]), + tabBody, + ]); // ---- sidebar + const recentList = el('ul', { class: 'chart-list' }); const nextList = el('ul', { class: 'side-list' }); - const recentList = el('ul', { class: 'side-list side-scroll' }); const side = el('aside', { class: 'replay__side' }, [ - card('What the model expects next', nextList, { sub: 'Most likely next events' }), - card('Recently recorded', recentList, { sub: `Last ${RECENT_HOURS} h` }), - ]); - - const main = el('div', { class: 'replay__main' }, [ - el('section', { class: 'card' }, [ - el('div', { class: 'moment' }, [momentTime, momentNote]), - el('div', { class: 'faint', text: meta.disclaimers.risk }), + card('Recently charted', recentList, { sub: `Last ${RECENT_HOURS} h` }), + el('details', { class: 'card more' }, [ + el('summary', { text: 'What the model expects to be charted next' }), + nextList, ]), - cardsRow, calloutCard, chartCard, scrubber, conceptCard, tabsCard, ]); - root.replaceChildren(header, el('div', { class: 'replay' }, [main, side])); + + const main = el('div', { class: 'replay__main' }, [chartCard, storyCard, conceptCard, askCard]); + root.replaceChildren(header, nowBar, tilesSection, el('div', { class: 'replay' }, [main, side])); // ---- charts const chart = createRiskChart(chartHost, { onScrub: (i) => setIndex(i) }); @@ -200,6 +255,10 @@ export async function renderReplay(root, { meta, sid, vid, t }) { cleanups.push(() => chart.destroy(), () => strip.destroy()); strip.update({ times, values: trace.concepts }); + function opFor(event) { + return operatingPoint(meta, event, horizon) ?? operatingPoint(meta, event, ALERT_HORIZON); + } + function chartData() { const key = horizonKey(horizon); return { @@ -217,7 +276,7 @@ export async function renderReplay(root, { meta, sid, vid, t }) { .filter((e) => trace.onsets[e.name] != null) .map((e) => ({ t: trace.onsets[e.name], label: e.short, color: eventColor(e.name) })), markers: trace.markers, - overlay: overlayEvent && horizon === 24 + overlay: overlayEvent && horizon === ALERT_HORIZON ? { color: eventColor(overlayEvent), label: 'Tuned GBM, 24 h risk, at 4-hourly landmarks', @@ -247,57 +306,52 @@ export async function renderReplay(root, { meta, sid, vid, t }) { function renderSegmented() { segmented.replaceChildren(...meta.horizons.map((h) => el('button', { - type: 'button', 'aria-pressed': String(h === horizon), text: `${h} h`, + type: 'button', 'aria-pressed': String(h === horizon), text: `${h} hours`, onClick: () => { horizon = h; renderSegmented(); chart.update(chartData()); + updateTiles(); }, }))); - chartTitle.textContent = `Chance of each event within ${horizon} hours`; + chartTitle.textContent = `How the ${horizon}-hour risk moved over the stay`; } - function updateCards() { + function updateTiles() { const now = times[index]; const before = indexAtOrBefore(times, now - TREND_HOURS); - for (const c of cards) { + const key = horizonKey(horizon); + for (const c of tiles) { const series = trace.risk[c.event]; const onset = trace.onsets[c.event]; - const op = operatingPoint(meta, c.event, 24); - const p24 = series['24h']?.[index]; + const op = opFor(c.event); + const p = series[key]?.[index]; if (onset != null && now >= onset) { c.root.dataset.level = 'done'; - c.nodes.value.replaceChildren('Happened'); - c.nodes.context.textContent = `at ${clock(onset)}`; - c.nodes.minor.textContent = ''; - c.nodes.line.textContent = 'Forecast stops at the event'; + c.nodes.value.textContent = 'Happened'; + c.nodes.status.textContent = clock(onset); + c.nodes.context.textContent = ''; continue; } - const lvl = level(p24, op?.threshold); + const lvl = level(p, op?.threshold); c.root.dataset.level = lvl; - c.nodes.value.replaceChildren(pct(p24), el('small', { text: 'in 24 h' })); - const tr = trend(p24, series['24h']?.[before], TREND_HOURS); - c.nodes.context.replaceChildren( - timesTypical(p24, op?.base_rate) ?? '', - tr.arrow ? el('span', { class: `trend ${tr.dir > 0 ? 'trend--up' : tr.dir < 0 ? 'trend--down' : ''}`, text: ` ${tr.arrow} `, title: tr.label }) : '', + c.nodes.value.textContent = pct(p); + const tr = trend(p, series[key]?.[before], TREND_HOURS); + c.nodes.status.replaceChildren( + el('span', { class: `status status--${lvl}`, text: STATUS[lvl] }), + tr.arrow ? el('span', { class: `trend trend--${tr.dir > 0 ? 'up' : tr.dir < 0 ? 'down' : 'flat'}`, text: tr.arrow, title: tr.label, 'aria-label': tr.label }) : null, ); - c.nodes.minor.textContent = `8 h ${pct(series['8h']?.[index])} · 72 h ${pct(series['72h']?.[index])}`; - const lineText = { - alert: 'Alert on: above the line', - watch: `Near the alert line (${pct(op?.threshold)})`, - }; - c.nodes.line.textContent = op ? lineText[lvl] ?? `Alert line ${pct(op.threshold)}` : ''; + c.nodes.context.textContent = timesTypical(p, op?.base_rate) ?? ''; } } // An alert is "on" exactly when the 24 h risk at this moment is at or // above its line (risk is null once the event has happened). - function updateCallouts() { - calloutItems.forEach((item, i) => { - const a = alerts[i]; - const p = trace.risk[a.event]?.['24h']?.[index]; - item.classList.toggle('is-live', p != null && a.threshold != null && p >= a.threshold); - }); + function updateStories() { + for (const a of alerts) { + const p = trace.risk[a.event]?.[horizonKey(ALERT_HORIZON)]?.[index]; + liveTags.get(a.event).hidden = !(p != null && a.threshold != null && p >= a.threshold); + } } function updateSide() { @@ -310,29 +364,37 @@ export async function renderReplay(root, { meta, sid, vid, t }) { el('span', { class: 'next-row__p', text: pct(n.probability) }), el('div', { class: 'bar' }, [fill]), ]); - }) : [el('li', { class: 'faint', text: 'Nothing to forecast here.' })])); + }) : [el('li', { class: 'muted', text: 'Nothing to forecast here.' })])); const recent = recentEntries(timeline, times[index]); - recentList.replaceChildren(...(recent.length ? recent.map((e) => el('li', { class: `event-row event-row--${e.category}` }, [ - el('span', { class: 'event-row__t', text: clock(e.t).replace('Day ', 'D') }), - el('span', { class: 'event-row__body' }, [ + recentList.replaceChildren(...(recent.length ? recent.map((e) => el('li', { class: `entry entry--${e.category}` }, [ + el('span', { class: 'entry__t', text: clock(e.t) }), + el('span', { class: 'entry__body' }, [ el('span', { class: `cat-dot cat-dot--${e.category}`, title: e.category }), - el('span', { class: 'event-row__label', text: e.label }), - e.value ? el('span', { class: 'event-row__value', text: e.value }) : null, + el('span', { class: 'entry__label', text: e.label }), + e.value ? el('span', { class: 'entry__value', text: e.value }) : null, e.flag ? el('span', { class: `pill pill--${e.flag}`, text: e.flag.toLowerCase() }) : null, ]), - ])) : [el('li', { class: 'faint', text: 'Nothing recorded in this window.' })])); + ])) : [el('li', { class: 'muted', text: 'Nothing charted in this window.' })])); } function updateConcepts() { const row = trace.concepts[index] ?? []; const top = meta.concepts .map((c, i) => ({ c, v: row[i] ?? 0 })) - .filter((x) => x.v >= 0.5) + .filter((x) => x.v >= CONCEPT_SHOW) .sort((a, b) => b.v - a.v) - .slice(0, 6); - conceptChips.replaceChildren(...(top.length - ? top.map((x) => el('span', { class: 'concept-chip', title: x.c.description }, [x.c.display, el('span', { class: 'num', text: pct(x.v) })])) - : [el('span', { class: 'faint', text: 'Nothing stands out yet.' })])); + .slice(0, CONCEPT_MAX); + conceptList.replaceChildren(...(top.length + ? top.map((x) => { + const fill = el('div', { class: 'bar__fill' }); + fill.style.setProperty('width', `${Math.round(x.v * 100)}%`); + return el('li', { class: 'belief', title: x.c.description }, [ + el('span', { class: 'belief__label', text: x.c.display }), + el('div', { class: 'bar' }, [fill]), + el('span', { class: 'belief__p', text: pct(x.v) }), + ]); + }) + : [el('li', { class: 'muted', text: 'Nothing stands out at this moment.' })])); } function syncUrl() { @@ -345,12 +407,12 @@ export async function renderReplay(root, { meta, sid, vid, t }) { function setIndex(i) { index = clamp(Math.round(i), 0, times.length - 1); range.value = String(index); - momentTime.textContent = clock(times[index]); - momentNote.textContent = `${Math.round(times[index])} h after admission · moment ${index + 1} of ${times.length}`; + nowTime.textContent = clock(times[index]); + nowSub.textContent = `hour ${Math.round(times[index])} of ${Math.round(last)}`; chart.setCursor(index); strip.setCursor(index); - updateCards(); - updateCallouts(); + updateTiles(); + updateStories(); updateSide(); updateConcepts(); syncUrl(); @@ -412,7 +474,7 @@ export async function renderReplay(root, { meta, sid, vid, t }) { } }); cleanups.push(onThemeChange(() => { - for (const c of cards) c.root.style.setProperty('--card-color', eventColor(c.event)); + for (const c of tiles) c.root.style.setProperty('--tile-color', eventColor(c.event)); renderLegend(); chart.update(chartData()); strip.redraw(); diff --git a/apps/clinician_demo/static/js/views/scorecard.js b/apps/clinician_demo/static/js/views/scorecard.js index 2c12517b..1cb5c3f7 100644 --- a/apps/clinician_demo/static/js/views/scorecard.js +++ b/apps/clinician_demo/static/js/views/scorecard.js @@ -34,16 +34,14 @@ function calibration(bins) { function cellView(cell) { const line = (who, value, ci) => - el('div', { class: 'score-cell__line' }, [ + el('div', { class: 'score-cell__line', title: ci ? `95% interval ${interval(ci)}` : '' }, [ el('span', { class: 'score-cell__who', text: who }), el('span', { class: 'score-cell__auroc', text: auroc(value) }), - el('span', { class: 'score-cell__ci', text: interval(ci) }), ]); return el('div', { class: 'score-cell' }, [ line('Model', cell.hazard_auroc, cell.hazard_ci), line('GBM', cell.gbm_auroc, cell.gbm_ci), verdict(cell), - el('div', { class: 'faint', text: `${pct(cell.base_rate)} of ${cell.n_at_risk.toLocaleString()} moments` }), ]); } @@ -56,15 +54,16 @@ function table(meta, cells) { el('thead', {}, el('tr', {}, [ el('th', { text: 'Event' }), ...horizons.map((h) => el('th', { text: `Within ${h} h` })), - el('th', { text: 'Calibration (24 h)' }), + el('th', { text: 'Calibration', title: 'Predicted against observed 24 h risk, by decile. On the dashed line means well calibrated.' }), ])), el('tbody', {}, events.map((e) => { const info = eventInfo(meta, e); const c24 = find(e, 24); return el('tr', {}, [ el('td', {}, [ - el('div', { class: 'risk-card__name' }, [el('span', { class: 'dot', style: { background: eventColor(e) } }), info.display]), - el('div', { class: 'faint', text: info.definition }), + el('div', { class: 'tile__name' }, [el('span', { class: 'dot', style: { background: eventColor(e) } }), info.display]), + el('div', { class: 'muted', text: info.definition }), + c24 ? el('div', { class: 'faint', text: `Happens within 24 h at ${pct(c24.base_rate)} of ${c24.n_at_risk.toLocaleString()} moments` }) : null, ]), ...horizons.map((h) => el('td', {}, find(e, h) ? cellView(find(e, h)) : '—')), el('td', {}, c24 ? calibration(c24.calibration) : '—'), @@ -78,11 +77,11 @@ function conceptBars(concepts) { const scored = concepts.filter((c) => c.readout_auroc != null).sort((a, b) => b.readout_auroc - a.readout_auroc); if (!scored.length) return emptyBlock('No concept readouts are banked for this run.'); return el('div', { class: 'auroc-bars' }, scored.map((c) => { - const fill = el('div', { class: 'auroc-bar__fill' }); + const fill = el('div', { class: 'bar__fill' }); fill.style.setProperty('width', `${Math.max(0, (c.readout_auroc - 0.5) / 0.5) * 100}%`); return el('div', { class: 'auroc-bar', title: c.description }, [ el('span', { text: c.display }), - el('div', { class: 'auroc-bar__track' }, [fill]), + el('div', { class: 'bar' }, [fill]), el('span', { class: 'num', text: auroc(c.readout_auroc) }), ]); })); @@ -107,15 +106,15 @@ export async function renderScorecard(root, { meta }) { el('div', { class: 'page-head' }, [ el('div', {}, [ el('h1', { text: 'How good is it?' }), - el('p', { text: 'Measured on held-out patients the model never trained on. Bars start at 0.5, which is chance.' }), + el('p', { text: 'How well the model ranks patients by risk (AUROC: 1.000 is perfect, 0.500 is chance), measured on held-out patients it never trained on, next to a tuned gradient-boosting model (GBM). Hover a number for its 95% interval.' }), ]), ]), el('div', { class: 'card headline-card', text: sc.headline }), el('div', { class: 'two-col' }, [ el('div', {}, [sc.cells.length ? table(meta, sc.cells) : emptyBlock('No alert evaluation is banked for this run.')]), - el('div', { class: 'replay__main' }, [ - card('Reading the chart: concept accuracy', conceptBars(sc.concepts), { - sub: 'AUROC of each named concept against its rule', + el('div', { class: 'replay__side' }, [ + card('How well it reads the chart', conceptBars(sc.concepts), { + sub: 'Each condition the model names, scored against its clinical rule (bars start at chance)', }), card('How to read this', el('ul', { class: 'notes' }, sc.notes.map((n) => el('li', { text: n })))), ]), diff --git a/apps/clinician_demo/static/js/views/whatif.js b/apps/clinician_demo/static/js/views/whatif.js index 103eed6e..1f5e22c6 100644 --- a/apps/clinician_demo/static/js/views/whatif.js +++ b/apps/clinician_demo/static/js/views/whatif.js @@ -55,7 +55,7 @@ function resultView(result, events, onsets) { .map((k) => `${k.replace('h', ' h')} ${points(result.delta.risk[e.name]?.[k])}`) .join(' · '); return el('div', { class: 'compare-row' }, [ - el('div', {}, [el('strong', { text: e.display }), el('div', { class: 'faint', text: minor })]), + el('div', {}, [el('strong', { text: e.display }), el('div', { class: 'muted', text: minor })]), el('div', { class: 'compare-bars', 'aria-label': `${e.display}: ${pct(before)} now, ${pct(after)} with the change` }, [ el('div', { class: 'compare-bar' }, [ el('div', { class: 'compare-bar__fill compare-bar__fill--before', style: { width: `${(before / max) * 100}%` } }), @@ -73,7 +73,7 @@ function resultView(result, events, onsets) { return el('div', { class: 'panel' }, [ el('div', { class: 'muted' }, [ `Forecast at ${clock(result.t_hours)} · ${result.rows_edited} reading${result.rows_edited === 1 ? '' : 's'} changed. `, - 'Grey bar: the chart as recorded. Coloured bar: with your change. Risk within 24 h.', + 'Grey bar: as charted. Coloured bar: with your change. Risk in the next 24 h.', ]), ...result.warnings.map((w) => el('div', { class: 'note', text: w })), result.rows_edited > 0 ? el('div', { class: 'compare' }, [...rows, ...done]) : null, @@ -93,8 +93,8 @@ export function createWhatIfPanel(container, { meta, sid, vid, getT, events, ons const rowsBox = el('div', { class: 'panel' }); const resultBox = el('div'); const select = el('select', { 'aria-label': 'Choose a reading to change' }); - const addBtn = el('button', { class: 'btn btn--ghost btn--small', type: 'button', text: 'Add change' }); - const runBtn = el('button', { class: 'btn', type: 'button', text: 'Show the new forecast', disabled: true }); + const addBtn = el('button', { class: 'btn btn--ghost btn--small', type: 'button', text: 'Add' }); + const runBtn = el('button', { class: 'btn', type: 'button', text: 'Re-run the forecast', disabled: true }); const controls = el('div', { class: 'panel__controls' }, [select, addBtn, el('span', { class: 'spacer' }), runBtn]); const panel = el('div', { class: 'panel' }, [ el('div', { class: 'note', text: meta.disclaimers.whatif }), @@ -131,7 +131,7 @@ export function createWhatIfPanel(container, { meta, sid, vid, getT, events, ons ]); }), ); - if (!edits.length) rowsBox.append(el('div', { class: 'faint', text: 'Add a change to begin. Up to three.' })); + if (!edits.length) rowsBox.append(el('div', { class: 'muted', text: 'Pick a reading above and press Add. Up to three changes.' })); addBtn.disabled = edits.length >= MAX_EDITS || !presets.length; runBtn.disabled = !edits.length; } diff --git a/apps/clinician_demo/static/styles.css b/apps/clinician_demo/static/styles.css index 16bca6df..c2a06b61 100644 --- a/apps/clinician_demo/static/styles.css +++ b/apps/clinician_demo/static/styles.css @@ -1,30 +1,29 @@ /* Odyssey · Bedside Forecast * - * Design tokens are copied from - * odyssey/reporting/concept_bottleneck_report_template.html (:root and its - * dark-mode block) so the demo and the research report look like one - * product. Keep the two in step when the palette changes. + * A calm, light, chart-like look: one accent, event colours only where + * they carry meaning (lines and dots), plain words, and details behind + * disclosures. Dark theme on request via [data-theme="dark"]. */ :root { - --bg: #F5F7F8; + --bg: #F4F6F8; --surface: #FFFFFF; - --surface-2: #ECF1F2; - --ink: #14212B; - --ink-muted: #566873; - --ink-faint: #8598A1; - --border: #D8E0E3; - --border-strong: #C1CDD1; - --accent: #0B6E77; - --accent-strong: #084F56; - --accent-soft: #DCEEF0; - --good: #2E8B57; - --good-soft: #E1F1E7; - --warn: #A6720A; - --warn-soft: #F5ECD8; - --critical: #B23A3A; - --critical-soft: #F5E1E1; - --shadow: 0 1px 2px rgba(20, 33, 43, 0.06), 0 4px 16px rgba(20, 33, 43, 0.05); + --surface-2: #EEF2F5; + --ink: #1B2733; + --ink-muted: #5B6B78; + --ink-faint: #8A98A3; + --border: #DFE5EA; + --border-strong: #C5CFD7; + --accent: #0F6E8C; + --accent-strong: #0A5169; + --accent-soft: #E1EFF4; + --good: #2E7D4F; + --good-soft: #E3F2E9; + --warn: #9A6700; + --warn-soft: #FFF3D6; + --critical: #B42B2B; + --critical-soft: #FBE5E5; + --shadow: 0 1px 2px rgba(27, 39, 51, 0.05); --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; @@ -36,59 +35,31 @@ --ev-sepsis3: #B4508F; --ev-death: #3C4650; - --radius: 10px; + --radius: 8px; --radius-small: 6px; --gap: 16px; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --bg: #0B1416; - --surface: #101B1E; - --surface-2: #162427; - --ink: #E7EEF0; - --ink-muted: #93A6AC; - --ink-faint: #64777D; - --border: #223236; - --border-strong: #2C4045; - --accent: #3FC1C9; - --accent-strong: #6FDEE3; - --accent-soft: #123338; - --good: #4CAF7D; - --good-soft: #123324; - --warn: #D9A441; - --warn-soft: #332A14; - --critical: #E07272; - --critical-soft: #331717; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 20px rgba(0, 0, 0, 0.35); - - --ev-icu_admission: #56B4E9; - --ev-vasopressor_start: #F08A4B; - --ev-acute_kidney_injury: #3CC9A0; - --ev-sepsis3: #E0A3C8; - --ev-death: #C9D2D6; - } + --page: 1280px; } :root[data-theme="dark"] { - --bg: #0B1416; - --surface: #101B1E; - --surface-2: #162427; + --bg: #0F171B; + --surface: #162126; + --surface-2: #1E2B31; --ink: #E7EEF0; - --ink-muted: #93A6AC; - --ink-faint: #64777D; - --border: #223236; - --border-strong: #2C4045; - --accent: #3FC1C9; - --accent-strong: #6FDEE3; + --ink-muted: #9AACB3; + --ink-faint: #6C7E85; + --border: #27363C; + --border-strong: #35474E; + --accent: #4CC3D2; + --accent-strong: #7EDDE8; --accent-soft: #123338; - --good: #4CAF7D; - --good-soft: #123324; - --warn: #D9A441; - --warn-soft: #332A14; - --critical: #E07272; - --critical-soft: #331717; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 20px rgba(0, 0, 0, 0.35); + --good: #58B983; + --good-soft: #143424; + --warn: #E0AB45; + --warn-soft: #382C12; + --critical: #E57373; + --critical-soft: #3A1A1A; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); --ev-icu_admission: #56B4E9; --ev-vasopressor_start: #F08A4B; @@ -100,6 +71,7 @@ /* ---------------------------------------------------------------- base */ * { box-sizing: border-box; } +[hidden] { display: none !important; } html, body { margin: 0; @@ -121,9 +93,10 @@ a:hover { text-decoration: underline; } } h1, h2, h3 { margin: 0; line-height: 1.25; } -h1 { font-size: 22px; font-weight: 650; } -h2 { font-size: 17px; font-weight: 650; } +h1 { font-size: 24px; font-weight: 650; } +h2 { font-size: 16px; font-weight: 650; } h3 { font-size: 14px; font-weight: 650; } +p { margin: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; @@ -140,53 +113,72 @@ h3 { font-size: 14px; font-weight: 650; } .faint { color: var(--ink-faint); } .mono { font-family: var(--font-mono); } .num { font-variant-numeric: tabular-nums; } +.grow { flex: 1; min-width: 0; } + +details.more > summary { + cursor: pointer; color: var(--accent); font-weight: 600; font-size: 14px; + list-style: none; display: inline-flex; align-items: center; gap: 6px; +} +details.more > summary::-webkit-details-marker { display: none; } +details.more > summary::before { content: "▸"; font-size: 12px; color: var(--ink-faint); } +details.more[open] > summary::before { content: "▾"; } +details.more > summary:hover { text-decoration: underline; } +details.more > :not(summary) { margin-top: 10px; } + +.linklike { + font: inherit; font-size: inherit; color: var(--accent); background: none; border: 0; + padding: 0; cursor: pointer; +} +.linklike:hover { text-decoration: underline; } /* -------------------------------------------------------------- chrome */ .topbar { - display: flex; align-items: center; gap: 24px; - padding: 12px 24px; + display: flex; align-items: center; gap: 20px; + padding: 10px 24px; background: var(--surface); border-bottom: 1px solid var(--border); - position: sticky; top: 0; z-index: 20; } -.topbar__brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); white-space: nowrap; } +.brand:hover { text-decoration: none; } .brand-mark { - width: 28px; height: 28px; border-radius: 8px; flex: none; - background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); - box-shadow: inset 0 0 0 5px var(--surface), inset 0 0 0 7px var(--accent); + width: 12px; height: 12px; border-radius: 50%; flex: none; + background: var(--accent); } -.brand-name { font-weight: 700; letter-spacing: 0.01em; } -.brand-sub { font-size: 12px; color: var(--ink-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.topbar__nav { display: flex; gap: 4px; margin-left: 8px; } +.brand-name { font-weight: 700; } +.brand-sub { color: var(--ink-muted); } +.topbar__nav { display: flex; gap: 2px; } .topbar__nav a { - color: var(--ink-muted); padding: 6px 12px; border-radius: 999px; font-weight: 550; + color: var(--ink-muted); padding: 6px 12px; border-radius: var(--radius-small); font-weight: 550; } .topbar__nav a:hover { text-decoration: none; background: var(--surface-2); color: var(--ink); } -.topbar__nav a.is-active { background: var(--accent-soft); color: var(--accent-strong); } +.topbar__nav a.is-active { color: var(--accent-strong); background: var(--accent-soft); } .search { display: flex; gap: 6px; margin-left: auto; } .search input { - width: 170px; padding: 6px 10px; border-radius: var(--radius-small); + width: 150px; padding: 6px 10px; border-radius: var(--radius-small); border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); font: inherit; font-size: 14px; } .mode-badge { - font-size: 12px; font-weight: 650; padding: 4px 10px; border-radius: 999px; - white-space: nowrap; + font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px; + white-space: nowrap; border: 1px solid transparent; } .search[hidden] + .mode-badge { margin-left: auto; } -.mode-badge--open { background: var(--good-soft); color: var(--good); } -.mode-badge--credentialed { background: var(--critical-soft); color: var(--critical); } +.mode-badge--open { color: var(--good); border-color: var(--good); } +.mode-badge--credentialed { color: var(--critical); border-color: var(--critical); } .banner { - padding: 8px 24px; font-size: 13px; - background: var(--warn-soft); color: var(--ink); - border-bottom: 1px solid var(--border); + padding: 6px 24px; font-size: 13px; color: var(--ink-muted); + background: var(--warn-soft); border-bottom: 1px solid var(--border); } -.app { max-width: 1480px; margin: 0 auto; padding: 20px 24px 48px; outline: none; } -.footer { max-width: 1480px; margin: 0 auto; padding: 0 24px 32px; font-size: 12px; color: var(--ink-faint); } +.app { max-width: var(--page); margin: 0 auto; padding: 24px 24px 48px; outline: none; } +.footer { + max-width: var(--page); margin: 0 auto; padding: 0 24px 32px; + font-size: 12px; color: var(--ink-faint); + display: flex; gap: 16px; flex-wrap: wrap; justify-content: space-between; +} /* ------------------------------------------------------------- blocks */ @@ -195,12 +187,13 @@ h3 { font-size: 14px; font-weight: 650; } border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); - padding: 16px; + padding: 16px 18px; min-width: 0; } -.card__head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } +.card__head { display: flex; align-items: baseline; gap: 6px 12px; flex-wrap: wrap; margin-bottom: 12px; } .card__head .spacer { flex: 1; } .card__sub { font-size: 13px; color: var(--ink-muted); } +.card__title { margin-bottom: 8px; } .btn { font: inherit; font-weight: 600; font-size: 14px; @@ -220,18 +213,20 @@ select, input[type="number"] { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-small); padding: 6px 8px; } +.select--small { font-size: 12px; padding: 3px 6px; color: var(--ink-muted); } -.segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; } +.segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-small); overflow: hidden; } .segmented button { - font: inherit; font-size: 12px; font-weight: 600; padding: 4px 10px; + font: inherit; font-size: 13px; font-weight: 600; padding: 4px 12px; border: 0; background: transparent; color: var(--ink-muted); cursor: pointer; } +.segmented button + button { border-left: 1px solid var(--border-strong); } .segmented button[aria-pressed="true"] { background: var(--accent); color: #fff; } .pill { display: inline-flex; align-items: center; gap: 4px; - font-size: 11px; font-weight: 700; letter-spacing: 0.03em; - padding: 1px 7px; border-radius: 999px; white-space: nowrap; + font-size: 11px; font-weight: 700; letter-spacing: 0.02em; + padding: 1px 8px; border-radius: 999px; white-space: nowrap; vertical-align: middle; background: var(--surface-2); color: var(--ink-muted); } .pill--LOW { background: var(--accent-soft); color: var(--accent-strong); } @@ -239,9 +234,10 @@ select, input[type="number"] { .pill--CRITICAL { background: var(--critical-soft); color: var(--critical); } .pill--training { background: var(--warn-soft); color: var(--warn); cursor: help; } .pill--heldout { background: var(--good-soft); color: var(--good); } -.pill--event { color: #fff; } +.pill--alert { background: var(--critical); color: #fff; } .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; } +.info { color: var(--ink-faint); cursor: help; font-size: 14px; } .note { font-size: 13px; color: var(--ink-muted); @@ -257,117 +253,120 @@ select, input[type="number"] { animation: spin 0.9s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -.skeleton { - border-radius: var(--radius); min-height: 96px; - background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface) 50%, var(--surface-2) 75%); - background-size: 200% 100%; animation: shimmer 1.4s ease infinite; -} -@keyframes shimmer { to { background-position: -200% 0; } } .progress { height: 8px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } .progress__bar { height: 100%; width: 0; background: var(--accent); transition: width 0.3s ease; } +.bar { height: 6px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.bar__fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; } + +.notes { margin: 0; padding-left: 18px; color: var(--ink-muted); font-size: 13px; display: grid; gap: 6px; } + +.backlink { display: inline-block; font-size: 14px; margin-bottom: 8px; } + /* ------------------------------------------------------------ gallery */ -.page-head { display: flex; align-items: flex-end; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; } -.page-head p { margin: 4px 0 0; color: var(--ink-muted); max-width: 760px; } +.page-head { margin-bottom: 24px; display: grid; gap: 8px; } +.page-head p { color: var(--ink-muted); max-width: 720px; } +.page-head .note { max-width: 720px; } -.section { margin-bottom: 28px; } -.section__head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; } -.section__summary { font-size: 13px; color: var(--ink-muted); margin: 0 0 12px; } -.section__kind { - font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--ink-faint); -} +.section { margin-bottom: 32px; } +.section__head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; } +.section__intro { font-size: 14px; color: var(--ink-muted); } +.section__rate { font-size: 12px; color: var(--ink-faint); margin-top: 10px; } -.case-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } -.case-card { - display: flex; flex-direction: column; gap: 8px; - color: var(--ink); padding: 14px 14px 12px; - border-left: 4px solid var(--case-color, var(--accent)); - transition: transform 0.12s ease, box-shadow 0.12s ease; +.case-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } +.case { + display: flex; flex-direction: column; gap: 4px; + color: var(--ink); padding: 14px 16px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow); } -.case-card:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 6px 20px rgba(20, 33, 43, 0.12); } -.case-card__headline { font-weight: 600; } -.case-card__meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 12px; color: var(--ink-muted); } -.case-card__id { font-family: var(--font-mono); font-size: 12px; color: var(--ink-faint); } +.case:hover { text-decoration: none; border-color: var(--accent); } +.case__event { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-muted); font-weight: 600; } +.case__key { font-size: 16px; font-weight: 650; } +.case__meta { font-size: 12px; color: var(--ink-faint); margin-top: 4px; } +.case__unseen { color: var(--good); font-weight: 600; } -.compact-list { list-style: none; margin: 0; padding: 0; columns: 3 260px; column-gap: 16px; } -.compact-list li { break-inside: avoid; padding: 3px 0; font-size: 13px; } +.all-patients > summary { cursor: pointer; font-size: 16px; font-weight: 650; } +.all-patients__title { color: var(--ink); } +.patient-list { list-style: none; margin: 12px 0 0; padding: 0; columns: 4 220px; column-gap: 16px; } +.patient-list li { break-inside: avoid; padding: 3px 0; font-size: 13px; } -.visit-list { display: grid; gap: 10px; max-width: 760px; } -.visit-row { display: flex; gap: 16px; align-items: center; color: var(--ink); } +.visit-list { display: grid; gap: 10px; max-width: 720px; } +.visit-row { + display: flex; gap: 16px; align-items: center; color: var(--ink); + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; +} .visit-row:hover { text-decoration: none; border-color: var(--accent); } -.visit-row .grow { flex: 1; } +.visit-row__title { font-weight: 600; } /* ------------------------------------------------------------- replay */ -.patient-head { - display: flex; align-items: center; gap: 12px 20px; flex-wrap: wrap; - margin-bottom: 14px; -} -.patient-head__facts { display: flex; gap: 6px 18px; flex-wrap: wrap; color: var(--ink-muted); font-size: 14px; } -.patient-head__facts strong { color: var(--ink); font-weight: 600; } - -.replay { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: var(--gap); align-items: start; } -.replay__main, .replay__side { display: grid; gap: var(--gap); min-width: 0; } -.replay__side { position: sticky; top: 72px; } - -.moment { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } -.moment__time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; } +.patient-head { margin-bottom: 16px; display: grid; gap: 4px; } +.patient-head h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.patient-head__facts { color: var(--ink-muted); font-size: 14px; } -.risk-cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; } -.risk-card { - position: relative; padding: 12px 12px 10px; - border-top: 4px solid var(--card-color, var(--accent)); +.now { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 16px; + padding: 10px 16px; margin: 0 -4px 16px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 2px 8px rgba(27, 39, 51, 0.08); +} +.now__label { display: grid; line-height: 1.15; min-width: 150px; } +.now__caption { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); } +.now__time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; } +.now__sub { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.now input[type="range"] { flex: 1; accent-color: var(--accent); height: 24px; min-width: 120px; } +.now__end { font-size: 12px; color: var(--ink-muted); white-space: nowrap; } +.play-btn { min-width: 88px; } + +.tiles { margin-bottom: var(--gap); } +.tiles__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.tile-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; } +.tile { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 12px 14px; + border-top: 3px solid var(--tile-color, var(--accent)); transition: background-color 0.25s ease; } -.risk-card[data-level="watch"] { box-shadow: inset 0 0 0 1px var(--warn); } -.risk-card[data-level="alert"] { background: var(--critical-soft); } -.risk-card[data-level="done"] { background: var(--surface-2); } -.risk-card__name { font-size: 13px; font-weight: 650; display: flex; align-items: center; gap: 6px; } -.risk-card__value { font-size: 30px; font-weight: 750; font-variant-numeric: tabular-nums; line-height: 1.1; margin-top: 6px; } -.risk-card__value small { font-size: 12px; font-weight: 500; color: var(--ink-muted); margin-left: 4px; } -.risk-card__context { font-size: 12px; color: var(--ink-muted); min-height: 18px; } -.risk-card__minor { font-size: 12px; color: var(--ink-muted); margin-top: 4px; font-variant-numeric: tabular-nums; } -.risk-card__line { font-size: 11px; margin-top: 6px; color: var(--ink-faint); } -.risk-card[data-level="alert"] .risk-card__line { color: var(--critical); font-weight: 700; } -.trend { font-weight: 700; } +.tile[data-level="alert"] { background: var(--critical-soft); border-color: var(--critical); } +.tile[data-level="done"] { background: var(--surface-2); } +.tile__name { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; color: var(--ink-muted); } +.tile__value { font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; margin-top: 6px; } +.tile[data-level="done"] .tile__value { font-size: 20px; margin-top: 10px; } +.tile__status { display: flex; align-items: center; gap: 8px; margin-top: 4px; min-height: 20px; font-size: 13px; } +.tile__context { font-size: 12px; color: var(--ink-muted); min-height: 17px; } +.status { font-weight: 700; } +.status--low { color: var(--ink-faint); } +.status--watch { color: var(--warn); } +.status--alert { color: var(--critical); } +.trend { font-weight: 700; font-size: 15px; } .trend--up { color: var(--critical); } .trend--down { color: var(--good); } +.trend--flat { color: var(--ink-faint); } -.callouts { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } -.callout { - display: flex; gap: 10px; align-items: flex-start; - padding: 8px 10px; border-radius: var(--radius-small); - border-left: 4px solid var(--callout-color, var(--accent)); - background: var(--surface-2); font-size: 14px; - transition: box-shadow 0.25s ease, background-color 0.25s ease; -} -.callout--lead { font-weight: 550; } -.callout__body { display: grid; gap: 3px; } -.callout__detail { font-size: 12px; font-weight: 400; color: var(--ink-muted); } -.onset text { font-size: 11px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; stroke-linejoin: round; } -.callout.is-live { background: var(--critical-soft); box-shadow: 0 0 0 2px var(--critical) inset; } -.callout__tag { font-size: 11px; font-weight: 700; color: var(--ink-faint); white-space: nowrap; margin-top: 2px; } +.replay { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--gap); align-items: start; } +.replay__main, .replay__side { display: grid; gap: var(--gap); min-width: 0; } +.legend-wrap { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .legend { display: flex; gap: 6px; flex-wrap: wrap; } .legend button { font: inherit; font-size: 12px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; - padding: 3px 9px; border-radius: 999px; cursor: pointer; + padding: 2px 9px; border-radius: 999px; cursor: pointer; border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); } .legend button[aria-pressed="false"] { opacity: 0.4; } .risk-chart { position: relative; width: 100%; } -.risk-chart__svg { display: block; width: 100%; height: 300px; cursor: crosshair; user-select: none; } +.risk-chart__svg { display: block; width: 100%; height: 280px; cursor: crosshair; user-select: none; } .risk-chart .axis text { fill: var(--ink-faint); font-size: 11px; } .risk-chart .grid line { stroke: var(--border); } .risk-chart .cursor { stroke: var(--ink); stroke-width: 1.5; } .risk-chart .marker line { stroke: var(--ink-faint); stroke-dasharray: 2 3; } -.risk-chart .marker text, .risk-chart .onset text { font-size: 10px; font-weight: 700; } -.risk-chart .marker text { fill: var(--ink-muted); } +.risk-chart .onset text { font-size: 11px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; stroke-linejoin: round; } .chart-tip { position: absolute; pointer-events: none; z-index: 5; @@ -379,10 +378,19 @@ select, input[type="number"] { .chart-tip__row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; } .chart-tip__row span:nth-child(2) { flex: 1; } -.scrubber { display: flex; align-items: center; gap: 12px; } -.scrubber input[type="range"] { flex: 1; accent-color: var(--accent); height: 24px; } -.scrubber__label { font-size: 12px; color: var(--ink-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } -.play-btn { min-width: 92px; } +.stories { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.story { + display: grid; grid-template-columns: 150px minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; + padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; +} +.story:last-child { border-bottom: 0; } +.story__event { display: inline-flex; align-items: center; gap: 8px; font-weight: 650; } +.story--miss .story__text, .story--false .story__text { color: var(--ink-muted); } + +.belief-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.belief { display: grid; grid-template-columns: 190px minmax(0, 1fr) 44px; gap: 10px; align-items: center; font-size: 14px; } +.belief__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.belief__p { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-muted); font-size: 13px; } .concept-strip { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 8px; } .concept-strip__labels { list-style: none; margin: 0; padding: 0; } @@ -394,40 +402,35 @@ select, input[type="number"] { .concept-strip__plot { position: relative; } .concept-strip__plot canvas { display: block; width: 100%; cursor: crosshair; border-radius: 4px; } .concept-strip__cursor { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--ink); pointer-events: none; } -.concept-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } -.concept-chip { - font-size: 12px; padding: 3px 9px; border-radius: 999px; - background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; -} -.concept-chip .num { font-weight: 500; margin-left: 4px; } .side-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } .next-row { display: grid; grid-template-columns: minmax(0, 1fr) 46px; gap: 2px 8px; font-size: 13px; } .next-row__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .next-row__p { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-muted); } -.bar { grid-column: 1 / -1; height: 5px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } -.bar__fill { height: 100%; width: 0; background: var(--accent); border-radius: 999px; } - -.event-row { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; font-size: 13px; } -.event-row__t { color: var(--ink-faint); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; } -.event-row__body { display: flex; flex-wrap: nowrap; gap: 6px; align-items: flex-start; } -.event-row__body > .event-row__label { flex: 1 1 auto; min-width: 0; } -.event-row__body > .event-row__value, .event-row__body > .pill { flex: none; } -.event-row__value { font-variant-numeric: tabular-nums; color: var(--ink-muted); } -.event-row--care .event-row__label, .event-row--death .event-row__label { font-weight: 700; } +.next-row .bar { grid-column: 1 / -1; height: 4px; } + +.chart-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; max-height: 420px; overflow: auto; padding-right: 4px; } +.entry { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; font-size: 13px; } +.entry__t { color: var(--ink-faint); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; } +.entry__body { display: flex; flex-wrap: nowrap; gap: 6px; align-items: flex-start; } +.entry__body > .entry__label { flex: 1 1 auto; min-width: 0; } +.entry__body > .entry__value, .entry__body > .pill { flex: none; } +.entry__value { font-variant-numeric: tabular-nums; color: var(--ink-muted); } +.entry--care .entry__label, .entry--death .entry__label { font-weight: 700; } .cat-dot { width: 7px; height: 7px; border-radius: 2px; background: var(--ink-faint); display: inline-block; flex: none; margin-top: 6px; } .cat-dot--lab { background: var(--accent); } .cat-dot--vital { background: var(--good); } .cat-dot--medication, .cat-dot--infusion { background: var(--warn); } .cat-dot--care, .cat-dot--death { background: var(--critical); } -.side-scroll { max-height: 360px; overflow: auto; padding-right: 4px; } +details.card > summary { cursor: pointer; font-weight: 650; font-size: 16px; } +details.card > summary ~ * { margin-top: 12px; } /* ---------------------------------------------------- what-if / why */ -.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin: -4px 0 14px; } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin: 0 0 14px; flex-wrap: wrap; } .tabs button { - font: inherit; font-weight: 650; font-size: 14px; - padding: 8px 14px; border: 0; background: transparent; color: var(--ink-muted); cursor: pointer; + font: inherit; font-weight: 600; font-size: 14px; + padding: 8px 12px; border: 0; background: transparent; color: var(--ink-muted); cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -1px; } .tabs button[aria-selected="true"] { color: var(--accent-strong); border-bottom-color: var(--accent); } @@ -465,12 +468,13 @@ select, input[type="number"] { /* ---------------------------------------------------------- scorecard */ -.headline-card { font-size: 17px; font-weight: 600; border-left: 4px solid var(--accent); } +.headline-card { font-size: 16px; font-weight: 600; border-left: 4px solid var(--accent); margin-bottom: var(--gap); } .score-table { width: 100%; border-collapse: collapse; font-size: 13px; } .score-table th, .score-table td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } .score-table th { font-size: 12px; color: var(--ink-muted); font-weight: 650; } +.score-table .tile__name { color: var(--ink); } .score-cell { display: grid; gap: 3px; } -.score-cell__line { display: flex; flex-wrap: wrap; gap: 0 6px; align-items: baseline; font-variant-numeric: tabular-nums; } +.score-cell__line { display: flex; flex-wrap: nowrap; gap: 0 6px; align-items: baseline; font-variant-numeric: tabular-nums; white-space: nowrap; } .score-cell__who { width: 44px; color: var(--ink-muted); font-size: 12px; } .score-cell__auroc { font-weight: 700; } .score-cell__ci { color: var(--ink-faint); font-size: 11px; white-space: nowrap; } @@ -483,28 +487,28 @@ select, input[type="number"] { .calib .curve { fill: none; stroke: var(--accent); stroke-width: 1.5; } .calib .pt { fill: var(--accent); } .auroc-bars { display: grid; gap: 4px; } -.auroc-bar { display: grid; grid-template-columns: 220px minmax(0, 1fr) 52px; gap: 10px; align-items: center; font-size: 13px; } -.auroc-bar__track { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } -.auroc-bar__fill { height: 100%; background: var(--accent); border-radius: 999px; } -.notes { margin: 0; padding-left: 18px; color: var(--ink-muted); font-size: 13px; display: grid; gap: 6px; } +.auroc-bar { display: grid; grid-template-columns: 200px minmax(0, 1fr) 52px; gap: 10px; align-items: center; font-size: 13px; } +.auroc-bar .bar { height: 8px; } .two-col { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: var(--gap); align-items: start; } /* --------------------------------------------------------- responsive */ -@media (max-width: 1240px) { - .risk-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .replay { grid-template-columns: minmax(0, 1fr) 290px; } +@media (max-width: 1200px) { + .tile-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .replay { grid-template-columns: minmax(0, 1fr) 280px; } } -@media (max-width: 1000px) { +@media (max-width: 960px) { .replay, .two-col { grid-template-columns: minmax(0, 1fr); } - .replay__side { position: static; } - .topbar { flex-wrap: wrap; gap: 12px; } + .topbar { flex-wrap: wrap; gap: 10px; } + .now { flex-wrap: wrap; } + .now input[type="range"] { flex-basis: 100%; order: 3; } .edit-row { grid-template-columns: minmax(0, 1fr); } - .evidence-row, .compare-row { grid-template-columns: minmax(0, 1fr); } + .evidence-row, .compare-row, .story { grid-template-columns: minmax(0, 1fr); } + .belief { grid-template-columns: 140px minmax(0, 1fr) 44px; } } @media (max-width: 640px) { - .risk-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .tile-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .concept-strip { grid-template-columns: 120px minmax(0, 1fr); } .app { padding: 16px 12px 40px; } } diff --git a/docs/clinician_demo.md b/docs/clinician_demo.md index b2b0f9a1..5d9bea3e 100644 --- a/docs/clinician_demo.md +++ b/docs/clinician_demo.md @@ -61,6 +61,25 @@ gcloud compute ssh odyssey-cbm-a100 --zone us-central1-f \ Then open http://localhost:8765 (credentialed) or http://localhost:8766 (open). +The server maps static URLs to files at start-up but reads each file on +every request, so a changed `index.html`, `styles.css` or `js/**` file can +be copied over the running deployment and takes effect on the next reload. +Adding or removing a static file needs a restart. + +## What a clinician sees + +The pages are written around a clinician's questions, in order. The +replay opens with the patient in one line (age, sex, admission type, length +of stay), a sticky "now" bar (play, scrub, clock time since admission), +then one tile per event with the risk in the chosen window, a one-word +status against its alert line (Low, Watch, Alert on, Happened) and how that +compares with the average patient. Below: the risk chart with what comes +after "now" faded, what happened in the stay in plain words, the +conditions the model believes are present right now, and the what-if and +evidence tools. The 29-row concept heat strip, the alert-line statistics +and the next-token forecast are behind disclosures. Light theme by default; +a footer link switches to dark. + ## What is shown, and what is deliberately not Shown: the hazard heads' risk (hidden at and after the event's onset), alert