LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_solve.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_SOLVE_H
6#define LINE_API_MC_CTMC_SOLVE_H
7
8/**
9 * @file
10 * @ingroup api_mc
11 * Steady-state distribution of a continuous-time Markov chain.
12 *
13 * Templated port of matlab/lib/kpctoolbox/mc/ctmc_solve.m (the numeric branch)
14 * and jar/src/main/java/jline/api/mc/Ctmc_solve.java. Solves pi Q = 0 with
15 * sum(pi) = 1 by replacing the last column of the generator with ones and
16 * solving Q' x = e_n, exactly as MATLAB's `Qnnz' \ bnnz` does.
17 *
18 * Every step is a field operation, so the exact instantiation returns pi as a
19 * vector of rationals: for a generator with rational rates that is the true
20 * stationary distribution with no rounding whatsoever, which is what makes the
21 * exact backend worth its cost here (the double path of the JAR needs 50.9 s
22 * at n=800 where this LU needs tens of ms, see _kb/14-cpp-multiprecision).
23 *
24 * Reducible generators are handled as MATLAB does: the weakly connected
25 * components are solved separately and renormalized. States that are ISOLATED
26 * (no transition in and none out) are trimmed first -- an absorbing state is NOT
27 * isolated and is kept, being where the stationary mass ends up -- and a
28 * generator that trims to nothing is an error rather than a plausible-looking
29 * uniform vector.
30 *
31 * ABOVE GMRES_MIN_STATES THE LU IS ABANDONED FOR THE KRYLOV PATH, as in the
32 * other three codebases: restarted GMRES first, BiCGSTAB if it reports a nonzero
33 * flag, and the direct solve only if both do. The gate is compile-time as well
34 * as size-based -- an exact instantiation always takes the LU, because the
35 * iteration stops on a residual tolerance and normalizes by a Euclidean norm, so
36 * there is nothing exact for it to converge to.
37 */
38
39#include <algorithm>
40#include <cstddef>
41#include <vector>
42
45#include "line/num/number.h"
46#include "line/util/error.h"
47#include "line/util/lu.h"
48#include "line/util/matrix.h"
49
50namespace line {
51namespace mc {
52
53/**
54 * Set the diagonal so that every row sums to zero (ctmc_makeinfgen).
55 * Any pre-existing diagonal entry is discarded, as in MATLAB.
56 */
57template <class T>
59 const std::size_t n = Q.rows();
60 if (Q.cols() != n) throw InputError("ctmc_makeinfgen: generator is not square");
61 Matrix<T> R = Q;
62 const T zero = num_traits<T>::from_int(0);
63 for (std::size_t i = 0; i < n; ++i) {
64 R(i, i) = zero;
65 T s = zero;
66 for (std::size_t j = 0; j < n; ++j) s += R(i, j);
67 R(i, i) = -s;
68 }
69 return R;
70}
71
72namespace detail {
73
74/** Weakly connected components of the graph of |Q + Q'| > 0. */
75template <class T>
76std::vector<std::vector<std::size_t>> weak_components(const Matrix<T>& Q) {
77 const std::size_t n = Q.rows();
78 const T zero = num_traits<T>::from_int(0);
79 std::vector<int> comp(n, -1);
80 std::vector<std::vector<std::size_t>> out;
81 for (std::size_t s = 0; s < n; ++s) {
82 if (comp[s] >= 0) continue;
83 std::vector<std::size_t> stack{s}, members;
84 comp[s] = static_cast<int>(out.size());
85 while (!stack.empty()) {
86 const std::size_t u = stack.back();
87 stack.pop_back();
88 members.push_back(u);
89 for (std::size_t v = 0; v < n; ++v) {
90 if (v == u || comp[v] >= 0) continue;
91 if (Q(u, v) != zero || Q(v, u) != zero) {
92 comp[v] = comp[u];
93 stack.push_back(v);
94 }
95 }
96 }
97 std::sort(members.begin(), members.end());
98 out.push_back(members);
99 }
100 return out;
101}
102
103/** Submatrix on the given index set, re-normalized as a generator. */
104template <class T>
105Matrix<T> submatrix(const Matrix<T>& Q, const std::vector<std::size_t>& idx) {
106 Matrix<T> S(idx.size(), idx.size());
107 for (std::size_t a = 0; a < idx.size(); ++a)
108 for (std::size_t b = 0; b < idx.size(); ++b) S(a, b) = Q(idx[a], idx[b]);
109 return S;
110}
111
112} // namespace detail
113
114/**
115 * @brief Steady-state distribution of a continuous-time Markov chain.
116 *
117 * @param Qin generator; the diagonal is recomputed, so an off-diagonal rate
118 * matrix is accepted directly
119 * @return stationary distribution as a row vector of length n, summing to one
120 */
121template <class T>
122std::vector<T> ctmc_solve(const Matrix<T>& Qin) {
123 const std::size_t n = Qin.rows();
124 if (Qin.cols() != n) throw InputError("ctmc_solve: generator is not square");
125 const T zero = num_traits<T>::from_int(0);
126 const T one = num_traits<T>::from_int(1);
127
128 if (n == 0) throw InputError("ctmc_solve: empty generator");
129 if (n == 1) return std::vector<T>{one};
130
131 const Matrix<T> Q = ctmc_makeinfgen(Qin);
132
133 bool allZero = true;
134 for (std::size_t i = 0; i < n && allZero; ++i)
135 for (std::size_t j = 0; j < n; ++j)
136 if (Q(i, j) != zero) {
137 allZero = false;
138 break;
139 }
140 if (allZero) {
141 // No transitions at all: every distribution satisfies pi Q = 0, so the
142 // stationary distribution is not unique and uniform is as good as any.
143 return std::vector<T>(n, one / num_traits<T>::from_int(static_cast<long>(n)));
144 }
145
146 // Reducible: solve each weakly connected component and renormalize.
147 const std::vector<std::vector<std::size_t>> comps = detail::weak_components(Q);
148 if (comps.size() > 1) {
149 std::vector<T> pi(n, zero);
150 for (const std::vector<std::size_t>& c : comps) {
151 const std::vector<T> pc = ctmc_solve(ctmc_makeinfgen(detail::submatrix(Q, c)));
152 for (std::size_t k = 0; k < c.size(); ++k) pi[c[k]] = pc[k];
153 }
154 T s = zero;
155 for (const T& v : pi) s += v;
156 if (s == zero) throw NumericError("ctmc_solve: components sum to zero");
157 for (T& v : pi) v /= s;
158 return pi;
159 }
160
161 // Trim ISOLATED states -- no transition in and none out -- repeatedly.
162 //
163 // AN ABSORBING STATE IS NOT ISOLATED AND IS KEPT. Its column carries the flow that
164 // reaches it, and it is where the stationary mass ends up; the trim used to also
165 // require a nonzero ROW, which dropped exactly that state, left its feeders with
166 // nothing to flow into, and cascaded through them until the generator was empty and
167 // this function refused a chain whose distribution is unique (Q = [0 0; 1 -1] has
168 // pi = [1 0]). The MATLAB and JAR twins carried the same rule and are fixed with
169 // this one; see _kb/06-solver-catalog.md, where it cost SolverMAM a host-dependent
170 // answer. The test here scans stored entries rather than a summed row, so it stays
171 // exact and needs no tolerance -- which also keeps it correct at T = Rational.
172 std::vector<std::size_t> keep(n);
173 for (std::size_t i = 0; i < n; ++i) keep[i] = i;
174 Matrix<T> Qk = Q;
175 for (;;) {
176 const std::size_t m = Qk.rows();
177 std::vector<std::size_t> active;
178 for (std::size_t i = 0; i < m; ++i) {
179 bool colNz = false;
180 for (std::size_t j = 0; j < m; ++j) {
181 if (Qk(j, i) != zero) { colNz = true; break; }
182 }
183 if (colNz) active.push_back(i);
184 }
185 if (active.empty())
186 throw NumericError(
187 "ctmc_solve: the generator has no connected state, every state was eliminated as "
188 "isolated; it admits no unique stationary distribution");
189 if (active.size() == m) break;
190 std::vector<std::size_t> keep2(active.size());
191 for (std::size_t k = 0; k < active.size(); ++k) keep2[k] = keep[active[k]];
192 keep = keep2;
193 Qk = ctmc_makeinfgen(detail::submatrix(Qk, active));
194 }
195
196 // Replace the last column with ones and solve the transposed system.
197 const std::size_t m = Qk.rows();
198 Matrix<T> A(m, m);
199 for (std::size_t i = 0; i < m; ++i)
200 for (std::size_t j = 0; j < m; ++j) A(i, j) = (i == m - 1) ? one : Qk(j, i);
201 std::vector<T> b(m, zero);
202 b[m - 1] = one;
203
204 // The iterative path. Its accuracy is a residual tolerance of 1e-12, tighter
205 // than any fixed-point tolerance a caller sets, so switching to it cannot
206 // move a reported metric; its failure modes are reported through the flag
207 // rather than thrown, which is what makes the fallback chain possible.
208 std::vector<T> x;
209 if constexpr (num_traits<T>::has_transcendental) {
210 if (m > GMRES_MIN_STATES) {
211 const GmresResult<T> g = ctmc_gmres(A, b);
212 if (g.flag == 0) {
213 x = g.x;
214 } else {
215 const BicgstabResult<T> bs = ctmc_bicgstab(A, b);
216 if (bs.flag == 0) x = bs.x;
217 }
218 }
219 }
220 if (x.empty()) x = solve(A, b);
221
222 std::vector<T> pi(n, zero);
223 for (std::size_t k = 0; k < m; ++k) pi[keep[k]] = x[k];
224 return pi;
225}
226
227} // namespace mc
228} // namespace line
229
230#endif // LINE_API_MC_CTMC_SOLVE_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
Preconditioned stabilized biconjugate gradients, for the linear systems a generator produces.
Restarted GMRES with an ILUT preconditioner, for the linear systems a generator produces.
The exception types the port throws.
LU factorization with partial pivoting, templated on the number type.
Dense matrix and non-owning view.
BicgstabResult< T > ctmc_bicgstab(const Matrix< T > &A, const std::vector< T > &b, double tol=1e-12, long maxit=0, const std::vector< T > &x0=std::vector< T >())
Preconditioned stabilized biconjugate gradients, for the linear systems a generator produces.
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
constexpr std::size_t GMRES_MIN_STATES
Order above which the direct sparse factorization is abandoned in favour of the Krylov path.
Definition ctmc_gmres.h:75
GmresResult< T > ctmc_gmres(const Matrix< T > &A, const std::vector< T > &b, double tol=1e-12, long restart=0, long maxit=0, const std::vector< T > &x0=std::vector< T >())
Restarted GMRES with an ILUT preconditioner, for the linear systems a generator produces.
Definition ctmc_gmres.h:653
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
Number-type abstraction for the templated API port.
std::vector< T > x
solution
int flag
0 converged, 1 iteration limit, 3 stagnation, 4 breakdown
int flag
0 converged, 1 iteration limit, 3 stagnation/divergence
Definition ctmc_gmres.h:80
std::vector< T > x
solution
Definition ctmc_gmres.h:79