pollard-jev 0.1__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.
@@ -0,0 +1,3 @@
1
+ """Typed decisions with Pollard execution governance. Simulation only."""
2
+
3
+ __version__ = "0.1"
@@ -0,0 +1,3 @@
1
+ from .demo import main
2
+
3
+ main()
@@ -0,0 +1,223 @@
1
+ """Versioned JSON contracts. Scores describe evidence, never physical success."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import (
9
+ AwareDatetime, BaseModel, ConfigDict, Field, StrictBool, StrictInt,
10
+ StrictStr, field_validator, model_validator,
11
+ )
12
+
13
+ Name = Annotated[str, Field(min_length=1, max_length=200)]
14
+ Number = Annotated[float, Field(strict=True, allow_inf_nan=False)]
15
+ Score = Annotated[float, Field(strict=True, ge=0, le=1, allow_inf_nan=False)]
16
+
17
+
18
+ class Contract(BaseModel):
19
+ model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False)
20
+
21
+
22
+ class Observation(Contract):
23
+ observation_id: Name
24
+ source: Name
25
+ feature: Name
26
+ value: Number | None
27
+ unit: Name
28
+ observed_at: AwareDatetime
29
+ valid_until: AwareDatetime
30
+ status: Literal["known", "unknown"] = "known"
31
+
32
+ @model_validator(mode="after")
33
+ def valid_observation(self):
34
+ if self.valid_until <= self.observed_at:
35
+ raise ValueError("valid_until must follow observed_at")
36
+ if (self.status == "known") != (self.value is not None):
37
+ raise ValueError("known observations require a value; unknown ones require None")
38
+ return self
39
+
40
+
41
+ class ActionChoice(Contract):
42
+ name: Name
43
+ hypothesis: Annotated[str, Field(min_length=1, max_length=4000)]
44
+ parameters: dict[str, StrictInt] = Field(default_factory=dict)
45
+
46
+
47
+ class DecisionRequest(Contract):
48
+ request_id: Name
49
+ question: Annotated[str, Field(min_length=1, max_length=8000)]
50
+ created_at: AwareDatetime
51
+ valid_until: AwareDatetime
52
+ observations: tuple[Observation, ...]
53
+ choices: Annotated[tuple[ActionChoice, ...], Field(min_length=1)]
54
+
55
+ @model_validator(mode="after")
56
+ def valid_request(self):
57
+ if self.valid_until <= self.created_at:
58
+ raise ValueError("request validity must be positive")
59
+ names = [choice.name for choice in self.choices]
60
+ ids = [obs.observation_id for obs in self.observations]
61
+ if len(names) != len(set(names)) or len(ids) != len(set(ids)):
62
+ raise ValueError("choice names and observation IDs must be unique")
63
+ return self
64
+
65
+
66
+ class ProviderIdentity(Contract):
67
+ provider: Name
68
+ provider_version: Name
69
+ model: Name
70
+ model_version: Name
71
+ synthetic: StrictBool
72
+ settings: dict[str, Number | StrictStr | StrictBool] = Field(default_factory=dict)
73
+
74
+
75
+ class NLIScores(Contract):
76
+ contradiction: Score
77
+ entailment: Score
78
+ neutral: Score
79
+
80
+ @model_validator(mode="after")
81
+ def distribution(self):
82
+ if abs(self.contradiction + self.entailment + self.neutral - 1) > 1e-5:
83
+ raise ValueError("NLI labels must form a distribution for each hypothesis")
84
+ return self
85
+
86
+
87
+ class ProviderResult(Contract):
88
+ request_id: Name
89
+ identity: ProviderIdentity
90
+ semantics: Literal["synthetic_support", "independent_entailment", "categorical"]
91
+ scores: dict[str, Score]
92
+ proposed_action: Name | None
93
+ parameters: dict[str, StrictInt] = Field(default_factory=dict)
94
+ evidence: Literal["sufficient", "insufficient", "unknown", "conflicting"]
95
+ nli_scores: dict[str, NLIScores] = Field(default_factory=dict)
96
+
97
+ @model_validator(mode="after")
98
+ def valid_scores(self):
99
+ if self.semantics == "synthetic_support" and not self.identity.synthetic:
100
+ raise ValueError("synthetic support must have a synthetic provider identity")
101
+ if self.semantics == "categorical" and self.scores:
102
+ if abs(sum(self.scores.values()) - 1) > 1e-5:
103
+ raise ValueError("categorical scores must sum to one")
104
+ if self.nli_scores:
105
+ if self.semantics != "independent_entailment" or self.scores.keys() != self.nli_scores.keys():
106
+ raise ValueError("NLI detail must match independent entailment scores")
107
+ if any(abs(self.scores[k] - v.entailment) > 1e-6 for k, v in self.nli_scores.items()):
108
+ raise ValueError("entailment detail disagrees with score")
109
+ return self
110
+
111
+
112
+ class PolicyConfig(Contract):
113
+ version: Name = "robot-demo-policy-v1"
114
+ allowlist: tuple[Name, ...] = ("continue", "inspect", "recover", "request_help")
115
+ acceptance_threshold: Score = 0.8
116
+ minimum_margin: Score = 0.15
117
+ required_units: dict[str, str] = Field(default_factory=lambda: {
118
+ "front_range_m": "m", "camera_clearance_m": "m", "battery_pct": "%", "stuck": "bool",
119
+ })
120
+ conflict_tolerance_m: Annotated[float, Field(ge=0, allow_inf_nan=False)] = 0.4
121
+ minimum_clearance_m: Annotated[float, Field(ge=0, allow_inf_nan=False)] = 0.5
122
+ minimum_battery_pct: Annotated[float, Field(ge=0, le=100, allow_inf_nan=False)] = 10.0
123
+
124
+ @field_validator("required_units")
125
+ @classmethod
126
+ def fixed_robot_units(cls, units):
127
+ required = {"front_range_m": "m", "camera_clearance_m": "m", "battery_pct": "%", "stuck": "bool"}
128
+ if any(units.get(feature) != unit for feature, unit in required.items()):
129
+ raise ValueError("robot features and their physical units cannot be removed or redefined")
130
+ if any(not key or not value for key, value in units.items()):
131
+ raise ValueError("feature and unit labels cannot be empty")
132
+ return units
133
+
134
+
135
+ class PolicyOutcome(Contract):
136
+ request_id: Name
137
+ policy_version: Name
138
+ disposition: Literal["accept", "need_evidence", "defer"]
139
+ reason: Name
140
+ evaluated_at: AwareDatetime
141
+ action: Name | None = None
142
+ parameters: dict[str, StrictInt] = Field(default_factory=dict)
143
+
144
+ @model_validator(mode="after")
145
+ def accepted_action(self):
146
+ if (self.disposition == "accept") != (self.action is not None):
147
+ raise ValueError("only an accepted outcome specifies a dispatchable action")
148
+ if self.disposition != "accept" and self.parameters:
149
+ raise ValueError("a deferred outcome cannot specify action parameters")
150
+ return self
151
+
152
+
153
+ class ActionOutcome(Contract):
154
+ request_id: Name
155
+ action: Name
156
+ action_version: Name = "sim-skill-v1"
157
+ status: Literal["completed", "refused", "failed"]
158
+ started_at: AwareDatetime
159
+ completed_at: AwareDatetime
160
+ simulated: Literal[True] = True
161
+ parameters: dict[str, StrictInt]
162
+ observed: dict[str, Number | StrictStr | StrictBool] = Field(default_factory=dict)
163
+
164
+ @model_validator(mode="after")
165
+ def valid_times(self):
166
+ if self.completed_at < self.started_at:
167
+ raise ValueError("completion precedes start")
168
+ return self
169
+
170
+
171
+ class DecisionRecord(Contract):
172
+ schema_version: Literal["1"] = "1"
173
+ record_id: Name
174
+ batch_id: Name
175
+ recorded_at: AwareDatetime
176
+ request: DecisionRequest
177
+ provider_identity: ProviderIdentity
178
+ provider_result: ProviderResult | None
179
+ provider_status: Literal["ok", "failure", "malformed", "timeout", "cancelled", "budget_exhausted", "busy"]
180
+ policy_config: PolicyConfig
181
+ policy: PolicyOutcome
182
+ action: ActionOutcome | None
183
+ pollard_version: Name
184
+ pollard_root_id: Name
185
+ pollard_model_node_id: Name | None
186
+ pollard_action_node_id: Name | None
187
+ budget_limit_requests: Annotated[StrictInt, Field(ge=0)]
188
+ budget_spent_requests: Annotated[StrictInt, Field(ge=0)]
189
+ inference_elapsed_s: Annotated[float, Field(ge=0, allow_inf_nan=False)]
190
+ inference_timeout_s: Annotated[float, Field(gt=0, allow_inf_nan=False)]
191
+ mode: Literal["decision", "live_reevaluation"] = "decision"
192
+ reevaluates_record_id: Name | None = None
193
+
194
+ @model_validator(mode="after")
195
+ def linked_record(self):
196
+ if self.policy.request_id != self.request.request_id or self.policy.policy_version != self.policy_config.version:
197
+ raise ValueError("policy must match the recorded request and configuration")
198
+ if (self.provider_status == "ok") != (self.provider_result is not None):
199
+ raise ValueError("only successful inference has a validated provider result")
200
+ if self.provider_result is not None and (
201
+ self.provider_result.request_id != self.request.request_id or self.provider_result.identity != self.provider_identity
202
+ ):
203
+ raise ValueError("provider result must match the request and identity")
204
+ if self.action is not None:
205
+ if self.action.request_id != self.request.request_id:
206
+ raise ValueError("action must match the request")
207
+ if self.action.status == "completed" and (
208
+ self.policy.disposition != "accept" or self.action.action != self.policy.action
209
+ or self.action.parameters != self.policy.parameters
210
+ ):
211
+ raise ValueError("completed action must match its accepted policy")
212
+ if self.budget_spent_requests > self.budget_limit_requests:
213
+ raise ValueError("logical request budget cannot be exceeded")
214
+ if self.mode == "live_reevaluation" and (self.action is not None or self.reevaluates_record_id is None):
215
+ raise ValueError("live reevaluation must link a record and cannot dispatch")
216
+ return self
217
+
218
+
219
+ def fresh(request: DecisionRequest, now: datetime) -> bool:
220
+ """All supplied evidence must be valid now; expiry is an exclusive bound."""
221
+ return request.created_at <= now < request.valid_until and all(
222
+ obs.observed_at <= now < obs.valid_until for obs in request.observations
223
+ )
pollard_jev/demo.py ADDED
@@ -0,0 +1,101 @@
1
+ """Credential-free offline scenarios and read-only record commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+ from .contracts import DecisionRequest, Observation
11
+ from .loop import DecisionLoop
12
+ from .providers.fixture import FixtureProvider
13
+ from .records import inspect_history, simulate_policy
14
+ from .simulator import baseline, default_choices
15
+
16
+
17
+ def example_request(
18
+ now: datetime, *, request_id: str = "example", conflict: bool = False, missing: bool = False,
19
+ ) -> DecisionRequest:
20
+ values = {"front_range_m": (1.5, "m"), "camera_clearance_m": (0.2 if conflict else 1.4, "m"),
21
+ "battery_pct": (75.0, "%"), "stuck": (0.0, "bool")}
22
+ observations = tuple(Observation(
23
+ observation_id=f"{request_id}-{feature}", source="synthetic-sensors-v1", feature=feature,
24
+ value=value, unit=unit, observed_at=now, valid_until=now + timedelta(seconds=30),
25
+ ) for feature, (value, unit) in values.items() if not (missing and feature == "front_range_m"))
26
+ return DecisionRequest(
27
+ request_id=request_id, question="Which bounded supervisory skill should the mobile robot run next?",
28
+ created_at=now, valid_until=now + timedelta(seconds=30), observations=observations, choices=default_choices(),
29
+ )
30
+
31
+
32
+ class ScenarioClock:
33
+ def __init__(self):
34
+ self.now = datetime.now(timezone.utc)
35
+
36
+ def __call__(self):
37
+ return self.now
38
+
39
+
40
+ class ExpiringFixture(FixtureProvider):
41
+ def __init__(self, clock):
42
+ super().__init__()
43
+ self.clock = clock
44
+
45
+ def infer(self, requests):
46
+ results = super().infer(requests)
47
+ # Deterministic virtual passage of time during inference; no timing race.
48
+ self.clock.now += timedelta(seconds=31)
49
+ return results
50
+
51
+
52
+ def run_demo(output: Path) -> tuple:
53
+ output.mkdir(parents=True, exist_ok=True)
54
+ run_id = uuid4().hex[:12]
55
+ records_path = output / f"decisions-{run_id}.jsonl"
56
+ pollard_path = output / f"pollard-{run_id}.db"
57
+ records = []
58
+ print("Offline mobile-robot supervisor | SYNTHETIC scores | SIMULATED actions")
59
+ print("Demonstration thresholds: support >= 0.80; top-score margin >= 0.15")
60
+ print("Budget unit: one logical provider batch attempt (not tokens or joules)")
61
+ print(f"{'scenario':<27} {'proposal':<14} {'policy':<15} {'baseline':<15} result")
62
+ for name in ("fresh_coherent", "conflicting_observations", "missing_evidence", "stale_during_inference", "exhausted_budget", "provider_failure"):
63
+ clock = ScenarioClock()
64
+ request = example_request(clock(), request_id=name, conflict=name == "conflicting_observations", missing=name == "missing_evidence")
65
+ provider = ExpiringFixture(clock) if name == "stale_during_inference" else FixtureProvider(failure=name == "provider_failure")
66
+ with DecisionLoop(
67
+ provider, max_requests=0 if name == "exhausted_budget" else 1, clock=clock,
68
+ records_path=records_path, pollard_path=pollard_path,
69
+ ) as loop:
70
+ record = loop.decide(request)
71
+ records.append(record)
72
+ proposal = record.provider_result.proposed_action if record.provider_result else "(none)"
73
+ baseline_action = baseline(request, record.policy.evaluated_at)
74
+ result = f"simulated {record.action.action}" if record.action and record.action.status == "completed" else record.policy.reason
75
+ print(f"{name:<27} {proposal:<14} {record.policy.disposition:<15} {baseline_action:<15} {result}")
76
+ print(f"Records: {records_path.resolve()}")
77
+ print(f"Pollard ledger: {pollard_path.resolve()}")
78
+ return tuple(records)
79
+
80
+
81
+ def main(argv=None):
82
+ parser = argparse.ArgumentParser(description=__doc__)
83
+ commands = parser.add_subparsers(dest="command", required=True)
84
+ demo = commands.add_parser("demo", help="run all six offline scenarios")
85
+ demo.add_argument("--output", type=Path, default=Path("artifacts"))
86
+ for name in ("history", "simulate-policy"):
87
+ command = commands.add_parser(name)
88
+ command.add_argument("records", type=Path)
89
+ args = parser.parse_args(argv)
90
+ if args.command == "demo":
91
+ run_demo(args.output)
92
+ elif args.command == "history":
93
+ for record in inspect_history(args.records):
94
+ print(record.model_dump_json())
95
+ else:
96
+ for record in inspect_history(args.records):
97
+ print(simulate_policy(record).model_dump_json())
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
pollard_jev/loop.py ADDED
@@ -0,0 +1,283 @@
1
+ """Pollard-backed supervisory loop; inference workers can only return data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import queue
8
+ import threading
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from importlib.metadata import version
12
+ from pathlib import Path
13
+ from typing import Callable
14
+ from uuid import uuid4
15
+
16
+ from pollard import ActionSpec, Budget, Registry, Runtime
17
+ from pollard.errors import BudgetExceeded, PolicyViolation
18
+
19
+ from .contracts import ActionOutcome, DecisionRecord, DecisionRequest, PolicyConfig, ProviderIdentity, ProviderResult, fresh
20
+ from .policy import DecisionPolicy
21
+ from .providers.base import DecisionProvider
22
+ from .records import RecordStore
23
+ from .simulator import RobotSimulator, SKILL_PARAMETERS
24
+
25
+
26
+ def utc_now() -> datetime:
27
+ return datetime.now(timezone.utc)
28
+
29
+
30
+ def canonical_json(value) -> str:
31
+ # Pollard identities prohibit float JSON values. A canonical JSON *string*
32
+ # preserves numeric precision and full typed inputs without inventing an API.
33
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
34
+
35
+
36
+ class RequestMeter:
37
+ """One logical provider batch attempt, including failure/timeout, costs one."""
38
+ name = "requests"
39
+
40
+ def precheck_estimate(self, node_kind, payload):
41
+ return int(node_kind == "model_call")
42
+
43
+ def charge(self, node_kind, payload, result, meta):
44
+ return int(node_kind == "model_call")
45
+
46
+
47
+ class DecisionLoop:
48
+ def __init__(
49
+ self, provider: DecisionProvider, *, max_requests: int = 10,
50
+ timeout_s: float = 1.0, policy: PolicyConfig | None = None,
51
+ simulator: RobotSimulator | None = None, records_path: str | Path | None = None,
52
+ pollard_path: str | Path | None = None, clock: Callable[[], datetime] = utc_now,
53
+ ):
54
+ if type(max_requests) is not int or max_requests < 0:
55
+ raise ValueError("max_requests must be a nonnegative integer")
56
+ if isinstance(timeout_s, bool) or not math.isfinite(timeout_s) or timeout_s <= 0:
57
+ raise ValueError("timeout_s must be positive and finite")
58
+ self.provider = provider
59
+ self.identity = ProviderIdentity.model_validate(provider.identity.model_dump())
60
+ self.policy = DecisionPolicy(policy)
61
+ self.simulator = simulator or RobotSimulator()
62
+ self.max_requests, self.timeout_s, self.clock = max_requests, timeout_s, clock
63
+ self.records = RecordStore(records_path) if records_path is not None else None
64
+ self._lock = threading.Lock()
65
+ self._worker: threading.Thread | None = None
66
+ self._dispatch_context = None
67
+ self._closed = False
68
+ self._registry = Registry([
69
+ ActionSpec(
70
+ name, "sim-skill-v1", "Bounded simulated robot skill",
71
+ {"type": "object", "properties": {
72
+ key: {"type": "integer", "minimum": lo, "maximum": hi}
73
+ for key, (lo, hi) in bounds.items()
74
+ }, "required": list(bounds), "additionalProperties": False},
75
+ True, handler=self._handler(name),
76
+ ) for name, bounds in SKILL_PARAMETERS.items() if name in self.policy.config.allowlist
77
+ ])
78
+ if pollard_path is not None:
79
+ Path(pollard_path).parent.mkdir(parents=True, exist_ok=True)
80
+ self.runtime = Runtime(pollard_path, meters=[RequestMeter()], registry=self._registry,
81
+ refuse_duplicate_recordings=True)
82
+ self.run = self.runtime.run("pollard-jev-" + uuid4().hex, budget=Budget(extra={"requests": max_requests}))
83
+ self.run.note({"configuration_json": canonical_json({
84
+ "policy": self.policy.config.model_dump(mode="json"),
85
+ "provider": self.identity.model_dump(mode="json"),
86
+ "max_requests": max_requests, "timeout_s": timeout_s,
87
+ "budget_unit": "logical_provider_batch_attempts", "simulator": "sim-skill-v1",
88
+ })})
89
+
90
+ @property
91
+ def spent_requests(self) -> int:
92
+ return int(self.run.report()["spent"].get("requests", 0))
93
+
94
+ def _handler(self, action):
95
+ def handle(parameters):
96
+ # This guard is inside the registered callable, after Pollard's own
97
+ # schema/budget gate, immediately before the simulator is invoked.
98
+ context = self._dispatch_context
99
+ if context is None:
100
+ return {"status": "refused", "reason": "no_dispatch_context"}
101
+ request, result, cancel, deadline = context
102
+ now = self.clock()
103
+ if cancel.is_set():
104
+ return {"status": "refused", "reason": "cancelled"}
105
+ if time.monotonic() >= deadline:
106
+ return {"status": "refused", "reason": "timeout"}
107
+ check = self.policy.evaluate(request, result, now)
108
+ if check.disposition != "accept" or check.action != action or check.parameters != parameters:
109
+ return {"status": "refused", "reason": check.reason}
110
+ # Validation itself can consume time. Check the cheap mutable gates
111
+ # again at the actual dispatch boundary, including wall-clock expiry.
112
+ now = self.clock()
113
+ if cancel.is_set():
114
+ return {"status": "refused", "reason": "cancelled"}
115
+ if time.monotonic() >= deadline:
116
+ return {"status": "refused", "reason": "timeout"}
117
+ if not fresh(request, now):
118
+ return {"status": "refused", "reason": "stale_or_future_evidence"}
119
+ try:
120
+ outcome = self.simulator.execute(request.request_id, action, parameters, now)
121
+ return {"status": "completed", "outcome": outcome.model_dump(mode="json")}
122
+ except Exception:
123
+ # Never persist exception strings, which can contain credentials.
124
+ return {"status": "failed", "reason": "simulator_failure"}
125
+ return handle
126
+
127
+ def _infer(self, requests, cancel, deadline):
128
+ replies = queue.Queue(maxsize=1)
129
+ provider = self.provider
130
+ # Pass a detached validated copy so a provider cannot mutate policy inputs.
131
+ inputs = tuple(DecisionRequest.model_validate(r.model_dump()) for r in requests)
132
+
133
+ def work():
134
+ try:
135
+ output = provider.infer(inputs)
136
+ except Exception:
137
+ replies.put(("failure", None))
138
+ return
139
+ try:
140
+ if not isinstance(output, (tuple, list)) or len(output) != len(requests):
141
+ raise ValueError("wrong batch shape")
142
+ results = tuple(ProviderResult.model_validate(
143
+ item.model_dump() if isinstance(item, ProviderResult) else item
144
+ ) for item in output)
145
+ if [r.request_id for r in results] != [r.request_id for r in requests]:
146
+ raise ValueError("wrong request IDs/order")
147
+ if any(r.identity != self.identity for r in results):
148
+ raise ValueError("wrong provider identity")
149
+ replies.put(("ok", [r.model_dump(mode="json") for r in results]))
150
+ except Exception:
151
+ # Malformed raw output is deliberately not stored.
152
+ replies.put(("malformed", None))
153
+
154
+ self._worker = threading.Thread(target=work, daemon=True, name="pollard-jev-inference")
155
+ self._worker.start()
156
+ while True:
157
+ if cancel.is_set():
158
+ return {"status": "cancelled", "results": None}
159
+ remaining = deadline - time.monotonic()
160
+ if remaining <= 0:
161
+ return {"status": "timeout", "results": None}
162
+ try:
163
+ status, result = replies.get(timeout=min(remaining, 0.01))
164
+ except queue.Empty:
165
+ continue
166
+ # A queued reply may itself have arrived too late.
167
+ if cancel.is_set():
168
+ return {"status": "cancelled", "results": None}
169
+ if time.monotonic() >= deadline:
170
+ return {"status": "timeout", "results": None}
171
+ return {"status": status, "results": result}
172
+
173
+ def decide(self, request: DecisionRequest, *, cancel: threading.Event | None = None) -> DecisionRecord:
174
+ return self.decide_batch((request,), cancel=cancel)[0]
175
+
176
+ def decide_batch(
177
+ self, requests: tuple[DecisionRequest, ...], *, cancel: threading.Event | None = None,
178
+ dispatch: bool = True, mode: str = "decision", reevaluates_record_id: str | None = None,
179
+ ) -> tuple[DecisionRecord, ...]:
180
+ if mode not in ("decision", "live_reevaluation"):
181
+ raise ValueError("invalid record mode")
182
+ if mode == "live_reevaluation" and dispatch:
183
+ raise ValueError("live reevaluation must disable dispatch")
184
+ if mode == "live_reevaluation" and not reevaluates_record_id:
185
+ raise ValueError("live reevaluation must reference a historical record")
186
+ requests = tuple(DecisionRequest.model_validate(r.model_dump()) for r in requests)
187
+ if not requests or len({r.request_id for r in requests}) != len(requests):
188
+ raise ValueError("a batch requires distinct request IDs")
189
+ cancel = cancel if cancel is not None else threading.Event()
190
+ with self._lock:
191
+ if self._closed:
192
+ raise RuntimeError("decision loop is closed")
193
+ batch_id = uuid4().hex
194
+ started = time.monotonic()
195
+ deadline = started + self.timeout_s
196
+ node_id = None
197
+ if cancel.is_set():
198
+ envelope = {"status": "cancelled", "results": None}
199
+ elif self._worker is not None and self._worker.is_alive():
200
+ # A timed-out native call may still be working. Bound this loop
201
+ # to one worker rather than accumulating abandoned inference.
202
+ envelope = {"status": "busy", "results": None}
203
+ else:
204
+ try:
205
+ node = self.run.model_call(
206
+ {"batch_id": batch_id, "requests_json": canonical_json([r.model_dump(mode="json") for r in requests]),
207
+ "provider_json": canonical_json(self.identity.model_dump(mode="json"))},
208
+ fn=lambda _: self._infer(requests, cancel, deadline),
209
+ )
210
+ node_id, envelope = node.id, node.result
211
+ except BudgetExceeded as exc:
212
+ node_id = exc.refusal_id
213
+ envelope = {"status": "budget_exhausted", "results": None}
214
+ elapsed = time.monotonic() - started
215
+ status = envelope["status"]
216
+ results = envelope["results"] or [None] * len(requests)
217
+ records = []
218
+ did_dispatch = False
219
+ for request, raw in zip(requests, results, strict=True):
220
+ result = ProviderResult.model_validate(raw) if raw is not None else None
221
+ now = self.clock()
222
+ outcome = self.policy.evaluate(request, result, now) if status == "ok" else self.policy.outcome(request, now, "defer", status)
223
+ if cancel.is_set():
224
+ outcome = self.policy.outcome(request, now, "defer", "cancelled")
225
+ elif status == "ok" and time.monotonic() >= deadline:
226
+ outcome = self.policy.outcome(request, now, "defer", "timeout")
227
+ action = None
228
+ action_node_id = None
229
+ if outcome.disposition == "accept" and dispatch:
230
+ if did_dispatch:
231
+ outcome = self.policy.outcome(request, now, "defer", "batch_requires_new_observation")
232
+ else:
233
+ self.run.note({"dispatch_intent_json": outcome.model_dump_json()})
234
+ self._dispatch_context = (request, result, cancel, deadline)
235
+ # A failed response cannot prove that the first action
236
+ # had no effect. Permit at most one attempt per batch.
237
+ did_dispatch = True
238
+ try:
239
+ action_node = self.run.tool_call(outcome.action, outcome.parameters, version="sim-skill-v1")
240
+ action_node_id = action_node.id
241
+ if action_node.result["status"] == "completed":
242
+ action = ActionOutcome.model_validate(action_node.result["outcome"])
243
+ else:
244
+ outcome = self.policy.outcome(request, self.clock(), "defer", action_node.result["reason"])
245
+ action = ActionOutcome(
246
+ request_id=request.request_id, action=result.proposed_action,
247
+ status=action_node.result["status"], started_at=now, completed_at=self.clock(),
248
+ parameters=result.parameters,
249
+ )
250
+ except (PolicyViolation, BudgetExceeded) as exc:
251
+ action_node_id = exc.refusal_id
252
+ outcome = self.policy.outcome(request, self.clock(), "defer", "pollard_refusal")
253
+ finally:
254
+ self._dispatch_context = None
255
+ record = DecisionRecord(
256
+ record_id=uuid4().hex, batch_id=batch_id, recorded_at=self.clock(), request=request,
257
+ provider_identity=self.identity, provider_result=result, provider_status=status,
258
+ policy_config=self.policy.config, policy=outcome, action=action,
259
+ pollard_version=version("pollard"), pollard_root_id=self.run.root_id,
260
+ pollard_model_node_id=node_id, pollard_action_node_id=action_node_id,
261
+ budget_limit_requests=self.max_requests, budget_spent_requests=self.spent_requests,
262
+ inference_elapsed_s=elapsed, inference_timeout_s=self.timeout_s,
263
+ mode=mode, reevaluates_record_id=reevaluates_record_id,
264
+ )
265
+ self.run.note({"decision_record_json": record.model_dump_json()})
266
+ if self.records is not None:
267
+ self.records.append(record)
268
+ records.append(record)
269
+ return tuple(records)
270
+
271
+ def close(self):
272
+ with self._lock:
273
+ if not self._closed:
274
+ close = getattr(self.runtime.store, "close", None)
275
+ if close is not None:
276
+ close()
277
+ self._closed = True
278
+
279
+ def __enter__(self):
280
+ return self
281
+
282
+ def __exit__(self, *exc):
283
+ self.close()