Skip to content

Add the limited(actual, limiter) operator: symbolic PCNR iterate limiting for nonlinear solves#4774

Draft
ChrisRackauckas-Claude wants to merge 2 commits into
SciML:masterfrom
ChrisRackauckas-Claude:symbolic-limiters-pcnr
Draft

Add the limited(actual, limiter) operator: symbolic PCNR iterate limiting for nonlinear solves#4774
ChrisRackauckas-Claude wants to merge 2 commits into
SciML:masterfrom
ChrisRackauckas-Claude:symbolic-limiters-pcnr

Conversation

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

Important

This PR should be ignored until reviewed by @ChrisRackauckas.

Note

Dependency chain: requires SciML/SciMLBase.jl#1449 (adds NonlinearFunction.precondition/postcondition, v3.37.0) and SciML/NonlinearSolve.jl#1084 (solvers apply the hooks). CI here cannot resolve until SciMLBase 3.37 is released; solver-behavior test assertions are additionally gated on isdefined(NonlinearSolveBase, :apply_postcondition!!) so they activate when NonlinearSolve releases. Everything below was verified locally against the companion branches.

Summary

Adds the limited(actual, limiter) operator: symbolic, component-level declaration of SPICE-style iterate limiting, lowered automatically to the Predictor/Corrector Newton-Raphson (PCNR) formulation of Aadithya, Keiter & Mei. A device author annotates a sensitive quantity once —

i ~ Is * (exp(limited(v, pnjlim(limitnew, limitold, Vt, vcrit)) / Vt) - 1)

— and every nonlinear solve built from any model containing that component gets predictor/corrector limiting fully automatically: mtkcompile performs the PCNR augmentation and NonlinearProblem construction compiles the limiters into the new NonlinearFunction.postcondition corrector hook.

The operator follows the architecture of the Modelica homotopy(actual, simplified) operator (#4601): the actual expression first, the helper second; limitnew/limitold are reserved placeholder symbols (fixed sentinels, precompile-safe like HOMOTOPY_LAMBDA) for the proposed and previously-accepted values inside the limiter expression.

What the lowering does

For time-independent systems (in both the ModelingToolkitBase light pipeline and the ModelingToolkit structural pipeline, inserted post-expand_connections):

  1. each unique limited(actual, limiter) node gets an auxiliary irreducible unknown limited_k; the node is replaced by it and the consistency equation limited_k ~ actual is appended (EquationSourceInformation is padded accordingly for TearingState);
  2. because limited_k is irreducible, alias elimination keeps the limited quantity as the surviving representative of its alias class — the PCNR augmentation is reduced symbolically, at no runtime cost;
  3. limited_k receives actual as both default and guess, so no user-provided initial value is needed;
  4. the limiter registry is stored in LimitedCtx system metadata; NonlinearFunction construction compiles each limiter via generate_custom_function (parameters, including bound parameters, resolve through standard codegen) into an iip/oop postcondition hook.

For time-dependent systems the operator is stripped to actual during mtkcompile, so component libraries carrying limiters compile unchanged for transient simulation (limiting inside implicit steppers' nonlinear solves is future work). Un-lowered limited nodes are rejected with clear errors at NonlinearFunction/ODEFunction construction; nested limited and limiters referencing other unknowns are rejected likewise.

Changes

  • ModelingToolkitBase (1.54.0 → 1.55.0): new src/systems/limited_operator.jl (operator, sentinels, detection/strip/lowering passes, generate_limited_postcondition); lowering invoked from __mtkcompile; postcondition wired into NonlinearFunction construction with guards; ODEFunction guard; exports limited, limitnew, limitold; SciMLBase compat 3.37; tests in test/limited_operator.jl (registered in the InterfaceII group).
  • ModelingToolkit (11.34.0 → 11.35.0): lowering invoked from the structural __mtkcompile (with source-info padding); SciMLBase compat 3.37; MTKBase compat 1.55.
  • Docs: new docs/src/basics/Limiting.md (registered in pages.jl) with a component-based diode-circuit walkthrough, lowering semantics, and contracts.

Verification (all local, Julia 1.12.4, dev'd SciMLBase#1449 + NonlinearSolve#1084)

  • New limited_operator.jl testset: 28/28 in the MTKBase light pipeline.
  • Neighboring homotopy_lowering.jl: 43/43 (no regression from the shared pipeline edits).
  • Full ModelingToolkit (structural) pipeline end-to-end:
    • scalar diode DC solve: plain NewtonRaphson 179 steps → with limited 11 steps (identical to the hand-written PCNR/NonlinearSolve tutorial results), root v = 0.6698509496766559 exact to 1e-6;
    • hierarchical two-component circuit (diode + resistor subsystems, namespaced parameters incl. a bound vcrit): 11 steps, correct v/i recovered through observed;
    • transient RC-diode System(eqs, t): operator stripped, ODEProblem compiles, simulates, and the generated RHS matches the analytic value at t=0.
  • MTKBase GROUP=InterfaceII suite (contains the new tests plus problem-construction/homotopy tests) run against the dev stack — result reported in a PR comment.

Design notes

  • Sentinels are parameters (not variables) so the time-dependent System(eqs, t) constructor's function-of-t validation accepts equations containing them; both lowering paths drop them from the discovered unknowns/parameters (namespaced occurrences — diode₊__limitnew_ₘₜₖ — are recognized by name suffix and canonicalized back to the toplevel sentinels).
  • The lowering keeps constructor-built caches coherent (var_to_name, and crucially the irreducibles field, which structural simplification consults rather than per-variable metadata — found the hard way).
  • Derivative rules register ∂₁ = 1, ∂₂ = 0: Jacobians treat the operator as actual, matching the hook semantics (the limiter is a corrector, not part of the residual).

Closes the ModelingToolkit third of the SciML/NonlinearSolve.jl#351 + PCNR feature arc.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9GHoo4pFa3DU9yhyTkevL

…ting

Adds symbolic, component-level declaration of SPICE-style iterate limiting,
lowered automatically to the Predictor/Corrector Newton-Raphson (PCNR)
formulation of Aadithya, Keiter & Mei. Mirrors the architecture of the
Modelica homotopy(actual, simplified) operator: limited(actual, limiter)
stays opaque through System construction; mtkcompile lowers it for
time-independent systems (auxiliary irreducible unknown per node, consistency
equation, symbolic default/guess, limiter registry in LimitedCtx metadata)
and strips it to actual for time-dependent systems so component libraries
compile unchanged for transient simulation. NonlinearFunction construction
compiles the limiter registry into the SciMLBase postcondition corrector
hook, which NonlinearSolve.jl's native solvers apply at every accepted
iterate.

The limiter is written in terms of the reserved limitnew/limitold
placeholders (fixed-name parameter sentinels, precompile-safe like
HOMOTOPY_LAMBDA; namespaced occurrences from subsystems are recognized by
name suffix and canonicalized during lowering). Because the auxiliary
unknowns are irreducible, alias elimination keeps the limited quantity as
the surviving representative of its alias class, i.e. the PCNR augmentation
is reduced symbolically at no runtime cost.

Measured on the classic Vs-R-diode DC circuit with pnjlim: plain
NewtonRaphson 179 steps, with limited 11 steps, both as a flat system and
through hierarchical namespaced components.

Requires SciMLBase >= 3.37 (SciML/SciMLBase.jl#1449) for the postcondition
field and SciML/NonlinearSolve.jl#1084 for solvers to apply it.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Verification detail (local, Julia 1.12.4). The full GROUP=InterfaceII harness run is currently blocked by registered-ecosystem version skew, not by this PR: dev'd SciMLBase master requires FindFirstFunctions 3 while the MTKBase test env pins DataInterpolations 8 (needs FFF <2), and dev'd NonlinearSolve requires LinearSolve 5 while registered OrdinaryDiffEq pins <5. Any MTK branch tested against SciMLBase master hits the same wall until the compat sweep accompanying those releases.

What was run instead, in a coherent dev environment (SciMLBase#1449 + NonlinearSolve#1084 + this branch):

Testset Result
test/limited_operator.jl (new, MTKBase light pipeline) 28/28
test/homotopy_lowering.jl (shares the touched pipeline) 43/43
test/homotopy_problem.jl (exercises the edited problems/nonlinearproblem.jl) 29/29
test/nonlinearsystem.jl 23/24 — the 1 error is an using OrdinaryDiffEq precompile failure from the version skew above (old OrdinaryDiffEqDifferentiation vs SciMLBase master), unrelated to this diff
Full ModelingToolkit structural pipeline (root __mtkcompile) end-to-end flat diode: 179 → 11 steps; hierarchical diode+resistor components (namespaced + bound vcrit parameter): 11 steps, correct v/i via observed; transient System(eqs, t): stripped, compiles, integrates, RHS matches analytic value

Also verified the operator's derivative rules (∂₁=1, ∂₂=0, Jacobians see actual), nested-limited rejection, the un-lowered guard on NonlinearFunction/ODEFunction, and the limiter-references-unknowns error.

🤖 Generated with Claude Code

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

CI triage (first full run): Runic Format Check and Spell Check pass. All 83 failing jobs (tests/sublibrary-ci/Downgrade/Documentation/Benchmarks/Catalyst downstream) fail at dependency resolution with Unsatisfiable requirements for SciMLBase — the documented state until SciML/SciMLBase.jl#1449 is merged and 3.37.0 is tagged (this PR's compat requires it). Verified by reading the InterfaceII and Downgrade logs directly; no non-resolution failure modes present.

Release order for green CI here: SciMLBase#1449 → tag 3.37.0 → re-run (tests pass with limiting inert) → NonlinearSolve#1084 + release → hook-gated solver-behavior assertions activate.

🤖 Generated with Claude Code

Return `true` iff `sys` contains a `limited(...)` node anywhere in its equations or its
observed equations.
"""
function has_any_limited(sys)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use https://github.com/JuliaSymbolics/SymbolicUtils.jl/blob/master/src/irstructure.jl#L659 to have any hope at scaling to Multibody models. Naive recursion on an expression is strictly off the table in such cases.

Recursively replace every `limited(actual, limiter)` node in the unwrapped expression `x`
with `actual`, discarding the limiter (and with it any `limitnew`/`limitold` sentinels).
"""
function _strip_limited(x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a similar fashion, this should use IRStructure, though we don't have an inbuilt utility for this. An alternative formulation is to propagate a cache::Base.IdDict{SymbolicT, SymbolicT} throughout the recursion, populate it with the result of maketerm and check it at the beginning to avoid duplicating work.

return filter(v -> _sentinel_kind(v) === :none, vars)
end

function _canonicalize_sentinels(x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar cache required.


# Replace every node in `x` that appears as a key of `repl` (an ordered
# node => variable map keyed by `isequal`) with its replacement.
function _replace_limited_nodes(x, repl)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this just substitute? It should use IRSubstituter.

return maketerm(typeof(x), operation(x), new_args, metadata(x))
end

function _collect_limited_nodes!(nodes, x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just search_variables!. It should use the IRStructureSearchBuffer version.

for v in Iterators.flatten((unknowns(sys), parameters(sys; initial_parameters = true)))
if hasname(v) && getname(v) in reserved
throw(
ArgumentError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't the lowering use Unicode or a name with # in it?

# parameters only; referencing other unknowns is not supported since the hook
# sees the limited entries, not the whole state.
bad = [
v for v in unwrap.(Symbolics.get_variables(lexpr))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using IRStructureSearchBuffer and get_variables! here will be faster.

lims = Tuple(lims)
if iip
return let lims = lims
function limited_postcondition_iip(up, uprev, p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these closures be made callable structs so we don't accidentally forget to add something to the let block later?

)
)
end
fn = generate_custom_function(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to FunctionWrapper these somehow to cut down on compile times?

Addresses @AayushSabharwal's review. All expression walking now goes through
SymbolicUtils' IRStructure machinery instead of naive recursion, so the passes
visit shared subexpressions once rather than once per occurrence (the Multibody
scaling concern):

- has_limited uses SU.query (DAG-memoized) with an IsLimitedNode callable
  predicate.
- Node collection uses search_variables! with IRStructureSearchBuffer and
  is_atomic = IsLimitedNode(), which collects the limited nodes themselves;
  insertion order stays deterministic so the generated names remain a pure
  function of the system.
- Node replacement, the strip pass, and sentinel canonicalization are now
  substitutions applied through SU.IRSubstituter rather than hand-rolled
  maketerm rebuilds.
- The limiter free-variable check uses IRStructureSearchBuffer + search_variables!.
- Auxiliary unknowns are named , which cannot be written as a Julia
  identifier, so the name-collision guard is gone entirely.
- The postcondition closures became a LimitedPostcondition{iip} callable struct
  so captured state is explicit.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Thanks @AayushSabharwal — all addressed in 204a5ce. Every traversal now goes through the IR machinery rather than naive recursion:

Comment Change
_has_limited naive recursion (scaling to Multibody) has_limited is now SU.query(IsLimitedNode(), expr) — DAG-memoized, plus a callable-struct predicate reused as the is_atomic below
_strip_limited needs IRStructure/cache strip is now node collection + one SU.IRSubstituter pass over equations and observed
_replace_limited_nodes "isn't this just substitute?" yes — replaced by IRSubstituter{false}(ir, rules); no hand-rolled maketerm rebuild left
_collect_limited_nodes! "just search_variables!" search_variables! with IRStructureSearchBuffer and is_atomic = IsLimitedNode(), so the limited nodes themselves land in the buffer. Kept an OrderedSet so insertion order stays deterministic — the generated names have to be a pure function of the system for RGF-cache/precompile reasons
sentinel canonicalization recursion same treatment: search_variables! with an IsSentinel atomic predicate → substitution rules → IRSubstituter
free-variable check → IRStructureSearchBuffer/get_variables! done, with the buffer reused across limiters
"Can't the lowering use Unicode or a name with #?" now Symbol("#limited_", k) — unwritable as a Julia identifier, so the name-collision guard is deleted outright
closures → callable structs LimitedPostcondition{iip, L} with an explicit limiters field

On the last one — FunctionWrapper for the compiled limiters: I left them unwrapped for now, because I don't think there's a signature that survives the use sites. The limiter is called as fn((u_new, u_old), p) where the value type must stay generic (ForwardDiff Duals flow through the same entries when the outer problem is differentiated, e.g. parameter estimation over a limited circuit) and p is an MTKParameters whose type varies per system, so a concrete FunctionWrapper{Float64, Tuple{Tuple{Float64,Float64}, P}} would either break AD or need one wrapper type per system anyway. The tuple-of-limiters does specialize on limiter count, so compile time grows with the number of distinct limited quantities in a model — if that bites on a large device library, the cheap fix is a length threshold above which we switch to a Vector of a single wrapper type with a per-eltype cache. Happy to add that upfront rather than on measurement if you'd prefer.

Re-verified after the rewrite: limited_operator.jl 28/28, and the full ModelingToolkit structural pipeline end-to-end — flat diode 179 → 11 steps, hierarchical component circuit 11 steps with identical root (0.6698509496766559), transient strip path unchanged.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants