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.
- pollard_jev/__init__.py +3 -0
- pollard_jev/__main__.py +3 -0
- pollard_jev/contracts.py +223 -0
- pollard_jev/demo.py +101 -0
- pollard_jev/loop.py +283 -0
- pollard_jev/policy.py +60 -0
- pollard_jev/providers/__init__.py +1 -0
- pollard_jev/providers/base.py +15 -0
- pollard_jev/providers/fixture.py +89 -0
- pollard_jev/providers/openjev.py +161 -0
- pollard_jev/py.typed +0 -0
- pollard_jev/records.py +61 -0
- pollard_jev/simulator.py +199 -0
- pollard_jev-0.1.dist-info/METADATA +258 -0
- pollard_jev-0.1.dist-info/RECORD +19 -0
- pollard_jev-0.1.dist-info/WHEEL +5 -0
- pollard_jev-0.1.dist-info/entry_points.txt +2 -0
- pollard_jev-0.1.dist-info/licenses/LICENSE +21 -0
- pollard_jev-0.1.dist-info/top_level.txt +1 -0
pollard_jev/__init__.py
ADDED
pollard_jev/__main__.py
ADDED
pollard_jev/contracts.py
ADDED
|
@@ -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()
|