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