LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ssa_nrm.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_SSA_SOLVER_SSA_NRM_H
6#define LINE_SOLVERS_SSA_SOLVER_SSA_NRM_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverSSA, the `nrm` method: a port of `solver_ssa_nrm.m` and its analyzer
12 * `solver_ssa_analyzer_nrm.m`.
13 *
14 * WHAT THE METHOD IS. The queueing network is rewritten as a REACTION NETWORK
15 * over a state vector counting jobs per (node, class, service phase), and the
16 * sample path is generated by Gibson and Bruck's Next Reaction Method: every
17 * reaction carries an absolute exponential clock `Pk`, the elapsed integrated
18 * propensity `Tk`, and fires at `tau = (Pk - Tk)/Ak`; only the reactions in the
19 * fired reaction's dependency set have their propensity recomputed. Metrics are
20 * accumulated as time integrals along the path rather than from a stored
21 * trajectory, so the memory cost is independent of the sample count.
22 *
23 * NRM IS NOT THE SERIAL SIMULATOR. `method='serial'` is a different engine with
24 * a different random stream and a different event granularity. A seed-fixed
25 * result from one cannot be reproduced by the other, and a mismatch between
26 * them is not evidence of a defect in either.
27 *
28 * DOUBLE ONLY. A sample path is generated from exponential clocks, which are
29 * `-log(u)` of a uniform; there is no exact rational value to compute and no
30 * additional information a wider float could carry, since the answer's error is
31 * the Monte Carlo error and not the rounding. A non-`double` backend is
32 * therefore refused BY NAME rather than silently narrowed, exactly as
33 * `solver_fluid` refuses one.
34 *
35 * WHAT IS PORTED AND WHAT REFUSES lives in `ssa_dispatch.h`, which holds the
36 * eligibility gate. This file assumes the gate has already passed and refuses
37 * again, by name, for anything it meets that the gate should have caught -- a
38 * second gate is cheap and a silently wrong sample path is not.
39 *
40 * ONE DELIBERATE DIVERGENCE, and it is a reference defect. In
41 * `solver_ssa_nrm.m` lines 1511-1521 the finite-capacity loss test is applied
42 * to the destination of EVERY single-destination firing, with only the renege
43 * and retry columns excluded -- `isPhaseRx` is not in the exclusion list. A
44 * PHASE CHANGE is a single-destination firing whose "destination" is another
45 * phase slot of the same job at the same station, so at a station that is at
46 * its physical capacity with an open class the reference declares the phase
47 * change a loss and DESTROYS the job. The condition is reachable (an M/PH/1/K
48 * queue under PS, which `phaseNrmOK` admits). This port excludes phase changes
49 * from the loss test. The divergence is stated rather than hidden because
50 * reproducing it would silently violate job conservation, and it is not fixed
51 * in the reference from here because that file is outside this port's scope.
52 */
53
55#include <algorithm>
56#include <cmath>
57#include <cstddef>
58#include <limits>
59#include <string>
60#include <vector>
61
67#include "line/util/error.h"
68#include "line/util/matrix.h"
69
70namespace line {
71namespace ssa {
72
73namespace detail {
74
76
77/** True for the non-preemptive disciplines that hold waiting jobs in a buffer. */
78inline bool sched_is_buffered(SchedStrategy s) {
82}
83
84/**
85 * The pass-and-swap family, whose buffer is the FULL ordered job list (oldest
86 * first) rather than the waiting jobs: `isListSched`. The reference canonicalizes
87 * OI to PAS with an all-zero swap graph; here OI keeps its own tag and
88 * `sn.pasparam` carries the zero graph, so both read the same machinery.
89 */
90inline bool sched_is_list(SchedStrategy s) {
91 return s == SchedStrategy::PAS || s == SchedStrategy::OI;
92}
93
94/**
95 * Of those, the ones whose in-service phase composition the NRM can track.
96 *
97 * LCFSPR is excluded: preempt-resume would need the preempted job's phase
98 * remembered in the buffer, which the waiting-only buffer does not record.
99 * `phaseNrmOK` in the reference draws the same line.
100 */
101inline bool sched_is_buf_ph(SchedStrategy s) {
102 return s == SchedStrategy::FCFS || s == SchedStrategy::LCFS || s == SchedStrategy::SIRO ||
104}
105
106/**
107 * The DPS / GPS family, whose rate law reads per-class weights from schedparam.
108 *
109 * THE PRIORITY VARIANTS BELONG HERE TOO. `dpsprioshare` and `gpsprioshare` call
110 * straight through to `dpsshare` / `gpsshare` -- with the priority-restricted
111 * population above capacity and the full one below it -- so they read the same
112 * weights. Leaving them out left `wnorm_` all zeros at such a station, which
113 * makes every share zero and every reaction zero-rate: the engine reports a
114 * DEADLOCK rather than a wrong number, which is at least loud.
115 */
116inline bool sched_is_weighted(SchedStrategy s) {
117 return s == SchedStrategy::DPS || s == SchedStrategy::GPS ||
119}
120
121/** One reaction of the network: a departure, or a phase change within a service process. */
122struct NrmReaction {
123 std::size_t node = 0; ///< 0-based node the reaction consumes a job at
124 std::size_t cls = 0; ///< 0-based class
125 std::size_t from = 0; ///< 0-based state slot the propensity reads
126 double rate = 0.0; ///< the service-process rate the reaction carries
127 bool is_phase = false; ///< an internal D0 phase change rather than a departure
128 std::size_t phase_from = 0, phase_to = 0; ///< 0-based phases of a phase change
129 std::size_t dep_phase = 0; ///< 0-based phase a departure absorbs from
130 bool is_buf_svc = false; ///< departure of a buffered-PH class
131 /** A polling SWITCHOVER step: moves no job, reads and advances the controller. */
132 bool is_sw = false;
133 /** A cache READ: the outcome class and the contents update are drawn at firing. */
134 bool is_cache = false;
135
136 /** `find(P(:,k))` and its cumulative weights, when the destination is drawn. */
137 std::vector<std::size_t> to_slots;
138 std::vector<double> cdf;
139 /** `find(S(:,k) < 0)`, the slots the firing decrements. */
140 std::vector<std::size_t> from_slots;
141 std::size_t nnzP = 0;
142 /** `find(S(:,k) > 0)` when the destination is deterministic; npos = none. */
143 std::size_t det_dest = static_cast<std::size_t>(-1);
144
145 /**
146 * A STATE-DEPENDENT destination, resolved at firing time rather than drawn
147 * from `cdf`.
148 *
149 * `refresh_routing` expands RROBIN, WRROBIN, JSQ and SQ into a probability
150 * split so that the matrix solvers have a matrix to read, and that split is
151 * what `cdf` above would carry. It is the right answer for a mean-value
152 * solver and the WRONG one for a sample path: a round-robin dispatcher
153 * sends the k-th job to the k-th target, not to a uniformly drawn one, and
154 * that is precisely the variance the strategy exists to remove. So a
155 * reaction leaving a node that declares one of the four keeps the CANDIDATE
156 * list instead, in the order `refresh_routing` enumerates it, and picks
157 * from the live population.
158 */
160 /** One candidate per declared out-arc: the destination (node, class). */
161 std::vector<std::size_t> sd_node, sd_class;
162 /** WRROBIN weights, normalised; empty for the other three. */
163 std::vector<double> sd_weight;
164 /** Per-candidate entry-phase slots and their `pie` weights. */
165 std::vector<std::vector<std::size_t>> sd_slots;
166 std::vector<std::vector<double>> sd_pie;
167};
168
169} // namespace detail
170
171/**
172 * The NRM engine: the reaction network built from `sn`, and the sample path.
173 *
174 * It is a class rather than a chain of free functions because the run loop
175 * threads eight pieces of mutable state (the population vector, the per-node
176 * buffers, the in-service phase multisets, the clocks, the propensities and the
177 * three metric accumulators) through every step, and the reference threads the
178 * same eight as arguments to a single 700-line function.
179 */
180template <class T>
182public:
184 : sn_(sn), opt_(opt), rng_(opt.seed) {
185 build_layout();
186 check_peaks();
187 build_regions();
188 build_reactions();
189 rr_cursor_.assign(I_ * K_ + K_ + 1, 0);
190 build_dependencies();
191 build_initial_state();
192 }
193
194 /** Run `opt.samples` firings and return the time-averaged metrics. */
196
197 /** The state-vector length, for the tests that assert the phase expansion. */
198 std::size_t nstates() const { return NS_; }
199 /** The reaction count, likewise. */
200 std::size_t nreactions() const { return rx_.size(); }
201 /** The cache write-back of the last `run()`, one entry per Cache node. */
202 const std::vector<SsaCacheRatio>& cache() const { return cache_out_; }
203
204private:
205 using SchedStrategy = lang::SchedStrategy;
206 using Rx = detail::NrmReaction;
207
208 // ---- the model ---------------------------------------------------------
209 const qn::NetworkStruct<T>& sn_;
210 SsaOptions opt_;
211 SsaRng rng_;
212
213 std::size_t I_ = 0, K_ = 0, M_ = 0, NS_ = 0, maxnph_ = 1;
214
215 // ---- the (node, class, phase) slot layout ------------------------------
216 std::vector<std::vector<std::size_t>> phoff_, nph_;
217 std::vector<std::size_t> slot_node_, slot_class_;
218
219 // ---- per-node and per-station data read by the rate laws ---------------
220 std::vector<bool> is_station_;
221 std::vector<std::size_t> to_station_; ///< 0-based station, npos when not a station
222 std::vector<double> mi_; ///< servers, infinite off a station
223 std::vector<std::vector<double>> rate_;
224 std::vector<std::vector<std::vector<double>>> pie_; ///< entry-phase law per (node, class)
225 std::vector<std::vector<bool>> buf_ph_class_;
226 std::vector<bool> buf_ph_node_;
227 std::vector<SchedStrategy> sched_;
228 std::vector<std::vector<double>> lld_; ///< per station, empty when not load dependent
229 std::vector<std::vector<double>> wnorm_; ///< per station, normalized DPS/GPS weights
230 std::vector<double> classprio_;
231 std::vector<double> nservers_;
232
233 // ---- the reaction network ---------------------------------------------
234 std::vector<Rx> rx_;
236 std::vector<std::vector<std::size_t>> D_;
237 /** Departure reactions of each (station, class), for the throughput integral. */
238 std::vector<std::vector<std::vector<std::size_t>>> dep_rx_;
239 /**
240 * Stations and classes at which a refused arrival must BLOCK rather than be
241 * lost, and whether any exist at all. See `capacity_block`.
242 */
243 std::vector<std::vector<char>> blk_can_;
244 bool blk_on_ = false;
245
246 // ---- the sample path ---------------------------------------------------
247 std::vector<double> nvec0_;
248 std::vector<std::vector<std::size_t>> buffers0_;
249 std::vector<Matrix<double>> svcph0_;
250 /**
251 * The derived START and PREEMPT tags, as COUNTS of events along the path;
252 * `run` divides them by the simulated time. This engine has no
253 * `EventOutcome` to annotate -- it is a reaction network, not a state
254 * machine -- so the tags are raised where the buffer moves, which is the
255 * only place a job can take or lose a server here. Both engines must carry
256 * them or the counters would depend on `options.method`.
257 */
258 Matrix<double> start_cnt_, preempt_cnt_;
259 /**
260 * Departures that were BLOCKED, per (station, class). `TN` integrates the
261 * PROPENSITY, which counts a departure the station never makes once its
262 * successor is full (0.744 against the exact 0.652 on the BUG-81 tandem), so
263 * the blocked firings are subtracted from that integral before it is
264 * normalized. In expectation the count IS the integral of the blocked share
265 * of the rate, so the difference is unbiased -- and unlike recomputing that
266 * share it needs no second evaluation of a state-dependent dispatcher, whose
267 * draw would otherwise have to be replayed.
268 */
269 Matrix<double> block_cnt_;
270
271 /**
272 * One finite capacity region, `fcrPrecompute`: its member NODES, so the gate
273 * reads the population straight off the state vector, the caps reduced over
274 * the member rows (infinity = unbounded), and the per-class rule.
275 */
276 struct FcrRegion {
277 std::vector<char> member_node; ///< per node
278 std::vector<double> ccap; ///< per class
279 double gcap = std::numeric_limits<double>::infinity();
280 double memcap = std::numeric_limits<double>::infinity();
281 std::vector<double> sz; ///< per-class memory footprint
282 Matrix<double> A; ///< optional linear constraint A n <= b
283 std::vector<double> b;
284 std::vector<char> waitq; ///< per class: WAITQ parks, DROP loses
285 };
286 std::vector<FcrRegion> fcr_;
287 bool fcr_any_waitq_ = false;
288
289 /**
290 * The polling controller of each node, the reference's `buffers{ind} =
291 * [mode, pos, swk, ctr]`: mode 0 parked, 1 serving `pos`, 2 switching
292 * towards `pos` in switchover phase `swk` (0-based). `pos` is 1-based, as
293 * `qn::polling_next` reads it. Mutable path state, like the buffers, but a
294 * member so the propensity can read it without widening its signature.
295 */
296 // ---- the Cache sub-engine, `solver_ssa_nrm.m` lines 103-160 / cacheAccess --
297 /** 1 at a Cache node; `cache_read_[ind][r]` marks the classes that READ there. */
298 std::vector<char> is_cache_node_;
299 std::vector<std::vector<char>> cache_read_;
300 /** The contents row of each cache, laid out as `after_event_cache` reads it. */
301 std::vector<std::vector<T>> cache_var0_, cache_var_;
302 /** Jobs PRODUCED per (cache node, class), and merges per (cache node, read class). */
303 Matrix<double> cache_prod_, cache_dly_;
304 std::vector<SsaCacheRatio> cache_out_;
305 void cache_fire(const Rx& x, std::vector<double>& X, std::size_t& dest_pos, bool& have_dest);
306 void cache_write_back();
307
308 struct PollCtrl {
309 int mode = 0;
310 std::size_t pos = 1;
311 std::size_t swk = 0;
312 long ctr = 0;
313 };
314 std::vector<char> is_poll_;
315 std::vector<qn::PollingInfo<T>> poll_info_;
316 std::vector<PollCtrl> poll_ctrl_;
317 /** The controller row `pollLandCtrl` lands in; raises the START of a visit. */
318 PollCtrl poll_land(std::size_t ind, std::size_t q, int mode, long budget, bool raise = true);
319 /** Per-class populations at node `ind`, as `polling_next` reads them. */
320 std::vector<long> poll_nbuf(const std::vector<double>& X, std::size_t ind) const {
321 std::vector<long> nb(K_, 0);
322 for (std::size_t r = 0; r < K_; ++r) nb[r] = std::lround(class_pop(X, ind, r));
323 return nb;
324 }
325
326 // ---- construction ------------------------------------------------------
327 void build_layout();
328 void build_regions();
329 void build_reactions();
330 void build_dependencies();
331 void build_initial_state();
332
333 // ---- rate-law helpers, one per reference subfunction -------------------
334 double class_pop(const std::vector<double>& X, std::size_t ind, std::size_t r) const {
335 double s = 0.0;
336 for (std::size_t k = 0; k < nph_[ind][r]; ++k) s += X[phoff_[ind][r] + k];
337 return s;
338 }
339 std::vector<double> class_counts(const std::vector<double>& X, std::size_t ind) const {
340 std::vector<double> v(K_, 0.0);
341 for (std::size_t r = 0; r < K_; ++r) v[r] = class_pop(X, ind, r);
342 return v;
343 }
344 double kir_frac(const std::vector<double>& X, std::size_t slot, std::size_t ind,
345 std::size_t r) const {
346 const double nir = class_pop(X, ind, r);
347 return nir <= 0.0 ? 0.0 : X[slot] / nir;
348 }
349 double lldfac(std::size_t ist, double ntot) const {
350 if (ist == npos || lld_[ist].empty() || ntot < 1.0) return 1.0;
351 const std::size_t lim = lld_[ist].size();
352 std::size_t k = static_cast<std::size_t>(std::llround(ntot));
353 if (k > lim) k = lim;
354 return lld_[ist][k - 1];
355 }
356 static double dpsshare(const std::vector<double>& w, const std::vector<double>& n,
357 std::size_t r) {
358 double den = 0.0;
359 for (std::size_t s = 0; s < n.size(); ++s) den += w[s] * n[s];
360 return den <= 0.0 ? 0.0 : w[r] * n[r] / den;
361 }
362 static double gpsshare(const std::vector<double>& w, const std::vector<double>& n,
363 std::size_t r) {
364 if (n[r] <= 0.0) return 0.0;
365 double den = 0.0;
366 for (std::size_t s = 0; s < n.size(); ++s) den += n[s] > 0.0 ? w[s] : 0.0;
367 return den <= 0.0 ? 0.0 : w[r] / den;
368 }
369 /**
370 * The three PRIORITY sharing disciplines, ported 2026-08-15.
371 *
372 * ALL THREE SPLIT AT THE SERVER COUNT, and that is the whole idea: below
373 * capacity every job is in service and the station behaves as its
374 * non-priority twin, so priority cannot matter; above capacity only the
375 * MOST URGENT NON-EMPTY group shares the servers and everyone else is
376 * frozen. LINE orders priorities with the LOWER value more urgent.
377 *
378 * "Non-empty" is doing real work in `urgent`: an empty class never defines
379 * the urgent group, so a station holding only low-priority jobs serves them
380 * rather than stalling on a priority class that is not there.
381 */
382 bool urgent(const std::vector<double>& n, std::size_t r) const {
383 double best = 0.0;
384 bool any = false;
385 for (std::size_t s = 0; s < n.size(); ++s)
386 if (n[s] > 0.0 && (!any || classprio_[s] < best)) {
387 best = classprio_[s];
388 any = true;
389 }
390 return any && classprio_[r] == best;
391 }
392 /** The population vector restricted to r's priority group, and its total. */
393 std::vector<double> prio_group(const std::vector<double>& n, std::size_t r,
394 double* total = nullptr) const {
395 std::vector<double> act(n.size(), 0.0);
396 double tot = 0.0;
397 for (std::size_t s = 0; s < n.size(); ++s)
398 if (classprio_[s] == classprio_[r]) {
399 act[s] = n[s];
400 tot += n[s];
401 }
402 if (total) *total = tot;
403 return act;
404 }
405 double psprioshare(const std::vector<double>& n, std::size_t r, double c) const {
406 double ni = 0.0;
407 for (double v : n) ni += v;
408 if (ni <= 0.0) return 0.0;
409 if (ni <= c) return (n[r] / ni) * std::min(ni, c);
410 if (!urgent(n, r)) return 0.0;
411 double niprio = 0.0;
412 prio_group(n, r, &niprio);
413 return niprio <= 0.0 ? 0.0 : (n[r] / niprio) * std::min(niprio, c);
414 }
415 double dpsprioshare(const std::vector<double>& w, const std::vector<double>& n, std::size_t r,
416 double c) const {
417 double ni = 0.0;
418 for (double v : n) ni += v;
419 if (ni <= 0.0) return 0.0;
420 if (ni <= c) return dpsshare(w, n, r);
421 if (!urgent(n, r)) return 0.0;
422 return dpsshare(w, prio_group(n, r), r);
423 }
424 double gpsprioshare(const std::vector<double>& w, const std::vector<double>& n, std::size_t r,
425 double c) const {
426 double ni = 0.0;
427 for (double v : n) ni += v;
428 if (ni <= 0.0) return 0.0;
429 if (ni <= c) return gpsshare(w, n, r);
430 if (!urgent(n, r)) return 0.0;
431 return gpsshare(w, prio_group(n, r), r);
432 }
433 /**
434 * The population the LOAD-DEPENDENT factor is read at.
435 *
436 * PSPRIO uses the FULL vector in both branches while DPSPRIO and GPSPRIO
437 * use the priority-restricted one above capacity. That asymmetry is
438 * inherited from `State.afterEventStation` and is reproduced rather than
439 * tidied: the two conventions give different lld factors on the same state,
440 * and the reference's numbers were computed with these.
441 */
442 double prio_pop(const std::vector<double>& n, std::size_t r, double c) const {
443 double ni = 0.0;
444 for (double v : n) ni += v;
445 if (ni <= c || !urgent(n, r)) return ni;
446 double niprio = 0.0;
447 prio_group(n, r, &niprio);
448 return niprio;
449 }
450 /** `prioVec`: the vector DPSPRIO / GPSPRIO read the class dependence at. */
451 std::vector<double> prio_vec(const std::vector<double>& n, std::size_t r, double c) const {
452 double ni = 0.0;
453 for (double v : n) ni += v;
454 if (ni <= c || !urgent(n, r)) return n;
455 return prio_group(n, r);
456 }
457 /** Utilization at a dependent station is T*S/peak, so a peak must be declared. */
458 void check_peaks() const {
459 for (std::size_t ist = 0; ist < M_; ++ist) {
460 const auto& st = sn_.stations[ist];
461 const bool on[2] = {static_cast<bool>(st.cdscaling), static_cast<bool>(st.jdscaling)};
462 const std::vector<T>* pks[2] = {&st.cdscalingpeak, &st.jdscalingpeak};
463 const char* names[2] = {"setClassDependence", "setJointDependence"};
464 for (std::size_t h = 0; h < 2; ++h) {
465 if (!on[h]) continue;
466 bool ok = pks[h]->size() >= K_;
467 for (std::size_t k = 0; ok && k < K_; ++k)
468 ok = num_traits<T>::to_double((*pks[h])[k]) > 0.0;
469 if (!ok)
470 throw InputError("SolverSSA(method='nrm'): station '" + st.name +
471 "' declares a dependent scaling with no declared peak rate. "
472 "Utilization there is T*E[S]/peak, so pass the peak to " +
473 names[h]);
474 }
475 }
476 }
477
478 /**
479 * `cdfac`: the class-r entry of eta_i(n) .* beta_{i,r}(n), evaluated on the
480 * station's per-class population at firing time, as `State.afterEventInit`
481 * folds the joint handle into the class one. 1 when neither is declared.
482 */
483 double cdfac(std::size_t ist, const std::vector<double>& n, std::size_t r) const {
484 if (ist == npos) return 1.0;
485 const auto& st = sn_.stations[ist];
486 if (!st.cdscaling && !st.jdscaling) return 1.0;
487 std::vector<T> nt(n.size());
488 for (std::size_t s = 0; s < n.size(); ++s) nt[s] = T(n[s]);
489 double f = 1.0;
490 for (const auto* h : {&st.cdscaling, &st.jdscaling}) {
491 if (!*h) continue;
492 const std::vector<T> v = (*h)(nt);
493 f *= num_traits<T>::to_double(v[std::min(r, v.size() - 1)]);
494 }
495 return f;
496 }
497
498 // ---- the sample path ---------------------------------------------------
499 double propensity(std::size_t j, const std::vector<double>& X,
500 const std::vector<std::vector<std::size_t>>& bufs,
501 const std::vector<Matrix<double>>& svc) const;
502 std::size_t pick_from_buffer(const std::vector<std::size_t>& buf, std::size_t ist);
503 std::size_t draw_entry_phase(std::size_t ind, std::size_t r);
504 bool capacity_loss(const std::vector<double>& X, std::size_t slot) const;
505 /** The PAS parameters of 0-based station `ist`; refuses a station with no mu(c). */
506 const typename qn::NetworkStruct<T>::PasParam& pas_of(std::size_t ist) const;
507 /** `oiIncrements`: Delta_mu of each position of the ordered list `c` (0-based classes). */
508 std::vector<double> pas_increments(std::size_t ist, const std::vector<std::size_t>& c) const;
509 /** `oirate`: the class-r departure rate of the list `c`. */
510 double pas_rate(std::size_t ist, const std::vector<std::size_t>& c, std::size_t r) const;
511 /** `pasInSvc`: class-r jobs at a served position (Delta_mu > 0). */
512 double pas_in_service(std::size_t ist, const std::vector<std::size_t>& c, std::size_t r) const;
513 /** `oiStarted`: tag a START for each position served in `cnew` and not in `cold`. */
514 void pas_tag_started(std::size_t ind, const std::vector<std::size_t>& cold,
515 const std::vector<std::size_t>& cnew);
516 /** `oiDepart`: the pass-and-swap rewrite of a class-r departure. */
517 void pas_depart(std::size_t ind, std::vector<std::size_t>& c, std::size_t r);
518 std::size_t fcr_refusing_region(const std::vector<double>& X, std::size_t src_node,
519 std::size_t src_class, std::size_t dst_node,
520 std::size_t dst_class) const;
521 void fcr_outcome(const std::vector<double>& X, const Rx& x, std::size_t dst_node,
522 std::size_t dst_class, std::vector<std::vector<std::size_t>>& fcr_buf,
523 bool& lost, bool& blocked) const;
524 std::size_t fcr_release_cascade(std::vector<double>& X,
525 std::vector<std::vector<std::size_t>>& bufs,
526 std::vector<std::vector<std::size_t>>& fcr_buf,
527 std::vector<Matrix<double>>& svc, bool& svc_changed);
528 bool capacity_block(const std::vector<double>& X, std::size_t slot,
529 std::size_t src_slot) const;
530 std::size_t pick_preempted(const std::vector<double>& X,
531 const std::vector<std::size_t>& buf, std::size_t jnd,
532 std::size_t arr_class);
533 void apply_arrival_buffer(std::size_t jnd, std::size_t s, const std::vector<double>& X,
534 std::vector<std::vector<std::size_t>>& bufs,
535 std::vector<Matrix<double>>& svc, bool& svc_changed);
536 void update_buffers(std::size_t kfire, const std::vector<double>& X,
537 std::vector<std::vector<std::size_t>>& bufs, std::size_t dest_pos,
538 bool have_dest, std::vector<Matrix<double>>& svc, bool& svc_changed);
539
540 /** Fill `sd_*` on a reaction leaving a node that routes by strategy. */
541 void build_state_dependent_dest(Rx& x);
542 /** Pick the destination slot of a state-dependent firing from the live state. */
543 std::size_t resolve_state_dependent_dest(std::size_t kfire, const std::vector<double>& X);
544
545 /**
546 * Round-robin cursor per (SOURCE NODE, CLASS), keyed `node * K_ + cls`.
547 * Not per reaction: a phase-type service splits one dispatcher across
548 * several reactions and a pointer each would let the phases cycle
549 * independently, which is not a round robin.
550 */
551 std::vector<std::size_t> rr_cursor_;
552
553 /** Raise a START (or, with `preempt`, a PREEMPT) for class R at node IND. */
554 void tag(std::size_t ind, std::size_t r, bool preempt) {
555 if (!is_station_[ind] || r >= K_) return;
556 const std::size_t ist = to_station_[ind];
557 if (ist >= M_) return;
558 // A Source CREATES jobs rather than admitting them to service, so it
559 // seizes nothing; every codebase reports a zero row for it.
560 if (sched_[ist] == SchedStrategy::EXT) return;
561 (preempt ? preempt_cnt_ : start_cnt_)(ist, r) += 1.0;
562 }
563
564 static constexpr std::size_t npos = static_cast<std::size_t>(-1);
565};
566
567// ---------------------------------------------------------------------------
568// Construction
569// ---------------------------------------------------------------------------
570
571/**
572 * The phase slot map, `solver_ssa_nrm.m` lines 33-111.
573 *
574 * The state vector counts jobs per (node, class, PHASE) so phase-type service
575 * is represented exactly instead of collapsed onto its mean rate. With one
576 * phase everywhere the layout degenerates to the flat (node, class) index, so
577 * an exponential model is unaffected -- which is the self-check the reference
578 * names for this generalization.
579 */
580template <class T>
581void NrmEngine<T>::build_layout() {
582 I_ = sn_.nof_nodes();
583 K_ = sn_.nclasses;
584 M_ = sn_.nstations;
585
586 is_station_.assign(I_, false);
587 to_station_.assign(I_, npos);
588 for (std::size_t i = 0; i < I_; ++i)
589 if (sn_.nodes[i].station != 0) {
590 is_station_[i] = true;
591 to_station_[i] = sn_.nodes[i].station - 1;
592 }
593
594 sched_.assign(M_, SchedStrategy::FCFS);
595 nservers_.assign(M_, 1.0);
596 for (std::size_t i = 0; i < M_; ++i) {
597 sched_[i] = sn_.stations[i].sched;
598 nservers_[i] = sn_.stations[i].nservers;
599 }
600
601 // Phase counts. `sn.phasessz` floors at one, so a disabled pair still owns
602 // exactly one slot and its zero rate silences the reaction.
603 nph_.assign(I_, std::vector<std::size_t>(K_, 1));
604 pie_.assign(I_, std::vector<std::vector<double>>(K_));
605 for (std::size_t i = 0; i < I_; ++i) {
606 for (std::size_t r = 0; r < K_; ++r) {
607 pie_[i][r].assign(1, 1.0);
608 if (!is_station_[i]) continue;
609 const std::size_t ist = to_station_[i];
610 if (sn_.disabled[ist][r]) continue;
611 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
612 const std::size_t n = m.D0.rows();
613 if (n <= 1) continue;
614 nph_[i][r] = n;
615 const std::vector<T> p = lang::dist_pie(sn_.service[ist][r]);
616 std::vector<double> pd(n, 0.0);
617 double tot = 0.0;
618 for (std::size_t k = 0; k < n && k < p.size(); ++k) {
619 pd[k] = num_traits<T>::to_double(p[k]);
620 if (!(pd[k] > 0.0)) pd[k] = 0.0;
621 tot += pd[k];
622 }
623 if (tot > 0.0) {
624 for (double& x : pd) x /= tot;
625 } else {
626 pd.assign(n, 0.0);
627 pd[0] = 1.0;
628 }
629 pie_[i][r] = pd;
630 }
631 }
632
633 phoff_.assign(I_, std::vector<std::size_t>(K_, 0));
634 NS_ = 0;
635 maxnph_ = 1;
636 for (std::size_t i = 0; i < I_; ++i)
637 for (std::size_t r = 0; r < K_; ++r) {
638 phoff_[i][r] = NS_;
639 NS_ += nph_[i][r];
640 maxnph_ = std::max(maxnph_, nph_[i][r]);
641 }
642 slot_node_.assign(NS_, 0);
643 slot_class_.assign(NS_, 0);
644 for (std::size_t i = 0; i < I_; ++i)
645 for (std::size_t r = 0; r < K_; ++r)
646 for (std::size_t k = 0; k < nph_[i][r]; ++k) {
647 slot_node_[phoff_[i][r] + k] = i;
648 slot_class_[phoff_[i][r] + k] = r;
649 }
650
651 // Buffered phase-type service: only the jobs ACTUALLY in service carry a
652 // phase, so their composition lives in svcph and not in the population.
653 buf_ph_class_.assign(I_, std::vector<bool>(K_, false));
654 buf_ph_node_.assign(I_, false);
655 for (std::size_t i = 0; i < I_; ++i) {
656 if (!is_station_[i] || !detail::sched_is_buf_ph(sched_[to_station_[i]])) continue;
657 for (std::size_t r = 0; r < K_; ++r)
658 if (nph_[i][r] > 1) {
659 buf_ph_class_[i][r] = true;
660 buf_ph_node_[i] = true;
661 }
662 }
663
664 // Rates, server counts and the scaling tables the rate laws read.
665 const double inf = std::numeric_limits<double>::infinity();
666 mi_.assign(I_, inf);
667 rate_.assign(I_, std::vector<double>(K_, 0.0));
668 for (std::size_t i = 0; i < I_; ++i) {
669 if (is_station_[i]) {
670 const std::size_t ist = to_station_[i];
671 mi_[i] = sn_.stations[ist].nservers;
672 for (std::size_t r = 0; r < K_; ++r)
673 if (!sn_.disabled[ist][r])
674 rate_[i][r] = num_traits<T>::to_double(sn_.rates(ist, r));
675 } else {
676 for (std::size_t r = 0; r < K_; ++r)
678 }
679 }
680
681 lld_.assign(M_, std::vector<double>());
682 wnorm_.assign(M_, std::vector<double>(K_, 0.0));
683 for (std::size_t i = 0; i < M_; ++i) {
684 for (const T& x : sn_.stations[i].lldscaling)
685 lld_[i].push_back(num_traits<T>::to_double(x));
686 if (!detail::sched_is_weighted(sched_[i])) continue;
687 double tot = 0.0;
688 for (std::size_t r = 0; r < K_ && r < sn_.stations[i].schedparam.size(); ++r)
689 tot += num_traits<T>::to_double(sn_.stations[i].schedparam[r]);
690 if (!(tot > 0.0))
691 throw InputError("solver_ssa_nrm: station '" + sn_.stations[i].name + "' has " +
692 std::string(lang::sched_to_text(sched_[i])) +
693 " scheduling with non-positive total weight");
694 if (sn_.stations[i].nservers > 1.0)
695 throw UnsupportedError(ErrorKind{"MultiServer"},
696 "solver_ssa_nrm: multi-server " + std::string(lang::sched_to_text(sched_[i])) +
697 " at station '" + sn_.stations[i].name +
698 "' is not supported; the reference's State.afterEventStation rejects it too");
699 for (std::size_t r = 0; r < K_ && r < sn_.stations[i].schedparam.size(); ++r)
700 wnorm_[i][r] = num_traits<T>::to_double(sn_.stations[i].schedparam[r]) / tot;
701 }
702
703 classprio_.assign(K_, 0.0);
704 for (std::size_t r = 0; r < K_; ++r) classprio_[r] = sn_.classes[r].prio;
705}
706
707/**
708 * `fcrPrecompute`: the finite capacity regions, field for field.
709 *
710 * A region constrains an aggregate of the per-class populations of its member
711 * stations, which is a linear function of the NRM state vector, so admission is
712 * a 0/1 gate on the drawn destination. DROP loses the refused job at the gate,
713 * as a balked arrival is lost; WAITQ parks it in the refusing region's FIFO,
714 * which is state the reaction network does not carry and therefore rides beside
715 * the buffers (`fcr_buf` in `run`).
716 */
717template <class T>
718void NrmEngine<T>::build_regions() {
719 const double inf = std::numeric_limits<double>::infinity();
720 fcr_.clear();
721 fcr_any_waitq_ = false;
722 for (const typename qn::NetworkStruct<T>::Region& rg : sn_.regions) {
723 FcrRegion f;
724 f.member_node.assign(I_, 0);
725 f.ccap.assign(K_, inf);
726 f.sz.assign(K_, 1.0);
727 f.waitq.assign(K_, 0);
728 for (std::size_t i = 0; i < M_ && i < rg.members.size(); ++i) {
729 if (!rg.members[i]) continue;
730 f.member_node[sn_.station_to_node[i] - 1] = 1;
731 for (std::size_t r = 0; r < K_; ++r)
732 if (rg.cap[i][r] != -1.0) f.ccap[r] = std::min(f.ccap[r], rg.cap[i][r]);
733 if (rg.cap[i][K_] != -1.0) f.gcap = std::min(f.gcap, rg.cap[i][K_]);
734 if (i < rg.maxmem.size() && rg.maxmem[i] != -1.0)
735 f.memcap = std::min(f.memcap, rg.maxmem[i]);
736 }
737 for (std::size_t r = 0; r < K_ && r < rg.size.size(); ++r)
738 f.sz[r] = num_traits<T>::to_double(rg.size[r]);
739 for (std::size_t r = 0; r < K_ && r < rg.rule.size(); ++r) {
740 const qn::DropStrategy d = rg.rule[r];
741 if (d == qn::DropStrategy::BAS || d == qn::DropStrategy::BBS ||
742 d == qn::DropStrategy::RSRD)
743 throw UnsupportedError(ErrorKind{"Region"},
744 "solver_ssa_nrm: finite capacity region '" + rg.name + "' declares a "
745 "blocking rule for class '" + sn_.classes[r].name + "'; only DROP and WAITQ "
746 "are region rules in any codebase's NRM");
747 f.waitq[r] = d != qn::DropStrategy::DROP;
748 if (f.waitq[r]) fcr_any_waitq_ = true;
749 }
750 if (rg.lincon_A.rows() > 0) {
751 f.A = Matrix<double>(rg.lincon_A.rows(), rg.lincon_A.cols(), 0.0);
752 for (std::size_t a = 0; a < rg.lincon_A.rows(); ++a)
753 for (std::size_t c = 0; c < rg.lincon_A.cols(); ++c)
754 f.A(a, c) = num_traits<T>::to_double(rg.lincon_A(a, c));
755 for (const T& v : rg.lincon_b) f.b.push_back(num_traits<T>::to_double(v));
756 }
757 fcr_.push_back(f);
758 }
759}
760
761/**
762 * `fcrRefusingRegion`: the 1-based index of the FIRST region that refuses a
763 * class-`dst_class` job entering `dst_node` having just left `src_node` as
764 * `src_class`, or 0 when every region admits it.
765 *
766 * Only a region holding the destination can refuse; a source in the same region
767 * frees its slot first. `src_node == npos` is a WAITQ release, whose job already
768 * left its source when it was parked, so no slot is freed.
769 */
770template <class T>
771std::size_t NrmEngine<T>::fcr_refusing_region(const std::vector<double>& X, std::size_t src_node,
772 std::size_t src_class, std::size_t dst_node,
773 std::size_t dst_class) const {
774 for (std::size_t f = 0; f < fcr_.size(); ++f) {
775 const FcrRegion& g = fcr_[f];
776 if (!g.member_node[dst_node]) continue;
777 std::vector<double> x(K_, 0.0);
778 for (std::size_t jnd = 0; jnd < I_; ++jnd)
779 if (g.member_node[jnd])
780 for (std::size_t r = 0; r < K_; ++r) x[r] += class_pop(X, jnd, r);
781 if (src_node != npos && g.member_node[src_node]) x[src_class] -= 1.0;
782 x[dst_class] += 1.0;
783 double tot = 0.0, mem = 0.0;
784 bool bad = false;
785 for (std::size_t r = 0; r < K_; ++r) {
786 tot += x[r];
787 mem += x[r] * g.sz[r];
788 if (x[r] > g.ccap[r]) bad = true;
789 }
790 if (tot > g.gcap || mem > g.memcap) bad = true;
791 for (std::size_t a = 0; !bad && a < g.A.rows() && a < g.b.size(); ++a) {
792 double lhs = 0.0;
793 for (std::size_t c = 0; c < K_ && c < g.A.cols(); ++c) lhs += g.A(a, c) * x[c];
794 if (lhs > g.b[a]) bad = true;
795 }
796 if (bad) return f + 1;
797 }
798 return 0;
799}
800
801template <class T>
802const typename qn::NetworkStruct<T>::PasParam& NrmEngine<T>::pas_of(std::size_t ist) const {
803 const auto it = sn_.pasparam.find(ist + 1);
804 if (it == sn_.pasparam.end() || !it->second.svc_rate_fun)
805 throw InputError("solver_ssa_nrm: PAS/OI station '" + sn_.stations[ist].name +
806 "' has no service rate function mu(c); set one with set_pas");
807 return it->second;
808}
809
810template <class T>
811std::vector<double> NrmEngine<T>::pas_increments(std::size_t ist,
812 const std::vector<std::size_t>& c) const {
813 const auto& mu = pas_of(ist).svc_rate_fun;
814 std::vector<double> inc(c.size(), 0.0);
815 std::vector<std::size_t> prefix;
816 double prev = 0.0;
817 for (std::size_t p = 0; p < c.size(); ++p) {
818 prefix.push_back(c[p] + 1); // mu(c) reads 1-based class indices
819 const double cur = num_traits<T>::to_double(mu(prefix));
820 inc[p] = cur - prev;
821 prev = cur;
822 }
823 return inc;
824}
825
826template <class T>
827double NrmEngine<T>::pas_rate(std::size_t ist, const std::vector<std::size_t>& c,
828 std::size_t r) const {
829 if (c.empty()) return 0.0;
830 const std::vector<double> inc = pas_increments(ist, c);
831 std::vector<std::size_t> c1(c.size());
832 for (std::size_t p = 0; p < c.size(); ++p) c1[p] = c[p] + 1;
833 const auto& G = pas_of(ist).swap_graph;
834 double rt = 0.0;
835 for (std::size_t p = 0; p < c.size(); ++p) {
836 if (inc[p] <= 0.0) continue; // position p receives no service
837 if (qn::pass_and_swap<T>(c1, p, G).second == r + 1) rt += inc[p];
838 }
839 return rt;
840}
841
842template <class T>
843double NrmEngine<T>::pas_in_service(std::size_t ist, const std::vector<std::size_t>& c,
844 std::size_t r) const {
845 if (c.empty()) return 0.0;
846 const std::vector<double> inc = pas_increments(ist, c);
847 double n = 0.0;
848 for (std::size_t p = 0; p < c.size(); ++p)
849 if (inc[p] > 0.0 && c[p] == r) n += 1.0;
850 return n;
851}
852
853template <class T>
854void NrmEngine<T>::pas_tag_started(std::size_t ind, const std::vector<std::size_t>& cold,
855 const std::vector<std::size_t>& cnew) {
856 const std::size_t ist = to_station_[ind];
857 const std::vector<double> inc_new = pas_increments(ist, cnew);
858 const std::vector<double> inc_old = pas_increments(ist, cold);
859 for (std::size_t p = 0; p < inc_new.size(); ++p) {
860 if (inc_new[p] <= 0.0) continue;
861 if (p < inc_old.size() && inc_old[p] > 0.0) continue; // already served
862 tag(ind, cnew[p], false);
863 }
864}
865
866/**
867 * The completing position is drawn among those whose pass-and-swap ejects class
868 * r, weighted by that position's own Delta_mu -- the split whose total is the
869 * propensity that just fired.
870 */
871template <class T>
872void NrmEngine<T>::pas_depart(std::size_t ind, std::vector<std::size_t>& c, std::size_t r) {
873 const std::size_t ist = to_station_[ind];
874 const std::vector<double> inc = pas_increments(ist, c);
875 std::vector<std::size_t> c1(c.size());
876 for (std::size_t p = 0; p < c.size(); ++p) c1[p] = c[p] + 1;
877 const auto& G = pas_of(ist).swap_graph;
878 std::vector<std::size_t> pos;
879 std::vector<double> w;
880 double tot = 0.0;
881 for (std::size_t p = 0; p < c.size(); ++p) {
882 if (inc[p] <= 0.0) continue;
883 if (qn::pass_and_swap<T>(c1, p, G).second != r + 1) continue;
884 pos.push_back(p);
885 w.push_back(inc[p]);
886 tot += inc[p];
887 }
888 if (pos.empty()) return; // this class cannot depart from the current list
889 const double u = rng_.uniform() * tot;
890 double acc = 0.0;
891 std::size_t pick = pos.back();
892 for (std::size_t x = 0; x < pos.size(); ++x) {
893 acc += w[x];
894 if (u < acc) {
895 pick = pos[x];
896 break;
897 }
898 }
899 const std::vector<std::size_t> nc = qn::pass_and_swap<T>(c1, pick, G).first;
900 c.assign(nc.size(), 0);
901 for (std::size_t p = 0; p < nc.size(); ++p) c[p] = nc[p] - 1;
902}
903
904/**
905 * `pollLandCtrl`: SERVING q with the visit budget, SWITCHING into q with the
906 * entry phase drawn from the switchover PH, or PARKED. A landing in SERVING is
907 * the moment a class-q job takes the server, so it raises that START here: the
908 * reference raises none at a polling station at all, which leaves StartN zero
909 * there although jobs plainly start service.
910 */
911template <class T>
912typename NrmEngine<T>::PollCtrl NrmEngine<T>::poll_land(std::size_t ind, std::size_t q, int mode,
913 long budget, bool raise) {
914 PollCtrl c;
915 c.pos = q;
916 c.mode = mode;
917 if (mode == 1) {
918 c.ctr = budget;
919 if (raise) tag(ind, q - 1, false);
920 } else if (mode == 2) {
921 const std::vector<T>& pie = poll_info_[ind].sw_pie[q - 1];
922 std::vector<double> w(pie.size());
923 for (std::size_t k = 0; k < pie.size(); ++k) w[k] = num_traits<T>::to_double(pie[k]);
924 c.swk = rng_.draw(w);
925 }
926 return c;
927}
928
929/**
930 * What a region refusal does to the firing, by the refusing region's rule for
931 * the destination class.
932 *
933 * WAITQ parks the job in that region's FIFO: it leaves its source and joins no
934 * station until `fcr_release_cascade` admits it. DROP LOSES an open job, as a
935 * balked arrival is lost. A CLOSED job refused under DROP is BLOCKED instead --
936 * the firing is cancelled and the job keeps its place -- which is this
937 * codebase's reading of the rule (SolverCTMC censors the transition and the
938 * serial engine does not take it, so the population is conserved). The
939 * reference's NRM, CTMC and serial engines instead all lose the closed job, so
940 * the population decays until the region can never refuse again: 1 job of 3 on
941 * the capped three-station cycle. That cross-language divergence belongs to the
942 * rule, not to this engine; see `_kb/07-cross-language-parity.md`.
943 */
944template <class T>
945void NrmEngine<T>::fcr_outcome(const std::vector<double>& X, const Rx& x, std::size_t dst_node,
946 std::size_t dst_class,
947 std::vector<std::vector<std::size_t>>& fcr_buf, bool& lost,
948 bool& blocked) const {
949 const std::size_t fref = fcr_refusing_region(X, x.node, x.cls, dst_node, dst_class);
950 if (fref == 0) return;
951 if (fcr_[fref - 1].waitq[dst_class]) {
952 lost = true;
953 fcr_buf[fref - 1].push_back(dst_node * K_ + dst_class);
954 } else if (std::isinf(sn_.classes[dst_class].population)) {
955 lost = true;
956 } else {
957 blocked = true;
958 }
959}
960
961/**
962 * `fcrReleaseCascade`: strict-FIFO head-of-line release of the parked WAITQ
963 * jobs. Each region's head is admitted while its constraints permit, joining the
964 * destination as a routed arrival does (entry phase drawn at release, buffer
965 * join, START tag); the loop repeats until a full pass frees nothing, so a
966 * release that relieves another region cascades. Returns the jobs released.
967 */
968template <class T>
969std::size_t NrmEngine<T>::fcr_release_cascade(std::vector<double>& X,
970 std::vector<std::vector<std::size_t>>& bufs,
971 std::vector<std::vector<std::size_t>>& fcr_buf,
972 std::vector<Matrix<double>>& svc,
973 bool& svc_changed) {
974 std::size_t released = 0;
975 bool progress = true;
976 while (progress) {
977 progress = false;
978 for (std::size_t f = 0; f < fcr_buf.size(); ++f) {
979 if (fcr_buf[f].empty()) continue;
980 const std::size_t tok = fcr_buf[f].front();
981 const std::size_t jnd = tok / K_, r = tok % K_;
982 if (fcr_refusing_region(X, npos, r, jnd, r) != 0) continue; // head-of-line
983 // A buffered-PH destination counts the job in its class total slot;
984 // whether and in which phase it enters service is decided against the
985 // server occupancy by `apply_arrival_buffer`, as for a routed arrival.
986 const std::size_t ke = buf_ph_node_[jnd] ? 0 : draw_entry_phase(jnd, r);
987 X[phoff_[jnd][r] + ke] += 1.0;
988 apply_arrival_buffer(jnd, r, X, bufs, svc, svc_changed);
989 fcr_buf[f].erase(fcr_buf[f].begin());
990 ++released;
991 progress = true;
992 }
993 }
994 return released;
995}
996
997/**
998 * The stoichiometry and the reaction list, `solver_ssa_nrm.m` lines 190-355.
999 *
1000 * A departure is the ABSORPTION of the phase-type service process, so it fires
1001 * at the D1 row sum of the phase it leaves and the job re-enters its
1002 * destination in an entry phase drawn from pie -- which is why the destination
1003 * weight is the product of the routing probability and the entry probability. A
1004 * phase change is an off-diagonal of D0 and never leaves the node.
1005 */
1006template <class T>
1007void NrmEngine<T>::build_reactions() {
1008 rx_.clear();
1009 // A class READS at a cache iff it has a popularity row and either a hit class
1010 // or is one of the cache's retrieval classes, which read to COMPLETE a miss.
1011 is_cache_node_.assign(I_, 0);
1012 cache_read_.assign(I_, std::vector<char>(K_, 0));
1013 for (std::size_t ind = 0; ind < I_; ++ind) {
1014 if (sn_.nodes[ind].nodetype != qn::NodeType::Cache) continue;
1015 const auto ci = sn_.nodeparam.find(ind + 1);
1016 if (ci == sn_.nodeparam.end())
1017 throw InputError("solver_ssa_nrm: Cache node '" + sn_.nodes[ind].name +
1018 "' has no cache parameters");
1019 const qn::CacheParam<T>& cp = ci->second;
1020 is_cache_node_[ind] = 1;
1021 std::vector<std::size_t> rcl, rci, rco;
1022 if (cp.retrieval_capacity > 0) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
1023 for (std::size_t r = 0; r < K_; ++r) {
1024 if (r >= cp.pread.size() || cp.pread[r].empty()) continue;
1025 const bool is_retr = std::find(rcl.begin(), rcl.end(), r + 1) != rcl.end();
1026 const std::size_t hc = r < cp.hitclass.size() ? cp.hitclass[r] : 0;
1027 const std::size_t mc = r < cp.missclass.size() ? cp.missclass[r] : 0;
1028 if (hc == 0 && !is_retr) continue;
1029 // A read class that is its own hit or miss class would read again on
1030 // the spot: the reference NRM livelocks there, so it is refused.
1031 if (hc == r + 1 || mc == r + 1)
1032 throw UnsupportedError(ErrorKind{"Cache"},
1033 "solver_ssa_nrm: class '" + sn_.classes[r].name + "' at Cache '" +
1034 sn_.nodes[ind].name + "' is its own hit or miss class, so the NRM's cache "
1035 "read would fire again at once; ask for method='serial'");
1036 cache_read_[ind][r] = 1;
1037 }
1038 }
1039 // Departures, one per (node, class, phase).
1040 for (std::size_t ind = 0; ind < I_; ++ind) {
1041 for (std::size_t r = 0; r < K_; ++r) {
1042 for (std::size_t kk = 0; kk < nph_[ind][r]; ++kk) {
1043 Rx x;
1044 x.node = ind;
1045 x.cls = r;
1046 x.dep_phase = kk;
1047 x.is_buf_svc = buf_ph_class_[ind][r];
1048 // At a buffered-PH source the population lives entirely in the
1049 // first phase slot; which phase completed is carried in
1050 // dep_phase and consumed from svcph at firing.
1051 x.from = buf_ph_class_[ind][r] ? phoff_[ind][r] : phoff_[ind][r] + kk;
1052 x.rate = rate_[ind][r];
1053 x.is_cache = is_cache_node_[ind] && cache_read_[ind][r];
1054 if (is_station_[ind] && nph_[ind][r] > 1) {
1055 const std::size_t ist = to_station_[ind];
1056 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
1057 double s = 0.0;
1058 for (std::size_t c = 0; c < m.D1.cols(); ++c)
1059 s += num_traits<T>::to_double(m.D1(kk, c));
1060 x.rate = s;
1061 }
1062 rx_.push_back(x);
1063 }
1064 }
1065 }
1066 const std::size_t ndep = rx_.size();
1067
1068 // Phase transitions, one per (node, class, ka -> kb) with D0(ka,kb) > 0.
1069 for (std::size_t ind = 0; ind < I_; ++ind) {
1070 if (!is_station_[ind]) continue;
1071 const std::size_t ist = to_station_[ind];
1072 for (std::size_t r = 0; r < K_; ++r) {
1073 if (nph_[ind][r] <= 1 || sn_.disabled[ist][r]) continue;
1074 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
1075 for (std::size_t ka = 0; ka < nph_[ind][r]; ++ka)
1076 for (std::size_t kb = 0; kb < nph_[ind][r]; ++kb) {
1077 if (ka == kb) continue;
1078 const double d = num_traits<T>::to_double(m.D0(ka, kb));
1079 if (!(d > 0.0)) continue;
1080 Rx x;
1081 x.node = ind;
1082 x.cls = r;
1083 x.is_phase = true;
1084 x.phase_from = ka;
1085 x.phase_to = kb;
1086 x.rate = d;
1087 // A buffered-PH phase change moves a job inside svcph and
1088 // leaves the population alone, so its stoichiometry column
1089 // is all zeros and its propensity reads the first slot.
1090 x.from = buf_ph_class_[ind][r] ? phoff_[ind][r] : phoff_[ind][r] + ka;
1091 rx_.push_back(x);
1092 }
1093 }
1094 }
1095
1096 // Polling switchover reactions, `solver_ssa_nrm.m` lines 419-482. A walk
1097 // between buffers is a timed event that moves no job, so it is one reaction
1098 // per polling node with a timed switchover: an all-zero stoichiometry column
1099 // whose propensity reads the controller. Only EXPONENTIAL service is taken at
1100 // a polling station: the controller tracks no in-service phase, and spreading
1101 // the class over phases would serve several fictitious phase-jobs at once.
1102 is_poll_.assign(I_, 0);
1103 poll_info_.assign(I_, qn::PollingInfo<T>());
1104 for (std::size_t ind = 0; ind < I_; ++ind) {
1105 if (!is_station_[ind] || sched_[to_station_[ind]] != SchedStrategy::POLLING) continue;
1106 is_poll_[ind] = 1;
1107 poll_info_[ind] = qn::polling_info(sn_, ind + 1);
1108 for (std::size_t r = 0; r < K_; ++r)
1109 if (nph_[ind][r] > 1)
1110 throw UnsupportedError(ErrorKind{"SchedStrategy_POLLING"},
1111 "solver_ssa_nrm: polling station '" + sn_.stations[to_station_[ind]].name +
1112 "' serves class '" + sn_.classes[r].name + "' with phase-type service. The "
1113 "NRM polling controller supports exponential service only, as the reference's "
1114 "does; ask for method='serial'");
1115 bool anysw = false;
1116 for (bool h : poll_info_[ind].has_sw) anysw = anysw || h;
1117 if (!anysw) continue; // every switchover immediate: the server never dwells
1118 Rx x;
1119 x.node = ind;
1120 x.cls = 0;
1121 x.from = phoff_[ind][0];
1122 x.is_sw = true;
1123 rx_.push_back(x);
1124 }
1125
1126 // The stoichiometry matrix, states x reactions.
1127 S_ = Matrix<double>(NS_, rx_.size(), 0.0);
1128 for (std::size_t k = 0; k < rx_.size(); ++k) {
1129 Rx& x = rx_[k];
1130 if (x.is_sw) continue; // a switchover moves no job
1131 if (x.is_cache) {
1132 // The read consumes the job; where it reappears is drawn at firing.
1133 S_(x.from, k) -= 1.0;
1134 continue;
1135 }
1136 if (x.is_phase) {
1137 if (!buf_ph_class_[x.node][x.cls]) {
1138 S_(phoff_[x.node][x.cls] + x.phase_from, k) -= 1.0;
1139 S_(phoff_[x.node][x.cls] + x.phase_to, k) += 1.0;
1140 }
1141 continue;
1142 }
1143 S_(x.from, k) -= 1.0;
1144 for (std::size_t jnd = 0; jnd < I_; ++jnd)
1145 for (std::size_t s = 0; s < K_; ++s) {
1146 const double p =
1147 num_traits<T>::to_double(sn_.rtnodes(x.node * K_ + x.cls, jnd * K_ + s));
1148 if (!(p > 0.0)) continue;
1149 if (buf_ph_class_[jnd][s]) {
1150 // The arrival lands in the total-population slot; whether it
1151 // enters service, and in which phase, is decided at firing
1152 // from the server occupancy and pie.
1153 S_(phoff_[jnd][s], k) += p;
1154 } else {
1155 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke) {
1156 const double pe = pie_[jnd][s][ke];
1157 if (!(pe > 0.0)) continue;
1158 S_(phoff_[jnd][s] + ke, k) += p * pe;
1159 }
1160 }
1161 }
1162 }
1163
1164 // `P = S; P(P<0) = P(P<0)+1` selects the destination draw: nnz(P) > 1 means
1165 // the firing must pick among several destination slots, and the weights are
1166 // read off P so a class that routes back into its own slot competes with the
1167 // others on equal footing.
1168 for (std::size_t k = 0; k < rx_.size(); ++k) {
1169 Rx& x = rx_[k];
1170 std::vector<double> Pcol(NS_, 0.0);
1171 for (std::size_t i = 0; i < NS_; ++i) {
1172 const double v = S_(i, k);
1173 Pcol[i] = v < 0.0 ? v + 1.0 : v;
1174 if (v < 0.0) x.from_slots.push_back(i);
1175 if (v > 0.0 && x.det_dest == npos) x.det_dest = i;
1176 }
1177 for (std::size_t i = 0; i < NS_; ++i)
1178 if (Pcol[i] != 0.0) ++x.nnzP;
1179 if (x.nnzP > 1) {
1180 double acc = 0.0;
1181 for (std::size_t i = 0; i < NS_; ++i)
1182 if (Pcol[i] != 0.0) {
1183 x.to_slots.push_back(i);
1184 acc += Pcol[i];
1185 x.cdf.push_back(acc);
1186 }
1187 }
1188 if (!x.is_phase && !x.is_sw) build_state_dependent_dest(x);
1189 }
1190
1191 // Departure reactions of each (station, class): the reference rescans all
1192 // reactions inside the metric loop, which is the same set every step.
1193 dep_rx_.assign(M_, std::vector<std::vector<std::size_t>>(K_));
1194 for (std::size_t k = 0; k < ndep; ++k)
1195 if (is_station_[rx_[k].node]) dep_rx_[to_station_[rx_[k].node]][rx_[k].cls].push_back(k);
1196
1197 // Stations and classes where a refusal BLOCKS; see `capacity_block`.
1198 // A cap that CANNOT BIND is not a blocking site. Every closed model carries
1199 // cap[ist] = N by default, and a station that can hold the whole population
1200 // never refuses one: the arriving job is itself one of the N, so the
1201 // pre-arrival count is at most N-1. Excluding those keeps `blk_on_` false --
1202 // and the per-firing test unpaid -- on ordinary models. An open class present
1203 // anywhere makes a finite station cap binding again, its jobs not being in N.
1204 double closed_total = 0.0;
1205 bool any_open = false;
1206 for (std::size_t r = 0; r < K_; ++r) {
1207 const double nj = sn_.classes[r].population;
1208 if (std::isinf(nj))
1209 any_open = true;
1210 else
1211 closed_total += nj;
1212 }
1213 blk_can_.assign(M_, std::vector<char>(K_, 0));
1214 blk_on_ = false;
1215 for (std::size_t ist = 0; ist < M_; ++ist)
1216 for (std::size_t r = 0; r < K_; ++r) {
1217 const double nj = sn_.classes[r].population;
1218 if (std::isinf(nj)) continue; // open: refusal LOSES
1219 const double ccap = sn_.classcap[ist][r];
1220 const bool binds_st =
1221 !std::isinf(sn_.cap[ist]) && (any_open || sn_.cap[ist] < closed_total);
1222 const bool binds_cl = ccap > 0.0 && !std::isinf(ccap) && ccap < nj;
1223 if (binds_st || binds_cl) {
1224 blk_can_[ist][r] = 1;
1225 blk_on_ = true;
1226 }
1227 }
1228}
1229
1230/**
1231 * The candidate destinations of a reaction whose node routes BY STRATEGY.
1232 *
1233 * The candidates are the DECLARED out-arcs, `P(r,s)(i,j) > 0`, enumerated in
1234 * the order `refresh_routing` uses, and not the probability split it wrote into
1235 * `rtnodes` from them: the split is what a matrix solver needs and it is not
1236 * what the dispatcher does. Nothing is filled for PROB, RAND or DISABLED, which
1237 * are draws or refusals and stay on the `cdf` path.
1238 */
1239template <class T>
1240void NrmEngine<T>::build_state_dependent_dest(Rx& x) {
1241 const qn::NodeDef& nd = sn_.nodes[x.node];
1242 const lang::RoutingStrategy rs = nd.routing.size() > x.cls
1243 ? nd.routing[x.cls]
1247 return;
1248 for (std::size_t s = 1; s <= K_; ++s)
1249 for (std::size_t j = 1; j <= I_; ++j) {
1250 if (!(num_traits<T>::to_double(sn_.get_route(x.cls + 1, s, x.node + 1, j)) > 0.0))
1251 continue;
1252 x.sd_node.push_back(j - 1);
1253 x.sd_class.push_back(s - 1);
1254 }
1255 if (x.sd_node.size() < 2) { // one arc is not a dispatch decision
1256 x.sd_node.clear();
1257 x.sd_class.clear();
1258 return;
1259 }
1260 x.sd_strategy = rs;
1261 // The entry-phase law of each candidate, so the phase is drawn AFTER the
1262 // destination is chosen rather than folded into one joint distribution.
1263 x.sd_slots.resize(x.sd_node.size());
1264 x.sd_pie.resize(x.sd_node.size());
1265 for (std::size_t d = 0; d < x.sd_node.size(); ++d) {
1266 const std::size_t jnd = x.sd_node[d], s = x.sd_class[d];
1267 if (buf_ph_class_[jnd][s]) {
1268 x.sd_slots[d].push_back(phoff_[jnd][s]);
1269 x.sd_pie[d].push_back(1.0);
1270 continue;
1271 }
1272 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke) {
1273 const double pe = pie_[jnd][s][ke];
1274 if (pe > 0.0) {
1275 x.sd_slots[d].push_back(phoff_[jnd][s] + ke);
1276 x.sd_pie[d].push_back(pe);
1277 }
1278 }
1279 }
1281 const std::map<std::size_t, double>* w =
1282 nd.routing_weights.size() > x.cls ? &nd.routing_weights[x.cls] : NULL;
1283 double total = 0.0;
1284 x.sd_weight.assign(x.sd_node.size(), 0.0);
1285 if (w != NULL)
1286 for (std::size_t d = 0; d < x.sd_node.size(); ++d) {
1287 const std::map<std::size_t, double>::const_iterator it =
1288 w->find(x.sd_node[d] + 1);
1289 x.sd_weight[d] = (it == w->end()) ? 0.0 : it->second;
1290 total += x.sd_weight[d];
1291 }
1292 if (!(total > 0.0)) {
1293 // No weights declared is plain round robin, which is what the
1294 // reference's WRROBIN degenerates to with a uniform weight vector.
1295 x.sd_weight.clear();
1296 x.sd_strategy = lang::RoutingStrategy::RROBIN;
1297 } else {
1298 for (std::size_t d = 0; d < x.sd_weight.size(); ++d) x.sd_weight[d] /= total;
1299 }
1300 }
1301}
1302
1303/**
1304 * The destination slot of a state-dependent firing, from the LIVE population.
1305 *
1306 * Each rule is the reference's (`solver_ssa_nrm.m` lines 1162-1200, 1390-1430):
1307 *
1308 * JSQ the candidate holding the smallest TOTAL population, each
1309 * evaluated on its own queue and never on the routing node's, with
1310 * TIES SPLIT UNIFORMLY. The tie split is not a detail: on a fleet of
1311 * identical servers the system starts with every queue equal, so a
1312 * first-candidate tie-break sends a run of jobs to candidate one and
1313 * reports a skew the policy does not have.
1314 * SQ(d) the power-of-d-choices rule, NOT "shortest queue": sample d
1315 * candidates uniformly WITH replacement and join the smallest of
1316 * those, ties by first occurrence in the sampled tuple. `d` is the
1317 * node's `routing_param`, 2 when unset, as `sub_sq` reads `nodeparam.d`.
1318 * RROBIN a pointer advanced once per firing, kept per (SOURCE NODE, CLASS)
1319 * and NOT per reaction: a phase-type service splits one dispatcher
1320 * across several reactions, and a pointer each would let the phases
1321 * cycle independently and undo the determinism.
1322 * WRROBIN the same pointer, spent in proportion to the declared weights.
1323 *
1324 * The destination NODE is what the rule fixes; the entry PHASE is drawn after,
1325 * from `pie` among that node's candidates, since taking the first match would
1326 * bias the service time.
1327 */
1328template <class T>
1329std::size_t NrmEngine<T>::resolve_state_dependent_dest(std::size_t kfire,
1330 const std::vector<double>& X) {
1331 Rx& x = rx_[kfire];
1332 const std::size_t nd = x.sd_node.size();
1333 const std::size_t cursor_key = x.node * K_ + x.cls;
1334 std::size_t pick = 0;
1335 if (x.sd_strategy == lang::RoutingStrategy::JSQ) {
1336 std::vector<double> load(nd, 0.0);
1337 double best = 0.0;
1338 for (std::size_t d = 0; d < nd; ++d) {
1339 const std::vector<double> cc = class_counts(X, x.sd_node[d]);
1340 for (double v : cc) load[d] += v;
1341 if (d == 0 || load[d] < best) best = load[d];
1342 }
1343 std::vector<std::size_t> amins;
1344 for (std::size_t d = 0; d < nd; ++d)
1345 if (load[d] == best) amins.push_back(d);
1346 pick = amins[std::min(amins.size() - 1,
1347 std::size_t(rng_.uniform() * double(amins.size())))];
1348 } else if (x.sd_strategy == lang::RoutingStrategy::SQ) {
1349 const std::vector<int>& rp = sn_.nodes[x.node].routing_param;
1350 const int dp = rp.size() > x.cls ? rp[x.cls] : 0;
1351 const std::size_t dsample = std::max<std::size_t>(1, std::min<std::size_t>(dp > 0 ? dp : 2, nd));
1352 double best = 0.0;
1353 for (std::size_t t = 0; t < dsample; ++t) {
1354 const std::size_t cand =
1355 std::min(nd - 1, std::size_t(rng_.uniform() * double(nd)));
1356 const std::vector<double> cc = class_counts(X, x.sd_node[cand]);
1357 double load = 0.0;
1358 for (double v : cc) load += v;
1359 if (t == 0 || load < best) {
1360 best = load;
1361 pick = cand;
1362 }
1363 }
1364 } else if (x.sd_strategy == lang::RoutingStrategy::WRROBIN) {
1365 // The pointer is spent in proportion to the declared weights: candidate
1366 // d owns the slice [sum w_{<d}, sum w_{<=d}) of one full turn.
1367 const std::size_t c = rr_cursor_[cursor_key];
1368 const double u = double(c % nd) / double(nd);
1369 double acc = 0.0;
1370 pick = nd - 1;
1371 for (std::size_t d = 0; d < nd; ++d) {
1372 acc += x.sd_weight[d];
1373 if (u < acc - 1e-12) {
1374 pick = d;
1375 break;
1376 }
1377 }
1378 rr_cursor_[cursor_key] = c + 1;
1379 } else {
1380 pick = rr_cursor_[cursor_key] % nd;
1381 rr_cursor_[cursor_key] = rr_cursor_[cursor_key] + 1;
1382 }
1383 // the entry phase, drawn within the chosen destination
1384 const std::vector<std::size_t>& slots = x.sd_slots[pick];
1385 if (slots.size() == 1) return slots[0];
1386 const double u = rng_.uniform();
1387 double acc = 0.0;
1388 for (std::size_t k = 0; k + 1 < slots.size(); ++k) {
1389 acc += x.sd_pie[pick][k];
1390 if (u < acc) return slots[k];
1391 }
1392 return slots.back();
1393}
1394
1395/**
1396 * The dependency sets, `solver_ssa_nrm.m` lines 1003-1050.
1397 *
1398 * A reaction's firing changes the slots its stoichiometry touches; every rate
1399 * law reads its node's WHOLE class-count vector (and, for the per-phase share,
1400 * the sibling phases of its own class), so the affected set is every slot of
1401 * every touched node, and the reactions to refresh are those that consume from
1402 * one of those slots.
1403 */
1404template <class T>
1405void NrmEngine<T>::build_dependencies() {
1406 const std::size_t nrx = rx_.size();
1407 D_.assign(nrx, std::vector<std::size_t>());
1408 for (std::size_t k = 0; k < nrx; ++k) {
1409 std::vector<bool> touched_node(I_, false);
1410 for (std::size_t i = 0; i < NS_; ++i)
1411 if (S_(i, k) != 0.0) touched_node[slot_node_[i]] = true;
1412 std::vector<bool> hit(nrx, false);
1413 for (std::size_t ind = 0; ind < I_; ++ind) {
1414 if (!touched_node[ind]) continue;
1415 for (std::size_t r = 0; r < K_; ++r)
1416 for (std::size_t p = 0; p < nph_[ind][r]; ++p) {
1417 const std::size_t slot = phoff_[ind][r] + p;
1418 for (std::size_t j = 0; j < nrx; ++j)
1419 if (S_(slot, j) < 0.0) hit[j] = true;
1420 }
1421 }
1422 for (std::size_t j = 0; j < nrx; ++j)
1423 if (hit[j]) D_[k].push_back(j);
1424 }
1425}
1426
1427/**
1428 * The initial state.
1429 *
1430 * THIS PORT HAS NO State PACKAGE, so the reference's `sn.state` cannot be read;
1431 * the same position `solver_fluid` takes applies here and for the same reason.
1432 * A closed class starts entirely at its reference station and an open class
1433 * puts the single fictitious token at the Source that the EXT rate law needs
1434 * (the reference substitutes exactly that for the infinite marginal). The chain
1435 * is ergodic, so the steady-state averages this engine reports do not depend on
1436 * the choice; a TRANSIENT would, which is why `getTranAvg` is not offered.
1437 */
1438template <class T>
1439void NrmEngine<T>::build_initial_state() {
1440 nvec0_.assign(NS_, 0.0);
1441 // The cache contents: the declared warm start when there is one (its row
1442 // after the per-class counts), else slot i holding item i, as the reference
1443 // NRM seeds it; with a retrieval system blocks A and B follow, empty.
1444 cache_var0_.assign(I_, std::vector<T>());
1445 for (std::size_t ind = 0; ind < I_; ++ind) {
1446 if (!is_cache_node_[ind]) continue;
1447 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
1448 std::size_t tcc = 0;
1449 for (int c : cp.itemcap)
1450 if (c > 0) tcc += static_cast<std::size_t>(c);
1451 std::vector<std::size_t> rcl, rci, rco;
1452 const bool retr = cp.retrieval_capacity > 0 && !cp.retrieval_classes.empty();
1453 if (retr) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
1454 std::vector<T>& v = cache_var0_[ind];
1455 if (cp.initstate.size() > K_) {
1456 v.assign(cp.initstate.begin() + static_cast<std::ptrdiff_t>(K_), cp.initstate.end());
1457 } else {
1458 for (std::size_t c = 0; c < tcc; ++c)
1459 v.push_back(num_traits<T>::from_int(static_cast<long>(c + 1)));
1460 }
1461 const std::size_t want = tcc + (retr ? cp.nitems + rcl.size() : 0);
1462 v.resize(want, num_traits<T>::from_int(0));
1463 }
1464 buffers0_.assign(I_, std::vector<std::size_t>());
1465 svcph0_.assign(I_, Matrix<double>());
1466 for (std::size_t i = 0; i < I_; ++i)
1467 if (buf_ph_node_[i]) svcph0_[i] = Matrix<double>(K_, maxnph_, 0.0);
1468
1469 std::vector<std::vector<double>> nir(I_, std::vector<double>(K_, 0.0));
1470 for (std::size_t r = 0; r < K_; ++r) {
1471 const double pop = sn_.classes[r].population;
1472 if (std::isinf(pop)) {
1473 if (sn_.sourceIdx == 0)
1474 throw InputError(
1475 "solver_ssa_nrm: the open class '" + sn_.classes[r].name +
1476 "' has no Source; an open model must carry one for the arrival reaction");
1477 nir[sn_.station_to_node[sn_.sourceIdx - 1] - 1][r] = 1.0;
1478 } else if (pop > 0.0) {
1479 const std::size_t rs = sn_.classes[r].refstat;
1480 if (rs < 1 || rs > M_)
1481 throw InputError("solver_ssa_nrm: class '" + sn_.classes[r].name +
1482 "' has no reference station");
1483 nir[sn_.station_to_node[rs - 1] - 1][r] = pop;
1484 }
1485 }
1486
1487 for (std::size_t ind = 0; ind < I_; ++ind) {
1488 for (std::size_t r = 0; r < K_; ++r) {
1489 const double n = nir[ind][r];
1490 if (n <= 0.0) continue;
1491 if (nph_[ind][r] <= 1 || buf_ph_class_[ind][r]) {
1492 nvec0_[phoff_[ind][r]] = n;
1493 } else {
1494 double left = n;
1495 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
1496 const double take = (ke + 1 == nph_[ind][r])
1497 ? left
1498 : std::min(left, std::round(n * pie_[ind][r][ke]));
1499 nvec0_[phoff_[ind][r] + ke] = take;
1500 left -= take;
1501 }
1502 }
1503 }
1504 // A PAS / OI list holds EVERY job, oldest first, in class order.
1505 if (is_station_[ind] && detail::sched_is_list(sched_[to_station_[ind]]))
1506 for (std::size_t r = 0; r < K_; ++r)
1507 for (std::size_t c = 0; c < static_cast<std::size_t>(nir[ind][r]); ++c)
1508 buffers0_[ind].push_back(r);
1509 // The buffer holds exactly the waiting jobs, `numel(buf) == max(0,
1510 // total - mi)`; which classes they are is immaterial to the steady
1511 // state, so they are taken in class order.
1512 if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]])) {
1513 double total = 0.0;
1514 for (std::size_t r = 0; r < K_; ++r) total += nir[ind][r];
1515 double waiting = std::max(0.0, total - mi_[ind]);
1516 for (std::size_t r = 0; r < K_ && waiting > 0.0; ++r) {
1517 double take = std::min(waiting, nir[ind][r]);
1518 for (std::size_t c = 0; c < static_cast<std::size_t>(take); ++c)
1519 buffers0_[ind].push_back(r);
1520 waiting -= take;
1521 }
1522 }
1523 if (!buf_ph_node_[ind]) continue;
1524 for (std::size_t r = 0; r < K_; ++r) {
1525 double waiting_r = 0.0;
1526 for (std::size_t c : buffers0_[ind])
1527 if (c == r) waiting_r += 1.0;
1528 const double insvc = std::max(0.0, nir[ind][r] - waiting_r);
1529 if (nph_[ind][r] <= 1) {
1530 svcph0_[ind](r, 0) = insvc;
1531 } else {
1532 double left = insvc;
1533 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
1534 const double take = (ke + 1 == nph_[ind][r])
1535 ? left
1536 : std::min(left, std::round(insvc * pie_[ind][r][ke]));
1537 svcph0_[ind](r, ke) = take;
1538 left -= take;
1539 }
1540 }
1541 }
1542 }
1543}
1544
1545// ---------------------------------------------------------------------------
1546// The rate laws
1547// ---------------------------------------------------------------------------
1548
1549/**
1550 * The propensity of reaction `j`, `solver_ssa_nrm.m` lines 795-948.
1551 *
1552 * The reference builds one closure per reaction; this is the same switch
1553 * evaluated on demand, which avoids an indirect call per reaction per step and
1554 * cannot drift from the utilization accumulators that reuse the same sharing
1555 * factors.
1556 */
1557template <class T>
1558double NrmEngine<T>::propensity(std::size_t j, const std::vector<double>& X,
1559 const std::vector<std::vector<std::size_t>>& bufs,
1560 const std::vector<Matrix<double>>& svc) const {
1561 const Rx& x = rx_[j];
1562 const std::size_t ind = x.node, r = x.cls;
1563 if (!is_station_[ind])
1564 return x.rate * kir_frac(X, x.from, ind, r) * std::min(1.0, class_pop(X, ind, r));
1565
1566 const std::size_t ist = to_station_[ind];
1567 const double eps = lang::GlobalConstants::Zero;
1568
1569 // `pollSwRate`: the total leaving rate -D0(swk,swk) of the switchover phase
1570 // the walking server occupies; which transition it takes is drawn at firing.
1571 if (x.is_sw) {
1572 const PollCtrl& c = poll_ctrl_[ind];
1573 if (c.mode != 2) return 0.0;
1574 return -num_traits<T>::to_double(poll_info_[ind].sw_d0[c.pos - 1](c.swk, c.swk));
1575 }
1576
1577 // Buffered phase-type service: the rate reads the in-service multiset, not
1578 // the class total, which also counts the jobs still waiting.
1579 if (buf_ph_class_[ind][r]) {
1580 const std::size_t kk = x.is_phase ? x.phase_from : x.dep_phase;
1581 const std::vector<double> n = class_counts(X, ind);
1582 double tot = 0.0;
1583 for (double v : n) tot += v;
1584 return x.rate * svc[ind](r, kk) * lldfac(ist, tot) * cdfac(ist, n, r);
1585 }
1586
1587 const double kf = kir_frac(X, x.from, ind, r);
1588 switch (sched_[ist]) {
1589 case SchedStrategy::EXT:
1590 // A Source fires at a constant arrival rate: its token is
1591 // fictitious, so applying the population share would silence it and
1592 // deadlock every open model.
1593 return x.rate;
1594 case SchedStrategy::INF:
1595 return x.rate * kf * class_pop(X, ind, r) * cdfac(ist, class_counts(X, ind), r);
1596 case SchedStrategy::PS:
1597 case SchedStrategy::LPS: {
1598 if (K_ == 1)
1599 return x.rate * kf * std::min(mi_[ind], class_pop(X, ind, r)) *
1600 lldfac(ist, class_pop(X, ind, r)) * cdfac(ist, class_counts(X, ind), r);
1601 const std::vector<double> n = class_counts(X, ind);
1602 double tot = 0.0;
1603 for (double v : n) tot += v;
1604 return x.rate * kf * (n[r] / (eps + tot)) * std::min(mi_[ind], eps + tot) *
1605 lldfac(ist, tot) * cdfac(ist, n, r);
1606 }
1607 case SchedStrategy::DPS: {
1608 const std::vector<double> n = class_counts(X, ind);
1609 double tot = 0.0;
1610 for (double v : n) tot += v;
1611 return x.rate * kf * dpsshare(wnorm_[ist], n, r) * lldfac(ist, tot) *
1612 cdfac(ist, n, r);
1613 }
1614 case SchedStrategy::GPS: {
1615 const std::vector<double> n = class_counts(X, ind);
1616 double tot = 0.0;
1617 for (double v : n) tot += v;
1618 return x.rate * kf * gpsshare(wnorm_[ist], n, r) * lldfac(ist, tot) *
1619 cdfac(ist, n, r);
1620 }
1621 // The three PRIORITY sharing disciplines. Below the server count each
1622 // is its non-priority twin; above it only the most urgent non-empty
1623 // group shares the servers. Note that PSPRIO reads the lld factor at
1624 // the FULL population and the other two at the priority-restricted one
1625 // -- `prio_pop` carries that asymmetry, which comes from the reference.
1626 case SchedStrategy::PSPRIO: {
1627 const std::vector<double> n = class_counts(X, ind);
1628 double tot = 0.0;
1629 for (double v : n) tot += v;
1630 return x.rate * kf * psprioshare(n, r, mi_[ind]) * lldfac(ist, tot) *
1631 cdfac(ist, n, r);
1632 }
1633 case SchedStrategy::DPSPRIO: {
1634 const std::vector<double> n = class_counts(X, ind);
1635 return x.rate * kf * dpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1636 lldfac(ist, prio_pop(n, r, mi_[ind])) *
1637 cdfac(ist, prio_vec(n, r, mi_[ind]), r);
1638 }
1639 case SchedStrategy::GPSPRIO: {
1640 const std::vector<double> n = class_counts(X, ind);
1641 return x.rate * kf * gpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1642 lldfac(ist, prio_pop(n, r, mi_[ind])) *
1643 cdfac(ist, prio_vec(n, r, mi_[ind]), r);
1644 }
1645 case SchedStrategy::FCFS:
1646 case SchedStrategy::LCFS:
1647 case SchedStrategy::SIRO:
1648 case SchedStrategy::HOL:
1649 case SchedStrategy::SEPT:
1650 case SchedStrategy::LEPT:
1651 case SchedStrategy::LCFSPR: {
1652 // Invariant: numel(buf) == max(0, total - mi), so the jobs in
1653 // service are the class population minus its share of the buffer.
1654 double waiting = 0.0;
1655 for (std::size_t c : bufs[ind])
1656 if (c == r) waiting += 1.0;
1657 const std::vector<double> n = class_counts(X, ind);
1658 double tot = 0.0;
1659 for (double v : n) tot += v;
1660 return x.rate * kf * std::max(0.0, n[r] - waiting) * lldfac(ist, tot) *
1661 cdfac(ist, n, r);
1662 }
1663 case SchedStrategy::POLLING: {
1664 // One server serving one job, of the class its controller attends:
1665 // the class-r departure fires only while the controller SERVES r, at
1666 // the plain rate, never scaled by the class population.
1667 const PollCtrl& c = poll_ctrl_[ind];
1668 if (c.mode != 1 || c.pos != r + 1) return 0.0;
1669 const std::vector<double> n = class_counts(X, ind);
1670 double tot = 0.0;
1671 for (double v : n) tot += v;
1672 return x.rate * lldfac(ist, tot) * cdfac(ist, n, r);
1673 }
1674 case SchedStrategy::PAS:
1675 case SchedStrategy::OI:
1676 // Position p is served at Delta_mu(c1..cp) and pass-and-swap
1677 // decides which class that completion ejects, so the class-r rate
1678 // is the total increment over the positions that eject class r.
1679 return pas_rate(ist, bufs[ind], r);
1680 default:
1681 throw UnsupportedError(ErrorKind{"SchedStrategy"},
1682 "solver_ssa_nrm: the scheduling policy '" +
1683 std::string(lang::sched_to_text(sched_[ist])) + "' at station '" +
1684 sn_.stations[ist].name + "' has no NRM rate law in this port");
1685 }
1686}
1687
1688// ---------------------------------------------------------------------------
1689// Buffer maintenance
1690// ---------------------------------------------------------------------------
1691
1692/** `pickFromBuffer`: the waiting job the discipline promotes, newest-first buffer. */
1693template <class T>
1694std::size_t NrmEngine<T>::pick_from_buffer(const std::vector<std::size_t>& buf, std::size_t ist) {
1695 switch (sched_[ist]) {
1696 case SchedStrategy::FCFS:
1697 return buf.size() - 1; // oldest
1698 case SchedStrategy::LCFS:
1699 case SchedStrategy::LCFSPR:
1700 return 0; // newest, or the most recently preempted
1701 case SchedStrategy::SIRO:
1702 return rng_.index(buf.size());
1703 case SchedStrategy::HOL: {
1704 // Highest priority (lowest value), FCFS within the group, so the
1705 // oldest is the LAST matching position.
1706 double best = std::numeric_limits<double>::infinity();
1707 for (std::size_t c : buf) best = std::min(best, classprio_[c]);
1708 for (std::size_t p = buf.size(); p-- > 0;)
1709 if (classprio_[buf[p]] == best) return p;
1710 return buf.size() - 1;
1711 }
1712 case SchedStrategy::SEPT:
1713 case SchedStrategy::LEPT: {
1714 // schedparam carries the rank of the class's mean service time,
1715 // ascending for SEPT and descending for LEPT, so both promote the
1716 // waiting class of least rank.
1717 double best = std::numeric_limits<double>::infinity();
1718 for (std::size_t c : buf)
1719 best = std::min(best, num_traits<T>::to_double(sn_.stations[ist].schedparam[c]));
1720 for (std::size_t p = buf.size(); p-- > 0;)
1721 if (num_traits<T>::to_double(sn_.stations[ist].schedparam[buf[p]]) == best)
1722 return p;
1723 return buf.size() - 1;
1724 }
1725 default:
1726 throw UnsupportedError(ErrorKind{"SchedStrategy"}, "solver_ssa_nrm: '" +
1727 std::string(lang::sched_to_text(sched_[ist])) +
1728 "' is not a buffered policy this port promotes from");
1729 }
1730}
1731
1732/** `drawEntryPhase`: the phase a job starts service in, drawn from pie. */
1733template <class T>
1734std::size_t NrmEngine<T>::draw_entry_phase(std::size_t ind, std::size_t r) {
1735 if (nph_[ind][r] <= 1) return 0;
1736 return rng_.draw(pie_[ind][r]);
1737}
1738
1739/**
1740 * `capacityLoss`: an OPEN arrival at a full physically-capped station is lost.
1741 *
1742 * A refused CLOSED job must block rather than vanish from the conserved
1743 * population, so it is not dropped here; `State.arrivalIsLost` draws that line
1744 * on the class type and this reproduces it. `isPhysicalCapacity` is the drop
1745 * rule test -- a bound that exists only as a state-space cutoff keeps the WAITQ
1746 * default and must not become a loss.
1747 */
1748template <class T>
1749bool NrmEngine<T>::capacity_loss(const std::vector<double>& X, std::size_t slot) const {
1750 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1751 if (!is_station_[jnd]) return false;
1752 const std::size_t ist = to_station_[jnd];
1753 const qn::DropStrategy dr = sn_.droprule[ist][dst];
1754 if (dr == qn::DropStrategy::WAITQ) return false; // cutoff-only, or unset
1755 if (dr == qn::DropStrategy::BAS || dr == qn::DropStrategy::BBS ||
1756 dr == qn::DropStrategy::RSRD)
1757 throw UnsupportedError(ErrorKind{"FiniteCapacity"}, "solver_ssa_nrm: station '" + sn_.stations[ist].name +
1758 "' declares the blocking drop rule for class '" +
1759 sn_.classes[dst].name +
1760 "'; blocking-after-service is not ported to the C++ NRM");
1761 if (!std::isinf(sn_.classes[dst].population)) return false; // closed: block, never lose
1762 const std::vector<double> cc = class_counts(X, jnd);
1763 double tot = 0.0;
1764 for (double v : cc) tot += v;
1765 if (!std::isinf(sn_.cap[ist]) && tot >= sn_.cap[ist]) return true;
1766 const double ccap = sn_.classcap[ist][dst];
1767 return ccap > 0.0 && !std::isinf(ccap) && cc[dst] >= ccap;
1768}
1769
1770/**
1771 * `capacityBlock`: a refused arrival that may NOT be dropped cancels the firing.
1772 *
1773 * `capacity_loss` above answers the OPEN half of the same question and returns
1774 * false for a closed class precisely because a closed network's population is an
1775 * invariant. Nothing then stopped the reaction, so the NRM fired into the full
1776 * station anyway and reported the UNCONSTRAINED answer (BUG-81): on a closed
1777 * 3-queue tandem, N=6, Q2 capped at 2, QLen came back [1.96 2.07 1.97] against
1778 * the exact [3.609 0.971 1.420] -- a mean of 2.07 at a station that holds 2 --
1779 * while the serial engine, whose producer already implements the contract, gave
1780 * the exact answer.
1781 *
1782 * Blocking is the THIRD outcome of a firing, next to moving the job and losing
1783 * it. The departure does not occur, the source is not decremented, no buffer
1784 * moves; only the reaction's own clock is redrawn, which is exact by
1785 * memorylessness -- the residual of an exponential, or of the current PH phase,
1786 * is that same exponential. It is what `solver_ctmc` does when the target state
1787 * is absent from the enumerated space and the arc is dropped, which is why the
1788 * two agree.
1789 *
1790 * The population read is the PRE-arrival one, minus the departing job when it
1791 * currently sits at the destination node: a self-loop or a feedback arc at a
1792 * station already at cap would otherwise block itself forever, while the
1793 * reference producer sees the state AFTER the departure half.
1794 */
1795template <class T>
1796bool NrmEngine<T>::capacity_block(const std::vector<double>& X, std::size_t slot,
1797 std::size_t src_slot) const {
1798 if (!blk_on_) return false;
1799 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1800 if (!is_station_[jnd]) return false;
1801 const std::size_t ist = to_station_[jnd];
1802 if (!blk_can_[ist][dst]) return false;
1803 const bool same_node = src_slot != npos && slot_node_[src_slot] == jnd;
1804 const std::vector<double> cc = class_counts(X, jnd);
1805 if (!std::isinf(sn_.cap[ist])) {
1806 double tot = 0.0;
1807 for (double v : cc) tot += v;
1808 if (same_node) tot -= 1.0;
1809 if (tot >= sn_.cap[ist]) return true;
1810 }
1811 const double ccap = sn_.classcap[ist][dst];
1812 if (ccap > 0.0 && !std::isinf(ccap)) {
1813 double pop = cc[dst];
1814 if (same_node && slot_class_[src_slot] == dst) pop -= 1.0;
1815 if (pop >= ccap) return true;
1816 }
1817 return false;
1818}
1819
1820/** `pickPreempted`: the incumbent an LCFSPR arrival displaces, by server occupancy. */
1821template <class T>
1822std::size_t NrmEngine<T>::pick_preempted(const std::vector<double>& X,
1823 const std::vector<std::size_t>& buf, std::size_t jnd,
1824 std::size_t arr_class) {
1825 std::vector<double> insvc = class_counts(X, jnd);
1826 for (std::size_t r = 0; r < K_; ++r) {
1827 for (std::size_t c : buf)
1828 if (c == r) insvc[r] -= 1.0;
1829 if (r == arr_class) insvc[r] -= 1.0; // the job that just arrived
1830 if (insvc[r] < 0.0) insvc[r] = 0.0;
1831 }
1832 double tot = 0.0;
1833 for (double v : insvc) tot += v;
1834 if (tot <= 0.0) return npos;
1835 const double u = rng_.uniform() * tot;
1836 double acc = 0.0;
1837 std::size_t last = npos;
1838 for (std::size_t r = 0; r < K_; ++r) {
1839 if (!(insvc[r] > 0.0)) continue;
1840 last = r;
1841 acc += insvc[r];
1842 if (u < acc) return r;
1843 }
1844 return last;
1845}
1846
1847/** `applyArrivalBuffer`: join a just-arrived class-s job to node jnd's buffer. */
1848template <class T>
1849void NrmEngine<T>::apply_arrival_buffer(std::size_t jnd, std::size_t s,
1850 const std::vector<double>& X,
1851 std::vector<std::vector<std::size_t>>& bufs,
1852 std::vector<Matrix<double>>& svc, bool& svc_changed) {
1853 if (is_station_[jnd] && detail::sched_is_list(sched_[to_station_[jnd]])) {
1854 // PAS / OI: the arrival joins the BACK of the ordered list; there is no
1855 // server/buffer split. An arrival past the station's cap was already
1856 // lost or blocked by the capacity gates before it got here.
1857 const std::vector<std::size_t> old = bufs[jnd];
1858 bufs[jnd].push_back(s);
1859 pas_tag_started(jnd, old, bufs[jnd]);
1860 return;
1861 }
1862 // Polling: the controller, not the arrival, opens a service (`poll_land`).
1863 if (!is_poll_.empty() && is_poll_[jnd]) return;
1864 if (!is_station_[jnd] || !detail::sched_is_buffered(sched_[to_station_[jnd]])) {
1865 // A station that holds no waiting line serves every job it admits at
1866 // once (INF, PS, LPS, DPS, GPS), so the arrival IS the service start.
1867 if (is_station_[jnd]) tag(jnd, s, false);
1868 return;
1869 }
1870 const std::vector<double> cc = class_counts(X, jnd);
1871 double total = 0.0;
1872 for (double v : cc) total += v;
1873 bool entered = false;
1874 if (total > mi_[jnd]) {
1875 if (sched_[to_station_[jnd]] == SchedStrategy::LCFSPR) {
1876 // Preempt-resume: the arrival seizes a server and the incumbent it
1877 // displaces is the one that joins the buffer, which is what leaves
1878 // the new job in service (in-service is population minus buffer).
1879 const std::size_t c = pick_preempted(X, bufs[jnd], jnd, s);
1880 if (c != npos) {
1881 bufs[jnd].insert(bufs[jnd].begin(), c);
1882 tag(jnd, c, true); // the incumbent is pushed back into the buffer
1883 }
1884 entered = true;
1885 } else {
1886 bufs[jnd].insert(bufs[jnd].begin(), s); // it waits: it starts nothing
1887 }
1888 } else {
1889 entered = true;
1890 }
1891 if (entered) tag(jnd, s, false); // the arrival seized a server
1892 if (entered && buf_ph_node_[jnd]) {
1893 svc[jnd](s, draw_entry_phase(jnd, s)) += 1.0;
1894 svc_changed = true;
1895 }
1896}
1897
1898/**
1899 * `cacheAccess`: one READ at a cache, drawn from `after_event_cache`.
1900 *
1901 * The reference NRM re-implements State.afterEventCache's simulation branch;
1902 * this port draws from the enumerating port itself instead, so the NRM, the
1903 * serial engine and SolverCTMC read one set of replacement rules. The reading
1904 * job is presented ALONE (the enumerator requires exactly one job at the cache,
1905 * and other jobs at an immediate node are immaterial to a read), and the one
1906 * successor is drawn in proportion to its rate. Its per-class block, against the
1907 * one-hot input, says what happened:
1908 *
1909 * all zero the request MERGED onto an in-flight fetch (block B);
1910 * a retrieval class a fetch BEGINS: the in-flight bit is set, as the
1911 * departure that carries the job out would set it, and
1912 * the job is placed straight at the retrieval station,
1913 * as `cacheRetrDest` does, so that a job at
1914 * [cache, retrieval class] is always an inbound one;
1915 * otherwise a hit or a completed miss, plus the delayed hits the
1916 * completion releases, all at the cache in their classes.
1917 */
1918template <class T>
1919void NrmEngine<T>::cache_fire(const Rx& x, std::vector<double>& X, std::size_t& dest_pos,
1920 bool& have_dest) {
1921 const std::size_t ind = x.node, r = x.cls;
1922 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
1923 std::vector<T> row(K_, num_traits<T>::from_int(0));
1924 row[r] = num_traits<T>::from_int(1);
1925 row.insert(row.end(), cache_var_[ind].begin(), cache_var_[ind].end());
1926 const qn::EventOutcome<T> o =
1927 qn::after_event_cache(sn_, ind + 1, row, lang::EventType::READ, r + 1);
1928 std::vector<double> w(o.space.size(), 0.0);
1929 double tot = 0.0;
1930 for (std::size_t j = 0; j < o.space.size(); ++j) {
1931 w[j] = num_traits<T>::to_double(o.rate[j]) * num_traits<T>::to_double(o.prob[j]);
1932 if (!(w[j] > 0.0)) w[j] = 0.0;
1933 tot += w[j];
1934 }
1935 if (!(tot > 0.0))
1936 throw NumericError(ErrorKind{"Degenerate"}, "solver_ssa_nrm: the READ of class '" + sn_.classes[r].name +
1937 "' at Cache '" + sn_.nodes[ind].name + "' has no enabled outcome");
1938 const std::vector<T>& nx = o.space[rng_.draw(w)];
1939 X[x.from] -= 1.0;
1940 cache_var_[ind].assign(nx.begin() + static_cast<std::ptrdiff_t>(K_), nx.end());
1941
1942 std::vector<std::size_t> rcl, rci, rco;
1943 if (cp.retrieval_capacity > 0) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
1944 const bool from_retr = std::find(rcl.begin(), rcl.end(), r + 1) != rcl.end();
1945 double produced = 0.0;
1946 for (std::size_t c = 0; c < K_; ++c) produced += num_traits<T>::to_double(nx[c]);
1947 if (produced == 0.0) {
1948 cache_dly_(ind, r) += 1.0; // merged: held in block B until its fetch completes
1949 return;
1950 }
1951 for (std::size_t c = 0; c < K_; ++c) {
1952 const double d = num_traits<T>::to_double(nx[c]);
1953 if (!(d > 0.0)) continue;
1954 const bool begins = !from_retr && std::find(rcl.begin(), rcl.end(), c + 1) != rcl.end();
1955 if (!begins) {
1956 X[phoff_[ind][c]] += d;
1957 cache_prod_(ind, c) += d;
1958 if (!have_dest) {
1959 dest_pos = phoff_[ind][c];
1960 have_dest = true;
1961 }
1962 continue;
1963 }
1964 // BEGIN a fetch. The in-flight bit of the item whose retrieval class c is.
1965 std::size_t tcc = 0;
1966 for (int m : cp.itemcap)
1967 if (m > 0) tcc += static_cast<std::size_t>(m);
1968 for (std::size_t it = 0; it < cp.retrieval_classes.size(); ++it)
1969 if (r < cp.retrieval_classes[it].size() && cp.retrieval_classes[it][r] == c + 1 &&
1970 tcc + it < cache_var_[ind].size())
1971 cache_var_[ind][tcc + it] = num_traits<T>::from_int(1);
1972 // The retrieval station the class routes to, drawn over the routing row
1973 // and the entry phases there.
1974 std::vector<std::size_t> slots;
1975 std::vector<double> sw;
1976 for (std::size_t jnd = 0; jnd < I_; ++jnd)
1977 for (std::size_t s = 0; s < K_; ++s) {
1978 const double p =
1979 num_traits<T>::to_double(sn_.rtnodes(ind * K_ + c, jnd * K_ + s));
1980 if (!(p > 0.0)) continue;
1981 if (buf_ph_class_[jnd][s]) {
1982 slots.push_back(phoff_[jnd][s]);
1983 sw.push_back(p);
1984 continue;
1985 }
1986 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke)
1987 if (pie_[jnd][s][ke] > 0.0) {
1988 slots.push_back(phoff_[jnd][s] + ke);
1989 sw.push_back(p * pie_[jnd][s][ke]);
1990 }
1991 }
1992 if (slots.empty())
1993 throw InputError("solver_ssa_nrm: retrieval class '" + sn_.classes[c].name +
1994 "' at Cache '" + sn_.nodes[ind].name + "' routes nowhere");
1995 const std::size_t slot = slots[rng_.draw(sw)];
1996 X[slot] += 1.0;
1997 dest_pos = slot;
1998 have_dest = true;
1999 }
2000}
2001
2002/**
2003 * The realized hit, miss and delayed-hit shares, as the serial engine reports
2004 * them: every read leaves as exactly one of the three, the released delayed
2005 * hits are carved out of the hit class's production, and the retrieval
2006 * classes, which have no hit class, get no share of their own.
2007 */
2008template <class T>
2009void NrmEngine<T>::cache_write_back() {
2010 cache_out_.clear();
2011 const double nan = std::numeric_limits<double>::quiet_NaN();
2012 for (std::size_t ind = 0; ind < I_; ++ind) {
2013 if (!is_cache_node_[ind]) continue;
2014 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
2015 SsaCacheRatio cr;
2016 cr.node = ind + 1;
2017 cr.hitprob.assign(K_, nan);
2018 cr.missprob.assign(K_, nan);
2019 cr.residt.assign(K_, nan);
2020 std::vector<double> dly(K_, 0.0);
2021 bool any_delayed = false;
2022 for (std::size_t k = 0; k < K_; ++k) {
2023 if (k >= cp.hitclass.size() || k >= cp.missclass.size()) continue;
2024 const std::size_t h = cp.hitclass[k], mi = cp.missclass[k];
2025 if (h == 0 || mi == 0 || h > K_ || mi > K_) continue;
2026 const double th = cache_prod_(ind, h - 1), tm = cache_prod_(ind, mi - 1);
2027 const double td = cache_dly_(ind, k);
2028 if (th + tm > 0.0) {
2029 cr.hitprob[k] = std::max(th - td, 0.0) / (th + tm);
2030 cr.missprob[k] = tm / (th + tm);
2031 dly[k] = td / (th + tm);
2032 if (td > 0.0) any_delayed = true;
2033 }
2034 }
2035 if (any_delayed) cr.delayedprob = dly;
2036 cache_out_.push_back(cr);
2037 }
2038}
2039
2040/** `updateBuffers`: promote on a departure, join on an arrival. */
2041template <class T>
2042void NrmEngine<T>::update_buffers(std::size_t kfire, const std::vector<double>& X,
2043 std::vector<std::vector<std::size_t>>& bufs,
2044 std::size_t dest_pos, bool have_dest,
2045 std::vector<Matrix<double>>& svc, bool& svc_changed) {
2046 const Rx& x = rx_[kfire];
2047 const std::size_t ind = x.node;
2048 // The completing job leaves the phase it occupied; the promotion below
2049 // refills the freed server at a fresh entry phase.
2050 if (buf_ph_node_[ind] && x.is_buf_svc && !x.is_phase) {
2051 svc[ind](x.cls, x.dep_phase) -= 1.0;
2052 svc_changed = true;
2053 }
2054 // A PAS / OI departure is not a promotion but a pass-and-swap rewrite of the
2055 // whole list. Unlike the reference, which returns here, the ARRIVAL half
2056 // below still runs: a PAS station feeding a buffered one must join the job
2057 // to that station's buffer, and returning early would leave it counted in
2058 // the population but absent from the buffer.
2059 if (is_station_[ind] && detail::sched_is_list(sched_[to_station_[ind]]) &&
2060 !bufs[ind].empty() && !x.is_phase) {
2061 const std::vector<std::size_t> old = bufs[ind];
2062 pas_depart(ind, bufs[ind], x.cls);
2063 pas_tag_started(ind, old, bufs[ind]);
2064 } else if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]]) &&
2065 !bufs[ind].empty() && !x.is_phase) {
2066 const std::size_t pos = pick_from_buffer(bufs[ind], to_station_[ind]);
2067 const std::size_t promoted = bufs[ind][pos];
2068 bufs[ind].erase(bufs[ind].begin() + static_cast<std::ptrdiff_t>(pos));
2069 tag(ind, promoted, false); // it takes the server the completion freed
2070 if (buf_ph_node_[ind]) {
2071 svc[ind](promoted, draw_entry_phase(ind, promoted)) += 1.0;
2072 svc_changed = true;
2073 }
2074 }
2075 if (have_dest)
2076 apply_arrival_buffer(slot_node_[dest_pos], slot_class_[dest_pos], X, bufs, svc,
2077 svc_changed);
2078}
2079
2080// ---------------------------------------------------------------------------
2081// The sample path
2082// ---------------------------------------------------------------------------
2083
2084/** `next_reaction_method_direct`: the Gibson and Bruck loop with metrics in line. */
2085template <class T>
2087 const std::size_t nrx = rx_.size();
2088 SsaSolution out;
2089 out.QN = Matrix<double>(M_, K_, 0.0);
2090 out.UN = Matrix<double>(M_, K_, 0.0);
2091 out.RN = Matrix<double>(M_, K_, 0.0);
2092 out.TN = Matrix<double>(M_, K_, 0.0);
2093 out.CN.assign(K_, 0.0);
2094 out.XN.assign(K_, 0.0);
2095 out.StartN = Matrix<double>(M_, K_, 0.0);
2096 out.PreemptN = Matrix<double>(M_, K_, 0.0);
2097 out.method = "nrm";
2098 start_cnt_ = Matrix<double>(M_, K_, 0.0);
2099 preempt_cnt_ = Matrix<double>(M_, K_, 0.0);
2100 block_cnt_ = Matrix<double>(M_, K_, 0.0);
2101 if (nrx == 0) return out;
2102
2103 std::vector<double> nvec = nvec0_;
2104 std::vector<std::vector<std::size_t>> bufs = buffers0_;
2105 std::vector<Matrix<double>> svc = svcph0_;
2106 // The WAITQ FIFO of each region, tokens `node * K + class` of the parked jobs.
2107 std::vector<std::vector<std::size_t>> fcr_buf(fcr_.size());
2108 cache_var_ = cache_var0_;
2109 cache_prod_ = Matrix<double>(I_, K_, 0.0);
2110 cache_dly_ = Matrix<double>(I_, K_, 0.0);
2111 // Seed each polling controller as `State.pollingInit` does: walk from buffer
2112 // 1 and settle on the first tangible state, so the seed is reachable.
2113 poll_ctrl_.assign(I_, PollCtrl());
2114 for (std::size_t ind = 0; ind < I_; ++ind) {
2115 if (!is_poll_[ind]) continue;
2116 std::size_t q = 1;
2117 int mode = 0;
2118 long budget = 0;
2119 qn::polling_next(poll_info_[ind], 1, poll_nbuf(nvec, ind), K_, true, q, mode, budget);
2120 poll_ctrl_[ind] = poll_land(ind, q, mode, budget, false);
2121 }
2122
2123 std::vector<double> Ak(nrx, 0.0), Pk(nrx, 0.0), Tk(nrx, 0.0), tau(nrx, 0.0);
2124 for (std::size_t k = 0; k < nrx; ++k) {
2125 Ak[k] = propensity(k, nvec, bufs, svc);
2126 Pk[k] = -std::log(rng_.uniform());
2127 tau[k] = Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
2128 }
2129
2130 double total_time = 0.0;
2131 std::size_t n = 0;
2132 line::util::LineConsole::loop("drawing the sample path: %zu samples requested",
2133 static_cast<std::size_t>(opt_.samples));
2134 const std::size_t console_every = std::max<std::size_t>(1, opt_.samples / 20);
2135 // Warmup discard (`ssa_resolve_warmupfrac`): the tallies restart once
2136 // `n_warm` firings have been drawn, so the initial transient enters no
2137 // estimate; the path itself runs on, as `solver_ssa_nrm.m` does.
2138 const std::size_t n_warm = ssa_warmup_firings(opt_.warmupfrac, opt_.samples);
2139 for (; n < opt_.samples; ++n) {
2140 if (n == n_warm && n_warm > 0) {
2141 out.QN = Matrix<double>(M_, K_, 0.0);
2142 out.UN = Matrix<double>(M_, K_, 0.0);
2143 out.TN = Matrix<double>(M_, K_, 0.0);
2144 total_time = 0.0;
2145 start_cnt_ = Matrix<double>(M_, K_, 0.0);
2146 preempt_cnt_ = Matrix<double>(M_, K_, 0.0);
2147 block_cnt_ = Matrix<double>(M_, K_, 0.0);
2148 cache_prod_ = Matrix<double>(I_, K_, 0.0);
2149 cache_dly_ = Matrix<double>(I_, K_, 0.0);
2150 }
2151 if ((n + 1) % console_every == 0)
2153 static_cast<long>((n + 1) / console_every),
2154 "simulated %zu of %zu samples (%.0f%%), simulated time %.4g", n + 1,
2155 static_cast<std::size_t>(opt_.samples),
2156 100.0 * static_cast<double>(n + 1) / static_cast<double>(opt_.samples),
2157 total_time);
2158 std::size_t kfire = 0;
2159 double dt = std::numeric_limits<double>::infinity();
2160 for (std::size_t k = 0; k < nrx; ++k)
2161 if (tau[k] < dt) {
2162 dt = tau[k];
2163 kfire = k;
2164 }
2165 if (std::isinf(dt))
2166 throw NumericError(ErrorKind{"Degenerate"},
2167 "solver_ssa_nrm: deadlock -- every reaction has propensity zero, so the sample "
2168 "path cannot advance");
2169 total_time += dt;
2170
2171 // Time-integrate the metrics over this interval.
2172 for (std::size_t ist = 0; ist < M_; ++ist) {
2173 const std::size_t ind = sn_.station_to_node[ist] - 1;
2174 const std::vector<double> npop = class_counts(nvec, ind);
2175 double totpop = 0.0;
2176 for (double v : npop) totpop += v;
2177 // A Source's state slot is a FICTITIOUS TOKEN, not a queue. It is
2178 // seeded at 1, decremented by every arrival and replenished only
2179 // when a job reaches the Sink and is forwarded back, so its time
2180 // average is 1 - E[jobs in system] -- negative on any model with
2181 // more than one job in system, which is what it was reporting. A
2182 // Source holds no jobs, so QLen, Util and hence RespT/ResidT are
2183 // ZERO BY DEFINITION there and only its throughput (the arrival
2184 // rate) is a quantity; MVA, NC and MAM print exactly that row, and
2185 // the reference SSA applies the same rule downstream in
2186 // `solver_ssa.py` / `@@NetworkSolver/getAvg.m`. This is a
2187 // definition, not a clamp: it is keyed on the station's discipline
2188 // and never on the sign, so a negative anywhere else stays visible.
2189 const bool is_source = sched_[ist] == SchedStrategy::EXT;
2190 for (std::size_t k = 0; k < K_; ++k) {
2191 double dep = 0.0;
2192 for (std::size_t jd : dep_rx_[ist][k]) dep += Ak[jd];
2193 out.TN(ist, k) += dep * dt;
2194 if (is_source) continue;
2195 out.QN(ist, k) += npop[k] * dt;
2196 switch (sched_[ist]) {
2197 case SchedStrategy::INF:
2198 out.UN(ist, k) += npop[k] * dt;
2199 break;
2200 case SchedStrategy::PS:
2201 case SchedStrategy::LPS:
2202 if (totpop > 0.0)
2203 out.UN(ist, k) += (npop[k] / totpop) *
2204 std::min(nservers_[ist], totpop) / nservers_[ist] *
2205 dt;
2206 break;
2207 case SchedStrategy::DPS:
2208 out.UN(ist, k) += dpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
2209 break;
2210 case SchedStrategy::GPS:
2211 out.UN(ist, k) += gpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
2212 break;
2213 // The utilization of a PRIORITY discipline is the SAME
2214 // share the rate law uses, which is the invariant this
2215 // accumulator exists to preserve: reusing the sharing
2216 // factor is what keeps U = T E[S] from drifting away from
2217 // the sample path that produced T.
2218 case SchedStrategy::PSPRIO:
2219 out.UN(ist, k) += psprioshare(npop, k, nservers_[ist]) / nservers_[ist] * dt;
2220 break;
2221 case SchedStrategy::DPSPRIO:
2222 out.UN(ist, k) +=
2223 dpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
2224 break;
2225 case SchedStrategy::GPSPRIO:
2226 out.UN(ist, k) +=
2227 gpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
2228 break;
2229 // Busy on one class-k job exactly while the controller serves k.
2230 case SchedStrategy::POLLING:
2231 if (poll_ctrl_[ind].mode == 1 && poll_ctrl_[ind].pos == k + 1)
2232 out.UN(ist, k) += dt / nservers_[ist];
2233 break;
2234 // The positions with a positive Delta_mu over the servers, so a
2235 // job served by several server types counts once.
2236 case SchedStrategy::PAS:
2237 case SchedStrategy::OI:
2238 out.UN(ist, k) += pas_in_service(ist, bufs[ind], k) / nservers_[ist] * dt;
2239 break;
2240 case SchedStrategy::FCFS:
2241 case SchedStrategy::LCFS:
2242 case SchedStrategy::SIRO:
2243 case SchedStrategy::HOL:
2244 case SchedStrategy::SEPT:
2245 case SchedStrategy::LEPT:
2246 case SchedStrategy::LCFSPR: {
2247 if (sn_.disabled[ist][k]) break;
2248 double waiting = 0.0;
2249 for (std::size_t c : bufs[ind])
2250 if (c == k) waiting += 1.0;
2251 out.UN(ist, k) += ((npop[k] - waiting) / nservers_[ist]) * dt;
2252 break;
2253 }
2254 default:
2255 break;
2256 }
2257 }
2258 }
2259
2260 // Apply the firing.
2261 const Rx& x = rx_[kfire];
2262 std::size_t dest_pos = npos;
2263 bool have_dest = false;
2264 // A firing has THREE outcomes, not two: it moves the job, it loses it
2265 // (the source departs either way), or it is BLOCKED -- cancelled with
2266 // the source keeping the job and no slot changing. See `capacity_block`.
2267 bool blocked = false;
2268 if (x.is_cache) {
2269 cache_fire(x, nvec, dest_pos, have_dest);
2270 } else if (x.nnzP > 1 || !x.sd_node.empty()) {
2271 std::size_t slot;
2272 if (!x.sd_node.empty()) {
2273 // the dispatcher decides, from the state, not from a draw
2274 slot = resolve_state_dependent_dest(kfire, nvec);
2275 } else {
2276 const double u = rng_.uniform();
2277 std::size_t sel = x.cdf.size() - 1;
2278 for (std::size_t i = 0; i < x.cdf.size(); ++i)
2279 if (x.cdf[i] > u) {
2280 sel = i;
2281 break;
2282 }
2283 slot = x.to_slots[sel];
2284 }
2285 // A closed job that finds no room BLOCKS: the firing is cancelled
2286 // outright and nothing below runs, because a job that cannot leave
2287 // its station never departs at all. See `capacity_block`.
2288 if (capacity_block(nvec, slot, x.from)) {
2289 blocked = true;
2290 } else {
2291 // The loss is decided on the PRE-arrival population, so it is drawn
2292 // before the state is updated: the source still releases the job and
2293 // the destination never receives it.
2294 bool lost = capacity_loss(nvec, slot);
2295 // A region refuses on the same pre-arrival population; see
2296 // `fcr_outcome` for what each rule does with the refused job.
2297 if (!lost && !fcr_.empty())
2298 fcr_outcome(nvec, x, slot_node_[slot], slot_class_[slot], fcr_buf, lost, blocked);
2299 if (!blocked) {
2300 for (std::size_t s : x.from_slots) nvec[s] -= 1.0;
2301 if (!lost) {
2302 nvec[slot] += 1.0;
2303 dest_pos = slot;
2304 have_dest = true;
2305 }
2306 }
2307 }
2308 } else if (x.det_dest != npos && !x.is_phase &&
2309 capacity_block(nvec, x.det_dest, x.from)) {
2310 blocked = true; // same closed-class block; a phase change is never one
2311 } else {
2312 bool lost = x.det_dest != npos && !x.is_phase && capacity_loss(nvec, x.det_dest);
2313 // Single-destination departures cross region boundaries too; a phase
2314 // change is not an arrival and is never gated.
2315 if (!lost && x.det_dest != npos && !x.is_phase && !fcr_.empty())
2316 fcr_outcome(nvec, x, slot_node_[x.det_dest], slot_class_[x.det_dest], fcr_buf, lost,
2317 blocked);
2318 if (blocked) {
2319 // censored: nothing moves, as for `capacity_block`
2320 } else if (lost) {
2321 nvec[x.from] -= 1.0;
2322 } else {
2323 for (std::size_t i = 0; i < NS_; ++i)
2324 if (S_(i, kfire) != 0.0) nvec[i] += S_(i, kfire);
2325 if (x.det_dest != npos) {
2326 dest_pos = x.det_dest;
2327 have_dest = true;
2328 }
2329 }
2330 }
2331
2332 bool svc_changed = false;
2333 if (blocked) {
2334 // nothing moved, so no buffer and no service phase may change
2335 if (is_station_[x.node]) block_cnt_(to_station_[x.node], x.cls) += 1.0;
2336 } else if (x.is_phase && buf_ph_node_[x.node]) {
2337 // A buffered-PH phase change frees no server and adds no arrival:
2338 // only the in-service multiset moves.
2339 svc[x.node](x.cls, x.phase_from) -= 1.0;
2340 svc[x.node](x.cls, x.phase_to) += 1.0;
2341 svc_changed = true;
2342 } else if (!x.is_sw) {
2343 update_buffers(kfire, nvec, bufs, dest_pos, have_dest, svc, svc_changed);
2344 }
2345 // A read rewrites the contents and places jobs outside the stoichiometry.
2346 if (x.is_cache) svc_changed = true;
2347
2348 // Polling controller advance, `solver_ssa_nrm.m` lines 1710-1774: a
2349 // switchover step, a completion that ends or continues the visit, and an
2350 // arrival that wakes a parked server. Each can flip a service gate or the
2351 // switchover rate outside the static dependency set, so any move forces a
2352 // full refresh below.
2353 if (!is_poll_.empty() && !blocked) {
2354 if (x.is_sw) {
2355 const std::size_t ind = x.node;
2356 PollCtrl& c = poll_ctrl_[ind];
2357 const Matrix<T>& D0 = poll_info_[ind].sw_d0[c.pos - 1];
2358 const std::size_t Ksw = D0.rows();
2359 std::vector<double> w(Ksw + 1, 0.0);
2360 double rowsum = 0.0;
2361 for (std::size_t kd = 0; kd < Ksw; ++kd) {
2362 const double v = num_traits<T>::to_double(D0(c.swk, kd));
2363 rowsum += v;
2364 if (kd != c.swk && v > 0.0) w[kd] = v;
2365 }
2366 w[Ksw] = std::max(0.0, -rowsum); // absorption: the D1 row sum
2367 const std::size_t pick = rng_.draw(w);
2368 if (pick < Ksw && pick != c.swk) {
2369 c.swk = pick; // an internal phase advance of the walk
2370 } else {
2371 std::size_t q = c.pos;
2372 int mode = 0;
2373 long budget = 0;
2374 qn::polling_next(poll_info_[ind], c.pos, poll_nbuf(nvec, ind), K_, true, q,
2375 mode, budget);
2376 c = poll_land(ind, q, mode, budget);
2377 }
2378 svc_changed = true;
2379 } else if (is_poll_[x.node] && !x.is_phase) {
2380 const std::size_t ind = x.node;
2381 PollCtrl& c = poll_ctrl_[ind];
2382 const qn::PollingInfo<T>& pi = poll_info_[ind];
2383 const std::vector<long> nb = poll_nbuf(nvec, ind); // after the departure
2384 long ctrnext = 0;
2385 bool goon = false;
2386 switch (pi.ptype) {
2388 goon = nb[c.pos - 1] > 0;
2389 break;
2391 ctrnext = c.ctr - 1;
2392 goon = ctrnext > 0;
2393 break;
2395 ctrnext = c.ctr - 1;
2396 goon = ctrnext > 0 && nb[c.pos - 1] > 0;
2397 break;
2399 ctrnext = c.ctr;
2400 goon = nb[c.pos - 1] > c.ctr;
2401 break;
2402 default:
2403 throw UnsupportedError(ErrorKind{"SchedStrategy_POLLING"}, "solver_ssa_nrm: unsupported polling type");
2404 }
2405 if (goon) {
2406 c = poll_land(ind, c.pos, 1, ctrnext);
2407 } else {
2408 std::size_t q = c.pos;
2409 int mode = 0;
2410 long budget = 0;
2411 qn::polling_next(pi, c.pos, nb, K_, false, q, mode, budget);
2412 c = poll_land(ind, q, mode, budget);
2413 }
2414 svc_changed = true;
2415 }
2416 if (have_dest && is_poll_[slot_node_[dest_pos]]) {
2417 const std::size_t jnd = slot_node_[dest_pos];
2418 PollCtrl& c = poll_ctrl_[jnd];
2419 if (c.mode == 0) {
2420 std::size_t q = c.pos;
2421 int mode = 0;
2422 long budget = 0;
2423 qn::polling_next(poll_info_[jnd], c.pos, poll_nbuf(nvec, jnd), K_, true, q,
2424 mode, budget);
2425 c = poll_land(jnd, q, mode, budget);
2426 svc_changed = true;
2427 }
2428 }
2429 }
2430
2431 // WAITQ: admit parked jobs whose regions this firing may have relieved.
2432 // A release changes populations at arbitrary nodes, so it forces a full
2433 // propensity refresh below, as a service-phase move does.
2434 if (fcr_any_waitq_ && !blocked &&
2435 fcr_release_cascade(nvec, bufs, fcr_buf, svc, svc_changed) > 0)
2436 svc_changed = true;
2437
2438 for (std::size_t k = 0; k < nrx; ++k) Tk[k] += Ak[k] * dt;
2439
2440 // svcph is not part of the stoichiometry, so the static dependency set
2441 // cannot see a change to it; a move there forces a full refresh.
2442 if (svc_changed) {
2443 for (std::size_t k = 0; k < nrx; ++k) Ak[k] = propensity(k, nvec, bufs, svc);
2444 } else {
2445 for (std::size_t k : D_[kfire]) Ak[k] = propensity(k, nvec, bufs, svc);
2446 }
2447
2448 Pk[kfire] -= std::log(rng_.uniform());
2449 for (std::size_t k = 0; k < nrx; ++k)
2450 tau[k] =
2451 Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
2452 }
2453
2454 if (total_time > 0.0)
2455 for (std::size_t ist = 0; ist < M_; ++ist)
2456 for (std::size_t k = 0; k < K_; ++k) {
2457 out.QN(ist, k) /= total_time;
2458 out.UN(ist, k) /= total_time;
2459 // net the blocked firings out of the departure-rate integral
2460 out.TN(ist, k) = (out.TN(ist, k) - block_cnt_(ist, k)) / total_time;
2461 // COUNTS over the simulated time, where TN above integrates a
2462 // rate: the two estimators of the same quantity agree in the
2463 // limit and differ by simulation error at any finite budget.
2464 out.StartN(ist, k) = start_cnt_(ist, k) / total_time;
2465 out.PreemptN(ist, k) = preempt_cnt_(ist, k) / total_time;
2466 }
2467
2468 // A class- or joint-dependent station reports U = T*S/peak against the
2469 // product of its declared cd and jd peaks, the T*S/c convention of the
2470 // analytic solvers and serial SSA; busy time divided by the server count is
2471 // not that quantity once beta(n) != 1.
2472 for (std::size_t ist = 0; ist < M_; ++ist) {
2473 const auto& st = sn_.stations[ist];
2474 if (!st.cdscaling && !st.jdscaling) continue;
2475 for (std::size_t k = 0; k < K_; ++k) {
2476 double peak = 1.0;
2477 if (st.cdscaling) peak *= num_traits<T>::to_double(st.cdscalingpeak[k]);
2478 if (st.jdscaling) peak *= num_traits<T>::to_double(st.jdscalingpeak[k]);
2479 const double rate = num_traits<T>::to_double(sn_.rates(ist, k));
2480 out.UN(ist, k) = (std::isfinite(rate) && rate > 0.0 && peak > 0.0)
2481 ? out.TN(ist, k) / rate / peak
2482 : 0.0;
2483 }
2484 }
2485
2486 // Every LOAD-DEPENDENT station reports the work-based T*S/peak instead,
2487 // peak = max(c, max(alpha)). The loop above integrates BUSY TIME, which is a
2488 // different quantity once alpha(n) != 1: a server running alpha(n) times
2489 // faster does the same work in less time, so busy time reads it as no busier
2490 // than one at its nominal rate. That put NRM at 0.9587 on a 4-job closed
2491 // model with alpha = [1 1.5 2 2.5] where CTMC, MVA, NC and serial SSA all
2492 // report 0.6612, and left the two SSA engines disagreeing with each other.
2493 // INF and EXT keep U = Q, as everywhere else.
2494 for (std::size_t ist = 0; ist < M_; ++ist) {
2495 if (lld_[ist].empty()) continue;
2496 double peak = nservers_[ist];
2497 bool non_unit = false;
2498 for (double a : lld_[ist]) {
2499 if (a != 1.0) non_unit = true;
2500 if (a > peak) peak = a;
2501 }
2502 if (!non_unit) continue;
2503 if (sched_[ist] == SchedStrategy::INF || sched_[ist] == SchedStrategy::EXT)
2504 continue;
2505 for (std::size_t k = 0; k < K_; ++k) {
2506 const double rate = num_traits<T>::to_double(sn_.rates(ist, k));
2507 out.UN(ist, k) = (std::isfinite(rate) && rate > 0.0 && peak > 0.0)
2508 ? out.TN(ist, k) / rate / peak
2509 : 0.0;
2510 }
2511 }
2512
2513 for (std::size_t k = 0; k < K_; ++k) {
2514 out.XN[k] = out.TN(sn_.classes[k].refstat - 1, k);
2515 for (std::size_t ist = 0; ist < M_; ++ist)
2516 out.RN(ist, k) = out.TN(ist, k) > 0.0 ? out.QN(ist, k) / out.TN(ist, k) : 0.0;
2517 if (out.XN[k] > 0.0) out.CN[k] = sn_.classes[k].population / out.XN[k];
2518 }
2519 out.simulated_time = total_time;
2520 out.samples = n;
2521 cache_write_back();
2522 return out;
2523}
2524
2525} // namespace ssa
2526} // namespace line
2527
2528#endif // LINE_SOLVERS_SSA_SOLVER_SSA_NRM_H
InputError(const std::string &what)
Definition error.h:105
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:112
UnsupportedError(const std::string &what)
Definition error.h:119
A network plus its refreshed NetworkStruct.
std::size_t sourceIdx
1-based station index of the Source, 0 = none
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::size_t nof_nodes() const
std::vector< std::vector< Distrib< T > > > service
service[i][r], 0-based station and class; a disabled entry marks a pair never visited.
std::vector< std::vector< bool > > disabled
std::map< std::size_t, CacheParam< T > > nodeparam
Cache parameters by 1-based NODE index; only Cache nodes have an entry.
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
std::vector< Station< T > > stations
stations[k-1] is the k-th station
Matrix< T > rates
(nstations x nclasses) service rates and SCVs, with a PARALLEL disabled flag instead of MATLAB's NaN ...
std::vector< NodeDef > nodes
every node, in creation order
std::vector< std::vector< double > > classcap
std::vector< std::vector< DropStrategy > > droprule
std::vector< std::size_t > station_to_node
(nstations) 1-based node index
std::vector< Region > regions
std::size_t nreactions() const
The reaction count, likewise.
NrmEngine(const qn::NetworkStruct< T > &sn, const SsaOptions &opt)
std::size_t nstates() const
The state-vector length, for the tests that assert the phase expansion.
const std::vector< SsaCacheRatio > & cache() const
The cache write-back of the last run(), one entry per Cache node.
SsaSolution run()
Run opt.samples firings and return the time-averaged metrics.
The uniform source, MATLAB's rand.
Definition ssa_types.h:195
std::size_t draw(const std::vector< double > &p)
Index drawn from the unnormalized nonnegative weights p, the reference's drawFromDist: an all-zero we...
Definition ssa_types.h:216
std::size_t index(std::size_t n)
Uniform index in [0, n), the reference's 1 + floor(rand*n).
Definition ssa_types.h:206
static void loop(const char *fmt,...)
Announce an iteration loop and reset its reporting budget.
static void iter(long k, const char *fmt,...)
Report iteration k of the current loop.
What refreshProcessRepresentations and refreshLST compute FROM a distribution: the (D0,...
The exception types the port throws.
Running progress log of a LINE solver run (the "solver console").
Dense matrix and non-owning view.
mam::Map< T > dist_to_map(const Distrib< T > &d)
The (D0,D1) pair that reaches sn.proc.
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:185
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:431
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:396
@ READ
a cache item is read
Definition lang_types.h:121
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:380
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:379
@ GATED
serve exactly the jobs present at the polling instant
Definition lang_types.h:378
@ DECREMENTING
serve until the queue is one shorter than at arrival
Definition lang_types.h:381
std::vector< T > dist_pie(const Distrib< T > &d)
sn.pie: the phase distribution seen by an arriving job.
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:235
double tot(const std::vector< double > &pops)
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,...
void polling_next(const PollingInfo< T > &pi, std::size_t pos, const std::vector< long > &nbuf, std::size_t R, bool arrived, std::size_t &q, int &mode, long &budget)
Port of State.pollingNext: where the server goes from buffer pos.
EventOutcome< T > after_event_cache(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< T > &inspace, EventType event, std::size_t cls)
Port of State.afterEventCache: events at a Cache node.
PrioPop< T > prio_pop(const NetworkStruct< T > &sn, std::size_t ist, const Marginal< T > &m, std::size_t cls, double ni, double S)
Compute the *PRIO effective population; a no-op for every other discipline.
void pas_tag_started(EventOutcome< T > &out, const F &mu_fun, const std::vector< std::size_t > &cold, const std::vector< std::size_t > &cnew)
Tag the successor just appended to OUT with the PAS positions that started service on it: those of CN...
std::pair< std::vector< std::size_t >, std::size_t > pass_and_swap(const std::vector< std::size_t > &c, std::size_t p, const std::vector< std::vector< bool > > &G)
Port of State.passAndSwap: the transition a service completion triggers at a pass-and-swap station (D...
std::vector< double > pas_increments(const F &mu_fun, const std::vector< std::size_t > &c)
Per-position service rate increments Delta_mu(c1..cp) = mu(c1..cp) - mu(c1..c_{p-1}...
PollingInfo< T > polling_info(const NetworkStruct< T > &sn, std::size_t ind)
std::size_t ssa_warmup_firings(double warmupfrac, std::size_t samples)
Firings whose tallies an engine discards: floor(warmupfrac * samples), with an unset (negative) fract...
Definition ssa_types.h:141
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
State.pollingInfo and the controller description it returns.
Controls, results and the random source of SolverSSA.
Port of the event half of MATLAB's +State package: the successor states an event produces at one node...
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:771
static constexpr double Zero
Definition lang_types.h:767
Port of State.pollingInfo: the derived description of a polling controller.
lang::PollingType ptype
What the cache write-back of solver_ssa_analyzer_serial.m produces (and the NRM's).
Definition ssa_types.h:167
std::size_t node
1-based Cache node index
Definition ssa_types.h:168
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:71
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:147
std::vector< double > XN
Definition ssa_types.h:149
std::vector< double > CN
Definition ssa_types.h:149
Matrix< double > UN
Definition ssa_types.h:148
Matrix< double > RN
Definition ssa_types.h:148
Matrix< double > StartN
The DERIVED rates, (nstations x nclasses): how often per unit time a class-r service STARTS at statio...
Definition ssa_types.h:157
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:161
Matrix< double > TN
Definition ssa_types.h:148
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:163
Matrix< double > QN
Definition ssa_types.h:148
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:159
Matrix< double > PreemptN
Definition ssa_types.h:157