2626
2727from .base_agent import RewardOverrides
2828from .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
4143class 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