Coverage for src/cvx/quadprog/_steps.py: 100%

52 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 18:50 +0000

1"""One pass of the dual method's inner loop. 

2 

3Given a direction to move the iterate in, these decide how far it may go 

4before a multiplier would turn negative, whether the step is full or partial, 

5and which constraint leaves the active set when it is partial. 

6""" 

7 

8# G, C, R and J are the names used in Goldfarb & Idnani (1983) and in the 

9# reference implementation's public signature `solve_qp(G, a, C, b, meq)`. 

10# Lowercasing them would obscure the correspondence to the paper, so the 

11# pep8-naming rules are waived here, as they are in _solve.py. 

12# ruff: noqa: N803, TRY003 

13 

14import numpy as np 

15from scipy.linalg.blas import dtpsv 

16 

17from ._base import _EMPTY, VSMALL 

18from ._qr import qr_delete 

19 

20# `dtpsv` and `dtrtri` are imported by name rather than resolved through 

21# get_blas_funcs/get_lapack_funcs. Those helpers pick a precision from prototype 

22# arrays, which is what you want when the caller's dtype is open; here every 

23# array is float64 by the time it reaches them -- solve_qp coerces on the way in 

24# -- so the choice is already made and resolving it costs an indirection, a 

25# module-level prototype array and a cast. Naming the double-precision wrappers 

26# yields the identical objects (`get_blas_funcs("tpsv", (f64,)) is dtpsv`) and a 

27# sharper static type: mypy reads dtpsv as returning ndarray[float64] where the 

28# cast to Callable[..., Any] erased it. Supporting float32 would mean going back. 

29# 

30# The packed triangular solve runs once per iteration and is the reason R is 

31# stored packed: `ap` is an unshaped rank-1 argument, so passing the whole array 

32# with n=nact reads the leading triangle in place. The dense equivalent, trtrs on 

33# R[:nact, :nact], is handed a strided view and copies it every call -- 77 us 

34# against 7.5 us at n = 700. Calling BLAS directly also skips 

35# scipy.linalg.solve_triangular's per-call validation, whose check_finite scans 

36# the whole array. 

37 

38# Active-set size below which the ratio test in _dual_step_limit runs on Python 

39# lists rather than arrays. Its work is one division and one argmin over `nact` 

40# entries, which for a small active set costs far less than asking NumPy to do 

41# it: measured per call at the solver's own call site, the array form takes 

42# 2.63 us against 0.75 us for the loop at nact = 4, and 3.29 us against 0.90 us 

43# at nact = 9. The two cross where the per-element interpreter cost overtakes 

44# NumPy's per-call overhead, a little below sixty. 

45_SCALAR_CUTOFF = 50 

46 

47 

48def _step_directions( 

49 J: np.ndarray, R: np.ndarray, nact: int, unit: bool, val: float, row: int, normal: np.ndarray 

50) -> tuple[np.ndarray, np.ndarray, np.ndarray, float | None]: 

51 """Return the primal and dual step directions for the entering constraint. 

52 

53 Recomputed on every pass of the inner loop because dropping a constraint 

54 changes ``J`` and ``R``. 

55 

56 Args: 

57 J: ``(n, n)`` inverse Cholesky factor, with ``J J^T = G^-1``. 

58 R: Packed upper triangular factor of the active constraint normals. 

59 nact: Size of the active set. 

60 unit: Whether the constraint normal is a single scaled unit vector. 

61 val: Its nonzero value, meaningful only when ``unit``. 

62 row: The row that nonzero occupies, meaningful only when ``unit``. 

63 normal: The constraint normal in dense form. 

64 

65 Returns: 

66 ``dv``, ``J^T n`` split as ``(d_1, d_2)`` at the size of the active 

67 set; ``zv``, the primal step direction; ``rv``, the negated dual step 

68 direction; and ``ztn``, the rate at which the entering constraint's 

69 slack closes -- ``None`` when the primal cannot move, which is the 

70 caller's signal that the step is limited by the dual alone. 

71 """ 

72 # For a unit column this is one scaled row of J, O(n) rather than O(n^2). 

73 dv = val * J[row, :] if unit else J.T @ normal 

74 

75 # zv = J_2 d_2, the component of the constraint normal orthogonal to the 

76 # active set. 

77 zv = J[:, nact:] @ dv[nact:] 

78 

79 # rv = R^-1 d_1. Solved on a copy: dv is still needed intact for qr_insert. 

80 rv = dtpsv(nact, R, dv[:nact].copy(), overwrite_x=True) if nact else _EMPTY 

81 

82 if abs(float(zv @ zv)) <= VSMALL: 

83 # The primal cannot move, so the entering constraint's slack does not 

84 # close at any rate and t2 is infinite. 

85 return dv, zv, rv, None 

86 

87 ztn = val * float(zv[row]) if unit else float(zv @ normal) 

88 return dv, zv, rv, ztn 

89 

90 

91def _step_choice(ztn: float | None, slack: float, t1: float, t1inf: bool, reverse_step: bool) -> tuple[float, bool]: 

92 """Return the step to take and whether it reaches the entering constraint. 

93 

94 Two limits compete: ``t1``, past which an active multiplier would turn 

95 negative, and ``t2``, at which the entering constraint's slack closes. The 

96 smaller one wins. Reaching ``t2`` ends the inner loop; stopping at ``t1`` 

97 means dropping a constraint and going round again. 

98 

99 Args: 

100 ztn: Rate at which the entering constraint's slack closes, or None 

101 when the primal cannot move and ``t2`` is therefore infinite. 

102 slack: Current slack of the entering constraint. 

103 t1: Largest dual-feasible step. 

104 t1inf: Whether ``t1`` is unbounded, in which case its value is 

105 meaningless. 

106 reverse_step: Whether to step in the negative direction, which is the 

107 case for an equality constraint violated from above. 

108 

109 Returns: 

110 The signed step, and whether it is a full step to the constraint. 

111 

112 Raises: 

113 ValueError: If neither limit is finite, which means the dual is 

114 unbounded and so the primal is infeasible. 

115 """ 

116 # Spelled as three outcomes rather than as a boolean built from both 

117 # limits, so that the branch establishing t2 is finite is also the branch 

118 # that steps to it -- otherwise nothing in the types rules out stepping to 

119 # an infinite t2, and a reader has to reconstruct the argument. 

120 if ztn is None: 

121 if t1inf: 

122 # Neither limit is finite: we can step infinitely far, so the dual 

123 # is unbounded and the primal is infeasible. 

124 raise ValueError("constraints are inconsistent, no solution") 

125 step_length, full_step = t1, False 

126 else: 

127 t2 = abs(slack) / ztn 

128 if t1inf or t1 >= t2: 

129 step_length, full_step = t2, True 

130 else: 

131 step_length, full_step = t1, False 

132 

133 return (-step_length if reverse_step else step_length), full_step 

134 

135 

136def _drop_constraint(idel: int, nact: int, uv: np.ndarray, iact: np.ndarray, J: np.ndarray, R: np.ndarray) -> int: 

137 """Remove the ``idel``-th active constraint, closing the gap it leaves. 

138 

139 ``uv``, ``iact``, ``J`` and ``R`` are all modified in place. 

140 

141 Args: 

142 idel: 1-based position in the active set of the constraint to drop. 

143 nact: Size of the active set before the drop. 

144 uv: Dual variables of the active constraints. 

145 iact: 1-based indices of the active constraints. 

146 J: ``(n, n)`` inverse Cholesky factor, updated by the QR downdate. 

147 R: Packed upper triangular factor, updated by the QR downdate. 

148 

149 Returns: 

150 The size of the active set after the drop. 

151 """ 

152 qr_delete(nact, idel, J, R) 

153 uv[idel - 1 : nact - 1] = uv[idel:nact].copy() 

154 iact[idel - 1 : nact - 1] = iact[idel:nact].copy() 

155 uv[nact - 1], iact[nact - 1] = 0.0, 0 

156 return nact - 1 

157 

158 

159def _dual_step_limit( 

160 uv: np.ndarray, 

161 rv: np.ndarray, 

162 iact: np.ndarray, 

163 nact: int, 

164 meq: int, 

165 reverse_step: bool, 

166) -> tuple[float, int]: 

167 """Return the largest dual-feasible step and the constraint that limits it. 

168 

169 Stepping along ``-rv`` drives the multipliers of the active inequality 

170 constraints towards zero. The first one to reach zero caps the step, since a 

171 negative multiplier would be dual infeasible. Equality constraints are 

172 exempt: their multipliers are unrestricted in sign. 

173 

174 Below :data:`_SCALAR_CUTOFF` active constraints the array form spends nearly 

175 all of its time in NumPy's per-call overhead rather than on the handful of 

176 divisions it performs, so the work is handed to :func:`_dual_step_scalar`, 

177 which computes the same answer -- ties included -- on Python lists. 

178 

179 Args: 

180 uv: Dual variables of the active constraints. 

181 rv: Negated step direction of the dual variables. 

182 iact: 1-based indices of the active constraints. 

183 nact: Size of the active set. 

184 meq: Number of leading constraints treated as equalities. 

185 reverse_step: Whether the step is taken in the negative direction. 

186 

187 Returns: 

188 The step limit and the 1-based position in the active set of the 

189 constraint that attains it. The position is 0 when no constraint limits 

190 the step, in which case the limit is meaningless. 

191 """ 

192 if nact <= _SCALAR_CUTOFF: 

193 return _dual_step_scalar(uv, rv, iact, nact, meq, reverse_step) 

194 

195 # Working with the signed direction lets one comparison serve both cases and 

196 # makes the eligible entries positive, so no separate abs is needed. 

197 direction = -rv[:nact] if reverse_step else rv[:nact] 

198 eligible = (iact[:nact] > meq) & (direction > 0.0) 

199 

200 # `where` leaves the ineligible entries at the infinity they were filled 

201 # with, so argmin skips them and the division never sees them. 

202 ratio = np.full(nact, np.inf) 

203 np.divide(uv[:nact], direction, out=ratio, where=eligible) 

204 idel = int(np.argmin(ratio)) 

205 limit = float(ratio[idel]) 

206 return (0.0, 0) if limit == np.inf else (limit, idel + 1) 

207 

208 

209def _dual_step_scalar( 

210 uv: np.ndarray, 

211 rv: np.ndarray, 

212 iact: np.ndarray, 

213 nact: int, 

214 meq: int, 

215 reverse_step: bool, 

216) -> tuple[float, int]: 

217 """Run the same ratio test on Python lists, for a small active set. 

218 

219 ``tolist()`` costs one call per array and then every comparison is an 

220 interpreter operation rather than an array one, which wins outright while 

221 the active set is smaller than :data:`_SCALAR_CUTOFF`. The list of active 

222 indices is built only when there are equalities to exclude: with ``meq == 0`` 

223 every constraint is an inequality and that test is vacuous, which is the case 

224 for box-constrained and dense-``C`` problems. 

225 

226 Args: 

227 uv: Dual variables of the active constraints. 

228 rv: Negated step direction of the dual variables. 

229 iact: 1-based indices of the active constraints. 

230 nact: Size of the active set. 

231 meq: Number of leading constraints treated as equalities. 

232 reverse_step: Whether the step is taken in the negative direction. 

233 

234 Returns: 

235 Exactly what :func:`_dual_step_limit` returns, ties included. 

236 """ 

237 sign = -1.0 if reverse_step else 1.0 

238 directions = rv[:nact].tolist() 

239 duals = uv[:nact].tolist() 

240 active = iact[:nact].tolist() if meq else None 

241 

242 limit, idel = np.inf, 0 

243 for i in range(nact): 

244 direction = sign * directions[i] 

245 if direction > 0.0 and (active is None or active[i] > meq): 

246 ratio = duals[i] / direction 

247 if ratio < limit: 

248 limit, idel = ratio, i + 1 

249 return (0.0, 0) if idel == 0 else (limit, idel)