LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_fau.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_FAU_H
6#define LINE_API_MC_CTMC_FAU_H
7
8/**
9 * @file
10 * @ingroup api_mc
11 * Transient distribution of a CTMC by fast adaptive uniformization.
12 *
13 * Templated port of matlab/src/api/mc/ctmc_fau.m and
14 * jar/src/main/java/jline/api/mc/Ctmc_fau.java.
15 *
16 * Ordinary uniformization fixes one rate q >= max_i |q_ii| over the WHOLE state
17 * space and mixes the powers of P = I + Q/q against a Poisson(q t) law, so its
18 * cost is set by the fastest state anywhere, including states carrying no
19 * probability at time t. Adaptive uniformization (van Moorsel and Sanders,
20 * 1994) picks a rate per step from the states the iterate occupies,
21 *
22 * Lambda_n >= max{|q_ii| : i in supp(u^(n))}, u^(n+1) = u^(n)(I + Q/Lambda_n),
23 *
24 * which keeps every entry of u^(n+1) nonnegative. The subordinating process is
25 * then the pure birth process N(t) with rates Lambda_0, Lambda_1, ... and
26 * pi(t) = sum_n P{N(t) = n} u^(n). The fast variant (Mateescu, Wolf, Didier and
27 * Henzinger, 2010) drops an entry below delta rather than propagating it, so
28 * the support tracks the states of non-negligible occupancy instead of the
29 * reachable set.
30 *
31 * NOTHING IS RENORMALIZED, so the error is measured rather than estimated: the
32 * birth index truncated at K, the Poisson window of the weight computation and
33 * the delta threshold each remove mass and none puts any back, whence
34 * 0 <= pi(t) - pit componentwise and |pi(t) - pit|_1 = sum(pi0) - sum(pit),
35 * which is what errorBound reports.
36 *
37 * The birth weights are exact rather than quadratured: the rates generate a
38 * bidiagonal generator on the birth index plus one absorbing overflow index,
39 * and its transient distribution is obtained by uniformizing that scalar chain
40 * at Lstar = max_n Lambda_n and mixing the shipped Fox-Glynn weights. The sweep
41 * runs twice because b_n(t) needs the rates up to n, which are not known before
42 * the sweep ends, while u^(n) is needed after them, and storing every iterate
43 * would cost K times the support.
44 *
45 * GATED ON TRANSCENDENTAL ARITHMETIC, exactly as ctmc_foxglynn is: the stopping
46 * rule is a logarithmic tail bound and the mixture is an approximation of
47 * exp(Qt) controlled by epsilon, so the rate bookkeeping and the truncation
48 * decisions are taken in double while the iterate and the weighted sum run in
49 * T, which is what a high-precision instantiation buys.
50 *
51 * This is a transient method: it produces no stationary distribution.
52 */
53
54#include <algorithm>
55#include <cmath>
56#include <cstddef>
57#include <iterator>
58#include <vector>
59
61#include "line/num/number.h"
62#include "line/util/error.h"
63#include "line/util/matrix.h"
64
65namespace line {
66namespace mc {
67
68/** Default cap on birth steps, so a pathological horizon reports truncation. */
69constexpr long FAU_MAX_STEPS = 1000000;
70
71template <class T>
72struct FauResult {
73 std::vector<T> pit; ///< defective distribution at t, a lower bound on pi(t)
74 long steps = 0; ///< number of birth steps K+1 actually taken
75 double lambdaMin = 0.0; ///< smallest adaptive rate used
76 double lambdaMax = 0.0; ///< largest adaptive rate used, the Lstar of the weights
77 double uniformRate = 0.0;///< max_i |q_ii|, the rate ordinary uniformization would use
78 T weightTail; ///< mass reaching the overflow index, i.e. P{N(t) > K}
79 T weightWindow; ///< Poisson mass outside the Fox-Glynn window
80 T droppedMass; ///< probability removed by the occupancy threshold
81 T errorBound; ///< sum(pi0) - sum(pit), which IS the L1 error
82 std::size_t supportMax = 0; ///< largest occupied support over the sweep
83 std::size_t supportFinal = 0; ///< support at the last step
84 bool truncated = false; ///< maxsteps stopped the sweep
85 bool absorbed = false; ///< the support emptied or became absorbing
86};
87
88namespace detail {
89
90/**
91 * Upper bound on P{N(t) >= k} for the birth process, through the stochastic
92 * domination of its k-th jump epoch by an Erlang(k, lstar): the bound is the
93 * Poisson(lstar t) upper tail P{X >= k} at its Chernoff exponent. That exponent
94 * bounds the upper tail only above the mean, so below it the bound is vacuous.
95 */
96inline double fau_tailbound(double lstar, double t, long k) {
97 const double lambda = lstar * t;
98 const double kd = static_cast<double>(k);
99 if (lambda <= 0.0 || kd <= lambda) return 1.0;
100 return std::exp(-(lambda - kd + kd * std::log(kd / lambda)));
101}
102
103/** Indices holding positive mass, ascending. */
104template <class T>
105std::vector<std::size_t> fau_support(const std::vector<T>& u) {
106 std::vector<std::size_t> act;
107 for (std::size_t i = 0; i < u.size(); ++i)
108 if (u[i] > num_traits<T>::from_int(0)) act.push_back(i);
109 return act;
110}
111
112/** Largest exit rate over the occupied states. */
113inline double fau_max_exit(const std::vector<double>& d, const std::vector<std::size_t>& act) {
114 double L = 0.0;
115 for (std::size_t k = 0; k < act.size(); ++k)
116 if (d[act[k]] > L) L = d[act[k]];
117 return L;
118}
119
120/**
121 * One adaptive uniformization step u <- u(I + Q/L), reading only the rows of Q
122 * in the current support, followed by the drop rule. A state with a zero exit
123 * rate is absorbing: its row of Q is empty, so it holds its mass and stays in
124 * the support. DROPPED, when not null, accumulates the mass the threshold
125 * removes.
126 */
127template <class T>
128std::vector<std::size_t> fau_step(std::vector<T>& u, const std::vector<std::size_t>& act,
129 const Matrix<T>& Q, const T& L, const T& delta,
130 std::vector<T>& scratch, std::vector<char>& touchedFlag,
131 T* dropped) {
132 const T zero = num_traits<T>::from_int(0);
133 const std::size_t n = u.size();
134 std::vector<std::size_t> touched;
135 for (std::size_t k = 0; k < act.size(); ++k) {
136 const std::size_t i = act[k];
137 const T& ui = u[i];
138 for (std::size_t j = 0; j < n; ++j) {
139 const T& qij = Q(i, j);
140 if (qij == zero) continue;
141 if (!touchedFlag[j]) {
142 touchedFlag[j] = 1;
143 touched.push_back(j);
144 }
145 scratch[j] += ui * qij;
146 }
147 }
148 if (touched.empty()) return act;
149 std::sort(touched.begin(), touched.end());
150
151 std::vector<std::size_t> written;
152 written.reserve(touched.size());
153 for (std::size_t k = 0; k < touched.size(); ++k) {
154 const std::size_t j = touched[k];
155 const T contrib = scratch[j];
156 scratch[j] = zero;
157 touchedFlag[j] = 0;
158 // A row that cancels exactly leaves its state untouched; the union
159 // below carries the surviving part of the old support anyway.
160 if (contrib == zero) continue;
161 T v = u[j] + contrib / L;
162 if (v < delta) {
163 if (dropped != nullptr && v > zero) *dropped += v;
164 v = zero;
165 }
166 u[j] = v;
167 if (v > zero) written.push_back(j);
168 }
169 std::vector<std::size_t> survivors;
170 survivors.reserve(act.size());
171 for (std::size_t k = 0; k < act.size(); ++k)
172 if (u[act[k]] > zero) survivors.push_back(act[k]);
173
174 std::vector<std::size_t> out;
175 out.reserve(survivors.size() + written.size());
176 std::set_union(survivors.begin(), survivors.end(), written.begin(), written.end(),
177 std::back_inserter(out));
178 return out;
179}
180
181/**
182 * Transient distribution of the pure birth process with rates LAMBDA at time T,
183 * that is b_n = P{N(t) = n} for n = 0..K, plus the mass that reached the
184 * absorbing overflow index and therefore measures P{N(t) > K}, and the Poisson
185 * mass left outside the Fox-Glynn window. The chain is uniformized at
186 * Lstar = max(LAMBDA), so the kernel entries 1 - Lambda_n/Lstar and
187 * Lambda_n/Lstar are probabilities and nothing cancels; the weights are taken
188 * unnormalized so that the window loss stays visible as missing mass.
189 */
190template <class T>
191void fau_weights(const std::vector<double>& lambda, const T& t, double tDouble, double tol,
192 std::vector<T>& b, T& tail, T& window) {
193 const T zero = num_traits<T>::from_int(0);
194 const std::size_t k1 = lambda.size();
195 b.assign(k1, zero);
196 tail = zero;
197 window = zero;
198 if (k1 == 0) return;
199 double lstar = 0.0;
200 for (std::size_t m = 0; m < k1; ++m) lstar = std::max(lstar, lambda[m]);
201 if (lstar <= 0.0 || tDouble <= 0.0) {
202 b[0] = num_traits<T>::from_int(1);
203 return;
204 }
205 const double lambdaDouble = lstar * tDouble;
206 const long left = detail::foxglynn_left(lambdaDouble, tol);
207 const long right = detail::foxglynn_right(lambdaDouble, tol);
208 const T lstarT = num_traits<T>::from_double(lstar);
209 const std::vector<T> w =
210 detail::foxglynn_poisson(lstarT * t, left, right, lambdaDouble, false);
211
212 T wsum = zero;
213 for (std::size_t i = 0; i < w.size(); ++i) wsum += w[i];
214 window = (num_traits<T>::from_int(1) > wsum) ? num_traits<T>::from_int(1) - wsum : zero;
215
216 std::vector<T> v(k1 + 1, zero);
217 std::vector<T> acc(k1 + 1, zero);
218 v[0] = num_traits<T>::from_int(1);
219 std::vector<T> c(k1, zero);
220 std::vector<T> a(k1, zero);
221 for (std::size_t m = 0; m < k1; ++m) {
222 c[m] = num_traits<T>::from_double(lambda[m]) / lstarT;
223 a[m] = num_traits<T>::from_int(1) - c[m];
224 }
225 for (long k = 0; k <= right; ++k) {
226 if (k >= left) {
227 const T& wk = w[static_cast<std::size_t>(k - left)];
228 for (std::size_t m = 0; m <= k1; ++m) acc[m] += wk * v[m];
229 }
230 if (k < right) {
231 for (std::size_t m = k1; m >= 1; --m) {
232 const T forward = v[m - 1] * c[m - 1];
233 const T stay = (m < k1) ? v[m] * a[m] : v[m];
234 v[m] = stay + forward;
235 }
236 v[0] = v[0] * a[0];
237 }
238 }
239 for (std::size_t m = 0; m < k1; ++m) b[m] = acc[m];
240 tail = acc[k1];
241}
242
243} // namespace detail
244
245/**
246 * @brief Transient distribution of a CTMC by fast adaptive uniformization.
247 *
248 * @param pi0 initial distribution (row vector)
249 * @param Q generator
250 * @param t time horizon, t >= 0
251 * @param epsilon birth-process truncation tolerance (MATLAB default 1e-6)
252 * @param delta occupancy threshold below which a state is dropped (default 1e-12)
253 * @param maxsteps cap on birth steps; <= 0 for the default cap
254 */
255template <class T>
256FauResult<T> ctmc_fau(const std::vector<T>& pi0, const Matrix<T>& Q, const T& t,
257 double epsilon = 1e-6, double delta = 1e-12, long maxsteps = -1) {
258 const T zero = num_traits<T>::from_int(0);
259 const std::size_t n = Q.rows();
260 if (Q.cols() != n) throw InputError("ctmc_fau: Q must be square");
261 if (pi0.size() != n) throw InputError("ctmc_fau: pi0 and Q have inconsistent sizes");
262 const double tDouble = num_traits<T>::to_double(t);
263 if (tDouble < 0.0) throw InputError("ctmc_fau: t must be nonnegative");
264 const double eps = (epsilon > 0.0) ? epsilon : 1e-6;
265 const T dropThreshold = num_traits<T>::from_double((delta > 0.0) ? delta : 0.0);
266 const long cap = (maxsteps > 0) ? maxsteps : FAU_MAX_STEPS;
267
268 FauResult<T> r;
269 r.weightTail = zero;
270 r.weightWindow = zero;
271 r.droppedMass = zero;
272 r.errorBound = zero;
273
274 std::vector<double> d(n, 0.0);
275 std::vector<T> exitRate(n, zero);
276 for (std::size_t i = 0; i < n; ++i) {
277 exitRate[i] = zero - Q(i, i);
278 d[i] = num_traits<T>::to_double(exitRate[i]);
279 r.uniformRate = std::max(r.uniformRate, d[i]);
280 }
281 if (tDouble == 0.0 || n == 0) {
282 r.pit = pi0;
283 r.steps = 1;
284 r.supportMax = detail::fau_support(pi0).size();
286 return r;
287 }
288
289 // Pass one: the adaptive rate sequence, and where it stops.
290 std::vector<double> lambda;
291 {
292 std::vector<T> u = pi0;
293 std::vector<T> scratch(n, zero);
294 std::vector<char> touchedFlag(n, 0);
295 std::vector<std::size_t> act = detail::fau_support(u);
296 double lstar = 0.0;
297 while (true) {
298 if (act.empty()) {
299 r.absorbed = true;
300 break;
301 }
302 const double L = detail::fau_max_exit(d, act);
303 lambda.push_back(L);
304 if (L <= 0.0) {
305 // Every occupied state is absorbing: the birth process stops
306 // here and the remaining weight falls on this iterate.
307 r.absorbed = true;
308 break;
309 }
310 lstar = std::max(lstar, L);
311 if (detail::fau_tailbound(lstar, tDouble, static_cast<long>(lambda.size())) <= eps) break;
312 if (static_cast<long>(lambda.size()) >= cap) {
313 r.truncated = true;
314 break;
315 }
316 act = detail::fau_step(u, act, Q, num_traits<T>::from_double(L), dropThreshold, scratch,
317 touchedFlag, static_cast<T*>(nullptr));
318 }
319 }
320
321 // The birth-process weights of that rate sequence, exactly.
322 std::vector<T> b;
323 detail::fau_weights(lambda, t, tDouble, eps, b, r.weightTail, r.weightWindow);
324
325 // Pass two: the same sweep again, accumulating sum_n b_n u^(n).
326 {
327 std::vector<T> u = pi0;
328 std::vector<T> scratch(n, zero);
329 std::vector<char> touchedFlag(n, 0);
330 std::vector<std::size_t> act = detail::fau_support(u);
331 r.pit.assign(n, zero);
332 r.supportMax = act.size();
333 r.supportFinal = act.size();
334 for (std::size_t m = 0; m < lambda.size(); ++m) {
335 if (act.empty()) break;
336 r.supportMax = std::max(r.supportMax, act.size());
337 r.supportFinal = act.size();
338 for (std::size_t k = 0; k < act.size(); ++k) r.pit[act[k]] += b[m] * u[act[k]];
339 if (m + 1 < lambda.size()) {
340 const double L = detail::fau_max_exit(d, act);
341 if (L <= 0.0) break;
342 act = detail::fau_step(u, act, Q, num_traits<T>::from_double(L), dropThreshold,
343 scratch, touchedFlag, &r.droppedMass);
344 }
345 }
346 }
347
348 r.steps = static_cast<long>(lambda.size());
349 if (!lambda.empty()) {
350 r.lambdaMin = *std::min_element(lambda.begin(), lambda.end());
351 r.lambdaMax = *std::max_element(lambda.begin(), lambda.end());
352 }
353 T mass0 = zero;
354 T massT = zero;
355 for (std::size_t i = 0; i < n; ++i) {
356 mass0 += pi0[i];
357 massT += r.pit[i];
358 }
359 r.errorBound = mass0 - massT;
360 return r;
361}
362
363} // namespace mc
364} // namespace line
365
366#endif // LINE_API_MC_CTMC_FAU_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
Transient distribution of a CTMC by uniformization with Fox-Glynn Poisson weights.
The exception types the port throws.
Dense matrix and non-owning view.
constexpr long FAU_MAX_STEPS
Default cap on birth steps, so a pathological horizon reports truncation.
Definition ctmc_fau.h:69
FauResult< T > ctmc_fau(const std::vector< T > &pi0, const Matrix< T > &Q, const T &t, double epsilon=1e-6, double delta=1e-12, long maxsteps=-1)
Transient distribution of a CTMC by fast adaptive uniformization.
Definition ctmc_fau.h:256
Number-type abstraction for the templated API port.
T weightTail
mass reaching the overflow index, i.e. P{N(t) > K}
Definition ctmc_fau.h:78
T droppedMass
probability removed by the occupancy threshold
Definition ctmc_fau.h:80
T weightWindow
Poisson mass outside the Fox-Glynn window.
Definition ctmc_fau.h:79
bool absorbed
the support emptied or became absorbing
Definition ctmc_fau.h:85
double lambdaMin
smallest adaptive rate used
Definition ctmc_fau.h:75
bool truncated
maxsteps stopped the sweep
Definition ctmc_fau.h:84
std::size_t supportFinal
support at the last step
Definition ctmc_fau.h:83
T errorBound
sum(pi0) - sum(pit), which IS the L1 error
Definition ctmc_fau.h:81
double uniformRate
max_i |q_ii|, the rate ordinary uniformization would use
Definition ctmc_fau.h:77
std::size_t supportMax
largest occupied support over the sweep
Definition ctmc_fau.h:82
long steps
number of birth steps K+1 actually taken
Definition ctmc_fau.h:74
std::vector< T > pit
defective distribution at t, a lower bound on pi(t)
Definition ctmc_fau.h:73
double lambdaMax
largest adaptive rate used, the Lstar of the weights
Definition ctmc_fau.h:76