The portfolio data, risk and mean-CVaR optimisation service, prepared for students who run it on their own DGX Spark without root, Docker or Tailscale. - deploy/apptainer/qc.sh: install, start/stop/status/logs, cli/api/python, data import/export and Yahoo setup/update. cuOpt and quantcore run as two Apptainer instances of the user and talk over 127.0.0.1; the API and the test bench listen on 127.0.0.1 only. Builds quantcore.sif without root, or falls back to a plain Python image plus a virtualenv when the Spark allows no unprivileged builds. Detects taken ports and suggests free ones. - deploy/apptainer/quantcore.def + constraints.txt: the image, with the library versions of the verified production container pinned. - cuOpt under Apptainer: NVIDIA's image sets HOME=/opt/cuopt, read-only in a SIF, so the solver died on its first job; qc.sh gives it a writable home. - solvers: a cuOpt that disappears during a solve now falls back to HiGHS instead of answering 500 (requests' ConnectionError escaped the fallback). - client: idempotent GETs retry a dropped keep-alive connection, which made `quantcore-api update --wait` fail when a poll met uvicorn's 5 s timeout. - config: cuOpt defaults to http://127.0.0.1:5000 and `quantcore serve` to 127.0.0.1; the Docker images keep passing their own values. - docs/SPARK_APPTAINER.md: the students' guide; README, USAGE, ARCHITECTURE and SPARK_DEPLOYMENT point to it. Tested on a DGX Spark (GB10, Apptainer 1.5.4, unprivileged user) in both modes: selftest on every endpoint, optimisation on cuOpt, Yahoo update, port conflicts; 139 tests pass in the container against the live cuOpt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.8 KiB
Python
166 lines
6.8 KiB
Python
import numpy as np
|
|
import pytest
|
|
import scipy.sparse as sp
|
|
|
|
from quantcore.config import Settings
|
|
from quantcore.optimization.problem import MILPModel
|
|
from quantcore.optimization.solvers import CuOptSolver, HighsSolver, solve_with_fallback, to_cuopt_payload
|
|
|
|
|
|
def readme_model() -> MILPModel:
|
|
# maximise 3x + 2y s.t. 2x + 3y <= 12, 4x + y <= 10, x,y >= 0 integer -> x = 2, y = 2, obj 10
|
|
A = sp.csr_matrix(np.array([[2.0, 3.0], [4.0, 1.0]]))
|
|
return MILPModel(c=np.array([3.0, 2.0]), A=A, row_lb=np.array([-np.inf, -np.inf]), row_ub=np.array([12.0, 10.0]),
|
|
var_lb=np.zeros(2), var_ub=np.array([np.inf, np.inf]), var_types=["I", "I"], var_names=["x", "y"],
|
|
row_names=["c1", "c2"], maximize=True)
|
|
|
|
|
|
def test_payload_format():
|
|
payload = to_cuopt_payload(readme_model(), time_limit=5)
|
|
assert payload["csr_constraint_matrix"] == {"offsets": [0, 2, 4], "indices": [0, 1, 0, 1], "values": [2.0, 3.0, 4.0, 1.0]}
|
|
assert payload["constraint_bounds"]["lower_bounds"] == ["ninf", "ninf"]
|
|
assert payload["variable_bounds"]["upper_bounds"] == ["inf", "inf"]
|
|
assert payload["variable_types"] == ["I", "I"] and payload["maximize"] is True
|
|
assert payload["solver_config"]["time_limit"] == 5.0
|
|
|
|
|
|
def test_highs_solves_readme_example():
|
|
res = HighsSolver().solve(readme_model())
|
|
assert res.ok and res.status == "Optimal"
|
|
np.testing.assert_allclose(res.x, [2.0, 2.0])
|
|
assert res.objective == pytest.approx(10.0)
|
|
|
|
|
|
def test_fallback_when_cuopt_unreachable():
|
|
settings = Settings(cuopt_url="http://127.0.0.1:9", solver_fallback=True, cuopt_request_timeout=2)
|
|
res = solve_with_fallback(readme_model(), settings, solver="auto")
|
|
assert res.solver == "highs" and "fallback" in res.message and res.objective == pytest.approx(10.0)
|
|
with pytest.raises(ConnectionError):
|
|
solve_with_fallback(readme_model(), settings, solver="cuopt")
|
|
assert CuOptSolver(settings).health()["reachable"] is False
|
|
|
|
|
|
def test_fallback_when_cuopt_dies_during_the_solve(monkeypatch):
|
|
"""cuOpt accepted the job, then its server went away while the client polled for the solution
|
|
(seen with a read-only container whose solver process crashed): 'auto' must still answer, on HiGHS."""
|
|
import requests
|
|
|
|
from quantcore.optimization import solvers as S
|
|
|
|
settings = Settings(cuopt_url="http://127.0.0.1:9", solver_fallback=True, cuopt_poll_interval=0.01)
|
|
monkeypatch.setattr(S.CuOptSolver, "reachable", lambda self, ttl=30.0: True)
|
|
monkeypatch.setattr(S.CuOptSolver, "_post_with_retry", lambda self, payload: "job-1")
|
|
|
|
def lost(*args, **kwargs):
|
|
raise requests.ConnectionError("Max retries exceeded")
|
|
|
|
monkeypatch.setattr(S.requests, "get", lost)
|
|
monkeypatch.setattr(S.requests, "delete", lost)
|
|
res = solve_with_fallback(readme_model(), settings, solver="auto")
|
|
assert res.solver == "highs" and "connection lost" in res.message and res.objective == pytest.approx(10.0)
|
|
with pytest.raises(ConnectionError):
|
|
solve_with_fallback(readme_model(), settings, solver="cuopt")
|
|
|
|
|
|
@pytest.mark.skipif(not CuOptSolver(Settings()).health().get("reachable"), reason="cuOpt server not reachable")
|
|
def test_cuopt_solves_readme_example():
|
|
res = CuOptSolver(Settings()).solve(readme_model(), time_limit=10)
|
|
assert res.ok and res.solver == "cuopt"
|
|
np.testing.assert_allclose(res.x, [2.0, 2.0])
|
|
assert res.objective == pytest.approx(10.0)
|
|
|
|
|
|
def test_cuopt_retries_down_the_method_ladder_after_an_abort(monkeypatch):
|
|
"""cuOpt's default method aborts on some CVaR-objective models; the client works down
|
|
``CUOPT_RETRY_METHODS`` and reports which method finally solved it."""
|
|
from quantcore.optimization import solvers as S
|
|
|
|
calls = []
|
|
|
|
class Response:
|
|
def __init__(self, payload, status=200):
|
|
self._payload = payload
|
|
self.status_code = status
|
|
self.text = str(payload)
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def fake_post(url, data=None, headers=None, timeout=None, **kw): # noqa: ARG001
|
|
import msgpack
|
|
|
|
calls.append(msgpack.unpackb(data)["solver_config"])
|
|
return Response({"reqId": f"req{len(calls)}"})
|
|
|
|
def fake_get(url, headers=None, timeout=None, **kw):
|
|
if len(calls) == 1: # first attempt: aborted
|
|
return Response({"error_result": True, "error": "req1 was aborted"})
|
|
return Response({"response": {"solver_response": {
|
|
"status": "Optimal",
|
|
"solution": {"primal_solution": [1.0, 0.0], "primal_objective": 1.0, "solver_time": 0.1,
|
|
"milp_statistics": {"mip_gap": 0.0}},
|
|
}}})
|
|
|
|
class FakeSession: # the client opens a fresh session per submit attempt
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
post = staticmethod(fake_post)
|
|
|
|
monkeypatch.setattr(S.requests, "Session", FakeSession)
|
|
monkeypatch.setattr(S.requests, "get", fake_get)
|
|
monkeypatch.setattr(S.requests, "delete", lambda *a, **k: Response({}))
|
|
|
|
model = readme_model()
|
|
res = S.CuOptSolver(Settings(cuopt_url="http://example.invalid")).solve(model, time_limit=5)
|
|
first = S.CUOPT_RETRY_METHODS[0]
|
|
assert res.status == "Optimal" and f"method={first}" in res.message
|
|
assert calls[0].get("method") is None and calls[1]["method"] == first # default, then the ladder
|
|
|
|
|
|
def test_cuopt_reports_when_every_method_aborts(monkeypatch):
|
|
"""All rungs exhausted: the caller is told that, not just that the first attempt aborted."""
|
|
from quantcore.optimization import solvers as S
|
|
|
|
calls = []
|
|
|
|
class Response:
|
|
def __init__(self, payload):
|
|
self._payload, self.status_code, self.text = payload, 200, str(payload)
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
class FakeSession:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
@staticmethod
|
|
def post(url, data=None, headers=None, timeout=None, **kw):
|
|
import msgpack
|
|
|
|
calls.append(msgpack.unpackb(data)["solver_config"])
|
|
return Response({"reqId": f"req{len(calls)}"})
|
|
|
|
monkeypatch.setattr(S.requests, "Session", FakeSession)
|
|
monkeypatch.setattr(S.requests, "get",
|
|
lambda *a, **k: Response({"error_result": True, "error": "req was aborted"}))
|
|
monkeypatch.setattr(S.requests, "delete", lambda *a, **k: Response({}))
|
|
|
|
res = S.CuOptSolver(Settings(cuopt_url="http://example.invalid")).solve(readme_model(), time_limit=5)
|
|
assert res.status == "Error"
|
|
assert str(S.CUOPT_RETRY_METHODS) in res.message
|
|
assert len(calls) == 1 + len(S.CUOPT_RETRY_METHODS) # the default plus every rung
|