Inspecting a graph#
A GraphManager can describe itself: its structure as text or as a
diagram, its state, parameters, coupling convergence and memory as
tables, and the software it is running on as a version report. All of
it is new in 0.4.0 and marked experimental: the methods may still
change in a minor release.
Every method on this page is read-only. It does not write the
state, _meta, gm.params, a node’s parameters, the dirty or compiled
flags, the schedule or a cache, and it does not compile or trace the
step. On a graph that is not ready to be read as it stands – never
compiled, modified since its last compile, or left holding JAX tracers
by jax.grad – it says so instead of fixing it (see “Graphs that are
not ready” below).
tests/core/test_inspection_read_only.py checks this for every method
on every kind of graph by fingerprinting the whole graph around each
call.
One thing does run. Where a coupling group has a transform or a mapping
on an edge between its members, coupling_report() asks that edge what
it delivers, and traces the edge’s rule once on every call to find
out (under jax.eval_shape: shapes and dtypes, never values). The step
is not compiled or traced again and nothing on the graph changes, but a
transform is your code and runs as Python once per report: one with a
side effect (a counter, a print) shows it each time. The other
methods on this page run no transform.
Output is plain text by default, and deterministic: the same graph
prints the same text on every machine, wrapped at width characters
(default 100) rather than at the terminal’s width. Pass rich=True to
any print_* method to render it with the optional
rich package
(pip install maddening[terminal]); that is the only way those methods
use rich, and without the package it raises an ImportError naming
the extra. The one exception to plain text is print_graph_diagram()
(at the end of this page), a drawing that needs the same extra.
The first print_*(rich=True) in a process imports rich, and importing
rich draws once from Python’s global random (random.getrandbits(24),
rich 15.0.0). A sequence seeded with random.seed() before that call
therefore changes: the random.random() draws that follow are not the
seeded ones, not even shifted by one. NumPy’s global generator and the
graph’s state are untouched. If a seeded random sequence matters, seed
it after the first rich print, or import rich before seeding.
The graph at a glance#
from maddening import GraphManager
from maddening.nodes import SpringDamperNode
gm = GraphManager()
gm.add_node(SpringDamperNode("left", 0.01, stiffness=30.0, damping=2.0,
initial_position=1.5))
gm.add_node(SpringDamperNode("right", 0.01, stiffness=30.0, damping=2.0))
gm.add_node(SpringDamperNode("probe", 0.02, stiffness=5.0, damping=1.0))
gm.add_edge("left", "right", "position", "anchor_position")
gm.add_edge("right", "left", "position", "anchor_position")
gm.add_edge("right", "probe", "position", "anchor_position")
gm.add_coupling_group(["left", "right"], max_iterations=8, tolerance=1e-6)
gm.compile()
assert "[left+right] coupled block" in gm.format_graph()
gm.print_graph()
prints
GraphManager: 3 nodes, 3 edges, 1 coupling group, 0 external inputs
status: compiled
base timestep: 0.01 (multi-rate)
Nodes (3)
left SpringDamperNode
timestep 0.01, rate divider 1
coupling group left+right
state: position float32[], velocity float32[]
right SpringDamperNode
timestep 0.01, rate divider 1
coupling group left+right
state: position float32[], velocity float32[]
probe SpringDamperNode
timestep 0.02, rate divider 2
state: position float32[], velocity float32[]
Edges (3)
left.position -> right.anchor_position
state; iterated inside its coupling group
right.position -> left.anchor_position
state; iterated inside its coupling group
right.position -> probe.anchor_position
state
Coupling groups (1)
left+right
members: left, right
solver ift, acceleration none, mode gauss-seidel
norm l2 (tolerance 1e-06), max_iterations 8
diagnostics off, strict off
External inputs (0)
(none)
Execution order
1. [left+right] coupled block: left, right
every base step
2. probe
every 2 base steps
gm.format_graph() returns the same text as a string. The sections:
Nodes: the type (a wrapper shows what it wraps, as
ShardedStencilNode(HeatNode)), timestep and rate divider, the coupling group and how many times a sub-cycled node runs per coupling pass, each state field asdtype[shape], and for a sharded node the wrapper, the mesh axes and which state axis is split. A sharded axis reads32@devices.Edges:
source.field -> target.input, whether the source is a state field or a boundary flux, the transform’s registered name, the mapping and itsparams["mappings"]key,additive, units, and whether the edge is iterated inside a coupling group or is a back edge that reads the previous step’s value. An edge from a node to itself is one or the other by whether the node is in a group, which makes the term it carries implicit or explicit, andgm.validate()says which in anINFO:line for the edge: see An edge from a node to itself, which shows both lines.Coupling groups: members, solver, acceleration, iteration mode, the norm with the tolerance it actually reads (
tolerancefor"l2",atol/rtolfor"mixed"and"interface"),max_iterations, diagnostics, strict convergence, and any other setting that is not its default.External inputs and the execution order: the compiled schedule, a coupling group as one block, and how often each block fires.
Long node names wrap onto their own line and are never split.
Diagrams: Mermaid and Graphviz#
to_mermaid() and to_dot() return the same structure as plain text.
Coupling groups are subgraphs, edges are labelled field→input, a flux
edge or external input is dashed, and every name is escaped, so quotes,
angle brackets or newlines in a node name cannot break the diagram.
Nothing here needs Graphviz or Mermaid installed.
print(gm.to_mermaid())
flowchart LR
subgraph g0["coupling group left+right"]
n0["left<br/>SpringDamperNode"]
n1["right<br/>SpringDamperNode"]
end
n2["probe<br/>SpringDamperNode"]
n0 -->|"position→anchor_position"| n1
n1 -->|"position→anchor_position"| n0
n1 -->|"position→anchor_position"| n2
Paste it into GitHub Markdown or the Mermaid live editor. For Graphviz, write the DOT text to a file:
from pathlib import Path
dot = gm.to_dot()
assert dot.startswith("digraph maddening {") and "subgraph cluster_g0" in dot
Path("graph.dot").write_text(dot)
and render it with dot -Tsvg graph.dot -o graph.svg. To draw the
structure in the terminal instead, see “Drawing the graph in the
terminal” at the end of this page.
Tables: state, parameters, memory and coupling#
Four methods return an InspectionTable, and each has a print_*
counterpart that prints it:
returns the table |
prints it |
|---|---|
|
|
|
|
|
|
|
|
An InspectionTable is a sequence of dict rows – table[0]["max"],
list(table), pandas.DataFrame(list(table)) – and print(table) shows
it as text. table.notes holds the caveats printed under it,
table.summary any table-wide values, and a row’s "flags" entry the
warnings about that row, which the text prints beneath the table with a
!. The table is a snapshot: editing a row never touches the graph.
State#
table = gm.state_summary()
print(table)
State summary: 6 fields in 3 nodes
node field shape dtype min max mean nan inf bytes
----- -------- ----- ------- --- --- ---- --- --- -----
left position () float32 1.5 1.5 1.5 0 0 4
left velocity () float32 0 0 0 0 0 4
probe position () float32 0 0 0 0 0 4
probe velocity () float32 0 0 0 0 0 4
right position () float32 0 0 0 0 0 4
right velocity () float32 0 0 0 0 0 4
notes:
- min, max and mean are taken over the finite entries; nan and inf count the rest
row = table[0]
assert (row["node"], row["field"], row["max"]) == ("left", "position", 1.5)
assert all(r["nan"] == 0 and r["inf"] == 0 for r in table)
The statistics are computed on the host from a copy of each array. A
field holding NaN or inf is flagged. include_meta=True adds the
internal _meta entries (the multi-rate step counter, the coupling
diagnostics and warm starts).
Parameters#
params_table() lists every leaf of gm.params with its ParamSpec
(the node’s own, with set_param_spec overrides applied) and checks it
against its bounds by ParamSpec.check()’s rule:
from maddening.core.params import ParamSpec
gm.set_param_spec("left", "damping", ParamSpec(bounds=(0.0, 1.0)))
print(gm.params_table())
Parameters: 18 leaves
owner param value shape dtype trainable bounds transform out_of_bounds
----- ---------------- ----- ----- ------- --------- ------ --------- -------------
left damping 2 () float32 yes (0, 1) - yes
left initial_position 1.5 () float32 no (-, -) - no
left initial_velocity 0 () float32 no (-, -) - no
left mass 1 () float32 yes (0, -) log no
left rest_length 1 () float32 yes (-, -) - no
left stiffness 30 () float32 yes (0, -) log no
probe damping 1 () float32 yes (0, -) - no
probe initial_position 0 () float32 no (-, -) - no
probe initial_velocity 0 () float32 no (-, -) - no
probe mass 1 () float32 yes (0, -) log no
probe rest_length 1 () float32 yes (-, -) - no
probe stiffness 5 () float32 yes (0, -) log no
right damping 2 () float32 yes (0, -) - no
right initial_position 0 () float32 no (-, -) - no
right initial_velocity 0 () float32 no (-, -) - no
right mass 1 () float32 yes (0, -) log no
right rest_length 1 () float32 yes (-, -) - no
right stiffness 30 () float32 yes (0, -) log no
flags:
! left.damping: out of bounds: above the upper bound 1
notes:
- value is the scalar for a one-element leaf; an array leaf shows its shape. out_of_bounds applies
ParamSpec.check()'s rule (non-finite values count)
gm.params is read as it stands. Unlike check_params(), which
converts a Python float written into gm.params to an array in place,
this changes nothing. On a graph that has never been compiled
gm.params is still empty; the table then shows the values compile()
would take, and says so.
Memory#
print(gm.memory_estimate())
State memory estimate: 40 B in 4 entries
node fields bytes per_device_bytes devices sharding
----- ------ ----- ---------------- ------- --------
left 2 8 8 1 -
probe 2 8 8 1 -
right 2 8 8 1 -
_meta 4 16 16 1 -
state_bytes: 24 (24 B)
meta_bytes: 16 (16 B)
total_bytes: 40 (40 B)
total_per_device_bytes: 40 (40 B)
notes:
- state memory only, computed from shapes and dtypes: XLA workspace, compiled programs, the copies
a step makes, scan histories, params and external inputs are not included
- bytes is a node's global (logical) size; per_device_bytes is what one device holds of it (a
sharded field's shard, a replicated or unsharded field in full); total_per_device_bytes adds
those, the most one device holds if every node's share lands on it
This counts state memory only: it is a floor on what a run needs, not
an estimate of it. For a sharded node, per_device_bytes is one shard
and devices the number of devices it is spread over;
python -m maddening.examples.advanced.sharding_demo shows it, and the
wrapper in print_graph(), on four emulated CPU devices.
Coupling convergence#
coupling_report() is coupling_diagnostics() as one row per coupling
group, with the documented caveats flagged where they apply: a group
that hit max_iterations, converged=False, ratio_usable=False (the
criterion fell back to the raw residual test, which is then all
converged reports), precision_limited=True (the residual is rounding,
and converged can be true on a stalled iterate), and an unsettled
spectral bound. A group with no report says why.
gm.step()
report = gm.coupling_report()
(row,) = report
assert row["group"] == "left+right" and 1 <= row["iterations"] <= 8
gm.print_coupling_report()
prints, for this graph,
Coupling report: 1 group
left+right
iterations 2, total_iterations 2, max_iterations 8, converged yes, residual 0,
error_estimate 0, amplification 1, ratio_usable yes, precision_limited yes,
rho_spectral nan, spectral_error_bound nan, spectral_usable no
! precision_limited=True: the residual is at its float floor, so residual and error_estimate
are rounding and converged can be True on a stalled iterate; read spectral_error_bound
(solver='ift', diagnostics=True)
notes:
- error_estimate is an estimate, not a bound: it can understate the distance to the fixed point by
large factors even with ratio_usable=True; spectral_error_bound (solver='ift', diagnostics=True)
is the bound where spectral_usable is True, for a linear map (asymptotic for a non-linear one),
in the group's own norm at the returned state (under 'interface', what each edge delivers: its
mapping, then its transform; or its source field where a static mapping delivers more entries
than the source holds). See coupling_diagnostics()
- multi-rate graph: a group's entry is its most recent applied solve
(the counts and residuals are the solver’s, so they can differ in the last
digits between JAX versions). Part 5 of
python -m maddening.examples.coupling.convergence_diagnostics_demo
prints the report for a converged group and a capped one, and shows
strict_convergence=True raising instead of reporting. diagnostics=True on a solver="ift"
group fills rho_spectral and spectral_error_bound.
What the report is of under convergence_norm="interface". iterations, residual,
converged and the bounds describe the iterate the loop accepted. The step returns that iterate
with every floating field the norm does not measure whole – one no internal edge reads, or one
read only through a gather, a tie or a transform – recomputed by one plain pass at it. (The source
field of a static mapping onto more entries than it holds is read at its source, so it is measured
whole and kept, like one a plain edge reads. The side is decided by those entry counts and not by
the mapping’s direction, and a multilinear_grid mapping is read by the same counts: with more
markers than grid entries its gather is the edge read at its source, whose grid field is kept, and
its scatter is read as delivered, the markers’ value recomputed; see
Geometry-dependent mappings.) The state you
read is therefore within the reported residual of the reported iterate on what the edges deliver,
and is not itself an iterate of the loop; its own residual can be a few times the reported one.
See “What a converged step returns under each norm” in
the algorithm guide.
A dead band (atol > 0), on any group. Leave atol at 0.0 in 0.4.0. A field at or below
atol leaves the residual, and a change that has to cross it is not seen until it reaches a field
the norm keeps, so a group that sets it can report converged=True after one pass with a kept
field thousands of tolerances from its fixed point (MADD-ANO-254, open; under every norm: 4.5e5 to
7.4e5 tolerances on a Jacobi pair; 4.6e3 to 1.8e4 on a Gauss-Seidel ring of three swept against its
data flow; and under Gauss-Seidel 4.3e3 to 7.7e3, 1.6e5 in the other sweep order, on a pair of
2-vectors, a pair with two fields a member, a pair with one edge from a member to itself and a
group of ONE member with two such edges). compile() warns and validate() says so for every
group that sets atol > 0, whatever its member count and schedule. A pair with one scalar field a
member and no edge to itself held where it was measured (at most 0.8 tolerances, both sweep
orders) and is advised on all the same: which groups hold is not a proved criterion, and none is
excepted. At atol=0.0 only a field that is exactly zero leaves the residual; every non-zero
field, however small, is measured against its own magnitude, and every graph above held there.
The dead band is expected to be replaced in 0.5.0, so do not tune to it.
Mixed dtypes. The float floor, and so precision_limited and both usable flags, is counted at
the coarsest floating dtype the group’s pass goes through: any field of any member that holds
entries (a field no loop passes through included), and what every internal edge delivers after its
mapping and its transform, so a transform that narrows to float32 between float64 members is
counted. A narrowing inside a node’s own update (a cast down and back) cannot be seen from
outside and is not counted: declare the narrower field, or do not rely on the flags there.
Nor is an internal edge whose source is a boundary flux (a key of compute_boundary_fluxes
and not a state field): what it delivers is not read, so a transform on it that narrows is not
counted (MADD-ANO-259, open). On a float64 pair at rtol=1e-12 whose one edge narrows to float32,
the value handed over as a flux stalled at float32’s rounding with converged=True, a residual of
exactly 0 and spectral_usable=True beside a bound 5.6e-8 to 7.5e-8 of the distance to the fixed
point, under "mixed" and "l2" and both schedules. Carry a value that is narrowed on its way
as a state field: the same pair with the same transform on a state field is counted, and its
bound read 30 to 39 times the distance. (convergence_norm="interface" refuses a flux edge at
compile().) What a geometry-dependent mapping delivers is counted at the rounding of its
kernel’s weights as well (experimental): float32 positions far into a grid make every value
field of the group that coarse, whatever the fields’ own dtype; see “Under "l2" and "mixed"”
in the guide to geometry-dependent mappings (MADD-ANO-261).
In a batch (jax.vmap of the step). The step of a batch is another compiled program than the
step alone. A member’s verdict, its pass count and every flag are the member’s alone, and its
state to a few roundings (measured on thirty graphs with a mapped internal edge and on plain
pairs and rings, under both schedules: identical flags and reasons, node state within 9e-8).
The report’s numbers are good to their own float resolution, and one of them shows it:
gradient_relative_error_bound is the bound’s own arithmetic run by the batched program. It
agreed with the member’s alone to 4e-6 on plain edges under either schedule and to 2.5e-7 on a
mapped edge under Gauss-Seidel; on a Jacobi group with a mapped edge the two were 0.03% to 0.8%
apart above the float floor and up to a factor of two apart where the report is at its float
floor (precision_limited=True). A member’s number does not depend on the other members of
the batch, their order or their count. On a plain pair whose gradient is closed form each
member’s bound held against the true error in the batch as alone (1.1 to 3.2 times it, both
schedules); behind a mapped edge under Jacobi at the float floor the batched number was not
compared with a true error, so read its order of magnitude there, not its digits.
What a node declares for spectral_usable at the float floor. A group whose residual is at
its float floor (precision_limited=True: any converged float32 group at the default tolerance)
reports spectral_usable=False, and so gradient_bound_usable=False, unless every node in it
declares update_evaluations(): there the floor is the whole bound, and the floor counts each
node’s evaluations. No node shipped in maddening.nodes declares it in 0.4.0, so such a group of
stock nodes never reads usable at the floor; the numbers are still reported. On your own node,
return how many sequential sub-steps one update takes (1 for a single explicit step or a
relay, N for a loop of N sub-steps), and read
the algorithm guide for what the count assumes;
a float32 group with a field under a hundredth of what drives it should be run in float64 before
its flag is relied on (MADD-ANO-230). The same holds for a float32 Gauss-Seidel group in which a
node multiplies two other nodes’ fields held in units a factor of 1e3 or more apart: one float32
rounding of such a field can move the pass’s derivative by more than the flag’s margin, and
rho_spectral then describes the float32 pass, not the equations (0.324 for 0.0238 in the measured
case, with spectral_usable=True; the error bound still held). Run it in float64 or under Jacobi,
or hold the multiplied fields as deviations in comparable units (MADD-ANO-239). Inside that
margin the same rounding still shows in the digits: where a node of such a group has a quadratic
or product term of curvature 100 over the size of the field it reads, rho_spectral is the float32
pass’s radius to eight digits and a few parts in ten thousand from the one exact arithmetic gives.
What diagnostics=True costs. It is opt-in per group, and the work is done in every step of
that group. Beside the solve, the step runs 9 Jacobian-vector products for the spectrum (18 under
convergence_norm="interface" with a transform, or a mapping that does not deliver more entries
than it reads, on an internal edge, or a field more than one internal edge reads) and 11 + 4 k + 5 n_p + 2 k n_p more for the gradient bound
(k <= 8, n_p the probed constants), then dense factorisations that are small in one dimension
only (a QR of an n x 2k matrix, linear solves in k x k, and the singular values of k x n
matrices, one per probe, n being the group’s floating entries) and one non-symmetric eigenvalue
solve of a matrix of at most 9 x 9. The eigenvalue solve runs in LAPACK on the host; on a GPU
backend it is a device round trip in every step. The work and the memory grow in proportion to
the group’s entries, and the factor is large: each product is a pass of the group, and the
factorisations hold a few hundred floats per entry. Measured on a pair of 3 values and n grid
cells in float32, 14 passes per step (CPU, 4 cores, jax 0.11.0; the pair of
tests/core/test_spectral_norm_takes_singular_values_only.py):
group entries |
step, diagnostics off |
step, diagnostics on |
peak memory, off |
on |
|---|---|---|---|---|
1e3 |
0.24 ms |
8.5 ms |
0.28 GB |
0.61 GB |
1e4 |
0.34 ms |
31 ms |
0.27 GB |
0.62 GB |
1e5 |
2.2 ms |
0.44 s |
0.28 GB |
0.73 GB |
1e6 |
23 ms |
3.9 s |
0.31 GB |
1.9 GB |
Against the same step without diagnostics, measured as each process’s own peak (VmHWM, one
fresh process per measurement; CPU, 4 cores, float32, jax 0.11.0): one step of a group of 2,000 to
10,000 unknowns (two fields coupled cell by cell through a three-point stencil, two scalar
parameters per node) peaked at 2.1 to 2.3 times the memory with diagnostics on, 0.61 to 0.62 GB
against 0.27 to 0.29 GB. What the step adds to the built graph was 6 to 7 times larger (0.40 GB
against 0.06 to 0.07 GB), most of it a fixed cost that did not grow between 2,000 and 10,000
unknowns. So plan for at least twice the memory of the step without diagnostics, and more on
a large group (six times in the table’s last row): a group that nearly fills a device’s memory
without diagnostics will not fit with them.
So on a large group take the report every so often (a second graph with diagnostics=True, or a
restart from a checkpoint), not in every step. The first step also compiles the analysis: about
6 s more here. Measured
once on a two-spring pair in float32 (an RTX A2000 laptop GPU, jax 0.11.0,
benchmarks/results/gpu_eigvals_probe/RESULT.md): a step takes 0.3 ms with diagnostics off and
6 to 8 ms with them on, about 4 ms of it the eigenvalue solve; on CPU the same diagnostics-on step
takes 0.25 to 0.5 ms.
diagnostics=True needs second derivatives of the members. The gradient-error bound
differentiates the group’s linearisation, which is a second derivative of every member’s pass. A
member whose derivative rule is first order only cannot supply it: a jax.custom_jvp whose
tangent uses a coefficient JAX cannot differentiate, such as one from jax.pure_callback or from a
jax.custom_vjp function. With diagnostics=True the step then raises when it is traced, so the
group reports no key at all. Measured on a two-node group (jax 0.11.0):
ValueError: Pure callbacks do not support JVP. Please use `jax.custom_jvp` to use callbacks while taking gradients.
TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
the first for a coefficient from a callback, the second for one from a custom_vjp. With
diagnostics=False the same group steps and converges in as many passes as its twin written in
plain jnp, and jax.grad and jax.jvp through it equal the twin’s. There is no setting that
keeps the spectral keys and leaves out the gradient bound: run such a group with
diagnostics=False, or give the member a rule JAX can differentiate twice. A member whose rule
is reverse mode only cannot be differentiated in a group on the default solver; see “A member with a
reverse-mode-only derivative” in the algorithm
guide.
Why a flag is False: reason_codes (experimental). Every entry of coupling_diagnostics()
has a reason_codes dict with a list of codes for spectral_usable, for gradient_bound_usable
and for precision_limited (the string constants of maddening.core.coupling.reason_codes), and
a usable flag that is False always has a not_usable_reason saying the same in words. A test
can branch on the codes: interface_too_wide is expected and nothing is wrong;
spectral_self_check_failed and not_contracting deserve a look; at_float_floor says to switch
to float64 or loosen the tolerance; diagnostics_off says the estimates were never asked for.
The table of codes
says what each means and what to do. precision_limited has a code only where the float floor
was not measured (the flag then says nothing of rounding); every entry also reports that floor as
residual_precision_floor, in tolerances, NaN where it was not measured. This table does not
print the codes; it quotes the reasons it always did.
How wide a group the spectral estimate resolves. Not a matter of how many points a member
holds. The estimate takes eight Krylov steps, which resolve a pass that depends on the previous
one through at most seven independent scalars (eight where they are the whole state). Under
Gauss-Seidel that is what the loop closes through, so a body exchanging one wrench (six scalars)
with a field of any size is within it, in either sweep order; a rigid body’s full state (13
scalars) is not, nor is a field-to-field pair; under Jacobi both directions count. Members are
swept in the order they were added, which is the lever. A wider group reads spectral_usable=False
with the code interface_too_wide: its rho_spectral is an estimate, and nothing is wrong. See
the algorithm guide.
A report describes the step that ran. The bound’s float floor is measured on the state the
step returned. Writing the state afterwards (set_node_state, PUT /graph/state/{node}) does not
change the entry: it describes that step until the group steps again. A checkpoint is a copy of the
state, so one saved after a member’s state was written holds the written state and not the returned
one. It says so, and the graph that loads it reports that group’s spectral_error_bound as NaN and
its spectral_usable, gradient_bound_usable and precision_limited as False, with a
not_usable_reason, until the group steps.
A report is judged under the group the graph holds now. The saved slots carry the numbers of
the step that wrote them, not its settings. Loaded into a graph whose group has another rtol,
norm or schedule, and read before the next step, they are judged under the loading group, with no
warning: a state saved under rtol=1e-3 and loaded with rtol=1e-6 reported converged=True, a
bound of 0.88 and both flags set while it was 788 of the new tolerances from its fixed point;
loaded under "mixed" it reported a usable bound in a norm no step had taken; loaded under Jacobi
it reported the Gauss-Seidel rho_spectral (0.72 where Jacobi’s is 0.8485). The next step
corrects it. After loading a checkpoint into a graph configured differently, step once before
reading coupling_diagnostics().
A group with a geometry-dependent mapping (experimental, see
Geometry-dependent mappings) has its numbers reported where
every such mapping is a multilinear_grid, the norm is "l2" or "mixed" and the group does not
sub-cycle. Otherwise, and on a step whose self-check of the geometry term failed, its entry has
iterations, total_iterations, residual and converged, every bound NaN, every *_usable
flag False, and a not_usable_reason that says which case it is. Its flags depend on
whether the group solves the positions. Where the pass reads a position from the iterate, or
builds one and reads it in the same pass (a source-anchored geometry inside the group; the
target-anchored geometry of a member that computes fluxes), spectral_usable and
gradient_bound_usable are False on every step in 0.4.0: the numbers are reported as computed,
uncertified, and the reason names the positions. The spectrum is taken at the returned iterate,
and across a lattice plane the stencil is another polynomial, whose fixed point may be in another
cell or nowhere. Where every position is fixed during the pass (a target-anchored geometry read
by update, positions held by a node outside the group) the flags are those of any other group.
A long row of a static mapping keeps its numbers and loses its flags at the float floor
(MADD-ANO-257, open). A mapped edge delivers sums over its rows, and a float sum of k terms of
one sign rounds by up to (k - 1) / 2 units of eps, systematically where the terms are nearly
equal (a uniform field) and the sum is taken in order; the float floor counts a fixed number of
units per evaluation. Where an internal edge of the group carries a static mapping of any kind (a
dense matrix, a sparse mapping in the gather layout or in the scatter layout, a registered kind’s
own static class) with a row longer than 10 entries (MAPPED_ROW_FLOOR_LIMIT, a measured constant)
and the residual is not above the float floor times the row’s length, spectral_usable and
gradient_bound_usable are False, every number is reported as computed, and not_usable_reason
names the edge, how its mapping is applied, the row’s length and the way out: a wider dtype at the
same tolerance. “The group’s fields” there means every floating field of every member, and what
its edges deliver: the floor is counted at the coarsest of them (see “Mixed dtypes” above), so one
float32 field on a float64 member, even one no loop passes through, keeps the whole group’s floor
at float32 until that field is widened too. Behind such a row rho_spectral itself is an estimate
(rows of 3333 entries, float32, "interface", rtol = 0.1: 0.926 for an exact 0.9 with both flags
set), while spectral_error_bound stayed conservative in every run measured (7.8x there).
What you see. A float32 group whose mapping adds up a few hundred entries a row has the two
flags withdrawn on every step at an rtol of 1e-4 or tighter: the float floor there is 0.005 of
the threshold per evaluation, so 300 entries reach past a residual at the threshold itself. The
same group in float64 keeps its flags (its floor times such a row reaches a residual only at an
rtol of about 1e-12 or tighter). A row is the entries one delivered value adds up, whatever the weights are,
since they are a parameter a step may be handed: a sparse layout’s valid slots, and a dense
matrix’s width. A dense selection or interpolation matrix more than ten entries wide is
therefore counted although each of its rows holds one or two non-zeros and its sum is exact; a
sparse mapping of the same operator is counted at those one or two entries and keeps its flags.
converged, residual, precision_limited, ratio_usable and every number are as they were.
A flag the rule keeps can still stand on a bound slightly under the distance: 0.9925 of it at the
least on the measured pairs, and 0.982 on a group of ONE member with an edge to itself (one
evaluation a pass, a row of 1000 entries, "interface").
Why every kind is counted. Measured on a float32 pair stalled behind a uniform field (the
smallest spectral_error_bound over the true distance among the reports that set the flag, before
the guard): the scatter layout, which adds a row up one entry after another, read 0.68 behind 100
entries, 0.24 behind 300 and 0.002 behind 3e4, on jax 0.10.2, 0.11.0 and 0.11.2 alike. The gather
layout and the dense kinds are summed in an order XLA chooses, which depends on the jax version,
the dtype and the operator’s shape: one row of either held at every length on jax 0.11 (1.8x at
300 entries, 1.09x at 3000), but jax 0.10.2 sums the gather layout’s float32 rows of 1e4 entries
and more in order (0.002 to 0.007 of the distance), and a dense matrix of three rows of 3000
entries read 0.18 on every version. Rows of up to 10 entries held by 3.9x or more in every kind,
behind fields whose terms do not cancel. A field that changes sign within a row loses the
cancellation’s factor whatever the row’s length and its kind, which is MADD-ANO-247’s case (open)
and not this guard’s: with a cancellation sum |t| / |sum t| of 25 the bound read 1.26x at 10
entries and 1.44x at two in float32, and 0.85x at 10 entries at the float64 floor with the flag
set. Behind a field whose terms cancel, do not rely on spectral_usable at the float floor.
Not counted: a sum written inside an edge transform. The guard reads the static mappings on
a group’s internal edges and nothing else. A transform= that adds many terms into one entry
(lambda v: jnp.zeros(n).at[cells].add(...)) is not a mapping: its sum is arithmetic the guard
cannot see, like a node’s own, and no flag is withdrawn for it (MADD-ANO-260, open). With 300,
3000 and 30000 float32 terms added into one entry behind a uniform field, members that declare
one evaluation and the residual at its float floor, the bound read 0.28, 0.034 and 0.0025 to
0.0038 of the distance with spectral_usable=True; with ordinary members at rtol=1e-4 it read
0.2 behind 3000 terms and 0.096 behind 30000, precision_limited=False. Declare the operator as
a static sparse mapping (mapping=): the guard then counts its rows and withdraws both flags
with its reason, and the bound read 1.16 to 27 times the distance on the same operators.
A geometry-dependent mapping is counted at the longest its row can be. A multilinear_grid
gather adds up at most 2^d entries, which is within the limit. Its conservative form (points
to a grid) is a scatter-add whose rows are the markers in a grid node’s support, a number decided
in the step, so the guard counts the edge at what a row can reach whatever the positions are:
its number of points (every marker can fall within a spacing of one grid entry), whichever
side its positions are anchored at. A scatter of more than ten points therefore has its flags
withdrawn where the residual is not above the float floor times the number of points, like a
static mapping with a row that long; ten points or fewer keep them. Before the guard read such
an edge, a pair whose scatter’s positions are constants of the pass (the grid node holds them)
read its bound at 0.40 and 0.32 of the distance behind 300 markers in one cell and 0.048 and
0.032 behind 3000 with both usable flags set (members declaring one evaluation, the residual at
its float32 floor), and 0.61 with ordinary members at rtol=1e-4 and precision_limited=False
(MADD-ANO-257). A group that solves the markers’ positions has no usable flag whatever its rows
(MADD-ANO-252); its report now names the row as well where the residual is within its reach
(with 8, 300 and 3000 markers in one cell behind a uniform field its bound read 13.8, 3.8 and
1.3 times the distance under "mixed").
A number whose flag is False is not a number to compare. Where gradient_bound_usable is
False the value beside it can be finite, inf or NaN, and at the float floor it can differ in
kind between backends: on the pair above after 40 float32 steps (residual exactly 0),
gradient_relative_error_bound read 4.3e-6 on CPU and NaN on the GPU, with the flag False on
both (MADD-ANO-232).
Graphs that are not ready#
None of these methods compiles a graph or puts it back after a transform. What each reports instead:
never compiled |
modified since the last compile |
holding JAX tracers |
|
|---|---|---|---|
|
structure as registered; rate dividers and execution order “not compiled” |
the last compile’s schedule, marked stale |
shapes and dtypes read from the tracers |
|
structure as registered |
structure as it stands now |
structure (it reads no state) |
|
the state |
the state it holds now |
shapes only; no values, with a note |
|
the values |
the live |
|
|
“not compiled” |
the last step’s report |
“state holds tracers” (it does not call |
|
node state, no |
the state it holds now |
from the tracers’ shapes and dtypes |
A graph holds tracers after jax.grad of a loss that calls run_scan
(see the quickstart). The next stepping method, or get_node_state,
puts it back to the state it had before the transform; inspect it after
that to see values.
Version report#
maddening.show_versions() prints what a bug report needs: the
MADDENING version and where it is installed; the Python, platform, JAX,
jaxlib, NumPy, SciPy and lineax versions; the JAX backend, its devices and
whether 64-bit floats are on; XLA_FLAGS and the other allowlisted
JAX/XLA/MADDENING environment variables that are set; and which optional
extras are installed.
import maddening
report = maddening.show_versions(as_dict=True)
assert report["dependencies"]["jax"] and report["jax"]["backend"]
maddening.show_versions()
It begins, for example,
MADDENING version information
=============================
maddening 0.4.0 (/path/to/site-packages/maddening)
python 3.12.3 (CPython)
platform Linux-6.8.0-x86_64-with-glibc2.39
machine x86_64
dependencies
jax 0.11.0
jaxlib 0.11.0
numpy 2.4.6
scipy 1.17.1
lineax 0.0.7
JAX runtime
backend cpu
devices 1 x cpu (cpu)
x64 enabled False
and goes on to the environment variables and the extras. The same report from the command line, as text or JSON:
python -m maddening info
python -m maddening info --json
It prints no secrets: environment variables are read from a fixed
allowlist (maddening.info.ENV_ALLOWLIST), so an API token or a cloud
key in the environment never reaches the report. Extras are located
without being imported, and listing the devices compiles nothing.
Readable fit and FIM results#
print() of a FitResult (from fit, fit_lm or
fit_multiple_shooting) or a FIMReport (from fim) gives a short
summary – whether the fit converged, its losses and the iterate it
returned, the identifiability guard’s verdict and the fitted values; the
FIM’s rank, conditioning, weakest direction and each parameter’s
Cramér–Rao bound – where repr() lists every field, as before.
import jax.numpy as jnp
from maddening.sysid import fim
info = fim(lambda p: jnp.stack([2.0 * p["k"], p["k"] + p["c"]]),
{"k": jnp.float32(3.0), "c": jnp.float32(1.0)}, scale=None)
assert str(info).startswith("FIMReport: rank 2 of 2 parameters")
print(info)
prints
FIMReport: rank 2 of 2 parameters (all determined); cond 6.854
least identifiable: ['c'] (weight 0.973 in the weakest direction)
Cramér–Rao bound on each variance (inf: not identifiable):
['c'] 1.25
['k'] 0.25
Drawing the graph in the terminal#
print_graph_diagram() draws a graph’s structure – what to_mermaid()
exports – as boxes and arrows in the terminal, with the optional
termaid package
(pip install "maddening[terminal]"). Each node is a box reading
name :: Type, a coupling group is a titled frame, edges carry their
field→input labels, and a flux edge or external input is drawn dotted.
With rich installed (the same extra) a terminal gets colour:
theme= picks one of termaid’s themes ("default", "terra", "neon",
"mono", "amber", "phosphor", "gruvbox", "monokai", "dracula",
"nord", "solarized"), and a file or pipe gets the same drawing as
plain text. direction="TB" stacks a wide graph vertically, and
use_ascii=True draws without Unicode box characters. The diagram is
for reading: the few characters termaid cannot carry in a label (a
double quote, a backtick, %%, :::) are drawn as look-alikes, while
to_mermaid() and format_graph() keep every name exact.
from maddening import GraphManager
from maddening.nodes import HeatNode
rods = GraphManager()
for name in ("rod_a", "rod_b"):
rods.add_node(HeatNode(name, 1e-4, n_cells=8, thermal_diffusivity=0.1,
initial_temperature=300.0))
rods.add_edge("rod_a", "rod_b", "temperature", "left_temperature",
transform="extract_last")
rods.add_edge("rod_b", "rod_a", "temperature", "right_temperature",
transform="extract_first")
rods.add_coupling_group(["rod_a", "rod_b"])
rods.add_external_input("rod_a", "left_temperature")
rods.print_graph_diagram()
prints
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ coupling group rod_a+rod_b │
│ │
│ │
/────────────────────/ │ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ │ │ │ │ │ │
│ external: │ │ │ rod_a :: HeatNode │ temperature→left_temperature (extract_last) │ rod_b :: HeatNode │ │
│ left_temperature ├┄┄┄┼►│ ├─────────────────────────────────────────────►│ │ │
│ │ │ │ │ │ │ │
/────────────────────/ │ └─────────────────────┘ └──────────┬──────────┘ │
│ ▲temperature→right_temperature (extract_first) │ │
└────────────┴────────────────────────────────────────────────────────────────────┴────────────┘
Like the rest of this page it is read-only and compiles nothing; without
termaid it raises an ImportError naming the extra.