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