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,518 @@
1
+ """Statistical test selector — suggests appropriate tests based on variable types.
2
+
3
+ This module SUGGESTS only. It never auto-picks a test based on outcome data.
4
+ Distribution checks that inform test selection must happen pre-lock on baseline
5
+ data or be declared explicitly in the study plan.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from abc import ABC, abstractmethod
10
+
11
+ from core.database import get_connection, DATA_ROOT
12
+
13
+
14
+ # ── AssumptionCheck interface ──────────────────────────────────────────
15
+
16
+
17
+ class AssumptionCheck(ABC):
18
+ """Pluggable pre-lock assumption check for a statistical test type.
19
+
20
+ Subclasses implement ``applies_to`` (which test names they handle) and
21
+ ``check`` (which produces warnings by querying only marginal / independently-
22
+ derivable quantities — never a cross-tabulation of outcome × group before
23
+ the plan is locked).
24
+
25
+ Warning messages must never name specific outcome category values or group
26
+ labels — only variable names, counts, and dimensions.
27
+ """
28
+
29
+ @abstractmethod
30
+ def applies_to(self, test_name: str) -> bool:
31
+ ...
32
+
33
+ @abstractmethod
34
+ def check(
35
+ self, study_id: str, test: dict, group_col: str, conn, var_info: dict
36
+ ) -> list[str]:
37
+ ...
38
+
39
+
40
+ # ── Registry of all registered checks ──────────────────────────────────
41
+ # Each AssumptionCheck is instantiated once at module level. The dispatcher
42
+ # (check_assumptions) iterates this list for every declared test.
43
+ _CHECKERS: list[AssumptionCheck] = []
44
+
45
+
46
+ def _register(checker: type[AssumptionCheck]) -> type[AssumptionCheck]:
47
+ _CHECKERS.append(checker())
48
+ return checker
49
+
50
+
51
+ # ── Test suggestion helpers ────────────────────────────────────────────
52
+
53
+
54
+ def suggested_tests(
55
+ primary_outcome_data_type: str,
56
+ study_type: str,
57
+ n_comparison_groups: int = 2,
58
+ is_paired: bool = False,
59
+ n_total: int | None = None,
60
+ ) -> list[dict]:
61
+ """Return a list of appropriate statistical tests given the variable types."""
62
+ if primary_outcome_data_type == "categorical":
63
+ return _categorical_tests(n_comparison_groups, is_paired, n_total)
64
+ elif primary_outcome_data_type == "continuous":
65
+ return _continuous_tests(n_comparison_groups, is_paired)
66
+ elif primary_outcome_data_type == "time_to_event":
67
+ return _survival_tests(n_comparison_groups)
68
+ return []
69
+
70
+
71
+ def _categorical_tests(n_groups: int, paired: bool, n_total: int | None = None) -> list[dict]:
72
+ if n_total is not None and n_total < 30:
73
+ tests = [
74
+ {
75
+ "test_name": "fishers_exact",
76
+ "rationale": "Exact test for 2x2 tables when sample sizes are small.",
77
+ "assumptions": "None.",
78
+ },
79
+ {
80
+ "test_name": "chi_square",
81
+ "rationale": "Tests association between categorical variables.",
82
+ "assumptions": "Expected frequency >=5 in each cell. May be unreliable with small samples.",
83
+ },
84
+ ]
85
+ else:
86
+ tests = [
87
+ {
88
+ "test_name": "chi_square",
89
+ "rationale": "Tests association between categorical variables.",
90
+ "assumptions": "Expected frequency >=5 in each cell.",
91
+ },
92
+ {
93
+ "test_name": "fishers_exact",
94
+ "rationale": "Exact test for 2x2 tables when sample sizes are small.",
95
+ "assumptions": "None.",
96
+ },
97
+ ]
98
+ if n_groups == 2 and paired:
99
+ tests.append({
100
+ "test_name": "mcnemar",
101
+ "rationale": "Paired categorical data (before/after, matched pairs).",
102
+ "assumptions": "Binary outcome, discordant pairs.",
103
+ })
104
+ return tests
105
+
106
+
107
+ def _continuous_tests(n_groups: int, paired: bool) -> list[dict]:
108
+ tests = [
109
+ {
110
+ "test_name": "t_test" if n_groups == 2 else "anova",
111
+ "rationale": "Compares means across groups. Parametric.",
112
+ "assumptions": "Normality and homogeneity of variance.",
113
+ },
114
+ {
115
+ "test_name": "mann_whitney_u" if n_groups == 2 else "kruskal_wallis",
116
+ "rationale": "Non-parametric comparison of distributions.",
117
+ "assumptions": "None (distribution-free).",
118
+ },
119
+ ]
120
+ if n_groups == 2 and paired:
121
+ tests.extend([
122
+ {
123
+ "test_name": "paired_t_test",
124
+ "rationale": "Paired continuous data. Parametric.",
125
+ "assumptions": "Normality of differences.",
126
+ },
127
+ {
128
+ "test_name": "wilcoxon_signed_rank",
129
+ "rationale": "Paired continuous data. Non-parametric.",
130
+ "assumptions": "Symmetric distribution of differences.",
131
+ },
132
+ ])
133
+ return tests
134
+
135
+
136
+ def _survival_tests(n_groups: int) -> list[dict]:
137
+ tests = [
138
+ {
139
+ "test_name": "kaplan_meier_logrank",
140
+ "rationale": "Compares survival distributions between groups.",
141
+ "assumptions": "Non-informative censoring, proportional hazards.",
142
+ },
143
+ {
144
+ "test_name": "cox_proportional_hazards",
145
+ "rationale": "Multivariable survival analysis with covariates.",
146
+ "assumptions": "Proportional hazards.",
147
+ },
148
+ ]
149
+ return tests
150
+
151
+
152
+ # ── Chi-square: sparse expected-cell warning ───────────────────────────
153
+
154
+
155
+ @_register
156
+ class ChiSquareAssumptionCheck(AssumptionCheck):
157
+ """Warn when any expected cell count < 5 for a chi-square test.
158
+
159
+ Expected counts are computed from marginal totals only (row counts from
160
+ the shadow table, column counts from the raw group column) — never a
161
+ cross-tabulation of outcome × arm.
162
+ """
163
+
164
+ def applies_to(self, test_name: str) -> bool:
165
+ return test_name == "chi_square"
166
+
167
+ def check(
168
+ self, study_id: str, test: dict, group_col: str, conn, var_info: dict
169
+ ) -> list[str]:
170
+ var_name = test.get("variable_name", "")
171
+ if not var_name:
172
+ return []
173
+ if var_info.get(var_name) != "categorical":
174
+ return []
175
+
176
+ shadow_table = f"raw_masked_{study_id}"
177
+ raw_table = f"raw_{study_id}"
178
+
179
+ # Row marginals — outcome category counts from the shadow table
180
+ try:
181
+ cur = conn.execute(
182
+ f'SELECT "{var_name}", COUNT(*) AS cnt FROM {shadow_table} '
183
+ f'WHERE "{var_name}" IS NOT NULL GROUP BY "{var_name}"'
184
+ )
185
+ row_marginals = [r["cnt"] for r in cur.fetchall()]
186
+ except Exception:
187
+ return [
188
+ f"Could not read outcome data for '{var_name}' from shadow table."
189
+ ]
190
+
191
+ if not row_marginals:
192
+ return []
193
+
194
+ # Column marginals — group counts from the raw table
195
+ try:
196
+ cur = conn.execute(
197
+ f'SELECT "{group_col}", COUNT(*) AS cnt FROM {raw_table} '
198
+ f'WHERE "{group_col}" IS NOT NULL GROUP BY "{group_col}"'
199
+ )
200
+ col_marginals = [r["cnt"] for r in cur.fetchall()]
201
+ except Exception:
202
+ return [
203
+ f"Could not read group data for '{group_col}' from raw table."
204
+ ]
205
+
206
+ if not col_marginals:
207
+ return []
208
+
209
+ # Expected counts under independence
210
+ grand = sum(row_marginals)
211
+ min_expected = float("inf")
212
+ for r_cnt in row_marginals:
213
+ for c_cnt in col_marginals:
214
+ exp = r_cnt * c_cnt / grand
215
+ if exp < min_expected:
216
+ min_expected = exp
217
+
218
+ if min_expected < 5:
219
+ r, c = len(row_marginals), len(col_marginals)
220
+ if r == 2 and c == 2:
221
+ alt = "Consider using fishers_exact instead."
222
+ else:
223
+ alt = (
224
+ "Table is not 2×2; fishers_exact is not applicable. "
225
+ "Options: (1) Collapse outcome to binary (e.g., ORR: CR+PR vs rest) "
226
+ "and re-declare with fishers_exact; (2) Descriptive only "
227
+ "(matches small-N oncology practice); "
228
+ "(3) Ordinal test if proportional odds assumption is plausible."
229
+ )
230
+ return [
231
+ f"chi_square on '{var_name}': minimum expected cell count is "
232
+ f"{min_expected:.1f} (below 5 threshold for a "
233
+ f"{r}×{c} table). {alt}"
234
+ ]
235
+
236
+ return []
237
+
238
+
239
+ # FIXME: Add t-test normality check here.
240
+ # Must check normality using ONLY baseline/masked-permitted data —
241
+ # no outcome values should be cross-tabulated with treatment groups.
242
+ # Relevant test_names: "t_test", "paired_t_test"
243
+
244
+
245
+ @_register
246
+ class TTestAssumptionCheck(AssumptionCheck):
247
+ """Check normality of pooled outcome distribution for t-test plans.
248
+
249
+ Reads the marginal (ungrouped) outcome values from the shadow table and
250
+ runs Shapiro-Wilk. Warns if n < 30 *and* the normality test suggests
251
+ non-normality (p < 0.05). For n >= 30 the Central Limit Theorem provides
252
+ robustness — no warning regardless of the Shapiro-Wilk result.
253
+
254
+ IMPORTANT: this checks the *pooled* distribution only, never per-group.
255
+ Per-group normality is not checked pre-lock because it would reveal
256
+ structure about the association between the grouping variable and the
257
+ outcome, the same class of problem as the chi-square cross-tab leak.
258
+ """
259
+
260
+ def applies_to(self, test_name: str) -> bool:
261
+ return test_name in ("t_test", "paired_t_test")
262
+
263
+ def check(
264
+ self, study_id: str, test: dict, group_col: str, conn, var_info: dict
265
+ ) -> list[str]:
266
+ var_name = test.get("variable_name", "")
267
+ if not var_name:
268
+ return []
269
+ if var_info.get(var_name) != "continuous":
270
+ return []
271
+
272
+ shadow_table = f"raw_masked_{study_id}"
273
+
274
+ # Read marginal (pooled, ungrouped) outcome values from shadow table.
275
+ # No GROUP BY, no reference to group_col — just the raw column values.
276
+ try:
277
+ cur = conn.execute(
278
+ f'SELECT CAST("{var_name}" AS REAL) AS val '
279
+ f'FROM {shadow_table} '
280
+ f'WHERE "{var_name}" IS NOT NULL'
281
+ )
282
+ vals = [r["val"] for r in cur.fetchall() if r["val"] is not None]
283
+ except Exception:
284
+ return [
285
+ f"Could not read outcome data for '{var_name}' from shadow table."
286
+ ]
287
+
288
+ if not vals:
289
+ return []
290
+
291
+ n = len(vals)
292
+ # CLT robustness for n >= 30 — skip test, no warning
293
+ if n >= 30:
294
+ return []
295
+
296
+ from scipy.stats import shapiro as shapiro_test
297
+ try:
298
+ _, p = shapiro_test(vals)
299
+ except Exception:
300
+ return []
301
+
302
+ if p < 0.05:
303
+ return [
304
+ f"t_test on '{var_name}': pooled distribution may be non-normal "
305
+ f"(Shapiro-Wilk p={p:.4f}, n={n}). "
306
+ f"Consider mann_whitney instead."
307
+ ]
308
+
309
+ return []
310
+
311
+
312
+ @_register
313
+ class CoxPHAssumptionCheck(AssumptionCheck):
314
+ """Screen Cox plans using only marginal event information available pre-lock.
315
+
316
+ Computes events-per-variable (EPV) from the pooled event count and declared
317
+ covariates. Warns when EPV < 10 (common rule of thumb).
318
+
319
+ NOTE: The true proportional-hazards assumption (Schoenfeld residuals) cannot
320
+ be checked pre-lock — it requires the fitted Cox model, which needs unmasked,
321
+ grouped outcome data. This EPV check is a feasibility proxy only. The real
322
+ PH check runs post-unmask inside ``_cox_ph()``.
323
+ """
324
+
325
+ def applies_to(self, test_name: str) -> bool:
326
+ return test_name == "cox_proportional_hazards"
327
+
328
+ def check(self, study_id: str, test: dict, group_col: str, conn, var_info: dict) -> list[str]:
329
+ var_name = test.get("variable_name", "")
330
+ if var_info.get(var_name) != "time_to_event":
331
+ return []
332
+ prefix = var_name.replace("_days", "").replace("_months", "").replace("_time", "")
333
+ event_col = f"{prefix}_event"
334
+ shadow_table = f"raw_masked_{study_id}"
335
+
336
+ # Number of covariates declared in the plan (passed through from cmd_plan)
337
+ n_covariates = max(int(test.get("n_covariates", 0)), 0)
338
+ # +1 for the primary group variable (treatment_arm)
339
+ total_predictors = n_covariates + 1
340
+
341
+ try:
342
+ row = conn.execute(
343
+ f'SELECT COUNT("{event_col}") AS n_events '
344
+ f'FROM {shadow_table} '
345
+ f'WHERE CAST("{event_col}" AS INTEGER) = 1'
346
+ ).fetchone()
347
+ except Exception:
348
+ return [
349
+ f"cox_proportional_hazards on '{var_name}': "
350
+ "could not count events from shadow table."
351
+ ]
352
+
353
+ n_events = int(row["n_events"] or 0)
354
+ if n_events == 0:
355
+ return [
356
+ f"cox_proportional_hazards on '{var_name}': "
357
+ "zero events recorded in pooled data — Cox model cannot converge."
358
+ ]
359
+
360
+ epv = n_events / total_predictors if total_predictors > 0 else n_events
361
+ if epv < 10:
362
+ return [
363
+ f"cox_proportional_hazards on '{var_name}': "
364
+ f"{n_events} events across {total_predictors} predictor(s) "
365
+ f"(EPV={epv:.1f}). "
366
+ f"Cox models are unreliable below 10 events per predictor. "
367
+ f"Consider reducing the number of covariates or using a "
368
+ f"simpler analysis."
369
+ ]
370
+
371
+ return []
372
+
373
+
374
+ def check_cox_ph_model_assumptions(
375
+ study_id: str,
376
+ models: list,
377
+ group_col: str = "treatment_arm",
378
+ ) -> list[str]:
379
+ """Check assumptions for declared multivariable Cox PH models.
380
+
381
+ Parameters
382
+ ----------
383
+ study_id : str
384
+ models : list[CoxPHModel] or list[dict]
385
+ List of declared Cox PH models from the study plan.
386
+ group_col : str
387
+ Column name for the comparison groups (e.g. treatment_arm).
388
+
389
+ Returns
390
+ -------
391
+ list[str]
392
+ Warning strings — empty when no assumptions are violated.
393
+ """
394
+ if not models:
395
+ return []
396
+
397
+ conn = get_connection(study_id)
398
+
399
+ # Pre-fetch variable data types
400
+ cur = conn.execute(
401
+ "SELECT column_name, data_type FROM variables WHERE study_id=?",
402
+ (study_id,),
403
+ )
404
+ var_info = {r["column_name"]: r["data_type"] for r in cur.fetchall()}
405
+
406
+ warnings: list[str] = []
407
+
408
+ for model in models:
409
+ if isinstance(model, dict):
410
+ m = model
411
+ else:
412
+ m = {
413
+ "model_name": model.model_name,
414
+ "survival_time_col": model.survival_time_col,
415
+ "event_col": model.event_col,
416
+ "primary_treatment_col": model.primary_treatment_col,
417
+ "covariate_cols": model.covariate_cols,
418
+ "interaction_terms": list(model.interaction_terms) if hasattr(model, "interaction_terms") else [],
419
+ }
420
+
421
+ survival_time_col = m.get("survival_time_col", "")
422
+ event_col = m.get("event_col", "")
423
+ covariate_cols = m.get("covariate_cols", [])
424
+ interaction_terms = m.get("interaction_terms", [])
425
+
426
+ if not survival_time_col or not event_col:
427
+ continue
428
+
429
+ if var_info.get(survival_time_col) != "time_to_event":
430
+ continue
431
+
432
+ shadow_table = f"raw_masked_{study_id}"
433
+
434
+ # Total predictors = primary treatment + covariates + interaction terms
435
+ total_predictors = 1 + len(covariate_cols) + len(interaction_terms)
436
+
437
+ try:
438
+ row = conn.execute(
439
+ f'SELECT COUNT("{event_col}") AS n_events '
440
+ f'FROM {shadow_table} '
441
+ f'WHERE CAST("{event_col}" AS INTEGER) = 1'
442
+ ).fetchone()
443
+ except Exception:
444
+ warnings.append(
445
+ f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
446
+ "could not count events from shadow table."
447
+ )
448
+ continue
449
+
450
+ n_events = int(row["n_events"] or 0)
451
+ if n_events == 0:
452
+ warnings.append(
453
+ f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
454
+ "zero events recorded in pooled data — Cox model cannot converge."
455
+ )
456
+ continue
457
+
458
+ epv = n_events / total_predictors if total_predictors > 0 else n_events
459
+ if epv < 10:
460
+ warnings.append(
461
+ f"cox_ph_model '{m.get('model_name', 'unnamed')}': "
462
+ f"{n_events} events across {total_predictors} predictor(s) "
463
+ f"(EPV={epv:.1f}). "
464
+ f"Cox models are unreliable below 10 events per predictor. "
465
+ f"Consider reducing the number of covariates or using a "
466
+ f"simpler analysis."
467
+ )
468
+
469
+ conn.close()
470
+ return warnings
471
+
472
+
473
+ def check_assumptions(
474
+ study_id: str,
475
+ tests: list[dict],
476
+ group_col: str = "treatment_arm",
477
+ ) -> list[str]:
478
+ """Check pre-lock assumptions for each planned test.
479
+
480
+ Iterates the registered :class:`AssumptionCheck` implementations and
481
+ calls each one whose ``applies_to`` matches the test name. All checks
482
+ use only marginal / independently-derivable quantities — never a cross-
483
+ tabulation of outcome × group before the plan is locked.
484
+
485
+ Parameters
486
+ ----------
487
+ study_id : str
488
+ tests : list[dict]
489
+ Each dict must have at least ``test_name`` and ``variable_name`` keys.
490
+ group_col : str
491
+ Column name for the comparison groups (e.g. treatment_arm).
492
+
493
+ Returns
494
+ -------
495
+ list[str]
496
+ Warning strings — empty when no assumptions are violated.
497
+ """
498
+ conn = get_connection(study_id)
499
+
500
+ # Pre-fetch variable data types
501
+ cur = conn.execute(
502
+ "SELECT column_name, data_type FROM variables WHERE study_id=?",
503
+ (study_id,),
504
+ )
505
+ var_info = {r["column_name"]: r["data_type"] for r in cur.fetchall()}
506
+
507
+ warnings: list[str] = []
508
+
509
+ for test in tests:
510
+ test_name = test.get("test_name", "")
511
+ for checker in _CHECKERS:
512
+ if checker.applies_to(test_name):
513
+ warnings.extend(
514
+ checker.check(study_id, test, group_col, conn, var_info)
515
+ )
516
+
517
+ conn.close()
518
+ return warnings
File without changes
@@ -0,0 +1,38 @@
1
+ """Canonical hashing utilities for data integrity verification.
2
+
3
+ Single source of truth for raw-data hashing across cmd_export, bundle, and
4
+ excel_export. Fixes M2: data hash inconsistency between code paths.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+
12
+ from core.database import get_connection
13
+
14
+
15
+ def sha256(content: str | bytes) -> str:
16
+ """SHA-256 hex digest of a string or bytes object."""
17
+ if isinstance(content, str):
18
+ content = content.encode("utf-8")
19
+ return hashlib.sha256(content).hexdigest()
20
+
21
+
22
+ def canonical_json(obj) -> str:
23
+ """Deterministic JSON serialization: sorted keys, no extra whitespace."""
24
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
25
+
26
+
27
+ def compute_raw_data_hash(study_id: str) -> str:
28
+ """Compute SHA-256 hash of a study's raw data using canonical JSON.
29
+
30
+ Reads from the raw_<study_id> ingestion table, exports all rows as a
31
+ sorted-keys JSON array, and returns the hex digest.
32
+ """
33
+ conn = get_connection(study_id)
34
+ raw_table = f"raw_{study_id}"
35
+ cur = conn.execute(f"SELECT * FROM {raw_table} ORDER BY row_id")
36
+ rows = [dict(r) for r in cur.fetchall()]
37
+ conn.close()
38
+ return sha256(canonical_json(rows))
@@ -0,0 +1,105 @@
1
+ """Provenance tracker — maps every computed statistic to its source lineage.
2
+
3
+ Given a statistic (test_name, variable_ids), returns:
4
+ - source row IDs from raw data that contributed
5
+ - column names used
6
+ - the exact function name and parameters
7
+ - timestamp
8
+
9
+ Stored as JSON per analysis run. Queryable by statistic name or variable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import json
14
+ from dataclasses import dataclass, asdict, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from core.database import DATA_ROOT
19
+
20
+
21
+ @dataclass
22
+ class ProvenanceEntry:
23
+ function_name: str
24
+ parameters: dict
25
+ source_row_ids: list[int]
26
+ column_names: list[str]
27
+ test_name: str
28
+ result_id: str
29
+ study_id: str
30
+ computed_at: str = ""
31
+ is_pre_registered: bool = True
32
+
33
+ def __post_init__(self):
34
+ if not self.computed_at:
35
+ self.computed_at = datetime.now(timezone.utc).isoformat()
36
+
37
+
38
+ class ProvenanceTracker:
39
+ """Tracks provenance for a single study.
40
+
41
+ Usage:
42
+ tracker = ProvenanceTracker("study_id")
43
+ entry = ProvenanceEntry(...)
44
+ tracker.record(entry)
45
+ lineage = tracker.get_lineage(test_name="chi_square")
46
+ """
47
+
48
+ def __init__(self, study_id: str):
49
+ self.study_id = study_id
50
+ self._entries: list[ProvenanceEntry] = []
51
+ self._load()
52
+
53
+ def _path(self) -> Path:
54
+ return DATA_ROOT / self.study_id / "provenance.json"
55
+
56
+ def _load(self):
57
+ p = self._path()
58
+ if p.exists():
59
+ data = json.loads(p.read_text())
60
+ self._entries = [ProvenanceEntry(**e) for e in data]
61
+
62
+ def _save(self):
63
+ self._path().write_text(
64
+ json.dumps([asdict(e) for e in self._entries], indent=2)
65
+ )
66
+
67
+ def record(self, entry: ProvenanceEntry):
68
+ self._entries.append(entry)
69
+ self._save()
70
+
71
+ def record_run(
72
+ self,
73
+ function_name: str,
74
+ parameters: dict,
75
+ source_row_ids: list[int],
76
+ column_names: list[str],
77
+ test_name: str,
78
+ result_id: str,
79
+ is_pre_registered: bool = True,
80
+ ):
81
+ entry = ProvenanceEntry(
82
+ function_name=function_name,
83
+ parameters=parameters,
84
+ source_row_ids=source_row_ids,
85
+ column_names=column_names,
86
+ test_name=test_name,
87
+ result_id=result_id,
88
+ study_id=self.study_id,
89
+ is_pre_registered=is_pre_registered,
90
+ )
91
+ self.record(entry)
92
+
93
+ def get_lineage(
94
+ self,
95
+ test_name: str | None = None,
96
+ variable_id: int | None = None,
97
+ ) -> list[ProvenanceEntry]:
98
+ """Return matching provenance entries."""
99
+ results = self._entries
100
+ if test_name:
101
+ results = [e for e in results if e.test_name == test_name]
102
+ return results
103
+
104
+ def get_all(self) -> list[ProvenanceEntry]:
105
+ return list(self._entries)