research-tool-cli 0.1.0__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 (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -0,0 +1,383 @@
1
+ """
2
+ Unit tests for the statistical reporting & analytical pipeline fixes:
3
+ 1. k degrees-of-freedom calculation for categorical dummy variables in epv_live
4
+ 2. Interaction term DoF multiplication: (L_A - 1) * (L_B - 1)
5
+ 3. EPV < 5.0 FAIL gate routing & EPV 5-10 WARNING gate propagation
6
+ 4. _classify_coefficient boundary behavior
7
+ 5. SMD calculation for continuous and multi-level categorical covariates
8
+ 6. Cox Proportional Hazards survival engine fitting & Schoenfeld residual testing
9
+ 7. VanderWeele & Ding (2017) dual E-value calculation (E_est and E_CI)
10
+ 8. Dynamic language engine (strict 'odds' for Logistic, 'hazard' for Cox PH)
11
+ 9. Forest plot SVG log-scaled X-axis, ticks, and axis titles
12
+ 10. Tab 2 amendment workflow: FAIL -> prepare_amendment -> amendment_state -> lock_stage2 (was_amended: True) -> re-run execution
13
+ 11. Clinical display label formatting reusing core COVARIATE_LABEL_MAP / COVARIATE_UNIT_MAP
14
+ 12. Label parser continuous underscore handling & dynamic exposure reference level contrast formatting
15
+ 13. Audit-binder end-to-end zip archive generation & structure verification
16
+ """
17
+ import math
18
+ import sys
19
+ import zipfile
20
+ from pathlib import Path
21
+ import pandas as pd
22
+ import pytest
23
+
24
+ backend_dir = Path(__file__).parent.parent / "app" / "backend"
25
+ if str(backend_dir) not in sys.path:
26
+ sys.path.insert(0, str(backend_dir))
27
+
28
+ from routers.execution import (
29
+ _e_value_dual,
30
+ _fit_model,
31
+ _run_gates,
32
+ AmendmentPrepareIn,
33
+ get_amendment_state,
34
+ prepare_amendment,
35
+ run_execution,
36
+ )
37
+ from routers.planning import lock_stage2
38
+ from routers.reporting import _classify_coefficient, build_audit_binder, download_binder, forest_plot, get_display_label, methods_text
39
+ from state import SESSION
40
+
41
+
42
+ def test_k_degrees_of_freedom_calculation():
43
+ """Test 1: k degrees-of-freedom counts correctly for binary + multi-level categorical confounders."""
44
+ df = pd.DataFrame({
45
+ "patient_id": [f"P{i}" for i in range(20)],
46
+ "pfs_event": [1, 0] * 10,
47
+ "treatment_arm": ["A", "B"] * 10,
48
+ "age": [60 + i for i in range(20)],
49
+ "iss_stage": ["I", "II", "III", "I"] * 5,
50
+ "high_risk_fish": ["yes", "no"] * 10,
51
+ "prior_lines": [1, 2] * 10,
52
+ "sex": ["M", "F"] * 10,
53
+ })
54
+
55
+ predictors = ["treatment_arm", "age", "iss_stage", "high_risk_fish", "prior_lines", "sex"]
56
+ k = 0
57
+ for col in predictors:
58
+ if pd.api.types.is_numeric_dtype(df[col]):
59
+ k += 1
60
+ else:
61
+ n_uniq = df[col].dropna().nunique()
62
+ k += max(n_uniq - 1, 1)
63
+
64
+ # treatment_arm(1) + age(1) + iss_stage(2) + high_risk_fish(1) + prior_lines(1) + sex(1) = 7
65
+ assert k == 7
66
+
67
+
68
+ def test_interaction_term_dof_multiplication():
69
+ """Test 2: Interaction terms multiply categorical levels: (L_A - 1) * (L_B - 1)."""
70
+ df = pd.DataFrame({
71
+ "treatment_arm": ["A", "B"] * 10,
72
+ "iss_stage": ["I", "II", "III", "I"] * 5, # 3 levels -> 2 dof
73
+ })
74
+
75
+ # Interaction term iss_stage:treatment_arm -> (3 - 1) * (2 - 1) = 2 * 1 = 2 DoF
76
+ inter_dof = 1
77
+ for p in ["iss_stage", "treatment_arm"]:
78
+ u = df[p].dropna().nunique()
79
+ inter_dof *= max(u - 1, 1)
80
+
81
+ assert inter_dof == 2
82
+
83
+
84
+ def test_epv_warning_and_fail_propagation():
85
+ """Test 3: EPV < 5 forces overall_status to FAIL; EPV between 5 and 10 forces WARNING."""
86
+ protocol = {"outcome_confirmation": {}}
87
+ fit_ok = True
88
+
89
+ # Moderate EPV (EPV = 6.29) -> WARNING
90
+ results_warning = {
91
+ "max_se": 1.50,
92
+ "vif": {"var1": 1.20},
93
+ "e_effective": 44,
94
+ "coefficients": [{"variable": f"v{i}"} for i in range(7)],
95
+ }
96
+ overall_w, tests_w = _run_gates(protocol, fit_ok, results_warning)
97
+ assert overall_w == "WARNING"
98
+
99
+ # Critical low EPV (EPV = 2.0) -> FAIL
100
+ results_fail = {
101
+ "max_se": 1.50,
102
+ "vif": {"var1": 1.20},
103
+ "e_effective": 14,
104
+ "coefficients": [{"variable": f"v{i}"} for i in range(7)], # 14 / 7 = 2.0 < 5.0
105
+ }
106
+ overall_f, tests_f = _run_gates(protocol, fit_ok, results_fail)
107
+ assert overall_f == "FAIL"
108
+
109
+ epv_test = next(t for t in tests_f if t["test_name"] == "events_per_variable_epv")
110
+ assert epv_test["status"] == "FAIL"
111
+ assert epv_test["metric_value"] == 2.0
112
+
113
+
114
+ def test_classify_coefficient_boundary_behavior():
115
+ """Test 4: _classify_coefficient() boundary behavior."""
116
+ sig_coef = {"adjusted_or": 2.50, "adjusted_ci_95": [1.30, 4.80], "adjusted_p": 0.012}
117
+ assert _classify_coefficient(sig_coef) == "significant"
118
+
119
+ borderline_coef = {"adjusted_or": 0.391, "adjusted_ci_95": [0.152, 1.006], "adjusted_p": 0.0516}
120
+ assert _classify_coefficient(borderline_coef) == "borderline/trend"
121
+
122
+ not_sig_coef = {"adjusted_or": 1.10, "adjusted_ci_95": [0.60, 2.00], "adjusted_p": 0.750}
123
+ assert _classify_coefficient(not_sig_coef) == "not_significant"
124
+
125
+
126
+ def test_smd_calculation_continuous_and_categorical():
127
+ """Test 5: SMD calculation for continuous and categorical covariates against expected math."""
128
+ m1, m2 = 60.0, 65.0
129
+ s1, s2 = 10.0, 10.0
130
+ pooled_sd = math.sqrt((s1**2 + s2**2) / 2)
131
+ smd_continuous = abs(m1 - m2) / pooled_sd
132
+ assert round(smd_continuous, 3) == 0.500
133
+
134
+ p1, p2 = 0.6, 0.2
135
+ denom = math.sqrt((p1 * (1 - p1) + p2 * (1 - p2)) / 2)
136
+ smd_cat = abs(p1 - p2) / denom
137
+ assert round(smd_cat, 3) == 0.894
138
+
139
+
140
+ def test_cox_ph_survival_model_execution():
141
+ """Test 6: Cox Proportional Hazards model fitting when time-to-event column is present."""
142
+ df = pd.read_csv("synthetic_100.csv")
143
+ SESSION.raw_df = df
144
+ SESSION.sentinels = {"global_na_strings": ["NA"], "column_overrides": {}}
145
+ SESSION.outcome_spec = {"column_name": "pfs_event", "event_value": 1, "censored_value": 0}
146
+ SESSION.time_column = "pfs_days"
147
+
148
+ protocol = {
149
+ "exposure": {"column_name": "treatment_arm"},
150
+ "confounders": ["age", "iss_stage", "high_risk_fish"],
151
+ "outcome_confirmation": {"column_name": "pfs_event", "time_column": "pfs_days"},
152
+ "interactions": [],
153
+ }
154
+
155
+ fit_ok, results = _fit_model(protocol)
156
+ assert fit_ok is True
157
+ assert results["model_type"] == "cox_ph"
158
+ assert "ph_p_min" in results
159
+
160
+ overall, tests = _run_gates(protocol, fit_ok, results)
161
+ ph_test = next(t for t in tests if t["test_name"] == "proportional_hazards")
162
+ assert ph_test["status"] in ("PASS", "WARNING")
163
+
164
+
165
+ def test_vanderweele_dual_e_value():
166
+ """Test 7: Dual E-value calculation per VanderWeele & Ding (2017)."""
167
+ # Case A: 95% CI includes 1.0 (0.391, CI [0.152, 1.006]) -> E_CI must be 1.000
168
+ res_null = _e_value_dual(0.391, 0.152, 1.006)
169
+ assert res_null["e_value_ci"] == 1.000
170
+ assert "CI bound: 1.000" in res_null["formatted"]
171
+
172
+ # Case B: Significant effect (0.405, CI [0.164, 0.999]) -> E_CI computed on closest bound (0.999)
173
+ res_sig = _e_value_dual(0.405, 0.164, 0.999)
174
+ assert res_sig["e_value_ci"] >= 1.000
175
+ assert res_sig["e_value"] > res_sig["e_value_ci"]
176
+
177
+
178
+ def test_dynamic_language_engine_strict_terms():
179
+ """Test 8: Ensure manuscript generator outputs strict 'odds' or 'hazard' without conflating ('hazard/odds')."""
180
+ df = pd.read_csv("synthetic_100.csv")
181
+ SESSION.raw_df = df
182
+ SESSION.sentinels = {"global_na_strings": ["NA"], "column_overrides": {}}
183
+ SESSION.outcome_spec = {"column_name": "pfs_event", "event_value": 1, "censored_value": 0}
184
+ SESSION.time_column = None
185
+
186
+ # Logistic Regression path
187
+ protocol_logit = {
188
+ "exposure": {"column_name": "treatment_arm"},
189
+ "confounders": ["age", "iss_stage"],
190
+ "outcome_confirmation": {"column_name": "pfs_event"},
191
+ "interactions": [],
192
+ }
193
+ SESSION.h1_payload = {"provenance": {"plan_fingerprint_h1": "h1_hash"}, "protocol": protocol_logit}
194
+ fit_ok, results = _fit_model(protocol_logit)
195
+ overall, tests = _run_gates(protocol_logit, fit_ok, results)
196
+ SESSION.hexec_payload = {
197
+ "provenance": {"payload_fingerprint_h0": "h0_hash", "execution_fingerprint": "exec_hash"},
198
+ "diagnostic_config": {"ruleset_version": "v1.2.0"},
199
+ "diagnostics_summary": {"overall_status": overall, "tests": tests},
200
+ "model_results": results,
201
+ "sensitivity_analysis": {"e_values": [{"variable": c["variable"], **_e_value_dual(c["adjusted_or"], c["adjusted_ci_95"][0], c["adjusted_ci_95"][1])} for c in results["coefficients"]]},
202
+ }
203
+
204
+ mtxt_logit = methods_text()["text"]
205
+ assert "hazard/odds" not in mtxt_logit
206
+ assert "odds" in mtxt_logit
207
+
208
+ # Cox PH path
209
+ protocol_cox = {
210
+ "exposure": {"column_name": "treatment_arm"},
211
+ "confounders": ["age", "iss_stage"],
212
+ "outcome_confirmation": {"column_name": "pfs_event", "time_column": "pfs_days"},
213
+ "interactions": [],
214
+ }
215
+ SESSION.time_column = "pfs_days"
216
+ SESSION.h1_payload = {"provenance": {"plan_fingerprint_h1": "h1_hash"}, "protocol": protocol_cox}
217
+ fit_ok_cox, results_cox = _fit_model(protocol_cox)
218
+ overall_cox, tests_cox = _run_gates(protocol_cox, fit_ok_cox, results_cox)
219
+ SESSION.hexec_payload["model_results"] = results_cox
220
+
221
+ mtxt_cox = methods_text()["text"]
222
+ assert "hazard/odds" not in mtxt_cox
223
+ assert "hazard" in mtxt_cox
224
+
225
+
226
+ def test_forest_plot_svg_axis_geometry():
227
+ """Test 9: Forest plot SVG contains logarithmic X-axis tick marks and titles."""
228
+ df = pd.read_csv("synthetic_100.csv")
229
+ SESSION.raw_df = df
230
+ SESSION.sentinels = {"global_na_strings": ["NA"], "column_overrides": {}}
231
+ SESSION.outcome_spec = {"column_name": "pfs_event", "event_value": 1, "censored_value": 0}
232
+ SESSION.time_column = None
233
+
234
+ protocol = {
235
+ "exposure": {"column_name": "treatment_arm"},
236
+ "confounders": ["age"],
237
+ "outcome_confirmation": {"column_name": "pfs_event"},
238
+ "interactions": [],
239
+ }
240
+ SESSION.h1_payload = {"provenance": {"plan_fingerprint_h1": "h1_hash"}, "protocol": protocol}
241
+ fit_ok, results = _fit_model(protocol)
242
+ SESSION.hexec_payload = {
243
+ "model_results": results,
244
+ "sensitivity_analysis": {"e_values": []},
245
+ }
246
+
247
+ svg_out = forest_plot()["svg"]
248
+ assert "Adjusted Odds Ratio (95% CI)" in svg_out or "Adjusted Hazard Ratio (95% CI)" in svg_out
249
+ assert ">1<" in svg_out or ">1.0<" in svg_out
250
+
251
+
252
+ def test_tab2_amendment_end_to_end_flow():
253
+ """Test 10: Tab 2 amendment workflow (FAIL -> prepare_amendment -> amendment_state -> lock_stage2 -> re-run)."""
254
+ df = pd.read_csv("synthetic_21_v2.csv")
255
+ SESSION.raw_df = df
256
+ SESSION.sentinels = {"global_na_strings": ["NA"], "column_overrides": {}}
257
+ SESSION.outcome_spec = {"column_name": "pfs_event", "event_value": 1, "censored_value": 0}
258
+ SESSION.time_column = "pfs_days"
259
+ SESSION.exposure = {"column_name": "treatment_arm", "reference_level": "A"}
260
+ SESSION.confounders = ["age", "iss_stage", "high_risk_fish", "prior_lines"]
261
+ SESSION.interactions = []
262
+ SESSION.missing_data_strategy = {"global_default": "complete_case", "column_overrides": {}}
263
+ SESSION.h0_payload = {"provenance": {"payload_fingerprint_h0": "h0_hash_123"}}
264
+ SESSION.plan_chain = []
265
+ SESSION.pending_amendment = None
266
+
267
+ # Lock initial H1 plan
268
+ h1 = lock_stage2()
269
+ assert "parent_plan_hash" not in h1["provenance"]
270
+
271
+ # Run execution -> should FAIL on EPV < 5.0 (5 events / 6 parameters = 0.83 < 5.0)
272
+ exec_res = run_execution()
273
+ assert exec_res["route"] == "AUTOPSY_CANVAS"
274
+ assert exec_res["diagnostics_summary"]["overall_status"] == "FAIL"
275
+ assert exec_res["failed_gate"] == "events_per_variable_epv"
276
+
277
+ # Prepare amendment in Tab 3 Autopsy Canvas
278
+ prep = prepare_amendment(AmendmentPrepareIn(
279
+ chosen_remediation="Prune non-essential confounders to restore EPV >= 10",
280
+ rationale="Removing prior_lines and high_risk_fish to achieve sufficient EPV for primary model."
281
+ ))
282
+ assert prep["amendment_mode"] is True
283
+ assert "parent_plan_hash" in prep
284
+
285
+ # Query amendment state (used by Tab 2 UI on entry)
286
+ state = get_amendment_state()
287
+ assert state["amendment_mode"] is True
288
+ assert state["parent_plan_hash"] == h1["provenance"]["plan_fingerprint_h1"]
289
+ assert state["locked_readonly"]["exposure"]["column_name"] == "treatment_arm"
290
+
291
+ # Amend confounders in Tab 2 (prune to 0 confounders: treatment_arm only -> k=1, EPV = 5.0 >= 5.0)
292
+ SESSION.confounders = []
293
+
294
+ # Re-lock plan (POST /api/plan/lock)
295
+ h2 = lock_stage2()
296
+ assert h2["provenance"]["parent_plan_hash"] == h1["provenance"]["plan_fingerprint_h1"]
297
+ assert h2["provenance"]["amendment_rationale"] != ""
298
+ assert SESSION.pending_amendment is None
299
+
300
+ # Re-run execution (POST /api/execute/run) -> now passes/warns (EPV = 5.0 >= 5.0)
301
+ exec_res2 = run_execution()
302
+ assert exec_res2["route"] in ("PUBLICATION_PACKAGE", "PUBLICATION_PACKAGE_WITH_LIMITATIONS")
303
+ assert exec_res2["provenance"]["was_amended"] is True
304
+
305
+
306
+ def test_clinical_display_label_formatting():
307
+ """Test 11: get_display_label() formats dummy and continuous variables into publication-grade labels."""
308
+ SESSION.exposure = {"column_name": "treatment_arm", "reference_level": "Arm A"}
309
+ assert "ISS Stage II (vs Stage I)" in get_display_label("iss_stage_II")
310
+ assert "High-Risk Cytogenetics (Yes vs No)" in get_display_label("high_risk_fish_yes")
311
+ assert "Treatment Group: B (vs Arm A)" in get_display_label("treatment_arm_B")
312
+
313
+ age_label = get_display_label("age")
314
+ assert "Age, years" in age_label
315
+ assert "Per year increase" in age_label
316
+
317
+
318
+ def test_label_parser_continuous_underscore_and_dynamic_reference_level():
319
+ """Test 12: get_display_label() handles continuous cols with '_' without mis-splitting and uses dynamic exposure reference level."""
320
+ # Test 12a: Continuous variable with underscore in raw_df should not split into base: level
321
+ SESSION.raw_df = pd.DataFrame({"hemoglobin_g_dl": [12.5, 14.1, 13.0]})
322
+ label_cont = get_display_label("hemoglobin_g_dl")
323
+ assert label_cont == "Hemoglobin G Dl"
324
+ assert "Hemoglobin G: dl" not in label_cont
325
+
326
+ # Test 12b: Dynamic exposure reference level (e.g. 'Control' instead of hardcoded 'Arm A')
327
+ SESSION.exposure = {"column_name": "treatment_arm", "reference_level": "Control"}
328
+ label_exp = get_display_label("treatment_arm_B")
329
+ assert label_exp == "Treatment Group: B (vs Control)"
330
+
331
+
332
+ def test_audit_binder_end_to_end_zip_generation():
333
+ """Test 13: End-to-end execution of build_audit_binder() produces a valid, non-empty ZIP file with all required audit assets."""
334
+ df = pd.read_csv("synthetic_100.csv")
335
+ SESSION.raw_df = df
336
+ SESSION.sentinels = {"global_na_strings": ["NA"], "column_overrides": {}}
337
+ SESSION.outcome_spec = {"column_name": "pfs_event", "event_value": 1, "censored_value": 0}
338
+ SESSION.time_column = "pfs_days"
339
+ SESSION.exposure = {"column_name": "treatment_arm", "reference_level": "A"}
340
+ SESSION.confounders = ["age", "iss_stage"]
341
+ SESSION.interactions = []
342
+ SESSION.missing_data_strategy = {"global_default": "complete_case", "column_overrides": {}}
343
+ SESSION.h0_payload = {"provenance": {"payload_fingerprint_h0": "h0_hash_123"}}
344
+ SESSION.plan_chain = []
345
+ SESSION.column_mappings = {"treatment_arm": "categorical", "age": "continuous"}
346
+
347
+ # Lock H1 plan
348
+ h1 = lock_stage2()
349
+
350
+ # Run execution -> populates hexec_payload
351
+ exec_res = run_execution()
352
+ assert exec_res["route"] in ("PUBLICATION_PACKAGE", "PUBLICATION_PACKAGE_WITH_LIMITATIONS")
353
+
354
+ # Call build_audit_binder()
355
+ binder_res = build_audit_binder()
356
+
357
+ assert "bundle_fingerprint_hbundle" in binder_res
358
+ assert binder_res["bundle_fingerprint_hbundle"] != ""
359
+ assert "download_url" in binder_res
360
+
361
+ zip_file_path = Path(binder_res["zip_path"])
362
+ assert zip_file_path.exists()
363
+ assert zip_file_path.stat().st_size > 0
364
+
365
+ # Read zip file contents
366
+ with zipfile.ZipFile(zip_file_path, "r") as z:
367
+ namelist = z.namelist()
368
+ assert "manifest.json" in namelist
369
+ assert "verification_script.py" in namelist
370
+ assert "01_raw_vaulted_data/dataset_fingerprint.sha256" in namelist
371
+ assert "01_raw_vaulted_data/schema_mapping.json" in namelist
372
+ assert "02_pre_registered_protocols/protocol_h1_locked.json" in namelist
373
+ assert "02_pre_registered_protocols/amendment_chain.json" in namelist
374
+ assert "03_execution_and_diagnostics/execution_results_hexec.json" in namelist
375
+ assert "04_manuscript_assets/table_1_baseline.html" in namelist
376
+ assert "04_manuscript_assets/table_2_primary_model.html" in namelist
377
+ assert "04_manuscript_assets/figure_1_forest_plot.svg" in namelist
378
+ assert "04_manuscript_assets/strobe_checklist_completed.txt" in namelist
379
+ assert "04_manuscript_assets/strobe_checklist.html" in namelist
380
+
381
+ # Test download endpoint
382
+ dl_res = download_binder(zip_file_path.name)
383
+ assert str(dl_res.path) == str(zip_file_path)