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,556 @@
1
+ """Integration test for the N=21 benchmark myeloma EMD study.
2
+
3
+ This test locks down the complete end-to-end pipeline for the 21-patient
4
+ synthetic myeloma cohort. It validates:
5
+ - Full pipeline execution (ingest -> classify -> plan -> lock -> unmask -> analyze -> draft -> bundle)
6
+ - Chi-square assumption warning for sparse 6x2 contingency tables
7
+ - Fisher's exact test hard-rejects non-2x2 tables (no silent rxc substitution)
8
+ - KM plot risk table row order matches legend order (Arm A top, Arm B bottom)
9
+ - Bundle creation and hash verification pass with matching SHA-256 composites
10
+
11
+ The study uses fixed marginals matching a published myeloma EMD paper:
12
+ - Sex: 14 M / 7 F
13
+ - High-Risk FISH: 9 yes / 12 no
14
+ - Response: 2 CR, 8 PR, 2 MR, 3 SD, 4 PD, 2 Unknown
15
+ - PFS median ~120 days, OS median ~365 days
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import io
22
+ import json
23
+ import math
24
+ import shutil
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ from core.database import get_connection, init_db, DATA_ROOT
31
+ from core.ingestion.csv_loader import load_file
32
+ from core.ingestion.variable_classifier import classify_variables_interactive, _classify_batch
33
+ from core.masking.gate import seal_outcomes, unmask_study, is_masked
34
+ from core.planning.study_plan import StudyPlan
35
+ from core.planning.lock import lock_plan
36
+ from core.planning.test_selector import check_assumptions
37
+ from core.stats.inferential import _fishers_exact, run_test
38
+ from core.reporting.strobe_checklist import generate_report
39
+ from core.reporting.manuscript_draft import write_draft
40
+ from core.reporting.plots import generate_km_plot
41
+ from core.reporting.bundle import create_bundle, verify_bundle
42
+
43
+
44
+ BENCHMARK_STUDY_NAME = "Synthetic EMD 21 Patient Validation"
45
+ BENCHMARK_STUDY_ID = "b7a49fc2a42d46deaf5c2f81471dd7ae"
46
+
47
+
48
+ def _generate_benchmark_csv() -> str:
49
+ """Generate the exact 21-row synthetic CSV with fixed marginals."""
50
+ import random
51
+ random.seed(42)
52
+
53
+ n = 21
54
+ sex_vals = ["M"] * 14 + ["F"] * 7
55
+ fish_vals = ["yes"] * 9 + ["no"] * 12
56
+ resp_vals = ["CR"] * 2 + ["PR"] * 8 + ["MR"] * 2 + ["SD"] * 3 + ["PD"] * 4 + ["Unknown"] * 2
57
+
58
+ random.shuffle(sex_vals)
59
+ random.shuffle(fish_vals)
60
+ random.shuffle(resp_vals)
61
+
62
+ arms = ["A"] * 10 + ["B"] * 11
63
+ random.shuffle(arms)
64
+
65
+ iss_vals = ["I"] * 7 + ["II"] * 8 + ["III"] * 6
66
+ random.shuffle(iss_vals)
67
+
68
+ prior_vals = [random.randint(0, 4) for _ in range(n)]
69
+ ages = [random.randint(40, 85) for _ in range(n)]
70
+
71
+ pfs_lambda = math.log(2) / 120
72
+ pfs_vals = [max(30, int(random.expovariate(pfs_lambda))) for _ in range(n)]
73
+ pfs_event = []
74
+ for r in resp_vals:
75
+ if r in ("PD", "SD"):
76
+ pfs_event.append(1 if random.random() < 0.8 else 0)
77
+ elif r == "Unknown":
78
+ pfs_event.append(1 if random.random() < 0.5 else 0)
79
+ else:
80
+ pfs_event.append(1 if random.random() < 0.4 else 0)
81
+
82
+ os_lambda = math.log(2) / 365
83
+ os_vals = [max(30, int(random.expovariate(os_lambda))) for _ in range(n)]
84
+ os_event = []
85
+ for i in range(n):
86
+ if pfs_event[i] == 1:
87
+ os_event.append(1 if random.random() < 0.6 else 0)
88
+ else:
89
+ os_event.append(1 if random.random() < 0.3 else 0)
90
+
91
+ rows = [
92
+ ["patient_id", "age", "sex", "iss_stage", "prior_lines",
93
+ "high_risk_fish", "treatment_arm", "response_category",
94
+ "pfs_days", "pfs_event", "os_days", "os_event"]
95
+ ]
96
+ for i in range(n):
97
+ rows.append([
98
+ f"EMD_{i+1:03d}", ages[i], sex_vals[i], iss_vals[i], prior_vals[i],
99
+ fish_vals[i], arms[i], resp_vals[i], pfs_vals[i], pfs_event[i],
100
+ os_vals[i], os_event[i]
101
+ ])
102
+
103
+ buf = io.StringIO()
104
+ w = csv.writer(buf)
105
+ w.writerows(rows)
106
+ return buf.getvalue()
107
+
108
+
109
+ @pytest.fixture(scope="module")
110
+ def benchmark_study():
111
+ """Create the benchmark study once per test module.
112
+
113
+ Returns:
114
+ tuple: (study_id, km_test_id) where km_test_id is the analysis_results
115
+ row ID for the Kaplan-Meier log-rank test.
116
+ """
117
+ study_id = BENCHMARK_STUDY_ID
118
+
119
+ # Clean any existing study
120
+ p = DATA_ROOT / study_id
121
+ if p.exists():
122
+ shutil.rmtree(p)
123
+
124
+ # Create study
125
+ conn = get_connection(study_id)
126
+ init_db(conn)
127
+ conn.execute(
128
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
129
+ "VALUES (?, ?, ?, ?, ?, ?)",
130
+ (study_id, BENCHMARK_STUDY_NAME, "2025-06-01T00:00:00",
131
+ str(DATA_ROOT / study_id), "cohort", 0),
132
+ )
133
+ conn.commit()
134
+ conn.close()
135
+
136
+ # Ingest CSV
137
+ csv_content = _generate_benchmark_csv()
138
+ tmp = Path(tempfile.mkstemp(suffix=".csv")[1])
139
+ tmp.write_text(csv_content)
140
+ load_file(study_id, str(tmp))
141
+ tmp.unlink()
142
+
143
+ # Classify variables
144
+ suggestions = classify_variables_interactive(study_id, [
145
+ "age", "sex", "iss_stage", "prior_lines", "high_risk_fish",
146
+ "treatment_arm", "response_category", "pfs_days", "pfs_event",
147
+ "os_days", "os_event"
148
+ ])
149
+ _classify_batch(study_id, suggestions)
150
+ seal_outcomes(study_id)
151
+
152
+ # Lock plan (with assumption checks like CLI does)
153
+ plan = StudyPlan(
154
+ study_id=study_id,
155
+ study_type="cohort",
156
+ primary_comparison="PFS and response by treatment arm",
157
+ primary_outcome_variable_ids=[7, 8], # response_category, pfs_days
158
+ planned_tests=[
159
+ {"variable_id": 7, "variable_name": "response_category",
160
+ "test_name": "chi_square",
161
+ "rationale": "Compare response distribution between arms"},
162
+ {"variable_id": 8, "variable_name": "pfs_days",
163
+ "test_name": "kaplan_meier_logrank",
164
+ "rationale": "Compare PFS between treatment arms"},
165
+ ],
166
+ covariates=[1, 3, 5], # age, iss_stage, high_risk_fish
167
+ )
168
+ warnings = check_assumptions(study_id, plan.planned_tests)
169
+ # Map warnings to test variable names (like CLI does)
170
+ warning_map = {}
171
+ for test in plan.planned_tests:
172
+ var_name = test.get("variable_name", "")
173
+ for w in warnings:
174
+ if var_name in w:
175
+ warning_map[var_name] = w
176
+ plan.warnings = warning_map
177
+ lock_plan(study_id, plan)
178
+
179
+ # Unmask
180
+ unmask_study(study_id)
181
+
182
+ # Run analyze (store results in DB)
183
+ conn = get_connection(study_id)
184
+ import pandas as pd
185
+ df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
186
+ conn.close()
187
+
188
+ from datetime import datetime, timezone
189
+ now = datetime.now(timezone.utc).isoformat()
190
+ conn = get_connection(study_id)
191
+ init_db(conn)
192
+
193
+ km_test_id = None
194
+ for t in plan.planned_tests:
195
+ var_name = t.get("variable_name", "")
196
+ test_name = t.get("test_name", "")
197
+ kwargs = {"outcome_col": var_name, "group_col": "treatment_arm"}
198
+ if test_name == "kaplan_meier_logrank":
199
+ kwargs["time_col"] = "pfs_days"
200
+ kwargs["event_col"] = "pfs_event"
201
+ result = run_test(test_name, df, **kwargs)
202
+
203
+ # Determine status (like CLI does)
204
+ if test_name == "chi_square" and var_name in plan.warnings:
205
+ status_record = {"status": "skipped_assumption_violation", "reason": plan.warnings[var_name]}
206
+ else:
207
+ status_record = {"status": "completed"}
208
+
209
+ conn.execute(
210
+ "INSERT INTO analysis_results "
211
+ "(study_id, study_plan_version, variable_ids_used, test_name, "
212
+ "statistic, p_value, adjusted_p_value, ci_lower, ci_upper, "
213
+ "is_pre_registered, provenance_json, computed_at, status_json) "
214
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
215
+ (study_id, plan.version, json.dumps([]),
216
+ result["test_name"], result["statistic"], result["p_value"],
217
+ result.get("adjusted_p_value"), result.get("ci_lower"), result.get("ci_upper"),
218
+ json.dumps({"plan_version": plan.version}), now,
219
+ json.dumps(status_record)),
220
+ )
221
+ if test_name == "kaplan_meier_logrank":
222
+ km_test_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
223
+ conn.commit()
224
+ conn.close()
225
+
226
+ yield study_id, km_test_id
227
+
228
+ # Cleanup
229
+ if p.exists():
230
+ shutil.rmtree(p)
231
+
232
+
233
+ class TestBenchmarkPipeline:
234
+ """Full pipeline execution tests."""
235
+
236
+ def test_pipeline_ingest_classify(self, benchmark_study):
237
+ """Verify ingest and classification produced expected columns and roles."""
238
+ study_id, _ = benchmark_study
239
+ conn = get_connection(study_id)
240
+ cur = conn.execute(
241
+ "SELECT column_name, role, data_type FROM variables WHERE study_id=? ORDER BY id",
242
+ (study_id,)
243
+ )
244
+ vars_info = {r["column_name"]: {"role": r["role"], "dtype": r["data_type"]}
245
+ for r in cur.fetchall()}
246
+ conn.close()
247
+
248
+ # Baseline variables
249
+ assert vars_info["age"]["role"] == "baseline"
250
+ assert vars_info["age"]["dtype"] == "continuous"
251
+ assert vars_info["sex"]["role"] == "baseline"
252
+ assert vars_info["sex"]["dtype"] == "categorical"
253
+ assert vars_info["iss_stage"]["role"] == "baseline"
254
+ assert vars_info["prior_lines"]["role"] == "baseline"
255
+ assert vars_info["high_risk_fish"]["role"] == "baseline"
256
+ assert vars_info["treatment_arm"]["role"] == "baseline"
257
+
258
+ # Outcome variables
259
+ assert vars_info["response_category"]["role"] == "outcome"
260
+ assert vars_info["response_category"]["dtype"] == "categorical"
261
+ assert vars_info["pfs_days"]["role"] == "outcome"
262
+ assert vars_info["pfs_days"]["dtype"] == "time_to_event"
263
+ assert vars_info["os_days"]["role"] == "outcome"
264
+ assert vars_info["os_days"]["dtype"] == "time_to_event"
265
+
266
+ def test_plan_locked(self, benchmark_study):
267
+ """Verify plan is locked and immutable."""
268
+ study_id, _ = benchmark_study
269
+ locked_plans = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
270
+ assert len(locked_plans) == 1
271
+ plan_data = json.loads(locked_plans[0].read_text())
272
+ assert plan_data["version"] == 1
273
+ assert len(plan_data["planned_tests"]) == 2
274
+ assert plan_data["planned_tests"][0]["test_name"] == "chi_square"
275
+ assert plan_data["planned_tests"][1]["test_name"] == "kaplan_meier_logrank"
276
+
277
+ def test_unmasked(self, benchmark_study):
278
+ """Verify study is unmasked and outcomes visible."""
279
+ study_id, _ = benchmark_study
280
+ assert not is_masked(study_id)
281
+ conn = get_connection(study_id)
282
+ cur = conn.execute(f"SELECT response_category FROM raw_{study_id}")
283
+ vals = [r["response_category"] for r in cur.fetchall()]
284
+ conn.close()
285
+ assert all(v is not None for v in vals)
286
+ assert len(vals) == 21
287
+
288
+
289
+ class TestChiSquareAssumptionWarning:
290
+ """Verify chi-square assumption warning for sparse 6x2 table."""
291
+
292
+ def test_plan_time_warning_recorded(self, benchmark_study):
293
+ """Chi-square on 6x2 table should record warning at plan time."""
294
+ study_id, _ = benchmark_study
295
+ locked_plans = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
296
+ plan_data = json.loads(locked_plans[0].read_text())
297
+ warnings = plan_data.get("warnings", {})
298
+
299
+ assert "response_category" in warnings
300
+ warning_msg = warnings["response_category"]
301
+ assert "minimum expected cell count" in warning_msg
302
+ assert "1.0" in warning_msg or "1" in warning_msg # min expected = 1.0
303
+ assert "6×2" in warning_msg or "6x2" in warning_msg.lower()
304
+ assert "fishers_exact" in warning_msg.lower()
305
+
306
+ def test_analyze_skips_chi_square_without_force(self, benchmark_study):
307
+ """Analyze should skip chi-square due to assumption warning (unless --force)."""
308
+ study_id, _ = benchmark_study
309
+ conn = get_connection(study_id)
310
+ import pandas as pd
311
+ df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
312
+ conn.close()
313
+
314
+ # Run chi-square directly on unmasked data
315
+ result = run_test("chi_square", df, outcome_col="response_category", group_col="treatment_arm")
316
+
317
+ # The test runs (we called it directly), but at plan time it was warned
318
+ # The analyze CLI command would skip it. Here we verify the warning exists.
319
+ assert result["p_value"] is not None # chi-square still computes
320
+
321
+
322
+ class TestFishersExactHardReject:
323
+ """Verify Fisher's exact test hard-rejects non-2x2 tables."""
324
+
325
+ def test_fishers_exact_raises_on_6x2(self, benchmark_study):
326
+ """Passing a 6x2 contingency table to _fishers_exact must raise explicit error."""
327
+ study_id, _ = benchmark_study
328
+ conn = get_connection(study_id)
329
+ import pandas as pd
330
+ df = pd.read_sql_query(f"SELECT * FROM raw_{study_id}", conn)
331
+ conn.close()
332
+
333
+ # response_category has 6 categories, treatment_arm has 2 -> 6x2 table
334
+ result = _fishers_exact(df, "response_category", "treatment_arm")
335
+
336
+ assert result["params"].get("error") is not None
337
+ assert "2x2" in result["params"]["error"] or "2x2" in result["params"]["error"]
338
+ assert result["statistic"] is None
339
+ assert result["p_value"] is None
340
+
341
+ def test_fishers_exact_works_on_2x2(self):
342
+ """Fisher's exact should work correctly on a 2x2 table."""
343
+ import pandas as pd
344
+ # Create a 2x2 dataframe
345
+ df = pd.DataFrame({
346
+ "outcome": ["yes", "yes", "no", "no", "yes", "no"],
347
+ "group": ["A", "A", "A", "B", "B", "B"],
348
+ })
349
+ result = _fishers_exact(df, "outcome", "group")
350
+
351
+ assert result["params"].get("error") is None
352
+ assert result["statistic"] is not None # odds ratio
353
+ assert result["p_value"] is not None
354
+ assert 0 <= result["p_value"] <= 1
355
+
356
+
357
+ class TestKMVisualIntegrity:
358
+ """Verify KM plot risk table row order matches legend order."""
359
+
360
+ def test_km_plot_generates(self, benchmark_study):
361
+ """KM plot should generate without error."""
362
+ study_id, km_test_id = benchmark_study
363
+ path = generate_km_plot(
364
+ study_id, km_test_id,
365
+ output_path=None,
366
+ fmt="svg",
367
+ show_risk_table=True,
368
+ style="clean",
369
+ )
370
+ assert path.exists()
371
+ assert path.suffix == ".svg"
372
+
373
+ def test_risk_table_order_matches_legend(self, benchmark_study):
374
+ """Risk table rows must be in same vertical order as legend (A top, B bottom)."""
375
+ study_id, km_test_id = benchmark_study
376
+ path = generate_km_plot(
377
+ study_id, km_test_id,
378
+ output_path=None,
379
+ fmt="svg",
380
+ show_risk_table=True,
381
+ style="clean",
382
+ )
383
+ svg_content = path.read_text()
384
+
385
+ # The risk table ytick labels appear as SVG comments: <!-- A --> and <!-- B -->
386
+ # Each is followed by a <g> element with transform="translate(x y) scale(...)"
387
+ # NOTE: SVG uses space-separated coordinates: translate(x y), NOT translate(x, y)
388
+ # With invert_yaxis(), group[0] (A) appears at TOP (smaller y in SVG coords)
389
+ import re
390
+
391
+ # Find A and B labels and their y-positions from the following transform
392
+ a_pos = svg_content.find("<!-- A -->")
393
+ b_pos = svg_content.find("<!-- B -->")
394
+
395
+ assert a_pos >= 0, "Risk table label for Arm A not found"
396
+ assert b_pos >= 0, "Risk table label for Arm B not found"
397
+
398
+ # Extract y-coordinate from transform after each label
399
+ # SVG transform uses space-separated: translate(x y)
400
+ def extract_y(pos):
401
+ # Look for transform="translate(x y)" within 500 chars after the comment
402
+ window = svg_content[pos:pos+500]
403
+ # Pattern: translate(23.460219 371.344052)
404
+ m = re.search(r'translate\([\d.]+ ([\d.]+)', window)
405
+ return float(m.group(1)) if m else None
406
+
407
+ a_y = extract_y(a_pos)
408
+ b_y = extract_y(b_pos)
409
+
410
+ print(f"A y={a_y}, B y={b_y}")
411
+
412
+ assert a_y is not None, "Could not extract y for Arm A"
413
+ assert b_y is not None, "Could not extract y for Arm B"
414
+
415
+ # SVG y increases downward; smaller y = higher on screen
416
+ # With invert_yaxis(), A should have smaller y = appear above B
417
+ assert a_y < b_y, f"Risk table order wrong: A y={a_y}, B y={b_y} (A should be above B)"
418
+
419
+ def test_right_padding_applied(self, benchmark_study):
420
+ """X-axis should have 5% right padding so curve doesn't clip border."""
421
+ study_id, km_test_id = benchmark_study
422
+ path = generate_km_plot(
423
+ study_id, km_test_id,
424
+ output_path=None,
425
+ fmt="svg",
426
+ show_risk_table=True,
427
+ style="clean",
428
+ )
429
+ svg_content = path.read_text()
430
+
431
+ import re
432
+ viewbox_match = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg_content)
433
+ assert viewbox_match, "viewBox not found in SVG"
434
+ width = float(viewbox_match.group(1))
435
+ # Width should be > 570 (base width for ~20 months) due to padding
436
+ assert width > 570, f"Expected width > 570 with padding, got {width}"
437
+
438
+ def test_risk_labels_color_coded(self, benchmark_study):
439
+ """Risk table row labels should be color-coded: blue for A, orange for B."""
440
+ study_id, km_test_id = benchmark_study
441
+ path = generate_km_plot(
442
+ study_id, km_test_id,
443
+ output_path=None,
444
+ fmt="svg",
445
+ show_risk_table=True,
446
+ style="clean",
447
+ )
448
+ svg_content = path.read_text()
449
+
450
+ # The code sets label.set_color(colors[i]) where colors = ["#1f77b4", "#ff7f0e"]
451
+ assert "#1f77b4" in svg_content, "Blue color for Arm A missing"
452
+ assert "#ff7f0e" in svg_content, "Orange color for Arm B missing"
453
+
454
+
455
+ class TestBundleVerification:
456
+ """Verify bundle creation and hash verification."""
457
+
458
+ def test_bundle_creates_and_verifies(self, benchmark_study):
459
+ """Bundle command should create archive and verify-bundle should pass."""
460
+ study_id, _ = benchmark_study
461
+ result = create_bundle(study_id)
462
+
463
+ assert "bundle_path" in result
464
+ assert "composite_hash" in result
465
+
466
+ bundle_path = Path(result["bundle_path"])
467
+ assert bundle_path.exists()
468
+ assert bundle_path.suffix == ".gz"
469
+
470
+ # Verify bundle
471
+ verification = verify_bundle(str(bundle_path))
472
+ assert verification["valid"] is True
473
+ assert verification["composite_match"] is True
474
+ # Hash should be deterministic for same inputs
475
+ assert verification["composite_hash"] == verification["computed_composite"]
476
+ assert verification["computed_raw_hash"] == verification["manifest"]["raw_data_hash"]
477
+ assert verification["computed_plan_hash"] == verification["manifest"]["locked_plan_hash"]
478
+ assert verification["computed_results_hash"] == verification["manifest"]["results_hash"]
479
+
480
+ def test_bundle_contains_all_artifacts(self, benchmark_study):
481
+ """Bundle should contain all expected study artifacts."""
482
+ study_id, _ = benchmark_study
483
+ result = create_bundle(study_id)
484
+ bundle_path = Path(result["bundle_path"])
485
+
486
+ import tarfile
487
+ with tarfile.open(bundle_path, "r:gz") as tar:
488
+ members = tar.getnames()
489
+
490
+ # Debug: print actual members
491
+ print("Bundle contents:", members)
492
+
493
+ # Essential files that must be in the bundle (at root level)
494
+ expected_files = [
495
+ "raw_data.json",
496
+ "study_plan.locked.json", # bundle uses generic name
497
+ "analysis_results.json",
498
+ "manifest.json",
499
+ ]
500
+ for exp in expected_files:
501
+ assert exp in members, f"Missing from bundle: {exp}"
502
+
503
+
504
+ class TestManuscriptDraft:
505
+ """Verify manuscript draft generation."""
506
+
507
+ def test_draft_generates(self, benchmark_study):
508
+ """Draft should generate without error."""
509
+ study_id, _ = benchmark_study
510
+ path = write_draft(study_id)
511
+ assert path.exists()
512
+ assert path.suffix == ".md"
513
+
514
+ def test_draft_contains_key_sections(self, benchmark_study):
515
+ """Draft must contain all IMRaD sections and STROBE checklist."""
516
+ study_id, _ = benchmark_study
517
+ path = write_draft(study_id)
518
+ content = path.read_text()
519
+
520
+ for section in ["Abstract", "Introduction", "Methods", "Results",
521
+ "Discussion", "STROBE Checklist"]:
522
+ assert section.lower() in content.lower(), f"Missing section: {section}"
523
+
524
+ def test_draft_references_correct_p_values(self, benchmark_study):
525
+ """Draft should reference the actual computed p-values."""
526
+ study_id, _ = benchmark_study
527
+ path = write_draft(study_id)
528
+ content = path.read_text()
529
+
530
+ # KM log-rank p=0.3124 should appear
531
+ assert "0.31" in content or "0.312" in content
532
+
533
+ def test_draft_no_exploratory_tag(self, benchmark_study):
534
+ """Draft should not contain EXPLORATORY_POST_HOC tag (no post-hoc tests run)."""
535
+ study_id, _ = benchmark_study
536
+ path = write_draft(study_id)
537
+ content = path.read_text()
538
+ assert "EXPLORATORY_POST_HOC" not in content
539
+
540
+
541
+ class TestSTROBECompliance:
542
+ """Verify STROBE checklist compliance."""
543
+
544
+ def test_strobe_all_satisfied(self, benchmark_study):
545
+ """All applicable STROBE items should be satisfied."""
546
+ study_id, _ = benchmark_study
547
+ report = generate_report(study_id)
548
+ satisfied = report.count("\u2713")
549
+ # 33 applicable items for this study design
550
+ assert satisfied == 33, f"Expected 33 satisfied items, got {satisfied}"
551
+
552
+
553
+ # Run as standalone for quick validation
554
+ if __name__ == "__main__":
555
+ import sys
556
+ sys.exit(pytest.main([__file__, "-v", "--tb=short"]))