From 9dee3958ac6e562bea2256a01eb4fa569853dbd0 Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Mon, 13 Jul 2026 15:53:56 -0700 Subject: [PATCH 1/8] fix: filter full recipe template from serverless train( ) --- .../src/sagemaker/train/base_trainer.py | 90 +++++++++++++++++-- .../src/sagemaker/train/dpo_trainer.py | 9 +- .../sagemaker/train/multi_turn_rl_trainer.py | 8 +- .../src/sagemaker/train/rlvr_trainer.py | 11 ++- .../src/sagemaker/train/sft_trainer.py | 9 +- 5 files changed, 113 insertions(+), 14 deletions(-) diff --git a/sagemaker-train/src/sagemaker/train/base_trainer.py b/sagemaker-train/src/sagemaker/train/base_trainer.py index a453720f53..5edbc671bb 100644 --- a/sagemaker-train/src/sagemaker/train/base_trainer.py +++ b/sagemaker-train/src/sagemaker/train/base_trainer.py @@ -236,19 +236,79 @@ def _patch_resolved_recipe(self, resolved: Dict[str, Any]) -> None: if dotpath: _set_nested_value(resolved, dotpath, value) - def _apply_recipe_to_hyperparameters(self, final_hyperparameters: Dict[str, Any]) -> Dict[str, Any]: + def _get_user_provided_recipe_keys(self) -> set: + """Return the set of leaf keys the user explicitly provided. + + Collects keys from every source that represents an explicit user choice: + + * direct hyperparameter assignments (``trainer.hyperparameters.x = val``), + tracked in ``hyperparameters._user_set``; + * the programmatic ``overrides`` dict passed at construction; + * the user recipe YAML file passed at construction. + + These are the keys that should be forwarded to a serverless training job, + alongside the Hub override spec. Full recipe-template internal keys that + the user never touched are intentionally excluded. + + Returns: + Set of leaf key names the user explicitly provided. Empty when the + user provided nothing beyond Hub defaults. + """ + keys: set = set() + + # Direct hyperparameter assignments (always members of the Hub spec). + user_set = getattr(getattr(self, 'hyperparameters', None), '_user_set', None) + if isinstance(user_set, set): + keys.update(user_set) + + # Programmatic overrides dict (may contain non-spec recipe keys). + overrides = getattr(self, '_overrides', None) + if isinstance(overrides, dict) and overrides: + keys.update(flatten_resolved_recipe(overrides).keys()) + + # User recipe YAML file (may contain non-spec recipe keys). + recipe_path = getattr(self, '_recipe_path', None) + if recipe_path: + try: + from sagemaker.train.recipe_resolver import _load_user_recipe + + user_recipe = _load_user_recipe(recipe_path) + keys.update(flatten_resolved_recipe(user_recipe).keys()) + except Exception as e: # pragma: no cover - best-effort key discovery + logger.debug("Could not load user recipe to collect keys: %s", e) + + return keys + + + def _apply_recipe_to_hyperparameters( + self, + final_hyperparameters: Dict[str, Any], + only_user_overrides: bool = False, + ) -> Dict[str, Any]: """Apply resolved recipe values to final_hyperparameters dict. If recipe/overrides were provided, or if the user set hyperparameters directly via ``.hyperparameters.*``, merges resolved recipe values into - the hyperparameters dict. All leaf values from the resolved recipe are - applied — including keys not in the Hub spec subset — enabling - power users to override any parameter in the full recipe. + the hyperparameters dict. By default all leaf values from the resolved + recipe are applied — including keys not in the Hub spec subset. This is + used by the serverful/HyperPod paths, which render the values into a + recipe YAML file (no key-count limit). + Values are converted to strings (matching the SageMaker API expectation for hyperparameter values). Args: final_hyperparameters: The hyperparameters dict from to_dict(). + only_user_overrides: When True, only apply resolved values for keys the + user explicitly provided — direct hyperparameter assignments, the + ``overrides`` dict, or a recipe file — instead of flattening the + entire resolved recipe. This is required for serverless SageMaker + training jobs, whose ``CreateTrainingJob`` ``HyperParameters`` map + is limited to 100 members by the CreateTrainingJob: the resolved full recipe + template can contain several hundred internal leaf keys the user + never touched. User-provided keys are not restricted to the Hub + spec — power users may override any recipe key, and validity is + enforced by ``get_resolved_recipe()``. Returns: The updated hyperparameters dict with recipe values applied. @@ -259,12 +319,30 @@ def _apply_recipe_to_hyperparameters(self, final_hyperparameters: Dict[str, Any] try: resolved = self.get_resolved_recipe() except NoRecipeError: + return final_hyperparameters flat = flatten_resolved_recipe(resolved) + + allowed_keys = None + if only_user_overrides: + try: + allowed_keys = self._get_user_provided_recipe_keys() + except Exception as e: + logger.warning( + "Failed to determine user-provided recipe keys (%s); " + "falling back to submitting the full resolved recipe.", + e, + ) + allowed_keys = None + for k, v in flat.items(): - if v is not None: - final_hyperparameters[k] = str(v) if not isinstance(v, str) else v + if v is None: + continue + if allowed_keys is not None and k not in allowed_keys: + logger.info("Skipping recipe key '%s' (not user-provided).", k) + continue + final_hyperparameters[k] = str(v) if not isinstance(v, str) else v return final_hyperparameters diff --git a/sagemaker-train/src/sagemaker/train/dpo_trainer.py b/sagemaker-train/src/sagemaker/train/dpo_trainer.py index a83d1ed2fe..e56adde46e 100644 --- a/sagemaker-train/src/sagemaker/train/dpo_trainer.py +++ b/sagemaker-train/src/sagemaker/train/dpo_trainer.py @@ -308,8 +308,13 @@ def train(self, final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) - final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Serverless CreateTrainingJob limits HyperParameters to 100 members, so + # only forward the values the user explicitly overrode rather than the + # entire resolved recipe template. + final_hyperparameters = self._apply_recipe_to_hyperparameters( + final_hyperparameters, only_user_overrides=True + ) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset diff --git a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py index daf3c8f5d9..35e137ee50 100644 --- a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py +++ b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py @@ -289,8 +289,12 @@ def train( self._final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) - self._final_hyperparameters = self._apply_recipe_to_hyperparameters(self._final_hyperparameters) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Only forward the values the user explicitly overrode rather than the + # entire resolved recipe template. + final_hyperparameters = self._apply_recipe_to_hyperparameters( + final_hyperparameters, only_user_overrides=True + ) _validate_hyperparameter_values(self._final_hyperparameters) diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index f20b54e7b1..279cd20857 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -471,8 +471,13 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) - final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Serverless CreateTrainingJob limits HyperParameters to 100 members, so + # only forward the values the user explicitly overrode rather than the + # entire resolved recipe template. + final_hyperparameters = self._apply_recipe_to_hyperparameters( + final_hyperparameters, only_user_overrides=False + ) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset @@ -518,6 +523,8 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, if self.stopping_condition is not None: create_args["stopping_condition"] = self.stopping_condition + logger.info("Final hyperparameters before submitting training job: %s", final_hyperparameters) + try: training_job = TrainingJob.create(**create_args) except Exception as e: diff --git a/sagemaker-train/src/sagemaker/train/sft_trainer.py b/sagemaker-train/src/sagemaker/train/sft_trainer.py index 3f14c348f0..7892fb8b28 100644 --- a/sagemaker-train/src/sagemaker/train/sft_trainer.py +++ b/sagemaker-train/src/sagemaker/train/sft_trainer.py @@ -361,8 +361,13 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) - final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Serverless CreateTrainingJob limits HyperParameters to 100 members, so + # only forward the values the user explicitly overrode rather than the + # entire resolved recipe template. + final_hyperparameters = self._apply_recipe_to_hyperparameters( + final_hyperparameters, only_user_overrides=True + ) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset From 038a14d4c76b5efefdfbb8c47f4967cc618cdd4e Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Mon, 13 Jul 2026 15:58:15 -0700 Subject: [PATCH 2/8] fix: update hyperparam ref in mtrl trainer --- sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py index 35e137ee50..0c2a44e647 100644 --- a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py +++ b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py @@ -292,8 +292,8 @@ def train( # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). # Only forward the values the user explicitly overrode rather than the # entire resolved recipe template. - final_hyperparameters = self._apply_recipe_to_hyperparameters( - final_hyperparameters, only_user_overrides=True + self._final_hyperparameters = self._apply_recipe_to_hyperparameters( + self._final_hyperparameters, only_user_overrides=True ) _validate_hyperparameter_values(self._final_hyperparameters) From c51d4e68ce6e4bd934cfb7eae076c33682a0be98 Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Mon, 13 Jul 2026 16:04:24 -0700 Subject: [PATCH 3/8] fix: enable only_user_overrides for RLVRTrainer serverless path --- sagemaker-train/src/sagemaker/train/rlvr_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index 279cd20857..da151d7165 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -476,7 +476,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, # only forward the values the user explicitly overrode rather than the # entire resolved recipe template. final_hyperparameters = self._apply_recipe_to_hyperparameters( - final_hyperparameters, only_user_overrides=False + final_hyperparameters, only_user_overrides=True ) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: From 15060015c9275a5cfa596d6f8312676f200ea3c2 Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Tue, 14 Jul 2026 12:11:48 -0700 Subject: [PATCH 4/8] chore: removing extra logging --- sagemaker-train/src/sagemaker/train/base_trainer.py | 1 - sagemaker-train/src/sagemaker/train/rlvr_trainer.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/sagemaker-train/src/sagemaker/train/base_trainer.py b/sagemaker-train/src/sagemaker/train/base_trainer.py index 5edbc671bb..966bb960f4 100644 --- a/sagemaker-train/src/sagemaker/train/base_trainer.py +++ b/sagemaker-train/src/sagemaker/train/base_trainer.py @@ -340,7 +340,6 @@ def _apply_recipe_to_hyperparameters( if v is None: continue if allowed_keys is not None and k not in allowed_keys: - logger.info("Skipping recipe key '%s' (not user-provided).", k) continue final_hyperparameters[k] = str(v) if not isinstance(v, str) else v diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index da151d7165..8ad88a6c00 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -523,8 +523,6 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, if self.stopping_condition is not None: create_args["stopping_condition"] = self.stopping_condition - logger.info("Final hyperparameters before submitting training job: %s", final_hyperparameters) - try: training_job = TrainingJob.create(**create_args) except Exception as e: From 50b62405e412600c4246501b07fd91aa6c9ed763 Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Tue, 14 Jul 2026 12:39:24 -0700 Subject: [PATCH 5/8] testing: added new integ test for nemotron --- .../train/test_rlvr_trainer_integration.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/sagemaker-train/tests/integ/train/test_rlvr_trainer_integration.py b/sagemaker-train/tests/integ/train/test_rlvr_trainer_integration.py index d3d244d2b9..420f72ec91 100644 --- a/sagemaker-train/tests/integ/train/test_rlvr_trainer_integration.py +++ b/sagemaker-train/tests/integ/train/test_rlvr_trainer_integration.py @@ -15,11 +15,14 @@ import time import random +import tempfile import pytest import boto3 +import yaml import logging from sagemaker.core.helper.session_helper import Session +from sagemaker.core.resources import ModelPackageGroup from sagemaker.train.rlvr_trainer import RLVRTrainer from sagemaker.train.common import TrainingType from sagemaker.ai_registry.evaluator import Evaluator @@ -161,6 +164,11 @@ def test_rlvr_trainer_nova_workflow(sagemaker_session_us_east_1): """Test RLVR training workflow with Nova model.""" # sagemaker_session_us_east_1 fixture is defined in conftest.py (us-east-1 region) + overrides={ + "training_config": { + "lambda_concurrency_limit": 64 + } + } unique_id = f"{int(time.time())}-{random.randint(1000, 9999)}" rlvr_trainer = RLVRTrainer( model="nova-textgeneration-lite-v2", @@ -176,9 +184,12 @@ def test_rlvr_trainer_nova_workflow(sagemaker_session_us_east_1): accept_eula=True, sagemaker_session=sagemaker_session_us_east_1, base_job_name=f"rlvr-nova-integ-{unique_id}", + overrides=overrides, ) # Workaround: Hub spec has save_steps default=null but recipe template requires an integer rlvr_trainer.hyperparameters.save_steps = 10 + rlvr_trainer.hyperparameters.global_batch_size = 32 + training_job = rlvr_trainer.train(wait=False) logger.info(f"Training job submitted: {training_job.training_job_arn}") @@ -279,4 +290,66 @@ def test_rlvr_trainer_with_evaluator_object(sagemaker_session, evaluator): assert training_job.training_job_status == "Completed" assert hasattr(training_job, 'output_model_package_arn') assert training_job.output_model_package_arn is not None + + +@pytest.mark.gpu_intensive +def test_rlvr_trainer_nemotron_with_kl_and_recipe(sagemaker_session): + """Test RLVR training with Nemotron model, KL regularization, recipe overrides, and hyperparameter tuning.""" + unique_id = f"{int(time.time())}-{random.randint(1000, 9999)}" + + recipe_content = { + "training_config": { + "data": { + "max_prompt_length": 3070, + }, + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(recipe_content, f) + recipe_path = f.name + + rlvr_trainer = RLVRTrainer( + model="huggingface-reasoning-nvidia-nemotron-3-nano-30b-a3b-bf16", + model_package_group="sdk-test-finetuned-models", + training_dataset="s3://mc-flows-sdk-testing/input_data/rlvr-rlaif-test-data/train_285.jsonl", + s3_output_path="s3://mc-flows-sdk-testing/output/", + sagemaker_session=sagemaker_session, + custom_reward_function="arn:aws:sagemaker:us-west-2:729646638167:hub-content/sdktest/JsonDoc/rlvr-test-rf/0.0.1", + accept_eula=True, + base_job_name=f"rlvr-nemotron-kl-integ-{unique_id}", + overrides={ + "training_config": { + "learning_rate": 2e-5, + "max_epochs": 10, + "train_val_split_ratio": 0.8, + "temperature": 1.1, + } + }, + recipe=recipe_path, + ) + + rlvr_trainer.hyperparameters.use_kl_loss = True + rlvr_trainer.hyperparameters.kl_loss_coef = 0.05 + rlvr_trainer.hyperparameters.max_epochs = 1 + rlvr_trainer.hyperparameters.clip_ratio = 0.2 + + training_job = rlvr_trainer.train(wait=False) + logger.info(f"Training job submitted: {training_job.training_job_arn}") + + max_wait_time = 7200 # 2 hour timeout for larger model + poll_interval = 30 + start_time = time.time() + + while time.time() - start_time < max_wait_time: + training_job.refresh() + status = training_job.training_job_status + + if status in ["Completed", "Failed", "Stopped"]: + break + + time.sleep(poll_interval) + + assert training_job.training_job_status == "Completed" + assert hasattr(training_job, 'output_model_package_arn') + assert training_job.output_model_package_arn is not None From 3dd951570cbea09950d47cd61c67be8c98721c82 Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Tue, 14 Jul 2026 16:00:25 -0700 Subject: [PATCH 6/8] fix: use self.compute instead of user override flag for apply_recipe_hyperparams fix --- .../src/sagemaker/train/base_trainer.py | 28 +-- .../src/sagemaker/train/dpo_trainer.py | 8 +- .../sagemaker/train/multi_turn_rl_trainer.py | 4 +- .../src/sagemaker/train/rlvr_trainer.py | 7 +- .../src/sagemaker/train/sft_trainer.py | 7 +- .../train/test_recipe_override_integration.py | 229 ++++++++++++++++++ 6 files changed, 244 insertions(+), 39 deletions(-) diff --git a/sagemaker-train/src/sagemaker/train/base_trainer.py b/sagemaker-train/src/sagemaker/train/base_trainer.py index 966bb960f4..da94cc0982 100644 --- a/sagemaker-train/src/sagemaker/train/base_trainer.py +++ b/sagemaker-train/src/sagemaker/train/base_trainer.py @@ -283,32 +283,23 @@ def _get_user_provided_recipe_keys(self) -> set: def _apply_recipe_to_hyperparameters( self, final_hyperparameters: Dict[str, Any], - only_user_overrides: bool = False, ) -> Dict[str, Any]: """Apply resolved recipe values to final_hyperparameters dict. If recipe/overrides were provided, or if the user set hyperparameters directly via ``.hyperparameters.*``, merges resolved recipe values into - the hyperparameters dict. By default all leaf values from the resolved - recipe are applied — including keys not in the Hub spec subset. This is - used by the serverful/HyperPod paths, which render the values into a - recipe YAML file (no key-count limit). - + the hyperparameters dict. All leaf values from the resolved recipe are + applied — including keys not in the Hub spec subset — enabling + power users to override any parameter in the full recipe. Values are converted to strings (matching the SageMaker API expectation for hyperparameter values). + For serverless training (``self.compute`` is None), only user-provided + keys (from .hyperparameters.*, recipe or overrides dict) are included because CreateTrainingJob limits HyperParameters to + 100 members and the full resolved recipe can exceed that. + Args: final_hyperparameters: The hyperparameters dict from to_dict(). - only_user_overrides: When True, only apply resolved values for keys the - user explicitly provided — direct hyperparameter assignments, the - ``overrides`` dict, or a recipe file — instead of flattening the - entire resolved recipe. This is required for serverless SageMaker - training jobs, whose ``CreateTrainingJob`` ``HyperParameters`` map - is limited to 100 members by the CreateTrainingJob: the resolved full recipe - template can contain several hundred internal leaf keys the user - never touched. User-provided keys are not restricted to the Hub - spec — power users may override any recipe key, and validity is - enforced by ``get_resolved_recipe()``. Returns: The updated hyperparameters dict with recipe values applied. @@ -319,13 +310,14 @@ def _apply_recipe_to_hyperparameters( try: resolved = self.get_resolved_recipe() except NoRecipeError: - + return final_hyperparameters flat = flatten_resolved_recipe(resolved) + # Serverless (compute is None) → only user-provided keys + defaults; allowed_keys = None - if only_user_overrides: + if getattr(self, 'compute', None) is None: try: allowed_keys = self._get_user_provided_recipe_keys() except Exception as e: diff --git a/sagemaker-train/src/sagemaker/train/dpo_trainer.py b/sagemaker-train/src/sagemaker/train/dpo_trainer.py index e56adde46e..1883d6f6dc 100644 --- a/sagemaker-train/src/sagemaker/train/dpo_trainer.py +++ b/sagemaker-train/src/sagemaker/train/dpo_trainer.py @@ -309,12 +309,8 @@ def train(self, final_hyperparameters = self.hyperparameters.to_dict() # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - # Serverless CreateTrainingJob limits HyperParameters to 100 members, so - # only forward the values the user explicitly overrode rather than the - # entire resolved recipe template. - final_hyperparameters = self._apply_recipe_to_hyperparameters( - final_hyperparameters, only_user_overrides=True - ) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset diff --git a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py index 0c2a44e647..84b11e5860 100644 --- a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py +++ b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py @@ -290,10 +290,8 @@ def train( self._final_hyperparameters = self.hyperparameters.to_dict() # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - # Only forward the values the user explicitly overrode rather than the - # entire resolved recipe template. self._final_hyperparameters = self._apply_recipe_to_hyperparameters( - self._final_hyperparameters, only_user_overrides=True + self._final_hyperparameters ) _validate_hyperparameter_values(self._final_hyperparameters) diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index 8ad88a6c00..7c0dc1535c 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -472,12 +472,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, final_hyperparameters = self.hyperparameters.to_dict() # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - # Serverless CreateTrainingJob limits HyperParameters to 100 members, so - # only forward the values the user explicitly overrode rather than the - # entire resolved recipe template. - final_hyperparameters = self._apply_recipe_to_hyperparameters( - final_hyperparameters, only_user_overrides=True - ) + final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset diff --git a/sagemaker-train/src/sagemaker/train/sft_trainer.py b/sagemaker-train/src/sagemaker/train/sft_trainer.py index 7892fb8b28..ac0f59e4a0 100644 --- a/sagemaker-train/src/sagemaker/train/sft_trainer.py +++ b/sagemaker-train/src/sagemaker/train/sft_trainer.py @@ -362,12 +362,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati final_hyperparameters = self.hyperparameters.to_dict() # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - # Serverless CreateTrainingJob limits HyperParameters to 100 members, so - # only forward the values the user explicitly overrode rather than the - # entire resolved recipe template. - final_hyperparameters = self._apply_recipe_to_hyperparameters( - final_hyperparameters, only_user_overrides=True - ) + final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: effective_training_dataset = training_dataset or self.training_dataset diff --git a/sagemaker-train/tests/integ/train/test_recipe_override_integration.py b/sagemaker-train/tests/integ/train/test_recipe_override_integration.py index ec1de908b1..b1867436f1 100644 --- a/sagemaker-train/tests/integ/train/test_recipe_override_integration.py +++ b/sagemaker-train/tests/integ/train/test_recipe_override_integration.py @@ -13,6 +13,7 @@ """Integration tests for recipe override feature (get_resolved_recipe).""" from __future__ import absolute_import +import logging import os import tempfile import time @@ -20,8 +21,12 @@ import pytest import yaml +logger = logging.getLogger(__name__) + from sagemaker.train.sft_trainer import SFTTrainer +from sagemaker.train.rlvr_trainer import RLVRTrainer from sagemaker.train.common import TrainingType +from sagemaker.train.recipe_resolver import flatten_resolved_recipe # Ensure bundled service model is available for botocore @@ -1002,3 +1007,227 @@ def test_model_trainer_get_resolved_recipe_is_idempotent(self): finally: os.unlink(recipe_path) + + +class TestRLVRServerlessOnlyUserOverrideKeys: + """Integration tests for serverless path filtering (compute=None excludes non-overridden keys).""" + + def test_rlvr_serverless_only_user_override_keys_applied(self, sagemaker_session): + """Test that serverless path (compute=None) excludes non-overridden recipe keys. + + When compute is None, CreateTrainingJob limits HyperParameters to 100 members. + A full recipe template can have several hundred internal leaf keys. This test + verifies that after applying overrides, only the user-provided keys from + overrides/recipe/direct assignment appear — non-overridden recipe template + keys must NOT leak into the result. + """ + recipe_content = { + "training_config": { + "data": { + "max_prompt_length": 3070, + }, + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(recipe_content, f) + recipe_path = f.name + + try: + rlvr_trainer = RLVRTrainer( + model="huggingface-reasoning-nvidia-nemotron-3-nano-30b-a3b-bf16", + model_package_group="sdk-test-finetuned-models", + training_dataset="s3://mc-flows-sdk-testing/input_data/rlvr-rlaif-test-data/train_285.jsonl", + s3_output_path="s3://mc-flows-sdk-testing/output/", + sagemaker_session=sagemaker_session, + custom_reward_function="arn:aws:sagemaker:us-west-2:729646638167:hub-content/sdktest/JsonDoc/rlvr-test-rf/0.0.1", + accept_eula=True, + base_job_name="rlvr-override-keys-integ", + overrides={ + "training_config": { + "learning_rate": 2e-5, + "max_epochs": 10, + "train_val_split_ratio": 0.8, + "temperature": 1.1, + } + }, + recipe=recipe_path, + ) + + rlvr_trainer.hyperparameters.use_kl_loss = True + rlvr_trainer.hyperparameters.kl_loss_coef = 0.05 + + # Get baseline hyperparameters (the Hub spec defaults) + baseline_hp = rlvr_trainer.hyperparameters.to_dict() + + # Serverless path (compute=None): only user-provided keys applied + assert rlvr_trainer.compute is None + result_hp = rlvr_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) + + # Simulate serverful path (compute set): full recipe applied + from sagemaker.core.training.configs import TrainingJobCompute + rlvr_trainer.compute = TrainingJobCompute(instance_type="ml.p5.48xlarge", instance_count=1) + full_hp = rlvr_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) + rlvr_trainer.compute = None # reset + + logger.info(f"Baseline HP keys: {len(baseline_hp)}") + logger.info(f"User-override-only HP keys: {len(result_hp)}") + logger.info(f"Full recipe HP keys: {len(full_hp)}") + + # Keys the user explicitly provided + expected_override_keys = {"learning_rate", "max_epochs", "train_val_split_ratio", "temperature"} + expected_recipe_keys = {"max_prompt_length"} + expected_direct_hp_keys = {"use_kl_loss", "kl_loss_coef"} + all_user_keys = expected_override_keys | expected_recipe_keys | expected_direct_hp_keys + + # All user-provided keys must be present in the user-override result + for key in all_user_keys: + assert key in result_hp, ( + f"User-provided key '{key}' missing from serverless (compute=None) result" + ) + + # Dynamically compute recipe keys NOT in the override spec by fetching + # the full resolved recipe and subtracting the spec keys + user-provided keys. + # Only consider keys with non-None values (None keys are skipped by _apply_recipe). + resolved_recipe = rlvr_trainer.get_resolved_recipe() + flat_recipe = flatten_resolved_recipe(resolved_recipe) + all_recipe_keys = {k for k, v in flat_recipe.items() if v is not None} + override_spec_keys = set(rlvr_trainer.hyperparameters._specs.keys()) + recipe_internal_keys_not_in_spec = all_recipe_keys - override_spec_keys - all_user_keys + + logger.info(f"All recipe keys from Hub ({len(all_recipe_keys)}): {sorted(all_recipe_keys)}") + logger.info(f"Override spec keys ({len(override_spec_keys)}): {sorted(override_spec_keys)}") + logger.info( + f"Recipe internal keys NOT in spec ({len(recipe_internal_keys_not_in_spec)}): " + f"{sorted(recipe_internal_keys_not_in_spec)}" + ) + + assert len(recipe_internal_keys_not_in_spec) > 0, ( + "Expected recipe template to have keys beyond the override spec, but found none." + ) + + # These internal recipe keys must NOT appear in the serverless result + leaked_keys = recipe_internal_keys_not_in_spec & set(result_hp.keys()) + assert not leaked_keys, ( + f"Non-overridable recipe template keys leaked into the serverless hyperparameters: " + f"{sorted(leaked_keys)}. These keys are not in the override spec and should be " + f"excluded when compute=None (serverless)." + ) + + # The full recipe (compute set) MUST contain these internal keys + missing_from_full = recipe_internal_keys_not_in_spec - set(full_hp.keys()) + assert not missing_from_full, ( + f"Full recipe (compute set) is missing expected internal keys: " + f"{sorted(missing_from_full)}. The full recipe path should include all template keys." + ) + + # The user-override result should not exceed the 100-key CreateTrainingJob limit + assert len(result_hp) <= 100, ( + f"Serverless hyperparameters have {len(result_hp)} keys, exceeding the " + f"CreateTrainingJob limit of 100. Non-overridden recipe keys are leaking through." + ) + + finally: + os.unlink(recipe_path) + + def test_sft_nova_serverless_only_user_override_keys_applied(self, sagemaker_session_us_east_1): + """Test that Nova SFT serverless path (compute=None) excludes non-overridden recipe keys. + + Same principle as the RLVR Nemotron test: when compute is None, + internal recipe template keys that the user never touched must not appear + in the hyperparameters sent to CreateTrainingJob. + """ + sft_trainer = SFTTrainer( + model="nova-textgeneration-lite-v2", + training_type=TrainingType.LORA, + model_package_group="sdk-test-finetuned-models", + training_dataset="s3://sagemaker-us-east-1-784379639078/input_data/sft-nova/sft_200_samples.jsonl", + s3_output_path="s3://sagemaker-us-east-1-784379639078/output/", + sagemaker_session=sagemaker_session_us_east_1, + accept_eula=True, + base_job_name="sft-nova-override-keys-integ", + overrides={ + "training_config": { + "learning_rate": 3e-5, + "max_steps": 50, + } + }, + ) + + sft_trainer.hyperparameters.warmup_steps = 5 + sft_trainer.hyperparameters.weight_decay = 0.01 + + baseline_hp = sft_trainer.hyperparameters.to_dict() + + # Serverless path (compute=None): only user-provided keys applied + assert sft_trainer.compute is None + result_hp = sft_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) + + # Simulate serverful path (compute set): full recipe applied + from sagemaker.core.training.configs import TrainingJobCompute + sft_trainer.compute = TrainingJobCompute(instance_type="ml.p5.48xlarge", instance_count=1) + full_hp = sft_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) + sft_trainer.compute = None # reset + + logger.info(f"Nova SFT — Baseline HP keys: {len(baseline_hp)}") + logger.info(f"Nova SFT — User-override-only HP keys: {len(result_hp)}") + logger.info(f"Nova SFT — Full recipe HP keys: {len(full_hp)}") + + # Keys the user explicitly provided + expected_override_keys = {"learning_rate", "max_steps"} + expected_direct_hp_keys = {"warmup_steps", "weight_decay"} + all_user_keys = expected_override_keys | expected_direct_hp_keys + + for key in all_user_keys: + assert key in result_hp, ( + f"User-provided key '{key}' missing from serverless (compute=None) result" + ) + + # Full recipe should have more keys than the user-override-only result + full_only_keys = set(full_hp.keys()) - set(result_hp.keys()) + assert len(full_only_keys) > 0, ( + "Full recipe should contain additional keys beyond the user-override-only result." + ) + logger.info( + f"Nova SFT — Keys excluded from serverless path: " + f"{len(full_only_keys)} keys — {sorted(list(full_only_keys))}" + ) + + # Dynamically compute recipe keys NOT in the override spec by fetching + # the full resolved recipe and subtracting the spec keys + user-provided keys. + # Only consider keys with non-None values (None keys are skipped by _apply_recipe). + resolved_recipe = sft_trainer.get_resolved_recipe() + flat_recipe = flatten_resolved_recipe(resolved_recipe) + all_recipe_keys = {k for k, v in flat_recipe.items() if v is not None} + override_spec_keys = set(sft_trainer.hyperparameters._specs.keys()) + recipe_internal_keys_not_in_spec = all_recipe_keys - override_spec_keys - all_user_keys + + logger.info(f"Nova SFT — All recipe keys from Hub ({len(all_recipe_keys)}): {sorted(all_recipe_keys)}") + logger.info(f"Nova SFT — Override spec keys ({len(override_spec_keys)}): {sorted(override_spec_keys)}") + logger.info( + f"Nova SFT — Recipe internal keys NOT in spec ({len(recipe_internal_keys_not_in_spec)}): " + f"{sorted(recipe_internal_keys_not_in_spec)}" + ) + + assert len(recipe_internal_keys_not_in_spec) > 0, ( + "Expected Nova recipe template to have keys beyond the override spec, but found none." + ) + + # These internal recipe keys must NOT appear in the serverless result + leaked_keys = recipe_internal_keys_not_in_spec & set(result_hp.keys()) + assert not leaked_keys, ( + f"Non-overridable Nova recipe template keys leaked into serverless hyperparameters: " + f"{sorted(leaked_keys)}. These keys are not in the override spec and should be " + f"excluded when compute=None (serverless)." + ) + + # The full recipe (compute set) MUST contain these internal keys + missing_from_full = recipe_internal_keys_not_in_spec - set(full_hp.keys()) + assert not missing_from_full, ( + f"Full recipe (compute set) is missing expected internal keys: " + f"{sorted(missing_from_full)}. The full recipe path should include all template keys." + ) + + assert len(result_hp) <= 100, ( + f"Serverless hyperparameters have {len(result_hp)} keys, exceeding the " + f"CreateTrainingJob limit of 100." + ) From 1030ea2c771ca24b803ae099e68e422f455d25bf Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Tue, 14 Jul 2026 16:03:36 -0700 Subject: [PATCH 7/8] chore: undo minor indent changes in trainers --- sagemaker-train/src/sagemaker/train/dpo_trainer.py | 3 +-- .../src/sagemaker/train/multi_turn_rl_trainer.py | 6 ++---- sagemaker-train/src/sagemaker/train/rlvr_trainer.py | 2 +- sagemaker-train/src/sagemaker/train/sft_trainer.py | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sagemaker-train/src/sagemaker/train/dpo_trainer.py b/sagemaker-train/src/sagemaker/train/dpo_trainer.py index 1883d6f6dc..a83d1ed2fe 100644 --- a/sagemaker-train/src/sagemaker/train/dpo_trainer.py +++ b/sagemaker-train/src/sagemaker/train/dpo_trainer.py @@ -308,8 +308,7 @@ def train(self, final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: diff --git a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py index 84b11e5860..daf3c8f5d9 100644 --- a/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py +++ b/sagemaker-train/src/sagemaker/train/multi_turn_rl_trainer.py @@ -289,10 +289,8 @@ def train( self._final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). - self._final_hyperparameters = self._apply_recipe_to_hyperparameters( - self._final_hyperparameters - ) + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) + self._final_hyperparameters = self._apply_recipe_to_hyperparameters(self._final_hyperparameters) _validate_hyperparameter_values(self._final_hyperparameters) diff --git a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py index 7c0dc1535c..f20b54e7b1 100644 --- a/sagemaker-train/src/sagemaker/train/rlvr_trainer.py +++ b/sagemaker-train/src/sagemaker/train/rlvr_trainer.py @@ -471,7 +471,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: diff --git a/sagemaker-train/src/sagemaker/train/sft_trainer.py b/sagemaker-train/src/sagemaker/train/sft_trainer.py index ac0f59e4a0..3f14c348f0 100644 --- a/sagemaker-train/src/sagemaker/train/sft_trainer.py +++ b/sagemaker-train/src/sagemaker/train/sft_trainer.py @@ -361,7 +361,7 @@ def train(self, training_dataset: Optional[Union[str, DataSet]] = None, validati final_hyperparameters = self.hyperparameters.to_dict() - # Apply recipe/overrides if provided (overrides > recipe > Hub defaults). + # Apply recipe/overrides if provided (overrides > recipe > Hub defaults) final_hyperparameters = self._apply_recipe_to_hyperparameters(final_hyperparameters) # Resolve is_multimodal: auto-detect from training dataset if not explicitly set if self.is_multimodal is None: From 1e883b037d6062230be2c27b7ac5b612a0196aba Mon Sep 17 00:00:00 2001 From: Syed Jafri Date: Tue, 14 Jul 2026 16:21:10 -0700 Subject: [PATCH 8/8] code cleanup: recipe override tests --- .../tests/integ/train/test_recipe_override_integration.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sagemaker-train/tests/integ/train/test_recipe_override_integration.py b/sagemaker-train/tests/integ/train/test_recipe_override_integration.py index b1867436f1..aeb7f43f0d 100644 --- a/sagemaker-train/tests/integ/train/test_recipe_override_integration.py +++ b/sagemaker-train/tests/integ/train/test_recipe_override_integration.py @@ -27,6 +27,7 @@ from sagemaker.train.rlvr_trainer import RLVRTrainer from sagemaker.train.common import TrainingType from sagemaker.train.recipe_resolver import flatten_resolved_recipe +from sagemaker.core.training.configs import TrainingJobCompute # Ensure bundled service model is available for botocore @@ -1064,7 +1065,6 @@ def test_rlvr_serverless_only_user_override_keys_applied(self, sagemaker_session result_hp = rlvr_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) # Simulate serverful path (compute set): full recipe applied - from sagemaker.core.training.configs import TrainingJobCompute rlvr_trainer.compute = TrainingJobCompute(instance_type="ml.p5.48xlarge", instance_count=1) full_hp = rlvr_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) rlvr_trainer.compute = None # reset @@ -1163,7 +1163,6 @@ def test_sft_nova_serverless_only_user_override_keys_applied(self, sagemaker_ses result_hp = sft_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) # Simulate serverful path (compute set): full recipe applied - from sagemaker.core.training.configs import TrainingJobCompute sft_trainer.compute = TrainingJobCompute(instance_type="ml.p5.48xlarge", instance_count=1) full_hp = sft_trainer._apply_recipe_to_hyperparameters(baseline_hp.copy()) sft_trainer.compute = None # reset