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