ipax 0.2.0__tar.gz → 0.3.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.
- {ipax-0.2.0 → ipax-0.3.0}/AGENTS.md +40 -2
- {ipax-0.2.0 → ipax-0.3.0}/PKG-INFO +1 -1
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/corpus/__init__.py +39 -0
- ipax-0.3.0/benchmarks/corpus/s2mpj.py +498 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/harness/__init__.py +51 -9
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/qc.py +2 -0
- ipax-0.3.0/benchmarks/runners/s2mpj.py +460 -0
- ipax-0.3.0/docs/benchmarks/s2mpj.md +129 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/concepts/linalg.md +14 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/__init__.py +1 -1
- {ipax-0.2.0 → ipax-0.3.0}/ipax/_logging.py +37 -10
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/operators.py +96 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/_routing.py +4 -1
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/cupy.py +35 -5
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/numpy_scipy.py +72 -20
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/driver.py +85 -71
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/filter_ls.py +18 -4
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/init.py +41 -1
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/kkt.py +14 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/restoration.py +23 -1
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/termination.py +11 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/sparse.py +7 -1
- {ipax-0.2.0 → ipax-0.3.0}/ipax/options.py +8 -6
- ipax-0.3.0/ipax/problem/linear_ineq.py +196 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/solve.py +11 -4
- {ipax-0.2.0 → ipax-0.3.0}/ipax/testing/problems.py +253 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_builtin_operator_contracts.py +12 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_benchmark_harness.py +12 -1
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_callback_and_logging.py +71 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_hock_schittkowski.py +52 -1
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_known_optima.py +33 -0
- ipax-0.3.0/tests/integration/test_linear_inequalities.py +224 -0
- ipax-0.3.0/tests/integration/test_s2mpj_corpus.py +226 -0
- ipax-0.3.0/tests/property/test_derivative_checks.py +189 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_filter_ls.py +21 -0
- ipax-0.3.0/tests/unit/test_init_bounds.py +32 -0
- ipax-0.3.0/tests/unit/test_linear_ineq_lowering.py +105 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_logging.py +52 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_operator_coo.py +31 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_restoration.py +43 -0
- ipax-0.3.0/tests/unit/test_s2mpj_adapter.py +202 -0
- ipax-0.3.0/tests/unit/test_sparse_operator_symmetry.py +64 -0
- ipax-0.3.0/tests/unit/test_step_failure_salvage.py +82 -0
- ipax-0.2.0/tests/property/test_derivative_checks.py +0 -94
- {ipax-0.2.0 → ipax-0.3.0}/.gitignore +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/CLAUDE.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/CONTRIBUTING.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/LICENSE +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/NOTICE +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/README.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/README.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/asv.conf.json +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/baselines/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/corpus/external.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/generators/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/harness/plots.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/reports/.gitkeep +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/bench_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/crosscheck.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/device_efficiency.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/micro/bench_kernels.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/benchmarks/runners/scaling.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/conftest.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/architecture.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/concepts/algorithm.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/concepts/problem.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/contributing.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/getting-started.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/guide/backends.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/guide/diagnostics.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/guide/options.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/guide/problems.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/guide/results.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/index.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/docs/reference.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/README.md +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/autodiff.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/bound_and_inequality.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/equality_constrained.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/lbfgs_finite_diff.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/matrix_free_krylov.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/nonconvex_hs.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/radiotherapy_sparse.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/examples/unconstrained_quadratic.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/namespace.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/jax.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/backend/sparse/torch.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/barrier.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/breedveld_ls.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/corrections.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/hessian.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/ipm/step.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/dense.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/krylov.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/regularize.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/linalg/solver.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/autodiff/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/autodiff/jax.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/autodiff/torch.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/base.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/derivatives.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/finitediff.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/function.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/problem/scaling.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/py.typed +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/result.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/testing/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/testing/backends.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/ipax/typing.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/pyproject.toml +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/_helpers.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/backends/test_cross_backend_conformance.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/conftest.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_builtin_problem_contracts.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_builtin_solver_contracts.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_builtin_sparse_contracts.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_operator_contract.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_problem_contract.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_solver_contract.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/contracts/test_sparse_contract.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_benchmark_crosscheck.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_benchmark_phase4.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_benchmark_scaling.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_corrections_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_dense_nonconvex.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_device_efficiency.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_krylov_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_scaling_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_sparse_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/integration/test_warm_start_solve.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/regression/__init__.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/regression/test_doctest_collection_adapters.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/regression/test_fraction_to_boundary_no_elementwise_sync.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/test_purity_gate.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_autodiff.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_backend_namespace.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_barrier.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_breedveld.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_corrections.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_cupy_sparse_adapter.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_dense_solver.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_hessian_lbfgs.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_inertia_correction.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_kkt.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_krylov.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_numpy_scipy_feral.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_operator_diagonal.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_options.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_problem_derivatives.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_regularize.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_result.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_scaling.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_solver_selection.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_sparse_device_routing.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_sparse_dispatch.py +0 -0
- {ipax-0.2.0 → ipax-0.3.0}/tests/unit/test_termination.py +0 -0
- {ipax-0.2.0 → ipax-0.3.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
|
-
|
|
183
|
-
hardware
|
|
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.
|
|
3
|
+
Version: 0.3.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,498 @@
|
|
|
1
|
+
"""S2MPJ problem loader (download-gated, NumPy-evaluated, backend-bridged).
|
|
2
|
+
|
|
3
|
+
`S2MPJ <https://github.com/GrattonToint/S2MPJ>`_ translates the CUTEst SIF test
|
|
4
|
+
collection (including the full Hock-Schittkowski set) into **pure Python** problem
|
|
5
|
+
files — no Fortran, no SIF decoder, no compilation, cross-platform. Each problem
|
|
6
|
+
exposes value/gradient/Jacobian/Hessian through a ``CUTEst_problem`` base class
|
|
7
|
+
(``fgx``, ``cJx``, ``LHxyv`` …) using NumPy + SciPy.
|
|
8
|
+
|
|
9
|
+
Because that evaluation is hardcoded to NumPy/SciPy, the problems cannot run
|
|
10
|
+
*natively* under an arbitrary Array-API backend. :class:`_S2MPJProblem` instead
|
|
11
|
+
**bridges**: it evaluates in NumPy and converts inputs/outputs to/from the target
|
|
12
|
+
namespace, so ipax's solver runs its linear algebra on any (CPU) backend while the
|
|
13
|
+
model is evaluated on the host. This is for accuracy / cross-backend consistency
|
|
14
|
+
benchmarking; it forces a host sync per evaluation, so it is *not* for GPU
|
|
15
|
+
performance work (use the synthetic RT generator / TROTS surrogate there).
|
|
16
|
+
|
|
17
|
+
S2MPJ has no license file, so its files are **not vendored**. Point
|
|
18
|
+
``IPAX_S2MPJ_DIR`` (or the ``directory`` argument) at a local checkout; the loader
|
|
19
|
+
returns ``[]`` when it is absent, mirroring the other external corpora.
|
|
20
|
+
|
|
21
|
+
S2MPJ stores each constraint as a two-sided range ``clower ≤ c(x) ≤ cupper`` with
|
|
22
|
+
an equality when the two coincide. The adapter maps that onto ipax's eq/ineq
|
|
23
|
+
split, lowering finite inequality sides to one-sided rows (``c − cupper ≤ 0`` and
|
|
24
|
+
``clower − c ≤ 0``) exactly as the native ``linear_ineq`` lowering does.
|
|
25
|
+
|
|
26
|
+
S2MPJ also exposes the **exact Lagrangian Hessian** (``LgHxy``/``LHxyv``, convention
|
|
27
|
+
``L = f + yᵀc``), so the adapter can drive ipax's exact-Hessian route — not only the
|
|
28
|
+
default L-BFGS. :class:`_S2MPJExactProblem` implements ``lagrangian_hessian`` by
|
|
29
|
+
mapping ipax's ``(σ, y_eq, y_ineq)`` onto S2MPJ's single multiplier vector ``Y``
|
|
30
|
+
(equality rows ``+y_eq``; lowered lower-side rows ``−y_ineq`` because their curvature
|
|
31
|
+
is ``−∇²c``; lowered upper-side rows ``+y_ineq``) and honoring ``σ`` on the objective
|
|
32
|
+
term (so it stays correct under gradient-based scaling, where ``σ ≠ 1``). With
|
|
33
|
+
``sparse=True`` the Jacobians and the Hessian are returned as
|
|
34
|
+
:class:`~ipax.backend.sparse.numpy_scipy.SparseOperator` (true COO sparsity, for the
|
|
35
|
+
sparse-direct route) rather than densified arrays.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import functools
|
|
41
|
+
import importlib
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
from typing import TYPE_CHECKING, Any
|
|
46
|
+
|
|
47
|
+
from ipax.problem.base import Problem
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Sequence
|
|
51
|
+
|
|
52
|
+
from benchmarks.corpus import BenchmarkProblem
|
|
53
|
+
from ipax.backend.operators import LinearOperator
|
|
54
|
+
from ipax.typing import Array, Namespace, Scalar
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_numpy(x: Array) -> Any:
|
|
58
|
+
"""Convert an Array-API array to a 1-D NumPy array (host bridge)."""
|
|
59
|
+
import numpy as np
|
|
60
|
+
|
|
61
|
+
if isinstance(x, np.ndarray):
|
|
62
|
+
return np.reshape(x, (-1,))
|
|
63
|
+
try:
|
|
64
|
+
return np.reshape(np.from_dlpack(x), (-1,))
|
|
65
|
+
except (TypeError, RuntimeError, BufferError, ValueError):
|
|
66
|
+
return np.reshape(np.asarray(x), (-1,))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _from_numpy(xp: Namespace, arr: Any) -> Array:
|
|
70
|
+
"""Convert a NumPy array back into the target namespace as float64 (1-/2-D)."""
|
|
71
|
+
import numpy as np
|
|
72
|
+
|
|
73
|
+
return xp.asarray(np.asarray(arr, dtype=np.float64))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _S2MPJProblem(Problem):
|
|
77
|
+
"""Bridge a NumPy-evaluated S2MPJ problem onto an Array-API namespace.
|
|
78
|
+
|
|
79
|
+
The wrapped ``instance`` is a CUTEst_problem from S2MPJ; ``xp`` is the target
|
|
80
|
+
namespace. Equality vs inequality constraints and the finite inequality sides
|
|
81
|
+
are precomputed once from ``clower``/``cupper``.
|
|
82
|
+
|
|
83
|
+
``sparse`` controls how Jacobians (and, in :class:`_S2MPJExactProblem`, the
|
|
84
|
+
Hessian) cross the bridge: ``False`` densifies to Array-API arrays; ``True``
|
|
85
|
+
wraps the native ``scipy.sparse`` structure in a ``SparseOperator`` so the
|
|
86
|
+
sparse-direct route factors true sparsity. This base class supplies no
|
|
87
|
+
Lagrangian Hessian, so the solver uses its default L-BFGS.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
instance: Any,
|
|
93
|
+
xp: Namespace,
|
|
94
|
+
*,
|
|
95
|
+
sparse: bool = False,
|
|
96
|
+
feasibility: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
import numpy as np
|
|
99
|
+
|
|
100
|
+
self._sparse = sparse
|
|
101
|
+
# Dataset-sourced expected outcome (set by the loader from the source file):
|
|
102
|
+
# the documented ``LO SOLTN`` objective, the ``infeasible`` marker, and the
|
|
103
|
+
# CUTEst classification. Defaults here so a directly-constructed instance is
|
|
104
|
+
# still valid; the loader overwrites them.
|
|
105
|
+
self.expected_objective: float | None = None
|
|
106
|
+
self.expected_infeasible: bool = False
|
|
107
|
+
self.pbclass: str | None = None
|
|
108
|
+
|
|
109
|
+
# A problem with no objective group is a *feasibility* / nonlinear-equations
|
|
110
|
+
# system, not a minimization problem (S2MPJ's ``fx`` would error). By default
|
|
111
|
+
# we reject it; with ``feasibility=True`` we run it as ``min 0`` s.t. the
|
|
112
|
+
# constraints, turning the IPM into a feasibility finder. ``H`` is the
|
|
113
|
+
# optional explicit quadratic objective.
|
|
114
|
+
self._no_objective = len(getattr(instance, "objgrps", ())) == 0 and not hasattr(
|
|
115
|
+
instance, "H"
|
|
116
|
+
)
|
|
117
|
+
if self._no_objective and not feasibility:
|
|
118
|
+
raise NotImplementedError("S2MPJ problem has no objective function")
|
|
119
|
+
|
|
120
|
+
self._inst = instance
|
|
121
|
+
self.xp = xp
|
|
122
|
+
self._n = int(instance.n)
|
|
123
|
+
m = int(getattr(instance, "m", 0))
|
|
124
|
+
self._m = m
|
|
125
|
+
|
|
126
|
+
# ``clower``/``cupper`` exist only when the problem has constraints.
|
|
127
|
+
clower_attr = getattr(instance, "clower", None)
|
|
128
|
+
cupper_attr = getattr(instance, "cupper", None)
|
|
129
|
+
if m > 0 and clower_attr is not None and cupper_attr is not None:
|
|
130
|
+
clower = np.reshape(np.asarray(clower_attr, dtype=float), (-1,))
|
|
131
|
+
cupper = np.reshape(np.asarray(cupper_attr, dtype=float), (-1,))
|
|
132
|
+
finite_l = np.isfinite(clower)
|
|
133
|
+
finite_u = np.isfinite(cupper)
|
|
134
|
+
eq_mask = finite_l & finite_u & (clower == cupper)
|
|
135
|
+
self._eq_idx = np.where(eq_mask)[0]
|
|
136
|
+
self._eq_rhs = clower[self._eq_idx]
|
|
137
|
+
self._lo_idx = np.where(finite_l & ~eq_mask)[0] # clower ≤ c ⇒ clower−c ≤ 0
|
|
138
|
+
self._up_idx = np.where(finite_u & ~eq_mask)[0] # c ≤ cupper ⇒ c−cupper ≤ 0
|
|
139
|
+
self._lo_rhs = clower[self._lo_idx]
|
|
140
|
+
self._up_rhs = cupper[self._up_idx]
|
|
141
|
+
else:
|
|
142
|
+
empty_i = np.zeros((0,), dtype=int)
|
|
143
|
+
empty_f = np.zeros((0,), dtype=float)
|
|
144
|
+
self._eq_idx = self._lo_idx = self._up_idx = empty_i
|
|
145
|
+
self._eq_rhs = self._lo_rhs = self._up_rhs = empty_f
|
|
146
|
+
|
|
147
|
+
lower_attr = getattr(instance, "xlower", None)
|
|
148
|
+
upper_attr = getattr(instance, "xupper", None)
|
|
149
|
+
self._lower = (
|
|
150
|
+
None
|
|
151
|
+
if lower_attr is None
|
|
152
|
+
else _from_numpy(xp, np.reshape(np.asarray(lower_attr, dtype=float), (-1,)))
|
|
153
|
+
)
|
|
154
|
+
self._upper = (
|
|
155
|
+
None
|
|
156
|
+
if upper_attr is None
|
|
157
|
+
else _from_numpy(xp, np.reshape(np.asarray(upper_attr, dtype=float), (-1,)))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def n_vars(self) -> int:
|
|
162
|
+
return self._n
|
|
163
|
+
|
|
164
|
+
def bounds(self) -> tuple[Array | None, Array | None]:
|
|
165
|
+
return self._lower, self._upper
|
|
166
|
+
|
|
167
|
+
def objective(self, x: Array) -> Scalar:
|
|
168
|
+
import numpy as np
|
|
169
|
+
|
|
170
|
+
if self._no_objective: # feasibility problem: minimize a constant 0
|
|
171
|
+
return _from_numpy(self.xp, np.asarray(0.0))
|
|
172
|
+
# S2MPJ's auto-generated evaluations use Python ``float**`` and can raise
|
|
173
|
+
# OverflowError on the wild trial points a line search probes (e.g.
|
|
174
|
+
# LUKVLE4C's ``100*GVAR**6``). Return +inf so the solver simply rejects
|
|
175
|
+
# the trial rather than crashing. Also return an xp float64 0-d (not a
|
|
176
|
+
# Python float) so the value keeps the backend dtype — a Python float
|
|
177
|
+
# re-cast via ``xp.asarray`` would silently drop to float32 on Torch.
|
|
178
|
+
try:
|
|
179
|
+
val = np.asarray(self._inst.fx(_to_numpy(x)))
|
|
180
|
+
except (OverflowError, FloatingPointError):
|
|
181
|
+
val = np.asarray(np.inf)
|
|
182
|
+
return _from_numpy(self.xp, val)
|
|
183
|
+
|
|
184
|
+
def gradient(self, x: Array) -> Array:
|
|
185
|
+
import numpy as np
|
|
186
|
+
|
|
187
|
+
if self._no_objective: # constant objective ⇒ zero gradient
|
|
188
|
+
return _from_numpy(self.xp, np.zeros((self._n,)))
|
|
189
|
+
try:
|
|
190
|
+
_f, g = self._inst.fgx(_to_numpy(x))
|
|
191
|
+
g = np.reshape(g, (-1,))
|
|
192
|
+
except (OverflowError, FloatingPointError):
|
|
193
|
+
g = np.full((self._n,), np.inf)
|
|
194
|
+
return _from_numpy(self.xp, g)
|
|
195
|
+
|
|
196
|
+
# -- equalities (present only when S2MPJ has clower == cupper rows) ----
|
|
197
|
+
def eq_constraints(self, x: Array) -> Array:
|
|
198
|
+
if self._eq_idx.shape[0] == 0:
|
|
199
|
+
raise NotImplementedError
|
|
200
|
+
import numpy as np
|
|
201
|
+
|
|
202
|
+
c = np.reshape(np.asarray(self._inst.cx(_to_numpy(x)), dtype=float), (-1,))
|
|
203
|
+
return _from_numpy(self.xp, c[self._eq_idx] - self._eq_rhs)
|
|
204
|
+
|
|
205
|
+
def eq_jacobian(self, x: Array) -> Array | LinearOperator:
|
|
206
|
+
if self._eq_idx.shape[0] == 0:
|
|
207
|
+
raise NotImplementedError
|
|
208
|
+
_c, jac = self._inst.cJx(_to_numpy(x))
|
|
209
|
+
if self._sparse:
|
|
210
|
+
return self._sparse_op(jac.tocsr()[self._eq_idx, :])
|
|
211
|
+
return _from_numpy(self.xp, jac.toarray()[self._eq_idx, :])
|
|
212
|
+
|
|
213
|
+
# -- inequalities (lowered finite sides of the two-sided ranges) -------
|
|
214
|
+
def ineq_constraints(self, x: Array) -> Array:
|
|
215
|
+
if self._lo_idx.shape[0] == 0 and self._up_idx.shape[0] == 0:
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
import numpy as np
|
|
218
|
+
|
|
219
|
+
c = np.reshape(np.asarray(self._inst.cx(_to_numpy(x)), dtype=float), (-1,))
|
|
220
|
+
lo = self._lo_rhs - c[self._lo_idx]
|
|
221
|
+
up = c[self._up_idx] - self._up_rhs
|
|
222
|
+
return _from_numpy(self.xp, np.concatenate((lo, up)))
|
|
223
|
+
|
|
224
|
+
def ineq_jacobian(self, x: Array) -> Array | LinearOperator:
|
|
225
|
+
if self._lo_idx.shape[0] == 0 and self._up_idx.shape[0] == 0:
|
|
226
|
+
raise NotImplementedError
|
|
227
|
+
import numpy as np
|
|
228
|
+
|
|
229
|
+
_c, jac = self._inst.cJx(_to_numpy(x))
|
|
230
|
+
# Lower side ``clower − c`` contributes ``−∇c``; upper side ``c − cupper``
|
|
231
|
+
# contributes ``+∇c`` (matches the constraint-value lowering above).
|
|
232
|
+
if self._sparse:
|
|
233
|
+
import scipy.sparse as sp
|
|
234
|
+
|
|
235
|
+
jcsr = jac.tocsr()
|
|
236
|
+
rows = sp.vstack((-jcsr[self._lo_idx, :], jcsr[self._up_idx, :]))
|
|
237
|
+
return self._sparse_op(rows)
|
|
238
|
+
dense = jac.toarray()
|
|
239
|
+
rows_d = np.concatenate(
|
|
240
|
+
(-dense[self._lo_idx, :], dense[self._up_idx, :]), axis=0
|
|
241
|
+
)
|
|
242
|
+
return _from_numpy(self.xp, rows_d)
|
|
243
|
+
|
|
244
|
+
# -- shared Hessian helpers (used by the exact subclass) ---------------
|
|
245
|
+
def _sparse_op(self, matrix: Any) -> LinearOperator:
|
|
246
|
+
"""Wrap a host ``scipy.sparse`` matrix as a COO-exposing operator."""
|
|
247
|
+
from ipax.backend.sparse.numpy_scipy import SparseOperator
|
|
248
|
+
|
|
249
|
+
return SparseOperator(matrix, self.xp)
|
|
250
|
+
|
|
251
|
+
def _s2mpj_multipliers(self, y_eq: Array, y_ineq: Array) -> Any:
|
|
252
|
+
"""Map ipax ``(y_eq, y_ineq)`` onto S2MPJ's per-constraint vector ``Y``.
|
|
253
|
+
|
|
254
|
+
S2MPJ's Lagrangian is ``f + Σ_i Y_i c_i`` over the *original* constraint
|
|
255
|
+
rows, so ``Σ_i Y_i ∇²c_i`` must reproduce ipax's curvature term
|
|
256
|
+
``Σ y_eq·∇²c + Σ y_ineq·∇²g``. Equality rows take ``+y_eq``; lowered
|
|
257
|
+
lower-side rows take ``−y_ineq`` (their ``g = clower − c`` has
|
|
258
|
+
``∇²g = −∇²c``); lowered upper-side rows take ``+y_ineq``. A range
|
|
259
|
+
constraint that appears in both blocks accumulates both contributions.
|
|
260
|
+
"""
|
|
261
|
+
import numpy as np
|
|
262
|
+
|
|
263
|
+
Y = np.zeros((self._m,), dtype=float)
|
|
264
|
+
if self._m == 0:
|
|
265
|
+
return Y
|
|
266
|
+
yeq = _to_numpy(y_eq)
|
|
267
|
+
yineq = _to_numpy(y_ineq)
|
|
268
|
+
n_lo = int(self._lo_idx.shape[0])
|
|
269
|
+
if self._eq_idx.shape[0]:
|
|
270
|
+
np.add.at(Y, self._eq_idx, yeq)
|
|
271
|
+
if n_lo:
|
|
272
|
+
np.add.at(Y, self._lo_idx, -yineq[:n_lo])
|
|
273
|
+
if self._up_idx.shape[0]:
|
|
274
|
+
np.add.at(Y, self._up_idx, yineq[n_lo:])
|
|
275
|
+
return Y
|
|
276
|
+
|
|
277
|
+
def _lagrangian_hessian_matrix(
|
|
278
|
+
self, x: Array, y_eq: Array, y_ineq: Array, sigma: Scalar
|
|
279
|
+
) -> Any:
|
|
280
|
+
"""Assemble ``σ∇²f + Σ y·∇²c`` as a host ``scipy.sparse`` matrix.
|
|
281
|
+
|
|
282
|
+
``LgHxy`` returns ``∇²f + Σ Y_i ∇²c_i`` (no objective scaling); the
|
|
283
|
+
``σ ≠ 1`` correction adds ``(σ−1)∇²f`` via a constraint-free call, so the
|
|
284
|
+
result is correct under gradient-based scaling, where the driver passes
|
|
285
|
+
``σ = s_f`` through the scaling wrapper.
|
|
286
|
+
"""
|
|
287
|
+
import numpy as np
|
|
288
|
+
|
|
289
|
+
s = float(sigma)
|
|
290
|
+
xcol = np.reshape(_to_numpy(x), (-1, 1))
|
|
291
|
+
Y = self._s2mpj_multipliers(y_eq, y_ineq)
|
|
292
|
+
_lval, _grad, hess = self._inst.LgHxy(xcol, np.reshape(Y, (-1, 1)))
|
|
293
|
+
if s != 1.0:
|
|
294
|
+
_l0, _g0, hess_f = self._inst.LgHxy(xcol, np.zeros((self._m, 1)))
|
|
295
|
+
hess = hess + (s - 1.0) * hess_f
|
|
296
|
+
return hess
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class _S2MPJExactProblem(_S2MPJProblem):
|
|
300
|
+
"""S2MPJ bridge that also supplies the **exact Lagrangian Hessian**.
|
|
301
|
+
|
|
302
|
+
Defining ``lagrangian_hessian`` on the class advertises it through ipax's
|
|
303
|
+
derivative resolution (``_provides`` compares against the base ``Problem``),
|
|
304
|
+
so the solver takes the exact-Hessian route instead of L-BFGS. The matrix is
|
|
305
|
+
returned dense or as a ``SparseOperator`` per the ``sparse`` flag.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def lagrangian_hessian(
|
|
309
|
+
self,
|
|
310
|
+
x: Array,
|
|
311
|
+
y_eq: Array,
|
|
312
|
+
y_ineq: Array,
|
|
313
|
+
sigma: Scalar = 1.0,
|
|
314
|
+
) -> Array | LinearOperator:
|
|
315
|
+
import numpy as np
|
|
316
|
+
|
|
317
|
+
hess = self._lagrangian_hessian_matrix(x, y_eq, y_ineq, sigma)
|
|
318
|
+
if self._sparse:
|
|
319
|
+
import scipy.sparse as sp
|
|
320
|
+
|
|
321
|
+
return self._sparse_op(sp.csr_matrix(hess))
|
|
322
|
+
return _from_numpy(self.xp, np.asarray(hess.toarray()))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def s2mpj_dir(directory: str | None = None) -> str | None:
|
|
326
|
+
"""Resolve the S2MPJ checkout directory, or ``None`` if unavailable.
|
|
327
|
+
|
|
328
|
+
Uses ``directory`` if given, else the ``IPAX_S2MPJ_DIR`` environment variable.
|
|
329
|
+
A directory qualifies only if it holds ``s2mpjlib.py`` and ``python_problems``.
|
|
330
|
+
"""
|
|
331
|
+
root = directory or os.environ.get("IPAX_S2MPJ_DIR")
|
|
332
|
+
if not root:
|
|
333
|
+
return None
|
|
334
|
+
if not os.path.isfile(os.path.join(root, "s2mpjlib.py")):
|
|
335
|
+
return None
|
|
336
|
+
if not os.path.isdir(os.path.join(root, "python_problems")):
|
|
337
|
+
return None
|
|
338
|
+
return root
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def list_s2mpj_problems(directory: str | None = None) -> list[str]:
|
|
342
|
+
"""All S2MPJ problem names available in the checkout (sorted).
|
|
343
|
+
|
|
344
|
+
Globs ``python_problems/*.py`` (the actual files present, more reliable than
|
|
345
|
+
the repo's ``list_of_python_problems`` which can include editor backups),
|
|
346
|
+
dropping the ``s2mpjlib`` support module. Returns ``[]`` when no checkout is
|
|
347
|
+
found. Note: not every name is benchmarkable — objective-free problems are
|
|
348
|
+
rejected at build time, and the runner caps problem size.
|
|
349
|
+
"""
|
|
350
|
+
import glob
|
|
351
|
+
|
|
352
|
+
root = s2mpj_dir(directory)
|
|
353
|
+
if root is None:
|
|
354
|
+
return []
|
|
355
|
+
pattern = os.path.join(root, "python_problems", "*.py")
|
|
356
|
+
names = sorted(
|
|
357
|
+
os.path.splitext(os.path.basename(path))[0]
|
|
358
|
+
for path in glob.glob(pattern)
|
|
359
|
+
if os.path.basename(path) != "s2mpjlib.py"
|
|
360
|
+
)
|
|
361
|
+
return names
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@functools.lru_cache(maxsize=2048)
|
|
365
|
+
def _problem_metadata(root: str, name: str) -> tuple[str | None, float | None, bool]:
|
|
366
|
+
"""Parse ``(pbclass, expected_objective, expected_infeasible)`` from the source.
|
|
367
|
+
|
|
368
|
+
These come from the dataset itself, not our own judgement: ``self.pbclass`` is
|
|
369
|
+
the CUTEst classification; ``# LO SOLTN <value>`` is the SIF author's documented
|
|
370
|
+
solution objective (present on ~72% of the corpus); and an explicit
|
|
371
|
+
``Solution (infeasible)`` / ``Source: an infeasible problem`` comment marks the
|
|
372
|
+
deliberately-infeasible problems (e.g. BURKEHAN). Missing fields → ``None`` /
|
|
373
|
+
``False``.
|
|
374
|
+
"""
|
|
375
|
+
path = os.path.join(root, "python_problems", name + ".py")
|
|
376
|
+
try:
|
|
377
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
378
|
+
text = fh.read()
|
|
379
|
+
except OSError:
|
|
380
|
+
return None, None, False
|
|
381
|
+
|
|
382
|
+
pbclass = None
|
|
383
|
+
match = re.search(r'self\.pbclass\s*=\s*"([^"]+)"', text)
|
|
384
|
+
if match:
|
|
385
|
+
pbclass = match.group(1)
|
|
386
|
+
|
|
387
|
+
expected_objective: float | None = None
|
|
388
|
+
match = re.search(r"(?im)^\s*#\s*LO\s+SOLTN\s+([-+0-9.eEdD]+)", text)
|
|
389
|
+
if match:
|
|
390
|
+
try: # SIF may use Fortran 'D' exponents
|
|
391
|
+
expected_objective = float(
|
|
392
|
+
match.group(1).replace("D", "E").replace("d", "e")
|
|
393
|
+
)
|
|
394
|
+
except ValueError:
|
|
395
|
+
expected_objective = None
|
|
396
|
+
|
|
397
|
+
infeasible = bool(
|
|
398
|
+
re.search(r"(?i)solution\s*\(\s*infeasible\s*\)", text)
|
|
399
|
+
or re.search(r"(?i)source:\s*an?\s+infeasible", text)
|
|
400
|
+
)
|
|
401
|
+
return pbclass, expected_objective, infeasible
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _ensure_on_path(root: str) -> None:
|
|
405
|
+
problems = os.path.join(root, "python_problems")
|
|
406
|
+
for entry in (root, problems):
|
|
407
|
+
if entry not in sys.path:
|
|
408
|
+
sys.path.insert(0, entry)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@functools.lru_cache(maxsize=8)
|
|
412
|
+
def _instantiate(root: str, name: str, size: int | None = None) -> Any:
|
|
413
|
+
"""Instantiate an S2MPJ problem, optionally at a target variable count.
|
|
414
|
+
|
|
415
|
+
Cached (small LRU) because the runner builds the same problem once per config
|
|
416
|
+
(gate + every linear-solver route), and S2MPJ's pure-Python construction is the
|
|
417
|
+
sweep's bottleneck — sharing the read-only instance across the per-problem
|
|
418
|
+
config fan-out turns ~5 builds into one. ``size`` selects a scalable problem's
|
|
419
|
+
dimension (``PROBLEM(N)``); a problem that is not size-parametrized (or rejects
|
|
420
|
+
``N``) falls back to its SIF default, so callers may request a size uniformly.
|
|
421
|
+
"""
|
|
422
|
+
_ensure_on_path(root)
|
|
423
|
+
cls = getattr(importlib.import_module(name), name)
|
|
424
|
+
if size is not None:
|
|
425
|
+
try:
|
|
426
|
+
return cls(size)
|
|
427
|
+
except Exception: # not scalable / invalid N → SIF default size
|
|
428
|
+
return cls()
|
|
429
|
+
return cls()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# A small, curated default selection: constrained Hock-Schittkowski problems that
|
|
433
|
+
# the L-BFGS path should solve. The loader accepts any S2MPJ problem name.
|
|
434
|
+
_DEFAULT_NAMES = ("HS21", "HS35", "HS71", "HS6", "HS7", "HS8", "HS28")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def s2mpj_problems(
|
|
438
|
+
names: Sequence[str] | None = None,
|
|
439
|
+
*,
|
|
440
|
+
directory: str | None = None,
|
|
441
|
+
backends: tuple[str, ...] = ("numpy",),
|
|
442
|
+
hessian: str = "lbfgs",
|
|
443
|
+
sparse: bool = False,
|
|
444
|
+
size: int | None = None,
|
|
445
|
+
feasibility: bool = False,
|
|
446
|
+
) -> list[BenchmarkProblem]:
|
|
447
|
+
"""Return :class:`BenchmarkProblem`s for the named S2MPJ problems.
|
|
448
|
+
|
|
449
|
+
Returns ``[]`` when no S2MPJ checkout is available (so callers may extend the
|
|
450
|
+
corpus unconditionally). ``backends`` restricts each case to host-bridgeable
|
|
451
|
+
namespaces (CPU NumPy/Torch); the default is NumPy only. ``hessian="exact"``
|
|
452
|
+
builds problems that supply the analytic Lagrangian Hessian (else default
|
|
453
|
+
L-BFGS); ``sparse=True`` returns Jacobians/Hessian as ``SparseOperator`` for
|
|
454
|
+
the sparse-direct route. ``size`` requests a target variable count for the
|
|
455
|
+
scalable problems (others keep their SIF default) — the lever for a
|
|
456
|
+
scaling-focused sweep that reaches the sparse route's intended regime.
|
|
457
|
+
``feasibility=True`` admits the objective-free problems (CUTEst feasibility /
|
|
458
|
+
nonlinear-equation systems), running them as ``min 0`` subject to the
|
|
459
|
+
constraints instead of rejecting them. Each built problem carries the
|
|
460
|
+
dataset's expected outcome (``expected_objective``/``expected_infeasible``/
|
|
461
|
+
``pbclass``) for authoritative scoring.
|
|
462
|
+
"""
|
|
463
|
+
from benchmarks.corpus import BenchmarkProblem
|
|
464
|
+
|
|
465
|
+
root = s2mpj_dir(directory)
|
|
466
|
+
if root is None:
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
selected = tuple(names) if names is not None else _DEFAULT_NAMES
|
|
470
|
+
cls = _S2MPJExactProblem if hessian == "exact" else _S2MPJProblem
|
|
471
|
+
|
|
472
|
+
def _make(name: str) -> BenchmarkProblem:
|
|
473
|
+
def build(xp: Namespace) -> tuple[Problem, Array]:
|
|
474
|
+
import numpy as np
|
|
475
|
+
|
|
476
|
+
instance = _instantiate(root, name, size)
|
|
477
|
+
problem = cls(instance, xp, sparse=sparse, feasibility=feasibility)
|
|
478
|
+
pbclass, expected_objective, expected_infeasible = _problem_metadata(
|
|
479
|
+
root, name
|
|
480
|
+
)
|
|
481
|
+
problem.pbclass = pbclass
|
|
482
|
+
problem.expected_objective = expected_objective
|
|
483
|
+
problem.expected_infeasible = expected_infeasible
|
|
484
|
+
x0 = _from_numpy(xp, np.reshape(instance.x0, (-1,)))
|
|
485
|
+
return problem, x0
|
|
486
|
+
|
|
487
|
+
return BenchmarkProblem(
|
|
488
|
+
name=f"s2mpj/{name}",
|
|
489
|
+
kind="NLP",
|
|
490
|
+
tags=("s2mpj", "cutest"),
|
|
491
|
+
build=build,
|
|
492
|
+
backends=backends,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return [_make(name) for name in selected]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
__all__ = ["list_s2mpj_problems", "s2mpj_dir", "s2mpj_problems"]
|