--- bibliography: ../../bibliography.bib --- # WaveletAdaptiveNode **Module**: `maddening.nodes.adaptive.wavelet` **Stability**: {term}`experimental ` **Algorithm ID**: `MADD-NODE-010` **Version**: 1.1.0 ## Summary `WaveletAdaptiveNode` is the first concrete [`AdaptiveNode`](adaptive_node.md) subclass with a real basis. It solves the steady elliptic problem $(-\Delta + m)\,u = f$ on the unit cube in one, two or three dimensions, periodic or with homogeneous Dirichlet walls, in an interpolating (Deslauriers–Dubuc) wavelet basis [@DeslauriersDubuc1989], selects the active set by Cohen–Dahmen–DeVore bulk chasing [@CohenDahmenDeVore2001; @Doerfler1996] up to a budget $k$, solves on that set through a gathered dense block, and returns the sensor reading $J = u(x_s)$ with the frozen-active-set adjoint the base class provides: plain reverse mode through the gathered solve, or the implicit-function rule of [@Blondel2022] on the masked-CG path. ## Governing Equations $$ (-\Delta + m)\, u(x) = f(x;\, \theta, \sigma), \qquad x \in [0, 1)^d,\; d \in \{1, 2, 3\}, $$ with $m > 0$ and the isotropic Gaussian source $$ f(x;\, \theta, \sigma) = \exp\!\left(-\frac{(x_1 - \theta)^2 + \sum_{i \ge 2} (x_i - \tfrac12)^2}{\sigma^2}\right), $$ periodic on every axis or $u = 0$ on every wall. On a periodic domain the source is **periodised** -- summed over its images, $f_{\text{per}}(x) = \prod_i \sum_{n=-2}^{2} \exp(-(d_i + n)^2/\sigma^2)$ with $d_i$ the distance to the centre on axis $i$ wrapped into $[-\tfrac12, \tfrac12)$ -- so the problem is translation-invariant on the torus like the operator; the omitted images contribute less than $2\,e^{-6.25/\sigma^2}$ of the peak per axis ($< 10^{-16}$ for $\sigma \le 0.41$). The objective is the point reading $J(\theta, \sigma) = u(x_s)$ at the grid point nearest the sensor $x_s$. In the basis $u = \sum_j c_j \psi_j$ this is $$ A\, c = b(\theta, \sigma), \qquad A = W_n^{\top} A_{\text{phys}} W_n, \qquad b = h^{d}\, W_n^{\top} f, \qquad J = W_n[s, :]\, c, $$ and, restricted to an active set $M$ with $|M| \le k$ chosen by $$ M = \mathrm{CDD}\big(D^{-1} A D^{-1},\; D^{-1} b;\; M_0 = \text{coarse level},\; k\big), $$ the frozen problem is $A_M c_M = b_M$, $c_j = 0$ for $j \notin M$, with the adjoint of the base class ([`adaptive_node.md`](adaptive_node.md) *Governing Equations*): exact within a region of constant $M$, blind to the region's boundary (`MADD-ANO-003`). ## Discretization - **Physical operator.** Second-order central differences for $-\Delta$ with lumped mass $h^d$ on a uniform dyadic grid of $n_c\,2^{J}$ points per axis (periodic) or $(n_c + 1)\,2^{J} - 1$ interior points (Dirichlet), $h = 1/n$ or $1/(n+1)$. - **Basis.** The Deslauriers–Dubuc interpolating wavelet basis of order 4 on the isotropic Mallat multiresolution (one level per refinement, $2^d - 1$ detail subbands per level), built by a lifting scheme (predict the midpoint from the four nearest coarse samples, add the detail), matrix-free in {term}`JAX`. The Dirichlet basis pins the wall values to zero and shrinks the {term}`stencil ` to linear at the walls; it is built dense at construction and the multi-D Dirichlet basis is the tensor product of the 1-D one. Columns are $L^2$-normalised ($W_n$). - **Exact change of basis.** $A = W_n^{\top} A_{\text{phys}} W_n$ is assembled once, in NumPy, *checked* for symmetry and only then symmetrised: the correct product measures $\max|A - A^{\top}| / \max|A| \approx 10^{-16}$ (15 configurations, 1-D to 3-D, both boundaries), a one-sided $[-1, 2, -1]/h$ stencil measures $1.07$, and `SYMMETRY_TOL = 10^{-12}` refuses anything above it. Symmetrising unconditionally would have turned that first-order stencil into a consistent second-order one and passed the order gate against the defect (measured 2.04). Because it is the finite-difference operator in another basis, the **full-basis** solve reproduces the finite-difference solution to round-off and the **order of accuracy is the stencil's: 2 in $h$**, for both boundary types. Declared as `DiscretizationOrder(spatial=2.0, temporal=None)` and measured by MMS (`MADD-VER-014`). - **Preconditioning.** Symmetric diagonal scaling $\hat A = D^{-1} A D^{-1}$ with $D$ the hybrid-Jacobi scaling (per-entry $\sqrt{A_{jj}}$ on the coarse level, the level mean of it on finer levels), which matches full Jacobi to four figures. $\kappa(\hat A) \approx 20$ (1-D, 256 points), $\approx 38$ (2-D, $32^2$), $\approx 3.8$ (1-D Dirichlet). The Dahmen–Kunoth $2^{t\,\text{level}}$ scaling [@DahmenKunoth1992] is available and weaker here. - **Selection.** CDD: start from the coarse level, estimate the residual $\hat r = \hat b - \hat A \hat c$, mark the smallest set of inactive functions carrying $\theta_D^2 = 0.25$ of the squared residual (Dörfler marking), capped at the room left under $k$, re-solve, repeat until $|M| \ge k$, 30 iterations, or a step with nothing left to mark, as a `lax.while_loop`. The marking reads no difference below the **rounding floor** $\tau = 16\,\varepsilon\,(\max|\hat b| + \max|\hat c|)$ (`rounding_floor`; the residual's rounding error measured at most $1.05\,\varepsilon(\max|\hat b| + \max|\hat c|)$): a residual at or below $\tau$ is never marked, and residual magnitudes within $\tau$ of the cutoff (the $n$-th largest, $n$ the Dörfler count capped at the room left) are **tied and taken in ascending basis index order**. Without it the mirror symmetry of the source (centred on every axis but the first) made residual pairs equal to rounding, and the last bits of $\hat b$ decided which member survived the cap -- so the eager diagnostics, a jitted `update` and the compiled graph selected different sets at the same parameters (2-D Dirichlet: 2 functions, $J$ 4.7e-3 apart), a Python float and its float32-array spelling did too, and a 1e-6 sweep of $\theta$ on the 2-D periodic problem switched set on 15 of 39 steps. Now the set changes only where a magnitude gap crosses $\tau$ or the Dörfler bulk falls on a cumulative sum -- isolated parameter values (no switch in the same sweep) -- and every parameter leaf is cast to the {term}`node `'s dtype before the source reads it, so spellings give the same bits. The seed is *every level-0 function* -- the coarse block plus the first detail band, $(2 n_c)^d$ periodic or $(2 n_c + 1)^d$ Dirichlet -- and $k$ is validated to hold it. The selection is a function of the parameters alone (never of the previous state), so it never empties, never exceeds $k$, and does not change at fixed parameters. For $k$ above about $n_{\max}/2$ the iteration bound is the exit in float64, not the budget: on 128 points $k = 64$ and $k = 96$ both stop at $|M| = 54$ (sensor-reading error $3 \times 10^{-11}$; 200 iterations reach 64), because each Dörfler step marks a fixed fraction of the *remaining* residual. In float32 the rounding floor usually ends it first (the reading moved by at most $1.6 \times 10^{-7}$ against marking to the bound). The mask is valid at every exit; `selection_diagnostics()` reports `outer_iterations`, `budget_reached` and `resolved`. - **Frozen solve.** The $k$ active functions gathered into a dense $k \times k$ block and solved directly (`frozen_solver="gather"`, $O(k^3)$), or the masked full-size operator (identity off the mask) handed to `ift_linear_solve` with CG (`frozen_solver="cg"`). On the 128-point basis at $k = 8$ the two agree to $3 \times 10^{-17}$ in $\max|\Delta c|$; the property claimed and pinned is agreement to the CG tolerance, $10^{-10}$. Differentiation differs between them: the gathered path is plain reverse mode through `jnp.linalg.solve`; only the CG path uses the implicit-function rule of `ift_linear_solve` [@Blondel2022]. A mask with more than $k$ functions cannot be solved in the gathered block: a concrete one is refused with a message, a traced one poisons the block with NaN rather than silently dropping the excess. - **Full-basis gradient.** A dense solve on $A$, overriding the base default: the gathered solve holds exactly $k$ functions and an all-true mask would be silently truncated. - **Conditioning guard.** The constructor bounds the relative solve error by $\kappa(\hat A)\,\varepsilon_{\text{dtype}} + \kappa_h\,\varepsilon_{64}$ and refuses a configuration above `CONDITION_LIMIT` $= 10^{-3}$, naming both terms, the dtype that would carry it and the smallest mass that would. $\kappa(\hat A)$ is `condition_estimate`: the largest Ritz value of 64 Lanczos steps over the smaller of the smallest Ritz value and the closed-form Rayleigh quotient of the constant function, $m\,h^d n / \lVert D W_n^{-1}\mathbf 1\rVert^2$ (periodic) -- within 0.03% below `eigvalsh` over 70 configurations, 0.6 s at 4096 functions. $\kappa_h = (\lambda_{\max} + m)/(\lambda_{\min} + m)$ of the grid operator is closed form (`physical_condition_number`): periodic $\lambda_{\min} = 0$, $\lambda_{\max} = 4d/h^2$; Dirichlet $\lambda = d\,(2 \mp 2\cos \pi h)/h^2$. The first term is the node's own solve (float32 sensor-reading error measured at 0.002 – 0.58 of it); the second the float64 formation of $W_n^{\top} A_{\text{phys}} W_n$, in which the Laplacian annihilates the constant only through cancellation (float64 error measured at 0.005 – 0.25 of it). The periodic operator's smallest eigenvalue is $m$, so both grow like $1/m$: float32 refuses a periodic $m$ below about $2 \times 10^{-3}$ (1-D) to $4 \times 10^{-3}$ (3-D, order 6), float64 below about $10^{-8}$ (128 points) to $6 \times 10^{-8}$ (256 points). Unchecked, float32 on 128 points read $J = 6.7 \times 10^5$ against $1.77 \times 10^5$ at $m = 10^{-6}$ and $-2.9 \times 10^{16}$ at $m = 10^{-8}$, silently. *What the bound covers.* $\kappa\varepsilon$ models a direct solve -- the gathered block and the dense full-basis gradient. The masked CG (`frozen_solver="cg"`) stops at a relative residual of `rtol` ($10^{-6}$ in float32, $10^{-10}$ in float64), which $\kappa$ amplifies in the same way, so on that path the first term is $\kappa(\hat A)\max(\varepsilon, \texttt{rtol})$: float32 refuses a periodic $m$ below about $2 \times 10^{-2}$, float64 below about $2 \times 10^{-6}$. That bounds the accuracy of a CG solve that *converges*; it does not say whether CG converges. Measured on the 1-D periodic node at the full budget (jaxlib 0.11.0; lineax 0.0.7 and 0.1.1 alike), CG stagnates or breaks down below its tolerance at conditionings the bound accepts -- float32 at 256 points already at $m = 0.3$ ($\kappa = 65$), at 64 or 128 points at $3 \times 10^{-2}$; float64 at 128 or 256 points at $10^{-5}$ -- and 4x or 16x the step budget does not help, so no condition-number-versus-`max_steps` bound predicts it. The failure is loud, never a wrong number: on an eager call (the cold start in `initial_state`, a diagnostic) the node re-raises it as a `ValueError` naming $\kappa$, the tolerance, the budget and the fixes (`frozen_solver="gather"`, float64, a larger mass); under a trace it surfaces from lineax when the step executes. The bound is also conservative for the `"level"` and `"dk"` preconditioners: periodic $m = 3 \times 10^{-3}$ in float32 is refused under both (bound $1.4$ – $1.9 \times 10^{-3}$) while the full-basis solve is measured $1.5 \times 10^{-4}$ (`"level"`) and $8 \times 10^{-5}$ (`"dk"`) off, about 12x inside the limit; the default `"hybrid"` accepts it (bound $7.7 \times 10^{-4}$). The limit is not loosened per preconditioner on three measured points. ## Implementation Mapping | Equation Term | Implementation | Notes | |---------------|---------------|-------| | Basis synthesis $u = W_n c$ | `maddening.nodes.adaptive.wavelets.transform.synthesis`, `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.field` | Lifting scheme, matrix-free; `field` applies the normalised dense $W_n$ | | Analysis $c = W^{-1} u$ | `maddening.nodes.adaptive.wavelets.transform.analysis` | Exact inverse of the lifting scheme | | Dirichlet basis | `maddening.nodes.adaptive.wavelets.dirichlet.synthesis_matrix_dirichlet` | Dense, walls pinned to zero, tensor product in multi-D | | Operator assembly $A = W_n^{\top} A_{\text{phys}} W_n$ | `maddening.nodes.adaptive.wavelets.operator.assemble_operator` | NumPy, once per node; returns the dense $A$, a sparse copy, $W_n$, levels | | Diagonal scaling $D$ | `maddening.nodes.adaptive.wavelets.precond.diagonal_scaling` | Hybrid Jacobi by default; Jacobi, level-mean and Dahmen–Kunoth selectable | | Source $f(x;\theta,\sigma)$ | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.source_field` | Reads `theta` and `sigma` from the injected `params`; periodised on a periodic domain; the override point for a manufactured source | | Right-hand side $b = h^d W_n^{\top} f$ | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode._rhs` | Recomputed on every call, so the tangent flows; every parameter leaf cast to the node's dtype first | | Active set $M$ by CDD | `maddening.nodes.adaptive.wavelets.cdd.cdd_select`, `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.compute_active_set` | `while_loop` over solve / estimate / mark / refine; input under `stop_gradient`; the coarse level is the seed | | Rounding floor $\tau$ and the index-order tie band | `maddening.nodes.adaptive.wavelets.cdd.rounding_floor` | $16\,\varepsilon(\max|\hat b| + \max|\hat c|)$; applied in the marking step `_doerfler_grow` | | $\kappa(\hat A)$ | `maddening.nodes.adaptive.wavelets.operator.condition_estimate` | Lanczos (64 steps, full reorthogonalisation) plus the periodic constant-mode Rayleigh quotient; computed by `assemble_operator(preconditioner=...)` on the float64 operator | | $\kappa_h$ of the grid operator | `maddening.nodes.adaptive.wavelets.operator.physical_condition_number` | Closed form of the central-difference spectrum | | Solve error bound vs `CONDITION_LIMIT` | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.solve_error_bound` | $\kappa(\hat A)\varepsilon_{\text{dtype}} + \kappa_h\varepsilon_{64}$, with $\max(\varepsilon, \texttt{rtol})$ on the masked-CG path; the constructor refuses above $10^{-3}$ | | Frozen solve $A_M c_M = b_M$ (gathered) | `maddening.nodes.adaptive.wavelets.operator.gather_solve`, `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.solve_frozen` | Dense $k \times k$ block; JAX primitive `jnp.linalg.solve`; requires $\lvert M \rvert \le k$ -- an oversized concrete mask is refused, a traced one is NaN-poisoned | | Selection diagnostics (iterations, budget reached) | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.selection_diagnostics`, `maddening.nodes.adaptive.wavelets.cdd.cdd_select_with_iterations` | Host-side; `outer_iterations`, `max_outer`, `active`, `k`, `budget_reached`, `resolved`; the same set the graph selects | | Frozen solve (masked CG) | `maddening.nodes.adaptive.wavelets.operator.make_masked_operator`, `maddening.core.solver_utils.ift_linear_solve` | Identity off the mask; `solver="cg"` | | Non-converging masked CG named | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode._cg_solve`, `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode._cg_failure_message` | Eager calls only: lineax's error re-raised as a `ValueError` with $\kappa$, the tolerance and the fixes | | $c_j = 0$ for $j \notin M$ | `maddening.nodes.adaptive.base.AdaptiveNode.update` | Inherited: the base class zeroes off the mask after every solve | | Sensor functional $J = W_n[s,:]\, c$ | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.objective` | Nearest grid point to `sensor` | | Full-basis gradient $\nabla J_{\text{full}}$ | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.compute_full_basis_gradient` | Dense solve on $A$; overrides the base default | | Baked constants and their provenance | `maddening.nodes.adaptive.wavelet.WaveletAdaptiveNode.static_data_deps` | `scaling` from `mass`, `sensor_row` from `sensor`; `compile()` refuses to train either | ## Assumptions and Simplifications 1. Unit cube, uniform dyadic grid, constant coefficient $m > 0$: the operator is assembled once from the structural settings and `mass`, so `mass` is `ParamSpec(trainable=False)`. Variable coefficients are not supported. 2. The source is the isotropic Gaussian above (periodised on a periodic domain) unless `source_field` is overridden; `theta` and `sigma` are the trainable leaves. 3. $\text{seed} \le k \le n_{\max}$, validated at construction, where the seed is every level-0 function -- the coarse block plus the first detail band, $(2 n_c)^d$ periodic or $(2 n_c + 1)^d$ Dirichlet, counted from the assembled basis -- so the CDD seed fits in the gathered buffer and the set never exceeds it. The default $k = \min(n_{\max}, \max(\text{seed}, 8, n_{\max}/16))$ always satisfies the bound. $k = n_{\max}$ turns adaptivity off. 4. The base-class assumptions: the returned gradient is exact within a region of constant $M$ and ignores the set's dependence on $\theta$ (`MADD-ANO-003`); the objective is a scalar function of $c$. 5. The sensor is read at the nearest grid point, not interpolated. 6. The solve's relative error bound $\kappa(\hat A)\varepsilon_{\text{dtype}} + \kappa_h\varepsilon_{64}$ ($\max(\varepsilon, \texttt{rtol})$ in place of $\varepsilon$ on the masked-CG path) is at most `CONDITION_LIMIT` $= 10^{-3}$, validated at construction. It bounds a solve that completes, not whether the masked CG converges (see *Discretization*). 7. Structural counts (`dim`, `n_levels`, `n_coarse`, `order`, `k`) are whole numbers -- an integral float from a JSON round trip is accepted, `6.9` or `True` is refused -- and `update` / `selection_diagnostics` refuse a parameter key the node does not have. ## Validated Physical Regimes | Parameter | Verified Range | Notes | |-----------|---------------|-------| | Grid, order of accuracy | 16 – 256 points (1-D), $8^2$ – $32^2$ (2-D) | MMS order 2.000 on the 1-D periodic ladder 16/32/64/128/256; 2.000 on the Dirichlet ladder 23 – 191 and 2.022 on the 2-D periodic ladder $8^2$ – $32^2$ (`tests/verification/test_wavelet_mms_order.py`). No 3-D order ladder | | Grid, adaptive solve and frozen gradient | up to 256 (1-D), $64^2$ (2-D), $16^3$ (3-D); Dirichlet $23^2$ and $7^3$ | At every size: the seed fits the default budget, the gathered solve equals the masked dense solve to $10^{-12}$, `jax.grad` matches central differences with the set held fixed to $10^{-6}$, capture ratio in $(0.9, 1.1)$. $64^2$ and $16^3$ (4096 functions, budget 256) are `@slow` tests, 10 – 15 s each on a loaded 24-core box (`test_the_largest_sizes_the_metadata_claims_construct_select_solve_and_differentiate`) | | Default budget vs seed | all default `(boundary, dim, n_levels <= 2, n_coarse <= 3)` up to 529 functions | The seed (level 0) is inside `k` and every active coefficient is solved -- the 13 cheapest of the 16 default configurations on which it used to be dropped (`test_the_default_budget_holds_the_whole_level_zero_seed_so_no_coefficient_is_dropped`) | | Budget $k$ | $n_{\max}/16$ (default) – $n_{\max}$ | At $k = n_{\max}/16$ on 128 points the sensor reading is within $4.5 \times 10^{-4}$ (relative, float64) of the full-basis one over $\theta \in \{0.04, 0.30, 0.42, 0.50, 0.92\}$ and never changes sign (`MADD-VER-015`). The error is **not** monotone in $k$ and no rate in $k$ is claimed | | Gradient-capture ratio | 0.99 – 1.01 | $\theta = 0.42$, 1-D 128 points, 2-D $16^2$, 3-D $8^3$, Dirichlet 95 points: the local basis reproduces the full-basis gradient at the default budget, so the cold-start check passes silently | | `jax.grad` vs central differences | $1.5 \times 10^{-9}$ (θ), $4 \times 10^{-11}$ (σ) relative | Active set asserted unchanged across the step; through `jit`, `scan` and the compiled {term}`graph step ` | | Order of the basis | 4 | 2 and 6 construct and round-trip; the operator's order stays 2 regardless | ## Known Limitations and Failure Modes 1. **Jump discontinuities at active-set switches** (`MADD-ANO-003`), inherited. At the default budget the ratio is ≈ 1, so the omitted first-order term is small, but a step across a switch still misses a jump. 2. **The adaptive solution is a truncation.** At a small $k$ the sensor reading can be off by more than the $10^{-2}$ measured at $n_{\max}/16$; the error is not monotone in $k$. 3. **`mass` and `sensor` are baked.** Changing either on a built node leaves the operator and the sensor row stale; rebuild the node. `compile()` refuses a graph that unfreezes them. 4. **Steady only.** `update` ignores `dt` and `boundary_inputs` because the base class's hooks do not receive them, so the node is an {term}`edge ` *source* and cannot time-step or consume an edge; a time-dependent wavelet node needs the freeze question in the developer guide answered. 5. **Dense operator.** $A$ is an $n_{\max} \times n_{\max}$ constant of the step; sizes beyond the validated range need a matrix-free assembly. 6. **Cost per update** is $O(30\,k^3 + 30\,\mathrm{nnz})$ for the selection plus one gathered solve, whether or not the budget is reached early. 7. **Singular at $m = 0$ and ill-conditioned like $1/m$ near it** (periodic): the constructor refuses a non-positive `mass`, and one whose solve error bound exceeds `CONDITION_LIMIT` -- in float32 a periodic $m$ below about $2 \times 10^{-3}$ (1-D) to $4 \times 10^{-3}$ (3-D, order 6); in float64 below about $10^{-8}$ to $6 \times 10^{-8}$, where the float64 assembly is the limit. Build in float64 for a smaller mass; below the float64 limit no dtype carries the operator. 8. **The iteration bound, not the budget, ends the selection for $k \gtrsim n_{\max}/2$** (in float64; in float32 the rounding floor usually does, `resolved`). Measured on 128 points: $k = 64$ and $k = 96$ both stop at $|M| = 54$ after 30 iterations (200 reach 64); the sensor error there is $3 \times 10^{-11}$, so the objective is unaffected, but `budget_reached` is `False`. The bound is deliberately not raised -- each iteration is a $k \times k$ solve on every update; if the budget matters, `selection_diagnostics()` says whether it was met. 9. **A mask larger than $k$ is not a valid input to the gathered solve.** The node never produces one (the seed is validated, the marking is capped); one supplied from outside is refused eagerly and NaN-poisoned under `jit`. `frozen_solver="cg"` accepts any mask. 10. **The masked CG may not converge where the bound accepts.** In float32 it stagnates at 256 points from $m = 0.3$, and at 64 or 128 points at $3 \times 10^{-2}$; in float64 at 128 or 256 points at $10^{-5}$ (full budget, periodic). More steps do not help. An eager solve raises a `ValueError` naming the conditioning and the fixes; under a trace the error comes from lineax. `frozen_solver="gather"` (the default) carries every configuration the bound accepts. 11. **The selection is deterministic under rounding but still discrete.** It changes where two residual magnitudes differ by about the rounding floor or the Dörfler bulk falls on a cumulative sum -- isolated parameter values, not every rounding perturbation -- and a gradient step across one misses a jump (`MADD-ANO-003`). Where ties are broken, the lower basis index is kept, so a mirror-symmetric problem can get a set that is not mirror-symmetric; it is the same set on every evaluation path. ## Stability Conditions The frozen solve is a linear solve on a symmetric positive-definite block; its conditioning is that of $\hat A_M$, bounded by $\kappa(\hat A)$ above (eigenvalue interlacing), and the constructor refuses an operator whose solve error bound exceeds `CONDITION_LIMIT` (see *Discretization*). There is no time-stepping and no stability limit. ## State Variables | Field | Shape | Units | Description | |-------|-------|-------|-------------| | `c` | `(n_max,)` | problem-defined | Wavelet coefficients of $u$ (physical, $u = W_n c$); zero off the active set | | `mask` | `(n_max,)` | — | Boolean active set the current `c` was solved on | No extra state: nothing can carry a stale per-mode value across a change of active set. ## Parameters | Parameter | Type | Default | Units | Description | |-----------|------|---------|-------|-------------| | `dim` | int | 1 | — | Spatial dimension (structural) | | `n_levels`, `n_coarse` | int | 6, 2 | — | Refinements and coarse points per axis (structural); together with `boundary` and `dim` they fix `n_max` | | `order` | int | 4 | — | Interpolating order, one of 2, 4, 6 (structural) | | `k` | int | `min(n_max, max(seed, 8, n_max // 16))` | — | Active-set budget (structural); `seed` is the level-0 count, $(2 n_c)^d$ periodic / $(2 n_c + 1)^d$ Dirichlet; `k < seed` is refused | | `theta` | float | 0.42 | — | Source centre on axis 0; **trainable**, bounds $(0, 1)$, logit | | `sigma` | float | 0.10 | — | Source width; **trainable**, positive, log | | `mass` | float | 1.0 | — | $m > 0$; `trainable=False`, baked into the operator; refused when the solve error bound exceeds `CONDITION_LIMIT` (float32: periodic $m \gtrsim 2 \times 10^{-3}$, $2 \times 10^{-2}$ with `frozen_solver="cg"`) | | `sensor` | tuple of float | `(0.30,)`, `(0.30, 0.40)`, `(0.30, 0.40, 0.60)` | — | Sensor location, snapped to the nearest grid point -- on the circle for a periodic axis (`1.0` is point 0), among the interior points for a Dirichlet axis; `trainable=False`, baked into the sensor row | | `preconditioner` | str | `"hybrid"` | — | `"hybrid"`, `"full"`, `"level"`, `"dk"` | | `boundary` | str | `"periodic"` | — | `"periodic"` or `"dirichlet"` | | `frozen_solver` | str | `"gather"` | — | `"gather"` or `"cg"` | | base-class settings | — | — | — | `blindness_gate`, `on_blind`, `dtype`, the diagnostic constants | Every entry is stored in `self.params`, so `cls(name=..., timestep=..., **node.params)` rebuilds the node; `n_max` is derived and not stored. ## Boundary Inputs | Field | Shape | Default | Description | |-------|-------|---------|-------------| | — | — | — | None. The node is an edge source only (see *Known Limitations* 4) | ## References - [@DeslauriersDubuc1989] Deslauriers, G., Dubuc, S. (1989). *Symmetric iterative interpolation processes*. Constructive Approximation, 5(1), 49–68. — The interpolating subdivision the basis is built from. - [@CohenDahmenDeVore2001] Cohen, A., Dahmen, W., DeVore, R. (2001). *Adaptive wavelet methods for elliptic operator equations: convergence rates*. Mathematics of Computation, 70(233), 27–75. — Bulk chasing on the residual; the selection rule. - [@Doerfler1996] Dörfler, W. (1996). *A convergent adaptive algorithm for Poisson's equation*. SIAM Journal on Numerical Analysis, 33(3), 1106–1124. — The bulk marking criterion inside the selection. - [@DahmenKunoth1992] Dahmen, W., Kunoth, A. (1992). *Multilevel preconditioning*. Numerische Mathematik, 63(1), 315–344. — The level-based diagonal scaling (`preconditioner="dk"`) and why a diagonal scaling suffices. - [@Blondel2022] Blondel, M. et al. (2022). *Efficient and modular implicit differentiation*. NeurIPS 35. — The implicit-function rule used by `ift_linear_solve` on the masked-CG path (`frozen_solver="cg"`); the default gathered path is plain reverse mode through `jnp.linalg.solve` and gives the same adjoint. - [@Roache2002] Roache, P. J. (2002). *Code verification by the method of manufactured solutions*. Journal of Fluids Engineering, 124(1), 4–10. — The order study in `MADD-VER-014`. ## Verification Evidence - `MADD-VER-014` (manufactured solution): observed spatial order over the finest pair of the 16/32/64/128/256 periodic 1-D ladder within $[-0.25, +1.0]$ of the declared 2 (measured 2.000); the Dirichlet (23 – 191) ladder measures 2.000 and the 2-D ($8^2$ – $32^2$) one 2.022 in the same test module, and a mis-specified source is shown to fail the gate. - `MADD-VER-015` (regression against the full-basis solve): adaptive sensor reading within $10^{-2}$ of the full one and of the same sign across the source sweep at $k = n_{\max}/16$; full budget equals the dense solve to $10^{-10}$. - Test files: `tests/nodes/adaptive/test_wavelet_node.py` (the seven-item author-facing contract, traceability, gradients with the active set held fixed, round trips, graph integration, the explicit-float32 dtype measurement, the seed-fits-the-budget pins in every dimension and both boundaries, refusal of an oversized set, construction inside a trace, the selection diagnostics, the sensor at the periodic seam, $64^2$ and $16^3$), `tests/nodes/adaptive/test_wavelet_engine.py` (transforms, assembly and its symmetry check, conditioning, both frozen solves, the NaN poison on an oversized mask, CDD and its iteration count), `tests/verification/test_wavelet_mms_order.py` (order studies and the two benchmarks). - 1.1.0 pins (`test_wavelet_node.py`, `test_wavelet_engine.py`): the auditor's float32 masses are refused naming the cause, the dtype and the mass that work, with no warning; every accepted mass reads a float64 FFT solve to `CONDITION_LIMIT` in float32 and float64; the estimate against `eigvalsh` and the closed form against the grid spectrum; eager, jit and graph select identical sets on the mirror-symmetric problems; a 1e-6 $\theta$ sweep does not switch set; four spellings give identical source bits and sets; the periodic source is translation-invariant across the seam; index-order ties at the cap; non-integral counts and unknown keys refused; `selection_diagnostics` honours its `params`. - Masked-CG conditioning (`test_wavelet_cg_conditioning.py`): the CG bound uses the CG tolerance and refuses what it cannot carry while the gathered solve accepts it; a converged CG reading is within the bound; a non-converging eager CG solve is refused with the node's message (never lineax's), and only lineax's convergence errors are translated. ## Changelog | Version | Date | Change | |---------|------|--------| | 1.0.0 | 2026-09-21 | Ported onto the 0.4.0 `AdaptiveNode` API: parameters in the graph {term}`pytree `, `{c, mask}` state, CDD as a `while_loop`, gathered frozen solve, dense full-basis gradient, declared and measured order 2 | | 1.1.0 | 2026-09-24 | Phase-3 audit: conditioning guard (`CONDITION_LIMIT`, `condition_estimate`, `physical_condition_number`); CDD rounding floor and index-order tie-break, a third loop exit and `resolved` in the diagnostics; leaves cast to the dtype before the source; periodised periodic source; non-integral structural counts and unknown keys refused. The masked-CG path is bounded with its stopping tolerance, and a non-converging eager CG solve is named rather than surfacing as lineax's error | | 1.0.1 | 2026-09-22 | Merged-tree audit: `k` sized and validated against the real CDD seed (level 0, not the coarse block) -- 16 default configurations were silently truncating the gathered solve; an oversized set is refused / NaN-poisoned; the assembly checks symmetry before symmetrising; construction is legal inside a trace; `selection_diagnostics()`; a periodic sensor at 1.0 snaps to point 0 |