Solvers¶
Underneath every layer is the same stack: a linear operator that knows how to apply itself but need not store itself, a Newton iteration built on it, and a selection rule that picks a linear backend on measured evidence.
The LinearOperator contract¶
Operators are matvec-free. An operator must apply itself; everything else is optional and advertised:
class LinearOperator(Protocol):
shape: tuple[int, ...]
dtype: torch.dtype
device: torch.device
symmetric: bool
def matvec(self, x: Tensor) -> Tensor: ...
def rmatvec(self, x: Tensor) -> Tensor: ...
def diagonal(self) -> Tensor: ... # for Jacobi preconditioning
def assemble(self) -> Tensor | None: ... # dense form, if it has one
def spd_certificate(self) -> Tensor | None: ...
An operator may additionally implement assemble_sparse() -> (row, col, values) — the
SparseAssembling protocol — which is what unlocks the sparse-direct backend.
Three implementations ship:
| Operator | What it represents |
|---|---|
DenseOperator |
A plain matrix. The retained reference against which the others are checked. |
GraphLaplacianOperator |
\(A_I \operatorname{diag}(g) A_I^\top\), built from endpoints and accumulate — never forming \(A\). This is the Jacobian of a Kirchhoff layer. |
AdvectionOperator |
The upwind advection operator a transport layer solves against. |
The point of the contract is that a network with \(10^5\) edges assembles an \(O(E)\) sparse triplet or applies itself in \(O(E)\) work, and never materialises an \(n \times b\) dense matrix.
The SPD certificate¶
spd_certificate() returns a tensor, or None for "cannot say". This drives backend selection,
and it is deliberately conservative: an operator asserts SPD only when it can prove it.
GraphLaplacianOperator certifies when its conductances are positive and the interior is
grounded. _DiagonalShifted — the Jacobian of a layer carrying potential-dependent nodal
sources — delegates to the Laplacian and keeps the certificate only when every shift entry is
non-negative: a non-negative diagonal added to an SPD matrix is SPD, a negative one might not
be, so the certificate is dropped rather than asserted. The solve then routes to GMRES, which
needs none.
Grounding¶
noodl.solvers.grounding provides spd_certificate and spd_diagnosis. The second is the
useful one when something goes wrong: a potential problem needs one grounded (boundary) node per
connected component, and a network that has lost grounding — a fan reaching shutoff, a damper
closing, a component that turns out to be isolated — produces a singular operator. spd_diagnosis
names which component is ungrounded rather than leaving you with a failed solve.
This can happen mid-iteration, which is why newton takes a where label and every layer
passes something specific like "KirchhoffLayer 'air' solve". In a composed model over one
network, a bare "newton: did not converge" is not enough to find the culprit.
solve: the auto table¶
from noodl.solvers.select import solve
result = solve(op, b, method="auto", rtol=1e-10, on_failure="raise")
method="auto" resolves by this table, top to bottom, first match wins:
| Condition | Backend |
|---|---|
spd_certificate() mixed across the batch |
RuntimeError — never split a batch |
| Certified SPD, and sparse-direct applicable | sparse_direct (SciPy SuperLU, per instance) |
| Certified SPD, otherwise | pcg (Jacobi-preconditioned conjugate gradients) |
Certificate None, or uniformly False |
gmres (assumes no symmetry) |
"Sparse-direct applicable" is a conjunction: the operator declares assemble_sparse and
returns a sparse form from it, SciPy is importable, the solve is grad-safe, and the flat
batch size is at most 32. Every one of those is a fall back to PCG when it fails, never a
refusal — auto is a promise to choose a backend that works.
Why that default, and why the batch threshold¶
Measured on the reference composed model (1028 unknowns, ~5300 nonzeros, float64, 14 threads, median of 3 warm runs):
| Pass | Ensemble | PCG | sparse-direct | |
|---|---|---|---|---|
| forward | 1 | 128.4 ms | 28.0 ms | 4.59x faster |
| forward | 100 | 2399 ms | 2182 ms | a tie |
| forward | 1000 | 29489 ms | 19263 ms | 1.53x faster |
| backward | 1 | 44.9 ms | 13.8 ms | 3.25x faster |
| backward | 100 | 1741 ms | 476 ms | 3.66x faster |
PCG needed 168–180 iterations per Newton step at this conditioning; the factorisation needs one. But SuperLU is driven by a per-instance Python loop whose cost is linear in the ensemble, while PCG's batched arithmetic is sub-linear — hence the threshold at 32 rather than an unconditional preference. A batched vendor backend would replace the loop, not the algorithm.
Because the choice depends on runtime predicates a caller cannot otherwise observe — the batch
size, and whether SciPy is importable at all — diagnostics reports method (what you asked
for) and backend (what ran) separately. Without that distinction, an installation missing the
sparse extra looks identical to one that has it, at 4.6x the cost.
Failure¶
pcg and gmres never raise. select.solve is the single layer where a failed numerical
solve becomes an exception, and on_failure="return" is the narrow escape hatch that returns a
SolveResult instead.
An eligibility refusal is different and always raises, whatever on_failure says: requesting
method="cg" on an operator that cannot certify SPD, or letting auto see a batch where some
but not all instances certify, is a modelling error, not a numerical one. There is no
per-instance result to return.
SolveResult carries x, converged, iterations, residual and status per instance, with
status one of CONVERGED, MAX_ITER, BREAKDOWN, SINGULAR. raise_on_failure(where) names
every non-converged batch index with its status and residual, and returns self when all
converged, so it chains.
Newton¶
from noodl.solvers.newton import newton
result = newton(residual, operator, x0, atol=None, rtol=None, max_iter=50,
omega=0.75, switch_ratio=0.5, method="auto", where="my solve")
Damped, batched Newton. NewtonResult carries x, converged (per instance), iterations,
residual_norm, linear_iterations and backend.
Tolerance defaults. atol and rtol both default to None, meaning derive from the
working dtype: each independently becomes \(\sqrt{\varepsilon}\) for the dtype of the residual
tensor actually returned — about 1.2e-4 for float32, 1.5e-8 for float64. This is the standard
"half the significant digits" heuristic. Asking for tighter than that requests precision the
dtype does not have: the residual floors below the target and converged never becomes true. An
explicit value always overrides, and the two are independent.
linear_iterations is, per instance, the maximum inner-solver count over the Newton steps
taken — the worst single linear solve. That is what a performance budget or a preconditioner
decision is made against, and it is monotone in problem difficulty in a way a sum over a varying
number of steps is not. It is None only when no linear solve happened at all, never as a
stand-in for an unknown count.
Convergence is per batch instance, and the mask is computed on detached copies so it never enters the autograd graph.
Kinked monotone laws: line_search="convex". When every branch flow is non-decreasing in its
own potential difference and every nodal withdrawal in its own potential, the residual is the
gradient of a convex function \(E\) and the Newton direction is a descent direction of \(E\). A
law with kinks (a flat-steep-flat S-curve, a stiff bed meeting a soft storage line) can still
send the damped Newton into a cycle it never leaves, because the step crosses a kink and lands
past the minimum of \(E\) along it. With line_search="convex" such a step, recognised by the
directional derivative \(\phi'(t) = r(x + t d)\cdot d\) at its end, is cut back to that minimum by
a regula falsi on the monotone \(\phi'\); a step that does not overshoot is taken unchanged, so a
smooth solve is bitwise the same.
Round-off floor: step_floor. An atol below the round-off of the residual's own terms (a
stiff law at a large potential) cannot be met: the iterate stands still and the solve fails.
step_floor=k ends an instance as converged once its Newton step is within k ULPs of the
iterate in every component, i.e. at the floor. Both options are off by default; a layer that
needs them sets them as its newton_defaults (the sewer's dynamic wave does).
Monotone scalar solves¶
noodl.solvers.scalar.solve_monotone is a batched bracketed root-finder for a scalar equation
known to be monotone. It appears wherever a closed-form inversion does not exist but monotonicity
is provable:
- The sewer's Manning normal-depth inversion — discharge is strictly increasing in depth up to \(h/D = 0.938\), so the ascending branch is the entire invertible domain.
- The sewer's implicit-Euler storage sweep.
- The water application's Hazen-Williams inversion with a minor-loss term.
AllocatedFlowLayer's projection-mode QP, whose KKT stationarity reduces to one scalar monotone equation per node.
Monotonicity is what makes bracketing safe, and each caller documents why it holds.
Implicit solve and the adjoint¶
noodl.solvers.implicit is what makes the whole package differentiable at reasonable cost.
implicit_solve wraps a Newton solve in a torch.autograd.Function whose backward applies the
implicit-function theorem at the converged solution rather than unrolling the forward iteration.
adjoint solves the transposed system, using TransposeOperator to present any operator's
rmatvec as a forward action.
The cost is one extra linear solve on the backward pass, independent of how many Newton iterations the forward took. Differentiability covers what this means for your model.