awp-python 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. awp/__init__.py +64 -0
  2. awp/_spec/lifecycle.json +190 -0
  3. awp/_spec/schemas/action-cancel-result.schema.json +13 -0
  4. awp/_spec/schemas/action-ref.schema.json +11 -0
  5. awp/_spec/schemas/action-schema.schema.json +37 -0
  6. awp/_spec/schemas/action-status.schema.json +39 -0
  7. awp/_spec/schemas/action-submit-result.schema.json +16 -0
  8. awp/_spec/schemas/action-submit.schema.json +19 -0
  9. awp/_spec/schemas/agent-manifest.schema.json +27 -0
  10. awp/_spec/schemas/approval-requested.schema.json +25 -0
  11. awp/_spec/schemas/approval-respond.schema.json +31 -0
  12. awp/_spec/schemas/common.schema.json +150 -0
  13. awp/_spec/schemas/embodiment.schema.json +22 -0
  14. awp/_spec/schemas/empty-result.schema.json +8 -0
  15. awp/_spec/schemas/error.schema.json +25 -0
  16. awp/_spec/schemas/frame-inline.schema.json +20 -0
  17. awp/_spec/schemas/frame-tree.schema.json +26 -0
  18. awp/_spec/schemas/obs-report.schema.json +40 -0
  19. awp/_spec/schemas/observation-channel.schema.json +28 -0
  20. awp/_spec/schemas/ping-result.schema.json +13 -0
  21. awp/_spec/schemas/ping.schema.json +12 -0
  22. awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
  23. awp/_spec/schemas/reset-result.schema.json +11 -0
  24. awp/_spec/schemas/reset.schema.json +12 -0
  25. awp/_spec/schemas/restore.schema.json +11 -0
  26. awp/_spec/schemas/safety-policy.schema.json +81 -0
  27. awp/_spec/schemas/session-open.schema.json +41 -0
  28. awp/_spec/schemas/session-ready.schema.json +50 -0
  29. awp/_spec/schemas/session-resume.schema.json +12 -0
  30. awp/_spec/schemas/session-state.schema.json +14 -0
  31. awp/_spec/schemas/session-telemetry.schema.json +22 -0
  32. awp/_spec/schemas/session-transfer-result.schema.json +12 -0
  33. awp/_spec/schemas/session-transfer.schema.json +11 -0
  34. awp/_spec/schemas/snapshot-result.schema.json +11 -0
  35. awp/_spec/schemas/subscribe-result.schema.json +11 -0
  36. awp/_spec/schemas/subscribe.schema.json +11 -0
  37. awp/_spec/schemas/task-update.schema.json +11 -0
  38. awp/_spec/schemas/tick-result.schema.json +9 -0
  39. awp/_spec/schemas/tick.schema.json +12 -0
  40. awp/_spec/schemas/unsubscribe.schema.json +11 -0
  41. awp/_spec/schemas/world-event.schema.json +45 -0
  42. awp/_spec/schemas/world-manifest.schema.json +61 -0
  43. awp/aio.py +380 -0
  44. awp/client.py +781 -0
  45. awp/clock.py +61 -0
  46. awp/errors.py +112 -0
  47. awp/frames.py +174 -0
  48. awp/jsonrpc.py +84 -0
  49. awp/lifecycle.py +78 -0
  50. awp/py.typed +0 -0
  51. awp/schema.py +158 -0
  52. awp_python-0.1.0a1.dist-info/METADATA +127 -0
  53. awp_python-0.1.0a1.dist-info/RECORD +71 -0
  54. awp_python-0.1.0a1.dist-info/WHEEL +4 -0
  55. awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
  56. awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  57. awp_sim/__init__.py +8 -0
  58. awp_sim/__main__.py +5 -0
  59. awp_sim/arm.py +163 -0
  60. awp_sim/audit.py +138 -0
  61. awp_sim/cli.py +238 -0
  62. awp_sim/config.py +225 -0
  63. awp_sim/demo.py +85 -0
  64. awp_sim/loopback.py +222 -0
  65. awp_sim/py.typed +0 -0
  66. awp_sim/recorder.py +72 -0
  67. awp_sim/replay.py +134 -0
  68. awp_sim/scenarios.py +427 -0
  69. awp_sim/server.py +333 -0
  70. awp_sim/session.py +153 -0
  71. awp_sim/world.py +1612 -0
awp/client.py ADDED
@@ -0,0 +1,781 @@
1
+ """The agent side of AWP as a sans-IO state machine.
2
+
3
+ `ClientConnection` never touches a socket or a timer. Callers feed it decoded messages with
4
+ `receive()`, send whatever `outgoing()` returns, and call the request methods to act. Every
5
+ request method returns the JSON-RPC id it used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import uuid
13
+ from collections import deque
14
+ from collections.abc import Callable, Iterable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from . import frames, jsonrpc, schema
19
+ from .clock import ClockEstimator, sample
20
+ from .errors import AwpError, ErrorCode, ProtocolError
21
+ from .jsonrpc import Message
22
+ from .lifecycle import ActionState, permitted
23
+
24
+ PROTOCOL_VERSIONS = ("0.1",)
25
+
26
+ # ---------------------------------------------------------------------------- events
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Initialized:
31
+ manifest: dict[str, Any]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SessionOpened:
36
+ ready: dict[str, Any]
37
+ resumed: bool
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SessionStateChanged:
42
+ state: str
43
+ reason: str | None
44
+ replayed: bool
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ActionUpdated:
49
+ action: ActionRecord
50
+ status: dict[str, Any]
51
+ replayed: bool
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class WorldEvent:
56
+ event: str
57
+ params: dict[str, Any]
58
+ replayed: bool
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class FrameReceived:
63
+ channel: str
64
+ frame: frames.Frame
65
+ received_ns: int
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Telemetry:
70
+ params: dict[str, Any]
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class TickCompleted:
75
+ tick: int
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class ReplayCompleted:
80
+ status_seq: int
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Response:
85
+ id: int
86
+ method: str
87
+ result: dict[str, Any]
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class ErrorResponse:
92
+ id: int
93
+ method: str
94
+ error: AwpError
95
+ action_id: str | None = None
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class ApprovalRequested:
100
+ """For an approver connection: an action awaits its decision (AWP-APR-001)."""
101
+
102
+ params: dict[str, Any]
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class ProtocolViolation:
107
+ """The world broke the specification. Informational; the connection stays usable."""
108
+
109
+ detail: str
110
+
111
+
112
+ Event = (
113
+ Initialized
114
+ | SessionOpened
115
+ | SessionStateChanged
116
+ | ActionUpdated
117
+ | WorldEvent
118
+ | FrameReceived
119
+ | Telemetry
120
+ | TickCompleted
121
+ | ReplayCompleted
122
+ | Response
123
+ | ErrorResponse
124
+ | ApprovalRequested
125
+ | ProtocolViolation
126
+ )
127
+
128
+
129
+ # ---------------------------------------------------------------------------- state
130
+
131
+
132
+ @dataclass(slots=True)
133
+ class ActionRecord:
134
+ action_id: str
135
+ content: dict[str, Any]
136
+ state: ActionState = ActionState.SUBMITTED
137
+ status_seq: int = 0
138
+ status: dict[str, Any] = field(default_factory=dict)
139
+
140
+ @property
141
+ def terminal(self) -> bool:
142
+ return self.state.terminal
143
+
144
+ @property
145
+ def reason(self) -> str | None:
146
+ return self.status.get("reason")
147
+
148
+ @property
149
+ def progress(self) -> float | None:
150
+ return self.status.get("progress")
151
+
152
+
153
+ @dataclass(slots=True)
154
+ class _ChannelStats:
155
+ frames: int = 0
156
+ gaps: int = 0
157
+ last_seq: int | None = None
158
+ last_transit: int | None = None
159
+ binding: str = "inline"
160
+ jitter_sum: int = 0
161
+ jitter_n: int = 0
162
+ staleness: deque[int] = field(default_factory=lambda: deque(maxlen=_SAMPLES))
163
+
164
+
165
+ _SAMPLES = 4096 # latency samples kept between reports
166
+
167
+
168
+ def _stats(values: Iterable[int]) -> dict[str, int] | None:
169
+ if not values:
170
+ return None
171
+ ordered = sorted(values)
172
+
173
+ def pct(p: float) -> int:
174
+ return ordered[min(len(ordered) - 1, round(p * (len(ordered) - 1)))]
175
+
176
+ return {"count": len(ordered), "p50": pct(0.5), "p95": pct(0.95), "max": ordered[-1]}
177
+
178
+
179
+ # ---------------------------------------------------------------------------- connection
180
+
181
+
182
+ class ClientConnection:
183
+ def __init__(
184
+ self,
185
+ agent: dict[str, str],
186
+ consumes_modalities: Iterable[str],
187
+ *,
188
+ time_models: Iterable[str] | None = None,
189
+ clock_ns: Callable[[], int] = time.monotonic_ns,
190
+ validate: bool = True,
191
+ ) -> None:
192
+ self.agent = agent
193
+ self.consumes_modalities = list(consumes_modalities)
194
+ self.time_models = list(time_models) if time_models else None
195
+ self.clock_ns = clock_ns
196
+ self.validate = validate
197
+
198
+ self.manifest: dict[str, Any] | None = None
199
+ self.ready: dict[str, Any] | None = None
200
+ self.session_state: str | None = None
201
+ self.tick: int | None = None
202
+ self.last_status_seq = 0 # every status_seq up to here has been processed (the ack point)
203
+ self.actions: dict[str, ActionRecord] = {}
204
+ self.clock = ClockEstimator()
205
+
206
+ self._out: list[Message] = []
207
+ self._next_id = 1
208
+ self._pending: dict[int, tuple[str, dict[str, Any]]] = {}
209
+ self._replay_to: int | None = None
210
+ self._seen_above: set[int] = set() # processed status_seqs beyond a gap
211
+ self._session_pings: set[int] = set()
212
+ self._channel_names: dict[int, str] = {}
213
+ self._channel_stats: dict[int, _ChannelStats] = {}
214
+ self._receipts: deque[tuple[int, int]] = deque(maxlen=512) # (ts_mono_ns, agent receipt)
215
+ self._decision_latency: deque[int] = deque(maxlen=_SAMPLES)
216
+ self._last_report_ns: int | None = None
217
+ self._command_seq: dict[int, int] = {}
218
+
219
+ # ------------------------------------------------------------ plumbing
220
+
221
+ def outgoing(self) -> list[Message]:
222
+ """Drain the messages to send, in order."""
223
+ out, self._out = self._out, []
224
+ return out
225
+
226
+ @property
227
+ def last_id(self) -> int:
228
+ """The id of the most recent request."""
229
+ return self._next_id - 1
230
+
231
+ def request(self, method: str, params: dict[str, Any] | None = None) -> int:
232
+ rid = self._next_id
233
+ self._next_id += 1
234
+ self._pending[rid] = (method, params or {})
235
+ self._out.append(jsonrpc.request(rid, method, params))
236
+ return rid
237
+
238
+ def forget(self, rid: int) -> None:
239
+ """Stop waiting for the response to `rid` (e.g. after a timeout)."""
240
+ self._pending.pop(rid, None)
241
+ self._session_pings.discard(rid)
242
+
243
+ def connection_lost(self) -> None:
244
+ """The transport closed. Unanswered requests will never be answered on this connection."""
245
+ self._pending.clear()
246
+ self._session_pings.clear()
247
+ if self.session_state not in (None, "closed"):
248
+ self.session_state = "suspended"
249
+
250
+ @property
251
+ def replaying(self) -> bool:
252
+ """True between a session.resume result and the end of its replay."""
253
+ return self._replay_to is not None
254
+
255
+ @property
256
+ def session_token(self) -> str | None:
257
+ return self.ready["session_token"] if self.ready else None
258
+
259
+ @property
260
+ def channels(self) -> dict[str, int]:
261
+ """Granted channel name → channel_id."""
262
+ return {name: cid for cid, name in self._channel_names.items()}
263
+
264
+ @property
265
+ def stream_endpoints(self) -> list[dict[str, Any]]:
266
+ """Endpoints from session.ready, by world preference; inline is always last."""
267
+ return list(self.ready.get("stream_endpoints", [])) if self.ready else []
268
+
269
+ def receive_frame(self, data: bytes) -> list[Event]:
270
+ """A binary frame from a stream connection (AWP-TRN-003)."""
271
+ events: list[Event] = []
272
+ try:
273
+ frame = frames.decode(data)
274
+ except AwpError as exc:
275
+ return [ProtocolViolation(f"stream frame: {exc}")]
276
+ self._frame(frame, "ws", events)
277
+ return events
278
+
279
+ @property
280
+ def granted_action_types(self) -> list[str]:
281
+ """Action types this session may submit; empty before a session opens."""
282
+ if self.ready is None:
283
+ return []
284
+ return list(self.ready["granted"]["action_types"])
285
+
286
+ # ------------------------------------------------------------ requests
287
+
288
+ def initialize(self) -> int:
289
+ params: dict[str, Any] = {
290
+ "protocol_versions": list(PROTOCOL_VERSIONS),
291
+ "agent": self.agent,
292
+ "consumes_modalities": self.consumes_modalities,
293
+ }
294
+ if self.time_models:
295
+ params["time_models"] = self.time_models
296
+ return self.request("initialize", params)
297
+
298
+ def open_session(
299
+ self,
300
+ mode: str,
301
+ *,
302
+ embodiment: str | None = None,
303
+ subscribe: Iterable[dict[str, Any] | str] = (),
304
+ action_types: Iterable[str] | None = None,
305
+ admin: Iterable[str] | None = None,
306
+ **extra: Any,
307
+ ) -> int:
308
+ if self.manifest is None:
309
+ raise ProtocolError("initialize first")
310
+ params: dict[str, Any] = {"mode": mode, **extra}
311
+ if embodiment is not None:
312
+ params["embodiment"] = embodiment
313
+ subs = [s if isinstance(s, dict) else {"channel": s} for s in subscribe]
314
+ if subs:
315
+ params["subscribe"] = subs
316
+ if action_types is not None:
317
+ params["action_types"] = list(action_types)
318
+ if admin is not None:
319
+ params["admin"] = list(admin)
320
+ return self.request("session.open", params)
321
+
322
+ def resume(self) -> int:
323
+ if self.ready is None:
324
+ raise ProtocolError("no session to resume")
325
+ return self.request(
326
+ "session.resume",
327
+ {"session_token": self.ready["session_token"], "last_status_seq": self.last_status_seq},
328
+ )
329
+
330
+ def close(self) -> int:
331
+ return self.request("session.close")
332
+
333
+ def submit(
334
+ self,
335
+ type: str,
336
+ params: dict[str, Any],
337
+ *,
338
+ action_id: str | None = None,
339
+ preempt: str | None = None,
340
+ deadline_ms: int | None = None,
341
+ basis: frames.Frame | int | None = None,
342
+ valid_until_ns: int | None = None,
343
+ valid_for_ms: float | None = None,
344
+ embodiment_id: str | None = None,
345
+ ) -> str:
346
+ """Submit an action; returns its action_id. The admission arrives as a Response.
347
+
348
+ `basis` is the observation the intent rests on (a frame or its `ts_mono_ns`). The validity
349
+ window is either an absolute session-clock `valid_until_ns` or `valid_for_ms` from now,
350
+ mapped through the clock offset (AWP-CLK-009).
351
+ """
352
+ action_id = action_id or f"a-{uuid.uuid4().hex[:16]}"
353
+ if action_id in self.actions:
354
+ raise ValueError(f"action_id {action_id} already used; use resubmit() to retry")
355
+ if type not in self.granted_action_types:
356
+ raise AwpError(ErrorCode.FORBIDDEN, f"{type} is not granted (AWP-AGT-003)")
357
+ now = self.clock_ns()
358
+ content: dict[str, Any] = {"action_id": action_id, "type": type, "params": params}
359
+ if embodiment_id is not None:
360
+ content["embodiment_id"] = embodiment_id
361
+ if preempt is not None:
362
+ content["preempt"] = preempt
363
+ if deadline_ms is not None:
364
+ content["deadline_ms"] = deadline_ms
365
+ if basis is not None:
366
+ basis_ts = basis.ts_mono_ns if isinstance(basis, frames.Frame) else basis
367
+ content["basis_ts_mono_ns"] = basis_ts
368
+ received = self._receipt_of(basis_ts)
369
+ if received is not None:
370
+ self._decision_latency.append(now - received)
371
+ if valid_until_ns is not None:
372
+ content["valid_until_ns"] = valid_until_ns
373
+ elif valid_for_ms is not None:
374
+ content["valid_until_ns"] = self.clock.to_session(now) + int(valid_for_ms * 1e6)
375
+ self.actions[action_id] = ActionRecord(action_id, content)
376
+ self.request("action.submit", content)
377
+ return action_id
378
+
379
+ def resubmit(self, action_id: str) -> int:
380
+ """Retry a submission with identical content. While the world retains the action it is
381
+ not executed again (AWP-ACT-001, AWP-ACT-006)."""
382
+ return self.request("action.submit", self.actions[action_id].content)
383
+
384
+ def cancel(self, action_id: str) -> int:
385
+ return self.request("action.cancel", {"action_id": action_id})
386
+
387
+ def pull_status(self, action_id: str) -> int:
388
+ return self.request("action.status", {"action_id": action_id})
389
+
390
+ def advance(self, count: int | None = None) -> int:
391
+ """world.tick from the tick the agent holds (AWP-TIM-011). Never implicit (AWP-AGT-009)."""
392
+ if self.tick is None:
393
+ raise ProtocolError("not a lockstep session")
394
+ params: dict[str, Any] = {"expected_tick": self.tick}
395
+ if count is not None:
396
+ params["count"] = count
397
+ return self.request("world.tick", params)
398
+
399
+ def update_task(self, task: dict[str, Any]) -> int:
400
+ """Replace the session's task (AWP-TSK-003); needs capabilities.task."""
401
+ return self.request("task.update", {"task": task})
402
+
403
+ def transfer(self, expires_in_ms: int | None = None) -> int:
404
+ """Mint a single-use token another session presents to take the embodiment over."""
405
+ return self.request(
406
+ "session.transfer", {} if expires_in_ms is None else {"expires_in_ms": expires_in_ms}
407
+ )
408
+
409
+ def reset(self, initial_state: str | None = None, seed: int | None = None) -> int:
410
+ params = {
411
+ k: v for k, v in (("initial_state", initial_state), ("seed", seed)) if v is not None
412
+ }
413
+ return self.request("world.reset", params)
414
+
415
+ def snapshot(self) -> int:
416
+ return self.request("world.snapshot", {})
417
+
418
+ def restore(self, snapshot_token: str) -> int:
419
+ return self.request("world.restore", {"snapshot_token": snapshot_token})
420
+
421
+ def respond_approval(self, approval_id: str, decision: str, note: str | None = None) -> int:
422
+ """For an approver connection: `approve` or `deny` (AWP-APR-002)."""
423
+ params = {"approval_id": approval_id, "decision": decision}
424
+ if note is not None:
425
+ params["note"] = note
426
+ return self.request("safety.approval.respond", params)
427
+
428
+ def command(
429
+ self, channel: str, payload: bytes | dict[str, Any], *, inline: bool = True
430
+ ) -> frames.Frame:
431
+ """A setpoint on a granted command channel (AWP-CMD-007). Its `ts_mono_ns` is the issue
432
+ time mapped through the clock offset (AWP-CLK-009). With `inline`, it is queued as a
433
+ `cmd.frame` notification; otherwise the caller sends it on a stream connection."""
434
+ cid = self.channels[channel]
435
+ seq = self._command_seq[cid] = self._command_seq.get(cid, 0) + 1
436
+ data = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
437
+ frame = frames.Frame(cid, seq, self.clock.to_session(self.clock_ns()), data)
438
+ if inline:
439
+ self._out.append(jsonrpc.notification("cmd.frame", frames.to_inline(frame)))
440
+ return frame
441
+
442
+ def subscribe(self, channels: Iterable[dict[str, Any] | str]) -> int:
443
+ subs = [c if isinstance(c, dict) else {"channel": c} for c in channels]
444
+ return self.request("obs.subscribe", {"channels": subs})
445
+
446
+ def unsubscribe(self, channels: Iterable[str]) -> int:
447
+ return self.request("obs.unsubscribe", {"channels": list(channels)})
448
+
449
+ def ping(self, *, ack: bool = True) -> int:
450
+ """A heartbeat and clock-sync sample; with `ack`, acknowledges delivery (AWP-CTL-010)."""
451
+ now = self.clock_ns()
452
+ params: dict[str, Any] = {"origin_ns": now}
453
+ if ack and self.ready is not None:
454
+ params["last_status_seq"] = self.last_status_seq
455
+ rid = self.request("ping", params)
456
+ if self.ready is not None and "tick" not in self.ready:
457
+ # Only streaming pongs are stamped on a clock the offset can track (AWP-CLK-008).
458
+ self._session_pings.add(rid)
459
+ return rid
460
+
461
+ def report(self) -> Message:
462
+ """Queue an obs.report covering the time since the previous one (AWP-OBS-007)."""
463
+ now = self.clock_ns()
464
+ best = self.clock.best
465
+ if best is None:
466
+ raise ProtocolError("no clock sample yet")
467
+ since = self._last_report_ns if self._last_report_ns is not None else now - 1_000_000_000
468
+ channels: dict[str, Any] = {}
469
+ for cid, st in self._channel_stats.items():
470
+ entry: dict[str, Any] = {"frames": st.frames, "gaps": st.gaps}
471
+ if st.jitter_n:
472
+ entry["jitter_ns"] = st.jitter_sum // st.jitter_n
473
+ staleness = _stats(st.staleness)
474
+ if staleness:
475
+ entry["staleness_ns"] = staleness
476
+ channels[str(cid)] = entry
477
+ self._channel_stats[cid] = _ChannelStats(
478
+ last_seq=st.last_seq, last_transit=st.last_transit
479
+ )
480
+ params: dict[str, Any] = {
481
+ "window_ms": max(1, (now - since) // 1_000_000),
482
+ "sync": {
483
+ "offset_ns": best.offset_ns,
484
+ "rtt_ns": best.rtt_ns,
485
+ "samples": self.clock.samples,
486
+ },
487
+ "channels": channels,
488
+ }
489
+ decision = _stats(self._decision_latency)
490
+ if decision:
491
+ params["decision_latency_ns"] = decision
492
+ self._decision_latency.clear()
493
+ self._last_report_ns = now
494
+ msg = jsonrpc.notification("obs.report", params)
495
+ self._out.append(msg)
496
+ return msg
497
+
498
+ # ------------------------------------------------------------ input
499
+
500
+ def receive(self, msg: Message) -> list[Event]:
501
+ if jsonrpc.is_response(msg):
502
+ return self._on_response(msg)
503
+ if jsonrpc.is_request(msg):
504
+ return self._on_world_request(msg)
505
+ return self._on_notification(msg)
506
+
507
+ def _check(self, name: str, instance: Any, events: list[Event]) -> bool:
508
+ if not self.validate:
509
+ return True
510
+ problems = schema.errors(name, instance)
511
+ if problems:
512
+ events.append(ProtocolViolation(f"{name}: {'; '.join(problems[:3])}"))
513
+ return not problems
514
+
515
+ def _on_world_request(self, msg: Message) -> list[Event]:
516
+ if msg["method"] == "ping":
517
+ now = self.clock_ns()
518
+ origin = (msg.get("params") or {}).get("origin_ns", 0)
519
+ self._out.append(
520
+ jsonrpc.result(
521
+ msg["id"],
522
+ {"origin_ns": origin, "receive_ns": now, "transmit_ns": self.clock_ns()},
523
+ )
524
+ )
525
+ else:
526
+ err = AwpError(ErrorCode.METHOD_NOT_FOUND, msg["method"])
527
+ self._out.append(jsonrpc.error(msg["id"], err))
528
+ return []
529
+
530
+ def _on_response(self, msg: Message) -> list[Event]:
531
+ events: list[Event] = []
532
+ pending = self._pending.pop(msg["id"], None) if isinstance(msg["id"], int) else None
533
+ if pending is None:
534
+ return [ProtocolViolation(f"response to unknown request id {msg['id']!r}")]
535
+ method, params = pending
536
+ if "error" in msg:
537
+ err = AwpError.from_dict(msg["error"])
538
+ action_id = params.get("action_id") if method.startswith("action.") else None
539
+ if method == "action.submit":
540
+ rec = self.actions.get(params["action_id"])
541
+ if (
542
+ rec
543
+ and rec.state is ActionState.SUBMITTED
544
+ and err.code != ErrorCode.ACTION_ID_CONFLICT
545
+ ):
546
+ del self.actions[params["action_id"]] # no action was created (AWP-ACT-010)
547
+ elif (
548
+ method == "world.tick"
549
+ and err.code == ErrorCode.TICK_MISMATCH
550
+ and "tick" in err.data
551
+ ):
552
+ self.tick = err.data["tick"]
553
+ events.append(ErrorResponse(msg["id"], method, err, action_id))
554
+ return events
555
+ res = msg["result"]
556
+ result_schema = schema.schema_for(method, "result")
557
+ valid = result_schema is None or self._check(result_schema, res, events)
558
+ if not valid and method in ("initialize", "world.manifest"):
559
+ raise ProtocolError("world manifest does not validate (AWP-AGT-002)")
560
+ if method == "ping":
561
+ if msg["id"] in self._session_pings:
562
+ self._session_pings.discard(msg["id"])
563
+ if valid:
564
+ received = self.clock_ns()
565
+ s = sample(params["origin_ns"], res["receive_ns"], res["transmit_ns"], received)
566
+ self.clock.add(s)
567
+ elif valid:
568
+ handler = getattr(self, "_result_" + method.replace(".", "_"), None)
569
+ if handler is not None:
570
+ handler(params, res, events)
571
+ events.append(Response(msg["id"], method, res))
572
+ return events
573
+
574
+ def _result_initialize(
575
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
576
+ ) -> None:
577
+ if res.get("protocol_version") not in PROTOCOL_VERSIONS:
578
+ raise ProtocolError(
579
+ f"world selected unsupported version {res.get('protocol_version')!r}"
580
+ )
581
+ self.manifest = res
582
+ events.append(Initialized(res))
583
+
584
+ _result_world_manifest = _result_initialize
585
+
586
+ def _result_session_open(
587
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
588
+ ) -> None:
589
+ self.ready = res
590
+ self.clock = ClockEstimator() # a new session clock
591
+ self._seen_above.clear()
592
+ self.last_status_seq = 0
593
+ self.tick = res.get("tick")
594
+ self._set_channels(res["granted"]["channels"])
595
+ self.session_state = "ready"
596
+ events.append(SessionOpened(res, resumed=False))
597
+
598
+ def _result_session_resume(
599
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
600
+ ) -> None:
601
+ self.ready = {**res, "session_token": res.get("session_token", self.session_token)}
602
+ if "tick" in res:
603
+ self.tick = res["tick"]
604
+ self._set_channels(res["granted"]["channels"])
605
+ for st in self._channel_stats.values():
606
+ st.last_seq = None # the seq gap across a resumption is not loss
607
+ self._replay_to = res.get("replay_to_status_seq")
608
+ events.append(SessionOpened(res, resumed=True))
609
+ if self._replay_to is not None and self._replay_to <= self.last_status_seq:
610
+ events.append(ReplayCompleted(self._replay_to))
611
+ self._replay_to = None
612
+
613
+ def _result_session_close(
614
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
615
+ ) -> None:
616
+ self.session_state = "closed"
617
+
618
+ def _result_action_submit(
619
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
620
+ ) -> None:
621
+ rec = self.actions.get(res["action_id"])
622
+ if rec is None:
623
+ rec = self.actions[res["action_id"]] = ActionRecord(res["action_id"], params)
624
+ if rec.state is ActionState.SUBMITTED:
625
+ self._apply_status(res, events)
626
+ elif res["status_seq"] > rec.status_seq:
627
+ self._apply_status(res, events) # idempotent result newer than anything we processed
628
+
629
+ def _result_action_cancel(
630
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
631
+ ) -> None:
632
+ self._apply_status(res, events)
633
+
634
+ def _result_action_status(
635
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
636
+ ) -> None:
637
+ rec = self.actions.get(res["action_id"])
638
+ if rec is not None and res["status_seq"] > rec.status_seq:
639
+ self._apply_status(res, events)
640
+
641
+ def _result_world_tick(
642
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
643
+ ) -> None:
644
+ self.tick = res["tick"]
645
+ events.append(TickCompleted(res["tick"]))
646
+
647
+ def _result_world_reset(
648
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
649
+ ) -> None:
650
+ if "tick" in res:
651
+ self.tick = res["tick"]
652
+
653
+ _result_world_restore = _result_world_reset
654
+
655
+ def _result_obs_subscribe(
656
+ self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
657
+ ) -> None:
658
+ self._set_channels(res["granted"])
659
+
660
+ _result_obs_unsubscribe = _result_obs_subscribe
661
+
662
+ def _on_notification(self, msg: Message) -> list[Event]:
663
+ method, params = msg["method"], msg.get("params") or {}
664
+ events: list[Event] = []
665
+ note_schema = schema.schema_for(method, "notification")
666
+ if note_schema is not None and not self._check(note_schema, params, events):
667
+ return events
668
+ if method == "obs.frame":
669
+ self._on_frame(params, events)
670
+ elif method == "session.telemetry":
671
+ events.append(Telemetry(params))
672
+ elif method == "safety.approval_requested":
673
+ events.append(ApprovalRequested(params))
674
+ elif method == "action.status":
675
+ self._apply_status(params, events)
676
+ elif method == "world.event":
677
+ if self._sequence(params["status_seq"], events):
678
+ events.append(WorldEvent(params["event"], params, self._replaying(params)))
679
+ elif method == "session.state" and self._sequence(params["status_seq"], events):
680
+ self.session_state = params["state"]
681
+ events.append(
682
+ SessionStateChanged(params["state"], params.get("reason"), self._replaying(params))
683
+ )
684
+ self._finish_replay(events)
685
+ return events
686
+
687
+ # ------------------------------------------------------------ status sequencing
688
+
689
+ def _replaying(self, params: dict[str, Any]) -> bool:
690
+ return self._replay_to is not None and params["status_seq"] <= self._replay_to
691
+
692
+ def _sequence(self, seq: int, events: list[Event]) -> bool:
693
+ """True for a status_seq not yet processed; False for a redelivery (AWP-LIF-009).
694
+
695
+ A gap is reported but not acknowledged: `last_status_seq` stays below it, so a resume
696
+ replays what is missing instead of the world discarding it.
697
+ """
698
+ if seq <= self.last_status_seq or seq in self._seen_above:
699
+ return False
700
+ if seq != self.last_status_seq + 1:
701
+ events.append(ProtocolViolation(f"status_seq {seq} after {self.last_status_seq}"))
702
+ self._seen_above.add(seq)
703
+ return True
704
+ self.last_status_seq = seq
705
+ while self.last_status_seq + 1 in self._seen_above:
706
+ self._seen_above.remove(self.last_status_seq + 1)
707
+ self.last_status_seq += 1
708
+ return True
709
+
710
+ def _finish_replay(self, events: list[Event]) -> None:
711
+ if self._replay_to is not None and self.last_status_seq >= self._replay_to:
712
+ events.append(ReplayCompleted(self._replay_to))
713
+ self._replay_to = None
714
+
715
+ def _apply_status(self, status: dict[str, Any], events: list[Event]) -> None:
716
+ if not self._sequence(status["status_seq"], events):
717
+ return
718
+ replayed = self._replaying(status)
719
+ action_id = status["action_id"]
720
+ rec = self.actions.get(action_id)
721
+ if rec is None:
722
+ rec = self.actions[action_id] = ActionRecord(action_id, {"action_id": action_id})
723
+ target = ActionState(status["state"])
724
+ if rec.state.terminal:
725
+ events.append(ProtocolViolation(f"{action_id}: {target} after terminal {rec.state}"))
726
+ elif target is not rec.state and not permitted(rec.state, target, status.get("reason")):
727
+ events.append(
728
+ ProtocolViolation(f"{action_id}: {rec.state} → {target} is not permitted")
729
+ )
730
+ rec.state = target
731
+ rec.status_seq = status["status_seq"]
732
+ rec.status = status
733
+ events.append(ActionUpdated(rec, status, replayed))
734
+ self._finish_replay(events)
735
+
736
+ # ------------------------------------------------------------ frames
737
+
738
+ def _set_channels(self, grants: list[dict[str, Any]]) -> None:
739
+ self._channel_names = {g["channel_id"]: g["channel"] for g in grants}
740
+
741
+ def _receipt_of(self, ts_mono_ns: int) -> int | None:
742
+ return next((r for ts, r in reversed(self._receipts) if ts == ts_mono_ns), None)
743
+
744
+ def _on_frame(self, params: dict[str, Any], events: list[Event]) -> None:
745
+ try:
746
+ frame = frames.from_inline(params)
747
+ except AwpError as exc:
748
+ events.append(ProtocolViolation(str(exc)))
749
+ return
750
+ self._frame(frame, "inline", events)
751
+
752
+ def _frame(self, frame: frames.Frame, binding: str, events: list[Event]) -> None:
753
+ now = self.clock_ns()
754
+ name = self._channel_names.get(frame.channel_id)
755
+ if name is None:
756
+ events.append(ProtocolViolation(f"frame on ungranted channel {frame.channel_id}"))
757
+ return
758
+ st = self._channel_stats.setdefault(frame.channel_id, _ChannelStats())
759
+ if st.last_seq is not None and frame.seq <= st.last_seq and binding != st.binding:
760
+ return # overtaken on the connection the channel moved to (AWP-TRN-012)
761
+ st.binding = binding
762
+ if st.last_seq is not None:
763
+ if frame.seq <= st.last_seq:
764
+ events.append(ProtocolViolation(f"channel {name}: seq {frame.seq} not increasing"))
765
+ elif frame.seq != st.last_seq + 1 and not frame.resync:
766
+ st.gaps += frame.seq - st.last_seq - 1
767
+ st.last_seq = frame.seq
768
+ st.frames += 1
769
+ offset = self.clock.offset_ns
770
+ if offset is not None and frame.ts_send_ns is not None:
771
+ received = now + offset
772
+ st.staleness.append(max(0, received - frame.ts_mono_ns))
773
+ transit = received - frame.ts_send_ns
774
+ if st.last_transit is not None:
775
+ st.jitter_sum += abs(transit - st.last_transit)
776
+ st.jitter_n += 1
777
+ st.last_transit = transit
778
+ self._receipts.append((frame.ts_mono_ns, now))
779
+ if self.session_state == "ready":
780
+ self.session_state = "active"
781
+ events.append(FrameReceived(name, frame, now))