Coverage for src/cvxcla/operators/_core.py: 100%

36 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-27 06:45 +0000

1"""Shared contract and solver primitives for the covariance backends. 

2 

3This module defines the :class:`QuadraticForm` protocol that every backend 

4implements, the deterministic symmetric reciprocal-condition helper they share, 

5and :func:`bordered_solve`, the bordered KKT solve used by both 

6``cvxcla.cla`` and ``cvxcla.lasso``. The concrete backends live in sibling 

7modules (``dense``, ``factor``, ``gram``) and are re-exported from the package 

8root ``cvxcla.operators``. 

9""" 

10 

11from __future__ import annotations 

12 

13from typing import Protocol, cast, runtime_checkable 

14 

15import numpy as np 

16from numpy.typing import NDArray 

17 

18# When the cheap LAPACK 1-norm condition *estimate* clears the singularity floor 

19# by this margin, the decision is conclusive; an estimate landing within the 

20# margin falls back to the exact symmetric rcond. The margin generously covers 

21# the gap between the 1-norm estimate and the true 2-norm reciprocal condition 

22# number, so the boolean answer is identical to the exact comparison. 

23_RCOND_ESTIMATE_MARGIN = 1.0e3 

24 

25 

26def _rcond_symmetric(block: NDArray[np.float64]) -> float: 

27 """Reciprocal 2-norm condition number of a symmetric (PSD) ``block``. 

28 

29 Returns a value in ``[0, 1]``: ``1`` is perfectly conditioned and ``0`` is 

30 numerically singular. The condition number is read off the symmetric 

31 eigenvalues (``lambda_min / lambda_max``), so the result is deterministic 

32 and independent of the BLAS/LAPACK build, unlike the residual of a solve 

33 against a singular block. An empty block is treated as well conditioned. 

34 """ 

35 if block.shape[0] == 0: 

36 return 1.0 

37 eigenvalues = np.linalg.eigvalsh(block) 

38 lam_max = float(eigenvalues[-1]) 

39 if lam_max <= 0.0: 

40 return 0.0 

41 return max(float(eigenvalues[0]), 0.0) / lam_max 

42 

43 

44@runtime_checkable 

45class QuadraticForm(Protocol): 

46 """Operations a parametric active-set path tracer needs from its Hessian. 

47 

48 The turning-point loop touches the symmetric positive (semi-)definite matrix 

49 ``H`` of the quadratic objective through a small number of operations: full 

50 matrix-vector products, solves against the *free* (active) principal block, 

51 and cross-products between free and blocked coordinates. In the Critical Line 

52 Algorithm ``H`` is the covariance ``Sigma``; in a LASSO / LARS path it is the 

53 Gram matrix ``X.T @ X``. Any object implementing this protocol can serve as 

54 that backend. 

55 

56 The reference implementation is ``DenseCovariance``, which wraps an explicit 

57 matrix; structured backends (e.g. diagonal-plus-low-rank via the Woodbury 

58 identity) implement the same contract without ever materialising an 

59 ``n x n`` matrix. 

60 

61 ``CovarianceOperator`` is kept as a backward-compatible alias of this 

62 protocol (see ``CovarianceOperator`` below). 

63 """ 

64 

65 @property 

66 def n(self) -> int: 

67 """Number of variables (the dimension of the quadratic form).""" 

68 ... # pragma: no cover 

69 

70 def matvec(self, x: NDArray[np.float64]) -> NDArray[np.float64]: 

71 """Compute the matrix-vector product ``Sigma @ x``. 

72 

73 Args: 

74 x: Vector of shape ``(n,)`` or matrix of shape ``(n, r)``. 

75 

76 Returns: 

77 ``Sigma @ x`` with the same trailing shape as ``x``. 

78 """ 

79 ... # pragma: no cover 

80 

81 def solve_free(self, free: NDArray[np.bool_], rhs: NDArray[np.float64]) -> NDArray[np.float64]: 

82 """Solve the free-block system ``Sigma[free][:, free] @ y = rhs``. 

83 

84 Args: 

85 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

86 rhs: Right-hand side of shape ``(n_free,)`` or ``(n_free, r)``. 

87 

88 Returns: 

89 The solution ``y`` with the same shape as ``rhs``. 

90 """ 

91 ... # pragma: no cover 

92 

93 def cross(self, free: NDArray[np.bool_], x: NDArray[np.float64]) -> NDArray[np.float64]: 

94 """Compute the free-to-blocked cross product ``Sigma[free][:, ~free] @ x[~free]``. 

95 

96 Args: 

97 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

98 x: Full-length vector of shape ``(n,)``; only the blocked entries 

99 ``x[~free]`` enter the product. 

100 

101 Returns: 

102 Vector of shape ``(n_free,)``. 

103 """ 

104 ... # pragma: no cover 

105 

106 def rcond_free(self, free: NDArray[np.bool_]) -> float: 

107 """Reciprocal condition number of the free block ``Sigma[free][:, free]``. 

108 

109 Returns a value in ``[0, 1]`` (``1`` well conditioned, ``0`` singular). 

110 The CLA uses this to detect a rank-deficient free block, whose solve is 

111 unreliable, directly and portably, rather than inferring it from the 

112 (backend-dependent) magnitude of the resulting box violation. 

113 

114 Args: 

115 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

116 

117 Returns: 

118 The reciprocal 2-norm condition number of the free block. 

119 """ 

120 ... # pragma: no cover 

121 

122 

123def bordered_solve( 

124 quad: QuadraticForm, 

125 free: NDArray[np.bool_], 

126 c_free: NDArray[np.float64], 

127 rhs_const: NDArray[np.float64], 

128 rhs_slope: NDArray[np.float64], 

129 d_const: NDArray[np.float64], 

130 d_slope: NDArray[np.float64], 

131) -> tuple[ 

132 NDArray[np.float64], 

133 NDArray[np.float64], 

134 NDArray[np.float64], 

135 NDArray[np.float64], 

136]: 

137 """Solve the bordered KKT system ``[[H_FF, C_F^T], [C_F, 0]] @ [x; nu] = [rhs; d]``. 

138 

139 The shared segment solve of ``cvxcla.cla`` and ``cvxcla.lasso``. ``H_FF`` is the 

140 free block of the quadratic form, ``C_F`` the constraint rows on the free columns. 

141 The constant and ``lambda``-slope right-hand sides are solved together so ``H_FF`` 

142 is factorised once; ``mc == 0`` (no constraint rows) is the plain free-block solve. 

143 ``H_FF`` is reached only through ``solve_free``, so structured backends never 

144 materialise an ``n x n`` matrix. Returns ``(x_const, x_slope, nu_const, nu_slope)``: 

145 the free solution and constraint multipliers per system (multipliers empty if 

146 ``mc == 0``). Callers assemble the right-hand sides and interpret the outputs. 

147 """ 

148 mc = c_free.shape[0] 

149 if mc == 0: 

150 sol = quad.solve_free(free, np.column_stack([rhs_const, rhs_slope])) 

151 empty: NDArray[np.float64] = np.zeros(0) 

152 return sol[:, 0], sol[:, 1], empty, empty 

153 

154 # One multi-RHS solve against H_FF covers the constraint columns C_F^T and both 

155 # the constant and slope systems, so H_FF is factorised a single time. 

156 solved = quad.solve_free(free, np.column_stack([c_free.T, rhs_const, rhs_slope])) 

157 y = solved[:, :mc] # H_FF^{-1} C_F^T 

158 z_const = solved[:, mc] 

159 z_slope = solved[:, mc + 1] 

160 

161 # Schur complement C_F H_FF^{-1} C_F^T and the stacked multipliers. 

162 schur = c_free @ y 

163 nu = cast( 

164 "NDArray[np.float64]", 

165 np.linalg.solve(schur, np.column_stack([c_free @ z_const - d_const, c_free @ z_slope - d_slope])), 

166 ) 

167 nu_const, nu_slope = nu[:, 0], nu[:, 1] 

168 

169 # Back-substitute the free solution. 

170 return z_const - y @ nu_const, z_slope - y @ nu_slope, nu_const, nu_slope 

171 

172 

173# Backward-compatible alias. The protocol was introduced as ``CovarianceOperator`` 

174# for the portfolio (covariance) setting; it is the Hessian contract for any 

175# parametric active-set path, so the canonical name is now ``QuadraticForm``. 

176# Existing imports of ``CovarianceOperator`` keep working unchanged. 

177CovarianceOperator = QuadraticForm