LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ctmc_symbolic.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_CTMC_SOLVER_CTMC_SYMBOLIC_H
6#define LINE_SOLVERS_CTMC_SOLVER_CTMC_SYMBOLIC_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `@@SolverCTMC/getSymbolicGenerator` and `getSymbolicSolution`.
12 *
13 * THE TWO HALVES ARE NOT THE SAME KIND OF PROBLEM, which is why they live in one
14 * header but only one of them needs a backend. The generator is LINEAR in the
15 * event symbols: each synchronization contributes one numeric filtration,
16 * normalized by its own minimum positive rate, scaled by its symbol x1..xE. So
17 * the symbolic generator is a sum of numeric matrices with symbolic
18 * coefficients, assembled here with no algebra system in sight and printed as
19 * expression strings at the end. Solving pi Q = 0 with it is a linear solve
20 * whose pivots are multivariate polynomials, which needs exact arithmetic with
21 * cancellation in a rational function field; that half is delegated to the
22 * backend `api/sym` resolves, exactly as the reference delegates it to the
23 * Symbolic Math Toolbox or to line-sage-rest.
24 *
25 * WHY THE FILTRATION IS NORMALIZED. Dividing event e's filtration by its
26 * smallest positive rate makes the nominal value of x_e that rate, so the
27 * printed coefficients are RATIOS within one event and are 1 wherever the event
28 * fires at its base rate. Substituting the minimum positive rates therefore
29 * reproduces the numeric generator; `ctmc_symbolic_eval_infgen` does exactly
30 * that and is the check to run before trusting a printed normal form.
31 *
32 * COEFFICIENT TEXT IS READ AS AN EXACT RATIONAL SERVER SIDE, so how a
33 * non-integer coefficient is printed changes the answer, not merely its
34 * appearance. This port prints the SHORTEST decimal that round-trips to the
35 * double, which is what the JAR's `Double.toString` emits; MATLAB prints
36 * `%.17g`, so on a coefficient such as 1/10 the two send different exact
37 * rationals to the same service. The divergence is deliberate here: the shortest
38 * form is the one whose exact value is the coefficient's intended decimal. The
39 * expressions were never text-comparable across codebases anyway -- symbol
40 * numbering follows event enumeration order and normal forms depend on the
41 * engine -- so compare by substituting rates and comparing numbers.
42 *
43 * AN EVENT WITH NO POSITIVE RATE CONTRIBUTES NO SYMBOL. Its slot in `symbols`
44 * is the empty string and its filtration and term are empty matrices, mirroring
45 * MATLAB's empty cell and the JAR's null. Dropping it from the vectors instead
46 * would renumber every later symbol and silently change which rate x_e means.
47 */
48
49#include <cmath>
50#include <cstddef>
51#include <cstdio>
52#include <memory>
53#include <string>
54#include <vector>
55
61#include "line/lang/qn/state.h"
65#include "line/util/error.h"
66#include "line/util/matrix.h"
67
68namespace line {
69namespace ctmc {
70
71/** Backend selection, mirroring `options.config.symbolic` and its timeout. */
73 /** `auto` to search, a URL, an image name, or `none` to stay local. */
74 std::string backend = "auto";
75 /** `options.config.symbolic_timeout`, seconds; the reference defaults to 300. */
76 int timeout_s = 300;
77};
78
79/**
80 * The outputs of `@@SolverCTMC/getSymbolicGenerator`.
81 *
82 * `filt`, `terms`, `rate0` and `symbols` are all indexed by SYNCHRONIZATION, in
83 * the order `sync` lists them, and an inactive event keeps its slot.
84 */
85template <class T>
87 /** The generator as expression strings, row major; "0" where the entry is zero. */
88 std::vector<std::vector<std::string> > Q;
89 /** `x1..xE`, empty for an event with no positive rate. */
90 std::vector<std::string> symbols;
91 /** Event filtration divided by its minimum positive rate; empty if inactive. */
92 std::vector<Matrix<T> > filt;
93 /** `ctmc_makeinfgen(filt[e])`, the numeric term symbol e scales; empty if inactive. */
94 std::vector<Matrix<T> > terms;
95 /** Minimum positive rate of each event, i.e. the nominal value of its symbol. */
96 std::vector<T> rate0;
97 std::vector<NetState<T> > space; ///< row i of Q is space[i]
98 std::vector<Sync<T> > sync; ///< what `filt` is indexed by
99 bool invert_symbol = false; ///< entries carry `c/x_e` instead of `c*x_e`
100
101 /** The symbols that actually occur, i.e. the non-empty ones. */
102 std::vector<std::string> active_symbols() const {
103 std::vector<std::string> out;
104 for (std::size_t e = 0; e < symbols.size(); ++e)
105 if (!symbols[e].empty()) out.push_back(symbols[e]);
106 return out;
107 }
108};
109
110namespace symbolic_detail {
111
112/**
113 * A coefficient as text the backend reads exactly: an integer when it is one,
114 * the shortest round-tripping decimal otherwise. See the header note on why the
115 * shortest form and not MATLAB's `%.17g`.
116 */
117inline std::string coeff_string(double c) {
118 if (c == std::floor(c) && std::fabs(c) < 1e15) {
119 char buf[32];
120 std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(c));
121 return std::string(buf);
122 }
123 return sym::detail::decimal_string(c);
124}
125
126/**
127 * Entry (i,j) of the symbolic generator, e.g. "2*x1 - 3*x2".
128 *
129 * The format is the JAR's `getSymbolicEntry` verbatim -- leading unary minus,
130 * " + " / " - " between terms, the coefficient omitted when it is 1 -- because
131 * that is the form the service parses and the only thing that makes two
132 * codebases' output comparable at all.
133 */
134template <class T>
135std::string symbolic_entry(const CtmcSymbolicGenerator<T>& g, std::size_t i, std::size_t j) {
136 std::string s;
137 for (std::size_t e = 0; e < g.symbols.size(); ++e) {
138 if (g.symbols[e].empty()) continue;
139 const double c = num_traits<T>::to_double(g.terms[e](i, j));
140 if (c == 0.0) continue;
141 if (s.empty()) {
142 if (c < 0.0) s += "-";
143 } else {
144 s += c < 0.0 ? " - " : " + ";
145 }
146 const double a = std::fabs(c);
147 if (g.invert_symbol) {
148 s += coeff_string(a) + "/" + g.symbols[e];
149 } else {
150 if (a != 1.0) s += coeff_string(a) + "*";
151 s += g.symbols[e];
152 }
153 }
154 return s.empty() ? std::string("0") : s;
155}
156
157/** Minimum positive entry of a filtration, or `has = false` when it has none. */
158template <class T>
159struct MinPositive {
160 T value = num_traits<T>::from_int(0);
161 bool has = false;
162};
163
164template <class T>
165MinPositive<T> min_positive(const Matrix<T>& F) {
166 MinPositive<T> out;
167 const T zero = num_traits<T>::from_int(0);
168 for (std::size_t i = 0; i < F.rows(); ++i)
169 for (std::size_t j = 0; j < F.cols(); ++j) {
170 if (!(F(i, j) > zero)) continue;
171 if (!out.has || F(i, j) < out.value) {
172 out.value = F(i, j);
173 out.has = true;
174 }
175 }
176 return out;
177}
178
179/** Sets the timeout on the engine, which only the REST one carries. */
180inline void apply_timeout(const std::shared_ptr<sym::SymEngine>& engine, int timeout_s) {
181 if (timeout_s <= 0) return;
182 sym::SageRestEngine* rest = dynamic_cast<sym::SageRestEngine*>(engine.get());
183 if (rest != nullptr) rest->setTimeoutSeconds(timeout_s);
184}
185
186/** The reference's `SAGE.require()` message, verbatim in substance. */
187inline std::string backend_missing(const std::string& what) {
188 return std::string("SolverCTMC: ") + what +
189 " needs a computer-algebra backend, and none is available. Start one with\n docker "
190 "run -d -p 8080:8080 " +
191 sym::SYM_DOCKER_IMAGE + "\npoint the " + sym::SYM_URL_ENV +
192 " environment variable at a running service, or set the backend to its URL";
193}
194
195/**
196 * Resolves a backend or throws with the guidance above.
197 *
198 * Refusing beats returning nothing: a caller that got an empty solution back
199 * would read it as "this chain has no symbolic stationary distribution", which
200 * is a different and false statement.
201 */
202inline std::shared_ptr<sym::SymEngine> require_engine(const CtmcSymbolicOptions& opt,
203 const std::string& what) {
204 std::shared_ptr<sym::SymEngine> engine = sym::sym_resolve(opt.backend);
205 if (!engine) throw sym::SymEngineError(backend_missing(what));
206 apply_timeout(engine, opt.timeout_s);
207 return engine;
208}
209
210} // namespace symbolic_detail
211
212/**
213 * Port of `@@SolverCTMC/getSymbolicGenerator.m`.
214 *
215 * No backend is contacted: the generator is linear in the symbols, so it is
216 * assembled from the numeric filtration `ctmc_get_generator` already returns.
217 *
218 * @param sn the refreshed network struct
219 * @param opt CTMC options; `keep_filtration` is forced on, the filtration being
220 * the whole content of the answer
221 * @param invert_symbol divide each filtration by its symbol instead of
222 * multiplying, i.e. parameterize by mean times not rates
223 * @return the expression matrix, the symbols, and the numeric terms they scale
224 */
225template <class T>
227 const CtmcOptions& opt,
228 bool invert_symbol = false) {
230
232 g.invert_symbol = invert_symbol;
233 g.space = gen.space;
234 g.sync = gen.sync;
235 const std::size_t n = gen.Q.rows();
236 const std::size_t ne = gen.filt.size();
237 g.symbols.resize(ne);
238 g.filt.resize(ne);
239 g.terms.resize(ne);
240 g.rate0.assign(ne, num_traits<T>::from_int(0));
241
242 for (std::size_t e = 0; e < ne; ++e) {
243 const symbolic_detail::MinPositive<T> m = symbolic_detail::min_positive(gen.filt[e]);
244 if (!m.has) continue; // inactive event: no symbol, empty term, slot kept
245 g.rate0[e] = m.value;
246 Matrix<T> F(gen.filt[e].rows(), gen.filt[e].cols(), num_traits<T>::from_int(0));
247 for (std::size_t i = 0; i < F.rows(); ++i)
248 for (std::size_t j = 0; j < F.cols(); ++j)
249 if (!(gen.filt[e](i, j) == num_traits<T>::from_int(0)))
250 F(i, j) = T(gen.filt[e](i, j) / m.value);
251 g.filt[e] = F;
252 // ctmc_makeinfgen is linear, so the symbolic generator is the sum of the
253 // per-event terms scaled by their symbols, diagonal included.
254 g.terms[e] = mc::ctmc_makeinfgen(F);
255 g.symbols[e] = "x" + std::to_string(e + 1);
256 }
257
258 g.Q.assign(n, std::vector<std::string>(n, "0"));
259 for (std::size_t i = 0; i < n; ++i)
260 for (std::size_t j = 0; j < n; ++j) g.Q[i][j] = symbolic_detail::symbolic_entry(g, i, j);
261 return g;
262}
263
264/**
265 * Evaluates the symbolic generator at a symbol assignment, the twin of the
266 * JAR's `evalInfGen`.
267 *
268 * With `x[e] = rate0[e]` this reproduces the numeric generator, which is the
269 * cheapest way to check a symbolic build against `ctmc_get_generator`.
270 *
271 * @param g the symbolic generator
272 * @param x one value per event, in the same order; an inactive event's value is
273 * ignored
274 */
275template <class T>
277 if (x.size() != g.symbols.size())
278 throw InputError("ctmc_symbolic_eval_infgen: expected " +
279 std::to_string(g.symbols.size()) + " symbol values, got " +
280 std::to_string(x.size()));
281 const std::size_t n = g.Q.size();
283 for (std::size_t e = 0; e < g.symbols.size(); ++e) {
284 if (g.symbols[e].empty()) continue;
285 if (g.invert_symbol && x[e] == num_traits<T>::from_int(0))
286 throw InputError("ctmc_symbolic_eval_infgen: symbol " + g.symbols[e] +
287 " is inverted in the generator and cannot be zero");
288 const T c = g.invert_symbol ? T(num_traits<T>::from_int(1) / x[e]) : x[e];
289 for (std::size_t i = 0; i < n; ++i)
290 for (std::size_t j = 0; j < n; ++j) Q(i, j) += T(c * g.terms[e](i, j));
291 }
292 return Q;
293}
294
295/** What `@@SolverCTMC/getSymbolicSolution.m` returns, plus the engine that answered. */
296template <class T>
298 std::vector<std::string> pi; ///< stationary probability of each state
299 std::vector<std::string> num; ///< numerator of each entry over `den`
300 std::string den = "1"; ///< common denominator of the vector
301 int nconncomp = 1; ///< weakly connected components of the generator
302 std::vector<int> conncomp; ///< component index of each state, one based
303 std::vector<NetState<T> > space; ///< entry i of `pi` is `space[i]`
304 std::string engine; ///< backend that solved it, e.g. `sage`
305};
306
307/**
308 * Port of `@@SolverCTMC/getSymbolicSolution.m`: pi Q = 0 with sum(pi) = 1 over
309 * the field of rational functions in x1..xE.
310 *
311 * @param sn the refreshed network struct
312 * @param opt CTMC options
313 * @param symopt backend selection and per-request timeout
314 * @return the distribution, also split over one common denominator
315 */
316template <class T>
318 const NetworkStruct<T>& sn, const CtmcOptions& opt,
319 const CtmcSymbolicOptions& symopt = CtmcSymbolicOptions()) {
321 const std::shared_ptr<sym::SymEngine> engine =
322 symbolic_detail::require_engine(symopt, "the symbolic stationary distribution");
323 const sym::CtmcSolution sol = engine->solveCTMC(g.Q, g.active_symbols());
324
326 out.pi = sol.pi;
327 out.num = sol.num;
328 out.den = sol.den;
329 out.nconncomp = sol.nConnComp;
330 out.conncomp = sol.connComp;
331 out.space = g.space;
332 out.engine = engine->name();
333 if (out.pi.size() != g.space.size())
334 throw sym::SymEngineError("ctmc_symbolic_solution: backend '" + out.engine +
335 "' returned " + std::to_string(out.pi.size()) +
336 " entries for a " + std::to_string(g.space.size()) +
337 " state chain");
338 return out;
339}
340
341} // namespace ctmc
342} // namespace line
343
344#endif // LINE_SOLVERS_CTMC_SOLVER_CTMC_SYMBOLIC_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
A network plus its refreshed NetworkStruct.
The symbolic backend is unreachable, or rejected the request.
Definition sym_engine.h:41
Steady-state distribution of a continuous-time Markov chain.
The exception types the port throws.
Dense matrix and non-owning view.
CtmcGenerator< T > ctmc_get_generator(const NetworkStruct< T > &sn, const CtmcSolution< T > &d)
Port of @@SolverCTMC/getGenerator.m: the generator, its event filtration and the synchronization list...
Matrix< T > ctmc_symbolic_eval_infgen(const CtmcSymbolicGenerator< T > &g, const std::vector< T > &x)
Evaluates the symbolic generator at a symbol assignment, the twin of the JAR's evalInfGen.
CtmcSymbolicSolution< T > ctmc_symbolic_solution(const NetworkStruct< T > &sn, const CtmcOptions &opt, const CtmcSymbolicOptions &symopt=CtmcSymbolicOptions())
Port of @@SolverCTMC/getSymbolicSolution.m: pi Q = 0 with sum(pi) = 1 over the field of rational func...
CtmcSymbolicGenerator< T > ctmc_symbolic_generator(const NetworkStruct< T > &sn, const CtmcOptions &opt, bool invert_symbol=false)
Port of @@SolverCTMC/getSymbolicGenerator.m.
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
const char *const SYM_URL_ENV
Environment variable naming a service to use.
Definition sym_engines.h:74
std::shared_ptr< SymEngine > sym_resolve(const std::string &requested="auto")
Resolves an engine.
const char *const SYM_DOCKER_IMAGE
Image serving the symbolic REST API.
Definition sym_engines.h:72
A queueing network and its refreshed NetworkStruct.
SymEngine backed by the line-sage-rest service.
Port of solver_ctmc_analyzer.m and the parts of @@SolverCTMC/runAnalyzer.m that surround one solve: t...
The remaining @@SolverCTMC accessors: getGenerator / getInfGen, getStateSpace / getStateSpaceAggr and...
Port of the MATLAB +State package: the encoding that turns a station's state row into marginal job co...
Port of the event half of MATLAB's +State package: the successor states an event produces at one node...
[infGen, eventFilt, ev] of @@SolverCTMC/getGenerator.m.
std::vector< NetState< T > > space
row i of Q is space[i]
std::vector< Matrix< T > > filt
eventFilt: filt[a] holds only what synchronization sync[a] contributed, so sum_a filt[a] is the off-d...
std::vector< Sync< T > > sync
ev, the reference's sn.sync
Matrix< T > Q
the infinitesimal generator
The SolverCTMC knobs this port honours.
The outputs of @@SolverCTMC/getSymbolicGenerator.
std::vector< Matrix< T > > terms
ctmc_makeinfgen(filt[e]), the numeric term symbol e scales; empty if inactive.
std::vector< T > rate0
Minimum positive rate of each event, i.e.
std::vector< std::string > symbols
x1..xE, empty for an event with no positive rate.
bool invert_symbol
entries carry c/x_e instead of c*x_e
std::vector< std::string > active_symbols() const
The symbols that actually occur, i.e.
std::vector< std::vector< std::string > > Q
The generator as expression strings, row major; "0" where the entry is zero.
std::vector< Matrix< T > > filt
Event filtration divided by its minimum positive rate; empty if inactive.
std::vector< Sync< T > > sync
what filt is indexed by
std::vector< NetState< T > > space
row i of Q is space[i]
Backend selection, mirroring options.config.symbolic and its timeout.
int timeout_s
options.config.symbolic_timeout, seconds; the reference defaults to 300.
std::string backend
auto to search, a URL, an image name, or none to stay local.
What @@SolverCTMC/getSymbolicSolution.m returns, plus the engine that answered.
std::string engine
backend that solved it, e.g. sage
int nconncomp
weakly connected components of the generator
std::vector< NetState< T > > space
entry i of pi is space[i]
std::vector< std::string > num
numerator of each entry over den
std::vector< int > conncomp
component index of each state, one based
std::vector< std::string > pi
stationary probability of each state
std::string den
common denominator of the vector
Symbolic stationary distribution of a CTMC.
Definition sym_engine.h:47
std::vector< std::string > pi
Stationary probability of each state, as an expression.
Definition sym_engine.h:48
std::vector< int > connComp
Component index of each state, one based.
Definition sym_engine.h:52
std::vector< std::string > num
Numerator of each entry over the common denominator.
Definition sym_engine.h:49
int nConnComp
Weakly connected components of the generator.
Definition sym_engine.h:51
std::string den
Common denominator of the whole vector.
Definition sym_engine.h:50
Computer algebra operations LINE needs, as seen by this port.
Resolves the symbolic backend to use, and owns the container that serves it.