LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ctmc.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_H
6#define LINE_SOLVERS_CTMC_SOLVER_CTMC_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `solver_ctmc.m`: the infinitesimal generator of a queueing network,
12 * assembled from the enumerated state space and the synchronization list.
13 *
14 * THE ASSEMBLY. Every transition of the chain is one SYNCHRONIZATION: an active
15 * event that sets the rate, and a passive event that says where the job lands.
16 * For each (synchronization, state) pair the active handler is applied at its
17 * node, the passive handler at its node, and the two local successors are
18 * spliced back into a full network state whose index gives the column. The
19 * generator entry is rate * probability, accumulated -- one (s, ns) pair can be
20 * reached by several synchronizations, and each contributes.
21 *
22 * WHY THE DIAGONAL COMES LAST. Self-loops are generated deliberately (a lost
23 * arrival is one), and they must cancel: `ctmc_makeinfgen` drops the diagonal
24 * and then sets it to minus the row sum, so a self-loop contributes nothing to
25 * the balance equations while the event still fired for rate-counting purposes.
26 */
27
28#include <cmath>
29#include <cstddef>
30#include <map>
31#include <vector>
32
34#include "line/lang/qn/state.h"
38#include "line/util/error.h"
40#include "line/util/lu.h"
41#include "line/util/matrix.h"
42
43namespace line {
44namespace ctmc {
45
46using qn::EventOutcome;
47using qn::NetState;
48using qn::NetworkStruct;
49using qn::Sync;
50using lang::EventType;
51using lang::GlobalConstants;
52using lang::NodeType;
54
55/** The generator, the state space it is indexed by, and the event rates. */
56template <class T>
57struct CtmcResult {
58 Matrix<T> Q; ///< (n x n) infinitesimal generator
59 std::vector<NetState<T>> space; ///< row i of Q is space[i]
60 /**
61 * `arvRates` / `depRates`, indexed [state][stateful-1][class-1]: the total
62 * rate of arrivals into, and departures out of, each stateful node in each
63 * class, from each state. They are accumulated per synchronization rather
64 * than read off Q, because Q has already summed the contributions of every
65 * synchronization into one entry and they cannot be separated afterwards.
66 */
67 std::vector<std::vector<std::vector<T>>> arv_rates, dep_rates;
68 /**
69 * `Dfilt`, MATLAB's EVENT FILTRATION: `filt[a]` holds only the rates that
70 * synchronization `a` contributed, so `sum_a filt[a]` is the off-diagonal
71 * part of Q.
72 *
73 * IT CANNOT BE RECOVERED FROM Q, which is why it is carried rather than
74 * recomputed: Q has already summed every synchronization's contribution
75 * into one entry. The response-time CDF is built by splitting the generator
76 * on ONE event -- the tagged job's arrival, then its departure -- into a
77 * MAP (Q - D1, D1), and that split needs the per-event matrix.
78 *
79 * Empty unless `solver_ctmc` was asked for it: it costs one n x n matrix per
80 * synchronization, which on a model with many routing pairs dwarfs Q itself.
81 */
82 std::vector<Matrix<T>> filt;
83 /**
84 * The DERIVED START and PREEMPT filtrations, indexed [station-1][class-1]:
85 * the rate at which a transition starts a class-r service at station i, and
86 * the rate at which it pushes a class-r job in service back into the buffer.
87 *
88 * They are NOT part of `filt`, which pairs one-to-one with the
89 * synchronization list and whose sum is the off-diagonal of Q: a START rides
90 * on the SAME arc as the ARV or DEP that causes it, so adding it there would
91 * double-count the generator. Filled whenever `filt` is.
92 */
93 std::vector<std::vector<Matrix<T>>> start_filt, preempt_filt;
94 /**
95 * `Qimm`, the IMMEDIATE-ONLY part of Q: the arcs contributed by a Router or
96 * Fork pass-through, by a Join firing on an FJ-augmented struct, and by an
97 * SPN ENABLE or an IMMEDIATE-mode firing. Empty when the model has no such
98 * source.
99 *
100 * It exists for the VANISHING-ROW PURGE. A zero-sojourn state is left the
101 * instant it is entered, so a timed arc out of one describes an event that
102 * cannot occur; the purge replaces such a row by its immediate part. Q has
103 * already summed the two together, so the split cannot be recovered from it
104 * afterwards -- the same reason `filt` is carried rather than recomputed.
105 */
107 /**
108 * The parts of `arv_rates` / `dep_rates` contributed by those same immediate
109 * sources. Kept alongside the totals for two reasons, both from
110 * `solver_ctmc.m`: the purge has to restate a vanishing row's rates as its
111 * immediate part, and the RATE COMPLEMENT applies to the immediate part
112 * alone -- an event that fires only from vanishing states would otherwise be
113 * lost when those rows are eliminated. Empty when `Qimm` is.
114 */
115 std::vector<std::vector<std::vector<T>>> arv_rates_imm, dep_rates_imm;
116 /**
117 * The rows the purge restated, i.e. the vanishing states that `Qimm` gave an
118 * immediate exit. `ctmc_eliminate_vanishing` complements exactly these out;
119 * carrying them avoids re-running the predicate, which costs one event
120 * evaluation per (state, source).
121 */
122 std::vector<std::size_t> vanishing;
123};
124
125namespace ctmc_detail {
126
127/** The flattened key of a network state, for exact index lookup. */
128template <class T>
129std::vector<double> state_key(const NetState<T>& ns) {
130 std::vector<double> key;
131 for (std::size_t i = 0; i < ns.local.size(); ++i) {
132 // The separator keeps two different splits of the same concatenation
133 // from colliding, which a plain flatten would allow.
134 key.push_back(-2.0);
135 for (std::size_t j = 0; j < ns.local[i].size(); ++j)
136 key.push_back(num_traits<T>::to_double(ns.local[i][j]));
137 }
138 return key;
139}
140
141/**
142 * The largest per-class count any single stateful node holds in a state.
143 *
144 * The peak and not the sum, because the bound it feeds is per node.
145 *
146 * A TRANSITION IS SKIPPED, not counted. Its row is per MODE -- idle servers,
147 * firing phases, fired counts -- so the leading columns `to_marginal_aggr`
148 * would read as class counts are mode counts, and an infinite-server mode
149 * carries MaxInt there. It holds no jobs; the tokens are in the places.
150 */
151template <class T>
152std::vector<double> state_peak_occupancy(const NetworkStruct<T>& sn, const NetState<T>& st) {
153 std::vector<double> pk(sn.nclasses, 0.0);
154 const std::vector<std::size_t>& sfn = sn.stateful_nodes;
155 for (std::size_t f = 0; f < sfn.size() && f < st.local.size(); ++f) {
156 if (sn.nodes[sfn[f] - 1].nodetype == NodeType::Transition) continue;
157 const std::pair<T, std::vector<T>> mg = qn::to_marginal_aggr(sn, sfn[f], st.local[f]);
158 for (std::size_t r = 0; r < sn.nclasses && r < mg.second.size(); ++r) {
159 const double v = num_traits<T>::to_double(mg.second[r]);
160 if (v > pk[r]) pk[r] = v;
161 }
162 }
163 return pk;
164}
165
166/**
167 * True when no stateful node holds more of an OPEN class than `lim` allows.
168 *
169 * PER NODE AND NOT IN TOTAL, which is the reference's `capacityc(ind,r)` of
170 * `State.spaceGeneratorNodes`: an open class is capped at the cutoff AT EACH
171 * node. A total bound is wrong for an SPN whose firings do not conserve tokens
172 * -- `spn_open_sevenplaces` has a mode consuming one token and producing two --
173 * because the sum then crosses the bound on a firing that no place overflows,
174 * the arc vanishes, and the truncated chain absorbs at the boundary and reports
175 * Tput 0. A per-node bound censors only where a place itself overflows.
176 */
177template <class T>
178bool within_cutoff(const NetworkStruct<T>& sn, const NetState<T>& st,
179 const std::vector<double>& njobs, const std::vector<std::size_t>& lim,
180 const std::vector<std::vector<std::size_t>>& lim_mat =
181 std::vector<std::vector<std::size_t>>()) {
182 const std::vector<std::size_t>& sfn = sn.stateful_nodes;
183 for (std::size_t f = 0; f < sfn.size() && f < st.local.size(); ++f) {
184 if (sn.nodes[sfn[f] - 1].nodetype == NodeType::Transition) continue;
185 // The reference's cutoff may be a (station x class) MATRIX, in which
186 // case the bound at THIS node is its own row rather than the per-class
187 // maximum; a node with no station (Cache, Router) keeps the vector.
188 const std::size_t ist = sn.nodes[sfn[f] - 1].station;
189 const std::pair<T, std::vector<T>> mg = qn::to_marginal_aggr(sn, sfn[f], st.local[f]);
190 for (std::size_t r = 0; r < sn.nclasses && r < njobs.size(); ++r) {
191 if (std::isfinite(njobs[r])) continue;
192 std::size_t bound = r < lim.size() ? lim[r] : 0;
193 if (!lim_mat.empty() && ist != 0 && ist - 1 < lim_mat.size() &&
194 r < lim_mat[ist - 1].size())
195 bound = lim_mat[ist - 1][r];
196 if (bound == 0) continue;
197 if (r < mg.second.size() &&
198 num_traits<T>::to_double(mg.second[r]) > static_cast<double>(bound))
199 return false;
200 }
201 }
202 return true;
203}
204
205/**
206 * Accumulate the START/PREEMPT annotation of one successor row into the derived
207 * filtrations of the station behind NODE. W is the same weight the caller added
208 * to Q and to `filt`, so the filtration integrates rate * count and pi*F*e is a
209 * rate of starts (or of preemptions) per unit time.
210 */
211template <class T, class R>
212inline void add_aux_filt(R& res, const NetworkStruct<T>& sn, std::size_t node, std::size_t s,
213 std::size_t ns, const T& w, const qn::EventOutcome<T>& oc,
214 std::size_t row) {
215 if (num_traits<T>::to_double(w) == 0) return;
216 if (node == 0 || node > sn.nodes.size()) return;
217 const std::size_t ist = sn.nodes[node - 1].station;
218 if (ist == 0 || ist > res.start_filt.size()) return;
219 if (row < oc.start.size())
220 for (std::size_t j = 0; j < oc.start[row].size(); ++j) {
221 const std::size_t cls = oc.start[row][j];
222 if (cls >= 1 && cls <= res.start_filt[ist - 1].size())
223 res.start_filt[ist - 1][cls - 1](s, ns) += w;
224 }
225 if (row < oc.preempt.size())
226 for (std::size_t j = 0; j < oc.preempt[row].size(); ++j) {
227 const std::size_t cls = oc.preempt[row][j];
228 if (cls >= 1 && cls <= res.preempt_filt[ist - 1].size())
229 res.preempt_filt[ist - 1][cls - 1](s, ns) += w;
230 }
231}
232
233/**
234 * Solve (-Q22) X = B, one elimination shared by every column.
235 *
236 * The vanishing machinery needs the SAME solve three times over -- inside the
237 * complement, once per event filtration and once per rate vector -- so the
238 * censored block is factorized once and back-substituted per right-hand side.
239 * `ctmc_stochcomp` performs its own factorization for S; this is the one every
240 * OTHER right-hand side rides on.
241 */
242template <class T>
243Matrix<T> censored_solve(const Matrix<T>& Q22, const Matrix<T>& B) {
244 const std::size_t nd = Q22.rows();
245 if (B.rows() != nd) throw InputError("censored_solve: the right-hand side is misshapen");
246 Matrix<T> A(nd, nd, num_traits<T>::from_int(0));
247 for (std::size_t a = 0; a < nd; ++a)
248 for (std::size_t b = 0; b < nd; ++b) A(a, b) = T(-Q22(a, b));
249 const std::vector<std::size_t> piv = lu_factor(A);
250 Matrix<T> X = B;
251 std::vector<T> rhs(nd);
252 for (std::size_t c = 0; c < B.cols(); ++c) {
253 for (std::size_t d = 0; d < nd; ++d) rhs[d] = B(d, c);
254 lu_solve(A, piv, rhs);
255 for (std::size_t d = 0; d < nd; ++d) X(d, c) = rhs[d];
256 }
257 return X;
258}
259
260/**
261 * Complement ONE filtration onto the tangible states:
262 * Dnew = D(nonimm, nonimm) + Q12 (-Q22)^-1 D(imm, nonimm)
263 *
264 * All right-hand sides of one matrix go through a single elimination, since the
265 * censored block is the same for every filtration in the model.
266 */
267template <class T>
268Matrix<T> complement_one_filt(const Matrix<T>& D, const std::vector<std::size_t>& nonimm,
269 const std::vector<std::size_t>& imm,
270 const mc::StochCompResult<T>& sc) {
271 const T zero = num_traits<T>::from_int(0);
272 const std::size_t nk = nonimm.size(), nd = imm.size();
273 Matrix<T> out(nk, nk, zero);
274 for (std::size_t a = 0; a < nk; ++a)
275 for (std::size_t b = 0; b < nk; ++b) out(a, b) = D(nonimm[a], nonimm[b]);
276 if (nd == 0) return out;
277 Matrix<T> B(nd, nk, zero);
278 bool any = false;
279 for (std::size_t d = 0; d < nd; ++d)
280 for (std::size_t b = 0; b < nk; ++b) {
281 B(d, b) = D(imm[d], nonimm[b]);
282 if (num_traits<T>::to_double(B(d, b)) != 0) any = true;
283 }
284 if (!any) return out;
285 const Matrix<T> X = censored_solve(sc.Q22, B);
286 for (std::size_t a = 0; a < nk; ++a)
287 for (std::size_t b = 0; b < nk; ++b) {
288 T acc = zero;
289 for (std::size_t d = 0; d < nd; ++d) acc = T(acc + sc.Q12(a, d) * X(d, b));
290 out(a, b) = T(out(a, b) + acc);
291 }
292 return out;
293}
294
295/** Apply `complement_one_filt` to the event, START and PREEMPT filtrations. */
296template <class T, class R>
297void complement_filtrations(R& res, const std::vector<std::size_t>& nonimm,
298 const std::vector<std::size_t>& imm,
299 const mc::StochCompResult<T>& sc) {
300 for (std::size_t a = 0; a < res.filt.size(); ++a)
301 res.filt[a] = complement_one_filt(res.filt[a], nonimm, imm, sc);
302 for (std::size_t i = 0; i < res.start_filt.size(); ++i)
303 for (std::size_t r = 0; r < res.start_filt[i].size(); ++r)
304 res.start_filt[i][r] = complement_one_filt(res.start_filt[i][r], nonimm, imm, sc);
305 for (std::size_t i = 0; i < res.preempt_filt.size(); ++i)
306 for (std::size_t r = 0; r < res.preempt_filt[i].size(); ++r)
307 res.preempt_filt[i][r] = complement_one_filt(res.preempt_filt[i][r], nonimm, imm, sc);
308}
309
310} // namespace ctmc_detail
311
312/**
313 * Port of `ctmc_makeinfgen`: turn an off-diagonal rate matrix into a generator.
314 *
315 * The diagonal is discarded first and then set to minus the row sum, so any
316 * self-loop that was accumulated cancels exactly.
317 */
318template <class T>
320 const T zero = num_traits<T>::from_int(0);
321 for (std::size_t i = 0; i < Q.rows(); ++i) Q(i, i) = zero;
322 for (std::size_t i = 0; i < Q.rows(); ++i) {
323 T s = zero;
324 for (std::size_t j = 0; j < Q.cols(); ++j) s += Q(i, j);
325 Q(i, i) = T(-s);
326 }
327}
328
329template <class T>
330Matrix<T> ctmc_state_space_aggr(const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space);
331
332/**
333 * Tabulates the globally state-dependent rate scaling phi(n) declared through
334 * `set_global_dependence`, ONE evaluation per state.
335 *
336 * Returns an (nstates x nstations*nclasses) matrix, row-major in (station,
337 * class), of the scaling applying at each state; empty when the model declares
338 * no global dependence. Evaluating once per state rather than per transition is
339 * the whole point: phi may be expensive (a bandwidth-sharing allocation solves a
340 * convex program per call), and within a state it is a CONSTANT multiplying every
341 * rate there, which is why it factors out of the generator assembly below.
342 */
343template <class T>
344Matrix<T> ctmc_gd_factor(const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space) {
345 const T zero = num_traits<T>::from_int(0);
346 if (!static_cast<bool>(sn.gdscaling)) return Matrix<T>(0, 0, zero);
347 if (!sn.regions.empty())
348 throw InputError(
349 "setGlobalDependence cannot be combined with finite capacity regions: the region "
350 "generator builds its own transitions and would ignore the scaling");
351 const std::size_t M = sn.stations.size(), K = sn.nclasses, n = space.size();
352 const std::size_t max_entries = 30000000u;
353 if (n * M * K > max_entries)
354 throw InputError(
355 "the global dependence table would exceed the state budget; lower the cutoff");
356 const Matrix<T> aggr = ctmc_state_space_aggr(sn, space);
357 Matrix<T> out(n, M * K, num_traits<T>::from_int(1));
358 std::vector<T> npop(M * K, zero);
359 for (std::size_t s = 0; s < n; ++s) {
360 for (std::size_t i = 0; i < M * K; ++i) npop[i] = aggr(s, i);
361 const std::vector<T> v = sn.gdscaling(npop);
362 for (std::size_t i = 0; i < M; ++i)
363 for (std::size_t r = 0; r < K; ++r) {
364 const T f = v.size() == 1 ? v[0] : (v.size() == M ? v[i] : v[i * K + r]);
365 if (!(num_traits<T>::to_double(f) >= 0))
366 throw InputError(
367 "the global dependence handle returned a non-finite or negative scaling");
368 out(s, i * K + r) = f;
369 }
370 }
371 return out;
372}
373
374/**
375 * Port of `ctmc_find_vanishing_states` (`solver_ctmc.m:928`): the indices of the
376 * VANISHING (zero-sojourn) global states.
377 *
378 * A state is vanishing when the model leaves it at the `GlobalConstants`
379 * Immediate scale rather than at a modelled rate, so its sojourn is an artefact
380 * of realising "instantaneous" as a very fast exponential. Four sources, which
381 * are the whole list:
382 *
383 * - a Router or Fork holding a job. Neither performs service: the job is in
384 * transit and leaves on the next event.
385 * - a Join whose sibling set is COMPLETE for some original class, so the
386 * rendezvous can fire. Only on an FJ-augmented struct -- without the tag
387 * classes a Join buffers nothing and its departures are timed elsewhere.
388 * - an SPN marking from which an ENABLE moves the Transition's own row.
389 * - an SPN marking enabling a `TimingStrategy::IMMEDIATE` firing mode.
390 *
391 * The same predicate drives BOTH the vanishing-row purge and the stochastic
392 * complementation, which is why it is one function: a row purged as vanishing
393 * and then left in the chain would carry only its immediate arcs and dominate
394 * the stationary vector with a 1e-8 sojourn, and a row complemented out without
395 * being purged would push its timed arcs into the tangible states.
396 *
397 * @return 0-based row indices, ascending and unique
398 */
399template <class T>
400std::vector<std::size_t> ctmc_find_vanishing_states(
401 const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space,
402 const std::vector<qn::GlobalSync<T>>& gsync, bool isfjaug) {
403 const std::size_t n = space.size();
404 const std::size_t R = sn.nclasses;
405 std::vector<bool> mark(n, false);
406
407 // Router / Fork pass-through occupancy.
408 for (std::size_t ind = 1; ind <= sn.nodes.size(); ++ind) {
409 const NodeType nt = sn.nodes[ind - 1].nodetype;
410 if (nt != NodeType::Router && nt != NodeType::Fork) continue;
411 if (sn.nodes[ind - 1].station != 0) continue; // stateful and NOT a station
412 const std::size_t isf = sn.stateful_index(ind);
413 if (isf == 0) continue;
414 for (std::size_t s = 0; s < n; ++s) {
415 if (mark[s]) continue;
416 const std::pair<T, std::vector<T>> mg =
417 qn::to_marginal_aggr(sn, ind, space[s].local[isf - 1]);
418 for (std::size_t r = 0; r < R && r < mg.second.size(); ++r) {
419 const double v = num_traits<T>::to_double(mg.second[r]);
420 if (std::isfinite(v) && v > 0.0) {
421 mark[s] = true;
422 break;
423 }
424 }
425 }
426 }
427
428 // Join rendezvous ready to fire.
429 if (isfjaug) {
430 for (std::size_t ind = 1; ind <= sn.nodes.size(); ++ind) {
431 if (sn.nodes[ind - 1].nodetype != NodeType::Join) continue;
432 const std::size_t isf = sn.stateful_index(ind);
433 if (isf == 0) continue;
434 const typename std::map<std::size_t, qn::FjJoinParam>::const_iterator jit =
435 sn.fjjoinparam.find(ind);
436 if (jit == sn.fjjoinparam.end()) continue;
437 const std::vector<std::size_t>& origcl = jit->second.origclasses;
438 for (std::size_t s = 0; s < n; ++s) {
439 if (mark[s]) continue;
440 for (std::size_t x = 0; x < origcl.size(); ++x) {
442 sn, ind, space[s].local[isf - 1], EventType::DEP, origcl[x]);
443 if (!oj.space.empty()) {
444 mark[s] = true;
445 break;
446 }
447 }
448 }
449 }
450 }
451
452 // SPN: an ENABLE that moves the Transition's row, and an IMMEDIATE firing.
453 for (std::size_t g = 0; g < gsync.size(); ++g) {
454 const qn::ModeEvent<T>& ae = gsync[g].active;
455 const std::size_t isf_t = sn.stateful_index(ae.node);
456 if (isf_t == 0) continue;
457 bool is_enable = ae.event == EventType::ENABLE;
458 bool is_imm_fire = false;
459 if (!is_enable && ae.event == EventType::FIRE) {
460 const typename std::map<std::size_t, qn::TransitionParam<T>>::const_iterator it =
461 sn.transparam.find(ae.node);
462 is_imm_fire = it != sn.transparam.end() && ae.mode >= 1 &&
463 ae.mode <= it->second.timing.size() &&
464 it->second.timing[ae.mode - 1] == lang::TimingStrategy::IMMEDIATE;
465 }
466 if (!is_enable && !is_imm_fire) continue;
467 for (std::size_t s = 0; s < n; ++s) {
468 if (mark[s]) continue;
469 const qn::GlobalOutcome<T> go = qn::after_global_event(sn, space[s], gsync[g]);
470 for (std::size_t io = 0; io < go.space.size(); ++io) {
471 if (num_traits<T>::to_double(go.rate[io]) <= 0) continue;
472 // AN ENABLE THAT LEAVES THE ROW ALONE IS NOT A MOVE. The
473 // reference tests the Transition's OWN row rather than the whole
474 // marking (`solver_ctmc.m:983`): an enabling that finds the mode
475 // already enabled re-emits the same state and takes no time to
476 // do nothing, which is not a vanishing state.
477 if (is_imm_fire || go.space[io].local[isf_t - 1] != space[s].local[isf_t - 1]) {
478 mark[s] = true;
479 break;
480 }
481 }
482 }
483 }
484
485 std::vector<std::size_t> imm;
486 for (std::size_t s = 0; s < n; ++s)
487 if (mark[s]) imm.push_back(s);
488 return imm;
489}
490
491/**
492 * Port of the generator assembly of `solver_ctmc.m`.
493 *
494 * @param sn the network struct
495 * @param space the enumerated state space, from `space_generator`
496 * @param sync the synchronization list, from `refresh_sync`
497 * @param gsync the SPN global synchronizations, from `refresh_gsync`
498 * @param fjsync the fork firing list, from `fj_tag`
499 * @param want_filtration also return the per-synchronisation rate matrices (the filtration), which sampling and reward paths need
500 */
501template <class T>
502CtmcResult<T> solver_ctmc(const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space,
503 const std::vector<Sync<T>>& sync,
504 const std::vector<qn::GlobalSync<T>>& gsync =
505 std::vector<qn::GlobalSync<T>>(),
506 bool want_filtration = false,
507 const std::vector<qn::FjSync<T>>& fjsync =
508 std::vector<qn::FjSync<T>>()) {
509 const std::size_t n = space.size();
510 const std::size_t local = sn.nodes.size() + 1; // the dummy passive node
511 const T zero = num_traits<T>::from_int(0);
512 CtmcResult<T> res;
513 res.space = space;
514 res.Q = Matrix<T>(n, n, zero);
515
516 const std::size_t NF = sn.stateful_nodes.size();
517 const std::size_t R = sn.nclasses;
518 res.arv_rates.assign(n, std::vector<std::vector<T>>(NF, std::vector<T>(R, zero)));
519 res.dep_rates.assign(n, std::vector<std::vector<T>>(NF, std::vector<T>(R, zero)));
520
521 if (want_filtration) {
522 res.filt.assign(sync.size(), Matrix<T>(n, n, zero));
523 res.start_filt.assign(sn.nstations, std::vector<Matrix<T>>(R, Matrix<T>(n, n, zero)));
524 res.preempt_filt.assign(sn.nstations, std::vector<Matrix<T>>(R, Matrix<T>(n, n, zero)));
525 }
526
527 // `immAction` / `immGsync` of `solver_ctmc.m:430-467`: which sources emit at
528 // the GlobalConstants::Immediate scale, and therefore land in `Qimm`. A Join
529 // counts only on an FJ-AUGMENTED struct -- without the tag classes a Join is
530 // an ordinary pass-through whose departures are timed by the siblings.
531 const bool isfjaug = !fjsync.empty();
532 std::vector<bool> imm_action(sync.size(), false);
533 bool has_imm = !fjsync.empty();
534 for (std::size_t a = 0; a < sync.size(); ++a) {
535 const std::size_t na = sync[a].active.node;
536 if (na == 0 || na > sn.nodes.size()) continue;
537 const NodeType nt = sn.nodes[na - 1].nodetype;
538 imm_action[a] = nt == NodeType::Router || nt == NodeType::Fork ||
539 (isfjaug && nt == NodeType::Join);
540 if (imm_action[a]) has_imm = true;
541 }
542 std::vector<bool> imm_gsync(gsync.size(), false);
543 for (std::size_t g = 0; g < gsync.size(); ++g) {
544 const qn::ModeEvent<T>& ae = gsync[g].active;
545 if (ae.event == EventType::ENABLE) {
546 imm_gsync[g] = true;
547 } else if (ae.event == EventType::FIRE) {
548 const typename std::map<std::size_t, qn::TransitionParam<T>>::const_iterator it =
549 sn.transparam.find(ae.node);
550 imm_gsync[g] = it != sn.transparam.end() && ae.mode >= 1 &&
551 ae.mode <= it->second.timing.size() &&
552 it->second.timing[ae.mode - 1] == lang::TimingStrategy::IMMEDIATE;
553 }
554 if (imm_gsync[g]) has_imm = true;
555 }
556 if (has_imm) {
557 res.Qimm = Matrix<T>(n, n, zero);
558 res.arv_rates_imm.assign(n, std::vector<std::vector<T>>(NF, std::vector<T>(R, zero)));
559 res.dep_rates_imm.assign(n, std::vector<std::vector<T>>(NF, std::vector<T>(R, zero)));
560 }
561
562 // The true-BAS become-blocked edges, kept out of Q until the end because they
563 // are not departures: they must reach the generator but not `dep_rates`.
564 bool any_bas = false;
565 for (std::size_t i = 0; i < sn.isbasblocking.size(); ++i)
566 if (sn.isbasblocking[i]) any_bas = true;
567 Matrix<T> bas_block(any_bas ? n : 0, any_bas ? n : 0, zero);
568
569 std::map<std::vector<double>, std::size_t> index;
570 for (std::size_t s = 0; s < n; ++s) index[ctmc_detail::state_key(space[s])] = s;
571
572 // phi(n) is constant within a state, so it factors out of every rate there
573 const Matrix<T> gd = ctmc_gd_factor(sn, space);
574 const bool has_gd = gd.rows() != 0;
575
576 // The state-dependent routing table, one per state. It is tabulated here for
577 // the same reason phi(n) is: the loop below runs synchronization-outer and
578 // state-inner, so evaluating eq. (10) inside it would redo one stochastic
579 // complement per (sync, state) pair rather than one per state.
580 std::vector<Matrix<T>> rt_by_state;
581 if (sn.has_sdr_routing()) {
582 rt_by_state.reserve(n);
583 for (std::size_t s = 0; s < n; ++s) rt_by_state.push_back(qn::rt_state(sn, space[s].local));
584 }
585
586 for (std::size_t a = 0; a < sync.size(); ++a) {
587 const Sync<T>& sy = sync[a];
588 const std::size_t node_a = sy.active.node;
589 const std::size_t isf_a = sn.stateful_index(node_a);
590 if (isf_a == 0) continue; // a stateless node schedules nothing
591 const std::size_t node_p = sy.passive.node;
592 const std::size_t isf_p = node_p == local ? 0 : sn.stateful_index(node_p);
593 if (node_p != local && isf_p == 0) continue;
594
595 // PHASE is scaled too, or phase-type service would advance unscaled
596 const bool gd_here = has_gd && sn.nodes[node_a - 1].station != 0 &&
597 (sy.active.event == EventType::DEP ||
598 sy.active.event == EventType::PHASE);
599 const std::size_t gd_col = gd_here ? (sn.nodes[node_a - 1].station - 1) * sn.nclasses +
600 (sy.active.cls - 1)
601 : 0;
602 // A round-robin dispatcher decides the destination from its own pointer;
603 // see the proute branch below.
604 const bool rr_here = sy.active.event == EventType::DEP &&
605 sn.rr_var_slot(node_a, sy.active.cls) != 0;
606 // Immediate feedback (sn.immfeed): the departure half of a self-loop must
607 // not promote a waiting job, so the fed-back arrival finds the server it
608 // just left free. See qn::immfeed_self_loop.
609 const bool immfeed_a = qn::immfeed_self_loop(sn, sy);
610 for (std::size_t s = 0; s < n; ++s) {
611 const NetState<T>& st = space[s];
612 T fired = zero; // the rate this synchronization contributes here
613 const EventOutcome<T> oa = qn::after_event(sn, node_a, st.local[isf_a - 1],
614 sy.active.event, sy.active.cls, immfeed_a);
615 for (std::size_t ia = 0; ia < oa.space.size(); ++ia) {
616 const T rate = gd_here ? T(oa.rate[ia] * gd(s, gd_col)) : oa.rate[ia];
617 // A zero-rate successor is a state the reference still emits so
618 // that the event exists; it contributes nothing to the balance
619 // equations, so skip it here rather than adding a zero.
620 if (num_traits<T>::to_double(rate) == 0) continue;
621
622 if (node_p == local) {
623 // A local action moves no job elsewhere: only the active
624 // node's block changes.
625 NetState<T> nsx = st;
626 nsx.local[isf_a - 1] = oa.space[ia];
627 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
628 index.find(ctmc_detail::state_key(nsx));
629 if (it == index.end()) continue;
630 res.Q(s, it->second) += T(rate * oa.prob[ia]);
631 if (imm_action[a]) res.Qimm(s, it->second) += T(rate * oa.prob[ia]);
632 if (want_filtration) {
633 res.filt[a](s, it->second) += T(rate * oa.prob[ia]);
634 // local action: only the active node can tag
635 ctmc_detail::add_aux_filt(res, sn, node_a, s, it->second,
636 T(rate * oa.prob[ia]), oa, ia);
637 }
638 fired += T(rate * oa.prob[ia]);
639 continue;
640 }
641
642 // A self-loop synchronization reads the passive node's state
643 // AFTER the active half has been applied, since they are the
644 // same node; otherwise the two halves see independent blocks.
645 const std::vector<T>& src =
646 node_p == node_a ? oa.space[ia] : st.local[isf_p - 1];
647 const EventOutcome<T> op =
648 qn::after_event(sn, node_p, src, sy.passive.event, sy.passive.cls);
649 // The routing probability, read at the state the job LEAVES from,
650 // which is what `sub_sdr` reads: the branch it is admitted to is
651 // decided by the populations the departing customer sees.
652 //
653 // ROUND-ROBIN IS THE ONE THAT READS THE STATE AFTER. Its
654 // destination is the pointer the ACTIVE node carries once its own
655 // departure has advanced it, so the probability is the 0/1
656 // indicator of that pointer and not the uniform mask
657 // `refresh_routing` wrote into `rt`. Reading `rt` here instead
658 // would spread the job over every outlink, which is the random
659 // routing the dispatcher exists not to be. This is the
660 // reference's `sub_rr`, which likewise takes `state_after`.
661 T proute = sy.passive.statedep
662 ? rt_by_state[s](sy.passive.rt_row, sy.passive.rt_col)
663 : sy.passive.prob;
664 if (rr_here) {
665 const std::size_t w = sn.nvars_of(node_a);
666 const std::vector<T>& arow = oa.space[ia];
667 std::size_t dest = 0;
668 if (arow.size() >= w) {
669 const std::vector<T> var(arow.end() - w, arow.end());
670 dest = sn.rr_dest(node_a, sy.active.cls, var);
671 }
672 proute = (dest == node_p && sy.passive.cls == sy.active.cls)
674 : zero;
675 }
676 bool placed = false;
677 for (std::size_t ip = 0; ip < op.space.size(); ++ip) {
678 NetState<T> nsx = st;
679 nsx.local[isf_a - 1] = oa.space[ia];
680 nsx.local[isf_p - 1] = op.space[ip];
681 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
682 index.find(ctmc_detail::state_key(nsx));
683 if (it == index.end()) continue;
684 placed = true;
685 // THE ACTIVE HALF'S OWN PROBABILITY COUNTS TOO. `oa.prob[ia]`
686 // is the share of the completion that leads to THIS successor
687 // -- SIRO's random pick of the next job to promote is the only
688 // branch that returns it below 1 -- and the LOCAL branch above
689 // already multiplies by it. Omitting it here gave every
690 // promotion candidate the FULL service rate, so a SIRO station
691 // with two waiting classes left the state at 2*mu: on
692 // prio_hol_open that is Source Tput 0.052334 against the
693 // reference's 0.052281. The reference folds the same share
694 // into its `outrate` instead (afterEventStation.m, `pick_prob`)
695 // and keeps `outprob` at 1, which is the same product.
696 const T w = T(rate * oa.prob[ia] * proute * op.prob[ip]);
697 res.Q(s, it->second) += w;
698 if (imm_action[a]) res.Qimm(s, it->second) += w;
699 if (want_filtration) {
700 res.filt[a](s, it->second) += w;
701 // Both halves of the synchronization are tagged: a DEP
702 // promotes at the sender while the paired ARV starts or
703 // preempts at the receiver.
704 ctmc_detail::add_aux_filt(res, sn, node_a, s, it->second, w, oa, ia);
705 ctmc_detail::add_aux_filt(res, sn, node_p, s, it->second, w, op, ip);
706 }
707 fired += w;
708 }
709 // TRUE BAS, the become-blocked half. The passive arrival was
710 // refused at every outcome, so the completing job cannot leave.
711 // Only the generator can emit this edge: the event layer sees one
712 // node at a time and cannot know the destination is full.
713 //
714 // The successor is the CURRENT state with the marker set, not the
715 // post-departure one: the job stays in the server it completed in,
716 // which is the whole content of blocking after service. It is
717 // accumulated separately from Q and folded in below because it is
718 // NOT a departure -- counting it in `dep_rates` would inflate
719 // throughput by the blocked transitions.
720 if (!placed && sy.active.event == EventType::DEP &&
721 node_a <= sn.isbasblocking.size() && sn.isbasblocking[node_a - 1] &&
722 !st.local[isf_a - 1].empty() &&
723 num_traits<T>::to_double(st.local[isf_a - 1].back()) == 0) {
724 NetState<T> nsb = st;
725 nsb.local[isf_a - 1].back() = num_traits<T>::from_int(1);
726 const typename std::map<std::vector<double>, std::size_t>::const_iterator ib =
727 index.find(ctmc_detail::state_key(nsb));
728 // THE ROUTING PROBABILITY IS NOT APPLIED, verbatim from
729 // `solver_ctmc.m:361`, which adds `rate_a(ia)` alone. It makes
730 // no difference where the blocked destination is the only one,
731 // and `solver_ctmc_avg_from_pi` declines to shift queue lengths
732 // at a station with several destinations anyway.
733 if (ib != index.end()) bas_block(s, ib->second) += rate;
734 }
735 }
736 // A DEP synchronization is one job LEAVING the active node and
737 // ENTERING the passive one, so the same accumulated rate is both a
738 // departure there and an arrival here. The passive half of a LOCAL
739 // action is the dummy node, which is nobody's arrival.
740 if (sy.active.event == EventType::DEP && num_traits<T>::to_double(fired) != 0) {
741 res.dep_rates[s][isf_a - 1][sy.active.cls - 1] += fired;
742 if (isf_p != 0) res.arv_rates[s][isf_p - 1][sy.passive.cls - 1] += fired;
743 // ONLY AN FJ-AUGMENTED JOIN takes the rate complement, verbatim
744 // from `solver_ctmc.m:841`: a Router or Fork DEP is restricted
745 // to the tangible rows like any timed action, and only a Join
746 // firing -- which exists nowhere else -- is complemented back.
747 if (isfjaug && sn.nodes[node_a - 1].nodetype == NodeType::Join) {
748 res.dep_rates_imm[s][isf_a - 1][sy.active.cls - 1] += fired;
749 if (isf_p != 0)
750 res.arv_rates_imm[s][isf_p - 1][sy.passive.cls - 1] += fired;
751 }
752 }
753 }
754 }
755
756 // SPN global synchronizations. A firing is ATOMIC across all its arcs, so
757 // unlike an ordinary sync it rewrites several nodes in one transition and
758 // cannot be decomposed into per-node halves.
759 //
760 // A PLACE'S FLOW IS COUNTED HERE OR NOWHERE. `refresh_sync` emits no DEP
761 // sync touching a Place -- a token crosses an arc of a firing, not a routing
762 // edge -- so leaving this loop to write only Q left `dep_rates` and
763 // `arv_rates` identically zero at every Place, and the AvgTable reported
764 // Tput 0 (hence RespT 0) for a Place whose QLen and Util were right. The
765 // reference accumulates the same two from `Dfilt_gsync_comp`
766 // (solver_ctmc.m:624-649): the PRE passives of a FIRE are that place's
767 // departures and the POST passives its arrivals.
768 //
769 // ONLY A COMPLETION COUNTS, which is what `GlobalOutcome::completion`
770 // records and what the reference's `is_comp` gates `Dfilt_gsync_comp` on. A
771 // FIRE outcome that merely starts a firing phase moves no token, and an
772 // ENABLE outcome never does, so neither is a flow.
773 for (std::size_t g = 0; g < gsync.size(); ++g) {
774 const bool is_fire = gsync[g].active.event == EventType::FIRE;
775 for (std::size_t s = 0; s < n; ++s) {
776 const qn::GlobalOutcome<T> go = qn::after_global_event(sn, space[s], gsync[g]);
777 T completed = zero;
778 for (std::size_t io = 0; io < go.space.size(); ++io) {
779 const T contrib = T(go.rate[io] * go.prob[io]);
780 if (num_traits<T>::to_double(contrib) == 0) continue;
781 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
782 index.find(ctmc_detail::state_key(go.space[io]));
783 if (it == index.end()) continue;
784 res.Q(s, it->second) += contrib;
785 if (imm_gsync[g]) res.Qimm(s, it->second) += contrib;
786 if (is_fire && io < go.completion.size() && go.completion[io]) completed += contrib;
787 }
788 if (!is_fire || num_traits<T>::to_double(completed) == 0) continue;
789 for (std::size_t j = 0; j < gsync[g].passive.size(); ++j) {
790 const qn::ModeEvent<T>& pev = gsync[g].passive[j];
791 if (pev.node == 0 || pev.node > sn.nodes.size()) continue;
792 const std::size_t isf_v = sn.stateful_index(pev.node);
793 if (isf_v == 0 || pev.cls == 0 || pev.cls > sn.nclasses) continue;
794 // THE ARC MULTIPLICITY IS PART OF THE FLOW. A place loses (or
795 // gains) `weight` tokens per firing, not one, so a rate counted
796 // per firing is a firing rate and not a job rate. Measured
797 // against the reference: `spn_closed_fourplaces`, whose cycle moves 2
798 // tokens a firing and the unweighted count reported exactly half
799 // its throughput, while `spn_twomodes`, whose two arcs have
800 // multiplicities 4 and 2, was off by exactly those two factors
801 // at its two places. Multiplicity 1 is the common case and
802 // leaves `spn_basic_closed`/`_open` unchanged.
803 const T flow = T(completed * pev.weight);
804 // EVERY FIRE completion takes the rate complement, not only an
805 // immediate one: `solver_ctmc.m:833-855` complements
806 // `Dfilt_gsync_comp{g}` for all of them, and for a timed mode the
807 // vanishing rows contribute nothing so the two agree.
808 if (pev.event == EventType::PRE) {
809 res.dep_rates[s][isf_v - 1][pev.cls - 1] += flow;
810 if (has_imm) res.dep_rates_imm[s][isf_v - 1][pev.cls - 1] += flow;
811 } else if (pev.event == EventType::POST) {
812 res.arv_rates[s][isf_v - 1][pev.cls - 1] += flow;
813 if (has_imm) res.arv_rates_imm[s][isf_v - 1][pev.cls - 1] += flow;
814 }
815 }
816 }
817 }
818
819 // FORK FIRINGS. Like an SPN firing this is atomic across several nodes, so it
820 // takes the whole network state and cannot be decomposed into sync halves.
821 //
822 // The rate statistics are accumulated by hand rather than through the DEP
823 // path: a firing is one DEPARTURE of the parent class at the fork and one
824 // ARRIVAL of the tag's auxiliary class at each branch head, which is B
825 // arrivals for one departure and is exactly what an ordinary sync cannot
826 // express. `refresh_sync` therefore emits no DEP sync for a Fork.
827 for (std::size_t k = 0; k < fjsync.size(); ++k) {
828 const qn::FjSync<T>& e = fjsync[k];
829 const std::size_t isf_f = sn.stateful_index(e.fork);
830 if (isf_f == 0) continue;
831 for (std::size_t s = 0; s < n; ++s) {
832 const qn::GlobalOutcome<T> fo = qn::after_fj_event(sn, e, space[s]);
833 T fired = zero;
834 for (std::size_t io = 0; io < fo.space.size(); ++io) {
835 const T contrib = T(fo.rate[io] * fo.prob[io]);
836 if (num_traits<T>::to_double(contrib) <= 0) continue;
837 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
838 index.find(ctmc_detail::state_key(fo.space[io]));
839 if (it == index.end()) continue;
840 res.Q(s, it->second) += contrib;
841 res.Qimm(s, it->second) += contrib;
842 fired += contrib;
843 }
844 if (num_traits<T>::to_double(fired) == 0) continue;
845 res.dep_rates[s][isf_f - 1][e.cls - 1] += fired;
846 res.dep_rates_imm[s][isf_f - 1][e.cls - 1] += fired;
847 for (std::size_t b = 0; b < e.branchheads.size(); ++b) {
848 const std::size_t isf_b = sn.stateful_index(e.branchheads[b]);
849 if (isf_b == 0) continue;
850 res.arv_rates[s][isf_b - 1][e.auxclasses[b] - 1] += fired;
851 res.arv_rates_imm[s][isf_b - 1][e.auxclasses[b] - 1] += fired;
852 }
853 }
854 }
855
856 if (any_bas)
857 for (std::size_t i = 0; i < n; ++i)
858 for (std::size_t j = 0; j < n; ++j) res.Q(i, j) += bas_block(i, j);
859
860 // THE VANISHING-ROW PURGE, `solver_ctmc.m:592-620`. A zero-sojourn state is
861 // left the instant it is entered, so a TIMED arc out of one describes an
862 // event that cannot occur -- the state is gone before the clock advances.
863 // Restating such a row as its immediate part is what makes the stochastic
864 // complement below the elimination of the immediate transitions rather than
865 // a censoring of a chain that still contains them.
866 if (has_imm) {
867 res.vanishing = ctmc_find_vanishing_states(sn, space, gsync, isfjaug);
868 // A vanishing state with NO immediate exit is a disagreement between the
869 // predicate and the arc tagging, not a model: purging it would leave an
870 // absorbing row and complementing it out would make the censored block
871 // singular. The reference purges neither but complements it anyway and
872 // lands on a NaN; drop it from both and say so.
873 std::vector<std::size_t> keep_van;
874 std::size_t gap = 0;
875 for (std::size_t x = 0; x < res.vanishing.size(); ++x) {
876 const std::size_t s = res.vanishing[x];
877 T out_imm = zero;
878 for (std::size_t j = 0; j < n; ++j)
879 if (j != s) out_imm += res.Qimm(s, j);
880 if (num_traits<T>::to_double(out_imm) > 0)
881 keep_van.push_back(s);
882 else
883 ++gap;
884 }
885 if (gap > 0)
887 "CTMC: %zu vanishing state(s) have no immediate outgoing arc; the vanishing "
888 "predicate and the immediate-arc tagging disagree, so those rows keep their "
889 "timed arcs",
890 gap);
891 res.vanishing.swap(keep_van);
892 for (std::size_t x = 0; x < res.vanishing.size(); ++x) {
893 const std::size_t s = res.vanishing[x];
894 for (std::size_t j = 0; j < n; ++j) res.Q(s, j) = res.Qimm(s, j);
895 res.arv_rates[s] = res.arv_rates_imm[s];
896 res.dep_rates[s] = res.dep_rates_imm[s];
897 for (std::size_t a = 0; a < res.filt.size(); ++a)
898 if (!imm_action[a])
899 for (std::size_t j = 0; j < n; ++j) res.filt[a](s, j) = zero;
900 }
901 }
902
903 make_infgen(res.Q);
904 return res;
905}
906
907/**
908 * Port of the "now remove immediate transitions" block of `solver_ctmc.m`
909 * (:812-870): eliminate the vanishing states by stochastic complementation.
910 *
911 * WHAT IT CHANGES AND WHAT IT MUST NOT. The chain shrinks to its TANGIBLE states
912 * and every metric read from it is unchanged to the digits printed, because the
913 * vanishing states carry ~1e-8 of the mass -- which is exactly why the omission
914 * was invisible until a caller asked for the chain itself. `-a states` and
915 * `-a gen` are the callers that see it: on `fj_tiny_closed` this takes the six
916 * enumerated states to the four the other three codebases return.
917 *
918 * THE RATE COMPLEMENT IS NOT OPTIONAL. An action that fires ONLY from vanishing
919 * states -- a fork firing, a join rendezvous, an immediate SPN mode -- has no
920 * tangible row to be read off, so restricting `arv_rates` / `dep_rates` to the
921 * tangible states alone would silently zero its flow. The rate observed from a
922 * tangible state is its own plus the expected number of firings along the
923 * vanishing excursion entered from it,
924 *
925 * r = total(nonimm) + Q12 (-Q22)^-1 imm_part(imm),
926 *
927 * which is `solver_ctmc_ratecomplement` applied to the immediate part and added
928 * to the plain restriction of the total. The two agree term by term with the
929 * reference's per-filtration form, since the totals are already the row sums.
930 *
931 * A no-op when the model declared no immediate source, which is the common case.
932 */
933template <class T>
935 const T zero = num_traits<T>::from_int(0);
936 if (res.vanishing.empty()) {
937 res.Qimm = Matrix<T>();
938 res.arv_rates_imm.clear();
939 res.dep_rates_imm.clear();
940 return;
941 }
942 const std::size_t n = res.Q.rows();
943 std::vector<bool> is_van(n, false);
944 for (std::size_t x = 0; x < res.vanishing.size(); ++x) is_van[res.vanishing[x]] = true;
945 std::vector<std::size_t> nonimm;
946 nonimm.reserve(n - res.vanishing.size());
947 for (std::size_t s = 0; s < n; ++s)
948 if (!is_van[s]) nonimm.push_back(s);
949 if (nonimm.empty())
950 throw NumericError(
951 "SolverCTMC: every state is vanishing; the chain has no tangible state to observe");
952
953 const mc::StochCompResult<T> sc = mc::ctmc_stochcomp(res.Q, nonimm);
954 const std::size_t nk = nonimm.size(), nd = res.vanishing.size();
955
956 // The rate complement, batched: one elimination of (-Q22) serves every
957 // (stateful, class) pair of both directions, which is what keeps the cost at
958 // one factorization rather than 2*NF*R of them.
959 const std::size_t NF = res.arv_rates.empty() ? 0 : res.arv_rates[0].size();
960 const std::size_t R = NF == 0 ? 0 : res.arv_rates[0][0].size();
961 const std::size_t ncol = 2 * NF * R;
962 Matrix<T> corr;
963 if (ncol > 0) {
964 Matrix<T> B(nd, ncol, zero);
965 for (std::size_t d = 0; d < nd; ++d) {
966 const std::size_t s = res.vanishing[d];
967 for (std::size_t f = 0; f < NF; ++f)
968 for (std::size_t r = 0; r < R; ++r) {
969 B(d, f * R + r) = res.arv_rates_imm[s][f][r];
970 B(d, NF * R + f * R + r) = res.dep_rates_imm[s][f][r];
971 }
972 }
973 const Matrix<T> X = ctmc_detail::censored_solve(sc.Q22, B);
974 corr = Matrix<T>(nk, ncol, zero);
975 for (std::size_t a = 0; a < nk; ++a)
976 for (std::size_t c = 0; c < ncol; ++c) {
977 T acc = zero;
978 for (std::size_t d = 0; d < nd; ++d) acc = T(acc + sc.Q12(a, d) * X(d, c));
979 corr(a, c) = acc;
980 }
981 }
982
983 std::vector<NetState<T>> space;
984 std::vector<std::vector<std::vector<T>>> arv, dep;
985 space.reserve(nk);
986 arv.reserve(nk);
987 dep.reserve(nk);
988 for (std::size_t a = 0; a < nk; ++a) {
989 space.push_back(res.space[nonimm[a]]);
990 arv.push_back(res.arv_rates[nonimm[a]]);
991 dep.push_back(res.dep_rates[nonimm[a]]);
992 for (std::size_t f = 0; f < NF; ++f)
993 for (std::size_t r = 0; r < R; ++r) {
994 arv[a][f][r] = T(arv[a][f][r] + corr(a, f * R + r));
995 dep[a][f][r] = T(dep[a][f][r] + corr(a, NF * R + f * R + r));
996 }
997 }
998
999 // Each filtration is complemented exactly as Q is: an event that reaches a
1000 // tangible state THROUGH a vanishing excursion belongs on the arc it induces,
1001 // and dropping the excursion rows would lose it from every CDF built on the
1002 // split. The derived START and PREEMPT filtrations take the same treatment --
1003 // a service start that lands on a vanishing state would otherwise undercount.
1004 ctmc_detail::complement_filtrations(res, nonimm, res.vanishing, sc);
1005
1006 res.Q = sc.S;
1007 res.space.swap(space);
1008 res.arv_rates.swap(arv);
1009 res.dep_rates.swap(dep);
1010 res.Qimm = Matrix<T>();
1011 res.arv_rates_imm.clear();
1012 res.dep_rates_imm.clear();
1013 res.vanishing.clear();
1014}
1015
1016
1017/**
1018 * Left-pad an ordered-buffer station's seed row to the width its CAPACITY
1019 * implies, rather than the width its INITIAL MARKING gave it.
1020 *
1021 * `from_marginal_node` sizes an ordered buffer from the occupancy it is asked
1022 * for -- `max(1, n - S)` waiting slots, so an empty FCFS station comes back with
1023 * exactly ONE. The lattice generator never uses that width, because it
1024 * enumerates every marginal up to the capacity and the fullest one sets the
1025 * space. The reachable walk has no such pass, and the arrival handler REFUSES a
1026 * job that finds no free slot instead of appending one -- deliberately, since
1027 * under the lattice a full row means the state-space cutoff and the state above
1028 * the cutoff is absent rather than blocked (`after_event_station`, ORDERED
1029 * BUFFER).
1030 *
1031 * Seeded at the marginal width, an ordered-buffer station therefore cannot hold
1032 * more than `S + 1` jobs for the whole walk. On a fork-join model that truncates
1033 * the chain as soon as three siblings can queue at one branch: the closed
1034 * model of `fj_closed_fcfs` reported X = 1.42385 at N = 3 against the exact
1035 * 1.436073, and got worse with N. The reference reaches the full space by
1036 * growing the buffer inside the arrival handler
1037 * (`State.afterEventStation`, "this section dynamically grows the number of
1038 * elements in the buffer"); widening the seed instead keeps this port's stricter
1039 * width rule and costs one zero-padding per station.
1040 *
1041 * The buffer is right-aligned and leads the row, so zeros on the left add empty
1042 * slots and change nothing else. A station whose capacity is NOT finite is left
1043 * alone: there the walk is bounded by the `cutoff` argument, which censors whole
1044 * states rather than sizing one row, and widening would only invite the walk to
1045 * run past it.
1046 */
1047template <class T>
1049 NetState<T> out = init;
1050 for (std::size_t isf = 0; isf < out.local.size(); ++isf) {
1051 const std::size_t ind = isf < sn.stateful_nodes.size() ? sn.stateful_nodes[isf] : 0;
1052 if (ind == 0 || ind > sn.nodes.size()) continue;
1053 const std::size_t ist = sn.nodes[ind - 1].station;
1054 if (ist == 0) continue; // a Fork, Join or Router carries no buffer/server split
1055 const SchedStrategy sched = sn.stations[ist - 1].sched;
1056 std::size_t percol = 0;
1057 if (qn::state_detail::buffer_is_class_tag(sched)) percol = 1;
1058 else if (qn::state_detail::buffer_is_tag_phase_pairs(sched)) percol = 2;
1059 else continue; // a per-class-count buffer is R wide whatever the occupancy
1060 const double S = sn.stations[ist - 1].nservers;
1061 const double capi = ist <= sn.cap.size() ? sn.cap[ist - 1] : 0.0;
1062 if (!std::isfinite(S) || !std::isfinite(capi) || capi <= 0) continue;
1063 std::size_t srvw = 0;
1064 for (std::size_t r = 0; r < sn.nclasses; ++r) srvw += sn.phasessz_of(ist, r + 1);
1065 const std::size_t nvar = sn.nvars_of(ind);
1066 if (out.local[isf].size() <= srvw + nvar) continue;
1067 const std::size_t bufw = out.local[isf].size() - srvw - nvar;
1068 const long slots = static_cast<long>(capi) - static_cast<long>(S);
1069 const std::size_t want = static_cast<std::size_t>(slots > 1 ? slots : 1) * percol;
1070 if (want <= bufw) continue;
1071 std::vector<T> row(want - bufw, num_traits<T>::from_int(0));
1072 row.insert(row.end(), out.local[isf].begin(), out.local[isf].end());
1073 out.local[isf] = row;
1074 }
1075 return out;
1076}
1077
1078
1079/**
1080 * Port of `State.reachableSpaceGenerator`: the states reachable from `init`.
1081 *
1082 * `space_generator` enumerates every state the ENCODING admits; this walks the
1083 * ones the DYNAMICS can actually occupy. The two differ whenever the encoding
1084 * is wider than the model -- a retrial station's idle-server states are
1085 * reachable, whereas an ordinary queue's are not, and enumerating the latter
1086 * leaves a generator with absorbing junk that perturbs the stationary vector
1087 * after normalization.
1088 *
1089 * The walk applies exactly the same handlers the generator does, so a state is
1090 * included precisely when some synchronization produces it at a positive rate.
1091 *
1092 * IT IS THE ONLY GENERATOR AN SPN HAS. `from_marginal_node` emits a single row
1093 * for a Transition -- every mode's servers free, nothing firing -- because a
1094 * transition's state is per-MODE and no population marginal determines it. The
1095 * lattice enumeration therefore never produces a state in which a mode is
1096 * firing, and a generator built over that space has every ENABLE landing
1097 * outside it. Walking `gsync` from the idle state is what materializes them,
1098 * which is why the reference forces `state_space_gen='reachable'` for any model
1099 * whose firings break per-chain population conservation.
1100 *
1101 * `cutoff` TRUNCATES AN OPEN CLASS, and without it this walk does not terminate.
1102 * The lattice generator bounds an open class's total population by the cutoff;
1103 * this walk had no such bound, so on ANY open SPN -- `spn_basic_open` at cutoff
1104 * 1, `spn_pareto_service`, `spn_open_sevenplaces` -- the Source kept producing
1105 * tokens and the walk ran to the `maxst` cap instead of answering. Passing the
1106 * same cutoff makes the two paths mean the same thing by "cutoff": a candidate
1107 * whose open-class population would exceed it is not a state of the truncated
1108 * chain, so the arc to it simply does not exist and `make_infgen` re-closes the
1109 * row, which is exactly what the lattice path leaves behind. Empty means
1110 * unbounded, which is right for a closed model and is what every existing
1111 * caller passes.
1112 *
1113 * THE INITIAL MARKING RAISES THE BOUND WHERE IT EXCEEDS IT. A Place may start
1114 * with more tokens than the cutoff -- `spn_open_sevenplaces` puts 2 in P1
1115 * against a default cutoff of 2 -- and a bound below the state the walk starts
1116 * from censors every successor of it, leaving the initial state alone in a
1117 * chain that is not the model's. A state space that cannot contain its own
1118 * initial state is empty by construction, so the floor is the initial marking.
1119 */
1120template <class T>
1121std::vector<NetState<T>> reachable_space_generator(
1122 const NetworkStruct<T>& sn, const NetState<T>& init, const std::vector<Sync<T>>& sync,
1123 const std::vector<qn::GlobalSync<T>>& gsync = std::vector<qn::GlobalSync<T>>(),
1124 std::size_t maxst = 3000000,
1125 const std::vector<qn::FjSync<T>>& fjsync = std::vector<qn::FjSync<T>>(),
1126 const std::vector<std::size_t>& cutoff = std::vector<std::size_t>(),
1127 const std::vector<std::vector<std::size_t>>& cutoff_mat =
1128 std::vector<std::vector<std::size_t>>()) {
1129 const std::size_t local = sn.nodes.size() + 1;
1130 // The seed carries the width of the INITIAL MARKING; the walk needs the
1131 // width the station's capacity implies, because the arrival handler refuses
1132 // rather than grows. See `reachable_seed_widen`.
1133 const NetState<T> seed = reachable_seed_widen(sn, init);
1134 std::vector<NetState<T>> out;
1135 std::map<std::vector<double>, std::size_t> seen;
1136 std::vector<std::size_t> stack;
1137 const std::vector<double> njobs = sn.njobs();
1138 // Only an OPEN class can leave the bound, so a closed model pays nothing.
1139 bool bound = false;
1140 for (std::size_t r = 0; r < sn.nclasses && r < njobs.size(); ++r)
1141 if (!std::isfinite(njobs[r]) && r < cutoff.size() && cutoff[r] > 0) bound = true;
1142 const bool bounded = bound;
1143 std::vector<std::size_t> lim = cutoff;
1144 if (bounded) {
1145 const std::vector<double> n0 = ctmc_detail::state_peak_occupancy(sn, seed);
1146 for (std::size_t r = 0; r < lim.size() && r < n0.size(); ++r) {
1147 const std::size_t p0 =
1148 n0[r] > 0 ? static_cast<std::size_t>(std::floor(n0[r] + 0.5)) : 0;
1149 if (p0 > lim[r]) lim[r] = p0;
1150 }
1151 }
1152
1153 seen[ctmc_detail::state_key(seed)] = 0;
1154 out.push_back(seed);
1155 stack.push_back(0);
1156
1157 while (!stack.empty()) {
1158 const std::size_t si = stack.back();
1159 stack.pop_back();
1160 const NetState<T> st = out[si]; // by value: `out` grows inside the loop
1161
1162 for (std::size_t a = 0; a < sync.size(); ++a) {
1163 const Sync<T>& sy = sync[a];
1164 const std::size_t isf_a = sn.stateful_index(sy.active.node);
1165 if (isf_a == 0) continue;
1166 const std::size_t isf_p =
1167 sy.passive.node == local ? 0 : sn.stateful_index(sy.passive.node);
1168 if (sy.passive.node != local && isf_p == 0) continue;
1169
1170 // The reachable set must be walked with the same arcs the generator
1171 // uses, or immediate feedback would reach states the generator never
1172 // fills in (and miss the ones it does). See qn::immfeed_self_loop.
1173 const EventOutcome<T> oa =
1174 qn::after_event(sn, sy.active.node, st.local[isf_a - 1], sy.active.event,
1175 sy.active.cls, qn::immfeed_self_loop(sn, sy));
1176 for (std::size_t ia = 0; ia < oa.space.size(); ++ia) {
1177 if (num_traits<T>::to_double(oa.rate[ia]) <= 0) continue;
1178
1179 std::vector<NetState<T>> cand;
1180 if (sy.passive.node == local) {
1181 NetState<T> nsx = st;
1182 nsx.local[isf_a - 1] = oa.space[ia];
1183 cand.push_back(nsx);
1184 } else {
1185 const std::vector<T>& src =
1186 sy.passive.node == sy.active.node ? oa.space[ia] : st.local[isf_p - 1];
1187 const EventOutcome<T> op = qn::after_event(sn, sy.passive.node, src,
1188 sy.passive.event, sy.passive.cls);
1189 for (std::size_t ip = 0; ip < op.space.size(); ++ip) {
1190 if (num_traits<T>::to_double(op.prob[ip]) <= 0) continue;
1191 NetState<T> nsx = st;
1192 nsx.local[isf_a - 1] = oa.space[ia];
1193 nsx.local[isf_p - 1] = op.space[ip];
1194 cand.push_back(nsx);
1195 }
1196 }
1197 for (std::size_t c = 0; c < cand.size(); ++c) {
1198 if (bounded && !ctmc_detail::within_cutoff(sn, cand[c], njobs, lim, cutoff_mat))
1199 continue;
1200 const std::vector<double> key = ctmc_detail::state_key(cand[c]);
1201 if (seen.find(key) != seen.end()) continue;
1202 if (out.size() >= maxst)
1203 throw UnsupportedError(
1204 "reachable_space_generator: the reachable state space exceeds the "
1205 "cap of " + std::to_string(maxst) + " states");
1206 seen[key] = out.size();
1207 out.push_back(cand[c]);
1208 stack.push_back(out.size() - 1);
1209 }
1210 }
1211 }
1212
1213 // The SPN half of the walk. A global synchronization already returns
1214 // WHOLE network states, since a firing is atomic across every arc it
1215 // touches and cannot be decomposed into an active and a passive half.
1216 for (std::size_t g = 0; g < gsync.size(); ++g) {
1217 const qn::GlobalOutcome<T> go = qn::after_global_event(sn, st, gsync[g]);
1218 for (std::size_t io = 0; io < go.space.size(); ++io) {
1219 if (num_traits<T>::to_double(go.rate[io]) <= 0) continue;
1220 if (num_traits<T>::to_double(go.prob[io]) <= 0) continue;
1221 if (bounded && !ctmc_detail::within_cutoff(sn, go.space[io], njobs, lim, cutoff_mat))
1222 continue;
1223 const std::vector<double> key = ctmc_detail::state_key(go.space[io]);
1224 if (seen.find(key) != seen.end()) continue;
1225 if (out.size() >= maxst)
1226 throw UnsupportedError(
1227 "reachable_space_generator: the reachable state space exceeds the cap of " +
1228 std::to_string(maxst) + " states");
1229 seen[key] = out.size();
1230 out.push_back(go.space[io]);
1231 stack.push_back(out.size() - 1);
1232 }
1233 }
1234
1235 // The fork-join half. A firing is atomic in the same sense and returns
1236 // whole network states too; it is walked here rather than folded into the
1237 // sync loop because it has no active/passive decomposition at all.
1238 for (std::size_t k = 0; k < fjsync.size(); ++k) {
1239 const qn::GlobalOutcome<T> fo = qn::after_fj_event(sn, fjsync[k], st);
1240 for (std::size_t io = 0; io < fo.space.size(); ++io) {
1241 if (num_traits<T>::to_double(fo.rate[io]) <= 0) continue;
1242 if (num_traits<T>::to_double(fo.prob[io]) <= 0) continue;
1243 if (bounded && !ctmc_detail::within_cutoff(sn, fo.space[io], njobs, lim, cutoff_mat))
1244 continue;
1245 const std::vector<double> key = ctmc_detail::state_key(fo.space[io]);
1246 if (seen.find(key) != seen.end()) continue;
1247 if (out.size() >= maxst)
1248 throw UnsupportedError(
1249 "reachable_space_generator: the reachable state space exceeds the cap of " +
1250 std::to_string(maxst) + " states");
1251 seen[key] = out.size();
1252 out.push_back(fo.space[io]);
1253 stack.push_back(out.size() - 1);
1254 }
1255 }
1256 }
1257 return out;
1258}
1259
1260/**
1261 * Port of `StateSpaceAggr`: the per-(station, class) job counts of every state,
1262 * as an (nstates x nstations*nclasses) matrix in column block order
1263 * `(ist-1)*K + k`.
1264 *
1265 * It is what `@@SolverCTMC/getStateSpaceAggr` returns and what the transient
1266 * analyzer, the reward analyzer and the BAS shift all index; building it once
1267 * keeps the three from re-deriving the same marginal decode with three chances
1268 * to disagree about the buffer encoding.
1269 *
1270 * A SOURCE ROW IS ZERO, not Inf. `to_marginal` reports an infinite reservoir for
1271 * an EXT station, which describes the encoding rather than a queue length, and
1272 * an Inf here would propagate into every aggregate that sums this matrix.
1273 */
1274template <class T>
1276 const std::vector<NetState<T>>& space) {
1277 const std::size_t M = sn.stations.size(), K = sn.nclasses;
1278 const T zero = num_traits<T>::from_int(0);
1279 Matrix<T> A(space.size(), M * K, zero);
1280 for (std::size_t ist = 1; ist <= M; ++ist) {
1281 const std::size_t isf = sn.stateful_of_station(ist);
1282 const std::size_t ind = sn.node_of_station(ist);
1283 if (isf == 0) continue;
1284 if (sn.stations[ist - 1].nodetype == NodeType::Source) continue;
1285 std::vector<std::size_t> ph(K, 1), shift(K, 0);
1286 std::size_t w = 0;
1287 for (std::size_t k = 0; k < K; ++k) {
1288 ph[k] = sn.phasessz_of(ist, k + 1);
1289 shift[k] = w;
1290 w += ph[k];
1291 }
1292 const std::size_t nvar = sn.nvars_of(ind);
1293 for (std::size_t s = 0; s < space.size(); ++s) {
1294 const qn::Marginal<T> m =
1295 qn::to_marginal(sn, ist, space[s].local[isf - 1], ph, shift, nvar);
1296 for (std::size_t k = 0; k < K; ++k) A(s, (ist - 1) * K + k) = m.nir[k];
1297 }
1298 }
1299 return A;
1300}
1301
1302/**
1303 * Port of `ctmc_signal_lossy`: classes a G-network signal can annihilate here.
1304 *
1305 * Such a job leaves the station WITHOUT a service completion, so the
1306 * arrival-based (offered-load) utilization estimator is invalid for it and only
1307 * the departure-based carried load is meaningful -- the same reasoning as the
1308 * finite-capacity `canDropClass`, reached by a different route.
1309 *
1310 * A signal class is active at this node when its stationary arrival rate there
1311 * is positive. A TARGETED signal removes only its target class; an untargeted
1312 * one is class-agnostic and removes any non-signal class, matching
1313 * `after_event_station_signal`, MAM and LDES.
1314 */
1315template <class T>
1316std::vector<bool> ctmc_signal_lossy(const NetworkStruct<T>& sn, const CtmcResult<T>& r,
1317 const std::vector<T>& p, std::size_t isf) {
1318 const std::size_t K = sn.nclasses;
1319 std::vector<bool> lossy(K, false);
1320 bool any = false;
1321 for (std::size_t k = 0; k < K && k < sn.issignal.size(); ++k) any = any || sn.issignal[k];
1322 if (!any) return lossy;
1323
1324 for (std::size_t r2 = 1; r2 <= K; ++r2) {
1325 if (sn.issignal.size() < r2 || !sn.issignal[r2 - 1]) continue;
1326 T arv = num_traits<T>::from_int(0);
1327 for (std::size_t s = 0; s < r.space.size(); ++s)
1328 arv += T(p[s] * r.arv_rates[s][isf - 1][r2 - 1]);
1329 if (num_traits<T>::to_double(arv) <= 0) continue;
1330 const std::size_t tgt =
1331 sn.signaltarget.size() >= r2 ? sn.signaltarget[r2 - 1] : 0;
1332 if (tgt >= 1 && tgt <= K) {
1333 lossy[tgt - 1] = true;
1334 } else {
1335 for (std::size_t k = 0; k < K; ++k)
1336 if (k >= sn.issignal.size() || !sn.issignal[k]) lossy[k] = true;
1337 }
1338 }
1339 return lossy;
1340}
1341
1342/**
1343 * Port of `ctmc_signal_busy`: the exact per-class busy-server fraction, read
1344 * off the enumerated state space.
1345 *
1346 * WHY THE DEPARTURE ESTIMATOR IS NOT ENOUGH. `T*E[S]/c` is exact for a lossy
1347 * class only under EXPONENTIAL service: a job destroyed mid-service leaves busy
1348 * time behind with no completion to account for it, so with phase-type service
1349 * the carried-rate estimator under-counts. Measured on an M/Er2/1 with
1350 * lambda+ = 0.5 and lambda- = 0.4 it gives 0.34941 against a true 0.37696. The
1351 * in-service occupancy below is exact for any service process.
1352 *
1353 * A PS-like discipline shares the servers among every resident job, so class k
1354 * takes the weighted share n_k w_k / sum_j n_j w_j of the busy servers; every
1355 * other discipline exposes the in-service indicator directly through
1356 * `to_marginal`.
1357 */
1358template <class T>
1359std::vector<T> ctmc_signal_busy(const NetworkStruct<T>& sn, std::size_t ist,
1360 const CtmcResult<T>& r, const std::vector<T>& p,
1361 std::size_t isf) {
1362 const std::size_t K = sn.nclasses;
1363 const T zero = num_traits<T>::from_int(0);
1364 std::vector<T> unb(K, zero);
1365 const SchedStrategy sched = sn.stations[ist - 1].sched;
1366 const bool is_ps = sched == SchedStrategy::PS || sched == SchedStrategy::DPS ||
1367 sched == SchedStrategy::GPS || sched == SchedStrategy::LPS;
1368 const double S = sn.stations[ist - 1].nservers;
1369 const std::size_t ind = sn.node_of_station(ist);
1370
1371 std::vector<std::size_t> ph(K, 1), shift(K, 0);
1372 std::size_t w = 0;
1373 for (std::size_t k = 0; k < K; ++k) {
1374 ph[k] = sn.phasessz_of(ist, k + 1);
1375 shift[k] = w;
1376 w += ph[k];
1377 }
1378 const std::size_t nvar = sn.nvars_of(ind);
1379
1380 for (std::size_t s = 0; s < r.space.size(); ++s) {
1381 if (num_traits<T>::to_double(p[s]) == 0) continue;
1382 const qn::Marginal<T> m =
1383 qn::to_marginal(sn, ist, r.space[s].local[isf - 1], ph, shift, nvar);
1384 double ni = 0;
1385 for (std::size_t k = 0; k < K; ++k) ni += num_traits<T>::to_double(m.nir[k]);
1386 if (ni <= 0) continue;
1387 if (is_ps) {
1388 T wtot = zero;
1389 for (std::size_t k = 0; k < K; ++k)
1390 wtot += T(m.nir[k] * sn.stations[ist - 1].schedparam[k]);
1391 if (num_traits<T>::to_double(wtot) <= 0) continue;
1392 const double busy = std::min(ni, S) / S;
1393 for (std::size_t k = 0; k < K; ++k)
1394 unb[k] += T(p[s] * (m.nir[k] * sn.stations[ist - 1].schedparam[k] / wtot) *
1396 } else {
1397 for (std::size_t k = 0; k < K; ++k)
1398 unb[k] += T(p[s] * m.sir[k] / num_traits<T>::from_double(S));
1399 }
1400 }
1401 return unb;
1402}
1403
1404/**
1405 * MATLAB's `all(sn.isph(:))`, read off the matrices instead of off a flag.
1406 *
1407 * False as soon as one station-class process is a matrix exponential: its D0
1408 * carries negative off-diagonal entries, or its D1 negative entries, neither of
1409 * which a phase-type has. A Source keeps its arrival process in `sn.service`,
1410 * so this one scan covers arrivals and services alike.
1411 */
1412template <class T>
1414 for (std::size_t i = 0; i < sn.service.size(); ++i)
1415 for (std::size_t r = 0; r < sn.service[i].size(); ++r) {
1416 const lang::Distrib<T>& d = sn.service[i][r];
1417 if (d.disabled || d.D0.rows() == 0) continue;
1418 for (std::size_t a = 0; a < d.D0.rows(); ++a)
1419 for (std::size_t b = 0; b < d.D0.cols(); ++b)
1420 if (a != b && num_traits<T>::to_double(d.D0(a, b)) < 0) return false;
1421 for (std::size_t a = 0; a < d.D1.rows(); ++a)
1422 for (std::size_t b = 0; b < d.D1.cols(); ++b)
1423 if (num_traits<T>::to_double(d.D1(a, b)) < 0) return false;
1424 }
1425 return true;
1426}
1427
1428/** The mean performance metrics a stationary vector maps to. */
1429template <class T>
1430struct CtmcAvg {
1431 Matrix<T> QN, UN, RN, TN; ///< (nstations x nclasses)
1432 std::vector<T> XN, CN; ///< (nclasses) system throughput and response time
1433 /**
1434 * The DERIVED rates, (nstations x nclasses): how often per unit time a
1435 * class-r service STARTS at station i, and how often a class-r job in
1436 * service is PUSHED BACK into the buffer there. All zero unless the result
1437 * carried the filtrations they reduce (`solver_ctmc` was asked for them).
1438 *
1439 * At a lossless station with no in-service abandonment
1440 * StartN == TN + PreemptN,
1441 * because every job starts service once per entry into a server and every
1442 * preemption is followed by exactly one later resume or restart.
1443 */
1445};
1446
1447/**
1448 * Port of `solver_ctmc_avg_from_pi`: map a state distribution to mean metrics.
1449 *
1450 * Factored from the analyzer exactly as the reference factors it, so a caller
1451 * holding its own distribution -- a time-averaged transient one, say -- reuses
1452 * the same discipline-aware reduction instead of re-deriving it.
1453 *
1454 * `stationary` says whether `pivec` is a STATIONARY law. It is what selects the
1455 * utilization estimator: the arrival-rate and departure-rate readings coincide
1456 * only under flow balance, so `max(u_arv, u_dep)` is a numerical-noise guard at
1457 * stationarity and an UPWARD BIAS out of it. The SolverENV state-vector analyzer
1458 * passes a sojourn-averaged TRANSIENT law and must pass false; on the two-stage
1459 * environment of test_environment_statevec the max reported 1.9246 at a
1460 * single-server FCFS queue, impossible in LINE's per-server convention. MATLAB
1461 * and the JAR give the ENV path a file of its own (solver_ctmc_avg_from_pi.m,
1462 * Ctmc_avg_from_pi.java) which carries the departure-rate estimator alone; here
1463 * one function serves both callers, so the distinction is a parameter.
1464 * See _kb/06-solver-catalog.md.
1465 */
1466template <class T>
1468 const std::vector<T>& pivec, bool stationary = true) {
1469 const std::size_t M = sn.stations.size(), R = sn.nclasses, n = r.space.size();
1470 const T zero = num_traits<T>::from_int(0);
1471 CtmcAvg<T> a;
1472 a.QN = Matrix<T>(M, R, zero);
1473 a.UN = Matrix<T>(M, R, zero);
1474 a.RN = Matrix<T>(M, R, zero);
1475 a.TN = Matrix<T>(M, R, zero);
1476 a.XN.assign(R, zero);
1477 a.CN.assign(R, zero);
1478 a.StartN = Matrix<T>(M, R, zero);
1479 a.PreemptN = Matrix<T>(M, R, zero);
1480
1481 // Renormalize, clamping the numerical dust an eigenvector solve leaves
1482 // below the zero threshold. WITH A MATRIX EXPONENTIAL THE CLAMP IS SKIPPED:
1483 // the stationary vector is then a genuinely SIGNED measure, only its
1484 // aggregates over each phase block are probabilities, and deleting the
1485 // negative entries deletes real mass -- an M/CME/1 at rho = 0.3 came out
1486 // with QLen 0.4469 against the Pollaczek-Khinchine 0.3772. Every metric
1487 // below is linear in the vector and stays exact without the clamp.
1488 const bool signed_measure = !ctmc_all_phasetype(sn);
1489 std::vector<T> p = pivec;
1490 T tot = zero;
1491 for (std::size_t s = 0; s < n; ++s) {
1492 if (!signed_measure && num_traits<T>::to_double(p[s]) < GlobalConstants::Zero) p[s] = zero;
1493 tot += p[s];
1494 }
1495 if (num_traits<T>::to_double(tot) > 0)
1496 for (std::size_t s = 0; s < n; ++s) p[s] = T(p[s] / tot);
1497
1498 // System throughput is the arrival rate seen at each class's REFERENCE
1499 // station, which is what makes X a per-class quantity rather than a sum.
1500 for (std::size_t k = 1; k <= R; ++k) {
1501 const std::size_t refsf = sn.stateful_of_station(sn.classes[k - 1].refstat);
1502 for (std::size_t s = 0; s < n; ++s) a.XN[k - 1] += T(p[s] * r.arv_rates[s][refsf - 1][k - 1]);
1503 }
1504
1505 // The derived rates: pi * F * e over each filtration, the same reduction
1506 // the departure rates use, so the three are directly comparable.
1507 for (std::size_t ist = 1; ist <= M && ist <= r.start_filt.size(); ++ist)
1508 for (std::size_t k = 1; k <= R && k <= r.start_filt[ist - 1].size(); ++k) {
1509 T accs = zero, accp = zero;
1510 for (std::size_t s = 0; s < n; ++s) {
1511 T rows = zero, rowp = zero;
1512 for (std::size_t ns = 0; ns < n; ++ns) {
1513 rows += r.start_filt[ist - 1][k - 1](s, ns);
1514 rowp += r.preempt_filt[ist - 1][k - 1](s, ns);
1515 }
1516 accs += T(p[s] * rows);
1517 accp += T(p[s] * rowp);
1518 }
1519 a.StartN(ist - 1, k - 1) = accs;
1520 a.PreemptN(ist - 1, k - 1) = accp;
1521 }
1522
1523 for (std::size_t ist = 1; ist <= M; ++ist) {
1524 const std::size_t isf = sn.stateful_of_station(ist);
1525 const std::size_t ind = sn.node_of_station(ist);
1526 const bool is_source = sn.stations[ist - 1].nodetype == NodeType::Source;
1527 const double S = sn.stations[ist - 1].nservers;
1528 std::vector<std::size_t> ph(R, 1), shift(R, 0);
1529 std::size_t w = 0;
1530 for (std::size_t r2 = 0; r2 < R; ++r2) {
1531 ph[r2] = sn.phasessz_of(ist, r2 + 1);
1532 shift[r2] = w;
1533 w += ph[r2];
1534 }
1535 const std::size_t nvar = sn.nvars_of(ind);
1536
1537 for (std::size_t k = 1; k <= R; ++k)
1538 for (std::size_t s = 0; s < n; ++s)
1539 a.TN(ist - 1, k - 1) += T(p[s] * r.dep_rates[s][isf - 1][k - 1]);
1540
1541 if (is_source) {
1542 // `to_marginal` encodes a Source as nir = Inf, an infinite
1543 // reservoir. That is a statement about the ENCODING, not a queue
1544 // length; reading it as one gives Q = Inf and then R = Q/T = Inf.
1545 continue;
1546 }
1547
1548 for (std::size_t s = 0; s < n; ++s) {
1549 if (num_traits<T>::to_double(p[s]) == 0) continue;
1550 const qn::Marginal<T> m =
1551 qn::to_marginal(sn, ist, r.space[s].local[isf - 1], ph, shift, nvar);
1552 for (std::size_t k = 1; k <= R; ++k) a.QN(ist - 1, k - 1) += T(p[s] * m.nir[k - 1]);
1553 }
1554
1555 const SchedStrategy sched = sn.stations[ist - 1].sched;
1556 // PAS / order-independent: utilization is the IN-SERVICE occupancy, not
1557 // the offered load. The two coincide only when a job engages a single
1558 // server, which is exactly what a pass-and-swap station does not do.
1559 if (sched == SchedStrategy::PAS) {
1560 for (std::size_t s = 0; s < n; ++s) {
1561 if (num_traits<T>::to_double(p[s]) == 0) continue;
1562 const qn::Marginal<T> m =
1563 qn::to_marginal(sn, ist, r.space[s].local[isf - 1], ph, shift, nvar);
1564 for (std::size_t k = 0; k < R; ++k)
1565 a.UN(ist - 1, k) += T(p[s] * m.sir[k] / num_traits<T>::from_double(S));
1566 }
1567 continue;
1568 }
1569 // A load-dependent station has no single service rate, so `T*E[S]/c` is
1570 // not its utilization: the reference accumulates the per-state capacity
1571 // share weighted by the lld factor and divides by the EFFECTIVE server
1572 // count max(c, max lld), which is what the scaling can deliver.
1573 // A class- or joint-dependent station takes the SAME branch: none of the
1574 // three has a single service rate, so `T*E[S]/c` is not its utilization.
1575 // The reference gates all three on one `isempty(lld) && isempty(cd) &&
1576 // isempty(jd)` test, and the cd/jd cases are then OVERWRITTEN by the
1577 // declared-peak normalization at the end of this function -- this branch
1578 // is what a jd-only station keeps, and what a cd station holds until the
1579 // peak pass replaces it.
1580 const std::vector<T>& lld = sn.stations[ist - 1].lldscaling;
1581 if (!lld.empty() || sn.stations[ist - 1].cdscaling || sn.stations[ist - 1].jdscaling) {
1582 double ceff = S;
1583 for (std::size_t j = 0; j < lld.size(); ++j)
1584 ceff = std::max(ceff, num_traits<T>::to_double(lld[j]));
1585 const bool share = sched == SchedStrategy::PS || sched == SchedStrategy::DPS ||
1586 sched == SchedStrategy::GPS || sched == SchedStrategy::LPS;
1587 for (std::size_t s = 0; s < n; ++s) {
1588 if (num_traits<T>::to_double(p[s]) == 0) continue;
1589 const qn::Marginal<T> m =
1590 qn::to_marginal(sn, ist, r.space[s].local[isf - 1], ph, shift, nvar);
1591 double ni = 0;
1592 for (std::size_t k = 0; k < R; ++k) ni += num_traits<T>::to_double(m.nir[k]);
1593 if (ni <= 0) continue;
1594 double lldnow = 1.0;
1595 if (!lld.empty()) {
1596 const std::size_t li = std::min<std::size_t>(
1597 lld.size(), std::max<std::size_t>(1, static_cast<std::size_t>(ni)));
1598 lldnow = num_traits<T>::to_double(lld[li - 1]);
1599 }
1600 if (share) {
1601 T wtot = zero;
1602 for (std::size_t k = 0; k < R; ++k)
1603 wtot += T(m.nir[k] * sn.stations[ist - 1].schedparam[k]);
1604 if (num_traits<T>::to_double(wtot) <= 0) continue;
1605 for (std::size_t k = 0; k < R; ++k)
1606 a.UN(ist - 1, k) +=
1607 T(p[s] * (m.nir[k] * sn.stations[ist - 1].schedparam[k] / wtot) *
1608 num_traits<T>::from_double(lldnow / ceff));
1609 } else {
1610 double sirtot = 0;
1611 for (std::size_t k = 0; k < R; ++k)
1612 sirtot += num_traits<T>::to_double(m.sir[k]);
1613 if (sirtot <= 0) continue;
1614 for (std::size_t k = 0; k < R; ++k)
1615 a.UN(ist - 1, k) += T(p[s] * num_traits<T>::from_double(
1617 sirtot * lldnow / ceff));
1618 }
1619 }
1620 continue;
1621 }
1622 if (sched == SchedStrategy::INF) {
1623 // An infinite server is "utilized" by every job it holds: there is
1624 // no queueing, so utilization and queue length coincide.
1625 for (std::size_t k = 1; k <= R; ++k) a.UN(ist - 1, k - 1) = a.QN(ist - 1, k - 1);
1626 } else {
1627 // A class that can be DROPPED here -- an open class at a station
1628 // with a finite capacity -- must be measured on the CARRIED rate
1629 // alone. The offered rate counts arrivals that never entered
1630 // service, so `max` would report the offered load as utilization:
1631 // for an M/M/1/4 with lambda = 0.6 that is 0.6 against the true
1632 // 1 - p0 = 0.566. Where nothing can be dropped the two estimates
1633 // agree in steady state and the max only guards numerical noise.
1634 // A class a G-network SIGNAL can annihilate is droppable for the
1635 // same reason a capacity-limited one is: the job leaves without a
1636 // completion, so the offered rate is not what the server did.
1637 const std::vector<bool> lossy = ctmc_signal_lossy(sn, r, p, isf);
1638 // A station inside a DROP region loses arrivals it cannot admit,
1639 // exactly as a finite per-station capacity does, so the same
1640 // carried-rate rule applies there.
1641 bool in_drop = false;
1642 for (std::size_t f = 0; f < sn.regions.size() && !in_drop; ++f) {
1643 bool has_drop = false;
1644 for (std::size_t rr = 0; rr < sn.regions[f].rule.size(); ++rr)
1645 if (sn.regions[f].rule[rr] == lang::DropStrategy::DROP) has_drop = true;
1646 if (has_drop && ist - 1 < sn.regions[f].members.size() &&
1647 sn.regions[f].members[ist - 1])
1648 in_drop = true;
1649 }
1650 for (std::size_t k = 1; k <= R; ++k) {
1651 const bool can_drop =
1652 (!std::isfinite(sn.njobs()[k - 1]) &&
1653 (std::isfinite(sn.cap[ist - 1]) ||
1654 std::isfinite(sn.classcap[ist - 1][k - 1]) || in_drop)) ||
1655 lossy[k - 1];
1656 const lang::Distrib<T>& d = sn.service[ist - 1][k - 1];
1657 if (d.disabled || d.D0.rows() == 0) continue;
1658 mam::Map<T> mp;
1659 mp.D0 = d.D0;
1660 mp.D1 = d.D1;
1661 const T mean = mam::map_mean(mp);
1662 const T u_dep = T(a.TN(ist - 1, k - 1) * mean / num_traits<T>::from_double(S));
1663 if (can_drop) {
1664 a.UN(ist - 1, k - 1) = u_dep;
1665 continue;
1666 }
1667 if (!stationary) {
1668 // Out of steady state the offered rate is not what the
1669 // server did; see the note on `stationary` above.
1670 a.UN(ist - 1, k - 1) = u_dep;
1671 continue;
1672 }
1673 T arv = zero;
1674 for (std::size_t s = 0; s < n; ++s)
1675 arv += T(p[s] * r.arv_rates[s][isf - 1][k - 1]);
1676 const T u_arv = T(arv * mean / num_traits<T>::from_double(S));
1677 a.UN(ist - 1, k - 1) =
1679 : u_dep;
1680 }
1681 // For a lossy class the carried rate is still not exact unless the
1682 // service is exponential, so the in-service occupancy read off the
1683 // state space REPLACES it -- see `ctmc_signal_busy`.
1684 bool anylossy = false;
1685 for (std::size_t k = 0; k < R; ++k) anylossy = anylossy || lossy[k];
1686 if (anylossy) {
1687 const std::vector<T> unb = ctmc_signal_busy(sn, ist, r, p, isf);
1688 for (std::size_t k = 0; k < R; ++k)
1689 if (lossy[k]) a.UN(ist - 1, k) = unb[k];
1690 }
1691 }
1692 }
1693
1694 // TRUE BAS: the held job is counted at its DESTINATION, not where it sits.
1695 //
1696 // The state has it at the blocking station -- that is what the marker means --
1697 // but it has FINISHED service there and is on its way out, so reporting it in
1698 // the upstream queue length would double-count the time it spends waiting for
1699 // room. The reference moves it, and only when the destination is unambiguous:
1700 // with several downstream stations there is no single place to move it to, so
1701 // it is left where it sits rather than assigned arbitrarily.
1702 for (std::size_t ist = 1; ist <= M && !sn.isbasblocking.empty(); ++ist) {
1703 const std::size_t ind = sn.node_of_station(ist);
1704 const std::size_t isf = sn.stateful_of_station(ist);
1705 if (ind == 0 || isf == 0 || ind > sn.isbasblocking.size()) continue;
1706 if (!sn.isbasblocking[ind - 1]) continue;
1707 const std::vector<std::size_t> dests = sn.downstream_stations(ind);
1708 if (dests.size() != 1) continue;
1709 const std::size_t jst = sn.nodes[dests[0] - 1].station;
1710 if (jst == 0) continue;
1711 std::vector<std::size_t> ph2(R, 1), sh2(R, 0);
1712 std::size_t w2 = 0;
1713 for (std::size_t k = 0; k < R; ++k) {
1714 ph2[k] = sn.phasessz_of(ist, k + 1);
1715 sh2[k] = w2;
1716 w2 += ph2[k];
1717 }
1718 const std::size_t nv2 = sn.nvars_of(ind);
1719 for (std::size_t s = 0; s < n; ++s) {
1720 if (num_traits<T>::to_double(p[s]) == 0) continue;
1721 const std::vector<T>& row = r.space[s].local[isf - 1];
1722 if (row.empty() || num_traits<T>::to_double(row.back()) != 1) continue;
1723 const qn::Marginal<T> m = qn::to_marginal(sn, ist, row, ph2, sh2, nv2);
1724 for (std::size_t k = 0; k < R; ++k) {
1725 // A blocked state holds EXACTLY ONE completed job, so the count
1726 // is capped at 1: the rest of the queue has not finished and
1727 // stays where it is.
1728 const double nk = num_traits<T>::to_double(m.nir[k]);
1729 if (!(nk > 0)) continue;
1730 const T shift = T(p[s] * num_traits<T>::from_double(std::min(nk, 1.0)));
1731 a.QN(ist - 1, k) -= shift;
1732 a.QN(jst - 1, k) += shift;
1733 }
1734 }
1735 }
1736
1737 // DECLARED-PEAK UTILIZATION at a class- or joint-dependent station. The
1738 // per-state capacity share accumulated above is a busy-server probability,
1739 // and at a dependent station that is not what utilization means: a beta_r(n)
1740 // emulating extra servers would report more than one. The reference REPLACES
1741 // it by T/mu/peak, restoring the T*S/c convention against the DECLARED peak.
1742 //
1743 // The `all njobs finite` guard is the reference's and is not a convenience:
1744 // with an open class the state space is a TRUNCATION, so the throughput this
1745 // divides is the truncated chain's and the ratio is not a utilization of the
1746 // model. The busy-server estimate above at least stays a probability, so it
1747 // is what an open dependent station keeps.
1748 //
1749 // THE PEAKS MULTIPLY. beta_r(n), eta_i(n) and the global phi(n) scale the
1750 // SAME nominal rate and the event layer folds them multiplicatively
1751 // (`cd_factor`), so the peak attainable rate is
1752 // `rates * cdpeak * jdpeak * gdpeak`. The reference used to run two
1753 // independent blocks each ASSIGNING UN, which let the jd peak overwrite the cd
1754 // one and dropped a factor `cdpeak` at a station carrying both; that was fixed
1755 // in `solver_ctmc_analyzer.m` / `solver_ctmc_avg_from_pi.m` together with this
1756 // port, and it is what SolverSSA already did
1757 // (`solver_ssa_analyzer_serial.m:87-90`).
1758 bool all_closed = true;
1759 for (std::size_t k = 0; k < R; ++k)
1760 if (!std::isfinite(sn.njobs()[k])) all_closed = false;
1761 const bool has_gd = static_cast<bool>(sn.gdscaling);
1762 if (all_closed) {
1763 for (std::size_t ist = 1; ist <= M; ++ist) {
1764 const qn::Station<T>& st = sn.stations[ist - 1];
1765 const bool has_cd = static_cast<bool>(st.cdscaling);
1766 const bool has_jd = static_cast<bool>(st.jdscaling);
1767 if (!has_cd && !has_jd && !has_gd) continue;
1768 for (std::size_t k = 0; k < R; ++k) {
1769 double bmax = 1.0;
1770 if (has_cd)
1771 bmax *= k < st.cdscalingpeak.size()
1773 : 0.0;
1774 if (has_jd)
1775 bmax *= k < st.jdscalingpeak.size()
1777 : 0.0;
1778 // phi(n) scales the same nominal rate from the WHOLE population,
1779 // so its declared peak joins the product at every station, the
1780 // delays among them; `sn.gdscalingpeak` is (M x R) row-major.
1781 if (has_gd)
1782 bmax *= (ist - 1) * R + k < sn.gdscalingpeak.size()
1783 ? num_traits<T>::to_double(sn.gdscalingpeak[(ist - 1) * R + k])
1784 : 0.0;
1785 const double mu = num_traits<T>::to_double(sn.rates(ist - 1, k));
1786 a.UN(ist - 1, k) = (std::isfinite(mu) && mu > 0 && bmax > 0)
1787 ? T(a.TN(ist - 1, k) /
1788 num_traits<T>::from_double(mu * bmax))
1789 : zero;
1790 }
1791 }
1792 }
1793
1794 // SYNCHRONOUS CALLS. A caller blocked waiting for its reply still HOLDS its
1795 // server, so those jobs belong in QLen and Util even though they are not in
1796 // service here. They are NOT in the response time: response time is time
1797 // spent AT the station, and the blocked job is at its callee.
1798 Matrix<T> qn_blocked(M, R, zero);
1799 if (!sn.replyblock.empty()) {
1800 for (std::size_t ist = 1; ist <= M; ++ist) {
1801 const std::size_t ind = sn.node_of_station(ist);
1802 const std::size_t isf = sn.stateful_of_station(ist);
1803 if (isf == 0 || sn.replyblock.size() < ind) continue;
1804 bool any = false;
1805 for (std::size_t k = 0; k < sn.replyblock[ind - 1].size(); ++k)
1806 any = any || sn.replyblock[ind - 1][k];
1807 if (!any) continue;
1808 const qn::ReplyBlockInfo info = qn::reply_block_info(sn, ind);
1809 if (info.width == 0) continue;
1810 const double S2 = sn.stations[ist - 1].nservers;
1811 // The counters are the LAST columns of the local row, one per
1812 // calling class, in the order `reply_block_info` lists them.
1813 for (std::size_t s = 0; s < n; ++s) {
1814 if (num_traits<T>::to_double(p[s]) == 0) continue;
1815 const std::vector<T>& row = r.space[s].local[isf - 1];
1816 for (std::size_t pos = 0; pos < info.classes.size(); ++pos) {
1817 const std::size_t col = row.size() - info.width + pos;
1818 const std::size_t cls = info.classes[pos];
1819 qn_blocked(ist - 1, cls - 1) += T(p[s] * row[col]);
1820 }
1821 }
1822 for (std::size_t k = 0; k < R; ++k) {
1823 a.QN(ist - 1, k) += qn_blocked(ist - 1, k);
1824 a.UN(ist - 1, k) += T(qn_blocked(ist - 1, k) / num_traits<T>::from_double(S2));
1825 }
1826 }
1827 }
1828
1829 // Little's law per station, then per class at the reference station.
1830 for (std::size_t k = 1; k <= R; ++k) {
1831 for (std::size_t ist = 1; ist <= M; ++ist)
1832 a.RN(ist - 1, k - 1) =
1833 num_traits<T>::to_double(a.TN(ist - 1, k - 1)) > 0
1834 ? T((a.QN(ist - 1, k - 1) - qn_blocked(ist - 1, k - 1)) /
1835 a.TN(ist - 1, k - 1))
1836 : zero;
1837 const double nk = sn.njobs()[k - 1];
1838 a.CN[k - 1] = std::isfinite(nk) && num_traits<T>::to_double(a.XN[k - 1]) > 0
1839 ? T(num_traits<T>::from_double(nk) / a.XN[k - 1])
1840 : zero;
1841 }
1842 return a;
1843}
1844
1845} // namespace ctmc
1846} // namespace line
1847
1848#endif // LINE_SOLVERS_CTMC_SOLVER_CTMC_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
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
std::vector< std::size_t > stateful_nodes
1-based node indices, ascending
std::vector< NodeDef > nodes
every node, in creation order
A network plus its refreshed NetworkStruct.
static void step(const char *fmt,...)
Write one progress line.
Equilibrium distribution of a discrete-time Markov chain, and stochastic complementation.
The exception types the port throws.
Running progress log of a LINE solver run (the "solver console").
LU factorization with partial pivoting, templated on the number type.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
void ctmc_eliminate_vanishing(CtmcResult< T > &res)
Port of the "now remove immediate transitions" block of solver_ctmc.m (:812-870): eliminate the vanis...
void make_infgen(Matrix< T > &Q)
Port of ctmc_makeinfgen: turn an off-diagonal rate matrix into a generator.
CtmcAvg< T > solver_ctmc_avg_from_pi(const NetworkStruct< T > &sn, const CtmcResult< T > &r, const std::vector< T > &pivec, bool stationary=true)
Port of solver_ctmc_avg_from_pi: map a state distribution to mean metrics.
CtmcResult< T > solver_ctmc(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space, const std::vector< Sync< T > > &sync, const std::vector< qn::GlobalSync< T > > &gsync=std::vector< qn::GlobalSync< T > >(), bool want_filtration=false, const std::vector< qn::FjSync< T > > &fjsync=std::vector< qn::FjSync< T > >())
Port of the generator assembly of solver_ctmc.m.
NetState< T > reachable_seed_widen(const NetworkStruct< T > &sn, const NetState< T > &init)
Left-pad an ordered-buffer station's seed row to the width its CAPACITY implies, rather than the widt...
bool ctmc_all_phasetype(const NetworkStruct< T > &sn)
MATLAB's all(sn.isph(:)), read off the matrices instead of off a flag.
std::vector< T > ctmc_signal_busy(const NetworkStruct< T > &sn, std::size_t ist, const CtmcResult< T > &r, const std::vector< T > &p, std::size_t isf)
Port of ctmc_signal_busy: the exact per-class busy-server fraction, read off the enumerated state spa...
Matrix< T > ctmc_gd_factor(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
Tabulates the globally state-dependent rate scaling phi(n) declared through set_global_dependence,...
Matrix< T > ctmc_state_space_aggr(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
Port of StateSpaceAggr: the per-(station, class) job counts of every state, as an (nstates x nstation...
std::vector< std::size_t > ctmc_find_vanishing_states(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space, const std::vector< qn::GlobalSync< T > > &gsync, bool isfjaug)
Port of ctmc_find_vanishing_states (solver_ctmc.m:928): the indices of the VANISHING (zero-sojourn) g...
std::vector< NetState< T > > reachable_space_generator(const NetworkStruct< T > &sn, const NetState< T > &init, const std::vector< Sync< T > > &sync, const std::vector< qn::GlobalSync< T > > &gsync=std::vector< qn::GlobalSync< T > >(), std::size_t maxst=3000000, const std::vector< qn::FjSync< T > > &fjsync=std::vector< qn::FjSync< T > >(), const std::vector< std::size_t > &cutoff=std::vector< std::size_t >(), const std::vector< std::vector< std::size_t > > &cutoff_mat=std::vector< std::vector< std::size_t > >())
Port of State.reachableSpaceGenerator: the states reachable from init.
std::vector< bool > ctmc_signal_lossy(const NetworkStruct< T > &sn, const CtmcResult< T > &r, const std::vector< T > &p, std::size_t isf)
Port of ctmc_signal_lossy: classes a G-network signal can annihilate here.
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
@ IMMEDIATE
fires with zero delay, resolved by weight and priority
Definition lang_types.h:365
EventType
The events a state can undergo, with the values of MATLAB EventType.
Definition lang_types.h:111
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:326
T map_mean(const Map< T > &m)
Mean inter-arrival time, 1/lambda.
Definition map_moment.h:101
StochCompResult< T > ctmc_stochcomp(const Matrix< T > &Q, const std::vector< std::size_t > &I)
Definition dtmc_solve.h:150
EventOutcome< T > after_event_join(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< T > &inspace, EventType event, std::size_t cls)
Port of State.afterEventJoin: an event at a Join node of an FJ-augmented struct.
ReplyBlockInfo reply_block_info(const NetworkStruct< T > &sn, std::size_t ind)
Defined below; the departure branch records a server held for a reply.
Marginal< T > to_marginal(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &state_i, const std::vector< std::size_t > &phasesz, const std::vector< std::size_t > &phaseshift, std::size_t nvar=0)
Port of State.toMarginal for a STATION, one state row at a time.
Definition state.h:130
bool immfeed_self_loop(const NetworkStruct< T > &sn, const Sync< T > &sy)
True when a synchronization is an IMMEDIATE-FEEDBACK SELF-LOOP: a departure whose passive half is an ...
GlobalOutcome< T > after_global_event(const NetworkStruct< T > &sn, const NetState< T > &glspace, const GlobalSync< T > &gl)
Port of State.afterGlobalEvent: an SPN mode ENABLEs or FIREs.
EventOutcome< T > after_event(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< T > &inspace, EventType event, std::size_t cls, bool no_promote=false, const T &aux_rate=num_traits< T >::from_int(0))
Port of State.afterEvent: the successors of one event at one NODE.
std::pair< T, std::vector< T > > to_marginal_aggr(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< T > &state_i)
Port of State.toMarginalAggr: the job counts of one node's state row, without the per-phase detail to...
GlobalOutcome< T > after_fj_event(const NetworkStruct< T > &sn, const FjSync< T > &e, const NetState< T > &gl)
Port of State.afterFJEvent: fire ONE entry of the fork firing list.
Matrix< T > rt_state(const NetworkStruct< T > &sn, const std::vector< std::vector< T > > &local)
Port of sn.rtfun: the routing over the stateful nodes AT ONE STATE.
Definition state.h:375
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
void lu_solve(const Matrix< T > &LU, const std::vector< std::size_t > &piv, std::vector< T > &b)
Solve LUx = Pb in place on b, using the factors from lu_factor.
Definition lu.h:94
std::vector< std::size_t > lu_factor(Matrix< T > &A)
In-place LU of A (n x n).
Definition lu.h:48
A queueing network and its refreshed NetworkStruct.
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...
The mean performance metrics a stationary vector maps to.
std::vector< T > CN
(nclasses) system throughput and response time
std::vector< T > XN
Matrix< T > StartN
The DERIVED rates, (nstations x nclasses): how often per unit time a class-r service STARTS at statio...
Matrix< T > TN
(nstations x nclasses)
The generator, the state space it is indexed by, and the event rates.
Definition solver_ctmc.h:57
std::vector< std::vector< std::vector< T > > > dep_rates_imm
std::vector< std::vector< Matrix< T > > > start_filt
The DERIVED START and PREEMPT filtrations, indexed [station-1][class-1]: the rate at which a transiti...
Definition solver_ctmc.h:93
std::vector< NetState< T > > space
row i of Q is space[i]
Definition solver_ctmc.h:59
std::vector< std::vector< std::vector< T > > > arv_rates
arvRates / depRates, indexed [state][stateful-1][class-1]: the total rate of arrivals into,...
Definition solver_ctmc.h:67
std::vector< std::vector< Matrix< T > > > preempt_filt
Definition solver_ctmc.h:93
std::vector< std::vector< std::vector< T > > > dep_rates
Definition solver_ctmc.h:67
std::vector< Matrix< T > > filt
Dfilt, MATLAB's EVENT FILTRATION: filt[a] holds only the rates that synchronization a contributed,...
Definition solver_ctmc.h:82
std::vector< std::vector< std::vector< T > > > arv_rates_imm
The parts of arv_rates / dep_rates contributed by those same immediate sources.
std::vector< std::size_t > vanishing
The rows the purge restated, i.e.
Matrix< T > Q
(n x n) infinitesimal generator
Definition solver_ctmc.h:58
Matrix< T > Qimm
Qimm, the IMMEDIATE-ONLY part of Q: the arcs contributed by a Router or Fork pass-through,...
static constexpr double Zero
Definition lang_types.h:762
One network state: the per-stateful-node local rows it is composed of.
Definition state.h:2157
std::vector< std::vector< T > > local
local[isf] is that node's state row
Definition state.h:2158
Matrix< T > D0
The (D0,D1) pair when the type carries one directly.
Definition lang_types.h:853
A MAP as the pair of matrices (D0, D1).
Definition map_moment.h:53
Matrix< T > D1
Definition map_moment.h:55
Matrix< T > D0
Definition map_moment.h:54
Matrix< T > S
stochastic complement on the selected states
Definition dtmc_solve.h:137
What one event produces at one node: the successor rows, their rates and their probabilities,...
std::vector< T > prob
per-row probability of the choice
std::vector< std::vector< T > > space
successor local state rows
std::vector< T > rate
per-row rate, -1 on a passive half
One fork firing synchronization: sn.fjsync{k}.
std::size_t fork
1-based Fork node
std::vector< std::size_t > branchheads
1-based node per branch
std::size_t cls
1-based ORIGINAL class being forked
std::vector< std::size_t > auxclasses
the tag's auxiliary class per branch
What one global event produces: a whole network state per outcome.
std::vector< T > rate
std::vector< NetState< T > > space
std::vector< T > prob
std::vector< bool > completion
True where the outcome is a firing COMPLETION, i.e.
A GLOBAL synchronization: an SPN mode event and the place arcs it drives.
What State.toMarginal returns for one station and one state row.
Definition state.h:51
std::vector< T > nir
jobs per class
Definition state.h:53
std::vector< T > sir
jobs in service per class
Definition state.h:54
One half of a GLOBAL synchronization: a mode event at a node.
std::size_t mode
1-based mode index
std::size_t node
1-based node index (a Transition, or a place)
T weight
arc multiplicity
std::size_t cls
1-based class the arc moves
One network state: the per-stateful-node local rows it is composed of.
Definition state.h:2157
std::vector< std::vector< T > > local
local[isf] is that node's state row
Definition state.h:2158
Where node ind keeps its reply-block counters inside the local vars.
std::vector< std::size_t > classes
1-based calling classes holding a block
One station of the network.
std::vector< T > jdscalingpeak
sn.jdscalingpeak for this station: the declared peak joint-dependent scaling per class.
CdScaling< T > jdscaling
sn.jdscaling for this station: MATLAB's Station.ljdScaling, the JOINT dependence map eta_i(n),...
std::vector< T > cdscalingpeak
sn.cdscalingpeak for this station: the DECLARED peak rate scaling per class, empty when the station i...
CdScaling< T > cdscaling
sn.cdscaling for this station: the class-dependence map, empty when unset.
One synchronization: an ACTIVE event and the PASSIVE event it drives.
SyncEvent< T > passive
SyncEvent< T > active