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,62 @@
1
+ """Reporting package — shared utilities for analysis result rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from core.database import DATA_ROOT
9
+
10
+
11
+ def filter_superseded(rows: list) -> list:
12
+ """Remove superseded analysis results (those replaced by a --rerun).
13
+
14
+ A result is superseded if its ``id`` appears as another row's
15
+ ``superseded_previous_result_id``.
16
+ """
17
+ superseded_ids = {
18
+ r["superseded_previous_result_id"]
19
+ for r in rows
20
+ if r["superseded_previous_result_id"] is not None
21
+ }
22
+ return [r for r in rows if r["id"] not in superseded_ids]
23
+
24
+
25
+ # ── D1: Shared plan loader ─────────────────────────────────────────────────
26
+
27
+ def latest_locked_plan(study_id: str) -> dict:
28
+ """Return the latest locked plan dict, or {} if none exists."""
29
+ locked = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
30
+ if not locked:
31
+ return {}
32
+ return json.loads(locked[-1].read_text())
33
+
34
+
35
+ # ── D2: Shared SVG escape (superset: escapes & < > " ') ────────────────────
36
+
37
+ def svg_escape(s: str) -> str:
38
+ """Escape special characters for safe embedding in SVG/XML."""
39
+ return (s.replace("&", "&amp;")
40
+ .replace("<", "&lt;")
41
+ .replace(">", "&gt;")
42
+ .replace('"', "&quot;")
43
+ .replace("'", "&apos;"))
44
+
45
+
46
+ # ── D3: Shared label formatter ──────────────────────────────────────────────
47
+
48
+ _ACRONYMS = frozenset({"PFS", "OS", "HR", "CI", "DFS", "ORR", "CR", "PR", "SD", "PD",
49
+ "ISS", "ECOG", "LDH", "BMI", "IQR", "KM", "PH"})
50
+
51
+
52
+ def format_label(raw: str) -> str:
53
+ """Acronym-aware label: pfs_multivariable → PFS Multivariable."""
54
+ parts = raw.replace("_", " ").split()
55
+ out: list[str] = []
56
+ for p in parts:
57
+ upper = p.upper()
58
+ if upper in _ACRONYMS:
59
+ out.append(upper)
60
+ else:
61
+ out.append(p[0].upper() + p[1:] if p else p)
62
+ return " ".join(out)
@@ -0,0 +1,58 @@
1
+ """Deterministic manuscript-appendix rendering for exported study results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core.reporting.strobe_checklist import check_study
6
+
7
+
8
+ def _table(headers: list[str], rows: list[dict]) -> str:
9
+ if not headers:
10
+ return "_No data available._\n"
11
+ lines = [
12
+ "| " + " | ".join(headers) + " |",
13
+ "| " + " | ".join("---" for _ in headers) + " |",
14
+ ]
15
+ for row in rows:
16
+ lines.append("| " + " | ".join(str(row.get(h, "")) for h in headers) + " |")
17
+ return "\n".join(lines) + "\n"
18
+
19
+
20
+ def generate_appendix(export: dict, study_id: str) -> str:
21
+ """Render Table 1, UROs, warnings, and STROBE from canonical export data."""
22
+ table1 = export.get("table1") or {}
23
+ metadata = export.get("study_metadata", {})
24
+ sections = [
25
+ f"# Statistical Appendix — {metadata.get('name', study_id)}",
26
+ "",
27
+ "## Table 1",
28
+ "",
29
+ _table(table1.get("headers", []), table1.get("rows", [])),
30
+ "## URO Analysis Results",
31
+ "",
32
+ ]
33
+ uro_rows = []
34
+ for result in export.get("analysis_results", []):
35
+ statistic = result.get("statistic") or {}
36
+ uro_rows.append({
37
+ "Test": result.get("test_name", ""),
38
+ "Status": result.get("status", ""),
39
+ "Statistic": statistic.get("value", "") if isinstance(statistic, dict) else statistic,
40
+ "P-value": result.get("p_value", ""),
41
+ "Adjusted P-value": result.get("adjusted_p_value", ""),
42
+ "Reason": result.get("reason", "") or "",
43
+ })
44
+ sections.append(_table(["Test", "Status", "Statistic", "P-value", "Adjusted P-value", "Reason"], uro_rows))
45
+
46
+ warnings = (export.get("locked_plan") or {}).get("warnings", {})
47
+ sections.extend(["## Warnings", ""])
48
+ if warnings:
49
+ sections.extend(f"- {key}: {value}" for key, value in sorted(warnings.items()))
50
+ else:
51
+ sections.append("_No plan warnings recorded._")
52
+
53
+ sections.extend(["", "## STROBE Checklist", ""])
54
+ for item in check_study(study_id):
55
+ mark = "x" if item.satisfied else " "
56
+ sections.append(f"- [{mark}] **{item.item_id}** — {item.evidence}")
57
+ sections.append("")
58
+ return "\n".join(sections)
@@ -0,0 +1,378 @@
1
+ """Hash-verified study archive (bundle) for portable verification.
2
+
3
+ A bundle packages a completed study into a single `.tar.gz` that lets a
4
+ third party independently verify that the manuscript's numbers genuinely
5
+ came from the exact locked plan run against the exact raw data.
6
+
7
+ Composite hash: SHA-256 of raw_data_hash || locked_plan_hash || results_hash
8
+ (hash of hashes — if any component changes, the composite breaks).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import json
15
+ import tarfile
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from core.database import get_connection, DATA_ROOT
22
+ from core.provenance.hashing import sha256 as _sha256, canonical_json as _canonical_json
23
+
24
+
25
+ SCHEMA_VERSION = "1.0.0"
26
+ HASH_ORDER = ("raw_data_hash", "locked_plan_hash", "results_hash")
27
+ COMPOSITE_SEPARATOR = "||" # documented separator between hashes in the concatenation
28
+
29
+
30
+ def _compute_composite(*component_hashes: str) -> str:
31
+ """SHA-256 of component hashes joined by the documented separator."""
32
+ return _sha256(COMPOSITE_SEPARATOR.join(component_hashes))
33
+
34
+
35
+ def _export_raw_data(study_id: str) -> str:
36
+ """Export the ingested raw data table as canonical JSON lines.
37
+
38
+ Returns the entire table as a JSON array of row objects (sorted keys),
39
+ so the hash is deterministic regardless of row_id insertion order.
40
+ """
41
+ conn = get_connection(study_id)
42
+ raw_table = f"raw_{study_id}"
43
+ cur = conn.execute(f"SELECT * FROM {raw_table} ORDER BY row_id")
44
+ rows = cur.fetchall()
45
+ conn.close()
46
+
47
+ # Build a list of dicts with sorted keys for canonical JSON
48
+ data = []
49
+ for r in rows:
50
+ row_dict = dict(r)
51
+ # Ensure all values are JSON-compatible
52
+ data.append({k: v for k, v in row_dict.items()})
53
+ return _canonical_json(data)
54
+
55
+
56
+ def _export_analysis_results(study_id: str) -> str:
57
+ """Export analysis results as canonical JSON array."""
58
+ conn = get_connection(study_id)
59
+ cur = conn.execute(
60
+ "SELECT * FROM analysis_results WHERE study_id=? ORDER BY id", (study_id,)
61
+ )
62
+ rows = cur.fetchall()
63
+
64
+ # Pre-fetch covariate results
65
+ result_ids = [r["id"] for r in rows]
66
+ covariate_map: dict[int, list[dict]] = {}
67
+ if result_ids:
68
+ placeholders = ",".join("?" for _ in result_ids)
69
+ cur = conn.execute(
70
+ f"SELECT * FROM analysis_covariate_results WHERE result_id IN ({placeholders}) ORDER BY id",
71
+ result_ids,
72
+ )
73
+ for row in cur.fetchall():
74
+ covariate_map.setdefault(row["result_id"], []).append(dict(row))
75
+
76
+ conn.close()
77
+
78
+ results = []
79
+ for r in rows:
80
+ row_dict = dict(r)
81
+ # Decode JSON fields so they're proper objects, not strings
82
+ for json_field in ("variable_ids_used", "effect_size_json",
83
+ "sample_counts_json", "status_json", "provenance_json",
84
+ "ph_diagnostics_json"):
85
+ if row_dict.get(json_field):
86
+ try:
87
+ row_dict[json_field] = json.loads(row_dict[json_field])
88
+ except (TypeError, json.JSONDecodeError):
89
+ pass
90
+ # Attach per-covariate results
91
+ cr = covariate_map.get(r["id"])
92
+ if cr:
93
+ row_dict["covariate_results"] = cr
94
+ results.append(row_dict)
95
+ return _canonical_json(results)
96
+
97
+
98
+ def create_bundle(study_id: str) -> dict:
99
+ """Create a hash-verified bundle archive for a completed study.
100
+
101
+ Returns a dict with ``bundle_path`` (Path), ``composite_hash`` (str),
102
+ and all component hashes.
103
+
104
+ Raises RuntimeError if the study hasn't been unmasked and analyzed.
105
+ """
106
+ conn = get_connection(study_id)
107
+ cur = conn.execute("SELECT * FROM studies WHERE id=?", (study_id,))
108
+ study = cur.fetchone()
109
+ if not study:
110
+ conn.close()
111
+ raise RuntimeError(f"Study '{study_id}' not found.")
112
+
113
+ study_state = study["is_locked"]
114
+ if study_state < 2:
115
+ conn.close()
116
+ raise RuntimeError(
117
+ f"Cannot bundle study '{study_id}': study has not been unmasked "
118
+ f"and analyzed. Run analyze first."
119
+ )
120
+
121
+ # Check for analysis results
122
+ cur = conn.execute(
123
+ "SELECT COUNT(*) as cnt FROM analysis_results WHERE study_id=?",
124
+ (study_id,),
125
+ )
126
+ n_results = cur.fetchone()["cnt"]
127
+ if n_results == 0:
128
+ conn.close()
129
+ raise RuntimeError(
130
+ f"Cannot bundle study '{study_id}': no analysis results found. "
131
+ f"Run analyze first."
132
+ )
133
+ conn.close()
134
+
135
+ # ── Gather bundle contents ──────────────────────────────────────────
136
+ raw_data_json = _export_raw_data(study_id)
137
+ raw_data_hash = _sha256(raw_data_json)
138
+
139
+ # Locked plan: find latest and reuse its content_hash
140
+ locked_plans = sorted(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
141
+ if not locked_plans:
142
+ raise RuntimeError(f"No locked plan found for study '{study_id}'.")
143
+ locked_plan_path = locked_plans[-1]
144
+ locked_plan_data = json.loads(locked_plan_path.read_text())
145
+ locked_plan_hash = locked_plan_data["content_hash"]
146
+ # Keep the locked plan JSON as-is (with content_hash) so verify_bundle
147
+ # can read the hash back from it.
148
+ locked_plan_json = _canonical_json(locked_plan_data)
149
+
150
+ results_json = _export_analysis_results(study_id)
151
+ results_hash = _sha256(results_json)
152
+
153
+ composite_hash = _compute_composite(raw_data_hash, locked_plan_hash, results_hash)
154
+
155
+ # ── Build manifest ──────────────────────────────────────────────────
156
+ manifest = {
157
+ "schema_version": SCHEMA_VERSION,
158
+ "study_id": study_id,
159
+ "generated_at": datetime.now(timezone.utc).isoformat(),
160
+ "hash_algorithm": "SHA-256",
161
+ "composite_separator": COMPOSITE_SEPARATOR,
162
+ "composite_hash": composite_hash,
163
+ "raw_data_hash": raw_data_hash,
164
+ "locked_plan_hash": locked_plan_hash,
165
+ "results_hash": results_hash,
166
+ "verification_instructions": (
167
+ "To verify this bundle, recompute each component hash from the "
168
+ "bundle's own files and compare to the manifest:\n"
169
+ f" 1. raw_data_hash (SHA-256 of raw_data.json): "
170
+ f"this file is a canonical-JSON snapshot of the actual ingested "
171
+ f"dataset as stored in the study database at bundling time "
172
+ f"(post whitespace-stripping, post NA-value normalization). "
173
+ f"It is NOT necessarily byte-identical to the originally uploaded "
174
+ f"CSV, and should not be treated as a re-ingestable source file.\n"
175
+ f" 2. locked_plan_hash: content_hash from "
176
+ f"study_plan.locked.json\n"
177
+ f" 3. results_hash: SHA-256 of analysis_results.json "
178
+ f"(canonical JSON)\n"
179
+ f" 4. composite_hash: SHA-256 of "
180
+ f"{raw_data_hash}{COMPOSITE_SEPARATOR}"
181
+ f"{locked_plan_hash}{COMPOSITE_SEPARATOR}"
182
+ f"{results_hash}\n"
183
+ "If the composite matches, all three components are authentic.\n"
184
+ "If it doesn't, check each component hash individually to find "
185
+ "which file was altered."
186
+ ),
187
+ }
188
+
189
+ # ── STROBE report ───────────────────────────────────────────────────
190
+ from core.reporting.strobe_checklist import generate_report
191
+ strobe_text = generate_report(study_id)
192
+
193
+ # ── Manuscript draft ────────────────────────────────────────────────
194
+ # Check if it exists on disk; if not, generate it in-memory
195
+ draft_path = DATA_ROOT / study_id / "manuscript_draft.md"
196
+ if draft_path.exists():
197
+ draft_text = draft_path.read_text()
198
+ else:
199
+ from core.reporting.manuscript_draft import generate_draft
200
+ draft_text = generate_draft(study_id)
201
+
202
+ # ── Write the bundle archive ────────────────────────────────────────
203
+ bundle_filename = f"{study_id}_bundle.tar.gz"
204
+ bundle_path = DATA_ROOT / study_id / bundle_filename
205
+
206
+ with tarfile.open(str(bundle_path), "w:gz") as tar:
207
+ # manifest.json
208
+ manifest_bytes = _canonical_json(manifest).encode("utf-8")
209
+ info = tarfile.TarInfo(name="manifest.json")
210
+ info.size = len(manifest_bytes)
211
+ tar.addfile(info, io.BytesIO(manifest_bytes))
212
+
213
+ # raw_data.csv — one JSON object per line (JSON Lines format)
214
+ raw_lines = raw_data_json # canonical JSON array
215
+ raw_bytes = raw_lines.encode("utf-8")
216
+ info = tarfile.TarInfo(name="raw_data.json")
217
+ info.size = len(raw_bytes)
218
+ tar.addfile(info, io.BytesIO(raw_bytes))
219
+
220
+ # study_plan.locked.json
221
+ plan_bytes = locked_plan_json.encode("utf-8")
222
+ info = tarfile.TarInfo(name="study_plan.locked.json")
223
+ info.size = len(plan_bytes)
224
+ tar.addfile(info, io.BytesIO(plan_bytes))
225
+
226
+ # analysis_results.json
227
+ results_bytes = results_json.encode("utf-8")
228
+ info = tarfile.TarInfo(name="analysis_results.json")
229
+ info.size = len(results_bytes)
230
+ tar.addfile(info, io.BytesIO(results_bytes))
231
+
232
+ # manuscript_draft.md
233
+ draft_bytes = draft_text.encode("utf-8")
234
+ info = tarfile.TarInfo(name="manuscript_draft.md")
235
+ info.size = len(draft_bytes)
236
+ tar.addfile(info, io.BytesIO(draft_bytes))
237
+
238
+ # strobe_report.txt
239
+ strobe_bytes = strobe_text.encode("utf-8")
240
+ info = tarfile.TarInfo(name="strobe_report.txt")
241
+ info.size = len(strobe_bytes)
242
+ tar.addfile(info, io.BytesIO(strobe_bytes))
243
+
244
+ return {
245
+ "bundle_path": bundle_path,
246
+ "composite_hash": composite_hash,
247
+ "raw_data_hash": raw_data_hash,
248
+ "locked_plan_hash": locked_plan_hash,
249
+ "results_hash": results_hash,
250
+ }
251
+
252
+
253
+ def verify_bundle(bundle_path: str | Path) -> dict:
254
+ """Verify a bundle archive's integrity by recomputing all hashes.
255
+
256
+ Returns a dict with keys:
257
+ - ``valid`` (bool): True if composite matches
258
+ - ``composite_match`` (bool)
259
+ - ``raw_data_match`` (bool)
260
+ - ``locked_plan_match`` (bool)
261
+ - ``results_match`` (bool)
262
+ - ``manifest`` (dict): the manifest from the bundle
263
+ - ``composite_hash`` (str): hash from manifest
264
+ - ``computed_composite`` (str): hash computed from bundle contents
265
+
266
+ Works from the bundle file ALONE — no dependency on the original
267
+ study database.
268
+ """
269
+ bundle_path = Path(bundle_path)
270
+ if not bundle_path.exists():
271
+ raise FileNotFoundError(f"Bundle not found: {bundle_path}")
272
+
273
+ # Extract bundle into memory
274
+ contents: dict[str, bytes] = {}
275
+ with tarfile.open(str(bundle_path), "r:gz") as tar:
276
+ for member in tar.getmembers():
277
+ f = tar.extractfile(member)
278
+ if f is not None:
279
+ contents[member.name] = f.read()
280
+
281
+ if "manifest.json" not in contents:
282
+ raise ValueError("Bundle is missing manifest.json")
283
+
284
+ manifest = json.loads(contents["manifest.json"].decode("utf-8"))
285
+
286
+ # Track which expected files are present vs missing
287
+ expected_files = ["raw_data.json", "study_plan.locked.json", "analysis_results.json"]
288
+ missing_files = [f for f in expected_files if f not in contents]
289
+
290
+ # Recompute each component hash from the bundle's own file contents
291
+ raw_data_present = "raw_data.json" in contents
292
+ raw_data_bytes = contents.get("raw_data.json", b"")
293
+ computed_raw_hash = _sha256(raw_data_bytes.decode("utf-8")) if raw_data_present else ""
294
+
295
+ locked_plan_present = "study_plan.locked.json" in contents
296
+ plan_bytes = contents.get("study_plan.locked.json", b"")
297
+ if locked_plan_present:
298
+ plan_data = json.loads(plan_bytes.decode("utf-8"))
299
+ computed_plan_hash = plan_data.get("content_hash", "")
300
+ else:
301
+ computed_plan_hash = ""
302
+
303
+ results_present = "analysis_results.json" in contents
304
+ results_bytes = contents.get("analysis_results.json", b"")
305
+ computed_results_hash = _sha256(results_bytes.decode("utf-8")) if results_present else ""
306
+
307
+ # Composite: SHA-256 of the three component hashes
308
+ computed_composite = _compute_composite(
309
+ computed_raw_hash, computed_plan_hash, computed_results_hash,
310
+ )
311
+
312
+ reported_composite = manifest.get("composite_hash", "")
313
+
314
+ return {
315
+ "valid": computed_composite == reported_composite,
316
+ "composite_match": computed_composite == reported_composite,
317
+ "raw_data_match": raw_data_present and computed_raw_hash == manifest.get("raw_data_hash"),
318
+ "locked_plan_match": locked_plan_present and computed_plan_hash == manifest.get("locked_plan_hash"),
319
+ "results_match": results_present and computed_results_hash == manifest.get("results_hash"),
320
+ "missing_files": missing_files,
321
+ "manifest": manifest,
322
+ "composite_hash": reported_composite,
323
+ "computed_composite": computed_composite,
324
+ "computed_raw_hash": computed_raw_hash,
325
+ "computed_plan_hash": computed_plan_hash,
326
+ "computed_results_hash": computed_results_hash,
327
+ }
328
+
329
+
330
+ def format_verification_report(result: dict) -> str:
331
+ """Format a verification result dict as a human-readable report."""
332
+ lines = [
333
+ "Bundle Verification Report",
334
+ "=" * 50,
335
+ f"Composite hash (manifest): {result['composite_hash']}",
336
+ f"Composite hash (computed): {result['computed_composite']}",
337
+ "",
338
+ ]
339
+ if result["valid"]:
340
+ lines.append("✓ PASS — all hashes match. Bundle is authentic.")
341
+ else:
342
+ lines.append("✗ FAIL — composite hash mismatch.")
343
+ lines.append("")
344
+
345
+ missing = result.get("missing_files", [])
346
+ for fname in missing:
347
+ lines.append(f" ✗ {fname} is missing from the archive.")
348
+
349
+ if "raw_data.json" not in missing and not result.get("raw_data_match"):
350
+ m = result["manifest"].get("raw_data_hash", "???")
351
+ c = result["computed_raw_hash"]
352
+ lines.append(f" ✗ raw_data_hash: manifest={m}, computed={c}")
353
+ if "study_plan.locked.json" not in missing and not result.get("locked_plan_match"):
354
+ m = result["manifest"].get("locked_plan_hash", "???")
355
+ c = result["computed_plan_hash"]
356
+ lines.append(f" ✗ locked_plan_hash: manifest={m}, computed={c}")
357
+ if "analysis_results.json" not in missing and not result.get("results_match"):
358
+ m = result["manifest"].get("results_hash", "???")
359
+ c = result["computed_results_hash"]
360
+ lines.append(f" ✗ results_hash: manifest={m}, computed={c}")
361
+ lines.append("")
362
+
363
+ if "raw_data.json" not in missing and not result.get("raw_data_match"):
364
+ lines.append(" → raw_data.json content has been altered.")
365
+ elif "raw_data.json" in missing:
366
+ lines.append(" → raw_data.json is missing from the archive.")
367
+
368
+ if "study_plan.locked.json" not in missing and not result.get("locked_plan_match"):
369
+ lines.append(" → study_plan.locked.json content has been altered.")
370
+ elif "study_plan.locked.json" in missing:
371
+ lines.append(" → study_plan.locked.json is missing from the archive.")
372
+
373
+ if "analysis_results.json" not in missing and not result.get("results_match"):
374
+ lines.append(" → analysis_results.json content has been altered.")
375
+ elif "analysis_results.json" in missing:
376
+ lines.append(" → analysis_results.json is missing from the archive.")
377
+
378
+ return "\n".join(lines)