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,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("&", "&")
|
|
40
|
+
.replace("<", "<")
|
|
41
|
+
.replace(">", ">")
|
|
42
|
+
.replace('"', """)
|
|
43
|
+
.replace("'", "'"))
|
|
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)
|
core/reporting/bundle.py
ADDED
|
@@ -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)
|