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,289 @@
|
|
|
1
|
+
"""Tests for manuscript draft limitations generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
12
|
+
from core.planning.lock import lock_plan
|
|
13
|
+
from core.planning.study_plan import StudyPlan
|
|
14
|
+
from core.reporting.manuscript_draft import (
|
|
15
|
+
generate_key_results, generate_limitations, generate_draft,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STUDY_ID = "test_draft_limitations"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _setup_study(seed_vars: bool = True):
|
|
23
|
+
p = DATA_ROOT / STUDY_ID
|
|
24
|
+
if p.exists():
|
|
25
|
+
shutil.rmtree(p)
|
|
26
|
+
p.mkdir(parents=True)
|
|
27
|
+
|
|
28
|
+
conn = get_connection(STUDY_ID)
|
|
29
|
+
init_db(conn)
|
|
30
|
+
conn.execute(
|
|
31
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
|
|
32
|
+
(STUDY_ID, "Limitations Test", "2025-01-01", str(p), "cohort"),
|
|
33
|
+
)
|
|
34
|
+
raw = f"raw_{STUDY_ID}"
|
|
35
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, response TEXT, treatment_arm TEXT)")
|
|
36
|
+
conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('65', 'CR', 'A')")
|
|
37
|
+
conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('70', 'PR', 'B')")
|
|
38
|
+
if seed_vars:
|
|
39
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
40
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
41
|
+
(STUDY_ID, "age", "baseline", "continuous"))
|
|
42
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?,?,?,?)",
|
|
43
|
+
(STUDY_ID, "response", "outcome", "categorical"))
|
|
44
|
+
conn.commit()
|
|
45
|
+
conn.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _add_skipped_result():
|
|
49
|
+
conn = get_connection(STUDY_ID)
|
|
50
|
+
init_db(conn)
|
|
51
|
+
conn.execute(
|
|
52
|
+
"""INSERT INTO analysis_results
|
|
53
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
54
|
+
sample_counts_json, is_pre_registered, computed_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
56
|
+
(STUDY_ID, "chi_square", None, None,
|
|
57
|
+
json.dumps({"status": "skipped_assumption_violation",
|
|
58
|
+
"reason": "minimum expected cell count is 1.4 (below 5 threshold)"}),
|
|
59
|
+
json.dumps({"n_total": 21, "n_analyzed": 0, "n_excluded": 21}),
|
|
60
|
+
"2025-01-01T00:00:00"),
|
|
61
|
+
)
|
|
62
|
+
conn.commit()
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _add_completed_result():
|
|
67
|
+
conn = get_connection(STUDY_ID)
|
|
68
|
+
init_db(conn)
|
|
69
|
+
conn.execute(
|
|
70
|
+
"""INSERT INTO analysis_results
|
|
71
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
72
|
+
sample_counts_json, is_pre_registered, computed_at)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
74
|
+
(STUDY_ID, "t_test", 2.5, 0.02,
|
|
75
|
+
json.dumps({"status": "completed", "reason": None}),
|
|
76
|
+
json.dumps({"n_total": 100, "n_analyzed": 95, "n_excluded": 5}),
|
|
77
|
+
"2025-01-01T00:00:00"),
|
|
78
|
+
)
|
|
79
|
+
conn.commit()
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_skipped_test_produces_limitations():
|
|
84
|
+
_setup_study()
|
|
85
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
|
|
86
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
|
|
87
|
+
lock_plan(STUDY_ID, plan)
|
|
88
|
+
_add_skipped_result()
|
|
89
|
+
|
|
90
|
+
text = generate_limitations(STUDY_ID)
|
|
91
|
+
assert "chi_square" in text
|
|
92
|
+
assert "expected cell count" in text
|
|
93
|
+
assert "exploratory only" in text
|
|
94
|
+
assert ".;" not in text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_no_skipped_no_fabricated():
|
|
98
|
+
"""No skipped tests, adequate sample → no fabricated limitation."""
|
|
99
|
+
_setup_study()
|
|
100
|
+
plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test")
|
|
101
|
+
lock_plan(STUDY_ID, plan)
|
|
102
|
+
_add_completed_result()
|
|
103
|
+
|
|
104
|
+
text = generate_limitations(STUDY_ID)
|
|
105
|
+
# Should have the generic retrospective limitation
|
|
106
|
+
assert "retrospective" in text
|
|
107
|
+
# Should NOT have any skipped-test limitation
|
|
108
|
+
assert "exploratory" not in text
|
|
109
|
+
# Should NOT fabricate a limitation about small sample (n>=50)
|
|
110
|
+
assert "limits statistical power" not in text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_placeholders_preserved():
|
|
114
|
+
"""Introduction, Interpretation, Generalisability remain placeholders."""
|
|
115
|
+
_setup_study()
|
|
116
|
+
lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort"))
|
|
117
|
+
_add_completed_result()
|
|
118
|
+
|
|
119
|
+
# Test limitations function directly — doesn't require Table 1 data
|
|
120
|
+
text = generate_limitations(STUDY_ID)
|
|
121
|
+
assert "retrospective" in text
|
|
122
|
+
# n=100 is >= 50 so no sample-size limitation should appear
|
|
123
|
+
assert "limits statistical power" not in text
|
|
124
|
+
assert "exploratory" not in text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_forced_warning_is_reported_from_planned_test_variable():
|
|
128
|
+
_setup_study()
|
|
129
|
+
plan = StudyPlan(
|
|
130
|
+
study_id=STUDY_ID,
|
|
131
|
+
study_type="cohort",
|
|
132
|
+
primary_comparison="response by arm",
|
|
133
|
+
planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
|
|
134
|
+
warnings={"response": "minimum expected cell count is 1.4"},
|
|
135
|
+
)
|
|
136
|
+
lock_plan(STUDY_ID, plan)
|
|
137
|
+
conn = get_connection(STUDY_ID)
|
|
138
|
+
conn.execute(
|
|
139
|
+
"""INSERT INTO analysis_results
|
|
140
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
141
|
+
sample_counts_json, is_pre_registered, computed_at)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
143
|
+
(STUDY_ID, "chi_square", 0.4, 0.5,
|
|
144
|
+
json.dumps({"status": "completed", "reason": None}),
|
|
145
|
+
json.dumps({"n_total": 21, "n_analyzed": 21, "n_excluded": 0}),
|
|
146
|
+
"2025-01-01T00:00:00"),
|
|
147
|
+
)
|
|
148
|
+
conn.commit()
|
|
149
|
+
conn.close()
|
|
150
|
+
|
|
151
|
+
text = generate_limitations(STUDY_ID)
|
|
152
|
+
assert "performed despite a flagged assumption violation" in text
|
|
153
|
+
assert "minimum expected cell count" in text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_cox_epv_uses_events_not_observations():
|
|
157
|
+
_setup_study()
|
|
158
|
+
conn = get_connection(STUDY_ID)
|
|
159
|
+
conn.execute("ALTER TABLE raw_test_draft_limitations ADD COLUMN pfs_days TEXT")
|
|
160
|
+
conn.execute("ALTER TABLE raw_test_draft_limitations ADD COLUMN pfs_event TEXT")
|
|
161
|
+
conn.execute("UPDATE raw_test_draft_limitations SET pfs_days='100', pfs_event='0'")
|
|
162
|
+
conn.execute("UPDATE raw_test_draft_limitations SET pfs_event='1' WHERE row_id=1")
|
|
163
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, ?, ?, ?)",
|
|
164
|
+
(STUDY_ID, "pfs_days", "outcome", "time_to_event"))
|
|
165
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, ?, ?, ?)",
|
|
166
|
+
(STUDY_ID, "pfs_event", "outcome", "categorical"))
|
|
167
|
+
conn.commit()
|
|
168
|
+
conn.close()
|
|
169
|
+
plan = StudyPlan(
|
|
170
|
+
study_id=STUDY_ID,
|
|
171
|
+
study_type="cohort",
|
|
172
|
+
primary_comparison="PFS by arm",
|
|
173
|
+
planned_tests=[{"variable_name": "pfs_days", "test_name": "cox_proportional_hazards"}],
|
|
174
|
+
covariates=[1],
|
|
175
|
+
)
|
|
176
|
+
lock_plan(STUDY_ID, plan)
|
|
177
|
+
conn = get_connection(STUDY_ID)
|
|
178
|
+
conn.execute(
|
|
179
|
+
"""INSERT INTO analysis_results
|
|
180
|
+
(study_id, test_name, statistic, p_value, status_json,
|
|
181
|
+
sample_counts_json, is_pre_registered, computed_at)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
183
|
+
(STUDY_ID, "cox_proportional_hazards", 1.2, 0.8,
|
|
184
|
+
json.dumps({"status": "completed", "reason": None}),
|
|
185
|
+
json.dumps({"n_total": 100, "n_analyzed": 100, "n_excluded": 0}),
|
|
186
|
+
"2025-01-01T00:00:00"),
|
|
187
|
+
)
|
|
188
|
+
conn.commit()
|
|
189
|
+
conn.close()
|
|
190
|
+
|
|
191
|
+
text = generate_limitations(STUDY_ID)
|
|
192
|
+
assert "EPV=0.5" in text
|
|
193
|
+
assert "events across 2 predictors" in text
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_key_results_counts_statuses_without_editorializing():
|
|
197
|
+
assert generate_key_results([
|
|
198
|
+
{"is_pre_registered": 1, "status_json": json.dumps({"status": "completed"})},
|
|
199
|
+
{"is_pre_registered": 1, "status_json": json.dumps({"status": "skipped_assumption_violation"})},
|
|
200
|
+
]) == (
|
|
201
|
+
"**Key results:** Of 2 pre-registered test(s), 1 completed successfully "
|
|
202
|
+
"and 1 was skipped due to an assumption violation."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Fix 1: MultiIndex column flattening ──────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def test_table1_multiindex_columns_flattened(monkeypatch):
|
|
209
|
+
"""Table 1 with MultiIndex columns should be flattened before markdown rendering."""
|
|
210
|
+
_setup_study()
|
|
211
|
+
lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test"))
|
|
212
|
+
|
|
213
|
+
# Create a DataFrame with MultiIndex columns like tableone produces
|
|
214
|
+
idx = pd.Index(["age", "sex"], name="variable")
|
|
215
|
+
cols = pd.MultiIndex.from_tuples([
|
|
216
|
+
("Grouped by treatment_arm", "Missing"),
|
|
217
|
+
("Grouped by treatment_arm", "Overall"),
|
|
218
|
+
("Grouped by treatment_arm", "A"),
|
|
219
|
+
("Grouped by treatment_arm", "B"),
|
|
220
|
+
])
|
|
221
|
+
fake_tbl = pd.DataFrame(
|
|
222
|
+
[["2 (10.0%)", "21", "10", "11"],
|
|
223
|
+
["21 (100.0%)", "21", "10", "11"]],
|
|
224
|
+
index=idx, columns=cols,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def mock_table1(study_id, groupby=None):
|
|
228
|
+
return fake_tbl
|
|
229
|
+
|
|
230
|
+
monkeypatch.setattr("core.stats.descriptive.generate_table1", mock_table1)
|
|
231
|
+
|
|
232
|
+
draft = generate_draft(STUDY_ID)
|
|
233
|
+
# No tuple strings in the output
|
|
234
|
+
assert "('Grouped" not in draft
|
|
235
|
+
assert "'Missing'" not in draft
|
|
236
|
+
# Clean headers should appear
|
|
237
|
+
assert "Missing" in draft
|
|
238
|
+
assert "Overall" in draft
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_table1_hierarchical_markdown_formatting(monkeypatch):
|
|
242
|
+
"""Categorical variables in Table 1 should render bold parent header and indented child rows."""
|
|
243
|
+
_setup_study()
|
|
244
|
+
lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test"))
|
|
245
|
+
|
|
246
|
+
idx = pd.MultiIndex.from_tuples([
|
|
247
|
+
("high_risk_fish, n (%)", "no"),
|
|
248
|
+
("high_risk_fish, n (%)", "yes"),
|
|
249
|
+
])
|
|
250
|
+
cols = ["Missing", "Overall", "A", "B"]
|
|
251
|
+
fake_tbl = pd.DataFrame(
|
|
252
|
+
[["0", "12 (57.1)", "6 (60.0)", "6 (54.5)"],
|
|
253
|
+
["0", "9 (42.9)", "4 (40.0)", "5 (45.5)"]],
|
|
254
|
+
index=idx, columns=cols,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
monkeypatch.setattr("core.stats.descriptive.generate_table1", lambda study_id, groupby=None: fake_tbl)
|
|
258
|
+
|
|
259
|
+
draft = generate_draft(STUDY_ID)
|
|
260
|
+
assert "**high_risk_fish, n (%)**" in draft
|
|
261
|
+
assert " no" in draft
|
|
262
|
+
assert " yes" in draft
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Fix 2: Abstract hydration ─────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def test_abstract_hydrated_with_analysis_results():
|
|
268
|
+
"""Abstract's Results line should use real analysis counts, not a placeholder."""
|
|
269
|
+
_setup_study()
|
|
270
|
+
lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test"))
|
|
271
|
+
_add_skipped_result()
|
|
272
|
+
_add_completed_result()
|
|
273
|
+
|
|
274
|
+
draft = generate_draft(STUDY_ID)
|
|
275
|
+
|
|
276
|
+
# Should NOT contain the old placeholder
|
|
277
|
+
assert "[Results summary" not in draft
|
|
278
|
+
# 1 skipped + 1 completed = 1 of 2 completed
|
|
279
|
+
assert "1 of 2 pre-registered tests completed" in draft
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_abstract_placeholder_when_no_analyses():
|
|
283
|
+
"""Abstract should keep placeholder when no analysis results exist."""
|
|
284
|
+
_setup_study()
|
|
285
|
+
lock_plan(STUDY_ID, StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test"))
|
|
286
|
+
|
|
287
|
+
draft = generate_draft(STUDY_ID)
|
|
288
|
+
|
|
289
|
+
assert "[Results summary" in draft
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Adversarial tests for the masking gate.
|
|
2
|
+
|
|
3
|
+
The gate MUST prevent outcome data access at the physical storage level.
|
|
4
|
+
|
|
5
|
+
Tests:
|
|
6
|
+
1. Raw sqlite3 SELECT on outcome columns pre-classify → visible (not yet sealed)
|
|
7
|
+
2. After classify + seal → outcome columns are NULL in the raw table
|
|
8
|
+
3. After lock → still NULL
|
|
9
|
+
4. After unmask → outcome data restored
|
|
10
|
+
5. Direct sqlite3 CLI attack → still NULL (storage-level, not proxy-level)
|
|
11
|
+
6. No outcome columns → no interference
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
22
|
+
from core.masking.gate import seal_outcomes, unmask_study, lock_study, is_masked
|
|
23
|
+
from core.ingestion.variable_classifier import _classify_batch
|
|
24
|
+
|
|
25
|
+
TEST_STUDY_ID = "test_masking_001"
|
|
26
|
+
RAW_TABLE = f"raw_{TEST_STUDY_ID}"
|
|
27
|
+
MASKED_TABLE = f"raw_masked_{TEST_STUDY_ID}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def _setup():
|
|
32
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
33
|
+
init_db(conn)
|
|
34
|
+
conn.execute(
|
|
35
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, is_locked) VALUES (?, ?, ?, ?, ?)",
|
|
36
|
+
(TEST_STUDY_ID, "Test Study", "2025-01-01T00:00:00",
|
|
37
|
+
str(Path("data/studies") / TEST_STUDY_ID), 0),
|
|
38
|
+
)
|
|
39
|
+
# Create raw table with outcome columns that have actual values
|
|
40
|
+
conn.execute(f"""
|
|
41
|
+
CREATE TABLE IF NOT EXISTS {RAW_TABLE} (
|
|
42
|
+
"row_id" INTEGER PRIMARY KEY,
|
|
43
|
+
"age" TEXT,
|
|
44
|
+
"response" TEXT,
|
|
45
|
+
"pfs_days" TEXT
|
|
46
|
+
)
|
|
47
|
+
""")
|
|
48
|
+
conn.execute(f"DELETE FROM {RAW_TABLE}")
|
|
49
|
+
for i in range(3):
|
|
50
|
+
conn.execute(
|
|
51
|
+
f"INSERT INTO {RAW_TABLE} (age, response, pfs_days) VALUES (?, ?, ?)",
|
|
52
|
+
(str(65 + i), "CR" if i == 0 else "PR", str(300 + i)),
|
|
53
|
+
)
|
|
54
|
+
conn.commit()
|
|
55
|
+
conn.close()
|
|
56
|
+
yield
|
|
57
|
+
import shutil
|
|
58
|
+
p = DATA_ROOT / TEST_STUDY_ID
|
|
59
|
+
if p.exists():
|
|
60
|
+
shutil.rmtree(p)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _classify(study_id, variables):
|
|
64
|
+
"""Helper to classify variables and seal outcomes."""
|
|
65
|
+
_classify_batch(study_id, variables)
|
|
66
|
+
seal_outcomes(study_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_raw_outcomes_visible_before_seal():
|
|
70
|
+
"""Before sealing, outcome values are visible in the raw table."""
|
|
71
|
+
# Classify but don't seal yet
|
|
72
|
+
_classify_batch(TEST_STUDY_ID, [
|
|
73
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
74
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
75
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
76
|
+
])
|
|
77
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
78
|
+
cur = conn.execute(f"SELECT response, pfs_days FROM {RAW_TABLE}")
|
|
79
|
+
rows = cur.fetchall()
|
|
80
|
+
conn.close()
|
|
81
|
+
for r in rows:
|
|
82
|
+
assert r["response"] is not None, "pre-seal outcomes should be visible"
|
|
83
|
+
assert r["pfs_days"] is not None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_outcomes_nulled_after_seal():
|
|
87
|
+
"""After seal_outcomes(), outcome values are physically NULL in the raw table."""
|
|
88
|
+
_classify_batch(TEST_STUDY_ID, [
|
|
89
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
90
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
91
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
92
|
+
])
|
|
93
|
+
seal_outcomes(TEST_STUDY_ID)
|
|
94
|
+
|
|
95
|
+
# Direct conn — no proxy
|
|
96
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
97
|
+
cur = conn.execute(f"SELECT age, response, pfs_days FROM {RAW_TABLE}")
|
|
98
|
+
rows = cur.fetchall()
|
|
99
|
+
conn.close()
|
|
100
|
+
for r in rows:
|
|
101
|
+
assert r["age"] is not None, "baseline should be visible"
|
|
102
|
+
assert r["response"] is None, f"outcome should be NULL after seal, got {r['response']}"
|
|
103
|
+
assert r["pfs_days"] is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_sqlite3_cli_bypass_still_null():
|
|
107
|
+
"""Even direct sqlite3 CLI (simulated via sqlite3 module) sees NULLs after seal."""
|
|
108
|
+
_classify_batch(TEST_STUDY_ID, [
|
|
109
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
110
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
111
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
112
|
+
])
|
|
113
|
+
seal_outcomes(TEST_STUDY_ID)
|
|
114
|
+
|
|
115
|
+
# Connect with raw sqlite3, not through our code
|
|
116
|
+
db_path = DATA_ROOT / TEST_STUDY_ID / "study.db"
|
|
117
|
+
raw_conn = sqlite3.connect(str(db_path))
|
|
118
|
+
raw_conn.row_factory = sqlite3.Row
|
|
119
|
+
cur = raw_conn.execute(f"SELECT response, pfs_days FROM {RAW_TABLE}")
|
|
120
|
+
rows = cur.fetchall()
|
|
121
|
+
raw_conn.close()
|
|
122
|
+
for r in rows:
|
|
123
|
+
assert r["response"] is None, "direct sqlite3 should also see NULL"
|
|
124
|
+
assert r["pfs_days"] is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_lock_still_null():
|
|
128
|
+
"""After lock_study(), outcomes remain NULL."""
|
|
129
|
+
_classify(TEST_STUDY_ID, [
|
|
130
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
131
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
132
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
133
|
+
])
|
|
134
|
+
lock_study(TEST_STUDY_ID)
|
|
135
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
136
|
+
cur = conn.execute(f"SELECT response FROM {RAW_TABLE}")
|
|
137
|
+
rows = cur.fetchall()
|
|
138
|
+
conn.close()
|
|
139
|
+
for r in rows:
|
|
140
|
+
assert r["response"] is None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_unmask_restores_values():
|
|
144
|
+
"""After unmask_study(), outcome values are restored in the raw table."""
|
|
145
|
+
_classify(TEST_STUDY_ID, [
|
|
146
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
147
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
148
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
149
|
+
])
|
|
150
|
+
unmask_study(TEST_STUDY_ID)
|
|
151
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
152
|
+
cur = conn.execute(f"SELECT age, response, pfs_days FROM {RAW_TABLE} ORDER BY row_id")
|
|
153
|
+
rows = cur.fetchall()
|
|
154
|
+
conn.close()
|
|
155
|
+
assert rows[0]["response"] == "CR"
|
|
156
|
+
assert rows[1]["response"] == "PR"
|
|
157
|
+
assert rows[0]["pfs_days"] == "300"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_is_masked_after_seal():
|
|
161
|
+
"""is_masked() returns True after sealing, False after unmask."""
|
|
162
|
+
_classify(TEST_STUDY_ID, [
|
|
163
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
164
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
165
|
+
{"column": "pfs_days", "role": "outcome", "data_type": "continuous"},
|
|
166
|
+
])
|
|
167
|
+
assert is_masked(TEST_STUDY_ID) is True
|
|
168
|
+
unmask_study(TEST_STUDY_ID)
|
|
169
|
+
assert is_masked(TEST_STUDY_ID) is False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_no_outcome_variables_no_effect():
|
|
173
|
+
"""If no outcome columns exist, sealing does nothing."""
|
|
174
|
+
_classify_batch(TEST_STUDY_ID, [
|
|
175
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
176
|
+
])
|
|
177
|
+
seal_outcomes(TEST_STUDY_ID)
|
|
178
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
179
|
+
cur = conn.execute(f"SELECT age FROM {RAW_TABLE}")
|
|
180
|
+
row = cur.fetchone()
|
|
181
|
+
conn.close()
|
|
182
|
+
assert row["age"] is not None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_shadow_table_contains_values():
|
|
186
|
+
"""The masked shadow table should have the original values."""
|
|
187
|
+
_classify(TEST_STUDY_ID, [
|
|
188
|
+
{"column": "age", "role": "baseline", "data_type": "continuous"},
|
|
189
|
+
{"column": "response", "role": "outcome", "data_type": "categorical"},
|
|
190
|
+
])
|
|
191
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
192
|
+
cur = conn.execute(f"SELECT response FROM {MASKED_TABLE} ORDER BY row_id")
|
|
193
|
+
rows = cur.fetchall()
|
|
194
|
+
conn.close()
|
|
195
|
+
assert rows[0]["response"] == "CR"
|
|
196
|
+
assert rows[1]["response"] == "PR"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone smoke test for the outcome-masking migration.
|
|
3
|
+
Builds an in-memory throwaway DB matching the migration's shape,
|
|
4
|
+
applies the migration, and asserts the masking invariant holds
|
|
5
|
+
for BOTH the sqlite3 stdlib driver directly (simulating a raw
|
|
6
|
+
CLI-equivalent connection) AND confirms it's not dependent on
|
|
7
|
+
any application wrapper.
|
|
8
|
+
|
|
9
|
+
Run: python3 test_masking_migration.py
|
|
10
|
+
"""
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
SETUP_RAW_TABLE = """
|
|
15
|
+
CREATE TABLE cohort (
|
|
16
|
+
study_id TEXT,
|
|
17
|
+
pt_id TEXT,
|
|
18
|
+
age INTEGER,
|
|
19
|
+
stage TEXT,
|
|
20
|
+
treatment TEXT,
|
|
21
|
+
os_event INTEGER,
|
|
22
|
+
os_months REAL
|
|
23
|
+
);
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
SEED_DATA = """
|
|
27
|
+
INSERT INTO cohort (study_id, pt_id, age, stage, treatment, os_event, os_months)
|
|
28
|
+
VALUES ('study_123', 'P001', 55, 'II', 'ArmA', 1, 14.2),
|
|
29
|
+
('study_123', 'P002', 61, 'III', 'ArmB', 0, 22.7);
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
MIGRATION = """
|
|
33
|
+
ALTER TABLE cohort RENAME TO _raw_cohort;
|
|
34
|
+
|
|
35
|
+
CREATE TABLE study_state (
|
|
36
|
+
study_id TEXT PRIMARY KEY,
|
|
37
|
+
status TEXT NOT NULL DEFAULT 'unlocked'
|
|
38
|
+
CHECK (status IN ('unlocked', 'locked', 'amendment_in_progress')),
|
|
39
|
+
current_plan_hash TEXT,
|
|
40
|
+
updated_at_utc TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
INSERT INTO study_state (study_id, status) VALUES ('study_123', 'unlocked');
|
|
44
|
+
|
|
45
|
+
CREATE VIEW cohort AS
|
|
46
|
+
SELECT
|
|
47
|
+
r.pt_id, r.age, r.stage, r.treatment,
|
|
48
|
+
CASE WHEN (SELECT status FROM study_state WHERE study_id = r.study_id) = 'locked'
|
|
49
|
+
THEN r.os_event ELSE NULL END AS os_event,
|
|
50
|
+
CASE WHEN (SELECT status FROM study_state WHERE study_id = r.study_id) = 'locked'
|
|
51
|
+
THEN r.os_months ELSE NULL END AS os_months
|
|
52
|
+
FROM _raw_cohort r;
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def run():
|
|
56
|
+
conn = sqlite3.connect(":memory:")
|
|
57
|
+
conn.executescript(SETUP_RAW_TABLE)
|
|
58
|
+
conn.executescript(SEED_DATA)
|
|
59
|
+
conn.executescript(MIGRATION)
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
failures = []
|
|
63
|
+
|
|
64
|
+
# Test 1: unlocked -> view returns NULL outcome, no wrapper involved
|
|
65
|
+
rows = conn.execute("SELECT pt_id, os_event, os_months FROM cohort").fetchall()
|
|
66
|
+
for pt_id, os_event, os_months in rows:
|
|
67
|
+
if os_event is not None or os_months is not None:
|
|
68
|
+
failures.append(f"UNLOCKED test FAILED: {pt_id} returned os_event={os_event}, os_months={os_months} (expected NULL)")
|
|
69
|
+
|
|
70
|
+
# Test 2: raw table still has real data (expected -- that's the vault, not the leak)
|
|
71
|
+
raw_rows = conn.execute("SELECT pt_id, os_event FROM _raw_cohort").fetchall()
|
|
72
|
+
if not any(r[1] is not None for r in raw_rows):
|
|
73
|
+
failures.append("RAW TABLE test FAILED: expected real values in _raw_cohort, got all NULL")
|
|
74
|
+
|
|
75
|
+
# Test 3: lock the study, view should now reveal real values
|
|
76
|
+
conn.execute("UPDATE study_state SET status = 'locked' WHERE study_id = 'study_123'")
|
|
77
|
+
conn.commit()
|
|
78
|
+
rows_locked = conn.execute("SELECT pt_id, os_event, os_months FROM cohort").fetchall()
|
|
79
|
+
if not any(r[1] is not None for r in rows_locked):
|
|
80
|
+
failures.append("LOCKED test FAILED: expected real os_event values after lock, got all NULL")
|
|
81
|
+
|
|
82
|
+
# Test 4: re-mask on amendment (simulating Autopsy Canvas re-entry)
|
|
83
|
+
conn.execute("UPDATE study_state SET status = 'amendment_in_progress' WHERE study_id = 'study_123'")
|
|
84
|
+
conn.commit()
|
|
85
|
+
rows_amend = conn.execute("SELECT pt_id, os_event FROM cohort").fetchall()
|
|
86
|
+
if any(r[1] is not None for r in rows_amend):
|
|
87
|
+
failures.append("AMENDMENT test FAILED: expected re-masking during amendment_in_progress, got real values")
|
|
88
|
+
|
|
89
|
+
# Test 5: a second study must not be affected by study_123's lock state
|
|
90
|
+
conn.execute("INSERT INTO study_state (study_id, status) VALUES ('study_456', 'unlocked')")
|
|
91
|
+
conn.execute("""INSERT INTO _raw_cohort (study_id, pt_id, age, stage, treatment, os_event, os_months)
|
|
92
|
+
VALUES ('study_456', 'Q001', 40, 'I', 'ArmA', 1, 5.0)""")
|
|
93
|
+
conn.commit()
|
|
94
|
+
cross_study = conn.execute(
|
|
95
|
+
"SELECT c.pt_id, c.os_event FROM cohort c JOIN _raw_cohort r USING(pt_id) WHERE r.study_id='study_456'"
|
|
96
|
+
).fetchall()
|
|
97
|
+
if any(r[1] is not None for r in cross_study):
|
|
98
|
+
failures.append("CROSS-STUDY test FAILED: study_456 (unlocked) leaked real os_event values")
|
|
99
|
+
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
if failures:
|
|
103
|
+
print("FAILURES:")
|
|
104
|
+
for f in failures:
|
|
105
|
+
print(" -", f)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
else:
|
|
108
|
+
print("All masking invariant tests passed.")
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
run()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for multiple comparisons correction."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from core.stats.multiple_comparisons import bonferroni, holm_bonferroni, benjamini_hochberg, correct
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_bonferroni_identity():
|
|
9
|
+
"""Single p-value unchanged by Bonferroni."""
|
|
10
|
+
assert bonferroni([0.05]) == [0.05]
|
|
11
|
+
assert bonferroni([0.01]) == [0.01]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_bonferroni_caps_at_one():
|
|
15
|
+
adjusted = bonferroni([0.5, 0.5])
|
|
16
|
+
assert all(a == 1.0 for a in adjusted)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_bonferroni_multiple():
|
|
20
|
+
adjusted = bonferroni([0.01, 0.03, 0.05])
|
|
21
|
+
assert adjusted[0] == pytest.approx(0.03) # 0.01 * 3
|
|
22
|
+
assert adjusted[1] == pytest.approx(0.09) # 0.03 * 3
|
|
23
|
+
assert adjusted[2] == pytest.approx(0.15) # 0.05 * 3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_holm_bonferroni():
|
|
27
|
+
adjusted = holm_bonferroni([0.01, 0.03, 0.05])
|
|
28
|
+
# After monotonicity enforcement: 0.06, 0.06, 0.05
|
|
29
|
+
assert adjusted == pytest.approx([0.06, 0.06, 0.05])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_holm_monotonic():
|
|
33
|
+
"""Holm-Bonferroni adjusted p-values must be non-decreasing."""
|
|
34
|
+
adjusted = holm_bonferroni([0.001, 0.01, 0.1, 0.5])
|
|
35
|
+
for i in range(len(adjusted) - 1):
|
|
36
|
+
assert adjusted[i] <= adjusted[i + 1] + 1e-10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_benjamini_hochberg():
|
|
40
|
+
adjusted = benjamini_hochberg([0.01, 0.02, 0.03, 0.04, 0.05])
|
|
41
|
+
assert all(a <= 1.0 for a in adjusted)
|
|
42
|
+
assert adjusted[0] <= adjusted[1] # non-decreasing
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_empty_input():
|
|
46
|
+
assert bonferroni([]) == []
|
|
47
|
+
assert holm_bonferroni([]) == []
|
|
48
|
+
assert benjamini_hochberg([]) == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_correct_dispatch():
|
|
52
|
+
pvals = [0.01, 0.02, 0.03]
|
|
53
|
+
assert correct(pvals, "bonferroni")[0] == 0.03
|
|
54
|
+
assert len(correct(pvals, "holm_bonferroni")) == 3
|
|
55
|
+
assert len(correct(pvals, "benjamini_hochberg")) == 3
|
|
56
|
+
assert correct(pvals, "unknown") == pvals
|