diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a2a444a --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,31 @@ +name: Validate skills + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + unittest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.13" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: skills/osqp-solver/requirements-test.txt + - name: Install OSQP test dependency + run: python -m pip install -r skills/osqp-solver/requirements-test.txt + - name: Run repository tests with zero skips + run: python -m unittest discover -s tests -v diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 5771d25..7f2f38a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,6 +16,7 @@ attribution ledger. | Module | Source | Contributor(s) | Contribution scope | Attribution record | | --- | --- | --- | --- | --- | | Root repository router | Current `VeryMath/AI4Math-Optimization` repository | [Conan Xu](https://github.com/ConanXu-math), conanxu <1845830029@qq.com> | Root README, bilingual repository map, package routing layer, and public repository structure. | Preserved through repository commit history. | +| `skills/osqp-solver` | New package prepared in the AI4Math Skill Library from official OSQP documentation | [Conan Xu](https://github.com/ConanXu-math) | Package commissioning, integration, review, and release stewardship; implementation was AI-assisted and independently tested. | Preserve through the upstream integration commit and pull request. | | Optimization skill packages | Repository history and imported optimization work | [ricercar77](https://github.com/ricercar77) | Optimization skill contributions and package content. | Preserved through GitHub contributor graph, repository history, and this ledger. | | `skills/linear-programming`, `skills/mixed-integer-programming`, `skills/second-order-cone-programming`, `skills/or-solver` | Current package metadata | 李爽夕 | LP, MIP, SOCP, solver-setup workflow content, examples, and benchmark materials. | Recorded in package metadata and this ledger. | diff --git a/README.md b/README.md index d00d474..c387ed3 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ # AI4Math · Optimization -Skill packages for mathematical optimization modeling, solver setup, LP/MIP/SOCP -workflows, and manifold-constrained optimization. +Skill packages for mathematical optimization modeling, solver setup, +LP/MIP/SOCP workflows, continuous convex QP/OSQP, and +manifold-constrained optimization. [中文说明](README.zh-CN.md) · [Contributors](CONTRIBUTORS.md) · [Skill packages](#skill-packages) · [Installation](#installation) · [Quick start](#quick-start) · [Security model](#security-and-scope) ![version](https://img.shields.io/badge/version-0.1.0-blue) -![skills](https://img.shields.io/badge/skills-6-2ea44f) +![skills](https://img.shields.io/badge/skills-7-2ea44f) ![license](https://img.shields.io/badge/license-MIT-green) @@ -37,6 +38,7 @@ package that matches the problem class. | [`mixed-integer-programming`](skills/mixed-integer-programming/) | MILP/MIP modeling with binary, integer, and continuous decision variables. | [`README`](skills/mixed-integer-programming/README.md) · [`SKILL`](skills/mixed-integer-programming/SKILL.md) | | [`second-order-cone-programming`](skills/second-order-cone-programming/) | SOCP modeling and cvxpy-based conic solver workflows. | [`README`](skills/second-order-cone-programming/README.md) · [`SKILL`](skills/second-order-cone-programming/SKILL.md) | | [`or-solver`](skills/or-solver/) | Shared solver detection, installation planning, license checks, and solver selection for OR skills. | [`README`](skills/or-solver/README.md) · [`SKILL`](skills/or-solver/SKILL.md) | +| [`osqp-solver`](skills/osqp-solver/) | OSQP modeling, repeated solves, status gates, and independent verification for continuous convex QPs. | [`README`](skills/osqp-solver/README.md) · [`SKILL`](skills/osqp-solver/SKILL.md) | ## Installation @@ -54,6 +56,7 @@ Skill paths: - skills/mixed-integer-programming - skills/second-order-cone-programming - skills/or-solver +- skills/osqp-solver Steps: 1. Clone or update the repository locally. @@ -76,6 +79,7 @@ ln -s "$PWD/skills/linear-programming" ~/.codex/skills/linear-programming ln -s "$PWD/skills/mixed-integer-programming" ~/.codex/skills/mixed-integer-programming ln -s "$PWD/skills/second-order-cone-programming" ~/.codex/skills/second-order-cone-programming ln -s "$PWD/skills/or-solver" ~/.codex/skills/or-solver +ln -s "$PWD/skills/osqp-solver" ~/.codex/skills/osqp-solver ``` If your agent uses a different local Skill directory, replace `~/.codex/skills` with that configured path. @@ -102,6 +106,12 @@ For general LP/MIP/SOCP modeling, start with the matching package under skills/cdopt-optimization/SKILL.md ``` +For continuous convex QPs solved with OSQP, start with: + +```text +skills/osqp-solver/SKILL.md +``` + ## Repository Layout ```text @@ -115,6 +125,7 @@ AI4Math-Optimization/ ├── linear-programming/ ├── mixed-integer-programming/ ├── or-solver/ + ├── osqp-solver/ └── second-order-cone-programming/ ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 49a8d12..1bc6d25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,12 +2,13 @@ # AI4Math · 优化 -面向数学优化建模、求解器配置、LP/MIP/SOCP workflow 和流形约束优化的 AI4Math 技能集合。 +面向数学优化建模、求解器配置、LP/MIP/SOCP workflow、连续凸 QP/OSQP +和流形约束优化的 AI4Math 技能集合。 [English](README.md) · [贡献者](CONTRIBUTORS.md) · [技能包](#技能包) · [安装](#安装) · [快速开始](#快速开始) · [安全边界](#安全边界) ![version](https://img.shields.io/badge/version-0.1.0-blue) -![skills](https://img.shields.io/badge/skills-6-2ea44f) +![skills](https://img.shields.io/badge/skills-7-2ea44f) ![license](https://img.shields.io/badge/license-MIT-green) @@ -33,6 +34,7 @@ | [`mixed-integer-programming`](skills/mixed-integer-programming/) | 含 binary、integer 和 continuous 变量的 MILP/MIP 建模。 | [`README`](skills/mixed-integer-programming/README.md) · [`SKILL`](skills/mixed-integer-programming/SKILL.md) | | [`second-order-cone-programming`](skills/second-order-cone-programming/) | SOCP 建模和基于 cvxpy 的锥优化求解流程。 | [`README`](skills/second-order-cone-programming/README.md) · [`SKILL`](skills/second-order-cone-programming/SKILL.md) | | [`or-solver`](skills/or-solver/) | 为 OR skills 提供统一求解器检测、安装规划、license 检查和选择策略。 | [`README`](skills/or-solver/README.md) · [`SKILL`](skills/or-solver/SKILL.md) | +| [`osqp-solver`](skills/osqp-solver/) | 面向连续凸 QP 的 OSQP 建模、重复求解、状态门槛与独立验证。 | [`README`](skills/osqp-solver/README.zh-CN.md) · [`SKILL`](skills/osqp-solver/SKILL.md) | ## 安装 @@ -50,6 +52,7 @@ Skill 路径: - skills/mixed-integer-programming - skills/second-order-cone-programming - skills/or-solver +- skills/osqp-solver 请执行: 1. 本地 clone 或更新仓库。 @@ -72,6 +75,7 @@ ln -s "$PWD/skills/linear-programming" ~/.codex/skills/linear-programming ln -s "$PWD/skills/mixed-integer-programming" ~/.codex/skills/mixed-integer-programming ln -s "$PWD/skills/second-order-cone-programming" ~/.codex/skills/second-order-cone-programming ln -s "$PWD/skills/or-solver" ~/.codex/skills/or-solver +ln -s "$PWD/skills/osqp-solver" ~/.codex/skills/osqp-solver ``` 如果你的 agent 使用别的本地 Skill 目录,把 `~/.codex/skills` 替换成对应配置路径。 @@ -97,6 +101,12 @@ skills/or-solver/SKILL.md skills/cdopt-optimization/SKILL.md ``` +连续凸 QP 与 OSQP workflow 从这里开始: + +```text +skills/osqp-solver/SKILL.md +``` + ## 仓库结构 ```text @@ -110,6 +120,7 @@ AI4Math-Optimization/ ├── linear-programming/ ├── mixed-integer-programming/ ├── or-solver/ + ├── osqp-solver/ └── second-order-cone-programming/ ``` diff --git a/SKILL.md b/SKILL.md index 023c0c9..add90d0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,6 +18,8 @@ Use this repository as a routing layer for optimization workflows. - `skills/mixed-integer-programming/`: MIP and MILP modeling workflows. - `skills/second-order-cone-programming/`: SOCP modeling and solver workflows. - `skills/or-solver/`: shared optimization solver setup and selection. +- `skills/osqp-solver/`: continuous convex QP modeling, OSQP execution, + status gates, and independent solution or infeasibility-certificate checks. Prefer package-local instructions over this router when running a concrete workflow. diff --git a/pyproject.toml b/pyproject.toml index 9928342..fccc812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [] [project.optional-dependencies] dev = ["pytest>=8.0"] +osqp = ["osqp>=1,<2"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/skills/osqp-solver/README.md b/skills/osqp-solver/README.md new file mode 100644 index 0000000..5eb9e76 --- /dev/null +++ b/skills/osqp-solver/README.md @@ -0,0 +1,115 @@ +# OSQP Solver Skill + +Chinese guide: [README.zh-CN.md](README.zh-CN.md) + +This AI4Math skill models, solves, updates, and independently checks continuous +convex quadratic programs with the native OSQP Python interface. It is a +release-ready local candidate for +[`VeryMath/AI4Math-Optimization`](https://github.com/VeryMath/AI4Math-Optimization); +it is not an official upstream package until that repository accepts and merges +it. + +## Scope + +Use the skill for + +\[ +\min_x \tfrac12 x^\top P x + q^\top x +\quad\text{subject to}\quad l \leq Ax \leq u, +\qquad P=P^\top\succeq0. +\] + +Do not route mixed-integer, nonconvex, nonlinear-constraint, SDP, or general +SOCP models to this package. The bundled JSON runner is an auditable tool for +small dense examples, not a production sparse-data format. + +## Isolated installation + +Create an isolated environment and install the supported OSQP major version: + +```bash +python3 -m venv .venv-osqp +source .venv-osqp/bin/activate +python -m pip install -r requirements-test.txt +python scripts/check_osqp_environment.py --json +``` + +The probe reports `ready: true` only when `osqp`, `numpy`, and `scipy` import +successfully and the OSQP version is in the supported range. Obtain approval +before installing into an existing environment. + +## Quick start + +From this package directory: + +```bash +python scripts/solve_qp.py references/feasible-example.json +python scripts/solve_qp.py references/primal-infeasible-example.json +python scripts/solve_qp.py references/dual-infeasible-example.json +``` + +Use `--validate-only` for dependency-free structural validation. It does not +run PSD or numerical solution checks: + +```bash +python scripts/solve_qp.py references/feasible-example.json --validate-only +``` + +For a persistent report, choose a path different from the input: + +```bash +python scripts/solve_qp.py references/feasible-example.json \ + --output outputs/feasible-result.json +``` + +## CLI contract and limits + +- Successful validation or an accepted solve exits `0`. Input/dependency + failures exit `2`; a rejected numerical status or solver failure exits `3`. +- Standard output is exactly one JSON document on success. Machine-readable + errors are written as exactly one JSON document to standard error. +- Result documents use `schema_version: osqp-solver-result-v1`; structural-only + reports use `osqp-solver-validation-v1`. +- `settings.verbose: true` is rejected because OSQP logging would corrupt the + single-JSON standard-output contract. +- The runner rejects resolved input/output paths that are the same. A distinct + existing output file may be atomically replaced, so use a run-specific path. +- The default dense guard accepts at most a 32 MiB input, `n <= 1000`, and + `2,000,000` logical entries across `P` and `A`. Use the native sparse API for + larger problems. +- An accepted solution must pass primal feasibility, stationarity, + normal-cone/complementarity, objective, and PSD checks. Infeasibility + certificates are normalized and must pass scale-aware residual, sign, and + strict negative-margin checks. + +Read [SKILL.md](SKILL.md) for the workflow and +[references/verification.md](references/verification.md) for the mathematical +acceptance conditions. + +## Validation + +From the AI4Math Skill Library root, with the isolated environment active: + +```bash +python -m unittest discover -s tests -p 'test_osqp_solver_skill.py' -v +python scripts/validate_skill_repo.py +``` + +Do not treat skipped numerical tests as release evidence. The OSQP-enabled +suite must run without skips before publishing; inspect the unittest summary +and require `skipped=0`. + +## License, citation, and provenance + +This package is intended to follow the AI4Math repository's MIT license after +maintainer authorization and upstream acceptance. The separately installed +OSQP project is Apache-2.0 licensed; this package does not vendor or relicense +OSQP code. Software licensing and academic citation are separate obligations. +Use [references/citation.md](references/citation.md) for the official OSQP +citation. + +The workflow and references were prepared from the official OSQP documentation +linked in [references/python-api.md](references/python-api.md). Before public +release, a human maintainer must confirm publication authorization and record +factual contributor attribution. Packaging by an AI agent is not that +authorization. diff --git a/skills/osqp-solver/README.zh-CN.md b/skills/osqp-solver/README.zh-CN.md new file mode 100644 index 0000000..2d06fd9 --- /dev/null +++ b/skills/osqp-solver/README.zh-CN.md @@ -0,0 +1,101 @@ +# OSQP Solver Skill + +English guide: [README.md](README.md) + +这个 AI4Math skill 使用原生 OSQP Python 接口,对连续凸二次规划进行建模、求解、 +参数更新和独立复核。它目前是 +[`VeryMath/AI4Math-Optimization`](https://github.com/VeryMath/AI4Math-Optimization) +的发布就绪本地候选;只有目标仓库接受并合并后,才能称为官方上游 package。 + +## 适用范围 + +使用本 skill 处理 + +\[ +\min_x \tfrac12 x^\top P x + q^\top x +\quad\text{subject to}\quad l \leq Ax \leq u, +\qquad P=P^\top\succeq0. +\] + +不要把混合整数、非凸、非线性约束、SDP 或一般 SOCP 模型路由到本 package。 +附带的 JSON runner 用于可审查的小型 dense 示例,不是生产级 sparse 数据格式。 + +## 隔离安装 + +创建隔离环境并安装受支持的 OSQP 主版本: + +```bash +python3 -m venv .venv-osqp +source .venv-osqp/bin/activate +python -m pip install -r requirements-test.txt +python scripts/check_osqp_environment.py --json +``` + +只有 `osqp`、`numpy`、`scipy` 均可导入,而且 OSQP 版本落在支持范围内时,探针才会 +报告 `ready: true`。修改已有环境前先取得批准。 + +## 快速开始 + +在本 package 目录运行: + +```bash +python scripts/solve_qp.py references/feasible-example.json +python scripts/solve_qp.py references/primal-infeasible-example.json +python scripts/solve_qp.py references/dual-infeasible-example.json +``` + +`--validate-only` 可在不安装数值依赖时做结构校验,但不会执行 PSD 或数值解检查: + +```bash +python scripts/solve_qp.py references/feasible-example.json --validate-only +``` + +需要保存报告时,输出路径必须与输入路径不同: + +```bash +python scripts/solve_qp.py references/feasible-example.json \ + --output outputs/feasible-result.json +``` + +## CLI 合同与边界 + +- 结构校验成功或求解结果被接受时退出码为 `0`;输入/依赖错误为 `2`;数值状态未被 + 接受或 solver 失败为 `3`。 +- 成功时标准输出严格为一个 JSON 文档;机器可读错误严格以一个 JSON 文档写入标准错误。 +- 求解报告使用 `schema_version: osqp-solver-result-v1`;仅结构校验的报告使用 + `osqp-solver-validation-v1`。 +- 拒绝 `settings.verbose: true`,避免 OSQP 日志破坏标准输出的单 JSON 合同。 +- runner 会拒绝解析后相同的输入/输出路径,从而保留输入。不同路径上的已有输出文件 + 可能被原子替换,因此应使用每次运行独立的路径。 +- 默认 dense guard 接受的输入最大为 32 MiB,要求 `n <= 1000`,并且 `P` 与 `A` + 的 logical entry 总数不超过 `2,000,000`。更大问题应直接使用原生 sparse API。 +- 被接受的解必须通过 primal feasibility、stationarity、 + normal-cone/complementarity、objective 和 PSD 检查;不可行性证书会先归一化, + 再接受尺度化 residual、方向符号和严格负裕量检查。 + +完整工作流见 [SKILL.md](SKILL.md),数学验收条件见 +[references/verification.md](references/verification.md)。 + +## 验证 + +激活隔离环境后,在 AI4Math Skill Library 根目录运行: + +```bash +python -m unittest discover -s tests -p 'test_osqp_solver_skill.py' -v +python scripts/validate_skill_repo.py +``` + +被跳过的数值测试不能作为发布证据;公开发布前必须在安装 OSQP 的环境中做到该测试集 +零跳过,并检查 unittest 汇总中 `skipped=0`。 + +## 许可证、引用与来源边界 + +在维护者授权并被上游接受后,本 package 预期沿用 AI4Math 仓库的 MIT license。 +单独安装的 OSQP 项目采用 Apache-2.0 license;本 package 不打包、也不重新许可 +OSQP 代码。软件许可和学术引用是两项不同义务。官方 OSQP 引用见 +[references/citation.md](references/citation.md)。 + +本工作流和参考材料依据 +[references/python-api.md](references/python-api.md) 所列 OSQP 官方文档整理。 +公开发布前,人类维护者必须确认发布授权,并如实记录贡献者署名;AI agent 完成打包 +不能替代这项授权。 diff --git a/skills/osqp-solver/SKILL.md b/skills/osqp-solver/SKILL.md new file mode 100644 index 0000000..3127c04 --- /dev/null +++ b/skills/osqp-solver/SKILL.md @@ -0,0 +1,244 @@ +--- +name: osqp-solver +description: >- + Model, solve, update, reproduce, and diagnose continuous convex quadratic + programs with the OSQP Python interface, including canonical-form conversion, + sparse CSC data, solver-status gates, residual checks, infeasibility + certificates, warm starts, repeated parametric QPs, and academic citation. + Use when a task names OSQP, osqp Python, operator-splitting QP, convex QP, + model-predictive-control QP, portfolio QP, or asks to reproduce or debug OSQP + results. Do not use for mixed-integer, nonconvex quadratic, + nonlinear-constraint, or general conic/SOCP problems. +--- + +# OSQP Solver + +## Scope + +Work only with the continuous convex QP + +\[ +\begin{array}{ll} +\operatorname{minimize} & \frac12 x^\top P x + q^\top x \\ +\operatorname{subject\ to} & l \leq A x \leq u, +\end{array} +\qquad P=P^\top\succeq 0. +\] + +Treat OSQP as a numerical solver, not as a modeling oracle. Derive the model, +check dimensions and convexity, solve, and then independently verify what the +returned status means. + +Route elsewhere when the task contains integer variables, a genuinely +indefinite \(P\), nonlinear constraints, or cones that cannot be represented by +linear bounds. Do not claim that failure to detect nonconvexity proves +convexity. + +## Workflow + +### 1. Freeze the interpreted model + +Write a short modeling checkpoint before executable code: + +```text +source: +variables and units: +objective before conversion: +constraints before conversion: +canonical P, q, A, l, u: +dimensions and expected sparsity: +why P is positive semidefinite: +requested accuracy and runtime budget: +ambiguities: +``` + +Confirm any interpretation that changes the mathematics. Keep the factor +\(\tfrac12\) visible: if the original quadratic is \(x^\top Qx\), then OSQP +usually needs \(P=2Q\). + +### 2. Inspect the environment + +Run the non-mutating probe: + +```bash +python3 scripts/check_osqp_environment.py --json +``` + +Require `osqp`, `numpy`, and `scipy`. If they are missing, report: + +```bash +python3 -m pip install 'osqp>=1,<2' +``` + +Obtain approval before changing an existing environment. Do not infer solver +availability from a package listing alone; import it with the probe. Require +`ready: true`: an importable but unsupported OSQP major version is not ready +for this runner. + +### 3. Construct solver data deliberately + +- Store `P` and `A` as `scipy.sparse.csc_matrix`. +- Reject materially asymmetric input. For differences within the declared + symmetry tolerance, construct one canonical symmetric `P` and use that same + matrix for the PSD, objective, stationarity, setup, and update checks. Pass + its upper triangle to OSQP because the solver uses upper-triangular storage. +- Encode one row of \(l\leq Ax\leq u\) per equality, interval, lower bound, or + upper bound. Use `numpy.inf` for open sides. +- Check `P.shape == (n, n)`, `q.shape == (n,)`, `A.shape == (m, n)`, and + `l.shape == u.shape == (m,)`. +- Check `l <= u`, finite `P`, `q`, and `A`, and the scale of every row and + column. Avoid accidental dense construction for large problems. + +For small reviewable JSON problems, use +`scripts/solve_qp.py`. Read `references/python-api.md` before adapting the +runner to a production sparse model. The dense runner rejects inputs above +32 MiB, `n > 1000`, or more than `2,000,000` logical entries across `P` and +`A`. + +### 4. Establish a baseline solve + +Prefer the native Python interface for transparent solver data: + +```python +import numpy as np +import osqp +from scipy import sparse + +P = sparse.csc_matrix(...) +q = np.asarray(..., dtype=float) +A = sparse.csc_matrix(...) +l = np.asarray(..., dtype=float) +u = np.asarray(..., dtype=float) + +model = osqp.OSQP() +model.setup( + P=P, + q=q, + A=A, + l=l, + u=u, + verbose=False, +) +result = model.solve() +``` + +Run defaults first unless the task already specifies settings. Record every +non-default setting. The bundled runner supports `osqp>=1,<2`. Use the OSQP v1 +names `polishing` and `warm_starting`; consult `references/python-api.md` when +reproducing v0.6 code that used `polish` or `warm_start`, but preserve that +older reproduction in a separate pinned environment instead of passing it to +this runner. + +Do not tune `rho`, `alpha`, scaling, or tolerances from folklore. First preserve +the baseline status, iteration count, residuals, and timing; then change one +justified setting at a time. + +### 5. Apply the status gate + +Interpret `result.info.status_val`, not a substring match on a log: + +| Status class | Treatment | +| --- | --- | +| `solved` | Candidate solution; verify before accepting. | +| `solved inaccurate` | Tentative only; report the relaxed-status boundary and verify. | +| `primal infeasible` / `dual infeasible` | Candidate classification; verify the returned certificate. | +| inaccurate infeasibility | Tentative certificate only. | +| maximum iterations / time limit | No accepted solution; diagnose or revise the budget. | +| non convex | Reject the solve and revisit \(P\); detection is not exhaustive. | +| interrupted / unsolved / setup error | No mathematical conclusion. | + +Never replace the status with a bare objective value or a visually plausible +vector. + +### 6. Verify independently + +For a feasible candidate \(x,y\), compute at minimum: + +\[ +\max\{\|(l-Ax)_+\|_\infty,\|(Ax-u)_+\|_\infty\}, +\qquad +\|Px+q+A^\top y\|_\infty, +\] + +also check the normal-cone/complementarity condition +\(y\in N_{[l,u]}(Ax)\), and recompute +\(\frac12x^\top Px+q^\top x\). Compare all residuals with the requested +absolute/relative tolerances and problem scaling. + +Do not treat an objective below a known feasible optimum as an improvement +until feasibility has passed: a slightly infeasible numerical point can have a +spuriously lower objective. + +For an infeasibility claim, verify the returned certificate using +`references/verification.md`. Reject zero or non-finite directions, normalize +the certificate by its infinity norm, and require a scale-aware strict negative +margin rather than accepting a value that is merely below zero. Distinguish: + +- OSQP's reported residuals; +- an independent recomputation; +- the requested tolerances; +- any looser threshold used only for diagnosis. + +### 7. Reuse setup for parametric QPs + +For repeated problems with fixed dimensions and sparsity: + +```python +model.update(q=q_new, l=l_new, u=u_new) +model.update(Px=Px_new, Ax=Ax_new) +result = model.solve() +``` + +OSQP automatically warm starts from the previous solution. Use +`model.warm_start(x=x0, y=y0)` only when supplying an intentional external +initial point. + +Matrix updates may change numerical values but not the sparsity pattern. Re-run +`setup` when a pattern or dimension changes. Benchmark setup time separately +from update and solve time. Do not infer a speedup from one tiny, warm-started, +microsecond-scale observation. + +### 8. Preserve evidence + +Save durable artifacts under `outputs//`: + +```text +model.md interpreted mathematics and assumptions +problem.json exact small runner input, when used +settings.json OSQP version and non-default settings +result.json status, solution or certificate, and timings +verification.json independently recomputed checks +run.log command and diagnostics +``` + +Report: + +1. the exact QP and interface version; +2. status and whether it passed the gate; +3. objective, residuals, and certificate checks; +4. settings and warm-start/update behavior; +5. limitations and unresolved numerical concerns. + +For published work, read `references/citation.md` and include the relevant +official OSQP citation. + +When using the bundled CLI, preserve its machine-readable stream contract: +success writes exactly one JSON document to standard output, and errors write +exactly one JSON document to standard error. Reject `settings.verbose: true`. +Never use the input path as `--output`; use a run-specific destination because +a distinct existing output may be atomically replaced. + +## Bundled Resources + +- `scripts/check_osqp_environment.py`: inspect Python and OSQP dependencies + without installing anything. +- `scripts/solve_qp.py`: solve and independently check a small dense JSON QP; + use it as an auditable runner, not a large sparse-data format. +- `references/python-api.md`: source-backed Python setup, update, warm-start, + settings, and version notes. +- `references/verification.md`: residual and infeasibility-certificate checks. +- `references/citation.md`: official publication citations. +- `references/*-example.json`: feasible, primal-infeasible, and + dual-infeasible smoke problems. +- `requirements-test.txt`: minimal OSQP 1.x dependency for isolated numerical + tests. diff --git a/skills/osqp-solver/agents/openai.yaml b/skills/osqp-solver/agents/openai.yaml new file mode 100644 index 0000000..678faff --- /dev/null +++ b/skills/osqp-solver/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OSQP Solver" + short_description: "Model and verify convex QPs with OSQP" + default_prompt: "Use $osqp-solver to model this convex quadratic program, solve it with OSQP, and verify the status, residuals, and certificates." diff --git a/skills/osqp-solver/references/citation.md b/skills/osqp-solver/references/citation.md new file mode 100644 index 0000000..68d8766 --- /dev/null +++ b/skills/osqp-solver/references/citation.md @@ -0,0 +1,25 @@ +# Citing OSQP + +Use the official citation page for the current list of papers: +. + +For the main algorithm, OSQP lists: + +```bibtex +@article{osqp, + author = {Stellato, B. and Banjac, G. and Goulart, P. and Bemporad, A. and Boyd, S.}, + title = {{OSQP}: an operator splitting solver for quadratic programs}, + journal = {Mathematical Programming Computation}, + year = {2020}, + volume = {12}, + number = {4}, + pages = {637--672}, + doi = {10.1007/s12532-020-00179-2}, + url = {https://doi.org/10.1007/s12532-020-00179-2}, +} +``` + +Add the official infeasibility, GPU, code-generation, or mixed-integer paper +only when the work actually uses that contribution. Do not cite the +mixed-integer extension as evidence that the core OSQP solver accepts integer +variables. diff --git a/skills/osqp-solver/references/dual-infeasible-example.json b/skills/osqp-solver/references/dual-infeasible-example.json new file mode 100644 index 0000000..0cb4048 --- /dev/null +++ b/skills/osqp-solver/references/dual-infeasible-example.json @@ -0,0 +1,14 @@ +{ + "name": "unbounded-linear-objective", + "P": [ + [0.0] + ], + "q": [-1.0], + "A": [], + "l": [], + "u": [], + "settings": { + "eps_dual_inf": 1e-07, + "verbose": false + } +} diff --git a/skills/osqp-solver/references/feasible-example.json b/skills/osqp-solver/references/feasible-example.json new file mode 100644 index 0000000..89dfd7f --- /dev/null +++ b/skills/osqp-solver/references/feasible-example.json @@ -0,0 +1,21 @@ +{ + "name": "official-two-variable-qp", + "P": [ + [4.0, 1.0], + [1.0, 2.0] + ], + "q": [1.0, 1.0], + "A": [ + [1.0, 1.0], + [1.0, 0.0], + [0.0, 1.0] + ], + "l": [1.0, 0.0, 0.0], + "u": [1.0, 0.7, 0.7], + "settings": { + "eps_abs": 1e-08, + "eps_rel": 1e-08, + "polishing": true, + "verbose": false + } +} diff --git a/skills/osqp-solver/references/primal-infeasible-example.json b/skills/osqp-solver/references/primal-infeasible-example.json new file mode 100644 index 0000000..77948c1 --- /dev/null +++ b/skills/osqp-solver/references/primal-infeasible-example.json @@ -0,0 +1,17 @@ +{ + "name": "incompatible-lower-and-upper-constraints", + "P": [ + [1.0] + ], + "q": [0.0], + "A": [ + [1.0], + [1.0] + ], + "l": [1.0, "-inf"], + "u": ["inf", 0.0], + "settings": { + "eps_prim_inf": 1e-07, + "verbose": false + } +} diff --git a/skills/osqp-solver/references/python-api.md b/skills/osqp-solver/references/python-api.md new file mode 100644 index 0000000..b4ded27 --- /dev/null +++ b/skills/osqp-solver/references/python-api.md @@ -0,0 +1,95 @@ +# OSQP Python API Notes + +Use this reference after the mathematical model has been converted to OSQP's +canonical QP form. + +The bundled environment probe and JSON runner support `osqp>=1,<2`. Keep an +older OSQP reproduction in a separately pinned environment; do not interpret +successful imports from OSQP v0.6 as runner readiness. + +## Source ledger + +- Problem form and algorithm: +- Python interface: +- Solver settings: +- Status values and errors: + +- Python installation: +- Migration from v0.6 to v1: + +- Code generation: + +The URLs above are the official OSQP documentation and should be rechecked when +version-specific behavior matters. + +## Setup and result + +Use `P` and `A` as SciPy CSC sparse matrices, and `q`, `l`, and `u` as NumPy +vectors: + +```python +model = osqp.OSQP() +model.setup(P=P, q=q, A=A, l=l, u=u, verbose=False) +result = model.solve() +``` + +OSQP uses only the upper-triangular part of `P`. Reject materially asymmetric +input, explicitly canonicalize within-tolerance asymmetry, and use the same +symmetric matrix for setup and every independent check. The result exposes +`x`, `y`, `prim_inf_cert`, `dual_inf_cert`, and an `info` record containing the +status, objective, residuals, iterations, and timing. + +## Repeated solves + +Update vectors without a new setup: + +```python +model.update(q=q_new, l=l_new, u=u_new) +``` + +Update matrix values while preserving the original sparsity pattern: + +```python +model.update(Px=Px_new, Ax=Ax_new) +``` + +OSQP automatically warm starts from its previous solution. Supply an external +initial point only when it is intentional: + +```python +model.warm_start(x=x0, y=y0) +``` + +Re-run `setup` if dimensions or sparsity patterns change. + +## Settings discipline + +Start from defaults and record non-default values. Common v1 settings include +`eps_abs`, `eps_rel`, `eps_prim_inf`, `eps_dual_inf`, `max_iter`, +`time_limit`, `polishing`, `warm_starting`, `adaptive_rho`, `rho`, and +`alpha`. + +OSQP v1 renamed the v0.6 setting `polish` to `polishing` and `warm_start` to +`warm_starting`. Preserve the original OSQP version when reproducing old code; +do not silently mix names or compare timings across changed backends. + +The small JSON runner rejects `verbose: true` to keep standard output as one +machine-readable JSON document. Treat its settings object as a controlled +runner interface, not as an unrestricted pass-through to OSQP. + +## Interface choice + +Use the native Python interface when exact `P`, `q`, `A`, `l`, `u`, update +behavior, or OSQP diagnostics matter. A modeling layer such as CVXPY can be +useful for expression conversion, but record the modeling-layer version, the +selected `OSQP` backend, solver options, and the final status. Do not assume a +modeling-layer status exposes every native OSQP diagnostic. + +Use code generation only for a fixed family of QPs after the native model and +update contract have been validated. The official generator fixes dimensions; +matrix-parameter mode also assumes fixed sparsity patterns. + +The bundled dense JSON runner accepts at most a 32 MiB input, `n <= 1000`, and +`2,000,000` logical entries across `P` and `A`. Larger models should use the +native sparse interface and application-specific storage rather than weakening +this review guard. diff --git a/skills/osqp-solver/references/verification.md b/skills/osqp-solver/references/verification.md new file mode 100644 index 0000000..6443d28 --- /dev/null +++ b/skills/osqp-solver/references/verification.md @@ -0,0 +1,94 @@ +# OSQP Result Verification + +Use these checks after reading the exact OSQP status. They are independent +recomputations, not substitutes for a derivation of the model. + +## Feasible candidate + +For the canonical QP and returned \(x,y\), recompute: + +\[ +r_{\mathrm{bound}}= +\max\{\|(l-Ax)_+\|_\infty,\|(Ax-u)_+\|_\infty\}, +\] + +\[ +r_{\mathrm{stat}}=\|Px+q+A^\top y\|_\infty, +\qquad +f(x)=\tfrac12x^\top Px+q^\top x. +\] + +Also check the normal-cone condition \(y\in N_{[l,u]}(Ax)\). With OSQP's dual +sign convention, use an activity tolerance and verify, row by row: + +- \(y_i\approx0\) when no finite bound is active, including a free row; +- \(y_i\leq0\) at a lower bound that is not also an upper bound; +- \(y_i\geq0\) at an upper bound that is not also a lower bound; +- an equality row may have either sign. + +Report the resulting normal-cone/complementarity residual separately. Primal +feasibility plus stationarity alone is not a complete optimality check. + +Check \(P=P^\top\succeq0\) independently. Compare residuals against both the +requested tolerances and problem scale. OSQP's documented stopping tolerances +use absolute and relative terms; do not compare every scaled problem to one +unexplained hard-coded threshold. + +If the application has stronger domain checks—dynamics defects in MPC, budget +balance in a portfolio, or physical units—run those as separate checks. + +## Primal infeasibility + +Reject a non-finite or numerically zero returned \(v\). Normalize a valid +direction as \(\hat v=v/\|v\|_\infty\), then check: + +\[ +\|A^\top \hat v\|_\infty\leq\epsilon_{\mathrm{prim\_inf}}, +\qquad +u^\top \hat v_+ + l^\top \hat v_- < -\tau_{\mathrm{neg}}. +\] + +Interpret the support expression with extended bounds. A positive component of +\(\hat v\) paired with \(u_i=+\infty\), or a negative component paired with +\(l_i=-\infty\), invalidates the direction beyond tolerance. Choose and record +the scale-aware strict-negative margin \(\tau_{\mathrm{neg}}>0\). Preserve the +raw certificate, normalized direction, norm, scale, and recomputed values. + +## Dual infeasibility + +Reject a non-finite or numerically zero returned \(s\). Normalize a valid +direction as \(\hat s=s/\|s\|_\infty\), then check: + +\[ +\|P\hat s\|_\infty\leq\epsilon_{\mathrm{dual\_inf}}, +\qquad q^\top \hat s<-\tau_{\mathrm{neg}}. +\] + +For each component of \(A\hat s\), also check: + +- \( (As)_i\approx0 \) when both bounds are finite; +- \( (As)_i\geq0 \) when only the lower bound is finite; +- \( (As)_i\leq0 \) when only the upper bound is finite. + +Rows open on both sides impose no direction restriction. + +Choose and record a scale-aware \(\tau_{\mathrm{neg}}>0\). A tiny unnormalized +vector that only makes the expression negative by floating-point noise is not +an accepted infeasibility certificate. + +## Status policy + +The official status table states that inaccurate statuses satisfy conditions +with tolerances ten times larger than configured. Keep `solved inaccurate` and +inaccurate infeasibility classifications visibly tentative, even when a +downstream application elects to accept them. + +Maximum-iteration, time-limit, interrupted, unsolved, and setup-error statuses +do not establish a solution or infeasibility conclusion. A `problem non convex` +status rejects the run, but the absence of that status does not prove that +\(P\succeq0\). + +Official sources: + +- +- diff --git a/skills/osqp-solver/requirements-test.txt b/skills/osqp-solver/requirements-test.txt new file mode 100644 index 0000000..d07e008 --- /dev/null +++ b/skills/osqp-solver/requirements-test.txt @@ -0,0 +1 @@ +osqp>=1,<2 diff --git a/skills/osqp-solver/scripts/check_osqp_environment.py b/skills/osqp-solver/scripts/check_osqp_environment.py new file mode 100755 index 0000000..28d2020 --- /dev/null +++ b/skills/osqp-solver/scripts/check_osqp_environment.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Probe the Python dependencies required by the OSQP skill without installing.""" + +from __future__ import annotations + +import argparse +import importlib +import importlib.util +import json +import re +import sys +from importlib import metadata +from typing import Any + + +PACKAGES = ("osqp", "numpy", "scipy") +OSQP_REQUIREMENT = "osqp>=1,<2" +SUPPORTED_OSQP_MAJOR = 1 + + +def package_version(name: str) -> str | None: + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +def probe_package(name: str) -> dict[str, Any]: + spec = importlib.util.find_spec(name) + if spec is None: + return { + "available": False, + "version": None, + "path": None, + "error": "module not found", + } + + try: + module = importlib.import_module(name) + except Exception as exc: # noqa: BLE001 - preserve the import failure + return { + "available": False, + "version": package_version(name), + "path": getattr(spec, "origin", None), + "error": f"{type(exc).__name__}: {exc}", + } + + return { + "available": True, + "version": getattr(module, "__version__", None) or package_version(name), + "path": getattr(module, "__file__", None), + "error": None, + } + + +def osqp_compatibility(version: Any) -> tuple[bool, str | None]: + if not isinstance(version, str): + return False, f"cannot determine OSQP version; requires {OSQP_REQUIREMENT}" + match = re.match(r"^\s*(\d+)(?:\.|$)", version) + if match is None: + return ( + False, + f"cannot parse OSQP version {version!r}; requires {OSQP_REQUIREMENT}", + ) + if int(match.group(1)) != SUPPORTED_OSQP_MAJOR: + return False, f"OSQP {version} is incompatible; requires {OSQP_REQUIREMENT}" + return True, None + + +def build_report() -> dict[str, Any]: + packages = {name: probe_package(name) for name in PACKAGES} + osqp_info = packages["osqp"] + compatible, compatibility_error = ( + osqp_compatibility(osqp_info["version"]) + if osqp_info["available"] + else (False, f"OSQP is unavailable; requires {OSQP_REQUIREMENT}") + ) + osqp_info["compatible"] = compatible + osqp_info["requirement"] = OSQP_REQUIREMENT + osqp_info["compatibility_error"] = compatibility_error + return { + "python": sys.executable, + "python_version": sys.version.split()[0], + "ready": all(info["available"] for info in packages.values()) and compatible, + "packages": packages, + "install_hint": f"python3 -m pip install '{OSQP_REQUIREMENT}'", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="print JSON") + args = parser.parse_args() + + report = build_report() + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + print(f"Python: {report['python']} ({report['python_version']})") + for name, info in report["packages"].items(): + state = "ok" if info["available"] else "missing" + if name == "osqp" and info["available"] and not info["compatible"]: + state = "incompatible" + print( + f"{name}: {state}, version={info['version'] or '-'}, " + f"path={info['path'] or '-'}" + ) + if info["error"]: + print(f" error: {info['error']}") + if name == "osqp" and info["compatibility_error"]: + print(f" compatibility_error: {info['compatibility_error']}") + print(f"ready: {str(report['ready']).lower()}") + if not report["ready"]: + print(f"install_hint: {report['install_hint']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/osqp-solver/scripts/solve_qp.py b/skills/osqp-solver/scripts/solve_qp.py new file mode 100755 index 0000000..adad68b --- /dev/null +++ b/skills/osqp-solver/scripts/solve_qp.py @@ -0,0 +1,943 @@ +#!/usr/bin/env python3 +"""Solve and independently verify a small dense JSON quadratic program.""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import math +import os +import re +import sys +import tempfile +from pathlib import Path +from typing import Any, Iterable + + +SCHEMA_VERSION = "osqp-solver-result-v1" +OSQP_REQUIREMENT = "osqp>=1,<2" +SUPPORTED_OSQP_MAJOR = 1 +MAX_INPUT_BYTES = 32 * 1024 * 1024 +MAX_DENSE_VARIABLES = 1000 +MAX_DENSE_ENTRIES = 2_000_000 +STATUS_BY_VALUE = { + 1: ("solved", False, "solution"), + 2: ("solved_inaccurate", True, "solution"), + 3: ("primal_infeasible", False, "primal_certificate"), + 4: ("primal_infeasible_inaccurate", True, "primal_certificate"), + 5: ("dual_infeasible", False, "dual_certificate"), + 6: ("dual_infeasible_inaccurate", True, "dual_certificate"), + 7: ("maximum_iterations_reached", False, "failure"), + 8: ("time_limit_reached", False, "failure"), + 9: ("problem_non_convex", False, "failure"), + 10: ("interrupted", False, "failure"), + 11: ("unsolved", False, "failure"), +} + +BOOLEAN_SETTINGS = { + "adaptive_rho", + "polishing", + "scaled_termination", + "warm_starting", +} +INTEGER_SETTINGS = { + "adaptive_rho_interval": (0, None), + "check_termination": (0, None), + "max_iter": (1, None), + "polish_refine_iter": (1, None), + "scaling": (0, None), +} +FLOAT_SETTINGS = { + "adaptive_rho_fraction": (0.0, None, False, False), + "adaptive_rho_tolerance": (1.0, None, True, False), + "alpha": (0.0, 2.0, False, False), + "delta": (0.0, None, False, False), + "eps_abs": (0.0, None, True, False), + "eps_dual_inf": (0.0, None, False, False), + "eps_prim_inf": (0.0, None, False, False), + "eps_rel": (0.0, None, True, False), + "rho": (0.0, None, False, False), + "sigma": (0.0, None, False, False), + "time_limit": (0.0, None, False, False), +} +ALLOWED_SETTINGS = ( + BOOLEAN_SETTINGS | set(INTEGER_SETTINGS) | set(FLOAT_SETTINGS) | {"verbose"} +) +ALLOWED_TOP_LEVEL_FIELDS = {"name", "P", "q", "A", "l", "u", "settings"} + + +class InputError(ValueError): + """Raised when the JSON problem is not a valid runner input.""" + + +class DependencyError(RuntimeError): + """Raised when the numerical runtime is unavailable.""" + + +def _number(value: Any, label: str, *, allow_infinity: bool = False) -> float: + if isinstance(value, bool): + raise InputError(f"{label} must be numeric, not boolean") + + if isinstance(value, str) and allow_infinity: + normalized = value.strip().lower() + if normalized in {"inf", "+inf", "infinity", "+infinity"}: + return math.inf + if normalized in {"-inf", "-infinity"}: + return -math.inf + + if not isinstance(value, (int, float)): + raise InputError(f"{label} must be numeric") + + result = float(value) + if math.isnan(result): + raise InputError(f"{label} must not be NaN") + if not allow_infinity and not math.isfinite(result): + raise InputError(f"{label} must be finite") + return result + + +def _vector(raw: Any, label: str, *, allow_infinity: bool = False) -> list[float]: + if not isinstance(raw, list): + raise InputError(f"{label} must be a JSON array") + return [ + _number(value, f"{label}[{index}]", allow_infinity=allow_infinity) + for index, value in enumerate(raw) + ] + + +def _matrix(raw: Any, label: str) -> list[list[float]]: + if not isinstance(raw, list): + raise InputError(f"{label} must be a JSON array of rows") + matrix: list[list[float]] = [] + width: int | None = None + for row_index, row in enumerate(raw): + if not isinstance(row, list): + raise InputError(f"{label}[{row_index}] must be a JSON array") + parsed = [ + _number(value, f"{label}[{row_index}][{column_index}]") + for column_index, value in enumerate(row) + ] + if width is None: + width = len(parsed) + elif len(parsed) != width: + raise InputError(f"{label} rows must all have the same length") + matrix.append(parsed) + return matrix + + +def _validate_settings(raw: Any) -> dict[str, Any]: + if raw is None: + return {"verbose": False} + if not isinstance(raw, dict): + raise InputError("settings must be a JSON object") + settings: dict[str, Any] = {} + for key, value in raw.items(): + if not isinstance(key, str) or not key: + raise InputError("settings keys must be non-empty strings") + if key not in ALLOWED_SETTINGS: + allowed = ", ".join(sorted(ALLOWED_SETTINGS)) + raise InputError(f"unsupported settings.{key}; allowed settings: {allowed}") + + if key == "verbose": + if not isinstance(value, bool): + raise InputError("settings.verbose must be boolean") + if value: + raise InputError( + "settings.verbose=true is not supported because stdout is reserved " + "for one machine-readable JSON report" + ) + settings[key] = False + elif key in BOOLEAN_SETTINGS: + if not isinstance(value, bool): + raise InputError(f"settings.{key} must be boolean") + settings[key] = value + elif key in INTEGER_SETTINGS: + if isinstance(value, bool) or not isinstance(value, int): + raise InputError(f"settings.{key} must be an integer") + lower, upper = INTEGER_SETTINGS[key] + if value < lower or (upper is not None and value > upper): + interval = f">= {lower}" if upper is None else f"in [{lower}, {upper}]" + raise InputError(f"settings.{key} must be {interval}") + settings[key] = value + else: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise InputError(f"settings.{key} must be numeric") + number = float(value) + if not math.isfinite(number): + raise InputError(f"settings.{key} must be finite") + lower, upper, include_lower, include_upper = FLOAT_SETTINGS[key] + lower_ok = number >= lower if include_lower else number > lower + upper_ok = ( + True + if upper is None + else number <= upper + if include_upper + else number < upper + ) + if not lower_ok or not upper_ok: + left = "[" if include_lower else "(" + right = "]" if include_upper else ")" + upper_text = "infinity" if upper is None else str(upper) + raise InputError( + f"settings.{key} must be in {left}{lower}, {upper_text}{right}" + ) + settings[key] = number + + if settings.get("eps_abs", 1e-3) == 0.0 and settings.get("eps_rel", 1e-3) == 0.0: + raise InputError("settings.eps_abs and settings.eps_rel cannot both be zero") + settings["verbose"] = False + return settings + + +def validate_problem(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise InputError("the input root must be a JSON object") + unknown = sorted(set(raw) - ALLOWED_TOP_LEVEL_FIELDS) + if unknown: + raise InputError(f"unknown top-level field(s): {', '.join(unknown)}") + + missing = [key for key in ("P", "q", "A", "l", "u") if key not in raw] + if missing: + raise InputError(f"missing required field(s): {', '.join(missing)}") + + q = _vector(raw["q"], "q") + if not q: + raise InputError("q must contain at least one variable") + n = len(q) + if n > MAX_DENSE_VARIABLES: + raise InputError( + f"dense runner limit exceeded: n={n}, maximum is " + f"{MAX_DENSE_VARIABLES}; use the native sparse OSQP API" + ) + + p_matrix = _matrix(raw["P"], "P") + if len(p_matrix) != n or any(len(row) != n for row in p_matrix): + raise InputError(f"P must have shape ({n}, {n})") + + max_abs_p = max((abs(value) for row in p_matrix for value in row), default=0.0) + symmetry_tolerance = 1e-10 * max(1.0, max_abs_p) + max_asymmetry = max( + abs(p_matrix[i][j] - p_matrix[j][i]) + for i in range(n) + for j in range(n) + ) + if max_asymmetry > symmetry_tolerance: + raise InputError( + "P must be symmetric; " + f"max asymmetry {max_asymmetry:.3e} exceeds {symmetry_tolerance:.3e}" + ) + p_matrix = [ + [ + 0.5 + * ( + p_matrix[row_index][column_index] + + p_matrix[column_index][row_index] + ) + for column_index in range(n) + ] + for row_index in range(n) + ] + + a_matrix = _matrix(raw["A"], "A") + if a_matrix and any(len(row) != n for row in a_matrix): + raise InputError(f"A must have {n} columns") + m = len(a_matrix) + dense_entries = n * n + m * n + if dense_entries > MAX_DENSE_ENTRIES: + raise InputError( + "dense runner limit exceeded: " + f"P and A contain {dense_entries} logical entries, maximum is " + f"{MAX_DENSE_ENTRIES}; use the native sparse OSQP API" + ) + + lower = _vector(raw["l"], "l", allow_infinity=True) + upper = _vector(raw["u"], "u", allow_infinity=True) + if len(lower) != m or len(upper) != m: + raise InputError(f"l and u must each have length {m}") + for index, (lo, hi) in enumerate(zip(lower, upper)): + if lo == math.inf: + raise InputError(f"l[{index}] cannot be +infinity") + if hi == -math.inf: + raise InputError(f"u[{index}] cannot be -infinity") + if lo > hi: + raise InputError(f"l[{index}] must be <= u[{index}]") + + settings = _validate_settings(raw.get("settings")) + settings.setdefault("verbose", False) + return { + "name": str(raw.get("name", "unnamed-qp")), + "P": p_matrix, + "q": q, + "A": a_matrix, + "l": lower, + "u": upper, + "settings": settings, + "dimensions": {"n": n, "m": m}, + "structural_checks": { + "symmetric_P": True, + "max_P_asymmetry": max_asymmetry, + "P_canonicalized": bool(max_asymmetry > 0.0), + "consistent_dimensions": True, + "ordered_bounds": True, + "dense_entries": dense_entries, + }, + } + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise InputError(f"duplicate JSON object key: {key}") + result[key] = value + return result + + +def load_problem(path: Path) -> dict[str, Any]: + try: + input_size = path.stat().st_size + if input_size > MAX_INPUT_BYTES: + raise InputError( + f"input file is {input_size} bytes; maximum is {MAX_INPUT_BYTES} " + "for the small dense runner" + ) + raw = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_unique_object, + ) + except OSError as exc: + raise InputError(f"cannot read {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise InputError(f"invalid JSON in {path}: {exc}") from exc + return validate_problem(raw) + + +def _parse_osqp_major(version: Any) -> int | None: + if not isinstance(version, str): + return None + match = re.match(r"^\s*(\d+)(?:\.|$)", version) + if match is None: + return None + return int(match.group(1)) + + +def load_numeric_stack() -> tuple[Any, Any, Any]: + try: + import numpy as np + import osqp + from scipy import sparse + except ModuleNotFoundError as exc: + raise DependencyError( + f"missing Python dependency {exc.name!r}; run: " + f"python3 -m pip install '{OSQP_REQUIREMENT}'" + ) from exc + version = getattr(osqp, "__version__", None) + major = _parse_osqp_major(version) + if major != SUPPORTED_OSQP_MAJOR: + shown = version if version is not None else "unknown" + raise DependencyError( + f"incompatible OSQP version {shown!r}; this runner requires " + f"{OSQP_REQUIREMENT}" + ) + return np, osqp, sparse + + +def _norm_inf(np: Any, values: Any) -> float: + array = np.asarray(values, dtype=float) + if array.size == 0: + return 0.0 + return float(np.max(np.abs(array))) + + +def _finite_float(value: Any) -> float | int | None: + if value is None: + return None + if isinstance(value, bool): + return int(value) + try: + number = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(number): + return None + if number.is_integer() and isinstance(value, int): + return int(value) + return number + + +def _array_list(np: Any, value: Any) -> list[float] | None: + if value is None: + return None + array = np.asarray(value, dtype=float).reshape(-1) + if not bool(np.all(np.isfinite(array))): + return None + return [float(item) for item in array] + + +def _normalized_certificate( + np: Any, + certificate: Any, + expected_size: int, +) -> tuple[Any | None, float | None, str | None]: + if certificate is None: + return None, None, "certificate is missing" + try: + vector = np.asarray(certificate, dtype=float).reshape(-1) + except (TypeError, ValueError) as exc: + return None, None, f"certificate is not numeric: {exc}" + if vector.size != expected_size: + return ( + None, + None, + f"certificate has length {vector.size}; expected {expected_size}", + ) + if not bool(np.all(np.isfinite(vector))): + return None, None, "certificate contains a non-finite value" + norm = _norm_inf(np, vector) + if norm == 0.0: + return None, norm, "certificate is the zero vector" + normalized = vector / norm + if not bool(np.all(np.isfinite(normalized))): + return None, norm, "certificate normalization produced a non-finite value" + return normalized, norm, None + + +def _first_attribute(obj: Any, *names: str) -> Any: + for name in names: + if hasattr(obj, name): + return getattr(obj, name) + return None + + +def _solver_statistics(info: Any) -> dict[str, Any]: + fields = { + "objective": ("obj_val",), + "dual_objective": ("dual_obj_val",), + "primal_residual": ("prim_res", "pri_res"), + "dual_residual": ("dual_res", "dua_res"), + "duality_gap": ("duality_gap",), + "iterations": ("iter",), + "rho_updates": ("rho_updates",), + "rho_estimate": ("rho_estimate",), + "setup_time": ("setup_time",), + "solve_time": ("solve_time",), + "update_time": ("update_time",), + "polish_time": ("polish_time",), + "run_time": ("run_time",), + } + return { + output_name: _finite_float(_first_attribute(info, *source_names)) + for output_name, source_names in fields.items() + } + + +def verify_solution( + np: Any, + problem: dict[str, Any], + p_matrix: Any, + a_matrix: Any, + x: Any, + y: Any, + min_eigenvalue: float, + psd_tolerance: float, + verify_tolerance: float, +) -> dict[str, Any]: + q = np.asarray(problem["q"], dtype=float) + lower = np.asarray(problem["l"], dtype=float) + upper = np.asarray(problem["u"], dtype=float) + x = np.asarray(x, dtype=float).reshape(-1) + y = np.asarray(y, dtype=float).reshape(-1) + if x.size != q.size or y.size != lower.size: + return { + "kind": "solution", + "passed": False, + "reason": ( + f"solution dimensions are x={x.size}, y={y.size}; expected " + f"x={q.size}, y={lower.size}" + ), + } + if not bool(np.all(np.isfinite(x))) or not bool(np.all(np.isfinite(y))): + return { + "kind": "solution", + "passed": False, + "reason": "solution contains a non-finite primal or dual value", + } + ax = a_matrix @ x + + lower_violation = np.where(np.isfinite(lower), np.maximum(lower - ax, 0.0), 0.0) + upper_violation = np.where(np.isfinite(upper), np.maximum(ax - upper, 0.0), 0.0) + primal_violation = max( + _norm_inf(np, lower_violation), + _norm_inf(np, upper_violation), + ) + stationarity = _norm_inf(np, p_matrix @ x + q + a_matrix.T @ y) + objective = float(0.5 * x @ (p_matrix @ x) + q @ x) + + projected = np.minimum(np.maximum(ax, lower), upper) + eps_abs = float(problem["settings"].get("eps_abs", 1e-3)) + eps_rel = float(problem["settings"].get("eps_rel", 1e-3)) + eps_primal = eps_abs + eps_rel * max(_norm_inf(np, ax), _norm_inf(np, projected)) + eps_dual = eps_abs + eps_rel * max( + _norm_inf(np, p_matrix @ x), + _norm_inf(np, a_matrix.T @ y), + _norm_inf(np, q), + ) + primal_threshold = max(verify_tolerance, eps_primal) + dual_threshold = max(verify_tolerance, eps_dual) + + normal_cone_violations: list[float] = [] + lower_complementarity: list[float] = [] + upper_complementarity: list[float] = [] + for lo, hi, activity, dual in zip(lower, upper, ax, y): + lo_finite = math.isfinite(float(lo)) + hi_finite = math.isfinite(float(hi)) + at_lower = lo_finite and float(activity) <= float(lo) + primal_threshold + at_upper = hi_finite and float(activity) >= float(hi) - primal_threshold + dual_value = float(dual) + + if at_lower and at_upper: + normal_cone_violations.append(0.0) + elif at_lower: + normal_cone_violations.append(max(dual_value, 0.0)) + elif at_upper: + normal_cone_violations.append(max(-dual_value, 0.0)) + else: + normal_cone_violations.append(abs(dual_value)) + + if lo_finite: + lower_multiplier = max(-dual_value, 0.0) + lower_slack = max(float(activity) - float(lo), 0.0) + lower_complementarity.append(lower_multiplier * lower_slack) + if hi_finite: + upper_multiplier = max(dual_value, 0.0) + upper_slack = max(float(hi) - float(activity), 0.0) + upper_complementarity.append(upper_multiplier * upper_slack) + + normal_cone_violation = max(normal_cone_violations, default=0.0) + complementarity = max( + max(lower_complementarity, default=0.0), + max(upper_complementarity, default=0.0), + ) + normal_cone_threshold = dual_threshold + complementarity_threshold = max( + verify_tolerance, + primal_threshold * max(1.0, _norm_inf(np, y)), + ) + + return { + "kind": "solution", + "passed": bool( + primal_violation <= primal_threshold + and stationarity <= dual_threshold + and normal_cone_violation <= normal_cone_threshold + and complementarity <= complementarity_threshold + and min_eigenvalue >= -psd_tolerance + ), + "primal_bound_violation_inf": primal_violation, + "stationarity_inf": stationarity, + "normal_cone_violation_inf": normal_cone_violation, + "complementarity_inf": complementarity, + "objective_recomputed": objective, + "minimum_P_eigenvalue": min_eigenvalue, + "psd_tolerance": psd_tolerance, + "primal_threshold": primal_threshold, + "dual_threshold": dual_threshold, + "normal_cone_threshold": normal_cone_threshold, + "complementarity_threshold": complementarity_threshold, + "requested_floor_tolerance": verify_tolerance, + } + + +def verify_primal_certificate( + np: Any, + problem: dict[str, Any], + a_matrix: Any, + certificate: Any, + verify_tolerance: float, +) -> dict[str, Any]: + lower = np.asarray(problem["l"], dtype=float) + upper = np.asarray(problem["u"], dtype=float) + eps_certificate = float(problem["settings"].get("eps_prim_inf", 1e-4)) + threshold = max(verify_tolerance, eps_certificate) + vector, certificate_norm, error = _normalized_certificate( + np, certificate, lower.size + ) + if error is not None: + return { + "kind": "primal_infeasibility_certificate", + "passed": False, + "reason": error, + "certificate_norm_inf": certificate_norm, + "normalized": False, + "threshold": threshold, + } + + positive = np.maximum(vector, 0.0) + negative = np.minimum(vector, 0.0) + invalid_positive_ray = np.where(np.isposinf(upper), positive, 0.0) + invalid_negative_ray = np.where(np.isneginf(lower), -negative, 0.0) + infinite_bound_violation = max( + _norm_inf(np, invalid_positive_ray), + _norm_inf(np, invalid_negative_ray), + ) + finite_upper = np.isfinite(upper) + finite_lower = np.isfinite(lower) + with np.errstate(over="ignore", invalid="ignore"): + support = float( + upper[finite_upper] @ positive[finite_upper] + + lower[finite_lower] @ negative[finite_lower] + ) + support_scale = float( + np.abs(upper[finite_upper]) @ positive[finite_upper] + + np.abs(lower[finite_lower]) @ (-negative[finite_lower]) + ) + strict_negative_margin = threshold * max(1.0, support_scale) + null_residual = _norm_inf(np, a_matrix.T @ vector) + finite_checks = all( + math.isfinite(value) + for value in ( + support, + support_scale, + strict_negative_margin, + null_residual, + infinite_bound_violation, + ) + ) + return { + "kind": "primal_infeasibility_certificate", + "passed": bool( + finite_checks + and null_residual <= threshold + and infinite_bound_violation <= threshold + and support < -strict_negative_margin + ), + "A_transpose_v_inf": null_residual, + "support_value": _finite_float(support), + "support_scale": _finite_float(support_scale), + "infinite_bound_direction_violation_inf": infinite_bound_violation, + "certificate_norm_inf": certificate_norm, + "normalized": True, + "strict_negative_margin": _finite_float(strict_negative_margin), + "threshold": threshold, + } + + +def verify_dual_certificate( + np: Any, + problem: dict[str, Any], + p_matrix: Any, + a_matrix: Any, + certificate: Any, + verify_tolerance: float, +) -> dict[str, Any]: + q = np.asarray(problem["q"], dtype=float) + lower = np.asarray(problem["l"], dtype=float) + upper = np.asarray(problem["u"], dtype=float) + eps_certificate = float(problem["settings"].get("eps_dual_inf", 1e-4)) + threshold = max(verify_tolerance, eps_certificate) + vector, certificate_norm, error = _normalized_certificate( + np, certificate, q.size + ) + if error is not None: + return { + "kind": "dual_infeasibility_certificate", + "passed": False, + "reason": error, + "certificate_norm_inf": certificate_norm, + "normalized": False, + "threshold": threshold, + } + a_direction = a_matrix @ vector + + violations: list[float] = [] + for lo, hi, direction in zip(lower, upper, a_direction): + if math.isfinite(lo) and math.isfinite(hi): + violations.append(abs(float(direction))) + elif math.isfinite(lo) and math.isposinf(hi): + violations.append(max(-float(direction), 0.0)) + elif math.isneginf(lo) and math.isfinite(hi): + violations.append(max(float(direction), 0.0)) + direction_violation = max(violations, default=0.0) + p_residual = _norm_inf(np, p_matrix @ vector) + with np.errstate(over="ignore", invalid="ignore"): + q_direction = float(q @ vector) + q_scale = float(np.abs(q) @ np.abs(vector)) + strict_negative_margin = threshold * max(1.0, q_scale) + finite_checks = all( + math.isfinite(value) + for value in ( + direction_violation, + p_residual, + q_direction, + q_scale, + strict_negative_margin, + ) + ) + return { + "kind": "dual_infeasibility_certificate", + "passed": bool( + finite_checks + and p_residual <= threshold + and direction_violation <= threshold + and q_direction < -strict_negative_margin + ), + "P_s_inf": p_residual, + "q_transpose_s": _finite_float(q_direction), + "q_direction_scale": _finite_float(q_scale), + "constraint_direction_violation_inf": direction_violation, + "certificate_norm_inf": certificate_norm, + "normalized": True, + "strict_negative_margin": _finite_float(strict_negative_margin), + "threshold": threshold, + } + + +def _payload_is_accepted( + payload_kind: str, + verification_passed: bool, + inaccurate: bool, + accept_inaccurate: bool, +) -> bool: + return bool( + payload_kind != "failure" + and verification_passed + and (accept_inaccurate or not inaccurate) + ) + + +def solve_problem( + problem: dict[str, Any], + *, + verify_tolerance: float, + accept_inaccurate: bool, +) -> tuple[dict[str, Any], bool]: + np, osqp, sparse = load_numeric_stack() + p_matrix = np.asarray(problem["P"], dtype=float) + q = np.asarray(problem["q"], dtype=float) + if problem["A"]: + a_matrix = np.asarray(problem["A"], dtype=float) + else: + a_matrix = np.empty((0, problem["dimensions"]["n"]), dtype=float) + lower = np.asarray(problem["l"], dtype=float) + upper = np.asarray(problem["u"], dtype=float) + + eigenvalues = np.linalg.eigvalsh(p_matrix) + min_eigenvalue = float(np.min(eigenvalues)) + psd_tolerance = 1e-9 * max(1.0, _norm_inf(np, eigenvalues)) + if min_eigenvalue < -psd_tolerance: + raise InputError( + "P is not positive semidefinite within the runner tolerance; " + f"minimum eigenvalue={min_eigenvalue:.6e}" + ) + + solver_stdout = io.StringIO() + with contextlib.redirect_stdout(solver_stdout): + model = osqp.OSQP() + model.setup( + P=sparse.csc_matrix(np.triu(p_matrix)), + q=q, + A=sparse.csc_matrix(a_matrix), + l=lower, + u=upper, + **problem["settings"], + ) + result = model.solve() + status_value = int(result.info.status_val) + status_key, inaccurate, payload_kind = STATUS_BY_VALUE.get( + status_value, ("unknown", False, "failure") + ) + + verification: dict[str, Any] = { + "kind": "not_performed", + "passed": False, + "reason": f"status {status_key} has no accepted mathematical payload", + } + solution: dict[str, Any] | None = None + certificate: dict[str, Any] | None = None + + if payload_kind == "solution": + x = np.asarray(result.x, dtype=float) + y = np.asarray(result.y, dtype=float) + verification = verify_solution( + np, + problem, + p_matrix, + a_matrix, + x, + y, + min_eigenvalue, + psd_tolerance, + verify_tolerance, + ) + solution = {"x": _array_list(np, x), "y": _array_list(np, y)} + elif payload_kind == "primal_certificate": + values = result.prim_inf_cert + verification = verify_primal_certificate( + np, problem, a_matrix, values, verify_tolerance + ) + certificate = { + "type": "primal_infeasibility", + "values": _array_list(np, values), + } + elif payload_kind == "dual_certificate": + values = result.dual_inf_cert + verification = verify_dual_certificate( + np, problem, p_matrix, a_matrix, values, verify_tolerance + ) + certificate = {"type": "dual_infeasibility", "values": _array_list(np, values)} + + accepted = _payload_is_accepted( + payload_kind, + bool(verification["passed"]), + inaccurate, + accept_inaccurate, + ) + report = { + "schema_version": SCHEMA_VERSION, + "problem": { + "name": problem["name"], + "dimensions": problem["dimensions"], + "structural_checks": problem["structural_checks"], + }, + "solver": { + "name": "OSQP", + "version": getattr(osqp, "__version__", None), + }, + "status": { + "reported": str(result.info.status), + "value": status_value, + "classification": status_key, + "inaccurate": inaccurate, + "accepted": accepted, + "accept_inaccurate_requested": accept_inaccurate, + }, + "settings": problem["settings"], + "statistics": _solver_statistics(result.info), + "solution": solution, + "certificate": certificate, + "verification": verification, + } + return report, accepted + + +def _write_report(report: dict[str, Any], output: Path | None, indent: int) -> None: + rendered = json.dumps(report, indent=indent, sort_keys=True, allow_nan=False) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + dir=output.parent, + prefix=f".{output.name}.", + suffix=".tmp", + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle: + handle.write(rendered + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, output) + except Exception: + temporary_path.unlink(missing_ok=True) + raise + print(rendered) + + +def _error_report(kind: str, message: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "error": {"kind": kind, "message": message}, + } + + +def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path, help="JSON problem file") + parser.add_argument("--output", type=Path, help="also write the JSON report here") + parser.add_argument( + "--verify-tol", + type=float, + default=1e-7, + help="floor for independent residual/certificate checks", + ) + parser.add_argument( + "--accept-inaccurate", + action="store_true", + help="allow an inaccurate status only when independent checks pass", + ) + parser.add_argument( + "--validate-only", + action="store_true", + help="run dependency-free structural JSON checks without solving", + ) + parser.add_argument("--indent", type=int, default=2, help="JSON indentation") + args = parser.parse_args(argv) + if not math.isfinite(args.verify_tol) or args.verify_tol <= 0: + parser.error("--verify-tol must be finite and positive") + if args.indent < 0: + parser.error("--indent must be nonnegative") + return args + + +def main(argv: Iterable[str] | None = None) -> int: + args = parse_args(argv) + try: + args.input = args.input.expanduser().resolve(strict=False) + if args.output is not None: + args.output = args.output.expanduser().resolve(strict=False) + same_file = args.input == args.output + if args.output.exists() and args.input.exists(): + same_file = same_file or os.path.samefile(args.input, args.output) + if same_file: + raise InputError("--output must not resolve to the input file") + problem = load_problem(args.input) + if args.validate_only: + report = { + "schema_version": "osqp-solver-validation-v1", + "valid": True, + "problem": { + "name": problem["name"], + "dimensions": problem["dimensions"], + "structural_checks": problem["structural_checks"], + }, + "note": ( + "Structural validation only; PSD and numerical checks " + "require OSQP." + ), + } + _write_report(report, args.output, args.indent) + return 0 + + report, accepted = solve_problem( + problem, + verify_tolerance=args.verify_tol, + accept_inaccurate=args.accept_inaccurate, + ) + _write_report(report, args.output, args.indent) + return 0 if accepted else 3 + except InputError as exc: + print( + json.dumps(_error_report("input", str(exc)), indent=args.indent), + file=sys.stderr, + ) + return 2 + except DependencyError as exc: + print( + json.dumps(_error_report("dependency", str(exc)), indent=args.indent), + file=sys.stderr, + ) + return 2 + except Exception as exc: # noqa: BLE001 - return a machine-readable solver error + print( + json.dumps( + _error_report("solver", f"{type(exc).__name__}: {exc}"), + indent=args.indent, + ), + file=sys.stderr, + ) + return 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_osqp_solver_skill.py b/tests/test_osqp_solver_skill.py new file mode 100644 index 0000000..604611f --- /dev/null +++ b/tests/test_osqp_solver_skill.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import tempfile +import types +import unittest +from unittest import mock +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CURATION_PACKAGE = ROOT / "skills" / "AI4Math-Optimization" / "osqp-solver" +UPSTREAM_PACKAGE = ROOT / "skills" / "osqp-solver" +PACKAGE = CURATION_PACKAGE if CURATION_PACKAGE.is_dir() else UPSTREAM_PACKAGE +SOLVER = PACKAGE / "scripts" / "solve_qp.py" +ENVIRONMENT = PACKAGE / "scripts" / "check_osqp_environment.py" +FEASIBLE = PACKAGE / "references" / "feasible-example.json" +PRIMAL_INFEASIBLE = PACKAGE / "references" / "primal-infeasible-example.json" +DUAL_INFEASIBLE = PACKAGE / "references" / "dual-infeasible-example.json" + +HAS_NUMPY = importlib.util.find_spec("numpy") is not None +HAS_OSQP_STACK = all( + importlib.util.find_spec(module) is not None + for module in ("osqp", "numpy", "scipy") +) + + +def load_script_module(name: str, path: Path) -> Any: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +SOLVER_MODULE = load_script_module("osqp_solver_release_gate", SOLVER) +ENVIRONMENT_MODULE = load_script_module( + "osqp_environment_release_gate", ENVIRONMENT +) + + +class OsqpSolverSkillTests(unittest.TestCase): + def run_json(self, *args: str) -> tuple[subprocess.CompletedProcess[str], dict]: + result = subprocess.run( + [sys.executable, *args], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + stream = result.stdout if result.stdout.strip() else result.stderr + return result, json.loads(stream) + + def assert_single_json_document(self, stream: str) -> dict: + self.assertTrue(stream.strip(), "expected a JSON document") + document = json.loads(stream) + self.assertIsInstance(document, dict) + return document + + def write_problem( + self, directory: str, problem: dict[str, Any], name: str = "problem.json" + ) -> Path: + path = Path(directory) / name + path.write_text(json.dumps(problem), encoding="utf-8") + return path + + @staticmethod + def scalar_qp(**overrides: Any) -> dict[str, Any]: + problem: dict[str, Any] = { + "name": "scalar-qp", + "P": [[1.0]], + "q": [0.0], + "A": [], + "l": [], + "u": [], + } + problem.update(overrides) + return problem + + def test_skill_has_expected_resources_and_no_placeholders(self) -> None: + expected = ( + PACKAGE / "README.md", + PACKAGE / "README.zh-CN.md", + PACKAGE / "SKILL.md", + PACKAGE / "requirements-test.txt", + PACKAGE / "agents" / "openai.yaml", + ENVIRONMENT, + SOLVER, + PACKAGE / "references" / "python-api.md", + PACKAGE / "references" / "verification.md", + PACKAGE / "references" / "citation.md", + FEASIBLE, + PRIMAL_INFEASIBLE, + DUAL_INFEASIBLE, + ) + for path in expected: + with self.subTest(path=path): + self.assertTrue(path.is_file()) + self.assertNotIn("TODO", path.read_text(encoding="utf-8")) + + def test_environment_probe_is_machine_readable_without_dependencies(self) -> None: + result, report = self.run_json(str(ENVIRONMENT), "--json") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual({"osqp", "numpy", "scipy"}, set(report["packages"])) + self.assertEqual( + ( + all(item["available"] for item in report["packages"].values()) + and report["packages"]["osqp"]["compatible"] + ), + report["ready"], + ) + self.assertIn("osqp>=1", report["install_hint"]) + + def test_release_dependency_matches_runtime_version_gate(self) -> None: + requirement = (PACKAGE / "requirements-test.txt").read_text( + encoding="utf-8" + ) + self.assertEqual("osqp>=1,<2", requirement.strip()) + self.assertEqual(requirement.strip(), SOLVER_MODULE.OSQP_REQUIREMENT) + self.assertEqual(requirement.strip(), ENVIRONMENT_MODULE.OSQP_REQUIREMENT) + + def test_environment_probe_marks_unsupported_osqp_versions_not_ready(self) -> None: + base = { + "available": True, + "path": "/isolated/site-packages/package.py", + "error": None, + } + for version in ("0.6.3", "2.0.0"): + packages = { + "osqp": {**base, "version": version}, + "numpy": {**base, "version": "2.0.0"}, + "scipy": {**base, "version": "1.14.0"}, + } + with self.subTest(version=version), mock.patch.object( + ENVIRONMENT_MODULE, + "probe_package", + side_effect=lambda name, reports=packages: dict(reports[name]), + ): + report = ENVIRONMENT_MODULE.build_report() + self.assertFalse(report["ready"]) + self.assertFalse(report["packages"]["osqp"]["compatible"]) + + def test_environment_probe_accepts_supported_osqp_major(self) -> None: + base = { + "available": True, + "path": "/isolated/site-packages/package.py", + "error": None, + } + packages = { + "osqp": {**base, "version": "1.1.3"}, + "numpy": {**base, "version": "2.0.0"}, + "scipy": {**base, "version": "1.14.0"}, + } + with mock.patch.object( + ENVIRONMENT_MODULE, + "probe_package", + side_effect=lambda name: dict(packages[name]), + ): + report = ENVIRONMENT_MODULE.build_report() + self.assertTrue(report["ready"]) + self.assertTrue(report["packages"]["osqp"]["compatible"]) + + def test_runtime_rejects_unsupported_osqp_major_without_solving(self) -> None: + fake_numpy = types.ModuleType("numpy") + fake_osqp = types.ModuleType("osqp") + fake_osqp.__version__ = "0.6.3" + fake_scipy = types.ModuleType("scipy") + fake_scipy.sparse = object() + with mock.patch.dict( + sys.modules, + { + "numpy": fake_numpy, + "osqp": fake_osqp, + "scipy": fake_scipy, + }, + ): + with self.assertRaises(SOLVER_MODULE.DependencyError): + SOLVER_MODULE.load_numeric_stack() + + def test_runtime_accepts_supported_osqp_major_without_solving(self) -> None: + fake_numpy = types.ModuleType("numpy") + fake_osqp = types.ModuleType("osqp") + fake_osqp.__version__ = "1.1.3" + fake_scipy = types.ModuleType("scipy") + fake_scipy.sparse = object() + with mock.patch.dict( + sys.modules, + { + "numpy": fake_numpy, + "osqp": fake_osqp, + "scipy": fake_scipy, + }, + ): + numerical_stack = SOLVER_MODULE.load_numeric_stack() + self.assertIs(fake_numpy, numerical_stack[0]) + self.assertIs(fake_osqp, numerical_stack[1]) + self.assertIs(fake_scipy.sparse, numerical_stack[2]) + + def test_inaccurate_statuses_require_explicit_acceptance(self) -> None: + expected_payloads = { + 2: "solution", + 4: "primal_certificate", + 6: "dual_certificate", + } + for status_value, payload_kind in expected_payloads.items(): + with self.subTest(status_value=status_value): + _, inaccurate, observed_payload = SOLVER_MODULE.STATUS_BY_VALUE[ + status_value + ] + self.assertTrue(inaccurate) + self.assertEqual(payload_kind, observed_payload) + self.assertFalse( + SOLVER_MODULE._payload_is_accepted( + payload_kind, True, inaccurate, False + ) + ) + self.assertTrue( + SOLVER_MODULE._payload_is_accepted( + payload_kind, True, inaccurate, True + ) + ) + self.assertFalse( + SOLVER_MODULE._payload_is_accepted( + payload_kind, False, inaccurate, True + ) + ) + + def test_examples_pass_dependency_free_structural_validation(self) -> None: + for example in (FEASIBLE, PRIMAL_INFEASIBLE, DUAL_INFEASIBLE): + with self.subTest(example=example.name): + result, report = self.run_json( + str(SOLVER), str(example), "--validate-only" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertTrue(report["valid"]) + self.assertTrue(report["problem"]["structural_checks"]["symmetric_P"]) + + def test_invalid_nonsymmetric_P_is_rejected(self) -> None: + problem = { + "P": [[1.0, 2.0], [0.0, 1.0]], + "q": [0.0, 0.0], + "A": [], + "l": [], + "u": [], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "invalid.json" + path.write_text(json.dumps(problem), encoding="utf-8") + result, report = self.run_json( + str(SOLVER), str(path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("input", report["error"]["kind"]) + self.assertIn("symmetric", report["error"]["message"]) + + def test_near_symmetric_P_is_canonicalized_once(self) -> None: + asymmetry = 5e-11 + problem = SOLVER_MODULE.validate_problem( + { + "P": [[2.0, asymmetry], [0.0, 1.0]], + "q": [0.0, 0.0], + "A": [], + "l": [], + "u": [], + } + ) + self.assertAlmostEqual( + problem["P"][0][1], problem["P"][1][0], places=18 + ) + self.assertAlmostEqual(asymmetry / 2.0, problem["P"][0][1], places=18) + self.assertTrue(problem["structural_checks"]["P_canonicalized"]) + + def test_unknown_top_level_field_is_rejected(self) -> None: + problem = self.scalar_qp(typo_field="silently ignored") + with tempfile.TemporaryDirectory() as directory: + path = self.write_problem(directory, problem) + result, report = self.run_json( + str(SOLVER), str(path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + + def test_duplicate_json_object_key_is_rejected(self) -> None: + duplicate = ( + '{"P":[[1.0]],"q":[0.0],"q":[1.0],' + '"A":[],"l":[],"u":[]}' + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "duplicate-key.json" + path.write_text(duplicate, encoding="utf-8") + result, report = self.run_json( + str(SOLVER), str(path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + self.assertIn("duplicate", report["error"]["message"].lower()) + + def test_unknown_solver_setting_is_rejected(self) -> None: + problem = self.scalar_qp(settings={"typo_setting": 1}) + with tempfile.TemporaryDirectory() as directory: + path = self.write_problem(directory, problem) + result, report = self.run_json( + str(SOLVER), str(path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + + def test_invalid_solver_setting_value_is_rejected(self) -> None: + invalid_settings = ( + {"eps_abs": -1.0}, + {"max_iter": 1.5}, + {"polishing": 1}, + ) + for settings in invalid_settings: + with self.subTest(settings=settings), tempfile.TemporaryDirectory() as directory: + path = self.write_problem( + directory, self.scalar_qp(settings=settings) + ) + result, report = self.run_json( + str(SOLVER), str(path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + + def test_verbose_true_is_rejected_and_false_is_retained(self) -> None: + with tempfile.TemporaryDirectory() as directory: + true_path = self.write_problem( + directory, + self.scalar_qp(settings={"verbose": True}), + "verbose-true.json", + ) + result, report = self.run_json( + str(SOLVER), str(true_path), "--validate-only" + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + + false_path = self.write_problem( + directory, + self.scalar_qp(settings={"verbose": False}), + "verbose-false.json", + ) + validated = SOLVER_MODULE.load_problem(false_path) + self.assertIs(False, validated["settings"]["verbose"]) + + def test_success_and_error_streams_each_contain_exactly_one_json_document( + self, + ) -> None: + success = subprocess.run( + [sys.executable, str(SOLVER), str(FEASIBLE), "--validate-only"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(0, success.returncode, success.stderr) + self.assertEqual("", success.stderr) + success_report = self.assert_single_json_document(success.stdout) + self.assertTrue(success_report["valid"]) + + with tempfile.TemporaryDirectory() as directory: + invalid_path = self.write_problem( + directory, self.scalar_qp(settings={"verbose": True}) + ) + failure = subprocess.run( + [sys.executable, str(SOLVER), str(invalid_path), "--validate-only"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(2, failure.returncode) + self.assertEqual("", failure.stdout) + error_report = self.assert_single_json_document(failure.stderr) + self.assertEqual("input", error_report["error"]["kind"]) + + def test_input_cannot_also_be_output_and_original_is_preserved(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = self.write_problem(directory, self.scalar_qp()) + original = path.read_bytes() + result, report = self.run_json( + str(SOLVER), + str(path), + "--validate-only", + "--output", + str(path), + ) + self.assertEqual(2, result.returncode) + self.assertEqual("", result.stdout) + self.assertEqual("input", report["error"]["kind"]) + self.assertEqual(original, path.read_bytes()) + + def test_distinct_output_is_atomically_replaced_with_report(self) -> None: + with tempfile.TemporaryDirectory() as directory: + input_path = self.write_problem(directory, self.scalar_qp()) + output_path = Path(directory) / "result.json" + output_path.write_text("stale", encoding="utf-8") + result, report = self.run_json( + str(SOLVER), + str(input_path), + "--validate-only", + "--output", + str(output_path), + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(report, json.loads(output_path.read_text(encoding="utf-8"))) + self.assertEqual([], list(Path(directory).glob(".result.json.*.tmp"))) + + def test_dense_variable_guard_rejects_before_numerical_solve(self) -> None: + problem = { + "P": [[1.0, 0.0], [0.0, 1.0]], + "q": [0.0, 0.0], + "A": [], + "l": [], + "u": [], + } + with mock.patch.object(SOLVER_MODULE, "MAX_DENSE_VARIABLES", 1): + with self.assertRaises(SOLVER_MODULE.InputError): + SOLVER_MODULE.validate_problem(problem) + + def test_dense_entry_guard_counts_P_and_A(self) -> None: + problem = { + "P": [[1.0, 0.0], [0.0, 1.0]], + "q": [0.0, 0.0], + "A": [[1.0, 1.0]], + "l": [0.0], + "u": [1.0], + } + with ( + mock.patch.object(SOLVER_MODULE, "MAX_DENSE_VARIABLES", 10), + mock.patch.object(SOLVER_MODULE, "MAX_DENSE_ENTRIES", 5), + ): + with self.assertRaises(SOLVER_MODULE.InputError): + SOLVER_MODULE.validate_problem(problem) + + def test_input_file_size_guard_runs_before_json_decode(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "oversized.json" + path.write_text(" " * 20, encoding="utf-8") + with mock.patch.object(SOLVER_MODULE, "MAX_INPUT_BYTES", 10): + with self.assertRaises(SOLVER_MODULE.InputError): + SOLVER_MODULE.load_problem(path) + + @unittest.skipUnless(HAS_NUMPY, "NumPy is required for direct verifier tests") + def test_nonoptimal_stationary_feasible_pair_fails_kkt_verification(self) -> None: + import numpy as np + + problem = SOLVER_MODULE.validate_problem( + { + "P": [[0.0]], + "q": [1.0], + "A": [[1.0]], + "l": [0.0], + "u": ["inf"], + "settings": {"eps_abs": 1e-8, "eps_rel": 1e-8}, + } + ) + report = SOLVER_MODULE.verify_solution( + np, + problem, + np.asarray(problem["P"], dtype=float), + np.asarray(problem["A"], dtype=float), + np.asarray([1.0]), + np.asarray([-1.0]), + min_eigenvalue=0.0, + psd_tolerance=1e-9, + verify_tolerance=1e-7, + ) + self.assertFalse(report["passed"]) + self.assertGreater(report["complementarity_inf"], 0.0) + + @unittest.skipUnless(HAS_NUMPY, "NumPy is required for direct verifier tests") + def test_tiny_forged_primal_certificate_is_rejected_after_normalization( + self, + ) -> None: + import numpy as np + + problem = SOLVER_MODULE.validate_problem( + { + "P": [[0.0]], + "q": [0.0], + "A": [[1.0]], + "l": [1.0], + "u": ["inf"], + } + ) + report = SOLVER_MODULE.verify_primal_certificate( + np, + problem, + np.asarray(problem["A"], dtype=float), + np.asarray([-1e-12]), + verify_tolerance=1e-7, + ) + self.assertFalse(report["passed"]) + self.assertGreater(report["certificate_norm_inf"], 0.0) + self.assertTrue(report["normalized"]) + + @unittest.skipUnless(HAS_NUMPY, "NumPy is required for direct verifier tests") + def test_tiny_forged_dual_certificate_is_rejected_after_normalization( + self, + ) -> None: + import numpy as np + + problem = SOLVER_MODULE.validate_problem( + { + "P": [[1.0]], + "q": [1.0], + "A": [], + "l": [], + "u": [], + } + ) + report = SOLVER_MODULE.verify_dual_certificate( + np, + problem, + np.asarray(problem["P"], dtype=float), + np.empty((0, 1), dtype=float), + np.asarray([-1e-12]), + verify_tolerance=1e-7, + ) + self.assertFalse(report["passed"]) + self.assertGreater(report["certificate_norm_inf"], 0.0) + self.assertTrue(report["normalized"]) + + @unittest.skipUnless(HAS_OSQP_STACK, "OSQP numerical stack is not installed") + def test_feasible_example_solves_and_verifies(self) -> None: + result, report = self.run_json(str(SOLVER), str(FEASIBLE)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + self.assertEqual("solved", report["status"]["classification"]) + self.assertTrue(report["status"]["accepted"]) + self.assertTrue(report["verification"]["passed"]) + self.assertAlmostEqual(0.3, report["solution"]["x"][0], places=5) + self.assertAlmostEqual(0.7, report["solution"]["x"][1], places=5) + + @unittest.skipUnless(HAS_OSQP_STACK, "OSQP numerical stack is not installed") + def test_infeasibility_certificates_verify(self) -> None: + expected = ( + (PRIMAL_INFEASIBLE, "primal_infeasible"), + (DUAL_INFEASIBLE, "dual_infeasible"), + ) + for example, classification in expected: + with self.subTest(example=example.name): + result, report = self.run_json(str(SOLVER), str(example)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + self.assertEqual(classification, report["status"]["classification"]) + self.assertTrue(report["status"]["accepted"]) + self.assertTrue(report["verification"]["passed"]) + + +if __name__ == "__main__": + unittest.main()