LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ag.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_SOLVERS_AG_SOLVER_AG_H
6#define LINE_SOLVERS_AG_SOLVER_AG_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `solver_ag.m`: the RCAT (Reversed Compound Agent Theorem)
12 * analyzers, reached by methods 'inap', 'inapplus', 'inapinf' and 'exact'.
13 *
14 * THE METHOD. Each (station, class) pair that carries jobs becomes an isolated
15 * CTMC, and the pairs are coupled only through the REVERSED RATES x_l of the
16 * synchronizing actions. A component is a QBD whose LEVEL is the queue length
17 * and whose PHASE is the pair (arrival phase, service phase), laid out in the
18 * Kronecker order of qbd_mapmap1.h: an arrival moves the level up carrying
19 * kron(D1^a, I), a service completion moves it down carrying kron(I, D1^s),
20 * the busy levels evolve under krons(D0^a, D0^s) and level zero under
21 * kron(D0^a, I), because no server is running there. With exponential
22 * processes every block is 1 x 1 and the QBD collapses to the scalar
23 * birth-death chain this analyzer built before, entry for entry. A departure from one
24 * component is an active transition there and a passive one at the destination;
25 * RCAT says that if the reversed rate of every active label is
26 * state-independent, the joint chain has a product form whose factors are the
27 * isolated components solved with the passive rates set to those x_l. INAP is
28 * the fixed point that looks for such an x: solve the components, re-estimate
29 * each x_l from the resulting marginals, repeat.
30 *
31 * THESE ARE APPROXIMATIONS, and the reference is explicit about it. The
32 * reversed rate is state-independent only for genuinely product-form models; on
33 * everything else INAP converges to an x that is merely a good average, and the
34 * marginals it returns are not the model's. The 'inapinf' variant reports the
35 * RCAT residual of Remark 2, max_l ||pi (x_l I - T_l)||, which is zero exactly
36 * when the product form is real, and it is exposed here as `rcat_residual` for
37 * the same reason: it is the only honest indication of how far off the answer
38 * is. Nothing in this header should be compared against an exact solver at a
39 * tolerance that pretends otherwise.
40 *
41 * THE THREE VARIANTS differ only in how x_l is re-estimated and how the open
42 * components are solved:
43 * inap x_l = mean over the support of the state-wise reversed rate
44 * (pi A_l)_j / pi_j, which on a birth-death component is the
45 * entrywise mean of A_l(i,j) pi(i)/pi(j) term for term
46 * inapplus x_l = sum over the support of A_l(i,j) pi(i), the rate-conserving
47 * estimator, which INAP also switches to on any component that is
48 * not a birth-death chain (a catastrophe or batch removal reaches
49 * beyond the neighbouring LEVEL, and the mean-of-ratios estimator
50 * is meaningless there) and on any component with more than one
51 * phase per level, where the same failure appears
52 * inapinf x_l from the closed-form geometric tail, with each OPEN component
53 * solved on its infinite state space by the scalar QBD root instead
54 * of being truncated at maxStates (Marin, Rota Bulo and Balsamo,
55 * MASCOTS 2012)
56 *
57 * TWO REFERENCE QUIRKS THAT LOOK LIKE BUGS AND ARE HARMLESS, both worth knowing
58 * before editing. First, `compute_equilibrium` subtracts unscaled row-sum
59 * diagonals while adding x(c)-scaled off-diagonal blocks, which is inconsistent;
60 * it does not matter because `ctmc_makeinfgen` discards the diagonal outright
61 * and rebuilds it from the row sums. Second, and for the same reason, the
62 * self-service term `L(n,n) = mu * P(self)` that `build_local_rates` writes on
63 * the DIAGONAL is discarded too, so self-routing at a station has no effect on
64 * the answer. Both are reproduced rather than corrected: correcting either
65 * would change every number this analyzer returns.
66 *
67 * NOT PORTED, AND WHY. The reference's self-looping-class override reads
68 * `sn.isslc`, which NetworkStruct does not carry. MATLAB guards that block with
69 * `isfield(sn,'isslc')`, so a struct without the field skips it and the port
70 * takes the same path; a model that genuinely has self-looping classes would
71 * differ, and there is no way to detect one here without inventing the concept.
72 *
73 * ARITHMETIC. The fixed point stops on a tolerance and the QBD root takes a
74 * square root, so the whole body is gated on transcendental arithmetic, as
75 * solver_mam_basic.h and solver_mam_ldqbd.h are.
76 */
77
78#include <algorithm>
79#include <cmath>
80#include <cstddef>
81#include <limits>
82#include <memory>
83#include <string>
84#include <utility>
85#include <vector>
86
89#include "line/api/mam/qbd_r.h"
93#include "line/num/number.h"
97#include "line/util/error.h"
98#include "line/util/linalg.h"
99#include "line/util/lu.h"
100#include "line/util/matrix.h"
101
102namespace line {
103namespace ag {
104
105// Kronecker products and Neuts' logarithmic reduction are MAM primitives, not
106// RCAT ones: they live in api/mam and are shared with every matrix-analytic
107// analyzer. Named explicitly rather than pulled in wholesale, so the split
108// between what AG owns and what it merely uses stays visible.
109using mam::kron;
110using mam::krons;
112
113namespace ag_detail {
114
115/** One synchronizing action: a departure from (from) that arrives at (to). */
116struct RcatAction {
117 std::size_t from_station = 0, from_class = 0; ///< 0-based
118 std::size_t to_station = 0, to_class = 0; ///< 0-based
119 double prob = 0.0;
120 bool is_negative = false;
121 bool is_catastrophe = false;
122 std::size_t removal_class = 0; ///< 0-based class whose signalremdist applies
123 bool has_removal_dist = false;
124};
125
126/** The RCAT form of the network: components, actions, and their rate matrices. */
127template <class T>
128struct RcatModel {
129 std::size_t num_processes = 0;
130 /** 1-based process id per (station, class), 0 where the pair carries no jobs. */
131 std::vector<std::vector<std::size_t>> process_map;
132 std::vector<RcatAction> actions;
133 std::vector<std::size_t> N; ///< state count per process, nlev * mph
134 std::vector<Matrix<T>> Aa, Pb; ///< active and passive matrix per action
135 std::vector<Matrix<T>> L; ///< local (hidden) rate matrix per process
136 std::vector<std::size_t> act, psv; ///< 0-based active/passive process per action
137 std::vector<bool> is_open_proc;
138 std::vector<std::size_t> nlev; ///< QBD levels per process
139 std::vector<std::size_t> mph; ///< phases per level per process
140 std::vector<std::vector<std::size_t>> level; ///< level index of every state
141 /** Service completion rate out of every state; zero on level 0. */
142 std::vector<std::vector<T>> svcrate;
143 /** The same rate per phase at a busy level. */
144 std::vector<std::vector<T>> svcdown;
145};
146
147/**
148 * One component's Markovian processes: the service MAP of its station and the
149 * arrival MAP of the external streams reaching it, plus the removal signals,
150 * which stay scalar because a signal is a trigger with no service.
151 */
152template <class T>
153struct RcatComponent {
154 std::size_t ist = 0, r = 0;
155 Matrix<T> Da0, Da1; ///< arrival MAP of the external streams
156 Matrix<T> Ds0, Ds1; ///< service MAP
157 Matrix<T> Dsvc; ///< kron(I_na, D1^s): a completion, level down
158 std::size_t na = 1, ns = 1, mph = 1, nlev = 0, N = 0;
159 T lam_neg = num_traits<T>::from_int(0);
160 T lam_cat = num_traits<T>::from_int(0);
161 std::vector<std::pair<T, std::size_t>> batch; ///< (rate, signal class)
162};
163
164/** Row/column offset of level N (0-based) in a component with MPH phases. */
165inline std::size_t blk(std::size_t n, std::size_t mph) { return n * mph; }
166
167/** Elementwise sum of two same-shaped matrices. */
168template <class T>
169Matrix<T> madd_local(const Matrix<T>& A, const Matrix<T>& B) {
170 Matrix<T> C(A.rows(), A.cols(), num_traits<T>::from_int(0));
171 for (std::size_t i = 0; i < A.rows(); ++i)
172 for (std::size_t j = 0; j < A.cols(); ++j) C(i, j) = T(A(i, j) + B(i, j));
173 return C;
174}
175
176/** Add SCALE times BLOCK into the (LI, LJ) level block of TARGET. */
177template <class T>
178void add_block(Matrix<T>& target, std::size_t li, std::size_t lj, std::size_t mph,
179 const Matrix<T>& block, const T& scale) {
180 const std::size_t r0 = blk(li, mph), c0 = blk(lj, mph);
181 for (std::size_t i = 0; i < mph; ++i)
182 for (std::size_t j = 0; j < mph; ++j) target(r0 + i, c0 + j) += T(scale * block(i, j));
183}
184
185/** Add SCALE times the identity into the (LI, LJ) level block of TARGET. */
186template <class T>
187void add_identity(Matrix<T>& target, std::size_t li, std::size_t lj, std::size_t mph,
188 const T& scale) {
189 const std::size_t r0 = blk(li, mph), c0 = blk(lj, mph);
190 for (std::size_t i = 0; i < mph; ++i) target(r0 + i, c0 + i) += scale;
191}
192
193/** Set the (LI, LJ) level block of TARGET to the identity. */
194template <class T>
195void set_identity(Matrix<T>& target, std::size_t li, std::size_t lj, std::size_t mph) {
196 const std::size_t r0 = blk(li, mph), c0 = blk(lj, mph);
197 const T one = num_traits<T>::from_int(1);
198 for (std::size_t i = 0; i < mph; ++i) target(r0 + i, c0 + i) = one;
199}
200
201/** The (LI, LJ) level block of Q. */
202template <class T>
203Matrix<T> level_block(const Matrix<T>& Q, std::size_t li, std::size_t lj, std::size_t mph) {
204 Matrix<T> out(mph, mph, num_traits<T>::from_int(0));
205 const std::size_t r0 = blk(li, mph), c0 = blk(lj, mph);
206 for (std::size_t i = 0; i < mph; ++i)
207 for (std::size_t j = 0; j < mph; ++j) out(i, j) = Q(r0 + i, c0 + j);
208 return out;
209}
210
211/**
212 * True when (D0, D1) is a genuine MAP rather than a RAP or an ME process:
213 * non-negative off-diagonal rates in D0, non-negative rates in D1, and
214 * (D0 + D1) an infinitesimal generator. A CTMC assembled from anything else is
215 * a rational generator whose stationary solution is a signed vector.
216 */
217template <class T>
218bool is_markovian_map(const Matrix<T>& D0, const Matrix<T>& D1) {
219 const std::size_t n = D0.rows();
220 if (n == 0 || D0.cols() != n || D1.rows() != n || D1.cols() != n) return false;
221 double scale = 1.0;
222 for (std::size_t i = 0; i < n; ++i)
223 for (std::size_t j = 0; j < n; ++j) {
224 const double a = num_traits<T>::to_double(D0(i, j));
225 const double b = num_traits<T>::to_double(D1(i, j));
226 if (!std::isfinite(a) || !std::isfinite(b)) return false;
227 scale = std::max(scale, std::max(std::fabs(a), std::fabs(b)));
228 }
229 const double tol = 1e-9 * scale;
230 for (std::size_t i = 0; i < n; ++i) {
231 double row = 0.0;
232 for (std::size_t j = 0; j < n; ++j) {
233 const double a = num_traits<T>::to_double(D0(i, j));
234 const double b = num_traits<T>::to_double(D1(i, j));
235 if (i != j && a < -tol) return false;
236 if (b < -tol) return false;
237 row += a + b;
238 }
239 if (std::fabs(row) > tol) return false;
240 }
241 return true;
242}
243
244/**
245 * (D0,D1) of the process at (IST,R), or the exponential pair built from
246 * `rates` when the station carries no usable matrix representation.
247 *
248 * A non-Markovian pair is refused here and answered as its mean rate; the
249 * runner-level gate rejects those models before they reach this point.
250 */
251template <class T>
252std::pair<Matrix<T>, Matrix<T>> proc_map(const qn::NetworkStruct<T>& L, std::size_t ist,
253 std::size_t r) {
254 const T zero = num_traits<T>::from_int(0);
255 if (L.has_service_law(ist, r)) {
256 try {
257 const mam::Map<T> m = lang::dist_to_map(L.service[ist][r]);
258 if (is_markovian_map(m.D0, m.D1)) return std::make_pair(m.D0, m.D1);
259 } catch (const std::exception&) {
260 // no usable representation: fall through to the exponential pair
261 }
262 }
263 T rate = L.disabled[ist][r] ? zero : L.rates(ist, r);
264 if (!(rate > zero) || !std::isfinite(num_traits<T>::to_double(rate))) rate = zero;
265 Matrix<T> D0(1, 1, zero), D1(1, 1, zero);
266 D0(0, 0) = T(zero - rate);
267 D1(0, 0) = rate;
268 return std::make_pair(D0, D1);
269}
270
271template <class T>
272bool is_tridiagonal(const Matrix<T>& Q) {
273 const std::size_t n = Q.rows();
274 for (std::size_t i = 0; i < n; ++i)
275 for (std::size_t j = 0; j < n; ++j) {
276 const std::size_t d = (i > j) ? (i - j) : (j - i);
277 if (d > 1 && std::fabs(num_traits<T>::to_double(Q(i, j))) > 1e-14) return false;
278 }
279 return true;
280}
281
282/**
283 * True when every transition of Q stays within the neighbouring level, LVL
284 * being the level index of each state.
285 */
286template <class T>
287bool is_block_tridiagonal(const Matrix<T>& Q, const std::vector<std::size_t>& lvl) {
288 const std::size_t n = Q.rows();
289 for (std::size_t i = 0; i < n; ++i)
290 for (std::size_t j = 0; j < n; ++j) {
291 const std::size_t d = (lvl[i] > lvl[j]) ? (lvl[i] - lvl[j]) : (lvl[j] - lvl[i]);
292 if (d > 1 && std::fabs(num_traits<T>::to_double(Q(i, j))) > 1e-14) return false;
293 }
294 return true;
295}
296
297/**
298 * Equilibrium of a birth-death chain by the ratio recursion.
299 *
300 * Preferred over the null-space solve wherever the generator is tridiagonal
301 * because the recursion is stable at the state-space sizes this analyzer builds
302 * (maxStates is 100 by default), where the linear system is already
303 * ill-conditioned.
304 */
305template <class T>
306std::vector<T> birth_death_solve(const Matrix<T>& Q) {
307 const std::size_t n = Q.rows();
308 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
309 if (n <= 1) return std::vector<T>(1, one);
310
311 std::vector<T> pi(n, zero);
312 pi[0] = one;
313 for (std::size_t i = 1; i < n; ++i) {
314 const T birth = Q(i - 1, i);
315 const T death = Q(i, i - 1);
316 pi[i] = (death > zero) ? T(pi[i - 1] * birth / death) : zero;
317 }
318 T total = zero;
319 for (const T& v : pi) total += v;
320 if (total > zero) {
321 for (T& v : pi) v /= total;
322 } else {
323 const T u = one / num_traits<T>::from_int(static_cast<long>(n));
324 for (T& v : pi) v = u;
325 }
326 return pi;
327}
328
329/**
330 * Stationary vector of a generator C, allowing a reducible one.
331 *
332 * Replaces the first balance equation by the normalization, which is the
333 * equation it is redundant with (the columns of a generator sum to zero), and
334 * solves the resulting square system. Unlike a null-space solve this stays well
335 * posed when the chain is reducible with ONE closed class, which the level-0
336 * chain of a phase-expanded component routinely is: a phase-type restarts in
337 * the support of alpha, so every service phase outside that support is
338 * unreachable once the queue has emptied at least once.
339 */
340template <class T>
341std::vector<T> stat_vector(const Matrix<T>& C) {
342 const std::size_t m = C.rows();
343 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
344 // Solve v A = e_0, i.e. A^T v^T = e_0^T.
345 Matrix<T> At(m, m, zero);
346 for (std::size_t i = 0; i < m; ++i)
347 for (std::size_t j = 0; j < m; ++j) At(j, i) = (j == 0) ? one : C(i, j);
348 std::vector<T> rhs(m, zero);
349 rhs[0] = one;
350 const std::vector<std::size_t> piv = lu_factor(At);
351 lu_solve(At, piv, rhs);
352 return rhs;
353}
354
355/**
356 * Stationary vector of a finite block-tridiagonal generator.
357 *
358 * Linear level reduction: censor the chain level by level from the top,
359 * C(nlev-1) = B(nlev-1), C(n) = B(n) + F(n) (-C(n+1))^-1 D(n+1),
360 * with B, F and D the diagonal, up and down blocks. C(0) is the generator of
361 * the chain censored on level 0, so pi_0 is its stationary vector and the rest
362 * follows from pi_(n+1) = pi_n F(n) (-C(n+1))^-1. This is the block form of
363 * birth_death_solve and reduces to it entry for entry when m == 1.
364 */
365template <class T>
366std::vector<T> qbd_finite_solve(const Matrix<T>& Q, std::size_t m, std::size_t nlev) {
367 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
368 if (nlev <= 1) return mc::ctmc_solve(Q);
369
370 std::vector<Matrix<T>> C(nlev);
371 C[nlev - 1] = level_block(Q, nlev - 1, nlev - 1, m);
372 for (std::size_t t = nlev - 1; t-- > 0;) {
373 const Matrix<T> F = level_block(Q, t, t + 1, m);
374 const Matrix<T> D = level_block(Q, t + 1, t, m);
375 Matrix<T> negC = C[t + 1];
376 for (std::size_t i = 0; i < m; ++i)
377 for (std::size_t j = 0; j < m; ++j) negC(i, j) = T(zero - negC(i, j));
378 const Matrix<T> step = matmul(F, inverse(negC));
379 Matrix<T> Cn = level_block(Q, t, t, m);
380 const Matrix<T> corr = matmul(step, D);
381 for (std::size_t i = 0; i < m; ++i)
382 for (std::size_t j = 0; j < m; ++j) Cn(i, j) += corr(i, j);
383 C[t] = Cn;
384 }
385
386 std::vector<T> pi(nlev * m, zero);
387 const std::vector<T> p0 = stat_vector(C[0]);
388 for (std::size_t i = 0; i < m; ++i) pi[i] = p0[i];
389 for (std::size_t n = 0; n + 1 < nlev; ++n) {
390 Matrix<T> negC = C[n + 1];
391 for (std::size_t i = 0; i < m; ++i)
392 for (std::size_t j = 0; j < m; ++j) negC(i, j) = T(zero - negC(i, j));
393 const Matrix<T> step = matmul(level_block(Q, n, n + 1, m), inverse(negC));
394 for (std::size_t j = 0; j < m; ++j) {
395 T acc = zero;
396 for (std::size_t i = 0; i < m; ++i) acc += T(pi[blk(n, m) + i] * step(i, j));
397 pi[blk(n + 1, m) + j] = acc;
398 }
399 }
400
401 T total = zero;
402 for (const T& v : pi) total += v;
403 if (total > zero) {
404 for (T& v : pi) v /= total;
405 } else {
406 const T u = one / num_traits<T>::from_int(static_cast<long>(nlev * m));
407 for (T& v : pi) v = u;
408 }
409 return pi;
410}
411
412/**
413 * Stationary vector of one isolated component.
414 *
415 * A component with a single phase per level is the birth-death chain the
416 * analyzer has always built, and the ratio recursion is both exact and stable
417 * there; a phase-expanded component is block tridiagonal instead, and the matrix
418 * analogue of that recursion keeps the same stability at the 100-level
419 * truncation, where a null-space solve is already ill-conditioned. Anything that
420 * reaches beyond the neighbouring level -- a catastrophe, a batch removal -- is
421 * neither, and falls back to ctmc_solve.
422 */
423template <class T>
424std::vector<T> solve_component(const Matrix<T>& Q, std::size_t mph, std::size_t nlev,
425 const std::vector<std::size_t>& lvl) {
426 if (mph == 1) {
427 if (is_tridiagonal(Q)) return birth_death_solve(Q);
428 } else if (is_block_tridiagonal(Q, lvl)) {
429 return qbd_finite_solve(Q, mph, nlev);
430 }
431 return mc::ctmc_solve(Q);
432}
433
434/** The matrix-geometric tail of one open component with more than one phase. */
435template <class T>
436struct QbdTail {
437 Matrix<T> R;
438 std::vector<T> pi0, pi1;
439 std::vector<T> busy; ///< sum_{n>=1} pi_n = pi_1 (I - R)^-1
440 T qlen = num_traits<T>::from_int(0);
441 bool ok = false;
442};
443
444/**
445 * Neuts' matrix-geometric solution of one open component with MPH phases per
446 * level: R from logarithmic reduction, then the boundary equations of levels 0
447 * and 1,
448 * pi_0 B00 + pi_1 A2 = 0, pi_0 A0 + pi_1 (A1 + R A2) = 0,
449 * normalized by pi_0 e + pi_1 (I - R)^-1 e = 1. Returns ok = false when R has no
450 * sub-unit spectral radius, i.e. when the isolated component is unstable and has
451 * no stationary tail to report.
452 *
453 * Logarithmic reduction rather than successive substitutions: this runs once per
454 * component per fixed-point sweep, and the quadratic convergence is what keeps
455 * that affordable. The cap is small for the same reason -- an unstable component
456 * must bail rather than grind to the library default of 1e5.
457 */
458template <class T>
459QbdTail<T> qbd_matrix_tail(const Matrix<T>& Qk, const Matrix<T>& A0, const Matrix<T>& A1,
460 const Matrix<T>& A2, std::size_t mph) {
461 QbdTail<T> out;
462 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
463 Matrix<T> R;
464 try {
465 R = qbd_R_logred(A2, A1, A0, 500u, T(num_traits<T>::from_double(1e-14)));
466 } catch (const std::exception&) {
467 return out;
468 }
469 for (std::size_t i = 0; i < mph; ++i)
470 for (std::size_t j = 0; j < mph; ++j) {
471 const double v = num_traits<T>::to_double(R(i, j));
472 // The minimal solution of a QBD is NON-NEGATIVE; anything else is
473 // the iteration having failed rather than a rate matrix.
474 if (!std::isfinite(v) || v < -1e-12) return out;
475 }
476
477 const Matrix<T> B00 = level_block(Qk, 0, 0, mph);
478 Matrix<T> IR = eye<T>(mph);
479 for (std::size_t i = 0; i < mph; ++i)
480 for (std::size_t j = 0; j < mph; ++j) IR(i, j) = T(IR(i, j) - R(i, j));
481 std::vector<T> tail_mass;
482 try {
483 tail_mass = mulvec(inverse(IR), ones<T>(mph));
484 } catch (const std::exception&) {
485 return out;
486 }
487 // STABILITY WITHOUT AN EIGENSOLVER. (I-R)^-1 = I + R + R^2 + ... converges
488 // exactly when the spectral radius is below one, and every row of that
489 // series is e_i plus non-negative terms, so (I-R)^-1 e >= 1 entrywise. When
490 // the isolated component is unstable the series diverges and the inverse
491 // picks up negative entries, so the test below is the spectral condition
492 // without the LAPACK dependency an eigensolve would carry.
493 for (std::size_t i = 0; i < mph; ++i) {
494 const double w = num_traits<T>::to_double(tail_mass[i]);
495 if (!std::isfinite(w) || w < 1.0 - 1e-9) return out;
496 }
497
498 // [pi_0 pi_1] Sys = 0, with the first column replaced by the normalization.
499 const Matrix<T> lower_right = madd_local(A1, matmul(R, A2));
500 const std::size_t n2 = 2 * mph;
501 Matrix<T> SysT(n2, n2, zero); // transposed: solve Sys^T v = e_0
502 for (std::size_t i = 0; i < mph; ++i)
503 for (std::size_t j = 0; j < mph; ++j) {
504 SysT(j, i) = B00(i, j);
505 SysT(mph + j, i) = A0(i, j);
506 SysT(j, mph + i) = A2(i, j);
507 SysT(mph + j, mph + i) = lower_right(i, j);
508 }
509 for (std::size_t i = 0; i < mph; ++i) {
510 SysT(0, i) = one;
511 SysT(0, mph + i) = tail_mass[i];
512 }
513 std::vector<T> v(n2, zero);
514 v[0] = one;
515 try {
516 Matrix<T> lu = SysT;
517 const std::vector<std::size_t> piv = lu_factor(lu);
518 lu_solve(lu, piv, v);
519 } catch (const std::exception&) {
520 return out;
521 }
522 for (std::size_t i = 0; i < n2; ++i)
523 if (!std::isfinite(num_traits<T>::to_double(v[i]))) return out;
524
525 out.pi0.assign(v.begin(), v.begin() + static_cast<long>(mph));
526 out.pi1.assign(v.begin() + static_cast<long>(mph), v.end());
527 const Matrix<T> IRinv = inverse(IR);
528 out.busy = vecmul(out.pi1, IRinv);
529 const std::vector<T> qv = vecmul(out.busy, IRinv);
530 T q = zero;
531 for (const T& t : qv) q += t;
532 out.qlen = q;
533 out.R = R;
534 out.ok = true;
535 return out;
536}
537
538/**
539 * Materialize the matrix-geometric tail over NLEV levels, so the block norm of
540 * the fixed point and the RCAT residual read one vector shape for every
541 * component. The metrics use the closed forms in the tail instead.
542 */
543template <class T>
544std::vector<T> qbd_tail_expand(const QbdTail<T>& g, std::size_t nlev, std::size_t mph) {
545 std::vector<T> pi(nlev * mph, num_traits<T>::from_int(0));
546 for (std::size_t i = 0; i < mph; ++i) pi[i] = g.pi0[i];
547 std::vector<T> v = g.pi1;
548 for (std::size_t n = 1; n < nlev; ++n) {
549 for (std::size_t i = 0; i < mph; ++i) pi[blk(n, mph) + i] = v[i];
550 v = vecmul(v, g.R);
551 }
552 return pi;
553}
554
555/** P(batch = k) off the pmf indexed by batch size, zero past its end. */
556template <class T>
557T pmf_at(const std::vector<T>& pmf, std::size_t k) {
558 return k < pmf.size() ? pmf[k] : num_traits<T>::from_int(0);
559}
560
561/**
562 * Accumulate the n -> m block of a batch removal into `B`, scaled by `rate`.
563 *
564 * Landing on the empty state absorbs the whole upper tail of the pmf, which is
565 * what keeps the block stochastic once the batch exceeds the queue length.
566 */
567template <class T>
568void add_batch_removal(Matrix<T>& B, const std::vector<T>& pmf, const T& rate,
569 std::size_t nlev, std::size_t mph) {
570 const T one = num_traits<T>::from_int(1), zero = num_traits<T>::from_int(0);
571 for (std::size_t n = 1; n < nlev; ++n) {
572 for (std::size_t m = 1; m <= n; ++m) {
573 const T p = pmf_at(pmf, n - m);
574 if (p > zero) add_identity(B, n, m, mph, T(rate * p));
575 }
576 T cdf = zero;
577 for (std::size_t j = 0; j + 1 <= n; ++j) cdf += pmf_at(pmf, j);
578 const T tail = T(one - cdf);
579 if (tail > zero) add_identity(B, n, 0, mph, T(rate * tail));
580 }
581}
582
583/** Sub-unit root of b rho^2 - (f+b+g) rho + f = 0, the block-size-1 Neuts R. */
584inline double qbd_scalar_rho(double f, double b, double g) {
585 const double inf = std::numeric_limits<double>::infinity();
586 if (b <= 1e-14) {
587 // Degenerate without a down transition: the catastrophe drain is the
588 // only thing that keeps the chain stable.
589 if (f + g <= 0.0) return inf;
590 return f / (f + g);
591 }
592 const double c1 = -(f + b + g);
593 const double disc = c1 * c1 - 4.0 * b * f;
594 if (disc < 0.0) return inf;
595 const double sq = std::sqrt(disc);
596 double r1 = (-c1 - sq) / (2.0 * b), r2 = (-c1 + sq) / (2.0 * b);
597 if (r1 > r2) std::swap(r1, r2);
598 return (r1 > 0.0) ? r1 : r2;
599}
600
601/**
602 * `sn.issignal(r)` under the struct's own convention: the five G-network arrays
603 * are allocated together, and only when `set_signal` declares one, so an EMPTY
604 * `issignal` means "no class is a signal" and is the ORDINARY case rather than a
605 * malformed struct.
606 *
607 * IT MUST BE READ THROUGH A SIZE TEST, and not merely because the subscript is
608 * out of range. `issignal` is a `std::vector<bool>`, whose empty state holds a
609 * NULL word pointer, so `issignal[r]` dereferences null and takes the process
610 * down -- it does not read a stray byte and carry on. That is what `build_rcat`
611 * did on the first signal-free model ever handed to it, Source -> Q1 -> Sink,
612 * and the SIGSEGV surfaced in a test whose subject is the M/M/1 marginal, with
613 * nothing about it pointing at G-networks. Every other reader of these arrays in
614 * the tree already guards: `solver_ctmc.h`, `state_events.h`, `tag_chain.h`.
615 */
616template <class T>
617bool is_signal_class(const qn::NetworkStruct<T>& L, std::size_t r) {
618 return r < L.issignal.size() && L.issignal[r];
619}
620
621/**
622 * The declared signal type, defaulting exactly as `set_signal` initializes the
623 * array. Only ever consulted for a class `is_signal_class` admits, so the
624 * default is unreachable through this header; it is stated so that the pair of
625 * arrays cannot disagree about how far they extend.
626 */
627template <class T>
628lang::SignalType signal_type_of(const qn::NetworkStruct<T>& L, std::size_t r) {
629 return r < L.signaltype.size() ? L.signaltype[r] : lang::SignalType::NEGATIVE;
630}
631
632/**
633 * External (Source) streams reaching the component, as one arrival MAP for the
634 * positive customers plus the scalar rates of the removal signals.
635 *
636 * Each stream is thinned by its routing probability -- a MAP thinned with
637 * probability p is (D0 + (1-p) D1, p D1) -- and the streams are superposed by
638 * the Kronecker sum, so several Poisson sources still collapse to the single
639 * rate sum this analyzer used before. Removal signals stay scalar: a signal is a
640 * trigger with no service, and its arrival process is required exponential.
641 */
642template <class T>
643void arrival_map(const qn::NetworkStruct<T>& L, RcatComponent<T>& c,
644 const std::vector<std::size_t>& source_stations) {
645 using lang::SignalType;
646 const std::size_t K = L.nclasses;
647 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
648 bool have_arrival = false;
649 Matrix<T> Da0(1, 1, zero), Da1(1, 1, zero);
650
651 for (std::size_t isrc : source_stations)
652 for (std::size_t s = 0; s < K; ++s) {
653 const bool is_signal = is_signal_class(L, s);
654 T prob_src = zero;
655 if (is_signal) {
656 // A signal routes to itself, so its effect on this component is
657 // its total probability of reaching this STATION in any class.
658 for (std::size_t sd = 0; sd < K; ++sd)
659 prob_src += L.rt(isrc * K + s, c.ist * K + sd);
660 } else {
661 prob_src = L.rt(isrc * K + s, c.ist * K + c.r);
662 }
663 if (!(prob_src > zero) || L.disabled[isrc][s]) continue;
664 const T src_rate = L.rates(isrc, s);
665 const bool removal_signal =
666 is_signal && (signal_type_of(L, s) == SignalType::NEGATIVE ||
667 signal_type_of(L, s) == SignalType::CATASTROPHE);
668 if (removal_signal) {
669 if (signal_type_of(L, s) == SignalType::CATASTROPHE) {
670 c.lam_cat += T(src_rate * prob_src);
671 } else if (s < L.signalremdist.size() && !L.signalremdist[s].empty()) {
672 c.batch.emplace_back(T(src_rate * prob_src), s);
673 } else {
674 c.lam_neg += T(src_rate * prob_src);
675 }
676 continue;
677 }
678 if (!(src_rate > zero)) continue;
679
680 std::pair<Matrix<T>, Matrix<T>> sm = proc_map(L, isrc, s);
681 Matrix<T> S0 = sm.first, S1 = sm.second;
682 if (prob_src < one) {
683 const T keep = prob_src, drop = T(one - prob_src);
684 for (std::size_t i = 0; i < S0.rows(); ++i)
685 for (std::size_t j = 0; j < S0.cols(); ++j) S0(i, j) += T(drop * S1(i, j));
686 for (std::size_t i = 0; i < S1.rows(); ++i)
687 for (std::size_t j = 0; j < S1.cols(); ++j) S1(i, j) = T(keep * S1(i, j));
688 }
689 if (have_arrival) {
690 Da0 = krons(Da0, S0);
691 Da1 = krons(Da1, S1);
692 } else {
693 Da0 = S0;
694 Da1 = S1;
695 have_arrival = true;
696 }
697 }
698 c.Da0 = Da0;
699 c.Da1 = Da1;
700}
701
702/** Build the local (hidden) rate matrix of the component at (station, class). */
703template <class T>
704Matrix<T> build_local_rates(const qn::NetworkStruct<T>& L, const RcatComponent<T>& c,
705 const std::vector<std::size_t>& sink_nodes) {
706 const std::size_t K = L.nclasses;
707 const std::size_t mph = c.mph, nlev = c.nlev;
708 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
709 Matrix<T> Lm(c.N, c.N, zero);
710
711 // Level-local blocks: the arrival phase always runs, the service phase only
712 // while the server is busy (qbd_mapmap1's Lbar = kron(D0^a, I) at level 0
713 // and L = krons(D0^a, D0^s) above it). With one phase each these are pure
714 // diagonals, which ctmc_makeinfgen discards and rebuilds from the row sums.
715 add_block(Lm, 0, 0, mph, kron(c.Da0, eye<T>(c.ns)), one);
716 const Matrix<T> Lbusy = krons(c.Da0, c.Ds0);
717 for (std::size_t n = 1; n < nlev; ++n) add_block(Lm, n, n, mph, Lbusy, one);
718
719 // Positive arrivals: level n -> n+1, carrying kron(D1^a, I).
720 const Matrix<T> Aup = kron(c.Da1, eye<T>(c.ns));
721 for (std::size_t n = 0; n + 1 < nlev; ++n) add_block(Lm, n, n + 1, mph, Aup, one);
722 // At the truncation the job is lost but the arrival process still moves on,
723 // so the block stays on the top level. With a single arrival phase this is a
724 // pure diagonal and is discarded, exactly as before.
725 add_block(Lm, nlev - 1, nlev - 1, mph, Aup, one);
726
727 // Catastrophe arrivals: every busy level drops to level 0.
728 if (c.lam_cat > zero)
729 for (std::size_t n = 1; n < nlev; ++n) add_identity(Lm, n, 0, mph, c.lam_cat);
730 for (const std::pair<T, std::size_t>& ba : c.batch)
731 add_batch_removal(Lm, L.signalremdist[ba.second], ba.first, nlev, mph);
732 // Single-removal negative arrivals: level n -> n-1 (busy levels only).
733 if (c.lam_neg > zero)
734 for (std::size_t n = 1; n < nlev; ++n) add_identity(Lm, n, n - 1, mph, c.lam_neg);
735
736 // Service completions that are not synchronizing actions: departures to a
737 // Sink (level down) and self-routing (level unchanged, service restarted).
738 if (L.disabled[c.ist][c.r]) return Lm;
739 const T mu = L.rates(c.ist, c.r);
740 if (!(mu > zero)) return Lm;
741
742 // The node index IS needed here -- rtnodes is indexed by node, not by
743 // station -- so a station the map does not cover has no row to read and
744 // contributes no sink probability. Tested up front rather than relying on
745 // the range check below to catch the wrapped `node - 1`.
746 const std::size_t node = L.node_of_station(c.ist + 1);
747 T prob_sink = zero;
748 if (node != 0)
749 for (std::size_t jsnk : sink_nodes)
750 for (std::size_t s = 0; s < K; ++s) {
751 const std::size_t from = (node - 1) * K + c.r, to = (jsnk - 1) * K + s;
752 if (from < L.rtnodes.rows() && to < L.rtnodes.cols())
753 prob_sink += L.rtnodes(from, to);
754 }
755 const T prob_self = L.rt(c.ist * K + c.r, c.ist * K + c.r);
756
757 if (prob_sink > zero)
758 for (std::size_t n = 1; n < nlev; ++n) add_block(Lm, n, n - 1, mph, c.Dsvc, prob_sink);
759 // With one service phase this lands on the diagonal, hence is discarded by
760 // ctmc_makeinfgen and self-routing has no effect, exactly as the reference
761 // records; with a phase block it restarts the service, which is the physics.
762 if (prob_self > zero)
763 for (std::size_t n = 1; n < nlev; ++n) add_block(Lm, n, n, mph, c.Dsvc, prob_self);
764 return Lm;
765}
766
767/** Port of `build_rcat`: the network to its RCAT components and actions. */
768template <class T>
769RcatModel<T> build_rcat(const qn::NetworkStruct<T>& L, std::size_t max_states) {
770 using lang::SignalType;
771 const std::size_t M = L.nstations, K = L.nclasses;
772 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
773 RcatModel<T> m;
774 m.process_map.assign(M, std::vector<std::size_t>(K, 0));
775
776 std::vector<std::size_t> source_stations, queue_stations, sink_nodes;
777 for (std::size_t i = 0; i < L.nof_nodes(); ++i)
778 if (L.nodes[i].nodetype == qn::NodeType::Sink) sink_nodes.push_back(i + 1);
779 for (std::size_t ist = 0; ist < M; ++ist) {
780 // FROM THE STATION, not through the node map. `add_station` copies the
781 // type into both, so the two always agree -- but `node_of_station`
782 // returns 0 for a station the map does not cover (a Layer built by the
783 // LQN path carries fewer entries than stations), and `nodes[node - 1]`
784 // then wraps to SIZE_MAX and reads off the end of the vector.
785 const qn::NodeType ty = L.stations[ist].nodetype;
786 if (ty == qn::NodeType::Source) source_stations.push_back(ist);
787 else if (ty != qn::NodeType::Sink) queue_stations.push_back(ist);
788 }
789
790 // A signal class never gets a component of its own: it has no queue, it
791 // only edits the state of the positive-customer components it reaches.
792 std::size_t pidx = 0;
793 for (std::size_t ist : queue_stations)
794 for (std::size_t r = 0; r < K; ++r) {
795 if (is_signal_class(L, r) || L.disabled[ist][r]) continue;
796 if (!(L.rates(ist, r) > zero)) continue;
797 m.process_map[ist][r] = ++pidx;
798 }
799 m.num_processes = pidx;
800 if (m.num_processes == 0) return m;
801
802 // The QBD shape of every component: both the service MAP of its station and
803 // the arrival MAP of the external streams reaching it.
804 const std::vector<double> njobs = L.njobs();
805 std::vector<RcatComponent<T>> comp(m.num_processes);
806 m.N.assign(m.num_processes, 0);
807 m.nlev.assign(m.num_processes, 0);
808 m.mph.assign(m.num_processes, 1);
809 m.level.assign(m.num_processes, std::vector<std::size_t>());
810 m.svcrate.assign(m.num_processes, std::vector<T>());
811 m.svcdown.assign(m.num_processes, std::vector<T>());
812 m.is_open_proc.assign(m.num_processes, false);
813 for (std::size_t ist : queue_stations)
814 for (std::size_t r = 0; r < K; ++r) {
815 const std::size_t p = m.process_map[ist][r];
816 if (p == 0 || m.N[p - 1] != 0) continue;
817 RcatComponent<T>& c = comp[p - 1];
818 c.ist = ist;
819 c.r = r;
820 const std::pair<Matrix<T>, Matrix<T>> svc = proc_map(L, ist, r);
821 c.Ds0 = svc.first;
822 c.Ds1 = svc.second;
823 arrival_map(L, c, source_stations);
824 c.ns = c.Ds0.rows();
825 c.na = c.Da0.rows();
826 c.mph = c.na * c.ns;
827 if (std::isfinite(njobs[r])) {
828 c.nlev = static_cast<std::size_t>(njobs[r]) + 1;
829 } else {
830 c.nlev = max_states; // open class: truncate
831 m.is_open_proc[p - 1] = true;
832 }
833 // Service completion: level down, arrival phase untouched.
834 c.Dsvc = kron(eye<T>(c.na), c.Ds1);
835 c.N = c.nlev * c.mph;
836
837 m.N[p - 1] = c.N;
838 m.nlev[p - 1] = c.nlev;
839 m.mph[p - 1] = c.mph;
840 m.level[p - 1].assign(c.N, 0);
841 for (std::size_t n = 0; n < c.nlev; ++n)
842 for (std::size_t j = 0; j < c.mph; ++j) m.level[p - 1][n * c.mph + j] = n;
843 m.svcdown[p - 1].assign(c.mph, zero);
844 for (std::size_t i = 0; i < c.mph; ++i) {
845 T acc = zero;
846 for (std::size_t j = 0; j < c.mph; ++j) acc += c.Dsvc(i, j);
847 m.svcdown[p - 1][i] = acc;
848 }
849 m.svcrate[p - 1].assign(c.N, zero);
850 for (std::size_t n = 1; n < c.nlev; ++n)
851 for (std::size_t j = 0; j < c.mph; ++j)
852 m.svcrate[p - 1][n * c.mph + j] = m.svcdown[p - 1][j];
853 }
854
855 for (std::size_t ist : queue_stations)
856 for (std::size_t r = 0; r < K; ++r) {
857 if (m.process_map[ist][r] == 0) continue;
858 const bool is_removal =
859 is_signal_class(L, r) && (signal_type_of(L, r) == SignalType::NEGATIVE ||
860 signal_type_of(L, r) == SignalType::CATASTROPHE);
861 const bool is_cat = is_removal && signal_type_of(L, r) == SignalType::CATASTROPHE;
862 const bool has_dist =
863 is_removal && r < L.signalremdist.size() && !L.signalremdist[r].empty();
864 for (std::size_t jst : queue_stations)
865 for (std::size_t s = 0; s < K; ++s) {
866 if (m.process_map[jst][s] == 0) continue;
867 if (ist == jst && r == s) continue;
868 const T pr = L.rt(ist * K + r, jst * K + s);
869 if (!(pr > zero)) continue;
870 RcatAction a;
871 a.from_station = ist;
872 a.from_class = r;
873 a.to_station = jst;
874 a.to_class = s;
875 a.prob = num_traits<T>::to_double(pr);
876 a.is_negative = is_removal;
877 a.is_catastrophe = is_cat;
878 a.has_removal_dist = has_dist;
879 a.removal_class = r;
880 m.actions.push_back(a);
881 }
882 }
883
884 m.L.reserve(m.num_processes);
885 for (std::size_t p = 0; p < m.num_processes; ++p)
886 m.L.push_back(build_local_rates(L, comp[p], sink_nodes));
887
888 const std::size_t A = m.actions.size();
889 m.Aa.reserve(A);
890 m.Pb.reserve(A);
891 m.act.reserve(A);
892 m.psv.reserve(A);
893 for (const RcatAction& a : m.actions) {
894 const std::size_t pa = m.process_map[a.from_station][a.from_class] - 1;
895 const std::size_t pp = m.process_map[a.to_station][a.to_class] - 1;
896 m.act.push_back(pa);
897 m.psv.push_back(pp);
898
899 const RcatComponent<T>& ca = comp[pa];
900 const T prob = num_traits<T>::from_double(a.prob);
901 Matrix<T> Am(ca.N, ca.N, zero);
902 for (std::size_t n = 1; n < ca.nlev; ++n)
903 add_block(Am, n, n - 1, ca.mph, ca.Dsvc, prob);
904 // The boundary self-loop is physical only for a closed class, where the
905 // top state is the real population bound. On an open class the top state
906 // is the artefact of the maxStates truncation, and a self-loop there
907 // would feed the truncation bias straight into the reversed rate. It is
908 // written on the DIAGONAL so it stays inert in the generator while still
909 // contributing the pi(i)/pi(i) = 1 ratio the INAP estimators read off.
910 if (std::isfinite(njobs[a.from_class])) {
911 const std::size_t off = blk(ca.nlev - 1, ca.mph);
912 for (std::size_t i = 0; i < ca.mph; ++i)
913 Am(off + i, off + i) += T(m.svcdown[pa][i] * prob);
914 }
915 m.Aa.push_back(Am);
916
917 const RcatComponent<T>& cp = comp[pp];
918 Matrix<T> Bm(cp.N, cp.N, zero);
919 if (a.is_negative) {
920 if (a.is_catastrophe) {
921 for (std::size_t n = 0; n < cp.nlev; ++n) set_identity(Bm, n, 0, cp.mph);
922 } else if (a.has_removal_dist) {
923 add_batch_removal(Bm, L.signalremdist[a.removal_class], one, cp.nlev, cp.mph);
924 set_identity(Bm, 0, 0, cp.mph); // an empty queue absorbs the signal
925 } else {
926 set_identity(Bm, 0, 0, cp.mph);
927 for (std::size_t n = 1; n < cp.nlev; ++n) set_identity(Bm, n, n - 1, cp.mph);
928 }
929 } else {
930 // The phase is untouched: a job joining does not restart the server,
931 // and the service phase frozen at level 0 is the one the last
932 // completion left behind, which for a phase-type is already its
933 // entry distribution.
934 for (std::size_t n = 0; n + 1 < cp.nlev; ++n) set_identity(Bm, n, n + 1, cp.mph);
935 set_identity(Bm, cp.nlev - 1, cp.nlev - 1, cp.mph);
936 }
937 m.Pb.push_back(Bm);
938 }
939 return m;
940}
941
942/** Assemble a component's generator from the local rates and the current x. */
943template <class T>
944Matrix<T> assemble_generator(const RcatModel<T>& m, const std::vector<T>& x, std::size_t k) {
945 Matrix<T> Qk = m.L[k];
946 for (std::size_t c = 0; c < m.actions.size(); ++c) {
947 if (m.psv[c] == k) {
948 for (std::size_t i = 0; i < Qk.rows(); ++i)
949 for (std::size_t j = 0; j < Qk.cols(); ++j) Qk(i, j) += T(x[c] * m.Pb[c](i, j));
950 } else if (m.act[c] == k) {
951 for (std::size_t i = 0; i < Qk.rows(); ++i)
952 for (std::size_t j = 0; j < Qk.cols(); ++j) Qk(i, j) += m.Aa[c](i, j);
953 }
954 }
955 // ctmc_makeinfgen discards whatever sits on the diagonal and rebuilds it
956 // from the row sums, which is why the reference's unscaled diagonal
957 // corrections here are inert. See the header note.
958 return mc::ctmc_makeinfgen(Qk);
959}
960
961/** A matrix in double, whatever arithmetic the model carries. */
962template <class T>
963Matrix<double> ag_as_double(const Matrix<T>& m) {
964 Matrix<double> out(m.rows(), m.cols(), 0.0);
965 for (std::size_t i = 0; i < m.rows(); ++i)
966 for (std::size_t j = 0; j < m.cols(); ++j)
967 out(i, j) = num_traits<T>::to_double(m(i, j));
968 return out;
969}
970
971/**
972 * Agent k's static half, as the ag-worker protocol carries it. Double only,
973 * because the wire is JSON; ag_sweep_cluster refuses any other arithmetic before
974 * this is reached.
975 */
976template <class T>
977AgWirePayload wire_payload(const RcatModel<T>& m, std::size_t k) {
978 AgWirePayload a;
979 a.k = static_cast<int>(k);
980 a.n = static_cast<int>(m.N[k]);
981 a.mph = static_cast<int>(m.mph[k]);
982 a.nlev = static_cast<int>(m.nlev[k]);
983 a.level.assign(m.level[k].begin(), m.level[k].end());
984 a.L = ag_triplets(ag_as_double(m.L[k]));
985 for (std::size_t c = 0; c < m.actions.size(); ++c) {
986 if (m.psv[c] == k) {
987 a.passive_c.push_back(static_cast<int>(c));
988 a.passive_m.push_back(ag_triplets(ag_as_double(m.Pb[c])));
989 } else if (m.act[c] == k) {
990 a.active_c.push_back(static_cast<int>(c));
991 a.active_m.push_back(ag_triplets(ag_as_double(m.Aa[c])));
992 }
993 }
994 return a;
995}
996
997/**
998 * Port of `compute_equilibrium`: every component solved in isolation.
999 *
1000 * Agent k reads the rest of the model only through the scalar reversed rates x
1001 * and writes only its own slot, so this is a fan-out and not a recurrence. That
1002 * is what lets @p exec evaluate the agents on a thread pool or on remote workers
1003 * and still walk the same iterates as the serial loop; any cross-agent read
1004 * added here would silently make those backends race.
1005 */
1006template <class T>
1007void compute_equilibrium(const RcatModel<T>& m, const std::vector<T>& x,
1008 std::vector<std::vector<T>>& pi, std::vector<Matrix<T>>& Q,
1009 const AgOptions* opt = nullptr, AgWorkerPool* pool = nullptr) {
1010 pi.assign(m.num_processes, std::vector<T>());
1011 Q.assign(m.num_processes, Matrix<T>());
1012
1013 auto gen = [&](std::size_t k) { return assemble_generator(m, x, k); };
1014 auto sol = [&](const Matrix<T>& Qk, std::size_t k) {
1015 return solve_component(Qk, m.mph[k], m.nlev[k], m.level[k]);
1016 };
1017
1018 const std::string mode = (opt == nullptr) ? std::string(exec_serial()) : opt->exec;
1019 if (exec_is_parallel(mode)) {
1020 ag_sweep_parallel<T>(m.num_processes, opt->nworkers, gen, sol, Q, pi);
1021 } else if (mode == exec_cluster()) {
1022 auto payload = [&](std::size_t k) { return wire_payload(m, k); };
1023 ag_sweep_cluster<T>(m.num_processes, *pool, x, gen, sol, payload, Q, pi);
1024 } else {
1025 ag_sweep_serial<T>(m.num_processes, gen, sol, Q, pi);
1026 }
1027}
1028
1029
1030/**
1031 * Port of `compute_equilibrium_qbd`: open components on their infinite state
1032 * space, closed ones as before.
1033 */
1034template <class T>
1035void compute_equilibrium_qbd(const RcatModel<T>& m, const std::vector<T>& x,
1036 std::vector<std::vector<T>>& pi, std::vector<Matrix<T>>& Q,
1037 std::vector<double>& rho_proc, std::vector<bool>& is_geom,
1038 std::vector<QbdTail<T>>& geom_data) {
1039 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
1040 pi.assign(m.num_processes, std::vector<T>());
1041 Q.assign(m.num_processes, Matrix<T>());
1042 rho_proc.assign(m.num_processes, 0.0);
1043 is_geom.assign(m.num_processes, false);
1044 geom_data.assign(m.num_processes, QbdTail<T>());
1045
1046 for (std::size_t k = 0; k < m.num_processes; ++k) {
1047 const std::size_t Nk = m.N[k];
1048 Matrix<T> Off = m.L[k];
1049 for (std::size_t i = 0; i < Nk; ++i) Off(i, i) = zero;
1050 for (std::size_t c = 0; c < m.actions.size(); ++c) {
1051 if (m.psv[c] == k) {
1052 for (std::size_t i = 0; i < Nk; ++i)
1053 for (std::size_t j = 0; j < Nk; ++j) Off(i, j) += T(x[c] * m.Pb[c](i, j));
1054 } else if (m.act[c] == k) {
1055 for (std::size_t i = 0; i < Nk; ++i)
1056 for (std::size_t j = 0; j < Nk; ++j) Off(i, j) += m.Aa[c](i, j);
1057 }
1058 }
1059 for (std::size_t i = 0; i < Nk; ++i) Off(i, i) = zero;
1060 Q[k] = mc::ctmc_makeinfgen(Off);
1061
1062 const std::size_t mph = m.mph[k], nlev = m.nlev[k];
1063 bool solved_geom = false;
1064 if (m.is_open_proc[k] && nlev >= 5) {
1065 if (mph == 1) {
1066 // Read the homogeneous interior one level below the truncation,
1067 // so the reflecting boundary of Off does not contaminate it.
1068 const std::size_t s0 = Nk - 2;
1069 const double f = num_traits<T>::to_double(Off(s0, s0 + 1));
1070 const double b = num_traits<T>::to_double(Off(s0, s0 - 1));
1071 const double g0 = num_traits<T>::to_double(Off(s0, 0));
1072 // A jump to a strictly interior lower level is batch removal onto
1073 // a non-empty state, which is not a scalar QBD; defer to the
1074 // finite solve rather than fit a geometric that does not hold.
1075 double inter_down = 0.0;
1076 for (std::size_t j = 1; j + 3 < Nk; ++j)
1077 inter_down += num_traits<T>::to_double(Off(s0, j));
1078 if (inter_down <= 1e-11 && f > 0.0) {
1079 const double rho = qbd_scalar_rho(f, b, g0);
1080 if (std::isfinite(rho) && rho > 0.0 && rho < 1.0 - 1e-12) {
1081 rho_proc[k] = rho;
1082 is_geom[k] = true;
1083 pi[k].assign(Nk, zero);
1084 const T rt = num_traits<T>::from_double(rho);
1085 T acc = T(one - rt);
1086 for (std::size_t n = 0; n < Nk; ++n) {
1087 pi[k][n] = acc;
1088 acc = T(acc * rt);
1089 }
1090 solved_geom = true;
1091 }
1092 }
1093 } else if (is_block_tridiagonal(Q[k], m.level[k])) {
1094 // Read the homogeneous interior BLOCKS one level below the
1095 // truncation, for the same reason the scalar branch reads the
1096 // interior row there.
1097 const std::size_t s0 = nlev - 2;
1098 const Matrix<T> A0 = level_block(Q[k], s0, s0 + 1, mph); // up
1099 const Matrix<T> A1 = level_block(Q[k], s0, s0, mph); // local
1100 const Matrix<T> A2 = level_block(Q[k], s0, s0 - 1, mph); // down
1101 bool any_up = false;
1102 for (std::size_t i = 0; i < mph && !any_up; ++i) {
1103 T rs = zero;
1104 for (std::size_t j = 0; j < mph; ++j) rs += A0(i, j);
1105 if (rs > zero) any_up = true;
1106 }
1107 if (any_up) {
1108 const QbdTail<T> g = qbd_matrix_tail(Q[k], A0, A1, A2, mph);
1109 if (g.ok) {
1110 is_geom[k] = true;
1111 geom_data[k] = g;
1112 pi[k] = qbd_tail_expand(g, nlev, mph);
1113 solved_geom = true;
1114 }
1115 }
1116 }
1117 }
1118 if (!solved_geom) pi[k] = solve_component(Q[k], mph, nlev, m.level[k]);
1119 }
1120}
1121
1122/** The reference's block norm: the largest 1-norm change over the components. */
1123template <class T>
1124double block_norm(const std::vector<std::vector<T>>& a, const std::vector<std::vector<T>>& b) {
1125 double e = 0.0;
1126 for (std::size_t k = 0; k < a.size() && k < b.size(); ++k) {
1127 const std::size_t n = std::min(a[k].size(), b[k].size());
1128 double s = 0.0;
1129 for (std::size_t i = 0; i < n; ++i)
1130 s += std::fabs(num_traits<T>::to_double(T(a[k][i] - b[k][i])));
1131 if (s > e) e = s;
1132 }
1133 return e;
1134}
1135
1136/** Port of `rcat_metrics`. */
1137template <class T>
1138mva::MvaSolution<T> rcat_metrics(const qn::NetworkStruct<T>& L, const RcatModel<T>& m,
1139 const std::vector<std::vector<T>>& pi,
1140 const std::vector<double>& rho_proc,
1141 const std::vector<bool>& is_geom,
1142 const std::vector<QbdTail<T>>& geom_data) {
1143 const std::size_t M = L.nstations, K = L.nclasses;
1144 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
1145 mva::MvaSolution<T> s;
1146 s.Q = Matrix<T>(M, K, zero);
1147 s.U = Matrix<T>(M, K, zero);
1148 s.R = Matrix<T>(M, K, zero);
1149 s.Tp = Matrix<T>(M, K, zero);
1150 s.C.assign(K, zero);
1151 s.X.assign(K, zero);
1152
1153 for (std::size_t ist = 0; ist < M; ++ist)
1154 for (std::size_t r = 0; r < K; ++r) {
1155 const std::size_t p = m.process_map.empty() ? 0 : m.process_map[ist][r];
1156 if (p == 0 || p > pi.size() || pi[p - 1].empty()) continue;
1157 const bool disabled = L.disabled[ist][r];
1158 const std::size_t mph = m.mph[p - 1];
1159 if (!is_geom.empty() && p <= is_geom.size() && is_geom[p - 1]) {
1160 if (mph == 1) {
1161 // The infinite geometric marginal pi_n = (1-rho) rho^n, whose
1162 // moments are closed forms; using the truncated vector here
1163 // would put the truncation back into the answer.
1164 const T mu = disabled ? zero : L.rates(ist, r);
1165 const T rho = num_traits<T>::from_double(rho_proc[p - 1]);
1166 s.Q(ist, r) = T(rho / (one - rho));
1167 s.U(ist, r) = rho;
1168 if (mu > zero) s.Tp(ist, r) = T(mu * rho);
1169 } else {
1170 // The matrix-geometric tail pi_(n+1) = pi_n R.
1171 const QbdTail<T>& g = geom_data[p - 1];
1172 T busy = zero, tput = zero;
1173 for (std::size_t i = 0; i < mph; ++i) {
1174 busy += g.busy[i];
1175 tput += T(g.busy[i] * m.svcdown[p - 1][i]);
1176 }
1177 s.Q(ist, r) = g.qlen;
1178 s.U(ist, r) = busy;
1179 s.Tp(ist, r) = tput;
1180 }
1181 } else {
1182 const std::vector<T>& v = pi[p - 1];
1183 T q = zero, tput = zero;
1184 for (std::size_t n = 0; n < v.size(); ++n) {
1185 q += T(num_traits<T>::from_int(static_cast<long>(m.level[p - 1][n])) * v[n]);
1186 // The rate of service completions, i.e. the phase-dependent
1187 // departure rate averaged over the marginal. With one phase
1188 // this is the mean rate times P(N>0).
1189 tput += T(m.svcrate[p - 1][n] * v[n]);
1190 }
1191 s.Q(ist, r) = q;
1192 T level0 = zero;
1193 for (std::size_t i = 0; i < mph; ++i) level0 += v[i];
1194 s.U(ist, r) = T(one - level0);
1195 s.Tp(ist, r) = tput;
1196 }
1197 }
1198
1199 for (std::size_t ist = 0; ist < M; ++ist)
1200 for (std::size_t r = 0; r < K; ++r)
1201 if (s.Tp(ist, r) > zero) s.R(ist, r) = T(s.Q(ist, r) / s.Tp(ist, r));
1202
1203 // A SOURCE'S THROUGHPUT IS ITS ARRIVAL RATE, and the loop above cannot fill
1204 // it: a Source has no queue and no service process, so the RCAT process map
1205 // covers no station of that type and every one of its cells stayed zero. The
1206 // reference reports lambda there, and -- far more than a cosmetic row -- the
1207 // whole open network's ARRIVAL RATES are derived from the throughput vector
1208 // by `sn_get_arvr_from_tput`, so a zero at the Source propagated to zero
1209 // arrivals everywhere downstream: `ag_tandem_open` reported ArvR 0 at Queue1
1210 // against a golden of 0.5, and `ag_gnetwork` lost its Negative class's rows
1211 // entirely. It is the same fact `s.X[r]` below already reads off the Source
1212 // for an open class, written into the table it belongs in.
1213 for (std::size_t ist = 0; ist < M; ++ist) {
1214 if (L.stations[ist].nodetype != qn::NodeType::Source) continue;
1215 for (std::size_t r = 0; r < K; ++r)
1216 s.Tp(ist, r) = L.disabled[ist][r] ? zero : L.rates(ist, r);
1217 }
1218
1219 const std::vector<double> njobs = L.njobs();
1220 for (std::size_t r = 0; r < K; ++r) {
1221 if (!std::isfinite(njobs[r])) {
1222 for (std::size_t ist = 0; ist < M; ++ist) {
1223 // From the STATION, for the reason `build_rcat` reads it there.
1224 if (L.stations[ist].nodetype == qn::NodeType::Source) {
1225 s.X[r] = L.disabled[ist][r] ? zero : L.rates(ist, r);
1226 break;
1227 }
1228 }
1229 T c = zero;
1230 for (std::size_t ist = 0; ist < M; ++ist) c += s.R(ist, r);
1231 s.C[r] = c;
1232 } else {
1233 const std::size_t refst = L.classes[r].refstat;
1234 if (refst > 0 && refst <= M) {
1235 s.X[r] = s.Tp(refst - 1, r);
1236 if (s.X[r] > zero)
1237 s.C[r] = T(num_traits<T>::from_double(njobs[r]) / s.X[r]);
1238 }
1239 }
1240 }
1241 return s;
1242}
1243
1244} // namespace ag_detail
1245
1246/**
1247 * The process types the RCAT construction can give a phase dimension to.
1248 *
1249 * After `sn_nonmarkov_toph` (which the RCAT methods run with the phase-type
1250 * fit and no Det preservation) each of these holds a genuine (D0,D1) pair with
1251 * non-negative off-diagonal rates and a single arrival per epoch. The list is
1252 * an ALLOW-list on purpose: a process type nobody has checked against this
1253 * construction must be refused, not answered. Refused are the laws whose
1254 * matrices are not a generator (ME, RAP), those that are not time-homogeneous
1255 * (NHPP, MAPt, PHt), those that are not continuous-time (DMAP), and those that
1256 * arrive in batches (BMAP, MMAP), since a batch moves the level by more than
1257 * one.
1258 */
1284
1285/** What the RCAT analyzer returns beyond the metrics. */
1286template <class T>
1287struct AgResult {
1289 std::string actualmethod;
1290 /**
1291 * The RCAT product-form residual of Remark 2, max_l ||pi (x_l I - T_l)||_2,
1292 * computed by 'inapinf' only. Zero exactly when the reversed rates came out
1293 * state-independent, i.e. when the product form the method assumes is real;
1294 * anything else is the size of the modelling error, not of a numerical one.
1295 */
1296 double rcat_residual = 0.0;
1297};
1298
1299/**
1300 * Port of `solver_ag.m`.
1301 *
1302 * `max_states` is the reference's `options.config.maxStates`, the truncation of
1303 * every OPEN component. It defaults to AgOptions::max_states, which carries the
1304 * reference's 100; the explicit parameter overrides it for a caller that has no
1305 * options object to hand.
1306 */
1307template <class T>
1309 std::size_t max_states = 0) {
1310 if (max_states == 0) max_states = opt.max_states;
1311 if constexpr (!num_traits<T>::has_transcendental) {
1312 throw UnsupportedError(
1313 "solver_ag: the RCAT analyzers stop on a tolerance and the matrix-geometric "
1314 "variant takes a square root, so they need transcendental arithmetic; rerun this "
1315 "model with --arith double or --arith real");
1316 } else {
1317 using namespace ag_detail;
1318 const T zero = num_traits<T>::from_int(0);
1319 const std::size_t M = L.nstations, K = L.nclasses;
1320
1321 std::string method = opt.method;
1322 if (method == "default") method = "inap";
1323 if (method == "exact") {
1324 // The reference warns and falls back rather than erroring, because
1325 // autocat moved out of the tree; solver_mam_autocat.h records that.
1326 method = "inap";
1327 }
1328 if (method != "inap" && method != "inapplus" && method != "inapinf")
1329 throw UnsupportedError("solver_ag: unknown method '" + opt.method + "'");
1330
1331 AgResult<T> out;
1332 out.actualmethod = method;
1333
1334 if (opt.exec == exec_cluster() && method == "inapinf") {
1335 // The remote worker implements the FINITE agent solve. 'inapinf' replaces
1336 // it with the matrix-geometric treatment of an open agent -- Neuts' R
1337 // matrix and the scalar-tail detection that precedes it -- which the
1338 // worker does not carry, and answering with the finite solve instead
1339 // would silently change the method.
1340 throw UnsupportedError(
1341 "solver_ag: the 'cluster' execution backend does not carry the 'inapinf' agent "
1342 "solve (the matrix-geometric tail of an open agent runs on the coordinator "
1343 "only); use exec 'serial' or 'parallel' with 'inapinf', or method 'inap'/"
1344 "'inapplus' with 'cluster'");
1345 }
1346 if (opt.exec != exec_serial() && !exec_is_parallel(opt.exec)
1347 && opt.exec != exec_cluster()) {
1348 // 'threads' was this backend's name until 2026-08-19. Naming the rename
1349 // costs one branch and saves a caller with an old script from reading
1350 // "unknown backend" about a backend that still exists.
1351 const std::string hint = (opt.exec == "threads")
1352 ? "; 'threads' was renamed to 'parallel' (alias 'para')" : "";
1353 throw InputError("solver_ag: unknown execution backend '" + opt.exec +
1354 "'; use 'serial', 'parallel' (alias 'para') or 'cluster'" + hint);
1355 }
1356
1357 std::unique_ptr<AgWorkerPool> pool;
1358 if (opt.exec == exec_cluster()) {
1359 pool.reset(new AgWorkerPool(opt.endpoints, opt.worker_timeout));
1360 }
1361
1362 const RcatModel<T> m = build_rcat(L, max_states);
1363
1364 if (m.num_processes == 0) {
1365 // The reference warns and returns zeros rather than erroring: a model
1366 // with no serving station is degenerate, not invalid.
1367 out.sol.Q = Matrix<T>(M, K, zero);
1368 out.sol.U = Matrix<T>(M, K, zero);
1369 out.sol.R = Matrix<T>(M, K, zero);
1370 out.sol.Tp = Matrix<T>(M, K, zero);
1371 out.sol.C.assign(K, zero);
1372 out.sol.X.assign(K, zero);
1373 out.sol.method = opt.method;
1374 out.sol.iter = 0;
1375 return out;
1376 }
1377
1378 const std::size_t A = m.actions.size();
1379 std::vector<std::vector<T>> pi;
1380 std::vector<Matrix<T>> Q;
1381
1382 if (A == 0) {
1383 // No synchronizing action, so there is no fixed point to run: every
1384 // component is already closed under its local rates alone. This is the
1385 // single-queue G-network shape (Source -> Queue -> Sink).
1386 // Solved through the same dispatcher the fixed point uses: this branch
1387 // carries a whole M/PH/1 on its own, whose marginal spans tens of orders
1388 // of magnitude over the truncation, and the level recursions are stable
1389 // there where a null-space solve is not.
1390 pi.assign(m.num_processes, std::vector<T>());
1391 for (std::size_t k = 0; k < m.num_processes; ++k) {
1392 const Matrix<T> Qk = mc::ctmc_makeinfgen(m.L[k]);
1393 pi[k] = solve_component(Qk, m.mph[k], m.nlev[k], m.level[k]);
1394 }
1395 out.sol = rcat_metrics(L, m, pi, std::vector<double>(), std::vector<bool>(),
1396 std::vector<QbdTail<T>>());
1397 out.sol.method = opt.method;
1398 out.sol.iter = 0;
1399 return out;
1400 }
1401
1402 // A component reaching beyond the NEIGHBOURING LEVEL is not a birth-death
1403 // chain, and the mean-of-ratios estimator is meaningless there, so INAP
1404 // switches it to the rate-conserving one that INAP+ uses everywhere. The
1405 // within-level phase transitions of a PH sit far off the diagonal and are
1406 // NOT such a departure, which is why the test is on the level index.
1407 //
1408 // A PHASE-EXPANDED component takes the same estimator, for the same reason.
1409 // On a birth-death chain every state-wise reversed rate equals lambda, so
1410 // their mean is exact; with a phase block per level they do not, the deep
1411 // truncation levels dominate the unweighted mean, and the mean-of-ratios
1412 // overestimates the departure rate exactly as it does on a catastrophe
1413 // (measured on a tandem with Erlang(2) service at Q1: the reversed rate came
1414 // out 1.27 against the exact 0.5, so flow was not conserved).
1415 std::vector<bool> not_birth_death(m.num_processes, false);
1416 for (std::size_t k = 0; k < m.num_processes; ++k) {
1417 if (m.mph[k] > 1) not_birth_death[k] = true;
1418 for (std::size_t n = 0; n < m.N[k]; ++n)
1419 for (std::size_t j = 0; j < m.N[k]; ++j) {
1420 const std::size_t ln = m.level[k][n], lj = m.level[k][j];
1421 const std::size_t d = (ln > lj) ? (ln - lj) : (lj - ln);
1422 if (d > 1 && m.L[k](n, j) > zero) not_birth_death[k] = true;
1423 }
1424 }
1425
1426 // Columns of each active matrix that carry any rate. Aa does not depend on
1427 // x, so this is fixed for the whole fixed point.
1428 std::vector<std::vector<std::size_t>> active_cols(A);
1429 for (std::size_t a = 0; a < A; ++a)
1430 for (std::size_t j = 0; j < m.Aa[a].cols(); ++j) {
1431 T colsum = zero;
1432 for (std::size_t i = 0; i < m.Aa[a].rows(); ++i) colsum += m.Aa[a](i, j);
1433 if (colsum > zero) active_cols[a].push_back(j);
1434 }
1435
1436 // Deterministic initial guess, so the answer does not depend on a seed.
1437 std::vector<T> x(A, zero);
1438 for (std::size_t a = 0; a < A; ++a)
1439 x[a] = num_traits<T>::from_double(static_cast<double>(a + 1) /
1440 static_cast<double>(A + 1));
1441
1442 std::vector<double> rho_proc;
1443 std::vector<bool> is_geom;
1444 std::vector<QbdTail<T>> geom_data;
1445 const bool qbd = (method == "inapinf");
1446 if (qbd) compute_equilibrium_qbd(m, x, pi, Q, rho_proc, is_geom, geom_data);
1447 else compute_equilibrium(m, x, pi, Q, &opt, pool.get());
1448
1449 std::vector<std::vector<T>> a_rowsum(A);
1450 for (std::size_t a = 0; a < A; ++a) {
1451 a_rowsum[a].assign(m.Aa[a].rows(), zero);
1452 for (std::size_t i = 0; i < m.Aa[a].rows(); ++i)
1453 for (std::size_t j = 0; j < m.Aa[a].cols(); ++j) a_rowsum[a][i] += m.Aa[a](i, j);
1454 }
1455
1456 // The fixed point is written out rather than driven by da_fpi: the
1457 // reference installs a custom `da_norm` (the per-component 1-norm above),
1458 // and the C++ da_fpi hard-codes the max-abs norm of a flat vector, which is
1459 // a strictly weaker stopping test and would stop earlier.
1460 const double tol = opt.tol;
1461 std::size_t iter = 0;
1462 bool converged = false;
1463 for (iter = 1; iter <= static_cast<std::size_t>(opt.iter_max); ++iter) {
1464 const std::vector<std::vector<T>> pi_ref = pi;
1465 for (std::size_t a = 0; a < A; ++a) {
1466 const std::size_t k = m.act[a];
1467 if (qbd) {
1468 if (is_geom[k]) {
1469 if (m.mph[k] == 1) {
1470 // On a geometric tail the active label fires only from an
1471 // occupied state, so x = (per-state rate) * P(occupied).
1472 const std::size_t idx = std::min<std::size_t>(1, m.N[k] - 1);
1473 x[a] = T(a_rowsum[a][idx] * num_traits<T>::from_double(rho_proc[k]));
1474 } else {
1475 // Matrix-geometric tail: sum_{n>=1} pi_n = pi_1 (I-R)^-1,
1476 // and the active label has the same row sums at every
1477 // busy level.
1478 T acc = zero;
1479 for (std::size_t i = 0; i < m.mph[k]; ++i)
1480 acc += T(geom_data[k].busy[i] * a_rowsum[a][m.mph[k] + i]);
1481 x[a] = acc;
1482 }
1483 } else {
1484 T acc = zero;
1485 for (std::size_t i = 0; i < pi[k].size() && i < a_rowsum[a].size(); ++i)
1486 acc += T(pi[k][i] * a_rowsum[a][i]);
1487 x[a] = acc;
1488 }
1489 } else {
1490 // pi Aa, the row vector both estimators are read off.
1491 const std::vector<T> v = vecmul(pi[k], m.Aa[a]);
1492 if (method == "inapplus" || not_birth_death[k]) {
1493 // The departure rate of the active component.
1494 T sum = zero;
1495 for (std::size_t j = 0; j < v.size(); ++j) sum += v[j];
1496 if (sum > zero) x[a] = sum;
1497 } else {
1498 // The mean over the support of the STATE-WISE reversed rate
1499 // (pi Aa)_j / pi_j, which RCAT requires to be independent of
1500 // j. On a birth-death component every column of Aa holds one
1501 // entry, so this is the reference's entrywise mean of
1502 // Aa(i,j) pi(i) / pi(j) term for term.
1503 T sum = zero;
1504 std::size_t cnt = 0;
1505 for (std::size_t j : active_cols[a])
1506 if (pi[k][j] > zero && v[j] > zero) {
1507 sum += T(v[j] / pi[k][j]);
1508 ++cnt;
1509 }
1510 if (cnt > 0) x[a] = T(sum / num_traits<T>::from_int(static_cast<long>(cnt)));
1511 }
1512 }
1513 }
1514 if (qbd) compute_equilibrium_qbd(m, x, pi, Q, rho_proc, is_geom, geom_data);
1515 else compute_equilibrium(m, x, pi, Q, &opt, pool.get());
1516
1517 if (block_norm<T>(pi, pi_ref) < tol) {
1518 converged = true;
1519 break;
1520 }
1521 }
1522 // The reference's legacy while-loop leaves the counter one past the cap
1523 // when it never converged, and callers read that as the "did not converge"
1524 // marker; reproduced so the iteration counts agree.
1525 if (!converged) iter = static_cast<std::size_t>(opt.iter_max) + 1;
1526
1527 if (qbd) {
1528 double res = 0.0;
1529 for (std::size_t a = 0; a < A; ++a) {
1530 const std::size_t k = m.act[a];
1531 double s = 0.0;
1532 for (std::size_t j = 0; j < m.N[k]; ++j) {
1533 T acc = T(x[a] * pi[k][j]);
1534 for (std::size_t i = 0; i < m.N[k]; ++i) acc -= T(pi[k][i] * m.Aa[a](i, j));
1535 const double d = num_traits<T>::to_double(acc);
1536 s += d * d;
1537 }
1538 res = std::max(res, std::sqrt(s));
1539 }
1540 out.rcat_residual = res;
1541 }
1542
1543 out.sol = rcat_metrics(L, m, pi, rho_proc, is_geom, geom_data);
1544 out.sol.method = opt.method;
1545 out.sol.iter = static_cast<int>(iter);
1546 return out;
1547 } // if constexpr has_transcendental
1548}
1549
1550} // namespace ag
1551} // namespace line
1552
1553#endif // LINE_SOLVERS_AG_SOLVER_AG_H
Execution backends of the reversed-rate fixed point.
Options of the agent-based (RCAT) solver.
InputError(const std::string &what)
Definition error.h:39
UnsupportedError(const std::string &what)
Definition error.h:51
AgWorkerPool(const std::vector< std::string > &endpoints, double timeout_seconds)
A network plus its refreshed NetworkStruct.
Steady-state distribution of a continuous-time Markov chain.
What refreshProcessRepresentations and refreshLST compute FROM a distribution: the (D0,...
The exception types the port throws.
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
LU factorization with partial pivoting, templated on the number type.
The option and result types SolverMAM shares with its analyzers.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
Marked MAP (MMAP) algebra: per-class rates, class probabilities, superposition, normalization and sca...
void ag_sweep_cluster(std::size_t n, AgWorkerPool &workers, const std::vector< T > &x, Gen gen, Sol sol, Payload payload, std::vector< Matrix< T > > &Q, std::vector< std::vector< T > > &pi)
The same sweep with the agents partitioned over remote ag-worker processes.
Definition ag_exec.h:114
std::vector< std::array< double, 3 > > ag_triplets(const Matrix< double > &m)
Non-zero entries of M as [row, col, value] triplets, 0-based.
void ag_sweep_serial(std::size_t n, Gen gen, Sol sol, std::vector< Matrix< T > > &Q, std::vector< std::vector< T > > &pi)
Evaluate every agent of one sweep.
Definition ag_exec.h:64
Matrix< T > krons(const Matrix< T > &A, const Matrix< T > &B)
Kronecker sum, MATLAB's krons: kron(A, I_nb) + kron(I_na, B).
Definition mmap_lambda.h:71
Matrix< T > kron(const Matrix< T > &A, const Matrix< T > &B)
Kronecker product.
Definition mmap_lambda.h:57
const char * exec_cluster()
Partition the agents over remote ag-worker processes.
Definition ag_types.h:47
const char * exec_serial()
The caller's own loop, in agent order.
Definition ag_types.h:28
Matrix< T > qbd_R_logred(const Matrix< T > &B, const Matrix< T > &L, const Matrix< T > &F, unsigned iter_max, const T &tol)
R by logarithmic reduction (qbd_R_logred.m).
Definition qbd_r.h:217
AgResult< T > solver_ag(const qn::NetworkStruct< T > &L, const AgOptions &opt, std::size_t max_states=0)
Port of solver_ag.m.
Definition solver_ag.h:1308
void ag_sweep_parallel(std::size_t n, unsigned nworkers, Gen gen, Sol sol, std::vector< Matrix< T > > &Q, std::vector< std::vector< T > > &pi)
The same sweep over a thread pool.
Definition ag_exec.h:78
bool exec_is_parallel(const std::string &mode)
True for either spelling of the local-thread-pool backend.
Definition ag_types.h:43
bool rcat_supports_process(lang::ProcessType t)
The process types the RCAT construction can give a phase dimension to.
Definition solver_ag.h:1259
mam::Map< T > dist_to_map(const Distrib< T > &d)
SignalType
G-network signal classes, with the values of MATLAB SignalType.
Definition lang_types.h:167
@ NEGATIVE
removes a batch of jobs (Gelenbe's negative customer)
Definition lang_types.h:169
ProcessType
Distribution kinds, with the values of MATLAB ProcessType.
Definition lang_types.h:483
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
Matrix< T > krons(const Matrix< T > &A, const Matrix< T > &B)
Kronecker sum, MATLAB's krons: kron(A, I_nb) + kron(I_na, B).
Definition mmap_lambda.h:71
Matrix< T > kron(const Matrix< T > &A, const Matrix< T > &B)
Kronecker product.
Definition mmap_lambda.h:57
Matrix< T > qbd_R_logred(const Matrix< T > &B, const Matrix< T > &L, const Matrix< T > &F, unsigned iter_max, const T &tol)
R by logarithmic reduction (qbd_R_logred.m).
Definition qbd_r.h:217
Matrix< T > ctmc_makeinfgen(const Matrix< T > &Q)
Set the diagonal so that every row sums to zero (ctmc_makeinfgen).
Definition ctmc_solve.h:58
std::vector< T > ctmc_solve(const Matrix< T > &Qin)
Steady-state distribution of a continuous-time Markov chain.
Definition ctmc_solve.h:122
void lu_solve(const Matrix< T > &LU, const std::vector< std::size_t > &piv, std::vector< T > &b)
Solve LUx = Pb in place on b, using the factors from lu_factor.
Definition lu.h:94
std::vector< T > vecmul(const std::vector< T > &v, const Matrix< T > &A)
Row vector times matrix, v A.
Definition linalg.h:50
std::vector< std::size_t > lu_factor(Matrix< T > &A)
In-place LU of A (n x n).
Definition lu.h:48
Matrix< T > inverse(const Matrix< T > &A)
Inverse by LU with one factorization and n back substitutions.
Definition linalg.h:72
Matrix< T > matmul(const Matrix< T > &A, const Matrix< T > &B)
Matrix product A B.
Definition linalg.h:36
std::vector< T > mulvec(const Matrix< T > &A, const std::vector< T > &v)
Matrix times column vector, A v.
Definition linalg.h:62
std::vector< T > ones(std::size_t n)
Column vector of ones, the ubiquitous e in MAP algebra.
Definition linalg.h:104
Matrix< T > eye(std::size_t n)
Identity of order n.
Definition linalg.h:28
A queueing network and its refreshed NetworkStruct.
Number-type abstraction for the templated API port.
Quasi-birth-death processes: the rate matrix R, the fundamental matrix G, the caudal characteristic,...
What the RCAT analyzer returns beyond the metrics.
Definition solver_ag.h:1287
double rcat_residual
The RCAT product-form residual of Remark 2, max_l ||pi (x_l I - T_l)||_2, computed by 'inapinf' only.
Definition solver_ag.h:1296
mva::MvaSolution< T > sol
Definition solver_ag.h:1288
std::string actualmethod
Definition solver_ag.h:1289
Class-level results, the [Q,U,R,T,C,X] of the MATLAB analyzers.
Definition mva_types.h:96