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,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 == ""