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
tests/test_bundle.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Tests for hash-verified study bundle creation and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
15
|
+
from core.ingestion.csv_loader import load_file
|
|
16
|
+
from core.ingestion.variable_classifier import classify_variables_interactive, _classify_batch
|
|
17
|
+
from core.masking.gate import seal_outcomes, unmask_study
|
|
18
|
+
from core.planning.study_plan import StudyPlan
|
|
19
|
+
from core.planning.lock import lock_plan
|
|
20
|
+
from core.stats.inferential import run_test
|
|
21
|
+
from core.reporting.bundle import create_bundle, verify_bundle, format_verification_report
|
|
22
|
+
from core.reporting.manuscript_draft import write_draft
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
STUDY_ID = "test_bundle"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def _setup():
|
|
30
|
+
if (DATA_ROOT / STUDY_ID).exists():
|
|
31
|
+
shutil.rmtree(DATA_ROOT / STUDY_ID)
|
|
32
|
+
(DATA_ROOT / STUDY_ID).mkdir(parents=True, exist_ok=True)
|
|
33
|
+
yield
|
|
34
|
+
if (DATA_ROOT / STUDY_ID).exists():
|
|
35
|
+
shutil.rmtree(DATA_ROOT / STUDY_ID)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _setup_completed_study(with_analysis: bool = True):
|
|
39
|
+
"""Create a full study through analysis. Returns nothing (uses STUDY_ID)."""
|
|
40
|
+
conn = get_connection(STUDY_ID)
|
|
41
|
+
init_db(conn)
|
|
42
|
+
conn.execute(
|
|
43
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
44
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
45
|
+
(STUDY_ID, "Bundle Test", "2025-01-01T00:00:00",
|
|
46
|
+
str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
47
|
+
)
|
|
48
|
+
conn.commit()
|
|
49
|
+
conn.close()
|
|
50
|
+
|
|
51
|
+
# Ingest minimal CSV
|
|
52
|
+
csv_content = (
|
|
53
|
+
"patient_id,age,sex,treatment_arm,response_category\n"
|
|
54
|
+
"P001,65,M,A,CR\n"
|
|
55
|
+
"P002,70,F,B,PR\n"
|
|
56
|
+
"P003,55,M,A,SD\n"
|
|
57
|
+
)
|
|
58
|
+
tmp = Path(tempfile.mkstemp(suffix=".csv")[1])
|
|
59
|
+
tmp.write_text(csv_content)
|
|
60
|
+
load_file(STUDY_ID, str(tmp))
|
|
61
|
+
tmp.unlink()
|
|
62
|
+
|
|
63
|
+
# Classify
|
|
64
|
+
suggestions = classify_variables_interactive(STUDY_ID,
|
|
65
|
+
["patient_id", "age", "sex", "treatment_arm", "response_category"])
|
|
66
|
+
from core.ingestion.variable_classifier import _classify_batch
|
|
67
|
+
_classify_batch(STUDY_ID, suggestions)
|
|
68
|
+
seal_outcomes(STUDY_ID)
|
|
69
|
+
|
|
70
|
+
# Lock plan
|
|
71
|
+
plan = StudyPlan(
|
|
72
|
+
study_id=STUDY_ID, study_type="cohort",
|
|
73
|
+
primary_comparison="Response by treatment arm",
|
|
74
|
+
primary_outcome_variable_ids=[],
|
|
75
|
+
planned_tests=[{"variable_name": "response_category", "test_name": "chi_square"}],
|
|
76
|
+
covariates=[],
|
|
77
|
+
)
|
|
78
|
+
lock_plan(STUDY_ID, plan)
|
|
79
|
+
|
|
80
|
+
if not with_analysis:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Unmask + analyze
|
|
84
|
+
unmask_study(STUDY_ID)
|
|
85
|
+
import pandas as pd
|
|
86
|
+
conn = get_connection(STUDY_ID)
|
|
87
|
+
df = pd.read_sql_query(f"SELECT * FROM raw_{STUDY_ID}", conn)
|
|
88
|
+
init_db(conn)
|
|
89
|
+
from datetime import datetime, timezone
|
|
90
|
+
|
|
91
|
+
result = run_test("chi_square", df, outcome_col="response_category", group_col="treatment_arm")
|
|
92
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
93
|
+
conn.execute(
|
|
94
|
+
"INSERT INTO analysis_results (study_id, study_plan_version, variable_ids_used, "
|
|
95
|
+
"test_name, statistic, p_value, ci_lower, ci_upper, status_json, "
|
|
96
|
+
"is_pre_registered, provenance_json, computed_at) "
|
|
97
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
|
98
|
+
(STUDY_ID, plan.version, json.dumps([]),
|
|
99
|
+
result["test_name"], result["statistic"], result["p_value"],
|
|
100
|
+
result.get("ci_lower"), result.get("ci_upper"),
|
|
101
|
+
json.dumps({"status": "completed", "reason": None}),
|
|
102
|
+
json.dumps({"plan_version": plan.version}), now),
|
|
103
|
+
)
|
|
104
|
+
conn.commit()
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
# Write draft so it's included in the bundle
|
|
108
|
+
write_draft(STUDY_ID)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_composite_hash_validates():
|
|
112
|
+
"""A bundle's composite hash must verify correctly against its own contents."""
|
|
113
|
+
_setup_completed_study()
|
|
114
|
+
result = create_bundle(STUDY_ID)
|
|
115
|
+
assert result["bundle_path"].exists()
|
|
116
|
+
|
|
117
|
+
verify = verify_bundle(str(result["bundle_path"]))
|
|
118
|
+
assert verify["valid"], f"Bundle should be valid: {format_verification_report(verify)}"
|
|
119
|
+
assert verify["composite_match"]
|
|
120
|
+
assert verify["raw_data_match"]
|
|
121
|
+
assert verify["locked_plan_match"]
|
|
122
|
+
assert verify["results_match"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_tampered_raw_data_detected():
|
|
126
|
+
"""Tampering with raw_data.json must make the bundle fail verification."""
|
|
127
|
+
_setup_completed_study()
|
|
128
|
+
result = create_bundle(STUDY_ID)
|
|
129
|
+
bundle_path = result["bundle_path"]
|
|
130
|
+
|
|
131
|
+
# Extract, modify raw_data.json, repack, verify
|
|
132
|
+
import tarfile
|
|
133
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
134
|
+
with tarfile.open(str(bundle_path), "r:gz") as tar:
|
|
135
|
+
tar.extractall(str(tmpdir))
|
|
136
|
+
|
|
137
|
+
# Tamper raw_data.json
|
|
138
|
+
raw_path = tmpdir / "raw_data.json"
|
|
139
|
+
orig = raw_path.read_text()
|
|
140
|
+
raw_path.write_text(orig.replace("P001", "P999"))
|
|
141
|
+
|
|
142
|
+
# Repack
|
|
143
|
+
tampered_path = tmpdir / "tampered.tar.gz"
|
|
144
|
+
with tarfile.open(str(tampered_path), "w:gz") as tar:
|
|
145
|
+
for f in tmpdir.iterdir():
|
|
146
|
+
if f.name == "tampered.tar.gz":
|
|
147
|
+
continue
|
|
148
|
+
tar.add(str(f), arcname=f.name)
|
|
149
|
+
|
|
150
|
+
verify = verify_bundle(str(tampered_path))
|
|
151
|
+
assert not verify["valid"]
|
|
152
|
+
assert not verify["raw_data_match"]
|
|
153
|
+
assert verify["locked_plan_match"] # plan unchanged
|
|
154
|
+
assert verify["results_match"] # results unchanged
|
|
155
|
+
shutil.rmtree(tmpdir)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_tampered_plan_detected():
|
|
159
|
+
"""Tampering with the locked plan must break verification and identify plan hash."""
|
|
160
|
+
_setup_completed_study()
|
|
161
|
+
result = create_bundle(STUDY_ID)
|
|
162
|
+
bundle_path = result["bundle_path"]
|
|
163
|
+
|
|
164
|
+
import tarfile
|
|
165
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
166
|
+
with tarfile.open(str(bundle_path), "r:gz") as tar:
|
|
167
|
+
tar.extractall(str(tmpdir))
|
|
168
|
+
|
|
169
|
+
# Tamper the locked plan (e.g. change a covariate)
|
|
170
|
+
plan_path = tmpdir / "study_plan.locked.json"
|
|
171
|
+
plan_data = json.loads(plan_path.read_text())
|
|
172
|
+
plan_data["covariates"] = [99]
|
|
173
|
+
# Regenerate content_hash for the tampered plan
|
|
174
|
+
from core.planning.lock import _compute_hash
|
|
175
|
+
plan_data["content_hash"] = _compute_hash(plan_data)
|
|
176
|
+
plan_path.write_text(json.dumps(plan_data, indent=2))
|
|
177
|
+
|
|
178
|
+
tampered_path = tmpdir / "tampered.tar.gz"
|
|
179
|
+
with tarfile.open(str(tampered_path), "w:gz") as tar:
|
|
180
|
+
for f in tmpdir.iterdir():
|
|
181
|
+
if f.name == "tampered.tar.gz":
|
|
182
|
+
continue
|
|
183
|
+
tar.add(str(f), arcname=f.name)
|
|
184
|
+
|
|
185
|
+
verify = verify_bundle(str(tampered_path))
|
|
186
|
+
assert not verify["valid"]
|
|
187
|
+
assert not verify["locked_plan_match"]
|
|
188
|
+
assert verify["raw_data_match"]
|
|
189
|
+
assert verify["results_match"]
|
|
190
|
+
shutil.rmtree(tmpdir)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_bundle_rejects_no_analysis():
|
|
194
|
+
"""Bundling must fail with a clear error if the study hasn't been analyzed."""
|
|
195
|
+
_setup_completed_study(with_analysis=False)
|
|
196
|
+
with pytest.raises(RuntimeError, match="not been unmasked"):
|
|
197
|
+
create_bundle(STUDY_ID)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_verify_bundle_works_independently():
|
|
201
|
+
"""verify_bundle must work from a bundle file alone, with no original DB."""
|
|
202
|
+
_setup_completed_study()
|
|
203
|
+
result = create_bundle(STUDY_ID)
|
|
204
|
+
bundle_path = result["bundle_path"]
|
|
205
|
+
|
|
206
|
+
# Copy bundle to a temp dir with NO study DB
|
|
207
|
+
isolated_dir = Path(tempfile.mkdtemp())
|
|
208
|
+
isolated_bundle = isolated_dir / bundle_path.name
|
|
209
|
+
shutil.copy2(str(bundle_path), str(isolated_bundle))
|
|
210
|
+
|
|
211
|
+
# Delete original study data
|
|
212
|
+
shutil.rmtree(DATA_ROOT / STUDY_ID)
|
|
213
|
+
|
|
214
|
+
verify = verify_bundle(str(isolated_bundle))
|
|
215
|
+
assert verify["valid"], "Bundle must verify independently of original DB"
|
|
216
|
+
assert verify["composite_match"]
|
|
217
|
+
shutil.rmtree(isolated_dir)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_missing_file_reported_correctly():
|
|
221
|
+
"""A bundle with a file missing entirely should say 'missing', not 'altered'."""
|
|
222
|
+
_setup_completed_study()
|
|
223
|
+
result = create_bundle(STUDY_ID)
|
|
224
|
+
bundle_path = result["bundle_path"]
|
|
225
|
+
|
|
226
|
+
import tarfile
|
|
227
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
228
|
+
with tarfile.open(str(bundle_path), "r:gz") as tar:
|
|
229
|
+
tar.extractall(str(tmpdir))
|
|
230
|
+
|
|
231
|
+
# Repack WITHOUT analysis_results.json
|
|
232
|
+
tampered_path = tmpdir / "tampered.tar.gz"
|
|
233
|
+
with tarfile.open(str(tampered_path), "w:gz") as tar:
|
|
234
|
+
for f in tmpdir.iterdir():
|
|
235
|
+
if f.name in ("tampered.tar.gz", "analysis_results.json"):
|
|
236
|
+
continue
|
|
237
|
+
tar.add(str(f), arcname=f.name)
|
|
238
|
+
|
|
239
|
+
verify = verify_bundle(str(tampered_path))
|
|
240
|
+
assert not verify["valid"]
|
|
241
|
+
assert "analysis_results.json" in verify.get("missing_files", [])
|
|
242
|
+
report = format_verification_report(verify)
|
|
243
|
+
assert "is missing from the archive" in report
|
|
244
|
+
assert "analysis_results.json is missing from the archive" in report
|
|
245
|
+
# Should NOT say "content has been altered" for a missing file
|
|
246
|
+
assert "content has been altered" not in report
|
|
247
|
+
shutil.rmtree(tmpdir)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_altered_file_says_altered():
|
|
251
|
+
"""A bundle with altered content should say 'altered', not 'missing'."""
|
|
252
|
+
_setup_completed_study()
|
|
253
|
+
result = create_bundle(STUDY_ID)
|
|
254
|
+
bundle_path = result["bundle_path"]
|
|
255
|
+
|
|
256
|
+
import tarfile
|
|
257
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
258
|
+
with tarfile.open(str(bundle_path), "r:gz") as tar:
|
|
259
|
+
tar.extractall(str(tmpdir))
|
|
260
|
+
|
|
261
|
+
# Tamper raw_data.json
|
|
262
|
+
raw_path = tmpdir / "raw_data.json"
|
|
263
|
+
raw_path.write_text("[]")
|
|
264
|
+
|
|
265
|
+
tampered_path = tmpdir / "tampered.tar.gz"
|
|
266
|
+
with tarfile.open(str(tampered_path), "w:gz") as tar:
|
|
267
|
+
for f in tmpdir.iterdir():
|
|
268
|
+
if f.name == "tampered.tar.gz":
|
|
269
|
+
continue
|
|
270
|
+
tar.add(str(f), arcname=f.name)
|
|
271
|
+
|
|
272
|
+
verify = verify_bundle(str(tampered_path))
|
|
273
|
+
report = format_verification_report(verify)
|
|
274
|
+
assert "content has been altered" in report
|
|
275
|
+
assert "raw_data.json content has been altered" in report
|
|
276
|
+
assert "is missing from the archive" not in report
|
|
277
|
+
shutil.rmtree(tmpdir)
|