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,89 @@
1
+ """Diagnostic result data structures for pre/post-unmask assumption checks."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ from dataclasses import dataclass, asdict
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class DiagnosticResult:
12
+ """Structured result of a single diagnostic/assumption check.
13
+
14
+ Serialized as part of the StudyPlan JSON under ``diagnostic_results``.
15
+ Backward-compatible: the legacy ``warnings: dict[str, str]`` on StudyPlan
16
+ remains a separate field for existing code.
17
+ """
18
+ check_name: str
19
+ status: str
20
+ value: float | None = None
21
+ threshold: float | None = None
22
+ message: str = ""
23
+ forceable: bool = True
24
+ stage: str = "pre_unmask"
25
+ recorded_at: Optional[str] = None
26
+
27
+ def __post_init__(self):
28
+ if self.recorded_at is None:
29
+ self.recorded_at = datetime.now(timezone.utc).isoformat()
30
+
31
+ def to_dict(self) -> dict:
32
+ return asdict(self)
33
+
34
+ @staticmethod
35
+ def from_dict(d: dict) -> DiagnosticResult:
36
+ return DiagnosticResult(**d)
37
+
38
+
39
+ def check_violation(row: dict) -> tuple[bool, str, list[dict]]:
40
+ """Inspect an analysis_results row for post-unmask diagnostic violations.
41
+
42
+ Parameters
43
+ ----------
44
+ row : dict
45
+ A row from analysis_results, typically including status_json and
46
+ ph_diagnostics_json.
47
+
48
+ Returns
49
+ -------
50
+ (has_violation, violation_summary, violation_details)
51
+ has_violation: True if any post-unmask diagnostic flags a violation.
52
+ violation_summary: Short human-readable sentence.
53
+ violation_details: list of dicts with keys (check_name, p_value, description).
54
+ """
55
+ details: list[dict] = []
56
+ status_data = json.loads(row.get("status_json") or "{}")
57
+ status = status_data.get("status", "completed")
58
+
59
+ # Check 1: Schoenfeld proportional hazards violation
60
+ ph_raw = row.get("ph_diagnostics_json")
61
+ if ph_raw:
62
+ ph = json.loads(ph_raw) if isinstance(ph_raw, str) else ph_raw
63
+ for cov in ph.get("covariates", []):
64
+ p = cov.get("p_value", 1)
65
+ if p < 0.05:
66
+ details.append({
67
+ "check_name": "proportional_hazards",
68
+ "p_value": p,
69
+ "description": f"PH violated for {cov.get('covariate', 'unknown')} (p={p:.4f})",
70
+ })
71
+
72
+ # Check 2: Explicit assumption_violation status not yet captured by a specific check.
73
+ # This is a fallback for future violation types (separation, linearity) that don't
74
+ # have their own structured fields yet — only fires when no more-specific check
75
+ # already populated details.
76
+ if status == "assumption_violation" and not details:
77
+ reason = status_data.get("reason", "Unknown assumption violation")
78
+ details.append({
79
+ "check_name": "post_unmask_diagnostic",
80
+ "p_value": None,
81
+ "description": reason,
82
+ })
83
+
84
+ if not details:
85
+ return False, "", []
86
+
87
+ summary = "; ".join(d["description"] for d in details)
88
+ return True, summary, details
89
+
core/planning/lock.py ADDED
@@ -0,0 +1,232 @@
1
+ """Lock/unmask mechanism — writes immutable versioned plan snapshots.
2
+
3
+ Once locked, a plan file must never be edited in place. Any change requires
4
+ creating a new version. Old versions are never deleted.
5
+
6
+ Integrity: every locked file includes a SHA-256 hash of its own content
7
+ (excluding the hash field itself). load_plan() verifies the hash on every
8
+ read and raises if the file was tampered with.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from core.database import DATA_ROOT, get_connection, init_db
17
+ from core.planning.study_plan import StudyPlan
18
+ from core.masking.gate import lock_study, is_masked, unmask_study as gate_unmask
19
+
20
+
21
+ def _plan_path(study_id: str, version: int) -> Path:
22
+ return DATA_ROOT / study_id / f"study_plan.v{version}.locked.json"
23
+
24
+
25
+ def _next_version(study_id: str) -> int:
26
+ """Find the highest existing version + 1."""
27
+ existing = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
28
+ if not existing:
29
+ return 1
30
+ max_v = 0
31
+ for p in existing:
32
+ try:
33
+ v = int(p.stem.split(".v")[1].split(".")[0])
34
+ max_v = max(max_v, v)
35
+ except (IndexError, ValueError):
36
+ continue
37
+ return max_v + 1
38
+
39
+
40
+ def _compute_hash(data: dict) -> str:
41
+ """SHA-256 of the canonical JSON, excluding the content_hash field."""
42
+ d = {k: v for k, v in data.items() if k != "content_hash"}
43
+ canonical = json.dumps(d, sort_keys=True, ensure_ascii=False)
44
+ return hashlib.sha256(canonical.encode()).hexdigest()
45
+
46
+
47
+ def lock_plan(study_id: str, plan: StudyPlan) -> Path:
48
+ """Write an immutable, versioned, timestamped plan to disk.
49
+
50
+ The file includes a content_hash that is verified on every load.
51
+ Must be called before unmasking (is_masked must be True).
52
+
53
+ Returns
54
+ -------
55
+ Path to the written lock file.
56
+ """
57
+ if not is_masked(study_id):
58
+ raise RuntimeError("Cannot lock a plan after unmasking.")
59
+
60
+ plan.version = _next_version(study_id)
61
+ plan.locked_at = None # will be set by to_dict()
62
+ data = plan.to_dict()
63
+ data["content_hash"] = _compute_hash(data)
64
+
65
+ path = _plan_path(study_id, plan.version)
66
+ path.write_text(json.dumps(data, indent=2))
67
+
68
+ # Update the study record in DB
69
+ conn = get_connection(study_id)
70
+ init_db(conn)
71
+ conn.execute(
72
+ "UPDATE studies SET is_locked=1, study_type=? WHERE id=?",
73
+ (plan.study_type, study_id),
74
+ )
75
+ conn.commit()
76
+ conn.close()
77
+
78
+ return path
79
+
80
+
81
+ def verify_hash(path: Path) -> bool:
82
+ """Verify the content_hash in a locked plan file."""
83
+ data = json.loads(path.read_text())
84
+ stored_hash = data.get("content_hash", "")
85
+ if not stored_hash:
86
+ return False
87
+ computed = _compute_hash(data)
88
+ return computed == stored_hash
89
+
90
+
91
+ def load_plan(study_id: str, version: int | None = None) -> StudyPlan:
92
+ """Load the latest (or specific version) plan file.
93
+
94
+ Raises ValueError if the content hash doesn't match (tampered file).
95
+ """
96
+ if version is not None:
97
+ path = _plan_path(study_id, version)
98
+ else:
99
+ versions = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
100
+ if not versions:
101
+ raise FileNotFoundError(f"No locked plan found for study {study_id}")
102
+ path = versions[-1]
103
+
104
+ data = json.loads(path.read_text())
105
+
106
+ if not verify_hash(path):
107
+ raise ValueError(
108
+ f"Locked plan tampered: {path}. "
109
+ "The file content has been modified since locking."
110
+ )
111
+
112
+ # Strip internal fields before deserializing
113
+ data.pop("content_hash", None)
114
+ return StudyPlan.from_dict(data)
115
+
116
+
117
+ def unmask_study(study_id: str) -> None:
118
+ """Unmask the study — irreversibly reveals outcome data."""
119
+ gate_unmask(study_id)
120
+
121
+
122
+ def lock_amendment(
123
+ study_id: str,
124
+ *,
125
+ amendment_reason: str,
126
+ planned_tests: list[dict] | None = None,
127
+ post_hoc_tests: list[dict] | None = None,
128
+ ) -> Path:
129
+ """Write a new versioned plan file for an amendment.
130
+
131
+ This is a separate function from ``lock_plan()`` — do not modify
132
+ ``lock_plan()``. ``lock_plan()``'s unconditional refusal to lock after
133
+ unmasking is the tool's core HARKing prevention guarantee and must not
134
+ accept any bypass parameter.
135
+
136
+ Parameters
137
+ ----------
138
+ study_id : str
139
+ amendment_reason : str
140
+ Required human-readable explanation for the amendment.
141
+ planned_tests : list[dict], optional
142
+ New pre-registered tests (pre-unmask amendment). Only one of
143
+ ``planned_tests`` or ``post_hoc_tests`` may be set.
144
+ post_hoc_tests : list[dict], optional
145
+ New post-hoc/exploratory tests (post-unmask amendment).
146
+
147
+ Returns
148
+ -------
149
+ Path to the written lock file.
150
+
151
+ Raises
152
+ ------
153
+ ValueError
154
+ If ``amendment_reason`` is empty, or both/both-none of the test
155
+ lists are provided, or state constraints are violated.
156
+ RuntimeError
157
+ If study has already been unmasked when trying a pre-unmask
158
+ amendment (planned_tests), or if study has NOT been unmasked when
159
+ trying a post-hoc amendment (post_hoc_tests).
160
+ """
161
+ if not amendment_reason:
162
+ raise ValueError("amendment_reason is required for any amendment.")
163
+
164
+ if not planned_tests and not post_hoc_tests:
165
+ raise ValueError("Must provide either planned_tests (pre-unmask) or post_hoc_tests (post-hoc).")
166
+ if planned_tests and post_hoc_tests:
167
+ raise ValueError("Cannot provide both planned_tests and post_hoc_tests in one amendment.")
168
+
169
+ # Load the latest locked plan to preserve existing state
170
+ latest = load_plan(study_id)
171
+
172
+ # ── State enforcement ─────────────────────────────────────────────
173
+ conn = get_connection(study_id)
174
+ cur = conn.execute("SELECT is_locked FROM studies WHERE id=?", (study_id,))
175
+ row = cur.fetchone()
176
+ state = row["is_locked"] if row else 0
177
+ conn.close()
178
+
179
+ if planned_tests is not None:
180
+ # Pre-unmask amendment: study must still be locked/masked (state < 2)
181
+ if state >= 2:
182
+ raise RuntimeError(
183
+ f"Cannot amend study '{study_id}' with pre-registered tests "
184
+ f"after unmasking. The study has already been unmasked — "
185
+ f"use lock_amendment(..., post_hoc_tests=...) for post-hoc "
186
+ f"amendments instead."
187
+ )
188
+ new_planned = list(planned_tests)
189
+ new_post_hoc = list(latest.post_hoc_tests) # preserve any existing post-hoc tests
190
+ else:
191
+ # Post-hoc amendment: study must be unmasked (state == 2)
192
+ if state < 2:
193
+ raise RuntimeError(
194
+ f"Cannot add post-hoc tests to study '{study_id}' before "
195
+ f"unmasking. Post-hoc amendments require unmasked data."
196
+ )
197
+ new_planned = list(latest.planned_tests)
198
+ new_post_hoc = list(latest.post_hoc_tests)
199
+ # Dedup: only add tests not already present (by test_name + rationale)
200
+ existing_keys = {(t.get("test_name"), t.get("rationale", "")) for t in new_post_hoc}
201
+ for t in post_hoc_tests:
202
+ key = (t.get("test_name"), t.get("rationale", ""))
203
+ if key not in existing_keys:
204
+ new_post_hoc.append(t)
205
+ existing_keys.add(key)
206
+
207
+ # ── Build the new plan ────────────────────────────────────────────
208
+ plan = StudyPlan(
209
+ study_id=study_id,
210
+ study_type=latest.study_type,
211
+ primary_comparison=latest.primary_comparison,
212
+ primary_outcome_variable_ids=list(latest.primary_outcome_variable_ids),
213
+ planned_tests=new_planned,
214
+ covariates=list(latest.covariates),
215
+ matching_criteria=list(latest.matching_criteria),
216
+ warnings=dict(latest.warnings),
217
+ role_overrides=dict(latest.role_overrides),
218
+ audit=dict(latest.audit),
219
+ post_hoc_tests=new_post_hoc,
220
+ amendment_reason=amendment_reason,
221
+ cox_ph_models=list(latest.cox_ph_models),
222
+ diagnostic_results=list(latest.diagnostic_results),
223
+ )
224
+
225
+ plan.version = _next_version(study_id)
226
+ plan.locked_at = None
227
+ data = plan.to_dict()
228
+ data["content_hash"] = _compute_hash(data)
229
+
230
+ path = _plan_path(study_id, plan.version)
231
+ path.write_text(json.dumps(data, indent=2))
232
+ return path
@@ -0,0 +1,73 @@
1
+ """Study plan data model — structured declaration of analytical intent.
2
+
3
+ The plan must be fully declared *before* unmasking outcome data.
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from dataclasses import dataclass, field, asdict
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class PlannedTest:
14
+ variable_id: int
15
+ variable_name: str
16
+ test_name: str
17
+ rationale: str = ""
18
+
19
+
20
+ @dataclass
21
+ class CoxPHModel:
22
+ """Multivariable Cox Proportional Hazards model declaration."""
23
+ model_name: str
24
+ survival_time_col: str
25
+ event_col: str
26
+ primary_treatment_col: str
27
+ covariate_cols: list[str]
28
+ rationale: str = ""
29
+ interaction_terms: list[list[str]] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class StudyPlan:
34
+ study_id: str
35
+ version: int = 1
36
+ locked_at: Optional[str] = None
37
+ study_type: str = "cohort" # cohort | case_control | cross_sectional
38
+ primary_comparison: str = ""
39
+ primary_treatment_col: str = "" # actual column name for groupby (M9)
40
+ primary_outcome_variable_ids: list[int] = field(default_factory=list)
41
+ planned_tests: list[dict] = field(default_factory=list)
42
+ covariates: list[int] = field(default_factory=list)
43
+ matching_criteria: list[int] = field(default_factory=list)
44
+ warnings: dict[str, str] = field(default_factory=dict)
45
+ role_overrides: dict[int, str] = field(default_factory=dict)
46
+ audit: dict = field(default_factory=dict)
47
+ post_hoc_tests: list[dict] = field(default_factory=list)
48
+ amendment_reason: str = ""
49
+ cox_ph_models: list[CoxPHModel] = field(default_factory=list)
50
+ diagnostic_results: list[dict] = field(default_factory=list)
51
+
52
+ def to_dict(self) -> dict:
53
+ d = asdict(self)
54
+ d["locked_at"] = self.locked_at or datetime.now(timezone.utc).isoformat()
55
+ return d
56
+
57
+ @staticmethod
58
+ def from_dict(d: dict) -> StudyPlan:
59
+ d.setdefault("warnings", {})
60
+ d.setdefault("role_overrides", {})
61
+ d.setdefault("audit", {})
62
+ d.setdefault("matching_criteria", [])
63
+ d.setdefault("primary_treatment_col", "")
64
+ d.setdefault("post_hoc_tests", [])
65
+ d.setdefault("amendment_reason", "")
66
+ d.setdefault("cox_ph_models", [])
67
+ d.setdefault("diagnostic_results", [])
68
+ d.pop("content_hash", None) # lock file artifact, not a model field
69
+ d["role_overrides"] = {int(k): v for k, v in d["role_overrides"].items()}
70
+ # Convert cox_ph_models dicts to CoxPHModel objects
71
+ if "cox_ph_models" in d and d["cox_ph_models"]:
72
+ d["cox_ph_models"] = [CoxPHModel(**m) if isinstance(m, dict) else m for m in d["cox_ph_models"]]
73
+ return StudyPlan(**d)