feat(solvers): default-on solve reporting + bounded/resumable difficulty solve - #377
Conversation
…lty solve Two general capabilities on SolverBaseClass, so every SNES-based solver gets them (Stokes, scalar, vector, and the rotated-free-slip KSP path): 1. Default-on difficulty reporting. Every solve() records a SolveReport (new pure-Python module systems/solve_report.py) exposing the converged reason, nonlinear/linear iteration counts, final and initial ||F||, the residual reduction, contraction rho, function-evaluations, and the residual ladder. Read-only via solver.solve_report; a bounded solver.solve_history (deque maxlen=32) keeps a short trail for continuation / time-stepping loops. Captured at the single solve chokepoint (_snes_solve_with_retries) so no solve() body needs editing; the rotated-free-slip path (which solves via ksp.solve, bypassing the chokepoint) gets an honest report from its own result rather than a stale one. 2. Bounded, resumable difficulty estimate. solver.estimate_difficulty( max_nl_its, warm=...) caps NONLINEAR iterations (predictable solver work, not wall-time), reports the effort spent, and continues losslessly from the partial iterate. A chunked start/stop/restart chain terminates at the SAME point an uninterrupted solve would: the chain anchors convergence to the original ||F0|| (absolute tol = tolerance*||F0||), which is looser than the restart-relative rtol*||F_restart|| and so fires first. Gated on an internal probe flag, so a plain solve() is byte-for-byte unchanged and cannot inherit a stale anchor. Reporting is default-on; the bounded/resumable knob is opt-in. Behaviour of a normal solve is preserved (only additions on that path are a guarded setConvergenceHistory and a read-only report capture). Tests: tests/test_1055_solve_report.py (default-on report on Stokes + scalar; bounded stop at the cap; lossless resume matching an uninterrupted reference; chunked chains terminate at the same ||F||). Regression: level_1/tier_a green (403 passed, 0 failed, 0 errors). Underworld development team with AI support from Claude Code
Post-merge adversarial review (04898b1)This PR merged without its public adversarial review; this is that review, run post-merge against the squash commit on development. An independent skeptic session probed every claim in the merge message with live serial and np2/np4 runs (probe scripts referenced by name; environment verified against site-packages before every probe). Findings will be addressed in an immediate follow-up bugfix PR. Verdict: 12 findings — 3 MAJOR, 3 MINOR, 6 NOTE. No CRITICAL. The core machinery (plain-solve isolation, chunked-chain equivalence, per-instance state, parallel consistency) survived every attack. The rotated-free-slip reporting — explicitly claimed in the merge message — is broken on the nonlinear path, and the resume-anchor has an unmanaged lifecycle. Findings (ranked)1. MAJOR — Nonlinear rotated free-slip solve leaves
|
… anchor lifecycle (PR #377 review) (#437) Fixes the three MAJOR and three MINOR findings from the post-merge adversarial review of PR #377: 1. _capture_rotated_report now honours both rotated result dicts: the nonlinear loop's ksp_its LIST (summed), nonlinear_iterations, and the outer converged verdict. Previously a TypeError on the list was swallowed by a blanket except, leaving solve_report = None after every nonlinear rotated solve. The blanket swallow is gone: a malformed dict now raises (the dicts and the reader are a contract; the swallow is what masked the breakage). 2. KSP converged-reason codes get their own table (KSP_ prefix) — they were rendered with SNES names that share integers but mean different outcomes (e.g. -3: KSP DIVERGED_MAX_IT vs SNES DIVERGED_LINEAR_SOLVE). A test pins the table against petsc4py's enum. 3. Both rotated paths report a real residual: the linear solve computes the true rotated residual (preonly/LU never computes a norm; iterative norms can be preconditioned), the nonlinear loop returns its final/initial |F|. 4. estimate_difficulty raises NotImplementedError with rotated free-slip BCs (that path solves outside self.snes, so the cap and anchor cannot reach it — it previously ran unbounded and returned None), and rejects zero_init_guess / divergence_retries kwargs that would break the probe contract. 5. Anchor lifecycle: a CONVERGED probe clears the resume anchor (a warm first call on a converged state previously armed an unreachable target at tolerance*|F_converged| and ground extra iterations with a misleading exit), and _reset clears it (a torn-down solver is a different problem). 6. Report capture moved into the finally of _snes_solve_with_retries: a solve that raises now leaves an honest report of the failed attempt instead of the previous solve's converged one. Tests: 5 new cases in test_1055 (rotated linear + nonlinear reports, rotated / kwarg rejection, converged-warm anchor hygiene, KSP table pinned to petsc4py). Underworld development team with AI support from Claude Code
What
Two general capabilities on
SolverBaseClass, so every SNES-based solver gets them (Stokes, scalar, vector, and the rotated-free-slip KSP path):1. Default-on difficulty reporting. Every
solve()records aSolveReport(new pure-Python modulesystems/solve_report.py) with the converged reason, nonlinear/linear iteration counts, final and initial||F||, residual reduction, contractionrho, function-evaluations, and the residual ladder. Read-only viasolver.solve_report; a boundedsolver.solve_history(deque, maxlen=32) keeps a short trail for continuation / time-stepping loops. Captured at the single solve chokepoint (_snes_solve_with_retries) so nosolve()body needs editing; the rotated-free-slip path (which solves viaksp.solve, bypassing the chokepoint) gets an honest report from its own result rather than a stale one.2. Bounded, resumable difficulty estimate.
solver.estimate_difficulty(max_nl_its, warm=...)caps nonlinear iterations (predictable solver work, not wall-time), reports the effort spent, and continues losslessly from the partial iterate. A chunked start/stop/restart chain terminates at the same point an uninterrupted solve would: the chain anchors convergence to the original||F0||(absolute tol =tolerance*||F0||), which is looser than the restart-relativertol*||F_restart||and so fires first. Gated on an internal probe flag, so a plainsolve()is byte-for-byte unchanged and cannot inherit a stale anchor.Why
Groundwork for adaptive continuation / homotopy control: a solver should expose its difficulty and let a driver bound it to estimate difficulty cheaply, then resume without losing work. Reporting is default-on; the bounded/resumable knob is opt-in.
Safety
Behaviour of a normal solve is preserved -- the only additions on that path are a guarded
setConvergenceHistory(reset=True)and a read-only report capture.estimate_difficultyrestores tolerances after each call.Tests
tests/test_1055_solve_report.py: default-on report on Stokes + scalar; bounded stop at the cap; lossless resume matching an uninterrupted reference; chunked chains terminate at the same||F||.Regression:
level_1 and tier_agreen -- 403 passed, 0 failed, 0 errors (1 pre-existing unrelated xfail).Underworld development team with AI support from Claude Code