jaddle 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.
jaddle/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
jaddle/glop_helpers.py ADDED
@@ -0,0 +1,112 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+
7
+ import numpy as np
8
+ import scipy.sparse as sp
9
+ from ortools.linear_solver import linear_solver_pb2
10
+
11
+ from jaddle.highs_helpers import rows_to_standard_form
12
+
13
+ # The OR-Tools Python bindings don't expose glop's presolver, so it runs in a
14
+ # small C++ helper (tools/glop_presolve, built with tools/glop_presolve/build.sh).
15
+ # Override its location with the JADDLE_GLOP_PRESOLVE environment variable.
16
+ GLOP_PRESOLVE_BINARY = os.environ.get(
17
+ "JADDLE_GLOP_PRESOLVE",
18
+ os.path.join(
19
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
20
+ "tools",
21
+ "glop_presolve",
22
+ "build",
23
+ "glop_presolve",
24
+ ),
25
+ )
26
+
27
+
28
+ def glop_presolve(path: str, verbose: bool = False):
29
+ """
30
+ Reads an MPS file, relaxes integrality and presolves the LP relaxation with
31
+ OR-Tools' glop presolver (MainLpPreprocessor, with PDLP's settings: no
32
+ dualization, no implied-free preprocessor, no scaling).
33
+
34
+ Returns: (LP, offset, info) where ``offset`` is the constant objective term
35
+ outside ``c`` in minimisation form (min c^T x + offset; for a maximisation
36
+ model the original objective is ``-(c^T x + offset)``, as in
37
+ ``sciopt_helpers.scip_to_standard_form_sparse``) and ``info`` is the
38
+ helper's report (status, original/presolved sizes, read/presolve seconds).
39
+
40
+ Raises RuntimeError if glop's presolve already decided the problem (status
41
+ other than INIT, e.g. PRIMAL_INFEASIBLE), since no LP is left to solve.
42
+ """
43
+ if not os.path.exists(GLOP_PRESOLVE_BINARY):
44
+ raise FileNotFoundError(
45
+ f"glop_presolve helper not found at {GLOP_PRESOLVE_BINARY}; "
46
+ "build it with tools/glop_presolve/build.sh"
47
+ )
48
+ with tempfile.TemporaryDirectory() as tmp:
49
+ out_path = os.path.join(tmp, "presolved.pb")
50
+ cmd = [GLOP_PRESOLVE_BINARY, path, out_path]
51
+ if verbose:
52
+ cmd.insert(1, "--verbose")
53
+ proc = subprocess.run(cmd, capture_output=True, text=True)
54
+ if proc.returncode != 0:
55
+ raise RuntimeError(
56
+ f"glop_presolve failed on {path} (exit {proc.returncode}): "
57
+ f"{proc.stderr.strip()}"
58
+ )
59
+ lines = proc.stdout.strip().splitlines()
60
+ if verbose:
61
+ print("\n".join(lines[:-1]))
62
+ sys.stdout.flush()
63
+ info = json.loads(lines[-1])
64
+ if info["status"] != "INIT":
65
+ raise RuntimeError(
66
+ f"glop presolve decided {path} outright (status {info['status']})"
67
+ )
68
+ model = linear_solver_pb2.MPModelProto()
69
+ with open(out_path, "rb") as f:
70
+ model.ParseFromString(f.read())
71
+
72
+ lp, offset = mpmodel_to_standard_form_sparse(
73
+ model, scaling_factor=info["objective_scaling_factor"]
74
+ )
75
+ return lp, offset, info
76
+
77
+
78
+ def mpmodel_to_standard_form_sparse(model, scaling_factor: float = 1.0):
79
+ """
80
+ Converts an MPModelProto (linear constraints only) to Jaddle's standard
81
+ form. Maximisation is negated into minimisation. ``scaling_factor`` is the
82
+ glop objective scaling factor (true objective = factor * (c^T x + offset)),
83
+ which MPModelProto can't store; it is folded into ``c`` and the offset.
84
+
85
+ Returns: (LP, offset).
86
+ """
87
+ if len(model.general_constraint) or model.HasField("quadratic_objective"):
88
+ raise ValueError("model has non-linear parts; cannot export to a Jaddle LP")
89
+
90
+ sense = -1.0 if model.maximize else 1.0
91
+ scale = sense * scaling_factor
92
+ variables = model.variable
93
+ c = scale * np.array([v.objective_coefficient for v in variables], dtype=np.float64)
94
+ lower_bounds = np.array([v.lower_bound for v in variables], dtype=np.float64)
95
+ upper_bounds = np.array([v.upper_bound for v in variables], dtype=np.float64)
96
+ offset = scale * model.objective_offset
97
+
98
+ cons = model.constraint
99
+ row_nnz = np.array([len(k.var_index) for k in cons], dtype=np.int64)
100
+ indptr = np.concatenate([[0], np.cumsum(row_nnz)])
101
+ indices = np.fromiter(
102
+ (j for k in cons for j in k.var_index), dtype=np.int64, count=indptr[-1]
103
+ )
104
+ data = np.fromiter(
105
+ (a for k in cons for a in k.coefficient), dtype=np.float64, count=indptr[-1]
106
+ )
107
+ A = sp.csr_matrix((data, indices, indptr), shape=(len(cons), len(variables)))
108
+ row_lower = np.array([k.lower_bound for k in cons], dtype=np.float64)
109
+ row_upper = np.array([k.upper_bound for k in cons], dtype=np.float64)
110
+
111
+ lp = rows_to_standard_form(c, A, row_lower, row_upper, lower_bounds, upper_bounds)
112
+ return lp, float(offset)
@@ -0,0 +1,90 @@
1
+ import numpy as np
2
+ import scipy.sparse as sp
3
+ import highspy as hspy
4
+ import jax.numpy as jnp
5
+ import jax.experimental.sparse as jsp
6
+ from jaddle.jaddle_basic_types import JaddleLP, LP
7
+
8
+
9
+ def highs_to_standard_form_sparse(lp: hspy.HighsLp):
10
+ """
11
+ Converts a HighsLp object to standard form matrices:
12
+ min c^T x
13
+ s.t. A_eq x = b_eq
14
+ A_ineq x <= b_ineq
15
+ x >= lower_bounds
16
+ x <= upper_bounds
17
+ Returns: LP (JAX-native; device-side sparse). Stages that need a scipy
18
+ view (scaling, polish, crossover) call ``LP.to_scipy()``.
19
+ """
20
+
21
+ c = np.array(lp.col_cost_, dtype=np.float64)
22
+ lower_bounds = np.array(lp.col_lower_, dtype=np.float64)
23
+ upper_bounds = np.array(lp.col_upper_, dtype=np.float64)
24
+
25
+ # Build A matrix from sparse representation
26
+ num_row = lp.a_matrix_.num_row_
27
+ num_col = lp.a_matrix_.num_col_
28
+ A = sp.csc_matrix(
29
+ (lp.a_matrix_.value_, lp.a_matrix_.index_, lp.a_matrix_.start_),
30
+ shape=(num_row, num_col),
31
+ dtype=np.float64,
32
+ )
33
+
34
+ row_lower = np.array(lp.row_lower_, dtype=np.float64)
35
+ row_upper = np.array(lp.row_upper_, dtype=np.float64)
36
+ return rows_to_standard_form(c, A, row_lower, row_upper, lower_bounds, upper_bounds)
37
+
38
+
39
+ def rows_to_standard_form(c, A, row_lower, row_upper, lower_bounds, upper_bounds):
40
+ """
41
+ Converts a row-bounded LP
42
+ min c^T x s.t. row_lower <= A x <= row_upper, lower <= x <= upper
43
+ (infinite bounds as +-inf) to the standard form of
44
+ ``highs_to_standard_form_sparse``: rows with equal finite bounds become
45
+ A_eq x = b_eq, every other finite row bound becomes a row of
46
+ A_ineq x <= b_ineq. Returns: LP.
47
+ """
48
+ num_col = A.shape[1]
49
+
50
+ # Equality constraints: use a numeric tolerance and require finite bounds
51
+ eps = 1e-8
52
+ finite_lower_all = np.isfinite(row_lower)
53
+ finite_upper_all = np.isfinite(row_upper)
54
+ eq_mask = (
55
+ finite_lower_all & finite_upper_all & (np.abs(row_lower - row_upper) <= eps)
56
+ )
57
+
58
+ A_eq = A[eq_mask, :].tocsc().astype(np.float64)
59
+ b_eq = row_lower[eq_mask].astype(np.float64)
60
+
61
+ # Inequality constraints (rows not treated as equalities)
62
+ ineq_mask = ~eq_mask
63
+ A_ineq_rows = A[ineq_mask, :]
64
+ row_lower_ineq = row_lower[ineq_mask]
65
+ row_upper_ineq = row_upper[ineq_mask]
66
+
67
+ finite_upper = np.isfinite(row_upper_ineq)
68
+ finite_lower = np.isfinite(row_lower_ineq)
69
+
70
+ # Build inequality matrices more efficiently
71
+ matrices = []
72
+ vectors = []
73
+
74
+ if finite_upper.any():
75
+ matrices.append(A_ineq_rows[finite_upper].tocsc().astype(np.float64))
76
+ vectors.append(row_upper_ineq[finite_upper].astype(np.float64))
77
+
78
+ if finite_lower.any():
79
+ matrices.append((-A_ineq_rows[finite_lower]).tocsc().astype(np.float64))
80
+ vectors.append((-row_lower_ineq[finite_lower]).astype(np.float64))
81
+
82
+ if len(matrices) > 0:
83
+ A_ineq = sp.vstack(matrices, format="csc")
84
+ b_ineq = np.concatenate(vectors)
85
+ else:
86
+ # No inequality rows: return empty (0 x num_col) matrix and empty RHS
87
+ A_ineq = sp.csc_matrix((0, num_col), dtype=np.float64)
88
+ b_ineq = np.empty(0, dtype=np.float64)
89
+
90
+ return LP(c, A_eq, b_eq, A_ineq, b_ineq, lower_bounds, upper_bounds)
@@ -0,0 +1,272 @@
1
+ import jax.numpy as jnp
2
+ from typing import NamedTuple
3
+ import optax
4
+ from typing import Any, Callable, NamedTuple, Sequence, Union
5
+ import jax
6
+ import numpy as np
7
+
8
+ ScheduleLike = Union[float, Callable[[jnp.ndarray], jnp.ndarray]]
9
+
10
+
11
+ # %%
12
+ # Basic Types
13
+ class SaddleState(NamedTuple):
14
+ primal: jnp.ndarray
15
+ dual_ineq: jnp.ndarray
16
+ dual_eq: jnp.ndarray
17
+
18
+
19
+ class JaddleCP:
20
+ def __init__(
21
+ self,
22
+ num_variables,
23
+ objective,
24
+ constraints_eq,
25
+ constraints_ineq,
26
+ lower_bounds,
27
+ upper_bounds,
28
+ dual_bound=None,
29
+ ):
30
+ self.num_variables = num_variables
31
+ self.objective = objective
32
+ self.constraints_eq = constraints_eq
33
+ self.constraints_ineq = constraints_ineq
34
+ self.lower_bounds = lower_bounds
35
+ self.upper_bounds = upper_bounds
36
+ self.dual_bound = dual_bound
37
+
38
+ def initial_primal_solution(self):
39
+ return jnp.zeros(self.num_variables)
40
+
41
+ def num_eq_constraints(self):
42
+ return len(self.constraints_eq(self.initial_primal_solution()))
43
+
44
+ def num_ineq_constraints(self):
45
+ return len(self.constraints_ineq(self.initial_primal_solution()))
46
+
47
+ def num_constraints(self):
48
+ return self.num_eq_constraints() + self.num_ineq_constraints()
49
+
50
+ def ineq_slack(self, x):
51
+ return jnp.max(jnp.maximum(self.constraints_ineq(x), 0.0))
52
+
53
+ def eq_slack(self, x):
54
+ return jnp.max(jnp.abs(self.constraints_eq(x)))
55
+
56
+ def complementarity_slack(self, x, dual_ineq):
57
+ return dual_ineq * (self.constraints_ineq(x))
58
+
59
+ def initial_solution(self):
60
+ return SaddleState(
61
+ primal=jnp.zeros(self.num_variables),
62
+ dual_ineq=jnp.zeros(self.num_ineq_constraints()),
63
+ dual_eq=jnp.zeros(self.num_eq_constraints()),
64
+ )
65
+
66
+
67
+ class LP:
68
+ def __init__(
69
+ self,
70
+ c,
71
+ A_eq,
72
+ b_eq,
73
+ A_ineq,
74
+ b_ineq,
75
+ lower_bounds,
76
+ upper_bounds,
77
+ ):
78
+ self.c = c
79
+ self.A_eq = A_eq
80
+ self.b_eq = b_eq
81
+ self.A_ineq = A_ineq
82
+ self.b_ineq = b_ineq
83
+ self.lower_bounds = lower_bounds
84
+ self.upper_bounds = upper_bounds
85
+
86
+ def objective(self, x):
87
+ return self.c @ x
88
+
89
+ def num_variables(self):
90
+ return len(self.c)
91
+
92
+ def num_eq_constraints(self):
93
+ return self.A_eq.shape[0]
94
+
95
+ def num_ineq_constraints(self):
96
+ return self.A_ineq.shape[0]
97
+
98
+ def num_constraints(self):
99
+ return self.A_eq.shape[0] + self.A_ineq.shape[0]
100
+
101
+ def ineq_slack(self, x):
102
+ return jnp.max(jnp.maximum(self.A_ineq @ x - self.b_ineq, 0.0), initial=0.0)
103
+
104
+ def eq_slack(self, x):
105
+ return jnp.max(jnp.abs(self.A_eq @ x - self.b_eq), initial=0.0)
106
+
107
+ def diff_eq_slack(self, x):
108
+ return self.A_eq @ x - self.b_eq
109
+
110
+ def complementarity_slack(self, x, dual_ineq):
111
+ return (dual_ineq * (self.A_ineq @ x - self.b_ineq)).sum()
112
+
113
+
114
+ class JaddleLP:
115
+ def __init__(
116
+ self,
117
+ c,
118
+ A_eq,
119
+ b_eq,
120
+ A_ineq,
121
+ b_ineq,
122
+ lower_bounds,
123
+ upper_bounds,
124
+ ):
125
+ self.c = c
126
+ self.A_eq = A_eq
127
+ self.b_eq = b_eq
128
+ self.A_ineq = A_ineq
129
+ self.b_ineq = b_ineq
130
+ self.lower_bounds = lower_bounds
131
+ self.upper_bounds = upper_bounds
132
+ # Fused [A_eq; A_ineq] for 2-matvec gradient computation.
133
+ #
134
+ # Both A and Aᵀ are kept as BCOO. BCSR was benchmarked here (Aᵀ
135
+ # materialised as its own row-major CSR, not a lazy `.T`) and was MUCH
136
+ # slower than BCOO on this workload — do not switch back to BCSR without
137
+ # re-benchmarking; BCOO's matvec wins for these matrices/precision/device.
138
+ #
139
+ # Aᵀ is stored as an explicit transposed BCOO (column-swapped indices)
140
+ # rather than the lazy `self.A.T`, so `Aᵀ @ y` runs its own matvec
141
+ # instead of the transposed-operand code path.
142
+ import jax.experimental.sparse as _jsp
143
+
144
+ self.n_eq = A_eq.shape[0]
145
+ A_bcoo = _jsp.bcoo_concatenate([A_eq, A_ineq], dimension=0)
146
+ A_T_bcoo = _jsp.BCOO(
147
+ (A_bcoo.data, A_bcoo.indices[:, ::-1]),
148
+ shape=A_bcoo.shape[::-1],
149
+ )
150
+
151
+ # Column-swapping A's indices destroys row-major sort order, leaving A_T
152
+ # with indices_sorted=False/unique_indices=False. JAX's BCOO matvec has a
153
+ # faster kernel for sorted+unique indices (and the unsorted path can't
154
+ # skip duplicate accumulation), so sort A_T once at build time. The LP
155
+ # has no duplicate (row, col) entries, so unique_indices is safe to set.
156
+ A_bcoo = A_bcoo.sort_indices()
157
+ A_bcoo.unique_indices = True
158
+
159
+ A_T_bcoo = A_T_bcoo.sort_indices()
160
+ A_T_bcoo.unique_indices = True
161
+
162
+ self.A = A_bcoo
163
+ self.A_T = A_T_bcoo
164
+ self.b = jnp.concatenate([b_eq, b_ineq])
165
+
166
+ @classmethod
167
+ def from_scipy(cls, c, A_eq, b_eq, A_ineq, b_ineq, lower_bounds, upper_bounds):
168
+ """Build a ``JaddleLP`` directly from scipy sparse blocks + numpy vectors.
169
+
170
+ Mirrors ``jaddle_linear.to_jaddle_sparse``: resolves the active precision
171
+ profile (float64 under x64, else float32; float16 lives only in JAX since
172
+ scipy cannot hold it), converts each constraint block to a sorted,
173
+ deduplicated BCOO, and casts the data array to the profile dtype. This is
174
+ the JAX-native entry point — callers (e.g. ``highs_to_standard_form_sparse``)
175
+ no longer route through a scipy ``LP``.
176
+ """
177
+ import jax.experimental.sparse as _jsp
178
+ from jaddle.jaddle_optimisers import jaddle_dtype
179
+
180
+ float_dtype = jaddle_dtype()
181
+ if float_dtype == jnp.float64 and not jax.config.jax_enable_x64:
182
+ float_dtype = jnp.float32
183
+ np_float = np.float64 if float_dtype == jnp.float64 else np.float32
184
+
185
+ def _to_bcoo(A):
186
+ A = A.astype(np_float).sorted_indices()
187
+ A.sum_duplicates()
188
+ bcoo = _jsp.BCOO.from_scipy_sparse(A.tocoo()).sort_indices()
189
+ return _jsp.BCOO(
190
+ (bcoo.data.astype(float_dtype), bcoo.indices), shape=bcoo.shape
191
+ )
192
+
193
+ return cls(
194
+ jnp.asarray(c, dtype=float_dtype),
195
+ _to_bcoo(A_eq),
196
+ jnp.asarray(b_eq, dtype=float_dtype),
197
+ _to_bcoo(A_ineq),
198
+ jnp.asarray(b_ineq, dtype=float_dtype),
199
+ jnp.asarray(lower_bounds, dtype=float_dtype),
200
+ jnp.asarray(upper_bounds, dtype=float_dtype),
201
+ )
202
+
203
+ def objective(self, x):
204
+ return self.c @ x
205
+
206
+ def num_variables(self):
207
+ return len(self.c)
208
+
209
+ def num_eq_constraints(self):
210
+ return self.A_eq.shape[0]
211
+
212
+ def num_ineq_constraints(self):
213
+ return self.A_ineq.shape[0]
214
+
215
+ def num_constraints(self):
216
+ return self.A_eq.shape[0] + self.A_ineq.shape[0]
217
+
218
+ def ineq_slack(self, x):
219
+ return jnp.max(jnp.maximum(self.A_ineq @ x - self.b_ineq, 0.0), initial=0.0)
220
+
221
+ def eq_slack(self, x):
222
+ return jnp.max(jnp.abs(self.A_eq @ x - self.b_eq), initial=0.0)
223
+
224
+ def diff_eq_slack(self, x):
225
+ return self.A_eq @ x - self.b_eq
226
+
227
+ def complementarity_slack(self, x, dual_ineq):
228
+ return (dual_ineq * (self.A_ineq @ x - self.b_ineq)).sum()
229
+
230
+ def initial_solution(self):
231
+ """Default start point: the box-projected zero primal with zero duals
232
+ (the PDLP default)."""
233
+ return SaddleState(
234
+ primal=optax.projections.projection_box(
235
+ jnp.zeros(self.num_variables()), self.lower_bounds, self.upper_bounds
236
+ ),
237
+ dual_ineq=jnp.zeros(self.num_ineq_constraints()),
238
+ dual_eq=jnp.zeros(self.num_eq_constraints()),
239
+ )
240
+
241
+ def to_scipy(self) -> "LP":
242
+ """Materialise a scipy-backed ``LP`` (CSC matrices, numpy vectors).
243
+
244
+ ``JaddleLP`` is the device-side representation the saddle solver iterates
245
+ on; the scaling, primal/dual polish, crossover and eq-projection LU stages
246
+ run host-side on scipy sparse (sparse direct solve, ``lsmr``, boolean
247
+ row-slicing — none of which JAX provides). Those stages call this to get
248
+ the scipy view they need; the solver never does.
249
+ """
250
+ import scipy.sparse as sp
251
+
252
+ def _to_csc(bcoo):
253
+ data = np.asarray(bcoo.data)
254
+ idx = np.asarray(bcoo.indices)
255
+ return sp.csc_matrix(
256
+ (data, (idx[:, 0], idx[:, 1])),
257
+ shape=bcoo.shape,
258
+ dtype=np.float64,
259
+ )
260
+
261
+ return LP(
262
+ np.asarray(self.c, dtype=np.float64),
263
+ _to_csc(self.A_eq),
264
+ np.asarray(self.b_eq, dtype=np.float64),
265
+ _to_csc(self.A_ineq),
266
+ np.asarray(self.b_ineq, dtype=np.float64),
267
+ np.asarray(self.lower_bounds, dtype=np.float64),
268
+ np.asarray(self.upper_bounds, dtype=np.float64),
269
+ )
270
+
271
+
272
+ # %%