simulo-interfaces 0.22.0__tar.gz → 0.23.0__tar.gz

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 (61) hide show
  1. {simulo_interfaces-0.22.0/src/simulo_interfaces.egg-info → simulo_interfaces-0.23.0}/PKG-INFO +1 -1
  2. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/pyproject.toml +1 -1
  3. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/record.py +30 -2
  4. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/robot.py +81 -25
  5. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/terrain.py +2 -4
  6. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/__init__.py +91 -0
  7. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/enums.py +2 -2
  8. simulo_interfaces-0.23.0/src/simulo/interfaces/platform/evaluation.py +451 -0
  9. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/manifest.py +7 -6
  10. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/runs.py +11 -0
  11. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/submit.py +10 -0
  12. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/components.py +3 -0
  13. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/env.py +12 -0
  14. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/task.py +35 -6
  15. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/trainer.py +4 -12
  16. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0/src/simulo_interfaces.egg-info}/PKG-INFO +1 -1
  17. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo_interfaces.egg-info/SOURCES.txt +1 -0
  18. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/MANIFEST.in +0 -0
  19. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/PYPI.md +0 -0
  20. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/setup.cfg +0 -0
  21. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/__init__.py +0 -0
  22. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/__init__.py +0 -0
  23. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/actuator.py +0 -0
  24. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/asset.py +0 -0
  25. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/checkpoint.py +0 -0
  26. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/entity.py +0 -0
  27. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/light.py +0 -0
  28. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/materials.py +0 -0
  29. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/physics.py +0 -0
  30. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/pose.py +0 -0
  31. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/primitives.py +0 -0
  32. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/prop.py +0 -0
  33. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/scene.py +0 -0
  34. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/sensor.py +0 -0
  35. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/visual.py +0 -0
  36. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/authoring/world.py +0 -0
  37. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/exceptions.py +0 -0
  38. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/ids.py +0 -0
  39. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/_payload.py +0 -0
  40. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/app.py +0 -0
  41. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/asset.py +0 -0
  42. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/asset_catalog.py +0 -0
  43. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/callbacks.py +0 -0
  44. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/debug.py +0 -0
  45. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/domain.py +0 -0
  46. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/job_events.py +0 -0
  47. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/onnx_bundle.py +0 -0
  48. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/outputs.py +0 -0
  49. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/preview.py +0 -0
  50. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/runtime.py +0 -0
  51. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/systems.py +0 -0
  52. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/platform/volume.py +0 -0
  53. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/py.typed +0 -0
  54. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/__init__.py +0 -0
  55. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/anomaly.py +0 -0
  56. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/player.py +0 -0
  57. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/policy.py +0 -0
  58. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo/interfaces/runtime/tensors.py +0 -0
  59. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo_interfaces.egg-info/dependency_links.txt +0 -0
  60. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo_interfaces.egg-info/requires.txt +0 -0
  61. {simulo_interfaces-0.22.0 → simulo_interfaces-0.23.0}/src/simulo_interfaces.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo-interfaces
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Typed Python contracts for the Simulo cloud robotics SDK and managed simulation runtime.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo-interfaces"
7
- version = "0.22.0"
7
+ version = "0.23.0"
8
8
  description = "Typed Python contracts for the Simulo cloud robotics SDK and managed simulation runtime."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -44,10 +44,13 @@ fingerprint that overrides the auto-collected one when present).
44
44
 
45
45
  from __future__ import annotations
46
46
 
47
+ import math
47
48
  import os
49
+ import sys
48
50
  from dataclasses import dataclass, field
51
+ from numbers import Real
49
52
  from pathlib import Path
50
- from typing import Any, Dict, Literal, Mapping, Optional, Sequence, Tuple, Union
53
+ from typing import Any, Dict, Literal, Mapping, Optional, Sequence, SupportsFloat, Tuple, Union
51
54
 
52
55
  # All include_* field names this config exposes. Centralizing avoids drift
53
56
  # between the dataclass declaration, _PROFILE_DEFAULTS, and resolve_includes.
@@ -253,7 +256,14 @@ class RecordConfig:
253
256
  video_fallback: Literal["software", "error"] = "software"
254
257
  video_codec: Literal["h264", "hevc", "av1"] = "h264"
255
258
  video_bitrate: Optional[str] = "8M"
256
- video_fps: Optional[int] = 30
259
+ # ``None`` leaves cadence to the frame producer. The encoder then uses its
260
+ # documented 30 fps default, but the recorder omits ``video_fps`` from
261
+ # /run/start because packet timestamps, not an encoder setting, describe
262
+ # that producer-owned cadence. Callers such as evaluation filming set the
263
+ # measured cadence explicitly.
264
+ # Values must also fit a finite IEEE-754 double because both supported
265
+ # encoders and the MCAP JSON record use that representation.
266
+ video_fps: Optional[SupportsFloat] = None
257
267
  video_gop: Optional[int] = 30
258
268
  video_storage: Literal["embedded", "sidecar"] = "embedded"
259
269
  # When True (default), the recorder writes a parallel
@@ -300,6 +310,24 @@ class RecordConfig:
300
310
  raise ValueError(f"RecordConfig.profile must be one of {VALID_PROFILES}; got {self.profile!r}")
301
311
  if self.record_every_n_steps < 1:
302
312
  raise ValueError(f"RecordConfig.record_every_n_steps must be >= 1; got {self.record_every_n_steps}")
313
+ try:
314
+ video_fps = None if self.video_fps is None else float(self.video_fps)
315
+ except (OverflowError, TypeError, ValueError):
316
+ video_fps = None
317
+ if self.video_fps is not None and (
318
+ isinstance(self.video_fps, bool)
319
+ or not isinstance(self.video_fps, Real)
320
+ or video_fps is None
321
+ or not math.isfinite(video_fps)
322
+ # 0.1 fps is a policy floor that keeps rates sensible; ffmpeg's
323
+ # rational conversion only rounds to zero far below it.
324
+ or video_fps < 0.1
325
+ or video_fps > sys.float_info.max
326
+ ):
327
+ raise ValueError(
328
+ "RecordConfig.video_fps must be a finite real number representable by the encoder and at least 0.1 "
329
+ f"when set; got {self.video_fps!r}"
330
+ )
303
331
  # env_indices: accept any sequence of ints, including a torch tensor —
304
332
  # but normalize to a tuple of ints for hashability + reproducibility.
305
333
  try:
@@ -7,7 +7,7 @@ and reset paths all reach through a live engine handle), so its
7
7
  *implementation* cannot live in a zero-dependency package. What CAN live here
8
8
  is its declared public surface: this Protocol names exactly what authoring
9
9
  code may rely on when it holds a robot — construction-time configuration,
10
- joint/body queries, command targets, state reads, and reset.
10
+ joint/body queries, command targets, default state, live state reads, and reset.
11
11
 
12
12
  The backend's concrete ``Robot`` class explicitly inherits this Protocol
13
13
  (nominal conformance). Enforcement is the standard four layers for a mirror —
@@ -23,15 +23,16 @@ The Protocol stays structural for any other implementer.
23
23
 
24
24
  ``state`` is DECLARED, as ``RobotStateProtocol`` — the grouped live-state
25
25
  contract. It is the supported, typed way to read a robot's simulation state
26
- (``robot.state.joint_positions``, ``robot.state.pose``, …): six read-only,
26
+ (``robot.state.joint_positions``, ``robot.state.pose``, …): eight read-only,
27
27
  batched members whose names are plain English rather than engine vocabulary.
28
- The members are LIVE VIEWS — each read goes straight through to the
29
- simulation's own buffers, at the same cost as reaching through the
30
- ``internals`` escape hatch, with no copy and no per-access container
31
- allocation — so reading them inside a training loop is free of hidden
32
- overhead. Unlike ``internals``, these six names ARE a compatibility promise:
33
- they keep working across engine upgrades, because the backend maps each one
34
- to whatever the engine calls it.
28
+ Six members are zero-copy live views of the simulation's buffers. The two
29
+ base-frame velocity members are different: the engine rotates the current
30
+ world-frame velocities into the base frame on every read, returning a new
31
+ tensor. That small computation and allocation are the cost of those two
32
+ accesses, and writing into either result does not reach the simulation.
33
+ Unlike ``internals``, all eight names ARE a compatibility promise: they keep
34
+ working across engine upgrades, because the backend maps each one to whatever
35
+ the engine calls it.
35
36
 
36
37
  Deliberate scoping notes, so they are not mistaken for drift (each is an
37
38
  entry in the backend conformance test's documented-omissions allowlist):
@@ -68,27 +69,31 @@ from simulo.interfaces.runtime.tensors import TensorLike
68
69
 
69
70
  @runtime_checkable
70
71
  class RobotStateProtocol(Protocol):
71
- """The live robot-state contract — batched, read-only, zero-copy.
72
+ """The live robot-state contract — batched and read-only.
72
73
 
73
74
  Obtained from a robot as ``robot.state``; never constructed directly.
74
- Each member is a live view of the simulation's own state buffers: reading
75
- one returns the current values without copying, and the same access works
76
- identically inside a training loop's hot path. All members carry the
77
- environment batch as their leading dimension (the scene's ``num_envs``).
75
+ Six members (``pose``, the two world-frame velocities, and the three
76
+ ``joint_*`` members) are zero-copy live views of the simulation's own
77
+ buffers. ``linear_velocity_in_base_frame`` and
78
+ ``angular_velocity_in_base_frame`` are computed from the current base
79
+ orientation on every read. Each of those reads returns a new tensor,
80
+ costs a small computation, and cannot modify the simulation when written
81
+ into. All members carry the environment batch as their leading dimension
82
+ (the scene's ``num_envs``).
78
83
 
79
84
  **Read a member each time you need it — do not hold the returned tensor
80
- across steps, and do not write into it.** The tensor IS the simulation's
81
- own memory: the engine refreshes most of these buffers by rebinding them
82
- to new tensors, so a tensor held across a step can silently serve stale
83
- values, and writing into one writes into the simulation. The accessor
84
- (``robot.state`` itself) may be held freely; the tensors its members
85
- return should be re-read at each use.
85
+ across steps, and do not write into it.** The six live-view tensors share
86
+ simulation memory, and writing into one writes into the simulation. The
87
+ engine can also rebind those buffers, while the base-frame members are
88
+ recomputed, so a tensor held across a step can silently serve stale values.
89
+ The accessor (``robot.state`` itself) may be held freely; the tensors its
90
+ members return should be re-read at each use.
86
91
 
87
92
  Members raise ``RuntimeError`` until the simulation runtime has attached
88
93
  the robot — read them from ``on_start`` onward. The ``state`` accessor
89
94
  itself is always available and is the same object on every access.
90
95
 
91
- These six names are a compatibility promise: they keep working across
96
+ These eight names are a compatibility promise: they keep working across
92
97
  engine upgrades. For anything not named here, the ``internals`` escape
93
98
  hatch remains available — explicitly unstable, at your own risk.
94
99
  """
@@ -111,6 +116,24 @@ class RobotStateProtocol(Protocol):
111
116
  ``[wx, wy, wz]``."""
112
117
  ...
113
118
 
119
+ @property
120
+ def linear_velocity_in_base_frame(self) -> TensorLike:
121
+ """Base linear velocity expressed in the base frame, shape
122
+ ``(num_envs, 3)`` in m/s with columns ``[vx, vy, vz]``. Available
123
+ from ``on_start`` onward. Computed from the current base orientation
124
+ on every read; each read returns a new tensor, costs a small
125
+ computation, and writing into it does not reach the simulation."""
126
+ ...
127
+
128
+ @property
129
+ def angular_velocity_in_base_frame(self) -> TensorLike:
130
+ """Base angular velocity expressed in the base frame, shape
131
+ ``(num_envs, 3)`` in rad/s with columns ``[wx, wy, wz]``. Available
132
+ from ``on_start`` onward. Computed from the current base orientation
133
+ on every read; each read returns a new tensor, costs a small
134
+ computation, and writing into it does not reach the simulation."""
135
+ ...
136
+
114
137
  @property
115
138
  def joint_positions(self) -> TensorLike:
116
139
  """Joint positions (rad or m), shape ``(num_envs, num_joints)``."""
@@ -146,7 +169,7 @@ class RobotProtocol(Protocol):
146
169
  """Model source (USD or URDF) defining the robot."""
147
170
 
148
171
  initial_pose: Pose
149
- """Pose the robot spawns at, in the world frame (identity when not given)."""
172
+ """Pose the robot spawns at (identity when not given). For a robot copied into each environment, the position is measured from that environment's origin, and default_pose reports it with the origin added. For some catalog robots, such as the cartpole, the identity pose keeps the catalog model's own spawn height; any other pose replaces it. A robot added under a Transform spawns at the Transform's position."""
150
173
 
151
174
  collision_enabled: bool
152
175
  """Whether collision detection is enabled."""
@@ -203,12 +226,45 @@ class RobotProtocol(Protocol):
203
226
  """The grouped live robot state — the supported, typed state surface.
204
227
 
205
228
  Always available (the accessor is the same object on every access);
206
- its members read straight through to the simulation's state buffers
207
- and raise ``RuntimeError`` until the simulation runtime has attached
208
- the robot — read them from ``on_start`` onward.
229
+ six members read straight through to the simulation's state buffers,
230
+ while the two base-frame velocity members are computed into new
231
+ tensors on every read. Members raise ``RuntimeError`` until the
232
+ simulation runtime has attached the robot — read them from
233
+ ``on_start`` onward.
209
234
  """
210
235
  ...
211
236
 
237
+ @property
238
+ def default_joint_positions(self) -> TensorLike:
239
+ """Joint positions that :meth:`reset` writes, shape
240
+ ``(num_envs, num_joints)`` in rad for hinge joints and m for slider
241
+ joints. Columns use the same order as ``state.joint_positions`` and
242
+ the indices returned by :meth:`find_joints`. Available from
243
+ ``on_start`` onward. Each read returns a new tensor, so writing into
244
+ it cannot change a later reset."""
245
+ ...
246
+
247
+ @property
248
+ def default_joint_velocities(self) -> TensorLike:
249
+ """Joint velocities that :meth:`reset` writes, shape
250
+ ``(num_envs, num_joints)`` in rad/s for hinge joints and m/s for
251
+ slider joints. Columns use the same order as
252
+ ``state.joint_positions`` and the indices returned by
253
+ :meth:`find_joints`. Available from ``on_start`` onward. Each read
254
+ returns a new tensor, so writing into it cannot change a later
255
+ reset."""
256
+ ...
257
+
258
+ @property
259
+ def default_pose(self) -> TensorLike:
260
+ """Base pose that :meth:`reset` writes in the world frame, shape
261
+ ``(num_envs, 7)`` with columns ``[x, y, z, qw, qx, qy, qz]``:
262
+ position in m followed by a w-first unit quaternion. Each
263
+ environment's origin is included. Available from ``on_start``
264
+ onward. Each read returns a new tensor, so writing into it cannot
265
+ change a later reset."""
266
+ ...
267
+
212
268
  @property
213
269
  def sensors(self) -> List[Any]:
214
270
  """Sensors attached to this robot via :meth:`add_sensor`."""
@@ -59,10 +59,8 @@ class SubTerrainConfig:
59
59
  - "hf_pyramid_slope": Height-field pyramid slope
60
60
  - "hf_pyramid_slope_inv": Inverted height-field pyramid slope
61
61
  proportion: Proportion of terrain grid to use this type (0.0-1.0).
62
- Defaults to 1.0 — "use 100% of the grid for this single
63
- sub-terrain". Every in-tree caller passes ``proportion=``
64
- explicitly (Wave 0 audit verified), so the default-value
65
- change from the pre-Wave-B local default of 0.2 is safe.
62
+ Defaults to 1.0, which uses the whole grid for this single
63
+ sub-terrain.
66
64
  params: Type-specific parameters (e.g., height, step_height, etc.)
67
65
 
68
66
  Example:
@@ -79,6 +79,50 @@ from simulo.interfaces.platform.enums import (
79
79
  OutputKind,
80
80
  SystemType,
81
81
  )
82
+ from simulo.interfaces.platform.evaluation import (
83
+ ENDED_BY_TASK_TRUNCATION,
84
+ ENDED_BY_TIME_LIMIT,
85
+ EPISODES_CSV_CHECK_PREFIX,
86
+ EPISODES_CSV_COLUMNS_AFTER_CHECKS,
87
+ EPISODES_CSV_COLUMNS_BEFORE_CHECKS,
88
+ EVALUATION_ACTIONS,
89
+ EVALUATION_REPORT_SCHEMA_VERSION,
90
+ MAX_CHECK_NAME_CHARS,
91
+ MAX_CHECKS_PER_RULE,
92
+ MAX_END_CONDITION_NAME_CHARS,
93
+ MAX_END_CONDITIONS,
94
+ MAX_EVALUATION_HEADLINE_SUMMARY_CHARS,
95
+ MAX_FAILURE_GROUPS,
96
+ MAX_LISTED_EPISODES,
97
+ MAX_LISTED_SIMULATION_PROBLEMS,
98
+ MAX_NEXT_LINES,
99
+ MAX_RECORDED_EPISODES,
100
+ MAX_RECORDING_PROBLEM_CHARS,
101
+ CheckCount,
102
+ ComparisonVerdict,
103
+ EndingCount,
104
+ EvaluationActions,
105
+ EvaluationCheckpoint,
106
+ EvaluationComparison,
107
+ EvaluationHeadline,
108
+ EvaluationReport,
109
+ EvaluationResult,
110
+ EvaluationTask,
111
+ EvaluationTested,
112
+ FailureGroup,
113
+ LikelyRange,
114
+ ListedEpisode,
115
+ NextLine,
116
+ NextRule,
117
+ ReplayFrame,
118
+ ReplayPose,
119
+ ReplayTrace,
120
+ SimulationProblem,
121
+ SimulationProblemEpisode,
122
+ end_condition_names_problem,
123
+ episodes_csv_columns,
124
+ success_checks_problem,
125
+ )
82
126
  from simulo.interfaces.platform.job_events import (
83
127
  MAX_PUBLIC_ASSET_FINDING_COUNT,
84
128
  MAX_PUBLIC_JOB_EVENT_ATTEMPT,
@@ -241,6 +285,7 @@ from simulo.interfaces.platform.submit import (
241
285
  CHECKPOINT_UNAVAILABLE_CODE,
242
286
  CHECKPOINT_UPLOAD_MISSING_CODE,
243
287
  EXPORTS_ROUTE,
288
+ INPUT_CHECKPOINT_LATEST_AVAILABLE_META_KEY,
244
289
  INPUT_CHECKPOINT_NOT_ALLOWED_CODE,
245
290
  JOB_RECORDING_DOWNLOAD_ROUTE_TEMPLATE,
246
291
  JOB_RECORDINGS_ROUTE_TEMPLATE,
@@ -419,6 +464,7 @@ __all__ = [
419
464
  "WORKER_JOB_CHECKPOINTS_PRESIGN_ROUTE_TEMPLATE",
420
465
  "WORKER_JOB_CHECKPOINTS_ROUTE_TEMPLATE",
421
466
  "WORKER_CLAIM_INPUT_CHECKPOINT_FIELDS",
467
+ "INPUT_CHECKPOINT_LATEST_AVAILABLE_META_KEY",
422
468
  "WORKER_LEASE_HEADER",
423
469
  "MAX_PACKAGE_BYTES",
424
470
  "MAX_LOG_CHUNK_BYTES",
@@ -579,6 +625,51 @@ __all__ = [
579
625
  "PreviewReportRow",
580
626
  "PreviewReportStage",
581
627
  "PreviewReport",
628
+ # evaluation report wire contract — schema version, how an episode ended,
629
+ # the bounds that keep the report under the result cap, the report shapes,
630
+ # the replay trace filming reads, and episodes.csv
631
+ "EVALUATION_REPORT_SCHEMA_VERSION",
632
+ "MAX_EVALUATION_HEADLINE_SUMMARY_CHARS",
633
+ "ENDED_BY_TIME_LIMIT",
634
+ "ENDED_BY_TASK_TRUNCATION",
635
+ "MAX_END_CONDITION_NAME_CHARS",
636
+ "MAX_END_CONDITIONS",
637
+ "MAX_CHECKS_PER_RULE",
638
+ "MAX_CHECK_NAME_CHARS",
639
+ "MAX_FAILURE_GROUPS",
640
+ "MAX_RECORDED_EPISODES",
641
+ "MAX_RECORDING_PROBLEM_CHARS",
642
+ "MAX_LISTED_EPISODES",
643
+ "MAX_LISTED_SIMULATION_PROBLEMS",
644
+ "MAX_NEXT_LINES",
645
+ "end_condition_names_problem",
646
+ "success_checks_problem",
647
+ "EVALUATION_ACTIONS",
648
+ "EvaluationActions",
649
+ "ComparisonVerdict",
650
+ "NextRule",
651
+ "SimulationProblem",
652
+ "EvaluationCheckpoint",
653
+ "EvaluationTask",
654
+ "EvaluationTested",
655
+ "LikelyRange",
656
+ "EvaluationHeadline",
657
+ "CheckCount",
658
+ "EndingCount",
659
+ "FailureGroup",
660
+ "EvaluationResult",
661
+ "EvaluationComparison",
662
+ "ListedEpisode",
663
+ "SimulationProblemEpisode",
664
+ "NextLine",
665
+ "EvaluationReport",
666
+ "ReplayPose",
667
+ "ReplayFrame",
668
+ "ReplayTrace",
669
+ "EPISODES_CSV_COLUMNS_BEFORE_CHECKS",
670
+ "EPISODES_CSV_CHECK_PREFIX",
671
+ "EPISODES_CSV_COLUMNS_AFTER_CHECKS",
672
+ "episodes_csv_columns",
582
673
  # protocols
583
674
  "RuntimeProtocol",
584
675
  "AssetProtocol",
@@ -19,10 +19,10 @@ class JobType(StrEnum):
19
19
 
20
20
 
21
21
  #: Types a user can submit today. Platform-owned jobs use the remaining types.
22
- USER_SUBMITTABLE_JOB_TYPES = frozenset({JobType.PREVIEW, JobType.TRAIN, JobType.PLAY})
22
+ USER_SUBMITTABLE_JOB_TYPES = frozenset({JobType.PREVIEW, JobType.TRAIN, JobType.EVAL, JobType.PLAY})
23
23
 
24
24
  #: Declared job types whose user-submission workflows are not available yet.
25
- UNAVAILABLE_JOB_TYPES = frozenset({JobType.EVAL})
25
+ UNAVAILABLE_JOB_TYPES: frozenset[JobType] = frozenset()
26
26
 
27
27
  #: Job types whose submission may carry an input policy checkpoint.
28
28
  INPUT_CHECKPOINT_JOB_TYPES = frozenset({JobType.TRAIN, JobType.PLAY, JobType.EVAL})
@@ -0,0 +1,451 @@
1
+ """Evaluation report: what an eval job returns, writes and replays.
2
+
3
+ An eval job runs a trained checkpoint on numbered episodes and judges each
4
+ one with the eval file's ``@app.success`` function. The runtime returns the
5
+ report as a plain ``dict`` of :class:`EvaluationReport` shape and registers it
6
+ as a ``report`` output; the client renders it, the Console reads it from the
7
+ job result, and the control plane checks its schema version. Every episode's
8
+ detail goes in ``episodes.csv`` (:func:`episodes_csv_columns`), so the report
9
+ stays small whatever the episode count.
10
+
11
+ The comparison block (``--compare``) is part of v1 from the start, so comparing
12
+ two checkpoints never needs a new schema version. Additive-only: a breaking
13
+ reshape mints ``simulo.evaluation_report.v2``.
14
+
15
+ Percentages are whole numbers. Times are seconds from the episode's start.
16
+ Checkpoints are written ``policy_x:best`` or ``policy_x:latest``.
17
+ """
18
+
19
+ from typing import Final, Literal, NotRequired, Sequence, TypedDict
20
+
21
+ EVALUATION_REPORT_SCHEMA_VERSION: Final = "simulo.evaluation_report.v1"
22
+
23
+ # --- How an episode ended ----------------------------------------------------
24
+ #
25
+ # The report's "Why" column and the ``ended_by`` column of ``episodes.csv``
26
+ # hold exactly one of: the name of the task's end condition that ended the
27
+ # episode (a key of the dict ``get_dones()`` returns for ``terminated``),
28
+ # :data:`ENDED_BY_TIME_LIMIT`, or :data:`ENDED_BY_TASK_TRUNCATION`.
29
+
30
+ ENDED_BY_TIME_LIMIT: Final = "time limit"
31
+ """The episode reached the time limit: Simulo's step counter reached
32
+ ``max_episode_length - 1`` and no end condition was true. Reserved for that
33
+ boundary; it counts as surviving."""
34
+
35
+ ENDED_BY_TASK_TRUNCATION: Final = "cut short by the task"
36
+ """The task's ``truncated`` ended the episode before the time limit and no end
37
+ condition was true. Never counts as surviving."""
38
+
39
+ MAX_END_CONDITION_NAME_CHARS: Final = 64
40
+ """Longest name ``get_dones()`` may give an end condition."""
41
+
42
+ # --- Bounds ------------------------------------------------------------------
43
+ #
44
+ # The report is a job result, so it must fit the worker's result cap
45
+ # (``simulo.interfaces.platform.submit.MAX_RESULT_BYTES``, 1 MiB) whatever the
46
+ # episode count. Every list in it is bounded by one of these, and every name in
47
+ # it by a character limit, so its size does not grow with the episodes run.
48
+ # The ``get_dones()`` check enforces the end-condition bounds
49
+ # (:func:`end_condition_names_problem`); the evaluator enforces the check bounds
50
+ # (:func:`success_checks_problem`) and builds every list within its bound.
51
+
52
+ MAX_END_CONDITIONS: Final = 32
53
+ """Most end conditions ``get_dones()`` may name. A RESULT's ``endings`` holds
54
+ at most this many plus :data:`ENDED_BY_TIME_LIMIT` and
55
+ :data:`ENDED_BY_TASK_TRUNCATION`."""
56
+
57
+ MAX_CHECKS_PER_RULE: Final = 16
58
+ """Most checks one ``@app.success`` function may name with ``task.check()``."""
59
+
60
+ MAX_CHECK_NAME_CHARS: Final = 64
61
+ """Longest name ``task.check()`` may give a check."""
62
+
63
+ MAX_FAILURE_GROUPS: Final = 3
64
+ """Failure groups a RESULT lists; ``other_failure_combinations`` counts the rest."""
65
+
66
+ MAX_RECORDED_EPISODES: Final = 10
67
+ """Episodes an evaluation records."""
68
+
69
+ MAX_LISTED_EPISODES: Final = 13
70
+ """Episodes WENT WRONG lists: the recorded ones, then up to three more."""
71
+
72
+ MAX_LISTED_SIMULATION_PROBLEMS: Final = 10
73
+ """Episodes SIMULATION PROBLEMS lists; ``simulation_problem_episodes`` counts them all."""
74
+
75
+ MAX_NEXT_LINES: Final = 4
76
+ """NEXT lines: up to three rules that match, then the ``--compare`` offer."""
77
+
78
+ MAX_RECORDING_PROBLEM_CHARS: Final = 512
79
+ """Longest reason an evaluation can report when requested filming did not complete."""
80
+
81
+ MAX_EVALUATION_HEADLINE_SUMMARY_CHARS: Final = 512
82
+ """Longest summary the jobs list may carry for one evaluation, omitted when longer than this.
83
+
84
+ The full report is available from the evaluation job result. Policy job lists
85
+ only need a compact rendering input, so this cap prevents one worker result
86
+ from making an otherwise paginated response unbounded.
87
+ """
88
+
89
+ _RESERVED_ENDINGS: Final = {
90
+ ENDED_BY_TIME_LIMIT: "an episode that ran to the time limit",
91
+ ENDED_BY_TASK_TRUNCATION: "an episode your truncated ended before the time limit",
92
+ }
93
+
94
+
95
+ def _name_problem(source: str, noun: str, example: str, limit: int, name: object) -> str | None:
96
+ if not isinstance(name, str):
97
+ return f'{source} {noun} named {name!r}; name each one with a string, such as "{example}".'
98
+ if not name:
99
+ return f'{source} {noun} with an empty name; name each one, such as "{example}".'
100
+ if name != name.strip():
101
+ return f'{source} "{name}", which starts or ends with a space; name it "{name.strip()}".'
102
+ if len(name) > limit:
103
+ return f'{source} "{name}", a name of {len(name)} characters; names can be at most {limit} characters.'
104
+ return None
105
+
106
+
107
+ def end_condition_names_problem(names: Sequence[object]) -> str | None:
108
+ """What is wrong with the end-condition names ``get_dones()`` returned, or ``None``.
109
+
110
+ The message names the condition and says how to fix the task. Each name is
111
+ a non-empty string of at most :data:`MAX_END_CONDITION_NAME_CHARS`
112
+ characters with no leading or trailing space, and not one of the two words
113
+ the report uses for an episode no condition ended (in any case); there are
114
+ at most :data:`MAX_END_CONDITIONS` of them.
115
+ """
116
+
117
+ if len(names) > MAX_END_CONDITIONS:
118
+ return (
119
+ f"get_dones() returned {len(names)} end conditions; a task can name at most "
120
+ f"{MAX_END_CONDITIONS}, so combine some of them."
121
+ )
122
+ for name in names:
123
+ source = "get_dones() returned"
124
+ problem = _name_problem(source, "an end condition", "pole fell", MAX_END_CONDITION_NAME_CHARS, name)
125
+ if problem is None and isinstance(name, str) and name.casefold() in _RESERVED_ENDINGS:
126
+ meaning = _RESERVED_ENDINGS[name.casefold()]
127
+ problem = f'{source} "{name}", which the report uses for {meaning}; give the condition another name.'
128
+ if problem is not None:
129
+ return problem
130
+ return None
131
+
132
+
133
+ def success_checks_problem(function: str, names: Sequence[object]) -> str | None:
134
+ """What is wrong with the checks the ``@app.success`` function ``function`` named, or ``None``.
135
+
136
+ ``names`` are its ``task.check()`` names in call order. Each is a non-empty
137
+ string of at most :data:`MAX_CHECK_NAME_CHARS` characters with no leading or
138
+ trailing space; there are at most :data:`MAX_CHECKS_PER_RULE` of them.
139
+ """
140
+
141
+ if len(names) > MAX_CHECKS_PER_RULE:
142
+ return (
143
+ f"{function}() named {len(names)} checks with task.check(); a success rule can name at most "
144
+ f"{MAX_CHECKS_PER_RULE}, so combine some of them."
145
+ )
146
+ for name in names:
147
+ problem = _name_problem("task.check() got", "a check", "pole upright", MAX_CHECK_NAME_CHARS, name)
148
+ if problem is not None:
149
+ return problem
150
+ return None
151
+
152
+
153
+ EvaluationActions = Literal["best", "sampled"]
154
+ """``best`` is the policy's best-guess action, what you would deploy;
155
+ ``sampled`` keeps training's random variation on, as a diagnostic."""
156
+
157
+ EVALUATION_ACTIONS: Final = ("best", "sampled")
158
+
159
+ ComparisonVerdict = Literal["did_better", "did_worse", "no_clear_difference"]
160
+ """How the ``--policy`` checkpoint did against the ``--compare`` checkpoint,
161
+ judged on the episodes whose outcome changed (McNemar's exact test, p < 0.05)."""
162
+
163
+ NextRule = Literal[
164
+ "simulation_problems",
165
+ "never_learned",
166
+ "reward_pays_for_failure",
167
+ "one_check_missed",
168
+ "more_episodes",
169
+ "watch_worst_failure",
170
+ "all_succeeded",
171
+ "compare_latest",
172
+ ]
173
+ """Which rule produced a NEXT line. The first seven are checked in this order and
174
+ NEXT prints up to three that match: simulation problems; a likely range whose
175
+ top is under 10%; the failures' median total reward at least the successes';
176
+ the largest failure group missing exactly one check; a likely range wider than
177
+ 15 points or crossing ``--require-success``; some failures, when the second
178
+ rule did not match; every episode succeeded. ``compare_latest`` is the ``--compare policy_x:latest`` offer after them."""
179
+
180
+ SimulationProblem = Literal["reward_not_finite", "observation_not_finite", "observation_out_of_bounds"]
181
+ """What made a step unusable; an episode with one counts as a failure."""
182
+
183
+
184
+ class EvaluationCheckpoint(TypedDict):
185
+ """One checkpoint that was evaluated, from its sidecar."""
186
+
187
+ reference: str
188
+ """``policy_x:best`` or ``policy_x:latest``."""
189
+ policy_id: str
190
+ kind: Literal["best", "latest"]
191
+ iteration: int
192
+ training_reward: float | None
193
+ digest_sha256: str
194
+ """Digest of the checkpoint file, so a report names the exact weights."""
195
+
196
+
197
+ class EvaluationTask(TypedDict):
198
+ """The task the episodes ran, against the one the checkpoint was trained on."""
199
+
200
+ name: str
201
+ """The task class name."""
202
+ file: str
203
+ digest: str
204
+ """Hash of today's ``task.py``."""
205
+ trained_digest: str | None
206
+ """Hash of ``task.py`` when the checkpoint was trained; ``None`` when unknown."""
207
+ changed: bool
208
+ """The task-changed flag: the class name or the digest differs from training's."""
209
+ end_conditions: list[str]
210
+ """The names ``get_dones()`` gives its end conditions, in the task's order;
211
+ empty for a task that never ends an episode early. At most
212
+ :data:`MAX_END_CONDITIONS`."""
213
+
214
+
215
+ class EvaluationTested(TypedDict):
216
+ """WHAT WAS TESTED."""
217
+
218
+ checkpoint: EvaluationCheckpoint
219
+ task: EvaluationTask
220
+ first_episode: int
221
+ last_episode: int
222
+ """Episode numbers run, inclusive. Each number fixes its starting state."""
223
+ num_envs: int
224
+ actions: EvaluationActions
225
+ success_function: str
226
+ """The ``@app.success`` function's name, such as ``balanced``."""
227
+ success_file: str
228
+ checks: list[str]
229
+ """Every ``task.check()`` name, in the order the rule declares them; at most
230
+ :data:`MAX_CHECKS_PER_RULE`."""
231
+
232
+
233
+ class LikelyRange(TypedDict):
234
+ """95% Wilson score interval, low rounded down and high rounded up."""
235
+
236
+ low_percent: int
237
+ high_percent: int
238
+
239
+
240
+ class EvaluationHeadline(TypedDict):
241
+ """Compact result projection served on an evaluation job record."""
242
+
243
+ summary: str
244
+ """The report's first RESULT line, omitted when longer than :data:`MAX_EVALUATION_HEADLINE_SUMMARY_CHARS`."""
245
+ episodes: int
246
+ succeeded: int
247
+ likely_range: LikelyRange
248
+
249
+
250
+ class CheckCount(TypedDict):
251
+ """How many episodes passed one check on its own."""
252
+
253
+ name: str
254
+ passed: int
255
+
256
+
257
+ class EndingCount(TypedDict):
258
+ """How many episodes ended one way."""
259
+
260
+ ended_by: str
261
+ """A condition name, :data:`ENDED_BY_TIME_LIMIT` or :data:`ENDED_BY_TASK_TRUNCATION`."""
262
+ episodes: int
263
+
264
+
265
+ class FailureGroup(TypedDict):
266
+ """Failures grouped by exactly which checks were "no"."""
267
+
268
+ missed: list[str]
269
+ """Check names, in rule order; empty when the rule failed with every check passing."""
270
+ episodes: int
271
+
272
+
273
+ class EvaluationResult(TypedDict):
274
+ """RESULT for one checkpoint."""
275
+
276
+ checkpoint: str
277
+ """The checkpoint's ``reference``."""
278
+ episodes: int
279
+ succeeded: int
280
+ likely_range: LikelyRange
281
+ checks: list[CheckCount]
282
+ """One entry per check, in rule order."""
283
+ failure_groups: list[FailureGroup]
284
+ """Most common first, at most :data:`MAX_FAILURE_GROUPS`."""
285
+ other_failure_combinations: int
286
+ """How many further groups exist beyond ``failure_groups``. Their episodes
287
+ are the failures ``failure_groups`` doesn't hold, so the listed groups
288
+ account for every failure exactly when this is 0."""
289
+ endings: list[EndingCount]
290
+ """Every episode counted by how it ended, most common first."""
291
+ simulation_problem_episodes: int
292
+
293
+
294
+ class EvaluationComparison(TypedDict):
295
+ """The ``--compare`` verdict: the same episode numbers, run by both checkpoints."""
296
+
297
+ verdict: ComparisonVerdict
298
+ p_value: float
299
+ compared_with: EvaluationCheckpoint
300
+ """The ``--compare`` checkpoint."""
301
+ compared_result: EvaluationResult
302
+ """The ``--compare`` checkpoint's own RESULT."""
303
+ improved: int
304
+ """Episodes that failed with ``compared_with`` and succeeded with the ``--policy`` checkpoint."""
305
+ regressed: int
306
+ """Episodes that succeeded with ``compared_with`` and failed with the ``--policy`` checkpoint."""
307
+
308
+
309
+ class ListedEpisode(TypedDict):
310
+ """One episode in WENT WRONG: a failure, or a recorded success when all succeeded."""
311
+
312
+ episode: int
313
+ success: bool
314
+ ended_by: str
315
+ """A condition name, :data:`ENDED_BY_TIME_LIMIT` or :data:`ENDED_BY_TASK_TRUNCATION`."""
316
+ ended_at_s: float
317
+ total_reward: float
318
+ checks: dict[str, bool]
319
+ """Each check's value on the ending step, keyed by name in rule order."""
320
+ simulation_problem: SimulationProblem | None
321
+ recording: str | None
322
+ """The recording name for ``simulo recordings JOB <name>``, such as ``episode-64``;
323
+ ``None`` when the episode was not recorded."""
324
+
325
+
326
+ class SimulationProblemEpisode(TypedDict):
327
+ """One episode in SIMULATION PROBLEMS, at the first step that went wrong."""
328
+
329
+ episode: int
330
+ problem: SimulationProblem
331
+ at_s: float
332
+
333
+
334
+ class NextLine(TypedDict):
335
+ """One NEXT line: what to do, and the command that does it."""
336
+
337
+ rule: NextRule
338
+ text: str
339
+ command: str | None
340
+
341
+
342
+ class EvaluationReport(TypedDict):
343
+ """The whole report, as returned by the eval job body."""
344
+
345
+ report_schema_version: Literal["simulo.evaluation_report.v1"]
346
+ summary: str
347
+ """RESULT's first line, such as ``87 of 100 episodes succeeded. Likely range: 79% to 93%.``"""
348
+ task_changed: bool
349
+ """Same as ``tested.task.changed``; when true, ``notice`` leads the report."""
350
+ notice: str | None
351
+ """The first line when the task changed since training, such as
352
+ ``task.py has changed since policy_x was trained``; otherwise ``None``."""
353
+ runtime_version: str
354
+ """The ``simulo-backend`` version the evaluation ran on."""
355
+ tested: EvaluationTested
356
+ result: EvaluationResult
357
+ """The ``--policy`` checkpoint's RESULT."""
358
+ comparison: EvaluationComparison | None
359
+ """Set only for a ``--compare`` evaluation."""
360
+ failures: int
361
+ """How many episodes failed; ``listed_episodes`` holds a selection of them."""
362
+ recorded: int
363
+ """How many episodes have a recording, at most :data:`MAX_RECORDED_EPISODES`."""
364
+ listed_episodes: list[ListedEpisode]
365
+ """The recorded episodes in the order they were chosen, then up to three more
366
+ failures by lowest total reward (ties by episode number); at most
367
+ :data:`MAX_LISTED_EPISODES`."""
368
+ simulation_problems: list[SimulationProblemEpisode]
369
+ """The first :data:`MAX_LISTED_SIMULATION_PROBLEMS` episodes with a
370
+ simulation problem, in episode order."""
371
+ next: list[NextLine]
372
+ """At most :data:`MAX_NEXT_LINES`."""
373
+ recording_problem: NotRequired[str]
374
+ """Why requested filming did not complete, at most
375
+ :data:`MAX_RECORDING_PROBLEM_CHARS` characters. Omitted when filming succeeds
376
+ and by reports written before this additive v1 field existed."""
377
+
378
+
379
+ # --- Replay traces (filming) -------------------------------------------------
380
+ #
381
+ # While an evaluation runs, each counted episode buffers the state needed to
382
+ # replay it. The filming pass replays a kept trace frame by frame by setting
383
+ # that state, with no physics, and films it.
384
+
385
+
386
+ class ReplayPose(TypedDict):
387
+ """A world-frame pose: position in metres, orientation as a (w, x, y, z) quaternion."""
388
+
389
+ position: list[float]
390
+ orientation_wxyz: list[float]
391
+
392
+
393
+ class ReplayFrame(TypedDict):
394
+ """The robot's and the scene's state at one moment: the start, then after each policy step."""
395
+
396
+ time_s: float
397
+ joint_positions: list[float]
398
+ """One value per joint, in ``ReplayTrace.joint_names`` order: radians for a hinge, metres for a slider."""
399
+ joint_velocities: list[float]
400
+ root_pose: ReplayPose
401
+ object_poses: dict[str, ReplayPose]
402
+ """The scene's moving objects other than the robot, keyed by their scene path."""
403
+
404
+
405
+ class ReplayTrace(TypedDict):
406
+ """One episode, replayable without physics."""
407
+
408
+ episode: int
409
+ checkpoint: str
410
+ joint_names: list[str]
411
+ step_dt: float
412
+ """Seconds between frames."""
413
+ source_env_origin: list[float]
414
+ """The source environment's world-frame xyz origin as three finite metres.
415
+
416
+ Filming translates world-frame poses from this origin into its destination
417
+ environment.
418
+ """
419
+ frames: list[ReplayFrame]
420
+ """The first frame is the episode's starting state, ``time_s == 0``."""
421
+
422
+
423
+ # --- episodes.csv ------------------------------------------------------------
424
+ #
425
+ # One row per episode. Booleans are ``true``/``false``. ``sim_problem`` holds a
426
+ # ``SimulationProblem`` value and ``recording`` a recording name, each empty when
427
+ # there is none. ``first_observation`` is the policy's observation on the
428
+ # episode's first step, as a JSON list.
429
+
430
+ EPISODES_CSV_COLUMNS_BEFORE_CHECKS: Final = (
431
+ "episode",
432
+ "seed",
433
+ "checkpoint",
434
+ "success",
435
+ "ended_by",
436
+ "ended_at_s",
437
+ "total_reward",
438
+ "sim_problem",
439
+ )
440
+ EPISODES_CSV_CHECK_PREFIX: Final = "check:"
441
+ EPISODES_CSV_COLUMNS_AFTER_CHECKS: Final = ("first_observation", "recording")
442
+
443
+
444
+ def episodes_csv_columns(check_names: Sequence[str]) -> tuple[str, ...]:
445
+ """The ``episodes.csv`` header for a rule with these checks, in rule order."""
446
+
447
+ return (
448
+ *EPISODES_CSV_COLUMNS_BEFORE_CHECKS,
449
+ *(EPISODES_CSV_CHECK_PREFIX + name for name in check_names),
450
+ *EPISODES_CSV_COLUMNS_AFTER_CHECKS,
451
+ )
@@ -96,11 +96,11 @@ class Manifest:
96
96
  source regardless of which job or args were submitted; used for
97
97
  reproducibility / source caching.
98
98
  * ``package_id`` — the *submission* identity: a ``sha256`` over
99
- ``source_digest + job_name + canonical_json(args)``. It is also the
100
- package directory name. Two submits of the same source with a
101
- different job or args get a different ``package_id`` (a distinct
102
- package dir), so one submission never clobbers another's recorded
103
- ``args``.
99
+ ``source_digest + entrypoint.file + job_name + canonical_json(args)``.
100
+ It is also the package directory name. Two submits of the same source
101
+ with a different entry file, job, or args get a different ``package_id``
102
+ (a distinct package dir), so one submission never clobbers another's
103
+ recorded entrypoint or ``args``.
104
104
 
105
105
  ``manifest_version`` defaults to **2**. Every entry in ``jobs`` carries a
106
106
  required ``"type"`` key holding a :class:`~simulo.interfaces.platform.enums.JobType`
@@ -109,7 +109,8 @@ class Manifest:
109
109
 
110
110
  ⚠️ **Wire stability:** :meth:`to_json` bytes feed the package archive,
111
111
  whose sha256 the control plane pins per ``package_id`` — while
112
- ``package_id`` itself hashes only ``(source_digest, job_name, args)``.
112
+ ``package_id`` itself hashes only
113
+ ``(source_digest, entrypoint.file, job_name, args)``.
113
114
  Any change to this dataclass's fields or serialisation therefore shifts
114
115
  ``archive_sha256`` under an UNCHANGED ``package_id``, which is exactly
115
116
  the server's ``package_conflict`` 409 for every user resubmitting an
@@ -193,6 +193,7 @@ from typing import Any, Optional
193
193
  from simulo.interfaces.ids import JobId, JobPublicId, PackageId, parse_job_public_id
194
194
  from simulo.interfaces.platform._payload import known_payload_fields
195
195
  from simulo.interfaces.platform.enums import JobStatus, JobType
196
+ from simulo.interfaces.platform.evaluation import EvaluationHeadline
196
197
  from simulo.interfaces.platform.outputs import JobOutputsSummary, OutputFlag
197
198
 
198
199
  #: Default TCP port for the jobs API. Locally this is where ``simulo-backend
@@ -413,6 +414,16 @@ class JobRecord:
413
414
  input_checkpoint: Optional[str] = None
414
415
  """The policy checkpoint this job started from, or ``None`` for a fresh job."""
415
416
 
417
+ compare_checkpoint: Optional[str] = None
418
+ """The checkpoint an evaluation compares against, or ``None`` when it evaluates one policy."""
419
+
420
+ evaluation: Optional[EvaluationHeadline] = None
421
+ """Compact result for an evaluation job, or ``None`` for another job or an
422
+ evaluation whose report is not available. Only the policy job list fills
423
+ this field. The full evaluation report stays on the job result endpoint;
424
+ this list-safe projection carries only its summary, episode count,
425
+ successes, and likely success-rate range."""
426
+
416
427
  tags: tuple[str, ...] = ()
417
428
  """Immutable, normalized job tags in first-seen order."""
418
429
 
@@ -104,6 +104,11 @@ platform-standard shape: ``{"error": {"code": ..., "message": ..., "request_id":
104
104
  checkpoint belongs to one execution. Consumers therefore keep
105
105
  ``input_checkpoint`` outside ``args`` and outside the package manifest.
106
106
 
107
+ An evaluation submission may additionally carry ``compare_checkpoint`` at
108
+ the same top level. It uses the same policy checkpoint grammar and resolves
109
+ inside the selected organization. The two fields identify their roles: the
110
+ ``input_checkpoint`` is evaluated and ``compare_checkpoint`` is the baseline.
111
+
107
112
  **App name (live-view wave, additive):** the request body
108
113
  optionally carries ``app_name`` (``str``) — the submitting ``App``'s
109
114
  declared name, display context the platform's live viewer shows next to
@@ -502,6 +507,11 @@ WORKER_CLAIM_INPUT_CHECKPOINT_FIELDS = (
502
507
  "checkpoint_meta",
503
508
  )
504
509
 
510
+ #: Claim-time metadata fact stating whether the source policy currently has a
511
+ #: ``latest`` checkpoint. The control plane owns this availability snapshot;
512
+ #: workers carry it through the installed sidecar to ``PolicyCheckpoint.meta``.
513
+ INPUT_CHECKPOINT_LATEST_AVAILABLE_META_KEY = "input_checkpoint_latest_available"
514
+
505
515
  #: Request header carrying the claim's ``lease_id`` ownership token on every
506
516
  #: job-scoped worker call (heartbeat / logs / events / complete). A header — not a body
507
517
  #: field — because the logs route's body is raw bytes and heartbeat has no
@@ -87,6 +87,9 @@ class TerminationConditionProtocol(Protocol):
87
87
  mirror of ``simulo.core.terminations.TerminationCondition``.
88
88
  """
89
89
 
90
+ name: Optional[str]
91
+ """Optional plain-language name used to report this end condition."""
92
+
90
93
  def initialize(self, robot: RobotProtocol, device: str, num_envs: int) -> None:
91
94
  """One-time setup: cache the ``robot`` handle and size buffers for ``num_envs`` on ``device``."""
92
95
  ...
@@ -90,12 +90,24 @@ class LearningEnvProtocol(Protocol):
90
90
  def step(self, actions: TensorLike) -> Tuple[TensorLike, TensorLike, TensorLike, TensorLike, Dict[str, Any]]:
91
91
  """Advance one step with ``actions`` ``(num_envs, action_dim)``.
92
92
 
93
+ The environment passes a copy of ``actions`` to the task before
94
+ physics advances. Tasks can modify that copy in place; the original
95
+ batch remains available to recording as the policy's choice.
96
+
93
97
  Returns ``(observations, rewards, terminated, truncated, info)`` — the
94
98
  gym-style 5-tuple, each tensor batched over ``num_envs``. Environments
95
99
  that finish are auto-reset by the implementation.
96
100
  """
97
101
  ...
98
102
 
103
+ def seeded_reset(self, env_id: int, episode_number: int) -> None:
104
+ """Reset one environment after seeding global generators for an episode."""
105
+ ...
106
+
107
+ def add_pre_reset_hook(self, hook: Any) -> None:
108
+ """Register code to run after dones are collected and before reset."""
109
+ ...
110
+
99
111
  def close(self) -> None:
100
112
  """Shut the simulation down and release its resources."""
101
113
  ...
@@ -1,6 +1,6 @@
1
1
  """``TaskProtocol`` — structural mirror of ``simulo.core.task.Task`` (Part A)."""
2
2
 
3
- from typing import Any, Protocol, Tuple, runtime_checkable
3
+ from typing import Any, Dict, Protocol, Tuple, runtime_checkable
4
4
 
5
5
  # A REAL import, deliberately not TYPE_CHECKING-guarded: the guarded form left
6
6
  # ``typing.get_type_hints(TaskProtocol.build)`` raising ``NameError`` at
@@ -24,6 +24,11 @@ class TaskProtocol(Protocol):
24
24
 
25
25
  Structural mirror of ``simulo.core.task.Task``, the abstract base your task
26
26
  subclasses on the worker.
27
+
28
+ Each environment step calls ``on_pre_physics_step(actions)``, advances
29
+ physics, then calls ``on_post_physics_step()`` before collecting dones,
30
+ rewards, observations, and a pre-reset recording snapshot. It resets
31
+ environments whose episodes ended after that snapshot.
27
32
  """
28
33
 
29
34
  observation_dim: int
@@ -54,6 +59,25 @@ class TaskProtocol(Protocol):
54
59
  """
55
60
  ...
56
61
 
62
+ def on_pre_physics_step(self, actions: TensorLike) -> None:
63
+ """Process one policy action batch before physics advances.
64
+
65
+ ``LearningEnv.step`` gives this hook a copy of the policy actions, so
66
+ tasks may safely scale or otherwise modify ``actions`` in place. The
67
+ environment keeps the original batch for recording what the policy
68
+ chose.
69
+ """
70
+ return None
71
+
72
+ def on_post_physics_step(self) -> None:
73
+ """Run once after physics advances and before dones, rewards, and observations.
74
+
75
+ Use this hook for work that must run exactly once per environment step,
76
+ or for an expensive value shared by several later task methods. Values
77
+ cached here must also be set for reset environments in ``reset_idx``.
78
+ """
79
+ return None
80
+
57
81
  def get_observations(self) -> TensorLike:
58
82
  """Return the observation batch, shape ``(num_envs, observation_dim)``."""
59
83
  ...
@@ -62,16 +86,21 @@ class TaskProtocol(Protocol):
62
86
  """Return the per-step reward for every environment, shape ``(num_envs,)``."""
63
87
  ...
64
88
 
65
- def get_dones(self) -> Tuple[TensorLike, TensorLike]:
66
- """Return ``(terminated, truncated)`` boolean batches, each shape ``(num_envs,)``.
89
+ def get_dones(self) -> Tuple[Dict[str, TensorLike], TensorLike]:
90
+ """Return named termination batches and one truncated batch.
67
91
 
68
- ``terminated`` marks episodes ended by the task itself (success/failure);
69
- ``truncated`` marks episodes cut off by a time limit.
92
+ Each ``terminated`` value marks where the task ended the episode,
93
+ whatever the reason. ``truncated`` marks episodes cut off by a time
94
+ limit.
70
95
  """
71
96
  ...
72
97
 
73
98
  def apply_actions(self, actions: TensorLike) -> None:
74
- """Apply the policy's action batch, shape ``(num_envs, action_dim)``, to the sim."""
99
+ """Apply a copy of the policy's action batch to the sim.
100
+
101
+ ``actions`` has shape ``(num_envs, action_dim)``. It may be modified in
102
+ place without changing the policy action batch the environment records.
103
+ """
75
104
  ...
76
105
 
77
106
  def reset_idx(self, env_ids: TensorLike) -> None:
@@ -1,7 +1,7 @@
1
1
  """``TrainerProtocol`` — structural mirror of ``simulo.core.trainer.Trainer`` (Part A).
2
2
 
3
- The contract is deliberately behavioral — ``train`` / ``evaluate`` / ``save``
4
- / ``load`` and nothing else (re-affirmed at the Wave C4 mirror completion; the
3
+ The contract is deliberately behavioral — ``train`` / ``save`` / ``load`` and
4
+ nothing else (re-affirmed at the Wave C4 mirror completion; the
5
5
  executable half of this record is the documented-omissions allowlist in the
6
6
  backend suite's conformance engine): ``env`` / ``device`` / ``seed`` /
7
7
  ``log_dir`` are conveniences the base ``simulo.core.trainer.Trainer.__init__``
@@ -20,12 +20,12 @@ user-extensible ABC) does not guarantee. Implementations that CAN export (today:
20
20
  consumers feature-detect with ``isinstance(trainer, PolicyExporterProtocol)``.
21
21
  """
22
22
 
23
- from typing import Any, Dict, Optional, Protocol, runtime_checkable
23
+ from typing import Any, Dict, Protocol, runtime_checkable
24
24
 
25
25
 
26
26
  @runtime_checkable
27
27
  class TrainerProtocol(Protocol):
28
- """The training-orchestration contract — run, evaluate, and persist a training job.
28
+ """The training-orchestration contract — run and persist a training job.
29
29
 
30
30
  A trainer owns the RL loop: it drives an environment, updates the policy, and
31
31
  reads/writes checkpoints. Structural mirror of ``simulo.core.trainer.Trainer``.
@@ -39,14 +39,6 @@ class TrainerProtocol(Protocol):
39
39
  """
40
40
  ...
41
41
 
42
- def evaluate(self, checkpoint: Optional[str] = ..., num_episodes: int = ...) -> Dict[str, Any]:
43
- """Roll out the current (or ``checkpoint``-loaded) policy without learning.
44
-
45
- Returns evaluation metrics such as mean episode return over
46
- ``num_episodes`` episodes.
47
- """
48
- ...
49
-
50
42
  def save(self, path: str) -> None:
51
43
  """Write the trainer's full state (policy + optimizer) to ``path``."""
52
44
  ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo-interfaces
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Typed Python contracts for the Simulo cloud robotics SDK and managed simulation runtime.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -32,6 +32,7 @@ src/simulo/interfaces/platform/callbacks.py
32
32
  src/simulo/interfaces/platform/debug.py
33
33
  src/simulo/interfaces/platform/domain.py
34
34
  src/simulo/interfaces/platform/enums.py
35
+ src/simulo/interfaces/platform/evaluation.py
35
36
  src/simulo/interfaces/platform/job_events.py
36
37
  src/simulo/interfaces/platform/manifest.py
37
38
  src/simulo/interfaces/platform/onnx_bundle.py