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,562 @@
1
+ """Provenance lineage: assemble and render a study's lifecycle as a DAG.
2
+
3
+ Every timestamp and hash is read from stored data (locked plan files,
4
+ SQLite DB, bundle manifests). Nothing is recomputed or guessed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import glob
10
+ import json
11
+ import tarfile
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from core.database import get_connection, DATA_ROOT
17
+ from core.reporting import svg_escape as _svg_escape
18
+
19
+
20
+ # ── Event model ──────────────────────────────────────────────────────────────
21
+
22
+ @dataclass
23
+ class LineageEvent:
24
+ event_type: str
25
+ timestamp: str
26
+ label: str
27
+ detail: dict = field(default_factory=dict)
28
+ depth: int = 0
29
+ # SVG branch coloring
30
+ branch: str = "main" # "main", "pre_unmask_amend", "post_hoc_amend", "rerun"
31
+
32
+
33
+ # ── Event assembly ───────────────────────────────────────────────────────────
34
+
35
+ def _parse_iso(ts: str | None) -> str:
36
+ """Normalise ISO-8601 timestamp for sorting; empty string if None."""
37
+ if not ts:
38
+ return ""
39
+ return ts.replace(" ", "T")
40
+
41
+
42
+ def _study_exists(study_id: str) -> bool:
43
+ return (DATA_ROOT / study_id).exists()
44
+
45
+
46
+ def _get_locked_plans(study_id: str) -> list[dict]:
47
+ """Return sorted list of locked plan dicts, each with a _version key."""
48
+ pattern = str(DATA_ROOT / study_id / "study_plan.v*.locked.json")
49
+ plans = []
50
+ for path in sorted(glob.glob(pattern)):
51
+ data = json.loads(Path(path).read_text())
52
+ try:
53
+ data["_version"] = int(path.split(".v")[1].split(".")[0])
54
+ except (IndexError, ValueError):
55
+ data["_version"] = 0
56
+ data["_file_path"] = path
57
+ plans.append(data)
58
+ return plans
59
+
60
+
61
+ def _get_state(conn, study_id: str) -> int:
62
+ row = conn.execute(
63
+ "SELECT is_locked FROM studies WHERE id=?", (study_id,)
64
+ ).fetchone()
65
+ return row["is_locked"] if row else 0
66
+
67
+
68
+ def _get_unmasked_at(conn, study_id: str) -> str | None:
69
+ row = conn.execute(
70
+ "SELECT unmasked_at FROM studies WHERE id=?", (study_id,)
71
+ ).fetchone()
72
+ return row["unmasked_at"] if row and row["unmasked_at"] else None
73
+
74
+
75
+ def _get_created_at(conn, study_id: str) -> str | None:
76
+ row = conn.execute(
77
+ "SELECT created_at FROM studies WHERE id=?", (study_id,)
78
+ ).fetchone()
79
+ return row["created_at"] if row else None
80
+
81
+
82
+ def _get_var_count(conn, study_id: str) -> int:
83
+ row = conn.execute(
84
+ "SELECT COUNT(*) AS cnt FROM variables WHERE study_id=?", (study_id,)
85
+ ).fetchone()
86
+ return row["cnt"] if row else 0
87
+
88
+
89
+ def _get_analysis_results(conn, study_id: str) -> list[dict]:
90
+ rows = conn.execute(
91
+ """SELECT id, test_name, computed_at, p_value, statistic,
92
+ status_json, is_pre_registered, study_plan_version,
93
+ provenance_json, superseded_previous_result_id,
94
+ sample_counts_json
95
+ FROM analysis_results
96
+ WHERE study_id=?
97
+ ORDER BY id""",
98
+ (study_id,),
99
+ ).fetchall()
100
+ return [dict(r) for r in rows]
101
+
102
+
103
+ def _get_bundle_info(study_id: str) -> dict | None:
104
+ pattern = str(DATA_ROOT / study_id / "*_bundle.tar.gz")
105
+ paths = sorted(glob.glob(pattern))
106
+ if not paths:
107
+ return None
108
+ path = Path(paths[-1])
109
+ try:
110
+ with tarfile.open(str(path), "r:gz") as tf:
111
+ member = tf.getmember("manifest.json")
112
+ manifest = json.loads(tf.extractfile(member).read())
113
+ return {
114
+ "path": str(path),
115
+ "generated_at": manifest.get("generated_at", ""),
116
+ "composite_hash": manifest.get("composite_hash", ""),
117
+ }
118
+ except Exception:
119
+ return None
120
+
121
+
122
+ def assemble_events(study_id: str) -> list[LineageEvent]:
123
+ """Walk study directory + DB and return ordered provenance events.
124
+
125
+ Every timestamp is read directly from stored data.
126
+ Missing stages produce no event (no fabricated placeholder nodes).
127
+ """
128
+ events: list[LineageEvent] = []
129
+
130
+ if not _study_exists(study_id):
131
+ return []
132
+
133
+ conn = get_connection(study_id)
134
+ study_exists_in_db = conn.execute(
135
+ "SELECT COUNT(*) AS cnt FROM studies WHERE id=?", (study_id,)
136
+ ).fetchone()["cnt"] > 0
137
+
138
+ if not study_exists_in_db:
139
+ conn.close()
140
+ return []
141
+
142
+ state = _get_state(conn, study_id)
143
+ created_at = _get_created_at(conn, study_id)
144
+ unmasked_at = _get_unmasked_at(conn, study_id)
145
+ var_count = _get_var_count(conn, study_id)
146
+ locked_plans = _get_locked_plans(study_id)
147
+ analysis_results = _get_analysis_results(conn, study_id)
148
+ bundle_info = _get_bundle_info(study_id)
149
+ conn.close()
150
+
151
+ # --- Ingest ---
152
+ if created_at:
153
+ events.append(LineageEvent(
154
+ event_type="ingest",
155
+ timestamp=created_at,
156
+ label="Study created / data ingested",
157
+ detail={},
158
+ ))
159
+
160
+ # --- Variable classification ---
161
+ if var_count > 0:
162
+ events.append(LineageEvent(
163
+ event_type="variable_classification",
164
+ timestamp=created_at or "",
165
+ label=f"Variables classified ({var_count} variables)",
166
+ detail={"n_variables": var_count},
167
+ ))
168
+
169
+ # --- Plan locks (including amendments) ---
170
+ for plan in locked_plans:
171
+ version = plan.get("_version", 0)
172
+ locked_at = plan.get("locked_at", "")
173
+ content_hash = plan.get("content_hash", "")
174
+ amendment_reason = plan.get("amendment_reason", "")
175
+ is_amendment = bool(amendment_reason)
176
+
177
+ if is_amendment:
178
+ if unmasked_at and locked_at > unmasked_at:
179
+ branch = "post_hoc_amend"
180
+ label = f"Post-hoc amendment (v{version})"
181
+ else:
182
+ branch = "pre_unmask_amend"
183
+ label = f"Pre-unmask amendment (v{version})"
184
+ events.append(LineageEvent(
185
+ event_type="amendment",
186
+ timestamp=locked_at,
187
+ label=label,
188
+ detail={
189
+ "version": version,
190
+ "reason": amendment_reason,
191
+ "content_hash": content_hash,
192
+ },
193
+ depth=1,
194
+ branch=branch,
195
+ ))
196
+ else:
197
+ events.append(LineageEvent(
198
+ event_type="plan_lock",
199
+ timestamp=locked_at,
200
+ label=f"Plan locked (v{version})",
201
+ detail={
202
+ "version": version,
203
+ "content_hash": content_hash,
204
+ },
205
+ branch="main",
206
+ ))
207
+
208
+ # --- Seal outcomes ---
209
+ # seal_outcomes() is called immediately after variable classification
210
+ # at ingest time (core/masking/gate.py:seal_outcomes). No independent
211
+ # timestamp is recorded — use created_at as the closest approximation.
212
+ if var_count > 0:
213
+ events.append(LineageEvent(
214
+ event_type="seal",
215
+ timestamp=created_at or "",
216
+ label="Outcome data sealed (ingest-time masking)",
217
+ detail={},
218
+ branch="main",
219
+ ))
220
+
221
+ # --- Unmask ---
222
+ if unmasked_at:
223
+ events.append(LineageEvent(
224
+ event_type="unmask",
225
+ timestamp=unmasked_at,
226
+ label="Study unmasked (outcome data restored)",
227
+ detail={},
228
+ branch="main",
229
+ ))
230
+
231
+ # --- Analysis results ---
232
+ superseded_ids = {r["id"] for r in analysis_results
233
+ if r.get("superseded_previous_result_id")}
234
+ for r in analysis_results:
235
+ computed_at = r.get("computed_at", "")
236
+ test_name = r.get("test_name", "")
237
+ p_value = r.get("p_value")
238
+ statistic = r.get("statistic")
239
+ status_data = json.loads(r["status_json"]) if r.get("status_json") else {}
240
+ status = status_data.get("status", "completed")
241
+ is_pre = r.get("is_pre_registered", 1)
242
+ plan_ver = r.get("study_plan_version", "")
243
+
244
+ if not test_name:
245
+ continue
246
+
247
+ # Build label
248
+ p_str = f", p={p_value:.4f}" if p_value is not None else ""
249
+ stat_str = f", stat={statistic:.4f}" if statistic is not None else ""
250
+ tag = "pre-registered" if is_pre else "post-hoc"
251
+ status_str = f" [{status}]" if status != "completed" else ""
252
+ label = f"{test_name}{stat_str}{p_str} ({tag}, plan v{plan_ver}){status_str}"
253
+
254
+ detail = {
255
+ "result_id": r["id"],
256
+ "test_name": test_name,
257
+ "is_pre_registered": is_pre,
258
+ "plan_version": plan_ver,
259
+ }
260
+
261
+ supersedes = r.get("superseded_previous_result_id")
262
+ if supersedes:
263
+ detail["supersedes_result_id"] = supersedes
264
+ label = f"{label} [supersedes id={supersedes}]"
265
+
266
+ events.append(LineageEvent(
267
+ event_type="analyze",
268
+ timestamp=computed_at,
269
+ label=label,
270
+ detail=detail,
271
+ branch="main",
272
+ ))
273
+
274
+ # --- Bundle ---
275
+ if bundle_info:
276
+ events.append(LineageEvent(
277
+ event_type="bundle",
278
+ timestamp=bundle_info["generated_at"],
279
+ label="Bundle created",
280
+ detail={
281
+ "composite_hash": bundle_info["composite_hash"],
282
+ "path": bundle_info["path"],
283
+ },
284
+ branch="main",
285
+ ))
286
+
287
+ # Sort chronologically; insertion order preserved for same-timestamp ties
288
+ events.sort(key=lambda e: _parse_iso(e.timestamp))
289
+ return events
290
+
291
+
292
+ # ── ASCII tree renderer ──────────────────────────────────────────────────────
293
+
294
+ def render_text(events: list[LineageEvent]) -> str:
295
+ """Render lineage as an ASCII tree to stdout."""
296
+ if not events:
297
+ return "No provenance data found for this study.\n"
298
+
299
+ icon_map = {
300
+ "ingest": "●",
301
+ "variable_classification": "○",
302
+ "plan_lock": "◈",
303
+ "amendment": "◇",
304
+ "seal": "△",
305
+ "unmask": "▽",
306
+ "analyze": "◆",
307
+ "bundle": "■",
308
+ }
309
+
310
+ lines: list[str] = []
311
+ lines.append("Study provenance DAG")
312
+ lines.append("")
313
+
314
+ for i, ev in enumerate(events):
315
+ ts = ev.timestamp[:19].replace("T", " ") if ev.timestamp else "—"
316
+ icon = icon_map.get(ev.event_type, "○")
317
+ is_branch = ev.depth > 0
318
+
319
+ tag = ""
320
+ if ev.branch == "pre_unmask_amend":
321
+ tag = " [CONFIRMATORY]"
322
+ elif ev.branch == "post_hoc_amend":
323
+ tag = " [EXPLORATORY_POST_HOC]"
324
+
325
+ if is_branch:
326
+ has_next = any(e.depth > 0 for e in events[i + 1:])
327
+ prefix = "├─ " if has_next else "└─ "
328
+ detail_prefix = "│ " if has_next else " "
329
+ else:
330
+ prefix = "│ "
331
+ detail_prefix = "│ "
332
+
333
+ lines.append(f"{prefix}{icon} {ts} {ev.label}{tag}")
334
+
335
+ if ev.detail.get("content_hash"):
336
+ lines.append(f"{detail_prefix}hash: {ev.detail['content_hash'][:12]}...")
337
+ if ev.detail.get("reason"):
338
+ lines.append(f"{detail_prefix}reason: {ev.detail['reason']}")
339
+ if ev.detail.get("composite_hash"):
340
+ lines.append(f"{detail_prefix}hash: {ev.detail['composite_hash'][:12]}...")
341
+
342
+ # Legend
343
+ lines.append("")
344
+ lines.append("Legend:")
345
+ lines.append(" ● Ingest ○ Classification ◈ Plan lock △ Seal")
346
+ lines.append(" ▽ Unmask ◇ Amendment ◆ Analysis ■ Bundle")
347
+ has_amend = any(e.event_type == "amendment" for e in events)
348
+ if has_amend:
349
+ lines.append(" [CONFIRMATORY] = pre-unmask amendment")
350
+ lines.append(" [EXPLORATORY_POST_HOC] = post-unmask amendment")
351
+
352
+ return "\n".join(lines)
353
+
354
+
355
+ # ── SVG renderer ─────────────────────────────────────────────────────────────
356
+
357
+ def render_svg(events: list[LineageEvent], output_path: str) -> None:
358
+ """Render lineage as an SVG DAG suitable for publication appendices."""
359
+ if not events:
360
+ svg = _svg_wrap(400, 100, "<text x='20' y='40' font-family='sans-serif'>"
361
+ "No provenance data.</text>")
362
+ Path(output_path).write_text(svg)
363
+ return
364
+
365
+ margin = 40
366
+ top = 40
367
+ label_left = 100
368
+ detail_left = label_left + 400
369
+ row_h = 36
370
+ branch_indent = 20
371
+
372
+ # Phase coloring
373
+ phase_colors = {
374
+ "ingest": "#2E86C1",
375
+ "variable_classification": "#5DADE2",
376
+ "plan_lock": "#1F4E78",
377
+ "amendment": "#E67E22",
378
+ "seal": "#F39C12",
379
+ "unmask": "#27AE60",
380
+ "analyze": "#7F8C8D",
381
+ "bundle": "#8E44AD",
382
+ }
383
+
384
+ # Determine width from longest label and detail
385
+ max_label_w = max(
386
+ len(ev.label) for ev in events
387
+ ) if events else 80
388
+ svg_w = max(800, label_left + max_label_w * 8 + 300)
389
+ svg_h = top + len(events) * row_h + 120 + margin
390
+
391
+ svg_parts = [
392
+ '<svg xmlns="http://www.w3.org/2000/svg"',
393
+ f' width="{svg_w}" height="{svg_h}"',
394
+ ' font-family="Consolas, Courier, monospace" font-size="13">',
395
+ '',
396
+ ' <defs>',
397
+ ' <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"',
398
+ ' markerWidth="6" markerHeight="6" orient="auto-start-reverse">',
399
+ ' <path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />',
400
+ ' </marker>',
401
+ ' </defs>',
402
+ '',
403
+ ' <!-- Title -->',
404
+ f' <text x="40" y="24" font-family="sans-serif" font-weight="bold"',
405
+ f' font-size="16" fill="#1F4E78">Study Provenance DAG</text>',
406
+ '',
407
+ ' <!-- Timeline spine -->',
408
+ f' <line x1="{margin + 8}" y1="{top}"',
409
+ f' x2="{margin + 8}" y2="{top + len(events) * row_h}"',
410
+ ' stroke="#ccc" stroke-width="2" />',
411
+ ]
412
+
413
+ # Determine branch parents: each branch event connects from its nearest
414
+ # preceding depth-0 ancestor on the timeline.
415
+ branch_connectors: list[tuple[int, int, str]] = [] # (parent_y, child_y, branch_label)
416
+ for i, ev in enumerate(events):
417
+ y = top + i * row_h + row_h // 2
418
+ x = margin + 8
419
+ is_branch = ev.depth > 0
420
+
421
+ if is_branch:
422
+ # Find parent (preceding depth-0 event)
423
+ parent_idx = next(
424
+ (j for j in range(i - 1, -1, -1) if events[j].depth == 0),
425
+ i - 1,
426
+ )
427
+ parent_y = top + parent_idx * row_h + row_h // 2
428
+ bx = x + 14 + branch_indent
429
+
430
+ # Draw vertical branch line from parent to child
431
+ branch_color = "#E67E22"
432
+ svg_parts.append(
433
+ f' <line x1="{x + 6}" y1="{parent_y}" x2="{x + 6}" y2="{y}"'
434
+ f' stroke="{branch_color}" stroke-width="1.5"'
435
+ f' stroke-dasharray="3,2" />'
436
+ )
437
+ # Horizontal from branch line to label
438
+ svg_parts.append(
439
+ f' <line x1="{x + 6}" y1="{y}" x2="{bx}" y2="{y}"'
440
+ f' stroke="{branch_color}" stroke-width="1.5"'
441
+ f' marker-end="url(#arrow)" />'
442
+ )
443
+
444
+ # Timeline dot on branch line offset
445
+ svg_parts.append(
446
+ f' <circle cx="{x + 6}" cy="{y}" r="4" fill="{branch_color}" />'
447
+ )
448
+ else:
449
+ bx = x + 14
450
+
451
+ # Dot on the main timeline spine
452
+ color = phase_colors.get(ev.event_type, "#999")
453
+ svg_parts.append(
454
+ f' <circle cx="{x}" cy="{y}" r="5" fill="{color}" />'
455
+ )
456
+
457
+ # Horizontal connector from spine to label
458
+ svg_parts.append(
459
+ f' <line x1="{x + 5}" y1="{y}" x2="{bx}" y2="{y}"'
460
+ f' stroke="{color}" stroke-width="1.5"'
461
+ f' marker-end="url(#arrow)" />'
462
+ )
463
+
464
+ # Timestamp
465
+ ts = ev.timestamp[:19].replace("T", " ") if ev.timestamp else "— "
466
+ svg_parts.append(
467
+ f' <text x="{bx + 6}" y="{y + 4}" font-size="11" fill="#666">'
468
+ f'{ts}</text>'
469
+ )
470
+
471
+ # Event label
472
+ label_x = bx + 160
473
+ svg_parts.append(
474
+ f' <text x="{label_x}" y="{y + 4}" font-size="13" fill="#333">'
475
+ f'{_svg_escape(ev.label)}</text>'
476
+ )
477
+
478
+ # Branch tag if amendment
479
+ if ev.branch == "pre_unmask_amend":
480
+ tag = "CONFIRMATORY"
481
+ tag_color = "#27AE60"
482
+ elif ev.branch == "post_hoc_amend":
483
+ tag = "EXPLORATORY_POST_HOC"
484
+ tag_color = "#E74C3C"
485
+ else:
486
+ tag = ""
487
+ tag_color = ""
488
+
489
+ if tag:
490
+ tag_x = label_x + len(ev.label) * 7.5 + 10
491
+ svg_parts.append(
492
+ f' <text x="{tag_x}" y="{y + 4}" font-size="10"'
493
+ f' fill="{tag_color}" font-weight="bold">'
494
+ f'[{tag}]</text>'
495
+ )
496
+
497
+ # Hash sub-line for relevant events
498
+ hash_str = ""
499
+ if ev.detail.get("content_hash"):
500
+ hash_str = f"hash: {ev.detail['content_hash'][:16]}..."
501
+ elif ev.detail.get("composite_hash"):
502
+ hash_str = f"hash: {ev.detail['composite_hash'][:16]}..."
503
+ if hash_str:
504
+ svg_parts.append(
505
+ f' <text x="{label_x + 8}" y="{y + 18}" font-size="10" fill="#999">'
506
+ f'{_svg_escape(hash_str)}</text>'
507
+ )
508
+
509
+ # Supersession arrows (rerun chains)
510
+ for ev in events:
511
+ if ev.detail.get("supersedes_result_id"):
512
+ old_id = ev.detail["supersedes_result_id"]
513
+ # Find Y positions of old and new result
514
+ new_idx = events.index(ev)
515
+ old_idx = next((i for i, e in enumerate(events)
516
+ if e.detail.get("result_id") == old_id), -1)
517
+ if old_idx >= 0:
518
+ y1 = top + old_idx * row_h + row_h // 2
519
+ y2 = top + new_idx * row_h + row_h // 2
520
+ # Draw dashed line from old to new, offset to right
521
+ rx = margin + 8 + 14 + max(e.depth for e in events) * branch_indent + 550
522
+ svg_parts.append(
523
+ f' <line x1="{rx}" y1="{y1}" x2="{rx}" y2="{y2}"'
524
+ f' stroke="#E74C3C" stroke-width="1" stroke-dasharray="4,3"'
525
+ f' marker-end="url(#arrow)" />'
526
+ )
527
+ svg_parts.append(
528
+ f' <text x="{rx + 8}" y="{(y1 + y2) // 2 + 4}" font-size="10" fill="#E74C3C">'
529
+ f'supersedes</text>'
530
+ )
531
+
532
+ # Legend
533
+ legend_y = top + len(events) * row_h + 20
534
+ svg_parts.append(
535
+ f' <text x="40" y="{legend_y}" font-family="sans-serif" font-weight="bold"'
536
+ f' font-size="12" fill="#555">Legend</text>'
537
+ )
538
+ lg_items = [
539
+ ("● Ingest / ○ Classification / ◈ Plan lock / △ Seal (ingest-time)", "#333"),
540
+ ("▽ Unmask / ◇ Amendment / ◆ Analysis / ■ Bundle", "#333"),
541
+ ("— CONFIRMATORY = pre-unmask amendment", "#27AE60"),
542
+ ("— EXPLORATORY_POST_HOC = post-unmask amendment", "#E74C3C"),
543
+ ]
544
+ for j, (text, color) in enumerate(lg_items):
545
+ svg_parts.append(
546
+ f' <text x="40" y="{legend_y + 20 + j * 18}" font-size="11"'
547
+ f' fill="{color}">{_svg_escape(text)}</text>'
548
+ )
549
+
550
+ svg_parts.append("</svg>")
551
+ svg = "\n".join(svg_parts)
552
+
553
+ Path(output_path).write_text(svg)
554
+
555
+
556
+ def _svg_wrap(w: int, h: int, body: str) -> str:
557
+ return (
558
+ f'<svg xmlns="http://www.w3.org/2000/svg"'
559
+ f' width="{w}" height="{h}">\n'
560
+ f'{body}\n'
561
+ f'</svg>'
562
+ )