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