praxis-eval 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. praxis_eval/__init__.py +97 -0
  2. praxis_eval/api.py +21 -0
  3. praxis_eval/artifacts.py +54 -0
  4. praxis_eval/contracts.py +87 -0
  5. praxis_eval/envs/__init__.py +5 -0
  6. praxis_eval/envs/async_vector_env.py +304 -0
  7. praxis_eval/envs/builtins.py +169 -0
  8. praxis_eval/envs/eval_pool.py +59 -0
  9. praxis_eval/envs/eval_registry.py +169 -0
  10. praxis_eval/envs/factory.py +318 -0
  11. praxis_eval/envs/libero/__init__.py +91 -0
  12. praxis_eval/envs/libero/config.py +231 -0
  13. praxis_eval/envs/libero/env.py +218 -0
  14. praxis_eval/envs/libero/eval.py +225 -0
  15. praxis_eval/envs/libero/output.py +24 -0
  16. praxis_eval/envs/libero/processor.py +24 -0
  17. praxis_eval/envs/libero/registration.py +104 -0
  18. praxis_eval/envs/libero/runtime.py +218 -0
  19. praxis_eval/envs/libero/spaces.py +118 -0
  20. praxis_eval/envs/libero/spec.py +76 -0
  21. praxis_eval/envs/libero/tasks.py +54 -0
  22. praxis_eval/envs/metaworld/__init__.py +83 -0
  23. praxis_eval/envs/metaworld/_compat.py +30 -0
  24. praxis_eval/envs/metaworld/config.py +84 -0
  25. praxis_eval/envs/metaworld/env.py +217 -0
  26. praxis_eval/envs/metaworld/eval.py +156 -0
  27. praxis_eval/envs/metaworld/registration.py +95 -0
  28. praxis_eval/envs/metaworld/runtime.py +178 -0
  29. praxis_eval/envs/metaworld/spec.py +26 -0
  30. praxis_eval/envs/metaworld/tasks.py +167 -0
  31. praxis_eval/envs/mshab/__init__.py +35 -0
  32. praxis_eval/envs/mshab/eval.py +457 -0
  33. praxis_eval/envs/mshab/registration.py +24 -0
  34. praxis_eval/envs/robocasa/__init__.py +75 -0
  35. praxis_eval/envs/robocasa/config.py +123 -0
  36. praxis_eval/envs/robocasa/datasets.py +147 -0
  37. praxis_eval/envs/robocasa/env.py +646 -0
  38. praxis_eval/envs/robocasa/eval.py +208 -0
  39. praxis_eval/envs/robocasa/processor.py +106 -0
  40. praxis_eval/envs/robocasa/runtime.py +351 -0
  41. praxis_eval/envs/robocasa/state.py +70 -0
  42. praxis_eval/envs/robocasa/tasks.py +126 -0
  43. praxis_eval/envs/robomimic/__init__.py +75 -0
  44. praxis_eval/envs/robomimic/config.py +112 -0
  45. praxis_eval/envs/robomimic/env.py +326 -0
  46. praxis_eval/envs/robomimic/eval.py +221 -0
  47. praxis_eval/envs/robomimic/processor.py +89 -0
  48. praxis_eval/envs/robomimic/runtime.py +295 -0
  49. praxis_eval/envs/robomimic/state.py +42 -0
  50. praxis_eval/envs/robomimic/tasks.py +124 -0
  51. praxis_eval/envs/simpler/__init__.py +28 -0
  52. praxis_eval/envs/simpler/eval.py +362 -0
  53. praxis_eval/envs/simpler/registration.py +24 -0
  54. praxis_eval/envs/subprocess_runtime.py +589 -0
  55. praxis_eval/evaluation/__init__.py +40 -0
  56. praxis_eval/evaluation/artifacts.py +54 -0
  57. praxis_eval/evaluation/config.py +127 -0
  58. praxis_eval/evaluation/overrides.py +127 -0
  59. praxis_eval/evaluation/sim/__init__.py +15 -0
  60. praxis_eval/evaluation/sim/adapters.py +165 -0
  61. praxis_eval/evaluation/sim/diagnostics.py +105 -0
  62. praxis_eval/evaluation/sim/metrics.py +133 -0
  63. praxis_eval/evaluation/sim/rollout_compat.py +695 -0
  64. praxis_eval/evaluation/sim/runner.py +319 -0
  65. praxis_eval/evaluation/sim/wave_retry.py +245 -0
  66. praxis_eval/evaluation/watchdog.py +213 -0
  67. praxis_eval/managed_paths.py +18 -0
  68. praxis_eval/metrics.py +70 -0
  69. praxis_eval/policies/__init__.py +18 -0
  70. praxis_eval/policies/actions.py +47 -0
  71. praxis_eval/policies/local.py +66 -0
  72. praxis_eval/policies/remote.py +65 -0
  73. praxis_eval/processing.py +116 -0
  74. praxis_eval/registry.py +72 -0
  75. praxis_eval/scripts/__init__.py +5 -0
  76. praxis_eval/scripts/_conda_runtime.py +131 -0
  77. praxis_eval/scripts/_dispatch.py +55 -0
  78. praxis_eval/scripts/_package_sources.py +126 -0
  79. praxis_eval/scripts/_verify_common.py +144 -0
  80. praxis_eval/scripts/setup.py +27 -0
  81. praxis_eval/scripts/setup_mshab.py +310 -0
  82. praxis_eval/scripts/setup_robocasa.py +119 -0
  83. praxis_eval/scripts/setup_simpler.py +305 -0
  84. praxis_eval/scripts/verify.py +30 -0
  85. praxis_eval/scripts/verify_libero.py +197 -0
  86. praxis_eval/scripts/verify_metaworld.py +171 -0
  87. praxis_eval/scripts/verify_mshab.py +260 -0
  88. praxis_eval/scripts/verify_robocasa.py +113 -0
  89. praxis_eval/scripts/verify_robomimic.py +175 -0
  90. praxis_eval/scripts/verify_simpler.py +103 -0
  91. praxis_eval/types.py +82 -0
  92. praxis_eval-0.1.0.dist-info/METADATA +506 -0
  93. praxis_eval-0.1.0.dist-info/RECORD +98 -0
  94. praxis_eval-0.1.0.dist-info/WHEEL +5 -0
  95. praxis_eval-0.1.0.dist-info/entry_points.txt +3 -0
  96. praxis_eval-0.1.0.dist-info/licenses/LICENSE +181 -0
  97. praxis_eval-0.1.0.dist-info/licenses/NOTICE +7 -0
  98. praxis_eval-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Standalone robot-policy evaluation harness."""
6
+
7
+ from praxis_eval.api import evaluate
8
+ from praxis_eval.contracts import ActionSpec, EnvContract, ObservationKey
9
+ from praxis_eval.evaluation.config import (
10
+ env_kwargs_without_type_task,
11
+ env_type_from_cfg,
12
+ optional_env_task,
13
+ optional_env_task_ids,
14
+ require_positive_int,
15
+ resolve_nonnegative_int,
16
+ resolve_optional_positive_timeout_sec,
17
+ resolve_policy_kwargs,
18
+ )
19
+ from praxis_eval.evaluation.watchdog import (
20
+ EvalPhaseWatchdog,
21
+ resolve_phase_watchdog_threshold_sec,
22
+ )
23
+ from praxis_eval.policies.actions import normalize_batched_action
24
+ from praxis_eval.policies.local import LocalPolicy
25
+ from praxis_eval.registry import (
26
+ EvalDriver,
27
+ available_drivers,
28
+ get_driver,
29
+ register_driver,
30
+ )
31
+ from praxis_eval.types import (
32
+ EvalConfig,
33
+ EvalResult,
34
+ EvalRuntimeHooks,
35
+ Observation,
36
+ Policy,
37
+ )
38
+
39
+ __all__ = [
40
+ "ActionSpec",
41
+ "EnvContract",
42
+ "EvalConfig",
43
+ "EvalDriver",
44
+ "EvalPhaseWatchdog",
45
+ "EvalResult",
46
+ "EvalRuntimeHooks",
47
+ "LocalPolicy",
48
+ "Observation",
49
+ "ObservationKey",
50
+ "Policy",
51
+ "RemotePolicy",
52
+ "available_drivers",
53
+ "build_eval_overrides_from_train_config",
54
+ "env_kwargs_without_type_task",
55
+ "env_type_from_cfg",
56
+ "evaluate",
57
+ "get_driver",
58
+ "infer_eval_env_target",
59
+ "normalize_batched_action",
60
+ "optional_env_task",
61
+ "optional_env_task_ids",
62
+ "register_driver",
63
+ "normalize_eval_overrides",
64
+ "require_positive_int",
65
+ "resolve_eval_step_dir",
66
+ "resolve_nonnegative_int",
67
+ "resolve_optional_positive_timeout_sec",
68
+ "resolve_phase_watchdog_threshold_sec",
69
+ "resolve_policy_kwargs",
70
+ ]
71
+
72
+
73
+ def __getattr__(name: str):
74
+ """Lazily expose optional remote policy support."""
75
+ if name == "RemotePolicy":
76
+ from praxis_eval.policies.remote import RemotePolicy
77
+
78
+ return RemotePolicy
79
+ if name == "infer_eval_env_target":
80
+ from praxis_eval.envs.factory import infer_eval_env_target
81
+
82
+ return infer_eval_env_target
83
+ if name == "resolve_eval_step_dir":
84
+ from praxis_eval.evaluation.artifacts import resolve_eval_step_dir
85
+
86
+ return resolve_eval_step_dir
87
+ if name == "build_eval_overrides_from_train_config":
88
+ from praxis_eval.evaluation.overrides import (
89
+ build_eval_overrides_from_train_config,
90
+ )
91
+
92
+ return build_eval_overrides_from_train_config
93
+ if name == "normalize_eval_overrides":
94
+ from praxis_eval.evaluation.overrides import normalize_eval_overrides
95
+
96
+ return normalize_eval_overrides
97
+ raise AttributeError(name)
praxis_eval/api.py ADDED
@@ -0,0 +1,21 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Top-level evaluation API."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from praxis_eval.registry import EvalDriver, get_driver
10
+ from praxis_eval.types import EvalConfig, EvalResult, Policy
11
+
12
+
13
+ def evaluate(
14
+ env: str | EvalDriver,
15
+ *,
16
+ policy: Policy,
17
+ config: EvalConfig,
18
+ ) -> EvalResult:
19
+ """Evaluate ``policy`` with a registered env driver or explicit driver."""
20
+ driver = get_driver(env) if isinstance(env, str) else env
21
+ return driver.evaluate(policy=policy, config=config)
@@ -0,0 +1,54 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Evaluation artifact helpers."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict, is_dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def resolve_artifact_dirs(output_dir: str | Path) -> tuple[Path, Path]:
17
+ """Create and return ``(output_dir, media_dir)``."""
18
+ root = Path(output_dir)
19
+ media = root / "media"
20
+ root.mkdir(parents=True, exist_ok=True)
21
+ media.mkdir(parents=True, exist_ok=True)
22
+ return root, media
23
+
24
+
25
+ def write_results_json(
26
+ result: Any,
27
+ output_dir: str | Path,
28
+ *,
29
+ filename: str = "results.json",
30
+ metadata: dict[str, Any] | None = None,
31
+ ) -> Path:
32
+ """Write an evaluation result payload to disk."""
33
+ root = Path(output_dir)
34
+ root.mkdir(parents=True, exist_ok=True)
35
+ payload = _jsonable(result)
36
+ if not isinstance(payload, dict):
37
+ raise TypeError(f"result must serialize to a dict, got {type(payload)!r}")
38
+ payload["_meta"] = {
39
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
40
+ **(metadata or {}),
41
+ }
42
+ path = root / filename
43
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
44
+ return path
45
+
46
+
47
+ def _jsonable(value: Any) -> Any:
48
+ if is_dataclass(value):
49
+ return _jsonable(asdict(value))
50
+ if isinstance(value, dict):
51
+ return {str(key): _jsonable(item) for key, item in value.items()}
52
+ if isinstance(value, (list, tuple)):
53
+ return [_jsonable(item) for item in value]
54
+ return value
@@ -0,0 +1,87 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Documented observation and action contracts for eval environments."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ObservationKey:
17
+ """One observation key emitted by an environment."""
18
+
19
+ key: str
20
+ dtype: str
21
+ shape: tuple[int | str, ...] | None = None
22
+ description: str = ""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ActionSpec:
27
+ """Action contract expected by an environment."""
28
+
29
+ shape: tuple[int, ...] | None
30
+ dtype: str = "float32"
31
+ minimum: float | None = None
32
+ maximum: float | None = None
33
+ convention: str = ""
34
+ description: str = ""
35
+
36
+ def validate(self, action: np.ndarray) -> np.ndarray:
37
+ """Validate and return ``action`` as a numpy array."""
38
+ arr = np.asarray(action)
39
+ if self.shape is not None and tuple(arr.shape) != tuple(self.shape):
40
+ raise ValueError(
41
+ f"Action shape {tuple(arr.shape)} does not match expected {self.shape}."
42
+ )
43
+ expected_dtype = np.dtype(self.dtype)
44
+ if arr.dtype != expected_dtype:
45
+ raise TypeError(
46
+ f"Action dtype {arr.dtype} does not match {expected_dtype}."
47
+ )
48
+ if not np.all(np.isfinite(arr)):
49
+ raise ValueError("Action contains non-finite values.")
50
+ if self.minimum is not None and np.any(arr < self.minimum):
51
+ raise ValueError(f"Action contains values below {self.minimum}.")
52
+ if self.maximum is not None and np.any(arr > self.maximum):
53
+ raise ValueError(f"Action contains values above {self.maximum}.")
54
+ return arr
55
+
56
+ def validate_batch(self, actions: np.ndarray, *, batch_size: int) -> np.ndarray:
57
+ """Validate and return a batched action array."""
58
+ arr = np.asarray(actions)
59
+ if batch_size < 1:
60
+ raise ValueError("batch_size must be >= 1")
61
+ if self.shape is None:
62
+ if int(arr.shape[0]) != int(batch_size):
63
+ raise ValueError(
64
+ f"Batched action leading dim {arr.shape[0]} does not match {batch_size}."
65
+ )
66
+ for row in arr:
67
+ self.validate(row)
68
+ return arr
69
+ expected = (int(batch_size), *tuple(self.shape))
70
+ if tuple(arr.shape) != expected:
71
+ raise ValueError(
72
+ f"Batched action shape {tuple(arr.shape)} does not match {expected}."
73
+ )
74
+ for row in arr:
75
+ self.validate(row)
76
+ return arr
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class EnvContract:
81
+ """Observation/action contract for one eval environment family."""
82
+
83
+ env_type: str
84
+ observation_keys: tuple[ObservationKey, ...]
85
+ action: ActionSpec
86
+ notes: str = ""
87
+ metadata: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Built-in benchmark environment runtimes for praxis-eval."""
@@ -0,0 +1,304 @@
1
+ # SPDX-FileCopyrightText: 2026 Chaoqi Liu
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Custom AsyncVectorEnv with optional ``dummy_env_fn`` bootstrap."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import multiprocessing
10
+ import time
11
+ from collections.abc import Callable, Sequence
12
+ from contextlib import suppress
13
+ from typing import Any, cast
14
+
15
+ import gymnasium as gym
16
+ import numpy as np
17
+ from gymnasium import Space
18
+ from gymnasium.core import Env
19
+ from gymnasium.error import AlreadyPendingCallError, CustomSpaceError, NoAsyncCallError
20
+ from gymnasium.vector.async_vector_env import AsyncState, _async_worker
21
+ from gymnasium.vector.utils import (
22
+ CloudpickleWrapper,
23
+ batch_space,
24
+ clear_mpi_env_vars,
25
+ create_empty_array,
26
+ create_shared_memory,
27
+ read_from_shared_memory,
28
+ )
29
+ from gymnasium.vector.vector_env import AutoresetMode
30
+
31
+
32
+ class AsyncVectorEnvCallTimeout(RuntimeError):
33
+ """Raised when ``call_each`` times out waiting on one or more lanes."""
34
+
35
+ def __init__(
36
+ self, *, method: str, timeout_sec: float | int, pending_lanes: list[int]
37
+ ):
38
+ self.details = {"method": str(method), "pending_lanes": list(pending_lanes)}
39
+ super().__init__(
40
+ "AsyncVectorEnv.call_each timed out after "
41
+ f"{float(timeout_sec):.3f}s for method={method!r}, "
42
+ f"pending_lanes={pending_lanes}."
43
+ )
44
+
45
+
46
+ class AsyncVectorEnv(gym.vector.AsyncVectorEnv):
47
+ """Drop-in replacement for ``gymnasium.vector.AsyncVectorEnv`` that
48
+ accepts an optional *dummy_env_fn* used only to read
49
+ observation/action spaces in the parent process.
50
+
51
+ When ``dummy_env_fn`` is provided the parent never calls
52
+ ``env_fns[0]()`` — real environment constructors run exclusively
53
+ inside child workers, avoiding EGL/MuJoCo state corruption under
54
+ fork-based multiprocessing.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ env_fns: Sequence[Callable[[], Env]],
60
+ *,
61
+ dummy_env_fn: Callable[[], Env] | None = None,
62
+ shared_memory: bool = True,
63
+ copy: bool = True,
64
+ context: str | None = None,
65
+ daemon: bool = True,
66
+ worker: Callable | None = None,
67
+ observation_mode: str | Space = "same",
68
+ autoreset_mode: str | AutoresetMode = AutoresetMode.NEXT_STEP,
69
+ ):
70
+ # ---- store attributes (mirrors gymnasium) ----
71
+ self.env_fns = env_fns
72
+ self.shared_memory = shared_memory
73
+ self.copy = copy
74
+ self.context = context
75
+ self.daemon = daemon
76
+ self.worker = worker
77
+ self.observation_mode = observation_mode
78
+ self.autoreset_mode = (
79
+ autoreset_mode
80
+ if isinstance(autoreset_mode, AutoresetMode)
81
+ else AutoresetMode(autoreset_mode)
82
+ )
83
+
84
+ self.num_envs = len(env_fns)
85
+
86
+ # >>> Only change from gymnasium: use dummy_env_fn when provided <<<
87
+ dummy_env = (dummy_env_fn or env_fns[0])()
88
+
89
+ self.metadata = dummy_env.metadata
90
+ self.metadata["autoreset_mode"] = self.autoreset_mode
91
+ self.render_mode = dummy_env.render_mode
92
+
93
+ self.single_action_space = dummy_env.action_space
94
+ self.action_space = batch_space(self.single_action_space, self.num_envs)
95
+
96
+ if isinstance(observation_mode, tuple) and len(observation_mode) == 2:
97
+ assert isinstance(observation_mode[0], Space)
98
+ assert isinstance(observation_mode[1], Space)
99
+ self.observation_space, self.single_observation_space = observation_mode
100
+ else:
101
+ if observation_mode == "same":
102
+ self.single_observation_space = dummy_env.observation_space
103
+ self.observation_space = batch_space(
104
+ self.single_observation_space, self.num_envs
105
+ )
106
+ elif observation_mode == "different":
107
+ if dummy_env_fn is not None:
108
+ raise ValueError(
109
+ "AsyncVectorEnv cannot infer observation_mode='different' "
110
+ "from real env_fns when dummy_env_fn is set. Pass explicit "
111
+ "(batch_space, single_space) observation spaces instead."
112
+ )
113
+ env_spaces = [env().observation_space for env in self.env_fns]
114
+ self.single_observation_space = env_spaces[0]
115
+ from gymnasium.vector.utils import batch_differing_spaces
116
+
117
+ self.observation_space = batch_differing_spaces(env_spaces)
118
+ else:
119
+ raise ValueError(
120
+ f"Invalid `observation_mode`, expected: 'same' or 'different' "
121
+ f"or tuple of single and batch observation space, actual got {observation_mode}"
122
+ )
123
+
124
+ dummy_env.close()
125
+ del dummy_env
126
+
127
+ # ---- shared memory / observation buffer (identical to gymnasium) ----
128
+ ctx = cast(Any, multiprocessing.get_context(context))
129
+ if self.shared_memory:
130
+ try:
131
+ _obs_buffer = create_shared_memory(
132
+ self.single_observation_space, n=self.num_envs, ctx=ctx
133
+ )
134
+ self.observations = read_from_shared_memory(
135
+ self.single_observation_space, _obs_buffer, n=self.num_envs
136
+ )
137
+ except CustomSpaceError as e:
138
+ raise ValueError(
139
+ "Using `AsyncVector(..., shared_memory=True)` caused an error, "
140
+ "you can disable this feature with `shared_memory=False` however this is slower."
141
+ ) from e
142
+ else:
143
+ _obs_buffer = None
144
+ self.observations = create_empty_array(
145
+ self.single_observation_space, n=self.num_envs, fn=np.zeros
146
+ )
147
+
148
+ # ---- spawn worker processes (identical to gymnasium) ----
149
+ self.parent_pipes, self.processes = [], []
150
+ self.error_queue = ctx.Queue()
151
+ target = worker or _async_worker
152
+ with clear_mpi_env_vars():
153
+ for idx, env_fn in enumerate(self.env_fns):
154
+ parent_pipe, child_pipe = ctx.Pipe()
155
+ process = ctx.Process(
156
+ target=target,
157
+ name=f"Worker<{type(self).__name__}>-{idx}",
158
+ args=(
159
+ idx,
160
+ CloudpickleWrapper(env_fn),
161
+ child_pipe,
162
+ parent_pipe,
163
+ _obs_buffer,
164
+ self.error_queue,
165
+ self.autoreset_mode,
166
+ ),
167
+ )
168
+
169
+ self.parent_pipes.append(parent_pipe)
170
+ self.processes.append(process)
171
+
172
+ process.daemon = daemon
173
+ process.start()
174
+ child_pipe.close()
175
+
176
+ self._state = AsyncState.DEFAULT
177
+ self._check_spaces()
178
+
179
+ def _abort_on_call_timeout(self) -> None:
180
+ """Destroy the pool on ``call_each`` timeout.
181
+
182
+ Re-using an AsyncVectorEnv after a timed-out ``call_each`` is unsafe:
183
+ worker processes may still be executing the first call and their
184
+ pending responses would shift the pipe read offsets of any subsequent
185
+ command, surfacing as spurious AttributeError / garbled return values
186
+ long after the original timeout. Closing parent pipes + terminating
187
+ workers makes every subsequent operation raise immediately.
188
+ """
189
+ with suppress(Exception):
190
+ self.close(terminate=True)
191
+ self._state = AsyncState.DEFAULT
192
+
193
+ def _poll_parent_pipes(self, timeout: int | float | None = None) -> bool:
194
+ """Poll all parent pipes until timeout, returning ``True`` if all are ready."""
195
+ self._assert_is_running()
196
+ if timeout is None:
197
+ return True
198
+ end_time = time.perf_counter() + float(timeout)
199
+ for pipe in self.parent_pipes:
200
+ remaining = max(end_time - time.perf_counter(), 0.0)
201
+ if pipe is None:
202
+ return False
203
+ if pipe.closed or (not pipe.poll(remaining)):
204
+ return False
205
+ return True
206
+
207
+ def call_each(
208
+ self,
209
+ name: str,
210
+ *,
211
+ args_list: Sequence[Sequence[Any] | Any] | None = None,
212
+ kwargs_list: Sequence[dict[str, Any]] | None = None,
213
+ timeout: float | None = None,
214
+ ) -> tuple[Any, ...]:
215
+ """Call ``name`` on each worker with lane-specific args/kwargs.
216
+
217
+ This is the per-lane companion to ``call``/``call_async`` where each
218
+ worker receives its own argument tuple.
219
+ """
220
+ self._assert_is_running()
221
+ if self._state != AsyncState.DEFAULT:
222
+ raise AlreadyPendingCallError(
223
+ f"Calling `call_each` while waiting for `{self._state.value}` to complete.",
224
+ str(self._state.value),
225
+ )
226
+
227
+ n_envs = len(self.parent_pipes)
228
+ if args_list is None:
229
+ args_list = [()] * n_envs
230
+ if kwargs_list is None:
231
+ kwargs_list = [{}] * n_envs
232
+
233
+ if len(args_list) != n_envs:
234
+ raise ValueError(
235
+ f"args_list length must be {n_envs}, got {len(args_list)}."
236
+ )
237
+ if len(kwargs_list) != n_envs:
238
+ raise ValueError(
239
+ f"kwargs_list length must be {n_envs}, got {len(kwargs_list)}."
240
+ )
241
+
242
+ normalized_args: list[tuple[Any, ...]] = []
243
+ for lane_args in args_list:
244
+ if isinstance(lane_args, tuple):
245
+ normalized_args.append(lane_args)
246
+ elif isinstance(lane_args, list):
247
+ normalized_args.append(tuple(lane_args))
248
+ else:
249
+ normalized_args.append((lane_args,))
250
+
251
+ for idx, pipe in enumerate(self.parent_pipes):
252
+ lane_kwargs = dict(kwargs_list[idx])
253
+ pipe.send(("_call", (name, normalized_args[idx], lane_kwargs)))
254
+ self._state = AsyncState.WAITING_CALL
255
+ timeout_sec = 0.0 if timeout is None else float(timeout)
256
+
257
+ self._assert_is_running()
258
+ if self._state != AsyncState.WAITING_CALL:
259
+ raise NoAsyncCallError(
260
+ "Calling `call_each` receive path without pending call.",
261
+ AsyncState.WAITING_CALL.value,
262
+ )
263
+
264
+ poll_fn = getattr(self, "_poll_pipe_envs", None)
265
+ ready = (
266
+ bool(poll_fn(timeout))
267
+ if callable(poll_fn)
268
+ else self._poll_parent_pipes(timeout)
269
+ )
270
+ if not ready:
271
+ pending_lanes = [
272
+ lane_idx
273
+ for lane_idx, pipe in enumerate(self.parent_pipes)
274
+ if not pipe.poll(0.0)
275
+ ]
276
+ if not pending_lanes:
277
+ pending_lanes = list(range(len(self.parent_pipes)))
278
+ self._abort_on_call_timeout()
279
+ raise AsyncVectorEnvCallTimeout(
280
+ method=name,
281
+ timeout_sec=timeout_sec,
282
+ pending_lanes=pending_lanes,
283
+ )
284
+
285
+ deadline = time.perf_counter() + timeout_sec if timeout is not None else None
286
+ results_with_status: list[tuple[Any, bool]] = []
287
+ for lane_idx, pipe in enumerate(self.parent_pipes):
288
+ if deadline is not None:
289
+ remaining = max(deadline - time.perf_counter(), 0.0)
290
+ if not pipe.poll(remaining):
291
+ pending_lanes = [lane_idx]
292
+ pending_lanes.extend(range(lane_idx + 1, len(self.parent_pipes)))
293
+ self._abort_on_call_timeout()
294
+ raise AsyncVectorEnvCallTimeout(
295
+ method=name,
296
+ timeout_sec=timeout_sec,
297
+ pending_lanes=pending_lanes,
298
+ )
299
+ results_with_status.append(pipe.recv())
300
+
301
+ results, successes = zip(*results_with_status, strict=True)
302
+ self._raise_if_errors(successes)
303
+ self._state = AsyncState.DEFAULT
304
+ return results