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
65#include "line/util/error.h"
66#include "line/util/matrix.h"
67
68namespace line {
69namespace ssa {
70
71namespace detail {
72
74
75/** True for the non-preemptive disciplines that hold waiting jobs in a buffer. */
76inline bool sched_is_buffered(SchedStrategy s) {
80}
81
82/**
83 * Of those, the ones whose in-service phase composition the NRM can track.
84 *
85 * LCFSPR is excluded: preempt-resume would need the preempted job's phase
86 * remembered in the buffer, which the waiting-only buffer does not record.
87 * `phaseNrmOK` in the reference draws the same line.
88 */
89inline bool sched_is_buf_ph(SchedStrategy s) {
92}
93
94/**
95 * The DPS / GPS family, whose rate law reads per-class weights from schedparam.
96 *
97 * THE PRIORITY VARIANTS BELONG HERE TOO. `dpsprioshare` and `gpsprioshare` call
98 * straight through to `dpsshare` / `gpsshare` -- with the priority-restricted
99 * population above capacity and the full one below it -- so they read the same
100 * weights. Leaving them out left `wnorm_` all zeros at such a station, which
101 * makes every share zero and every reaction zero-rate: the engine reports a
102 * DEADLOCK rather than a wrong number, which is at least loud.
103 */
104inline bool sched_is_weighted(SchedStrategy s) {
105 return s == SchedStrategy::DPS || s == SchedStrategy::GPS ||
107}
108
109/** One reaction of the network: a departure, or a phase change within a service process. */
110struct NrmReaction {
111 std::size_t node = 0; ///< 0-based node the reaction consumes a job at
112 std::size_t cls = 0; ///< 0-based class
113 std::size_t from = 0; ///< 0-based state slot the propensity reads
114 double rate = 0.0; ///< the service-process rate the reaction carries
115 bool is_phase = false; ///< an internal D0 phase change rather than a departure
116 std::size_t phase_from = 0, phase_to = 0; ///< 0-based phases of a phase change
117 std::size_t dep_phase = 0; ///< 0-based phase a departure absorbs from
118 bool is_buf_svc = false; ///< departure of a buffered-PH class
119
120 /** `find(P(:,k))` and its cumulative weights, when the destination is drawn. */
121 std::vector<std::size_t> to_slots;
122 std::vector<double> cdf;
123 /** `find(S(:,k) < 0)`, the slots the firing decrements. */
124 std::vector<std::size_t> from_slots;
125 std::size_t nnzP = 0;
126 /** `find(S(:,k) > 0)` when the destination is deterministic; npos = none. */
127 std::size_t det_dest = static_cast<std::size_t>(-1);
128
129 /**
130 * A STATE-DEPENDENT destination, resolved at firing time rather than drawn
131 * from `cdf`.
132 *
133 * `refresh_routing` expands RROBIN, WRROBIN, JSQ and SQ into a probability
134 * split so that the matrix solvers have a matrix to read, and that split is
135 * what `cdf` above would carry. It is the right answer for a mean-value
136 * solver and the WRONG one for a sample path: a round-robin dispatcher
137 * sends the k-th job to the k-th target, not to a uniformly drawn one, and
138 * that is precisely the variance the strategy exists to remove. So a
139 * reaction leaving a node that declares one of the four keeps the CANDIDATE
140 * list instead, in the order `refresh_routing` enumerates it, and picks
141 * from the live population.
142 */
144 /** One candidate per declared out-arc: the destination (node, class). */
145 std::vector<std::size_t> sd_node, sd_class;
146 /** WRROBIN weights, normalised; empty for the other three. */
147 std::vector<double> sd_weight;
148 /** Per-candidate entry-phase slots and their `pie` weights. */
149 std::vector<std::vector<std::size_t>> sd_slots;
150 std::vector<std::vector<double>> sd_pie;
151};
152
153} // namespace detail
154
155/**
156 * The NRM engine: the reaction network built from `sn`, and the sample path.
157 *
158 * It is a class rather than a chain of free functions because the run loop
159 * threads eight pieces of mutable state (the population vector, the per-node
160 * buffers, the in-service phase multisets, the clocks, the propensities and the
161 * three metric accumulators) through every step, and the reference threads the
162 * same eight as arguments to a single 700-line function.
163 */
164template <class T>
166public:
168 : sn_(sn), opt_(opt), rng_(opt.seed) {
169 build_layout();
170 build_reactions();
171 rr_cursor_.assign(I_ * K_ + K_ + 1, 0);
172 build_dependencies();
173 build_initial_state();
174 }
175
176 /** Run `opt.samples` firings and return the time-averaged metrics. */
178
179 /** The state-vector length, for the tests that assert the phase expansion. */
180 std::size_t nstates() const { return NS_; }
181 /** The reaction count, likewise. */
182 std::size_t nreactions() const { return rx_.size(); }
183
184private:
185 using SchedStrategy = lang::SchedStrategy;
186 using Rx = detail::NrmReaction;
187
188 // ---- the model ---------------------------------------------------------
189 const qn::NetworkStruct<T>& sn_;
190 SsaOptions opt_;
191 SsaRng rng_;
192
193 std::size_t I_ = 0, K_ = 0, M_ = 0, NS_ = 0, maxnph_ = 1;
194
195 // ---- the (node, class, phase) slot layout ------------------------------
196 std::vector<std::vector<std::size_t>> phoff_, nph_;
197 std::vector<std::size_t> slot_node_, slot_class_;
198
199 // ---- per-node and per-station data read by the rate laws ---------------
200 std::vector<bool> is_station_;
201 std::vector<std::size_t> to_station_; ///< 0-based station, npos when not a station
202 std::vector<double> mi_; ///< servers, infinite off a station
203 std::vector<std::vector<double>> rate_;
204 std::vector<std::vector<std::vector<double>>> pie_; ///< entry-phase law per (node, class)
205 std::vector<std::vector<bool>> buf_ph_class_;
206 std::vector<bool> buf_ph_node_;
207 std::vector<SchedStrategy> sched_;
208 std::vector<std::vector<double>> lld_; ///< per station, empty when not load dependent
209 std::vector<std::vector<double>> wnorm_; ///< per station, normalized DPS/GPS weights
210 std::vector<double> classprio_;
211 std::vector<double> nservers_;
212
213 // ---- the reaction network ---------------------------------------------
214 std::vector<Rx> rx_;
216 std::vector<std::vector<std::size_t>> D_;
217 /** Departure reactions of each (station, class), for the throughput integral. */
218 std::vector<std::vector<std::vector<std::size_t>>> dep_rx_;
219 /**
220 * Stations and classes at which a refused arrival must BLOCK rather than be
221 * lost, and whether any exist at all. See `capacity_block`.
222 */
223 std::vector<std::vector<char>> blk_can_;
224 bool blk_on_ = false;
225
226 // ---- the sample path ---------------------------------------------------
227 std::vector<double> nvec0_;
228 std::vector<std::vector<std::size_t>> buffers0_;
229 std::vector<Matrix<double>> svcph0_;
230 /**
231 * The derived START and PREEMPT tags, as COUNTS of events along the path;
232 * `run` divides them by the simulated time. This engine has no
233 * `EventOutcome` to annotate -- it is a reaction network, not a state
234 * machine -- so the tags are raised where the buffer moves, which is the
235 * only place a job can take or lose a server here. Both engines must carry
236 * them or the counters would depend on `options.method`.
237 */
238 Matrix<double> start_cnt_, preempt_cnt_;
239 /**
240 * Departures that were BLOCKED, per (station, class). `TN` integrates the
241 * PROPENSITY, which counts a departure the station never makes once its
242 * successor is full (0.744 against the exact 0.652 on the BUG-81 tandem), so
243 * the blocked firings are subtracted from that integral before it is
244 * normalized. In expectation the count IS the integral of the blocked share
245 * of the rate, so the difference is unbiased -- and unlike recomputing that
246 * share it needs no second evaluation of a state-dependent dispatcher, whose
247 * draw would otherwise have to be replayed.
248 */
249 Matrix<double> block_cnt_;
250
251 // ---- construction ------------------------------------------------------
252 void build_layout();
253 void build_reactions();
254 void build_dependencies();
255 void build_initial_state();
256
257 // ---- rate-law helpers, one per reference subfunction -------------------
258 double class_pop(const std::vector<double>& X, std::size_t ind, std::size_t r) const {
259 double s = 0.0;
260 for (std::size_t k = 0; k < nph_[ind][r]; ++k) s += X[phoff_[ind][r] + k];
261 return s;
262 }
263 std::vector<double> class_counts(const std::vector<double>& X, std::size_t ind) const {
264 std::vector<double> v(K_, 0.0);
265 for (std::size_t r = 0; r < K_; ++r) v[r] = class_pop(X, ind, r);
266 return v;
267 }
268 double kir_frac(const std::vector<double>& X, std::size_t slot, std::size_t ind,
269 std::size_t r) const {
270 const double nir = class_pop(X, ind, r);
271 return nir <= 0.0 ? 0.0 : X[slot] / nir;
272 }
273 double lldfac(std::size_t ist, double ntot) const {
274 if (ist == npos || lld_[ist].empty() || ntot < 1.0) return 1.0;
275 const std::size_t lim = lld_[ist].size();
276 std::size_t k = static_cast<std::size_t>(std::llround(ntot));
277 if (k > lim) k = lim;
278 return lld_[ist][k - 1];
279 }
280 static double dpsshare(const std::vector<double>& w, const std::vector<double>& n,
281 std::size_t r) {
282 double den = 0.0;
283 for (std::size_t s = 0; s < n.size(); ++s) den += w[s] * n[s];
284 return den <= 0.0 ? 0.0 : w[r] * n[r] / den;
285 }
286 static double gpsshare(const std::vector<double>& w, const std::vector<double>& n,
287 std::size_t r) {
288 if (n[r] <= 0.0) return 0.0;
289 double den = 0.0;
290 for (std::size_t s = 0; s < n.size(); ++s) den += n[s] > 0.0 ? w[s] : 0.0;
291 return den <= 0.0 ? 0.0 : w[r] / den;
292 }
293 /**
294 * The three PRIORITY sharing disciplines, ported 2026-08-15.
295 *
296 * ALL THREE SPLIT AT THE SERVER COUNT, and that is the whole idea: below
297 * capacity every job is in service and the station behaves as its
298 * non-priority twin, so priority cannot matter; above capacity only the
299 * MOST URGENT NON-EMPTY group shares the servers and everyone else is
300 * frozen. LINE orders priorities with the LOWER value more urgent.
301 *
302 * "Non-empty" is doing real work in `urgent`: an empty class never defines
303 * the urgent group, so a station holding only low-priority jobs serves them
304 * rather than stalling on a priority class that is not there.
305 */
306 bool urgent(const std::vector<double>& n, std::size_t r) const {
307 double best = 0.0;
308 bool any = false;
309 for (std::size_t s = 0; s < n.size(); ++s)
310 if (n[s] > 0.0 && (!any || classprio_[s] < best)) {
311 best = classprio_[s];
312 any = true;
313 }
314 return any && classprio_[r] == best;
315 }
316 /** The population vector restricted to r's priority group, and its total. */
317 std::vector<double> prio_group(const std::vector<double>& n, std::size_t r,
318 double* total = nullptr) const {
319 std::vector<double> act(n.size(), 0.0);
320 double tot = 0.0;
321 for (std::size_t s = 0; s < n.size(); ++s)
322 if (classprio_[s] == classprio_[r]) {
323 act[s] = n[s];
324 tot += n[s];
325 }
326 if (total) *total = tot;
327 return act;
328 }
329 double psprioshare(const std::vector<double>& n, std::size_t r, double c) const {
330 double ni = 0.0;
331 for (double v : n) ni += v;
332 if (ni <= 0.0) return 0.0;
333 if (ni <= c) return (n[r] / ni) * std::min(ni, c);
334 if (!urgent(n, r)) return 0.0;
335 double niprio = 0.0;
336 prio_group(n, r, &niprio);
337 return niprio <= 0.0 ? 0.0 : (n[r] / niprio) * std::min(niprio, c);
338 }
339 double dpsprioshare(const std::vector<double>& w, const std::vector<double>& n, std::size_t r,
340 double c) const {
341 double ni = 0.0;
342 for (double v : n) ni += v;
343 if (ni <= 0.0) return 0.0;
344 if (ni <= c) return dpsshare(w, n, r);
345 if (!urgent(n, r)) return 0.0;
346 return dpsshare(w, prio_group(n, r), r);
347 }
348 double gpsprioshare(const std::vector<double>& w, const std::vector<double>& n, std::size_t r,
349 double c) const {
350 double ni = 0.0;
351 for (double v : n) ni += v;
352 if (ni <= 0.0) return 0.0;
353 if (ni <= c) return gpsshare(w, n, r);
354 if (!urgent(n, r)) return 0.0;
355 return gpsshare(w, prio_group(n, r), r);
356 }
357 /**
358 * The population the LOAD-DEPENDENT factor is read at.
359 *
360 * PSPRIO uses the FULL vector in both branches while DPSPRIO and GPSPRIO
361 * use the priority-restricted one above capacity. That asymmetry is
362 * inherited from `State.afterEventStation` and is reproduced rather than
363 * tidied: the two conventions give different lld factors on the same state,
364 * and the reference's numbers were computed with these.
365 */
366 double prio_pop(const std::vector<double>& n, std::size_t r, double c) const {
367 double ni = 0.0;
368 for (double v : n) ni += v;
369 if (ni <= c || !urgent(n, r)) return ni;
370 double niprio = 0.0;
371 prio_group(n, r, &niprio);
372 return niprio;
373 }
374
375 // ---- the sample path ---------------------------------------------------
376 double propensity(std::size_t j, const std::vector<double>& X,
377 const std::vector<std::vector<std::size_t>>& bufs,
378 const std::vector<Matrix<double>>& svc) const;
379 std::size_t pick_from_buffer(const std::vector<std::size_t>& buf, std::size_t ist);
380 std::size_t draw_entry_phase(std::size_t ind, std::size_t r);
381 bool capacity_loss(const std::vector<double>& X, std::size_t slot) const;
382 bool capacity_block(const std::vector<double>& X, std::size_t slot,
383 std::size_t src_slot) const;
384 std::size_t pick_preempted(const std::vector<double>& X,
385 const std::vector<std::size_t>& buf, std::size_t jnd,
386 std::size_t arr_class);
387 void apply_arrival_buffer(std::size_t jnd, std::size_t s, const std::vector<double>& X,
388 std::vector<std::vector<std::size_t>>& bufs,
389 std::vector<Matrix<double>>& svc, bool& svc_changed);
390 void update_buffers(std::size_t kfire, const std::vector<double>& X,
391 std::vector<std::vector<std::size_t>>& bufs, std::size_t dest_pos,
392 bool have_dest, std::vector<Matrix<double>>& svc, bool& svc_changed);
393
394 /** Fill `sd_*` on a reaction leaving a node that routes by strategy. */
395 void build_state_dependent_dest(Rx& x);
396 /** Pick the destination slot of a state-dependent firing from the live state. */
397 std::size_t resolve_state_dependent_dest(std::size_t kfire, const std::vector<double>& X);
398
399 /**
400 * Round-robin cursor per (SOURCE NODE, CLASS), keyed `node * K_ + cls`.
401 * Not per reaction: a phase-type service splits one dispatcher across
402 * several reactions and a pointer each would let the phases cycle
403 * independently, which is not a round robin.
404 */
405 std::vector<std::size_t> rr_cursor_;
406
407 /** Raise a START (or, with `preempt`, a PREEMPT) for class R at node IND. */
408 void tag(std::size_t ind, std::size_t r, bool preempt) {
409 if (!is_station_[ind] || r >= K_) return;
410 const std::size_t ist = to_station_[ind];
411 if (ist >= M_) return;
412 // A Source CREATES jobs rather than admitting them to service, so it
413 // seizes nothing; every codebase reports a zero row for it.
414 if (sched_[ist] == SchedStrategy::EXT) return;
415 (preempt ? preempt_cnt_ : start_cnt_)(ist, r) += 1.0;
416 }
417
418 static constexpr std::size_t npos = static_cast<std::size_t>(-1);
419};
420
421// ---------------------------------------------------------------------------
422// Construction
423// ---------------------------------------------------------------------------
424
425/**
426 * The phase slot map, `solver_ssa_nrm.m` lines 33-111.
427 *
428 * The state vector counts jobs per (node, class, PHASE) so phase-type service
429 * is represented exactly instead of collapsed onto its mean rate. With one
430 * phase everywhere the layout degenerates to the flat (node, class) index, so
431 * an exponential model is unaffected -- which is the self-check the reference
432 * names for this generalization.
433 */
434template <class T>
435void NrmEngine<T>::build_layout() {
436 I_ = sn_.nof_nodes();
437 K_ = sn_.nclasses;
438 M_ = sn_.nstations;
439
440 is_station_.assign(I_, false);
441 to_station_.assign(I_, npos);
442 for (std::size_t i = 0; i < I_; ++i)
443 if (sn_.nodes[i].station != 0) {
444 is_station_[i] = true;
445 to_station_[i] = sn_.nodes[i].station - 1;
446 }
447
448 sched_.assign(M_, SchedStrategy::FCFS);
449 nservers_.assign(M_, 1.0);
450 for (std::size_t i = 0; i < M_; ++i) {
451 sched_[i] = sn_.stations[i].sched;
452 nservers_[i] = sn_.stations[i].nservers;
453 }
454
455 // Phase counts. `sn.phasessz` floors at one, so a disabled pair still owns
456 // exactly one slot and its zero rate silences the reaction.
457 nph_.assign(I_, std::vector<std::size_t>(K_, 1));
458 pie_.assign(I_, std::vector<std::vector<double>>(K_));
459 for (std::size_t i = 0; i < I_; ++i) {
460 for (std::size_t r = 0; r < K_; ++r) {
461 pie_[i][r].assign(1, 1.0);
462 if (!is_station_[i]) continue;
463 const std::size_t ist = to_station_[i];
464 if (sn_.disabled[ist][r]) continue;
465 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
466 const std::size_t n = m.D0.rows();
467 if (n <= 1) continue;
468 nph_[i][r] = n;
469 const std::vector<T> p = lang::dist_pie(sn_.service[ist][r]);
470 std::vector<double> pd(n, 0.0);
471 double tot = 0.0;
472 for (std::size_t k = 0; k < n && k < p.size(); ++k) {
473 pd[k] = num_traits<T>::to_double(p[k]);
474 if (!(pd[k] > 0.0)) pd[k] = 0.0;
475 tot += pd[k];
476 }
477 if (tot > 0.0) {
478 for (double& x : pd) x /= tot;
479 } else {
480 pd.assign(n, 0.0);
481 pd[0] = 1.0;
482 }
483 pie_[i][r] = pd;
484 }
485 }
486
487 phoff_.assign(I_, std::vector<std::size_t>(K_, 0));
488 NS_ = 0;
489 maxnph_ = 1;
490 for (std::size_t i = 0; i < I_; ++i)
491 for (std::size_t r = 0; r < K_; ++r) {
492 phoff_[i][r] = NS_;
493 NS_ += nph_[i][r];
494 maxnph_ = std::max(maxnph_, nph_[i][r]);
495 }
496 slot_node_.assign(NS_, 0);
497 slot_class_.assign(NS_, 0);
498 for (std::size_t i = 0; i < I_; ++i)
499 for (std::size_t r = 0; r < K_; ++r)
500 for (std::size_t k = 0; k < nph_[i][r]; ++k) {
501 slot_node_[phoff_[i][r] + k] = i;
502 slot_class_[phoff_[i][r] + k] = r;
503 }
504
505 // Buffered phase-type service: only the jobs ACTUALLY in service carry a
506 // phase, so their composition lives in svcph and not in the population.
507 buf_ph_class_.assign(I_, std::vector<bool>(K_, false));
508 buf_ph_node_.assign(I_, false);
509 for (std::size_t i = 0; i < I_; ++i) {
510 if (!is_station_[i] || !detail::sched_is_buf_ph(sched_[to_station_[i]])) continue;
511 for (std::size_t r = 0; r < K_; ++r)
512 if (nph_[i][r] > 1) {
513 buf_ph_class_[i][r] = true;
514 buf_ph_node_[i] = true;
515 }
516 }
517
518 // Rates, server counts and the scaling tables the rate laws read.
519 const double inf = std::numeric_limits<double>::infinity();
520 mi_.assign(I_, inf);
521 rate_.assign(I_, std::vector<double>(K_, 0.0));
522 for (std::size_t i = 0; i < I_; ++i) {
523 if (is_station_[i]) {
524 const std::size_t ist = to_station_[i];
525 mi_[i] = sn_.stations[ist].nservers;
526 for (std::size_t r = 0; r < K_; ++r)
527 if (!sn_.disabled[ist][r])
528 rate_[i][r] = num_traits<T>::to_double(sn_.rates(ist, r));
529 } else {
530 for (std::size_t r = 0; r < K_; ++r)
532 }
533 }
534
535 lld_.assign(M_, std::vector<double>());
536 wnorm_.assign(M_, std::vector<double>(K_, 0.0));
537 for (std::size_t i = 0; i < M_; ++i) {
538 for (const T& x : sn_.stations[i].lldscaling)
539 lld_[i].push_back(num_traits<T>::to_double(x));
540 if (!detail::sched_is_weighted(sched_[i])) continue;
541 double tot = 0.0;
542 for (std::size_t r = 0; r < K_ && r < sn_.stations[i].schedparam.size(); ++r)
543 tot += num_traits<T>::to_double(sn_.stations[i].schedparam[r]);
544 if (!(tot > 0.0))
545 throw InputError("solver_ssa_nrm: station '" + sn_.stations[i].name + "' has " +
546 std::string(lang::sched_to_text(sched_[i])) +
547 " scheduling with non-positive total weight");
548 if (sn_.stations[i].nservers > 1.0)
549 throw UnsupportedError(
550 "solver_ssa_nrm: multi-server " + std::string(lang::sched_to_text(sched_[i])) +
551 " at station '" + sn_.stations[i].name +
552 "' is not supported; the reference's State.afterEventStation rejects it too");
553 for (std::size_t r = 0; r < K_ && r < sn_.stations[i].schedparam.size(); ++r)
554 wnorm_[i][r] = num_traits<T>::to_double(sn_.stations[i].schedparam[r]) / tot;
555 }
556
557 classprio_.assign(K_, 0.0);
558 for (std::size_t r = 0; r < K_; ++r) classprio_[r] = sn_.classes[r].prio;
559}
560
561/**
562 * The stoichiometry and the reaction list, `solver_ssa_nrm.m` lines 190-355.
563 *
564 * A departure is the ABSORPTION of the phase-type service process, so it fires
565 * at the D1 row sum of the phase it leaves and the job re-enters its
566 * destination in an entry phase drawn from pie -- which is why the destination
567 * weight is the product of the routing probability and the entry probability. A
568 * phase change is an off-diagonal of D0 and never leaves the node.
569 */
570template <class T>
571void NrmEngine<T>::build_reactions() {
572 rx_.clear();
573 // Departures, one per (node, class, phase).
574 for (std::size_t ind = 0; ind < I_; ++ind) {
575 for (std::size_t r = 0; r < K_; ++r) {
576 for (std::size_t kk = 0; kk < nph_[ind][r]; ++kk) {
577 Rx x;
578 x.node = ind;
579 x.cls = r;
580 x.dep_phase = kk;
581 x.is_buf_svc = buf_ph_class_[ind][r];
582 // At a buffered-PH source the population lives entirely in the
583 // first phase slot; which phase completed is carried in
584 // dep_phase and consumed from svcph at firing.
585 x.from = buf_ph_class_[ind][r] ? phoff_[ind][r] : phoff_[ind][r] + kk;
586 x.rate = rate_[ind][r];
587 if (is_station_[ind] && nph_[ind][r] > 1) {
588 const std::size_t ist = to_station_[ind];
589 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
590 double s = 0.0;
591 for (std::size_t c = 0; c < m.D1.cols(); ++c)
592 s += num_traits<T>::to_double(m.D1(kk, c));
593 x.rate = s;
594 }
595 rx_.push_back(x);
596 }
597 }
598 }
599 const std::size_t ndep = rx_.size();
600
601 // Phase transitions, one per (node, class, ka -> kb) with D0(ka,kb) > 0.
602 for (std::size_t ind = 0; ind < I_; ++ind) {
603 if (!is_station_[ind]) continue;
604 const std::size_t ist = to_station_[ind];
605 for (std::size_t r = 0; r < K_; ++r) {
606 if (nph_[ind][r] <= 1 || sn_.disabled[ist][r]) continue;
607 const mam::Map<T> m = lang::dist_to_map(sn_.service[ist][r]);
608 for (std::size_t ka = 0; ka < nph_[ind][r]; ++ka)
609 for (std::size_t kb = 0; kb < nph_[ind][r]; ++kb) {
610 if (ka == kb) continue;
611 const double d = num_traits<T>::to_double(m.D0(ka, kb));
612 if (!(d > 0.0)) continue;
613 Rx x;
614 x.node = ind;
615 x.cls = r;
616 x.is_phase = true;
617 x.phase_from = ka;
618 x.phase_to = kb;
619 x.rate = d;
620 // A buffered-PH phase change moves a job inside svcph and
621 // leaves the population alone, so its stoichiometry column
622 // is all zeros and its propensity reads the first slot.
623 x.from = buf_ph_class_[ind][r] ? phoff_[ind][r] : phoff_[ind][r] + ka;
624 rx_.push_back(x);
625 }
626 }
627 }
628
629 // The stoichiometry matrix, states x reactions.
630 S_ = Matrix<double>(NS_, rx_.size(), 0.0);
631 for (std::size_t k = 0; k < rx_.size(); ++k) {
632 Rx& x = rx_[k];
633 if (x.is_phase) {
634 if (!buf_ph_class_[x.node][x.cls]) {
635 S_(phoff_[x.node][x.cls] + x.phase_from, k) -= 1.0;
636 S_(phoff_[x.node][x.cls] + x.phase_to, k) += 1.0;
637 }
638 continue;
639 }
640 S_(x.from, k) -= 1.0;
641 for (std::size_t jnd = 0; jnd < I_; ++jnd)
642 for (std::size_t s = 0; s < K_; ++s) {
643 const double p =
644 num_traits<T>::to_double(sn_.rtnodes(x.node * K_ + x.cls, jnd * K_ + s));
645 if (!(p > 0.0)) continue;
646 if (buf_ph_class_[jnd][s]) {
647 // The arrival lands in the total-population slot; whether it
648 // enters service, and in which phase, is decided at firing
649 // from the server occupancy and pie.
650 S_(phoff_[jnd][s], k) += p;
651 } else {
652 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke) {
653 const double pe = pie_[jnd][s][ke];
654 if (!(pe > 0.0)) continue;
655 S_(phoff_[jnd][s] + ke, k) += p * pe;
656 }
657 }
658 }
659 }
660
661 // `P = S; P(P<0) = P(P<0)+1` selects the destination draw: nnz(P) > 1 means
662 // the firing must pick among several destination slots, and the weights are
663 // read off P so a class that routes back into its own slot competes with the
664 // others on equal footing.
665 for (std::size_t k = 0; k < rx_.size(); ++k) {
666 Rx& x = rx_[k];
667 std::vector<double> Pcol(NS_, 0.0);
668 for (std::size_t i = 0; i < NS_; ++i) {
669 const double v = S_(i, k);
670 Pcol[i] = v < 0.0 ? v + 1.0 : v;
671 if (v < 0.0) x.from_slots.push_back(i);
672 if (v > 0.0 && x.det_dest == npos) x.det_dest = i;
673 }
674 for (std::size_t i = 0; i < NS_; ++i)
675 if (Pcol[i] != 0.0) ++x.nnzP;
676 if (x.nnzP > 1) {
677 double acc = 0.0;
678 for (std::size_t i = 0; i < NS_; ++i)
679 if (Pcol[i] != 0.0) {
680 x.to_slots.push_back(i);
681 acc += Pcol[i];
682 x.cdf.push_back(acc);
683 }
684 }
685 if (!x.is_phase) build_state_dependent_dest(x);
686 }
687
688 // Departure reactions of each (station, class): the reference rescans all
689 // reactions inside the metric loop, which is the same set every step.
690 dep_rx_.assign(M_, std::vector<std::vector<std::size_t>>(K_));
691 for (std::size_t k = 0; k < ndep; ++k)
692 if (is_station_[rx_[k].node]) dep_rx_[to_station_[rx_[k].node]][rx_[k].cls].push_back(k);
693
694 // Stations and classes where a refusal BLOCKS; see `capacity_block`.
695 // A cap that CANNOT BIND is not a blocking site. Every closed model carries
696 // cap[ist] = N by default, and a station that can hold the whole population
697 // never refuses one: the arriving job is itself one of the N, so the
698 // pre-arrival count is at most N-1. Excluding those keeps `blk_on_` false --
699 // and the per-firing test unpaid -- on ordinary models. An open class present
700 // anywhere makes a finite station cap binding again, its jobs not being in N.
701 double closed_total = 0.0;
702 bool any_open = false;
703 for (std::size_t r = 0; r < K_; ++r) {
704 const double nj = sn_.classes[r].population;
705 if (std::isinf(nj))
706 any_open = true;
707 else
708 closed_total += nj;
709 }
710 blk_can_.assign(M_, std::vector<char>(K_, 0));
711 blk_on_ = false;
712 for (std::size_t ist = 0; ist < M_; ++ist)
713 for (std::size_t r = 0; r < K_; ++r) {
714 const double nj = sn_.classes[r].population;
715 if (std::isinf(nj)) continue; // open: refusal LOSES
716 const double ccap = sn_.classcap[ist][r];
717 const bool binds_st =
718 !std::isinf(sn_.cap[ist]) && (any_open || sn_.cap[ist] < closed_total);
719 const bool binds_cl = ccap > 0.0 && !std::isinf(ccap) && ccap < nj;
720 if (binds_st || binds_cl) {
721 blk_can_[ist][r] = 1;
722 blk_on_ = true;
723 }
724 }
725}
726
727/**
728 * The candidate destinations of a reaction whose node routes BY STRATEGY.
729 *
730 * The candidates are the DECLARED out-arcs, `P(r,s)(i,j) > 0`, enumerated in
731 * the order `refresh_routing` uses, and not the probability split it wrote into
732 * `rtnodes` from them: the split is what a matrix solver needs and it is not
733 * what the dispatcher does. Nothing is filled for PROB, RAND or DISABLED, which
734 * are draws or refusals and stay on the `cdf` path.
735 */
736template <class T>
737void NrmEngine<T>::build_state_dependent_dest(Rx& x) {
738 const qn::NodeDef& nd = sn_.nodes[x.node];
739 const lang::RoutingStrategy rs = nd.routing.size() > x.cls
740 ? nd.routing[x.cls]
744 return;
745 for (std::size_t s = 1; s <= K_; ++s)
746 for (std::size_t j = 1; j <= I_; ++j) {
747 if (!(num_traits<T>::to_double(sn_.get_route(x.cls + 1, s, x.node + 1, j)) > 0.0))
748 continue;
749 x.sd_node.push_back(j - 1);
750 x.sd_class.push_back(s - 1);
751 }
752 if (x.sd_node.size() < 2) { // one arc is not a dispatch decision
753 x.sd_node.clear();
754 x.sd_class.clear();
755 return;
756 }
757 x.sd_strategy = rs;
758 // The entry-phase law of each candidate, so the phase is drawn AFTER the
759 // destination is chosen rather than folded into one joint distribution.
760 x.sd_slots.resize(x.sd_node.size());
761 x.sd_pie.resize(x.sd_node.size());
762 for (std::size_t d = 0; d < x.sd_node.size(); ++d) {
763 const std::size_t jnd = x.sd_node[d], s = x.sd_class[d];
764 if (buf_ph_class_[jnd][s]) {
765 x.sd_slots[d].push_back(phoff_[jnd][s]);
766 x.sd_pie[d].push_back(1.0);
767 continue;
768 }
769 for (std::size_t ke = 0; ke < nph_[jnd][s]; ++ke) {
770 const double pe = pie_[jnd][s][ke];
771 if (pe > 0.0) {
772 x.sd_slots[d].push_back(phoff_[jnd][s] + ke);
773 x.sd_pie[d].push_back(pe);
774 }
775 }
776 }
778 const std::map<std::size_t, double>* w =
779 nd.routing_weights.size() > x.cls ? &nd.routing_weights[x.cls] : NULL;
780 double total = 0.0;
781 x.sd_weight.assign(x.sd_node.size(), 0.0);
782 if (w != NULL)
783 for (std::size_t d = 0; d < x.sd_node.size(); ++d) {
784 const std::map<std::size_t, double>::const_iterator it =
785 w->find(x.sd_node[d] + 1);
786 x.sd_weight[d] = (it == w->end()) ? 0.0 : it->second;
787 total += x.sd_weight[d];
788 }
789 if (!(total > 0.0)) {
790 // No weights declared is plain round robin, which is what the
791 // reference's WRROBIN degenerates to with a uniform weight vector.
792 x.sd_weight.clear();
793 x.sd_strategy = lang::RoutingStrategy::RROBIN;
794 } else {
795 for (std::size_t d = 0; d < x.sd_weight.size(); ++d) x.sd_weight[d] /= total;
796 }
797 }
798}
799
800/**
801 * The destination slot of a state-dependent firing, from the LIVE population.
802 *
803 * Each rule is the reference's (`solver_ssa_nrm.m` lines 1162-1200, 1390-1430):
804 *
805 * JSQ the candidate holding the smallest TOTAL population, each
806 * evaluated on its own queue and never on the routing node's, with
807 * TIES SPLIT UNIFORMLY. The tie split is not a detail: on a fleet of
808 * identical servers the system starts with every queue equal, so a
809 * first-candidate tie-break sends a run of jobs to candidate one and
810 * reports a skew the policy does not have.
811 * SQ(d) the power-of-d-choices rule, NOT "shortest queue": sample d
812 * candidates uniformly WITH replacement and join the smallest of
813 * those, ties by first occurrence in the sampled tuple. `d` defaults
814 * to 2, this port carrying no per-node override for it.
815 * RROBIN a pointer advanced once per firing, kept per (SOURCE NODE, CLASS)
816 * and NOT per reaction: a phase-type service splits one dispatcher
817 * across several reactions, and a pointer each would let the phases
818 * cycle independently and undo the determinism.
819 * WRROBIN the same pointer, spent in proportion to the declared weights.
820 *
821 * The destination NODE is what the rule fixes; the entry PHASE is drawn after,
822 * from `pie` among that node's candidates, since taking the first match would
823 * bias the service time.
824 */
825template <class T>
826std::size_t NrmEngine<T>::resolve_state_dependent_dest(std::size_t kfire,
827 const std::vector<double>& X) {
828 Rx& x = rx_[kfire];
829 const std::size_t nd = x.sd_node.size();
830 const std::size_t cursor_key = x.node * K_ + x.cls;
831 std::size_t pick = 0;
832 if (x.sd_strategy == lang::RoutingStrategy::JSQ) {
833 std::vector<double> load(nd, 0.0);
834 double best = 0.0;
835 for (std::size_t d = 0; d < nd; ++d) {
836 const std::vector<double> cc = class_counts(X, x.sd_node[d]);
837 for (double v : cc) load[d] += v;
838 if (d == 0 || load[d] < best) best = load[d];
839 }
840 std::vector<std::size_t> amins;
841 for (std::size_t d = 0; d < nd; ++d)
842 if (load[d] == best) amins.push_back(d);
843 pick = amins[std::min(amins.size() - 1,
844 std::size_t(rng_.uniform() * double(amins.size())))];
845 } else if (x.sd_strategy == lang::RoutingStrategy::SQ) {
846 const std::size_t dsample = std::min<std::size_t>(2, nd);
847 double best = 0.0;
848 for (std::size_t t = 0; t < dsample; ++t) {
849 const std::size_t cand =
850 std::min(nd - 1, std::size_t(rng_.uniform() * double(nd)));
851 const std::vector<double> cc = class_counts(X, x.sd_node[cand]);
852 double load = 0.0;
853 for (double v : cc) load += v;
854 if (t == 0 || load < best) {
855 best = load;
856 pick = cand;
857 }
858 }
859 } else if (x.sd_strategy == lang::RoutingStrategy::WRROBIN) {
860 // The pointer is spent in proportion to the declared weights: candidate
861 // d owns the slice [sum w_{<d}, sum w_{<=d}) of one full turn.
862 const std::size_t c = rr_cursor_[cursor_key];
863 const double u = double(c % nd) / double(nd);
864 double acc = 0.0;
865 pick = nd - 1;
866 for (std::size_t d = 0; d < nd; ++d) {
867 acc += x.sd_weight[d];
868 if (u < acc - 1e-12) {
869 pick = d;
870 break;
871 }
872 }
873 rr_cursor_[cursor_key] = c + 1;
874 } else {
875 pick = rr_cursor_[cursor_key] % nd;
876 rr_cursor_[cursor_key] = rr_cursor_[cursor_key] + 1;
877 }
878 // the entry phase, drawn within the chosen destination
879 const std::vector<std::size_t>& slots = x.sd_slots[pick];
880 if (slots.size() == 1) return slots[0];
881 const double u = rng_.uniform();
882 double acc = 0.0;
883 for (std::size_t k = 0; k + 1 < slots.size(); ++k) {
884 acc += x.sd_pie[pick][k];
885 if (u < acc) return slots[k];
886 }
887 return slots.back();
888}
889
890/**
891 * The dependency sets, `solver_ssa_nrm.m` lines 1003-1050.
892 *
893 * A reaction's firing changes the slots its stoichiometry touches; every rate
894 * law reads its node's WHOLE class-count vector (and, for the per-phase share,
895 * the sibling phases of its own class), so the affected set is every slot of
896 * every touched node, and the reactions to refresh are those that consume from
897 * one of those slots.
898 */
899template <class T>
900void NrmEngine<T>::build_dependencies() {
901 const std::size_t nrx = rx_.size();
902 D_.assign(nrx, std::vector<std::size_t>());
903 for (std::size_t k = 0; k < nrx; ++k) {
904 std::vector<bool> touched_node(I_, false);
905 for (std::size_t i = 0; i < NS_; ++i)
906 if (S_(i, k) != 0.0) touched_node[slot_node_[i]] = true;
907 std::vector<bool> hit(nrx, false);
908 for (std::size_t ind = 0; ind < I_; ++ind) {
909 if (!touched_node[ind]) continue;
910 for (std::size_t r = 0; r < K_; ++r)
911 for (std::size_t p = 0; p < nph_[ind][r]; ++p) {
912 const std::size_t slot = phoff_[ind][r] + p;
913 for (std::size_t j = 0; j < nrx; ++j)
914 if (S_(slot, j) < 0.0) hit[j] = true;
915 }
916 }
917 for (std::size_t j = 0; j < nrx; ++j)
918 if (hit[j]) D_[k].push_back(j);
919 }
920}
921
922/**
923 * The initial state.
924 *
925 * THIS PORT HAS NO State PACKAGE, so the reference's `sn.state` cannot be read;
926 * the same position `solver_fluid` takes applies here and for the same reason.
927 * A closed class starts entirely at its reference station and an open class
928 * puts the single fictitious token at the Source that the EXT rate law needs
929 * (the reference substitutes exactly that for the infinite marginal). The chain
930 * is ergodic, so the steady-state averages this engine reports do not depend on
931 * the choice; a TRANSIENT would, which is why `getTranAvg` is not offered.
932 */
933template <class T>
934void NrmEngine<T>::build_initial_state() {
935 nvec0_.assign(NS_, 0.0);
936 buffers0_.assign(I_, std::vector<std::size_t>());
937 svcph0_.assign(I_, Matrix<double>());
938 for (std::size_t i = 0; i < I_; ++i)
939 if (buf_ph_node_[i]) svcph0_[i] = Matrix<double>(K_, maxnph_, 0.0);
940
941 std::vector<std::vector<double>> nir(I_, std::vector<double>(K_, 0.0));
942 for (std::size_t r = 0; r < K_; ++r) {
943 const double pop = sn_.classes[r].population;
944 if (std::isinf(pop)) {
945 if (sn_.sourceIdx == 0)
946 throw InputError(
947 "solver_ssa_nrm: the open class '" + sn_.classes[r].name +
948 "' has no Source; an open model must carry one for the arrival reaction");
949 nir[sn_.station_to_node[sn_.sourceIdx - 1] - 1][r] = 1.0;
950 } else if (pop > 0.0) {
951 const std::size_t rs = sn_.classes[r].refstat;
952 if (rs < 1 || rs > M_)
953 throw InputError("solver_ssa_nrm: class '" + sn_.classes[r].name +
954 "' has no reference station");
955 nir[sn_.station_to_node[rs - 1] - 1][r] = pop;
956 }
957 }
958
959 for (std::size_t ind = 0; ind < I_; ++ind) {
960 for (std::size_t r = 0; r < K_; ++r) {
961 const double n = nir[ind][r];
962 if (n <= 0.0) continue;
963 if (nph_[ind][r] <= 1 || buf_ph_class_[ind][r]) {
964 nvec0_[phoff_[ind][r]] = n;
965 } else {
966 double left = n;
967 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
968 const double take = (ke + 1 == nph_[ind][r])
969 ? left
970 : std::min(left, std::round(n * pie_[ind][r][ke]));
971 nvec0_[phoff_[ind][r] + ke] = take;
972 left -= take;
973 }
974 }
975 }
976 // The buffer holds exactly the waiting jobs, `numel(buf) == max(0,
977 // total - mi)`; which classes they are is immaterial to the steady
978 // state, so they are taken in class order.
979 if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]])) {
980 double total = 0.0;
981 for (std::size_t r = 0; r < K_; ++r) total += nir[ind][r];
982 double waiting = std::max(0.0, total - mi_[ind]);
983 for (std::size_t r = 0; r < K_ && waiting > 0.0; ++r) {
984 double take = std::min(waiting, nir[ind][r]);
985 for (std::size_t c = 0; c < static_cast<std::size_t>(take); ++c)
986 buffers0_[ind].push_back(r);
987 waiting -= take;
988 }
989 }
990 if (!buf_ph_node_[ind]) continue;
991 for (std::size_t r = 0; r < K_; ++r) {
992 double waiting_r = 0.0;
993 for (std::size_t c : buffers0_[ind])
994 if (c == r) waiting_r += 1.0;
995 const double insvc = std::max(0.0, nir[ind][r] - waiting_r);
996 if (nph_[ind][r] <= 1) {
997 svcph0_[ind](r, 0) = insvc;
998 } else {
999 double left = insvc;
1000 for (std::size_t ke = 0; ke < nph_[ind][r]; ++ke) {
1001 const double take = (ke + 1 == nph_[ind][r])
1002 ? left
1003 : std::min(left, std::round(insvc * pie_[ind][r][ke]));
1004 svcph0_[ind](r, ke) = take;
1005 left -= take;
1006 }
1007 }
1008 }
1009 }
1010}
1011
1012// ---------------------------------------------------------------------------
1013// The rate laws
1014// ---------------------------------------------------------------------------
1015
1016/**
1017 * The propensity of reaction `j`, `solver_ssa_nrm.m` lines 795-948.
1018 *
1019 * The reference builds one closure per reaction; this is the same switch
1020 * evaluated on demand, which avoids an indirect call per reaction per step and
1021 * cannot drift from the utilization accumulators that reuse the same sharing
1022 * factors.
1023 */
1024template <class T>
1025double NrmEngine<T>::propensity(std::size_t j, const std::vector<double>& X,
1026 const std::vector<std::vector<std::size_t>>& bufs,
1027 const std::vector<Matrix<double>>& svc) const {
1028 const Rx& x = rx_[j];
1029 const std::size_t ind = x.node, r = x.cls;
1030 if (!is_station_[ind])
1031 return x.rate * kir_frac(X, x.from, ind, r) * std::min(1.0, class_pop(X, ind, r));
1032
1033 const std::size_t ist = to_station_[ind];
1034 const double eps = lang::GlobalConstants::Zero;
1035
1036 // Buffered phase-type service: the rate reads the in-service multiset, not
1037 // the class total, which also counts the jobs still waiting.
1038 if (buf_ph_class_[ind][r]) {
1039 const std::size_t kk = x.is_phase ? x.phase_from : x.dep_phase;
1040 const std::vector<double> n = class_counts(X, ind);
1041 double tot = 0.0;
1042 for (double v : n) tot += v;
1043 return x.rate * svc[ind](r, kk) * lldfac(ist, tot);
1044 }
1045
1046 const double kf = kir_frac(X, x.from, ind, r);
1047 switch (sched_[ist]) {
1048 case SchedStrategy::EXT:
1049 // A Source fires at a constant arrival rate: its token is
1050 // fictitious, so applying the population share would silence it and
1051 // deadlock every open model.
1052 return x.rate;
1053 case SchedStrategy::INF:
1054 return x.rate * kf * class_pop(X, ind, r);
1055 case SchedStrategy::PS:
1056 case SchedStrategy::LPS: {
1057 if (K_ == 1)
1058 return x.rate * kf * std::min(mi_[ind], class_pop(X, ind, r)) *
1059 lldfac(ist, class_pop(X, ind, r));
1060 const std::vector<double> n = class_counts(X, ind);
1061 double tot = 0.0;
1062 for (double v : n) tot += v;
1063 return x.rate * kf * (n[r] / (eps + tot)) * std::min(mi_[ind], eps + tot) *
1064 lldfac(ist, tot);
1065 }
1066 case SchedStrategy::DPS: {
1067 const std::vector<double> n = class_counts(X, ind);
1068 double tot = 0.0;
1069 for (double v : n) tot += v;
1070 return x.rate * kf * dpsshare(wnorm_[ist], n, r) * lldfac(ist, tot);
1071 }
1072 case SchedStrategy::GPS: {
1073 const std::vector<double> n = class_counts(X, ind);
1074 double tot = 0.0;
1075 for (double v : n) tot += v;
1076 return x.rate * kf * gpsshare(wnorm_[ist], n, r) * lldfac(ist, tot);
1077 }
1078 // The three PRIORITY sharing disciplines. Below the server count each
1079 // is its non-priority twin; above it only the most urgent non-empty
1080 // group shares the servers. Note that PSPRIO reads the lld factor at
1081 // the FULL population and the other two at the priority-restricted one
1082 // -- `prio_pop` carries that asymmetry, which comes from the reference.
1083 case SchedStrategy::PSPRIO: {
1084 const std::vector<double> n = class_counts(X, ind);
1085 double tot = 0.0;
1086 for (double v : n) tot += v;
1087 return x.rate * kf * psprioshare(n, r, mi_[ind]) * lldfac(ist, tot);
1088 }
1089 case SchedStrategy::DPSPRIO: {
1090 const std::vector<double> n = class_counts(X, ind);
1091 return x.rate * kf * dpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1092 lldfac(ist, prio_pop(n, r, mi_[ind]));
1093 }
1094 case SchedStrategy::GPSPRIO: {
1095 const std::vector<double> n = class_counts(X, ind);
1096 return x.rate * kf * gpsprioshare(wnorm_[ist], n, r, mi_[ind]) *
1097 lldfac(ist, prio_pop(n, r, mi_[ind]));
1098 }
1099 case SchedStrategy::FCFS:
1100 case SchedStrategy::LCFS:
1101 case SchedStrategy::SIRO:
1102 case SchedStrategy::HOL:
1103 case SchedStrategy::SEPT:
1104 case SchedStrategy::LEPT:
1105 case SchedStrategy::LCFSPR: {
1106 // Invariant: numel(buf) == max(0, total - mi), so the jobs in
1107 // service are the class population minus its share of the buffer.
1108 double waiting = 0.0;
1109 for (std::size_t c : bufs[ind])
1110 if (c == r) waiting += 1.0;
1111 const std::vector<double> n = class_counts(X, ind);
1112 double tot = 0.0;
1113 for (double v : n) tot += v;
1114 return x.rate * kf * std::max(0.0, n[r] - waiting) * lldfac(ist, tot);
1115 }
1116 default:
1117 throw UnsupportedError(
1118 "solver_ssa_nrm: the scheduling policy '" +
1119 std::string(lang::sched_to_text(sched_[ist])) + "' at station '" +
1120 sn_.stations[ist].name + "' has no NRM rate law in this port");
1121 }
1122}
1123
1124// ---------------------------------------------------------------------------
1125// Buffer maintenance
1126// ---------------------------------------------------------------------------
1127
1128/** `pickFromBuffer`: the waiting job the discipline promotes, newest-first buffer. */
1129template <class T>
1130std::size_t NrmEngine<T>::pick_from_buffer(const std::vector<std::size_t>& buf, std::size_t ist) {
1131 switch (sched_[ist]) {
1132 case SchedStrategy::FCFS:
1133 return buf.size() - 1; // oldest
1134 case SchedStrategy::LCFS:
1135 case SchedStrategy::LCFSPR:
1136 return 0; // newest, or the most recently preempted
1137 case SchedStrategy::SIRO:
1138 return rng_.index(buf.size());
1139 case SchedStrategy::HOL: {
1140 // Highest priority (lowest value), FCFS within the group, so the
1141 // oldest is the LAST matching position.
1142 double best = std::numeric_limits<double>::infinity();
1143 for (std::size_t c : buf) best = std::min(best, classprio_[c]);
1144 for (std::size_t p = buf.size(); p-- > 0;)
1145 if (classprio_[buf[p]] == best) return p;
1146 return buf.size() - 1;
1147 }
1148 case SchedStrategy::SEPT:
1149 case SchedStrategy::LEPT: {
1150 // schedparam carries the rank of the class's mean service time,
1151 // ascending for SEPT and descending for LEPT, so both promote the
1152 // waiting class of least rank.
1153 double best = std::numeric_limits<double>::infinity();
1154 for (std::size_t c : buf)
1155 best = std::min(best, num_traits<T>::to_double(sn_.stations[ist].schedparam[c]));
1156 for (std::size_t p = buf.size(); p-- > 0;)
1157 if (num_traits<T>::to_double(sn_.stations[ist].schedparam[buf[p]]) == best)
1158 return p;
1159 return buf.size() - 1;
1160 }
1161 default:
1162 throw UnsupportedError("solver_ssa_nrm: '" +
1163 std::string(lang::sched_to_text(sched_[ist])) +
1164 "' is not a buffered policy this port promotes from");
1165 }
1166}
1167
1168/** `drawEntryPhase`: the phase a job starts service in, drawn from pie. */
1169template <class T>
1170std::size_t NrmEngine<T>::draw_entry_phase(std::size_t ind, std::size_t r) {
1171 if (nph_[ind][r] <= 1) return 0;
1172 return rng_.draw(pie_[ind][r]);
1173}
1174
1175/**
1176 * `capacityLoss`: an OPEN arrival at a full physically-capped station is lost.
1177 *
1178 * A refused CLOSED job must block rather than vanish from the conserved
1179 * population, so it is not dropped here; `State.arrivalIsLost` draws that line
1180 * on the class type and this reproduces it. `isPhysicalCapacity` is the drop
1181 * rule test -- a bound that exists only as a state-space cutoff keeps the WAITQ
1182 * default and must not become a loss.
1183 */
1184template <class T>
1185bool NrmEngine<T>::capacity_loss(const std::vector<double>& X, std::size_t slot) const {
1186 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1187 if (!is_station_[jnd]) return false;
1188 const std::size_t ist = to_station_[jnd];
1189 const qn::DropStrategy dr = sn_.droprule[ist][dst];
1190 if (dr == qn::DropStrategy::WAITQ) return false; // cutoff-only, or unset
1191 if (dr == qn::DropStrategy::BAS || dr == qn::DropStrategy::BBS ||
1192 dr == qn::DropStrategy::RSRD)
1193 throw UnsupportedError("solver_ssa_nrm: station '" + sn_.stations[ist].name +
1194 "' declares the blocking drop rule for class '" +
1195 sn_.classes[dst].name +
1196 "'; blocking-after-service is not ported to the C++ NRM");
1197 if (!std::isinf(sn_.classes[dst].population)) return false; // closed: block, never lose
1198 const std::vector<double> cc = class_counts(X, jnd);
1199 double tot = 0.0;
1200 for (double v : cc) tot += v;
1201 if (!std::isinf(sn_.cap[ist]) && tot >= sn_.cap[ist]) return true;
1202 const double ccap = sn_.classcap[ist][dst];
1203 return ccap > 0.0 && !std::isinf(ccap) && cc[dst] >= ccap;
1204}
1205
1206/**
1207 * `capacityBlock`: a refused arrival that may NOT be dropped cancels the firing.
1208 *
1209 * `capacity_loss` above answers the OPEN half of the same question and returns
1210 * false for a closed class precisely because a closed network's population is an
1211 * invariant. Nothing then stopped the reaction, so the NRM fired into the full
1212 * station anyway and reported the UNCONSTRAINED answer (BUG-81): on a closed
1213 * 3-queue tandem, N=6, Q2 capped at 2, QLen came back [1.96 2.07 1.97] against
1214 * the exact [3.609 0.971 1.420] -- a mean of 2.07 at a station that holds 2 --
1215 * while the serial engine, whose producer already implements the contract, gave
1216 * the exact answer.
1217 *
1218 * Blocking is the THIRD outcome of a firing, next to moving the job and losing
1219 * it. The departure does not occur, the source is not decremented, no buffer
1220 * moves; only the reaction's own clock is redrawn, which is exact by
1221 * memorylessness -- the residual of an exponential, or of the current PH phase,
1222 * is that same exponential. It is what `solver_ctmc` does when the target state
1223 * is absent from the enumerated space and the arc is dropped, which is why the
1224 * two agree.
1225 *
1226 * The population read is the PRE-arrival one, minus the departing job when it
1227 * currently sits at the destination node: a self-loop or a feedback arc at a
1228 * station already at cap would otherwise block itself forever, while the
1229 * reference producer sees the state AFTER the departure half.
1230 */
1231template <class T>
1232bool NrmEngine<T>::capacity_block(const std::vector<double>& X, std::size_t slot,
1233 std::size_t src_slot) const {
1234 if (!blk_on_) return false;
1235 const std::size_t jnd = slot_node_[slot], dst = slot_class_[slot];
1236 if (!is_station_[jnd]) return false;
1237 const std::size_t ist = to_station_[jnd];
1238 if (!blk_can_[ist][dst]) return false;
1239 const bool same_node = src_slot != npos && slot_node_[src_slot] == jnd;
1240 const std::vector<double> cc = class_counts(X, jnd);
1241 if (!std::isinf(sn_.cap[ist])) {
1242 double tot = 0.0;
1243 for (double v : cc) tot += v;
1244 if (same_node) tot -= 1.0;
1245 if (tot >= sn_.cap[ist]) return true;
1246 }
1247 const double ccap = sn_.classcap[ist][dst];
1248 if (ccap > 0.0 && !std::isinf(ccap)) {
1249 double pop = cc[dst];
1250 if (same_node && slot_class_[src_slot] == dst) pop -= 1.0;
1251 if (pop >= ccap) return true;
1252 }
1253 return false;
1254}
1255
1256/** `pickPreempted`: the incumbent an LCFSPR arrival displaces, by server occupancy. */
1257template <class T>
1258std::size_t NrmEngine<T>::pick_preempted(const std::vector<double>& X,
1259 const std::vector<std::size_t>& buf, std::size_t jnd,
1260 std::size_t arr_class) {
1261 std::vector<double> insvc = class_counts(X, jnd);
1262 for (std::size_t r = 0; r < K_; ++r) {
1263 for (std::size_t c : buf)
1264 if (c == r) insvc[r] -= 1.0;
1265 if (r == arr_class) insvc[r] -= 1.0; // the job that just arrived
1266 if (insvc[r] < 0.0) insvc[r] = 0.0;
1267 }
1268 double tot = 0.0;
1269 for (double v : insvc) tot += v;
1270 if (tot <= 0.0) return npos;
1271 const double u = rng_.uniform() * tot;
1272 double acc = 0.0;
1273 std::size_t last = npos;
1274 for (std::size_t r = 0; r < K_; ++r) {
1275 if (!(insvc[r] > 0.0)) continue;
1276 last = r;
1277 acc += insvc[r];
1278 if (u < acc) return r;
1279 }
1280 return last;
1281}
1282
1283/** `applyArrivalBuffer`: join a just-arrived class-s job to node jnd's buffer. */
1284template <class T>
1285void NrmEngine<T>::apply_arrival_buffer(std::size_t jnd, std::size_t s,
1286 const std::vector<double>& X,
1287 std::vector<std::vector<std::size_t>>& bufs,
1288 std::vector<Matrix<double>>& svc, bool& svc_changed) {
1289 if (!is_station_[jnd] || !detail::sched_is_buffered(sched_[to_station_[jnd]])) {
1290 // A station that holds no waiting line serves every job it admits at
1291 // once (INF, PS, LPS, DPS, GPS), so the arrival IS the service start.
1292 if (is_station_[jnd]) tag(jnd, s, false);
1293 return;
1294 }
1295 const std::vector<double> cc = class_counts(X, jnd);
1296 double total = 0.0;
1297 for (double v : cc) total += v;
1298 bool entered = false;
1299 if (total > mi_[jnd]) {
1300 if (sched_[to_station_[jnd]] == SchedStrategy::LCFSPR) {
1301 // Preempt-resume: the arrival seizes a server and the incumbent it
1302 // displaces is the one that joins the buffer, which is what leaves
1303 // the new job in service (in-service is population minus buffer).
1304 const std::size_t c = pick_preempted(X, bufs[jnd], jnd, s);
1305 if (c != npos) {
1306 bufs[jnd].insert(bufs[jnd].begin(), c);
1307 tag(jnd, c, true); // the incumbent is pushed back into the buffer
1308 }
1309 entered = true;
1310 } else {
1311 bufs[jnd].insert(bufs[jnd].begin(), s); // it waits: it starts nothing
1312 }
1313 } else {
1314 entered = true;
1315 }
1316 if (entered) tag(jnd, s, false); // the arrival seized a server
1317 if (entered && buf_ph_node_[jnd]) {
1318 svc[jnd](s, draw_entry_phase(jnd, s)) += 1.0;
1319 svc_changed = true;
1320 }
1321}
1322
1323/** `updateBuffers`: promote on a departure, join on an arrival. */
1324template <class T>
1325void NrmEngine<T>::update_buffers(std::size_t kfire, const std::vector<double>& X,
1326 std::vector<std::vector<std::size_t>>& bufs,
1327 std::size_t dest_pos, bool have_dest,
1328 std::vector<Matrix<double>>& svc, bool& svc_changed) {
1329 const Rx& x = rx_[kfire];
1330 const std::size_t ind = x.node;
1331 // The completing job leaves the phase it occupied; the promotion below
1332 // refills the freed server at a fresh entry phase.
1333 if (buf_ph_node_[ind] && x.is_buf_svc && !x.is_phase) {
1334 svc[ind](x.cls, x.dep_phase) -= 1.0;
1335 svc_changed = true;
1336 }
1337 if (is_station_[ind] && detail::sched_is_buffered(sched_[to_station_[ind]]) &&
1338 !bufs[ind].empty() && !x.is_phase) {
1339 const std::size_t pos = pick_from_buffer(bufs[ind], to_station_[ind]);
1340 const std::size_t promoted = bufs[ind][pos];
1341 bufs[ind].erase(bufs[ind].begin() + static_cast<std::ptrdiff_t>(pos));
1342 tag(ind, promoted, false); // it takes the server the completion freed
1343 if (buf_ph_node_[ind]) {
1344 svc[ind](promoted, draw_entry_phase(ind, promoted)) += 1.0;
1345 svc_changed = true;
1346 }
1347 }
1348 if (have_dest)
1349 apply_arrival_buffer(slot_node_[dest_pos], slot_class_[dest_pos], X, bufs, svc,
1350 svc_changed);
1351}
1352
1353// ---------------------------------------------------------------------------
1354// The sample path
1355// ---------------------------------------------------------------------------
1356
1357/** `next_reaction_method_direct`: the Gibson and Bruck loop with metrics in line. */
1358template <class T>
1360 const std::size_t nrx = rx_.size();
1361 SsaSolution out;
1362 out.QN = Matrix<double>(M_, K_, 0.0);
1363 out.UN = Matrix<double>(M_, K_, 0.0);
1364 out.RN = Matrix<double>(M_, K_, 0.0);
1365 out.TN = Matrix<double>(M_, K_, 0.0);
1366 out.CN.assign(K_, 0.0);
1367 out.XN.assign(K_, 0.0);
1368 out.StartN = Matrix<double>(M_, K_, 0.0);
1369 out.PreemptN = Matrix<double>(M_, K_, 0.0);
1370 out.method = "nrm";
1371 start_cnt_ = Matrix<double>(M_, K_, 0.0);
1372 preempt_cnt_ = Matrix<double>(M_, K_, 0.0);
1373 block_cnt_ = Matrix<double>(M_, K_, 0.0);
1374 if (nrx == 0) return out;
1375
1376 std::vector<double> nvec = nvec0_;
1377 std::vector<std::vector<std::size_t>> bufs = buffers0_;
1378 std::vector<Matrix<double>> svc = svcph0_;
1379
1380 std::vector<double> Ak(nrx, 0.0), Pk(nrx, 0.0), Tk(nrx, 0.0), tau(nrx, 0.0);
1381 for (std::size_t k = 0; k < nrx; ++k) {
1382 Ak[k] = propensity(k, nvec, bufs, svc);
1383 Pk[k] = -std::log(rng_.uniform());
1384 tau[k] = Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
1385 }
1386
1387 double total_time = 0.0;
1388 std::size_t n = 0;
1389 line::util::LineConsole::loop("drawing the sample path: %zu samples requested",
1390 static_cast<std::size_t>(opt_.samples));
1391 const std::size_t console_every = std::max<std::size_t>(1, opt_.samples / 20);
1392 for (; n < opt_.samples; ++n) {
1393 if ((n + 1) % console_every == 0)
1395 static_cast<long>((n + 1) / console_every),
1396 "simulated %zu of %zu samples (%.0f%%), simulated time %.4g", n + 1,
1397 static_cast<std::size_t>(opt_.samples),
1398 100.0 * static_cast<double>(n + 1) / static_cast<double>(opt_.samples),
1399 total_time);
1400 std::size_t kfire = 0;
1401 double dt = std::numeric_limits<double>::infinity();
1402 for (std::size_t k = 0; k < nrx; ++k)
1403 if (tau[k] < dt) {
1404 dt = tau[k];
1405 kfire = k;
1406 }
1407 if (std::isinf(dt))
1408 throw NumericError(
1409 "solver_ssa_nrm: deadlock -- every reaction has propensity zero, so the sample "
1410 "path cannot advance");
1411 total_time += dt;
1412
1413 // Time-integrate the metrics over this interval.
1414 for (std::size_t ist = 0; ist < M_; ++ist) {
1415 const std::size_t ind = sn_.station_to_node[ist] - 1;
1416 const std::vector<double> npop = class_counts(nvec, ind);
1417 double totpop = 0.0;
1418 for (double v : npop) totpop += v;
1419 // A Source's state slot is a FICTITIOUS TOKEN, not a queue. It is
1420 // seeded at 1, decremented by every arrival and replenished only
1421 // when a job reaches the Sink and is forwarded back, so its time
1422 // average is 1 - E[jobs in system] -- negative on any model with
1423 // more than one job in system, which is what it was reporting. A
1424 // Source holds no jobs, so QLen, Util and hence RespT/ResidT are
1425 // ZERO BY DEFINITION there and only its throughput (the arrival
1426 // rate) is a quantity; MVA, NC and MAM print exactly that row, and
1427 // the reference SSA applies the same rule downstream in
1428 // `solver_ssa.py` / `@@NetworkSolver/getAvg.m`. This is a
1429 // definition, not a clamp: it is keyed on the station's discipline
1430 // and never on the sign, so a negative anywhere else stays visible.
1431 const bool is_source = sched_[ist] == SchedStrategy::EXT;
1432 for (std::size_t k = 0; k < K_; ++k) {
1433 double dep = 0.0;
1434 for (std::size_t jd : dep_rx_[ist][k]) dep += Ak[jd];
1435 out.TN(ist, k) += dep * dt;
1436 if (is_source) continue;
1437 out.QN(ist, k) += npop[k] * dt;
1438 switch (sched_[ist]) {
1439 case SchedStrategy::INF:
1440 out.UN(ist, k) += npop[k] * dt;
1441 break;
1442 case SchedStrategy::PS:
1443 case SchedStrategy::LPS:
1444 if (totpop > 0.0)
1445 out.UN(ist, k) += (npop[k] / totpop) *
1446 std::min(nservers_[ist], totpop) / nservers_[ist] *
1447 dt;
1448 break;
1449 case SchedStrategy::DPS:
1450 out.UN(ist, k) += dpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
1451 break;
1452 case SchedStrategy::GPS:
1453 out.UN(ist, k) += gpsshare(wnorm_[ist], npop, k) / nservers_[ist] * dt;
1454 break;
1455 // The utilization of a PRIORITY discipline is the SAME
1456 // share the rate law uses, which is the invariant this
1457 // accumulator exists to preserve: reusing the sharing
1458 // factor is what keeps U = T E[S] from drifting away from
1459 // the sample path that produced T.
1460 case SchedStrategy::PSPRIO:
1461 out.UN(ist, k) += psprioshare(npop, k, nservers_[ist]) / nservers_[ist] * dt;
1462 break;
1463 case SchedStrategy::DPSPRIO:
1464 out.UN(ist, k) +=
1465 dpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
1466 break;
1467 case SchedStrategy::GPSPRIO:
1468 out.UN(ist, k) +=
1469 gpsprioshare(wnorm_[ist], npop, k, nservers_[ist]) / nservers_[ist] * dt;
1470 break;
1471 case SchedStrategy::FCFS:
1472 case SchedStrategy::LCFS:
1473 case SchedStrategy::SIRO:
1474 case SchedStrategy::HOL:
1475 case SchedStrategy::SEPT:
1476 case SchedStrategy::LEPT:
1477 case SchedStrategy::LCFSPR: {
1478 if (sn_.disabled[ist][k]) break;
1479 double waiting = 0.0;
1480 for (std::size_t c : bufs[ind])
1481 if (c == k) waiting += 1.0;
1482 out.UN(ist, k) += ((npop[k] - waiting) / nservers_[ist]) * dt;
1483 break;
1484 }
1485 default:
1486 break;
1487 }
1488 }
1489 }
1490
1491 // Apply the firing.
1492 const Rx& x = rx_[kfire];
1493 std::size_t dest_pos = npos;
1494 bool have_dest = false;
1495 // A firing has THREE outcomes, not two: it moves the job, it loses it
1496 // (the source departs either way), or it is BLOCKED -- cancelled with
1497 // the source keeping the job and no slot changing. See `capacity_block`.
1498 bool blocked = false;
1499 if (x.nnzP > 1 || !x.sd_node.empty()) {
1500 std::size_t slot;
1501 if (!x.sd_node.empty()) {
1502 // the dispatcher decides, from the state, not from a draw
1503 slot = resolve_state_dependent_dest(kfire, nvec);
1504 } else {
1505 const double u = rng_.uniform();
1506 std::size_t sel = x.cdf.size() - 1;
1507 for (std::size_t i = 0; i < x.cdf.size(); ++i)
1508 if (x.cdf[i] > u) {
1509 sel = i;
1510 break;
1511 }
1512 slot = x.to_slots[sel];
1513 }
1514 // A closed job that finds no room BLOCKS: the firing is cancelled
1515 // outright and nothing below runs, because a job that cannot leave
1516 // its station never departs at all. See `capacity_block`.
1517 if (capacity_block(nvec, slot, x.from)) {
1518 blocked = true;
1519 } else {
1520 // The loss is decided on the PRE-arrival population, so it is drawn
1521 // before the state is updated: the source still releases the job and
1522 // the destination never receives it.
1523 const bool lost = capacity_loss(nvec, slot);
1524 for (std::size_t s : x.from_slots) nvec[s] -= 1.0;
1525 if (!lost) {
1526 nvec[slot] += 1.0;
1527 dest_pos = slot;
1528 have_dest = true;
1529 }
1530 }
1531 } else if (x.det_dest != npos && !x.is_phase &&
1532 capacity_block(nvec, x.det_dest, x.from)) {
1533 blocked = true; // same closed-class block; a phase change is never one
1534 } else {
1535 const bool lost =
1536 x.det_dest != npos && !x.is_phase && capacity_loss(nvec, x.det_dest);
1537 if (lost) {
1538 nvec[x.from] -= 1.0;
1539 } else {
1540 for (std::size_t i = 0; i < NS_; ++i)
1541 if (S_(i, kfire) != 0.0) nvec[i] += S_(i, kfire);
1542 if (x.det_dest != npos) {
1543 dest_pos = x.det_dest;
1544 have_dest = true;
1545 }
1546 }
1547 }
1548
1549 bool svc_changed = false;
1550 if (blocked) {
1551 // nothing moved, so no buffer and no service phase may change
1552 if (is_station_[x.node]) block_cnt_(to_station_[x.node], x.cls) += 1.0;
1553 } else if (x.is_phase && buf_ph_node_[x.node]) {
1554 // A buffered-PH phase change frees no server and adds no arrival:
1555 // only the in-service multiset moves.
1556 svc[x.node](x.cls, x.phase_from) -= 1.0;
1557 svc[x.node](x.cls, x.phase_to) += 1.0;
1558 svc_changed = true;
1559 } else {
1560 update_buffers(kfire, nvec, bufs, dest_pos, have_dest, svc, svc_changed);
1561 }
1562
1563 for (std::size_t k = 0; k < nrx; ++k) Tk[k] += Ak[k] * dt;
1564
1565 // svcph is not part of the stoichiometry, so the static dependency set
1566 // cannot see a change to it; a move there forces a full refresh.
1567 if (svc_changed) {
1568 for (std::size_t k = 0; k < nrx; ++k) Ak[k] = propensity(k, nvec, bufs, svc);
1569 } else {
1570 for (std::size_t k : D_[kfire]) Ak[k] = propensity(k, nvec, bufs, svc);
1571 }
1572
1573 Pk[kfire] -= std::log(rng_.uniform());
1574 for (std::size_t k = 0; k < nrx; ++k)
1575 tau[k] =
1576 Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : std::numeric_limits<double>::infinity();
1577 }
1578
1579 if (total_time > 0.0)
1580 for (std::size_t ist = 0; ist < M_; ++ist)
1581 for (std::size_t k = 0; k < K_; ++k) {
1582 out.QN(ist, k) /= total_time;
1583 out.UN(ist, k) /= total_time;
1584 // net the blocked firings out of the departure-rate integral
1585 out.TN(ist, k) = (out.TN(ist, k) - block_cnt_(ist, k)) / total_time;
1586 // COUNTS over the simulated time, where TN above integrates a
1587 // rate: the two estimators of the same quantity agree in the
1588 // limit and differ by simulation error at any finite budget.
1589 out.StartN(ist, k) = start_cnt_(ist, k) / total_time;
1590 out.PreemptN(ist, k) = preempt_cnt_(ist, k) / total_time;
1591 }
1592
1593 // Every LOAD-DEPENDENT station reports the work-based T*S/peak instead,
1594 // peak = max(c, max(alpha)). The loop above integrates BUSY TIME, which is a
1595 // different quantity once alpha(n) != 1: a server running alpha(n) times
1596 // faster does the same work in less time, so busy time reads it as no busier
1597 // than one at its nominal rate. That put NRM at 0.9587 on a 4-job closed
1598 // model with alpha = [1 1.5 2 2.5] where CTMC, MVA, NC and serial SSA all
1599 // report 0.6612, and left the two SSA engines disagreeing with each other.
1600 // INF and EXT keep U = Q, as everywhere else.
1601 for (std::size_t ist = 0; ist < M_; ++ist) {
1602 if (lld_[ist].empty()) continue;
1603 double peak = nservers_[ist];
1604 bool non_unit = false;
1605 for (double a : lld_[ist]) {
1606 if (a != 1.0) non_unit = true;
1607 if (a > peak) peak = a;
1608 }
1609 if (!non_unit) continue;
1610 if (sched_[ist] == SchedStrategy::INF || sched_[ist] == SchedStrategy::EXT)
1611 continue;
1612 for (std::size_t k = 0; k < K_; ++k) {
1613 const double rate = num_traits<T>::to_double(sn_.rates(ist, k));
1614 out.UN(ist, k) = (std::isfinite(rate) && rate > 0.0 && peak > 0.0)
1615 ? out.TN(ist, k) / rate / peak
1616 : 0.0;
1617 }
1618 }
1619
1620 for (std::size_t k = 0; k < K_; ++k) {
1621 out.XN[k] = out.TN(sn_.classes[k].refstat - 1, k);
1622 for (std::size_t ist = 0; ist < M_; ++ist)
1623 out.RN(ist, k) = out.TN(ist, k) > 0.0 ? out.QN(ist, k) / out.TN(ist, k) : 0.0;
1624 if (out.XN[k] > 0.0) out.CN[k] = sn_.classes[k].population / out.XN[k];
1625 }
1626 out.simulated_time = total_time;
1627 out.samples = n;
1628 return out;
1629}
1630
1631} // namespace ssa
1632} // namespace line
1633
1634#endif // LINE_SOLVERS_SSA_SOLVER_SSA_NRM_H
InputError(const std::string &what)
Definition error.h:39
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::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::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.
SsaSolution run()
Run opt.samples firings and return the time-averaged metrics.
The uniform source, MATLAB's rand.
Definition ssa_types.h:129
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:150
std::size_t index(std::size_t n)
Uniform index in [0, n), the reference's 1 + floor(rand*n).
Definition ssa_types.h:140
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:424
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:389
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
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.
A queueing network and its refreshed NetworkStruct.
Controls, results and the random source of SolverSSA.
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:674
static constexpr double Zero
Definition lang_types.h:670
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