ipax 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. {ipax-0.2.0 → ipax-0.4.0}/AGENTS.md +40 -2
  2. {ipax-0.2.0 → ipax-0.4.0}/PKG-INFO +1 -1
  3. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/corpus/__init__.py +39 -0
  4. ipax-0.4.0/benchmarks/corpus/_s2mpj_fast.py +413 -0
  5. ipax-0.4.0/benchmarks/corpus/s2mpj.py +569 -0
  6. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/harness/__init__.py +82 -17
  7. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/qc.py +2 -0
  8. ipax-0.4.0/benchmarks/runners/s2mpj.py +608 -0
  9. ipax-0.4.0/docs/benchmarks/s2mpj.md +199 -0
  10. {ipax-0.2.0 → ipax-0.4.0}/docs/concepts/linalg.md +29 -0
  11. {ipax-0.2.0 → ipax-0.4.0}/examples/radiotherapy_sparse.py +7 -4
  12. ipax-0.4.0/examples/sparse_operator_problem.py +82 -0
  13. {ipax-0.2.0 → ipax-0.4.0}/ipax/__init__.py +9 -1
  14. {ipax-0.2.0 → ipax-0.4.0}/ipax/_logging.py +37 -10
  15. ipax-0.4.0/ipax/backend/operators.py +970 -0
  16. ipax-0.4.0/ipax/backend/sparse/_canonical.py +207 -0
  17. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/_routing.py +27 -9
  18. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/cupy.py +308 -66
  19. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/numpy_scipy.py +203 -24
  20. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/driver.py +236 -89
  21. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/filter_ls.py +59 -7
  22. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/hessian.py +57 -8
  23. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/init.py +41 -1
  24. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/kkt.py +401 -24
  25. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/restoration.py +23 -1
  26. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/termination.py +11 -0
  27. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/dense.py +49 -8
  28. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/krylov.py +199 -14
  29. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/regularize.py +22 -1
  30. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/solver.py +10 -0
  31. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/sparse.py +70 -8
  32. {ipax-0.2.0 → ipax-0.4.0}/ipax/options.py +90 -21
  33. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/derivatives.py +20 -8
  34. ipax-0.4.0/ipax/problem/linear_ineq.py +196 -0
  35. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/scaling.py +6 -0
  36. {ipax-0.2.0 → ipax-0.4.0}/ipax/solve.py +11 -4
  37. {ipax-0.2.0 → ipax-0.4.0}/ipax/testing/problems.py +253 -0
  38. {ipax-0.2.0 → ipax-0.4.0}/pyproject.toml +1 -1
  39. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_builtin_operator_contracts.py +81 -0
  40. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_operator_contract.py +11 -0
  41. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_benchmark_harness.py +51 -1
  42. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_callback_and_logging.py +79 -1
  43. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_hock_schittkowski.py +52 -1
  44. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_known_optima.py +263 -199
  45. ipax-0.4.0/tests/integration/test_linear_inequalities.py +224 -0
  46. ipax-0.4.0/tests/integration/test_s2mpj_corpus.py +349 -0
  47. ipax-0.4.0/tests/integration/test_sparse_operator_problem.py +182 -0
  48. ipax-0.4.0/tests/property/test_derivative_checks.py +189 -0
  49. ipax-0.4.0/tests/regression/test_delta_c_rank_deficient_constraints.py +83 -0
  50. ipax-0.4.0/tests/regression/test_hs7_lbfgs_no_false_infeasible.py +80 -0
  51. ipax-0.4.0/tests/regression/test_hs9_krylov_zero_hessian.py +47 -0
  52. ipax-0.4.0/tests/regression/test_lbfgs_gradient_overshoot.py +60 -0
  53. ipax-0.4.0/tests/regression/test_sparse_non_finite_kkt_no_crash.py +59 -0
  54. ipax-0.4.0/tests/regression/test_unbounded_detection.py +90 -0
  55. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_cupy_sparse_adapter.py +232 -4
  56. ipax-0.4.0/tests/unit/test_dense_solver.py +327 -0
  57. ipax-0.4.0/tests/unit/test_filter_ls.py +186 -0
  58. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_hessian_lbfgs.py +67 -0
  59. ipax-0.4.0/tests/unit/test_init_bounds.py +32 -0
  60. ipax-0.4.0/tests/unit/test_kkt.py +669 -0
  61. ipax-0.4.0/tests/unit/test_krylov.py +838 -0
  62. ipax-0.4.0/tests/unit/test_linear_ineq_lowering.py +105 -0
  63. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_logging.py +52 -0
  64. ipax-0.4.0/tests/unit/test_numpy_scipy_canonical_reuse.py +131 -0
  65. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_numpy_scipy_feral.py +40 -1
  66. ipax-0.4.0/tests/unit/test_operator_batched_hooks.py +79 -0
  67. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_operator_coo.py +167 -1
  68. ipax-0.4.0/tests/unit/test_operator_coo_values.py +115 -0
  69. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_operator_diagonal.py +27 -0
  70. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_options.py +46 -4
  71. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_problem_derivatives.py +37 -0
  72. ipax-0.4.0/tests/unit/test_public_sparse_operators.py +218 -0
  73. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_regularize.py +19 -1
  74. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_restoration.py +43 -0
  75. ipax-0.4.0/tests/unit/test_s2mpj_adapter.py +226 -0
  76. ipax-0.4.0/tests/unit/test_s2mpj_fasteval.py +331 -0
  77. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_scaling.py +14 -0
  78. ipax-0.4.0/tests/unit/test_sparse_canonical.py +151 -0
  79. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_sparse_device_routing.py +14 -0
  80. ipax-0.4.0/tests/unit/test_sparse_dispatch.py +78 -0
  81. ipax-0.4.0/tests/unit/test_sparse_facade_structure_cache.py +112 -0
  82. ipax-0.4.0/tests/unit/test_sparse_operator_symmetry.py +64 -0
  83. ipax-0.4.0/tests/unit/test_step_failure_salvage.py +110 -0
  84. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_termination.py +8 -1
  85. ipax-0.2.0/ipax/backend/operators.py +0 -429
  86. ipax-0.2.0/tests/property/test_derivative_checks.py +0 -94
  87. ipax-0.2.0/tests/unit/test_dense_solver.py +0 -63
  88. ipax-0.2.0/tests/unit/test_filter_ls.py +0 -54
  89. ipax-0.2.0/tests/unit/test_kkt.py +0 -162
  90. ipax-0.2.0/tests/unit/test_krylov.py +0 -442
  91. ipax-0.2.0/tests/unit/test_sparse_dispatch.py +0 -19
  92. {ipax-0.2.0 → ipax-0.4.0}/.gitignore +0 -0
  93. {ipax-0.2.0 → ipax-0.4.0}/CLAUDE.md +0 -0
  94. {ipax-0.2.0 → ipax-0.4.0}/CONTRIBUTING.md +0 -0
  95. {ipax-0.2.0 → ipax-0.4.0}/LICENSE +0 -0
  96. {ipax-0.2.0 → ipax-0.4.0}/NOTICE +0 -0
  97. {ipax-0.2.0 → ipax-0.4.0}/README.md +0 -0
  98. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/README.md +0 -0
  99. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/asv.conf.json +0 -0
  100. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/baselines/__init__.py +0 -0
  101. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/corpus/external.py +0 -0
  102. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/generators/__init__.py +0 -0
  103. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/harness/plots.py +0 -0
  104. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/reports/.gitkeep +0 -0
  105. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/bench_solve.py +0 -0
  106. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/crosscheck.py +0 -0
  107. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/device_efficiency.py +0 -0
  108. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/micro/bench_kernels.py +0 -0
  109. {ipax-0.2.0 → ipax-0.4.0}/benchmarks/runners/scaling.py +0 -0
  110. {ipax-0.2.0 → ipax-0.4.0}/conftest.py +0 -0
  111. {ipax-0.2.0 → ipax-0.4.0}/docs/architecture.md +0 -0
  112. {ipax-0.2.0 → ipax-0.4.0}/docs/concepts/algorithm.md +0 -0
  113. {ipax-0.2.0 → ipax-0.4.0}/docs/concepts/problem.md +0 -0
  114. {ipax-0.2.0 → ipax-0.4.0}/docs/contributing.md +0 -0
  115. {ipax-0.2.0 → ipax-0.4.0}/docs/getting-started.md +0 -0
  116. {ipax-0.2.0 → ipax-0.4.0}/docs/guide/backends.md +0 -0
  117. {ipax-0.2.0 → ipax-0.4.0}/docs/guide/diagnostics.md +0 -0
  118. {ipax-0.2.0 → ipax-0.4.0}/docs/guide/options.md +0 -0
  119. {ipax-0.2.0 → ipax-0.4.0}/docs/guide/problems.md +0 -0
  120. {ipax-0.2.0 → ipax-0.4.0}/docs/guide/results.md +0 -0
  121. {ipax-0.2.0 → ipax-0.4.0}/docs/index.md +0 -0
  122. {ipax-0.2.0 → ipax-0.4.0}/docs/reference.md +0 -0
  123. {ipax-0.2.0 → ipax-0.4.0}/examples/README.md +0 -0
  124. {ipax-0.2.0 → ipax-0.4.0}/examples/autodiff.py +0 -0
  125. {ipax-0.2.0 → ipax-0.4.0}/examples/bound_and_inequality.py +0 -0
  126. {ipax-0.2.0 → ipax-0.4.0}/examples/equality_constrained.py +0 -0
  127. {ipax-0.2.0 → ipax-0.4.0}/examples/lbfgs_finite_diff.py +0 -0
  128. {ipax-0.2.0 → ipax-0.4.0}/examples/matrix_free_krylov.py +0 -0
  129. {ipax-0.2.0 → ipax-0.4.0}/examples/nonconvex_hs.py +0 -0
  130. {ipax-0.2.0 → ipax-0.4.0}/examples/unconstrained_quadratic.py +0 -0
  131. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/__init__.py +0 -0
  132. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/namespace.py +0 -0
  133. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/__init__.py +0 -0
  134. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/jax.py +0 -0
  135. {ipax-0.2.0 → ipax-0.4.0}/ipax/backend/sparse/torch.py +0 -0
  136. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/__init__.py +0 -0
  137. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/barrier.py +0 -0
  138. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/breedveld_ls.py +0 -0
  139. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/corrections.py +0 -0
  140. {ipax-0.2.0 → ipax-0.4.0}/ipax/ipm/step.py +0 -0
  141. {ipax-0.2.0 → ipax-0.4.0}/ipax/linalg/__init__.py +0 -0
  142. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/__init__.py +0 -0
  143. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/autodiff/__init__.py +0 -0
  144. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/autodiff/jax.py +0 -0
  145. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/autodiff/torch.py +0 -0
  146. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/base.py +0 -0
  147. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/finitediff.py +0 -0
  148. {ipax-0.2.0 → ipax-0.4.0}/ipax/problem/function.py +0 -0
  149. {ipax-0.2.0 → ipax-0.4.0}/ipax/py.typed +0 -0
  150. {ipax-0.2.0 → ipax-0.4.0}/ipax/result.py +0 -0
  151. {ipax-0.2.0 → ipax-0.4.0}/ipax/testing/__init__.py +0 -0
  152. {ipax-0.2.0 → ipax-0.4.0}/ipax/testing/backends.py +0 -0
  153. {ipax-0.2.0 → ipax-0.4.0}/ipax/typing.py +0 -0
  154. {ipax-0.2.0 → ipax-0.4.0}/tests/__init__.py +0 -0
  155. {ipax-0.2.0 → ipax-0.4.0}/tests/_helpers.py +0 -0
  156. {ipax-0.2.0 → ipax-0.4.0}/tests/backends/test_cross_backend_conformance.py +0 -0
  157. {ipax-0.2.0 → ipax-0.4.0}/tests/conftest.py +0 -0
  158. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/__init__.py +0 -0
  159. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_builtin_problem_contracts.py +0 -0
  160. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_builtin_solver_contracts.py +0 -0
  161. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_builtin_sparse_contracts.py +0 -0
  162. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_problem_contract.py +0 -0
  163. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_solver_contract.py +0 -0
  164. {ipax-0.2.0 → ipax-0.4.0}/tests/contracts/test_sparse_contract.py +0 -0
  165. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_benchmark_crosscheck.py +0 -0
  166. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_benchmark_phase4.py +0 -0
  167. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_benchmark_scaling.py +0 -0
  168. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_corrections_solve.py +0 -0
  169. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_dense_nonconvex.py +0 -0
  170. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_device_efficiency.py +0 -0
  171. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_krylov_solve.py +0 -0
  172. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_scaling_solve.py +0 -0
  173. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_sparse_solve.py +0 -0
  174. {ipax-0.2.0 → ipax-0.4.0}/tests/integration/test_warm_start_solve.py +0 -0
  175. {ipax-0.2.0 → ipax-0.4.0}/tests/regression/__init__.py +0 -0
  176. {ipax-0.2.0 → ipax-0.4.0}/tests/regression/test_doctest_collection_adapters.py +0 -0
  177. {ipax-0.2.0 → ipax-0.4.0}/tests/regression/test_fraction_to_boundary_no_elementwise_sync.py +0 -0
  178. {ipax-0.2.0 → ipax-0.4.0}/tests/test_purity_gate.py +0 -0
  179. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_autodiff.py +0 -0
  180. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_backend_namespace.py +0 -0
  181. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_barrier.py +0 -0
  182. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_breedveld.py +0 -0
  183. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_corrections.py +0 -0
  184. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_inertia_correction.py +0 -0
  185. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_result.py +0 -0
  186. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_solver_selection.py +0 -0
  187. {ipax-0.2.0 → ipax-0.4.0}/tests/unit/test_warm_start.py +0 -0
@@ -177,10 +177,14 @@ Claude Code config lives in `.claude/` and is checked in:
177
177
  - **`/verify`** (`.claude/commands/verify.md`) — run `scripts/check.py` and summarize.
178
178
  - **`/tdd`** (`.claude/commands/tdd.md`) — drive a change through the mandated
179
179
  red→green→verify→regression loop, multi-backend and invariant-aware.
180
+ - **`/release`** (`.claude/commands/release.md`) — drive the release workflow
181
+ (`rc/vX.Y.Z` branch → PR onto `main` → review loop → version/changelog bump → tag →
182
+ sync `develop`), stopping at each human/CI gate. Follows the **Releasing** section.
180
183
 
181
184
  The auditor's rubric is the static half of GPU performance work; the measured half is
182
- a future `benchmarks/runners/device_efficiency.py` (sync/kernel profiling on real
183
- hardware), deferred until GPU CI exists.
185
+ `benchmarks/runners/device_efficiency.py` (host-sync counting + per-iteration timing
186
+ on real hardware, kernel-launch counts best-effort via `nsys`). It is GPU-gated, so it
187
+ is a no-op in CI until GPU CI exists; run it locally on a CUDA backend.
184
188
 
185
189
  ---
186
190
 
@@ -250,6 +254,40 @@ core must remain backend-agnostic.
250
254
 
251
255
  ---
252
256
 
257
+ ## Releasing
258
+
259
+ Releases follow a release-candidate PR onto `main`, then a tag. Conventions to know
260
+ before starting:
261
+
262
+ - **Version is single-sourced** in `ipax/__init__.py` (`__version__`); `pyproject.toml`
263
+ derives it via `[tool.hatch.version]`. Bump in **exactly that one place**.
264
+ - **`CHANGELOG.md` follows Keep a Changelog** and is enforced by the `kacl-verify`
265
+ pre-commit/CI hook. Keep entries under `## [Unreleased]` as work lands.
266
+ - **Tags are `vX.Y.Z`**; pushing a tag triggers `release.yml` (PyPI Trusted Publishing
267
+ + a GitHub release whose notes come from the tag annotation and the changelog).
268
+
269
+ Steps:
270
+
271
+ 1. **Branch.** From an up-to-date `develop`, create `rc/vX.Y.Z`.
272
+ 2. **Open the PR.** Push the branch and open a PR with **base `main`, head `rc/vX.Y.Z`**.
273
+ **Do not bump the version yet** — review may change the contents.
274
+ 3. **Wait for CI + code review** (CI gates + Copilot/human review).
275
+ 4. **Address review.** Push fixes to `rc/vX.Y.Z`; repeat 3–4 until the PR is approved.
276
+ Avoid `git add -A` (it sweeps unrelated working-tree edits into the commit) — stage
277
+ the files you changed.
278
+ 5. **Release bump (only once approved).** Confirm `CHANGELOG.md` covers the release:
279
+ add `## [X.Y.Z] - YYYY-MM-DD` (move the `[Unreleased]` items into it) and update the
280
+ compare links at the bottom. Bump `ipax.__version__`. Run `python scripts/check.py`
281
+ and `pre-commit run kacl-verify --files CHANGELOG.md`. Push; let CI go green.
282
+ 6. **Merge** the PR into `main`.
283
+ 7. **Tag and sync.** Sync `main` (`git switch main && git pull --ff-only`), create the
284
+ annotated tag (`git tag -a vX.Y.Z main` with a short changelog-derived summary),
285
+ merge `main` back into `develop` (`--no-ff`), then push **both** `develop` and the
286
+ tag (`git push origin develop && git push origin vX.Y.Z`). The tag push runs
287
+ `release.yml` — confirm that workflow succeeds.
288
+
289
+ ---
290
+
253
291
  ## References
254
292
 
255
293
  - Wächter, A. & Biegler, L. T. (2006). "On the implementation of an interior-point
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipax
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Array-API-conformant primal–dual interior-point solver for large-scale nonlinear constrained optimization.
5
5
  Project-URL: Homepage, https://github.com/wahln/ipax
6
6
  Project-URL: Documentation, https://ipax.readthedocs.io/
@@ -21,8 +21,12 @@ from ipax.testing.problems import (
21
21
  HS6,
22
22
  HS7,
23
23
  HS8,
24
+ HS9,
25
+ HS21,
26
+ HS28,
24
27
  HS35,
25
28
  HS43,
29
+ HS71,
26
30
  BoundConstrainedQP,
27
31
  EqualityConstrainedQP,
28
32
  UnconstrainedQuadratic,
@@ -61,6 +65,7 @@ class BenchmarkProblem:
61
65
  default=lambda _problem: None, repr=False
62
66
  )
63
67
  backends: tuple[str, ...] | None = None
68
+ exclude_configs: tuple[str, ...] = () # config labels the QC sweep skips here
64
69
 
65
70
 
66
71
  def _known(problem: Problem) -> Array | None:
@@ -149,6 +154,40 @@ def default_corpus() -> list[BenchmarkProblem]:
149
154
  tags=("eq", "nonlinear"),
150
155
  build=lambda xp: (HS8(xp), _arr(xp, [2.0, 1.0])),
151
156
  ),
157
+ BenchmarkProblem(
158
+ name="hs9",
159
+ kind="NLP",
160
+ tags=("eq", "nonlinear"),
161
+ build=lambda xp: (HS9(xp), _arr(xp, [0.0, 0.0])),
162
+ ),
163
+ BenchmarkProblem(
164
+ name="hs21",
165
+ kind="QP",
166
+ tags=("bounds", "ineq"),
167
+ build=lambda xp: (HS21(xp), _arr(xp, [3.0, 1.0])),
168
+ optimum=_known,
169
+ ),
170
+ BenchmarkProblem(
171
+ name="hs28",
172
+ kind="QP",
173
+ tags=("eq",),
174
+ build=lambda xp: (HS28(xp), _arr(xp, [-1.0, 0.5, 0.5])),
175
+ optimum=_known,
176
+ ),
177
+ BenchmarkProblem(
178
+ name="hs71",
179
+ kind="NLP",
180
+ tags=("eq", "ineq", "bounds", "nonlinear"),
181
+ build=lambda xp: (HS71(xp), _arr(xp, [1.0, 5.0, 5.0, 1.0])),
182
+ optimum=_known,
183
+ # The Mehrotra/Gondzio correctors sit at HS71's convergence edge on this
184
+ # nonconvex problem and stall on some backends/platforms (e.g. CI's
185
+ # Torch build) while converging on others — a known corrector-robustness
186
+ # gap, not a per-PR regression. Exclude those configs here so the gate is
187
+ # deterministic; HS71 is still swept on every stable route, and covered
188
+ # under the default solve by the integration tests.
189
+ exclude_configs=("exact/dense+mehrotra", "exact/dense+gondzio"),
190
+ ),
152
191
  _rt_case(),
153
192
  ]
154
193
 
@@ -0,0 +1,413 @@
1
+ """Precompiled S2MPJ evaluator (benchmark-side, NumPy/SciPy allowed).
2
+
3
+ S2MPJ's generic ``CUTEst_problem.evalgrsum`` re-interprets the group-partially-
4
+ separable problem structure on **every** evaluation: each element and group
5
+ function is dispatched through ``eval('self.'+name+'(...)')`` (a string compile
6
+ per call), the Jacobian is assembled row-by-row into a ``lil_matrix`` (with each
7
+ group's dense gradient converted to ``lil`` just to write one row), and the
8
+ linear term is sliced out of the sparse ``A`` one group at a time. Profiling the
9
+ S2MPJ sweep shows ~90% of solve wall-time in that interpretive loop — tiny
10
+ problems like ACOPP14 (n=38) cost ~10 ms per constraint evaluation.
11
+
12
+ :class:`FastS2MPJEval` compiles the same structure **once per instance**: element
13
+ and group functions are resolved to callables via ``getattr``, all linear terms
14
+ become one vectorized ``A @ x − gconst`` product, and the Jacobian is built as
15
+ COO triplets on each group's precomputed sparsity support. The element/group
16
+ functions themselves — the actual math S2MPJ generated from the SIF file — are
17
+ untouched, so values match the original to floating-point roundoff (summation
18
+ order differs). Measured: 8–130× per ``cx`` call, 6–37× end-to-end solves.
19
+
20
+ :func:`fast_evaluator` is the safe entry point: it builds the fast evaluator and
21
+ **verifies it against the original methods** at the start point (and a perturbed
22
+ point) before use, returning ``None`` — meaning "use the original methods" — on
23
+ any unsupported feature, construction error, or numerical mismatch. A corpus
24
+ oddity can therefore never silently corrupt benchmark scores. The result is
25
+ cached on the instance, so the per-config problem rebuilds share one
26
+ verification.
27
+
28
+ Scope: values, gradients, and Jacobians (``fx/fgx/cx/cJx``, ``nargout ≤ 2``).
29
+ The Lagrangian-Hessian path (``LgHxy``) keeps the original implementation.
30
+ Problems that declare *partial* derivative levels (``objderlvl``/``conderlvl``
31
+ < 2, where the original substitutes NaNs) are rejected at construction and fall
32
+ back to the original methods.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any
38
+
39
+ import numpy as np
40
+ import scipy.sparse as sp
41
+
42
+ # Element entry: (element function, elemental variable indices, weight or None,
43
+ # element index). Group entry: (elements, group function or None for TRIVIAL).
44
+ _Element = tuple[Any, np.ndarray, float | None, int]
45
+ _Group = tuple[list[_Element], Any]
46
+
47
+ # Agreement gates for verify-at-build: a genuine reimplementation bug is orders
48
+ # of magnitude larger than the summation-order roundoff these absorb.
49
+ _VERIFY_RTOL = 1e-7
50
+ _VERIFY_ATOL = 1e-9
51
+
52
+ _FAST_ATTR = "_ipax_fast_eval"
53
+ _UNSET = object()
54
+
55
+
56
+ class FastS2MPJEval:
57
+ """Precompiled ``fx/fgx/cx/cJx`` for one S2MPJ ``CUTEst_problem`` instance.
58
+
59
+ Raises ``NotImplementedError`` (or any construction error) when the instance
60
+ uses a feature outside the replicated ``evalgrsum`` semantics; callers should
61
+ then use the original methods (see :func:`fast_evaluator`).
62
+ """
63
+
64
+ def __init__(self, instance: Any) -> None:
65
+ self.inst = instance
66
+ getglobs = getattr(instance, "getglobs", None)
67
+ if getglobs is not None: # set global element/group parameters once
68
+ getglobs()
69
+ n = int(instance.n)
70
+ self.n = n
71
+
72
+ # Partial derivative levels make evalgrsum emit NaN rows; keep those on
73
+ # the original path rather than replicating the NaN bookkeeping.
74
+ if int(getattr(instance, "objderlvl", 2)) < 2:
75
+ raise NotImplementedError("partial objective derivatives")
76
+ conderlvl = getattr(instance, "conderlvl", None)
77
+ if conderlvl is not None and any(int(d) < 2 for d in conderlvl):
78
+ raise NotImplementedError("partial constraint derivatives")
79
+
80
+ self.objgrps = np.asarray(getattr(instance, "objgrps", ()), dtype=int).reshape(
81
+ -1
82
+ )
83
+ self.congrps = np.asarray(getattr(instance, "congrps", ()), dtype=int).reshape(
84
+ -1
85
+ )
86
+ n_groups = int(max([*self.objgrps, *self.congrps, -1])) + 1
87
+
88
+ self.gconst = self._dense_per_group(
89
+ getattr(instance, "gconst", None), n_groups, default=0.0
90
+ )
91
+ gscale = self._dense_per_group(
92
+ getattr(instance, "gscale", None), n_groups, default=1.0
93
+ )
94
+ # evalgrsum treats |scale| ≤ 1e-15 as "no scaling"
95
+ gscale[np.abs(gscale) <= 1.0e-15] = 1.0
96
+ self.gscale = gscale
97
+
98
+ # Linear term: one padded CSR so every group's row is A[ig].
99
+ if hasattr(instance, "A"):
100
+ A = sp.csr_matrix(instance.A, dtype=float)
101
+ if A.shape[1] > n: # evalgrsum requires sA2 ≤ n (gin[:sA2] scatter)
102
+ raise NotImplementedError("linear term wider than n")
103
+ A.resize((max(A.shape[0], n_groups), n))
104
+ else:
105
+ A = sp.csr_matrix((n_groups, n))
106
+ self.A = A
107
+
108
+ self.obj_groups = [self._compile_group(int(ig)) for ig in self.objgrps]
109
+ self.con_groups = [self._compile_group(int(ig)) for ig in self.congrps]
110
+
111
+ # Per-constraint-group Jacobian support: A-row indices ∪ element vars.
112
+ self.con_support: list[np.ndarray] = []
113
+ for k, ig in enumerate(self.congrps):
114
+ cols = set(A.indices[A.indptr[ig] : A.indptr[ig + 1]].tolist())
115
+ for _fn, idx, _w, _iel in self.con_groups[k][0]:
116
+ cols.update(idx.tolist())
117
+ self.con_support.append(
118
+ np.fromiter(sorted(cols), dtype=int, count=len(cols))
119
+ )
120
+
121
+ self.H = getattr(instance, "H", None)
122
+ self._has_objective = len(self.objgrps) > 0 or self.H is not None
123
+
124
+ @staticmethod
125
+ def _dense_per_group(values: Any, n_groups: int, *, default: float) -> np.ndarray:
126
+ """Densify a lazily-padded per-group S2MPJ array (None entries → default)."""
127
+ out = np.full(n_groups, default)
128
+ if values is None:
129
+ return out
130
+ for ig in range(min(len(values), n_groups)):
131
+ v = values[ig]
132
+ if v is not None:
133
+ out[ig] = float(np.asarray(v).reshape(-1)[0])
134
+ return out
135
+
136
+ def _compile_group(self, ig: int) -> _Group:
137
+ """Resolve one group's element list and group function to callables."""
138
+ inst = self.inst
139
+ elements: list[_Element] = []
140
+ grelt = getattr(inst, "grelt", None)
141
+ if grelt is not None and ig < len(grelt) and grelt[ig] is not None:
142
+ grelw = getattr(inst, "grelw", None)
143
+ weights = (
144
+ grelw[ig]
145
+ if grelw is not None and ig < len(grelw) and grelw[ig] is not None
146
+ else None
147
+ )
148
+ for pos, iel in enumerate(grelt[ig]):
149
+ iel = int(iel)
150
+ fn = getattr(inst, inst.elftype[iel])
151
+ idx = np.asarray([int(iv) for iv in inst.elvar[iel]], dtype=int)
152
+ w = None if weights is None else float(np.asarray(weights[pos]))
153
+ elements.append((fn, idx, w, iel))
154
+ gname = "TRIVIAL"
155
+ grftype = getattr(inst, "grftype", None)
156
+ if grftype is not None and ig < len(grftype) and grftype[ig] is not None:
157
+ gname = grftype[ig]
158
+ gfn = None if gname == "TRIVIAL" else getattr(inst, gname)
159
+ return elements, gfn
160
+
161
+ # -- constraints --------------------------------------------------------
162
+
163
+ def cx(self, x: Any) -> np.ndarray:
164
+ """Constraint values, shape ``(m, 1)`` (mirrors the original ``cx``)."""
165
+ x = np.asarray(x, dtype=float).reshape(-1)
166
+ xc = x.reshape(-1, 1)
167
+ fin = (self.A @ x)[self.congrps] - self.gconst[self.congrps]
168
+ out = np.empty((len(self.congrps), 1))
169
+ for k, (elements, gfn) in enumerate(self.con_groups):
170
+ f = fin[k]
171
+ for fn, idx, w, iel in elements:
172
+ fiel = fn(self.inst, 1, xc[idx], iel)
173
+ f = f + (w * fiel if w is not None else fiel)
174
+ ig = int(self.congrps[k])
175
+ if gfn is not None:
176
+ f = gfn(self.inst, 1, f, ig)
177
+ out[k, 0] = float(np.asarray(f).reshape(-1)[0]) / self.gscale[ig]
178
+ return out
179
+
180
+ def cJx(self, x: Any) -> tuple[np.ndarray, sp.csr_matrix]:
181
+ """Constraint values and Jacobian ``(c, J)`` with ``J`` in CSR."""
182
+ x = np.asarray(x, dtype=float).reshape(-1)
183
+ xc = x.reshape(-1, 1)
184
+ m = len(self.congrps)
185
+ fin = (self.A @ x)[self.congrps] - self.gconst[self.congrps]
186
+ A = self.A
187
+ cvals = np.empty((m, 1))
188
+ rows: list[np.ndarray] = []
189
+ cols: list[np.ndarray] = []
190
+ vals: list[np.ndarray] = []
191
+ for k, (elements, gfn) in enumerate(self.con_groups):
192
+ ig = int(self.congrps[k])
193
+ support = self.con_support[k]
194
+ gin = np.zeros(len(support))
195
+ a0, a1 = A.indptr[ig], A.indptr[ig + 1]
196
+ if a1 > a0:
197
+ gin[np.searchsorted(support, A.indices[a0:a1])] += A.data[a0:a1]
198
+ f = fin[k]
199
+ for fn, idx, w, iel in elements:
200
+ fiel, giel = fn(self.inst, 2, xc[idx], iel)
201
+ giel = np.asarray(giel, dtype=float).reshape(-1)
202
+ pos = np.searchsorted(support, idx)
203
+ if w is not None:
204
+ f = f + w * fiel
205
+ np.add.at(gin, pos, w * giel)
206
+ else:
207
+ f = f + fiel
208
+ np.add.at(gin, pos, giel)
209
+ if gfn is not None:
210
+ f, grada = gfn(self.inst, 2, f, ig)
211
+ gin = grada * gin
212
+ gsc = self.gscale[ig]
213
+ cvals[k, 0] = float(np.asarray(f).reshape(-1)[0]) / gsc
214
+ if len(support):
215
+ rows.append(np.full(len(support), k))
216
+ cols.append(support)
217
+ vals.append(np.asarray(gin, dtype=float).reshape(-1) / gsc)
218
+ if rows:
219
+ J = sp.csr_matrix(
220
+ (np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))),
221
+ shape=(m, self.n),
222
+ )
223
+ else:
224
+ J = sp.csr_matrix((m, self.n))
225
+ return cvals, J
226
+
227
+ # -- objective ----------------------------------------------------------
228
+
229
+ def fx(self, x: Any) -> float:
230
+ return self._obj(x, want_grad=False) # type: ignore[return-value]
231
+
232
+ def fgx(self, x: Any) -> tuple[float, np.ndarray]:
233
+ return self._obj(x, want_grad=True) # type: ignore[return-value]
234
+
235
+ def _obj(self, x: Any, *, want_grad: bool) -> float | tuple[float, np.ndarray]:
236
+ if not self._has_objective:
237
+ raise ValueError("S2MPJ problem has no objective function")
238
+ x = np.asarray(x, dtype=float).reshape(-1)
239
+ xc = x.reshape(-1, 1)
240
+ n = self.n
241
+ fx = 0.0
242
+ gx = np.zeros(n) if want_grad else None
243
+ if self.H is not None:
244
+ Hx = np.asarray(self.H @ x).reshape(-1)
245
+ fx += 0.5 * float(x @ Hx)
246
+ if want_grad:
247
+ gx += Hx
248
+ fin = (
249
+ (self.A @ x)[self.objgrps] - self.gconst[self.objgrps]
250
+ if len(self.objgrps)
251
+ else np.zeros(0)
252
+ )
253
+ A = self.A
254
+ for k, (elements, gfn) in enumerate(self.obj_groups):
255
+ ig = int(self.objgrps[k])
256
+ f = fin[k]
257
+ if want_grad:
258
+ gin = np.zeros(n)
259
+ a0, a1 = A.indptr[ig], A.indptr[ig + 1]
260
+ if a1 > a0:
261
+ gin[A.indices[a0:a1]] += A.data[a0:a1]
262
+ for fn, idx, w, iel in elements:
263
+ if want_grad:
264
+ fiel, giel = fn(self.inst, 2, xc[idx], iel)
265
+ giel = np.asarray(giel, dtype=float).reshape(-1)
266
+ if w is not None:
267
+ f = f + w * fiel
268
+ np.add.at(gin, idx, w * giel)
269
+ else:
270
+ f = f + fiel
271
+ np.add.at(gin, idx, giel)
272
+ else:
273
+ fiel = fn(self.inst, 1, xc[idx], iel)
274
+ f = f + (w * fiel if w is not None else fiel)
275
+ if gfn is not None:
276
+ if want_grad:
277
+ f, grada = gfn(self.inst, 2, f, ig)
278
+ gin = grada * gin
279
+ else:
280
+ f = gfn(self.inst, 1, f, ig)
281
+ gsc = self.gscale[ig]
282
+ fx += float(np.asarray(f).reshape(-1)[0]) / gsc
283
+ if want_grad:
284
+ gx += gin / gsc
285
+ if want_grad:
286
+ return fx, gx.reshape(-1, 1)
287
+ return fx
288
+
289
+
290
+ # -- verify-at-build entry point ----------------------------------------------
291
+
292
+
293
+ def fast_evaluator(instance: Any) -> FastS2MPJEval | None:
294
+ """The verified fast evaluator for ``instance``, or ``None`` to use the original.
295
+
296
+ Builds :class:`FastS2MPJEval` and compares every replicated method against
297
+ the instance's original one at the start point and a perturbed point. Any
298
+ construction failure, evaluation mismatch, or inability to verify at least
299
+ one method yields ``None``. The outcome is cached on the instance so the
300
+ per-config problem rebuilds (which share the lru-cached instance) verify
301
+ only once.
302
+ """
303
+ cached = getattr(instance, _FAST_ATTR, _UNSET)
304
+ if cached is not _UNSET:
305
+ return cached # type: ignore[return-value]
306
+ fast = _build_and_verify(instance)
307
+ try:
308
+ setattr(instance, _FAST_ATTR, fast)
309
+ except Exception: # exotic instance forbids attributes — just skip caching
310
+ pass
311
+ return fast
312
+
313
+
314
+ def _build_and_verify(instance: Any) -> FastS2MPJEval | None:
315
+ try:
316
+ fast = FastS2MPJEval(instance)
317
+ x0 = np.asarray(instance.x0, dtype=float).reshape(-1)
318
+ except Exception:
319
+ return None
320
+ # Two deterministic probe points: x0 itself and a relative perturbation
321
+ # (x0 is often special — e.g. all zeros kills every linear term).
322
+ probe = x0 + 1e-4 * (1.0 + np.abs(x0)) * np.cos(np.arange(x0.shape[0]))
323
+ verified = 0
324
+ for x in (x0, probe):
325
+ agree = _agrees_at(instance, fast, x)
326
+ if agree is False:
327
+ return None
328
+ verified += agree is True
329
+ return fast if verified else None
330
+
331
+
332
+ def _agrees_at(instance: Any, fast: FastS2MPJEval, x: np.ndarray) -> bool | None:
333
+ """Tri-state agreement at one point: True/False, or None when uncheckable.
334
+
335
+ A raising *original* method skips that comparison (the original is the
336
+ authority on exceptions, and the solve would fail there anyway); a raising
337
+ or mismatching *fast* method where the original succeeded is a failure.
338
+ """
339
+ checked = 0
340
+ with np.errstate(all="ignore"):
341
+ if fast._has_objective:
342
+ for ref_fn, fast_fn, is_pair in (
343
+ (getattr(instance, "fx", None), fast.fx, False),
344
+ (getattr(instance, "fgx", None), fast.fgx, True),
345
+ ):
346
+ if ref_fn is None:
347
+ continue
348
+ try:
349
+ ref = ref_fn(x.copy())
350
+ except Exception:
351
+ continue
352
+ try:
353
+ new = fast_fn(x)
354
+ except Exception:
355
+ return False
356
+ if is_pair:
357
+ if not (_close(ref[0], new[0]) and _close(ref[1], new[1])):
358
+ return False
359
+ elif not _close(ref, new):
360
+ return False
361
+ checked += 1
362
+ if len(fast.congrps):
363
+ ref_cx = getattr(instance, "cx", None)
364
+ if ref_cx is not None:
365
+ try:
366
+ ref = ref_cx(x.copy())
367
+ except Exception:
368
+ ref = None
369
+ if ref is not None:
370
+ try:
371
+ if not _close(ref, fast.cx(x)):
372
+ return False
373
+ except Exception:
374
+ return False
375
+ checked += 1
376
+ ref_cJx = getattr(instance, "cJx", None)
377
+ if ref_cJx is not None:
378
+ try:
379
+ c_ref, J_ref = ref_cJx(x.copy())
380
+ except Exception:
381
+ c_ref = J_ref = None
382
+ if c_ref is not None:
383
+ try:
384
+ c_new, J_new = fast.cJx(x)
385
+ if not (_close(c_ref, c_new) and _jac_close(J_ref, J_new)):
386
+ return False
387
+ except Exception:
388
+ return False
389
+ checked += 1
390
+ return True if checked else None
391
+
392
+
393
+ def _close(a: Any, b: Any) -> bool:
394
+ a = np.asarray(a, dtype=float).reshape(-1)
395
+ b = np.asarray(b, dtype=float).reshape(-1)
396
+ if a.shape != b.shape:
397
+ return False
398
+ return bool(np.allclose(a, b, rtol=_VERIFY_RTOL, atol=_VERIFY_ATOL, equal_nan=True))
399
+
400
+
401
+ def _jac_close(ref: Any, new: Any) -> bool:
402
+ ref = sp.csr_matrix(ref)
403
+ new = sp.csr_matrix(new)
404
+ if ref.shape != new.shape:
405
+ return False
406
+ diff = ref - new
407
+ if diff.nnz == 0:
408
+ return True
409
+ scale = max(1.0, float(np.max(np.abs(ref.data))) if ref.nnz else 1.0)
410
+ return float(np.max(np.abs(diff.data))) <= _VERIFY_RTOL * scale
411
+
412
+
413
+ __all__ = ["FastS2MPJEval", "fast_evaluator"]