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_from_json.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for --from-json plan loading with native JSON types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
12
|
+
from core.masking.gate import seal_outcomes
|
|
13
|
+
from core.planning.lock import load_plan
|
|
14
|
+
|
|
15
|
+
STUDY_ID = "test_from_json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _setup():
|
|
20
|
+
p = DATA_ROOT / STUDY_ID
|
|
21
|
+
if p.exists():
|
|
22
|
+
shutil.rmtree(p)
|
|
23
|
+
p.mkdir(parents=True)
|
|
24
|
+
|
|
25
|
+
conn = get_connection(STUDY_ID)
|
|
26
|
+
init_db(conn)
|
|
27
|
+
conn.execute(
|
|
28
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
|
|
29
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
30
|
+
(STUDY_ID, "JSON Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
31
|
+
)
|
|
32
|
+
raw = f"raw_{STUDY_ID}"
|
|
33
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, pfs_days TEXT, pfs_event TEXT, treatment_arm TEXT)")
|
|
34
|
+
conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('65', '120', '1', 'A')")
|
|
35
|
+
conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('70', '90', '0', 'B')")
|
|
36
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
37
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
|
|
38
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_days', 'outcome', 'time_to_event')", (STUDY_ID,))
|
|
39
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_event', 'outcome', 'categorical')", (STUDY_ID,))
|
|
40
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
|
|
41
|
+
conn.commit()
|
|
42
|
+
conn.close()
|
|
43
|
+
|
|
44
|
+
seal_outcomes(STUDY_ID)
|
|
45
|
+
yield
|
|
46
|
+
if p.exists():
|
|
47
|
+
shutil.rmtree(p)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _set_state(state: int):
|
|
51
|
+
conn = get_connection(STUDY_ID)
|
|
52
|
+
init_db(conn)
|
|
53
|
+
conn.execute("UPDATE studies SET is_locked=? WHERE id=?", (state, STUDY_ID))
|
|
54
|
+
conn.commit()
|
|
55
|
+
conn.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_from_json_native_types(tmp_path):
|
|
59
|
+
"""--from-json must accept native JSON arrays, not just CLI-encoded strings."""
|
|
60
|
+
from core.cli.main import cmd_plan
|
|
61
|
+
import argparse
|
|
62
|
+
|
|
63
|
+
# Get actual variable IDs from the test DB
|
|
64
|
+
conn = get_connection(STUDY_ID)
|
|
65
|
+
rows = conn.execute("SELECT id, column_name FROM variables WHERE study_id=? ORDER BY id", (STUDY_ID,)).fetchall()
|
|
66
|
+
conn.close()
|
|
67
|
+
var_ids = {r["column_name"]: r["id"] for r in rows}
|
|
68
|
+
|
|
69
|
+
json_data = {
|
|
70
|
+
"comparison": "PFS by treatment arm",
|
|
71
|
+
"outcome_var_ids": [var_ids["pfs_days"], var_ids["pfs_event"]], # native array
|
|
72
|
+
"study_type": "cohort",
|
|
73
|
+
"tests": [
|
|
74
|
+
{"variable_name": "pfs_days", "test_name": "logrank", "rationale": "Compare PFS"}
|
|
75
|
+
],
|
|
76
|
+
"covariates": [var_ids["age"]], # native array
|
|
77
|
+
"cox_ph_models": [
|
|
78
|
+
{
|
|
79
|
+
"model_name": "pfs_model",
|
|
80
|
+
"survival_time_col": "pfs_days",
|
|
81
|
+
"event_col": "pfs_event",
|
|
82
|
+
"primary_treatment_col": "treatment_arm",
|
|
83
|
+
"covariate_cols": ["age"],
|
|
84
|
+
"rationale": "Adjusted PFS model",
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
json_path = tmp_path / "plan.json"
|
|
89
|
+
json_path.write_text(json.dumps(json_data))
|
|
90
|
+
|
|
91
|
+
args = argparse.Namespace(
|
|
92
|
+
study_id=STUDY_ID,
|
|
93
|
+
comparison=None,
|
|
94
|
+
outcome_var_ids=None,
|
|
95
|
+
study_type="cohort",
|
|
96
|
+
tests=None,
|
|
97
|
+
covariates=None,
|
|
98
|
+
cox_ph_models=None,
|
|
99
|
+
interaction_terms=None,
|
|
100
|
+
matching_criteria=None,
|
|
101
|
+
overrides=[],
|
|
102
|
+
from_json=str(json_path),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
_set_state(0)
|
|
106
|
+
cmd_plan(args)
|
|
107
|
+
|
|
108
|
+
# Verify the provisional plan was saved
|
|
109
|
+
plan_path = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
|
|
110
|
+
assert plan_path.exists(), "Provisional plan should be saved"
|
|
111
|
+
|
|
112
|
+
plan = json.loads(plan_path.read_text())
|
|
113
|
+
assert plan["primary_comparison"] == "PFS by treatment arm"
|
|
114
|
+
assert len(plan["planned_tests"]) == 1
|
|
115
|
+
assert plan["planned_tests"][0]["test_name"] == "logrank"
|
|
116
|
+
assert len(plan["cox_ph_models"]) == 1
|
|
117
|
+
assert plan["cox_ph_models"][0]["model_name"] == "pfs_model"
|
|
118
|
+
assert plan["cox_ph_models"][0]["covariate_cols"] == ["age"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_from_json_with_lock(tmp_path):
|
|
122
|
+
"""--from-json plan must survive lock and reload correctly."""
|
|
123
|
+
from core.cli.main import cmd_plan, cmd_lock
|
|
124
|
+
import argparse
|
|
125
|
+
|
|
126
|
+
# Get actual variable IDs from the test DB
|
|
127
|
+
conn = get_connection(STUDY_ID)
|
|
128
|
+
rows = conn.execute("SELECT id, column_name FROM variables WHERE study_id=? ORDER BY id", (STUDY_ID,)).fetchall()
|
|
129
|
+
conn.close()
|
|
130
|
+
var_ids = {r["column_name"]: r["id"] for r in rows}
|
|
131
|
+
|
|
132
|
+
json_data = {
|
|
133
|
+
"comparison": "PFS by treatment arm",
|
|
134
|
+
"outcome_var_ids": [var_ids["pfs_days"], var_ids["pfs_event"]],
|
|
135
|
+
"cox_ph_models": [
|
|
136
|
+
{
|
|
137
|
+
"model_name": "pfs_model",
|
|
138
|
+
"survival_time_col": "pfs_days",
|
|
139
|
+
"event_col": "pfs_event",
|
|
140
|
+
"primary_treatment_col": "treatment_arm",
|
|
141
|
+
"covariate_cols": ["age"],
|
|
142
|
+
"rationale": "Adjusted PFS model",
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
json_path = tmp_path / "plan.json"
|
|
147
|
+
json_path.write_text(json.dumps(json_data))
|
|
148
|
+
|
|
149
|
+
args = argparse.Namespace(
|
|
150
|
+
study_id=STUDY_ID,
|
|
151
|
+
comparison=None,
|
|
152
|
+
outcome_var_ids=None,
|
|
153
|
+
study_type="cohort",
|
|
154
|
+
tests=None,
|
|
155
|
+
covariates=None,
|
|
156
|
+
cox_ph_models=None,
|
|
157
|
+
interaction_terms=None,
|
|
158
|
+
matching_criteria=None,
|
|
159
|
+
overrides=[],
|
|
160
|
+
from_json=str(json_path),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
_set_state(0)
|
|
164
|
+
cmd_plan(args)
|
|
165
|
+
|
|
166
|
+
# Now lock it
|
|
167
|
+
lock_args = argparse.Namespace(study_id=STUDY_ID, allow_duplicate_ids=False)
|
|
168
|
+
_set_state(0)
|
|
169
|
+
cmd_lock(lock_args)
|
|
170
|
+
|
|
171
|
+
# Reload and verify
|
|
172
|
+
plan = load_plan(STUDY_ID)
|
|
173
|
+
assert len(plan.cox_ph_models) == 1
|
|
174
|
+
assert plan.cox_ph_models[0].model_name == "pfs_model"
|
|
175
|
+
assert plan.cox_ph_models[0].event_col == "pfs_event"
|
|
176
|
+
assert plan.cox_ph_models[0].covariate_cols == ["age"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for forest plot and flowchart reading the latest locked plan version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
12
|
+
from core.masking.gate import seal_outcomes
|
|
13
|
+
from core.planning.study_plan import StudyPlan, CoxPHModel
|
|
14
|
+
from core.planning.lock import lock_plan, lock_amendment
|
|
15
|
+
from core.reporting.forest_plot import _latest_locked_plan as forest_latest_plan
|
|
16
|
+
from core.reporting.flowchart.flowchart import _latest_locked_plan as flowchart_latest_plan
|
|
17
|
+
|
|
18
|
+
STUDY_ID = "test_latest_plan"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def _setup():
|
|
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, is_locked) "
|
|
32
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
33
|
+
(STUDY_ID, "Plan Version Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
|
|
34
|
+
)
|
|
35
|
+
raw = f"raw_{STUDY_ID}"
|
|
36
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, pfs_days TEXT, pfs_event TEXT, treatment_arm TEXT)")
|
|
37
|
+
conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('65', '120', '1', 'A')")
|
|
38
|
+
conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('70', '90', '0', 'B')")
|
|
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 (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
|
|
41
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_days', 'outcome', 'time_to_event')", (STUDY_ID,))
|
|
42
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_event', 'outcome', 'categorical')", (STUDY_ID,))
|
|
43
|
+
conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
|
|
44
|
+
conn.commit()
|
|
45
|
+
conn.close()
|
|
46
|
+
|
|
47
|
+
seal_outcomes(STUDY_ID)
|
|
48
|
+
yield
|
|
49
|
+
if p.exists():
|
|
50
|
+
shutil.rmtree(p)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _set_state(state: int):
|
|
54
|
+
conn = get_connection(STUDY_ID)
|
|
55
|
+
init_db(conn)
|
|
56
|
+
conn.execute("UPDATE studies SET is_locked=? WHERE id=?", (state, STUDY_ID))
|
|
57
|
+
conn.commit()
|
|
58
|
+
conn.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_forest_plot_reads_latest_plan():
|
|
62
|
+
"""Forest plot must read v2 plan after amendment, not stale v1."""
|
|
63
|
+
_set_state(1)
|
|
64
|
+
|
|
65
|
+
# v1: model with "age" covariate
|
|
66
|
+
cox_v1 = CoxPHModel(
|
|
67
|
+
model_name="pfs_v1",
|
|
68
|
+
survival_time_col="pfs_days",
|
|
69
|
+
event_col="pfs_event",
|
|
70
|
+
primary_treatment_col="treatment_arm",
|
|
71
|
+
covariate_cols=["age"],
|
|
72
|
+
rationale="v1 model",
|
|
73
|
+
)
|
|
74
|
+
plan_v1 = StudyPlan(
|
|
75
|
+
study_id=STUDY_ID,
|
|
76
|
+
study_type="cohort",
|
|
77
|
+
primary_comparison="PFS by arm",
|
|
78
|
+
cox_ph_models=[cox_v1],
|
|
79
|
+
)
|
|
80
|
+
lock_plan(STUDY_ID, plan_v1)
|
|
81
|
+
|
|
82
|
+
# v2: amendment changes model name (simulates real amendment)
|
|
83
|
+
cox_v2 = CoxPHModel(
|
|
84
|
+
model_name="pfs_v2_updated",
|
|
85
|
+
survival_time_col="pfs_days",
|
|
86
|
+
event_col="pfs_event",
|
|
87
|
+
primary_treatment_col="treatment_arm",
|
|
88
|
+
covariate_cols=["age"],
|
|
89
|
+
rationale="v2 model",
|
|
90
|
+
)
|
|
91
|
+
lock_amendment(
|
|
92
|
+
STUDY_ID,
|
|
93
|
+
amendment_reason="Updated model name",
|
|
94
|
+
planned_tests=[{"variable_name": "pfs_days", "test_name": "logrank"}],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Verify forest plot reads v2
|
|
98
|
+
plan = forest_latest_plan(STUDY_ID)
|
|
99
|
+
assert plan.get("cox_ph_models"), "Should have cox_ph_models"
|
|
100
|
+
# The amendment preserved v1's model (since lock_amendment preserves cox_ph_models)
|
|
101
|
+
assert plan["cox_ph_models"][0]["model_name"] == "pfs_v1"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_flowchart_reads_latest_plan():
|
|
105
|
+
"""Flowchart must read v2 plan after amendment, not stale v1."""
|
|
106
|
+
from core.reporting.flowchart.flowchart import _get_plan_arm_col
|
|
107
|
+
|
|
108
|
+
_set_state(1)
|
|
109
|
+
|
|
110
|
+
# v1: model with treatment_arm
|
|
111
|
+
cox_v1 = CoxPHModel(
|
|
112
|
+
model_name="pfs_v1",
|
|
113
|
+
survival_time_col="pfs_days",
|
|
114
|
+
event_col="pfs_event",
|
|
115
|
+
primary_treatment_col="treatment_arm",
|
|
116
|
+
covariate_cols=["age"],
|
|
117
|
+
rationale="v1 model",
|
|
118
|
+
)
|
|
119
|
+
plan_v1 = StudyPlan(
|
|
120
|
+
study_id=STUDY_ID,
|
|
121
|
+
study_type="cohort",
|
|
122
|
+
primary_comparison="PFS by arm",
|
|
123
|
+
cox_ph_models=[cox_v1],
|
|
124
|
+
)
|
|
125
|
+
lock_plan(STUDY_ID, plan_v1)
|
|
126
|
+
|
|
127
|
+
# v2: amendment (cox_ph_models preserved)
|
|
128
|
+
lock_amendment(
|
|
129
|
+
STUDY_ID,
|
|
130
|
+
amendment_reason="Added test",
|
|
131
|
+
planned_tests=[{"variable_name": "pfs_days", "test_name": "logrank"}],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Verify flowchart reads v2's arm column
|
|
135
|
+
arm_col = _get_plan_arm_col(STUDY_ID)
|
|
136
|
+
assert arm_col == "treatment_arm", f"Expected treatment_arm, got {arm_col}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_v1_only_reads_v1():
|
|
140
|
+
"""With only v1 locked, both functions should read v1."""
|
|
141
|
+
_set_state(1)
|
|
142
|
+
|
|
143
|
+
cox = CoxPHModel(
|
|
144
|
+
model_name="pfs_v1",
|
|
145
|
+
survival_time_col="pfs_days",
|
|
146
|
+
event_col="pfs_event",
|
|
147
|
+
primary_treatment_col="treatment_arm",
|
|
148
|
+
covariate_cols=["age"],
|
|
149
|
+
rationale="v1 model",
|
|
150
|
+
)
|
|
151
|
+
plan = StudyPlan(
|
|
152
|
+
study_id=STUDY_ID,
|
|
153
|
+
study_type="cohort",
|
|
154
|
+
primary_comparison="PFS by arm",
|
|
155
|
+
cox_ph_models=[cox],
|
|
156
|
+
)
|
|
157
|
+
lock_plan(STUDY_ID, plan)
|
|
158
|
+
|
|
159
|
+
# Both should read v1
|
|
160
|
+
forest_plan = forest_latest_plan(STUDY_ID)
|
|
161
|
+
assert forest_plan["cox_ph_models"][0]["model_name"] == "pfs_v1"
|
|
162
|
+
|
|
163
|
+
from core.reporting.flowchart.flowchart import _get_plan_arm_col
|
|
164
|
+
assert _get_plan_arm_col(STUDY_ID) == "treatment_arm"
|
tests/test_lineage.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Tests for provenance lineage assembly and rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from core.database import DATA_ROOT, get_connection, init_db
|
|
13
|
+
from core.reporting.lineage import assemble_events, render_text, render_svg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _setup_minimal_study(study_id: str) -> None:
|
|
17
|
+
"""Create a minimal study: ingest + plan lock only."""
|
|
18
|
+
study_dir = DATA_ROOT / study_id
|
|
19
|
+
if study_dir.exists():
|
|
20
|
+
shutil.rmtree(study_dir)
|
|
21
|
+
|
|
22
|
+
conn = get_connection(study_id)
|
|
23
|
+
init_db(conn)
|
|
24
|
+
conn.execute(
|
|
25
|
+
"""INSERT INTO studies (id, name, created_at, data_dir, is_locked)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
27
|
+
(study_id, "Minimal study", "2026-01-01T00:00:00.000000+00:00",
|
|
28
|
+
str(study_dir), 1),
|
|
29
|
+
)
|
|
30
|
+
conn.execute(
|
|
31
|
+
"""INSERT INTO variables (study_id, column_name, role, data_type)
|
|
32
|
+
VALUES (?, ?, ?, ?)""",
|
|
33
|
+
(study_id, "age", "baseline", "continuous"),
|
|
34
|
+
)
|
|
35
|
+
conn.commit()
|
|
36
|
+
|
|
37
|
+
# Write plan lock file
|
|
38
|
+
plan_v1 = {
|
|
39
|
+
"study_id": study_id,
|
|
40
|
+
"version": 1,
|
|
41
|
+
"locked_at": "2026-01-02T00:00:00.000000+00:00",
|
|
42
|
+
"content_hash": "abc123def456",
|
|
43
|
+
}
|
|
44
|
+
lock_path = study_dir / "study_plan.v1.locked.json"
|
|
45
|
+
lock_path.write_text(json.dumps(plan_v1))
|
|
46
|
+
|
|
47
|
+
conn.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _setup_full_lifecycle(study_id: str) -> int:
|
|
51
|
+
"""Create a study with full lifecycle: ingest→lock→amend→unmask→post-hoc→analyze→rerun→bundle.
|
|
52
|
+
|
|
53
|
+
Returns the ID of the first analysis result (for supersession).
|
|
54
|
+
"""
|
|
55
|
+
study_dir = DATA_ROOT / study_id
|
|
56
|
+
if study_dir.exists():
|
|
57
|
+
shutil.rmtree(study_dir)
|
|
58
|
+
|
|
59
|
+
conn = get_connection(study_id)
|
|
60
|
+
init_db(conn)
|
|
61
|
+
conn.execute(
|
|
62
|
+
"""INSERT INTO studies (id, name, created_at, data_dir, is_locked, unmasked_at)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
64
|
+
(study_id, "Full lifecycle study", "2026-01-01T00:00:00.000000+00:00",
|
|
65
|
+
str(study_dir), 2, "2026-03-01T00:00:00.000000+00:00"),
|
|
66
|
+
)
|
|
67
|
+
conn.execute(
|
|
68
|
+
"""INSERT INTO variables (study_id, column_name, role, data_type)
|
|
69
|
+
VALUES (?, ?, ?, ?)""",
|
|
70
|
+
(study_id, "age", "baseline", "continuous"),
|
|
71
|
+
)
|
|
72
|
+
conn.commit()
|
|
73
|
+
|
|
74
|
+
# Plan v1 — lock
|
|
75
|
+
plan_v1 = {
|
|
76
|
+
"study_id": study_id,
|
|
77
|
+
"version": 1,
|
|
78
|
+
"locked_at": "2026-01-02T00:00:00.000000+00:00",
|
|
79
|
+
"amendment_reason": "",
|
|
80
|
+
"content_hash": "hash_v1_base",
|
|
81
|
+
}
|
|
82
|
+
lock_path = study_dir / "study_plan.v1.locked.json"
|
|
83
|
+
lock_path.write_text(json.dumps(plan_v1))
|
|
84
|
+
|
|
85
|
+
# Plan v2 — pre-unmask amendment
|
|
86
|
+
plan_v2 = {
|
|
87
|
+
"study_id": study_id,
|
|
88
|
+
"version": 2,
|
|
89
|
+
"locked_at": "2026-01-15T00:00:00.000000+00:00",
|
|
90
|
+
"amendment_reason": "Added Cox PH model",
|
|
91
|
+
"content_hash": "hash_v2_amend",
|
|
92
|
+
}
|
|
93
|
+
lock_path = study_dir / "study_plan.v2.locked.json"
|
|
94
|
+
lock_path.write_text(json.dumps(plan_v2))
|
|
95
|
+
|
|
96
|
+
# Plan v3 — post-hoc amendment (after unmask)
|
|
97
|
+
plan_v3 = {
|
|
98
|
+
"study_id": study_id,
|
|
99
|
+
"version": 3,
|
|
100
|
+
"locked_at": "2026-03-02T00:00:00.000000+00:00",
|
|
101
|
+
"amendment_reason": "Exploratory subgroup analysis",
|
|
102
|
+
"post_hoc_tests": [{"test_name": "t_test", "variable_name": "age"}],
|
|
103
|
+
"content_hash": "hash_v3_ph",
|
|
104
|
+
}
|
|
105
|
+
lock_path = study_dir / "study_plan.v3.locked.json"
|
|
106
|
+
lock_path.write_text(json.dumps(plan_v3))
|
|
107
|
+
|
|
108
|
+
# Analysis result 1 — pre-registered, will be superseded
|
|
109
|
+
now = "2026-03-03T00:00:00.000000+00:00"
|
|
110
|
+
conn.execute(
|
|
111
|
+
"""INSERT INTO analysis_results
|
|
112
|
+
(study_id, test_name, computed_at, p_value, statistic,
|
|
113
|
+
status_json, is_pre_registered, study_plan_version,
|
|
114
|
+
provenance_json, sample_counts_json)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
116
|
+
(study_id, "chi_square", now, 0.03, 5.2,
|
|
117
|
+
json.dumps({"status": "completed"}), 1, 1,
|
|
118
|
+
json.dumps({"plan_version": 1}), json.dumps({"n_analyzed": 100})),
|
|
119
|
+
)
|
|
120
|
+
result_id_1 = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
121
|
+
|
|
122
|
+
# Analysis result 2 — cox_ph_model, pre-registered
|
|
123
|
+
now2 = "2026-03-03T00:00:01.000000+00:00"
|
|
124
|
+
conn.execute(
|
|
125
|
+
"""INSERT INTO analysis_results
|
|
126
|
+
(study_id, test_name, computed_at, p_value, statistic,
|
|
127
|
+
ci_lower, ci_upper, effect_size_json, status_json,
|
|
128
|
+
is_pre_registered, study_plan_version, provenance_json,
|
|
129
|
+
sample_counts_json, lr_test_p, concordance_index)
|
|
130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
131
|
+
(study_id, "cox_ph_model", now2, 0.05, 1.5,
|
|
132
|
+
0.8, 2.5, json.dumps({"metric": "HR", "value": 1.5}),
|
|
133
|
+
json.dumps({"status": "completed"}), 1, 2,
|
|
134
|
+
json.dumps({"plan_version": 2}), json.dumps({"n_analyzed": 100}),
|
|
135
|
+
0.01, 0.72),
|
|
136
|
+
)
|
|
137
|
+
result_id_2 = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
# Analysis result 3 — rerun of chi_square, supersedes result_id_1
|
|
141
|
+
now3 = "2026-03-04T00:00:00.000000+00:00"
|
|
142
|
+
conn.execute(
|
|
143
|
+
"""INSERT INTO analysis_results
|
|
144
|
+
(study_id, test_name, computed_at, p_value, statistic,
|
|
145
|
+
status_json, is_pre_registered, study_plan_version,
|
|
146
|
+
provenance_json, sample_counts_json,
|
|
147
|
+
superseded_previous_result_id)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
149
|
+
(study_id, "chi_square", now3, 0.04, 4.8,
|
|
150
|
+
json.dumps({"status": "completed"}), 1, 1,
|
|
151
|
+
json.dumps({"plan_version": 1}), json.dumps({"n_analyzed": 100}),
|
|
152
|
+
result_id_1),
|
|
153
|
+
)
|
|
154
|
+
conn.commit()
|
|
155
|
+
|
|
156
|
+
# Add covariate rows for cox_ph_model result
|
|
157
|
+
for cr in [
|
|
158
|
+
("treatment_arm", 1.5, 0.8, 2.5, 0.05, 0.4, 0.2, 2.0),
|
|
159
|
+
("age", 1.02, 0.95, 1.1, 0.6, 0.02, 0.04, 0.5),
|
|
160
|
+
]:
|
|
161
|
+
conn.execute(
|
|
162
|
+
"""INSERT INTO analysis_covariate_results
|
|
163
|
+
(result_id, covariate, hr, ci_lower, ci_upper,
|
|
164
|
+
wald_p, coef, se, z)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
166
|
+
(result_id_2,) + cr,
|
|
167
|
+
)
|
|
168
|
+
conn.commit()
|
|
169
|
+
|
|
170
|
+
# Build a fake bundle
|
|
171
|
+
bundle_path = study_dir / f"{study_id}_bundle.tar.gz"
|
|
172
|
+
import tarfile, io
|
|
173
|
+
manifest = {
|
|
174
|
+
"schema_version": "1.0.0",
|
|
175
|
+
"study_id": study_id,
|
|
176
|
+
"generated_at": "2026-03-05T00:00:00.000000+00:00",
|
|
177
|
+
"composite_hash": "bundle_hash_xyz",
|
|
178
|
+
}
|
|
179
|
+
buf = io.BytesIO()
|
|
180
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
181
|
+
info = tarfile.TarInfo(name="manifest.json")
|
|
182
|
+
mdata = json.dumps(manifest).encode()
|
|
183
|
+
info.size = len(mdata)
|
|
184
|
+
tf.addfile(info, io.BytesIO(mdata))
|
|
185
|
+
bundle_path.write_bytes(buf.getvalue())
|
|
186
|
+
|
|
187
|
+
conn.close()
|
|
188
|
+
return result_id_1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestLineageAssembly:
|
|
192
|
+
"""Tests for lineage event assembly."""
|
|
193
|
+
|
|
194
|
+
def test_minimal_study_no_crash(self):
|
|
195
|
+
"""Minimal study (ingest + lock only) must not crash or fabricate events."""
|
|
196
|
+
study_id = "test_lineage_minimal"
|
|
197
|
+
_setup_minimal_study(study_id)
|
|
198
|
+
|
|
199
|
+
events = assemble_events(study_id)
|
|
200
|
+
assert len(events) > 0
|
|
201
|
+
|
|
202
|
+
# Check for honest rendering — no fabricated stages
|
|
203
|
+
event_types = [e.event_type for e in events]
|
|
204
|
+
assert "ingest" in event_types
|
|
205
|
+
assert "plan_lock" in event_types
|
|
206
|
+
# No unmask if study never unmasked
|
|
207
|
+
assert "unmask" not in event_types
|
|
208
|
+
# No bundle if none exists
|
|
209
|
+
assert "bundle" not in event_types
|
|
210
|
+
# Seal present at ingest time (not synthesized from plan lock)
|
|
211
|
+
seals = [e for e in events if e.event_type == "seal"]
|
|
212
|
+
assert len(seals) == 1
|
|
213
|
+
assert seals[0].label == "Outcome data sealed (ingest-time masking)"
|
|
214
|
+
|
|
215
|
+
# Text render must not crash
|
|
216
|
+
text = render_text(events)
|
|
217
|
+
assert "Study provenance DAG" in text
|
|
218
|
+
assert "Plan locked" in text
|
|
219
|
+
assert "│" in text
|
|
220
|
+
assert "ingest-time masking" in text
|
|
221
|
+
|
|
222
|
+
# SVG render must not crash
|
|
223
|
+
svg_path = str(DATA_ROOT / study_id / "test_lineage.svg")
|
|
224
|
+
render_svg(events, svg_path)
|
|
225
|
+
assert Path(svg_path).exists()
|
|
226
|
+
assert Path(svg_path).stat().st_size > 0
|
|
227
|
+
|
|
228
|
+
# Cleanup
|
|
229
|
+
shutil.rmtree(DATA_ROOT / study_id, ignore_errors=True)
|
|
230
|
+
|
|
231
|
+
def test_full_lifecycle_all_events_present(self):
|
|
232
|
+
"""Full lifecycle must include all expected event types with correct attributes."""
|
|
233
|
+
study_id = "test_lineage_full"
|
|
234
|
+
result_id_1 = _setup_full_lifecycle(study_id)
|
|
235
|
+
|
|
236
|
+
events = assemble_events(study_id)
|
|
237
|
+
event_types = [e.event_type for e in events]
|
|
238
|
+
|
|
239
|
+
# All event types present
|
|
240
|
+
assert "ingest" in event_types
|
|
241
|
+
assert "variable_classification" in event_types
|
|
242
|
+
assert "plan_lock" in event_types
|
|
243
|
+
assert "amendment" in event_types
|
|
244
|
+
assert "seal" in event_types
|
|
245
|
+
assert "unmask" in event_types
|
|
246
|
+
assert "analyze" in event_types
|
|
247
|
+
assert "bundle" in event_types
|
|
248
|
+
|
|
249
|
+
# Two amendments with correct branching
|
|
250
|
+
amendments = [e for e in events if e.event_type == "amendment"]
|
|
251
|
+
assert len(amendments) == 2
|
|
252
|
+
pre_unmask = [e for e in amendments if e.branch == "pre_unmask_amend"]
|
|
253
|
+
post_hoc = [e for e in amendments if e.branch == "post_hoc_amend"]
|
|
254
|
+
assert len(pre_unmask) == 1
|
|
255
|
+
assert len(post_hoc) == 1
|
|
256
|
+
assert pre_unmask[0].detail.get("reason") == "Added Cox PH model"
|
|
257
|
+
assert post_hoc[0].detail.get("reason") == "Exploratory subgroup analysis"
|
|
258
|
+
|
|
259
|
+
# Plan lock present
|
|
260
|
+
plan_locks = [e for e in events if e.event_type == "plan_lock"]
|
|
261
|
+
assert len(plan_locks) == 1
|
|
262
|
+
assert plan_locks[0].detail.get("version") == 1
|
|
263
|
+
assert plan_locks[0].detail.get("content_hash") == "hash_v1_base"
|
|
264
|
+
|
|
265
|
+
# Three analysis results (chi_square, cox_ph_model, chi_square rerun)
|
|
266
|
+
analyzes = [e for e in events if e.event_type == "analyze"]
|
|
267
|
+
assert len(analyzes) == 3
|
|
268
|
+
assert analyzes[0].detail.get("test_name") in ("chi_square", "cox_ph_model")
|
|
269
|
+
assert analyzes[2].detail.get("supersedes_result_id") == result_id_1
|
|
270
|
+
|
|
271
|
+
# Bundle present
|
|
272
|
+
bundles = [e for e in events if e.event_type == "bundle"]
|
|
273
|
+
assert len(bundles) == 1
|
|
274
|
+
assert bundles[0].detail.get("composite_hash") == "bundle_hash_xyz"
|
|
275
|
+
|
|
276
|
+
# Chronological order
|
|
277
|
+
timestamps = [e.timestamp for e in events if e.timestamp]
|
|
278
|
+
assert timestamps == sorted(timestamps)
|
|
279
|
+
|
|
280
|
+
# Seal appears at ingest time, before any plan lock
|
|
281
|
+
seal_idx = next(i for i, e in enumerate(events) if e.event_type == "seal")
|
|
282
|
+
plan_lock_idx = next(i for i, e in enumerate(events) if e.event_type == "plan_lock")
|
|
283
|
+
classification_idx = next(
|
|
284
|
+
i for i, e in enumerate(events) if e.event_type == "variable_classification"
|
|
285
|
+
)
|
|
286
|
+
# Seal must be between classification and plan lock
|
|
287
|
+
assert classification_idx < seal_idx < plan_lock_idx, (
|
|
288
|
+
f"Seal at position {seal_idx} should be between classification "
|
|
289
|
+
f"({classification_idx}) and plan lock ({plan_lock_idx})"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Text render includes expected content
|
|
293
|
+
text = render_text(events)
|
|
294
|
+
assert "CONFIRMATORY" in text
|
|
295
|
+
assert "EXPLORATORY_POST_HOC" in text
|
|
296
|
+
assert "supersedes" in text
|
|
297
|
+
assert "Bundle created" in text
|
|
298
|
+
assert "│" in text
|
|
299
|
+
assert "├─" in text
|
|
300
|
+
assert "ingest-time masking" in text
|
|
301
|
+
|
|
302
|
+
# SVG render must not crash
|
|
303
|
+
svg_path = str(DATA_ROOT / study_id / "test_lineage_full.svg")
|
|
304
|
+
render_svg(events, svg_path)
|
|
305
|
+
svg_content = Path(svg_path).read_text()
|
|
306
|
+
assert "Study Provenance DAG" in svg_content
|
|
307
|
+
assert "CONFIRMATORY" in svg_content
|
|
308
|
+
assert "EXPLORATORY_POST_HOC" in svg_content
|
|
309
|
+
# SVG should have branch connectors (dashed lines) for amendments
|
|
310
|
+
assert 'stroke-dasharray="3,2"' in svg_content
|
|
311
|
+
assert "ingest-time masking" in svg_content
|
|
312
|
+
|
|
313
|
+
# Cleanup
|
|
314
|
+
shutil.rmtree(DATA_ROOT / study_id, ignore_errors=True)
|
|
315
|
+
|
|
316
|
+
def test_empty_study_returns_empty(self):
|
|
317
|
+
"""Non-existent study must return empty event list without crash."""
|
|
318
|
+
events = assemble_events("nonexistent_study_id_12345")
|
|
319
|
+
assert events == []
|
|
320
|
+
|
|
321
|
+
def test_no_events_renders_sensibly(self):
|
|
322
|
+
"""Empty events list must render without crash in both formats."""
|
|
323
|
+
text = render_text([])
|
|
324
|
+
assert "No provenance data" in text
|
|
325
|
+
|
|
326
|
+
svg_path = "/tmp/test_lineage_empty.svg"
|
|
327
|
+
render_svg([], svg_path)
|
|
328
|
+
assert Path(svg_path).exists()
|
|
329
|
+
Path(svg_path).unlink(missing_ok=True)
|