iclayout-bench 0.4.0a1__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.
Files changed (88) hide show
  1. benchmarking/__init__.py +1 -0
  2. benchmarking/_build.json +1 -0
  3. benchmarking/analysis.py +185 -0
  4. benchmarking/analyze.py +29 -0
  5. benchmarking/bundles.py +89 -0
  6. benchmarking/client.py +289 -0
  7. benchmarking/dataset.py +136 -0
  8. benchmarking/dataset_index.py +133 -0
  9. benchmarking/engine/__init__.py +1 -0
  10. benchmarking/engine/benchmark_feedback.py +60 -0
  11. benchmarking/engine/cli.py +129 -0
  12. benchmarking/engine/docker.py +122 -0
  13. benchmarking/engine/environment.py +42 -0
  14. benchmarking/engine/evaluate.py +267 -0
  15. benchmarking/engine/execution.py +183 -0
  16. benchmarking/engine/geometry.py +123 -0
  17. benchmarking/engine/geometry_runner.py +115 -0
  18. benchmarking/engine/hbt.py +1718 -0
  19. benchmarking/engine/hbt_runner.py +63 -0
  20. benchmarking/engine/identity.py +19 -0
  21. benchmarking/engine/inference.py +675 -0
  22. benchmarking/engine/klayout.py +195 -0
  23. benchmarking/engine/klayout_runner.py +265 -0
  24. benchmarking/engine/kpex.py +93 -0
  25. benchmarking/engine/kpex_runner.py +150 -0
  26. benchmarking/engine/layout_image.py +42 -0
  27. benchmarking/engine/magic.py +204 -0
  28. benchmarking/engine/magic_netlist.py +80 -0
  29. benchmarking/engine/magic_ports.py +50 -0
  30. benchmarking/engine/messages.py +155 -0
  31. benchmarking/engine/model_config.py +77 -0
  32. benchmarking/engine/ngspice.py +159 -0
  33. benchmarking/engine/pdk_installation.py +68 -0
  34. benchmarking/engine/pdk_probe.py +42 -0
  35. benchmarking/engine/pdk_resources.py +151 -0
  36. benchmarking/engine/preparation.py +48 -0
  37. benchmarking/engine/prepare_support.py +111 -0
  38. benchmarking/engine/preview.py +284 -0
  39. benchmarking/engine/process_check.py +12 -0
  40. benchmarking/engine/qualification.py +285 -0
  41. benchmarking/engine/recorder.py +187 -0
  42. benchmarking/engine/recording.py +20 -0
  43. benchmarking/engine/resource_cache.py +34 -0
  44. benchmarking/engine/runtime.py +61 -0
  45. benchmarking/engine/session.py +611 -0
  46. benchmarking/engine/snapshot.py +41 -0
  47. benchmarking/engine/source.py +29 -0
  48. benchmarking/engine/submit.py +12 -0
  49. benchmarking/engine/toolchains.py +88 -0
  50. benchmarking/engine/workspace.py +101 -0
  51. benchmarking/evaluation.py +421 -0
  52. benchmarking/files.py +126 -0
  53. benchmarking/harnesses.py +89 -0
  54. benchmarking/layout_preview.py +104 -0
  55. benchmarking/model_configs.yaml +2 -0
  56. benchmarking/observe.py +72 -0
  57. benchmarking/participants/__init__.py +1 -0
  58. benchmarking/participants/archive.py +57 -0
  59. benchmarking/participants/bridge.py +176 -0
  60. benchmarking/participants/config.py +202 -0
  61. benchmarking/participants/dsh.py +102 -0
  62. benchmarking/participants/process.py +31 -0
  63. benchmarking/participants/recovery.py +106 -0
  64. benchmarking/participants/results.py +285 -0
  65. benchmarking/participants/runner.py +402 -0
  66. benchmarking/participants/scheme.py +85 -0
  67. benchmarking/participants/storage.py +20 -0
  68. benchmarking/plotting.py +149 -0
  69. benchmarking/protocol.py +42 -0
  70. benchmarking/results/__init__.py +5 -0
  71. benchmarking/results/__main__.py +81 -0
  72. benchmarking/results/geometry.py +142 -0
  73. benchmarking/results/presentation.py +333 -0
  74. benchmarking/results/schema.py +105 -0
  75. benchmarking/results/store.py +788 -0
  76. benchmarking/run.py +348 -0
  77. benchmarking/scoring.py +201 -0
  78. benchmarking/service/__init__.py +1 -0
  79. benchmarking/service/__main__.py +45 -0
  80. benchmarking/service/server.py +545 -0
  81. benchmarking/tasks.py +307 -0
  82. benchmarking/transfer.py +256 -0
  83. benchmarking/version.py +17 -0
  84. iclayout_bench-0.4.0a1.dist-info/METADATA +100 -0
  85. iclayout_bench-0.4.0a1.dist-info/RECORD +88 -0
  86. iclayout_bench-0.4.0a1.dist-info/WHEEL +5 -0
  87. iclayout_bench-0.4.0a1.dist-info/licenses/LICENSE +21 -0
  88. iclayout_bench-0.4.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """ICLayout-Bench: HTTP client, observation, public contracts and analysis."""
@@ -0,0 +1 @@
1
+ {"version": "0.4.0a1", "commit": "c0f67225a7b56242275239a65ed70507c23cd7a3"}
@@ -0,0 +1,185 @@
1
+ """Analysis of service results; no evaluator or private-package dependency."""
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .files import Asset, atomic_write
8
+
9
+
10
+ def _csv(path, columns, rows):
11
+ with Path(path).open("w", newline="", encoding="utf-8") as stream:
12
+ writer = csv.DictWriter(stream, fieldnames=columns)
13
+ writer.writeheader()
14
+ writer.writerows({key: row.get(key) for key in columns} for row in rows)
15
+ Path(path).chmod(0o600)
16
+
17
+
18
+ def export_session_result(result, destination):
19
+ """Export service-reported data, preserving its trust label and missing values.
20
+
21
+ This is not local cryptographic verification of the private judge's evidence.
22
+ The complete response and its digest accompany plotting tables.
23
+ """
24
+ from .protocol import PROTOCOL, json_bytes
25
+
26
+ if result.get("protocol") != PROTOCOL or result.get("state") not in {
27
+ "complete",
28
+ "error",
29
+ }:
30
+ raise ValueError("Expected a terminal layout-http result")
31
+ output = Path(destination)
32
+ output.mkdir(parents=True, exist_ok=False)
33
+ raw = json_bytes(result)
34
+ atomic_write(output / "result.json", raw)
35
+ receipt = result.get("submission") or {}
36
+ score = result.get("score") or {}
37
+ row = {
38
+ key: result.get(key)
39
+ for key in (
40
+ "session_id",
41
+ "task_id",
42
+ "task_sha256",
43
+ "verification_level",
44
+ "outcome",
45
+ "task_success",
46
+ "failure_reason",
47
+ )
48
+ }
49
+ row.update(
50
+ candidate_sha256=receipt.get("candidate_sha256"),
51
+ score=score.get("value"),
52
+ **result["condition"],
53
+ **result["usage"],
54
+ )
55
+ for key in ("tool_identity", "limits", "provenance"):
56
+ row[key] = json.dumps(result[key], sort_keys=True)
57
+ _csv(output / "summary.csv", tuple(row), [row])
58
+ metrics = []
59
+ for name, metric in result.get("metrics", {}).items():
60
+ metrics.append(
61
+ {
62
+ "session_id": result["session_id"],
63
+ "metric": name,
64
+ "value": metric.get("value"),
65
+ "unit": metric.get("unit"),
66
+ "status": metric.get("status"),
67
+ }
68
+ )
69
+ _csv(
70
+ output / "metrics.csv",
71
+ ("session_id", "metric", "value", "unit", "status"),
72
+ metrics,
73
+ )
74
+ atomic_write(
75
+ output / "manifest.json",
76
+ json_bytes(
77
+ {
78
+ "source_sha256": Asset(raw, "json").sha256,
79
+ "verification": "service_reported",
80
+ "missing_csv_values": "empty",
81
+ "files": {
82
+ name: Asset((output / name).read_bytes(), "binary").identity()
83
+ for name in ("result.json", "summary.csv", "metrics.csv")
84
+ },
85
+ }
86
+ ),
87
+ )
88
+ return output
89
+
90
+
91
+ def export_results(results, destination, *, plots=False):
92
+ """Compare like conditions; incomplete/error results never become zero scores.
93
+
94
+ This consumes disclosed service results, not private evaluator artifacts.
95
+ Verification labels are retained, not independently upgraded by analysis.
96
+ """
97
+ from collections import defaultdict
98
+ from statistics import mean, stdev
99
+
100
+ from .protocol import PROTOCOL, json_bytes
101
+
102
+ output = Path(destination)
103
+ output.mkdir(parents=True, exist_ok=False, mode=0o700)
104
+ groups = defaultdict(list)
105
+ rows = []
106
+ seen = set()
107
+ for result in results:
108
+ if result.get("protocol") != PROTOCOL or result.get("test_only"):
109
+ raise ValueError(
110
+ "Only real protocol results can enter measurement analysis"
111
+ )
112
+ sid = result["session_id"]
113
+ if sid in seen:
114
+ raise ValueError("Duplicate session")
115
+ seen.add(sid)
116
+ binding = {
117
+ key: result[key]
118
+ for key in ("condition", "verification_level", "tool_identity", "limits")
119
+ }
120
+ cohort = Asset(json_bytes(binding), "json").sha256
121
+ value = (result.get("score") or {}).get("value")
122
+ if result.get("outcome") == "error" or result.get("state") not in {
123
+ "complete",
124
+ "error",
125
+ }:
126
+ value = None
127
+ row = {
128
+ "cohort": cohort,
129
+ "session_id": sid,
130
+ "task_id": result["task_id"],
131
+ "task_sha256": result["task_sha256"],
132
+ "state": result["state"],
133
+ "outcome": result.get("outcome"),
134
+ "score": value,
135
+ "verification_level": result["verification_level"],
136
+ **result["condition"],
137
+ **result["usage"],
138
+ }
139
+ rows.append(row)
140
+ groups[(cohort, result["task_id"], result["task_sha256"])].append(row)
141
+ atomic_write(output / "results.json", json_bytes(results))
142
+ if rows:
143
+ _csv(output / "runs.csv", tuple(rows[0]), rows)
144
+ summaries = []
145
+ for (cohort, task, sha), group in sorted(groups.items()):
146
+ values = [row["score"] for row in group if row["score"] is not None]
147
+ summaries.append(
148
+ {
149
+ "cohort": cohort,
150
+ "task_id": task,
151
+ "task_sha256": sha,
152
+ "trials": len(group),
153
+ "measured": len(values),
154
+ "unknown": len(group) - len(values),
155
+ "mean": mean(values) if values else None,
156
+ "sample_stddev": stdev(values) if len(values) > 1 else None,
157
+ "pass": sum(row["outcome"] == "pass" for row in group),
158
+ "fail": sum(row["outcome"] == "fail" for row in group),
159
+ "no_submission": sum(
160
+ row["outcome"] == "no_submission" for row in group
161
+ ),
162
+ "infrastructure_error": sum(row["outcome"] == "error" for row in group),
163
+ }
164
+ )
165
+ if summaries:
166
+ _csv(output / "tasks.csv", tuple(summaries[0]), summaries)
167
+ if plots and summaries:
168
+ from .plotting import render_service_results
169
+
170
+ render_service_results(rows, output)
171
+ atomic_write(
172
+ output / "manifest.json",
173
+ json_bytes(
174
+ {
175
+ "verification": "service_reported",
176
+ "missing_csv_values": "empty",
177
+ "files": {
178
+ p.name: Asset(p.read_bytes(), "binary").identity()
179
+ for p in sorted(output.iterdir())
180
+ if p.is_file()
181
+ },
182
+ }
183
+ ),
184
+ )
185
+ return output
@@ -0,0 +1,29 @@
1
+ """Export disclosed HTTP results or an operator-verified public rerun release."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .analysis import export_results
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description=__doc__)
12
+ parser.add_argument("inputs", nargs="+", type=Path)
13
+ parser.add_argument("--output", required=True, type=Path)
14
+ parser.add_argument("--plots", action="store_true")
15
+ args = parser.parse_args()
16
+ results = []
17
+ for path in args.inputs:
18
+ data = json.loads(path.read_bytes())
19
+ if isinstance(data, dict) and "results" in data:
20
+ if data.get("dataset") != "public_development":
21
+ raise ValueError("Hidden results require operator disclosure export")
22
+ results.extend(data["results"])
23
+ else:
24
+ results.extend(data if isinstance(data, list) else [data])
25
+ print(export_results(results, args.output, plots=args.plots))
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,89 @@
1
+ """Portable file bundles and local bindings to installed tool resources."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .files import Asset, ReadOnlyMount, keys, read_file, relative
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Bundle:
12
+ files: tuple[tuple[str, Asset | ReadOnlyMount], ...]
13
+ manifest: Asset
14
+ paths: tuple[tuple[str, Path], ...] = ()
15
+
16
+ def mounted_files(self) -> dict[str, Asset | ReadOnlyMount]:
17
+ return {f"support/{name}": asset for name, asset in self.files}
18
+
19
+ def evidence(self) -> dict[str, Asset]:
20
+ if any(isinstance(asset, ReadOnlyMount) for _, asset in self.files):
21
+ return {"support_manifest": self.manifest}
22
+ return {"support_manifest": self.manifest,
23
+ **{f"support:{name}": asset for name, asset in self.files}}
24
+
25
+
26
+ def load_bundle(root: Path) -> Bundle:
27
+ root = root.absolute()
28
+ if (root / "resource.json").is_file():
29
+ return load_resources(root)
30
+ manifest = Asset(read_file(root, "manifest.json"), "json")
31
+ data = json.loads(manifest.content)
32
+ keys(data, {"files", "provenance"}, set(), "support bundle")
33
+ if not isinstance(data["files"], dict) or not data["files"]:
34
+ raise ValueError("Support bundle needs a nonempty file list")
35
+ files = []
36
+ for name, identity in data["files"].items():
37
+ relative(name, "support path")
38
+ if name == "manifest.json":
39
+ raise ValueError("manifest.json is reserved")
40
+ keys(identity, {"sha256", "format", "bytes"}, set(), "support identity")
41
+ asset = Asset(read_file(root, name), identity["format"])
42
+ if asset.identity() != identity:
43
+ raise ValueError(f"Support checksum mismatch: {name}")
44
+ files.append((name, asset))
45
+ actual = set()
46
+ for path in root.rglob("*"):
47
+ if path.is_symlink():
48
+ raise ValueError(f"Symlink in support bundle: {path}")
49
+ if path.is_file():
50
+ actual.add(path.relative_to(root).as_posix())
51
+ if actual != set(data["files"]) | {"manifest.json"}:
52
+ raise ValueError("Support bundle contains undeclared files")
53
+ return Bundle(tuple(files), manifest, tuple((name, root / name) for name, _ in files))
54
+
55
+
56
+ def publish_bundle(files: dict[str, Asset], provenance: dict, destination: Path) -> Bundle:
57
+ """Publish new preparation output, never overwrite an existing bundle."""
58
+ destination = destination.absolute()
59
+ if destination.exists() or destination.is_symlink():
60
+ raise FileExistsError(f"Bundle destination exists: {destination}")
61
+ for name in files:
62
+ relative(name, "support path")
63
+ if name == "manifest.json":
64
+ raise ValueError("manifest.json is reserved")
65
+ manifest = {"files": {k: a.identity() for k, a in sorted(files.items())},
66
+ "provenance": provenance}
67
+ raw = json.dumps(manifest, sort_keys=True, indent=2, allow_nan=False).encode() + b"\n"
68
+ destination.mkdir(parents=True)
69
+ for name, asset in {**files, "manifest.json": Asset(raw, "json")}.items():
70
+ path = destination / name
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ path.write_bytes(asset.content)
73
+ path.chmod(0o444)
74
+ return load_bundle(destination)
75
+
76
+
77
+ def load_resources(root: Path) -> Bundle:
78
+ """Load mount bindings without traversing or copying installed PDKs."""
79
+ root = root.resolve()
80
+ data = json.loads((root / "resource.json").read_text())
81
+ if "root" in data:
82
+ return load_resources(Path(data["root"]))
83
+ manifest = Asset((json.dumps({"provenance": data["provenance"]}, sort_keys=True) + "\n").encode(), "json")
84
+ paths = {name: Path(path) for name, path in data["mounts"].items()}
85
+ paths.update({p.relative_to(root).as_posix(): p for p in root.rglob("*")
86
+ if p.is_file() and p.name != "resource.json"})
87
+ files = tuple((relative(name, "resource target"), ReadOnlyMount(path, manifest))
88
+ for name, path in sorted(paths.items()))
89
+ return Bundle(files, manifest, tuple(sorted(paths.items())))
benchmarking/client.py ADDED
@@ -0,0 +1,289 @@
1
+ """Generic layout-http client; no Docker, model SDK or Private dependency."""
2
+
3
+ import argparse
4
+ import base64
5
+ import hashlib
6
+ import http.client
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import urlencode, urlsplit
14
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
15
+
16
+ from .protocol import (
17
+ PROTOCOL,
18
+ SESSION_STARTUP_RESPONSE_GRACE_SECONDS,
19
+ SESSION_STARTUP_TIMEOUT_SECONDS,
20
+ )
21
+
22
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
23
+ _KEY = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z")
24
+
25
+
26
+ def _reject_constant(value):
27
+ raise ValueError("Non-finite JSON number")
28
+
29
+
30
+ class ClientError(Exception):
31
+ """A structured service error or a locally rejected response."""
32
+
33
+ def __init__(self, code, message, *, status=None, retryable=False, retry_after=None):
34
+ super().__init__(message)
35
+ self.code, self.status, self.retryable = code, status, retryable
36
+ self.retry_after = retry_after
37
+
38
+
39
+ class _NoRedirect(HTTPRedirectHandler):
40
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
41
+ return None # Never forward a session credential to a redirected endpoint.
42
+
43
+
44
+ def _identifier(value):
45
+ if not isinstance(value, str) or not _KEY.fullmatch(value):
46
+ raise ValueError("Invalid opaque identifier or idempotency key")
47
+ return value
48
+
49
+
50
+ class Client:
51
+ """Explicit retries: retain and replay the same key after uncertain delivery.
52
+
53
+ Automatic bounded same-key retries require an explicit retry_policy.
54
+ Without one, the client performs exactly one transport attempt.
55
+ Construct a new instance with the returned session token after creation.
56
+ """
57
+
58
+ def __init__(self, endpoint, token, *, timeout=30, max_response_bytes=MAX_RESPONSE_BYTES,
59
+ retry_policy=None):
60
+ parsed = urlsplit(endpoint)
61
+ if (parsed.scheme not in {"http", "https"} or not parsed.hostname
62
+ or parsed.username or parsed.password or parsed.query or parsed.fragment):
63
+ raise ValueError("Expected a service URL without credentials, query or fragment")
64
+ if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
65
+ raise ValueError("Non-loopback service endpoints require HTTPS")
66
+ if not isinstance(token, str) or not token or any(ord(c) < 33 or ord(c) > 126 for c in token):
67
+ raise ValueError("Expected a nonempty bearer token")
68
+ if timeout <= 0 or max_response_bytes <= 0:
69
+ raise ValueError("Timeout and response limit must be positive")
70
+ from .participants.recovery import policy
71
+ self.retry_policy = policy(retry_policy)
72
+ self.endpoint = endpoint.rstrip("/")
73
+ self.token = token
74
+ self.timeout = timeout
75
+ self.max_response_bytes = max_response_bytes
76
+ self._opener = build_opener(ProxyHandler({}), _NoRedirect())
77
+
78
+ def request(self, method, path, body=None, *, key=None):
79
+ # One owner for service transport retries. Every replay retains body/key.
80
+ settings = self.retry_policy
81
+ for attempt in range(settings["http_attempts"]):
82
+ try:
83
+ return self._request(method, path, body, key=key)
84
+ except ClientError as error:
85
+ recoverable = error.code == "transport_error" or (error.status == 503 and error.retryable)
86
+ if not recoverable or attempt + 1 == settings["http_attempts"]:
87
+ raise
88
+ delay = min(settings["max_backoff_seconds"], settings["backoff_seconds"] * 2 ** attempt)
89
+ if error.retry_after is not None:
90
+ if error.retry_after > settings["max_backoff_seconds"]:
91
+ raise
92
+ delay = max(delay, error.retry_after)
93
+ time.sleep(delay)
94
+
95
+ def _request(self, method, path, body=None, *, key=None):
96
+ if not path.startswith("/sessions") or path.startswith("//") or "#" in path:
97
+ raise ValueError("Expected a /sessions service path")
98
+ if method not in {"GET", "POST"}:
99
+ raise ValueError("Only GET and POST are supported")
100
+ headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
101
+ data = None
102
+ if method == "POST":
103
+ headers["Idempotency-Key"] = _identifier(key)
104
+ headers["Content-Type"] = "application/json"
105
+ data = json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
106
+ elif body is not None or key is not None:
107
+ raise ValueError("GET does not accept a body or idempotency key")
108
+ req = Request(self.endpoint + path, data=data, headers=headers, method=method)
109
+ try:
110
+ try:
111
+ timeout = self.timeout
112
+ if method == "POST" and path == "/sessions":
113
+ timeout = max(timeout, SESSION_STARTUP_TIMEOUT_SECONDS + SESSION_STARTUP_RESPONSE_GRACE_SECONDS)
114
+ response = self._opener.open(req, timeout=timeout)
115
+ except HTTPError as error:
116
+ response = error
117
+ with response:
118
+ status = response.code
119
+ retry_after = response.headers.get("Retry-After") if hasattr(response, "headers") else None
120
+ retry_after = int(retry_after) if retry_after and retry_after.isdigit() else None
121
+ raw = response.read(self.max_response_bytes + 1)
122
+ except (URLError, TimeoutError, OSError, http.client.HTTPException) as error:
123
+ raise ClientError("transport_error", "Delivery unknown; query status or replay the same key",
124
+ retryable=True) from error
125
+ if len(raw) > self.max_response_bytes:
126
+ raise ClientError("invalid_response", "Service response exceeds client limit", status=status)
127
+ try:
128
+ result = json.loads(raw, parse_constant=_reject_constant)
129
+ except (ValueError, UnicodeDecodeError) as error:
130
+ raise ClientError("invalid_response", "Service returned invalid JSON", status=status) from error
131
+ if not isinstance(result, dict) or result.get("protocol") != PROTOCOL:
132
+ raise ClientError("incompatible_protocol", "Unsupported service response", status=status)
133
+ if not 200 <= status < 300:
134
+ detail = result.get("error", {})
135
+ if not isinstance(detail, dict):
136
+ detail = {}
137
+ raise ClientError(detail.get("code", "invalid_response"),
138
+ str(detail.get("message", "Service request failed")), status=status,
139
+ retryable=detail.get("retryable") is True, retry_after=retry_after)
140
+ return result
141
+
142
+ def create(self, task_id, condition, *, key):
143
+ return self.request("POST", "/sessions", {"task_id": task_id, "condition": condition}, key=key)
144
+
145
+ def session(self, session_id, operation="", body=None, *, key=None):
146
+ path = f"/sessions/{_identifier(session_id)}"
147
+ if operation:
148
+ path += "/" + operation
149
+ return self.request("POST" if key is not None else "GET", path, body, key=key)
150
+
151
+ def read(self, session_id, path):
152
+ result = self.session(session_id, "file?" + urlencode({"path": path}))
153
+ try:
154
+ content = base64.b64decode(result["content_base64"], validate=True)
155
+ if len(content) != result["size_bytes"] or hashlib.sha256(content).hexdigest() != result["sha256"]:
156
+ raise ValueError("Content digest mismatch")
157
+ except (KeyError, ValueError, TypeError) as error:
158
+ raise ClientError("invalid_response", "File integrity check failed") from error
159
+ return content
160
+
161
+ def write(self, session_id, path, content, *, key):
162
+ return self.session(session_id, "files", {
163
+ "path": path, "content_base64": base64.b64encode(content).decode("ascii")}, key=key)
164
+
165
+ def execute(self, session_id, command, timeout_seconds, *, key):
166
+ return self.session(session_id, "executions", {
167
+ "command": command, "timeout_seconds": timeout_seconds}, key=key)
168
+
169
+ def poll(self, session_id, execution_id, *, offset=0):
170
+ if type(offset) is not int or offset < 0:
171
+ raise ValueError("Offset must be a nonnegative integer")
172
+ return self.session(session_id, f"executions/{_identifier(execution_id)}?offset={offset}")
173
+
174
+ def check(self, session_id, timeout_seconds=None, *, key):
175
+ """Start a frozen-candidate check through the normal replayable execution API."""
176
+ status = self.session(session_id)
177
+ if "process-feedback" not in status.get("capabilities", []):
178
+ raise ValueError("Service does not advertise process-feedback")
179
+ seconds = status["remaining_seconds"] if timeout_seconds is None else timeout_seconds
180
+ return self.execute(session_id, "python -I /protocol/process_check.py", seconds, key=key)
181
+
182
+ def submit(self, session_id, path, *, key):
183
+ return self.session(session_id, "submissions", {"path": path}, key=key)
184
+
185
+ def close(self, session_id, *, key):
186
+ return self.session(session_id, "close", {}, key=key)
187
+
188
+ def observations(self, session_id, *, offset=0):
189
+ if type(offset) is not int or offset < 0:
190
+ raise ValueError("Offset must be a nonnegative integer")
191
+ return self.session(session_id, "observations?" + urlencode({"offset": offset}))
192
+
193
+ def result(self, session_id):
194
+ return self.session(session_id, "result")
195
+
196
+
197
+ def command_main(argv):
198
+ """Small shell-friendly view of the same client, useful to any local harness."""
199
+ parser = argparse.ArgumentParser(description=__doc__)
200
+ parser.add_argument("action", choices=("status", "exec", "check", "submit", "close", "result", "read", "write"))
201
+ parser.add_argument("value", nargs="?")
202
+ parser.add_argument("--key")
203
+ parser.add_argument("--seconds", type=float, default=120)
204
+ parser.add_argument("--export", help="Export a terminal result to a new directory")
205
+ args = parser.parse_args(argv)
206
+ client = Client(os.environ.get("ICLAYOUT_BENCH_ENDPOINT", ""), os.environ.get("ICLAYOUT_BENCH_TOKEN", ""))
207
+ sid = os.environ.get("ICLAYOUT_BENCH_SESSION", "")
208
+ if args.action in {"exec", "check"}:
209
+ started = (client.check(sid, key=args.key) if args.action == "check" else
210
+ client.execute(sid, args.value, args.seconds, key=args.key))
211
+ offset = 0
212
+ while True:
213
+ status = client.poll(sid, started["execution_id"], offset=offset)
214
+ raw = base64.b64decode(status["log_base64"], validate=True)
215
+ sys.stdout.buffer.write(raw)
216
+ sys.stdout.buffer.flush()
217
+ offset = status["next_offset"]
218
+ if status["state"] != "running" and not raw:
219
+ print(json.dumps({k: status[k] for k in ("state", "exit_code", "truncated")}), file=sys.stderr)
220
+ return 0 if status["exit_code"] == 0 else 1
221
+ time.sleep(.2)
222
+ elif args.action == "submit":
223
+ result = client.submit(sid, args.value, key=args.key)
224
+ elif args.action == "close":
225
+ result = client.close(sid, key=args.key)
226
+ elif args.action == "read":
227
+ sys.stdout.buffer.write(client.read(sid, args.value))
228
+ return 0
229
+ elif args.action == "write":
230
+ result = client.write(sid, args.value, sys.stdin.buffer.read(MAX_RESPONSE_BYTES), key=args.key)
231
+ elif args.action == "result":
232
+ result = client.result(sid)
233
+ if args.export:
234
+ from .analysis import export_session_result
235
+ export_session_result(result, args.export)
236
+ else:
237
+ result = client.session(sid)
238
+ print(json.dumps(result, allow_nan=False))
239
+ return 0
240
+
241
+
242
+ def main(argv=None):
243
+ argv = sys.argv[1:] if argv is None else argv
244
+ if argv and argv[0] in {"status", "exec", "check", "submit", "close", "result", "read", "write"}:
245
+ try:
246
+ return command_main(argv)
247
+ except (ClientError, ValueError, OSError) as error:
248
+ print(str(error), file=sys.stderr)
249
+ return 1
250
+ parser = argparse.ArgumentParser(description=__doc__)
251
+ parser.add_argument("--endpoint", default=os.environ.get("ICLAYOUT_BENCH_ENDPOINT"))
252
+ parser.add_argument("--token-env", default="ICLAYOUT_BENCH_TOKEN")
253
+ parser.add_argument("--timeout", type=float, default=30)
254
+ parser.add_argument("--session", default=os.environ.get("ICLAYOUT_BENCH_SESSION"))
255
+ parser.add_argument("--key", help="Required for POST; retain this value for retries")
256
+ parser.add_argument("method", choices=("GET", "POST"))
257
+ parser.add_argument("operation", help="Session-relative route, or sessions for creation")
258
+ parser.add_argument("--json", default="-", help="POST body file; default reads stdin")
259
+ args = parser.parse_args(argv)
260
+ try:
261
+ if not args.endpoint:
262
+ raise ValueError("Set --endpoint or ICLAYOUT_BENCH_ENDPOINT")
263
+ client = Client(args.endpoint, os.environ.get(args.token_env, ""), timeout=args.timeout)
264
+ body = None
265
+ if args.method == "POST":
266
+ if args.json == "-":
267
+ body = json.load(sys.stdin)
268
+ else:
269
+ with open(args.json) as stream:
270
+ body = json.load(stream)
271
+ if args.session:
272
+ path = f"/sessions/{_identifier(args.session)}"
273
+ if args.operation != "status":
274
+ path += "/" + args.operation
275
+ else:
276
+ if args.operation != "sessions":
277
+ raise ValueError("Set --session for session operations")
278
+ path = "/sessions"
279
+ result = client.request(args.method, path, body, key=args.key)
280
+ print(json.dumps(result, allow_nan=False))
281
+ except (ClientError, ValueError, OSError) as error:
282
+ code = error.code if isinstance(error, ClientError) else "invalid_request"
283
+ print(json.dumps({"error": {"code": code, "message": str(error)}}), file=sys.stderr)
284
+ return 1
285
+ return 0
286
+
287
+
288
+ if __name__ == "__main__":
289
+ raise SystemExit(main())