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

106 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 05:55 +0000

1"""The Goldfarb/Idnani dual active-set method for strictly convex QPs. 

2 

3A NumPy/SciPy reimplementation of the ``quadprog`` package, which itself 

4descends from Berwin Turlach's Fortran translation of the algorithm in [1]. 

5 

6The method is *dual* feasible throughout: it starts at the unconstrained 

7minimum, which satisfies the dual conditions trivially, and drives the primal 

8infeasibility to zero one constraint at a time. Because every iterate is dual 

9feasible, the objective increases monotonically and the iteration count is 

10bounded by the number of constraints -- no phase-1 problem is needed. 

11 

12What is here is the driver and the outer loop: choosing the constraint to enter, 

13and deciding when the problem is infeasible. One pass of the inner loop lives in 

14:mod:`._steps`, the work before the first iteration in :mod:`._setup`, the 

15constraint-shape detection in :mod:`._structure`, and the records they are all 

16written in terms of in :mod:`._base`. 

17 

18References: 

19 [1] D. Goldfarb and A. Idnani (1983). A numerically stable dual method for 

20 solving strictly convex quadratic programs. Mathematical Programming, 

21 27, 1-33. 

22""" 

23 

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

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

26# Lowercasing them would obscure the correspondence to the paper and break 

27# drop-in compatibility, so the pep8-naming rules are waived here. TRY003 goes 

28# with them: the ValueError messages are reproduced verbatim from the reference 

29# so that callers matching on the text keep working. 

30# 

31# These live in the files rather than in a [lint.per-file-ignores] block because 

32# ruff.toml is template-owned -- a local edit to it is reverted by the next 

33# `/rhiza:update` sync and flagged as non-template by stage_synced.py. 

34# ruff: noqa: N803, N806 

35 

36import numpy as np 

37 

38from . import _pdas, _threads 

39from ._base import _EMPTY, VSMALL, Solution, _WarmEntry 

40from ._qr import qr_insert 

41from ._setup import _factorize, _validate 

42from ._steps import _drop_constraint, _dual_step_limit, _step_choice, _step_directions 

43from ._structure import _analyse_constraints, _default_constraints, _slack_evaluator 

44 

45__all__ = ["Solution", "solve_qp"] 

46 

47 

48# How far above the arithmetic's noise floor a violation must sit before the 

49# solver is willing to call the problem infeasible. Used only by 

50# _is_spurious_violation, on the one path that would otherwise raise; constraint 

51# selection keeps the reference's VSMALL snap untouched. 

52# 

53# The constant is loose on purpose, and can afford to be. Selection has to 

54# separate "rounding" from "violated but tiny", which admits no safe margin -- 

55# a real violation can be arbitrarily small. This test separates "rounding" from 

56# "provably infeasible", and infeasibility is macroscopic: the violation is set 

57# by the geometry of the constraints, not by the arithmetic. Any threshold 

58# between the two works, so there is nothing here to tune. 32 leaves two decades 

59# over the worst residual observed (8 * eps against VSMALL's 6.43 * eps, #36) 

60# and stays some thirteen orders below a genuine infeasibility. 

61_NOISE_MARGIN = 32.0 

62 

63 

64# What is left here is the dual method's add/drop state machine: an outer loop 

65# choosing the most violated constraint, an inner loop walking to its boundary 

66# while dropping constraints whose multipliers would turn negative. The work 

67# inside each pass is delegated -- _step_directions, _dual_step_limit, 

68# _step_choice and _drop_constraint -- so this function reads as the algorithm 

69# Goldfarb and Idnani specify rather than as the arithmetic implementing it. 

70# 

71# The inner loop's helpers are called once per *inner* iteration, which is the 

72# hot path, so the split was benchmarked rather than assumed. Twelve interleaved 

73# A/B rounds on box-constrained problems put the cost at about 1% for n <= 25 

74# and nothing measurable from n = 50 up; the paired per-round differences 

75# straddle zero (-1.5% to +3.0%), so 1% is the right order but the sign is only 

76# just resolvable. Two extra Python calls per iteration are small against the 

77# ~18 NumPy dispatches that already dominate at those sizes. 

78# 

79# That is the price of every block in the package rating B or better. If a 

80# future change makes small-n dispatch matter more than it does today, inlining 

81# _step_choice back into the loop is the first thing to undo. 

82# 

83# One thing the split must not do is hoist `C[:, iadd - 1]` out of the `unit` 

84# branch to give the helpers a uniform signature: on a box-constrained problem 

85# every column is a unit column, so that view would be built once per outer 

86# iteration and never read. That regressed n = 10 by ~2% when tried. 

87def solve_qp( 

88 G: np.ndarray, 

89 a: np.ndarray, 

90 C: np.ndarray | None = None, 

91 b: np.ndarray | None = None, 

92 meq: int = 0, 

93 factorized: bool = False, 

94 check_finite: bool = False, 

95 fast: bool = False, 

96 blas_threads: int | None = None, 

97) -> Solution: 

98 r"""Solve a strictly convex quadratic program. 

99 

100 Minimises :math:`\tfrac{1}{2} x^T G x - a^T x` subject to 

101 :math:`C^T x \ge b`, with the first ``meq`` constraints held as equalities. 

102 

103 The example below is chosen so its answer can be written down rather than 

104 discovered: with ``G`` the identity the objective separates, and each 

105 coordinate reduces to minimising :math:`\tfrac{1}{2} x_i^2 - a_i x_i` over 

106 :math:`x_i \ge 0`. That is the unconstrained minimiser with its negative 

107 entries clipped to zero -- the projection of ``a`` onto the non-negative 

108 orthant. 

109 

110 >>> import numpy as np 

111 >>> from cvx.quadprog import solve_qp 

112 >>> G = np.eye(3) 

113 >>> a = np.array([1.0, -2.0, 3.0]) 

114 >>> C = np.eye(3) 

115 >>> b = np.zeros(3) 

116 >>> solution = solve_qp(G, a, C, b) 

117 >>> bool(np.allclose(solution.x, [1.0, 0.0, 3.0])) 

118 True 

119 

120 The other fields describe the same solve. ``xu`` is the unconstrained 

121 minimiser :math:`G^{-1} a`, which here is ``a`` itself; ``iact`` names the 

122 constraints that ended up binding, 1-based -- only the second, since it is 

123 the only one ``xu`` violates; and ``f`` is the objective at ``x``. 

124 

125 >>> bool(np.allclose(solution.xu, a)) 

126 True 

127 >>> solution.iact.tolist() 

128 [2] 

129 >>> round(solution.f, 12) 

130 -5.0 

131 

132 Args: 

133 G: See :func:`_solve_with_factors`. 

134 a: See :func:`_solve_with_factors`. 

135 C: See :func:`_solve_with_factors`. 

136 b: See :func:`_solve_with_factors`. 

137 meq: See :func:`_solve_with_factors`. 

138 factorized: See :func:`_solve_with_factors`. 

139 check_finite: See :func:`_solve_with_factors`. 

140 fast: Offer the problem to the primal-dual active-set path in 

141 :mod:`._pdas` before walking it exactly. That path guesses the whole 

142 active set at once and is checked against the KKT conditions, so it 

143 returns **the same minimiser or nothing at all** -- when it declines, 

144 the exact walk runs and the result is bit-for-bit what it would have 

145 been. Measured 1.0x to 5.0x faster, growing with ``n``, because the 

146 exact walk's iteration count grows with the active set where this 

147 stays at two to four repairs. 

148 

149 It is not *uniformly* faster, which is the other reason it is opt-in. 

150 Where the exact walk happens to converge in one or two iterations -- 

151 a box-constrained problem whose unconstrained minimum is nearly 

152 feasible, say -- there is nothing to save, and the factorisation and 

153 certificate this path pays for anyway make it up to 20% slower. Those 

154 are also the cheapest solves there are, so the loss is a handful of 

155 microseconds against the hundreds this saves elsewhere. 

156 

157 Two reported fields differ when the fast path answers, which is why 

158 this is off by default. ``iterations`` counts working-set additions 

159 and removals of a *different algorithm*, so it no longer matches the 

160 reference implementation's, and ``iact`` is ordered by constraint 

161 index rather than by insertion. ``x``, ``f``, ``xu`` and 

162 ``lagrangian`` are unaffected. The path is skipped entirely when 

163 ``factorized`` is set, since the certificate needs ``G`` itself. 

164 blas_threads: Cap the BLAS thread count for the duration of this call, via 

165 a scoped `threadpoolctl <https://github.com/joblib/threadpoolctl>`_ 

166 context that restores the previous limits on exit. Requires 

167 ``threadpoolctl``, an optional dependency; a no-op in effect on 

168 Accelerate, which exposes no thread knob to set. 

169 

170 **Left unset, threading is touched only where it has been measured to 

171 be catastrophic**: on Linux, against an OpenBLAS build, with more 

172 threads configured than there are physical cores, and only once ``n`` 

173 is large enough for the collapse to be reachable -- at which point the 

174 count is capped to the physical core count. Everywhere else nothing is 

175 changed on the caller's behalf, because there is no default worth 

176 having: the best count differs by BLAS in opposite directions -- the 

177 fast path wants 4 threads on OpenBLAS, where 16 reads 0.05x, and 16 on 

178 MKL, where it is still improving -- and by path, since every 

179 contributed Windows exact-path sweep is best at 1. See 

180 :func:`~cvx.quadprog._threads.auto_cap_threads` for the gate, and #66 

181 for the measurements behind it. 

182 

183 Set it explicitly to override that, in either direction: an explicit 

184 count is used as given and the automatic gate is not consulted. Worth 

185 doing on MKL, where more threads than cores is not the trap it is on 

186 OpenBLAS, or to pin a solve to 1. Not worth doing around a small solve: 

187 ``threadpoolctl`` costs ~100 microseconds against a 0.2 ms solve at 

188 ``n = 10``, and for a batch of solves one context around the batch is 

189 cheaper than one per call. 

190 

191 Returns: 

192 The solution. 

193 

194 This is a thin wrapper over :func:`_solve_with_factors`, which additionally 

195 returns the factorisation it ends on. Nothing about the solve differs; the 

196 factors are simply discarded here, because for a single problem they are dead 

197 state and ``J`` alone is ``n^2`` doubles -- 15.7 MB at ``n = 1400``, against 

198 the 33 KB of the :class:`Solution` itself. :class:`~cvx.quadprog.Sweep` keeps 

199 them instead, which is the whole reason the split exists. 

200 

201 Setting ``fast`` additionally offers the problem to :mod:`._pdas` first. See 

202 the argument's own documentation for what that changes and what it does not. 

203 

204 Raises: 

205 ValueError: As :func:`_solve_with_factors`, and if ``blas_threads`` is not 

206 at least 1. 

207 ImportError: If ``blas_threads`` is given and ``threadpoolctl`` is not 

208 installed. 

209 """ 

210 if blas_threads is not None: 

211 with _threads.limit(blas_threads): 

212 return _dispatch(G, a, C, b, meq, factorized, check_finite, fast) 

213 

214 n = np.shape(G)[0] if len(np.shape(G)) > 0 else 0 

215 auto_threads = _threads.auto_cap_threads(n, fast=fast) 

216 if auto_threads is not None: 

217 with _threads.limit(auto_threads): 

218 return _dispatch(G, a, C, b, meq, factorized, check_finite, fast) 

219 

220 return _dispatch(G, a, C, b, meq, factorized, check_finite, fast) 

221 

222 

223def _dispatch( 

224 G: np.ndarray, 

225 a: np.ndarray, 

226 C: np.ndarray | None, 

227 b: np.ndarray | None, 

228 meq: int, 

229 factorized: bool, 

230 check_finite: bool, 

231 fast: bool, 

232) -> Solution: 

233 """Offer the problem to the fast path if asked, then walk it exactly. 

234 

235 Split out of :func:`solve_qp` only so that ``blas_threads`` can wrap both 

236 paths in one context manager without the body being written twice. Every 

237 argument means what it does there. 

238 

239 Args: 

240 G: See :func:`_solve_with_factors`. 

241 a: See :func:`_solve_with_factors`. 

242 C: See :func:`_solve_with_factors`. 

243 b: See :func:`_solve_with_factors`. 

244 meq: See :func:`_solve_with_factors`. 

245 factorized: See :func:`_solve_with_factors`. 

246 check_finite: See :func:`_solve_with_factors`. 

247 fast: See :func:`solve_qp`. 

248 

249 Returns: 

250 The solution. 

251 """ 

252 if fast and not factorized and C is not None and b is not None: 

253 solution = _pdas._fast_solution(G, a, C, b, meq, check_finite) 

254 if solution is not None: 

255 return solution 

256 

257 solution, _J, _R = _solve_with_factors(G, a, C, b, meq, factorized, check_finite) 

258 return solution 

259 

260 

261# Measured, and deliberately left alone. `radon cc` puts this function at B (10) -- the 

262# worst block in the package, against an average of A (3.96) over 54 blocks with nothing 

263# at C or worse -- and `radon mi` puts this module lowest of the ten, in the mid-20s of 

264# band A. Those MI decimals are quoted as a band rather than a figure on purpose: MI folds 

265# in Halstead volume, so a comment like this one lowers the very number it reports. The 

266# CC is comment-invariant; the MI is not. 

267# 

268# It stays one function because the thing it transcribes is one thing: the outer loop of 

269# Goldfarb & Idnani (1983), whose steps share the working state (`J`, packed `R`, `nact`, 

270# the active index vector) and read in the paper's order. Splitting it would move that 

271# state into arguments threaded through helpers that are only ever called once, in 

272# sequence, from here -- trading a legible transcription for a less legible one and making 

273# the correspondence to the paper harder to check, which is the property this file is 

274# organised around. The per-iteration work already lives in `_steps.py` and `_qr.py`; what 

275# is left is the loop itself. 

276# 

277# So the B (10) is recorded rather than removed. The signal to revisit is a rank change -- 

278# this block reaching C, or the module leaving MI band A (below 20) -- not the decimals. 

279def _solve_with_factors( 

280 G: np.ndarray, 

281 a: np.ndarray, 

282 C: np.ndarray | None = None, 

283 b: np.ndarray | None = None, 

284 meq: int = 0, 

285 factorized: bool = False, 

286 check_finite: bool = False, 

287 warm: _WarmEntry | None = None, 

288) -> tuple[Solution, np.ndarray, np.ndarray]: 

289 r"""Solve a strictly convex quadratic program, returning the factorisation too. 

290 

291 .. math:: 

292 \min_x \tfrac{1}{2} x^T G x - a^T x \quad\text{subject to}\quad C^T x \ge b 

293 

294 The first ``meq`` constraints are treated as equalities. 

295 

296 Args: 

297 G: ``(n, n)`` symmetric positive definite matrix of the quadratic term. 

298 If ``factorized`` is True, pass :math:`R^{-1}` instead, where 

299 :math:`G = R^T R` with ``R`` upper triangular. 

300 a: ``(n,)`` vector of the linear term. 

301 C: ``(n, m)`` constraint matrix, one column per constraint. Defaults to 

302 a single inactive constraint, giving the unconstrained problem. 

303 b: ``(m,)`` right-hand side of the constraints. 

304 meq: Number of leading constraints to treat as equalities. 

305 factorized: Whether ``G`` holds :math:`R^{-1}` rather than :math:`G`. 

306 check_finite: Whether to reject NaN and infinity in the inputs. **Off by 

307 default**, matching the reference, which does not scan either: the 

308 check is :math:`O(n^2)` on ``G`` and callers that already validate 

309 their data should not pay for it. Left off, a non-finite ``G`` is not 

310 diagnosed and what happens next belongs to the LAPACK build -- 

311 Accelerate reports a failed factorisation, OpenBLAS propagates NaNs 

312 into the result. Neither returns a finite wrong answer, but only one 

313 of them raises, so a program that must behave identically on every 

314 platform should pass True. 

315 warm: A dual-feasible state to resume from, in place of the cold start at 

316 the unconstrained minimum. See :class:`_WarmEntry` for the invariant 

317 it must satisfy, which is the caller's to establish; from there the 

318 iteration cannot tell a resumed state from a cold one. 

319 

320 Returns: 

321 A :class:`Solution` with the minimiser, the objective value, the 

322 unconstrained minimiser, the iteration counts, the Lagrange multipliers 

323 and the active set; together with ``J``, the inverse Cholesky factor as 

324 the iteration left it, and ``R``, the packed triangular factor of the 

325 active constraint normals. Both are live internal buffers, freshly 

326 allocated by this call and not aliased to anything the caller passed in. 

327 

328 Raises: 

329 ValueError: If the shapes are inconsistent, if ``meq`` is out of range, 

330 if ``G`` is not positive definite, if the constraints admit no 

331 solution, or if ``check_finite`` is set and any input holds a 

332 non-finite value. 

333 """ 

334 G = np.asarray(G, dtype=np.float64) 

335 a = np.asarray(a, dtype=np.float64) 

336 C, b, meq = _default_constraints(G, C, b, meq) 

337 

338 n, q = _validate(G, a, C, b, meq, check_finite) 

339 r = min(n, q) 

340 

341 # The norm of each column of C, used to scale the pivoting rule so that the 

342 # choice of constraint is invariant to how each one happens to be scaled. 

343 # A zero-norm column reads 0 >= b, which no x can influence; scoring it as 

344 # infinitely violated sends the solver to the infeasibility verdict instead 

345 # of dividing by zero below. 

346 # einsum rather than `np.sum(C * C, axis=0)`, which materialises a whole 

347 # n x m temporary to reduce it away again -- 0.32 ms against 0.19 ms at 

348 # n = 800, and 3.48 ms against 0.69 ms at n = 1400, where the temporary is 

349 # 31 MB and stops fitting anywhere useful (#108). 

350 # 

351 # The two are not bit-identical on a dense C: the reduction order differs, so 

352 # a column norm can land one ulp apart. That cannot change what the solver 

353 # computes -- nbv only scales the selection score below, and the arithmetic of 

354 # the solve itself never sees it -- and it cannot change which constraint is 

355 # chosen except between two whose scores already agree to within an ulp, 

356 # where either is a legitimate choice. The two shapes where selection order is 

357 # load-bearing are both exactly equal under either form: a single-nonzero 

358 # column has one term and no order to differ over, and duplicated or dependent 

359 # columns produce identical values to each other under both, so argmax still 

360 # resolves the tie towards the lowest index. 

361 nbv = np.sqrt(np.einsum("ij,ij->j", C, C)) 

362 degenerate = nbv == 0.0 

363 nbv_safe = np.where(degenerate, 1.0, nbv) 

364 

365 # Sparsity of C, detected once. Bound constraints make most columns a single 

366 # scaled unit vector, which turns three of the per-iteration operations from 

367 # O(n) or O(n*q) work into scalar indexing. 

368 single, srow, sval = _analyse_constraints(C) 

369 slack_of = _slack_evaluator(C, single, srow, sval) 

370 

371 if warm is None: 

372 # Cold start. xv holds G^-1 a, the unconstrained minimum, and J holds 

373 # R^-1 so that J J^T = G^-1; the active set is empty, which is trivially 

374 # dual feasible and is the whole reason this method needs no phase 1. The 

375 # objective is kept as a running total, each step updating it in closed 

376 # form rather than re-evaluating the quadratic. R is upper triangular 

377 # stored as packed columns -- see the note in _qr. 

378 J, xv = _factorize(G, a, factorized) 

379 obj = -float(a @ xv) / 2.0 

380 xu = xv.copy() 

381 R = np.zeros(r * (r + 1) // 2) 

382 uv = np.zeros(r) # dual variables of the active constraints 

383 iact = np.zeros(q, dtype=np.int64) # 1-based, first nact entries valid 

384 nact = 0 

385 else: 

386 # Resuming from a state a caller already holds. It must satisfy the same 

387 # invariant the cold start gets for free -- see _WarmEntry -- and from 

388 # here the loop cannot tell the two apart. 

389 J, R, iact, nact, xv, uv, obj, xu = warm 

390 

391 lagr = np.zeros(q) 

392 iter_full, iter_partial = 0, 0 

393 

394 # Constraints found violated only by rounding at the current xv, which the 

395 # iteration can neither enforce nor draw a conclusion from -- see 

396 # _is_spurious_violation. Held 0-based, first nign entries valid, and reset 

397 # whenever xv moves, so nothing is masked on the strength of a stale iterate. 

398 ignored = np.zeros(q, dtype=np.int64) 

399 nign = 0 

400 

401 while True: 

402 iter_full += 1 

403 

404 # The slack of every constraint. Slacks of active constraints are forced 

405 # to exactly zero as a safeguard against rounding error. 

406 sv = slack_of(xv) - b 

407 sv[np.abs(sv) < VSMALL] = 0.0 

408 sv[iact[:nact] - 1] = 0.0 

409 sv[ignored[:nign]] = 0.0 

410 

411 iadd = _choose_constraint(sv, nbv_safe, degenerate, meq) 

412 

413 if iadd == 0: 

414 # Every constraint is satisfied, so we are at the optimum. 

415 lagr[iact[:nact] - 1] = uv[:nact] 

416 iterations = np.array([iter_full, iter_partial], dtype=np.int64) 

417 return Solution(xv, obj, xu, iterations, lagr, iact[:nact]), J, R 

418 

419 # An equality constraint may be violated from either side. When its 

420 # slack is positive we have to step in the opposite direction. 

421 slack = float(sv[iadd - 1]) 

422 reverse_step = slack > 0.0 

423 u = 0.0 

424 

425 unit, row, val, normal = _entering(C, single, srow, sval, iadd) 

426 

427 # Inner loop: walk towards the constraint boundary, dropping active 

428 # constraints whose multipliers would otherwise turn negative. 

429 while True: 

430 dv, zv, rv, ztn = _step_directions(J, R, nact, unit, val, row, normal) 

431 

432 # The largest step t1 that keeps the dual variables non-negative, 

433 # and the constraint idel that would be the first to bind at zero. 

434 t1, idel = _dual_step_limit(uv, rv, iact, nact, meq, reverse_step) 

435 

436 if _is_spurious_violation(ztn, idel, slack, nbv_safe[iadd - 1], xv, b[iadd - 1]): 

437 # Satisfied to within the accuracy of xv, but the primal cannot 

438 # move and no multiplier can be reduced. Enforcing it would be a 

439 # no-op and concluding infeasibility from it would be wrong, so 

440 # set it aside and let the outer loop take the next candidate. 

441 ignored[nign] = iadd - 1 

442 nign += 1 

443 break 

444 

445 step, full_step = _step_choice(ztn, slack, t1, idel == 0, reverse_step) 

446 

447 if ztn is not None: 

448 xv += step * zv 

449 obj += step * ztn * (step / 2.0 + u) 

450 # xv moved, so every slack set aside against the old one is 

451 # stale and must be measured again. 

452 nign = 0 

453 

454 uv[:nact] -= step * rv 

455 u += step 

456 

457 if full_step: 

458 # The entering constraint now holds with equality: add it. 

459 nact += 1 

460 uv[nact - 1], iact[nact - 1] = u, iadd 

461 qr_insert(nact, dv, J, R) 

462 break 

463 

464 # Only a partial step: drop constraint idel from the active set. 

465 nact = _drop_constraint(idel, nact, uv, iact, J, R) 

466 iter_partial += 1 

467 

468 if ztn is not None: 

469 # We moved in primal space, so the slack we are closing has 

470 # changed and must be recomputed. 

471 reached = val * float(xv[row]) if unit else float(xv @ normal) 

472 slack = reached - float(b[iadd - 1]) 

473 

474 

475def _is_spurious_violation( 

476 ztn: float | None, idel: int, slack: float, normal_norm: float, xv: np.ndarray, rhs: float 

477) -> bool: 

478 """Return whether a stuck iteration reflects rounding rather than infeasibility. 

479 

480 The iteration is *stuck* when the primal cannot move (``ztn`` is None, so 

481 the entering normal already lies in the span of the active set) and no 

482 active multiplier can be reduced (``idel`` is 0). Goldfarb and Idnani's 

483 conclusion from that pair is that the dual is unbounded and the primal 

484 therefore infeasible -- but the argument assumes the entering constraint is 

485 genuinely violated. When its violation is the size of the rounding in 

486 ``xv``, it is not, and the conclusion does not follow. 

487 

488 That is reachable here rather than being theoretical. ``qr_insert`` reduces 

489 with a Householder reflection where the reference chases Givens rotations; 

490 the two agree in exact arithmetic (see the README) but round differently, so 

491 an iterate that the reference leaves 4.68 * eps inside a constraint can land 

492 8 * eps outside it -- either side of the fixed ``VSMALL`` snap applied to the 

493 slacks in ``solve_qp``. 

494 

495 Args: 

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

497 the primal cannot move. 

498 idel: 1-based position of the constraint limiting the dual step, or 0 

499 when nothing limits it. 

500 slack: Current slack of the entering constraint. 

501 normal_norm: Norm of the entering constraint's normal, zeros replaced 

502 by one. 

503 xv: Current primal iterate, whose magnitude sets the scale of the 

504 rounding the slack inherits. 

505 rhs: The entering constraint's right-hand side. 

506 

507 Returns: 

508 True when the violation is indistinguishable from rounding, so the 

509 constraint should be set aside rather than treated as proof of 

510 infeasibility. 

511 """ 

512 if ztn is not None or idel != 0: 

513 return False 

514 

515 # Reached only on the stuck path, so the O(n) norm is off the hot loop. The 

516 # slack inherits the error in xv rather than merely the error of its own dot 

517 # product, so the scale that matters is ||c|| ||x||, not the size of the 

518 # terms that formed it -- for a constraint that x sits on, those are already 

519 # at the noise floor and say nothing. 

520 scale = normal_norm * float(np.max(np.abs(xv))) + abs(rhs) 

521 return abs(slack) <= _NOISE_MARGIN * VSMALL * max(scale, 1.0) 

522 

523 

524def _entering( 

525 C: np.ndarray, single: np.ndarray, srow: np.ndarray, sval: np.ndarray, iadd: int 

526) -> tuple[bool, int, float, np.ndarray]: 

527 """Return how to read the entering constraint's normal. 

528 

529 A column holding a single scaled unit vector ``e_row`` lets the three products 

530 against it in :func:`_step_directions` be read off by index instead of 

531 computed. 

532 

533 Both branches bind all four values, so ``_step_directions`` can take a fixed 

534 signature -- but only one branch builds the dense view, and that asymmetry is 

535 the point. On a box-constrained problem every column is a unit column, so 

536 hoisting ``C[:, iadd - 1]`` out of the branch would construct a strided view 

537 per outer iteration that nothing ever reads; measured at ~2% on ``n = 10``. 

538 Lifting the branch into this function keeps that property, since the view is 

539 still built only where it is used. 

540 

541 Args: 

542 C: ``(n, m)`` constraint matrix. 

543 single: Mask of the columns holding exactly one nonzero. 

544 srow: Row index of that nonzero per column. 

545 sval: Value of that nonzero per column. 

546 iadd: 1-based index of the entering constraint. 

547 

548 Returns: 

549 Whether the column is a scaled unit vector, the row its nonzero occupies, 

550 that nonzero's value, and the dense normal -- the last three meaningful 

551 only in the branch that binds them. 

552 """ 

553 if single[iadd - 1]: 

554 return True, int(srow[iadd - 1]), float(sval[iadd - 1]), _EMPTY 

555 return False, 0, 0.0, C[:, iadd - 1] 

556 

557 

558def _choose_constraint(sv: np.ndarray, nbv_safe: np.ndarray, degenerate: np.ndarray, meq: int) -> int: 

559 """Return the 1-based index of the most violated constraint, or 0 if none. 

560 

561 Violations are measured relative to the norm of the constraint normal, so 

562 the choice does not depend on the scaling of individual constraints. An 

563 equality constraint counts as violated in either direction. 

564 

565 Scanning for the largest violation is equivalent to taking an ``argmax``, 

566 which resolves ties towards the lowest index just as a left-to-right scan 

567 with a strict improvement test does. 

568 

569 Args: 

570 sv: Slack of each constraint. 

571 nbv_safe: Norm of each constraint normal, with zeros replaced by one. 

572 degenerate: Mask of the constraints whose normal has zero norm. 

573 meq: Number of leading constraints treated as equalities. 

574 

575 Returns: 

576 The 1-based index of the constraint to add, or 0 at the optimum. 

577 """ 

578 # An inequality is violated when its slack is negative; an equality whenever 

579 # its slack is nonzero. 

580 violation = -sv 

581 np.abs(violation[:meq], out=violation[:meq]) 

582 

583 score = violation / nbv_safe 

584 # A zero-norm normal cannot be satisfied by any step, so rank it first. 

585 if degenerate.any(): 

586 score = np.where(degenerate & (violation > 0.0), np.inf, score) 

587 

588 iadd = int(np.argmax(score)) 

589 return iadd + 1 if score[iadd] > 0.0 else 0