# 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.
"""Per-variable typed wrappers around equation-system state.
``SparseVector`` and ``SparseMatrix`` are the typed duals of
:class:`~neml2.es.assembled.AssembledVector` and
:class:`~neml2.es.assembled.AssembledMatrix`. They wrap the per-variable
form (``dict`` keyed by variable name with typed-wrapper values) plus the
:class:`~neml2.es.axis_layout.AxisLayout` that names the variables, so
``(layout, values)`` rides as one object across API boundaries instead
of as two arguments that callers have to keep in sync.
The pair is symmetric and round-trip-clean::
SparseVector(layout, values).assemble() -> AssembledVector
AssembledVector.disassemble() -> SparseVector
SparseMatrix(row_layout, col_layout, cells).assemble() -> AssembledMatrix
AssembledMatrix.disassemble() -> SparseMatrix
Use the ``Sparse*`` forms at user-facing API boundaries
(``ModelNonlinearSystem.initialize``, the ``deterministic.ipynb`` /
``statistical.ipynb`` notebooks, the pyzag adapter, anywhere a
``(layout, dict)`` pair would otherwise be passed separately); use
``Assembled*`` inside the Newton hot path and at the AOTI export
contract surface, where per-group raw tensors are the canonical
representation.
The ``__post_init__`` validation catches missing-variable bugs at
construction time rather than letting them surface as ``KeyError`` deep
in :func:`~neml2.es.assembled._build_group_block` once a ``forward``
actually tries to assemble the state.
Note on ``SparseMatrix`` vs ``ChainRuleDict``: although both have the
shape ``dict[str, dict[str, TensorWrapper-ish]]``, ``ChainRuleDict``
holds leading-K typed tangents (``k_ndim > 0``, the chain-rule seed
direction lives on the outer axes) while ``SparseMatrix.cells`` holds
the assembled per-cell :class:`~neml2.types.Tensor` blocks that
``AssembledMatrix.tensors[i][j]`` produces -- K has already been folded
into trailing base via :func:`_tangent_block_to_trailing_k`. They are
different objects despite the dict-of-dict shape coincidence; do not
unify them.
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from neml2.types import Tensor, TensorWrapper
from .assembled import AssembledMatrix, AssembledVector
from .axis_layout import AxisLayout
if TYPE_CHECKING:
from collections.abc import ItemsView
[docs]
@dataclass(frozen=True)
class SparseVector:
"""Per-variable typed-wrapper vector; the typed dual of :class:`AssembledVector`.
``values`` maps each variable name listed in ``layout.vars()`` to its
typed value (a :class:`~neml2.types.TensorWrapper` subclass instance).
Construction validates that every layout variable is covered.
Per CLAUDE.md rule 1: ``values`` is strictly typed -- raw
``torch.Tensor`` is rejected. External boundaries that have raw
tensors (the pyzag adapter, AOTI tracer fixtures, user code in
notebooks / tests) wrap with the appropriate ``TensorWrapper``
subclass *at the construction site*, not by handing raw tensors to
an internal neml2 helper.
"""
layout: AxisLayout
values: Mapping[str, TensorWrapper]
def __post_init__(self) -> None:
# Catch missing-variable bugs here rather than deep in assembly.
missing = sorted(set(self.layout.vars()) - set(self.values.keys()))
if missing:
raise KeyError(
f"SparseVector: missing values for variables {missing}; "
f"layout declares {list(self.layout.vars())}, "
f"got {sorted(self.values.keys())}."
)
[docs]
def assemble(self) -> AssembledVector:
"""Stack ``values`` into per-group tensors via the layout's grouping."""
return AssembledVector.from_dict(self.layout, self.values)
[docs]
def to(self, *args, **kwargs) -> SparseVector:
"""Move every value to a new device / dtype; returns a new SparseVector."""
return SparseVector(
self.layout,
{name: val.to(*args, **kwargs) for name, val in self.values.items()},
)
def __getitem__(self, name: str) -> TensorWrapper:
return self.values[name]
def __contains__(self, name: object) -> bool:
return name in self.values
def __iter__(self) -> Iterator[str]:
return iter(self.values)
def __len__(self) -> int:
return len(self.values)
[docs]
def items(self) -> ItemsView[str, TensorWrapper]:
return self.values.items()
[docs]
def keys(self):
return self.values.keys()
[docs]
@dataclass(frozen=True)
class SparseMatrix:
"""Per-(row_var, col_var) cell map; the typed dual of :class:`AssembledMatrix`.
``cells[row_var][col_var]`` is the assembled per-cell
:class:`~neml2.types.Tensor` block that
:attr:`AssembledMatrix.tensors[i][j]` holds -- K has already been
folded into trailing base via
:func:`~neml2.es._helpers._tangent_block_to_trailing_k`. Construction
validates that the outer keys cover every row variable in
``row_layout``; missing inner ``(row_var, col_var)`` entries are
allowed and become zero blocks at assembly time (per-block sparsity
is normal in chain-rule derivatives).
"""
row_layout: AxisLayout
col_layout: AxisLayout
cells: Mapping[str, Mapping[str, Tensor]]
def __post_init__(self) -> None:
missing = sorted(set(self.row_layout.vars()) - set(self.cells.keys()))
if missing:
raise KeyError(
f"SparseMatrix: missing row entries for variables {missing}; "
f"row_layout declares {list(self.row_layout.vars())}, "
f"got {sorted(self.cells.keys())}."
)
[docs]
def assemble(self) -> AssembledMatrix:
"""Walk row x col groups and pack into an :class:`AssembledMatrix`.
Delegates to :meth:`AssembledMatrix.select_blocks`, which handles
per-block sparsity (missing entries -> zero blocks).
"""
# select_blocks wants a plain ``dict[str, dict[str, Tensor]]``;
# unwrap our nested Mapping so it doesn't matter whether the
# caller used a regular dict or some other Mapping type.
plain: dict[str, dict[str, Tensor]] = {
row: dict(inner) for row, inner in self.cells.items()
}
return AssembledMatrix.select_blocks(self.row_layout, self.col_layout, plain)
[docs]
def to(self, *args, **kwargs) -> SparseMatrix:
"""Move every cell to a new device / dtype; returns a new SparseMatrix."""
return SparseMatrix(
self.row_layout,
self.col_layout,
{
row: {col: cell.to(*args, **kwargs) for col, cell in inner.items()}
for row, inner in self.cells.items()
},
)
def __getitem__(self, key: tuple[str, str]) -> Tensor:
row, col = key
return self.cells[row][col]
def __contains__(self, key: object) -> bool:
if not (isinstance(key, tuple) and len(key) == 2):
return False
row, col = key
return row in self.cells and col in self.cells[row]
__all__ = ["SparseVector", "SparseMatrix"]