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 /**
458 * `cdfac`: the class-r entry of eta_i(n) .* beta_{i,r}(n), evaluated on the
459 * station's per-class population at firing time, as `State.afterEventInit`
460 * folds the joint handle into the class one. 1 when neither is declared.
461 */
462 /** Utilization at a dependent station is T*S/peak, so a peak must be declared. */
463 void check_peaks() const {
464 for (std::size_t ist = 0; ist < M_; ++ist) {
465 const auto& st = sn_.stations[ist];
466 const bool on[2] = {static_cast<bool>(st.cdscaling), static_cast<bool>(st.jdscaling)};
467 const std::vector<T>* pks[2] = {&st.cdscalingpeak, &st.jdscalingpeak};
468 const char* names[2] = {"setClassDependence", "setJointDependence"};
469 for (std::size_t h = 0; h < 2; ++h) {
470 if (!on[h]) continue;
471 bool ok = pks[h]->size() >= K_;
472 for (std::size_t k = 0; ok && k < K_; ++k)
473 ok = num_traits<T>::to_double((*pks[h])[k]) > 0.0;
474 if (!ok)
475 throw InputError("SolverSSA(method='nrm'): station '" + st.name +
476 "' declares a dependent scaling with no declared peak rate. "
477 "Utilization there is T*E[S]/peak, so pass the peak to " +
478 names[h]);
479 }
480 }
481 }
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(
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 * The stoichiometry and the reaction list, `solver_ssa_nrm.m` lines 190-355.
709 *
710 * A departure is the ABSORPTION of the phase-type service process, so it fires
711 * at the D1 row sum of the phase it leaves and the job re-enters its
712 * destination in an entry phase drawn from pie -- which is why the destination
713 * weight is the product of the routing probability and the entry probability. A
714 * phase change is an off-diagonal of D0 and never leaves the node.
715 */
716/**
717 * `fcrPrecompute`: the finite capacity regions, field for field.
718 *
719 * A region constrains an aggregate of the per-class populations of its member
720 * stations, which is a linear function of the NRM state vector, so admission is
721 * a 0/1 gate on the drawn destination. DROP loses the refused job at the gate,
722 * as a balked arrival is lost; WAITQ parks it in the refusing region's FIFO,
723 * which is state the reaction network does not carry and therefore rides beside
724 * the buffers (`fcr_buf` in `run`).
725 */
726template <class T>
727void NrmEngine<T>::build_regions() {
728 const double inf = std::numeric_limits<double>::infinity();
729 fcr_.clear();
730 fcr_any_waitq_ = false;
731 for (const typename qn::NetworkStruct<T>::Region& rg : sn_.regions) {
732 FcrRegion f;
733 f.member_node.assign(I_, 0);
734 f.ccap.assign(K_, inf);
735 f.sz.assign(K_, 1.0);
736 f.waitq.assign(K_, 0);
737 for (std::size_t i = 0; i < M_ && i < rg.members.size(); ++i) {
738 if (!rg.members[i]) continue;
739 f.member_node[sn_.station_to_node[i] - 1] = 1;
740 for (std::size_t r = 0; r < K_; ++r)
741 if (rg.cap[i][r] != -1.0) f.ccap[r] = std::min(f.ccap[r], rg.cap[i][r]);
742 if (rg.cap[i][K_] != -1.0) f.gcap = std::min(f.gcap, rg.cap[i][K_]);
743 if (i < rg.maxmem.size() && rg.maxmem[i] != -1.0)
744 f.memcap = std::min(f.memcap, rg.maxmem[i]);
745 }
746 for (std::size_t r = 0; r < K_ && r < rg.size.size(); ++r)
747 f.sz[r] = num_traits<T>::to_double(rg.size[r]);
748 for (std::size_t r = 0; r < K_ && r < rg.rule.size(); ++r) {
749 const qn::DropStrategy d = rg.rule[r];
750 if (d == qn::DropStrategy::BAS || d == qn::DropStrategy::BBS ||
751 d == qn::DropStrategy::RSRD)
752 throw UnsupportedError(
753 "solver_ssa_nrm: finite capacity region '" + rg.name + "' declares a "
754 "blocking rule for class '" + sn_.classes[r].name + "'; only DROP and WAITQ "
755 "are region rules in any codebase's NRM");
756 f.waitq[r] = d != qn::DropStrategy::DROP;
757 if (f.waitq[r]) fcr_any_waitq_ = true;
758 }
759 if (rg.lincon_A.rows() > 0) {
760 f.A = Matrix<double>(rg.lincon_A.rows(), rg.lincon_A.cols(), 0.0);
761 for (std::size_t a = 0; a < rg.lincon_A.rows(); ++a)
762 for (std::size_t c = 0; c < rg.lincon_A.cols(); ++c)
763 f.A(a, c) = num_traits<T>::to_double(rg.lincon_A(a, c));
764 for (const T& v : rg.lincon_b) f.b.push_back(num_traits<T>::to_double(v));
765 }
766 fcr_.push_back(f);
767 }
768}
769
770/**
771 * `fcrRefusingRegion`: the 1-based index of the FIRST region that refuses a
772 * class-`dst_class` job entering `dst_node` having just left `src_node` as
773 * `src_class`, or 0 when every region admits it.
774 *
775 * Only a region holding the destination can refuse; a source in the same region
776 * frees its slot first. `src_node == npos` is a WAITQ release, whose job already
777 * left its source when it was parked, so no slot is freed.
778 */
779template <class T>
780std::size_t NrmEngine<T>::fcr_refusing_region(const std::vector<double>& X, std::size_t src_node,
781 std::size_t src_class, std::size_t dst_node,
782 std::size_t dst_class) const {
783 for (std::size_t f = 0; f < fcr_.size(); ++f) {
784 const FcrRegion& g = fcr_[f];
785 if (!g.member_node[dst_node]) continue;
786 std::vector<double> x(K_, 0.0);
787 for (std::size_t jnd = 0; jnd < I_; ++jnd)
788 if (g.member_node[jnd])
789 for (std::size_t r = 0; r < K_; ++r) x[r] += class_pop(X, jnd, r);
790 if (src_node != npos && g.member_node[src_node]) x[src_class] -= 1.0;
791 x[dst_class] += 1.0;
792 double tot = 0.0, mem = 0.0;
793 bool bad = false;
794 for (std::size_t r = 0; r < K_; ++r) {
795 tot += x[r];
796 mem += x[r] * g.sz[r];
797 if (x[r] > g.ccap[r]) bad = true;
798 }
799 if (tot > g.gcap || mem > g.memcap) bad = true;
800 for (std::size_t a = 0; !bad && a < g.A.rows() && a < g.b.size(); ++a) {
801 double lhs = 0.0;
802 for (std::size_t c = 0; c < K_ && c < g.A.cols(); ++c) lhs += g.A(a, c) * x[c];
803 if (lhs > g.b[a]) bad = true;
804 }
805 if (bad) return f + 1;
806 }
807 return 0;
808}
809
810template <class T>
811const typename qn::NetworkStruct<T>::PasParam& NrmEngine<T>::pas_of(std::size_t ist) const {
812 const auto it = sn_.pasparam.find(ist + 1);
813 if (it == sn_.pasparam.end() || !it->second.svc_rate_fun)
814 throw InputError("solver_ssa_nrm: PAS/OI station '" + sn_.stations[ist].name +
815 "' has no service rate function mu(c); set one with set_pas");
816 return it->second;
817}
818
819template <class T>
820std::vector<double> NrmEngine<T>::pas_increments(std::size_t ist,
821 const std::vector<std::size_t>& c) const {
822 const auto& mu = pas_of(ist).svc_rate_fun;
823 std::vector<double> inc(c.size(), 0.0);
824 std::vector<std::size_t> prefix;
825 double prev = 0.0;
826 for (std::size_t p = 0; p < c.size(); ++p) {
827 prefix.push_back(c[p] + 1); // mu(c) reads 1-based class indices
828 const double cur = num_traits<T>::to_double(mu(prefix));
829 inc[p] = cur - prev;
830 prev = cur;
831 }
832 return inc;
833}
834
835template <class T>
836double NrmEngine<T>::pas_rate(std::size_t ist, const std::vector<std::size_t>& c,
837 std::size_t r) const {
838 if (c.empty()) return 0.0;
839 const std::vector<double> inc = pas_increments(ist, c);
840 std::vector<std::size_t> c1(c.size());
841 for (std::size_t p = 0; p < c.size(); ++p) c1[p] = c[p] + 1;
842 const auto& G = pas_of(ist).swap_graph;
843 double rt = 0.0;
844 for (std::size_t p = 0; p < c.size(); ++p) {
845 if (inc[p] <= 0.0) continue; // position p receives no service
846 if (qn::pass_and_swap<T>(c1, p, G).second == r + 1) rt += inc[p];
847 }
848 return rt;
849}
850
851template <class T>
852double NrmEngine<T>::pas_in_service(std::size_t ist, const std::vector<std::size_t>& c,
853 std::size_t r) const {
854 if (c.empty()) return 0.0;
855 const std::vector<double> inc = pas_increments(ist, c);
856 double n = 0.0;
857 for (std::size_t p = 0; p < c.size(); ++p)
858 if (inc[p] > 0.0 && c[p] == r) n += 1.0;
859 return n;
860}
861
862template <class T>
863void NrmEngine<T>::pas_tag_started(std::size_t ind, const std::vector<std::size_t>& cold,
864 const std::vector<std::size_t>& cnew) {
865 const std::size_t ist = to_station_[ind];
866 const std::vector<double> inc_new = pas_increments(ist, cnew);
867 const std::vector<double> inc_old = pas_increments(ist, cold);
868 for (std::size_t p = 0; p < inc_new.size(); ++p) {
869 if (inc_new[p] <= 0.0) continue;
870 if (p < inc_old.size() && inc_old[p] > 0.0) continue; // already served
871 tag(ind, cnew[p], false);
872 }
873}
874
875/**
876 * The completing position is drawn among those whose pass-and-swap ejects class
877 * r, weighted by that position's own Delta_mu -- the split whose total is the
878 * propensity that just fired.
879 */
880template <class T>
881void NrmEngine<T>::pas_depart(std::size_t ind, std::vector<std::size_t>& c, std::size_t r) {
882 const std::size_t ist = to_station_[ind];
883 const std::vector<double> inc = pas_increments(ist, c);
884 std::vector<std::size_t> c1(c.size());
885 for (std::size_t p = 0; p < c.size(); ++p) c1[p] = c[p] + 1;
886 const auto& G = pas_of(ist).swap_graph;
887 std::vector<std::size_t> pos;
888 std::vector<double> w;
889 double tot = 0.0;
890 for (std::size_t p = 0; p < c.size(); ++p) {
891 if (inc[p] <= 0.0) continue;
892 if (qn::pass_and_swap<T>(c1, p, G).second != r + 1) continue;
893 pos.push_back(p);
894 w.push_back(inc[p]);
895 tot += inc[p];
896 }
897 if (pos.empty()) return; // this class cannot depart from the current list
898 const double u = rng_.uniform() * tot;
899 double acc = 0.0;
900 std::size_t pick = pos.back();
901 for (std::size_t x = 0; x < pos.size(); ++x) {
902 acc += w[x];
903 if (u < acc) {
904 pick = pos[x];
905 break;
906 }
907 }
908 const std::vector<std::size_t> nc = qn::pass_and_swap<T>(c1, pick, G).first;
909 c.assign(nc.size(), 0);
910 for (std::size_t p = 0; p < nc.size(); ++p) c[p] = nc[p] - 1;
911}
912
913/**
914 * `pollLandCtrl`: SERVING q with the visit budget, SWITCHING into q with the
915 * entry phase drawn from the switchover PH, or PARKED. A landing in SERVING is
916 * the moment a class-q job takes the server, so it raises that START here: the
917 * reference raises none at a polling station at all, which leaves StartN zero
918 * there although jobs plainly start service.
919 */
920template <class T>
921typename NrmEngine<T>::PollCtrl NrmEngine<T>::poll_land(std::size_t ind, std::size_t q, int mode,
922 long budget, bool raise) {
923 PollCtrl c;
924 c.pos = q;
925 c.mode = mode;
926 if (mode == 1) {
927 c.ctr = budget;
928 if (raise) tag(ind, q - 1, false);
929 } else if (mode == 2) {
930 const std::vector<T>& pie = poll_info_[ind].sw_pie[q - 1];
931 std::vector<double> w(pie.size());
932 for (std::size_t k = 0; k < pie.size(); ++k) w[k] = num_traits<T>::to_double(pie[k]);
933 c.swk = rng_.draw(w);
934 }
935 return c;
936}
937
938/**
939 * What a region refusal does to the firing, by the refusing region's rule for
940 * the destination class.
941 *
942 * WAITQ parks the job in that region's FIFO: it leaves its source and joins no
943 * station until `fcr_release_cascade` admits it. DROP LOSES an open job, as a
944 * balked arrival is lost. A CLOSED job refused under DROP is BLOCKED instead --
945 * the firing is cancelled and the job keeps its place -- which is this
946 * codebase's reading of the rule (SolverCTMC censors the transition and the
947 * serial engine does not take it, so the population is conserved). The
948 * reference's NRM, CTMC and serial engines instead all lose the closed job, so
949 * the population decays until the region can never refuse again: 1 job of 3 on
950 * the capped three-station cycle. That cross-language divergence belongs to the
951 * rule, not to this engine; see `_kb/07-cross-language-parity.md`.
952 */
953template <class T>
954void NrmEngine<T>::fcr_outcome(const std::vector<double>& X, const Rx& x, std::size_t dst_node,
955 std::size_t dst_class,
956 std::vector<std::vector<std::size_t>>& fcr_buf, bool& lost,
957 bool& blocked) const {
958 const std::size_t fref = fcr_refusing_region(X, x.node, x.cls, dst_node, dst_class);
959 if (fref == 0) return;
960 if (fcr_[fref - 1].waitq[dst_class]) {
961 lost = true;
962 fcr_buf[fref - 1].push_back(dst_node * K_ + dst_class);
963 } else if (std::isinf(sn_.classes[dst_class].population)) {
964 lost = true;
965 } else {
966 blocked = true;
967 }
968}
969
970/**
971 * `fcrReleaseCascade`: strict-FIFO head-of-line release of the parked WAITQ
972 * jobs. Each region's head is admitted while its constraints permit, joining the
973 * destination as a routed arrival does (entry phase drawn at release, buffer
974 * join, START tag); the loop repeats until a full pass frees nothing, so a
975 * release that relieves another region cascades. Returns the jobs released.
976 */
977template <class T>
978std::size_t NrmEngine<T>::fcr_release_cascade(std::vector<double>& X,
979 std::vector<std::vector<std::size_t>>& bufs,
980 std::vector<std::vector<std::size_t>>& fcr_buf,
981 std::vector<Matrix<double>>& svc,
982 bool& svc_changed) {
983 std::size_t released = 0;
984 bool progress = true;
985 while (progress) {
986 progress = false;
987 for (std::size_t f = 0; f < fcr_buf.size(); ++f) {
988 if (fcr_buf[f].empty()) continue;
989 const std::size_t tok = fcr_buf[f].front();
990 const std::size_t jnd = tok / K_, r = tok % K_;
991 if (fcr_refusing_region(X, npos, r, jnd, r) != 0) continue; // head-of-line
992 // A buffered-PH destination counts the job in its class total slot;
993 // whether and in which phase it enters service is decided against the
994 // server occupancy by `apply_arrival_buffer`, as for a routed arrival.
995 const std::size_t ke = buf_ph_node_[jnd] ? 0 : draw_entry_phase(jnd, r);
996 X[phoff_[jnd][r] + ke] += 1.0;
997 apply_arrival_buffer(jnd, r, X, bufs, svc, svc_changed);
998 fcr_buf[f].erase(fcr_buf[f].begin());
999 ++released;
1000 progress = true;
1001 }
1002 }
1003 return released;
1004}
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(
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(
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` defaults
1317 * to 2, this port carrying no per-node override for it.
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::size_t dsample = std::min<std::size_t>(2, nd);
1350 double best = 0.0;
1351 for (std::size_t t = 0; t < dsample; ++t) {
1352 const std::size_t cand =
1353 std::min(nd - 1, std::size_t(rng_.uniform() * double(nd)));
1354 const std::vector<double> cc = class_counts(X, x.sd_node[cand]);
1355 double load = 0.0;
1356 for (double v : cc) load += v;
1357 if (t == 0 || load < best) {
1358 best = load;
1359 pick = cand;
1360 }
1361 }
1362 } else if (x.sd_strategy == lang::RoutingStrategy::WRROBIN) {
1363 // The pointer is spent in proportion to the declared weights: candidate
1364 // d owns the slice [sum w_{<d}, sum w_{<=d}) of one full turn.
1365 const std::size_t c = rr_cursor_[cursor_key];
1366 const double u = double(c % nd) / double(nd);
1367 double acc = 0.0;
1368 pick = nd - 1;
1369 for (std::size_t d = 0; d < nd; ++d) {
1370 acc += x.sd_weight[d];
1371 if (u < acc - 1e-12) {
1372 pick = d;
1373 break;
1374 }
1375 }
1376 rr_cursor_[cursor_key] = c + 1;
1377 } else {
1378 pick = rr_cursor_[cursor_key] % nd;
1379 rr_cursor_[cursor_key] = rr_cursor_[cursor_key] + 1;
1380 }
1381 // the entry phase, drawn within the chosen destination
1382 const std::vector<std::size_t>& slots = x.sd_slots[pick];
1383 if (slots.size() == 1) return slots[0];
1384 const double u = rng_.uniform();
1385 double acc = 0.0;
1386 for (std::size_t k = 0; k + 1 < slots.size(); ++k) {
1387 acc += x.sd_pie[pick][k];
1388 if (u < acc) return slots[k];
1389 }
1390 return slots.back();
1391}
1392
1393/**
1394 * The dependency sets, `solver_ssa_nrm.m` lines 1003-1050.
1395 *
1396 * A reaction's firing changes the slots its stoichiometry touches; every rate
1397 * law reads its node's WHOLE class-count vector (and, for the per-phase share,
1398 * the sibling phases of its own class), so the affected set is every slot of
1399 * every touched node, and the reactions to refresh are those that consume from
1400 * one of those slots.
1401 */
1402template <class T>
1403void NrmEngine<T>::build_dependencies() {
1404 const std::size_t nrx = rx_.size();
1405 D_.assign(nrx, std::vector<std::size_t>());
1406 for (std::size_t k = 0; k < nrx; ++k) {
1407 std::vector<bool> touched_node(I_, false);
1408 for (std::size_t i = 0; i < NS_; ++i)
1409 if (S_(i, k) != 0.0) touched_node[slot_node_[i]] = true;
1410 std::vector<bool> hit(nrx, false);
1411 for (std::size_t ind = 0; ind < I_; ++ind) {
1412 if (!touched_node[ind]) continue;
1413 for (std::size_t r = 0; r < K_; ++r)
1414 for (std::size_t p = 0; p < nph_[ind][r]; ++p) {
1415 const std::size_t slot = phoff_[ind][r] + p;
1416 for (std::size_t j = 0; j < nrx; ++j)
1417 if (S_(slot, j) < 0.0) hit[j] = true;
1418 }
1419 }
1420 for (std::size_t j = 0; j < nrx; ++j)
1421 if (hit[j]) D_[k].push_back(j);
1422 }
1423}
1424
1425/**
1426 * The initial state.
1427 *
1428 * THIS PORT HAS NO State PACKAGE, so the reference's `sn.state` cannot be read;
1429 * the same position `solver_fluid` takes applies here and for the same reason.
1430 * A closed class starts entirely at its reference station and an open class
1431 * puts the single fictitious token at the Source that the EXT rate law needs
1432 * (the reference substitutes exactly that for the infinite marginal). The chain
1433 * is ergodic, so the steady-state averages this engine reports do not depend on
1434 * the choice; a TRANSIENT would, which is why `getTranAvg` is not offered.
1435 */
1436template <class T>
1437void NrmEngine<T>::build_initial_state() {
1438 nvec0_.assign(NS_, 0.0);
1439 // The cache contents: the declared warm start when there is one (its row
1440 // after the per-class counts), else slot i holding item i, as the reference
1441 // NRM seeds it; with a retrieval system blocks A and B follow, empty.
1442 cache_var0_.assign(I_, std::vector<T>());
1443 for (std::size_t ind = 0; ind < I_; ++ind) {
1444 if (!is_cache_node_[ind]) continue;
1445 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
1446 std::size_t tcc = 0;
1447 for (int c : cp.itemcap)
1448 if (c > 0) tcc += static_cast<std::size_t>(c);
1449 std::vector<std::size_t> rcl, rci, rco;
1450 const bool retr = cp.retrieval_capacity > 0 && !cp.retrieval_classes.empty();
1451 if (retr) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
1452 std::vector<T>& v = cache_var0_[ind];
1453 if (cp.initstate.size() > K_) {
1454 v.assign(cp.initstate.begin() + static_cast<std::ptrdiff_t>(K_), cp.initstate.end());
1455 } else {
1456 for (std::size_t c = 0; c < tcc; ++c)
1457 v.push_back(num_traits<T>::from_int(static_cast<long>(c + 1)));
1458 }
1459 const std::size_t want = tcc + (retr ? cp.nitems + rcl.size() : 0);
1460 v.resize(want, num_traits<T>::from_int(0));
1461 }
1462 buffers0_.assign(I_, std::vector<std::size_t>());
1463 svcph0_.assign(I_, Matrix<double>());
1464 for (std::size_t i = 0; i < I_; ++i)
1465 if (buf_ph_node_[i]) svcph0_[i] = Matrix<double>(K_, maxnph_, 0.0);
1466
1467 std::vector<std::vector<double>> nir(I_, std::vector<double>(K_, 0.0));
1468 for (std::size_t r = 0; r < K_; ++r) {
1469 const double pop = sn_.classes[r].population;
1470 if (std::isinf(pop)) {
1471 if (sn_.sourceIdx == 0)
1472 throw InputError(
1473 "solver_ssa_nrm: the open class '" + sn_.classes[r].name +
1474 "' has no Source; an open model must carry one for the arrival reaction");
1475 nir[sn_.station_to_node[sn_.sourceIdx - 1] - 1][r] = 1.0;
1476 } else if (pop > 0.0) {
1477 const std::size_t rs = sn_.classes[r].refstat;
1478 if (rs < 1 || rs > M_)
1479 throw InputError("solver_ssa_nrm: class '" + sn_.classes[r].name +
1480 "' has no reference station");
1481 nir[sn_.station_to_node[rs - 1] - 1][r] = pop;
1482 }
1483 }
1484
1485 for (std::size_t ind = 0; ind < I_; ++ind) {
1486 for (std::size_t r = 0; r < K_; ++r) {
1487 const double n = nir[ind][r];
1488 if (n <= 0.0) continue;
1489 if (nph_[ind][r] <= 1 || buf_ph_class_[ind][r]) {
1490 nvec0_[phoff_[ind][r]] = n;
1491 } else {
1492 double left = n;
1493 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
1494 const double take = (ke + 1 == nph_[ind][r])
1495 ? left
1496 : std::min(left, std::round(n * pie_[ind][r][ke]));
1497 nvec0_[phoff_[ind][r] + ke] = take;
1498 left -= take;
1499 }
1500 }
1501 }
1502 // A PAS / OI list holds EVERY job, oldest first, in class order.
1503 if (is_station_[ind] && detail::sched_is_list(sched_[to_station_[ind]]))
1504 for (std::size_t r = 0; r < K_; ++r)
1505 for (std::size_t c = 0; c < static_cast<std::size_t>(nir[ind][r]); ++c)
1506 buffers0_[ind].push_back(r);
1507 // The buffer holds exactly the waiting jobs, `numel(buf) == max(0,
1508 // total - mi)`; which classes they are is immaterial to the steady
1509 // state, so they are taken in class order.
1510 if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]])) {
1511 double total = 0.0;
1512 for (std::size_t r = 0; r < K_; ++r) total += nir[ind][r];
1513 double waiting = std::max(0.0, total - mi_[ind]);
1514 for (std::size_t r = 0; r < K_ && waiting > 0.0; ++r) {
1515 double take = std::min(waiting, nir[ind][r]);
1516 for (std::size_t c = 0; c < static_cast<std::size_t>(take); ++c)
1517 buffers0_[ind].push_back(r);
1518 waiting -= take;
1519 }
1520 }
1521 if (!buf_ph_node_[ind]) continue;
1522 for (std::size_t r = 0; r < K_; ++r) {
1523 double waiting_r = 0.0;
1524 for (std::size_t c : buffers0_[ind])
1525 if (c == r) waiting_r += 1.0;
1526 const double insvc = std::max(0.0, nir[ind][r] - waiting_r);
1527 if (nph_[ind][r] <= 1) {
1528 svcph0_[ind](r, 0) = insvc;
1529 } else {
1530 double left = insvc;
1531 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
1532 const double take = (ke + 1 == nph_[ind][r])
1533 ? left
1534 : std::min(left, std::round(insvc * pie_[ind][r][ke]));
1535 svcph0_[ind](r, ke) = take;
1536 left -= take;
1537 }
1538 }
1539 }
1540 }
1541}
1542
1543// ---------------------------------------------------------------------------
1544// The rate laws
1545// ---------------------------------------------------------------------------
1546
1547/**
1548 * The propensity of reaction `j`, `solver_ssa_nrm.m` lines 795-948.
1549 *
1550 * The reference builds one closure per reaction; this is the same switch
1551 * evaluated on demand, which avoids an indirect call per reaction per step and
1552 * cannot drift from the utilization accumulators that reuse the same sharing
1553 * factors.
1554 */
1555template <class T>
1556double NrmEngine<T>::propensity(std::size_t j, const std::vector<double>& X,
1557 const std::vector<std::vector<std::size_t>>& bufs,
1558 const std::vector<Matrix<double>>& svc) const {
1559 const Rx& x = rx_[j];
1560 const std::size_t ind = x.node, r = x.cls;
1561 if (!is_station_[ind])
1562 return x.rate * kir_frac(X, x.from, ind, r) * std::min(1.0, class_pop(X, ind, r));
1563
1564 const std::size_t ist = to_station_[ind];
1565 const double eps = lang::GlobalConstants::Zero;
1566
1567 // `pollSwRate`: the total leaving rate -D0(swk,swk) of the switchover phase
1568 // the walking server occupies; which transition it takes is drawn at firing.
1569 if (x.is_sw) {
1570 const PollCtrl& c = poll_ctrl_[ind];
1571 if (c.mode != 2) return 0.0;
1572 return -num_traits<T>::to_double(poll_info_[ind].sw_d0[c.pos - 1](c.swk, c.swk));
1573 }
1574
1575 // Buffered phase-type service: the rate reads the in-service multiset, not
1576 // the class total, which also counts the jobs still waiting.
1577 if (buf_ph_class_[ind][r]) {
1578 const std::size_t kk = x.is_phase ? x.phase_from : x.dep_phase;
1579 const std::vector<double> n = class_counts(X, ind);
1580 double tot = 0.0;
1581 for (double v : n) tot += v;
1582 return x.rate * svc[ind](r, kk) * lldfac(ist, tot) * cdfac(ist, n, r);
1583 }
1584
1585 const double kf = kir_frac(X, x.from, ind, r);
1586 switch (sched_[ist]) {
1587 case SchedStrategy::EXT:
1588 // A Source fires at a constant arrival rate: its token is
1589 // fictitious, so applying the population share would silence it and
1590 // deadlock every open model.
1591 return x.rate;
1592 case SchedStrategy::INF:
1593 return x.rate * kf * class_pop(X, ind, r) * cdfac(ist, class_counts(X, ind), r);
1594 case SchedStrategy::PS:
1595 case SchedStrategy::LPS: {
1596 if (K_ == 1)
1597 return x.rate * kf * std::min(mi_[ind], class_pop(X, ind, r)) *
1598 lldfac(ist, class_pop(X, ind, r)) * cdfac(ist, class_counts(X, ind), r);
1599 const std::vector<double> n = class_counts(X, ind);
1600 double tot = 0.0;
1601 for (double v : n) tot += v;
1602 return x.rate * kf * (n[r] / (eps + tot)) * std::min(mi_[ind], eps + tot) *
1603 lldfac(ist, tot) * cdfac(ist, n, r);
1604 }
1605 case SchedStrategy::DPS: {
1606 const std::vector<double> n = class_counts(X, ind);
1607 double tot = 0.0;
1608 for (double v : n) tot += v;
1609 return x.rate * kf * dpsshare(wnorm_[ist], n, r) * lldfac(ist, tot) *
1610 cdfac(ist, n, r);
1611 }
1612 case SchedStrategy::GPS: {
1613 const std::vector<double> n = class_counts(X, ind);
1614 double tot = 0.0;
1615 for (double v : n) tot += v;
1616 return x.rate * kf * gpsshare(wnorm_[ist], n, r) * lldfac(ist, tot) *
1617 cdfac(ist, n, r);
1618 }
1619 // The three PRIORITY sharing disciplines. Below the server count each
1620 // is its non-priority twin; above it only the most urgent non-empty
1621 // group shares the servers. Note that PSPRIO reads the lld factor at
1622 // the FULL population and the other two at the priority-restricted one
1623 // -- `prio_pop` carries that asymmetry, which comes from the reference.
1624 case SchedStrategy::PSPRIO: {
1625 const std::vector<double> n = class_counts(X, ind);
1626 double tot = 0.0;
1627 for (double v : n) tot += v;
1628 return x.rate * kf * psprioshare(n, r, mi_[ind]) * lldfac(ist, tot) *
1629 cdfac(ist, n, r);
1630 }
1631 case SchedStrategy::DPSPRIO: {
1632 const std::vector<double> n = class_counts(X, ind);
1633 return x.rate * kf * dpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1634 lldfac(ist, prio_pop(n, r, mi_[ind])) *
1635 cdfac(ist, prio_vec(n, r, mi_[ind]), r);
1636 }
1637 case SchedStrategy::GPSPRIO: {
1638 const std::vector<double> n = class_counts(X, ind);
1639 return x.rate * kf * gpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1640 lldfac(ist, prio_pop(n, r, mi_[ind])) *
1641 cdfac(ist, prio_vec(n, r, mi_[ind]), r);
1642 }
1643 case SchedStrategy::FCFS:
1644 case SchedStrategy::LCFS:
1645 case SchedStrategy::SIRO:
1646 case SchedStrategy::HOL:
1647 case SchedStrategy::SEPT:
1648 case SchedStrategy::LEPT:
1649 case SchedStrategy::LCFSPR: {
1650 // Invariant: numel(buf) == max(0, total - mi), so the jobs in
1651 // service are the class population minus its share of the buffer.
1652 double waiting = 0.0;
1653 for (std::size_t c : bufs[ind])
1654 if (c == r) waiting += 1.0;
1655 const std::vector<double> n = class_counts(X, ind);
1656 double tot = 0.0;
1657 for (double v : n) tot += v;
1658 return x.rate * kf * std::max(0.0, n[r] - waiting) * lldfac(ist, tot) *
1659 cdfac(ist, n, r);
1660 }
1661 case SchedStrategy::POLLING: {
1662 // One server serving one job, of the class its controller attends:
1663 // the class-r departure fires only while the controller SERVES r, at
1664 // the plain rate, never scaled by the class population.
1665 const PollCtrl& c = poll_ctrl_[ind];
1666 if (c.mode != 1 || c.pos != r + 1) return 0.0;
1667 const std::vector<double> n = class_counts(X, ind);
1668 double tot = 0.0;
1669 for (double v : n) tot += v;
1670 return x.rate * lldfac(ist, tot) * cdfac(ist, n, r);
1671 }
1672 case SchedStrategy::PAS:
1673 case SchedStrategy::OI:
1674 // Position p is served at Delta_mu(c1..cp) and pass-and-swap
1675 // decides which class that completion ejects, so the class-r rate
1676 // is the total increment over the positions that eject class r.
1677 return pas_rate(ist, bufs[ind], r);
1678 default:
1679 throw UnsupportedError(
1680 "solver_ssa_nrm: the scheduling policy '" +
1681 std::string(lang::sched_to_text(sched_[ist])) + "' at station '" +
1682 sn_.stations[ist].name + "' has no NRM rate law in this port");
1683 }
1684}
1685
1686// ---------------------------------------------------------------------------
1687// Buffer maintenance
1688// ---------------------------------------------------------------------------
1689
1690/** `pickFromBuffer`: the waiting job the discipline promotes, newest-first buffer. */
1691template <class T>
1692std::size_t NrmEngine<T>::pick_from_buffer(const std::vector<std::size_t>& buf, std::size_t ist) {
1693 switch (sched_[ist]) {
1694 case SchedStrategy::FCFS:
1695 return buf.size() - 1; // oldest
1696 case SchedStrategy::LCFS:
1697 case SchedStrategy::LCFSPR:
1698 return 0; // newest, or the most recently preempted
1699 case SchedStrategy::SIRO:
1700 return rng_.index(buf.size());
1701 case SchedStrategy::HOL: {
1702 // Highest priority (lowest value), FCFS within the group, so the
1703 // oldest is the LAST matching position.
1704 double best = std::numeric_limits<double>::infinity();
1705 for (std::size_t c : buf) best = std::min(best, classprio_[c]);
1706 for (std::size_t p = buf.size(); p-- > 0;)
1707 if (classprio_[buf[p]] == best) return p;
1708 return buf.size() - 1;
1709 }
1710 case SchedStrategy::SEPT:
1711 case SchedStrategy::LEPT: {
1712 // schedparam carries the rank of the class's mean service time,
1713 // ascending for SEPT and descending for LEPT, so both promote the
1714 // waiting class of least rank.
1715 double best = std::numeric_limits<double>::infinity();
1716 for (std::size_t c : buf)
1717 best = std::min(best, num_traits<T>::to_double(sn_.stations[ist].schedparam[c]));
1718 for (std::size_t p = buf.size(); p-- > 0;)
1719 if (num_traits<T>::to_double(sn_.stations[ist].schedparam[buf[p]]) == best)
1720 return p;
1721 return buf.size() - 1;
1722 }
1723 default:
1724 throw UnsupportedError("solver_ssa_nrm: '" +
1725 std::string(lang::sched_to_text(sched_[ist])) +
1726 "' is not a buffered policy this port promotes from");
1727 }
1728}
1729
1730/** `drawEntryPhase`: the phase a job starts service in, drawn from pie. */
1731template <class T>
1732std::size_t NrmEngine<T>::draw_entry_phase(std::size_t ind, std::size_t r) {
1733 if (nph_[ind][r] <= 1) return 0;
1734 return rng_.draw(pie_[ind][r]);
1735}
1736
1737/**
1738 * `capacityLoss`: an OPEN arrival at a full physically-capped station is lost.
1739 *
1740 * A refused CLOSED job must block rather than vanish from the conserved
1741 * population, so it is not dropped here; `State.arrivalIsLost` draws that line
1742 * on the class type and this reproduces it. `isPhysicalCapacity` is the drop
1743 * rule test -- a bound that exists only as a state-space cutoff keeps the WAITQ
1744 * default and must not become a loss.
1745 */
1746template <class T>
1747bool NrmEngine<T>::capacity_loss(const std::vector<double>& X, std::size_t slot) const {
1748 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1749 if (!is_station_[jnd]) return false;
1750 const std::size_t ist = to_station_[jnd];
1751 const qn::DropStrategy dr = sn_.droprule[ist][dst];
1752 if (dr == qn::DropStrategy::WAITQ) return false; // cutoff-only, or unset
1753 if (dr == qn::DropStrategy::BAS || dr == qn::DropStrategy::BBS ||
1754 dr == qn::DropStrategy::RSRD)
1755 throw UnsupportedError("solver_ssa_nrm: station '" + sn_.stations[ist].name +
1756 "' declares the blocking drop rule for class '" +
1757 sn_.classes[dst].name +
1758 "'; blocking-after-service is not ported to the C++ NRM");
1759 if (!std::isinf(sn_.classes[dst].population)) return false; // closed: block, never lose
1760 const std::vector<double> cc = class_counts(X, jnd);
1761 double tot = 0.0;
1762 for (double v : cc) tot += v;
1763 if (!std::isinf(sn_.cap[ist]) && tot >= sn_.cap[ist]) return true;
1764 const double ccap = sn_.classcap[ist][dst];
1765 return ccap > 0.0 && !std::isinf(ccap) && cc[dst] >= ccap;
1766}
1767
1768/**
1769 * `capacityBlock`: a refused arrival that may NOT be dropped cancels the firing.
1770 *
1771 * `capacity_loss` above answers the OPEN half of the same question and returns
1772 * false for a closed class precisely because a closed network's population is an
1773 * invariant. Nothing then stopped the reaction, so the NRM fired into the full
1774 * station anyway and reported the UNCONSTRAINED answer (BUG-81): on a closed
1775 * 3-queue tandem, N=6, Q2 capped at 2, QLen came back [1.96 2.07 1.97] against
1776 * the exact [3.609 0.971 1.420] -- a mean of 2.07 at a station that holds 2 --
1777 * while the serial engine, whose producer already implements the contract, gave
1778 * the exact answer.
1779 *
1780 * Blocking is the THIRD outcome of a firing, next to moving the job and losing
1781 * it. The departure does not occur, the source is not decremented, no buffer
1782 * moves; only the reaction's own clock is redrawn, which is exact by
1783 * memorylessness -- the residual of an exponential, or of the current PH phase,
1784 * is that same exponential. It is what `solver_ctmc` does when the target state
1785 * is absent from the enumerated space and the arc is dropped, which is why the
1786 * two agree.
1787 *
1788 * The population read is the PRE-arrival one, minus the departing job when it
1789 * currently sits at the destination node: a self-loop or a feedback arc at a
1790 * station already at cap would otherwise block itself forever, while the
1791 * reference producer sees the state AFTER the departure half.
1792 */
1793template <class T>
1794bool NrmEngine<T>::capacity_block(const std::vector<double>& X, std::size_t slot,
1795 std::size_t src_slot) const {
1796 if (!blk_on_) return false;
1797 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1798 if (!is_station_[jnd]) return false;
1799 const std::size_t ist = to_station_[jnd];
1800 if (!blk_can_[ist][dst]) return false;
1801 const bool same_node = src_slot != npos && slot_node_[src_slot] == jnd;
1802 const std::vector<double> cc = class_counts(X, jnd);
1803 if (!std::isinf(sn_.cap[ist])) {
1804 double tot = 0.0;
1805 for (double v : cc) tot += v;
1806 if (same_node) tot -= 1.0;
1807 if (tot >= sn_.cap[ist]) return true;
1808 }
1809 const double ccap = sn_.classcap[ist][dst];
1810 if (ccap > 0.0 && !std::isinf(ccap)) {
1811 double pop = cc[dst];
1812 if (same_node && slot_class_[src_slot] == dst) pop -= 1.0;
1813 if (pop >= ccap) return true;
1814 }
1815 return false;
1816}
1817
1818/** `pickPreempted`: the incumbent an LCFSPR arrival displaces, by server occupancy. */
1819template <class T>
1820std::size_t NrmEngine<T>::pick_preempted(const std::vector<double>& X,
1821 const std::vector<std::size_t>& buf, std::size_t jnd,
1822 std::size_t arr_class) {
1823 std::vector<double> insvc = class_counts(X, jnd);
1824 for (std::size_t r = 0; r < K_; ++r) {
1825 for (std::size_t c : buf)
1826 if (c == r) insvc[r] -= 1.0;
1827 if (r == arr_class) insvc[r] -= 1.0; // the job that just arrived
1828 if (insvc[r] < 0.0) insvc[r] = 0.0;
1829 }
1830 double tot = 0.0;
1831 for (double v : insvc) tot += v;
1832 if (tot <= 0.0) return npos;
1833 const double u = rng_.uniform() * tot;
1834 double acc = 0.0;
1835 std::size_t last = npos;
1836 for (std::size_t r = 0; r < K_; ++r) {
1837 if (!(insvc[r] > 0.0)) continue;
1838 last = r;
1839 acc += insvc[r];
1840 if (u < acc) return r;
1841 }
1842 return last;
1843}
1844
1845/** `applyArrivalBuffer`: join a just-arrived class-s job to node jnd's buffer. */
1846template <class T>
1847void NrmEngine<T>::apply_arrival_buffer(std::size_t jnd, std::size_t s,
1848 const std::vector<double>& X,
1849 std::vector<std::vector<std::size_t>>& bufs,
1850 std::vector<Matrix<double>>& svc, bool& svc_changed) {
1851 if (is_station_[jnd] && detail::sched_is_list(sched_[to_station_[jnd]])) {
1852 // PAS / OI: the arrival joins the BACK of the ordered list; there is no
1853 // server/buffer split. An arrival past the station's cap was already
1854 // lost or blocked by the capacity gates before it got here.
1855 const std::vector<std::size_t> old = bufs[jnd];
1856 bufs[jnd].push_back(s);
1857 pas_tag_started(jnd, old, bufs[jnd]);
1858 return;
1859 }
1860 // Polling: the controller, not the arrival, opens a service (`poll_land`).
1861 if (!is_poll_.empty() && is_poll_[jnd]) return;
1862 if (!is_station_[jnd] || !detail::sched_is_buffered(sched_[to_station_[jnd]])) {
1863 // A station that holds no waiting line serves every job it admits at
1864 // once (INF, PS, LPS, DPS, GPS), so the arrival IS the service start.
1865 if (is_station_[jnd]) tag(jnd, s, false);
1866 return;
1867 }
1868 const std::vector<double> cc = class_counts(X, jnd);
1869 double total = 0.0;
1870 for (double v : cc) total += v;
1871 bool entered = false;
1872 if (total > mi_[jnd]) {
1873 if (sched_[to_station_[jnd]] == SchedStrategy::LCFSPR) {
1874 // Preempt-resume: the arrival seizes a server and the incumbent it
1875 // displaces is the one that joins the buffer, which is what leaves
1876 // the new job in service (in-service is population minus buffer).
1877 const std::size_t c = pick_preempted(X, bufs[jnd], jnd, s);
1878 if (c != npos) {
1879 bufs[jnd].insert(bufs[jnd].begin(), c);
1880 tag(jnd, c, true); // the incumbent is pushed back into the buffer
1881 }
1882 entered = true;
1883 } else {
1884 bufs[jnd].insert(bufs[jnd].begin(), s); // it waits: it starts nothing
1885 }
1886 } else {
1887 entered = true;
1888 }
1889 if (entered) tag(jnd, s, false); // the arrival seized a server
1890 if (entered && buf_ph_node_[jnd]) {
1891 svc[jnd](s, draw_entry_phase(jnd, s)) += 1.0;
1892 svc_changed = true;
1893 }
1894}
1895
1896/**
1897 * `cacheAccess`: one READ at a cache, drawn from `after_event_cache`.
1898 *
1899 * The reference NRM re-implements State.afterEventCache's simulation branch;
1900 * this port draws from the enumerating port itself instead, so the NRM, the
1901 * serial engine and SolverCTMC read one set of replacement rules. The reading
1902 * job is presented ALONE (the enumerator requires exactly one job at the cache,
1903 * and other jobs at an immediate node are immaterial to a read), and the one
1904 * successor is drawn in proportion to its rate. Its per-class block, against the
1905 * one-hot input, says what happened:
1906 *
1907 * all zero the request MERGED onto an in-flight fetch (block B);
1908 * a retrieval class a fetch BEGINS: the in-flight bit is set, as the
1909 * departure that carries the job out would set it, and
1910 * the job is placed straight at the retrieval station,
1911 * as `cacheRetrDest` does, so that a job at
1912 * [cache, retrieval class] is always an inbound one;
1913 * otherwise a hit or a completed miss, plus the delayed hits the
1914 * completion releases, all at the cache in their classes.
1915 */
1916template <class T>
1917void NrmEngine<T>::cache_fire(const Rx& x, std::vector<double>& X, std::size_t& dest_pos,
1918 bool& have_dest) {
1919 const std::size_t ind = x.node, r = x.cls;
1920 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
1921 std::vector<T> row(K_, num_traits<T>::from_int(0));
1922 row[r] = num_traits<T>::from_int(1);
1923 row.insert(row.end(), cache_var_[ind].begin(), cache_var_[ind].end());
1924 const qn::EventOutcome<T> o =
1925 qn::after_event_cache(sn_, ind + 1, row, lang::EventType::READ, r + 1);
1926 std::vector<double> w(o.space.size(), 0.0);
1927 double tot = 0.0;
1928 for (std::size_t j = 0; j < o.space.size(); ++j) {
1929 w[j] = num_traits<T>::to_double(o.rate[j]) * num_traits<T>::to_double(o.prob[j]);
1930 if (!(w[j] > 0.0)) w[j] = 0.0;
1931 tot += w[j];
1932 }
1933 if (!(tot > 0.0))
1934 throw NumericError("solver_ssa_nrm: the READ of class '" + sn_.classes[r].name +
1935 "' at Cache '" + sn_.nodes[ind].name + "' has no enabled outcome");
1936 const std::vector<T>& nx = o.space[rng_.draw(w)];
1937 X[x.from] -= 1.0;
1938 cache_var_[ind].assign(nx.begin() + static_cast<std::ptrdiff_t>(K_), nx.end());
1939
1940 std::vector<std::size_t> rcl, rci, rco;
1941 if (cp.retrieval_capacity > 0) qn::cache_retrieval_class_map(cp, rcl, rci, rco);
1942 const bool from_retr = std::find(rcl.begin(), rcl.end(), r + 1) != rcl.end();
1943 double produced = 0.0;
1944 for (std::size_t c = 0; c < K_; ++c) produced += num_traits<T>::to_double(nx[c]);
1945 if (produced == 0.0) {
1946 cache_dly_(ind, r) += 1.0; // merged: held in block B until its fetch completes
1947 return;
1948 }
1949 for (std::size_t c = 0; c < K_; ++c) {
1950 const double d = num_traits<T>::to_double(nx[c]);
1951 if (!(d > 0.0)) continue;
1952 const bool begins = !from_retr && std::find(rcl.begin(), rcl.end(), c + 1) != rcl.end();
1953 if (!begins) {
1954 X[phoff_[ind][c]] += d;
1955 cache_prod_(ind, c) += d;
1956 if (!have_dest) {
1957 dest_pos = phoff_[ind][c];
1958 have_dest = true;
1959 }
1960 continue;
1961 }
1962 // BEGIN a fetch. The in-flight bit of the item whose retrieval class c is.
1963 std::size_t tcc = 0;
1964 for (int m : cp.itemcap)
1965 if (m > 0) tcc += static_cast<std::size_t>(m);
1966 for (std::size_t it = 0; it < cp.retrieval_classes.size(); ++it)
1967 if (r < cp.retrieval_classes[it].size() && cp.retrieval_classes[it][r] == c + 1 &&
1968 tcc + it < cache_var_[ind].size())
1969 cache_var_[ind][tcc + it] = num_traits<T>::from_int(1);
1970 // The retrieval station the class routes to, drawn over the routing row
1971 // and the entry phases there.
1972 std::vector<std::size_t> slots;
1973 std::vector<double> sw;
1974 for (std::size_t jnd = 0; jnd < I_; ++jnd)
1975 for (std::size_t s = 0; s < K_; ++s) {
1976 const double p =
1977 num_traits<T>::to_double(sn_.rtnodes(ind * K_ + c, jnd * K_ + s));
1978 if (!(p > 0.0)) continue;
1979 if (buf_ph_class_[jnd][s]) {
1980 slots.push_back(phoff_[jnd][s]);
1981 sw.push_back(p);
1982 continue;
1983 }
1984 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke)
1985 if (pie_[jnd][s][ke] > 0.0) {
1986 slots.push_back(phoff_[jnd][s] + ke);
1987 sw.push_back(p * pie_[jnd][s][ke]);
1988 }
1989 }
1990 if (slots.empty())
1991 throw InputError("solver_ssa_nrm: retrieval class '" + sn_.classes[c].name +
1992 "' at Cache '" + sn_.nodes[ind].name + "' routes nowhere");
1993 const std::size_t slot = slots[rng_.draw(sw)];
1994 X[slot] += 1.0;
1995 dest_pos = slot;
1996 have_dest = true;
1997 }
1998}
1999
2000/**
2001 * The realized hit, miss and delayed-hit shares, as the serial engine reports
2002 * them: every read leaves as exactly one of the three, the released delayed
2003 * hits are carved out of the hit class's production, and the retrieval
2004 * classes, which have no hit class, get no share of their own.
2005 */
2006template <class T>
2007void NrmEngine<T>::cache_write_back() {
2008 cache_out_.clear();
2009 const double nan = std::numeric_limits<double>::quiet_NaN();
2010 for (std::size_t ind = 0; ind < I_; ++ind) {
2011 if (!is_cache_node_[ind]) continue;
2012 const qn::CacheParam<T>& cp = sn_.nodeparam.find(ind + 1)->second;
2013 SsaCacheRatio cr;
2014 cr.node = ind + 1;
2015 cr.hitprob.assign(K_, nan);
2016 cr.missprob.assign(K_, nan);
2017 cr.residt.assign(K_, nan);
2018 std::vector<double> dly(K_, 0.0);
2019 bool any_delayed = false;
2020 for (std::size_t k = 0; k < K_; ++k) {
2021 if (k >= cp.hitclass.size() || k >= cp.missclass.size()) continue;
2022 const std::size_t h = cp.hitclass[k], mi = cp.missclass[k];
2023 if (h == 0 || mi == 0 || h > K_ || mi > K_) continue;
2024 const double th = cache_prod_(ind, h - 1), tm = cache_prod_(ind, mi - 1);
2025 const double td = cache_dly_(ind, k);
2026 if (th + tm > 0.0) {
2027 cr.hitprob[k] = std::max(th - td, 0.0) / (th + tm);
2028 cr.missprob[k] = tm / (th + tm);
2029 dly[k] = td / (th + tm);
2030 if (td > 0.0) any_delayed = true;
2031 }
2032 }
2033 if (any_delayed) cr.delayedprob = dly;
2034 cache_out_.push_back(cr);
2035 }
2036}
2037
2038/** `updateBuffers`: promote on a departure, join on an arrival. */
2039template <class T>
2040void NrmEngine<T>::update_buffers(std::size_t kfire, const std::vector<double>& X,
2041 std::vector<std::vector<std::size_t>>& bufs,
2042 std::size_t dest_pos, bool have_dest,
2043 std::vector<Matrix<double>>& svc, bool& svc_changed) {
2044 const Rx& x = rx_[kfire];
2045 const std::size_t ind = x.node;
2046 // The completing job leaves the phase it occupied; the promotion below
2047 // refills the freed server at a fresh entry phase.
2048 if (buf_ph_node_[ind] && x.is_buf_svc && !x.is_phase) {
2049 svc[ind](x.cls, x.dep_phase) -= 1.0;
2050 svc_changed = true;
2051 }
2052 // A PAS / OI departure is not a promotion but a pass-and-swap rewrite of the
2053 // whole list. Unlike the reference, which returns here, the ARRIVAL half
2054 // below still runs: a PAS station feeding a buffered one must join the job
2055 // to that station's buffer, and returning early would leave it counted in
2056 // the population but absent from the buffer.
2057 if (is_station_[ind] && detail::sched_is_list(sched_[to_station_[ind]]) &&
2058 !bufs[ind].empty() && !x.is_phase) {
2059 const std::vector<std::size_t> old = bufs[ind];
2060 pas_depart(ind, bufs[ind], x.cls);
2061 pas_tag_started(ind, old, bufs[ind]);
2062 } else if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]]) &&
2063 !bufs[ind].empty() && !x.is_phase) {
2064 const std::size_t pos = pick_from_buffer(bufs[ind], to_station_[ind]);
2065 const std::size_t promoted = bufs[ind][pos];
2066 bufs[ind].erase(bufs[ind].begin() + static_cast<std::ptrdiff_t>(pos));
2067 tag(ind, promoted, false); // it takes the server the completion freed
2068 if (buf_ph_node_[ind]) {
2069 svc[ind](promoted, draw_entry_phase(ind, promoted)) += 1.0;
2070 svc_changed = true;
2071 }
2072 }
2073 if (have_dest)
2074 apply_arrival_buffer(slot_node_[dest_pos], slot_class_[dest_pos], X, bufs, svc,
2075 svc_changed);
2076}
2077
2078// ---------------------------------------------------------------------------
2079// The sample path
2080// ---------------------------------------------------------------------------
2081
2082/** `next_reaction_method_direct`: the Gibson and Bruck loop with metrics in line. */
2083template <class T>
2085 const std::size_t nrx = rx_.size();
2086 SsaSolution out;
2087 out.QN = Matrix<double>(M_, K_, 0.0);
2088 out.UN = Matrix<double>(M_, K_, 0.0);
2089 out.RN = Matrix<double>(M_, K_, 0.0);
2090 out.TN = Matrix<double>(M_, K_, 0.0);
2091 out.CN.assign(K_, 0.0);
2092 out.XN.assign(K_, 0.0);
2093 out.StartN = Matrix<double>(M_, K_, 0.0);
2094 out.PreemptN = Matrix<double>(M_, K_, 0.0);
2095 out.method = "nrm";
2096 start_cnt_ = Matrix<double>(M_, K_, 0.0);
2097 preempt_cnt_ = Matrix<double>(M_, K_, 0.0);
2098 block_cnt_ = Matrix<double>(M_, K_, 0.0);
2099 if (nrx == 0) return out;
2100
2101 std::vector<double> nvec = nvec0_;
2102 std::vector<std::vector<std::size_t>> bufs = buffers0_;
2103 std::vector<Matrix<double>> svc = svcph0_;
2104 // The WAITQ FIFO of each region, tokens `node * K + class` of the parked jobs.
2105 std::vector<std::vector<std::size_t>> fcr_buf(fcr_.size());
2106 cache_var_ = cache_var0_;
2107 cache_prod_ = Matrix<double>(I_, K_, 0.0);
2108 cache_dly_ = Matrix<double>(I_, K_, 0.0);
2109 // Seed each polling controller as `State.pollingInit` does: walk from buffer
2110 // 1 and settle on the first tangible state, so the seed is reachable.
2111 poll_ctrl_.assign(I_, PollCtrl());
2112 for (std::size_t ind = 0; ind < I_; ++ind) {
2113 if (!is_poll_[ind]) continue;
2114 std::size_t q = 1;
2115 int mode = 0;
2116 long budget = 0;
2117 qn::polling_next(poll_info_[ind], 1, poll_nbuf(nvec, ind), K_, true, q, mode, budget);
2118 poll_ctrl_[ind] = poll_land(ind, q, mode, budget, false);
2119 }
2120
2121 std::vector<double> Ak(nrx, 0.0), Pk(nrx, 0.0), Tk(nrx, 0.0), tau(nrx, 0.0);
2122 for (std::size_t k = 0; k < nrx; ++k) {
2123 Ak[k] = propensity(k, nvec, bufs, svc);
2124 Pk[k] = -std::log(rng_.uniform());
2125 tau[k] = Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
2126 }
2127
2128 double total_time = 0.0;
2129 std::size_t n = 0;
2130 line::util::LineConsole::loop("drawing the sample path: %zu samples requested",
2131 static_cast<std::size_t>(opt_.samples));
2132 const std::size_t console_every = std::max<std::size_t>(1, opt_.samples / 20);
2133 for (; n < opt_.samples; ++n) {
2134 if ((n + 1) % console_every == 0)
2136 static_cast<long>((n + 1) / console_every),
2137 "simulated %zu of %zu samples (%.0f%%), simulated time %.4g", n + 1,
2138 static_cast<std::size_t>(opt_.samples),
2139 100.0 * static_cast<double>(n + 1) / static_cast<double>(opt_.samples),
2140 total_time);
2141 std::size_t kfire = 0;
2142 double dt = std::numeric_limits<double>::infinity();
2143 for (std::size_t k = 0; k < nrx; ++k)
2144 if (tau[k] < dt) {
2145 dt = tau[k];
2146 kfire = k;
2147 }
2148 if (std::isinf(dt))
2149 throw NumericError(
2150 "solver_ssa_nrm: deadlock -- every reaction has propensity zero, so the sample "
2151 "path cannot advance");
2152 total_time += dt;
2153
2154 // Time-integrate the metrics over this interval.
2155 for (std::size_t ist = 0; ist < M_; ++ist) {
2156 const std::size_t ind = sn_.station_to_node[ist] - 1;
2157 const std::vector<double> npop = class_counts(nvec, ind);
2158 double totpop = 0.0;
2159 for (double v : npop) totpop += v;
2160 // A Source's state slot is a FICTITIOUS TOKEN, not a queue. It is
2161 // seeded at 1, decremented by every arrival and replenished only
2162 // when a job reaches the Sink and is forwarded back, so its time
2163 // average is 1 - E[jobs in system] -- negative on any model with
2164 // more than one job in system, which is what it was reporting. A
2165 // Source holds no jobs, so QLen, Util and hence RespT/ResidT are
2166 // ZERO BY DEFINITION there and only its throughput (the arrival
2167 // rate) is a quantity; MVA, NC and MAM print exactly that row, and
2168 // the reference SSA applies the same rule downstream in
2169 // `solver_ssa.py` / `@@NetworkSolver/getAvg.m`. This is a
2170 // definition, not a clamp: it is keyed on the station's discipline
2171 // and never on the sign, so a negative anywhere else stays visible.
2172 const bool is_source = sched_[ist] == SchedStrategy::EXT;
2173 for (std::size_t k = 0; k < K_; ++k) {
2174 double dep = 0.0;
2175 for (std::size_t jd : dep_rx_[ist][k]) dep += Ak[jd];
2176 out.TN(ist, k) += dep * dt;
2177 if (is_source) continue;
2178 out.QN(ist, k) += npop[k] * dt;
2179 switch (sched_[ist]) {
2180 case SchedStrategy::INF:
2181 out.UN(ist, k) += npop[k] * dt;
2182 break;
2183 case SchedStrategy::PS:
2184 case SchedStrategy::LPS:
2185 if (totpop > 0.0)
2186 out.UN(ist, k) += (npop[k] / totpop) *
2187 std::min(nservers_[ist], totpop) / nservers_[ist] *
2188 dt;
2189 break;
2190 case SchedStrategy::DPS:
2191 out.UN(ist, k) += dpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
2192 break;
2193 case SchedStrategy::GPS:
2194 out.UN(ist, k) += gpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
2195 break;
2196 // The utilization of a PRIORITY discipline is the SAME
2197 // share the rate law uses, which is the invariant this
2198 // accumulator exists to preserve: reusing the sharing
2199 // factor is what keeps U = T E[S] from drifting away from
2200 // the sample path that produced T.
2201 case SchedStrategy::PSPRIO:
2202 out.UN(ist, k) += psprioshare(npop, k, nservers_[ist]) / nservers_[ist] * dt;
2203 break;
2204 case SchedStrategy::DPSPRIO:
2205 out.UN(ist, k) +=
2206 dpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
2207 break;
2208 case SchedStrategy::GPSPRIO:
2209 out.UN(ist, k) +=
2210 gpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
2211 break;
2212 // Busy on one class-k job exactly while the controller serves k.
2213 case SchedStrategy::POLLING:
2214 if (poll_ctrl_[ind].mode == 1 && poll_ctrl_[ind].pos == k + 1)
2215 out.UN(ist, k) += dt / nservers_[ist];
2216 break;
2217 // The positions with a positive Delta_mu over the servers, so a
2218 // job served by several server types counts once.
2219 case SchedStrategy::PAS:
2220 case SchedStrategy::OI:
2221 out.UN(ist, k) += pas_in_service(ist, bufs[ind], k) / nservers_[ist] * dt;
2222 break;
2223 case SchedStrategy::FCFS:
2224 case SchedStrategy::LCFS:
2225 case SchedStrategy::SIRO:
2226 case SchedStrategy::HOL:
2227 case SchedStrategy::SEPT:
2228 case SchedStrategy::LEPT:
2229 case SchedStrategy::LCFSPR: {
2230 if (sn_.disabled[ist][k]) break;
2231 double waiting = 0.0;
2232 for (std::size_t c : bufs[ind])
2233 if (c == k) waiting += 1.0;
2234 out.UN(ist, k) += ((npop[k] - waiting) / nservers_[ist]) * dt;
2235 break;
2236 }
2237 default:
2238 break;
2239 }
2240 }
2241 }
2242
2243 // Apply the firing.
2244 const Rx& x = rx_[kfire];
2245 std::size_t dest_pos = npos;
2246 bool have_dest = false;
2247 // A firing has THREE outcomes, not two: it moves the job, it loses it
2248 // (the source departs either way), or it is BLOCKED -- cancelled with
2249 // the source keeping the job and no slot changing. See `capacity_block`.
2250 bool blocked = false;
2251 if (x.is_cache) {
2252 cache_fire(x, nvec, dest_pos, have_dest);
2253 } else if (x.nnzP > 1 || !x.sd_node.empty()) {
2254 std::size_t slot;
2255 if (!x.sd_node.empty()) {
2256 // the dispatcher decides, from the state, not from a draw
2257 slot = resolve_state_dependent_dest(kfire, nvec);
2258 } else {
2259 const double u = rng_.uniform();
2260 std::size_t sel = x.cdf.size() - 1;
2261 for (std::size_t i = 0; i < x.cdf.size(); ++i)
2262 if (x.cdf[i] > u) {
2263 sel = i;
2264 break;
2265 }
2266 slot = x.to_slots[sel];
2267 }
2268 // A closed job that finds no room BLOCKS: the firing is cancelled
2269 // outright and nothing below runs, because a job that cannot leave
2270 // its station never departs at all. See `capacity_block`.
2271 if (capacity_block(nvec, slot, x.from)) {
2272 blocked = true;
2273 } else {
2274 // The loss is decided on the PRE-arrival population, so it is drawn
2275 // before the state is updated: the source still releases the job and
2276 // the destination never receives it.
2277 bool lost = capacity_loss(nvec, slot);
2278 // A region refuses on the same pre-arrival population; see
2279 // `fcr_outcome` for what each rule does with the refused job.
2280 if (!lost && !fcr_.empty())
2281 fcr_outcome(nvec, x, slot_node_[slot], slot_class_[slot], fcr_buf, lost, blocked);
2282 if (!blocked) {
2283 for (std::size_t s : x.from_slots) nvec[s] -= 1.0;
2284 if (!lost) {
2285 nvec[slot] += 1.0;
2286 dest_pos = slot;
2287 have_dest = true;
2288 }
2289 }
2290 }
2291 } else if (x.det_dest != npos && !x.is_phase &&
2292 capacity_block(nvec, x.det_dest, x.from)) {
2293 blocked = true; // same closed-class block; a phase change is never one
2294 } else {
2295 bool lost = x.det_dest != npos && !x.is_phase && capacity_loss(nvec, x.det_dest);
2296 // Single-destination departures cross region boundaries too; a phase
2297 // change is not an arrival and is never gated.
2298 if (!lost && x.det_dest != npos && !x.is_phase && !fcr_.empty())
2299 fcr_outcome(nvec, x, slot_node_[x.det_dest], slot_class_[x.det_dest], fcr_buf, lost,
2300 blocked);
2301 if (blocked) {
2302 // censored: nothing moves, as for `capacity_block`
2303 } else if (lost) {
2304 nvec[x.from] -= 1.0;
2305 } else {
2306 for (std::size_t i = 0; i < NS_; ++i)
2307 if (S_(i, kfire) != 0.0) nvec[i] += S_(i, kfire);
2308 if (x.det_dest != npos) {
2309 dest_pos = x.det_dest;
2310 have_dest = true;
2311 }
2312 }
2313 }
2314
2315 bool svc_changed = false;
2316 if (blocked) {
2317 // nothing moved, so no buffer and no service phase may change
2318 if (is_station_[x.node]) block_cnt_(to_station_[x.node], x.cls) += 1.0;
2319 } else if (x.is_phase && buf_ph_node_[x.node]) {
2320 // A buffered-PH phase change frees no server and adds no arrival:
2321 // only the in-service multiset moves.
2322 svc[x.node](x.cls, x.phase_from) -= 1.0;
2323 svc[x.node](x.cls, x.phase_to) += 1.0;
2324 svc_changed = true;
2325 } else if (!x.is_sw) {
2326 update_buffers(kfire, nvec, bufs, dest_pos, have_dest, svc, svc_changed);
2327 }
2328 // A read rewrites the contents and places jobs outside the stoichiometry.
2329 if (x.is_cache) svc_changed = true;
2330
2331 // Polling controller advance, `solver_ssa_nrm.m` lines 1710-1774: a
2332 // switchover step, a completion that ends or continues the visit, and an
2333 // arrival that wakes a parked server. Each can flip a service gate or the
2334 // switchover rate outside the static dependency set, so any move forces a
2335 // full refresh below.
2336 if (!is_poll_.empty() && !blocked) {
2337 if (x.is_sw) {
2338 const std::size_t ind = x.node;
2339 PollCtrl& c = poll_ctrl_[ind];
2340 const Matrix<T>& D0 = poll_info_[ind].sw_d0[c.pos - 1];
2341 const std::size_t Ksw = D0.rows();
2342 std::vector<double> w(Ksw + 1, 0.0);
2343 double rowsum = 0.0;
2344 for (std::size_t kd = 0; kd < Ksw; ++kd) {
2345 const double v = num_traits<T>::to_double(D0(c.swk, kd));
2346 rowsum += v;
2347 if (kd != c.swk && v > 0.0) w[kd] = v;
2348 }
2349 w[Ksw] = std::max(0.0, -rowsum); // absorption: the D1 row sum
2350 const std::size_t pick = rng_.draw(w);
2351 if (pick < Ksw && pick != c.swk) {
2352 c.swk = pick; // an internal phase advance of the walk
2353 } else {
2354 std::size_t q = c.pos;
2355 int mode = 0;
2356 long budget = 0;
2357 qn::polling_next(poll_info_[ind], c.pos, poll_nbuf(nvec, ind), K_, true, q,
2358 mode, budget);
2359 c = poll_land(ind, q, mode, budget);
2360 }
2361 svc_changed = true;
2362 } else if (is_poll_[x.node] && !x.is_phase) {
2363 const std::size_t ind = x.node;
2364 PollCtrl& c = poll_ctrl_[ind];
2365 const qn::PollingInfo<T>& pi = poll_info_[ind];
2366 const std::vector<long> nb = poll_nbuf(nvec, ind); // after the departure
2367 long ctrnext = 0;
2368 bool goon = false;
2369 switch (pi.ptype) {
2371 goon = nb[c.pos - 1] > 0;
2372 break;
2374 ctrnext = c.ctr - 1;
2375 goon = ctrnext > 0;
2376 break;
2378 ctrnext = c.ctr - 1;
2379 goon = ctrnext > 0 && nb[c.pos - 1] > 0;
2380 break;
2382 ctrnext = c.ctr;
2383 goon = nb[c.pos - 1] > c.ctr;
2384 break;
2385 default:
2386 throw UnsupportedError("solver_ssa_nrm: unsupported polling type");
2387 }
2388 if (goon) {
2389 c = poll_land(ind, c.pos, 1, ctrnext);
2390 } else {
2391 std::size_t q = c.pos;
2392 int mode = 0;
2393 long budget = 0;
2394 qn::polling_next(pi, c.pos, nb, K_, false, q, mode, budget);
2395 c = poll_land(ind, q, mode, budget);
2396 }
2397 svc_changed = true;
2398 }
2399 if (have_dest && is_poll_[slot_node_[dest_pos]]) {
2400 const std::size_t jnd = slot_node_[dest_pos];
2401 PollCtrl& c = poll_ctrl_[jnd];
2402 if (c.mode == 0) {
2403 std::size_t q = c.pos;
2404 int mode = 0;
2405 long budget = 0;
2406 qn::polling_next(poll_info_[jnd], c.pos, poll_nbuf(nvec, jnd), K_, true, q,
2407 mode, budget);
2408 c = poll_land(jnd, q, mode, budget);
2409 svc_changed = true;
2410 }
2411 }
2412 }
2413
2414 // WAITQ: admit parked jobs whose regions this firing may have relieved.
2415 // A release changes populations at arbitrary nodes, so it forces a full
2416 // propensity refresh below, as a service-phase move does.
2417 if (fcr_any_waitq_ && !blocked &&
2418 fcr_release_cascade(nvec, bufs, fcr_buf, svc, svc_changed) > 0)
2419 svc_changed = true;
2420
2421 for (std::size_t k = 0; k < nrx; ++k) Tk[k] += Ak[k] * dt;
2422
2423 // svcph is not part of the stoichiometry, so the static dependency set
2424 // cannot see a change to it; a move there forces a full refresh.
2425 if (svc_changed) {
2426 for (std::size_t k = 0; k < nrx; ++k) Ak[k] = propensity(k, nvec, bufs, svc);
2427 } else {
2428 for (std::size_t k : D_[kfire]) Ak[k] = propensity(k, nvec, bufs, svc);
2429 }
2430
2431 Pk[kfire] -= std::log(rng_.uniform());
2432 for (std::size_t k = 0; k < nrx; ++k)
2433 tau[k] =
2434 Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
2435 }
2436
2437 if (total_time > 0.0)
2438 for (std::size_t ist = 0; ist < M_; ++ist)
2439 for (std::size_t k = 0; k < K_; ++k) {
2440 out.QN(ist, k) /= total_time;
2441 out.UN(ist, k) /= total_time;
2442 // net the blocked firings out of the departure-rate integral
2443 out.TN(ist, k) = (out.TN(ist, k) - block_cnt_(ist, k)) / total_time;
2444 // COUNTS over the simulated time, where TN above integrates a
2445 // rate: the two estimators of the same quantity agree in the
2446 // limit and differ by simulation error at any finite budget.
2447 out.StartN(ist, k) = start_cnt_(ist, k) / total_time;
2448 out.PreemptN(ist, k) = preempt_cnt_(ist, k) / total_time;
2449 }
2450
2451 // A class- or joint-dependent station reports U = T*S/peak against the
2452 // product of its declared cd and jd peaks, the T*S/c convention of the
2453 // analytic solvers and serial SSA; busy time divided by the server count is
2454 // not that quantity once beta(n) != 1.
2455 for (std::size_t ist = 0; ist < M_; ++ist) {
2456 const auto& st = sn_.stations[ist];
2457 if (!st.cdscaling && !st.jdscaling) continue;
2458 for (std::size_t k = 0; k < K_; ++k) {
2459 double peak = 1.0;
2460 if (st.cdscaling) peak *= num_traits<T>::to_double(st.cdscalingpeak[k]);
2461 if (st.jdscaling) peak *= num_traits<T>::to_double(st.jdscalingpeak[k]);
2462 const double rate = num_traits<T>::to_double(sn_.rates(ist, k));
2463 out.UN(ist, k) = (std::isfinite(rate) && rate > 0.0 && peak > 0.0)
2464 ? out.TN(ist, k) / rate / peak
2465 : 0.0;
2466 }
2467 }
2468
2469 // Every LOAD-DEPENDENT station reports the work-based T*S/peak instead,
2470 // peak = max(c, max(alpha)). The loop above integrates BUSY TIME, which is a
2471 // different quantity once alpha(n) != 1: a server running alpha(n) times
2472 // faster does the same work in less time, so busy time reads it as no busier
2473 // than one at its nominal rate. That put NRM at 0.9587 on a 4-job closed
2474 // model with alpha = [1 1.5 2 2.5] where CTMC, MVA, NC and serial SSA all
2475 // report 0.6612, and left the two SSA engines disagreeing with each other.
2476 // INF and EXT keep U = Q, as everywhere else.
2477 for (std::size_t ist = 0; ist < M_; ++ist) {
2478 if (lld_[ist].empty()) continue;
2479 double peak = nservers_[ist];
2480 bool non_unit = false;
2481 for (double a : lld_[ist]) {
2482 if (a != 1.0) non_unit = true;
2483 if (a > peak) peak = a;
2484 }
2485 if (!non_unit) continue;
2486 if (sched_[ist] == SchedStrategy::INF || sched_[ist] == SchedStrategy::EXT)
2487 continue;
2488 for (std::size_t k = 0; k < K_; ++k) {
2489 const double rate = num_traits<T>::to_double(sn_.rates(ist, k));
2490 out.UN(ist, k) = (std::isfinite(rate) && rate > 0.0 && peak > 0.0)
2491 ? out.TN(ist, k) / rate / peak
2492 : 0.0;
2493 }
2494 }
2495
2496 for (std::size_t k = 0; k < K_; ++k) {
2497 out.XN[k] = out.TN(sn_.classes[k].refstat - 1, k);
2498 for (std::size_t ist = 0; ist < M_; ++ist)
2499 out.RN(ist, k) = out.TN(ist, k) > 0.0 ? out.QN(ist, k) / out.TN(ist, k) : 0.0;
2500 if (out.XN[k] > 0.0) out.CN[k] = sn_.classes[k].population / out.XN[k];
2501 }
2502 out.simulated_time = total_time;
2503 out.samples = n;
2504 cache_write_back();
2505 return out;
2506}
2507
2508} // namespace ssa
2509} // namespace line
2510
2511#endif // LINE_SOLVERS_SSA_SOLVER_SSA_NRM_H
InputError(const std::string &what)
Definition error.h:39
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
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:149
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:170
std::size_t index(std::size_t n)
Uniform index in [0, n), the reference's 1 + floor(rand*n).
Definition ssa_types.h:160
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)
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:426
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:391
@ READ
a cache item is read
Definition lang_types.h:117
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:375
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:374
@ GATED
serve exactly the jobs present at the polling instant
Definition lang_types.h:373
@ DECREMENTING
serve until the queue is one shorter than at arrival
Definition lang_types.h:376
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:230
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)
Port of State.afterEventStationPAS: events at a pass-and-swap station.
PollingInfo< T > polling_info(const NetworkStruct< T > &sn, std::size_t ind)
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...
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:766
static constexpr double Zero
Definition lang_types.h:762
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:121
std::size_t node
1-based Cache node index
Definition ssa_types.h:122
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:69
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:101
std::vector< double > XN
Definition ssa_types.h:103
std::vector< double > CN
Definition ssa_types.h:103
Matrix< double > UN
Definition ssa_types.h:102
Matrix< double > RN
Definition ssa_types.h:102
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:111
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:115
Matrix< double > TN
Definition ssa_types.h:102
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:117
Matrix< double > QN
Definition ssa_types.h:102
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:113
Matrix< double > PreemptN
Definition ssa_types.h:111