Skip to content

Commit 501d80b

Browse files
committed
Add adapter smoke CI
1 parent a3c9089 commit 501d80b

4 files changed

Lines changed: 191 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
schedule:
7+
- cron: "23 2 * * *"
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 20
17+
18+
env:
19+
OMP_NUM_THREADS: "2"
20+
OPENBLAS_NUM_THREADS: "2"
21+
MKL_NUM_THREADS: "2"
22+
NUMEXPR_NUM_THREADS: "2"
23+
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
28+
- name: Checkout OptiProfiler
29+
uses: actions/checkout@v4
30+
with:
31+
repository: optiprofiler/optiprofiler
32+
path: optiprofiler
33+
ref: main
34+
35+
- name: Set up Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.11"
39+
40+
- name: Install dependencies
41+
run: |
42+
python -m pip install --upgrade pip
43+
python -m pip install -e optiprofiler
44+
45+
- name: Run adapter smoke and random tests
46+
run: |
47+
python -m unittest discover -s tests -p 'test_*.py'

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,27 @@ set_plib_config('s2mpj', variable_size='all', test_feasibility_problems=2)
2828

2929
You can also set the environment variables `S2MPJ_VARIABLE_SIZE` and `S2MPJ_TEST_FEASIBILITY_PROBLEMS` directly. Environment variables take precedence over `config.txt`.
3030

31+
## Testing
32+
33+
The `CI` workflow runs daily and on pushes. It checks the OptiProfiler adapter layer by:
34+
35+
- selecting a small set of representative `u`, `b`, `l`, and `n` problems;
36+
- loading each selected problem through `s2mpj_load`;
37+
- evaluating `fun`, `cub`, and `ceq` at the initial point;
38+
- sampling a few additional small problems each day with at most two numerical-library threads.
39+
40+
Locally, from this repository:
41+
42+
```bash
43+
python -m unittest discover -s tests -p 'test_*.py'
44+
```
45+
3146
## Maintenance
3247

3348
This repository is **automatically synchronized** with the upstream `GrattonToint/S2MPJ` repository via GitHub Actions. It checks for updates daily to ensure the problem set remains current.
3449

50+
## Provenance and Citation
51+
52+
The files under `src/` are a filtered Python subset of the upstream [S2MPJ](https://github.com/GrattonToint/S2MPJ) repository. This repository adds only the OptiProfiler adapter, metadata, and maintenance workflows. Please follow the upstream S2MPJ citation and license guidance when using the problem collection.
53+
3554
For the full collection or other languages, please visit the [original repository](https://github.com/GrattonToint/S2MPJ).

s2mpj_tools.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,20 +176,24 @@ def s2mpj_load(problem_name, *args):
176176

177177
# The linear constraints are hidden in the cJx method output.
178178
# cx = jx @ x0 - bx
179-
buf = io.StringIO()
180-
with redirect_stdout(buf):
181-
try:
182-
cx, jx = p.cJx(p.x0)[:2]
183-
if hasattr(cx, 'toarray'):
184-
cx = cx.toarray()
185-
if hasattr(jx, 'toarray'):
186-
jx = jx.toarray()
187-
cx = cx.flatten()
188-
bx = jx @ x0 - cx
189-
except Exception as err:
190-
_warn_s2mpj_evaluation_failure(name, 'the linear constraint Jacobian', err)
191-
jx = np.full((getattr(p, 'm', 0), getattr(p, 'n', x0.size)), np.nan)
192-
bx = np.full(getattr(p, 'm', 0), np.nan)
179+
if getattr(p, 'm', 0) == 0:
180+
jx = np.empty((0, getattr(p, 'n', x0.size)))
181+
bx = np.empty(0)
182+
else:
183+
buf = io.StringIO()
184+
with redirect_stdout(buf):
185+
try:
186+
cx, jx = p.cJx(p.x0)[:2]
187+
if hasattr(cx, 'toarray'):
188+
cx = cx.toarray()
189+
if hasattr(jx, 'toarray'):
190+
jx = jx.toarray()
191+
cx = cx.flatten()
192+
bx = jx @ x0 - cx
193+
except Exception as err:
194+
_warn_s2mpj_evaluation_failure(name, 'the linear constraint Jacobian', err)
195+
jx = np.full((getattr(p, 'm', 0), getattr(p, 'n', x0.size)), np.nan)
196+
bx = np.full(getattr(p, 'm', 0), np.nan)
193197

194198
nonlincons = np.setdiff1d(np.arange(p.m), p.lincons)
195199
idx_eq = np.intersect1d(np.arange(p.nle, p.nle + p.neq), idx_cl_finite)
@@ -607,6 +611,8 @@ def _gethess(p, is_feasibility, problem_name, x):
607611
return h
608612

609613
def _getcx(p, problem_name, x):
614+
if getattr(p, 'm', 0) == 0:
615+
return np.empty(0)
610616
buf = io.StringIO()
611617
with redirect_stdout(buf):
612618
try:
@@ -619,6 +625,8 @@ def _getcx(p, problem_name, x):
619625
return c
620626

621627
def _getJx(p, problem_name, x):
628+
if getattr(p, 'm', 0) == 0:
629+
return np.empty((0, getattr(p, 'n', x.size)))
622630
buf = io.StringIO()
623631
with redirect_stdout(buf):
624632
try:
@@ -630,6 +638,8 @@ def _getJx(p, problem_name, x):
630638
return j
631639

632640
def _getHx(p, problem_name, x):
641+
if getattr(p, 'm', 0) == 0:
642+
return []
633643
buf = io.StringIO()
634644
with redirect_stdout(buf):
635645
try:

tests/test_s2mpj_python.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
from datetime import date
4+
from pathlib import Path
5+
import math
6+
import os
7+
import random
8+
import sys
9+
import unittest
10+
11+
import numpy as np
12+
13+
14+
REPO_DIR = Path(__file__).resolve().parents[1]
15+
16+
op_candidates = [
17+
REPO_DIR / "optiprofiler" / "python",
18+
REPO_DIR.parents[1] / "optiprofiler" / "python",
19+
]
20+
for op_path in op_candidates:
21+
if (op_path / "optiprofiler").is_dir():
22+
sys.path.insert(0, str(op_path))
23+
break
24+
25+
sys.path.insert(0, str(REPO_DIR))
26+
27+
from s2mpj_tools import s2mpj_load, s2mpj_select
28+
29+
30+
REPRESENTATIVE_PROBLEMS = ["ALLINITU", "ALLINIT", "ALSOTAME", "ALLINITA"]
31+
32+
33+
def _as_array(value):
34+
if value is None:
35+
return np.empty(0)
36+
return np.asarray(value)
37+
38+
39+
def _assert_problem_contract(testcase, problem_name):
40+
problem = s2mpj_load(problem_name)
41+
testcase.assertEqual(problem.name.split("_")[0], problem_name.split("_")[0])
42+
testcase.assertGreaterEqual(problem.n, 1)
43+
testcase.assertEqual(problem.x0.size, problem.n)
44+
45+
fx0 = problem.fun(problem.x0)
46+
testcase.assertTrue(math.isfinite(float(fx0)) or math.isnan(float(fx0)))
47+
48+
cub0 = _as_array(problem.cub(problem.x0))
49+
ceq0 = _as_array(problem.ceq(problem.x0))
50+
testcase.assertEqual(cub0.ndim, 1)
51+
testcase.assertEqual(ceq0.ndim, 1)
52+
53+
# Evaluate twice to catch wrappers that mutate S2MPJ state unexpectedly.
54+
fx1 = problem.fun(problem.x0)
55+
testcase.assertTrue(math.isfinite(float(fx1)) or math.isnan(float(fx1)))
56+
57+
58+
class S2MPJPythonAdapterTests(unittest.TestCase):
59+
def test_select_representative_problem_types(self):
60+
selected = s2mpj_select(
61+
{
62+
"ptype": "ubln",
63+
"maxdim": 5,
64+
"maxb": 20,
65+
"maxlcon": 20,
66+
"maxnlcon": 20,
67+
"maxcon": 20,
68+
}
69+
)
70+
for problem_name in REPRESENTATIVE_PROBLEMS:
71+
self.assertIn(problem_name, selected)
72+
73+
def test_load_representative_problem_types(self):
74+
for problem_name in REPRESENTATIVE_PROBLEMS:
75+
with self.subTest(problem=problem_name):
76+
_assert_problem_contract(self, problem_name)
77+
78+
def test_daily_random_small_problem_sample(self):
79+
seed = int(os.environ.get("OP_RANDOM_SEED", date.today().strftime("%Y%m%d")))
80+
candidates = s2mpj_select(
81+
{
82+
"ptype": "ubln",
83+
"maxdim": 5,
84+
"maxb": 20,
85+
"maxlcon": 20,
86+
"maxnlcon": 20,
87+
"maxcon": 20,
88+
}
89+
)
90+
self.assertGreaterEqual(len(candidates), 4)
91+
92+
rng = random.Random(seed)
93+
sample = rng.sample(candidates, k=min(4, len(candidates)))
94+
print(f"S2MPJ Python random sample seed={seed}: {sample}")
95+
for problem_name in sample:
96+
with self.subTest(problem=problem_name):
97+
_assert_problem_contract(self, problem_name)
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

0 commit comments

Comments
 (0)