agent-harness / scripts /analyze_study3.py
cuber12's picture
Publish agent harness research code and paper artifacts
d61821a verified
Raw
History Blame Contribute Delete
28.3 kB
#!/usr/bin/env python3
"""Prespecified fail-closed analysis for the frozen E09 protocol study."""
from __future__ import annotations
import argparse
from collections import Counter
import csv
from hashlib import sha256
import json
import math
from pathlib import Path
import random
import statistics
import subprocess
import tomllib
from typing import Any, Sequence
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
MODELS = ("M002", "M003", "M004")
INTERFACES = ("P001", "P002", "P003")
REPOSITORIES = ("R001", "R002", "R003")
BOOTSTRAPS = 20_000
BOOTSTRAP_SEED = 20260719
PRIMARY = ("P1_M003_P002_vs_P001_accepted", "accepted_edit_cell", "M003", "P002", "P001")
class Study3AnalysisError(RuntimeError):
pass
def percentile(values: Sequence[float], probability: float) -> float:
ordered = sorted(float(value) for value in values)
if not ordered:
raise ValueError("percentile requires values")
position = (len(ordered) - 1) * probability
low, high = math.floor(position), math.ceil(position)
if low == high:
return ordered[low]
return ordered[low] * (high - position) + ordered[high] * (position - low)
def bootstrap_mean_ci(
values: Sequence[float], rng: random.Random, samples: int = BOOTSTRAPS
) -> tuple[float, float]:
draws = [statistics.fmean(rng.choice(values) for _ in values) for _ in range(samples)]
return percentile(draws, 0.025), percentile(draws, 0.975)
def exact_mcnemar(left: Sequence[int], right: Sequence[int]) -> tuple[int, int, float]:
if len(left) != len(right):
raise ValueError("paired vectors differ in length")
n10 = sum(a == 1 and b == 0 for a, b in zip(left, right))
n01 = sum(a == 0 and b == 1 for a, b in zip(left, right))
discordant = n10 + n01
if discordant == 0:
return n10, n01, 1.0
tail = sum(math.comb(discordant, k) for k in range(min(n10, n01) + 1)) / 2**discordant
return n10, n01, min(1.0, 2 * tail)
def holm_adjust(values: Sequence[float]) -> list[float]:
adjusted = [1.0] * len(values)
running = 0.0
for rank, index in enumerate(sorted(range(len(values)), key=values.__getitem__)):
running = max(running, min(1.0, (len(values) - rank) * values[index]))
adjusted[index] = running
return adjusted
def sha256_file(path: Path) -> str:
value = sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
value.update(block)
return value.hexdigest()
def git_output(root: Path, *arguments: str) -> str:
return subprocess.run(
["git", *arguments], cwd=root, check=True, capture_output=True, text=True
).stdout.strip()
def task_metadata(root: Path) -> dict[str, dict[str, Any]]:
repositories: dict[str, dict[str, Any]] = {}
urls: dict[str, str] = {}
for path in sorted((root / "configs/repositories").glob("R*.toml")):
value = tomllib.loads(path.read_text(encoding="utf-8"))
repository_id = str(value["repository_id"])
repositories[repository_id] = value
urls[str(value["repository_url"])] = repository_id
split = [
line.strip()
for line in (root / "tasks/splits/study3_protocol.txt").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
result: dict[str, dict[str, Any]] = {}
for task_id in split:
value = tomllib.loads(
(root / "tasks/manifests" / f"{task_id}.toml").read_text(encoding="utf-8")
)
repository_id = urls[str(value["repository_url"])]
result[task_id] = {**value, "repository_id": repository_id}
if len(result) != 60 or Counter(v["repository_id"] for v in result.values()) != Counter(
{"R001": 20, "R002": 20, "R003": 20}
):
raise Study3AnalysisError("E09 task metadata is not the frozen 20/20/20 split")
return result
def _required_artifacts(directory: Path) -> None:
required = (
"run_manifest.json",
"final_metrics.json",
"messages.json",
"model.patch",
"validation.json",
"trajectory.jsonl",
)
missing = [item for item in required if not (directory / item).is_file()]
if missing:
raise Study3AnalysisError(f"missing artifacts {missing} in {directory}")
def discover(root: Path) -> tuple[list[dict[str, Any]], dict[str, Any]]:
tasks = task_metadata(root)
paths = sorted((root / "results/raw/E09").rglob("final_metrics.json"))
if len(paths) != 540:
raise Study3AnalysisError(f"E09 requires 540 final metrics; observed {len(paths)}")
expected = {
(task_id, interface, model, 0, 0)
for task_id in tasks
for interface in INTERFACES
for model in MODELS
}
identities: set[tuple[Any, ...]] = set()
run_ids: set[str] = set()
revisions: Counter[str] = Counter()
model_keys: Counter[str] = Counter()
responses = 0
raw_digest = sha256()
rows: list[dict[str, Any]] = []
for path in paths:
directory = path.parent
_required_artifacts(directory)
final = json.loads(path.read_text(encoding="utf-8"))
manifest = json.loads((directory / "run_manifest.json").read_text(encoding="utf-8"))
identity = manifest["identity"]
key = tuple(
identity[item]
for item in ("task_id", "harness_id", "model_id", "seed", "repetition")
)
if key in identities:
raise Study3AnalysisError(f"duplicate E09 identity: {key}")
identities.add(key)
if key not in expected:
raise Study3AnalysisError(f"unexpected E09 identity: {key}")
run_id = str(final["run_id"])
if run_id in run_ids or run_id != manifest["run_id"]:
raise Study3AnalysisError(f"duplicate/mismatched run ID: {run_id}")
run_ids.add(run_id)
task_id, interface, model_id, seed, repetition = key
if final["edit_interface_id"] != interface or final["model_id"] != model_id:
raise Study3AnalysisError(f"manifest/final mismatch in {directory}")
resolved_model = manifest["resolved_model"]
runtime = resolved_model["agent_runtime"]
if runtime["inference_key"] != identity["model_key"]:
raise Study3AnalysisError(f"inference identity mismatch in {directory}")
loaded = runtime["native_record"].get("loaded_instances") or []
if len(loaded) != 1 or int(loaded[0]["config"]["context_length"]) != 65536:
raise Study3AnalysisError(f"runtime context/residency mismatch in {directory}")
if any(len(item.get("after_instances", [])) != 1 for item in final["residency_transitions"]):
raise Study3AnalysisError(f"non-exclusive residency in {directory}")
response_paths = sorted(directory.glob("model_response_*.json"))
if len(response_paths) != int(final["model_calls"]):
raise Study3AnalysisError(f"response count mismatch in {directory}")
responses += len(response_paths)
revisions[str(identity["code_revision"])] += 1
model_keys[str(identity["model_key"])] += 1
usage = final.get("usage") or {}
metadata = tasks[str(task_id)]
row = {
"run_id": run_id,
"task_id": str(task_id),
"repository_id": metadata["repository_id"],
"language": metadata["language"],
"difficulty": metadata["difficulty"],
"interface_id": str(interface),
"edit_tool": str(final["edit_tool"]),
"model_id": str(model_id),
"model_key": str(identity["model_key"]),
"model_config_hash": str(identity["model_config_hash"]),
"interface_config_hash": str(identity["harness_hash"]),
"code_revision": str(identity["code_revision"]),
"seed": int(seed),
"repetition": int(repetition),
"accepted_edit_cell": int(bool(final["accepted_edit_cell"])),
"edit_attempts": int(final["edit_attempts"]),
"edit_acceptances": int(final["edit_acceptances"]),
"applicable_final_patch": int(bool(final["applicable_final_patch"])),
"resolved_at_1": int(bool(final["resolved_at_1"])),
"fail_to_pass": int(bool(final["fail_to_pass"])),
"pass_to_pass": int(bool(final["pass_to_pass"])),
"exact_modified_file_match": int(bool(final["exact_modified_file_match"])),
"gold_file_modified_recall": float(final["gold_file_modified_recall"]),
"read_file_recall_at_10": float(
final["read_localization_metrics"]["file_recall_at_10"]
),
"protocol_violation_count": len(final["protocol_violations"]),
"model_calls": int(final["model_calls"]),
"tool_calls": int(final["tool_calls"]),
"test_runs": int(final["test_runs"]),
"prompt_tokens": int(usage.get("prompt_tokens", 0)),
"completion_tokens": int(usage.get("completion_tokens", 0)),
"total_tokens": int(usage.get("total_tokens", 0)),
"elapsed_seconds": float(final["elapsed_seconds"]),
"finished_reason": str(final["finished_reason"]),
"failure_stage": str(final["failure_stage"]),
}
rows.append(row)
for artifact in (directory / "run_manifest.json", path):
raw_digest.update(
artifact.relative_to(root).as_posix().encode()
+ b"\0"
+ artifact.read_bytes()
+ b"\0"
)
if identities != expected:
raise Study3AnalysisError(f"E09 grid mismatch; missing={sorted(expected-identities)[:5]}")
if len(revisions) != 1:
raise Study3AnalysisError(f"mixed E09 revisions: {dict(revisions)}")
revision = next(iter(revisions))
preflight_path = root / "results/reports/study3_preflight.json"
if not preflight_path.is_file():
raise Study3AnalysisError("Study 3 preflight report is missing")
preflight = json.loads(preflight_path.read_text(encoding="utf-8"))
if not preflight.get("passed") or preflight.get("research_code_revision") != revision:
raise Study3AnalysisError("preflight did not pass on the E09 execution revision")
audit = {
"cells": len(rows),
"tasks": len(tasks),
"unique_run_ids": len(run_ids),
"unique_execution_identities": len(identities),
"execution_revision": revision,
"model_keys": dict(sorted(model_keys.items())),
"model_responses": responses,
"cells_per_model": dict(sorted(Counter(r["model_id"] for r in rows).items())),
"cells_per_interface": dict(sorted(Counter(r["interface_id"] for r in rows).items())),
"raw_manifest_and_metrics_sha256": raw_digest.hexdigest(),
"raw_manifest_and_metrics_file_count": 1080,
"infrastructure_attempts": len(
list((root / "results/infrastructure_attempts/E09").rglob("archive_record.json"))
),
}
return rows, audit
def summarize(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
rng = random.Random(BOOTSTRAP_SEED)
result: list[dict[str, Any]] = []
for model in MODELS:
for interface in INTERFACES:
group = [r for r in rows if r["model_id"] == model and r["interface_id"] == interface]
accepted = [float(r["accepted_edit_cell"]) for r in group]
low, high = bootstrap_mean_ci(accepted, rng)
attempts = sum(int(r["edit_attempts"]) for r in group)
acceptances = sum(int(r["edit_acceptances"]) for r in group)
result.append(
{
"model_id": model,
"interface_id": interface,
"n": len(group),
"accepted_edit_count": int(sum(accepted)),
"accepted_edit_rate": statistics.fmean(accepted),
"accepted_ci_low": low,
"accepted_ci_high": high,
"edit_attempts": attempts,
"edit_acceptances": acceptances,
"attempt_acceptance_rate": acceptances / attempts if attempts else 0.0,
"applicable_count": sum(int(r["applicable_final_patch"]) for r in group),
"applicable_rate": statistics.fmean(float(r["applicable_final_patch"]) for r in group),
"resolved_count": sum(int(r["resolved_at_1"]) for r in group),
"resolved_rate": statistics.fmean(float(r["resolved_at_1"]) for r in group),
"exact_file_rate": statistics.fmean(float(r["exact_modified_file_match"]) for r in group),
"mean_total_tokens": statistics.fmean(float(r["total_tokens"]) for r in group),
"mean_elapsed_seconds": statistics.fmean(float(r["elapsed_seconds"]) for r in group),
"mean_model_calls": statistics.fmean(float(r["model_calls"]) for r in group),
"mean_tool_calls": statistics.fmean(float(r["tool_calls"]) for r in group),
"protocol_violation_rate": statistics.fmean(float(r["protocol_violation_count"] > 0) for r in group),
}
)
return result
def _paired(
rows: Sequence[dict[str, Any]], name: str, endpoint: str, model: str, left: str, right: str,
family: str,
) -> dict[str, Any]:
by = {(r["task_id"], r["model_id"], r["interface_id"]): r for r in rows}
tasks = sorted({r["task_id"] for r in rows})
left_values = [int(by[(task, model, left)][endpoint]) for task in tasks]
right_values = [int(by[(task, model, right)][endpoint]) for task in tasks]
differences = [a - b for a, b in zip(left_values, right_values)]
rng = random.Random(BOOTSTRAP_SEED + sum(ord(c) for c in name))
low, high = bootstrap_mean_ci(differences, rng)
n10, n01, p_value = exact_mcnemar(left_values, right_values)
return {
"contrast": name,
"family": family,
"endpoint": endpoint,
"model_id": model,
"left_interface": left,
"right_interface": right,
"tasks": len(tasks),
"left_count": sum(left_values),
"right_count": sum(right_values),
"paired_risk_difference": statistics.fmean(differences),
"risk_difference_ci_low": low,
"risk_difference_ci_high": high,
"discordant_left_only": n10,
"discordant_right_only": n01,
"mcnemar_p": p_value,
"mcnemar_p_holm": p_value,
}
def contrasts(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
primary = _paired(rows, *PRIMARY, family="confirmatory_primary")
secondary: list[dict[str, Any]] = []
pairs = (("P002", "P001"), ("P003", "P001"), ("P003", "P002"))
index = 1
for endpoint in ("accepted_edit_cell", "resolved_at_1"):
for model in MODELS:
for left, right in pairs:
if endpoint == "accepted_edit_cell" and model == "M003" and (left, right) == (
"P002",
"P001",
):
continue
secondary.append(
_paired(
rows,
f"S{index:02d}_{model}_{left}_vs_{right}_{endpoint}",
endpoint,
model,
left,
right,
family="prespecified_secondary",
)
)
index += 1
adjusted = holm_adjust([row["mcnemar_p"] for row in secondary])
for row, value in zip(secondary, adjusted):
row["mcnemar_p_holm"] = value
return [primary, *secondary]
def compatibility_gate(summaries: Sequence[dict[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {
"criteria": {
"minimum_accepted_edit_cells": 12,
"minimum_attempt_acceptance_rate": 0.5,
"selection_order": [
"accepted_edit_rate descending",
"applicable_rate descending",
"mean_total_tokens ascending",
"interface_id ascending",
],
"hidden_test_resolution_used": False,
},
"models": {},
}
for model in MODELS:
values = [row for row in summaries if row["model_id"] == model]
qualifying = [
row
for row in values
if row["accepted_edit_count"] >= 12 and row["attempt_acceptance_rate"] >= 0.5
]
qualifying.sort(
key=lambda row: (
-row["accepted_edit_rate"],
-row["applicable_rate"],
row["mean_total_tokens"],
row["interface_id"],
)
)
result["models"][model] = {
"qualifying_interfaces": [row["interface_id"] for row in qualifying],
"selected_interface": qualifying[0]["interface_id"] if qualifying else None,
"eligible_for_fresh_retrieval": bool(qualifying),
}
return result
def hierarchical_model(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
try:
from statsmodels.genmod.bayes_mixed_glm import BinomialBayesMixedGLM
frame = pd.DataFrame(rows)
formula = (
"accepted_edit_cell ~ C(interface_id, Treatment(reference='P001')) * "
"C(model_id, Treatment(reference='M002')) + "
"C(repository_id, Treatment(reference='R001'))"
)
fitted = BinomialBayesMixedGLM.from_formula(
formula, {"task": "0 + C(task_id)"}, frame
).fit_vb()
names = list(fitted.model.exog_names)
means = np.asarray(fitted.params[: len(names)], dtype=float)
standard = np.asarray(fitted.fe_sd, dtype=float)
coefficients = [
{
"term": name,
"log_odds_mean": float(mean),
"log_odds_sd": float(sd),
"odds_ratio": float(math.exp(mean)),
"or_interval_low": float(math.exp(mean - 1.96 * sd)),
"or_interval_high": float(math.exp(mean + 1.96 * sd)),
}
for name, mean, sd in zip(names, means, standard)
]
return {
"status": "converged",
"model_type": "Bayesian binomial mixed model (variational Bayes)",
"formula": formula,
"task_random_intercept": True,
"coefficients": coefficients,
}
except Exception as exc:
return {
"status": "failed",
"model_type": "Bayesian binomial mixed model (variational Bayes)",
"error": repr(exc),
"coefficients": [],
}
def failure_analysis(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
return {
"failure_stages": dict(sorted(Counter(r["failure_stage"] for r in rows).items())),
"finished_reasons": dict(sorted(Counter(r["finished_reason"] for r in rows).items())),
"cells_with_edit_attempt": sum(r["edit_attempts"] > 0 for r in rows),
"accepted_edit_cells": sum(r["accepted_edit_cell"] for r in rows),
"applicable_final_patches": sum(r["applicable_final_patch"] for r in rows),
"resolved_cells": sum(r["resolved_at_1"] for r in rows),
"protocol_violation_cells": sum(r["protocol_violation_count"] > 0 for r in rows),
}
def write_csv(path: Path, rows: Sequence[dict[str, Any]]) -> None:
fields = list(rows[0]) if rows else []
with path.open("w", newline="", encoding="utf-8") as handle:
if not fields:
return
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def paper_tables(
path: Path,
summaries: Sequence[dict[str, Any]],
contrast_rows: Sequence[dict[str, Any]],
gate: dict[str, Any],
) -> None:
lines = [
"% Generated by scripts/analyze_study3.py; do not edit.",
r"\begin{table*}[t]",
r"\centering\small",
r"\caption{E09 protocol outcomes by model and edit interface.}",
r"\label{tab:e09-main}",
r"\begin{tabular}{lllrrrrrr}",
r"\toprule",
r"Model & Interface & Accepted & Attempt acceptance & Applicable & Resolved & Exact files & Mean tokens \\",
r"\midrule",
]
for row in summaries:
lines.append(
f"{row['model_id']} & {row['interface_id']} & "
f"{row['accepted_edit_count']}/{row['n']} ({row['accepted_edit_rate']:.3f}) & "
f"{row['edit_acceptances']}/{row['edit_attempts']} ({row['attempt_acceptance_rate']:.3f}) & "
f"{row['applicable_count']}/{row['n']} & {row['resolved_count']}/{row['n']} & "
f"{row['exact_file_rate']:.3f} & {row['mean_total_tokens']:.0f} \\\\"
)
lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table*}", ""])
primary = contrast_rows[0]
lines.extend(
[
r"\begin{table}[t]",
r"\centering\small",
r"\caption{E09 sole confirmatory paired contrast.}",
r"\label{tab:e09-primary}",
r"\begin{tabular}{lr}",
r"\toprule",
r"Statistic & Value \\",
r"\midrule",
f"P002/P001 accepted & {primary['left_count']}/60 vs {primary['right_count']}/60 \\\\ ",
f"Paired risk difference & {primary['paired_risk_difference']:+.3f} "
f"[{primary['risk_difference_ci_low']:+.3f}, {primary['risk_difference_ci_high']:+.3f}] \\\\ ",
f"Discordant P002/P001 only & {primary['discordant_left_only']}/{primary['discordant_right_only']} \\\\ ",
f"Exact McNemar $p$ & {primary['mcnemar_p']:.4g} \\\\ ",
r"\bottomrule",
r"\end{tabular}",
r"\end{table}",
"",
"% Compatibility gate: " + json.dumps(gate, sort_keys=True),
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def plots(
output: Path,
summaries: Sequence[dict[str, Any]],
contrast_rows: Sequence[dict[str, Any]],
) -> list[Path]:
x = np.arange(len(INTERFACES))
width = 0.24
colors = {"M002": "#2364aa", "M003": "#f18f01", "M004": "#6a994e"}
fig, axes = plt.subplots(1, 3, figsize=(11.0, 3.8), sharey=True)
for axis, endpoint, title in zip(
axes,
("accepted_edit_rate", "applicable_rate", "resolved_rate"),
("Executor-accepted edit", "Applicable final patch", "Resolved@1"),
):
for offset_index, model in enumerate(MODELS):
values = [
next(
row[endpoint]
for row in summaries
if row["model_id"] == model and row["interface_id"] == interface
)
for interface in INTERFACES
]
offset = (offset_index - 1) * width
axis.bar(x + offset, values, width, label=model, color=colors[model])
axis.set_xticks(x, INTERFACES)
axis.set_title(title)
axis.set_ylim(0, 1)
axis.grid(axis="y", alpha=0.25)
axes[0].set_ylabel("Cell rate")
axes[-1].legend(frameon=False)
fig.tight_layout()
bars = output / "e09_protocol_outcomes.pdf"
fig.savefig(bars)
fig.savefig(output / "e09_protocol_outcomes.png", dpi=240)
plt.close(fig)
accepted = [row for row in contrast_rows if row["endpoint"] == "accepted_edit_cell"]
forest = list(reversed(accepted))
estimates = np.asarray([row["paired_risk_difference"] for row in forest])
low = np.asarray([row["risk_difference_ci_low"] for row in forest])
high = np.asarray([row["risk_difference_ci_high"] for row in forest])
fig, axis = plt.subplots(figsize=(8.0, 5.2))
y = np.arange(len(forest))
axis.errorbar(
estimates,
y,
xerr=[estimates - low, high - estimates],
fmt="o",
color="#2a6f97",
capsize=3,
)
axis.axvline(0, color="black", linewidth=0.8)
axis.set_yticks(y, [row["contrast"] for row in forest], fontsize=8)
axis.set_xlabel("Paired accepted-edit risk difference")
axis.set_xlim(-1, 1)
axis.grid(axis="x", alpha=0.25)
fig.tight_layout()
forest_path = output / "e09_accepted_contrast_forest.pdf"
fig.savefig(forest_path)
fig.savefig(output / "e09_accepted_contrast_forest.png", dpi=240)
plt.close(fig)
return [bars, forest_path]
def analyze(root: Path) -> dict[str, Any]:
rows, audit = discover(root)
summaries = summarize(rows)
contrast_rows = contrasts(rows)
gate = compatibility_gate(summaries)
hierarchy = hierarchical_model(rows)
failures = failure_analysis(rows)
output = root / "results/derived/study3"
output.mkdir(parents=True, exist_ok=True)
csv_files: list[Path] = []
for name, values in (
("e09_cells.csv", rows),
("e09_model_interface_summary.csv", summaries),
("e09_contrasts.csv", contrast_rows),
("e09_hierarchical_coefficients.csv", hierarchy["coefficients"]),
):
path = output / name
write_csv(path, values)
csv_files.append(path)
table_path = output / "e09_paper_tables.tex"
paper_tables(table_path, summaries, contrast_rows, gate)
figures = plots(output, summaries, contrast_rows)
analysis_revision = git_output(root, "rev-parse", "HEAD")
manifest = {
"schema_version": 1,
"experiment_id": "E09",
"execution_revision": audit["execution_revision"],
"analysis_code_revision": analysis_revision,
"analysis_script_sha256": sha256_file(root / "scripts/analyze_study3.py"),
"input_cells": 540,
"input_run_ids": sorted(row["run_id"] for row in rows),
"raw_manifest_and_metrics_sha256": audit["raw_manifest_and_metrics_sha256"],
"bootstrap_samples": BOOTSTRAPS,
"bootstrap_seed": BOOTSTRAP_SEED,
"primary_test": "P002 versus P001 accepted-edit cell on M003; two-sided exact McNemar",
"secondary_multiplicity": "Holm across 17 prespecified binary contrasts",
"gate_uses_resolution": False,
}
report = {
"schema_version": 1,
"experiment_id": "E09",
"claim_boundary": {
"confirmatory": "P002 versus P001 accepted-edit cell on M003 only",
"resolution": "secondary end-to-end endpoint",
"task_reuse": "same 60 E08 tasks; new protocol-causality estimand, not independent benchmark replication",
"language_limit": "repository and language remain confounded",
},
"audit": audit,
"model_interface_summaries": summaries,
"contrasts": contrast_rows,
"compatibility_gate": gate,
"hierarchical_model": hierarchy,
"failure_analysis": failures,
"analysis_manifest": manifest,
}
manifest_path = output / "analysis_manifest.json"
report_path = output / "e09_analysis.json"
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
generated = [*csv_files, table_path, *figures, manifest_path, report_path]
checksums = {path.name: sha256_file(path) for path in generated}
(output / "SHA256SUMS.json").write_text(
json.dumps(checksums, indent=2, sort_keys=True) + "\n"
)
return {
**report,
"output_directory": str(output),
"generated_file_count": len(generated) + 1,
"checksums": checksums,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
arguments = parser.parse_args()
try:
result = analyze(arguments.root.resolve())
except (
Study3AnalysisError,
OSError,
ValueError,
KeyError,
subprocess.CalledProcessError,
) as exc:
print(f"STUDY 3 ANALYSIS FAILED: {exc}")
return 1
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())