Source code for nvalchemi.training._spec

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Reproducible, no-pickle serialization of MLIP hyperparameters.

This module provides :class:`BaseSpec`, a Pydantic model that captures the
keyword arguments of any importable target callable --- typically an MLIP
constructor, model factory, optimizer, or learning-rate scheduler --- and
serializes them to plain JSON. Spec reconstruction imports the target callable
by its dotted path and invokes it with the stored kwargs. This approach ensures that ``pickle`` is not needed
to recreate objects at runtime:

- Hyperparameters are stored as plain JSON (strings, numbers, lists, dicts).
- :class:`torch.Tensor` is serialized as ``{dtype, shape, data}`` — a data
  structure, not a bytecode payload.
- :class:`torch.dtype` is serialized as its string name and rehydrated with
  an :func:`isinstance` guard so that an attacker-controlled string cannot
  smuggle arbitrary ``torch.*`` attributes through :func:`getattr`.
- Model weights (stored separately) must be loaded with
  ``torch.load(..., weights_only=True)`` — the only pickle-free code path
  that PyTorch offers for weight bundles.

Custom (de)serializers for additional types are registered via
:func:`register_type_serializer`. The module pre-registers handlers for
:class:`torch.dtype`, :class:`torch.device`, and :class:`torch.Tensor`.
"""

from __future__ import annotations

import inspect
from datetime import datetime, timezone
from typing import Annotated, Any, get_args, get_origin

import torch
from pydantic import (
    AfterValidator,
    BaseModel,
    ConfigDict,
    Field,
    SerializeAsAny,
    create_model,
)

from nvalchemi._serialization import (
    _TYPE_SERIALIZERS,
    SerializableTaggedClass,
    _callable_path_of,
    _callable_signature,
    _constructor_signature,
    _deserialize_tagged_type,
    _import_callable,
    _is_serializable_class_annotation,
    _is_tagged_type,
    _wrap_class_type_annotation,
    _wrap_custom_type,
)
from nvalchemi._serialization import (
    _dtype_deserialize as _dtype_deserialize,
)
from nvalchemi._serialization import (
    _import_cls as _import_cls,
)
from nvalchemi._serialization import (
    register_type_serializer as register_type_serializer,
)

_META_FIELDS: frozenset[str] = frozenset({"cls_path", "timestamp"})
"""Field names reserved by :class:`BaseSpec` itself; never forwarded to ``build``."""


def _ensure_importable(cls_path: str) -> str:
    """Pydantic validator: ensure the target path is importable and callable."""
    _import_callable(cls_path)
    return cls_path


# ---------------------------------------------------------------------------
# Signature introspection
# ---------------------------------------------------------------------------


def _signature(target: Any) -> inspect.Signature:
    """Return the string-annotation-resolved signature for ``target``."""
    if isinstance(target, type):
        return _constructor_signature(target)
    return _callable_signature(target)


def _check_no_positional_only(target: Any) -> None:
    """Raise :class:`TypeError` if ``target`` has positional-only params."""
    for name, p in _signature(target).parameters.items():
        if p.kind is inspect.Parameter.POSITIONAL_ONLY:
            raise TypeError(
                f"{_callable_path_of(target)} has positional-only param {name!r}; "
                "create_model_spec only supports kwargs."
            )


# ---------------------------------------------------------------------------
# BaseSpec
# ---------------------------------------------------------------------------


[docs] class BaseSpec(BaseModel): """Base class for JSON-serializable, no-pickle hyperparameter specs. Concrete spec classes are built dynamically by :func:`create_model_spec` via :func:`pydantic.create_model`; each carries one field per ``__init__`` kwarg of its target class plus the two metadata fields defined here. Notes ----- ``revalidate_instances="never"`` is deliberate: specs are immutable records of past state; revalidating on access would reject any already-typed field values (e.g. rehydrated :class:`torch.Tensor` objects) that were stored through a :class:`~pydantic.BeforeValidator`. """ model_config = ConfigDict( arbitrary_types_allowed=True, revalidate_instances="never", ) cls_path: Annotated[ str, AfterValidator(_ensure_importable), Field(description="Dotted import path of the target callable."), ] timestamp: Annotated[ str, Field(description="ISO-8601 UTC timestamp of spec creation."), ] def accepts_kwarg(self, name: str) -> bool: """Return whether the target callable accepts ``name`` as a keyword.""" target = _import_callable(self.cls_path) sig = _signature(target) return name in sig.parameters or any( p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() ) def build(self, *args: Any, strict: bool = False, **extra_kwargs: Any) -> object: """Invoke the target callable with the stored hyperparameters. Positional ``*args`` and ``**extra_kwargs`` inject runtime-only values that cannot be serialized into the spec --- for example, ``model.parameters()`` for an optimizer or an ``optimizer`` instance for a learning-rate scheduler. Nested :class:`BaseSpec` field values are built recursively before forwarding to the target constructor. Non-empty ``list``/``tuple`` fields that contain :class:`BaseSpec` items are built item-wise, preserving non-spec items and the container type. Nested collections (e.g. ``list[list[BaseSpec]]``) are not traversed; wrap them in a serializable spec object or flatten the collection. A JSON round-trip preserves tuple-valued spec sequences when the target constructor annotates the parameter as a tuple; otherwise JSON arrays rehydrate as lists. Parameters ---------- *args Positional arguments forwarded to the target class constructor (runtime-only, not stored in the spec). strict Reserved for future use; currently a no-op retained to preserve the public API. Accepts any value without effect. **extra_kwargs Extra keyword arguments forwarded to the target callable, overriding any spec-stored kwargs of the same name. Returns ------- object A freshly constructed object from the callable at :attr:`cls_path`. Raises ------ TypeError If the target callable cannot be invoked with the resolved kwargs. """ del strict # reserved for future use target = _import_callable(self.cls_path) sig = _signature(target) resolved: dict[str, Any] = {} for name in type(self).model_fields: if name in _META_FIELDS: continue v = getattr(self, name) # Nested spec: build unless target expects the spec itself. if isinstance(v, BaseSpec): param = sig.parameters.get(name) ann = param.annotation if param is not None else None wants_spec = isinstance(ann, type) and issubclass(ann, BaseSpec) resolved[name] = v if wants_spec else v.build() elif _is_basespec_sequence(v): resolved[name] = _build_sequence_of_specs(v) else: resolved[name] = v resolved.update(extra_kwargs) try: return target(*args, **resolved) except TypeError as e: raise TypeError( f"Failed to build {self.cls_path} from spec " f"(saved at {self.timestamp}): {e}. The callable signature " "may have changed since the spec was created." ) from e
# --------------------------------------------------------------------------- # Type annotation resolution # --------------------------------------------------------------------------- def _try_deserialize(name: str, value: Any, sig: inspect.Signature) -> Any: """Probe registered deserializers to rehydrate a raw JSON value. Returns the first successfully deserialized typed instance, or the original ``value`` unchanged if no safe deserializer accepts it. This covers the case where ``__init__`` has no annotation for well-known parameters whose stored value is a serialized custom type (e.g. ``torch.dtype`` as a str for a ``dtype`` parameter). Only tagged class dictionaries, unannotated ``dtype`` / ``device`` strings, and tensor-shaped dicts are probed. Broad string deserializers such as raw class dotted-path resolution are deliberately skipped here so ordinary string fields remain strings. """ if not isinstance(value, (str, dict)): return value param = sig.parameters.get(name) sig_ann = param.annotation if param is not None else inspect.Parameter.empty if sig_ann is not inspect.Parameter.empty and sig_ann is not Any: return value deserializer: Any | None = None if isinstance(value, str): if name == "dtype": deserializer = _TYPE_SERIALIZERS[torch.dtype][1] elif name == "device": deserializer = _TYPE_SERIALIZERS[torch.device][1] elif _is_tagged_type(value): deserializer = _deserialize_tagged_type elif set(value) == {"data", "dtype", "shape"}: deserializer = _TYPE_SERIALIZERS[torch.Tensor][1] if deserializer is None: return value try: return deserializer(value) except (TypeError, ValueError, KeyError, AttributeError, RuntimeError): return value def _maybe_class_annotation(annotation: Any) -> Any | None: """Return a dotted-path serializer annotation for class types if applicable.""" if not _is_serializable_class_annotation(annotation): return None return _wrap_class_type_annotation(annotation) def _maybe_registered_type_annotation(annotation: Any) -> Any | None: """Return a serializer annotation for registered types and optional variants.""" if annotation in _TYPE_SERIALIZERS: return _wrap_custom_type(annotation) args = get_args(annotation) if len(args) != 2 or type(None) not in args: return None registered = [arg for arg in args if arg in _TYPE_SERIALIZERS] if len(registered) != 1: return None return _wrap_custom_type(registered[0]) | None def _expects_tuple_sequence(name: str, sig: inspect.Signature) -> bool: """Return whether ``name`` is annotated as a tuple-valued parameter.""" param = sig.parameters.get(name) if param is None: return False annotation = param.annotation return annotation is tuple or get_origin(annotation) is tuple def _is_basespec_sequence(value: Any) -> bool: """Return whether value is a non-empty list/tuple containing BaseSpec items.""" return ( isinstance(value, (list, tuple)) and len(value) > 0 and any(isinstance(v, BaseSpec) for v in value) ) def _is_spec_dict(value: Any) -> bool: """Return whether value is a JSON-dict representation of a BaseSpec.""" return isinstance(value, dict) and "cls_path" in value def _is_spec_dict_sequence(value: Any) -> bool: """Return whether value is a non-empty list containing spec-dicts.""" return ( isinstance(value, list) and len(value) > 0 and any(_is_spec_dict(v) for v in value) ) def _build_sequence_of_specs(value: Any) -> Any: """Rebuild :class:`BaseSpec` items in a list/tuple, preserving other items.""" return type(value)( item.build() if isinstance(item, BaseSpec) else item for item in value ) def _rehydrate_spec_sequence( name: str, value: list[Any], sig: inspect.Signature, ) -> list[Any] | tuple[Any, ...]: """Rehydrate spec-dict items in a JSON list, preserving other items.""" spec_items = [ create_model_spec_from_json(item) if _is_spec_dict(item) else _try_deserialize(name, item, sig) for item in value ] return tuple(spec_items) if _expects_tuple_sequence(name, sig) else spec_items def _resolve_annotation(name: str, value: Any, sig: inspect.Signature) -> Any: """Pick the Pydantic field annotation for ``(name, value)`` in ``sig``. Order of precedence: 1. ``value`` is a :class:`BaseSpec` → ``SerializeAsAny[BaseSpec]`` (preserves the concrete dynamic schema under :meth:`~pydantic.BaseModel.model_dump_json`). 2. ``value`` is a non-empty ``list``/``tuple`` containing :class:`BaseSpec` items → ``SerializeAsAny[list[Any]]`` or ``SerializeAsAny[tuple[Any, ...]]``. This lets collection fields (e.g. ``ComposedLossFunction.components`` and mixed scalar/spec weight lists) round-trip by preserving each item's dynamic schema. 3. The ``__init__`` signature annotates this parameter as a class type (``type``, ``type[T]``, or optional variants) → wrap with dotted-path class serialization hooks. 4. The ``__init__`` signature annotates this parameter with a registered custom type → wrap via :func:`_wrap_custom_type`. 5. The ``__init__`` signature has any non-``Any`` annotation → use it. 6. Otherwise infer from ``type(value)``; if the inferred type is in the registry, wrap it; ``None`` values fall back to :class:`typing.Any`. """ if isinstance(value, BaseSpec): return SerializeAsAny[BaseSpec] if _is_basespec_sequence(value): return ( SerializeAsAny[list[Any]] if isinstance(value, list) else SerializeAsAny[tuple[Any, ...]] ) param = sig.parameters.get(name) sig_ann = param.annotation if param is not None else inspect.Parameter.empty if isinstance(sig_ann, str): sig_ann = inspect.Parameter.empty has_sig_ann = sig_ann is not inspect.Parameter.empty and sig_ann is not Any if has_sig_ann: class_annotation = _maybe_class_annotation(sig_ann) if class_annotation is not None: return class_annotation registered_annotation = _maybe_registered_type_annotation(sig_ann) if registered_annotation is not None: return registered_annotation if has_sig_ann: return sig_ann if isinstance(value, type): return SerializableTaggedClass vt = type(value) if vt in _TYPE_SERIALIZERS: return _wrap_custom_type(vt) return vt if value is not None else Any # --------------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------------
[docs] def create_model_spec(target: Any, **kwargs: Any) -> BaseSpec: """Build a :class:`BaseSpec` instance for ``target`` with the given kwargs. A new Pydantic model class is dynamically created via :func:`pydantic.create_model`, one field per kwarg, each annotated by :func:`_resolve_annotation`. The resulting spec is JSON-serializable with :meth:`~pydantic.BaseModel.model_dump_json` and reconstructible with :func:`create_model_spec_from_json`. Non-empty ``list``/``tuple`` kwargs containing :class:`BaseSpec` items are annotated so each dynamic spec schema survives JSON dump and rehydration, and :meth:`BaseSpec.build` then rebuilds each spec item while preserving non-spec items. Empty collections are stored as-is. Nested collections (e.g. ``list[list[BaseSpec]]``) are not traversed; wrap them in a serializable spec object or flatten the collection. A JSON round-trip preserves tuple-valued spec sequences when the target constructor annotates the parameter as a tuple; otherwise JSON arrays rehydrate as lists. Parameters ---------- target The target importable callable. Must accept all ``**kwargs`` as keyword arguments and must not declare any positional-only parameters. **kwargs Hyperparameters for ``target``. Registered types (:class:`torch.Tensor`, :class:`torch.dtype`, :class:`torch.device`, and any user-registered types) are handled via the type-serializer registry. Other values must themselves be JSON-serializable by Pydantic. Returns ------- BaseSpec A dynamically subclassed :class:`BaseSpec` instance named ``"{target.__name__}Spec"`` with one field per kwarg plus the two metadata fields. Raises ------ TypeError If ``target`` has positional-only parameters, or if ``**kwargs`` contains names absent from the signature while the signature has no ``**kwargs`` parameter. Examples -------- >>> import torch.nn as nn >>> spec = create_model_spec(nn.Linear, in_features=8, out_features=4) >>> module = spec.build() >>> (module.in_features, module.out_features) (8, 4) """ _check_no_positional_only(target) sig = _signature(target) unknown = set(kwargs) - set(sig.parameters) if unknown: var_kw = any( p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() ) if not var_kw: raise TypeError( f"Unknown kwargs for {_callable_path_of(target)}: {sorted(unknown)}" ) fields: dict[str, tuple[Any, Any]] = {} for name, value in kwargs.items(): annotation = _resolve_annotation(name, value, sig) fields[name] = (annotation, value) model_cls = create_model( f"{getattr(target, '__name__', type(target).__name__)}Spec", __base__=BaseSpec, **fields, ) return model_cls( cls_path=_callable_path_of(target), timestamp=datetime.now(timezone.utc).isoformat(), **kwargs, )
[docs] def create_model_spec_from_json(spec: dict[str, Any]) -> BaseSpec: """Rebuild a :class:`BaseSpec` from its JSON-dict form. Recursively rehydrates nested specs (detected as values that are :class:`dict` and contain a ``"cls_path"`` key). Lists of such dicts are rehydrated item-wise, preserving the collection order. Pydantic's :class:`~pydantic.BeforeValidator` hooks on registered types handle the str → :class:`torch.dtype` / :class:`torch.device` / dict → :class:`torch.Tensor` conversions transparently. The original ``timestamp`` is preserved via :func:`object.__setattr__` rather than stamped fresh, so that a round-tripped spec remains byte-identical (up to JSON-whitespace) with its source. Parameters ---------- spec A :class:`dict` as produced by :meth:`~pydantic.BaseModel.model_dump` or by :func:`json.loads` on the output of :meth:`~pydantic.BaseModel.model_dump_json`. Returns ------- BaseSpec A spec instance equivalent to the source, with the original ``timestamp`` preserved. Raises ------ ValueError If ``spec`` is missing ``cls_path`` or ``timestamp``, or if ``cls_path`` cannot be imported / resolves to a non-callable. The underlying exception is preserved as ``__cause__``. Examples -------- >>> import json, torch.nn as nn >>> s = create_model_spec(nn.Linear, in_features=4, out_features=2) >>> dumped = json.loads(s.model_dump_json()) >>> s2 = create_model_spec_from_json(dumped) >>> s2.timestamp == s.timestamp True """ schema = dict(spec) try: cls_path = schema.pop("cls_path") stored_timestamp = schema.pop("timestamp") except KeyError as e: raise ValueError( f"Spec JSON missing required field {e.args[0]!r}; " f"present keys: {sorted(spec)}" ) from e try: target = _import_callable(cls_path) except Exception as e: raise ValueError( f"Could not resolve cls_path={cls_path!r} while rehydrating spec JSON: {e}" ) from e sig = _signature(target) kwargs: dict[str, Any] = {} for name, value in schema.items(): if _is_spec_dict(value): kwargs[name] = create_model_spec_from_json(value) elif _is_spec_dict_sequence(value): kwargs[name] = _rehydrate_spec_sequence(name, value, sig) else: # Eagerly deserialize safe unannotated custom forms (tagged class # dicts, dtype/device strings, tensor dicts). This keeps raw # importable strings as strings while preserving known structured # serializer payloads. kwargs[name] = _try_deserialize(name, value, sig) rebuilt = create_model_spec(target, **kwargs) # Preserve original provenance rather than stamping a fresh timestamp. object.__setattr__(rebuilt, "timestamp", stored_timestamp) return rebuilt