dsh-math-modeling-agent 0.2.8 → 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,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())