LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_simulate.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_SIMULATE_H
6#define LINE_API_MC_CTMC_SIMULATE_H
7
8/**
9 * @file
10 * @ingroup api_mc
11 * Sample path of a continuous-time Markov chain given its generator.
12 *
13 * Templated port of matlab/src/api/mc/ctmc_simulate.m. The chain is simulated
14 * by the standard jump-chain construction: from state i the holding time is
15 * exponential with mean -1/Q(i,i), and the next state is drawn from the
16 * embedded jump chain P(i,j) = Q(i,j) / sum_{k != i} Q(i,k).
17 *
18 * This is the one entry point in this batch that takes MATRICES and not an
19 * `sn`: its arguments are the generator, an initial distribution and a step
20 * count, so it is portable independently of the NetworkStruct layer.
21 *
22 * ARITHMETIC. Gated on num_traits<T>::has_transcendental. The holding times
23 * are exponential deviates, drawn by inverse transform as -mean * log(u), so
24 * there is no exact instantiation: the sample path is a realization, not a
25 * number that a rational field could represent.
26 *
27 * RANDOMNESS. The generator is `line::pfqn::McRng` (a std::mt19937_64) passed
28 * by reference and advanced by the call, the same convention every Monte Carlo
29 * entry point in this tree uses, so a caller controls reproducibility by
30 * controlling the generator state. The stream is NOT comparable with MATLAB's
31 * -- different generator, different mapping from bits to deviates -- so the
32 * oracle for this function is distributional, never path-for-path. Two
33 * generator steps are consumed per simulated step: one for the holding time
34 * and one for the jump.
35 *
36 * REFERENCE DEFECT: the initial state is not drawn from pi0.
37 *
38 * ctmc_simulate.m selects the starting state with
39 *
40 * [~, st] = min(abs(rand - cumsum(pi0)));
41 *
42 * which returns the state whose CUMULATIVE probability is nearest to the
43 * uniform deviate, ties going to the lowest index because min returns the
44 * first minimizer. That partitions [0,1] at the MIDPOINTS between consecutive
45 * DISTINCT cumulative values c_k = sum_{j<=k} p_j, instead of at the
46 * cumulative values themselves. Where the c_k are distinct this reduces to
47 *
48 * P(1) = p_1 + p_2/2, P(k) = (p_k + p_{k+1})/2, P(n) = p_n/2,
49 *
50 * i.e. the LAST state always receives about half its intended mass and the
51 * first receives an excess. Where two consecutive c_k coincide -- which is
52 * exactly what a zero-probability entry produces -- the first of them takes
53 * the whole window and the rest get nothing, so the naive midpoint reading
54 * above does NOT apply there. Measured in MATLAB over 400000 draws:
55 *
56 * pi0 = [0.5 0.5] -> [0.7498 0.2502] (want [0.5 0.5])
57 * pi0 = [1/3 1/3 1/3] -> [0.4997 0.3335 0.1668] (want [1/3 1/3 1/3])
58 * pi0 = [0.9 0 0.1] -> [0.9497 0.0000 0.0503] (want [0.9 0 0.1])
59 *
60 * the last of which is also reproducible through the entry point itself,
61 * ctmc_simulate(Q, [0.9;0;0.1], 1) over 20000 calls giving
62 * [0.9488 0.0000 0.0512]. The error washes out of a long ergodic run -- the
63 * time-average occupancy of a two-state chain still converges to the exact
64 * stationary law -- so it is invisible in steady-state use and corrupts
65 * exactly the transient and short-run use that passing pi0 is for.
66 *
67 * The port draws the initial state by correct inverse transform. Reproducing
68 * the defect was rejected: a sampler that does not sample from the
69 * distribution it is handed has no contract left to preserve, and unlike a
70 * closed-form value there is nothing downstream that could be calibrated
71 * against the wrong answer. `ctmc_simulate_reference_initial_law` below
72 * returns the law the reference actually realizes, so the discrepancy is
73 * available to a caller as a value rather than only as prose.
74 *
75 * A second, smaller divergence: an ABSORBING state (a row whose off-diagonal
76 * entries are all zero) gives MATLAB `F = 0/0 = NaN` on that row, after which
77 * `find(rand - NaN > 0)` is empty and the chain silently jumps to state 1,
78 * while the holding time is exprnd(Inf) = Inf. The port raises NumericError
79 * naming the state instead: there is no Inf in an exact field, and a silent
80 * jump to state 1 is not a property of the chain.
81 */
82
83#include <cmath>
84#include <cstddef>
85#include <vector>
86
88#include "line/num/number.h"
89#include "line/util/error.h"
90#include "line/util/matrix.h"
91
92namespace line {
93namespace mc {
94
95/** One simulated sample path: the state visited at each step and its holding time. */
96template <class T>
97struct CtmcPath {
98 std::vector<std::size_t> states; ///< 0-based state index at each step
99 std::vector<T> sojourn; ///< holding time spent in that state
100};
101
102/**
103 * The initial-state law that ctmc_simulate.m actually realizes for a given
104 * pi0, as opposed to pi0 itself. Provided so a caller can measure the
105 * reference defect documented above rather than take it on trust; it is not
106 * used by the simulation.
107 */
108template <class T>
109std::vector<T> ctmc_simulate_reference_initial_law(const std::vector<T>& pi0) {
110 const std::size_t n = pi0.size();
111 if (n == 0) throw InputError("ctmc_simulate_reference_initial_law: empty distribution");
112 const T zero = num_traits<T>::from_int(0);
113 const T one = num_traits<T>::from_int(1);
114 const T half = num_traits<T>::from_rational(1, 2);
115
116 T tot = zero;
117 for (std::size_t i = 0; i < n; ++i) {
118 if (pi0[i] < zero)
119 throw InputError("ctmc_simulate_reference_initial_law: negative entry");
120 tot += pi0[i];
121 }
122 if (tot == zero) throw InputError("ctmc_simulate_reference_initial_law: zero total mass");
123
124 std::vector<T> c(n);
125 {
126 T run = zero;
127 for (std::size_t i = 0; i < n; ++i) {
128 run += pi0[i] / tot;
129 c[i] = run;
130 }
131 }
132
133 // tie-breaking rationale: see _kb/03-api-layer.md (cpp port notes: mc)
134 std::vector<std::size_t> rep;
135 for (std::size_t k = 0; k < n; ++k)
136 if (rep.empty() || c[k] != c[rep.back()]) rep.push_back(k);
137
138 std::vector<T> q(n, zero);
139 const std::size_t M = rep.size();
140 for (std::size_t m = 0; m < M; ++m) {
141 const T lo = (m == 0) ? zero : T(half * (c[rep[m - 1]] + c[rep[m]]));
142 const T hi = (m + 1 == M) ? one : T(half * (c[rep[m]] + c[rep[m + 1]]));
143 q[rep[m]] = hi - lo;
144 }
145 return q;
146}
147
148/**
149 * Simulate n steps of the CTMC with generator Q.
150 *
151 * @param Q (m x m) generator, negative diagonal and zero row sums
152 * @param pi0 initial distribution; empty draws it uniformly at random and
153 * normalizes, as the reference does
154 * @param n number of steps
155 * @param rng generator, advanced by the call
156 */
157template <class T>
158CtmcPath<T> ctmc_simulate(const Matrix<T>& Q, const std::vector<T>& pi0, std::size_t n,
159 pfqn::McRng& rng) {
161 "ctmc_simulate requires transcendental arithmetic: the holding times are "
162 "exponential deviates drawn as -mean * log(u)");
163 const std::size_t m = Q.rows();
164 if (Q.cols() != m) throw InputError("ctmc_simulate: the generator is not square");
165 if (m == 0) throw InputError("ctmc_simulate: empty generator");
166 const T zero = num_traits<T>::from_int(0);
167
168 // Initial distribution: uniform random and normalized when not supplied,
169 // which is what `r = rand(length(Q),1); pi0 = r/sum(r)` does.
170 std::vector<T> p0;
171 if (pi0.empty()) {
172 p0.resize(m);
173 T tot = zero;
174 for (std::size_t i = 0; i < m; ++i) {
175 p0[i] = pfqn::mc_uniform<T>(rng);
176 tot += p0[i];
177 }
178 if (tot == zero) throw NumericError("ctmc_simulate: degenerate random initial distribution");
179 for (std::size_t i = 0; i < m; ++i) p0[i] /= tot;
180 } else {
181 if (pi0.size() != m)
182 throw InputError("ctmc_simulate: pi0 has the wrong length for the generator");
183 T tot = zero;
184 for (std::size_t i = 0; i < m; ++i) {
185 if (pi0[i] < zero) throw InputError("ctmc_simulate: pi0 has a negative entry");
186 tot += pi0[i];
187 }
188 if (tot == zero) throw InputError("ctmc_simulate: pi0 has zero total mass");
189 p0 = pi0;
190 for (std::size_t i = 0; i < m; ++i) p0[i] /= tot;
191 }
192
193 // Row-normalized cumulative jump probabilities over the off-diagonal.
194 Matrix<T> F(m, m, zero);
195 for (std::size_t i = 0; i < m; ++i) {
196 T run = zero;
197 for (std::size_t j = 0; j < m; ++j) {
198 if (j != i) {
199 if (Q(i, j) < zero)
200 throw InputError("ctmc_simulate: negative off-diagonal rate in the generator");
201 run += Q(i, j);
202 }
203 F(i, j) = run;
204 }
205 if (run == zero)
206 throw NumericError(
207 "ctmc_simulate: state " + std::to_string(i) +
208 " is absorbing (no outgoing rate), so the sample path cannot be continued; "
209 "the reference silently jumps to state 1 here with an infinite holding time");
210 for (std::size_t j = 0; j < m; ++j) F(i, j) /= run;
211 }
212
213 // Correct inverse transform for the initial state; see the header note.
214 std::size_t st = m - 1;
215 {
216 const T u = pfqn::mc_uniform<T>(rng);
217 T run = zero;
218 for (std::size_t i = 0; i < m; ++i) {
219 run += p0[i];
220 if (u < run) {
221 st = i;
222 break;
223 }
224 }
225 }
226
227 CtmcPath<T> path;
228 path.states.reserve(n);
229 path.sojourn.reserve(n);
230 using std::log;
231 for (std::size_t k = 0; k < n; ++k) {
232 path.states.push_back(st);
233 const T rate = -Q(st, st);
234 if (!(rate > zero))
235 throw NumericError("ctmc_simulate: state " + std::to_string(st) +
236 " has a non-negative diagonal, the holding time is undefined");
237 // Exponential of mean 1/rate by inverse transform. u is drawn on
238 // [0,1), so 1-u is in (0,1] and the logarithm is always finite.
239 const T u = pfqn::mc_uniform<T>(rng);
240 path.sojourn.push_back(T(-log(T(num_traits<T>::from_int(1) - u)) / rate));
241
242 const T v = pfqn::mc_uniform<T>(rng);
243 std::size_t nxt = m - 1;
244 for (std::size_t j = 0; j < m; ++j) {
245 if (v < F(st, j)) {
246 nxt = j;
247 break;
248 }
249 }
250 st = nxt;
251 }
252 return path;
253}
254
255} // namespace mc
256} // namespace line
257
258#endif // LINE_API_MC_CTMC_SIMULATE_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
The exception types the port throws.
Dense matrix and non-owning view.
std::vector< T > ctmc_simulate_reference_initial_law(const std::vector< T > &pi0)
The initial-state law that ctmc_simulate.m actually realizes for a given pi0, as opposed to pi0 itsel...
CtmcPath< T > ctmc_simulate(const Matrix< T > &Q, const std::vector< T > &pi0, std::size_t n, pfqn::McRng &rng)
Simulate n steps of the CTMC with generator Q.
std::mt19937_64 McRng
The generator type every Monte Carlo entry point in this tree accepts.
T mc_uniform(McRng &g)
The same deviate materialized in the working arithmetic.
Number-type abstraction for the templated API port.
Randomness scaffolding shared by the Monte Carlo normalizing-constant estimators (pfqn_mci,...
One simulated sample path: the state visited at each step and its holding time.
std::vector< std::size_t > states
0-based state index at each step
std::vector< T > sojourn
holding time spent in that state