nlls-gram 2.7.0: unified solver contracts, shape-aware AD defaults, float32/GPU correctness - #30
Merged
Merged
Conversation
… base class utility.py (1099 lines) splits into lm_types.py (status/hyperparams/state/ info/action/context/result), solve_loop.py (the jitted while_loop driver, its Python mirror, save_steps buffers), multi_start.py (MultiStart, DrawNNXModule, the three drivers), and utilities.py (tree selection/masking, static-key hashing, residual canonicalization). LMState/RidgeLMState and LMInfo/RidgeLMInfo merge into one pair each: the ridge fields default to None and stay None for the metric solver, where a None subtree costs nothing in the while_loop carry -- the same design the optional resid/Jt/aux slots already relied on. New LevenbergMarquardtBase holds what both solvers did identically: solve() and its custom_jvp implicit-AD wrapper, _solve_impl/_multi_start_impl, the callback-action plumbing, dense Jacobian assembly, and value-based __eq__/ __hash__. RidgeLevenbergMarquardt subclasses it and keeps only the ridge contract, through five hooks (_validate_tolerances, _solve_lm_state, _initial_ad_point, _check_action_state, _apply_action_state). The metric solver ports in a later commit; here it only follows the moved imports. Pure refactor: 596 passed, 12 skipped, unchanged from baseline. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
solver_config.py becomes linear_solvers.py and grows the contract the configs
were missing. Each config now supplies:
new_cache(m, n, n_m, dtype) -> the reject-step cache pytree, or None
prepare(Subproblem) -> StepSolver(grad, velocity, solve, accel_rhs,
make_cache)
so init() no longer branches on a solver-name string to allocate a cache, and
update() no longer carries three parallel if/elif blocks that each redefine
solve_step/accel_rhs. The QR path's extra machinery -- its backward-stable Q2
velocity route and the corrected semi-normal refinement -- lives behind
StepSolver.velocity instead of a fourth closure the other branches lacked.
CholeskyCache/QRCache move alongside the configs that build them, and
_resolved_solver() (the string the branches keyed on) is gone.
Dispatch still happens once at trace time, so the compiled program is
unchanged; Subproblem pins metric-callback outputs to the residual dtype in
one place rather than at eleven call sites.
Constructor validation drops the isinstance checks on user-supplied types and
the reserved metric_factory stub, keeping the mathematical invariants (ridge >
0, the damping schedule, a CG stopping rule) and the size checks. The ridge
solver still rejects a None ridge in update() -- with the merged LMState that
is now a legal state for the metric solver, so it is a real contract check
rather than defensive noise.
ridge_lm.py: 2043 -> 1114 lines. 596 passed, 12 skipped, unchanged.
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Both hook base classes gain the same optional pair: prepare(theta, ctx) -> traced state pytree, or None (the default) rebuild(ctx) -> traced predicate gating a rebuild, True by default The output rides on lm_state (metric_state/precond) and comes back as ctx.metric_state / ctx.preconditioner_state, rebuilt on accepted steps and reused across rejected ones. Whether a hook is stateful is a static property of its class, so the slots and their lax.cond compile away entirely for the stateless default. BlockEigenPreconditioner stops reading ctx.args[args_key]: it holds blocks_fn + permutation and eigendecomposes in prepare, so its state is no longer threaded through the residual args and rebuilt by hand from a solve callback. rebuild() is the knob for declining a refresh -- a stale preconditioner only changes the CG iteration path, never the converged step. MetricContext becomes SolverContext and moves to lm_types: it now serves the metric, the preconditioner, AND the linear solver, and carries the two new state slots. Under implicit AD the hooks are prepared once at the returned solution and not differentiated, the documented freeze contract. 594 passed, 12 skipped (four BlockEigenPreconditioner tests for the old args-threading contract replaced by two for the new one). Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
The metric solver moves onto everything the ridge solver already had.
GramMetric is gone. With M = F'F its four optional callables are the ridge
Metric's factor ops at identical cost -- inv_sqrt IS factor_solve, "whiten"
and "unwhiten" ARE the factor solves -- so both solvers now take one Metric,
and _validate_metric_requirements (the which-callbacks-does-this-solver-need
matrix) has nothing left to check. metric_from_cholesky/metric_from_diagonal
become CholeskyMetric/DiagonalMetric, and repeated_shifted_dense_metric
becomes RepeatedFactorMetric.from_gram, with the old zero-pad tail now the
solver's free block weighted by Metric.free_scale.
gram_lm.py becomes metric_lm.py and subclasses LevenbergMarquardtBase. The
eight-value string menu becomes the same typed configs the ridge solver takes:
Cholesky(form=auto|gram|normal), QR(), CG(precond), GramCG(precond). QR is now
the damping-row form, which is rank-safe and so subsumes both the old qr and
augmented_qr. The five preconditioner keywords (dual_, normal_, whitened_,
ad_solver_, and the factory) collapse into one typed Preconditioner whose
space the config names, which deletes the mutual-exclusion matrix and the
three missing_*_preconditioner branches along with it.
Seven AD tangent implementations become four. The undamped AD operator is
singular on whichever side the problem is deficient in, so each Krylov rule is
now offered only where its operator is invertible and says so loudly
otherwise -- previously CG returned a silently wrong tangent for n > m.
regularized_normal_cg becomes CG(precond, penalty=...), so ad_solver_penalty
stops being a free-floating kwarg that errors with five of seven methods.
Reverse mode through the unpenalized normal rule uses the push-through
identity N^+ = B'(BB')^{+2}B, since a cotangent does not lie in range(B').
Deleted: lsmr.py, recycled_cg.py, WhitenedPreconditioner, and the metric-LM
constructor's other 17 keywords (30 -> 13). quasiseparable and the state-space
metric move to experimental/ as StateSpaceMetric. The dual-space preconditioner
helpers become classes (Nystrom/Woodbury/ShermanMorrison/Padded).
Tests: 4697-line test_gram_lm.py plus test_normal_solvers, test_lsmr,
test_recycled_cg, test_augmented_qr, test_implicit_geometry,
test_metric_factory, test_preconditioner_factory, test_ad_solver_methods and
test_jacobian_mode (9700 lines) replaced by a 400-line test_metric_lm.py where
every check is against a closed form or an independent reference, plus a
trimmed float64 suite. 149 passed, 13 skipped in 48s (was 596/195s).
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
…lass of bug The solve loop marks the callback a jit STATIC argument, so ridge_continuation() returning a fresh closure meant every construction recompiled the entire loop. It now returns a frozen RidgeContinuation dataclass, value-hashable on (ridge_floor, decrease, grad_rtol, stall_rtol), so equal schedules share one compiled loop. tests/test_compilation.py pins the whole class of regression, measured against the jitted loop's own compilation cache rather than a proxy. It asserts one compilation for: solvers rebuilt repeatedly with equal configs, traced values and loop controls changing, a reused metric across solver rebuilds, a freshly constructed IdentityPreconditioner, a rebuilt continuation callback, and the realistic driver that reconstructs the solver every iteration. It also asserts the negative direction -- a changed shape, linear solver, static scalar, or continuation schedule IS a different program -- so the guards cannot pass by never compiling anything. Also confirmed while writing these: the +1 residual evaluation per solve is init()'s eager sizing pass, not a retrace. 161 passed, 13 skipped. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Docs go 4252 -> 1743 lines. index.md leads with the one thing that distinguishes the solvers (where the selection of the root lives) as a table, then the residual interface and the solver menu; the eight per-solver math subsections it used to carry duplicated tuning_guide and utilities. gauss_newton.md and metrics.md become metric_lm.md (the damping geometry and its minimum-norm limit, with the rank-deficiency table that says which ad_solver is valid at which shape). utilities.md -- a grab-bag of eleven unrelated helpers, a third of them for deleted features -- becomes metrics.md, covering just the two hook types and why one must be exact and the other need not be. Every mkdocstrings stub moves into api.md, so a removed name breaks one file instead of six. tuning_guide.md and implicit_ad.md are rewritten around what survives; llms.txt (orphaned, not in the nav) is deleted. Benchmarks move to the typed configs and the new metric constructors; the metric-factory case becomes a prepare()-based metric, which is what it was measuring. The augmented_qr benchmark goes with the feature. 37 CPU benchmarks run clean. Also deleted: FAILED_IMPLICIT_AD_PLAN.md and the stale benchmarks/results artifacts from the 2026-07 investigation. mkdocs build --strict passes. 161 passed, 13 skipped. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
From an external review. Five real bugs, all reproduced before fixing: 1. The ridge solver accepted GramCG, whose dual operator never sees the penalty rows -- it returned the UNPENALIZED step (1.0 where the ridge step is 0.5) with no complaint. It only rejected Cholesky(form="gram"). 2. The normal-CG implicit VJP's push-through transpose reused the parameter-space preconditioner for its two dual solves, handing an m-vector to a hook expecting n. Those solves are now unpreconditioned, which is what residual space requires here. 3. ad_solver=QR() was accepted and silently ran dense Cholesky; the ridge solver treated QR/SVD/GramCG as normal CG; forward SVD() failed only later inside update. 4. Both init methods built the hook context WITHOUT lm_state, so a metric or preconditioner prepare() reading ctx.lm_state.damping -- documented as available -- crashed on the first call. 5. BlockEigenPreconditioner unconditionally read ctx.lm_state.ridge, which is None under LevenbergMarquardt, so it could not serve the metric solver at all. (3) is fixed structurally rather than case by case: each config declares supports_forward / supports_ad / supports_penalty, and one shared _validate_configuration in the base rejects the combination at construction. Cholesky.form and jacobian_mode now reject unknown values instead of falling through to a different algorithm, and the metrics validate free_scale > 0 and size >= 0 (a non-positive free_scale makes the whitening noninvertible). Also from the review: _cold_state and _block_sizes were still duplicated between the solvers and move to the base; LMState.recycle and LevenbergMarquardt.ridge were dead after their features were removed; the ridge module and LMInfo docstrings still described blockdiag(F, I) and metric-LM grad_norm as ||J'r||, both stale since whitening became shared. Tests: the reject-step cache test was silently SKIPPING (its fixture no longer produced a rejected step) and now exercises the path; the padded-SVD test checked only the primal, where ad_solver affects only the tangent; added coverage for role rejection, free_scale's effect on step and tangent, and a tall normal-CG VJP with a dimension-specific preconditioner (which is what catches bug 2). 165 passed, 12 skipped. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
From a second (JAX-focused) review. The serious one: _apply_action invalidated the Jacobian cache when a callback replaced x or args, but not metric_valid or precond_valid. update() sets metric_valid = ~improved, so after a REJECTED step it is True; a callback that then installed a new x left the metric state prepared at the old iterate in place and the next step reused it. The metric defines the subproblem, so that silently solved a different problem in a different geometry. All three validity flags now get the same treatment. Also fixed: - solve() called hyperparams() with no dtype, so CG(tol=None) resolved against the JAX default float. On a float32 problem under enabled x64 that gave tol=1e-10 -- unreachable, since float32 eps is 1.2e-7 -- and every inner CG burned its full maxiter for the whole solve. The sentinel now resolves in _cast_hyper, where the residual dtype is known. - The solve callback was not passed through _hashable_hook, so a callback written to the frozen-dataclass pattern the docs recommend died with a raw "Non-hashable static arguments" error if it held an array. draw and accept already had this. - ad_solver=None under CG(penalty=...) inherited the preconditioner but not the penalty, silently discarding the option -- and leaving the AD solve on the singular operator whose explicit form the guard rejects. - Cholesky's docstring called gram-vs-normal "a cost choice, not a semantics choice". True in exact arithmetic; false in floating point for m > n, where the dual carries m-n structural zero eigenvalues and loses ~1e-2 relative at damping=1e-14 against the normal form's 1e-15. form="auto" never picks gram there, so the default was always safe; the docstring now says which is. Documented rather than changed: only result.x/aux/p carry tangents. info (including info.loss), lm_state, steps, and the histories get exact zero, which jax.grad reports as a silent zero on an otherwise natural bilevel objective. Damping and step norms are path artifacts, not properties of the root, so the contract is right -- it just was not written down. Dead: canonicalize_ad_preconditioner (unreferenced, documents the removed string API). Metric.norm's "must match to floating-point accuracy" contract was vacuous -- neither solver calls it. test_compilation.py was blind to the multi-start drivers' own jit caches; it now counts all three and adds the trap the file was missing: a residual lambda rebuilt per call recompiles every time (3 solves, 3 programs), while a module-level one compiles once. Also pins save_steps making max_steps static, and a stateful preconditioner reused across solver rebuilds. 169 passed, 12 skipped. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
…hifted Gram themselves Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
… LMState Instances register via register_pytree_dataclass (bypass-unflatten, statics as type-tagged aux), ride in lm_state.metric/preconditioner, and every traced read goes through the carried instance -- rebuild is calling the constructor again inside the solve callback. Compile identity keys on instance structure, so equal-config fresh instances share one loop. Deletes prepare/rebuild, hook-state fields and machinery, block_eigen_state, and the ridge_continuation factory (AnnealRidge, with init_state, replaces it); renames LMSolveAction/LMSolveContext to LMAction/LMContext. A ridge-solver metric change suppresses that step's convergence test and stales the solver caches; the metric solver only stales caches; preconditioner refreshes are free. ad_solver=CG(None, ...) inherits the carried forward preconditioner at the solution with pinned AD knobs; failed lanes differentiate at the pre-loop instances. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
New pins: same-treedef different-valued instances give different answers under the shared compile; callback swaps neither recompile nor (for preconditioners) invalidate; ridge metric swaps suppress that step's convergence while metric-solver swaps do not; the untouched-instance identity short-circuit emits no comparison ops; GramCG equation-space preconditioning with a mid-solve refresh plus a CG-budget schedule; the implicit tangent uses the carried metric at the solution. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
…ce guard A mutated solver attribute would keep the stale static key and silently reuse another configuration's compiled loop, so assignment now raises. The callback structure guard compares leaf weak types too: a weak/strong scalar swap passes the carry's physical checks while retracing the body under different promotion rules. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Both knobs guarded failure modes that are not failures. Without an upper cap, repeated rejections can overflow damping to inf in float32 -- but the step then goes to zero, every trial is rejected, x stays at the last accepted iterate, and the solve ends at MAX_STEPS with the best point it found. The dangerous case, a stall reported as convergence, is already blocked in _converged by `xtol_met & info.accepted`: a stalled solver only produces rejected steps, so xtol cannot fire. max_damping turned MAX_STEPS into MAX_STEPS. min_damping as a user knob was equally inert. It could only ever RAISE the floor (the resolver takes a max against the dtype floor), so the documented advice to "lower it (e.g. 1e-12)" was impossible to follow and would have introduced the endgame truncation it warned about. Damping falling freely is the point: the endgame wants it to vanish so the step approaches Gauss-Newton and the minimum-norm limit. Every test that pinned min_damping=1e-12 passes without it, against a floor 296 orders of magnitude lower. What remains is the internal floor at finfo(dtype).tiny, which guards something genuinely irreversible: the update is multiplicative, so a damping in a backend's flush-to-zero range is absorbing -- 0 * damping_increase stays 0 and the solver could never re-damp again. That floor is far below the scale at which damping still perturbs the Gram diagonal, so it is an anti-underflow backstop, not regularization; RidgeLevenbergMarquardt is the tool for that. A callback clamp replaces both knobs for the rare problem needing a bound, which is where 2.7.0 puts adaptive policy anyway. Behavior is unchanged for every caller that did not pass them: min_damping None already resolved to tiny and max_damping None already skipped the upper clamp. spooky's full replication is bit-identical across the change (387 leaves; only train_time moves) and a warm repeat run still writes zero .jax_cache entries. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
…therwise) 2.6/2.7 collapsed the AD rules onto Cholesky: ad_solver=None resolved to the assembled normal/dual factorization at every dense shape. That lost both of 2.4.0's shape-appropriate defaults, and each loss is a real defect. Square (a DAE root, a determined system): 2.4.0 factored J itself via ad_solver="direct". Cholesky computes the same map through B'B, at cond(B)^2. Measured on a square constraint in float64, the tangent error tracked cond^2 * eps instead of cond * eps -- 2.6e-10 at cond=1e4, 7.1e-06 at 1e6, and 1.5e-01 at 1e8, where the correct answer is 1.3e-09 and the solve still reports CONVERGED. The new LU config restores it. Squareness is the exact condition that makes a plain solve valid: the tangent is unique, so no norm is minimized and the metric selects nothing, which is also why the square path can skip the whitening round-trip the rectangular rules need. One factorization serves both directions. LU is ad_solver-only (the damped forward subproblem is SPD at every shape) and raises on a rectangular system rather than guessing. Rectangular: 2.4.0 resolved to SVD, and this is the more serious loss. The undamped dual is singular whenever the small side is rank deficient, which is not exotic -- test_float64_svd_ad_solver_near_duplicate_rows already documents the growth-model pathology where a converged simulation duplicates its late-horizon states to ~1e-13. That test pinned the behavior with an explicit ad_solver=SVD(), so it kept passing while the default silently went to Cholesky. spooky's notebooks/growth_recursive_advanced.py, which differentiates a neural solve against a closed-form steady state, returned an all-NaN Jacobian under the Cholesky default and returns the analytically correct one under SVD. Verified across consumers: tinydiffeq 251 passed with the DAE reverse-mode regression closed (vjp-vector16-dae +18.4% -> -1.6%, worst DAE case +22.4% -> +3.2%); spooky's replication bit-identical on 385/385 non-timing leaves with zero new .jax_cache entries; kernels' multicountry AD experiment unchanged at its analytic neutrality bound (1.40e-10 both ways); spooky's advanced AD example NaN -> correct. Cholesky() remains available as the opt-in for a rectangular system whose small side is known to have full rank, trading tangent accuracy for speed. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
nlls-gram has no ad/metric solve-dtype knob -- the 2.6/2.7 refactor removed linear_solve_dtype, metric_solve_dtype and ad_dtype -- so a float32 problem must stay float32 end to end, at float32 accuracy, with no promotion to recover conditioning. Nothing pinned that. tests/test_float32.py runs at default precision and covers, for each config, both dtype purity and accuracy against a float64 reference, so a silently promoted solve and a silently wrong one each fail: dense forward solvers (Cholesky auto/gram/normal, QR), matrix-free (CG, GramCG), the implicit-AD rules on a rectangular system (auto->SVD, SVD, Cholesky, GramCG) and on a square one (auto->LU, LU, SVD), the metrics, and every preconditioner. test_float64_subprocess.py gains the harder direction: x64 ENABLED with float32 inputs, where Python scalars default to f64 and can promote the whole compute silently. Across 18 configurations no primal, jvp or vjp jaxpr contains an f64 compute op; f64 appears only in explicit convert_element_type at the call boundary. This also gives NystromPreconditioner, WoodburyPreconditioner, ShermanMorrisonPreconditioner and PaddedPreconditioner their first tests -- only BlockEigen and Identity had any. The apply contract is checked for float32, finiteness and SPD (v'Mv > 0 plus symmetry on a random pair), and GramCG is run end to end under each dual preconditioner to confirm a preconditioner changes the CG path but not the converged step. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
XLA:GPU serves float32 dot_general from TF32 tensor cores by default on Ampere and later: a 10-bit mantissa, so ~1e-3 relative where float32 is ~1e-7. Forming a Gram or normal matrix already squares the condition number, so paying TF32 on top spends about three decimal digits before the factorization starts. That is not a trade this package should be making silently on a user's behalf. It was doing exactly that. Eleven tests fail on an RTX 3090 and pass on CPU -- cross-solver step agreement, the metric factor round-trip, the block-eigen apply against its dense inverse, and the CG-vs-Cholesky implicit tangent -- every one of them a comparison routing through a product. They are not a regression: the same eleven fail at 0643a49, before any of this branch. They are also not tolerance problems. On the 3090: default (TF32) 11 failed, 20 passed JAX_DEFAULT_MATMUL_PRECISION=highest 31 passed Every product the package owns now goes through utilities.mm (or passes precision=HIGHEST to einsum): the Gram and normal assembly, the matrix-free CG operator, the QR back-substitution, the metric factor applications, all six preconditioners, the state-space metric scans, and the implicit-AD tangent solves. Unconditional, with no opt-out knob -- a float32 solve now answers the same on GPU as on CPU, where the setting is a no-op. The metric round-trip is the tell for why this is the right layer: factor_apply is a matmul and factor_solve is a triangular solve, so each op looked fine alone while the round-trip drifted. tests/conftest.py sets jax_default_matmul_precision globally, which the package's own pinning does NOT cover: the tests build dense references of their own, and without it those would stay TF32 and the comparison would fail on the reference side. To keep that from masking the library change, a new test traces a solve whose residual contains no matmul -- so every dot_general in the jaxpr is the solver's -- and asserts each carries HIGHEST. Asserting on the jaxpr rather than on numbers makes it device-independent, so CPU CI guards the GPU path; there is no GPU runner. The float32 tolerances are untouched. They sit above the TF32 floor, which is why all 26 float32 tests passed on GPU while the library was degraded -- they now pass for the right reason. docs/tuning_guide.md gains a section on the trap, what the package pins, what it cannot reach (matmuls in the caller's residual), and the failure signature: CPU and GPU agreeing to about three digits and no further, worst in the tangent. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
Measured on the 3090: the package pinning its own products leaves a dense float32 solve at 3.3e-4 relative, because the Jacobian is differentiated through the caller's residual matmuls and arrives already carrying TF32 error. Setting jax_default_matmul_precision=highest takes the same solve to 1.6e-7. Since a residual here is usually a network or a kernel evaluation, that is the normal case. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
…ergence test_callback_refresh_reaches_the_next_inner_solve failed in CI on x86 while passing on ARM. It is not a regression -- it fails identically at 0643a49 -- and it was never visible before because the test is branch-only (added by c8e11e2) and CI runs only on main and PRs, so this branch's 21 commits had never been through it. The mechanism is fine. Swapping a preconditioner into the carried state produces a BIT-IDENTICAL step to a solver built with that instance, on both x86 and ARM (measured: ||swapped - exact|| = 0.0). What the old assertion actually measured was the float32 endgame. The residual is linear, so with a starved maxiter=3 budget the solve reaches the float32 loss floor, no trial step strictly improves the loss any more, every step is rejected, and damping ratchets to ~3e4. Whether gtol=1e-5 sits above or below that floor is platform arithmetic: ARM reached 4.5e-6, x86 stopped at 6.0e-5 and stayed there at 60, 200 and 600 steps. Exact-from-start converges to ~1e-6 on both, which is what made the difference look like a mechanism failure rather than a stalled endgame. So the convergence assertion is replaced by the contract it was standing in for: the step taken with a swapped-in instance equals the step a solver built with it takes from the same state, and differs materially from the stale one so the check is not vacuous. The end-to-end callback path still asserts the refreshed instance is the one carried out, that a starved scrambled run does not converge, and that refreshing strictly improves stationarity -- which holds on both platforms without depending on where the float32 floor lands. Co-Authored-By: Mecha Perla (Claude) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014og23CSBQfdHGNCfA21F8x
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release branch for 2.7.0. Breaking, and the breaks are deliberate.
Carried instances (the headline refactor)
Metric and preconditioner instances are pytrees carried in
LMState, and alladaptive policy moved into the per-step callback.
LMSolveActionis nowLMAction.Shape-aware implicit-AD defaults, restored
2.6 had collapsed both of 2.4's AD defaults onto
Cholesky. Each loss was areal defect:
Jdirectly;Choleskycomputes the same mapthrough
B'B, atcond^2. Measured in float64: 2.6e-10 at cond 1e4, 7.1e-6at 1e6, and 1.5e-1 at 1e8, where the right answer is 1.3e-9 and the
solve still reports CONVERGED. The new
LUconfig restores it.SVD. The undamped dual is singularwhenever the small side is rank deficient, which padded zero residuals
produce by construction. spooky's
growth_recursive_advanced.pyreturned anall-NaN Jacobian under the Cholesky default and returns the analytically
correct one under SVD.
test_float64_svd_ad_solver_near_duplicate_rowsdocuments that exact pathology but pins
ad_solver=SVD()explicitly, so itkept passing while the default moved out from under it.
autonow resolves square ->LU, rectangular ->SVD, which is 2.4's rule.float32 on GPU
XLA:GPU serves float32
dot_generalfrom TF32 tensor cores on Ampere: a10-bit mantissa, ~1e-3. Forming a Gram matrix already squares the condition
number, so TF32 spends ~3 decimal digits before the factorization. Eleven
tests failed on an RTX 3090 and passed on CPU; the same eleven failed before
this branch, so it was long-standing, not a regression.
Every product the package owns is now pinned to
Precision.HIGHEST. That isnecessary but not sufficient: the Jacobian is differentiated through the
caller's residual matmuls, so callers must also set
jax_default_matmul_precision="highest"(3.3e-4 -> 1.6e-7 on a dense float32solve). Documented in the tuning guide.
Removed
min_damping/max_damping. Both guarded non-failures -- unbounded dampingends at MAX_STEPS with the best iterate, and
_convergedalready blocks astall reading as convergence via
xtol_met & info.accepted. The internalanti-underflow floor at
finfo(dtype).tinystays: the update ismultiplicative, so zero is absorbing.
Verification
--float32clean, first-ever baselineNew float32 module (dtype purity + accuracy across the config matrix), an
x64-with-float32-inputs promotion test over 18 configs, and the first tests
for
Nystrom/Woodbury/ShermanMorrison/Paddedpreconditioners.Downstream
Publishing this breaks published tinydiffeq 2.2.0, which declares
nlls-gram>=2.4.0and passes 17 kwargs that no longer exist. v2.6.0 stillaccepts them, so the break starts here. A tinydiffeq release with a
>=2.7.0floor follows immediately.