diff --git a/docs/user-guide/running.md b/docs/user-guide/running.md index 2e1594fd2..b655b7158 100644 --- a/docs/user-guide/running.md +++ b/docs/user-guide/running.md @@ -255,7 +255,15 @@ safe-synthesizer run generate \ | `--run-path` | Explicit path to a previous run's output directory | | `--wandb-resume-job-id` | WandB run ID to resume (or path to file containing the ID) | -Accepts the same common options and synthesis parameter overrides as `run`. +Accepts the same common options and synthesis parameter override syntax as `run`. + +!!! note "Override scope on resume" + `run generate` reloads the trained run's saved configuration. Only + `generation` and `evaluation` overrides (and `emit_telemetry`) supplied via + `--config` or `--section__field` flags take effect; fields you do not set + keep their saved values. `training`, `data`, `privacy`, and `time_series` + are always inherited from the trained run and cannot be changed at generate + time, since they describe how the adapter was produced. ### `run --validate` diff --git a/src/nemo_safe_synthesizer/cli/run.py b/src/nemo_safe_synthesizer/cli/run.py index 17dfc5ef6..0d4cfb7cd 100644 --- a/src/nemo_safe_synthesizer/cli/run.py +++ b/src/nemo_safe_synthesizer/cli/run.py @@ -572,7 +572,7 @@ def run_generate( try: nss = ( - nss.load_from_save_path() + nss.load_from_save_path(runtime_config=config) .process_data() .generate() .evaluate() diff --git a/src/nemo_safe_synthesizer/cli/utils.py b/src/nemo_safe_synthesizer/cli/utils.py index fad664d04..4d6908cdf 100644 --- a/src/nemo_safe_synthesizer/cli/utils.py +++ b/src/nemo_safe_synthesizer/cli/utils.py @@ -276,7 +276,8 @@ def common_setup( df: pd.DataFrame | None = None if settings.data_source: dataset_info = dataset_registry.get_dataset(settings.data_source) - synthesis_overrides = merge_dicts(synthesis_overrides, dataset_info.overrides or dict()) + if not resume: + synthesis_overrides = merge_dicts(synthesis_overrides, dataset_info.overrides or dict()) df = dataset_info.fetch() elif resume: # For generate-only runs without --data-source, verify cached dataset exists. @@ -447,9 +448,8 @@ def merge_overrides(config_path: str | Path | None, overrides: dict) -> SafeSynt if config_path is None: my_config = SafeSynthesizerParameters.model_validate(overrides) else: - params = merge_dicts( - SafeSynthesizerParameters.from_yaml(config_path).model_dump(exclude_unset=False), overrides - ) + file_config = SafeSynthesizerParameters.from_yaml(config_path).model_dump(exclude_unset=True) + params = merge_dicts(file_config, overrides) my_config = SafeSynthesizerParameters.model_validate(params) except ValidationError as e: click.echo(f"{config_path} is invalid:\n{e}") diff --git a/src/nemo_safe_synthesizer/config/parameters.py b/src/nemo_safe_synthesizer/config/parameters.py index 43e28644b..9829de50b 100644 --- a/src/nemo_safe_synthesizer/config/parameters.py +++ b/src/nemo_safe_synthesizer/config/parameters.py @@ -6,12 +6,13 @@ import warnings from typing import Any, Self -from pydantic import Field, model_validator +from pydantic import BaseModel, Field, model_validator from ..configurator.parameters import Parameters from ..errors import ParameterError from ..observability import get_logger from ..telemetry import _telemetry_enabled +from ..utils import merge_dicts from .data import DataParameters from .differential_privacy import DifferentialPrivacyHyperparams from .evaluate import EvaluationParameters @@ -28,6 +29,42 @@ logger = get_logger(__name__) +def _collect_set_fields(model: BaseModel) -> dict[str, Any]: + """Recursively collect a model's explicitly-set fields as a nested dict. + + Unlike ``model_dump(exclude_unset=True)``, nested models are always + traversed -- even when the parent did not mark the nested field as set -- + so in-place mutations of nested fields (e.g. + ``cfg.generation.validation.foo = True``) are captured. A nested model is + included only when it has at least one set field of its own. + """ + overrides: dict[str, Any] = {} + for name in type(model).model_fields: + value = getattr(model, name) + if isinstance(value, BaseModel): + nested = _collect_set_fields(value) + if nested: + overrides[name] = nested + elif name in model.__pydantic_fields_set__: + overrides[name] = value + return overrides + + +def _overlay_set_fields(saved: Parameters, runtime: Parameters) -> Parameters: + """Deep-merge ``runtime``'s explicitly-set fields onto ``saved``. + + Only fields ``runtime`` marks as set (recursively, at every nesting level) + override ``saved``; unset fields keep their saved values. The merged mapping + is revalidated through the model so nested groups and type coercion are + handled correctly. Returns ``saved`` unchanged when ``runtime`` sets no + fields. + """ + overrides = _collect_set_fields(runtime) + if not overrides: + return saved + return saved.model_validate(merge_dicts(saved.model_dump(), overrides)) + + class SafeSynthesizerParameters(Parameters): """Main configuration class for the Safe Synthesizer pipeline. @@ -199,3 +236,38 @@ def from_params(cls, **kwargs) -> "SafeSynthesizerParameters": if "emit_telemetry" in kwargs: extra["emit_telemetry"] = kwargs["emit_telemetry"] return cls(**extra) + + def with_runtime_overrides(self, runtime: SafeSynthesizerParameters) -> "SafeSynthesizerParameters": + """Apply resume-time generation/evaluation/telemetry overrides onto a copy of self. + + ``self`` is the saved training-run config. Only explicitly-set + ``generation`` and ``evaluation`` fields from ``runtime`` are merged in, + plus ``emit_telemetry`` when the caller set it. Training, data, privacy, + and other sections are preserved so training provenance survives a + generate-only resume. + + Args: + runtime: Config carrying resume-time CLI/SDK overrides. Typically + sparse -- only the fields the caller set are applied. + + Returns: + A new ``SafeSynthesizerParameters`` with overrides applied. The + result is fully independent of ``self``: sections that are not + overridden are deep-copied, so later mutation of either object does + not affect the other. + """ + updates: dict[str, object] = {} + # Only record sections that actually changed; unchanged sections are + # deep-copied by ``model_copy(deep=True)`` below so the returned config + # never shares mutable sub-objects with ``self``. + generation = _overlay_set_fields(self.generation, runtime.generation) + if generation is not self.generation: + updates["generation"] = generation + evaluation = _overlay_set_fields(self.evaluation, runtime.evaluation) + if evaluation is not self.evaluation: + updates["evaluation"] = evaluation + # emit_telemetry is a top-level scalar: detect explicit assignment, + # since there is no sub-model to inspect for set fields. + if "emit_telemetry" in runtime.__pydantic_fields_set__: + updates["emit_telemetry"] = runtime.emit_telemetry + return self.model_copy(update=updates, deep=True) diff --git a/src/nemo_safe_synthesizer/sdk/library_builder.py b/src/nemo_safe_synthesizer/sdk/library_builder.py index 901212750..678936fd5 100644 --- a/src/nemo_safe_synthesizer/sdk/library_builder.py +++ b/src/nemo_safe_synthesizer/sdk/library_builder.py @@ -7,6 +7,7 @@ import os import time +from collections.abc import Iterator from pathlib import Path from typing import TYPE_CHECKING @@ -18,6 +19,7 @@ SafeSynthesizerParameters, ) from ..config.autoconfig import AutoConfigResolver +from ..configurator.parameters import Parameters from ..errors import ParameterError from ..evaluation.evaluator import Evaluator from ..generation.timeseries_backend import TimeseriesBackend @@ -51,6 +53,37 @@ from ..training.backend import TrainingBackend +def _direct_parameter_values(params: Parameters) -> Iterator[tuple[str, object]]: + for item in params._iter_parameters(recursive=False): + yield next(iter(item.items())) + + +def _default_drift_paths(saved_model: Parameters, default_model: Parameters, prefix: str = "") -> list[str]: + paths: list[str] = [] + default_values = dict(_direct_parameter_values(default_model)) + for field_name, saved_value in _direct_parameter_values(saved_model): + default_value = default_values[field_name] + field_path = f"{prefix}.{field_name}" if prefix else field_name + + saved_attr = saved_model.__dict__[field_name] + default_attr = default_model.__dict__[field_name] + if isinstance(saved_attr, Parameters) and isinstance(default_attr, Parameters): + paths.extend(_default_drift_paths(saved_attr, default_attr, field_path)) + elif saved_value != default_value: + paths.append(field_path) + return paths + + +def _warn_for_saved_default_drift(saved_config: SafeSynthesizerParameters) -> None: + """Warn when saved materialized values differ from current defaults.""" + current_defaults = SafeSynthesizerParameters() + for path in _default_drift_paths(saved_config, current_defaults): + logger.user.warning( + f"Saved run config value at {path} is non-default; preserving saved value.", + extra={"config_path": path}, + ) + + def _build_telemetry_event(ss: SafeSynthesizer, status: TaskStatusEnum) -> NSSTrainingAndGenerationEvent: """Build a telemetry event from the current pipeline state.""" cfg = ss._nss_config @@ -237,12 +270,15 @@ def _ensure_observability(self) -> None: initialize_observability() @traced("SafeSynthesizer.load_from_save_path", category=LogCategory.RUNTIME) - def load_from_save_path(self) -> SafeSynthesizer: + def load_from_save_path(self, runtime_config: SafeSynthesizerParameters | None = None) -> SafeSynthesizer: """Load the Safe Synthesizer configuration from the save path. Loads the configuration from the source run directory's config file. When resuming from a trained model for generation, the source paths point to the parent workdir that contains the trained adapter. + Optional ``runtime_config`` values for generation and evaluation are + applied after loading the saved training-run config so resume-time CLI + overrides work without mutating the persisted train config. Always prefers cached train/test splits from the training run to ensure evaluation metrics are consistent and privacy guarantees are maintained. @@ -256,7 +292,14 @@ def load_from_save_path(self) -> SafeSynthesizer: # Use source paths which point to parent workdir when resuming for generation config_file = self._workdir.source_config - self._nss_config = SafeSynthesizerParameters.from_json(config_file) + saved_config = SafeSynthesizerParameters.from_json(config_file) + _warn_for_saved_default_drift(saved_config) + if runtime_config is not None: + saved_config = saved_config.with_runtime_overrides(runtime_config) + self._nss_config = saved_config + self._generation_config = self._nss_config.generation + self._evaluation_config = self._nss_config.evaluation + self._emit_telemetry_config = self._nss_config.emit_telemetry # Load model metadata from saved file (contains initial_prefill for timeseries) # rather than creating new metadata from config diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index e95103a55..c89ea1e9e 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -633,6 +633,7 @@ def test_generate_with_run_path_calls_common_setup( dummy_csv: Path, tmp_path: Path, patched_run_dependencies: dict, + mock_common_setup_return: tuple, ): """Verify generate with --run-path calls common_setup correctly.""" run_dir = tmp_path / "trained-run" @@ -659,6 +660,7 @@ def test_generate_with_run_path_calls_common_setup( settings: CLISettings = call_kwargs["settings"] assert settings.run_path == str(run_dir) mock_ss = patched_run_dependencies["safe_synthesizer"] + mock_ss.load_from_save_path.assert_called_once_with(runtime_config=mock_common_setup_return[1]) mock_ss.evaluate.assert_called_once_with() patched_run_dependencies["emit_telemetry"].assert_called_once_with(mock_ss, TaskStatusEnum.COMPLETED) diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index 3a694f67e..de3fc3d35 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -13,7 +13,7 @@ import pytest from nemo_safe_synthesizer.cli.settings import CLISettings -from nemo_safe_synthesizer.cli.utils import _propagate_runtime_settings_to_env, common_setup +from nemo_safe_synthesizer.cli.utils import _propagate_runtime_settings_to_env, common_setup, merge_overrides @pytest.fixture @@ -230,6 +230,29 @@ def test_merge_dataset_and_cli_overrides( assert config.training.batch_size == 8 assert config.data.holdout == 0.1 + def test_resume_uses_registry_data_without_registry_config_overrides( + self, + registry_with_base_url: Path, + patched_common_setup_dependencies: dict, + ): + """Resume treats dataset registry overrides as default-like, not runtime overrides.""" + settings = CLISettings.from_cli_kwargs( + data_source="test-data", + dataset_registry=str(registry_with_base_url), + synthesis_overrides={ + "generation": { + "temperature": 0.7, + }, + }, + ) + + _, config, df, _ = common_setup(settings, resume=True) + + assert df is not None + assert list(df.columns) == ["x", "y", "z"] + assert config.generation.num_records == 1000 + assert config.generation.temperature == 0.7 + def test_overrides_config_registry_and_cli( self, registry_with_dataset: Path, @@ -327,6 +350,24 @@ def test_apply_cli_overrides_without_registry( assert config.generation.temperature == 0.7 +class TestMergeOverrides: + """Tests for config-file and CLI override merging.""" + + def test_partial_config_preserves_explicit_field_metadata(self, tmp_path: Path): + """Partial config files should not make default values look explicit.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +generation: + num_records: 77 +""") + + config = merge_overrides(config_file, {}) + + assert config.generation.num_records == 77 + assert config.generation.use_structured_generation is False + assert config.model_dump(exclude_unset=True) == {"generation": {"num_records": 77}} + + class TestPropagateRuntimeSettingsToEnv: """Tests for materializing CLISettings runtime fields back to os.environ.""" diff --git a/tests/config/test_parameters.py b/tests/config/test_parameters.py index 667752777..1dda3cd2e 100644 --- a/tests/config/test_parameters.py +++ b/tests/config/test_parameters.py @@ -131,3 +131,119 @@ def test_from_params_absent_enables_pii(): def test_from_params_none_disables_pii(): assert SafeSynthesizerParameters.from_params(replace_pii=None).replace_pii is None + + +def _resolve(obj: object, path: str) -> object: + """Resolve a dotted attribute ``path`` (e.g. ``generation.validation.foo``).""" + for part in path.split("."): + obj = getattr(obj, part) + return obj + + +def _saved_config() -> SafeSynthesizerParameters: + """Saved training-run config with customized generation, validation, and telemetry.""" + saved = SafeSynthesizerParameters() + saved.training.batch_size = 8 + saved.emit_telemetry = False + saved.generation.num_records = 3000 + saved.generation.use_structured_generation = True + saved.generation.validation.group_by_ignore_invalid_records = True + return saved + + +def _runtime_num_records() -> SafeSynthesizerParameters: + """Runtime config overriding only a top-level generation field via mutation.""" + runtime = SafeSynthesizerParameters() + runtime.generation.num_records = 100 + return runtime + + +def _runtime_nested_validation() -> SafeSynthesizerParameters: + """Runtime config overriding a nested generation.validation field via mutation.""" + runtime = SafeSynthesizerParameters() + runtime.generation.validation.group_by_fix_unordered_records = True + return runtime + + +class TestWithRuntimeOverrides: + """Tests for resume-time generation/evaluation/telemetry override merging.""" + + @pytest.mark.parametrize( + ("make_runtime", "expected"), + [ + pytest.param( + SafeSynthesizerParameters, + { + "generation.num_records": 3000, + "generation.use_structured_generation": True, + "generation.validation.group_by_ignore_invalid_records": True, + "training.batch_size": 8, + "emit_telemetry": False, + }, + id="empty-runtime-preserves-saved", + ), + pytest.param( + _runtime_num_records, + { + "generation.num_records": 100, + "generation.use_structured_generation": True, # unset runtime field preserved + "training.batch_size": 8, # non-overridable section inherited + }, + id="top-level-generation-override", + ), + pytest.param( + _runtime_nested_validation, + { + "generation.validation.group_by_fix_unordered_records": True, + "generation.validation.group_by_ignore_invalid_records": True, # saved sibling kept + "generation.num_records": 3000, + }, + id="nested-validation-via-mutation", + ), + pytest.param( + lambda: SafeSynthesizerParameters.model_validate( + {"generation": {"validation": {"group_by_fix_unordered_records": True}}} + ), + { + "generation.validation.group_by_fix_unordered_records": True, + "generation.validation.group_by_ignore_invalid_records": True, # saved sibling kept + }, + id="nested-validation-via-dict", + ), + pytest.param( + lambda: SafeSynthesizerParameters.model_validate({}), + {"emit_telemetry": False}, + id="telemetry-unset-keeps-saved", + ), + pytest.param( + lambda: SafeSynthesizerParameters.model_validate({"emit_telemetry": True}), + {"emit_telemetry": True}, + id="telemetry-set-applied", + ), + ], + ) + def test_overrides(self, make_runtime, expected: dict[str, object]): + merged = _saved_config().with_runtime_overrides(make_runtime()) + for path, value in expected.items(): + assert _resolve(merged, path) == value, path + + def test_does_not_mutate_saved(self): + saved = _saved_config() + saved.with_runtime_overrides(_runtime_num_records()) + assert saved.generation.num_records == 3000 + + def test_returned_config_is_independent_of_saved(self): + """Mutating the returned config must not affect the original (no shared references).""" + saved = _saved_config() + merged = saved.with_runtime_overrides(_runtime_num_records()) + + # Inherited section, overridden section, and an unchanged section. + merged.data.holdout = 0.42 + merged.training.batch_size = 99 + merged.generation.use_structured_generation = False + + assert merged.data is not saved.data + assert merged.training is not saved.training + assert saved.data.holdout != 0.42 + assert saved.training.batch_size == 8 + assert saved.generation.use_structured_generation is True diff --git a/tests/sdk/test_process_data.py b/tests/sdk/test_process_data.py index adebe4ade..f2e4c2567 100644 --- a/tests/sdk/test_process_data.py +++ b/tests/sdk/test_process_data.py @@ -439,6 +439,178 @@ def test_load_restores_training_split( assert builder._original_training_df is not None pd.testing.assert_frame_equal(builder._original_training_df, train_split) + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") + def test_load_applies_runtime_generation_and_evaluation_config( + self, + mock_metadata_cls, + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ): + """Resume keeps train config from disk while honoring generation overrides.""" + workdir, _, _ = self._prepare_workdir( + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ) + saved_config = SafeSynthesizerParameters() + saved_config.training.batch_size = 8 + saved_config.generation.num_records = 3000 + saved_config.generation.use_structured_generation = True + saved_config.generation.structured_generation_schema_method = "regex" + workdir.config.write_text(saved_config.model_dump_json()) + + runtime_config = SafeSynthesizerParameters() + runtime_config.training.batch_size = 32 + runtime_config.generation.num_records = 100 + runtime_config.generation.structured_generation_schema_method = "json_schema" + runtime_config.evaluation.enabled = False + mock_metadata_cls.from_metadata_json.return_value = MagicMock() + + builder = SafeSynthesizer(config=runtime_config, workdir=workdir) + builder.load_from_save_path(runtime_config=runtime_config) + + assert builder._nss_config is not None + assert builder._nss_config.training.batch_size == 8 + assert builder._nss_config.generation.num_records == 100 + # Saved value not re-specified at runtime is preserved (field-level merge). + assert builder._nss_config.generation.use_structured_generation is True + assert builder._nss_config.generation.structured_generation_schema_method == "json_schema" + assert builder._nss_config.evaluation.enabled is False + + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") + def test_load_preserves_saved_generation_when_runtime_config_has_no_overrides( + self, + mock_metadata_cls, + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ): + """Default runtime config must not reset saved generation settings.""" + workdir, _, _ = self._prepare_workdir( + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ) + saved_config = SafeSynthesizerParameters() + saved_config.generation.num_records = 3000 + saved_config.generation.use_structured_generation = True + saved_config.generation.structured_generation_schema_method = "json_schema" + saved_config.evaluation.enabled = True + workdir.config.write_text(saved_config.model_dump_json()) + + mock_metadata_cls.from_metadata_json.return_value = MagicMock() + + builder = SafeSynthesizer(config=SafeSynthesizerParameters(), workdir=workdir) + builder.load_from_save_path(runtime_config=SafeSynthesizerParameters()) + + assert builder._nss_config is not None + assert builder._nss_config.generation.num_records == 3000 + assert builder._nss_config.generation.use_structured_generation is True + assert builder._nss_config.generation.structured_generation_schema_method == "json_schema" + assert builder._nss_config.evaluation.enabled is True + + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") + def test_load_deep_merges_nested_validation_overrides( + self, + mock_metadata_cls, + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ): + """Resume merges nested generation.validation fields without dropping saved siblings.""" + workdir, _, _ = self._prepare_workdir( + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ) + saved_config = SafeSynthesizerParameters() + saved_config.generation.num_records = 3000 + saved_config.generation.validation.group_by_ignore_invalid_records = True + workdir.config.write_text(saved_config.model_dump_json()) + + runtime_config = SafeSynthesizerParameters() + runtime_config.generation.validation.group_by_fix_unordered_records = True + mock_metadata_cls.from_metadata_json.return_value = MagicMock() + + builder = SafeSynthesizer(config=runtime_config, workdir=workdir) + builder.load_from_save_path(runtime_config=runtime_config) + + assert builder._nss_config is not None + # Nested override applied. + assert builder._nss_config.generation.validation.group_by_fix_unordered_records is True + # Saved sibling in the same nested group preserved. + assert builder._nss_config.generation.validation.group_by_ignore_invalid_records is True + # Unrelated saved generation field preserved. + assert builder._nss_config.generation.num_records == 3000 + + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") + def test_load_full_runtime_config_replaces_supported_runtime_sections( + self, + mock_metadata_cls, + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ): + """A fully materialized generate-time config replaces generation/evaluation sections.""" + workdir, _, _ = self._prepare_workdir( + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ) + saved_config = SafeSynthesizerParameters() + saved_config.training.batch_size = 8 + saved_config.generation.num_records = 3000 + saved_config.generation.use_structured_generation = True + saved_config.generation.structured_generation_schema_method = "json_schema" + saved_config.evaluation.enabled = True + workdir.config.write_text(saved_config.model_dump_json()) + + # model_validate(model_dump(...)) marks every field as explicitly set, so + # the whole generation/evaluation sections override the saved values. + runtime_config = SafeSynthesizerParameters.model_validate(SafeSynthesizerParameters().model_dump(mode="json")) + runtime_config.generation.num_records = 100 + runtime_config.evaluation.enabled = False + mock_metadata_cls.from_metadata_json.return_value = MagicMock() + + builder = SafeSynthesizer(config=runtime_config, workdir=workdir) + builder.load_from_save_path(runtime_config=runtime_config) + + assert builder._nss_config is not None + assert builder._nss_config.training.batch_size == 8 + assert builder._nss_config.generation.num_records == 100 + assert builder._nss_config.generation.use_structured_generation is False + assert builder._nss_config.generation.structured_generation_schema_method == "regex" + assert builder._nss_config.evaluation.enabled is False + + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") + def test_load_warns_when_saved_values_differ_from_current_defaults( + self, + mock_metadata_cls, + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ): + """Default drift warnings fire because saved configs carry no provenance metadata.""" + workdir, _, _ = self._prepare_workdir( + tmp_path, + fixture_sample_patient_dataframe, + fixture_sample_patient_redacted_dataframe, + ) + saved_config = SafeSynthesizerParameters() + saved_config.generation.num_records = 3000 + workdir.config.write_text(saved_config.model_dump_json()) + mock_metadata_cls.from_metadata_json.return_value = MagicMock() + + builder = SafeSynthesizer(config=SafeSynthesizerParameters(), workdir=workdir) + with patch("nemo_safe_synthesizer.sdk.library_builder.logger") as mock_logger: + builder.load_from_save_path() + + warning_messages = [call.args[0] for call in mock_logger.user.warning.call_args_list] + assert any("generation.num_records" in message for message in warning_messages) + assert builder._nss_config is not None + assert builder._nss_config.generation.num_records == 3000 + @patch("nemo_safe_synthesizer.sdk.library_builder.ModelMetadata") def test_process_data_skips_when_cached_splits_loaded( self,