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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- 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
|
+
)
|