Source code for neml2.factory

# Copyright 2024, UChicago Argonne, LLC
# All Rights Reserved
# Software Name: NEML2 -- the New Engineering material Model Library, version 2
# By: Argonne National Laboratory
# OPEN SOURCE LICENSE (MIT)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

"""Python-native HIT input file factory.

Mirrors the C++ Factory / Registry pattern for Python-native models:

- :func:`register_neml2_object` — decorator that registers a model class under its
  C++ type name. ``Model`` subclasses usually inherit schema-backed
  ``from_hit(node, factory)``; non-model objects and special cases implement
  their own.
- :func:`load_input` — parse a HIT file and return a lazy :class:`_NativeInputFile`
  factory object.
- :func:`load_model` — convenience wrapper: ``load_input(path).get_model(name)``.

The factory is "dumb" — it dispatches only by type name.  Each registered class
is responsible for reading its own parameters and resolving sub-object
dependencies via the factory's ``get_model`` / ``get_solver`` /
``get_equation_system`` methods (mirroring ``NEML2Object::get_model`` etc.).
"""

from __future__ import annotations

import keyword
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any, TypeVar

import nmhit
import torch

_registry: dict[str, type] = {}

_T = TypeVar("_T")


def _check_python_attr_name(name: str, *, kind: str, owner: str) -> None:
    """Refuse names that won't survive becoming a Python attribute on an ``nn.Module``.

    Eager use only requires the name be a valid Python identifier — ``setattr`` and
    ``__getattr__`` happily handle reserved keywords like ``yield`` or ``class``.
    The trap is :func:`torch.export`: during ``GraphModule.recompile()`` torch
    rewrites the module hierarchy back into literal Python *source* (``self.X.Y.Z``).
    If any path component is a Python keyword, the parser rejects the source with
    ``SyntaxError`` and AOTI compilation collapses with a deeply nested traceback.

    We refuse the name up front so the error surfaces at HIT-load time with a
    message the user can act on, instead of at export time. Pure Python-eager use
    is also blocked — that's intentional: keeping an export-friendly name now is
    cheaper than discovering the conflict the first time someone runs
    ``neml2-compile``.
    """
    if keyword.iskeyword(name):
        raise ValueError(
            f"{kind} name {name!r} ({owner}) is a Python reserved keyword. Pick a "
            f"different name — Python keywords cannot appear as path components in "
            f"torch.export's generated forward source, so AOTI compilation will fail "
            f"at recompile time with a SyntaxError on this path."
        )


[docs] def register_neml2_object(type_name: str) -> Callable[[type[_T]], type[_T]]: """Decorator: register a Model (or solver/system) class under *type_name*. The class must provide a ``from_hit(cls, node, factory)`` classmethod that constructs an instance from the given nmhit Section node. ``Model`` subclasses inherit a default implementation backed by ``HitSchema``; other registered object types implement it directly. The *factory* argument is a :class:`_NativeInputFile` instance whose ``get_model``, ``get_solver``, and ``get_equation_system`` methods may be called to resolve named sub-objects — exactly as C++ ``NEML2Object::get_model()`` does. """ def decorator(cls: type[_T]) -> type[_T]: if not hasattr(cls, "from_hit"): raise TypeError( f"Cannot register {cls.__name__!r}: " "it must implement a from_hit(cls, node, factory) classmethod." ) _registry[type_name] = cls cls._native_type_name = type_name # type: ignore[attr-defined] return cls return decorator
class _NativeInputFile: """Lazy factory built from a parsed nmhit AST. Mirrors ``neml2::Factory``. Objects are cached by ``(section, name)`` so that repeated lookups return the same instance. Every unregistered type raises ``KeyError`` — the previous An internal Python-native object surface is now expected to cover every HIT type a Python caller might load; gaps surface immediately rather than silently routing through the C++ Python bindings. """ def __init__(self, root: nmhit.Root, path: Path) -> None: self._root = root self._path = path # (section_plural, name) → built object self._cache: dict[tuple[str, str], Any] = {} # names currently being evaluated — used to detect circular cross-references self._evaluating: set[str] = set() # ── section lookup ──────────────────────────────────────────────────────── def _find_in_section(self, section: str, name: str) -> nmhit.Node | None: """Search *all* top-level blocks named *section* for a child named *name*. nmhit does not automatically merge repeated explicit `[section]` blocks, so we must scan all of them. """ for top in self._root.children(nmhit.NodeType.Section): if top.path() == section: node = top.find(name) if node is not None: return node return None # ── generic dispatcher ──────────────────────────────────────────────────── def _get_object(self, section: str, name: str) -> Any: key = (section, name) if key in self._cache: return self._cache[key] node = self._find_in_section(section, name) if node is None: raise KeyError(f"No [{section}/{name}] found in {self._path}") type_name = node.param_str("type") if type_name not in _registry: raise KeyError( f"Type {type_name!r} is not registered in NativeRegistry " f"(section={section!r}, name={name!r}). The Python-native " "object surface must cover every HIT type loaded through " "neml2; port the type or remove the [{section}/{name}] " "block from the input." ) cls = _registry[type_name] # Block names cross the user→Python boundary here; if the name is a # Python keyword it survives setattr() / hasattr() fine but breaks # torch.export's GraphModule.recompile (see _check_python_attr_name). # Refuse early so the failure is loud and local instead of a SyntaxError # deep inside the AOTI lowering. _check_python_attr_name(name, kind="HIT block", owner=f"[{section}/{name}]") # Reject unknown HIT fields against the class's declared schema. # Catches stale options (e.g. a removed `priority` on ComposedModel) # and typos before they're silently ignored -- runs uniformly whether # the class uses the schema-driven kwargs_from_hit path or owns its # own from_hit. from .schema import HitSchema # noqa: PLC0415 schema = getattr(cls, "hit", None) if isinstance(schema, HitSchema): schema.reject_unknown_fields(node) obj = cls.from_hit(node, self) # Variable-name resolution (HIT override → schema default → option name) # happens inside _store_schema_values via the input()/output() / var_* # schema fields, so there is no longer a boundary-wrapping pass here. # # Stash the HIT block name on the constructed object so downstream # consumers (notably ComposedModel) can register children under # readable HIT names instead of opaque _child_N indices. Falls back # silently if the object's class blocks attribute assignment (frozen # dataclass etc.) — composed models built directly from Python still # work, they just lose the HIT-name overlay. try: obj._hit_name = name except (AttributeError, TypeError): pass self._cache[key] = obj return obj # ── public get_* API (mirrors NEML2Object::get_model / get_solver / …) ─── def get_model(self, name: str) -> Any: """Build (or return cached) the model named *name* from ``[Models]``. Raises ``KeyError`` if the type isn't registered in ``NativeRegistry`` (D-054 — no fallback to C++). """ return self._get_object("Models", name) def has_model(self, name: str) -> bool: """Whether a ``[Models/<name>]`` entry exists (does not build it).""" return self._find_in_section("Models", name) is not None def get_solver(self, name: str) -> Any: """Build (or return cached) the solver named *name* from ``[Solvers]``.""" return self._get_object("Solvers", name) def get_equation_system(self, name: str) -> Any: """Build (or return cached) the system from ``[EquationSystems]``.""" return self._get_object("EquationSystems", name) def get_data(self, name: str) -> Any: """Build (or return cached) the data object from ``[Data]``. ``[Data]`` blocks are construction-time inputs shared by reference across consumers — currently ``CrystalGeometry`` / ``CubicCrystal`` for crystal-plasticity Schmid tensor lookup. No C++ fallback: ``[Data]`` is small, self-contained, and natively registered types cover everything needs. """ return self._get_object("Data", name) def get_driver(self, name: str) -> Any: """Build (or return cached) the driver from ``[Drivers]``. ``[Drivers]`` blocks are top-level workflow objects (e.g. ``TransientDriver`` for the regression suite, ``ModelUnitTest`` for the unit-test suite). The C++ side wires them through Factory; we mirror that here so a ``.i`` file's ``[Drivers]`` blocks resolve to Python-native classes. """ return self._get_object("Drivers", name) # ── [Tensors] support (mode 2 of declare_typed_parameter) ──────────────── def get_tensor(self, name: str) -> Any: """Build (or return cached) the ``[Tensors/<name>]`` value. Dispatches on the block's ``type`` to a class in the native registry — ``type = Python`` resolves to :class:`~neml2.user_tensors.PythonTensor.PythonTensor` (inline Python expression evaluated in the typed-tensor namespace), ``type = CSV<T>`` to the corresponding CSV reader, and so on. Returns the raw value produced by the registered class — either a ``torch.Tensor`` or a ``TensorWrapper`` subclass. The call site (``declare_typed_parameter`` mode 2) is responsible for wrapping a raw ``torch.Tensor`` into the appropriate typed wrapper. Raises ``KeyError`` if no ``[Tensors/<name>]`` block exists or its ``type`` isn't registered. """ key = ("Tensors", name) if key in self._cache: return self._cache[key] node = self._find_in_section("Tensors", name) if node is None: raise KeyError(f"No [Tensors/{name}] found in {self._path}") type_str = node.param_str("type") if type_str not in _registry: raise ValueError( f"[Tensors/{name}] has type={type_str!r}; expected a registered tensor " "type. For an inline Python expression use:\n" f" [Tensors/{name}]\n" " type = Python\n" " expr = '...pytorch expression...'\n" " []" ) # Cycle detection wraps the dispatch itself because registered tensor # classes (notably ``Python``) may recursively call back into # ``get_tensor`` through the eval namespace's ``__missing__`` hook. if name in self._evaluating: raise RecursionError( f"[Tensors/{name}] cross-references itself (directly or indirectly)." ) self._evaluating.add(name) try: result = _registry[type_str].from_hit(node, self) finally: self._evaluating.discard(name) # Sub-batch tagging happens inside ``expr`` via method chaining on the # returned wrapper — e.g. ``Scalar.zeros(5).sub_batch.retag(1)`` or # ``linspace(Scalar(0).sub_batch, Scalar(1).sub_batch, 5)``. # That keeps a single source of truth for the tensor pipeline (and lets # users compose ``.sub_batch.expand_at(...)``, ``.sub_batch.diagonalize()`` # etc. without a parallel HIT option for each). self._cache[key] = result return result # ── [Tensors] Python-expression helpers ─────────────────────────────────────── class _TensorNamespace(dict): """eval/exec namespace that lazily resolves unknown names as tensor cross-references. When an identifier is not found in the pre-populated symbols (``torch``, ``neml2.types`` exports, ``math``, ``np``), Python's name-lookup calls ``__missing__``. We intercept that and try ``factory.get_tensor(key)`` so users can write ``base`` in an expression instead of ``tensor('base')``, avoiding HIT's restriction that quotes may not appear inside quoted strings. Cycle detection is handled by ``_NativeInputFile.get_tensor`` itself. """ def __init__(self, factory: _NativeInputFile, base_ns: dict) -> None: super().__init__(base_ns) self._factory = factory def __missing__(self, key: str) -> Any: try: val = self._factory.get_tensor(key) except KeyError: raise NameError(f"name {key!r} is not defined") from None self[key] = val # cache resolved value in the namespace return val def _build_tensor_eval_namespace(factory: _NativeInputFile) -> _TensorNamespace: """Construct the eval namespace for ``[Tensors]`` Python expressions. Exposes the entire ``neml2.types`` public namespace (all names listed in its ``__all__``) so that new types or free functions added there are automatically available without touching this file. Cross-references to other ``[Tensors]`` entries are resolved implicitly: an unknown identifier is looked up as ``factory.get_tensor(name)``, so users write ``base`` in the expression rather than ``tensor('base')`` (which would conflict with HIT's restriction on quotes inside quoted strings). """ from . import types as _types_mod base_ns: dict[str, Any] = {name: getattr(_types_mod, name) for name in _types_mod.__all__} base_ns.update( { "torch": torch, "math": __import__("math"), } ) try: base_ns["np"] = __import__("numpy") except ModuleNotFoundError: pass return _TensorNamespace(factory, base_ns) def _dedent_hit_expr(code: str) -> str: """Dedent a multi-line expression string as returned by ``nmhit.param_str``. nmhit strips the leading whitespace from the first line of a quoted value (the newline and spaces between the opening quote and the first content character) but leaves subsequent lines intact. Standard ``textwrap.dedent`` therefore sees the first line with 0 indent and cannot strip anything. This helper deduces the common indentation from lines 2 onwards and strips it from the entire block, leaving relative indentation inside the code unchanged. """ lines = code.splitlines() if len(lines) <= 1: return code.strip() # Lines 2+ still carry the original HIT indentation. rest_nonempty = [ln for ln in lines[1:] if ln.strip()] if not rest_nonempty: return lines[0].strip() min_indent = min(len(ln) - len(ln.lstrip()) for ln in rest_nonempty) dedented = [lines[0]] # first line already has no leading whitespace for ln in lines[1:]: dedented.append(ln[min_indent:] if ln.strip() else "") return "\n".join(dedented) def _eval_tensor_code(code: str, name: str, ns: dict) -> Any: """Eval a single-expression or exec a multi-line block; return the result.""" src = _dedent_hit_expr(code) filename = f"<[Tensors/{name}]>" try: return eval(compile(src, filename, "eval"), ns) # noqa: S307 except SyntaxError: pass except RecursionError: raise except Exception as exc: raise ValueError(f"[Tensors/{name}] expr raised {type(exc).__name__}: {exc}") from exc try: exec(compile(src, filename, "exec"), ns) # noqa: S102 except RecursionError: raise except Exception as exc: raise ValueError(f"[Tensors/{name}] code raised {type(exc).__name__}: {exc}") from exc if "result" not in ns: raise ValueError( f"[Tensors/{name}] multi-line code block must assign its output to 'result'." ) return ns["result"] # ── public entry points ────────────────────────────────────────────────────────
[docs] def load_input( path: str | Path, *, pre: Sequence[str] = (), post: Sequence[str] = (), additional_args: Sequence[str] = (), ) -> _NativeInputFile: """Parse a HIT input file and return a lazy native factory. Parameters ---------- path: Path to the HIT ``.i`` file. pre, post: Optional HIT snippets prepended / appended before parsing (same semantics as ``nmhit.parse_file``). additional_args: Trailing command-line HIT overrides (e.g. ``["Models/elasticity/E:=210000"]``). Each element is a HIT snippet appended after ``post`` so any ``:=`` override takes effect after the file's own assignments. Mirrors the C++ side's ``neml2::load_input(path, additional_cliargs)``. """ p = Path(path) full_post = (*post, *additional_args) root = nmhit.parse_file(p, list(pre), list(full_post)) return _NativeInputFile(root, p)
[docs] def load_string( text: str, *, pre: Sequence[str] = (), post: Sequence[str] = (), additional_args: Sequence[str] = (), ) -> _NativeInputFile: """Parse an in-memory HIT snippet and return a lazy native factory. Same semantics as :func:`load_input` but reads from a string (via ``nmhit.parse_text``) instead of a file. Used by :meth:`neml2.drivers.ModelUnitTest.from_string` so a unit test can embed its ``[Models]`` / ``[Tensors]`` / ``[Drivers]`` blocks inline. """ full_post = (*post, *additional_args) root = nmhit.parse_text(text, list(pre), list(full_post)) return _NativeInputFile(root, Path("<string>"))
[docs] def load_model(path: str | Path, model_name: str) -> Any: """Load a named model from a HIT input file as a Python-native model. Raises ``KeyError`` if the model's type (or any of its sub-object types) isn't registered in ``NativeRegistry``. Parameters ---------- path: Path to the HIT ``.i`` file. model_name: Name of the model in the ``[Models]`` section. """ return load_input(path).get_model(model_name)
[docs] def load_nonlinear_system(path: str | Path, name: str) -> Any: """Load a named system from ``[EquationSystems]`` as a Python-native object. Convenience wrapper around ``load_input(path).get_equation_system(name)`` — mirrors :func:`load_model`. Returns a :class:`~neml2.es.NonlinearSystem` (typically a :class:`~neml2.es.ModelNonlinearSystem`). """ return load_input(path).get_equation_system(name)
__all__ = [ "register_neml2_object", "load_input", "load_string", "load_model", "load_nonlinear_system", "_NativeInputFile", ]