LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ctmc_analyzer.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_ANALYZER_H
6#define LINE_SOLVERS_CTMC_SOLVER_CTMC_ANALYZER_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `solver_ctmc_analyzer.m` and the parts of `@@SolverCTMC/runAnalyzer.m`
12 * that surround one solve: the method gate, the open-model CUTOFF, the state
13 * space and synchronization construction, the stationary solve with its
14 * reducible-generator handling, and the mapping onto the AvgTable columns.
15 *
16 * WHAT THE CUTOFF IS, AND WHY IT IS NOT A TOLERANCE. A closed class carries its
17 * own population, so its lattice is finite. An open class does not, so the
18 * chain is infinite and the reference TRUNCATES it at `options.cutoff` jobs per
19 * class. The answer is therefore the exact stationary law of a DIFFERENT chain,
20 * one the truncation defined, and it converges to the model's only as the
21 * cutoff grows. That is why the reference prints a warning on every open model
22 * and why the banner here reports the cutoff it used: a CTMC number for an open
23 * model is not a number without it.
24 *
25 * REDUCIBILITY. `space_generator` enumerates every state the ENCODING admits,
26 * and the dynamics need not reach all of them; the generator is then reducible
27 * and pi is not unique. The reference resolves this by keeping the weakly
28 * connected component of the INITIAL state, and falling back to the largest
29 * component when the initial state is not in the space at all. Solving the whole
30 * generator instead -- which is what `ctmc_solve` does on its own, splitting
31 * per component and renormalizing -- spreads mass over states the model can
32 * never occupy and moves every reported mean.
33 */
34
36#include <algorithm>
37#include <cmath>
38#include <cstddef>
39#include <limits>
40#include <map>
41#include <memory>
42#include <string>
43#include <vector>
44
51#include "line/lang/qn/fj_tag.h"
56#include "line/lang/qn/state.h"
67#include "line/util/error.h"
68#include "line/util/matrix.h"
69
70namespace line {
71namespace ctmc {
72
73/**
74 * `SolverOptions.m:107`: the per-class state-space cutoff SolverCTMC defaults an
75 * open or mixed model to. It is a SOLVER default, not a fallback computed from
76 * the model, and the reference's `ceil(6000^(1/(M*K)))` is reached only from an
77 * explicitly infinite request. See `resolve_cutoff`.
78 */
79constexpr std::size_t CTMC_DEFAULT_CUTOFF = 10;
80
81/**
82 * One entry of `options.config.rate_sched`: the rate of (station, class)
83 * follows the piecewise-linear schedule (tgrid, rates) over a transient.
84 *
85 * `station` and `cls` are 1-based, as in the MATLAB struct. The multiplier the
86 * generator is scaled by is rate(t) / nominal, where a NaN nominal means the
87 * model's own rate at that (station, class), which is the reference default.
88 * Outside [tgrid.front(), tgrid.back()] the schedule holds its end values.
89 */
91 std::size_t station = 0;
92 std::size_t cls = 0;
93 std::vector<double> tgrid;
94 std::vector<double> rates;
95 double nominal = std::numeric_limits<double>::quiet_NaN();
96};
97
98/**
99 * The SolverCTMC knobs this port honours.
100 *
101 * `cutoff` is per-class when `cutoff_vec` is given and uniform otherwise; a
102 * negative `cutoff` means "not given" and takes the reference's solver default
103 * `CTMC_DEFAULT_CUTOFF`, while an infinite one takes its automatic value,
104 * `ceil(6000^(1/(M*K)))`.
105 */
107 std::string method = "default";
108 /**
109 * Keep the per-synchronization EVENT FILTRATION alongside Q.
110 *
111 * Off by default because it costs one n x n matrix per synchronization,
112 * which on a model with many routing pairs dwarfs the generator itself. The
113 * response-time CDF needs it, since splitting Q on one event cannot be done
114 * after the contributions have been summed.
115 */
116 bool keep_filtration = false;
117 double cutoff = -1.0; ///< < 0 = not given
118 std::vector<std::size_t> cutoff_vec; ///< per-class override, or empty
119 /**
120 * `options.cutoff` AS A (station x class) MATRIX, or empty.
121 *
122 * The reference accepts a matrix wherever a model needs a different
123 * truncation per station -- oqn_cs_routing writes `[1,1,0;3,3,0;0,0,3]`,
124 * which bounds each queue only in the classes it actually serves. Reducing
125 * that to its per-class maximum enumerates a far larger chain and answers a
126 * different model, so it is carried whole and applied per station in
127 * `space_capacity_c`.
128 */
129 std::vector<std::vector<std::size_t>> cutoff_mat;
130 std::size_t state_max = 3000000; ///< refuse a space larger than this
131 /// `options.force`: downgrade the memory pre-gate's refusal to a warning.
132 bool force = false;
133 /// `options.memorySafetyFraction`: share of available memory a solve may target.
135 /**
136 * `options.config.nonmkvorder`: the phase budget `sn_nonmarkov_toph` spends
137 * on a non-Markovian service law. The reference default is 20, and it is a
138 * real cost here -- every phase multiplies the state space.
139 */
140 std::size_t nonmkv_order = 20;
141 /**
142 * `options.timestep`: the FIXED OUTPUT STEP of a transient analysis.
143 *
144 * <= 0 means adaptive, which is the reference's default and its `[]`. It
145 * changes WHERE the solution is reported and not how it is computed: the
146 * integrator takes the same steps either way, and the grid points are read
147 * off its interpolant. A caller comparing two transients needs the second,
148 * because two adaptive solves land on different time vectors.
149 */
150 double timestep = -1.0;
151 /**
152 * `options.stiff`, which `SolverOptions` defaults to TRUE and the transient passes to
153 * `ctmc_transient` as `useStiff`, so the reference's 'ode' transient is ode15s. An
154 * immediate transition (rate 1e8) makes the explicit ode23 pair take ~4e7 steps per unit
155 * time, so false is only usable on a chain without one.
156 */
157 bool stiff = true;
158 /**
159 * `options.config.transient_method`: "ode" (the default) integrates the
160 * forward equation, "fau" marches fast adaptive uniformization
161 * (`mc::ctmc_fau`) over the output grid.
162 *
163 * It is a config key rather than a value of `method` because it changes no
164 * stationary answer -- it is the transient path only -- and because the
165 * reference's valid-method list is enumerated by its sanity harness, which
166 * then wants a recorded baseline per method.
167 */
168 std::string transient_method = "ode";
169 /**
170 * `options.config.fau_epsilon`: total probability mass the whole grid may
171 * discard under "fau". It is divided by the number of steps, each step
172 * removing mass and none putting any back, so the accumulated defect stays
173 * below it.
174 */
175 double fau_epsilon = 1e-6;
176 /// `options.config.fau_delta`: occupancy below which a state is dropped.
177 double fau_delta = 1e-12;
178 /// `options.config.fau_ngrid`: output grid size when `timestep` is unset.
179 std::size_t fau_ngrid = 100;
180 /**
181 * `options.config.rate_sched`: the TIME-INHOMOGENEOUS transient. Empty means
182 * the constant-rate generator. Transient path only; the stationary solve
183 * ignores it, as the reference's does.
184 */
185 std::vector<CtmcRateSched> rate_sched;
186 /// `options.config.ctmc_tv_ngrid`: uniform grid size of the rate_sched propagator.
187 std::size_t ctmc_tv_ngrid = 100;
188 /**
189 * `options.config.chain_aggregation`: solve the CHAIN-AGGREGATED model.
190 *
191 * The state space grows with the per-class populations, so collapsing every
192 * chain onto a single class is the standard way to make an otherwise
193 * intractable multiclass model solvable: `api::sn_aggregate_chains` builds
194 * the collapsed model and `mva::sn_deaggregate_chain_results` maps the
195 * chain-level metrics back through alpha. EXACT on a product-form model, an
196 * approximation otherwise, since one aggregate service law replaces the
197 * per-class ones. Off by default: a caller who needs the exact multiclass
198 * answer must pay the state space, not discover the trade after the fact.
199 */
200 bool chain_aggregation = false;
201
202 /**
203 * `options.config.transform='lc'`: solve by LOAD CONCEALMENT.
204 *
205 * Birman-Kogan Algorithm 2 as a model transformation: each chain is solved
206 * on its own against the residual capacity the others leave it, with the
207 * single-chain subproblem a real struct this analyzer solves. Distinct from
208 * the `pfqn_bklc` KERNEL, which sweeps a demand matrix and stays the fast
209 * path; see `tr::transform_solve_lc`.
210 */
211 bool load_concealment = false;
212
213 /** Sweep cap for an iterated transformation; the kernel's own default. */
214 std::size_t transform_iter_max = 1000;
215 /**
216 * `options.config.fes_stations`: 1-BASED station indices to collapse into a
217 * flow-equivalent server before the chain is enumerated. Empty by default.
218 *
219 * The collapsed stations' own metrics are recovered by conditioning on the
220 * FES population, so the table still names every station of the model the
221 * caller built; see `solver_ctmc_fes_aggregation`.
222 */
223 std::vector<std::size_t> fes_stations;
224};
225
226/**
227 * Everything one CTMC solve produces.
228 *
229 * `chain` is the generator RESTRICTED to the component that was kept, together
230 * with the state space and the per-state arrival and departure rates on it. The
231 * rates are carried rather than recomputed because Q has already summed every
232 * synchronization's contribution into one entry, and a per-class rate cannot be
233 * recovered from that sum afterwards.
234 */
235template <class T>
238 std::vector<T> pi; ///< stationary distribution over chain.space
240 std::vector<std::size_t> cutoff; ///< the per-class cutoff actually used
241 std::string actualmethod = "default";
242 /**
243 * `fjclassmap` when the model was fork-join, empty otherwise: the ORIGINAL
244 * class of each auxiliary sibling class of the AUGMENTED struct that `chain`
245 * and `pi` are indexed by. `avg` is already folded back onto the original
246 * classes, so this is what relates the two index spaces.
247 */
248 std::vector<std::size_t> fjclassmap;
249 /**
250 * The struct the chain was built on, when it differs from the caller's in its
251 * state LAYOUT: set when heterogeneous server pools or a MAP service in pair form were
252 * rewritten (`ctmc_pools`, `ctmc_mapc`), so every reader decoding `chain.space` must
253 * use it. Null otherwise.
254 */
255 std::shared_ptr<const NetworkStruct<T>> solved_sn;
256 /**
257 * What the chain says about the model's Cache nodes: exact, since the hit
258 * and miss shares are read off the stationary law rather than approximated.
259 * Empty on a model with no Cache.
260 */
262 /** Set when the chain is a reducible mixture solved from an invented seed. */
263 std::string warning;
264};
265
266/**
267 * Port of `SolverCTMC.listValidMethods`.
268 *
269 * `exact` is an explicit ALIAS for the default state-space path: it pins the
270 * intent at the call site so an example or test cannot be re-baselined by a
271 * later change of what `default` selects. It must stay behaviourally identical
272 * to `default` -- nothing below branches on the name -- and that equivalence is
273 * the point of the alias.
274 *
275 * `mdd` never builds the explicit generator, so it is served by
276 * `solver_ctmc_mdd_analyzer` and returns before this file's state-space
277 * machinery is reached; it appears here only because this is the list the gate
278 * below is read against. The perfect sampler `cftp` / `cftp.approx` is a
279 * SolverNC method (`nc/solver_nc_cftp.h`) and is an unknown name here.
280 */
281inline std::vector<std::string> list_valid_methods() {
282 return {"default", "exact", "mdd"};
283}
284
285/** True for a method whose analyzer is not the explicit-generator one. */
286inline bool is_stateless_method(const std::string& method) {
287 return method == "mdd";
288}
289
290/**
291 * Port of `runAnalyzerChecks`' method gate.
292 *
293 * `mdd` IS refused here, because reaching THIS analyzer with that name means
294 * the caller routed a generator-free method into the generator path: the answer
295 * would be the enumerated one under a reported method that never ran. Its own
296 * entry point does not call this gate.
297 */
298inline void check_method(const std::string& method) {
299 const std::vector<std::string> valid = list_valid_methods();
300 if (std::find(valid.begin(), valid.end(), method) == valid.end())
301 throw UnsupportedError("SolverCTMC: the '" + method +
302 "' method is unsupported by this solver");
303 if (is_stateless_method(method))
304 throw UnsupportedError("SolverCTMC: the '" + method +
305 "' method does not enumerate a state space and is served by its "
306 "own analyzer, not by solver_ctmc_analyzer");
307}
308
309/**
310 * Refuse the constructs this port generates a chain for but does not MODEL.
311 *
312 * WHY A GATE AND NOT A TODO. A construct this port generates a chain for but does
313 * not MODEL comes back as a complete, plausible AvgTable computed from a chain
314 * that is not the model's, and a caller has no way to tell. Where the reference
315 * declares the construct supported, silence here would show two different answers
316 * with no indication which is wrong.
317 *
318 * As of this change the list is empty of structural gaps: fork-join goes through
319 * `fj_tag`, class and joint dependence through `cd_factor`, and BAS through the
320 * blocked marker. What remains are the DECLARATION defects below -- a dependence
321 * handle with no peak -- plus the refusals stated by name elsewhere
322 * (`getSymbolicGenerator`, a matrix-exponential `getProb`, `getCdfRespT` on an
323 * open model, and every region rule but DROP and WAITQ).
324 */
325template <class T>
327 // Server breakdowns are MODELLED (2026-09-23): `refresh_local_vars` reserves
328 // the trailing status column, `append_local_vars` enumerates it, and
329 // `after_event_station` fires FAILURE and REPAIR against it and suspends or
330 // degrades service while it is down, as `State.afterEventStation` does.
331 // Class dependence requires a DECLARED peak, and unlike the handle itself
332 // the peak cannot be derived: `cd_factor` scales the generator correctly
333 // without it, but the utilization column would then silently fall back to the
334 // busy-server probability, which at a station whose beta emulates extra
335 // servers is not a utilization. `getLimitedClassDependencePeak` makes it
336 // mandatory in the reference, so a missing peak is a model defect.
337 for (std::size_t i = 0; i < sn.stations.size(); ++i) {
338 if (sn.stations[i].cdscaling && sn.stations[i].cdscalingpeak.empty())
339 throw InputError(
340 "SolverCTMC: station '" + sn.stations[i].name +
341 "' declares class-dependent service without a peak rate. Utilization at a "
342 "class-dependent station is reported as T/mu/peak, so pass the peak to "
343 "setClassDependence");
344 if (sn.stations[i].jdscaling && sn.stations[i].jdscalingpeak.empty())
345 throw InputError(
346 "SolverCTMC: station '" + sn.stations[i].name +
347 "' declares joint-dependent service without a peak rate; pass the peak to "
348 "setJointDependence");
349 }
350 // BAS / BBS / RSRD are SOLVED, not refused, and the two tiers are the
351 // reference's, not a shortcut here:
352 //
353 // BAS gets the blocked marker. `refresh_bas_blocking` reserves the column,
354 // the generator emits the become-blocked edge, the departure handler clears
355 // it at 1e7 and `solver_ctmc_avg_from_pi` moves the held job to its
356 // destination. The chain is exact.
357 //
358 // BBS and RSRD get the DISABLED DEPARTURE only -- `arrival_is_lost` returns
359 // false, so the completion that would move the job is simply not generated
360 // until room frees. `declaresBlockedMarker` tests BAS alone, so the
361 // reference does the same and does not distinguish repetitive service from
362 // a random redraw of the destination. The server freezes with the job in
363 // it, which is the right occupancy but not the right sample path for RSRD.
364 //
365 // `solver_ctmc_waitq` still refuses all three on ITS path: the WAITQ
366 // generator augments the state with a per-region token FIFO and would have to
367 // carry the marker through that augmentation as well.
368}
369
370namespace analyzer_detail {
371
372/**
373 * The per-class cutoff of `@@SolverCTMC/runAnalyzer.m`.
374 *
375 * A closed class never consults it; an open one takes the caller's value.
376 *
377 * THE DEFAULT IS 10, NOT THE 6000-STATE BUDGET. `SolverOptions.m:107` sets
378 * `options.cutoff = 10` for SolverCTMC, so `runAnalyzer`'s
379 * `ceil(6000^(1/(M*K)))` fallback is reached only when the caller explicitly
380 * asks for an INFINITE cutoff, which is the one value the truncation cannot
381 * honour. Taking the fallback as the default instead put this port one lattice
382 * step below the reference on every open model and the answers differed in the
383 * third digit with nothing to show for it: on the Exp(0.5) -> three-FCFS-queue
384 * tandem the budget gives 9 and QLen at Q1 came out 0.9437940 against the
385 * reference's 0.9446411, while cutoff 10 reproduces it to every digit.
386 */
387template <class T>
388std::vector<std::size_t> resolve_cutoff(const NetworkStruct<T>& sn, const CtmcOptions& opt) {
389 const std::size_t M = sn.nstations, K = sn.nclasses;
390 std::vector<std::size_t> cut(K, 0);
391 bool any_open = false;
392 for (std::size_t k = 0; k < K; ++k)
393 if (!std::isfinite(sn.njobs()[k])) any_open = true;
394 if (!any_open) return cut;
395
396 if (!opt.cutoff_mat.empty()) {
397 // `spaceGenerator.m`: the LATTICE height of an open class is the largest
398 // bound any station grants it, `max(capacityc(:,r))`; the per-station
399 // entries then bound each node individually.
400 if (opt.cutoff_mat.size() != M)
401 throw InputError("SolverCTMC: the cutoff matrix must have one row per station");
402 for (std::size_t k = 0; k < K; ++k) {
403 if (std::isfinite(sn.njobs()[k])) continue;
404 for (std::size_t i = 0; i < M; ++i) {
405 if (opt.cutoff_mat[i].size() != K)
406 throw InputError(
407 "SolverCTMC: the cutoff matrix must have one column per class");
408 cut[k] = std::max(cut[k], opt.cutoff_mat[i][k]);
409 }
410 }
411 return cut;
412 }
413 if (!opt.cutoff_vec.empty()) {
414 if (opt.cutoff_vec.size() != K)
415 throw InputError("SolverCTMC: the per-class cutoff must have one entry per class");
416 return opt.cutoff_vec;
417 }
418 std::size_t c;
419 if (opt.cutoff > 0 && std::isfinite(opt.cutoff)) {
420 c = static_cast<std::size_t>(opt.cutoff);
421 } else if (opt.cutoff < 0) {
423 } else {
424 const double e = 1.0 / static_cast<double>(M * K);
425 c = static_cast<std::size_t>(std::ceil(std::pow(6000.0, e)));
426 if (c < 1) c = 1;
427 }
428 for (std::size_t k = 0; k < K; ++k)
429 if (!std::isfinite(sn.njobs()[k])) cut[k] = c;
430 return cut;
431}
432
433/**
434 * Port of the `maxPending` rule of `State.spaceGeneratorNodes`.
435 *
436 * Block B of a delayed-hit cache counts the secondary requests merged onto an
437 * in-flight fetch, and an EXACT solver has to enumerate it, so it needs a
438 * truncation level: the closed population bounds it where there is one, the
439 * state-space cutoff where the model is open. `after_event_cache` reads the same
440 * field and REFUSES a merge past the level rather than landing in a state the
441 * enumeration does not hold -- which is why the two must be set together.
442 *
443 * @return false when no cache carries a retrieval system, leaving `out` untouched
444 */
445template <class T>
446bool set_retrieval_truncation(const NetworkStruct<T>& sn, const std::vector<std::size_t>& cutoff,
447 NetworkStruct<T>& out) {
448 bool any = false;
449 for (typename std::map<std::size_t, qn::CacheParam<T>>::const_iterator ci =
450 sn.nodeparam.begin();
451 ci != sn.nodeparam.end(); ++ci)
452 if (ci->second.retrieval_capacity > 0 && !ci->second.retrieval_classes.empty()) any = true;
453 if (!any) return false;
454 long lvl = 0;
455 if (sn.nclosedjobs() > 0) {
456 lvl = static_cast<long>(sn.nclosedjobs()) - 1;
457 } else {
458 std::size_t mx = 0;
459 for (std::size_t k = 0; k < cutoff.size(); ++k) mx = std::max(mx, cutoff[k]);
460 lvl = static_cast<long>(mx) - 1;
461 }
462 if (lvl < 0) lvl = 0;
463 out = sn;
464 for (typename std::map<std::size_t, qn::CacheParam<T>>::iterator ci = out.nodeparam.begin();
465 ci != out.nodeparam.end(); ++ci)
466 if (ci->second.retrieval_capacity > 0) ci->second.max_pending_retrieval = lvl;
467 return true;
468}
469
470/**
471 * Port of `solver_ctmc.m:47-62`: a G-network SIGNAL NEVER RESIDES at a station,
472 * so its per-class capacity there is zero. A REPLY signal is exempt -- it
473 * completes a synchronous call and queues like an ordinary job.
474 *
475 * WHY THE LATTICE CANNOT DISCOVER THIS ON ITS OWN, and why leaving it undiscovered
476 * is not merely slow. `space_generator` walks every marginal admitted by
477 * `classcap` and, at an FCFS-family station, enumerates the ORDERED buffer of each
478 * one, so an uncapped signal class multiplies the buffer alphabet: a cutoff of c
479 * over two classes gives on the order of 2^c sequences where the model has c+1
480 * states. Every one of them is unreachable -- the arrival handler annihilates the
481 * signal instead of seating it -- so the answer is right and only the cost is
482 * wrong, which is exactly what makes it dangerous: it is invisible in the results.
483 *
484 * IT ALSO REPAIRS THE MEMORY GATE, which is the part that turns this from a
485 * slowdown into a kill. `ctmc_state_space_logsize` already drops signal classes
486 * from its buffered set (ctmc_state_space_logsize.h:176), so without this the gate
487 * sizes the SMALL space and clears a generation of the large one. Measured on the
488 * `test_ctmc_signal_util` G-network at the cutoff of 30 its tests ask for, the
489 * unfixed enumeration was OOM-killed at 43.6 GB resident.
490 *
491 * @return false when the model declares no annihilating signal, leaving `out` untouched
492 */
493template <class T>
494bool annihilate_signal_capacity(const NetworkStruct<T>& sn, NetworkStruct<T>& out) {
495 std::vector<bool> annihilated(sn.nclasses, false);
496 bool any = false;
497 for (std::size_t k = 0; k < sn.nclasses && k < sn.issignal.size(); ++k) {
498 if (!sn.issignal[k]) continue;
499 if (k < sn.signaltype.size() && sn.signaltype[k] == lang::SignalType::REPLY) continue;
500 annihilated[k] = true;
501 any = true;
502 }
503 if (!any) return false;
504 out = sn;
505 for (std::size_t i = 0; i < out.stations.size() && i < out.classcap.size(); ++i) {
506 // The Source is where a signal is BORN, so its capacity there is what the
507 // arrival stream is drawn from and must not be zeroed.
508 if (out.stations[i].sched == SchedStrategy::EXT) continue;
509 for (std::size_t k = 0; k < annihilated.size() && k < out.classcap[i].size(); ++k)
510 if (annihilated[k]) out.classcap[i][k] = 0.0;
511 }
512 return true;
513}
514
515/**
516 * Port of the cache write-back of `solver_ctmc_analyzer.m:333-450`.
517 *
518 * WHAT A CACHE'S HIT AND MISS SHARES ARE, ON A CHAIN. A read leaves the cache in
519 * its configured hit class or its miss class and in no other, so the two
520 * departure rates of those classes at the cache node ARE the hit and the miss
521 * flows, and normalizing by their sum gives the shares. Nothing here reads the
522 * routing matrix, whose cache entries `refresh_routing` resolved to a uniform
523 * split that is not the answer.
524 *
525 * THE DELAYED HIT IS A TRANSITION REWARD, NOT A STATE REWARD, and that is the
526 * whole reason this is not two lines. A merged secondary request departs in the
527 * HIT class, so the hit-class rate above is (true hits + delayed hits) and
528 * something has to split it. A fetch of item i completes on exactly the
529 * transitions that clear block A bit i, and each such transition releases the
530 * block-B count of item i, so the delayed rate is the pi-weighted sum of
531 * (count held) x (rate out on those transitions). The alternative identity
532 * lambda_i * phi_i is only PASTA-exact and would be wrong on a closed model.
533 *
534 * `phi` and `d1` are ordinary state rewards of the same two blocks and give the
535 * per-item DelayedHitQLen columns.
536 *
537 * `latency` is Little's law over the retrieval sub-system,
538 *
539 * Z_k = (sum_i phi_{i,k} + sum_i d_{i,k}) / (miss_k + delayed_k)
540 *
541 * the requests the sub-system holds over the rate at which they enter it. The
542 * numerator is the primary request of every in-flight fetch, counted as the network
543 * population of that read class's retrieval classes (block A is indexed by item alone
544 * and cannot be split when two read classes share a cache, whereas the retrieval
545 * classes are per (item, read class) pair), plus the secondary requests merged onto
546 * those fetches. Both terms and the delayed rate are exact rewards of this chain, so
547 * Z_k is exact up to the state space cutoff, which truncates block B and so approaches
548 * the exact value from below.
549 */
550template <class T>
551solvers::CacheMetrics<T> cache_metrics(const NetworkStruct<T>& sn, const CtmcResult<T>& r,
552 const std::vector<T>& pi, const Matrix<T>& QN) {
553 solvers::CacheMetrics<T> out =
554 solvers::cache_metrics_of(sn, std::vector<T>(), std::vector<T>(), std::vector<T>(),
555 std::vector<T>(), Matrix<T>(), Matrix<T>(), std::vector<T>());
556 if (out.caches.empty() || r.space.empty()) return out;
557 const std::size_t K = sn.nclasses, ns = r.space.size();
558 const double dnan = std::numeric_limits<double>::quiet_NaN();
559
560 for (std::size_t c = 0; c < out.caches.size(); ++c) {
561 solvers::CacheNodeMetrics<T>& m = out.caches[c];
562 const std::size_t isf = sn.stateful_index(m.node);
563 if (isf == 0) continue;
564 const qn::CacheParam<T>& cp = sn.nodeparam.find(m.node)->second;
565
566 std::vector<double> tn(K, 0.0);
567 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
568 const double p = num_traits<T>::to_double(pi[s]);
569 if (p == 0) continue;
570 if (isf - 1 >= r.dep_rates[s].size()) continue;
571 for (std::size_t k = 0; k < K && k < r.dep_rates[s][isf - 1].size(); ++k)
572 tn[k] += p * num_traits<T>::to_double(r.dep_rates[s][isf - 1][k]);
573 }
574
575 const std::size_t n = cp.nitems;
576 std::size_t tcc = 0;
577 for (std::size_t l = 0; l < cp.itemcap.size(); ++l)
578 if (cp.itemcap[l] > 0) tcc += static_cast<std::size_t>(cp.itemcap[l]);
579 std::vector<std::size_t> rcl, rci, rco;
580 if (cp.retrieval_capacity > 0) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
581 const std::size_t lvw = r.space[0].local[isf - 1].size();
582 const bool retr = !rcl.empty() && lvw >= tcc + n + rcl.size();
583 std::vector<double> drate(K, 0.0), dclass(K, 0.0), inflight(K, 0.0);
584
585 // TIME-STATIONARY per-item occupancy of each list. The contents block of the
586 // local vector holds the item index resident in each cache position, so
587 // P(item i is held by list l) is a state reward of pi. This is the
588 // TIME-WEIGHTED law, the counterpart of the EMBEDDED (per-request) one the
589 // NC/MVA cache algorithms return; the two coincide only under PASTA.
590 // The local row is [per-class counts | contents | block A | block B], so the
591 // contents block sits at the offset the trailing retrieval blocks leave.
592 const std::size_t tail = cp.retrieval_capacity > 0 ? n + rcl.size() : 0;
593 if (n > 0 && tcc > 0 && lvw >= tcc + tail) {
594 const std::size_t coff = lvw - (tcc + tail);
595 const std::size_t h = cp.itemcap.size();
596 Matrix<T> ip(n, h + 1);
597 std::vector<double> acc(n * h, 0.0);
598 std::vector<char> present(n, 0);
599 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
600 const double p = num_traits<T>::to_double(pi[s]);
601 if (p == 0) continue;
602 const std::vector<T>& lv = r.space[s].local[isf - 1];
603 std::size_t off = coff;
604 for (std::size_t l = 0; l < h; ++l) {
605 const std::size_t cap =
606 cp.itemcap[l] > 0 ? static_cast<std::size_t>(cp.itemcap[l]) : 0;
607 std::fill(present.begin(), present.end(), 0);
608 for (std::size_t q = 0; q < cap; ++q) {
609 const long it = static_cast<long>(num_traits<T>::to_double(lv[off + q]));
610 if (it >= 1 && static_cast<std::size_t>(it) <= n)
611 present[static_cast<std::size_t>(it) - 1] = 1;
612 }
613 for (std::size_t i = 0; i < n; ++i)
614 if (present[i]) acc[i * h + l] += p;
615 off += cap;
616 }
617 }
618 for (std::size_t i = 0; i < n; ++i) {
619 double miss = 1.0;
620 for (std::size_t l = 0; l < h; ++l) {
621 const double v = acc[i * h + l];
622 ip(i, l + 1) = num_traits<T>::from_double(v);
623 miss -= v;
624 }
625 ip(i, 0) = num_traits<T>::from_double(miss);
626 }
627 m.itemprob = ip;
628 }
629
630 if (retr) {
631 const std::size_t aoff = lvw - (n + rcl.size()), boff = aoff + n;
632 std::vector<double> phi(n, 0.0), d1(n, 0.0);
633 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
634 const double p = num_traits<T>::to_double(pi[s]);
635 if (p == 0) continue;
636 const std::vector<T>& lv = r.space[s].local[isf - 1];
637 for (std::size_t i = 0; i < n; ++i)
638 if (num_traits<T>::to_double(lv[aoff + i]) != 0) phi[i] += p;
639 for (std::size_t j = 0; j < rcl.size(); ++j)
640 d1[rci[j] - 1] += p * num_traits<T>::to_double(lv[boff + j]);
641 }
642 m.delayedhitqlen.assign(n, num_traits<T>::from_int(0));
643 m.delayedhitqlenfull.assign(n, num_traits<T>::from_int(0));
644 for (std::size_t i = 0; i < n; ++i) {
645 m.delayedhitqlen[i] = num_traits<T>::from_double(d1[i]);
646 m.delayedhitqlenfull[i] = num_traits<T>::from_double(d1[i] + phi[i]);
647 }
648 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
649 const double p = num_traits<T>::to_double(pi[s]);
650 if (p == 0) continue;
651 const std::vector<T>& lv = r.space[s].local[isf - 1];
652 for (std::size_t j = 0; j < rcl.size(); ++j) {
653 const std::size_t i = rci[j] - 1;
654 const double held = num_traits<T>::to_double(lv[boff + j]);
655 if (held <= 0 || num_traits<T>::to_double(lv[aoff + i]) == 0) continue;
656 double completes = 0.0;
657 for (std::size_t b = 0; b < ns; ++b) {
658 if (b == s) continue;
659 const double q = num_traits<T>::to_double(r.Q(s, b));
660 if (q == 0) continue;
661 if (num_traits<T>::to_double(r.space[b].local[isf - 1][aoff + i]) == 0)
662 completes += q;
663 }
664 if (rco[j] - 1 < K) drate[rco[j] - 1] += p * held * completes;
665 }
666 }
667 // The two populations the retrieval sub-system holds, split by the
668 // originating (reading) class, for the expected latency below.
669 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
670 const double p = num_traits<T>::to_double(pi[s]);
671 if (p == 0) continue;
672 const std::vector<T>& lv = r.space[s].local[isf - 1];
673 for (std::size_t j = 0; j < rcl.size(); ++j)
674 if (rco[j] - 1 < K)
675 dclass[rco[j] - 1] += p * num_traits<T>::to_double(lv[boff + j]);
676 }
677 for (std::size_t k = 0; k < K; ++k)
678 for (std::size_t i = 0; i < cp.retrieval_classes.size(); ++i) {
679 if (k >= cp.retrieval_classes[i].size()) continue;
680 const std::size_t rc = cp.retrieval_classes[i][k];
681 if (rc == 0 || rc > static_cast<std::size_t>(QN.cols())) continue;
682 for (std::size_t st = 0; st < static_cast<std::size_t>(QN.rows()); ++st)
683 inflight[k] += num_traits<T>::to_double(QN(st, rc - 1));
684 }
685 }
686
687 m.hitprob.assign(K, num_traits<T>::from_double(dnan));
688 m.missprob.assign(K, num_traits<T>::from_double(dnan));
689 if (retr) m.delayedprob.assign(K, num_traits<T>::from_double(dnan));
690 for (std::size_t k = 0; k < K; ++k) {
691 if (k >= cp.hitclass.size() || k >= cp.missclass.size()) continue;
692 const std::size_t h = cp.hitclass[k], mi = cp.missclass[k];
693 if (h == 0 || mi == 0 || h > K || mi > K) continue;
694 const double denom = tn[h - 1] + tn[mi - 1];
695 if (denom <= 0) continue;
696 // Capped at the hit-class flow: the two are the same measurement of
697 // the same transitions, and a floating-point excess would report a
698 // negative true-hit share rather than a zero one.
699 const double d = retr ? std::min(drate[k], tn[h - 1]) : 0.0;
700 m.hitprob[k] = num_traits<T>::from_double((tn[h - 1] - d) / denom);
701 m.missprob[k] = num_traits<T>::from_double(tn[mi - 1] / denom);
702 if (retr) m.delayedprob[k] = num_traits<T>::from_double(d / denom);
703 if (retr && cp.retrieval_queues.find(k) != cp.retrieval_queues.end() &&
704 !cp.retrieval_queues.find(k)->second.empty()) {
705 if (m.latency.empty()) m.latency.assign(K, num_traits<T>::from_double(dnan));
706 const double enter = d + tn[mi - 1];
707 if (enter > GlobalConstants::Zero)
708 m.latency[k] = num_traits<T>::from_double((inflight[k] + dclass[k]) / enter);
709 }
710 }
711 }
712 return out;
713}
714
715/**
716 * `cache_delayed_hit_rate`: the exact delayed-hit rate of a retrieval-aware cache, per
717 * originating class, as a TRANSITION reward over the solved chain `r` weighted by `pi`.
718 *
719 * A fetch of item i completes on exactly the transitions that clear block A bit i, and
720 * each releases the block-B counts of item i as delayed hits. `ind` is the 1-based
721 * node index of the cache. All zeros when the cache has no retrieval sub-system.
722 * The same reward `cache_metrics` takes above, exposed for the environment blend,
723 * which solves the same generators one stage at a time.
724 */
725template <class T>
726std::vector<double> cache_delayed_hit_rate(const NetworkStruct<T>& sn, std::size_t ind,
727 const CtmcResult<T>& r, const std::vector<T>& pi) {
728 const std::size_t K = sn.nclasses;
729 std::vector<double> drate(K, 0.0);
730 const std::size_t isf = sn.stateful_index(ind);
731 if (isf == 0 || r.space.empty()) return drate;
732 const auto it = sn.nodeparam.find(ind);
733 if (it == sn.nodeparam.end() || sn.nodes[ind - 1].nodetype != lang::NodeType::Cache)
734 return drate;
735 const qn::CacheParam<T>& cp = it->second;
736 if (!(cp.retrieval_capacity > 0)) return drate;
737 std::vector<std::size_t> rcl, rci, rco;
738 qn::cache_retrieval_class_map(cp, rcl, rci, rco);
739 if (rcl.empty()) return drate;
740 const std::size_t n = cp.nitems, ns = r.space.size();
741 std::size_t tcc = 0;
742 for (std::size_t l = 0; l < cp.itemcap.size(); ++l)
743 if (cp.itemcap[l] > 0) tcc += static_cast<std::size_t>(cp.itemcap[l]);
744 const std::size_t lvw = r.space[0].local[isf - 1].size();
745 if (lvw < tcc + n + rcl.size()) return drate;
746 const std::size_t aoff = lvw - (n + rcl.size()), boff = aoff + n;
747 for (std::size_t s = 0; s < ns && s < pi.size(); ++s) {
748 const double p = num_traits<T>::to_double(pi[s]);
749 if (p == 0) continue;
750 const std::vector<T>& lv = r.space[s].local[isf - 1];
751 for (std::size_t j = 0; j < rcl.size(); ++j) {
752 const std::size_t i = rci[j] - 1;
753 const double held = num_traits<T>::to_double(lv[boff + j]);
754 if (held <= 0 || num_traits<T>::to_double(lv[aoff + i]) == 0) continue;
755 double completes = 0.0;
756 for (std::size_t b = 0; b < ns; ++b) {
757 if (b == s) continue;
758 const double q = num_traits<T>::to_double(r.Q(s, b));
759 if (q == 0) continue;
760 if (num_traits<T>::to_double(r.space[b].local[isf - 1][aoff + i]) == 0)
761 completes += q;
762 }
763 if (rco[j] - 1 < K) drate[rco[j] - 1] += p * held * completes;
764 }
765 }
766 return drate;
767}
768
769/**
770 * Strip the reference's leading infinite-population column from a DECLARED
771 * Source row, so a state written by MATLAB, the JAR or native python names a
772 * row this port's enumerated space actually holds.
773 *
774 * The reference writes a Source as `[Inf | one phase block per class]` --
775 * `[Inf 1 0 0 0 0 0]` for a Source generating the first of six classes -- and
776 * this port's `from_marginal_core`, which the state-space walk runs, writes the
777 * phase blocks alone. The two therefore differ by exactly one column, and an
778 * untranslated row is one wider than every row of the space, so no lookup can
779 * match it and the padding rule cannot help: it left-pads a NARROW candidate
780 * and refuses a wide one. `rewardModel_mm1k` came back from its M2C row as
781 * `solver_ctmc_transient_analyzer: the initial state is not contained in the
782 * state space` while the JAR and native-python rows, each reading its own
783 * encoding, passed.
784 *
785 * The phase block is KEPT rather than rebuilt: a Source with a MAP arrival has
786 * several rows in its local space and which one the model declared is the
787 * caller's statement, not a detail to regenerate.
788 */
789template <class T>
790void strip_reference_source_column(const NetworkStruct<T>& sn, std::size_t ind,
791 std::vector<T>& row) {
792 const std::size_t ist = sn.nodes[ind - 1].station;
793 if (ist == 0 || ist > sn.stations.size()) return;
794 if (sn.stations[ist - 1].nodetype != NodeType::Source &&
795 sn.stations[ist - 1].sched != SchedStrategy::EXT)
796 return;
797 std::size_t w = 0;
798 for (std::size_t r = 0; r < sn.nclasses; ++r) w += sn.phasessz_of(ist, r + 1);
799 // Width is the discriminator rather than the leading value: an exact
800 // arithmetic has no infinity, so the reference's marker arrives as the
801 // MaxInt clamp `to_marginal` uses and cannot be tested for.
802 if (row.size() == w + 1) row.erase(row.begin());
803}
804
805/**
806 * The model's default initial state: every closed class's jobs at its reference
807 * station, everything else empty, which is `Network.initDefault`.
808 *
809 * The first row `from_marginal_node` emits for that marginal is taken. It is
810 * used for two things -- seeding the reachable walk, and choosing a connected
811 * component -- and for both any row with the right marginal serves, since rows
812 * sharing a marginal are mutually reachable by construction.
813 *
814 * @return false when some node admits no state at all for that marginal
815 */
816template <class T>
817bool default_init_state(const NetworkStruct<T>& sn, NetState<T>& init) {
818 const std::size_t R = sn.nclasses;
819 const std::vector<std::size_t>& sfn = sn.stateful_nodes;
820 init.local.assign(sfn.size(), std::vector<T>());
821 for (std::size_t f = 0; f < sfn.size(); ++f) {
822 const std::size_t ind = sfn[f];
823 // A DECLARED STATE WINS OVER THE DEFAULT MARKING, which is the whole
824 // point of declaring one: `setState` is used precisely to move the jobs
825 // off their reference stations, and rebuilding the default here would
826 // answer `getProbSysAggr` for a state the caller did not name. The pair
827 // (statespace, stateprior) is what the writers emit and the reader
828 // stores; the FIRST row is the state when the prior is the trivial one,
829 // and a genuine distribution over several rows is not a single initial
830 // state at all, so only the one-row case is taken.
831 const typename std::map<std::size_t, Matrix<T>>::const_iterator sp =
832 sn.statespace.find(ind);
833 if (sp != sn.statespace.end() && sp->second.rows() == 1 && sp->second.cols() > 0) {
834 std::vector<T> row(sp->second.cols());
835 for (std::size_t c = 0; c < sp->second.cols(); ++c) row[c] = sp->second(0, c);
836 strip_reference_source_column(sn, ind, row);
837 // A ROW OF EXACTLY ONE ENTRY PER CLASS IS A MARGINAL, NOT AN
838 // ENCODED ROW, and the two differ wherever a class is DISABLED at
839 // the station. The reference keeps a column for every class
840 // (`sn.phases` is 1 at a class it does not serve), while this port
841 // drops the block outright (`phases_of` is 0), so a Delay serving
842 // one of three classes has a width-3 row there and a width-1 row
843 // here, LEFT-PADDED to the encoding width. Installing the
844 // reference's row verbatim then puts the jobs in another class's
845 // block: on tut06_cache_lru_zipf the single client job landed where
846 // nothing could serve it and the sample path deadlocked at once,
847 // which is what a state nobody can leave looks like. Rebuilding it
848 // through `from_marginal_node_first` is the same construction the
849 // default branch below uses, so the two agree by construction.
850 std::vector<std::size_t> marg(R, 0), mph(R, 1);
851 const std::size_t mist = sn.nodes[ind - 1].station;
852 bool rebuilt = false;
853 if (row.size() == R && mist != 0) {
854 for (std::size_t r = 0; r < R; ++r) {
855 const double v = num_traits<T>::to_double(row[r]);
856 marg[r] = v > 0.0 ? static_cast<std::size_t>(v + 0.5) : 0;
857 mph[r] = sn.phasessz_of(mist, r + 1);
858 }
859 std::vector<T> built;
860 if (from_marginal_node_first(sn, ind, marg, mph, built) && !built.empty()) {
861 init.local[f] = built;
862 rebuilt = true;
863 }
864 }
865 if (!rebuilt) init.local[f] = row;
866 continue;
867 }
868 const std::size_t ist = sn.nodes[ind - 1].station;
869 std::vector<std::size_t> nmarg(R, 0), ph(R, 1);
870 if (ist != 0) {
871 for (std::size_t r = 0; r < R; ++r) ph[r] = sn.phasessz_of(ist, r + 1);
872 if (sn.stations[ist - 1].nodetype == NodeType::Source) {
873 // The infinite reservoir: one job per class in service, and only
874 // for the classes the Source generates -- the same guard
875 // `space_generator` needs. Without it this returns false on any
876 // model with a downstream-only class, the initial state is
877 // reported as absent, and the reducible-component selection
878 // silently falls back to "largest component": a plausible answer
879 // for the wrong chain.
880 for (std::size_t r = 0; r < R; ++r)
881 if (!sn.disabled[ist - 1][r]) nmarg[r] = 1;
882 } else {
883 for (std::size_t r = 0; r < R; ++r) {
884 const double nj = sn.njobs()[r];
885 if (std::isfinite(nj) && sn.classes[r].refstat == ist)
886 nmarg[r] = static_cast<std::size_t>(nj);
887 }
888 // A PLACE'S DECLARED MARKING IS THE INITIAL STATE, and it is not
889 // derivable from a class population: an OPEN class has none, so
890 // every Place of an open SPN started empty however many tokens
891 // the model put there. On `spn_open_sevenplaces` that dropped
892 // P1's two tokens and P5's one; T4 needs a P5 token to fire, so
893 // the marking never moved past P3 and the chain reported Tput 0
894 // with P3 pinned at the cutoff. `state_initial_occupancy` is the
895 // same accessor `space_capacity_c` sizes the lattice with, so the
896 // two agree on what the model declared.
897 for (std::size_t r = 0; r < R; ++r) {
898 const std::size_t m0 = qn::state_initial_occupancy(sn, ind, r);
899 if (m0 > nmarg[r]) nmarg[r] = m0;
900 }
901 }
902 }
903 if (!from_marginal_node_first(sn, ind, nmarg, ph, init.local[f])) return false;
904 }
905 return true;
906}
907
908/** The index in `space` of the default initial state, or npos. */
909template <class T>
910std::size_t init_state_index(const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space) {
911 const std::size_t npos = static_cast<std::size_t>(-1);
912 if (space.empty()) return npos;
913 NetState<T> init;
914 if (!default_init_state(sn, init)) return npos;
915 for (std::size_t f = 0; f < init.local.size(); ++f) {
916 // Every state of a node carries the node's widest row, left-padded with
917 // zeros; a candidate built at the natural width would never match.
918 const std::size_t w = space[0].local[f].size();
919 if (init.local[f].size() > w) return npos;
920 if (init.local[f].size() < w)
921 init.local[f].insert(init.local[f].begin(), w - init.local[f].size(),
922 num_traits<T>::from_int(0));
923 }
924 const std::vector<double> key = ctmc_detail::state_key(init);
925 for (std::size_t s = 0; s < space.size(); ++s)
926 if (ctmc_detail::state_key(space[s]) == key) return s;
927 return npos;
928}
929
930/**
931 * The initial DISTRIBUTION over `space`: the product of the declared per-node
932 * priors, or a point mass on the default initial state where none is declared.
933 *
934 * Port of the initial-state loop of `@@SolverCTMC/runAnalyzer.m`, which walks
935 * the cartesian product of the per-node state spaces, weights each combination
936 * by the product of its per-node priors, integrates the forward equation once
937 * per combination and SUMS the trajectories with those weights. Every quantity
938 * the transient analyzer reports is linear in pi(t), and pi(t) is linear in
939 * pi(0), so seeding the mixture and integrating ONCE gives the same answer at a
940 * fraction of the cost -- and on ONE time grid, where the reference has to
941 * interpolate its separate adaptive grids onto their union to add them.
942 *
943 * A combination the enumerated space does not contain is an error rather than a
944 * dropped term: it means the declared space and the reachable one disagree, and
945 * renormalizing over what is left would answer for a different prior.
946 *
947 * @return false when a node admits no state at all, as `default_init_state` does
948 */
949template <class T>
950bool init_state_distribution(const NetworkStruct<T>& sn, const std::vector<NetState<T>>& space,
951 std::vector<T>& pi0) {
952 const std::size_t npos = static_cast<std::size_t>(-1);
953 pi0.assign(space.size(), num_traits<T>::from_int(0));
954 if (space.empty()) return false;
955 NetState<T> base;
956 if (!default_init_state(sn, base)) return false;
957 const std::vector<std::size_t>& sfn = sn.stateful_nodes;
958
959 // Per stateful node: the rows it may start in and their probabilities. A
960 // node with no declared prior contributes its default row alone.
961 std::vector<std::vector<std::vector<T>>> rows(sfn.size());
962 std::vector<std::vector<double>> wts(sfn.size());
963 for (std::size_t f = 0; f < sfn.size(); ++f) {
964 const typename std::map<std::size_t, Matrix<T>>::const_iterator ss =
965 sn.statespace.find(sfn[f]);
966 const typename std::map<std::size_t, std::vector<T>>::const_iterator sp =
967 sn.stateprior.find(sfn[f]);
968 if (ss == sn.statespace.end() || sp == sn.stateprior.end() || ss->second.rows() == 0 ||
969 ss->second.rows() != sp->second.size()) {
970 rows[f].push_back(base.local[f]);
971 wts[f].push_back(1.0);
972 continue;
973 }
974 for (std::size_t r = 0; r < ss->second.rows(); ++r) {
975 const double w = num_traits<T>::to_double(sp->second[r]);
976 if (!(w > 0.0)) continue; // a zero-probability row is not a state to visit
977 std::vector<T> row(ss->second.cols());
978 for (std::size_t c = 0; c < ss->second.cols(); ++c) row[c] = ss->second(r, c);
979 strip_reference_source_column(sn, sfn[f], row);
980 rows[f].push_back(row);
981 wts[f].push_back(w);
982 }
983 if (rows[f].empty()) return false;
984 }
985
986 std::vector<std::size_t> pick(sfn.size(), 0);
987 double total = 0.0;
988 for (;;) {
989 NetState<T> cand = base;
990 double w = 1.0;
991 for (std::size_t f = 0; f < sfn.size(); ++f) {
992 cand.local[f] = rows[f][pick[f]];
993 w *= wts[f][pick[f]];
994 // Every state of a node carries the node's widest row, left-padded
995 // with zeros; a candidate at the natural width would never match.
996 const std::size_t wd = space[0].local[f].size();
997 if (cand.local[f].size() > wd) return false;
998 if (cand.local[f].size() < wd)
999 cand.local[f].insert(cand.local[f].begin(), wd - cand.local[f].size(),
1000 num_traits<T>::from_int(0));
1001 }
1002 std::size_t idx = npos;
1003 const std::vector<double> key = ctmc_detail::state_key(cand);
1004 for (std::size_t s = 0; s < space.size() && idx == npos; ++s)
1005 if (ctmc_detail::state_key(space[s]) == key) idx = s;
1006 if (idx == npos) return false;
1007 pi0[idx] = T(pi0[idx] + num_traits<T>::from_double(w));
1008 total += w;
1009
1010 std::size_t f = 0;
1011 for (; f < sfn.size(); ++f) {
1012 if (++pick[f] < rows[f].size()) break;
1013 pick[f] = 0;
1014 }
1015 if (f == sfn.size()) break;
1016 }
1017 if (!(total > 0.0)) return false;
1018 // The declared priors are per node and need not multiply to one; the
1019 // reference sums the weighted trajectories without renormalizing, so a
1020 // prior that already sums to one is unchanged and one that does not is
1021 // reported as the reference reports it.
1022 return true;
1023}
1024
1025/** Weakly connected components of the generator, as a per-state label. */
1026template <class T>
1027std::vector<std::size_t> weak_components(const Matrix<T>& Q, std::size_t& ncomp) {
1028 const std::size_t n = Q.rows();
1029 const std::size_t npos = static_cast<std::size_t>(-1);
1030 std::vector<std::size_t> comp(n, npos);
1031 ncomp = 0;
1032 for (std::size_t s = 0; s < n; ++s) {
1033 if (comp[s] != npos) continue;
1034 std::vector<std::size_t> stack{s};
1035 comp[s] = ncomp;
1036 while (!stack.empty()) {
1037 const std::size_t u = stack.back();
1038 stack.pop_back();
1039 for (std::size_t v = 0; v < n; ++v) {
1040 if (comp[v] != npos) continue;
1041 // The DIAGONAL is minus the row sum and is nonzero at almost
1042 // every state, so it must not be read as an edge to itself.
1043 if (v == u) continue;
1044 if (num_traits<T>::to_double(Q(u, v)) != 0 ||
1045 num_traits<T>::to_double(Q(v, u)) != 0) {
1046 comp[v] = comp[u];
1047 stack.push_back(v);
1048 }
1049 }
1050 }
1051 ++ncomp;
1052 }
1053 return comp;
1054}
1055
1056/** Restrict a CtmcResult to a subset of its states, keeping their order. */
1057template <class T>
1058CtmcResult<T> restrict_to(const CtmcResult<T>& r, const std::vector<std::size_t>& wset) {
1059 CtmcResult<T> out;
1060 out.Q = Matrix<T>(wset.size(), wset.size(), num_traits<T>::from_int(0));
1061 for (std::size_t a = 0; a < wset.size(); ++a)
1062 for (std::size_t b = 0; b < wset.size(); ++b) out.Q(a, b) = r.Q(wset[a], wset[b]);
1063 out.space.reserve(wset.size());
1064 out.arv_rates.reserve(wset.size());
1065 out.dep_rates.reserve(wset.size());
1066 for (std::size_t a = 0; a < wset.size(); ++a) {
1067 out.space.push_back(r.space[wset[a]]);
1068 out.arv_rates.push_back(r.arv_rates[wset[a]]);
1069 out.dep_rates.push_back(r.dep_rates[wset[a]]);
1070 }
1071 // The filtration is indexed by the SAME rows as Q, so it has to be
1072 // restricted with it or every CDF built from it would index the wrong
1073 // states. Its diagonal is NOT rebuilt: a filtration entry is a rate the
1074 // event contributed, not a generator, and it has no diagonal to speak of.
1075 out.filt.reserve(r.filt.size());
1076 for (std::size_t f = 0; f < r.filt.size(); ++f) {
1077 Matrix<T> F(wset.size(), wset.size(), num_traits<T>::from_int(0));
1078 for (std::size_t a = 0; a < wset.size(); ++a)
1079 for (std::size_t b = 0; b < wset.size(); ++b) F(a, b) = r.filt[f](wset[a], wset[b]);
1080 out.filt.push_back(F);
1081 }
1082 // The submatrix of a generator is not a generator: the rates that left the
1083 // component have to come off the diagonal, or the rows no longer sum to zero
1084 // and the stationary solve is of a matrix that is not a chain.
1085 make_infgen(out.Q);
1086 return out;
1087}
1088
1089} // namespace analyzer_detail
1090
1091/**
1092 * Build the chain of ONE struct, solve it, reduce it.
1093 *
1094 * The state space is the FULL encoding space, as the reference's
1095 * `options.config.state_space_gen` default of 'default' asks for, so the
1096 * reducible handling below is on the normal path and not an edge case. Two
1097 * exceptions force the reachable walk instead: a global synchronization (an SPN)
1098 * and a fork firing list.
1099 *
1100 * Split out of `solver_ctmc_analyzer` so that the fork-join wrapper can run it on
1101 * the AUGMENTED struct without the gates firing twice.
1102 */
1103template <class T>
1105 const std::vector<qn::FjSync<T>>& fjsync) {
1106 CtmcSolution<T> out;
1107 // Heterogeneous server pools are rewritten into exact per-pool phase blocks
1108 // (`solver_ctmc_pools`); everything below enumerates and solves the rewritten copy.
1109 NetworkStruct<T> sn_pool;
1110 const bool pooled = ctmc_pools(sn_arg, sn_pool);
1111 const NetworkStruct<T>& sn_pl = pooled ? sn_pool : sn_arg;
1112 // A MAP service at an FCFS station runs in pair form, the carried-phase law of JMT and
1113 // LDES (`solver_ctmc_mapc`); it widens the server block, so readers need the copy too.
1114 NetworkStruct<T> sn_mapc;
1115 const bool mapc = ctmc_mapc(sn_pl, sn_mapc);
1116 const NetworkStruct<T>& sn_in = mapc ? sn_mapc : sn_pl;
1117 if (pooled || mapc) out.solved_sn = std::make_shared<const NetworkStruct<T>>(sn_in);
1118 out.cutoff = analyzer_detail::resolve_cutoff(sn_in, opt);
1119 // A delayed-hit cache is enumerated under a declared merge truncation, and
1120 // the events are held to the same one; every other model walks `sn_in`.
1121 NetworkStruct<T> sn_trunc;
1122 const bool retr = analyzer_detail::set_retrieval_truncation(sn_in, out.cutoff, sn_trunc);
1123 const NetworkStruct<T>& sn_r = retr ? sn_trunc : sn_in;
1124 // A signal class holds no place in a station's buffer, and saying so BEFORE
1125 // the memory gate is what keeps the gate's estimate and the generator's
1126 // enumeration measuring the same space; see `annihilate_signal_capacity`.
1127 NetworkStruct<T> sn_sig;
1128 const bool sig = analyzer_detail::annihilate_signal_capacity(sn_r, sn_sig);
1129 const NetworkStruct<T>& sn = sig ? sn_sig : sn_r;
1130
1131 // THE MEMORY PRE-GATE, `@SolverCTMC/runAnalyzer.m:150-164`. It runs BEFORE
1132 // any generation because `state_max` is enforced inside the generator loop,
1133 // so on its own it enumerates up to three million states before refusing --
1134 // and three million is a fixed count that knows nothing about how much
1135 // memory this host actually has. `mdd` never reaches here;
1136 // see the exemption note in ctmc_memory_gate.h, which is load-bearing.
1137 {
1138 mc::CtmcSizeOptions szopt;
1139 szopt.cutoff = opt.cutoff;
1140 const double log_nstates = mc::ctmc_state_space_logsize(sn, szopt);
1141 const mc::CtmcGateResult gate =
1142 mc::ctmc_memory_gate(log_nstates, opt.force, opt.memory_safety_fraction);
1143 if (!gate.ok) throw UnsupportedError(gate.message + " Stopping SolverCTMC.");
1144 }
1145
1146 const std::vector<Sync<T>> sync = refresh_sync(sn);
1147 const std::vector<qn::GlobalSync<T>> gsync = refresh_global_sync(sn);
1148
1149 // AN SPN NEEDS THE REACHABLE WALK, not the lattice enumeration. A firing
1150 // does not conserve the per-chain population, and a Transition's state is
1151 // per-MODE, so no population marginal produces the states in which a mode is
1152 // firing: `from_marginal_node` emits only the all-idle row. Enumerating the
1153 // lattice therefore yields a space every ENABLE lands outside of, and the
1154 // generator comes out empty. The reference forces
1155 // `options.config.state_space_gen = 'reachable'` for exactly this class of
1156 // model; the condition here is the presence of a global synchronization,
1157 // which is the same set.
1158 // A FORK FIRING LIST forces the same choice as an SPN, for the same reason:
1159 // the lattice enumeration walks the per-chain population, and a firing does
1160 // not conserve it -- one parent becomes B siblings in classes the lattice
1161 // gives population 0. The reference sets
1162 // `options.config.state_space_gen = 'reachable'` on every fork-join model.
1163 std::vector<NetState<T>> space;
1164 if (!gsync.empty() || !fjsync.empty()) {
1165 NetState<T> init;
1166 if (!analyzer_detail::default_init_state(sn, init))
1167 throw UnsupportedError(
1168 "SolverCTMC: the model's initial marking admits no state; check the Place "
1169 "populations against the class reference stations");
1170 // THE CUTOFF TRAVELS WITH THE WALK, or an open model never terminates:
1171 // the Source keeps producing and the walk runs to `state_max` instead of
1172 // answering. It is the same bound the lattice arm below applies.
1173 space = reachable_space_generator(sn, init, sync, gsync, opt.state_max, fjsync,
1174 out.cutoff, opt.cutoff_mat);
1175 } else {
1176 space = space_generator(sn, out.cutoff, opt.state_max, opt.cutoff_mat);
1177 }
1178 if (space.empty())
1179 throw UnsupportedError(
1180 "SolverCTMC: the state space is empty; no state satisfies the model's capacities");
1181 // A DROP region censors the chain: the states it forbids are simply never
1182 // occupied, so removing them and letting `make_infgen` re-close the rows IS
1183 // the censored chain. WAITQ and the blocking rules augment the state
1184 // instead and are refused by name inside.
1185 space = ctmc_filter_regions(sn, space);
1186 CtmcResult<T> r = solver_ctmc(sn, space, sync, gsync, opt.keep_filtration, fjsync);
1187 // ELIMINATE THE VANISHING STATES, `solver_ctmc.m:812`. Until this ran the
1188 // port returned the UNREDUCED chain: a Router or Fork pass-through, a firable
1189 // Join and an immediate SPN mode all kept their GlobalConstants::Immediate
1190 // row, so `-a states` and `-a gen` reported a state space the other three
1191 // codebases had already complemented away -- six states against four on
1192 // `fj_tiny_closed`, the two extras carrying 6.1e-09 of the mass each. `-a avg`
1193 // agreed to the digits printed for exactly that reason, which is why the gap
1194 // stayed invisible unless the chain itself was asked for.
1196
1197 const std::size_t npos = static_cast<std::size_t>(-1);
1198 // The initial state is looked up in the REDUCED space: the enumeration index
1199 // no longer addresses the same rows once the vanishing ones are gone.
1200 std::size_t init = analyzer_detail::init_state_index(sn, r.space);
1201
1202 std::size_t ncomp = 0;
1203 const std::vector<std::size_t> comp = analyzer_detail::weak_components(r.Q, ncomp);
1204 if (ncomp > 1) {
1205 std::size_t pick;
1206 if (init != npos) {
1207 pick = comp[init];
1208 } else {
1209 // The initial state is not representable in this space -- an SPN
1210 // whose ENABLE states were folded away, for one -- so the reference
1211 // keeps the largest component instead of failing.
1212 std::vector<std::size_t> sizes(ncomp, 0);
1213 for (std::size_t s = 0; s < comp.size(); ++s) ++sizes[comp[s]];
1214 pick = static_cast<std::size_t>(
1215 std::max_element(sizes.begin(), sizes.end()) - sizes.begin());
1216 }
1217 std::vector<std::size_t> wset;
1218 for (std::size_t s = 0; s < comp.size(); ++s)
1219 if (comp[s] == pick) wset.push_back(s);
1220 // The seed moves WITH the restriction: keeping the pre-restriction row
1221 // would point the block decomposition at whatever state now sits there.
1222 std::size_t moved = npos;
1223 if (init != npos)
1224 for (std::size_t k = 0; k < wset.size(); ++k)
1225 if (wset[k] == init) {
1226 moved = k;
1227 break;
1228 }
1229 init = moved;
1230 r = analyzer_detail::restrict_to(r, wset);
1231 }
1232
1233 // Every stationary solve goes through the block decomposition, as
1234 // `solver_ctmc_analyzer.m` does: the irreducible case is the degenerate one
1235 // BSCC / no transient states, so no dispatch can disagree with the
1236 // algorithm about whether the chain is reducible. `ctmc_solve` splits a
1237 // reducible generator into WEAK components and renormalizes across them,
1238 // which is not the answer the declared initial state selects.
1239 line::util::LineConsole::step("infinitesimal generator built: %zu states",
1240 static_cast<std::size_t>(r.Q.rows()));
1241 line::util::LineConsole::step("solving for the stationary distribution");
1242 const CtmcStationaryResult<T> st = ctmc_stationary(r.Q, init);
1243 line::util::LineConsole::step("stationary distribution obtained, computing the mean metrics");
1244 out.pi = st.pi;
1245 out.warning = st.warning;
1246 out.avg = solver_ctmc_avg_from_pi(sn, r, out.pi);
1247 out.cache = analyzer_detail::cache_metrics(sn, r, out.pi, out.avg.QN);
1248 out.chain = r;
1249 out.actualmethod = opt.method;
1250 return out;
1251}
1252
1253
1254/**
1255 * Port of `solver_ctmc_analyzer.m` plus the fork-join wrapper of
1256 * `@@SolverCTMC/runAnalyzer.m`.
1257 *
1258 * A fork-join model is solved on the TAG-AUGMENTED copy and the sibling classes
1259 * are folded back at the end. The chain and the stationary vector returned are the
1260 * AUGMENTED ones, as the reference's `result.space` is: they are indexed by a
1261 * class set the caller did not declare, which is why `fjclassmap` comes back with
1262 * them rather than being discarded.
1263 */
1264template <class T>
1266 check_method(opt.method);
1267
1268 // `@@SolverCTMC/runAnalyzer.m:126` converts the non-Markovian service laws
1269 // to a Markovian surrogate on its own copy of the struct. The conversion
1270 // runs before the support check, because what the check must see is the
1271 // struct the generator will actually be built from.
1272 //
1273 // THE PH FIT IS FORCED HERE, where the reference takes its CME default.
1274 // MATLAB's CTMC assembles a RATIONAL generator and can carry a matrix
1275 // exponential; this port assembles an ordinary one, and an ME's D0 has
1276 // off-diagonal entries that are not rates, so the chain it builds is not a
1277 // Markov chain. Measured on a closed Delay + Gamma(4, 0.5) FCFS model with
1278 // two jobs: the CME fit gives Util 1.075 -- IMPOSSIBLE for one server --
1279 // and a throughput of 0.538 at the delay against 0.400 at the queue, i.e.
1280 // flow that does not balance around a cycle. The PH fit gives Util 0.950,
1281 // balanced throughput 0.475 and QLen 1.525, against MATLAB's 0.954, 0.477
1282 // and 1.523.
1283 //
1284 // The residual 0.4% IS the divergence from the reference, and it is the
1285 // price of the honest fit: MATLAB matches two moments exactly with its ME
1286 // (scv 0.25) where the Bernstein PH lands at scv 0.323. Closing it needs
1287 // the CTMC to handle a rational generator, which is a separate change.
1288 NetworkStruct<T> converted;
1289 const NetworkStruct<T>* snp = &sn_in;
1290 if constexpr (num_traits<T>::has_transcendental) {
1291 if (api::sn_has_nonmarkov(sn_in, false)) {
1292 converted = sn_in;
1294 no.order = opt.nonmkv_order;
1295 no.phfit = api::PhFit::Ph;
1296 api::sn_nonmarkov_toph(converted, no);
1297 snp = &converted;
1298 }
1299 }
1300 const NetworkStruct<T>& sn = *snp;
1301
1303 // runAnalyzerChecks' universal feature gate, AFTER ctmc_check_support so the
1304 // declaration-defect messages, which the declared set also withholds, still
1305 // win over the gate's generic one.
1306 qn::feature_gate("SolverCTMC", qn::ctmc_feature_set(opt.method), sn);
1307
1308 if (!tr::has_fork_join(sn))
1309 return solve_struct(sn, opt, std::vector<qn::FjSync<T>>());
1310
1311 const qn::FjTagged<T> fjt = qn::fj_tag(sn);
1312 CtmcSolution<T> out = solve_struct(fjt.V, opt, fjt.fjsync);
1313 tr::fj_foldback(sn, out.avg, fjt.fjclassmap, fjt.korig);
1314 out.fjclassmap = fjt.fjclassmap;
1315 return out;
1316}
1317
1318/**
1319 * Port of `@@SolverCTMC/runAnalyzer.m`'s result assembly: solve, then apply the
1320 * metric filter `@@NetworkSolver/getAvg` puts between the analyzer and the
1321 * caller, so the table is the same shape SolverMVA and SolverNC print.
1322 *
1323 * The response-time mask the product-form runners apply -- drop a metric whose
1324 * response time is below the tolerance -- is NOT applied here. A CTMC reports
1325 * what the chain does, and a station a class genuinely visits with a tiny
1326 * response time is a real measurement rather than a numerical artefact of a
1327 * fixed point that did not converge there.
1328 */
1329template <class T>
1331 const std::string& method) {
1332 const std::size_t M = sn.nstations, K = sn.nclasses;
1333
1334 std::vector<std::vector<bool>> srcmask(M, std::vector<bool>(K, false));
1335 for (std::size_t i = 0; i < M; ++i)
1336 if (sn.stations[i].nodetype == NodeType::Source)
1337 for (std::size_t k = 0; k < K; ++k) srcmask[i][k] = true;
1338
1339 // A PLACE IS MEASURED IN TOKENS and the analyzer counts FIRING EVENTS: an
1340 // arc of multiplicity 2 moves two tokens per firing, and an IMMEDIATE mode
1341 // moves them in zero time, which is not a timed event and so is counted by
1342 // no analyzer here or in the reference. `sn_pn_avg_rates` recovers the mode
1343 // firing rates from the flow-balance equations and rewrites the Place rows
1344 // of TN and RN, exactly where `@SolverCTMC/runAnalyzer.m:126` and `:266` do
1345 // it -- on the RAW table, before the filter, and on the ORIGINAL struct,
1346 // since `solver_ctmc_analyzer_any` folds a fork-join tagging back before
1347 // returning. A model with no Place is returned untouched. AN follows below
1348 // from the repaired TN, as `AN = sn_get_arvr_from_tput(sn, TN)` does there.
1349 const api::SnPnAvgRates<T> pn =
1350 api::sn_pn_avg_rates(sn, d.avg.QN, d.avg.TN, Matrix<T>(), d.avg.RN);
1351
1353 o.QN = mva::filter_metric(sn, d.avg.QN, mva::MetricKind::QLen, nullptr);
1354 o.UN = mva::filter_metric(sn, d.avg.UN, mva::MetricKind::Util, nullptr);
1356 o.TN = mva::filter_metric(sn, pn.TN, mva::MetricKind::Tput, nullptr);
1358 mva::MetricKind::ResidT, nullptr);
1360 &srcmask);
1361 o.CN = d.avg.CN;
1362 o.XN = d.avg.XN;
1363 o.cache = d.cache;
1364 o.method = method;
1366 o.iter = 1;
1367 return o;
1368}
1369
1370template <class T>
1371mva::AvgResult<T> solver_ctmc_run_analyzer(const NetworkStruct<T>& sn, const CtmcOptions& opt);
1372
1373/**
1374 * Solve the CHAIN-AGGREGATED model and map its metrics back to the classes.
1375 *
1376 * `api::sn_aggregate_chains` collapses every chain onto a single class, class
1377 * switching disappearing with it, and `mva::sn_deaggregate_chain_results` maps
1378 * chain-level metrics back through alpha, the per-station share of the chain's
1379 * visits each class carries. Both transforms existed in all four codebases with
1380 * no solver consumer; this is that consumer.
1381 *
1382 * WHAT IS TRADED. Exactness on a non-product-form model: one aggregate service
1383 * law, fitted to the alpha-weighted first two moments, replaces the per-class
1384 * ones. On a product-form model the chain IS the unit MVA and convolution
1385 * already solve in, so the answer is exact and the state space is the smaller
1386 * one.
1387 */
1388template <class T>
1390 const CtmcOptions& opt) {
1391 // Driven by tr::transform_solve_chains, so the aggregate is solved through
1392 // an inner-solve seam rather than a hard-wired call to this analyzer.
1393 // Clearing the flag states that the aggregate must not be re-aggregated,
1394 // rather than relying on the caller's nchains < nclasses guard to decline.
1395 CtmcOptions sub = opt;
1396 sub.chain_aggregation = false;
1398 sn,
1399 [&sub](const NetworkStruct<T>& subsn) { return solver_ctmc_run_analyzer(subsn, sub); },
1400 opt.method);
1401}
1402
1403/**
1404 * Solve by LOAD CONCEALMENT, the iterated transformation.
1405 *
1406 * `tr::transform_solve_lc` sweeps the chains in Gauss-Seidel order, solving each
1407 * concealed single-chain struct with THIS analyzer. Clearing the flag on the
1408 * inner options states that a subproblem must not be concealed again, rather
1409 * than relying on its one-chain shape to decline.
1410 */
1411template <class T>
1413 const CtmcOptions& opt) {
1414 CtmcOptions sub = opt;
1415 sub.load_concealment = false;
1417 sn,
1418 [&sub](const NetworkStruct<T>& subsn) { return solver_ctmc_run_analyzer(subsn, sub); },
1419 opt.method, opt.transform_iter_max);
1420}
1421
1422/**
1423 * Solve with a station subset replaced by a FLOW-EQUIVALENT SERVER, then
1424 * recover the collapsed stations' own metrics by conditioning.
1425 *
1426 * `api::fes_aggregate` has existed in all four codebases with no solver
1427 * consumer at all: it was exercised by examples and tests only, so nothing in
1428 * the solver stack depended on it. Flow-equivalent aggregation is the standard
1429 * route to HIERARCHICAL DECOMPOSITION -- a subnetwork is solved in isolation
1430 * and enters the outer chain as a single load-dependent station, which is what
1431 * makes an otherwise intractable state space tractable.
1432 *
1433 * The reduced model answers for the surviving stations directly. For a
1434 * collapsed station the answer is the Chandy-Herzog-Woo conditional sum
1435 * E[Q_i] = sum_n P(N_fes = n) * Q_i(n), with P read off the reduced chain's
1436 * stationary law and Q_i(n) from the isolated subnetwork
1437 * (`fes::fes_compute_metrics`). Throughput needs no conditioning: flow is fixed
1438 * by the routing and an exact reduction leaves the chain throughput unchanged.
1439 *
1440 * EXACT when the collapsed subnetwork is product-form, which is the condition
1441 * `fes_aggregate` already imposes; an approximation otherwise, and the
1442 * state-space saving is the reason to accept that.
1443 */
1444template <class T>
1446 const CtmcOptions& opt) {
1447 const std::size_t M = sn.nstations, K = sn.nclasses;
1448 const std::vector<std::size_t>& subset = opt.fes_stations;
1449 if (subset.size() < 2)
1450 throw InputError(
1451 "options.config.fes_stations must name at least two stations: collapsing one "
1452 "station into a flow-equivalent server saves nothing.");
1453 if (subset.size() >= M)
1454 throw InputError(
1455 "options.config.fes_stations names every station: there is no complement left "
1456 "to solve.");
1457 for (std::size_t i : subset)
1458 if (i < 1 || i > M)
1459 throw InputError("options.config.fes_stations must be 1-based station indices in 1.." +
1460 std::to_string(M) + ".");
1461
1463 const fes::FesDeaggInfo<T>& info = agg.deagg;
1464 const NetworkStruct<T>& snRed = agg.model.get_struct();
1465
1466 CtmcOptions sub = opt;
1467 sub.fes_stations.clear();
1468 const CtmcSolution<T> red = solver_ctmc_analyzer(snRed, sub);
1469 const mva::AvgResult<T> redAvg = solver_ctmc_avg_table(snRed, red, sub.method);
1470
1471 // P(N_fes = n). The aggregate state space carries K columns per STATION, at
1472 // (ist-1)*K + k, so the FES's block is the one at its station index.
1473 const Matrix<T> SSq = ctmc_state_space_aggr(snRed, red.chain.space);
1474 const std::size_t fesIst = snRed.nodes[info.fesNode - 1].station;
1475 std::size_t tableSize = 1;
1476 for (int c : info.cutoffs) tableSize *= static_cast<std::size_t>(c + 1);
1477 std::vector<double> Pn(tableSize, 0.0);
1478 for (std::size_t r = 0; r < SSq.rows(); ++r) {
1479 std::vector<int> nvec(K, 0);
1480 for (std::size_t k = 0; k < K; ++k)
1481 nvec[k] = static_cast<int>(
1482 std::llround(num_traits<T>::to_double(SSq(r, (fesIst - 1) * K + k))));
1483 Pn[fes::ljd_linearize(nvec, info.cutoffs) - 1] +=
1484 r < red.pi.size() ? num_traits<T>::to_double(red.pi[r]) : 0.0;
1485 }
1486
1489
1490 const T zero = num_traits<T>::from_int(0);
1492 out.QN = Matrix<T>(M, K, zero);
1493 out.UN = Matrix<T>(M, K, zero);
1494 out.RN = Matrix<T>(M, K, zero);
1495 out.TN = Matrix<T>(M, K, zero);
1496
1497 for (std::size_t a = 0; a < info.complementIndices.size(); ++a) {
1498 const std::size_t i = info.complementIndices[a] - 1;
1499 for (std::size_t k = 0; k < K; ++k) {
1500 out.QN(i, k) = redAvg.QN(a, k);
1501 out.UN(i, k) = redAvg.UN(a, k);
1502 out.TN(i, k) = redAvg.TN(a, k);
1503 }
1504 }
1505
1506 const std::size_t Msub = info.subsetIndices.size();
1507 Matrix<T> Qsub(Msub, K, zero), Usub(Msub, K, zero);
1508 for (std::size_t idx = 0; idx < tableSize; ++idx) {
1509 if (!(Pn[idx] > 0.0)) continue;
1510 const T w = num_traits<T>::from_double(Pn[idx]);
1511 for (std::size_t a = 0; a < Msub && a < cm.QN[idx].rows(); ++a)
1512 for (std::size_t k = 0; k < K; ++k) {
1513 Qsub(a, k) += T(w * cm.QN[idx](a, k));
1514 Usub(a, k) += T(w * cm.UN[idx](a, k));
1515 }
1516 }
1517 for (std::size_t a = 0; a < Msub; ++a) {
1518 const std::size_t i = info.subsetIndices[a] - 1;
1519 for (std::size_t k = 0; k < K; ++k) {
1520 out.QN(i, k) = Qsub(a, k);
1521 out.UN(i, k) = Usub(a, k);
1522 // Flow through a station is fixed by the routing, so it is the FES's
1523 // throughput scaled by the ratio of ORIGINAL visit ratios.
1524 T ratio = zero;
1525 for (std::size_t c = 0; c < sn.nchains; ++c) {
1526 const std::size_t isf = sn.stateful_of_station(i + 1) - 1;
1527 const std::size_t isfFes = snRed.stateful_of_station(fesIst) - 1;
1528 if (isf < sn.visits[c].rows() && isfFes < snRed.visits[c].rows() &&
1529 snRed.visits[c](isfFes, k) > zero)
1530 ratio += T(sn.visits[c](isf, k) / snRed.visits[c](isfFes, k));
1531 }
1532 out.TN(i, k) = T(redAvg.TN(fesIst - 1, k) * ratio);
1533 }
1534 }
1535
1536 out.CN.assign(K, zero);
1537 out.XN = redAvg.XN;
1538 for (std::size_t i = 0; i < M; ++i)
1539 for (std::size_t k = 0; k < K; ++k) {
1540 if (out.TN(i, k) > zero) out.RN(i, k) = T(out.QN(i, k) / out.TN(i, k));
1541 out.CN[k] += out.RN(i, k);
1542 }
1543 out.AN = mva::sn_get_arvr_from_tput(sn, out.TN);
1545 out.method = opt.method;
1546 out.actualmethod = opt.method + "/fes";
1547 return out;
1548}
1549
1550/** Solve and format in one call, for a caller with no use for the chain. */
1551template <class T>
1553 // Chain aggregation, opt-in and only where the transform is not the identity.
1554 if (opt.load_concealment) return solver_ctmc_load_concealment(sn, opt);
1555 if (opt.chain_aggregation && sn.nchains < sn.nclasses)
1557 // Flow-equivalent server aggregation, opt-in.
1558 if (!opt.fes_stations.empty()) return solver_ctmc_fes_aggregation(sn, opt);
1560}
1561
1562} // namespace ctmc
1563} // namespace line
1564
1565#endif // LINE_SOLVERS_CTMC_SOLVER_CTMC_ANALYZER_H
What a solver observed about the Cache nodes of a model.
InputError(const std::string &what)
Definition error.h:39
std::size_t rows() const
Definition matrix.h:89
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
std::size_t stateful_of_station(std::size_t st) const
std::vector< NodeDef > nodes
every node, in creation order
std::vector< Matrix< T > > visits
(nchains) each (nstateful x nclasses)
static void step(const char *fmt,...)
Write one progress line.
Host-aware memory pre-gate for SolverCTMC.
Steady-state distribution of a continuous-time Markov chain.
Worst-case log-size of the CTMC state space induced by a NetworkStruct.
Port of matlab/src/solvers/CTMC/ctmc_stationary.m: the single entry point for the stationary distribu...
The exception types the port throws.
Flow-equivalent-server aggregation: replace a station subset by one station.
Per-station metrics of the ISOLATED subnetwork at every population state, the companion of fes_comput...
Port of matlab/src/api/fj/sn_fj_validate.m and matlab/src/io/@@ModelAdapter/fjtag....
Fork-join TAG AUGMENTATION: the fold-back half of the transform/lift pair that CTMC and SSA share.
Running progress log of a LINE solver run (the "solver console").
Dense matrix and non-owning view.
bool sn_has_nonmarkov(const qn::NetworkStruct< T > &sn, bool preserve_det=false)
Whether any law in the struct would be replaced, so a caller can skip copying the struct when there i...
SnPnAvgRates< T > sn_pn_avg_rates(const qn::NetworkStruct< T > &sn, const Matrix< T > &QN, const Matrix< T > &TN, const Matrix< T > &AN, const Matrix< T > &RN)
Port of sn_pn_avg_rates: rewrite the place rows of TN, AN and RN so that they agree with the recovere...
@ Ph
Bernstein density fit: a genuine phase-type, shape-carrying.
void sn_nonmarkov_toph(qn::NetworkStruct< T > &sn, const NonmarkovOptions &opts=NonmarkovOptions())
Replace every non-Markovian service and firing law by a Markovian surrogate.
mva::AvgResult< T > solver_ctmc_fes_aggregation(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Solve with a station subset replaced by a FLOW-EQUIVALENT SERVER, then recover the collapsed stations...
bool ctmc_pools(const qn::NetworkStruct< T > &in, qn::NetworkStruct< T > &out)
Rewrite a copy of in into out when it has pooled stations; false, leaving out untouched,...
void ctmc_eliminate_vanishing(CtmcResult< T > &res)
Port of the "now remove immediate transitions" block of solver_ctmc.m (:812-870): eliminate the vanis...
mva::AvgResult< T > solver_ctmc_chain_aggregation(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Solve the CHAIN-AGGREGATED model and map its metrics back to the classes.
void check_method(const std::string &method)
Port of runAnalyzerChecks' method gate.
std::vector< std::string > list_valid_methods()
Port of SolverCTMC.listValidMethods.
void ctmc_check_support(const NetworkStruct< T > &sn)
Refuse the constructs this port generates a chain for but does not MODEL.
bool ctmc_mapc(const qn::NetworkStruct< T > &in, qn::NetworkStruct< T > &out)
Rewrite a copy of in into out when it has a MAP service at an FCFS station; false,...
void make_infgen(Matrix< T > &Q)
Port of ctmc_makeinfgen: turn an off-diagonal rate matrix into a generator.
mva::AvgResult< T > solver_ctmc_avg_table(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const std::string &method)
Port of @@SolverCTMC/runAnalyzer.m's result assembly: solve, then apply the metric filter @@NetworkSo...
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.
mva::AvgResult< T > solver_ctmc_load_concealment(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Solve by LOAD CONCEALMENT, the iterated transformation.
CtmcSolution< T > solve_struct(const NetworkStruct< T > &sn_arg, const CtmcOptions &opt, const std::vector< qn::FjSync< T > > &fjsync)
Build the chain of ONE struct, solve it, reduce it.
std::vector< NetState< T > > ctmc_filter_regions(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
The states of space a DROP region admits, in their original order.
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.
CtmcSolution< T > solver_ctmc_analyzer(const NetworkStruct< T > &sn_in, const CtmcOptions &opt)
Port of solver_ctmc_analyzer.m plus the fork-join wrapper of @@SolverCTMC/runAnalyzer....
mva::AvgResult< T > solver_ctmc_run_analyzer(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Solve and format in one call, for a caller with no use for the chain.
CtmcStationaryResult< T > ctmc_stationary(const Matrix< T > &Q, std::size_t init_index=static_cast< std::size_t >(-1))
constexpr std::size_t CTMC_DEFAULT_CUTOFF
SolverOptions.m:107: the per-class state-space cutoff SolverCTMC defaults an open or mixed model to.
bool is_stateless_method(const std::string &method)
True for a method whose analyzer is not the explicit-generator one.
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< 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::size_t ljd_linearize(const std::vector< int > &nvec, const std::vector< int > &cutoffs)
Linearized index of a per-class population vector, for Limited Joint Dependence (LJD) tables.
FesConditionalMetrics< T > fes_compute_metrics(const Matrix< T > &L, const std::vector< int > &mi, const std::vector< bool > &isDelay, const std::vector< int > &cutoffs)
Per-station metrics of the ISOLATED subnetwork at every population state, the companion of fes_comput...
FesAggregateResult< T > fes_aggregate(const qn::NetworkStruct< T > &sn, const std::vector< std::size_t > &subsetIndices, const FesOptions &options=FesOptions())
Flow-equivalent-server aggregation: replace a station subset by one station.
@ REPLY
completes a synchronous call, releasing a held server
Definition lang_types.h:168
constexpr double CTMC_DEFAULT_SAFETY_FRACTION
Fraction of available memory the solver may target.
CtmcGateResult ctmc_memory_gate(double log_nstates, bool force=false, double safety_fraction=CTMC_DEFAULT_SAFETY_FRACTION)
Decide whether a state space of log-size log_nstates can be solved here.
double ctmc_state_space_logsize(const qn::NetworkStruct< T > &sn, const CtmcSizeOptions &opt=CtmcSizeOptions())
Worst-case log state-space size of sn.
Matrix< T > sn_get_residt_from_respt(const qn::NetworkStruct< T > &L, const Matrix< T > &RN)
Port of sn_get_residt_from_respt: the per-JOB residence time.
Matrix< T > filter_metric(const qn::NetworkStruct< T > &L, const Matrix< T > &metric, MetricKind kind, const std::vector< std::vector< bool > > *zero_mask)
Port of filterMetric: what @@NetworkSolver/getAvg does between the analyzer and the caller.
Matrix< T > sn_get_arvr_from_tput(const qn::NetworkStruct< T > &L, const Matrix< T > &TN)
void cache_retrieval_class_map(const CacheParam< T > &cp, std::vector< std::size_t > &rc_list, std::vector< std::size_t > &rc_items, std::vector< std::size_t > &rc_orig)
Port of State.cacheRetrievalClassMap: the canonical order of a cache's retrieval classes,...
bool from_marginal_node_first(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< std::size_t > &n, const std::vector< std::size_t > &phases, std::vector< T > &out)
The FIRST row from_marginal_node emits, BUILT rather than enumerated.
Definition state.h:2212
std::size_t state_initial_occupancy(const NetworkStruct< T > &sn, std::size_t ind, std::size_t r)
Port of State.initialOccupancy: the class-r jobs node ind holds in the DECLARED initial state,...
Definition state.h:2361
FeatureSet ctmc_feature_set(const std::string &method)
SolverCTMC.getFeatureSet, the reference's 104 MATLAB names in full.
void feature_gate(const std::string &solver, const FeatureSet &declared, const NetworkStruct< T > &sn, const std::string &requested_method="", const std::string &resolved_method="")
runAnalyzerChecks: refuse a model the solver does not declare, by name.
FjTagged< T > fj_tag(const NetworkStruct< T > &sn)
Port of ModelAdapter.fjtag.
Definition fj_tag.h:302
CacheMetrics< T > cache_metrics_of(const qn::NetworkStruct< T > &sn, const std::vector< T > &hitprob, const std::vector< T > &missprob, const std::vector< T > &delayedprob, const std::vector< T > &latency, const Matrix< T > &hitproblist, const Matrix< T > &itemprob, const std::vector< T > &listcost)
Assemble CacheMetrics from what a cache analyzer returned.
mva::AvgResult< T > transform_solve_lc(const qn::NetworkStruct< T > &sn, InnerSolve inner_solve, const std::string &method, std::size_t iter_max=1000)
LOAD CONCEALMENT (Birman-Kogan Algorithm 2) as a transformation, and the first ITERATED one.
void fj_foldback(const qn::NetworkStruct< T > &sn, Avg &a, const std::vector< std::size_t > &fjclassmap, std::size_t korig)
Reduce the augmented metrics onto the original classes.
mva::AvgResult< T > transform_solve_chains(const qn::NetworkStruct< T > &sn, InnerSolve inner_solve, const std::string &method)
Chain aggregation: collapse every chain onto a single class, solve, and map the chain metrics back on...
bool has_fork_join(const qn::NetworkStruct< T > &sn)
Whether the model needs the tag augmentation at all.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Collapse every chain onto one class, port of ModelAdapter.aggregateChains.
Replace every non-Markovian service and firing law by a Markovian surrogate.
Ports of matlab/src/api/sn/sn_pn_firing_rates.m and sn_pn_avg_rates.m.
Port of solver_ctmc.m: the infinitesimal generator of a queueing network, assembled from the enumerat...
Finite Capacity Regions in SolverCTMC: the DROP rule, as a filter on the enumerated state space,...
Port of solver_ctmc_mapc.m: the pair form of a MAP (and MMPP2) service at an FCFS station,...
Port of solver_ctmc_pools.m: rewrite every heterogeneous-server station (Queue.addServerType) into th...
The DECLARED side of the gate: one feature set per solver.
The SolverMVA class surface: @@SolverMVA/runAnalyzer.m and the gates around it.
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...
options.config.nonmkv and friends.
std::size_t order
nonmkvorder, the phase budget
PhFit phfit
which surrogate family
What sn_pn_avg_rates rewrites in place; empty tables are left empty.
The mean performance metrics a stationary vector maps to.
The SolverCTMC knobs this port honours.
bool force
options.force: downgrade the memory pre-gate's refusal to a warning.
double memory_safety_fraction
options.memorySafetyFraction: share of available memory a solve may target.
std::vector< std::size_t > cutoff_vec
per-class override, or empty
std::size_t nonmkv_order
options.config.nonmkvorder: the phase budget sn_nonmarkov_toph spends on a non-Markovian service law.
bool load_concealment
options.config.transform='lc': solve by LOAD CONCEALMENT.
std::size_t transform_iter_max
Sweep cap for an iterated transformation; the kernel's own default.
bool stiff
options.stiff, which SolverOptions defaults to TRUE and the transient passes to ctmc_transient as use...
std::vector< std::vector< std::size_t > > cutoff_mat
options.cutoff AS A (station x class) MATRIX, or empty.
std::vector< std::size_t > fes_stations
options.config.fes_stations: 1-BASED station indices to collapse into a flow-equivalent server before...
std::size_t state_max
refuse a space larger than this
double fau_delta
options.config.fau_delta: occupancy below which a state is dropped.
double cutoff
< 0 = not given
double timestep
options.timestep: the FIXED OUTPUT STEP of a transient analysis.
std::string transient_method
options.config.transient_method: "ode" (the default) integrates the forward equation,...
std::size_t fau_ngrid
options.config.fau_ngrid: output grid size when timestep is unset.
std::vector< CtmcRateSched > rate_sched
options.config.rate_sched: the TIME-INHOMOGENEOUS transient.
bool chain_aggregation
options.config.chain_aggregation: solve the CHAIN-AGGREGATED model.
std::size_t ctmc_tv_ngrid
options.config.ctmc_tv_ngrid: uniform grid size of the rate_sched propagator.
double fau_epsilon
options.config.fau_epsilon: total probability mass the whole grid may discard under "fau".
bool keep_filtration
Keep the per-synchronization EVENT FILTRATION alongside Q.
One entry of options.config.rate_sched: the rate of (station, class) follows the piecewise-linear sch...
std::vector< double > rates
std::vector< double > tgrid
The generator, the state space it is indexed by, and the event rates.
Definition solver_ctmc.h:57
std::vector< NetState< T > > space
row i of Q is space[i]
Definition solver_ctmc.h:59
Matrix< T > Q
(n x n) infinitesimal generator
Definition solver_ctmc.h:58
Everything one CTMC solve produces.
std::string warning
Set when the chain is a reducible mixture solved from an invented seed.
std::vector< std::size_t > cutoff
the per-class cutoff actually used
solvers::CacheMetrics< T > cache
What the chain says about the model's Cache nodes: exact, since the hit and miss shares are read off ...
std::shared_ptr< const NetworkStruct< T > > solved_sn
The struct the chain was built on, when it differs from the caller's in its state LAYOUT: set when he...
std::vector< std::size_t > fjclassmap
fjclassmap when the model was fork-join, empty otherwise: the ORIGINAL class of each auxiliary siblin...
std::vector< T > pi
stationary distribution over chain.space
std::vector< T > pi
stationary distribution, length N
std::string warning
Empty unless the chain is an UNSEEDED reducible mixture.
static constexpr double Zero
Definition lang_types.h:762
What fes_aggregate returns.
The per-population tables the conditional sum is taken over.
std::vector< Matrix< T > > QN
Indexed by the 0-based linearized population state; each (M_sub x K).
std::vector< Matrix< T > > UN
Indexed by the 0-based linearized population state; each (M_sub x K).
Everything needed to map an FES result back onto the original model.
Matrix< T > isolatedDemands
(M_sub x K)
std::vector< std::size_t > subsetIndices
1-based, as given
std::vector< std::size_t > complementIndices
1-based
std::vector< bool > isolatedIsDelay
std::vector< int > isolatedServers
std::size_t fesNode
1-based node index of the FES in the new model
std::vector< int > cutoffs
The gate verdict, plus the message the caller reports either way.
Options the estimator reads; only the cutoff matters.
double cutoff
< 0 or non-finite = not given, take the solver default
The metrics getAvg returns, after filtering.
Matrix< T > TN
throughput
Matrix< T > RN
response time, per visit
Matrix< T > UN
utilization
Matrix< T > WN
residence time, per job
std::string method
the method asked for
std::string actualmethod
the algorithm that ran
Matrix< T > QN
queue length
std::vector< T > CN
system response time per class
std::vector< T > XN
system throughput per class
solvers::CacheMetrics< T > cache
What the cache branches observed, EMPTY on a model with no Cache node and on every solver that does n...
Matrix< T > AN
arrival rate
One fork firing synchronization: sn.fjsync{k}.
The augmented struct and everything needed to read its results back.
Definition fj_tag.h:71
std::vector< FjSync< T > > fjsync
Definition fj_tag.h:76
NetworkStruct< T > V
Definition fj_tag.h:72
std::size_t korig
Definition fj_tag.h:78
std::vector< std::size_t > fjclassmap
fjclassmap[a-1] is the ORIGINAL class of auxiliary class a, 0 for originals.
Definition fj_tag.h:74
One network state: the per-stateful-node local rows it is composed of.
Definition state.h:2347
Every Cache node of the model, in node order; empty on a model with none.
Solver-agnostic driver of a model TRANSFORMATION, the sibling of solvers/mva/fj_driver....