Interface mapping#
When two coupled nodes discretise their shared interface differently (a coarse and a fine rod, a surface mesh and a point cloud), an edge needs a mapping that turns a field on the source interface into a field on the target interface before the node consumes it.
from maddening.core.coupling.mapping import rbf_mapping
gm.add_edge("fluid", "solid", "traction", "force",
mapping=rbf_mapping(fluid_points, solid_points,
kernel="thin_plate_spline", mode="conservative"))
The edge applies mapping first, then the scalar transform (unit
conversion, sign), then additive accumulation, exactly as before.
The Mapping protocol#
class Mapping(Protocol):
kind: str; mode: str
n_source: int; n_target: int
def params_pytree(self) -> dict: ...
def apply(self, field, weights=None, geom=None): ...
def apply_T(self, field, weights=None, geom=None): ...
apply maps a source field ((n_source,) or (n_source, C)) to the
target; apply_T is the transpose. weights is the mapping’s entry of
the graph parameter pytree and geom is the geometry of a mapping that
depends on a moving interface (experimental, see
Geometry-dependent mappings below; static mappings ignore it). A mapping whose weights are applied through a structure the
parameter tree does not hold may also define structure_digest(); see
Sparse mappings.
Weights live in params["mappings"]#
A mapping’s weights are not baked into a closure. At compile time
the graph snapshots mapping.params_pytree() into
gm.params["mappings"]["<src>.<field>-><tgt>.<field>"], and every step
receives them as a traced input, exactly like node constants (see
graph parameters). Consequences:
jax.gradof a trajectory loss reaches the mapping weights (learned edges, sensitivity of a coupled result to the interface operator);a replaced matrix takes effect on the next step without recompiling;
the IFT rule of a coupling group carries the dependence, so a mapped edge inside a coupling group is differentiable end-to-end.
gm.params["mappings"] is validated like the node entries: an unknown
edge key is a ValueError at trace time.
A mapped edge inside a coupling group: what convergence_norm="interface" reads#
A coupling group under convergence_norm="interface" is judged on what
crosses its internal edges, every entry pooled into one RMS. A mapped
edge is read on its compact side:
The edge’s static mapping |
The norm reads |
|---|---|
delivers more entries than its source field holds (a scatter: a few values spread onto a grid) |
the source field itself, before the mapping and before the edge’s transform |
delivers fewer entries (a gather) |
the delivered value: mapping, then transform |
delivers as many entries as it reads (a tie) |
the delivered value |
no mapping |
the delivered value (the field, through the transform if there is one) |
The two sizes are the ones the mapping declares (n_source and
n_target, or field_shapes() where it has one), which add_edge
holds the edge’s two ends to; a dense matrix, a sparse mapping in either
layout and a registered kind of your own are treated alike. The target
still receives the mapped value and the weights are still parameters of
the step: only the criterion changes side.
Why: read as delivered, 30 marker forces scattered onto N cells put
N entries into the norm of which a few dozen change, and the criterion
loosens as the grid grows. On a two-way marker and grid pair
(rtol=1e-4, Gauss-Seidel) the marker forces at a converged=True exit
were 23, 39, 134 and 459 tolerances from their fixed point at N = 1e3,
1e4, 1e5 and 1e6; read on the compact side they are 3.3 to 3.6 at every
N (the table is in the
coupling algorithm guide).
With the mixed norm, which reads every field of the group whole, the
same pair stops as early as the diluted criterion did: prefer the
interface norm where a small field drives a large one.
This needs the transfer to be on the edge. The figures above are of
a pair whose grid field comes back to the markers through a gather
mapping. A member that is handed the whole grid through a plain edge
and samples it inside its own update is read at the grid’s N
entries, as any plain edge is, and the dilution returns: on the same
pair the markers were 4.6 tolerances off at N = 300, 27 to 74 at 3e3,
56 to 104 at 3e4 and 179 at 3e5 with converged=True, against 1.8 to
6.2 with the gather on the edge. Put the sampling on the edge: a static
gather (a dense or sparse matrix), or multilinear_grid with
geometry= where the sample points move.
A geometry-dependent mapping inside a group is read by the same rule, with its positions (see “What the interface norm reads on a geometry edge” below).
StaticLinearMapping and its factories#
The first implementation is a dense matrix H (n_target × n_source),
target = H @ source, with params_pytree() == {"H": H}.
factory |
what it builds |
|---|---|
|
radial-basis interpolation; kernels |
|
0/1 selection matrix |
|
cell-average projection between 1D grids (conservative) |
|
bring your own weights (e.g. supermesh weights precomputed offline); |
The dtype of the weights is each factory’s own, and only one follows
jax_enable_x64:
factory |
weights |
|---|---|
|
float32 under either setting, whatever dtype the points or boundaries have: a config written under one setting rebuilds the same weights under the other, and a float32 field stays float32 through the mapping in an x64 process |
|
float64 where a point array was passed as float64 under |
|
the dtype of |
So in a float64 graph the projection’s identities hold to float32 rounding:
a row sums to one, and the integral is preserved, to about 3e-8 relative,
not to 1e-16. The weights are the float64 overlap formula rounded once.
Where that matters, evaluate P[i, j] = |target_i ∩ source_j| / |target_i|
in float64 yourself and pass it to matrix_mapping (or its rows to
sparse_matrix_mapping), which keeps float64 under jax_enable_x64.
Polynomial augmentation and the patch test#
With polynomial=True (the default) the RBF interpolant is augmented
with the linear polynomial 1, x_1, …, x_D:
[Φ_ss + λI P_s] [c] [v]
[P_sᵀ 0 ] [d] = [0] H = [Φ_ts P_t] · A⁻¹[:, :n_source]
This is the well-posed form for thin-plate splines and it reproduces
constant and linear fields exactly, which is the patch test the
implementation is gated on (tests/core/test_mapping.py: constants and
linear fields across random non-conforming point sets to float32
round-off, and to 1e-8 in float64). A plain Gaussian interpolant does
not pass it.
The system is solved, not inverted, and the ridge λ = ridge · max|Φ_ss|
is relative to the kernel matrix, so ridge=1e-8 means the same thing
for a Gaussian (entries ≤ 1) and a thin-plate spline on a metre-sized
interface.
Consistent vs conservative#
Following preCICE:
mode="consistent"transfers a value (temperature, displacement, velocity):H @ vinterpolates, so a constant field stays constant.mode="conservative"transfers an integral quantity (force, heat flow, mass flux): the total over the target equals the total over the source. It is the transpose of the consistent mapping in the opposite direction,H = H_{t→s}ᵀ, and it preserves sums exactly whenH_{t→s}reproduces constants (1ᵀ H_{t→s}ᵀ v = (H_{t→s} 1)ᵀ v = 1ᵀ v) — which is why polynomial augmentation is on by default. The conservation test intests/core/test_mapping.pychecks this for every kernel and for nearest-neighbour.
Choose the mode by what the field is, not by which node sends it: a fluid sending tractions is conservative; a solid sending displacements is consistent.
Shapes and validation#
add_edge checks mapping.n_source against the source field’s size
and, when the target declares a boundary_input_spec shape,
mapping.n_target against it — a mismatch is a ValueError at graph
construction, not a broadcast surprise inside the jit.
The factories check the coordinates they are given, and refuse what their
formula does not cover with a ValueError naming the argument and the first
offending index (a MappingRebuildError when the mapping is rebuilt from a
config or a USD stage):
cell boundaries (
projection_1d_mapping) must be one-dimensional, hold at least two values, be finite and be strictly increasing. They are never sorted or reversed for you: the field keeps its cell order. The two grids need not cover the same interval — a cell outside the other grid is treated as empty, so the integral is preserved when the target grid covers the source grid, a constant is reproduced on target cells the source grid covers, and two grids that share no interval give a matrix of zeros;point sets (
rbf_mapping,nearest_neighbor_mapping) must be finite(n,)or(n, d)arrays of oned, and the set the operator interpolates from (the source in consistent mode, the target in conservative mode) must hold a point. Coincident points are accepted: nearest neighbour takes the lowest index, and the RBF ridge shares the weight between them;a matrix (
matrix_mapping) must be finite.
An accepted input gives the same operator as before the checks, bit for bit (MADD-ANO-192 has the inputs that used to give a wrong one).
Serialisation#
A mapping is written to a config (gm.to_dict(), JSON / YAML) or a USD
stage (save_graph_to_usd, attribute maddening:mappingSpecJson) as its
MappingSpec — the recipe, never the weights:
"mapping": {"kind": "rbf", "mode": "conservative", "shape": [12, 6],
"kernel": "thin_plate_spline", "epsilon": 2.0,
"polynomial": true, "ridge": 1e-8,
"points": {"source_points": {"node": "fluid", "field": "grid_x",
"sha256": "8b6cb7ec8637..."},
"target_points": {"asset": "solid_points.npy",
"sha256": "ba24aeb27cac..."}}}
kind names the factory – one of the four above, one of the three
sparse kinds, or a kind you
registered yourself – the flat keys
are its hyper-parameters (shape is informational and checked on reload),
and points maps each array argument of the factory (source_points /
target_points, source_boundaries / target_boundaries, H) to a
point reference.
Hyper-parameters are type-checked on the way in and out: epsilon and
ridge must be finite reals (a config must stay valid JSON — no
Infinity — and an integer too large to be a float64 is refused the same
way), polynomial a bool, kernel / mode / label strings.
Every factory attaches the spec to the mapping it returns
(mapping.spec, mapping.describe()); GraphManager.from_dict and
load_graph_from_usd rebuild the mapping by calling the same factory on
the resolved points (MappingSpec.build(resolve_points)), so the rebuilt
H is bitwise equal to the original, and register it in
params["mappings"] exactly as add_edge(mapping=) does. add_edge
also accepts a MappingSpec (or its dict) directly. Anything that goes
wrong while rebuilding one edge — a malformed spec, a reference that does
not resolve, an unreadable asset, a hyper-parameter of the wrong type, a
singular solve, a package a factory cannot import — is a
MappingRebuildError (a ValueError) naming that edge, with the original
exception chained as __cause__.
describe()["kind"] is the mapping’s user-facing kind: for
matrix_mapping(H, kind="supermesh") it stays "supermesh" (that is
what GET /graph and dashboards key on), while the spec underneath
keeps kind: "matrix" — the factory to call — and carries the label as
the label hyper-parameter. Reading describe() back with
MappingSpec.from_dict restores both.
Point references#
reference |
resolves to |
|---|---|
|
the node’s |
|
a NumPy file, relative to the directory the config / stage lives in ( |
|
the points themselves — at most |
A node reference is resolved once, when the mapping is built. If the
static it names is derived from a trainable parameter, such as a uniform
HeatNode’s grid_x, which is built from length, then calibrating
that parameter – a fit, or any params= pytree – leaves the weights at
the constructor’s geometry. Nothing refuses it or warns. This is
MADD-ANO-022. A value written into the running graph is refused
(MADD-ANO-063): gm.params at the next run, to_dict() and
save_state(), PUT /graph/params with a 400, a checkpoint at
POST /checkpoint/load, and an exported FMU leaves the parameter fixed,
because the node would use it while the mapping kept the old points and a
saved config would not load. See
Calibrating a parameter that a mapped edge’s grid derives from
for what it does to a fit and for the workarounds.
Accepted dtypes#
Whatever the reference form, a point set must be a bool, integer or float
array of at most 8 bytes per element: bool, int8…int64,
uint8…uint64, float16, float32, float64. Complex, string,
object and datetime arrays are refused, and so is extended precision —
numpy.longdouble, spelled float96 on 32-bit x86 and float128 on
x86-64 and aarch64.
An extended-precision point set is refused with an error, not narrowed
to float64. It fails both halves of what a reference has to do:
arr.tolist() yields numpy.longdouble objects that json.dumps cannot
write, so the config could not be saved; and point_array_digest is not
stable for it, because the padding bytes of an 80-bit value in its
16-byte slot are not zeroed, so two arrays that compare equal can hash
differently and a reference to them is rejected at random. Downcasting
quietly would hide a precision loss you did not ask for — a well-known
source of numerical bugs that are very hard to trace back to their cause
— so the error names the dtype and the fix:
mapping = rbf_mapping(np.asarray(fluid_pts, dtype=np.float64),
np.asarray(solid_pts, dtype=np.float64))
The same applies to the values of an inline reference that names no
"dtype". Such a payload is read as float64. numpy.longdouble arrays
and the numpy.longdouble scalars their tolist() returns used to be
rounded to float64 without a word. They are now refused, and the error
says what you can do instead. No reference form keeps the extra
precision, so the points can be at most float64 whichever route you take:
convert them first:
np.asarray(points, dtype=np.float64).tolist()gives plain Python floats;or add
"dtype": "float64"to the reference to accept the rounding explicitly;or, for a set too large to inline, save the
float64array withnumpy.saveand pass{"asset": "<file>.npy"}.
Three more kinds of value were changed by the same coercion and are refused the same way, each with a message saying what to do instead:
integers
float64cannot represent (magnitude above2**53and not a float64 value, as a Pythonintor an 8-byte NumPy integer):2**53 + 1used to become2**53. Add"dtype": "int64"(or"uint64") to keep integer points exact, or"dtype": "float64"to accept the rounding;decimal.Decimalvaluesfloat64cannot represent: they went throughfloat(). No reference keeps decimal digits; convert first or add"dtype": "float64";complex values, at any width (
numpy.clongdoubleincluded) — refused with or without a"dtype", because the coercion to a real dtype drops the imaginary part (with only a NumPyComplexWarning) rather than rounding it. Passnp.real(points)if the imaginary parts are zero, or the two parts as two real columns.
Values float64 holds exactly (2**53, powers of two beyond it,
Decimal("0.5")) and every other input are coerced as before.
This is a limit of the serialised form, not a judgement about extended precision, and it is not a closed door. If a real interface ever needs it, extended precision can be supported later behind the same API — a composite inline representation (the value bytes, or a mantissa / exponent pair, written as JSON-safe integers) together with a canonical digest, or an FFI path that formats and hashes the value itself. Nothing in the current format assumes 8 bytes is the last word; the constraint is explicit today so that you can trust what a saved config holds.
Asset files are read defensively, because a config is untrusted input:
the .npy header (or the .npz directory entry) is read first and the
array is refused before anything is allocated when it would exceed
MAX_ASSET_BYTES (256 MiB — a module constant you can raise for a
genuinely large interface), when the header claims more data than the
file holds, or when its dtype is not an accepted one — including
extended precision, which is caught from the header before any read.
The resolved path is opened exactly once, with O_NOFOLLOW, and the
size check (fstat on that descriptor), the header and the data all
come from it — re-opening the name would let a writer in the config
directory swap the checked file for a symlink in between. A reference
that names a symlink is unaffected: the link is followed by the
resolution, and it is the target that is opened.
A {"node": ...} reference resolves through gm.get_node(name): the
node’s static_data[key] first (a StaticArray is unwrapped), then an
array-valued constructor parameter node.params[key]; a scalar (0-d) or
non-numeric field is a PointReferenceError. Wrapper nodes classify
their inner node’s static data at build time and expose none of their
own — ShardedStencilNode and ShardedUnstructuredNode included — so a
node reference cannot reach through them; save those points as an asset
instead.
Tell the factory where its points came from with source_ref= /
target_ref= (and matrix_mapping(H, asset="H.npy") for an explicit
matrix, which is never inlined — save it yourself with numpy.save next
to the config):
gm.add_edge("fluid", "solid", "traction", "force",
mapping=rbf_mapping(fluid_pts, solid_pts, mode="conservative",
source_ref={"node": "fluid", "field": "grid_x"},
target_ref={"asset": "solid_points.npy"}))
Without a reference, a set of at most 64 points is inlined automatically;
a larger one leaves the mapping usable but not serialisable:
gm.to_dict() and save_graph_to_usd refuse with a message naming the
argument to pass (gm.to_dict(strict_mappings=False) still describes it,
for display — the REST GET /graph uses that). A hand-built
StaticLinearMapping or a custom Mapping object that carries no spec
is refused the same way; to make a mapping of your own serialisable,
register its kind.
References are checked against the points they describe#
Each reference records sha256, the content hash
(point_array_digest: dtype, shape and C-order bytes) of the array the
factory was actually given. A reference that resolves to something else
— a typo naming the wrong node field, a node field or asset file that
moved since the config was written — is refused instead of silently
rebuilding a different operator:
gm.to_dict()andsave_graph_to_usdresolve every node reference as they write and refuse one that no longer resolves (a removed node) or no longer matches the hash, naming the edge;MappingSpec.build/from_dict/load_graph_from_usdcheck every reference again — node, asset and inline — as they rebuild.
A hand-written reference may omit sha256; then nothing is checked and
the hash is recorded on the first rebuild.
python -m maddening.examples.coupling.interface_mapping_demo builds a
mapped edge between an 8-cell and a 24-cell rod from node references,
saves it with to_dict(), reloads it with from_dict() (the rebuilt
H is bitwise equal and the two graphs step identically), and shows the
MADD-ANO-063 refusal of a length write that would move the mapped
points.
Weights: config vs checkpoint#
The config carries the recipe; a checkpoint (save_state) carries the
actual params["mappings"] weights, which may have been trained by
sysid or edited. Loading a config rebuilds the geometric weights; a
checkpoint loaded afterwards overwrites them — the checkpoint wins
(tests/core/test_mapping_spec_serialisation.py). Because the config
cannot carry them, gm.to_dict() emits a UserWarning naming the edge
when the live params["mappings"] weights are no longer the ones the
recipe rebuilds, so trained weights are not lost silently; save a
checkpoint and load it after the config. Which weights an optimiser may
move is metadata, not data, so it is in the config:
gm.set_param_spec(edge.key, "H", ParamSpec()) round-trips through both
JSON and USD. The FMI exporter never exposes mapping weights, so the
exported modelDescription.xml is identical with and without a mapping
on an edge.
Still planned for 0.5.0: further kinds for moving interfaces (0.4.0 has one, experimental: Geometry-dependent mappings below), Wendland / partition-of-unity sparsity, and scaled-consistent / nearest-projection variants.
Registering your own mapping kind#
Experimental in 0.4.0: register_mapping may change in a minor release.
The four kinds above are entries of one registry
(maddening.core.coupling.mapping_registry), and register_mapping adds
yours to it. A mapping of a registered kind is written by to_dict()
and save_graph_to_usd and rebuilt by from_dict() and
load_graph_from_usd exactly as a built-in one is: the recipe travels,
the weights are recomputed bit for bit by your factory.
import json
import jax.numpy as jnp
import numpy as np
from maddening.core.coupling.mapping import StaticLinearMapping, register_mapping
from maddening.core.coupling.mapping_spec import MappingSpec, reference_for_array
from maddening.core.graph_manager import GraphManager
from maddening.nodes.heat import HeatNode
@register_mapping(
"inverse_distance",
arrays=("source_points", "target_points"),
hyperparameters={"power": float, "normalise": bool},
references={"source_points": "source_ref", "target_points": "target_ref"},
)
def inverse_distance_mapping(source_points, target_points, *, power=2.0,
normalise=True, source_ref=None, target_ref=None):
"""Each target value is a distance-weighted mean of the source values."""
src = np.asarray(source_points, dtype=np.float64).reshape(-1, 1)
tgt = np.asarray(target_points, dtype=np.float64).reshape(-1, 1)
weights = 1.0 / (1.0 + np.abs(tgt - src.T) ** power)
if normalise:
weights = weights / weights.sum(axis=1, keepdims=True)
spec = MappingSpec(
"inverse_distance",
{"power": float(power), "normalise": bool(normalise)},
{"source_points": reference_for_array(source_points, source_ref,
name="source_points"),
"target_points": reference_for_array(target_points, target_ref,
name="target_points")},
)
return StaticLinearMapping(jnp.asarray(weights, dtype=jnp.float32),
kind="inverse_distance", spec=spec)
gm = GraphManager()
gm.add_node(HeatNode("coarse", 1e-4, n_cells=6, thermal_diffusivity=0.1))
gm.add_node(HeatNode("fine", 1e-4, n_cells=12, thermal_diffusivity=0.1))
x_coarse = gm.get_node("coarse").static_data["grid_x"].value
x_fine = gm.get_node("fine").static_data["grid_x"].value
gm.add_edge("coarse", "fine", "temperature", "heat_source",
mapping=inverse_distance_mapping(
x_coarse, x_fine, power=3.0,
source_ref={"node": "coarse", "field": "grid_x"},
target_ref={"node": "fine", "field": "grid_x"}))
gm.compile()
config = json.loads(json.dumps(gm.to_dict()))
stored = config["edges"][0]["mapping"]
assert stored["kind"] == "inverse_distance" and stored["power"] == 3.0
assert "H" not in stored # the recipe, never the weights
reloaded = GraphManager.from_dict(config, {"HeatNode": HeatNode})
reloaded.compile()
key = "coarse.temperature->fine.heat_source"
assert np.array_equal(np.asarray(reloaded.params["mappings"][key]["H"]),
np.asarray(gm.params["mappings"][key]["H"]))
What the declaration says:
arrays– the factory’s array arguments, the point sets or matrices a spec refers to by reference.hyperparameters– every other argument a spec may carry, with its type:str,bool,intorfloat.floatis a real number: a finiteintorfloatthat is not abool, handed to the factory as afloat.intis an integer (a count, a size): anintthat is not abool, of a magnitude a float64 can hold, handed to the factory as anint; a float is refused for it, whatever its value. A spec’s hyper-parameters are checked against these declarations before your factory is called, as the built-in ones are, and an unknown one is refused. The nameskind,points,shapeandlabelare reserved.references– for each array, the factory keyword that carries its reference ("<array>_ref"when omitted).
The rebuild calls factory(**arrays, **hyperparameters, **references).
The declaration is checked against the factory’s signature when it is
registered, so a misspelt name fails at the decorator rather than while
someone loads a config. A required argument must be one a keyword can
supply: a factory that requires a positional-only argument is refused.
A kind name is any non-empty string but the three
a config reserves for non-finite numbers (NaN, Infinity,
-Infinity); registering a name that is taken – a built-in kind, or
another factory’s – is a ValueError, registering the same factory
again with the same declaration is a no-op, and nothing removes a kind.
What the factory returns is checked when a spec is rebuilt:
a
Mappingwhosekindis the registered kind;carrying, as
.spec, aMappingSpecof that kind which records the references and the hyper-parameters it was given. Build each reference withreference_for_array(array, reference, name=...), passing the array as you received it (its content hash covers the dtype, so hash it before any conversion) and the reference keyword through;whose
params_pytree()meets the contract below.
The short way is to return a StaticLinearMapping, as above: a dense
matrix whose one weight is H. Pass kind= (left at its default, the
mapping would be written as a matrix; to_dict() refuses that). A
class of your own needs the members of the Mapping protocol and a
spec attribute, and no describe() method: the edge writes the spec
for you.
Check a kind of your own with maddening.testing.mapping.verify_mapping
(experimental): linearity, the consistency or conservation it claims, the
adjoint identity between apply and apply_T, and the save/load round
trip of a registered kind; see Verifying a coupled model in three
levels.
What params_pytree() may contain#
The graph snapshots mapping.params_pytree() into
gm.params["mappings"][edge.key], and checkpoints, POST /checkpoint/load, system identification, the FMU’s state archive and
to_dict()’s “live weights differ” warning all read that entry as a flat
table of arrays. For a mapping of any class other than
StaticLinearMapping, add_edge therefore refuses, with a ValueError
naming the edge, a params_pytree() that is not
a plain
dict– empty for a mapping without weights;keyed by Python identifiers (a key is a member name in a checkpoint archive and a key of a config’s
param_specs);holding under each key one concrete JAX array of a floating-point dtype, of any shape, all finite – not a nested container, not a NumPy array or a Python number (
jnp.asarray(value)gives it a dtype that no longer depends onjax_enable_x64), not an integer or complex array. Indices and other structure stay attributes of the mapping, outside the parameter tree;the same on every call: it is what
reset_params()restores and whatto_dict()compares the live weights with.
Every weight is frozen by default and opts in to a fit by name
(gm.set_param_spec(edge.key, "<weight>", ParamSpec())), is carried by a
checkpoint under its own name, and is fixed in an exported FMU, like H.
A file can only name a kind#
A config, a USD stage and a checkpoint are untrusted input. All one of
them can say about a mapping is the name of its kind, which is looked up
in the registry and nowhere else: nothing imports a module, follows a
dotted path or discovers an entry point because of what a file contains.
A kind exists only because the running program imported the code that
registered it. A file naming a kind this process has not registered is
a MappingRebuildError listing the registered kinds; import the module
that registers it, then load.
The limits on point references – the asset size
cap, the inline limits, the accepted dtypes, the containment of asset
paths in the config directory, the content hash – are enforced by the
reference resolver before a factory runs, so they hold for a registered
kind exactly as for a built-in one. The coordinate checks of
Shapes and validation do not: each built-in
factory makes them itself, so your factory is handed whatever its
references resolve to, and geometry its formula does not cover – an
unsorted grid, a NaN coordinate – is its own to refuse, with a
ValueError. Whatever a registered factory
raises, from_dict and load_graph_from_usd report as a
MappingRebuildError naming the edge and the kind, with the factory’s
exception chained as __cause__ – an ImportError included, for a
factory that needs a package the loading environment lacks.
Sparse mappings#
Experimental in 0.4.0: everything in
maddening.core.coupling.sparse_mapping may change in a minor release.
A StaticLinearMapping stores the whole n_target × n_source matrix.
Between two interfaces of 100 000 points each that is 40 GB of float32,
of which a nearest-neighbour selection uses one entry per row. A
StaticSparseMapping stores only the entries a row uses:
an integer index of shape
(n_rows, k), kept on the mapping as a read-only host array – structure, not a parameter;one floating-point weight array
Wof the same shape, which is the mapping’s whole entry of the parameter tree:params_pytree() == {"W": W}.
Use a sparse mapping when the dense matrix does not fit in memory or is mostly zeros: a nearest-neighbour transfer, a cell-average projection, a stencil or supermesh operator computed offline. Keep the dense kinds for small interfaces and for RBF interpolation, whose matrix is full.
The three kinds#
factory |
what it builds |
|---|---|
|
the matrix of |
|
the matrix of |
|
bring your own rows: |
The first two hold float32 weights under either setting of
jax_enable_x64, as the dense kinds they reproduce do (see the table of
dtypes above); sparse_matrix_mapping keeps the dtype of values.
A sparse mapping goes on an edge like any other, and its kind is
registered, so to_dict() / from_dict() and the USD writer and reader
carry it:
import json
import numpy as np
from maddening.core.coupling.sparse_mapping import sparse_nearest_neighbor_mapping
from maddening.core.graph_manager import GraphManager
from maddening.nodes.heat import HeatNode
gm = GraphManager()
gm.add_node(HeatNode("coarse", 1e-4, n_cells=6, thermal_diffusivity=0.1))
gm.add_node(HeatNode("fine", 1e-4, n_cells=12, thermal_diffusivity=0.1))
x_coarse = gm.get_node("coarse").static_data["grid_x"].value
x_fine = gm.get_node("fine").static_data["grid_x"].value
mapping = sparse_nearest_neighbor_mapping(
x_coarse, x_fine,
source_ref={"node": "coarse", "field": "grid_x"},
target_ref={"node": "fine", "field": "grid_x"})
gm.add_edge("coarse", "fine", "temperature", "heat_source", mapping=mapping)
gm.compile()
key = "coarse.temperature->fine.heat_source"
assert mapping.indices.shape == (12, 1) # one source per target
assert gm.params["mappings"][key]["W"].shape == (12, 1) # one weight per slot
config = json.loads(json.dumps(gm.to_dict()))
stored = config["edges"][0]["mapping"]
assert stored["kind"] == "sparse_nearest_neighbor"
assert "W" not in stored and "indices" not in stored # the recipe, nothing else
reloaded = GraphManager.from_dict(config, {"HeatNode": HeatNode})
rebuilt = reloaded.edges[0].mapping
assert np.array_equal(rebuilt.indices, mapping.indices)
assert rebuilt.structure_digest() == mapping.structure_digest()
The same matrix as the dense kind. Scattering the rows of
sparse_nearest_neighbor_mapping or sparse_projection_1d_mapping into a
zero matrix gives the dense factory’s H bit for bit, in both modes. For
the nearest neighbour that includes ties: they go where the dense kind
sends them, to the lowest index among the points at the minimal
float64 squared distance. The k-d tree only proposes candidates; wherever
two candidates are closer to one another than rounding can tell apart, the
dense expression decides, so the result does not depend on the tree or on
the scipy version. The equality holds for coordinates up to 1e150 in
magnitude (a larger one is refused: its squared distance overflows).
sparse_projection_1d_mapping requires strictly increasing boundaries
on both sides and refuses anything else. It does not sort or reverse them
for you, because the field keeps its cell order. The sparse builders ask
of their coordinates what the dense factories ask (Shapes and validation
above), through the same checks and in the same words, and three things
more: both point sets must hold a point (a sparse mapping has at least one
row and one column), a complex, text or object array is refused by name,
and so is a coordinate past 1e150.
sparse_matrix_mapping takes an integer indices array and a
floating-point values array, both (n_target, k), and n_source, which
cannot be inferred. -1 in indices marks an unused slot (a row with
fewer than k entries), and the value there must be exactly 0: a weight
that would be dropped is refused rather than dropped. The same index may
appear twice in a row; its values add. Like an explicit dense matrix, the
two arrays are never inlined into a config: save them with numpy.save
(or as two members of one .npz) next to the config and name the files
with indices_asset= and values_asset=. name= is a free label for
display; the mapping’s kind stays "sparse_matrix".
The index is structure, the weights are parameters#
Only W is in gm.params["mappings"]. It is a traced input like any
other weight: a gradient reaches it, a replaced W takes effect on the
next step without recompiling, it is frozen by default and opts in to a
fit by name, a checkpoint carries it, and an exported FMU fixes it.
The index is baked into the compiled step as a constant. A weight write can therefore never change the sparsity pattern: a new pattern is a new mapping, a new edge and a recompile. The mapping object is immutable (the index array is read-only, and a copy of the object is the object).
A config carries a sparse mapping’s recipe – kind, hyper-parameters and
references – and never its index or its weights; from_dict calls the
same builder on the same arrays, so the reloaded index is the one that was
saved, bit for bit.
A checkpoint carries W and nothing of the index, and W means
something only against the index it was built for. So save_state writes
the SHA-256 of the pattern (mapping.structure_digest(): layout, sizes,
index and row counts) beside the weights, and load_state – and POST /checkpoint/load – refuses weights whose digest is not the live
mapping’s, before anything is loaded: weights saved for other points or
another index of the same shape, for the same index in the other layout,
or for a dense mapping (and a dense mapping refuses a sparse one’s). The
digest is judged whatever the weights are called – a stock sparse
mapping’s weights are W and a stock dense one’s are H, and neither
checkpoint loads into the other – and a checkpoint that carries mapping
weights this graph has no leaf for (another leaf name on the edge, or an
edge that has no mapping weights here) is refused too: a saved mapping
weight is installed or the load fails, never dropped. A
mapping class of your own that applies its weights through a structure
gets the same check by defining structure_digest().
The conservative nearest neighbour: two forms#
The conservative nearest neighbour is the transpose of the reverse
selection: each source adds its value to the target nearest to it. A
target’s row is as long as the number of sources that share it, and
transpose= chooses how those rows are held:
transpose="gather"(the default): for each target, the sources that add to it, padded to the longest row, summed along the row. Each output is reduced on its own, so one compiled program gives one result – measured on the CPU and on a GPU. The cost is the padding: a pattern in which many sources share one target has one long row and pads every other row to it. PastMAX_SPARSE_STRUCTURE_BYTESthe builder refuses, before allocating, with an error that namestranspose="scatter".transpose="scatter": one entry per source, applied as a scatter-add. Compact whatever the pattern. Measured on the CPU it is the in-order sum, one result on every call. Measured on a GPU the same program gave a different result on every run (twenty results in twenty runs, hundreds to tens of thousands of ulp apart). Choose it when the gather form is refused and run-to-run reproducibility on a GPU is not needed.
That is what was measured (jax / jaxlib 0.11.0, 2026-10-05) and nothing more is claimed: not bit-equality between the CPU and a GPU for either form, and not GPU behaviour on another driver or jaxlib. The GPU statements are one attended measurement that no test runs (GPU is not a verified configuration, MADD-ANO-001); the CPU statements are tests, and held on the three jaxlib versions they were run on (0.10.2, 0.11.0, 0.11.2). The choice is recorded in the spec, so a reloaded mapping is applied the way it was built. The two forms hold the same matrix, and agree with one another and with the dense conservative kind to rounding.
The order of the sum and a coupling report’s float floor
(MADD-ANO-257, open). “To rounding” is (k + 2) eps of a row of k
entries, and an in-order sum of k terms of one sign reaches a good
part of it where the terms are nearly equal: the rounding is then
systematic and grows like k. The float floor of
coupling_diagnostics() counts a fixed number of eps per evaluation
and does not count a row’s own sum. So a group stalled at its float
floor behind a long row is further from its fixed point than
spectral_error_bound says. Measured on a pair of relays (one value fed
by a row of k values that each read it back; gains 0.9 and 0.99, both
schedules, uniform, ramp and random fields; the smallest bound over the
true distance among the reports with spectral_usable set; the same
digits on jax 0.10.2, 0.11.0 and 0.11.2):
row |
3 |
10 |
30 |
100 |
300 |
1000 |
3000 |
1e4 |
3e4 |
|---|---|---|---|---|---|---|---|---|---|
float32, |
6.2 |
3.9 |
2.07 |
0.68 |
0.24 |
0.069 |
0.023 |
0.0068 |
0.0022 |
float32, |
6.3 |
4.9 |
2.09 |
0.66 |
0.22 |
0.069 |
0.023 |
0.0067 |
0.0022 |
float64 floor, |
8.0 |
5.0 |
2.08 |
0.63 |
0.22 |
0.068 |
0.023 |
0.0065 |
0.0022 |
The gather layout and the dense kinds carry the same rounding: XLA reduces a row in an order of its own, which depends on the jax version, the dtype and the shape. Rows of up to 100 entries held by 2.2x or more in both forms (one, three and sixteen rows; float32; the three versions alike), and one row behind a uniform field held by 1.8x at 300 entries, 1.2x at 1000 and 1.09x at 3000, and by 3.7x or more at 1e4 and 3e4; but jax 0.10.2 sums the gather layout’s float32 rows of 1e4 entries and more in order (0.0068 and 0.0022, as the scatter layout), and a dense matrix of three rows of 3000 entries read 0.18 of the distance in float32 on the three versions (0.09 at the float64 floor), all with the flags set.
In 0.4.0 the report therefore withdraws spectral_usable and
gradient_bound_usable, with a reason, where an internal edge carries a
static mapping of any kind with a row longer than 10 entries and the
residual is not above the float floor times the row’s length; the
numbers are reported as computed, and the floor itself is corrected in
0.5.0 (a row’s own (k - 1) / 2 eps, which holds for any order of the
sum, counted with the evaluations). A row is the entries one delivered
value adds up, whatever the weights are (they are a parameter a step may
be handed): the valid slots of a sparse row in either layout, and the
width of a dense matrix, so a dense selection matrix is counted at
its width although its sums are exact. The limit is the scatter
layout’s (30 entries hold by 2.07x, 100 do not hold): which rows the
other forms sum in order is XLA’s to choose, so they are not given a
limit of their own. A wider dtype at the same tolerance takes the
report off its float floor in every kind: the scatter pair in float64 at
rtol=1e-7 kept its flags on a bound at 0.9995 of the distance or more.
A geometry-dependent mapping is not counted. A multilinear_grid
gather adds up at most 2^d entries; its conservative form is a
scatter-add with as many entries on a grid node as there are markers in
its support, a number decided in the step: with 8, 300 and 3000 markers
in one cell behind a uniform field the bound read 13.8, 3.8 and 1.3
times the distance under "mixed". That group solves the markers’
positions and has no usable flag in 0.4.0 (MADD-ANO-252); a group whose
positions are constants of the pass keeps its flags behind the same
uncounted rows, which was not measured.
The reverse-mode derivative of a gather with respect to the field is itself a scatter-add. On a GPU a gradient with respect to the source field through either form may therefore differ in its last bits between runs even where the forward result does not.
What “equal to the dense mapping” means#
The sparse and the dense mapping are two float evaluations of the same
sums. Each output entry is within (k + 2) · eps · Σ|w|·|f| of the exact
sum over its row’s k entries (eps of the result dtype), so the two
agree to rounding, entry by entry – not bit for bit. The row sum is
deterministic for one compiled program; like H @ field, it is not
bit-stable between a jax.vmap of a step and the step alone.
With one entry per row the two are the same number when the mapping is
applied on its own. Inside a compiled step not even that carries over,
whatever the weights: a graph with sparse edges and the same graph with
dense ones are two programs, and the compiler evaluates the arithmetic
around a mapping differently in each (it may fuse a product with an
addition next to it – an additive edge, a transform – in one and not in
the other). A graph with sparse edges and its dense twin therefore step
to within rounding of one another, not to the same bits. Under a
constant iterator the two were at most 7.5 eps of the largest state
entry apart, over every structure, layout and domain the coupling
topology harness compares them in; the harness allows 256. The harness
runs over sparse edges as it does over dense ones: a graph with sparse
edges satisfies the same monolithic reference the dense graph is held to,
and its gradients are the dense graph’s. In bfloat16 and float16, where
a rounding is a percent of the state, the twin is not compared; a renamed
or reordered sparse graph returns the original’s states bit for bit.
One difference is deliberate. A row reads only its own entries, so an
infinity or a NaN in the source field reaches only the targets that list
it; H @ field multiplies it by the zeros of every other row and returns
NaN everywhere.
Limits#
what |
bound |
|---|---|
each referenced array |
|
the row structure (index plus weights) |
|
a nearest-neighbour search over a degenerate point set |
|
index values |
|
Each refusal is a SparseMappingLimitError, a ValueError; from
from_dict or load_graph_from_usd it is a MappingRebuildError naming
the edge. n_source sizes nothing, so a config cannot allocate through
it. In a process capped at 6 GiB of address space, a pattern whose padded
gather would need 160 GB, a tie set of 9e8 candidates and a projection row
of five million slots among fifty thousand rows are each a
SparseMappingLimitError, not a MemoryError. scipy, which the nearest-neighbour builder imports for its k-d tree,
is a declared dependency; it is imported by that builder, not by
import maddening.
The scale it has been measured at#
One process, capped at 8 GiB of address space, jax 0.11.0 on three CPU cores of a shared machine (2026-10-05); the times are indicative.
a million points (or cells, or rows) a side |
build |
first call (compile and apply) |
a later call |
|---|---|---|---|
nearest neighbour in 3-D, consistent ( |
5.6 s |
0.06 s |
2 ms |
nearest neighbour, conservative, gather ( |
5.4 s |
1.0 s |
44 ms |
nearest neighbour, conservative, scatter |
5.0 s |
0.06 s |
4 ms |
projection onto 700 000 cells ( |
0.34 s |
||
your own rows, |
0.10 s |
0.6 s |
25 ms |
The whole run peaked at 1.4 GiB of resident memory with all five mappings
alive. The index is embedded in every compiled program that applies the
mapping (the step, each cached scan length, a sweep): four bytes per slot
per program, 32 MB at a million rows of eight. The same script runs at a
hundred thousand points on every push
(tests/core/test_sparse_mapping_builders.py).
Not in 0.4.0: a sparse mapping on a moving interface (geom is ignored),
sharded graphs, and sparse RBF kinds.
Geometry-dependent mappings#
Experimental in 0.4.0: add_edge(..., geometry=...), the registry flag
needs_geometry and everything in maddening.core.coupling.grid_mapping
may change in a minor release. The user guide page
Geometry-dependent mappings
has a runnable example, the refusals and the limits of this release.
Every mapping above is static: its operator is fixed when the graph is built. A geometry-dependent mapping also reads a geometry, a state field of the edge’s own source or target node that moves during the run (marker positions, a deforming interface), and builds its operator from it at every call:
value_at_target = mapping.apply(value_at_source, weights, geom)
The edge names the field, add_edge(..., mapping=m, geometry=(anchor, field)) with anchor "source" or "target", and the graph resolves it
as it resolves the value. The geometry is ordinary node state: it is
integrated by its node, batched by run_sweep, differentiated by
jax.grad, and saved by a checkpoint. The mapping declares
needs_geometry = True and a static geometry_shape (see the Mapping
protocol’s optional attributes), and must be a pure function of its three
arguments.
The time level a geometry is read at#
Two rules cover every call site.
Source-anchored (
geometry=("source", g)): the geometry is read exactly as an ordinary edgesource.g -> targetwould be read there, from the same state the edge’s value is read from. Where the value is interpolated between two snapshots (a sub-cycled member of a coupling group), the geometry is interpolated between the same two snapshots with the same weight, componentwise, and the mapping is applied to the interpolated value at the interpolated geometry. Those two snapshots are two iterates of the same end-of-step value – the pass’s incoming iterate and the in-pass state – not the source at two times: see the note under the table.Target-anchored (
geometry=("target", g)): the geometry is fieldgof thestateargument of the hook the boundary inputs are being resolved for,updateorcompute_boundary_fluxes.
Call site |
Value |
Source-anchored geometry |
Target-anchored geometry |
|---|---|---|---|
Plain step, forward edge |
the source’s value from this step |
the source’s |
the target’s pre-step |
Plain step, back edge |
the previous step |
the source’s |
the target’s pre-step |
Plain step, the target’s flux hook |
as for its |
as for its |
the target’s post-update |
Multi-rate, slow source |
the state the source holds between its steps |
the same held state |
the target’s current state |
Multi-rate, a slow node’s flux hook |
as for its |
as for its |
the |
Group member’s |
the in-pass state |
the in-pass |
the member’s pre-step |
Group member’s |
the incoming iterate |
the incoming iterate’s |
the member’s pre-step |
Group member’s flux, seeded before a pass |
the incoming iterate |
the incoming iterate’s |
the incoming iterate’s |
Group member’s flux after its update |
the in-pass state |
the in-pass |
the member’s in-pass, post-update |
Sub-cycled member, |
|
|
the member’s state before this sub-step |
Sub-cycled member, |
the in-pass state (an end-of-step estimate) |
the in-pass |
the member’s state before this sub-step |
An edge from outside the group |
not interpolated |
read with the value |
by the hook, as above |
|
the current state |
the current state’s |
the node’s current |
A target-anchored geometry is therefore the same at every pass of a coupling solve, because members integrate from the pre-step state; a source-anchored one is part of the iterate.
What “linear” interpolates between. Both ends are estimates of the
source’s end-of-step value: the iterate the pass was handed and the
state the pass has built so far. It is not an interpolation in time
across the sub-steps (MADD-ANO-027), so “linear” and “constant” read the
same value and the same geometry wherever the two iterates coincide:
under iteration_mode="jacobi", for a source scheduled after the
sub-cycled member, and, to the solve’s tolerance, at a converged solve
(a sub-cycled float64 pair with the source scheduled first: the two modes
1.2e-4 apart, relative, at tolerance=1e-4 and 1.7e-13 apart at
tolerance=1e-13; with the source scheduled second, identical).
See boundary_interpolation in the
coupling guide.
A slow node’s flux on a multi-rate graph. A node whose rate divider
is above one holds its state between the base steps it fires on, and its
flux hook is called on every base step with the state the node holds
after that step and the boundary inputs resolved at that step. A reader
of the flux therefore sees the flux of the held state, as a reader of a
state field sees the held state, and a target-anchored geometry of that
node is the held g on the base steps the node does not fire on. (The
hook used to be called on the result of update before the step decided
whether to keep it: MADD-ANO-235.)
The interpolation of a geometry is componentwise. A geometry whose
components are not affine coordinates (a unit quaternion, an angle across
its wrap) should use boundary_interpolation="constant".
multilinear_grid: gather and scatter on a uniform grid#
The reference kind, multilinear_grid_mapping(origin, spacing, shape, *, n_points, mode, layout="flat"), transfers between a uniform grid of one
to three axes and n_points moving points whose positions, shape
(n_points, d), are the geometry.
Grid. The grid is the lattice of sample points: value \((i_0, \dots, i_{d-1})\) sits at \(x_a = o_a + i_a h_a\), \(0 \le i_a \le n_a - 1\). The flat index is C order, \(I = \sum_a s_a i_a\) with strides \(s_a = \prod_{b > a} n_b\).
Index arithmetic. For point \(p\) and axis \(a\), in the geometry’s dtype:
\(u\) is computed in a static power-of-two frame of \(h_a\) (the position, the origin and the spacing are multiplied by the same power of two, which changes no bit of the quotient and keeps every intermediate a normal number), so a spacing far below one is resolved. On an axis with a single point \(i^0 = i^1 = 0\) and \(t = 0\).
Stencil and weights. Each point reads the \(2^d\) corners \(c \in \{0, 1\}^d\) of its cell:
Gather and scatter are one operator and its transpose.
Gather reproduces any field that is multilinear in the coordinates, at
every point inside the hull of the lattice. Scatter preserves the plain
sum, \(\sum_j g_j = \sum_p y_p\), to the rounding of the geometry’s dtype:
it deposits amounts, and the weights of a point, computed in the
geometry’s dtype, sum to one only to that rounding (a float64 field
scattered at a float32 geometry: 1.5e-11 relative over 20,000 points,
where a float64 geometry gives 1e-16). It divides by no
cell volume and applies no quadrature weight; turning the result into a
density belongs to a node or to the edge’s transform. apply_T of one
mode is apply of the other at the same positions.
Outside the grid. A point outside the hull is clamped to it, coordinate by coordinate (\(\bar u\) above): gather extrapolates constantly, scatter deposits on the boundary, and the weights still sum to one. A non-finite coordinate is not clamped: every weight of that point is NaN, on the \(2^d\) corners of the grid’s first cell (index 0 or 1 on each axis; index 0 alone on an axis of one point), so gather returns NaN for that point only and scatter puts NaN in those corner cells and no other.
Derivative with respect to a position. Inside a cell, \(\partial y_p / \partial x_{p,a}\) is the slope of the multilinear interpolant along axis \(a\), \(\tfrac{1}{h_a}\sum_c \pm W^{(a)}_{p,c} f_{I_{p,c}}\) with \(W^{(a)}\) the product over the other axes. The interpolant has a kink at every lattice plane; there the derivative is that of the cell above the plane (of the last cell at the top of the hull), so on the two faces of the hull it is the interior one-sided derivative, and strictly outside the hull it is zero.
Precision. Indices and weights are computed in the geometry’s dtype and the weights are cast to the field’s dtype, so the result has the field’s dtype: a float32 field with a float64 geometry gives a float32 input computed from float64 positions. A position \(x\) resolves \(\varepsilon |x|\), so on axis \(a\) a point is located to
cells. When that is 1/16 of a cell or more for the dtype the geometry
field has, compile() refuses the graph; at 1/1024 or more, validate()
reports a warning. Hold the geometry in float64 or move the origin of the
coordinates closer to the grid. That figure is the grid’s own, at its
farther end from the coordinates’ zero. A point’s own figure is its
lattice coordinate: the stencil forms \((x - o_a)/h_a\) in the
geometry’s dtype, so its weights on axis \(a\) are resolved to
\(\varepsilon\,|x - o_a|/h_a\) of a cell, the point’s distance from the
grid’s first point, whatever \(|x|\) is. On a grid of 16001 points
centred on the coordinates’ zero a float32 marker beside that zero has
weights resolved to \(\varepsilon \cdot 8000 = 9.5 \times 10^{-4}\) of a
cell, which is the grid’s figure and under this warning. The float
floor of the interface norm counts that per point (below), and it is
the tolerance asked long before 1/1024 of a cell. The refusal is not
only compile()’s: a
geometry compiled as float64 and later written as float32
(set_node_state is not a recompile) changes the dtype of the program,
which is then traced again, and the same rule is asked at that trace –
step() and the run methods raise ValueError naming the edge instead
of sampling a grid the positions cannot resolve; the kernel asks it of a
direct apply call as well. (So is the float32-or-float64 rule, with a
TypeError.)
Determinism. Gather is a fixed left fold over the \(2^d\) corners. Scatter is a scatter-add with repeated indices: on CPU it accumulates in update order (points, then corners), so the result is a deterministic function of the inputs including the order of the points, and a permutation of the points changes it by rounding only. On an accelerator a cell that receives several contributions may differ by rounding from run to run.
What reads a geometry in 0.4.0, and what does not#
The step reads it: the plain step, a coupling group’s passes under every solver and acceleration, sub-cycled members, multi-rate graphs, and the gradients through all of them. IQN’s automatic interface set includes a source-anchored geometry that is internal to the group, since it is part of the iterate.
The interface norm reads it for the multilinear_grid kind, in a
group that does not sub-cycle (next section).
The diagnostics read it in one case: a coupling group that does not
sub-cycle, under convergence_norm="l2" or "mixed", every
geometry-dependent mapping of whose pass is a multilinear_grid. The
spectral estimate and the gradient bound are built from Jacobian-vector
products of the group’s pass, taken with respect to the members’ whole
floating state and to the constants of the pass. A source-anchored
geometry inside the group is part of that state and is read by the pass
from the iterate, so the products carry the pass’s dependence on the
iterate through the positions; a target-anchored geometry is the
member’s pre-step state, a constant of the pass that the gradient bound
probes like any other. Such a group reports every bound and flag as any
other group does.
With diagnostics=True the step checks that claim on itself. It moves
every position the pass reads by sqrt(eps) of the lattice spacing
towards the middle of its cell (no point crosses a lattice plane, so the
stencil is one polynomial along the step), once where the positions are
in the iterate and once where they are constants of the pass, and
compares the product along that step with the difference of two
evaluations of the pass, field by field:
with \(D\) the norm’s weights and \(\mathrm{res}\) the residual’s float resolution. A product that does not see the geometry reads 1 on every field the positions move by much more than 32 resolutions, and less on a field they move by about that (the allowance is in the denominator: a term that moves a field by \(G\) reads \(G / (G + 32\,\mathrm{res})\), under the tolerance below about 11 resolutions). An honest pass reads the finite difference’s own error. Measured on jaxlib 0.10.2, 0.11.0 and 0.11.2:
the pass |
draws |
gap |
|---|---|---|
honest, six cells of the geometry search |
350 |
float32: median 6e-4, at most 5.7e-3; float64: at most 4.3e-6 |
honest, the search’s hunts away from a lattice plane |
3,200 |
at most 9.5e-3 |
every read of a geometry under |
353 |
float32: 0.305 to 0.98; float64: 1.0 |
the source-anchored reads under |
1 |
0.22, under the tolerance: the radius reported was 0.003 against 0.025 |
honest float32, a position built and read in one Gauss-Seidel sweep, placed 1e-6 to 1e-2 of a spacing from a lattice plane (1489 within 2e-5) |
2829 |
47 over 0.05, 13 over 0.1, 2 over 0.2, one 0.59 (MADD-ANO-246) |
honest, an audit’s hunts with the fixed point at every distance from a lattice plane, float32 and float64 |
65,714 |
180 withheld, each with a position the pass reads within the check’s step of a plane ( |
honest float32, Gauss-Seidel behind a gather of a field alternating \(\pm A\) around a sample of order one |
126 |
under 0.09 up to \(A = 1020\); 0.25 to 0.75 beyond, but 0.14 at \(A\) = 2200 to 2700 (MADD-ANO-212) |
The separation is therefore measured and not clean. The step of the
positions in the iterate moves a position that another member builds in
the same sweep through that member’s mapped input, in a direction the
step does not choose; beside a lattice plane it crosses, and the
difference straddles two polynomials. A gather that cancels digits
hands a small field the rounding of a large one, which the allowance,
taken in the small field’s resolution, does not cover. At a tolerance
of 0.05 the check would catch the weak deposit and withhold 7 to 9 of
the 4,450 honest examples of the hunts, all beside a plane, where 0.25
withholds none or one; the reason of a withheld report names both
readings of a gap. The step is sqrt(eps) of the
spacing at the coarsest floating dtype the pass evaluates in (a step
sized for float64 positions is below what float32 fields resolve), and a
point on the last lattice point of an axis steps inwards, as one on the
first does: outside the hull the kernel clamps. A member’s pre-step
position steps towards the middle of the cell of whichever of it and the
position the pass builds from it is nearer to a lattice plane (a
Gauss-Seidel sweep reads the second after the holder’s update). Where the gap is above
GEOMETRY_GAP_TOLERANCE (0.25) or cannot be evaluated, the report
withholds the bounds, and says which of the two it was. A gap above the
tolerance is reported as either a derivative that is not the value’s or
a finite difference that could not be formed: the check does not tell
those apart. The check costs
one product and one evaluation of the pass per direction, and exists
only in the step of such a group with diagnostics on.
Across a lattice plane. The stencil is piecewise polynomial in the
positions, so the pass \(F\) is piecewise smooth in the iterate wherever it
reads positions from the iterate, and its Jacobian jumps where one of
them crosses a lattice plane (the faces of the hull included). Every
number of the report is the linearisation at the returned iterate \(x_k\):
it describes the polynomial \(p\) that \(F\) is in the lattice cells of
\(x_k\)’s positions. spectral_error_bound is the distance to the fixed
point \(x_p\) of \(p\), and \(x_p\) is a fixed point of \(F\) only where it lies
in those cells. Past a plane \(F\) is another polynomial, with another
fixed point or none nearby, and the bound is then to a point the pass
does not have.
The rule of 0.4.0. A group whose pass reads a position from the
iterate, or builds one and reads it in the same pass (a member’s
source-anchored geometry; the target-anchored geometry of a member that
computes fluxes: InterfacePlan.geometry_iterate_reads), has neither
flag: spectral_usable and gradient_bound_usable are False on
every step, whatever its numbers read, the numbers are kept as computed,
and not_usable_reason names the positions and says so. A group whose
positions are all constants of the pass (a target-anchored geometry read
by update, positions held by a node outside the group) has a smooth
group’s flags: no plane can come between its iterate and its fixed
point. One function decides (_group_layout._geometry_flags), from what
compile() committed; it sets a flag for constant positions only where
the step’s two stored numbers below both read \(\infty\), so a state that
this build’s step did not write sets none. The two numbers are defined
next, and then why no flag stands on them.
The margin of the Newton-Kantorovich ball (a stored number,
geometry_plane_margin). The step stores,
with diagnostics=True and \(\eta = \lVert D\delta \rVert_2\) the Newton
step in the group’s norm,
over the entries \(j\) of every position field the pass reads from the iterate or from the state the same pass has built: \(d_j\) the distance from \(x_{k,j}\) to the nearest lattice plane of the mapping that reads it (to the face it is clamped to, outside the hull), \(w_j\) eight float resolutions of the position (below), \(f\) the residual’s float resolution through the resolvent, \(D_j\) the norm’s weight (zero for a field in the norm’s dead band, which then counts as on a plane), and \(d_j\) taken as zero where the position the pass builds at \(x_k\) or at the Newton point \(x_N = x_k + \delta\) (what a Gauss-Seidel sweep reads after its holder’s update) is not strictly inside the cell of \(x_{k,j}\). A pass that reads no position from the iterate has \(\mathrm{margin} = \infty\). What a margin over one says, under five assumptions:
the kernel: between two positions with no lattice plane and no face of the hull between them the mapping is one polynomial of the positions;
the members: away from those planes the pass is smooth (no member’s
updatehas a kink of its own inside the ball);the smooth theory: for the polynomial piece \(p\) that the pass is at \(x_k\), taken as a map of the whole space, Newton-Kantorovich holds with some \(h \le 1/2\), so \(p\) has a fixed point \(x_p\) with \(\lVert D(x_p - x_N) \rVert_2 \le t^* - \eta \le \eta\), whatever \(h\) is under one half: the radius does not use the measured value. That the true \(h\) is under one half is assumed, not proved (the step measures it on one secant; see the limit below);
the floats: the measured residual is the exact pass’s to within its float resolution, which the resolvent carries into the Newton point and into \(\eta\) (\(2f\)), and a position within eight float resolutions of a plane is on it;
the norm: \(\lVert Dv \rVert_2 \le R\) bounds entry \(j\) by \(R/D_j\) where \(D_j > 0\), and an entry the norm does not read (\(D_j = 0\)) counts as on a plane.
Where \(\mathrm{margin} > 1\), entry \(j\) of \(x_N\) is \(\lvert\delta_j\rvert\)
from \(x_{k,j}\) and entry \(j\) of \(x_p\) within \((\eta + 2f)/D_j\) of that, so
every position of \(x_N\) and of \(x_p\) is in the cell \(x_k\)’s is in; the
positions the pass builds at \(x_k\) and at \(x_N\) are in it by the two
evaluations, and at \(x_p\) they are \(x_p\)’s own. The pass is therefore \(p\)
at the three points and \(x_p\) is a fixed point of the pass, within
\(t^* \le 2\eta\) of \(x_k\): what spectral_error_bound is a bound to.
Both Jacobians the gradient bound takes and the one it bounds are \(p\)’s,
so that bound is the smooth one too. No point is sampled to speak for a
plane it is not at.
Why a check at two points is not enough. The gradient bound’s
Newton-Kantorovich check takes the Jacobian at \(x_k\) and at \(x_N\), and
two Jacobians say nothing of a plane neither point is beyond. Inside a
cell the pass is not affine (the kernel’s own \(t_x t_y\) term; a scatter
times a field), so the Newton step misses \(x_p\) at second order. With
\(x_p\) a distance \(s\) past a plane and \(s\) under that miss, \(x_k\) and
\(x_N\) are in one cell, \(h\) reads near zero, and the pass has no fixed
point in that cell. The flags stood there: on a gradient bound 15 to
70,000 times under the error, and, where the next cell expands and the
pass’s fixed point is two cells on, on a spectral_error_bound 17 to
1,294 times under the distance (MADD-ANO-252).
Why no flag stands on the margin in 0.4.0. The argument needs
assumption 3, the Newton-Kantorovich condition for the smooth piece \(p\),
and the step does not prove it: it measures \(h\) on one secant, and the
radius of the ball does not use the measured value. An audit built a
cell whose polynomial has no fixed point: one marker on a
one-dimensional lattice, affine members, the only nonlinearity the
scatter’s own, the pass a saddle-node with a gap of \(10^{-5}\) to
\(10^{-8}\). The iterate creeps towards the bottleneck, the solve reports
converged, and the pass’s only fixed point is one cell on. The step’s
own check had failed there (gradient_relative_error_bound was inf),
the margin read 2.8 to 186 and the bound was under the limit below, so
both rules kept spectral_usable, on a bound 34 to 1,874 times under
the distance. It was the third audit in a row to find a flag set beside
a wrong number near a lattice plane, each on the rule that closed the
one before, so 0.4.0 certifies nothing for a group that solves
positions, and a rule that does is left to a later release (it has to
establish assumption 3, not take it). The margin stays a stored number:
it equals its definition (in float64; within 5% in float32 for a
position nine to three hundred float resolutions from a plane), a marker
on a lattice plane or a hull face has \(\mathrm{margin} = 0\) on every
step, and so has a position field of zero weight in the norm.
The limit (a stored number, geometry_plane_limit). The step also
stores
over the same entries (zero where the pass itself moves the position
further than \(d_j\)), here with \(D_j\) the spectrum’s weight (a field
in the norm’s dead band keeps its own magnitude’s) and \(u\) the norm’s
constant (1 under "l2", \(1/(\mathrm{rtol}\sqrt{n})\) under "mixed"),
so that the limit is in the units of spectral_error_bound. Where the
bound \(B > \mathrm{limit}\) a plane is within \(2B\) of a position. As a
rule for the flags it came before the margin: alone (a bound 0.13 times
the distance, MADD-ANO-242), then with the gradient bound’s
Newton-Kantorovich check required where \(B > \mathrm{limit}\), then
beside the margin. It does not put the ball clear of the planes,
because \(B\) is not proved to be at least \(\eta\) (the Krylov compression
behind it can fall short), and with the plane beyond \(2B\) it does not
look at a failed check, which is where the case above sat. A position
that is a constant of the pass is in neither minimum, and both numbers
read \(\infty\) where every position is one.
Every cause is reported. A group that solves positions has one
reason, the rule’s. Where a flag of a group whose positions are
constants of the pass is False and the step computed the estimate,
not_usable_reason gives each cause: the float floor with members that
do not declare their evaluations, an estimate that did not settle (more
independent interface scalars than its eight Krylov steps: no flag is
set for such a group at all, with or without a geometry), a bound that
is not finite, a gradient bound that was not computed (NaN) told from
one that did not certify (inf), and a record of the pass’s positions
that this build’s step did not write. None of them names a lattice
plane.
On a plane. A position within eight float resolutions of a lattice
plane has distance zero in both numbers: eps of its dtype times the
largest coordinate of the lattice’s axis, or the position’s own
magnitude (or its offset’s from the lattice origin) where that is
larger. The kernel decides the cell from the rounded quotient of that
offset by the spacing, and a position a member builds is the rounding of
the terms it was built from: one moved to 2e-9 from a face at zero, from
0.1, is in float32 the rounding of a sum of order 0.1, and the float64
reference has it 1e-8 on the other side. So the scale is the lattice’s.
The margin is zero there: rho_spectral is
then the radius of the cell the float pass evaluated, which may not be
exact arithmetic’s (MADD-ANO-239), and it carries no flag. The
Newton-Kantorovich check’s own outcome is rounding’s on a plane. A
marker at rest at coordinate 0.0, the lower face of a lattice whose
origin is 0.0, has a Newton step of about 1e-20 of either sign in its
position (rounding of the resolvent, which nothing absorbs at exactly
zero); on the steps where it is negative the Newton point is outside the
hull, in the clamped kernel, and gradient_relative_error_bound reads
inf (59 of 60 such steps measured, none of 53 with the other sign).
The flags and the reason of a group that solves positions do not follow
that reading: the report of such a state is the same on every step.
Everywhere else they do not. A coupling group whose pass resolves a
geometry-dependent mapping (on an edge into a member, from inside the
group or from outside) of another kind, or that sub-cycles, or that uses
the interface norm, or whose step failed the check above, reports its
solve, iterations, total_iterations, residual and converged, and
withholds everything built on the float floor or on the contraction
estimates: the bounds and estimates are NaN (gradient_error_estimate
is inf), ratio_usable, spectral_usable, gradient_bound_usable
and precision_limited are False, and the entry has a
not_usable_reason string that says so. One of these keeps the float
floor itself: a group withheld on account of the interface norm reports
precision_limited by the rule of every group and the floor it is
taken from, residual_precision_floor (below).
convergence_norm="interface" is refused at compile() for a group with
a geometry-dependent mapping on an internal edge where the mapping is of
another kind than multilinear_grid (a position is measured in the
kind’s own length scale, and only that kind declares one) or the group
sub-cycles (the reading is one value per pass, which is not what a member
that takes several sub-steps per pass was handed); use "l2" or
"mixed". The adaptive steppers and edges with a sharded end are
refused too.
What the interface norm reads on a geometry edge#
The compact-side rule of a static mapping, with the geometry in it. For
an internal edge \(S.f \to T\) through a multilinear_grid mapping \(m\)
with positions \(g\):
The mapping, between \(M\) points and a grid of \(N\) entries |
Parts of the reading |
Each part is measured against |
|---|---|---|
does not deliver more entries than it reads: a gather onto \(M \le N\) points, a scatter of \(M \ge N\) points |
the delivered value \(t(m(S.f;\,g))\), \(g\) at the time level the step uses: \(S.g\) of the same iterate for a source anchor, the pre-step \(T.g\) for a target anchor |
its own largest magnitude |
delivers more entries, source anchor: a scatter of \(M < N\) points (the markers hold \(g\)), a gather onto \(M > N\) points (the grid node holds \(g\)) |
the source value \(S.f\) as stored; and the positions \(S.g\), coordinate \(a\) divided by the grid spacing \(h_a\) |
\(S.f\): its own largest magnitude. The positions: one spacing |
delivers more entries, target anchor |
the source value \(S.f\) as stored |
its own largest magnitude |
The rows are by the entry counts the mapping declares, never by its mode: with more markers than grid entries the gather is the edge read at its source and the scatter the one read as delivered.
The residual pools every entry of every part into one root mean square:
a value’s change over rtol times its magnitude, a position’s change
over rtol spacings. A transform comes after the mapping, so a reading
at the source carries none.
Why a length scale and not the positions’ magnitude. A position’s magnitude is the distance from the origin of the coordinates. Divided by it, the tolerance of a marker a thousand spacings from the origin is a thousand times looser, in cells, than that of the same marker at the origin, and it loosens as a grid is refined under fixed markers. What a position does to the transfer is move a deposit by cells, so a cell is the unit; the mapping kind declares it (
geometry_length_scale()), and a length of your own is not offered in 0.4.0.atolis a statement about a magnitude and does not apply to a position: positions are always read.A target-anchored geometry is a constant of the solve. It is the target’s pre-step state at every pass, so it has no residual and is not a reading of a scatter anchored there.
What a solve returns. A field a part holds entry for entry is measured whole and returned as the accepted iterate holds it: the source value of an edge that delivers more entries than it reads and, for a source anchor, its positions. Every other floating field is recomputed by one plain pass.
The float floor. A position is rounded at the larger of two magnitudes, per coordinate, in spacings: its distance from the coordinates’ zero, \(|u|\) (it is stored to
eps |u|), and its lattice coordinate, \(|u - o/h|\), its distance from the grid’s first point (the kernel forms it in the positions’ dtype and resolves its weights toepsof that: the kernel paragraph above), wherever the kernel reads the coordinate. Call the larger \(r\); where several edges read one position field on different lattices, the largest over them. A value counts at theepsof the coarsest dtype it was computed from, the positions’ among them for a delivered one, and at leasteps rfor the positions it depends on (their rounding moves a kernel weight by as much); a position counts ateps rof one spacing. A value’sepsis taken no finer than the group’s coarsest floating dtype’s; theepsofeps ris always that of the positions’ own dtype, so float64 positions beside float32 fields put only float64’seps rinto the floor. Counted from the coordinates’ zero alone (eps |u|), float32 markers within 4 spacings of zero on grids of 16001 and 1e5 points centred there read a floor of 0.26 to 0.50 andconverged=Truewith the state 2 to 20 and 34 tolerances from the float64 fixed point, and one physical problem read 623 with the zero at the grid’s first point and 0.26 with it at the markers: no choice of the coordinates’ origin changes a lattice coordinate, so the larger of the two does not depend on it once the lattice coordinate is the larger. For a delivered valueeps rassumes a field that varies across one cell by about the size of the value delivered: it is early for a smoother field and it does not see a value far smaller than the field’s variation across a cell (a field sampled near its zero), which the positions’ rounding moves byeps rtimes that ratio (MADD-ANO-247; the user guide’s limits have the measured figures). The coordinates a delivered value does not depend on are not counted: one on an axis of one lattice point, and one of a point clamped to the hull from further out than its rounding can cross (geometry_coordinates_read). Positions that are a part of the reading are counted coordinate for coordinate, as the criterion reads them. The floor of a delivered value anchored at its target needs the pre-step positions, which the returned state does not hold: the step records it (reading_floor), and a state no step wrote has none.The advisory and the run-time reading.
compile()warns of each edge for which the positions’ term alone,4 E eps max r / rtol, is one or more on the state it sees: a part that is positions, or a value delivered at them, where the floor counts that part (not one the dead band drops, and not a coordinate the mapping does not read). It is asked once, and it is one part’s number. The report of such a group reads the floor at every step:residual_precision_flooris the floor of the state the step returned, pooled as the residual is over every entry the norm reads, andprecision_limitedisTruewhere the residual is at or below it, the rule of every group’s report, so markers that drift, a state write and a loaded checkpoint are read where they are. With finer entries beside the part the pooled floor is the smaller of the two, by up to the root of the part’s share of the entries (four markers a thousand spacings out beside two plain edges of 1e5 entries: 95.7 quoted atcompile(), 0.61 reported): compare the advisory’s number with one for the part, and the report’s floor with its residual and with one for the group.Eis the pass’s structural evaluation count in both, withdiagnostics=Trueas without: the count a step measures with diagnostics weights a read of positions by their distance from the coordinates’ zero, which this floor already counts (multiplied in, the floor of one state read 0.078 without diagnostics and 18 to 1189 with them), and no other number of such a group reads it in 0.4.0. (The user guide’s limits have the rule, the measured cases and the remedies.)The claim. A group that reports
converged=Trueis within \(K\) tolerances of its fixed point in these readings, \(K = \lVert D (I - A)^{-1} (I - L) D^{-1} \rVert_2\) on the compact readings as for a static mapping, to first order in the distance (the pass is nonlinear in the positions), above the residual’s float floor. \(K\) does not see the size of the grid. Nor the place of the coordinates’ origin; the floor sees a point’s lattice coordinate (the measured figures are in MAP-050 of the mapping claims).The verdict is a local statement.
converged=Truereads the contraction the last passes showed (a residual under the threshold after a step that shrank). \(K\) is the linearisation at the fixed point. The two describe the same thing where the pass keeps that rate from the accepted iterate to its fixed point, and the claim holds there to first order. Two ways to lose it, each constructed and pinned with its numbers (MAP-050):a lattice plane. The pass is another polynomial of the positions in each lattice cell. An iterate thrown across a plane into a cell where the pass contracts at 0.999 read
converged=True250 tolerances from a fixed point in the cell it came from (\(K = 0.95\) there);inside one cell, on two or three axes. Within a cell the kernel is multilinear of degree \(d\) in the positions (and a scatter is bilinear in a value and its weight on any number of axes), so the pass’s rate varies inside the cell too. One point in a lattice of ONE cell on three axes, its position \((v, v, v)\) for the value \(v\) it gathers, has a cubic pass with a single fixed point (contraction 0.05 there, \(K = 0.95\)) that creeps at 0.9999 elsewhere in the cell: it read
converged=Trueafter one counted pass 14,000 tolerances from that fixed point, with no plane or face crossed. “No marker crosses a lattice plane” is therefore not the condition. (A pass whose only nonlinearity is a gather on one axis is linear in the position within a cell, and for that pass a plane is the only place the rate changes.)
Not in 0.4.0. The bounds of such a group (
rho_spectral,spectral_error_bound, the gradient bound, the estimates and their flags) are not reported under this norm in 0.4.0 (the paragraph above); a diagnostic run of the same group under"mixed"or"l2"reports them. Another mapping kind and a sub-cycled group are refused.
A graph without a geometry edge is not affected by any of this: it
compiles to the same programs as before the feature existed
(tests/core/test_step_program_digests.py compares the lowered program
text of 24 graphs with a capture taken before it).
Legacy closures#
maddening.core.coupling.interface_mapping.rbf_interpolation and friends
still return closures usable as transform=; they now share the
polynomial-augmented, solve-based matrix construction (rbf_matrix).
Prefer mapping=: a closure’s matrix is a baked constant the graph can
neither differentiate nor replace.