LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
network_struct.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_LANG_QN_NETWORK_STRUCT_H
6#define LINE_LANG_QN_NETWORK_STRUCT_H
7
8/**
9 * @file
10 * @ingroup line_lang
11 * A queueing network and its refreshed NetworkStruct.
12 *
13 * SCOPE. This is the `sn` of `matlab/src/lang/@@MNetwork/refreshStruct.m`, held
14 * together with the model it was refreshed from, because every consumer of a
15 * struct in this port also mutates the model and re-derives it (SolverLN
16 * re-parameterises service processes between outer iterations, the fork-join
17 * transform rewrites routing). MATLAB does the same through the `sn` its
18 * Network caches.
19 *
20 * It grew out of the SolverLN layer -- which is now `qn::Layer<T>`, a
21 * NetworkStruct plus the LQN element annotations -- so the fields a layer never
22 * carries are being filled in as the solvers that read them are ported. What is
23 * ABSENT is absent by name, never silently: a model needing an unported field
24 * is refused where the field would be read.
25 *
26 * NODES vs STATIONS vs STATEFUL NODES. There are three nested index spaces,
27 * exactly as `sn` has, and they stopped coinciding the moment fork/join
28 * arrived:
29 *
30 * nodes everything routing passes through: the stations, plus a Fork, its
31 * output Routers, a Join, a Sink
32 * stateful the nodes that hold jobs: every node except the Fork
33 * stations the nodes that serve jobs, including a Join (which serves at rate
34 * Inf) and a Source (whose service process is the arrival process)
35 *
36 * Routing (`P`, and the `rtnodes` derived from it) lives at NODE level; `rt` is
37 * its stochastic complement over the stateful nodes, which is what eliminates
38 * the Fork; `visits` is indexed by stateful node and `nodevisits` by node.
39 *
40 * ClassSwitch NODES ARE MATERIALISED, as `@MNetwork/link.m:225-329` does it:
41 * `link()` inserts one `CS_<i>_to_<j>` per ordered pair of linked nodes whose
42 * routing switches class, folds the switching probability into the first leg
43 * and leaves every surviving route SAME-CLASS. The stochastic complement in
44 * `rt` removes them again, since they are not stateful, so `rt` is unchanged by
45 * their presence; `rtnodes` and `nnodes` carry the extra hop, which is what the
46 * node table reports and what the JSIM export needs (`jmt_writer.h` can only
47 * emit a ClassSwitch for a node whose type IS ClassSwitch). Until 2026-08-04
48 * this port kept the switch on the EDGE and synthesized nothing; the analytical
49 * solvers read that correctly through `route_eff`, but the node count was one
50 * short per switch and JMT silently simulated the UNSWITCHED model. Verified
51 * rather than assumed: the regression compares chains, inchain, refstat,
52 * refclass, njobs, nservers, rates, scv and visits against MATLAB dumps.
53 *
54 * ARITHMETIC. Rates, service times, routing probabilities and visits are T.
55 * Populations and server counts are `double`, since they are counts that may
56 * be infinite (a delay station, an open class).
57 */
58
60#include <algorithm>
61#include <cmath>
62#include <functional>
63#include <limits>
64#include <map>
65#include <string>
66#include <vector>
67
75#include "line/num/number.h"
76#include "line/util/error.h"
77#include "line/util/matrix.h"
78
79namespace line {
80namespace qn {
81
82using lang::CdScaling;
83using lang::GdScaling;
84using lang::Distrib;
86using lang::GlobalConstants;
89using lang::NodeType;
94
95/** One fork firing synchronization: `sn.fjsync{k}`. */
96template <class T>
97struct FjSync {
98 std::size_t fork = 0; ///< 1-based Fork node
99 std::size_t join = 0; ///< 1-based Join node that closes it
100 std::size_t cls = 0; ///< 1-based ORIGINAL class being forked
101 std::size_t tag = 0; ///< 1-based tag this entry allocates
102 std::vector<std::size_t> branchheads; ///< 1-based node per branch
103 std::vector<std::size_t> auxclasses; ///< the tag's auxiliary class per branch
104 /** (B x T) every auxiliary class of this (fork, class), for the tag scan. */
105 std::vector<std::vector<std::size_t>> auxall;
106 std::size_t weight = 1; ///< tasksPerLink: siblings emitted per branch
107 /**
108 * Per-branch tasksPerLink, EMPTY when every branch carries `weight`.
109 *
110 * A fork may send a different number of tasks down each link
111 * (`Fork.setTasksPerLink(class, n, dest)`), and the count is what sizes the
112 * auxiliary class capacity and the join's required count, so it cannot be
113 * collapsed to the node-wide mean. It stays empty in the uniform case so
114 * that a plain fork carries exactly the shape it always did; the two paths
115 * emit the same interleaved order there, so this is a shape convention and
116 * not a behavioural fork.
117 */
118 std::vector<std::size_t> weightlink;
120};
121
122/**
123 * `sn.nodeparam{j}.fj`: what a Join node needs to fire on identity.
124 *
125 * `auxmatrix[r]` is the (B x T) matrix of auxiliary sibling classes minted for
126 * original class r -- row b is branch b, column t is tag t -- and `required[r][b]`
127 * is how many siblings of branch b a firing consumes (`tasksPerLink` under the
128 * only join strategy the exact implementation accepts). It lives here rather
129 * than in `fj_tag.h` because `NetworkStruct` carries it and the event layer reads
130 * it; `fj_tag.h` is what FILLS it.
131 */
133 std::size_t fork = 0;
134 std::vector<std::size_t> origclasses;
135 std::map<std::size_t, std::vector<std::vector<std::size_t>>> auxmatrix;
136 std::map<std::size_t, std::vector<std::size_t>> required;
137};
138
139/**
140 * Variable forking levels, the twin of MATLAB `sn.nodeparam{f}.fanOutLink` /
141 * `.fanOutProb` / `.fanOutDist`.
142 *
143 * All three are (nnodes x nclasses) and indexed by DESTINATION NODE rather than
144 * by link ordinal, because the link order is an artefact of `connmatrix`
145 * traversal and renumbers whenever the model is relinked.
146 *
147 * `fan_out_link(k,r)`: expected tasks sent to destination k for class r, zero
148 * on a link class r does not take. `fan_out_prob(k,r)`: probability the branch
149 * fires at all, so the SIBLING COUNT is random even when each link carries a
150 * fixed number. `fan_out_dist[k][r]`: the jobs-per-link distribution, with an
151 * unset (DISABLED) entry meaning degenerate at `fan_out_link(k,r)`.
152 *
153 * This is a per-node BLOCK keyed by node index -- the shape `joindecl` already
154 * uses, and the shape MATLAB's `nodeparam{f}` has -- rather than three fields on
155 * `NodeDef`, because `NodeDef` is not templated on the arithmetic and a
156 * `Matrix<T>` cannot live there. A fork with no override has no entry at all,
157 * which is how a consumer tells the classic case from the variable one without
158 * inspecting a single number.
159 */
160template <class T>
161struct ForkParam {
164 std::vector<std::vector<lang::Distrib<T> > > fan_out_dist;
165};
166
167/**
168 * A node of the network.
169 *
170 * `station` is the 1-based station index when the node serves jobs and 0 when
171 * it does not (a Fork, a Router, a ClassSwitch, a Sink). `stateful` is false
172 * for the nodes that hold no jobs and are eliminated by the stochastic
173 * complement that builds rt.
174 */
175struct NodeDef {
176 std::string name;
177 NodeType nodetype = NodeType::Queue;
178 /**
179 * Declared as a Queue although its INF discipline makes `nodetype` Delay (`Network::add_queue`). MATLAB keeps
180 * the node's CLASS apart from sn.nodetype, and the TikZ exporter and linemodel_save read the class: such a node
181 * is drawn as a buffer with an infinite server and saved with type "Queue". Not part of the reference sn.
182 */
183 bool queue_object = false;
184 bool stateful = true;
185 std::size_t station = 0;
186 /**
187 * `sn.routing`, per class. PROB means the routing block P carries the
188 * probabilities as given; RAND and RROBIN mean the refresh spreads them
189 * uniformly over the nodes this one is connected to, which is the routing
190 * MATRIX of a round-robin dispatcher (its determinism lives in the higher
191 * moments, see refresh_routing). Anything else is state dependent, and the
192 * refresh refuses it by name: a state-dependent strategy silently treated
193 * as PROB returns a product-form answer for a model that has none.
194 */
195 std::vector<RoutingStrategy> routing;
196 /**
197 * The per-destination weights of a WRROBIN dispatcher, per class: a map
198 * from 1-based destination NODE index to its weight.
199 *
200 * MATLAB and the JAR keep these on the node object and the native Python
201 * struct calls the field `sn.routingweights`; this port carries them here
202 * so a WRROBIN model round-trips through model.json without loss. The
203 * refresh still refuses WRROBIN by name, because spreading the weights into
204 * a routing MATRIX would answer a state-independent model instead.
205 */
206 std::vector<std::map<std::size_t, double>> routing_weights;
207 /**
208 * The scalar parameter of a parameterized dispatcher, per class: the d of
209 * a power-of-d (SQ) choice. Zero means the strategy takes none. Carried for
210 * the same reason as `routing_weights`.
211 */
212 std::vector<int> routing_param;
213 /**
214 * `Fork.output.tasksPerLink` == MATLAB `sn.nodeparam{f}.fanOut`: how many
215 * tasks a fork emits per outgoing link. It scales both the MMT auxiliary
216 * arrival rate and the synchronisation delay; the default 1 leaves a plain
217 * fork unchanged.
218 *
219 * For a fork whose degree is RANDOM this is the MEAN, so a consumer that
220 * only knows this field gets E[tasks per link] rather than a number the
221 * fork never emits.
222 */
223 double tasks_per_link = 1.0;
224
225 /**
226 * A Logger node's trace configuration, MATLAB's `Logger` properties and
227 * `sn.nodeparam{ind}` for a Logger.
228 *
229 * The defaults are `Logger.m`'s constructor: timestamp, job id and job
230 * class are recorded, the wall-clock start time, the logger name and the
231 * two inter-departure columns are not. They are carried on the node rather
232 * than derived because a Logger with no file name exports to JMT as a
233 * LogTunnel that writes nowhere -- the solver runs, and the trace the user
234 * asked for is silently absent.
235 */
236 struct LoggerParam {
237 std::string file_name; ///< base name, no directory
238 std::string file_path; ///< directory, MATLAB's model.getLogPath
239 bool start_time = false;
240 bool logger_name = false;
241 bool timestamp = true;
242 bool job_id = true;
243 bool job_class = true;
244 bool time_same_class = false;
245 bool time_any_class = false;
246 };
248};
249
250/**
251 * The parameters of a Cache node, MATLAB's `sn.nodeparam{ind}` for a Cache.
252 *
253 * `itemcap` is the capacity of each of the `h` cache lists (a single-level
254 * cache has one entry). `nitems` is the item population `n`.
255 *
256 * `pread[v]` is the read distribution of class v over the n items; an EMPTY row
257 * is MATLAB's `NaN` placeholder, "class v does not read this cache", and leaves
258 * that class's rates at zero. `accost[v][k]` is the ((h+1) x (h+1)) list-to-list
259 * routing matrix of class v on item k; an EMPTY `accost` selects the reference
260 * default, the linear cache in which an item moves from list l to l+1 on a hit.
261 * These two match `da::CacheParam` exactly, so `da_cache_isolate` consumes them
262 * without a conversion.
263 *
264 * `hitclass` and `missclass` are the 1-based classes a job of class r switches
265 * into on a hit and on a miss, 0 meaning the transition is disabled.
266 */
267/**
268 * The parameters of a Transition node: MATLAB `sn.nodeparam{ind}` for an SPN
269 * transition, as `refreshPetriNetNodes.m` writes them.
270 *
271 * A transition has MODES, not classes, but its ARCS carry a class: MATLAB's
272 * `enablingConditions{m}` is an (nnodes x nclasses) matrix, so one mode can
273 * require two tokens of Class1 at a place while another requires one of Class2
274 * at the same place. Each mode carries its own enabling and inhibiting
275 * conditions over the input places, its firing effect, and its own firing
276 * process -- which is why State.fromMarginal treats a Transition's state as
277 * per-mode and cannot reuse the per-class station encoding.
278 *
279 * THE CLASS DIMENSION IS NOT DECORATION. Collapsing the arcs onto the place, as
280 * this port did until 2026-08-12, lets a token of one class satisfy another
281 * class's pre-arc -- a DIFFERENT net, not an approximation of this one. Every
282 * consumer therefore reads a (node, class) pair, and the few that are genuinely
283 * class-blind (the MDD level aggregation, the S-invariants) say so by refusing a
284 * multiclass net rather than by summing it.
285 */
286template <class T>
288 std::size_t nmodes = 0;
289 std::vector<std::string> modenames;
290 /** enabling[m](p,r): class-r tokens of place p (0-based node) mode m needs. */
291 std::vector<Matrix<T>> enabling;
292 /** inhibiting[m](p,r): class-r tokens of p that BLOCK mode m (Inf = never). */
293 std::vector<Matrix<T>> inhibiting;
294 /** firing[m](p,r): class-r tokens mode m moves to/from place p when it fires. */
295 std::vector<Matrix<T>> firing;
296
297 /**
298 * The arcs of one mode summed over classes, for a consumer that is class
299 * blind BECAUSE THE NET IS SINGLE CLASS.
300 *
301 * `is_multiclass()` is what makes that safe, and a caller that cannot honour
302 * the class dimension must test it and refuse: the sum of a multiclass net's
303 * arcs describes a net whose tokens are interchangeable, which is not this
304 * one.
305 *
306 * A NON-FINITE ENTRY IS SKIPPED, and a row of nothing but non-finite entries
307 * stays non-finite: on an enabling arc Inf is JMT's "any number of tokens"
308 * and on an inhibiting one it is the ABSENCE of the arc, so adding it in
309 * would turn either into a row that no marking satisfies.
310 */
311 static std::vector<T> arc_total(const std::vector<Matrix<T>>& a, std::size_t m) {
312 std::vector<T> v;
313 if (m >= a.size()) return v;
314 v.assign(a[m].rows(), num_traits<T>::from_int(0));
315 for (std::size_t p = 0; p < a[m].rows(); ++p) {
316 double s = 0.0;
317 bool any = false;
318 for (std::size_t r = 0; r < a[m].cols(); ++r) {
319 const double x = num_traits<T>::to_double(a[m](p, r));
320 if (!std::isfinite(x)) continue;
321 s += x;
322 any = true;
323 }
324 v[p] = any || a[m].cols() == 0
326 : num_traits<T>::from_double(std::numeric_limits<double>::infinity());
327 }
328 return v;
329 }
330
331 /**
332 * The inhibiting THRESHOLD of one mode per place, class blind.
333 *
334 * A threshold is not a count and does not add up: the mode is blocked as
335 * soon as ANY class reaches its own bound, so the class-blind reduction is
336 * the smallest finite threshold, and Inf where no class declares one.
337 */
338 static std::vector<T> inhibit_total(const std::vector<Matrix<T>>& a, std::size_t m) {
339 std::vector<T> v;
340 if (m >= a.size()) return v;
341 const double inf = std::numeric_limits<double>::infinity();
342 v.assign(a[m].rows(), num_traits<T>::from_double(inf));
343 for (std::size_t p = 0; p < a[m].rows(); ++p) {
344 double best = inf;
345 for (std::size_t r = 0; r < a[m].cols(); ++r) {
346 const double x = num_traits<T>::to_double(a[m](p, r));
347 if (std::isfinite(x) && x < best) best = x;
348 }
349 v[p] = num_traits<T>::from_double(best);
350 }
351 return v;
352 }
353
354 /** True when any mode's arcs touch more than one class. */
355 bool is_multiclass() const {
356 const std::vector<Matrix<T>>* all[3] = {&enabling, &inhibiting, &firing};
357 for (int w = 0; w < 3; ++w)
358 for (std::size_t m = 0; m < all[w]->size(); ++m) {
359 std::size_t touched = 0;
360 for (std::size_t r = 0; r < (*all[w])[m].cols(); ++r) {
361 bool any = false;
362 for (std::size_t p = 0; p < (*all[w])[m].rows() && !any; ++p) {
363 const double v = num_traits<T>::to_double((*all[w])[m](p, r));
364 // An inhibiting Inf is the ABSENCE of an arc, not one.
365 any = w == 1 ? (std::isfinite(v) && v > 0.0) : v > 0.0;
366 }
367 if (any) ++touched;
368 }
369 if (touched > 1) return true;
370 }
371 return false;
372 }
373
374 /** The one class every arc of every mode touches, 1-based; 0 when none does. */
375 std::size_t single_class() const {
376 const std::vector<Matrix<T>>* all[3] = {&enabling, &inhibiting, &firing};
377 for (int w = 0; w < 3; ++w)
378 for (std::size_t m = 0; m < all[w]->size(); ++m)
379 for (std::size_t r = 0; r < (*all[w])[m].cols(); ++r)
380 for (std::size_t p = 0; p < (*all[w])[m].rows(); ++p) {
381 const double v = num_traits<T>::to_double((*all[w])[m](p, r));
382 if (w == 1 ? (std::isfinite(v) && v > 0.0) : v > 0.0) return r + 1;
383 }
384 return 0;
385 }
386 std::vector<double> nmodeservers; ///< servers per mode, may be infinite
387 std::vector<double> firingprio; ///< firing priority per mode
388 std::vector<T> fireweight; ///< weight among simultaneously enabled modes
389 std::vector<lang::TimingStrategy> timing; ///< immediate or timed
390 std::vector<lang::Distrib<T>> firingproc; ///< firing distribution per mode
391 std::vector<std::size_t> firingphases; ///< phase count per mode, 0 when non-Markovian
392 /**
393 * Marking-dependent firing-rate multiplier g_m(marking); an empty entry is
394 * the unit multiplier. Transition.setFiringRateDependence.
395 */
396 std::vector<std::function<T(const std::vector<T>&)>> firingdep;
397};
398
399/**
400 * The parameters of a retrial station: MATLAB `sn.retrialProc` and friends.
401 *
402 * A retrial station has NO waiting line. An arrival that finds every server
403 * busy joins an ORBIT and re-attempts at the retrial rate, so its state is an
404 * (in-service, orbit) split rather than an ordered buffer -- State.fromMarginal
405 * enumerates that split, and the freed server is NOT filled on a completion.
406 */
407template <class T>
409 /** retrial_proc[r] is the class-r retrial process; empty = not a retrial class. */
410 std::vector<lang::Distrib<T>> retrial_proc;
411 std::vector<T> retrial_rate; ///< mu_r, the per-class orbit retry rate
412 std::vector<int> max_attempts; ///< 0 = unbounded
413};
414
415/**
416 * Setup and delay-off of a station that powers down when it falls idle.
417 *
418 * MATLAB `Queue.setDelayOff(class, setupTime, delayoffTime)`, which
419 * refreshLocalVars copies into `sn.nodeparam{node}{class}`. The server starts
420 * a delay-off timer when it empties and shuts down when the timer expires; the
421 * next arrival to a shut-down server pays the setup time before service.
422 *
423 * Stored per class because the reference stores it per class, but every
424 * consumer reads ONE pair per station -- `solver_mam_basic.m` takes the LAST
425 * class's, so `last()` below is what a solver should call rather than picking
426 * a class itself.
427 */
428template <class T>
430 std::vector<lang::Distrib<T>> setup; ///< per class, disabled = not declared
431 std::vector<lang::Distrib<T>> delayoff; ///< per class
432 /** The pair the solvers use: the last class that declares one. */
433 bool last(lang::Distrib<T>& su, lang::Distrib<T>& doff) const {
434 for (std::size_t r = setup.size(); r > 0; --r)
435 if (!setup[r - 1].disabled) {
436 su = setup[r - 1];
437 doff = r - 1 < delayoff.size() ? delayoff[r - 1] : lang::Distrib<T>::disabled_dist();
438 return true;
439 }
440 return false;
441 }
442};
443
444/**
445 * Server breakdown and repair of a station whose server fails and is repaired.
446 *
447 * MATLAB `Queue.setBreakdown(failure, repair, downService)`, which
448 * `refreshStruct` spreads over `sn.hasbreakdown` (per NODE),
449 * `sn.breakdownMu` / `sn.repairMu` / `sn.breakdownProc` / `sn.repairProc` (per
450 * STATION) and `sn.downServiceRates` (per station and class). They are one
451 * feature and are kept as one record here, keyed by station, for the same
452 * reason `SetupDelayOffParam` is: presence IS the flag.
453 *
454 * WHAT A BREAKDOWN IS, and what it is not. The server alternates up and down on
455 * independent clocks. A job IN SERVICE when the server goes down is NOT evicted
456 * and does not restart: it holds its residual work across the outage, so the
457 * outage is a pure interruption. `down_service_rates(r)` is a DEGRADED server,
458 * not a stopped one -- a positive rate there means class r keeps being served
459 * while the server is down, more slowly. Zero means no service at all, which is
460 * the ordinary reading of "broken".
461 *
462 * THE DEGRADED SERVICE MUST BE EXPONENTIAL, and the reference refuses anything
463 * else by name: a phase-type degraded service would need its own phase block in
464 * the joint chain, which no codebase builds. Only the RATE is stored for that
465 * reason -- there is no distribution left to carry.
466 */
467template <class T>
469 lang::Distrib<T> failure; ///< time to failure of an up server
470 lang::Distrib<T> repair; ///< time to repair of a down server
471 T failure_rate = num_traits<T>::from_int(0); ///< `sn.breakdownMu`: 1 / mean failure time
472 T repair_rate = num_traits<T>::from_int(0); ///< `sn.repairMu`: 1 / mean repair time
473 /** `sn.downServiceRates(ist, :)`: per class, 0 = no service while down. */
474 std::vector<T> down_service_rates;
475
476 T down_rate_of(std::size_t cls_1based) const {
477 if (cls_1based == 0 || cls_1based > down_service_rates.size())
478 return num_traits<T>::from_int(0);
479 return down_service_rates[cls_1based - 1];
480 }
481};
482
483template <class T>
485 std::size_t nitems = 0;
486 std::vector<int> itemcap;
487 /**
488 * Item read by each per-item class of a cache network (MATLAB
489 * `Cache.setItemReadClasses`, `sn.nodeparam{i}.classitem`), 1-based, 0 where
490 * the class is not one. Stored rather than inferred from a one-hot pread,
491 * which is ambiguous against a genuine single-item popularity.
492 */
493 std::vector<std::size_t> classitem;
494 /**
495 * Per-item storage cost (size) and per-list cap on the total cost of the
496 * resident items (ton21cache Sec. IX). BOTH EMPTY = unconstrained, the
497 * classic model. `costcapglobal` records that the caps came from a single
498 * cache-wide value.
499 */
500 std::vector<int> itemsize;
501 std::vector<int> costcap;
502 bool costcapglobal = false;
503 std::vector<std::vector<T>> pread; ///< (u) x (n), empty row = NaN
504 /**
505 * The popularity LAW each class declared, beside the pmf it expands to.
506 *
507 * Every solver reads `pread`, which is why the pmf is what the reader
508 * materializes. The law is kept because JMT's Cache section takes a
509 * PARAMETRIC popularity -- a Zipf exponent or a uniform range -- and
510 * cannot be given a pmf; without this, exporting a Zipf cache would have to
511 * either guess the exponent back out of the pmf or drop the popularity.
512 * `type` is NONE when the pmf was supplied directly.
513 */
514 struct Popularity {
516 double s = 0.0; ///< Zipf exponent
517 std::size_t n = 0; ///< support size of a Zipf or a DiscreteSampler
518 };
519 std::vector<Popularity> preadkind; ///< per class, parallel to `pread`
521 std::vector<std::size_t> hitclass, missclass;
522 std::vector<std::vector<Matrix<T>>> accost; ///< (u) x (n) of (h+1)x(h+1), or empty
523 /**
524 * Delayed-hit retrieval system (Cache.setRetrievalSystem). Zero capacity =
525 * none. `retrieval_queues[r]` are the 1-based retrieval-station node indices
526 * a read of class r (0-based key) circulates on a miss; `retrieval_classes`
527 * is (nitems x nclasses), item i of read class r -> the per-item retrieval
528 * class (1-based), 0 where none. Built by set_retrieval_system.
529 */
530 /** q-LRU admission probability; 1 admits every miss (plain LRU). */
533 std::map<std::size_t, std::vector<std::size_t>> retrieval_queues; ///< read class(0-based)->nodes
534 std::vector<std::vector<std::size_t>> retrieval_classes; ///< (nitems x nclasses), 1-based
535 /**
536 * The DECLARED initial contents of the cache, as the reference dumps the
537 * node's state row: the per-class job counts, then the list contents, then
538 * the retrieval bitmap. Empty means the cache starts empty, which is what
539 * `initDefault` builds; a warm cache is not derivable from anything else.
540 */
541 std::vector<T> initstate;
542 /**
543 * Truncation level of block B: how many secondary requests may be merged
544 * onto the in-flight fetches of this cache at once. -1 is UNBOUNDED, which
545 * is what a sample path needs and what `State.afterEventCache` calls the
546 * `isSimulation` branch; a non-negative value is the enumeration bound an
547 * exact solver generates its local state space under.
548 */
550};
551
552/**
553 * Port of `State.cacheRetrievalClassMap`: the canonical order of a cache's
554 * retrieval classes, which is the column order of block B.
555 *
556 * Block B keys the merged requests by RETRIEVAL CLASS rather than by item so
557 * that the originating class, hence its hit class, is recoverable when the
558 * fetch completes and the merged requests are released as delayed hits.
559 */
560template <class T>
561void cache_retrieval_class_map(const CacheParam<T>& cp, std::vector<std::size_t>& rc_list,
562 std::vector<std::size_t>& rc_items,
563 std::vector<std::size_t>& rc_orig) {
564 rc_list.clear();
565 rc_items.clear();
566 rc_orig.clear();
567 for (std::size_t k = 0; k < cp.retrieval_classes.size(); ++k)
568 for (std::size_t c = 0; c < cp.retrieval_classes[k].size(); ++c)
569 if (cp.retrieval_classes[k][c] != 0) {
570 rc_list.push_back(cp.retrieval_classes[k][c]);
571 rc_items.push_back(k + 1);
572 rc_orig.push_back(c + 1);
573 }
574 // The reference sorts by the retrieval class index and carries the two
575 // parallel arrays along; a plain index sort reproduces that permutation.
576 std::vector<std::size_t> ord(rc_list.size());
577 for (std::size_t i = 0; i < ord.size(); ++i) ord[i] = i;
578 std::stable_sort(ord.begin(), ord.end(),
579 [&](std::size_t a, std::size_t b) { return rc_list[a] < rc_list[b]; });
580 std::vector<std::size_t> l(rc_list.size()), it(rc_list.size()), oc(rc_list.size());
581 for (std::size_t i = 0; i < ord.size(); ++i) {
582 l[i] = rc_list[ord[i]];
583 it[i] = rc_items[ord[i]];
584 oc[i] = rc_orig[ord[i]];
585 }
586 rc_list.swap(l);
587 rc_items.swap(it);
588 rc_orig.swap(oc);
589}
590
591/** One station of the network. */
592template <class T>
593struct Station {
594 std::string name;
595 NodeType nodetype = NodeType::Queue;
596 SchedStrategy sched = SchedStrategy::FCFS;
597 double nservers = 1.0; ///< may be infinite (a Delay, or an inf-scheduled task)
598 bool attr_ishost = false;
599 std::size_t attr_idx = 0; ///< LQN element this station stands for
600
601 /**
602 * `sn.schedparam`, per class: the DPS / GPS weight, or the SEPT / LEPT rank.
603 * Empty means the discipline takes no parameter; the refresh fills it with
604 * ones for DPS and GPS, which is MATLAB's default weight.
605 */
606 std::vector<T> schedparam;
607 /**
608 * Station capacity in Kendall's K, as `setCapacity` sets it. Infinite means
609 * unbounded; `sn.cap` is derived from it and from the class capacities.
610 */
611 double cap = std::numeric_limits<double>::infinity();
612 /** Per-class buffer from `setChainCapacity`; infinite where unset. */
613 std::vector<double> classcap;
614 /**
615 * Node-level immediate feedback, per class; empty when the station sets
616 * none. Only a Queue carries it in the reference, which is also the only
617 * node the JSON writer emits it for.
618 */
619 std::vector<bool> immfeed;
620 /**
621 * Per-class blocking rule as an INT, with 0 meaning "not set".
622 *
623 * The sentinel is MATLAB's: DropStrategy has no member with value 0, so a
624 * zero entry is what an unset rule looks like there, and the refresh
625 * derives those from the capacity. It matters that the two are
626 * distinguishable, because an EXPLICIT WAITQ for an open class at a finite
627 * buffer is rejected while the derived one is not.
628 */
629 std::vector<int> droprule;
630 /**
631 * `sn.lldscaling` for this station: the multiplier at population 1, 2, ...
632 * Empty when the station is not load dependent.
633 */
634 std::vector<T> lldscaling;
635 /** `sn.cdscaling` for this station: the class-dependence map, empty when unset. */
637 /**
638 * `sn.cdscalingpeak` for this station: the DECLARED peak rate scaling per
639 * class, empty when the station is not class dependent.
640 *
641 * It is not derivable from `cdscaling`: finding max_n beta_r(n) would mean
642 * sweeping the whole population lattice, and the reference does not. It is
643 * what utilization at a class-dependent station is normalized by, so that
644 * U = T*S/peak keeps the T*S/c convention of an ordinary multiserver
645 * station; without it a beta emulating two servers reports twice the true
646 * utilization. MATLAB's `setClassDependence` requires it.
647 */
648 std::vector<T> cdscalingpeak;
649 /**
650 * `sn.jdscaling` for this station: MATLAB's `Station.ljdScaling`, the JOINT
651 * dependence map eta_i(n), empty when unset.
652 *
653 * It has the same signature as `cdscaling` and is folded into it
654 * multiplicatively wherever a rate is scaled, exactly as
655 * `State.afterEventInit` does. It is kept as a SEPARATE field rather than
656 * pre-multiplied into `cdscaling` because the two carry different modelling
657 * claims: a `cdscaling` beta_r(n) keeps the product form (it is the
658 * class-dependent rate lattice `pfqn_cdfun` evaluates), while an eta_i(n)
659 * does not, and `pfqn_mvajd` / `pfqn_ncjd` are selected on that distinction.
660 */
662 /**
663 * `sn.jdscalingpeak` for this station: the declared peak joint-dependent
664 * scaling per class. `setJointDependence` makes it mandatory for the same
665 * reason `setClassDependence` does -- utilization is reported as T*S/peak.
666 */
667 std::vector<T> jdscalingpeak;
668
669 /**
670 * `sn.nodeparam{ind}.svcRateFun` for a PAS / OI station: the TOTAL service
671 * rate as a function of the ordered microstate, a 1-based list of class
672 * indices in queue order.
673 *
674 * A pass-and-swap or order-independent queue is parameterized by mu(c) as a
675 * whole; there is no per-class service distribution, and MATLAB's
676 * `setServiceRateFunction` rejects one. The refresh still derives a
677 * representative per-class rate mu([r]) so the ordinary rate machinery
678 * stays consistent, exactly as `Queue.setServiceRateFunction` does.
679 */
680 std::function<T(const std::vector<std::size_t>&)> svc_rate_fun;
681
682 /**
683 * Polling parameters for a POLLING station, MATLAB's `pollingType`,
684 * `switchoverTime` and `pollingPar` on the Queue.
685 *
686 * `polling_type[r]` is the discipline of class r's buffer (the reference
687 * assumes it is identical across buffers), `switchover[r]` its switchover
688 * distribution, and `polling_par` the K of a K-limited discipline. Empty
689 * `polling_type` means the station is not a polling station.
690 */
691 std::vector<lang::PollingType> polling_type;
692 std::vector<Distrib<T>> switchover;
693 int polling_par = 0;
694 /**
695 * `Queue.setSwitchover(fromClass, toClass, distrib)`: the walk the server
696 * takes when it turns from serving class r to serving class s, MATLAB's
697 * (K x K) `switchoverTime` cell. Empty means none declared at all;
698 * otherwise it is (K x K) and an undeclared pair is DISABLED, so it is not
699 * serialized. MATLAB fills its cell with Immediate and writes all K^2
700 * entries instead; nothing reads either, and the sparse form is the one
701 * that round-trips a document unchanged.
702 *
703 * IT IS CARRIED, NOT CONSUMED, and that is parity rather than an omission.
704 * No solver in any codebase reads a pairwise switchover: the MVA polling
705 * analyzer and the state machinery reach `switchover` above through the
706 * polling buffers, and `writeJSIM` warns and DROPS the pairwise times on an
707 * ordinary Server because JMT's `Server` has no switchover of its own. What
708 * the field buys is that the model can be declared, serialized and read
709 * back unchanged -- which is what `switchover_basic` exercises.
710 */
711 std::vector<std::vector<Distrib<T>>> switchover_pair;
712 /**
713 * `sn.nodeparam{ind}.swapGraph`: which class a departing job promotes the
714 * jobs behind it into. All zero is the ORDER-INDEPENDENT case, where no
715 * swapping happens and the station is product-form; a nonzero entry makes
716 * it a genuine pass-and-swap station, which the OI analyzer refuses.
717 */
719
720 // ---- impatience, balking and heterogeneous servers ---------------------
721 //
722 // These are declared per class on the station, as the reference declares
723 // them, and they are all OPTIONAL: an empty vector means the station
724 // declares none, which is not the same as declaring a disabled one.
725 // `used_lang_features` emits Reneging / Balking / HeteroServers from them,
726 // so a solver that does not implement one refuses the model by name
727 // instead of solving a station without it.
728
729 /**
730 * `Queue.setPatience(class, dist)`: the abandonment timer of a WAITING job,
731 * with `impatience[r]` naming which rule it is. A disabled entry is a class
732 * that declares none.
733 */
734 std::vector<Distrib<T>> patience;
735 std::vector<lang::ImpatienceType> impatience;
736 /**
737 * `Queue.setOrbitImpatience(class, dist)`: abandonment from the RETRIAL
738 * ORBIT, which is a different population from the waiting line above -- a
739 * job that gave up retrying never occupied a buffer slot.
740 */
741 std::vector<Distrib<T>> orbit_impatience;
742 /** `Queue.setBatchRejectProbability`: per-class rejection of a whole batch. */
743 std::vector<T> batch_reject;
744
745 /**
746 * One balking threshold: with `min_jobs <= n <= max_jobs` at the station,
747 * an arriving job of the class refuses to join with `probability`.
748 * `max_jobs = -1` is the wire's spelling of an unbounded upper end.
749 */
751 double min_jobs = 0.0;
752 double max_jobs = -1.0;
754 };
755 /** Per class; `strategy == NONE` is a class that declares no balking. */
760 std::vector<BalkingParam> balking;
761
762 /**
763 * A heterogeneous server pool: `count` servers that serve only
764 * `compatible` classes, each with its own service law.
765 *
766 * The station's own `service` row stays the class-level default and is what
767 * every homogeneous consumer reads; `server_types` is the refinement, and
768 * `hetero_policy` says how the pools are picked among.
769 */
770 struct ServerType {
771 std::string name;
772 double count = 1.0;
773 std::vector<bool> compatible; ///< per class; empty = every class
774 std::vector<Distrib<T>> service; ///< per class
775 };
776 std::vector<ServerType> server_types;
778
779 /**
780 * `Queue.setServerParallelism(class, n)`: the servers a job seizes for the
781 * whole of its service, JMT's job parallelism. Per class, empty or all ones
782 * when every job seizes one server.
783 */
784 std::vector<std::size_t> server_parallelism;
785
786 /**
787 * `Source.setArrivalBatch(class, dist)`: the batch-size law released at
788 * each arrival epoch. It does NOT space the epochs -- the arrival process
789 * in `service` does -- so the two are separate and both are needed.
790 */
791 std::vector<Distrib<T>> arrival_batch;
792 /** `Source.markedClasses`: the 1-based class of each mark of an MMAP arrival. */
793 std::vector<std::size_t> marked_classes;
794 /** `Place.departureDiscipline`, per class. */
795 std::vector<lang::DepartureDiscipline> departure_discipline;
796};
797
798/** One job class of the network. */
799struct JobClass {
800 std::string name;
801 JobClassType type = JobClassType::CLOSED;
802 double population = 0.0; ///< infinite for an open class
803 std::size_t refstat = 1; ///< 1-based reference station
804 /**
805 * Whether passage through the reference station is a COMPLETION.
806 *
807 * TRUE BY DEFAULT, as in `JobClass.m:34`, `JobClass.java:108` and the native
808 * Python `classes.py:37` -- every other codebase constructs a class that
809 * completes, and nothing on the model.json wire carries the flag, so a
810 * `false` default here made the SAME model mean different things in this port
811 * than in the three it is a port of. The visible consequence was that every
812 * response-time law refused: `solver_ctmc_cdf_respt` needs one completing
813 * class in the tagged chain to have an event to end the passage at, so
814 * `getCdfRespT` on an ordinary two-station model reported that no class
815 * completes. The paths that need a NON-completing class -- an auxiliary
816 * fork-join sibling, an LN pseudo-class -- set it to false explicitly, and
817 * did so already.
818 */
819 bool completes = true;
820 bool is_ref_class = false; ///< marks the chain's reference class
821 int attr_kind = -1; ///< LayeredNetworkElement of the element it stands for
822 std::size_t attr_idx = 0; ///< index of that element
823 int prio = 0;
824 /**
825 * Class-level immediate feedback, ORed with the station's own setting into
826 * `sn.immfeed`. It is the class-wide spelling of the same property, and the
827 * JSON wire carries it as a bare `"immediateFeedback": true` on the class
828 * where the node-level form is a per-class map on the node.
829 */
830 bool immfeed = false;
831 /**
832 * `sn.classdeadline(r)`: the soft deadline EDD and EDF order by, and the
833 * tardiness JMT reports. Infinite where the class declares none, which is
834 * `NetworkStruct.m:17`'s "Inf = no deadline" sentinel.
835 */
836 double deadline = std::numeric_limits<double>::infinity();
837 /**
838 * `sn.classspawn(r)`: the 1-based class injected at the SAME station on
839 * every completion of this class, 0 where none. MATLAB stores -1 for none;
840 * the 0 here is this port's usual absent-index sentinel.
841 */
842 std::size_t spawn = 0;
843 /**
844 * A `SelfLoopingClass`: a closed class that perpetually cycles at its
845 * reference station. It carries no state beyond `ClosedClass`, so it is
846 * built as one; the marker exists so the writer does not silently downgrade
847 * the wire type to `Closed` and so `getUsedLangFeatures` can name it.
848 */
849 bool self_looping = false;
850};
851
852/**
853 * CTMC bookkeeping of a heterogeneous-server station, written by `ctmc::ctmc_pools`
854 * on the solver's private copy of the struct (MATLAB `sn.nodeparam{ind}.ctmcpool`).
855 *
856 * The class-r service law there is a block-diagonal phase-type law with one block
857 * per compatible pool, in ascending pool order, so the phase of a job in service
858 * names its pool. All indices below are 1-based pools and classes, as in MATLAB.
859 */
860template <class T>
861struct CtmcPool {
862 std::size_t ntypes = 0;
863 std::vector<double> count; ///< servers per pool
864 std::vector<std::vector<bool>> compat; ///< compat[t-1][r-1]
866 std::vector<std::vector<std::size_t>> pools; ///< pools[r-1]: compatible pools of r, ascending
867 std::vector<std::vector<std::size_t>> off, len; ///< offset and length of block k of class r
868 std::vector<std::vector<std::vector<T>>> alpha; ///< entry vector of block k of class r
869 std::vector<std::vector<std::vector<T>>> exit; ///< exit rates of block k of class r
870 std::vector<std::vector<Matrix<T>>> D0; ///< D0 of block k of class r
871 std::vector<std::vector<double>> fsfrate; ///< fsfrate[t-1][r-1] = 1/mean (FSF)
872 std::vector<std::size_t> alfsorder; ///< pools by ascending class count (ALFS)
873 bool rotate = false; ///< ALIS/FAIRNESS order is part of the state
874 std::vector<std::vector<std::size_t>> perms; ///< every pool order, identity first
875 /** 1-based index of `order` in `perms`, 0 when absent. */
876 std::size_t perm_index(const std::vector<std::size_t>& order) const {
877 for (std::size_t p = 0; p < perms.size(); ++p)
878 if (perms[p] == order) return p + 1;
879 return 0;
880 }
881};
882
883/**
884 * CTMC pair form of a MAP service at an FCFS station, written by `ctmc::ctmc_mapc` on the
885 * solver's private copy of the struct (MATLAB `sn.ctmcmapc{ist,r}`). JMT and both LDES
886 * engines chain ONE sampler per (station, class) in service-start order, so with
887 * V = (-D0)^-1 D1 a busy server is a pair (i,j): current phase i, landing phase j drawn at
888 * the start. The class memory variable holds the 1-based landing of the most recently
889 * STARTED draw; a start from h enters pair (h,j) w.p. V(h,j) and sets it to j.
890 *
891 * A SINGLE server keeps the original law instead (`single`, no pairs), as the reference
892 * does: the memory is the phase the last completion left the MAP in, a start enters it,
893 * and while the class is in service the memory tracks the phase of that one job.
894 */
895template <class T>
896struct CtmcMapc {
897 std::size_t p = 0; ///< phases of the original MAP
898 bool single = false; ///< c = 1: carried phase, no pairs
899 std::vector<std::pair<std::size_t, std::size_t>> pairs; ///< 0-based (i,j), i-major, V(i,j) > 0
900 Matrix<T> V; ///< landing law (-D0)^-1 D1
901 std::vector<T> done; ///< completion rate D1(i,j)/V(i,j)
902};
903
904/**
905 * A network plus its refreshed NetworkStruct.
906 *
907 * The two halves are one object because every caller that mutates the model
908 * (service processes, class populations, routing probabilities) re-derives the
909 * struct, exactly as MATLAB's Network does through its cached `sn`. The refresh
910 * entry points mirror the MATLAB ones and have the same granularity, which
911 * matters for cost: `refresh_rates` is called on every outer iteration of
912 * SolverLN for every layer, `refresh_chains` only where the routing changed.
913 */
914template <class T>
916public:
917 std::string name;
918 /**
919 * `Network.setLogPath` / `getLogPath`: the directory every Logger writes
920 * into, and the `logPath` attribute of an exported JMT model.
921 *
922 * Model-level rather than per-Logger because that is where the reference
923 * keeps it -- `Logger`'s constructor REFUSES when it is unset -- and
924 * because the JSIM header carries one such path for the whole model.
925 */
926 std::string log_path;
927 std::vector<NodeDef> nodes; ///< every node, in creation order
928 std::vector<Station<T>> stations; ///< stations[k-1] is the k-th station
929 std::vector<std::size_t> station_to_node; ///< (nstations) 1-based node index
930 std::vector<std::size_t> stateful_nodes; ///< 1-based node indices, ascending
931 std::vector<JobClass> classes;
932 /** service[i][r], 0-based station and class; a disabled entry marks a pair never visited. */
933 std::vector<std::vector<Distrib<T>>> service;
934
935 /**
936 * Does (station i, class r) have a service law an analyzer may convert?
937 * 0-based, and the ONLY correct precondition for `dist_to_map(service[i][r])`.
938 *
939 * `disabled` alone is not enough, and the gap is not hypothetical. It is the
940 * twin of MATLAB's NaN-in-`sn.rates` sentinel, so it answers "does the class
941 * visit this station". A JOIN is visited -- `refreshRates` gives it
942 * `rates = Inf`, `scv = 0` -- and yet it has NO service law:
943 * `refreshProcessRepresentations` hands it a `Coxian(NaN,NaN)`, which every
944 * MATLAB analyzer then skips through a SECOND and separate `any(isnan(D0))`
945 * guard. Rational has no NaN, so the C++ twin of that Coxian is a DISABLED
946 * `Distrib`, and a guard that tests only `disabled[i][r] || rates <= 0` sails
947 * past a Join (Inf > 0) straight into `dist_to_map`, which throws
948 * "the distribution is disabled" with no station named. `fj_basic_open`
949 * failed exactly this way under `-s mam`.
950 *
951 * So: `disabled[i][r]` is about the VISIT, `service[i][r].disabled` is about
952 * the LAW, and a station can be visited without having one.
953 */
954 bool has_service_law(std::size_t i, std::size_t r) const {
955 return !disabled[i][r] && !service[i][r].disabled;
956 }
957
958 /**
959 * Is station `i` (0-based) a QUEUEING PLACE, i.e. a Place with an embedded
960 * queue? `Place.isQueueing()` in MATLAB.
961 *
962 * KEYED ON THE SERVICE LAW, not on the discipline, because that is the
963 * reference's own rule: `Place.queueing` is raised by `setService` and by
964 * nothing else, so a place constructed with a discipline and never given a
965 * law is an ordinary place. The reverse cannot happen -- an ordinary place
966 * carries a `ServiceTunnel` and no law -- so the two tests agree wherever
967 * both are defined, and only this one survives the JSON round trip, where a
968 * `queueing` flag would have to be carried separately and kept in step.
969 */
970 bool is_queueing_place(std::size_t i) const {
971 if (i >= stations.size() || stations[i].nodetype != NodeType::Place) return false;
972 for (std::size_t r = 0; r < service[i].size(); ++r)
973 if (!service[i][r].disabled) return true;
974 return false;
975 }
976 /** P[(r,s)] is an (nnodes x nnodes) block; absent means all zero. */
977 std::map<std::pair<std::size_t, std::size_t>, Matrix<T>> P;
978 /**
979 * The routing after refresh_routing() has expanded the non-PROB strategies
980 * and folded the class switches in. EMPTY when the expansion is the
981 * identity, which is the case for every model whose routing is given as
982 * probabilities and has no ClassSwitch node -- and then route_eff() reads P
983 * directly, so the two representations never drift apart.
984 */
985 std::map<std::pair<std::size_t, std::size_t>, Matrix<T>> Peff;
986 /**
987 * Krzesinski (1987) product-form state-dependent routing, in 0-based
988 * STATION indices; empty unless a node declares it with
989 * `set_state_dep_routing`. Branch index 1 denotes the complement M-V and is
990 * unused. `sdr_nodes` is the same structure in 0-based NODE indices.
991 *
992 * The routing is state dependent yet keeps a product form of its own, so
993 * `sn_has_sd_routing` is true for it while the normalizing-constant solver
994 * still accepts it. See _kb/16-state-dependent-routing.md
995 */
997 /** Node-indexed twin of `sdr`. */
999 /** fj(f,j): the Join node j that closes the Fork node f, 1-based. */
1000 std::vector<std::pair<std::size_t, std::size_t>> fj;
1001 /**
1002 * `sn.isfjaugmented`: this struct came out of `fj_tag`, so its Fork nodes are
1003 * STATEFUL and its Join nodes carry a per-class sibling count instead of the
1004 * ordinary buffer/server split.
1005 *
1006 * It is a flag and not an inference from `fj` being non-empty, because the
1007 * un-augmented struct of the SAME model also has `fj` populated: what
1008 * distinguishes them is the auxiliary class block, and the event layer must
1009 * not take the Join branch before it exists.
1010 */
1011 bool isfjaugmented = false;
1012 /**
1013 * `sn.fjclassmap`: the ORIGINAL class of each auxiliary sibling class,
1014 * 0 for an original class. Empty unless `isfjaugmented`.
1015 */
1016 std::vector<std::size_t> fjclassmap;
1017 /**
1018 * `sn.fjauxclass`: (nclasses+1, 1-based) true where the class is an MMT
1019 * AUXILIARY OPEN class, i.e. one `fj_mmt` added to carry parallelism at rate
1020 * (fanout-1)*forkLambda. Such a class has infinite njobs but its arrivals come
1021 * from the finite closed population, so a finite-population correction still
1022 * applies to it. Empty on every struct the user's own model produces, and on
1023 * the H-T arm, whose auxiliary classes are CLOSED and need no such marker.
1024 * Distinct from `fjclassmap`/`isfjaugmented`, which describe the CTMC tag
1025 * augmentation.
1026 */
1027 std::vector<bool> fjauxclass;
1028 /**
1029 * `sn.nodeparam{j}.fj` for each Join node: the tag matrix and the required
1030 * sibling multiplicity `after_event_join` fires on. Declared as an opaque
1031 * map here and defined in `fj_tag.h`, which owns its layout.
1032 */
1033 std::map<std::size_t, FjJoinParam> fjjoinparam;
1034
1035 // ---- the Source/Sink pair, when the model has open classes -------------
1036 std::size_t sourceIdx = 0; ///< 1-based station index of the Source, 0 = none
1037 std::size_t sinkNode = 0; ///< 1-based NODE index of the Sink, 0 = none (it is not a station)
1038
1039 /** Cache parameters by 1-based NODE index; only Cache nodes have an entry. */
1040 std::map<std::size_t, CacheParam<T>> nodeparam;
1041 /**
1042 * Heterogeneous server pools rewritten for the CTMC, by 1-based NODE index.
1043 * Empty except on the private copy `ctmc::ctmc_pools` returns; the rotating
1044 * ALIS/FAIRNESS pool order, when present, is the single local variable
1045 * (`nvars` column 2R+1) and holds a 1-based index into `perms`.
1046 */
1047 std::map<std::size_t, CtmcPool<T>> ctmcpool;
1048 /**
1049 * MAP services in CTMC pair form, keyed by (1-based station, 1-based class). Empty except
1050 * on the private copy `ctmc::ctmc_mapc` returns; see `CtmcMapc`.
1051 */
1052 std::map<std::pair<std::size_t, std::size_t>, CtmcMapc<T>> ctmcmapc;
1053
1054 /**
1055 * The DECLARED initial state of a stateful node, by 1-based node index.
1056 *
1057 * `initmarking` is a Place's token count per class, which is the initial
1058 * marking of an SPN and is not derivable from anything else -- an SPN with
1059 * no tokens anywhere is a dead net, so dropping it changes the answer to
1060 * "nothing ever fires". `stateprior` and `statespace` are the pair MATLAB
1061 * writes together (`StatefulNode.statePrior` over `StatefulNode.space`):
1062 * the prior is a distribution over the ROWS of that space, so neither is
1063 * meaningful without the other and the reader refuses a lone one.
1064 */
1065 std::map<std::size_t, std::vector<T>> initmarking;
1066 std::map<std::size_t, std::vector<T>> stateprior;
1067 std::map<std::size_t, Matrix<T>> statespace;
1068
1069 /**
1070 * The DECLARED join rule of a Join node, by 1-based node index.
1071 *
1072 * STD waits for every sibling; PARTIAL fires on `quorum` of them. The
1073 * quorum is what `FjJoinParam::required` holds once the fork-join tagging
1074 * has run, but that runs on the augmented struct: this is the declaration
1075 * as the model.json carries it, so the two do not replace each other.
1076 */
1077 struct JoinDecl {
1079 double quorum = 0.0; ///< 0 = every sibling
1080 };
1081 std::map<std::size_t, JoinDecl> joindecl;
1082
1083 /** Variable forking levels, by 1-based Fork node; absent on a plain fork. */
1084 std::map<std::size_t, ForkParam<T> > forkparam;
1085
1086 /** The fork's override block, or null when it declares none. */
1087 const ForkParam<T>* fork_param_of(std::size_t node) const {
1088 const typename std::map<std::size_t, ForkParam<T> >::const_iterator it =
1089 forkparam.find(node);
1090 return it == forkparam.end() ? static_cast<const ForkParam<T>*>(0) : &it->second;
1091 }
1092
1093 /**
1094 * `sn.reward`: the user-declared reward functions, MATLAB's
1095 * `model.setReward(name, fn)`.
1096 *
1097 * `fn` is evaluated on the AGGREGATE state row -- the per-(station, class)
1098 * job counts in `(ist-1)*K + k` order, which is what
1099 * `ctmc_state_space_aggr` builds -- and not on the detailed state. That is
1100 * the reference's `RewardState` contract, and it is what makes a reward
1101 * portable across disciplines: the caller writes `state[q]` for a queue
1102 * length without having to know whether the buffer stores class tags or
1103 * per-class counts.
1104 */
1105 struct Reward {
1106 std::string name;
1107 std::function<T(const std::vector<T>&)> fn;
1108 /**
1109 * The DECLARATIVE form the reward was built from, when it was: the
1110 * template name (QLen, Util, Blocking), the 1-based node it is declared
1111 * at, and the 1-based class it covers (0 = every class).
1112 *
1113 * A lambda cannot be written back out, so a reward built from one has
1114 * an empty `kind` and the writer omits it -- which is what
1115 * `linemodel_save` does, with a warning, rather than emitting a
1116 * definition that would be wrong on reload.
1117 */
1118 std::string kind;
1119 std::size_t node = 0;
1120 std::size_t cls = 0;
1121 };
1122 std::vector<Reward> reward;
1123
1124 /**
1125 * FINITE CAPACITY REGIONS, MATLAB's `refreshRegions` output.
1126 *
1127 * A region caps the jobs (and the memory) held ACROSS a set of stations,
1128 * which no per-station capacity can express: three stations each able to
1129 * hold 5 jobs but at most 6 between them is a region, not three caps.
1130 *
1131 * `cap(i,r)` is the class-r bound at station i, `cap(i,K)` the station's
1132 * global bound, and -1 means unbounded -- the reference's sentinel, kept
1133 * rather than translated to infinity because it is compared with `~= -1`
1134 * to test MEMBERSHIP as well as boundedness.
1135 *
1136 * `rule(r)` decides what an arrival that would violate the region does:
1137 * DROP loses it, WAITQ parks it outside every station's own queue, which is
1138 * why the CTMC needs a separate waiting-room state per region and not just
1139 * a filter on the enumerated space.
1140 */
1141 struct Region {
1142 /** The region's declared name, as the wire carries it; a generated one otherwise. */
1143 std::string name;
1144 std::vector<std::vector<double>> cap; ///< (nstations x nclasses+1), -1 = unbounded
1145 std::vector<double> maxmem; ///< per member station, -1 = unbounded
1146 std::vector<bool> members; ///< membership, independent of the caps
1147 std::vector<DropStrategy> rule; ///< per class
1148 std::vector<T> weight, size; ///< per class; size is the memory footprint
1149 Matrix<T> lincon_A; ///< optional linear constraint A n <= b
1150 std::vector<T> lincon_b;
1151 };
1152 std::vector<Region> regions;
1153 /** Transition (SPN) parameters, keyed by 1-based node index. */
1154 std::map<std::size_t, TransitionParam<T>> transparam;
1155 /** Retrial parameters, keyed by 1-based STATION index. */
1156 std::map<std::size_t, RetrialParam<T>> retrialparam;
1157 /**
1158 * Setup / delay-off, keyed by 1-based STATION index.
1159 *
1160 * Presence IS MATLAB's `sn.hassetup(ist)`: the reference sets that flag
1161 * from `~isempty(station.setupTime)`, so a station appears here exactly
1162 * when it is a setup task's server.
1163 */
1164 std::map<std::size_t, SetupDelayOffParam<T>> setupparam;
1165 /**
1166 * Server breakdown / repair, keyed by 1-based STATION index.
1167 *
1168 * Presence IS MATLAB's `sn.hasbreakdown(node)`, which the reference sets
1169 * from `~isempty(node.breakdownFailure) && ~isempty(node.breakdownRepair)`
1170 * -- BOTH, since a server that fails and is never repaired is a different
1171 * model and the reference declines to infer one.
1172 */
1173 std::map<std::size_t, BreakdownParam<T>> breakdownparam;
1174
1175 /** `sn.hasbreakdown(ind)`: does the NODE's server break down? */
1176 bool has_breakdown_node(std::size_t ind) const {
1177 if (ind == 0 || ind > nodes.size()) return false;
1178 const std::size_t ist = nodes[ind - 1].station;
1179 return ist != 0 && breakdownparam.find(ist) != breakdownparam.end();
1180 }
1181 /** Any station at all, the guard every solver gate needs first. */
1182 bool has_breakdown() const { return !breakdownparam.empty(); }
1183 /**
1184 * The class-switch matrix of a ClassSwitch node, by 1-based NODE index.
1185 *
1186 * (nclasses x nclasses), row-stochastic. It is applied on the way OUT of
1187 * the node, exactly as MATLAB's ClassSwitch does, and the node is not
1188 * stateful, so the stochastic complement folds it into the edges around it.
1189 */
1190 std::map<std::size_t, Matrix<T>> csmatrix;
1191
1192 // ---- refreshed struct -------------------------------------------------
1193 std::size_t nstations = 0, nclasses = 0, nchains = 0;
1194 /**
1195 * `sn.gdscaling`: the network-level globally state-dependent (Whittle)
1196 * rate scaling phi(n). Its argument is the FULL (nstations x nclasses)
1197 * population matrix in row-major order, NOT one station's slice, so unlike
1198 * `Station::jdscaling` it lives on the struct rather than on a station.
1199 * Empty when the model declares none. See `set_global_dependence`.
1200 */
1202 /**
1203 * `sn.gdscalingpeak`: the declared (nstations x nclasses) peak of
1204 * `gdscaling`, row-major, used to report Util = T*S/peak.
1205 */
1206 std::vector<T> gdscalingpeak;
1207 /**
1208 * `sn.gdscalingcutoff`: the per-slot OPEN-class truncation used when
1209 * `gdscaling` is materialized onto the JSON wire (closed classes are
1210 * tabulated up to their own population). Solving ignores it entirely.
1211 */
1213 /**
1214 * (nstations x nclasses) service rates and SCVs, with a PARALLEL disabled
1215 * flag instead of MATLAB's NaN sentinel.
1216 *
1217 * MATLAB writes NaN into sn.rates for a (station, class) pair the class
1218 * never visits, and every consumer tests isnan. Rational has no NaN -- it
1219 * is a field, not a floating-point format -- so the marker has to be
1220 * carried out of band or the exact instantiation could not represent a
1221 * disabled pair at all. The flag is the marker; rates and scv hold zero
1222 * there, and no consumer may read them without consulting `disabled`.
1223 */
1225 std::vector<std::vector<bool>> disabled;
1226
1227 /**
1228 * `sn.immfeed`: (nstations x nclasses) IMMEDIATE FEEDBACK, the reference's
1229 * `refreshStruct` field.
1230 *
1231 * A job of class r completing at station i is fed straight back into
1232 * service, HOLDING THE SERVER, instead of being routed out and re-queued.
1233 * The flag is the OR of the station's own setting (Queue only) and the
1234 * class-level one, exactly as MATLAB `@@MNetwork/refreshStruct.m` computes
1235 * it, so either spelling reaches the same matrix.
1236 *
1237 * IT IS NOT A `Feature`, deliberately. `feature_name` is byte-for-byte the
1238 * MATLAB registry field name, and the reference has no such entry: MVA and
1239 * NC WARN that they approximate it as class-switching with re-queueing,
1240 * while CTMC and SSA implement the sample-path semantics. Making it a
1241 * feature would invent a name no other codebase carries and would turn the
1242 * reference's warning into a refusal.
1243 */
1244 std::vector<std::vector<bool>> immfeed;
1245 std::vector<std::vector<bool>> chains; ///< (nchains x nclasses)
1246 std::vector<std::vector<std::size_t>> inchain;///< 1-based class indices per chain
1247 std::vector<std::size_t> refclass; ///< (nchains) 1-based class, 0 = none
1248 std::vector<Matrix<T>> visits; ///< (nchains) each (nstateful x nclasses)
1249 std::vector<Matrix<T>> nodevisits; ///< (nchains) each (nnodes x nclasses)
1250
1251 /**
1252 * `sn.cap` and `sn.classcap`: the total and per-class buffers.
1253 *
1254 * Derived by refresh_capacity() from the station capacities and the chain
1255 * populations, exactly as MATLAB's refreshCapacity does, so a station with
1256 * no explicit capacity still carries the population bound of the chains
1257 * that reach it.
1258 */
1259 std::vector<double> cap;
1260 std::vector<std::vector<double>> classcap;
1261 std::vector<std::vector<DropStrategy>> droprule;
1262
1263 /**
1264 * `sn.rt` and `sn.rtnodes`: the class-expanded routing.
1265 *
1266 * rt is (nstateful*nclasses) square over the STATEFUL nodes -- the
1267 * stochastic complement that removes Fork, ClassSwitch and Router nodes --
1268 * and rtnodes is (nnodes*nclasses) square over every node. Row (i-1)*K + r
1269 * is node i in class r, which is MATLAB's ordering.
1270 */
1272
1273 /**
1274 * `sn.nvars`, (nnodes x 3R+1): the LOCAL VARIABLE columns each node appends
1275 * to its state, beyond the buffer and the servers. Layout, verbatim from
1276 * refreshLocalVars:
1277 *
1278 * 1 .. R modulating phase, one per class with a MAP/MMPP2 process
1279 * R+1 .. 2R routing variable, one per class routed round-robin
1280 * 2R+1 the SHARED node block: cache width, or the BAS blocked
1281 * marker, or the breakdown status, or the polling
1282 * controller. They share one column and are therefore
1283 * mutually exclusive -- refreshLocalVars rejects the
1284 * combinations rather than widening the state.
1285 * 2R+1+r the REPLY blocked-server counter for calling class r
1286 *
1287 * The trailing sum(nvars(ind,:)) columns are what every state slicer must
1288 * take clear of before reading the server block.
1289 */
1290 std::vector<std::vector<std::size_t>> nvars;
1291
1292 /**
1293 * `sn.isbasblocking`, per NODE: true where the node is the BLOCKING
1294 * (upstream) side of a true-BAS relation and therefore carries the blocked
1295 * marker in `nvars` column 2R+1.
1296 *
1297 * TRUE BAS is not a drop rule, it is a HELD JOB. Under blocking after
1298 * service the job that finished at this station cannot leave because its
1299 * destination is full; it stays at the server, occupying it, until room
1300 * frees. That needs a state bit -- "the front job here is completed and
1301 * waiting" -- which no queue length can express, and it is what separates
1302 * BAS from DROP (where the arrival is lost) and from WAITQ (where the
1303 * arrival waits at the region gate instead).
1304 *
1305 * IT IS KEYED ON THE NODE, NOT ON THE DROP RULE, and the distinction is
1306 * load-bearing. BAS may be DECLARED on either side: upstream, on the station
1307 * that will hold the job, or on the full destination (the JMT/LDES
1308 * convention). Both resolve to the same blocking station, because the held
1309 * job sits upstream either way, so keying enumeration on the station's own
1310 * `droprule` misses every destination-declared model.
1311 */
1312 std::vector<bool> isbasblocking;
1313 /**
1314 * `sn.isbasdestination`, (nstations x nclasses): true where a refusal at
1315 * this station must BLOCK an upstream BAS station rather than drop the job.
1316 *
1317 * `arrival_is_lost` needs it because it sees only the station where the
1318 * refusal happens. Under the upstream declaration form that station carries
1319 * no BAS rule of its own, so without this mask an open class refused there
1320 * would be declared lost, the become-blocked edge would never fire, and the
1321 * blocking station would behave as if its destination were unbounded.
1322 */
1323 std::vector<std::vector<bool>> isbasdestination;
1324
1325 /**
1326 * The G-network signal declaration, per CLASS. `issignal` is the gate: the
1327 * remaining vectors are only read where it is true.
1328 *
1329 * `signaltarget` is the 1-based class a NEGATIVE signal may remove, or 0
1330 * for the classic untargeted Gelenbe customer, which is eligible against
1331 * every non-signal class. `signalremdist` is the batch-size pmf indexed by
1332 * batch size 0,1,2,...; an empty entry means "remove exactly one".
1333 */
1334 /**
1335 * The pass-and-swap / order-independent parameters of a PAS station, keyed
1336 * by station index (Dorsman and Gardner 2024, Sect. 2).
1337 *
1338 * `svc_rate_fun` is the total service rate mu(c) of an ordered list of
1339 * class indices; the rate of the token at position p is the INCREMENT
1340 * mu(c1..cp) - mu(c1..c_{p-1}), which is what makes the station
1341 * order-independent. `swap_graph` is the (R x R) adjacency saying which
1342 * class may take another's place. An OI station is the special case of an
1343 * empty swap graph.
1344 */
1345 struct PasParam {
1346 std::function<T(const std::vector<std::size_t>&)> svc_rate_fun;
1347 std::vector<std::vector<bool>> swap_graph;
1348 };
1349 std::map<std::size_t, PasParam> pasparam;
1350
1351 /**
1352 * `sn.replyblock` (nnodes x nclasses) and `sn.syncreply` (nclasses).
1353 *
1354 * A SYNCHRONOUS call: a job of a calling class leaves this station for the
1355 * callee but KEEPS its server, released only when the matching REPLY class
1356 * arrives back. `replyblock` marks the (node, calling class) pairs that
1357 * hold such a server, and `syncreply[r]` is the 1-based reply class the
1358 * calling class r expects (0 = none). A CTMC has no job identity to key on
1359 * as LDES does, so the state carries COUNTS of held servers.
1360 */
1361 std::vector<std::vector<bool>> replyblock;
1362 std::vector<std::size_t> syncreply;
1363
1364 /**
1365 * The polling controller of a POLLING station, keyed by station index.
1366 *
1367 * `switchover[r]` is the walk into buffer r, taken from the buffer the
1368 * server LEAVES to get there. An Immediate switchover is NOT represented as
1369 * a state: taking its ~1e8 rate literally would make the generator stiff
1370 * and add a spurious state per buffer, so `pollingNext` folds it into the
1371 * enclosing transition instead.
1372 */
1375 std::size_t pk = 1; ///< the K of K-LIMITED
1376 std::vector<lang::Distrib<T>> switchover;
1377 };
1378 std::map<std::size_t, PollingParam> pollingparam;
1379
1380 /**
1381 * The polling controller of station `ist`, from whichever API declared it.
1382 *
1383 * TWO APIS WRITE THE SAME CONTROLLER. `set_polling` fills `pollingparam`;
1384 * `Queue.setPollingType` / `Queue.setSwitchover` -- the MATLAB-faithful pair
1385 * the JSON reader also uses -- fill `Station::polling_type`, `switchover`
1386 * and `polling_par`. Reading only the first left a station built the second
1387 * way with NO controller at all, which is not a degraded model but an
1388 * unrepresentable one: the state handlers then index an empty `polled`.
1389 *
1390 * A POLLING station that declares neither still HAS a controller, exactly as
1391 * `State.pollingInfo` builds one: EXHAUSTIVE service, every switchover
1392 * immediate. That is a discipline, not an absence.
1393 */
1394 PollingParam effective_polling(std::size_t ist) const {
1395 PollingParam pp;
1396 if (ist == 0 || ist > stations.size()) return pp;
1397 const typename std::map<std::size_t, PollingParam>::const_iterator it =
1398 pollingparam.find(ist);
1399 if (it != pollingparam.end()) return it->second;
1400 const Station<T>& st = stations[ist - 1];
1401 if (!st.polling_type.empty()) {
1402 pp.ptype = st.polling_type[0];
1403 if (pp.ptype == lang::PollingType::KLIMITED && st.polling_par >= 1)
1404 pp.pk = static_cast<std::size_t>(st.polling_par);
1405 }
1406 pp.switchover = st.switchover;
1407 return pp;
1408 }
1409
1410 std::vector<bool> issignal;
1411 std::vector<lang::SignalType> signaltype;
1412 std::vector<std::size_t> signaltarget;
1413 std::vector<lang::RemovalPolicy> signalrempolicy;
1414 std::vector<std::vector<T>> signalremdist;
1415
1416 /** Total local-variable width of node `ind` (1-based). */
1417 std::size_t nvars_of(std::size_t ind) const {
1418 // A node index is 1-BASED, so 0 means "no node" and must return 0. The
1419 // old guard `nvars.size() < ind` is vacuously false at ind == 0 because
1420 // both sides are unsigned, and the loop below then indexed nvars[-1]:
1421 // heap corruption, surfacing as a SIGABRT far from here.
1422 if (ind == 0 || ind > nvars.size()) return 0;
1423 std::size_t w = 0;
1424 for (std::size_t j = 0; j < nvars[ind - 1].size(); ++j) w += nvars[ind - 1][j];
1425 return w;
1426 }
1427
1428 std::size_t nof_stations() const { return stations.size(); }
1429 std::size_t nof_classes() const { return classes.size(); }
1430 std::size_t nof_nodes() const { return nodes.size(); }
1431 std::size_t nof_stateful() const { return stateful_nodes.size(); }
1432
1433 /** `sn.njobs`: the population of each class, infinite for an open one. */
1434 std::vector<double> njobs() const {
1435 std::vector<double> v;
1436 v.reserve(classes.size());
1437 for (const JobClass& c : classes) v.push_back(c.population);
1438 return v;
1439 }
1440
1441 /** `sn.nclosedjobs`: the total population of the closed classes. */
1442 double nclosedjobs() const { return total_jobs(); }
1443
1444 /** `sn.procid(i,r)`: the process type of a (station, class) pair. */
1445 ProcessType procid(std::size_t ist, std::size_t r) const {
1446 return service[ist - 1][r - 1].type;
1447 }
1448
1449 /**
1450 * `sn.phases(i,r)`: the order of the process representation.
1451 *
1452 * A PLACE HOLDS TOKENS IN ONE PHASE, and that is not cosmetic. MATLAB's
1453 * `refreshProcessRepresentations` decides the count from what `ph{ist}{r}`
1454 * turned out to be, and it has TWO ways of having no service:
1455 * - `isempty(ph{ist}{r})` -> `phases = 1`, its "fluid fails otherwise" arm
1456 * - a process that is all NaN -> `phases = 0`
1457 * A non-queueing Place carries a `ServiceTunnel`, so its entry is EMPTY and
1458 * it gets 1; a Join is handed an explicit `Coxian(NaN,NaN)` and gets 0.
1459 * C++ spells both "no service" as a disabled `Distrib`, whose `phases()` is
1460 * 0, so the Place silently took the Join's answer.
1461 *
1462 * The cost was total: `from_marginal_node_first` can build no row for a node
1463 * with 0 phases, so `default_init_state` failed on EVERY Place and every
1464 * closed SPN was refused with "the model's initial marking admits no state"
1465 * -- spn_inhibiting, spn_basic_closed and spn_fourmodes alike, under a
1466 * message about Place populations that were in fact correct.
1467 *
1468 * A QUEUEING Place is untouched: it has a real server and a real law, so it
1469 * is not disabled and answers from its representation as before.
1470 */
1471 std::size_t phases_of(std::size_t ist, std::size_t r) const {
1472 const Distrib<T>& d = service[ist - 1][r - 1];
1473 if (d.disabled && stations[ist - 1].nodetype == NodeType::Place) return 1;
1474 return d.phases();
1475 }
1476 /**
1477 * `sn.phasessz(i,r) = max(sn.phases(i,r),1)`: THE WIDTH of class r's phase
1478 * block in a state row, as opposed to `phases_of`, which is its CONTENT.
1479 *
1480 * The two differ exactly where a class is disabled at a station, and the
1481 * reference keeps the column anyway: a disabled process is stored as a
1482 * `1 x 1 NaN`, never as an empty, so `length(sn.proc{ist}{r}{1})` is 1 and
1483 * `State.fromMarginal` emits one always-zero column for it. A Source
1484 * serving one of six classes therefore writes `[Inf 1 0 0 0 0 0]`, and this
1485 * port used to write `[Inf 1]`.
1486 *
1487 * The narrow row is internally consistent, so it is invisible until it
1488 * CROSSES A BRIDGE: a `model.json` exported by MATLAB carries the wide row,
1489 * and decoding it against a narrow layout put the arrival one-hot in
1490 * another class's column. `solver_ssa_serial` then walked into a state with
1491 * no enabled transition and reported a deadlock rather than a refusal.
1492 *
1493 * Use this wherever a WIDTH or an OFFSET is computed -- `row_layout`,
1494 * `to_marginal`, every `from_marginal*` builder -- and `phases_of` wherever
1495 * the question is whether the class has a process at all, or how many real
1496 * phases it has to iterate over.
1497 */
1498 std::size_t phasessz_of(std::size_t ist, std::size_t r) const {
1499 // A NON-CARRIER MARK OWNS NO PHASE BLOCK. Every mark of a marked
1500 // arrival shares ONE modulating chain, and `set_marked_arrival` binds
1501 // the same `Distrib` to each marked class, so asking each of them for
1502 // its own width would lay K independent copies of the chain into the
1503 // state row -- K independent streams, at K times the offered load.
1504 // The chain lives in the CARRIER's block (mark 1) and a later mark
1505 // contributes one always-zero column, which is MATLAB's
1506 // `sn.phasessz(sn.markidx > 1) = 1`.
1507 if (markidx_of(ist, r) > 1) return 1;
1508 const std::size_t p = phases_of(ist, r);
1509 return p > 0 ? p : 1;
1510 }
1511 /**
1512 * `sn.markidx(i,r)`: the 1-based MARK that class r carries in station i's
1513 * marked arrival process, or 0 when the class carries no mark.
1514 *
1515 * DERIVED from `stations[i-1].marked_classes` rather than stored beside it,
1516 * since that vector already IS the binding -- mark k names class
1517 * `marked_classes[k-1]` -- and a second copy could disagree with it after a
1518 * class removal.
1519 */
1520 std::size_t markidx_of(std::size_t ist, std::size_t r) const {
1521 if (ist == 0 || ist > stations.size()) return 0;
1522 const std::vector<std::size_t>& mc = stations[ist - 1].marked_classes;
1523 for (std::size_t k = 0; k < mc.size(); ++k)
1524 if (mc[k] == r) return k + 1;
1525 return 0;
1526 }
1527 /**
1528 * The CARRIER of station i's marked arrival: the class of mark 1, whose
1529 * phase block holds the one modulating chain every mark shares. 0 when the
1530 * station has no marked process.
1531 */
1532 std::size_t mark_carrier_of(std::size_t ist) const {
1533 if (ist == 0 || ist > stations.size()) return 0;
1534 const std::vector<std::size_t>& mc = stations[ist - 1].marked_classes;
1535 return mc.empty() ? 0 : mc[0];
1536 }
1537 bool has_fork() const {
1538 for (const NodeDef& n : nodes)
1539 if (n.nodetype == NodeType::Fork) return true;
1540 return false;
1541 }
1542 /**
1543 * MATLAB's `any(sn.isstatedep(:,3))` NARROWED TO THE ONE STRATEGY THIS PORT
1544 * EVALUATES PER STATE: Krzesinski's SDR.
1545 *
1546 * RROBIN, WRROBIN, JSQ and SQ are state dependent too and `sn_has_sd_routing`
1547 * reports them, but their per-state tables need auxiliary state this port
1548 * does not carry (a round-robin pointer) or are refused outright, so a
1549 * generator asking "must I re-evaluate the routing at every state?" must ask
1550 * this, not that.
1551 */
1552 bool has_sdr_routing() const {
1553 for (const NodeDef& n : nodes)
1554 for (RoutingStrategy rs : n.routing)
1555 if (rs == RoutingStrategy::SDR) return true;
1556 return false;
1557 }
1558 /**
1559 * ROUND-ROBIN DISPATCH, the state that makes it deterministic.
1560 *
1561 * A round-robin dispatcher is not a coin: which link the next job takes is
1562 * decided by a POINTER the node carries between departures, and without it
1563 * `refresh_routing`'s uniform expansion answers a random-routing model under
1564 * a dispatcher's name. The pointer lives in the node's local-variable block,
1565 * one column per class that routes RROBIN or WRROBIN
1566 * (`refresh_local_vars` allocates it as `nvars[ind-1][R+r-1]`).
1567 *
1568 * RROBIN stores the DESTINATION NODE INDEX in its slot; WRROBIN stores a
1569 * POSITION in the weighted cycle, because a repeated outlink must advance
1570 * once per repetition and a destination value could not tell the copies
1571 * apart. Both are the reference's encodings (`refreshLocalVars.m:318-355`,
1572 * `afterEventStation.m:632-658`, `afterEventRouter.m:22-52`).
1573 */
1574 std::vector<std::size_t> rr_outlinks(std::size_t ind, std::size_t r) const {
1575 std::vector<std::size_t> out;
1576 const T zero = num_traits<T>::from_int(0);
1577 const std::size_t K = classes.size(), I = nodes.size();
1578 for (std::size_t j = 1; j <= I; ++j) {
1579 bool linked = false;
1580 for (std::size_t s = 1; s <= K && !linked; ++s)
1581 if (get_route(r, s, ind, j) > zero) linked = true;
1582 if (linked) out.push_back(j);
1583 }
1584 return out;
1585 }
1586
1587 /** The WRROBIN cycle: each outlink repeated by its weight, weight 0 once. */
1588 std::vector<std::size_t> rr_weighted_outlinks(std::size_t ind, std::size_t r) const {
1589 const std::vector<std::size_t> ol = rr_outlinks(ind, r);
1590 const std::map<std::size_t, double>* w =
1591 (ind <= nodes.size() && nodes[ind - 1].routing_weights.size() >= r)
1592 ? &nodes[ind - 1].routing_weights[r - 1]
1593 : NULL;
1594 std::vector<std::size_t> cycle;
1595 for (std::size_t d = 0; d < ol.size(); ++d) {
1596 long reps = 1;
1597 if (w != NULL) {
1598 const std::map<std::size_t, double>::const_iterator it = w->find(ol[d]);
1599 if (it != w->end() && it->second > 0.0)
1600 reps = std::lround(it->second) < 1 ? 1 : std::lround(it->second);
1601 }
1602 for (long q = 0; q < reps; ++q) cycle.push_back(ol[d]);
1603 }
1604 return cycle;
1605 }
1606
1607 /** The pair form of class r (1-based) at station ist (1-based), or null when there is none. */
1608 const CtmcMapc<T>* mapc_of(std::size_t ist, std::size_t r) const {
1609 if (ctmcmapc.empty()) return nullptr;
1610 const auto it = ctmcmapc.find(std::make_pair(ist, r));
1611 return it == ctmcmapc.end() ? nullptr : &it->second;
1612 }
1613
1614 /** 0-based column of class r's MAP memory inside node ind's local-variable block. */
1615 std::size_t map_var_slot(std::size_t ind, std::size_t r) const {
1616 std::size_t slot = 0;
1617 for (std::size_t j = 0; j + 1 < r && j < nvars[ind - 1].size(); ++j) slot += nvars[ind - 1][j];
1618 return slot;
1619 }
1620
1621 /**
1622 * 1-BASED index of the pointer of (ind, r) INSIDE the node's local-variable
1623 * block, or 0 when that pair does not dispatch round-robin.
1624 *
1625 * `sum(nvars(ind, 1:(R+class)))`, which counts over the phase columns first:
1626 * the reference indexes `space_var` with exactly that, and reading the
1627 * pointer as "the r-th trailing column" instead is wrong the moment a class
1628 * carries a modulating phase or only some classes dispatch.
1629 */
1630 std::size_t rr_var_slot(std::size_t ind, std::size_t r) const {
1631 const std::size_t R = classes.size();
1632 if (ind == 0 || ind > nodes.size() || r == 0 || r > R) return 0;
1633 const std::vector<RoutingStrategy>& rt_i = nodes[ind - 1].routing;
1634 if (rt_i.size() < r) return 0;
1635 if (rt_i[r - 1] != RoutingStrategy::RROBIN && rt_i[r - 1] != RoutingStrategy::WRROBIN)
1636 return 0;
1637 if (nvars.size() < ind) return 0;
1638 std::size_t slot = 0;
1639 for (std::size_t j = 0; j < R + r && j < nvars[ind - 1].size(); ++j)
1640 slot += nvars[ind - 1][j];
1641 return slot;
1642 }
1643
1644 /**
1645 * The destination node the pointer in VARROW names, or 0 when (ind, r) does
1646 * not dispatch round-robin or the pointer is out of range.
1647 *
1648 * @param varrow the node's local-variable columns, as `after_event` slices them
1649 */
1650 std::size_t rr_dest(std::size_t ind, std::size_t r, const std::vector<T>& varrow) const {
1651 const std::size_t slot = rr_var_slot(ind, r);
1652 if (slot == 0 || slot > varrow.size()) return 0;
1653 const long v = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1654 if (nodes[ind - 1].routing[r - 1] == RoutingStrategy::RROBIN)
1655 return v > 0 ? static_cast<std::size_t>(v) : 0;
1656 const std::vector<std::size_t> cycle = rr_weighted_outlinks(ind, r);
1657 if (v < 1 || static_cast<std::size_t>(v) > cycle.size()) return 0;
1658 return cycle[static_cast<std::size_t>(v) - 1];
1659 }
1660
1661 /**
1662 * Advance the pointer of (ind, r) in VARROW by one position, cyclically.
1663 *
1664 * A no-op where the pair does not dispatch round-robin, so a caller can run
1665 * it unconditionally on every departure.
1666 */
1667 void rr_advance(std::size_t ind, std::size_t r, std::vector<T>& varrow) const {
1668 const std::size_t slot = rr_var_slot(ind, r);
1669 if (slot == 0 || slot > varrow.size()) return;
1670 if (nodes[ind - 1].routing[r - 1] == RoutingStrategy::RROBIN) {
1671 const std::vector<std::size_t> ol = rr_outlinks(ind, r);
1672 if (ol.empty()) return;
1673 const long cur = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1674 std::size_t idx = ol.size(); // "not found" -> restart at the first
1675 for (std::size_t d = 0; d < ol.size(); ++d)
1676 if (static_cast<long>(ol[d]) == cur) { idx = d; break; }
1677 const std::size_t nxt = (idx + 1 < ol.size()) ? idx + 1 : 0;
1678 varrow[slot - 1] = num_traits<T>::from_int(static_cast<long>(ol[nxt]));
1679 return;
1680 }
1681 const std::vector<std::size_t> cycle = rr_weighted_outlinks(ind, r);
1682 if (cycle.empty()) return;
1683 const long pos = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1684 const long nxt = (pos < 1 || static_cast<std::size_t>(pos) >= cycle.size()) ? 1 : pos + 1;
1685 varrow[slot - 1] = num_traits<T>::from_int(nxt);
1686 }
1687
1688 /** Whether ANY (node, class) pair dispatches round-robin. */
1689 bool has_rr_routing() const {
1690 for (const NodeDef& n : nodes)
1691 for (RoutingStrategy rs : n.routing)
1692 if (rs == RoutingStrategy::RROBIN || rs == RoutingStrategy::WRROBIN) return true;
1693 return false;
1694 }
1695
1696 /**
1697 * Whether immediate feedback is EFFECTIVE anywhere in the model.
1698 *
1699 * A DECLARATION ALONE IS NOT ENOUGH. `Queue.setImmediateFeedback` marks a
1700 * station and `JobClass.setImmediateFeedback` marks a class -- and the class
1701 * spelling marks EVERY station, since `immfeed` is the OR of the two -- so a
1702 * model with no self-loop at all can carry a full `immfeed` matrix while the
1703 * feature changes nothing. Reading the raw matrix made every solver that
1704 * consults it warn, or refuse, on a plain M/M/1 that merely mentioned the flag.
1705 *
1706 * Immediate feedback is effective at (station i, class r) when `immfeed[i][r]`
1707 * holds AND the routing table has a self-loop INTO (i, r) from some class s at
1708 * the same station, which is the only way a job can come back to the server it
1709 * just left. A class switch on the way round is folded into `rt` by
1710 * `refresh_routing`, so the incoming class s need not be r.
1711 *
1712 * Solvers that HANDLE immediate feedback look at the SYNCHRONIZATION instead;
1713 * see `qn::immfeed_self_loop`, which applies the same test per sync.
1714 */
1716 bool declared = false;
1717 for (const std::vector<bool>& row : immfeed) {
1718 for (bool b : row)
1719 if (b) {
1720 declared = true;
1721 break;
1722 }
1723 if (declared) break;
1724 }
1725 if (!declared) return false;
1726 // Without a routing table there is nothing to qualify the declaration
1727 // with, so report it as declared rather than silently dropping it.
1728 if (rt.rows() == 0 || rt.cols() == 0) return true;
1729 const std::size_t R = nclasses;
1730 for (std::size_t ist = 1; ist <= immfeed.size(); ++ist) {
1731 const std::size_t ind = node_of_station(ist);
1732 if (ind == 0 || ind > nodes.size()) continue;
1733 const std::size_t isf = stateful_index(ind);
1734 if (isf == 0) continue;
1735 for (std::size_t r = 1; r <= immfeed[ist - 1].size() && r <= R; ++r) {
1736 if (!immfeed[ist - 1][r - 1]) continue;
1737 const std::size_t col = (isf - 1) * R + (r - 1);
1738 if (col >= rt.cols()) continue;
1739 for (std::size_t sc = 0; sc < R; ++sc) {
1740 const std::size_t row = (isf - 1) * R + sc;
1741 if (row < rt.rows() &&
1742 num_traits<T>::to_double(rt(row, col)) > 0)
1743 return true;
1744 }
1745 }
1746 }
1747 return false;
1748 }
1749 /** 1-based node index of a station, and the reverse; 0 when absent. */
1750 /** 1-based node index of station `st`, 0 when the map has no entry. */
1751 std::size_t node_of_station(std::size_t st) const {
1752 // A Layer built by the LQN path can carry fewer map entries than
1753 // stations; returning 0 says "no node" rather than reading past the end.
1754 if (st == 0 || st > station_to_node.size()) return 0;
1755 return station_to_node[st - 1];
1756 }
1757 /** 1-based stateful index of node `ind`, 0 when the node is not stateful. */
1758 std::size_t stateful_index(std::size_t ind) const {
1759 for (std::size_t k = 0; k < stateful_nodes.size(); ++k)
1760 if (stateful_nodes[k] == ind) return k + 1;
1761 return 0;
1762 }
1763
1764 std::size_t stateful_of_station(std::size_t st) const {
1765 const std::size_t nd = station_to_node[st - 1];
1766 for (std::size_t k = 0; k < stateful_nodes.size(); ++k)
1767 if (stateful_nodes[k] == nd) return k + 1;
1768 throw InputError("network: a station is not a stateful node");
1769 }
1770
1771 /** Add a station, which is also a node, and grow the service table. */
1772 std::size_t add_station(const Station<T>& st) {
1773 stations.push_back(st);
1774 service.emplace_back(classes.size(), Distrib<T>::disabled_dist());
1775 NodeDef nd;
1776 nd.name = st.name;
1777 nd.nodetype = st.nodetype;
1778 nd.stateful = true;
1779 nd.station = stations.size();
1780 nodes.push_back(nd);
1781 station_to_node.push_back(nodes.size());
1782 stateful_nodes.push_back(nodes.size());
1783 grow_routing();
1784 return stations.size();
1785 }
1786
1787 /** Add a non-station node (a Fork, a Router). Returns its 1-based index. */
1788 std::size_t add_node(const std::string& nm, NodeType ty, bool stateful) {
1789 NodeDef nd;
1790 nd.name = nm;
1791 nd.nodetype = ty;
1792 nd.stateful = stateful;
1793 nd.station = 0;
1794 nodes.push_back(nd);
1795 if (stateful) stateful_nodes.push_back(nodes.size());
1796 grow_routing();
1797 return nodes.size();
1798 }
1799
1800 /** Add a class and grow the service table. */
1801 std::size_t add_class(const JobClass& cl) {
1802 classes.push_back(cl);
1803 for (auto& row : service) row.emplace_back(Distrib<T>::disabled_dist());
1804 return classes.size();
1805 }
1806
1807 void set_service(std::size_t station, std::size_t cls, const Distrib<T>& d) {
1808 service[station - 1][cls - 1] = d;
1809 }
1810
1811 /** P{r,s}(i,j) = p, with 1-based NODE and class indices. */
1812 void set_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j, const T& p) {
1813 auto key = std::make_pair(r, s);
1814 auto it = P.find(key);
1815 if (it == P.end())
1816 it = P.emplace(key, Matrix<T>(nodes.size(), nodes.size(), num_traits<T>::from_int(0)))
1817 .first;
1818 if (it->second.rows() != nodes.size()) grow_block(it->second);
1819 it->second(i - 1, j - 1) = p;
1820 }
1821
1822 /**
1823 * Write into the routing the consumers actually read.
1824 *
1825 * The Sink -> Source closure is derived by the refresh, not given by the
1826 * user, so it belongs in the effective routing; writing it into P alone
1827 * would make it invisible on any model whose routing was expanded.
1828 */
1829 void set_route_effective(std::size_t r, std::size_t s, std::size_t i, std::size_t j,
1830 const T& p) {
1831 if (Peff.empty()) {
1832 set_route(r, s, i, j, p);
1833 return;
1834 }
1835 auto key = std::make_pair(r, s);
1836 auto it = Peff.find(key);
1837 if (it == Peff.end())
1838 it = Peff.emplace(key, Matrix<T>(nodes.size(), nodes.size(), num_traits<T>::from_int(0)))
1839 .first;
1840 if (it->second.rows() != nodes.size()) {
1841 Matrix<T> g(nodes.size(), nodes.size(), num_traits<T>::from_int(0));
1842 for (std::size_t a = 0; a < it->second.rows(); ++a)
1843 for (std::size_t b = 0; b < it->second.cols(); ++b) g(a, b) = it->second(a, b);
1844 it->second = g;
1845 }
1846 it->second(i - 1, j - 1) = p;
1847 }
1848
1849 /**
1850 * P{r,s}(i,j), AS THE USER SET IT.
1851 *
1852 * Every consumer of the routing reads route_eff() instead, which is this
1853 * matrix once refresh_routing() has expanded the strategies that are not
1854 * literal probabilities. The two agree on a model whose routing is entirely
1855 * PROB, which is every model the LN layer builder produces.
1856 */
1857 T get_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const {
1858 auto it = P.find(std::make_pair(r, s));
1859 if (it == P.end() || it->second.rows() < nodes.size()) return num_traits<T>::from_int(0);
1860 return it->second(i - 1, j - 1);
1861 }
1862
1863 /** The routing actually in force: the expansion when there is one, else P. */
1864 T route_eff(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const {
1865 if (Peff.empty()) return get_route(r, s, i, j);
1866 auto it = Peff.find(std::make_pair(r, s));
1867 if (it == Peff.end() || it->second.rows() < nodes.size()) return num_traits<T>::from_int(0);
1868 return it->second(i - 1, j - 1);
1869 }
1870
1871 // -----------------------------------------------------------------------
1872 // Refresh
1873 // -----------------------------------------------------------------------
1874
1875 /**
1876 * The whole chain, in MATLAB's refreshStruct order.
1877 *
1878 * The order is load bearing: the routing expansion must precede the chains
1879 * (they are read off the class-switch structure of the routing), the chains
1880 * must precede the capacities (a station's buffer is bounded by the
1881 * population of the chains that reach it), and the visits must precede
1882 * nothing but must follow the sink closure, which needs the chains.
1883 */
1886 line::util::LineConsole::compile_detail("refreshing service and arrival processes");
1887 refresh_rates();
1889 // BEFORE the routing table: a round-robin Router must be STATEFUL, or
1890 // the stochastic complement that builds `rt` erases it and the pointer
1891 // has nowhere to live.
1893 line::util::LineConsole::compile_detail("computing the routing table");
1895 line::util::LineConsole::compile_detail("computing the chains and the visit ratios");
1898 "found %s over %s",
1899 line::util::LineConsole::plural(static_cast<long>(this->nchains), "chain", "chains").c_str(),
1900 line::util::LineConsole::plural(static_cast<long>(this->classes.size()), "class", "classes").c_str());
1902 refresh_rt();
1903 line::util::LineConsole::compile_detail("refreshing node parameters and state-dependent routing");
1906 // Needs the visit ratios, so it runs after refresh_chains.
1908 }
1909
1910 /**
1911 * True when node `ind` (1-based) holds a server across a synchronous call
1912 * whose reply class is `r` (1-based), i.e. `r` returns here to release a
1913 * server rather than to be served by one.
1914 */
1915 bool holds_reply_for(std::size_t ind, std::size_t r) const {
1916 if (ind == 0 || ind > replyblock.size()) return false;
1917 for (std::size_t k = 0; k < syncreply.size(); ++k)
1918 if (syncreply[k] == r && k < replyblock[ind - 1].size() && replyblock[ind - 1][k])
1919 return true;
1920 return false;
1921 }
1922
1923 /**
1924 * Whether a job of class `r` (0-based) can LEAVE node `ind` (1-based) again.
1925 *
1926 * True for anything that is not a service station, and for a station that
1927 * serves `r`, declares heterogeneous server types (its per-class process is
1928 * disabled by construction there) or holds a server across a synchronous
1929 * call whose reply class is `r`. False only for the flow sink itself, which
1930 * is what stops the walk in `reached_node_classes` -- the same three
1931 * exemptions the guard applies, kept in one place so the walk and the
1932 * verdict cannot drift apart.
1933 */
1934 bool serves_class(std::size_t ind, std::size_t r) const {
1935 if (ind == 0 || ind > nodes.size()) return true;
1936 const NodeType nt = nodes[ind - 1].nodetype;
1937 if (nt != NodeType::Queue && nt != NodeType::Delay) return true;
1938 const std::size_t sti = nodes[ind - 1].station;
1939 if (sti == 0 || sti > stations.size()) return true;
1940 if (!stations[sti - 1].server_types.empty()) return true;
1941 if (procid(sti, r + 1) != ProcessType::DISABLED) return true;
1942 return holds_reply_for(ind, r + 1);
1943 }
1944
1945 /**
1946 * The (node, class) pairs a job can actually ARRIVE at, 0-based on both axes.
1947 *
1948 * A forward walk of `rtnodes` from the feed points. See
1949 * `check_service_reachable` for why the evidence is the UNMASKED routing
1950 * kernel rather than `nodevisits`, and for the three rules -- absorbing
1951 * Sink, Source expanded only as a seed, unservable pair reached but not
1952 * expanded -- that keep the walk from over-approximating.
1953 *
1954 * Returns an empty vector when `rtnodes` is not the expected (N*K) square,
1955 * which leaves the caller checking nothing, exactly as before.
1956 */
1957 std::vector<std::vector<bool>> reached_node_classes() const {
1958 const std::size_t N = nodes.size(), K = nclasses;
1959 std::vector<std::vector<bool>> reached;
1960 if (N == 0 || K == 0) return reached;
1961 if (rtnodes.rows() < N * K || rtnodes.cols() < N * K) return reached;
1962 reached.assign(N, std::vector<bool>(K, false));
1963 std::vector<std::vector<bool>> seed(N, std::vector<bool>(K, false));
1964 for (std::size_t i = 0; i < stations.size(); ++i) {
1965 if (stations[i].nodetype != NodeType::Source) continue;
1966 const std::size_t ind = node_of_station(i + 1);
1967 if (ind == 0 || ind > N) continue;
1968 for (std::size_t r = 0; r < K; ++r)
1969 if (procid(i + 1, r + 1) != ProcessType::DISABLED) seed[ind - 1][r] = true;
1970 }
1971 for (std::size_t r = 0; r < K && r < classes.size(); ++r) {
1972 const double pop = classes[r].population;
1973 if (!std::isfinite(pop) || pop <= 0.0) continue;
1974 const std::size_t ind = node_of_station(classes[r].refstat);
1975 if (ind == 0 || ind > N) continue;
1976 seed[ind - 1][r] = true;
1977 }
1978 std::vector<std::pair<std::size_t, std::size_t>> stack;
1979 for (std::size_t i = 0; i < N; ++i)
1980 for (std::size_t r = 0; r < K; ++r)
1981 if (seed[i][r]) {
1982 reached[i][r] = true;
1983 stack.push_back(std::make_pair(i, r));
1984 }
1985 while (!stack.empty()) {
1986 const std::size_t i = stack.back().first;
1987 const std::size_t r = stack.back().second;
1988 stack.pop_back();
1989 const NodeType nt = nodes[i].nodetype;
1990 if (nt == NodeType::Sink) continue;
1991 if (nt == NodeType::Source && !seed[i][r]) continue;
1992 if (!serves_class(i + 1, r)) continue;
1993 const std::size_t row = i * K + r;
1994 for (std::size_t col = 0; col < N * K; ++col) {
1995 if (num_traits<T>::to_double(rtnodes(row, col)) <=
1997 continue;
1998 const std::size_t j = col / K, sIdx = col % K;
1999 if (!reached[j][sIdx]) {
2000 reached[j][sIdx] = true;
2001 stack.push_back(std::make_pair(j, sIdx));
2002 }
2003 }
2004 }
2005 return reached;
2006 }
2007
2008 /**
2009 * Refuses a class that is ROUTED TO a station which cannot serve it.
2010 *
2011 * The reference's `sanitize` disables the OUTGOING routing of a class a
2012 * station cannot serve, which is what keeps it out of that station's visit
2013 * ratios -- but nothing stopped the class being routed IN, and a class that
2014 * arrives where it cannot be served is a flow sink: it enters and never
2015 * leaves. The station-level guard next to it cannot see this, because it
2016 * asks whether the station serves ANY class, not whether it serves the
2017 * classes that reach it.
2018 *
2019 * One such model gave three different wrong answers, none flagged, on a
2020 * closed cycle D <-> Q whose class C2 has no service at Q: MVA reported
2021 * Q/C2 with ArvR 1 against Tput 0, CTMC dropped C2 entirely, and SSA
2022 * returned D/C2 QLen 2e-06 with the Q rows absent.
2023 *
2024 * READS `rtnodes`, WALKED FORWARD FROM THE FEED POINTS -- not `nodevisits`,
2025 * which this guard read until 2026-09-02 and which 94d5570f3 had made blind
2026 * to the very case it exists for. That commit extended the `served` mask
2027 * from the station chain to the NODE chain, and it had to: on a materialised
2028 * LQN replica the unserved states close into a spurious cycle. But the mask
2029 * zeroes exactly the (station, class) cell a flow sink shows up in. On
2030 * Source -> Q -> Sink with class B unservable at Q, B's chain went from
2031 * Q = 1 to Q = 0 and the guard fell silent, while the Sink still read 1 --
2032 * flow arriving downstream of a node it never visited. A MASKED VISIT VECTOR
2033 * CANNOT ANSWER THIS QUESTION, because the mask IS the answer being looked
2034 * for. Do not route this guard back through `nodevisits` or `visits`; both
2035 * carry that mask. See _kb/07-cross-language-parity.md.
2036 *
2037 * `rtnodes` on its own over-approximates -- it says where a class WOULD go
2038 * if one existed -- and the WALK is what removes the slack. It starts only
2039 * at (Source, class) pairs whose arrival process is not DISABLED, and at the
2040 * reference station of each closed class with a positive population, so the
2041 * disabled-arrival row a class-switching Source carries is never entered.
2042 * Three rules keep it honest: a Sink is ABSORBING (`rtnodes` wraps it back
2043 * to the Source to close the kernel, and following that wrap re-enters every
2044 * Source row, including the disabled ones the seeding just excluded); a
2045 * Source is expanded ONLY AS A SEED, for the same reason; and an unservable
2046 * (station, class) is REACHED BUT NOT EXPANDED, since nothing leaves it --
2047 * that is the whole complaint -- so nothing downstream of it is evidence of
2048 * anything.
2049 *
2050 * This SUBSUMES the fed-chain precondition the guard used to carry
2051 * separately: a chain no job can enter has no seed, so its rows are never
2052 * walked at all. That is strictly finer than the per-chain test it replaces,
2053 * which admitted every class of a chain any one of whose classes was fed.
2054 */
2056 if (nclasses == 0) return;
2057 // Same exemption as the reference's sanitize checks: a station of a
2058 // cache, Petri-net or fork-join model legitimately carries no per-class
2059 // service.
2060 for (std::size_t ind = 0; ind < nodes.size(); ++ind) {
2061 const NodeType nt = nodes[ind].nodetype;
2062 if (nt == NodeType::Cache || nt == NodeType::Place ||
2063 nt == NodeType::Transition || nt == NodeType::Fork ||
2064 nt == NodeType::Join)
2065 return;
2066 }
2067 const std::size_t K = nclasses;
2068 const std::vector<std::vector<bool>> reached = reached_node_classes();
2069 if (reached.empty()) return;
2070 for (std::size_t i = 0; i < nstations; ++i) {
2071 const NodeType nt = stations[i].nodetype;
2072 if (nt != NodeType::Queue && nt != NodeType::Delay) continue;
2073 // A heterogeneous pool carries its service on the server types, so
2074 // the per-class process is legitimately disabled there. Skipped in
2075 // all four codebases.
2076 if (!stations[i].server_types.empty()) continue;
2077 const std::size_t ind = node_of_station(i + 1);
2078 if (ind == 0) continue;
2079 for (std::size_t r = 0; r < K; ++r) {
2080 if (procid(i + 1, r + 1) != ProcessType::DISABLED) continue;
2081 // A SYNCHRONOUS REPLY is not served by the station it returns
2082 // to: it releases the server that station held across the call,
2083 // which is the whole content of set_sync_reply. Its Disabled
2084 // service there is the marker of the feature, not a flow sink.
2085 if (holds_reply_for(ind, r + 1)) continue;
2086 if (ind - 1 >= reached.size() || r >= reached[ind - 1].size()) continue;
2087 if (!reached[ind - 1][r]) continue;
2088 const std::string kind = (nt == NodeType::Delay) ? "Delay" : "Queue";
2089 throw InputError(kind + " '" + stations[i].name +
2090 "' has no service configured for job class '" +
2091 classes[r].name +
2092 "', but the class is routed to it. Jobs would arrive and "
2093 "never leave. Configure a service for that class, or route "
2094 "it elsewhere.");
2095 }
2096 }
2097 }
2098
2099 /**
2100 * Port of the `sn.immfeed` block of `@@MNetwork/refreshStruct.m`.
2101 *
2102 * The station's own per-class setting OR the class-level one, which is the
2103 * reference's `stationHas || classHas`. Sized here rather than in
2104 * `refresh_rates` because it is a property of the model's topology and not
2105 * of the service processes, so it must not be cleared when only the rates
2106 * are re-derived (SolverLN calls `refresh_rates` per layer, per iteration).
2107 */
2109 const std::size_t M = stations.size(), R = classes.size();
2110 immfeed.assign(M, std::vector<bool>(R, false));
2111 for (std::size_t i = 0; i < M; ++i)
2112 for (std::size_t r = 0; r < R; ++r) {
2113 const bool station_has =
2114 r < stations[i].immfeed.size() && stations[i].immfeed[r];
2115 immfeed[i][r] = station_has || classes[r].immfeed;
2116 }
2117 }
2118
2119 /**
2120 * Port of MNetwork.refreshLocalVars: the per-node local-variable widths.
2121 *
2122 * A column is reserved only where the model actually needs one, because the
2123 * width is part of the state encoding -- a spurious column widens every
2124 * state row and makes it unmatchable against the enumerated space.
2125 */
2126 /**
2127 * A Router that DISPATCHES ROUND-ROBIN holds state, so it must survive the
2128 * stochastic complement that removes the stateless nodes.
2129 *
2130 * The reference makes EVERY Router stateful (`refreshStruct.m:155`); this
2131 * port promotes only the dispatching ones, because a PROB or RAND Router
2132 * genuinely holds nothing and complementing it away is both correct and
2133 * cheaper -- it keeps the state space of every existing model unchanged.
2134 * The promotion happens at refresh rather than at `add_router`, since
2135 * `set_routing` is called after the node exists.
2136 */
2137 /**
2138 * Make node `ind` stateful, keeping `stateful_nodes` ascending. Idempotent.
2139 *
2140 * Used by the fork-join transforms: the Router they put where a Fork stood
2141 * is a `Router` OBJECT in the reference, hence a `StatefulNode`, and it has
2142 * to stay one here. Its chain block is deliberately NOT stochastic -- an
2143 * auxiliary class keeps the OTHER fork's full fan-out, so that row sums to
2144 * the fan-out -- and on such a block the stochastic complement is not
2145 * visit-preserving, so complementing the node away moves every downstream
2146 * visit ratio off the reference's.
2147 */
2148 void promote_stateful(std::size_t ind) {
2149 if (ind == 0 || ind > nodes.size() || nodes[ind - 1].stateful) return;
2150 nodes[ind - 1].stateful = true;
2151 stateful_nodes.insert(std::lower_bound(stateful_nodes.begin(), stateful_nodes.end(), ind),
2152 ind);
2153 }
2154
2156 for (std::size_t ind = 1; ind <= nodes.size(); ++ind) {
2157 NodeDef& nd = nodes[ind - 1];
2158 if (nd.stateful || nd.nodetype != NodeType::Router) continue;
2159 bool rr = false;
2160 for (std::size_t r = 0; r < nd.routing.size(); ++r)
2161 if (nd.routing[r] == RoutingStrategy::RROBIN ||
2162 nd.routing[r] == RoutingStrategy::WRROBIN)
2163 rr = true;
2164 if (!rr) continue;
2165 nd.stateful = true;
2166 stateful_nodes.insert(
2167 std::lower_bound(stateful_nodes.begin(), stateful_nodes.end(), ind), ind);
2168 }
2169 }
2170
2172 const std::size_t R = classes.size();
2173 // One entry per class, 0 = none, as MATLAB's -ones(nclasses,1): a class added after the last binding
2174 // (LQN2QN's Ph2End destructors) or a model with no binding at all would otherwise leave it short
2175 syncreply.resize(R, 0);
2177 nvars.assign(nodes.size(), std::vector<std::size_t>(3 * R + 1, 0));
2178 for (std::size_t ind = 1; ind <= nodes.size(); ++ind) {
2179 const std::size_t ist = nodes[ind - 1].station;
2180 // A Markov-modulated process restarts from the phase it was left
2181 // in, so that phase has to survive in the state between services.
2182 if (ist != 0)
2183 for (std::size_t r = 1; r <= R; ++r) {
2184 const ProcessType pt = procid(ist, r);
2185 if (pt == ProcessType::MAP || pt == ProcessType::MMPP2)
2186 nvars[ind - 1][r - 1] += 1;
2187 }
2188 // Round-robin routing is stateful: the pointer to the next outgoing
2189 // link is what makes it deterministic rather than random.
2190 const std::vector<RoutingStrategy>& rt_i = nodes[ind - 1].routing;
2191 for (std::size_t r = 1; r <= R && r <= rt_i.size(); ++r)
2192 if (rt_i[r - 1] == RoutingStrategy::RROBIN ||
2193 rt_i[r - 1] == RoutingStrategy::WRROBIN)
2194 nvars[ind - 1][R + r - 1] += 1;
2195 // The shared node block. A Cache stores its list contents here;
2196 // the BAS marker, the breakdown status and the polling controller
2197 // claim the same column, which is why the reference rejects those
2198 // combinations instead of widening the state.
2199 // The polling controller shares the node block with the cache,
2200 // the BAS marker and the breakdown status, which is why those
2201 // combinations are rejected rather than the state widened.
2202 // The SAME resolution polling_info reads, or its offset misses nvars.
2203 if (ist != 0 && stations[ist - 1].sched == SchedStrategy::POLLING) {
2204 const PollingParam pp = effective_polling(ist);
2205 bool anysw = false;
2206 for (std::size_t r = 0; r < pp.switchover.size(); ++r) {
2207 const lang::Distrib<T>& d = pp.switchover[r];
2208 if (!d.disabled && d.D0.rows() > 0 && d.type != ProcessType::IMMEDIATE)
2209 anysw = true;
2210 }
2211 std::size_t w = anysw ? 2 : 0; // pos and swk
2212 if (pp.ptype != lang::PollingType::EXHAUSTIVE) w += 1; // ctr
2213 nvars[ind - 1][2 * R] = w;
2214 }
2215 // The REPLY block: one counter column per (node, calling class).
2216 for (std::size_t r = 1; r <= R; ++r)
2217 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2218 replyblock[ind - 1][r - 1])
2219 nvars[ind - 1][2 * R + r] = 1;
2220 // The SERVER STATUS of a station that breaks down (1 = up, 0 = down),
2221 // `refreshLocalVars.m`. It takes the shared node block, so a polling
2222 // controller there is refused as the reference refuses it; the BAS
2223 // marker is refused by `refresh_bas_blocking`. The status must be
2224 // the TRAILING column, which is where the DEP, PHASE, FAILURE and
2225 // REPAIR handlers read it, so a reply block (whose counters trail
2226 // the node block in this port) is refused too.
2227 if (ist != 0 && breakdownparam.find(ist) != breakdownparam.end()) {
2228 if (stations[ist - 1].sched == SchedStrategy::POLLING)
2229 throw UnsupportedError(
2230 "Server breakdowns are not supported at a polling station (" +
2231 nodes[ind - 1].name +
2232 "): the polling controller and the breakdown status share one "
2233 "local-state column.");
2234 for (std::size_t r = 1; r <= R; ++r)
2235 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2236 replyblock[ind - 1][r - 1])
2237 throw UnsupportedError(
2238 "Server breakdowns are not supported at station '" +
2239 nodes[ind - 1].name +
2240 "', which also holds servers for a synchronous reply: the reply "
2241 "counters would trail the breakdown status the service handlers read");
2242 nvars[ind - 1][2 * R] = 1;
2243 }
2244 const typename std::map<std::size_t, CacheParam<T>>::const_iterator ci =
2245 nodeparam.find(ind);
2246 if (ci != nodeparam.end()) {
2247 // Contents, plus block A (a per-item occupancy bitmap) and block
2248 // B (a per-retrieval-class count of merged secondary requests)
2249 // when a delayed-hit retrieval system is attached.
2250 std::size_t w = 0;
2251 for (std::size_t u = 0; u < ci->second.itemcap.size(); ++u)
2252 if (ci->second.itemcap[u] > 0)
2253 w += static_cast<std::size_t>(ci->second.itemcap[u]);
2254 if (ci->second.retrieval_capacity > 0) {
2255 w += ci->second.nitems;
2256 std::vector<std::size_t> rcl, rci, rco;
2257 cache_retrieval_class_map(ci->second, rcl, rci, rco);
2258 w += rcl.size();
2259 }
2260 nvars[ind - 1][2 * R] = w;
2261 }
2262 }
2264 }
2265
2266 /**
2267 * `sn.replyblock`, DERIVED from `sn.syncreply` and the routing.
2268 *
2269 * Port of `refreshLocalVars.m:340-386`. A server is held wherever the REPLY
2270 * class can arrive: every node that is a station, is not the Source, and is
2271 * not an infinite server -- an INF station has a server per job, so holding
2272 * one is immaterial and needs no state. Every such station must be FCFS,
2273 * because a held server is encoded as a per-class COUNTER, which is exact
2274 * only where the servers are interchangeable.
2275 *
2276 * Derived rather than declared because the model layer carries only the
2277 * class-to-class binding (`JobClass.setReplySignalClass`), and a caller
2278 * naming the nodes itself would silently disagree with the reference on any
2279 * model whose routing sends the reply somewhere it did not think of.
2280 */
2282 const std::size_t R = classes.size(), N = nodes.size();
2283 bool any = false;
2284 for (std::size_t r = 0; r < R && r < syncreply.size(); ++r)
2285 if (syncreply[r] >= 1 && syncreply[r] <= R) any = true;
2286 if (!any) return;
2287 replyblock.assign(N, std::vector<bool>(R, false));
2288 for (std::size_t r = 1; r <= R; ++r) {
2289 if (r > syncreply.size()) break;
2290 const std::size_t s = syncreply[r - 1];
2291 if (s < 1 || s > R) continue;
2292 for (std::size_t ind = 1; ind <= N; ++ind) {
2293 const std::size_t ist = nodes[ind - 1].station;
2294 if (ist == 0 || nodes[ind - 1].nodetype == lang::NodeType::Source) continue;
2295 if (stations[ist - 1].sched == SchedStrategy::INF) continue;
2296 bool arrives_here = false;
2297 for (std::size_t i = 1; i <= N && !arrives_here; ++i)
2298 for (std::size_t q = 1; q <= R; ++q)
2299 if (rtnodes.rows() >= N * R &&
2301 rtnodes((i - 1) * R + q - 1, (ind - 1) * R + s - 1)) > 0) {
2302 arrives_here = true;
2303 break;
2304 }
2305 if (!arrives_here) continue;
2306 if (stations[ist - 1].sched != SchedStrategy::FCFS)
2307 throw InputError(
2308 "network '" + name +
2309 "': synchronous calls (REPLY signals) are supported only at FCFS "
2310 "stations, but '" +
2311 nodes[ind - 1].name +
2312 "' uses another discipline. A held server is encoded as a per-class "
2313 "counter, which is exact only where servers are interchangeable (FCFS) "
2314 "or unlimited (INF); the other disciplines are not yet encoded rather than "
2315 "infeasible. Set this station to FCFS or INF, or simulate the layered model "
2316 "directly with SolverLDES.");
2317 replyblock[ind - 1][r - 1] = true;
2318 }
2319 }
2320 }
2321
2322 /**
2323 * The nodes directly downstream of `ind`, walking THROUGH stateless nodes
2324 * and stopping at the first station on each path.
2325 *
2326 * `downstreamStations` in `refreshLocalVars.m`. A blocked job is held for its
2327 * IMMEDIATE destination, so the walk stops at the first station: a Router or
2328 * ClassSwitch in between is a routing decision, not a place to wait.
2329 *
2330 * The reference reads `sn.connmatrix`; this port has no such field and reads
2331 * `rtnodes` instead, which is the same graph after the refresh has resolved
2332 * the routing strategies. The two differ only on a link the model declares
2333 * and then routes zero mass over, and such a link cannot fill a destination,
2334 * so it cannot block anything either.
2335 */
2336 std::vector<std::size_t> downstream_stations(std::size_t ind) const {
2337 const std::size_t R = classes.size();
2338 const std::size_t N = nodes.size();
2339 std::vector<std::size_t> out;
2340 if (rtnodes.rows() < N * R) return out;
2341 std::vector<bool> seen(N + 1, false);
2342 std::vector<std::size_t> frontier(1, ind);
2343 seen[ind] = true;
2344 while (!frontier.empty()) {
2345 const std::size_t i = frontier.back();
2346 frontier.pop_back();
2347 for (std::size_t j = 1; j <= N; ++j) {
2348 if (seen[j]) continue;
2349 bool linked = false;
2350 for (std::size_t r = 0; r < R && !linked; ++r)
2351 for (std::size_t s = 0; s < R && !linked; ++s)
2353 rtnodes((i - 1) * R + r, (j - 1) * R + s)) > 0)
2354 linked = true;
2355 if (!linked) continue;
2356 seen[j] = true;
2357 if (nodes[j - 1].station != 0) {
2358 out.push_back(j); // a station terminates this path
2359 } else {
2360 frontier.push_back(j); // walk through the stateless node
2361 }
2362 }
2363 }
2364 return out;
2365 }
2366
2367 /**
2368 * Port of `refreshLocalVars`' true-BAS block and its `declaresBlockedMarker`
2369 * helper: which nodes carry the blocked marker, and where a refusal blocks.
2370 */
2372 const std::size_t R = classes.size();
2373 const std::size_t N = nodes.size();
2374 isbasblocking.assign(N, false);
2375 isbasdestination.assign(stations.size(), std::vector<bool>(R, false));
2376 if (droprule.empty()) return;
2377 for (std::size_t ind = 1; ind <= N; ++ind) {
2378 const std::size_t ist = nodes[ind - 1].station;
2379 if (ist == 0) continue;
2380 const NodeType nt = nodes[ind - 1].nodetype;
2381 if (nt == NodeType::Source || nt == NodeType::Cache) continue;
2382 const std::vector<std::size_t> dests = downstream_stations(ind);
2383 bool declares = false;
2384 for (std::size_t r = 1; r <= R; ++r) {
2385 const bool here_bas = droprule.size() >= ist && droprule[ist - 1].size() >= r &&
2386 droprule[ist - 1][r - 1] == DropStrategy::BAS;
2387 for (std::size_t d = 0; d < dests.size(); ++d) {
2388 const std::size_t jst = nodes[dests[d] - 1].station;
2389 if (jst == 0 || nodes[dests[d] - 1].nodetype == NodeType::Source) continue;
2390 // The destination must be able to FILL. An unbounded queue
2391 // never refuses, so nothing upstream of it can ever block,
2392 // and reserving the marker would widen the state for nothing.
2393 //
2394 // THE DECLARED CAPACITY, not the refreshed `cap`. The refresh
2395 // clamps an unbounded station to the total closed population,
2396 // which is finite, so reading `cap` here would find every
2397 // destination in a closed model "able to fill" and reserve a
2398 // marker on every upstream station. The reference reads
2399 // `dnode.cap`, the value the user set.
2400 const double dcap = stations[jst - 1].cap;
2401 if (!std::isfinite(dcap) || !(dcap > 0)) continue;
2402 const bool there_bas =
2403 droprule.size() >= jst && droprule[jst - 1].size() >= r &&
2404 droprule[jst - 1][r - 1] == DropStrategy::BAS;
2405 if (!here_bas && !there_bas) continue;
2406 // Do NOT stop at the first hit: every such destination has to
2407 // be recorded, since a refusal at ANY of them must block here
2408 // rather than drop.
2409 declares = true;
2410 isbasdestination[jst - 1][r - 1] = true;
2411 }
2412 }
2413 if (!declares) continue;
2414 // The marker shares nvars column 2R+1 with the cache contents, the
2415 // breakdown status and the polling controller, so the reference
2416 // rejects the combinations rather than widening the state.
2417 if (breakdownparam.find(ist) != breakdownparam.end())
2418 throw UnsupportedError(
2419 "station '" + nodes[ind - 1].name +
2420 "' combines server breakdowns with true-BAS blocking: the breakdown status "
2421 "and the BAS blocked marker share one local-state column. Remove the BAS drop "
2422 "rule or the breakdown");
2423 if (stations[ist - 1].sched == SchedStrategy::POLLING)
2424 throw UnsupportedError(
2425 "true BAS blocking is not supported at the polling station '" +
2426 nodes[ind - 1].name +
2427 "': the polling controller and the BAS blocked marker share one local-state "
2428 "column. Use a non-polling discipline at the blocking station, or remove the "
2429 "BAS drop rule");
2430 for (std::size_t r = 1; r <= R; ++r)
2431 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2432 replyblock[ind - 1][r - 1])
2433 throw UnsupportedError(
2434 "true BAS blocking is not supported at station '" + nodes[ind - 1].name +
2435 "', which also holds servers for a synchronous reply: fromMarginal appends "
2436 "the reply counters AFTER the blocked marker, so the marker would no "
2437 "longer be the trailing column the departure handler reads");
2438 nvars[ind - 1][2 * R] = 1;
2439 isbasblocking[ind - 1] = true;
2440 }
2441 }
2442
2443 /**
2444 * Port of MNetwork.refreshScheduling's schedparam half.
2445 *
2446 * DPS and GPS take a per-class weight, defaulting to 1; SEPT and LEPT take
2447 * the rank of the class's mean service time among the distinct means, which
2448 * is what the reference computes here rather than at the solver.
2449 *
2450 * EVERY OTHER DISCIPLINE DEFAULTS TO 1, NOT 0. Measured against MATLAB
2451 * R2025a: FCFS, PS, LPS, SIRO, LCFS, HOL and SJF all report schedparam 1
2452 * per class on a two-class model, and only SEPT/LEPT differ. Defaulting to
2453 * zero here made every consumer that normalizes by the weight total divide
2454 * 0/0: a load-dependent PS station reported Util exactly 0 against the
2455 * reference's 0.400822578299582, and `ctmc_signal_busy` shares the pattern.
2456 */
2458 const T one = num_traits<T>::from_int(1);
2459 for (std::size_t i = 0; i < stations.size(); ++i) {
2460 Station<T>& st = stations[i];
2461 if (st.sched == SchedStrategy::DPS || st.sched == SchedStrategy::GPS) {
2462 if (st.schedparam.size() != classes.size())
2463 st.schedparam.assign(classes.size(), one);
2464 continue;
2465 }
2466 if (st.sched == SchedStrategy::SEPT || st.sched == SchedStrategy::LEPT) {
2467 if (st.schedparam.size() == classes.size()) continue;
2468 std::vector<double> means;
2469 for (std::size_t r = 0; r < classes.size(); ++r)
2470 means.push_back(num_traits<T>::to_double(service[i][r].mean));
2471 std::vector<double> sorted = means;
2472 std::sort(sorted.begin(), sorted.end());
2473 sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end());
2474 if (st.sched == SchedStrategy::LEPT)
2475 std::reverse(sorted.begin(), sorted.end());
2476 st.schedparam.assign(classes.size(), num_traits<T>::from_int(0));
2477 for (std::size_t r = 0; r < classes.size(); ++r)
2478 for (std::size_t k = 0; k < sorted.size(); ++k)
2479 if (sorted[k] == means[r])
2480 st.schedparam[r] = num_traits<T>::from_int(static_cast<long>(k) + 1);
2481 continue;
2482 }
2483 if (st.schedparam.empty()) st.schedparam.assign(classes.size(), one);
2484 }
2485 }
2486
2487 /**
2488 * Port of the part of MNetwork.refreshRoutingMatrix this port reaches: the
2489 * expansion of a routing STRATEGY into the probabilities `rt` is built from.
2490 *
2491 * PROB is already probabilities and is copied through. RAND and RROBIN
2492 * spread the mass uniformly over the nodes the user connected this one to,
2493 * which is what MATLAB's `RoutingStrategy.RAND` means once `link` has
2494 * recorded the connections, and what `getRoutingMatrix.m:117` does for both
2495 * in the same branch: a round-robin pointer visits every outgoing link
2496 * equally often, so the ROUTING PROBABILITIES it induces are uniform and
2497 * only the higher moments of the split are deterministic. Recovering that
2498 * determinism is the consumer's job -- `npfqn_traffic_split_rr` gives QNA
2499 * and MNA the split degree, and a solver that needs the pointer itself must
2500 * carry it in the state. A solver that does neither must not declare
2501 * `RoutingStrategy_RROBIN` in its feature set, or it answers a random-
2502 * routing model under a round-robin name.
2503 * A ClassSwitch node's outgoing mass is multiplied by its
2504 * class-switch matrix, so a job leaving it in class r continues as class s
2505 * with probability C(r,s) -- the node itself is not stateful, and the
2506 * stochastic complement folds it into the edges around it.
2507 *
2508 * Every other strategy is state dependent (WRROBIN, JSQ, SQ, FIRING) and is
2509 * REFUSED by name: silently treating one as PROB returns a product-form
2510 * answer for a model that does not have one. WRROBIN is not RROBIN with
2511 * weights for this purpose -- its uniform expansion would be wrong even in
2512 * the first moment.
2513 */
2515 const T zero = num_traits<T>::from_int(0);
2516 const std::size_t K = classes.size(), I = nodes.size();
2517 Peff.clear();
2518 bool trivial = true;
2519 for (const NodeDef& nd : nodes)
2520 for (RoutingStrategy rs : nd.routing)
2521 if (rs != RoutingStrategy::PROB && rs != RoutingStrategy::DISABLED) trivial = false;
2522 // A (node, class) PAIR THE CALLER NEVER ROUTED IS NOT TRIVIAL EITHER,
2523 // because the reference FILLS it -- see the unrouted branch below. This
2524 // port has no DISABLED strategy to key on (every pair defaults to PROB),
2525 // so the condition is the one MATLAB's DISABLED case IS: no outgoing
2526 // mass for this class at this node, at a node that has connections.
2527
2528 bool has_cs = false;
2529 for (const NodeDef& nd : nodes)
2530 if (nd.nodetype == NodeType::ClassSwitch) has_cs = true;
2531 // A Cache node is an implicit class switch: its read class routes on to
2532 // the hit and miss classes, so the routing is never trivial.
2533 const bool has_cache = !nodeparam.empty();
2534 if (trivial && !has_cs && !has_cache) return; // P is already the effective routing
2535
2536 for (const auto& kv : P) Peff[kv.first] = kv.second;
2537 // The class-INDEPENDENT topology (`sn.connmatrix`), built once from the arcs P holds. Probing
2538 // get_route over every class pair per (node, class, destination) was I^2 K^3 lookups, which
2539 // never finished on a flattened LQN (25 nodes, 146 classes: ~2e9 map finds).
2540 std::vector<char> conn(I * I, 0);
2541 for (const auto& kv : P) {
2542 const Matrix<T>& M = kv.second;
2543 if (M.rows() < I || M.cols() < I) continue; // get_route reads such a block as zero
2544 for (std::size_t a = 0; a < I; ++a)
2545 for (std::size_t b = 0; b < I; ++b)
2546 if (M(a, b) > zero) conn[a * I + b] = 1;
2547 }
2548 auto eff_at = [&](std::size_t r, std::size_t s) -> Matrix<T>& {
2549 auto key = std::make_pair(r, s);
2550 auto it = Peff.find(key);
2551 if (it == Peff.end())
2552 it = Peff.emplace(key, Matrix<T>(I, I, zero)).first;
2553 if (it->second.rows() != I) {
2554 Matrix<T> g(I, I, zero);
2555 for (std::size_t a = 0; a < it->second.rows(); ++a)
2556 for (std::size_t b = 0; b < it->second.cols(); ++b) g(a, b) = it->second(a, b);
2557 it->second = g;
2558 }
2559 return it->second;
2560 };
2561
2562 for (std::size_t i = 1; i <= I; ++i) {
2563 const NodeDef& nd = nodes[i - 1];
2564 for (std::size_t r = 1; r <= K; ++r) {
2565 const RoutingStrategy rs =
2566 nd.routing.size() >= r ? nd.routing[r - 1] : RoutingStrategy::PROB;
2567 if (rs == RoutingStrategy::PROB || rs == RoutingStrategy::DISABLED) continue;
2568 if (rs != RoutingStrategy::RAND && rs != RoutingStrategy::RROBIN &&
2569 rs != RoutingStrategy::JSQ && rs != RoutingStrategy::SQ &&
2570 rs != RoutingStrategy::WRROBIN && rs != RoutingStrategy::SDR)
2571 throw UnsupportedError(std::string("network: routing strategy '") +
2572 routing_to_text(rs) + "' at node '" + nd.name +
2573 "' has no routing-matrix expansion in this port");
2574 // THE DESTINATIONS ARE THE NODE'S CONNECTIONS, NOT THIS CLASS'S
2575 // OWN ARCS. `getRoutingMatrix.m:117-135` enumerates `sn.connmatrix`
2576 // -- a class-INDEPENDENT topology -- and routes same-class over
2577 // it, which is what makes a class that declared no arc at a node
2578 // it nevertheless reaches get a routing at all. Reading the
2579 // class's own entries instead left such a row EMPTY, and an empty
2580 // row is not a visit: on `gallery_erlerl1`, where Class1 declares
2581 // Source->Queue and Class2 declares Queue->Sink, the Queue came
2582 // out unvisited by Class1 and every metric of the model was zero.
2583 // The `served` mask in `refresh_visits` is what keeps the fill
2584 // honest -- it drops the (station, class) pairs with no service.
2585 //
2586 // A CLOSED CLASS IS NOT ROUTED INTO A SINK, and not out of a
2587 // Source at all, which is the same branch's rule: a sink would
2588 // absorb a job the population must conserve.
2589 const bool open_class = !std::isfinite(
2590 num_traits<T>::to_double(classes[r - 1].population));
2591 const bool from_source = nd.nodetype == NodeType::Source;
2592 const bool from_sink = nd.nodetype == NodeType::Sink;
2593 if (!open_class && (from_source || from_sink)) continue;
2594 std::vector<std::pair<std::size_t, std::size_t>> dest; // (node, class)
2595 for (std::size_t j = 1; j <= I; ++j) {
2596 if (!open_class && nodes[j - 1].nodetype == NodeType::Sink) continue;
2597 if (conn[(i - 1) * I + (j - 1)]) dest.emplace_back(j, r);
2598 }
2599 if (dest.empty()) continue;
2600 // WRROBIN SPREADS BY ITS WEIGHTS, everything else uniformly.
2601 // That is `getRoutingMatrix.m`'s split and it keeps the FIRST
2602 // MOMENT right for a weighted dispatcher, which a uniform
2603 // expansion would not.
2604 std::vector<T> share(dest.size(),
2606 num_traits<T>::from_int(static_cast<long>(dest.size()))));
2607 if (rs == RoutingStrategy::WRROBIN) {
2608 const std::map<std::size_t, double>* w =
2609 nd.routing_weights.size() >= r ? &nd.routing_weights[r - 1] : NULL;
2610 double total = 0.0;
2611 std::vector<double> raw(dest.size(), 0.0);
2612 if (w != NULL)
2613 for (std::size_t d = 0; d < dest.size(); ++d) {
2614 const std::map<std::size_t, double>::const_iterator it =
2615 w->find(dest[d].first);
2616 raw[d] = (it == w->end()) ? 0.0 : it->second;
2617 total += raw[d];
2618 }
2619 if (total > 0.0)
2620 for (std::size_t d = 0; d < dest.size(); ++d)
2621 share[d] = num_traits<T>::from_double(raw[d] / total);
2622 }
2623 // Clear row i of every (r,s) block that exists: an absent block already reads as zero,
2624 // and materialising all K^2 of them only to zero one row is K^2 I^2 of memory
2625 for (std::size_t s = 1; s <= K; ++s) {
2626 if (Peff.find(std::make_pair(r, s)) == Peff.end()) continue;
2627 Matrix<T>& B = eff_at(r, s);
2628 for (std::size_t j = 1; j <= I; ++j) B(i - 1, j - 1) = zero;
2629 }
2630 for (std::size_t d = 0; d < dest.size(); ++d)
2631 eff_at(r, dest[d].second)(i - 1, dest[d].first - 1) = share[d];
2632 }
2633 }
2634
2635 // ClassSwitch: split the outgoing mass across the class-switch matrix.
2636 for (const auto& kv : csmatrix) {
2637 const std::size_t cs = kv.first;
2638 const Matrix<T>& C = kv.second;
2639 if (C.rows() != K || C.cols() != K)
2640 throw InputError("network: the class-switch matrix of node '" +
2641 nodes[cs - 1].name + "' is not (nclasses x nclasses)");
2642 // WHERE A SWITCHED JOB GOES IS THE ARRIVAL CLASS'S ROUTING, NOT THE
2643 // DEPARTURE CLASS'S. `getRoutingMatrix.m`'s StatelessClassSwitcher
2644 // block sets rtnodes(r, (j-1)*K+s) = Pcs(r,s) * Pij(s,s): the
2645 // destination distribution is read off the DIAGONAL, i.e. from the
2646 // row class s routes on with, and only the mass is Pcs(r,s).
2647 // Reading class r's own outgoing row instead is the same matrix
2648 // only when every class of the chain leaves the switch the same
2649 // way. Where they do not -- `Delay -> CS1 -> Queue -> CS2 -> Delay`
2650 // with each class Disabled at the station it does not visit, so
2651 // each class is routed on one arc of the cycle only -- class r has
2652 // NO outgoing arc at the switch it enters, the whole block comes
2653 // out zero, and the two classes then fall into separate chains with
2654 // empty visits. The state space collapses to its initial state and
2655 // the CTMC, NC and MVA answers are an empty table, not an error.
2656 std::vector<std::vector<T>> diag(K, std::vector<T>(I, zero));
2657 for (std::size_t s = 1; s <= K; ++s) {
2658 if (!Peff.empty() && Peff.find(std::make_pair(s, s)) == Peff.end()) continue;
2659 for (std::size_t j = 1; j <= I; ++j)
2660 diag[s - 1][j - 1] =
2661 Peff.empty() ? get_route(s, s, cs, j) : eff_at(s, s)(cs - 1, j - 1);
2662 }
2663 // Zero row cs only in blocks that exist: an absent block reads as zero, and eff_at on all K^2 pairs materialised them
2664 for (std::size_t r = 1; r <= K; ++r)
2665 for (std::size_t s = 1; s <= K; ++s) {
2666 if (Peff.find(std::make_pair(r, s)) == Peff.end()) continue;
2667 Matrix<T>& B = eff_at(r, s);
2668 for (std::size_t j = 1; j <= I; ++j) B(cs - 1, j - 1) = zero;
2669 }
2670 for (std::size_t r = 1; r <= K; ++r)
2671 for (std::size_t s = 1; s <= K; ++s) {
2672 if (!(C(r - 1, s - 1) > zero)) continue;
2673 Matrix<T>& B = eff_at(r, s);
2674 for (std::size_t j = 1; j <= I; ++j)
2675 if (diag[s - 1][j - 1] > zero)
2676 B(cs - 1, j - 1) = T(diag[s - 1][j - 1] * C(r - 1, s - 1));
2677 }
2678 }
2679
2680 // Cache: the read (input) class self-switches at the cache node to the
2681 // hit and the miss class, which then follow their own routing. The
2682 // reference leaves the split unresolved (NaN) in the base struct and the
2683 // solver decides it; for the visit equations that back the offered
2684 // arrival rate and residence time it resolves to a uniform 1/2 - 1/2,
2685 // which is what makes ArvR the OFFERED rate (Tput is the carried one the
2686 // cacheqn decomposition produces). A half of the read mass reaching each
2687 // of hit/miss is a same-node class switch, hence a self-loop edge.
2688 const T half = T(num_traits<T>::from_int(1) / num_traits<T>::from_int(2));
2689 for (const auto& kv : nodeparam) {
2690 const std::size_t ci = kv.first; // 1-based cache node
2691 const CacheParam<T>& cp = kv.second;
2692 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
2693 if (cp.hitclass[r] == 0) continue;
2694 // clear the read class's own outgoing routing at the cache
2695 for (std::size_t s = 1; s <= K; ++s) {
2696 Matrix<T>& B = eff_at(r + 1, s);
2697 for (std::size_t j = 1; j <= I; ++j) B(ci - 1, j - 1) = zero;
2698 }
2699 eff_at(r + 1, cp.hitclass[r])(ci - 1, ci - 1) = half;
2700 if (r < cp.missclass.size() && cp.missclass[r] != 0)
2701 eff_at(r + 1, cp.missclass[r])(ci - 1, ci - 1) = half;
2702 }
2703 }
2704 }
2705
2706 /**
2707 * Port of MNetwork.refreshCapacity.
2708 *
2709 * `classcap(i,r)` is the population of r's CHAIN, cut down by any explicit
2710 * per-class or per-station buffer, and 0 where the class does not visit the
2711 * station; `cap(i)` is the explicit station buffer when there is one, and
2712 * otherwise the smaller of the chain and class sums.
2713 *
2714 * `chaincap` IS K COLUMNS WIDE, NOT nchains, exactly as the reference sizes
2715 * it (`chaincap = Inf*ones(M,K)`). The last class of a chain wins the write,
2716 * so a chain with a class disabled at station i can leave chaincap(i,c) at
2717 * 0; under class switching, where nchains < K, the untouched columns stay
2718 * Inf and carry the sum, which is what stops that 0 from capping a station
2719 * that holds the whole chain population at nothing.
2720 *
2721 * The derived drop rule follows the reference exactly, including that WAITQ
2722 * means "never consulted" at an unbounded station and "unsettled" for a
2723 * closed class at a bounded one: only an OPEN class at a real finite buffer
2724 * gets DROP.
2725 *
2726 * A PLACE IS EXEMPT FROM THE ZEROING. `disabled` is this port's marker for
2727 * the reference's `isnan(sn.rates(i,r))`, and a Place holds a marking
2728 * rather than serving, so it has no service process and every class reads
2729 * as disabled there. Zeroing it leaves a token container that cannot hold a
2730 * token: `cap` and `classcap` both come out 0, JMT is handed a Storage
2731 * section of capacity 0, and the net is dead on arrival. `refreshCapacity.m`
2732 * carries the same `~= NodeType.Place` guard on its `isnan` test.
2733 */
2734 /**
2735 * The number of siblings the fork-join pair ending at Join node `joinNode`
2736 * (1-based) emits per parent job: the matched Fork's out-degree times its
2737 * tasksPerLink, with the Join's in-degree as the fallback when no Fork is
2738 * matched. Port of `matlab/src/api/fj/sn_join_siblings.m`.
2739 *
2740 * Read off the DECLARED routing `P` rather than off `rtnodes`, because
2741 * `refresh_capacity` asks this question and runs BEFORE `refresh_rt`. The
2742 * two differ only on a declared link that carries no mass, which forks no
2743 * sibling either.
2744 */
2745 std::size_t join_siblings(std::size_t joinNode, std::size_t r = 0) const {
2746 const std::size_t I = nodes.size();
2747 if (joinNode == 0 || joinNode > I) return 0;
2748 std::size_t forkNode = 0;
2749 for (std::size_t a = 0; a < fj.size(); ++a)
2750 if (fj[a].second == joinNode) {
2751 forkNode = fj[a].first;
2752 break;
2753 }
2754 const T zero = num_traits<T>::from_int(0);
2755 std::vector<bool> seen(I + 1, false);
2756 std::size_t deg = 0;
2757 for (typename std::map<std::pair<std::size_t, std::size_t>, Matrix<T> >::const_iterator
2758 it = P.begin();
2759 it != P.end(); ++it) {
2760 if (it->second.rows() < I || it->second.cols() < I) continue;
2761 for (std::size_t b = 1; b <= I; ++b) {
2762 if (seen[b]) continue;
2763 const T v = (forkNode != 0) ? it->second(forkNode - 1, b - 1)
2764 : it->second(b - 1, joinNode - 1);
2765 if (v > zero) {
2766 seen[b] = true;
2767 ++deg;
2768 }
2769 }
2770 }
2771 if (forkNode == 0 || forkNode > I) return deg;
2772
2773 // THE COUNT IS PER LINK when the fork carries a VARIABLE FORKING LEVEL.
2774 // `fan_out_link(d,r)` is the expected tasks towards destination d for
2775 // class r (the DISTRIBUTION case stores its mean there) and
2776 // `fan_out_prob(d,r)` whether the branch fires at all, so the expected
2777 // sibling count is sum_d fan_out_prob(d,r)*fan_out_link(d,r). A fork
2778 // with no override has no `forkparam` entry, which is how the classic
2779 // out-degree times `tasks_per_link` case is told apart.
2780 const ForkParam<T>* fp = fork_param_of(forkNode);
2781 if (fp != 0 && fp->fan_out_link.rows() > 0) {
2782 const Matrix<T>& fol = fp->fan_out_link;
2783 const bool haveProb = fp->fan_out_prob.rows() == fol.rows() &&
2784 fp->fan_out_prob.cols() == fol.cols();
2785 const std::size_t lo = (r != 0 && r <= fol.cols()) ? r - 1 : 0;
2786 const std::size_t hi = (r != 0 && r <= fol.cols()) ? r - 1 : fol.cols() - 1;
2787 double best = 0.0;
2788 for (std::size_t c = lo; c <= hi && c < fol.cols(); ++c) {
2789 double acc = 0.0;
2790 for (std::size_t d = 0; d < fol.rows(); ++d) {
2791 const double link = num_traits<T>::to_double(fol(d, c));
2792 if (!(link > 0.0)) continue;
2793 acc += link * (haveProb ? num_traits<T>::to_double(fp->fan_out_prob(d, c))
2794 : 1.0);
2795 }
2796 if (acc > best) best = acc;
2797 }
2798 if (best > 0.0) return static_cast<std::size_t>(best + 0.5);
2799 }
2800
2801 double w = nodes[forkNode - 1].tasks_per_link;
2802 if (!(w >= 1.0)) w = 1.0;
2803 return deg * static_cast<std::size_t>(w + 0.5);
2804 }
2805
2806 /**
2807 * The 1-based Join nodes that fire on a STRICT quorum, i.e. on FEWER
2808 * siblings than are forked. A model holding one is not
2809 * population-conserving at the sibling level: the join releases the parent
2810 * at the k-th of n siblings and the n-k stragglers stay in their branches,
2811 * so the parent forks again while they are still in flight.
2812 */
2813 std::vector<std::size_t> quorum_joins() const {
2814 std::vector<std::size_t> out;
2815 for (typename std::map<std::size_t, JoinDecl>::const_iterator it = joindecl.begin();
2816 it != joindecl.end(); ++it) {
2817 if (it->second.strategy != lang::JoinStrategy::PARTIAL) continue;
2818 if (it->second.quorum <= 0.0) continue;
2819 const std::size_t n = join_siblings(it->first);
2820 const std::size_t k = static_cast<std::size_t>(it->second.quorum + 0.5);
2821 if (n == 0 || k < n) out.push_back(it->first);
2822 }
2823 return out;
2824 }
2825
2827 const std::size_t M = stations.size(), K = classes.size();
2828 const double inf = std::numeric_limits<double>::infinity();
2829 cap.assign(M, 0.0);
2830 classcap.assign(M, std::vector<double>(K, inf));
2831 droprule.assign(M, std::vector<DropStrategy>(K, DropStrategy::WAITQ));
2832 std::vector<std::vector<double>> chaincap(M, std::vector<double>(std::max(nchains, K), inf));
2833
2834 // A class routed into a QUORUM Join is not population-conserving: the
2835 // stragglers of an already-fired parent are still in their branches
2836 // when it forks again, and nothing bounds that backlog, so a branch
2837 // station holds no more than the class population only under a STANDARD
2838 // join. Capping it at the chain population makes the engine drop a
2839 // closed job. see _kb/05-solvers-overview.md
2840 std::vector<char> quorum_class(K + 1, 0);
2841 {
2842 const std::vector<std::size_t> qj = quorum_joins();
2843 const T zero = num_traits<T>::from_int(0);
2844 for (std::size_t x = 0; x < qj.size(); ++x)
2845 for (typename std::map<std::pair<std::size_t, std::size_t>,
2846 Matrix<T> >::const_iterator it = P.begin();
2847 it != P.end(); ++it) {
2848 if (it->second.rows() < nodes.size() || qj[x] > nodes.size()) continue;
2849 for (std::size_t a = 1; a <= nodes.size(); ++a)
2850 if (it->second(a - 1, qj[x] - 1) > zero) {
2851 if (it->first.first <= K) quorum_class[it->first.first] = 1;
2852 if (it->first.second <= K) quorum_class[it->first.second] = 1;
2853 }
2854 }
2855 }
2856
2857 // A fork with tasksPerLink = w > 1 puts w tasks of the SAME parent on one
2858 // link, so a branch station can hold w jobs per circulating parent and the
2859 // chain population is no longer its bound. The multiplier is the PRODUCT
2860 // over the forks, because a fork nested in another's branch multiplies
2861 // again; that is an upper bound for forks in series, where a cap that never
2862 // binds costs nothing, and exact for the single-fork case. Without it this
2863 // engine drops a closed job at a branch station.
2864 double fork_task_factor = 1.0;
2865 for (std::size_t i = 0; i < nodes.size(); ++i) {
2866 if (nodes[i].nodetype != NodeType::Fork) continue;
2867 const double w = nodes[i].tasks_per_link;
2868 if (w >= 1.0 && std::isfinite(w)) fork_task_factor *= std::floor(w + 0.5);
2869 }
2870
2871 for (std::size_t c = 0; c < nchains; ++c) {
2872 double chain_cap = 0.0;
2873 bool open = false;
2874 bool quorum = false;
2875 for (std::size_t r : inchain[c]) {
2876 if (std::isinf(classes[r - 1].population)) open = true;
2877 else chain_cap += classes[r - 1].population;
2878 if (r <= K && quorum_class[r]) quorum = true;
2879 }
2880 // A chain holding a spawn TARGET gains a job on every trigger completion, so it is not population-conserving
2881 // either (refreshCapacity.m spawnFedChain); capping it at its population makes the engine drop phase-2 jobs
2882 bool spawn_fed = false;
2883 for (std::size_t q = 0; q < K && !spawn_fed; ++q)
2884 for (std::size_t r : inchain[c])
2885 if (classes[q].spawn == r) spawn_fed = true;
2886 chain_cap *= fork_task_factor;
2887 if (open || quorum || spawn_fed) chain_cap = inf;
2888 for (std::size_t r : inchain[c])
2889 for (std::size_t i = 0; i < M; ++i) {
2890 const Station<T>& st = stations[i];
2891 const bool user_rule = st.droprule.size() >= r && st.droprule[r - 1] != 0;
2892 if (st.nodetype != NodeType::Source) {
2893 const bool cap_finite = st.cap >= 0.0 && !std::isinf(st.cap);
2894 const bool classcap_finite = st.classcap.size() >= r &&
2895 st.classcap[r - 1] > 0.0 &&
2896 !std::isinf(st.classcap[r - 1]);
2897 if (user_rule &&
2898 st.droprule[r - 1] == static_cast<int>(DropStrategy::WAITQ) &&
2899 std::isinf(classes[r - 1].population) &&
2900 (cap_finite || classcap_finite))
2901 throw UnsupportedError(
2902 "station '" + st.name + "' declares setDropRule(WAITQ) for the "
2903 "open class '" + classes[r - 1].name + "' at a finite capacity: "
2904 "LINE does not implement waiting-room blocking for an open "
2905 "arrival at a plain finite buffer. Use DropStrategy.DROP for a "
2906 "loss station, or BAS / BBS / RSRD for blocking between "
2907 "stations");
2908 if (user_rule) {
2909 droprule[i][r - 1] = static_cast<DropStrategy>(st.droprule[r - 1]);
2910 } else if (std::isinf(st.cap)) {
2911 droprule[i][r - 1] = DropStrategy::WAITQ;
2912 } else if (!std::isinf(classes[r - 1].population)) {
2913 droprule[i][r - 1] = DropStrategy::WAITQ;
2914 } else {
2915 droprule[i][r - 1] = DropStrategy::DROP;
2916 }
2917 }
2918 // A Place has no service process, so `disabled` is not absence.
2919 if (disabled[i][r - 1] && st.nodetype != NodeType::Place) {
2920 classcap[i][r - 1] = 0.0;
2921 chaincap[i][c] = 0.0;
2922 continue;
2923 }
2924 chaincap[i][c] = chain_cap;
2925 classcap[i][r - 1] = chain_cap;
2926 if (st.classcap.size() >= r && st.classcap[r - 1] >= 0.0)
2927 classcap[i][r - 1] = std::min(classcap[i][r - 1], st.classcap[r - 1]);
2928 if (st.cap >= 0.0) classcap[i][r - 1] = std::min(classcap[i][r - 1], st.cap);
2929 }
2930 }
2931 for (std::size_t i = 0; i < M; ++i) {
2932 if (stations[i].cap >= 0.0 && !std::isinf(stations[i].cap)) {
2933 cap[i] = stations[i].cap;
2934 continue;
2935 }
2936 double sc = 0.0, scl = 0.0;
2937 for (std::size_t c = 0; c < chaincap[i].size(); ++c) sc += chaincap[i][c];
2938 for (std::size_t r = 0; r < K; ++r) scl += classcap[i][r];
2939 cap[i] = std::min(sc, scl);
2940 }
2941 }
2942
2943 /**
2944 * `sn.rt` and `sn.rtnodes`: the class-expanded routing matrices.
2945 *
2946 * rtnodes is the routing over every node; rt is its stochastic complement
2947 * over the stateful ones, which is the same construction the per-chain
2948 * visits use -- but over ALL classes at once rather than one chain at a
2949 * time, because that is what `sn.rt` means and what solver_qna reads.
2950 */
2951 void refresh_rt() {
2952 const T zero = num_traits<T>::from_int(0);
2953 const std::size_t K = classes.size(), I = nodes.size();
2954 std::vector<std::size_t> all(K);
2955 for (std::size_t r = 0; r < K; ++r) all[r] = r + 1;
2956 rtnodes = Matrix<T>(I * K, I * K, zero);
2957 // route_eff over every entry is (I*K)^2 map finds; the blocks that exist hold every nonzero
2958 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
2959 for (std::size_t a = 0; a < I; ++a)
2960 for (std::size_t b = 0; b < I; ++b) rtnodes(a * K + r - 1, b * K + s - 1) = M(a, b);
2961 });
2962 rt = station_routing(all);
2963 }
2964
2965 /**
2966 * Re-resolve the cache read self-switch from the offered 1/2-1/2 to the
2967 * ACTUAL hit/miss probabilities the cacheqn decomposition converged on, then
2968 * recompute `rt` and the visits. The runner reads ArvR and ResidT off the
2969 * result, matching MATLAB whose `sn.visits` carry the actual
2970 * (setResultHitProb) split, not the offered one. Served metrics are
2971 * unaffected: they come from the analyzer's own over-routed inner solve.
2972 * `hitprob`/`missprob` are (ncaches x nclasses), indexed by cache order and
2973 * input class, exactly as `da_cacheqn` returns them.
2974 */
2975 void refresh_cacheqn_actual_visits(const Matrix<T>& hitprob, const Matrix<T>& missprob) {
2976 const T zero = num_traits<T>::from_int(0);
2977 const std::size_t K = classes.size();
2978 std::size_t cidx = 0;
2979 for (const auto& kv : nodeparam) {
2980 const std::size_t ci = kv.first; // 1-based cache node
2981 const CacheParam<T>& cp = kv.second;
2982 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
2983 if (cp.hitclass[r] == 0) continue;
2984 set_route_effective(r + 1, cp.hitclass[r], ci, ci, hitprob(cidx, r));
2985 if (r < cp.missclass.size() && cp.missclass[r] != 0)
2986 set_route_effective(r + 1, cp.missclass[r], ci, ci, missprob(cidx, r));
2987 }
2988 ++cidx;
2989 }
2990 refresh_rt();
2991 const bool fork = has_fork();
2992 visits.assign(nchains, Matrix<T>(stateful_nodes.size(), nclasses, zero));
2993 nodevisits.assign(nchains, Matrix<T>(nodes.size(), nclasses, zero));
2994 for (std::size_t c = 0; c < nchains; ++c) {
2995 visits[c] = chain_visits(c, stateful_nodes, true, fork);
2996 nodevisits[c] = chain_visits(c, all_nodes(), false, fork);
2997 }
2998 }
2999
3000 /**
3001 * Port of MNetwork.refreshRates: lower each service process onto a rate and
3002 * an SCV. A disabled (station, class) pair becomes NaN, which is the marker
3003 * every downstream consumer keys on to mean "this class never visits here".
3004 */
3006 nstations = stations.size();
3007 nclasses = classes.size();
3008 const T zero = num_traits<T>::from_int(0);
3010 scv = Matrix<T>(nstations, nclasses, zero);
3011 disabled.assign(nstations, std::vector<bool>(nclasses, true));
3012 for (std::size_t i = 0; i < nstations; ++i)
3013 for (std::size_t r = 0; r < nclasses; ++r) {
3014 // Join infinite-rate rationale: see _kb/04-networkstruct.md (cpp port notes)
3015 if (stations[i].nodetype == NodeType::Join) {
3016 disabled[i][r] = false;
3018 std::numeric_limits<double>::infinity());
3019 scv(i, r) = zero;
3020 continue;
3021 }
3022 const Distrib<T>& d = service[i][r];
3023 if (d.disabled) continue;
3024 disabled[i][r] = false;
3025 // A MARKED class receives ONLY ITS MARK'S stream, so its rate is
3026 // the mark's, not the shared process's. `set_marked_arrival`
3027 // binds one `Distrib` to every marked class, so `d.rate()` is
3028 // `1/mean` of the AGGREGATE and reports the whole stream K times
3029 // over -- 2.5 apiece on `cache_mmap_rr_env` against 1.9 and 0.6.
3030 const std::size_t mk = markidx_of(i + 1, r + 1);
3031 if (mk > 0 && mk <= d.Dmark.size()) {
3032 const std::pair<T, T> ms = mark_marginal_moments(d, mk);
3033 rates(i, r) = num_traits<T>::to_double(ms.first) > 0
3034 ? T(num_traits<T>::from_int(1) / ms.first)
3036 scv(i, r) = ms.second;
3037 continue;
3038 }
3039 rates(i, r) = d.rate();
3040 scv(i, r) = d.scv;
3041 }
3042 }
3043
3044 /**
3045 * Mean and SCV of ONE MARK'S MARGINAL MAP, `MarkedMAP.toMAPs(k)`.
3046 *
3047 * The marginal moves the OTHER marks' arrivals into the hidden part:
3048 * `D0' = D0 + (D1 - D1k)`, `D1' = D1k` (`MarkedMAP.m:130-140`,
3049 * `refreshRates.m:56-63`). It is what a consumer that sees only class k
3050 * experiences, and it is NOT the aggregate restricted -- an arrival of
3051 * another mark still moves the shared chain, it just is not an arrival here.
3052 *
3053 * `D0' + D1' = D0 + D1`, so the marginal keeps the aggregate's phase
3054 * generator and its rate reduces to `pi D1k e`; only the SCV needs the
3055 * second moment, which is why this goes through `map_scv` rather than
3056 * stopping at the rate.
3057 */
3058 std::pair<T, T> mark_marginal_moments(const Distrib<T>& d, std::size_t mk) const {
3059 mam::Map<T> m;
3060 m.D0 = d.D0;
3061 m.D1 = d.Dmark[mk - 1];
3062 for (std::size_t a = 0; a < m.D0.rows(); ++a)
3063 for (std::size_t b = 0; b < m.D0.cols(); ++b)
3064 m.D0(a, b) += T(d.D1(a, b) - m.D1(a, b));
3065 return std::make_pair(mam::map_mean(m), mam::map_scv(m));
3066 }
3067
3068 /**
3069 * Port of MNetwork.refreshChains followed by sn_refresh_visits.
3070 *
3071 * The class-switch mask is (r == s) or "some link carries r into s", which
3072 * is what link() records in csMatrix and what refreshChains then re-derives
3073 * from `rt`; the chains are the connected components of that mask read as
3074 * an undirected graph. MATLAB orders the chains by `sortrows(...,'descend')`
3075 * on the indicator rows, which for disjoint components is the same as
3076 * ordering by their smallest class index, and that is what is done here.
3077 */
3079 refresh_rates();
3080 const T zero = num_traits<T>::from_int(0);
3081 const std::size_t K = nclasses;
3082
3083 // class-switch mask rationale: see _kb/04-networkstruct.md (cpp port notes)
3084 std::vector<std::vector<bool>> cs(K, std::vector<bool>(K, false));
3085 for (std::size_t r = 0; r < K; ++r) cs[r][r] = true;
3086 for (const auto& kv : (Peff.empty() ? P : Peff)) {
3087 bool any = false;
3088 for (std::size_t a = 0; a < kv.second.rows() && !any; ++a)
3089 for (std::size_t b = 0; b < kv.second.cols() && !any; ++b)
3090 if (kv.second(a, b) > zero) any = true;
3091 if (any) cs[kv.first.first - 1][kv.first.second - 1] = true;
3092 }
3093 // A ClassSwitch NODE couples classes too, and once `link()` synthesizes
3094 // one the coupling lives ONLY here: the rewrite folds `P{r,s}(i,j)` into
3095 // a same-class pair of legs, so the r != s block that used to carry it
3096 // is gone from P. `link.m` accumulates the same thing --
3097 // `csMatrix = csMatrix | nodes{ind}.server.csMatrix > 0` -- and without
3098 // it every switched class falls into its own chain.
3099 for (const auto& kv : csmatrix) {
3100 const Matrix<T>& C = kv.second;
3101 for (std::size_t r = 0; r < K && r < C.rows(); ++r)
3102 for (std::size_t s = 0; s < K && s < C.cols(); ++s)
3103 if (C(r, s) > zero) cs[r][s] = true;
3104 }
3105 // A Cache node couples each input class with the classes it switches to
3106 // on a hit and a miss; that coupling lives in nodeparam, not in P, so it
3107 // must be added here or the read/hit/miss classes fall into separate
3108 // chains and the switched (open) classes get no arrivals.
3109 for (const auto& kv : nodeparam) {
3110 const CacheParam<T>& cp = kv.second;
3111 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
3112 if (cp.hitclass[r] != 0) cs[r][cp.hitclass[r] - 1] = true;
3113 if (r < cp.missclass.size() && cp.missclass[r] != 0)
3114 cs[r][cp.missclass[r] - 1] = true;
3115 }
3116 // A RETRIEVAL class is also a class the cache switches the read
3117 // class into on a miss -- it just lives in `retrieval_classes`
3118 // rather than in `missclass`, because there is one per ITEM. The
3119 // coupling above missed them, so on a closed delayed-hit model each
3120 // retrieval class formed its own singleton chain: 4 chains where
3121 // MATLAB has 1, with zero visits at the fetch station and the whole
3122 // population parked at the delay. The rule in the comment above
3123 // applies to them unchanged.
3124 for (const std::vector<std::size_t>& row : cp.retrieval_classes)
3125 for (std::size_t r = 0; r < row.size() && r < K; ++r)
3126 if (row[r] != 0) cs[r][row[r] - 1] = true;
3127 }
3128 std::vector<std::size_t> comp(K, K);
3129 std::size_t ncomp = 0;
3130 for (std::size_t r = 0; r < K; ++r) {
3131 if (comp[r] != K) continue;
3132 std::vector<std::size_t> stack{r};
3133 comp[r] = ncomp;
3134 while (!stack.empty()) {
3135 const std::size_t v = stack.back();
3136 stack.pop_back();
3137 for (std::size_t w = 0; w < K; ++w)
3138 if (comp[w] == K && (cs[v][w] || cs[w][v])) {
3139 comp[w] = ncomp;
3140 stack.push_back(w);
3141 }
3142 }
3143 ++ncomp;
3144 }
3145 // components are already discovered in order of their smallest member,
3146 // which is the order sortrows(...,'descend') produces
3147 nchains = ncomp;
3148 chains.assign(nchains, std::vector<bool>(K, false));
3149 inchain.assign(nchains, {});
3150 for (std::size_t r = 0; r < K; ++r) {
3151 chains[comp[r]][r] = true;
3152 inchain[comp[r]].push_back(r + 1);
3153 }
3154
3155 // ---- reference class per chain -------------------------------------
3156 refclass.assign(nchains, 0);
3157 for (std::size_t c = 0; c < nchains; ++c)
3158 for (std::size_t k : inchain[c])
3159 if (classes[k - 1].is_ref_class) refclass[c] = k;
3160
3161 for (std::size_t c = 0; c < nchains; ++c) {
3162 const std::size_t rs = classes[inchain[c][0] - 1].refstat;
3163 for (std::size_t k : inchain[c])
3164 if (classes[k - 1].refstat != rs)
3165 throw InputError("network '" + name + "': classes within a chain have different "
3166 "reference stations");
3167 }
3168
3170
3171 // ---- visits, at stateful-node and at node level ---------------------
3172 const bool fork = has_fork();
3173 visits.assign(nchains, Matrix<T>(stateful_nodes.size(), nclasses, zero));
3174 nodevisits.assign(nchains, Matrix<T>(nodes.size(), nclasses, zero));
3175 for (std::size_t c = 0; c < nchains; ++c) {
3176 visits[c] = chain_visits(c, stateful_nodes, true, fork);
3177 nodevisits[c] = chain_visits(c, all_nodes(), false, fork);
3178 }
3179 }
3180
3181 /**
3182 * Route every open chain from the Sink back into the Source, as the tail of
3183 * MATLAB's getRoutingMatrix does before it takes the stochastic complement.
3184 *
3185 * WHY IT IS NOT OPTIONAL. `visits` is the stationary vector of the chain's
3186 * routing DTMC. An open chain that ends at the Sink has no such vector --
3187 * the Sink is absorbing, so the solve returns the point mass there and
3188 * every station comes out with zero visits. The closing arc turns the open
3189 * chain into a recurrent one whose stationary vector, renormalised by the
3190 * Source, is the visit count PER ARRIVAL, which is what the chain demands
3191 * are built from.
3192 *
3193 * The destination class is drawn in proportion to the ARRIVAL RATES of the
3194 * chain's classes, so a job leaving the Sink re-enters as the class the
3195 * Source would have generated. A chain whose arrivals are all disabled has
3196 * no such proportion, and the reference falls back to the uniform choice
3197 * rather than dividing by zero -- those chains carry no traffic and only
3198 * need to stay well posed.
3199 *
3200 * The arcs are rebuilt on every refresh, and the previous ones cleared
3201 * first: the rates move between passes of the fork-join fixed point, and a
3202 * stale arc would leave two closures with different weights in place.
3203 */
3205 if (sourceIdx == 0 || sinkNode == 0) return;
3206 const T zero = num_traits<T>::from_int(0);
3207 const T one = num_traits<T>::from_int(1);
3208 const std::size_t src = station_to_node[sourceIdx - 1];
3209 // closure-clearing rationale: see _kb/04-networkstruct.md (cpp port notes)
3210 for (auto& kv : P) {
3211 if (kv.second.rows() < nodes.size()) continue;
3212 kv.second(sinkNode - 1, src - 1) = zero;
3213 }
3214 for (auto& kv : Peff) {
3215 if (kv.second.rows() < nodes.size()) continue;
3216 kv.second(sinkNode - 1, src - 1) = zero;
3217 }
3218 for (std::size_t c = 0; c < nchains; ++c) {
3219 bool open = false;
3220 for (std::size_t k : inchain[c])
3221 if (std::isinf(classes[k - 1].population)) open = true;
3222 if (!open) continue;
3223 T tot = zero;
3224 for (std::size_t k : inchain[c])
3225 if (!disabled[sourceIdx - 1][k - 1]) tot += rates(sourceIdx - 1, k - 1);
3226 const T uniform =
3227 T(one / num_traits<T>::from_int(static_cast<long>(inchain[c].size())));
3228 for (std::size_t s : inchain[c]) {
3229 T p = uniform;
3230 if (tot > zero) {
3231 const T ar =
3232 disabled[sourceIdx - 1][s - 1] ? zero : rates(sourceIdx - 1, s - 1);
3233 p = T(ar / tot);
3234 }
3235 if (!(p > zero)) continue;
3236 for (std::size_t r : inchain[c]) set_route_effective(r, s, sinkNode, src, p);
3237 }
3238 }
3239 }
3240
3241 /** Every node index, 1-based, for the node-level visit computation. */
3242 std::vector<std::size_t> all_nodes() const {
3243 std::vector<std::size_t> v(nodes.size());
3244 for (std::size_t i = 0; i < nodes.size(); ++i) v[i] = i + 1;
3245 return v;
3246 }
3247
3248 /**
3249 * Port of the per-chain body of sn_refresh_visits, over an arbitrary node
3250 * subset -- the stateful nodes for `visits`, all nodes for `nodevisits`.
3251 *
3252 * The FORK CORRECTIONS are the reference's, and they are blunt on purpose.
3253 * A Fork row sums to its fan-out rather than to one, so the rows are
3254 * renormalised before the DTMC solve; afterwards, rather than trusting the
3255 * resulting stationary vector, a population-preserving SPN argument sets
3256 * EVERY visited entry to 1 (and a Join entry to its in-degree, one per
3257 * incoming branch). Reproduced exactly: the corrected visits are what the
3258 * chain demands are built from, so an "improved" version would disagree
3259 * with every other codebase.
3260 */
3261 Matrix<T> chain_visits(std::size_t c, const std::vector<std::size_t>& sel, bool complement,
3262 bool fork) const {
3263 const T zero = num_traits<T>::from_int(0);
3264 const std::vector<std::size_t>& ic = inchain[c];
3265 const std::size_t nIC = ic.size();
3266 const std::size_t dim = sel.size() * nIC;
3267 Matrix<T> Pc(dim, dim, zero);
3268 if (complement) {
3269 const Matrix<T> rt = station_routing(ic);
3270 Pc = rt;
3271 } else {
3272 // Block-wise over the routing map: route_eff per entry was dim^2 map finds per chain
3273 std::vector<std::size_t> cpos(nclasses + 1, nIC);
3274 for (std::size_t x = 0; x < nIC; ++x) cpos[ic[x]] = x;
3275 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
3276 if (r > nclasses || s > nclasses) return;
3277 const std::size_t x = cpos[r], y = cpos[s];
3278 if (x == nIC || y == nIC) return;
3279 for (std::size_t a = 0; a < sel.size(); ++a)
3280 for (std::size_t b = 0; b < sel.size(); ++b)
3281 Pc(a * nIC + x, b * nIC + y) = M(sel[a] - 1, sel[b] - 1);
3282 });
3283 }
3284
3285 // THE `served` MASK of sn_refresh_visits.m, ON BOTH CHAINS.
3286 // `getRoutingMatrix` leaves a JMT-oriented UNIFORM FILL on (station,
3287 // class) pairs the class has no service at, and those states are not
3288 // reachable: a class with no service law cannot be served there. Left
3289 // in, they leak mass between what are otherwise separate recurrent
3290 // branches -- on cs_transient_class that moved Queue1 from 0.1875 to
3291 // 0.22917 and Queue2 from 0.3125 to 0.27083, against an absorption of
3292 // 0.375/0.625 halved over each two-station cycle.
3293 //
3294 // It matters MORE on the node chain, which the reference used to leave
3295 // untouched: at station level an unserved (station,class) is a dead end,
3296 // while the node kernel keeps the class-switch nodes between the
3297 // stations, so the disabled states close into a whole spurious CYCLE. A
3298 // materialised LQN replica is exactly that -- replica 2's stations still
3299 // carry replica 1's classes in rtnodes -- and the reducible solve then
3300 // splits the mass between the real chain and the phantom one, giving
3301 // every node of replica 2 a visit in replica 1's classes.
3302 //
3303 // A Place, a Transition and a station declaring heterogeneous server
3304 // types are EXEMPT, exactly as the reference exempts them: all three
3305 // carry NaN station rates by construction, so the test would read as
3306 // "not served" for a station that plainly is.
3307 std::vector<bool> served(dim, true);
3308 {
3309 for (std::size_t a = 0; a < sel.size(); ++a) {
3310 const std::size_t sti = nodes[sel[a] - 1].station;
3311 if (sti == 0 || sti > stations.size()) continue;
3312 const NodeType nt = stations[sti - 1].nodetype;
3313 if (nt == NodeType::Place || nt == NodeType::Transition) continue;
3314 if (!stations[sti - 1].server_types.empty()) continue;
3315 if (sti > disabled.size()) continue;
3316 // The reference tests `isnan(sn.rates(...))`; THIS PORT'S MARKER
3317 // IS `disabled`. refresh_rates leaves a disabled pair's rate at
3318 // 0 rather than NaN (its comment says otherwise and is stale),
3319 // so an isnan test here would never fire and the mask would be
3320 // silently inert. `disabled` is what every other consumer keys
3321 // on and it carries exactly MATLAB's meaning.
3322 for (std::size_t x = 0; x < nIC; ++x)
3323 if (ic[x] <= disabled[sti - 1].size() && disabled[sti - 1][ic[x] - 1])
3324 served[a * nIC + x] = false;
3325 }
3326 for (std::size_t row = 0; row < dim; ++row)
3327 if (!served[row])
3328 for (std::size_t col = 0; col < dim; ++col) Pc(row, col) = zero;
3329 for (std::size_t col = 0; col < dim; ++col)
3330 if (!served[col])
3331 for (std::size_t row = 0; row < dim; ++row) Pc(row, col) = zero;
3332 }
3333
3334 std::vector<std::size_t> visited;
3335 std::vector<T> rowsum(dim, zero);
3336 for (std::size_t row = 0; row < dim; ++row) {
3337 T s = zero;
3338 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3339 rowsum[row] = s;
3340 if (s > zero) visited.push_back(row);
3341 }
3342 bool oversum = false;
3343 if (fork) {
3344 for (std::size_t row = 0; row < dim; ++row) {
3345 if (num_traits<T>::to_double(rowsum[row]) > 1.0 + GlobalConstants::FineTol)
3346 oversum = true;
3348 for (std::size_t col = 0; col < dim; ++col)
3349 Pc(row, col) = T(Pc(row, col) / rowsum[row]);
3350 }
3351 }
3352
3353 // Detect a genuinely reducible chain up front, so its DISABLED-routing
3354 // fillers and the reducible solver apply ONLY here -- every irreducible
3355 // chain, i.e. the whole existing test surface, stays on the exact
3356 // dtmc_solve path below. In MATLAB the singular normalization solve
3357 // returns NaN here, tripping the same fallback; this reproduces that
3358 // without changing the shared solver.
3359 //
3360 // "Genuinely reducible" is more than one recurrent class AND all of them
3361 // inside ONE weak component, which is the shape MATLAB's solve actually
3362 // fails on. `ctmc_solve` (MATLAB's, the JAR's and this port's alike)
3363 // SPLITS ON WEAK COMPONENTS first, solves each on its own and
3364 // renormalizes the union, so a chain whose recurrent classes sit in
3365 // SEPARATE weak components has an answer there, and an exact one: each
3366 // component gets equal weight.
3367 //
3368 // A delayed-hit retrieval cache is exactly that second shape. Its
3369 // per-item fetch cycle (cache -> retrieval station -> cache, in that
3370 // item's retrieval class) shares no state with the read class's own
3371 // loop, because the class switch that feeds it happens INSIDE the cache
3372 // node rather than on a routing arc. Calling that reducible sends it to
3373 // `dtmc_solve_reducible`, which hands the read loop all the mass and
3374 // leaves every retrieval station with ZERO visits -- and a station with
3375 // no visits is given no capacity by `space_capacity_c`, so the CTMC then
3376 // enumerates a state space with no in-flight fetch in it at all and
3377 // reports NaN hit/miss shares. Measured on retrieval_simple before this
3378 // qualifier: 12 states where MATLAB, the JAR and native Python have 144.
3379 bool reducible = false;
3380 if (!fork && !visited.empty()) {
3381 Matrix<T> Pv0(visited.size(), visited.size(), zero);
3382 for (std::size_t a = 0; a < visited.size(); ++a)
3383 for (std::size_t b = 0; b < visited.size(); ++b) Pv0(a, b) = Pc(visited[a], visited[b]);
3384 const mc::SccResult scc = mc::stronglyconncomp(Pv0);
3385 std::size_t nrec = 0;
3386 for (bool rc : scc.recurrent)
3387 if (rc) ++nrec;
3388 reducible = (nrec > 1) && mc::detail::weak_components(Pv0).size() == 1;
3389 }
3390 if (reducible) {
3391 // DISABLED-routing filler (getRoutingMatrix.m DISABLED case): a
3392 // (node,class) with no outgoing routing at a physically connected node
3393 // routes SAME-CLASS to each neighbour at 1/nconn. These 0-visit
3394 // transient states give the reducible solver the multiple transient
3395 // SCCs MATLAB has, so it weights the recurrent classes correctly
3396 // (11:13) instead of the single-transient absorption split (3:5).
3397 std::vector<std::size_t> nconn(sel.size(), 0);
3398 std::vector<std::vector<bool>> conn(sel.size(), std::vector<bool>(sel.size(), false));
3399 for (std::size_t a = 0; a < sel.size(); ++a)
3400 for (std::size_t b = 0; b < sel.size(); ++b) {
3401 bool cbit = false;
3402 for (std::size_t x = 0; x < nIC && !cbit; ++x)
3403 for (std::size_t y = 0; y < nIC && !cbit; ++y)
3404 if (Pc(a * nIC + x, b * nIC + y) > zero) cbit = true;
3405 conn[a][b] = cbit;
3406 if (cbit) ++nconn[a];
3407 }
3408 for (std::size_t a = 0; a < sel.size(); ++a) {
3409 if (nconn[a] == 0) continue;
3410 for (std::size_t x = 0; x < nIC; ++x) {
3411 T outsum = zero;
3412 for (std::size_t col = 0; col < dim; ++col) outsum += Pc(a * nIC + x, col);
3413 if (outsum > zero) continue; // class already routes onward here
3414 const T p = num_traits<T>::from_int(1) /
3415 num_traits<T>::from_int(static_cast<long>(nconn[a]));
3416 for (std::size_t b = 0; b < sel.size(); ++b)
3417 if (conn[a][b]) Pc(a * nIC + x, b * nIC + x) = p;
3418 }
3419 }
3420 // AND THE FILLER IS MASKED AGAIN, which is the whole point of the
3421 // mask. The reference's order is: getRoutingMatrix lays the fill
3422 // down, THEN `served` zeroes those rows and columns -- so an
3423 // unserved state never carries fill in the matrix that is solved.
3424 // Synthesising the fill here and stopping would restore exactly what
3425 // the mask was ported to remove: on cs_transient_class the two
3426 // recurrent branches came out 11:13 (0.22917 / 0.27083) instead of
3427 // the absorption split 3:5 (0.1875 / 0.3125) that MATLAB, Java and
3428 // Python all report and that the routing implies.
3429 for (std::size_t row = 0; row < dim; ++row)
3430 if (!served[row])
3431 for (std::size_t col = 0; col < dim; ++col) Pc(row, col) = zero;
3432 for (std::size_t col = 0; col < dim; ++col)
3433 if (!served[col])
3434 for (std::size_t row = 0; row < dim; ++row) Pc(row, col) = zero;
3435 visited.clear();
3436 for (std::size_t row = 0; row < dim; ++row) {
3437 T s = zero;
3438 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3439 if (s > zero) visited.push_back(row);
3440 }
3441 }
3442
3443 std::vector<T> alpha(dim, zero);
3444 if (!visited.empty()) {
3445 Matrix<T> Pv(visited.size(), visited.size(), zero);
3446 for (std::size_t a = 0; a < visited.size(); ++a)
3447 for (std::size_t b = 0; b < visited.size(); ++b)
3448 Pv(a, b) = Pc(visited[a], visited[b]);
3449 std::vector<T> av;
3450 bool ok = false;
3451 if (!reducible) {
3452 try {
3453 av = mc::dtmc_solve(Pv);
3454 ok = true;
3455 bool allzero = true, hasnan = false;
3456 for (const T& x : av) {
3457 if (x != zero) allzero = false;
3458 if (num_traits<T>::to_double(x) != num_traits<T>::to_double(x)) hasnan = true;
3459 }
3460 if (allzero || hasnan) ok = false;
3461 } catch (const Error&) {
3462 ok = false;
3463 }
3464 }
3465 if (!ok) {
3466 // DTMC solver order: see _kb/04-networkstruct.md (cpp port notes) and _kb/11-conventions-and-gotchas.md
3468 }
3469 for (std::size_t a = 0; a < visited.size(); ++a) alpha[visited[a]] = av[a];
3470
3471 // A TRANSIENT STATE HAS VISIT RATIO EXACTLY ZERO, and saying so by
3472 // the graph rather than by a tolerance is what keeps this port on
3473 // the reference's answer. `pi P = pi` puts all of its mass on the
3474 // recurrent classes, so a state whose SCC has an edge leaving it
3475 // carries none; whether the LU lands on 0 or on an eps residue is a
3476 // pivot-order accident, and MATLAB's backslash happens to land on 0
3477 // where this LU lands on 3e-17.
3478 //
3479 // That accident is not cosmetic downstream. RN is QN/TN, so a class
3480 // with an eps visit at a station reads 0/0 -- NaN in the reference,
3481 // which the H-T order statistic DROPS, and the station's full
3482 // residence time here, which it COUNTS. On the two-span H-T model
3483 // that handed Delay1 and the other fork's auxiliary delay a
3484 // residence time of 2 each for classes that never reach either, and
3485 // `ri` came out 5.747 against the reference's 1.747 -- exactly the
3486 // 2 + 2 those two rows contribute.
3487 {
3488 const mc::SccResult scc = mc::stronglyconncomp(Pv);
3489 for (std::size_t a = 0; a < visited.size(); ++a)
3490 if (!scc.recurrent[scc.scc[a] - 1]) alpha[visited[a]] = zero;
3491 }
3492 }
3493
3494 if (fork && oversum) {
3495 for (std::size_t idx = 0; idx < dim; ++idx) {
3496 if (!(num_traits<T>::to_double(alpha[idx]) > GlobalConstants::FineTol)) continue;
3497 const std::size_t nd = sel[idx / nIC];
3498 if (!complement && nodes[nd - 1].nodetype == NodeType::Join) {
3499 // a Join is entered once per incoming branch. The reference
3500 // counts POSITIVE ROWS of rtnodes in column (nd,r), i.e.
3501 // (source node, source class) PAIRS over every class -- two
3502 // classes entering the Join as r from the same predecessor
3503 // are two branches, so neither the break nor the restriction
3504 // to the chain's own classes belongs here.
3505 const std::size_t r = ic[idx % nIC];
3506 std::size_t nsrc = 0;
3507 for (std::size_t src = 1; src <= nodes.size(); ++src)
3508 for (std::size_t q = 1; q <= nclasses; ++q)
3509 if (num_traits<T>::to_double(route_eff(q, r, src, nd)) >
3511 ++nsrc;
3512 alpha[idx] = num_traits<T>::from_int(static_cast<long>(nsrc));
3513 } else {
3514 alpha[idx] = num_traits<T>::from_int(1);
3515 }
3516 }
3517 }
3518
3519 Matrix<T> out(sel.size(), nclasses, zero);
3520 for (std::size_t a = 0; a < sel.size(); ++a)
3521 for (std::size_t x = 0; x < nIC; ++x) out(a, ic[x] - 1) = alpha[a * nIC + x];
3522
3523 // normalise by the total visits of the chain at its reference node
3524 const std::size_t rstat = classes[ic[0] - 1].refstat;
3525 const std::size_t refnode = station_to_node[rstat - 1];
3526 std::size_t refrow = sel.size();
3527 for (std::size_t a = 0; a < sel.size(); ++a)
3528 if (sel[a] == refnode) refrow = a;
3529 if (refrow < sel.size()) {
3530 T norm = zero;
3531 for (std::size_t x = 0; x < nIC; ++x) norm += out(refrow, ic[x] - 1);
3533 for (std::size_t a = 0; a < sel.size(); ++a)
3534 for (std::size_t x = 0; x < nIC; ++x)
3535 out(a, ic[x] - 1) = T(out(a, ic[x] - 1) / norm);
3536 }
3537 for (std::size_t a = 0; a < sel.size(); ++a)
3538 for (std::size_t x = 0; x < nIC; ++x)
3539 if (out(a, ic[x] - 1) < zero) out(a, ic[x] - 1) = T(-out(a, ic[x] - 1));
3540 return out;
3541 }
3542
3543 /**
3544 * The visit ratios of one chain from an already-formed chain routing block
3545 * `Pc` (dim = sel.size() * nIC), the no-fork body of sn_refresh_visits: solve
3546 * the embedded DTMC over the visited states and normalise by the total visits
3547 * at the reference node. NaN entries (a Cache class switch can leave one) are
3548 * given the row's residual probability spread uniformly, as the reference
3549 * does. Fork models keep the route_eff path (`chain_visits`); this is used by
3550 * the cacheqn driver, which rewrites `rtnodes` directly and has no fork.
3551 */
3552 Matrix<T> visits_from_block(const Matrix<T>& Pc_in, const std::vector<std::size_t>& sel,
3553 const std::vector<std::size_t>& ic, std::size_t refnode) const {
3554 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
3555 const std::size_t nIC = ic.size();
3556 const std::size_t dim = Pc_in.rows();
3557 Matrix<T> Pc = Pc_in;
3558 // NaN handling: distribute the row's residual mass over the NaN columns.
3559 for (std::size_t row = 0; row < dim; ++row) {
3560 T nonnan = zero;
3561 std::size_t nnan = 0;
3562 for (std::size_t col = 0; col < dim; ++col) {
3563 const double v = num_traits<T>::to_double(Pc(row, col));
3564 if (v != v) ++nnan;
3565 else nonnan = T(nonnan + Pc(row, col));
3566 }
3567 if (nnan == 0) continue;
3568 const double rem = 1.0 - num_traits<T>::to_double(nonnan);
3569 const T fill = (rem > 0.0) ? num_traits<T>::from_double(rem / double(nnan)) : zero;
3570 for (std::size_t col = 0; col < dim; ++col) {
3571 const double v = num_traits<T>::to_double(Pc(row, col));
3572 if (v != v) Pc(row, col) = fill;
3573 }
3574 }
3575 std::vector<std::size_t> visited;
3576 for (std::size_t row = 0; row < dim; ++row) {
3577 T s = zero;
3578 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3579 if (s > zero) visited.push_back(row);
3580 }
3581 std::vector<T> alpha(dim, zero);
3582 if (!visited.empty()) {
3583 Matrix<T> Pv(visited.size(), visited.size(), zero);
3584 for (std::size_t a = 0; a < visited.size(); ++a)
3585 for (std::size_t b = 0; b < visited.size(); ++b) Pv(a, b) = Pc(visited[a], visited[b]);
3586 std::vector<T> av;
3587 bool ok = false;
3588 try {
3589 av = mc::dtmc_solve(Pv);
3590 ok = true;
3591 bool allzero = true, hasnan = false;
3592 for (const T& x : av) {
3593 if (x != zero) allzero = false;
3594 if (num_traits<T>::to_double(x) != num_traits<T>::to_double(x)) hasnan = true;
3595 }
3596 if (allzero || hasnan) ok = false;
3597 } catch (const Error&) {
3598 ok = false;
3599 }
3601 for (std::size_t a = 0; a < visited.size(); ++a) alpha[visited[a]] = av[a];
3602 }
3603 Matrix<T> out(sel.size(), nclasses, zero);
3604 for (std::size_t a = 0; a < sel.size(); ++a)
3605 for (std::size_t x = 0; x < nIC; ++x) out(a, ic[x] - 1) = alpha[a * nIC + x];
3606 std::size_t refrow = sel.size();
3607 for (std::size_t a = 0; a < sel.size(); ++a)
3608 if (sel[a] == refnode) refrow = a;
3609 if (refrow < sel.size()) {
3610 T norm = zero;
3611 for (std::size_t x = 0; x < nIC; ++x) norm += out(refrow, ic[x] - 1);
3613 for (std::size_t a = 0; a < sel.size(); ++a)
3614 for (std::size_t x = 0; x < nIC; ++x)
3615 out(a, ic[x] - 1) = T(out(a, ic[x] - 1) / norm);
3616 }
3617 for (std::size_t a = 0; a < sel.size(); ++a)
3618 for (std::size_t x = 0; x < nIC; ++x)
3619 if (out(a, ic[x] - 1) < zero) out(a, ic[x] - 1) = T(-out(a, ic[x] - 1));
3620 return out;
3621 }
3622
3623 /**
3624 * Recompute `rt`, `visits` and `nodevisits` after the caller has rewritten
3625 * `rtnodes` in place -- the cacheqn driver's per-sweep refresh. `rt` is the
3626 * stochastic complement of `rtnodes` over the stateful (node, class) rows;
3627 * the per-chain visits then read directly off `rt` (stateful) and `rtnodes`
3628 * (all nodes), with the chains held FIXED (the reference does not recompute
3629 * them inside the sweep). No-fork only; a fork keeps the refresh path.
3630 */
3632 const T zero = num_traits<T>::from_int(0);
3633 const std::size_t K = nclasses, I = nodes.size(), M = stateful_nodes.size();
3634 for (const NodeDef& nd : nodes)
3635 if (nd.nodetype == NodeType::Fork)
3636 throw UnsupportedError("da_recompute_visits_from_rtnodes: fork models keep the "
3637 "route_eff visit path");
3638 std::vector<std::size_t> keep;
3639 keep.reserve(M * K);
3640 for (std::size_t p = 0; p < M; ++p) {
3641 const std::size_t base = (stateful_nodes[p] - 1) * K;
3642 for (std::size_t r = 0; r < K; ++r) keep.push_back(base + r);
3643 }
3644 rt = mc::dtmc_stochcomp(rtnodes, keep);
3645
3646 std::vector<std::size_t> allnodes(I);
3647 for (std::size_t a = 0; a < I; ++a) allnodes[a] = a + 1;
3648 visits.assign(nchains, Matrix<T>(M, K, zero));
3649 nodevisits.assign(nchains, Matrix<T>(I, K, zero));
3650 for (std::size_t c = 0; c < nchains; ++c) {
3651 const std::vector<std::size_t>& ic = inchain[c];
3652 const std::size_t nIC = ic.size();
3653 // stateful visits from rt (ordered by stateful position)
3654 Matrix<T> Ps(M * nIC, M * nIC, zero);
3655 for (std::size_t a = 0; a < M; ++a)
3656 for (std::size_t x = 0; x < nIC; ++x)
3657 for (std::size_t b = 0; b < M; ++b)
3658 for (std::size_t y = 0; y < nIC; ++y)
3659 Ps(a * nIC + x, b * nIC + y) = rt(a * K + (ic[x] - 1), b * K + (ic[y] - 1));
3660 const std::size_t rstat = classes[ic[0] - 1].refstat;
3661 const std::size_t refnode = station_to_node[rstat - 1];
3662 // sel for the stateful block is the stateful node list; its "refnode"
3663 // position must be matched by node index, so pass stateful_nodes.
3664 visits[c] = visits_from_block(Ps, stateful_nodes, ic, refnode);
3665 // node visits from rtnodes (ordered by node)
3666 Matrix<T> Pn(I * nIC, I * nIC, zero);
3667 for (std::size_t a = 0; a < I; ++a)
3668 for (std::size_t x = 0; x < nIC; ++x)
3669 for (std::size_t b = 0; b < I; ++b)
3670 for (std::size_t y = 0; y < nIC; ++y)
3671 Pn(a * nIC + x, b * nIC + y) =
3672 rtnodes(a * K + (ic[x] - 1), b * K + (ic[y] - 1));
3673 nodevisits[c] = visits_from_block(Pn, allnodes, ic, refnode);
3674 }
3675 }
3676
3677 /**
3678 * The chain-restricted routing over the STATEFUL nodes: the node-level
3679 * routing with the non-stateful nodes eliminated by a stochastic
3680 * complement, S = P11 + P12 (I - P22)^-1 P21. That is MATLAB's
3681 * dtmc_stochcomp, and it is what removes a Fork (and, there, the auto-added
3682 * ClassSwitch nodes) from the visit equations.
3683 */
3684 Matrix<T> station_routing(const std::vector<std::size_t>& ic) const {
3685 const T zero = num_traits<T>::from_int(0);
3686 const std::size_t nIC = ic.size();
3687 const std::size_t I = nodes.size();
3688 Matrix<T> full(I * nIC, I * nIC, zero);
3689 std::vector<std::size_t> pos(classes.size() + 1, 0); // class -> 1-based position in ic
3690 for (std::size_t x = 0; x < nIC; ++x) pos[ic[x]] = x + 1;
3691 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
3692 if (r >= pos.size() || s >= pos.size() || pos[r] == 0 || pos[s] == 0) return;
3693 const std::size_t x = pos[r] - 1, y = pos[s] - 1;
3694 for (std::size_t a = 0; a < I; ++a)
3695 for (std::size_t b = 0; b < I; ++b) full(a * nIC + x, b * nIC + y) = M(a, b);
3696 });
3697 return stoch_comp_stateful(full, nIC);
3698 }
3699
3700 /**
3701 * Visit every (r, s) block of the routing in force, the one route_eff reads: Peff when the
3702 * expansion ran, else P. A block smaller than nnodes reads as zero there, so it is skipped.
3703 */
3704 template <class F>
3705 void for_each_route_block(F f) const {
3706 const std::map<std::pair<std::size_t, std::size_t>, Matrix<T>>& src = Peff.empty() ? P : Peff;
3707 for (const auto& kv : src)
3708 if (kv.second.rows() >= nodes.size() && kv.second.cols() >= nodes.size())
3709 f(kv.first.first, kv.first.second, kv.second);
3710 }
3711
3712 /**
3713 * The stochastic complement of a NODE-level routing block over the stateful
3714 * nodes, S = P11 + P12 (I - P22)^-1 P21.
3715 *
3716 * Split out of `station_routing` because the STATE-DEPENDENT routing table
3717 * (`rt_state`, state.h) is the same complement of a block whose SDR rows
3718 * have been re-evaluated at one state: the two must eliminate the stateless
3719 * nodes identically, or the per-state table and `rt` would disagree on a
3720 * model that merely has a Router in it.
3721 *
3722 * @param full node-major (nnodes * nIC) square block
3723 * @param nIC number of classes carried per node in that block
3724 */
3725 Matrix<T> stoch_comp_stateful(const Matrix<T>& full, std::size_t nIC) const {
3726 const T zero = num_traits<T>::from_int(0);
3727 const T one = num_traits<T>::from_int(1);
3728 const std::size_t I = nodes.size();
3729 std::vector<std::size_t> keep, drop;
3730 for (std::size_t a = 0; a < I; ++a) {
3731 const bool st = nodes[a].stateful;
3732 for (std::size_t x = 0; x < nIC; ++x) (st ? keep : drop).push_back(a * nIC + x);
3733 }
3734 if (drop.empty()) return full;
3735 // Only the stateless states a stateful one can reach enter S = P11 + P12 X, and that set is
3736 // closed under P22, so its own sub-system gives X exactly. On a flattened LQN nearly every
3737 // (node, class) pair is unreachable, and eliminating them all was cubic in nnodes*nclasses.
3738 {
3739 std::vector<char> isdrop(full.rows(), 0), seen(full.rows(), 0);
3740 for (std::size_t d : drop) isdrop[d] = 1;
3741 std::vector<std::size_t> stack;
3742 for (std::size_t k : keep)
3743 for (std::size_t d : drop)
3744 if (!seen[d] && full(k, d) != zero) { seen[d] = 1; stack.push_back(d); }
3745 while (!stack.empty()) {
3746 const std::size_t u = stack.back();
3747 stack.pop_back();
3748 for (std::size_t d : drop)
3749 if (!seen[d] && full(u, d) != zero) { seen[d] = 1; stack.push_back(d); }
3750 }
3751 std::vector<std::size_t> reach;
3752 for (std::size_t d : drop)
3753 if (seen[d]) reach.push_back(d);
3754 drop.swap(reach);
3755 }
3756 if (drop.empty()) {
3757 Matrix<T> S(keep.size(), keep.size(), zero);
3758 for (std::size_t a = 0; a < keep.size(); ++a)
3759 for (std::size_t b = 0; b < keep.size(); ++b) S(a, b) = full(keep[a], keep[b]);
3760 return S;
3761 }
3762
3763 const std::size_t nk = keep.size(), nd = drop.size();
3764 Matrix<T> P11(nk, nk, zero), P12(nk, nd, zero), P21(nd, nk, zero), A(nd, nd, zero);
3765 for (std::size_t a = 0; a < nk; ++a) {
3766 for (std::size_t b = 0; b < nk; ++b) P11(a, b) = full(keep[a], keep[b]);
3767 for (std::size_t b = 0; b < nd; ++b) P12(a, b) = full(keep[a], drop[b]);
3768 }
3769 for (std::size_t a = 0; a < nd; ++a) {
3770 for (std::size_t b = 0; b < nk; ++b) P21(a, b) = full(drop[a], keep[b]);
3771 for (std::size_t b = 0; b < nd; ++b)
3772 A(a, b) = T((a == b ? one : zero) - full(drop[a], drop[b]));
3773 }
3774 // X = A^-1 P21 by Gaussian elimination with partial pivoting
3775 Matrix<T> X = P21;
3776 std::vector<std::size_t> piv(nd);
3777 for (std::size_t i = 0; i < nd; ++i) piv[i] = i;
3778 for (std::size_t col = 0; col < nd; ++col) {
3779 std::size_t best = col;
3780 double bv = std::fabs(num_traits<T>::to_double(A(col, col)));
3781 for (std::size_t r2 = col + 1; r2 < nd; ++r2) {
3782 const double v = std::fabs(num_traits<T>::to_double(A(r2, col)));
3783 if (v > bv) { bv = v; best = r2; }
3784 }
3785 if (best != col) {
3786 for (std::size_t b = 0; b < nd; ++b) std::swap(A(col, b), A(best, b));
3787 for (std::size_t b = 0; b < nk; ++b) std::swap(X(col, b), X(best, b));
3788 }
3789 if (A(col, col) == zero)
3790 throw NumericError("network: the stochastic complement is singular");
3791 // The update touches only the pivot row's nonzero columns; a zero one would change nothing
3792 std::vector<std::size_t> nzA, nzX;
3793 for (std::size_t b = 0; b < nd; ++b)
3794 if (A(col, b) != zero) nzA.push_back(b);
3795 for (std::size_t b = 0; b < nk; ++b)
3796 if (X(col, b) != zero) nzX.push_back(b);
3797 for (std::size_t r2 = 0; r2 < nd; ++r2) {
3798 if (r2 == col) continue;
3799 const T f = T(A(r2, col) / A(col, col));
3800 if (f == zero) continue;
3801 for (std::size_t b : nzA) A(r2, b) = T(A(r2, b) - f * A(col, b));
3802 for (std::size_t b : nzX) X(r2, b) = T(X(r2, b) - f * X(col, b));
3803 }
3804 }
3805 for (std::size_t r2 = 0; r2 < nd; ++r2)
3806 for (std::size_t b = 0; b < nk; ++b) X(r2, b) = T(X(r2, b) / A(r2, r2));
3807
3808 // Same summation order as the dense product, with the zero terms of P12 skipped
3809 Matrix<T> S = P11;
3810 std::vector<T> acc(nk, zero);
3811 for (std::size_t a = 0; a < nk; ++a) {
3812 std::fill(acc.begin(), acc.end(), zero);
3813 for (std::size_t m = 0; m < nd; ++m) {
3814 const T w = P12(a, m);
3815 if (w == zero) continue;
3816 for (std::size_t b = 0; b < nk; ++b) acc[b] += w * X(m, b);
3817 }
3818 for (std::size_t b = 0; b < nk; ++b) S(a, b) = T(S(a, b) + acc[b]);
3819 }
3820 return S;
3821 }
3822
3823 // ---- predicates, ports of the sn_has_* family -------------------------
3824
3825 bool has_open_classes() const {
3826 for (const JobClass& c : classes)
3827 if (std::isinf(c.population)) return true;
3828 return false;
3829 }
3830
3831 /**
3832 * `sn_is_open_model`: EVERY class is open, which is not `has_open_classes`.
3833 * A mixed model passes that predicate and fails this one, and an analyzer
3834 * that confuses the two hands a closed chain to a solver with no level for
3835 * its population. An empty class list is not an open model either.
3836 */
3837 bool is_open_model() const {
3838 for (const JobClass& c : classes)
3839 if (!std::isinf(c.population)) return false;
3840 return !classes.empty();
3841 }
3842
3843 bool has_multi_server() const {
3844 for (const Station<T>& s : stations)
3845 if (std::isfinite(s.nservers) && s.nservers > 1.0) return true;
3846 return false;
3847 }
3848
3850 for (const JobClass& c : classes)
3851 if (std::isfinite(c.population) && c.population != std::floor(c.population + 0.5))
3852 return true;
3853 return false;
3854 }
3855
3856 bool has_class_switching() const { return nclasses != nchains; }
3857
3858 bool has_priorities() const {
3859 for (const JobClass& c : classes)
3860 if (c.prio > 0) return true;
3861 return false;
3862 }
3863
3864 /**
3865 * Whether the classes carry more than one priority level.
3866 *
3867 * NOT `has_priorities()`, which asks whether any priority is nonzero: a
3868 * model whose classes all sit at level 1 has priorities by that test and
3869 * nothing to distinguish, and the reference's warning below keys on the
3870 * distinction rather than on the magnitude.
3871 */
3873 if (classes.empty()) return false;
3874 for (const JobClass& c : classes)
3875 if (c.prio != classes.front().prio) return true;
3876 return false;
3877 }
3878
3879 /**
3880 * Whether some station runs a discipline that READS the class priorities.
3881 *
3882 * The list is exactly the `*PRIO` family. Priority-awareness is a property
3883 * of the DECLARED policy and is never inferred from the data: a base policy
3884 * is not upgraded because the classes it was handed carry unequal
3885 * priorities. `afterEventStation` did infer it once, and plain LCFS with
3886 * distinct priorities then behaved as none of the three policies involved.
3887 */
3889 for (const Station<T>& s : stations) {
3890 switch (s.sched) {
3891 case SchedStrategy::HOL:
3892 case SchedStrategy::PSPRIO:
3893 case SchedStrategy::DPSPRIO:
3894 case SchedStrategy::GPSPRIO:
3895 case SchedStrategy::LCFSPRIO:
3896 case SchedStrategy::LCFSPRPRIO:
3897 case SchedStrategy::LCFSPIPRIO:
3898 case SchedStrategy::FCFSPRPRIO:
3899 case SchedStrategy::FCFSPIPRIO:
3900 case SchedStrategy::SRPTPRIO:
3901 return true;
3902 default:
3903 break;
3904 }
3905 }
3906 return false;
3907 }
3908
3909 /** Priorities were declared and no station will read them. */
3910 bool priorities_ignored() const {
3912 }
3913
3914 /**
3915 * Port of sn_has_homogeneous_scheduling.
3916 *
3917 * The MATLAB function is `length(findstring(sn.sched, strategy)) ==
3918 * sn.nstations`, and findstring matches STRINGS: on the numeric sched
3919 * vector its strcmp is false, so it returns the sentinel -1, whose length
3920 * is 1. The predicate therefore reduces to nstations == 1 whatever the
3921 * disciplines are, which is what the reference actually computes and what
3922 * the AMVA dispatch in solver_amva actually sees. Reproduced rather than
3923 * corrected: fixing it here would send homogeneous-delay layers down a
3924 * different branch than every other codebase takes.
3925 */
3927
3929 bool bad = false;
3930 for (std::size_t i = 0; i < nstations; ++i) {
3931 if (stations[i].sched != SchedStrategy::FCFS) continue;
3932 bool any = false;
3934 for (std::size_t r = 0; r < nclasses; ++r) {
3935 if (disabled[i][r]) continue; // MATLAB drops the NaN entries here
3936 if (!any) {
3937 lo = hi = rates(i, r);
3938 any = true;
3939 } else {
3940 if (rates(i, r) < lo) lo = rates(i, r);
3941 if (rates(i, r) > hi) hi = rates(i, r);
3942 }
3943 }
3944 // Relative FineTol, not hi > lo: a one-ulp spread in LN layer rates flipped exact MVA vs AMVA
3945 if (any && num_traits<T>::to_double(hi - lo) >
3947 bad = true;
3948 }
3949 return bad;
3950 }
3951
3953 for (const Station<T>& s : stations)
3954 if (!(s.sched == SchedStrategy::INF || s.sched == SchedStrategy::PS ||
3955 s.sched == SchedStrategy::FCFS || s.sched == SchedStrategy::LCFSPR ||
3956 s.sched == SchedStrategy::LCFS || s.sched == SchedStrategy::EXT))
3957 return false;
3958 return true;
3959 }
3960
3961 /**
3962 * BCMP type 1 asks the FCFS service to be exponential. `has_multi_class_heter_fcfs`
3963 * compares the class MEANS only, so a class-homogeneous Erlang, hyper-exponential or
3964 * deterministic FCFS station used to pass this gate and be dispatched to exact MVA,
3965 * which reads the means alone and returns the exponential answer with no warning.
3966 */
3968 for (std::size_t i = 0; i < nstations; ++i) {
3969 if (stations[i].sched != SchedStrategy::FCFS) continue;
3970 for (std::size_t r = 0; r < nclasses; ++r) {
3971 const double v = num_traits<T>::to_double(scv(i, r));
3972 if (std::isinf(v) || !(v > 0.0)) continue;
3973 if (!(v > 1.0 - GlobalConstants::FineTol && v < 1.0 + GlobalConstants::FineTol))
3974 return false;
3975 }
3976 }
3977 return true;
3978 }
3979
3980 /**
3981 * Kendall's K of station IST (1-based), +inf when unbounded. Implementation of
3982 * api/sn/sn_get_buffer_size.h, which delegates here so the solvers' member calls and
3983 * the api free function cannot drift; the traps are documented on that header.
3984 */
3985 double buffer_size(std::size_t ist) const {
3986 double k = std::numeric_limits<double>::infinity();
3987 if (cap[ist - 1] >= 0.0) k = std::min(k, cap[ist - 1]);
3988 double ccap = 0.0;
3989 bool anyServed = false;
3990 for (std::size_t r = 0; r < nclasses; ++r)
3991 if (classcap[ist - 1][r] > 0.0) {
3992 ccap += classcap[ist - 1][r];
3993 anyServed = true;
3994 }
3995 if (anyServed) k = std::min(k, ccap);
3996 double reachable = 0.0;
3997 for (std::size_t r = 0; r < nclasses; ++r)
3998 if (!anyServed || classcap[ist - 1][r] > 0.0) reachable += classes[r].population;
3999 return (k >= reachable) ? std::numeric_limits<double>::infinity() : k;
4000 }
4001
4002 /** Implementation of api::sn_is_mm1k_loss; that free function delegates here. */
4003 bool is_mm1k_loss() const {
4004 if (nclasses != 1 || nodes.size() != 3) return false;
4005 for (std::size_t k = 0; k < classes.size(); ++k)
4006 if (std::isfinite(classes[k].population)) return false; // nclosedjobs ~= 0
4007 std::size_t nq = 0, nsrc = 0, nsink = 0, qnode = 0, snode = 0;
4008 for (std::size_t a = 0; a < nodes.size(); ++a) {
4009 switch (nodes[a].nodetype) {
4010 case NodeType::Queue: ++nq; qnode = a + 1; break;
4011 case NodeType::Source: ++nsrc; snode = a + 1; break;
4012 case NodeType::Sink: ++nsink; break;
4013 default: return false;
4014 }
4015 }
4016 if (nq != 1 || nsrc != 1 || nsink != 1) return false;
4017 const std::size_t qist = nodes[qnode - 1].station;
4018 const std::size_t sist = nodes[snode - 1].station;
4019 if (qist == 0 || sist == 0) return false;
4020 if (stations[qist - 1].nservers != 1.0) return false;
4021 if (droprule.size() < qist || droprule[qist - 1].empty() ||
4022 droprule[qist - 1][0] != DropStrategy::DROP)
4023 return false;
4024 if (!(qist <= cap.size()) || !std::isfinite(cap[qist - 1]) || !(cap[qist - 1] > 0.0))
4025 return false;
4026 if (std::fabs(num_traits<T>::to_double(scv(sist - 1, 0)) - 1.0) > 1e-6) return false;
4027 if (std::fabs(num_traits<T>::to_double(scv(qist - 1, 0)) - 1.0) > 1e-6) return false;
4028 // A DECLARED STATE DEPENDENCE IS NOT AN M/M/1/K. Both closed forms this
4029 // predicate gates read ONE service rate -- `qsys_mm1k_loss` a scalar mu,
4030 // `qsys_mg1k_loss_mgs` the first two moments of one service law -- so a
4031 // station carrying lldscaling, cdscaling or jdscaling would be answered with
4032 // the UNSCALED queue: a wrong number, not a coarse one, and silent.
4033 const qn::Station<T>& qst = stations[qist - 1];
4034 if (!qst.lldscaling.empty() || static_cast<bool>(qst.cdscaling) ||
4035 static_cast<bool>(qst.jdscaling))
4036 return false;
4037 return true;
4038 }
4039
4040 /**
4041 * Some station can REFUSE a job: its own buffer BINDS, or a finite capacity region
4042 * caps a set of stations jointly. Implementation of api::sn_has_blocking, which
4043 * delegates here; the rule and its two exemptions are documented on that function.
4044 */
4045 bool has_blocking() const {
4046 if (!regions.empty()) return true;
4047 for (std::size_t a = 0; a < nodes.size(); ++a)
4048 if (nodes[a].nodetype == NodeType::Cache) return false;
4049 if (is_mm1k_loss()) return false;
4050 for (std::size_t ist = 1; ist <= stations.size(); ++ist)
4051 if (std::isfinite(buffer_size(ist))) return true;
4052 return false;
4053 }
4054
4055 bool has_product_form() const {
4056 // BCMP asks for infinite buffers: without this conjunct a BAS-blocked station or
4057 // any binding finite buffer read as product form, though its truncation couples
4058 // the station occupancies.
4061 }
4062
4063 /**
4064 * Port of sn_has_product_form_not_het_fcfs: LCFS is excluded, and at FCFS the service
4065 * must be exponential AND class-independent, which is what BCMP type 1 asks for. With
4066 * unequal per-class means the product-form solve returns a wait proportional to each
4067 * class's own demand where FCFS makes every class wait behind the same queue. The mean
4068 * comparison is between CHAIN service times (visit-weighted over the classes that
4069 * actually visit the station): a class that never visits cannot break product form,
4070 * and within-chain heterogeneity is invisible to both the product-form and the qd
4071 * branch, which deaggregate a chain result proportionally to each class's own demand,
4072 * so only between-chain heterogeneity warrants the divert. LN layers carry seeded
4073 * rates for classes with zero visits, which a raw per-class comparison mistakes for
4074 * heterogeneity.
4075 *
4076 * CHECK_MEANS drops the mean test; pass false only for an algorithm
4077 * that models class-dependent FCFS itself (ab, schmidt, schmidt-ext).
4078 */
4079 bool has_product_form_not_het_fcfs(bool check_means = true) const {
4080 for (const Station<T>& s : stations)
4081 if (!(s.sched == SchedStrategy::INF || s.sched == SchedStrategy::PS ||
4082 s.sched == SchedStrategy::FCFS || s.sched == SchedStrategy::LCFSPR ||
4083 s.sched == SchedStrategy::EXT))
4084 return false;
4085 if (has_priorities()) return false;
4086 for (std::size_t i = 0; i < nstations; ++i) {
4087 if (stations[i].sched != SchedStrategy::FCFS) continue;
4088 for (std::size_t r = 0; r < nclasses; ++r) {
4089 if (disabled[i][r]) continue;
4090 const double v = num_traits<T>::to_double(scv(i, r));
4091 if (!std::isinf(v) && v > 0.0 &&
4092 !(v > 1.0 - GlobalConstants::FineTol && v < 1.0 + GlobalConstants::FineTol))
4093 return false;
4094 }
4095 if (!check_means || visits.empty()) continue;
4096 const std::size_t isf = stateful_of_station(i + 1) - 1;
4097 double stmin = 0.0, stmax = 0.0;
4098 bool anyserved = false;
4099 for (std::size_t c = 0; c < nchains && c < visits.size(); ++c) {
4100 double num = 0.0, den = 0.0;
4101 for (std::size_t r = 0; r < nclasses; ++r) {
4102 if (!chains.empty() && !chains[c][r]) continue;
4103 if (disabled[i][r]) continue;
4104 const double w = num_traits<T>::to_double(visits[c](isf, r));
4105 const double rate = num_traits<T>::to_double(rates(i, r));
4106 if (w > GlobalConstants::Zero && std::isfinite(rate) && rate > 0.0) {
4107 num += w / rate;
4108 den += w;
4109 }
4110 }
4111 if (den > 0.0) {
4112 const double st = num / den;
4113 if (!anyserved) {
4114 stmin = stmax = st;
4115 anyserved = true;
4116 } else {
4117 stmin = std::min(stmin, st);
4118 stmax = std::max(stmax, st);
4119 }
4120 }
4121 }
4122 if (anyserved && stmax - stmin > GlobalConstants::CoarseTol * stmax) return false;
4123 }
4124 return true;
4125 }
4126
4127 /** Grow every routing block to the current node count. */
4129 for (auto& kv : P) grow_block(kv.second);
4130 }
4131 void grow_block(Matrix<T>& B) const {
4132 if (B.rows() == nodes.size()) return;
4133 Matrix<T> g(nodes.size(), nodes.size(), num_traits<T>::from_int(0));
4134 for (std::size_t a = 0; a < B.rows(); ++a)
4135 for (std::size_t b = 0; b < B.cols(); ++b) g(a, b) = B(a, b);
4136 B = g;
4137 }
4138
4139 /** Total population, as MATLAB's getNumberOfJobs summed. */
4140 double total_jobs() const {
4141 double s = 0.0;
4142 for (const JobClass& c : classes)
4143 if (std::isfinite(c.population)) s += c.population;
4144 return s;
4145 }
4146};
4147
4148/**
4149 * The swap graph of a PAS / OI station, with the defaults `refreshLocalVars.m`
4150 * installs applied.
4151 *
4152 * MATLAB fills `sn.nodeparam{ind}.swapGraph` at refresh time and NEVER leaves it
4153 * empty: an OI station always gets `zeros(R,R)`, a PAS station with no explicit
4154 * graph gets the complete compatibility graph `ones(R,R) - eye(R)`, and an
4155 * explicit graph is taken as given. The builder here stores the raw user graph,
4156 * so the defaulting has to happen on read; doing it here rather than in the
4157 * builder keeps it independent of whether the classes were added before or
4158 * after `set_service_rate_function`.
4159 *
4160 * @param sn the struct
4161 * @param ist 1-based station index
4162 */
4163template <class T>
4165 const Station<T>& st = sn.stations[ist - 1];
4166 const std::size_t R = sn.nclasses;
4167 const T zero = num_traits<T>::from_int(0);
4168 if (st.sched == SchedStrategy::OI) return Matrix<T>(R, R, zero);
4169 if (!st.swap_graph.empty()) return st.swap_graph;
4170 if (st.sched != SchedStrategy::PAS) return Matrix<T>(R, R, zero);
4172 for (std::size_t r = 0; r < R; ++r) g(r, r) = zero;
4173 return g;
4174}
4175
4176/** True when the station's materialized swap graph is entirely zero. */
4177template <class T>
4178bool station_swap_graph_is_zero(const NetworkStruct<T>& sn, std::size_t ist) {
4179 const Matrix<T> g = station_swap_graph(sn, ist);
4180 const T zero = num_traits<T>::from_int(0);
4181 for (std::size_t a = 0; a < g.rows(); ++a)
4182 for (std::size_t b = 0; b < g.cols(); ++b)
4183 if (g(a, b) != zero) return false;
4184 return true;
4185}
4186
4187} // namespace qn
4188} // namespace line
4189
4190#endif // LINE_LANG_QN_NETWORK_STRUCT_H
Base error for the multiprecision C++ port.
Definition error.h:31
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.
void set_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j, const T &p)
P{r,s}(i,j) = p, with 1-based NODE and class indices.
std::size_t sourceIdx
1-based station index of the Source, 0 = none
bool has_multi_class_heter_fcfs() const
bool has_distinct_priorities() const
Whether the classes carry more than one priority level.
std::size_t nvars_of(std::size_t ind) const
Total local-variable width of node ind (1-based).
pfqn::SdrStruct sdr_nodes
Node-indexed twin of sdr.
std::size_t add_class(const JobClass &cl)
Add a class and grow the service table.
T get_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const
P{r,s}(i,j), AS THE USER SET IT.
std::vector< Matrix< T > > nodevisits
(nchains) each (nnodes x nclasses)
void refresh_rates()
Port of MNetwork.refreshRates: lower each service process onto a rate and an SCV.
Matrix< T > chain_visits(std::size_t c, const std::vector< std::size_t > &sel, bool complement, bool fork) const
Port of the per-chain body of sn_refresh_visits, over an arbitrary node subset – the stateful nodes f...
std::size_t stateful_index(std::size_t ind) const
1-based stateful index of node ind, 0 when the node is not stateful.
void refresh_cacheqn_actual_visits(const Matrix< T > &hitprob, const Matrix< T > &missprob)
Re-resolve the cache read self-switch from the offered 1/2-1/2 to the ACTUAL hit/miss probabilities t...
bool has_immediate_feedback() const
Whether immediate feedback is EFFECTIVE anywhere in the model.
std::vector< lang::RemovalPolicy > signalrempolicy
std::vector< std::vector< bool > > immfeed
sn.immfeed: (nstations x nclasses) IMMEDIATE FEEDBACK, the reference's refreshStruct field.
const ForkParam< T > * fork_param_of(std::size_t node) const
The fork's override block, or null when it declares none.
std::size_t stateful_of_station(std::size_t st) const
std::vector< std::size_t > downstream_stations(std::size_t ind) const
The nodes directly downstream of ind, walking THROUGH stateless nodes and stopping at the first stati...
std::size_t nof_nodes() const
std::size_t nof_stateful() const
bool has_rr_routing() const
Whether ANY (node, class) pair dispatches round-robin.
std::map< std::pair< std::size_t, std::size_t >, Matrix< T > > P
P[(r,s)] is an (nnodes x nnodes) block; absent means all zero.
std::size_t phases_of(std::size_t ist, std::size_t r) const
sn.phases(i,r): the order of the process representation.
std::map< std::size_t, TransitionParam< T > > transparam
Transition (SPN) parameters, keyed by 1-based node index.
std::size_t mark_carrier_of(std::size_t ist) const
The CARRIER of station i's marked arrival: the class of mark 1, whose phase block holds the one modul...
std::vector< std::vector< bool > > replyblock
sn.replyblock (nnodes x nclasses) and sn.syncreply (nclasses).
std::vector< std::size_t > all_nodes() const
Every node index, 1-based, for the node-level visit computation.
std::vector< std::vector< bool > > isbasdestination
sn.isbasdestination, (nstations x nclasses): true where a refusal at this station must BLOCK an upstr...
bool is_mm1k_loss() const
Implementation of api::sn_is_mm1k_loss; that free function delegates here.
void for_each_route_block(F f) const
Visit every (r, s) block of the routing in force, the one route_eff reads: Peff when the expansion ra...
bool has_sdr_routing() const
MATLAB's any(sn.isstatedep(:,3)) NARROWED TO THE ONE STRATEGY THIS PORT EVALUATES PER STATE: Krzesins...
std::vector< Reward > reward
std::vector< std::size_t > stateful_nodes
1-based node indices, ascending
bool sched_has_priority_aware() const
Whether some station runs a discipline that READS the class priorities.
bool holds_reply_for(std::size_t ind, std::size_t r) const
True when node ind (1-based) holds a server across a synchronous call whose reply class is r (1-based...
void refresh_replyblock()
sn.replyblock, DERIVED from sn.syncreply and the routing.
std::vector< std::vector< Distrib< T > > > service
service[i][r], 0-based station and class; a disabled entry marks a pair never visited.
void refresh_chains()
Port of MNetwork.refreshChains followed by sn_refresh_visits.
std::vector< std::vector< bool > > chains
(nchains x nclasses)
bool has_homogeneous_scheduling(SchedStrategy) const
Port of sn_has_homogeneous_scheduling.
void apply_sink_closure()
Route every open chain from the Sink back into the Source, as the tail of MATLAB's getRoutingMatrix d...
std::vector< std::size_t > refclass
(nchains) 1-based class, 0 = none
int gdscalingcutoff
sn.gdscalingcutoff: the per-slot OPEN-class truncation used when gdscaling is materialized onto the J...
std::map< std::size_t, SetupDelayOffParam< T > > setupparam
Setup / delay-off, keyed by 1-based STATION index.
std::map< std::size_t, BreakdownParam< T > > breakdownparam
Server breakdown / repair, keyed by 1-based STATION index.
bool has_exponential_fcfs() const
BCMP type 1 asks the FCFS service to be exponential.
void check_service_reachable() const
Refuses a class that is ROUTED TO a station which cannot serve it.
bool has_breakdown() const
Any station at all, the guard every solver gate needs first.
GdScaling< T > gdscaling
sn.gdscaling: the network-level globally state-dependent (Whittle) rate scaling phi(n).
std::vector< bool > fjauxclass
sn.fjauxclass: (nclasses+1, 1-based) true where the class is an MMT AUXILIARY OPEN class,...
std::vector< std::vector< std::size_t > > nvars
sn.nvars, (nnodes x 3R+1): the LOCAL VARIABLE columns each node appends to its state,...
bool has_service_law(std::size_t i, std::size_t r) const
Does (station i, class r) have a service law an analyzer may convert?
std::vector< std::vector< bool > > disabled
Matrix< T > visits_from_block(const Matrix< T > &Pc_in, const std::vector< std::size_t > &sel, const std::vector< std::size_t > &ic, std::size_t refnode) const
The visit ratios of one chain from an already-formed chain routing block Pc (dim = sel....
pfqn::SdrStruct sdr
Krzesinski (1987) product-form state-dependent routing, in 0-based STATION indices; empty unless a no...
std::vector< std::vector< bool > > reached_node_classes() const
The (node, class) pairs a job can actually ARRIVE at, 0-based on both axes.
void refresh_sched_param()
Port of MNetwork.refreshScheduling's schedparam half.
std::map< std::size_t, FjJoinParam > fjjoinparam
sn.nodeparam{j}.fj for each Join node: the tag matrix and the required sibling multiplicity after_eve...
bool serves_class(std::size_t ind, std::size_t r) const
Whether a job of class r (0-based) can LEAVE node ind (1-based) again.
double total_jobs() const
Total population, as MATLAB's getNumberOfJobs summed.
Matrix< T > rt
sn.rt and sn.rtnodes: the class-expanded routing.
const CtmcMapc< T > * mapc_of(std::size_t ist, std::size_t r) const
The pair form of class r (1-based) at station ist (1-based), or null when there is none.
std::map< std::size_t, RetrialParam< T > > retrialparam
Retrial parameters, keyed by 1-based STATION index.
void promote_stateful(std::size_t ind)
Port of MNetwork.refreshLocalVars: the per-node local-variable widths.
std::vector< bool > issignal
void grow_routing()
Grow every routing block to the current node count.
std::map< std::size_t, CacheParam< T > > nodeparam
Cache parameters by 1-based NODE index; only Cache nodes have an entry.
void grow_block(Matrix< T > &B) const
std::size_t map_var_slot(std::size_t ind, std::size_t r) const
0-based column of class r's MAP memory inside node ind's local-variable block.
std::size_t add_node(const std::string &nm, NodeType ty, bool stateful)
Add a non-station node (a Fork, a Router).
std::map< std::size_t, PasParam > pasparam
std::vector< double > cap
sn.cap and sn.classcap: the total and per-class buffers.
std::map< std::pair< std::size_t, std::size_t >, CtmcMapc< T > > ctmcmapc
MAP services in CTMC pair form, keyed by (1-based station, 1-based class).
std::vector< JobClass > classes
void rr_advance(std::size_t ind, std::size_t r, std::vector< T > &varrow) const
Advance the pointer of (ind, r) in VARROW by one position, cyclically.
std::vector< bool > isbasblocking
sn.isbasblocking, per NODE: true where the node is the BLOCKING (upstream) side of a true-BAS relatio...
bool has_product_form_not_het_fcfs(bool check_means=true) const
Port of sn_has_product_form_not_het_fcfs: LCFS is excluded, and at FCFS the service must be exponenti...
std::size_t sinkNode
1-based NODE index of the Sink, 0 = none (it is not a station)
std::map< std::size_t, JoinDecl > joindecl
std::vector< std::size_t > signaltarget
ProcessType procid(std::size_t ist, std::size_t r) const
sn.procid(i,r): the process type of a (station, class) pair.
std::size_t rr_dest(std::size_t ind, std::size_t r, const std::vector< T > &varrow) const
The destination node the pointer in VARROW names, or 0 when (ind, r) does not dispatch round-robin or...
PollingParam effective_polling(std::size_t ist) const
The polling controller of station ist, from whichever API declared it.
std::vector< T > gdscalingpeak
sn.gdscalingpeak: the declared (nstations x nclasses) peak of gdscaling, row-major,...
std::size_t rr_var_slot(std::size_t ind, std::size_t r) const
1-BASED index of the pointer of (ind, r) INSIDE the node's local-variable block, or 0 when that pair ...
void refresh_bas_blocking()
Port of refreshLocalVars' true-BAS block and its declaresBlockedMarker helper: which nodes carry the ...
std::vector< Station< T > > stations
stations[k-1] is the k-th station
std::vector< std::size_t > rr_outlinks(std::size_t ind, std::size_t r) const
ROUND-ROBIN DISPATCH, the state that makes it deterministic.
Matrix< T > rates
(nstations x nclasses) service rates and SCVs, with a PARALLEL disabled flag instead of MATLAB's NaN ...
void refresh_struct()
The whole chain, in MATLAB's refreshStruct order.
T route_eff(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const
The routing actually in force: the expansion when there is one, else P.
bool is_queueing_place(std::size_t i) const
Is station i (0-based) a QUEUEING PLACE, i.e.
void set_service(std::size_t station, std::size_t cls, const Distrib< T > &d)
std::vector< std::size_t > syncreply
std::map< std::pair< std::size_t, std::size_t >, Matrix< T > > Peff
The routing after refresh_routing() has expanded the non-PROB strategies and folded the class switche...
std::vector< std::vector< std::size_t > > inchain
1-based class indices per chain
std::size_t node_of_station(std::size_t st) const
1-based node index of a station, and the reverse; 0 when absent.
void da_recompute_visits_from_rtnodes()
Recompute rt, visits and nodevisits after the caller has rewritten rtnodes in place – the cacheqn dri...
std::vector< NodeDef > nodes
every node, in creation order
std::vector< std::vector< double > > classcap
std::vector< std::size_t > rr_weighted_outlinks(std::size_t ind, std::size_t r) const
The WRROBIN cycle: each outlink repeated by its weight, weight 0 once.
std::map< std::size_t, Matrix< T > > csmatrix
The class-switch matrix of a ClassSwitch node, by 1-based NODE index.
std::vector< double > njobs() const
sn.njobs: the population of each class, infinite for an open one.
bool has_breakdown_node(std::size_t ind) const
sn.hasbreakdown(ind): does the NODE's server break down?
bool is_open_model() const
sn_is_open_model: EVERY class is open, which is not has_open_classes.
bool sched_is_product_form() const
std::vector< std::vector< DropStrategy > > droprule
std::string log_path
Network.setLogPath / getLogPath: the directory every Logger writes into, and the logPath attribute of...
bool has_blocking() const
Some station can REFUSE a job: its own buffer BINDS, or a finite capacity region caps a set of statio...
void refresh_rt()
sn.rt and sn.rtnodes: the class-expanded routing matrices.
bool priorities_ignored() const
Priorities were declared and no station will read them.
std::vector< Matrix< T > > visits
(nchains) each (nstateful x nclasses)
double buffer_size(std::size_t ist) const
Kendall's K of station IST (1-based), +inf when unbounded.
std::map< std::size_t, std::vector< T > > initmarking
The DECLARED initial state of a stateful node, by 1-based node index.
std::pair< T, T > mark_marginal_moments(const Distrib< T > &d, std::size_t mk) const
Mean and SCV of ONE MARK'S MARGINAL MAP, MarkedMAP.toMAPs(k).
std::map< std::size_t, std::vector< T > > stateprior
std::map< std::size_t, CtmcPool< T > > ctmcpool
Heterogeneous server pools rewritten for the CTMC, by 1-based NODE index.
double nclosedjobs() const
sn.nclosedjobs: the total population of the closed classes.
std::vector< std::pair< std::size_t, std::size_t > > fj
fj(f,j): the Join node j that closes the Fork node f, 1-based.
std::size_t nof_stations() const
void set_route_effective(std::size_t r, std::size_t s, std::size_t i, std::size_t j, const T &p)
Write into the routing the consumers actually read.
std::size_t join_siblings(std::size_t joinNode, std::size_t r=0) const
Port of MNetwork.refreshCapacity.
std::size_t phasessz_of(std::size_t ist, std::size_t r) const
sn.phasessz(i,r) = max(sn.phases(i,r),1): THE WIDTH of class r's phase block in a state row,...
std::vector< lang::SignalType > signaltype
std::vector< std::size_t > fjclassmap
sn.fjclassmap: the ORIGINAL class of each auxiliary sibling class, 0 for an original class.
std::map< std::size_t, PollingParam > pollingparam
std::vector< std::size_t > quorum_joins() const
The 1-based Join nodes that fire on a STRICT quorum, i.e.
bool has_fractional_populations() const
std::map< std::size_t, Matrix< T > > statespace
std::size_t markidx_of(std::size_t ist, std::size_t r) const
sn.markidx(i,r): the 1-based MARK that class r carries in station i's marked arrival process,...
Matrix< T > stoch_comp_stateful(const Matrix< T > &full, std::size_t nIC) const
The stochastic complement of a NODE-level routing block over the stateful nodes, S = P11 + P12 (I - P...
std::size_t add_station(const Station< T > &st)
Add a station, which is also a node, and grow the service table.
std::vector< std::vector< T > > signalremdist
Matrix< T > station_routing(const std::vector< std::size_t > &ic) const
The chain-restricted routing over the STATEFUL nodes: the node-level routing with the non-stateful no...
bool isfjaugmented
sn.isfjaugmented: this struct came out of fj_tag, so its Fork nodes are STATEFUL and its Join nodes c...
std::vector< std::size_t > station_to_node
(nstations) 1-based node index
void refresh_routing()
Port of the part of MNetwork.refreshRoutingMatrix this port reaches: the expansion of a routing STRAT...
std::vector< Region > regions
std::size_t nof_classes() const
void refresh_immfeed()
Port of the sn.immfeed block of @@MNetwork/refreshStruct.m.
std::map< std::size_t, ForkParam< T > > forkparam
Variable forking levels, by 1-based Fork node; absent on a plain fork.
static std::string plural(long n, const std::string &singular, const std::string &plural_form)
Count with an agreeing noun, e.g.
static void compiling(const std::string &name)
Announce the compilation of a model structure.
static void compile_detail(const char *fmt,...)
One stage line of a structure compile: silenced inside a Quiet scope, and inside an open run,...
Equilibrium distribution of a discrete-time Markov chain, and stochastic complementation.
Limiting distribution of a discrete-time Markov chain whose transition matrix may be reducible.
Stochastic complement of a DTMC partition, a port of matlab/lib/kpctoolbox/mc/dtmc_stochcomp....
The exception types the port throws.
Enumerations and the minimal distribution descriptor shared by the model layer of the C++ port.
Running progress log of a LINE solver run (the "solver console").
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:426
BalkingStrategy
Balking rules, with the values of MATLAB BalkingStrategy.
Definition lang_types.h:447
JoinStrategy
Join rules, with the values of MATLAB JoinStrategy.
Definition lang_types.h:463
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:391
HeteroSchedPolicy
How a heterogeneous station picks among its server types, MATLAB HeteroSchedPolicy.
Definition lang_types.h:453
PollingType
Polling service disciplines, with the values of MATLAB PollingType.
Definition lang_types.h:372
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:375
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:374
JobClassType
Job class kinds, with the values of MATLAB JobClassType.
Definition lang_types.h:369
ProcessType
Distribution kinds, with the values of MATLAB ProcessType.
Definition lang_types.h:485
std::function< std::vector< T >(const std::vector< T > &)> GdScaling
A globally state-dependent scaling, sn.gdscaling.
Definition lang_types.h:744
std::function< std::vector< T >(const std::vector< T > &)> CdScaling
A class-dependent scaling map, sn.cdscaling.
Definition lang_types.h:731
const char * routing_to_text(RoutingStrategy r)
Definition lang_types.h:404
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:326
ReplacementStrategy
Cache replacement policies, with the values of MATLAB ReplacementStrategy.
Definition lang_types.h:380
T map_mean(const Map< T > &m)
Mean inter-arrival time, 1/lambda.
Definition map_moment.h:101
T map_scv(const Map< T > &m)
Squared coefficient of variation.
Definition map_moment.h:140
ReducibleResult< T > dtmc_solve_reducible(const Matrix< T > &P, const std::vector< T > &pin, double zeroColTol=1e-12)
Limiting distribution of a discrete-time Markov chain whose transition matrix may be reducible.
Matrix< T > dtmc_stochcomp(const Matrix< T > &P, const std::vector< std::size_t > &keep)
Stochastic complement of a DTMC partition, a port of matlab/lib/kpctoolbox/mc/dtmc_stochcomp....
SccResult stronglyconncomp(const Matrix< T > &A)
Strongly connected components of a directed graph, and which of them are recurrent (closed under the ...
std::vector< T > dtmc_solve(const Matrix< T > &P)
Stationary distribution of a stochastic matrix P.
Definition dtmc_solve.h:106
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,...
Matrix< T > station_swap_graph(const NetworkStruct< T > &sn, std::size_t ist)
The swap graph of a PAS / OI station, with the defaults refreshLocalVars.m installs applied.
bool station_swap_graph_is_zero(const NetworkStruct< T > &sn, std::size_t ist)
True when the station's materialized swap graph is entirely zero.
const char * routing_to_text(RoutingStrategy r)
Definition lang_types.h:404
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
Number-type abstraction for the templated API port.
Product-form state-dependent routing.
Strongly connected components of a directed graph, and which of them are recurrent (closed under the ...
The DECLARED join rule of a Join node, by 1-based node index.
The G-network signal declaration, per CLASS.
The polling controller of a POLLING station, keyed by station index.
FINITE CAPACITY REGIONS, MATLAB's refreshRegions output.
sn.reward: the user-declared reward functions, MATLAB's model.setReward(name, fn).
Matrix< T > D0
The (D0,D1) pair when the type carries one directly.
Definition lang_types.h:853
std::vector< Matrix< T > > Dmark
MMAP per-class D1 blocks / BMAP per-batch-size blocks; empty otherwise.
Definition lang_types.h:863
T rate() const
The rate MATLAB's refreshRates would store: 1/mean, with the Immediate singleton short-circuited to i...
std::size_t phases() const
The order of the representation, MATLAB's sn.phases.
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
std::vector< bool > recurrent
recurrent[c-1] is true when component c has no edge leaving it.
std::vector< std::size_t > scc
Component index of each state, 1-based as in MATLAB (0 is never used).
Topology and coefficients of a state-dependent routing subnetwork.
Definition pfqn_sdr.h:59
Server breakdown and repair of a station whose server fails and is repaired.
T failure_rate
sn.breakdownMu: 1 / mean failure time
T down_rate_of(std::size_t cls_1based) const
lang::Distrib< T > repair
time to repair of a down server
lang::Distrib< T > failure
time to failure of an up server
T repair_rate
sn.repairMu: 1 / mean repair time
std::vector< T > down_service_rates
sn.downServiceRates(ist, :): per class, 0 = no service while down.
The popularity LAW each class declared, beside the pmf it expands to.
std::size_t n
support size of a Zipf or a DiscreteSampler
T qlru
Delayed-hit retrieval system (Cache.setRetrievalSystem).
std::vector< Popularity > preadkind
per class, parallel to pread
std::vector< T > initstate
The DECLARED initial contents of the cache, as the reference dumps the node's state row: the per-clas...
std::vector< int > itemsize
Per-item storage cost (size) and per-list cap on the total cost of the resident items (ton21cache Sec...
std::vector< int > costcap
std::map< std::size_t, std::vector< std::size_t > > retrieval_queues
read class(0-based)->nodes
std::vector< std::vector< Matrix< T > > > accost
(u) x (n) of (h+1)x(h+1), or empty
long max_pending_retrieval
Truncation level of block B: how many secondary requests may be merged onto the in-flight fetches of ...
std::vector< std::vector< std::size_t > > retrieval_classes
(nitems x nclasses), 1-based
std::vector< int > itemcap
std::vector< std::size_t > missclass
std::vector< std::size_t > hitclass
lang::ReplacementStrategy replacestrat
std::vector< std::size_t > classitem
Item read by each per-item class of a cache network (MATLAB Cache.setItemReadClasses,...
std::vector< std::vector< T > > pread
(u) x (n), empty row = NaN
CTMC pair form of a MAP service at an FCFS station, written by ctmc::ctmc_mapc on the solver's privat...
Matrix< T > V
landing law (-D0)^-1 D1
std::size_t p
phases of the original MAP
std::vector< std::pair< std::size_t, std::size_t > > pairs
0-based (i,j), i-major, V(i,j) > 0
std::vector< T > done
completion rate D1(i,j)/V(i,j)
bool single
c = 1: carried phase, no pairs
CTMC bookkeeping of a heterogeneous-server station, written by ctmc::ctmc_pools on the solver's priva...
std::vector< std::vector< std::size_t > > len
offset and length of block k of class r
std::vector< std::vector< std::vector< T > > > alpha
entry vector of block k of class r
std::vector< std::vector< std::vector< T > > > exit
exit rates of block k of class r
std::vector< std::vector< bool > > compat
compat[t-1][r-1]
std::vector< double > count
servers per pool
std::vector< std::size_t > alfsorder
pools by ascending class count (ALFS)
std::vector< std::vector< Matrix< T > > > D0
D0 of block k of class r.
std::vector< std::vector< std::size_t > > pools
pools[r-1]: compatible pools of r, ascending
lang::HeteroSchedPolicy policy
bool rotate
ALIS/FAIRNESS order is part of the state.
std::vector< std::vector< std::size_t > > off
std::size_t perm_index(const std::vector< std::size_t > &order) const
1-based index of order in perms, 0 when absent.
std::vector< std::vector< double > > fsfrate
fsfrate[t-1][r-1] = 1/mean (FSF)
std::vector< std::vector< std::size_t > > perms
every pool order, identity first
static Distrib disabled_dist()
Definition lang_types.h:988
sn.nodeparam{j}.fj: what a Join node needs to fire on identity.
std::vector< std::size_t > origclasses
std::map< std::size_t, std::vector< std::vector< std::size_t > > > auxmatrix
std::map< std::size_t, std::vector< std::size_t > > required
One fork firing synchronization: sn.fjsync{k}.
std::size_t fork
1-based Fork node
std::vector< std::size_t > weightlink
Per-branch tasksPerLink, EMPTY when every branch carries weight.
std::size_t weight
tasksPerLink: siblings emitted per branch
std::vector< std::size_t > branchheads
1-based node per branch
std::size_t join
1-based Join node that closes it
std::size_t cls
1-based ORIGINAL class being forked
std::vector< std::vector< std::size_t > > auxall
(B x T) every auxiliary class of this (fork, class), for the tag scan.
std::vector< std::size_t > auxclasses
the tag's auxiliary class per branch
std::size_t tag
1-based tag this entry allocates
Variable forking levels, the twin of MATLAB sn.nodeparam{f}.fanOutLink / .fanOutProb / ....
std::vector< std::vector< lang::Distrib< T > > > fan_out_dist
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:766
static constexpr double FineTol
Definition lang_types.h:760
static constexpr double Zero
Definition lang_types.h:762
static constexpr double CoarseTol
Definition lang_types.h:761
One job class of the network.
std::size_t refstat
1-based reference station
bool completes
Whether passage through the reference station is a COMPLETION.
double deadline
sn.classdeadline(r): the soft deadline EDD and EDF order by, and the tardiness JMT reports.
int attr_kind
LayeredNetworkElement of the element it stands for.
std::size_t attr_idx
index of that element
double population
infinite for an open class
bool immfeed
Class-level immediate feedback, ORed with the station's own setting into sn.immfeed.
std::size_t spawn
sn.classspawn(r): the 1-based class injected at the SAME station on every completion of this class,...
bool self_looping
A SelfLoopingClass: a closed class that perpetually cycles at its reference station.
bool is_ref_class
marks the chain's reference class
std::function< T(const std::vector< std::size_t > &)> svc_rate_fun
std::vector< std::vector< bool > > swap_graph
The polling controller of a POLLING station, keyed by station index.
std::size_t pk
the K of K-LIMITED
std::vector< lang::Distrib< T > > switchover
Matrix< T > lincon_A
optional linear constraint A n <= b
std::vector< std::vector< double > > cap
(nstations x nclasses+1), -1 = unbounded
std::string name
The region's declared name, as the wire carries it; a generated one otherwise.
std::vector< double > maxmem
per member station, -1 = unbounded
std::vector< DropStrategy > rule
per class
std::vector< T > size
per class; size is the memory footprint
std::vector< bool > members
membership, independent of the caps
std::function< T(const std::vector< T > &)> fn
std::string kind
The DECLARATIVE form the reward was built from, when it was: the template name (QLen,...
A Logger node's trace configuration, MATLAB's Logger properties and sn.nodeparam{ind}...
std::string file_name
base name, no directory
std::string file_path
directory, MATLAB's model.getLogPath
A node of the network.
std::vector< int > routing_param
The scalar parameter of a parameterized dispatcher, per class: the d of a power-of-d (SQ) choice.
std::vector< std::map< std::size_t, double > > routing_weights
The per-destination weights of a WRROBIN dispatcher, per class: a map from 1-based destination NODE i...
bool queue_object
Declared as a Queue although its INF discipline makes nodetype Delay (Network::add_queue).
std::vector< RoutingStrategy > routing
sn.routing, per class.
double tasks_per_link
Fork.output.tasksPerLink == MATLAB sn.nodeparam{f}.fanOut: how many tasks a fork emits per outgoing l...
The parameters of a retrial station: MATLAB sn.retrialProc and friends.
std::vector< int > max_attempts
0 = unbounded
std::vector< T > retrial_rate
mu_r, the per-class orbit retry rate
std::vector< lang::Distrib< T > > retrial_proc
retrial_proc[r] is the class-r retrial process; empty = not a retrial class.
Setup and delay-off of a station that powers down when it falls idle.
bool last(lang::Distrib< T > &su, lang::Distrib< T > &doff) const
The pair the solvers use: the last class that declares one.
std::vector< lang::Distrib< T > > setup
per class, disabled = not declared
std::vector< lang::Distrib< T > > delayoff
per class
Per class; strategy == NONE is a class that declares no balking.
lang::BalkingStrategy strategy
std::vector< BalkingThreshold > thresholds
One balking threshold: with min_jobs <= n <= max_jobs at the station, an arriving job of the class re...
A heterogeneous server pool: count servers that serve only compatible classes, each with its own serv...
std::vector< bool > compatible
per class; empty = every class
std::vector< Distrib< T > > service
per class
One station of the network.
std::vector< Distrib< T > > orbit_impatience
Queue.setOrbitImpatience(class, dist): abandonment from the RETRIAL ORBIT, which is a different popul...
double cap
Station capacity in Kendall's K, as setCapacity sets it.
std::vector< T > jdscalingpeak
sn.jdscalingpeak for this station: the declared peak joint-dependent scaling per class.
std::vector< BalkingParam > balking
std::function< T(const std::vector< std::size_t > &)> svc_rate_fun
sn.nodeparam{ind}.svcRateFun for a PAS / OI station: the TOTAL service rate as a function of the orde...
std::vector< T > batch_reject
Queue.setBatchRejectProbability: per-class rejection of a whole batch.
std::vector< Distrib< T > > patience
Queue.setPatience(class, dist): the abandonment timer of a WAITING job, with impatience[r] naming whi...
std::vector< std::size_t > server_parallelism
Queue.setServerParallelism(class, n): the servers a job seizes for the whole of its service,...
std::vector< int > droprule
Per-class blocking rule as an INT, with 0 meaning "not set".
SchedStrategy sched
std::vector< std::vector< Distrib< T > > > switchover_pair
Queue.setSwitchover(fromClass, toClass, distrib): the walk the server takes when it turns from servin...
double nservers
may be infinite (a Delay, or an inf-scheduled task)
std::vector< lang::PollingType > polling_type
Polling parameters for a POLLING station, MATLAB's pollingType, switchoverTime and pollingPar on the ...
Matrix< T > swap_graph
sn.nodeparam{ind}.swapGraph: which class a departing job promotes the jobs behind it into.
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...
std::vector< std::size_t > marked_classes
Source.markedClasses: the 1-based class of each mark of an MMAP arrival.
std::vector< T > lldscaling
sn.lldscaling for this station: the multiplier at population 1, 2, ... Empty when the station is not ...
std::vector< lang::ImpatienceType > impatience
std::vector< T > schedparam
sn.schedparam, per class: the DPS / GPS weight, or the SEPT / LEPT rank.
lang::HeteroSchedPolicy hetero_policy
CdScaling< T > cdscaling
sn.cdscaling for this station: the class-dependence map, empty when unset.
std::vector< double > classcap
Per-class buffer from setChainCapacity; infinite where unset.
std::vector< lang::DepartureDiscipline > departure_discipline
Place.departureDiscipline, per class.
std::vector< Distrib< T > > arrival_batch
Source.setArrivalBatch(class, dist): the batch-size law released at each arrival epoch.
std::vector< Distrib< T > > switchover
std::vector< bool > immfeed
Node-level immediate feedback, per class; empty when the station sets none.
std::vector< ServerType > server_types
std::size_t attr_idx
LQN element this station stands for.
The parameters of a Cache node, MATLAB's sn.nodeparam{ind} for a Cache.
static std::vector< T > inhibit_total(const std::vector< Matrix< T > > &a, std::size_t m)
The inhibiting THRESHOLD of one mode per place, class blind.
static std::vector< T > arc_total(const std::vector< Matrix< T > > &a, std::size_t m)
The arcs of one mode summed over classes, for a consumer that is class blind BECAUSE THE NET IS SINGL...
bool is_multiclass() const
True when any mode's arcs touch more than one class.
std::vector< double > firingprio
firing priority per mode
std::vector< lang::TimingStrategy > timing
immediate or timed
std::vector< std::string > modenames
std::vector< lang::Distrib< T > > firingproc
firing distribution per mode
std::vector< double > nmodeservers
servers per mode, may be infinite
std::vector< T > fireweight
weight among simultaneously enabled modes
std::size_t single_class() const
The one class every arc of every mode touches, 1-based; 0 when none does.
std::vector< Matrix< T > > firing
firing[m](p,r): class-r tokens mode m moves to/from place p when it fires.
std::vector< Matrix< T > > enabling
enabling[m](p,r): class-r tokens of place p (0-based node) mode m needs.
std::vector< std::function< T(const std::vector< T > &)> > firingdep
Marking-dependent firing-rate multiplier g_m(marking); an empty entry is the unit multiplier.
std::vector< Matrix< T > > inhibiting
inhibiting[m](p,r): class-r tokens of p that BLOCK mode m (Inf = never).
std::vector< std::size_t > firingphases
phase count per mode, 0 when non-Markovian