@@ -441,3 +441,130 @@ def test_cached_step_appends_trajectory_row(nemorun: NeMoRunTestDefinition, tmp_
441441 contents = csv_path .read_text ().strip ().splitlines ()
442442 assert contents [0 ] == "step,action,reward,observation"
443443 assert contents [- 1 ].startswith ("5," )
444+
445+
446+ def _seed_cached_entry_with_env_params (
447+ env : CloudAIGymEnv , action : dict [str , object ], env_params : dict [str , object ]
448+ ) -> None :
449+ """Seed env.trajectory with one entry, attaching env_params via object.__setattr__.
450+
451+ TrajectoryEntry is a frozen dataclass and does not yet declare env_params.
452+ Once the field is added, drop this helper and pass env_params as a kwarg.
453+ """
454+ entry = TrajectoryEntry (step = 1 , action = action , reward = 0.5 , observation = [100.0 ])
455+ object .__setattr__ (entry , "env_params" , env_params )
456+ env .test_run .current_iteration = 0
457+ env .trajectory = {0 : [entry ]}
458+
459+
460+ def test_cache_miss_when_env_params_differ (base_tr : TestRun , tmp_path : Path ) -> None :
461+ """Cache MUST miss when env_params differ, even if action is identical.
462+
463+ Without this property the agent receives stale rewards on every cache hit
464+ under domain randomization. PPO/DQN/BO all silently train on labels that
465+ do not correspond to the env they were nominally generated under.
466+ """
467+ runner = MagicMock ()
468+ runner .scenario_root = tmp_path / "scenario"
469+ runner .system = MagicMock ()
470+ runner .test_scenario = MagicMock (test_runs = [])
471+ runner .jobs = {}
472+ runner .testrun_to_job_map = {}
473+
474+ env = CloudAIGymEnv (test_run = base_tr , runner = runner , rewards = RewardOverrides ())
475+ _seed_cached_entry_with_env_params (env , {"x" : 10 }, env_params = {"drop_rate" : 0.001 })
476+
477+ env .test_run .current_env_params = {"drop_rate" : 0.01 } # type: ignore[attr-defined]
478+
479+ assert env .get_cached_trajectory_result ({"x" : 10 }) is None , (
480+ "Cache must include env_params in its key. The current implementation "
481+ "keys on action alone, so trials repeating the same action under a "
482+ "different env_params sample receive a stale cached reward. See "
483+ "env-params-cloudai-corpus-plan.md."
484+ )
485+
486+
487+ def test_cache_hit_when_action_and_env_params_match (base_tr : TestRun , tmp_path : Path ) -> None :
488+ """Same action AND same env_params must still HIT the cache."""
489+ runner = MagicMock ()
490+ runner .scenario_root = tmp_path / "scenario"
491+ runner .system = MagicMock ()
492+ runner .test_scenario = MagicMock (test_runs = [])
493+ runner .jobs = {}
494+ runner .testrun_to_job_map = {}
495+
496+ env = CloudAIGymEnv (test_run = base_tr , runner = runner , rewards = RewardOverrides ())
497+ _seed_cached_entry_with_env_params (env , {"x" : 10 }, env_params = {"drop_rate" : 0.001 })
498+
499+ env .test_run .current_env_params = {"drop_rate" : 0.001 } # type: ignore[attr-defined]
500+
501+ result = env .get_cached_trajectory_result ({"x" : 10 })
502+ assert result is not None and result .step == 1
503+
504+
505+ def test_cache_hit_when_neither_has_env_params (base_tr : TestRun , tmp_path : Path ) -> None :
506+ """Workloads without env_params behave exactly as today (back-compat)."""
507+ runner = MagicMock ()
508+ runner .scenario_root = tmp_path / "scenario"
509+ runner .system = MagicMock ()
510+ runner .test_scenario = MagicMock (test_runs = [])
511+ runner .jobs = {}
512+ runner .testrun_to_job_map = {}
513+
514+ env = CloudAIGymEnv (test_run = base_tr , runner = runner , rewards = RewardOverrides ())
515+ env .test_run .current_iteration = 0
516+ env .trajectory = {0 : [TrajectoryEntry (step = 1 , action = {"x" : 10 }, reward = 0.5 , observation = [100.0 ])]}
517+ # Note: neither the cached entry nor test_run carries env_params -> existing behavior.
518+
519+ result = env .get_cached_trajectory_result ({"x" : 10 })
520+ assert result is not None and result .step == 1
521+
522+
523+ def test_step_reruns_workload_when_env_params_change (
524+ nemorun : NeMoRunTestDefinition , tmp_path : Path
525+ ) -> None :
526+ """Integration: env.step() with same action but different env_params re-runs the workload.
527+
528+ Counterpart to test_cache_miss_when_env_params_differ but exercising the
529+ full step() flow: increment_step -> apply_params_set -> cache lookup ->
530+ runner.run() -> write_trajectory.
531+ """
532+ tdef = nemorun .model_copy (deep = True )
533+ tdef .cmd_args .data .global_batch_size = 8
534+ tdef .agent_metrics = ["default" ]
535+ test_run = TestRun (
536+ name = "dr_tr" ,
537+ test = tdef ,
538+ num_nodes = 1 ,
539+ nodes = [],
540+ output_path = tmp_path / "out" / "dr_tr" / "0" ,
541+ reports = {NeMoRunReportGenerationStrategy },
542+ )
543+ test_scenario = TestScenario (name = "dr_scenario" , test_runs = [test_run ])
544+
545+ runner = MagicMock (spec = BaseRunner )
546+ runner .scenario_root = tmp_path / "scenario"
547+ runner .system = MagicMock ()
548+ runner .test_scenario = test_scenario
549+ runner .jobs = {}
550+ runner .testrun_to_job_map = {}
551+ runner .shutting_down = False
552+ runner .get_job_output_path .return_value = test_run .output_path
553+
554+ env = CloudAIGymEnv (test_run = test_run , runner = runner , rewards = RewardOverrides ())
555+ action = {"trainer.max_steps" : 1000 }
556+ fake_obs = iter ([[100.0 ], [50.0 ]])
557+
558+ with patch .object (env , "get_observation" , side_effect = lambda _action : next (fake_obs )):
559+ env .test_run .step = 0
560+ env .test_run .current_env_params = {"drop_rate" : 0.001 } # type: ignore[attr-defined]
561+ obs1 , _r1 , * _ = env .step (action )
562+
563+ env .test_run .current_env_params = {"drop_rate" : 0.01 } # type: ignore[attr-defined]
564+ obs2 , _r2 , * _ = env .step (action )
565+
566+ assert runner .run .call_count == 2 , (
567+ "Different env_params between two env.step() calls with the same action "
568+ "must trigger a workload re-run; the cache lookup must miss."
569+ )
570+ assert obs1 != obs2 , "fresh workload run should produce a fresh observation"
0 commit comments