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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- 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)
|