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,556 @@
|
|
|
1
|
+
"""Integration test for the N=21 benchmark myeloma EMD study.
|
|
2
|
+
|
|
3
|
+
This test locks down the complete end-to-end pipeline for the 21-patient
|
|
4
|
+
synthetic myeloma cohort. It validates:
|
|
5
|
+
- Full pipeline execution (ingest -> classify -> plan -> lock -> unmask -> analyze -> draft -> bundle)
|
|
6
|
+
- Chi-square assumption warning for sparse 6x2 contingency tables
|
|
7
|
+
- Fisher's exact test hard-rejects non-2x2 tables (no silent rxc substitution)
|
|
8
|
+
- KM plot risk table row order matches legend order (Arm A top, Arm B bottom)
|
|
9
|
+
- Bundle creation and hash verification pass with matching SHA-256 composites
|
|
10
|
+
|
|
11
|
+
The study uses fixed marginals matching a published myeloma EMD paper:
|
|
12
|
+
- Sex: 14 M / 7 F
|
|
13
|
+
- High-Risk FISH: 9 yes / 12 no
|
|
14
|
+
- Response: 2 CR, 8 PR, 2 MR, 3 SD, 4 PD, 2 Unknown
|
|
15
|
+
- PFS median ~120 days, OS median ~365 days
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import shutil
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
31
|
+
from core.ingestion.csv_loader import load_file
|
|
32
|
+
from core.ingestion.variable_classifier import classify_variables_interactive, _classify_batch
|
|
33
|
+
from core.masking.gate import seal_outcomes, unmask_study, is_masked
|
|
34
|
+
from core.planning.study_plan import StudyPlan
|
|
35
|
+
from core.planning.lock import lock_plan
|
|
36
|
+
from core.planning.test_selector import check_assumptions
|
|
37
|
+
from core.stats.inferential import _fishers_exact, run_test
|
|
38
|
+
from core.reporting.strobe_checklist import generate_report
|
|
39
|
+
from core.reporting.manuscript_draft import write_draft
|
|
40
|
+
from core.reporting.plots import generate_km_plot
|
|
41
|
+
from core.reporting.bundle import create_bundle, verify_bundle
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
BENCHMARK_STUDY_NAME = "Synthetic EMD 21 Patient Validation"
|
|
45
|
+
BENCHMARK_STUDY_ID = "b7a49fc2a42d46deaf5c2f81471dd7ae"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _generate_benchmark_csv() -> str:
|
|
49
|
+
"""Generate the exact 21-row synthetic CSV with fixed marginals."""
|
|
50
|
+
import random
|
|
51
|
+
random.seed(42)
|
|
52
|
+
|
|
53
|
+
n = 21
|
|
54
|
+
sex_vals = ["M"] * 14 + ["F"] * 7
|
|
55
|
+
fish_vals = ["yes"] * 9 + ["no"] * 12
|
|
56
|
+
resp_vals = ["CR"] * 2 + ["PR"] * 8 + ["MR"] * 2 + ["SD"] * 3 + ["PD"] * 4 + ["Unknown"] * 2
|
|
57
|
+
|
|
58
|
+
random.shuffle(sex_vals)
|
|
59
|
+
random.shuffle(fish_vals)
|
|
60
|
+
random.shuffle(resp_vals)
|
|
61
|
+
|
|
62
|
+
arms = ["A"] * 10 + ["B"] * 11
|
|
63
|
+
random.shuffle(arms)
|
|
64
|
+
|
|
65
|
+
iss_vals = ["I"] * 7 + ["II"] * 8 + ["III"] * 6
|
|
66
|
+
random.shuffle(iss_vals)
|
|
67
|
+
|
|
68
|
+
prior_vals = [random.randint(0, 4) for _ in range(n)]
|
|
69
|
+
ages = [random.randint(40, 85) for _ in range(n)]
|
|
70
|
+
|
|
71
|
+
pfs_lambda = math.log(2) / 120
|
|
72
|
+
pfs_vals = [max(30, int(random.expovariate(pfs_lambda))) for _ in range(n)]
|
|
73
|
+
pfs_event = []
|
|
74
|
+
for r in resp_vals:
|
|
75
|
+
if r in ("PD", "SD"):
|
|
76
|
+
pfs_event.append(1 if random.random() < 0.8 else 0)
|
|
77
|
+
elif r == "Unknown":
|
|
78
|
+
pfs_event.append(1 if random.random() < 0.5 else 0)
|
|
79
|
+
else:
|
|
80
|
+
pfs_event.append(1 if random.random() < 0.4 else 0)
|
|
81
|
+
|
|
82
|
+
os_lambda = math.log(2) / 365
|
|
83
|
+
os_vals = [max(30, int(random.expovariate(os_lambda))) for _ in range(n)]
|
|
84
|
+
os_event = []
|
|
85
|
+
for i in range(n):
|
|
86
|
+
if pfs_event[i] == 1:
|
|
87
|
+
os_event.append(1 if random.random() < 0.6 else 0)
|
|
88
|
+
else:
|
|
89
|
+
os_event.append(1 if random.random() < 0.3 else 0)
|
|
90
|
+
|
|
91
|
+
rows = [
|
|
92
|
+
["patient_id", "age", "sex", "iss_stage", "prior_lines",
|
|
93
|
+
"high_risk_fish", "treatment_arm", "response_category",
|
|
94
|
+
"pfs_days", "pfs_event", "os_days", "os_event"]
|
|
95
|
+
]
|
|
96
|
+
for i in range(n):
|
|
97
|
+
rows.append([
|
|
98
|
+
f"EMD_{i+1:03d}", ages[i], sex_vals[i], iss_vals[i], prior_vals[i],
|
|
99
|
+
fish_vals[i], arms[i], resp_vals[i], pfs_vals[i], pfs_event[i],
|
|
100
|
+
os_vals[i], os_event[i]
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
buf = io.StringIO()
|
|
104
|
+
w = csv.writer(buf)
|
|
105
|
+
w.writerows(rows)
|
|
106
|
+
return buf.getvalue()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.fixture(scope="module")
|
|
110
|
+
def benchmark_study():
|
|
111
|
+
"""Create the benchmark study once per test module.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
tuple: (study_id, km_test_id) where km_test_id is the analysis_results
|
|
115
|
+
row ID for the Kaplan-Meier log-rank test.
|
|
116
|
+
"""
|
|
117
|
+
study_id = BENCHMARK_STUDY_ID
|
|
118
|
+
|
|
119
|
+
# Clean any existing study
|
|
120
|
+
p = DATA_ROOT / study_id
|
|
121
|
+
if p.exists():
|
|
122
|
+
shutil.rmtree(p)
|
|
123
|
+
|
|
124
|
+
# Create study
|
|
125
|
+
conn = get_connection(study_id)
|
|
126
|
+
init_db(conn)
|
|
127
|
+
conn.execute(
|
|
128
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
129
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
130
|
+
(study_id, BENCHMARK_STUDY_NAME, "2025-06-01T00:00:00",
|
|
131
|
+
str(DATA_ROOT / study_id), "cohort", 0),
|
|
132
|
+
)
|
|
133
|
+
conn.commit()
|
|
134
|
+
conn.close()
|
|
135
|
+
|
|
136
|
+
# Ingest CSV
|
|
137
|
+
csv_content = _generate_benchmark_csv()
|
|
138
|
+
tmp = Path(tempfile.mkstemp(suffix=".csv")[1])
|
|
139
|
+
tmp.write_text(csv_content)
|
|
140
|
+
load_file(study_id, str(tmp))
|
|
141
|
+
tmp.unlink()
|
|
142
|
+
|
|
143
|
+
# Classify variables
|
|
144
|
+
suggestions = classify_variables_interactive(study_id, [
|
|
145
|
+
"age", "sex", "iss_stage", "prior_lines", "high_risk_fish",
|
|
146
|
+
"treatment_arm", "response_category", "pfs_days", "pfs_event",
|
|
147
|
+
"os_days", "os_event"
|
|
148
|
+
])
|
|
149
|
+
_classify_batch(study_id, suggestions)
|
|
150
|
+
seal_outcomes(study_id)
|
|
151
|
+
|
|
152
|
+
# Lock plan (with assumption checks like CLI does)
|
|
153
|
+
plan = StudyPlan(
|
|
154
|
+
study_id=study_id,
|
|
155
|
+
study_type="cohort",
|
|
156
|
+
primary_comparison="PFS and response by treatment arm",
|
|
157
|
+
primary_outcome_variable_ids=[7, 8], # response_category, pfs_days
|
|
158
|
+
planned_tests=[
|
|
159
|
+
{"variable_id": 7, "variable_name": "response_category",
|
|
160
|
+
"test_name": "chi_square",
|
|
161
|
+
"rationale": "Compare response distribution between arms"},
|
|
162
|
+
{"variable_id": 8, "variable_name": "pfs_days",
|
|
163
|
+
"test_name": "kaplan_meier_logrank",
|
|
164
|
+
"rationale": "Compare PFS between treatment arms"},
|
|
165
|
+
],
|
|
166
|
+
covariates=[1, 3, 5], # age, iss_stage, high_risk_fish
|
|
167
|
+
)
|
|
168
|
+
warnings = check_assumptions(study_id, plan.planned_tests)
|
|
169
|
+
# Map warnings to test variable names (like CLI does)
|
|
170
|
+
warning_map = {}
|
|
171
|
+
for test in plan.planned_tests:
|
|
172
|
+
var_name = test.get("variable_name", "")
|
|
173
|
+
for w in warnings:
|
|
174
|
+
if var_name in w:
|
|
175
|
+
warning_map[var_name] = w
|
|
176
|
+
plan.warnings = warning_map
|
|
177
|
+
lock_plan(study_id, plan)
|
|
178
|
+
|
|
179
|
+
# Unmask
|
|
180
|
+
unmask_study(study_id)
|
|
181
|
+
|
|
182
|
+
# Run analyze (store results in DB)
|
|
183
|
+
conn = get_connection(study_id)
|
|
184
|
+
import pandas as pd
|
|
185
|
+
df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
|
|
186
|
+
conn.close()
|
|
187
|
+
|
|
188
|
+
from datetime import datetime, timezone
|
|
189
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
190
|
+
conn = get_connection(study_id)
|
|
191
|
+
init_db(conn)
|
|
192
|
+
|
|
193
|
+
km_test_id = None
|
|
194
|
+
for t in plan.planned_tests:
|
|
195
|
+
var_name = t.get("variable_name", "")
|
|
196
|
+
test_name = t.get("test_name", "")
|
|
197
|
+
kwargs = {"outcome_col": var_name, "group_col": "treatment_arm"}
|
|
198
|
+
if test_name == "kaplan_meier_logrank":
|
|
199
|
+
kwargs["time_col"] = "pfs_days"
|
|
200
|
+
kwargs["event_col"] = "pfs_event"
|
|
201
|
+
result = run_test(test_name, df, **kwargs)
|
|
202
|
+
|
|
203
|
+
# Determine status (like CLI does)
|
|
204
|
+
if test_name == "chi_square" and var_name in plan.warnings:
|
|
205
|
+
status_record = {"status": "skipped_assumption_violation", "reason": plan.warnings[var_name]}
|
|
206
|
+
else:
|
|
207
|
+
status_record = {"status": "completed"}
|
|
208
|
+
|
|
209
|
+
conn.execute(
|
|
210
|
+
"INSERT INTO analysis_results "
|
|
211
|
+
"(study_id, study_plan_version, variable_ids_used, test_name, "
|
|
212
|
+
"statistic, p_value, adjusted_p_value, ci_lower, ci_upper, "
|
|
213
|
+
"is_pre_registered, provenance_json, computed_at, status_json) "
|
|
214
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
|
215
|
+
(study_id, plan.version, json.dumps([]),
|
|
216
|
+
result["test_name"], result["statistic"], result["p_value"],
|
|
217
|
+
result.get("adjusted_p_value"), result.get("ci_lower"), result.get("ci_upper"),
|
|
218
|
+
json.dumps({"plan_version": plan.version}), now,
|
|
219
|
+
json.dumps(status_record)),
|
|
220
|
+
)
|
|
221
|
+
if test_name == "kaplan_meier_logrank":
|
|
222
|
+
km_test_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
223
|
+
conn.commit()
|
|
224
|
+
conn.close()
|
|
225
|
+
|
|
226
|
+
yield study_id, km_test_id
|
|
227
|
+
|
|
228
|
+
# Cleanup
|
|
229
|
+
if p.exists():
|
|
230
|
+
shutil.rmtree(p)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestBenchmarkPipeline:
|
|
234
|
+
"""Full pipeline execution tests."""
|
|
235
|
+
|
|
236
|
+
def test_pipeline_ingest_classify(self, benchmark_study):
|
|
237
|
+
"""Verify ingest and classification produced expected columns and roles."""
|
|
238
|
+
study_id, _ = benchmark_study
|
|
239
|
+
conn = get_connection(study_id)
|
|
240
|
+
cur = conn.execute(
|
|
241
|
+
"SELECT column_name, role, data_type FROM variables WHERE study_id=? ORDER BY id",
|
|
242
|
+
(study_id,)
|
|
243
|
+
)
|
|
244
|
+
vars_info = {r["column_name"]: {"role": r["role"], "dtype": r["data_type"]}
|
|
245
|
+
for r in cur.fetchall()}
|
|
246
|
+
conn.close()
|
|
247
|
+
|
|
248
|
+
# Baseline variables
|
|
249
|
+
assert vars_info["age"]["role"] == "baseline"
|
|
250
|
+
assert vars_info["age"]["dtype"] == "continuous"
|
|
251
|
+
assert vars_info["sex"]["role"] == "baseline"
|
|
252
|
+
assert vars_info["sex"]["dtype"] == "categorical"
|
|
253
|
+
assert vars_info["iss_stage"]["role"] == "baseline"
|
|
254
|
+
assert vars_info["prior_lines"]["role"] == "baseline"
|
|
255
|
+
assert vars_info["high_risk_fish"]["role"] == "baseline"
|
|
256
|
+
assert vars_info["treatment_arm"]["role"] == "baseline"
|
|
257
|
+
|
|
258
|
+
# Outcome variables
|
|
259
|
+
assert vars_info["response_category"]["role"] == "outcome"
|
|
260
|
+
assert vars_info["response_category"]["dtype"] == "categorical"
|
|
261
|
+
assert vars_info["pfs_days"]["role"] == "outcome"
|
|
262
|
+
assert vars_info["pfs_days"]["dtype"] == "time_to_event"
|
|
263
|
+
assert vars_info["os_days"]["role"] == "outcome"
|
|
264
|
+
assert vars_info["os_days"]["dtype"] == "time_to_event"
|
|
265
|
+
|
|
266
|
+
def test_plan_locked(self, benchmark_study):
|
|
267
|
+
"""Verify plan is locked and immutable."""
|
|
268
|
+
study_id, _ = benchmark_study
|
|
269
|
+
locked_plans = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
270
|
+
assert len(locked_plans) == 1
|
|
271
|
+
plan_data = json.loads(locked_plans[0].read_text())
|
|
272
|
+
assert plan_data["version"] == 1
|
|
273
|
+
assert len(plan_data["planned_tests"]) == 2
|
|
274
|
+
assert plan_data["planned_tests"][0]["test_name"] == "chi_square"
|
|
275
|
+
assert plan_data["planned_tests"][1]["test_name"] == "kaplan_meier_logrank"
|
|
276
|
+
|
|
277
|
+
def test_unmasked(self, benchmark_study):
|
|
278
|
+
"""Verify study is unmasked and outcomes visible."""
|
|
279
|
+
study_id, _ = benchmark_study
|
|
280
|
+
assert not is_masked(study_id)
|
|
281
|
+
conn = get_connection(study_id)
|
|
282
|
+
cur = conn.execute(f"SELECT response_category FROM raw_{study_id}")
|
|
283
|
+
vals = [r["response_category"] for r in cur.fetchall()]
|
|
284
|
+
conn.close()
|
|
285
|
+
assert all(v is not None for v in vals)
|
|
286
|
+
assert len(vals) == 21
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestChiSquareAssumptionWarning:
|
|
290
|
+
"""Verify chi-square assumption warning for sparse 6x2 table."""
|
|
291
|
+
|
|
292
|
+
def test_plan_time_warning_recorded(self, benchmark_study):
|
|
293
|
+
"""Chi-square on 6x2 table should record warning at plan time."""
|
|
294
|
+
study_id, _ = benchmark_study
|
|
295
|
+
locked_plans = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
296
|
+
plan_data = json.loads(locked_plans[0].read_text())
|
|
297
|
+
warnings = plan_data.get("warnings", {})
|
|
298
|
+
|
|
299
|
+
assert "response_category" in warnings
|
|
300
|
+
warning_msg = warnings["response_category"]
|
|
301
|
+
assert "minimum expected cell count" in warning_msg
|
|
302
|
+
assert "1.0" in warning_msg or "1" in warning_msg # min expected = 1.0
|
|
303
|
+
assert "6×2" in warning_msg or "6x2" in warning_msg.lower()
|
|
304
|
+
assert "fishers_exact" in warning_msg.lower()
|
|
305
|
+
|
|
306
|
+
def test_analyze_skips_chi_square_without_force(self, benchmark_study):
|
|
307
|
+
"""Analyze should skip chi-square due to assumption warning (unless --force)."""
|
|
308
|
+
study_id, _ = benchmark_study
|
|
309
|
+
conn = get_connection(study_id)
|
|
310
|
+
import pandas as pd
|
|
311
|
+
df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
|
|
312
|
+
conn.close()
|
|
313
|
+
|
|
314
|
+
# Run chi-square directly on unmasked data
|
|
315
|
+
result = run_test("chi_square", df, outcome_col="response_category", group_col="treatment_arm")
|
|
316
|
+
|
|
317
|
+
# The test runs (we called it directly), but at plan time it was warned
|
|
318
|
+
# The analyze CLI command would skip it. Here we verify the warning exists.
|
|
319
|
+
assert result["p_value"] is not None # chi-square still computes
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TestFishersExactHardReject:
|
|
323
|
+
"""Verify Fisher's exact test hard-rejects non-2x2 tables."""
|
|
324
|
+
|
|
325
|
+
def test_fishers_exact_raises_on_6x2(self, benchmark_study):
|
|
326
|
+
"""Passing a 6x2 contingency table to _fishers_exact must raise explicit error."""
|
|
327
|
+
study_id, _ = benchmark_study
|
|
328
|
+
conn = get_connection(study_id)
|
|
329
|
+
import pandas as pd
|
|
330
|
+
df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
|
|
331
|
+
conn.close()
|
|
332
|
+
|
|
333
|
+
# response_category has 6 categories, treatment_arm has 2 -> 6x2 table
|
|
334
|
+
result = _fishers_exact(df, "response_category", "treatment_arm")
|
|
335
|
+
|
|
336
|
+
assert result["params"].get("error") is not None
|
|
337
|
+
assert "2x2" in result["params"]["error"] or "2x2" in result["params"]["error"]
|
|
338
|
+
assert result["statistic"] is None
|
|
339
|
+
assert result["p_value"] is None
|
|
340
|
+
|
|
341
|
+
def test_fishers_exact_works_on_2x2(self):
|
|
342
|
+
"""Fisher's exact should work correctly on a 2x2 table."""
|
|
343
|
+
import pandas as pd
|
|
344
|
+
# Create a 2x2 dataframe
|
|
345
|
+
df = pd.DataFrame({
|
|
346
|
+
"outcome": ["yes", "yes", "no", "no", "yes", "no"],
|
|
347
|
+
"group": ["A", "A", "A", "B", "B", "B"],
|
|
348
|
+
})
|
|
349
|
+
result = _fishers_exact(df, "outcome", "group")
|
|
350
|
+
|
|
351
|
+
assert result["params"].get("error") is None
|
|
352
|
+
assert result["statistic"] is not None # odds ratio
|
|
353
|
+
assert result["p_value"] is not None
|
|
354
|
+
assert 0 <= result["p_value"] <= 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class TestKMVisualIntegrity:
|
|
358
|
+
"""Verify KM plot risk table row order matches legend order."""
|
|
359
|
+
|
|
360
|
+
def test_km_plot_generates(self, benchmark_study):
|
|
361
|
+
"""KM plot should generate without error."""
|
|
362
|
+
study_id, km_test_id = benchmark_study
|
|
363
|
+
path = generate_km_plot(
|
|
364
|
+
study_id, km_test_id,
|
|
365
|
+
output_path=None,
|
|
366
|
+
fmt="svg",
|
|
367
|
+
show_risk_table=True,
|
|
368
|
+
style="clean",
|
|
369
|
+
)
|
|
370
|
+
assert path.exists()
|
|
371
|
+
assert path.suffix == ".svg"
|
|
372
|
+
|
|
373
|
+
def test_risk_table_order_matches_legend(self, benchmark_study):
|
|
374
|
+
"""Risk table rows must be in same vertical order as legend (A top, B bottom)."""
|
|
375
|
+
study_id, km_test_id = benchmark_study
|
|
376
|
+
path = generate_km_plot(
|
|
377
|
+
study_id, km_test_id,
|
|
378
|
+
output_path=None,
|
|
379
|
+
fmt="svg",
|
|
380
|
+
show_risk_table=True,
|
|
381
|
+
style="clean",
|
|
382
|
+
)
|
|
383
|
+
svg_content = path.read_text()
|
|
384
|
+
|
|
385
|
+
# The risk table ytick labels appear as SVG comments: <!-- A --> and <!-- B -->
|
|
386
|
+
# Each is followed by a <g> element with transform="translate(x y) scale(...)"
|
|
387
|
+
# NOTE: SVG uses space-separated coordinates: translate(x y), NOT translate(x, y)
|
|
388
|
+
# With invert_yaxis(), group[0] (A) appears at TOP (smaller y in SVG coords)
|
|
389
|
+
import re
|
|
390
|
+
|
|
391
|
+
# Find A and B labels and their y-positions from the following transform
|
|
392
|
+
a_pos = svg_content.find("<!-- A -->")
|
|
393
|
+
b_pos = svg_content.find("<!-- B -->")
|
|
394
|
+
|
|
395
|
+
assert a_pos >= 0, "Risk table label for Arm A not found"
|
|
396
|
+
assert b_pos >= 0, "Risk table label for Arm B not found"
|
|
397
|
+
|
|
398
|
+
# Extract y-coordinate from transform after each label
|
|
399
|
+
# SVG transform uses space-separated: translate(x y)
|
|
400
|
+
def extract_y(pos):
|
|
401
|
+
# Look for transform="translate(x y)" within 500 chars after the comment
|
|
402
|
+
window = svg_content[pos:pos+500]
|
|
403
|
+
# Pattern: translate(23.460219 371.344052)
|
|
404
|
+
m = re.search(r'translate\([\d.]+ ([\d.]+)', window)
|
|
405
|
+
return float(m.group(1)) if m else None
|
|
406
|
+
|
|
407
|
+
a_y = extract_y(a_pos)
|
|
408
|
+
b_y = extract_y(b_pos)
|
|
409
|
+
|
|
410
|
+
print(f"A y={a_y}, B y={b_y}")
|
|
411
|
+
|
|
412
|
+
assert a_y is not None, "Could not extract y for Arm A"
|
|
413
|
+
assert b_y is not None, "Could not extract y for Arm B"
|
|
414
|
+
|
|
415
|
+
# SVG y increases downward; smaller y = higher on screen
|
|
416
|
+
# With invert_yaxis(), A should have smaller y = appear above B
|
|
417
|
+
assert a_y < b_y, f"Risk table order wrong: A y={a_y}, B y={b_y} (A should be above B)"
|
|
418
|
+
|
|
419
|
+
def test_right_padding_applied(self, benchmark_study):
|
|
420
|
+
"""X-axis should have 5% right padding so curve doesn't clip border."""
|
|
421
|
+
study_id, km_test_id = benchmark_study
|
|
422
|
+
path = generate_km_plot(
|
|
423
|
+
study_id, km_test_id,
|
|
424
|
+
output_path=None,
|
|
425
|
+
fmt="svg",
|
|
426
|
+
show_risk_table=True,
|
|
427
|
+
style="clean",
|
|
428
|
+
)
|
|
429
|
+
svg_content = path.read_text()
|
|
430
|
+
|
|
431
|
+
import re
|
|
432
|
+
viewbox_match = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg_content)
|
|
433
|
+
assert viewbox_match, "viewBox not found in SVG"
|
|
434
|
+
width = float(viewbox_match.group(1))
|
|
435
|
+
# Width should be > 570 (base width for ~20 months) due to padding
|
|
436
|
+
assert width > 570, f"Expected width > 570 with padding, got {width}"
|
|
437
|
+
|
|
438
|
+
def test_risk_labels_color_coded(self, benchmark_study):
|
|
439
|
+
"""Risk table row labels should be color-coded: blue for A, orange for B."""
|
|
440
|
+
study_id, km_test_id = benchmark_study
|
|
441
|
+
path = generate_km_plot(
|
|
442
|
+
study_id, km_test_id,
|
|
443
|
+
output_path=None,
|
|
444
|
+
fmt="svg",
|
|
445
|
+
show_risk_table=True,
|
|
446
|
+
style="clean",
|
|
447
|
+
)
|
|
448
|
+
svg_content = path.read_text()
|
|
449
|
+
|
|
450
|
+
# The code sets label.set_color(colors[i]) where colors = ["#1f77b4", "#ff7f0e"]
|
|
451
|
+
assert "#1f77b4" in svg_content, "Blue color for Arm A missing"
|
|
452
|
+
assert "#ff7f0e" in svg_content, "Orange color for Arm B missing"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class TestBundleVerification:
|
|
456
|
+
"""Verify bundle creation and hash verification."""
|
|
457
|
+
|
|
458
|
+
def test_bundle_creates_and_verifies(self, benchmark_study):
|
|
459
|
+
"""Bundle command should create archive and verify-bundle should pass."""
|
|
460
|
+
study_id, _ = benchmark_study
|
|
461
|
+
result = create_bundle(study_id)
|
|
462
|
+
|
|
463
|
+
assert "bundle_path" in result
|
|
464
|
+
assert "composite_hash" in result
|
|
465
|
+
|
|
466
|
+
bundle_path = Path(result["bundle_path"])
|
|
467
|
+
assert bundle_path.exists()
|
|
468
|
+
assert bundle_path.suffix == ".gz"
|
|
469
|
+
|
|
470
|
+
# Verify bundle
|
|
471
|
+
verification = verify_bundle(str(bundle_path))
|
|
472
|
+
assert verification["valid"] is True
|
|
473
|
+
assert verification["composite_match"] is True
|
|
474
|
+
# Hash should be deterministic for same inputs
|
|
475
|
+
assert verification["composite_hash"] == verification["computed_composite"]
|
|
476
|
+
assert verification["computed_raw_hash"] == verification["manifest"]["raw_data_hash"]
|
|
477
|
+
assert verification["computed_plan_hash"] == verification["manifest"]["locked_plan_hash"]
|
|
478
|
+
assert verification["computed_results_hash"] == verification["manifest"]["results_hash"]
|
|
479
|
+
|
|
480
|
+
def test_bundle_contains_all_artifacts(self, benchmark_study):
|
|
481
|
+
"""Bundle should contain all expected study artifacts."""
|
|
482
|
+
study_id, _ = benchmark_study
|
|
483
|
+
result = create_bundle(study_id)
|
|
484
|
+
bundle_path = Path(result["bundle_path"])
|
|
485
|
+
|
|
486
|
+
import tarfile
|
|
487
|
+
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
488
|
+
members = tar.getnames()
|
|
489
|
+
|
|
490
|
+
# Debug: print actual members
|
|
491
|
+
print("Bundle contents:", members)
|
|
492
|
+
|
|
493
|
+
# Essential files that must be in the bundle (at root level)
|
|
494
|
+
expected_files = [
|
|
495
|
+
"raw_data.json",
|
|
496
|
+
"study_plan.locked.json", # bundle uses generic name
|
|
497
|
+
"analysis_results.json",
|
|
498
|
+
"manifest.json",
|
|
499
|
+
]
|
|
500
|
+
for exp in expected_files:
|
|
501
|
+
assert exp in members, f"Missing from bundle: {exp}"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class TestManuscriptDraft:
|
|
505
|
+
"""Verify manuscript draft generation."""
|
|
506
|
+
|
|
507
|
+
def test_draft_generates(self, benchmark_study):
|
|
508
|
+
"""Draft should generate without error."""
|
|
509
|
+
study_id, _ = benchmark_study
|
|
510
|
+
path = write_draft(study_id)
|
|
511
|
+
assert path.exists()
|
|
512
|
+
assert path.suffix == ".md"
|
|
513
|
+
|
|
514
|
+
def test_draft_contains_key_sections(self, benchmark_study):
|
|
515
|
+
"""Draft must contain all IMRaD sections and STROBE checklist."""
|
|
516
|
+
study_id, _ = benchmark_study
|
|
517
|
+
path = write_draft(study_id)
|
|
518
|
+
content = path.read_text()
|
|
519
|
+
|
|
520
|
+
for section in ["Abstract", "Introduction", "Methods", "Results",
|
|
521
|
+
"Discussion", "STROBE Checklist"]:
|
|
522
|
+
assert section.lower() in content.lower(), f"Missing section: {section}"
|
|
523
|
+
|
|
524
|
+
def test_draft_references_correct_p_values(self, benchmark_study):
|
|
525
|
+
"""Draft should reference the actual computed p-values."""
|
|
526
|
+
study_id, _ = benchmark_study
|
|
527
|
+
path = write_draft(study_id)
|
|
528
|
+
content = path.read_text()
|
|
529
|
+
|
|
530
|
+
# KM log-rank p=0.3124 should appear
|
|
531
|
+
assert "0.31" in content or "0.312" in content
|
|
532
|
+
|
|
533
|
+
def test_draft_no_exploratory_tag(self, benchmark_study):
|
|
534
|
+
"""Draft should not contain EXPLORATORY_POST_HOC tag (no post-hoc tests run)."""
|
|
535
|
+
study_id, _ = benchmark_study
|
|
536
|
+
path = write_draft(study_id)
|
|
537
|
+
content = path.read_text()
|
|
538
|
+
assert "EXPLORATORY_POST_HOC" not in content
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class TestSTROBECompliance:
|
|
542
|
+
"""Verify STROBE checklist compliance."""
|
|
543
|
+
|
|
544
|
+
def test_strobe_all_satisfied(self, benchmark_study):
|
|
545
|
+
"""All applicable STROBE items should be satisfied."""
|
|
546
|
+
study_id, _ = benchmark_study
|
|
547
|
+
report = generate_report(study_id)
|
|
548
|
+
satisfied = report.count("\u2713")
|
|
549
|
+
# 33 applicable items for this study design
|
|
550
|
+
assert satisfied == 33, f"Expected 33 satisfied items, got {satisfied}"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# Run as standalone for quick validation
|
|
554
|
+
if __name__ == "__main__":
|
|
555
|
+
import sys
|
|
556
|
+
sys.exit(pytest.main([__file__, "-v", "--tb=short"]))
|