Skip to content

Support inner=pcg/exact in solve_nnqp_eq - #17

Merged
tschm merged 2 commits into
mainfrom
eq-inner-solver
Jul 4, 2026
Merged

Support inner=pcg/exact in solve_nnqp_eq#17
tschm merged 2 commits into
mainfrom
eq-inner-solver

Conversation

@tschm

@tschm tschm commented Jul 4, 2026

Copy link
Copy Markdown
Member

Follow-up to #16 (now merged): solve_nnqp_eq gains the same inner-solver choice solve_nnqp offers.

What

solve_nnqp_eq now takes inner ("cg"/"pcg"/"exact") and cg_maxit. Previously it hardcoded plain CG.

Both entry points now share one free-block solver factory _make_free_solve(op, inner, cg_tol, cg_maxit) -> (idx, rhs, x0) -> (y, iters), which dispatches cg/pcg/exact, lazily caches the Jacobi preconditioner across a free set, guards the exact path's rcond_free check, and raises ValueError eagerly on an unknown inner. This removes the inner-solver dispatch that had been duplicated (and had drifted) between the two solvers.

Behaviour on the equality path

All p + 1 Schur right-hand sides (v0 + p columns) go through the chosen backend:

  • exactop.solve_free replaces all p+1 CG calls (natural, since they share A_F).
  • pcg — one Jacobi preconditioner off op.diag, reused across all p+1 RHS.
  • Warm start stays cg-only (as in solve_nnqp).

Tests (coverage 100%)

  • test_eq_exact_inner_matches_cg — exact recovers the optimum and visits the same free set as CG (equality analogue of the inexactness lemma).
  • test_eq_pcg_inner_recovers_optimum — PCG recovers the planted optimum, feasible.
  • test_eq_pcg_beats_cg_under_diagonal_scaling — on a new make_scaled_eq_problem generator (planted eq optimum under a bad diagonal scaling), PCG needs fewer inner iterations than CG.
  • test_eq_rejects_unknown_inner — bad inner raises.

Gates

make typecheck (ty + mypy strict), make test (61 passed, 100% coverage), make fmt, make deptry — all green.

🤖 Generated with Claude Code

Extend the equality-augmented solver with the same inner-solver choice
solve_nnqp already offers. Both entry points now share a single free-block
solver factory (_make_free_solve), which dispatches cg/pcg/exact, caches the
Jacobi preconditioner across a free set, and guards the exact path's rcond
check — removing the duplicated dispatch that had drifted between the two.

solve_nnqp_eq gains `inner` and `cg_maxit`; all p+1 Schur right-hand sides go
through the chosen backend (exact via solve_free replaces all p+1 CG calls;
pcg preconditions each once). Warm start stays cg-only, as before.

Tests: exact matches the cg trajectory, pcg recovers the optimum, pcg beats
cg on a new diagonally ill-scaled eq generator (make_scaled_eq_problem), and
an unknown inner is rejected. Coverage 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 4, 2026 04:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the equality-constrained solver solve_nnqp_eq to support the same inner-solver choices as solve_nnqp ("cg", "pcg", "exact"), factoring shared free-block solve logic into a common factory to avoid duplicated dispatch.

Changes:

  • Add inner and cg_maxit parameters to solve_nnqp_eq, enabling "cg"/"pcg"/"exact" backends.
  • Introduce _make_free_solve(...) to centralize inner-solver dispatch and (for PCG) reuse a Jacobi preconditioner across solves.
  • Add new equality-path tests and a new scaled equality problem generator to validate "exact"/"pcg" behavior and error handling.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/nncg/solver.py Adds _make_free_solve and wires solve_nnqp_eq to accept/dispatch inner backends.
tests/test_eq.py Adds tests covering inner="exact", inner="pcg", iteration comparisons, and unknown-inner rejection.
tests/problems.py Adds make_scaled_eq_problem to generate diagonally ill-scaled equality problems for preconditioning tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/nncg/solver.py
Comment on lines +151 to +165
dinv: Vector | None = None # Jacobi preconditioner, read off op.diag on first use

def free_solve(idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
nonlocal dinv
if inner == "exact":
rcond = op.rcond_free(idx)
if rcond < _RCOND_MIN:
msg = f"free block of size {idx.size} is numerically singular (rcond={rcond:.2e})"
raise ValueError(msg)
return op.solve_free(idx, rhs), 1
if inner == "pcg":
if dinv is None:
dinv = 1.0 / op.diag
return pcg(_free_matvec(op, idx), rhs, dinv[idx], tol=cg_tol, maxit=cg_maxit)
return cg(_free_matvec(op, idx), rhs, tol=cg_tol, maxit=cg_maxit, x0=x0)
Comment thread src/nncg/solver.py
Comment on lines 421 to 425
p_max: int = 3,
inner: InnerSolver = "cg",
cg_maxit: int = 100_000,
warm: tuple[NDArray[np.bool_], Vector] | None = None,
) -> Result:
- #18: memoise the exact-path rcond_free guard per free set in
  _make_free_solve, so solve_nnqp_eq's p+1 same-idx solves pay for one
  conditioning estimate, not p+1.
- #19: replace the bare `r_pcg.inner < r_cg.inner` assertions with a 0.7x
  margin on large diagonal spreads, robust to BLAS-dependent iteration drift.
- #20: forward track and max_outer from solve_nnqp_eq to _active_set_loop,
  matching solve_nnqp; add trajectory-recording and outer-cap tests.

Closes #18
Closes #19
Closes #20

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tschm
tschm merged commit e11e8d3 into main Jul 4, 2026
55 checks passed
@tschm
tschm deleted the eq-inner-solver branch July 4, 2026 04:43
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