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 * A network plus its refreshed NetworkStruct.
854 *
855 * The two halves are one object because every caller that mutates the model
856 * (service processes, class populations, routing probabilities) re-derives the
857 * struct, exactly as MATLAB's Network does through its cached `sn`. The refresh
858 * entry points mirror the MATLAB ones and have the same granularity, which
859 * matters for cost: `refresh_rates` is called on every outer iteration of
860 * SolverLN for every layer, `refresh_chains` only where the routing changed.
861 */
862template <class T>
864public:
865 std::string name;
866 /**
867 * `Network.setLogPath` / `getLogPath`: the directory every Logger writes
868 * into, and the `logPath` attribute of an exported JMT model.
869 *
870 * Model-level rather than per-Logger because that is where the reference
871 * keeps it -- `Logger`'s constructor REFUSES when it is unset -- and
872 * because the JSIM header carries one such path for the whole model.
873 */
874 std::string log_path;
875 std::vector<NodeDef> nodes; ///< every node, in creation order
876 std::vector<Station<T>> stations; ///< stations[k-1] is the k-th station
877 std::vector<std::size_t> station_to_node; ///< (nstations) 1-based node index
878 std::vector<std::size_t> stateful_nodes; ///< 1-based node indices, ascending
879 std::vector<JobClass> classes;
880 /** service[i][r], 0-based station and class; a disabled entry marks a pair never visited. */
881 std::vector<std::vector<Distrib<T>>> service;
882
883 /**
884 * Does (station i, class r) have a service law an analyzer may convert?
885 * 0-based, and the ONLY correct precondition for `dist_to_map(service[i][r])`.
886 *
887 * `disabled` alone is not enough, and the gap is not hypothetical. It is the
888 * twin of MATLAB's NaN-in-`sn.rates` sentinel, so it answers "does the class
889 * visit this station". A JOIN is visited -- `refreshRates` gives it
890 * `rates = Inf`, `scv = 0` -- and yet it has NO service law:
891 * `refreshProcessRepresentations` hands it a `Coxian(NaN,NaN)`, which every
892 * MATLAB analyzer then skips through a SECOND and separate `any(isnan(D0))`
893 * guard. Rational has no NaN, so the C++ twin of that Coxian is a DISABLED
894 * `Distrib`, and a guard that tests only `disabled[i][r] || rates <= 0` sails
895 * past a Join (Inf > 0) straight into `dist_to_map`, which throws
896 * "the distribution is disabled" with no station named. `fj_basic_open`
897 * failed exactly this way under `-s mam`.
898 *
899 * So: `disabled[i][r]` is about the VISIT, `service[i][r].disabled` is about
900 * the LAW, and a station can be visited without having one.
901 */
902 bool has_service_law(std::size_t i, std::size_t r) const {
903 return !disabled[i][r] && !service[i][r].disabled;
904 }
905
906 /**
907 * Is station `i` (0-based) a QUEUEING PLACE, i.e. a Place with an embedded
908 * queue? `Place.isQueueing()` in MATLAB.
909 *
910 * KEYED ON THE SERVICE LAW, not on the discipline, because that is the
911 * reference's own rule: `Place.queueing` is raised by `setService` and by
912 * nothing else, so a place constructed with a discipline and never given a
913 * law is an ordinary place. The reverse cannot happen -- an ordinary place
914 * carries a `ServiceTunnel` and no law -- so the two tests agree wherever
915 * both are defined, and only this one survives the JSON round trip, where a
916 * `queueing` flag would have to be carried separately and kept in step.
917 */
918 bool is_queueing_place(std::size_t i) const {
919 if (i >= stations.size() || stations[i].nodetype != NodeType::Place) return false;
920 for (std::size_t r = 0; r < service[i].size(); ++r)
921 if (!service[i][r].disabled) return true;
922 return false;
923 }
924 /** P[(r,s)] is an (nnodes x nnodes) block; absent means all zero. */
925 std::map<std::pair<std::size_t, std::size_t>, Matrix<T>> P;
926 /**
927 * The routing after refresh_routing() has expanded the non-PROB strategies
928 * and folded the class switches in. EMPTY when the expansion is the
929 * identity, which is the case for every model whose routing is given as
930 * probabilities and has no ClassSwitch node -- and then route_eff() reads P
931 * directly, so the two representations never drift apart.
932 */
933 std::map<std::pair<std::size_t, std::size_t>, Matrix<T>> Peff;
934 /**
935 * Krzesinski (1987) product-form state-dependent routing, in 0-based
936 * STATION indices; empty unless a node declares it with
937 * `set_state_dep_routing`. Branch index 1 denotes the complement M-V and is
938 * unused. `sdr_nodes` is the same structure in 0-based NODE indices.
939 *
940 * The routing is state dependent yet keeps a product form of its own, so
941 * `sn_has_sd_routing` is true for it while the normalizing-constant solver
942 * still accepts it. See _kb/16-state-dependent-routing.md
943 */
945 /** Node-indexed twin of `sdr`. */
947 /** fj(f,j): the Join node j that closes the Fork node f, 1-based. */
948 std::vector<std::pair<std::size_t, std::size_t>> fj;
949 /**
950 * `sn.isfjaugmented`: this struct came out of `fj_tag`, so its Fork nodes are
951 * STATEFUL and its Join nodes carry a per-class sibling count instead of the
952 * ordinary buffer/server split.
953 *
954 * It is a flag and not an inference from `fj` being non-empty, because the
955 * un-augmented struct of the SAME model also has `fj` populated: what
956 * distinguishes them is the auxiliary class block, and the event layer must
957 * not take the Join branch before it exists.
958 */
959 bool isfjaugmented = false;
960 /**
961 * `sn.fjclassmap`: the ORIGINAL class of each auxiliary sibling class,
962 * 0 for an original class. Empty unless `isfjaugmented`.
963 */
964 std::vector<std::size_t> fjclassmap;
965 /**
966 * `sn.fjauxclass`: (nclasses+1, 1-based) true where the class is an MMT
967 * AUXILIARY OPEN class, i.e. one `fj_mmt` added to carry parallelism at rate
968 * (fanout-1)*forkLambda. Such a class has infinite njobs but its arrivals come
969 * from the finite closed population, so a finite-population correction still
970 * applies to it. Empty on every struct the user's own model produces, and on
971 * the H-T arm, whose auxiliary classes are CLOSED and need no such marker.
972 * Distinct from `fjclassmap`/`isfjaugmented`, which describe the CTMC tag
973 * augmentation.
974 */
975 std::vector<bool> fjauxclass;
976 /**
977 * `sn.nodeparam{j}.fj` for each Join node: the tag matrix and the required
978 * sibling multiplicity `after_event_join` fires on. Declared as an opaque
979 * map here and defined in `fj_tag.h`, which owns its layout.
980 */
981 std::map<std::size_t, FjJoinParam> fjjoinparam;
982
983 // ---- the Source/Sink pair, when the model has open classes -------------
984 std::size_t sourceIdx = 0; ///< 1-based station index of the Source, 0 = none
985 std::size_t sinkNode = 0; ///< 1-based NODE index of the Sink, 0 = none (it is not a station)
986
987 /** Cache parameters by 1-based NODE index; only Cache nodes have an entry. */
988 std::map<std::size_t, CacheParam<T>> nodeparam;
989
990 /**
991 * The DECLARED initial state of a stateful node, by 1-based node index.
992 *
993 * `initmarking` is a Place's token count per class, which is the initial
994 * marking of an SPN and is not derivable from anything else -- an SPN with
995 * no tokens anywhere is a dead net, so dropping it changes the answer to
996 * "nothing ever fires". `stateprior` and `statespace` are the pair MATLAB
997 * writes together (`StatefulNode.statePrior` over `StatefulNode.space`):
998 * the prior is a distribution over the ROWS of that space, so neither is
999 * meaningful without the other and the reader refuses a lone one.
1000 */
1001 std::map<std::size_t, std::vector<T>> initmarking;
1002 std::map<std::size_t, std::vector<T>> stateprior;
1003 std::map<std::size_t, Matrix<T>> statespace;
1004
1005 /**
1006 * The DECLARED join rule of a Join node, by 1-based node index.
1007 *
1008 * STD waits for every sibling; PARTIAL fires on `quorum` of them. The
1009 * quorum is what `FjJoinParam::required` holds once the fork-join tagging
1010 * has run, but that runs on the augmented struct: this is the declaration
1011 * as the model.json carries it, so the two do not replace each other.
1012 */
1013 struct JoinDecl {
1015 double quorum = 0.0; ///< 0 = every sibling
1016 };
1017 std::map<std::size_t, JoinDecl> joindecl;
1018
1019 /** Variable forking levels, by 1-based Fork node; absent on a plain fork. */
1020 std::map<std::size_t, ForkParam<T> > forkparam;
1021
1022 /** The fork's override block, or null when it declares none. */
1023 const ForkParam<T>* fork_param_of(std::size_t node) const {
1024 const typename std::map<std::size_t, ForkParam<T> >::const_iterator it =
1025 forkparam.find(node);
1026 return it == forkparam.end() ? static_cast<const ForkParam<T>*>(0) : &it->second;
1027 }
1028
1029 /**
1030 * `sn.reward`: the user-declared reward functions, MATLAB's
1031 * `model.setReward(name, fn)`.
1032 *
1033 * `fn` is evaluated on the AGGREGATE state row -- the per-(station, class)
1034 * job counts in `(ist-1)*K + k` order, which is what
1035 * `ctmc_state_space_aggr` builds -- and not on the detailed state. That is
1036 * the reference's `RewardState` contract, and it is what makes a reward
1037 * portable across disciplines: the caller writes `state[q]` for a queue
1038 * length without having to know whether the buffer stores class tags or
1039 * per-class counts.
1040 */
1041 struct Reward {
1042 std::string name;
1043 std::function<T(const std::vector<T>&)> fn;
1044 /**
1045 * The DECLARATIVE form the reward was built from, when it was: the
1046 * template name (QLen, Util, Blocking), the 1-based node it is declared
1047 * at, and the 1-based class it covers (0 = every class).
1048 *
1049 * A lambda cannot be written back out, so a reward built from one has
1050 * an empty `kind` and the writer omits it -- which is what
1051 * `linemodel_save` does, with a warning, rather than emitting a
1052 * definition that would be wrong on reload.
1053 */
1054 std::string kind;
1055 std::size_t node = 0;
1056 std::size_t cls = 0;
1057 };
1058 std::vector<Reward> reward;
1059
1060 /**
1061 * FINITE CAPACITY REGIONS, MATLAB's `refreshRegions` output.
1062 *
1063 * A region caps the jobs (and the memory) held ACROSS a set of stations,
1064 * which no per-station capacity can express: three stations each able to
1065 * hold 5 jobs but at most 6 between them is a region, not three caps.
1066 *
1067 * `cap(i,r)` is the class-r bound at station i, `cap(i,K)` the station's
1068 * global bound, and -1 means unbounded -- the reference's sentinel, kept
1069 * rather than translated to infinity because it is compared with `~= -1`
1070 * to test MEMBERSHIP as well as boundedness.
1071 *
1072 * `rule(r)` decides what an arrival that would violate the region does:
1073 * DROP loses it, WAITQ parks it outside every station's own queue, which is
1074 * why the CTMC needs a separate waiting-room state per region and not just
1075 * a filter on the enumerated space.
1076 */
1077 struct Region {
1078 /** The region's declared name, as the wire carries it; a generated one otherwise. */
1079 std::string name;
1080 std::vector<std::vector<double>> cap; ///< (nstations x nclasses+1), -1 = unbounded
1081 std::vector<double> maxmem; ///< per member station, -1 = unbounded
1082 std::vector<bool> members; ///< membership, independent of the caps
1083 std::vector<DropStrategy> rule; ///< per class
1084 std::vector<T> weight, size; ///< per class; size is the memory footprint
1085 Matrix<T> lincon_A; ///< optional linear constraint A n <= b
1086 std::vector<T> lincon_b;
1087 };
1088 std::vector<Region> regions;
1089 /** Transition (SPN) parameters, keyed by 1-based node index. */
1090 std::map<std::size_t, TransitionParam<T>> transparam;
1091 /** Retrial parameters, keyed by 1-based STATION index. */
1092 std::map<std::size_t, RetrialParam<T>> retrialparam;
1093 /**
1094 * Setup / delay-off, keyed by 1-based STATION index.
1095 *
1096 * Presence IS MATLAB's `sn.hassetup(ist)`: the reference sets that flag
1097 * from `~isempty(station.setupTime)`, so a station appears here exactly
1098 * when it is a setup task's server.
1099 */
1100 std::map<std::size_t, SetupDelayOffParam<T>> setupparam;
1101 /**
1102 * Server breakdown / repair, keyed by 1-based STATION index.
1103 *
1104 * Presence IS MATLAB's `sn.hasbreakdown(node)`, which the reference sets
1105 * from `~isempty(node.breakdownFailure) && ~isempty(node.breakdownRepair)`
1106 * -- BOTH, since a server that fails and is never repaired is a different
1107 * model and the reference declines to infer one.
1108 */
1109 std::map<std::size_t, BreakdownParam<T>> breakdownparam;
1110
1111 /** `sn.hasbreakdown(ind)`: does the NODE's server break down? */
1112 bool has_breakdown_node(std::size_t ind) const {
1113 if (ind == 0 || ind > nodes.size()) return false;
1114 const std::size_t ist = nodes[ind - 1].station;
1115 return ist != 0 && breakdownparam.find(ist) != breakdownparam.end();
1116 }
1117 /** Any station at all, the guard every solver gate needs first. */
1118 bool has_breakdown() const { return !breakdownparam.empty(); }
1119 /**
1120 * The class-switch matrix of a ClassSwitch node, by 1-based NODE index.
1121 *
1122 * (nclasses x nclasses), row-stochastic. It is applied on the way OUT of
1123 * the node, exactly as MATLAB's ClassSwitch does, and the node is not
1124 * stateful, so the stochastic complement folds it into the edges around it.
1125 */
1126 std::map<std::size_t, Matrix<T>> csmatrix;
1127
1128 // ---- refreshed struct -------------------------------------------------
1129 std::size_t nstations = 0, nclasses = 0, nchains = 0;
1130 /**
1131 * `sn.gdscaling`: the network-level globally state-dependent (Whittle)
1132 * rate scaling phi(n). Its argument is the FULL (nstations x nclasses)
1133 * population matrix in row-major order, NOT one station's slice, so unlike
1134 * `Station::jdscaling` it lives on the struct rather than on a station.
1135 * Empty when the model declares none. See `set_global_dependence`.
1136 */
1138 /**
1139 * `sn.gdscalingpeak`: the declared (nstations x nclasses) peak of
1140 * `gdscaling`, row-major, used to report Util = T*S/peak.
1141 */
1142 std::vector<T> gdscalingpeak;
1143 /**
1144 * `sn.gdscalingcutoff`: the per-slot OPEN-class truncation used when
1145 * `gdscaling` is materialized onto the JSON wire (closed classes are
1146 * tabulated up to their own population). Solving ignores it entirely.
1147 */
1149 /**
1150 * (nstations x nclasses) service rates and SCVs, with a PARALLEL disabled
1151 * flag instead of MATLAB's NaN sentinel.
1152 *
1153 * MATLAB writes NaN into sn.rates for a (station, class) pair the class
1154 * never visits, and every consumer tests isnan. Rational has no NaN -- it
1155 * is a field, not a floating-point format -- so the marker has to be
1156 * carried out of band or the exact instantiation could not represent a
1157 * disabled pair at all. The flag is the marker; rates and scv hold zero
1158 * there, and no consumer may read them without consulting `disabled`.
1159 */
1161 std::vector<std::vector<bool>> disabled;
1162
1163 /**
1164 * `sn.immfeed`: (nstations x nclasses) IMMEDIATE FEEDBACK, the reference's
1165 * `refreshStruct` field.
1166 *
1167 * A job of class r completing at station i is fed straight back into
1168 * service, HOLDING THE SERVER, instead of being routed out and re-queued.
1169 * The flag is the OR of the station's own setting (Queue only) and the
1170 * class-level one, exactly as MATLAB `@@MNetwork/refreshStruct.m` computes
1171 * it, so either spelling reaches the same matrix.
1172 *
1173 * IT IS NOT A `Feature`, deliberately. `feature_name` is byte-for-byte the
1174 * MATLAB registry field name, and the reference has no such entry: MVA and
1175 * NC WARN that they approximate it as class-switching with re-queueing,
1176 * while CTMC and SSA implement the sample-path semantics. Making it a
1177 * feature would invent a name no other codebase carries and would turn the
1178 * reference's warning into a refusal.
1179 */
1180 std::vector<std::vector<bool>> immfeed;
1181 std::vector<std::vector<bool>> chains; ///< (nchains x nclasses)
1182 std::vector<std::vector<std::size_t>> inchain;///< 1-based class indices per chain
1183 std::vector<std::size_t> refclass; ///< (nchains) 1-based class, 0 = none
1184 std::vector<Matrix<T>> visits; ///< (nchains) each (nstateful x nclasses)
1185 std::vector<Matrix<T>> nodevisits; ///< (nchains) each (nnodes x nclasses)
1186
1187 /**
1188 * `sn.cap` and `sn.classcap`: the total and per-class buffers.
1189 *
1190 * Derived by refresh_capacity() from the station capacities and the chain
1191 * populations, exactly as MATLAB's refreshCapacity does, so a station with
1192 * no explicit capacity still carries the population bound of the chains
1193 * that reach it.
1194 */
1195 std::vector<double> cap;
1196 std::vector<std::vector<double>> classcap;
1197 std::vector<std::vector<DropStrategy>> droprule;
1198
1199 /**
1200 * `sn.rt` and `sn.rtnodes`: the class-expanded routing.
1201 *
1202 * rt is (nstateful*nclasses) square over the STATEFUL nodes -- the
1203 * stochastic complement that removes Fork, ClassSwitch and Router nodes --
1204 * and rtnodes is (nnodes*nclasses) square over every node. Row (i-1)*K + r
1205 * is node i in class r, which is MATLAB's ordering.
1206 */
1208
1209 /**
1210 * `sn.nvars`, (nnodes x 3R+1): the LOCAL VARIABLE columns each node appends
1211 * to its state, beyond the buffer and the servers. Layout, verbatim from
1212 * refreshLocalVars:
1213 *
1214 * 1 .. R modulating phase, one per class with a MAP/MMPP2 process
1215 * R+1 .. 2R routing variable, one per class routed round-robin
1216 * 2R+1 the SHARED node block: cache width, or the BAS blocked
1217 * marker, or the breakdown status, or the polling
1218 * controller. They share one column and are therefore
1219 * mutually exclusive -- refreshLocalVars rejects the
1220 * combinations rather than widening the state.
1221 * 2R+1+r the REPLY blocked-server counter for calling class r
1222 *
1223 * The trailing sum(nvars(ind,:)) columns are what every state slicer must
1224 * take clear of before reading the server block.
1225 */
1226 std::vector<std::vector<std::size_t>> nvars;
1227
1228 /**
1229 * `sn.isbasblocking`, per NODE: true where the node is the BLOCKING
1230 * (upstream) side of a true-BAS relation and therefore carries the blocked
1231 * marker in `nvars` column 2R+1.
1232 *
1233 * TRUE BAS is not a drop rule, it is a HELD JOB. Under blocking after
1234 * service the job that finished at this station cannot leave because its
1235 * destination is full; it stays at the server, occupying it, until room
1236 * frees. That needs a state bit -- "the front job here is completed and
1237 * waiting" -- which no queue length can express, and it is what separates
1238 * BAS from DROP (where the arrival is lost) and from WAITQ (where the
1239 * arrival waits at the region gate instead).
1240 *
1241 * IT IS KEYED ON THE NODE, NOT ON THE DROP RULE, and the distinction is
1242 * load-bearing. BAS may be DECLARED on either side: upstream, on the station
1243 * that will hold the job, or on the full destination (the JMT/LDES
1244 * convention). Both resolve to the same blocking station, because the held
1245 * job sits upstream either way, so keying enumeration on the station's own
1246 * `droprule` misses every destination-declared model.
1247 */
1248 std::vector<bool> isbasblocking;
1249 /**
1250 * `sn.isbasdestination`, (nstations x nclasses): true where a refusal at
1251 * this station must BLOCK an upstream BAS station rather than drop the job.
1252 *
1253 * `arrival_is_lost` needs it because it sees only the station where the
1254 * refusal happens. Under the upstream declaration form that station carries
1255 * no BAS rule of its own, so without this mask an open class refused there
1256 * would be declared lost, the become-blocked edge would never fire, and the
1257 * blocking station would behave as if its destination were unbounded.
1258 */
1259 std::vector<std::vector<bool>> isbasdestination;
1260
1261 /**
1262 * The G-network signal declaration, per CLASS. `issignal` is the gate: the
1263 * remaining vectors are only read where it is true.
1264 *
1265 * `signaltarget` is the 1-based class a NEGATIVE signal may remove, or 0
1266 * for the classic untargeted Gelenbe customer, which is eligible against
1267 * every non-signal class. `signalremdist` is the batch-size pmf indexed by
1268 * batch size 0,1,2,...; an empty entry means "remove exactly one".
1269 */
1270 /**
1271 * The pass-and-swap / order-independent parameters of a PAS station, keyed
1272 * by station index (Dorsman and Gardner 2024, Sect. 2).
1273 *
1274 * `svc_rate_fun` is the total service rate mu(c) of an ordered list of
1275 * class indices; the rate of the token at position p is the INCREMENT
1276 * mu(c1..cp) - mu(c1..c_{p-1}), which is what makes the station
1277 * order-independent. `swap_graph` is the (R x R) adjacency saying which
1278 * class may take another's place. An OI station is the special case of an
1279 * empty swap graph.
1280 */
1281 struct PasParam {
1282 std::function<T(const std::vector<std::size_t>&)> svc_rate_fun;
1283 std::vector<std::vector<bool>> swap_graph;
1284 };
1285 std::map<std::size_t, PasParam> pasparam;
1286
1287 /**
1288 * `sn.replyblock` (nnodes x nclasses) and `sn.syncreply` (nclasses).
1289 *
1290 * A SYNCHRONOUS call: a job of a calling class leaves this station for the
1291 * callee but KEEPS its server, released only when the matching REPLY class
1292 * arrives back. `replyblock` marks the (node, calling class) pairs that
1293 * hold such a server, and `syncreply[r]` is the 1-based reply class the
1294 * calling class r expects (0 = none). A CTMC has no job identity to key on
1295 * as LDES does, so the state carries COUNTS of held servers.
1296 */
1297 std::vector<std::vector<bool>> replyblock;
1298 std::vector<std::size_t> syncreply;
1299
1300 /**
1301 * The polling controller of a POLLING station, keyed by station index.
1302 *
1303 * `switchover[r]` is the walk into buffer r, taken from the buffer the
1304 * server LEAVES to get there. An Immediate switchover is NOT represented as
1305 * a state: taking its ~1e8 rate literally would make the generator stiff
1306 * and add a spurious state per buffer, so `pollingNext` folds it into the
1307 * enclosing transition instead.
1308 */
1311 std::size_t pk = 1; ///< the K of K-LIMITED
1312 std::vector<lang::Distrib<T>> switchover;
1313 };
1314 std::map<std::size_t, PollingParam> pollingparam;
1315
1316 /**
1317 * The polling controller of station `ist`, from whichever API declared it.
1318 *
1319 * TWO APIS WRITE THE SAME CONTROLLER. `set_polling` fills `pollingparam`;
1320 * `Queue.setPollingType` / `Queue.setSwitchover` -- the MATLAB-faithful pair
1321 * the JSON reader also uses -- fill `Station::polling_type`, `switchover`
1322 * and `polling_par`. Reading only the first left a station built the second
1323 * way with NO controller at all, which is not a degraded model but an
1324 * unrepresentable one: the state handlers then index an empty `polled`.
1325 *
1326 * A POLLING station that declares neither still HAS a controller, exactly as
1327 * `State.pollingInfo` builds one: EXHAUSTIVE service, every switchover
1328 * immediate. That is a discipline, not an absence.
1329 */
1330 PollingParam effective_polling(std::size_t ist) const {
1331 PollingParam pp;
1332 if (ist == 0 || ist > stations.size()) return pp;
1333 const typename std::map<std::size_t, PollingParam>::const_iterator it =
1334 pollingparam.find(ist);
1335 if (it != pollingparam.end()) return it->second;
1336 const Station<T>& st = stations[ist - 1];
1337 if (!st.polling_type.empty()) {
1338 pp.ptype = st.polling_type[0];
1339 if (pp.ptype == lang::PollingType::KLIMITED && st.polling_par >= 1)
1340 pp.pk = static_cast<std::size_t>(st.polling_par);
1341 }
1342 pp.switchover = st.switchover;
1343 return pp;
1344 }
1345
1346 std::vector<bool> issignal;
1347 std::vector<lang::SignalType> signaltype;
1348 std::vector<std::size_t> signaltarget;
1349 std::vector<lang::RemovalPolicy> signalrempolicy;
1350 std::vector<std::vector<T>> signalremdist;
1351
1352 /** Total local-variable width of node `ind` (1-based). */
1353 std::size_t nvars_of(std::size_t ind) const {
1354 // A node index is 1-BASED, so 0 means "no node" and must return 0. The
1355 // old guard `nvars.size() < ind` is vacuously false at ind == 0 because
1356 // both sides are unsigned, and the loop below then indexed nvars[-1]:
1357 // heap corruption, surfacing as a SIGABRT far from here.
1358 if (ind == 0 || ind > nvars.size()) return 0;
1359 std::size_t w = 0;
1360 for (std::size_t j = 0; j < nvars[ind - 1].size(); ++j) w += nvars[ind - 1][j];
1361 return w;
1362 }
1363
1364 std::size_t nof_stations() const { return stations.size(); }
1365 std::size_t nof_classes() const { return classes.size(); }
1366 std::size_t nof_nodes() const { return nodes.size(); }
1367 std::size_t nof_stateful() const { return stateful_nodes.size(); }
1368
1369 /** `sn.njobs`: the population of each class, infinite for an open one. */
1370 std::vector<double> njobs() const {
1371 std::vector<double> v;
1372 v.reserve(classes.size());
1373 for (const JobClass& c : classes) v.push_back(c.population);
1374 return v;
1375 }
1376
1377 /** `sn.nclosedjobs`: the total population of the closed classes. */
1378 double nclosedjobs() const { return total_jobs(); }
1379
1380 /** `sn.procid(i,r)`: the process type of a (station, class) pair. */
1381 ProcessType procid(std::size_t ist, std::size_t r) const {
1382 return service[ist - 1][r - 1].type;
1383 }
1384
1385 /**
1386 * `sn.phases(i,r)`: the order of the process representation.
1387 *
1388 * A PLACE HOLDS TOKENS IN ONE PHASE, and that is not cosmetic. MATLAB's
1389 * `refreshProcessRepresentations` decides the count from what `ph{ist}{r}`
1390 * turned out to be, and it has TWO ways of having no service:
1391 * - `isempty(ph{ist}{r})` -> `phases = 1`, its "fluid fails otherwise" arm
1392 * - a process that is all NaN -> `phases = 0`
1393 * A non-queueing Place carries a `ServiceTunnel`, so its entry is EMPTY and
1394 * it gets 1; a Join is handed an explicit `Coxian(NaN,NaN)` and gets 0.
1395 * C++ spells both "no service" as a disabled `Distrib`, whose `phases()` is
1396 * 0, so the Place silently took the Join's answer.
1397 *
1398 * The cost was total: `from_marginal_node_first` can build no row for a node
1399 * with 0 phases, so `default_init_state` failed on EVERY Place and every
1400 * closed SPN was refused with "the model's initial marking admits no state"
1401 * -- spn_inhibiting, spn_basic_closed and spn_fourmodes alike, under a
1402 * message about Place populations that were in fact correct.
1403 *
1404 * A QUEUEING Place is untouched: it has a real server and a real law, so it
1405 * is not disabled and answers from its representation as before.
1406 */
1407 std::size_t phases_of(std::size_t ist, std::size_t r) const {
1408 const Distrib<T>& d = service[ist - 1][r - 1];
1409 if (d.disabled && stations[ist - 1].nodetype == NodeType::Place) return 1;
1410 return d.phases();
1411 }
1412 /**
1413 * `sn.phasessz(i,r) = max(sn.phases(i,r),1)`: THE WIDTH of class r's phase
1414 * block in a state row, as opposed to `phases_of`, which is its CONTENT.
1415 *
1416 * The two differ exactly where a class is disabled at a station, and the
1417 * reference keeps the column anyway: a disabled process is stored as a
1418 * `1 x 1 NaN`, never as an empty, so `length(sn.proc{ist}{r}{1})` is 1 and
1419 * `State.fromMarginal` emits one always-zero column for it. A Source
1420 * serving one of six classes therefore writes `[Inf 1 0 0 0 0 0]`, and this
1421 * port used to write `[Inf 1]`.
1422 *
1423 * The narrow row is internally consistent, so it is invisible until it
1424 * CROSSES A BRIDGE: a `model.json` exported by MATLAB carries the wide row,
1425 * and decoding it against a narrow layout put the arrival one-hot in
1426 * another class's column. `solver_ssa_serial` then walked into a state with
1427 * no enabled transition and reported a deadlock rather than a refusal.
1428 *
1429 * Use this wherever a WIDTH or an OFFSET is computed -- `row_layout`,
1430 * `to_marginal`, every `from_marginal*` builder -- and `phases_of` wherever
1431 * the question is whether the class has a process at all, or how many real
1432 * phases it has to iterate over.
1433 */
1434 std::size_t phasessz_of(std::size_t ist, std::size_t r) const {
1435 // A NON-CARRIER MARK OWNS NO PHASE BLOCK. Every mark of a marked
1436 // arrival shares ONE modulating chain, and `set_marked_arrival` binds
1437 // the same `Distrib` to each marked class, so asking each of them for
1438 // its own width would lay K independent copies of the chain into the
1439 // state row -- K independent streams, at K times the offered load.
1440 // The chain lives in the CARRIER's block (mark 1) and a later mark
1441 // contributes one always-zero column, which is MATLAB's
1442 // `sn.phasessz(sn.markidx > 1) = 1`.
1443 if (markidx_of(ist, r) > 1) return 1;
1444 const std::size_t p = phases_of(ist, r);
1445 return p > 0 ? p : 1;
1446 }
1447 /**
1448 * `sn.markidx(i,r)`: the 1-based MARK that class r carries in station i's
1449 * marked arrival process, or 0 when the class carries no mark.
1450 *
1451 * DERIVED from `stations[i-1].marked_classes` rather than stored beside it,
1452 * since that vector already IS the binding -- mark k names class
1453 * `marked_classes[k-1]` -- and a second copy could disagree with it after a
1454 * class removal.
1455 */
1456 std::size_t markidx_of(std::size_t ist, std::size_t r) const {
1457 if (ist == 0 || ist > stations.size()) return 0;
1458 const std::vector<std::size_t>& mc = stations[ist - 1].marked_classes;
1459 for (std::size_t k = 0; k < mc.size(); ++k)
1460 if (mc[k] == r) return k + 1;
1461 return 0;
1462 }
1463 /**
1464 * The CARRIER of station i's marked arrival: the class of mark 1, whose
1465 * phase block holds the one modulating chain every mark shares. 0 when the
1466 * station has no marked process.
1467 */
1468 std::size_t mark_carrier_of(std::size_t ist) const {
1469 if (ist == 0 || ist > stations.size()) return 0;
1470 const std::vector<std::size_t>& mc = stations[ist - 1].marked_classes;
1471 return mc.empty() ? 0 : mc[0];
1472 }
1473 bool has_fork() const {
1474 for (const NodeDef& n : nodes)
1475 if (n.nodetype == NodeType::Fork) return true;
1476 return false;
1477 }
1478 /**
1479 * MATLAB's `any(sn.isstatedep(:,3))` NARROWED TO THE ONE STRATEGY THIS PORT
1480 * EVALUATES PER STATE: Krzesinski's SDR.
1481 *
1482 * RROBIN, WRROBIN, JSQ and SQ are state dependent too and `sn_has_sd_routing`
1483 * reports them, but their per-state tables need auxiliary state this port
1484 * does not carry (a round-robin pointer) or are refused outright, so a
1485 * generator asking "must I re-evaluate the routing at every state?" must ask
1486 * this, not that.
1487 */
1488 bool has_sdr_routing() const {
1489 for (const NodeDef& n : nodes)
1490 for (RoutingStrategy rs : n.routing)
1491 if (rs == RoutingStrategy::SDR) return true;
1492 return false;
1493 }
1494 /**
1495 * ROUND-ROBIN DISPATCH, the state that makes it deterministic.
1496 *
1497 * A round-robin dispatcher is not a coin: which link the next job takes is
1498 * decided by a POINTER the node carries between departures, and without it
1499 * `refresh_routing`'s uniform expansion answers a random-routing model under
1500 * a dispatcher's name. The pointer lives in the node's local-variable block,
1501 * one column per class that routes RROBIN or WRROBIN
1502 * (`refresh_local_vars` allocates it as `nvars[ind-1][R+r-1]`).
1503 *
1504 * RROBIN stores the DESTINATION NODE INDEX in its slot; WRROBIN stores a
1505 * POSITION in the weighted cycle, because a repeated outlink must advance
1506 * once per repetition and a destination value could not tell the copies
1507 * apart. Both are the reference's encodings (`refreshLocalVars.m:318-355`,
1508 * `afterEventStation.m:632-658`, `afterEventRouter.m:22-52`).
1509 */
1510 std::vector<std::size_t> rr_outlinks(std::size_t ind, std::size_t r) const {
1511 std::vector<std::size_t> out;
1512 const T zero = num_traits<T>::from_int(0);
1513 const std::size_t K = classes.size(), I = nodes.size();
1514 for (std::size_t j = 1; j <= I; ++j) {
1515 bool linked = false;
1516 for (std::size_t s = 1; s <= K && !linked; ++s)
1517 if (get_route(r, s, ind, j) > zero) linked = true;
1518 if (linked) out.push_back(j);
1519 }
1520 return out;
1521 }
1522
1523 /** The WRROBIN cycle: each outlink repeated by its weight, weight 0 once. */
1524 std::vector<std::size_t> rr_weighted_outlinks(std::size_t ind, std::size_t r) const {
1525 const std::vector<std::size_t> ol = rr_outlinks(ind, r);
1526 const std::map<std::size_t, double>* w =
1527 (ind <= nodes.size() && nodes[ind - 1].routing_weights.size() >= r)
1528 ? &nodes[ind - 1].routing_weights[r - 1]
1529 : NULL;
1530 std::vector<std::size_t> cycle;
1531 for (std::size_t d = 0; d < ol.size(); ++d) {
1532 long reps = 1;
1533 if (w != NULL) {
1534 const std::map<std::size_t, double>::const_iterator it = w->find(ol[d]);
1535 if (it != w->end() && it->second > 0.0)
1536 reps = std::lround(it->second) < 1 ? 1 : std::lround(it->second);
1537 }
1538 for (long q = 0; q < reps; ++q) cycle.push_back(ol[d]);
1539 }
1540 return cycle;
1541 }
1542
1543 /**
1544 * 1-BASED index of the pointer of (ind, r) INSIDE the node's local-variable
1545 * block, or 0 when that pair does not dispatch round-robin.
1546 *
1547 * `sum(nvars(ind, 1:(R+class)))`, which counts over the phase columns first:
1548 * the reference indexes `space_var` with exactly that, and reading the
1549 * pointer as "the r-th trailing column" instead is wrong the moment a class
1550 * carries a modulating phase or only some classes dispatch.
1551 */
1552 std::size_t rr_var_slot(std::size_t ind, std::size_t r) const {
1553 const std::size_t R = classes.size();
1554 if (ind == 0 || ind > nodes.size() || r == 0 || r > R) return 0;
1555 const std::vector<RoutingStrategy>& rt_i = nodes[ind - 1].routing;
1556 if (rt_i.size() < r) return 0;
1557 if (rt_i[r - 1] != RoutingStrategy::RROBIN && rt_i[r - 1] != RoutingStrategy::WRROBIN)
1558 return 0;
1559 if (nvars.size() < ind) return 0;
1560 std::size_t slot = 0;
1561 for (std::size_t j = 0; j < R + r && j < nvars[ind - 1].size(); ++j)
1562 slot += nvars[ind - 1][j];
1563 return slot;
1564 }
1565
1566 /**
1567 * The destination node the pointer in VARROW names, or 0 when (ind, r) does
1568 * not dispatch round-robin or the pointer is out of range.
1569 *
1570 * @param varrow the node's local-variable columns, as `after_event` slices them
1571 */
1572 std::size_t rr_dest(std::size_t ind, std::size_t r, const std::vector<T>& varrow) const {
1573 const std::size_t slot = rr_var_slot(ind, r);
1574 if (slot == 0 || slot > varrow.size()) return 0;
1575 const long v = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1576 if (nodes[ind - 1].routing[r - 1] == RoutingStrategy::RROBIN)
1577 return v > 0 ? static_cast<std::size_t>(v) : 0;
1578 const std::vector<std::size_t> cycle = rr_weighted_outlinks(ind, r);
1579 if (v < 1 || static_cast<std::size_t>(v) > cycle.size()) return 0;
1580 return cycle[static_cast<std::size_t>(v) - 1];
1581 }
1582
1583 /**
1584 * Advance the pointer of (ind, r) in VARROW by one position, cyclically.
1585 *
1586 * A no-op where the pair does not dispatch round-robin, so a caller can run
1587 * it unconditionally on every departure.
1588 */
1589 void rr_advance(std::size_t ind, std::size_t r, std::vector<T>& varrow) const {
1590 const std::size_t slot = rr_var_slot(ind, r);
1591 if (slot == 0 || slot > varrow.size()) return;
1592 if (nodes[ind - 1].routing[r - 1] == RoutingStrategy::RROBIN) {
1593 const std::vector<std::size_t> ol = rr_outlinks(ind, r);
1594 if (ol.empty()) return;
1595 const long cur = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1596 std::size_t idx = ol.size(); // "not found" -> restart at the first
1597 for (std::size_t d = 0; d < ol.size(); ++d)
1598 if (static_cast<long>(ol[d]) == cur) { idx = d; break; }
1599 const std::size_t nxt = (idx + 1 < ol.size()) ? idx + 1 : 0;
1600 varrow[slot - 1] = num_traits<T>::from_int(static_cast<long>(ol[nxt]));
1601 return;
1602 }
1603 const std::vector<std::size_t> cycle = rr_weighted_outlinks(ind, r);
1604 if (cycle.empty()) return;
1605 const long pos = std::lround(num_traits<T>::to_double(varrow[slot - 1]));
1606 const long nxt = (pos < 1 || static_cast<std::size_t>(pos) >= cycle.size()) ? 1 : pos + 1;
1607 varrow[slot - 1] = num_traits<T>::from_int(nxt);
1608 }
1609
1610 /** Whether ANY (node, class) pair dispatches round-robin. */
1611 bool has_rr_routing() const {
1612 for (const NodeDef& n : nodes)
1613 for (RoutingStrategy rs : n.routing)
1614 if (rs == RoutingStrategy::RROBIN || rs == RoutingStrategy::WRROBIN) return true;
1615 return false;
1616 }
1617
1618 /**
1619 * Whether immediate feedback is EFFECTIVE anywhere in the model.
1620 *
1621 * A DECLARATION ALONE IS NOT ENOUGH. `Queue.setImmediateFeedback` marks a
1622 * station and `JobClass.setImmediateFeedback` marks a class -- and the class
1623 * spelling marks EVERY station, since `immfeed` is the OR of the two -- so a
1624 * model with no self-loop at all can carry a full `immfeed` matrix while the
1625 * feature changes nothing. Reading the raw matrix made every solver that
1626 * consults it warn, or refuse, on a plain M/M/1 that merely mentioned the flag.
1627 *
1628 * Immediate feedback is effective at (station i, class r) when `immfeed[i][r]`
1629 * holds AND the routing table has a self-loop INTO (i, r) from some class s at
1630 * the same station, which is the only way a job can come back to the server it
1631 * just left. A class switch on the way round is folded into `rt` by
1632 * `refresh_routing`, so the incoming class s need not be r.
1633 *
1634 * Solvers that HANDLE immediate feedback look at the SYNCHRONIZATION instead;
1635 * see `qn::immfeed_self_loop`, which applies the same test per sync.
1636 */
1638 bool declared = false;
1639 for (const std::vector<bool>& row : immfeed) {
1640 for (bool b : row)
1641 if (b) {
1642 declared = true;
1643 break;
1644 }
1645 if (declared) break;
1646 }
1647 if (!declared) return false;
1648 // Without a routing table there is nothing to qualify the declaration
1649 // with, so report it as declared rather than silently dropping it.
1650 if (rt.rows() == 0 || rt.cols() == 0) return true;
1651 const std::size_t R = nclasses;
1652 for (std::size_t ist = 1; ist <= immfeed.size(); ++ist) {
1653 const std::size_t ind = node_of_station(ist);
1654 if (ind == 0 || ind > nodes.size()) continue;
1655 const std::size_t isf = stateful_index(ind);
1656 if (isf == 0) continue;
1657 for (std::size_t r = 1; r <= immfeed[ist - 1].size() && r <= R; ++r) {
1658 if (!immfeed[ist - 1][r - 1]) continue;
1659 const std::size_t col = (isf - 1) * R + (r - 1);
1660 if (col >= rt.cols()) continue;
1661 for (std::size_t sc = 0; sc < R; ++sc) {
1662 const std::size_t row = (isf - 1) * R + sc;
1663 if (row < rt.rows() &&
1664 num_traits<T>::to_double(rt(row, col)) > 0)
1665 return true;
1666 }
1667 }
1668 }
1669 return false;
1670 }
1671 /** 1-based node index of a station, and the reverse; 0 when absent. */
1672 /** 1-based node index of station `st`, 0 when the map has no entry. */
1673 std::size_t node_of_station(std::size_t st) const {
1674 // A Layer built by the LQN path can carry fewer map entries than
1675 // stations; returning 0 says "no node" rather than reading past the end.
1676 if (st == 0 || st > station_to_node.size()) return 0;
1677 return station_to_node[st - 1];
1678 }
1679 /** 1-based stateful index of node `ind`, 0 when the node is not stateful. */
1680 std::size_t stateful_index(std::size_t ind) const {
1681 for (std::size_t k = 0; k < stateful_nodes.size(); ++k)
1682 if (stateful_nodes[k] == ind) return k + 1;
1683 return 0;
1684 }
1685
1686 std::size_t stateful_of_station(std::size_t st) const {
1687 const std::size_t nd = station_to_node[st - 1];
1688 for (std::size_t k = 0; k < stateful_nodes.size(); ++k)
1689 if (stateful_nodes[k] == nd) return k + 1;
1690 throw InputError("network: a station is not a stateful node");
1691 }
1692
1693 /** Add a station, which is also a node, and grow the service table. */
1694 std::size_t add_station(const Station<T>& st) {
1695 stations.push_back(st);
1696 service.emplace_back(classes.size(), Distrib<T>::disabled_dist());
1697 NodeDef nd;
1698 nd.name = st.name;
1699 nd.nodetype = st.nodetype;
1700 nd.stateful = true;
1701 nd.station = stations.size();
1702 nodes.push_back(nd);
1703 station_to_node.push_back(nodes.size());
1704 stateful_nodes.push_back(nodes.size());
1705 grow_routing();
1706 return stations.size();
1707 }
1708
1709 /** Add a non-station node (a Fork, a Router). Returns its 1-based index. */
1710 std::size_t add_node(const std::string& nm, NodeType ty, bool stateful) {
1711 NodeDef nd;
1712 nd.name = nm;
1713 nd.nodetype = ty;
1714 nd.stateful = stateful;
1715 nd.station = 0;
1716 nodes.push_back(nd);
1717 if (stateful) stateful_nodes.push_back(nodes.size());
1718 grow_routing();
1719 return nodes.size();
1720 }
1721
1722 /** Add a class and grow the service table. */
1723 std::size_t add_class(const JobClass& cl) {
1724 classes.push_back(cl);
1725 for (auto& row : service) row.emplace_back(Distrib<T>::disabled_dist());
1726 return classes.size();
1727 }
1728
1729 void set_service(std::size_t station, std::size_t cls, const Distrib<T>& d) {
1730 service[station - 1][cls - 1] = d;
1731 }
1732
1733 /** P{r,s}(i,j) = p, with 1-based NODE and class indices. */
1734 void set_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j, const T& p) {
1735 auto key = std::make_pair(r, s);
1736 auto it = P.find(key);
1737 if (it == P.end())
1738 it = P.emplace(key, Matrix<T>(nodes.size(), nodes.size(), num_traits<T>::from_int(0)))
1739 .first;
1740 if (it->second.rows() != nodes.size()) grow_block(it->second);
1741 it->second(i - 1, j - 1) = p;
1742 }
1743
1744 /**
1745 * Write into the routing the consumers actually read.
1746 *
1747 * The Sink -> Source closure is derived by the refresh, not given by the
1748 * user, so it belongs in the effective routing; writing it into P alone
1749 * would make it invisible on any model whose routing was expanded.
1750 */
1751 void set_route_effective(std::size_t r, std::size_t s, std::size_t i, std::size_t j,
1752 const T& p) {
1753 if (Peff.empty()) {
1754 set_route(r, s, i, j, p);
1755 return;
1756 }
1757 auto key = std::make_pair(r, s);
1758 auto it = Peff.find(key);
1759 if (it == Peff.end())
1760 it = Peff.emplace(key, Matrix<T>(nodes.size(), nodes.size(), num_traits<T>::from_int(0)))
1761 .first;
1762 if (it->second.rows() != nodes.size()) {
1763 Matrix<T> g(nodes.size(), nodes.size(), num_traits<T>::from_int(0));
1764 for (std::size_t a = 0; a < it->second.rows(); ++a)
1765 for (std::size_t b = 0; b < it->second.cols(); ++b) g(a, b) = it->second(a, b);
1766 it->second = g;
1767 }
1768 it->second(i - 1, j - 1) = p;
1769 }
1770
1771 /**
1772 * P{r,s}(i,j), AS THE USER SET IT.
1773 *
1774 * Every consumer of the routing reads route_eff() instead, which is this
1775 * matrix once refresh_routing() has expanded the strategies that are not
1776 * literal probabilities. The two agree on a model whose routing is entirely
1777 * PROB, which is every model the LN layer builder produces.
1778 */
1779 T get_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const {
1780 auto it = P.find(std::make_pair(r, s));
1781 if (it == P.end() || it->second.rows() < nodes.size()) return num_traits<T>::from_int(0);
1782 return it->second(i - 1, j - 1);
1783 }
1784
1785 /** The routing actually in force: the expansion when there is one, else P. */
1786 T route_eff(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const {
1787 if (Peff.empty()) return get_route(r, s, i, j);
1788 auto it = Peff.find(std::make_pair(r, s));
1789 if (it == Peff.end() || it->second.rows() < nodes.size()) return num_traits<T>::from_int(0);
1790 return it->second(i - 1, j - 1);
1791 }
1792
1793 // -----------------------------------------------------------------------
1794 // Refresh
1795 // -----------------------------------------------------------------------
1796
1797 /**
1798 * The whole chain, in MATLAB's refreshStruct order.
1799 *
1800 * The order is load bearing: the routing expansion must precede the chains
1801 * (they are read off the class-switch structure of the routing), the chains
1802 * must precede the capacities (a station's buffer is bounded by the
1803 * population of the chains that reach it), and the visits must precede
1804 * nothing but must follow the sink closure, which needs the chains.
1805 */
1808 line::util::LineConsole::compile_detail("refreshing service and arrival processes");
1809 refresh_rates();
1811 // BEFORE the routing table: a round-robin Router must be STATEFUL, or
1812 // the stochastic complement that builds `rt` erases it and the pointer
1813 // has nowhere to live.
1815 line::util::LineConsole::compile_detail("computing the routing table");
1817 line::util::LineConsole::compile_detail("computing the chains and the visit ratios");
1820 "found %s over %s",
1821 line::util::LineConsole::plural(static_cast<long>(this->nchains), "chain", "chains").c_str(),
1822 line::util::LineConsole::plural(static_cast<long>(this->classes.size()), "class", "classes").c_str());
1824 refresh_rt();
1825 line::util::LineConsole::compile_detail("refreshing node parameters and state-dependent routing");
1828 // Needs the visit ratios, so it runs after refresh_chains.
1830 }
1831
1832 /**
1833 * True when node `ind` (1-based) holds a server across a synchronous call
1834 * whose reply class is `r` (1-based), i.e. `r` returns here to release a
1835 * server rather than to be served by one.
1836 */
1837 bool holds_reply_for(std::size_t ind, std::size_t r) const {
1838 if (ind == 0 || ind > replyblock.size()) return false;
1839 for (std::size_t k = 0; k < syncreply.size(); ++k)
1840 if (syncreply[k] == r && k < replyblock[ind - 1].size() && replyblock[ind - 1][k])
1841 return true;
1842 return false;
1843 }
1844
1845 /**
1846 * Whether a job of class `r` (0-based) can LEAVE node `ind` (1-based) again.
1847 *
1848 * True for anything that is not a service station, and for a station that
1849 * serves `r`, declares heterogeneous server types (its per-class process is
1850 * disabled by construction there) or holds a server across a synchronous
1851 * call whose reply class is `r`. False only for the flow sink itself, which
1852 * is what stops the walk in `reached_node_classes` -- the same three
1853 * exemptions the guard applies, kept in one place so the walk and the
1854 * verdict cannot drift apart.
1855 */
1856 bool serves_class(std::size_t ind, std::size_t r) const {
1857 if (ind == 0 || ind > nodes.size()) return true;
1858 const NodeType nt = nodes[ind - 1].nodetype;
1859 if (nt != NodeType::Queue && nt != NodeType::Delay) return true;
1860 const std::size_t sti = nodes[ind - 1].station;
1861 if (sti == 0 || sti > stations.size()) return true;
1862 if (!stations[sti - 1].server_types.empty()) return true;
1863 if (procid(sti, r + 1) != ProcessType::DISABLED) return true;
1864 return holds_reply_for(ind, r + 1);
1865 }
1866
1867 /**
1868 * The (node, class) pairs a job can actually ARRIVE at, 0-based on both axes.
1869 *
1870 * A forward walk of `rtnodes` from the feed points. See
1871 * `check_service_reachable` for why the evidence is the UNMASKED routing
1872 * kernel rather than `nodevisits`, and for the three rules -- absorbing
1873 * Sink, Source expanded only as a seed, unservable pair reached but not
1874 * expanded -- that keep the walk from over-approximating.
1875 *
1876 * Returns an empty vector when `rtnodes` is not the expected (N*K) square,
1877 * which leaves the caller checking nothing, exactly as before.
1878 */
1879 std::vector<std::vector<bool>> reached_node_classes() const {
1880 const std::size_t N = nodes.size(), K = nclasses;
1881 std::vector<std::vector<bool>> reached;
1882 if (N == 0 || K == 0) return reached;
1883 if (rtnodes.rows() < N * K || rtnodes.cols() < N * K) return reached;
1884 reached.assign(N, std::vector<bool>(K, false));
1885 std::vector<std::vector<bool>> seed(N, std::vector<bool>(K, false));
1886 for (std::size_t i = 0; i < stations.size(); ++i) {
1887 if (stations[i].nodetype != NodeType::Source) continue;
1888 const std::size_t ind = node_of_station(i + 1);
1889 if (ind == 0 || ind > N) continue;
1890 for (std::size_t r = 0; r < K; ++r)
1891 if (procid(i + 1, r + 1) != ProcessType::DISABLED) seed[ind - 1][r] = true;
1892 }
1893 for (std::size_t r = 0; r < K && r < classes.size(); ++r) {
1894 const double pop = classes[r].population;
1895 if (!std::isfinite(pop) || pop <= 0.0) continue;
1896 const std::size_t ind = node_of_station(classes[r].refstat);
1897 if (ind == 0 || ind > N) continue;
1898 seed[ind - 1][r] = true;
1899 }
1900 std::vector<std::pair<std::size_t, std::size_t>> stack;
1901 for (std::size_t i = 0; i < N; ++i)
1902 for (std::size_t r = 0; r < K; ++r)
1903 if (seed[i][r]) {
1904 reached[i][r] = true;
1905 stack.push_back(std::make_pair(i, r));
1906 }
1907 while (!stack.empty()) {
1908 const std::size_t i = stack.back().first;
1909 const std::size_t r = stack.back().second;
1910 stack.pop_back();
1911 const NodeType nt = nodes[i].nodetype;
1912 if (nt == NodeType::Sink) continue;
1913 if (nt == NodeType::Source && !seed[i][r]) continue;
1914 if (!serves_class(i + 1, r)) continue;
1915 const std::size_t row = i * K + r;
1916 for (std::size_t col = 0; col < N * K; ++col) {
1917 if (num_traits<T>::to_double(rtnodes(row, col)) <=
1919 continue;
1920 const std::size_t j = col / K, sIdx = col % K;
1921 if (!reached[j][sIdx]) {
1922 reached[j][sIdx] = true;
1923 stack.push_back(std::make_pair(j, sIdx));
1924 }
1925 }
1926 }
1927 return reached;
1928 }
1929
1930 /**
1931 * Refuses a class that is ROUTED TO a station which cannot serve it.
1932 *
1933 * The reference's `sanitize` disables the OUTGOING routing of a class a
1934 * station cannot serve, which is what keeps it out of that station's visit
1935 * ratios -- but nothing stopped the class being routed IN, and a class that
1936 * arrives where it cannot be served is a flow sink: it enters and never
1937 * leaves. The station-level guard next to it cannot see this, because it
1938 * asks whether the station serves ANY class, not whether it serves the
1939 * classes that reach it.
1940 *
1941 * One such model gave three different wrong answers, none flagged, on a
1942 * closed cycle D <-> Q whose class C2 has no service at Q: MVA reported
1943 * Q/C2 with ArvR 1 against Tput 0, CTMC dropped C2 entirely, and SSA
1944 * returned D/C2 QLen 2e-06 with the Q rows absent.
1945 *
1946 * READS `rtnodes`, WALKED FORWARD FROM THE FEED POINTS -- not `nodevisits`,
1947 * which this guard read until 2026-09-02 and which 94d5570f3 had made blind
1948 * to the very case it exists for. That commit extended the `served` mask
1949 * from the station chain to the NODE chain, and it had to: on a materialised
1950 * LQN replica the unserved states close into a spurious cycle. But the mask
1951 * zeroes exactly the (station, class) cell a flow sink shows up in. On
1952 * Source -> Q -> Sink with class B unservable at Q, B's chain went from
1953 * Q = 1 to Q = 0 and the guard fell silent, while the Sink still read 1 --
1954 * flow arriving downstream of a node it never visited. A MASKED VISIT VECTOR
1955 * CANNOT ANSWER THIS QUESTION, because the mask IS the answer being looked
1956 * for. Do not route this guard back through `nodevisits` or `visits`; both
1957 * carry that mask. See _kb/07-cross-language-parity.md.
1958 *
1959 * `rtnodes` on its own over-approximates -- it says where a class WOULD go
1960 * if one existed -- and the WALK is what removes the slack. It starts only
1961 * at (Source, class) pairs whose arrival process is not DISABLED, and at the
1962 * reference station of each closed class with a positive population, so the
1963 * disabled-arrival row a class-switching Source carries is never entered.
1964 * Three rules keep it honest: a Sink is ABSORBING (`rtnodes` wraps it back
1965 * to the Source to close the kernel, and following that wrap re-enters every
1966 * Source row, including the disabled ones the seeding just excluded); a
1967 * Source is expanded ONLY AS A SEED, for the same reason; and an unservable
1968 * (station, class) is REACHED BUT NOT EXPANDED, since nothing leaves it --
1969 * that is the whole complaint -- so nothing downstream of it is evidence of
1970 * anything.
1971 *
1972 * This SUBSUMES the fed-chain precondition the guard used to carry
1973 * separately: a chain no job can enter has no seed, so its rows are never
1974 * walked at all. That is strictly finer than the per-chain test it replaces,
1975 * which admitted every class of a chain any one of whose classes was fed.
1976 */
1978 if (nclasses == 0) return;
1979 // Same exemption as the reference's sanitize checks: a station of a
1980 // cache, Petri-net or fork-join model legitimately carries no per-class
1981 // service.
1982 for (std::size_t ind = 0; ind < nodes.size(); ++ind) {
1983 const NodeType nt = nodes[ind].nodetype;
1984 if (nt == NodeType::Cache || nt == NodeType::Place ||
1985 nt == NodeType::Transition || nt == NodeType::Fork ||
1986 nt == NodeType::Join)
1987 return;
1988 }
1989 const std::size_t K = nclasses;
1990 const std::vector<std::vector<bool>> reached = reached_node_classes();
1991 if (reached.empty()) return;
1992 for (std::size_t i = 0; i < nstations; ++i) {
1993 const NodeType nt = stations[i].nodetype;
1994 if (nt != NodeType::Queue && nt != NodeType::Delay) continue;
1995 // A heterogeneous pool carries its service on the server types, so
1996 // the per-class process is legitimately disabled there. Skipped in
1997 // all four codebases.
1998 if (!stations[i].server_types.empty()) continue;
1999 const std::size_t ind = node_of_station(i + 1);
2000 if (ind == 0) continue;
2001 for (std::size_t r = 0; r < K; ++r) {
2002 if (procid(i + 1, r + 1) != ProcessType::DISABLED) continue;
2003 // A SYNCHRONOUS REPLY is not served by the station it returns
2004 // to: it releases the server that station held across the call,
2005 // which is the whole content of set_sync_reply. Its Disabled
2006 // service there is the marker of the feature, not a flow sink.
2007 if (holds_reply_for(ind, r + 1)) continue;
2008 if (ind - 1 >= reached.size() || r >= reached[ind - 1].size()) continue;
2009 if (!reached[ind - 1][r]) continue;
2010 const std::string kind = (nt == NodeType::Delay) ? "Delay" : "Queue";
2011 throw InputError(kind + " '" + stations[i].name +
2012 "' has no service configured for job class '" +
2013 classes[r].name +
2014 "', but the class is routed to it. Jobs would arrive and "
2015 "never leave. Configure a service for that class, or route "
2016 "it elsewhere.");
2017 }
2018 }
2019 }
2020
2021 /**
2022 * Port of the `sn.immfeed` block of `@@MNetwork/refreshStruct.m`.
2023 *
2024 * The station's own per-class setting OR the class-level one, which is the
2025 * reference's `stationHas || classHas`. Sized here rather than in
2026 * `refresh_rates` because it is a property of the model's topology and not
2027 * of the service processes, so it must not be cleared when only the rates
2028 * are re-derived (SolverLN calls `refresh_rates` per layer, per iteration).
2029 */
2031 const std::size_t M = stations.size(), R = classes.size();
2032 immfeed.assign(M, std::vector<bool>(R, false));
2033 for (std::size_t i = 0; i < M; ++i)
2034 for (std::size_t r = 0; r < R; ++r) {
2035 const bool station_has =
2036 r < stations[i].immfeed.size() && stations[i].immfeed[r];
2037 immfeed[i][r] = station_has || classes[r].immfeed;
2038 }
2039 }
2040
2041 /**
2042 * Port of MNetwork.refreshLocalVars: the per-node local-variable widths.
2043 *
2044 * A column is reserved only where the model actually needs one, because the
2045 * width is part of the state encoding -- a spurious column widens every
2046 * state row and makes it unmatchable against the enumerated space.
2047 */
2048 /**
2049 * A Router that DISPATCHES ROUND-ROBIN holds state, so it must survive the
2050 * stochastic complement that removes the stateless nodes.
2051 *
2052 * The reference makes EVERY Router stateful (`refreshStruct.m:155`); this
2053 * port promotes only the dispatching ones, because a PROB or RAND Router
2054 * genuinely holds nothing and complementing it away is both correct and
2055 * cheaper -- it keeps the state space of every existing model unchanged.
2056 * The promotion happens at refresh rather than at `add_router`, since
2057 * `set_routing` is called after the node exists.
2058 */
2059 /**
2060 * Make node `ind` stateful, keeping `stateful_nodes` ascending. Idempotent.
2061 *
2062 * Used by the fork-join transforms: the Router they put where a Fork stood
2063 * is a `Router` OBJECT in the reference, hence a `StatefulNode`, and it has
2064 * to stay one here. Its chain block is deliberately NOT stochastic -- an
2065 * auxiliary class keeps the OTHER fork's full fan-out, so that row sums to
2066 * the fan-out -- and on such a block the stochastic complement is not
2067 * visit-preserving, so complementing the node away moves every downstream
2068 * visit ratio off the reference's.
2069 */
2070 void promote_stateful(std::size_t ind) {
2071 if (ind == 0 || ind > nodes.size() || nodes[ind - 1].stateful) return;
2072 nodes[ind - 1].stateful = true;
2073 stateful_nodes.insert(std::lower_bound(stateful_nodes.begin(), stateful_nodes.end(), ind),
2074 ind);
2075 }
2076
2078 for (std::size_t ind = 1; ind <= nodes.size(); ++ind) {
2079 NodeDef& nd = nodes[ind - 1];
2080 if (nd.stateful || nd.nodetype != NodeType::Router) continue;
2081 bool rr = false;
2082 for (std::size_t r = 0; r < nd.routing.size(); ++r)
2083 if (nd.routing[r] == RoutingStrategy::RROBIN ||
2084 nd.routing[r] == RoutingStrategy::WRROBIN)
2085 rr = true;
2086 if (!rr) continue;
2087 nd.stateful = true;
2088 stateful_nodes.insert(
2089 std::lower_bound(stateful_nodes.begin(), stateful_nodes.end(), ind), ind);
2090 }
2091 }
2092
2094 const std::size_t R = classes.size();
2095 // One entry per class, 0 = none, as MATLAB's -ones(nclasses,1): a class added after the last binding
2096 // (LQN2QN's Ph2End destructors) or a model with no binding at all would otherwise leave it short
2097 syncreply.resize(R, 0);
2099 nvars.assign(nodes.size(), std::vector<std::size_t>(3 * R + 1, 0));
2100 for (std::size_t ind = 1; ind <= nodes.size(); ++ind) {
2101 const std::size_t ist = nodes[ind - 1].station;
2102 // A Markov-modulated process restarts from the phase it was left
2103 // in, so that phase has to survive in the state between services.
2104 if (ist != 0)
2105 for (std::size_t r = 1; r <= R; ++r) {
2106 const ProcessType pt = procid(ist, r);
2107 if (pt == ProcessType::MAP || pt == ProcessType::MMPP2)
2108 nvars[ind - 1][r - 1] += 1;
2109 }
2110 // Round-robin routing is stateful: the pointer to the next outgoing
2111 // link is what makes it deterministic rather than random.
2112 const std::vector<RoutingStrategy>& rt_i = nodes[ind - 1].routing;
2113 for (std::size_t r = 1; r <= R && r <= rt_i.size(); ++r)
2114 if (rt_i[r - 1] == RoutingStrategy::RROBIN ||
2115 rt_i[r - 1] == RoutingStrategy::WRROBIN)
2116 nvars[ind - 1][R + r - 1] += 1;
2117 // The shared node block. A Cache stores its list contents here;
2118 // the BAS marker, the breakdown status and the polling controller
2119 // claim the same column, which is why the reference rejects those
2120 // combinations instead of widening the state.
2121 // The polling controller shares the node block with the cache,
2122 // the BAS marker and the breakdown status, which is why those
2123 // combinations are rejected rather than the state widened.
2124 // The SAME resolution polling_info reads, or its offset misses nvars.
2125 if (ist != 0 && stations[ist - 1].sched == SchedStrategy::POLLING) {
2126 const PollingParam pp = effective_polling(ist);
2127 bool anysw = false;
2128 for (std::size_t r = 0; r < pp.switchover.size(); ++r) {
2129 const lang::Distrib<T>& d = pp.switchover[r];
2130 if (!d.disabled && d.D0.rows() > 0 && d.type != ProcessType::IMMEDIATE)
2131 anysw = true;
2132 }
2133 std::size_t w = anysw ? 2 : 0; // pos and swk
2134 if (pp.ptype != lang::PollingType::EXHAUSTIVE) w += 1; // ctr
2135 nvars[ind - 1][2 * R] = w;
2136 }
2137 // The REPLY block: one counter column per (node, calling class).
2138 for (std::size_t r = 1; r <= R; ++r)
2139 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2140 replyblock[ind - 1][r - 1])
2141 nvars[ind - 1][2 * R + r] = 1;
2142 // The SERVER STATUS of a station that breaks down (1 = up, 0 = down),
2143 // `refreshLocalVars.m`. It takes the shared node block, so a polling
2144 // controller there is refused as the reference refuses it; the BAS
2145 // marker is refused by `refresh_bas_blocking`. The status must be
2146 // the TRAILING column, which is where the DEP, PHASE, FAILURE and
2147 // REPAIR handlers read it, so a reply block (whose counters trail
2148 // the node block in this port) is refused too.
2149 if (ist != 0 && breakdownparam.find(ist) != breakdownparam.end()) {
2150 if (stations[ist - 1].sched == SchedStrategy::POLLING)
2151 throw UnsupportedError(
2152 "Server breakdowns are not supported at a polling station (" +
2153 nodes[ind - 1].name +
2154 "): the polling controller and the breakdown status share one "
2155 "local-state column.");
2156 for (std::size_t r = 1; r <= R; ++r)
2157 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2158 replyblock[ind - 1][r - 1])
2159 throw UnsupportedError(
2160 "Server breakdowns are not supported at station '" +
2161 nodes[ind - 1].name +
2162 "', which also holds servers for a synchronous reply: the reply "
2163 "counters would trail the breakdown status the service handlers read");
2164 nvars[ind - 1][2 * R] = 1;
2165 }
2166 const typename std::map<std::size_t, CacheParam<T>>::const_iterator ci =
2167 nodeparam.find(ind);
2168 if (ci != nodeparam.end()) {
2169 // Contents, plus block A (a per-item occupancy bitmap) and block
2170 // B (a per-retrieval-class count of merged secondary requests)
2171 // when a delayed-hit retrieval system is attached.
2172 std::size_t w = 0;
2173 for (std::size_t u = 0; u < ci->second.itemcap.size(); ++u)
2174 if (ci->second.itemcap[u] > 0)
2175 w += static_cast<std::size_t>(ci->second.itemcap[u]);
2176 if (ci->second.retrieval_capacity > 0) {
2177 w += ci->second.nitems;
2178 std::vector<std::size_t> rcl, rci, rco;
2179 cache_retrieval_class_map(ci->second, rcl, rci, rco);
2180 w += rcl.size();
2181 }
2182 nvars[ind - 1][2 * R] = w;
2183 }
2184 }
2186 }
2187
2188 /**
2189 * `sn.replyblock`, DERIVED from `sn.syncreply` and the routing.
2190 *
2191 * Port of `refreshLocalVars.m:340-386`. A server is held wherever the REPLY
2192 * class can arrive: every node that is a station, is not the Source, and is
2193 * not an infinite server -- an INF station has a server per job, so holding
2194 * one is immaterial and needs no state. Every such station must be FCFS,
2195 * because a held server is encoded as a per-class COUNTER, which is exact
2196 * only where the servers are interchangeable.
2197 *
2198 * Derived rather than declared because the model layer carries only the
2199 * class-to-class binding (`JobClass.setReplySignalClass`), and a caller
2200 * naming the nodes itself would silently disagree with the reference on any
2201 * model whose routing sends the reply somewhere it did not think of.
2202 */
2204 const std::size_t R = classes.size(), N = nodes.size();
2205 bool any = false;
2206 for (std::size_t r = 0; r < R && r < syncreply.size(); ++r)
2207 if (syncreply[r] >= 1 && syncreply[r] <= R) any = true;
2208 if (!any) return;
2209 replyblock.assign(N, std::vector<bool>(R, false));
2210 for (std::size_t r = 1; r <= R; ++r) {
2211 if (r > syncreply.size()) break;
2212 const std::size_t s = syncreply[r - 1];
2213 if (s < 1 || s > R) continue;
2214 for (std::size_t ind = 1; ind <= N; ++ind) {
2215 const std::size_t ist = nodes[ind - 1].station;
2216 if (ist == 0 || nodes[ind - 1].nodetype == lang::NodeType::Source) continue;
2217 if (stations[ist - 1].sched == SchedStrategy::INF) continue;
2218 bool arrives_here = false;
2219 for (std::size_t i = 1; i <= N && !arrives_here; ++i)
2220 for (std::size_t q = 1; q <= R; ++q)
2221 if (rtnodes.rows() >= N * R &&
2223 rtnodes((i - 1) * R + q - 1, (ind - 1) * R + s - 1)) > 0) {
2224 arrives_here = true;
2225 break;
2226 }
2227 if (!arrives_here) continue;
2228 if (stations[ist - 1].sched != SchedStrategy::FCFS)
2229 throw InputError(
2230 "network '" + name +
2231 "': synchronous calls (REPLY signals) are supported only at FCFS "
2232 "stations, but '" +
2233 nodes[ind - 1].name +
2234 "' uses another discipline. A held server is encoded as a per-class "
2235 "counter, which is exact only where servers are interchangeable (FCFS) "
2236 "or unlimited (INF); the other disciplines are not yet encoded rather than "
2237 "infeasible. Set this station to FCFS or INF, or simulate the layered model "
2238 "directly with SolverLDES.");
2239 replyblock[ind - 1][r - 1] = true;
2240 }
2241 }
2242 }
2243
2244 /**
2245 * The nodes directly downstream of `ind`, walking THROUGH stateless nodes
2246 * and stopping at the first station on each path.
2247 *
2248 * `downstreamStations` in `refreshLocalVars.m`. A blocked job is held for its
2249 * IMMEDIATE destination, so the walk stops at the first station: a Router or
2250 * ClassSwitch in between is a routing decision, not a place to wait.
2251 *
2252 * The reference reads `sn.connmatrix`; this port has no such field and reads
2253 * `rtnodes` instead, which is the same graph after the refresh has resolved
2254 * the routing strategies. The two differ only on a link the model declares
2255 * and then routes zero mass over, and such a link cannot fill a destination,
2256 * so it cannot block anything either.
2257 */
2258 std::vector<std::size_t> downstream_stations(std::size_t ind) const {
2259 const std::size_t R = classes.size();
2260 const std::size_t N = nodes.size();
2261 std::vector<std::size_t> out;
2262 if (rtnodes.rows() < N * R) return out;
2263 std::vector<bool> seen(N + 1, false);
2264 std::vector<std::size_t> frontier(1, ind);
2265 seen[ind] = true;
2266 while (!frontier.empty()) {
2267 const std::size_t i = frontier.back();
2268 frontier.pop_back();
2269 for (std::size_t j = 1; j <= N; ++j) {
2270 if (seen[j]) continue;
2271 bool linked = false;
2272 for (std::size_t r = 0; r < R && !linked; ++r)
2273 for (std::size_t s = 0; s < R && !linked; ++s)
2275 rtnodes((i - 1) * R + r, (j - 1) * R + s)) > 0)
2276 linked = true;
2277 if (!linked) continue;
2278 seen[j] = true;
2279 if (nodes[j - 1].station != 0) {
2280 out.push_back(j); // a station terminates this path
2281 } else {
2282 frontier.push_back(j); // walk through the stateless node
2283 }
2284 }
2285 }
2286 return out;
2287 }
2288
2289 /**
2290 * Port of `refreshLocalVars`' true-BAS block and its `declaresBlockedMarker`
2291 * helper: which nodes carry the blocked marker, and where a refusal blocks.
2292 */
2294 const std::size_t R = classes.size();
2295 const std::size_t N = nodes.size();
2296 isbasblocking.assign(N, false);
2297 isbasdestination.assign(stations.size(), std::vector<bool>(R, false));
2298 if (droprule.empty()) return;
2299 for (std::size_t ind = 1; ind <= N; ++ind) {
2300 const std::size_t ist = nodes[ind - 1].station;
2301 if (ist == 0) continue;
2302 const NodeType nt = nodes[ind - 1].nodetype;
2303 if (nt == NodeType::Source || nt == NodeType::Cache) continue;
2304 const std::vector<std::size_t> dests = downstream_stations(ind);
2305 bool declares = false;
2306 for (std::size_t r = 1; r <= R; ++r) {
2307 const bool here_bas = droprule.size() >= ist && droprule[ist - 1].size() >= r &&
2308 droprule[ist - 1][r - 1] == DropStrategy::BAS;
2309 for (std::size_t d = 0; d < dests.size(); ++d) {
2310 const std::size_t jst = nodes[dests[d] - 1].station;
2311 if (jst == 0 || nodes[dests[d] - 1].nodetype == NodeType::Source) continue;
2312 // The destination must be able to FILL. An unbounded queue
2313 // never refuses, so nothing upstream of it can ever block,
2314 // and reserving the marker would widen the state for nothing.
2315 //
2316 // THE DECLARED CAPACITY, not the refreshed `cap`. The refresh
2317 // clamps an unbounded station to the total closed population,
2318 // which is finite, so reading `cap` here would find every
2319 // destination in a closed model "able to fill" and reserve a
2320 // marker on every upstream station. The reference reads
2321 // `dnode.cap`, the value the user set.
2322 const double dcap = stations[jst - 1].cap;
2323 if (!std::isfinite(dcap) || !(dcap > 0)) continue;
2324 const bool there_bas =
2325 droprule.size() >= jst && droprule[jst - 1].size() >= r &&
2326 droprule[jst - 1][r - 1] == DropStrategy::BAS;
2327 if (!here_bas && !there_bas) continue;
2328 // Do NOT stop at the first hit: every such destination has to
2329 // be recorded, since a refusal at ANY of them must block here
2330 // rather than drop.
2331 declares = true;
2332 isbasdestination[jst - 1][r - 1] = true;
2333 }
2334 }
2335 if (!declares) continue;
2336 // The marker shares nvars column 2R+1 with the cache contents, the
2337 // breakdown status and the polling controller, so the reference
2338 // rejects the combinations rather than widening the state.
2339 if (breakdownparam.find(ist) != breakdownparam.end())
2340 throw UnsupportedError(
2341 "station '" + nodes[ind - 1].name +
2342 "' combines server breakdowns with true-BAS blocking: the breakdown status "
2343 "and the BAS blocked marker share one local-state column. Remove the BAS drop "
2344 "rule or the breakdown");
2345 if (stations[ist - 1].sched == SchedStrategy::POLLING)
2346 throw UnsupportedError(
2347 "true BAS blocking is not supported at the polling station '" +
2348 nodes[ind - 1].name +
2349 "': the polling controller and the BAS blocked marker share one local-state "
2350 "column. Use a non-polling discipline at the blocking station, or remove the "
2351 "BAS drop rule");
2352 for (std::size_t r = 1; r <= R; ++r)
2353 if (replyblock.size() >= ind && replyblock[ind - 1].size() >= r &&
2354 replyblock[ind - 1][r - 1])
2355 throw UnsupportedError(
2356 "true BAS blocking is not supported at station '" + nodes[ind - 1].name +
2357 "', which also holds servers for a synchronous reply: fromMarginal appends "
2358 "the reply counters AFTER the blocked marker, so the marker would no "
2359 "longer be the trailing column the departure handler reads");
2360 nvars[ind - 1][2 * R] = 1;
2361 isbasblocking[ind - 1] = true;
2362 }
2363 }
2364
2365 /**
2366 * Port of MNetwork.refreshScheduling's schedparam half.
2367 *
2368 * DPS and GPS take a per-class weight, defaulting to 1; SEPT and LEPT take
2369 * the rank of the class's mean service time among the distinct means, which
2370 * is what the reference computes here rather than at the solver.
2371 *
2372 * EVERY OTHER DISCIPLINE DEFAULTS TO 1, NOT 0. Measured against MATLAB
2373 * R2025a: FCFS, PS, LPS, SIRO, LCFS, HOL and SJF all report schedparam 1
2374 * per class on a two-class model, and only SEPT/LEPT differ. Defaulting to
2375 * zero here made every consumer that normalizes by the weight total divide
2376 * 0/0: a load-dependent PS station reported Util exactly 0 against the
2377 * reference's 0.400822578299582, and `ctmc_signal_busy` shares the pattern.
2378 */
2380 const T one = num_traits<T>::from_int(1);
2381 for (std::size_t i = 0; i < stations.size(); ++i) {
2382 Station<T>& st = stations[i];
2383 if (st.sched == SchedStrategy::DPS || st.sched == SchedStrategy::GPS) {
2384 if (st.schedparam.size() != classes.size())
2385 st.schedparam.assign(classes.size(), one);
2386 continue;
2387 }
2388 if (st.sched == SchedStrategy::SEPT || st.sched == SchedStrategy::LEPT) {
2389 if (st.schedparam.size() == classes.size()) continue;
2390 std::vector<double> means;
2391 for (std::size_t r = 0; r < classes.size(); ++r)
2392 means.push_back(num_traits<T>::to_double(service[i][r].mean));
2393 std::vector<double> sorted = means;
2394 std::sort(sorted.begin(), sorted.end());
2395 sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end());
2396 if (st.sched == SchedStrategy::LEPT)
2397 std::reverse(sorted.begin(), sorted.end());
2398 st.schedparam.assign(classes.size(), num_traits<T>::from_int(0));
2399 for (std::size_t r = 0; r < classes.size(); ++r)
2400 for (std::size_t k = 0; k < sorted.size(); ++k)
2401 if (sorted[k] == means[r])
2402 st.schedparam[r] = num_traits<T>::from_int(static_cast<long>(k) + 1);
2403 continue;
2404 }
2405 if (st.schedparam.empty()) st.schedparam.assign(classes.size(), one);
2406 }
2407 }
2408
2409 /**
2410 * Port of the part of MNetwork.refreshRoutingMatrix this port reaches: the
2411 * expansion of a routing STRATEGY into the probabilities `rt` is built from.
2412 *
2413 * PROB is already probabilities and is copied through. RAND and RROBIN
2414 * spread the mass uniformly over the nodes the user connected this one to,
2415 * which is what MATLAB's `RoutingStrategy.RAND` means once `link` has
2416 * recorded the connections, and what `getRoutingMatrix.m:117` does for both
2417 * in the same branch: a round-robin pointer visits every outgoing link
2418 * equally often, so the ROUTING PROBABILITIES it induces are uniform and
2419 * only the higher moments of the split are deterministic. Recovering that
2420 * determinism is the consumer's job -- `npfqn_traffic_split_rr` gives QNA
2421 * and MNA the split degree, and a solver that needs the pointer itself must
2422 * carry it in the state. A solver that does neither must not declare
2423 * `RoutingStrategy_RROBIN` in its feature set, or it answers a random-
2424 * routing model under a round-robin name.
2425 * A ClassSwitch node's outgoing mass is multiplied by its
2426 * class-switch matrix, so a job leaving it in class r continues as class s
2427 * with probability C(r,s) -- the node itself is not stateful, and the
2428 * stochastic complement folds it into the edges around it.
2429 *
2430 * Every other strategy is state dependent (WRROBIN, JSQ, SQ, FIRING) and is
2431 * REFUSED by name: silently treating one as PROB returns a product-form
2432 * answer for a model that does not have one. WRROBIN is not RROBIN with
2433 * weights for this purpose -- its uniform expansion would be wrong even in
2434 * the first moment.
2435 */
2437 const T zero = num_traits<T>::from_int(0);
2438 const std::size_t K = classes.size(), I = nodes.size();
2439 Peff.clear();
2440 bool trivial = true;
2441 for (const NodeDef& nd : nodes)
2442 for (RoutingStrategy rs : nd.routing)
2443 if (rs != RoutingStrategy::PROB && rs != RoutingStrategy::DISABLED) trivial = false;
2444 // A (node, class) PAIR THE CALLER NEVER ROUTED IS NOT TRIVIAL EITHER,
2445 // because the reference FILLS it -- see the unrouted branch below. This
2446 // port has no DISABLED strategy to key on (every pair defaults to PROB),
2447 // so the condition is the one MATLAB's DISABLED case IS: no outgoing
2448 // mass for this class at this node, at a node that has connections.
2449
2450 bool has_cs = false;
2451 for (const NodeDef& nd : nodes)
2452 if (nd.nodetype == NodeType::ClassSwitch) has_cs = true;
2453 // A Cache node is an implicit class switch: its read class routes on to
2454 // the hit and miss classes, so the routing is never trivial.
2455 const bool has_cache = !nodeparam.empty();
2456 if (trivial && !has_cs && !has_cache) return; // P is already the effective routing
2457
2458 for (const auto& kv : P) Peff[kv.first] = kv.second;
2459 // The class-INDEPENDENT topology (`sn.connmatrix`), built once from the arcs P holds. Probing
2460 // get_route over every class pair per (node, class, destination) was I^2 K^3 lookups, which
2461 // never finished on a flattened LQN (25 nodes, 146 classes: ~2e9 map finds).
2462 std::vector<char> conn(I * I, 0);
2463 for (const auto& kv : P) {
2464 const Matrix<T>& M = kv.second;
2465 if (M.rows() < I || M.cols() < I) continue; // get_route reads such a block as zero
2466 for (std::size_t a = 0; a < I; ++a)
2467 for (std::size_t b = 0; b < I; ++b)
2468 if (M(a, b) > zero) conn[a * I + b] = 1;
2469 }
2470 auto eff_at = [&](std::size_t r, std::size_t s) -> Matrix<T>& {
2471 auto key = std::make_pair(r, s);
2472 auto it = Peff.find(key);
2473 if (it == Peff.end())
2474 it = Peff.emplace(key, Matrix<T>(I, I, zero)).first;
2475 if (it->second.rows() != I) {
2476 Matrix<T> g(I, I, zero);
2477 for (std::size_t a = 0; a < it->second.rows(); ++a)
2478 for (std::size_t b = 0; b < it->second.cols(); ++b) g(a, b) = it->second(a, b);
2479 it->second = g;
2480 }
2481 return it->second;
2482 };
2483
2484 for (std::size_t i = 1; i <= I; ++i) {
2485 const NodeDef& nd = nodes[i - 1];
2486 for (std::size_t r = 1; r <= K; ++r) {
2487 const RoutingStrategy rs =
2488 nd.routing.size() >= r ? nd.routing[r - 1] : RoutingStrategy::PROB;
2489 if (rs == RoutingStrategy::PROB || rs == RoutingStrategy::DISABLED) continue;
2490 if (rs != RoutingStrategy::RAND && rs != RoutingStrategy::RROBIN &&
2491 rs != RoutingStrategy::JSQ && rs != RoutingStrategy::SQ &&
2492 rs != RoutingStrategy::WRROBIN && rs != RoutingStrategy::SDR)
2493 throw UnsupportedError(std::string("network: routing strategy '") +
2494 routing_to_text(rs) + "' at node '" + nd.name +
2495 "' has no routing-matrix expansion in this port");
2496 // THE DESTINATIONS ARE THE NODE'S CONNECTIONS, NOT THIS CLASS'S
2497 // OWN ARCS. `getRoutingMatrix.m:117-135` enumerates `sn.connmatrix`
2498 // -- a class-INDEPENDENT topology -- and routes same-class over
2499 // it, which is what makes a class that declared no arc at a node
2500 // it nevertheless reaches get a routing at all. Reading the
2501 // class's own entries instead left such a row EMPTY, and an empty
2502 // row is not a visit: on `gallery_erlerl1`, where Class1 declares
2503 // Source->Queue and Class2 declares Queue->Sink, the Queue came
2504 // out unvisited by Class1 and every metric of the model was zero.
2505 // The `served` mask in `refresh_visits` is what keeps the fill
2506 // honest -- it drops the (station, class) pairs with no service.
2507 //
2508 // A CLOSED CLASS IS NOT ROUTED INTO A SINK, and not out of a
2509 // Source at all, which is the same branch's rule: a sink would
2510 // absorb a job the population must conserve.
2511 const bool open_class = !std::isfinite(
2512 num_traits<T>::to_double(classes[r - 1].population));
2513 const bool from_source = nd.nodetype == NodeType::Source;
2514 const bool from_sink = nd.nodetype == NodeType::Sink;
2515 if (!open_class && (from_source || from_sink)) continue;
2516 std::vector<std::pair<std::size_t, std::size_t>> dest; // (node, class)
2517 for (std::size_t j = 1; j <= I; ++j) {
2518 if (!open_class && nodes[j - 1].nodetype == NodeType::Sink) continue;
2519 if (conn[(i - 1) * I + (j - 1)]) dest.emplace_back(j, r);
2520 }
2521 if (dest.empty()) continue;
2522 // WRROBIN SPREADS BY ITS WEIGHTS, everything else uniformly.
2523 // That is `getRoutingMatrix.m`'s split and it keeps the FIRST
2524 // MOMENT right for a weighted dispatcher, which a uniform
2525 // expansion would not.
2526 std::vector<T> share(dest.size(),
2528 num_traits<T>::from_int(static_cast<long>(dest.size()))));
2529 if (rs == RoutingStrategy::WRROBIN) {
2530 const std::map<std::size_t, double>* w =
2531 nd.routing_weights.size() >= r ? &nd.routing_weights[r - 1] : NULL;
2532 double total = 0.0;
2533 std::vector<double> raw(dest.size(), 0.0);
2534 if (w != NULL)
2535 for (std::size_t d = 0; d < dest.size(); ++d) {
2536 const std::map<std::size_t, double>::const_iterator it =
2537 w->find(dest[d].first);
2538 raw[d] = (it == w->end()) ? 0.0 : it->second;
2539 total += raw[d];
2540 }
2541 if (total > 0.0)
2542 for (std::size_t d = 0; d < dest.size(); ++d)
2543 share[d] = num_traits<T>::from_double(raw[d] / total);
2544 }
2545 // Clear row i of every (r,s) block that exists: an absent block already reads as zero,
2546 // and materialising all K^2 of them only to zero one row is K^2 I^2 of memory
2547 for (std::size_t s = 1; s <= K; ++s) {
2548 if (Peff.find(std::make_pair(r, s)) == Peff.end()) continue;
2549 Matrix<T>& B = eff_at(r, s);
2550 for (std::size_t j = 1; j <= I; ++j) B(i - 1, j - 1) = zero;
2551 }
2552 for (std::size_t d = 0; d < dest.size(); ++d)
2553 eff_at(r, dest[d].second)(i - 1, dest[d].first - 1) = share[d];
2554 }
2555 }
2556
2557 // ClassSwitch: split the outgoing mass across the class-switch matrix.
2558 for (const auto& kv : csmatrix) {
2559 const std::size_t cs = kv.first;
2560 const Matrix<T>& C = kv.second;
2561 if (C.rows() != K || C.cols() != K)
2562 throw InputError("network: the class-switch matrix of node '" +
2563 nodes[cs - 1].name + "' is not (nclasses x nclasses)");
2564 // WHERE A SWITCHED JOB GOES IS THE ARRIVAL CLASS'S ROUTING, NOT THE
2565 // DEPARTURE CLASS'S. `getRoutingMatrix.m`'s StatelessClassSwitcher
2566 // block sets rtnodes(r, (j-1)*K+s) = Pcs(r,s) * Pij(s,s): the
2567 // destination distribution is read off the DIAGONAL, i.e. from the
2568 // row class s routes on with, and only the mass is Pcs(r,s).
2569 // Reading class r's own outgoing row instead is the same matrix
2570 // only when every class of the chain leaves the switch the same
2571 // way. Where they do not -- `Delay -> CS1 -> Queue -> CS2 -> Delay`
2572 // with each class Disabled at the station it does not visit, so
2573 // each class is routed on one arc of the cycle only -- class r has
2574 // NO outgoing arc at the switch it enters, the whole block comes
2575 // out zero, and the two classes then fall into separate chains with
2576 // empty visits. The state space collapses to its initial state and
2577 // the CTMC, NC and MVA answers are an empty table, not an error.
2578 std::vector<std::vector<T>> diag(K, std::vector<T>(I, zero));
2579 for (std::size_t s = 1; s <= K; ++s) {
2580 if (!Peff.empty() && Peff.find(std::make_pair(s, s)) == Peff.end()) continue;
2581 for (std::size_t j = 1; j <= I; ++j)
2582 diag[s - 1][j - 1] =
2583 Peff.empty() ? get_route(s, s, cs, j) : eff_at(s, s)(cs - 1, j - 1);
2584 }
2585 // Zero row cs only in blocks that exist: an absent block reads as zero, and eff_at on all K^2 pairs materialised them
2586 for (std::size_t r = 1; r <= K; ++r)
2587 for (std::size_t s = 1; s <= K; ++s) {
2588 if (Peff.find(std::make_pair(r, s)) == Peff.end()) continue;
2589 Matrix<T>& B = eff_at(r, s);
2590 for (std::size_t j = 1; j <= I; ++j) B(cs - 1, j - 1) = zero;
2591 }
2592 for (std::size_t r = 1; r <= K; ++r)
2593 for (std::size_t s = 1; s <= K; ++s) {
2594 if (!(C(r - 1, s - 1) > zero)) continue;
2595 Matrix<T>& B = eff_at(r, s);
2596 for (std::size_t j = 1; j <= I; ++j)
2597 if (diag[s - 1][j - 1] > zero)
2598 B(cs - 1, j - 1) = T(diag[s - 1][j - 1] * C(r - 1, s - 1));
2599 }
2600 }
2601
2602 // Cache: the read (input) class self-switches at the cache node to the
2603 // hit and the miss class, which then follow their own routing. The
2604 // reference leaves the split unresolved (NaN) in the base struct and the
2605 // solver decides it; for the visit equations that back the offered
2606 // arrival rate and residence time it resolves to a uniform 1/2 - 1/2,
2607 // which is what makes ArvR the OFFERED rate (Tput is the carried one the
2608 // cacheqn decomposition produces). A half of the read mass reaching each
2609 // of hit/miss is a same-node class switch, hence a self-loop edge.
2610 const T half = T(num_traits<T>::from_int(1) / num_traits<T>::from_int(2));
2611 for (const auto& kv : nodeparam) {
2612 const std::size_t ci = kv.first; // 1-based cache node
2613 const CacheParam<T>& cp = kv.second;
2614 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
2615 if (cp.hitclass[r] == 0) continue;
2616 // clear the read class's own outgoing routing at the cache
2617 for (std::size_t s = 1; s <= K; ++s) {
2618 Matrix<T>& B = eff_at(r + 1, s);
2619 for (std::size_t j = 1; j <= I; ++j) B(ci - 1, j - 1) = zero;
2620 }
2621 eff_at(r + 1, cp.hitclass[r])(ci - 1, ci - 1) = half;
2622 if (r < cp.missclass.size() && cp.missclass[r] != 0)
2623 eff_at(r + 1, cp.missclass[r])(ci - 1, ci - 1) = half;
2624 }
2625 }
2626 }
2627
2628 /**
2629 * Port of MNetwork.refreshCapacity.
2630 *
2631 * `classcap(i,r)` is the population of r's CHAIN, cut down by any explicit
2632 * per-class or per-station buffer, and 0 where the class does not visit the
2633 * station; `cap(i)` is the explicit station buffer when there is one, and
2634 * otherwise the smaller of the chain and class sums.
2635 *
2636 * `chaincap` IS K COLUMNS WIDE, NOT nchains, exactly as the reference sizes
2637 * it (`chaincap = Inf*ones(M,K)`). The last class of a chain wins the write,
2638 * so a chain with a class disabled at station i can leave chaincap(i,c) at
2639 * 0; under class switching, where nchains < K, the untouched columns stay
2640 * Inf and carry the sum, which is what stops that 0 from capping a station
2641 * that holds the whole chain population at nothing.
2642 *
2643 * The derived drop rule follows the reference exactly, including that WAITQ
2644 * means "never consulted" at an unbounded station and "unsettled" for a
2645 * closed class at a bounded one: only an OPEN class at a real finite buffer
2646 * gets DROP.
2647 *
2648 * A PLACE IS EXEMPT FROM THE ZEROING. `disabled` is this port's marker for
2649 * the reference's `isnan(sn.rates(i,r))`, and a Place holds a marking
2650 * rather than serving, so it has no service process and every class reads
2651 * as disabled there. Zeroing it leaves a token container that cannot hold a
2652 * token: `cap` and `classcap` both come out 0, JMT is handed a Storage
2653 * section of capacity 0, and the net is dead on arrival. `refreshCapacity.m`
2654 * carries the same `~= NodeType.Place` guard on its `isnan` test.
2655 */
2656 /**
2657 * The number of siblings the fork-join pair ending at Join node `joinNode`
2658 * (1-based) emits per parent job: the matched Fork's out-degree times its
2659 * tasksPerLink, with the Join's in-degree as the fallback when no Fork is
2660 * matched. Port of `matlab/src/api/fj/sn_join_siblings.m`.
2661 *
2662 * Read off the DECLARED routing `P` rather than off `rtnodes`, because
2663 * `refresh_capacity` asks this question and runs BEFORE `refresh_rt`. The
2664 * two differ only on a declared link that carries no mass, which forks no
2665 * sibling either.
2666 */
2667 std::size_t join_siblings(std::size_t joinNode, std::size_t r = 0) const {
2668 const std::size_t I = nodes.size();
2669 if (joinNode == 0 || joinNode > I) return 0;
2670 std::size_t forkNode = 0;
2671 for (std::size_t a = 0; a < fj.size(); ++a)
2672 if (fj[a].second == joinNode) {
2673 forkNode = fj[a].first;
2674 break;
2675 }
2676 const T zero = num_traits<T>::from_int(0);
2677 std::vector<bool> seen(I + 1, false);
2678 std::size_t deg = 0;
2679 for (typename std::map<std::pair<std::size_t, std::size_t>, Matrix<T> >::const_iterator
2680 it = P.begin();
2681 it != P.end(); ++it) {
2682 if (it->second.rows() < I || it->second.cols() < I) continue;
2683 for (std::size_t b = 1; b <= I; ++b) {
2684 if (seen[b]) continue;
2685 const T v = (forkNode != 0) ? it->second(forkNode - 1, b - 1)
2686 : it->second(b - 1, joinNode - 1);
2687 if (v > zero) {
2688 seen[b] = true;
2689 ++deg;
2690 }
2691 }
2692 }
2693 if (forkNode == 0 || forkNode > I) return deg;
2694
2695 // THE COUNT IS PER LINK when the fork carries a VARIABLE FORKING LEVEL.
2696 // `fan_out_link(d,r)` is the expected tasks towards destination d for
2697 // class r (the DISTRIBUTION case stores its mean there) and
2698 // `fan_out_prob(d,r)` whether the branch fires at all, so the expected
2699 // sibling count is sum_d fan_out_prob(d,r)*fan_out_link(d,r). A fork
2700 // with no override has no `forkparam` entry, which is how the classic
2701 // out-degree times `tasks_per_link` case is told apart.
2702 const ForkParam<T>* fp = fork_param_of(forkNode);
2703 if (fp != 0 && fp->fan_out_link.rows() > 0) {
2704 const Matrix<T>& fol = fp->fan_out_link;
2705 const bool haveProb = fp->fan_out_prob.rows() == fol.rows() &&
2706 fp->fan_out_prob.cols() == fol.cols();
2707 const std::size_t lo = (r != 0 && r <= fol.cols()) ? r - 1 : 0;
2708 const std::size_t hi = (r != 0 && r <= fol.cols()) ? r - 1 : fol.cols() - 1;
2709 double best = 0.0;
2710 for (std::size_t c = lo; c <= hi && c < fol.cols(); ++c) {
2711 double acc = 0.0;
2712 for (std::size_t d = 0; d < fol.rows(); ++d) {
2713 const double link = num_traits<T>::to_double(fol(d, c));
2714 if (!(link > 0.0)) continue;
2715 acc += link * (haveProb ? num_traits<T>::to_double(fp->fan_out_prob(d, c))
2716 : 1.0);
2717 }
2718 if (acc > best) best = acc;
2719 }
2720 if (best > 0.0) return static_cast<std::size_t>(best + 0.5);
2721 }
2722
2723 double w = nodes[forkNode - 1].tasks_per_link;
2724 if (!(w >= 1.0)) w = 1.0;
2725 return deg * static_cast<std::size_t>(w + 0.5);
2726 }
2727
2728 /**
2729 * The 1-based Join nodes that fire on a STRICT quorum, i.e. on FEWER
2730 * siblings than are forked. A model holding one is not
2731 * population-conserving at the sibling level: the join releases the parent
2732 * at the k-th of n siblings and the n-k stragglers stay in their branches,
2733 * so the parent forks again while they are still in flight.
2734 */
2735 std::vector<std::size_t> quorum_joins() const {
2736 std::vector<std::size_t> out;
2737 for (typename std::map<std::size_t, JoinDecl>::const_iterator it = joindecl.begin();
2738 it != joindecl.end(); ++it) {
2739 if (it->second.strategy != lang::JoinStrategy::PARTIAL) continue;
2740 if (it->second.quorum <= 0.0) continue;
2741 const std::size_t n = join_siblings(it->first);
2742 const std::size_t k = static_cast<std::size_t>(it->second.quorum + 0.5);
2743 if (n == 0 || k < n) out.push_back(it->first);
2744 }
2745 return out;
2746 }
2747
2749 const std::size_t M = stations.size(), K = classes.size();
2750 const double inf = std::numeric_limits<double>::infinity();
2751 cap.assign(M, 0.0);
2752 classcap.assign(M, std::vector<double>(K, inf));
2753 droprule.assign(M, std::vector<DropStrategy>(K, DropStrategy::WAITQ));
2754 std::vector<std::vector<double>> chaincap(M, std::vector<double>(std::max(nchains, K), inf));
2755
2756 // A class routed into a QUORUM Join is not population-conserving: the
2757 // stragglers of an already-fired parent are still in their branches
2758 // when it forks again, and nothing bounds that backlog, so a branch
2759 // station holds no more than the class population only under a STANDARD
2760 // join. Capping it at the chain population makes the engine drop a
2761 // closed job. see _kb/05-solvers-overview.md
2762 std::vector<char> quorum_class(K + 1, 0);
2763 {
2764 const std::vector<std::size_t> qj = quorum_joins();
2765 const T zero = num_traits<T>::from_int(0);
2766 for (std::size_t x = 0; x < qj.size(); ++x)
2767 for (typename std::map<std::pair<std::size_t, std::size_t>,
2768 Matrix<T> >::const_iterator it = P.begin();
2769 it != P.end(); ++it) {
2770 if (it->second.rows() < nodes.size() || qj[x] > nodes.size()) continue;
2771 for (std::size_t a = 1; a <= nodes.size(); ++a)
2772 if (it->second(a - 1, qj[x] - 1) > zero) {
2773 if (it->first.first <= K) quorum_class[it->first.first] = 1;
2774 if (it->first.second <= K) quorum_class[it->first.second] = 1;
2775 }
2776 }
2777 }
2778
2779 // A fork with tasksPerLink = w > 1 puts w tasks of the SAME parent on one
2780 // link, so a branch station can hold w jobs per circulating parent and the
2781 // chain population is no longer its bound. The multiplier is the PRODUCT
2782 // over the forks, because a fork nested in another's branch multiplies
2783 // again; that is an upper bound for forks in series, where a cap that never
2784 // binds costs nothing, and exact for the single-fork case. Without it this
2785 // engine drops a closed job at a branch station.
2786 double fork_task_factor = 1.0;
2787 for (std::size_t i = 0; i < nodes.size(); ++i) {
2788 if (nodes[i].nodetype != NodeType::Fork) continue;
2789 const double w = nodes[i].tasks_per_link;
2790 if (w >= 1.0 && std::isfinite(w)) fork_task_factor *= std::floor(w + 0.5);
2791 }
2792
2793 for (std::size_t c = 0; c < nchains; ++c) {
2794 double chain_cap = 0.0;
2795 bool open = false;
2796 bool quorum = false;
2797 for (std::size_t r : inchain[c]) {
2798 if (std::isinf(classes[r - 1].population)) open = true;
2799 else chain_cap += classes[r - 1].population;
2800 if (r <= K && quorum_class[r]) quorum = true;
2801 }
2802 // A chain holding a spawn TARGET gains a job on every trigger completion, so it is not population-conserving
2803 // either (refreshCapacity.m spawnFedChain); capping it at its population makes the engine drop phase-2 jobs
2804 bool spawn_fed = false;
2805 for (std::size_t q = 0; q < K && !spawn_fed; ++q)
2806 for (std::size_t r : inchain[c])
2807 if (classes[q].spawn == r) spawn_fed = true;
2808 chain_cap *= fork_task_factor;
2809 if (open || quorum || spawn_fed) chain_cap = inf;
2810 for (std::size_t r : inchain[c])
2811 for (std::size_t i = 0; i < M; ++i) {
2812 const Station<T>& st = stations[i];
2813 const bool user_rule = st.droprule.size() >= r && st.droprule[r - 1] != 0;
2814 if (st.nodetype != NodeType::Source) {
2815 const bool cap_finite = st.cap >= 0.0 && !std::isinf(st.cap);
2816 const bool classcap_finite = st.classcap.size() >= r &&
2817 st.classcap[r - 1] > 0.0 &&
2818 !std::isinf(st.classcap[r - 1]);
2819 if (user_rule &&
2820 st.droprule[r - 1] == static_cast<int>(DropStrategy::WAITQ) &&
2821 std::isinf(classes[r - 1].population) &&
2822 (cap_finite || classcap_finite))
2823 throw UnsupportedError(
2824 "station '" + st.name + "' declares setDropRule(WAITQ) for the "
2825 "open class '" + classes[r - 1].name + "' at a finite capacity: "
2826 "LINE does not implement waiting-room blocking for an open "
2827 "arrival at a plain finite buffer. Use DropStrategy.DROP for a "
2828 "loss station, or BAS / BBS / RSRD for blocking between "
2829 "stations");
2830 if (user_rule) {
2831 droprule[i][r - 1] = static_cast<DropStrategy>(st.droprule[r - 1]);
2832 } else if (std::isinf(st.cap)) {
2833 droprule[i][r - 1] = DropStrategy::WAITQ;
2834 } else if (!std::isinf(classes[r - 1].population)) {
2835 droprule[i][r - 1] = DropStrategy::WAITQ;
2836 } else {
2837 droprule[i][r - 1] = DropStrategy::DROP;
2838 }
2839 }
2840 // A Place has no service process, so `disabled` is not absence.
2841 if (disabled[i][r - 1] && st.nodetype != NodeType::Place) {
2842 classcap[i][r - 1] = 0.0;
2843 chaincap[i][c] = 0.0;
2844 continue;
2845 }
2846 chaincap[i][c] = chain_cap;
2847 classcap[i][r - 1] = chain_cap;
2848 if (st.classcap.size() >= r && st.classcap[r - 1] >= 0.0)
2849 classcap[i][r - 1] = std::min(classcap[i][r - 1], st.classcap[r - 1]);
2850 if (st.cap >= 0.0) classcap[i][r - 1] = std::min(classcap[i][r - 1], st.cap);
2851 }
2852 }
2853 for (std::size_t i = 0; i < M; ++i) {
2854 if (stations[i].cap >= 0.0 && !std::isinf(stations[i].cap)) {
2855 cap[i] = stations[i].cap;
2856 continue;
2857 }
2858 double sc = 0.0, scl = 0.0;
2859 for (std::size_t c = 0; c < chaincap[i].size(); ++c) sc += chaincap[i][c];
2860 for (std::size_t r = 0; r < K; ++r) scl += classcap[i][r];
2861 cap[i] = std::min(sc, scl);
2862 }
2863 }
2864
2865 /**
2866 * `sn.rt` and `sn.rtnodes`: the class-expanded routing matrices.
2867 *
2868 * rtnodes is the routing over every node; rt is its stochastic complement
2869 * over the stateful ones, which is the same construction the per-chain
2870 * visits use -- but over ALL classes at once rather than one chain at a
2871 * time, because that is what `sn.rt` means and what solver_qna reads.
2872 */
2873 void refresh_rt() {
2874 const T zero = num_traits<T>::from_int(0);
2875 const std::size_t K = classes.size(), I = nodes.size();
2876 std::vector<std::size_t> all(K);
2877 for (std::size_t r = 0; r < K; ++r) all[r] = r + 1;
2878 rtnodes = Matrix<T>(I * K, I * K, zero);
2879 // route_eff over every entry is (I*K)^2 map finds; the blocks that exist hold every nonzero
2880 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
2881 for (std::size_t a = 0; a < I; ++a)
2882 for (std::size_t b = 0; b < I; ++b) rtnodes(a * K + r - 1, b * K + s - 1) = M(a, b);
2883 });
2884 rt = station_routing(all);
2885 }
2886
2887 /**
2888 * Re-resolve the cache read self-switch from the offered 1/2-1/2 to the
2889 * ACTUAL hit/miss probabilities the cacheqn decomposition converged on, then
2890 * recompute `rt` and the visits. The runner reads ArvR and ResidT off the
2891 * result, matching MATLAB whose `sn.visits` carry the actual
2892 * (setResultHitProb) split, not the offered one. Served metrics are
2893 * unaffected: they come from the analyzer's own over-routed inner solve.
2894 * `hitprob`/`missprob` are (ncaches x nclasses), indexed by cache order and
2895 * input class, exactly as `da_cacheqn` returns them.
2896 */
2897 void refresh_cacheqn_actual_visits(const Matrix<T>& hitprob, const Matrix<T>& missprob) {
2898 const T zero = num_traits<T>::from_int(0);
2899 const std::size_t K = classes.size();
2900 std::size_t cidx = 0;
2901 for (const auto& kv : nodeparam) {
2902 const std::size_t ci = kv.first; // 1-based cache node
2903 const CacheParam<T>& cp = kv.second;
2904 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
2905 if (cp.hitclass[r] == 0) continue;
2906 set_route_effective(r + 1, cp.hitclass[r], ci, ci, hitprob(cidx, r));
2907 if (r < cp.missclass.size() && cp.missclass[r] != 0)
2908 set_route_effective(r + 1, cp.missclass[r], ci, ci, missprob(cidx, r));
2909 }
2910 ++cidx;
2911 }
2912 refresh_rt();
2913 const bool fork = has_fork();
2914 visits.assign(nchains, Matrix<T>(stateful_nodes.size(), nclasses, zero));
2915 nodevisits.assign(nchains, Matrix<T>(nodes.size(), nclasses, zero));
2916 for (std::size_t c = 0; c < nchains; ++c) {
2917 visits[c] = chain_visits(c, stateful_nodes, true, fork);
2918 nodevisits[c] = chain_visits(c, all_nodes(), false, fork);
2919 }
2920 }
2921
2922 /**
2923 * Port of MNetwork.refreshRates: lower each service process onto a rate and
2924 * an SCV. A disabled (station, class) pair becomes NaN, which is the marker
2925 * every downstream consumer keys on to mean "this class never visits here".
2926 */
2928 nstations = stations.size();
2929 nclasses = classes.size();
2930 const T zero = num_traits<T>::from_int(0);
2932 scv = Matrix<T>(nstations, nclasses, zero);
2933 disabled.assign(nstations, std::vector<bool>(nclasses, true));
2934 for (std::size_t i = 0; i < nstations; ++i)
2935 for (std::size_t r = 0; r < nclasses; ++r) {
2936 // Join infinite-rate rationale: see _kb/04-networkstruct.md (cpp port notes)
2937 if (stations[i].nodetype == NodeType::Join) {
2938 disabled[i][r] = false;
2940 std::numeric_limits<double>::infinity());
2941 scv(i, r) = zero;
2942 continue;
2943 }
2944 const Distrib<T>& d = service[i][r];
2945 if (d.disabled) continue;
2946 disabled[i][r] = false;
2947 // A MARKED class receives ONLY ITS MARK'S stream, so its rate is
2948 // the mark's, not the shared process's. `set_marked_arrival`
2949 // binds one `Distrib` to every marked class, so `d.rate()` is
2950 // `1/mean` of the AGGREGATE and reports the whole stream K times
2951 // over -- 2.5 apiece on `cache_mmap_rr_env` against 1.9 and 0.6.
2952 const std::size_t mk = markidx_of(i + 1, r + 1);
2953 if (mk > 0 && mk <= d.Dmark.size()) {
2954 const std::pair<T, T> ms = mark_marginal_moments(d, mk);
2955 rates(i, r) = num_traits<T>::to_double(ms.first) > 0
2956 ? T(num_traits<T>::from_int(1) / ms.first)
2958 scv(i, r) = ms.second;
2959 continue;
2960 }
2961 rates(i, r) = d.rate();
2962 scv(i, r) = d.scv;
2963 }
2964 }
2965
2966 /**
2967 * Mean and SCV of ONE MARK'S MARGINAL MAP, `MarkedMAP.toMAPs(k)`.
2968 *
2969 * The marginal moves the OTHER marks' arrivals into the hidden part:
2970 * `D0' = D0 + (D1 - D1k)`, `D1' = D1k` (`MarkedMAP.m:130-140`,
2971 * `refreshRates.m:56-63`). It is what a consumer that sees only class k
2972 * experiences, and it is NOT the aggregate restricted -- an arrival of
2973 * another mark still moves the shared chain, it just is not an arrival here.
2974 *
2975 * `D0' + D1' = D0 + D1`, so the marginal keeps the aggregate's phase
2976 * generator and its rate reduces to `pi D1k e`; only the SCV needs the
2977 * second moment, which is why this goes through `map_scv` rather than
2978 * stopping at the rate.
2979 */
2980 std::pair<T, T> mark_marginal_moments(const Distrib<T>& d, std::size_t mk) const {
2981 mam::Map<T> m;
2982 m.D0 = d.D0;
2983 m.D1 = d.Dmark[mk - 1];
2984 for (std::size_t a = 0; a < m.D0.rows(); ++a)
2985 for (std::size_t b = 0; b < m.D0.cols(); ++b)
2986 m.D0(a, b) += T(d.D1(a, b) - m.D1(a, b));
2987 return std::make_pair(mam::map_mean(m), mam::map_scv(m));
2988 }
2989
2990 /**
2991 * Port of MNetwork.refreshChains followed by sn_refresh_visits.
2992 *
2993 * The class-switch mask is (r == s) or "some link carries r into s", which
2994 * is what link() records in csMatrix and what refreshChains then re-derives
2995 * from `rt`; the chains are the connected components of that mask read as
2996 * an undirected graph. MATLAB orders the chains by `sortrows(...,'descend')`
2997 * on the indicator rows, which for disjoint components is the same as
2998 * ordering by their smallest class index, and that is what is done here.
2999 */
3001 refresh_rates();
3002 const T zero = num_traits<T>::from_int(0);
3003 const std::size_t K = nclasses;
3004
3005 // class-switch mask rationale: see _kb/04-networkstruct.md (cpp port notes)
3006 std::vector<std::vector<bool>> cs(K, std::vector<bool>(K, false));
3007 for (std::size_t r = 0; r < K; ++r) cs[r][r] = true;
3008 for (const auto& kv : (Peff.empty() ? P : Peff)) {
3009 bool any = false;
3010 for (std::size_t a = 0; a < kv.second.rows() && !any; ++a)
3011 for (std::size_t b = 0; b < kv.second.cols() && !any; ++b)
3012 if (kv.second(a, b) > zero) any = true;
3013 if (any) cs[kv.first.first - 1][kv.first.second - 1] = true;
3014 }
3015 // A ClassSwitch NODE couples classes too, and once `link()` synthesizes
3016 // one the coupling lives ONLY here: the rewrite folds `P{r,s}(i,j)` into
3017 // a same-class pair of legs, so the r != s block that used to carry it
3018 // is gone from P. `link.m` accumulates the same thing --
3019 // `csMatrix = csMatrix | nodes{ind}.server.csMatrix > 0` -- and without
3020 // it every switched class falls into its own chain.
3021 for (const auto& kv : csmatrix) {
3022 const Matrix<T>& C = kv.second;
3023 for (std::size_t r = 0; r < K && r < C.rows(); ++r)
3024 for (std::size_t s = 0; s < K && s < C.cols(); ++s)
3025 if (C(r, s) > zero) cs[r][s] = true;
3026 }
3027 // A Cache node couples each input class with the classes it switches to
3028 // on a hit and a miss; that coupling lives in nodeparam, not in P, so it
3029 // must be added here or the read/hit/miss classes fall into separate
3030 // chains and the switched (open) classes get no arrivals.
3031 for (const auto& kv : nodeparam) {
3032 const CacheParam<T>& cp = kv.second;
3033 for (std::size_t r = 0; r < cp.hitclass.size() && r < K; ++r) {
3034 if (cp.hitclass[r] != 0) cs[r][cp.hitclass[r] - 1] = true;
3035 if (r < cp.missclass.size() && cp.missclass[r] != 0)
3036 cs[r][cp.missclass[r] - 1] = true;
3037 }
3038 // A RETRIEVAL class is also a class the cache switches the read
3039 // class into on a miss -- it just lives in `retrieval_classes`
3040 // rather than in `missclass`, because there is one per ITEM. The
3041 // coupling above missed them, so on a closed delayed-hit model each
3042 // retrieval class formed its own singleton chain: 4 chains where
3043 // MATLAB has 1, with zero visits at the fetch station and the whole
3044 // population parked at the delay. The rule in the comment above
3045 // applies to them unchanged.
3046 for (const std::vector<std::size_t>& row : cp.retrieval_classes)
3047 for (std::size_t r = 0; r < row.size() && r < K; ++r)
3048 if (row[r] != 0) cs[r][row[r] - 1] = true;
3049 }
3050 std::vector<std::size_t> comp(K, K);
3051 std::size_t ncomp = 0;
3052 for (std::size_t r = 0; r < K; ++r) {
3053 if (comp[r] != K) continue;
3054 std::vector<std::size_t> stack{r};
3055 comp[r] = ncomp;
3056 while (!stack.empty()) {
3057 const std::size_t v = stack.back();
3058 stack.pop_back();
3059 for (std::size_t w = 0; w < K; ++w)
3060 if (comp[w] == K && (cs[v][w] || cs[w][v])) {
3061 comp[w] = ncomp;
3062 stack.push_back(w);
3063 }
3064 }
3065 ++ncomp;
3066 }
3067 // components are already discovered in order of their smallest member,
3068 // which is the order sortrows(...,'descend') produces
3069 nchains = ncomp;
3070 chains.assign(nchains, std::vector<bool>(K, false));
3071 inchain.assign(nchains, {});
3072 for (std::size_t r = 0; r < K; ++r) {
3073 chains[comp[r]][r] = true;
3074 inchain[comp[r]].push_back(r + 1);
3075 }
3076
3077 // ---- reference class per chain -------------------------------------
3078 refclass.assign(nchains, 0);
3079 for (std::size_t c = 0; c < nchains; ++c)
3080 for (std::size_t k : inchain[c])
3081 if (classes[k - 1].is_ref_class) refclass[c] = k;
3082
3083 for (std::size_t c = 0; c < nchains; ++c) {
3084 const std::size_t rs = classes[inchain[c][0] - 1].refstat;
3085 for (std::size_t k : inchain[c])
3086 if (classes[k - 1].refstat != rs)
3087 throw InputError("network '" + name + "': classes within a chain have different "
3088 "reference stations");
3089 }
3090
3092
3093 // ---- visits, at stateful-node and at node level ---------------------
3094 const bool fork = has_fork();
3095 visits.assign(nchains, Matrix<T>(stateful_nodes.size(), nclasses, zero));
3096 nodevisits.assign(nchains, Matrix<T>(nodes.size(), nclasses, zero));
3097 for (std::size_t c = 0; c < nchains; ++c) {
3098 visits[c] = chain_visits(c, stateful_nodes, true, fork);
3099 nodevisits[c] = chain_visits(c, all_nodes(), false, fork);
3100 }
3101 }
3102
3103 /**
3104 * Route every open chain from the Sink back into the Source, as the tail of
3105 * MATLAB's getRoutingMatrix does before it takes the stochastic complement.
3106 *
3107 * WHY IT IS NOT OPTIONAL. `visits` is the stationary vector of the chain's
3108 * routing DTMC. An open chain that ends at the Sink has no such vector --
3109 * the Sink is absorbing, so the solve returns the point mass there and
3110 * every station comes out with zero visits. The closing arc turns the open
3111 * chain into a recurrent one whose stationary vector, renormalised by the
3112 * Source, is the visit count PER ARRIVAL, which is what the chain demands
3113 * are built from.
3114 *
3115 * The destination class is drawn in proportion to the ARRIVAL RATES of the
3116 * chain's classes, so a job leaving the Sink re-enters as the class the
3117 * Source would have generated. A chain whose arrivals are all disabled has
3118 * no such proportion, and the reference falls back to the uniform choice
3119 * rather than dividing by zero -- those chains carry no traffic and only
3120 * need to stay well posed.
3121 *
3122 * The arcs are rebuilt on every refresh, and the previous ones cleared
3123 * first: the rates move between passes of the fork-join fixed point, and a
3124 * stale arc would leave two closures with different weights in place.
3125 */
3127 if (sourceIdx == 0 || sinkNode == 0) return;
3128 const T zero = num_traits<T>::from_int(0);
3129 const T one = num_traits<T>::from_int(1);
3130 const std::size_t src = station_to_node[sourceIdx - 1];
3131 // closure-clearing rationale: see _kb/04-networkstruct.md (cpp port notes)
3132 for (auto& kv : P) {
3133 if (kv.second.rows() < nodes.size()) continue;
3134 kv.second(sinkNode - 1, src - 1) = zero;
3135 }
3136 for (auto& kv : Peff) {
3137 if (kv.second.rows() < nodes.size()) continue;
3138 kv.second(sinkNode - 1, src - 1) = zero;
3139 }
3140 for (std::size_t c = 0; c < nchains; ++c) {
3141 bool open = false;
3142 for (std::size_t k : inchain[c])
3143 if (std::isinf(classes[k - 1].population)) open = true;
3144 if (!open) continue;
3145 T tot = zero;
3146 for (std::size_t k : inchain[c])
3147 if (!disabled[sourceIdx - 1][k - 1]) tot += rates(sourceIdx - 1, k - 1);
3148 const T uniform =
3149 T(one / num_traits<T>::from_int(static_cast<long>(inchain[c].size())));
3150 for (std::size_t s : inchain[c]) {
3151 T p = uniform;
3152 if (tot > zero) {
3153 const T ar =
3154 disabled[sourceIdx - 1][s - 1] ? zero : rates(sourceIdx - 1, s - 1);
3155 p = T(ar / tot);
3156 }
3157 if (!(p > zero)) continue;
3158 for (std::size_t r : inchain[c]) set_route_effective(r, s, sinkNode, src, p);
3159 }
3160 }
3161 }
3162
3163 /** Every node index, 1-based, for the node-level visit computation. */
3164 std::vector<std::size_t> all_nodes() const {
3165 std::vector<std::size_t> v(nodes.size());
3166 for (std::size_t i = 0; i < nodes.size(); ++i) v[i] = i + 1;
3167 return v;
3168 }
3169
3170 /**
3171 * Port of the per-chain body of sn_refresh_visits, over an arbitrary node
3172 * subset -- the stateful nodes for `visits`, all nodes for `nodevisits`.
3173 *
3174 * The FORK CORRECTIONS are the reference's, and they are blunt on purpose.
3175 * A Fork row sums to its fan-out rather than to one, so the rows are
3176 * renormalised before the DTMC solve; afterwards, rather than trusting the
3177 * resulting stationary vector, a population-preserving SPN argument sets
3178 * EVERY visited entry to 1 (and a Join entry to its in-degree, one per
3179 * incoming branch). Reproduced exactly: the corrected visits are what the
3180 * chain demands are built from, so an "improved" version would disagree
3181 * with every other codebase.
3182 */
3183 Matrix<T> chain_visits(std::size_t c, const std::vector<std::size_t>& sel, bool complement,
3184 bool fork) const {
3185 const T zero = num_traits<T>::from_int(0);
3186 const std::vector<std::size_t>& ic = inchain[c];
3187 const std::size_t nIC = ic.size();
3188 const std::size_t dim = sel.size() * nIC;
3189 Matrix<T> Pc(dim, dim, zero);
3190 if (complement) {
3191 const Matrix<T> rt = station_routing(ic);
3192 Pc = rt;
3193 } else {
3194 // Block-wise over the routing map: route_eff per entry was dim^2 map finds per chain
3195 std::vector<std::size_t> cpos(nclasses + 1, nIC);
3196 for (std::size_t x = 0; x < nIC; ++x) cpos[ic[x]] = x;
3197 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
3198 if (r > nclasses || s > nclasses) return;
3199 const std::size_t x = cpos[r], y = cpos[s];
3200 if (x == nIC || y == nIC) return;
3201 for (std::size_t a = 0; a < sel.size(); ++a)
3202 for (std::size_t b = 0; b < sel.size(); ++b)
3203 Pc(a * nIC + x, b * nIC + y) = M(sel[a] - 1, sel[b] - 1);
3204 });
3205 }
3206
3207 // THE `served` MASK of sn_refresh_visits.m, ON BOTH CHAINS.
3208 // `getRoutingMatrix` leaves a JMT-oriented UNIFORM FILL on (station,
3209 // class) pairs the class has no service at, and those states are not
3210 // reachable: a class with no service law cannot be served there. Left
3211 // in, they leak mass between what are otherwise separate recurrent
3212 // branches -- on cs_transient_class that moved Queue1 from 0.1875 to
3213 // 0.22917 and Queue2 from 0.3125 to 0.27083, against an absorption of
3214 // 0.375/0.625 halved over each two-station cycle.
3215 //
3216 // It matters MORE on the node chain, which the reference used to leave
3217 // untouched: at station level an unserved (station,class) is a dead end,
3218 // while the node kernel keeps the class-switch nodes between the
3219 // stations, so the disabled states close into a whole spurious CYCLE. A
3220 // materialised LQN replica is exactly that -- replica 2's stations still
3221 // carry replica 1's classes in rtnodes -- and the reducible solve then
3222 // splits the mass between the real chain and the phantom one, giving
3223 // every node of replica 2 a visit in replica 1's classes.
3224 //
3225 // A Place, a Transition and a station declaring heterogeneous server
3226 // types are EXEMPT, exactly as the reference exempts them: all three
3227 // carry NaN station rates by construction, so the test would read as
3228 // "not served" for a station that plainly is.
3229 std::vector<bool> served(dim, true);
3230 {
3231 for (std::size_t a = 0; a < sel.size(); ++a) {
3232 const std::size_t sti = nodes[sel[a] - 1].station;
3233 if (sti == 0 || sti > stations.size()) continue;
3234 const NodeType nt = stations[sti - 1].nodetype;
3235 if (nt == NodeType::Place || nt == NodeType::Transition) continue;
3236 if (!stations[sti - 1].server_types.empty()) continue;
3237 if (sti > disabled.size()) continue;
3238 // The reference tests `isnan(sn.rates(...))`; THIS PORT'S MARKER
3239 // IS `disabled`. refresh_rates leaves a disabled pair's rate at
3240 // 0 rather than NaN (its comment says otherwise and is stale),
3241 // so an isnan test here would never fire and the mask would be
3242 // silently inert. `disabled` is what every other consumer keys
3243 // on and it carries exactly MATLAB's meaning.
3244 for (std::size_t x = 0; x < nIC; ++x)
3245 if (ic[x] <= disabled[sti - 1].size() && disabled[sti - 1][ic[x] - 1])
3246 served[a * nIC + x] = false;
3247 }
3248 for (std::size_t row = 0; row < dim; ++row)
3249 if (!served[row])
3250 for (std::size_t col = 0; col < dim; ++col) Pc(row, col) = zero;
3251 for (std::size_t col = 0; col < dim; ++col)
3252 if (!served[col])
3253 for (std::size_t row = 0; row < dim; ++row) Pc(row, col) = zero;
3254 }
3255
3256 std::vector<std::size_t> visited;
3257 std::vector<T> rowsum(dim, zero);
3258 for (std::size_t row = 0; row < dim; ++row) {
3259 T s = zero;
3260 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3261 rowsum[row] = s;
3262 if (s > zero) visited.push_back(row);
3263 }
3264 bool oversum = false;
3265 if (fork) {
3266 for (std::size_t row = 0; row < dim; ++row) {
3267 if (num_traits<T>::to_double(rowsum[row]) > 1.0 + GlobalConstants::FineTol)
3268 oversum = true;
3270 for (std::size_t col = 0; col < dim; ++col)
3271 Pc(row, col) = T(Pc(row, col) / rowsum[row]);
3272 }
3273 }
3274
3275 // Detect a genuinely reducible chain up front, so its DISABLED-routing
3276 // fillers and the reducible solver apply ONLY here -- every irreducible
3277 // chain, i.e. the whole existing test surface, stays on the exact
3278 // dtmc_solve path below. In MATLAB the singular normalization solve
3279 // returns NaN here, tripping the same fallback; this reproduces that
3280 // without changing the shared solver.
3281 //
3282 // "Genuinely reducible" is more than one recurrent class AND all of them
3283 // inside ONE weak component, which is the shape MATLAB's solve actually
3284 // fails on. `ctmc_solve` (MATLAB's, the JAR's and this port's alike)
3285 // SPLITS ON WEAK COMPONENTS first, solves each on its own and
3286 // renormalizes the union, so a chain whose recurrent classes sit in
3287 // SEPARATE weak components has an answer there, and an exact one: each
3288 // component gets equal weight.
3289 //
3290 // A delayed-hit retrieval cache is exactly that second shape. Its
3291 // per-item fetch cycle (cache -> retrieval station -> cache, in that
3292 // item's retrieval class) shares no state with the read class's own
3293 // loop, because the class switch that feeds it happens INSIDE the cache
3294 // node rather than on a routing arc. Calling that reducible sends it to
3295 // `dtmc_solve_reducible`, which hands the read loop all the mass and
3296 // leaves every retrieval station with ZERO visits -- and a station with
3297 // no visits is given no capacity by `space_capacity_c`, so the CTMC then
3298 // enumerates a state space with no in-flight fetch in it at all and
3299 // reports NaN hit/miss shares. Measured on retrieval_simple before this
3300 // qualifier: 12 states where MATLAB, the JAR and native Python have 144.
3301 bool reducible = false;
3302 if (!fork && !visited.empty()) {
3303 Matrix<T> Pv0(visited.size(), visited.size(), zero);
3304 for (std::size_t a = 0; a < visited.size(); ++a)
3305 for (std::size_t b = 0; b < visited.size(); ++b) Pv0(a, b) = Pc(visited[a], visited[b]);
3306 const mc::SccResult scc = mc::stronglyconncomp(Pv0);
3307 std::size_t nrec = 0;
3308 for (bool rc : scc.recurrent)
3309 if (rc) ++nrec;
3310 reducible = (nrec > 1) && mc::detail::weak_components(Pv0).size() == 1;
3311 }
3312 if (reducible) {
3313 // DISABLED-routing filler (getRoutingMatrix.m DISABLED case): a
3314 // (node,class) with no outgoing routing at a physically connected node
3315 // routes SAME-CLASS to each neighbour at 1/nconn. These 0-visit
3316 // transient states give the reducible solver the multiple transient
3317 // SCCs MATLAB has, so it weights the recurrent classes correctly
3318 // (11:13) instead of the single-transient absorption split (3:5).
3319 std::vector<std::size_t> nconn(sel.size(), 0);
3320 std::vector<std::vector<bool>> conn(sel.size(), std::vector<bool>(sel.size(), false));
3321 for (std::size_t a = 0; a < sel.size(); ++a)
3322 for (std::size_t b = 0; b < sel.size(); ++b) {
3323 bool cbit = false;
3324 for (std::size_t x = 0; x < nIC && !cbit; ++x)
3325 for (std::size_t y = 0; y < nIC && !cbit; ++y)
3326 if (Pc(a * nIC + x, b * nIC + y) > zero) cbit = true;
3327 conn[a][b] = cbit;
3328 if (cbit) ++nconn[a];
3329 }
3330 for (std::size_t a = 0; a < sel.size(); ++a) {
3331 if (nconn[a] == 0) continue;
3332 for (std::size_t x = 0; x < nIC; ++x) {
3333 T outsum = zero;
3334 for (std::size_t col = 0; col < dim; ++col) outsum += Pc(a * nIC + x, col);
3335 if (outsum > zero) continue; // class already routes onward here
3336 const T p = num_traits<T>::from_int(1) /
3337 num_traits<T>::from_int(static_cast<long>(nconn[a]));
3338 for (std::size_t b = 0; b < sel.size(); ++b)
3339 if (conn[a][b]) Pc(a * nIC + x, b * nIC + x) = p;
3340 }
3341 }
3342 // AND THE FILLER IS MASKED AGAIN, which is the whole point of the
3343 // mask. The reference's order is: getRoutingMatrix lays the fill
3344 // down, THEN `served` zeroes those rows and columns -- so an
3345 // unserved state never carries fill in the matrix that is solved.
3346 // Synthesising the fill here and stopping would restore exactly what
3347 // the mask was ported to remove: on cs_transient_class the two
3348 // recurrent branches came out 11:13 (0.22917 / 0.27083) instead of
3349 // the absorption split 3:5 (0.1875 / 0.3125) that MATLAB, Java and
3350 // Python all report and that the routing implies.
3351 for (std::size_t row = 0; row < dim; ++row)
3352 if (!served[row])
3353 for (std::size_t col = 0; col < dim; ++col) Pc(row, col) = zero;
3354 for (std::size_t col = 0; col < dim; ++col)
3355 if (!served[col])
3356 for (std::size_t row = 0; row < dim; ++row) Pc(row, col) = zero;
3357 visited.clear();
3358 for (std::size_t row = 0; row < dim; ++row) {
3359 T s = zero;
3360 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3361 if (s > zero) visited.push_back(row);
3362 }
3363 }
3364
3365 std::vector<T> alpha(dim, zero);
3366 if (!visited.empty()) {
3367 Matrix<T> Pv(visited.size(), visited.size(), zero);
3368 for (std::size_t a = 0; a < visited.size(); ++a)
3369 for (std::size_t b = 0; b < visited.size(); ++b)
3370 Pv(a, b) = Pc(visited[a], visited[b]);
3371 std::vector<T> av;
3372 bool ok = false;
3373 if (!reducible) {
3374 try {
3375 av = mc::dtmc_solve(Pv);
3376 ok = true;
3377 bool allzero = true, hasnan = false;
3378 for (const T& x : av) {
3379 if (x != zero) allzero = false;
3380 if (num_traits<T>::to_double(x) != num_traits<T>::to_double(x)) hasnan = true;
3381 }
3382 if (allzero || hasnan) ok = false;
3383 } catch (const Error&) {
3384 ok = false;
3385 }
3386 }
3387 if (!ok) {
3388 // DTMC solver order: see _kb/04-networkstruct.md (cpp port notes) and _kb/11-conventions-and-gotchas.md
3390 }
3391 for (std::size_t a = 0; a < visited.size(); ++a) alpha[visited[a]] = av[a];
3392
3393 // A TRANSIENT STATE HAS VISIT RATIO EXACTLY ZERO, and saying so by
3394 // the graph rather than by a tolerance is what keeps this port on
3395 // the reference's answer. `pi P = pi` puts all of its mass on the
3396 // recurrent classes, so a state whose SCC has an edge leaving it
3397 // carries none; whether the LU lands on 0 or on an eps residue is a
3398 // pivot-order accident, and MATLAB's backslash happens to land on 0
3399 // where this LU lands on 3e-17.
3400 //
3401 // That accident is not cosmetic downstream. RN is QN/TN, so a class
3402 // with an eps visit at a station reads 0/0 -- NaN in the reference,
3403 // which the H-T order statistic DROPS, and the station's full
3404 // residence time here, which it COUNTS. On the two-span H-T model
3405 // that handed Delay1 and the other fork's auxiliary delay a
3406 // residence time of 2 each for classes that never reach either, and
3407 // `ri` came out 5.747 against the reference's 1.747 -- exactly the
3408 // 2 + 2 those two rows contribute.
3409 {
3410 const mc::SccResult scc = mc::stronglyconncomp(Pv);
3411 for (std::size_t a = 0; a < visited.size(); ++a)
3412 if (!scc.recurrent[scc.scc[a] - 1]) alpha[visited[a]] = zero;
3413 }
3414 }
3415
3416 if (fork && oversum) {
3417 for (std::size_t idx = 0; idx < dim; ++idx) {
3418 if (!(num_traits<T>::to_double(alpha[idx]) > GlobalConstants::FineTol)) continue;
3419 const std::size_t nd = sel[idx / nIC];
3420 if (!complement && nodes[nd - 1].nodetype == NodeType::Join) {
3421 // a Join is entered once per incoming branch. The reference
3422 // counts POSITIVE ROWS of rtnodes in column (nd,r), i.e.
3423 // (source node, source class) PAIRS over every class -- two
3424 // classes entering the Join as r from the same predecessor
3425 // are two branches, so neither the break nor the restriction
3426 // to the chain's own classes belongs here.
3427 const std::size_t r = ic[idx % nIC];
3428 std::size_t nsrc = 0;
3429 for (std::size_t src = 1; src <= nodes.size(); ++src)
3430 for (std::size_t q = 1; q <= nclasses; ++q)
3431 if (num_traits<T>::to_double(route_eff(q, r, src, nd)) >
3433 ++nsrc;
3434 alpha[idx] = num_traits<T>::from_int(static_cast<long>(nsrc));
3435 } else {
3436 alpha[idx] = num_traits<T>::from_int(1);
3437 }
3438 }
3439 }
3440
3441 Matrix<T> out(sel.size(), nclasses, zero);
3442 for (std::size_t a = 0; a < sel.size(); ++a)
3443 for (std::size_t x = 0; x < nIC; ++x) out(a, ic[x] - 1) = alpha[a * nIC + x];
3444
3445 // normalise by the total visits of the chain at its reference node
3446 const std::size_t rstat = classes[ic[0] - 1].refstat;
3447 const std::size_t refnode = station_to_node[rstat - 1];
3448 std::size_t refrow = sel.size();
3449 for (std::size_t a = 0; a < sel.size(); ++a)
3450 if (sel[a] == refnode) refrow = a;
3451 if (refrow < sel.size()) {
3452 T norm = zero;
3453 for (std::size_t x = 0; x < nIC; ++x) norm += out(refrow, ic[x] - 1);
3455 for (std::size_t a = 0; a < sel.size(); ++a)
3456 for (std::size_t x = 0; x < nIC; ++x)
3457 out(a, ic[x] - 1) = T(out(a, ic[x] - 1) / norm);
3458 }
3459 for (std::size_t a = 0; a < sel.size(); ++a)
3460 for (std::size_t x = 0; x < nIC; ++x)
3461 if (out(a, ic[x] - 1) < zero) out(a, ic[x] - 1) = T(-out(a, ic[x] - 1));
3462 return out;
3463 }
3464
3465 /**
3466 * The visit ratios of one chain from an already-formed chain routing block
3467 * `Pc` (dim = sel.size() * nIC), the no-fork body of sn_refresh_visits: solve
3468 * the embedded DTMC over the visited states and normalise by the total visits
3469 * at the reference node. NaN entries (a Cache class switch can leave one) are
3470 * given the row's residual probability spread uniformly, as the reference
3471 * does. Fork models keep the route_eff path (`chain_visits`); this is used by
3472 * the cacheqn driver, which rewrites `rtnodes` directly and has no fork.
3473 */
3474 Matrix<T> visits_from_block(const Matrix<T>& Pc_in, const std::vector<std::size_t>& sel,
3475 const std::vector<std::size_t>& ic, std::size_t refnode) const {
3476 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
3477 const std::size_t nIC = ic.size();
3478 const std::size_t dim = Pc_in.rows();
3479 Matrix<T> Pc = Pc_in;
3480 // NaN handling: distribute the row's residual mass over the NaN columns.
3481 for (std::size_t row = 0; row < dim; ++row) {
3482 T nonnan = zero;
3483 std::size_t nnan = 0;
3484 for (std::size_t col = 0; col < dim; ++col) {
3485 const double v = num_traits<T>::to_double(Pc(row, col));
3486 if (v != v) ++nnan;
3487 else nonnan = T(nonnan + Pc(row, col));
3488 }
3489 if (nnan == 0) continue;
3490 const double rem = 1.0 - num_traits<T>::to_double(nonnan);
3491 const T fill = (rem > 0.0) ? num_traits<T>::from_double(rem / double(nnan)) : zero;
3492 for (std::size_t col = 0; col < dim; ++col) {
3493 const double v = num_traits<T>::to_double(Pc(row, col));
3494 if (v != v) Pc(row, col) = fill;
3495 }
3496 }
3497 std::vector<std::size_t> visited;
3498 for (std::size_t row = 0; row < dim; ++row) {
3499 T s = zero;
3500 for (std::size_t col = 0; col < dim; ++col) s += Pc(row, col);
3501 if (s > zero) visited.push_back(row);
3502 }
3503 std::vector<T> alpha(dim, zero);
3504 if (!visited.empty()) {
3505 Matrix<T> Pv(visited.size(), visited.size(), zero);
3506 for (std::size_t a = 0; a < visited.size(); ++a)
3507 for (std::size_t b = 0; b < visited.size(); ++b) Pv(a, b) = Pc(visited[a], visited[b]);
3508 std::vector<T> av;
3509 bool ok = false;
3510 try {
3511 av = mc::dtmc_solve(Pv);
3512 ok = true;
3513 bool allzero = true, hasnan = false;
3514 for (const T& x : av) {
3515 if (x != zero) allzero = false;
3516 if (num_traits<T>::to_double(x) != num_traits<T>::to_double(x)) hasnan = true;
3517 }
3518 if (allzero || hasnan) ok = false;
3519 } catch (const Error&) {
3520 ok = false;
3521 }
3523 for (std::size_t a = 0; a < visited.size(); ++a) alpha[visited[a]] = av[a];
3524 }
3525 Matrix<T> out(sel.size(), nclasses, zero);
3526 for (std::size_t a = 0; a < sel.size(); ++a)
3527 for (std::size_t x = 0; x < nIC; ++x) out(a, ic[x] - 1) = alpha[a * nIC + x];
3528 std::size_t refrow = sel.size();
3529 for (std::size_t a = 0; a < sel.size(); ++a)
3530 if (sel[a] == refnode) refrow = a;
3531 if (refrow < sel.size()) {
3532 T norm = zero;
3533 for (std::size_t x = 0; x < nIC; ++x) norm += out(refrow, ic[x] - 1);
3535 for (std::size_t a = 0; a < sel.size(); ++a)
3536 for (std::size_t x = 0; x < nIC; ++x)
3537 out(a, ic[x] - 1) = T(out(a, ic[x] - 1) / norm);
3538 }
3539 for (std::size_t a = 0; a < sel.size(); ++a)
3540 for (std::size_t x = 0; x < nIC; ++x)
3541 if (out(a, ic[x] - 1) < zero) out(a, ic[x] - 1) = T(-out(a, ic[x] - 1));
3542 return out;
3543 }
3544
3545 /**
3546 * Recompute `rt`, `visits` and `nodevisits` after the caller has rewritten
3547 * `rtnodes` in place -- the cacheqn driver's per-sweep refresh. `rt` is the
3548 * stochastic complement of `rtnodes` over the stateful (node, class) rows;
3549 * the per-chain visits then read directly off `rt` (stateful) and `rtnodes`
3550 * (all nodes), with the chains held FIXED (the reference does not recompute
3551 * them inside the sweep). No-fork only; a fork keeps the refresh path.
3552 */
3554 const T zero = num_traits<T>::from_int(0);
3555 const std::size_t K = nclasses, I = nodes.size(), M = stateful_nodes.size();
3556 for (const NodeDef& nd : nodes)
3557 if (nd.nodetype == NodeType::Fork)
3558 throw UnsupportedError("da_recompute_visits_from_rtnodes: fork models keep the "
3559 "route_eff visit path");
3560 std::vector<std::size_t> keep;
3561 keep.reserve(M * K);
3562 for (std::size_t p = 0; p < M; ++p) {
3563 const std::size_t base = (stateful_nodes[p] - 1) * K;
3564 for (std::size_t r = 0; r < K; ++r) keep.push_back(base + r);
3565 }
3566 rt = mc::dtmc_stochcomp(rtnodes, keep);
3567
3568 std::vector<std::size_t> allnodes(I);
3569 for (std::size_t a = 0; a < I; ++a) allnodes[a] = a + 1;
3570 visits.assign(nchains, Matrix<T>(M, K, zero));
3571 nodevisits.assign(nchains, Matrix<T>(I, K, zero));
3572 for (std::size_t c = 0; c < nchains; ++c) {
3573 const std::vector<std::size_t>& ic = inchain[c];
3574 const std::size_t nIC = ic.size();
3575 // stateful visits from rt (ordered by stateful position)
3576 Matrix<T> Ps(M * nIC, M * nIC, zero);
3577 for (std::size_t a = 0; a < M; ++a)
3578 for (std::size_t x = 0; x < nIC; ++x)
3579 for (std::size_t b = 0; b < M; ++b)
3580 for (std::size_t y = 0; y < nIC; ++y)
3581 Ps(a * nIC + x, b * nIC + y) = rt(a * K + (ic[x] - 1), b * K + (ic[y] - 1));
3582 const std::size_t rstat = classes[ic[0] - 1].refstat;
3583 const std::size_t refnode = station_to_node[rstat - 1];
3584 // sel for the stateful block is the stateful node list; its "refnode"
3585 // position must be matched by node index, so pass stateful_nodes.
3586 visits[c] = visits_from_block(Ps, stateful_nodes, ic, refnode);
3587 // node visits from rtnodes (ordered by node)
3588 Matrix<T> Pn(I * nIC, I * nIC, zero);
3589 for (std::size_t a = 0; a < I; ++a)
3590 for (std::size_t x = 0; x < nIC; ++x)
3591 for (std::size_t b = 0; b < I; ++b)
3592 for (std::size_t y = 0; y < nIC; ++y)
3593 Pn(a * nIC + x, b * nIC + y) =
3594 rtnodes(a * K + (ic[x] - 1), b * K + (ic[y] - 1));
3595 nodevisits[c] = visits_from_block(Pn, allnodes, ic, refnode);
3596 }
3597 }
3598
3599 /**
3600 * The chain-restricted routing over the STATEFUL nodes: the node-level
3601 * routing with the non-stateful nodes eliminated by a stochastic
3602 * complement, S = P11 + P12 (I - P22)^-1 P21. That is MATLAB's
3603 * dtmc_stochcomp, and it is what removes a Fork (and, there, the auto-added
3604 * ClassSwitch nodes) from the visit equations.
3605 */
3606 Matrix<T> station_routing(const std::vector<std::size_t>& ic) const {
3607 const T zero = num_traits<T>::from_int(0);
3608 const std::size_t nIC = ic.size();
3609 const std::size_t I = nodes.size();
3610 Matrix<T> full(I * nIC, I * nIC, zero);
3611 std::vector<std::size_t> pos(classes.size() + 1, 0); // class -> 1-based position in ic
3612 for (std::size_t x = 0; x < nIC; ++x) pos[ic[x]] = x + 1;
3613 for_each_route_block([&](std::size_t r, std::size_t s, const Matrix<T>& M) {
3614 if (r >= pos.size() || s >= pos.size() || pos[r] == 0 || pos[s] == 0) return;
3615 const std::size_t x = pos[r] - 1, y = pos[s] - 1;
3616 for (std::size_t a = 0; a < I; ++a)
3617 for (std::size_t b = 0; b < I; ++b) full(a * nIC + x, b * nIC + y) = M(a, b);
3618 });
3619 return stoch_comp_stateful(full, nIC);
3620 }
3621
3622 /**
3623 * Visit every (r, s) block of the routing in force, the one route_eff reads: Peff when the
3624 * expansion ran, else P. A block smaller than nnodes reads as zero there, so it is skipped.
3625 */
3626 template <class F>
3627 void for_each_route_block(F f) const {
3628 const std::map<std::pair<std::size_t, std::size_t>, Matrix<T>>& src = Peff.empty() ? P : Peff;
3629 for (const auto& kv : src)
3630 if (kv.second.rows() >= nodes.size() && kv.second.cols() >= nodes.size())
3631 f(kv.first.first, kv.first.second, kv.second);
3632 }
3633
3634 /**
3635 * The stochastic complement of a NODE-level routing block over the stateful
3636 * nodes, S = P11 + P12 (I - P22)^-1 P21.
3637 *
3638 * Split out of `station_routing` because the STATE-DEPENDENT routing table
3639 * (`rt_state`, state.h) is the same complement of a block whose SDR rows
3640 * have been re-evaluated at one state: the two must eliminate the stateless
3641 * nodes identically, or the per-state table and `rt` would disagree on a
3642 * model that merely has a Router in it.
3643 *
3644 * @param full node-major (nnodes * nIC) square block
3645 * @param nIC number of classes carried per node in that block
3646 */
3647 Matrix<T> stoch_comp_stateful(const Matrix<T>& full, std::size_t nIC) const {
3648 const T zero = num_traits<T>::from_int(0);
3649 const T one = num_traits<T>::from_int(1);
3650 const std::size_t I = nodes.size();
3651 std::vector<std::size_t> keep, drop;
3652 for (std::size_t a = 0; a < I; ++a) {
3653 const bool st = nodes[a].stateful;
3654 for (std::size_t x = 0; x < nIC; ++x) (st ? keep : drop).push_back(a * nIC + x);
3655 }
3656 if (drop.empty()) return full;
3657 // Only the stateless states a stateful one can reach enter S = P11 + P12 X, and that set is
3658 // closed under P22, so its own sub-system gives X exactly. On a flattened LQN nearly every
3659 // (node, class) pair is unreachable, and eliminating them all was cubic in nnodes*nclasses.
3660 {
3661 std::vector<char> isdrop(full.rows(), 0), seen(full.rows(), 0);
3662 for (std::size_t d : drop) isdrop[d] = 1;
3663 std::vector<std::size_t> stack;
3664 for (std::size_t k : keep)
3665 for (std::size_t d : drop)
3666 if (!seen[d] && full(k, d) != zero) { seen[d] = 1; stack.push_back(d); }
3667 while (!stack.empty()) {
3668 const std::size_t u = stack.back();
3669 stack.pop_back();
3670 for (std::size_t d : drop)
3671 if (!seen[d] && full(u, d) != zero) { seen[d] = 1; stack.push_back(d); }
3672 }
3673 std::vector<std::size_t> reach;
3674 for (std::size_t d : drop)
3675 if (seen[d]) reach.push_back(d);
3676 drop.swap(reach);
3677 }
3678 if (drop.empty()) {
3679 Matrix<T> S(keep.size(), keep.size(), zero);
3680 for (std::size_t a = 0; a < keep.size(); ++a)
3681 for (std::size_t b = 0; b < keep.size(); ++b) S(a, b) = full(keep[a], keep[b]);
3682 return S;
3683 }
3684
3685 const std::size_t nk = keep.size(), nd = drop.size();
3686 Matrix<T> P11(nk, nk, zero), P12(nk, nd, zero), P21(nd, nk, zero), A(nd, nd, zero);
3687 for (std::size_t a = 0; a < nk; ++a) {
3688 for (std::size_t b = 0; b < nk; ++b) P11(a, b) = full(keep[a], keep[b]);
3689 for (std::size_t b = 0; b < nd; ++b) P12(a, b) = full(keep[a], drop[b]);
3690 }
3691 for (std::size_t a = 0; a < nd; ++a) {
3692 for (std::size_t b = 0; b < nk; ++b) P21(a, b) = full(drop[a], keep[b]);
3693 for (std::size_t b = 0; b < nd; ++b)
3694 A(a, b) = T((a == b ? one : zero) - full(drop[a], drop[b]));
3695 }
3696 // X = A^-1 P21 by Gaussian elimination with partial pivoting
3697 Matrix<T> X = P21;
3698 std::vector<std::size_t> piv(nd);
3699 for (std::size_t i = 0; i < nd; ++i) piv[i] = i;
3700 for (std::size_t col = 0; col < nd; ++col) {
3701 std::size_t best = col;
3702 double bv = std::fabs(num_traits<T>::to_double(A(col, col)));
3703 for (std::size_t r2 = col + 1; r2 < nd; ++r2) {
3704 const double v = std::fabs(num_traits<T>::to_double(A(r2, col)));
3705 if (v > bv) { bv = v; best = r2; }
3706 }
3707 if (best != col) {
3708 for (std::size_t b = 0; b < nd; ++b) std::swap(A(col, b), A(best, b));
3709 for (std::size_t b = 0; b < nk; ++b) std::swap(X(col, b), X(best, b));
3710 }
3711 if (A(col, col) == zero)
3712 throw NumericError("network: the stochastic complement is singular");
3713 // The update touches only the pivot row's nonzero columns; a zero one would change nothing
3714 std::vector<std::size_t> nzA, nzX;
3715 for (std::size_t b = 0; b < nd; ++b)
3716 if (A(col, b) != zero) nzA.push_back(b);
3717 for (std::size_t b = 0; b < nk; ++b)
3718 if (X(col, b) != zero) nzX.push_back(b);
3719 for (std::size_t r2 = 0; r2 < nd; ++r2) {
3720 if (r2 == col) continue;
3721 const T f = T(A(r2, col) / A(col, col));
3722 if (f == zero) continue;
3723 for (std::size_t b : nzA) A(r2, b) = T(A(r2, b) - f * A(col, b));
3724 for (std::size_t b : nzX) X(r2, b) = T(X(r2, b) - f * X(col, b));
3725 }
3726 }
3727 for (std::size_t r2 = 0; r2 < nd; ++r2)
3728 for (std::size_t b = 0; b < nk; ++b) X(r2, b) = T(X(r2, b) / A(r2, r2));
3729
3730 // Same summation order as the dense product, with the zero terms of P12 skipped
3731 Matrix<T> S = P11;
3732 std::vector<T> acc(nk, zero);
3733 for (std::size_t a = 0; a < nk; ++a) {
3734 std::fill(acc.begin(), acc.end(), zero);
3735 for (std::size_t m = 0; m < nd; ++m) {
3736 const T w = P12(a, m);
3737 if (w == zero) continue;
3738 for (std::size_t b = 0; b < nk; ++b) acc[b] += w * X(m, b);
3739 }
3740 for (std::size_t b = 0; b < nk; ++b) S(a, b) = T(S(a, b) + acc[b]);
3741 }
3742 return S;
3743 }
3744
3745 // ---- predicates, ports of the sn_has_* family -------------------------
3746
3747 bool has_open_classes() const {
3748 for (const JobClass& c : classes)
3749 if (std::isinf(c.population)) return true;
3750 return false;
3751 }
3752
3753 /**
3754 * `sn_is_open_model`: EVERY class is open, which is not `has_open_classes`.
3755 * A mixed model passes that predicate and fails this one, and an analyzer
3756 * that confuses the two hands a closed chain to a solver with no level for
3757 * its population. An empty class list is not an open model either.
3758 */
3759 bool is_open_model() const {
3760 for (const JobClass& c : classes)
3761 if (!std::isinf(c.population)) return false;
3762 return !classes.empty();
3763 }
3764
3765 bool has_multi_server() const {
3766 for (const Station<T>& s : stations)
3767 if (std::isfinite(s.nservers) && s.nservers > 1.0) return true;
3768 return false;
3769 }
3770
3772 for (const JobClass& c : classes)
3773 if (std::isfinite(c.population) && c.population != std::floor(c.population + 0.5))
3774 return true;
3775 return false;
3776 }
3777
3778 bool has_class_switching() const { return nclasses != nchains; }
3779
3780 bool has_priorities() const {
3781 for (const JobClass& c : classes)
3782 if (c.prio > 0) return true;
3783 return false;
3784 }
3785
3786 /**
3787 * Whether the classes carry more than one priority level.
3788 *
3789 * NOT `has_priorities()`, which asks whether any priority is nonzero: a
3790 * model whose classes all sit at level 1 has priorities by that test and
3791 * nothing to distinguish, and the reference's warning below keys on the
3792 * distinction rather than on the magnitude.
3793 */
3795 if (classes.empty()) return false;
3796 for (const JobClass& c : classes)
3797 if (c.prio != classes.front().prio) return true;
3798 return false;
3799 }
3800
3801 /**
3802 * Whether some station runs a discipline that READS the class priorities.
3803 *
3804 * The list is exactly the `*PRIO` family. Priority-awareness is a property
3805 * of the DECLARED policy and is never inferred from the data: a base policy
3806 * is not upgraded because the classes it was handed carry unequal
3807 * priorities. `afterEventStation` did infer it once, and plain LCFS with
3808 * distinct priorities then behaved as none of the three policies involved.
3809 */
3811 for (const Station<T>& s : stations) {
3812 switch (s.sched) {
3813 case SchedStrategy::HOL:
3814 case SchedStrategy::PSPRIO:
3815 case SchedStrategy::DPSPRIO:
3816 case SchedStrategy::GPSPRIO:
3817 case SchedStrategy::LCFSPRIO:
3818 case SchedStrategy::LCFSPRPRIO:
3819 case SchedStrategy::LCFSPIPRIO:
3820 case SchedStrategy::FCFSPRPRIO:
3821 case SchedStrategy::FCFSPIPRIO:
3822 case SchedStrategy::SRPTPRIO:
3823 return true;
3824 default:
3825 break;
3826 }
3827 }
3828 return false;
3829 }
3830
3831 /** Priorities were declared and no station will read them. */
3832 bool priorities_ignored() const {
3834 }
3835
3836 /**
3837 * Port of sn_has_homogeneous_scheduling.
3838 *
3839 * The MATLAB function is `length(findstring(sn.sched, strategy)) ==
3840 * sn.nstations`, and findstring matches STRINGS: on the numeric sched
3841 * vector its strcmp is false, so it returns the sentinel -1, whose length
3842 * is 1. The predicate therefore reduces to nstations == 1 whatever the
3843 * disciplines are, which is what the reference actually computes and what
3844 * the AMVA dispatch in solver_amva actually sees. Reproduced rather than
3845 * corrected: fixing it here would send homogeneous-delay layers down a
3846 * different branch than every other codebase takes.
3847 */
3849
3851 bool bad = false;
3852 for (std::size_t i = 0; i < nstations; ++i) {
3853 if (stations[i].sched != SchedStrategy::FCFS) continue;
3854 bool any = false;
3856 for (std::size_t r = 0; r < nclasses; ++r) {
3857 if (disabled[i][r]) continue; // MATLAB drops the NaN entries here
3858 if (!any) {
3859 lo = hi = rates(i, r);
3860 any = true;
3861 } else {
3862 if (rates(i, r) < lo) lo = rates(i, r);
3863 if (rates(i, r) > hi) hi = rates(i, r);
3864 }
3865 }
3866 if (any && hi > lo) bad = true;
3867 }
3868 return bad;
3869 }
3870
3872 for (const Station<T>& s : stations)
3873 if (!(s.sched == SchedStrategy::INF || s.sched == SchedStrategy::PS ||
3874 s.sched == SchedStrategy::FCFS || s.sched == SchedStrategy::LCFSPR ||
3875 s.sched == SchedStrategy::LCFS || s.sched == SchedStrategy::EXT))
3876 return false;
3877 return true;
3878 }
3879
3880 /**
3881 * BCMP type 1 asks the FCFS service to be exponential. `has_multi_class_heter_fcfs`
3882 * compares the class MEANS only, so a class-homogeneous Erlang, hyper-exponential or
3883 * deterministic FCFS station used to pass this gate and be dispatched to exact MVA,
3884 * which reads the means alone and returns the exponential answer with no warning.
3885 */
3887 for (std::size_t i = 0; i < nstations; ++i) {
3888 if (stations[i].sched != SchedStrategy::FCFS) continue;
3889 for (std::size_t r = 0; r < nclasses; ++r) {
3890 const double v = num_traits<T>::to_double(scv(i, r));
3891 if (std::isinf(v) || !(v > 0.0)) continue;
3892 if (!(v > 1.0 - GlobalConstants::FineTol && v < 1.0 + GlobalConstants::FineTol))
3893 return false;
3894 }
3895 }
3896 return true;
3897 }
3898
3899 /**
3900 * Kendall's K of station IST (1-based), +inf when unbounded. Implementation of
3901 * api/sn/sn_get_buffer_size.h, which delegates here so the solvers' member calls and
3902 * the api free function cannot drift; the traps are documented on that header.
3903 */
3904 double buffer_size(std::size_t ist) const {
3905 double k = std::numeric_limits<double>::infinity();
3906 if (cap[ist - 1] >= 0.0) k = std::min(k, cap[ist - 1]);
3907 double ccap = 0.0;
3908 bool anyServed = false;
3909 for (std::size_t r = 0; r < nclasses; ++r)
3910 if (classcap[ist - 1][r] > 0.0) {
3911 ccap += classcap[ist - 1][r];
3912 anyServed = true;
3913 }
3914 if (anyServed) k = std::min(k, ccap);
3915 double reachable = 0.0;
3916 for (std::size_t r = 0; r < nclasses; ++r)
3917 if (!anyServed || classcap[ist - 1][r] > 0.0) reachable += classes[r].population;
3918 return (k >= reachable) ? std::numeric_limits<double>::infinity() : k;
3919 }
3920
3921 /** Implementation of api::sn_is_mm1k_loss; that free function delegates here. */
3922 bool is_mm1k_loss() const {
3923 if (nclasses != 1 || nodes.size() != 3) return false;
3924 for (std::size_t k = 0; k < classes.size(); ++k)
3925 if (std::isfinite(classes[k].population)) return false; // nclosedjobs ~= 0
3926 std::size_t nq = 0, nsrc = 0, nsink = 0, qnode = 0, snode = 0;
3927 for (std::size_t a = 0; a < nodes.size(); ++a) {
3928 switch (nodes[a].nodetype) {
3929 case NodeType::Queue: ++nq; qnode = a + 1; break;
3930 case NodeType::Source: ++nsrc; snode = a + 1; break;
3931 case NodeType::Sink: ++nsink; break;
3932 default: return false;
3933 }
3934 }
3935 if (nq != 1 || nsrc != 1 || nsink != 1) return false;
3936 const std::size_t qist = nodes[qnode - 1].station;
3937 const std::size_t sist = nodes[snode - 1].station;
3938 if (qist == 0 || sist == 0) return false;
3939 if (stations[qist - 1].nservers != 1.0) return false;
3940 if (droprule.size() < qist || droprule[qist - 1].empty() ||
3941 droprule[qist - 1][0] != DropStrategy::DROP)
3942 return false;
3943 if (!(qist <= cap.size()) || !std::isfinite(cap[qist - 1]) || !(cap[qist - 1] > 0.0))
3944 return false;
3945 if (std::fabs(num_traits<T>::to_double(scv(sist - 1, 0)) - 1.0) > 1e-6) return false;
3946 if (std::fabs(num_traits<T>::to_double(scv(qist - 1, 0)) - 1.0) > 1e-6) return false;
3947 // A DECLARED STATE DEPENDENCE IS NOT AN M/M/1/K. Both closed forms this
3948 // predicate gates read ONE service rate -- `qsys_mm1k_loss` a scalar mu,
3949 // `qsys_mg1k_loss_mgs` the first two moments of one service law -- so a
3950 // station carrying lldscaling, cdscaling or jdscaling would be answered with
3951 // the UNSCALED queue: a wrong number, not a coarse one, and silent.
3952 const qn::Station<T>& qst = stations[qist - 1];
3953 if (!qst.lldscaling.empty() || static_cast<bool>(qst.cdscaling) ||
3954 static_cast<bool>(qst.jdscaling))
3955 return false;
3956 return true;
3957 }
3958
3959 /**
3960 * Some station can REFUSE a job: its own buffer BINDS, or a finite capacity region
3961 * caps a set of stations jointly. Implementation of api::sn_has_blocking, which
3962 * delegates here; the rule and its two exemptions are documented on that function.
3963 */
3964 bool has_blocking() const {
3965 if (!regions.empty()) return true;
3966 for (std::size_t a = 0; a < nodes.size(); ++a)
3967 if (nodes[a].nodetype == NodeType::Cache) return false;
3968 if (is_mm1k_loss()) return false;
3969 for (std::size_t ist = 1; ist <= stations.size(); ++ist)
3970 if (std::isfinite(buffer_size(ist))) return true;
3971 return false;
3972 }
3973
3974 bool has_product_form() const {
3975 // BCMP asks for infinite buffers: without this conjunct a BAS-blocked station or
3976 // any binding finite buffer read as product form, though its truncation couples
3977 // the station occupancies.
3980 }
3981
3982 /**
3983 * Port of sn_has_product_form_not_het_fcfs: LCFS is excluded, and at FCFS the service
3984 * must be exponential AND class-independent, which is what BCMP type 1 asks for. With
3985 * unequal per-class means the product-form solve returns a wait proportional to each
3986 * class's own demand where FCFS makes every class wait behind the same queue. The mean
3987 * comparison is between CHAIN service times (visit-weighted over the classes that
3988 * actually visit the station): a class that never visits cannot break product form,
3989 * and within-chain heterogeneity is invisible to both the product-form and the qd
3990 * branch, which deaggregate a chain result proportionally to each class's own demand,
3991 * so only between-chain heterogeneity warrants the divert. LN layers carry seeded
3992 * rates for classes with zero visits, which a raw per-class comparison mistakes for
3993 * heterogeneity.
3994 *
3995 * CHECK_MEANS drops the mean test; pass false only for an algorithm
3996 * that models class-dependent FCFS itself (ab, schmidt, schmidt-ext).
3997 */
3998 bool has_product_form_not_het_fcfs(bool check_means = true) const {
3999 for (const Station<T>& s : stations)
4000 if (!(s.sched == SchedStrategy::INF || s.sched == SchedStrategy::PS ||
4001 s.sched == SchedStrategy::FCFS || s.sched == SchedStrategy::LCFSPR ||
4002 s.sched == SchedStrategy::EXT))
4003 return false;
4004 if (has_priorities()) return false;
4005 for (std::size_t i = 0; i < nstations; ++i) {
4006 if (stations[i].sched != SchedStrategy::FCFS) continue;
4007 for (std::size_t r = 0; r < nclasses; ++r) {
4008 if (disabled[i][r]) continue;
4009 const double v = num_traits<T>::to_double(scv(i, r));
4010 if (!std::isinf(v) && v > 0.0 &&
4011 !(v > 1.0 - GlobalConstants::FineTol && v < 1.0 + GlobalConstants::FineTol))
4012 return false;
4013 }
4014 if (!check_means || visits.empty()) continue;
4015 const std::size_t isf = stateful_of_station(i + 1) - 1;
4016 double stmin = 0.0, stmax = 0.0;
4017 bool anyserved = false;
4018 for (std::size_t c = 0; c < nchains && c < visits.size(); ++c) {
4019 double num = 0.0, den = 0.0;
4020 for (std::size_t r = 0; r < nclasses; ++r) {
4021 if (!chains.empty() && !chains[c][r]) continue;
4022 if (disabled[i][r]) continue;
4023 const double w = num_traits<T>::to_double(visits[c](isf, r));
4024 const double rate = num_traits<T>::to_double(rates(i, r));
4025 if (w > GlobalConstants::Zero && std::isfinite(rate) && rate > 0.0) {
4026 num += w / rate;
4027 den += w;
4028 }
4029 }
4030 if (den > 0.0) {
4031 const double st = num / den;
4032 if (!anyserved) {
4033 stmin = stmax = st;
4034 anyserved = true;
4035 } else {
4036 stmin = std::min(stmin, st);
4037 stmax = std::max(stmax, st);
4038 }
4039 }
4040 }
4041 if (anyserved && stmax - stmin > GlobalConstants::CoarseTol * stmax) return false;
4042 }
4043 return true;
4044 }
4045
4046 /** Grow every routing block to the current node count. */
4048 for (auto& kv : P) grow_block(kv.second);
4049 }
4050 void grow_block(Matrix<T>& B) const {
4051 if (B.rows() == nodes.size()) return;
4052 Matrix<T> g(nodes.size(), nodes.size(), num_traits<T>::from_int(0));
4053 for (std::size_t a = 0; a < B.rows(); ++a)
4054 for (std::size_t b = 0; b < B.cols(); ++b) g(a, b) = B(a, b);
4055 B = g;
4056 }
4057
4058 /** Total population, as MATLAB's getNumberOfJobs summed. */
4059 double total_jobs() const {
4060 double s = 0.0;
4061 for (const JobClass& c : classes)
4062 if (std::isfinite(c.population)) s += c.population;
4063 return s;
4064 }
4065};
4066
4067/**
4068 * The swap graph of a PAS / OI station, with the defaults `refreshLocalVars.m`
4069 * installs applied.
4070 *
4071 * MATLAB fills `sn.nodeparam{ind}.swapGraph` at refresh time and NEVER leaves it
4072 * empty: an OI station always gets `zeros(R,R)`, a PAS station with no explicit
4073 * graph gets the complete compatibility graph `ones(R,R) - eye(R)`, and an
4074 * explicit graph is taken as given. The builder here stores the raw user graph,
4075 * so the defaulting has to happen on read; doing it here rather than in the
4076 * builder keeps it independent of whether the classes were added before or
4077 * after `set_service_rate_function`.
4078 *
4079 * @param sn the struct
4080 * @param ist 1-based station index
4081 */
4082template <class T>
4084 const Station<T>& st = sn.stations[ist - 1];
4085 const std::size_t R = sn.nclasses;
4086 const T zero = num_traits<T>::from_int(0);
4087 if (st.sched == SchedStrategy::OI) return Matrix<T>(R, R, zero);
4088 if (!st.swap_graph.empty()) return st.swap_graph;
4089 if (st.sched != SchedStrategy::PAS) return Matrix<T>(R, R, zero);
4091 for (std::size_t r = 0; r < R; ++r) g(r, r) = zero;
4092 return g;
4093}
4094
4095/** True when the station's materialized swap graph is entirely zero. */
4096template <class T>
4097bool station_swap_graph_is_zero(const NetworkStruct<T>& sn, std::size_t ist) {
4098 const Matrix<T> g = station_swap_graph(sn, ist);
4099 const T zero = num_traits<T>::from_int(0);
4100 for (std::size_t a = 0; a < g.rows(); ++a)
4101 for (std::size_t b = 0; b < g.cols(); ++b)
4102 if (g(a, b) != zero) return false;
4103 return true;
4104}
4105
4106} // namespace qn
4107} // namespace line
4108
4109#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.
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 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::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
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
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