Skip to content

Commit 468419b

Browse files
committed
test(configurator): pin env_params in CloudAIGymEnv cache key (TDD red)
CloudAIGymEnv.get_cached_trajectory_result(action) keys the trajectory cache on action alone. When a workload declares env_params (e.g. drop_rate) and the agent re-selects the same action under a different env_params sample, the cache returns a stale reward measured under a different env, silently invalidating any domain-randomization workflow. Four tests pin the contract; today two FAIL (bug-exposing), two PASS (back-compat sanity). All 27 pre-existing tests are untouched. FAIL test_cache_miss_when_env_params_differ FAIL test_step_reruns_workload_when_env_params_change PASS test_cache_hit_when_action_and_env_params_match PASS test_cache_hit_when_neither_has_env_params Driver for the follow-up PR that adds env_params as a first-class field on TestDefinition + TrajectoryEntry and rewrites the cache key as (action, env_params). Both unit and integration shapes are covered so the fix can be validated end-to-end through env.step().
1 parent fe6a90b commit 468419b

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

tests/test_cloudaigym.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)