Skip to content

Nonlinear solver contract

op_engine exposes the two-stage Alexander SDIRK2 method for systems that need a genuine nonlinear solve at each implicit stage. The existing implicit-euler, trapezoidal, and bdf2 methods still take only one Jacobian linearization per attempted step; despite their historical method names, they are linearly implicit approximations rather than converged nonlinear methods.

The public nonlinear contract used by SDIRK2 keeps the method independent of a specific array package. NonlinearProblem supplies a residual plus an optional dense Jacobian and/or Jacobian-vector product. Each callback must preserve the iterate's array namespace and dtype. Residuals retain the iterate shape; dense Jacobians act on the flattened state and therefore have shape (state_size, state_size).

import numpy as np

from op_engine import DenseNewtonSolver, NonlinearProblem, require_converged


def residual(value):
    return value * value - 2.0


def jacobian(value):
    return np.reshape(2.0 * value, (1, 1))


result = DenseNewtonSolver().solve(
    NonlinearProblem(residual=residual, jacobian=jacobian),
    np.asarray([1.5]),
)
root = require_converged(result).value

The solver receives the initial guess on every call. It has no hidden warm-start cache. SDIRK2 selects a deterministic lower-triangular stage predictor; a rejected attempt does not mutate solver state used by its retry.

Portable dense default

DenseNewtonSolver requires a floating-point initial guess and uses xp.linalg.solve from that array's Array-API namespace. It performs exactly NewtonConfig.max_iterations updates. The fixed loop length is deliberate: it can be statically unrolled by JAX and does not need a traced value to control a Python loop.

The result always contains the candidate, final residual, and diagnostics. converged, the initial/final residual norms, and the final step norm remain zero-dimensional arrays in the active namespace. No host scalar conversion is performed during the solve. The convergence test is

final_rms <= atol + rtol * initial_rms

and also requires every final residual element to be finite. A missing dense Jacobian, callback shape/dtype/namespace violation, or exception raised by the underlying linear solve is a fatal evaluation error. If a backend instead represents a singular solve with non-finite arrays, the final diagnostic reports nonconvergence.

The dense default is intentionally small. Sparse direct solvers, Krylov/JVP methods, line searches, trust regions, and PETSc- or package-specific state belong in optional implementations of the NonlinearSolver protocol. Core configuration depends only on that protocol; it has no SciPy-, JAX-, or PETSc-specific field or backend selector.

Eager NumPy Newton–Krylov

NewtonKrylovSolver is an optional real NumPy float32/float64 implementation. It consumes the full residual JVP through the existing ActionOperator, LinearSolveRequest and solve_linear contracts, with ScipyGmresSolver as its default inner solver. It creates no full Jacobian, identity or basis-probed matrix. Dense Newton remains the default. Krylov storage scales with state size times restart length; supplying a preconditioner may require additional storage.

import numpy as np
from op_engine import (
    NewtonKrylovConfig, NewtonKrylovSolver, NonlinearProblem, require_converged,
)

problem = NonlinearProblem(
    residual=lambda x: x * x - 2.0,
    jvp=lambda x, v: 2.0 * x * v,
)
result = NewtonKrylovSolver(
    NewtonKrylovConfig(max_iterations=20, rtol=0.0, atol=1e-12)
).solve(problem, np.array([0.01]))
require_converged(result)

The outer convergence test uses residual RMS and the initial residual scale: rms <= atol + rtol * initial_rms. max_iterations limits applied updates; an initially converged guess performs none. linear_options independently controls the inner original-residual L2 test, ||rhs - A*delta|| <= max(atol, rtol * ||rhs||), and its iteration budget. The default GMRES budget counts inner iterations, with a configurable restart on ScipyGmresSolver. Outer and inner tolerances have different norms and scales; a passing residual gate alone does not certify solution accuracy.

Every checked Newton direction starts with the configured damping. Bounded backtracking multiplies it by backtrack_factor until a finite trial reaches the outer gate or reduces RMS by the Armijo fraction armijo * step_length. max_backtracks counts reductions after the first trial. Exhaustion preserves the last accepted iterate and reports nonconvergence. Callback exceptions and invalid shapes/dtypes/namespaces propagate as fatal errors. Validated residuals are copied before retention so callbacks may reuse output buffers across trials. The SciPy adapter also owns action outputs before GMRES orthogonalization; an action or inverse may return its input or a read-only view. Matrix actions snapshot each column before another callback can reuse its buffer, using native operations that retain the automatic differentiation graph.

NewtonKrylovTermination reports convergence (1), outer budget exhaustion (2), failed checked inner solve (3), exhausted line search (4), or a nonfinite residual (5). A failed inner candidate is never applied, even if the callback reports a small residual; the original residual and solver success must both pass. Existing fixed/adaptive/replay policies then apply. Solve diagnostics retain each inner result, JVP count and step-length reduction count. Integration diagnostics expose array-only work: per-stage termination/action counts, solves_per_stage, columns_per_solve, and flattened inner convergence, iterations, L2 residuals and thresholds. Rejected attempts remain visible. Diagnostic norms use the integration dtype; inner acceptance is checked before that conversion. Zero termination means unspecified for other solvers.

A preconditioner_factory(request) may return an inverse ActionOperator or None. GMRES applies this inverse on the left. Each call receives a fresh NewtonLinearization context with an owned read-only iterate and the original problem metadata. SDIRK supplies NonlinearStageContext: stage time, full step size, diagonal step scale and stage index. The factory runs once per current linearization, including new stages and retries. The engine keeps no preparation cache; callers that retain one must invalidate it when the context, coefficients, mesh, state, scale or inverse version changes. Solver diagnostics keep no iterate history.

Use NonlinearMethodConfig(rhs_jvp=full_rhs_jvp, solver=NewtonKrylovSolver(...)) for SDIRK. Schedule provenance records outer, inner and line-search controls, the linear-solver class/restart and inverse presence. A custom callback's hidden state and coefficient versions must be represented by the caller's RunConfig.replay_model_id; discover a fresh mesh when that contract changes.

This solver makes data-dependent host decisions and rejects native JAX/Torch arrays before coercion. It supplies no JIT, vmap, automatic derivative or converged-root implicit derivative. The portable dense solver retains its separately qualified finite-iteration transformations; converged-root differentiation remains a separate implementation task.

Synthetic CPU measurements

The raw worker evidence contains 54 isolated, single-threaded workers: three workers for each size, dtype and solver case, with seven timed solves after warmup. Every timed, warmup and separate memory run passed the same independently declared residual and manufactured-root pointwise gates. Numerical and benchmark source hashes, dependencies, thread settings, CPU model/affinity, setup time, individual solve times, updates and process peak RSS are retained.

These are coupled synthetic nonlinear roots at a fixed spectral scale, rather than a domain-refinement or application study. The dense implementation performs its configured eight updates; the eager implementation stops at its outer gate. The comparison includes both that policy difference and the derivative/linear solve implementation. Times below are medians of the three worker medians; allocation peaks come from a separate tracemalloc solve and exclude some native allocator/BLAS work. Process RSS includes Python and dependency imports.

dtype state size dense ms GMRES ms inverse-preconditioned GMRES ms dense traced peak KiB preconditioned traced peak KiB
float32 32 0.519 3.592 1.194 23.9 14.8
float32 128 0.956 5.987 1.302 328.8 26.8
float32 512 33.184 6.647 1.500 5148.3 78.1
float64 32 0.272 13.173 2.859 24.0 21.5
float64 128 0.913 23.526 4.521 268.5 45.7
float64 512 42.075 40.784 9.847 4132.6 150.8

The small cases favor dense solves. At size 512, the inverse-preconditioned path takes about 22.1× less time for float32 and 4.3× less for float64, with about 66× and 27× lower traced allocation peaks on this machine. These bounds support an optional choice for qualified workloads; they do not select defaults for other geometries, trajectories or hardware. The pre-fix candidate workers are retained with their original source hashes; this table uses refreshed runs after the callback-buffer ownership corrections.

Reproduce on Linux with a suitable available CPU in place of 0:

taskset -c 0 uv run --no-sync python -m scripts.benchmark_newton_krylov \
  --sizes 32 128 512 --repeats 7 --workers-per-case 3 --require-success \
  --output /tmp/newton-krylov-workers.jsonl

Failure and adaptive stepping

Nonconvergence is data until an integration boundary applies policy:

Execution context Required behavior
Eager fixed step CoreSolver.run raises NonlinearConvergenceError with the failed stage result attached and does not advance state.
Live adaptive step Reject the attempted step, reduce dt, and charge the rejection against AdaptiveConfig.max_reject. Never advance state from a failed candidate.
Compiled accepted-schedule replay Return array-valued diagnostics and validate them afterward. A failed stage invalidates that replay; the recorded mesh cannot be changed inside the trace.
Callback or linear-algebra exception Propagate as a fatal evaluation/configuration error; do not reinterpret arbitrary exceptions as a recoverable timestep rejection.

These rules keep numerical nonconvergence distinct from malformed callbacks and backend failures. They also prevent a compiled replay from silently accepting a failed stage merely because its timestep sequence is fixed.

JAX differentiation

With JAX arrays, the dense default is compatible with jax.jit, and automatic differentiation follows the fixed sequence of Newton operations. This is unrolled-iteration differentiation: computation and derivative cost grow with max_iterations, and the derivative is that of the finite iteration, not an implicit-function derivative of an exact root.

require_converged is an eager host boundary and must not be called inside a JAX trace. Return the array-valued diagnostics from compiled code and inspect them afterward.

A JAX-specific optional solver may instead implement implicit differentiation or a custom solve behind the same protocol. For example, JAX provides jax.lax.custom_root for roots whose gradients are defined by the implicit function theorem. That package-specific mechanism does not enter the core configuration or alter the portable default's semantics.

Configuring SDIRK2

The public sdirk2 method uses the two-stage, second-order, L-stable formula from Alexander (1977), with gamma = 1 - 1/sqrt(2), coefficient rows (gamma) and (1 - gamma, gamma), and weights equal to the final row. It is stiffly accurate: a converged final stage is the accepted state.

Every stage constructs a NonlinearProblem, starts from its explicit lower-triangular predictor, and returns its NonlinearSolveDiagnostics without a host decision. Adaptive step doubling evaluates one full step and two half steps, scales their difference by 1 / (2**order - 1), and combines all six stage-convergence flags for an adaptive controller.

Supply at least one full-system RHS derivative through NonlinearMethodConfig: rhs_jacobian(t, state) or rhs_jvp(t, state, direction). The dense Jacobian has shape (state.size, state.size), even when the state has several axes. The JVP maps a full-state direction to the same shape, dtype and namespace, including all cross-axis coupling. Neither uses the operator-axis RunConfig.jacobian of linearly implicit methods. Existing positional (rhs_jacobian, solver) calls retain their meaning.

import numpy as np

from op_engine import CoreSolver, ModelCore, NonlinearMethodConfig
from op_engine.core_solver import AdaptiveConfig, RunConfig

times = np.linspace(0.0, 1.0, 11)
core = ModelCore(1, 1, times)
core.set_initial_state(np.asarray([[1.0]]))


def rhs(_time, state):
    return -(state * state)


def full_rhs_jacobian(_time, state):
    return np.reshape(-2.0 * state, (1, 1))


config = RunConfig(
    method="sdirk2",
    adaptive=True,
    adaptive_cfg=AdaptiveConfig(rtol=1e-6, atol=1e-9),
    nonlinear=NonlinearMethodConfig(rhs_jacobian=full_rhs_jacobian),
)
diagnostics = CoreSolver(core).run(rhs, config=config)
assert diagnostics is not None
diagnostics.require_converged()

NonlinearMethodConfig defaults to the backend-neutral DenseNewtonSolver and also accepts another NonlinearSolver implementation. It never stores an array namespace or backend module. A JVP-only configuration can use the eager NumPy NewtonKrylovSolver or another implementation consuming NonlinearProblem.jvp; the default dense solver still requires the dense callback and raises if it is absent.

Matrix-free stage boundary

For stage time t_i, predictor base and diagonal step d = dt * gamma, the stage residual is R(Y) = Y - base - d * rhs(t_i, Y). Its action is exactly R'(Y) v = v - d * rhs_jvp(t_i, Y, v). The method supplies this action through the existing NonlinearProblem.jvp field, checking both direction and result shape, dtype and namespace before returning it to the solver. Callback errors remain fatal model/derivative errors.

Provide NonlinearMethodConfig(rhs_jvp=full_rhs_jvp, solver=jvp_solver) for a JVP-aware solver. When rhs_jacobian is omitted, the stage constructs neither a dense identity nor a dense Jacobian, and never probes basis vectors to infer one. Supplying both callbacks exposes both derivative forms; the provider must ensure they describe the same RHS. Fixed integration, live adaptive step doubling and frozen-mesh replay all use the same stage action.

The focused CPU contract in tests/op_engine/test_sdirk_jvp.py uses an explicitly diagonal manufactured nonlinear problem and a test-only diagonal Newton solver. It compares trajectories with dense Newton and the independent continuous solution, checks a coupled time-dependent action against dense and finite-difference references, and checks float32/64 native values in NumPy, JAX and Torch. JAX fixed solves support JIT, first gradients and vmap in this contract; Torch supports eager functional first gradients. The existing public Torch SDIRK convergence check calls .item() and excludes vmap. This change does not qualify Torch compilation or GPU execution.

These native derivatives follow the finite iterations performed by the supplied test solver. The eager NumPy Newton–Krylov implementation described above has its own value, failure and finite-difference sensitivity qualification; it supplies no native transformation rule. The stage boundary adds no operator registry, automatic backend selection or implicit differentiation rule. Keep a custom solver's configuration and derivative semantics fixed when replaying a recorded mesh; custom configuration is caller-owned rather than automatically fingerprinted.

Integration diagnostics and replay

CoreSolver.run and replay_adaptive_schedule return NonlinearIntegrationDiagnostics for SDIRK2. All arrays, including nested work evidence, retain the native namespace. step_converged, step_accepted, and step_sizes describe attempted steps; rejected adaptive attempts remain present. stages_per_step maps the flattened stage fields back to each attempt. The stage fields include convergence flags, iteration/evaluation counts, and initial, final, and update RMS norms.

Live adaptive integration may therefore finish successfully even though a rejected attempt has step_converged=False; the aggregate converged flag requires every accepted step to converge. last_nonlinear_diagnostics retains the most recent record, including after a fixed failure or exhausted rejection budget.

Compiled replay cannot change its frozen mesh. Return its diagnostics from the transformed function, then call diagnostics.require_converged() outside the trace. A NonlinearIntegrationConvergenceError means the replayed state and derivatives must be discarded. A valid replay remains differentiable through the fixed Newton iterations with JAX.