Streamlit MVP for running a Tidepool Loop risk assessment without a terminal.
This is the view layer only — all simulator/validation logic lives in
gui_runner.py, which stays in the
data-science-simulator
repo as the sanctioned in-process service layer.
The simulator is consumed as a pinned git ref, not an editable sibling
checkout — the two repos no longer need to be cloned side by side.
conda-environment.yml installs
git+https://github.com/tidepool-org/data-science-simulator@main
and is the single source of truth for the pins. The simulator is tracked on
main (not a fixed tag) because this GUI is an exploration wrapper only —
updates flow in on the next env rebuild with no re-tag or re-pin.
Two simulator paths — post_processing/severity_model.py and
scenario_configs/ — are not part of the installed package. gui_runner
does import severity_model, and ScenarioParserV2 resolves reusable.*
pointers from a path hardcoded relative to its own module
(<package>/../../scenario_configs/…, not any env var), so both paths must sit
beside the installed package. The packaged bundle vendors them from the same
pinned tag and its launcher symlinks them into site-packages
(scenario_configs/ and a top-level severity_model.py), making every
resolution native. For a dev checkout, an editable install already puts them in
the right place; LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT /
LOOP_RISK_GUI_POST_PROCESSING_DIR can redirect browsing/tests if needed.
Requires an arm64 conda (matches the committed arm64 .dylib in the
simulator — never use ambient conda or uv run):
conda env create -f conda-environment.yml
conda activate loop-risk-simulator-guistreamlit run streamlit_app.pyBuild a versioned, self-contained macOS bundle (pinned env spec + vendored
LoopAlgorithmToPython + vendored simulator orphan paths + app code + launcher),
then publish it as a GitHub Release asset:
python packaging/build_bundle.py build \
--simulator-ref main \
--simulator-repo ../data-science-simulator \
--swift-repo ../LoopAlgorithmToPython \
--output-dir dist/The release number comes from version.py (see "Versioning" below), so
--version is optional; passing one that disagrees with APP_VERSION fails the
build rather than producing an archive numbered differently from the app inside
it. The build also refuses to run if a local module streamlit_app.py imports is
missing from APP_ARTIFACTS (see "Bundled app artifacts" below).
Before publishing, run the bundle-boundary test against the bundle you just built. It is the only check that a built bundle actually launches; the build-time guard proves the files are staged, not that the app starts:
tar -xzf dist/loop-risk-simulator-gui-<version>.tar.gz -C /tmp/bundle-check
export LOOP_RISK_GUI_BUNDLE_DIR=/tmp/bundle-check
cd /tmp && ~/miniconda3/envs/<bundle-env>/bin/python -m pytest \
<gui-repo>/tests/test_phase4_bundle_integration.pyThe builder prints the exact gh release create … command to publish the
archive — publishing is a deliberate, separate step, never run automatically.
The bundle's run_simulator_gui.command establishes the arm64 env from the
pinned spec, builds the Swift .dylib on first run, symlinks the vendored paths
beside the package, and launches the app. (The polished colleague-facing
launcher UX is Phase 5.)
version.py's APP_VERSION is the single source of the app's version number.
The app renders it in the shared page header on every page, and
packaging/build_bundle.py reads the same constant for the archive name,
BUNDLE_VERSION.json's bundle_version, and the gui-bundle-v<version> release
tag. There is deliberately no second source: there is no pyproject.toml, so
importlib.metadata has no distribution to read; BUNDLE_VERSION.json exists
only inside a built bundle; and git describe fails in the shipped tarball.
Semantic versioning per SOP-0005 §7.1, read for this GUI as:
| Component | Bump when | SOP-0005 |
|---|---|---|
| MAJOR | Significant new capability, or a major change to what the user sees | §7.1.1.1 |
| MINOR | An ordinary new feature; a cosmetic or content change | §7.1.2.1–2 |
| PATCH | An anomaly (bug) fix — nothing else | §7.1.3.1 |
1.0.0 is the MVP internal release.
The displayed number means the last released version, not the state of the
working tree. It is bumped by hand as part of making a release, not on ticket
merge, and nothing enforces that — no CI check, no pre-commit hook, no
dirty-tree suffix. A checkout between releases therefore shows the number of the
release it followed. Build metadata (SOP-0005 §7.1.4, optional) is deliberately
not displayed; per-build provenance — the simulator ref and both resolved SHAs —
stays in the bundle's BUNDLE_VERSION.json.
python -m pytest tests/ # unit + Phase-3 integration (arm64 env)The Phase-4 bundle-boundary test (tests/test_phase4_bundle_integration.py) is
opt-in — it needs a built, extracted bundle. Run it with the bundle env's
interpreter and LOOP_RISK_GUI_BUNDLE_DIR set to the extracted bundle (see the
module docstring). Run everything with the conda env's own interpreter, never
uv run — the simulator and its deps are only available there.
What changed (≤100 words): Replaced the Phase-3 -e ../data-science-simulator
editable-sibling install with a pinned git tag, removing the "clone as
siblings" constraint. Added packaging/build_bundle.py, which produces a
versioned macOS bundle: it renders the pinned env spec from this repo's
conda-environment.yml, extracts the two non-packaged simulator paths
(severity_model.py, scenario_configs/ incl. reusable/) from the same tag,
vendors LoopAlgorithmToPython source, stamps provenance, and emits the publish
command. The launcher symlinks the vendored paths beside the package so
resolution is native. Phase-3 integration fixtures moved to a self-contained
temp library.
Validation (≤100 words): Packaging logic covered by unit tests (pin
rendering, version stamp, staging assembly — git boundary mocked). All six
Phase-3 integration cases pass from the relocated temp-library fixtures, writing
nothing into the installed library. A bundle-boundary integration test builds a
bundle, installs it into a fresh arm64 env with no sibling checkout, and
asserts the pinned (non-editable) simulator imports, severity_model resolves,
scenario_configs/reusable resolve beside the package, the version stamp
matches the built tag, and a real TLR-QAE-482-test run completes with a
populated assessment and non-blank PNGs.
Cautions / limitations: arm64 only (matches the committed .dylib); never
uv run. First run builds the Swift .dylib and a full conda env (minutes) and
needs GitHub connectivity to resolve the pinned deps. The bundle vendors the
whole reusable/ subtree (~18 MB). The launcher writes two symlinks into the
env's site-packages. .app freeze/sign/notarization and the colleague-facing
launcher UX are out of scope (later phases).
Breaking change + migration: The install/dev-setup contract changed —
siblings are no longer required and the env-spec format moved from -e ../… to
git+…@tag. To migrate: recreate the conda env from the updated
conda-environment.yml. For a live-checkout dev loop, use an editable install or
set LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT / LOOP_RISK_GUI_POST_PROCESSING_DIR.
Rollback (High regression risk): Revert the pinned
data-science-simulator line in conda-environment.yml back to
- -e ../data-science-simulator, re-clone the two repos as siblings, and
recreate the env. That restores the exact Phase-3 editable-sibling behavior.
tests/test_accessibility.py adds a regression-guarding layer for three basic
WCAG requirements against the app's own tokens and emitted markup — no browser,
Playwright, Selenium, or axe-core:
- 1.4.3 Contrast (unit): a small pure-Python
contrast_ratio()helper computes WCAG ratios for the text pairs the app renders. Tokens are read from their real sources (.streamlit/config.tomlandstreamlit_app._BRAND_CSS), so the ratios re-derive if a token changes. Gated pairs —textColoronbackgroundColor(~15.9:1) and the CSS-override text onsecondaryBackgroundColor(~14.7:1) — must clear 4.5:1 (normal text). - 2.1 Keyboard & 1.3 Adaptable (rendered smoke): via the existing
AppTest.from_fileharness — interactive widgets carry accessible labels, emitted<img>s keep alt text (guards the TRSET-3 logo), the app'sunsafe_allow_htmlblocks add no positivetabindex, and severity info is conveyed as text, not color alone. A full-run integration test asserts all three markers at the rendered-tree boundary against the real config library.
Example:
python -m pytest tests/test_accessibility.py # arm64 conda env, never `uv run`Known finding (not a gate): brand primaryColor #627CFF is ~3.6:1 on
white — below the 4.5:1 normal-text minimum. It is excluded from the
pass/fail gate per adjudication (brand color; remediation is a separate ticket);
one test documents the known value and flags loudly only if it shifts out of
band. Regression risk for this change is Low (additive, GUI-repo test layer;
no simulator or gui_runner change), so no rollback note applies.
What changed (≤100 words): Added a persistent regulatory disclaimer at the
top of the main page — a custom-styled role="alert" caution box (rendered via
st.markdown(..., unsafe_allow_html=True), above the logo/header on every load)
stating the tool is not medical software and must not drive insulin dosing
decisions. The wording lives in a single DISCLAIMER_TEXT constant. Colors
reuse the existing Tidepool palette — no new brand colors: background is the
theme's secondaryBackgroundColor (#281946), text is the _BRAND_CSS
override color (#F5F5FA). Styling is inline on the element, leaving _BRAND_CSS
untouched. It is a distinct box, not st.warning.
Example:
streamlit run streamlit_app.py # banner shows at the top of the pageValidation (≤100 words): Extends the existing AppTest.from_file harness —
no browser/Playwright/Selenium. test_streamlit_app.py adds a full-run
integration test asserting the banner renders once with the exact verbatim text,
carries the ⚠ icon and role="alert", and precedes the logo <img>; plus a
guard that it does not collide with at.warning. test_accessibility.py adds
the banner's #F5F5FA-on-#281946 pair to the contrast gate (~14.7:1 ≥ 4.5:1),
asserts it reuses existing palette tokens (never #627CFF), and checks the alert
is conveyed by text + semantics, not color alone. Full suite green (22 tests).
Cautions / limitations: Presentation layer only — no change to gui_runner,
the Loop algorithm interface, the scenario-config schema, or RTF output. Banner
is on the main page only, not exported artifacts. Regression risk Low–Medium
(shares the render path and interacts with the TRSET-4 contrast gate, but reuses
already-gated tokens); not a breaking change, so no rollback note applies.
What changed (≤100 words): The results pane no longer renders the generic
three-panel simulator PNGs. Each TLR-* expander now shows one row per VP
profile, each row three co-equal columns — Pre-mitigation | No Loop |
Post-mitigation — of TRSET-22 Loop-home-screen charts, read from that sim's
<sim_id>.tsv via TRSET-21's read_trace. gui_runner.RiskDirRunResult gains
an additive trace_paths field (scenario_config_filename -> {sim_id: tsv})
that supplies them. Stage identity comes from severity_model.classify_sim_id
and the order/labels from STAGE_ORDER/STAGE_DISPLAY, re-exported through
gui_runner so the view layer no longer redeclares them.
Example:
streamlit run streamlit_app.py # run a TLR directory; charts appear under its metrics tableValidation (≤100 words): An integration test runs the real
run_risk_assessment on the real test/TLR-QAE-482-test config directory
(copied into a temp library), then drives the returned trace paths through the
real read_trace → render_loop_home_screen, asserting non-blank 900×1100 PNGs,
and re-renders the resulting RunResult through AppTest to assert one chart per
present stage. That directory genuinely defines no no_loop stage, so the "No
data" placeholder is exercised against real data. Unit tests cover the profile
label parser, the (profile → stage) grouping, and the placeholder/error branches.
Full GUI suite: 108 passed, 7 skipped.
Cautions / limitations: The three stages are always drawn side by side as
equals with no selector and no auto-collapsing (TWI-0006 §2.g.ii). A missing
stage renders an explicit "No data"; an unreadable trace renders its own message
so a failed read stays distinguishable from an absent stage. PNG bytes are
st.cache_data-cached by trace path, since Streamlit re-executes the script on
every interaction and one directory is three charts per profile. png_paths and
plot_sim_results plumbing remains in gui_runner but is no longer rendered —
removing it is a follow-up. Known limitation: classify_sim_id does not
match three post-stage prefix spellings present in the library
(post-Loop_withMitigations_, post-Loop-withMitigations_,
post_Loop_WithMitigations_), so those directories show "No data" in the
Post-mitigation column — the same pre-existing gap their metrics-table row
already has; filed separately, not fixed here. Regression risk Medium (shared
runner entry point plus the app's main render path), contained by the
gui_runner change being purely additive: no existing field, signature, or
schema changed, so this is not a breaking change and no rollback note applies.
What changed (≤100 words): A completed run now offers an Export results
control that produces one zip and hands it over as a browser download. The zip
nests everything under a single risk_run_<timestamp>/ folder: every raw output
the run wrote (summary CSVs, <sim_id>.tsv traces, the simulator figures,
loop_algo_io/, metadata.json), the risk_summary_<sim_id>.rtf severity
summaries — generated at export time by the unmodified
create_severity_summary.process_results_directory — and a charts/ folder of
the Loop home-screen PNGs, one per VP profile × stage. Assembly lives in the new
streamlit-free export_bundle.py; gui_runner gains a metadata.json write and
re-exports the RTF renderer.
Example:
streamlit run streamlit_app.py # run a directory, then: Export results -> Download export (.zip)Chart files are named <TLR dir>_<profile>_<stage>.png (e.g.
TLR-909_Adolescent_profile_Pre-mitigation.png), sanitized to
[A-Za-z0-9._-]; a stage with no trace, or an unreadable one, is skipped and
listed in an on-screen warning rather than exported as a blank chart.
Validation (≤100 words): An integration test runs the real
run_risk_assessment on the real test/TLR-QAE-482-test config (copied into a
temp library), exports it for real, and asserts at the zip boundary:
metadata.json dated like the displayed assessment, an RTF whose bytes are
identical to the one on disk, charts/ holding exactly the two PNGs (900×1100)
for the stages that directory genuinely defines and none for the no_loop stage
it lacks, the raw outputs present, and nothing from outside save_dir. It then
drives the whole thing through AppTest. Unit tests cover naming/sanitization,
archive layout, the guard rails, and the control's states. GUI suite: 138
passed, 7 skipped. Simulator test_gui_runner 16/16, RTF renderer suite 32/32
unchanged.
Cautions / limitations: Export is two clicks by necessity —
st.download_button materializes its payload at render time, so a one-click
version would rebuild the zip on every rerun. The zip is built to a
session-scoped tempfile directory and written file-by-file (never assembled in
memory); its bytes are read once for the download and cached by path. A
cancelled run is not exportable, and a run directory missing metadata.json or
any TLR-* dir raises rather than shipping a summary-free zip — the two cases
process_results_directory otherwise only prints and returns on. Charts are
loose files: the filename carries their identity, not the app's profile × stage
grid. The classify_sim_id prefix gap noted under TRSET-23 applies here too — a
stage it cannot classify has no chart in the export, listed as skipped.
Regression risk Medium (GUI run path plus run-directory contents, which now also
carry metadata.json and the RTFs); additive only — no existing field,
signature, schema, or RTF byte changed — so this is not a breaking change and no
rollback note applies.
What changed (≤100 words): The app gained a second config source. Alongside
Choose from the config library, Configure meals & boluses lets a user define
meal and bolus entries directly and generate four runnable scenario configs — one per
T1 profile (Median, Resistant, Adolescent, Sensitive) — with TLR-<YYYYMMDD-HHMMSS>
as the risk id. Meal values come from one of three modes: the per-profile standard,
a 0.25-step multiplier of it, or one custom grams value. Generation lives in the new
streamlit-free meal_config.py; export_bundle gained an optional
generated_configs parameter. gui_runner is unchanged.
Example:
streamlit run streamlit_app.py # Config source -> Configure meals & boluses -> Generate configs -> Run ToolThe generated configs are written to a session temp directory laid out like the
library (with reusable/ symlinked so pointer resolution works as it does for a real
collection) and handed to run_risk_assessment as its config_dir — the
"configure parameters directly" mode streamlit_app.py's docstring reserves.
Nothing is ever written into scenario_configs/. They are downloadable on their
own (<risk_id>_configs.zip, nested under one <risk_id>/ folder) and ride along in
the run export under generated_configs/.
Generated configs use the library's own baseline template — the 2_0/swift base
configs (reusable.simulations.base_<profile>_2_0_v1), flat_110_12hr glucose, and
the post stage's target_range_<profile>_v1 + controller_settings_<profile>_swift
guardrails. Only carb_entries / bolus_entries come from the user. The three
sim_ids (pre-Loop_NoMitigations_, pre-noLoop_, post-Loop_WithMitigations_) all
classify through severity_model.classify_sim_id, so every stage reaches the results
grid and the export.
Validation (≤100 words): A single integration test drives the whole feature
through the real app with no mocks: configure → generate → validate_config_dir
(zero errors) → a real four-profile Swift run → export. It asserts at the boundaries —
the JSON on disk, the <sim_id>_override_config.json files the run itself wrote, and
the zip. Multiplier 2.0 really produced 62/66/120/50 g in the simulations; the
duration-less meal really resolved to 180 minutes in the real parser; the No Loop
stage really ran on its numeric bolus. 20 integration + 45 unit tests, ~53 s.
Full GUI suite: 213 passed, 7 skipped.
Cautions / limitations: Entries must fall inside the base configs' simulation
window (8/15/2019 12:00 + 8 h) — that window and the standard baselines
(31/33/60/25 g) are read from the library at generation time, never hardcoded, so
a library change flows straight through. accept_recommendation is written to
patient.patient_model only: ConfigValidator rejects it under
patient.pump.bolus_entries, where it would reach the Loop input JSON verbatim and
crash the Swift bridge's Double decode. Because the No Loop stage has no controller
to make a recommendation, any sentinel bolus must carry a numeric units value for
that stage; a blank dose is an error, never a silent 0 U. The mode is per
configuration but the value input is per meal row. Only settings/targets/schedules
and the t2 profiles remain out of scope (later stages). The scenario-config schema,
scenario_json_parser_v2.py and the Loop algorithm interface are untouched.
Regression risk Medium (shares validate_config_dir / run_risk_assessment / the
export control, and adds a second way to reach them); additive only — gui_runner is
unchanged and build_export_zip's new parameter is keyword-defaulted — so this is
not a breaking change and no rollback note applies.
One TRSET-4 follow-through: the editor introduced st.time_input, whose value
box sits on secondaryBackgroundColor but was not in _BRAND_CSS's text-color
override — the entered time rendered #281946 on #281946, an invisible field
(found in a running app, not by inspection). It needed its own selector rather than
the existing role="group"/input ones, because its value is a div inside a
baseweb select. test_accessibility.py gained a guard that derives the widget types
the app actually renders and fails if one is absent from the override; the guard was
confirmed to fail without the fix. It checks presence, not cascade — resolving the
cascade needs a browser, which that suite deliberately avoids.
Post-release fix — results never outlive their selection: as first shipped, the
results pane rendered unconditionally at the bottom of the page, so a previous run's
expanders and charts stayed on screen after switching config source or generating a
new config set. That misled in practice: a library physical-activity directory
(TLR-1117_bike — no meals, so an empty Active Carbohydrates panel by construction)
was still displayed under a freshly generated meal configuration and read as that
configuration's output. The displayed run is now cleared whenever the
selection behind it changes — switching config source, generating a new config set,
or picking another collection or TLR-* directory. One check (_sync_selection) covers
every case, keyed on (config source, config dir, target TLR dir), so the results pane
always matches the current selection or is empty. It is deliberately keyed on the
selection changing rather than on any rerun happening: the rerun that follows a run
completing must not wipe the run that just finished. Skipped while a run is in flight,
since that run owns the state and has nothing displayed yet.
What changed (≤100 words): The meal/bolus editor gained a duration picker, framed
by the investigative question rather than an hour count: overdelivery (8 h),
underdelivery (23 h), a full day (24 h), or a free 2.0–7.5 h short-term value in
0.5-h steps. One duration applies to the whole configuration and is written into
every override_config entry, where it replaces the base config's own. Window
arithmetic in meal_config.py is now datetime-aware, fixing a latent wrap that made
every entry check false past midnight. OverrideItem already declares
duration_hours; no parser, schema, gui_runner or severity_model change.
Example:
streamlit run streamlit_app.py # Configure meals & boluses -> Simulation duration -> Generate configs -> Run Toolimport meal_config
start, end, hours = meal_config.simulation_window(24.0) # datetimes; never wraps
earliest, latest = meal_config.authoring_window(24.0) # view bound: ends 23:59:59 on the start day
spec = meal_config.MealConfigSpec.aligned(mode, entries, duration_hours=24.0)Validation (≤100 words): Two real runs, no mocks
(tests/test_trset13_integration.py). A 2-hour run proves picker → duration_hours
in all twelve overrides → ConfigValidator → the merged config the run resolved →
a trace that stops at 14:00 rather than 20:00, and the results pane marking LBGI,
DKAI and Severity as not valid. A -m slow 24-hour run proves the midnight-crossing
path: a 23:30 entry kept where it was authored, and a trace reaching 8/16. The 8- and
23-hour presets are asserted at the config boundary. The slow run takes 4m25s
against roughly a minute for the 8-hour default. Full GUI suite: 292 passed,
7 skipped, 1 deselected (-m slow: 1 passed).
Cautions / limitations: Under 8 hours, LBGI and DKAI are not valid — the app
says so in the editor and marks the cells in the results, but the RTF summaries and
the export zip carry the raw values unmarked (deliberately out of scope; raise
separately before treating an exported short run as a record). Entries can only be
authored on the start calendar day, so a 23/24-hour run has no entries after
midnight; the run itself continues past it. Carb absorption still defaults to 180
minutes, so a sub-3-hour run truncates it. The duration applies to the
Configure meals & boluses path only — library configs keep their own
duration_hours, and gui_runner.run_risk_assessment's signature is unchanged. A
23/24-hour run takes roughly three times as long as the 8-hour default.
Internal shape changes (this repo only, no external consumers): MealEntry.start_time
and BolusEntry.time are datetime.datetime rather than datetime.time;
simulation_window() returns (start, end, duration_hours) as datetimes and takes an
optional duration; _window_bounds() is replaced by authoring_window(). The
start-day clamp lives in the view (streamlit_app._on_start_day), never inside
meal_config — adding multi-day authoring is a day control plus a widened bound, not
a rewrite.
What changed (≤100 words): Two fixes in loop_home_renderer.py. (1) loop_cob
is the Loop algorithm's carbs-on-board estimate, so it is absent on a stage that
runs no controller (pre-noLoop_*, controller: null); the renderer drew an
invisible all-NaN line and still legended it "Carbs on board", which reads as this
scenario had no carbs. The line and its legend entry are now omitted there and an
in-panel note says why. (2) x-limits are pinned to the data span, so an event at
simulation t=0 sat on the left spine with half its glyph outside the axes; carb
and dose markers now draw unclipped.
Example:
python -m pytest tests/test_loop_home_renderer.py # arm64 conda env, never `uv run`Validation (≤100 words): Diagnosed against a real run: the No Loop trace carries
the same carb events as the Loop stages (true_carb_value populated on all three;
loop_cob 97 points on the Loop stages, 0 on No Loop), and its glucose runs
110 → ~475 mg/dL — the meal absorbing unopposed. Marker position measured at fraction
0.0000 across the axis. Seven unit tests added, each mutation-checked: reverting
either fix fails exactly the tests that assert it. A populated-but-all-zero loop_cob
is deliberately still treated as computed. Full GUI suite: 228 passed, 7 skipped.
Cautions / limitations: There is no non-Loop COB series to substitute —
SimulationTrace exposes only loop_cob, and the TSV's other carb columns are the
discrete entry values/durations. So the No Loop panel shows carb-entry markers and the
note, never a curve. Deriving a patient-side COB would mean modelling absorption in
the view layer, which this deliberately does not do. clip_on=False lets a boundary
marker overhang the axes by half a glyph; that is the intent (the alternative, padding
the x-limits, would reintroduce the empty forward gutter TRSET-22 removed). This
touches the TRSET-22/23 renderer, not TRSET-9 code — the clipping predates this
branch; TRSET-9 only made it prominent by defaulting meals to simulation start.
What changed (≤100 words): TRSET-13 AC 11 scoped the sub-8-hour "not valid"
marking to the per-stage table. The Catastrophic findings (severity 4→5) table in
the same expander kept stating an updated_severity derived from the same LBGI, so
the two tables contradicted each other on a short run. That column now carries
SUB_MINIMUM_DURATION_CELL when meal_config.metrics_are_valid() is False, under a
caption saying the row set is LBGI-gated too. sim_id, stage and condition
still render: condition is read from the BG trace and holds at any duration.
Presentation only — streamlit_app.py alone, no severity_model change.
Example:
streamlit run streamlit_app.py # Simulation duration -> Short term (2 h) -> Generate configs -> Run Toolimport streamlit_app
streamlit_app.INVALIDATED_CATASTROPHIC_COLUMNS # ("updated_severity",)
streamlit_app._render_catastrophic_table(findings, metrics_valid=False)Validation (≤100 words): Six AppTest cases in tests/test_streamlit_app.py,
beside the TRSET-13 marking test, over a fixture holding both an escalated row
(zero_or_negative, 5) and an unescalated one (none, 4) — the pair proves the
marking is not keyed on the escalated value. Covered: marked at 2 h; rows and
condition survive rather than being suppressed; the row-set caption is present;
the SeverityAssessment is not mutated; unchanged at 8 h and on a library run
(no known duration). Mutation-checked — reverting streamlit_app.py fails four of
the six. Affected suites: 196 passed, 1 deselected, no new failures.
Cautions / limitations: Marking only. A short run's catastrophic findings still
reach the RTF summaries and the export zip unmarked — the same carve-out TRSET-13
recorded, still open, still to be raised separately before an exported short run is
treated as a record. The row set is reported unreliable, not corrected: severity_model
selects rows on lbgi_risk_score == 4 before check_catastrophic_conditions ever
runs, so a sim the short run mis-scored is never assessed for escalation at all, and
no view-layer change can recover it. extended_low needs 48 consecutive readings
under 40 mg/dL (4 hours at 5-minute steps), so it is unreachable below a ~4-hour run
while zero_or_negative is reachable at any length — the table's two conditions are
not equally available on short runs. Marking is keyed on the duration snapshot taken
at run start (_run_duration_hours), so a library run is never marked.
What changed (≤100 words): A start page now stands in front of the tool on a
fresh session. It carries a purpose statement (risk exploration, not formal risk
assessment; not medical software) and an AI code disclosure (the UI wrapper was
written with AI assistance; TRSET itself is unmodified, and every change went
through a human-defined, human-reviewed process). Got it advances to the app.
It lives in its own start_page.py as verbatim text constants plus a
zero-argument render(); the session gate is a few lines in main(). Page
config, _BRAND_CSS, the disclaimer banner and the logo run above the gate, so
both pages carry them.
Example:
streamlit run streamlit_app.py # start page first; Got it opens the toolValidation (≤100 words): tests/test_trset34_integration.py — the Feature
gate — drives the real app through AppTest in three phases: the unacknowledged
first render (start page present, text asserted against the module constants,
chrome present, no config radio/selectbox/results), a real Got it click
(tool present, start page gone, still exactly one role="alert" banner), and
the gate staying down across an unrelated widget change and repeated reruns —
the _init_session_state() clobbering failure mode. test_accessibility.py
gains start-page label/tabindex/alt-text/banner gates. Every other AppTest
suite bypasses the gate through one make_app_test factory in
tests/conftest.py. Full suite: 310 passed, 7 skipped.
Cautions / limitations: Per-session only, by design — a refresh shows the page
again, and nothing is persisted. No control returns to the start page once
acknowledged. The page is not in the export bundle, so a reviewer holding an
exported zip still sees no AI disclosure (separate ticket). The page title
restates the app title rather than importing it, so the two are kept in step by
a test rather than by construction; the naming inconsistency this originally
shipped with is resolved in TRSET-44 below.
st.navigation/st.Page was deliberately not adopted: AppTest renders only
the default page of a multipage app. render() takes no arguments and does not
act on its own button, so that migration can adopt it unchanged.
What changed (≤100 words): The tool is called the Risk Severity Evaluation
Tool everywhere. Three names for it used to render within a screenful:
st.title and set_page_config said "Estimation", start_page.PAGE_TITLE and
the purpose statement said "Estimation", DISCLAIMER_TEXT said "Evaluation".
"Evaluation" is correct — it is what TRSET expands to (Tidepool Risk Severity
Evaluation Tool), confirmed against the QMS documentation on 2026-09-01. A
one-word substitution at four sites; no behavioural change, and no other
wording touched. "Loop" stays a title-only qualifier (see Cautions).
Validation (≤100 words): tests/test_trset44_integration.py holds the
invariant. It extracts the name from each site with one regex and compares the
captures rather than reading the strings — TRSET-34 showed the eyeball check is
the one that misses — so a future edit to any single site fails here instead of
shipping a fresh split. Each of the four sites was mutation-checked
individually, and each failure was observed. Two independent gates: the
agreement test catches any divergence, a second test blocklists "Estimation" on
everything rendered. The set_page_config title is asserted at source level,
since AppTest does not expose it. Suite: 313 passed, 7 skipped (+3, this
file).
Cautions / limitations: The invariant is on the canonical core, "Risk
Severity Evaluation Tool", with "Loop" optional — not on the full string.
st.title and PAGE_TITLE name this GUI ("Tidepool Loop Risk Severity
Evaluation Tool"); DISCLAIMER_TEXT and the purpose statement name the
underlying tool without "Loop", and the purpose statement's "(TRSET)" makes its
occurrence the acronym's expansion, which has no "Loop" in it. So the
DISCLAIMER_TEXT body is unchanged by design. Nothing outside the app
UI renames: no export filename, no RTF content, no export-bundle path
contained the name, so there is no downstream break. The launcher's
"Tidepool Loop Risk Simulator GUI" is a different name and was left alone.
What changed (≤100 words): APP_ARTIFACTS staged five of the app's nine
runtime artifacts. streamlit_app.py imports loop_home_renderer,
export_bundle, meal_config and start_page at module level and none was
staged, so the builder produced a bundle that raised ModuleNotFoundError on the
colleague's first double-click — after env provisioning had already succeeded.
The four names are added, and pytest.ini with them, so the bundled tests/ run
under the repo's markers. The list was correct at Phase 4; TRSET-7, TRSET-9,
TRSET-22 and TRSET-34 each added a module without updating it, so a guard now
makes the list self-maintaining.
Validation (≤100 words): verify_app_artifacts_complete() parses
streamlit_app.py with ast — stdlib-only, parse-only, never imported — resolves
which imports are local by what exists in the repo (a second hardcoded list would
be the same defect one level up), and raises before any git or staging work.
Scope is one level of indirection, bounded on purpose. test_build_bundle.py
runs the guard against the real repo and mutation-checks it per module:
dropping any one of the four raises, and removing the call itself was observed to
fail the build-level test. Suite: 324 passed, 7 skipped.
Cautions / limitations: The guard reads module-level imports only — an import
nested inside a function or an if is not seen — and does not walk past one
level of indirection. It proves the files are staged, not that the bundle
launches: only tests/test_phase4_bundle_integration.py does that, and it is
opt-in and has not been run since TRSET-7. Running it needs a real arm64 conda
env and a built bundle; until it runs, this fix is verified by unit tests alone.
LICENSE is still unstaged (a separate call, not an ImportError).
What changed (≤100 words): The app displayed no version anywhere, and the
only version concept was build-time — build_bundle.py's required --version,
which nothing tied to the running app, so the two could disagree silently. A new
version.py holds APP_VERSION = "1.0.0" and is now the single source for both:
the app renders Version 1.0.0 in the shared header, above the start-page gate,
so every page carries it from one insertion point; --version becomes optional,
defaults to the constant, and raises if given a value that disagrees. version.py
ships in the bundle. See "Versioning" above for the SOP-0005 §7.1 reading.
Validation (≤100 words): tests/test_trset35_integration.py drives the real
app through a bare AppTest in four phases: the version on the unacknowledged
start-page render and still there after a real Got it click; the string
asserted against version.APP_VERSION rather than a literal, with a mutation
that repoints the constant and watches the screen follow; all three release
artifacts carrying APP_VERSION, with a disagreeing --version raising before
any archive exists; and version.py present in the built tree with its real
contents. Suite: 342 passed, 7 skipped.
Cautions / limitations: Rendered with st.markdown, not st.caption, on a
measured finding: Streamlit fades captions with opacity: 0.6 on the caption
container, compositing the theme's #281946 to #7E7590 on white — 4.34:1,
under the 4.5:1 WCAG 1.4.3 minimum for normal text (14px, weight 400, so the
large-text allowance does not apply). Measured in a running app on 2026-09-02 and
pinned by test_accessibility.py. The number is bumped manually and nothing
enforces it, so a stale number is possible between releases. Phases 3–4 mock the
git boundary (resolve_ref/extract_tree_paths) as test_build_bundle.py does;
the app repo they build from is real.
What changed (≤100 words): The Configure meals & boluses editor's first
control is a 250-character Risk description text area. Its text is written to
metadata.risk_description in all four generated configs, replacing the hardcoded
RISK_DESCRIPTION placeholder, and echoed in the generated-configs summary — read
back from the written JSON, not from the widget. meal_config.RISK_DESCRIPTION_MAX_CHARS
is the only limit; the widget reads it and _validate_risk_description mirrors it for
non-Streamlit callers. Text is stripped before the empty check, the limit check and
the write. Changing it invalidates a generated set, exactly as changing the duration
does. No parser, schema, gui_runner or severity_model change.
Example:
streamlit run streamlit_app.py # Configure meals & boluses -> Risk description -> Generate configsimport meal_config
spec = meal_config.MealConfigSpec.aligned(
mode, entries, risk_description=" Large evening meal\nno correction for 4 h "
)
meal_config.resolve_risk_description(None) # -> "GUI-configured meal and bolus entries"
meal_config.resolve_risk_description(" x \n ") # -> "x" (stripped, then written)An unfilled field falls back to the existing constant, so a spec that names no description produces byte-identical output to the version before this feature.
Validation (≤100 words): tests/test_trset36_integration.py — 15 tests, no
mocks, real configs under the LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT seam. Padded text
with an embedded newline reaches all four files stripped, newline serialized as \n,
the other three metadata keys untouched; empty and whitespace-only both write and
echo the constant — which is also the proof the echo reads the file, not the widget;
editing it drops the set, regenerating writes the new text, changing the meal count
drops nothing; generate_config() refuses 251 stripped characters, accepts 250.
test_trset13's real 2-hour run carries one into the export zip; test_accessibility
covers the new surface. Suite: 359 passed, 7 skipped,
1 deselected (-m slow: 1 passed).
Cautions / limitations: Counting is code points, as both max_chars and
len() count — an emoji or a combining accent may cost more than one, so 250 is not a
grapheme guarantee. The MealConfigError for an over-long description is unreachable
from the UI (max_chars blocks first) and exists to defend generate_config() for
another caller; that is by design, not a gap. The description is not surfaced in
the results pane or the severity RTF — those stay byte-identical, and surfacing it in
both together is a separate item. The library path is untouched: the descriptions its
configs already carry are still never displayed. The echo goes through st.caption,
so markdown in the text renders as markdown (HTML is escaped, not executed); the file
keeps the text verbatim. And library configs spell the neighbouring key risk-id
where generate_config() writes risk_id — accepted by ScenarioMetadata by design,
untouched here, and still needing a decision.
Two Streamlit behaviours this feature rests on, measured in a running app on
2026-09-02 rather than assumed. (1) max_chars is enforced in Streamlit's own JS, not
as an HTML maxlength, and an over-limit edit is rejected whole rather than
truncated: at 250 characters further keystrokes and an over-long paste do nothing,
with no message — which is what makes the mirrored MealConfigError unreachable from
the UI, and also means a user pasting 300 characters sees the field stay empty.
(2) st.text_area commits on blur (or Ctrl+Enter), not per keystroke, so a generated
set is dropped when the field loses focus rather than vanishing mid-sentence.
What changed (≤100 words): The Configure meals & boluses editor gains two radio
groups: Controller settings (Tidepool Loop 2.x, default; Tidepool Loop 1.x) and
Dosing strategy (Autobolus, default; Temp basal). The settings group chooses
base_config — base_<profile>_2_0_v1 or base_<profile>_1dotx — for all four T1
profiles. 2.x + Temp basal writes partial_application_factor: 0.0 on both Loop
stages, inlining the guardrails file's contents on the post-mitigation stage. Both
choices land in metadata and are echoed in the summary, read back from the JSON.
Changing either invalidates a generated set. No parser, schema, gui_runner or
severity_model change.
Loop 1.x is temp-basal only, and the two controls are coupled accordingly: selecting
it removes Autobolus from the options rather than showing it and ignoring it.
1dotX.json already ships partial_application_factor: 0.0, and SwiftLoopController
picks recommendationType from the truthiness of that value alone, so "1.x +
Autobolus" would mean overriding the very settings file the choice names — and matches
no released Loop, since autobolus arrived with 2.x. For the same reason 1.x writes no
override at all: the base already resolves to temp basal.
maximum_autobolus is never written. resolve_override applies only keys already
present in the resolved base and then raises "Only applied N of M overriding values"
on a count mismatch, so writing it would hard-fail the run rather than be ignored.
Example:
streamlit run streamlit_app.py # Configure meals & boluses -> Controller settings -> Generate configsimport meal_config
two_x, one_x = meal_config.SETTINGS_GROUPS
spec = meal_config.MealConfigSpec.aligned(
mode, entries, settings_group=one_x, dosing_strategy=meal_config.DOSING_AUTOBOLUS
)
spec.resolved_dosing_strategy # -> "Temp basal" (1.x forces it)
spec.writes_temp_basal_override # -> False (1dotX.json already says 0.0)Breaking change (§6): the generated-config output schema moves. Every generated
file's metadata gains controller_settings_group and dosing_strategy, and in the
2.x + Temp basal combination the pre-mitigation stage gains a controller key it did
not have before. The only consumer is the export bundle, which ships the configs
verbatim. Additions are safe for the run: every model in schema_models.py sets
extra="allow", and ScenarioParserV2 reads only metadata["simulation_id"] from
that block. Rollback note: reverting restores the four-key metadata and removes
the pre-mitigation controller key; configs generated while this was in place stay
readable, since both additions are ignored by the parser.
Validation (≤100 words): tests/test_trset15_integration.py — 46 tests to the
approved five-phase plan, no mocks, real configs under the
LOOP_RISK_GUI_SCENARIO_CONFIGS_ROOT seam. Per-combination config shape across all
four profiles; the guardrails inline dict compared against the file read at test time,
never against literals; maximum_autobolus asserted absent recursively; a no-delta
comparison against the committed pre-change fixture; the 1.x coupling and the
Autobolus-selected transition; metadata, echo and invalidation both ways; every
combination validated and parsed; one real 2-hour end-to-end run on 2.x + Temp basal.
Three mutation checks kill their tests. Suite: 405 passed, 7 skipped, 1 deselected.
Cautions / limitations: (1) Switching to Loop 1.x and back to 2.x leaves Temp
basal selected rather than restoring Autobolus — the last thing shown selected is
what stays selected, since silently re-enabling a dosing strategy is the worse
surprise. (2) The info text and per-option captions are written from the ticket's
described behaviour, not copied from its verbatim UI copy, which is not recorded
in the request; the tests assert the module constants rather than the strings, so
replacing the wording is a one-line change. (3) Streamlit radios have no per-option
tooltip, so the per-option text renders as captions beneath each option — more
discoverable than a hover tooltip, but not hidden. (4) Loop 1.x reaches the four
base_<profile>_1dotx configs, whose settings pointer casing is corrected by
simulator-side TRSET-48; on a case-sensitive filesystem 1.x is broken until that
lands. (5) Loop 1.x results change when simulator-side TRSET-49 lands — this
suite deliberately asserts config shape only, never 1.x results.