LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ldes_engine.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_LDES_LDES_ENGINE_H
6#define LINE_SOLVERS_LDES_LDES_ENGINE_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The NATIVE LDES discrete-event engine.
12 *
13 * WHAT THIS IS. `solvers/wrappers/ldes/solver_ldes.h` is a CLIENT: it writes a
14 * model.json, runs the Java engine as a subprocess and parses the result back.
15 * This header is the engine itself, in process, and is the C++ twin of
16 * `jline/solvers/ldes/handlers/Solver_ssj.java`. Do not confuse the two
17 * directories -- `wrappers/ldes/` shells out, `ldes/` simulates.
18 *
19 * WHAT IT COVERS TODAY. Networks over Source, Queue, Delay, Router,
20 * ClassSwitch, Logger and Sink; every renewal, Markovian and trace-driven
21 * service law the language carries (see `ldes_sampler.h`); the buffered
22 * disciplines FCFS, LCFS, SIRO, HOL, the LCFS priority variants, SJF, LJF,
23 * SEPT, LEPT, EDD, EDF, INF; the preemptive disciplines FCFSPR/PI,
24 * LCFSPR/PI, their PRIO variants, SRPT, SRPTPRIO, PSJF, FB, LRPT, SETF and
25 * FSP, in both resume and restart; the sharing disciplines PS, DPS, GPS,
26 * their priority variants and LPS; multi-server stations; class priorities;
27 * finite station and per-class capacity with DROP; balking (all three
28 * strategies, one job-count table, as the Java engine) and reneging;
29 * load dependence (`sn.lldscaling`) and class dependence (`sn.cdscaling`);
30 * open and closed classes; class switching; G-network NEGATIVE and CATASTROPHE
31 * signals, with their batch-size law and removal policy; and confidence
32 * intervals by overlapping or non-overlapping batch means.
33 *
34 * WHAT IT REFUSES, by name, in `ldes_engine_reject` (this list was rewritten on
35 * 2026-08-01 against the code; the tranche that followed the first draft added
36 * most of what the old text said was missing): the EXT discipline; a REPLY
37 * signal, whose synchronous call holds a server this engine does not model; a
38 * blocking rule that is neither DROP, BAS nor BBS; a continuously shared server
39 * or an NHPP under SLOTTED mode, neither of which lands on the slot lattice; and any
40 * node kind outside Source, Sink, Queue, Delay, Router, ClassSwitch, Logger,
41 * Cache, Place and Transition. POLLING, PAS, FSP, SETF, EDD and EDF ARE
42 * simulated, as are caches, Petri nets, fork-join, finite capacity regions,
43 * retrial orbits, setup/delay-off, MAPt/PHt, slotted time, transient runs, busy
44 * periods and replications. SERVER BREAKDOWNS are simulated as of 2026-08-15:
45 * `NetworkStruct::breakdownparam` carries the two clocks and the degraded
46 * `downServiceRates`, and the engine runs the outage as a state-dependent rate
47 * change -- a zero multiplier stalls the job in service with its residual
48 * intact, a positive one is the degraded server -- so a job is interrupted and
49 * never evicted. They are refused under SLOTTED mode, where the failure and
50 * repair epochs would have to land on the lattice too. Each refusal is a
51 * definite feature with a definite semantics, and serving one as though it were
52 * something else produces a run that finishes and reports numbers no downstream
53 * test can distinguish from correct ones.
54 *
55 * WHAT IS TRANSCRIBED FROM THE JAVA ENGINE, because these choices decide the
56 * numbers and not just the speed:
57 *
58 * - THE BUDGET IS SERVICE COMPLETIONS, not simulated time and not arrivals.
59 * - WARMUP IS MSER-5 over EVENT-SPACED observations, interval
60 * `maxEvents / 1000` completions, truncation chosen on the AGGREGATE queue
61 * length with a per-series fallback (the closed-network case, whose
62 * constant total carries no transient signal).
63 * - THE ESTIMATORS ARE TIME-WEIGHTED AND TRUNCATION-DIFFERENCED: QLen and
64 * Util are (integral at the end - integral at the truncation) / elapsed,
65 * never an unweighted mean of the per-interval averages, which overweights
66 * congested epochs because they contain more events per unit time.
67 * - RESPONSE TIME IS THE FULL TALLY. `getAvgResponseTime` averages every
68 * observation including the warmup ones, so Little's law holds only up to
69 * the warmup mass. That is the reference's behaviour, mirrored rather than
70 * corrected, or the two engines would disagree on a short run.
71 * - THE SOURCE ROW OF TN IS THE NOMINAL ARRIVAL RATE, not a measured one.
72 *
73 * RANDOMNESS, AND HOW FAR IT NOW AGREES WITH THE JAVA ENGINE. The generators
74 * are no longer Mersenne Twisters: `ldes_sampler.h` draws every renewal family
75 * from `rng::Mrg32k3a`, a bit-exact reproduction of SSJ's MRG32k3a, through the
76 * inverse-CDF quantiles of `ldes_ssj_variates.h`, and every Markovian family
77 * from `rng::JavaRandom` through the transcribed `Map_sample.MapSampler` walk,
78 * because the reference sends those two families to those two generators. What
79 * is NOT yet aligned is stream OWNERSHIP: `Solver_ssj` seeds one stream per
80 * (node, class) from `seed + offset`, while this engine still carries three
81 * streams for the whole run (arrivals, services, routing). Until that is
82 * matched a fixed seed does not reproduce the Java sample path, so a
83 * cross-engine test must still compare means against a tolerance derived from
84 * the standard error rather than element by element. The seed bands are kept separate
85 * per role (arrivals, services, routing) for the reason the Java engine
86 * separates them: one shared stream makes the routing draw the very uniform
87 * that generated the interarrival time, which correlates a probabilistic split
88 * with the gap length and biases the destinations in opposite directions.
89 */
90
91#include <algorithm>
92#include <array>
93#include <cmath>
94#include <cstddef>
95#include <cstdint>
96#include <cstdio>
97#include <cstdlib>
98#include <ctime>
99#include <deque>
100#include <exception>
101#include <fstream>
102#include <functional>
103#include <limits>
104#include <map>
105#include <memory>
106#include <numeric>
107#include <queue>
108#include <random>
109#include <string>
110#include <thread>
111#include <vector>
112
115#include "line/lang/qn/state.h"
125#include "line/util/error.h"
126#include "line/util/matrix.h"
127
128namespace line {
129namespace ldes {
130namespace engine {
131
132/**
133 * Station roles this engine simulates.
134 *
135 * A Fork and a Join appear in `sn.stations` because the refresh registers
136 * them, but they SERVE NOTHING: they are handled by the routing layer, which
137 * replicates at one and synchronizes at the other. Giving them a role of their
138 * own keeps them out of the service-station setup, which would otherwise
139 * demand a service law they do not have.
140 */
142
143/**
144 * Event tags, ordered so that a service completion precedes an arrival
145 * scheduled for the same instant.
146 *
147 * The order matters on any model whose rates produce exact ties (a Det
148 * service, a closed class placed at t=0), and it is the completions-then-
149 * arrivals phase order of the Java engine's `slotPhase`. Ties inside one tag
150 * break on the insertion sequence, so the event list is a total order and the
151 * path is reproducible from the seed.
152 */
162 /**
163 * A service completion in the EMBEDDED QUEUE of a queueing place: the token
164 * leaves the place's servers for its depository, where the output arcs can
165 * finally see it. `station` is the place SLOT and `cls` the token colour.
166 *
167 * It sorts AFTER a firing at the same instant, which matters only for the
168 * degenerate Immediate service law: a token whose service ends exactly when
169 * a transition fires reaches the depository after that firing read it, which
170 * is the reference's own order (`placeDepartureActions` re-checks enabling
171 * once it has deposited, so the firing simply happens next).
172 */
174 /** A job abandons the retrial orbit (`sn.orbitImpatience`); `tag` is its id. */
176};
177
178/** One scheduled event. */
179struct Event {
180 double t = 0.0;
182 std::size_t station = 0;
183 std::size_t cls = 0;
184 std::size_t slot = 0; ///< server slot at a buffered station
185 /**
186 * Identifies the job the event was scheduled for.
187 *
188 * A preempted or resharing job leaves a departure event behind that the
189 * event list gives no way to cancel, so every completion carries the tag
190 * its job held when it was scheduled and is DISCARDED if the two no longer
191 * agree. Without it a preempted job would depart twice: once on its stale
192 * event and once on the one its resumption schedules.
193 */
194 std::uint64_t tag = 0;
195 Job job; ///< Delay and PS carry the job in the event
196 std::uint64_t seq = 0;
197};
198
199/** Later-is-greater, so the priority queue pops the earliest event. */
201 bool operator()(const Event& a, const Event& b) const {
202 if (a.t != b.t) return a.t > b.t;
203 if (a.kind != b.kind) return a.kind > b.kind;
204 return a.seq > b.seq;
205 }
206};
207
208/** One routing alternative out of a (node, class) pair. */
210 std::size_t node = 0; ///< 1-based destination NODE
211 std::size_t station = 0; ///< its station index, or M when it is not one
212 std::size_t cls = 0;
213 bool sink = false;
214 double cum = 0.0;
215};
216
217/**
218 * One destination NODE of a (node, class) pair, with the class it switches to.
219 *
220 * THE STRATEGY CHOOSES THE NODE AND THE SWITCH ROW CHOOSES THE CLASS, in that
221 * order, which is `selectDestinationWithClassSwitch` and the reason the two are
222 * separated at all. A strategy applied to (node, class) PAIRS would resample
223 * the class as well -- uniformly under RAND, in turn under RROBIN -- and
224 * destroy the class-switch probabilities the pair was carrying.
225 *
226 * The list is built in ASCENDING NODE INDEX because a round-robin pointer walks
227 * it in order: any other order is a different, and equally arbitrary, cycle.
228 */
229struct RouteDest {
230 std::size_t node = 0; ///< 1-based destination NODE
231 std::size_t station = 0; ///< its station index, or M when it is not one
232 bool sink = false;
233 double mass = 0.0; ///< total probability into this node, over classes
234 std::vector<std::pair<std::size_t, double>> cls; ///< (class, probability)
235};
236
237/**
238 * A double as Java's `Double.toString` spells it, which is how the reference's
239 * Logger writes its time columns: the shortest digit string that reads back to
240 * the same value, plain between 1e-3 and 1e7 and `d.dddE<n>` outside, always
241 * with one digit after the point (`1.0`, `2.5E-4`).
242 */
243inline std::string java_double_str(double v) {
244 if (std::isnan(v)) return "NaN";
245 if (std::isinf(v)) return v > 0 ? "Infinity" : "-Infinity";
246 if (v == 0.0) return std::signbit(v) ? "-0.0" : "0.0";
247 char buf[40];
248 int p = 1;
249 for (; p < 17; ++p) {
250 std::snprintf(buf, sizeof buf, "%.*e", p - 1, v);
251 if (std::strtod(buf, nullptr) == v) break;
252 }
253 std::snprintf(buf, sizeof buf, "%.*e", p - 1, v);
254 const std::string e(buf);
255 const bool neg = e[0] == '-';
256 const std::size_t epos = e.find('e');
257 std::string digits;
258 for (std::size_t i = neg ? 1 : 0; i < epos; ++i)
259 if (e[i] != '.') digits += e[i];
260 const int ex = std::atoi(e.c_str() + epos + 1);
261 while (digits.size() > 1 && digits[digits.size() - 1] == '0') digits.erase(digits.size() - 1);
262 std::string out = neg ? "-" : "";
263 const double a = std::fabs(v);
264 if (a >= 1e-3 && a < 1e7) {
265 if (ex >= 0) {
266 const std::size_t ip = static_cast<std::size_t>(ex) + 1;
267 std::string ipart = digits.substr(0, std::min(ip, digits.size()));
268 while (ipart.size() < ip) ipart += '0';
269 const std::string fpart = digits.size() > ip ? digits.substr(ip) : "0";
270 out += ipart + "." + fpart;
271 } else {
272 out += "0." + std::string(static_cast<std::size_t>(-ex - 1), '0') + digits;
273 }
274 } else {
275 out += digits.substr(0, 1) + "." + (digits.size() > 1 ? digits.substr(1) : "0") + "E" +
276 std::to_string(ex);
277 }
278 return out;
279}
280
281/**
282 * Refuse, by name, every model outside this engine's scope.
283 *
284 * The refusal is the point. A PS station served FCFS, a preemptive-resume
285 * station served non-preemptively, or a blocking model run without blocking
286 * all finish and report plausible numbers; nothing downstream can tell them
287 * from correct ones, so the check has to happen here, before the run.
288 */
289template <class T>
291 using lang::NodeType;
293
294 // A USER-DEFINED ACCESS-COST MATRIX (Cache.setAccessProb / setAccessGraph) is not read by this engine, which
295 // always promotes one list per hit and inserts misses into the first list -- the default matrix. Anything else
296 // would be simulated as the default, so it is refused and the subprocess client falls back to the JAR engine,
297 // which samples the target list from the matrix as MATLAB afterEventCache does.
298 for (const auto& kv : sn.nodeparam) {
299 const qn::CacheParam<T>& cp = kv.second;
300 if (cp.accost.empty()) continue;
301 for (const auto& row : cp.accost)
302 for (const Matrix<T>& A : row) {
303 const std::size_t H = A.rows();
304 if (H == 0) continue;
305 for (std::size_t r = 0; r < H; ++r)
306 for (std::size_t c = 0; c < A.cols(); ++c) {
307 const std::size_t want = (r == 0) ? 1 : std::min(r + 1, H - 1);
308 const double v = num_traits<T>::to_double(A(r, c));
309 const double expect = (c == want) ? 1.0 : 0.0;
310 // rows only matter up to normalization: compare the support, not the scale
311 if ((v > 0.0) != (expect > 0.0))
312 throw UnsupportedError(ErrorKind{"CacheAccessProb"},
313 "SolverLDES (native engine): a user-defined cache access-cost matrix "
314 "(setAccessProb/setAccessGraph) is not read by this engine; use the subprocess client");
315 }
316 }
317 }
318
319 for (const auto& nd : sn.nodes) {
320 switch (nd.nodetype) {
321 case NodeType::Source:
322 case NodeType::Sink:
323 case NodeType::Queue:
324 case NodeType::Delay:
325 case NodeType::Router:
326 case NodeType::ClassSwitch:
327 case NodeType::Logger:
328 case NodeType::Fork:
329 case NodeType::Join:
330 case NodeType::Cache:
331 case NodeType::Place:
332 case NodeType::Transition:
333 break;
334 default:
335 throw UnsupportedError(ErrorKind{"NodeType"},
336 std::string("SolverLDES (native engine): node kind ") +
337 lang::node_type_to_text(nd.nodetype) +
338 " is not ported yet; use the subprocess client");
339 }
340 }
341 // HETEROGENEOUS SERVER POOLS are simulated here as of 2026-09-04: each job
342 // occupies one server of one compatible pool and is served at that pool's
343 // own law, which is what makes a pooled model a different system from the
344 // multiserver it would otherwise look like. What is NOT ported is the pools
345 // under a SHARING discipline, where the reference has no per-pool treatment
346 // either -- `calculatePSRates` never consults the server types, so the JAR
347 // silently flattens them. Rather than reproduce that, the sharing case is
348 // refused by name; the max-min fair allocation it would need is the layered
349 // engine's `SnCompatShare`, and neither engine carries it for a flat model.
350 //
351 // A FIFO depository is still understood by no engine at all.
352 for (std::size_t si = 0; si < sn.stations.size(); ++si) {
353 const auto& st = sn.stations[si];
354 if (!st.server_types.empty()) {
355 const SchedStrategy sc = st.sched;
356 const bool sharing = (sc == SchedStrategy::PS || sc == SchedStrategy::DPS ||
357 sc == SchedStrategy::GPS || sc == SchedStrategy::LPS ||
358 sc == SchedStrategy::PSPRIO || sc == SchedStrategy::DPSPRIO ||
359 sc == SchedStrategy::GPSPRIO);
360 if (sharing)
361 throw UnsupportedError(ErrorKind{"HeteroServers"},
362 std::string("SolverLDES (native engine): station '") + st.name +
363 "' declares heterogeneous server pools under a sharing discipline, which "
364 "no engine allocates per pool; use a queueing discipline, or the "
365 "subprocess client, which flattens them");
366 if (sc == SchedStrategy::POLLING || sc == SchedStrategy::PAS)
367 throw UnsupportedError(ErrorKind{"HeteroServers"},
368 std::string("SolverLDES (native engine): station '") + st.name +
369 "' declares heterogeneous server pools under " +
370 std::string(lang::sched_to_text(sc)) +
371 ", whose controller picks the server itself and has no pool to pick from");
372 }
373 for (lang::DepartureDiscipline d : st.departure_discipline)
375 throw UnsupportedError(ErrorKind{"DepartureDiscipline"},
376 std::string("SolverLDES (native engine): station '") + st.name +
377 "' sets a non-NORMAL depository departure discipline, which no "
378 "engine implements");
379 // A TIME-INHOMOGENEOUS MATRIX-SCHEDULE SERVICE (PHt or MAPt) is sampled
380 // by walking a phase process forward from the SERVICE START instant,
381 // which is exact only while service is then delivered continuously at
382 // unit rate. Sharing hands the job a varying fraction of the server,
383 // preemption interrupts the walk, a load- or class-dependent rate
384 // rescales it, and a pool serves it under a law of its own; none of
385 // those is recoverable from a start instant alone, and unlike an NHPP
386 // rate there is no operational-time transform to fall back on, because
387 // tau = int mu du presumes a SCALAR rate while these vary their matrix
388 // entries independently. Refusing mirrors the reference engine's
389 // `assertMatrixScheduleServiceSupported`; the alternative is a silently
390 // wrong sample path.
391 for (std::size_t r = 0; r < sn.service[si].size(); ++r) {
392 const lang::ProcessType pt = sn.service[si][r].type;
393 if (pt != lang::ProcessType::PHT && pt != lang::ProcessType::MAPT) continue;
394 const SchedStrategy sc = st.sched;
395 const std::string family = (pt == lang::ProcessType::MAPT) ? "MAPt" : "PHt";
396 const std::string where = std::string(" service is not supported at station '") +
397 st.name + "' for class '" + sn.classes[r].name + "'";
398 if (is_ps_family(sc) || is_preemptive(sc))
399 throw UnsupportedError(ErrorKind{"MAPt"}, "SolverLDES (native engine): " + family + where +
400 " under " + std::string(lang::sched_to_text(sc)) +
401 ". A time-inhomogeneous matrix schedule is walked from"
402 " the service start instant, which is exact only while"
403 " service runs continuously at unit rate; use INF or a"
404 " non-preemptive FCFS/LCFS-family discipline");
405 if (!st.lldscaling.empty())
406 throw UnsupportedError(ErrorKind{"LoadDependence"}, "SolverLDES (native engine): " + family + where +
407 " with load dependence: the rate rescaling cannot be"
408 " applied to a phase walk taken from the service start"
409 " instant");
410 if (sn.gdscaling)
411 throw UnsupportedError(ErrorKind{"GlobalDependence"}, "SolverLDES (native engine): " + family + where +
412 " with a global dependence: phi(n) rescales the rate at"
413 " every population change anywhere in the network, which"
414 " a phase walk taken from the service start instant"
415 " cannot be asked for");
416 if (!st.server_types.empty())
417 throw UnsupportedError(ErrorKind{"HeteroServers"}, "SolverLDES (native engine): " + family + where +
418 " with heterogeneous server types: the pool draws its"
419 " own duration, which the walk cannot be asked for");
420 }
421 }
422 for (std::size_t i = 0; i < sn.nstations; ++i) {
423 const auto& st = sn.stations[i];
424 if (st.nodetype == NodeType::Source) continue;
425 switch (st.sched) {
426 case SchedStrategy::INF:
427 case SchedStrategy::FCFS:
428 case SchedStrategy::LCFS:
429 case SchedStrategy::SIRO:
430 case SchedStrategy::HOL:
431 case SchedStrategy::LCFSPRIO:
432 case SchedStrategy::SJF:
433 case SchedStrategy::LJF:
434 case SchedStrategy::SEPT:
435 case SchedStrategy::LEPT:
436 case SchedStrategy::PS:
437 case SchedStrategy::DPS:
438 case SchedStrategy::GPS:
439 case SchedStrategy::PSPRIO:
440 case SchedStrategy::DPSPRIO:
441 case SchedStrategy::GPSPRIO:
442 case SchedStrategy::LPS:
443 case SchedStrategy::LCFSPR:
444 case SchedStrategy::LCFSPI:
445 case SchedStrategy::LCFSPRPRIO:
446 case SchedStrategy::LCFSPIPRIO:
447 case SchedStrategy::FCFSPR:
448 case SchedStrategy::FCFSPI:
449 case SchedStrategy::FCFSPRPRIO:
450 case SchedStrategy::FCFSPIPRIO:
451 case SchedStrategy::SRPT:
452 case SchedStrategy::SRPTPRIO:
453 case SchedStrategy::PSJF:
454 case SchedStrategy::FB:
455 case SchedStrategy::LRPT:
456 case SchedStrategy::SETF:
457 case SchedStrategy::FSP:
458 case SchedStrategy::EDD:
459 case SchedStrategy::EDF:
460 case SchedStrategy::POLLING:
461 case SchedStrategy::PAS:
462 case SchedStrategy::OI:
463 break;
464 default:
465 // EXT IS NOT A SERVICE DISCIPLINE AND IS REFUSED ON PURPOSE.
466 // `Source.m` and `Sink.m` set it as their own marker, so it
467 // never reaches a served station by any supported route; a
468 // Queue explicitly built with it is refused by the reference
469 // too, at its model layer -- `Queue.<init>` throws "Routing
470 // Strategy is not supported in JLINE" before the Java engine
471 // sees the model. Serving it as INF, which is how `npfqn_sqd`
472 // and `sn_has_product_form` group it, would be a guess at a
473 // semantics neither reference implements.
474 throw UnsupportedError(ErrorKind{"SchedStrategy_EXT"},
475 "SolverLDES (native engine): station '" + st.name +
476 "' uses a scheduling this engine does not simulate (EXT is the "
477 "Source and Sink marker, not a queueing discipline)");
478 }
479 }
480 // BAS and BBS are simulated, and so is RETRIAL on a class that declares a
481 // retrial process (the capacity then bounds service plus orbit, setOrbit's
482 // finite orbit); RSRD is not. Only a rule on a CAPPED station can ever fire,
483 // and the refresh gives every station a default rule including the Source,
484 // so an uncapped station is not consulted -- rejecting on the default would
485 // refuse every model.
486 for (std::size_t i = 0; i < sn.nstations; ++i) {
487 if (sn.stations[i].nodetype == NodeType::Source) continue;
488 if (!std::isfinite(sn.stations[i].cap)) continue;
489 const auto rpit = sn.retrialparam.find(i + 1);
490 for (std::size_t r = 0; r < sn.nclasses; ++r) {
491 if (i >= sn.droprule.size() || r >= sn.droprule[i].size()) continue;
492 const lang::DropStrategy d = sn.droprule[i][r];
493 const bool simulated_retrial =
495 rpit != sn.retrialparam.end() && r < rpit->second.retrial_proc.size() &&
496 !rpit->second.retrial_proc[r].disabled;
497 if (simulated_retrial) continue;
500 throw UnsupportedError(ErrorKind{"FiniteCapacity"},
501 "SolverLDES (native engine): station '" + sn.stations[i].name +
502 "' declares a blocking rule that is not DROP, BAS or BBS");
503 }
504 }
505 // AN ARRIVAL BATCH IS DRAWN AND RELEASED (see the EV_ARRIVAL arm). It used to be
506 // stored and ignored -- `Station::arrival_batch` crossed the wire and BatchArrival
507 // was declared, but every epoch released one job, so an M[3]/M/2 queue came back
508 // as the M/M/2 queue at a third of the task rate -- and was then refused by name
509 // rather than answered wrongly. Both are now gone.
510 //
511 // `Source.setArrivalBatch` and a process that carries its OWN batch sizes are
512 // MUTUALLY EXCLUSIVE: a BMAP or a BMMAPt already says how many jobs an epoch
513 // releases, so a second law bolted on top would multiply the two.
514 for (std::size_t i = 0; i < sn.nstations; ++i) {
515 const auto& stb = sn.stations[i];
516 for (std::size_t r = 0; r < stb.arrival_batch.size(); ++r) {
517 if (stb.arrival_batch[r].disabled) continue;
518 if (r < sn.service[i].size() && lang::process_is_batch(sn.service[i][r].type))
519 throw InputError(
520 "SolverLDES (native engine): station '" + stb.name + "' class '" +
521 sn.classes[r].name + "' declares BOTH an arrival batch and a " +
522 lang::process_to_text(sn.service[i][r].type) +
523 " arrival process, which carries its own batch sizes; the two are "
524 "mutually exclusive");
525 }
526 }
527 // A BULK SERVER (BMSP) is refused with immediate feedback and with a blocking
528 // destination, the two model-level features its per-firing release cannot honour.
529 // Feedback would have to hand a completed job back into a server the firing clock
530 // owns rather than a slot, and blocking has no answer to "which of the b jobs
531 // just released is the one that blocks". The per-station guards -- single server,
532 // single class, FCFS, no preemption/polling/PAS/setup/load dependence/pools --
533 // are applied where the stations are built.
534 for (std::size_t i = 0; i < sn.nstations; ++i) {
535 if (sn.stations[i].nodetype == NodeType::Source) continue;
536 bool bulk = false;
537 for (std::size_t r = 0; r < sn.nclasses && r < sn.service[i].size(); ++r)
538 if (!sn.disabled[i][r] && lang::process_is_batch(sn.service[i][r].type)) bulk = true;
539 if (!bulk) continue;
540 for (std::size_t r = 0; r < sn.nclasses; ++r)
541 if (i < sn.immfeed.size() && r < sn.immfeed[i].size() && sn.immfeed[i][r])
542 throw UnsupportedError(ErrorKind{"BMAP"},
543 "SolverLDES (native engine): batch (BMSP) service at station '" +
544 sn.stations[i].name + "' is incompatible with immediate feedback");
545 for (std::size_t d = 0; d < sn.nstations && d < sn.droprule.size(); ++d)
546 for (std::size_t r = 0; r < sn.droprule[d].size(); ++r)
547 if (sn.droprule[d][r] == lang::DropStrategy::BAS ||
548 sn.droprule[d][r] == lang::DropStrategy::BBS)
549 throw UnsupportedError(ErrorKind{"BMAP"},
550 "SolverLDES (native engine): batch (BMSP) service at station '" +
551 sn.stations[i].name + "' is incompatible with the blocking rule at "
552 "station '" + sn.stations[d].name +
553 "'; a firing releases several jobs at once and none of them can hold "
554 "the server on the destination's behalf");
555 }
556 // A REPLY SIGNAL completes a SYNCHRONOUS CALL: the caller keeps its server
557 // while the callee runs and gets it back only when the reply arrives. That
558 // is simulated -- see the `pending_reply` map below -- but only where a
559 // server EXISTS to hold. A sharing station divides one server continuously
560 // among everyone present and has no slot to park, so a caller there would
561 // be served as though its call were asynchronous.
562 for (std::size_t r = 0; r < sn.nclasses; ++r) {
563 if (r >= sn.syncreply.size() || sn.syncreply[r] == 0) continue;
564 for (std::size_t i = 0; i < sn.nstations; ++i) {
565 if (sn.stations[i].nodetype == NodeType::Source) continue;
566 if (i < sn.disabled.size() && r < sn.disabled[i].size() && sn.disabled[i][r]) continue;
567 if (is_ps_family(sn.stations[i].sched) || sn.stations[i].sched == SchedStrategy::INF)
568 throw UnsupportedError(ErrorKind{"SyncCall"},
569 "SolverLDES (native engine): class '" + sn.classes[r].name +
570 "' makes a synchronous call at station '" + sn.stations[i].name +
571 "', which has no server slot to hold across it");
572 }
573 }
574 // SLOTTED TIME EXCLUDES TWO FAMILIES, and refuses rather than approximates.
575 // NHPP integrates a cumulative intensity over CONTINUOUS time, so its
576 // inverse transform does not land on the lattice; the PS family divides the
577 // server continuously among the jobs in service, which has no discrete-time
578 // counterpart at all. Serving either on a lattice would answer a different
579 // model.
580 if (o.slotted) {
581 for (std::size_t i = 0; i < sn.nstations; ++i) {
582 if (sn.stations[i].nodetype == NodeType::Source) continue;
583 if (is_ps_family(sn.stations[i].sched))
584 throw UnsupportedError(ErrorKind{"Slotted"},
585 "SolverLDES (native engine): station '" + sn.stations[i].name +
586 "' shares its server continuously, which slotted mode has no "
587 "counterpart for");
588 for (std::size_t r = 0; r < sn.nclasses; ++r)
589 if (!sn.disabled[i][r] && sn.procid(i + 1, r + 1) == lang::ProcessType::NHPP)
590 throw UnsupportedError(ErrorKind{"Slotted"},
591 "SolverLDES (native engine): station '" + sn.stations[i].name +
592 "' carries an NHPP, whose intensity is integrated over continuous "
593 "time and does not land on the slot lattice");
594 }
595 for (std::size_t i = 0; i < sn.nstations; ++i)
596 if (sn.breakdownparam.find(i + 1) != sn.breakdownparam.end())
597 throw UnsupportedError(ErrorKind{"Slotted"},
598 "SolverLDES (native engine): station '" + sn.stations[i].name +
599 "' declares a server breakdown, whose failure and repair epochs are drawn on "
600 "a continuous clock and do not land on the slot lattice");
601 }
602}
603
604/** One station's mutable state. */
608 bool ps = false;
609 bool preemptive = false;
610 bool resume = false; ///< PR (continue the residual) as against PI (redraw)
611 /**
612 * The waiting room is ranked by the job's SAMPLED size, so the size has to
613 * exist before the job enters service. A time-inhomogeneous law is drawn at
614 * the service start instant everywhere else (see `draw_at_arrival`); here it
615 * cannot be, and is drawn on arrival as the reference does.
616 */
617 bool size_ordered = false;
618 std::size_t nservers = 1;
619 std::size_t lps_limit = 0; ///< LPS admission cap; 0 = none
620 double cap = std::numeric_limits<double>::infinity();
621 std::vector<double> classcap;
622 /**
623 * `sn.droprule`, per class, declared on the DESTINATION station.
624 *
625 * JMT's convention and the reference's: the policy belongs to the station
626 * whose capacity is limited, not to the upstream one whose server ends up
627 * blocked. A model that declared it on the source would be read as having
628 * no policy at all.
629 */
630 std::vector<lang::DropStrategy> droprule;
631 /**
632 * Jobs held OUTSIDE this station's buffer but counted in its queue length:
633 * a BAS or BBS job whose destination is full occupies the upstream server
634 * and is charged to the destination, which is where it is queueing for.
635 * Charging it upstream would let a full station report a queue below its
636 * own capacity while jobs pile up in front of it.
637 */
638 std::vector<double> blocked_at;
639 std::vector<double> weight; ///< schedparam, the DPS/GPS weights
640 std::vector<double> class_mean; ///< mean service per class, for SEPT/LEPT
641 /**
642 * `sn.lldscaling`: the RATE multiplier when n jobs are present, indexed
643 * n-1. It divides the sampled service time at an ordinary station and IS
644 * the effective server count at a sharing one -- the same table read two
645 * ways, because a share of c servers and a c-fold rate are the same thing
646 * only for PS.
647 */
648 std::vector<double> lld;
649 /** `sn.cdscaling`: beta_r(n), a per-class RATE at the station's population. */
651 bool has_cd = false;
652 /**
653 * A PASS-AND-SWAP station serves an ORDERED LIST whose total rate is a
654 * function of the whole class sequence (Dorsman and Gardner 2024).
655 *
656 * The rate of the token at position p is the INCREMENT
657 * mu(c1..cp) - mu(c1..c_{p-1}), which is what makes the station
658 * order-independent. On a completion at position p the departing job is not
659 * the one at p: the SWAP GRAPH chains forward from p to the last position
660 * reachable through it, and that job departs while the chain shifts into
661 * the hole. An empty swap graph is the plain OI station, where p itself
662 * departs.
663 */
664 bool pas = false;
665 std::function<double(const std::vector<std::size_t>&)> pas_rate;
666 std::vector<std::vector<bool>> pas_swap;
667 std::vector<Job> pas_list;
668 std::uint64_t pas_tag = 0;
669
670 /**
671 * A POLLING SERVER visits the per-class buffers in a fixed cyclic order,
672 * paying a SWITCHOVER on each leg.
673 *
674 * It advances ONE STEP AT A TIME and pays that step's switchover whether or
675 * not the buffer it reaches holds work. Skipping ahead to the next
676 * non-empty buffer for a single switchover is a different and much faster
677 * discipline: it shortens the cycle and understates the waiting time by
678 * tens of percent against Takagi's exact formulas, which assume the walking
679 * server. The one exception is a lap made entirely of zero-time legs, which
680 * would spin forever at the same instant: there the server PARKS and the
681 * next arrival restarts it.
682 *
683 * The switchover charged to a leg is the one stored against the buffer
684 * being LEFT -- that is Takagi's r_i and what `Queue.setSwitchover` means --
685 * not the one against the buffer being entered.
686 */
687 bool polling = false;
688 std::size_t poll_at = 0; ///< the buffer the server is serving or heading to
689 bool poll_switching = false;
690 bool poll_parked = false;
691 std::size_t poll_budget = 0; ///< remaining visit budget; 0 = exhausted
692 std::size_t poll_gate = 0; ///< DECREMENTING: jobs found at the polling instant
694 std::size_t poll_k = 1;
695 std::vector<Sampler> switchover;
696 std::vector<bool> has_switchover;
697
698 /**
699 * SETUP AND DELAY-OFF: a station that powers down when it falls idle.
700 *
701 * The server runs a four-state machine. It starts OFF; an arrival puts it
702 * in SETUP for the setup time and only then does service begin; when it
703 * empties it enters DELAYOFF and shuts down when that timer expires, so a
704 * job arriving during the delay-off window pays NOTHING and one arriving
705 * after it pays the full setup. Collapsing the two idle states makes every
706 * arrival to an idle server pay the setup, which is the model of a server
707 * with no delay-off at all.
708 */
709 bool has_setup = false;
711 bool setup_on = false; ///< the server has completed its setup
712 bool setup_running = false; ///< a setup is in progress
713 double delayoff_at = std::numeric_limits<double>::infinity(); ///< when it shuts down
714
715 /**
716 * RETRIAL: a retrial station has NO WAITING ROOM. A job of a retrial class
717 * that finds every server busy joins an ORBIT and re-offers itself later;
718 * a freed server is never filled from the orbit, only by a retry.
719 *
720 * The orbit is not a waiting line, but it IS part of the station
721 * population, as in the reference (MATLAB SSA holds it in the station, MAM
722 * and CTMC count it in QLen, manual Section 5.3.5): QLen counts orbiting
723 * jobs, the response time runs from the first arrival, and the capacity
724 * bounds service plus orbit, so setOrbit's cap = nservers + maxOrbit is
725 * what loses a job arriving to a full orbit.
726 */
727 std::vector<Sampler> retrial;
728 std::vector<bool> has_retrial;
729 std::vector<int> max_attempts;
730 std::vector<int> retrial_policy; ///< 1 = LINEAR (a clock per job), 2 = CONSTANT (one per orbit)
731 std::vector<bool> const_pending; ///< CONSTANT: the shared clock is armed
732 std::vector<std::deque<Job> > orbit; ///< per class, oldest first
733 std::vector<Sampler> orbit_abandon;
734 std::vector<bool> has_orbit_abandon;
735 std::vector<double> orbit_size; ///< live, per class
736 std::vector<double> tot_orbit; ///< time integral of the orbit size
737 double orbit_last = 0.0;
738 std::vector<double> retried, retrial_lost;
739
740 /** Reneging: the abandonment timer of a WAITING job, per class. */
741 std::vector<Sampler> patience;
742 std::vector<bool> has_patience;
743 /** Balking: the (min, max, probability) triples an arrival consults. */
744 struct BalkRule {
745 double min_jobs = 0.0;
746 double max_jobs = -1.0; ///< -1 = unbounded above, the wire's spelling
747 double probability = 0.0;
748 };
749 std::vector<std::vector<BalkRule>> balk;
750 std::vector<Sampler> svc;
751 std::vector<bool> off;
752
753 /**
754 * BULK SERVICE (BMSP): a THIRD station kind, beside the buffered one and the
755 * sharing one.
756 *
757 * A BMAP or BMMAPt assigned as a SERVICE process is a batch Markovian service
758 * process: the station has no per-job service time at all, only a station-level
759 * FIRING CLOCK, and one firing completes min(b, present) jobs at once. Neither
760 * existing path can express that -- `draw_at_arrival` gives every job a
761 * `Job::service`, and a sharing station divides one server among everyone -- so
762 * this is its own arm in the admit path and in the departure arm.
763 *
764 * The clock is armed when the station goes from EMPTY to non-empty and re-armed
765 * at every firing, so the server fires continuously while it holds work. That is
766 * what makes a wall-clock schedule walk exact for a BMMAPt clock: the firing
767 * interval is drawn from the instant the clock is armed, never from a job's
768 * arrival.
769 *
770 * Restricted BY NAME to a single-server, single-class FCFS queue with no
771 * preemption, polling, PAS, setup, load dependence, server pools, immediate
772 * feedback or blocking destination, exactly as the Java engine restricts it: a
773 * bulk server is a single server by construction, and "which of the b jobs it
774 * just released is the one blocking" has no answer.
775 */
776 bool bmsp = false;
777 std::size_t bmsp_cls = 0; ///< the one enabled class
778 std::size_t bmsp_pending = 1; ///< jobs the armed firing will complete
779 std::uint64_t bmsp_tag = 0; ///< neutralises a superseded firing
780
781 /**
782 * HETEROGENEOUS SERVER POOLS, empty on a station whose servers are alike.
783 *
784 * A pool is `count` servers that accept only `compatible` classes and serve
785 * them at a law of their own, so a model that declares them is a DIFFERENT
786 * system from the multiserver it would otherwise look like: flattening the
787 * pools into `nservers` answers that other system, plausibly and silently.
788 *
789 * The slots are laid out POOL BY POOL in declaration order, so slot
790 * `type_first[t] .. type_first[t] + type_count[t] - 1` belongs to pool `t`
791 * and `server_type[slot]` is the inverse. Every other part of the engine
792 * addresses a server by its slot and needs to know nothing about this.
793 */
794 bool has_pools = false;
795 std::vector<std::size_t> server_type; ///< per slot, its pool
796 std::vector<std::size_t> type_first; ///< per pool, its first slot
797 std::vector<std::size_t> type_count; ///< per pool, its server count
798 std::vector<std::vector<bool>> type_compat; ///< [pool][class]
799 std::vector<std::vector<Sampler>> type_svc; ///< [pool][class]
800 std::vector<std::vector<bool>> type_has_svc; ///< [pool][class], a law of its own
801 std::vector<std::vector<double>> type_rate; ///< [pool][class], 1/mean, for FSF
803 /** ALIS/FAIRNESS rotate this order; a used pool goes to the back. */
804 std::vector<std::size_t> type_order;
805 /** ALFS walks the pools least-flexible first: fewest compatible classes. */
806 std::vector<std::size_t> alfs_order;
807
808 /**
809 * The waiting room, as an EXPLICIT HEAP rather than a `std::priority_queue`.
810 *
811 * FSP is why. Its order is the virtual finish time of a processor-sharing
812 * schedule over the residual work PRESENT AT THE STATION, so it changes
813 * whenever the station's composition does and cannot be stamped on a job
814 * when it arrives. A container that hides its storage cannot be reordered
815 * or rescanned; this one is scanned linearly for FSP and kept as a heap
816 * for every other discipline, whose keys are per-job constants.
817 */
818 std::vector<Job> buffer;
820 std::vector<Job> server;
821 std::vector<bool> server_busy;
822 std::vector<double> server_start; ///< instant the held job entered service
823 std::vector<std::uint64_t> server_tag;
824 /**
825 * A server holding a completed job it cannot hand on, because its
826 * destination is full. It is NOT free: refusing to start a new service is
827 * the whole content of blocking, and a blocked slot treated as idle turns
828 * a blocking network into a lossless one with the same topology.
829 */
830 std::vector<bool> server_blocked;
831 /**
832 * A server HELD ACROSS A SYNCHRONOUS CALL. Its job has left for the callee
833 * and the slot is NOT free: releasing it would let the caller serve someone
834 * else while its own call is outstanding, which is the asynchronous model
835 * under the synchronous one's name. Separate from `server_blocked`, which
836 * holds a COMPLETED job whose destination is full.
837 */
838 std::vector<bool> server_held;
839 std::vector<std::size_t> held_cls;
840 std::vector<Job> blocked_job;
841 std::vector<std::size_t> blocked_dest;
842 std::vector<std::size_t> blocked_dest_cls;
843
844 // sharing disciplines
845 std::vector<PsJob> ps_jobs;
846 double ps_last_update = 0.0;
847
848 /**
849 * True when the station's service RATE depends on its own population, i.e.
850 * it declares a load- or class-dependence table.
851 *
852 * Such a station cannot schedule a departure once and forget it: the rate
853 * in force changes every time the population does, so the residual work
854 * must be integrated forward and the completion re-timed at each change --
855 * the same discipline a sharing station needs, for a different reason.
856 * Applying the scaling only at service start would time every completion
857 * at the rate that happened to hold when it began.
858 */
859 bool state_dependent = false;
860 /** max(c, max(alpha)): the peak capacity the utilization is reported against. */
861 double util_peak = 1.0;
862 /**
863 * Class or joint dependence: the declared peak per class, the product over the declared families of the
864 * largest entry within the class's CHAIN, as solver_nc_conv normalizes. A single station-wide maximum halved
865 * the utilization of a class whose own peak is 1 (setClassDependence(beta, [2 1]) gave B 0.22 for 0.44).
866 */
867 std::vector<double> cd_peak_class;
868 /**
869 * `Queue.setServerParallelism(class, n)`: the servers a class-r job seizes
870 * for the whole of its service, one entry per class.
871 *
872 * The job holds the WHOLE SET or it waits, so the station serves at most
873 * floor(c/n) of them at once and the utilization one contributes is n/c
874 * rather than 1/c. That is JMT's `Server.serverNumRequired`, and it is not
875 * the same model as an n-fold slower server, which would leave the other
876 * slots free for someone else.
877 */
878 std::vector<std::size_t> parallelism;
879 bool has_parallelism = false;
880 /**
881 * The further slots held beside each primary one, so that releasing the
882 * primary releases the whole set. Empty at a station that declares none.
883 */
884 std::vector<std::vector<std::size_t> > held_extra;
885 /**
886 * The declared peak of the GLOBAL (Whittle) dependence at this station, one
887 * entry per class, 1 when the model declares none.
888 *
889 * It MULTIPLIES whatever peak the station already reports against, because
890 * phi multiplies the rate the class-, joint- and load-dependent scalings
891 * have already set; `getPeakScaling` folds the same product.
892 */
893 std::vector<double> gd_peak;
894 /**
895 * The class-dependent speed a SHARING station is running at right now, one
896 * entry per class.
897 *
898 * It is cached rather than recomputed because `ps_advance` integrates the
899 * interval that just ENDED while the caller has already written the new
900 * population: reading the handle there would credit that interval at the
901 * speed that only starts now. `ps_reschedule` refreshes it in the same
902 * breath as it re-times the departures, which is the moment the new speed
903 * actually takes effect -- the same flush-then-install order
904 * `Accum::set_busy_scale` uses for load dependence.
905 */
906 std::vector<double> ps_cd;
907 /**
908 * Server breakdown: the server alternates up and down on two clocks.
909 *
910 * `down_scale[r]` is the RATE MULTIPLIER while down, `down_rate(r) *
911 * E[S(i,r)]`, so the whole outage is expressed as a state-dependent rate
912 * change and needs no separate machinery: a zero multiplier stalls the job
913 * with its residual intact (nothing is rescheduled, so nothing departs
914 * until repair) and a positive one is the degraded server. That is exactly
915 * the reference's semantics -- a breakdown does not evict the job in
916 * service, and `downServiceRates` degrades rather than stops.
917 */
918 bool has_breakdown = false;
919 bool up = true;
920 std::vector<double> down_scale;
921 /** The declared absolute rate, kept so the scale can be formed once E[S] is known. */
922 std::vector<double> down_rate_raw;
924 double sd_last_update = 0.0;
925};
926
927/**
928 * The job placement the model's DECLARED initial state stands for, station-major
929 * like `init_sol`, or empty when there is none to take.
930 *
931 * Port of the JAR's `Solver_ldes_analyzer.deriveInitSolFromState`, so both engines
932 * start from the same placement and inject it in the same order. A station's
933 * declared state is its (statespace, stateprior) pair, which
934 * `Network::set_state_prior` and the reader of `initialState` write and which
935 * CTMC, SSA, FLD and JMT start from; the weighed row gives the per-class COUNT
936 * at each Queue and Delay. The engine draws every service afresh, so buffer
937 * order and phase are not carried, exactly as for `init_sol`. Empty, and so the
938 * reference-station default, when a Queue or Delay declares nothing or the
939 * counts do not conserve every closed population; a prior over rows with
940 * different counts is refused, since one run starts from one placement.
941 */
942template <class T>
944 using lang::NodeType;
945 const std::size_t M = sn.stations.size(), K = sn.nclasses;
946 std::vector<double> placed(M * K, 0.0), totals(K, 0.0);
947 for (std::size_t ist = 1; ist <= M; ++ist) {
948 const NodeType nt = sn.stations[ist - 1].nodetype;
949 if (nt != NodeType::Queue && nt != NodeType::Delay) continue;
950 const std::size_t ind = sn.node_of_station(ist);
951 typename std::map<std::size_t, std::vector<T> >::const_iterator sp = sn.stateprior.find(ind);
952 typename std::map<std::size_t, Matrix<T> >::const_iterator ss = sn.statespace.find(ind);
953 if (sp == sn.stateprior.end() || ss == sn.statespace.end() || ss->second.rows() == 0)
954 return std::vector<double>();
955 std::vector<std::size_t> ph(K, 1), shift(K, 0);
956 std::size_t w = 0;
957 for (std::size_t k = 0; k < K; ++k) {
958 ph[k] = sn.phasessz_of(ist, k + 1);
959 shift[k] = w;
960 w += ph[k];
961 }
962 const std::size_t nvar = sn.nvars_of(ind);
963 const Matrix<T>& space = ss->second;
964 const std::size_t base = (ist - 1) * K;
965 bool first = true;
966 for (std::size_t row = 0; row < space.rows(); ++row) {
967 if (row >= sp->second.size() || !(num_traits<T>::to_double(sp->second[row]) > 0.0)) continue;
968 std::vector<T> v(space.cols());
969 for (std::size_t c = 0; c < space.cols(); ++c) v[c] = space(row, c);
970 const qn::Marginal<T> m = qn::to_marginal(sn, ist, v, ph, shift, nvar);
971 for (std::size_t r = 0; r < K; ++r) {
972 const double n = num_traits<T>::to_double(m.nir[r]);
973 if (first) {
974 placed[base + r] = n;
975 } else if (n != placed[base + r]) {
976 throw UnsupportedError(ErrorKind{"StatePrior"},
977 "SolverLDES (native engine): the initial state of station '" +
978 sn.stations[ist - 1].name + "' is a distribution over different "
979 "job counts; LDES starts every run from one placement");
980 }
981 }
982 first = false;
983 }
984 if (first) return std::vector<double>();
985 for (std::size_t r = 0; r < K; ++r) totals[r] += placed[base + r];
986 }
987 for (std::size_t r = 0; r < K; ++r) {
988 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
989 if (std::fabs(totals[r] - sn.classes[r].population) > 1e-6) return std::vector<double>();
990 }
991 return placed;
992}
993
994} // namespace engine
995
996/**
997 * Simulate `sn` in process and return the same record the subprocess client
998 * parses out of an `ldes-result` document.
999 *
1000 * A metric the run did not measure stays EMPTY rather than becoming a matrix
1001 * of zeros: zero is a measurement and absence is not.
1002 */
1003template <class T>
1005 using namespace engine;
1006 using lang::NodeType;
1007 using lang::SchedStrategy;
1008
1009 ldes_engine_reject(sn, o);
1010
1011 const std::size_t M = sn.nstations, K = sn.nclasses;
1012 if (M == 0 || K == 0) throw InputError("SolverLDES (native engine): empty model");
1013
1014 const std::uint64_t max_events =
1015 static_cast<std::uint64_t>(o.events > 0 ? o.events : o.samples);
1016 if (max_events == 0)
1017 throw InputError("SolverLDES (native engine): the completion budget is zero");
1018
1019 // ---- random streams, one band per role ---------------------------------
1020 const std::uint64_t base =
1021 (o.seed >= 0) ? static_cast<std::uint64_t>(o.seed) : std::random_device{}();
1022 /**
1023 * Snap a sampled duration onto the slot lattice, or REFUSE it.
1024 *
1025 * A non-lattice sample is an ERROR, not something to round: rounding
1026 * silently changes the distribution, and a model whose service law is not
1027 * lattice-valued is not a discrete-time model. The reference refuses for
1028 * the same reason. Only Geometric, Det with an integral slot count, and
1029 * DMAP produce lattice values.
1030 */
1031 const bool slotted = o.slotted;
1032 const double slot_len = o.slot_length;
1033 auto slot_snap = [&](double v, const char* what) -> double {
1034 if (!slotted || v == 0.0) return v;
1035 const double slots = v / slot_len;
1036 const double rounded = std::floor(slots + 0.5);
1037 if (rounded < 1.0 || std::fabs(slots - rounded) > 1e-9 * std::max(1.0, slots))
1038 throw InputError(std::string("SolverLDES (native engine): slotted mode sampled a ") +
1039 what + " of " + std::to_string(v) +
1040 ", which is not a positive multiple of the slot length " +
1041 std::to_string(slot_len) +
1042 "; a discrete-time model needs lattice-valued interarrival and "
1043 "service times, e.g. Geometric or Det on an integral slot count");
1044 return rounded * slot_len;
1045 };
1046
1047 /**
1048 * ONE STREAM PER (node, class), with the reference's own offsets.
1049 *
1050 * `Solver_ssj` never shares a stream between two generators: it builds one
1051 * MRG32k3a and one java.util.Random per (node, class) and seeds them from
1052 * (seed, offset), the MRG stream through the `rng::SeedHash` of the pair and
1053 * the Random from `seed + offset`, with
1054 *
1055 * arrival (source s, class k) stream (s*K + k)*10, random the same + 2000
1056 * service (svc j, class k) both ((numSources + j)*K + k)*10 + 1000
1057 * routing ROUTING_SEED_BASE = 900000
1058 *
1059 * where `numSources` counts SOURCE nodes and `j` indexes the SERVICE nodes
1060 * in order, neither of which is the station index. Sharing three run-wide
1061 * streams instead, as this engine did, changes the interleaving of every
1062 * draw: two stations that each consume their own stream in the reference
1063 * would take turns from one here, so the paths diverge from the first event
1064 * even with identical generators and identical quantiles.
1065 *
1066 * The station-level draws that are not per-class -- a polling switchover,
1067 * a setup or delay-off, an SPN transition -- sit on `g_aux[station]` in a
1068 * band of their own, mirroring the reference's own
1069 * `(numSources + numServiceNodes + j)` family without claiming to match it
1070 * draw for draw.
1071 */
1072 const long long seed_ll = static_cast<long long>(base);
1073 const long long Kll = static_cast<long long>(K);
1074 std::size_t num_sources = 0, num_service_nodes = 0;
1075 std::vector<std::size_t> svc_index(M, 0);
1076 for (std::size_t i = 0; i < M; ++i) {
1077 if (sn.stations[i].nodetype == NodeType::Source) {
1078 ++num_sources;
1079 } else {
1080 svc_index[i] = num_service_nodes++;
1081 }
1082 }
1083 const long long nsrc = static_cast<long long>(num_sources);
1084 const long long nsvc = static_cast<long long>(num_service_nodes);
1085
1086 std::vector<Rng> g_arr; ///< [class], at the (single) source
1087 g_arr.reserve(K);
1088 for (std::size_t k = 0; k < K; ++k) {
1089 const long long off = (static_cast<long long>(k)) * 10; // srcIdx == 0
1090 g_arr.push_back(Rng(seed_ll, off, off + 2000));
1091 }
1092 std::vector<std::vector<Rng>> g_svc; ///< [station][class]
1093 g_svc.reserve(M);
1094 for (std::size_t i = 0; i < M; ++i) {
1095 std::vector<Rng> row;
1096 row.reserve(K);
1097 for (std::size_t k = 0; k < K; ++k) {
1098 const long long off =
1099 ((nsrc + static_cast<long long>(svc_index[i])) * Kll + static_cast<long long>(k)) *
1100 10 + 1000;
1101 row.push_back(Rng(seed_ll, off));
1102 }
1103 g_svc.push_back(row);
1104 }
1105 // [station][pool][class]: a heterogeneous pool's own service stream, banded
1106 // past `g_svc` so a pooled station's draws never interleave with the
1107 // class-level ones and a homogeneous model's sequence is untouched.
1108 std::vector<std::vector<std::vector<Rng>>> g_hsvc(M);
1109 for (std::size_t i = 0; i < M; ++i) {
1110 const std::size_t nT = sn.stations[i].server_types.size();
1111 g_hsvc[i].resize(nT);
1112 for (std::size_t t = 0; t < nT; ++t) {
1113 g_hsvc[i][t].reserve(K);
1114 for (std::size_t k = 0; k < K; ++k) {
1115 const long long off =
1116 ((nsrc + static_cast<long long>(svc_index[i])) * Kll +
1117 static_cast<long long>(k)) * 10 +
1118 600000 + static_cast<long long>(t) * 137;
1119 g_hsvc[i][t].push_back(Rng(seed_ll, off));
1120 }
1121 }
1122 }
1123 std::vector<Rng> g_aux; ///< [station], the draws that carry no class
1124 g_aux.reserve(M);
1125 for (std::size_t i = 0; i < M; ++i) {
1126 const long long off = ((nsrc + nsvc + static_cast<long long>(svc_index[i])) * Kll) * 10;
1127 g_aux.push_back(Rng(seed_ll, off));
1128 }
1129 Rng g_routing(seed_ll, 900000); // ROUTING_SEED_BASE
1130 // The SPN transition clocks are a model-level draw, not a station's.
1131 // The reference bands the place generators at +5000; this is that band.
1132 Rng g_spn(seed_ll, 5000);
1133 // The SIRO pick of a queueing place's embedded queue, on a band of its own:
1134 // a place that serves FCFS or INF never reaches it, so adding the discipline
1135 // to the engine moves no other model's draws.
1136 Rng g_qplace(seed_ll, 5001);
1137 // The fork degree and branch activation draws, on the reference's own
1138 // `new Random(seed + 88888)` band. DRAWN FROM ONLY BY A FORK THAT DECLARES
1139 // AN OVERRIDE: a plain fork must consume nothing here, or every seeded
1140 // golden and the bit-exact agreement with the Java engine would move.
1141 Rng g_fork(seed_ll, 88888);
1142 // THE SIRO KEY of an admitted job, on the reference's `new Random(seed + 99999)` band (`siroRng`). It used to
1143 // come off g_routing, which made every admission consume a ROUTING draw the Java engine never takes, so any
1144 // model whose routing stream is read at all (a probabilistic split, a balk) parted company with the reference
1145 // path. Only SIRO reads the key; the routing stream now carries only the draws Java's routingRng carries.
1146 Rng g_siro(seed_ll, 99999);
1147
1148 /**
1149 * True when a fork's parameters actually vary: some branch is uncertain,
1150 * some link carries a count other than the scalar, or some link draws its
1151 * count. A fork that declares the matrices but leaves them uniform stays on
1152 * the fixed path, so it keeps drawing nothing.
1153 */
1154 auto fork_is_variable = [&](std::size_t node) -> bool {
1155 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
1156 if (fp == 0) return false;
1157 const double tpl = sn.nodes[node - 1].tasks_per_link;
1158 for (std::size_t k = 0; k < fp->fan_out_link.rows(); ++k)
1159 for (std::size_t r = 0; r < fp->fan_out_link.cols(); ++r) {
1160 const double p = fp->fan_out_prob(k, r);
1161 if (p == 0.0) continue; // link not taken
1162 if (p != 1.0) return true;
1163 if (fp->fan_out_link(k, r) != tpl) return true;
1164 if (!fp->fan_out_dist[k][r].disabled) return true;
1165 }
1166 return false;
1167 };
1168
1169 /** One draw from a jobs-per-link distribution, by inverse CDF on its pmf. */
1170 auto sample_fork_degree = [&](const lang::Distrib<double>& d) -> int {
1171 double tot = 0.0;
1172 for (std::size_t e = 0; e < d.params.size(); ++e) tot += d.params[e];
1173 const double u = g_fork.aux.next_double() * tot;
1174 double accp = 0.0;
1175 for (std::size_t e = 0; e < d.params.size(); ++e) {
1176 accp += d.params[e];
1177 if (u <= accp)
1178 return static_cast<int>((d.trace.empty() ? static_cast<double>(e + 1) : d.trace[e]) +
1179 0.5);
1180 }
1181 return static_cast<int>(
1182 (d.trace.empty() ? static_cast<double>(d.params.size()) : d.trace.back()) + 0.5);
1183 };
1184
1185 // ---- stations ----------------------------------------------------------
1186 std::vector<StationState> S(M);
1187 std::size_t source_st = M;
1188 std::vector<int> classprio(K, 0);
1189 std::vector<double> classdeadline(K, std::numeric_limits<double>::infinity());
1190 for (std::size_t r = 0; r < K; ++r) {
1191 classprio[r] = sn.classes[r].prio;
1192 classdeadline[r] = sn.classes[r].deadline;
1193 }
1194
1195 for (std::size_t i = 0; i < M; ++i) {
1196 const auto& st = sn.stations[i];
1197 StationState& s = S[i];
1198 s.sched = st.sched;
1199 s.off.assign(K, true);
1200 s.class_mean.assign(K, 0.0);
1201 s.weight.assign(K, 1.0);
1202 for (std::size_t r = 0; r < K; ++r)
1203 if (r < st.schedparam.size()) s.weight[r] = num_traits<T>::to_double(st.schedparam[r]);
1204
1205 if (st.nodetype == NodeType::Source) {
1206 s.role = Role::Source;
1207 source_st = i;
1208 } else if (st.nodetype == NodeType::Fork || st.nodetype == NodeType::Join ||
1209 st.nodetype == NodeType::Place || st.nodetype == NodeType::Transition) {
1210 // A Place holds tokens and a Transition moves them; neither serves,
1211 // so neither belongs in the service-station setup.
1212 s.role = Role::Synchronization;
1213 // A JOIN STILL REPORTS. It serves nothing, so it stays out of the
1214 // setup below, but it holds siblings and releases parents and the
1215 // result assembly must read those rows; leaving every class OFF is
1216 // what emitted it as an all-zero station. `off` gates the report
1217 // here, not admission: `admit` is never reached through a Join.
1218 if (st.nodetype == NodeType::Join) s.off.assign(K, false);
1219 // A PLACE STILL REPORTS, for the same reason a Join does: it holds
1220 // tokens and the result assembly has to read its rows. The marking
1221 // is carried in `acc.qlen` by the SPN section below, which is what
1222 // QN, TN and (for a queueing place) UN are read off.
1223 //
1224 // THE SERVER COUNT IS THE EMBEDDED QUEUE'S, and it doubles as the
1225 // utilization denominator: a c-server place reports busy/(t*c),
1226 // while an INF one leaves `nservers` at 1 so that busy/t is the mean
1227 // number in service, which is the convention LINE uses for every
1228 // infinite server (U == Q).
1229 if (st.nodetype == NodeType::Place) {
1230 s.off.assign(K, false);
1231 if (std::isfinite(st.nservers))
1232 s.nservers = static_cast<std::size_t>(st.nservers + 0.5);
1233 }
1234 } else if (st.nodetype == NodeType::Delay || st.sched == SchedStrategy::INF) {
1235 s.role = Role::Delay;
1236 } else {
1237 s.role = Role::Queue;
1238 s.ps = is_ps_family(st.sched);
1239 s.preemptive = is_preemptive(st.sched);
1240 s.resume = is_preemptive_resume(st.sched);
1241 // The disciplines whose WaitCmp reads job.service or job.remaining.
1242 s.size_ordered = (st.sched == SchedStrategy::SJF ||
1243 st.sched == SchedStrategy::LJF ||
1244 st.sched == SchedStrategy::SRPT ||
1245 st.sched == SchedStrategy::SRPTPRIO ||
1246 st.sched == SchedStrategy::PSJF ||
1247 st.sched == SchedStrategy::LRPT ||
1248 st.sched == SchedStrategy::FSP);
1249 const double c = st.nservers;
1250 if (!(c >= 1.0) || !std::isfinite(c))
1251 throw InputError("SolverLDES (native engine): station '" + st.name +
1252 "' has a server count that is neither finite nor at least one");
1253 s.nservers = static_cast<std::size_t>(c + 0.5);
1254 s.cap = st.cap;
1255 s.classcap.assign(K, std::numeric_limits<double>::infinity());
1256 s.blocked_at.assign(K, 0.0);
1257 s.droprule.assign(K, lang::DropStrategy::DROP);
1258 for (std::size_t r = 0; r < K; ++r)
1259 if (i < sn.droprule.size() && r < sn.droprule[i].size())
1260 s.droprule[r] = sn.droprule[i][r];
1261 for (std::size_t r = 0; r < K; ++r)
1262 if (i < sn.classcap.size() && r < sn.classcap[i].size())
1263 s.classcap[r] = sn.classcap[i][r];
1264 if (st.sched == SchedStrategy::LPS) s.lps_limit = s.nservers;
1265 // SERVER PARALLELISM. Read unconditionally so that a station
1266 // declaring only ones keeps every path it had: `has_parallelism`
1267 // is what the seize, the release and the admission gate consult.
1268 s.parallelism.assign(K, 1);
1269 for (std::size_t r = 0; r < K && r < st.server_parallelism.size(); ++r)
1270 if (st.server_parallelism[r] > 1) {
1271 s.parallelism[r] = st.server_parallelism[r];
1272 s.has_parallelism = true;
1273 }
1274 }
1275
1276 // One entry per class, neutral until a sharing station with a class
1277 // dependence refreshes it in `ps_reschedule`.
1278 s.ps_cd.assign(K, 1.0);
1279 s.gd_peak.assign(K, 1.0);
1280 for (const T& v : st.lldscaling) s.lld.push_back(num_traits<T>::to_double(v));
1281 // The peak capacity that normalizes this station's utilization is
1282 // max(c, max(alpha)) -- CTMC's own ceff, which is what makes the two
1283 // report the same number on a load-dependent station. Without load
1284 // dependence it is just c, so nothing else moves.
1285 s.util_peak = static_cast<double>(s.nservers);
1286 for (double a : s.lld) s.util_peak = std::max(s.util_peak, a);
1287 // CLASS DEPENDENCE beta_r(n) AND JOINT DEPENDENCE eta_i(n) ENTER THE
1288 // SAMPLE-PATH RATE THE SAME WAY, and a station declaring both is scaled
1289 // by their PRODUCT -- `initializeClassDependence` and `State.java` both
1290 // fold them that way. They stay separate on the struct because they
1291 // carry different modelling claims (beta keeps the product form, eta
1292 // does not), but a simulator has no use for that distinction: it needs
1293 // the rate in force, which is the product.
1294 if (st.cdscaling || st.jdscaling) {
1295 s.has_cd = true;
1296 // THE DECLARED PEAK IS THE NORMALIZER, not a peak swept out of the
1297 // handle. `solver_ssa_serial` and `solver_ssa_nrm_space` take the
1298 // PRODUCT of whichever declared peaks are present, and drop the
1299 // server count while doing so, because the rate in force is that
1300 // same product; this engine must answer with their number. A handle
1301 // with no declared peak is a model defect here exactly as it is
1302 // there -- an open class has no lattice to sweep, so a swept peak
1303 // would be an invented bound.
1304 double dep_peak = 1.0;
1305 const std::vector<T>* pks[2] = {&st.cdscalingpeak, &st.jdscalingpeak};
1306 const bool on[2] = {static_cast<bool>(st.cdscaling), static_cast<bool>(st.jdscaling)};
1307 const char* names[2] = {"setClassDependence", "setJointDependence"};
1308 for (std::size_t h = 0; h < 2; ++h) {
1309 if (!on[h]) continue;
1310 if (pks[h]->empty())
1311 throw InputError(
1312 std::string("SolverLDES: station '") + st.name +
1313 "' declares a dependent scaling with no declared peak rate. Utilization "
1314 "there is T*E[S]/peak, so pass the peak to " + names[h]);
1315 double m = 0.0;
1316 for (const T& v : *pks[h]) m = std::max(m, num_traits<T>::to_double(v));
1317 if (!(m > 0.0))
1318 throw InputError(std::string("SolverLDES: station '") + st.name +
1319 "' declares a non-positive peak rate for " + names[h]);
1320 dep_peak *= m;
1321 }
1322 s.util_peak = dep_peak;
1323 s.cd_peak_class.assign(sn.nclasses, 1.0);
1324 for (std::size_t r = 0; r < sn.nclasses; ++r) {
1325 std::size_t cr = sn.chains.size();
1326 for (std::size_t c = 0; c < sn.chains.size(); ++c)
1327 if (r < sn.chains[c].size() && sn.chains[c][r]) { cr = c; break; }
1328 for (std::size_t h = 0; h < 2; ++h) {
1329 if (!on[h]) continue;
1330 double m = 0.0;
1331 for (std::size_t k = 0; k < sn.nclasses; ++k) {
1332 const bool same = (cr < sn.chains.size()) ? (k < sn.chains[cr].size() && sn.chains[cr][k])
1333 : (k == r);
1334 if (!same) continue;
1335 const std::size_t e = pks[h]->size() > 1 ? k : 0;
1336 if (e < pks[h]->size()) m = std::max(m, num_traits<T>::to_double((*pks[h])[e]));
1337 }
1338 s.cd_peak_class[r] *= (m > 0.0) ? m : 1.0;
1339 }
1340 }
1341 const auto& beta = st.cdscaling;
1342 const auto& eta = st.jdscaling;
1343 s.cd = [beta, eta](const std::vector<double>& n) {
1344 std::vector<T> nt(n.size());
1345 for (std::size_t k = 0; k < n.size(); ++k) nt[k] = num_traits<T>::from_double(n[k]);
1346 std::vector<double> bd, ed;
1347 if (beta) {
1348 const std::vector<T> b = beta(nt);
1349 bd.resize(b.size());
1350 for (std::size_t k = 0; k < b.size(); ++k) bd[k] = num_traits<T>::to_double(b[k]);
1351 }
1352 if (eta) {
1353 const std::vector<T> e = eta(nt);
1354 ed.resize(e.size());
1355 for (std::size_t k = 0; k < e.size(); ++k) ed[k] = num_traits<T>::to_double(e[k]);
1356 }
1357 if (bd.empty()) return ed;
1358 if (ed.empty()) return bd;
1359 // Elementwise, with scalar broadcasting: either factor may be a
1360 // single value shared across the classes.
1361 const std::size_t n2 = std::max(bd.size(), ed.size());
1362 std::vector<double> out(n2, 1.0);
1363 for (std::size_t k = 0; k < n2; ++k)
1364 out[k] = bd[bd.size() > 1 ? k : 0] * ed[ed.size() > 1 ? k : 0];
1365 return out;
1366 };
1367 }
1368 if (st.sched == SchedStrategy::PAS || st.sched == SchedStrategy::OI) {
1369 s.pas = true;
1370 auto pp = sn.pasparam.find(i + 1);
1371 if (pp != sn.pasparam.end()) {
1372 if (pp->second.svc_rate_fun) {
1373 const auto f = pp->second.svc_rate_fun;
1374 // mu(c) TAKES 1-BASED CLASS TAGS -- it is the state
1375 // encoding's own convention, the one `state.h` and
1376 // `after_event_station_pas` hand it and the one
1377 // `oi_rate_from_json` decodes (it drops any entry below 1).
1378 // This engine's `Job::cls` is 0-based, so shift on the way
1379 // in: unshifted, class 0 is invisible to mu, contributes no
1380 // rate increment at any position, and its jobs pile up at
1381 // the station and never complete.
1382 s.pas_rate = [f](const std::vector<std::size_t>& seq) {
1383 std::vector<std::size_t> tags(seq.size());
1384 for (std::size_t k = 0; k < seq.size(); ++k) tags[k] = seq[k] + 1;
1385 return num_traits<T>::to_double(f(tags));
1386 };
1387 }
1388 s.pas_swap = pp->second.swap_graph;
1389 }
1390 if (!s.pas_rate)
1391 throw InputError("SolverLDES (native engine): station '" + st.name +
1392 "' is a pass-and-swap station with no service rate function");
1393 }
1394 if (st.sched == SchedStrategy::POLLING) {
1395 s.polling = true;
1396 const auto pp = sn.effective_polling(i + 1);
1397 s.poll_type = pp.ptype;
1398 s.poll_k = (pp.pk >= 1) ? pp.pk : 1;
1399 s.poll_budget = (s.poll_type == lang::PollingType::EXHAUSTIVE)
1400 ? std::numeric_limits<std::size_t>::max()
1401 : ((s.poll_type == lang::PollingType::KLIMITED) ? s.poll_k : 1);
1402 s.poll_parked = true;
1403 s.switchover.resize(K);
1404 s.has_switchover.assign(K, false);
1405 for (std::size_t r = 0; r < K && r < pp.switchover.size(); ++r)
1406 if (!pp.switchover[r].disabled &&
1407 num_traits<T>::to_double(pp.switchover[r].mean) > 0.0) {
1408 s.switchover[r] = Sampler(pp.switchover[r],
1409 "the switchover of station '" + st.name +
1410 "', class '" + sn.classes[r].name + "'");
1411 s.has_switchover[r] = true;
1412 }
1413 }
1414 s.retrial.resize(K);
1415 s.has_retrial.assign(K, false);
1416 s.max_attempts.assign(K, 0);
1417 s.retrial_policy.assign(K, 1);
1418 s.const_pending.assign(K, false);
1419 s.orbit.assign(K, std::deque<Job>());
1420 s.orbit_abandon.resize(K);
1421 s.has_orbit_abandon.assign(K, false);
1422 s.orbit_size.assign(K, 0.0);
1423 s.tot_orbit.assign(K, 0.0);
1424 s.retried.assign(K, 0.0);
1425 s.retrial_lost.assign(K, 0.0);
1426 {
1427 auto rp = sn.retrialparam.find(i + 1);
1428 if (rp != sn.retrialparam.end())
1429 for (std::size_t r = 0; r < K && r < rp->second.retrial_proc.size(); ++r)
1430 if (!rp->second.retrial_proc[r].disabled) {
1431 s.retrial[r] = Sampler(rp->second.retrial_proc[r],
1432 "the retrial process of station '" + st.name +
1433 "', class '" + sn.classes[r].name + "'");
1434 s.has_retrial[r] = true;
1435 if (r < rp->second.max_attempts.size())
1436 s.max_attempts[r] = rp->second.max_attempts[r];
1437 if (r < rp->second.policy.size())
1438 s.retrial_policy[r] = rp->second.policy[r];
1439 if (r < st.orbit_impatience.size() && !st.orbit_impatience[r].disabled) {
1440 s.orbit_abandon[r] = Sampler(st.orbit_impatience[r],
1441 "the orbit impatience of station '" + st.name +
1442 "', class '" + sn.classes[r].name + "'");
1443 s.has_orbit_abandon[r] = true;
1444 }
1445 }
1446 }
1447 s.patience.resize(K);
1448 s.has_patience.assign(K, false);
1449 s.balk.assign(K, std::vector<StationState::BalkRule>());
1450 for (std::size_t r = 0; r < K; ++r) {
1451 if (r < st.impatience.size() && st.impatience[r] == lang::ImpatienceType::RENEGING &&
1452 r < st.patience.size() && !st.patience[r].disabled) {
1453 s.patience[r] = Sampler(st.patience[r], "the patience of station '" + st.name +
1454 "', class '" + sn.classes[r].name + "'");
1455 s.has_patience[r] = true;
1456 }
1457 // ALL THREE STRATEGIES READ THE SAME TABLE, as in the Java engine
1458 // (Solver_ssj.shouldBalk), which never consults the strategy: the
1459 // model layer stores EXPECTED_WAIT and COMBINED thresholds in JOBS,
1460 // {minJobs, maxJobs, probability}, and carries no wait parameter.
1461 if (r < st.balking.size() &&
1462 st.balking[r].strategy != lang::BalkingStrategy::NONE) {
1463 for (const auto& th : st.balking[r].thresholds) {
1464 StationState::BalkRule br;
1465 br.min_jobs = th.min_jobs;
1466 br.max_jobs = th.max_jobs;
1467 br.probability = num_traits<T>::to_double(th.probability);
1468 s.balk[r].push_back(br);
1469 }
1470 }
1471 }
1472
1473 {
1474 auto sp = sn.setupparam.find(i + 1);
1475 if (sp != sn.setupparam.end()) {
1476 lang::Distrib<T> su, doff;
1477 if (sp->second.last(su, doff) && !su.disabled) {
1478 s.has_setup = true;
1479 s.setup_time = Sampler(su, "the setup time of station '" + st.name + "'");
1480 // A ZERO delay-off is not an error, it is the absence of the
1481 // window: the server shuts down the instant it empties, so
1482 // every busy period starts cold. Only a positive one gets a
1483 // sampler; a disabled or zero entry leaves it unset.
1484 if (!doff.disabled && num_traits<T>::to_double(doff.mean) > 0.0)
1485 s.delayoff_time =
1486 Sampler(doff, "the delay-off time of station '" + st.name + "'");
1487 else
1488 s.delayoff_time = Sampler();
1489 }
1490 }
1491 }
1492 {
1493 typename std::map<std::size_t, qn::BreakdownParam<T> >::const_iterator bp =
1494 sn.breakdownparam.find(i + 1);
1495 if (bp != sn.breakdownparam.end()) {
1496 s.has_breakdown = true;
1497 s.up = true;
1498 s.failure_time =
1499 Sampler(bp->second.failure, "the failure time of station '" + st.name + "'");
1500 s.repair_time =
1501 Sampler(bp->second.repair, "the repair time of station '" + st.name + "'");
1502 s.down_scale.assign(K, 0.0);
1503 s.down_rate_raw.assign(K, 0.0);
1504 for (std::size_t r = 0; r < K && r < bp->second.down_service_rates.size(); ++r)
1505 s.down_rate_raw[r] = num_traits<T>::to_double(bp->second.down_service_rates[r]);
1506 }
1507 }
1508 // A BREAKDOWN IS A STATE-DEPENDENT RATE, so it reuses sd_advance /
1509 // sd_reschedule rather than growing a second freeze-and-retime path.
1510 s.state_dependent = (!s.lld.empty() || s.has_cd || s.has_breakdown);
1511
1512 s.svc.resize(K);
1513 if (s.role == Role::Synchronization) continue;
1514 for (std::size_t r = 0; r < K; ++r) {
1515 s.off[r] = sn.disabled[i][r];
1516 if (s.off[r]) continue;
1517 const std::string where = "station '" + st.name + "', class '" + sn.classes[r].name + "'";
1518 s.svc[r] = Sampler(sn.service[i][r], where);
1519 s.class_mean[r] = s.svc[r].mean();
1520 if (!(s.class_mean[r] > 0.0))
1521 throw InputError("SolverLDES (native engine): " + where +
1522 " has a non-positive mean service time");
1523 }
1524
1525 // ---- bulk service (BMSP) ----------------------------------------------
1526 //
1527 // Detected from the PROCESS, not from a flag: a BMAP or BMMAPt assigned as a
1528 // service process IS a batch Markovian service process. Every guard below is
1529 // the Java engine's, refused by name rather than answered wrongly -- before
1530 // this arm existed the batch blocks were read and never consulted, so a bulk
1531 // server was simulated as an ordinary one completing one job per firing.
1532 // A SOURCE's `service` column holds its ARRIVAL processes, so a batch family
1533 // there is a batch ARRIVAL and has nothing to do with bulk service.
1534 if (s.role != Role::Source) {
1535 std::size_t nbatch = 0, enabled = 0, bcls = K;
1536 for (std::size_t r = 0; r < K; ++r) {
1537 if (s.off[r]) continue;
1538 ++enabled;
1539 if (lang::process_is_batch(sn.service[i][r].type)) {
1540 ++nbatch;
1541 bcls = r;
1542 }
1543 }
1544 if (nbatch > 0) {
1545 const std::string fam = lang::process_to_text(sn.service[i][bcls].type);
1546 const std::string at = fam + " service (BMSP) at station '" + st.name + "'";
1547 if (s.role == Role::Delay)
1548 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1549 " is not supported at an infinite-server station; "
1550 "batch service requires a single-server FCFS queue");
1551 if (s.nservers != 1)
1552 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1553 " requires a single server; multiserver bulk service "
1554 "is not supported");
1555 if (s.sched != lang::SchedStrategy::FCFS)
1556 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1557 " requires FCFS scheduling");
1558 if (enabled != 1)
1559 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1560 " requires exactly one job class; multiclass bulk "
1561 "service is not supported");
1562 if (s.pas || s.preemptive || s.polling)
1563 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1564 " is incompatible with PAS/preemptive/polling "
1565 "scheduling");
1566 if (s.has_setup)
1567 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1568 " is incompatible with server setup/delayoff");
1569 if (s.state_dependent)
1570 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1571 " is not supported with load or class dependence or "
1572 "breakdowns: the rate rescaling cannot be applied to "
1573 "a firing clock armed at the instant the station "
1574 "became busy");
1575 if (!st.server_types.empty())
1576 throw UnsupportedError(ErrorKind{"BMAP"}, "SolverLDES (native engine): " + at +
1577 " is not supported with heterogeneous server types; a "
1578 "bulk server is a single server by construction");
1579 s.bmsp = true;
1580 s.bmsp_cls = bcls;
1581 s.bmsp_pending = 1;
1582 }
1583 }
1584
1585 // ---- heterogeneous server pools ---------------------------------------
1586 //
1587 // The pools REPLACE the flat server bank rather than sitting beside it:
1588 // `nservers` becomes their total, so every slot loop below is already
1589 // over the right range and only the CHOICE of slot has to learn about
1590 // compatibility. A pool that declares no law of its own for a class
1591 // serves it at the station's class-level default, which is what the
1592 // reference's `generateHeteroServiceTime` falls back to.
1593 if (!st.server_types.empty()) {
1594 const std::size_t nT = st.server_types.size();
1595 s.has_pools = true;
1596 s.hetero_policy = st.hetero_policy;
1597 s.type_count.assign(nT, 0);
1598 s.type_first.assign(nT, 0);
1599 s.type_compat.assign(nT, std::vector<bool>(K, true));
1600 s.type_svc.assign(nT, std::vector<Sampler>(K));
1601 s.type_has_svc.assign(nT, std::vector<bool>(K, false));
1602 s.type_rate.assign(nT, std::vector<double>(K, 0.0));
1603 std::size_t total = 0;
1604 for (std::size_t t = 0; t < nT; ++t) {
1605 const typename qn::Station<T>::ServerType& pt = st.server_types[t];
1606 const double c = pt.count;
1607 if (!(c >= 1.0))
1608 throw InputError("SolverLDES (native engine): station '" + st.name +
1609 "' declares server pool '" + pt.name +
1610 "' with fewer than one server");
1611 s.type_first[t] = total;
1612 s.type_count[t] = static_cast<std::size_t>(c + 0.5);
1613 total += s.type_count[t];
1614 for (std::size_t r = 0; r < K; ++r) {
1615 // An empty compatibility row means every class, as the
1616 // struct documents; a short one is padded the same way.
1617 s.type_compat[t][r] = pt.compatible.empty() || r >= pt.compatible.size()
1618 ? true
1619 : pt.compatible[r];
1620 if (s.off[r]) s.type_compat[t][r] = false;
1621 if (r < pt.service.size() && !pt.service[r].disabled) {
1622 const std::string where = "station '" + st.name + "', pool '" + pt.name +
1623 "', class '" + sn.classes[r].name + "'";
1624 s.type_svc[t][r] = Sampler(pt.service[r], where);
1625 s.type_has_svc[t][r] = true;
1626 const double mu = s.type_svc[t][r].mean();
1627 if (!(mu > 0.0))
1628 throw InputError("SolverLDES (native engine): " + where +
1629 " has a non-positive mean service time");
1630 s.type_rate[t][r] = 1.0 / mu;
1631 } else if (!s.off[r]) {
1632 s.type_rate[t][r] =
1633 (s.class_mean[r] > 0.0) ? 1.0 / s.class_mean[r] : 0.0;
1634 }
1635 }
1636 }
1637 for (std::size_t r = 0; r < K; ++r) {
1638 if (s.off[r]) continue;
1639 bool served = false;
1640 for (std::size_t t = 0; t < nT && !served; ++t) served = s.type_compat[t][r];
1641 if (!served)
1642 throw InputError("SolverLDES (native engine): station '" + st.name +
1643 "' declares no server pool compatible with class '" +
1644 sn.classes[r].name +
1645 "', so a job of that class would wait forever");
1646 }
1647 // The pools ARE the server bank. A declared `nservers` that
1648 // disagrees is the flattened view of the same pools, and taking it
1649 // instead would serve more or fewer jobs at once than declared.
1650 s.nservers = total;
1651 s.util_peak = static_cast<double>(total);
1652 if (s.sched == lang::SchedStrategy::LPS) s.lps_limit = total;
1653 s.server_type.assign(total, 0);
1654 for (std::size_t t = 0; t < nT; ++t)
1655 for (std::size_t j = 0; j < s.type_count[t]; ++j)
1656 s.server_type[s.type_first[t] + j] = t;
1657 s.type_order.resize(nT);
1658 for (std::size_t t = 0; t < nT; ++t) s.type_order[t] = t;
1659 // ALFS: fewest compatible classes first, ties by declaration order,
1660 // so the least flexible pool is spent before the more flexible one.
1661 s.alfs_order = s.type_order;
1662 std::stable_sort(s.alfs_order.begin(), s.alfs_order.end(),
1663 [&](std::size_t a, std::size_t b) {
1664 std::size_t ca = 0, cb = 0;
1665 for (std::size_t r = 0; r < K; ++r) {
1666 if (s.type_compat[a][r]) ++ca;
1667 if (s.type_compat[b][r]) ++cb;
1668 }
1669 return ca < cb;
1670 });
1671 }
1672 // THE DEGRADED RATE BECOMES A MULTIPLIER, and it can only be formed
1673 // here, once the mean service time of each class is known: the wire
1674 // carries an ABSOLUTE rate while the engine's state-dependent path
1675 // takes a factor on the nominal one.
1676 if (s.has_breakdown) {
1677 s.down_scale.assign(K, 0.0);
1678 for (std::size_t r = 0; r < K; ++r) {
1679 if (s.off[r] || !(s.class_mean[r] > 0.0)) continue;
1680 if (r < s.down_rate_raw.size() && s.down_rate_raw[r] > 0.0)
1681 s.down_scale[r] = s.down_rate_raw[r] * s.class_mean[r];
1682 }
1683 }
1684 if (!s.ps) {
1685 s.cmp.sched = s.sched;
1686 s.cmp.class_mean = &s.class_mean;
1687 if (s.role == Role::Queue) {
1688 s.server.assign(s.nservers, Job());
1689 s.server_busy.assign(s.nservers, false);
1690 s.server_start.assign(s.nservers, 0.0);
1691 s.server_tag.assign(s.nservers, 0);
1692 s.server_blocked.assign(s.nservers, false);
1693 s.server_held.assign(s.nservers, false);
1694 s.held_cls.assign(s.nservers, 0);
1695 s.blocked_job.assign(s.nservers, Job());
1696 s.blocked_dest.assign(s.nservers, 0);
1697 s.blocked_dest_cls.assign(s.nservers, 0);
1698 if (s.has_parallelism) s.held_extra.assign(s.nservers, std::vector<std::size_t>());
1699 }
1700 }
1701 }
1702
1703 // ---- server parallelism: what a station must be for a job to seize n ----
1704 //
1705 // Every refusal below is the reference engine's `initializeServerParallelism`,
1706 // named rather than answered wrongly. What they have in common is that the
1707 // station has no BANK OF INTERCHANGEABLE SLOTS to take n of: an infinite
1708 // server never makes a job wait for one, a sharing discipline hands out
1709 // fractions of its whole capacity instead of slots, a polling or
1710 // pass-and-swap controller picks the server itself, a bulk server has only a
1711 // station-level firing clock, a heterogeneous pool binds a job to the one
1712 // type whose law serves it, and a setup station powers its slots up one at a
1713 // time so "n free at once" is not a state it passes through.
1714 for (std::size_t i = 0; i < M; ++i) {
1715 const auto& st = sn.stations[i];
1716 std::size_t maxpar = 1;
1717 for (std::size_t r = 0; r < st.server_parallelism.size(); ++r)
1718 maxpar = std::max(maxpar, st.server_parallelism[r]);
1719 if (maxpar <= 1) continue;
1720 StationState& s = S[i];
1721 const std::string where = std::string("SolverLDES (native engine): station '") +
1722 st.name + "' declares server parallelism";
1723 if (s.role != Role::Queue)
1724 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " at an infinite server, which never makes a job "
1725 "wait for a slot; drop the declaration or give the "
1726 "station a finite server count");
1727 if (s.ps)
1728 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " under " + std::string(lang::sched_to_text(s.sched)) +
1729 ", which shares its whole capacity rather than handing out "
1730 "slots, so a job cannot hold n of them; use a queueing "
1731 "discipline, or a class weight to slow the class down");
1732 if (s.polling || s.pas)
1733 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " under " + std::string(lang::sched_to_text(s.sched)) +
1734 ", whose controller picks the server itself and has no pool "
1735 "to seize n slots from");
1736 if (s.bmsp)
1737 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " at a bulk (BMSP) server, which has only a "
1738 "station-level firing clock and no per-job server "
1739 "to seize");
1740 if (s.has_pools)
1741 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " together with heterogeneous server pools: a job is "
1742 "served at the law of the one pool it occupies, which "
1743 "n servers of mixed types cannot express");
1744 if (s.has_setup)
1745 throw UnsupportedError(ErrorKind{"ServerParallelism"}, where + " together with setup/delayoff: the slots of such a "
1746 "station power up one at a time, so 'n free at once' "
1747 "is not a state it passes through");
1748 if (maxpar > s.nservers)
1749 throw InputError(where + " of " + std::to_string(maxpar) + " above its " +
1750 std::to_string(s.nservers) + " servers, so the job could never "
1751 "enter service");
1752 }
1753
1754 // ---- global (Whittle) dependence ----------------------------------------
1755 /**
1756 * `sn.gdscaling`: ONE handle phi(n), read at the FULL (nstations x nclasses)
1757 * population, that multiplies every station's service rate at once.
1758 *
1759 * It is not a station's own state dependence under another name. A job
1760 * moving ANYWHERE changes the rate EVERYWHERE, so the four advance and
1761 * reschedule calls fan out over the whole network instead of touching the
1762 * station that moved. That fan-out is cheap here precisely because this
1763 * engine rescales RESIDUAL WORK rather than resampling: repeating a
1764 * reschedule burns no variate and lands on the same completion instant, so
1765 * it is idempotent in a way the reference engine's cancel-and-redraw is not.
1766 */
1767 const bool has_gd = static_cast<bool>(sn.gdscaling);
1768 std::vector<std::vector<double> > gd_cache(M, std::vector<double>(K, 1.0));
1769 if (has_gd) {
1770 if (sn.gdscalingpeak.size() < M * K)
1771 throw InputError(
1772 "SolverLDES (native engine): a global dependence requires an explicit peak rate, "
1773 "because utilization under it is reported as T*E[S]/peak; pass one to "
1774 "setGlobalDependence");
1775 for (std::size_t i = 0; i < M; ++i) {
1776 StationState& s = S[i];
1777 const std::string& nm = sn.stations[i].name;
1778 if (s.bmsp)
1779 throw UnsupportedError(ErrorKind{"GlobalDependence"},
1780 "SolverLDES (native engine): station '" + nm +
1781 "' is a bulk (BMSP) server under a global dependence, which holds only a "
1782 "station-level firing clock and no per-job residual work to rescale");
1783 if (s.pas)
1784 throw UnsupportedError(ErrorKind{"GlobalDependence"},
1785 "SolverLDES (native engine): station '" + nm +
1786 "' is a pass-and-swap station under a global dependence, whose single "
1787 "aggregate clock is armed from its own list and carries no residual work "
1788 "to rescale");
1789 for (std::size_t r = 0; r < K; ++r) {
1790 const double pk = num_traits<T>::to_double(sn.gdscalingpeak[i * K + r]);
1791 if (!(pk > 0.0) || !std::isfinite(pk))
1792 throw InputError("SolverLDES (native engine): the global dependence declares "
1793 "a peak rate that is not finite and positive at station '" +
1794 nm + "'");
1795 s.gd_peak[r] = pk;
1796 }
1797 // Its rate is now a function of the whole population, so it has to
1798 // be re-timed at every change -- which is what `state_dependent`
1799 // means and is why a global dependence needs no flag of its own on
1800 // the buffered path.
1801 if (s.role == Role::Queue) s.state_dependent = true;
1802 }
1803 }
1804
1805 // ---- routing, resolved once per (station, class) -----------------------
1806 // A Router, a ClassSwitch and a Logger are not stations. `Peff` is a
1807 // NODE-level matrix and keeps them, so they appear as destinations here and
1808 // `deliver` walks through them in one instant; a destination that is none of
1809 // those and none of Source/Queue/Delay/Sink/Fork/Join/Cache/Place/Transition
1810 // is a node this engine must not guess at.
1811 const std::size_t nnodes = sn.nof_nodes();
1812 std::vector<std::size_t> node_to_station(nnodes + 1, M);
1813 for (std::size_t i = 0; i < M; ++i) node_to_station[sn.station_to_node[i]] = i;
1814
1815 /**
1816 * ROUTING IS RESOLVED OVER NODES, not over stations.
1817 *
1818 * A Router, a ClassSwitch and a Logger appear here as destinations: the
1819 * stochastic complement that removes them lives in `rt`, not in the
1820 * NODE-level `Peff` this table is read off. They hold nothing, so `deliver`
1821 * walks straight through them. A FORK and a JOIN are different again: they
1822 * are stateful, they change the NUMBER of jobs in flight, and no stochastic
1823 * complement can express that, so `deliver` replicates at a Fork and
1824 * synchronizes at a Join.
1825 */
1826 std::vector<std::vector<std::vector<RouteDest>>> nroute(
1827 nnodes + 1, std::vector<std::vector<RouteDest>>(K));
1828 /**
1829 * The dispatcher each (node, class) declares, and its round-robin pointer.
1830 *
1831 * The pointer is per (node, class) and NOT per destination: a round-robin
1832 * dispatcher hands out its outgoing links in turn, and one counter per link
1833 * would let two classes at the same node walk the cycle independently and
1834 * both start at the head.
1835 */
1836 std::vector<std::vector<lang::RoutingStrategy>> node_routing(
1837 nnodes + 1, std::vector<lang::RoutingStrategy>(K, lang::RoutingStrategy::PROB));
1838 std::vector<std::vector<std::size_t>> rr_counter(nnodes + 1, std::vector<std::size_t>(K, 0));
1839 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1840 for (std::size_t r = 0; r < K; ++r)
1841 if (sn.nodes[inode - 1].routing.size() > r)
1842 node_routing[inode][r] = sn.nodes[inode - 1].routing[r];
1843 for (std::size_t inode = 1; inode <= nnodes; ++inode) {
1844 // THE SINK HAS NO OUTGOING PATH. The refresh writes a Sink -> Source
1845 // closure into the effective routing so the visit ratios of an open
1846 // model have a cycle to solve on; it is bookkeeping, not a route a job
1847 // takes. Reading it as one sends every departing job back to the
1848 // Source. The station-level table never saw it because the Sink is not
1849 // a station.
1850 if (sn.nodes[inode - 1].nodetype == NodeType::Sink) continue;
1851 // A PETRI NET DOES NOT ROUTE. Its arcs declare the structure, but the
1852 // tokens move by the enabling and firing matrices of the transitions,
1853 // so walking the routing here would move them twice.
1854 if (sn.nodes[inode - 1].nodetype == NodeType::Place ||
1855 sn.nodes[inode - 1].nodetype == NodeType::Transition)
1856 continue;
1857 for (std::size_t r = 0; r < K; ++r) {
1858 // ASCENDING NODE INDEX in the outer loop: this is the order the
1859 // round-robin pointer walks, and it is the reference's order.
1860 for (std::size_t j = 1; j <= nnodes; ++j) {
1861 RouteDest d;
1862 d.node = j;
1863 d.sink = (sn.nodes[j - 1].nodetype == NodeType::Sink);
1864 d.station = node_to_station[j];
1865 for (std::size_t s2 = 0; s2 < K; ++s2) {
1866 const double p = num_traits<T>::to_double(sn.route_eff(r + 1, s2 + 1, inode, j));
1867 if (!(p > 0.0)) continue;
1868 d.mass += p;
1869 d.cls.push_back(std::make_pair(s2, p));
1870 }
1871 if (d.cls.empty()) continue;
1872 // A Fork, a Join and a Cache are STATEFUL and hold jobs; a
1873 // ClassSwitch, a Router and a Logger do not and are walked
1874 // through in one step by `deliver`. Neither kind is folded away
1875 // by the refresh -- `Peff` is a NODE-level matrix and keeps
1876 // every intermediate hop -- so both must be reachable here.
1877 // Anything else is a node this engine would have to guess at.
1878 if (!d.sink && d.station >= M && sn.nodes[j - 1].nodetype != NodeType::Fork &&
1879 sn.nodes[j - 1].nodetype != NodeType::Join &&
1880 sn.nodes[j - 1].nodetype != NodeType::Cache &&
1881 sn.nodes[j - 1].nodetype != NodeType::ClassSwitch &&
1882 sn.nodes[j - 1].nodetype != NodeType::Router &&
1883 sn.nodes[j - 1].nodetype != NodeType::Logger &&
1884 sn.nodes[j - 1].nodetype != NodeType::Place &&
1885 sn.nodes[j - 1].nodetype != NodeType::Transition)
1886 throw UnsupportedError(ErrorKind{"RoutingStrategy"},
1887 "SolverLDES (native engine): the routing crosses node '" +
1888 sn.nodes[j - 1].name +
1889 "', which the refresh did not fold into the station-to-station "
1890 "routing");
1891 if (d.station < M && S[d.station].role == Role::Source)
1892 throw InputError("SolverLDES (native engine): the routing sends a "
1893 "job back into the Source");
1894 nroute[inode][r].push_back(d);
1895 }
1896 }
1897 }
1898
1899 // ---- fork and join --------------------------------------------------------
1900 // A Fork replicates the arriving job onto every outgoing edge; a Join holds
1901 // the siblings until the strategy is satisfied and releases ONE job. The
1902 // pair is matched by `sn.fj`, so a Fork with no Join is a model whose
1903 // siblings never recombine and is refused rather than run.
1904 std::vector<int> join_of_fork(nnodes + 1, -1);
1905 std::vector<bool> is_fork(nnodes + 1, false), is_join(nnodes + 1, false);
1906 for (const auto& fjp : sn.fj) {
1907 if (fjp.first > nnodes || fjp.second > nnodes) continue;
1908 is_fork[fjp.first] = true;
1909 is_join[fjp.second] = true;
1910 join_of_fork[fjp.first] = static_cast<int>(fjp.second);
1911 }
1912 // Whether a class routes into a Join ANYWHERE: it decides whether a phase-2
1913 // continuation of that class inherits the trigger's fork identity.
1914 std::vector<bool> spawn_joins_at(K, false);
1915 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1916 for (std::size_t r = 0; r < K; ++r)
1917 for (const RouteDest& d : nroute[inode][r])
1918 if (d.node >= 1 && d.node <= nnodes &&
1919 sn.nodes[d.node - 1].nodetype == NodeType::Join)
1920 for (std::size_t c = 0; c < d.cls.size(); ++c)
1921 spawn_joins_at[d.cls[c].first] = true;
1922 for (std::size_t nd = 1; nd <= nnodes; ++nd) {
1923 if (sn.nodes[nd - 1].nodetype == NodeType::Fork && !is_fork[nd])
1924 throw InputError("SolverLDES (native engine): Fork node '" + sn.nodes[nd - 1].name +
1925 "' has no matching Join");
1926 if (sn.nodes[nd - 1].nodetype == NodeType::Join && !is_join[nd])
1927 throw InputError("SolverLDES (native engine): Join node '" + sn.nodes[nd - 1].name +
1928 "' closes no Fork");
1929 }
1930
1931 /**
1932 * One forked parent awaiting its siblings.
1933 *
1934 * `required` is fixed at FORK time, not at join time: the quorum is a
1935 * fraction of the siblings THIS fork produced, and a Join shared by two
1936 * forks of different fan-out would otherwise apply one fork's count to the
1937 * other's siblings.
1938 */
1939 struct ForkSync {
1940 std::size_t cls = 0;
1941 double t_sys = 0.0;
1942 int total = 0;
1943 int required = 0;
1944 /**
1945 * (class, instant) of every sibling this parent has parked at the Join.
1946 *
1947 * Held PER PARENT, not per Join: the Join's queue length must fall by
1948 * the siblings THIS synchronization releases, and a node-wide tally
1949 * would clear the siblings of every other parent still waiting. It also
1950 * dates each sibling's own wait, which is `forkedJobJoinArrivalTimes`.
1951 */
1952 std::vector<std::pair<std::size_t, double>> siblings;
1953 /** The parent's own fork identity, restored on release so a NESTED section rejoins its outer one. */
1954 std::uint64_t outer = 0;
1955 double t_fork = 0.0; ///< when the parent arrived at the Fork, the start of its FJRespT sample
1956 int pair = -1; ///< index of the (Fork, Join) pair in fj_pairs, -1 when unpaired
1957 };
1958 std::map<std::uint64_t, ForkSync> fork_sync;
1959 std::uint64_t next_parent = 0;
1960 // FORK-JOIN SECTION METRICS (getAvgFJ). One sojourn sample per parent, from its arrival at the Fork to its
1961 // release by the Join, under the (pair, class it arrived in). Release epochs are kept so FJTput is
1962 // differenced at the truncation point exactly as TN is; the sojourn is the full tally, as RN is.
1963 const std::vector<std::pair<std::size_t, std::size_t>> fj_pairs = solvers::fj_sorted_pairs(sn);
1964 std::vector<int> fj_pair_of_fork(nnodes + 1, -1);
1965 for (std::size_t p = 0; p < fj_pairs.size(); ++p)
1966 if (fj_pairs[p].first <= nnodes &&
1967 join_of_fork[fj_pairs[p].first] == static_cast<int>(fj_pairs[p].second))
1968 fj_pair_of_fork[fj_pairs[p].first] = static_cast<int>(p);
1969 std::vector<std::vector<double>> fj_resp_sum(fj_pairs.size(), std::vector<double>(K, 0.0));
1970 std::vector<std::vector<double>> fj_resp_cnt(fj_pairs.size(), std::vector<double>(K, 0.0));
1971 std::vector<std::vector<std::vector<double>>> fj_release(fj_pairs.size(),
1972 std::vector<std::vector<double>>(K));
1973
1974
1975 // ---- cache nodes ----------------------------------------------------------
1976 // A Cache is a ROUTING node with state: it reads an item, decides hit or
1977 // miss on its real list contents, and switches the job onto the hit or the
1978 // miss class. That makes the hit an exact sample-path event rather than a
1979 // rate, which is the reason to ask a simulator about a cache at all.
1980 std::map<std::size_t, CacheState> caches;
1981 for (const auto& np : sn.nodeparam) {
1982 const std::size_t nd = np.first;
1983 if (nd == 0 || nd > nnodes) continue;
1984 if (sn.nodes[nd - 1].nodetype != NodeType::Cache) continue;
1985 const auto& cp = np.second;
1986 CacheState cs;
1987 cs.node = nd;
1988 cs.nitems = cp.nitems;
1989 cs.policy = cp.replacestrat;
1990 for (int c : cp.itemcap)
1991 if (c > 0) cs.capacity.push_back(static_cast<std::size_t>(c));
1992 if (cs.capacity.empty()) cs.capacity.push_back(1);
1993 cs.lists.assign(cs.capacity.size(), std::list<std::size_t>());
1994 for (int v : cp.itemsize) cs.item_size.push_back(v);
1995 for (int v : cp.costcap) cs.cost_cap.push_back(v);
1996 cs.hits.assign(K, 0.0);
1997 cs.misses.assign(K, 0.0);
1998 cs.hits_per_list.assign(K, std::vector<double>(cs.lists.size(), 0.0));
1999 cs.item_level_time.assign(cs.nitems, std::vector<double>(cs.lists.size(), 0.0));
2000 cs.hit_class.assign(K, -1);
2001 cs.miss_class.assign(K, -1);
2002 for (std::size_t r = 0; r < K && r < cp.hitclass.size(); ++r)
2003 if (cp.hitclass[r] > 0) cs.hit_class[r] = static_cast<int>(cp.hitclass[r] - 1);
2004 for (std::size_t r = 0; r < K && r < cp.missclass.size(); ++r)
2005 if (cp.missclass[r] > 0) cs.miss_class[r] = static_cast<int>(cp.missclass[r] - 1);
2006 // The popularity is stored as a pmf per class and used as a CUMULATIVE
2007 // law here, so one uniform draw picks an item.
2008 cs.popularity.assign(K, std::vector<double>());
2009 for (std::size_t r = 0; r < K && r < cp.pread.size(); ++r) {
2010 double acc = 0.0;
2011 for (const T& v : cp.pread[r]) {
2012 acc += num_traits<T>::to_double(v);
2013 cs.popularity[r].push_back(acc);
2014 }
2015 if (!cs.popularity[r].empty()) cs.popularity[r].back() = 1.0;
2016 }
2017 // Delayed-hit retrieval: one class per (item, read class) carries the
2018 // fetch through the retrieval stations and back here.
2019 if (cp.retrieval_capacity > 0 && !cp.retrieval_classes.empty()) {
2020 cs.has_retrieval = true;
2021 cs.retrieval_class = cp.retrieval_classes;
2022 cs.retrieval_class_to_item.assign(K, -1);
2023 for (std::size_t it = 0; it < cs.retrieval_class.size(); ++it)
2024 for (std::size_t r = 0; r < cs.retrieval_class[it].size(); ++r) {
2025 const std::size_t rc = cs.retrieval_class[it][r];
2026 if (rc > 0 && rc <= K) cs.retrieval_class_to_item[rc - 1] = static_cast<int>(it);
2027 }
2028 cs.in_flight.assign(cs.nitems, 0);
2029 cs.fetch_start.assign(cs.nitems, 0.0);
2030 cs.held.assign(cs.nitems, std::vector<CacheState::HeldRequest>());
2031 cs.delayed.assign(K, 0.0);
2032 }
2033 caches[nd] = cs;
2034 }
2035
2036
2037 // ---- Petri net: places and transitions --------------------------------------
2038 // A Place is a STATION holding tokens per class; a Transition is a node that
2039 // moves them. The marking is the places' token counts, and it is the only
2040 // state the net has.
2041 std::vector<std::size_t> place_of(nnodes + 1, M);
2042 std::vector<std::size_t> place_nodes;
2043 for (std::size_t nd = 1; nd <= nnodes; ++nd)
2044 if (sn.nodes[nd - 1].nodetype == NodeType::Place) {
2045 place_of[nd] = place_nodes.size();
2046 place_nodes.push_back(nd);
2047 }
2048 std::vector<std::size_t> place_station(place_nodes.size(), M);
2049 for (std::size_t p = 0; p < place_nodes.size(); ++p)
2050 place_station[p] = node_to_station[place_nodes[p]];
2051 /**
2052 * The initial marking: what the model DECLARED, widened by the closed
2053 * populations whose reference station is this place.
2054 *
2055 * The widening is not a convenience. A closed class in a Petri net has its
2056 * jobs in the marking and nowhere else, and `Network.initDefault` is what
2057 * puts them there -- which the JSON document only records once someone has
2058 * called `setState`, so a net that never did would start EMPTY and never
2059 * fire. `default_init_state` takes the same maximum for the CTMC, and
2060 * `initClosedClassPopulations` skips its own injection on a Place for the
2061 * same reason, so all three agree on the state the model starts in.
2062 */
2063 std::vector<std::vector<double>> marking(place_nodes.size(), std::vector<double>(K, 0.0));
2064 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
2065 auto im = sn.initmarking.find(place_nodes[p]);
2066 if (im != sn.initmarking.end())
2067 for (std::size_t r = 0; r < K && r < im->second.size(); ++r)
2068 marking[p][r] = num_traits<T>::to_double(im->second[r]);
2069 for (std::size_t r = 0; r < K; ++r) {
2070 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
2071 const double n = sn.classes[r].population;
2072 if (!std::isfinite(n) || !(n > 0.0)) continue;
2073 if (sn.classes[r].refstat != place_station[p] + 1) continue;
2074 marking[p][r] = std::max(marking[p][r], n);
2075 }
2076 }
2077
2078 // ---- queueing places -----------------------------------------------------
2079 /**
2080 * A QUEUEING PLACE holds its tokens in three compartments, and only the last
2081 * of them is what a Petri net normally calls the marking.
2082 *
2083 * waiting tokens queued for one of the place's own servers
2084 * in service tokens a server is working on
2085 * depository tokens whose service is finished
2086 *
2087 * THE OUTPUT ARCS SEE THE DEPOSITORY ALONE. That is the entire construct: a
2088 * token deposited by an input transition has to be SERVED before it can
2089 * enable an output one, which is how a queueing Petri net puts a scheduling
2090 * station inside a place. `marking` stays the sum of the three, because that
2091 * is what the place's token count means and what QN reports.
2092 *
2093 * An ORDINARY place keeps `avail == marking` and never touches the rest, so
2094 * every existing net is unmoved.
2095 */
2096 std::vector<std::vector<double>> avail(place_nodes.size(), std::vector<double>(K, 0.0));
2097 std::vector<bool> qplace(place_nodes.size(), false);
2098 std::vector<std::size_t> place_servers(place_nodes.size(), 1);
2099 std::vector<std::deque<std::size_t>> place_wait(place_nodes.size());
2100 std::vector<std::size_t> place_busy(place_nodes.size(), 0);
2101 std::vector<std::vector<double>> place_insvc(place_nodes.size(), std::vector<double>(K, 0.0));
2102 std::vector<std::vector<Sampler>> place_svc(place_nodes.size(), std::vector<Sampler>(K));
2103 /**
2104 * One stream per (place, class), on the reference's own band.
2105 *
2106 * `Solver_ssj` seeds a queueing place's generators at
2107 * `((numSources + numServiceNodes + placeIdx) * K + k) * 10 + 5000`, and
2108 * this is that expression: `svc_index` already numbers the non-Source
2109 * stations the way `numServiceNodes` counts them, and a place IS one of
2110 * them here. The transition clocks sit at the bare +5000 of `g_spn`, which
2111 * the expression never reaches because an SPN has at least one place and so
2112 * at least one full stride of K*10 above it.
2113 */
2114 std::vector<std::vector<Rng>> g_place(place_nodes.size());
2115 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
2116 const std::size_t ist = place_station[p];
2117 g_place[p].reserve(K);
2118 for (std::size_t k = 0; k < K; ++k) {
2119 const long long off =
2120 ((nsrc + nsvc + static_cast<long long>(p)) * Kll + static_cast<long long>(k)) *
2121 10 + 5000;
2122 g_place[p].push_back(Rng(seed_ll, off));
2123 }
2124 if (ist >= M) continue;
2125 qplace[p] = sn.is_queueing_place(ist);
2126 if (!qplace[p]) {
2127 for (std::size_t r = 0; r < K; ++r) avail[p][r] = marking[p][r];
2128 continue;
2129 }
2130 const auto& pst = sn.stations[ist];
2131 // The embedded queue's discipline. FCFS, LCFS and SIRO differ only in
2132 // WHICH waiting token is taken next; INF takes every one of them at
2133 // once and so has no order to choose. Anything else is refused by name:
2134 // sharing a place's server among its tokens, or ordering them by a size
2135 // the place never sampled, is a queue this construct does not describe.
2136 if (pst.sched != SchedStrategy::FCFS && pst.sched != SchedStrategy::LCFS &&
2137 pst.sched != SchedStrategy::SIRO && pst.sched != SchedStrategy::INF)
2138 throw UnsupportedError(ErrorKind{"QueueingPlace"},
2139 "SolverLDES (native engine): queueing place '" + pst.name +
2140 "' is scheduled " + lang::sched_to_text(pst.sched) +
2141 ", which the embedded-queue algorithm does not support "
2142 "(supported: FCFS, LCFS, SIRO, INF)");
2143 place_servers[p] = (pst.sched == SchedStrategy::INF || !std::isfinite(pst.nservers))
2144 ? std::numeric_limits<std::size_t>::max()
2145 : static_cast<std::size_t>(pst.nservers + 0.5);
2146 for (std::size_t r = 0; r < K; ++r) {
2147 if (sn.service[ist][r].disabled) continue;
2148 place_svc[p][r] =
2149 Sampler(sn.service[ist][r],
2150 "queueing place '" + pst.name + "', class '" + sn.classes[r].name + "'");
2151 }
2152 // The initial tokens are QUEUED, not deposited: a token the model puts
2153 // in a queueing place has not been served yet, so it must pass through
2154 // the embedded queue before an output arc may take it. ASCENDING class
2155 // order, which is the order `Solver_ssj` enqueues them in.
2156 for (std::size_t k = 0; k < K; ++k)
2157 for (double t = 0.0; t + 0.5 < marking[p][k]; t += 1.0) place_wait[p].push_back(k);
2158 }
2159
2160 std::vector<SpnTransition> transitions;
2161 // The firing LAW of each timed mode, [transition][mode]; `SpnMode::rate` keeps only its mean.
2162 std::vector<std::vector<Sampler>> spn_law;
2163 for (const auto& tp : sn.transparam) {
2164 const std::size_t nd = tp.first;
2165 if (nd == 0 || nd > nnodes) continue;
2166 SpnTransition tr;
2167 tr.node = nd;
2168 tr.places = place_nodes;
2169 for (std::size_t m = 0; m < tp.second.nmodes; ++m) {
2170 SpnMode mode;
2171 // (place slot, class), flattened p*K + r, the layout `flat_marking`
2172 // produces and `spn_enabled`/`spn_fire` read.
2173 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
2174 const std::size_t pn = place_nodes[p];
2175 for (std::size_t r = 0; r < K; ++r) {
2176 mode.enabling.push_back(
2177 (m < tp.second.enabling.size() && pn - 1 < tp.second.enabling[m].rows() &&
2178 r < tp.second.enabling[m].cols())
2179 ? num_traits<T>::to_double(tp.second.enabling[m](pn - 1, r))
2180 : 0.0);
2181 mode.inhibiting.push_back(
2182 (m < tp.second.inhibiting.size() &&
2183 pn - 1 < tp.second.inhibiting[m].rows() &&
2184 r < tp.second.inhibiting[m].cols())
2185 ? num_traits<T>::to_double(tp.second.inhibiting[m](pn - 1, r))
2186 : std::numeric_limits<double>::infinity());
2187 mode.firing.push_back(
2188 (m < tp.second.firing.size() && pn - 1 < tp.second.firing[m].rows() &&
2189 r < tp.second.firing[m].cols())
2190 ? num_traits<T>::to_double(tp.second.firing[m](pn - 1, r))
2191 : 0.0);
2192 }
2193 }
2194 if (m < tp.second.firing.size())
2195 for (std::size_t nd2 = 1; nd2 <= nnodes && nd2 - 1 < tp.second.firing[m].rows(); ++nd2) {
2196 if (sn.nodes[nd2 - 1].nodetype == NodeType::Place) continue;
2197 for (std::size_t r = 0; r < K && r < tp.second.firing[m].cols(); ++r) {
2198 const double f = num_traits<T>::to_double(tp.second.firing[m](nd2 - 1, r));
2199 if (f > 0.0) mode.exits.emplace_back(nd2, r, f);
2200 }
2201 }
2202 if (m < tp.second.nmodeservers.size()) mode.servers = tp.second.nmodeservers[m];
2203 if (m < tp.second.firingprio.size()) mode.priority = tp.second.firingprio[m];
2204 if (m < tp.second.fireweight.size())
2205 mode.weight = num_traits<T>::to_double(tp.second.fireweight[m]);
2206 mode.immediate = (m < tp.second.timing.size() &&
2207 tp.second.timing[m] == lang::TimingStrategy::IMMEDIATE);
2208 if (m < tp.second.firingproc.size() && !tp.second.firingproc[m].disabled) {
2209 const double mean = num_traits<T>::to_double(tp.second.firingproc[m].mean);
2210 if (mean > 0.0) mode.rate = 1.0 / mean;
2211 }
2212 if (m < tp.second.firingdep.size() && tp.second.firingdep[m]) {
2213 const auto f = tp.second.firingdep[m];
2214 const std::vector<std::size_t> pl = place_nodes;
2215 const std::size_t nn = nnodes, KK = K;
2216 // g(marking) reads the NODE-indexed (node, class) marking flattened node-major, the vector
2217 // `state_events.h` builds; the engine's own (place, class) vector is re-laid onto it by node.
2218 mode.dep = [f, pl, nn, KK](const std::vector<double>& tok) {
2219 std::vector<T> mk(nn * KK, num_traits<T>::from_int(0));
2220 for (std::size_t p = 0; p < pl.size(); ++p) {
2221 if (pl[p] < 1 || pl[p] - 1 >= nn) continue;
2222 for (std::size_t r = 0; r < KK; ++r)
2223 if (p * KK + r < tok.size())
2224 mk[(pl[p] - 1) * KK + r] = num_traits<T>::from_double(tok[p * KK + r]);
2225 }
2226 return num_traits<T>::to_double(f(mk));
2227 };
2228 }
2229 tr.modes.push_back(mode);
2230 }
2231 tr.fired.assign(tr.modes.size(), 0.0);
2232 std::vector<Sampler> law(tr.modes.size());
2233 const std::string tname = sn.nodes[nd - 1].name;
2234 for (std::size_t m = 0; m < tr.modes.size(); ++m)
2235 if (!tr.modes[m].immediate && m < tp.second.firingproc.size() &&
2236 !tp.second.firingproc[m].disabled)
2237 law[m] = Sampler(tp.second.firingproc[m], "the firing time of mode " +
2238 std::to_string(m + 1) + " of transition '" + tname + "'");
2239 spn_law.push_back(law);
2240 transitions.push_back(tr);
2241 }
2242
2243 // ---- statistics --------------------------------------------------------
2244 Accum acc(M, K);
2245 for (std::size_t i = 0; i < M; ++i) acc.util_peak[i] = S[i].util_peak;
2246 // A PLACE'S QUEUE LENGTH IS ITS MARKING, and it starts at the initial one.
2247 // The integral is lazy, so the value has to be in `acc.qlen` from t = 0 and
2248 // every later write has to go through `update_qlen` first, exactly as a
2249 // station's does.
2250 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
2251 if (place_station[p] >= M) continue;
2252 for (std::size_t r = 0; r < K; ++r) acc.qlen[place_station[p]][r] = marking[p][r];
2253 }
2254 // Declared here and populated below: the admission lambda tracks into it,
2255 // and a lambda cannot capture a name introduced after it.
2256 BusyPeriods bp;
2257 std::vector<double> sys_resp_sum(K, 0.0), sys_resp_cnt(K, 0.0), sys_completed(K, 0.0);
2258 // Per (station, class): a model can drop at one station and balk at
2259 // another, and a per-class total would not say which.
2260 std::vector<std::vector<double>> dropped(M, std::vector<double>(K, 0.0));
2261 std::vector<std::vector<double>> balked(M, std::vector<double>(K, 0.0));
2262 std::vector<std::vector<double>> reneged(M, std::vector<double>(K, 0.0));
2263 // Epoch of the LAST job lost unserved (reneged, or removed by a NEGATIVE/CATASTROPHE signal) per (station, class),
2264 // -1 when none: compared with the truncation epoch it says whether any loss falls in the measured window.
2265 std::vector<std::vector<double>> last_lost(M, std::vector<double>(K, -1.0));
2266 std::vector<std::vector<double>> blocked_count(M, std::vector<double>(K, 0.0));
2267 /**
2268 * Every per-visit response time, when either flag asked for them.
2269 *
2270 * --trajectory counts as asking: the reference NESTS the same samples inside
2271 * its `transient` block, and MATLAB's `sample()` and `sampleSys()` read them
2272 * from there. Recording only under --respt-samples left those two returning
2273 * nothing on the transient path.
2274 */
2275 const bool want_respt = o.export_respt || o.export_trajectory;
2276 std::vector<std::vector<std::vector<double>>> resp_samples(
2277 want_respt ? M : 0, std::vector<std::vector<double>>(K));
2278 std::uint64_t job_id = 0;
2279 double now = 0.0;
2280
2281 /**
2282 * LOGGER TRACES, the reference's `initializeLoggers` and `logJobPassage`.
2283 *
2284 * Every Logger writes one CSV row per job crossing it, into `filePath` (or
2285 * the model's log path when the node names none) joined with `fileName`
2286 * (`logger_<name>.csv` when unnamed). The header always carries all seven
2287 * columns and a column whose flag is off is left EMPTY, delimiters kept,
2288 * so a trace has the same shape whatever it records. The interarrival
2289 * columns measure from time 0 for the first crossing, as the reference's
2290 * zero-initialized last-crossing times do. JOB_ID is a counter taken
2291 * once per routing walk through pass-through nodes, which is what the
2292 * reference's `nextJobId++` in `selectDestination` numbers.
2293 */
2294 struct LoggerTrace {
2295 std::unique_ptr<std::ofstream> out;
2296 qn::NodeDef::LoggerParam par;
2297 std::string name;
2298 double last_any = 0.0;
2299 std::vector<double> last_cls;
2300 };
2301 std::map<std::size_t, LoggerTrace> logger_traces;
2302 std::string log_start_time;
2303 std::uint64_t log_walk_id = 0;
2304 for (std::size_t nd = 1; nd <= sn.nof_nodes(); ++nd) {
2305 if (sn.nodes[nd - 1].nodetype != NodeType::Logger) continue;
2306 if (log_start_time.empty()) {
2307 const std::time_t tt = std::time(nullptr);
2308 char buf[32];
2309 std::strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", std::localtime(&tt));
2310 log_start_time = buf;
2311 }
2312 LoggerTrace lt;
2313 lt.par = sn.nodes[nd - 1].logger;
2314 lt.name = sn.nodes[nd - 1].name;
2315 lt.last_cls.assign(K, 0.0);
2316 const std::string file =
2317 lt.par.file_name.empty() ? "logger_" + lt.name + ".csv" : lt.par.file_name;
2318 std::string dir = lt.par.file_path.empty() ? sn.log_path : lt.par.file_path;
2319 if (!dir.empty() && dir[dir.size() - 1] != '/') dir += '/';
2320 const std::string path = dir + file;
2321 lt.out.reset(new std::ofstream(path.c_str(), std::ios::out | std::ios::trunc));
2322 // A trace is a side output: a path that cannot be opened is reported and the run goes on.
2323 if (!*lt.out) {
2324 std::fprintf(stderr, "Warning: solver_ldes: failed to create logger file for %s: %s\n",
2325 lt.name.c_str(), path.c_str());
2326 continue;
2327 }
2328 *lt.out << "LOGGERNAME,TIMESTAMP,JOB_ID,CLASS_ID,INTERARRIVAL_SAMECLASS,"
2329 "INTERARRIVAL_ANYCLASS,SIMUL_START_TIME\n";
2330 logger_traces[nd] = std::move(lt);
2331 }
2332 auto log_passage = [&](std::size_t nd, std::size_t r, std::uint64_t id) {
2333 typename std::map<std::size_t, LoggerTrace>::iterator it = logger_traces.find(nd);
2334 if (it == logger_traces.end()) return;
2335 LoggerTrace& lt = it->second;
2336 const double same = now - lt.last_cls[r], any = now - lt.last_any;
2337 lt.last_cls[r] = now;
2338 lt.last_any = now;
2339 std::ostream& w = *lt.out;
2340 if (lt.par.logger_name) w << lt.name;
2341 w << ',';
2342 if (lt.par.timestamp) w << java_double_str(now);
2343 w << ',';
2344 if (lt.par.job_id) w << id;
2345 w << ',';
2346 if (lt.par.job_class) w << sn.classes[r].name;
2347 w << ',';
2348 if (lt.par.time_same_class) w << java_double_str(same);
2349 w << ',';
2350 if (lt.par.time_any_class) w << java_double_str(any);
2351 w << ',';
2352 if (lt.par.start_time) w << log_start_time;
2353 w << '\n';
2354 };
2355
2356 /**
2357 * G-NETWORK REMOVAL SIGNALS, resolved once per class.
2358 *
2359 * A removal signal is a class like any other while it is in transit and
2360 * stops being one the instant it reaches a station: it never joins, it
2361 * removes jobs already there and it is annihilated. `is_removal_signal`
2362 * is the whole gate, and everything the removal costs -- the registry of
2363 * jobs held at a Delay station, the extra draws -- is paid only where it
2364 * is true, so a model without signals runs exactly as before.
2365 */
2366 std::vector<bool> is_removal_signal(K, false);
2367 bool has_removal_signal = false;
2368 for (std::size_t r = 0; r < K; ++r) {
2369 if (r >= sn.issignal.size() || !sn.issignal[r]) continue;
2370 const lang::SignalType kind =
2371 (r < sn.signaltype.size()) ? sn.signaltype[r] : lang::SignalType::NEGATIVE;
2373 is_removal_signal[r] = true;
2374 has_removal_signal = true;
2375 }
2376 }
2377 /**
2378 * The Delay job registry is kept for a GLOBAL DEPENDENCE as well as for a
2379 * removal signal: phi re-times the think times with everything else, and
2380 * the registry is the only handle on them (an infinite server has no slot
2381 * to hang a generation off).
2382 */
2383 // `options.init_jobs` and `options.track_jobs`: see the injection below and
2384 // the export at the end of the run.
2385 const bool init_jobs_on = o.init_jobs.rows() > 0;
2386 const bool track_jobs = o.track_jobs || init_jobs_on;
2387 const bool track_delay_jobs = has_removal_signal || has_gd || track_jobs;
2388 /** [id, station, class, time] of each departure of a tagged init_jobs job. */
2389 std::vector<std::array<double, 4>> job_departure_log;
2390 /**
2391 * The jobs a Delay station currently holds, in arrival order.
2392 *
2393 * A Delay keeps no buffer and no server: its occupants live only in the
2394 * departure events already scheduled for them, and the event list has no
2395 * handle to cancel one. The registry is that handle -- a job removed by a
2396 * signal is erased from it, and the departure that arrives later finds no
2397 * entry and is discarded, which is the same tag trick the buffered
2398 * stations use against a preempted departure.
2399 */
2400 std::vector<std::vector<Job>> delay_live(track_delay_jobs ? M : 0);
2401
2402 /**
2403 * SYNCHRONOUS CALLS, `sn.syncreply`: the class a caller expects its reply in.
2404 *
2405 * A caller that expects one KEEPS ITS SERVER when it leaves for the callee
2406 * and gets it back only when the matching REPLY class returns, which is the
2407 * whole difference between a synchronous call and an ordinary hop. The
2408 * pending calls are keyed by an identity that rides on the job, because the
2409 * reply comes back from a station the caller never names.
2410 */
2411 std::vector<std::size_t> sync_reply(K, K);
2412 std::vector<bool> is_reply_signal(K, false);
2413 bool has_sync_call = false;
2414 for (std::size_t r = 0; r < K; ++r) {
2415 if (r < sn.syncreply.size() && sn.syncreply[r] >= 1 && sn.syncreply[r] <= K) {
2416 sync_reply[r] = sn.syncreply[r] - 1;
2417 has_sync_call = true;
2418 }
2419 if (r < sn.issignal.size() && sn.issignal[r] && r < sn.signaltype.size() &&
2420 sn.signaltype[r] == lang::SignalType::REPLY)
2421 is_reply_signal[r] = true;
2422 }
2423 /** One outstanding call: the slot it holds and the class that holds it. */
2424 struct PendingCall {
2425 std::size_t station = 0;
2426 std::size_t slot = 0;
2427 std::size_t cls = 0;
2428 double since = 0.0;
2429 };
2430 std::map<std::uint64_t, PendingCall> pending_reply;
2431 std::uint64_t next_call = 0;
2432
2433 /** Whether any (station, class) declares immediate feedback at all. */
2434 bool has_immfeed = false;
2435 for (std::size_t i = 0; i < M && i < sn.immfeed.size(); ++i)
2436 for (std::size_t r = 0; r < sn.immfeed[i].size(); ++r)
2437 if (sn.immfeed[i][r]) has_immfeed = true;
2438
2439 /**
2440 * SPAWN ON COMPLETION, `sn.classspawn`: completing a job of class r injects
2441 * a FRESH job of the mapped class at the same station.
2442 *
2443 * It is an LQN phase-2 continuation: the work the server owes after the
2444 * reply has gone back. The continuation is a new passage, so it starts its
2445 * own response-time clock and is not the completing job under another name.
2446 */
2447 std::vector<std::size_t> spawn_of(K, K);
2448 bool has_spawn = false;
2449 for (std::size_t r = 0; r < K; ++r)
2450 if (sn.classes[r].spawn >= 1 && sn.classes[r].spawn <= K) {
2451 spawn_of[r] = sn.classes[r].spawn - 1;
2452 has_spawn = true;
2453 }
2454
2455 // ---- event list --------------------------------------------------------
2456 std::priority_queue<Event, std::vector<Event>, EventLater> evq;
2457 std::uint64_t seq = 0, ps_tag = 0;
2458
2459 auto push = [&](Event e) {
2460 e.seq = seq++;
2461 evq.push(e);
2462 };
2463
2464 // ---- the embedded queue of a queueing place ------------------------------
2465 /**
2466 * Take the next waiting token into service, as often as a server is free.
2467 *
2468 * THE DISCIPLINE CHOOSES WHICH TOKEN, and nothing else about the place:
2469 * FCFS takes the token that has waited longest, LCFS the one that has waited
2470 * least, SIRO one at random, and INF takes all of them because it never runs
2471 * out of servers. The reference's engine accepts the same four names but
2472 * polls the head of one deque for all of them, so LCFS and SIRO are served
2473 * FIFO there; the mean token count is the same under any work-conserving
2474 * order at exponential service, and the two engines part company only in the
2475 * sojourn-time DISTRIBUTION they report.
2476 */
2477 std::function<void(std::size_t)> place_try_start = [&](std::size_t p) {
2478 const std::size_t ist = place_station[p];
2479 if (ist >= M) return;
2480 while (place_busy[p] < place_servers[p] && !place_wait[p].empty()) {
2481 std::size_t r = 0;
2482 if (S[ist].sched == SchedStrategy::LCFS) {
2483 r = place_wait[p].back();
2484 place_wait[p].pop_back();
2485 } else if (S[ist].sched == SchedStrategy::SIRO) {
2486 const std::size_t at = std::min(
2487 place_wait[p].size() - 1,
2488 static_cast<std::size_t>(uniform01(g_qplace) *
2489 static_cast<double>(place_wait[p].size())));
2490 r = place_wait[p][at];
2491 place_wait[p].erase(place_wait[p].begin() + static_cast<std::ptrdiff_t>(at));
2492 } else {
2493 r = place_wait[p].front();
2494 place_wait[p].pop_front();
2495 }
2496 // A COLOUR THE PLACE CANNOT SERVE IS A MODEL DEFECT, not a token to
2497 // pass through: a queueing place is queueing for every colour at
2498 // once, so a token arriving in a colour `setService` never named has
2499 // no service time to draw and would otherwise sit in the queue
2500 // forever, silently draining the net.
2501 if (place_svc[p][r].disabled())
2502 throw InputError("SolverLDES (native engine): a token of class '" +
2503 sn.classes[r].name + "' entered queueing place '" +
2504 sn.stations[ist].name +
2505 "', which has no service process for that class; call "
2506 "setService for every token colour of a queueing place");
2507 acc.update_busy(ist, r, now);
2508 ++place_busy[p];
2509 place_insvc[p][r] += 1.0;
2510 acc.busy[ist][r] = place_insvc[p][r];
2511 Event e;
2512 e.t = now + place_svc[p][r].next(g_place[p][r]);
2513 e.kind = EV_PLACE_SVC;
2514 e.station = p;
2515 e.cls = r;
2516 push(e);
2517 }
2518 };
2519
2520 /** Tokens an input arc puts at place `p`: into the queue, or straight to the arcs. */
2521 auto place_deposit = [&](std::size_t p, std::size_t r, double n) {
2522 const std::size_t ist = place_station[p];
2523 if (ist < M) {
2524 acc.update_qlen(ist, r, now);
2525 marking[p][r] += n;
2526 acc.qlen[ist][r] = marking[p][r];
2527 acc.arrived[ist][r] += n;
2528 } else {
2529 marking[p][r] += n;
2530 }
2531 if (!qplace[p]) {
2532 avail[p][r] += n;
2533 return;
2534 }
2535 for (double t = 0.0; t + 0.5 < n; t += 1.0) place_wait[p].push_back(r);
2536 place_try_start(p);
2537 };
2538
2539 /**
2540 * Tokens an output arc takes from place `p`, which are its COMPLETIONS.
2541 *
2542 * TN at a place is the rate at which tokens leave it, which is
2543 * `placeCompletions` in the reference and what Little's law turns back into
2544 * the mean token sojourn reported as RespT. A queueing place takes them from
2545 * the depository, the only compartment an arc can see.
2546 */
2547 auto place_take = [&](std::size_t p, std::size_t r, double n) {
2548 const std::size_t ist = place_station[p];
2549 marking[p][r] -= n;
2550 avail[p][r] -= n;
2551 if (ist < M) {
2552 acc.update_qlen(ist, r, now);
2553 acc.qlen[ist][r] = marking[p][r];
2554 acc.completed[ist][r] += n;
2555 }
2556 };
2557
2558
2559 /**
2560 * Push onto, and pop from, a station's waiting room.
2561 *
2562 * FSP takes the linear branch: its key is recomputed against the residual
2563 * work present at the station RIGHT NOW, over the waiting jobs and the
2564 * ones in service alike, so no stamped ordering can stand in for it. Every
2565 * other discipline has a per-job constant key and rides the heap.
2566 */
2567 auto buffer_push = [&](std::size_t i, const Job& job) {
2568 StationState& s = S[i];
2569 s.buffer.push_back(job);
2570 if (s.sched != SchedStrategy::FSP)
2571 std::push_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
2572 };
2573 auto buffer_pop = [&](std::size_t i) -> Job {
2574 StationState& s = S[i];
2575 if (s.sched != SchedStrategy::FSP) {
2576 std::pop_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
2577 Job job = s.buffer.back();
2578 s.buffer.pop_back();
2579 return job;
2580 }
2581 std::vector<double> works;
2582 for (const Job& j : s.buffer) works.push_back(j.remaining);
2583 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2584 if (s.server_busy[sl])
2585 works.push_back(std::max(0.0, s.server[sl].remaining -
2586 (now - s.server_start[sl])));
2587 std::size_t best = 0;
2588 double best_vft = std::numeric_limits<double>::infinity();
2589 for (std::size_t j = 0; j < s.buffer.size(); ++j) {
2590 const double v = fsp_virtual_finish(works, s.buffer[j].remaining,
2591 static_cast<double>(s.nservers), now);
2592 if (v < best_vft) {
2593 best_vft = v;
2594 best = j;
2595 }
2596 }
2597 Job job = s.buffer[best];
2598 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(best));
2599 return job;
2600 };
2601
2602 // ---- heterogeneous server pools: slot choice and buffer matching ---------
2603 //
2604 // Two questions the flat server bank never had to ask. WHICH SLOT can take
2605 // this job, and WHICH WAITING JOB can this slot take. Everything else in the
2606 // engine addresses a server by its slot and is untouched.
2607 //
2608 // On a station with no pools both reduce to what was there before -- the
2609 // first free slot, and the head of the waiting room -- so the pool-free path
2610 // keeps its exact draw sequence and a seeded homogeneous run is unchanged.
2611
2612 /** Free, unblocked, unheld, and its pool accepts `cls`. */
2613 auto slot_ok_for = [&](const StationState& s, std::size_t sl, std::size_t cls) -> bool {
2614 if (s.server_busy[sl] || s.server_blocked[sl] || s.server_held[sl]) return false;
2615 if (!s.has_pools) return true;
2616 return s.type_compat[s.server_type[sl]][cls];
2617 };
2618
2619 /** Idle, unblocked, unheld slots: the pool a job seizing n of them draws from. */
2620 auto free_slot_count = [&](std::size_t i) -> std::size_t {
2621 const StationState& s = S[i];
2622 std::size_t n = 0;
2623 for (std::size_t sl = 0; sl < s.server_busy.size(); ++sl)
2624 if (!s.server_busy[sl] && !s.server_blocked[sl] && !s.server_held[sl]) ++n;
2625 return n;
2626 };
2627
2628 /**
2629 * The free slot a job of class `cls` may enter, or `s.nservers` for none.
2630 *
2631 * The POOL is chosen first, by the station's heterogeneous scheduling
2632 * policy, and the slot is then the first free one inside it. That is the
2633 * reference's order and it matters: picking the globally-first free slot
2634 * would make every policy behave as ORDER.
2635 */
2636 auto free_slot_for = [&](std::size_t i, std::size_t cls) -> std::size_t {
2637 StationState& s = S[i];
2638 // A JOB DECLARING SERVER PARALLELISM NEEDS ITS WHOLE SET AT ONCE: a
2639 // station with fewer than n idle slots cannot start it even though one
2640 // of them is free, which is the entire content of the feature.
2641 if (s.has_parallelism && free_slot_count(i) < s.parallelism[cls]) return s.nservers;
2642 if (!s.has_pools) {
2643 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2644 if (slot_ok_for(s, sl, cls)) return sl;
2645 return s.nservers;
2646 }
2647 // The pools that accept the class AND still hold a free slot.
2648 std::vector<std::size_t> cand;
2649 for (std::size_t t = 0; t < s.type_count.size(); ++t) {
2650 if (!s.type_compat[t][cls]) continue;
2651 for (std::size_t j = 0; j < s.type_count[t]; ++j)
2652 if (slot_ok_for(s, s.type_first[t] + j, cls)) {
2653 cand.push_back(t);
2654 break;
2655 }
2656 }
2657 if (cand.empty()) return s.nservers;
2658 std::size_t chosen = cand[0];
2659 if (cand.size() > 1) {
2660 switch (s.hetero_policy) {
2663 // Round robin over the pools: the one just used goes to the
2664 // back, so a steady stream spreads over the compatible
2665 // pools instead of filling the first one.
2666 for (std::size_t k = 0; k < s.type_order.size(); ++k) {
2667 const std::size_t t = s.type_order[k];
2668 if (std::find(cand.begin(), cand.end(), t) == cand.end()) continue;
2669 chosen = t;
2670 s.type_order.erase(s.type_order.begin() +
2671 static_cast<std::ptrdiff_t>(k));
2672 s.type_order.push_back(t);
2673 break;
2674 }
2675 break;
2676 }
2678 // Least flexible first: spend the pool that fewest classes
2679 // can use before the one that many can.
2680 for (std::size_t k = 0; k < s.alfs_order.size(); ++k)
2681 if (std::find(cand.begin(), cand.end(), s.alfs_order[k]) != cand.end()) {
2682 chosen = s.alfs_order[k];
2683 break;
2684 }
2685 break;
2686 }
2688 // Fastest server first, by this class's rate at the pool.
2689 double best = -1.0;
2690 for (std::size_t k = 0; k < cand.size(); ++k) {
2691 const double rt = s.type_rate[cand[k]][cls];
2692 if (rt > best) {
2693 best = rt;
2694 chosen = cand[k];
2695 }
2696 }
2697 break;
2698 }
2700 // A uniform draw over the candidate pools, off the ROUTING
2701 // stream as the reference draws it.
2702 std::size_t at = static_cast<std::size_t>(
2703 uniform01(g_routing) * static_cast<double>(cand.size()));
2704 if (at >= cand.size()) at = cand.size() - 1;
2705 chosen = cand[at];
2706 break;
2707 }
2709 default: {
2710 // Declaration order, which `cand` is already built in.
2711 chosen = cand[0];
2712 break;
2713 }
2714 }
2715 }
2716 for (std::size_t j = 0; j < s.type_count[chosen]; ++j) {
2717 const std::size_t sl = s.type_first[chosen] + j;
2718 if (slot_ok_for(s, sl, cls)) return sl;
2719 }
2720 return s.nservers;
2721 };
2722
2723 /** Is any waiting job servable by slot `sl`? */
2724 auto buffer_has_for_slot = [&](std::size_t i, std::size_t sl) -> bool {
2725 StationState& s = S[i];
2726 if (s.buffer.empty()) return false;
2727 if (!s.has_pools) return true;
2728 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
2729 for (std::size_t j = 0; j < s.buffer.size(); ++j)
2730 if (ok[s.buffer[j].cls]) return true;
2731 return false;
2732 };
2733
2734 /**
2735 * Pop the job slot `sl` should serve next.
2736 *
2737 * Off a pooled station this IS `buffer_pop`. On one it is the job the
2738 * station's own order would have taken FIRST AMONG THOSE THE SLOT CAN
2739 * SERVE, which is what a pool means: a server does not skip the queue, it
2740 * skips the jobs it is not compatible with. The heap is drained and rebuilt
2741 * because the discipline's order lives in the comparator, not in the
2742 * sequence, so scanning the vector would answer in heap order rather than
2743 * in service order.
2744 */
2745 auto buffer_pop_for_slot = [&](std::size_t i, std::size_t sl) -> Job {
2746 StationState& s = S[i];
2747 if (!s.has_pools) return buffer_pop(i);
2748 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
2749 std::vector<Job> skipped;
2750 Job job;
2751 bool got = false;
2752 while (!s.buffer.empty()) {
2753 Job cand = buffer_pop(i);
2754 if (ok[cand.cls]) {
2755 job = cand;
2756 got = true;
2757 break;
2758 }
2759 skipped.push_back(cand);
2760 }
2761 for (std::size_t j = 0; j < skipped.size(); ++j) buffer_push(i, skipped[j]);
2762 if (!got)
2763 throw InputError("SolverLDES (native engine): buffer_pop_for_slot was asked for a "
2764 "job no pool of this slot can serve; guard with buffer_has_for_slot");
2765 return job;
2766 };
2767
2768
2769 /**
2770 * The RATE multiplier a station applies to a class at its current
2771 * population: load dependence times class dependence.
2772 *
2773 * A sampled requirement is DIVIDED by this, so a multiplier above one is a
2774 * faster station. `lldscaling` is indexed by the total population minus
2775 * one, saturating at the table's end, which is the reference's convention
2776 * and not a guard: the table is declared up to the station's capacity and a
2777 * closed model can hold exactly that many.
2778 *
2779 * A SHARING station does NOT take the load-dependent factor here. Its
2780 * `lldscaling` is read instead as the effective SERVER COUNT in
2781 * `ps_shares`, because c servers and a c-fold rate are the same thing only
2782 * when the capacity is shared -- applying both would scale it twice.
2783 */
2784 // The load-dependent speed alone, which is both a factor of the service
2785 // rate below and the scale the busy integral is measured in. Sharing one
2786 // definition is what keeps `U = T*E[S]/peak` exact on the sample path.
2787 auto lld_factor = [&](std::size_t i) -> double {
2788 StationState& s = S[i];
2789 if (s.lld.empty() || s.ps) return 1.0;
2790 double total = 0.0;
2791 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
2792 if (!(total > 0.0)) return 1.0;
2793 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
2794 return s.lld[idx] > 0.0 ? s.lld[idx] : 1.0;
2795 };
2796 // The class-dependent speed alone. It is a factor of the RATE, so every
2797 // path that turns a population into a departure time has to apply it -- the
2798 // sharing disciplines included, which is what `ps_advance`/`ps_reschedule`
2799 // below missed: they built their shares from `ps_shares` and the breakdown
2800 // clock and never read the handle, so a PS station carrying a `cdscaling`
2801 // ran its whole sample path at the UNSCALED rates. On a closed
2802 // Delay+Queue(PS), N=3, beta(n)=min(n,2), that was Tput 0.935 against the
2803 // exact 1.412, and utilization inherited the error through T*E[S]/peak.
2804 auto cd_factor = [&](std::size_t i, std::size_t r) -> double {
2805 StationState& s = S[i];
2806 if (!s.has_cd) return 1.0;
2807 std::vector<double> nvec(K, 0.0);
2808 for (std::size_t k = 0; k < K; ++k) nvec[k] = acc.qlen[i][k];
2809 const std::vector<double> beta = s.cd(nvec);
2810 const double b = (beta.size() > 1) ? beta[r] : (beta.empty() ? 1.0 : beta[0]);
2811 return (b > 1e-10) ? b : 1.0;
2812 };
2813 /**
2814 * Read phi at the LIVE population and install it in the cache.
2815 *
2816 * It is called from the RESCHEDULE fan-out and from nowhere else, because
2817 * that is the instant the new speed takes effect: an advance has to credit
2818 * the interval that just ended at the speed which held THROUGH it, and the
2819 * cache still holds that one. Same flush-then-install order as `ps_cd` and
2820 * `Accum::set_busy_scale`.
2821 */
2822 auto gd_refresh = [&]() {
2823 std::vector<T> nvec(M * K);
2824 for (std::size_t i = 0; i < M; ++i)
2825 for (std::size_t r = 0; r < K; ++r)
2826 nvec[i * K + r] = num_traits<T>::from_double(acc.qlen[i][r]);
2827 const std::vector<T> v = sn.gdscaling(nvec);
2828 if (v.empty())
2829 throw InputError("SolverLDES (native engine): the global dependence handle returned "
2830 "no scaling");
2831 for (std::size_t i = 0; i < M; ++i)
2832 for (std::size_t r = 0; r < K; ++r) {
2833 // Scalar, one per station, or one per (station, class): the same
2834 // broadcast `set_global_dependence` validates at declaration.
2835 const double f = num_traits<T>::to_double(
2836 (v.size() == 1) ? v[0] : ((v.size() == M) ? v[i] : v[i * K + r]));
2837 if (!std::isfinite(f) || f < 0.0)
2838 throw InputError("SolverLDES (native engine): the global dependence handle "
2839 "returned a scaling that is not finite and nonnegative at "
2840 "station '" + sn.stations[i].name + "'");
2841 if (!(f > 0.0) && acc.qlen[i][r] > 0.0)
2842 throw InputError(
2843 "SolverLDES (native engine): the global dependence handle returned a "
2844 "zero scaling at station '" + sn.stations[i].name +
2845 "', which holds jobs of class '" + sn.classes[r].name +
2846 "'. A station that stops while it holds work has no completion left to "
2847 "schedule; use Queue.setBreakdown for a server that genuinely stops");
2848 gd_cache[i][r] = f;
2849 }
2850 };
2851
2852 auto rate_scaling = [&](std::size_t i, std::size_t r) -> double {
2853 StationState& s = S[i];
2854 double factor = lld_factor(i) * cd_factor(i, r);
2855 // A GLOBAL DEPENDENCE IS A FACTOR OF THE SAME RATE, read out of the
2856 // cache rather than the handle for the reason `gd_refresh` gives.
2857 if (has_gd) factor *= gd_cache[i][r];
2858 // A DOWN SERVER IS A ZERO FACTOR, not a skipped station: the job in
2859 // service keeps its residual and simply stops accruing, which is what
2860 // makes the outage an interruption rather than an eviction. A positive
2861 // `down_scale` is the degraded server of `downServiceRates`.
2862 if (s.has_breakdown && !s.up)
2863 factor *= (r < s.down_scale.size() ? s.down_scale[r] : 0.0);
2864 return factor;
2865 };
2866
2867 /**
2868 * The effective server count of a SHARING station, which is where its
2869 * load-dependence table is read. Transcribes `getEffectivePSServerCount`:
2870 * with no jobs present the count is 1, not the table's first entry, so the
2871 * very first arrival gets a whole server.
2872 */
2873 auto ps_servers = [&](std::size_t i) -> double {
2874 StationState& s = S[i];
2875 if (s.lld.empty()) return static_cast<double>(s.nservers);
2876 double total = 0.0;
2877 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
2878 if (!(total > 0.0)) return 1.0;
2879 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
2880 return s.lld[idx];
2881 };
2882
2883 // ---- the sharing disciplines -------------------------------------------
2884 // Every population change at a PS station invalidates every departure it
2885 // has scheduled, so the pair below is always called together: integrate the
2886 // work delivered since the last change, then reschedule everything. A
2887 // stale event is not cancelled (the event list has no handle); it is
2888 // ignored on arrival by comparing its tag against the job's current one.
2889 auto ps_advance_one = [&](std::size_t i) {
2890 StationState& s = S[i];
2891 const double dt = now - s.ps_last_update;
2892 s.ps_last_update = now;
2893 // THE BUSY TIME OF A SHARING STATION IS THE INTEGRAL OF THE SHARES, not
2894 // of the head count. Every job at a PS station is "in service", so
2895 // counting heads makes the busy time n*dt and reports a utilization of
2896 // n/c -- above one as soon as the station queues at all. The reference
2897 // accumulates `rate * elapsed` per job for exactly this reason, which
2898 // also makes U = T*E[S] hold automatically, since the shares sum to the
2899 // work actually delivered.
2900 for (std::size_t r = 0; r < K; ++r) acc.last_busy[i][r] = now;
2901 if (!(dt > 0.0) || s.ps_jobs.empty()) return;
2902 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
2903 if (s.has_cd)
2904 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2905 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
2906 if (has_gd)
2907 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2908 rates[j] *= gd_cache[i][s.ps_jobs[j].cls];
2909 if (s.has_breakdown && !s.up)
2910 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2911 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
2912 : 0.0);
2913 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
2914 if (rates[j] > 0.0) {
2915 acc.tot_busy[i][s.ps_jobs[j].cls] += rates[j] * dt;
2916 s.ps_jobs[j].remaining = std::max(0.0, s.ps_jobs[j].remaining - rates[j] * dt);
2917 }
2918 };
2919 auto ps_reschedule_one = [&](std::size_t i) {
2920 StationState& s = S[i];
2921 // The new speed takes effect HERE, on the population the caller has just
2922 // written, and the departures re-timed below are what it applies to.
2923 if (s.has_cd)
2924 for (std::size_t r = 0; r < K; ++r) s.ps_cd[r] = cd_factor(i, r);
2925 if (s.ps_jobs.empty()) return;
2926 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
2927 if (s.has_cd)
2928 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2929 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
2930 if (has_gd)
2931 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2932 rates[j] *= gd_cache[i][s.ps_jobs[j].cls];
2933 if (s.has_breakdown && !s.up)
2934 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2935 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
2936 : 0.0);
2937 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j) {
2938 PsJob& pj = s.ps_jobs[j];
2939 // Retag the STARVED jobs too: the tag is what invalidates a departure already in the list.
2940 pj.tag = ++ps_tag;
2941 const double rate = rates[j];
2942 if (!(rate > 0.0)) continue;
2943 Event e;
2944 e.t = now + ((pj.remaining <= 1e-12) ? 1e-12 : pj.remaining / rate);
2945 e.kind = EV_DEPARTURE;
2946 e.station = i;
2947 e.cls = pj.cls;
2948 e.tag = pj.tag;
2949 push(e);
2950 }
2951 };
2952
2953
2954 /**
2955 * Integrate the work delivered at a state-dependent station since its last
2956 * change, then re-time every completion it holds.
2957 *
2958 * `sd_advance` must run BEFORE the population is written and `sd_reschedule`
2959 * after, because the rate in force over the elapsed interval is the one the
2960 * OLD population implied. The stale departures are neutralised by the tag,
2961 * exactly as a preemption's are.
2962 */
2963 auto sd_advance_one = [&](std::size_t i) {
2964 StationState& s = S[i];
2965 if (!s.state_dependent || s.ps) return;
2966 const double dt = now - s.sd_last_update;
2967 s.sd_last_update = now;
2968 if (!(dt > 0.0)) return;
2969 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2970 if (s.server_busy[sl]) {
2971 const double scale = rate_scaling(i, s.server[sl].cls);
2972 s.server[sl].remaining = std::max(0.0, s.server[sl].remaining - dt * scale);
2973 s.server[sl].elapsed += dt * scale;
2974 s.server_start[sl] = now;
2975 }
2976 };
2977 auto sd_reschedule_one = [&](std::size_t i) {
2978 StationState& s = S[i];
2979 if (!s.state_dependent || s.ps) return;
2980 // The population has just been written, so this is where the new speed
2981 // takes effect -- for the busy integral exactly as for the departures
2982 // re-timed below. `set_busy_scale` credits the interval that just ended
2983 // at the OLD speed before installing the new one.
2984 acc.set_busy_scale(i, lld_factor(i), now);
2985 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2986 if (s.server_busy[sl]) {
2987 const double scale = rate_scaling(i, s.server[sl].cls);
2988 // RETAG BEFORE THE ZERO TEST, as ps_reschedule does. The tag is
2989 // the only thing that invalidates a departure already in the
2990 // list, so a slot whose rate has fallen to zero must be retagged
2991 // too -- otherwise the departure scheduled at the OLD rate stays
2992 // valid and the job leaves in the middle of the outage that was
2993 // supposed to stop it. Measured on M/M/1/8 with a failing
2994 // server: mean queue 1.486 against the exact chain's 1.922, with
2995 // throughput 1.6% HIGH, because the stalled work was delivered
2996 // anyway.
2997 s.server_tag[sl] = ++ps_tag;
2998 if (!(scale > 0.0)) continue;
2999 Event e;
3000 e.t = now + s.server[sl].remaining / scale;
3001 e.kind = EV_DEPARTURE;
3002 e.station = i;
3003 e.cls = s.server[sl].cls;
3004 e.slot = sl;
3005 e.tag = s.server_tag[sl];
3006 push(e);
3007 }
3008 };
3009
3010
3011 /**
3012 * A GLOBAL DEPENDENCE REACHES THE INFINITE SERVERS TOO.
3013 *
3014 * phi multiplies every station's rate, an INF station's included, so its
3015 * think times are integrated forward and re-timed along with everything
3016 * else. They are reached through the job registry because a Delay has no
3017 * slot to hang a residual or a generation off, which is why
3018 * `track_delay_jobs` keeps the registry for a global dependence as well.
3019 */
3020 auto gd_delay_advance = [&](std::size_t i) {
3021 StationState& s = S[i];
3022 const double dt = now - s.sd_last_update;
3023 s.sd_last_update = now;
3024 if (!(dt > 0.0)) return;
3025 for (std::size_t j = 0; j < delay_live[i].size(); ++j) {
3026 Job& d = delay_live[i][j];
3027 const double scale = gd_cache[i][d.cls];
3028 d.remaining = std::max(0.0, d.remaining - dt * scale);
3029 d.elapsed += dt * scale;
3030 }
3031 };
3032 auto gd_delay_reschedule = [&](std::size_t i) {
3033 for (std::size_t j = 0; j < delay_live[i].size(); ++j) {
3034 Job& d = delay_live[i][j];
3035 // Retag before the zero test, as `sd_reschedule_one` does: the tag
3036 // is the only thing that invalidates the departure already listed.
3037 d.tag = ++ps_tag;
3038 const double scale = gd_cache[i][d.cls];
3039 if (!(scale > 0.0)) continue;
3040 Event e;
3041 e.t = now + ((d.remaining <= 1e-12) ? 1e-12 : d.remaining / scale);
3042 e.kind = EV_DEPARTURE;
3043 e.station = i;
3044 e.cls = d.cls;
3045 e.slot = std::numeric_limits<std::size_t>::max();
3046 e.tag = d.tag;
3047 e.job = d;
3048 push(e);
3049 }
3050 };
3051
3052 /**
3053 * The four calls above, fanned out over the whole network when a global
3054 * dependence couples the stations.
3055 *
3056 * A job moving anywhere changes every station's rate at once, so an advance
3057 * has to book the work EVERY station delivered over the interval that just
3058 * ended, and a reschedule has to re-time every completion in flight against
3059 * the refreshed phi. Repeating either is free: an advance over a zero
3060 * interval books nothing, and a reschedule rescales residual work rather
3061 * than resampling, so it consumes no variate and lands on the instant one
3062 * call would have produced. Without a global dependence each call touches
3063 * exactly the station it names, as before.
3064 */
3065 auto gd_advance_all = [&]() {
3066 for (std::size_t a = 0; a < M; ++a) {
3067 if (S[a].role == Role::Delay)
3068 gd_delay_advance(a);
3069 else if (S[a].role != Role::Queue)
3070 continue;
3071 else if (S[a].ps)
3072 ps_advance_one(a);
3073 else
3074 sd_advance_one(a);
3075 }
3076 };
3077 auto gd_reschedule_all = [&]() {
3078 gd_refresh();
3079 for (std::size_t a = 0; a < M; ++a) {
3080 if (S[a].role == Role::Delay)
3081 gd_delay_reschedule(a);
3082 else if (S[a].role != Role::Queue)
3083 continue;
3084 else if (S[a].ps)
3085 ps_reschedule_one(a);
3086 else
3087 sd_reschedule_one(a);
3088 }
3089 };
3090 auto ps_advance = [&](std::size_t i) {
3091 if (has_gd) gd_advance_all(); else ps_advance_one(i);
3092 };
3093 auto ps_reschedule = [&](std::size_t i) {
3094 if (has_gd) gd_reschedule_all(); else ps_reschedule_one(i);
3095 };
3096 auto sd_advance = [&](std::size_t i) {
3097 if (has_gd) gd_advance_all(); else sd_advance_one(i);
3098 };
3099 auto sd_reschedule = [&](std::size_t i) {
3100 if (has_gd) gd_reschedule_all(); else sd_reschedule_one(i);
3101 };
3102
3103 /**
3104 * Whether station `j` can admit one more job of class `r` RIGHT NOW,
3105 * counting the jobs already blocked in front of it.
3106 *
3107 * THE CAPACITY TEST EXCLUDES THE BLOCKED JOBS, and reporting includes
3108 * them. `acc.qlen` carries both, because the reference's
3109 * effectiveQueueLength adds basBlockedAtDest/bbsBlockedAtDest for the
3110 * statistics, while `destinationHasCapacity` reads
3111 * getTotalCustomersAtStation -- the jobs actually AT the station. Testing
3112 * against the inclusive count DEADLOCKS a station of capacity one: the job
3113 * blocked in front of it occupies the very slot it is waiting for, so the
3114 * station can never make room and the upstream server is never released.
3115 */
3116 auto dest_has_room = [&](std::size_t j, std::size_t r) -> bool {
3117 StationState& d = S[j];
3118 if (d.role != Role::Queue) return true;
3119 double total = 0.0;
3120 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[j][k] - d.blocked_at[k];
3121 return (total + 1.0 <= d.cap) &&
3122 (acc.qlen[j][r] - d.blocked_at[r] + 1.0 <= d.classcap[r]);
3123 };
3124
3125 /** The blocking policy the DESTINATION declares for an arriving class. */
3126 auto dest_policy = [&](std::size_t j, std::size_t r) -> lang::DropStrategy {
3127 return (S[j].role == Role::Queue && r < S[j].droprule.size()) ? S[j].droprule[r]
3129 };
3130
3131 // ---- finite capacity regions --------------------------------------------
3132 // At most ONE region per station, which is the reference's own restriction:
3133 // `fcRegionIndices` is a single index per station, and overlapping regions
3134 // would need a join over their constraints that no codebase implements.
3135 std::vector<Region> regions;
3136 std::vector<int> region_of(M, -1);
3137 for (const auto& rg : sn.regions) {
3138 Region R;
3139 R.name = rg.name;
3140 R.members.assign(M, false);
3141 R.class_cap.assign(K, -1.0);
3142 R.class_size.assign(K, 1.0);
3143 R.class_weight.assign(K, 1.0);
3144 R.rule.assign(K, lang::DropStrategy::DROP);
3145 R.jobs.assign(K, 0.0);
3146 R.blocked.assign(K, 0.0);
3147 R.dropped.assign(K, 0.0);
3148 R.tot_jobs.assign(K, 0.0);
3149 R.tot_weight.assign(K, 0.0);
3150 R.tot_mem.assign(K, 0.0);
3151 R.completed.assign(K, 0.0);
3152 R.resp_sum.assign(K, 0.0);
3153 R.resp_cnt.assign(K, 0.0);
3154 for (std::size_t i = 0; i < M && i < rg.members.size(); ++i)
3155 if (rg.members[i]) {
3156 R.members[i] = true;
3157 if (region_of[i] >= 0)
3158 throw UnsupportedError(ErrorKind{"Region"},
3159 "SolverLDES (native engine): station '" + sn.stations[i].name +
3160 "' belongs to more than one finite capacity region");
3161 region_of[i] = static_cast<int>(regions.size());
3162 }
3163 // `cap(i,r)` is the class-r bound at member i and `cap(i,K)` the global
3164 // one; -1 is the reference's unbounded sentinel and is kept, because it
3165 // is compared with != -1 to test MEMBERSHIP as well as boundedness.
3166 for (std::size_t i = 0; i < rg.cap.size(); ++i) {
3167 if (i >= M || !R.members[i]) continue;
3168 for (std::size_t r = 0; r < K && r < rg.cap[i].size(); ++r)
3169 if (rg.cap[i][r] >= 0.0)
3170 R.class_cap[r] = (R.class_cap[r] < 0.0) ? rg.cap[i][r]
3171 : std::min(R.class_cap[r], rg.cap[i][r]);
3172 if (rg.cap[i].size() > K && rg.cap[i][K] >= 0.0)
3173 R.global_cap = (R.global_cap < 0.0) ? rg.cap[i][K]
3174 : std::min(R.global_cap, rg.cap[i][K]);
3175 }
3176 for (double mm : rg.maxmem)
3177 if (mm >= 0.0) R.max_mem = (R.max_mem < 0.0) ? mm : std::min(R.max_mem, mm);
3178 for (std::size_t r = 0; r < K && r < rg.rule.size(); ++r) R.rule[r] = rg.rule[r];
3179 for (std::size_t r = 0; r < K && r < rg.size.size(); ++r)
3180 R.class_size[r] = num_traits<T>::to_double(rg.size[r]);
3181 for (std::size_t r = 0; r < K && r < rg.weight.size(); ++r)
3182 R.class_weight[r] = num_traits<T>::to_double(rg.weight[r]);
3183 for (std::size_t c = 0; c < rg.lincon_A.rows(); ++c) {
3184 std::vector<double> row;
3185 for (std::size_t r = 0; r < rg.lincon_A.cols(); ++r)
3186 row.push_back(num_traits<T>::to_double(rg.lincon_A(c, r)));
3187 R.lincon_A.push_back(row);
3188 R.lincon_b.push_back(c < rg.lincon_b.size() ? num_traits<T>::to_double(rg.lincon_b[c])
3189 : 0.0);
3190 }
3191 regions.push_back(R);
3192 }
3193
3194 /**
3195 * Jobs parked by a WAITQ region: they have NOT entered the region, so they
3196 * are outside every station's buffer, but they are still in the system and
3197 * are charged to the station they were trying to enter.
3198 */
3199 struct RegionWaiter {
3200 std::size_t station = 0;
3201 Job job;
3202 };
3203 std::vector<std::vector<RegionWaiter>> region_wait(regions.size());
3204
3205 /**
3206 * Whether the service requirement of (station, class) is drawn when the job
3207 * ARRIVES rather than when it reaches a server.
3208 *
3209 * Every time-invariant law is: the two instants give the same distribution,
3210 * and drawing once on arrival is what the reference engine does. A
3211 * TIME-INHOMOGENEOUS law is not, because its rate is a function of absolute
3212 * time and a job that queues is served under a later part of the schedule
3213 * than the one in force when it joined -- so it waits for `start_service`.
3214 * Three station kinds cannot wait: a Delay, where the two instants coincide
3215 * anyway; a processor-sharing station, which never calls `start_service` and
3216 * holds the requirement itself; and one whose waiting room is RANKED by the
3217 * sampled size, which must have a size to rank by. All three draw on arrival.
3218 */
3219 auto draw_at_arrival = [&](std::size_t i, std::size_t r) -> bool {
3220 const StationState& s = S[i];
3221 // A BULK SERVER has no per-job service time at all, only a station-level
3222 // firing clock, so nothing may be drawn from its process on arrival: one
3223 // draw per arrival would advance the batch sampler once per JOB instead of
3224 // once per FIRING and consume the stream the clock reads from.
3225 if (s.bmsp) return false;
3226 if (s.role != Role::Queue || s.ps || s.size_ordered) return true;
3227 return !s.svc[r].time_varying();
3228 };
3229
3230 /**
3231 * Arm a BULK SERVER's firing clock and record how many jobs the firing will
3232 * complete.
3233 *
3234 * The interval comes from the station's own process, drawn at `now` because a
3235 * BMMAPt clock is a wall-clock SCHEDULE and a BMAP one simply ignores the
3236 * instant. The size is whatever block fired, read off the sampler beside the
3237 * interval and at no extra draw; a MARK the walk may also have returned is
3238 * DISCARDED, because a completion drives no class switch.
3239 *
3240 * A non-cyclic schedule past its horizon yields no interval: the clock is left
3241 * UNARMED rather than re-armed at zero delay, which would spin at one instant.
3242 * The jobs present stay present, which is what "the process has stopped" means
3243 * for a bulk server.
3244 */
3245 auto bmsp_arm = [&](std::size_t i) {
3246 StationState& s = S[i];
3247 const std::size_t r = s.bmsp_cls;
3248 const double gap = slot_snap(s.svc[r].next_at(g_svc[i][r], now), "firing interval");
3249 const int b = s.svc[r].last_batch();
3250 s.bmsp_pending = (b > 0) ? static_cast<std::size_t>(b) : 1;
3251 if (!(gap > 0.0) && s.svc[r].time_varying()) return;
3252 Event e;
3253 e.t = now + gap;
3254 e.kind = EV_DEPARTURE;
3255 e.station = i;
3256 e.cls = r;
3257 e.slot = 0;
3258 e.tag = ++ps_tag;
3259 s.bmsp_tag = e.tag;
3260 push(e);
3261 };
3262
3263 auto start_service = [&](std::size_t i, std::size_t slot, const Job& job_in) {
3264 StationState& s = S[i];
3265 Job job = job_in;
3266 // A TIME-INHOMOGENEOUS law is drawn HERE, at the instant service begins,
3267 // because its rate is a function of absolute time: a duration drawn when
3268 // the job ARRIVED would be governed by the schedule in force while the
3269 // job was still waiting, which is not the schedule that serves it. The
3270 // arrival path leaves the draw to this one (see `draw_at_arrival`), so
3271 // there is exactly one draw per service and the modulating phase a MAPt
3272 // carries is never advanced by a discarded sample.
3273 bool drew = false;
3274 if (!draw_at_arrival(i, job.cls)) {
3275 job.service = slot_snap(s.svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
3276 job.remaining = job.service;
3277 job.elapsed = 0.0;
3278 drew = true;
3279 }
3280 // A RESUMED job owes its residual; a RESTARTED one draws afresh and
3281 // discards what it had done, which is the whole of the PR/PI split.
3282 if (!drew && s.preemptive && !s.resume && job.elapsed > 0.0) {
3283 job.service = slot_snap(s.svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
3284 job.remaining = job.service;
3285 job.elapsed = 0.0;
3286 }
3287 // A POOLED STATION DRAWS THE SERVICE FROM THE POOL, and it can only be
3288 // drawn here: the law depends on which server takes the job, and that
3289 // is not known until the slot is assigned. The draw made on arrival is
3290 // the station's class-level default and stands as the NOMINAL size the
3291 // size-based disciplines order the waiting room by; the duration
3292 // actually served is this one. A pool that declares no law of its own
3293 // for the class keeps that nominal draw, so it costs no extra variate.
3294 if (s.has_pools && !(s.preemptive && s.resume && job.elapsed > 0.0)) {
3295 const std::size_t ty = s.server_type[slot];
3296 if (s.type_has_svc[ty][job.cls]) {
3297 job.service = slot_snap(
3298 s.type_svc[ty][job.cls].next_at(g_hsvc[i][ty][job.cls], now),
3299 "service time");
3300 job.remaining = job.service;
3301 job.elapsed = 0.0;
3302 }
3303 }
3304 s.server[slot] = job;
3305 s.server_busy[slot] = true;
3306 s.server_start[slot] = now;
3307 s.server_tag[slot] = ++ps_tag;
3308 // A JOB DECLARING SERVER PARALLELISM SEIZES ITS WHOLE SET HERE, and the
3309 // extras are recorded against the primary so that releasing that one
3310 // releases them together. `free_slot_for` has already refused a station
3311 // with fewer than n idle slots, so coming up short is an inconsistency
3312 // in this engine rather than a busy station, and is reported as one.
3313 double occupied = 1.0;
3314 if (s.has_parallelism && s.parallelism[job.cls] > 1) {
3315 const std::size_t need = s.parallelism[job.cls];
3316 std::vector<std::size_t> extras;
3317 for (std::size_t sl = 0; sl < s.nservers && extras.size() + 1 < need; ++sl) {
3318 if (sl == slot || s.server_busy[sl] || s.server_blocked[sl] || s.server_held[sl])
3319 continue;
3320 s.server_busy[sl] = true;
3321 extras.push_back(sl);
3322 }
3323 if (extras.size() + 1 < need)
3324 throw InputError("SolverLDES (native engine): station '" + sn.stations[i].name +
3325 "' started a job holding " + std::to_string(need) +
3326 " servers with only " + std::to_string(extras.size() + 1) +
3327 " free");
3328 s.held_extra[slot] = extras;
3329 occupied = static_cast<double>(need);
3330 }
3331 acc.update_busy(i, job.cls, now);
3332 // SERVERS OCCUPIED, not jobs in service: a job holding n slots for the
3333 // whole of its service contributes n/c to the utilization, not 1/c.
3334 acc.busy[i][job.cls] += occupied;
3335 // A ZERO RATE SCHEDULES NOTHING, it does not schedule a departure at
3336 // t = infinity. A job that enters service while the server is down
3337 // holds the slot with its residual intact and gets its departure back
3338 // from the repair's reschedule; pushing `now + remaining/0` instead
3339 // puts an infinite timestamp in the event list, which the run then
3340 // pops and processes with now = inf.
3341 const double scale0 = rate_scaling(i, job.cls);
3342 if (!(scale0 > 0.0)) return;
3343 Event e;
3344 e.t = now + job.remaining / scale0;
3345 e.kind = EV_DEPARTURE;
3346 e.station = i;
3347 e.cls = job.cls;
3348 e.slot = slot;
3349 e.tag = s.server_tag[slot];
3350 push(e);
3351 };
3352
3353 /**
3354 * Free the slots a job held BESIDE its primary one, and answer how many
3355 * servers it occupied in all.
3356 *
3357 * One at a station that declares no parallelism, which is every caller's
3358 * previous behaviour; calling it twice on the same slot is harmless, which
3359 * matters because a blocked slot is released once at the completion that
3360 * blocked it and again when the handoff finally goes through.
3361 */
3362 auto release_slots = [&](std::size_t i, std::size_t slot) -> double {
3363 StationState& s = S[i];
3364 if (!s.has_parallelism || slot >= s.held_extra.size()) return 1.0;
3365 std::vector<std::size_t>& extras = s.held_extra[slot];
3366 if (extras.empty()) return 1.0;
3367 for (std::size_t j = 0; j < extras.size(); ++j) s.server_busy[extras[j]] = false;
3368 const double n = static_cast<double>(extras.size() + 1);
3369 extras.clear();
3370 return n;
3371 };
3372
3373 /**
3374 * Start the next waiting job on the slot just freed, or leave the slot idle.
3375 *
3376 * SERVER PARALLELISM IS HEAD-OF-LINE: the job the station's own order takes
3377 * next needs n slots free at once, and when the station cannot offer them it
3378 * WAITS rather than serving whoever behind it would fit. Skipping ahead to a
3379 * job that fits is a different discipline, not the same one under load.
3380 */
3381 auto serve_next_on_slot = [&](std::size_t i, std::size_t sl) {
3382 if (!buffer_has_for_slot(i, sl)) return;
3383 Job nextjob = buffer_pop_for_slot(i, sl);
3384 if (S[i].has_parallelism && free_slot_count(i) < S[i].parallelism[nextjob.cls]) {
3385 buffer_push(i, nextjob);
3386 return;
3387 }
3388 start_service(i, sl, nextjob);
3389 };
3390
3391 /**
3392 * Interrupt the job in `slot` and return it to the waiting room.
3393 *
3394 * Its residual work and attained service are carried on the job, so a
3395 * resume needs no side table; the tag is left stale, which is how the
3396 * departure event already in the list is neutralised.
3397 */
3398 auto preempt = [&](std::size_t i, std::size_t slot) {
3399 StationState& s = S[i];
3400 Job job = s.server[slot];
3401 const double served = now - s.server_start[slot];
3402 job.remaining = std::max(0.0, job.remaining - served);
3403 job.elapsed += served;
3404 s.server_busy[slot] = false;
3405 s.server_tag[slot] = 0;
3406 const double freed = release_slots(i, slot);
3407 acc.update_busy(i, job.cls, now);
3408 acc.busy[i][job.cls] -= freed;
3409 // The job stays in the system: its queue length is unchanged, and it
3410 // re-enters the waiting room under the station's own order.
3411 buffer_push(i, job);
3412 };
3413
3414 /**
3415 * Advance a polling server one leg, or park it.
3416 *
3417 * `poll_serve` starts the next job at the buffer the server stands at;
3418 * `poll_advance` walks it to the next buffer, paying that leg's switchover.
3419 * The walk stops after a full lap of zero-time legs that found no work, or
3420 * the server would spin forever at the same simulated instant.
3421 */
3422 std::function<void(std::size_t)> poll_serve;
3423 std::function<void(std::size_t)> poll_advance;
3424
3425 /**
3426 * Open a visit at the buffer the server has just polled.
3427 *
3428 * The budget is fixed AT THE POLLING INSTANT: EXHAUSTIVE serves until the
3429 * buffer empties, K-LIMITED at most K, GATED exactly the jobs found there
3430 * (later arrivals wait for the next visit), and DECREMENTING until the
3431 * buffer is one shorter than it was found. GATED and DECREMENTING are NOT
3432 * one-job visits; serving one job per visit is 1-limited, a different and
3433 * slower discipline.
3434 */
3435 auto poll_open_visit = [&](StationState& s) {
3436 std::size_t found = 0;
3437 for (const Job& j : s.buffer)
3438 if (j.cls == s.poll_at) ++found;
3439 switch (s.poll_type) {
3440 case lang::PollingType::KLIMITED: s.poll_budget = s.poll_k; break;
3441 case lang::PollingType::GATED: s.poll_budget = found; break;
3443 s.poll_budget = std::numeric_limits<std::size_t>::max();
3444 s.poll_gate = found;
3445 break;
3447 default: s.poll_budget = std::numeric_limits<std::size_t>::max(); break;
3448 }
3449 };
3450
3451 poll_serve = [&](std::size_t i) {
3452 StationState& s = S[i];
3453 if (s.server_busy[0] || s.server_blocked[0] || s.server_held[0]) return;
3454 // Only the class the server currently stands at may be served.
3455 std::size_t at = s.buffer.size();
3456 std::size_t waiting = 0;
3457 for (std::size_t j = 0; j < s.buffer.size(); ++j)
3458 if (s.buffer[j].cls == s.poll_at) {
3459 ++waiting;
3460 if (at == s.buffer.size() || s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
3461 }
3462 // DECREMENTING ends the visit once the buffer has fallen to one below
3463 // the count found at the polling instant.
3464 const bool decremented =
3465 s.poll_type == lang::PollingType::DECREMENTING && waiting < s.poll_gate;
3466 if (at == s.buffer.size() || s.poll_budget == 0 || decremented) {
3467 poll_advance(i);
3468 return;
3469 }
3470 Job job = s.buffer[at];
3471 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
3472 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
3473 if (s.poll_type == lang::PollingType::GATED || s.poll_type == lang::PollingType::KLIMITED)
3474 --s.poll_budget;
3475 start_service(i, 0, job);
3476 };
3477
3478 poll_advance = [&](std::size_t i) {
3479 StationState& s = S[i];
3480 for (std::size_t step = 0; step < K; ++step) {
3481 const std::size_t from = s.poll_at;
3482 const std::size_t next = (from + 1) % K;
3483 // Takagi's r_i: the leg OUT of `from` is charged the switchover
3484 // stored against `from`, not against the buffer being entered.
3485 if (s.has_switchover[from]) {
3486 s.poll_switching = true;
3487 Event e;
3488 e.t = now + s.switchover[from].next(g_aux[i]);
3489 e.kind = EV_SWITCHOVER;
3490 e.station = i;
3491 e.cls = next;
3492 push(e);
3493 return;
3494 }
3495 s.poll_at = next;
3496 poll_open_visit(s);
3497 bool has_work = false;
3498 for (const Job& j : s.buffer)
3499 if (j.cls == next) {
3500 has_work = true;
3501 break;
3502 }
3503 if (has_work) {
3504 poll_serve(i);
3505 return;
3506 }
3507 }
3508 // A whole lap of zero-time legs found nothing. Parking is the only
3509 // alternative to spinning at this instant, and it is unobservable: the
3510 // next arrival restarts the server.
3511 s.poll_parked = true;
3512 };
3513
3514 // ---- admission ---------------------------------------------------------
3515
3516 /**
3517 * Reschedule the aggregate completion of a pass-and-swap station.
3518 *
3519 * ONE CLOCK FOR THE WHOLE STATION, at the total rate mu(c) of its ordered
3520 * list, and the position that completes is drawn from the per-position
3521 * INCREMENTS. Scheduling one clock per job instead would need each job's
3522 * marginal rate to be constant, which is exactly what an order-independent
3523 * station does not have: every arrival and departure changes every other
3524 * job's rate.
3525 */
3526 std::function<void(std::size_t)> pas_reschedule = [&](std::size_t i) {
3527 StationState& s = S[i];
3528 if (s.pas_list.empty()) return;
3529 std::vector<std::size_t> seq;
3530 for (const Job& j : s.pas_list) seq.push_back(j.cls);
3531 const double total = s.pas_rate(seq);
3532 if (!(total > 0.0)) return;
3533 s.pas_tag = ++ps_tag;
3534 Event e;
3535 e.t = now + (-std::log(uniform01(g_aux[i])) / total);
3536 e.kind = EV_DEPARTURE;
3537 e.station = i;
3538 e.cls = 0;
3539 e.tag = s.pas_tag;
3540 e.slot = std::numeric_limits<std::size_t>::max();
3541 push(e);
3542 };
3543
3544 /**
3545 * The position that completes, drawn from the per-position rate increments,
3546 * and the job that actually departs after the swap chain.
3547 */
3548 auto pas_complete = [&](std::size_t i) -> Job {
3549 StationState& s = S[i];
3550 std::vector<std::size_t> seq;
3551 for (const Job& j : s.pas_list) seq.push_back(j.cls);
3552 std::vector<double> inc(seq.size(), 0.0);
3553 double prev = 0.0, total = 0.0;
3554 for (std::size_t p = 0; p < seq.size(); ++p) {
3555 std::vector<std::size_t> pre(seq.begin(),
3556 seq.begin() + static_cast<std::ptrdiff_t>(p + 1));
3557 const double cur = s.pas_rate(pre);
3558 inc[p] = std::max(0.0, cur - prev);
3559 prev = cur;
3560 total += inc[p];
3561 }
3562 std::size_t pos = 0;
3563 if (total > 0.0) {
3564 const double u = uniform01(g_routing) * total;
3565 double acc2 = 0.0;
3566 for (std::size_t p = 0; p < inc.size(); ++p) {
3567 acc2 += inc[p];
3568 if (u < acc2) {
3569 pos = p;
3570 break;
3571 }
3572 }
3573 }
3574 // The swap chain: from `pos`, walk forward to the last position the swap
3575 // graph can reach. That job departs and the chain shifts back into the
3576 // hole at `pos`. An empty graph makes the chain trivial, which is the
3577 // plain order-independent station.
3578 std::vector<std::size_t> chain(1, pos);
3579 std::size_t moving = seq[pos], cur = pos;
3580 while (true) {
3581 std::size_t nxt = seq.size();
3582 for (std::size_t j = cur + 1; j < seq.size(); ++j) {
3583 const bool swappable =
3584 s.pas_swap.empty() ||
3585 (moving < s.pas_swap.size() && seq[j] < s.pas_swap[moving].size() &&
3586 s.pas_swap[moving][seq[j]]);
3587 if (swappable) {
3588 nxt = j;
3589 break;
3590 }
3591 }
3592 if (nxt >= seq.size()) break;
3593 chain.push_back(nxt);
3594 moving = seq[nxt];
3595 cur = nxt;
3596 }
3597 Job departing = s.pas_list[chain.back()];
3598 std::vector<Job> arr = s.pas_list;
3599 for (std::size_t k = 0; k + 1 < chain.size(); ++k)
3600 arr[chain[k + 1]] = s.pas_list[chain[k]];
3601 std::vector<Job> survivors;
3602 for (std::size_t k = 0; k < arr.size(); ++k)
3603 if (k != chain.front()) survivors.push_back(arr[k]);
3604 s.pas_list = survivors;
3605 return departing;
3606 };
3607 /**
3608 * Admit a job at station `i`, or drop it when the station is full.
3609 *
3610 * `from` is the station the job comes from, or M for an arrival that enters
3611 * from outside the network. IT IS NOT BOOKKEEPING: a hop BETWEEN TWO
3612 * MEMBERS of the same finite capacity region does not cross the region
3613 * boundary, so the job must neither be re-tested against the region's caps
3614 * nor counted into it again. Without that test an intra-region hop adds one
3615 * to the region on arrival and removes nothing on departure, so occupancy
3616 * grows by one per hop until every arrival is refused and the run never
3617 * completes another job.
3618 */
3619 // The main departure site leaves the region BEFORE routing and sets `hop_old_cls` to the departing class. A hop
3620 // into the same region is then re-gated like an arrival; one that also SWITCHES class crosses the border, as in
3621 // JMT and the Java engine (the ClassSwitch node sits outside the region), so it first completes and releases the
3622 // waiters, and a freed per-class or linear slot is never left idle.
3623 std::size_t hop_old_cls = K;
3624 std::function<void(std::size_t)> release_region; ///< defined below, called by admit on a class-switching hop
3625 /// Region wait of the waiter release_region is re-admitting; admit takes it onto the job and clears it.
3626 double pending_region_wait = 0.0;
3627 bool hop_internal = false; ///< set by admit when the hop stayed inside a region, read by the departure site
3628 /// Set by the retrial site for the next admit only: a job re-offering itself from the orbit decided to join
3629 /// already, so it is not asked to balk again (Java `arriveAtQueueFromRetrial` skips the test likewise).
3630 bool pending_retry = false;
3631 /// Set by the retrial site when an orbiting job takes a free server: it is already counted in the station
3632 /// population and keeps its first arrival time, so admit must neither count it again nor re-test its admission.
3633 bool pending_orbit_exit = false;
3634 /// Set by the init_jobs injection for the next admit only: the job is already present, so it keeps the arrival
3635 /// instants it was given and is not asked to balk.
3636 bool pending_preset = false;
3637
3638 // ---- the retrial orbit (see StationState::retrial) ------------------------------------------------------
3639 auto retrial_server_free = [&](std::size_t i, std::size_t cls) -> bool {
3640 StationState& s = S[i];
3641 if (s.ps) return s.ps_jobs.size() < s.nservers;
3642 return free_slot_for(i, cls) < s.nservers;
3643 };
3644 auto orbit_tick = [&](std::size_t i) {
3645 StationState& s = S[i];
3646 const double dt = now - s.orbit_last;
3647 if (dt > 0.0)
3648 for (std::size_t r = 0; r < K; ++r) s.tot_orbit[r] += s.orbit_size[r] * dt;
3649 s.orbit_last = now;
3650 };
3651 // LINEAR: every orbiting job carries its own clock, tagged with its id. CONSTANT: one clock serves the whole
3652 // (station, class) orbit, tagged 0, and is given to the oldest member when it fires.
3653 auto arm_retrial = [&](std::size_t i, std::size_t c, std::uint64_t id) {
3654 StationState& s = S[i];
3655 if (s.retrial_policy[c] == 2) {
3656 if (s.const_pending[c] || s.orbit[c].empty()) return;
3657 s.const_pending[c] = true;
3658 id = 0;
3659 }
3660 Event e;
3661 e.t = now + s.retrial[c].next(g_svc[i][c]);
3662 e.kind = EV_RETRIAL;
3663 e.station = i;
3664 e.cls = c;
3665 e.tag = id;
3666 push(e);
3667 };
3668 auto orbit_join = [&](std::size_t i, Job job) {
3669 StationState& s = S[i];
3670 const std::size_t c = job.cls;
3671 orbit_tick(i);
3672 s.orbit_size[c] += 1.0;
3673 job.t_arr = now;
3674 job.id = ++job_id;
3675 job.attempts = 0;
3676 job.deadline = now + classdeadline[c];
3677 sd_advance(i);
3678 acc.update_qlen(i, c, now);
3679 acc.qlen[i][c] += 1.0;
3680 acc.arrived[i][c] += 1.0;
3681 bp.track(i, c, +1, now);
3682 s.orbit[c].push_back(job);
3683 if (s.has_orbit_abandon[c]) {
3684 Event e;
3685 e.t = now + s.orbit_abandon[c].next(g_svc[i][c]);
3686 e.kind = EV_ORBIT_ABANDON;
3687 e.station = i;
3688 e.cls = c;
3689 e.tag = job.id;
3690 push(e);
3691 }
3692 arm_retrial(i, c, job.id);
3693 sd_reschedule(i);
3694 };
3695 // Takes the orbit job at position `at` out of the orbit; `leaves` also takes it out of the station.
3696 auto orbit_leave = [&](std::size_t i, std::size_t c, std::size_t at, bool leaves) {
3697 StationState& s = S[i];
3698 orbit_tick(i);
3699 s.orbit_size[c] -= 1.0;
3700 s.orbit[c].erase(s.orbit[c].begin() + static_cast<std::ptrdiff_t>(at));
3701 if (leaves) {
3702 sd_advance(i);
3703 acc.update_qlen(i, c, now);
3704 acc.qlen[i][c] -= 1.0;
3705 bp.track(i, c, -1, now);
3706 sd_reschedule(i);
3707 }
3708 };
3709
3710 std::function<bool(std::size_t, Job, std::size_t)> admit =
3711 [&](std::size_t i, Job job, std::size_t from) -> bool {
3712 StationState& s = S[i];
3713 const double rwait = pending_region_wait;
3714 pending_region_wait = 0.0;
3715 const bool is_retry = pending_retry;
3716 pending_retry = false;
3717 const bool from_orbit = pending_orbit_exit;
3718 pending_orbit_exit = false;
3719 const bool preset = pending_preset;
3720 pending_preset = false;
3721 if (s.off[job.cls])
3722 throw InputError("SolverLDES (native engine): a job of class '" +
3723 sn.classes[job.cls].name + "' reached station '" +
3724 sn.stations[i].name + "', which does not serve it");
3725 // THE REGION IS CONSULTED FIRST. Its cap spans stations, so a job may
3726 // be refused entry to a station that has room of its own; testing the
3727 // station first and the region second would admit into the buffer and
3728 // then have to unwind it. The test is on MEMBERSHIP ALONE, exactly as
3729 // on the departure side: a Delay is capped by the region like any other
3730 // member, and gating entry on Role::Queue while the exit is ungated
3731 // drains the occupancy below zero, after which nothing is ever refused.
3732 int rgi = region_of[i];
3733 if (rgi >= 0 && from < M && region_of[from] == rgi) {
3734 const std::size_t oc = hop_old_cls;
3735 hop_old_cls = K;
3736 if (oc < K) {
3737 // The main departure site has already left the region: the job is gated like an arrival, and a
3738 // class-switching hop first completes and releases the waiters, the ClassSwitch being outside
3739 hop_internal = true;
3740 if (oc != job.cls) {
3741 const std::size_t rg = static_cast<std::size_t>(rgi);
3742 regions[rg].completed[oc] += 1.0;
3743 release_region(rg);
3744 }
3745 } else {
3746 rgi = -1; // any other departure site keeps the job counted inside: it stays inside
3747 }
3748 }
3749 if (rgi >= 0) {
3750 Region& R = regions[static_cast<std::size_t>(rgi)];
3751 if (R.would_exceed(job.cls)) {
3752 R.update(now);
3753 if (R.drops(job.cls)) {
3754 R.dropped[job.cls] += 1.0;
3755 dropped[i][job.cls] += 1.0;
3756 return false;
3757 }
3758 // WAITQ: parked outside the region, still in the system.
3759 RegionWaiter w;
3760 w.station = i;
3761 w.job = job;
3762 w.job.t_arr = now;
3763 w.job.id = ++job_id;
3764 region_wait[static_cast<std::size_t>(rgi)].push_back(w);
3765 R.blocked[job.cls] += 1.0;
3766 return true;
3767 }
3768 }
3769
3770 // Kendall's K counts the jobs HELD by the station, waiting and in
3771 // service alike; a per-class buffer caps its own class on top of that.
3772 // At a retrial station the orbit is held too (see StationState::retrial),
3773 // so a full station there is a full orbit and the job is lost.
3774 if (s.role == Role::Queue && !from_orbit) {
3775 double total = 0.0;
3776 for (std::size_t r = 0; r < K; ++r) total += acc.qlen[i][r] - s.blocked_at[r];
3777 if (total + 1.0 > s.cap ||
3778 acc.qlen[i][job.cls] - s.blocked_at[job.cls] + 1.0 > s.classcap[job.cls]) {
3779 if (s.has_retrial[job.cls]) s.retrial_lost[job.cls] += 1.0;
3780 dropped[i][job.cls] += 1.0;
3781 // A job lost to a full STATION buffer did arrive there: CTMC and JMT report the
3782 // offered rate as ArvR (M/M/1/2 at lambda 0.8: ArvR 0.8, Tput 0.59), as does the JAR
3783 // engine. A REGION drop above is not counted: the job never reaches the member.
3784 acc.arrived[i][job.cls] += 1.0;
3785 return false;
3786 }
3787 // A RETRIAL STATION HAS NO WAITING ROOM: a job of a retrial class
3788 // that finds every server busy joins the orbit at once.
3789 if (s.has_retrial[job.cls] && !retrial_server_free(i, job.cls)) {
3790 orbit_join(i, job);
3791 return true;
3792 }
3793 }
3794 // BALKING is a refusal to JOIN, decided on what the arrival sees.
3795 // It is not a drop for want of room: the station may have plenty,
3796 // and the job declines anyway. Counted separately for that reason.
3797 // The FIRST range holding the pre-arrival population decides, as in
3798 // MATLAB afterEventStation and Java Solver_ssj.shouldBalk, and a match
3799 // costs one routing draw even at probability 0, because Java's
3800 // `routingRng.nextDouble() < p` does. A Delay balks too: both
3801 // references apply the rule at any station that declares it.
3802 if (!is_retry && !preset && !s.balk[job.cls].empty()) {
3803 double total = 0.0;
3804 for (std::size_t r = 0; r < K; ++r)
3805 total += acc.qlen[i][r] - (s.blocked_at.empty() ? 0.0 : s.blocked_at[r]);
3806 for (const StationState::BalkRule& br : s.balk[job.cls])
3807 if (total >= br.min_jobs && (br.max_jobs < 0.0 || total <= br.max_jobs)) {
3808 if (uniform01(g_routing) < br.probability) {
3809 balked[i][job.cls] += 1.0;
3810 acc.arrived[i][job.cls] += 1.0; // offered, as CTMC and the JAR engine count it
3811 return false;
3812 }
3813 break;
3814 }
3815 }
3816 if (!from_orbit && !preset) job.t_arr = now;
3817 job.region_wait = rwait;
3818 job.priority = classprio[job.cls];
3819 // A TIME-INHOMOGENEOUS service is NOT drawn here: it is drawn when the
3820 // job reaches a server, because its rate depends on absolute time and
3821 // the job may wait first. The exception is a station that ranks its
3822 // waiting room by the sampled size, which has to have one now. Every
3823 // other family is time-invariant and is drawn once, here, as before.
3824 if (draw_at_arrival(i, job.cls) && !job.preset) {
3825 job.service = slot_snap(S[i].svc[job.cls].next_at(g_svc[i][job.cls], now),
3826 "service time");
3827 job.remaining = job.service;
3828 job.elapsed = 0.0;
3829 }
3830 job.rank = g_siro.aux.next_double();
3831 if (!from_orbit) job.deadline = (preset ? job.t_sys : now) + classdeadline[job.cls];
3832 job.id = ++job_id;
3833 if (rgi >= 0) {
3834 Region& R = regions[static_cast<std::size_t>(rgi)];
3835 R.update(now);
3836 R.enter(job.cls);
3837 }
3838 sd_advance(i);
3839 if (!from_orbit) {
3840 // an orbiting job has been in the station population since it arrived
3841 acc.update_qlen(i, job.cls, now);
3842 acc.qlen[i][job.cls] += 1.0;
3843 // Counted where the job JOINS the station; a buffer drop and a balk are
3844 // counted where they are refused, above, so a drop, a balk or a reneging
3845 // leaves AN above TN, which is the difference AN exists to show.
3846 acc.arrived[i][job.cls] += 1.0;
3847 bp.track(i, job.cls, +1, now);
3848 }
3849
3850 if (s.role == Role::Delay) {
3851 job.due = now + job.service;
3852 if (track_delay_jobs) {
3853 job.tag = ++ps_tag;
3854 delay_live[i].push_back(job);
3855 }
3856 Event e;
3857 e.t = now + job.service;
3858 e.kind = EV_DEPARTURE;
3859 e.station = i;
3860 e.cls = job.cls;
3861 e.slot = std::numeric_limits<std::size_t>::max();
3862 e.tag = job.tag;
3863 e.job = job;
3864 push(e);
3865 // The scaling this think time actually runs at is installed by the
3866 // flush, which re-times the event just pushed along with every other
3867 // completion in flight. A model with no global dependence never gets
3868 // here, and the departure stands as scheduled.
3869 if (has_gd) sd_reschedule(i);
3870 return true;
3871 }
3872 if (s.ps) {
3873 ps_advance(i);
3874 // LPS admits at most `limit` jobs into the shared server; the rest
3875 // wait in the buffer under the same order a PS station would use.
3876 if (s.lps_limit > 0 && s.ps_jobs.size() >= s.lps_limit) {
3877 buffer_push(i, job);
3878 return true;
3879 }
3880 PsJob pj;
3881 pj.cls = job.cls;
3882 pj.priority = job.priority;
3883 pj.t_arr = job.t_arr;
3884 pj.region_wait = job.region_wait;
3885 pj.t_sys = job.t_sys;
3886 pj.total = job.preset ? job.elapsed + job.service : job.service;
3887 pj.remaining = job.service;
3888 pj.parent = job.parent;
3889 pj.ext = job.ext;
3890 s.ps_jobs.push_back(pj);
3891 ps_reschedule(i);
3892 return true;
3893 }
3894 // A station that powers down: the first arrival to a cold server pays
3895 // the setup before any service starts, and one arriving inside the
3896 // delay-off window pays nothing because the server never shut down.
3897 if (s.has_setup && !s.setup_on) {
3898 buffer_push(i, job);
3899 if (!s.setup_running) {
3900 s.setup_running = true;
3901 Event e;
3902 e.t = now + s.setup_time.next(g_aux[i]);
3903 e.kind = EV_SETUP;
3904 e.station = i;
3905 e.cls = job.cls;
3906 push(e);
3907 }
3908 return true;
3909 }
3910 if (s.pas) {
3911 s.pas_list.push_back(job);
3912 acc.update_busy(i, job.cls, now);
3913 acc.busy[i][job.cls] += 1.0;
3914 pas_reschedule(i);
3915 return true;
3916 }
3917 if (s.polling) {
3918 buffer_push(i, job);
3919 if (s.poll_parked && !s.poll_switching) {
3920 s.poll_parked = false;
3921 // A parked server stands at a polling instant: the arrival
3922 // opens a fresh visit if it joins the buffer the server is at.
3923 if (job.cls == s.poll_at) {
3924 poll_open_visit(s);
3925 poll_serve(i);
3926 } else {
3927 poll_advance(i);
3928 }
3929 } else if (!s.server_busy[0] && !s.poll_switching) {
3930 poll_serve(i);
3931 }
3932 return true;
3933 }
3934 // A BULK SERVER holds no per-job service time: the arrival joins the queue
3935 // and, if the station was EMPTY, arms the station-level firing clock. It is
3936 // never given a slot, so the ordinary free-slot path below must not see it.
3937 if (s.bmsp) {
3938 buffer_push(i, job);
3939 if (!s.server_busy[0]) {
3940 s.server_busy[0] = true;
3941 s.server_start[0] = now;
3942 acc.update_busy(i, job.cls, now);
3943 acc.busy[i][job.cls] += 1.0;
3944 bmsp_arm(i);
3945 }
3946 return true;
3947 }
3948 {
3949 const std::size_t sl = free_slot_for(i, job.cls);
3950 if (sl < s.nservers) {
3951 start_service(i, sl, job);
3952 sd_reschedule(i);
3953 return true;
3954 }
3955 }
3956 // Every server is busy, or on a pooled station every server THIS CLASS
3957 // MAY USE is. A preemptive station now asks whether this arrival
3958 // displaces one of them; the freed slot is taken by the arrival itself,
3959 // not by the head of the queue, because the victim has just been pushed
3960 // there and could otherwise be picked straight back.
3961 // A job seizing n slots displaces ONE job and still needs n-1 idle, so
3962 // a station that cannot offer them gains nothing by preempting: the
3963 // victim would be sent back to the waiting room for a service that
3964 // still could not start.
3965 if (s.preemptive &&
3966 (!s.has_parallelism || free_slot_count(i) + 1 >= s.parallelism[job.cls])) {
3967 std::vector<Job> held;
3968 std::vector<std::size_t> slot_of;
3969 std::vector<double> started;
3970 for (std::size_t sl = 0; sl < s.nservers; ++sl)
3971 if (s.server_busy[sl] && !s.server_blocked[sl]) {
3972 // ONLY A SLOT THE ARRIVAL COULD THEN OCCUPY is a candidate
3973 // victim: preempting a job on a pool that does not accept
3974 // this class frees a server the arrival cannot use, so the
3975 // victim would be displaced for nothing.
3976 if (s.has_pools && !s.type_compat[s.server_type[sl]][job.cls]) continue;
3977 Job h = s.server[sl];
3978 // The residual as of NOW, which is what every size-based
3979 // rule compares against; the stored value is as of the
3980 // instant service started.
3981 h.remaining = std::max(0.0, h.remaining - (now - s.server_start[sl]));
3982 h.elapsed += now - s.server_start[sl];
3983 held.push_back(h);
3984 slot_of.push_back(sl);
3985 started.push_back(s.server_start[sl]);
3986 }
3987 const std::size_t v = preemption_victim(s.sched, held, started, job,
3988 static_cast<double>(s.nservers), now);
3989 if (v < held.size()) {
3990 const std::size_t sl = slot_of[v];
3991 preempt(i, sl);
3992 start_service(i, sl, job);
3993 sd_reschedule(i);
3994 return true;
3995 }
3996 }
3997 buffer_push(i, job);
3998 // RENEGING times out a job that is WAITING, not one in service: a job
3999 // that reached a server has been served and no longer abandons. The
4000 // timer is therefore armed here and nowhere else, and it is re-armed
4001 // if a preemption sends the job back to the waiting room.
4002 if (s.has_patience[job.cls]) {
4003 Event e;
4004 e.t = now + s.patience[job.cls].next(g_svc[i][job.cls]);
4005 e.kind = EV_RENEGE;
4006 e.station = i;
4007 e.cls = job.cls;
4008 e.tag = job.id;
4009 push(e);
4010 }
4011 sd_reschedule(i);
4012 return true;
4013 };
4014
4015 const std::map<std::size_t, double> kNoWeights;
4016 /** Every index of `tab`, so JSQ can be told to consider all of them. */
4017 std::vector<std::size_t> tab_all;
4018
4019 /**
4020 * PROB: one draw against the static row.
4021 *
4022 * The mass is NOT renormalized before the draw. The reference scales the
4023 * uniform by the row total instead, so a row summing to one only up to
4024 * rounding still lands, and the two engines consume the routing stream
4025 * identically.
4026 */
4027 auto draw_prob = [&](const std::vector<RouteDest>& tab) -> std::size_t {
4028 double total = 0.0;
4029 for (std::size_t k = 0; k < tab.size(); ++k) total += tab[k].mass;
4030 const double u = uniform01(g_routing) * total;
4031 double cum = 0.0;
4032 std::size_t pick = 0;
4033 for (pick = 0; pick + 1 < tab.size(); ++pick) {
4034 cum += tab[pick].mass;
4035 if (u <= cum) break;
4036 }
4037 return pick;
4038 };
4039
4040 // ---- state-dependent routing (Krzesinski 1987) ----------------------------
4041 // The coefficients are derived ONCE: `pfqn_sdrcoeff` validates the
4042 // declaration and its population bounds, and repeating that at every
4043 // departure would make the validation the cost of the run.
4044 //
4045 // INDEX SPACE. The probabilities are read off the STATION-indexed structure
4046 // against station populations, and the destination split is mapped back
4047 // through the NODE-indexed twin, exactly as `state.h`'s `rt_state` does. The
4048 // two spaces must not be mixed: `pfqn_sdrprob` uses `sdr.branch[b][k]` as
4049 // indices into the population vector, so the structure and the vector have
4050 // to agree, while `entryOf`/`departure` are compared against node indices.
4051 const bool has_sdr = !sn.sdr.empty() && !sn.sdr_nodes.empty();
4052 pfqn::SdrCoeff sdr_coeff;
4053 if (has_sdr) sdr_coeff = pfqn::pfqn_sdrcoeff(sn.sdr);
4054 std::vector<double> sdr_pop, sdr_mass;
4055
4056 /**
4057 * Eq. (10) of Krzesinski (1987) at the CURRENT marking: the split out of the
4058 * entry centre into the branch entries, plus the residual that returns the
4059 * customer to the departure centre.
4060 *
4061 * A Source is reported as zero rather than as the infinite reservoir its
4062 * encoding carries, which is the `station_populations` rule and the one the
4063 * enumerating solvers use, so the sample path here has an exact counterpart
4064 * in SolverNC rather than merely a similar one.
4065 */
4066 auto draw_sdr = [&](const std::vector<RouteDest>& tab) -> std::size_t {
4067 sdr_pop.assign(M, 0.0);
4068 for (std::size_t i = 0; i < M; ++i) {
4069 if (S[i].role == Role::Source) continue;
4070 double held = 0.0;
4071 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[i][q];
4072 sdr_pop[i] = held;
4073 }
4074 const std::vector<double> Pb = pfqn::pfqn_sdrprob(sdr_coeff, sdr_pop);
4075 const double Ped = pfqn::pfqn_sdrped(Pb);
4076
4077 sdr_mass.assign(tab.size(), 0.0);
4078 double total = 0.0;
4079 for (std::size_t i = 0; i < tab.size(); ++i) {
4080 const std::size_t dnode = tab[i].node - 1; // 0-based, as sdr_nodes is
4081 double p = 0.0;
4082 for (std::size_t b = 1; b < sn.sdr_nodes.branch.size(); ++b)
4083 if (sn.sdr_nodes.entryOf[b] == dnode) p += Pb[b];
4084 if (sn.sdr_nodes.departure == dnode) p += Ped;
4085 // Eq. (10) returns zero at a branch closed to new arrivals; the
4086 // clamp only guards the residual against round-off.
4087 if (p < 0.0) p = 0.0;
4088 sdr_mass[i] = p;
4089 total += p;
4090 }
4091 // The branch probabilities and the residual sum to one over the branch
4092 // entries and the departure centre, so this normalization is an identity
4093 // whenever the declaration and the links agree. It is kept so the draw
4094 // stays a distribution if they do not, and the fallback below keeps a
4095 // model whose links reach neither from dividing by zero.
4096 if (!(total > 0.0)) return draw_prob(tab);
4097 const double u = uniform01(g_routing) * total;
4098 double cum = 0.0;
4099 std::size_t pick = 0;
4100 for (pick = 0; pick + 1 < tab.size(); ++pick) {
4101 cum += sdr_mass[pick];
4102 if (u <= cum) break;
4103 }
4104 return pick;
4105 };
4106
4107 /**
4108 * The candidate holding the FEWEST jobs, ties broken uniformly.
4109 *
4110 * A destination that is not a service station holds nothing to compare, so
4111 * it is skipped rather than counted as empty -- a Sink would otherwise win
4112 * every comparison and absorb the whole stream. When no candidate is a
4113 * station the first one is taken, which is the reference's fallback.
4114 */
4115 auto shortest_queue = [&](const std::vector<RouteDest>& tab,
4116 const std::vector<std::size_t>& cand) -> std::size_t {
4117 double best = std::numeric_limits<double>::infinity();
4118 std::vector<std::size_t> tied;
4119 for (std::size_t c = 0; c < cand.size(); ++c) {
4120 const std::size_t k = cand[c];
4121 if (tab[k].station >= M || S[tab[k].station].role == Role::Source) continue;
4122 double held = 0.0;
4123 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[tab[k].station][q];
4124 if (held < best) {
4125 best = held;
4126 tied.clear();
4127 tied.push_back(k);
4128 } else if (held == best) {
4129 tied.push_back(k);
4130 }
4131 }
4132 if (tied.empty()) return cand.empty() ? 0 : cand[0];
4133 if (tied.size() == 1) return tied[0];
4134 std::size_t at = static_cast<std::size_t>(uniform01(g_routing) *
4135 static_cast<double>(tied.size()));
4136 if (at >= tied.size()) at = tied.size() - 1;
4137 return tied[at];
4138 };
4139
4140 /**
4141 * One hop out of node `inode` in class `r`: the DISPATCHER picks the node,
4142 * the class-switch row picks the class.
4143 *
4144 * NEITHER STAGE DRAWS WHEN ITS OUTCOME IS DETERMINED. A single destination
4145 * and a single arriving class each cost nothing, which is the reference's
4146 * rule and not an optimization: a routing stream that advanced on a forced
4147 * hop would put every subsequent draw out of step with the Java engine and
4148 * make a seeded run of a tandem irreproducible across the two.
4149 */
4150 auto draw_node_route = [&](std::size_t inode, std::size_t r) -> RouteEntry {
4151 const std::vector<RouteDest>& tab = nroute[inode][r];
4152 if (tab.empty())
4153 throw InputError("SolverLDES (native engine): node '" + sn.nodes[inode - 1].name +
4154 "' has no routing for class '" + sn.classes[r].name + "'");
4155 std::size_t pick = 0;
4156 if (tab.size() > 1) {
4157 if (tab_all.size() != tab.size()) {
4158 tab_all.resize(tab.size());
4159 for (std::size_t k = 0; k < tab.size(); ++k) tab_all[k] = k;
4160 }
4161 switch (node_routing[inode][r]) {
4163 pick = static_cast<std::size_t>(uniform01(g_routing) *
4164 static_cast<double>(tab.size()));
4165 if (pick >= tab.size()) pick = tab.size() - 1;
4166 break;
4167 }
4169 pick = rr_counter[inode][r] % tab.size();
4170 ++rr_counter[inode][r];
4171 break;
4172 }
4174 // The weights become an INTEGER schedule and the counter
4175 // walks it, so a 3:1 split really is three then one and not
4176 // a 0.75 coin. A fractional weight is scaled by 1000 and a
4177 // positive weight never rounds to zero, or a link declared
4178 // open would never be offered a job.
4179 const std::map<std::size_t, double>& w =
4180 (sn.nodes[inode - 1].routing_weights.size() > r)
4181 ? sn.nodes[inode - 1].routing_weights[r]
4182 : kNoWeights;
4183 std::vector<double> raw(tab.size(), 0.0);
4184 bool integral = true;
4185 for (std::size_t k = 0; k < tab.size(); ++k) {
4186 const std::map<std::size_t, double>::const_iterator it = w.find(tab[k].node);
4187 raw[k] = (it == w.end()) ? 0.0 : it->second;
4188 if (raw[k] < 0.0 || raw[k] != std::floor(raw[k])) integral = false;
4189 }
4190 std::vector<std::size_t> quota(tab.size(), 0);
4191 std::size_t total = 0;
4192 for (std::size_t k = 0; k < tab.size(); ++k) {
4193 double v = integral ? raw[k] : raw[k] * 1000.0;
4194 std::size_t q = (v > 0.0) ? static_cast<std::size_t>(v) : 0;
4195 if (raw[k] > 0.0 && q < 1) q = 1;
4196 quota[k] = q;
4197 total += q;
4198 }
4199 if (total == 0) {
4200 // No usable weights: the dispatcher degenerates to plain
4201 // round robin, which is the reference's fallback.
4202 pick = rr_counter[inode][r] % tab.size();
4203 ++rr_counter[inode][r];
4204 break;
4205 }
4206 std::size_t counter = rr_counter[inode][r] % total;
4207 ++rr_counter[inode][r];
4208 std::size_t cum = 0;
4209 for (pick = 0; pick + 1 < tab.size(); ++pick) {
4210 cum += quota[pick];
4211 if (counter < cum) break;
4212 }
4213 break;
4214 }
4216 // Unlike JSQ and SQ(d) this routing keeps a product form of
4217 // its own, so this sample path has an exact counterpart in
4218 // solver_nc_sdr. The two agree only if both read the same
4219 // populations; see draw_sdr. A routing named SDR with no
4220 // declaration behind it is a malformed model every reader
4221 // refuses, and draw_prob is what it falls back to rather
4222 // than dividing by an absent structure.
4223 pick = has_sdr ? draw_sdr(tab) : draw_prob(tab);
4224 break;
4225 }
4227 pick = shortest_queue(tab, tab_all);
4228 break;
4229 }
4231 // POWER OF d: sample d candidates WITH replacement (manual
4232 // 3.2, SSA's NRM), then take the shortest among them. Two
4233 // draws can hit the same queue, so it is not JSQ even when d
4234 // covers every destination; sampling without replacement and
4235 // short-cutting to JSQ made SQ bit-identical to JSQ there.
4236 std::size_t d = (sn.nodes[inode - 1].routing_param.size() > r &&
4237 sn.nodes[inode - 1].routing_param[r] > 0)
4238 ? static_cast<std::size_t>(
4239 sn.nodes[inode - 1].routing_param[r])
4240 : 2;
4241 std::vector<std::size_t> cand;
4242 for (std::size_t k = 0; k < d; ++k) {
4243 std::size_t at = static_cast<std::size_t>(
4244 uniform01(g_routing) * static_cast<double>(tab.size()));
4245 if (at >= tab.size()) at = tab.size() - 1;
4246 cand.push_back(at);
4247 }
4248 pick = shortest_queue(tab, cand);
4249 break;
4250 }
4251 default: {
4252 pick = draw_prob(tab);
4253 break;
4254 }
4255 }
4256 }
4257 const RouteDest& d = tab[pick];
4258 RouteEntry e;
4259 e.node = d.node;
4260 e.station = d.station;
4261 e.sink = d.sink;
4262 e.cls = d.cls[0].first;
4263 if (d.cls.size() > 1) {
4264 double total = 0.0;
4265 for (std::size_t k = 0; k < d.cls.size(); ++k) total += d.cls[k].second;
4266 const double u = uniform01(g_routing) * total;
4267 double cum = 0.0;
4268 for (std::size_t k = 0; k < d.cls.size(); ++k) {
4269 cum += d.cls[k].second;
4270 if (u <= cum || k + 1 == d.cls.size()) {
4271 e.cls = d.cls[k].first;
4272 break;
4273 }
4274 }
4275 }
4276 return e;
4277 };
4278
4279 /**
4280 * The class a job routed out of `node` under `cls` will ARRIVE the true
4281 * destination station under, resolving through any ClassSwitch/Router/
4282 * Logger chain `draw_node_route` itself stops one hop short of.
4283 *
4284 * `draw_node_route(i, r).cls` names the class on the FIRST edge out of a
4285 * station, and when `link()` synthesized a ClassSwitch node for that edge
4286 * (any route whose class differs from station to station), that first
4287 * edge's class is the PRE-switch one -- the switch happens INSIDE the
4288 * synthesized node, one hop later. The synchronous-call park check below
4289 * needs the class the job actually lands under, or a callee's own reply
4290 * (its edge into the synthesized node still reads as the CALL class) is
4291 * misread as a brand-new call: the reply's `job.call` is then overwritten
4292 * with a fresh id, the caller's real call is never answered, and its
4293 * server stays held while the buffer behind it grows without bound.
4294 *
4295 * Consumes no extra draw when every hop is forced, which a link()-
4296 * synthesized pass-through node always is (`draw_node_route` never spends
4297 * a draw on a single-destination table), so the routing stream stays in
4298 * step with a caller that already resolved through this same node.
4299 */
4300 auto resolve_final_cls = [&](std::size_t node, std::size_t cls) -> std::size_t {
4301 std::size_t at_node = node, at_cls = cls;
4302 for (std::size_t hop = 0; hop <= nnodes && at_node != 0; ++hop) {
4303 const NodeType dt = sn.nodes[at_node - 1].nodetype;
4304 if (dt != NodeType::ClassSwitch && dt != NodeType::Router && dt != NodeType::Logger)
4305 break;
4306 const RouteEntry e = draw_node_route(at_node, at_cls);
4307 at_cls = e.cls;
4308 at_node = e.node;
4309 }
4310 return at_cls;
4311 };
4312
4313 /**
4314 * The station and class a job routed onto `re` ACTUALLY lands at, walking
4315 * the ClassSwitch/Router/Logger chain `draw_node_route` stops one hop short
4316 * of, and only while every hop is FORCED.
4317 *
4318 * WHY IT EXISTS: `link()` synthesizes `CS_<i>_to_<j>` for any route whose
4319 * class changes, so a CLASS-SWITCHING SELF-LOOP leaves the station as an
4320 * edge into that synthesized node. `re.station` is then not the station at
4321 * all and `re.cls` is still the PRE-switch class, so an immediate-feedback
4322 * test written on the raw entry reads the self-loop as a departure to
4323 * elsewhere and the feature goes silently inert on exactly the models that
4324 * need it -- measured against the Java engine and SolverSSA, which both
4325 * move the held job between classes.
4326 *
4327 * WHY FORCED HOPS ONLY: this is a LOOK-AHEAD. A job that turns out not to
4328 * be fed back is handed to `deliver`, which makes the same hops again, so
4329 * the walk must not spend a routing draw or every ordinary departure would
4330 * consume the stream twice and put the run out of step with the Java
4331 * engine. A hop with one destination and one arriving class costs nothing
4332 * (`draw_node_route` draws only where the outcome is undetermined), and a
4333 * link()-synthesized pass-through is always of that shape. Anything else
4334 * stops the walk and leaves the entry as it was, which reads as "not a
4335 * self-loop". A Logger is walked with the other two; the Loggers crossed are
4336 * kept in `fb_loggers`, and a job that IS fed back writes its trace rows from
4337 * there, since it never reaches the `deliver` that would otherwise write them.
4338 */
4339 std::vector<std::pair<std::size_t, std::size_t>> fb_loggers; // Loggers the last look-ahead crossed
4340 auto resolve_forced_dest = [&](const RouteEntry& re) -> RouteEntry {
4341 RouteEntry at = re;
4342 fb_loggers.clear();
4343 for (std::size_t hop = 0; hop <= nnodes; ++hop) {
4344 if (at.node == 0 || at.sink) break;
4345 const NodeType dt = sn.nodes[at.node - 1].nodetype;
4346 if (dt != NodeType::ClassSwitch && dt != NodeType::Router && dt != NodeType::Logger)
4347 break;
4348 if (dt == NodeType::Logger) fb_loggers.push_back(std::make_pair(at.node, at.cls));
4349 const std::vector<RouteDest>& tab = nroute[at.node][at.cls];
4350 if (tab.size() != 1 || tab[0].cls.size() != 1) break;
4351 RouteEntry nxt;
4352 nxt.node = tab[0].node;
4353 nxt.station = tab[0].station;
4354 nxt.sink = tab[0].sink;
4355 nxt.cls = tab[0].cls[0].first;
4356 at = nxt;
4357 }
4358 return at;
4359 };
4360
4361 // ---- busy periods -------------------------------------------------------
4362 // Every station is a target set of its own, every declared subnetwork is a
4363 // set, and each is measured aggregated and then per class, which is the
4364 // order `initializeBusyPeriods` builds them in and the order the result
4365 // rows carry.
4366 if (o.busy_period_orders > 0) {
4367 std::vector<std::vector<std::size_t>> sets;
4368 std::vector<std::string> set_names;
4369 for (std::size_t i = 0; i < M; ++i) {
4370 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) continue;
4371 sets.push_back(std::vector<std::size_t>(1, i));
4372 set_names.push_back(sn.stations[i].name);
4373 }
4374 for (const std::vector<std::size_t>& sub : o.busy_period_subnets) {
4375 std::string nm;
4376 for (std::size_t s2 : sub) {
4377 if (s2 >= M || S[s2].role == Role::Source)
4378 throw InputError("SolverLDES (native engine): station " +
4379 std::to_string(s2) +
4380 " of a busy period subnetwork is not a service station");
4381 nm += (nm.empty() ? "" : "+") + sn.stations[s2].name;
4382 }
4383 sets.push_back(sub);
4384 set_names.push_back(nm);
4385 }
4386 std::vector<BpTarget> targets;
4387 for (std::size_t si = 0; si < sets.size(); ++si) {
4388 BpTarget t;
4389 t.stations = sets[si];
4390 t.job_class = -1;
4391 t.name = set_names[si];
4392 targets.push_back(t);
4393 for (std::size_t r = 0; r < K; ++r) {
4394 BpTarget tc;
4395 tc.stations = sets[si];
4396 tc.job_class = static_cast<int>(r);
4397 tc.name = set_names[si] + ":" + sn.classes[r].name;
4398 targets.push_back(tc);
4399 }
4400 }
4401 bp.init(targets, o.busy_period_orders, M);
4402 }
4403
4404
4405 /**
4406 * Hand on the jobs blocked in front of `j`, oldest first, now that it has
4407 * room.
4408 *
4409 * OLDEST FIRST is the reference's rule and it matters: releasing the newest
4410 * would starve a server that has been blocked longest, which no blocking
4411 * discipline does and which shows up as an unbounded blocked time at one
4412 * station while another cycles freely.
4413 */
4414 std::function<void(std::size_t)> release_blocked = [&](std::size_t j) {
4415 bool progress = true;
4416 while (progress) {
4417 progress = false;
4418 std::size_t best_i = M, best_sl = 0;
4419 double oldest = std::numeric_limits<double>::infinity();
4420 for (std::size_t a = 0; a < M; ++a) {
4421 // A SHARING station has no per-server slots at all, so it has
4422 // no slot to block and none of these vectors is allocated for
4423 // it. Indexing them by `nservers` reads off the end.
4424 if (S[a].role != Role::Queue || S[a].ps || S[a].server_blocked.empty()) continue;
4425 for (std::size_t sl = 0; sl < S[a].nservers; ++sl)
4426 if (S[a].server_blocked[sl] && S[a].blocked_dest[sl] == j &&
4427 S[a].blocked_job[sl].t_arr < oldest) {
4428 oldest = S[a].blocked_job[sl].t_arr;
4429 best_i = a;
4430 best_sl = sl;
4431 }
4432 }
4433 if (best_i >= M) break;
4434 const std::size_t dcls = S[best_i].blocked_dest_cls[best_sl];
4435 if (!dest_has_room(j, dcls)) break;
4436
4437 StationState& src = S[best_i];
4438 Job moved = src.blocked_job[best_sl];
4439 src.server_blocked[best_sl] = false;
4440 src.server_busy[best_sl] = false;
4441 src.server_tag[best_sl] = 0;
4442 acc.update_qlen(j, dcls, now);
4443 acc.qlen[j][dcls] -= 1.0;
4444 S[j].blocked_at[dcls] -= 1.0;
4445
4446 // The job now leaves the upstream station for good.
4447 acc.update_busy(best_i, moved.cls, now);
4448 if (acc.busy[best_i][moved.cls] > 0.0) acc.busy[best_i][moved.cls] -= 1.0;
4449
4450 Job fresh;
4451 fresh.cls = dcls;
4452 fresh.t_sys = moved.t_sys;
4453 fresh.parent = moved.parent;
4454 admit(j, fresh, best_i);
4455
4456 // The freed server takes its own next waiting job -- on a pooled
4457 // station, the first one in service order that its pool accepts.
4458 serve_next_on_slot(best_i, best_sl);
4459 progress = true;
4460 }
4461 };
4462
4463
4464 /**
4465 * Let the jobs parked by region `rg` in, oldest first, now that it has
4466 * room.
4467 *
4468 * THE WAIT QUEUE IS STRICT FIFO: a head that still does not fit stops the release, and no younger waiter
4469 * overtakes it, as in JMT and the Java engine (`tryReleaseBlockedCustomers`). Letting a waiter of another class
4470 * jump the head starves the head whenever a per-class or linear cap keeps binding for it.
4471 *
4472 * A waiter re-enters through `admit`, which re-tests every constraint: the
4473 * region may have freed one slot while the waiter's own class cap or the
4474 * linear constraints still bind, and admitting on the global count alone
4475 * would breach them.
4476 */
4477 release_region = [&](std::size_t rg) {
4478 std::vector<RegionWaiter>& q = region_wait[rg];
4479 while (!q.empty()) {
4480 {
4481 const std::size_t w = 0;
4482 Region& R = regions[rg];
4483 if (R.would_exceed(q[w].job.cls)) break;
4484 RegionWaiter taken = q[w];
4485 R.update(now);
4486 R.blocked[taken.job.cls] -= 1.0;
4487 q.erase(q.begin() + static_cast<std::ptrdiff_t>(w));
4488 Job fresh;
4489 fresh.cls = taken.job.cls;
4490 fresh.t_sys = taken.job.t_sys;
4491 // A parked job keeps its fork and call identities: without them a Join discards it as an orphan and
4492 // the reply to a parked call finds no pending call, so the caller's server is held forever
4493 fresh.parent = taken.job.parent;
4494 fresh.call = taken.job.call;
4495 // The wait outside the region is charged to the visit's response time, as JMT and
4496 // Solver_ssj charge it (the released customer keeps its queueArrivalTime); QLen
4497 // still counts the waiter nowhere, which is the JMT convention the reference keeps.
4498 pending_region_wait = now - taken.job.t_arr;
4499 admit(taken.station, fresh, M);
4500 }
4501 }
4502 };
4503
4504 // ---- G-network signals --------------------------------------------------
4505
4506 /** The jobs station `i` actually HOLDS, waiting and in service alike. */
4507 auto signal_held = [&](std::size_t i) -> std::size_t {
4508 const StationState& s = S[i];
4509 if (s.role == Role::Delay) return delay_live[i].size();
4510 if (s.pas) return s.pas_list.size();
4511 std::size_t n = s.buffer.size();
4512 if (s.ps) return n + s.ps_jobs.size();
4513 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4514 if (s.server_busy[sl]) ++n;
4515 return n;
4516 };
4517
4518 /**
4519 * Remove ONE job from station `i` under `policy`, returning its class or
4520 * `K` when the station holds nothing.
4521 *
4522 * THE POLICY IS TWO-TIER, as in the reference: FCFS and LCFS rank the
4523 * WAITING line by age and only reach into the servers once nobody waits,
4524 * while RANDOM draws uniformly over waiting and in-service jobs together.
4525 * A station whose occupants are all in service -- Delay, PS, PAS -- has no
4526 * waiting line for the age tiers to rank, so there the three policies
4527 * differ only in which of the equally-served jobs is taken.
4528 *
4529 * The victim is drawn WITHOUT REGARD TO CLASS. That is Gelenbe's negative
4530 * customer and what the Java engine and SolverMAM both do; `sn.signaltarget`
4531 * narrows the eligible class only in the state-space solvers, whose state
4532 * carries the per-class composition this one draws over directly.
4533 */
4534 auto signal_remove_one = [&](std::size_t i, lang::RemovalPolicy policy) -> std::size_t {
4535 StationState& s = S[i];
4536
4537 // A DELAY holds every occupant in service, in the registry.
4538 if (s.role == Role::Delay) {
4539 std::vector<Job>& live = delay_live[i];
4540 if (live.empty()) return K;
4541 std::size_t at = 0;
4542 if (policy == lang::RemovalPolicy::FCFS) {
4543 for (std::size_t j = 1; j < live.size(); ++j)
4544 if (live[j].t_arr < live[at].t_arr) at = j;
4545 } else if (policy == lang::RemovalPolicy::LCFS) {
4546 for (std::size_t j = 1; j < live.size(); ++j)
4547 if (live[j].t_arr > live[at].t_arr) at = j;
4548 } else {
4549 at = static_cast<std::size_t>(uniform01(g_routing) *
4550 static_cast<double>(live.size()));
4551 if (at >= live.size()) at = live.size() - 1;
4552 }
4553 const std::size_t rc = live[at].cls;
4554 live.erase(live.begin() + static_cast<std::ptrdiff_t>(at));
4555 return rc;
4556 }
4557
4558 // A PASS-AND-SWAP list: one aggregate clock covers the whole station,
4559 // so the survivors must be re-timed rather than left on a rate the
4560 // list no longer has.
4561 if (s.pas) {
4562 if (s.pas_list.empty()) return K;
4563 std::size_t at = 0;
4564 if (policy == lang::RemovalPolicy::FCFS) {
4565 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
4566 if (s.pas_list[j].t_arr < s.pas_list[at].t_arr) at = j;
4567 } else if (policy == lang::RemovalPolicy::LCFS) {
4568 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
4569 if (s.pas_list[j].t_arr > s.pas_list[at].t_arr) at = j;
4570 } else {
4571 at = static_cast<std::size_t>(uniform01(g_routing) *
4572 static_cast<double>(s.pas_list.size()));
4573 if (at >= s.pas_list.size()) at = s.pas_list.size() - 1;
4574 }
4575 const std::size_t rc = s.pas_list[at].cls;
4576 s.pas_list.erase(s.pas_list.begin() + static_cast<std::ptrdiff_t>(at));
4577 acc.update_busy(i, rc, now);
4578 if (acc.busy[i][rc] > 0.0) acc.busy[i][rc] -= 1.0;
4579 pas_reschedule(i);
4580 return rc;
4581 }
4582
4583 const std::size_t waiting = s.buffer.size();
4584
4585 // A SHARING station: `ps_jobs` are in service, `buffer` holds only the
4586 // jobs an LPS admission cap has left outside.
4587 if (s.ps) {
4588 const std::size_t inservice = s.ps_jobs.size();
4589 if (waiting + inservice == 0) return K;
4590 bool from_wait;
4591 if (policy == lang::RemovalPolicy::RANDOM) {
4592 const std::size_t pick = static_cast<std::size_t>(
4593 uniform01(g_routing) * static_cast<double>(waiting + inservice));
4594 from_wait = pick < waiting;
4595 } else {
4596 from_wait = waiting > 0;
4597 }
4598 if (from_wait) {
4599 std::size_t at = 0;
4600 if (policy == lang::RemovalPolicy::FCFS) {
4601 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4602 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
4603 } else if (policy == lang::RemovalPolicy::LCFS) {
4604 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4605 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
4606 } else {
4607 at = std::min(waiting - 1,
4608 static_cast<std::size_t>(uniform01(g_routing) *
4609 static_cast<double>(waiting)));
4610 }
4611 const std::size_t rc = s.buffer[at].cls;
4612 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
4613 if (s.sched != SchedStrategy::FSP)
4614 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
4615 return rc;
4616 }
4617 ps_advance(i);
4618 std::size_t at = 0;
4619 if (policy == lang::RemovalPolicy::FCFS) {
4620 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
4621 if (s.ps_jobs[j].t_arr < s.ps_jobs[at].t_arr) at = j;
4622 } else if (policy == lang::RemovalPolicy::LCFS) {
4623 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
4624 if (s.ps_jobs[j].t_arr > s.ps_jobs[at].t_arr) at = j;
4625 } else {
4626 at = std::min(inservice - 1,
4627 static_cast<std::size_t>(uniform01(g_routing) *
4628 static_cast<double>(inservice)));
4629 }
4630 const std::size_t rc = s.ps_jobs[at].cls;
4631 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(at));
4632 // The freed share is taken by whatever an LPS cap was holding out.
4633 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
4634 Job nextjob = buffer_pop(i);
4635 PsJob pj;
4636 pj.cls = nextjob.cls;
4637 pj.priority = nextjob.priority;
4638 pj.t_arr = nextjob.t_arr;
4639 pj.region_wait = nextjob.region_wait;
4640 pj.t_sys = nextjob.t_sys;
4641 pj.total = nextjob.preset ? nextjob.elapsed + nextjob.service : nextjob.service;
4642 pj.remaining = nextjob.service;
4643 pj.parent = nextjob.parent;
4644 pj.ext = nextjob.ext;
4645 s.ps_jobs.push_back(pj);
4646 }
4647 ps_reschedule(i);
4648 return rc;
4649 }
4650
4651 // A BUFFERED station. A blocked slot is NOT a candidate: the job it
4652 // holds has already completed here and is charged to the destination
4653 // it is queueing for, so removing it would decrement the wrong station.
4654 std::size_t inservice = 0;
4655 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4656 if (s.server_busy[sl]) ++inservice;
4657 if (waiting + inservice == 0) return K;
4658 bool from_wait;
4659 if (policy == lang::RemovalPolicy::RANDOM) {
4660 const std::size_t pick = static_cast<std::size_t>(
4661 uniform01(g_routing) * static_cast<double>(waiting + inservice));
4662 from_wait = pick < waiting;
4663 } else {
4664 from_wait = waiting > 0;
4665 }
4666
4667 if (from_wait) {
4668 std::size_t at = 0;
4669 if (policy == lang::RemovalPolicy::FCFS) {
4670 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4671 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
4672 } else if (policy == lang::RemovalPolicy::LCFS) {
4673 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4674 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
4675 } else {
4676 at = std::min(waiting - 1,
4677 static_cast<std::size_t>(uniform01(g_routing) *
4678 static_cast<double>(waiting)));
4679 }
4680 const std::size_t rc = s.buffer[at].cls;
4681 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
4682 if (s.sched != SchedStrategy::FSP)
4683 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
4684 return rc;
4685 }
4686
4687 std::size_t slot = s.nservers;
4688 for (std::size_t sl = 0; sl < s.nservers; ++sl) {
4689 if (!s.server_busy[sl]) continue;
4690 if (slot == s.nservers) {
4691 slot = sl;
4692 continue;
4693 }
4694 if (policy == lang::RemovalPolicy::FCFS) {
4695 if (s.server[sl].t_arr < s.server[slot].t_arr) slot = sl;
4696 } else if (policy == lang::RemovalPolicy::LCFS) {
4697 if (s.server[sl].t_arr > s.server[slot].t_arr) slot = sl;
4698 }
4699 }
4700 if (policy == lang::RemovalPolicy::RANDOM) {
4701 std::size_t pick = std::min(inservice - 1,
4702 static_cast<std::size_t>(uniform01(g_routing) *
4703 static_cast<double>(inservice)));
4704 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4705 if (s.server_busy[sl]) {
4706 if (pick == 0) {
4707 slot = sl;
4708 break;
4709 }
4710 --pick;
4711 }
4712 }
4713 if (slot == s.nservers) return K;
4714 sd_advance(i);
4715 const std::size_t rc = s.server[slot].cls;
4716 // Freeing the slot and clearing its tag is what neutralises the
4717 // departure already in the list; there is no handle to cancel it with.
4718 s.server_busy[slot] = false;
4719 s.server_tag[slot] = 0;
4720 const double freed = release_slots(i, slot);
4721 acc.update_busy(i, rc, now);
4722 acc.busy[i][rc] -= freed;
4723 // The vacated server takes the next job, under the station's own order:
4724 // the polling controller decides for itself, everyone else pops.
4725 if (s.polling) {
4726 poll_serve(i);
4727 } else if (!s.server_blocked[slot] && !s.server_held[slot]) {
4728 serve_next_on_slot(i, slot);
4729 }
4730 sd_reschedule(i);
4731 return rc;
4732 };
4733
4734 /**
4735 * A G-NETWORK REMOVAL SIGNAL arriving at station `i`, and annihilated there.
4736 *
4737 * The signal NEVER JOINS the station and never continues along its routing
4738 * chain: it removes a batch of the jobs it finds, and one that finds the
4739 * station empty is simply lost. That is Gelenbe's semantics and the one
4740 * `State.afterEventStationSignal` and the Java engine both implement;
4741 * routing the signal onward instead would make a single signal fire once
4742 * per downstream station, which is what the tandem regression measures.
4743 *
4744 * A CATASTROPHE removes EVERY job held, in-service ones included, and
4745 * ignores the batch-size law entirely -- removing all of them is what the
4746 * word means. A NEGATIVE signal draws its batch size from `signalremdist`
4747 * (absent: exactly one) and CLIPS it at what the station holds, so an
4748 * oversized batch drains the station instead of driving it negative.
4749 */
4750 auto apply_signal = [&](std::size_t i, const Job& sig) {
4751 StationState& s = S[i];
4752 const std::size_t held = signal_held(i);
4753 if (held > 0) {
4754 std::size_t to_remove = 1;
4755 if (sig.cls < sn.signaltype.size() &&
4756 sn.signaltype[sig.cls] == lang::SignalType::CATASTROPHE) {
4757 to_remove = held;
4758 } else if (sig.cls < sn.signalremdist.size() && !sn.signalremdist[sig.cls].empty()) {
4759 const std::vector<T>& pmf = sn.signalremdist[sig.cls];
4760 const double u = uniform01(g_routing);
4761 double cum = 0.0;
4762 std::size_t b = 0;
4763 for (; b + 1 < pmf.size(); ++b) {
4764 cum += static_cast<double>(pmf[b]);
4765 if (u < cum) break;
4766 }
4767 to_remove = std::min(b, held);
4768 } else {
4769 to_remove = std::min<std::size_t>(1, held);
4770 }
4772 if (sig.cls < sn.signalrempolicy.size()) policy = sn.signalrempolicy[sig.cls];
4773 for (std::size_t rep = 0; rep < to_remove; ++rep) {
4774 if (signal_held(i) == 0) break;
4775 const std::size_t rc = signal_remove_one(i, policy);
4776 if (rc >= K) break;
4777 last_lost[i][rc] = now;
4778 sd_advance(i);
4779 acc.update_qlen(i, rc, now);
4780 acc.qlen[i][rc] -= 1.0;
4781 bp.track(i, rc, -1, now);
4782 // A job destroyed inside a finite capacity region frees the
4783 // slot it occupied; leaving it charged would shrink the region
4784 // by one for the rest of the run.
4785 if (region_of[i] >= 0) {
4786 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
4787 regions[rg].update(now);
4788 regions[rg].leave(rc);
4789 release_region(rg);
4790 }
4791 }
4792 release_blocked(i);
4793 }
4794 // The signal is annihilated here: it leaves the system without ever
4795 // having been at a station, so it is a system completion and nothing
4796 // else -- no arrival, no queue length and no station throughput.
4797 sys_resp_sum[sig.cls] += now - sig.t_sys;
4798 sys_resp_cnt[sig.cls] += 1.0;
4799 sys_completed[sig.cls] += 1.0;
4800 };
4801
4802
4803 /**
4804 * The completion budget, spent by anything the model actually DOES.
4805 *
4806 * Declared ahead of `deliver` because a service completion is not the only
4807 * thing that spends it: a Petri net has no services of its own and counts a
4808 * FIRING, and a cache-only model (Source -> Cache -> Sink) has none either
4809 * and counts the cache's delivery to the Sink. Without the latter the loop
4810 * at the bottom of this function never terminates on such a model, since
4811 * the Source keeps the event queue non-empty forever. `Solver_ssj` spends
4812 * the budget at the same point (`deliverCacheCompletion` calls
4813 * `checkEventCountStop` on the sink branch), so this is the reference's
4814 * rule and not a local convention.
4815 */
4816 std::uint64_t total_completions = 0;
4817
4818 /**
4819 * Free every request parked while `item` was being fetched.
4820 *
4821 * Declared ahead of `deliver` and assigned after it because the two are
4822 * mutually recursive: a release routes each freed request onward, and a
4823 * delivery to a cache is what triggers a release. Each freed request reads
4824 * the now-cached item and completes at once, counted as a DELAYED HIT and
4825 * leaving under its own hit class; its original `t_sys` is preserved so the
4826 * response time still spans the wait it actually served.
4827 */
4828 std::function<void(CacheState&, std::size_t, std::size_t)> release_delayed_hits;
4829
4830 /**
4831 * Hand a job to a NODE, replicating at a Fork and synchronizing at a Join.
4832 *
4833 * A FORK sends a sibling down EVERY outgoing edge, not one drawn from the
4834 * routing probabilities: the edges of a fork are branches taken together,
4835 * and drawing among them would turn a fork into a probabilistic split with
4836 * the same picture and a completely different response time. `tasksPerLink`
4837 * multiplies each branch.
4838 *
4839 * A JOIN releases ONE job once its strategy is satisfied and DISCARDS the
4840 * siblings that arrive afterwards -- under a quorum they are the losers of
4841 * the race, and counting them as completions would inflate the throughput
4842 * of everything downstream.
4843 */
4844 std::function<void()> spn_settle; // defined with the net below; a token reaching a Place settles it
4845 std::function<void(std::size_t, Job, std::size_t)> deliver =
4846 [&](std::size_t node, Job job, std::size_t from) {
4847 const NodeType nt = sn.nodes[node - 1].nodetype;
4848
4849 /**
4850 * A JOB REACHING A PLACE BECOMES A TOKEN of its own class, which is the
4851 * reference's `handlePlaceArrival`; one reaching a Transition is absorbed
4852 * and only re-evaluates the net. Before 2026-10-11 both fell through to
4853 * the station admission and drew a service time from the place's empty
4854 * process, so every open net died in `map_sample`.
4855 */
4856 if (nt == NodeType::Place) {
4857 place_deposit(place_of[node], job.cls, 1.0);
4858 spn_settle();
4859 return;
4860 }
4861 if (nt == NodeType::Transition) {
4862 spn_settle();
4863 return;
4864 }
4865
4866 if (nt == NodeType::Sink) {
4867 sys_resp_sum[job.cls] += now - job.t_sys;
4868 sys_resp_cnt[job.cls] += 1.0;
4869 sys_completed[job.cls] += 1.0;
4870 return;
4871 }
4872
4873 if (nt == NodeType::Cache) {
4874 auto ci = caches.find(node);
4875 if (ci == caches.end())
4876 throw InputError("SolverLDES (native engine): Cache node '" +
4877 sn.nodes[node - 1].name + "' carries no parameters");
4878 CacheState& cs = ci->second;
4879 Job out = job;
4880
4881 // A RETURNING FETCH, not a read. The job wearing a retrieval class
4882 // is the fetch this cache sent out on an earlier miss: it inserts
4883 // the item, clears the in-flight flag and frees everyone parked
4884 // behind it. The miss was counted when the fetch was TRIGGERED, so
4885 // counting it again here would double every miss.
4886 if (cs.has_retrieval) {
4887 const int fetched = cs.fetches_item(job.cls);
4888 if (fetched >= 0) {
4889 const std::size_t fi = static_cast<std::size_t>(fetched);
4890 cs.accumulate(now);
4891 cache_miss(cs, fi, uniform01(g_routing), uniform01(g_routing));
4892 cs.in_flight[fi] = 0;
4893 cs.total_fetch_time += now - cs.fetch_start[fi];
4894 cs.completed_fetches += 1.0;
4895 release_delayed_hits(cs, fi, node);
4896 if (cs.miss_class[job.cls] >= 0)
4897 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
4898 const RouteEntry re = draw_node_route(node, out.cls);
4899 out.cls = re.cls;
4900 if (re.node != 0 && sn.nodes[re.node - 1].nodetype == NodeType::Sink)
4901 ++total_completions;
4902 deliver(re.node, out, M);
4903 return;
4904 }
4905 }
4906
4907 const std::vector<double>& pop = cs.popularity[job.cls];
4908 if (pop.empty())
4909 throw InputError("SolverLDES (native engine): class '" +
4910 sn.classes[job.cls].name + "' reads cache '" +
4911 sn.nodes[node - 1].name + "' with no item popularity");
4912 const double u = uniform01(g_routing);
4913 std::size_t item = 0;
4914 while (item + 1 < pop.size() && u >= pop[item]) ++item;
4915
4916 const int at = cs.find(item);
4917 if (at >= 0) {
4918 cs.hits[job.cls] += 1.0;
4919 cs.hits_per_list[job.cls][static_cast<std::size_t>(at)] += 1.0;
4920 cs.accumulate(now);
4921 cache_hit(cs, item, static_cast<std::size_t>(at), uniform01(g_routing));
4922 if (cs.hit_class[job.cls] >= 0)
4923 out.cls = static_cast<std::size_t>(cs.hit_class[job.cls]);
4924 } else if (cs.has_retrieval && cs.retrieval_of(item, job.cls) > 0) {
4925 if (cs.in_flight[item]) {
4926 // A fetch for this item is already out: PARK the request. It
4927 // leaves the event stream entirely and is released when the
4928 // fetch returns, so it is neither a hit nor a miss yet.
4929 CacheState::HeldRequest hr;
4930 hr.cls = job.cls;
4931 hr.hold_time = now;
4932 hr.t_sys = job.t_sys;
4933 cs.held[item].push_back(hr);
4934 return;
4935 }
4936 // Trigger the fetch: count the miss now and leave wearing the
4937 // per-item retrieval class, which the routing carries through
4938 // the retrieval stations and back to this cache.
4939 cs.misses[job.cls] += 1.0;
4940 cs.in_flight[item] = 1;
4941 cs.fetch_start[item] = now;
4942 out.cls = cs.retrieval_of(item, job.cls) - 1;
4943 } else {
4944 cs.misses[job.cls] += 1.0;
4945 cs.accumulate(now);
4946 cache_miss(cs, item, uniform01(g_routing), uniform01(g_routing));
4947 if (cs.miss_class[job.cls] >= 0)
4948 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
4949 }
4950 // The hit and miss classes are what the POST_CACHE branch routes on,
4951 // so the job leaves the cache under its new class.
4952 const RouteEntry e = draw_node_route(node, out.cls);
4953 out.cls = e.cls;
4954 // A cache delivering to a Sink is this model's only completion when
4955 // it has no service station, so it spends the budget (see above).
4956 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
4957 ++total_completions;
4958 deliver(e.node, out, M);
4959 return;
4960 }
4961
4962 if (nt == NodeType::ClassSwitch || nt == NodeType::Router || nt == NodeType::Logger) {
4963 // A PASS-THROUGH NODE HOLDS NOTHING. It changes the class (a
4964 // ClassSwitch), picks a destination (a Router) or records the
4965 // crossing (a Logger) and the job continues in the SAME instant, so
4966 // there is no queue, no service and no row to fill.
4967 //
4968 // `link()` synthesizes `CS_<i>_to_<j>` for every link that switches
4969 // class, so this branch carries every class switch the routing
4970 // matrix used to hold as an edge attribute. Such a node has ONE
4971 // outgoing destination, so `draw_node_route` spends no draw on it
4972 // and the sample path stays in step with the Java engine.
4973 //
4974 // The walk is iterative, not recursive, and bounded: a routing that
4975 // sends a job round a cycle of pass-through nodes never reaches a
4976 // station and would otherwise hang the run.
4977 Job out = job;
4978 std::size_t at = node;
4979 const std::uint64_t walk = logger_traces.empty() ? 0 : log_walk_id++;
4980 if (nt == NodeType::Logger) log_passage(node, out.cls, walk);
4981 for (std::size_t hop = 0; hop <= nnodes; ++hop) {
4982 const RouteEntry e = draw_node_route(at, out.cls);
4983 out.cls = e.cls;
4984 if (e.node == 0) return;
4985 const NodeType dt = sn.nodes[e.node - 1].nodetype;
4986 if (dt == NodeType::ClassSwitch || dt == NodeType::Router ||
4987 dt == NodeType::Logger) {
4988 if (dt == NodeType::Logger) log_passage(e.node, out.cls, walk);
4989 at = e.node;
4990 continue;
4991 }
4992 deliver(e.node, out, from);
4993 return;
4994 }
4995 throw InputError("SolverLDES (native engine): the routing out of node '" +
4996 sn.nodes[node - 1].name +
4997 "' cycles through pass-through nodes without reaching a station");
4998 }
4999
5000 if (nt == NodeType::Fork) {
5001 // A FORK TAKES EVERY EDGE, so it flattens the two-stage table back
5002 // into one sibling per (destination, arriving class): there is no
5003 // dispatcher decision to make and no draw to spend.
5004 const std::vector<RouteDest>& tab = nroute[node][job.cls];
5005 if (tab.empty()) return;
5006 std::vector<RouteEntry> links;
5007 for (std::size_t di = 0; di < tab.size(); ++di)
5008 for (std::size_t ci = 0; ci < tab[di].cls.size(); ++ci) {
5009 RouteEntry e;
5010 e.node = tab[di].node;
5011 e.station = tab[di].station;
5012 e.sink = tab[di].sink;
5013 e.cls = tab[di].cls[ci].first;
5014 links.push_back(e);
5015 }
5016 const qn::NodeDef& fk = sn.nodes[node - 1];
5017 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
5018 const int per_link = std::max(1, static_cast<int>(fk.tasks_per_link + 0.5));
5019
5020 // How many tasks each branch emits on THIS firing. A plain fork
5021 // takes `per_link` on every link and draws NOTHING -- which is what
5022 // keeps every seeded golden and the bit-exact agreement with the
5023 // Java engine intact. A variable one draws the branch activation
5024 // and the degree per link, so the count the Join waits for is known
5025 // only after the draws.
5026 std::vector<int> per_branch(links.size(), per_link);
5027 int total = 0;
5028 const bool variable = fork_is_variable(node);
5029 for (std::size_t li = 0; li < links.size(); ++li) {
5030 if (variable) {
5031 const std::size_t k0 = links[li].node - 1, r0 = job.cls;
5032 const double p = fp->fan_out_prob(k0, r0);
5033 if (p < 1.0 && g_fork.aux.next_double() >= p) {
5034 per_branch[li] = 0;
5035 } else if (!fp->fan_out_dist[k0][r0].disabled) {
5036 per_branch[li] = sample_fork_degree(fp->fan_out_dist[k0][r0]);
5037 } else {
5038 per_branch[li] = static_cast<int>(fp->fan_out_link(k0, r0) + 0.5);
5039 }
5040 }
5041 total += per_branch[li];
5042 }
5043 if (total <= 0)
5044 throw InputError("SolverLDES (native engine): Fork '" + fk.name +
5045 "' emitted no sibling; at least one branch must be certain "
5046 "to emit at least one task");
5047
5048 ForkSync fs;
5049 fs.cls = job.cls;
5050 fs.t_sys = job.t_sys;
5051 fs.outer = job.parent;
5052 fs.t_fork = now;
5053 fs.pair = fj_pair_of_fork[node];
5054 fs.total = total;
5055 fs.required = fs.total;
5056 const int jn = join_of_fork[node];
5057 if (jn > 0) {
5058 auto jd = sn.joindecl.find(static_cast<std::size_t>(jn));
5059 if (jd != sn.joindecl.end() &&
5060 jd->second.strategy != lang::JoinStrategy::STD && jd->second.quorum > 0.0) {
5061 // The quorum is a count of THIS fork's siblings, fixed here
5062 // rather than at the Join: a Join shared by two forks of
5063 // different fan-out would otherwise apply one fork's count
5064 // to the other's siblings.
5065 fs.required = std::min(fs.total,
5066 std::max(1, static_cast<int>(jd->second.quorum + 0.5)));
5067 }
5068 }
5069 const std::uint64_t pid = ++next_parent;
5070 fork_sync[pid] = fs;
5071 for (std::size_t li = 0; li < links.size(); ++li)
5072 for (int t = 0; t < per_branch[li]; ++t) {
5073 Job sib;
5074 sib.cls = links[li].cls;
5075 sib.t_sys = job.t_sys;
5076 sib.parent = pid;
5077 deliver(links[li].node, sib, M);
5078 }
5079 return;
5080 }
5081
5082 if (nt == NodeType::Join) {
5083 auto it = fork_sync.find(job.parent);
5084 if (it == fork_sync.end()) {
5085 // Its parent already synchronized: this sibling lost the quorum
5086 // race and is discarded rather than released a second time. It
5087 // goes into the SAME accumulators every other station uses, so
5088 // it is warmup-truncated with them; see Accum::join_dropped.
5089 //
5090 // IT STILL ARRIVED. `arrived` is the OFFERED rate, and the whole
5091 // point of a quorum is that N siblings are offered and only K
5092 // consumed, so leaving the straggler out reports AN = K*TN and
5093 // hides the very loss the measurement exists for. The reference
5094 // counts it the same way: its AN at the join is exactly N times
5095 // the fork rate under a quorum, not K times.
5096 const std::size_t jd = node_to_station[node];
5097 if (jd < M) {
5098 acc.arrived[jd][job.cls] += 1.0;
5099 acc.join_dropped[jd][job.cls] += 1.0;
5100 }
5101 return;
5102 }
5103 ForkSync& fs = it->second;
5104 // A JOIN IS A STATION LIKE ANY OTHER. Its siblings are its queue
5105 // length and its synchronizations are its completions, so they go
5106 // into the same accumulators every other station uses and are
5107 // warmup-truncated with them rather than measured on a private
5108 // tally the result assembly never reads.
5109 // `js == M` is a Join the refresh registered as a node and not as a
5110 // station, which has no row to fill; `handleJoinArrival` gates on
5111 // the same bound. The synchronization itself runs either way.
5112 const std::size_t js = node_to_station[node];
5113 if (js < M) {
5114 acc.update_qlen(js, job.cls, now);
5115 acc.qlen[js][job.cls] += 1.0;
5116 acc.arrived[js][job.cls] += 1.0;
5117 }
5118 fs.siblings.push_back(std::make_pair(job.cls, now));
5119 if (static_cast<int>(fs.siblings.size()) < fs.required) return;
5120
5121 // Satisfied: the siblings leave the Join together and one job goes on.
5122 if (js < M) {
5123 for (std::size_t si = 0; si < fs.siblings.size(); ++si) {
5124 const std::size_t sc = fs.siblings[si].first;
5125 acc.update_qlen(js, sc, now);
5126 acc.qlen[js][sc] -= 1.0;
5127 // Each sibling waited from ITS OWN arrival, not the first:
5128 // the Join charges the delay it imposed on each.
5129 acc.tally_resp(js, sc, now - fs.siblings[si].second);
5130 }
5131 acc.completed[js][fs.cls] += 1.0;
5132 }
5133 Job merged;
5134 merged.cls = fs.cls;
5135 merged.t_sys = fs.t_sys;
5136 merged.parent = fs.outer;
5137 if (fs.pair >= 0 && fs.cls < K) {
5138 const std::size_t p = static_cast<std::size_t>(fs.pair);
5139 fj_resp_sum[p][fs.cls] += now - fs.t_fork;
5140 fj_resp_cnt[p][fs.cls] += 1.0;
5141 fj_release[p][fs.cls].push_back(now);
5142 }
5143 fork_sync.erase(it);
5144 const RouteEntry e = draw_node_route(node, merged.cls);
5145 merged.cls = e.cls;
5146 deliver(e.node, merged, M);
5147 return;
5148 }
5149
5150 // An ordinary station.
5151 const std::size_t j = node_to_station[node];
5152 // A REMOVAL SIGNAL acts on the station and is annihilated: it is
5153 // intercepted here, before any of the accounting an arrival gets,
5154 // because it never becomes one.
5155 if (has_removal_signal && j < M && is_removal_signal[job.cls]) {
5156 apply_signal(j, job);
5157 return;
5158 }
5159 /**
5160 * A REPLY ANSWERS ITS CALL AND TRAVELS ON. It releases the server the
5161 * caller has been holding, lets that station take its next waiting job,
5162 * and then CONTINUES along its own routing -- which is what separates it
5163 * from a removal signal, annihilated where it lands.
5164 *
5165 * A reply with no matching call is not an error: the call may have been
5166 * answered already, or the class may be routed as a reply without any
5167 * caller having parked. It routes on, having released nothing.
5168 */
5169 if (has_sync_call && j < M && is_reply_signal[job.cls]) {
5170 acc.completed[j][job.cls] += 1.0;
5171 typename std::map<std::uint64_t, PendingCall>::iterator pc =
5172 pending_reply.find(job.call);
5173 if (pc != pending_reply.end()) {
5174 const std::size_t bi = pc->second.station, bs = pc->second.slot;
5175 const std::size_t bc = pc->second.cls;
5176 pending_reply.erase(pc);
5177 StationState& bst = S[bi];
5178 bst.server_held[bs] = false;
5179 acc.update_busy(bi, bc, now);
5180 if (acc.busy[bi][bc] > 0.0) acc.busy[bi][bc] -= 1.0;
5181 acc.update_qlen(bi, bc, now);
5182 acc.held[bi][bc] -= 1.0;
5183 if (!bst.server_blocked[bs]) {
5184 serve_next_on_slot(bi, bs);
5185 sd_reschedule(bi);
5186 }
5187 release_blocked(bi);
5188 }
5189 const RouteEntry re2 = draw_node_route(node, job.cls);
5190 Job onward;
5191 onward.cls = re2.cls;
5192 onward.t_sys = job.t_sys;
5193 onward.parent = job.parent;
5194 deliver(re2.node, onward, M);
5195 return;
5196 }
5197 Job moved = job;
5198 // A closed class completes its passage on reaching its reference
5199 // station: that crossing IS the system completion, and the elapsed time
5200 // is the cycle time.
5201 if (j < M && sn.classes[job.cls].type != lang::JobClassType::OPEN &&
5202 j + 1 == sn.classes[job.cls].refstat) {
5203 sys_resp_sum[job.cls] += now - job.t_sys;
5204 sys_resp_cnt[job.cls] += 1.0;
5205 sys_completed[job.cls] += 1.0;
5206 moved.t_sys = now;
5207 }
5208 if (!admit(j, moved, from) && sn.classes[job.cls].type != lang::JobClassType::OPEN)
5209 throw InputError("SolverLDES (native engine): a closed job was dropped at "
5210 "station '" + sn.stations[j].name +
5211 "'; a closed class cannot lose population");
5212 };
5213
5214 release_delayed_hits = [&](CacheState& cs, std::size_t item, std::size_t node) {
5215 if (item >= cs.held.size() || cs.held[item].empty()) return;
5216 std::vector<CacheState::HeldRequest> freed;
5217 freed.swap(cs.held[item]);
5218 for (std::size_t i = 0; i < freed.size(); ++i) {
5219 const CacheState::HeldRequest& hr = freed[i];
5220 cs.delayed[hr.cls] += 1.0;
5221 cs.delayed_wait += now - hr.hold_time;
5222 Job out;
5223 out.cls = (cs.hit_class[hr.cls] >= 0) ? static_cast<std::size_t>(cs.hit_class[hr.cls])
5224 : hr.cls;
5225 out.t_sys = hr.t_sys;
5226 const RouteEntry e = draw_node_route(node, out.cls);
5227 out.cls = e.cls;
5228 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
5229 ++total_completions;
5230 deliver(e.node, out, M);
5231 }
5232 };
5233
5234
5235 /**
5236 * The marking as one flat vector over (place slot, class), `p * K + r`,
5237 * which is what a mode's arcs are indexed by.
5238 *
5239 * SUMMED OVER CLASSES UNTIL 2026-08-12, which made every arc colourless and
5240 * let a token of one class satisfy another's pre-arc.
5241 */
5242 auto flat_marking = [&]() {
5243 std::vector<double> tok(place_nodes.size() * K, 0.0);
5244 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5245 for (std::size_t r = 0; r < K; ++r) tok[p * K + r] = marking[p][r];
5246 return tok;
5247 };
5248
5249 /**
5250 * The tokens the ARCS can see, in the same layout.
5251 *
5252 * Identical to the marking at an ordinary place and the DEPOSITORY at a
5253 * queueing one, which is the whole difference between the two: enabling,
5254 * inhibiting and the immediate pick are decided on this vector, while a
5255 * MARKING-DEPENDENT FIRING RATE is still a function of the marking, because
5256 * `Transition.setFiringRateDependence` is declared over token counts and a
5257 * place's token count includes the ones it is still serving.
5258 */
5259 auto flat_avail = [&]() {
5260 std::vector<double> tok(place_nodes.size() * K, 0.0);
5261 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5262 for (std::size_t r = 0; r < K; ++r) tok[p * K + r] = avail[p][r];
5263 return tok;
5264 };
5265
5266 /**
5267 * Move the tokens of one firing of `m`.
5268 *
5269 * CONSUME FIRST AND DEPOSIT SECOND, which is what a self-loop arc needs: a
5270 * mode that takes a token from a queueing place and puts one back takes the
5271 * SERVED one out of the depository and queues the new one for service again.
5272 * Netting the two against each other would leave the token where it was and
5273 * the place would never serve anything.
5274 */
5275 auto spn_apply = [&](const SpnMode& m) {
5276 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5277 for (std::size_t r = 0; r < K; ++r) {
5278 const double e = m.enabling[p * K + r];
5279 if (e > 0.0) place_take(p, r, e);
5280 }
5281 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5282 for (std::size_t r = 0; r < K; ++r) {
5283 const double f = m.firing[p * K + r];
5284 if (f > 0.0) place_deposit(p, r, f);
5285 }
5286 // A token leaving the net to a Sink completes a passage with no response time to
5287 // charge; one sent to a station enters it as a fresh job, as `routeTokenFromTransition`.
5288 for (const auto& ex : m.exits) {
5289 const std::size_t nd = std::get<0>(ex), r = std::get<1>(ex);
5290 const double n = std::get<2>(ex);
5291 if (sn.nodes[nd - 1].nodetype == NodeType::Sink) {
5292 sys_completed[r] += n;
5293 continue;
5294 }
5295 for (double t = 0.0; t + 0.5 < n; t += 1.0) {
5296 Job job;
5297 job.cls = r;
5298 job.t_sys = now;
5299 deliver(nd, job, M);
5300 }
5301 }
5302 };
5303
5304 /**
5305 * Fire every enabled IMMEDIATE mode, then arm the timed ones.
5306 *
5307 * The immediate modes are exhausted FIRST and to a fixed point: a marking
5308 * that enables one is not a marking the net rests in, so letting a timed
5309 * mode fire from it would visit a state the model does not have. The guard
5310 * bounds a net whose immediate modes form a cycle, which is a modelling
5311 * error rather than something to simulate forever.
5312 */
5313 std::uint64_t fire_tag = 0;
5314 // The tags of the clocks each (transition, mode) runs; an event whose tag is gone was cancelled.
5315 std::vector<std::vector<std::vector<std::uint64_t>>> spn_clocks(transitions.size());
5316 for (std::size_t t = 0; t < transitions.size(); ++t)
5317 spn_clocks[t].resize(transitions[t].modes.size());
5318 spn_settle = [&]() {
5319 if (transitions.empty()) return;
5320 for (int guard = 0; guard < 1000000; ++guard) {
5321 bool fired_any = false;
5322 std::vector<double> tok = flat_avail();
5323 for (std::size_t t = 0; t < transitions.size(); ++t) {
5324 const int k = spn_pick_immediate(transitions[t].modes, tok, uniform01(g_routing));
5325 if (k < 0) continue;
5326 const SpnMode& m = transitions[t].modes[static_cast<std::size_t>(k)];
5327 // The tokens move in THEIR OWN CLASS: an arc names a (place,
5328 // class) pair, so a Class2 pre-arc consumes Class2 tokens.
5329 spn_apply(m);
5330 transitions[t].fired[static_cast<std::size_t>(k)] += 1.0;
5331 ++total_completions;
5332 fired_any = true;
5333 break;
5334 }
5335 if (!fired_any) break;
5336 }
5337 // EVERY timed mode runs min(servers, enabling degree) clocks of its own law on the full
5338 // marking, as Solver_ssj's phase 2. A clock survives a marking change it does not lose its
5339 // enabling to, so a non-exponential law keeps its age; excess clocks are cancelled
5340 // uniformly at random. A marking-dependent mode is resampled on every change instead,
5341 // because its rate is a function of the marking (exact for an exponential law).
5342 const std::vector<double> tok = flat_avail();
5343 const std::vector<double> mk = flat_marking();
5344 for (std::size_t t = 0; t < transitions.size(); ++t)
5345 for (std::size_t k = 0; k < transitions[t].modes.size(); ++k) {
5346 const SpnMode& m = transitions[t].modes[k];
5347 if (m.immediate) continue;
5348 std::vector<std::uint64_t>& live = spn_clocks[t][k];
5349 const bool on = spn_enabled(m, tok) && !spn_law[t][k].disabled();
5350 double g = 1.0;
5351 if (on && m.dep) {
5352 g = m.dep(mk);
5353 live.clear();
5354 }
5355 const std::size_t target =
5356 (on && g > 0.0) ? static_cast<std::size_t>(spn_active_servers(m, tok)) : 0;
5357 while (live.size() > target) {
5358 const std::size_t pick = std::min(
5359 live.size() - 1, static_cast<std::size_t>(uniform01(g_spn) * live.size()));
5360 live.erase(live.begin() + static_cast<std::ptrdiff_t>(pick));
5361 }
5362 while (live.size() < target) {
5363 Event e;
5364 e.t = now + spn_law[t][k].next(g_spn) / g;
5365 e.kind = EV_FIRING;
5366 e.station = t;
5367 e.cls = k;
5368 e.tag = ++fire_tag;
5369 live.push_back(e.tag);
5370 push(e);
5371 }
5372 }
5373 };
5374
5375 // ---- initial state -----------------------------------------------------
5376 /**
5377 * WARM START, `--initsol`: the placement a companion solver's steady state
5378 * implies, as a STATION-MAJOR vector [st0_cl0, ..., stM-1_clK-1].
5379 *
5380 * It REPLACES the reference-station placement of the closed classes; the
5381 * open arrival streams are armed either way. A Petri net is exempt: its
5382 * tokens live in Places and not in the service queues this vector
5383 * describes, so a station-based placement would put nothing anywhere and
5384 * then fail the conservation check.
5385 *
5386 * CLASSES DESCEND WITHIN A STATION. Every initial job arrives at time 0, so
5387 * under an order-preserving discipline the LAST one injected holds the
5388 * server; ascending order puts the highest-index class in service, which is
5389 * the mirror of what `State.initDefault` encodes and selects the other
5390 * closed communicating class on a chain made reducible by non-overtaking
5391 * routing -- a different stationary distribution, not a different tie-break.
5392 */
5393 /**
5394 * The stations whose job-level state init_jobs and track_jobs can carry:
5395 * FCFS queues, the PS family and Delays, in a model with no feature that
5396 * keeps state outside the jobs' residual work. Refused by name otherwise,
5397 * as `ldes_engine_reject` does, because a restored run that silently lost
5398 * that state reports numbers nothing downstream can tell from correct ones.
5399 */
5400 auto job_state_check = [&](std::size_t i) {
5401 const StationState& s = S[i];
5402 std::string why;
5403 if (!place_nodes.empty() || !regions.empty() || !sn.fj.empty() || has_spawn)
5404 why = "Petri-net places, finite capacity regions, fork-join or spawning";
5405 else if (has_removal_signal || has_sync_call)
5406 why = "G-network removal signals or synchronous calls";
5407 else if (has_gd || slotted)
5408 why = "global state dependence or slotted time";
5409 else if (!s.lld.empty() || s.has_cd || s.state_dependent)
5410 why = "load-, class- or state-dependent service";
5411 else if (s.pas || s.polling || s.bmsp)
5412 why = "pass-and-swap, polling or batch service";
5413 else if (s.has_setup || s.has_breakdown || s.has_pools || s.has_parallelism)
5414 why = "setup, breakdowns, server pools or server parallelism";
5415 else if (s.role == Role::Queue && !s.ps && s.sched != SchedStrategy::FCFS)
5416 why = std::string("scheduling ") + lang::sched_to_text(s.sched) +
5417 " (supported: FCFS, the PS family and Delay)";
5418 else
5419 for (std::size_t r = 0; r < K && why.empty(); ++r) {
5420 if (s.off[r]) continue;
5421 if ((r < s.has_retrial.size() && s.has_retrial[r]) ||
5422 (r < s.has_patience.size() && s.has_patience[r]) || s.svc[r].time_varying())
5423 why = "retrial, reneging or a time-varying service law";
5424 }
5425 if (!why.empty())
5426 throw UnsupportedError(ErrorKind{"InitJobs"},
5427 "SolverLDES (native engine): init_jobs/track_jobs do not support station '" +
5428 sn.stations[i].name + "': " + why);
5429 };
5430
5431 /**
5432 * JOB-LEVEL INITIAL STATE, `options.init_jobs`, one row per job:
5433 * [station, class, remaining, elapsed, qage, sage, order, id].
5434 *
5435 * Each row carries the job's residual work, so a run continues exactly where
5436 * an earlier one stopped; `finalJobs` is the export in the same layout. A
5437 * positive `remaining` is used as given; -1 draws a full service time and -2
5438 * draws the residual given the attained `elapsed`, by rejection (S given
5439 * S > elapsed), which is exact for any law. qage and sage become arrival
5440 * instants in the past, so response times count the time already spent.
5441 * Rows are injected per station in increasing `order`: the first ones take
5442 * the free servers and the rest wait in that order, which the FCFS heap
5443 * honours only if the arrival instants increase strictly, so they are
5444 * nudged by one ulp where `qage` ties or disagrees with `order`.
5445 */
5446 if (init_jobs_on) {
5447 const Matrix<double>& J = o.init_jobs;
5448 if (J.cols() < 7)
5449 throw InputError("SolverLDES (native engine): init_jobs needs the columns [station, class, "
5450 "remaining, elapsed, qage, sage, order(, id)], got " +
5451 std::to_string(J.cols()) + " columns");
5452 std::vector<std::size_t> rows(J.rows());
5453 std::iota(rows.begin(), rows.end(), std::size_t(0));
5454 std::stable_sort(rows.begin(), rows.end(), [&](std::size_t a, std::size_t b) {
5455 if (J(a, 0) != J(b, 0)) return J(a, 0) < J(b, 0);
5456 return J(a, 6) < J(b, 6);
5457 });
5458 std::size_t last_station = M;
5459 double last_arrival = -std::numeric_limits<double>::infinity();
5460 for (std::size_t row : rows) {
5461 const double sv = J(row, 0), kv = J(row, 1);
5462 if (!(sv >= 0.0 && sv < static_cast<double>(M) && kv >= 0.0 && kv < static_cast<double>(K)))
5463 throw InputError("SolverLDES (native engine): init_jobs row " + std::to_string(row) +
5464 " names station " + std::to_string(sv) + ", class " + std::to_string(kv) +
5465 "; the model has " + std::to_string(M) + " stations and " +
5466 std::to_string(K) + " classes");
5467 const std::size_t i = static_cast<std::size_t>(sv), k = static_cast<std::size_t>(kv);
5468 if (S[i].role != Role::Queue && S[i].role != Role::Delay)
5469 throw InputError("SolverLDES (native engine): init_jobs row " + std::to_string(row) +
5470 " places a job at station '" + sn.stations[i].name +
5471 "', which is not a Queue or Delay");
5472 job_state_check(i);
5473 const double remaining = J(row, 2), el = J(row, 3), qage = J(row, 4), sage = J(row, 5);
5474 if (!(el >= 0.0 && qage >= 0.0 && sage >= qage))
5475 throw InputError("SolverLDES (native engine): init_jobs row " + std::to_string(row) +
5476 " needs elapsed >= 0 and sage >= qage >= 0");
5477 Job job;
5478 job.cls = k;
5479 job.t_sys = now - sage;
5480 if (i != last_station) {
5481 last_station = i;
5482 last_arrival = -std::numeric_limits<double>::infinity();
5483 }
5484 job.t_arr = std::max(now - qage,
5485 std::nextafter(last_arrival, std::numeric_limits<double>::infinity()));
5486 last_arrival = job.t_arr;
5487 job.ext = (J.cols() >= 8) ? static_cast<std::int64_t>(J(row, 7)) : -1;
5488 if (remaining > 0.0) {
5489 job.service = remaining;
5490 } else if (remaining == -2.0) {
5491 const int max_tries = 1000000;
5492 int t = 0;
5493 for (; t < max_tries; ++t) {
5494 const double v = S[i].svc[k].next_at(g_svc[i][k], now);
5495 if (v > el) {
5496 job.service = v - el;
5497 break;
5498 }
5499 }
5500 if (t == max_tries)
5501 throw InputError("SolverLDES (native engine): init_jobs could not draw a residual for "
5502 "class '" + sn.classes[k].name + "' with elapsed = " +
5503 std::to_string(el) + " (P(S > elapsed) is below 1e-6); give it explicitly");
5504 } else if (remaining != -1.0) {
5505 throw InputError("SolverLDES (native engine): init_jobs row " + std::to_string(row) +
5506 " has remaining = " + std::to_string(remaining) +
5507 "; use a positive residual, -1 (draw a full service) or -2 (draw given elapsed)");
5508 }
5509 if (job.service > 0.0) {
5510 job.preset = true;
5511 job.remaining = job.service;
5512 job.elapsed = el;
5513 }
5514 pending_preset = true;
5515 if (!admit(i, job, M))
5516 throw InputError("SolverLDES (native engine): init_jobs row " + std::to_string(row) +
5517 " does not fit station '" + sn.stations[i].name + "'");
5518 }
5519 for (std::size_t r = 0; r < K; ++r) {
5520 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
5521 double held = 0.0;
5522 for (std::size_t i = 0; i < M; ++i) held += acc.qlen[i][r];
5523 const double want = sn.classes[r].population;
5524 if (std::fabs(held - want) > 0.5)
5525 throw InputError("SolverLDES (native engine): init_jobs holds " +
5526 std::to_string(static_cast<long>(held + 0.5)) + " jobs of class '" +
5527 sn.classes[r].name + "' against a population of " +
5528 std::to_string(static_cast<long>(want + 0.5)));
5529 }
5530 }
5531 // The placement is `init_sol` when given, else the model's declared initial state.
5532 const std::vector<double> placement =
5533 init_jobs_on ? std::vector<double>()
5534 : (!o.init_sol.empty() ? o.init_sol : ldes_declared_placement(sn));
5535 const bool warm_start = !init_jobs_on && !placement.empty() && place_nodes.empty();
5536 if (warm_start) {
5537 for (std::size_t i = 0; i < M; ++i) {
5538 if (S[i].role != Role::Queue && S[i].role != Role::Delay) continue;
5539 for (std::size_t k = K; k-- > 0;) {
5540 const std::size_t idx = i * K + k;
5541 if (idx >= placement.size()) continue;
5542 const double v = placement[idx];
5543 if (!(v > 0.0)) continue;
5544 const std::size_t count = static_cast<std::size_t>(v);
5545 for (std::size_t j = 0; j < count; ++j) {
5546 Job job;
5547 job.cls = k;
5548 job.t_sys = 0.0;
5549 if (!admit(i, job, M))
5550 throw InputError("SolverLDES (native engine): the warm-start placement "
5551 "of class '" + sn.classes[k].name + "' does not fit "
5552 "station '" + sn.stations[i].name + "'");
5553 }
5554 }
5555 }
5556 // A closed class must be placed in full: a warm start that loses jobs is
5557 // a different model, and the loss is invisible in every mean it reports.
5558 for (std::size_t r = 0; r < K; ++r) {
5559 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
5560 double held = 0.0;
5561 for (std::size_t i = 0; i < M; ++i) held += acc.qlen[i][r];
5562 const double want = sn.classes[r].population;
5563 if (std::fabs(held - want) > 0.5)
5564 throw InputError("SolverLDES (native engine): the warm-start placement holds " +
5565 std::to_string(static_cast<long>(held + 0.5)) + " jobs of class '" +
5566 sn.classes[r].name + "' against a population of " +
5567 std::to_string(static_cast<long>(want + 0.5)));
5568 }
5569 }
5570 // Every breakdown-carrying station starts UP with its first failure armed,
5571 // which is the reference's initial condition: a model that started down
5572 // would report a transient nobody asked for.
5573 for (std::size_t i = 0; i < M; ++i) {
5574 if (!S[i].has_breakdown) continue;
5575 Event e;
5576 e.t = S[i].failure_time.next_at(g_aux[i], 0.0);
5577 e.kind = EV_BREAKDOWN;
5578 e.station = i;
5579 push(e);
5580 }
5581
5582 std::vector<Sampler> arrival(K);
5583 std::vector<double> lambda(K, 0.0);
5584 // `Source.setArrivalBatch`: a batch-size law bolted onto a renewal stream, drawn
5585 // alongside the interarrival time and consumed by the release loop below. It is
5586 // the SEPARATE mechanism from a process that carries its own sizes -- the two are
5587 // refused together above -- and it rides a stream of its own on the reference's
5588 // own `seed + 991*(srcIdx+1) + 97*(k+1)` band, so adding a batch law to a model
5589 // does not shift any other draw. The LAW matches the reference; the sample path
5590 // does not, because the reference inverts its discrete families off
5591 // java.util.Random and these invert off the MRG stream.
5592 std::vector<Sampler> arr_batch(K);
5593 std::vector<bool> has_arr_batch(K, false);
5594 std::vector<Rng> g_arr_batch;
5595 g_arr_batch.reserve(K);
5596 for (std::size_t k = 0; k < K; ++k)
5597 g_arr_batch.push_back(Rng(seed_ll, 991LL + 97LL * (static_cast<long long>(k) + 1LL)));
5598 if (source_st < M) {
5599 const auto& sb = sn.stations[source_st].arrival_batch;
5600 for (std::size_t r = 0; r < K && r < sb.size(); ++r) {
5601 if (sb[r].disabled) continue;
5602 arr_batch[r] = Sampler(sb[r], "the arrival batch of class '" + sn.classes[r].name +
5603 "' at station '" + sn.stations[source_st].name + "'");
5604 has_arr_batch[r] = true;
5605 }
5606 }
5607
5608 // ---- the Source's mark -> class binding -------------------------------
5609 //
5610 // `marked_classes` is 1-based and ordered by mark, so mark k names class
5611 // `mark_class[k-1]` in the 0-based form used here. The FIRST marked class is the
5612 // CARRIER: only its stream is armed, and every arrival it produces is routed to
5613 // the class its mark names. The other marked classes share the one modulating
5614 // chain and must NOT arm streams of their own, or the model would run K
5615 // independent copies of the process instead of one marked process. This mirrors
5616 // `Solver_ssj`'s markedGroupClass / markedCarrierClass.
5617 //
5618 // Their SOURCE THROUGHPUT is the per-mark rate pie*D1k*e, not the aggregate: the
5619 // whole binding shares one Distrib, so `1/mean()` would report the total stream
5620 // K times over.
5621 std::vector<std::size_t> mark_class;
5622 std::vector<double> mark_rate;
5623 std::size_t mark_carrier = K;
5624 if (source_st < M) {
5625 for (std::size_t c : sn.stations[source_st].marked_classes)
5626 if (c >= 1 && c <= K) mark_class.push_back(c - 1);
5627 }
5628 /**
5629 * The width-weighted time average of one segment list, i.e. the nominal block.
5630 *
5631 * `sched_dist` builds the nominal D0/D1 this way and `mmapt` the nominal per-mark
5632 * blocks; this is the same average applied to a (mark, batch) list, which nothing
5633 * stores because `Dmark` carries the MARK reading for a BMMAPt.
5634 */
5635 auto nominal_of = [](const std::vector<T>& bp, const std::vector<Matrix<T>>& segs) {
5636 const T zero = num_traits<T>::from_int(0);
5637 Matrix<T> acc(segs[0].rows(), segs[0].cols(), zero);
5638 T total = zero;
5639 for (std::size_t k = 0; k + 1 < bp.size(); ++k) total += T(bp[k + 1] - bp[k]);
5640 for (std::size_t k = 0; k < segs.size(); ++k) {
5641 const T w = T(T(bp[k + 1] - bp[k]) / total);
5642 for (std::size_t a = 0; a < acc.rows(); ++a)
5643 for (std::size_t b = 0; b < acc.cols(); ++b) acc(a, b) += w * segs[k](a, b);
5644 }
5645 return acc;
5646 };
5647 /**
5648 * JOBS PER EPOCH of an arrival process that carries its own batch sizes, and 1.0
5649 * for one that does not.
5650 *
5651 * A batch epoch is ONE EVENT and `b` JOBS, so the Source row of TN -- which is
5652 * analytic here, not counted -- has to report the JOB rate or it will not balance
5653 * against the counted throughput of the stations downstream. Getting this wrong
5654 * reads an M[3]/M/2 as an M/M/2 at a third of the task rate, which is exactly the
5655 * failure the old `arrival_batch` refusal existed to prevent. An unbatched
5656 * process returns 1.0, so every existing model's Source row is untouched.
5657 */
5658 auto mean_batch_of = [&](const lang::Distrib<T>& cd) -> double {
5659 std::vector<Matrix<T>> blocks;
5660 std::vector<std::size_t> sizes;
5661 if (cd.type == lang::ProcessType::BMAP) {
5662 for (std::size_t b = 0; b < cd.Dmark.size(); ++b) {
5663 blocks.push_back(cd.Dmark[b]);
5664 sizes.push_back(b + 1);
5665 }
5666 } else if (cd.has_batch_schedule()) {
5667 for (std::size_t c = 0; c < cd.sched_Dbatch.size(); ++c)
5668 for (std::size_t b = 0; b < cd.sched_Dbatch[c].size(); ++b) {
5669 blocks.push_back(nominal_of(cd.sched_bp, cd.sched_Dbatch[c][b]));
5670 sizes.push_back(b + 1);
5671 }
5672 }
5673 if (blocks.empty()) return 1.0;
5674 mam::Mmap<T> mm;
5675 mm.D0 = cd.D0;
5676 mm.D1 = cd.D1;
5677 mm.Dc = blocks;
5678 const std::vector<T> lam = mam::mmap_lambda(mm);
5679 double epochs = 0.0, jobs = 0.0;
5680 for (std::size_t b = 0; b < blocks.size(); ++b) {
5681 const double v = num_traits<T>::to_double(lam[b]);
5682 epochs += v;
5683 jobs += static_cast<double>(sizes[b]) * v;
5684 }
5685 return (epochs > 0.0) ? (jobs / epochs) : 1.0;
5686 };
5687 if (!mark_class.empty()) {
5688 mark_carrier = mark_class[0];
5689 const lang::Distrib<T>& cd = sn.service[source_st][mark_carrier];
5690 if (cd.Dmark.size() < mark_class.size())
5691 throw InputError("SolverLDES (native engine): the Source binds " +
5692 std::to_string(mark_class.size()) +
5693 " marks but its arrival process carries only " +
5694 std::to_string(cd.Dmark.size()) + " marked blocks");
5695 if (cd.has_batch_schedule()) {
5696 // A BMMAPt's per-mark JOB rate, sum_b b * theta * D_(c,b) * e. Through the
5697 // same `mmap_lambda` as the unbatched case, on the (mark, batch) nominals
5698 // flattened mark-major: the weighting is by the STATIONARY phase
5699 // distribution and must not be re-implemented per axis.
5700 const std::size_t B = cd.sched_Dbatch[0].size();
5701 std::vector<Matrix<T>> blocks;
5702 for (std::size_t c = 0; c < cd.sched_Dbatch.size(); ++c)
5703 for (std::size_t b = 0; b < B; ++b)
5704 blocks.push_back(nominal_of(cd.sched_bp, cd.sched_Dbatch[c][b]));
5705 mam::Mmap<T> mm;
5706 mm.D0 = cd.D0;
5707 mm.D1 = cd.D1;
5708 mm.Dc = blocks;
5709 const std::vector<T> lam = mam::mmap_lambda(mm);
5710 for (std::size_t k = 0; k < mark_class.size(); ++k) {
5711 double jobs = 0.0;
5712 for (std::size_t b = 0; b < B; ++b)
5713 jobs += static_cast<double>(b + 1) *
5714 num_traits<T>::to_double(lam[k * B + b]);
5715 mark_rate.push_back(jobs);
5716 }
5717 } else {
5718 mam::Mmap<T> mm;
5719 mm.D0 = cd.D0;
5720 mm.D1 = cd.D1;
5721 mm.Dc = cd.Dmark;
5722 const std::vector<T> lam = mam::mmap_lambda(mm);
5723 for (std::size_t k = 0; k < mark_class.size(); ++k)
5724 mark_rate.push_back(num_traits<T>::to_double(lam[k]));
5725 }
5726 }
5727 // True for a marked class that is not the carrier.
5728 const std::vector<std::size_t>& mkc = mark_class;
5729 auto marked_non_carrier = [&mkc, &mark_carrier](std::size_t r) {
5730 if (mkc.empty() || r == mark_carrier) return false;
5731 for (std::size_t c : mkc)
5732 if (c == r) return true;
5733 return false;
5734 };
5735
5736 for (std::size_t r = 0; r < K; ++r) {
5737 if (sn.classes[r].type == lang::JobClassType::OPEN) {
5738 if (source_st >= M)
5739 throw InputError("SolverLDES (native engine): an open class with no Source");
5740 if (S[source_st].off[r]) continue;
5741 arrival[r] = S[source_st].svc[r];
5742 // The EPOCH rate, scaled to a JOB rate by the mean batch each epoch
5743 // releases: its own, for a BMAP or BMMAPt, or the separate
5744 // `Source.setArrivalBatch` law. Both are 1 for an unbatched stream.
5745 lambda[r] = mean_batch_of(sn.service[source_st][r]) / arrival[r].mean();
5746 if (has_arr_batch[r]) lambda[r] = arr_batch[r].mean() / arrival[r].mean();
5747 for (std::size_t k = 0; k < mark_class.size(); ++k)
5748 if (mark_class[k] == r) lambda[r] = mark_rate[k];
5749 // A non-carrier receives its jobs through the carrier's arrivals.
5750 if (marked_non_carrier(r)) continue;
5751 Event e;
5752 e.t = slot_snap(arrival[r].next_at(g_arr[r], 0.0), "interarrival time");
5753 e.kind = EV_ARRIVAL;
5754 e.station = source_st;
5755 e.cls = r;
5756 push(e);
5757 } else {
5758 if (warm_start || init_jobs_on) continue; // already placed from the warm start
5759 const double n = sn.classes[r].population;
5760 if (!(n >= 0.0) || !std::isfinite(n))
5761 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
5762 "' has a population that is not a finite count");
5763 const std::size_t refst = sn.classes[r].refstat;
5764 if (refst == 0 || refst > M)
5765 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
5766 "' has no reference station");
5767 // A CLOSED CLASS OF A PETRI NET IS ALREADY PLACED: its jobs are
5768 // TOKENS and they live in the initial marking, which the SPN section
5769 // above widened by exactly this population. Admitting them here as
5770 // well would both double the population and fail outright, since a
5771 // place serves no job through `admit` -- which is how every Petri
5772 // net reaching this engine used to die, queueing or not, under a
5773 // message about a station that "does not serve" the class.
5774 // `initClosedClassPopulations` skips the injection on a Place for
5775 // the same reason.
5776 if (sn.stations[refst - 1].nodetype == NodeType::Place) continue;
5777 const std::size_t count = static_cast<std::size_t>(n + 0.5);
5778 for (std::size_t j = 0; j < count; ++j) {
5779 Job job;
5780 job.cls = r;
5781 job.t_sys = 0.0;
5782 if (!admit(refst - 1, job, M))
5783 throw InputError("SolverLDES (native engine): the initial population of "
5784 "class '" + sn.classes[r].name +
5785 "' does not fit its reference station's capacity");
5786 }
5787 }
5788 }
5789
5790 // ---- convergence-based stopping -----------------------------------------
5791 Convergence cnvg;
5792 cnvg.init(M, K, o, max_events);
5793 std::vector<std::size_t> servers_of(M, 1);
5794 std::vector<std::vector<bool>> off_of(M, std::vector<bool>(K, true));
5795 for (std::size_t i = 0; i < M; ++i) {
5796 servers_of[i] = S[i].nservers;
5797 for (std::size_t r = 0; r < K; ++r)
5798 off_of[i][r] = S[i].off[r] || S[i].role == Role::Source ||
5799 S[i].role == Role::Synchronization;
5800 }
5801 std::uint64_t last_cnvg_events = 0;
5802 bool converged = false;
5803
5804 // The initial tokens of a queueing place are queued, not deposited, so the
5805 // embedded queues have to be started before the net can settle: until a
5806 // token has been served it is in no depository and no output arc sees it.
5807 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5808 if (qplace[p]) place_try_start(p);
5809 // The immediate modes settle the initial marking before any clock runs.
5810 spn_settle();
5811
5812 // ---- MSER-5 and batch-means bookkeeping --------------------------------
5813 Observations obs(M, K, o.tranfilter == "mser5", o.mserbatch > 0 ? o.mserbatch : 5);
5814 for (std::size_t i = 0; i < M; ++i)
5815 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) obs.in_mser[i] = 0;
5816 std::uint64_t mser_interval = max_events / 1000ULL;
5817 if (mser_interval < 1) mser_interval = 1;
5818 std::uint64_t last_mser_events = 0;
5819
5820
5821 // ---- transient sampling and the joint-state histogram ---------------------
5822 /**
5823 * A TRANSIENT run is bounded by SIMULATED TIME, not by completions.
5824 *
5825 * `options.timespan = [t0, t1]` sets the horizon and the completion budget
5826 * is then ignored, exactly as the reference does: a transient trajectory is
5827 * a function of time, so stopping on an event count would end it at a
5828 * different instant on every path and make the series incomparable across
5829 * seeds.
5830 */
5831 const bool transient_run = o.has_timespan && std::isfinite(o.t1) && o.t1 > o.t0;
5832 const double horizon = transient_run ? o.t1 : std::numeric_limits<double>::infinity();
5833 // o.tranobs points when given, else ~1000: enough to draw and few enough that
5834 // the series is not itself a memory problem on a long horizon. Same default as the JAR.
5835 const double tran_points = (o.tranobs > 0) ? static_cast<double>(o.tranobs) : 1000.0;
5836 const double tran_interval = transient_run ? (o.t1 - o.t0) / tran_points : 0.0;
5837 double next_tran_sample = transient_run ? o.t0 + tran_interval : 0.0;
5838 double last_tran_time = transient_run ? o.t0 : 0.0;
5839 std::vector<double> tran_times;
5840 std::vector<std::vector<std::vector<double>>> tran_q(M, std::vector<std::vector<double>>(K));
5841 std::vector<std::vector<std::vector<double>>> tran_u(M, std::vector<std::vector<double>>(K));
5842 std::vector<std::vector<std::vector<double>>> tran_t(M, std::vector<std::vector<double>>(K));
5843 std::vector<std::vector<double>> last_tran_q(M, std::vector<double>(K, 0.0));
5844 std::vector<std::vector<double>> last_tran_b(M, std::vector<double>(K, 0.0));
5845 std::vector<std::vector<double>> last_tran_c(M, std::vector<double>(K, 0.0));
5846
5847 /**
5848 * The EXACT joint-state residence time, keyed by the aggregate state row.
5849 *
5850 * `histogram_space` plus `histogram_time` let a caller evaluate its own
5851 * reward on the empirical distribution, INCLUDING a nonlinear one:
5852 * E[r] = sum_s (t_s / sum t) r(state_s) is exact only because the residence
5853 * time of each distinct state is kept, which a trajectory of means cannot
5854 * reconstruct.
5855 */
5856 std::map<std::vector<int>, double> histogram;
5857 std::vector<std::pair<double, std::vector<int>>> trajectory;
5858 double hist_last = 0.0;
5859 // A transient run needs the state walk anyway; a steady-state one pays for
5860 // it only when asked.
5861 const bool want_traj = transient_run || o.export_trajectory;
5862 const bool want_hist = transient_run || o.export_histogram || want_traj;
5863
5864 auto joint_state = [&]() {
5865 std::vector<int> row(M * K, 0);
5866 for (std::size_t i = 0; i < M; ++i)
5867 for (std::size_t r = 0; r < K; ++r)
5868 row[i * K + r] = static_cast<int>(acc.qlen[i][r] + acc.held[i][r] + 0.5);
5869 return row;
5870 };
5871 auto hist_accumulate = [&]() {
5872 if (!want_hist) return;
5873 const double dt = now - hist_last;
5874 if (dt > 0.0) {
5875 const std::vector<int> row = joint_state();
5876 histogram[row] += dt;
5877 if (want_traj) trajectory.push_back(std::make_pair(hist_last, row));
5878 }
5879 hist_last = now;
5880 };
5881
5882 auto tran_sample = [&]() {
5883 const double dt = now - last_tran_time;
5884 tran_times.push_back(now);
5885 for (std::size_t i = 0; i < M; ++i)
5886 for (std::size_t r = 0; r < K; ++r) {
5887 if (dt > 0.0) {
5888 tran_q[i][r].push_back((acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt);
5889 const double c = S[i].util_peak;
5890 tran_u[i][r].push_back(
5891 (S[i].role == Role::Delay)
5892 ? (acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt
5893 : (acc.tot_busy[i][r] - last_tran_b[i][r]) / (dt * c));
5894 tran_t[i][r].push_back((acc.completed[i][r] - last_tran_c[i][r]) / dt);
5895 } else {
5896 tran_q[i][r].push_back(acc.qlen[i][r] + acc.held[i][r]);
5897 tran_u[i][r].push_back(0.0);
5898 tran_t[i][r].push_back(0.0);
5899 }
5900 last_tran_q[i][r] = acc.tot_qlen[i][r];
5901 last_tran_b[i][r] = acc.tot_busy[i][r];
5902 last_tran_c[i][r] = acc.completed[i][r];
5903 }
5904 last_tran_time = now;
5905 };
5906
5907 // ---- the event loop ----------------------------------------------------
5908 while (!evq.empty() && (transient_run || total_completions < max_events)) {
5909 const Event ev = evq.top();
5910 if (transient_run && ev.t > horizon) break;
5911 evq.pop();
5912 now = ev.t;
5913
5914 // THE STATE IS WALKED BEFORE THE EVENT IS APPLIED, so the interval just
5915 // ended is charged to the state that held during it. A steady-state run
5916 // asked for the histogram takes the same walk and nothing else.
5917 if (want_hist) hist_accumulate();
5918 if (transient_run) {
5919 while (now >= next_tran_sample && next_tran_sample <= horizon) {
5920 const double save = now;
5921 now = next_tran_sample;
5922 for (std::size_t a = 0; a < M; ++a) {
5923 if (S[a].ps) ps_advance(a);
5924 for (std::size_t r = 0; r < K; ++r) {
5925 acc.update_qlen(a, r, now);
5926 acc.update_busy(a, r, now);
5927 }
5928 }
5929 tran_sample();
5930 next_tran_sample += tran_interval;
5931 now = save;
5932 }
5933 }
5934
5935 if (ev.kind == EV_BREAKDOWN) {
5936 // FREEZE AT THE OLD RATE, FLIP, RE-TIME AT THE NEW, in that order,
5937 // for the same reason sd_advance runs before a population change:
5938 // the rate in force over the elapsed interval is the one the OLD
5939 // state implied. The stale departures are neutralised by the tag.
5940 StationState& s = S[ev.station];
5941 if (s.ps)
5942 ps_advance(ev.station);
5943 else
5944 sd_advance(ev.station);
5945 s.up = !s.up;
5946 if (s.ps)
5947 ps_reschedule(ev.station);
5948 else
5949 sd_reschedule(ev.station);
5950 // The next transition is drawn from the clock now in force. A job
5951 // stalled by the outage has no departure event at all; the repair's
5952 // reschedule is what gives it one back, with its residual intact.
5953 // `next_at` returns a DURATION, not an instant: it takes `from`
5954 // only so a time-inhomogeneous family can read its own schedule.
5955 // Assigning it straight to `nxt.t` puts the next flip in the PAST,
5956 // which the loop pops immediately and re-arms -- an infinite
5957 // cascade at one simulated instant.
5958 Event nxt;
5959 nxt.t = now + (s.up ? s.failure_time.next_at(g_aux[ev.station], now)
5960 : s.repair_time.next_at(g_aux[ev.station], now));
5961 nxt.kind = EV_BREAKDOWN;
5962 nxt.station = ev.station;
5963 push(nxt);
5964 continue;
5965 }
5966
5967 if (ev.kind == EV_FIRING) {
5968 // A cancelled clock: its mode lost the enabling degree that ran it, or was resampled.
5969 if (ev.station >= transitions.size()) continue;
5970 std::vector<std::uint64_t>& live = spn_clocks[ev.station][ev.cls];
5971 const auto it = std::find(live.begin(), live.end(), ev.tag);
5972 if (it == live.end()) continue;
5973 live.erase(it);
5974 SpnTransition& tr = transitions[ev.station];
5975 const SpnMode& m = tr.modes[ev.cls];
5976 if (spn_enabled(m, flat_avail())) {
5977 spn_apply(m);
5978 tr.fired[ev.cls] += 1.0;
5979 ++total_completions;
5980 }
5981 spn_settle();
5982 continue;
5983 }
5984
5985 /**
5986 * A token finished its embedded service: it leaves the place's servers
5987 * for its depository, where the output arcs can take it.
5988 *
5989 * THE MARKING DOES NOT MOVE. The token is still at the place -- it has
5990 * only changed compartment -- so QN is untouched and only UN and the
5991 * arcs' view of the place change. The next waiting token is taken into
5992 * service before the net settles, which is `placeDepartureActions`.
5993 */
5994 if (ev.kind == EV_PLACE_SVC) {
5995 const std::size_t p = ev.station;
5996 const std::size_t r = ev.cls;
5997 const std::size_t ist = place_station[p];
5998 if (ist < M) {
5999 acc.update_busy(ist, r, now);
6000 place_insvc[p][r] -= 1.0;
6001 acc.busy[ist][r] = place_insvc[p][r];
6002 }
6003 --place_busy[p];
6004 avail[p][r] += 1.0;
6005 place_try_start(p);
6006 spn_settle();
6007 continue;
6008 }
6009
6010 if (ev.kind == EV_SWITCHOVER) {
6011 StationState& sp = S[ev.station];
6012 sp.poll_switching = false;
6013 sp.poll_at = ev.cls;
6014 poll_open_visit(sp);
6015 bool work = false;
6016 for (const Job& j : sp.buffer)
6017 if (j.cls == sp.poll_at) {
6018 work = true;
6019 break;
6020 }
6021 if (work)
6022 poll_serve(ev.station);
6023 else
6024 poll_advance(ev.station);
6025 continue;
6026 }
6027
6028 if (ev.kind == EV_RETRIAL) {
6029 StationState& sr = S[ev.station];
6030 const std::size_t c = ev.cls;
6031 if (ev.tag == 0) sr.const_pending[c] = false; // the CONSTANT policy's shared clock
6032 std::deque<Job>& ob = sr.orbit[c];
6033 std::size_t at = ob.size();
6034 if (ev.tag == 0) {
6035 if (!ob.empty()) at = 0;
6036 } else {
6037 for (std::size_t j = 0; j < ob.size(); ++j)
6038 if (ob[j].id == ev.tag) {
6039 at = j;
6040 break;
6041 }
6042 }
6043 if (at == ob.size()) continue; // the job abandoned the orbit meanwhile
6044 if (retrial_server_free(ev.station, c)) {
6045 const Job job = ob[at];
6046 orbit_leave(ev.station, c, at, false);
6047 sr.retried[c] += 1.0;
6048 // It decided to join long ago, so it does not balk; it is already
6049 // in the station population, so admit does not count it again.
6050 pending_retry = true;
6051 pending_orbit_exit = true;
6052 admit(ev.station, job, M);
6053 } else {
6054 ob[at].attempts += 1;
6055 if (sr.max_attempts[c] > 0 && ob[at].attempts >= sr.max_attempts[c]) {
6056 orbit_leave(ev.station, c, at, true);
6057 sr.retrial_lost[c] += 1.0;
6058 dropped[ev.station][c] += 1.0;
6059 } else if (sr.retrial_policy[c] == 2) {
6060 // the shared clock goes round the orbit, oldest first
6061 const Job jb = ob[at];
6062 ob.erase(ob.begin() + static_cast<std::ptrdiff_t>(at));
6063 ob.push_back(jb);
6064 } else {
6065 arm_retrial(ev.station, c, ob[at].id);
6066 }
6067 }
6068 if (sr.retrial_policy[c] == 2) arm_retrial(ev.station, c, 0);
6069 continue;
6070 }
6071
6072 if (ev.kind == EV_ORBIT_ABANDON) {
6073 StationState& sr = S[ev.station];
6074 const std::size_t c = ev.cls;
6075 std::deque<Job>& ob = sr.orbit[c];
6076 std::size_t at = ob.size();
6077 for (std::size_t j = 0; j < ob.size(); ++j)
6078 if (ob[j].id == ev.tag) {
6079 at = j;
6080 break;
6081 }
6082 if (at == ob.size()) continue; // it reached a server first
6083 orbit_leave(ev.station, c, at, true);
6084 dropped[ev.station][c] += 1.0;
6085 if (sr.retrial_policy[c] == 2) arm_retrial(ev.station, c, 0);
6086 continue;
6087 }
6088
6089 if (ev.kind == EV_SETUP) {
6090 StationState& ss = S[ev.station];
6091 if (ev.slot == 1) {
6092 // A power-down. It is CANCELLED if a job arrived meanwhile: the
6093 // server never went cold, so that job must not pay a setup.
6094 double held = 0.0;
6095 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[ev.station][r];
6096 if (held > 0.0 || now + 1e-12 < ss.delayoff_at) continue;
6097 ss.setup_on = false;
6098 ss.delayoff_at = std::numeric_limits<double>::infinity();
6099 continue;
6100 }
6101 if (ss.setup_on) continue; // superseded
6102 ss.setup_running = false;
6103 ss.setup_on = true;
6104 ss.delayoff_at = std::numeric_limits<double>::infinity();
6105 for (std::size_t sl = 0; sl < ss.nservers && !ss.buffer.empty(); ++sl)
6106 if (!ss.server_busy[sl] && !ss.server_blocked[sl] && !ss.server_held[sl] &&
6107 buffer_has_for_slot(ev.station, sl)) {
6108 Job nextjob = buffer_pop_for_slot(ev.station, sl);
6109 start_service(ev.station, sl, nextjob);
6110 }
6111 sd_reschedule(ev.station);
6112 continue;
6113 }
6114
6115 if (ev.kind == EV_RENEGE) {
6116 // The job abandons only if it is STILL WAITING. Having entered
6117 // service, departed, or been dropped all leave the timer with
6118 // nothing to find, which is the ordinary case and not an error.
6119 StationState& sr = S[ev.station];
6120 std::size_t at = sr.buffer.size();
6121 for (std::size_t j = 0; j < sr.buffer.size(); ++j)
6122 if (sr.buffer[j].id == ev.tag) {
6123 at = j;
6124 break;
6125 }
6126 if (at == sr.buffer.size()) continue;
6127 const std::size_t rc = sr.buffer[at].cls;
6128 sd_advance(ev.station);
6129 acc.update_qlen(ev.station, rc, now);
6130 acc.qlen[ev.station][rc] -= 1.0;
6131 bp.track(ev.station, rc, -1, now);
6132 sr.buffer.erase(sr.buffer.begin() + static_cast<std::ptrdiff_t>(at));
6133 if (sr.sched != SchedStrategy::FSP)
6134 std::make_heap(sr.buffer.begin(), sr.buffer.end(), sr.cmp);
6135 reneged[ev.station][rc] += 1.0;
6136 last_lost[ev.station][rc] = now;
6137 sd_reschedule(ev.station);
6138 continue;
6139 }
6140
6141 if (ev.kind == EV_ARRIVAL) {
6142 // The mark of THIS arrival, read BEFORE the draw below overwrites it with
6143 // the next one's. Same ordering as `Solver_ssj`'s arrivalPendingMark, and
6144 // it is not cosmetic: reading it after the draw would label every job with
6145 // its successor's mark.
6146 const int mark = arrival[ev.cls].last_mark();
6147 // ... and the BATCH SIZE of this arrival, for the same reason and from
6148 // the same two sources: a BMAP or BMMAPt carries its own sizes in the
6149 // blocks that fired, while `Source.setArrivalBatch` is a separate law
6150 // drawn on its own stream. They are mutually exclusive, checked above.
6151 std::size_t batch = 1;
6152 if (arrival[ev.cls].batched()) {
6153 const int b = arrival[ev.cls].last_batch();
6154 if (b > 0) batch = static_cast<std::size_t>(b);
6155 } else if (has_arr_batch[ev.cls]) {
6156 const double sampled = arr_batch[ev.cls].next(g_arr_batch[ev.cls]);
6157 const double rounded = std::floor(sampled + 0.5);
6158 if (!(rounded >= 1.0) || std::fabs(sampled - rounded) > 1e-9)
6159 throw InputError(
6160 "SolverLDES (native engine): the arrival batch of class '" +
6161 sn.classes[ev.cls].name + "' sampled " + std::to_string(sampled) +
6162 ", which is not a positive integer; a batch-size law must be "
6163 "supported on {1,2,...}");
6164 batch = static_cast<std::size_t>(rounded);
6165 }
6166 Event nxt;
6167 const double gap = slot_snap(arrival[ev.cls].next_at(g_arr[ev.cls], now),
6168 "interarrival time");
6169 // A non-cyclic schedule past its horizon yields NO further arrival:
6170 // the stream has ended, and re-arming it at rate zero would loop.
6171 if (!(gap > 0.0) && arrival[ev.cls].time_varying()) continue;
6172 nxt.t = now + gap;
6173 nxt.kind = EV_ARRIVAL;
6174 nxt.station = ev.station;
6175 nxt.cls = ev.cls;
6176 push(nxt);
6177
6178 // A marked Source routes on the mark: the event stays on the carrier
6179 // class, and the job that arrives belongs to the class the mark names.
6180 std::size_t arv_cls = ev.cls;
6181 if (mark > 0 && static_cast<std::size_t>(mark) <= mark_class.size())
6182 arv_cls = mark_class[static_cast<std::size_t>(mark) - 1];
6183
6184 // ONE EPOCH, `batch` JOBS, ALL OF THE SAME CLASS. The route is redrawn
6185 // per member, matching the reference's `selectDestination` inside its own
6186 // release loop: a batch that took one route for all of its members would
6187 // correlate their destinations, which is a different model wherever the
6188 // Source feeds more than one station.
6189 for (std::size_t b = 0; b < batch; ++b) {
6190 const RouteEntry e = draw_node_route(sn.station_to_node[ev.station], arv_cls);
6191 Job job;
6192 job.cls = e.cls;
6193 job.t_sys = now;
6194 deliver(e.node, job, M);
6195 }
6196 continue;
6197 }
6198
6199 // ---- a service completion ------------------------------------------
6200 const std::size_t i = ev.station;
6201 StationState& s = S[i];
6202 Job job;
6203
6204 /**
6205 * A BULK SERVER'S FIRING. One event, min(b, present) completions.
6206 *
6207 * TWO PHASES, and the order is load-bearing: every served job is removed
6208 * and recorded BEFORE any of them is routed, because a job routed back to
6209 * this same station would otherwise re-arm the clock while the remaining
6210 * count is still unsettled and the station would fire twice for one batch.
6211 * The Java engine's `batchServiceDepartureActions` has the same split for
6212 * the same reason.
6213 */
6214 if (s.bmsp) {
6215 if (s.bmsp_tag != ev.tag) continue; // a superseded firing
6216 const std::size_t r = s.bmsp_cls;
6217 const std::size_t want = s.bmsp_pending;
6218 std::vector<Job> served;
6219 for (std::size_t d = 0; d < want && !s.buffer.empty(); ++d)
6220 served.push_back(buffer_pop(i));
6221 for (std::size_t d = 0; d < served.size(); ++d) {
6222 const Job& sj = served[d];
6223 acc.update_qlen(i, sj.cls, now);
6224 acc.qlen[i][sj.cls] -= 1.0;
6225 bp.track(i, sj.cls, -1, now);
6226 acc.completed[i][sj.cls] += 1.0;
6227 acc.tally_resp(i, sj.cls, now - sj.t_arr + sj.region_wait);
6228 if (want_respt) resp_samples[i][sj.cls].push_back(now - sj.t_arr + sj.region_wait);
6229 ++total_completions;
6230 }
6231 // Re-arm while work remains, or idle the single server. The clock is
6232 // armed from THIS instant, which is what makes the schedule walk of a
6233 // BMMAPt clock exact: a bulk server runs continuously while non-empty.
6234 if (!s.buffer.empty()) {
6235 bmsp_arm(i);
6236 } else {
6237 acc.update_busy(i, r, now);
6238 if (acc.busy[i][r] > 0.0) acc.busy[i][r] -= 1.0;
6239 s.server_busy[0] = false;
6240 s.bmsp_tag = 0;
6241 }
6242 // Phase B: route the served jobs, now that the station's own state is
6243 // settled.
6244 for (std::size_t d = 0; d < served.size(); ++d) {
6245 const Job& sj = served[d];
6246 const RouteEntry re2 = draw_node_route(sn.station_to_node[i], sj.cls);
6247 if (region_of[i] >= 0) {
6248 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
6249 const bool inside = !re2.sink && re2.station < M &&
6250 region_of[re2.station] == region_of[i];
6251 if (!inside) {
6252 regions[rg].update(now);
6253 regions[rg].leave(sj.cls);
6254 regions[rg].completed[sj.cls] += 1.0;
6255 release_region(rg);
6256 }
6257 }
6258 Job moved;
6259 moved.cls = re2.cls;
6260 moved.t_sys = sj.t_sys;
6261 moved.parent = sj.parent;
6262 moved.call = sj.call;
6263 deliver(re2.node, moved, i);
6264 }
6265 if (total_completions >= max_events) break;
6266 continue;
6267 }
6268
6269 if (s.role == Role::Delay) {
6270 job = ev.job;
6271 if (track_delay_jobs) {
6272 // The registry is the Delay's cancellation handle: no entry
6273 // means a signal already destroyed this job, and a generation
6274 // that no longer matches means a global dependence has re-timed
6275 // it. Serving the departure in either case would put a job back
6276 // into circulation that has already left, or left twice.
6277 std::size_t at = delay_live[i].size();
6278 for (std::size_t j = 0; j < delay_live[i].size(); ++j)
6279 if (delay_live[i][j].id == job.id) {
6280 at = j;
6281 break;
6282 }
6283 if (at == delay_live[i].size()) continue;
6284 if (delay_live[i][at].tag != ev.tag) continue;
6285 delay_live[i].erase(delay_live[i].begin() + static_cast<std::ptrdiff_t>(at));
6286 }
6287 } else if (s.ps) {
6288 ps_advance(i);
6289 std::size_t idx = s.ps_jobs.size();
6290 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
6291 if (s.ps_jobs[j].tag == ev.tag) {
6292 idx = j;
6293 break;
6294 }
6295 // A superseded departure: the job it named has since been
6296 // rescheduled by a population change. Dropping it here is the
6297 // engine's substitute for cancelling the event.
6298 if (idx == s.ps_jobs.size()) continue;
6299 const PsJob& pj = s.ps_jobs[idx];
6300 job.cls = pj.cls;
6301 job.t_arr = pj.t_arr;
6302 job.region_wait = pj.region_wait;
6303 job.t_sys = pj.t_sys;
6304 job.parent = pj.parent;
6305 job.ext = pj.ext;
6306 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(idx));
6307 } else if (s.pas) {
6308 // A stale aggregate clock: the list changed after it was armed, so
6309 // its total rate no longer describes the station.
6310 if (s.pas_tag != ev.tag || s.pas_list.empty()) continue;
6311 job = pas_complete(i);
6312 acc.update_busy(i, job.cls, now);
6313 if (acc.busy[i][job.cls] > 0.0) acc.busy[i][job.cls] -= 1.0;
6314 pas_reschedule(i);
6315 } else {
6316 // A preemption leaves its victim's departure in the list with no
6317 // way to cancel it; the tag mismatch is what neutralises it.
6318 if (!s.server_busy[ev.slot] || s.server_tag[ev.slot] != ev.tag) continue;
6319 sd_advance(i);
6320 job = s.server[ev.slot];
6321 s.server_busy[ev.slot] = false;
6322 s.server_tag[ev.slot] = 0;
6323 const double freed = release_slots(i, ev.slot);
6324 acc.update_busy(i, job.cls, now);
6325 acc.busy[i][job.cls] -= freed;
6326 }
6327
6328 sd_advance(i);
6329 acc.update_qlen(i, job.cls, now);
6330 acc.qlen[i][job.cls] -= 1.0;
6331 bp.track(i, job.cls, -1, now);
6332 acc.completed[i][job.cls] += 1.0;
6333 acc.tally_resp(i, job.cls, now - job.t_arr + job.region_wait);
6334 if (want_respt) resp_samples[i][job.cls].push_back(now - job.t_arr + job.region_wait);
6335 ++total_completions;
6336 if (track_jobs && job.ext >= 0)
6337 job_departure_log.push_back({static_cast<double>(job.ext), static_cast<double>(i),
6338 static_cast<double>(job.cls), now});
6339
6340 if (s.ps) {
6341 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
6342 Job nextjob = buffer_pop(i);
6343 PsJob pj;
6344 pj.cls = nextjob.cls;
6345 pj.priority = nextjob.priority;
6346 pj.t_arr = nextjob.t_arr;
6347 pj.region_wait = nextjob.region_wait;
6348 pj.t_sys = nextjob.t_sys;
6349 pj.total = nextjob.preset ? nextjob.elapsed + nextjob.service : nextjob.service;
6350 pj.remaining = nextjob.service;
6351 pj.parent = nextjob.parent;
6352 pj.ext = nextjob.ext;
6353 s.ps_jobs.push_back(pj);
6354 }
6355 ps_reschedule(i);
6356 }
6357
6358 const RouteEntry re = draw_node_route(sn.station_to_node[i], job.cls);
6359 const bool to_sink = re.sink;
6360 const std::size_t dst = re.station, dcls = re.cls;
6361
6362 /**
6363 * IMMEDIATE FEEDBACK: the completing job goes straight back into THE
6364 * SAME SERVER under the destination class, holding the slot instead of
6365 * re-queueing behind whoever is waiting.
6366 *
6367 * That is the whole content of the feature and it is not the same model
6368 * as a self-loop: a self-loop puts the job at the tail and lets the head
6369 * of the queue in, while feedback lets one job hold the server for as
6370 * many services as its routing keeps returning it. It also never leaves
6371 * the station, so it exits no region, releases no blocked upstream
6372 * server and is not a system completion.
6373 */
6374 const RouteEntry fb = has_immfeed ? resolve_forced_dest(re) : re;
6375 const std::size_t fcls = fb.cls;
6376 if (has_immfeed && !fb.sink && fb.station == i && s.role == Role::Queue && !s.ps &&
6377 !s.pas && ev.slot < s.nservers && fcls < sn.immfeed[i].size() && sn.immfeed[i][fcls]) {
6378 // The fed-back job never reaches `deliver`, so the Loggers it crossed record it here.
6379 if (!fb_loggers.empty()) {
6380 const std::uint64_t walk = log_walk_id++;
6381 for (std::size_t q = 0; q < fb_loggers.size(); ++q)
6382 log_passage(fb_loggers[q].first, fb_loggers[q].second, walk);
6383 }
6384 Job fed;
6385 fed.cls = fcls;
6386 fed.t_sys = job.t_sys;
6387 fed.parent = job.parent;
6388 fed.t_arr = now;
6389 fed.priority = classprio[fcls];
6390 if (draw_at_arrival(i, fcls)) {
6391 fed.service = slot_snap(s.svc[fcls].next_at(g_svc[i][fcls], now), "service time");
6392 fed.remaining = fed.service;
6393 fed.elapsed = 0.0;
6394 }
6395 fed.rank = g_siro.aux.next_double();
6396 fed.deadline = now + classdeadline[fcls];
6397 fed.id = ++job_id;
6398 sd_advance(i);
6399 acc.update_qlen(i, fcls, now);
6400 acc.qlen[i][fcls] += 1.0;
6401 // THE FED-BACK JOB IS AN ARRIVAL. It joins the station under its new
6402 // class, which is where `station_arrival` counts one, and both the
6403 // Java engine and SolverSSA report it: leaving it out halved AN on a
6404 // 50% self-loop (0.8036 -> 0.4006 against a reference 0.8001) and
6405 // turned AN < TN, which no station can do.
6406 acc.arrived[i][fcls] += 1.0;
6407 bp.track(i, fcls, +1, now);
6408 start_service(i, ev.slot, fed);
6409 sd_reschedule(i);
6410 if (total_completions >= max_events) break;
6411 continue;
6412 }
6413
6414 /**
6415 * A SYNCHRONOUS CALL PARKS THE SERVER. The completing job leaves for the
6416 * callee and its slot stays held until the matching reply comes back:
6417 * no waiter is promoted into it, and the time it spends held counts as
6418 * BUSY, because the server is unavailable and reporting it idle would
6419 * credit the station with capacity it does not have. That is the same
6420 * rule the BAS branch below applies for the same reason.
6421 *
6422 * A hop that ALREADY switched into the reply class is not a call: it is
6423 * the answer, and parking on it would wait for a reply to a reply.
6424 * That switch may not show on `dcls` yet -- see `resolve_final_cls` --
6425 * when the routing matrix moves this edge through a synthesized
6426 * ClassSwitch node, so the check resolves through it rather than
6427 * reading `dcls` directly.
6428 */
6429 if (has_sync_call && sync_reply[job.cls] < K &&
6430 !is_reply_signal[resolve_final_cls(re.node, dcls)] &&
6431 s.role == Role::Queue && !s.ps && !s.pas && ev.slot < s.nservers) {
6432 const std::uint64_t call = ++next_call;
6433 PendingCall pc;
6434 pc.station = i;
6435 pc.slot = ev.slot;
6436 pc.cls = job.cls;
6437 pc.since = now;
6438 pending_reply[call] = pc;
6439 s.server_held[ev.slot] = true;
6440 s.held_cls[ev.slot] = job.cls;
6441 acc.update_busy(i, job.cls, now);
6442 acc.busy[i][job.cls] += 1.0;
6443 // The parked caller is still a job AT THIS STATION for QLen (the reference's
6444 // currentBlockedServers), but not in `qlen`, which the admission and rate logic reads.
6445 acc.update_qlen(i, job.cls, now);
6446 acc.held[i][job.cls] += 1.0;
6447 Job moved;
6448 moved.cls = re.cls;
6449 moved.t_sys = job.t_sys;
6450 moved.parent = job.parent;
6451 moved.call = call;
6452 deliver(re.node, moved, i);
6453 if (total_completions >= max_events) break;
6454 continue;
6455 }
6456
6457 // BLOCKING IS DECIDED BEFORE THE SLOT IS REUSED. If the destination is
6458 // full and declares BAS or BBS, this server keeps the completed job and
6459 // stops serving; taking the next waiting job first would let the station
6460 // run at full rate while its output is stopped, which is precisely the
6461 // behaviour blocking removes.
6462 if (!to_sink && dst < M && s.role == Role::Queue && !s.ps && ev.slot < s.nservers &&
6463 !dest_has_room(dst, dcls)) {
6464 const lang::DropStrategy rule = dest_policy(dst, dcls);
6465 if (rule == lang::DropStrategy::BAS || rule == lang::DropStrategy::BBS) {
6466 Job held = job;
6467 held.cls = dcls;
6468 s.server_blocked[ev.slot] = true;
6469 s.blocked_job[ev.slot] = held;
6470 s.blocked_dest[ev.slot] = dst;
6471 s.blocked_dest_cls[ev.slot] = dcls;
6472 // Charged to the DESTINATION it is queueing for, not to the
6473 // station whose server it occupies.
6474 // The blocked job IS part of the destination's queue length --
6475 // the reference's effectiveQueueLength adds basBlockedAtDest and
6476 // bbsBlockedAtDest to it -- so it is carried in `qlen` and the
6477 // counter beside it is bookkeeping, not a second population.
6478 acc.update_qlen(dst, dcls, now);
6479 acc.qlen[dst][dcls] += 1.0;
6480 S[dst].blocked_at[dcls] += 1.0;
6481 // The upstream server is BLOCKED, not idle. Utilization counts
6482 // blocking time as busy time (the reference divides
6483 // busy + blocking by the horizon), because the server is
6484 // unavailable either way and reporting it idle would credit the
6485 // station with capacity it does not have.
6486 acc.update_busy(i, job.cls, now);
6487 acc.busy[i][job.cls] += 1.0;
6488 blocked_count[i][job.cls] += 1.0;
6489 continue;
6490 }
6491 }
6492
6493 // ONLY NOW may the freed slot take the next waiting job: a slot that
6494 // blocked above must not also start serving, or the station holds two
6495 // jobs in one server and the blocked one is duplicated on release.
6496 if (s.polling) {
6497 // The controller decides what happens next, not the buffer order:
6498 // it may keep serving this buffer, or walk on under its budget.
6499 poll_serve(i);
6500 } else if (s.role == Role::Queue && !s.ps && !s.pas && !s.server_blocked.empty() &&
6501 ev.slot < s.nservers && !s.server_blocked[ev.slot] &&
6502 !s.server_held[ev.slot]) {
6503 serve_next_on_slot(i, ev.slot);
6504 sd_reschedule(i);
6505 }
6506 // THE FORK IDENTITY IS HANDED OVER, NOT COPIED. A continuation that
6507 // stands in for the trigger at a Join must take its sibling slot; if
6508 // the trigger kept it too, one fork would present two siblings and the
6509 // Join would release early and discard the second.
6510 std::uint64_t spawn_parent = 0;
6511 if (has_spawn && spawn_of[job.cls] < K && spawn_joins_at[spawn_of[job.cls]]) {
6512 spawn_parent = job.parent;
6513 job.parent = 0;
6514 }
6515 // THE REGION IS LEFT FIRST, AS IN THE JAVA ENGINE: the completing job gives up its slot, the phase-2
6516 // continuation (if any) is admitted into it, and only then is the job routed. A hop to another member of the
6517 // same region under the SAME class completes nothing and releases no waiter: admit re-gates it into the
6518 // slot it just freed. Any other hop completes, and the waiters parked on the region are released.
6519 const int dep_rg = region_of[i];
6520 if (dep_rg >= 0) {
6521 regions[static_cast<std::size_t>(dep_rg)].update(now);
6522 regions[static_cast<std::size_t>(dep_rg)].leave(job.cls);
6523 }
6524 // THE PHASE-2 CONTINUATION IS INJECTED HERE, after the completing job's release of its region slot and
6525 // before the job is routed or anything parked on the region is released: the continuation takes over the
6526 // slot the trigger just freed, ahead of the blocked queue, which is the order `maybeSpawnOnCompletion` is
6527 // called in.
6528 if (has_spawn && spawn_of[job.cls] < K) {
6529 const std::size_t scls = spawn_of[job.cls];
6530 Job spawned;
6531 spawned.cls = scls;
6532 spawned.t_sys = now;
6533 // A continuation aimed at a Join stands in for the trigger at the
6534 // fork it belongs to (phase 2 at an AND-join branch tail), so it
6535 // inherits the fork identity rather than arriving as an orphan the
6536 // Join would discard.
6537 spawned.parent = spawn_parent;
6538 admit(i, spawned, M);
6539 }
6540 {
6541 Job moved;
6542 moved.cls = re.cls;
6543 moved.t_sys = job.t_sys;
6544 moved.parent = job.parent;
6545 // The identity travels WITH the job, through the callee and back on
6546 // the reply: a call answered two stations downstream is still this
6547 // caller's call, and dropping the tag here would strand its server.
6548 moved.call = job.call;
6549 hop_old_cls = job.cls;
6550 hop_internal = false;
6551 deliver(re.node, moved, i);
6552 hop_old_cls = K;
6553 }
6554 // The verdict on the hop is admit's, which sees the station it really reaches: `re.station` is M when the
6555 // edge runs through a ClassSwitch node. A Fork, Join, Sink or signal destination has no region: a crossing.
6556 if (dep_rg >= 0) {
6557 const bool stays_inside = hop_internal;
6558 hop_internal = false;
6559 if (!stays_inside) {
6560 regions[static_cast<std::size_t>(dep_rg)].completed[job.cls] += 1.0;
6561 release_region(static_cast<std::size_t>(dep_rg));
6562 }
6563 }
6564 // The station has emptied: arm the delay-off timer, after which the
6565 // server shuts down and the next arrival pays a setup.
6566 if (s.has_setup && s.setup_on) {
6567 double held = 0.0;
6568 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[i][r];
6569 if (!(held > 0.0)) {
6570 const double idle =
6571 s.delayoff_time.disabled() ? 0.0 : s.delayoff_time.next(g_aux[i]);
6572 s.delayoff_at = now + idle;
6573 Event e;
6574 e.t = s.delayoff_at;
6575 e.kind = EV_SETUP; // reuse the tag; the handler below powers down
6576 e.station = i;
6577 e.cls = job.cls;
6578 e.slot = 1; // marks a power-down rather than a setup completion
6579 push(e);
6580 }
6581 }
6582 release_blocked(i);
6583
6584 if (total_completions >= max_events) break;
6585 if (cnvg.enabled() && (total_completions - last_cnvg_events) >= cnvg.interval()) {
6586 for (std::size_t a = 0; a < M; ++a) {
6587 if (S[a].ps) ps_advance(a);
6588 for (std::size_t r = 0; r < K; ++r) {
6589 acc.update_qlen(a, r, now);
6590 acc.update_busy(a, r, now);
6591 }
6592 }
6593 cnvg.finalize_batch(acc, servers_of, now);
6594 last_cnvg_events = total_completions;
6595 if (cnvg.converged(off_of)) {
6596 converged = true;
6597 break;
6598 }
6599 }
6600 if ((total_completions - last_mser_events) >= mser_interval) {
6601 for (std::size_t a = 0; a < M; ++a) {
6602 if (S[a].ps) ps_advance(a);
6603 for (std::size_t r = 0; r < K; ++r) {
6604 acc.update_qlen(a, r, now);
6605 acc.update_busy(a, r, now);
6606 }
6607 }
6608 obs.collect(acc, now);
6609 last_mser_events = total_completions;
6610 }
6611 }
6612
6613 // Close every open integral at the final instant.
6614 for (std::size_t i = 0; i < M; ++i) {
6615 if (S[i].ps) ps_advance(i);
6616 for (std::size_t r = 0; r < K; ++r) {
6617 acc.update_qlen(i, r, now);
6618 acc.update_busy(i, r, now);
6619 }
6620 }
6621
6622 bp.commit();
6623 const Truncation tr = obs.truncate();
6624 const double sim_time = now - tr.warmup_end;
6625 const double elapsed = tr.applied ? (now - obs.time[tr.index]) : sim_time;
6626 // Classes that lost jobs unserved INSIDE the measured window (post-truncation), as the JAR counts them post-warmup.
6627 const double t_meas = tr.applied ? obs.time[tr.index] : 0.0;
6628 std::vector<std::vector<bool>> lost_unserved(M, std::vector<bool>(K, false));
6629 for (std::size_t i = 0; i < M; ++i)
6630 for (std::size_t r = 0; r < K; ++r) lost_unserved[i][r] = last_lost[i][r] >= t_meas;
6631
6632 // ---- result -------------------------------------------------------------
6633 LdesResult res;
6634 res.nstations = M;
6635 res.nclasses = K;
6636 res.nchains = sn.nchains;
6637 for (std::size_t i = 0; i < M; ++i) res.station_names.push_back(sn.stations[i].name);
6638 for (std::size_t r = 0; r < K; ++r) res.class_names.push_back(sn.classes[r].name);
6639 res.QN = Matrix<double>(M, K, 0.0);
6640 res.UN = Matrix<double>(M, K, 0.0);
6641 res.RN = Matrix<double>(M, K, 0.0);
6642 res.TN = Matrix<double>(M, K, 0.0);
6643 res.CN = Matrix<double>(1, K, 0.0);
6644 res.XN = Matrix<double>(1, K, 0.0);
6645 res.AN = Matrix<double>(M, K, 0.0);
6646 res.WN = Matrix<double>(M, K, 0.0);
6647 // U per unit of T under the utilization law each row of UN is read against (S/c, S/peak, S/gd_peak at a delay),
6648 // which carries TNCI into UNCI as MATLAB's U = T*S/c does; 0 where no law applies.
6649 Matrix<double> u_law(M, K, 0.0);
6650
6651 for (std::size_t i = 0; i < M; ++i) {
6652 for (std::size_t r = 0; r < K; ++r) {
6653 if (S[i].role == Role::Source) {
6654 res.TN(i, r) = lambda[r];
6655 continue;
6656 }
6657 if (S[i].off[r]) continue;
6658 if (elapsed > 0.0) {
6659 const double q0 = tr.applied ? obs.qt[i][r][tr.index] : 0.0;
6660 const double b0 = tr.applied ? obs.bt[i][r][tr.index] : 0.0;
6661 const double c0 = tr.applied ? obs.cmp[i][r][tr.index] : 0.0;
6662 res.QN(i, r) = (acc.tot_qlen[i][r] - q0) / elapsed;
6663 res.TN(i, r) = (acc.completed[i][r] - c0) / elapsed;
6664 if (S[i].role == Role::Delay) {
6665 // An infinite server has no capacity to be busy against: the
6666 // reference reports the traffic intensity T/mu, which is what
6667 // SolverNC and SolverMVA return for a Delay. A GLOBAL
6668 // DEPENDENCE RE-TIMES THE THINK TIME TOO, so its declared
6669 // peak normalizes this row as well -- `solver_ctmc_analyzer`
6670 // runs the T/(mu*peak) division over EVERY station once a
6671 // handle is present, and there is no other peak a delay can
6672 // carry.
6673 res.UN(i, r) = res.TN(i, r) * S[i].class_mean[r] / S[i].gd_peak[r];
6674 u_law(i, r) = S[i].class_mean[r] / S[i].gd_peak[r];
6675 } else if (sn.stations[i].nodetype == NodeType::Place && !sn.is_queueing_place(i)) {
6676 // An ordinary Place is an INF station: U == Q, as SolverCTMC, SolverSSA and SolverNC report.
6677 res.UN(i, r) = res.QN(i, r);
6678 if (res.TN(i, r) > 0.0) u_law(i, r) = res.QN(i, r) / res.TN(i, r);
6679 } else if (S[i].has_cd || has_gd) {
6680 // A CLASS-, JOINT- OR GLOBALLY DEPENDENT STATION IS READ
6681 // THROUGH THE LAW, not the busy integral, which is what the
6682 // Java engine does (`getUtilization` returns
6683 // T/(mu*peakScaling) here). beta_r(n) and phi(n) scale the
6684 // RATE, so a busy server delivers beta*phi times the work;
6685 // the head-count integral would report the fraction of TIME
6686 // occupied and divide it by a peak that counts work, mixing
6687 // the two conventions in one number. The load-dependent case
6688 // keeps the integral because `Accum::busy_scale` already
6689 // weights it by alpha(n).
6690 //
6691 // THE PEAKS MULTIPLY, and the server count drops out as soon
6692 // as any of them is declared: that is `getPeakScaling` and
6693 // `solver_ssa_analyzer_serial`, which take the product of
6694 // whichever peaks the model declares. With only a global
6695 // dependence the peak is phi's alone; with a class or load
6696 // dependence beside it, `util_peak` already holds that
6697 // factor.
6698 const double base =
6699 (S[i].has_cd && r < S[i].cd_peak_class.size()) ? S[i].cd_peak_class[r]
6700 : (S[i].has_cd || !S[i].lld.empty()) ? S[i].util_peak : 1.0;
6701 const double peak = base * S[i].gd_peak[r];
6702 res.UN(i, r) =
6703 (peak > 0.0) ? res.TN(i, r) * S[i].class_mean[r] / peak : 0.0;
6704 if (peak > 0.0) u_law(i, r) = S[i].class_mean[r] / peak;
6705 } else {
6706 res.UN(i, r) = (acc.tot_busy[i][r] - b0) / (elapsed * S[i].util_peak);
6707 if (S[i].util_peak > 0.0) u_law(i, r) = S[i].class_mean[r] / S[i].util_peak;
6708 }
6709 }
6710 if (lost_unserved[i][r]) {
6711 // JOBS LEFT UNSERVED (reneging, G-network removal): the sojourn tally sees only the survivors, a biased
6712 // subset (G-queue lambda+ 0.5, lambda- 0.2, mu 1: ~1.476 against QN/TN = 1.7143), so RN is read by
6713 // Little's law on the completion throughput, the CTMC/SSA/MATLAB convention, as the JAR does (F87).
6714 res.RN(i, r) = (res.TN(i, r) > 0.0) ? res.QN(i, r) / res.TN(i, r) : 0.0;
6715 } else if (acc.resp_cnt[i][r] > 0.0) {
6716 res.RN(i, r) = acc.resp_sum[i][r] / acc.resp_cnt[i][r];
6717 } else if (sn.stations[i].nodetype == NodeType::Place && res.TN(i, r) > 0.0) {
6718 // A PLACE MEASURES NO PER-TOKEN SOJOURN, so its response time is
6719 // read off Little's law, E[N]/X, which is what the reference
6720 // engine reports for a place and what JMT calls a place's
6721 // residence time. There is no per-token stamp to average: a
6722 // token is not a job passing through a service station, and at a
6723 // queueing place it may sit in the depository for as long as the
6724 // output transitions leave it there.
6725 res.RN(i, r) = res.QN(i, r) / res.TN(i, r);
6726 }
6727 // AN is the OFFERED rate, as `getArrivalRate` measures it: the
6728 // arrivals since the truncation point over the same window as TN; WN is the residence time, which for
6729 // this engine's single-visit accounting is the response time. Both
6730 // are part of the result contract every client reads, and leaving
6731 // them at zero reads as "no arrivals" rather than "not measured".
6732 const double a0 = tr.applied ? obs.arv[i][r][tr.index] : 0.0;
6733 if (elapsed > 0.0) res.AN(i, r) = (acc.arrived[i][r] - a0) / elapsed;
6734 res.WN(i, r) = res.RN(i, r);
6735 }
6736 }
6737 // Sibling drops at a Join, on the Join rows only. Emitted whenever the model
6738 // HAS a fork-join, drops or none: a MEASURED zero is what a standard join
6739 // loses, and it is a better answer than the client's derived AN - K*TN,
6740 // which carries the finite-sample gap between the two rates. An absent
6741 // matrix means "this engine does not count", not "nothing was lost".
6742 if (!sn.fj.empty()) {
6743 res.DropRateJoin = Matrix<double>(M, K, 0.0);
6744 for (std::size_t i = 0; i < M; ++i)
6745 for (std::size_t r = 0; r < K; ++r) {
6746 const double d0 = tr.applied ? obs.drp[i][r][tr.index] : 0.0;
6747 if (elapsed > 0.0)
6748 res.DropRateJoin(i, r) = (acc.join_dropped[i][r] - d0) / elapsed;
6749 }
6750 }
6751 // The "forkJoin" block: FJRespT is the full sojourn tally (as RN), FJTput the releases after the truncation
6752 // point over the same window as TN, and FJQLen = FJTput * FJRespT by Little's law.
6753 if (!fj_pairs.empty()) {
6754 const std::size_t P = fj_pairs.size();
6755 res.FJQLen = Matrix<double>(P, K, 0.0);
6756 res.FJRespT = Matrix<double>(P, K, 0.0);
6757 res.FJTput = Matrix<double>(P, K, 0.0);
6758 const double t_trunc = tr.applied ? obs.time[tr.index] : -1.0;
6759 for (std::size_t p = 0; p < P; ++p) {
6760 res.fj_forks.push_back(sn.nodes[fj_pairs[p].first - 1].name);
6761 res.fj_joins.push_back(sn.nodes[fj_pairs[p].second - 1].name);
6762 for (std::size_t r = 0; r < K; ++r) {
6763 if (fj_resp_cnt[p][r] > 0.0) res.FJRespT(p, r) = fj_resp_sum[p][r] / fj_resp_cnt[p][r];
6764 double nrel = 0.0;
6765 for (double tr_k : fj_release[p][r])
6766 if (tr_k > t_trunc) nrel += 1.0;
6767 if (elapsed > 0.0) res.FJTput(p, r) = nrel / elapsed;
6768 res.FJQLen(p, r) = res.FJTput(p, r) * res.FJRespT(p, r);
6769 }
6770 }
6771 }
6772 for (std::size_t r = 0; r < K; ++r) {
6773 if (sim_time > 0.0) res.XN(0, r) = sys_completed[r] / sim_time;
6774 if (sys_resp_cnt[r] > 0.0) res.CN(0, r) = sys_resp_sum[r] / sys_resp_cnt[r];
6775 // A PETRI NET COMPLETES NOTHING AT A SINK AND CROSSES NO REFERENCE
6776 // STATION, because its tokens move by the firing rules and never pass
6777 // through `deliver`, so `sys_completed` stays at zero and the system
6778 // throughput of a closed net would be reported as none at all. It is the
6779 // token rate out of the class's reference PLACE, which is that row of TN
6780 // and is what the reference engine reports. CN is left alone: a token
6781 // carries no arrival stamp -- the marking is a count, not a queue of
6782 // identified jobs -- so there is no cycle time to average, and the
6783 // reference reports none either.
6784 const std::size_t rs = sn.classes[r].refstat;
6785 if (res.XN(0, r) == 0.0 && rs != 0 && rs <= M &&
6786 sn.stations[rs - 1].nodetype == NodeType::Place)
6787 res.XN(0, r) = res.TN(rs - 1, r);
6788 }
6789
6790 // ---- busy periods --------------------------------------------------------
6791 for (std::size_t ti = 0; ti < bp.targets().size(); ++ti) {
6792 LdesResult::BusyPeriodTarget out;
6793 out.name = bp.targets()[ti].name;
6794 out.stations = bp.targets()[ti].stations;
6795 out.job_class = bp.targets()[ti].job_class;
6796 for (std::size_t oi = 0; oi < static_cast<std::size_t>(bp.orders()); ++oi) {
6797 out.mean.push_back(bp.mean(ti, oi));
6798 out.count.push_back(bp.count(ti, oi));
6799 }
6800 res.busy_periods.push_back(out);
6801 }
6802
6803 // ---- transient trajectory and histogram -----------------------------------
6804 if (transient_run) {
6805 now = std::min(now, horizon);
6806 hist_accumulate();
6807 res.t = tran_times;
6808 res.QNt.assign(M, std::vector<Matrix<double>>(K));
6809 res.UNt.assign(M, std::vector<Matrix<double>>(K));
6810 res.TNt.assign(M, std::vector<Matrix<double>>(K));
6811 for (std::size_t i = 0; i < M; ++i)
6812 for (std::size_t r = 0; r < K; ++r) {
6813 const std::size_t n = tran_times.size();
6814 // Columns are [value, time], which is the layout the reference
6815 // emits and the clients read.
6816 Matrix<double> q(n, 2, 0.0), u(n, 2, 0.0), t(n, 2, 0.0);
6817 for (std::size_t k = 0; k < n; ++k) {
6818 q(k, 0) = tran_q[i][r][k];
6819 q(k, 1) = tran_times[k];
6820 u(k, 0) = tran_u[i][r][k];
6821 u(k, 1) = tran_times[k];
6822 t(k, 0) = tran_t[i][r][k];
6823 t(k, 1) = tran_times[k];
6824 }
6825 res.QNt[i][r] = q;
6826 res.UNt[i][r] = u;
6827 res.TNt[i][r] = t;
6828 }
6829 res.stopping_reason = "max_time";
6830 }
6831
6832 if (want_respt) {
6833 res.respTimeSamples.assign(M, std::vector<std::vector<double>>(K));
6834 for (std::size_t i = 0; i < M; ++i)
6835 for (std::size_t r = 0; r < K; ++r) res.respTimeSamples[i][r] = resp_samples[i][r];
6836 }
6837
6838 // ---- the joint-state histogram and the state path -------------------------
6839 // OUTSIDE the transient branch: a steady-state run that asked for either
6840 // fills it too, which is what `--export-histogram` and `--trajectory` mean.
6841 // The last interval is closed here, or the state the run ended in would
6842 // carry no residence time at all.
6843 if (want_hist && !transient_run) hist_accumulate();
6844 if (!histogram.empty()) {
6845 res.histogram_space = Matrix<double>(histogram.size(), M * K, 0.0);
6846 res.histogram_time = Matrix<double>(histogram.size(), 1, 0.0);
6847 std::size_t row = 0;
6848 for (const auto& kv : histogram) {
6849 for (std::size_t c = 0; c < kv.first.size(); ++c)
6850 res.histogram_space(row, c) = kv.first[c];
6851 res.histogram_time(row, 0) = kv.second;
6852 ++row;
6853 }
6854 }
6855 if (!trajectory.empty()) {
6856 res.traj_space = Matrix<double>(trajectory.size(), M * K, 0.0);
6857 res.traj_time = Matrix<double>(trajectory.size(), 1, 0.0);
6858 for (std::size_t k = 0; k < trajectory.size(); ++k) {
6859 for (std::size_t c = 0; c < trajectory[k].second.size(); ++c)
6860 res.traj_space(k, c) = trajectory[k].second[c];
6861 res.traj_time(k, 0) = trajectory[k].first;
6862 }
6863 }
6864
6865 // ---- cache metrics --------------------------------------------------------
6866 for (auto& kv : caches) {
6867 CacheState& cs = kv.second;
6868 LdesCacheMetrics cm;
6869 cm.hit = Matrix<double>(1, K, 0.0);
6870 cm.miss = Matrix<double>(1, K, 0.0);
6871 // A DELAYED HIT IS ITS OWN OUTCOME, so it enters the denominator beside
6872 // the other two: with a retrieval system the three fractions partition
6873 // the reads, and dividing by hits + misses alone would report fractions
6874 // that sum above one.
6875 if (cs.has_retrieval) cm.delayed = Matrix<double>(1, K, 0.0);
6876 for (std::size_t r = 0; r < K; ++r) {
6877 const double dl = cs.has_retrieval ? cs.delayed[r] : 0.0;
6878 const double tot = cs.hits[r] + cs.misses[r] + dl;
6879 if (tot > 0.0) {
6880 cm.hit(0, r) = cs.hits[r] / tot;
6881 cm.miss(0, r) = cs.misses[r] / tot;
6882 if (cs.has_retrieval) cm.delayed(0, r) = dl / tot;
6883 }
6884 }
6885 // The measured retrieval latency: the total time requests WAITED on the
6886 // retrieval system, summed over BOTH populations that wait -- the one
6887 // that triggered the fetch and every request parked behind it -- and
6888 // averaged over ALL read requests, a hit waiting zero. A parked request
6889 // waits the RESIDUAL of the fetch period, not the whole of it, so
6890 // summing the fetch sojourns alone would read high where the fetch
6891 // period is not memoryless (6.5% on `retrieval_chain`, hypoexponential).
6892 //
6893 // ALL READS IS WHAT MAKES THE PAIR (ArvR, ResidT) MEAN ANYTHING.
6894 // `getAvgCacheTable` reports ArvR = lambda (hits + delayed + misses),
6895 // the read rate AT THE CACHE, so the residence time beside it must be
6896 // Little-consistent with that rate: E[N] = sum_i (phi_i + d_i) =
6897 // lambda * Z. That is the paper's eq. (latency tot), what the analytic
6898 // reference computes -- `retrieval_fpi_latency`'s
6899 // Z = sum(phi + d) / sum(lambda) -- and what `Solver_ssj.exportCacheResults`
6900 // measures. Reported only where measured.
6901 double reads = 0.0;
6902 if (cs.has_retrieval)
6903 for (std::size_t r = 0; r < K; ++r) reads += cs.hits[r] + cs.misses[r] + cs.delayed[r];
6904 if (reads > 0.0) {
6905 cm.latency = Matrix<double>(1, K, 0.0);
6906 const double mean_wait = (cs.total_fetch_time + cs.delayed_wait) / reads;
6907 for (std::size_t r = 0; r < K; ++r)
6908 cm.latency(0, r) = (cs.hits[r] + cs.misses[r] + cs.delayed[r] > 0.0)
6909 ? mean_wait
6910 : std::numeric_limits<double>::quiet_NaN();
6911 }
6912 // Per-list hits [classes x lists], over the same reads as `hit` (NaN on classes that never read),
6913 // the item position law [items x (lists+1)] as a time average with column 0 the not-cached share,
6914 // and the mean storage cost held per list, sum_i size_i * itemProb(i, l), when sizes are declared.
6915 // Transcribed from Solver_ssj.exportCacheResults.
6916 const std::size_t H = cs.lists.size();
6917 const double nan = std::numeric_limits<double>::quiet_NaN();
6918 cm.hitList = Matrix<double>(K, H, nan);
6919 for (std::size_t r = 0; r < K; ++r) {
6920 const double tot = cs.hits[r] + cs.misses[r] + (cs.has_retrieval ? cs.delayed[r] : 0.0);
6921 if (tot > 0.0)
6922 for (std::size_t l = 0; l < H; ++l) cm.hitList(r, l) = cs.hits_per_list[r][l] / tot;
6923 }
6924 cs.accumulate(now);
6925 cm.itemProb = Matrix<double>(cs.nitems, H + 1, nan);
6926 if (now > 0.0) {
6927 for (std::size_t it = 0; it < cs.nitems; ++it) {
6928 double in_cache = 0.0;
6929 for (std::size_t l = 0; l < H; ++l) {
6930 const double p = cs.item_level_time[it][l] / now;
6931 cm.itemProb(it, l + 1) = p;
6932 in_cache += p;
6933 }
6934 cm.itemProb(it, 0) = std::max(0.0, 1.0 - in_cache);
6935 }
6936 if (!cs.item_size.empty()) {
6937 cm.listCost = Matrix<double>(1, H, 0.0);
6938 for (std::size_t l = 0; l < H; ++l)
6939 for (std::size_t it = 0; it < cs.nitems; ++it)
6940 cm.listCost(0, l) += cs.size_of(it) * cm.itemProb(it, l + 1);
6941 }
6942 }
6943 res.cache_metrics[sn.nodes[cs.node - 1].name] = cm;
6944 }
6945
6946 // ---- finite capacity regions ---------------------------------------------
6947 if (!regions.empty()) {
6948 const std::size_t NR = regions.size();
6949 res.nregions = NR;
6950 res.QNfcr = Matrix<double>(NR, K, 0.0);
6951 res.TNfcr = Matrix<double>(NR, K, 0.0);
6952 res.WeightNfcr = Matrix<double>(NR, K, 0.0);
6953 res.MemOccNfcr = Matrix<double>(NR, K, 0.0);
6954 res.DropRateNfcr = Matrix<double>(NR, K, 0.0);
6955 for (std::size_t g = 0; g < NR; ++g) {
6956 regions[g].update(now);
6957 for (std::size_t r = 0; r < K; ++r) {
6958 if (sim_time > 0.0) {
6959 // JMT's convention: the region's queue length counts only
6960 // the jobs INSIDE it. A WAITQ job parked outside has not
6961 // entered and is not part of the region's occupancy.
6962 res.QNfcr(g, r) = regions[g].tot_jobs[r] / sim_time;
6963 res.WeightNfcr(g, r) = regions[g].tot_weight[r] / sim_time;
6964 res.MemOccNfcr(g, r) = regions[g].tot_mem[r] / sim_time;
6965 res.TNfcr(g, r) = regions[g].completed[r] / sim_time;
6966 res.DropRateNfcr(g, r) = regions[g].dropped[r] / sim_time;
6967 }
6968 }
6969 }
6970 }
6971
6972 // ---- confidence intervals ------------------------------------------------
6973 // The event count is set BEFORE the intervals, not after: `batch_means_ci`
6974 // plans a run length from it when the caller asked for one, and reading a
6975 // zero there would silently fall back to the BUDGET, which an early
6976 // convergence stop never spent.
6977 res.total_simulated_events = static_cast<long long>(total_completions);
6978 batch_means_ci(obs, tr, acc, o, res, u_law, &lost_unserved);
6979
6980 // The impatience block is reported only when the model declares one: an
6981 // all-zero matrix would read as "measured, and none happened".
6982 double any_balk = 0.0, any_renege = 0.0;
6983 for (std::size_t i = 0; i < M; ++i)
6984 for (std::size_t r = 0; r < K; ++r) {
6985 any_balk += balked[i][r];
6986 any_renege += reneged[i][r];
6987 }
6988 // The orbit block follows the DECLARATION, not the traffic: a retrial station whose orbit stayed empty (a capacity
6989 // pinned to the server count loses every blocked job) still reports its drops, and an absent block read as 0x0.
6990 bool any_orbit = false;
6991 for (std::size_t i = 0; i < M; ++i)
6992 for (std::size_t r = 0; r < K; ++r) any_orbit = any_orbit || S[i].has_retrial[r];
6993 if (any_orbit) {
6994 res.retriedCustomers = Matrix<double>(M, K, 0.0);
6995 res.retrialDropped = Matrix<double>(M, K, 0.0);
6996 res.avgOrbitSize = Matrix<double>(M, K, 0.0);
6997 for (std::size_t i = 0; i < M; ++i) {
6998 const double dt = now - S[i].orbit_last;
6999 for (std::size_t r = 0; r < K; ++r) {
7000 double tot = S[i].tot_orbit[r];
7001 if (dt > 0.0) tot += S[i].orbit_size[r] * dt;
7002 res.retriedCustomers(i, r) = S[i].retried[r];
7003 res.retrialDropped(i, r) = S[i].retrial_lost[r];
7004 if (sim_time > 0.0) res.avgOrbitSize(i, r) = tot / sim_time;
7005 }
7006 }
7007 }
7008
7009 if (any_balk > 0.0 || any_renege > 0.0) {
7010 res.balkedCustomers = Matrix<double>(M, K, 0.0);
7011 res.renegedCustomers = Matrix<double>(M, K, 0.0);
7012 res.renegingRate = Matrix<double>(M, K, 0.0);
7013 for (std::size_t i = 0; i < M; ++i)
7014 for (std::size_t r = 0; r < K; ++r) {
7015 res.balkedCustomers(i, r) = balked[i][r];
7016 res.renegedCustomers(i, r) = reneged[i][r];
7017 if (sim_time > 0.0) res.renegingRate(i, r) = reneged[i][r] / sim_time;
7018 }
7019 }
7020
7021 /**
7022 * THE JOB-LEVEL END STATE, `finalJobs`, in the init_jobs layout, and the
7023 * departure log of tagged jobs. A transient run is read at its horizon, not
7024 * at its last event; untagged jobs get fresh ids above every tag in use. It
7025 * runs last, once every other block is filled, because it advances the
7026 * sharing stations to that instant.
7027 */
7028 if (track_jobs) {
7029 if (transient_run) now = horizon;
7030 std::int64_t next_id = 0;
7031 for (std::size_t r = 0; r < o.init_jobs.rows() && o.init_jobs.cols() >= 8; ++r)
7032 next_id = std::max(next_id, static_cast<std::int64_t>(o.init_jobs(r, 7)) + 1);
7033 std::vector<std::array<double, 8>> out;
7034 auto emit = [&](std::size_t i, const Job& j, double remaining, double el, double& order) {
7035 out.push_back({static_cast<double>(i), static_cast<double>(j.cls),
7036 std::max(remaining, std::numeric_limits<double>::min()), el, now - j.t_arr,
7037 now - j.t_sys, order, static_cast<double>(j.ext)});
7038 order += 1.0;
7039 };
7040 for (std::size_t i = 0; i < M; ++i) {
7041 StationState& s = S[i];
7042 if (s.role != Role::Queue && s.role != Role::Delay) continue;
7043 const std::size_t before = out.size();
7044 double order = 0.0;
7045 if (s.role == Role::Delay) {
7046 for (const Job& d : delay_live[i]) emit(i, d, d.due - now, d.elapsed + d.service - (d.due - now), order);
7047 } else if (s.ps) {
7048 ps_advance(i);
7049 for (const PsJob& pj : s.ps_jobs) {
7050 Job j;
7051 j.cls = pj.cls;
7052 j.t_arr = pj.t_arr;
7053 j.t_sys = pj.t_sys;
7054 j.ext = pj.ext;
7055 emit(i, j, pj.remaining, pj.total - pj.remaining, order);
7056 }
7057 } else {
7058 std::vector<std::size_t> busy;
7059 for (std::size_t sl = 0; sl < s.nservers; ++sl) {
7060 if (s.server_blocked[sl] || s.server_held[sl])
7061 throw UnsupportedError(ErrorKind{"InitJobs"},
7062 "SolverLDES (native engine): track_jobs cannot export station '" +
7063 sn.stations[i].name + "', whose server is blocked or held");
7064 if (s.server_busy[sl]) busy.push_back(sl);
7065 }
7066 std::sort(busy.begin(), busy.end(), [&](std::size_t a, std::size_t b) {
7067 return s.server[a].t_arr < s.server[b].t_arr;
7068 });
7069 for (std::size_t sl : busy) {
7070 const Job& j = s.server[sl];
7071 const double done = now - s.server_start[sl];
7072 emit(i, j, j.remaining - done, j.elapsed + done, order);
7073 }
7074 }
7075 // The waiting room, in the order the station would serve it.
7076 std::vector<Job> waiting = s.buffer;
7077 for (std::size_t n = waiting.size(); n > 0; --n) {
7078 std::pop_heap(waiting.begin(), waiting.begin() + static_cast<std::ptrdiff_t>(n), s.cmp);
7079 const Job& j = waiting[n - 1];
7080 emit(i, j, j.remaining, j.elapsed, order);
7081 }
7082 if (out.size() > before) job_state_check(i);
7083 }
7084 for (const auto& row : out) next_id = std::max(next_id, static_cast<std::int64_t>(row[7]) + 1);
7085 res.finalJobs = Matrix<double>(out.size(), 8, 0.0);
7086 for (std::size_t r = 0; r < out.size(); ++r) {
7087 if (out[r][7] < 0.0) out[r][7] = static_cast<double>(next_id++);
7088 for (std::size_t c = 0; c < 8; ++c) res.finalJobs(r, c) = out[r][c];
7089 }
7090 res.jobDepartures = Matrix<double>(job_departure_log.size(), 4, 0.0);
7091 for (std::size_t r = 0; r < job_departure_log.size(); ++r)
7092 for (std::size_t c = 0; c < 4; ++c) res.jobDepartures(r, c) = job_departure_log[r][c];
7093 }
7094 res.converged = converged;
7095 res.convergence_batches = cnvg.batches();
7096 if (!transient_run) res.stopping_reason = converged ? "convergence" : "max_events";
7097 res.engine = "native";
7098 res.method = o.method;
7099 return res;
7100}
7101
7102/**
7103 * Simulate `sn`, over independent REPLICATIONS when `options.replications > 1`.
7104 *
7105 * Each replication is one path with its OWN seed, `seed + r`, exactly as
7106 * `Solver_ldes_analyzer_parallel` derives them. That is what makes the paths
7107 * independent while keeping the whole run reproducible; sharing a seed would
7108 * make every replication identical and collapse the interval to zero, and
7109 * drawing them randomly would make the run irreproducible.
7110 *
7111 * THE INTERVAL COMES FROM THE SPREAD BETWEEN REPLICATIONS, not from within a
7112 * path, and it is the honest one: independent paths need no assumption about
7113 * the correlation structure that batch means has to model. The cost is that
7114 * `replications - 1` degrees of freedom is few, so the t critical value is
7115 * large and the interval wide -- which is the estimate, not a defect of it.
7116 *
7117 * A single replication yields NO interval rather than a zero-width one: with
7118 * one path there is no spread to measure, and reporting zero would read as
7119 * perfect precision.
7120 */
7121template <class T>
7123 using namespace engine;
7124 const int R = o.replications;
7125 if (R <= 1) return ldes_engine_solve_one(sn, o);
7126
7127 // THE BUDGET IS SPLIT ACROSS THE PATHS, as `Solver_ldes_analyzer_parallel` does: each replication runs
7128 // ceil(budget / R) completions with a floor of 1000, so --replications buys an interval at the cost of
7129 // the single path's work rather than R times it. `events` is the same budget as `samples` here and is
7130 // split the same way.
7131 const std::size_t budget = o.events > 0 ? o.events : o.samples;
7132 const std::size_t rep_budget =
7133 std::max<std::size_t>(1000, (budget + static_cast<std::size_t>(R) - 1) / static_cast<std::size_t>(R));
7134 std::vector<LdesResult> reps(static_cast<std::size_t>(R));
7135 auto run_rep = [&](int r) {
7136 LdesOptions ro = o;
7137 ro.replications = 0;
7138 ro.samples = rep_budget;
7139 if (o.events > 0) ro.events = rep_budget;
7140 // A replication measures its OWN interval nowhere: the spread across
7141 // paths is the estimate, so the within-path machinery is switched off.
7142 ro.cimethod = "none";
7143 ro.seed = (o.seed >= 0) ? (o.seed + r) : o.seed;
7144 reps[static_cast<std::size_t>(r)] = ldes_engine_solve_one(sn, ro);
7145 };
7146
7147 // THE POOL. Each replication owns its seed and writes only its own slot, so the result is
7148 // the serial one bit for bit, whatever the interleaving. An unset --numthreads takes half
7149 // the cores, the JAR's LDESOptions.numThreads default. A Logger node forces one thread:
7150 // every path truncates and rewrites the same trace file, and serially the last one wins.
7151 bool has_logger = false;
7152 for (std::size_t nd = 0; nd < sn.nof_nodes(); ++nd)
7153 if (sn.nodes[nd].nodetype == lang::NodeType::Logger) has_logger = true;
7154 unsigned hw = std::thread::hardware_concurrency();
7155 if (hw == 0) hw = 1;
7156 const unsigned want = o.numthreads > 0 ? static_cast<unsigned>(o.numthreads) : (hw + 1) / 2;
7157 const int nthreads = has_logger ? 1 : static_cast<int>(std::min<unsigned>(want, static_cast<unsigned>(R)));
7158 if (nthreads <= 1) {
7159 for (int r = 0; r < R; ++r) run_rep(r);
7160 } else {
7161 // A failing replication is rethrown after the join, the LOWEST index first, so the error
7162 // a caller sees is the one the serial loop would have raised.
7163 std::vector<std::exception_ptr> errs(static_cast<std::size_t>(R));
7164 std::vector<std::thread> pool;
7165 pool.reserve(static_cast<std::size_t>(nthreads));
7166 for (int t = 0; t < nthreads; ++t) {
7167 pool.emplace_back([&, t]() {
7168 for (int r = t; r < R; r += nthreads) {
7169 try {
7170 run_rep(r);
7171 } catch (...) {
7172 errs[static_cast<std::size_t>(r)] = std::current_exception();
7173 }
7174 }
7175 });
7176 }
7177 for (std::size_t t = 0; t < pool.size(); ++t) pool[t].join();
7178 for (int r = 0; r < R; ++r)
7179 if (errs[static_cast<std::size_t>(r)]) std::rethrow_exception(errs[static_cast<std::size_t>(r)]);
7180 }
7181
7182 LdesResult res = reps[0];
7183 // finalJobs and jobDepartures describe ONE path; the mean over paths has no end state.
7184 res.finalJobs = Matrix<double>();
7186 res.QNCI = Matrix<double>();
7187 res.UNCI = Matrix<double>();
7188 res.RNCI = Matrix<double>();
7189 res.TNCI = Matrix<double>();
7190
7191 const std::size_t M = res.nstations, K = res.nclasses;
7192 Matrix<double>* means[4] = {&res.QN, &res.UN, &res.RN, &res.TN};
7193 Matrix<double>* cis[4] = {&res.QNCI, &res.UNCI, &res.RNCI, &res.TNCI};
7194 const Matrix<double>* per_rep[4];
7195
7196 for (int q = 0; q < 4; ++q) {
7197 *means[q] = Matrix<double>(M, K, 0.0);
7198 *cis[q] = Matrix<double>(M, K, 0.0);
7199 }
7200 const double tcrit = t_critical(o.confint > 0.0 ? o.confint : 0.95, R - 1);
7201
7202 for (std::size_t i = 0; i < M; ++i)
7203 for (std::size_t k = 0; k < K; ++k)
7204 for (int q = 0; q < 4; ++q) {
7205 double sum = 0.0;
7206 for (int r = 0; r < R; ++r) {
7207 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
7208 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
7209 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
7210 : &reps[static_cast<std::size_t>(r)].TN;
7211 sum += (*per_rep[q])(i, k);
7212 }
7213 const double mean = sum / R;
7214 (*means[q])(i, k) = mean;
7215 double ss = 0.0;
7216 for (int r = 0; r < R; ++r) {
7217 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
7218 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
7219 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
7220 : &reps[static_cast<std::size_t>(r)].TN;
7221 const double d = (*per_rep[q])(i, k) - mean;
7222 ss += d * d;
7223 }
7224 (*cis[q])(i, k) = tcrit * std::sqrt((ss / (R - 1)) / R);
7225 }
7226
7227 // The system-level rows average the same way; they carry no interval,
7228 // matching what the single-path engine reports for them.
7229 res.XN = Matrix<double>(1, K, 0.0);
7230 res.CN = Matrix<double>(1, K, 0.0);
7231 for (std::size_t k = 0; k < K; ++k) {
7232 double sx = 0.0, sc = 0.0;
7233 for (int r = 0; r < R; ++r) {
7234 sx += reps[static_cast<std::size_t>(r)].XN(0, k);
7235 sc += reps[static_cast<std::size_t>(r)].CN(0, k);
7236 }
7237 res.XN(0, k) = sx / R;
7238 res.CN(0, k) = sc / R;
7239 }
7240
7241 // The fork-join sections average across replications like the station means; FJQLen is re-formed from the
7242 // averaged pair so that Little's law still holds exactly on the reported numbers.
7243 if (!res.FJTput.empty()) {
7244 Matrix<double> x(res.FJTput.rows(), res.FJTput.cols(), 0.0), w = x;
7245 for (int r = 0; r < R; ++r) {
7246 const LdesResult& rp = reps[static_cast<std::size_t>(r)];
7247 for (std::size_t p = 0; p < x.rows() && p < rp.FJTput.rows(); ++p)
7248 for (std::size_t k = 0; k < x.cols() && k < rp.FJTput.cols(); ++k) {
7249 x(p, k) += rp.FJTput(p, k) / R;
7250 w(p, k) += rp.FJRespT(p, k) / R;
7251 }
7252 }
7253 res.FJTput = x;
7254 res.FJRespT = w;
7255 for (std::size_t p = 0; p < x.rows(); ++p)
7256 for (std::size_t k = 0; k < x.cols(); ++k) res.FJQLen(p, k) = x(p, k) * w(p, k);
7257 }
7258
7259 long long total = 0;
7260 for (int r = 0; r < R; ++r) total += reps[static_cast<std::size_t>(r)].total_simulated_events;
7261 res.total_simulated_events = total;
7262 return res;
7263}
7264
7265} // namespace ldes
7266} // namespace line
7267
7268#endif // LINE_SOLVERS_LDES_LDES_ENGINE_H
Delay(model, name): the infinite-server station.
Definition nodes.h:154
InputError(const std::string &what)
Definition error.h:105
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
bool empty() const
Definition matrix.h:92
Queue(model, name, strategy).
Definition nodes.h:146
Source(model, name): the external arrival station.
Definition nodes.h:160
UnsupportedError(const std::string &what)
Definition error.h:119
One variate generator, holding whatever state its family needs.
A network plus its refreshed NetworkStruct.
The exception types the port throws.
Fork-join section metrics: FJQLen, FJRespT and FJTput of every Fork/Join pair.
Busy period measurement for the native LDES engine.
The Cache node of the native LDES engine.
The option and result records of SolverLDES, the discrete-event simulator.
Finite capacity regions for the native LDES engine.
The variate generators of the native LDES engine.
The Petri-net layer of the native LDES engine: Places and Transitions.
The scheduling disciplines of the native LDES engine.
The estimators of the native LDES engine: running integrals, the MSER-5 warmup filter,...
Dense matrix and non-owning view.
Marked MAP (MMAP) algebra: per-class rates, class probabilities, superposition, normalization and sca...
CacheMissResult< T > cache_miss(const Matrix< T > &gamma, const std::vector< int > &m, const Matrix< T > &lambda)
Exact cache miss rates from the recursive normalizing constant.
Definition cache_miss.h:65
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:185
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:431
@ IMMEDIATE
fires with zero delay, resolved by weight and priority
Definition lang_types.h:370
SignalType
G-network signal classes, with the values of MATLAB SignalType.
Definition lang_types.h:171
@ REPLY
completes a synchronous call, releasing a held server
Definition lang_types.h:172
@ NEGATIVE
removes a batch of jobs (Gelenbe's negative customer)
Definition lang_types.h:173
@ CATASTROPHE
removes EVERY job at the station
Definition lang_types.h:174
bool process_is_batch(ProcessType p)
True when an EVENT of this process releases (or, as a service process, completes) a BATCH of jobs who...
Definition lang_types.h:713
@ SDR
Krzesinski (1987) product-form state-dependent routing.
Definition lang_types.h:405
DepartureDiscipline
When a Place releases a served token, MATLAB DepartureDiscipline.
Definition lang_types.h:465
RemovalPolicy
Which job a negative signal removes, with the values of MATLAB RemovalPolicy.
Definition lang_types.h:178
@ FCFS
the oldest waiting job; servers only once nobody waits
Definition lang_types.h:180
@ LCFS
the newest waiting job; servers only once nobody waits
Definition lang_types.h:181
@ RANDOM
uniform over waiting AND in-service jobs
Definition lang_types.h:179
HeteroSchedPolicy
How a heterogeneous station picks among its server types, MATLAB HeteroSchedPolicy.
Definition lang_types.h:458
PollingType
Polling service disciplines, with the values of MATLAB PollingType.
Definition lang_types.h:377
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:380
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:379
@ GATED
serve exactly the jobs present at the polling instant
Definition lang_types.h:378
@ DECREMENTING
serve until the queue is one shorter than at arrival
Definition lang_types.h:381
const char * node_type_to_text(NodeType t)
Name of a node kind, for diagnostics.
Definition lang_types.h:348
ProcessType
Distribution kinds, with the values of MATLAB ProcessType.
Definition lang_types.h:490
@ NHPP
The time-INHOMOGENEOUS families of Ko and Pender (ORL 45, 2017): an NHPP is a rate schedule lambda(t)...
Definition lang_types.h:543
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:235
const char * process_to_text(ProcessType p)
The MATLAB ProcessType name, as sn.procid prints it.
Definition lang_types.h:601
std::function< std::vector< T >(const std::vector< T > &)> CdScaling
A class-dependent scaling map, sn.cdscaling.
Definition lang_types.h:736
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:331
std::size_t preemption_victim(lang::SchedStrategy sched, const std::vector< Job > &in_service, const std::vector< double > &started, const Job &arriving, double c, double now)
Which job in service arriving displaces, or in_service.size() for none.
EventKind
Event tags, ordered so that a service completion precedes an arrival scheduled for the same instant.
@ EV_PLACE_SVC
A service completion in the EMBEDDED QUEUE of a queueing place: the token leaves the place's servers ...
@ EV_ORBIT_ABANDON
A job abandons the retrial orbit (sn.orbitImpatience); tag is its id.
double spn_active_servers(const SpnMode &m, const std::vector< double > &tok)
The clocks an enabled timed mode runs at once: min(servers, enabling degree), the degree being the fl...
Definition ldes_spn.h:109
void ldes_engine_reject(const qn::NetworkStruct< T > &sn, const LdesOptions &o)
Refuse, by name, every model outside this engine's scope.
void batch_means_ci(const Observations &obs, const Truncation &tr, const Accum &acc, const LdesOptions &o, LdesResult &res, const Matrix< double > &u_law, const std::vector< std::vector< bool > > *lost_unserved=nullptr)
Fill the half-width matrices of res from the post-warmup observation series.
Definition ldes_stats.h:658
bool is_preemptive(lang::SchedStrategy s)
True for the disciplines that interrupt a job already in service.
double fsp_virtual_finish(const std::vector< double > &residuals, double target_work, double c, double now)
FSP's virtual finish time: when target would finish if the station ran processor sharing from now on.
std::vector< double > ldes_declared_placement(const qn::NetworkStruct< T > &sn)
The job placement the model's DECLARED initial state stands for, station-major like init_sol,...
int spn_pick_immediate(const std::vector< SpnMode > &modes, const std::vector< double > &tok, double u)
Pick among the enabled IMMEDIATE modes: the highest priority first (lowest value),...
Definition ldes_spn.h:129
void cache_hit(CacheState &cs, std::size_t item, std::size_t at, double u_random)
Serve a HIT in list at.
Definition ldes_cache.h:159
std::vector< double > ps_shares(lang::SchedStrategy sched, const std::vector< PsJob > &jobs, double c, const std::vector< double > &weight, std::size_t nclasses)
The per-job shares of a sharing discipline, in units of one server.
Role
Station roles this engine simulates.
std::string java_double_str(double v)
A double as Java's Double.toString spells it, which is how the reference's Logger writes its time col...
bool is_ps_family(lang::SchedStrategy s)
True for the disciplines that share the server instead of ordering a queue.
bool spn_enabled(const SpnMode &m, const std::vector< double > &tok)
True when m is enabled by the marking tok, which is indexed the same way as the mode's own vectors – ...
Definition ldes_spn.h:85
LdesResult ldes_engine_solve(const qn::NetworkStruct< T > &sn, const LdesOptions &o)
Simulate sn, over independent REPLICATIONS when options.replications > 1.
LdesResult ldes_engine_solve_one(const qn::NetworkStruct< T > &sn, const LdesOptions &o)
Simulate sn in process and return the same record the subprocess client parses out of an ldes-result ...
double tot(const std::vector< double > &pops)
double re(double x)
std::string name(const Element *el, const std::string &key="name")
std::vector< T > mmap_lambda(const Mmap< T > &m)
Alias kept for parity with the MATLAB name.
std::vector< double > pfqn_sdrprob(const SdrCoeff &c, const std::vector< double > &n)
SDR routing probabilities of eq.
Definition pfqn_sdr.h:207
SdrCoeff pfqn_sdrcoeff(const SdrStruct &sdr)
Validates an SDR structure and returns its derived coefficients.
Definition pfqn_sdr.h:105
double pfqn_sdrped(const std::vector< double > &P)
Probability of being denied entry and routed straight to the departure centre.
Definition pfqn_sdr.h:239
Marginal< T > to_marginal(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &state_i, const std::vector< std::size_t > &phasesz, const std::vector< std::size_t > &phaseshift, std::size_t nvar=0)
Port of State.toMarginal for a STATION, one state row at a time.
Definition state.h:131
T cd_factor(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &nir, std::size_t cls)
Port of State.cdclassfactor: the class-dependence multiplier of a class-cls rate at the per-class pop...
T lld_factor(const NetworkStruct< T > &sn, std::size_t ist, double n)
The limited-load-dependent multiplier at population n, 1 when unset.
std::vector< std::pair< std::size_t, std::size_t > > fj_sorted_pairs(const S &sn)
The Fork/Join pairs of a struct, sortrows([forks joins]) over find(sn.fj): ascending in fork node,...
Definition fj_metrics.h:62
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Port of the MATLAB +State package: the encoding that turns a station's state row into marginal job co...
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
std::string name
Definition error.h:38
The knobs of one LDES run.
double confint
Confidence level of the reported half-widths.
long seed
–seed; -1 requests a random stream
bool slotted
–slotted, run on the slot lattice
std::string cimethod
–cimethod: obm | bm | spectral | none
double slot_length
–slotlength
int replications
–replications; 0 = not given (one path)
std::size_t events
0 = not given; overrides samples when set
int numthreads
–numthreads; 0 = not given
std::size_t samples
-s, service-completion budget
One ldes-result document, parsed.
Matrix< double > FJTput
getAvgFJ, FJQLen = FJTput * FJRespT
Matrix< double > TNCI
Matrix< double > XN
(1 x nclasses), per-class visits and system tput
Matrix< double > FJQLen
Matrix< double > FJRespT
Matrix< double > UN
Matrix< double > TN
Matrix< double > UNCI
Matrix< double > RN
Matrix< double > QNCI
Matrix< double > CN
long long total_simulated_events
Matrix< double > jobDepartures
[id, station, class, time] of every departure of a tagged init_jobs job (track_jobs).
Matrix< double > RNCI
Matrix< double > QN
Matrix< double > finalJobs
The jobs present at the end of the run, in the init_jobs layout (track_jobs).
Later-is-greater, so the priority queue pops the earliest event.
bool operator()(const Event &a, const Event &b) const
One scheduled event.
Job job
Delay and PS carry the job in the event.
std::size_t slot
server slot at a buffered station
std::uint64_t tag
Identifies the job the event was scheduled for.
A job held by a station.
One destination NODE of a (node, class) pair, with the class it switches to.
std::size_t node
1-based destination NODE
std::vector< std::pair< std::size_t, double > > cls
(class, probability)
std::size_t station
its station index, or M when it is not one
double mass
total probability into this node, over classes
One routing alternative out of a (node, class) pair.
std::size_t station
its station index, or M when it is not one
std::size_t node
1-based destination NODE
Balking: the (min, max, probability) triples an arrival consults.
double max_jobs
-1 = unbounded above, the wire's spelling
One station's mutable state.
bool state_dependent
True when the station's service RATE depends on its own population, i.e.
bool has_setup
SETUP AND DELAY-OFF: a station that powers down when it falls idle.
std::vector< double > retried
std::vector< std::vector< double > > type_rate
[pool][class], 1/mean, for FSF
std::vector< lang::DropStrategy > droprule
sn.droprule, per class, declared on the DESTINATION station.
std::vector< bool > has_orbit_abandon
std::vector< double > down_rate_raw
The declared absolute rate, kept so the scale can be formed once E[S] is known.
lang::CdScaling< double > cd
sn.cdscaling: beta_r(n), a per-class RATE at the station's population.
lang::HeteroSchedPolicy hetero_policy
std::vector< bool > server_busy
std::vector< std::size_t > held_cls
std::size_t poll_budget
remaining visit budget; 0 = exhausted
std::vector< std::deque< Job > > orbit
per class, oldest first
bool setup_running
a setup is in progress
std::vector< Sampler > orbit_abandon
std::vector< int > max_attempts
std::vector< Sampler > switchover
std::vector< std::vector< BalkRule > > balk
std::vector< std::size_t > type_first
per pool, its first slot
std::vector< double > retrial_lost
std::vector< std::vector< Sampler > > type_svc
[pool][class]
bool setup_on
the server has completed its setup
std::vector< double > class_mean
mean service per class, for SEPT/LEPT
std::vector< bool > server_held
A server HELD ACROSS A SYNCHRONOUS CALL.
std::vector< std::vector< bool > > type_compat
[pool][class]
std::vector< int > retrial_policy
1 = LINEAR (a clock per job), 2 = CONSTANT (one per orbit)
std::vector< double > blocked_at
Jobs held OUTSIDE this station's buffer but counted in its queue length: a BAS or BBS job whose desti...
std::vector< Job > buffer
The waiting room, as an EXPLICIT HEAP rather than a std::priority_queue.
std::vector< std::size_t > blocked_dest_cls
bool resume
PR (continue the residual) as against PI (redraw).
std::vector< double > ps_cd
The class-dependent speed a SHARING station is running at right now, one entry per class.
std::vector< std::vector< bool > > type_has_svc
[pool][class], a law of its own
std::vector< bool > has_patience
std::vector< double > weight
schedparam, the DPS/GPS weights
std::vector< Sampler > patience
Reneging: the abandonment timer of a WAITING job, per class.
std::vector< double > gd_peak
The declared peak of the GLOBAL (Whittle) dependence at this station, one entry per class,...
std::vector< bool > has_retrial
std::vector< Sampler > retrial
RETRIAL: a retrial station has NO WAITING ROOM.
std::function< double(const std::vector< std::size_t > &)> pas_rate
bool size_ordered
The waiting room is ranked by the job's SAMPLED size, so the size has to exist before the job enters ...
std::vector< std::uint64_t > server_tag
bool polling
A POLLING SERVER visits the per-class buffers in a fixed cyclic order, paying a SWITCHOVER on each le...
std::vector< double > lld
sn.lldscaling: the RATE multiplier when n jobs are present, indexed n-1.
std::vector< std::size_t > alfs_order
ALFS walks the pools least-flexible first: fewest compatible classes.
double delayoff_at
when it shuts down
std::vector< std::size_t > server_type
per slot, its pool
std::vector< double > down_scale
std::vector< double > orbit_size
live, per class
std::size_t lps_limit
LPS admission cap; 0 = none.
double util_peak
max(c, max(alpha)): the peak capacity the utilization is reported against.
std::uint64_t bmsp_tag
neutralises a superseded firing
std::vector< std::vector< bool > > pas_swap
std::vector< std::size_t > type_count
per pool, its server count
std::size_t poll_gate
DECREMENTING: jobs found at the polling instant.
std::vector< double > tot_orbit
time integral of the orbit size
std::vector< bool > const_pending
CONSTANT: the shared clock is armed.
std::vector< std::size_t > type_order
ALIS/FAIRNESS rotate this order; a used pool goes to the back.
std::size_t poll_at
the buffer the server is serving or heading to
std::vector< double > server_start
instant the held job entered service
std::vector< bool > server_blocked
A server holding a completed job it cannot hand on, because its destination is full.
std::vector< PsJob > ps_jobs
std::vector< std::size_t > parallelism
Queue.setServerParallelism(class, n): the servers a class-r job seizes for the whole of its service,...
std::vector< Sampler > svc
std::size_t bmsp_pending
jobs the armed firing will complete
std::vector< std::size_t > blocked_dest
std::vector< std::vector< std::size_t > > held_extra
The further slots held beside each primary one, so that releasing the primary releases the whole set.
std::vector< double > classcap
bool has_breakdown
Server breakdown: the server alternates up and down on two clocks.
std::size_t bmsp_cls
the one enabled class
bool has_pools
HETEROGENEOUS SERVER POOLS, empty on a station whose servers are alike.
std::vector< double > cd_peak_class
Class or joint dependence: the declared peak per class, the product over the declared families of the...
bool pas
A PASS-AND-SWAP station serves an ORDERED LIST whose total rate is a function of the whole class sequ...
std::vector< bool > has_switchover
bool bmsp
BULK SERVICE (BMSP): a THIRD station kind, beside the buffered one and the sharing one.
The waiting-room order of a buffered discipline.
The parameters of a Cache node, MATLAB's sn.nodeparam{ind} for a Cache.
std::vector< std::vector< Matrix< T > > > accost
(u) x (n) of (h+1)x(h+1), or empty
Variable forking levels, the twin of MATLAB sn.nodeparam{f}.fanOutLink / .fanOutProb / ....
std::vector< std::vector< lang::Distrib< T > > > fan_out_dist
What State.toMarginal returns for one station and one state row.
Definition state.h:52
std::vector< T > nir
jobs per class
Definition state.h:54
A heterogeneous server pool: count servers that serve only compatible classes, each with its own serv...
std::vector< bool > compatible
per class; empty = every class
std::vector< Distrib< T > > service
per class