quicopt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quicopt/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
3
+ """
4
+ quicopt — the Python client for the Quicopt optimization service.
5
+
6
+ Authors an optimization model in a Python front-end (Pyomo, …) and converts it to
7
+ Quicopt's wire IR — the versioned, language-neutral contract the service consumes.
8
+ This package is a thin front-end; see ``README.md`` for usage.
9
+
10
+ Layers:
11
+ ir — the ``Program`` IR data model (front-end-agnostic)
12
+ wire — ``Program`` → versioned wire bytes (front-end-agnostic)
13
+ pyomo — a Pyomo model → ``Program`` importer (a front-end)
14
+ mathopt — an OR-Tools MathOpt model → ``Program`` importer (a front-end)
15
+ client — POST the wire bytes to the service and read the result (HTTP, stdlib)
16
+ """
17
+ from .ir import (Const, Param, Var, Apply, Reduce, SetRef,
18
+ Zero, Nonneg, Indicator,
19
+ VarDecl, IndexSet, Constraint, Program,
20
+ Domain, CONTINUOUS, INTEGER, BINARY)
21
+ from .wire import encode, encode_params, SCHEMA_VERSION
22
+ from .client import Client, Job, Result, QuicoptError
23
+
24
+ __all__ = [
25
+ "Const", "Param", "Var", "Apply", "Reduce", "SetRef",
26
+ "Zero", "Nonneg", "Indicator",
27
+ "VarDecl", "IndexSet", "Constraint", "Program",
28
+ "Domain", "CONTINUOUS", "INTEGER", "BINARY",
29
+ "encode", "encode_params", "SCHEMA_VERSION",
30
+ "Client", "Job", "Result", "QuicoptError",
31
+ ]
quicopt/client.py ADDED
@@ -0,0 +1,351 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
3
+ """
4
+ quicopt.client — talk to the Quicopt service over HTTP.
5
+
6
+ Encode a model to wire bytes (``quicopt.wire``), POST it, read the result back.
7
+ Stdlib-only (``urllib``): the transport adds no dependency, like the rest of the
8
+ core. The request body is the versioned wire bytes; the response is the service's
9
+ result JSON (``status`` / ``objective`` / ``solution`` / a ready-to-print
10
+ ``display`` / …). The first keyless call mints an API key, returned in the
11
+ ``X-Quicopt-Api-Key`` response header and remembered on the :class:`Client` so
12
+ later calls replay it as ``Authorization: Bearer``.
13
+
14
+ Two entry points mirror the two service endpoints:
15
+
16
+ - :meth:`Client.solve` — POST ``/v1/solve``, block for the result (synchronous).
17
+ - :meth:`Client.submit` — POST ``/v1/jobs``, return a :class:`Job` to poll.
18
+
19
+ A non-2xx response raises :class:`QuicoptError`, carrying the service's stable
20
+ ``reason`` code and the framed ``display`` text.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import gzip as _gzip
25
+ import json
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from typing import Any, Dict, Optional, Union
31
+ from urllib.parse import urlencode
32
+
33
+ from .ir import Program
34
+ from .wire import encode
35
+
36
+ __all__ = ["Client", "Job", "Result", "QuicoptError"]
37
+
38
+ _OCTET = "application/octet-stream"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Result:
43
+ """A finished solve, parsed from the service's result JSON. ``objective`` and
44
+ ``feasible`` are ``None`` when the class or outcome leaves them undefined
45
+ (e.g. an unconstrained heuristic, or no incumbent). ``display`` is the framed,
46
+ ready-to-print summary the service renders for every backend alike."""
47
+ job_id: str
48
+ status: str
49
+ objective: Optional[float]
50
+ feasible: Optional[bool]
51
+ solution: Dict[str, float]
52
+ solve_time_seconds: float
53
+ solver_data: Dict[str, Any]
54
+ display: str
55
+
56
+ @property
57
+ def model_class(self) -> Optional[str]:
58
+ """The class the service routed the model to (``LP``/``MILP``/``QUBO``/…).
59
+
60
+ Returns:
61
+ The ``model_class`` recorded in ``solver_data``, or ``None`` if the
62
+ service did not report one.
63
+ """
64
+ return self.solver_data.get("model_class")
65
+
66
+ @classmethod
67
+ def _from_json(cls, d: Dict[str, Any]) -> "Result":
68
+ """Build a ``Result`` from the service's decoded result JSON.
69
+
70
+ Missing optional keys default rather than raise, so a partial result (e.g. a
71
+ heuristic backend that reports no ``objective``) still parses.
72
+
73
+ Args:
74
+ d: The decoded result JSON object.
75
+
76
+ Returns:
77
+ Result: The parsed result.
78
+ """
79
+ return cls(
80
+ job_id=d.get("job_id", ""),
81
+ status=d["status"],
82
+ objective=d.get("objective"),
83
+ feasible=d.get("feasible"),
84
+ solution=dict(d.get("solution") or {}),
85
+ solve_time_seconds=d.get("solve_time_seconds", 0.0),
86
+ solver_data=dict(d.get("solver_data") or {}),
87
+ display=d.get("display", ""),
88
+ )
89
+
90
+
91
+ class QuicoptError(Exception):
92
+ """A non-2xx service response. ``reason`` is the service's stable snake_case
93
+ code (``size_exceeded``, ``unsupported_model``, ``quota_exhausted``, …),
94
+ ``display`` the framed message to print, ``status_code`` the HTTP status."""
95
+
96
+ def __init__(self, status_code: int, body: Dict[str, Any]):
97
+ self.status_code = status_code
98
+ self.body = body if isinstance(body, dict) else {"error": str(body)}
99
+ self.reason = self.body.get("reason")
100
+ self.display = self.body.get("display")
101
+ super().__init__(self.body.get("error") or f"HTTP {status_code}")
102
+
103
+
104
+ _Model = Any # a front-end model (Pyomo, MathOpt), a Program, or pre-encoded wire bytes
105
+
106
+
107
+ def _import_frontend(model: Any) -> Program:
108
+ """A modeling-front-end object → ``Program``, via that front-end's importer,
109
+ dispatched by type. The importers are imported lazily so the stdlib-only core
110
+ never pulls in an optional front-end dependency until a model of that kind is
111
+ actually solved."""
112
+ mod = type(model).__module__ or ""
113
+ if mod.startswith("ortools") or hasattr(model, "export_model"):
114
+ from .mathopt import import_model
115
+ return import_model(model)
116
+ if mod.startswith("pyomo"):
117
+ from .pyomo import import_model
118
+ return import_model(model)
119
+ raise TypeError(f"cannot solve a {type(model).__name__}: pass a Pyomo or OR-Tools "
120
+ "MathOpt model, a Program, or wire bytes")
121
+
122
+
123
+ def _to_wire(model: _Model) -> bytes:
124
+ """Coerce to wire bytes: pre-encoded bytes pass through, a ``Program`` is
125
+ encoded, and a front-end model (Pyomo, MathOpt) is imported then encoded — so
126
+ a caller solves the *model*, never a hand-built ``Program``."""
127
+ if isinstance(model, (bytes, bytearray)):
128
+ return bytes(model)
129
+ if isinstance(model, Program):
130
+ return encode(model)
131
+ return encode(_import_frontend(model))
132
+
133
+
134
+ def _query(config: Optional[Dict[str, Any]]) -> str:
135
+ """Render a config mapping as a URL query-string suffix.
136
+
137
+ Args:
138
+ config: Optional service parameters.
139
+
140
+ Returns:
141
+ str: ``"?k=v&…"`` if ``config`` is non-empty, else ``""``.
142
+ """
143
+ return "?" + urlencode(config) if config else ""
144
+
145
+
146
+ class Client:
147
+ """A connection to a Quicopt service at ``base_url``. Holds the API key: pass
148
+ a known one, or let the first keyless call mint one (then read it back from
149
+ ``client.api_key`` to persist)."""
150
+
151
+ def __init__(self, base_url: str, api_key: Optional[str] = None, *, timeout: float = 60.0):
152
+ """Bind a client to a service endpoint.
153
+
154
+ Args:
155
+ base_url: The service base URL; a trailing slash is stripped.
156
+ api_key: A known API key, or ``None`` to let the first keyless call mint
157
+ one (afterwards readable back from ``self.api_key``).
158
+ timeout: Per-request socket timeout, in seconds.
159
+ """
160
+ self.base_url = base_url.rstrip("/")
161
+ self.api_key = api_key
162
+ self.timeout = timeout
163
+
164
+ def solve(self, model: _Model, *, config: Optional[Dict[str, Any]] = None,
165
+ gzip: bool = False) -> Result:
166
+ """Solve ``model`` synchronously, blocking until the result returns.
167
+
168
+ Args:
169
+ model: A front-end model (Pyomo, OR-Tools MathOpt) — imported to the
170
+ wire IR here — or a ``Program`` / pre-encoded wire bytes if you built
171
+ them yourself.
172
+ config: Optional service parameters, sent as the query string.
173
+ gzip: If ``True``, gzip-compress the request body.
174
+
175
+ Returns:
176
+ Result: The finished solve.
177
+
178
+ Raises:
179
+ QuicoptError: On a non-2xx response.
180
+ """
181
+ return Result._from_json(
182
+ self._request("POST", "/v1/solve", _to_wire(model), config=config, gzip=gzip))
183
+
184
+ def submit(self, model: _Model, *, config: Optional[Dict[str, Any]] = None,
185
+ gzip: bool = False) -> "Job":
186
+ """Submit ``model`` for asynchronous solving and return a handle to poll.
187
+
188
+ Args:
189
+ model: A Pyomo/MathOpt model, a ``Program``, or pre-encoded wire bytes.
190
+ config: Optional service parameters, sent as the query string.
191
+ gzip: If ``True``, gzip-compress the request body.
192
+
193
+ Returns:
194
+ Job: A handle to the queued job; call :meth:`Job.result` to await it.
195
+
196
+ Raises:
197
+ QuicoptError: On a non-2xx response.
198
+ """
199
+ body = self._request("POST", "/v1/jobs", _to_wire(model), config=config, gzip=gzip)
200
+ return Job(self, body["job_id"])
201
+
202
+ # ── transport ───────────────────────────────────────────────────────────
203
+
204
+ def _open(self, method: str, path: str, data: Optional[bytes] = None, *,
205
+ config: Optional[Dict[str, Any]] = None, gzip: bool = False) -> bytes:
206
+ """Perform one HTTP request and return the raw response body.
207
+
208
+ Sets the octet-stream content type (and gzip encoding, if requested) for a
209
+ body, attaches the bearer key when known, and captures a freshly minted key
210
+ off the response — including from an ``HTTPError``, since a 502/504 on a
211
+ first call may still have minted one.
212
+
213
+ Args:
214
+ method: The HTTP method.
215
+ path: The path under ``base_url`` (e.g. ``/v1/solve``).
216
+ data: The request body, or ``None`` for a bodyless request.
217
+ config: Optional service parameters, sent as the query string.
218
+ gzip: If ``True``, gzip-compress ``data``.
219
+
220
+ Returns:
221
+ bytes: The raw response body.
222
+
223
+ Raises:
224
+ QuicoptError: On a non-2xx response.
225
+ """
226
+ headers: Dict[str, str] = {}
227
+ if data is not None:
228
+ headers["Content-Type"] = _OCTET
229
+ if gzip:
230
+ data = _gzip.compress(data)
231
+ headers["Content-Encoding"] = "gzip"
232
+ if self.api_key:
233
+ headers["Authorization"] = "Bearer " + self.api_key
234
+ req = urllib.request.Request(self.base_url + path + _query(config),
235
+ data=data, headers=headers, method=method)
236
+ try:
237
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
238
+ self._capture_key(resp.headers)
239
+ return resp.read()
240
+ except urllib.error.HTTPError as e:
241
+ self._capture_key(e.headers) # a 504/502 on a first call still minted a key
242
+ raise QuicoptError(e.code, _decode(e.read())) from None
243
+
244
+ def _request(self, method: str, path: str, data: Optional[bytes] = None, **kw) -> Dict[str, Any]:
245
+ """Perform a request via :meth:`_open` and parse its JSON body.
246
+
247
+ Args:
248
+ method: The HTTP method.
249
+ path: The path under ``base_url``.
250
+ data: The request body, or ``None``.
251
+ **kw: Forwarded to :meth:`_open` (``config``, ``gzip``).
252
+
253
+ Returns:
254
+ dict: The parsed JSON object, or ``{}`` for an empty body.
255
+
256
+ Raises:
257
+ QuicoptError: On a non-2xx response.
258
+ """
259
+ raw = self._open(method, path, data, **kw)
260
+ return json.loads(raw) if raw else {}
261
+
262
+ def _capture_key(self, headers) -> None:
263
+ """Remember a freshly minted API key from a response, if we have none yet.
264
+
265
+ The first keyless call mints a key, returned in the ``X-Quicopt-Api-Key``
266
+ response header; storing it lets the next call authenticate as the same
267
+ caller. A key we already hold is never overwritten.
268
+
269
+ Args:
270
+ headers: The response headers.
271
+
272
+ Returns:
273
+ None. ``self.api_key`` is set on first mint.
274
+ """
275
+ minted = headers.get("X-Quicopt-Api-Key")
276
+ if minted and not self.api_key:
277
+ self.api_key = minted
278
+
279
+
280
+ def _decode(raw: bytes) -> Dict[str, Any]:
281
+ """Best-effort decode of an error response body into a dict.
282
+
283
+ Args:
284
+ raw: The raw response body (from a non-2xx response).
285
+
286
+ Returns:
287
+ dict: The parsed JSON if it parses (or ``{}`` when empty), else the body
288
+ wrapped as ``{"error": <text>}`` so a non-JSON error still surfaces.
289
+ """
290
+ try:
291
+ return json.loads(raw) if raw else {}
292
+ except json.JSONDecodeError:
293
+ return {"error": raw.decode("utf-8", "replace")}
294
+
295
+
296
+ @dataclass
297
+ class Job:
298
+ """A handle to an async job. :meth:`result` polls until it finishes."""
299
+ client: Client
300
+ job_id: str
301
+
302
+ def status(self) -> Dict[str, Any]:
303
+ """Fetch the job's metadata and framed ``log_tail``.
304
+
305
+ Returns:
306
+ dict: The job state (``queued``/``running``/``done``/``failed``) and its
307
+ log tail, as returned by the service.
308
+ """
309
+ return self.client._request("GET", f"/v1/jobs/{self.job_id}")
310
+
311
+ def result(self, *, wait: bool = True, timeout: float = 120.0, poll: float = 0.5) -> Result:
312
+ """Fetch the job's result, optionally polling until it is ready.
313
+
314
+ Args:
315
+ wait: If ``True``, poll past the service's ``not_done`` reason until the
316
+ worker finishes; if ``False``, fetch once.
317
+ timeout: Maximum time to poll, in seconds, before giving up.
318
+ poll: Delay between polls, in seconds.
319
+
320
+ Returns:
321
+ Result: The finished solve.
322
+
323
+ Raises:
324
+ QuicoptError: If ``wait`` is ``False`` and the job is not yet done, on
325
+ any non-``not_done`` error, or once ``timeout`` elapses.
326
+ """
327
+ deadline = time.monotonic() + timeout
328
+ while True:
329
+ try:
330
+ return Result._from_json(
331
+ self.client._request("GET", f"/v1/jobs/{self.job_id}/result"))
332
+ except QuicoptError as e:
333
+ if not wait or e.reason != "not_done" or time.monotonic() > deadline:
334
+ raise
335
+ time.sleep(poll)
336
+
337
+ def log(self) -> str:
338
+ """Fetch the job's plain-text log.
339
+
340
+ Returns:
341
+ str: The framed log view on success, or the error text on failure.
342
+ """
343
+ return self.client._open("GET", f"/v1/jobs/{self.job_id}/log").decode("utf-8", "replace")
344
+
345
+ def delete(self) -> None:
346
+ """Delete the job and its stored blob/result/log on the server.
347
+
348
+ Returns:
349
+ None.
350
+ """
351
+ self.client._open("DELETE", f"/v1/jobs/{self.job_id}")
quicopt/ir.py ADDED
@@ -0,0 +1,154 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
3
+ """
4
+ quicopt.ir — the structured ``Program`` IR.
5
+
6
+ Front-end-agnostic data: a front-end (``quicopt.pyomo``, ``quicopt.mathopt``) builds a ``Program``;
7
+ ``quicopt.wire`` serializes it to the versioned wire bytes. The IR/wire is the
8
+ service's contract — these types track it, they never fork it. Index tuple entries
9
+ are ``int`` (a concrete coordinate) or ``str`` (a bound index symbol); the IR never
10
+ uses a bare string for data, so the mapping is total.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from enum import IntEnum
16
+
17
+ __all__ = [
18
+ "Const", "Param", "Var", "Apply", "Reduce", "SetRef",
19
+ "Zero", "Nonneg", "Indicator",
20
+ "VarDecl", "IndexSet", "Constraint", "Program",
21
+ "Domain", "CONTINUOUS", "INTEGER", "BINARY",
22
+ ]
23
+
24
+
25
+ class Domain(IntEnum):
26
+ """The domain a variable ranges over. Values are the wire enum codes."""
27
+ CONTINUOUS = 1
28
+ INTEGER = 2
29
+ BINARY = 3
30
+
31
+
32
+ CONTINUOUS = Domain.CONTINUOUS
33
+ INTEGER = Domain.INTEGER
34
+ BINARY = Domain.BINARY
35
+
36
+
37
+ # ── Expression = Const | Param | Var | Apply | Reduce ───────────────────────
38
+
39
+ class Expression:
40
+ """Base of the expression grammar."""
41
+
42
+
43
+ @dataclass
44
+ class Const(Expression):
45
+ """A literal numeric constant."""
46
+ value: float
47
+
48
+
49
+ @dataclass
50
+ class Param(Expression):
51
+ """A reference to a parameter-table entry — data bound at instance time, by
52
+ ``name`` and an index tuple (``()`` for a scalar)."""
53
+ name: str
54
+ index: tuple = ()
55
+
56
+
57
+ @dataclass
58
+ class Var(Expression):
59
+ """A reference to a decision variable, by ``name`` and an index tuple
60
+ (``()`` for a scalar)."""
61
+ name: str
62
+ index: tuple = ()
63
+
64
+
65
+ @dataclass
66
+ class Apply(Expression):
67
+ """A catalog operator ``op`` applied to its argument subexpressions."""
68
+ op: str
69
+ args: list # list[Expression]
70
+
71
+
72
+ @dataclass
73
+ class SetRef:
74
+ """A reference to an index set: ``args=()`` is the flat set; ``args=("i",)``
75
+ references the set indexed by enclosing bound indices."""
76
+ name: str
77
+ args: tuple = ()
78
+
79
+
80
+ @dataclass
81
+ class Reduce(Expression):
82
+ """A fold of ``body`` over ``idx`` ranging across ``over`` — e.g. a Σ or Π —
83
+ keeping a term only where ``cond`` is non-zero (``None`` ⇒ keep every term)."""
84
+ op: str # the fold operator key (+, *, …)
85
+ idx: str # the bound dummy index
86
+ over: SetRef
87
+ body: Expression
88
+ cond: "Expression | None" = None # keep a term only where cond ≠ 0
89
+
90
+
91
+ # ── ConSet = Zero | Nonneg | Indicator ──────────────────────────────────────
92
+
93
+ @dataclass
94
+ class Zero:
95
+ """f = 0"""
96
+
97
+
98
+ @dataclass
99
+ class Nonneg:
100
+ """f ≥ 0"""
101
+
102
+
103
+ @dataclass
104
+ class Indicator:
105
+ """``bin`` active (= 1) implies the body satisfies the ``inner`` ConSet."""
106
+ bin: Var # the binary being active implies f ∈ inner
107
+ inner: object # ConSet
108
+
109
+
110
+ # ── variables, constraints, container ───────────────────────────────────────
111
+
112
+ @dataclass
113
+ class VarDecl:
114
+ """A variable declaration: ``name`` over the product of the index sets in
115
+ ``axes`` (``[]`` ⇒ scalar), ranging over ``domain``, with ``lower``/``upper``
116
+ bounds and an initial ``start``. A bound is a float, or the ``str`` name of a
117
+ Param table when it varies by index."""
118
+ name: str
119
+ axes: list # list[str] of set names; [] ⇒ scalar
120
+ domain: Domain
121
+ lower: object # float scalar, or str = name of a Param table
122
+ upper: object
123
+ start: float
124
+
125
+
126
+ @dataclass
127
+ class IndexSet:
128
+ """A named index set with concrete ``elements`` (each ``int`` or ``str``)."""
129
+ name: str
130
+ elements: list # list[int | str]
131
+
132
+
133
+ @dataclass
134
+ class Constraint:
135
+ """A constraint row: the expression ``f`` lies in the ConSet ``set``, for every
136
+ binding in ``over`` (``[(dummy, SetRef)]``; empty ⇒ a single scalar row)."""
137
+ f: Expression
138
+ set: object # ConSet
139
+ over: list = field(default_factory=list) # list[(str, SetRef)] quantifier bindings
140
+
141
+
142
+ @dataclass
143
+ class Program:
144
+ """A complete optimization model: index sets and data tables, variable
145
+ declarations, the objective and its ``sense``, the constraint rows, and any
146
+ per-index variable pins (``fix``)."""
147
+ sets: list = field(default_factory=list) # list[IndexSet]
148
+ indexed_sets: dict = field(default_factory=dict) # {name: {tuple: list}} a[(i,)] -> [j…]
149
+ params: dict = field(default_factory=dict) # {name: {tuple: float}} p[(i,)], A[(i,j)]
150
+ vars: list = field(default_factory=list) # list[VarDecl]
151
+ objective: Expression = None
152
+ sense: str = "min" # "min" | "max"
153
+ constraints: list = field(default_factory=list) # list[Constraint]
154
+ fix: dict = field(default_factory=dict) # {(varname, tuple): float} per-index pins