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