Coverage for src/nncg/solver.py: 100%

55 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-01 07:21 +0000

1"""Non-negative conjugate gradients: the active-set / block-principal-pivoting loop. 

2 

3Solves the strictly convex non-negative quadratic program 

4 

5 min_{x >= 0} 1/2 x^T A x - b^T x, A symmetric positive definite, 

6 

7and its equality-augmented variant with a general linear system ``B x = c``, 

8by wrapping a matrix-free inner solver in a primal-dual active-set outer loop. 

9The working-set toggles are the principal pivots of the linear complementarity 

10problem LCP(A, -b); guarding the fast block-pivot path with a least-index Bland 

11fallback gives unconditional finite termination at the unique global minimiser 

12— no non-degeneracy assumption (Theorem 5.1 of the accompanying paper). See 

13https://github.com/Jebel-Quant/mean_variance_solvers. 

14 

15:class:`ActiveSetSolver` is the outer loop and the entry point. It knows nothing 

16about preconditioning: it asks its :class:`nncg.inner.InnerSolver` for a 

17per-free-block solve and drives the pivots around it. The quadratic term enters 

18as a :class:`cvx.linalg.SymmetricOperator`, accessed only through block products 

19— wrap an explicit SPD array in ``DenseOperator``, or pass ``GramOperator(M, 

20ridge)`` for ``A = M^T M + ridge I`` so the ``n x n`` matrix is never formed. 

21""" 

22 

23from __future__ import annotations 

24 

25from dataclasses import dataclass, field, replace 

26from typing import Protocol 

27 

28import numpy as np 

29from cvx.linalg import Matrix, SymmetricOperator, Vector 

30from numpy.typing import NDArray 

31 

32from ._active_set import ReducedGradient, SubSolve, _drive 

33from ._equality import _eq_feasible, _require_eq_shapes, _saddle_solve 

34from .certificate import _require_operator 

35 

36 

37class InnerSolver(Protocol): 

38 """The inner-solver interface the active-set loop depends on (dependency inversion). 

39 

40 Structural (a :class:`typing.Protocol`): anything with a matching 

41 :meth:`solve` is an inner solver, so implementations need neither import 

42 nor subclass this — this module (the high-level loop) owns the interface, and 

43 the implementations depend on it, not the other way round. The built-ins live 

44 in :mod:`nncg.inner` (:class:`~nncg.inner.CG`, :class:`~nncg.inner.Jacobi`, 

45 :class:`~nncg.inner.Nystrom`, :class:`~nncg.inner.Exact`); further ones — 

46 Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers. 

47 """ 

48 

49 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: 

50 """Solve the free-block system ``A[F, F] y = rhs``, warm-started at ``x0``. 

51 

52 Returns the free-block solution and the inner iteration count (each 

53 direct solve counts as one). Called once per outer step by the 

54 bound-constrained loop, and once per ``p + 1`` right-hand side per outer 

55 step by the equality-augmented loop. 

56 """ 

57 ... 

58 

59 

60@dataclass(frozen=True) 

61class ActiveSetConfig: 

62 """Configuration of the active-set outer loop (:class:`ActiveSetSolver`). 

63 

64 Bundles the outer-loop knobs into one argument; the inner solver and its 

65 tolerances live in :class:`nncg.inner.InnerSolver`, and the warm start stays 

66 a separate argument. 

67 

68 Attributes: 

69 tol: Threshold of the primal and dual KKT violator tests. 

70 p_max: Patience budget — non-improving batch steps tolerated before a 

71 least-index Bland fallback pivot. Any value gives finite termination. 

72 track: Record the visited free-set trajectory in ``Result.traj``. 

73 max_outer: Optional cap on outer steps; when hit, the current iterate is 

74 returned with ``converged=False``. 

75 """ 

76 

77 tol: float = 1e-8 

78 p_max: int = 3 

79 track: bool = False 

80 max_outer: int | None = None 

81 

82 

83@dataclass(frozen=True) 

84class Result: 

85 """Outcome of an active-set solve. 

86 

87 Attributes: 

88 x: The minimiser (or the final iterate if ``converged`` is False). 

89 outer: Number of outer active-set steps taken. 

90 inner: Total inner (CG/PCG) iterations across all outer steps; each 

91 direct inner solve counts as one. 

92 fallback: Number of least-index Bland fallback pivots taken. 

93 converged: True when the KKT exit was reached; False when an 

94 ``max_outer`` cap stopped the loop first, or (equality-augmented 

95 solves) when the final iterate violates ``B x = c``. 

96 free: Boolean mask of the final free set. 

97 lam: Multipliers of the equality constraints (equality-augmented 

98 solves only; None otherwise). 

99 traj: The sequence of visited free sets as index tuples when 

100 trajectory tracking was requested; None otherwise. 

101 """ 

102 

103 x: Vector 

104 outer: int 

105 inner: int 

106 fallback: int 

107 converged: bool 

108 free: NDArray[np.bool_] 

109 lam: Vector | None = None 

110 traj: list[tuple[int, ...]] | None = None 

111 

112 

113@dataclass(frozen=True) 

114class ActiveSetSolver: 

115 """The primal-dual active-set outer loop for the non-negative quadratic program. 

116 

117 Holds the outer-loop :class:`ActiveSetConfig` and an 

118 :class:`nncg.inner.InnerSolver`, and drives the guarded block-pivot loop 

119 around the per-free-block solve the inner solver provides. It never touches a 

120 preconditioner — everything about CG/PCG/Nyström lives in ``inner``. 

121 

122 Attributes: 

123 inner: The inner solver for each free block — e.g. :class:`nncg.inner.CG` 

124 (plain CG), :class:`nncg.inner.Jacobi`, :class:`nncg.inner.Nystrom` 

125 or :class:`nncg.inner.Exact`. 

126 config: Outer-loop configuration (violator tolerance, patience, 

127 trajectory tracking, outer-step cap). 

128 

129 Examples: 

130 ``A`` enters as an operator, never as a bare array: 

131 

132 >>> import numpy as np 

133 >>> from cvx.linalg import DenseOperator 

134 >>> from nncg import ActiveSetSolver, CG, kkt_violation 

135 >>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]])) 

136 >>> b = np.array([2.0, -2.0]) 

137 

138 The unconstrained minimiser would be ``(1, -1)``, so the bound binds on 

139 the second coordinate and the loop returns ``(1, 0)`` with that 

140 coordinate active: 

141 

142 >>> res = ActiveSetSolver(inner=CG()).solve(a, b) 

143 >>> res.converged 

144 True 

145 >>> bool(np.allclose(res.x, [1.0, 0.0])) 

146 True 

147 >>> res.free.tolist() 

148 [True, False] 

149 

150 ``converged`` is the KKT exit, which :func:`nncg.kkt_violation` scores 

151 independently — zero certifies the unique global minimiser: 

152 

153 >>> round(kkt_violation(a, b, res.x), 12) 

154 0.0 

155 

156 Generic data never needs the Bland fallback; that it stayed dormant is 

157 reported rather than assumed: 

158 

159 >>> res.fallback 

160 0 

161 

162 Swap the inner solver freely — the outer loop is unchanged, and on this 

163 problem so is the answer: 

164 

165 >>> from nncg import Exact 

166 >>> direct = ActiveSetSolver(inner=Exact()).solve(a, b) 

167 >>> bool(np.allclose(direct.x, res.x)) 

168 True 

169 """ 

170 

171 inner: InnerSolver 

172 config: ActiveSetConfig = field(default_factory=ActiveSetConfig) 

173 

174 def solve( 

175 self, 

176 a: SymmetricOperator, 

177 b: Vector, 

178 warm: tuple[NDArray[np.bool_], Vector] | None = None, 

179 ) -> Result: 

180 """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by the active-set loop. 

181 

182 Each free-block solve is delegated to :attr:`inner`; the reduced matrix 

183 is never materialised and ``A`` is never refactorised. The batch 

184 block-pivot fast path is guarded by a least-index Bland fallback, so 

185 termination at the unique global minimiser is unconditional. 

186 

187 Args: 

188 a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) — 

189 ``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)`` 

190 for ``A = M^T M + ridge I`` whose Gram matrix is never formed. 

191 b: The linear term ``b``. 

192 warm: Optional ``(free_mask, x_prev)`` pair from a previous solve. 

193 Starts the loop from that free set and warm-starts every inner 

194 solve from the newest iterate (the :class:`nncg.inner.Exact` 

195 inner solver is direct, so it has nothing to seed but still 

196 starts from the warm free set) — across a support-stable 

197 parameter step the loop then terminates in a single outer step. 

198 

199 Returns: 

200 A :class:`Result`; ``converged`` is True iff the KKT system was 

201 satisfied to ``config.tol``, which certifies the unique global 

202 minimiser. 

203 

204 Raises: 

205 TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`. 

206 ValueError: When the operator dimension does not match ``len(b)``, or 

207 on the inner solver's own conditions in 

208 :meth:`InnerSolver.solve`. 

209 NotImplementedError: When a diagonal-preconditioned inner solver 

210 (:class:`nncg.inner.Jacobi`) meets a backend without ``diag`` 

211 (propagated from ``cvx.linalg``). 

212 """ 

213 _require_operator(a, b) 

214 

215 def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]: 

216 """Solve the reduced system ``A_F x_F = b_F`` with the chosen inner solver.""" 

217 xf, k_step = self.inner.solve(a, idx, b[idx], x0) 

218 return xf, None, k_step 

219 

220 def reduced_gradient(x: Vector, lam: Vector | None) -> Vector: # noqa: ARG001 

221 """Return the reduced gradient ``s = A x - b``.""" 

222 return a.matvec(x) - b 

223 

224 return self._run(len(b), sub_solve, reduced_gradient, warm) 

225 

226 def solve_eq( 

227 self, 

228 a: SymmetricOperator, 

229 b: Vector, 

230 b_eq: Matrix, 

231 c_eq: Vector, 

232 warm: tuple[NDArray[np.bool_], Vector] | None = None, 

233 ) -> Result: 

234 """Solve ``min 1/2 x^T A x - b^T x`` subject to ``x >= 0`` and ``B x = c``. 

235 

236 On each free set the saddle system is solved by eliminating the 

237 multiplier ``lambda`` in R^p through the p-by-p Schur complement 

238 ``S = B_F A_F^{-1} B_F^T``: the ``p + 1`` right-hand sides share the 

239 operator ``A_F`` and are each one inner solve, then ``S lambda = c - B_F 

240 v0`` fixes the multipliers in closed form. The single normalisation 

241 ``1^T x = beta`` is the ``p = 1`` case. ``B`` must have full row rank on 

242 the visited free sets (automatic for ``p = 1``). 

243 

244 Args: 

245 a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`). 

246 b: The linear term ``b``. 

247 b_eq: Equality matrix ``B`` of shape ``(p, n)``, full row rank. 

248 c_eq: Equality right-hand side ``c`` of shape ``(p,)``. 

249 warm: Optional ``(free_mask, x_prev)`` pair from a previous solve. 

250 Starts the loop from that free set and seeds the ``v0`` solve of 

251 every saddle step from the newest iterate; the ``v1`` columns are 

252 re-solved cold (their right-hand sides are the rows of ``B_F``, 

253 unrelated to ``x_prev``). Across a support-stable parameter step 

254 the loop then terminates in a single outer step. 

255 

256 Returns: 

257 A :class:`Result` with the multipliers in ``lam``. The reduced 

258 gradient underlying the dual test is ``s = A x - b - B^T lam``. 

259 ``converged`` additionally requires ``max|B x - c| <= tol * 

260 max(1, max|c|)``: a rank-deficient ``B`` with an inconsistent ``c`` 

261 can pass the KKT test while infeasible, and is returned with 

262 ``converged=False`` rather than certified. 

263 

264 Raises: 

265 TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`. 

266 ValueError: When the operator dimension does not match ``len(b)``, 

267 when ``b_eq`` is not of shape ``(p, n)`` or ``c_eq`` not of shape 

268 ``(p,)``, or on the inner solver's own conditions in 

269 :meth:`InnerSolver.solve`. 

270 NotImplementedError: When a diagonal-preconditioned inner solver 

271 (:class:`nncg.inner.Jacobi`) meets a backend without ``diag`` 

272 (propagated from ``cvx.linalg``). 

273 """ 

274 _require_operator(a, b) 

275 _require_eq_shapes(b_eq, c_eq, len(b)) 

276 

277 def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]: 

278 """Solve the saddle system on the free set via the p-by-p Schur complement.""" 

279 return _saddle_solve(self.inner, a, b, b_eq, c_eq, idx, x0) 

280 

281 def reduced_gradient(x: Vector, lam: Vector | None) -> Vector: 

282 """Return the constrained reduced gradient ``s = A x - b - B^T lam``.""" 

283 correction = b_eq.T @ lam if lam is not None else np.zeros_like(b) 

284 return a.matvec(x) - b - correction 

285 

286 result = self._run(len(b), sub_solve, reduced_gradient, warm) 

287 if result.converged and not _eq_feasible(b_eq, c_eq, result.x, self.config.tol): 

288 return replace(result, converged=False) 

289 return result 

290 

291 def _run( 

292 self, 

293 n: int, 

294 sub_solve: SubSolve, 

295 reduced_gradient: ReducedGradient, 

296 warm: tuple[NDArray[np.bool_], Vector] | None, 

297 ) -> Result: 

298 """Run the guarded primal-dual active-set loop. 

299 

300 The driver owns everything the termination proof depends on: the primal 

301 and dual violator tests, the batch exchange with its patience counter, 

302 and the least-index Bland fallback. What is solved on each free set — a 

303 single reduced system (:meth:`solve`), or the equality-augmented saddle 

304 system (:meth:`solve_eq`) — enters through the ``sub_solve`` callback, 

305 with ``reduced_gradient`` supplying the matching dual test quantity. The 

306 thresholds (``tol``, ``p_max``, ``track``, ``max_outer``) are read from 

307 :attr:`config`. 

308 

309 Args: 

310 n: Problem dimension. 

311 sub_solve: Callback ``(idx, x0) -> (x_F, lam, inner_iters)`` solving 

312 the subproblem on the free set ``idx``. ``x0`` is a warm inner 

313 guess restricted to ``idx`` (None on a cold start); ``lam`` are 

314 the equality multipliers (None for the bound-only problem). 

315 reduced_gradient: Callback ``(x, lam) -> s`` computing the reduced 

316 gradient that drives the dual violator test. 

317 warm: Optional ``(free_mask, x_prev)`` pair from a previous solve. 

318 Starts the loop from that free set and seeds every subproblem 

319 solve from the newest iterate. 

320 

321 Returns: 

322 A :class:`Result`; ``lam`` is whatever the last subproblem returned. 

323 """ 

324 cfg = self.config 

325 out = _drive(cfg.tol, cfg.p_max, cfg.track, cfg.max_outer, n, sub_solve, reduced_gradient, warm) 

326 return Result( 

327 x=out.x, 

328 outer=out.outer, 

329 inner=out.inner, 

330 fallback=out.fallback, 

331 converged=out.converged, 

332 free=out.free, 

333 lam=out.lam, 

334 traj=out.traj, 

335 )