Skills › Scientific Agent Skills › matlab › reproducibility_report.py
reproducibility_report.py
233 lines · skills/matlab/scripts/reproducibility_report.py at 5f2bc03
1#!/usr/bin/env python3
2"""Create a deterministic named-file reproducibility report."""
4from __future__ import annotations
6import argparse
7import json
8import math
9import re
10import sys
11from pathlib import Path
12from typing import Any
14from _common import (
15 CliError,
16 checked_input,
17 checked_output,
18 checked_root,
19 emit_json,
20 fail_json,
21 load_json,
22 relative_id,
23 sha256_file,
24 validate_release,
25 write_new_text,
26)
28TOOL = "reproducibility_report"
29OCTAVE_VERSION = re.compile(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$")
30ALLOWED_SUFFIXES = {
31 ".csv",
32 ".fig",
33 ".h5",
34 ".hdf5",
35 ".json",
36 ".m",
37 ".mat",
38 ".mlx",
39 ".pdf",
40 ".png",
41 ".svg",
42 ".tsv",
43 ".txt",
44}
47def _finite_nonnegative(value: float | None, name: str) -> float | None:
48 if value is None:
49 return None
50 if not math.isfinite(value) or value < 0:
51 raise CliError(f"{name} must be finite and nonnegative")
52 return value
55def _runtime_version(runtime: str, version: str) -> str:
56 if runtime == "matlab":
57 return validate_release(version)
58 if not OCTAVE_VERSION.fullmatch(version):
59 raise CliError("Octave runtime version must be a three-part numeric version")
60 return version
63def _named_fact(value: str | None, name: str, *, maximum: int = 500) -> str | None:
64 if value is None:
65 return None
66 if not value or len(value) > maximum or any(
67 ord(character) < 32 for character in value
68 ):
69 raise CliError(f"{name} must be bounded printable text")
70 return value
73def build(args: argparse.Namespace) -> dict[str, Any]:
74 root = checked_root(args.root)
75 if len(args.file) > 200:
76 raise CliError("at most 200 named files are accepted")
77 if not args.file:
78 raise CliError("provide at least one --file")
79 files = [
80 checked_input(
81 value,
82 root=root,
83 suffixes=ALLOWED_SUFFIXES,
84 max_bytes=args.max_file_bytes,
85 )
86 for value in args.file
87 ]
88 ids = [relative_id(path, root) for path in files]
89 if len(set(ids)) != len(ids):
90 raise CliError("--file paths must be unique")
91 file_records = [
92 {
93 "path": relative_id(path, root),
94 "sha256": sha256_file(path, max_bytes=args.max_file_bytes),
95 "size_bytes": path.stat().st_size,
96 }
97 for path in sorted(files, key=lambda item: relative_id(item, root))
98 ]
99 product_manifest: dict[str, Any] | None = None
100 if args.product_manifest:
101 path = checked_input(
102 args.product_manifest,
103 root=root,
104 suffixes={".json"},
105 max_bytes=2 * 1024 * 1024,
106 )
107 data = load_json(path)
108 if not isinstance(data, dict) or data.get("schema_version") != "1.0":
109 raise CliError("product manifest must be a schema_version 1.0 object")
110 product_manifest = {
111 "path": relative_id(path, root),
112 "sha256": sha256_file(path, max_bytes=2 * 1024 * 1024),
113 "license_status_verified": False,
114 }
115 command_plan: dict[str, Any] | None = None
116 if args.command_plan:
117 path = checked_input(
118 args.command_plan,
119 root=root,
120 suffixes={".json"},
121 max_bytes=2 * 1024 * 1024,
122 )
123 data = load_json(path)
124 if not isinstance(data, dict) or data.get("executes") is not False:
125 raise CliError("command plan must be a nonexecuting JSON plan")
126 command_plan = {
127 "path": relative_id(path, root),
128 "sha256": sha256_file(path, max_bytes=2 * 1024 * 1024),
129 }
130 runtime_version = _runtime_version(args.runtime, args.runtime_version)
131 abs_tol = _finite_nonnegative(args.absolute_tolerance, "absolute_tolerance")
132 rel_tol = _finite_nonnegative(args.relative_tolerance, "relative_tolerance")
133 if (abs_tol is not None or rel_tol is not None) and not args.tolerance_rationale:
134 raise CliError(
135 "--tolerance-rationale is required when a numeric tolerance is recorded"
136 )
137 if args.rng_seed is not None and not 0 <= args.rng_seed <= 2**32 - 1:
138 raise CliError("--rng-seed must be between 0 and 4294967295")
139 if args.rng_substream is not None and not 1 <= args.rng_substream <= 2**31 - 1:
140 raise CliError("--rng-substream must be between 1 and 2147483647")
141 rng_algorithm = _named_fact(args.rng_algorithm, "rng_algorithm", maximum=100)
142 tolerance_rationale = _named_fact(
143 args.tolerance_rationale, "tolerance_rationale", maximum=2000
144 )
145 platform_os = _named_fact(args.platform_os, "platform_os", maximum=200)
146 platform_arch = _named_fact(args.platform_arch, "platform_arch", maximum=200)
147 return {
148 "command_plan": command_plan,
149 "environment_dumped": False,
150 "executes": False,
151 "named_files": file_records,
152 "network_accessed": False,
153 "numeric_policy": {
154 "absolute_tolerance": abs_tol,
155 "relative_tolerance": rel_tol,
156 "rationale": tolerance_rationale,
157 },
158 "ok": True,
159 "platform": {
160 "architecture": platform_arch,
161 "operating_system": platform_os,
162 "source": "caller-supplied; not probed",
163 },
164 "products_manifest": product_manifest,
165 "randomness": {
166 "algorithm": rng_algorithm,
167 "seed": args.rng_seed,
168 "substream": args.rng_substream,
169 },
170 "root_emitted": False,
171 "runtime": args.runtime,
172 "runtime_version": runtime_version,
173 "schema_version": "1.0",
174 "tool": TOOL,
175 "warning": (
176 "Hashes and named facts support provenance but do not establish "
177 "safety, scientific validity, or license availability."
178 ),
179 }
182def parser() -> argparse.ArgumentParser:
183 result = argparse.ArgumentParser(
184 description=(
185 "Hash only named local artifacts and emit a deterministic MATLAB/"
186 "Octave reproducibility report. No environment or runtime probe occurs."
187 )
188 )
189 result.add_argument("--root", default=".", help="allowed local root")
190 result.add_argument(
191 "--file", action="append", default=[], help="named local artifact; repeat"
192 )
193 result.add_argument("--runtime", choices=("matlab", "octave"), default="matlab")
194 result.add_argument("--runtime-version", default="R2026a")
195 result.add_argument("--product-manifest")
196 result.add_argument("--command-plan")
197 result.add_argument("--rng-algorithm")
198 result.add_argument("--rng-seed", type=int)
199 result.add_argument("--rng-substream", type=int)
200 result.add_argument("--absolute-tolerance", type=float)
201 result.add_argument("--relative-tolerance", type=float)
202 result.add_argument("--tolerance-rationale")
203 result.add_argument("--platform-os", default="unknown")
204 result.add_argument("--platform-arch", default="unknown")
205 result.add_argument("--max-file-bytes", type=int, default=64 * 1024 * 1024)
206 result.add_argument("--output", help="optional new .json output under root")
207 return result
210def main() -> int:
211 try:
212 args = parser().parse_args()
213 if args.max_file_bytes < 1 or args.max_file_bytes > 512 * 1024 * 1024:
214 raise CliError("--max-file-bytes must be between 1 and 536870912")
215 report = build(args)
216 if args.output:
217 root = checked_root(args.root)
218 output = checked_output(args.output, root=root, suffixes={".json"})
219 write_new_text(
220 output,
221 json.dumps(
222 report, indent=2, sort_keys=True, ensure_ascii=False
223 )
224 + "\n",
225 )
226 emit_json(report)
227 return 0
228 except CliError as exc:
229 return fail_json(TOOL, exc)
232if __name__ == "__main__":
233 sys.exit(main())