Skip to content

Latest commit

 

History

History
153 lines (133 loc) · 8.67 KB

File metadata and controls

153 lines (133 loc) · 8.67 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

cvx-quadprog is a pure NumPy/SciPy implementation of the Goldfarb/Idnani dual active-set method for strictly convex QPs: minimise ½ xᵀGx − aᵀx subject to Cᵀx ≥ b, with the leading meq constraints held as equalities. It is a drop-in, MIT-licensed replacement for the GPL-2.0 quadprog package (which wraps C descended from Turlach's Fortran). No compiler, no build step.

Three conventions inherited from the original and easy to trip over: the linear term is subtracted, C is column-wise (n × m, one column per constraint), and equalities are the leading meq columns — they cannot be interleaved.

Commands

The Makefile is a thin shim over uvx rhiza-task@<pin>; make <anything> is routed to the CLI, and RHIZA_TASK in the Makefile is the whole version contract. make help lists every task.

make install         # create the venv and sync dependencies (bootstraps uv if missing)
make doctor          # check local prerequisites
make all             # every gate CI runs: fmt deps test docs-coverage security license typecheck rhiza-test
make test            # full suite, gated at 100% coverage of statements AND branches
make fmt             # pre-commit hooks + ruff
make typecheck       # ty and mypy, both --strict (typechecker = "both" in pyproject.toml)
make hypothesis-test # property-based tests only
make benchmark       # performance benchmarks
make rhiza-test      # repository conformance checks (pytest-rhiza), incl. README code-block execution

Single test or single file — go through uv run directly, not make:

uv run pytest tests/test_qr.py -v
uv run pytest tests/test_specification.py::test_name -v
uv run pytest -k "sweep and not stress"
uv run pytest -m stress                       # markers: stress, property, kaleido
uv run pytest -o log_cli=true --log-cli-level=DEBUG   # live logs (off by default)

pytest.ini (template-owned) is authoritative over [tool.pytest.ini_options]; there is deliberately no such table in pyproject.toml. It sets a 60s timeout and turns one specific pytest deprecation into an error.

Versions live in [tool.bumpversion] and are rewritten across pyproject.toml, uv.lock and CITATION.cff together — never edit one by hand. CHANGELOG.md is generated by git-cliff from Conventional Commit subjects; do not hand-edit it.

Architecture

src/cvx/quadprog/ — public surface is exactly three names (solve_qp, Solution, Sweep), everything else is underscore-private.

  • _base.pySolution, _WarmEntry, VSMALL. Depends on nothing, so the fast path can build a Solution without importing the solver (which would cycle).
  • _setup.py — validation and the Cholesky/triangular-inverse of G that the iteration is expressed in terms of.
  • _solve.pysolve_qp, plus the outer loop: pick the entering constraint, decide infeasibility. _dispatch tries the fast path first when asked, then falls back to _solve_with_factors, which is also what Sweep calls (it returns the factorisation (J, R) alongside the solution, and accepts a warm state).
  • _steps.py — one pass of the inner loop: the ratio test, full-vs-partial step, which constraint leaves.
  • _qr.py — orthogonal updates of the active-normals factorisation. Insertion is a single Householder reflection, deletion a Givens chase. R is stored packed by column ((i, j) → j*(j+1)/2 + i) so the leading nact triangle is contiguous and BLAS tpsv reads it in place — 7.5 µs against 77 µs for the dense strided equivalent at n = 700, once per iteration.
  • _structure.py — detects single-nonzero constraint columns (bounds), so those become indexing rather than reductions, and picks dense vs CSR for the slack product by size and density.
  • _pdas.py — the fast=True primal-dual active-set path: guess the whole set, one dense KKT solve, repair from the signs. Not globally convergent — every candidate must pass _certified (sufficient here because the problem is strictly convex) or it is discarded and the exact walk runs.
  • _sweep.pySweep keeps the factorisation across a family of QPs differing only in a; verifies the cached active set against KKT, repairs it when stale.
  • _threads.py — opt-in blas_threads= cap via threadpoolctl, plus stdlib+NumPy-only probes of which BLAS is linked and how many threads it will start.

Two invariants worth knowing before touching the numerics:

  • fast=True changes two reported fields, which is why it is off by default: iterations counts a different algorithm's working-set edits, and iact comes out index-ordered rather than insertion-ordered. x, f, xu and lagrangian are unaffected. It also declines below 12 variables and whenever factorized is set.
  • Infeasibility is only concluded above the rounding floor. The Householder reduction makes a degenerate iterate land ~8·eps outside a constraint where the reference leaves it ~4.68·eps inside; such a constraint is set aside rather than taken as proof of infeasibility. The margin is deliberately loose.

Five project-specific rules

  1. Never copy from the GPL-2.0 reference. Solver code must be your own work from the 1983 paper — not from the C, the R package, or Turlach's Fortran. PROVENANCE.md records what is deliberately shared (the API signature, the ValueError message strings verbatim, the packed R layout) and why each is functional rather than expressive. The GPL quadprog is a dev-only dependency, imported solely by tests/test_against_c.py behind pytest.importorskip.
  2. Tests derive expectations independently. tests/test_specification.py uses closed forms, direct KKT saddle-point solves, or KKT certificates — which for a strictly convex QP prove optimality. A test that only checks agreement with the C reference measures the oracle. test_against_c.py is for differential comparison, not for specifications, and it verifies KKT rather than an identical dual where the dual is non-unique (duplicated or dependent constraints).
  3. Performance claims in README.md and docs/paper/ are measured, not illustrative — the n≈135 crossover, 11× at n=1600, the 10.2× packed solve. If a change moves any of them, re-run benchmarks/ref_probe.py and update both places; if it does not, say so in the PR. A timing without its BLAS and thread count is not reproducible.
  4. Rhiza-managed files are synced, not owned. Everything under files: in .rhiza/template.lock — most of .github/, ruff.toml, pytest.ini, .pre-commit-config.yaml, docs/index.md, tests/test_rhiza_packaging.py — is overwritten at the next /rhiza:update. This is why per-module # ruff: noqa: N803, N806 comments sit in the source files instead of a [lint.per-file-ignores] block in ruff.toml. README.md, CONTRIBUTING.md, PROVENANCE.md, CITATION.cff, and everything under src/, tests/ and docs/paper/ are repo-owned.
  5. Docstrings and docs are gated. interrogate runs at fail-under = 100 (make docs-coverage), and tests/test_docs.py asserts that cvx.quadprog.__all__ and the ::: cvx.quadprog.X blocks in docs/api.md are the same set in both directions. Adding an export means adding an API-reference block and a summary-table row.

Conventions

  • Uppercase G, C, R, J, CA, Y match Goldfarb & Idnani (1983) and the reference signature; pep8-naming is waived per-file for exactly this reason.
  • ValueError message strings are reproduced verbatim from the reference, so TRY003 is waived alongside N803/N806.
  • Inputs are never mutated (the C routine overwrites G and a).
  • Line length 120. Tests are organised by behaviour, not mirroring module names — the rhiza test-layout check is switched off in [tool.check_test_layout] with the reasoning recorded there; per-module reach is guaranteed by the 100% statement-and-branch coverage gate instead.
  • BLAS wrappers (dtpsv, dtrtri) are imported by name rather than through get_blas_funcs: everything is float64 by the time it arrives, and naming them gives a sharper static type.
  • Branch off main (protected, no direct pushes). Conventional Commits are enforced — the subject becomes the changelog entry verbatim.
  • Long explanatory comments in pyproject.toml, _qr.py, _structure.py and _threads.py record why a constant or a layout is what it is, usually with the measurement behind it. Preserve that when editing nearby; if you change a threshold, change the recorded number too.