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,233 @@
1
+ """
2
+ Tab 1 — Data & Schema (Blinded Inspection).
3
+ Maps to core.ingestion / core.masking.blind per DECISIONS.md tab-to-CLI mapping.
4
+
5
+ Read-only inspection only. No drop/impute/interaction here (§3). Outcome column,
6
+ once declared, is vaulted from variable browser / redundancy / cross-tabs for the
7
+ rest of Tab 1 — everything below enforces that by filtering it out of responses,
8
+ except the one fixed cohort banner (explicitly allowed, §3).
9
+
10
+ NOTE: dtype inference and correlation/cross-tab logic below are simple pandas
11
+ implementations to get Tab 1 running end-to-end. Swap for the real
12
+ core.ingestion.variable_classifier / core stats calls when wiring the actual
13
+ v2.7.0-core engine in — kept as thin as possible so that swap is a drop-in.
14
+ """
15
+ import hashlib
16
+ import io
17
+ import json
18
+ from itertools import combinations
19
+
20
+ import pandas as pd
21
+ from fastapi import APIRouter, File, HTTPException, UploadFile
22
+ from pydantic import BaseModel
23
+
24
+ from state import SESSION
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ # ---------- request schemas ----------
30
+
31
+ class ColumnMapping(BaseModel):
32
+ name: str
33
+ type: str # "identifier" | "numeric_covariate" | "categorical_covariate" | "primary_outcome" | "time_to_event"
34
+
35
+ class SchemaIn(BaseModel):
36
+ column_mappings: list[ColumnMapping]
37
+
38
+ class OutcomeIn(BaseModel):
39
+ column_name: str
40
+ event_value: str
41
+ censored_value: str
42
+
43
+ class SentinelsIn(BaseModel):
44
+ global_na_strings: list[str] = []
45
+ column_overrides: dict[str, list[str]] = {}
46
+
47
+
48
+ # ---------- helpers ----------
49
+
50
+ def _require_data():
51
+ if SESSION.raw_df is None:
52
+ raise HTTPException(400, "No dataset uploaded yet. POST /api/ingest/upload first.")
53
+
54
+ def _outcome_col():
55
+ return SESSION.outcome_spec["column_name"] if SESSION.outcome_vaulted() else None
56
+
57
+ def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
58
+ out = df.copy()
59
+ global_na = SESSION.sentinels.get("global_na_strings", [])
60
+ overrides = SESSION.sentinels.get("column_overrides", {})
61
+ for col in out.columns:
62
+ na_list = global_na + overrides.get(col, [])
63
+ if na_list:
64
+ out[col] = out[col].replace(na_list, pd.NA)
65
+ return out
66
+
67
+ def _visible_columns() -> list[str]:
68
+ """Columns minus the vaulted outcome identity."""
69
+ outcome = _outcome_col()
70
+ return [c for c in SESSION.raw_df.columns if c != outcome]
71
+
72
+
73
+ # ---------- endpoints ----------
74
+
75
+ @router.post("/upload")
76
+ async def upload(file: UploadFile = File(...)):
77
+ raw_bytes = await file.read()
78
+ SESSION.dataset_fingerprint = hashlib.sha256(raw_bytes).hexdigest()
79
+ SESSION.raw_path = file.filename
80
+
81
+ if file.filename.lower().endswith((".xlsx", ".xls")):
82
+ df = pd.read_excel(io.BytesIO(raw_bytes))
83
+ else:
84
+ df = pd.read_csv(io.BytesIO(raw_bytes))
85
+ SESSION.raw_df = df
86
+
87
+ # naive dtype guess — placeholder for core.ingestion.variable_classifier
88
+ guessed = []
89
+ for col in df.columns:
90
+ if df[col].nunique() == len(df[col]) and df[col].is_unique:
91
+ t = "identifier"
92
+ elif pd.api.types.is_numeric_dtype(df[col]):
93
+ t = "numeric_covariate"
94
+ else:
95
+ t = "categorical_covariate"
96
+ guessed.append({"name": col, "guessed_type": t})
97
+
98
+ return {
99
+ "dataset_fingerprint": SESSION.dataset_fingerprint,
100
+ "n_rows": len(df),
101
+ "columns": guessed,
102
+ }
103
+
104
+
105
+ @router.post("/schema")
106
+ def set_schema(body: SchemaIn):
107
+ _require_data()
108
+ mappings = []
109
+ for m in body.column_mappings:
110
+ entry = {"name": m.name, "type": m.type}
111
+ if m.type == "identifier":
112
+ entry["has_duplicates"] = bool(SESSION.raw_df[m.name].duplicated().any())
113
+ mappings.append(entry)
114
+ SESSION.column_mappings = mappings
115
+ return {"column_mappings": mappings}
116
+
117
+
118
+ @router.post("/outcome")
119
+ def declare_outcome(body: OutcomeIn):
120
+ """Direct declaration only — never auto-detected (§3). Vaults the column immediately."""
121
+ _require_data()
122
+ if body.column_name not in SESSION.raw_df.columns:
123
+ raise HTTPException(400, f"Column '{body.column_name}' not found.")
124
+ SESSION.outcome_spec = {
125
+ "column_name": body.column_name,
126
+ "event_value": body.event_value,
127
+ "censored_value": body.censored_value,
128
+ "vaulted": True,
129
+ }
130
+ return {"outcome_spec": {"vaulted": True}} # column identity not echoed back either
131
+
132
+
133
+ @router.post("/sentinels")
134
+ def set_sentinels(body: SentinelsIn):
135
+ _require_data()
136
+ SESSION.sentinels = {
137
+ "global_na_strings": body.global_na_strings,
138
+ "column_overrides": body.column_overrides,
139
+ }
140
+ return SESSION.sentinels
141
+
142
+
143
+ @router.get("/missingness")
144
+ def missingness():
145
+ _require_data()
146
+ df = _apply_sentinels(SESSION.raw_df)
147
+ cols = _visible_columns()
148
+ return {c: round(float(df[c].isna().mean()), 4) for c in cols}
149
+
150
+
151
+ @router.get("/redundancy")
152
+ def redundancy():
153
+ """Covariate-covariate correlation only — outcome excluded. |r| > 0.8 flagged (§3)."""
154
+ _require_data()
155
+ df = _apply_sentinels(SESSION.raw_df)
156
+ numeric_cols = [
157
+ c for c in _visible_columns()
158
+ if pd.api.types.is_numeric_dtype(df[c])
159
+ ]
160
+ pairs = []
161
+ for a, b in combinations(numeric_cols, 2):
162
+ r = df[[a, b]].corr().iloc[0, 1]
163
+ if pd.notna(r) and abs(r) > 0.8:
164
+ pairs.append({"var1": a, "var2": b, "r": round(float(r), 3)})
165
+ return {"high_correlation_pairs": pairs}
166
+
167
+
168
+ @router.get("/sparse-crosstabs")
169
+ def sparse_crosstabs(min_cell_threshold: int = 5):
170
+ """Categorical-categorical sparse cell check — outcome excluded."""
171
+ _require_data()
172
+ df = _apply_sentinels(SESSION.raw_df)
173
+ cat_cols = [
174
+ c for c in _visible_columns()
175
+ if not pd.api.types.is_numeric_dtype(df[c])
176
+ ]
177
+ flagged = []
178
+ for a, b in combinations(cat_cols, 2):
179
+ ct = pd.crosstab(df[a], df[b])
180
+ if ct.size and ct.values.min() < min_cell_threshold:
181
+ flagged.append({"var1": a, "var2": b, "min_cell_count": int(ct.values.min())})
182
+ return {"sparse_cross_tabs": flagged}
183
+
184
+
185
+ @router.get("/cohort-banner")
186
+ def cohort_banner():
187
+ """The one allowed exception: touches outcome once, fixed, not recomputed per action (§3)."""
188
+ _require_data()
189
+ if not SESSION.outcome_vaulted():
190
+ raise HTTPException(400, "Declare outcome first — banner needs event count.")
191
+ df = SESSION.raw_df
192
+ spec = SESSION.outcome_spec
193
+ n_total = len(df)
194
+ e_total = int((df[spec["column_name"]].astype(str) == str(spec["event_value"])).sum())
195
+ return {
196
+ "n_total": n_total,
197
+ "e_total": e_total,
198
+ "event_rate": round(e_total / n_total, 4) if n_total else 0.0,
199
+ }
200
+
201
+
202
+ @router.post("/lock")
203
+ def lock_stage1():
204
+ """Emits Stage1Payload (H0) per DECISIONS.md §3. Hash excludes itself, computed then inserted."""
205
+ _require_data()
206
+ if not SESSION.outcome_vaulted():
207
+ raise HTTPException(400, "Cannot lock Stage 1 without a declared outcome.")
208
+
209
+ banner = cohort_banner()
210
+ miss = missingness()
211
+ redund = redundancy()
212
+ sparse = sparse_crosstabs()
213
+
214
+ payload = {
215
+ "provenance": {
216
+ "dataset_fingerprint": SESSION.dataset_fingerprint,
217
+ "ephemeral_raw_path": SESSION.raw_path,
218
+ },
219
+ "cohort_facts": banner,
220
+ "outcome_spec": SESSION.outcome_spec,
221
+ "column_mappings": SESSION.column_mappings,
222
+ "sentinels": SESSION.sentinels,
223
+ "precomputations_advisory_cache": {
224
+ "is_advisory": True,
225
+ "missingness_pct": miss,
226
+ "high_correlation_pairs": redund["high_correlation_pairs"],
227
+ "sparse_cross_tabs": sparse["sparse_cross_tabs"],
228
+ },
229
+ }
230
+ canonical = json.dumps(payload, sort_keys=True, default=str).encode()
231
+ payload["provenance"]["payload_fingerprint_h0"] = hashlib.sha256(canonical).hexdigest()
232
+ SESSION.h0_payload = payload # Tab 2 chains plan_fingerprint_h1 off this
233
+ return payload
@@ -0,0 +1,265 @@
1
+ """
2
+ Tab 2 — Socratic Wizard & Lock Gate.
3
+ Maps to core.planning / core.provenance per DECISIONS.md §4-5.
4
+
5
+ Novice wizard and Expert JSON-import both land here structurally — this file only
6
+ implements the wizard steps (entry_mode="socratic_wizard"). Expert direct-spec
7
+ import is a separate endpoint, not built this session.
8
+
9
+ Steps A-D per §4:
10
+ A exposure -> /exposure
11
+ B outcome confirm -> /outcome-confirm (GET, read-only re-display) + /time-column
12
+ C confounders -> /confounders, /interactions (Manuscript Mirror at /manuscript-mirror)
13
+ D rigor / missing -> /missing-strategy, /epv-live (recomputed live, never cached)
14
+ lock -> /lock, emits Stage2Payload (H1), chained to H0 via payload_fingerprint_h0.
15
+ """
16
+ import hashlib
17
+ import json
18
+
19
+ import pandas as pd
20
+ from fastapi import APIRouter, HTTPException
21
+ from pydantic import BaseModel
22
+
23
+ from state import SESSION
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ # ---------- request schemas ----------
29
+
30
+ class ExposureIn(BaseModel):
31
+ column_name: str
32
+ reference_level: str
33
+
34
+ class TimeColumnIn(BaseModel):
35
+ time_column: str
36
+
37
+ class ConfoundersIn(BaseModel):
38
+ confounders: list[str]
39
+
40
+ class InteractionIn(BaseModel):
41
+ term: str
42
+ rationale: str
43
+
44
+ class MissingStrategyIn(BaseModel):
45
+ global_default: str # "complete_case" | "impute" | "flag_as_missing_category"
46
+ column_overrides: dict[str, str] = {}
47
+
48
+
49
+ # ---------- helpers ----------
50
+
51
+ def _require_h0():
52
+ if SESSION.h0_payload is None:
53
+ raise HTTPException(400, "Lock Stage 1 first — no H0 payload to chain off.")
54
+
55
+ def _apply_sentinels(df: pd.DataFrame) -> pd.DataFrame:
56
+ out = df.copy()
57
+ global_na = SESSION.sentinels.get("global_na_strings", [])
58
+ overrides = SESSION.sentinels.get("column_overrides", {})
59
+ for col in out.columns:
60
+ na_list = global_na + overrides.get(col, [])
61
+ if na_list:
62
+ out[col] = out[col].replace(na_list, pd.NA)
63
+ return out
64
+
65
+ def _complete_case_columns() -> list[str]:
66
+ """Columns whose missingness actually costs rows under the current strategy
67
+ impute / flag_as_missing_category columns don't drop rows, complete_case ones do."""
68
+ strat = SESSION.missing_data_strategy
69
+ cols = []
70
+ if SESSION.exposure:
71
+ cols.append(SESSION.exposure["column_name"])
72
+ cols += SESSION.confounders
73
+ for c in cols[:]:
74
+ method = strat["column_overrides"].get(c, strat["global_default"])
75
+ if method != "complete_case":
76
+ cols.remove(c)
77
+ return cols
78
+
79
+
80
+ # ---------- endpoints ----------
81
+
82
+ @router.post("/exposure")
83
+ def set_exposure(body: ExposureIn):
84
+ _require_h0()
85
+ vaulted_outcome = SESSION.outcome_spec["column_name"]
86
+ if body.column_name == vaulted_outcome:
87
+ raise HTTPException(400, "Exposure can't be the vaulted outcome column.")
88
+ SESSION.exposure = body.dict()
89
+ return SESSION.exposure
90
+
91
+
92
+ @router.get("/outcome-confirm")
93
+ def outcome_confirm():
94
+ """Read-only re-display — never a re-selection. Outcome was fixed in Stage 1."""
95
+ _require_h0()
96
+ spec = SESSION.outcome_spec
97
+ return {
98
+ "column_name": spec["column_name"],
99
+ "event_value": spec["event_value"],
100
+ "censored_value": spec["censored_value"],
101
+ "time_column": SESSION.time_column,
102
+ }
103
+
104
+
105
+ @router.post("/time-column")
106
+ def set_time_column(body: TimeColumnIn):
107
+ _require_h0()
108
+ SESSION.time_column = body.time_column
109
+ return {"time_column": SESSION.time_column}
110
+
111
+
112
+ @router.post("/confounders")
113
+ def set_confounders(body: ConfoundersIn):
114
+ _require_h0()
115
+ SESSION.confounders = body.confounders
116
+ return {"confounders": SESSION.confounders}
117
+
118
+
119
+ @router.get("/redundancy-warnings")
120
+ def redundancy_warnings():
121
+ """Surfaces only the pairs relevant to what's actually been selected as confounders."""
122
+ _require_h0()
123
+ selected = set(SESSION.confounders)
124
+ pairs = SESSION.h0_payload["precomputations_advisory_cache"]["high_correlation_pairs"]
125
+ return {"warnings": [p for p in pairs if p["var1"] in selected and p["var2"] in selected]}
126
+
127
+
128
+ @router.post("/interactions")
129
+ def add_interaction(body: InteractionIn):
130
+ """Interaction terms permitted only here (§4), gated by rationale sanity floor (§5) —
131
+ ~15 char minimum to block literal 'yes'/'none', not a strict quality gate."""
132
+ _require_h0()
133
+ if len(body.rationale.strip()) < 15:
134
+ raise HTTPException(400, "Rationale too short — needs genuine clinical justification, not a placeholder.")
135
+ SESSION.interactions.append(body.dict())
136
+ return {"interactions": SESSION.interactions}
137
+
138
+
139
+ @router.get("/manuscript-mirror")
140
+ def manuscript_mirror():
141
+ """Live preview of how each interaction rationale will read in the exported
142
+ Methods / STROBE supplement — the real quality incentive, no keyword gating (§5)."""
143
+ _require_h0()
144
+ lines = []
145
+ for i in SESSION.interactions:
146
+ lines.append(
147
+ f"An interaction term ({i['term']}) was pre-specified. Rationale: {i['rationale']}"
148
+ )
149
+ return {"methods_section_preview": " ".join(lines) or "No interaction terms declared."}
150
+
151
+
152
+ @router.post("/missing-strategy")
153
+ def set_missing_strategy(body: MissingStrategyIn):
154
+ _require_h0()
155
+ SESSION.missing_data_strategy = body.dict()
156
+ return SESSION.missing_data_strategy
157
+
158
+
159
+ @router.get("/epv-live")
160
+ def epv_live():
161
+ """Recomputed fresh on every call — never a cached/static number (§4)."""
162
+ _require_h0()
163
+ if not SESSION.exposure:
164
+ raise HTTPException(400, "Set exposure first.")
165
+
166
+ df = _apply_sentinels(SESSION.raw_df)
167
+ cc_cols = _complete_case_columns()
168
+ outcome_col = SESSION.outcome_spec["column_name"]
169
+
170
+ subset_cols = cc_cols + [outcome_col]
171
+ effective_df = df.dropna(subset=subset_cols) if cc_cols else df
172
+
173
+ n_effective = len(effective_df)
174
+ e_total = (effective_df[outcome_col].astype(str) == str(SESSION.outcome_spec["event_value"])).sum()
175
+
176
+ # Calculate degrees of freedom (k) for main effects
177
+ predictors = [SESSION.exposure["column_name"]] + SESSION.confounders
178
+ k = 0
179
+ for col in predictors:
180
+ if col in effective_df.columns:
181
+ if pd.api.types.is_numeric_dtype(effective_df[col]):
182
+ k += 1
183
+ else:
184
+ n_uniq = effective_df[col].dropna().nunique()
185
+ k += max(n_uniq - 1, 1)
186
+ else:
187
+ k += 1
188
+
189
+ # Calculate degrees of freedom for interaction terms (DoF_A * DoF_B)
190
+ for inter in SESSION.interactions:
191
+ term_str = inter.get("term", "")
192
+ parts = [p.strip() for p in term_str.replace("*", ":").split(":") if p.strip()]
193
+ if len(parts) >= 2:
194
+ inter_dof = 1
195
+ for p in parts:
196
+ if p in effective_df.columns:
197
+ if not pd.api.types.is_numeric_dtype(effective_df[p]):
198
+ u = effective_df[p].dropna().nunique()
199
+ inter_dof *= max(u - 1, 1)
200
+ k += inter_dof
201
+ else:
202
+ k += 1
203
+
204
+ epv = round(e_total / k, 2) if k else None
205
+
206
+ return {
207
+ "n_effective": n_effective,
208
+ "e_effective": int(e_total),
209
+ "parameters_k": k,
210
+ "epv": epv,
211
+ }
212
+
213
+
214
+ @router.get("/amendment-status")
215
+ def amendment_status():
216
+ return {"pending": SESSION.pending_amendment}
217
+
218
+
219
+ @router.post("/lock")
220
+ def lock_stage2():
221
+ """Emits Stage2Payload (H1), chained off H0's payload_fingerprint_h0."""
222
+ _require_h0()
223
+ if not SESSION.exposure:
224
+ raise HTTPException(400, "Exposure not set — Step A incomplete.")
225
+
226
+ feasibility = epv_live()
227
+ if feasibility["epv"] is not None and feasibility["epv"] < 10:
228
+ # not a hard block by design choice here — surfaced, not silently allowed either.
229
+ # (DECISIONS.md doesn't mandate a hard EPV floor at lock; flag only.)
230
+ pass
231
+
232
+ payload = {
233
+ "provenance": {
234
+ "payload_fingerprint_h0": SESSION.h0_payload["provenance"]["payload_fingerprint_h0"],
235
+ "entry_mode": "socratic_wizard",
236
+ },
237
+ "protocol": {
238
+ "study_design": "retrospective_cohort",
239
+ "exposure": SESSION.exposure,
240
+ "outcome_confirmation": {
241
+ "column_name": SESSION.outcome_spec["column_name"],
242
+ "event_value": SESSION.outcome_spec["event_value"],
243
+ "time_column": SESSION.time_column,
244
+ },
245
+ "confounders": SESSION.confounders,
246
+ "interactions": SESSION.interactions,
247
+ "missing_data_strategy": SESSION.missing_data_strategy,
248
+ "pre_specified_transforms": {}, # NOTE: assumes linear for all continuous covariates
249
+ # until Tab2 UI grows a transform picker (Gate 4 reads this)
250
+ },
251
+ "locked_feasibility_metrics": feasibility,
252
+ }
253
+ # amendment chaining: if this lock follows a Tab3 FAIL, parent is the failed plan's hash,
254
+ # not H0 directly — new plan hash still gets computed fresh below.
255
+ if SESSION.pending_amendment:
256
+ payload["provenance"]["parent_plan_hash"] = SESSION.pending_amendment["parent_plan_hash"]
257
+ payload["provenance"]["amendment_rationale"] = SESSION.pending_amendment.get("rationale", "")
258
+
259
+ canonical = json.dumps(payload, sort_keys=True, default=str).encode()
260
+ payload["provenance"]["plan_fingerprint_h1"] = hashlib.sha256(canonical).hexdigest()
261
+
262
+ SESSION.h1_payload = payload
263
+ SESSION.plan_chain.append(payload)
264
+ SESSION.pending_amendment = None
265
+ return payload