ithrl 0.1.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.
- ithrl/__init__.py +1 -0
- ithrl/__main__.py +6 -0
- ithrl/adapters/__init__.py +49 -0
- ithrl/adapters/_jsonl.py +266 -0
- ithrl/adapters/codex/__init__.py +5 -0
- ithrl/adapters/codex/context.py +204 -0
- ithrl/adapters/codex/decisions.py +224 -0
- ithrl/adapters/codex/discovery.py +60 -0
- ithrl/adapters/codex/parser.py +191 -0
- ithrl/adapters/contracts.py +106 -0
- ithrl/adapters/copilot/__init__.py +5 -0
- ithrl/adapters/copilot/context.py +182 -0
- ithrl/adapters/copilot/decisions.py +294 -0
- ithrl/adapters/copilot/discovery.py +28 -0
- ithrl/adapters/copilot/parser.py +283 -0
- ithrl/adapters/discovery.py +220 -0
- ithrl/adapters/errors.py +52 -0
- ithrl/adapters/registry.py +433 -0
- ithrl/behavior.py +138 -0
- ithrl/calibration.py +434 -0
- ithrl/case/__init__.py +33 -0
- ithrl/case/contracts.py +88 -0
- ithrl/case/evidence.py +169 -0
- ithrl/case/filesystem.py +340 -0
- ithrl/case/source.py +208 -0
- ithrl/case/source_context.py +119 -0
- ithrl/case/source_storage.py +234 -0
- ithrl/case/storage.py +377 -0
- ithrl/cli/__init__.py +968 -0
- ithrl/cli/extract.py +478 -0
- ithrl/cli/native.py +484 -0
- ithrl/cli/output.py +786 -0
- ithrl/cli/paths.py +24 -0
- ithrl/cli/sampled.py +485 -0
- ithrl/cli/sampled_output.py +472 -0
- ithrl/cli/sampled_reduction.py +1036 -0
- ithrl/cli/sampled_session.py +342 -0
- ithrl/cli/shrink.py +523 -0
- ithrl/context.py +465 -0
- ithrl/decisions.py +271 -0
- ithrl/examples/synthetic-trace.json +46 -0
- ithrl/execution/__init__.py +68 -0
- ithrl/execution/budget.py +146 -0
- ithrl/execution/contracts.py +426 -0
- ithrl/execution/preflight.py +346 -0
- ithrl/experiment.py +453 -0
- ithrl/extraction.py +206 -0
- ithrl/measurement.py +315 -0
- ithrl/observations.py +1148 -0
- ithrl/path_safety.py +100 -0
- ithrl/reduce/__init__.py +53 -0
- ithrl/reduce/contracts.py +508 -0
- ithrl/reduce/engine.py +901 -0
- ithrl/reduce/search.py +178 -0
- ithrl/results/__init__.py +49 -0
- ithrl/results/models.py +294 -0
- ithrl/results/storage.py +485 -0
- ithrl/results/summary.py +420 -0
- ithrl/results/validation.py +611 -0
- ithrl/run_state.py +1135 -0
- ithrl/runner/__init__.py +44 -0
- ithrl/runner/codex/__init__.py +17 -0
- ithrl/runner/codex/capability.py +708 -0
- ithrl/runner/codex/configuration.py +529 -0
- ithrl/runner/codex/isolation.py +396 -0
- ithrl/runner/codex/materialize.py +358 -0
- ithrl/runner/codex/policy.py +67 -0
- ithrl/runner/codex/protocol.py +649 -0
- ithrl/runner/codex/protocol_contracts.py +171 -0
- ithrl/runner/codex/runner.py +533 -0
- ithrl/runner/codex/runtime_configuration.py +264 -0
- ithrl/runner/codex/schema.py +527 -0
- ithrl/runner/codex/transcript.py +130 -0
- ithrl/runner/codex/worker.py +175 -0
- ithrl/runner/contracts.py +419 -0
- ithrl/runner/copilot/__init__.py +18 -0
- ithrl/runner/copilot/capability.py +825 -0
- ithrl/runner/copilot/configuration.py +201 -0
- ithrl/runner/copilot/isolation.py +374 -0
- ithrl/runner/copilot/materialize.py +484 -0
- ithrl/runner/copilot/policy.py +110 -0
- ithrl/runner/copilot/protocol/__init__.py +18 -0
- ithrl/runner/copilot/protocol/contracts.py +74 -0
- ithrl/runner/copilot/protocol/decoding.py +93 -0
- ithrl/runner/copilot/protocol/parser.py +418 -0
- ithrl/runner/copilot/protocol/state.py +130 -0
- ithrl/runner/copilot/runner.py +425 -0
- ithrl/runner/copilot/worker.py +351 -0
- ithrl/runner/evidence.py +345 -0
- ithrl/runner/fake.py +107 -0
- ithrl/runner/registry.py +90 -0
- ithrl/runner/runtime/__init__.py +50 -0
- ithrl/runner/runtime/contracts.py +157 -0
- ithrl/runner/runtime/output.py +184 -0
- ithrl/runner/runtime/process.py +954 -0
- ithrl/runner/runtime/protection.py +562 -0
- ithrl/runner/runtime/state.py +282 -0
- ithrl/runner/validation.py +376 -0
- ithrl/sampling.py +412 -0
- ithrl/statistics.py +276 -0
- ithrl/synthetic.py +511 -0
- ithrl/timestamps.py +26 -0
- ithrl/workflow.py +570 -0
- ithrl-0.1.0.dist-info/METADATA +118 -0
- ithrl-0.1.0.dist-info/RECORD +108 -0
- ithrl-0.1.0.dist-info/WHEEL +4 -0
- ithrl-0.1.0.dist-info/entry_points.txt +2 -0
- ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
ithrl/run_state.py
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
"""Atomic mutable state for an in-progress reduction run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from filelock import Timeout
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
19
|
+
|
|
20
|
+
from ithrl.case.contracts import CaseError
|
|
21
|
+
from ithrl.case.filesystem import read_safe_bytes
|
|
22
|
+
from ithrl.context import content_digest
|
|
23
|
+
from ithrl.observations import (
|
|
24
|
+
Observation,
|
|
25
|
+
ObservationArm,
|
|
26
|
+
ObservationIdentity,
|
|
27
|
+
ObservationRole,
|
|
28
|
+
ObservationStore,
|
|
29
|
+
ObservationStoreError,
|
|
30
|
+
SamplingArm,
|
|
31
|
+
SamplingAttempt,
|
|
32
|
+
SamplingBlockEvidence,
|
|
33
|
+
SamplingBlockPlan,
|
|
34
|
+
SamplingBudgetAllowance,
|
|
35
|
+
)
|
|
36
|
+
from ithrl.path_safety import UnsafePathError, private_file_lock
|
|
37
|
+
from ithrl.reduce.contracts import ReductionCheckpoint, SampledReductionCheckpoint
|
|
38
|
+
from ithrl.runner import ProviderAcceptanceState
|
|
39
|
+
|
|
40
|
+
_MAX_RUN_STATE_BYTES = 64 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RunStateError(ValueError):
|
|
44
|
+
"""An in-progress run state is unavailable, unsafe, or inconsistent."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunStateBusyError(RunStateError):
|
|
48
|
+
"""Another process holds the exclusive lock for an in-progress run."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RunState(BaseModel):
|
|
52
|
+
"""The complete state needed to continue an interrupted process."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
55
|
+
|
|
56
|
+
schema_version: Literal[1] = 1
|
|
57
|
+
run_id: Annotated[str, Field(min_length=1, max_length=128)]
|
|
58
|
+
source_case: Annotated[str, Field(min_length=1, max_length=4096)]
|
|
59
|
+
observation_store: Annotated[str, Field(min_length=1, max_length=4096)]
|
|
60
|
+
checkpoint: ReductionCheckpoint
|
|
61
|
+
status: Literal["active", "completed"] = "active"
|
|
62
|
+
result_path: Annotated[str | None, Field(min_length=1)] = None
|
|
63
|
+
|
|
64
|
+
@model_validator(mode="after")
|
|
65
|
+
def validate_completion(self) -> RunState:
|
|
66
|
+
if (self.status == "completed") != (self.result_path is not None):
|
|
67
|
+
raise ValueError("only completed run state may reference a result")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunStateFile:
|
|
72
|
+
"""A private, atomically replaced state file outside immutable results."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, path: Path) -> None:
|
|
75
|
+
self.path = path if path.is_absolute() else Path.cwd() / path
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def create(
|
|
79
|
+
cls,
|
|
80
|
+
*,
|
|
81
|
+
case_root: Path,
|
|
82
|
+
run_id: str,
|
|
83
|
+
observation_store: Path,
|
|
84
|
+
checkpoint: ReductionCheckpoint,
|
|
85
|
+
) -> RunStateFile:
|
|
86
|
+
runs_root = case_root / ".runs"
|
|
87
|
+
_reject_unsafe_path(runs_root)
|
|
88
|
+
runs_root.mkdir(mode=0o700, exist_ok=True)
|
|
89
|
+
if os.name == "posix":
|
|
90
|
+
runs_root.chmod(0o700)
|
|
91
|
+
root = runs_root / run_id
|
|
92
|
+
_reject_unsafe_path(root)
|
|
93
|
+
root.mkdir(mode=0o700, exist_ok=False)
|
|
94
|
+
if os.name == "posix":
|
|
95
|
+
root.chmod(0o700)
|
|
96
|
+
state_file = cls(root / "state.json")
|
|
97
|
+
state_file._write(
|
|
98
|
+
RunState(
|
|
99
|
+
run_id=run_id,
|
|
100
|
+
source_case=str(case_root.absolute()),
|
|
101
|
+
observation_store=str(observation_store.absolute()),
|
|
102
|
+
checkpoint=checkpoint,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return state_file
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def open(cls, source: Path) -> RunStateFile:
|
|
109
|
+
path = source / "state.json" if source.is_dir() else source
|
|
110
|
+
return cls(path)
|
|
111
|
+
|
|
112
|
+
def load(self) -> RunState:
|
|
113
|
+
"""Load and validate one private state file without accepting a symbolic link."""
|
|
114
|
+
|
|
115
|
+
_reject_unsafe_path(self.path)
|
|
116
|
+
try:
|
|
117
|
+
metadata = self.path.lstat()
|
|
118
|
+
except OSError as error:
|
|
119
|
+
raise RunStateError(f"run state is unavailable: {self.path}") from error
|
|
120
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
121
|
+
raise RunStateError("run state must be a regular file, not a link")
|
|
122
|
+
if os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o600:
|
|
123
|
+
raise RunStateError("run state must have mode 0600")
|
|
124
|
+
if metadata.st_size > _MAX_RUN_STATE_BYTES:
|
|
125
|
+
raise RunStateError(f"run state exceeds the {_MAX_RUN_STATE_BYTES}-byte limit")
|
|
126
|
+
if os.name == "posix":
|
|
127
|
+
parent_mode = stat.S_IMODE(self.path.parent.stat().st_mode)
|
|
128
|
+
if parent_mode != 0o700:
|
|
129
|
+
raise RunStateError("run-state directory must have mode 0700")
|
|
130
|
+
try:
|
|
131
|
+
source = self.path.read_text(encoding="utf-8")
|
|
132
|
+
except (OSError, UnicodeError) as error:
|
|
133
|
+
raise RunStateError(f"run state is unavailable: {self.path}") from error
|
|
134
|
+
|
|
135
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
136
|
+
result: dict[str, object] = {}
|
|
137
|
+
for key, value in pairs:
|
|
138
|
+
if key in result:
|
|
139
|
+
raise RunStateError(f"run state contains duplicate key {key!r}")
|
|
140
|
+
result[key] = value
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
payload = json.loads(source, object_pairs_hook=reject_duplicate_keys)
|
|
145
|
+
return RunState.model_validate(payload)
|
|
146
|
+
except (json.JSONDecodeError, RecursionError, ValidationError) as error:
|
|
147
|
+
raise RunStateError(f"invalid run state: {error}") from error
|
|
148
|
+
|
|
149
|
+
def save_checkpoint(self, checkpoint: ReductionCheckpoint) -> None:
|
|
150
|
+
current = self.load()
|
|
151
|
+
if current.status == "completed":
|
|
152
|
+
raise RunStateError(f"run already completed: {current.result_path}")
|
|
153
|
+
self._write(current.model_copy(update={"checkpoint": checkpoint}))
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def acquire_lock(self) -> Generator[None]:
|
|
157
|
+
"""Prevent concurrent execution against the active run."""
|
|
158
|
+
|
|
159
|
+
lock_path = self.path.with_name("run.lock")
|
|
160
|
+
_reject_unsafe_path(lock_path)
|
|
161
|
+
try:
|
|
162
|
+
lock = private_file_lock(lock_path)
|
|
163
|
+
except UnsafePathError as error:
|
|
164
|
+
raise RunStateError("run lock file is unsafe") from error
|
|
165
|
+
try:
|
|
166
|
+
with lock:
|
|
167
|
+
yield
|
|
168
|
+
except Timeout as error:
|
|
169
|
+
raise RunStateBusyError("another process is already using the active run") from error
|
|
170
|
+
|
|
171
|
+
def complete(self, result_path: Path) -> None:
|
|
172
|
+
current = self.load()
|
|
173
|
+
self._write(
|
|
174
|
+
current.model_copy(
|
|
175
|
+
update={"status": "completed", "result_path": str(result_path.absolute())}
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _write(self, state: RunState) -> None:
|
|
180
|
+
_reject_unsafe_path(self.path.parent)
|
|
181
|
+
temporary = self.path.with_name(f".{self.path.name}.tmp")
|
|
182
|
+
if temporary.exists() or temporary.is_symlink():
|
|
183
|
+
temporary.unlink()
|
|
184
|
+
rendered = json.dumps(
|
|
185
|
+
state.model_dump(mode="json"),
|
|
186
|
+
allow_nan=False,
|
|
187
|
+
ensure_ascii=False,
|
|
188
|
+
indent=2,
|
|
189
|
+
sort_keys=True,
|
|
190
|
+
)
|
|
191
|
+
if len(rendered.encode("utf-8")) + 1 > _MAX_RUN_STATE_BYTES:
|
|
192
|
+
raise RunStateError(f"run state exceeds the {_MAX_RUN_STATE_BYTES}-byte limit")
|
|
193
|
+
try:
|
|
194
|
+
with temporary.open("x", encoding="utf-8", newline="\n") as stream:
|
|
195
|
+
stream.write(f"{rendered}\n")
|
|
196
|
+
stream.flush()
|
|
197
|
+
os.fsync(stream.fileno())
|
|
198
|
+
if os.name == "posix":
|
|
199
|
+
temporary.chmod(0o600)
|
|
200
|
+
temporary.replace(self.path)
|
|
201
|
+
_sync_directory(self.path.parent)
|
|
202
|
+
except BaseException:
|
|
203
|
+
temporary.unlink(missing_ok=True)
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _reject_unsafe_path(path: Path) -> None:
|
|
208
|
+
if ".." in path.parts:
|
|
209
|
+
raise RunStateError("run-state path cannot contain '..'")
|
|
210
|
+
absolute = path if path.is_absolute() else Path.cwd() / path
|
|
211
|
+
current = Path(absolute.anchor)
|
|
212
|
+
for part in absolute.parts[1:]:
|
|
213
|
+
current /= part
|
|
214
|
+
if os.path.lexists(current) and current.is_symlink():
|
|
215
|
+
raise RunStateError(f"run-state path contains a symbolic link: {current}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _sync_directory(path: Path) -> None:
|
|
219
|
+
if os.name != "posix":
|
|
220
|
+
return
|
|
221
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
222
|
+
try:
|
|
223
|
+
os.fsync(descriptor)
|
|
224
|
+
finally:
|
|
225
|
+
os.close(descriptor)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
type _StateDigest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
|
|
229
|
+
type _PrivateRunId = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _SampledStateContract(BaseModel):
|
|
233
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SamplingObservationReference(_SampledStateContract):
|
|
237
|
+
"""Membership and prepared-attempt binding; observation facts remain in SQLite."""
|
|
238
|
+
|
|
239
|
+
observation_id: Annotated[str, Field(min_length=1, max_length=128)]
|
|
240
|
+
attempt_id: Annotated[str, Field(pattern=r"^attempt-[0-9a-f]{32}$")]
|
|
241
|
+
attempt_digest: _StateDigest
|
|
242
|
+
schedule_position: Annotated[int, Field(ge=0)]
|
|
243
|
+
independence_evidence_digest: _StateDigest | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SamplingBlockProgress(_SampledStateContract):
|
|
247
|
+
"""One immutable admitted plan and its append-only ordered observation references."""
|
|
248
|
+
|
|
249
|
+
plan: SamplingBlockPlan
|
|
250
|
+
observations: Annotated[tuple[SamplingObservationReference, ...], Field(max_length=20_000)] = ()
|
|
251
|
+
archived: bool = False
|
|
252
|
+
|
|
253
|
+
@model_validator(mode="after")
|
|
254
|
+
def validate_positions(self) -> SamplingBlockProgress:
|
|
255
|
+
positions = tuple(item.schedule_position for item in self.observations)
|
|
256
|
+
if positions != tuple(sorted(set(positions))):
|
|
257
|
+
raise ValueError("sampling references must have unique increasing positions")
|
|
258
|
+
if positions and positions[-1] >= len(self.plan.schedule):
|
|
259
|
+
raise ValueError("sampling reference exceeds the frozen schedule")
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class SamplingBudgetApproval(_SampledStateContract):
|
|
264
|
+
"""Recorded administrative allowance; this reference grants no execution authority."""
|
|
265
|
+
|
|
266
|
+
approval_id: _PrivateRunId
|
|
267
|
+
approved_at: datetime
|
|
268
|
+
allowance: SamplingBudgetAllowance
|
|
269
|
+
|
|
270
|
+
@field_validator("approved_at")
|
|
271
|
+
@classmethod
|
|
272
|
+
def normalize_time(cls, value: datetime) -> datetime:
|
|
273
|
+
return _sampled_time(value)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class SamplingDispatchReservation(_SampledStateContract):
|
|
277
|
+
"""Durable charge and exact reconciliation key, published before native dispatch."""
|
|
278
|
+
|
|
279
|
+
block_id: Annotated[str, Field(min_length=1, max_length=128)]
|
|
280
|
+
block_digest: _StateDigest
|
|
281
|
+
schedule_position: Annotated[int, Field(ge=0)]
|
|
282
|
+
arm: SamplingArm
|
|
283
|
+
attempt: Annotated[int, Field(ge=1)]
|
|
284
|
+
attempt_id: Annotated[str, Field(pattern=r"^attempt-[0-9a-f]{32}$")]
|
|
285
|
+
attempt_digest: _StateDigest
|
|
286
|
+
reserved_at: datetime
|
|
287
|
+
provider_calls: Annotated[int, Field(ge=1, le=2)]
|
|
288
|
+
runner_seconds: Annotated[int, Field(ge=1)]
|
|
289
|
+
|
|
290
|
+
@field_validator("reserved_at")
|
|
291
|
+
@classmethod
|
|
292
|
+
def normalize_time(cls, value: datetime) -> datetime:
|
|
293
|
+
return _sampled_time(value)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class SampledRunUsage(_SampledStateContract):
|
|
297
|
+
"""Known call lower bounds, conservative upper bounds and separately charged dispatch.
|
|
298
|
+
|
|
299
|
+
Missing provider accounting remains explicit, including accepted attempts whose evidence
|
|
300
|
+
proves at least one call but cannot establish the complete count.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
actual_provider_calls: int
|
|
304
|
+
possible_provider_calls: int
|
|
305
|
+
unknown_provider_attempts: int
|
|
306
|
+
pending_provider_calls: int
|
|
307
|
+
runner_milliseconds: int
|
|
308
|
+
unknown_runner_attempts: int
|
|
309
|
+
known_tokens: int
|
|
310
|
+
unknown_token_attempts: int
|
|
311
|
+
wall_time_seconds: float
|
|
312
|
+
pending_runner_seconds: int
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def charged_provider_calls(self) -> int:
|
|
316
|
+
return (
|
|
317
|
+
self.actual_provider_calls + self.possible_provider_calls + self.pending_provider_calls
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SampledFinalSelection(_SampledStateContract):
|
|
322
|
+
"""One selected artifact and holdout plan, frozen before any confirmation dispatch."""
|
|
323
|
+
|
|
324
|
+
checkpoint_digest: _StateDigest
|
|
325
|
+
selected_at: datetime
|
|
326
|
+
plan: SamplingBlockPlan
|
|
327
|
+
search_stop_reason: (
|
|
328
|
+
Literal[
|
|
329
|
+
"candidate-limit",
|
|
330
|
+
"trial-limit",
|
|
331
|
+
"provider-call-budget",
|
|
332
|
+
"wall-time-budget",
|
|
333
|
+
"freshness-budget",
|
|
334
|
+
"attempt-limit",
|
|
335
|
+
]
|
|
336
|
+
| None
|
|
337
|
+
) = None
|
|
338
|
+
|
|
339
|
+
@field_validator("selected_at")
|
|
340
|
+
@classmethod
|
|
341
|
+
def normalize_time(cls, value: datetime) -> datetime:
|
|
342
|
+
return _sampled_time(value)
|
|
343
|
+
|
|
344
|
+
@model_validator(mode="after")
|
|
345
|
+
def validate_plan(self) -> SampledFinalSelection:
|
|
346
|
+
if self.plan.role != "confirmation" or self.plan.frozen_at <= self.selected_at:
|
|
347
|
+
raise ValueError("a fresh confirmation plan must follow selection")
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SampledRunState(_SampledStateContract):
|
|
352
|
+
"""Private sampled progress, with administrative history outside frozen execution identities."""
|
|
353
|
+
|
|
354
|
+
run_id: _PrivateRunId
|
|
355
|
+
source_case: Annotated[str, Field(min_length=1, max_length=4096)]
|
|
356
|
+
observation_store: Annotated[str, Field(min_length=1, max_length=4096)]
|
|
357
|
+
started_at: datetime
|
|
358
|
+
original_identity: ObservationIdentity
|
|
359
|
+
original_candidate_digest: _StateDigest
|
|
360
|
+
provider_calls_per_attempt: Annotated[int, Field(ge=1, le=2)]
|
|
361
|
+
approvals: Annotated[tuple[SamplingBudgetApproval, ...], Field(min_length=1, max_length=10_000)]
|
|
362
|
+
calibration_observation_ids: Annotated[tuple[str, ...], Field(max_length=16)] = ()
|
|
363
|
+
blocks: Annotated[tuple[SamplingBlockProgress, ...], Field(max_length=10_000)] = ()
|
|
364
|
+
pending: SamplingDispatchReservation | None = None
|
|
365
|
+
checkpoint: SampledReductionCheckpoint | None = None
|
|
366
|
+
selection: SampledFinalSelection | None = None
|
|
367
|
+
|
|
368
|
+
@field_validator("started_at")
|
|
369
|
+
@classmethod
|
|
370
|
+
def normalize_time(cls, value: datetime) -> datetime:
|
|
371
|
+
return _sampled_time(value)
|
|
372
|
+
|
|
373
|
+
@model_validator(mode="after")
|
|
374
|
+
def validate_history(self) -> SampledRunState:
|
|
375
|
+
if any(item.allowance.run_id != self.run_id for item in self.approvals):
|
|
376
|
+
raise ValueError("budget approvals must belong to this run")
|
|
377
|
+
if len({item.approval_id for item in self.approvals}) != len(self.approvals):
|
|
378
|
+
raise ValueError("budget approval IDs must be unique")
|
|
379
|
+
if self.approvals[0].approved_at > self.started_at:
|
|
380
|
+
raise ValueError("initial allowance must precede run start")
|
|
381
|
+
for earlier, later in zip(self.approvals, self.approvals[1:], strict=False):
|
|
382
|
+
_validate_budget_extension(earlier, later)
|
|
383
|
+
if len({item.plan.block_id for item in self.blocks}) != len(self.blocks):
|
|
384
|
+
raise ValueError("block IDs must be unique")
|
|
385
|
+
previous_time = self.started_at
|
|
386
|
+
for index, progress in enumerate(self.blocks):
|
|
387
|
+
plan = progress.plan
|
|
388
|
+
if plan.original_identity != self.original_identity:
|
|
389
|
+
raise ValueError("block original identity must match the run")
|
|
390
|
+
if plan.original_candidate_digest != self.original_candidate_digest:
|
|
391
|
+
raise ValueError("block original materialization must match the run")
|
|
392
|
+
if plan.provider_calls_per_attempt != self.provider_calls_per_attempt:
|
|
393
|
+
raise ValueError("blocks must preserve the exact run profile call bound")
|
|
394
|
+
if plan.frozen_at < previous_time:
|
|
395
|
+
raise ValueError("blocks must follow run chronology")
|
|
396
|
+
previous_time = plan.frozen_at
|
|
397
|
+
if index + 1 < len(self.blocks) and not progress.archived:
|
|
398
|
+
raise ValueError("only the final block may remain active")
|
|
399
|
+
if self.blocks and plan.reduction != self.blocks[0].plan.reduction:
|
|
400
|
+
raise ValueError("blocks must preserve the frozen reduction plan")
|
|
401
|
+
references = tuple(item for block in self.blocks for item in block.observations)
|
|
402
|
+
observation_ids = (
|
|
403
|
+
*self.calibration_observation_ids,
|
|
404
|
+
*(r.observation_id for r in references),
|
|
405
|
+
)
|
|
406
|
+
if len(set(observation_ids)) != len(observation_ids):
|
|
407
|
+
raise ValueError("observation references must be unique across the run")
|
|
408
|
+
if len({item.attempt_id for item in references}) != len(references):
|
|
409
|
+
raise ValueError("prepared attempts must be unique across blocks")
|
|
410
|
+
if self.pending is not None:
|
|
411
|
+
reservation = self.pending
|
|
412
|
+
if not self.blocks or self.blocks[-1].archived:
|
|
413
|
+
raise ValueError("dispatch requires an active block")
|
|
414
|
+
plan = self.blocks[-1].plan
|
|
415
|
+
if reservation.block_id != plan.block_id or reservation.block_digest != plan.digest:
|
|
416
|
+
raise ValueError("dispatch must reference the exact frozen block")
|
|
417
|
+
if reservation.schedule_position >= len(plan.schedule):
|
|
418
|
+
raise ValueError("dispatch exceeds the frozen schedule")
|
|
419
|
+
if reservation.arm != plan.schedule[reservation.schedule_position]:
|
|
420
|
+
raise ValueError("dispatch arm must match the frozen schedule")
|
|
421
|
+
if reservation.provider_calls != plan.provider_calls_per_attempt:
|
|
422
|
+
raise ValueError("dispatch charge must match the exact profile bound")
|
|
423
|
+
if not plan.frozen_at <= reservation.reserved_at < plan.expires_at:
|
|
424
|
+
raise ValueError("dispatch must be reserved within frozen freshness")
|
|
425
|
+
if reservation.attempt_id in {item.attempt_id for item in references}:
|
|
426
|
+
raise ValueError("dispatch cannot repeat a published prepared attempt")
|
|
427
|
+
if self.selection is not None:
|
|
428
|
+
selection, checkpoint = self.selection, self.checkpoint
|
|
429
|
+
plan = selection.plan
|
|
430
|
+
holdout_id = content_digest({"run_id": self.run_id, "role": "confirmation"})
|
|
431
|
+
if plan.block_id != f"confirmation-{holdout_id}" or plan.schedule_seed != int(
|
|
432
|
+
holdout_id[:16], 16
|
|
433
|
+
):
|
|
434
|
+
raise ValueError("confirmation must use the single frozen run holdout")
|
|
435
|
+
if checkpoint is None or selection.checkpoint_digest != content_digest(
|
|
436
|
+
checkpoint.model_dump(mode="json")
|
|
437
|
+
):
|
|
438
|
+
raise ValueError("selected search checkpoint cannot change")
|
|
439
|
+
if (
|
|
440
|
+
plan.reduction != checkpoint.reduction
|
|
441
|
+
or plan.original_identity != checkpoint.original_identity
|
|
442
|
+
or plan.original_candidate_digest != checkpoint.original_candidate_digest
|
|
443
|
+
or plan.candidate_identity != checkpoint.best_candidate_identity
|
|
444
|
+
or plan.candidate_digest != checkpoint.best_candidate_digest
|
|
445
|
+
):
|
|
446
|
+
raise ValueError("confirmation must bind the selected candidate and original")
|
|
447
|
+
confirmation = tuple(b for b in self.blocks if b.plan.role == "confirmation")
|
|
448
|
+
if len(confirmation) > 1 or (
|
|
449
|
+
confirmation
|
|
450
|
+
and (self.blocks[-1] != confirmation[0] or confirmation[0].plan != plan)
|
|
451
|
+
):
|
|
452
|
+
raise ValueError("selection permits only its single final confirmation block")
|
|
453
|
+
return self
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def allowance(self) -> SamplingBudgetAllowance:
|
|
457
|
+
return self.approvals[-1].allowance
|
|
458
|
+
|
|
459
|
+
def validate_observations(self, store: ObservationStore) -> None:
|
|
460
|
+
"""Resolve immutable references and validate any derived checkpoint against SQLite."""
|
|
461
|
+
|
|
462
|
+
if Path(self.observation_store) != store.path.absolute():
|
|
463
|
+
raise RunStateError("sampled run observation-store identity does not match")
|
|
464
|
+
SampledRunStateFile._validate_authority(self, store)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _sampled_time(value: datetime) -> datetime:
|
|
468
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
469
|
+
raise ValueError("sampled state times must include a UTC offset")
|
|
470
|
+
return value.astimezone(UTC)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _validate_budget_extension(
|
|
474
|
+
earlier: SamplingBudgetApproval, later: SamplingBudgetApproval
|
|
475
|
+
) -> None:
|
|
476
|
+
if later.approved_at < earlier.approved_at:
|
|
477
|
+
raise ValueError("budget approvals must follow chronological order")
|
|
478
|
+
original, extended = earlier.allowance, later.allowance
|
|
479
|
+
limits = ("max_provider_calls", "max_wall_time_seconds", "max_candidate_comparisons")
|
|
480
|
+
if any(getattr(extended, name) < getattr(original, name) for name in limits):
|
|
481
|
+
raise ValueError("administrative budget limits cannot decrease")
|
|
482
|
+
if original.max_tokens is None:
|
|
483
|
+
if extended.max_tokens is not None:
|
|
484
|
+
raise ValueError("an unbounded token allowance cannot become smaller")
|
|
485
|
+
elif extended.max_tokens is not None and extended.max_tokens < original.max_tokens:
|
|
486
|
+
raise ValueError("token allowance cannot decrease")
|
|
487
|
+
fixed = (
|
|
488
|
+
"run_id",
|
|
489
|
+
"confirmation_provider_calls",
|
|
490
|
+
"confirmation_runner_seconds",
|
|
491
|
+
"inter_attempt_delay_seconds",
|
|
492
|
+
"token_accounting",
|
|
493
|
+
)
|
|
494
|
+
if any(getattr(extended, name) != getattr(original, name) for name in fixed):
|
|
495
|
+
raise ValueError("budget extensions must preserve confirmation reserves and accounting")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
499
|
+
class _SampledRunSnapshot:
|
|
500
|
+
"""Private in-memory evidence resolved by one fresh owned state read."""
|
|
501
|
+
|
|
502
|
+
state: SampledRunState
|
|
503
|
+
calibration: tuple[Observation, ...]
|
|
504
|
+
blocks: tuple[SamplingBlockEvidence, ...]
|
|
505
|
+
usage: SampledRunUsage
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class SampledRunStateFile:
|
|
509
|
+
"""Owned, atomic sampled progress; authoritative observations are never replayed."""
|
|
510
|
+
|
|
511
|
+
def __init__(self, path: Path) -> None:
|
|
512
|
+
self.path = path.absolute()
|
|
513
|
+
self._owner: tuple[int, int] | None = None
|
|
514
|
+
self._store_path: Path | None = None
|
|
515
|
+
|
|
516
|
+
@classmethod
|
|
517
|
+
def create(
|
|
518
|
+
cls,
|
|
519
|
+
*,
|
|
520
|
+
case_root: Path,
|
|
521
|
+
run_id: str,
|
|
522
|
+
store: ObservationStore,
|
|
523
|
+
started_at: datetime,
|
|
524
|
+
original_identity: ObservationIdentity,
|
|
525
|
+
original_candidate_digest: str,
|
|
526
|
+
provider_calls_per_attempt: int,
|
|
527
|
+
approval: SamplingBudgetApproval,
|
|
528
|
+
calibration_observation_ids: tuple[str, ...] = (),
|
|
529
|
+
) -> SampledRunStateFile:
|
|
530
|
+
try:
|
|
531
|
+
state = SampledRunState(
|
|
532
|
+
run_id=run_id,
|
|
533
|
+
source_case=str(case_root.absolute()),
|
|
534
|
+
observation_store=str(store.path.absolute()),
|
|
535
|
+
started_at=started_at,
|
|
536
|
+
original_identity=original_identity,
|
|
537
|
+
original_candidate_digest=original_candidate_digest,
|
|
538
|
+
provider_calls_per_attempt=provider_calls_per_attempt,
|
|
539
|
+
approvals=(approval,),
|
|
540
|
+
calibration_observation_ids=calibration_observation_ids,
|
|
541
|
+
)
|
|
542
|
+
except ValidationError as error:
|
|
543
|
+
raise RunStateError("invalid sampled run initialization") from error
|
|
544
|
+
runs_root = case_root / ".runs"
|
|
545
|
+
_reject_sampled_path(runs_root)
|
|
546
|
+
runs_root.mkdir(mode=0o700, exist_ok=True)
|
|
547
|
+
_private_directory(runs_root)
|
|
548
|
+
root = runs_root / state.run_id
|
|
549
|
+
root.mkdir(mode=0o700, exist_ok=False)
|
|
550
|
+
state_file = cls(root / "sampled-state.json")
|
|
551
|
+
with state_file.acquire_lock(store):
|
|
552
|
+
state_file._validate_authority(state, store)
|
|
553
|
+
state_file._write(state)
|
|
554
|
+
return state_file
|
|
555
|
+
|
|
556
|
+
@classmethod
|
|
557
|
+
def open(cls, source: Path) -> SampledRunStateFile:
|
|
558
|
+
_reject_sampled_path(source)
|
|
559
|
+
return cls(source / "sampled-state.json" if source.is_dir() else source)
|
|
560
|
+
|
|
561
|
+
@contextmanager
|
|
562
|
+
def acquire_lock(self, store: ObservationStore) -> Generator[None]:
|
|
563
|
+
"""Own this run and sampled store from reconciliation through durable publication."""
|
|
564
|
+
|
|
565
|
+
if self._owner is not None:
|
|
566
|
+
raise RunStateBusyError("sampled run ownership is already active")
|
|
567
|
+
_reject_sampled_path(self.path.parent)
|
|
568
|
+
_private_directory(self.path.parent)
|
|
569
|
+
lock_path = self.path.with_name("run.lock")
|
|
570
|
+
_reject_sampled_path(lock_path)
|
|
571
|
+
try:
|
|
572
|
+
lock = private_file_lock(lock_path)
|
|
573
|
+
except UnsafePathError as error:
|
|
574
|
+
raise RunStateError("sampled run lock file is unsafe") from error
|
|
575
|
+
try:
|
|
576
|
+
lock.acquire()
|
|
577
|
+
except Timeout as error:
|
|
578
|
+
raise RunStateBusyError("another process owns sampled run progress") from error
|
|
579
|
+
try:
|
|
580
|
+
with store.acquire_sampling_lock():
|
|
581
|
+
self._owner = (os.getpid(), threading.get_ident())
|
|
582
|
+
self._store_path = store.path.absolute()
|
|
583
|
+
try:
|
|
584
|
+
yield
|
|
585
|
+
finally:
|
|
586
|
+
self._owner = None
|
|
587
|
+
self._store_path = None
|
|
588
|
+
finally:
|
|
589
|
+
lock.release()
|
|
590
|
+
|
|
591
|
+
def _require_lock(self, store: ObservationStore) -> None:
|
|
592
|
+
if (
|
|
593
|
+
self._owner != (os.getpid(), threading.get_ident())
|
|
594
|
+
or self._store_path != store.path.absolute()
|
|
595
|
+
):
|
|
596
|
+
raise RunStateError("sampled progress requires owned run and observation-store locks")
|
|
597
|
+
|
|
598
|
+
def load(self, store: ObservationStore) -> SampledRunState:
|
|
599
|
+
"""Read bounded private state and cross-check every cached fact against SQLite."""
|
|
600
|
+
|
|
601
|
+
state, _, _ = self._read_validated(store)
|
|
602
|
+
return state
|
|
603
|
+
|
|
604
|
+
def _read_validated(
|
|
605
|
+
self, store: ObservationStore
|
|
606
|
+
) -> tuple[SampledRunState, tuple[Observation, ...], tuple[SamplingBlockEvidence, ...]]:
|
|
607
|
+
self._require_lock(store)
|
|
608
|
+
_reject_sampled_path(self.path)
|
|
609
|
+
_private_directory(self.path.parent)
|
|
610
|
+
_private_regular_file(self.path)
|
|
611
|
+
try:
|
|
612
|
+
raw = read_safe_bytes(
|
|
613
|
+
self.path, max_bytes=_MAX_RUN_STATE_BYTES, label="sampled run state"
|
|
614
|
+
)
|
|
615
|
+
_private_regular_file(self.path)
|
|
616
|
+
# Validate JSON directly to preserve strict tuple/datetime JSON decoding.
|
|
617
|
+
json.loads(raw, object_pairs_hook=_sampled_unique_keys)
|
|
618
|
+
state = SampledRunState.model_validate_json(raw)
|
|
619
|
+
except (
|
|
620
|
+
OSError,
|
|
621
|
+
UnicodeError,
|
|
622
|
+
json.JSONDecodeError,
|
|
623
|
+
RecursionError,
|
|
624
|
+
ValidationError,
|
|
625
|
+
CaseError,
|
|
626
|
+
) as error:
|
|
627
|
+
raise RunStateError("invalid or unavailable sampled run state") from error
|
|
628
|
+
if Path(state.observation_store) != store.path.absolute():
|
|
629
|
+
raise RunStateError("sampled run observation-store identity does not match")
|
|
630
|
+
calibration, blocks = self._validate_authority(state, store)
|
|
631
|
+
return state, calibration, blocks
|
|
632
|
+
|
|
633
|
+
def evidence(self, store: ObservationStore) -> tuple[SamplingBlockEvidence, ...]:
|
|
634
|
+
_, _, blocks = self._read_validated(store)
|
|
635
|
+
return blocks
|
|
636
|
+
|
|
637
|
+
def snapshot(self, store: ObservationStore, *, now: datetime) -> _SampledRunSnapshot:
|
|
638
|
+
"""Resolve fresh authority and its accounting once under the owned locks."""
|
|
639
|
+
|
|
640
|
+
state, calibration, blocks = self._read_validated(store)
|
|
641
|
+
return _SampledRunSnapshot(
|
|
642
|
+
state=state,
|
|
643
|
+
calibration=calibration,
|
|
644
|
+
blocks=blocks,
|
|
645
|
+
usage=self._usage(state, calibration, blocks, now=now),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
@staticmethod
|
|
649
|
+
def _hydrate(progress: SamplingBlockProgress, store: ObservationStore) -> SamplingBlockEvidence:
|
|
650
|
+
attempts: list[SamplingAttempt] = []
|
|
651
|
+
try:
|
|
652
|
+
resolved = store.get_many(item.observation_id for item in progress.observations)
|
|
653
|
+
except (ObservationStoreError, ValueError) as error:
|
|
654
|
+
raise RunStateError(
|
|
655
|
+
"sampled progress references unavailable observation evidence"
|
|
656
|
+
) from error
|
|
657
|
+
for reference, observation in zip(progress.observations, resolved, strict=True):
|
|
658
|
+
if (
|
|
659
|
+
observation.attempt_evidence.attempt_id != reference.attempt_id
|
|
660
|
+
or observation.attempt_evidence.attempt_digest != reference.attempt_digest
|
|
661
|
+
):
|
|
662
|
+
raise RunStateError("sampled progress prepared-attempt identity does not match")
|
|
663
|
+
attempts.append(
|
|
664
|
+
SamplingAttempt(
|
|
665
|
+
schedule_position=reference.schedule_position,
|
|
666
|
+
observation=observation,
|
|
667
|
+
independence_evidence_digest=reference.independence_evidence_digest,
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
try:
|
|
671
|
+
return SamplingBlockEvidence(plan=progress.plan, attempts=tuple(attempts))
|
|
672
|
+
except ValidationError as error:
|
|
673
|
+
raise RunStateError("sampled progress has inconsistent block membership") from error
|
|
674
|
+
|
|
675
|
+
@staticmethod
|
|
676
|
+
def _validate_authority(
|
|
677
|
+
state: SampledRunState, store: ObservationStore
|
|
678
|
+
) -> tuple[tuple[Observation, ...], tuple[SamplingBlockEvidence, ...]]:
|
|
679
|
+
observations = SampledRunStateFile._calibration(state, store)
|
|
680
|
+
blocks = tuple(SampledRunStateFile._hydrate(progress, store) for progress in state.blocks)
|
|
681
|
+
attempt_ids = [item.attempt_evidence.attempt_id for item in observations]
|
|
682
|
+
attempt_ids.extend(attempt_id for block in blocks for attempt_id in block.attempt_ids)
|
|
683
|
+
if len(set(attempt_ids)) != len(attempt_ids):
|
|
684
|
+
raise RunStateError("sampled run contains reused prepared attempts")
|
|
685
|
+
if state.pending is not None and state.pending.attempt_id in attempt_ids:
|
|
686
|
+
raise RunStateError("pending dispatch duplicates a published attempt")
|
|
687
|
+
if state.pending is not None and (
|
|
688
|
+
state.pending.schedule_position != SampledRunStateFile._next_position(blocks[-1])
|
|
689
|
+
):
|
|
690
|
+
raise RunStateError("pending dispatch does not follow the unfinished frozen schedule")
|
|
691
|
+
checkpoint = state.checkpoint
|
|
692
|
+
if checkpoint is not None:
|
|
693
|
+
by_digest = {block.plan.digest: block for block in blocks}
|
|
694
|
+
if (
|
|
695
|
+
checkpoint.original_identity != state.original_identity
|
|
696
|
+
or checkpoint.original_candidate_digest != state.original_candidate_digest
|
|
697
|
+
or (state.blocks and checkpoint.reduction != state.blocks[0].plan.reduction)
|
|
698
|
+
or any(by_digest.get(block.plan.digest) != block for block in checkpoint.blocks)
|
|
699
|
+
):
|
|
700
|
+
raise RunStateError("sampled checkpoint differs from authoritative block progress")
|
|
701
|
+
if state.selection is not None:
|
|
702
|
+
selection = state.selection
|
|
703
|
+
search = tuple(block for block in blocks if block.plan.role == "search")
|
|
704
|
+
if checkpoint is None or checkpoint.blocks != search:
|
|
705
|
+
raise RunStateError(
|
|
706
|
+
"selected checkpoint must include all authoritative search evidence"
|
|
707
|
+
)
|
|
708
|
+
if any(
|
|
709
|
+
selection.selected_at < block.plan.frozen_at
|
|
710
|
+
or any(selection.selected_at < a.observation.recorded_at for a in block.attempts)
|
|
711
|
+
for block in search
|
|
712
|
+
) or any(selection.selected_at < o.recorded_at for o in observations):
|
|
713
|
+
raise RunStateError("selection must follow calibration and search evidence")
|
|
714
|
+
return observations, blocks
|
|
715
|
+
|
|
716
|
+
@staticmethod
|
|
717
|
+
def _calibration(state: SampledRunState, store: ObservationStore) -> tuple[Observation, ...]:
|
|
718
|
+
observations: list[Observation] = []
|
|
719
|
+
try:
|
|
720
|
+
resolved = store.get_many(state.calibration_observation_ids)
|
|
721
|
+
except (ObservationStoreError, ValueError) as error:
|
|
722
|
+
raise RunStateError("calibration accounting references unavailable evidence") from error
|
|
723
|
+
for observation in resolved:
|
|
724
|
+
if (
|
|
725
|
+
observation.role is not ObservationRole.CALIBRATION
|
|
726
|
+
or observation.identity != state.original_identity
|
|
727
|
+
or observation.candidate_digest != state.original_candidate_digest
|
|
728
|
+
or observation.arm is not ObservationArm.BASELINE
|
|
729
|
+
):
|
|
730
|
+
raise RunStateError("calibration accounting does not match original run identity")
|
|
731
|
+
observations.append(observation)
|
|
732
|
+
return tuple(observations)
|
|
733
|
+
|
|
734
|
+
def usage(self, store: ObservationStore, *, now: datetime) -> SampledRunUsage:
|
|
735
|
+
state, calibration, blocks = self._read_validated(store)
|
|
736
|
+
return self._usage(state, calibration, blocks, now=now)
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _usage(
|
|
740
|
+
state: SampledRunState,
|
|
741
|
+
calibration: tuple[Observation, ...],
|
|
742
|
+
blocks: tuple[SamplingBlockEvidence, ...],
|
|
743
|
+
*,
|
|
744
|
+
now: datetime,
|
|
745
|
+
) -> SampledRunUsage:
|
|
746
|
+
now = _sampled_time(now)
|
|
747
|
+
if now < state.started_at:
|
|
748
|
+
raise RunStateError("run clock precedes its recorded start")
|
|
749
|
+
observations = list(calibration)
|
|
750
|
+
observations.extend(attempt.observation for block in blocks for attempt in block.attempts)
|
|
751
|
+
actual = possible = missing_provider = runner = missing_runner = tokens = missing_tokens = 0
|
|
752
|
+
for observation in observations:
|
|
753
|
+
evidence = observation.attempt_evidence
|
|
754
|
+
accounting = evidence.accounting
|
|
755
|
+
known = (
|
|
756
|
+
accounting.provider_calls_started
|
|
757
|
+
if accounting is not None
|
|
758
|
+
else int(evidence.provider_call_started)
|
|
759
|
+
)
|
|
760
|
+
actual += known
|
|
761
|
+
upper = max(
|
|
762
|
+
known,
|
|
763
|
+
evidence.provider_calls_possible,
|
|
764
|
+
state.provider_calls_per_attempt if evidence.provider_call_started else 0,
|
|
765
|
+
)
|
|
766
|
+
possible += upper - known
|
|
767
|
+
if (
|
|
768
|
+
accounting is None
|
|
769
|
+
and evidence.provider_acceptance_state is not ProviderAcceptanceState.NOT_STARTED
|
|
770
|
+
):
|
|
771
|
+
missing_provider += 1
|
|
772
|
+
if accounting is None:
|
|
773
|
+
missing_runner += 1
|
|
774
|
+
else:
|
|
775
|
+
runner += accounting.elapsed_milliseconds
|
|
776
|
+
if evidence.usage is not None:
|
|
777
|
+
tokens += evidence.usage.input_tokens + evidence.usage.output_tokens
|
|
778
|
+
elif evidence.provider_acceptance_state is not ProviderAcceptanceState.NOT_STARTED:
|
|
779
|
+
missing_tokens += 1
|
|
780
|
+
pending = state.pending
|
|
781
|
+
return SampledRunUsage(
|
|
782
|
+
actual_provider_calls=actual,
|
|
783
|
+
possible_provider_calls=possible,
|
|
784
|
+
unknown_provider_attempts=missing_provider,
|
|
785
|
+
pending_provider_calls=0 if pending is None else pending.provider_calls,
|
|
786
|
+
runner_milliseconds=runner,
|
|
787
|
+
unknown_runner_attempts=missing_runner,
|
|
788
|
+
known_tokens=tokens,
|
|
789
|
+
unknown_token_attempts=missing_tokens,
|
|
790
|
+
wall_time_seconds=(now - state.started_at).total_seconds(),
|
|
791
|
+
pending_runner_seconds=0 if pending is None else pending.runner_seconds,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
def admit_block(self, store: ObservationStore, plan: SamplingBlockPlan) -> SampledRunState:
|
|
795
|
+
"""Record an externally admitted plan; whole-block budget scheduling is separate."""
|
|
796
|
+
|
|
797
|
+
current = self.load(store)
|
|
798
|
+
if current.pending is not None or (current.blocks and not current.blocks[-1].archived):
|
|
799
|
+
raise RunStateError("previous sampled block must be reconciled and archived")
|
|
800
|
+
return self._publish(
|
|
801
|
+
current, store, blocks=(*current.blocks, SamplingBlockProgress(plan=plan))
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def continue_block(
|
|
805
|
+
self, store: ObservationStore, *, expected_plan_digest: str, now: datetime
|
|
806
|
+
) -> SamplingBlockEvidence:
|
|
807
|
+
"""Prove exact partial-block continuation; never clear or repeat an unresolved dispatch."""
|
|
808
|
+
|
|
809
|
+
current = self.load(store)
|
|
810
|
+
if current.pending is not None:
|
|
811
|
+
raise RunStateError("pending dispatch must be reconciled before continuation")
|
|
812
|
+
if not current.blocks or current.blocks[-1].archived:
|
|
813
|
+
raise RunStateError("no active sampled block is available")
|
|
814
|
+
block = self._hydrate(current.blocks[-1], store)
|
|
815
|
+
if block.plan.digest != expected_plan_digest:
|
|
816
|
+
raise RunStateError("continuation requires the exact frozen block identity")
|
|
817
|
+
now = _sampled_time(now)
|
|
818
|
+
if not block.plan.frozen_at <= now < block.plan.expires_at:
|
|
819
|
+
raise RunStateError("sampled block freshness has expired")
|
|
820
|
+
if block.attempts and now < block.attempts[-1].observation.recorded_at:
|
|
821
|
+
raise RunStateError("continuation clock precedes recorded sampling progress")
|
|
822
|
+
if block.complete or not block.eligible:
|
|
823
|
+
raise RunStateError("sampled block does not permit incomplete continuation")
|
|
824
|
+
usage = self.usage(store, now=now)
|
|
825
|
+
if usage.wall_time_seconds >= current.allowance.max_wall_time_seconds:
|
|
826
|
+
raise RunStateError("sampled run wall-time allowance is exhausted")
|
|
827
|
+
return block
|
|
828
|
+
|
|
829
|
+
def reserve_dispatch(
|
|
830
|
+
self, store: ObservationStore, reservation: SamplingDispatchReservation
|
|
831
|
+
) -> SampledRunState:
|
|
832
|
+
block = self.continue_block(
|
|
833
|
+
store, expected_plan_digest=reservation.block_digest, now=reservation.reserved_at
|
|
834
|
+
)
|
|
835
|
+
position = self._next_position(block)
|
|
836
|
+
if reservation.schedule_position != position:
|
|
837
|
+
raise RunStateError("dispatch must follow the frozen unfinished schedule")
|
|
838
|
+
plan = block.plan
|
|
839
|
+
identity, digest = _sampling_arm_identity(plan, reservation.arm)
|
|
840
|
+
previous = store.list_compatible(
|
|
841
|
+
identity,
|
|
842
|
+
candidate_digest=digest,
|
|
843
|
+
role=ObservationRole(plan.role),
|
|
844
|
+
arm=ObservationArm(reservation.arm),
|
|
845
|
+
)
|
|
846
|
+
expected_attempt = 1 + max((item.attempt for item in previous), default=0)
|
|
847
|
+
if reservation.attempt != expected_attempt:
|
|
848
|
+
raise RunStateError("dispatch must pin the next authoritative arm-local attempt")
|
|
849
|
+
current = self.load(store)
|
|
850
|
+
usage = self.usage(store, now=reservation.reserved_at)
|
|
851
|
+
if (
|
|
852
|
+
usage.charged_provider_calls + reservation.provider_calls
|
|
853
|
+
> current.allowance.max_provider_calls
|
|
854
|
+
):
|
|
855
|
+
raise RunStateError("dispatch exceeds the remaining provider-call allowance")
|
|
856
|
+
return self._publish(current, store, pending=reservation)
|
|
857
|
+
|
|
858
|
+
@staticmethod
|
|
859
|
+
def _next_position(block: SamplingBlockEvidence) -> int:
|
|
860
|
+
position = block.next_schedule_position
|
|
861
|
+
if position is None:
|
|
862
|
+
raise RunStateError("frozen sampling schedule is exhausted")
|
|
863
|
+
return position
|
|
864
|
+
|
|
865
|
+
def append_observation(
|
|
866
|
+
self,
|
|
867
|
+
store: ObservationStore,
|
|
868
|
+
observation: Observation,
|
|
869
|
+
*,
|
|
870
|
+
independence_evidence_digest: str | None = None,
|
|
871
|
+
) -> SampledRunState:
|
|
872
|
+
"""Validate the pinned dispatch, append once, then atomically publish its reference."""
|
|
873
|
+
|
|
874
|
+
current = self.load(store)
|
|
875
|
+
reference = self._reserved_reference(current, observation, independence_evidence_digest)
|
|
876
|
+
updated = self._with_reference(current, reference)
|
|
877
|
+
# Membership must validate before appending immutable evidence. Missing new record is
|
|
878
|
+
# supplied locally only for this check; cached checkpoint is invalidated by progress.
|
|
879
|
+
progress = updated.blocks[-1]
|
|
880
|
+
earlier = self._hydrate(current.blocks[-1], store)
|
|
881
|
+
try:
|
|
882
|
+
SamplingBlockEvidence(
|
|
883
|
+
plan=progress.plan,
|
|
884
|
+
attempts=(
|
|
885
|
+
*earlier.attempts,
|
|
886
|
+
SamplingAttempt(
|
|
887
|
+
schedule_position=reference.schedule_position,
|
|
888
|
+
observation=observation,
|
|
889
|
+
independence_evidence_digest=reference.independence_evidence_digest,
|
|
890
|
+
),
|
|
891
|
+
),
|
|
892
|
+
)
|
|
893
|
+
except ValidationError as error:
|
|
894
|
+
raise RunStateError("reserved observation is not valid frozen membership") from error
|
|
895
|
+
store.append(observation)
|
|
896
|
+
updated = self._refresh_checkpoint(updated, store)
|
|
897
|
+
self._validate_authority(updated, store)
|
|
898
|
+
self._write(updated)
|
|
899
|
+
return updated
|
|
900
|
+
|
|
901
|
+
def reconcile(
|
|
902
|
+
self, store: ObservationStore, *, independence_evidence_digest: str | None = None
|
|
903
|
+
) -> SampledRunState:
|
|
904
|
+
"""Publish an already appended exact dispatch, without performing another attempt."""
|
|
905
|
+
|
|
906
|
+
current = self.load(store)
|
|
907
|
+
reservation = current.pending
|
|
908
|
+
if reservation is None:
|
|
909
|
+
return current
|
|
910
|
+
plan = current.blocks[-1].plan
|
|
911
|
+
identity, digest = _sampling_arm_identity(plan, reservation.arm)
|
|
912
|
+
observation = store.get_attempt(
|
|
913
|
+
identity,
|
|
914
|
+
candidate_digest=digest,
|
|
915
|
+
role=ObservationRole(plan.role),
|
|
916
|
+
arm=ObservationArm(reservation.arm),
|
|
917
|
+
attempt=reservation.attempt,
|
|
918
|
+
)
|
|
919
|
+
if observation is None:
|
|
920
|
+
raise RunStateError(
|
|
921
|
+
"pending dispatch remains unresolved; automatic replay is forbidden"
|
|
922
|
+
)
|
|
923
|
+
reference = self._reserved_reference(current, observation, independence_evidence_digest)
|
|
924
|
+
updated = self._with_reference(current, reference)
|
|
925
|
+
updated = self._refresh_checkpoint(updated, store)
|
|
926
|
+
self._validate_authority(updated, store)
|
|
927
|
+
self._write(updated)
|
|
928
|
+
return updated
|
|
929
|
+
|
|
930
|
+
@staticmethod
|
|
931
|
+
def _reserved_reference(
|
|
932
|
+
current: SampledRunState, observation: Observation, independence: str | None
|
|
933
|
+
) -> SamplingObservationReference:
|
|
934
|
+
reservation = current.pending
|
|
935
|
+
if reservation is None:
|
|
936
|
+
raise RunStateError("observation publication requires a persisted dispatch reservation")
|
|
937
|
+
plan = current.blocks[-1].plan
|
|
938
|
+
identity, digest = _sampling_arm_identity(plan, reservation.arm)
|
|
939
|
+
if (
|
|
940
|
+
observation.identity != identity
|
|
941
|
+
or observation.candidate_digest != digest
|
|
942
|
+
or observation.role is not ObservationRole(plan.role)
|
|
943
|
+
or observation.arm is not ObservationArm(reservation.arm)
|
|
944
|
+
or observation.attempt != reservation.attempt
|
|
945
|
+
or observation.attempt_evidence.attempt_id != reservation.attempt_id
|
|
946
|
+
or observation.attempt_evidence.attempt_digest != reservation.attempt_digest
|
|
947
|
+
or observation.recorded_at < reservation.reserved_at
|
|
948
|
+
):
|
|
949
|
+
raise RunStateError("observation does not match the exact reserved prepared attempt")
|
|
950
|
+
try:
|
|
951
|
+
return SamplingObservationReference(
|
|
952
|
+
observation_id=observation.observation_id,
|
|
953
|
+
attempt_id=reservation.attempt_id,
|
|
954
|
+
attempt_digest=reservation.attempt_digest,
|
|
955
|
+
schedule_position=reservation.schedule_position,
|
|
956
|
+
independence_evidence_digest=independence,
|
|
957
|
+
)
|
|
958
|
+
except ValidationError as error:
|
|
959
|
+
raise RunStateError("invalid sampling observation reference") from error
|
|
960
|
+
|
|
961
|
+
@staticmethod
|
|
962
|
+
def _with_reference(
|
|
963
|
+
current: SampledRunState, reference: SamplingObservationReference
|
|
964
|
+
) -> SampledRunState:
|
|
965
|
+
progress = current.blocks[-1]
|
|
966
|
+
updated = SamplingBlockProgress(
|
|
967
|
+
plan=progress.plan, observations=(*progress.observations, reference)
|
|
968
|
+
)
|
|
969
|
+
return _sampled_replace(current, blocks=(*current.blocks[:-1], updated), pending=None)
|
|
970
|
+
|
|
971
|
+
def _refresh_checkpoint(
|
|
972
|
+
self, state: SampledRunState, store: ObservationStore
|
|
973
|
+
) -> SampledRunState:
|
|
974
|
+
checkpoint = state.checkpoint
|
|
975
|
+
if checkpoint is None or checkpoint.active_block is None:
|
|
976
|
+
return state
|
|
977
|
+
progress = state.blocks[-1]
|
|
978
|
+
if checkpoint.active_block.plan.digest != progress.plan.digest:
|
|
979
|
+
return state
|
|
980
|
+
evidence = self._hydrate(progress, store)
|
|
981
|
+
# Search classification belongs to the reducer. A completed block remains durable in
|
|
982
|
+
# progress while the cached checkpoint retains its previously selected trials.
|
|
983
|
+
checkpoint = SampledReductionCheckpoint.model_validate(
|
|
984
|
+
{**checkpoint.model_dump(), "active_block": None if evidence.complete else evidence}
|
|
985
|
+
)
|
|
986
|
+
return _sampled_replace(state, checkpoint=checkpoint)
|
|
987
|
+
|
|
988
|
+
def archive_block(self, store: ObservationStore) -> SampledRunState:
|
|
989
|
+
current = self.load(store)
|
|
990
|
+
if current.pending is not None or not current.blocks or current.blocks[-1].archived:
|
|
991
|
+
raise RunStateError("only a reconciled active block can be archived")
|
|
992
|
+
block = current.blocks[-1]
|
|
993
|
+
archived = SamplingBlockProgress(
|
|
994
|
+
plan=block.plan, observations=block.observations, archived=True
|
|
995
|
+
)
|
|
996
|
+
return self._publish(current, store, blocks=(*current.blocks[:-1], archived))
|
|
997
|
+
|
|
998
|
+
def record_calibration(
|
|
999
|
+
self, store: ObservationStore, observation_ids: tuple[str, ...]
|
|
1000
|
+
) -> SampledRunState:
|
|
1001
|
+
"""Attach the exact calibration prefix, charging compatible prior observations too.
|
|
1002
|
+
|
|
1003
|
+
Calibration may precede the administrative run. It never supplies observations
|
|
1004
|
+
to a search or confirmation arm. Once search starts, its references are frozen.
|
|
1005
|
+
"""
|
|
1006
|
+
|
|
1007
|
+
current = self.load(store)
|
|
1008
|
+
previous = current.calibration_observation_ids
|
|
1009
|
+
if observation_ids[: len(previous)] != previous:
|
|
1010
|
+
raise RunStateError("calibration continuation must preserve its recorded prefix")
|
|
1011
|
+
if current.checkpoint is not None or current.blocks or current.selection is not None:
|
|
1012
|
+
if observation_ids != previous:
|
|
1013
|
+
raise RunStateError("calibration references cannot change after search starts")
|
|
1014
|
+
return current
|
|
1015
|
+
return self._publish(current, store, calibration_observation_ids=observation_ids)
|
|
1016
|
+
|
|
1017
|
+
def extend_budget(
|
|
1018
|
+
self, store: ObservationStore, approval: SamplingBudgetApproval
|
|
1019
|
+
) -> SampledRunState:
|
|
1020
|
+
current = self.load(store)
|
|
1021
|
+
return self._publish(current, store, approvals=(*current.approvals, approval))
|
|
1022
|
+
|
|
1023
|
+
def save_checkpoint(
|
|
1024
|
+
self, store: ObservationStore, checkpoint: SampledReductionCheckpoint
|
|
1025
|
+
) -> SampledRunState:
|
|
1026
|
+
current = self.load(store)
|
|
1027
|
+
if current.pending is not None:
|
|
1028
|
+
raise RunStateError("checkpoint publication requires dispatch reconciliation")
|
|
1029
|
+
return self._publish(current, store, checkpoint=checkpoint)
|
|
1030
|
+
|
|
1031
|
+
def select_candidate(
|
|
1032
|
+
self, store: ObservationStore, selection: SampledFinalSelection
|
|
1033
|
+
) -> SampledRunState:
|
|
1034
|
+
"""Freeze selection and close search atomically, including a safe partial last block."""
|
|
1035
|
+
|
|
1036
|
+
current = self.load(store)
|
|
1037
|
+
if current.selection is not None or current.pending is not None:
|
|
1038
|
+
raise RunStateError("selection requires reconciled search and cannot be repeated")
|
|
1039
|
+
if any(b.plan.role != "search" for b in current.blocks):
|
|
1040
|
+
raise RunStateError("selection must precede every confirmation dispatch")
|
|
1041
|
+
blocks = current.blocks
|
|
1042
|
+
if blocks and not blocks[-1].archived:
|
|
1043
|
+
blocks = (*blocks[:-1], blocks[-1].model_copy(update={"archived": True}))
|
|
1044
|
+
return self._publish(current, store, blocks=blocks, selection=selection)
|
|
1045
|
+
|
|
1046
|
+
def _publish(
|
|
1047
|
+
self, current: SampledRunState, store: ObservationStore, **changes: object
|
|
1048
|
+
) -> SampledRunState:
|
|
1049
|
+
updated = _sampled_replace(current, **changes)
|
|
1050
|
+
self._validate_authority(updated, store)
|
|
1051
|
+
self._write(updated)
|
|
1052
|
+
return updated
|
|
1053
|
+
|
|
1054
|
+
def _write(self, state: SampledRunState) -> None:
|
|
1055
|
+
if self._owner != (os.getpid(), threading.get_ident()):
|
|
1056
|
+
raise RunStateError("sampled state publication requires owned locks")
|
|
1057
|
+
_reject_sampled_path(self.path)
|
|
1058
|
+
_private_directory(self.path.parent)
|
|
1059
|
+
if os.path.lexists(self.path):
|
|
1060
|
+
_private_regular_file(self.path)
|
|
1061
|
+
try:
|
|
1062
|
+
state = SampledRunState.model_validate_json(state.model_dump_json())
|
|
1063
|
+
except ValidationError as error:
|
|
1064
|
+
raise RunStateError("invalid sampled state publication") from error
|
|
1065
|
+
rendered = state.model_dump_json(indent=2).encode("utf-8") + b"\n"
|
|
1066
|
+
if len(rendered) > _MAX_RUN_STATE_BYTES:
|
|
1067
|
+
raise RunStateError("sampled run state exceeds its byte limit")
|
|
1068
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1069
|
+
prefix=".sampled-state-", dir=self.path.parent
|
|
1070
|
+
)
|
|
1071
|
+
temporary = Path(temporary_name)
|
|
1072
|
+
try:
|
|
1073
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
1074
|
+
stream.write(rendered)
|
|
1075
|
+
stream.flush()
|
|
1076
|
+
os.fsync(stream.fileno())
|
|
1077
|
+
temporary.replace(self.path)
|
|
1078
|
+
_sync_directory(self.path.parent)
|
|
1079
|
+
finally:
|
|
1080
|
+
temporary.unlink(missing_ok=True)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _sampled_replace(state: SampledRunState, **changes: object) -> SampledRunState:
|
|
1084
|
+
try:
|
|
1085
|
+
return SampledRunState.model_validate({**state.model_dump(), **changes})
|
|
1086
|
+
except ValidationError as error:
|
|
1087
|
+
raise RunStateError("invalid sampled progress transition") from error
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _sampling_arm_identity(
|
|
1091
|
+
plan: SamplingBlockPlan, arm: SamplingArm
|
|
1092
|
+
) -> tuple[ObservationIdentity, str]:
|
|
1093
|
+
if arm == "baseline":
|
|
1094
|
+
return plan.original_identity, plan.original_candidate_digest
|
|
1095
|
+
return plan.candidate_identity, plan.candidate_digest
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _sampled_unique_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
1099
|
+
result: dict[str, object] = {}
|
|
1100
|
+
for key, value in pairs:
|
|
1101
|
+
if key in result:
|
|
1102
|
+
raise RunStateError("sampled run state contains duplicate JSON keys")
|
|
1103
|
+
result[key] = value
|
|
1104
|
+
return result
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _reject_sampled_path(path: Path) -> None:
|
|
1108
|
+
try:
|
|
1109
|
+
_reject_unsafe_path(path)
|
|
1110
|
+
except RunStateError as error:
|
|
1111
|
+
raise RunStateError("sampled run-state path is unsafe") from error
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _private_directory(path: Path) -> None:
|
|
1115
|
+
try:
|
|
1116
|
+
metadata = path.lstat()
|
|
1117
|
+
except OSError as error:
|
|
1118
|
+
raise RunStateError("sampled run-state directory is unavailable") from error
|
|
1119
|
+
if not stat.S_ISDIR(metadata.st_mode) or (
|
|
1120
|
+
os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o700
|
|
1121
|
+
):
|
|
1122
|
+
raise RunStateError("sampled run-state directory must be private and regular")
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _private_regular_file(path: Path) -> None:
|
|
1126
|
+
try:
|
|
1127
|
+
metadata = path.lstat()
|
|
1128
|
+
except OSError as error:
|
|
1129
|
+
raise RunStateError("sampled run-state file is unavailable") from error
|
|
1130
|
+
if (
|
|
1131
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1132
|
+
or metadata.st_nlink != 1
|
|
1133
|
+
or (os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o600)
|
|
1134
|
+
):
|
|
1135
|
+
raise RunStateError("sampled run-state file must be regular with mode 0600")
|