Skip to content

validation

validation

Experiment validation module for validating global training experiment configuration.

ExperimentValidationError

Bases: Exception

Raised when experiment validation fails.

ExperimentValidator

ExperimentValidator(encoding_pipeline, algorithm, decoder, observation_space, action_space, loss, is_tokenized=False, tokenized_obs_keys=None, image_norm_type=None, quantization_config=None, training_config=None)

Validates experiment configuration consistency.

Validates encoder-observation consistency, decoder-encoder compatibility, and loss key validation. When training_config.stages is provided, the validator also owns checks required by multi-stage training:

Initialize validator with policy components.

Parameters:

Name Type Description Default
encoding_pipeline EncodingPipeline

The encoding pipeline with configured encoders.

required
algorithm DecodingAlgorithm

The decoding algorithm (BC, diffusion, etc.).

required
decoder ActionDecoder

The action decoder architecture.

required
observation_space ObservationSpace

Task observation space configuration.

required
action_space ActionSpace

Task action space configuration.

required
loss BaseLoss

The loss module for training.

required
is_tokenized bool

Whether observations are tokenized.

False
tokenized_obs_keys set[str] | None

Keys of observations that are tokenized.

None
image_norm_type str | None

RGB image normalization type for pretrained vision encoder validation.

None
quantization_config BaseQuantizationWorkflow | None

Optional quantization configuration to validate.

None
training_config TrainingConfig | None

Training configuration. When provided with stages, the validator also checks stage ordering, optimizer group references, and loss-weight paths against the loss tree.

None
Source code in src/versatil/validation.py
def __init__(
    self,
    encoding_pipeline: EncodingPipeline,
    algorithm: DecodingAlgorithm,
    decoder: ActionDecoder,
    observation_space: ObservationSpace,
    action_space: ActionSpace,
    loss: BaseLoss,
    is_tokenized: bool = False,
    tokenized_obs_keys: set[str] | None = None,
    image_norm_type: str | None = None,
    quantization_config: BaseQuantizationWorkflow | None = None,
    training_config: TrainingConfig | None = None,
):
    """Initialize validator with policy components.

    Args:
        encoding_pipeline: The encoding pipeline with configured encoders.
        algorithm: The decoding algorithm (BC, diffusion, etc.).
        decoder: The action decoder architecture.
        observation_space: Task observation space configuration.
        action_space: Task action space configuration.
        loss: The loss module for training.
        is_tokenized: Whether observations are tokenized.
        tokenized_obs_keys: Keys of observations that are tokenized.
        image_norm_type: RGB image normalization type for pretrained vision
            encoder validation.
        quantization_config: Optional quantization configuration to validate.
        training_config: Training configuration. When provided with
            ``stages``, the validator also checks stage ordering, optimizer
            group references, and loss-weight paths against the loss tree.
    """
    self.encoding_pipeline = encoding_pipeline
    self.algorithm = algorithm
    self.decoder = decoder
    self.observation_space = observation_space
    self.action_space = action_space
    self.loss = loss
    self.is_tokenized = is_tokenized
    self.tokenized_obs_keys = tokenized_obs_keys or set()
    self.image_norm_type = image_norm_type
    self.quantization_config = quantization_config
    self.training_config = training_config
    self.errors: list[str] = []
    self.warnings: list[str] = []

validate_all

validate_all(validate_loss_keys=True)

Run all validation checks and raise if any fail.

Parameters:

Name Type Description Default
validate_loss_keys bool

Whether to validate loss keys against action heads.

True
Source code in src/versatil/validation.py
def validate_all(self, validate_loss_keys: bool = True) -> None:
    """Run all validation checks and raise if any fail.

    Args:
        validate_loss_keys: Whether to validate loss keys against action heads.
    """
    self.validate_encoder_observation_consistency()
    self.validate_decoder_encoder_compatibility()
    self.validate_loss_algorithm_compatibility()
    if validate_loss_keys:
        self.validate_loss_keys()
    if self.quantization_config is not None:
        self.validate_quantization()
    if self.training_config is not None and self.training_config.stages:
        self.validate_stage_ordering()
        self.validate_stage_group_references()
        self.validate_stage_loss_paths()
    if self.errors:
        error_msg = "\n".join([f"  - {err}" for err in self.errors])
        raise ExperimentValidationError(
            f"Policy validation failed with {len(self.errors)} error(s):\n{error_msg}"
        )
    if self.warnings:
        warning_msg = "\n".join([f"  - {warn}" for warn in self.warnings])
        logging.warning(
            msg=f"Policy validation warnings ({len(self.warnings)}):\n{warning_msg}"
        )

validate_encoder_observation_consistency

validate_encoder_observation_consistency()

Validate that encoder inputs match available observations.

Source code in src/versatil/validation.py
def validate_encoder_observation_consistency(self) -> None:
    """Validate that encoder inputs match available observations."""
    has_language = (
        ObsKey.LANGUAGE.value in self.observation_space.observations_metadata
    )
    if has_language and not self.is_tokenized:
        self.errors.append(
            "Language observations are enabled but tokenization is disabled. "
            "Language observations require tokenization to be enabled."
        )
        return
    available_keys = self._available_observation_keys()
    if (
        has_language
        and self.is_tokenized
        and self.tokenized_obs_keys
        and ObsKey.LANGUAGE.value not in self.tokenized_obs_keys
    ):
        self.errors.append(
            f"Language observations are enabled but '{ObsKey.LANGUAGE.value}' is not in "
            f"observation_tokenizer.observation_keys: {self.tokenized_obs_keys}"
        )

    configured_encoder_inputs = set()
    for encoder_name, encoder in self.encoding_pipeline.encoders.items():
        encoder: EncodingMixin
        self._validate_encoder_image_normalization(
            encoder_name=encoder_name,
            encoder=encoder,
        )

        input_keys = encoder.input_specification.keys
        if isinstance(input_keys, str):
            input_keys = [input_keys]

        configured_encoder_inputs.update(input_keys)
        missing = set(input_keys) - available_keys
        if missing:
            self.errors.append(
                f"Encoder '{encoder_name}' requires keys {missing} "
                f"which are not in observation space. "
                f"Available keys: {available_keys}. "
                f"Please either add them to the observation space or modify encoder configuration."
            )

        self._validate_camera_modality_constraints(
            owner_name=f"Encoder '{encoder_name}'",
            input_specification=encoder.input_specification,
        )
        for key in input_keys:
            metadata = self.observation_space.observations_metadata.get(key)
            if metadata is None:
                continue
            error = encoder.validate_input_metadata(key=key, metadata=metadata)
            if error:
                self.errors.append(f"Encoder '{encoder_name}': {error}")

    configured_encoder_inputs.update(
        self._validate_decoder_observation_inputs(available_keys=available_keys)
    )

    uncovered_keys = available_keys - configured_encoder_inputs
    uncovered_keys -= {
        SampleKey.TOKENIZED_OBSERVATIONS.value,
        SampleKey.IS_PAD_OBSERVATION.value,
    }
    if uncovered_keys:
        self.warnings.append(
            f"Observation space contains keys {uncovered_keys} "
            f"but no encoder is configured to process them."
        )

validate_decoder_encoder_compatibility

validate_decoder_encoder_compatibility()

Validate that decoder inputs match encoder outputs or raw observations.

Source code in src/versatil/validation.py
def validate_decoder_encoder_compatibility(self) -> None:
    """Validate that decoder inputs match encoder outputs or raw observations."""
    available_features = self.encoding_pipeline.get_features()
    available_observation_keys = self._available_observation_keys()
    available_feature_names = set(available_features.keys())
    available_input_names = (
        available_feature_names
        | available_observation_keys
        | self.algorithm.injected_feature_keys()
    )
    decoder_input_keys = self.decoder.decoder_input.keys

    for expected_feature in decoder_input_keys:
        if expected_feature not in available_input_names:
            self.errors.append(
                f"Action decoder expects input key '{expected_feature}' but it "
                "is neither a raw observation nor produced by any encoder or "
                "fusion layer. Available raw observations: "
                f"{sorted(available_observation_keys)}. Available encoded "
                f"features: {sorted(available_feature_names)}"
            )

    self.decoder.decoder_input.validate_feature_types(
        available_features={
            **available_features,
            **self._observation_feature_metadata(),
        }
    )

    if isinstance(self.decoder, MoEDecoder):
        self._validate_moe_gating_feature(sorted(available_feature_names))

validate_loss_algorithm_compatibility

validate_loss_algorithm_compatibility()

Validate that no loss module requires action-space targets when the algorithm predicts outside it.

Source code in src/versatil/validation.py
def validate_loss_algorithm_compatibility(self) -> None:
    """Validate that no loss module requires action-space targets when the algorithm predicts outside it."""
    if self.algorithm.predicts_in_action_space:
        return
    algorithm_name = type(self.algorithm).__name__
    for name, loss_module in self.loss.loss_modules.items():
        if loss_module.requires_action_space_targets:
            self.errors.append(
                f"Loss module '{name}' requires action-space targets "
                f"but algorithm '{algorithm_name}' predicts outside the "
                f"action space (e.g. velocity or noise). Use a regression "
                f"loss (MSE/L1) instead."
            )

validate_loss_keys

validate_loss_keys()

Validate that loss keys reference valid action heads or auxiliary keys.

Source code in src/versatil/validation.py
def validate_loss_keys(self) -> None:
    """Validate that loss keys reference valid action heads or auxiliary keys."""
    valid_loss_keys: set[str] = set()
    valid_loss_keys.update(self.decoder.get_loss_output_keys())

    for key, meta in self.action_space.actions_metadata.items():
        if not meta.requires_prediction_head:
            valid_loss_keys.add(key)

    valid_loss_keys.update(self.algorithm.get_auxiliary_output_keys())
    valid_loss_keys.update(self.decoder.get_auxiliary_output_keys())
    required_keys = self.loss.get_required_keys()
    invalid_keys = required_keys - valid_loss_keys
    if invalid_keys:
        self.errors.append(
            f"Loss module references keys {invalid_keys} that are not "
            f"defined in the action space or auxiliary keys. "
            f"Valid loss keys: {valid_loss_keys}. "
            f"Please update your loss configuration or decoder."
        )

validate_quantization

validate_quantization()

Validate that quantization config is present and is a valid strategy.

Source code in src/versatil/validation.py
def validate_quantization(self) -> None:
    """Validate that quantization config is present and is a valid strategy."""
    config = self.quantization_config
    if config is None:
        return
    if not isinstance(config, BaseQuantizationWorkflow):
        self.errors.append(
            f"Quantization config is type {type(config).__name__}, "
            "expected a quantization workflow with quantization_mode. "
            f"This may indicate incorrect Hydra instantiation."
        )

validate_stage_ordering

validate_stage_ordering()

Validate stage names are unique and start epochs are strictly ordered.

Note

Gaps between stages are allowed. Those epochs fall back to the cached base regime at runtime.

Source code in src/versatil/validation.py
def validate_stage_ordering(self) -> None:
    """Validate stage names are unique and start epochs are strictly ordered.

    Note:
        Gaps between stages are allowed. Those epochs fall back to the cached
        base regime at runtime.
    """
    stages = self.training_config.stages
    names = [stage.name for stage in stages]
    duplicates = sorted({name for name in names if names.count(name) > 1})
    if duplicates:
        self.errors.append(f"Training stage names must be unique: {duplicates}.")
    for previous, current in zip(stages, stages[1:], strict=False):
        if current.start_epoch <= previous.start_epoch:
            self.errors.append(
                "training.stages must be listed in strictly increasing "
                "start_epoch order."
            )
            break
    for previous, current in zip(stages, stages[1:], strict=False):
        if (
            previous.end_epoch is not None
            and previous.end_epoch > current.start_epoch
        ):
            self.errors.append("training.stages intervals must not overlap.")
            break

validate_stage_group_references

validate_stage_group_references()

Validate that every staged group name exists in the optimizer layout.

The reserved unmatched group is always considered available because LightningPolicy injects it when building optimizer parameter groups.

Source code in src/versatil/validation.py
def validate_stage_group_references(self) -> None:
    """Validate that every staged group name exists in the optimizer layout.

    The reserved unmatched group is always considered available because
    ``LightningPolicy`` injects it when building optimizer parameter groups.
    """
    available_groups = {OPTIMIZER_UNMATCHED_GROUPS_NAME}
    available_groups.update(
        group.name for group in self.training_config.optimizer.param_groups
    )
    for stage in self.training_config.stages:
        referenced = (
            set(stage.trainable_groups)
            | set(stage.frozen_groups)
            | set(stage.group_lrs)
            | set(stage.group_weight_decays)
        )
        missing = sorted(referenced - available_groups)
        if missing:
            self.errors.append(
                f"Training stage '{stage.name}' references unknown optimizer "
                f"groups {missing}. Available groups: "
                f"{sorted(available_groups)}."
            )

validate_stage_loss_paths

validate_stage_loss_paths()

Validate every staged loss_weights patch against the loss tree.

stage.loss_weights must be a nested partial tree compatible with policy.loss_module.weights. Unknown keys and dict/scalar shape mismatches are rejected here so training fails before the callback ever mutates runtime state.

Source code in src/versatil/validation.py
def validate_stage_loss_paths(self) -> None:
    """Validate every staged ``loss_weights`` patch against the loss tree.

    ``stage.loss_weights`` must be a nested partial tree compatible with
    ``policy.loss_module.weights``. Unknown keys and dict/scalar shape
    mismatches are rejected here so training fails before the callback ever
    mutates runtime state.
    """
    loss_tree = self.loss.weights
    uses_loss_weights = any(
        stage.loss_weights for stage in self.training_config.stages
    )
    if uses_loss_weights and not loss_tree:
        self.errors.append(
            "training.stages declare loss_weights overrides but the loss "
            "module exposes no tunable weights."
        )
        return
    for stage in self.training_config.stages:
        if not stage.loss_weights:
            continue
        try:
            _merge_weights(
                existing_weights=loss_tree,
                override_weights=stage.loss_weights,
            )
        except (KeyError, TypeError) as exc:
            self.errors.append(f"Training stage '{stage.name}' loss_weights: {exc}")

validate_experiment

validate_experiment(config)

Validate experiment configuration from instantiated MainConfig.

Parameters:

Name Type Description Default
config MainConfig

Instantiated MainConfig with experiment, policy, and task components.

required

Raises:

Type Description
ExperimentValidationError

If validation fails.

Source code in src/versatil/validation.py
def validate_experiment(config: MainConfig) -> None:
    """Validate experiment configuration from instantiated MainConfig.

    Args:
        config: Instantiated MainConfig with experiment, policy, and task components.

    Raises:
        ExperimentValidationError: If validation fails.
    """
    task = config.task
    policy = config.policy
    dataloader = task.dataloader
    is_tokenized = dataloader.tokenization.tokenize_observations
    tokenized_obs_keys = set()
    if is_tokenized and dataloader.tokenization.observation_tokenizer:
        tokenized_obs_keys = set(
            dataloader.tokenization.observation_tokenizer.observation_keys
        )
    validator = ExperimentValidator(
        encoding_pipeline=policy.encoding_pipeline,
        algorithm=policy.algorithm,
        decoder=policy.decoder,
        observation_space=task.observation_space,
        action_space=task.action_space,
        loss=policy.loss_module,
        is_tokenized=is_tokenized,
        tokenized_obs_keys=tokenized_obs_keys,
        image_norm_type=dataloader.image_norm_type,
        quantization_config=config.quantization,
        training_config=config.training,
    )
    validator.validate_all(validate_loss_keys=config.experiment.validate_loss_keys)