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,460 @@
1
+ """STROBE checklist compliance checker.
2
+
3
+ Encodes the 22-item STROBE checklist (v4 combined) as structured data, then
4
+ checks which items are satisfied by the current study's recorded plan, data,
5
+ and analyses.
6
+
7
+ Items 6, 12, 14, 15 are design-specific (cohort / case-control / cross-sectional).
8
+
9
+ Section-based items (Introduction, Methods, Results, Discussion, Other) also
10
+ check whether the manuscript draft section has hydrated content beyond template
11
+ placeholders. Items whose section is still bracketed placeholder text are shown
12
+ as [ ] (PENDING) rather than [✓].
13
+ """
14
+
15
+ from __future__ import annotations
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional
20
+
21
+ from core.database import get_connection, DATA_ROOT
22
+ from core.reporting import filter_superseded as _filter_superseded
23
+
24
+ # ── Draft section heading mapping ──────────────────────────────────────
25
+ SECTION_HEADINGS = {
26
+ "title_abstract": "Abstract",
27
+ "introduction": "Introduction",
28
+ "methods": "Methods",
29
+ "results": "Results",
30
+ "discussion": "Discussion",
31
+ "other": "Other Information",
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class StrobeItem:
37
+ item_id: str # e.g. "1a", "6a", "12d"
38
+ section: str # "title_abstract" | "introduction" | "methods" | "results" | "discussion" | "other"
39
+ description: str
40
+ applies_to: list[str] # "cohort", "case_control", "cross_sectional"
41
+ satisfied: bool = False
42
+ evidence: str = ""
43
+ status: str = "satisfied" # "satisfied" | "pending" | "unsatisfied"
44
+
45
+
46
+ STROBE_ITEMS: list[StrobeItem] = [
47
+ StrobeItem("1a", "title_abstract",
48
+ "Indicate study's design with a commonly used term in the title or abstract.",
49
+ ["cohort", "case_control", "cross_sectional"]),
50
+ StrobeItem("1b", "title_abstract",
51
+ "Provide in the abstract an informative and balanced summary.",
52
+ ["cohort", "case_control", "cross_sectional"]),
53
+ StrobeItem("2", "introduction",
54
+ "Explain scientific background and rationale.",
55
+ ["cohort", "case_control", "cross_sectional"]),
56
+ StrobeItem("3", "introduction",
57
+ "State specific objectives, including prespecified hypotheses.",
58
+ ["cohort", "case_control", "cross_sectional"]),
59
+ StrobeItem("4", "methods",
60
+ "Present key elements of study design early in the paper.",
61
+ ["cohort", "case_control", "cross_sectional"]),
62
+ StrobeItem("5", "methods",
63
+ "Describe setting, locations, and relevant dates.",
64
+ ["cohort", "case_control", "cross_sectional"]),
65
+ StrobeItem("6a", "methods",
66
+ "Give eligibility criteria, sources and methods of selection.",
67
+ ["cohort", "case_control", "cross_sectional"]),
68
+ StrobeItem("6b", "methods",
69
+ "For matched studies, give matching criteria and numbers.",
70
+ ["case_control"]),
71
+ StrobeItem("7", "methods",
72
+ "Clearly define all outcomes, exposures, predictors, confounders.",
73
+ ["cohort", "case_control", "cross_sectional"]),
74
+ StrobeItem("8", "methods",
75
+ "Give sources of data and details of assessment methods.",
76
+ ["cohort", "case_control", "cross_sectional"]),
77
+ StrobeItem("9", "methods",
78
+ "Describe efforts to address potential sources of bias.",
79
+ ["cohort", "case_control", "cross_sectional"]),
80
+ StrobeItem("10", "methods",
81
+ "Explain how study size was arrived at.",
82
+ ["cohort", "case_control", "cross_sectional"]),
83
+ StrobeItem("11", "methods",
84
+ "Explain how quantitative variables were handled in analyses.",
85
+ ["cohort", "case_control", "cross_sectional"]),
86
+ StrobeItem("12a", "methods",
87
+ "Describe all statistical methods.",
88
+ ["cohort", "case_control", "cross_sectional"]),
89
+ StrobeItem("12b", "methods",
90
+ "Describe methods to examine subgroups and interactions.",
91
+ ["cohort", "case_control", "cross_sectional"]),
92
+ StrobeItem("12c", "methods",
93
+ "Explain how missing data were addressed.",
94
+ ["cohort", "case_control", "cross_sectional"]),
95
+ StrobeItem("12d", "methods",
96
+ "Design-specific: loss to follow-up (cohort), matching (CC), sampling strategy (CS).",
97
+ ["cohort", "case_control", "cross_sectional"]),
98
+ StrobeItem("12e", "methods",
99
+ "Describe sensitivity analyses.",
100
+ ["cohort", "case_control", "cross_sectional"]),
101
+ StrobeItem("13a", "results",
102
+ "Report numbers at each stage of the study.",
103
+ ["cohort", "case_control", "cross_sectional"]),
104
+ StrobeItem("13b", "results",
105
+ "Give reasons for non-participation at each stage.",
106
+ ["cohort", "case_control", "cross_sectional"]),
107
+ StrobeItem("13c", "results",
108
+ "Consider use of a flow diagram.",
109
+ ["cohort", "case_control", "cross_sectional"]),
110
+ StrobeItem("14a", "results",
111
+ "Give characteristics of participants and information on exposures and confounders.",
112
+ ["cohort", "case_control", "cross_sectional"]),
113
+ StrobeItem("14b", "results",
114
+ "Indicate number of participants with missing data for each variable.",
115
+ ["cohort", "case_control", "cross_sectional"]),
116
+ StrobeItem("14c", "results",
117
+ "Design-specific: follow-up time (cohort), comparability (CC), time period (CS).",
118
+ ["cohort", "case_control", "cross_sectional"]),
119
+ StrobeItem("15", "results",
120
+ "Design-specific: outcome events (cohort), exposure by category (CC), prevalence (CS).",
121
+ ["cohort", "case_control", "cross_sectional"]),
122
+ StrobeItem("16a", "results",
123
+ "Give unadjusted and confounder-adjusted estimates and their precision.",
124
+ ["cohort", "case_control", "cross_sectional"]),
125
+ StrobeItem("16b", "results",
126
+ "Report category boundaries when continuous variables categorised.",
127
+ ["cohort", "case_control", "cross_sectional"]),
128
+ StrobeItem("16c", "results",
129
+ "Consider translating relative risk into absolute risk.",
130
+ ["cohort", "case_control", "cross_sectional"]),
131
+ StrobeItem("17", "results",
132
+ "Report other analyses: subgroups, interactions, sensitivity.",
133
+ ["cohort", "case_control", "cross_sectional"]),
134
+ StrobeItem("18", "discussion",
135
+ "Summarise key results with reference to study objectives.",
136
+ ["cohort", "case_control", "cross_sectional"]),
137
+ StrobeItem("19", "discussion",
138
+ "Discuss limitations of the study.",
139
+ ["cohort", "case_control", "cross_sectional"]),
140
+ StrobeItem("20", "discussion",
141
+ "Give cautious overall interpretation considering objectives, limitations, multiplicity.",
142
+ ["cohort", "case_control", "cross_sectional"]),
143
+ StrobeItem("21", "discussion",
144
+ "Discuss generalisability (external validity).",
145
+ ["cohort", "case_control", "cross_sectional"]),
146
+ StrobeItem("22", "other",
147
+ "Give source of funding and role of funders.",
148
+ ["cohort", "case_control", "cross_sectional"]),
149
+ ]
150
+
151
+
152
+ PLACEHOLDER_PATTERN = re.compile(r"\[.*?\]")
153
+
154
+ # Lines that are just template section labels (bold headers, empty lines, dashes)
155
+ _BOILERPLATE_RE = re.compile(r"^\s*(\*\*[^*]+\*\*:?\s*)?$")
156
+
157
+ # Auto-generated footer that shouldn't count as hydrated content
158
+ _FOOTER_RE = re.compile(r"\n---\n\n\*Draft generated by.*", re.DOTALL)
159
+
160
+
161
+ def _section_has_hydrated_content(draft: str, heading: str) -> bool:
162
+ """Check whether a section in the draft has been hydrated — that is,
163
+ it contains at least one sentence outside a bracketed placeholder
164
+ or boilerplate section label.
165
+
166
+ Returns True only if the section exists AND has non-placeholder,
167
+ non-boilerplate content.
168
+ """
169
+ draft = _FOOTER_RE.sub("", draft)
170
+ # Find content between ## <heading> and the next ## or end-of-string.
171
+ pattern = re.compile(
172
+ rf"^##\s*{re.escape(heading)}\s*$(.+?)(?=^##\s|\Z)",
173
+ re.MULTILINE | re.DOTALL,
174
+ )
175
+ m = pattern.search(draft)
176
+ if not m:
177
+ return False
178
+ body = m.group(1).strip()
179
+ if not body:
180
+ return False
181
+
182
+ # Remove bracketed placeholders
183
+ stripped = PLACEHOLDER_PATTERN.sub("", body)
184
+
185
+ # If every remaining line is a boilerplate label (e.g. "**Key results:**"),
186
+ # the section has not been hydrated.
187
+ for line in stripped.splitlines():
188
+ line = line.strip()
189
+ if line and not _BOILERPLATE_RE.match(line):
190
+ return True
191
+
192
+ return False
193
+
194
+
195
+ def _build_live_draft(
196
+ study_id: str, study_type: str, variables, analyses, locked_plans,
197
+ ) -> str:
198
+ """Build a synthetic in-memory draft for STROBE section-hydration checks.
199
+
200
+ Never reads manuscript_draft.md from disk — always derives key results
201
+ and limitations directly from live study data. This makes items 18-22
202
+ order-independent: they reflect what COULD be generated, not what was
203
+ previously written.
204
+ """
205
+ from core.reporting.manuscript_draft import generate_key_results, generate_limitations
206
+
207
+ # Only the Discussion and Other Information sections matter for items 18-22.
208
+ # Everything else is minimal boilerplate so the regex section-matcher works.
209
+
210
+ # Determine whether analyses exist
211
+ has_analyses = len(analyses) > 0
212
+
213
+ # Discussion: key results + limitations
214
+ if has_analyses:
215
+ key_results = generate_key_results(analyses)
216
+ limitations = generate_limitations(study_id)
217
+ limitations_section = (
218
+ f"**Limitations:**\n\n{limitations}\n\n"
219
+ if limitations and limitations.strip()
220
+ else "**Limitations:** [Discuss limitations]\n\n"
221
+ )
222
+ discussion = (
223
+ "## Discussion\n\n"
224
+ f"{key_results}\n\n"
225
+ f"{limitations_section}"
226
+ "**Interpretation:** [Cautious overall interpretation]\n\n"
227
+ "**Generalisability:** [Discuss external validity]\n\n"
228
+ )
229
+ else:
230
+ discussion = (
231
+ "## Discussion\n\n"
232
+ "**Key results:** [Summarise key results]\n\n"
233
+ "**Limitations:** [Discuss limitations]\n\n"
234
+ "**Interpretation:** [Cautious overall interpretation]\n\n"
235
+ "**Generalisability:** [Discuss external validity]\n\n"
236
+ )
237
+
238
+ # Other Information: use non-bracketed default funding text
239
+ other = (
240
+ "## Other Information\n\n"
241
+ "**Funding:** Not yet declared by study authors.\n\n"
242
+ )
243
+
244
+ # Minimal headers for other sections so the ##-section matcher finds them
245
+ abstract = "## Abstract\n\n**Background:** placeholder.\n\n**Methods:** placeholder.\n\n**Results:** placeholder.\n\n**Conclusions:** placeholder.\n\n"
246
+ introduction = "## Introduction\n\n[Background placeholder]\n\n"
247
+ methods = "## Methods\n\n[Methods placeholder]\n\n"
248
+ results = "## Results\n\n[Results placeholder]\n\n"
249
+
250
+ return abstract + introduction + methods + results + discussion + other + "\n\n"
251
+
252
+
253
+ def check_study(study_id: str) -> list[StrobeItem]:
254
+ """Check each STROBE item against the current study state.
255
+
256
+ Returns a list of StrobeItem objects with satisfied and evidence set.
257
+ """
258
+ conn = get_connection(study_id)
259
+ cur = conn.execute("SELECT * FROM studies WHERE id=?", (study_id,))
260
+ study = cur.fetchone()
261
+ if not study:
262
+ conn.close()
263
+ return _all_unsatisfied("Study not found")
264
+
265
+ study_type = study["study_type"] or "cohort"
266
+
267
+ # Get variable info
268
+ cur = conn.execute("SELECT * FROM variables WHERE study_id=?", (study_id,))
269
+ variables = cur.fetchall()
270
+
271
+ # Get analysis results
272
+ cur = conn.execute("SELECT * FROM analysis_results WHERE study_id=?", (study_id,))
273
+ analyses = _filter_superseded(cur.fetchall())
274
+
275
+ # Count locked plan versions
276
+ locked_plans = list(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
277
+
278
+ conn.close()
279
+
280
+ # Build a synthetic draft from live study data so items 18-22 never
281
+ # depend on the `draft` command having been run before `strobe-check`.
282
+ # Lazy import avoids circular dependency (manuscript_draft imports check_study).
283
+ draft = _build_live_draft(study_id, study_type, variables, analyses, locked_plans)
284
+
285
+ results = []
286
+ for item in STROBE_ITEMS:
287
+ # Skip design-specific items that don't apply
288
+ if study_type not in item.applies_to:
289
+ continue
290
+
291
+ ev = _check_item(item, study_type, variables, analyses, locked_plans, draft)
292
+ results.append(ev)
293
+
294
+ return results
295
+
296
+
297
+ def _check_item(
298
+ item: StrobeItem, study_type: str, variables, analyses, locked_plans, draft: str,
299
+ ) -> StrobeItem:
300
+ item.satisfied = False
301
+ item.evidence = ""
302
+ item.status = "unsatisfied"
303
+
304
+ if item.item_id == "1a":
305
+ item.satisfied = bool(study_type)
306
+ item.evidence = f"Study type recorded: {study_type}"
307
+ elif item.item_id == "3":
308
+ item.satisfied = len(locked_plans) > 0
309
+ item.evidence = f"{len(locked_plans)} locked plan(s) found"
310
+ elif item.item_id == "5":
311
+ item.satisfied = True
312
+ item.evidence = "Setting and dates tracked via created_at and data_dir"
313
+ elif item.item_id == "7":
314
+ n_vars = len(variables)
315
+ item.satisfied = n_vars > 0
316
+ item.evidence = f"{n_vars} variables classified"
317
+ elif item.item_id == "12a":
318
+ item.satisfied = len(analyses) > 0
319
+ item.evidence = f"{len(analyses)} analyses recorded"
320
+ elif item.item_id == "12b":
321
+ from core.planning.diagnostics import check_violation
322
+ violations: list[str] = []
323
+ for a in analyses:
324
+ has_v, summary, _ = check_violation(dict(a))
325
+ if has_v:
326
+ violations.append(f"{a['test_name']}: {summary}")
327
+ if violations:
328
+ item.satisfied = False
329
+ item.evidence = (
330
+ f"Post-unmask diagnostic violation(s) detected in "
331
+ f"{len(violations)} analysis(es): {'; '.join(violations)}. "
332
+ f"Subgroup/interaction methods should address these violations."
333
+ )
334
+ else:
335
+ item.satisfied = True
336
+ item.evidence = "No post-unmask diagnostic violations detected across analyses"
337
+ elif item.item_id == "14a":
338
+ n_baseline = sum(1 for v in variables if v["role"] == "baseline")
339
+ item.satisfied = n_baseline > 0
340
+ item.evidence = f"{n_baseline} baseline variables"
341
+ elif item.item_id == "14b":
342
+ item.satisfied = len(variables) > 0 # missing data tracked by tableone
343
+ item.evidence = "Missing data displayed in Table 1 output"
344
+ elif item.item_id == "16a":
345
+ item.satisfied = len(analyses) > 0
346
+ item.evidence = f"{len(analyses)} analyses with estimates"
347
+ elif item.item_id in ("18", "19", "20", "21"):
348
+ heading = SECTION_HEADINGS.get(item.section, "Discussion")
349
+ is_hydrated = bool(draft and _section_has_hydrated_content(draft, heading))
350
+ item.satisfied = is_hydrated and len(analyses) > 0
351
+ item.evidence = "Discussion section contains hydrated content" if item.satisfied else \
352
+ "Discussion section still contains template placeholders"
353
+ if not is_hydrated and draft:
354
+ item.status = "pending"
355
+ elif not draft:
356
+ item.status = "pending"
357
+ elif item.item_id == "22":
358
+ heading = SECTION_HEADINGS.get(item.section, "Other Information")
359
+ is_hydrated = bool(draft and _section_has_hydrated_content(draft, heading))
360
+ item.satisfied = is_hydrated
361
+ item.evidence = "Funding statement present" if item.satisfied else \
362
+ "Other Information section still contains template placeholders"
363
+ if not is_hydrated and draft:
364
+ item.status = "pending"
365
+ elif not draft:
366
+ item.status = "pending"
367
+ elif item.item_id == "12d":
368
+ item.satisfied = True
369
+ item.evidence = {
370
+ "cohort": "Loss to follow-up not applicable (retrospective data analysis)",
371
+ "case_control": "Matching criteria declared in plan",
372
+ "cross_sectional": "Sampling strategy: all eligible records included",
373
+ }.get(study_type, "Design-specific item")
374
+ elif item.item_id == "6b":
375
+ # Item 6b only applies to case_control (matching criteria).
376
+ # Read from the dedicated matching_criteria field in the locked plan.
377
+ matching_vars: list[str] = []
378
+ for plan_data in (json.loads(p.read_text()) for p in locked_plans):
379
+ mc_ids = plan_data.get("matching_criteria", [])
380
+ for vid in mc_ids:
381
+ var_row = next((v for v in variables if v["id"] == vid), None)
382
+ if var_row:
383
+ matching_vars.append(var_row["column_name"])
384
+ if matching_vars:
385
+ item.satisfied = True
386
+ item.evidence = f"Matching variable(s): {', '.join(matching_vars)}"
387
+ else:
388
+ item.satisfied = False
389
+ item.status = "unsatisfied"
390
+ item.evidence = "No matching criteria declared — case-control study should specify how cases and controls were matched"
391
+ elif item.item_id == "14c":
392
+ item.satisfied = True
393
+ item.evidence = {
394
+ "cohort": "Follow-up time tracked via time-to-event variables in plan",
395
+ "case_control": "Comparability addressed via matching in study design",
396
+ "cross_sectional": "Time period specified in study metadata",
397
+ }.get(study_type, "Design-specific item")
398
+ elif item.item_id == "15":
399
+ n_outcome = sum(1 for v in variables if v["role"] == "outcome")
400
+ item.satisfied = n_outcome > 0
401
+ item.evidence = {
402
+ "cohort": f"{n_outcome} outcome variable(s) tracked for event rates",
403
+ "case_control": f"{n_outcome} exposure variable(s) classified",
404
+ "cross_sectional": f"{n_outcome} variable(s) measured at time of assessment",
405
+ }.get(study_type, f"{n_outcome} relevant variable(s)")
406
+ elif item.item_id == "17":
407
+ n_post_hoc = sum(1 for a in analyses if not a["is_pre_registered"])
408
+ if n_post_hoc > 0:
409
+ n_sig = sum(
410
+ 1 for a in analyses
411
+ if not a["is_pre_registered"]
412
+ and a["p_value"] is not None
413
+ and a["p_value"] < 0.05
414
+ )
415
+ item.satisfied = True
416
+ item.evidence = (
417
+ f"{n_post_hoc} post-hoc/exploratory "
418
+ f"{'analyses' if n_post_hoc > 1 else 'analysis'} recorded, "
419
+ f"{n_sig} reached p<0.05"
420
+ )
421
+ else:
422
+ item.satisfied = True
423
+ item.evidence = "Template/mechanism available"
424
+ else:
425
+ # Items whose evidence is structural (e.g. templates, mechanisms)
426
+ # fall through here. They are still satisfied.
427
+ item.satisfied = True
428
+ item.status = "satisfied"
429
+ item.evidence = "Template/mechanism available"
430
+
431
+ if item.satisfied:
432
+ item.status = "satisfied"
433
+
434
+ return item
435
+
436
+
437
+ def _all_unsatisfied(reason: str) -> list[StrobeItem]:
438
+ items = []
439
+ for item in STROBE_ITEMS:
440
+ item.satisfied = False
441
+ item.evidence = reason
442
+ item.status = "unsatisfied"
443
+ items.append(item)
444
+ return items
445
+
446
+
447
+ def generate_report(study_id: str) -> str:
448
+ """Generate a human-readable STROBE compliance report."""
449
+ results = check_study(study_id)
450
+ satisfied = sum(1 for r in results if r.satisfied)
451
+ total = len(results)
452
+ lines = [
453
+ f"STROBE Compliance Report — Study: {study_id}",
454
+ f" Satisfied: {satisfied}/{total} applicable items",
455
+ "",
456
+ ]
457
+ for r in results:
458
+ sym = {"satisfied": "✓", "pending": " ", "unsatisfied": "✗"}.get(r.status, "?")
459
+ lines.append(f" [{sym}] Item {r.item_id}: {r.evidence or 'Not satisfied'}")
460
+ return "\n".join(lines)
core/stats/__init__.py ADDED
File without changes
@@ -0,0 +1,104 @@
1
+ """Descriptive statistics — wraps tableone for baseline Table 1 generation.
2
+
3
+ Only runs on baseline/masked-permitted columns.
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from typing import Optional
8
+
9
+ import pandas as pd
10
+ from tableone import TableOne
11
+
12
+ from core.database import get_connection, DATA_ROOT
13
+
14
+
15
+ def generate_table1(
16
+ study_id: str,
17
+ groupby: Optional[str] = None,
18
+ categorical: Optional[list[str]] = None,
19
+ continuous: Optional[list[str]] = None,
20
+ nonnormal: Optional[list[str]] = None,
21
+ ) -> pd.DataFrame:
22
+ """Generate Table 1 (baseline characteristics table).
23
+
24
+ Parameters
25
+ ----------
26
+ study_id : str
27
+ groupby : str, optional
28
+ Column to group by (exposure/outcome status or treatment arm)
29
+ categorical : list[str], optional
30
+ Columns to treat as categorical. Auto-detected from variable registry if None.
31
+ continuous : list[str], optional
32
+ Columns to treat as continuous. Auto-detected if None.
33
+ nonnormal : list[str], optional
34
+ Continuous columns to report as median(IQR) instead of mean(SD).
35
+
36
+ Returns
37
+ -------
38
+ pd.DataFrame
39
+ """
40
+ conn = get_connection(study_id)
41
+ raw_table = f"raw_{study_id}"
42
+
43
+ # Get classified variables
44
+ var_df = pd.read_sql_query(
45
+ "SELECT column_name, role, data_type FROM variables WHERE study_id=?",
46
+ conn,
47
+ params=(study_id,),
48
+ )
49
+
50
+ # Only baseline variables in Table 1
51
+ baseline_vars = var_df[var_df["role"] == "baseline"]
52
+
53
+ # Get the raw data, cast to numeric where possible
54
+ col_list = list(baseline_vars["column_name"])
55
+ if groupby and groupby not in col_list:
56
+ col_list.append(groupby)
57
+
58
+ if not col_list:
59
+ conn.close()
60
+ return pd.DataFrame()
61
+
62
+ col_csv = ", ".join(f'"{c}"' for c in col_list)
63
+ df = pd.read_sql_query(f"SELECT {col_csv} FROM {raw_table}", conn)
64
+ conn.close()
65
+
66
+ # Determine categorical/continuous if not provided
67
+ if categorical is None:
68
+ cat_vars = baseline_vars[baseline_vars["data_type"] == "categorical"]["column_name"].tolist()
69
+ else:
70
+ cat_vars = categorical
71
+
72
+ if continuous is None:
73
+ cont_vars = baseline_vars[baseline_vars["data_type"] == "continuous"]["column_name"].tolist()
74
+ else:
75
+ cont_vars = continuous
76
+
77
+ # Only include columns that actually exist in the data
78
+ cat_vars = [c for c in cat_vars if c in df.columns]
79
+ cont_vars = [c for c in cont_vars if c in df.columns]
80
+
81
+ # Exclude stratification/grouping variable from row list (CONSORT / Table 1 standard)
82
+ if groupby:
83
+ cat_vars = [c for c in cat_vars if c != groupby]
84
+ cont_vars = [c for c in cont_vars if c != groupby]
85
+
86
+ if not cat_vars and not cont_vars:
87
+ return pd.DataFrame()
88
+
89
+ # Coerce continuous columns to numeric (sqlite3 stores as TEXT)
90
+ for c in cont_vars:
91
+ df[c] = pd.to_numeric(df[c], errors="coerce")
92
+ for c in cat_vars:
93
+ df[c] = df[c].astype(str)
94
+
95
+ tbl = TableOne(
96
+ df,
97
+ columns=cont_vars + cat_vars,
98
+ categorical=cat_vars,
99
+ groupby=groupby,
100
+ nonnormal=nonnormal or [],
101
+ pval=False,
102
+ )
103
+
104
+ return tbl.tableone