iints-sdk-python35 1.5.2__py3-none-any.whl → 1.5.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. iints/__init__.py +20 -3
  2. iints/ai/cli.py +8 -8
  3. iints/analysis/__init__.py +44 -0
  4. iints/analysis/baseline.py +7 -2
  5. iints/analysis/booth_demo.py +286 -0
  6. iints/analysis/carelink_workbench.py +1 -1
  7. iints/analysis/eucys_results.py +549 -0
  8. iints/analysis/hardware_benchmark.py +17 -7
  9. iints/analysis/reporting.py +5 -2
  10. iints/analysis/study_analysis.py +727 -159
  11. iints/analysis/study_engine.py +442 -0
  12. iints/analysis/study_experiment.py +168 -0
  13. iints/analysis/study_poster.py +267 -73
  14. iints/analysis/study_protocol.py +161 -164
  15. iints/cli/cli.py +3028 -607
  16. iints/cli/patient_cli.py +100 -15
  17. iints/core/algorithms/clinical_baseline.py +151 -0
  18. iints/core/algorithms/pid_controller.py +22 -6
  19. iints/core/patient/models.py +27 -8
  20. iints/core/simulator.py +2 -1
  21. iints/data/nightscout.py +4 -0
  22. iints/data/registry.py +36 -1
  23. iints/data/tidepool.py +5 -0
  24. iints/highlevel.py +9 -3
  25. iints/live_patient/__init__.py +23 -1
  26. iints/live_patient/api.py +182 -51
  27. iints/live_patient/daemon.py +22 -1
  28. iints/live_patient/edge_benchmark.py +3 -3
  29. iints/live_patient/edge_ops.py +855 -25
  30. iints/live_patient/long_study.py +885 -0
  31. iints/live_patient/runtime.py +101 -20
  32. iints/live_patient/service_export.py +507 -0
  33. iints/live_patient/uno_q.py +376 -0
  34. iints/scenarios/study_pack.py +4 -7
  35. iints/templates/uno_q/README.md +99 -10
  36. iints/templates/uno_q/iints_supervisor_bridge.ino +4 -0
  37. iints/utils/csv_safety.py +31 -0
  38. iints/utils/url_safety.py +33 -0
  39. iints_sdk_python35-1.5.4.dist-info/METADATA +200 -0
  40. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/RECORD +46 -39
  41. iints_sdk_python35-1.5.2.dist-info/METADATA +0 -120
  42. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/WHEEL +0 -0
  43. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/entry_points.txt +0 -0
  44. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/LICENSE +0 -0
  45. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/LICENSE-MIT-IINTS-LEGACY +0 -0
  46. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/licenses/NOTICE +0 -0
  47. {iints_sdk_python35-1.5.2.dist-info → iints_sdk_python35-1.5.4.dist-info}/top_level.txt +0 -0
iints/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # src/iints/__init__.py
2
2
 
3
3
  import pandas as pd # Required for type hints like pd.DataFrame
4
- from typing import Optional
4
+ from typing import Any, Optional
5
5
 
6
6
  try:
7
7
  from importlib.metadata import PackageNotFoundError, version
@@ -11,7 +11,7 @@ except ImportError: # pragma: no cover - Python < 3.8 fallback
11
11
  try:
12
12
  __version__ = version("iints-sdk-python35")
13
13
  except PackageNotFoundError: # pragma: no cover - source tree fallback
14
- __version__ = "1.5.2"
14
+ __version__ = "1.5.4"
15
15
 
16
16
  # Note to developers: this SDK is currently maintained by a single author.
17
17
  # Please report bugs via GitHub issues and feel free to contribute fixes via PRs.
@@ -95,9 +95,26 @@ from .live_patient import (
95
95
  summarize_edge_workspace,
96
96
  write_edge_update_script,
97
97
  )
98
- from .highlevel import run_simulation, run_full, run_population
99
98
  from .scenarios import ScenarioGeneratorConfig, generate_random_scenario
100
99
 
100
+
101
+ def run_simulation(*args: Any, **kwargs: Any) -> Any:
102
+ from .highlevel import run_simulation as _run_simulation
103
+
104
+ return _run_simulation(*args, **kwargs)
105
+
106
+
107
+ def run_full(*args: Any, **kwargs: Any) -> Any:
108
+ from .highlevel import run_full as _run_full
109
+
110
+ return _run_full(*args, **kwargs)
111
+
112
+
113
+ def run_population(*args: Any, **kwargs: Any) -> Any:
114
+ from .highlevel import run_population as _run_population
115
+
116
+ return _run_population(*args, **kwargs)
117
+
101
118
  try:
102
119
  from .analysis.booth_demo import build_booth_demo
103
120
  except Exception as exc: # pragma: no cover - optional reports stack
iints/ai/cli.py CHANGED
@@ -160,7 +160,7 @@ def _render_local_check(console: Console, status: dict[str, object]) -> None:
160
160
  console.print("[bold red]Ollama is too old for the open Ministral 3 runtime.[/bold red]")
161
161
 
162
162
 
163
- @app.command("models")
163
+ @app.command(name="models")
164
164
  def models() -> None:
165
165
  console = Console()
166
166
  table = Table(title="IINTS AI Local Mistral Model Guide")
@@ -189,7 +189,7 @@ def models() -> None:
189
189
  )
190
190
 
191
191
 
192
- @app.command("prepare")
192
+ @app.command(name="prepare")
193
193
  def prepare(
194
194
  run_dir: Annotated[Path, typer.Argument(help="Run output directory containing results.csv and run_metadata.json.")],
195
195
  create_dev_mdmp_cert: Annotated[
@@ -254,7 +254,7 @@ def _build_assistant(
254
254
  )
255
255
 
256
256
 
257
- @app.command("local-check")
257
+ @app.command(name="local-check")
258
258
  def local_check(
259
259
  model: Annotated[str, typer.Option(help="Ollama model name to validate locally.")] = DEFAULT_MINISTRAL_MODEL,
260
260
  ollama_host: Annotated[Optional[str], typer.Option(help="Override the Ollama base URL.")] = None,
@@ -294,7 +294,7 @@ def local_check(
294
294
  raise typer.Exit(code=1)
295
295
 
296
296
 
297
- @app.command("explain")
297
+ @app.command(name="explain")
298
298
  def explain(
299
299
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with a single simulation step or decision context.")],
300
300
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -335,7 +335,7 @@ def explain(
335
335
  raise typer.Exit(code=1)
336
336
 
337
337
 
338
- @app.command("trends")
338
+ @app.command(name="trends")
339
339
  def trends(
340
340
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with glucose trace data or a run payload.")],
341
341
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -376,7 +376,7 @@ def trends(
376
376
  raise typer.Exit(code=1)
377
377
 
378
378
 
379
- @app.command("anomalies")
379
+ @app.command(name="anomalies")
380
380
  def anomalies(
381
381
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with simulation results or run summary.")],
382
382
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -417,7 +417,7 @@ def anomalies(
417
417
  raise typer.Exit(code=1)
418
418
 
419
419
 
420
- @app.command("report")
420
+ @app.command(name="report")
421
421
  def report(
422
422
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with run-level simulation outputs.")],
423
423
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -458,7 +458,7 @@ def report(
458
458
  raise typer.Exit(code=1)
459
459
 
460
460
 
461
- @app.command("review")
461
+ @app.command(name="review")
462
462
  def review(
463
463
  input_json: Annotated[Path, typer.Argument(help="Prepared run directory or JSON file with run-level simulation outputs.")],
464
464
  mdmp_cert: Annotated[Optional[Path], typer.Option(help="Signed MDMP artifact required before AI analysis can run.")] = None,
@@ -1,10 +1,26 @@
1
1
  from .clinical_metrics import ClinicalMetricsCalculator, ClinicalMetricsResult
2
2
  from .baseline import compute_metrics, run_baseline_comparison, write_baseline_comparison
3
+ from .study_engine import (
4
+ StudyAlgorithmSpec,
5
+ StudyArmSpec,
6
+ StudyDesignPayload,
7
+ StudyMatrixRow,
8
+ StudyProfileSpec,
9
+ build_algorithm_registry,
10
+ build_study_design_payload,
11
+ resolve_profile_specs,
12
+ )
3
13
  from .study_protocol import (
4
14
  build_study_protocol_payload,
5
15
  render_study_protocol_markdown,
6
16
  write_study_protocol_bundle,
7
17
  )
18
+ from .study_experiment import (
19
+ StudyExperimentConfig,
20
+ build_study_experiment_template,
21
+ load_study_experiment_config,
22
+ render_study_experiment_yaml,
23
+ )
8
24
  from .study_analysis import (
9
25
  analyze_run_directory,
10
26
  analyze_study_directory,
@@ -15,6 +31,15 @@ from .study_analysis import (
15
31
  StudyRunSummary,
16
32
  StudySummary,
17
33
  )
34
+ from .eucys_results import (
35
+ build_eucys_abstract_draft_markdown,
36
+ build_eucys_filled_abstract_markdown,
37
+ build_eucys_jury_qa_markdown,
38
+ build_eucys_limitations_and_ethics_markdown,
39
+ build_eucys_poster_outline_markdown,
40
+ generate_eucys_main_figure,
41
+ generate_eucys_results_bundle,
42
+ )
18
43
 
19
44
 
20
45
  def _missing_reports_dependency(feature: str, exc: Exception) -> None:
@@ -80,15 +105,34 @@ __all__ = [
80
105
  "ClinicalReportGenerator",
81
106
  "compute_metrics",
82
107
  "compare_studies",
108
+ "build_eucys_abstract_draft_markdown",
109
+ "build_eucys_filled_abstract_markdown",
110
+ "build_eucys_jury_qa_markdown",
111
+ "build_eucys_limitations_and_ethics_markdown",
112
+ "build_eucys_poster_outline_markdown",
113
+ "generate_eucys_main_figure",
114
+ "resolve_profile_specs",
115
+ "generate_eucys_results_bundle",
83
116
  "generate_results_poster",
84
117
  "generate_study_poster",
118
+ "build_algorithm_registry",
119
+ "build_study_design_payload",
85
120
  "build_study_protocol_payload",
121
+ "build_study_experiment_template",
86
122
  "render_study_protocol_markdown",
123
+ "render_study_experiment_yaml",
87
124
  "write_study_protocol_bundle",
125
+ "load_study_experiment_config",
88
126
  "load_study_summary",
89
127
  "quality_badges_for_metrics",
90
128
  "run_baseline_comparison",
129
+ "StudyAlgorithmSpec",
130
+ "StudyArmSpec",
91
131
  "StudyComparison",
132
+ "StudyDesignPayload",
133
+ "StudyExperimentConfig",
134
+ "StudyMatrixRow",
135
+ "StudyProfileSpec",
92
136
  "StudyRunSummary",
93
137
  "StudySummary",
94
138
  "write_baseline_comparison",
@@ -8,10 +8,12 @@ import pandas as pd
8
8
 
9
9
  from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
10
10
  from iints.api.base_algorithm import InsulinAlgorithm
11
+ from iints.core.algorithms.clinical_baseline import ClinicalBaselineAlgorithm
11
12
  from iints.core.algorithms.pid_controller import PIDController
12
13
  from iints.core.algorithms.standard_pump_algo import StandardPumpAlgorithm
13
14
  from iints.core.patient.models import PatientModel
14
15
  from iints.core.simulator import Simulator
16
+ from iints.utils.csv_safety import sanitize_csv_dataframe
15
17
  from iints.validation import build_stress_events
16
18
 
17
19
 
@@ -50,7 +52,10 @@ def run_baseline_comparison(
50
52
  }
51
53
  )
52
54
 
53
- baselines: List[Tuple[str, InsulinAlgorithm]] = [("Standard PID", PIDController())]
55
+ baselines: List[Tuple[str, InsulinAlgorithm]] = [
56
+ ("Clinical Baseline", ClinicalBaselineAlgorithm()),
57
+ ("Standard PID", PIDController()),
58
+ ]
54
59
  if compare_standard_pump:
55
60
  baselines.append(("Standard Pump", StandardPumpAlgorithm()))
56
61
 
@@ -88,5 +93,5 @@ def write_baseline_comparison(comparison: Dict[str, Any], output_dir: Path) -> D
88
93
  json_path = output_dir / "baseline_comparison.json"
89
94
  csv_path = output_dir / "baseline_comparison.csv"
90
95
  json_path.write_text(json.dumps(comparison, indent=2))
91
- pd.DataFrame(comparison.get("rows", [])).to_csv(csv_path, index=False)
96
+ sanitize_csv_dataframe(pd.DataFrame(comparison.get("rows", []))).to_csv(csv_path, index=False)
92
97
  return {"json": str(json_path), "csv": str(csv_path)}
@@ -9,6 +9,7 @@ from iints.ai.prepare import prepare_ai_ready_artifacts
9
9
  from iints.analysis.poster import generate_results_poster
10
10
  from iints.core.algorithms.mock_algorithms import RunawayAIAlgorithm
11
11
  from iints.core.algorithms.pid_controller import PIDController
12
+ from iints.core.safety.config import SafetyConfig
12
13
  from iints.highlevel import run_full
13
14
 
14
15
 
@@ -22,6 +23,17 @@ class BoothScenarioSpec:
22
23
  algorithm_factory: Callable[[], Any]
23
24
 
24
25
 
26
+ @dataclass(frozen=True)
27
+ class ShowcaseRunSpec:
28
+ run_dir: Path
29
+ algorithm_factory: Callable[[], Any]
30
+ algorithm_name: str
31
+ algorithm_role: str
32
+ study_arm: str
33
+ condition_group: str
34
+ supervisor_enabled: bool
35
+
36
+
25
37
  def _scenario_specs() -> list[BoothScenarioSpec]:
26
38
  return [
27
39
  BoothScenarioSpec(
@@ -138,6 +150,269 @@ def _write_text(path: Path, content: str) -> None:
138
150
  path.write_text(content, encoding="utf-8")
139
151
 
140
152
 
153
+ def _booth_profile_id(patient_config: str | Path | dict[str, Any]) -> str:
154
+ if isinstance(patient_config, dict):
155
+ return str(patient_config.get("patient_name") or patient_config.get("profile_id") or "booth_demo_patient")
156
+ if isinstance(patient_config, Path):
157
+ return patient_config.stem
158
+ return Path(str(patient_config)).stem if str(patient_config).endswith(".json") else str(patient_config)
159
+
160
+
161
+ def _booth_supervisor_off_safety_config() -> SafetyConfig:
162
+ return SafetyConfig(
163
+ min_glucose=10.0,
164
+ max_glucose=1000.0,
165
+ max_glucose_delta_per_5_min=250.0,
166
+ hypoglycemia_threshold=-1000.0,
167
+ severe_hypoglycemia_threshold=-1000.0,
168
+ hyperglycemia_threshold=10000.0,
169
+ max_insulin_per_bolus=1000.0,
170
+ glucose_rate_alarm=-1000.0,
171
+ max_insulin_per_hour=1000.0,
172
+ max_iob=1000.0,
173
+ trend_stop=-1000.0,
174
+ hypo_cutoff=-1000.0,
175
+ predicted_hypoglycemia_threshold=-1000.0,
176
+ predictor_uncertainty_gate_enabled=False,
177
+ predictor_ood_gate_enabled=False,
178
+ contract_enabled=False,
179
+ critical_glucose_threshold=-1000.0,
180
+ critical_glucose_duration_minutes=100000,
181
+ )
182
+
183
+
184
+ def _annotate_showcase_run(
185
+ run_dir: Path,
186
+ *,
187
+ study_arm: str,
188
+ condition_group: str,
189
+ algorithm_name: str,
190
+ algorithm_role: str,
191
+ profile_id: str,
192
+ scenario_slug: str,
193
+ supervisor_enabled: bool,
194
+ ) -> None:
195
+ from iints.analysis.study_engine import slugify_study_token
196
+
197
+ algorithm_id = slugify_study_token(algorithm_name)
198
+ for candidate in (run_dir / "run_metadata.json", run_dir / "config.json"):
199
+ payload: dict[str, Any]
200
+ if candidate.is_file():
201
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
202
+ else:
203
+ payload = {}
204
+
205
+ if candidate.name == "run_metadata.json":
206
+ config = payload.get("config", {}) if isinstance(payload.get("config"), dict) else {}
207
+ else:
208
+ config = payload if isinstance(payload, dict) else {}
209
+
210
+ config["study_condition"] = study_arm
211
+ config["study_arm"] = study_arm
212
+ config["condition_group"] = condition_group
213
+ config["study_protocol_preset"] = "showcase_demo"
214
+ config["algorithm_id"] = algorithm_id
215
+ config["algorithm_role"] = algorithm_role
216
+ config["profile_id"] = profile_id
217
+ config["scenario_slug"] = scenario_slug
218
+ config["supervisor_enabled"] = supervisor_enabled
219
+ config["corruption_modes"] = []
220
+ scenario_payload = config.get("scenario", {}) if isinstance(config.get("scenario"), dict) else {}
221
+ scenario_payload["condition_group"] = condition_group
222
+ scenario_payload["study_arm"] = study_arm
223
+ scenario_payload["study_protocol_preset"] = "showcase_demo"
224
+ scenario_payload["scenario_slug"] = scenario_slug
225
+ scenario_payload["supervisor_enabled"] = supervisor_enabled
226
+ config["scenario"] = scenario_payload
227
+
228
+ if candidate.name == "run_metadata.json":
229
+ payload["config"] = config
230
+ payload["algorithm_id"] = algorithm_id
231
+ payload["algorithm_role"] = algorithm_role
232
+ payload["profile_id"] = profile_id
233
+ else:
234
+ payload = config
235
+
236
+ candidate.write_text(json.dumps(payload, indent=2), encoding="utf-8")
237
+
238
+
239
+ def _write_showcase_research_sync(
240
+ *,
241
+ output_dir: Path,
242
+ patient_config: str | Path | dict[str, Any],
243
+ duration_minutes: int,
244
+ time_step: int,
245
+ seed: int,
246
+ ai_status: str,
247
+ ) -> dict[str, str]:
248
+ from iints.analysis.study_analysis import analyze_study_directory, compare_studies
249
+ from iints.analysis.study_poster import generate_study_poster
250
+
251
+ profile_id = _booth_profile_id(patient_config)
252
+ showcase_dir = output_dir / "showcase_study"
253
+ baseline_dir = showcase_dir / "baseline_reference" / "pid_supervisor_override"
254
+ candidate_on_dir = showcase_dir / "candidate_safety_on" / "runaway_supervisor_override"
255
+ candidate_off_dir = showcase_dir / "candidate_safety_off" / "runaway_supervisor_override"
256
+
257
+ supervisor_spec = next(spec for spec in _scenario_specs() if spec.slug == "03_supervisor_override")
258
+ showcase_runs = [
259
+ ShowcaseRunSpec(
260
+ run_dir=baseline_dir,
261
+ algorithm_factory=PIDController,
262
+ algorithm_name="PID Controller",
263
+ algorithm_role="baseline",
264
+ study_arm="showcase_baseline_vs_candidate",
265
+ condition_group="showcase_baseline_vs_candidate",
266
+ supervisor_enabled=True,
267
+ ),
268
+ ShowcaseRunSpec(
269
+ run_dir=candidate_on_dir,
270
+ algorithm_factory=supervisor_spec.algorithm_factory,
271
+ algorithm_name="Runaway AI Candidate",
272
+ algorithm_role="candidate",
273
+ study_arm="showcase_baseline_vs_candidate",
274
+ condition_group="showcase_baseline_vs_candidate",
275
+ supervisor_enabled=True,
276
+ ),
277
+ ShowcaseRunSpec(
278
+ run_dir=candidate_off_dir,
279
+ algorithm_factory=supervisor_spec.algorithm_factory,
280
+ algorithm_name="Runaway AI Candidate",
281
+ algorithm_role="candidate",
282
+ study_arm="showcase_candidate_safety_off",
283
+ condition_group="showcase_candidate_safety_off",
284
+ supervisor_enabled=False,
285
+ ),
286
+ ]
287
+
288
+ for run_spec in showcase_runs:
289
+ run_full(
290
+ algorithm=run_spec.algorithm_factory(),
291
+ scenario=supervisor_spec.scenario,
292
+ patient_config=patient_config,
293
+ duration_minutes=duration_minutes,
294
+ time_step=time_step,
295
+ seed=seed,
296
+ output_dir=run_spec.run_dir,
297
+ safety_config=None if run_spec.supervisor_enabled else _booth_supervisor_off_safety_config(),
298
+ enable_profiling=False,
299
+ )
300
+ _annotate_showcase_run(
301
+ run_spec.run_dir,
302
+ study_arm=run_spec.study_arm,
303
+ condition_group=run_spec.condition_group,
304
+ algorithm_name=run_spec.algorithm_name,
305
+ algorithm_role=run_spec.algorithm_role,
306
+ profile_id=profile_id,
307
+ scenario_slug="showcase_supervisor_override",
308
+ supervisor_enabled=run_spec.supervisor_enabled,
309
+ )
310
+
311
+ summary = analyze_study_directory(showcase_dir)
312
+ payload = summary.to_dict()
313
+ summary_json = showcase_dir / "showcase_study_summary.json"
314
+ summary_md = showcase_dir / "showcase_study_summary.md"
315
+ summary_json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
316
+ summary_md.write_text(
317
+ "# Showcase Research Sync\n\n"
318
+ "This mini-study mirrors the benchmark story used elsewhere in the SDK.\n\n"
319
+ f"- Profile: `{profile_id}`\n"
320
+ "- Scenario: `showcase_supervisor_override`\n"
321
+ "- Baseline: `PID Controller`\n"
322
+ "- Candidate: `Runaway AI Candidate`\n"
323
+ "- Safety comparison: candidate with supervisor on vs candidate with supervisor off\n",
324
+ encoding="utf-8",
325
+ )
326
+
327
+ poster_outputs = generate_study_poster(
328
+ summary,
329
+ output_path=showcase_dir / "showcase_study_poster.png",
330
+ title="IINTS Showcase Benchmark Sync",
331
+ subtitle="Baseline vs candidate, plus safety-on vs safety-off, with the same metrics used in study bundles.",
332
+ summary_output_path=showcase_dir / "showcase_study_poster.json",
333
+ )
334
+
335
+ baseline_vs_candidate = compare_studies(baseline_dir, candidate_on_dir, left_label="PID baseline", right_label="Candidate safety on").to_dict()
336
+ safety_on_vs_off = compare_studies(candidate_on_dir, candidate_off_dir, left_label="Candidate safety on", right_label="Candidate safety off").to_dict()
337
+ comparisons_dir = showcase_dir / "comparisons"
338
+ comparisons_dir.mkdir(parents=True, exist_ok=True)
339
+ baseline_vs_candidate_json = comparisons_dir / "baseline_vs_candidate.json"
340
+ safety_on_vs_off_json = comparisons_dir / "candidate_safety_on_vs_off.json"
341
+ baseline_vs_candidate_json.write_text(json.dumps(baseline_vs_candidate, indent=2), encoding="utf-8")
342
+ safety_on_vs_off_json.write_text(json.dumps(safety_on_vs_off, indent=2), encoding="utf-8")
343
+
344
+ baseline_delta = baseline_vs_candidate.get("delta", {})
345
+ safety_delta = safety_on_vs_off.get("delta", {})
346
+ explanation_lines = [
347
+ "# Showcase Explanation Panel",
348
+ "",
349
+ "Use this panel when you want the booth story to match the benchmark language used in the scientific workflow.",
350
+ "",
351
+ "## Baseline vs candidate",
352
+ "",
353
+ "- Baseline: `PID Controller`",
354
+ "- Candidate: `Runaway AI Candidate`",
355
+ f"- TIR delta (baseline - candidate): `{baseline_delta.get('mean_tir_70_180')}`",
356
+ f"- Intervention delta (baseline - candidate): `{baseline_delta.get('mean_supervisor_interventions')}`",
357
+ "",
358
+ "## Safety on vs safety off",
359
+ "",
360
+ "- Same candidate algorithm, same profile, same scenario",
361
+ f"- TIR delta (safety on - safety off): `{safety_delta.get('mean_tir_70_180')}`",
362
+ f"- Severe hypo delta: `{safety_delta.get('severe_hypo_runs')}`",
363
+ f"- Early termination delta: `{safety_delta.get('terminated_early_runs')}`",
364
+ "",
365
+ "## Plain-language talking points",
366
+ "",
367
+ "- This panel compares a stable baseline with a deliberately bad candidate.",
368
+ "- Then it compares the same candidate with the safety layer on and off.",
369
+ "- That makes the public demo line up with the benchmark logic used in the full study engine.",
370
+ "",
371
+ ]
372
+ explanation_panel = showcase_dir / "SHOWCASE_EXPLANATION_PANEL.md"
373
+ _write_text(explanation_panel, "\n".join(explanation_lines))
374
+
375
+ sync_lines = [
376
+ "# Showcase Research Sync",
377
+ "",
378
+ "This artifact links the fair demo to the scientific benchmark language used elsewhere in the SDK.",
379
+ "",
380
+ "## What this mirrors",
381
+ "",
382
+ "- Baseline vs candidate comparison",
383
+ "- Safety-on vs safety-off comparison",
384
+ "- The same TIR, hypo, intervention, uncertainty, and calibration vocabulary used in `run-study` bundles",
385
+ "",
386
+ "## Files",
387
+ "",
388
+ f"- Summary JSON: `{summary_json}`",
389
+ f"- Poster PNG: `{poster_outputs['poster_png']}`",
390
+ f"- Baseline vs candidate: `{baseline_vs_candidate_json}`",
391
+ f"- Safety on vs off: `{safety_on_vs_off_json}`",
392
+ "",
393
+ "## AI explanation note",
394
+ "",
395
+ ai_status,
396
+ "",
397
+ "If local AI is available, use the candidate safety-on run as the explanation target so the live explanation matches the benchmark story.",
398
+ "",
399
+ ]
400
+ sync_markdown = showcase_dir / "SHOWCASE_RESEARCH_SYNC.md"
401
+ _write_text(sync_markdown, "\n".join(sync_lines))
402
+
403
+ return {
404
+ "showcase_study_dir": str(showcase_dir),
405
+ "showcase_study_summary_json": str(summary_json),
406
+ "showcase_study_summary_md": str(summary_md),
407
+ "showcase_study_poster_png": str(poster_outputs["poster_png"]),
408
+ "showcase_study_poster_json": str(poster_outputs["poster_summary_json"]),
409
+ "showcase_baseline_vs_candidate_json": str(baseline_vs_candidate_json),
410
+ "showcase_safety_on_vs_off_json": str(safety_on_vs_off_json),
411
+ "showcase_research_sync_md": str(sync_markdown),
412
+ "showcase_explanation_panel_md": str(explanation_panel),
413
+ }
414
+
415
+
141
416
  def _build_jury_brief(
142
417
  *,
143
418
  output_dir: Path,
@@ -386,6 +661,15 @@ def build_booth_demo(
386
661
  except Exception as exc:
387
662
  ai_status = f"AI preparation did not block the demo, but it could not finish cleanly: {exc}"
388
663
 
664
+ showcase_outputs = _write_showcase_research_sync(
665
+ output_dir=resolved_output,
666
+ patient_config=patient_config,
667
+ duration_minutes=duration_minutes,
668
+ time_step=time_step,
669
+ seed=seed,
670
+ ai_status=ai_status,
671
+ )
672
+
389
673
  summary_payload = {
390
674
  "output_dir": str(resolved_output),
391
675
  "patient_config": str(patient_config),
@@ -406,6 +690,7 @@ def build_booth_demo(
406
690
  }
407
691
  for spec in specs
408
692
  ],
693
+ "showcase_sync": showcase_outputs,
409
694
  }
410
695
  _write_json(resolved_output / "demo_summary.json", summary_payload)
411
696
 
@@ -450,4 +735,5 @@ def build_booth_demo(
450
735
  for spec in specs:
451
736
  artifact_paths[f"{spec.slug}_dir"] = run_outputs[spec.slug]["output_dir"]
452
737
  artifact_paths.update(ai_outputs)
738
+ artifact_paths.update(showcase_outputs)
453
739
  return artifact_paths
@@ -43,7 +43,7 @@ def _round(value: Any, digits: int = 2) -> float | int | None:
43
43
  return None
44
44
  try:
45
45
  number = float(value)
46
- except Exception:
46
+ except (TypeError, ValueError, OverflowError):
47
47
  return None
48
48
  if math.isnan(number) or math.isinf(number):
49
49
  return None