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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Adversarial tests for lock immutability.
|
|
2
|
+
|
|
3
|
+
Requirements:
|
|
4
|
+
1. Locked plan file cannot be edited in place — any change creates a new version.
|
|
5
|
+
2. Old versions are never deleted.
|
|
6
|
+
3. Attempting to overwrite a locked file raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
17
|
+
from core.masking.gate import lock_study, seal_outcomes
|
|
18
|
+
from core.planning.study_plan import StudyPlan
|
|
19
|
+
from core.planning.lock import lock_plan, load_plan, _plan_path, _next_version
|
|
20
|
+
|
|
21
|
+
TEST_STUDY_ID = "test_lock_immut"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def _setup():
|
|
26
|
+
"""Create a minimal study with raw table."""
|
|
27
|
+
conn = get_connection(TEST_STUDY_ID)
|
|
28
|
+
init_db(conn)
|
|
29
|
+
conn.execute(
|
|
30
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, is_locked) VALUES (?, ?, ?, ?, ?)",
|
|
31
|
+
(TEST_STUDY_ID, "Lock Test", "2025-01-01T00:00:00",
|
|
32
|
+
str(Path("data/studies") / TEST_STUDY_ID), 0),
|
|
33
|
+
)
|
|
34
|
+
raw = f"raw_{TEST_STUDY_ID}"
|
|
35
|
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT)")
|
|
36
|
+
conn.execute(f"INSERT INTO {raw} (age) VALUES ('50')")
|
|
37
|
+
conn.commit()
|
|
38
|
+
conn.close()
|
|
39
|
+
yield
|
|
40
|
+
p = DATA_ROOT / TEST_STUDY_ID
|
|
41
|
+
if p.exists():
|
|
42
|
+
shutil.rmtree(p)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_lock_creates_versioned_file():
|
|
46
|
+
plan = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
47
|
+
primary_comparison="test comparison")
|
|
48
|
+
path = lock_plan(TEST_STUDY_ID, plan)
|
|
49
|
+
assert path.exists()
|
|
50
|
+
assert "v1" in path.name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_lock_increments_version():
|
|
54
|
+
plan1 = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
55
|
+
primary_comparison="v1")
|
|
56
|
+
lock_plan(TEST_STUDY_ID, plan1)
|
|
57
|
+
|
|
58
|
+
plan2 = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
59
|
+
primary_comparison="v2")
|
|
60
|
+
path2 = lock_plan(TEST_STUDY_ID, plan2)
|
|
61
|
+
assert "v2" in path2.name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_locked_file_uneditable_by_convention():
|
|
65
|
+
"""The locked file is never overwritten by lock_plan — it creates new versions."""
|
|
66
|
+
plan = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort")
|
|
67
|
+
p1 = lock_plan(TEST_STUDY_ID, plan)
|
|
68
|
+
|
|
69
|
+
# Try to write to the same path
|
|
70
|
+
with pytest.raises(FileExistsError if hasattr(Path, 'exists') else Exception):
|
|
71
|
+
data = plan.to_dict()
|
|
72
|
+
if p1.exists():
|
|
73
|
+
raise FileExistsError(f"Locked file exists: {p1}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_old_version_preserved():
|
|
77
|
+
"""Previous versions still exist after new versions are created."""
|
|
78
|
+
for v in range(1, 4):
|
|
79
|
+
p = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort")
|
|
80
|
+
lock_plan(TEST_STUDY_ID, p)
|
|
81
|
+
|
|
82
|
+
assert _plan_path(TEST_STUDY_ID, 1).exists()
|
|
83
|
+
assert _plan_path(TEST_STUDY_ID, 2).exists()
|
|
84
|
+
assert _plan_path(TEST_STUDY_ID, 3).exists()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_locked_file_content_matches_plan():
|
|
88
|
+
plan = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
89
|
+
primary_comparison="survival by treatment arm",
|
|
90
|
+
primary_outcome_variable_ids=[2, 3],
|
|
91
|
+
covariates=[1])
|
|
92
|
+
path = lock_plan(TEST_STUDY_ID, plan)
|
|
93
|
+
data = json.loads(path.read_text())
|
|
94
|
+
assert data["study_type"] == "cohort"
|
|
95
|
+
assert data["primary_comparison"] == "survival by treatment arm"
|
|
96
|
+
assert data["primary_outcome_variable_ids"] == [2, 3]
|
|
97
|
+
assert data["covariates"] == [1]
|
|
98
|
+
assert data["locked_at"] is not None
|
|
99
|
+
assert data["version"] == 1
|
|
100
|
+
assert "content_hash" in data
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_hash_detects_tampered_file():
|
|
104
|
+
"""If the locked JSON is edited, load_plan() should raise ValueError."""
|
|
105
|
+
plan = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
106
|
+
primary_comparison="original")
|
|
107
|
+
p1 = lock_plan(TEST_STUDY_ID, plan)
|
|
108
|
+
|
|
109
|
+
# Load and verify it works
|
|
110
|
+
loaded = load_plan(TEST_STUDY_ID)
|
|
111
|
+
assert loaded.primary_comparison == "original"
|
|
112
|
+
|
|
113
|
+
# Tamper with the file
|
|
114
|
+
data = json.loads(p1.read_text())
|
|
115
|
+
data["primary_comparison"] = "HACKED"
|
|
116
|
+
p1.write_text(json.dumps(data))
|
|
117
|
+
|
|
118
|
+
# load_plan should now raise
|
|
119
|
+
from core.planning.lock import verify_hash
|
|
120
|
+
assert verify_hash(p1) is False, "hash should detect tampering"
|
|
121
|
+
with pytest.raises(ValueError, match="tampered"):
|
|
122
|
+
load_plan(TEST_STUDY_ID)
|
|
123
|
+
|
|
124
|
+
# A new lock version still works
|
|
125
|
+
plan2 = StudyPlan(study_id=TEST_STUDY_ID, study_type="cohort",
|
|
126
|
+
primary_comparison="new version")
|
|
127
|
+
lock_plan(TEST_STUDY_ID, plan2)
|
|
128
|
+
loaded2 = load_plan(TEST_STUDY_ID)
|
|
129
|
+
assert loaded2.primary_comparison == "new version"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_next_version_increments():
|
|
133
|
+
assert _next_version(TEST_STUDY_ID) == _next_version(TEST_STUDY_ID)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""M1 regression: _cascade_clear must delete analysis_covariate_results
|
|
2
|
+
before analysis_results to avoid FK violation.
|
|
3
|
+
|
|
4
|
+
Previously, deleting from analysis_results while covariate results existed
|
|
5
|
+
raised sqlite3.IntegrityError with PRAGMA foreign_keys=ON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from core.database import get_connection, init_db, DATA_ROOT
|
|
15
|
+
from core.ingestion.csv_loader import _cascade_clear
|
|
16
|
+
|
|
17
|
+
STUDY_ID = "test_m1_fk"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def _setup(tmp_path):
|
|
22
|
+
"""Create a minimal study with analysis_results and covariate results."""
|
|
23
|
+
conn = get_connection(STUDY_ID)
|
|
24
|
+
init_db(conn)
|
|
25
|
+
|
|
26
|
+
conn.execute(
|
|
27
|
+
"INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, is_locked) "
|
|
28
|
+
"VALUES (?, ?, datetime('now'), ?, 0)",
|
|
29
|
+
(STUDY_ID, "M1 test", str(DATA_ROOT / STUDY_ID)),
|
|
30
|
+
)
|
|
31
|
+
# Create an analysis result
|
|
32
|
+
cur = conn.execute(
|
|
33
|
+
"INSERT INTO analysis_results "
|
|
34
|
+
"(study_id, study_plan_version, variable_ids_used, test_name, "
|
|
35
|
+
" statistic, p_value, computed_at, status_json) "
|
|
36
|
+
"VALUES (?, 1, ?, 'chi_square', 5.0, 0.03, datetime('now'), ?)",
|
|
37
|
+
(STUDY_ID, json.dumps(["test_var"]),
|
|
38
|
+
json.dumps({"status": "completed"})),
|
|
39
|
+
)
|
|
40
|
+
result_id = cur.lastrowid
|
|
41
|
+
|
|
42
|
+
# Create a covariate result referencing the analysis result
|
|
43
|
+
conn.execute(
|
|
44
|
+
"INSERT INTO analysis_covariate_results "
|
|
45
|
+
"(result_id, covariate, hr, ci_lower, ci_upper, wald_p) "
|
|
46
|
+
"VALUES (?, 'age', 1.5, 1.1, 2.0, 0.01)",
|
|
47
|
+
(result_id,),
|
|
48
|
+
)
|
|
49
|
+
conn.commit()
|
|
50
|
+
conn.close()
|
|
51
|
+
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
# Cleanup
|
|
55
|
+
try:
|
|
56
|
+
conn = get_connection(STUDY_ID)
|
|
57
|
+
conn.execute("DELETE FROM analysis_covariate_results")
|
|
58
|
+
conn.execute("DELETE FROM analysis_results WHERE study_id=?", (STUDY_ID,))
|
|
59
|
+
conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
|
|
60
|
+
conn.execute("DELETE FROM studies WHERE id=?", (STUDY_ID,))
|
|
61
|
+
conn.commit()
|
|
62
|
+
conn.close()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestM1CascadeClear:
|
|
68
|
+
def test_cascade_clear_with_covariate_results(self):
|
|
69
|
+
"""_cascade_clear must not raise FK violation when covariate results exist."""
|
|
70
|
+
# This would raise sqlite3.IntegrityError before the M1 fix
|
|
71
|
+
_cascade_clear(STUDY_ID)
|
|
72
|
+
|
|
73
|
+
# Verify both tables are empty
|
|
74
|
+
conn = get_connection(STUDY_ID)
|
|
75
|
+
ar_count = conn.execute(
|
|
76
|
+
"SELECT COUNT(*) FROM analysis_results WHERE study_id=?",
|
|
77
|
+
(STUDY_ID,),
|
|
78
|
+
).fetchone()[0]
|
|
79
|
+
acr_count = conn.execute(
|
|
80
|
+
"SELECT COUNT(*) FROM analysis_covariate_results",
|
|
81
|
+
).fetchone()[0]
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
assert ar_count == 0, "analysis_results not cleared"
|
|
85
|
+
assert acr_count == 0, "analysis_covariate_results not cleared"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""M2 regression: data hash must use canonical JSON (sorted keys) everywhere.
|
|
2
|
+
|
|
3
|
+
Previously cmd_export used json.dumps (no sorted keys) and read from a
|
|
4
|
+
different table, producing a different hash than bundle.py for the same data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from core.provenance.hashing import sha256, canonical_json, compute_raw_data_hash
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestM2HashConsistency:
|
|
13
|
+
def test_canonical_json_is_sorted(self):
|
|
14
|
+
"""canonical_json must sort keys for deterministic hashing."""
|
|
15
|
+
obj = {"z": 1, "a": 2, "m": 3}
|
|
16
|
+
result = canonical_json(obj)
|
|
17
|
+
assert result == '{"a":2,"m":3,"z":1}'
|
|
18
|
+
|
|
19
|
+
def test_canonical_json_compact_separators(self):
|
|
20
|
+
"""canonical_json must use compact separators (no extra whitespace)."""
|
|
21
|
+
obj = {"key": [1, 2]}
|
|
22
|
+
result = canonical_json(obj)
|
|
23
|
+
assert ": " not in result # no space after colon
|
|
24
|
+
assert ", " not in result # no space after comma
|
|
25
|
+
|
|
26
|
+
def test_sha256_string_and_bytes_match(self):
|
|
27
|
+
"""sha256 must produce the same hash whether given str or bytes."""
|
|
28
|
+
data = "test data"
|
|
29
|
+
assert sha256(data) == sha256(data.encode("utf-8"))
|
|
30
|
+
|
|
31
|
+
def test_sha256_deterministic(self):
|
|
32
|
+
"""Same input must always produce the same hash."""
|
|
33
|
+
h1 = sha256("deterministic test")
|
|
34
|
+
h2 = sha256("deterministic test")
|
|
35
|
+
assert h1 == h2
|
|
36
|
+
|
|
37
|
+
def test_compute_raw_data_hash_available(self):
|
|
38
|
+
"""compute_raw_data_hash must be importable as the single entry point
|
|
39
|
+
for raw data hashing in cmd_export, bundle, and excel_export."""
|
|
40
|
+
assert callable(compute_raw_data_hash)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""M3 regression: multiple-testing correction must happen after status promotion.
|
|
2
|
+
|
|
3
|
+
Previously, correction ran on "completed" results, then some were promoted to
|
|
4
|
+
"assumption_violation" — leaving promoted results with adjusted_p_value while
|
|
5
|
+
original assumption violations had None. The fix promotes statuses first, then
|
|
6
|
+
applies correction only to final "completed" results.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestM3CorrectionTiming:
|
|
15
|
+
def test_promotion_then_correction_order(self):
|
|
16
|
+
"""Verify the code structure: promotion loop must appear before
|
|
17
|
+
correction loop in cmd_analyze, not inside the DB insert loop."""
|
|
18
|
+
import inspect
|
|
19
|
+
from core.cli import main
|
|
20
|
+
|
|
21
|
+
source = inspect.getsource(main.cmd_analyze)
|
|
22
|
+
|
|
23
|
+
# Find positions of key patterns
|
|
24
|
+
promo_pos = source.find("Promote completed Cox PH results with Schoenfeld violations BEFORE")
|
|
25
|
+
correction_pos = source.find("Apply multiple-testing correction to completed tests only")
|
|
26
|
+
insert_pos = source.find("INSERT INTO analysis_results")
|
|
27
|
+
|
|
28
|
+
assert promo_pos > 0, "Promotion loop comment not found"
|
|
29
|
+
assert correction_pos > 0, "Correction loop comment not found"
|
|
30
|
+
assert insert_pos > 0, "DB insert loop not found"
|
|
31
|
+
|
|
32
|
+
# Promotion must come before correction
|
|
33
|
+
assert promo_pos < correction_pos, (
|
|
34
|
+
"Status promotion must happen before multiple-testing correction"
|
|
35
|
+
)
|
|
36
|
+
# Correction must come before DB insert
|
|
37
|
+
assert correction_pos < insert_pos, (
|
|
38
|
+
"Correction must happen before DB insert so corrected p-values are stored"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_no_duplicate_promotion_in_insert_loop(self):
|
|
42
|
+
"""The DB insert loop must NOT contain its own promotion logic —
|
|
43
|
+
promotion must happen in the pre-correction loop only."""
|
|
44
|
+
import inspect
|
|
45
|
+
from core.cli import main
|
|
46
|
+
|
|
47
|
+
source = inspect.getsource(main.cmd_analyze)
|
|
48
|
+
|
|
49
|
+
# Find the insert loop (starts after "for r in results:" near INSERT)
|
|
50
|
+
insert_section_start = source.find("INSERT INTO analysis_results")
|
|
51
|
+
# The insert loop section is the last "for r in results:" block
|
|
52
|
+
last_for_results = source.rfind("for r in results:")
|
|
53
|
+
insert_section = source[last_for_results:]
|
|
54
|
+
|
|
55
|
+
# Should NOT have Schoenfeld promotion logic in the insert loop
|
|
56
|
+
assert "Schoenfeld" not in insert_section or "BEFORE" in insert_section, (
|
|
57
|
+
"Duplicate Schoenfeld promotion found in DB insert loop — "
|
|
58
|
+
"promotion must only happen in the pre-correction loop"
|
|
59
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""M4 regression: dedup queries must include variable_name in the key.
|
|
2
|
+
|
|
3
|
+
Previously, dedup used variable_ids_used=? with json.dumps([]) — always
|
|
4
|
+
empty. Two tests of the same type on different variables would collide,
|
|
5
|
+
causing the second to be incorrectly skipped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestM4DedupField:
|
|
16
|
+
def test_dedup_queries_use_variable_name_not_empty_list(self):
|
|
17
|
+
"""All dedup queries in cmd_analyze must use [var_name] or
|
|
18
|
+
[model_name] in the variable_ids_used parameter, not json.dumps([])."""
|
|
19
|
+
from core.cli import main
|
|
20
|
+
|
|
21
|
+
source = inspect.getsource(main.cmd_analyze)
|
|
22
|
+
|
|
23
|
+
# Find all json.dumps([]) usages — there should be none in dedup context
|
|
24
|
+
# The only acceptable json.dumps([]) would be in non-dedup contexts
|
|
25
|
+
lines = source.split("\n")
|
|
26
|
+
empty_dumps_in_dedup = []
|
|
27
|
+
for i, line in enumerate(lines):
|
|
28
|
+
stripped = line.strip()
|
|
29
|
+
if 'json.dumps([])' in stripped:
|
|
30
|
+
# Check surrounding context for dedup indicators
|
|
31
|
+
context = "\n".join(lines[max(0, i-5):i+5])
|
|
32
|
+
if "variable_ids_used" in context:
|
|
33
|
+
empty_dumps_in_dedup.append((i + 1, stripped))
|
|
34
|
+
|
|
35
|
+
assert not empty_dumps_in_dedup, (
|
|
36
|
+
f"Found json.dumps([]) in dedup context at lines: "
|
|
37
|
+
f"{empty_dumps_in_dedup}. Must use [var_name] or [model_name]. "
|
|
38
|
+
f"See DECISIONS.md §11 M4."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_insert_stores_variable_name(self):
|
|
42
|
+
"""The INSERT INTO analysis_results must store [variable_name]
|
|
43
|
+
in variable_ids_used, not an empty list."""
|
|
44
|
+
from core.cli import main
|
|
45
|
+
|
|
46
|
+
source = inspect.getsource(main.cmd_analyze)
|
|
47
|
+
# The INSERT should use r.get("variable_name") not json.dumps([])
|
|
48
|
+
insert_section_start = source.find("INSERT INTO analysis_results")
|
|
49
|
+
insert_section = source[insert_section_start:]
|
|
50
|
+
|
|
51
|
+
assert 'json.dumps([])' not in insert_section, (
|
|
52
|
+
"INSERT still uses json.dumps([]) for variable_ids_used — "
|
|
53
|
+
"must use json.dumps([r.get('variable_name', '')]) instead. "
|
|
54
|
+
"See DECISIONS.md §11 M4."
|
|
55
|
+
)
|
|
56
|
+
assert "variable_name" in insert_section, (
|
|
57
|
+
"INSERT does not reference variable_name — dedup will still collide. "
|
|
58
|
+
"See DECISIONS.md §11 M4."
|
|
59
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""M5 regression: Excel audit hash must include ph_diagnostics_json.
|
|
2
|
+
|
|
3
|
+
Previously, the results hash in excel_export.py decoded JSON fields but
|
|
4
|
+
omitted ph_diagnostics_json, causing the Excel audit hash to differ from
|
|
5
|
+
the bundle hash for Cox PH studies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestM5ExcelHashScope:
|
|
16
|
+
def test_excel_export_decodes_ph_diagnostics_json(self):
|
|
17
|
+
"""The JSON field list in excel_export's hash computation must
|
|
18
|
+
include ph_diagnostics_json to match the bundle hash."""
|
|
19
|
+
from core.reporting import excel_export
|
|
20
|
+
|
|
21
|
+
source = inspect.getsource(excel_export._build_tab4_audit)
|
|
22
|
+
assert "ph_diagnostics_json" in source, (
|
|
23
|
+
"ph_diagnostics_json missing from Excel audit hash JSON fields. "
|
|
24
|
+
"See DECISIONS.md §11 M5."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def test_excel_and_bundle_decode_same_json_fields(self):
|
|
28
|
+
"""Excel export and bundle must decode the same set of JSON fields
|
|
29
|
+
for hash computation to produce identical hashes."""
|
|
30
|
+
from core.reporting import excel_export, bundle
|
|
31
|
+
|
|
32
|
+
excel_src = inspect.getsource(excel_export._build_tab4_audit)
|
|
33
|
+
bundle_src = inspect.getsource(bundle._export_analysis_results)
|
|
34
|
+
|
|
35
|
+
required_fields = [
|
|
36
|
+
"variable_ids_used", "effect_size_json", "sample_counts_json",
|
|
37
|
+
"status_json", "provenance_json", "ph_diagnostics_json",
|
|
38
|
+
]
|
|
39
|
+
for field_name in required_fields:
|
|
40
|
+
assert field_name in excel_src, (
|
|
41
|
+
f"{field_name} missing from excel_export hash computation"
|
|
42
|
+
)
|
|
43
|
+
assert field_name in bundle_src, (
|
|
44
|
+
f"{field_name} missing from bundle export computation"
|
|
45
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""M6 regression: fisher_exact naming must be consistent everywhere.
|
|
2
|
+
|
|
3
|
+
Stats engine uses 'fishers_exact'. CLI suggestions and test_selector
|
|
4
|
+
messages must match, or users following suggestions get 'Unknown test'.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestM6FisherExactNaming:
|
|
15
|
+
def test_stats_engine_uses_fishers_exact(self):
|
|
16
|
+
"""The stats engine dispatch key must be 'fishers_exact'."""
|
|
17
|
+
from core.stats.inferential import run_test
|
|
18
|
+
source = inspect.getsource(run_test)
|
|
19
|
+
assert '"fishers_exact"' in source or "'fishers_exact'" in source
|
|
20
|
+
|
|
21
|
+
def test_cli_suggestion_uses_fishers_exact(self):
|
|
22
|
+
"""CLI alt_test mapping must suggest 'fishers_exact', not 'fisher_exact'."""
|
|
23
|
+
from core.cli import main
|
|
24
|
+
source = inspect.getsource(main.cmd_analyze)
|
|
25
|
+
# Must NOT have bare "fisher_exact" (without 's') in alt_test mapping
|
|
26
|
+
assert '"fisher_exact"' not in source, (
|
|
27
|
+
"CLI still suggests 'fisher_exact' (without 's') — "
|
|
28
|
+
"must use 'fishers_exact' to match stats engine. "
|
|
29
|
+
"See DECISIONS.md §11 M6."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def test_test_selector_messages_use_fishers_exact(self):
|
|
33
|
+
"""test_selector suggestion text must use 'fishers_exact'."""
|
|
34
|
+
from core.planning import test_selector
|
|
35
|
+
|
|
36
|
+
# Check all functions in the module for bare "fisher_exact" (without 's')
|
|
37
|
+
source = inspect.getsource(test_selector)
|
|
38
|
+
# "fishers_exact" is correct — look for bare "fisher_exact" NOT preceded by 's'
|
|
39
|
+
import re
|
|
40
|
+
bare_fisher = re.findall(r'(?<![s])fisher_exact', source)
|
|
41
|
+
assert not bare_fisher, (
|
|
42
|
+
f"test_selector has bare 'fisher_exact' (without 's'): {bare_fisher}. "
|
|
43
|
+
f"Must use 'fishers_exact'. See DECISIONS.md §11 M6."
|
|
44
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""M7 regression: canonical StudyPlan lives in core.planning.study_plan only.
|
|
2
|
+
|
|
3
|
+
Prevents re-introduction of a stale StudyPlan in core/models.py that's missing
|
|
4
|
+
fields (study_type, matching_criteria, post_hoc_tests, amendment_reason,
|
|
5
|
+
cox_ph_models, diagnostic_results) and would cause silent data corruption.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import dataclasses
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REQUIRED_FIELDS = {
|
|
17
|
+
"study_type",
|
|
18
|
+
"matching_criteria",
|
|
19
|
+
"post_hoc_tests",
|
|
20
|
+
"amendment_reason",
|
|
21
|
+
"cox_ph_models",
|
|
22
|
+
"diagnostic_results",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestM7SingleSourceOfTruth:
|
|
27
|
+
def test_canonical_study_plan_has_all_required_fields(self):
|
|
28
|
+
"""The canonical StudyPlan in core.planning.study_plan must have all
|
|
29
|
+
fields that were missing from the stale copy in core.models."""
|
|
30
|
+
from core.planning.study_plan import StudyPlan
|
|
31
|
+
|
|
32
|
+
field_names = {f.name for f in dataclasses.fields(StudyPlan)}
|
|
33
|
+
missing = REQUIRED_FIELDS - field_names
|
|
34
|
+
assert not missing, f"Canonical StudyPlan missing fields: {missing}"
|
|
35
|
+
|
|
36
|
+
def test_core_models_does_not_define_study_plan(self):
|
|
37
|
+
"""core.models must not re-define a StudyPlan class.
|
|
38
|
+
If someone adds one back, this test catches it immediately."""
|
|
39
|
+
mod = importlib.import_module("core.models")
|
|
40
|
+
assert not hasattr(mod, "StudyPlan"), (
|
|
41
|
+
"StudyPlan must NOT be defined in core.models — "
|
|
42
|
+
"use core.planning.study_plan.StudyPlan instead. "
|
|
43
|
+
"See DECISIONS.md §11 M7."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def test_canonical_study_plan_round_trips_missing_fields(self):
|
|
47
|
+
"""from_dict must default the 6 fields that the stale copy was missing,
|
|
48
|
+
ensuring data loaded from old lock files still works."""
|
|
49
|
+
from core.planning.study_plan import StudyPlan
|
|
50
|
+
|
|
51
|
+
minimal = {"study_id": "test", "version": 1}
|
|
52
|
+
plan = StudyPlan.from_dict(minimal)
|
|
53
|
+
d = plan.to_dict()
|
|
54
|
+
for field_name in REQUIRED_FIELDS:
|
|
55
|
+
assert field_name in d, f"Round-trip lost field: {field_name}"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""M8 regression: _filter_superseded must be a single shared implementation.
|
|
2
|
+
|
|
3
|
+
Previously duplicated in strobe_checklist.py and manuscript_draft.py.
|
|
4
|
+
Divergence risk if one is updated and the other isn't.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from core.reporting import filter_superseded
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestM8FilterSuperseded:
|
|
15
|
+
def test_shared_implementation_available(self):
|
|
16
|
+
"""filter_superseded must be importable from core.reporting."""
|
|
17
|
+
assert callable(filter_superseded)
|
|
18
|
+
|
|
19
|
+
def test_removes_superseded_rows(self):
|
|
20
|
+
"""Rows whose id appears as another's superseded_previous_result_id
|
|
21
|
+
must be filtered out."""
|
|
22
|
+
rows = [
|
|
23
|
+
{"id": 1, "superseded_previous_result_id": None},
|
|
24
|
+
{"id": 2, "superseded_previous_result_id": 1}, # supersedes id=1
|
|
25
|
+
{"id": 3, "superseded_previous_result_id": None},
|
|
26
|
+
]
|
|
27
|
+
result = filter_superseded(rows)
|
|
28
|
+
ids = [r["id"] for r in result]
|
|
29
|
+
assert ids == [2, 3]
|
|
30
|
+
|
|
31
|
+
def test_no_superseded_rows(self):
|
|
32
|
+
"""When no rows are superseded, all rows are returned."""
|
|
33
|
+
rows = [
|
|
34
|
+
{"id": 1, "superseded_previous_result_id": None},
|
|
35
|
+
{"id": 2, "superseded_previous_result_id": None},
|
|
36
|
+
]
|
|
37
|
+
result = filter_superseded(rows)
|
|
38
|
+
assert len(result) == 2
|
|
39
|
+
|
|
40
|
+
def test_strobe_uses_shared(self):
|
|
41
|
+
"""strobe_checklist must import from core.reporting, not define its own."""
|
|
42
|
+
import inspect
|
|
43
|
+
from core.reporting import strobe_checklist
|
|
44
|
+
source = inspect.getsource(strobe_checklist)
|
|
45
|
+
assert "def _filter_superseded" not in source, (
|
|
46
|
+
"strobe_checklist still defines local _filter_superseded. "
|
|
47
|
+
"Must import from core.reporting. See DECISIONS.md §11 M8."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_manuscript_uses_shared(self):
|
|
51
|
+
"""manuscript_draft must import from core.reporting, not define its own."""
|
|
52
|
+
import inspect
|
|
53
|
+
from core.reporting import manuscript_draft
|
|
54
|
+
source = inspect.getsource(manuscript_draft)
|
|
55
|
+
assert "def _filter_superseded" not in source, (
|
|
56
|
+
"manuscript_draft still defines local _filter_superseded. "
|
|
57
|
+
"Must import from core.reporting. See DECISIONS.md §11 M8."
|
|
58
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""M9 regression: cmd_table1 must read exposure column from locked plan.
|
|
2
|
+
|
|
3
|
+
Previously hardcoded to "treatment_arm". Studies using different column
|
|
4
|
+
names got unstratified Table 1 with no warning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from core.planning.study_plan import StudyPlan
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestM9Table1Groupby:
|
|
18
|
+
def test_study_plan_has_primary_treatment_col(self):
|
|
19
|
+
"""StudyPlan must have a primary_treatment_col field."""
|
|
20
|
+
field_names = {f.name for f in dataclasses.fields(StudyPlan)}
|
|
21
|
+
assert "primary_treatment_col" in field_names, (
|
|
22
|
+
"StudyPlan missing primary_treatment_col field. See DECISIONS.md §11 M9."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def test_primary_treatment_col_defaults_empty(self):
|
|
26
|
+
"""primary_treatment_col must default to empty string."""
|
|
27
|
+
plan = StudyPlan(study_id="test")
|
|
28
|
+
assert plan.primary_treatment_col == ""
|
|
29
|
+
|
|
30
|
+
def test_primary_treatment_col_round_trips(self):
|
|
31
|
+
"""primary_treatment_col must survive to_dict/from_dict."""
|
|
32
|
+
plan = StudyPlan(study_id="test", primary_treatment_col="arm")
|
|
33
|
+
d = plan.to_dict()
|
|
34
|
+
restored = StudyPlan.from_dict(d)
|
|
35
|
+
assert restored.primary_treatment_col == "arm"
|
|
36
|
+
|
|
37
|
+
def test_cmd_table1_reads_from_plan(self):
|
|
38
|
+
"""cmd_table1 must not hardcode 'treatment_arm' — must read from plan."""
|
|
39
|
+
from core.cli import main
|
|
40
|
+
source = inspect.getsource(main.cmd_table1)
|
|
41
|
+
# Should load plan to get primary_treatment_col
|
|
42
|
+
assert "primary_treatment_col" in source, (
|
|
43
|
+
"cmd_table1 doesn't read primary_treatment_col from plan. "
|
|
44
|
+
"See DECISIONS.md §11 M9."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def test_old_plans_without_field_still_work(self):
|
|
48
|
+
"""Plans loaded from old lock files (without primary_treatment_col)
|
|
49
|
+
must default gracefully."""
|
|
50
|
+
old_data = {
|
|
51
|
+
"study_id": "test",
|
|
52
|
+
"version": 1,
|
|
53
|
+
"primary_comparison": "test",
|
|
54
|
+
}
|
|
55
|
+
plan = StudyPlan.from_dict(old_data)
|
|
56
|
+
assert plan.primary_treatment_col == ""
|