eqc-models 0.15.0__py3-none-any.whl → 0.15.3__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.
Files changed (74) hide show
  1. eqc_models-0.15.3.data/platlib/eqc_models/algorithms/alm.py +782 -0
  2. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/polyeval.c +250 -227
  3. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/polyeval.cpython-310-darwin.so +0 -0
  4. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/rcshortestpath.py +3 -1
  5. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/shortestpath.py +4 -2
  6. eqc_models-0.15.3.data/platlib/eqc_models/ml/utils.py +132 -0
  7. {eqc_models-0.15.0.dist-info → eqc_models-0.15.3.dist-info}/METADATA +1 -1
  8. eqc_models-0.15.3.dist-info/RECORD +72 -0
  9. {eqc_models-0.15.0.dist-info → eqc_models-0.15.3.dist-info}/WHEEL +1 -1
  10. eqc_models-0.15.0.data/platlib/eqc_models/algorithms/alm.py +0 -464
  11. eqc_models-0.15.0.dist-info/RECORD +0 -71
  12. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/compile_extensions.py +0 -0
  13. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/__init__.py +0 -0
  14. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/algorithms/__init__.py +0 -0
  15. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/algorithms/base.py +0 -0
  16. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/algorithms/penaltymultiplier.py +0 -0
  17. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/allocation/__init__.py +0 -0
  18. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/allocation/allocation.py +0 -0
  19. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/allocation/portbase.py +0 -0
  20. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/allocation/portmomentum.py +0 -0
  21. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/assignment/__init__.py +0 -0
  22. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/assignment/qap.py +0 -0
  23. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/assignment/resource.py +0 -0
  24. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/assignment/setpartition.py +0 -0
  25. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/__init__.py +0 -0
  26. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/base.py +0 -0
  27. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/binaries.py +0 -0
  28. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/constraints.py +0 -0
  29. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/operators.py +0 -0
  30. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/polyeval.pyx +0 -0
  31. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/polynomial.py +0 -0
  32. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/quadratic.py +0 -0
  33. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/base/results.py +0 -0
  34. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/combinatorics/__init__.py +0 -0
  35. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/combinatorics/setcover.py +0 -0
  36. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/combinatorics/setpartition.py +0 -0
  37. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/decoding.py +0 -0
  38. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/__init__.py +0 -0
  39. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/base.py +0 -0
  40. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/hypergraph.py +0 -0
  41. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/maxcut.py +0 -0
  42. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/maxkcut.py +0 -0
  43. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/graph/partition.py +0 -0
  44. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/__init__.py +0 -0
  45. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/classifierbase.py +0 -0
  46. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/classifierqboost.py +0 -0
  47. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/classifierqsvm.py +0 -0
  48. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/clustering.py +0 -0
  49. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/clusteringbase.py +0 -0
  50. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/cvqboost_hamiltonian.pyx +0 -0
  51. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/cvqboost_hamiltonian_c_func.c +0 -0
  52. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/cvqboost_hamiltonian_c_func.h +0 -0
  53. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/decomposition.py +0 -0
  54. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/forecast.py +0 -0
  55. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/forecastbase.py +0 -0
  56. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/regressor.py +0 -0
  57. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/regressorbase.py +0 -0
  58. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/ml/reservoir.py +0 -0
  59. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/process/base.py +0 -0
  60. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/process/mpc.py +0 -0
  61. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/sequence/__init__.py +0 -0
  62. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/sequence/tsp.py +0 -0
  63. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/solvers/__init__.py +0 -0
  64. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/solvers/eqcdirect.py +0 -0
  65. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/solvers/mip.py +0 -0
  66. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/solvers/qciclient.py +0 -0
  67. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/solvers/responselog.py +0 -0
  68. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/utilities/__init__.py +0 -0
  69. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/utilities/fileio.py +0 -0
  70. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/utilities/general.py +0 -0
  71. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/utilities/polynomial.py +0 -0
  72. {eqc_models-0.15.0.data → eqc_models-0.15.3.data}/platlib/eqc_models/utilities/qplib.py +0 -0
  73. {eqc_models-0.15.0.dist-info → eqc_models-0.15.3.dist-info}/licenses/LICENSE.txt +0 -0
  74. {eqc_models-0.15.0.dist-info → eqc_models-0.15.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,782 @@
1
+ # (C) Quantum Computing Inc., 2025.
2
+ from dataclasses import dataclass, field
3
+ from typing import Callable, Dict, List, Tuple, Optional, Sequence, Union
4
+ import numpy as np
5
+ from collections import defaultdict
6
+ from eqc_models.base.polynomial import PolynomialModel
7
+
8
+ Array = np.ndarray
9
+ PolyTerm = Tuple[Tuple[int, ...], float]
10
+
11
+
12
+ @dataclass
13
+ class ALMConstraint:
14
+ """One constraint family; fun returns a vector; jac returns its Jacobian."""
15
+ kind: str # "eq" or "ineq"
16
+ fun: Callable[[Array], Array] # h(x) or g(x)
17
+ jac: Optional[Callable[[Array], Array]] = None
18
+ name: str = ""
19
+ family: str = ""
20
+
21
+
22
+ @dataclass
23
+ class ALMBlock:
24
+ """Lifted discrete variable block (optional)."""
25
+ idx: Sequence[int] # indices of block in the full x
26
+ levels: Array # (k,) level values (b_i)
27
+ enforce_sum_to_one: bool = True # register as equality via helper
28
+ enforce_one_hot: bool = True # ALM linearization with M = 11^T - I
29
+
30
+
31
+ @dataclass
32
+ class ALMConfig:
33
+ # penalties
34
+ rho_h: float = 50.0 # equalities
35
+ rho_g: float = 50.0 # inequalities / one-hot
36
+ rho_min: float = 1e-3
37
+ rho_max: float = 1e3
38
+ # per-family penalty overrides
39
+ rho_eq_family: Dict[str, float] = field(default_factory=dict)
40
+ rho_ineq_family: Dict[str, float] = field(default_factory=dict)
41
+ # allow per-family adaptation toggle (optional)
42
+ adapt_by_family: bool = True
43
+ # adaptation toggles
44
+ adapt: bool = True
45
+ tau_up_h: float = 0.90
46
+ tau_down_h: float = 0.50
47
+ tau_up_g: float = 0.90
48
+ tau_down_g: float = 0.50
49
+ gamma_up: float = 2.0
50
+ gamma_down: float = 1.0
51
+ # tolerances & loop
52
+ tol_h: float = 1e-6
53
+ tol_g: float = 1e-6
54
+ max_outer: int = 100
55
+ # stagnation safety net
56
+ use_stagnation_bump: bool = True
57
+ patience_h: int = 10
58
+ patience_g: int = 10
59
+ stagnation_factor: float = 1e-3
60
+ # smoothing (optional)
61
+ ema_alpha: float = 0.3
62
+ # finite diff (only used if jac=None)
63
+ fd_eps: float = 1e-6
64
+ # activation threshold for projected ALM
65
+ act_tol: float = 1e-10
66
+
67
+
68
+ class ConstraintRegistry:
69
+ """
70
+ Holds constraints and block metadata; keeps ALMAlgorithm stateless. Register constraints and
71
+ (optional) lifted-discrete blocks here.
72
+ """
73
+ def __init__(self):
74
+ self.constraints: List[ALMConstraint] = []
75
+ self.blocks: List[ALMBlock] = []
76
+
77
+ def add_equality(self, fun, jac=None, name="", family=""):
78
+ self.constraints.append(ALMConstraint("eq", fun, jac, name, family))
79
+
80
+ def add_inequality(self, fun, jac=None, name="", family=""):
81
+ self.constraints.append(ALMConstraint("ineq", fun, jac, name, family))
82
+
83
+ def add_block(self, idx: Sequence[int], levels: Array, sum_to_one=True, one_hot=True):
84
+ self.blocks.append(ALMBlock(list(idx), np.asarray(levels, float), sum_to_one, one_hot))
85
+
86
+
87
+ class ALMAlgorithm:
88
+ """Stateless ALM outer loop. Call `run(model, registry, core, cfg, **core_kwargs)`."""
89
+
90
+ # ---- helpers (static) ----
91
+ @staticmethod
92
+ def _finite_diff_jac(fun: Callable[[Array], Array], x: Array, eps: float) -> Array:
93
+ y0 = fun(x)
94
+ m = int(np.prod(y0.shape))
95
+ y0 = y0.reshape(-1)
96
+ n = x.size
97
+ J = np.zeros((m, n), dtype=float)
98
+ for j in range(n):
99
+ xp = x.copy()
100
+ xp[j] += eps
101
+ J[:, j] = (fun(xp).reshape(-1) - y0) / eps
102
+ return J
103
+
104
+ @staticmethod
105
+ def _pairwise_M(k: int) -> Array:
106
+ return np.ones((k, k), dtype=float) - np.eye(k, dtype=float)
107
+
108
+ @staticmethod
109
+ def _sum_to_one_selector(n: int, idx: Sequence[int]) -> Array:
110
+ S = np.zeros((1, n), dtype=float)
111
+ S[0, np.array(list(idx), int)] = 1.0
112
+ return S
113
+
114
+ @staticmethod
115
+ def _make_sum1_fun(S):
116
+ return lambda x: S @ x - np.array([1.0])
117
+
118
+ @staticmethod
119
+ def _make_sum1_jac(S):
120
+ return lambda x: S
121
+
122
+ @staticmethod
123
+ def _make_onehot_fun(sl, M):
124
+ sl = np.array(sl, int)
125
+
126
+ def _f(x):
127
+ s = x[sl]
128
+ return np.array([float(s @ (M @ s))]) # shape (1,)
129
+
130
+ return _f
131
+
132
+ @staticmethod
133
+ def _make_onehot_jac(sl, M, n):
134
+ sl = np.array(sl, int)
135
+
136
+ def _J(x):
137
+ s = x[sl]
138
+ grad_blk = 2.0 * (M @ s) # (k,)
139
+ J = np.zeros((1, n), dtype=float) # shape (1, n)
140
+ J[0, sl] = grad_blk
141
+ return J
142
+
143
+ return _J
144
+
145
+ @staticmethod
146
+ def _poly_value(poly_terms: List[PolyTerm], x: Array) -> float:
147
+ val = 0.0
148
+ for inds, coeff in poly_terms:
149
+ prod = 1.0
150
+ for j in inds:
151
+ if j == 0:
152
+ continue
153
+ else:
154
+ prod *= x[j - 1]
155
+ val += coeff * prod
156
+ return float(val)
157
+
158
+ @staticmethod
159
+ def _merge_poly(poly_terms: Optional[List[PolyTerm]], Q_aug: Optional[Array],
160
+ c_aug: Optional[Array]) -> List[PolyTerm]:
161
+ """
162
+ Merge ALM's quadratic/linear increments (Q_aug, c_aug) into the base polynomial term list `poly_terms`.
163
+ If 'poly_terms' is None, then turn x^T Q_aug x + c_aug^T x into polynomial monomials.
164
+ Terms are of the form:
165
+ ((0, i), w) for linear, ((i, j), w) for quadratic.
166
+ """
167
+ merged = list(poly_terms) if poly_terms is not None else []
168
+
169
+ if Q_aug is not None:
170
+ Qs = 0.5 * (Q_aug + Q_aug.T)
171
+ n = Qs.shape[0]
172
+ for i in range(n):
173
+ # diagonal contributes Qii * x_i^2
174
+ if Qs[i, i] != 0.0:
175
+ merged.append(((i + 1, i + 1), float(Qs[i, i])))
176
+ for j in range(i + 1, n):
177
+ q = 2.0 * Qs[i, j] # x^T Q x -> sum_{i<j} 2*Q_ij x_i x_j
178
+ if q != 0.0:
179
+ merged.append(((i + 1, j + 1), float(q)))
180
+ if c_aug is not None:
181
+ for i, ci in enumerate(c_aug):
182
+ if ci != 0.0:
183
+ merged.append(((0, i + 1), float(ci)))
184
+ return merged
185
+
186
+ @staticmethod
187
+ def _block_offsets(blocks: List[ALMBlock]) -> List[int]:
188
+ """
189
+ Return starting offsets (0-based) for each lifted block in the
190
+ concatenated s-vector.
191
+ """
192
+ offs, pos = [], 0
193
+ for blk in blocks:
194
+ offs.append(pos)
195
+ pos += len(blk.levels) # each block contributes k lifted coordinates
196
+ return offs
197
+
198
+ @staticmethod
199
+ def lift_Qc_to_poly_terms(
200
+ Q_native: np.ndarray, # shape (m, m) over original discrete vars (one block per var)
201
+ c_native: np.ndarray, # shape (m,)
202
+ blocks: List[ALMBlock], # ALM blocks (in the same order as the rows/cols of Q_native, c_native)
203
+ ) -> Tuple[List[Tuple[int, ...]], List[float]]:
204
+ """
205
+ Expand a quadratic/linear objective over m native discrete variables into the lifted
206
+ (one-hot) s-space.
207
+
208
+ Given `Q_native` and `c_native` over original m discrete vars (one block per var), and
209
+ `blocks` (`list[ALMBlock]` in the same order as original vars), we enforce:
210
+ - For variable i with level values b_i (length k_i), we create k_i lifted coords s_{i,a}.
211
+ - Quadratic term: J_ij = Q_native[i,j] * (b_i b_j^T) contributes to pairs (s_{i,a}, s_{j,b})
212
+ - Linear term: C_i = c_native[i] * b_i contributes to s_{i,a}
213
+
214
+ Returns polynomial (indices, coeffs) over concatenated s variables.
215
+ """
216
+ m = len(blocks)
217
+ assert Q_native.shape == (m, m), "Q_native must match number of blocks"
218
+ assert c_native.shape == (m,), "c_native must match number of blocks"
219
+
220
+ offs = ALMAlgorithm._block_offsets(blocks)
221
+ terms_acc = defaultdict(float)
222
+
223
+ # Quadratic lift: J = kron-expansion
224
+ for i in range(m):
225
+ bi = blocks[i].levels[:, None] # (k_i, 1)
226
+ oi = offs[i]
227
+ for j in range(m):
228
+ bj = blocks[j].levels[:, None] # (k_j, 1)
229
+ oj = offs[j]
230
+ J_ij = Q_native[i, j] * (bi @ bj.T) # (k_i, k_j)
231
+ if np.allclose(J_ij, 0.0):
232
+ continue
233
+ ki, kj = bi.shape[0], bj.shape[0]
234
+ for a in range(ki):
235
+ ia = oi + a + 1
236
+ for b in range(kj):
237
+ jb = oj + b + 1
238
+ w = float(J_ij[a, b])
239
+ if w == 0.0:
240
+ continue
241
+ if ia == jb:
242
+ terms_acc[(ia, ia)] += w
243
+ else:
244
+ # NOTE: we store each cross monomial once (i < j); for i==j block pairs,
245
+ # double to represent J_ab + J_ba
246
+ if i == j: # intra-block off-diagonal needs 2x
247
+ w *= 2.0
248
+ i1, i2 = (ia, jb) if ia < jb else (jb, ia)
249
+ terms_acc[(i1, i2)] += w
250
+
251
+ # Linear lift: C_i = L_i * b_i
252
+ for i in range(m):
253
+ b = blocks[i].levels
254
+ oi = offs[i]
255
+ for a, val in enumerate(b):
256
+ ia = oi + a + 1
257
+ w = float(c_native[i] * val)
258
+ if w != 0.0:
259
+ terms_acc[(0, ia)] += w
260
+
261
+ # Pack to PolynomialModel format
262
+ indices = [tuple(k) for k in terms_acc.keys()]
263
+ coeffs = [float(v) for v in terms_acc.values()]
264
+ return indices, coeffs
265
+
266
+ # ---- main entrypoint ----
267
+ @staticmethod
268
+ def run(
269
+ base_model: PolynomialModel,
270
+ registry: ConstraintRegistry,
271
+ solver,
272
+ cfg: ALMConfig = ALMConfig(),
273
+ x0: Optional[Array] = None,
274
+ *,
275
+ parse_output=None,
276
+ verbose: bool = True,
277
+ **solver_kwargs,
278
+ ) -> Dict[str, Union[Array, Dict[int, float], Dict]]:
279
+ """
280
+ Solve with ALM. Keep all ALM state local to this call (no global side-effects).
281
+ Handles three modes:
282
+ (A) No blocks -> continuous; use base_model as-is
283
+ (B) Blocks + native base_model -> lift to s-space
284
+ (C) Blocks + already-lifted base_model -> use as-is (compat)
285
+
286
+ Returns:
287
+ {
288
+ "x": final iterate,
289
+ "decoded": {start_idx_of_block: level_value, ...} for lifted blocks,
290
+ "decoded_debug": {start_idx_of_block: native_device_value, ...},
291
+ "hist": { "eq_inf": [...], "ineq_inf": [...], "obj": [...], "x": [...] }
292
+ }
293
+ """
294
+ blocks = registry.blocks
295
+ has_blocks = len(blocks) > 0
296
+
297
+ # ---- choose working model + dimension n ----
298
+ if not has_blocks:
299
+ # (A) continuous case: use the provided model directly
300
+ model = base_model
301
+ # Prefer model.n, fall back to bounds; else infer from polynomial indices
302
+ n = getattr(model, "n", None)
303
+ if n is None:
304
+ ub = getattr(model, "upper_bound", None)
305
+ lb = getattr(model, "lower_bound", None)
306
+ if ub is not None:
307
+ n = len(ub)
308
+ elif lb is not None:
309
+ n = len(lb)
310
+ else:
311
+ # infer from polynomial terms
312
+ n = 0
313
+ for inds in getattr(model, "indices", getattr(model.polynomial, "indices", [])):
314
+ for j in inds:
315
+ if j > 0:
316
+ n = max(n, j)
317
+ lifted_slices: List[List[int]] = []
318
+
319
+ else:
320
+ # (B/C) lifted (discrete) case
321
+ target_lifted_n = sum(len(blk.levels) for blk in blocks)
322
+ base_n = getattr(base_model, "n", None)
323
+
324
+ def _infer_n_from_terms(pm: PolynomialModel) -> int:
325
+ inds_list = getattr(pm, "indices", getattr(pm.polynomial, "indices", []))
326
+ mx = 0
327
+ for inds in inds_list:
328
+ for j in inds:
329
+ if j > mx:
330
+ mx = j
331
+ return int(mx)
332
+
333
+ if base_n is None:
334
+ base_n = _infer_n_from_terms(base_model)
335
+
336
+ # detect "already-lifted" native input (compat path)
337
+ already_lifted = (base_n == target_lifted_n)
338
+
339
+ if already_lifted:
340
+ # (C) use provided model directly; assume bounds already sensible
341
+ model = base_model
342
+ n = target_lifted_n
343
+ else:
344
+ # (B) lift from native space
345
+ # base_model must expose coefficients/indices compatible with this call
346
+ c_base, Q_base = base_model._quadratic_polynomial_to_qubo_coefficients(
347
+ getattr(base_model, "coefficients", getattr(base_model.polynomial, "coefficients", [])),
348
+ getattr(base_model, "indices", getattr(base_model.polynomial, "indices", [])),
349
+ getattr(base_model, "n")
350
+ )
351
+ assert Q_base.shape[0] == Q_base.shape[1]
352
+ assert c_base.shape[0] == Q_base.shape[0]
353
+ indices_lifted, coeffs_lifted = ALMAlgorithm.lift_Qc_to_poly_terms(Q_base, c_base, blocks)
354
+ model = PolynomialModel(coeffs_lifted, indices_lifted)
355
+ n = target_lifted_n
356
+ # set canonical [0,1] bounds for lifted s
357
+ setattr(model, "lower_bound", np.zeros(n, float))
358
+ setattr(model, "upper_bound", np.ones(n, float))
359
+
360
+ # ---- n and lifted_slices ----
361
+ lifted_slices = []
362
+ pos = 0
363
+ for blk in blocks:
364
+ k = len(blk.levels) # number of lifted coords for this block
365
+ lifted_slices.append(list(range(pos, pos + k))) # 0-based in lifted x
366
+ pos += k
367
+
368
+ # Algorithm initial solution and bounds
369
+ lb = getattr(model, "lower_bound", None)
370
+ ub = getattr(model, "upper_bound", None)
371
+ if x0 is not None:
372
+ x = np.asarray(x0, float).copy()
373
+ else:
374
+ # default init
375
+ if (lb is not None) and (ub is not None) and np.all(np.isfinite(lb)) and np.all(np.isfinite(ub)):
376
+ x = 0.5 * (np.asarray(lb, float) + np.asarray(ub, float))
377
+ else:
378
+ x = np.zeros(n, float)
379
+
380
+ # ---- collect constraints ----
381
+ problem_eqs = [c for c in registry.constraints if c.kind == "eq"]
382
+ problem_ineqs = [c for c in registry.constraints if c.kind == "ineq"]
383
+
384
+ # auto-install sum-to-one and one-hot as equalities
385
+ # (One-hot: s^T (11^T - I) s = 0))
386
+ def _install_block_equalities() -> List[ALMConstraint]:
387
+ if not has_blocks:
388
+ return []
389
+ eqs: List[ALMConstraint] = []
390
+ for blk, lift_idx in zip(registry.blocks, lifted_slices):
391
+ if blk.enforce_sum_to_one:
392
+ S = ALMAlgorithm._sum_to_one_selector(n, lift_idx)
393
+ eqs.append(ALMConstraint(
394
+ "eq",
395
+ fun=ALMAlgorithm._make_sum1_fun(S),
396
+ jac=ALMAlgorithm._make_sum1_jac(S),
397
+ name=f"sum_to_one_block_{lift_idx[0]}",
398
+ family="block_sum1",
399
+ ))
400
+ if blk.enforce_one_hot:
401
+ k = len(lift_idx)
402
+ M = ALMAlgorithm._pairwise_M(k)
403
+ eqs.append(ALMConstraint(
404
+ "eq",
405
+ fun=ALMAlgorithm._make_onehot_fun(lift_idx, M),
406
+ jac=ALMAlgorithm._make_onehot_jac(lift_idx, M, n),
407
+ name=f"onehot_block_{lift_idx[0]}",
408
+ family="block_onehot",
409
+ ))
410
+ return eqs
411
+
412
+ block_eqs = _install_block_equalities()
413
+
414
+ # Unified equality list (order is fixed for whole run)
415
+ full_eqs = problem_eqs + block_eqs
416
+
417
+ # Allocate multipliers for every equality in full_eqs
418
+ lam_eq = []
419
+ for csp in full_eqs:
420
+ r0 = csp.fun(x).reshape(-1)
421
+ lam_eq.append(np.zeros_like(r0, dtype=float))
422
+
423
+ # Inequality multipliers per user inequality
424
+ mu_ineq = []
425
+ for csp in problem_ineqs:
426
+ r0 = csp.fun(x).reshape(-1)
427
+ mu_ineq.append(np.zeros_like(r0, dtype=float))
428
+
429
+ rho_eq_family = dict(getattr(cfg, "rho_eq_family", {}) or {})
430
+ rho_ineq_family = dict(getattr(cfg, "rho_ineq_family", {}) or {})
431
+
432
+ def _rho_for(csp_k: ALMConstraint) -> float:
433
+ fam = getattr(csp_k, "family", "") or ""
434
+ if csp_k.kind == "eq":
435
+ return float(rho_eq_family.get(fam, rho_h))
436
+ else:
437
+ return float(rho_ineq_family.get(fam, rho_g))
438
+
439
+ # -------- running stats for adaptive penalties --------
440
+ rho_h, rho_g = cfg.rho_h, cfg.rho_g
441
+ best_eq, best_ineq = np.inf, np.inf
442
+ no_imp_eq = no_imp_ineq = 0
443
+ prev_eq_inf, prev_ineq_inf = np.inf, np.inf
444
+ eps = 1e-12
445
+ prev_eq_inf_by_family, prev_ineq_inf_by_family = {}, {}
446
+ # ---- per-family stagnation tracking (only used when cfg.adapt_by_family=True)
447
+ best_eq_by_family: Dict[str, float] = {}
448
+ best_ineq_by_family: Dict[str, float] = {}
449
+ no_imp_eq_by_family: Dict[str, int] = {}
450
+ no_imp_ineq_by_family: Dict[str, int] = {}
451
+
452
+ hist = {"eq_inf": [], "ineq_inf": [], "obj": [], "x": [],
453
+ # per-iteration logs for parameters/multipliers
454
+ "rho_h": [], "rho_g": [],
455
+ "rho_eq_family": [], "rho_ineq_family": [],
456
+ "eq_inf_by_family": [], "ineq_inf_by_family": [],
457
+ }
458
+ for k_idx, csp in enumerate(full_eqs):
459
+ if csp.kind != "eq":
460
+ continue
461
+ hist[f"lam_eq_max_idx{k_idx}"] = []
462
+ hist[f"lam_eq_min_idx{k_idx}"] = []
463
+ for k_idx, csp in enumerate(problem_ineqs):
464
+ if csp.kind != "ineq":
465
+ continue
466
+ hist[f"mu_ineq_max_idx{k_idx}"] = []
467
+ hist[f"mu_ineq_min_idx{k_idx}"] = []
468
+
469
+ for it in range(cfg.max_outer):
470
+ # -------- base polynomial (does not include fixed penalties here) --------
471
+ base_terms: List[PolyTerm] = list(zip(model.polynomial.indices, model.polynomial.coefficients))
472
+
473
+ # -------- ALM quadratic/linear pieces (assembled here, kept separate) --------
474
+ Q_aug = np.zeros((n, n), dtype=float)
475
+ c_aug = np.zeros(n, dtype=float)
476
+ have_aug = False
477
+
478
+ # (A) Equalities: linearize h near x^t => (rho/2)||A x - b||^2 + lam^T(Ax - b)
479
+ for k_idx, csp in enumerate(full_eqs):
480
+ if csp.kind != "eq":
481
+ continue
482
+ h = csp.fun(x).reshape(-1)
483
+ A = csp.jac(x) if csp.jac is not None else ALMAlgorithm._finite_diff_jac(csp.fun, x, cfg.fd_eps)
484
+ A = np.atleast_2d(A)
485
+ assert A.shape[1] == n, f"A has {A.shape[1]} cols, expected {n}"
486
+ # linearization about current x: residual model r(x) = A x - b, with b = A x - h
487
+ b = A @ x - h
488
+ rho_k = _rho_for(csp)
489
+ Qk = 0.5 * rho_k * (A.T @ A)
490
+ ck = (A.T @ lam_eq[k_idx]) - rho_k * (A.T @ b)
491
+ Q_aug += Qk
492
+ c_aug += ck
493
+ have_aug = True
494
+
495
+ # (B) Inequalities: projected ALM. Linearize g near x^t.
496
+ for k_idx, csp in enumerate(problem_ineqs):
497
+ if csp.kind != "ineq":
498
+ continue
499
+ g = csp.fun(x).reshape(-1)
500
+ G = csp.jac(x) if csp.jac is not None else ALMAlgorithm._finite_diff_jac(csp.fun, x, cfg.fd_eps)
501
+ G = np.atleast_2d(G)
502
+ assert G.shape[1] == n, f"G has {G.shape[1]} cols, expected {n}"
503
+ d = G @ x - g
504
+ rho_k = _rho_for(csp)
505
+ # Activation measure at current iterate; meaning, the current violating inequality components:
506
+ # g(x) + mu/rho; Powell-Hestenes-Rockafellar shifted residual
507
+ y = G @ x - d + mu_ineq[k_idx] / rho_k
508
+ active = (y > cfg.act_tol)
509
+ if np.any(active):
510
+ GA = G[active, :]
511
+ muA = mu_ineq[k_idx][active]
512
+ gA = g[active]
513
+ # Q += (rho/2) * GA^T GA
514
+ Qk = 0.5 * rho_k * (GA.T @ GA)
515
+ # c += GA^T mu - rho * GA^T (GA x - gA); where GA x - gA is active measures of d = G @ x - g
516
+ ck = (GA.T @ muA) - rho_k * (GA.T @ (GA @ x - gA))
517
+ Q_aug += Qk
518
+ c_aug += ck
519
+ have_aug = True
520
+
521
+ # -------- build merged polynomial for the core solver --------
522
+ all_terms = ALMAlgorithm._merge_poly(base_terms, Q_aug if have_aug else None,
523
+ c_aug if have_aug else None)
524
+ idxs, coeffs = zip(*[(inds, w) for (inds, w) in all_terms]) if all_terms else ([], [])
525
+ poly_model = PolynomialModel(list(coeffs), list(idxs))
526
+ if lb is not None and hasattr(poly_model, "lower_bound"):
527
+ poly_model.lower_bound = np.asarray(lb, float)
528
+ if ub is not None and hasattr(poly_model, "upper_bound"):
529
+ poly_model.upper_bound = np.asarray(ub, float)
530
+
531
+ x_ws = x.copy()
532
+
533
+ # Convention: many cores look for one of these fields if present.
534
+ # Use one or more to be future-proof; harmless if ignored.
535
+ setattr(poly_model, "initial_guess", x_ws)
536
+ setattr(poly_model, "warm_start", x_ws)
537
+ setattr(poly_model, "x0", x_ws)
538
+
539
+ # -------- inner solve --------
540
+ out = solver.solve(poly_model, **solver_kwargs)
541
+
542
+ # -------- parse --------
543
+ if parse_output:
544
+ x = parse_output(out)
545
+ else:
546
+ # default: support (value, x) or `.x` or raw x
547
+ if isinstance(out, tuple) and len(out) == 2:
548
+ _, x = out
549
+ elif isinstance(out, dict) and "results" in out and "solutions" in out["results"]:
550
+ x = out["results"]["solutions"][0]
551
+ elif isinstance(out, dict) and "x" in out:
552
+ x = out["x"]
553
+ else:
554
+ x = getattr(out, "x", out)
555
+ x = np.asarray(x, float)
556
+
557
+ # ---- multiplier tracking ----
558
+ for k_idx, csp in enumerate(full_eqs):
559
+ if csp.kind != "eq": continue
560
+ hist[f"lam_eq_max_idx{k_idx}"].append(float(np.max(lam_eq[k_idx])))
561
+ hist[f"lam_eq_min_idx{k_idx}"].append(float(np.min(lam_eq[k_idx])))
562
+ for k_idx, csp in enumerate(problem_ineqs):
563
+ if csp.kind != "ineq": continue
564
+ hist[f"mu_ineq_max_idx{k_idx}"].append(float(np.max(mu_ineq[k_idx])))
565
+ hist[f"mu_ineq_min_idx{k_idx}"].append(float(np.min(mu_ineq[k_idx])))
566
+
567
+ # -------- residuals + multiplier updates --------
568
+ eq_infs = []
569
+ for k_idx, csp in enumerate(full_eqs):
570
+ if csp.kind != "eq": continue
571
+ r = csp.fun(x).reshape(-1)
572
+ rho_k = _rho_for(csp)
573
+ lam_eq[k_idx] = lam_eq[k_idx] + rho_k * r
574
+ if r.size:
575
+ eq_infs.append(np.max(np.abs(r)))
576
+ eq_inf = float(np.max(eq_infs)) if eq_infs else 0.0
577
+
578
+ ineq_infs = []
579
+ for k_idx, csp in enumerate(problem_ineqs):
580
+ if csp.kind != "ineq": continue
581
+ r = csp.fun(x).reshape(-1)
582
+ rho_k = _rho_for(csp)
583
+ mu_ineq[k_idx] = np.maximum(0.0, mu_ineq[k_idx] + rho_k * r)
584
+ if r.size:
585
+ ineq_infs.append(np.max(np.maximum(0.0, r)))
586
+ ineq_inf = float(np.max(ineq_infs)) if ineq_infs else 0.0
587
+
588
+ assert len(lam_eq) == len(full_eqs)
589
+ assert len(mu_ineq) == len(problem_ineqs)
590
+
591
+ # ---- per-family residual telemetry ----
592
+ eq_inf_by_family = {}
593
+ for k_idx, csp in enumerate(full_eqs):
594
+ if csp.kind != "eq": continue
595
+ fam = getattr(csp, "family", "") or ""
596
+ r = csp.fun(x).reshape(-1)
597
+ v = float(np.max(np.abs(r))) if r.size else 0.0
598
+ eq_inf_by_family[fam] = max(eq_inf_by_family.get(fam, 0.0), v)
599
+
600
+ ineq_inf_by_family = {}
601
+ for k_idx, csp in enumerate(problem_ineqs):
602
+ if csp.kind != "ineq": continue
603
+ fam = getattr(csp, "family", "") or ""
604
+ r = csp.fun(x).reshape(-1)
605
+ v = float(np.max(np.maximum(0.0, r))) if r.size else 0.0
606
+ ineq_inf_by_family[fam] = max(ineq_inf_by_family.get(fam, 0.0), v)
607
+
608
+ # evaluate base polynomial only (ca add aug value if want to track full L_A)
609
+ f_val = ALMAlgorithm._poly_value(base_terms, x)
610
+
611
+ hist["eq_inf"].append(eq_inf); hist["ineq_inf"].append(ineq_inf)
612
+ hist["obj"].append(float(f_val)); hist["x"].append(x.copy())
613
+ # parameter tracking
614
+ hist["rho_h"].append(float(rho_h)); hist["rho_g"].append(float(rho_g))
615
+ hist["rho_eq_family"].append(dict(rho_eq_family))
616
+ hist["rho_ineq_family"].append(dict(rho_ineq_family))
617
+ hist["eq_inf_by_family"].append(dict(eq_inf_by_family))
618
+ hist["ineq_inf_by_family"].append(dict(ineq_inf_by_family))
619
+
620
+ if verbose:
621
+ # show worst 3 equality families by residual, with their rhos
622
+ eq_items = sorted(eq_inf_by_family.items(), key=lambda kv: kv[1], reverse=True)
623
+ eq_top = eq_items[:3]
624
+ eq_str = ",".join([
625
+ f"{fam or 'eq'}:{val:.2e}@"
626
+ f"{_rho_for(next(c for c in full_eqs if (getattr(c,'family','') or '')==fam and c.kind=='eq')):.1g}"
627
+ for fam, val in eq_top
628
+ ])
629
+ # show worst 3 inequality families by residual, with their rhos
630
+ ineq_items = sorted(ineq_inf_by_family.items(), key=lambda kv: kv[1], reverse=True)
631
+ ineq_top = ineq_items[:3]
632
+ ineq_str = ",".join([
633
+ f"{fam or 'ineq'}:{val:.2e}@"
634
+ f"{_rho_for(next(c for c in problem_ineqs if (getattr(c, 'family', '') or '') == fam and c.kind == 'ineq')):.1g}"
635
+ for fam, val in ineq_top
636
+ ])
637
+
638
+ print(f"[ALM {it:02d}] f={f_val:.6g} | eq_inf={eq_inf:.2e} | ineq_inf={ineq_inf:.2e} "
639
+ f"| rho_h={rho_h:.2e} | rho_g={rho_g:.2e} | eq_fam[{eq_str}] | ineq_fam[{ineq_str}]")
640
+
641
+ # stopping
642
+ if eq_inf <= cfg.tol_h and ineq_inf <= cfg.tol_g:
643
+ if verbose:
644
+ print(f"[ALM] converged at iter {it}")
645
+ break
646
+
647
+ # EMA smoothing to reduce jitter
648
+ if it == 0:
649
+ eq_inf_smooth = eq_inf
650
+ ineq_inf_smooth = ineq_inf
651
+ else:
652
+ eq_inf_smooth = cfg.ema_alpha * eq_inf + (1 - cfg.ema_alpha) * eq_inf_smooth
653
+ ineq_inf_smooth = cfg.ema_alpha * ineq_inf + (1 - cfg.ema_alpha) * ineq_inf_smooth
654
+
655
+ # -------- Residual-ratio controller --------
656
+ if cfg.adapt and it > 0:
657
+ # per-family rho adaptation for equalities
658
+ if getattr(cfg, "adapt_by_family", False):
659
+ # Equality family
660
+ for fam, cur in eq_inf_by_family.items():
661
+ prev = max(prev_eq_inf_by_family.get(fam, cur), eps)
662
+ # rho value for family: if absent, start at rho_h
663
+ rho_f = float(rho_eq_family.get(fam, rho_h))
664
+ if cur > cfg.tau_up_h * prev:
665
+ rho_f = min(cfg.gamma_up * rho_f, cfg.rho_max)
666
+ elif cur < cfg.tau_down_h * prev:
667
+ rho_f = max(cfg.gamma_down * rho_f, cfg.rho_min)
668
+ rho_eq_family[fam] = rho_f
669
+ # update prev map
670
+ prev_eq_inf_by_family = dict(eq_inf_by_family)
671
+
672
+ # Inequality family
673
+ for fam, cur in ineq_inf_by_family.items():
674
+ prev = max(prev_ineq_inf_by_family.get(fam, cur), eps)
675
+ # rho value for family: if absent, start at rho_g
676
+ rho_f = float(rho_ineq_family.get(fam, rho_g))
677
+ if cur > cfg.tau_up_g * prev:
678
+ rho_f = min(cfg.gamma_up * rho_f, cfg.rho_max)
679
+ elif cur < cfg.tau_down_g * prev:
680
+ rho_f = max(cfg.gamma_down * rho_f, cfg.rho_min)
681
+ rho_ineq_family[fam] = rho_f
682
+ # update prev map
683
+ prev_ineq_inf_by_family = dict(ineq_inf_by_family)
684
+ else:
685
+ # Equality group
686
+ if eq_inf_smooth > cfg.tau_up_h * max(prev_eq_inf, eps): # stalled or not shrinking
687
+ rho_h = min(cfg.gamma_up * rho_h, cfg.rho_max)
688
+ elif eq_inf_smooth < cfg.tau_down_h * max(prev_eq_inf, eps): # fast progress, allow relaxation
689
+ rho_h = max(cfg.gamma_down * rho_h, cfg.rho_min)
690
+
691
+ # Inequality group
692
+ if ineq_inf_smooth > cfg.tau_up_g * max(prev_ineq_inf, eps):
693
+ rho_g = min(cfg.gamma_up * rho_g, cfg.rho_max)
694
+ elif ineq_inf_smooth < cfg.tau_down_g * max(prev_ineq_inf, eps):
695
+ rho_g = max(cfg.gamma_down * rho_g, cfg.rho_min)
696
+ else:
697
+ # per-family rho adaptation for equalities
698
+ if getattr(cfg, "adapt_by_family", False):
699
+ # update prev map
700
+ prev_eq_inf_by_family = dict(eq_inf_by_family)
701
+ prev_ineq_inf_by_family = dict(ineq_inf_by_family)
702
+
703
+ # -------- Stagnation bump (safety net) --------
704
+ if cfg.use_stagnation_bump:
705
+ if getattr(cfg, "adapt_by_family", False):
706
+ # ---- Equality families ----
707
+ for fam, cur in eq_inf_by_family.items():
708
+ # initialize
709
+ if fam not in best_eq_by_family:
710
+ best_eq_by_family[fam] = float(cur)
711
+ no_imp_eq_by_family[fam] = 0
712
+
713
+ if cur <= best_eq_by_family[fam] * (1 - cfg.stagnation_factor):
714
+ best_eq_by_family[fam] = float(cur)
715
+ no_imp_eq_by_family[fam] = 0
716
+ else:
717
+ no_imp_eq_by_family[fam] += 1
718
+ if no_imp_eq_by_family[fam] >= cfg.patience_h:
719
+ rho_f = float(rho_eq_family.get(fam, rho_h))
720
+ rho_eq_family[fam] = min(2.0 * rho_f, cfg.rho_max)
721
+ no_imp_eq_by_family[fam] = 0
722
+
723
+ # ---- Inequality families ----
724
+ for fam, cur in ineq_inf_by_family.items():
725
+ # initialize
726
+ if fam not in best_ineq_by_family:
727
+ best_ineq_by_family[fam] = float(cur)
728
+ no_imp_ineq_by_family[fam] = 0
729
+
730
+ if cur <= best_ineq_by_family[fam] * (1 - cfg.stagnation_factor):
731
+ best_ineq_by_family[fam] = float(cur)
732
+ no_imp_ineq_by_family[fam] = 0
733
+ else:
734
+ no_imp_ineq_by_family[fam] += 1
735
+ if no_imp_ineq_by_family[fam] >= cfg.patience_g:
736
+ rho_f = float(rho_ineq_family.get(fam, rho_g))
737
+ rho_ineq_family[fam] = min(2.0 * rho_f, cfg.rho_max)
738
+ no_imp_ineq_by_family[fam] = 0
739
+
740
+ else:
741
+ # Equality stagnation
742
+ if eq_inf <= best_eq * (1 - cfg.stagnation_factor):
743
+ best_eq = eq_inf; no_imp_eq = 0
744
+ else:
745
+ no_imp_eq += 1
746
+ if no_imp_eq >= cfg.patience_h:
747
+ rho_h = min(2.0 * rho_h, cfg.rho_max); no_imp_eq = 0
748
+
749
+ # Inequality stagnation
750
+ if ineq_inf <= best_ineq * (1 - cfg.stagnation_factor):
751
+ best_ineq = ineq_inf; no_imp_ineq = 0
752
+ else:
753
+ no_imp_ineq += 1
754
+ if no_imp_ineq >= cfg.patience_g:
755
+ rho_g = min(2.0 * rho_g, cfg.rho_max); no_imp_ineq = 0
756
+
757
+ # -------- finalize for next iteration --------
758
+ prev_eq_inf = max(eq_inf_smooth, eps)
759
+ prev_ineq_inf = max(ineq_inf_smooth, eps)
760
+
761
+ # ---- decoding back to native levels (only if blocks) ----
762
+ decoded_native: Dict[int, float] = {} # maps original var anchor -> chosen level value
763
+ decoded_lifted: Dict[int, int] = {} # maps lifted start index -> argmax position (optional)
764
+ if has_blocks:
765
+ for blk, lift_idx in zip(registry.blocks, lifted_slices):
766
+ if not lift_idx:
767
+ continue
768
+ sl = np.array(lift_idx, int)
769
+ if len(sl) == 0:
770
+ continue
771
+ sblk = x[sl]
772
+ j = int(np.argmax(sblk)) # which level got selected in the block
773
+ orig_anchor = int(blk.idx[0]) # anchor original var id for this block
774
+ decoded_native[orig_anchor] = float(blk.levels[j])
775
+ decoded_lifted[sl[0]] = j # optional: lifted index -> chosen slot
776
+
777
+ return {
778
+ "x": x,
779
+ "decoded": decoded_native if has_blocks else {},
780
+ "decoded_debug": decoded_lifted if has_blocks else {},
781
+ "hist": hist,
782
+ }