metewand 0.2.0__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.
- metewand/__init__.py +53 -0
- metewand/__main__.py +9 -0
- metewand/_artifacts.py +157 -0
- metewand/_buffered.py +223 -0
- metewand/_canonical.py +125 -0
- metewand/_functions.py +103 -0
- metewand/_worker.py +549 -0
- metewand-0.2.0.dist-info/METADATA +129 -0
- metewand-0.2.0.dist-info/RECORD +13 -0
- metewand-0.2.0.dist-info/WHEEL +5 -0
- metewand-0.2.0.dist-info/licenses/LICENSE-APACHE +176 -0
- metewand-0.2.0.dist-info/licenses/LICENSE-MIT +21 -0
- metewand-0.2.0.dist-info/top_level.txt +1 -0
metewand/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Small, process-based Python worker helpers for Metewand."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
from ._artifacts import (
|
|
6
|
+
file_entry,
|
|
7
|
+
read_json,
|
|
8
|
+
write_dataset_manifest,
|
|
9
|
+
write_json,
|
|
10
|
+
write_metrics,
|
|
11
|
+
write_result,
|
|
12
|
+
)
|
|
13
|
+
from ._buffered import BufferedTrace, Decision
|
|
14
|
+
from ._canonical import CanonicalJsonError, canonical_bytes, parse_json
|
|
15
|
+
from ._worker import (
|
|
16
|
+
SDK_VERSION,
|
|
17
|
+
Evaluation,
|
|
18
|
+
Implementation,
|
|
19
|
+
OneShotImplementation,
|
|
20
|
+
BufferedImplementation,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
WorkerError,
|
|
23
|
+
serve_dataset_materializer,
|
|
24
|
+
serve_evaluator,
|
|
25
|
+
serve_implementation,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ._functions import FunctionContext, serve_function
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"FunctionContext",
|
|
32
|
+
"serve_function",
|
|
33
|
+
"BufferedImplementation",
|
|
34
|
+
"BufferedTrace",
|
|
35
|
+
"Decision",
|
|
36
|
+
"CanonicalJsonError",
|
|
37
|
+
"Evaluation",
|
|
38
|
+
"Implementation",
|
|
39
|
+
"OneShotImplementation",
|
|
40
|
+
"ProtocolError",
|
|
41
|
+
"WorkerError",
|
|
42
|
+
"canonical_bytes",
|
|
43
|
+
"file_entry",
|
|
44
|
+
"parse_json",
|
|
45
|
+
"read_json",
|
|
46
|
+
"serve_dataset_materializer",
|
|
47
|
+
"serve_evaluator",
|
|
48
|
+
"serve_implementation",
|
|
49
|
+
"write_dataset_manifest",
|
|
50
|
+
"write_json",
|
|
51
|
+
"write_metrics",
|
|
52
|
+
"write_result",
|
|
53
|
+
]
|
metewand/__main__.py
ADDED
metewand/_artifacts.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Explicit file inventories and version-1 worker output envelopes."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ._canonical import MAX_SAFE_INTEGER, canonical_bytes, parse_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_json(path):
|
|
11
|
+
"""Read a document using the restricted version-1 JSON domain."""
|
|
12
|
+
return parse_json(Path(path).read_bytes())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_json(path, value):
|
|
16
|
+
"""Write canonical JSON and close the file before returning."""
|
|
17
|
+
encoded = canonical_bytes(value)
|
|
18
|
+
Path(path).write_bytes(encoded)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _relative_path(value):
|
|
22
|
+
if not isinstance(value, (str, Path)):
|
|
23
|
+
raise TypeError("Paths must be strings or pathlib Paths.")
|
|
24
|
+
value = str(value)
|
|
25
|
+
if (
|
|
26
|
+
not value
|
|
27
|
+
or value.startswith("/")
|
|
28
|
+
or "\\" in value
|
|
29
|
+
or "\x00" in value
|
|
30
|
+
or any(part in {"", ".", ".."} for part in value.split("/"))
|
|
31
|
+
):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"Paths must be normalized, nonempty, and relative to the assigned directory."
|
|
34
|
+
)
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _output_path(directory, relative):
|
|
39
|
+
relative = _relative_path(relative)
|
|
40
|
+
root = Path(directory).resolve()
|
|
41
|
+
target = root / relative
|
|
42
|
+
if not target.resolve().is_relative_to(root):
|
|
43
|
+
raise ValueError("Output paths must stay beneath the assigned directory.")
|
|
44
|
+
return target
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _object(value, name):
|
|
48
|
+
if type(value) is not dict:
|
|
49
|
+
raise ValueError(f"{name} must be a JSON object.")
|
|
50
|
+
canonical_bytes(value)
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _representation(value):
|
|
55
|
+
_object(value, "File representation")
|
|
56
|
+
if not value or set(value) - {"shape", "data_type", "byte_order", "sparse_layout"}:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"File representation must contain only version-1 representation fields."
|
|
59
|
+
)
|
|
60
|
+
if "shape" in value and (
|
|
61
|
+
type(value["shape"]) is not list
|
|
62
|
+
or any(
|
|
63
|
+
type(size) is not int or not 0 <= size <= MAX_SAFE_INTEGER
|
|
64
|
+
for size in value["shape"]
|
|
65
|
+
)
|
|
66
|
+
):
|
|
67
|
+
raise ValueError("Shape must contain nonnegative safe integers.")
|
|
68
|
+
for key in ("data_type", "sparse_layout"):
|
|
69
|
+
if key in value and (type(value[key]) is not str or not value[key]):
|
|
70
|
+
raise ValueError(f"{key} must be a nonempty string.")
|
|
71
|
+
if "byte_order" in value and (
|
|
72
|
+
type(value["byte_order"]) is not str
|
|
73
|
+
or value["byte_order"] not in {"little", "big", "not_applicable"}
|
|
74
|
+
):
|
|
75
|
+
raise ValueError("Invalid byte order.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def file_entry(directory, path, media_type, *, representation=None):
|
|
79
|
+
"""Hash a completed file and describe its author-supplied representation."""
|
|
80
|
+
path = _relative_path(path)
|
|
81
|
+
target = _output_path(directory, path)
|
|
82
|
+
if not target.is_file() or target.is_symlink():
|
|
83
|
+
raise ValueError("An inventory entry must identify a regular file.")
|
|
84
|
+
with target.open("rb") as source:
|
|
85
|
+
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
|
86
|
+
entry = {
|
|
87
|
+
"path": path,
|
|
88
|
+
"media_type": media_type,
|
|
89
|
+
"sha256": digest,
|
|
90
|
+
"size_bytes": target.stat().st_size,
|
|
91
|
+
}
|
|
92
|
+
if representation is not None:
|
|
93
|
+
entry["representation"] = representation
|
|
94
|
+
_files([entry], manifest=None)
|
|
95
|
+
return entry
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _files(files, manifest):
|
|
99
|
+
entries = list(files)
|
|
100
|
+
paths = set()
|
|
101
|
+
for entry in entries:
|
|
102
|
+
_object(entry, "File entry")
|
|
103
|
+
required = {"path", "media_type", "sha256", "size_bytes"}
|
|
104
|
+
if not required <= set(entry) or set(entry) - required - {"representation"}:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"File entries must contain the version-1 inventory fields."
|
|
107
|
+
)
|
|
108
|
+
if type(entry["path"]) is not str:
|
|
109
|
+
raise ValueError("File inventory paths must be strings.")
|
|
110
|
+
path = _relative_path(entry["path"])
|
|
111
|
+
if path in paths or path == manifest:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"File inventory paths must be unique and exclude the manifest itself."
|
|
114
|
+
)
|
|
115
|
+
paths.add(path)
|
|
116
|
+
if type(entry["media_type"]) is not str or not entry["media_type"]:
|
|
117
|
+
raise ValueError("Media types must be nonempty strings.")
|
|
118
|
+
if type(entry["sha256"]) is not str or not re.fullmatch(
|
|
119
|
+
"[0-9a-f]{64}", entry["sha256"]
|
|
120
|
+
):
|
|
121
|
+
raise ValueError("File digests must be lowercase SHA-256 hex strings.")
|
|
122
|
+
size = entry["size_bytes"]
|
|
123
|
+
if type(size) is not int or not 0 <= size <= MAX_SAFE_INTEGER:
|
|
124
|
+
raise ValueError("File sizes must be nonnegative safe integers.")
|
|
125
|
+
if "representation" in entry:
|
|
126
|
+
_representation(entry["representation"])
|
|
127
|
+
return sorted(entries, key=lambda entry: entry["path"].encode("utf-8"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_dataset_manifest(directory, files, *, manifest="dataset-manifest.json"):
|
|
131
|
+
"""Write a complete dataset inventory last and return its relative path."""
|
|
132
|
+
manifest = _relative_path(manifest)
|
|
133
|
+
document = {"schema_version": 1, "complete": True, "files": _files(files, manifest)}
|
|
134
|
+
write_json(_output_path(directory, manifest), document)
|
|
135
|
+
return manifest
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def write_result(directory, data, *, schema=None, files=(), manifest="result.json"):
|
|
139
|
+
"""Write a problem-owned result envelope and return its relative manifest path."""
|
|
140
|
+
manifest = _relative_path(manifest)
|
|
141
|
+
document = {
|
|
142
|
+
"schema_version": 1,
|
|
143
|
+
"data": _object(data, "Result data"),
|
|
144
|
+
"files": _files(files, manifest),
|
|
145
|
+
}
|
|
146
|
+
if schema is not None:
|
|
147
|
+
document["schema"] = _relative_path(schema)
|
|
148
|
+
write_json(_output_path(directory, manifest), document)
|
|
149
|
+
return manifest
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_metrics(path, data, *, schema=None):
|
|
153
|
+
"""Write evaluator-owned metrics at the exact path assigned by Metewand."""
|
|
154
|
+
document = {"schema_version": 1, "data": _object(data, "Metrics data")}
|
|
155
|
+
if schema is not None:
|
|
156
|
+
document["schema"] = _relative_path(schema)
|
|
157
|
+
write_json(path, document)
|
metewand/_buffered.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Bounded local observers; scientific formulas remain adapter-owned."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ._artifacts import _relative_path
|
|
9
|
+
from ._canonical import MAX_SAFE_INTEGER, canonical_bytes, parse_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Decision:
|
|
14
|
+
reason: str | None = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def stop(self):
|
|
18
|
+
return self.reason is not None
|
|
19
|
+
|
|
20
|
+
def __bool__(self):
|
|
21
|
+
return not self.stop
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fail(message, code="operation_failed"):
|
|
25
|
+
from ._worker import WorkerError
|
|
26
|
+
raise WorkerError(code, message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _file(path, data):
|
|
30
|
+
return {"path": path, "sha256": hashlib.sha256(data).hexdigest(), "size_bytes": len(data)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BufferedTrace:
|
|
34
|
+
"""Freeze selected results and finish the checked terminal iterate before return.
|
|
35
|
+
|
|
36
|
+
``clock`` returns cumulative monotonic nanoseconds from the declared scope's
|
|
37
|
+
origin. Native integrations can write to ``directory`` and adopt the same
|
|
38
|
+
artifact contract after their solve, with no per-point Python callbacks.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, profile, directory, *, clock=None):
|
|
42
|
+
self.profile = parse_json(canonical_bytes(profile))
|
|
43
|
+
self.policy = self.profile["policy"]
|
|
44
|
+
self.directory = Path(directory)
|
|
45
|
+
self.points = []
|
|
46
|
+
self.snapshots = []
|
|
47
|
+
self.data = []
|
|
48
|
+
self.reached = []
|
|
49
|
+
self.reason = None
|
|
50
|
+
self.finished = False
|
|
51
|
+
self.adopted = None
|
|
52
|
+
self.total = 0
|
|
53
|
+
self.trace_bytes = 0
|
|
54
|
+
self.snapshot_bytes = 0
|
|
55
|
+
for key in ("max-observations", "max-trace-points", "max-buffer-bytes", "terminal-reserve-bytes"):
|
|
56
|
+
value = self.policy[key]
|
|
57
|
+
if type(value) is not int or not 0 < value <= MAX_SAFE_INTEGER:
|
|
58
|
+
_fail(f"Invalid profile bound: {key}")
|
|
59
|
+
if self.policy["terminal-reserve-bytes"] > self.policy["max-buffer-bytes"]:
|
|
60
|
+
_fail("Terminal reserve exceeds buffer capacity.")
|
|
61
|
+
self.schedule = self.profile["check_schedule"]
|
|
62
|
+
self.schedule.setdefault("start", 0)
|
|
63
|
+
if type(self.schedule["every"]) is not int or not 0 < self.schedule["every"] <= MAX_SAFE_INTEGER:
|
|
64
|
+
_fail("Check interval must be a positive safe integer.")
|
|
65
|
+
self.thresholds = self.policy.get("record-thresholds") or ([self.policy["threshold"]] if "threshold" in self.policy else [])
|
|
66
|
+
expression = self.policy["max-active-time"].strip()
|
|
67
|
+
for suffix, multiplier in (("ms", 1_000_000), ("s", 1_000_000_000), ("min", 60_000_000_000), ("h", 3_600_000_000_000)):
|
|
68
|
+
if expression.endswith(suffix):
|
|
69
|
+
self.active_ns = int(expression[:-len(suffix)].strip()) * multiplier
|
|
70
|
+
break
|
|
71
|
+
else:
|
|
72
|
+
_fail("Invalid max-active-time.")
|
|
73
|
+
if not 0 < self.active_ns <= MAX_SAFE_INTEGER:
|
|
74
|
+
_fail("Invalid max-active-time.")
|
|
75
|
+
epoch = time.perf_counter_ns()
|
|
76
|
+
self.clock = clock if clock is not None else lambda: time.perf_counter_ns() - epoch
|
|
77
|
+
|
|
78
|
+
def _reaches(self, value, threshold):
|
|
79
|
+
return value <= threshold if self.policy["direction"] == "at_most" else value >= threshold
|
|
80
|
+
|
|
81
|
+
def observe(self, progress, measure, snapshot):
|
|
82
|
+
return self._check(progress, measure, snapshot, False)
|
|
83
|
+
|
|
84
|
+
def record(self, progress, metrics, snapshot):
|
|
85
|
+
return self._check(progress, lambda: metrics, snapshot, False)
|
|
86
|
+
|
|
87
|
+
def _snapshot(self, snapshot):
|
|
88
|
+
data = snapshot()
|
|
89
|
+
if type(data) is not dict:
|
|
90
|
+
_fail("Snapshot data must be a JSON object.")
|
|
91
|
+
return canonical_bytes({"schema_version": 1, "data": data, "files": []})
|
|
92
|
+
|
|
93
|
+
def _check(self, progress, measure, snapshot, terminal):
|
|
94
|
+
if self.finished or self.reason or self.adopted:
|
|
95
|
+
_fail("Recording after stop or finish violates the buffered contract.")
|
|
96
|
+
if set(progress) - {"check_index", "work"}:
|
|
97
|
+
_fail("Unknown progress field.")
|
|
98
|
+
index = progress["check_index"]
|
|
99
|
+
work = progress.get("work", {})
|
|
100
|
+
if type(index) is not int or not 0 <= index <= MAX_SAFE_INTEGER or type(work) is not dict or any(type(v) is not int or not 0 <= v <= MAX_SAFE_INTEGER for v in work.values()):
|
|
101
|
+
_fail("Progress must contain nonnegative safe integers.")
|
|
102
|
+
previous = self.points[-1] if self.points else None
|
|
103
|
+
expected = previous["check_index"] + self.schedule["every"] if previous else self.schedule["start"]
|
|
104
|
+
if previous and (index <= previous["check_index"] or any(work.get(k, -1) < v for k, v in previous["work"].items())):
|
|
105
|
+
_fail("Progress and work counts must be monotonic.")
|
|
106
|
+
if index > expected:
|
|
107
|
+
_fail("A scheduled check was skipped.")
|
|
108
|
+
expired = self.clock() >= self.active_ns
|
|
109
|
+
if not terminal and not expired and index != expected:
|
|
110
|
+
return Decision()
|
|
111
|
+
metrics = measure()
|
|
112
|
+
if type(metrics) is not dict:
|
|
113
|
+
_fail("Provisional metrics must be a JSON object.")
|
|
114
|
+
metrics = parse_json(canonical_bytes(metrics))
|
|
115
|
+
elapsed = self.clock()
|
|
116
|
+
if type(elapsed) is not int or not 0 <= elapsed <= MAX_SAFE_INTEGER or (previous and elapsed < previous["instrumented_time_ns"]):
|
|
117
|
+
_fail("Instrumented clock must be monotonic safe nanoseconds.")
|
|
118
|
+
metric = self.policy.get("metric")
|
|
119
|
+
value = metrics.get(metric) if metric else None
|
|
120
|
+
if metric and (type(value) not in (int, float)):
|
|
121
|
+
_fail("Provisional target metric must be finite and numeric.")
|
|
122
|
+
crossed = [t for t in self.thresholds if t not in self.reached and self._reaches(value, t)]
|
|
123
|
+
reason = ("target_reached" if metric and self._reaches(value, self.policy["threshold"]) else
|
|
124
|
+
"implementation_stopped" if terminal else
|
|
125
|
+
"active_time_limit" if elapsed >= self.active_ns else
|
|
126
|
+
"trace_limit" if len(self.points) + 1 >= self.policy["max-trace-points"] else None)
|
|
127
|
+
capture = not metric or bool(crossed) or reason is not None
|
|
128
|
+
if capture and len(self.snapshots) + 1 >= self.policy["max-observations"] and reason is None:
|
|
129
|
+
reason = "observation_limit" if metric else "fixed_count"
|
|
130
|
+
point = {"check_index": index, "work": parse_json(canonical_bytes(work)), "provisional_metrics": metrics,
|
|
131
|
+
"instrumented_time_ns": elapsed, "terminal": reason is not None}
|
|
132
|
+
extra = len(canonical_bytes(point)) + 1
|
|
133
|
+
regular = self.policy["max-buffer-bytes"] - self.policy["terminal-reserve-bytes"]
|
|
134
|
+
if reason is None and self._estimate(extra, None) > regular:
|
|
135
|
+
reason = "byte_limit"
|
|
136
|
+
point["terminal"] = True
|
|
137
|
+
extra = len(canonical_bytes(point)) + 1
|
|
138
|
+
data = self._snapshot(snapshot) if capture or reason else None
|
|
139
|
+
if reason is None and self._estimate(extra, data) > regular:
|
|
140
|
+
reason = "byte_limit"
|
|
141
|
+
point["terminal"] = True
|
|
142
|
+
extra = len(canonical_bytes(point)) + 1
|
|
143
|
+
if self._estimate(extra, data) > self.policy["max-buffer-bytes"]:
|
|
144
|
+
_fail("Terminal check and snapshot exceed max-buffer-bytes.", "resource_limit")
|
|
145
|
+
self.trace_bytes += extra
|
|
146
|
+
self.points.append(point)
|
|
147
|
+
if data is not None:
|
|
148
|
+
self._capture(data, crossed)
|
|
149
|
+
self.reached.extend(crossed)
|
|
150
|
+
self.reason = reason
|
|
151
|
+
return Decision(reason)
|
|
152
|
+
|
|
153
|
+
def _capture(self, data, thresholds):
|
|
154
|
+
ordinal = len(self.snapshots)
|
|
155
|
+
if ordinal >= self.policy["max-observations"]:
|
|
156
|
+
_fail("Terminal snapshot exceeds max-observations.", "resource_limit")
|
|
157
|
+
self.snapshots.append({"capture_ordinal": ordinal, "trace_index": len(self.points) - 1,
|
|
158
|
+
"thresholds": thresholds, "manifest": f"snapshots/{ordinal}/result.json"})
|
|
159
|
+
self.data.append(data)
|
|
160
|
+
self.snapshot_bytes += len(data)
|
|
161
|
+
|
|
162
|
+
def _manifest(self, total, reason, files):
|
|
163
|
+
return {"schema_version": 1, "complete": True, "clock": "monotonic_ns",
|
|
164
|
+
"instrumented_scope": self.profile["instrumented_scope"], "instrumented_total_time_ns": total,
|
|
165
|
+
"stop_reason": reason, "trace": "trace.jsonl", "snapshots": self.snapshots.copy(), "files": files}
|
|
166
|
+
|
|
167
|
+
def _estimate(self, extra, data):
|
|
168
|
+
files = [{"path": "trace.jsonl", "sha256": "0" * 64, "size_bytes": self.trace_bytes + extra}]
|
|
169
|
+
files.extend({"path": s["manifest"], "sha256": "0" * 64, "size_bytes": len(b)} for s, b in zip(self.snapshots, self.data))
|
|
170
|
+
manifest = self._manifest(MAX_SAFE_INTEGER, "implementation_stopped", files)
|
|
171
|
+
if data is not None:
|
|
172
|
+
ordinal = len(self.snapshots)
|
|
173
|
+
path = f"snapshots/{ordinal}/result.json"
|
|
174
|
+
manifest["snapshots"].append({"capture_ordinal": ordinal, "trace_index": len(self.points), "thresholds": self.thresholds, "manifest": path})
|
|
175
|
+
files.append({"path": path, "sha256": "0" * 64, "size_bytes": len(data)})
|
|
176
|
+
return self.trace_bytes + extra + self.snapshot_bytes + (len(data) if data is not None else 0) + len(canonical_bytes(manifest))
|
|
177
|
+
|
|
178
|
+
def finish(self, progress, measure, snapshot):
|
|
179
|
+
if self.finished:
|
|
180
|
+
_fail("Buffer was already finished.")
|
|
181
|
+
if self.points and progress["check_index"] == self.points[-1]["check_index"]:
|
|
182
|
+
if progress.get("work", {}) != self.points[-1]["work"]:
|
|
183
|
+
_fail("Terminal work changed without advancing progress.")
|
|
184
|
+
if not self.snapshots or self.snapshots[-1]["trace_index"] != len(self.points) - 1:
|
|
185
|
+
data = self._snapshot(snapshot)
|
|
186
|
+
if self._estimate(0, data) > self.policy["max-buffer-bytes"]:
|
|
187
|
+
_fail("Terminal snapshot does not fit.", "resource_limit")
|
|
188
|
+
self._capture(data, [])
|
|
189
|
+
self.points[-1]["terminal"] = True
|
|
190
|
+
else:
|
|
191
|
+
self._check(progress, measure, snapshot, True)
|
|
192
|
+
self.reason = self.reason or "implementation_stopped"
|
|
193
|
+
self.total = self.clock()
|
|
194
|
+
if type(self.total) is not int or not self.points[-1]["instrumented_time_ns"] <= self.total <= MAX_SAFE_INTEGER:
|
|
195
|
+
_fail("Invalid total instrumented duration.")
|
|
196
|
+
self.finished = True
|
|
197
|
+
|
|
198
|
+
def adopt_buffer(self, manifest="buffer.json"):
|
|
199
|
+
if self.points or self.finished:
|
|
200
|
+
_fail("Cannot combine a native buffer with another trajectory.")
|
|
201
|
+
manifest = _relative_path(manifest)
|
|
202
|
+
self.adopted = manifest
|
|
203
|
+
self.finished = True
|
|
204
|
+
|
|
205
|
+
def write(self):
|
|
206
|
+
if not self.finished:
|
|
207
|
+
_fail("execute_buffered must finish the terminal check before returning.")
|
|
208
|
+
if self.adopted:
|
|
209
|
+
return self.adopted
|
|
210
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
trace = b"".join(canonical_bytes(p) + b"\n" for p in self.points)
|
|
212
|
+
(self.directory / "trace.jsonl").write_bytes(trace)
|
|
213
|
+
files = [_file("trace.jsonl", trace)]
|
|
214
|
+
for saved, data in zip(self.snapshots, self.data):
|
|
215
|
+
path = self.directory / saved["manifest"]
|
|
216
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
path.write_bytes(data)
|
|
218
|
+
files.append(_file(saved["manifest"], data))
|
|
219
|
+
manifest = canonical_bytes(self._manifest(self.total, self.reason, files))
|
|
220
|
+
if len(trace) + self.snapshot_bytes + len(manifest) > self.policy["max-buffer-bytes"]:
|
|
221
|
+
_fail("Completed buffer exceeds byte bound.", "resource_limit")
|
|
222
|
+
(self.directory / "buffer.json").write_bytes(manifest)
|
|
223
|
+
return "buffer.json"
|
metewand/_canonical.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The restricted version-1 JSON domain and RFC 8785 encoding."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
MAX_SAFE_INTEGER = (1 << 53) - 1
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CanonicalJsonError(ValueError):
|
|
10
|
+
"""A value cannot be represented by Metewand's canonical JSON contract."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize(value):
|
|
14
|
+
if value is None or type(value) is bool:
|
|
15
|
+
return value
|
|
16
|
+
if type(value) is str:
|
|
17
|
+
value.encode("utf-8")
|
|
18
|
+
return value
|
|
19
|
+
if type(value) is int:
|
|
20
|
+
if abs(value) > MAX_SAFE_INTEGER:
|
|
21
|
+
raise CanonicalJsonError(
|
|
22
|
+
"Integers must lie in the inclusive 53-bit safe range."
|
|
23
|
+
)
|
|
24
|
+
return value
|
|
25
|
+
if type(value) is float:
|
|
26
|
+
if not math.isfinite(value):
|
|
27
|
+
raise CanonicalJsonError("Numbers must be finite binary64 values.")
|
|
28
|
+
if value.is_integer():
|
|
29
|
+
return _normalize(int(value))
|
|
30
|
+
return value
|
|
31
|
+
if type(value) is list:
|
|
32
|
+
return [_normalize(item) for item in value]
|
|
33
|
+
if type(value) is dict:
|
|
34
|
+
if any(type(key) is not str for key in value):
|
|
35
|
+
raise CanonicalJsonError("Object keys must be strings.")
|
|
36
|
+
return {_normalize(key): _normalize(item) for key, item in value.items()}
|
|
37
|
+
raise CanonicalJsonError(f"Unsupported JSON value type: {type(value).__name__}.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unique_object(pairs):
|
|
41
|
+
result = {}
|
|
42
|
+
for key, value in pairs:
|
|
43
|
+
if key in result:
|
|
44
|
+
raise CanonicalJsonError(f"Duplicate JSON object key: {key!r}.")
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _invalid_constant(value):
|
|
50
|
+
raise CanonicalJsonError(f"Invalid JSON number: {value}.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_json(source):
|
|
54
|
+
"""Parse UTF-8 bytes or text, rejecting duplicate keys and noncanonical values.
|
|
55
|
+
|
|
56
|
+
The returned value normalizes integral floats and negative zero to integers.
|
|
57
|
+
Source whitespace and property order need not be canonical.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
if isinstance(source, bytes):
|
|
61
|
+
source = source.decode("utf-8")
|
|
62
|
+
if not isinstance(source, str):
|
|
63
|
+
raise CanonicalJsonError("JSON input must be UTF-8 bytes or text.")
|
|
64
|
+
return _normalize(
|
|
65
|
+
json.loads(
|
|
66
|
+
source,
|
|
67
|
+
object_pairs_hook=_unique_object,
|
|
68
|
+
parse_constant=_invalid_constant,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
except (ValueError, UnicodeError, RecursionError) as error:
|
|
72
|
+
raise CanonicalJsonError(str(error)) from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _float_text(value):
|
|
76
|
+
# Python supplies the shortest round-tripping digits; ECMAScript uses a
|
|
77
|
+
# different threshold for exponent notation and omits exponent zero padding.
|
|
78
|
+
text = repr(value)
|
|
79
|
+
if "e" not in text:
|
|
80
|
+
return text
|
|
81
|
+
mantissa, exponent = text.split("e")
|
|
82
|
+
exponent = int(exponent)
|
|
83
|
+
if abs(value) < 1e-6:
|
|
84
|
+
return f"{mantissa}e{exponent}"
|
|
85
|
+
negative = mantissa.startswith("-")
|
|
86
|
+
digits = mantissa.lstrip("-").replace(".", "")
|
|
87
|
+
point = 1 + exponent
|
|
88
|
+
if point <= 0:
|
|
89
|
+
text = "0." + "0" * -point + digits
|
|
90
|
+
elif point >= len(digits):
|
|
91
|
+
text = digits + "0" * (point - len(digits))
|
|
92
|
+
else:
|
|
93
|
+
text = digits[:point] + "." + digits[point:]
|
|
94
|
+
return ("-" if negative else "") + text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _encode(value):
|
|
98
|
+
if value is None:
|
|
99
|
+
return "null"
|
|
100
|
+
if type(value) is bool:
|
|
101
|
+
return "true" if value else "false"
|
|
102
|
+
if type(value) is str:
|
|
103
|
+
return json.dumps(value, ensure_ascii=False)
|
|
104
|
+
if type(value) is int:
|
|
105
|
+
return str(value)
|
|
106
|
+
if type(value) is float:
|
|
107
|
+
return _float_text(value)
|
|
108
|
+
if type(value) is list:
|
|
109
|
+
return "[" + ",".join(_encode(item) for item in value) + "]"
|
|
110
|
+
keys = sorted(value, key=lambda key: key.encode("utf-16-be"))
|
|
111
|
+
return (
|
|
112
|
+
"{" + ",".join(_encode(key) + ":" + _encode(value[key]) for key in keys) + "}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def canonical_bytes(value):
|
|
117
|
+
"""Encode built-in JSON values as canonical UTF-8 bytes without a newline.
|
|
118
|
+
|
|
119
|
+
This never coerces tuples, arrays, custom scalars, or nonstring object keys.
|
|
120
|
+
Convert library-specific values explicitly in benchmark code.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
return _encode(_normalize(value)).encode("utf-8")
|
|
124
|
+
except (ValueError, UnicodeError, RecursionError) as error:
|
|
125
|
+
raise CanonicalJsonError(str(error)) from error
|
metewand/_functions.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Ordinary scientific functions backed by the existing worker lifecycle."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ._artifacts import file_entry, read_json, write_dataset_manifest, write_json
|
|
9
|
+
from ._worker import Evaluation, OneShotImplementation, _serve_fds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FunctionContext:
|
|
14
|
+
"""Role parameters, problem parameters, seed, and declared scientific rules."""
|
|
15
|
+
|
|
16
|
+
parameters: dict
|
|
17
|
+
problem_parameters: dict
|
|
18
|
+
seed: int | None
|
|
19
|
+
semantics: dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def materialize_function(function, source_dir, parameters, seed, output_dir):
|
|
23
|
+
data = function(FunctionContext(parameters, {}, seed, {}))
|
|
24
|
+
if type(data) is not dict:
|
|
25
|
+
raise TypeError("A dataset function must return a JSON-compatible object.")
|
|
26
|
+
write_json(output_dir / "data.json", data)
|
|
27
|
+
return write_dataset_manifest(output_dir, [file_entry(output_dir, "data.json", "application/json")])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_function_data(dataset):
|
|
31
|
+
path = dataset / "data.json"
|
|
32
|
+
if not path.exists() and not any(dataset.iterdir()):
|
|
33
|
+
return {}
|
|
34
|
+
data = read_json(path)
|
|
35
|
+
if type(data) is not dict:
|
|
36
|
+
raise TypeError("A function dataset must contain a JSON object in data.json.")
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FunctionImplementation(OneShotImplementation):
|
|
41
|
+
def __init__(self, function):
|
|
42
|
+
self.function = function
|
|
43
|
+
|
|
44
|
+
def prepare(self, dataset, contract, problem_parameters, parameters, seed):
|
|
45
|
+
self.data = read_function_data(dataset)
|
|
46
|
+
self.context = FunctionContext(parameters, problem_parameters, seed, {})
|
|
47
|
+
|
|
48
|
+
def execute(self, budget, observation_control):
|
|
49
|
+
return self.function(self.data, self.context)
|
|
50
|
+
|
|
51
|
+
def reset(self):
|
|
52
|
+
self.data = None
|
|
53
|
+
self.context = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def evaluate_function(function, data, result, context):
|
|
57
|
+
outcome = function(data, result, context)
|
|
58
|
+
completion = context.semantics.get("one_shot_completion", {})
|
|
59
|
+
if isinstance(outcome, Evaluation):
|
|
60
|
+
if not completion and outcome.verdict in {"reached", "not_reached"}:
|
|
61
|
+
raise ValueError("A completion verdict requires declared completion rules; use not_required or invalid.")
|
|
62
|
+
if completion and outcome.verdict == "not_required":
|
|
63
|
+
raise ValueError("Declared completion rules require an explicit completion verdict.")
|
|
64
|
+
return outcome
|
|
65
|
+
if context.semantics.get("validity") or completion:
|
|
66
|
+
raise ValueError("Scientific validity or completion rules require an explicit Evaluation verdict.")
|
|
67
|
+
return Evaluation(outcome, "not_required")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def serve_function(role, file, name):
|
|
71
|
+
"""Load one explicitly selected function inside an authorized worker process."""
|
|
72
|
+
file = Path(file)
|
|
73
|
+
function = None
|
|
74
|
+
|
|
75
|
+
def initialize():
|
|
76
|
+
nonlocal function
|
|
77
|
+
# Loading during hello excludes imports from default timing and reports errors.
|
|
78
|
+
sys.path.insert(0, str(file.parent))
|
|
79
|
+
spec = importlib.util.spec_from_file_location("_metewand_benchmark", file)
|
|
80
|
+
if spec is None or spec.loader is None:
|
|
81
|
+
raise ValueError(f"Cannot load function source {file}.")
|
|
82
|
+
module = importlib.util.module_from_spec(spec)
|
|
83
|
+
sys.modules[spec.name] = module
|
|
84
|
+
spec.loader.exec_module(module)
|
|
85
|
+
function = getattr(module, name, None)
|
|
86
|
+
if not callable(function):
|
|
87
|
+
raise ValueError(f"{file}:{name} does not identify a callable function.")
|
|
88
|
+
|
|
89
|
+
def call(*args):
|
|
90
|
+
return function(*args)
|
|
91
|
+
|
|
92
|
+
if role == "dataset_materializer":
|
|
93
|
+
_serve_fds(lambda *args: materialize_function(call, *args), role, None, None, initialize=initialize)
|
|
94
|
+
elif role == "implementation":
|
|
95
|
+
_serve_fds(FunctionImplementation(call), role, None, None, initialize=initialize)
|
|
96
|
+
elif role == "problem_evaluator":
|
|
97
|
+
def evaluate(dataset, contract, parameters, semantics, result_dir, metrics_path):
|
|
98
|
+
data = read_function_data(dataset)
|
|
99
|
+
result = read_json(result_dir / "result.json")["data"]
|
|
100
|
+
return evaluate_function(call, data, result, FunctionContext(parameters, parameters, None, semantics))
|
|
101
|
+
_serve_fds(evaluate, role, None, None, semantics=True, initialize=initialize)
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(f"Unknown function worker role: {role}.")
|