LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
mapqn_qrf_common.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_MAPQN_MAPQN_QRF_COMMON_H
6#define LINE_API_MAPQN_MAPQN_QRF_COMMON_H
7
8/**
9 * @file
10 * @ingroup api_mapqn
11 * Shared machinery of the QRF nonlinear bounds (`qrf_noblo_*`, `qrf_bas_*`).
12 *
13 * Port of python/line_solver/api/mapqn/qrf_noblo_common.py (the JAR twin is
14 * `Mapqn_qrf_noblo_*` plus `Mapqn_nlp_solver`). `api/mapqn` has NO MATLAB
15 * implementation, so Python and the JAR are the references here.
16 *
17 * WHAT THE FAMILY IS. The linear QRF bounds (`mapqn_qr_bounds_*`) MAXIMIZE one
18 * station's utilization over a polytope of pairwise queue-phase probabilities.
19 * These entry points optimize a different objective over the SAME feasible set:
20 * mutual information (MMI) or negative entropy (MEM).
21 *
22 * NEITHER OBJECTIVE IS A CONVEX PROGRAM AS THE REFERENCE STATES IT, and a
23 * caller has to know which one it asked for:
24 *
25 * - MEM minimizes -sum p log p over the diagonal entries. Despite the name,
26 * that MINIMIZES the entropy, and -p log p is CONCAVE, so the minimum sits at
27 * a vertex and every method reports a stationary point fixed by its start.
28 * The three codebases agree because they start from the same place: the
29 * MINIMUM-NORM feasible point, not an arbitrary phase-1 vertex. See
30 * `qrf_feasible_start_lp`, which is where that is arranged; measured on a
31 * two-station cycle at N = 2 with both one and two phases.
32 * - MMI minimizes sum_{i != j} p_ij (log p_ij - log p_ii - log p_jj). The
33 * `-p_ij log p_ii` terms are NOT convex, so the problem is nonconvex and both
34 * codebases report a LOCAL optimum whose identity is fixed by the start
35 * point, i.e. by whichever vertex the phase-1 LP happens to return. Measured:
36 * on a one-phase pair the two agree exactly (0.857143 / 0.428571, itself the
37 * exact product-form answer); on a two-phase / one-phase pair they land on
38 * different vertices, 0.6931 against 0.6438 in objective, and NEITHER is
39 * wrong -- the reference's own solve does not move from its start either.
40 * This is verifiable rather than suspected: the reference's start point is
41 * FEASIBLE under this port's constraints to 8.9e-16, and from this port's
42 * start it is an ASCENT direction (a directional derivative of +0.184), which
43 * only a nonconvex objective permits.
44 *
45 * So MMI parity is start-point parity, and reproducing it would mean
46 * reproducing HiGHS's pivoting rather than any property of the model.
47 *
48 * THE THREE SUBSTITUTIONS FOR SCIPY, and why each is exact rather than
49 * approximate:
50 *
51 * 1. `scipy.linalg.qr(pivoting=True)` behind `independent_rows` is replaced by
52 * a pivoted modified Gram-Schmidt over the same columns. Column-pivoted QR
53 * selects, at each step, the remaining column of largest residual norm, and
54 * that is precisely what the sweep below does; the retained index SET is the
55 * same, and the set is all the caller uses. (The R factor itself is never
56 * read.)
57 * 2. `scipy.optimize.linprog` behind `feasible_start` is replaced by
58 * `lp::simplex_solve` on the same rows with the same [0,1] box, followed by
59 * the minimum-norm refinement `qrf_noblo_start.m` performs with QUADPROG.
60 * The LP is exact in either; the refinement is what keeps the start off a
61 * vertex, and on these objectives the start decides the answer.
62 * 3. `scipy.optimize.minimize(method='SLSQP')` is replaced by FRANK-WOLFE over
63 * the same polytope. See `solve_qrf_nlp` for why this is the right answer
64 * and not a compromise: the available augmented-Lagrangian path has a
65 * derivative-free inner solve, and started at a VERTEX -- which is what the
66 * phase-1 LP returns -- it does not move at all. Conditional gradient uses
67 * the LP that is already here and returns an optimality CERTIFICATE.
68 *
69 * THE REDUCTION IS NOT AN OPTIMIZATION, IT IS WHAT MAKES THE PROBLEM SOLVABLE.
70 * The raw decision vector has M^2 (N+1)^2 Kmax^2 MR + M Kmax entries and the
71 * equality block pins nearly all of them; substituting x = x0 + Z t for an
72 * orthonormal basis Z of null(Aeq) leaves a few free directions. The reference
73 * measured SLSQP at 14 iterations and under a second on the reduced problem
74 * against a failure to move on the raw one.
75 *
76 * THE GUARD THAT MUST NOT BE DROPPED. `reduce_equalities` refuses an
77 * INCONSISTENT system rather than dropping the offending rows -- an
78 * inconsistent polytope is a modelling error, and silently discarding it
79 * returns numbers for a model nobody wrote. The reference's second guard, a
80 * one-shot probe asking whether a feasible descent direction exists at the
81 * start (its SLSQP can silently return the phase-1 vertex), is SUBSUMED here:
82 * `solve_qrf_nlp` asks that same question at EVERY iterate, as its Frank-Wolfe
83 * gap, and terminates on the answer.
84 *
85 * ARITHMETIC: transcendental (p log p, and the orthogonalization).
86 */
87
88#include <algorithm>
89#include <cmath>
90#include <cstddef>
91#include <string>
92#include <vector>
93
94#include "line/num/number.h"
95#include "line/util/error.h"
96#include "line/util/matrix.h"
97#include "line/util/lp_highs.h"
98#include "line/util/simplex.h"
99
100namespace line {
101namespace mapqn {
102
103/**
104 * log() in the working arithmetic.
105 *
106 * `num_traits` exposes the logarithm only as a double (`log_as_double`), which
107 * is enough here: every consumer of this file is gated on
108 * `has_transcendental`, so T is a floating type and the round trip loses
109 * nothing it did not already lose.
110 */
111template <class T>
115
116/** The reference's LOGTOL: the shift that keeps log() off zero. */
117template <class T>
119 return num_traits<T>::from_double(1e-6);
120}
121
122/** The unflattened decision vector: the pair tensor and the effective rates. */
123template <class T>
124struct QrfVars {
125 /** p2[j][nj][k][i][ni][h][m], flattened row-major over the seven indices. */
126 std::vector<T> p2;
127 std::vector<T> e; ///< e[i*Kmax + k]
128 std::size_t M = 0, N = 0, Kmax = 0, MR = 0;
129
130 std::size_t p2_at(std::size_t j, std::size_t nj, std::size_t k, std::size_t i,
131 std::size_t ni, std::size_t h, std::size_t m) const {
132 return (((((j * (N + 1) + nj) * Kmax + k) * M + i) * (N + 1) + ni) * Kmax + h) * MR + m;
133 }
134 const T& p(std::size_t j, std::size_t nj, std::size_t k, std::size_t i, std::size_t ni,
135 std::size_t h, std::size_t m) const {
136 return p2[p2_at(j, nj, k, i, ni, h, m)];
137 }
138 T& p(std::size_t j, std::size_t nj, std::size_t k, std::size_t i, std::size_t ni,
139 std::size_t h, std::size_t m) {
140 return p2[p2_at(j, nj, k, i, ni, h, m)];
141 }
142};
143
144/**
145 * Number of decision variables the layout actually USES.
146 *
147 * THIS IS DELIBERATELY SMALLER THAN THE REFERENCE'S `compute_num_vars`, which
148 * returns the full `M (N+1) Kmax M (N+1) Kmax MR + M Kmax` tensor. The fill
149 * loops of `sub_qrfvar` run over K[j] and K[i], not over Kmax, so with
150 * heterogeneous phase counts the tail of that vector is never written, never
151 * read by a constraint, and never read by an objective -- and, being unread, it
152 * has an all-zero column in Aeq, so every one of those coordinates lands in
153 * null(Aeq) as a FLAT direction of the reduced problem.
154 *
155 * The reference's gradient-based SLSQP shrugs that off (a flat direction has
156 * zero gradient). The augmented-Lagrangian inner solve here is Nelder-Mead,
157 * which degrades sharply with dimension, and the padding is not a rounding
158 * detail: on a two-phase / one-phase pair at N = 2 it is 64 flat directions
159 * against 20 real ones, and the solve returns a utilization of exactly 1
160 * against the reference's 0.8. Dropping the dead coordinates is EXACT, not an
161 * approximation -- nothing reads them -- and it is what makes the port agree.
162 *
163 * The live coordinates are a PREFIX of the reference's vector, since the fill
164 * order is consecutive, so the two layouts agree wherever both are defined.
165 */
166inline std::size_t qrf_num_vars(std::size_t M, std::size_t N, const std::vector<int>& K,
167 std::size_t MR) {
168 std::size_t outer = 0, phases = 0;
169 for (std::size_t i = 0; i < M; ++i) {
170 outer += (N + 1) * static_cast<std::size_t>(K[i]);
171 phases += static_cast<std::size_t>(K[i]);
172 }
173 return outer * outer * MR + phases;
174}
175
176/**
177 * Flat position of every p2 entry, in the FILL ORDER of sub_qrfvar.
178 *
179 * The layout is not a plain strided tensor: the phase loops run over K[j] and
180 * K[i], not over Kmax, so a station with fewer phases leaves GAPS. An entry
181 * that carries no variable keeps -1, and every gradient scatter must skip it.
182 */
183inline std::vector<long> qrf_index_map(std::size_t M, std::size_t N, const std::vector<int>& K,
184 std::size_t MR) {
185 const std::size_t Kmax = static_cast<std::size_t>(*std::max_element(K.begin(), K.end()));
186 QrfVars<double> shape;
187 shape.M = M;
188 shape.N = N;
189 shape.Kmax = Kmax;
190 shape.MR = MR;
191 std::vector<long> idx(M * (N + 1) * Kmax * M * (N + 1) * Kmax * MR, -1);
192 long ctr = 0;
193 for (std::size_t j = 0; j < M; ++j)
194 for (std::size_t nj = 0; nj <= N; ++nj)
195 for (std::size_t k = 0; k < static_cast<std::size_t>(K[j]); ++k)
196 for (std::size_t i = 0; i < M; ++i)
197 for (std::size_t ni = 0; ni <= N; ++ni)
198 for (std::size_t h = 0; h < static_cast<std::size_t>(K[i]); ++h)
199 for (std::size_t m = 0; m < MR; ++m)
200 idx[shape.p2_at(j, nj, k, i, ni, h, m)] = ctr++;
201 return idx;
202}
203
204/** Unflatten x into the pair tensor and the effective rates. */
205template <class T>
206QrfVars<T> sub_qrfvar(const std::vector<T>& x, std::size_t M, std::size_t N,
207 const std::vector<int>& K, std::size_t MR) {
208 const T zero = num_traits<T>::from_int(0);
209 const std::size_t Kmax = static_cast<std::size_t>(*std::max_element(K.begin(), K.end()));
210 QrfVars<T> out;
211 out.M = M;
212 out.N = N;
213 out.Kmax = Kmax;
214 out.MR = MR;
215 out.p2.assign(M * (N + 1) * Kmax * M * (N + 1) * Kmax * MR, zero);
216 out.e.assign(M * Kmax, zero);
217 std::size_t ctr = 0;
218 for (std::size_t j = 0; j < M; ++j)
219 for (std::size_t nj = 0; nj <= N; ++nj)
220 for (std::size_t k = 0; k < static_cast<std::size_t>(K[j]); ++k)
221 for (std::size_t i = 0; i < M; ++i)
222 for (std::size_t ni = 0; ni <= N; ++ni)
223 for (std::size_t h = 0; h < static_cast<std::size_t>(K[i]); ++h)
224 for (std::size_t m = 0; m < MR; ++m)
225 out.p(j, nj, k, i, ni, h, m) = x[ctr++];
226 for (std::size_t i = 0; i < M; ++i)
227 for (std::size_t k = 0; k < static_cast<std::size_t>(K[i]); ++k)
228 out.e[i * Kmax + k] = x[ctr++];
229 // x may be LONGER than the fill consumes, and that is the reference's own
230 // layout rather than an error: `compute_num_vars` sizes the vector on the
231 // full Kmax tensor while the fill loops run over K[j] and K[i], so a
232 // station with fewer phases leaves DEAD entries that no constraint and no
233 // objective ever reads. Too SHORT is a real defect and is refused.
234 if (ctr > x.size())
235 throw InputError("sub_qrfvar: the decision vector is shorter than the layout requires");
236 return out;
237}
238
239/**
240 * Mutual-information objective.
241 *
242 * sum over i != j of p_ij (log p_ij - log p_ii - log p_jj), each log taken on
243 * the LOGTOL-shifted value so the sweep can visit the boundary.
244 */
245template <class T>
246T mmi_objective(const std::vector<T>& x, std::size_t M, std::size_t N,
247 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR) {
248 const T tol = qrf_logtol<T>();
249 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
250 T fobj = num_traits<T>::from_int(0);
251 for (std::size_t m = 0; m < MR; ++m)
252 for (std::size_t i = 0; i < M; ++i)
253 for (std::size_t ki = 0; ki < static_cast<std::size_t>(K[i]); ++ki)
254 for (std::size_t j = 0; j < M; ++j) {
255 if (i == j) continue;
256 for (std::size_t kj = 0; kj < static_cast<std::size_t>(K[j]); ++kj)
257 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni)
258 for (std::size_t nj = 0; nj <= static_cast<std::size_t>(F[j]); ++nj) {
259 const T pij = v.p(i, ni, ki, j, nj, kj, m);
260 const T pii = v.p(i, ni, ki, i, ni, ki, m);
261 const T pjj = v.p(j, nj, kj, j, nj, kj, m);
262 fobj += pij * (qrf_log<T>(tol + pij) -
263 qrf_log<T>(tol + pii) -
264 qrf_log<T>(tol + pjj));
265 }
266 }
267 return fobj;
268}
269
270/**
271 * Maximum-entropy objective, returned as the NEGATIVE entropy +sum p log p
272 * over the diagonal entries, because the solver MINIMIZES and the AMPL model
273 * states this objective as `maximize H`. Returning +H (as every port did until
274 * 2026-08-29) selects the minimum-entropy face of the polytope instead, under
275 * a method documented as maximum-entropy.
276 */
277template <class T>
278T mem_objective(const std::vector<T>& x, std::size_t M, std::size_t N,
279 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR) {
280 const T tol = qrf_logtol<T>();
281 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
282 T fobj = num_traits<T>::from_int(0);
283 for (std::size_t m = 0; m < MR; ++m)
284 for (std::size_t i = 0; i < M; ++i)
285 for (std::size_t k = 0; k < static_cast<std::size_t>(K[i]); ++k)
286 for (std::size_t ni = 1; ni <= static_cast<std::size_t>(F[i]); ++ni) {
287 const T pv = v.p(i, ni, k, i, ni, k, m);
288 fobj += pv * qrf_log<T>(tol + pv);
289 }
290 return fobj;
291}
292
293/**
294 * Gradient of mmi_objective.
295 *
296 * For t = p_ij (log p_ij - log p_ii - log p_jj) the three partials are
297 * dt/dp_ij = log p_ij - log p_ii - log p_jj + p_ij/p_ij', dt/dp_ii =
298 * -p_ij/p_ii' and dt/dp_jj = -p_ij/p_jj', a primed denominator standing for the
299 * shifted value. i != j throughout, so no term aliases its own partials.
300 */
301template <class T>
302std::vector<T> mmi_gradient(const std::vector<T>& x, std::size_t M, std::size_t N,
303 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR,
304 const std::vector<long>& idx) {
305 const T tol = qrf_logtol<T>();
306 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
307 std::vector<T> g(x.size(), num_traits<T>::from_int(0));
308 for (std::size_t m = 0; m < MR; ++m)
309 for (std::size_t i = 0; i < M; ++i)
310 for (std::size_t ki = 0; ki < static_cast<std::size_t>(K[i]); ++ki)
311 for (std::size_t j = 0; j < M; ++j) {
312 if (i == j) continue;
313 for (std::size_t kj = 0; kj < static_cast<std::size_t>(K[j]); ++kj)
314 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni) {
315 const T pii = v.p(i, ni, ki, i, ni, ki, m);
316 const long iii = idx[v.p2_at(i, ni, ki, i, ni, ki, m)];
317 for (std::size_t nj = 0; nj <= static_cast<std::size_t>(F[j]); ++nj) {
318 const T pij = v.p(i, ni, ki, j, nj, kj, m);
319 const T pjj = v.p(j, nj, kj, j, nj, kj, m);
320 const long iij = idx[v.p2_at(i, ni, ki, j, nj, kj, m)];
321 const long ijj = idx[v.p2_at(j, nj, kj, j, nj, kj, m)];
322 if (iij >= 0)
323 g[iij] += qrf_log<T>(tol + pij) -
324 qrf_log<T>(tol + pii) -
325 qrf_log<T>(tol + pjj) + pij / (tol + pij);
326 if (iii >= 0) g[iii] -= pij / (tol + pii);
327 if (ijj >= 0) g[ijj] -= pij / (tol + pjj);
328 }
329 }
330 }
331 return g;
332}
333
334/** Gradient of mem_objective: d/dp of p log(p') is log p' + p/p'. */
335template <class T>
336std::vector<T> mem_gradient(const std::vector<T>& x, std::size_t M, std::size_t N,
337 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR,
338 const std::vector<long>& idx) {
339 const T tol = qrf_logtol<T>();
340 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
341 std::vector<T> g(x.size(), num_traits<T>::from_int(0));
342 for (std::size_t m = 0; m < MR; ++m)
343 for (std::size_t i = 0; i < M; ++i)
344 for (std::size_t k = 0; k < static_cast<std::size_t>(K[i]); ++k)
345 for (std::size_t ni = 1; ni <= static_cast<std::size_t>(F[i]); ++ni) {
346 const T pv = v.p(i, ni, k, i, ni, k, m);
347 const long ip = idx[v.p2_at(i, ni, k, i, ni, k, m)];
348 if (ip >= 0)
349 g[ip] += qrf_log<T>(tol + pv) + pv / (tol + pv);
350 }
351 return g;
352}
353
354/**
355 * Tree-reweighted (Bethe) free entropy at the uniform spanning-tree weight,
356 * the objective of `qrf.bethe`.
357 *
358 * With lambda = 1/M this is lambda*sum_{i!=j} I(n_i;n_j) - sum_i H(n_i), the
359 * NEGATIVE of a tree-reweighted entropy with uniform edge weight
360 * rho_ij = 2*lambda on the complete station graph. H_rho is a convex
361 * combination of tree entropies -- hence concave on the local marginal
362 * polytope -- exactly when rho lies in the spanning tree polytope of K_M,
363 * whose uniform point is rho_ij = 2/M. So lambda = 1/M is the LARGEST uniform
364 * weight for which minimising this is a CONVEX program: every local optimum is
365 * global and the answer stops depending on the start point. The Bethe weight
366 * lambda = 1/2 (total edge mass C(M,2) against the M-1 a spanning tree can
367 * carry) is outside that polytope for every M > 2 and coincides with 1/M at
368 * M = 2.
369 *
370 * TWO DIFFERENCES FROM `mmi_objective`, BOTH DELIBERATE. The population loops
371 * start at n = 0, the range the AMPL source states (`ni, nj in 0..F`) and the
372 * one `mmi_objective` does not use, so the idle/idle cell -- the strongest
373 * correlation in a closed chain -- is inside the sum; and the entropy term is
374 * `mem_objective`'s body over the same restored range, which already carries
375 * the sign a minimiser needs. Neither repair touches `qrf.mmi` or `qrf.mem`,
376 * whose values are pinned by tests.
377 *
378 * NUMERICAL NOTE. The restored n = 0 cells are structurally zero: they
379 * contribute 0*log(tol) = 0 to the VALUE but log(tol) ~ -13.8 to the GRADIENT,
380 * so the value is insensitive to the shift while the descent direction is not.
381 */
382template <class T>
383T bethe_objective(const std::vector<T>& x, std::size_t M, std::size_t N,
384 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR) {
385 const T tol = qrf_logtol<T>();
386 const T lambda = num_traits<T>::from_int(1) / num_traits<T>::from_int(static_cast<long>(M));
387 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
388 T fobj = num_traits<T>::from_int(0);
389 for (std::size_t m = 0; m < MR; ++m)
390 for (std::size_t i = 0; i < M; ++i)
391 for (std::size_t ki = 0; ki < static_cast<std::size_t>(K[i]); ++ki)
392 for (std::size_t j = 0; j < M; ++j) {
393 if (i == j) continue;
394 for (std::size_t kj = 0; kj < static_cast<std::size_t>(K[j]); ++kj)
395 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni)
396 for (std::size_t nj = 0; nj <= static_cast<std::size_t>(F[j]); ++nj) {
397 const T pij = v.p(i, ni, ki, j, nj, kj, m);
398 const T pii = v.p(i, ni, ki, i, ni, ki, m);
399 const T pjj = v.p(j, nj, kj, j, nj, kj, m);
400 fobj += lambda * pij * (qrf_log<T>(tol + pij) -
401 qrf_log<T>(tol + pii) -
402 qrf_log<T>(tol + pjj));
403 }
404 }
405 for (std::size_t m = 0; m < MR; ++m)
406 for (std::size_t i = 0; i < M; ++i)
407 for (std::size_t k = 0; k < static_cast<std::size_t>(K[i]); ++k)
408 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni) {
409 const T pv = v.p(i, ni, k, i, ni, k, m);
410 fobj += pv * qrf_log<T>(tol + pv);
411 }
412 return fobj;
413}
414
415/**
416 * Gradient of bethe_objective.
417 *
418 * df/dp_ij = lambda*(log p_ij' + p_ij/p_ij' - log p_ii' - log p_jj') for
419 * i != j, and df/dp_ii = log p_ii' + p_ii/p_ii'
420 * - (lambda/p_ii')*sum_{j!=i,kj,nj}(p_ij + p_ji),
421 * a primed denominator standing for the shifted value. The second sum is
422 * accumulated by the scatter below, which visits both orderings of every pair.
423 */
424template <class T>
425std::vector<T> bethe_gradient(const std::vector<T>& x, std::size_t M, std::size_t N,
426 const std::vector<int>& K, const std::vector<int>& F, std::size_t MR,
427 const std::vector<long>& idx) {
428 const T tol = qrf_logtol<T>();
429 const T lambda = num_traits<T>::from_int(1) / num_traits<T>::from_int(static_cast<long>(M));
430 const QrfVars<T> v = sub_qrfvar(x, M, N, K, MR);
431 std::vector<T> g(x.size(), num_traits<T>::from_int(0));
432 for (std::size_t m = 0; m < MR; ++m)
433 for (std::size_t i = 0; i < M; ++i)
434 for (std::size_t ki = 0; ki < static_cast<std::size_t>(K[i]); ++ki)
435 for (std::size_t j = 0; j < M; ++j) {
436 if (i == j) continue;
437 for (std::size_t kj = 0; kj < static_cast<std::size_t>(K[j]); ++kj)
438 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni) {
439 const T pii = v.p(i, ni, ki, i, ni, ki, m);
440 const long iii = idx[v.p2_at(i, ni, ki, i, ni, ki, m)];
441 for (std::size_t nj = 0; nj <= static_cast<std::size_t>(F[j]); ++nj) {
442 const T pij = v.p(i, ni, ki, j, nj, kj, m);
443 const T pjj = v.p(j, nj, kj, j, nj, kj, m);
444 const long iij = idx[v.p2_at(i, ni, ki, j, nj, kj, m)];
445 const long ijj = idx[v.p2_at(j, nj, kj, j, nj, kj, m)];
446 if (iij >= 0)
447 g[iij] += lambda * (qrf_log<T>(tol + pij) -
448 qrf_log<T>(tol + pii) -
449 qrf_log<T>(tol + pjj) + pij / (tol + pij));
450 if (iii >= 0) g[iii] -= lambda * pij / (tol + pii);
451 if (ijj >= 0) g[ijj] -= lambda * pij / (tol + pjj);
452 }
453 }
454 }
455 for (std::size_t m = 0; m < MR; ++m)
456 for (std::size_t i = 0; i < M; ++i)
457 for (std::size_t k = 0; k < static_cast<std::size_t>(K[i]); ++k)
458 for (std::size_t ni = 0; ni <= static_cast<std::size_t>(F[i]); ++ni) {
459 const T pv = v.p(i, ni, k, i, ni, k, m);
460 const long ip = idx[v.p2_at(i, ni, k, i, ni, k, m)];
461 if (ip >= 0)
462 g[ip] += qrf_log<T>(tol + pv) + pv / (tol + pv);
463 }
464 return g;
465}
466
467/** The utilizations and queue lengths read off an optimal pair tensor. */
468template <class T>
470 std::vector<T> UN, QN;
471 /**
472 * The ALPHA-WEIGHTED diagonal marginal mean: the mean number of jobs
473 * actually in service, E[min(n,c)] at a c-server station, E[n] at a delay
474 * and P(n >= 1) where alpha is 1. It is what the departure rate is
475 * proportional to, since alpha(i,n) scales the completion rate, so a
476 * station's throughput is BN/stime exactly at the relaxed point. Equal to
477 * UN on the alpha-free arms, which is why they need no separate readout.
478 */
479 std::vector<T> BN;
480};
481
482/**
483 * extract_results: the diagonal marginals of the optimal tensor, plus the
484 * alpha-weighted mean BN. `alpha` may be empty, meaning load independent.
485 */
486template <class T>
487QrfMetrics<T> qrf_extract_results(const QrfVars<T>& v, std::size_t M, const std::vector<int>& K,
488 const std::vector<int>& F, std::size_t MR,
489 const Matrix<T>* alpha = nullptr) {
490 QrfMetrics<T> out;
491 out.UN.assign(M, num_traits<T>::from_int(0));
492 out.QN.assign(M, num_traits<T>::from_int(0));
493 out.BN.assign(M, num_traits<T>::from_int(0));
494 for (std::size_t ti = 0; ti < M; ++ti)
495 for (std::size_t m = 0; m < MR; ++m)
496 for (std::size_t ni = 1; ni <= static_cast<std::size_t>(F[ti]); ++ni) {
498 if (alpha != nullptr && alpha->rows() > static_cast<int>(ti) &&
499 alpha->cols() >= static_cast<int>(ni))
500 a = (*alpha)(ti, ni - 1);
501 for (std::size_t ki = 0; ki < static_cast<std::size_t>(K[ti]); ++ki) {
502 const T pv = v.p(ti, ni, ki, ti, ni, ki, m);
503 out.UN[ti] += pv;
504 out.QN[ti] += num_traits<T>::from_int(static_cast<long>(ni)) * pv;
505 out.BN[ti] += a * pv;
506 }
507 }
508 return out;
509}
510
511/**
512 * Rows of a maximal linearly independent subset of A, by pivoted
513 * Gram-Schmidt.
514 *
515 * The reference uses column-pivoted QR of A^T, which at each step retains the
516 * remaining column of largest residual norm; this is the same greedy selection
517 * written out, and only the index SET is used downstream. Selecting by
518 * conditioning rather than by first-encountered matters: the QRF equality block
519 * is heavily redundant (SYMMETRY states every pair twice, ZERO / MARGINALS /
520 * UEFF overlap), carrying roughly twice as many rows as its rank.
521 */
522template <class T>
523std::vector<std::size_t> qrf_independent_rows(const Matrix<T>& A, double tol = -1.0) {
524 const std::size_t m = A.rows(), n = A.cols();
525 if (m == 0) return std::vector<std::size_t>();
526 std::vector<std::vector<double>> res(m, std::vector<double>(n, 0.0));
527 std::vector<double> nrm(m, 0.0);
528 for (std::size_t i = 0; i < m; ++i) {
529 for (std::size_t j = 0; j < n; ++j) res[i][j] = num_traits<T>::to_double(A(i, j));
530 for (std::size_t j = 0; j < n; ++j) nrm[i] += res[i][j] * res[i][j];
531 }
532 double first = 0.0;
533 for (std::size_t i = 0; i < m; ++i) first = std::max(first, std::sqrt(nrm[i]));
534 if (first == 0.0) return std::vector<std::size_t>();
535 // The reference's default rank tolerance: max(shape) * eps * |R00|, and
536 // |R00| is the largest column norm, which is what `first` holds.
537 const double rtol = (tol > 0.0) ? tol
538 : static_cast<double>(std::max(m, n)) * 2.220446049250313e-16 *
539 first;
540
541 std::vector<bool> taken(m, false);
542 std::vector<std::size_t> keep;
543 std::vector<std::vector<double>> basis;
544 for (std::size_t step = 0; step < std::min(m, n); ++step) {
545 std::size_t best = m;
546 double bestn = rtol;
547 for (std::size_t i = 0; i < m; ++i) {
548 if (taken[i]) continue;
549 const double v = std::sqrt(std::max(0.0, nrm[i]));
550 if (v > bestn) {
551 bestn = v;
552 best = i;
553 }
554 }
555 if (best == m) break;
556 taken[best] = true;
557 keep.push_back(best);
558 std::vector<double> q = res[best];
559 const double qn = std::sqrt(nrm[best]);
560 for (std::size_t j = 0; j < n; ++j) q[j] /= qn;
561 basis.push_back(q);
562 for (std::size_t i = 0; i < m; ++i) {
563 if (taken[i]) continue;
564 double dot = 0.0;
565 for (std::size_t j = 0; j < n; ++j) dot += res[i][j] * q[j];
566 double newn = 0.0;
567 for (std::size_t j = 0; j < n; ++j) {
568 res[i][j] -= dot * q[j];
569 newn += res[i][j] * res[i][j];
570 }
571 nrm[i] = newn;
572 }
573 }
574 std::sort(keep.begin(), keep.end());
575 return keep;
576}
577
578/** An affine residual map recovered as (A, b) with fn(x) = A x - b. */
579template <class T>
580struct QrfAffine {
582 std::vector<T> b;
583};
584
585/**
586 * Recover (A, b) from an affine residual map.
587 *
588 * Every constraint of the QRF inventory is LINEAR in the decision vector --
589 * only the objectives are nonlinear -- so the matrix form is exact, not a
590 * linearization. Affinity is VERIFIED at a probe point and a violation is
591 * raised rather than tolerated, because a silently non-affine callback would
592 * make the recovered matrix wrong everywhere except at the probe.
593 */
594template <class T, class Fn>
595QrfAffine<T> qrf_affine_matrices(Fn fn, std::size_t n) {
596 const T zero = num_traits<T>::from_int(0);
597 std::vector<T> basis(n, zero);
598 const std::vector<T> r0 = fn(basis);
599 QrfAffine<T> out;
600 out.A = Matrix<T>(r0.size(), n, zero);
601 out.b.assign(r0.size(), zero);
602 for (std::size_t i = 0; i < r0.size(); ++i) out.b[i] = -r0[i];
603 const T one = num_traits<T>::from_int(1);
604 for (std::size_t col = 0; col < n; ++col) {
605 basis[col] = one;
606 const std::vector<T> rc = fn(basis);
607 basis[col] = zero;
608 for (std::size_t i = 0; i < r0.size(); ++i) out.A(i, col) = rc[i] - r0[i];
609 }
610 if (!r0.empty() && n > 0) {
611 std::vector<T> probe(n, zero);
612 for (std::size_t j = 0; j < n; ++j)
614 0.1 + 0.8 * (n == 1 ? 0.0 : static_cast<double>(j) / static_cast<double>(n - 1)));
615 const std::vector<T> rp = fn(probe);
616 double err = 0.0;
617 for (std::size_t i = 0; i < r0.size(); ++i) {
618 T v = -out.b[i];
619 for (std::size_t j = 0; j < n; ++j) v += out.A(i, j) * probe[j];
620 err = std::max(err, std::fabs(num_traits<T>::to_double(rp[i] - v)));
621 }
622 if (err > 1e-9)
623 throw InputError(
624 "qrf_affine_matrices: the constraint residuals are not affine in x; the matrix "
625 "form recovered here would be wrong away from the probe");
626 }
627 return out;
628}
629
630/** The equality block with its dependent rows dropped. */
631template <class T>
634 std::vector<T> b;
635 std::vector<std::size_t> keep;
636};
637
638/**
639 * Drop the linearly dependent equality rows, keeping the feasible set exact.
640 *
641 * An INCONSISTENT system is refused rather than reduced: rank([A|b]) above
642 * rank(A) means the polytope is empty, which is a modelling error, and
643 * discarding the offending rows would return numbers for a model nobody wrote.
644 */
645template <class T>
646QrfReduced<T> qrf_reduce_equalities(const Matrix<T>& A, const std::vector<T>& b) {
647 QrfReduced<T> out;
648 out.keep = qrf_independent_rows(A);
649 if (out.keep.size() == A.rows()) {
650 out.A = A;
651 out.b = b;
652 return out;
653 }
654 Matrix<T> aug(A.rows(), A.cols() + 1, num_traits<T>::from_int(0));
655 for (std::size_t i = 0; i < A.rows(); ++i) {
656 for (std::size_t j = 0; j < A.cols(); ++j) aug(i, j) = A(i, j);
657 aug(i, A.cols()) = b[i];
658 }
659 if (qrf_independent_rows(aug).size() > out.keep.size())
660 throw InputError(
661 "qrf_reduce_equalities: the equality system is inconsistent (the augmented matrix has "
662 "the higher rank), i.e. the polytope is empty");
663 out.A = Matrix<T>(out.keep.size(), A.cols(), num_traits<T>::from_int(0));
664 out.b.assign(out.keep.size(), num_traits<T>::from_int(0));
665 for (std::size_t r = 0; r < out.keep.size(); ++r) {
666 for (std::size_t j = 0; j < A.cols(); ++j) out.A(r, j) = A(out.keep[r], j);
667 out.b[r] = b[out.keep[r]];
668 }
669 return out;
670}
671
672/**
673 * The polytope of a QRF instance, as an LpModel over the box [0,1]^n.
674 *
675 * Shared by the phase 1 and by the conditional-gradient loop, so the two can
676 * never disagree about which set they are working over.
677 */
678template <class T>
679lp::LpModel<T> qrf_polytope(const Matrix<T>& Aeq, const std::vector<T>& beq,
680 const Matrix<T>& Aub, const std::vector<T>& bub, std::size_t n) {
681 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
682 lp::LpModel<T> model(n);
683 model.set_maximize(false); // a MINIMIZATION throughout; see solve_qrf_nlp
684 for (std::size_t j = 0; j < n; ++j) {
685 model.set_bounds(j, zero, one);
686 model.set_cost(j, zero);
687 }
688 for (std::size_t i = 0; i < Aeq.rows(); ++i) {
689 model.row_clear();
690 for (std::size_t j = 0; j < n; ++j)
691 if (Aeq(i, j) != zero) model.row_add(j, Aeq(i, j));
692 model.emit_eq(beq[i]);
693 }
694 for (std::size_t i = 0; i < Aub.rows(); ++i) {
695 model.row_clear();
696 for (std::size_t j = 0; j < n; ++j)
697 if (Aub(i, j) != zero) model.row_add(j, Aub(i, j));
698 model.emit_le(bub[i]);
699 }
700 return model;
701}
702
703/**
704 * Orthonormal basis of null(A), as an (n x d) matrix.
705 *
706 * The reference calls `scipy.linalg.null_space`, an SVD; this builds the same
707 * space by modified Gram-Schmidt -- orthonormalize the rows of A, then sweep
708 * the canonical directions and keep each one whose residual against the row
709 * space and the basis so far is nontrivial. The basis is not the SVD's, but the
710 * SPACE is, and the caller only ever uses the space: `x0 + Z t` ranges over the
711 * same affine set whichever orthonormal basis Z carries. Gram-Schmidt is used
712 * rather than `util/svd.h` because that header needs LAPACK, which this call
713 * path must not require.
714 */
715template <class T>
716Matrix<T> qrf_null_space(const Matrix<T>& A, std::size_t n) {
717 std::vector<std::vector<double>> basis; // orthonormal, spanning row(A)
718 const double eps = 1e-10;
719 for (std::size_t r = 0; r < A.rows(); ++r) {
720 std::vector<double> v(n, 0.0);
721 for (std::size_t j = 0; j < n; ++j) v[j] = num_traits<T>::to_double(A(r, j));
722 for (std::size_t b = 0; b < basis.size(); ++b) {
723 double dot = 0.0;
724 for (std::size_t j = 0; j < n; ++j) dot += v[j] * basis[b][j];
725 for (std::size_t j = 0; j < n; ++j) v[j] -= dot * basis[b][j];
726 }
727 double nv = 0.0;
728 for (std::size_t j = 0; j < n; ++j) nv += v[j] * v[j];
729 nv = std::sqrt(nv);
730 if (nv <= eps) continue;
731 for (std::size_t j = 0; j < n; ++j) v[j] /= nv;
732 basis.push_back(v);
733 }
734 const std::size_t rank = basis.size();
735 std::vector<std::vector<double>> nullb;
736 for (std::size_t c = 0; c < n && rank + nullb.size() < n; ++c) {
737 std::vector<double> v(n, 0.0);
738 v[c] = 1.0;
739 for (std::size_t b = 0; b < basis.size(); ++b) {
740 double dot = 0.0;
741 for (std::size_t j = 0; j < n; ++j) dot += v[j] * basis[b][j];
742 for (std::size_t j = 0; j < n; ++j) v[j] -= dot * basis[b][j];
743 }
744 for (std::size_t b = 0; b < nullb.size(); ++b) {
745 double dot = 0.0;
746 for (std::size_t j = 0; j < n; ++j) dot += v[j] * nullb[b][j];
747 for (std::size_t j = 0; j < n; ++j) v[j] -= dot * nullb[b][j];
748 }
749 double nv = 0.0;
750 for (std::size_t j = 0; j < n; ++j) nv += v[j] * v[j];
751 nv = std::sqrt(nv);
752 if (nv <= 1e-8) continue;
753 for (std::size_t j = 0; j < n; ++j) v[j] /= nv;
754 nullb.push_back(v);
755 }
756 Matrix<T> Z(n, nullb.size(), num_traits<T>::from_int(0));
757 for (std::size_t j = 0; j < nullb.size(); ++j)
758 for (std::size_t i = 0; i < n; ++i) Z(i, j) = num_traits<T>::from_double(nullb[j][i]);
759 return Z;
760}
761
762/**
763 * Minimize a convex objective over {Aeq x = beq, Aub x <= bub, 0 <= x <= 1},
764 * starting from a feasible point, by FRANK-WOLFE.
765 *
766 * THIS IS A DELIBERATE SUBSTITUTION FOR THE REFERENCE'S SLSQP, and it is the
767 * one place in this port where a different algorithm is the right answer rather
768 * than a compromise. The reference eliminates the equalities with a null-space
769 * basis and runs SLSQP on the reduced problem. That works because SLSQP is
770 * gradient-based; the augmented-Lagrangian path available here has a
771 * derivative-free Nelder-Mead inner solve, and started AT A VERTEX of the
772 * polytope -- which is exactly what the phase-1 LP returns -- its first simplex
773 * steps leave the feasible set, are penalized, and it does not move at all.
774 * Measured on a two-phase / one-phase pair at N = 2: it returned its start
775 * point, an objective of 0.6931 against the reference's 0.6438, and a
776 * utilization of 1.0 against 0.8.
777 *
778 * The feasible set is a polytope, so conditional gradient applies directly and
779 * gives more than convergence: at each step the linear minimization over the
780 * SAME polytope yields the FRANK-WOLFE GAP <grad f(x), x - s>, which is the
781 * exact stationarity measure -- zero iff no feasible descent direction exists
782 * at x. On MEM, which is convex, it is additionally an upper bound on
783 * f(x) - f(x*). It subsumes the reference's one-shot "did a feasible descent
784 * direction exist at the start?" probe: the same question is asked at every
785 * iterate, and the answer is what terminates the loop.
786 *
787 * No null-space reduction is needed, since the LP carries the equalities
788 * itself.
789 *
790 * @param objective convex objective
791 * @param gradient its gradient
792 * @param x0 a feasible point, from qrf_feasible_start
793 */
794template <class T, class Obj, class Grad>
795std::vector<T> solve_qrf_nlp_lp(Obj objective, Grad gradient, const std::vector<T>& x0,
796 const lp::LpModel<T>& polytope, const std::string& name,
797 unsigned max_iter = 200, double gap_tol = 1e-10) {
798 static_assert(num_traits<T>::has_transcendental, "solve_qrf_nlp needs a log()");
799 const T zero = num_traits<T>::from_int(0);
800 const std::size_t n = x0.size();
801 std::vector<T> x = x0;
802
803 // The polytope, assembled once: only the LP costs change between rounds.
804 lp::LpModel<T> base = polytope;
805 // `LpModel` MAXIMIZES by default. The conditional-gradient step is a
806 // MINIMIZATION of <grad f(x), d>, and taking the default silently returns
807 // the ascent vertex: the Frank-Wolfe gap then comes out NEGATIVE (-0.184 on
808 // the two-phase instance), the loop reads that as "already optimal" and the
809 // routine hands back its phase-1 start.
810 base.set_maximize(false);
811
812 for (unsigned it = 0; it < max_iter; ++it) {
813 const std::vector<T> g = gradient(x);
814 lp::LpModel<T> model = base;
815 for (std::size_t j = 0; j < n; ++j) model.set_cost(j, g[j]);
816 // lp_solve, not simplex_solve: the BAS polytope outgrows the dense
817 // tableau quickly, and this is the same dispatch mapqn_qr_bounds_bas uses.
818 const lp::LpSolution<T> sol = lp::lp_solve(model);
819 if (sol.status != lp::LpStatus::Optimal)
820 throw InputError(name +
821 ": the linear minimization over the QRF polytope failed, so no "
822 "descent direction and no optimality certificate can be had");
823
824 // The Frank-Wolfe gap bounds f(x) - f(x*) from above.
825 double gap = 0.0;
826 for (std::size_t j = 0; j < n; ++j)
827 gap += num_traits<T>::to_double(g[j] * (x[j] - sol.x[j]));
828 if (gap <= gap_tol) break;
829
830 // f is convex along the segment, so a golden-section search on [0,1]
831 // is exact to the bracket it reports.
832 const double invphi = 0.6180339887498949;
833 double a = 0.0, b = 1.0;
834 double c = b - invphi * (b - a), d = a + invphi * (b - a);
835 auto at = [&](double t) {
836 std::vector<T> y(n, zero);
837 const T tt = num_traits<T>::from_double(t);
838 for (std::size_t j = 0; j < n; ++j) y[j] = x[j] + tt * (sol.x[j] - x[j]);
839 return num_traits<T>::to_double(objective(y));
840 };
841 const double f_cur = at(0.0);
842 double fc = at(c), fd = at(d);
843 for (int k = 0; k < 60 && (b - a) > 1e-12; ++k) {
844 if (fc < fd) {
845 b = d;
846 d = c;
847 fd = fc;
848 c = b - invphi * (b - a);
849 fc = at(c);
850 } else {
851 a = c;
852 c = d;
853 fc = fd;
854 d = a + invphi * (b - a);
855 fd = at(d);
856 }
857 }
858 const double gamma = 0.5 * (a + b);
859 if (gamma <= 0.0) break; // the line search found no improvement to take
860
861 // TERMINATE ON PROGRESS, not on the gap alone. The Frank-Wolfe gap is
862 // an exact stationarity measure in exact arithmetic, but it is computed
863 // as a sum of n floating-point products and settles at the NOISE FLOOR
864 // of that sum rather than at zero. Measured on the ragged two-phase /
865 // one-phase instance at N = 2: the gap is 1.45 at the first step, which
866 // is real and drops f from 1.375 to 0.8246, and from the second step on
867 // it rattles between 1e-10 and 2e-8 -- numerically zero, but above the
868 // 1e-10 `gap_tol`. The loop then spent its whole 200-iteration budget
869 // there, solving an LP per round and taking gamma ~ 3e-8 steps that
870 // moved neither x nor f. Scaling `gap_tol` with n would trade one
871 // arbitrary constant for another; a step that does not measurably
872 // reduce the objective is the honest end of the useful run, whatever
873 // the gap says. The returned point is unchanged -- this only stops the
874 // spinning.
875 const double f_new = at(gamma);
876 if (!(f_new < f_cur - 1e-12 * (1.0 + std::fabs(f_cur)))) break;
877
878 const T gt = num_traits<T>::from_double(gamma);
879 for (std::size_t j = 0; j < n; ++j) x[j] = x[j] + gt * (sol.x[j] - x[j]);
880 }
881 return x;
882}
883
884/** The same, with the polytope given as matrices rather than as an LpModel. */
885template <class T, class Obj, class Grad>
886std::vector<T> solve_qrf_nlp(Obj objective, Grad gradient, const std::vector<T>& x0,
887 const Matrix<T>& Aeq, const std::vector<T>& beq,
888 const Matrix<T>& Aub, const std::vector<T>& bub,
889 const std::string& name, unsigned max_iter = 200,
890 double gap_tol = 1e-10) {
891 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
892 const std::size_t n = x0.size();
893 lp::LpModel<T> base(n);
894 base.set_maximize(false);
895 for (std::size_t j = 0; j < n; ++j) base.set_bounds(j, zero, one);
896 for (std::size_t i = 0; i < Aeq.rows(); ++i) {
897 base.row_clear();
898 for (std::size_t j = 0; j < n; ++j)
899 if (Aeq(i, j) != zero) base.row_add(j, Aeq(i, j));
900 base.emit_eq(beq[i]);
901 }
902 for (std::size_t i = 0; i < Aub.rows(); ++i) {
903 base.row_clear();
904 for (std::size_t j = 0; j < n; ++j)
905 if (Aub(i, j) != zero) base.row_add(j, Aub(i, j));
906 base.emit_le(bub[i]);
907 }
908 return solve_qrf_nlp_lp(objective, gradient, x0, base, name, max_iter, gap_tol);
909}
910
911/**
912 * The minimum-norm point of the polytope, from any feasible point of it.
913 *
914 * `0.5||x||^2` is strongly convex, so the Frank-Wolfe gap is a true bound on
915 * the optimality gap and the loop terminates on it rather than on its budget.
916 */
917template <class T>
918std::vector<T> qrf_min_norm_point(const lp::LpModel<T>& polytope, const std::vector<T>& x0,
919 const std::string& name) {
920 const T half = num_traits<T>::from_double(0.5);
921 return solve_qrf_nlp_lp(
922 [&](const std::vector<T>& x) {
924 for (std::size_t j = 0; j < x.size(); ++j) s += x[j] * x[j];
925 return T(half * s);
926 },
927 [](const std::vector<T>& x) { return x; }, x0, polytope, name + " phase 1", 500u, 1e-12);
928}
929
930/**
931 * A feasible point of an LpModel polytope: the MINIMUM-NORM one.
932 *
933 * THE START IS PART OF THE ANSWER HERE, and a vertex is the wrong one. Neither
934 * MEM nor MMI is a convex program as the reference states them -- the MEM
935 * objective is `-sum p log p`, which is CONCAVE, and it is MINIMIZED -- so
936 * every method reports a stationary point fixed by where it started. A
937 * zero-cost phase-1 LP lands on an arbitrary VERTEX, and a vertex is already a
938 * local minimum of a concave objective: conditional gradient reports a zero
939 * gap and hands the vertex straight back. Measured on the two-phase /
940 * one-phase pair at N = 2: f(x0) = f(xopt) = 1.0397 and a utilization of
941 * exactly 1 against the references' 0.8.
942 *
943 * `qrf_noblo_start.m` minimizes `0.5||x||^2` over the same rows for exactly
944 * this reason ("so the returned point is the unique minimum-norm feasible
945 * point instead of an arbitrary vertex"), and native Python does the same.
946 * That objective IS strongly convex, so conditional gradient solves it to
947 * optimality over the polytope and needs no QP backend. From that start the
948 * port returns [0.8, 0.4] / [1.4, 0.6], which is native Python to the last
949 * digit printed and MATLAB to 1e-3 -- MATLAB's fmincon stops on its own
950 * `MaxIter = 100` slightly short of it.
951 */
952template <class T>
953std::vector<T> qrf_feasible_start_lp(const lp::LpModel<T>& polytope, const std::string& name) {
954 const T zero = num_traits<T>::from_int(0);
955 lp::LpModel<T> m = polytope;
956 m.set_maximize(false);
957 for (std::size_t j = 0; j < m.num_vars(); ++j) m.set_cost(j, zero);
958 const lp::LpSolution<T> sol = lp::lp_solve(m);
959 if (sol.status != lp::LpStatus::Optimal)
960 throw InputError(name +
961 ": the polytope is infeasible; a well-posed instance always has a "
962 "feasible point, so this is a modelling error");
963 return qrf_min_norm_point(m, sol.x, name);
964}
965
966/**
967 * A point of the polytope, as the phase 1 of `qrf_noblo_start.m`.
968 *
969 * The all-zero vector violates normalization by a full unit and COR1 by N^2,
970 * and from there a local method terminates AT the start point. Every
971 * constraint here is linear, so an LP lands on the polytope exactly; the
972 * minimum-norm refinement above is what keeps it off a vertex.
973 */
974template <class T>
975std::vector<T> qrf_feasible_start(const Matrix<T>& Aeq, const std::vector<T>& beq,
976 const Matrix<T>& Aub, const std::vector<T>& bub,
977 std::size_t n) {
978 const lp::LpModel<T> model = qrf_polytope(Aeq, beq, Aub, bub, n);
979 const lp::LpSolution<T> sol = lp::simplex_solve(model);
980 if (sol.status != lp::LpStatus::Optimal)
981 throw InputError(
982 "qrf_feasible_start: the QRF polytope is infeasible; a well-posed instance always has "
983 "one, so an empty polytope is a modelling error rather than a numerical accident");
984 return qrf_min_norm_point(model, sol.x, "qrf_feasible_start");
985}
986
987} // namespace mapqn
988} // namespace line
989
990#endif // LINE_API_MAPQN_MAPQN_QRF_COMMON_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
Sparse LP in the natural form, with per-variable bounds.
Definition simplex.h:112
void emit_eq(const T &rhs)
Definition simplex.h:217
void set_maximize(bool m)
true to maximize c'x (the default), false to minimize.
Definition simplex.h:174
void emit_le(const T &rhs)
Definition simplex.h:216
void set_cost(std::size_t j, const T &v)
Definition simplex.h:164
std::size_t num_vars() const
Definition simplex.h:124
void row_clear()
Discard whatever the row accumulator holds.
Definition simplex.h:179
void set_bounds(std::size_t j, const T &lo, const T &hi)
Definition simplex.h:139
void row_add(std::size_t j, const T &v)
row(j) += v, the accumulation the MATLAB reference performs.
Definition simplex.h:188
The exception types the port throws.
A sparse LP backend for line::lp::LpModel, on HiGHS (MIT).
Dense matrix and non-owning view.
LpSolution< T > lp_solve(const LpModel< T > &model, std::size_t dense_max_cols=512)
Solve, choosing the backend by arithmetic and size.
Definition lp_highs.h:176
LpSolution< T > simplex_solve(const LpModel< T > &model, std::size_t max_iterations=0)
Solve the model.
Definition simplex.h:286
@ Optimal
an optimal vertex was reached
Definition simplex.h:77
std::vector< T > qrf_feasible_start_lp(const lp::LpModel< T > &polytope, const std::string &name)
A feasible point of an LpModel polytope: the MINIMUM-NORM one.
std::vector< T > mem_gradient(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR, const std::vector< long > &idx)
Gradient of mem_objective: d/dp of p log(p') is log p' + p/p'.
QrfMetrics< T > qrf_extract_results(const QrfVars< T > &v, std::size_t M, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR, const Matrix< T > *alpha=nullptr)
extract_results: the diagonal marginals of the optimal tensor, plus the alpha-weighted mean BN.
T mem_objective(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR)
Maximum-entropy objective, returned as the NEGATIVE entropy +sum p log p over the diagonal entries,...
std::size_t qrf_num_vars(std::size_t M, std::size_t N, const std::vector< int > &K, std::size_t MR)
Number of decision variables the layout actually USES.
QrfAffine< T > qrf_affine_matrices(Fn fn, std::size_t n)
Recover (A, b) from an affine residual map.
T mmi_objective(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR)
Mutual-information objective.
std::vector< T > qrf_feasible_start(const Matrix< T > &Aeq, const std::vector< T > &beq, const Matrix< T > &Aub, const std::vector< T > &bub, std::size_t n)
A point of the polytope, as the phase 1 of qrf_noblo_start.m.
Matrix< T > qrf_null_space(const Matrix< T > &A, std::size_t n)
Orthonormal basis of null(A), as an (n x d) matrix.
QrfVars< T > sub_qrfvar(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, std::size_t MR)
Unflatten x into the pair tensor and the effective rates.
std::vector< T > bethe_gradient(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR, const std::vector< long > &idx)
Gradient of bethe_objective.
std::vector< T > solve_qrf_nlp(Obj objective, Grad gradient, const std::vector< T > &x0, const Matrix< T > &Aeq, const std::vector< T > &beq, const Matrix< T > &Aub, const std::vector< T > &bub, const std::string &name, unsigned max_iter=200, double gap_tol=1e-10)
The same, with the polytope given as matrices rather than as an LpModel.
QrfReduced< T > qrf_reduce_equalities(const Matrix< T > &A, const std::vector< T > &b)
Drop the linearly dependent equality rows, keeping the feasible set exact.
lp::LpModel< T > qrf_polytope(const Matrix< T > &Aeq, const std::vector< T > &beq, const Matrix< T > &Aub, const std::vector< T > &bub, std::size_t n)
The polytope of a QRF instance, as an LpModel over the box [0,1]^n.
std::vector< T > solve_qrf_nlp_lp(Obj objective, Grad gradient, const std::vector< T > &x0, const lp::LpModel< T > &polytope, const std::string &name, unsigned max_iter=200, double gap_tol=1e-10)
Minimize a convex objective over {Aeq x = beq, Aub x <= bub, 0 <= x <= 1}, starting from a feasible p...
std::vector< long > qrf_index_map(std::size_t M, std::size_t N, const std::vector< int > &K, std::size_t MR)
Flat position of every p2 entry, in the FILL ORDER of sub_qrfvar.
std::vector< T > mmi_gradient(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR, const std::vector< long > &idx)
Gradient of mmi_objective.
T bethe_objective(const std::vector< T > &x, std::size_t M, std::size_t N, const std::vector< int > &K, const std::vector< int > &F, std::size_t MR)
Tree-reweighted (Bethe) free entropy at the uniform spanning-tree weight, the objective of qrf....
std::vector< T > qrf_min_norm_point(const lp::LpModel< T > &polytope, const std::vector< T > &x0, const std::string &name)
The minimum-norm point of the polytope, from any feasible point of it.
std::vector< std::size_t > qrf_independent_rows(const Matrix< T > &A, double tol=-1.0)
Rows of a maximal linearly independent subset of A, by pivoted Gram-Schmidt.
T qrf_logtol()
The reference's LOGTOL: the shift that keeps log() off zero.
T qrf_log(const T &v)
log() in the working arithmetic.
Number-type abstraction for the templated API port.
Templated primal simplex with Bland's rule.
std::vector< T > x
primal solution in the ORIGINAL variable space
Definition simplex.h:96
An affine residual map recovered as (A, b) with fn(x) = A x - b.
The utilizations and queue lengths read off an optimal pair tensor.
std::vector< T > BN
The ALPHA-WEIGHTED diagonal marginal mean: the mean number of jobs actually in service,...
The equality block with its dependent rows dropped.
std::vector< std::size_t > keep
The unflattened decision vector: the pair tensor and the effective rates.
const T & p(std::size_t j, std::size_t nj, std::size_t k, std::size_t i, std::size_t ni, std::size_t h, std::size_t m) const
std::size_t p2_at(std::size_t j, std::size_t nj, std::size_t k, std::size_t i, std::size_t ni, std::size_t h, std::size_t m) const
T & p(std::size_t j, std::size_t nj, std::size_t k, std::size_t i, std::size_t ni, std::size_t h, std::size_t m)
std::vector< T > p2
p2[j][nj][k][i][ni][h][m], flattened row-major over the seven indices.
std::vector< T > e
e[i*Kmax + k]