Skip to content

Commit e160386

Browse files
authored
Merge pull request #901 from rutayan-nv/rpatro/env-params-first-class
fix(configurator): make env_params first-class to fix the trajectory cache key
2 parents 79f8bdb + 2437aa7 commit e160386

9 files changed

Lines changed: 1240 additions & 23 deletions

File tree

src/cloudai/_core/test_scenario.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import TYPE_CHECKING, Any, List, Optional, Set, Type, TypeAlias, Union
2424

2525
from ..util import flatten_dict
26+
from .registry import Registry
2627
from .system import System
2728

2829
if TYPE_CHECKING:
@@ -140,6 +141,22 @@ def get_metric_value(self, system: System, metric: str) -> MetricValue:
140141
def is_dse_job(self) -> bool:
141142
return self.test.is_dse_job or isinstance(self.num_nodes, list)
142143

144+
@property
145+
def is_domain_randomization_active(self) -> bool:
146+
"""
147+
Whether this run will actually env-sample (domain-randomize) per trial.
148+
149+
True only when domain randomization is declared (``env_params`` present), the run is a DSE
150+
job (so a per-trial loop exists - including a ``num_nodes`` sweep), and the agent opts into
151+
sampling. An unknown agent is treated as opted-in so the dedicated agent-resolution error
152+
surfaces instead of this one.
153+
"""
154+
if not self.test.is_domain_randomization_enabled:
155+
return False
156+
157+
agent = Registry().agents_map.get(self.test.agent)
158+
return self.is_dse_job and (agent is None or agent.supports_variable_environment)
159+
143160
@property
144161
def nnodes(self) -> int:
145162
"""Type safe getter for num_nodes, should only be used on an unrolled DSE job."""
@@ -156,7 +173,9 @@ def param_space(self) -> dict[str, Any]:
156173
**{
157174
key: value
158175
for key, value in cmd_args_dict.items()
159-
if isinstance(value, list) and not self.test.is_dse_excluded_arg(key)
176+
if isinstance(value, list)
177+
and not self.test.is_dse_excluded_arg(key)
178+
and not self.test.is_env_sampled(key)
160179
},
161180
**{f"extra_env_vars.{key}": value for key, value in extra_env_vars_dict.items() if isinstance(value, list)},
162181
}
@@ -184,9 +203,13 @@ def all_combinations(self) -> list[dict[str, Any]]:
184203

185204
return all_combinations
186205

187-
def apply_params_set(self, action: dict[str, Any]) -> "TestRun":
206+
def apply_params_set(self, action: dict[str, Any], env_params: dict[str, Any] | None = None) -> "TestRun":
188207
tdef = self.test.model_copy(deep=True)
189-
for key, value in action.items():
208+
209+
# RNG runs in the env before this call; applying only concrete values keeps this deterministic.
210+
# action and env_params target disjoint keys, so a plain merge applies both in one pass.
211+
full_action = action | (env_params or {})
212+
for key, value in full_action.items():
190213
if key.startswith("extra_env_vars."):
191214
tdef.extra_env_vars[key[len("extra_env_vars.") :]] = value
192215
else:
@@ -199,7 +222,12 @@ def apply_params_set(self, action: dict[str, Any]) -> "TestRun":
199222
else:
200223
setattr(obj, attrs[-1], value)
201224

202-
type(tdef)(**tdef.model_dump()) # trigger validation
225+
# env_params is validated at parse time; after the overlay its target cmd_args fields hold
226+
# concrete scalar draws, so re-validating it here would reject weighted specs. Drop it for
227+
# this validation-only pass, which exists to validate the applied action values.
228+
validation_args = tdef.model_dump()
229+
validation_args.pop("env_params", None)
230+
type(tdef)(**validation_args) # trigger validation
203231

204232
new_tr = copy.deepcopy(self)
205233
new_tr.test = tdef

src/cloudai/cli/handlers.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import toml
2828
import yaml
2929

30+
from cloudai.configurator.env_params import validate_domain_randomization_active
3031
from cloudai.core import (
3132
BaseInstaller,
3233
CloudAIGymEnv,
@@ -39,6 +40,7 @@
3940
System,
4041
TestParser,
4142
TestScenario,
43+
TestScenarioParsingError,
4244
)
4345
from cloudai.models.scenario import ReportConfig
4446
from cloudai.models.workload import TestDefinition
@@ -133,8 +135,7 @@ def handle_dse_job(runner: Runner, args: argparse.Namespace) -> int:
133135
return 1
134136

135137
err = 0
136-
# Recoverable failures return a non-zero rc and are accumulated here; an unexpected exception
137-
# (a bug) is a hard-fail. We capture it so reports still generate, then re-raise below.
138+
# Capture an unexpected error so reports still generate, then re-raise below.
138139
run_error: Exception | None = None
139140
try:
140141
for tr in runner.runner.test_scenario.test_runs:
@@ -303,6 +304,12 @@ def handle_dry_run_and_run(args: argparse.Namespace) -> int:
303304
return 1
304305
system, test_scenario, tests = setup_result
305306

307+
try:
308+
validate_domain_randomization_active(test_scenario)
309+
except TestScenarioParsingError as e:
310+
logging.error(str(e))
311+
return 1
312+
306313
if not _handle_single_sbatch(args, system):
307314
return 1
308315

@@ -491,7 +498,8 @@ def verify_test_scenarios(
491498
tests = Parser.parse_tests(test_tomls, system)
492499
hook_tests = Parser.parse_tests(hook_test_tomls, system)
493500
hooks = Parser.parse_hooks(hook_tomls, system, {t.name: t for t in hook_tests})
494-
Parser.parse_test_scenario(scenario_file, system, {t.name: t for t in tests}, hooks)
501+
scenario = Parser.parse_test_scenario(scenario_file, system, {t.name: t for t in tests}, hooks)
502+
validate_domain_randomization_active(scenario)
495503
except Exception:
496504
nfailed += 1
497505

src/cloudai/configurator/base_agent.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ class BaseAgent(ABC):
5858
Provides a unified interface and parameter management for action spaces.
5959
"""
6060

61+
# Opt-in: agents that operate over a variable environment - one that changes per trial, whether
62+
# by env_params sampling (domain randomization) or a curriculum schedule - set this True. Default
63+
# False so env_params declared for an agent that cannot handle a varying env are rejected rather
64+
# than silently ignored.
65+
supports_variable_environment: bool = False
66+
6167
def __init__(self, env: BaseGym, config: BaseAgentConfig):
6268
"""
6369
Initialize the agent with the environment.
@@ -94,9 +100,8 @@ def select_action(self, observation: list[float] | None = None) -> tuple[int, di
94100
95101
Args:
96102
observation: Latest observation produced by the environment (``env.reset()`` on the
97-
first call, then the result of the prior ``env.step()``). Stateless agents such
98-
as grid search or Bayesian optimization may ignore this; observation-conditioned
99-
agents (RL, contextual bandits) should use it.
103+
first call, then the result of the prior ``env.step()``). Stateless agents may
104+
ignore this; observation-conditioned agents should use it.
100105
101106
Returns:
102107
Tuple[int, Dict[str, Any]] | None: The current step index and a dictionary mapping action keys
@@ -120,8 +125,7 @@ def run(self) -> int:
120125
121126
Default: a step loop driven by the dispatcher (``select_action`` →
122127
``env.step`` → ``update_policy`` per trial). Agents that drive their
123-
own training loop (e.g. RLlib-based agents calling ``algo.train()``)
124-
override this method.
128+
own training loop override this method.
125129
126130
Failure contract (``handle_dse_job`` consumes the result via
127131
``err |= agent.run()``):
@@ -131,7 +135,8 @@ def run(self) -> int:
131135
accumulated and the next ``TestRun`` still executes. Workload-level
132136
failures are already surfaced this way: ``CloudAIGymEnv.step`` maps a
133137
failed metric to ``rewards.metric_failure`` rather than raising, and
134-
``rllib_run`` catches training errors and returns ``rc=1``.
138+
agents with their own training loop should likewise catch training
139+
errors and return a non-zero code.
135140
- Raise for *unexpected* failures (framework/agent bugs). Exceptions
136141
propagate out of ``handle_dse_job`` and hard-fail the job so the bug
137142
is surfaced instead of masked as a penalizing reward.

src/cloudai/configurator/cloudai_gym.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from .base_agent import RewardOverrides
2828
from .base_gym import BaseGym
29+
from .env_params import EnvParams, write_env_params
2930

3031

3132
@dataclasses.dataclass(frozen=True)
@@ -36,6 +37,7 @@ class TrajectoryEntry:
3637
action: dict[str, Any]
3738
reward: float
3839
observation: list
40+
env_params: dict[str, Any] = dataclasses.field(default_factory=dict)
3941

4042

4143
class CloudAIGymEnv(BaseGym):
@@ -61,8 +63,14 @@ def __init__(self, test_run: TestRun, runner: BaseRunner, rewards: RewardOverrid
6163
self.max_steps = test_run.test.agent_steps
6264
self.reward_function = Registry().get_reward_function(test_run.test.agent_reward_function)
6365
self.trajectory: dict[int, list[TrajectoryEntry]] = {}
66+
self.params: EnvParams | None = EnvParams.from_test(test_run.test)
6467
super().__init__()
6568

69+
@property
70+
def env_params_record_path(self) -> Path:
71+
"""``env.csv`` lives alongside ``trajectory.csv`` so a plain ``merge`` joins them."""
72+
return self.iteration_dir / "env.csv"
73+
6674
def define_action_space(self) -> Dict[str, list[Any]]:
6775
return self.test_run.param_space
6876

@@ -119,9 +127,11 @@ def step(self, action: Any) -> Tuple[list, float, bool, dict]:
119127
- info (dict): Additional info for debugging.
120128
"""
121129
self.test_run.increment_step()
122-
self.test_run = self.test_run.apply_params_set(action)
130+
# RNG lives in the env: sample here, then apply action + sample so the run and cache key see them.
131+
sampled_env_params = self.params.sample(self.test_run.step) if self.params else {}
132+
self.test_run = self.test_run.apply_params_set(action, env_params=sampled_env_params)
123133

124-
cached_result = self.get_cached_trajectory_result(action)
134+
cached_result = self.get_cached_trajectory_result(action, sampled_env_params)
125135
if cached_result is not None:
126136
logging.info(
127137
"Retrieved cached result from trajectory with reward %s (from step %s). Skipping execution.",
@@ -134,6 +144,7 @@ def step(self, action: Any) -> Tuple[list, float, bool, dict]:
134144
action=action,
135145
reward=cached_result.reward,
136146
observation=cached_result.observation,
147+
env_params=sampled_env_params,
137148
)
138149
)
139150
return cached_result.observation, cached_result.reward, False, {}
@@ -171,6 +182,7 @@ def step(self, action: Any) -> Tuple[list, float, bool, dict]:
171182
action=action,
172183
reward=reward,
173184
observation=observation,
185+
env_params=sampled_env_params,
174186
)
175187
)
176188

@@ -230,7 +242,14 @@ def get_observation(self, action: Any) -> list:
230242
return observation
231243

232244
def write_trajectory(self, entry: TrajectoryEntry):
233-
"""Append the trajectory to the CSV file and to the local attribute."""
245+
"""
246+
Append the entry to the in-memory cache and trajectory.csv (plus env.csv when declared).
247+
248+
``trajectory.csv`` and the ``env.csv`` projection are sunk from the same
249+
``TrajectoryEntry`` here, so a trial that never produces an entry (e.g. a
250+
constraint failure returns before this call) lands in neither file and the
251+
two stay 1:1 step-aligned.
252+
"""
234253
self.current_trajectory.append(entry)
235254

236255
file_exists = self.trajectory_file_path.exists()
@@ -243,17 +262,36 @@ def write_trajectory(self, entry: TrajectoryEntry):
243262
writer.writerow(["step", "action", "reward", "observation"])
244263
writer.writerow([entry.step, entry.action, entry.reward, entry.observation])
245264

265+
write_env_params(self.env_params_record_path, entry.step, entry.env_params)
266+
267+
@property
268+
def iteration_dir(self) -> Path:
269+
"""Per-iteration output dir; trajectory.csv and env.csv both live here, step-aligned."""
270+
return self.runner.scenario_root / self.test_run.name / f"{self.test_run.current_iteration}"
271+
246272
@property
247273
def trajectory_file_path(self) -> Path:
248-
return self.runner.scenario_root / self.test_run.name / f"{self.test_run.current_iteration}" / "trajectory.csv"
274+
return self.iteration_dir / "trajectory.csv"
249275

250276
@property
251277
def current_trajectory(self) -> list[TrajectoryEntry]:
252278
return self.trajectory.setdefault(self.test_run.current_iteration, [])
253279

254-
def get_cached_trajectory_result(self, action: Any) -> TrajectoryEntry | None:
280+
def get_cached_trajectory_result(self, action: Any, env_params: dict[str, Any]) -> TrajectoryEntry | None:
281+
"""
282+
Return a cached entry only when the full trial identity matches.
283+
284+
Trial identity is ``(action, env_params)``: env-randomized parameters
285+
change the workload's behaviour, so a trial repeating the same action
286+
under a different ``env_params`` sample must miss and re-run. Empty
287+
env_params on both sides is the back-compat path for workloads that
288+
do not declare any ``[env_params.*]`` block. The sample is passed in (a
289+
per-trial local owned by ``step``), exactly like ``action``.
290+
"""
255291
for entry in self.current_trajectory:
256-
if self._values_match_exact(entry.action, action):
292+
action_match = self._values_match_exact(entry.action, action)
293+
env_params_match = self._values_match_exact(entry.env_params, env_params)
294+
if action_match and env_params_match:
257295
return entry
258296

259297
return None

0 commit comments

Comments
 (0)