LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_courtois.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_MC_CTMC_COURTOIS_H
6#define LINE_API_MC_CTMC_COURTOIS_H
7
8/**
9 * @file
10 * @ingroup api_mc
11 * Courtois decomposition of a nearly completely decomposable (NCD) CTMC.
12 *
13 * Templated port of matlab/src/api/mc/ctmc_courtois.m and
14 * jar/src/main/java/jline/api/mc/Ctmc_courtois.java. The states are permuted
15 * into macro-state order; the coupling between macro-states is deleted to give
16 * a block-diagonal generator Qdec whose blocks are solved independently for
17 * the conditional (micro) distributions; the macro-state chain G is assembled
18 * from the uniformized matrix weighted by those micro distributions; and the
19 * approximation is the product of the macro and micro probabilities, mapped
20 * back to the original state ordering.
21 *
22 * The approximation error is governed by the degree of coupling eps, which is
23 * meaningful only when it is small against epsMAX = (1 - max_i mu_i)/2, mu_i
24 * being the subdominant eigenvalue modulus of the i-th diagonal block made
25 * stochastic. Both diagnostics are always computed here; MATLAB computes them
26 * only when more than three outputs are requested and returns eps = 0
27 * otherwise, which is a trap for a caller who asks for two outputs and reads
28 * the second as a coupling measure.
29 *
30 * WHICH SUM eps IS, and the divergence that used to live here. The NCD index of
31 * Courtois is the largest ROW sum of the coupling matrix, ||B||_inf, the
32 * largest probability of leaving a macro-state in one uniformized step. MATLAB
33 * once wrote it `max(sum(B))`, and `sum(B)` on a matrix is the vector of COLUMN
34 * sums, so its eps was the largest column sum instead; `Ctmc_courtois.java`
35 * computed the row sum (`B.sumRows().elementMax()`) and the two separated
36 * whenever any state carried coupling to more than one other macro-state.
37 * MATLAB is `max(sum(B,2))` as of 2026-08-15 and the three codebases agree, so
38 * `eps` here is the ROW sum. `epsColMax` keeps the old column-sum value,
39 * because a golden or a comparison recorded before that date holds it: on the
40 * six-state fixture of test_mc_aggregation the row sum is 0.0076190 and the
41 * column sum 0.013333, a factor of 1.75, and the column sum is the OPTIMISTIC
42 * one -- it understates the coupling, which is the wrong direction for a
43 * diagnostic answering "is this partition decomposable enough to trust".
44 *
45 * GATED ON TRANSCENDENTAL ARITHMETIC. epsMAX is a subdominant eigenvalue
46 * modulus, obtained by the Francis QR iteration, which is iterative and stops
47 * on a tolerance; it has no closed form in the field of the rates (the
48 * eigenvalues of a rational matrix are algebraic, not rational). The rest of
49 * the method -- permutation, block solves, aggregation, disaggregation -- is
50 * finite and exact, so a caller who needs the approximate stationary vector at
51 * Rational and no diagnostics can assemble it from ctmc_solve_reducible and
52 * dtmc_solve directly.
53 */
54
55#include <algorithm>
56#include <cmath>
57#include <cstddef>
58#include <limits>
59#include <vector>
60
65#include "line/num/number.h"
66#include "line/util/error.h"
67#include "line/util/matrix.h"
68
69namespace line {
70namespace mc {
71
72namespace detail {
73
74/**
75 * Moduli of the eigenvalues of a real square matrix, ascending.
76 *
77 * Householder-free elimination to upper Hessenberg form followed by the
78 * Francis implicit double-shift QR iteration, in real arithmetic, so a complex
79 * conjugate pair is deflated as a 2 x 2 block and never needs complex storage.
80 * This is the classical hqr of Wilkinson and Reinsch. The convergence tests are
81 * relative to the machine epsilon of T, so raising the precision of T tightens
82 * them rather than leaving them at double resolution.
83 *
84 * Indices are one-based throughout, as in the original algorithm; the working
85 * array is therefore (n+1) x (n+1) with row and column zero unused. Departing
86 * from that indexing is the classic way to introduce an off-by-one into this
87 * particular routine.
88 */
89template <class T>
90std::vector<T> eig_moduli(const Matrix<T>& Ain) {
91 const std::size_t n = Ain.rows();
92 if (Ain.cols() != n) throw InputError("eig_moduli: matrix is not square");
93 const T zero = num_traits<T>::from_int(0);
94 if (n == 0) return std::vector<T>();
95 if (n == 1) return std::vector<T>(1, num_abs(T(Ain(0, 0))));
96
97 Matrix<T> a(n + 1, n + 1, zero);
98 for (std::size_t i = 1; i <= n; ++i)
99 for (std::size_t j = 1; j <= n; ++j) a(i, j) = Ain(i - 1, j - 1);
100
101 // Reduction to upper Hessenberg form by elimination with pivoting.
102 for (std::size_t m = 2; m < n; ++m) {
103 T x = zero;
104 std::size_t piv = m;
105 for (std::size_t j = m; j <= n; ++j)
106 if (num_abs(T(a(j, m - 1))) > num_abs(T(x))) {
107 x = a(j, m - 1);
108 piv = j;
109 }
110 if (piv != m) {
111 for (std::size_t j = m - 1; j <= n; ++j) std::swap(a(piv, j), a(m, j));
112 for (std::size_t j = 1; j <= n; ++j) std::swap(a(j, piv), a(j, m));
113 }
114 if (x != zero) {
115 for (std::size_t i = m + 1; i <= n; ++i) {
116 T y = a(i, m - 1);
117 if (y == zero) continue;
118 y /= x;
119 a(i, m - 1) = y;
120 for (std::size_t j = m; j <= n; ++j) a(i, j) -= y * a(m, j);
121 for (std::size_t j = 1; j <= n; ++j) a(j, m) += y * a(j, i);
122 }
123 }
124 }
125 // The elimination multipliers left below the subdiagonal are not part of
126 // the Hessenberg matrix and must go before the QR iteration reads them.
127 for (std::size_t i = 3; i <= n; ++i)
128 for (std::size_t j = 1; j + 1 < i; ++j) a(i, j) = zero;
129
130 const T epsT = std::numeric_limits<T>::epsilon();
131 T anorm = zero;
132 for (std::size_t i = 1; i <= n; ++i)
133 for (std::size_t j = (i > 1 ? i - 1 : 1); j <= n; ++j) anorm += num_abs(T(a(i, j)));
134
135 std::vector<T> wr(n + 1, zero), wi(n + 1, zero);
136 std::size_t nn = n;
137 T t = zero;
138 while (nn >= 1) {
139 int its = 0;
140 std::size_t l = 0;
141 do {
142 for (l = nn; l >= 2; --l) {
143 T s = num_abs(T(a(l - 1, l - 1))) + num_abs(T(a(l, l)));
144 if (s == zero) s = anorm;
145 if (num_abs(T(a(l, l - 1))) <= epsT * s) {
146 a(l, l - 1) = zero;
147 break;
148 }
149 }
150 if (l < 2) l = 1;
151 T x = a(nn, nn);
152 if (l == nn) {
153 wr[nn] = x + t;
154 wi[nn] = zero;
155 --nn;
156 } else {
157 T y = a(nn - 1, nn - 1);
158 T w = a(nn, nn - 1) * a(nn - 1, nn);
159 if (l == nn - 1) {
160 const T p = (y - x) / num_traits<T>::from_int(2);
161 const T q = p * p + w;
162 using std::sqrt;
163 T z = sqrt(T(num_abs(T(q))));
164 x += t;
165 if (!(q < zero)) {
166 z = p + (p < zero ? T(-z) : z);
167 wr[nn - 1] = x + z;
168 wr[nn] = (z != zero) ? T(x - w / z) : T(x + z);
169 wi[nn - 1] = zero;
170 wi[nn] = zero;
171 } else {
172 wr[nn - 1] = x + p;
173 wr[nn] = x + p;
174 wi[nn] = z;
175 wi[nn - 1] = -z;
176 }
177 nn -= 2;
178 } else {
179 if (its == 60) throw NumericError("eig_moduli: QR iteration did not converge");
180 if (its == 10 || its == 20 || its == 30 || its == 40 || its == 50) {
181 // Exceptional shift, to break a cycle the Wilkinson
182 // shift cannot resolve.
183 t += x;
184 for (std::size_t i = 1; i <= nn; ++i) a(i, i) -= x;
185 const T s = num_abs(T(a(nn, nn - 1))) + num_abs(T(a(nn - 1, nn - 2)));
186 x = num_traits<T>::from_rational(3, 4) * s;
187 y = x;
188 w = -num_traits<T>::from_rational(7, 16) * s * s;
189 }
190 ++its;
191 std::size_t m = nn - 2;
192 T p = zero, q = zero, r = zero;
193 for (; m >= l; --m) {
194 const T z = a(m, m);
195 const T rr = x - z;
196 const T ss = y - z;
197 p = (rr * ss - w) / a(m + 1, m) + a(m, m + 1);
198 q = a(m + 1, m + 1) - z - rr - ss;
199 r = a(m + 2, m + 1);
200 const T s = num_abs(T(p)) + num_abs(T(q)) + num_abs(T(r));
201 p /= s;
202 q /= s;
203 r /= s;
204 if (m == l) break;
205 const T u = num_abs(T(a(m, m - 1))) * (num_abs(T(q)) + num_abs(T(r)));
206 const T v = num_abs(T(p)) *
207 (num_abs(T(a(m - 1, m - 1))) + num_abs(T(z)) + num_abs(T(a(m + 1, m + 1))));
208 if (u <= epsT * v) break;
209 }
210 for (std::size_t i = m + 2; i <= nn; ++i) {
211 a(i, i - 2) = zero;
212 if (i != m + 2) a(i, i - 3) = zero;
213 }
214 for (std::size_t k = m; k <= nn - 1; ++k) {
215 if (k != m) {
216 p = a(k, k - 1);
217 q = a(k + 1, k - 1);
218 r = zero;
219 if (k != nn - 1) r = a(k + 2, k - 1);
220 x = num_abs(T(p)) + num_abs(T(q)) + num_abs(T(r));
221 if (x != zero) {
222 p /= x;
223 q /= x;
224 r /= x;
225 }
226 }
227 using std::sqrt;
228 T s = sqrt(T(p * p + q * q + r * r));
229 if (p < zero) s = -s;
230 if (s == zero) continue;
231 if (k == m) {
232 if (l != m) a(k, k - 1) = -a(k, k - 1);
233 } else {
234 a(k, k - 1) = -s * x;
235 }
236 p += s;
237 x = p / s;
238 y = q / s;
239 const T z = r / s;
240 q /= p;
241 r /= p;
242 for (std::size_t j = k; j <= nn; ++j) {
243 T pp = a(k, j) + q * a(k + 1, j);
244 if (k != nn - 1) {
245 pp += r * a(k + 2, j);
246 a(k + 2, j) -= pp * z;
247 }
248 a(k + 1, j) -= pp * y;
249 a(k, j) -= pp * x;
250 }
251 const std::size_t mmin = nn < k + 3 ? nn : k + 3;
252 for (std::size_t i = l; i <= mmin; ++i) {
253 T pp = x * a(i, k) + y * a(i, k + 1);
254 if (k != nn - 1) {
255 pp += z * a(i, k + 2);
256 a(i, k + 2) -= pp * r;
257 }
258 a(i, k + 1) -= pp * q;
259 a(i, k) -= pp;
260 }
261 }
262 }
263 }
264 } while (nn >= 2 && l < nn - 1);
265 }
266
267 std::vector<T> mod(n);
268 using std::sqrt;
269 for (std::size_t i = 1; i <= n; ++i) mod[i - 1] = sqrt(T(wr[i] * wr[i] + wi[i] * wi[i]));
270 std::sort(mod.begin(), mod.end());
271 return mod;
272}
273
274/** Concatenation of the macro-state index sets, the permutation v of MATLAB. */
275inline std::vector<std::size_t> macrostate_permutation(const std::vector<std::vector<std::size_t>>& MS,
276 std::size_t n) {
277 std::vector<std::size_t> v;
278 std::vector<char> seen(n, 0);
279 for (const std::vector<std::size_t>& b : MS)
280 for (std::size_t k : b) {
281 if (k >= n) throw InputError("ctmc_courtois: macro-state index out of range");
282 if (seen[k]) throw InputError("ctmc_courtois: state listed in more than one macro-state");
283 seen[k] = 1;
284 v.push_back(k);
285 }
286 if (v.size() != n) throw InputError("ctmc_courtois: the macro-states do not cover every state");
287 return v;
288}
289
290/** Zeroes every entry outside the diagonal blocks of the given sizes. */
291template <class T>
292void zero_offblock(Matrix<T>& A, const std::vector<std::vector<std::size_t>>& MS) {
293 const std::size_t n = A.rows();
294 const T zero = num_traits<T>::from_int(0);
295 std::size_t proc = 0;
296 for (const std::vector<std::size_t>& b : MS) {
297 const std::size_t sz = b.size();
298 for (std::size_t row = proc; row < proc + sz; ++row) {
299 for (std::size_t col = 0; col < proc; ++col) A(row, col) = zero;
300 for (std::size_t col = proc + sz; col < n; ++col) A(row, col) = zero;
301 }
302 proc += sz;
303 }
304}
305
306/**
307 * Everything the Courtois construction produces before the macro-state chain is
308 * solved. ctmc_multi differs from ctmc_courtois only in HOW that chain is
309 * solved -- by a second Courtois decomposition rather than directly -- so the
310 * shared part lives here and neither routine reimplements it.
311 */
312template <class T>
313struct CourtoisCore {
314 std::vector<std::size_t> v; ///< macro-state-major permutation
315 Matrix<T> Qperm, Qdec, P, B;
316 std::vector<T> pmicro; ///< conditional distributions, permuted order
317 Matrix<T> G; ///< macro-state transition matrix
318 T eps, epsRowMax, epsColMax, epsMAX, q;
319};
320
321template <class T>
322CourtoisCore<T> courtois_core(const Matrix<T>& Q, const std::vector<std::vector<std::size_t>>& MS,
323 const T& q) {
324 const std::size_t n = Q.rows();
325 if (Q.cols() != n) throw InputError("ctmc_courtois: generator is not square");
326 if (MS.empty()) throw InputError("ctmc_courtois: no macro-states given");
327 const T zero = num_traits<T>::from_int(0);
328 const T one = num_traits<T>::from_int(1);
329 const std::size_t nMacro = MS.size();
330
331 CourtoisCore<T> r;
332 r.q = q;
333 r.v = macrostate_permutation(MS, n);
334 r.Qperm = submatrix(Q, r.v);
335
336 r.Qdec = r.Qperm;
337 zero_offblock(r.Qdec, MS);
338 r.Qdec = ctmc_makeinfgen(r.Qdec);
339
340 r.P = ctmc_randomization(r.Qperm, q).P;
341
342 Matrix<T> A = r.P;
343 zero_offblock(A, MS);
344 r.B = Matrix<T>(n, n);
345 for (std::size_t i = 0; i < n; ++i)
346 for (std::size_t j = 0; j < n; ++j) r.B(i, j) = r.P(i, j) - A(i, j);
347
348 r.epsColMax = zero;
349 for (std::size_t j = 0; j < n; ++j) {
350 T cs = zero;
351 for (std::size_t i = 0; i < n; ++i) cs += r.B(i, j);
352 if (j == 0 || cs > r.epsColMax) r.epsColMax = cs;
353 }
354 r.eps = zero;
355 for (std::size_t i = 0; i < n; ++i) {
356 T rs = zero;
357 for (std::size_t j = 0; j < n; ++j) rs += r.B(i, j);
358 if (i == 0 || rs > r.eps) r.eps = rs;
359 }
360 r.epsRowMax = r.eps;
361
362 // epsMAX: make each diagonal block stochastic by absorbing the row deficit
363 // into its diagonal, then take its subdominant eigenvalue modulus.
364 {
365 Matrix<T> As = A;
366 std::size_t proc = 0;
367 for (const std::vector<std::size_t>& b : MS) {
368 const std::size_t sz = b.size();
369 for (std::size_t i = 0; i < sz; ++i) {
370 T off = zero;
371 for (std::size_t j = 0; j < sz; ++j)
372 if (j != i) off += As(proc + i, proc + j);
373 As(proc + i, proc + i) = one - off;
374 }
375 proc += sz;
376 }
377 T maxSub = zero;
378 proc = 0;
379 for (const std::vector<std::size_t>& b : MS) {
380 const std::size_t sz = b.size();
381 if (sz > 1) {
382 Matrix<T> blk(sz, sz);
383 for (std::size_t i = 0; i < sz; ++i)
384 for (std::size_t j = 0; j < sz; ++j) blk(i, j) = As(proc + i, proc + j);
385 const std::vector<T> mod = eig_moduli(blk);
386 const T sub = mod[mod.size() - 2];
387 if (sub > maxSub) maxSub = sub;
388 }
389 proc += sz;
390 }
391 r.epsMAX = (one - maxSub) / num_traits<T>::from_int(2);
392 }
393
394 // Microprobabilities, one decoupled block at a time.
395 r.pmicro.assign(n, zero);
396 {
397 std::size_t proc = 0;
398 for (const std::vector<std::size_t>& b : MS) {
399 const std::size_t sz = b.size();
400 Matrix<T> blk(sz, sz);
401 for (std::size_t i = 0; i < sz; ++i)
402 for (std::size_t j = 0; j < sz; ++j) blk(i, j) = r.Qdec(proc + i, proc + j);
403 const std::vector<T> pb = ctmc_solve_reducible(blk).pi;
404 for (std::size_t i = 0; i < sz; ++i) r.pmicro[proc + i] = pb[i];
405 proc += sz;
406 }
407 }
408
409 // Macro-state chain, weighted by the micro distributions.
410 r.G = Matrix<T>(nMacro, nMacro, zero);
411 std::size_t procRows = 0;
412 for (std::size_t i = 0; i < nMacro; ++i) {
413 std::size_t procCols = 0;
414 for (std::size_t j = 0; j < nMacro; ++j) {
415 if (i != j) {
416 T acc = zero;
417 for (std::size_t a = 0; a < MS[i].size(); ++a) {
418 T s = zero;
419 for (std::size_t b = 0; b < MS[j].size(); ++b) s += r.P(procRows + a, procCols + b);
420 acc += r.pmicro[procRows + a] * s;
421 }
422 r.G(i, j) = acc;
423 }
424 procCols += MS[j].size();
425 }
426 procRows += MS[i].size();
427 }
428 for (std::size_t i = 0; i < nMacro; ++i) {
429 T rs = zero;
430 for (std::size_t j = 0; j < nMacro; ++j)
431 if (j != i) rs += r.G(i, j);
432 r.G(i, i) = one - rs;
433 }
434 return r;
435}
436
437/** The rate MATLAB derives when none is supplied, (21/20) max|Qperm|. */
438template <class T>
439T courtois_default_rate(const Matrix<T>& Q, const std::vector<std::vector<std::size_t>>& MS) {
440 const std::vector<std::size_t> v = macrostate_permutation(MS, Q.rows());
441 const T m = ctmc_maxabs(submatrix(Q, v));
442 if (m == num_traits<T>::from_int(0)) throw InputError("ctmc_courtois: the generator has no transitions");
443 return T(m * num_traits<T>::from_rational(21, 20));
444}
445
446/** Scatters a permuted vector back to the original state ordering. */
447template <class T>
448std::vector<T> unpermute_states(const std::vector<T>& pperm, const std::vector<std::size_t>& v) {
449 std::vector<T> p(pperm.size(), num_traits<T>::from_int(0));
450 for (std::size_t i = 0; i < v.size(); ++i) p[v[i]] = pperm[i];
451 return p;
452}
453
454} // namespace detail
455
456template <class T>
458 std::vector<T> p; ///< approximate stationary vector, ORIGINAL state ordering
459 std::vector<std::size_t> v; ///< macro-state-major permutation used
460 Matrix<T> Qperm; ///< Q reordered by macro-state
461 Matrix<T> Qdec; ///< block-diagonal generator of the decoupled chain
462 Matrix<T> P; ///< uniformized Qperm
463 Matrix<T> B; ///< the coupling part of P, P minus its block diagonal
464 T C; ///< the reference's degenerate output, identically zero
465 T eps; ///< NCD index: largest ROW sum of B, ||B||_inf (MATLAB and the JAR)
466 T epsRowMax; ///< the same quantity under the name it had when only the JAR computed it
467 T epsColMax; ///< largest COLUMN sum of B: what MATLAB reported before 2026-08-15
468 T epsMAX; ///< (1 - max subdominant block eigenvalue modulus) / 2
469 T q; ///< uniformization rate used
470};
471
472/**
473 * @brief Courtois decomposition of a nearly completely decomposable (NCD)
474 * CTMC.
475 *
476 * @param Q generator
477 * @param MS macro-states, MS[i] listing the states of macro-state i; the sets
478 * must partition 0..n-1
479 * @param q uniformization rate
480 */
481template <class T>
482CourtoisResult<T> ctmc_courtois(const Matrix<T>& Q, const std::vector<std::vector<std::size_t>>& MS,
483 const T& q) {
485 "ctmc_courtois requires transcendental arithmetic: epsMAX is a subdominant "
486 "eigenvalue modulus, computed by an iterative QR that stops on a tolerance and "
487 "has no rational closed form");
488 const detail::CourtoisCore<T> c = detail::courtois_core(Q, MS, q);
489 const std::vector<T> pMacro = dtmc_solve(c.G);
490
491 std::vector<T> pperm(Q.rows(), num_traits<T>::from_int(0));
492 std::size_t proc = 0;
493 for (std::size_t i = 0; i < MS.size(); ++i) {
494 for (std::size_t a = 0; a < MS[i].size(); ++a) pperm[proc + a] = pMacro[i] * c.pmicro[proc + a];
495 proc += MS[i].size();
496 }
497
499 r.p = detail::unpermute_states(pperm, c.v);
500 r.v = c.v;
501 r.Qperm = c.Qperm;
502 r.Qdec = c.Qdec;
503 r.P = c.P;
504 r.B = c.B;
506 r.eps = c.eps;
507 r.epsRowMax = c.epsRowMax;
508 r.epsColMax = c.epsColMax;
509 r.epsMAX = c.epsMAX;
510 r.q = c.q;
511 return r;
512}
513
514/** Overload deriving the rate as MATLAB does, q = (21/20) max|Qperm|. */
515template <class T>
516CourtoisResult<T> ctmc_courtois(const Matrix<T>& Q, const std::vector<std::vector<std::size_t>>& MS) {
517 return ctmc_courtois(Q, MS, detail::courtois_default_rate(Q, MS));
518}
519
520} // namespace mc
521} // namespace line
522
523#endif // LINE_API_MC_CTMC_COURTOIS_H
InputError(const std::string &what)
Definition error.h:39
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
Uniformization (randomization) of a CTMC: the embedded DTMC P = I + Q/q.
Steady-state distribution of a continuous-time Markov chain.
Limiting distribution of a CTMC whose generator may be reducible.
Equilibrium distribution of a discrete-time Markov chain, and stochastic complementation.
The exception types the port throws.
Dense matrix and non-owning view.
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
T ctmc_maxabs(const Matrix< T > &Q)
Largest magnitude of any entry of Q; equals max_i |q_ii| for a generator.
ReducibleResult< T > ctmc_solve_reducible(const Matrix< T > &Q, const std::vector< T > &pi0, double zeroColTol=1e-12)
Limiting distribution of a CTMC whose generator may be reducible.
std::vector< T > dtmc_solve(const Matrix< T > &P)
Stationary distribution of a stochastic matrix P.
Definition dtmc_solve.h:106
RandomizationResult< T > ctmc_randomization(const Matrix< T > &Q, const T &q)
Uniformization (randomization) of a CTMC: the embedded DTMC P = I + Q/q.
CourtoisResult< T > ctmc_courtois(const Matrix< T > &Q, const std::vector< std::vector< std::size_t > > &MS, const T &q)
Courtois decomposition of a nearly completely decomposable (NCD) CTMC.
T num_abs(const T &v)
Definition number.h:172
Number-type abstraction for the templated API port.
Matrix< T > Qdec
block-diagonal generator of the decoupled chain
T C
the reference's degenerate output, identically zero
T q
uniformization rate used
Matrix< T > P
uniformized Qperm
std::vector< std::size_t > v
macro-state-major permutation used
Matrix< T > B
the coupling part of P, P minus its block diagonal
T eps
NCD index: largest ROW sum of B, ||B||_inf (MATLAB and the JAR).
T epsRowMax
the same quantity under the name it had when only the JAR computed it
std::vector< T > p
approximate stationary vector, ORIGINAL state ordering
T epsMAX
(1 - max subdominant block eigenvalue modulus) / 2
T epsColMax
largest COLUMN sum of B: what MATLAB reported before 2026-08-15
Matrix< T > Qperm
Q reordered by macro-state.