Skip to content

Let postcondition correctors declare the coordinates they act in (bounded problems) - #1142

Merged
ChrisRackauckas merged 5 commits into
SciML:masterfrom
ChrisRackauckas-Claude:postcondition-transform-space
Aug 7, 2026
Merged

ChrisRackauckas merged 5 commits into
SciML:masterfrom
ChrisRackauckas-Claude:postcondition-transform-space

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Aug 7, 2026 •

Copy link
Copy Markdown
Member

Draft — please ignore until reviewed by @ChrisRackauckas.

Follow-up to #1084 (merged). That PR made postcondition correctors compose with lb/ub
bounds, but only in the solver's coordinates: bounds are handled by reparametrizing the
iterate to an unconstrained variable, the bounds transform runs after the conditioning
transform, so the corrector was applied to the unconstrained coordinate and the
postcondition_bounds_transform toggle merely warned about it.

That is backwards for the main use case. A limiting rule like SPICE pnjlim is written for
a junction voltage in volts; applied to the logit of a scaled voltage it means nothing,
and a ModelingToolkit-emitted limiter is a statement about the model's own variables. So
which space the corrector acts in is now an exposed option, defaulting to the original
(physical) variable.

The option

solve(prob, NewtonRaphson(); postcondition = H)   # :original — the default
solve(
    prob, NewtonRaphson();
    postcondition = PostconditionSpecifier(H; space = :transformed)
)

PostconditionSpecifier(corrector; space = :original) wraps the postcondition value
rather than adding a solve keyword. That is deliberate:

  • A new keyword (postcondition_space = ...) would have to be registered in SciMLBase's
    allowedkeywords — a separate repo and a separate release for one enum. If you would
    rather have the registered keyword, say so and I will open the SciMLBase PR;
    I did not
    add one unilaterally.
  • The option belongs to the corrector, not to the solve call: a model compiler can build the
    corrector once, tag it, and carry it on the problem, and it stays correct wherever the
    problem is solved.
  • The wrapper is callable and forwards, so every path that just calls the corrector is
    unchanged, and space is a type parameter, so the branch resolves at compile time.
  • It leaves room for further per-corrector options without more keywords.

Default :original, because that is what a corrector written against the model means and
because a bare (unwrapped) corrector should do the obviously-correct thing. This changes the
behaviour introduced in #1084 — released in NonlinearSolveBase 2.41.0 but only reachable by
combining postcondition with lb/ub, which the docs described as "rarely what a limiting
rule means".

What :original does

At each iterate-commit point, when the cache's problem function is wrapped in a
BoundedWrapper: map u and u_prev back with _from_unbounded, apply H there, then
_clamp_to_bounds and _to_unbounded forward again. In-place correctors mutate the
mapped-back buffer and the transformed iterate is written back in place; out-of-place ones
return a new iterate.

The _clamp_to_bounds step is load-bearing and was the main hazard: a corrector that clamps
exactly onto a bound — the natural thing for a projection — is at ±Inf under the logit. It
gets the same interior nudge (relative eps^(3/4)) the bounds transform already applies to
u0, so Hfloor!(up, ...) = (up[1] = lb[1]) commits a finite iterate that maps back to the
bound to ~1e-11 instead of an Inf.

The initial-guess correction now also runs on bounded problems in :original mode — u0 is
still in the original coordinates when transform_conditioned_problem sees it, so there is
nothing to map. It stays skipped for :transformed, which is now the only case that reports
the postcondition_bounds_transform toggle (message reworded: it no longer fires on the
default path, where nothing surprising happens).

While in here, _apply_postcondition!! now dispatches on the in-place convention instead of
branching on a Bool, matching what #1133 did for evaluate_f. Without it the unused
convention's return type leaked into the caller: apply_postcondition!! inferred
Union{Vector{Float64}, Vector{Union{}}} at the cache.u = ... assignment. It now infers
Vector{Float64}, with 0 allocations on unbounded problems (unchanged) and 160 bytes per
application on bounded ones (the two mapped-back temporaries). There is an @inferred
regression test.

Docs / versions

  • docs/src/tutorials/nonlinear_preconditioning.md: new "Correctors on bounded problems"
    subsection with a runnable side-by-side of the two spaces, plus the skipped initial guess
    and the on-a-bound nudge. Also fixes two stale 3-argument corrector signatures at the top
    of the page (they became 4-argument in Nonlinear preconditioning hooks: precondition (G) / postcondition (H) with PCNR limiting support #1084).
  • docs/src/basics/solve.md: @docs entry for PostconditionSpecifier — required by the QA
    api-docs check for a new public name — and the solve docstring keyword list.
  • NonlinearSolveBase 2.41.0 → 2.42.0 (new public API); NonlinearSolve 4.25.0 → 4.26.0 and
    NonlinearSolveFirstOrder 2.3.0 → 2.3.1, with their NonlinearSolveBase compat moved to
    "2.42".

Local test results (Julia 1.12.4)

Runic --check exit 0 on every changed Julia file.

$ cd lib/NonlinearSolveBase && NONLINEARSOLVE_TEST_GROUP=Core julia --project=. -e 'using Pkg; Pkg.test()'
Test Summary:                            | Pass  Total  Time
Nonlinear preconditioning options (#351) |   44     44  6.2s
Test Summary:           | Pass  Total  Time
Bounds transform (#955) |    8      8  1.2s
     Testing NonlinearSolveBase tests passed
$ cd lib/NonlinearSolveFirstOrder && NONLINEARSOLVE_TEST_GROUP=Core julia --project=. -e 'using Pkg; Pkg.test()'
Test Summary:                                          | Pass  Total   Time
Iterate limiting via the `postcondition` option (PCNR) |   27     27  25.1s
Test Summary:                                      | Pass  Total   Time
Left preconditioning via the `precondition` option |   11     11  16.4s
...
     Testing NonlinearSolveFirstOrder tests passed

(the whole group: 36 testsets, 2412 passing assertions, 0 failures — TrustRegion 1176/1176,
NewtonRaphson 309/309, etc.)

Every test file that touches the corrector path, run against this branch's working tree
(66 tests, all passing):

Test Summary:                             | Pass  Total     Time
cross-sublib postcondition (final source) |   66     66  2m45.4s

covering lib/NonlinearSolveFirstOrder/test/conditioning_tests__item{1,2}.jl,
lib/NonlinearSolveQuasiNewton/test/core_tests__item10.jl,
lib/NonlinearSolveSpectralMethods/test/core_tests__item6.jl,
lib/SimpleNonlinearSolve/test/core/conditioning_tests__item1.jl,
lib/NonlinearSolveBase/test/bounds_transform.jl and test/Core/core_tests__item23.jl.
test/Core/bounds_tests__item{1,2,4}.jl also pass (41/41).

The new tutorial block was run as Documenter would:

(sol_orig.u[1], sol_transformed.u[1]) = (0.9999999999999998, 7.310585786300049)
lb + (ub - lb) * logistic(1)          =                      7.310585786300049

Three red CI checks inherited from master, not from this PR. NONLINEARSOLVE_TEST_GROUP=QA
fails because nine names publicized by #1084 lack docstrings / @docs entries, and Spell Check
fails on the deliberate postcondtion misspelling in the keyword-rejection test that came in
with the same PR (correcting it would invert the assertion); #1141 fixes both. `Downgrade Tests

  • Corefails on master too (runs on 4bd97b77f and 4aad48784, both predating this branch) withUndefVarError: issquare not definedunder downgraded dependencies — that one is #1143 (the downgrade-pinned LinearSolve 5.0.1's ForwardDiff extension uses a bareissquarethat only resolved while SciMLBase re-exported SciMLOperators; #1084'sSciMLBase = "3.43"floor is what exposed it). None of the three involves anything this PR adds:PostconditionSpecifierhas a docstring and a rendered@docsentry, Aqua's unbound-type-parameter check passes with the{space, F}` signature, and the one new
    spelling finding this branch did introduce ("reparametrization") is fixed here.

Version note. Two sibling PRs bump the same files from the same base: #1141 takes
lib/NonlinearSolveBase/Project.toml to 2.41.1, and #1143 takes the umbrella Project.toml to
4.25.1. Whichever lands last needs a one-line rebase on the version field. This PR keeps
NonlinearSolveBase at 2.42.0 and the umbrella at 4.26.0, since it adds public API.

New tests

  • lib/NonlinearSolveBase/test/conditioning.jl: PostconditionSpecifier construction,
    default/rejected space, transparency of the wrapper; the initial guess corrected under
    :original and skipped under :transformed; and a commit-point testset asserting that the
    same clamp lands on the physical value under :original and on the transformed
    coordinate under :transformed, that u_prev also arrives in the original variable, that
    clamping onto a bound stays finite, and that the mapped path is @inferred.
  • lib/NonlinearSolveFirstOrder/test/conditioning_tests__item2.jl: end-to-end — pnjlim
    limiting on the bounded diode circuit now converges to the same root; a component pinned to
    1.0 lands on 1.0 under :original and on lb + (ub - lb) * logistic(1) = 7.31 under
    :transformed; a corrector pinning exactly to lb stays finite; plus the out-of-place
    bounded form.
  • test/Core/core_tests__item23.jl: the same through the default polyalgorithm, and the
    reexport of PostconditionSpecifier from the umbrella package.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B9GHoo4pFa3DU9yhyTkevL

`lb`/`ub` bounds are handled by reparametrizing the iterate to an unconstrained
variable, and that transform runs after the conditioning transform, so an iterate
corrector was applied to the unconstrained coordinate. A limiting rule like SPICE
`pnjlim` is written for a junction voltage in volts; applied to the logit of a
scaled voltage it means nothing. Which space the corrector acts in is now the
user's choice, defaulting to the problem's own variable.

- `PostconditionSpecifier(H; space = :original | :transformed)` wraps the
  corrector value instead of adding a solve keyword, so no new SciMLBase
  `allowedkeywords` entry is needed. A bare corrector is `:original`.
- `:original` maps each committed iterate back through the bounds transform,
  applies `H` there, and maps it forward again. A correction landing exactly on a
  bound is at infinity under the logit, so `_clamp_to_bounds` nudges it into the
  interior before the inverse map.
- The initial-guess correction now runs on bounded problems in `:original` (`u0`
  is still in the original coordinates at that point). It stays skipped for
  `:transformed`, which is also the only case that reports the
  `postcondition_bounds_transform` verbosity toggle now.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Comment on lines +77 to +78
function PostconditionSpecifier(corrector; space::Symbol = :original)
space in (:original, :transformed) || throw(

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 be EnumX instead of symbols.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 98afe94 — space is now PostconditionSpace via EnumX:

PostconditionSpecifier(H; space = PostconditionSpace.Transformed)
# default: PostconditionSpace.Original

PostconditionSpace is exported (and reexported through NonlinearSolve), documented, and covered by the existing conditioning tests.

Address review feedback: declare the postcondition coordinate space with
EnumX (`PostconditionSpace.Original` / `PostconditionSpace.Transformed`)
rather than bare symbols.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Comment on lines +275 to +278
u_orig = _from_unbounded.(u, lb, ub)
u_prev_orig = _from_unbounded.(u_prev, lb, ub)
post(u_orig, u_prev_orig, p, cache)
@. u = _to_unbounded(_clamp_to_bounds(u_orig, lb, ub), lb, ub)

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 allocates the u_prev_orig and u_orig

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in the latest push. The IIP original-space path was broadcasting into new u_orig / u_prev_orig arrays on every commit (~128 B for a length-1 Float64 state).

It now reuses preallocated BoundedWrapper buffers:

  • u_cache (already used by residual evaluation) for the mapped proposed iterate
  • new u_prev_cache for the mapped previous iterate

Fail-before / pass-after on the new @allocated test:

BEFORE fix: allocs = 128
AFTER fix:  allocs = 0

NONLINEARSOLVE_TEST_GROUP=Core NonlinearSolveBase: 45/45 in the preconditioning suite (including the new allocation assertion).

The original-space bounded corrector path was broadcasting into new
`u_orig`/`u_prev_orig` arrays on every commit. Reuse the
`BoundedWrapper` temps (add a second `u_prev_cache`) so the in-place
path is allocation-free.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Default is Original space, which matches user expectations, and
Transformed is an explicit opt-in, so warning about it is unnecessary.
Keep skipping the initial-guess correction for Transformed + bounds.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review August 7, 2026 13:01
@ChrisRackauckas
ChrisRackauckas merged commit c6922fc into SciML:master Aug 7, 2026
95 of 97 checks passed
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.

2 participants