WaveletAdaptiveNode: the worked AdaptiveNode subclass#
maddening.nodes.adaptive.WaveletAdaptiveNode is the first
AdaptiveNode subclass with a real basis, and it is
written to be read as the worked answer to the base class’s author-facing
contract. This page is about using it and about what porting it onto the
0.4.0 API taught us; the method is in the
algorithm guide.
Stability.
EXPERIMENTAL, one level below theEVOLVINGbase class. The wavelet engine undermaddening.nodes.adaptive.waveletsis importable and taggedEXPERIMENTALfunction by function; the node is the surface the freeze round covers.
Quick start#
import jax
from maddening.nodes.adaptive import WaveletAdaptiveNode
node = WaveletAdaptiveNode("wavelet", 1.0, n_levels=6, theta=0.42)
state = node.initial_state() # cold start + the gradient-capture check
state = node.update(state, {}, 1.0) # one CDD selection + frozen solve
u = node.field(state) # u on the grid, shape (n_max,)
J = node.objective(state, {}) # u at the sensor
# theta and sigma are graph-parameter leaves; the gradient is the frozen-set adjoint
dJ = jax.grad(lambda th: node.objective(
node.update(state, {}, 1.0, params={"theta": th}), {}))(0.42)
dim is 1, 2 or 3; the grid has n_coarse * 2**n_levels points per axis
(periodic) or (n_coarse + 1) * 2**n_levels - 1 interior points
(boundary="dirichlet"); n_max is that to the power dim, and the default
budget is k = min(n_max, max(seed, 8, n_max // 16)), where seed is the
CDD seed – every level-0 function, the coarse block plus the first detail
band, (2 * n_coarse) ** dim periodic or (2 * n_coarse + 1) ** dim
Dirichlet. A k below the seed is refused with both numbers in the message.
k = n_max turns adaptivity off.
Inside a graph the node is an edge source: it declares no boundary inputs and update reads neither dt nor boundary_inputs, because the
problem is steady. Edges out of c work as for any node; to expose the
field rather than the coefficients, map through node.field in the consumer
or add a transform.
The seven contract items, answered#
The 0.4.0 adaptive-node audit listed seven things a second subclass author
gets wrong that the base class does not stop. How the wavelet node handles
each, and the test that pins it (tests/nodes/adaptive/test_wavelet_node.py
unless stated):
# |
Item |
How the wavelet node handles it |
Pinned by |
|---|---|---|---|
1 |
Empty active set at cold start |
CDD is seeded with level 0 (the coarse block plus the first detail band, which the budget is validated to hold) and only grows; the selection reads the parameters, never |
|
2 |
Non-boolean mask |
The mask is built from boolean operations on a boolean seed ( |
|
3 |
Basis array baked from a trainable parameter |
|
|
4 |
|
Correct for a steady elliptic solve: two different |
|
5 |
|
The state is exactly |
|
6 |
Hooks receive non-numeric settings in |
The hooks index the named leaves ( |
|
7 |
|
It is not, for the gathered solve: the buffer holds exactly |
|
Declared order, measured#
The full-basis solve is the second-order finite-difference solution in a
different basis, so the node declares DiscretizationOrder(spatial=2.0) and
tests/verification/test_wavelet_mms_order.py measures it with
maddening.testing.mms: a manufactured field, its source derived by autodiff,
the level refined at k = n_max so only the discretisation error remains.
Periodic 1-D (16 – 256 points) measures 2.000; the Dirichlet basis
(23 – 191 interior points) measures 2.000 and 2-D (\(8^2\) – \(32^2\)) 2.022
(MADD-VER-014). The manufactured solutions are checked for the properties
that let them see a broken scheme — a non-vanishing fourth derivative, no
symmetry about the centre, and for Dirichlet a non-zero second derivative at
both walls (a profile flat at the walls let a second-order closure measure
4.08 on another node this release) — and a source with the wrong sign on the
mass term is shown to fail the gate. Adaptive truncation is a separate claim
(MADD-VER-015): within 1e-2 of the full solve at k = n_max / 16, same
sign across the source sweep, no rate in k.
What the merged-tree audit changed (1.0.1)#
The pre-release audit found that the CDD seed is every level-0 function
– the coarse block plus the first detail band – while k was validated
against n_coarse ** dim and the default k sized from that. On 16 default
configurations the seed exceeded k, gather_solve kept the first k
active functions and dropped the rest, and nothing objected: at dim=2, n_levels=2 the mask had 16 functions in a buffer of 8, c was 4% off the
masked solve and dJ/dtheta was five times too small, while the capture
ratio read 0.97 because it measured through the same truncated solve. Now:
the seed is counted from the level labels it is built from,
k >= seedis validated (message names both numbers and the remedy), the default ismax(seed, 8, n_max // 16), and the count is re-checked against the assembled seed;an oversized concrete mask is refused on every eager path (
_refuse_oversized_mask), andgather_solvepoisons an oversized block with NaN under a trace – the truncation cannot happen silently;assemble_operatormeasuresmax|A - A^T| / max|A|before symmetrising and refuses aboveSYMMETRY_TOL = 1e-12(correct assembly ~1e-16, a one-sided stencil 1.07), so the symmetrisation can no longer turn a first-order stencil into a consistent second-order one that passes the order gate;a periodic sensor at
1.0snaps to grid point 0, its periodic image, rather than to(side - 1) / side.
Construction inside a trace#
Every constant of the node is built on the host from static settings
(assemble_operator runs under jax.ensure_compile_time_eval; levels and
diagonal are NumPy arrays), so a function that builds a fresh node – or a
fresh GraphManager holding one – per call traces under jax.jit. This is
what a residual_fn for sysid.fim that constructs its graph inside the
call needs; it used to fail in the assembly with a
TracerArrayConversionError whose traceback pointed at sysid.py. Build
with blindness_gate=False inside a trace: the gate’s diagnostics return
host floats and cannot run traced. Pinned by
test_the_node_can_be_constructed_inside_a_jit_trace and
test_a_fresh_graph_holding_the_node_can_be_built_inside_the_fim_trace.
Observing the selection: selection_diagnostics#
node.selection_diagnostics()
# {'active': 8, 'k': 8, 'outer_iterations': 4, 'max_outer': 30,
# 'budget_reached': True, 'resolved': False}
WaveletAdaptiveNode("w", 1.0, n_levels=6, k=64, blindness_gate=False).selection_diagnostics()
# float64: {'active': 54, 'k': 64, 'outer_iterations': 30, 'max_outer': 30,
# 'budget_reached': False, 'resolved': False}
The CDD loop has three exits: the budget, the 30-iteration bound, and a
step that finds nothing above the rounding floor to mark (resolved). For
k above about n_max / 2 the bound is the one taken in float64, and in
float32 the floor usually comes first: each Dörfler step
marks a fixed fraction of the remaining residual, so the steps shrink once
the source is resolved. Measured on 128 points at the default source:
k = 64 and k = 96 both stop at |mask| = 54 (200 iterations reach 64;
the sensor-reading error at 54 is 3e-11, so the objective does not care).
The bound is deliberately not raised – every iteration is a k x k solve
on every update. If reaching the budget matters, read budget_reached; the
engine-level cdd_select_with_iterations returns the same count.
What the phase-3 audit changed (1.1.0)#
Conditioning is checked. A small positive
massused to give a finite, wrong reading in the default float32 (128 points:mass=1e-6readJ = 6.7e5against an FFT reference of1.77e5,mass=1e-8-2.9e16), silently, or with a warning blaming the active-set budget atk = n_max. The constructor now bounds the solve’s relative error bycondition_number * eps(dtype) + physical_condition_number * eps(float64)(node.solve_error_bound()) and refuses aboveCONDITION_LIMIT = 1e-3, saying which term is too large, whether float64 would carry it, and the smallest mass that would. In float32 that is a periodic mass below about2e-3; in float64 about1e-8to6e-8, where the float64 assembly of the Galerkin product becomes the limit. The budget warning the old behaviour produced atk = n_max(256 points,mass=1e-6) cannot arise: that configuration is refused, and just inside the limit the capture ratio at the full budget is 1.The masked-CG path is bounded by what it is, and says when it fails.
kappa * epsmodels a direct solve;frozen_solver="cg"stops at a relative residual ofrtol(1e-6 float32, 1e-10 float64), so its bound iskappa * max(eps, rtol)– float32 cg refuses a periodic mass below about2e-2, and the refusal names the gathered solve’s own bound. That bounds a CG solve that converges, not whether it does: at conditionings it accepts, lineax’s CG stagnates or breaks down (float32 at 256 points frommass=0.3; float64 at1e-5), and more steps do not help, so akappa-versus-max_stepsguard would not predict it. An eager solve that does not converge is re-raised as aValueErrornamingkappa, the tolerance, the budget and the fixes; under a trace the error still comes from lineax when the step runs (the node cannot catch a runtime error inside XLA without lineax’sthrow=False, whichift_linear_solvedoes not expose – one of its open API questions). The bound is also conservative for the"level"and"dk"preconditioners (periodicmass=3e-3in float32 refused while the measured error is 8e-5 to 1.5e-4); documented, not loosened.The selection no longer depends on rounding. The source is centred on every axis but the first, so residuals of mirror-image functions tie to rounding and the last bits decided which one survived the cap: eager and compiled evaluations, and a Python float and its float32-array spelling, selected different sets at the same parameters, and a 1e-6 sweep of
thetaswitched set on 15 of 39 steps (a finite difference across one read -4.57 againstgrad-0.0101). The marking step now never marks a residual at or belowrounding_floorand breaks ties within it by basis index, and parameter leaves are cast to the node’s dtype before the source reads them. The diagnostics therefore describe the solve the graph runs. The set still changes at genuine crossings – isolated parameter values – and a gradient step across one still misses a jump.The periodic source is periodised, so a source near the seam no longer loses the part that should wrap round (the same problem shifted across the seam read 28% low, with
dJ/dthetaof the wrong sign).Structural counts are validated, not truncated (
n_levels=6.9used to build 64 points), andupdate/selection_diagnosticsrefuse an unknown parameter key ({"thetta": 0.9}used to return the constructor-theta answer).
Using a different source: source_field#
Override source_field(params) to solve for another forcing. It must read
every parameter it depends on from params (never self.params), return
the forcing sampled on grid_coordinates() flattened row-major, and stay
traceable. The leaves it receives are already cast to the node’s dtype. On
a periodic domain it should be periodic itself: the default one sums the
Gaussian’s periodic images. The MMS study is exactly this: a subclass returning the
manufactured source, with blindness_gate=False because its source ignores
theta and sigma and the base class would (correctly) warn that their
full-basis gradient is bitwise zero.
Performance notes#
Compile time was the whole cost. The 30-step CDD unroll the node was first written with cost 7–10 s of XLA compile in every outer
jit(nestedjitis inlined, soupdate,gradand the graph step each paid it again); a barevmapfor the synthesis matrix paid ~60 eager primitive compiles per size; and the base class calls the hooks eagerly, where one gathered solve cost 4.9 s and one frozen gradient 6.3 s on first use. Now: CDD is alax.while_loop, assembly is NumPy, and the hook bodies are module-level jitted kernels taking the constant arrays as arguments, so every instance of one size shares the cache. Measured on the shared dev box: ~10 s for the first node of a new size (all first-use compiles), ~1 s for the next instance,updatejit ≈ 1.2 s,gradjit ≈ 1.4 s.Each
updateruns up to 30 CDD iterations, each with onek × ksolve and one sparse matvec, then one gathered solve. At the default budget the loop stops on the budget in a few iterations; forkabove aboutn_max / 2it runs all 30 (see Observing the selection).The dense
n_max × n_maxoperator is a constant of the step. MMS order is measured to 256 / \(32^2\); construction, the adaptive solve and the frozen gradient are tested at \(64^2\) and \(16^3\) (4096 functions,@slow, 10 – 15 s each on a loaded box, ~7 s of it the assembly).
What porting it onto the 0.4.0 API found#
These are findings about AdaptiveNode, not about the wavelet node; the
freeze is the moment they are cheap to act on.
Overriding
compute_full_basis_gradientmeans reimplementing private plumbing. The hook must return a dict overparams_pytree()leaves with non-trainable ones zeroed, which the subclass can only produce by callingself._pytreeandself._trainable. A public helper taking a “solve on this mask” callable would let a subclass supply just the solve.Tuple parameters are trainable leaves.
params_pytree()turns a tuple of floats into an array leaf, so a structural location likesensormust be declaredtrainable=Falseand declared instatic_data_deps, or it is a silently dead gradient. Nothing in the base class says so.The hooks are called eagerly by the diagnostics, and an eager JAX function is compiled primitive by primitive. Any subclass with a non-trivial solve will find its cold start dominated by compile time until it jits its own hook bodies; the base class could jit the objective gradient it takes.
gather_solve-style fixed-buffer solves are incompatible with the base default full-basis gradient (item 7 above): the contract “an all-true mask is a valid solve” should be stated as a requirement of the default hook, with the override as the documented alternative.dt/boundary_inputsnever reach the hooks. Fine for this steady node; a time-dependent wavelet collocation node cannot be written without overridingupdatewholesale. Open question 2 of the base-class guide, now with a concrete customer.dtype policy. The strict xfail in
tests/property/test_adaptive_invariants.pyattributes therun_scanfailure underjax_enable_x64toAdaptiveNodebeing the only node whose state dtype follows the flag. Measured while porting: build the wavelet node withdtype=jnp.float32explicitly (socis float32) and the graph still fails, oncarry['ball']— and a plainBallNodeorSpringDamperNodealone fails the same way on this tree, becauseparams_pytree()injects float64 leaves under x64 and the node’s arithmetic promotes its float32 state (step()hides it by storing the promoted state). So of the three candidate policies, “AdaptiveNodestops following the flag” does not fix the graph on its own; “the graph coerces each update output back to its carry dtype” is the one that covers every node. Pinned bytest_under_x64_the_mixed_graph_fails_on_the_downstream_carry_not_on_the_wavelet_one. For the wavelet node itself float32 is a fine working precision: forward field 1.1e-6 anddJ/dtheta1.8e-6 relative to float64 (jaxlib 0.11.0). Note the consequence for any float32-pinned subclass: it must cast every leaf it reads, which the wavelet node does in_rhs.
Out of scope for this port#
The lid-driven-cavity fluid benchmark and the biharmonic (stream-function) operator: fluid numerics, not the node port, and the cavity run takes minutes rather than seconds.
Variable coefficients \(-\nabla\cdot(a\nabla u)\): the differentiable assembly exists in the source branch but no node surface exposes it.
A matrix-free assembly for grids beyond the validated range.