Skip to content

Add the homotopy operator and HomotopyProblem construction#4601

Open
BatchZero wants to merge 3 commits into
SciML:masterfrom
BatchZero:feature/homotopy-lowering
Open

Add the homotopy operator and HomotopyProblem construction#4601
BatchZero wants to merge 3 commits into
SciML:masterfrom
BatchZero:feature/homotopy-lowering

Conversation

@BatchZero

@BatchZero BatchZero commented Jun 8, 2026

Copy link
Copy Markdown

Summary

Implements the homotopy(actual, simplified) operator as a general nonlinear-solving construct. homotopy is a way to solve a hard nonlinear system by continuation: start from an easy simplified form and deform it into the actual one. Initialization is the most common consumer, but it is not the only one — so this PR exposes the construct on its own, and a follow-up wires it into the initialization default path.

Per the review, this is PR 1 of 2:

  • This PR (A): the operator, the lowering pass, the SciMLBase.HomotopyProblem(sys, op) constructor, and the automatic SciMLBase.AbstractNonlinearProblem(sys, op) selection.
  • Follow-up (B): initialization wiring — default initializealg = OverrideInit(nlsolve = HomotopySweep()) injection, InitializationProblem routing through the constructor, expression = Val{true} auto-injection, the NonlinearSolveBase hard dep.

MTK's part is an independent lowering pass targeting an MTK-independent API. The solver stack merged and registered upstream first:

This PR contains lowering + targeting only — no solver code.

Review items addressed

  • "It's just a nonlinear solver, why speak to initialization here?" The docs page and the operator docstring are reframed around general nonlinear solving; the direct HomotopyProblem / AbstractNonlinearProblem construction is the headline, with initialization mentioned as one consumer (wired in PR B).
  • "There should be an AbstractNonlinearProblem constructor that is automatic." Added SciMLBase.AbstractNonlinearProblem(sys, op) (re-exported), which returns a HomotopyProblem when sys carries homotopy nodes and a NonlinearProblem otherwise (get_nonlinear_problem_type). The explicit SciMLBase.HomotopyProblem(sys, op) is also provided.
  • "Split [the rewrap] to the AbstractNonlinearProblem constructor." The detection + lowering + wrap now live in the HomotopyProblem(sys, op) constructor (problems/homotopyproblem.jl), not inside InitializationProblem. PR B routes init through it.
  • gensym breaks precompilation. λ is now a fixed reserved unicode sentinel (__homotopy_λₘₜₖ, matching MTK's other internal ₘₜₖ sentinels), so codegen is a pure function of the system and the precompile / RuntimeGeneratedFunctions Expr-hash caches hit. A cheap guard turns the otherwise-silent user-symbol collision into an error.
  • The mirrored codegen path ("?"). generate_rhs gains an additive extra_args::Tuple = () keyword; generate_homotopy_residual now threads λ through it and collapses to two guards plus a one-line forward. The "keep in sync" comment is gone — there is no second copy. Zero behavior change for the 9 existing generate_rhs callers (extra_args defaults to (), p_end only forwarded when non-empty).
  • "Recommend broadcast." Documented and tested: homotopy.(actual, simplified) creates one node per element; the lowering rewrites each independently under the single shared λ.
  • NEWS.md "# Unreleased". Removed — the repo convention is that feature PRs don't touch NEWS.md (it is a major-version upgrade guide).

How it works

  • Operator. homotopy(actual, simplified) is registered and opaque through System construction, mtkcompile, and runtime codegen. The numeric fallback is actualsimplified is evaluated and discarded, an honest cost borne only by annotated systems. Systems that don't use the operator are byte-unaffected.
  • Derivatives. Registered nodewise: ∂₁ = homotopy(1, 0), ∂₂ = homotopy(0, 1). At runtime these fold to the derivative of actual; under the swept rewrite they blend to λ and 1 − λ, so differentiated equations keep the same blend structure.
  • Lowering pass (lower_homotopy). A shadow rewrite replaces every homotopy(a, s) (in equations and observed) with (1 − λ)·s + λ·a, one shared sentinel λ across all occurrences (and nested ones). Structure-preserving: same equation count/order/incidence modulo the free λ. The shadow never passes through mtkcompile.
  • Residual (generate_homotopy_residual). Compiled as f(u, p, λ) via generate_rhs's extra_args, with λ in the t-slot; an explicit p_end keeps λ out of the MTKParameters destructuring. User p / MTKParameters pass through untouched. The jacobian/sparsity of the standard build are dropped (they encode the λ = 1 system).
  • Construction. HomotopyProblem(sys, op) (or AbstractNonlinearProblem(sys, op)) builds the swept problem; λspan is inherited from the upstream HomotopyProblem default (0.0, 1.0) — this PR introduces no numeric defaults of its own. Solve it with solve(prob) (defaults to HomotopySweep) or an explicit HomotopySweep(...).

Tests (MTKBase, all green locally)

  • homotopy_lowering.jl — opaque-through-complete, runtime fallback, nodewise derivatives, shared-λ lowering across equations + observed, nested homotopy, the sentinel guard, and broadcast.
  • homotopy_problem.jl — direct HomotopyProblem(sys, op) construction + residual endpoints (simplified / actual / convex blend, oop + iip), the automatic AbstractNonlinearProblem selection (and its negative control), out-of-basin rescue through the sweep, structured p pass-through, two homotopy calls sharing one sweep.
  • homotopy_omc_parity.jl — a pressure-drop network pattern (homotopy reaching the residual through an observed equation), solved to the analytic root (m_flow = √5 to 1e-6) by both solve(prob) and an explicit HomotopySweep.

Dependencies & compat

SciMLBase floored at 3.19 (HomotopyProblem). No new hard dependency: the solver routes to HomotopySweep through NonlinearSolveBase's own solve(::HomotopyProblem) fallback at solve time, so NonlinearSolveBase is only a test dependency here (re-exported via NonlinearSolve). The hard NonlinearSolveBase dep arrives with the initialization injection in PR B. SimpleNonlinearSolve floored at 2.11.1 (lowest co-resolvable with SciMLBase 3.19).

Deferred to follow-up PRs (listed so "why just N?" is pre-answered)

  • PR B: initialization wiring (default initializealg injection, InitializationProblem routing, Val{true} auto-injection).
  • Per-SCC sweep. A homotopy-containing system can be swept per strongly-connected component. The structure supports it (SCCNonlinearProblem.probs has no element-type constraint; HomotopyProblem subproblems route to HomotopySweep via the existing fallback), but it needs a small NonlinearSolve enabler first (probvec(::HomotopyProblem), a solve(::HomotopyProblem, ::AbstractNonlinearAlgorithm) wrapping rule, Stalled-resid aggregation) and per-SCC residual codegen — its own PR.
  • expression = Val{true} for the direct constructor.
  • Analytic jacobian for the swept residual.

Expected CI reds (pre-attributed)

  • codecov upload (fork token);
  • repo-wide Runic FormatCheck (red on master itself; this PR adds zero new Runic violations — every touched line is Runic 1.7.0-clean, and unrelated formatting on master was left untouched to avoid drive-by churn);
  • the usual downstream/integration flakes.

@BatchZero

Copy link
Copy Markdown
Author

Status note: per the review on SciML/SciMLBase.jl#1375 and SciML/NonlinearSolve.jl#962, the homotopy parameter λ is being separated from the parameter vector (the residual becomes f(u, p, λ) instead of a homotopy_parameter locator into p). Once those two PRs settle, this PR's lowering will be reworked to target the revised API — holding off on changes here until then to avoid churn.

@BatchZero BatchZero force-pushed the feature/homotopy-lowering branch from 6f2690c to 4706e06 Compare June 12, 2026 06:17
@BatchZero

Copy link
Copy Markdown
Author

Both upstream dependencies are merged and registered: SciML/SciMLBase.jl#1375 → SciMLBase v3.19.0 (HomotopyProblem, residual f(u, p, λ)) and SciML/NonlinearSolve.jl#962 → NonlinearSolveBase v2.31.0 (HomotopySweep, re-exported by NonlinearSolve 4.19.1). This PR is reworked to that convention and rebased onto master d95960f (34 commits, d95960f..4706e06; description updated to match): λ is now a trailing scalar argument of the regenerated init residual — compiled into the t-slot, never a parameter, user p/MTKParameters untouched, the old locator design gone — produced by an independent lowering pass at the InitializationProblem boundary (parent system unmutated) and wrapped as a HomotopyProblem targeting HomotopySweep through OverrideInit. Per the #4576 verdict no solver code lives here: a grep over the src diff finds zero sweep/λ-stepping logic (9 doc/comment mentions + 1 construction); the weakdep extension, isdefined guard, and factory Ref are deleted in favor of a hard NonlinearSolveBase dep that is already in MTKBase's unconditional load closure via SimpleNonlinearSolve (zero packages added to the resolved set, Pkg.resolve-verified; precedent: the hard import SCCNonlinearSolve). Compat floors set: SciMLBase = "3.19", NonlinearSolveBase = "2.31", and on the existing hard dep SimpleNonlinearSolve = "2.11.1" — the lowest version co-resolvable with the new floors, since DowngradeSublibraries pins [deps] floors and SciMLBase 3.19 does not admit older 2.x releases — plus test dep NonlinearSolve = "4.19"; root NonlinearSolve is not floored at 4.20 because 4.20.0 is unregistered and the registered 4.19.1 co-resolves with NonlinearSolveBase 2.31 (verified). MTKBase Initialization group full run is green — the only non-green is 12 pre-existing Broken at initializationsystem.jl:959 (+2 version-gated at :1735-1736 off Julia 1.12; all pre-existing on master) — plus 92 new tests across five files, including OpenModelica v1.27 parity (m_flow(0) = √5 to 1e-6 on the Buildings PressureDrop pattern through the swept-observed path) and a root-MTK SCC-forcing e2e with negative control; touched files are Runic 1.7.0-clean (the CI version) and the injected-alg construction is concrete under @code_warntype. Expected CI reds, all pre-attributed: codecov upload failure (fork token), repo-wide Runic FormatCheck (red on master itself), the root-level Downgrade conflict (root SciMLBase compat vs in-repo MTKBase ≥ 3.18, present on master), and the usual downstream/integration flakes. Deferred as follow-up PRs, listed in the description: NLLS sweep for non-square init, per-SCC sweep, the default_nlsolve(::Nothing, …, ::HomotopyProblem) hook in OrdinaryDiffEqNonlinearSolve (which retires the injection branch here), Val{true} auto-injection, and elision of the dead simplified branch.

Comment thread docs/src/basics/Homotopy.md Outdated
@@ -0,0 +1,167 @@
# [Homotopy Initialization](@id homotopy)

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.

Why even speak to initialization here? It's just a nonlinear solver. The fact that it shows up commonly in initialization is a detail, but there are many other uses for it.

Comment thread docs/src/basics/Homotopy.md Outdated
Problems built through the default path then receive

```julia
initializealg = SciMLBase.OverrideInit(nlsolve = NonlinearSolveBase.HomotopySweep())

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.

Any nonlinear solve of equations which have a homotopy operator should default to defining a homotopy problem.

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.

Or rather, there should be an AbstractNonlinearProblem constructor that is automatic here.

Comment thread docs/src/basics/Homotopy.md Outdated
Comment on lines +90 to +92
- If the initialization would otherwise target an `SCCNonlinearProblem`, a
homotopy-using system is forced to the monolithic `NonlinearProblem` target,
since the continuation deforms the coupled system along one `λ`.

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.

It should probably split that to SCCs.

Comment thread docs/src/basics/Homotopy.md Outdated
falls back to least squares), a one-time warning is emitted and initialization
solves the `actual` (`λ = 1`) form instead. Least-squares and per-SCC sweeps
are future work.
- **Trivial initialization is silently correct.** If the initialization system

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.

Obviously. Why is this even worth mentioning?

Comment thread docs/src/basics/Homotopy.md Outdated
Comment on lines +144 to +147
- **`expression = Val{true}` does not auto-inject the `initializealg`.** When
generating problem-construction expressions, pass
`initializealg = SciMLBase.OverrideInit(nlsolve = NonlinearSolveBase.HomotopySweep())`
explicitly.

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.

That's a bug.

Comment thread docs/src/basics/Homotopy.md Outdated
Comment on lines +150 to +151
- **Scalar `Real` expressions only**, matching Modelica's restriction; the
numeric fallback method is `homotopy(::Real, ::Real)`.

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.

Recommend broadcast.

Comment thread docs/src/basics/Homotopy.md Outdated
Comment on lines +150 to +152
- **Scalar `Real` expressions only**, matching Modelica's restriction; the
numeric fallback method is `homotopy(::Real, ::Real)`.
- **Failure surfaces as a failed initialization.** If the continuation cannot

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.

Obviously. Also, not relevant to the MTK side.

Comment on lines +131 to +143
homotopic = has_any_homotopy(isys)
if homotopic && TProb === SCCNonlinearProblem
# A continuation sweep deforms the COUPLED system along one λ; per-SCC
# sweeping is a separate future improvement. Root ModelingToolkit's
# `get_initialization_problem_type` prefers `SCCNonlinearProblem` for split
# fully-determined systems — force the monolithic target here.
TProb = NonlinearProblem
end
initprob = TProb{_iip}(isys, op; kwargs..., build_initializeprob = false, is_initializeprob = true)
if homotopic
initprob = _maybe_sweep_homotopy_initprob(initprob, isys, _iip, kwargs)
end
return initprob

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.

Split to the AbstractNonlinearProblem constructor.

rhss = [_iszero(eq.lhs) ? eq.rhs : eq.rhs - eq.lhs for eq in eqs]
end
else
# NOTE: generate_homotopy_residual in homotopy.jl mirrors the time-independent path here; keep in sync.

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.

?

)
)
end
λname = gensym(:__homotopy_λ)

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.

gensym will break precompilation. Instead use a hard to hit sentinal with unicodes.

Comment thread NEWS.md Outdated
@@ -1,3 +1,22 @@
# Unreleased

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.

? it'll release right away? Why write that here?

@ChrisRackauckas

Copy link
Copy Markdown
Member

Do one PR first with just the HomotopyProblem constructor and direct interface, and the AbstractNonlinearProblem stuff. Then do a follow up with initialization.

Comment thread docs/src/basics/Homotopy.md Outdated
Comment on lines +15 to +20
homotopy(actual, simplified)
```

During initialization the solver may start from the easy `simplified` form and
continuously deform it into `actual`; outside initialization the operator
evaluates to `actual`, as the Modelica specification prescribes.

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.

It should be more clear. The simplified equations are a set of equations for which the set of nonlinear equations is easier to get a convergent (Newton) iteration for, and the actual equations are the more complex equations you want to actually solve for. Homotopy solvers start by solving the nonlinear system with the simplified equations, and uses that solution as a starting point to solve the actual problem. There are many ways this can be used. For example if you have equations that have multiple solutions, like a quadratic equation with positive and negative solutions, you can simplify it down to an approximating linear problem which has one problem (the positive solution), and then deform it to the actual in order to stabilize the process of getting that positive solution.

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 also highlights that your implementation of homotopy has a bug since it doesn't start at lambda=0 😅

@BatchZero BatchZero force-pushed the feature/homotopy-lowering branch from 4706e06 to 8cea8ba Compare June 13, 2026 07:33
@BatchZero BatchZero changed the title Modelica homotopy(actual, simplified) operator for initialization (spec 3.7.4) Add the homotopy operator and HomotopyProblem construction Jun 13, 2026
@BatchZero

Copy link
Copy Markdown
Author

Restructured into PR 1 of 2 per your review. Force-pushed as a single commit (8cea8baa) and rewrote the description. The solver stack went upstream first and is registered: SciMLBase.HomotopyProblem (#1375, v3.19.0 — residual f(u, p, λ), λ a trailing argument, never an entry of p) and NonlinearSolveBase.HomotopySweep (#962, v2.31.0). This PR is an independent lowering pass targeting that MTK-independent API — lowering + targeting + tests + one docs page, no solver code.

Addressed here (PR A):

  • "Do one PR first with just the HomotopyProblem constructor and direct interface… then a follow up with initialization." — This PR is exactly that: the operator, the lowering pass, SciMLBase.HomotopyProblem(sys, op), and the automatic selection. Initialization wiring is the follow-up (PR B).
  • "It's just a nonlinear solver. Why even speak to initialization here?" — The docs page and the operator docstring are reframed around general nonlinear solving; the direct construction is the headline, initialization is mentioned as one consumer.
  • "There should be an AbstractNonlinearProblem constructor that is automatic" / "split it to the AbstractNonlinearProblem constructor." — Added SciMLBase.AbstractNonlinearProblem(sys, op): returns a HomotopyProblem when sys carries homotopy nodes, a NonlinearProblem otherwise (get_nonlinear_problem_type); the explicit HomotopyProblem(sys, op) is also provided. Detection + lowering + wrap now live in the constructor, not inside InitializationProblem.
  • the mirrored "?" codegengenerate_rhs gained an additive extra_args::Tuple = (); the swept residual threads λ through it, so the second copy and its "keep in sync" comment are gone. Byte-identical for the 9 existing generate_rhs callers (extra_args empty ⇒ p_end not forwarded ⇒ same build_function_wrapper call).
  • gensym breaks precompilation — λ is now a fixed reserved unicode sentinel __homotopy_λₘₜₖ (matching MTK's other ₘₜₖ internals), so codegen is a pure function of the system and the RuntimeGeneratedFunctions Expr-hash cache hits; a cheap guard turns the otherwise-silent user-symbol collision into an error.
  • "Recommend broadcast." — Documented and tested: homotopy.(actual, simplified) makes one node per element, all sharing the single λ.
  • NEWS "# Unreleased" — No NEWS.md change in this PR.

Deferred to the initialization follow-up (PR B): the Val{true} auto-injection you flagged as a bug, the initialization-specific doc bullets (trivial-init, failure-surfaces — not in this PR's general-solver docs), routing InitializationProblem through the constructor, and the hard NonlinearSolveBase dependency that the default-initializealg injection needs.

Separate, in NonlinearSolve: "It doesn't start at lambda=0… that's a bug." — Confirmed, and it's worse than robustness: the adaptive bisection only triggers on an inner-solve failure, so a bad u0 that converges on the wrong branch returns a success retcode for the wrong root (H = (1−λ)(u−4) + λ(u²−4) from u0 = −10 returns −2, not +2). The fix is an anchor solve at λ = λspan[1] in HomotopySweep — entirely in NonlinearSolve, now up as SciML/NonlinearSolve.jl#972 (a follow-up to #962); MTK is unchanged (the docs here already describe the sweep starting at λ = 0).

Deferred further: "should be split out to the SCC." — The structure supports a per-SCC sweep (SCCNonlinearProblem.probs has no element-type constraint, and HomotopyProblem subproblems already route to HomotopySweep), but it needs a small NonlinearSolve enabler first (probvec(::HomotopyProblem), a solve(::HomotopyProblem, ::AbstractNonlinearAlgorithm) rule, stalled-residual aggregation) plus per-SCC residual codegen — its own PR.

MTKBase tests green locally. Expected CI reds, pre-attributed: codecov upload (fork token), the repo-wide Runic FormatCheck (red on master; every line this PR touches is Runic-clean), and the usual downstream flakes.

Implement the `homotopy(actual, simplified)` operator as a general
nonlinear-solving construct: a `System` whose equations carry `homotopy`
annotations is lowered into a `SciMLBase.HomotopyProblem` whose residual is the
convex blend `(1 - λ)*simplified + λ*actual`, compiled as `f(u, p, λ)` and solved
by natural-parameter continuation (`NonlinearSolveBase.HomotopySweep`).

- systems/homotopy_operator.jl: `@register_symbolic homotopy` with the numeric
  fallback `homotopy(a, s) = a`; nodewise derivatives kept as opaque
  `homotopy(1, 0)`/`homotopy(0, 1)` nodes so jacobians and index reduction stay
  consistent under the blend; the `has_*homotopy` detection family; the fixed
  continuation sentinel `HOMOTOPY_LAMBDA` (not `gensym`, to keep codegen
  precompile-stable) with a collision guard; the structure-preserving
  `lower_homotopy` shadow pass; and `generate_homotopy_residual`.
- problems/homotopyproblem.jl: `SciMLBase.HomotopyProblem(sys, op)` builds the
  standard residual to obtain `u0`/`p`/observed, then swaps in the swept
  `f(u, p, λ)`. `λ` is a trailing argument, never added to `p`.
- problems/nonlinearproblem.jl: `SciMLBase.AbstractNonlinearProblem(sys, op)`
  auto-selects `HomotopyProblem` when `sys` contains `homotopy` nodes, otherwise
  `NonlinearProblem`.
- systems/codegen.jl: `generate_rhs` gains an additive `extra_args` to thread `λ`
  as a trailing scalar argument; empty by default, leaving the standard codegen
  path byte-identical.
- docs (basics/Homotopy.md, problems API reference) and tests for the operator,
  lowering, broadcasting, problem construction, auto-selection, the codegen
  guards, and pressure-drop parity.

Initialization wiring (injecting the continuation solver as the default
`initializealg`) is intentionally left to a follow-up PR.
@BatchZero BatchZero force-pushed the feature/homotopy-lowering branch from 8cea8ba to 543e24b Compare June 16, 2026 05:24
@BatchZero

Copy link
Copy Markdown
Author

@ChrisRackauckas a nudge on this one. The split you asked for is done: this is now PR 1 — the HomotopyProblem constructor plus the automatic AbstractNonlinearProblem(sys, op) selector, with initialization moved to the follow-up — and the per-comment mapping is in the comment above (Jun 13). It's a standalone lowering pass targeting the already-registered API (SciMLBase 3.19 HomotopyProblem, NonlinearSolveBase 2.31 HomotopySweep), no solver code of its own, so there's no upstream dependency left to wait on. Happy to rebase onto current master whenever you have a window.

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