dsh-math-modeling-agent 0.3.1 → 0.4.1

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.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """Read, refresh, or invalidate the cross-platform backend inventory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import platform
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+
20
+ BACKENDS = ("mathematica", "primecount", "sagemath", "python")
21
+ REASON_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
22
+ MCP_PROTOCOL_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
23
+
24
+
25
+ def utc_now() -> str:
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ def normalized_architecture() -> str:
30
+ value = platform.machine().lower()
31
+ return {
32
+ "amd64": "x86_64",
33
+ "x64": "x86_64",
34
+ "aarch64": "arm64",
35
+ "arm64": "arm64",
36
+ "i386": "x86",
37
+ "i686": "x86",
38
+ "x86": "x86",
39
+ }.get(value, value or "unknown")
40
+
41
+
42
+ def default_state_file() -> Path:
43
+ override = os.environ.get("MATH_SCIENCE_BACKEND_INVENTORY")
44
+ if override:
45
+ return Path(override).expanduser()
46
+ return Path(tempfile.gettempdir(), "DSH", "math-science-computation", "backend-inventory.json")
47
+
48
+
49
+ def read_inventory(path: Path) -> dict | None:
50
+ if not path.is_file():
51
+ return None
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ local = data.get("local") if isinstance(data, dict) else None
55
+ mcp = data.get("mcp") if isinstance(data, dict) else None
56
+ if (
57
+ not isinstance(data, dict)
58
+ or not isinstance(local, dict)
59
+ or not isinstance(mcp, dict)
60
+ or data.get("inventory_schema_version") != "1.0"
61
+ or local.get("schema_version") != "1.0"
62
+ ):
63
+ return None
64
+ return data
65
+ except (OSError, json.JSONDecodeError, TypeError):
66
+ return None
67
+
68
+
69
+ def invoke_probe(args: argparse.Namespace) -> dict:
70
+ if args.probe_json_file:
71
+ return json.loads(Path(args.probe_json_file).read_text(encoding="utf-8"))
72
+ probe_script = Path(args.probe_script)
73
+ if not probe_script.is_file():
74
+ raise RuntimeError("Backend probe script is unavailable.")
75
+ command = [
76
+ sys.executable,
77
+ str(probe_script),
78
+ "--python-command",
79
+ args.python_command,
80
+ "--wsl-sage-command",
81
+ args.wsl_sage_command,
82
+ ]
83
+ for option, value in (
84
+ ("--sage-command", args.sage_command),
85
+ ("--wsl-distro", args.wsl_distro),
86
+ ("--primecount-command", args.primecount_command),
87
+ ):
88
+ if value:
89
+ command.extend([option, value])
90
+ process = subprocess.run(command, text=True, capture_output=True, check=False, timeout=60)
91
+ if process.returncode != 0:
92
+ message = (process.stderr or process.stdout).strip()
93
+ raise RuntimeError(f"Backend probe failed with exit code {process.returncode}: {message}")
94
+ return json.loads(process.stdout)
95
+
96
+
97
+ def new_inventory(local: dict) -> dict:
98
+ now = utc_now()
99
+ return {
100
+ "inventory_schema_version": "1.0",
101
+ "created_at_utc": now,
102
+ "updated_at_utc": now,
103
+ "local": local,
104
+ "mcp": {
105
+ "authority": "current_session_tool_discovery_and_call",
106
+ "persisted_status": "historical_only",
107
+ "required_action": "Build a current-session overlay and live-check only the selected MCP backend.",
108
+ },
109
+ "invalidations": [],
110
+ }
111
+
112
+
113
+ def mcp_observation(args: argparse.Namespace) -> dict:
114
+ values = {
115
+ "server_name": args.mcp_server_name,
116
+ "protocol_version": args.mcp_protocol_version,
117
+ "server_version": args.mcp_server_version,
118
+ "wolfram_language_version": args.mcp_wolfram_language_version,
119
+ }
120
+ missing = [name for name, value in values.items() if not value.strip()]
121
+ if missing:
122
+ raise SystemExit("RecordMcp requires: " + ", ".join(missing))
123
+ if not MCP_PROTOCOL_PATTERN.fullmatch(values["protocol_version"]):
124
+ raise SystemExit("MCP protocol version must use the negotiated YYYY-MM-DD form.")
125
+ observed_at = args.mcp_observed_at_utc or utc_now()
126
+ try:
127
+ datetime.fromisoformat(observed_at.replace("Z", "+00:00"))
128
+ except ValueError as exc:
129
+ raise SystemExit("MCP observation time must be an ISO-8601 timestamp.") from exc
130
+ return {
131
+ **values,
132
+ "observed_at_utc": observed_at,
133
+ "evidence": "initialize_handshake_and_evaluator",
134
+ }
135
+
136
+
137
+ def write_inventory_atomic(inventory: dict, path: Path) -> None:
138
+ path = path.expanduser().resolve()
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ temporary = path.parent / f".backend-inventory-{uuid.uuid4().hex}.tmp"
141
+ try:
142
+ temporary.write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8")
143
+ os.replace(temporary, path)
144
+ finally:
145
+ try:
146
+ temporary.unlink()
147
+ except FileNotFoundError:
148
+ pass
149
+
150
+
151
+ def missing_backend_paths(inventory: dict) -> list[str]:
152
+ local = inventory["local"]
153
+ missing: set[str] = set()
154
+ for installation in local.get("mathematica", {}).get("installations", []):
155
+ executable = installation.get("executable")
156
+ if executable and not Path(executable).is_file():
157
+ missing.add("mathematica")
158
+ checks = (
159
+ ("mathematica", local.get("mathematica", {}).get("wolframscript", {}).get("path")),
160
+ ("primecount", local.get("primecount", {}).get("path")),
161
+ ("sagemath", local.get("sagemath", {}).get("native", {}).get("path")),
162
+ ("python", local.get("python", {}).get("path")),
163
+ )
164
+ for name, value in checks:
165
+ if value and not Path(value).is_file():
166
+ missing.add(name)
167
+ return [name for name in BACKENDS if name in missing]
168
+
169
+
170
+ def inventory_expired(inventory: dict, max_age_hours: int) -> bool:
171
+ if max_age_hours <= 0:
172
+ return False
173
+ try:
174
+ updated = datetime.fromisoformat(inventory["updated_at_utc"].replace("Z", "+00:00"))
175
+ return (datetime.now(timezone.utc) - updated.astimezone(timezone.utc)).total_seconds() >= max_age_hours * 3600
176
+ except (KeyError, TypeError, ValueError):
177
+ return True
178
+
179
+
180
+ def host_changed(inventory: dict) -> bool:
181
+ host = inventory.get("local", {}).get("host")
182
+ if not isinstance(host, dict):
183
+ return True
184
+ return host.get("system") != platform.system() or host.get("architecture") != normalized_architecture()
185
+
186
+
187
+ def selected_backends(names: list[str]) -> list[str]:
188
+ if "all" in names:
189
+ return list(BACKENDS)
190
+ return [name for name in BACKENDS if name in names]
191
+
192
+
193
+ def merge_backends(inventory: dict, fresh_local: dict, names: list[str]) -> dict:
194
+ for name in selected_backends(names):
195
+ inventory["local"][name] = fresh_local[name]
196
+ inventory["local"]["probed_at_utc"] = fresh_local["probed_at_utc"]
197
+ inventory["local"]["host"] = fresh_local.get("host", {})
198
+ inventory["updated_at_utc"] = utc_now()
199
+ return inventory
200
+
201
+
202
+ def emit_result(
203
+ inventory: dict,
204
+ state_file: Path,
205
+ started: float,
206
+ cache_status: str,
207
+ refreshed: list[str] | None = None,
208
+ invalid_paths: list[str] | None = None,
209
+ backend_started: bool = False,
210
+ write_error: str = "",
211
+ ) -> None:
212
+ output = {
213
+ "inventory_schema_version": inventory["inventory_schema_version"],
214
+ "snapshot_updated_at_utc": inventory["updated_at_utc"],
215
+ "cache": {
216
+ "status": cache_status,
217
+ "state_file": str(state_file),
218
+ "elapsed_ms": int((time.perf_counter() - started) * 1000),
219
+ "backend_started": backend_started,
220
+ "refreshed_backends": refreshed or [],
221
+ "invalid_path_backends": invalid_paths or [],
222
+ "write_error": write_error,
223
+ },
224
+ "local": inventory["local"],
225
+ "mcp": {
226
+ "status": "session_probe_required",
227
+ "authority": "current_session_tool_discovery_and_call",
228
+ "note": "The persisted snapshot is not evidence that an MCP tool is callable in this session.",
229
+ "recorded_mathematica_observation": inventory.get("mcp", {}).get("mathematica"),
230
+ },
231
+ }
232
+ print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
233
+
234
+
235
+ def build_parser() -> argparse.ArgumentParser:
236
+ parser = argparse.ArgumentParser(description=__doc__)
237
+ parser.add_argument("--mode", choices=("ReadOrCreate", "Refresh", "Invalidate", "RecordMcp"), default="ReadOrCreate")
238
+ parser.add_argument("--state-file", default="")
239
+ parser.add_argument("--backend", choices=("all", *BACKENDS), nargs="+", default=["all"])
240
+ parser.add_argument("--reason-code", default="")
241
+ parser.add_argument("--max-age-hours", type=int, default=168)
242
+ parser.add_argument("--probe-script", default=str(Path(__file__).with_name("probe_backends.py")))
243
+ parser.add_argument("--probe-json-file", default="")
244
+ parser.add_argument("--python-command", default=sys.executable)
245
+ parser.add_argument("--sage-command", default="")
246
+ parser.add_argument("--wsl-distro", default="")
247
+ parser.add_argument("--wsl-sage-command", default="sage")
248
+ parser.add_argument("--primecount-command", default="")
249
+ parser.add_argument("--mcp-server-name", default="")
250
+ parser.add_argument("--mcp-protocol-version", default="")
251
+ parser.add_argument("--mcp-server-version", default="")
252
+ parser.add_argument("--mcp-wolfram-language-version", default="")
253
+ parser.add_argument("--mcp-observed-at-utc", default="")
254
+ return parser
255
+
256
+
257
+ def main() -> int:
258
+ started = time.perf_counter()
259
+ args = build_parser().parse_args()
260
+ if args.mode == "Invalidate" and not REASON_PATTERN.fullmatch(args.reason_code):
261
+ raise SystemExit("Invalidate mode requires a bounded lowercase reason code.")
262
+ state_file = Path(args.state_file).expanduser() if args.state_file else default_state_file()
263
+ state_file = state_file.resolve()
264
+ inventory = read_inventory(state_file)
265
+ missing: list[str] = []
266
+
267
+ if args.mode == "RecordMcp":
268
+ observation = mcp_observation(args)
269
+ backend_started = False
270
+ if inventory is None:
271
+ fresh_local = invoke_probe(args)
272
+ if fresh_local.get("schema_version") != "1.0":
273
+ raise SystemExit("Unsupported backend probe schema.")
274
+ fresh_local.get("mathematica", {}).pop("mcp", None)
275
+ inventory = new_inventory(fresh_local)
276
+ backend_started = True
277
+ inventory["mcp"]["mathematica"] = observation
278
+ inventory["updated_at_utc"] = utc_now()
279
+ write_inventory_atomic(inventory, state_file)
280
+ emit_result(inventory, state_file, started, "mcp_recorded", backend_started=backend_started)
281
+ return 0
282
+
283
+ if args.mode == "ReadOrCreate" and inventory:
284
+ missing = missing_backend_paths(inventory)
285
+ if not missing and not inventory_expired(inventory, args.max_age_hours) and not host_changed(inventory):
286
+ emit_result(inventory, state_file, started, "hit")
287
+ return 0
288
+
289
+ fresh_local = invoke_probe(args)
290
+ if fresh_local.get("schema_version") != "1.0":
291
+ raise SystemExit("Unsupported backend probe schema.")
292
+ fresh_local.get("mathematica", {}).pop("mcp", None)
293
+
294
+ refreshed = list(BACKENDS)
295
+ cache_status = "created"
296
+ if inventory is None:
297
+ inventory = new_inventory(fresh_local)
298
+ else:
299
+ cache_status = "refreshed"
300
+ if args.mode in {"Refresh", "Invalidate"}:
301
+ refreshed = selected_backends(args.backend)
302
+ elif missing and not host_changed(inventory):
303
+ refreshed = missing
304
+ if args.mode == "Invalidate":
305
+ inventory["invalidations"] = (inventory.get("invalidations", []) + [
306
+ {"backend": name, "reason": args.reason_code, "recorded_at_utc": utc_now()}
307
+ for name in refreshed
308
+ ])[-20:]
309
+ inventory = merge_backends(inventory, fresh_local, refreshed)
310
+
311
+ write_error = ""
312
+ try:
313
+ write_inventory_atomic(inventory, state_file)
314
+ except OSError as exc:
315
+ write_error = type(exc).__name__
316
+ cache_status = "write_failed"
317
+ emit_result(inventory, state_file, started, cache_status, refreshed, missing, True, write_error)
318
+ return 1 if write_error else 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main())
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env python3
2
+ """Initialize and validate reproducible mathematics/science computation records."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ SCHEMA_VERSION = "1.0"
19
+ BACKENDS = {"mathematica", "primecount", "sagemath", "python", "other"}
20
+ AVAILABILITY = {"available", "unavailable", "unknown", "not-applicable"}
21
+ PRECISION_MODES = {"exact", "machine", "arbitrary", "interval", "mixed"}
22
+ EVIDENCE_LEVELS = {
23
+ "proof-certificate",
24
+ "formal-verification",
25
+ "exact-check",
26
+ "bounded-check",
27
+ "numerical-evidence",
28
+ }
29
+ HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
30
+
31
+
32
+ class RecordError(ValueError):
33
+ """A computation record is invalid."""
34
+
35
+
36
+ def sha256_file(path: Path) -> str:
37
+ digest = hashlib.sha256()
38
+ with path.open("rb") as handle:
39
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
40
+ digest.update(chunk)
41
+ return digest.hexdigest()
42
+
43
+
44
+ def write_json(path: Path, value: Any) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
47
+ descriptor, temporary_name = tempfile.mkstemp(
48
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
49
+ )
50
+ try:
51
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
52
+ handle.write(payload)
53
+ handle.flush()
54
+ os.fsync(handle.fileno())
55
+ os.replace(temporary_name, path)
56
+ except BaseException:
57
+ try:
58
+ Path(temporary_name).unlink()
59
+ except FileNotFoundError:
60
+ pass
61
+ raise
62
+
63
+
64
+ def relative_inside(path: Path, base_dir: Path, label: str) -> str:
65
+ resolved_path = path.resolve()
66
+ resolved_base = base_dir.resolve()
67
+ try:
68
+ relative = resolved_path.relative_to(resolved_base)
69
+ except ValueError as exc:
70
+ raise RecordError(f"{label} must be inside the record base directory") from exc
71
+ return relative.as_posix()
72
+
73
+
74
+ def resolve_safe_relative(base_dir: Path, raw_path: Any, label: str) -> Path:
75
+ if not isinstance(raw_path, str) or not raw_path.strip():
76
+ raise RecordError(f"{label} must be a nonempty relative path")
77
+ candidate = Path(raw_path)
78
+ if candidate.is_absolute():
79
+ raise RecordError(f"{label} must not be absolute")
80
+ resolved_base = base_dir.resolve()
81
+ resolved = (resolved_base / candidate).resolve()
82
+ try:
83
+ resolved.relative_to(resolved_base)
84
+ except ValueError as exc:
85
+ raise RecordError(f"{label} escapes the base directory") from exc
86
+ return resolved
87
+
88
+
89
+ def require_mapping(parent: dict[str, Any], key: str, label: str) -> dict[str, Any]:
90
+ value = parent.get(key)
91
+ if not isinstance(value, dict):
92
+ raise RecordError(f"{label}.{key} must be an object")
93
+ return value
94
+
95
+
96
+ def require_nonempty_string(parent: dict[str, Any], key: str, label: str) -> str:
97
+ value = parent.get(key)
98
+ if not isinstance(value, str) or not value.strip():
99
+ raise RecordError(f"{label}.{key} must be a nonempty string")
100
+ return value.strip()
101
+
102
+
103
+ def require_string_list(parent: dict[str, Any], key: str, label: str) -> list[str]:
104
+ value = parent.get(key)
105
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
106
+ raise RecordError(f"{label}.{key} must be a list of strings")
107
+ return value
108
+
109
+
110
+ def validate_hash(raw_hash: Any, label: str) -> str:
111
+ if not isinstance(raw_hash, str) or not HEX_SHA256.fullmatch(raw_hash):
112
+ raise RecordError(f"{label} must be a lowercase SHA-256 hex digest")
113
+ return raw_hash
114
+
115
+
116
+ def init_record(task_file: Path, record_path: Path, force: bool) -> None:
117
+ task_file = task_file.resolve()
118
+ record_path = record_path.resolve()
119
+ if not task_file.is_file():
120
+ raise RecordError(f"task file does not exist: {task_file}")
121
+ record_path.parent.mkdir(parents=True, exist_ok=True)
122
+ if record_path.exists() and not force:
123
+ raise RecordError(f"record already exists: {record_path}; pass --force to replace it")
124
+ task_relative = relative_inside(task_file, record_path.parent, "task file")
125
+
126
+ record = {
127
+ "schema_version": SCHEMA_VERSION,
128
+ "created_at_utc": datetime.now(timezone.utc).isoformat(),
129
+ "task": {
130
+ "file": task_relative,
131
+ "sha256": sha256_file(task_file),
132
+ "object": "",
133
+ "deliverables": [],
134
+ },
135
+ "mathematical_context": {
136
+ "assumptions": [],
137
+ "domain": "",
138
+ "precision": {
139
+ "mode": "exact",
140
+ "working_digits": None,
141
+ "target_tolerance": "",
142
+ },
143
+ },
144
+ "implementation_discovery": {
145
+ backend: {
146
+ "candidate_implementations": [],
147
+ "existence_evidence": "",
148
+ "local_availability": "unknown",
149
+ "availability_evidence": "",
150
+ }
151
+ for backend in ("mathematica", "primecount", "sagemath", "python")
152
+ },
153
+ "decision": {
154
+ "selected_backend": "",
155
+ "backend_version": "",
156
+ "selection_reason": "",
157
+ "fallback_reason": "",
158
+ },
159
+ "execution": {
160
+ "status": "planned",
161
+ "interface": "",
162
+ "command_or_input": "",
163
+ "code_artifact": "",
164
+ },
165
+ "artifacts": [],
166
+ "result": {
167
+ "status": "pending",
168
+ "summary": "",
169
+ "result_artifact": "",
170
+ },
171
+ "verification": {
172
+ "methods": [],
173
+ "evidence_level": "",
174
+ "residual_or_error": "",
175
+ "limitations": [],
176
+ },
177
+ }
178
+ write_json(record_path, record)
179
+ print(json.dumps({"ok": True, "record": str(record_path)}, ensure_ascii=False))
180
+
181
+
182
+ def validate_record(record_path: Path, base_dir: Path | None) -> dict[str, Any]:
183
+ record_path = record_path.resolve()
184
+ if not record_path.is_file():
185
+ raise RecordError(f"record does not exist: {record_path}")
186
+ base = (base_dir or record_path.parent).resolve()
187
+ if not base.is_dir():
188
+ raise RecordError(f"base directory does not exist: {base}")
189
+
190
+ try:
191
+ record = json.loads(record_path.read_text(encoding="utf-8"))
192
+ except (OSError, json.JSONDecodeError) as exc:
193
+ raise RecordError(f"cannot read record JSON: {exc}") from exc
194
+ if not isinstance(record, dict):
195
+ raise RecordError("record root must be an object")
196
+ if record.get("schema_version") != SCHEMA_VERSION:
197
+ raise RecordError(f"schema_version must be {SCHEMA_VERSION}")
198
+
199
+ task = require_mapping(record, "task", "record")
200
+ require_nonempty_string(task, "object", "task")
201
+ deliverables = require_string_list(task, "deliverables", "task")
202
+ if not deliverables:
203
+ raise RecordError("task.deliverables must contain at least one item")
204
+ task_path = resolve_safe_relative(base, task.get("file"), "task.file")
205
+ if not task_path.is_file():
206
+ raise RecordError(f"task.file does not exist: {task_path}")
207
+ task_hash = validate_hash(task.get("sha256"), "task.sha256")
208
+ if sha256_file(task_path) != task_hash:
209
+ raise RecordError("task.sha256 does not match task.file")
210
+
211
+ context = require_mapping(record, "mathematical_context", "record")
212
+ require_string_list(context, "assumptions", "mathematical_context")
213
+ require_nonempty_string(context, "domain", "mathematical_context")
214
+ precision = require_mapping(context, "precision", "mathematical_context")
215
+ mode = require_nonempty_string(precision, "mode", "mathematical_context.precision")
216
+ if mode not in PRECISION_MODES:
217
+ raise RecordError(f"mathematical_context.precision.mode must be one of {sorted(PRECISION_MODES)}")
218
+ working_digits = precision.get("working_digits")
219
+ if working_digits is not None and (not isinstance(working_digits, int) or working_digits <= 0):
220
+ raise RecordError("mathematical_context.precision.working_digits must be null or a positive integer")
221
+ target_tolerance = precision.get("target_tolerance")
222
+ if not isinstance(target_tolerance, str):
223
+ raise RecordError("mathematical_context.precision.target_tolerance must be a string")
224
+ if mode != "exact" and not target_tolerance.strip():
225
+ raise RecordError("numerical precision modes require mathematical_context.precision.target_tolerance")
226
+
227
+ discovery = require_mapping(record, "implementation_discovery", "record")
228
+ for backend in ("mathematica", "primecount", "sagemath", "python"):
229
+ entry = require_mapping(discovery, backend, "implementation_discovery")
230
+ candidates = entry.get("candidate_implementations")
231
+ if not isinstance(candidates, list) or any(not isinstance(item, str) for item in candidates):
232
+ raise RecordError(
233
+ f"implementation_discovery.{backend}.candidate_implementations must be a list of strings"
234
+ )
235
+ require_nonempty_string(entry, "existence_evidence", f"implementation_discovery.{backend}")
236
+ availability = require_nonempty_string(
237
+ entry, "local_availability", f"implementation_discovery.{backend}"
238
+ )
239
+ if availability not in AVAILABILITY:
240
+ raise RecordError(
241
+ f"implementation_discovery.{backend}.local_availability must be one of {sorted(AVAILABILITY)}"
242
+ )
243
+ require_nonempty_string(
244
+ entry, "availability_evidence", f"implementation_discovery.{backend}"
245
+ )
246
+
247
+ decision = require_mapping(record, "decision", "record")
248
+ selected_backend = require_nonempty_string(decision, "selected_backend", "decision")
249
+ if selected_backend not in BACKENDS:
250
+ raise RecordError(f"decision.selected_backend must be one of {sorted(BACKENDS)}")
251
+ require_nonempty_string(decision, "backend_version", "decision")
252
+ require_nonempty_string(decision, "selection_reason", "decision")
253
+ require_nonempty_string(decision, "fallback_reason", "decision")
254
+ if selected_backend in discovery:
255
+ selected_entry = discovery[selected_backend]
256
+ if selected_entry.get("local_availability") != "available":
257
+ raise RecordError("the selected backend must have local_availability=available")
258
+
259
+ execution = require_mapping(record, "execution", "record")
260
+ if require_nonempty_string(execution, "status", "execution") != "complete":
261
+ raise RecordError("execution.status must be complete")
262
+ require_nonempty_string(execution, "interface", "execution")
263
+ require_nonempty_string(execution, "command_or_input", "execution")
264
+ code_artifact = require_nonempty_string(execution, "code_artifact", "execution")
265
+
266
+ artifacts = record.get("artifacts")
267
+ if not isinstance(artifacts, list) or not artifacts:
268
+ raise RecordError("artifacts must be a nonempty list")
269
+ roles: set[str] = set()
270
+ artifact_paths: set[str] = set()
271
+ verified_artifacts = []
272
+ for index, artifact in enumerate(artifacts):
273
+ label = f"artifacts[{index}]"
274
+ if not isinstance(artifact, dict):
275
+ raise RecordError(f"{label} must be an object")
276
+ role = require_nonempty_string(artifact, "role", label)
277
+ raw_path = require_nonempty_string(artifact, "path", label)
278
+ expected_hash = validate_hash(artifact.get("sha256"), f"{label}.sha256")
279
+ artifact_path = resolve_safe_relative(base, raw_path, f"{label}.path")
280
+ if not artifact_path.is_file():
281
+ raise RecordError(f"{label}.path does not exist: {artifact_path}")
282
+ actual_hash = sha256_file(artifact_path)
283
+ if actual_hash != expected_hash:
284
+ raise RecordError(f"{label}.sha256 does not match {raw_path}")
285
+ roles.add(role)
286
+ artifact_paths.add(Path(raw_path).as_posix())
287
+ verified_artifacts.append(
288
+ {"role": role, "path": Path(raw_path).as_posix(), "sha256": actual_hash}
289
+ )
290
+ if "code" not in roles or "result" not in roles:
291
+ raise RecordError("artifacts must include both code and result roles")
292
+ if Path(code_artifact).as_posix() not in artifact_paths:
293
+ raise RecordError("execution.code_artifact must name a hashed artifact")
294
+
295
+ result = require_mapping(record, "result", "record")
296
+ if require_nonempty_string(result, "status", "result") != "complete":
297
+ raise RecordError("result.status must be complete")
298
+ require_nonempty_string(result, "summary", "result")
299
+ result_artifact = require_nonempty_string(result, "result_artifact", "result")
300
+ if Path(result_artifact).as_posix() not in artifact_paths:
301
+ raise RecordError("result.result_artifact must name a hashed artifact")
302
+
303
+ verification = require_mapping(record, "verification", "record")
304
+ methods = require_string_list(verification, "methods", "verification")
305
+ if not methods or any(not method.strip() for method in methods):
306
+ raise RecordError("verification.methods must contain at least one nonempty method")
307
+ evidence_level = require_nonempty_string(
308
+ verification, "evidence_level", "verification"
309
+ )
310
+ if evidence_level not in EVIDENCE_LEVELS:
311
+ raise RecordError(f"verification.evidence_level must be one of {sorted(EVIDENCE_LEVELS)}")
312
+ residual_or_error = verification.get("residual_or_error")
313
+ if not isinstance(residual_or_error, str):
314
+ raise RecordError("verification.residual_or_error must be a string")
315
+ if mode != "exact" and not residual_or_error.strip():
316
+ raise RecordError("numerical precision modes require verification.residual_or_error")
317
+ require_string_list(verification, "limitations", "verification")
318
+
319
+ return {
320
+ "ok": True,
321
+ "record": str(record_path),
322
+ "base_dir": str(base),
323
+ "selected_backend": selected_backend,
324
+ "evidence_level": evidence_level,
325
+ "verified_artifacts": verified_artifacts,
326
+ }
327
+
328
+
329
+ def build_parser() -> argparse.ArgumentParser:
330
+ parser = argparse.ArgumentParser(
331
+ description="Initialize or validate a computation-record.json file."
332
+ )
333
+ subparsers = parser.add_subparsers(dest="command", required=True)
334
+
335
+ init_parser = subparsers.add_parser("init", help="initialize a record from a task file")
336
+ init_parser.add_argument("--task-file", required=True, type=Path)
337
+ init_parser.add_argument("--record", required=True, type=Path)
338
+ init_parser.add_argument("--force", action="store_true")
339
+
340
+ validate_parser = subparsers.add_parser("validate", help="validate a completed record")
341
+ validate_parser.add_argument("--record", required=True, type=Path)
342
+ validate_parser.add_argument("--base-dir", type=Path)
343
+ return parser
344
+
345
+
346
+ def main(argv: list[str] | None = None) -> int:
347
+ args = build_parser().parse_args(argv)
348
+ try:
349
+ if args.command == "init":
350
+ init_record(args.task_file, args.record, args.force)
351
+ return 0
352
+ result = validate_record(args.record, args.base_dir)
353
+ print(json.dumps(result, ensure_ascii=False))
354
+ return 0
355
+ except (OSError, RecordError) as exc:
356
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
357
+ return 1
358
+
359
+
360
+ if __name__ == "__main__":
361
+ sys.exit(main())