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,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