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 <cmath>
93#include <cstddef>
94#include <cstdint>
95#include <deque>
96#include <functional>
97#include <limits>
98#include <map>
99#include <numeric>
100#include <queue>
101#include <random>
102#include <string>
103#include <vector>
104
115#include "line/util/error.h"
116#include "line/util/matrix.h"
117
118namespace line {
119namespace ldes {
120namespace engine {
121
122/** Station roles this engine simulates. */
123/**
124 * Station roles this engine simulates.
125 *
126 * A Fork and a Join appear in `sn.stations` because the refresh registers
127 * them, but they SERVE NOTHING: they are handled by the routing layer, which
128 * replicates at one and synchronizes at the other. Giving them a role of their
129 * own keeps them out of the service-station setup, which would otherwise
130 * demand a service law they do not have.
131 */
133
134/**
135 * Event tags, ordered so that a service completion precedes an arrival
136 * scheduled for the same instant.
137 *
138 * The order matters on any model whose rates produce exact ties (a Det
139 * service, a closed class placed at t=0), and it is the completions-then-
140 * arrivals phase order of the Java engine's `slotPhase`. Ties inside one tag
141 * break on the insertion sequence, so the event list is a total order and the
142 * path is reproducible from the seed.
143 */
153 /**
154 * A service completion in the EMBEDDED QUEUE of a queueing place: the token
155 * leaves the place's servers for its depository, where the output arcs can
156 * finally see it. `station` is the place SLOT and `cls` the token colour.
157 *
158 * It sorts AFTER a firing at the same instant, which matters only for the
159 * degenerate Immediate service law: a token whose service ends exactly when
160 * a transition fires reaches the depository after that firing read it, which
161 * is the reference's own order (`placeDepartureActions` re-checks enabling
162 * once it has deposited, so the firing simply happens next).
163 */
165};
166
167/** One scheduled event. */
168struct Event {
169 double t = 0.0;
171 std::size_t station = 0;
172 std::size_t cls = 0;
173 std::size_t slot = 0; ///< server slot at a buffered station
174 /**
175 * Identifies the job the event was scheduled for.
176 *
177 * A preempted or resharing job leaves a departure event behind that the
178 * event list gives no way to cancel, so every completion carries the tag
179 * its job held when it was scheduled and is DISCARDED if the two no longer
180 * agree. Without it a preempted job would depart twice: once on its stale
181 * event and once on the one its resumption schedules.
182 */
183 std::uint64_t tag = 0;
184 Job job; ///< Delay and PS carry the job in the event
185 std::uint64_t seq = 0;
186};
187
188/** Later-is-greater, so the priority queue pops the earliest event. */
190 bool operator()(const Event& a, const Event& b) const {
191 if (a.t != b.t) return a.t > b.t;
192 if (a.kind != b.kind) return a.kind > b.kind;
193 return a.seq > b.seq;
194 }
195};
196
197/** One routing alternative out of a (node, class) pair. */
199 std::size_t node = 0; ///< 1-based destination NODE
200 std::size_t station = 0; ///< its station index, or M when it is not one
201 std::size_t cls = 0;
202 bool sink = false;
203 double cum = 0.0;
204};
205
206/**
207 * One destination NODE of a (node, class) pair, with the class it switches to.
208 *
209 * THE STRATEGY CHOOSES THE NODE AND THE SWITCH ROW CHOOSES THE CLASS, in that
210 * order, which is `selectDestinationWithClassSwitch` and the reason the two are
211 * separated at all. A strategy applied to (node, class) PAIRS would resample
212 * the class as well -- uniformly under RAND, in turn under RROBIN -- and
213 * destroy the class-switch probabilities the pair was carrying.
214 *
215 * The list is built in ASCENDING NODE INDEX because a round-robin pointer walks
216 * it in order: any other order is a different, and equally arbitrary, cycle.
217 */
218struct RouteDest {
219 std::size_t node = 0; ///< 1-based destination NODE
220 std::size_t station = 0; ///< its station index, or M when it is not one
221 bool sink = false;
222 double mass = 0.0; ///< total probability into this node, over classes
223 std::vector<std::pair<std::size_t, double>> cls; ///< (class, probability)
224};
225
226/**
227 * Refuse, by name, every model outside this engine's scope.
228 *
229 * The refusal is the point. A PS station served FCFS, a preemptive-resume
230 * station served non-preemptively, or a blocking model run without blocking
231 * all finish and report plausible numbers; nothing downstream can tell them
232 * from correct ones, so the check has to happen here, before the run.
233 */
234template <class T>
236 using lang::NodeType;
238
239 for (const auto& nd : sn.nodes) {
240 switch (nd.nodetype) {
241 case NodeType::Source:
242 case NodeType::Sink:
243 case NodeType::Queue:
244 case NodeType::Delay:
245 case NodeType::Router:
246 case NodeType::ClassSwitch:
247 case NodeType::Logger:
248 case NodeType::Fork:
249 case NodeType::Join:
250 case NodeType::Cache:
251 case NodeType::Place:
252 case NodeType::Transition:
253 break;
254 default:
255 throw UnsupportedError(
256 std::string("SolverLDES (native engine): node kind ") +
257 lang::node_type_to_text(nd.nodetype) +
258 " is not ported yet; use the subprocess client");
259 }
260 }
261 // HETEROGENEOUS SERVER POOLS are simulated here as of 2026-09-04: each job
262 // occupies one server of one compatible pool and is served at that pool's
263 // own law, which is what makes a pooled model a different system from the
264 // multiserver it would otherwise look like. What is NOT ported is the pools
265 // under a SHARING discipline, where the reference has no per-pool treatment
266 // either -- `calculatePSRates` never consults the server types, so the JAR
267 // silently flattens them. Rather than reproduce that, the sharing case is
268 // refused by name; the max-min fair allocation it would need is the layered
269 // engine's `SnCompatShare`, and neither engine carries it for a flat model.
270 //
271 // A FIFO depository is still understood by no engine at all.
272 for (std::size_t si = 0; si < sn.stations.size(); ++si) {
273 const auto& st = sn.stations[si];
274 if (!st.server_types.empty()) {
275 const SchedStrategy sc = st.sched;
276 const bool sharing = (sc == SchedStrategy::PS || sc == SchedStrategy::DPS ||
277 sc == SchedStrategy::GPS || sc == SchedStrategy::LPS ||
278 sc == SchedStrategy::PSPRIO || sc == SchedStrategy::DPSPRIO ||
279 sc == SchedStrategy::GPSPRIO);
280 if (sharing)
281 throw UnsupportedError(
282 std::string("SolverLDES (native engine): station '") + st.name +
283 "' declares heterogeneous server pools under a sharing discipline, which "
284 "no engine allocates per pool; use a queueing discipline, or the "
285 "subprocess client, which flattens them");
286 if (sc == SchedStrategy::POLLING || sc == SchedStrategy::PAS)
287 throw UnsupportedError(
288 std::string("SolverLDES (native engine): station '") + st.name +
289 "' declares heterogeneous server pools under " +
290 std::string(lang::sched_to_text(sc)) +
291 ", whose controller picks the server itself and has no pool to pick from");
292 }
293 for (lang::DepartureDiscipline d : st.departure_discipline)
295 throw UnsupportedError(
296 std::string("SolverLDES (native engine): station '") + st.name +
297 "' sets a non-NORMAL depository departure discipline, which no "
298 "engine implements");
299 // A TIME-INHOMOGENEOUS MATRIX-SCHEDULE SERVICE (PHt or MAPt) is sampled
300 // by walking a phase process forward from the SERVICE START instant,
301 // which is exact only while service is then delivered continuously at
302 // unit rate. Sharing hands the job a varying fraction of the server,
303 // preemption interrupts the walk, a load- or class-dependent rate
304 // rescales it, and a pool serves it under a law of its own; none of
305 // those is recoverable from a start instant alone, and unlike an NHPP
306 // rate there is no operational-time transform to fall back on, because
307 // tau = int mu du presumes a SCALAR rate while these vary their matrix
308 // entries independently. Refusing mirrors the reference engine's
309 // `assertMatrixScheduleServiceSupported`; the alternative is a silently
310 // wrong sample path.
311 for (std::size_t r = 0; r < sn.service[si].size(); ++r) {
312 const lang::ProcessType pt = sn.service[si][r].type;
313 if (pt != lang::ProcessType::PHT && pt != lang::ProcessType::MAPT) continue;
314 const SchedStrategy sc = st.sched;
315 const std::string family = (pt == lang::ProcessType::MAPT) ? "MAPt" : "PHt";
316 const std::string where = std::string(" service is not supported at station '") +
317 st.name + "' for class '" + sn.classes[r].name + "'";
318 if (is_ps_family(sc) || is_preemptive(sc))
319 throw UnsupportedError("SolverLDES (native engine): " + family + where +
320 " under " + std::string(lang::sched_to_text(sc)) +
321 ". A time-inhomogeneous matrix schedule is walked from"
322 " the service start instant, which is exact only while"
323 " service runs continuously at unit rate; use INF or a"
324 " non-preemptive FCFS/LCFS-family discipline");
325 if (!st.lldscaling.empty())
326 throw UnsupportedError("SolverLDES (native engine): " + family + where +
327 " with load dependence: the rate rescaling cannot be"
328 " applied to a phase walk taken from the service start"
329 " instant");
330 if (sn.gdscaling)
331 throw UnsupportedError("SolverLDES (native engine): " + family + where +
332 " with a global dependence: phi(n) rescales the rate at"
333 " every population change anywhere in the network, which"
334 " a phase walk taken from the service start instant"
335 " cannot be asked for");
336 if (!st.server_types.empty())
337 throw UnsupportedError("SolverLDES (native engine): " + family + where +
338 " with heterogeneous server types: the pool draws its"
339 " own duration, which the walk cannot be asked for");
340 }
341 }
342 for (std::size_t i = 0; i < sn.nstations; ++i) {
343 const auto& st = sn.stations[i];
344 if (st.nodetype == NodeType::Source) continue;
345 switch (st.sched) {
346 case SchedStrategy::INF:
347 case SchedStrategy::FCFS:
348 case SchedStrategy::LCFS:
349 case SchedStrategy::SIRO:
350 case SchedStrategy::HOL:
351 case SchedStrategy::LCFSPRIO:
352 case SchedStrategy::SJF:
353 case SchedStrategy::LJF:
354 case SchedStrategy::SEPT:
355 case SchedStrategy::LEPT:
356 case SchedStrategy::PS:
357 case SchedStrategy::DPS:
358 case SchedStrategy::GPS:
359 case SchedStrategy::PSPRIO:
360 case SchedStrategy::DPSPRIO:
361 case SchedStrategy::GPSPRIO:
362 case SchedStrategy::LPS:
363 case SchedStrategy::LCFSPR:
364 case SchedStrategy::LCFSPI:
365 case SchedStrategy::LCFSPRPRIO:
366 case SchedStrategy::LCFSPIPRIO:
367 case SchedStrategy::FCFSPR:
368 case SchedStrategy::FCFSPI:
369 case SchedStrategy::FCFSPRPRIO:
370 case SchedStrategy::FCFSPIPRIO:
371 case SchedStrategy::SRPT:
372 case SchedStrategy::SRPTPRIO:
373 case SchedStrategy::PSJF:
374 case SchedStrategy::FB:
375 case SchedStrategy::LRPT:
376 case SchedStrategy::SETF:
377 case SchedStrategy::FSP:
378 case SchedStrategy::EDD:
379 case SchedStrategy::EDF:
380 case SchedStrategy::POLLING:
381 case SchedStrategy::PAS:
382 case SchedStrategy::OI:
383 break;
384 default:
385 // EXT IS NOT A SERVICE DISCIPLINE AND IS REFUSED ON PURPOSE.
386 // `Source.m` and `Sink.m` set it as their own marker, so it
387 // never reaches a served station by any supported route; a
388 // Queue explicitly built with it is refused by the reference
389 // too, at its model layer -- `Queue.<init>` throws "Routing
390 // Strategy is not supported in JLINE" before the Java engine
391 // sees the model. Serving it as INF, which is how `npfqn_sqd`
392 // and `sn_has_product_form` group it, would be a guess at a
393 // semantics neither reference implements.
394 throw UnsupportedError(
395 "SolverLDES (native engine): station '" + st.name +
396 "' uses a scheduling this engine does not simulate (EXT is the "
397 "Source and Sink marker, not a queueing discipline)");
398 }
399 }
400 // BAS and BBS are simulated; RSRD and the retrial rules are not. Only a
401 // rule on a CAPPED station can ever fire, and the refresh gives every
402 // station a default rule including the Source, so an uncapped station is
403 // not consulted -- rejecting on the default would refuse every model.
404 for (std::size_t i = 0; i < sn.nstations; ++i) {
405 if (sn.stations[i].nodetype == NodeType::Source) continue;
406 if (!std::isfinite(sn.stations[i].cap)) continue;
407 for (std::size_t r = 0; r < sn.nclasses; ++r) {
408 if (i >= sn.droprule.size() || r >= sn.droprule[i].size()) continue;
409 const lang::DropStrategy d = sn.droprule[i][r];
412 throw UnsupportedError(
413 "SolverLDES (native engine): station '" + sn.stations[i].name +
414 "' declares a blocking rule that is not DROP, BAS or BBS");
415 }
416 }
417 // AN ARRIVAL BATCH IS DRAWN AND RELEASED (see the EV_ARRIVAL arm). It used to be
418 // stored and ignored -- `Station::arrival_batch` crossed the wire and BatchArrival
419 // was declared, but every epoch released one job, so an M[3]/M/2 queue came back
420 // as the M/M/2 queue at a third of the task rate -- and was then refused by name
421 // rather than answered wrongly. Both are now gone.
422 //
423 // `Source.setArrivalBatch` and a process that carries its OWN batch sizes are
424 // MUTUALLY EXCLUSIVE: a BMAP or a BMMAPt already says how many jobs an epoch
425 // releases, so a second law bolted on top would multiply the two.
426 for (std::size_t i = 0; i < sn.nstations; ++i) {
427 const auto& stb = sn.stations[i];
428 for (std::size_t r = 0; r < stb.arrival_batch.size(); ++r) {
429 if (stb.arrival_batch[r].disabled) continue;
430 if (r < sn.service[i].size() && lang::process_is_batch(sn.service[i][r].type))
431 throw InputError(
432 "SolverLDES (native engine): station '" + stb.name + "' class '" +
433 sn.classes[r].name + "' declares BOTH an arrival batch and a " +
434 lang::process_to_text(sn.service[i][r].type) +
435 " arrival process, which carries its own batch sizes; the two are "
436 "mutually exclusive");
437 }
438 }
439 // A BULK SERVER (BMSP) is refused with immediate feedback and with a blocking
440 // destination, the two model-level features its per-firing release cannot honour.
441 // Feedback would have to hand a completed job back into a server the firing clock
442 // owns rather than a slot, and blocking has no answer to "which of the b jobs
443 // just released is the one that blocks". The per-station guards -- single server,
444 // single class, FCFS, no preemption/polling/PAS/setup/load dependence/pools --
445 // are applied where the stations are built.
446 for (std::size_t i = 0; i < sn.nstations; ++i) {
447 if (sn.stations[i].nodetype == NodeType::Source) continue;
448 bool bulk = false;
449 for (std::size_t r = 0; r < sn.nclasses && r < sn.service[i].size(); ++r)
450 if (!sn.disabled[i][r] && lang::process_is_batch(sn.service[i][r].type)) bulk = true;
451 if (!bulk) continue;
452 for (std::size_t r = 0; r < sn.nclasses; ++r)
453 if (i < sn.immfeed.size() && r < sn.immfeed[i].size() && sn.immfeed[i][r])
454 throw UnsupportedError(
455 "SolverLDES (native engine): batch (BMSP) service at station '" +
456 sn.stations[i].name + "' is incompatible with immediate feedback");
457 for (std::size_t d = 0; d < sn.nstations && d < sn.droprule.size(); ++d)
458 for (std::size_t r = 0; r < sn.droprule[d].size(); ++r)
459 if (sn.droprule[d][r] == lang::DropStrategy::BAS ||
460 sn.droprule[d][r] == lang::DropStrategy::BBS)
461 throw UnsupportedError(
462 "SolverLDES (native engine): batch (BMSP) service at station '" +
463 sn.stations[i].name + "' is incompatible with the blocking rule at "
464 "station '" + sn.stations[d].name +
465 "'; a firing releases several jobs at once and none of them can hold "
466 "the server on the destination's behalf");
467 }
468 // A REPLY SIGNAL completes a SYNCHRONOUS CALL: the caller keeps its server
469 // while the callee runs and gets it back only when the reply arrives. That
470 // is simulated -- see the `pending_reply` map below -- but only where a
471 // server EXISTS to hold. A sharing station divides one server continuously
472 // among everyone present and has no slot to park, so a caller there would
473 // be served as though its call were asynchronous.
474 for (std::size_t r = 0; r < sn.nclasses; ++r) {
475 if (r >= sn.syncreply.size() || sn.syncreply[r] == 0) continue;
476 for (std::size_t i = 0; i < sn.nstations; ++i) {
477 if (sn.stations[i].nodetype == NodeType::Source) continue;
478 if (i < sn.disabled.size() && r < sn.disabled[i].size() && sn.disabled[i][r]) continue;
479 if (is_ps_family(sn.stations[i].sched) || sn.stations[i].sched == SchedStrategy::INF)
480 throw UnsupportedError(
481 "SolverLDES (native engine): class '" + sn.classes[r].name +
482 "' makes a synchronous call at station '" + sn.stations[i].name +
483 "', which has no server slot to hold across it");
484 }
485 }
486 // SLOTTED TIME EXCLUDES TWO FAMILIES, and refuses rather than approximates.
487 // NHPP integrates a cumulative intensity over CONTINUOUS time, so its
488 // inverse transform does not land on the lattice; the PS family divides the
489 // server continuously among the jobs in service, which has no discrete-time
490 // counterpart at all. Serving either on a lattice would answer a different
491 // model.
492 if (o.slotted) {
493 for (std::size_t i = 0; i < sn.nstations; ++i) {
494 if (sn.stations[i].nodetype == NodeType::Source) continue;
495 if (is_ps_family(sn.stations[i].sched))
496 throw UnsupportedError(
497 "SolverLDES (native engine): station '" + sn.stations[i].name +
498 "' shares its server continuously, which slotted mode has no "
499 "counterpart for");
500 for (std::size_t r = 0; r < sn.nclasses; ++r)
501 if (!sn.disabled[i][r] && sn.procid(i + 1, r + 1) == lang::ProcessType::NHPP)
502 throw UnsupportedError(
503 "SolverLDES (native engine): station '" + sn.stations[i].name +
504 "' carries an NHPP, whose intensity is integrated over continuous "
505 "time and does not land on the slot lattice");
506 }
507 for (std::size_t i = 0; i < sn.nstations; ++i)
508 if (sn.breakdownparam.find(i + 1) != sn.breakdownparam.end())
509 throw UnsupportedError(
510 "SolverLDES (native engine): station '" + sn.stations[i].name +
511 "' declares a server breakdown, whose failure and repair epochs are drawn on "
512 "a continuous clock and do not land on the slot lattice");
513 }
514}
515
516/** One station's mutable state. */
520 bool ps = false;
521 bool preemptive = false;
522 bool resume = false; ///< PR (continue the residual) as against PI (redraw)
523 /**
524 * The waiting room is ranked by the job's SAMPLED size, so the size has to
525 * exist before the job enters service. A time-inhomogeneous law is drawn at
526 * the service start instant everywhere else (see `draw_at_arrival`); here it
527 * cannot be, and is drawn on arrival as the reference does.
528 */
529 bool size_ordered = false;
530 std::size_t nservers = 1;
531 std::size_t lps_limit = 0; ///< LPS admission cap; 0 = none
532 double cap = std::numeric_limits<double>::infinity();
533 std::vector<double> classcap;
534 /**
535 * `sn.droprule`, per class, declared on the DESTINATION station.
536 *
537 * JMT's convention and the reference's: the policy belongs to the station
538 * whose capacity is limited, not to the upstream one whose server ends up
539 * blocked. A model that declared it on the source would be read as having
540 * no policy at all.
541 */
542 std::vector<lang::DropStrategy> droprule;
543 /**
544 * Jobs held OUTSIDE this station's buffer but counted in its queue length:
545 * a BAS or BBS job whose destination is full occupies the upstream server
546 * and is charged to the destination, which is where it is queueing for.
547 * Charging it upstream would let a full station report a queue below its
548 * own capacity while jobs pile up in front of it.
549 */
550 std::vector<double> blocked_at;
551 std::vector<double> weight; ///< schedparam, the DPS/GPS weights
552 std::vector<double> class_mean; ///< mean service per class, for SEPT/LEPT
553 /**
554 * `sn.lldscaling`: the RATE multiplier when n jobs are present, indexed
555 * n-1. It divides the sampled service time at an ordinary station and IS
556 * the effective server count at a sharing one -- the same table read two
557 * ways, because a share of c servers and a c-fold rate are the same thing
558 * only for PS.
559 */
560 std::vector<double> lld;
561 /** `sn.cdscaling`: beta_r(n), a per-class RATE at the station's population. */
563 bool has_cd = false;
564 /**
565 * A PASS-AND-SWAP station serves an ORDERED LIST whose total rate is a
566 * function of the whole class sequence (Dorsman and Gardner 2024).
567 *
568 * The rate of the token at position p is the INCREMENT
569 * mu(c1..cp) - mu(c1..c_{p-1}), which is what makes the station
570 * order-independent. On a completion at position p the departing job is not
571 * the one at p: the SWAP GRAPH chains forward from p to the last position
572 * reachable through it, and that job departs while the chain shifts into
573 * the hole. An empty swap graph is the plain OI station, where p itself
574 * departs.
575 */
576 bool pas = false;
577 std::function<double(const std::vector<std::size_t>&)> pas_rate;
578 std::vector<std::vector<bool>> pas_swap;
579 std::vector<Job> pas_list;
580 std::uint64_t pas_tag = 0;
581
582 /**
583 * A POLLING SERVER visits the per-class buffers in a fixed cyclic order,
584 * paying a SWITCHOVER on each leg.
585 *
586 * It advances ONE STEP AT A TIME and pays that step's switchover whether or
587 * not the buffer it reaches holds work. Skipping ahead to the next
588 * non-empty buffer for a single switchover is a different and much faster
589 * discipline: it shortens the cycle and understates the waiting time by
590 * tens of percent against Takagi's exact formulas, which assume the walking
591 * server. The one exception is a lap made entirely of zero-time legs, which
592 * would spin forever at the same instant: there the server PARKS and the
593 * next arrival restarts it.
594 *
595 * The switchover charged to a leg is the one stored against the buffer
596 * being LEFT -- that is Takagi's r_i and what `Queue.setSwitchover` means --
597 * not the one against the buffer being entered.
598 */
599 bool polling = false;
600 std::size_t poll_at = 0; ///< the buffer the server is serving or heading to
601 bool poll_switching = false;
602 bool poll_parked = false;
603 std::size_t poll_budget = 0; ///< remaining visit budget; 0 = exhausted
604 std::size_t poll_gate = 0; ///< DECREMENTING: jobs found at the polling instant
606 std::size_t poll_k = 1;
607 std::vector<Sampler> switchover;
608 std::vector<bool> has_switchover;
609
610 /**
611 * SETUP AND DELAY-OFF: a station that powers down when it falls idle.
612 *
613 * The server runs a four-state machine. It starts OFF; an arrival puts it
614 * in SETUP for the setup time and only then does service begin; when it
615 * empties it enters DELAYOFF and shuts down when that timer expires, so a
616 * job arriving during the delay-off window pays NOTHING and one arriving
617 * after it pays the full setup. Collapsing the two idle states makes every
618 * arrival to an idle server pay the setup, which is the model of a server
619 * with no delay-off at all.
620 */
621 bool has_setup = false;
623 bool setup_on = false; ///< the server has completed its setup
624 bool setup_running = false; ///< a setup is in progress
625 double delayoff_at = std::numeric_limits<double>::infinity(); ///< when it shuts down
626
627 /**
628 * RETRIAL: a job refused admission joins an ORBIT and tries again later,
629 * instead of being dropped or queueing.
630 *
631 * The orbit is not a buffer. Its occupants are not at the station, do not
632 * occupy its capacity and are not in its queue length; they are a separate
633 * population that re-offers itself at the retrial rate. Treating a retrial
634 * job as queued makes the station hold more than its own capacity, which is
635 * exactly what the model says it cannot.
636 */
637 std::vector<Sampler> retrial;
638 std::vector<bool> has_retrial;
639 std::vector<int> max_attempts;
640 std::vector<double> orbit_size; ///< live, per class
641 std::vector<double> tot_orbit; ///< time integral of the orbit size
642 double orbit_last = 0.0;
643 std::vector<double> retried, retrial_lost;
644
645 /** Reneging: the abandonment timer of a WAITING job, per class. */
646 std::vector<Sampler> patience;
647 std::vector<bool> has_patience;
648 /** Balking: the (min, max, probability) triples an arrival consults. */
649 struct BalkRule {
650 double min_jobs = 0.0;
651 double max_jobs = -1.0; ///< -1 = unbounded above, the wire's spelling
652 double probability = 0.0;
653 };
654 std::vector<std::vector<BalkRule>> balk;
655 std::vector<Sampler> svc;
656 std::vector<bool> off;
657
658 /**
659 * BULK SERVICE (BMSP): a THIRD station kind, beside the buffered one and the
660 * sharing one.
661 *
662 * A BMAP or BMMAPt assigned as a SERVICE process is a batch Markovian service
663 * process: the station has no per-job service time at all, only a station-level
664 * FIRING CLOCK, and one firing completes min(b, present) jobs at once. Neither
665 * existing path can express that -- `draw_at_arrival` gives every job a
666 * `Job::service`, and a sharing station divides one server among everyone -- so
667 * this is its own arm in the admit path and in the departure arm.
668 *
669 * The clock is armed when the station goes from EMPTY to non-empty and re-armed
670 * at every firing, so the server fires continuously while it holds work. That is
671 * what makes a wall-clock schedule walk exact for a BMMAPt clock: the firing
672 * interval is drawn from the instant the clock is armed, never from a job's
673 * arrival.
674 *
675 * Restricted BY NAME to a single-server, single-class FCFS queue with no
676 * preemption, polling, PAS, setup, load dependence, server pools, immediate
677 * feedback or blocking destination, exactly as the Java engine restricts it: a
678 * bulk server is a single server by construction, and "which of the b jobs it
679 * just released is the one blocking" has no answer.
680 */
681 bool bmsp = false;
682 std::size_t bmsp_cls = 0; ///< the one enabled class
683 std::size_t bmsp_pending = 1; ///< jobs the armed firing will complete
684 std::uint64_t bmsp_tag = 0; ///< neutralises a superseded firing
685
686 /**
687 * HETEROGENEOUS SERVER POOLS, empty on a station whose servers are alike.
688 *
689 * A pool is `count` servers that accept only `compatible` classes and serve
690 * them at a law of their own, so a model that declares them is a DIFFERENT
691 * system from the multiserver it would otherwise look like: flattening the
692 * pools into `nservers` answers that other system, plausibly and silently.
693 *
694 * The slots are laid out POOL BY POOL in declaration order, so slot
695 * `type_first[t] .. type_first[t] + type_count[t] - 1` belongs to pool `t`
696 * and `server_type[slot]` is the inverse. Every other part of the engine
697 * addresses a server by its slot and needs to know nothing about this.
698 */
699 bool has_pools = false;
700 std::vector<std::size_t> server_type; ///< per slot, its pool
701 std::vector<std::size_t> type_first; ///< per pool, its first slot
702 std::vector<std::size_t> type_count; ///< per pool, its server count
703 std::vector<std::vector<bool>> type_compat; ///< [pool][class]
704 std::vector<std::vector<Sampler>> type_svc; ///< [pool][class]
705 std::vector<std::vector<bool>> type_has_svc; ///< [pool][class], a law of its own
706 std::vector<std::vector<double>> type_rate; ///< [pool][class], 1/mean, for FSF
708 /** ALIS/FAIRNESS rotate this order; a used pool goes to the back. */
709 std::vector<std::size_t> type_order;
710 /** ALFS walks the pools least-flexible first: fewest compatible classes. */
711 std::vector<std::size_t> alfs_order;
712
713 /**
714 * The waiting room, as an EXPLICIT HEAP rather than a `std::priority_queue`.
715 *
716 * FSP is why. Its order is the virtual finish time of a processor-sharing
717 * schedule over the residual work PRESENT AT THE STATION, so it changes
718 * whenever the station's composition does and cannot be stamped on a job
719 * when it arrives. A container that hides its storage cannot be reordered
720 * or rescanned; this one is scanned linearly for FSP and kept as a heap
721 * for every other discipline, whose keys are per-job constants.
722 */
723 std::vector<Job> buffer;
725 std::vector<Job> server;
726 std::vector<bool> server_busy;
727 std::vector<double> server_start; ///< instant the held job entered service
728 std::vector<std::uint64_t> server_tag;
729 /**
730 * A server holding a completed job it cannot hand on, because its
731 * destination is full. It is NOT free: refusing to start a new service is
732 * the whole content of blocking, and a blocked slot treated as idle turns
733 * a blocking network into a lossless one with the same topology.
734 */
735 std::vector<bool> server_blocked;
736 /**
737 * A server HELD ACROSS A SYNCHRONOUS CALL. Its job has left for the callee
738 * and the slot is NOT free: releasing it would let the caller serve someone
739 * else while its own call is outstanding, which is the asynchronous model
740 * under the synchronous one's name. Separate from `server_blocked`, which
741 * holds a COMPLETED job whose destination is full.
742 */
743 std::vector<bool> server_held;
744 std::vector<std::size_t> held_cls;
745 std::vector<Job> blocked_job;
746 std::vector<std::size_t> blocked_dest;
747 std::vector<std::size_t> blocked_dest_cls;
748
749 // sharing disciplines
750 std::vector<PsJob> ps_jobs;
751 double ps_last_update = 0.0;
752
753 /**
754 * True when the station's service RATE depends on its own population, i.e.
755 * it declares a load- or class-dependence table.
756 *
757 * Such a station cannot schedule a departure once and forget it: the rate
758 * in force changes every time the population does, so the residual work
759 * must be integrated forward and the completion re-timed at each change --
760 * the same discipline a sharing station needs, for a different reason.
761 * Applying the scaling only at service start would time every completion
762 * at the rate that happened to hold when it began.
763 */
764 bool state_dependent = false;
765 /** max(c, max(alpha)): the peak capacity the utilization is reported against. */
766 double util_peak = 1.0;
767 /**
768 * `Queue.setServerParallelism(class, n)`: the servers a class-r job seizes
769 * for the whole of its service, one entry per class.
770 *
771 * The job holds the WHOLE SET or it waits, so the station serves at most
772 * floor(c/n) of them at once and the utilization one contributes is n/c
773 * rather than 1/c. That is JMT's `Server.serverNumRequired`, and it is not
774 * the same model as an n-fold slower server, which would leave the other
775 * slots free for someone else.
776 */
777 std::vector<std::size_t> parallelism;
778 bool has_parallelism = false;
779 /**
780 * The further slots held beside each primary one, so that releasing the
781 * primary releases the whole set. Empty at a station that declares none.
782 */
783 std::vector<std::vector<std::size_t> > held_extra;
784 /**
785 * The declared peak of the GLOBAL (Whittle) dependence at this station, one
786 * entry per class, 1 when the model declares none.
787 *
788 * It MULTIPLIES whatever peak the station already reports against, because
789 * phi multiplies the rate the class-, joint- and load-dependent scalings
790 * have already set; `getPeakScaling` folds the same product.
791 */
792 std::vector<double> gd_peak;
793 /**
794 * The class-dependent speed a SHARING station is running at right now, one
795 * entry per class.
796 *
797 * It is cached rather than recomputed because `ps_advance` integrates the
798 * interval that just ENDED while the caller has already written the new
799 * population: reading the handle there would credit that interval at the
800 * speed that only starts now. `ps_reschedule` refreshes it in the same
801 * breath as it re-times the departures, which is the moment the new speed
802 * actually takes effect -- the same flush-then-install order
803 * `Accum::set_busy_scale` uses for load dependence.
804 */
805 std::vector<double> ps_cd;
806 /**
807 * Server breakdown: the server alternates up and down on two clocks.
808 *
809 * `down_scale[r]` is the RATE MULTIPLIER while down, `down_rate(r) *
810 * E[S(i,r)]`, so the whole outage is expressed as a state-dependent rate
811 * change and needs no separate machinery: a zero multiplier stalls the job
812 * with its residual intact (nothing is rescheduled, so nothing departs
813 * until repair) and a positive one is the degraded server. That is exactly
814 * the reference's semantics -- a breakdown does not evict the job in
815 * service, and `downServiceRates` degrades rather than stops.
816 */
817 bool has_breakdown = false;
818 bool up = true;
819 std::vector<double> down_scale;
820 /** The declared absolute rate, kept so the scale can be formed once E[S] is known. */
821 std::vector<double> down_rate_raw;
823 double sd_last_update = 0.0;
824};
825
826} // namespace engine
827
828/**
829 * Simulate `sn` in process and return the same record the subprocess client
830 * parses out of an `ldes-result` document.
831 *
832 * A metric the run did not measure stays EMPTY rather than becoming a matrix
833 * of zeros: zero is a measurement and absence is not.
834 */
835template <class T>
837 using namespace engine;
838 using lang::NodeType;
840
841 ldes_engine_reject(sn, o);
842
843 const std::size_t M = sn.nstations, K = sn.nclasses;
844 if (M == 0 || K == 0) throw InputError("SolverLDES (native engine): empty model");
845
846 const std::uint64_t max_events =
847 static_cast<std::uint64_t>(o.events > 0 ? o.events : o.samples);
848 if (max_events == 0)
849 throw InputError("SolverLDES (native engine): the completion budget is zero");
850
851 // ---- random streams, one band per role ---------------------------------
852 const std::uint64_t base =
853 (o.seed >= 0) ? static_cast<std::uint64_t>(o.seed) : std::random_device{}();
854 /**
855 * Snap a sampled duration onto the slot lattice, or REFUSE it.
856 *
857 * A non-lattice sample is an ERROR, not something to round: rounding
858 * silently changes the distribution, and a model whose service law is not
859 * lattice-valued is not a discrete-time model. The reference refuses for
860 * the same reason. Only Geometric, Det with an integral slot count, and
861 * DMAP produce lattice values.
862 */
863 const bool slotted = o.slotted;
864 const double slot_len = o.slot_length;
865 auto slot_snap = [&](double v, const char* what) -> double {
866 if (!slotted || v == 0.0) return v;
867 const double slots = v / slot_len;
868 const double rounded = std::floor(slots + 0.5);
869 if (rounded < 1.0 || std::fabs(slots - rounded) > 1e-9 * std::max(1.0, slots))
870 throw InputError(std::string("SolverLDES (native engine): slotted mode sampled a ") +
871 what + " of " + std::to_string(v) +
872 ", which is not a positive multiple of the slot length " +
873 std::to_string(slot_len) +
874 "; a discrete-time model needs lattice-valued interarrival and "
875 "service times, e.g. Geometric or Det on an integral slot count");
876 return rounded * slot_len;
877 };
878
879 /**
880 * ONE STREAM PER (node, class), with the reference's own offsets.
881 *
882 * `Solver_ssj` never shares a stream between two generators: it builds one
883 * MRG32k3a and one java.util.Random per (node, class) and seeds them from
884 * (seed, offset), the MRG stream through the `rng::SeedHash` of the pair and
885 * the Random from `seed + offset`, with
886 *
887 * arrival (source s, class k) stream (s*K + k)*10, random the same + 2000
888 * service (svc j, class k) both ((numSources + j)*K + k)*10 + 1000
889 * routing ROUTING_SEED_BASE = 900000
890 *
891 * where `numSources` counts SOURCE nodes and `j` indexes the SERVICE nodes
892 * in order, neither of which is the station index. Sharing three run-wide
893 * streams instead, as this engine did, changes the interleaving of every
894 * draw: two stations that each consume their own stream in the reference
895 * would take turns from one here, so the paths diverge from the first event
896 * even with identical generators and identical quantiles.
897 *
898 * The station-level draws that are not per-class -- a polling switchover,
899 * a setup or delay-off, an SPN transition -- sit on `g_aux[station]` in a
900 * band of their own, mirroring the reference's own
901 * `(numSources + numServiceNodes + j)` family without claiming to match it
902 * draw for draw.
903 */
904 const long long seed_ll = static_cast<long long>(base);
905 const long long Kll = static_cast<long long>(K);
906 std::size_t num_sources = 0, num_service_nodes = 0;
907 std::vector<std::size_t> svc_index(M, 0);
908 for (std::size_t i = 0; i < M; ++i) {
909 if (sn.stations[i].nodetype == NodeType::Source) {
910 ++num_sources;
911 } else {
912 svc_index[i] = num_service_nodes++;
913 }
914 }
915 const long long nsrc = static_cast<long long>(num_sources);
916 const long long nsvc = static_cast<long long>(num_service_nodes);
917
918 std::vector<Rng> g_arr; ///< [class], at the (single) source
919 g_arr.reserve(K);
920 for (std::size_t k = 0; k < K; ++k) {
921 const long long off = (static_cast<long long>(k)) * 10; // srcIdx == 0
922 g_arr.push_back(Rng(seed_ll, off, off + 2000));
923 }
924 std::vector<std::vector<Rng>> g_svc; ///< [station][class]
925 g_svc.reserve(M);
926 for (std::size_t i = 0; i < M; ++i) {
927 std::vector<Rng> row;
928 row.reserve(K);
929 for (std::size_t k = 0; k < K; ++k) {
930 const long long off =
931 ((nsrc + static_cast<long long>(svc_index[i])) * Kll + static_cast<long long>(k)) *
932 10 + 1000;
933 row.push_back(Rng(seed_ll, off));
934 }
935 g_svc.push_back(row);
936 }
937 // [station][pool][class]: a heterogeneous pool's own service stream, banded
938 // past `g_svc` so a pooled station's draws never interleave with the
939 // class-level ones and a homogeneous model's sequence is untouched.
940 std::vector<std::vector<std::vector<Rng>>> g_hsvc(M);
941 for (std::size_t i = 0; i < M; ++i) {
942 const std::size_t nT = sn.stations[i].server_types.size();
943 g_hsvc[i].resize(nT);
944 for (std::size_t t = 0; t < nT; ++t) {
945 g_hsvc[i][t].reserve(K);
946 for (std::size_t k = 0; k < K; ++k) {
947 const long long off =
948 ((nsrc + static_cast<long long>(svc_index[i])) * Kll +
949 static_cast<long long>(k)) * 10 +
950 600000 + static_cast<long long>(t) * 137;
951 g_hsvc[i][t].push_back(Rng(seed_ll, off));
952 }
953 }
954 }
955 std::vector<Rng> g_aux; ///< [station], the draws that carry no class
956 g_aux.reserve(M);
957 for (std::size_t i = 0; i < M; ++i) {
958 const long long off = ((nsrc + nsvc + static_cast<long long>(svc_index[i])) * Kll) * 10;
959 g_aux.push_back(Rng(seed_ll, off));
960 }
961 Rng g_routing(seed_ll, 900000); // ROUTING_SEED_BASE
962 // The SPN transition clocks are a model-level draw, not a station's.
963 // The reference bands the place generators at +5000; this is that band.
964 Rng g_spn(seed_ll, 5000);
965 // The SIRO pick of a queueing place's embedded queue, on a band of its own:
966 // a place that serves FCFS or INF never reaches it, so adding the discipline
967 // to the engine moves no other model's draws.
968 Rng g_qplace(seed_ll, 5001);
969 // The fork degree and branch activation draws, on the reference's own
970 // `new Random(seed + 88888)` band. DRAWN FROM ONLY BY A FORK THAT DECLARES
971 // AN OVERRIDE: a plain fork must consume nothing here, or every seeded
972 // golden and the bit-exact agreement with the Java engine would move.
973 Rng g_fork(seed_ll, 88888);
974 // THE SIRO KEY of an admitted job, on the reference's `new Random(seed + 99999)` band (`siroRng`). It used to
975 // come off g_routing, which made every admission consume a ROUTING draw the Java engine never takes, so any
976 // model whose routing stream is read at all (a probabilistic split, a balk) parted company with the reference
977 // path. Only SIRO reads the key; the routing stream now carries only the draws Java's routingRng carries.
978 Rng g_siro(seed_ll, 99999);
979
980 /**
981 * True when a fork's parameters actually vary: some branch is uncertain,
982 * some link carries a count other than the scalar, or some link draws its
983 * count. A fork that declares the matrices but leaves them uniform stays on
984 * the fixed path, so it keeps drawing nothing.
985 */
986 auto fork_is_variable = [&](std::size_t node) -> bool {
987 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
988 if (fp == 0) return false;
989 const double tpl = sn.nodes[node - 1].tasks_per_link;
990 for (std::size_t k = 0; k < fp->fan_out_link.rows(); ++k)
991 for (std::size_t r = 0; r < fp->fan_out_link.cols(); ++r) {
992 const double p = fp->fan_out_prob(k, r);
993 if (p == 0.0) continue; // link not taken
994 if (p != 1.0) return true;
995 if (fp->fan_out_link(k, r) != tpl) return true;
996 if (!fp->fan_out_dist[k][r].disabled) return true;
997 }
998 return false;
999 };
1000
1001 /** One draw from a jobs-per-link distribution, by inverse CDF on its pmf. */
1002 auto sample_fork_degree = [&](const lang::Distrib<double>& d) -> int {
1003 double tot = 0.0;
1004 for (std::size_t e = 0; e < d.params.size(); ++e) tot += d.params[e];
1005 const double u = g_fork.aux.next_double() * tot;
1006 double accp = 0.0;
1007 for (std::size_t e = 0; e < d.params.size(); ++e) {
1008 accp += d.params[e];
1009 if (u <= accp)
1010 return static_cast<int>((d.trace.empty() ? static_cast<double>(e + 1) : d.trace[e]) +
1011 0.5);
1012 }
1013 return static_cast<int>(
1014 (d.trace.empty() ? static_cast<double>(d.params.size()) : d.trace.back()) + 0.5);
1015 };
1016
1017 // ---- stations ----------------------------------------------------------
1018 std::vector<StationState> S(M);
1019 std::size_t source_st = M;
1020 std::vector<int> classprio(K, 0);
1021 std::vector<double> classdeadline(K, std::numeric_limits<double>::infinity());
1022 for (std::size_t r = 0; r < K; ++r) {
1023 classprio[r] = sn.classes[r].prio;
1024 classdeadline[r] = sn.classes[r].deadline;
1025 }
1026
1027 for (std::size_t i = 0; i < M; ++i) {
1028 const auto& st = sn.stations[i];
1029 StationState& s = S[i];
1030 s.sched = st.sched;
1031 s.off.assign(K, true);
1032 s.class_mean.assign(K, 0.0);
1033 s.weight.assign(K, 1.0);
1034 for (std::size_t r = 0; r < K; ++r)
1035 if (r < st.schedparam.size()) s.weight[r] = num_traits<T>::to_double(st.schedparam[r]);
1036
1037 if (st.nodetype == NodeType::Source) {
1038 s.role = Role::Source;
1039 source_st = i;
1040 } else if (st.nodetype == NodeType::Fork || st.nodetype == NodeType::Join ||
1041 st.nodetype == NodeType::Place || st.nodetype == NodeType::Transition) {
1042 // A Place holds tokens and a Transition moves them; neither serves,
1043 // so neither belongs in the service-station setup.
1044 s.role = Role::Synchronization;
1045 // A JOIN STILL REPORTS. It serves nothing, so it stays out of the
1046 // setup below, but it holds siblings and releases parents and the
1047 // result assembly must read those rows; leaving every class OFF is
1048 // what emitted it as an all-zero station. `off` gates the report
1049 // here, not admission: `admit` is never reached through a Join.
1050 if (st.nodetype == NodeType::Join) s.off.assign(K, false);
1051 // A PLACE STILL REPORTS, for the same reason a Join does: it holds
1052 // tokens and the result assembly has to read its rows. The marking
1053 // is carried in `acc.qlen` by the SPN section below, which is what
1054 // QN, TN and (for a queueing place) UN are read off.
1055 //
1056 // THE SERVER COUNT IS THE EMBEDDED QUEUE'S, and it doubles as the
1057 // utilization denominator: a c-server place reports busy/(t*c),
1058 // while an INF one leaves `nservers` at 1 so that busy/t is the mean
1059 // number in service, which is the convention LINE uses for every
1060 // infinite server (U == Q).
1061 if (st.nodetype == NodeType::Place) {
1062 s.off.assign(K, false);
1063 if (std::isfinite(st.nservers))
1064 s.nservers = static_cast<std::size_t>(st.nservers + 0.5);
1065 }
1066 } else if (st.nodetype == NodeType::Delay || st.sched == SchedStrategy::INF) {
1067 s.role = Role::Delay;
1068 } else {
1069 s.role = Role::Queue;
1070 s.ps = is_ps_family(st.sched);
1071 s.preemptive = is_preemptive(st.sched);
1072 s.resume = is_preemptive_resume(st.sched);
1073 // The disciplines whose WaitCmp reads job.service or job.remaining.
1074 s.size_ordered = (st.sched == SchedStrategy::SJF ||
1075 st.sched == SchedStrategy::LJF ||
1076 st.sched == SchedStrategy::SRPT ||
1077 st.sched == SchedStrategy::SRPTPRIO ||
1078 st.sched == SchedStrategy::PSJF ||
1079 st.sched == SchedStrategy::LRPT ||
1080 st.sched == SchedStrategy::FSP);
1081 const double c = st.nservers;
1082 if (!(c >= 1.0) || !std::isfinite(c))
1083 throw InputError("SolverLDES (native engine): station '" + st.name +
1084 "' has a server count that is neither finite nor at least one");
1085 s.nservers = static_cast<std::size_t>(c + 0.5);
1086 s.cap = st.cap;
1087 s.classcap.assign(K, std::numeric_limits<double>::infinity());
1088 s.blocked_at.assign(K, 0.0);
1089 s.droprule.assign(K, lang::DropStrategy::DROP);
1090 for (std::size_t r = 0; r < K; ++r)
1091 if (i < sn.droprule.size() && r < sn.droprule[i].size())
1092 s.droprule[r] = sn.droprule[i][r];
1093 for (std::size_t r = 0; r < K; ++r)
1094 if (i < sn.classcap.size() && r < sn.classcap[i].size())
1095 s.classcap[r] = sn.classcap[i][r];
1096 if (st.sched == SchedStrategy::LPS) s.lps_limit = s.nservers;
1097 // SERVER PARALLELISM. Read unconditionally so that a station
1098 // declaring only ones keeps every path it had: `has_parallelism`
1099 // is what the seize, the release and the admission gate consult.
1100 s.parallelism.assign(K, 1);
1101 for (std::size_t r = 0; r < K && r < st.server_parallelism.size(); ++r)
1102 if (st.server_parallelism[r] > 1) {
1103 s.parallelism[r] = st.server_parallelism[r];
1104 s.has_parallelism = true;
1105 }
1106 }
1107
1108 // One entry per class, neutral until a sharing station with a class
1109 // dependence refreshes it in `ps_reschedule`.
1110 s.ps_cd.assign(K, 1.0);
1111 s.gd_peak.assign(K, 1.0);
1112 for (const T& v : st.lldscaling) s.lld.push_back(num_traits<T>::to_double(v));
1113 // The peak capacity that normalizes this station's utilization is
1114 // max(c, max(alpha)) -- CTMC's own ceff, which is what makes the two
1115 // report the same number on a load-dependent station. Without load
1116 // dependence it is just c, so nothing else moves.
1117 s.util_peak = static_cast<double>(s.nservers);
1118 for (double a : s.lld) s.util_peak = std::max(s.util_peak, a);
1119 // CLASS DEPENDENCE beta_r(n) AND JOINT DEPENDENCE eta_i(n) ENTER THE
1120 // SAMPLE-PATH RATE THE SAME WAY, and a station declaring both is scaled
1121 // by their PRODUCT -- `initializeClassDependence` and `State.java` both
1122 // fold them that way. They stay separate on the struct because they
1123 // carry different modelling claims (beta keeps the product form, eta
1124 // does not), but a simulator has no use for that distinction: it needs
1125 // the rate in force, which is the product.
1126 if (st.cdscaling || st.jdscaling) {
1127 s.has_cd = true;
1128 // THE DECLARED PEAK IS THE NORMALIZER, not a peak swept out of the
1129 // handle. `solver_ssa_serial` and `solver_ssa_nrm_space` take the
1130 // PRODUCT of whichever declared peaks are present, and drop the
1131 // server count while doing so, because the rate in force is that
1132 // same product; this engine must answer with their number. A handle
1133 // with no declared peak is a model defect here exactly as it is
1134 // there -- an open class has no lattice to sweep, so a swept peak
1135 // would be an invented bound.
1136 double dep_peak = 1.0;
1137 const std::vector<T>* pks[2] = {&st.cdscalingpeak, &st.jdscalingpeak};
1138 const bool on[2] = {static_cast<bool>(st.cdscaling), static_cast<bool>(st.jdscaling)};
1139 const char* names[2] = {"setClassDependence", "setJointDependence"};
1140 for (std::size_t h = 0; h < 2; ++h) {
1141 if (!on[h]) continue;
1142 if (pks[h]->empty())
1143 throw InputError(
1144 std::string("SolverLDES: station '") + st.name +
1145 "' declares a dependent scaling with no declared peak rate. Utilization "
1146 "there is T*E[S]/peak, so pass the peak to " + names[h]);
1147 double m = 0.0;
1148 for (const T& v : *pks[h]) m = std::max(m, num_traits<T>::to_double(v));
1149 if (!(m > 0.0))
1150 throw InputError(std::string("SolverLDES: station '") + st.name +
1151 "' declares a non-positive peak rate for " + names[h]);
1152 dep_peak *= m;
1153 }
1154 s.util_peak = dep_peak;
1155 const auto& beta = st.cdscaling;
1156 const auto& eta = st.jdscaling;
1157 s.cd = [beta, eta](const std::vector<double>& n) {
1158 std::vector<T> nt(n.size());
1159 for (std::size_t k = 0; k < n.size(); ++k) nt[k] = num_traits<T>::from_double(n[k]);
1160 std::vector<double> bd, ed;
1161 if (beta) {
1162 const std::vector<T> b = beta(nt);
1163 bd.resize(b.size());
1164 for (std::size_t k = 0; k < b.size(); ++k) bd[k] = num_traits<T>::to_double(b[k]);
1165 }
1166 if (eta) {
1167 const std::vector<T> e = eta(nt);
1168 ed.resize(e.size());
1169 for (std::size_t k = 0; k < e.size(); ++k) ed[k] = num_traits<T>::to_double(e[k]);
1170 }
1171 if (bd.empty()) return ed;
1172 if (ed.empty()) return bd;
1173 // Elementwise, with scalar broadcasting: either factor may be a
1174 // single value shared across the classes.
1175 const std::size_t n2 = std::max(bd.size(), ed.size());
1176 std::vector<double> out(n2, 1.0);
1177 for (std::size_t k = 0; k < n2; ++k)
1178 out[k] = bd[bd.size() > 1 ? k : 0] * ed[ed.size() > 1 ? k : 0];
1179 return out;
1180 };
1181 }
1182 if (st.sched == SchedStrategy::PAS || st.sched == SchedStrategy::OI) {
1183 s.pas = true;
1184 auto pp = sn.pasparam.find(i + 1);
1185 if (pp != sn.pasparam.end()) {
1186 if (pp->second.svc_rate_fun) {
1187 const auto f = pp->second.svc_rate_fun;
1188 // mu(c) TAKES 1-BASED CLASS TAGS -- it is the state
1189 // encoding's own convention, the one `state.h` and
1190 // `after_event_station_pas` hand it and the one
1191 // `oi_rate_from_json` decodes (it drops any entry below 1).
1192 // This engine's `Job::cls` is 0-based, so shift on the way
1193 // in: unshifted, class 0 is invisible to mu, contributes no
1194 // rate increment at any position, and its jobs pile up at
1195 // the station and never complete.
1196 s.pas_rate = [f](const std::vector<std::size_t>& seq) {
1197 std::vector<std::size_t> tags(seq.size());
1198 for (std::size_t k = 0; k < seq.size(); ++k) tags[k] = seq[k] + 1;
1199 return num_traits<T>::to_double(f(tags));
1200 };
1201 }
1202 s.pas_swap = pp->second.swap_graph;
1203 }
1204 if (!s.pas_rate)
1205 throw InputError("SolverLDES (native engine): station '" + st.name +
1206 "' is a pass-and-swap station with no service rate function");
1207 }
1208 if (st.sched == SchedStrategy::POLLING) {
1209 s.polling = true;
1210 const auto pp = sn.effective_polling(i + 1);
1211 s.poll_type = pp.ptype;
1212 s.poll_k = (pp.pk >= 1) ? pp.pk : 1;
1213 s.poll_budget = (s.poll_type == lang::PollingType::EXHAUSTIVE)
1214 ? std::numeric_limits<std::size_t>::max()
1215 : ((s.poll_type == lang::PollingType::KLIMITED) ? s.poll_k : 1);
1216 s.poll_parked = true;
1217 s.switchover.resize(K);
1218 s.has_switchover.assign(K, false);
1219 for (std::size_t r = 0; r < K && r < pp.switchover.size(); ++r)
1220 if (!pp.switchover[r].disabled &&
1221 num_traits<T>::to_double(pp.switchover[r].mean) > 0.0) {
1222 s.switchover[r] = Sampler(pp.switchover[r],
1223 "the switchover of station '" + st.name +
1224 "', class '" + sn.classes[r].name + "'");
1225 s.has_switchover[r] = true;
1226 }
1227 }
1228 s.retrial.resize(K);
1229 s.has_retrial.assign(K, false);
1230 s.max_attempts.assign(K, 0);
1231 s.orbit_size.assign(K, 0.0);
1232 s.tot_orbit.assign(K, 0.0);
1233 s.retried.assign(K, 0.0);
1234 s.retrial_lost.assign(K, 0.0);
1235 {
1236 auto rp = sn.retrialparam.find(i + 1);
1237 if (rp != sn.retrialparam.end())
1238 for (std::size_t r = 0; r < K && r < rp->second.retrial_proc.size(); ++r)
1239 if (!rp->second.retrial_proc[r].disabled) {
1240 s.retrial[r] = Sampler(rp->second.retrial_proc[r],
1241 "the retrial process of station '" + st.name +
1242 "', class '" + sn.classes[r].name + "'");
1243 s.has_retrial[r] = true;
1244 if (r < rp->second.max_attempts.size())
1245 s.max_attempts[r] = rp->second.max_attempts[r];
1246 }
1247 }
1248 s.patience.resize(K);
1249 s.has_patience.assign(K, false);
1250 s.balk.assign(K, std::vector<StationState::BalkRule>());
1251 for (std::size_t r = 0; r < K; ++r) {
1252 if (r < st.impatience.size() && st.impatience[r] == lang::ImpatienceType::RENEGING &&
1253 r < st.patience.size() && !st.patience[r].disabled) {
1254 s.patience[r] = Sampler(st.patience[r], "the patience of station '" + st.name +
1255 "', class '" + sn.classes[r].name + "'");
1256 s.has_patience[r] = true;
1257 }
1258 // ALL THREE STRATEGIES READ THE SAME TABLE, as in the Java engine
1259 // (Solver_ssj.shouldBalk), which never consults the strategy: the
1260 // model layer stores EXPECTED_WAIT and COMBINED thresholds in JOBS,
1261 // {minJobs, maxJobs, probability}, and carries no wait parameter.
1262 if (r < st.balking.size() &&
1263 st.balking[r].strategy != lang::BalkingStrategy::NONE) {
1264 for (const auto& th : st.balking[r].thresholds) {
1265 StationState::BalkRule br;
1266 br.min_jobs = th.min_jobs;
1267 br.max_jobs = th.max_jobs;
1268 br.probability = num_traits<T>::to_double(th.probability);
1269 s.balk[r].push_back(br);
1270 }
1271 }
1272 }
1273
1274 {
1275 auto sp = sn.setupparam.find(i + 1);
1276 if (sp != sn.setupparam.end()) {
1277 lang::Distrib<T> su, doff;
1278 if (sp->second.last(su, doff) && !su.disabled) {
1279 s.has_setup = true;
1280 s.setup_time = Sampler(su, "the setup time of station '" + st.name + "'");
1281 // A ZERO delay-off is not an error, it is the absence of the
1282 // window: the server shuts down the instant it empties, so
1283 // every busy period starts cold. Only a positive one gets a
1284 // sampler; a disabled or zero entry leaves it unset.
1285 if (!doff.disabled && num_traits<T>::to_double(doff.mean) > 0.0)
1286 s.delayoff_time =
1287 Sampler(doff, "the delay-off time of station '" + st.name + "'");
1288 else
1289 s.delayoff_time = Sampler();
1290 }
1291 }
1292 }
1293 {
1294 typename std::map<std::size_t, qn::BreakdownParam<T> >::const_iterator bp =
1295 sn.breakdownparam.find(i + 1);
1296 if (bp != sn.breakdownparam.end()) {
1297 s.has_breakdown = true;
1298 s.up = true;
1299 s.failure_time =
1300 Sampler(bp->second.failure, "the failure time of station '" + st.name + "'");
1301 s.repair_time =
1302 Sampler(bp->second.repair, "the repair time of station '" + st.name + "'");
1303 s.down_scale.assign(K, 0.0);
1304 s.down_rate_raw.assign(K, 0.0);
1305 for (std::size_t r = 0; r < K && r < bp->second.down_service_rates.size(); ++r)
1306 s.down_rate_raw[r] = num_traits<T>::to_double(bp->second.down_service_rates[r]);
1307 }
1308 }
1309 // A BREAKDOWN IS A STATE-DEPENDENT RATE, so it reuses sd_advance /
1310 // sd_reschedule rather than growing a second freeze-and-retime path.
1311 s.state_dependent = (!s.lld.empty() || s.has_cd || s.has_breakdown);
1312
1313 s.svc.resize(K);
1314 if (s.role == Role::Synchronization) continue;
1315 for (std::size_t r = 0; r < K; ++r) {
1316 s.off[r] = sn.disabled[i][r];
1317 if (s.off[r]) continue;
1318 const std::string where = "station '" + st.name + "', class '" + sn.classes[r].name + "'";
1319 s.svc[r] = Sampler(sn.service[i][r], where);
1320 s.class_mean[r] = s.svc[r].mean();
1321 if (!(s.class_mean[r] > 0.0))
1322 throw InputError("SolverLDES (native engine): " + where +
1323 " has a non-positive mean service time");
1324 }
1325
1326 // ---- bulk service (BMSP) ----------------------------------------------
1327 //
1328 // Detected from the PROCESS, not from a flag: a BMAP or BMMAPt assigned as a
1329 // service process IS a batch Markovian service process. Every guard below is
1330 // the Java engine's, refused by name rather than answered wrongly -- before
1331 // this arm existed the batch blocks were read and never consulted, so a bulk
1332 // server was simulated as an ordinary one completing one job per firing.
1333 // A SOURCE's `service` column holds its ARRIVAL processes, so a batch family
1334 // there is a batch ARRIVAL and has nothing to do with bulk service.
1335 if (s.role != Role::Source) {
1336 std::size_t nbatch = 0, enabled = 0, bcls = K;
1337 for (std::size_t r = 0; r < K; ++r) {
1338 if (s.off[r]) continue;
1339 ++enabled;
1340 if (lang::process_is_batch(sn.service[i][r].type)) {
1341 ++nbatch;
1342 bcls = r;
1343 }
1344 }
1345 if (nbatch > 0) {
1346 const std::string fam = lang::process_to_text(sn.service[i][bcls].type);
1347 const std::string at = fam + " service (BMSP) at station '" + st.name + "'";
1348 if (s.role == Role::Delay)
1349 throw UnsupportedError("SolverLDES (native engine): " + at +
1350 " is not supported at an infinite-server station; "
1351 "batch service requires a single-server FCFS queue");
1352 if (s.nservers != 1)
1353 throw UnsupportedError("SolverLDES (native engine): " + at +
1354 " requires a single server; multiserver bulk service "
1355 "is not supported");
1356 if (s.sched != lang::SchedStrategy::FCFS)
1357 throw UnsupportedError("SolverLDES (native engine): " + at +
1358 " requires FCFS scheduling");
1359 if (enabled != 1)
1360 throw UnsupportedError("SolverLDES (native engine): " + at +
1361 " requires exactly one job class; multiclass bulk "
1362 "service is not supported");
1363 if (s.pas || s.preemptive || s.polling)
1364 throw UnsupportedError("SolverLDES (native engine): " + at +
1365 " is incompatible with PAS/preemptive/polling "
1366 "scheduling");
1367 if (s.has_setup)
1368 throw UnsupportedError("SolverLDES (native engine): " + at +
1369 " is incompatible with server setup/delayoff");
1370 if (s.state_dependent)
1371 throw UnsupportedError("SolverLDES (native engine): " + at +
1372 " is not supported with load or class dependence or "
1373 "breakdowns: the rate rescaling cannot be applied to "
1374 "a firing clock armed at the instant the station "
1375 "became busy");
1376 if (!st.server_types.empty())
1377 throw UnsupportedError("SolverLDES (native engine): " + at +
1378 " is not supported with heterogeneous server types; a "
1379 "bulk server is a single server by construction");
1380 s.bmsp = true;
1381 s.bmsp_cls = bcls;
1382 s.bmsp_pending = 1;
1383 }
1384 }
1385
1386 // ---- heterogeneous server pools ---------------------------------------
1387 //
1388 // The pools REPLACE the flat server bank rather than sitting beside it:
1389 // `nservers` becomes their total, so every slot loop below is already
1390 // over the right range and only the CHOICE of slot has to learn about
1391 // compatibility. A pool that declares no law of its own for a class
1392 // serves it at the station's class-level default, which is what the
1393 // reference's `generateHeteroServiceTime` falls back to.
1394 if (!st.server_types.empty()) {
1395 const std::size_t nT = st.server_types.size();
1396 s.has_pools = true;
1397 s.hetero_policy = st.hetero_policy;
1398 s.type_count.assign(nT, 0);
1399 s.type_first.assign(nT, 0);
1400 s.type_compat.assign(nT, std::vector<bool>(K, true));
1401 s.type_svc.assign(nT, std::vector<Sampler>(K));
1402 s.type_has_svc.assign(nT, std::vector<bool>(K, false));
1403 s.type_rate.assign(nT, std::vector<double>(K, 0.0));
1404 std::size_t total = 0;
1405 for (std::size_t t = 0; t < nT; ++t) {
1406 const typename qn::Station<T>::ServerType& pt = st.server_types[t];
1407 const double c = pt.count;
1408 if (!(c >= 1.0))
1409 throw InputError("SolverLDES (native engine): station '" + st.name +
1410 "' declares server pool '" + pt.name +
1411 "' with fewer than one server");
1412 s.type_first[t] = total;
1413 s.type_count[t] = static_cast<std::size_t>(c + 0.5);
1414 total += s.type_count[t];
1415 for (std::size_t r = 0; r < K; ++r) {
1416 // An empty compatibility row means every class, as the
1417 // struct documents; a short one is padded the same way.
1418 s.type_compat[t][r] = pt.compatible.empty() || r >= pt.compatible.size()
1419 ? true
1420 : pt.compatible[r];
1421 if (s.off[r]) s.type_compat[t][r] = false;
1422 if (r < pt.service.size() && !pt.service[r].disabled) {
1423 const std::string where = "station '" + st.name + "', pool '" + pt.name +
1424 "', class '" + sn.classes[r].name + "'";
1425 s.type_svc[t][r] = Sampler(pt.service[r], where);
1426 s.type_has_svc[t][r] = true;
1427 const double mu = s.type_svc[t][r].mean();
1428 if (!(mu > 0.0))
1429 throw InputError("SolverLDES (native engine): " + where +
1430 " has a non-positive mean service time");
1431 s.type_rate[t][r] = 1.0 / mu;
1432 } else if (!s.off[r]) {
1433 s.type_rate[t][r] =
1434 (s.class_mean[r] > 0.0) ? 1.0 / s.class_mean[r] : 0.0;
1435 }
1436 }
1437 }
1438 for (std::size_t r = 0; r < K; ++r) {
1439 if (s.off[r]) continue;
1440 bool served = false;
1441 for (std::size_t t = 0; t < nT && !served; ++t) served = s.type_compat[t][r];
1442 if (!served)
1443 throw InputError("SolverLDES (native engine): station '" + st.name +
1444 "' declares no server pool compatible with class '" +
1445 sn.classes[r].name +
1446 "', so a job of that class would wait forever");
1447 }
1448 // The pools ARE the server bank. A declared `nservers` that
1449 // disagrees is the flattened view of the same pools, and taking it
1450 // instead would serve more or fewer jobs at once than declared.
1451 s.nservers = total;
1452 s.util_peak = static_cast<double>(total);
1453 if (s.sched == lang::SchedStrategy::LPS) s.lps_limit = total;
1454 s.server_type.assign(total, 0);
1455 for (std::size_t t = 0; t < nT; ++t)
1456 for (std::size_t j = 0; j < s.type_count[t]; ++j)
1457 s.server_type[s.type_first[t] + j] = t;
1458 s.type_order.resize(nT);
1459 for (std::size_t t = 0; t < nT; ++t) s.type_order[t] = t;
1460 // ALFS: fewest compatible classes first, ties by declaration order,
1461 // so the least flexible pool is spent before the more flexible one.
1462 s.alfs_order = s.type_order;
1463 std::stable_sort(s.alfs_order.begin(), s.alfs_order.end(),
1464 [&](std::size_t a, std::size_t b) {
1465 std::size_t ca = 0, cb = 0;
1466 for (std::size_t r = 0; r < K; ++r) {
1467 if (s.type_compat[a][r]) ++ca;
1468 if (s.type_compat[b][r]) ++cb;
1469 }
1470 return ca < cb;
1471 });
1472 }
1473 // THE DEGRADED RATE BECOMES A MULTIPLIER, and it can only be formed
1474 // here, once the mean service time of each class is known: the wire
1475 // carries an ABSOLUTE rate while the engine's state-dependent path
1476 // takes a factor on the nominal one.
1477 if (s.has_breakdown) {
1478 s.down_scale.assign(K, 0.0);
1479 for (std::size_t r = 0; r < K; ++r) {
1480 if (s.off[r] || !(s.class_mean[r] > 0.0)) continue;
1481 if (r < s.down_rate_raw.size() && s.down_rate_raw[r] > 0.0)
1482 s.down_scale[r] = s.down_rate_raw[r] * s.class_mean[r];
1483 }
1484 }
1485 if (!s.ps) {
1486 s.cmp.sched = s.sched;
1487 s.cmp.class_mean = &s.class_mean;
1488 if (s.role == Role::Queue) {
1489 s.server.assign(s.nservers, Job());
1490 s.server_busy.assign(s.nservers, false);
1491 s.server_start.assign(s.nservers, 0.0);
1492 s.server_tag.assign(s.nservers, 0);
1493 s.server_blocked.assign(s.nservers, false);
1494 s.server_held.assign(s.nservers, false);
1495 s.held_cls.assign(s.nservers, 0);
1496 s.blocked_job.assign(s.nservers, Job());
1497 s.blocked_dest.assign(s.nservers, 0);
1498 s.blocked_dest_cls.assign(s.nservers, 0);
1499 if (s.has_parallelism) s.held_extra.assign(s.nservers, std::vector<std::size_t>());
1500 }
1501 }
1502 }
1503
1504 // ---- server parallelism: what a station must be for a job to seize n ----
1505 //
1506 // Every refusal below is the reference engine's `initializeServerParallelism`,
1507 // named rather than answered wrongly. What they have in common is that the
1508 // station has no BANK OF INTERCHANGEABLE SLOTS to take n of: an infinite
1509 // server never makes a job wait for one, a sharing discipline hands out
1510 // fractions of its whole capacity instead of slots, a polling or
1511 // pass-and-swap controller picks the server itself, a bulk server has only a
1512 // station-level firing clock, a heterogeneous pool binds a job to the one
1513 // type whose law serves it, and a setup station powers its slots up one at a
1514 // time so "n free at once" is not a state it passes through.
1515 for (std::size_t i = 0; i < M; ++i) {
1516 const auto& st = sn.stations[i];
1517 std::size_t maxpar = 1;
1518 for (std::size_t r = 0; r < st.server_parallelism.size(); ++r)
1519 maxpar = std::max(maxpar, st.server_parallelism[r]);
1520 if (maxpar <= 1) continue;
1521 StationState& s = S[i];
1522 const std::string where = std::string("SolverLDES (native engine): station '") +
1523 st.name + "' declares server parallelism";
1524 if (s.role != Role::Queue)
1525 throw UnsupportedError(where + " at an infinite server, which never makes a job "
1526 "wait for a slot; drop the declaration or give the "
1527 "station a finite server count");
1528 if (s.ps)
1529 throw UnsupportedError(where + " under " + std::string(lang::sched_to_text(s.sched)) +
1530 ", which shares its whole capacity rather than handing out "
1531 "slots, so a job cannot hold n of them; use a queueing "
1532 "discipline, or a class weight to slow the class down");
1533 if (s.polling || s.pas)
1534 throw UnsupportedError(where + " under " + std::string(lang::sched_to_text(s.sched)) +
1535 ", whose controller picks the server itself and has no pool "
1536 "to seize n slots from");
1537 if (s.bmsp)
1538 throw UnsupportedError(where + " at a bulk (BMSP) server, which has only a "
1539 "station-level firing clock and no per-job server "
1540 "to seize");
1541 if (s.has_pools)
1542 throw UnsupportedError(where + " together with heterogeneous server pools: a job is "
1543 "served at the law of the one pool it occupies, which "
1544 "n servers of mixed types cannot express");
1545 if (s.has_setup)
1546 throw UnsupportedError(where + " together with setup/delayoff: the slots of such a "
1547 "station power up one at a time, so 'n free at once' "
1548 "is not a state it passes through");
1549 if (maxpar > s.nservers)
1550 throw InputError(where + " of " + std::to_string(maxpar) + " above its " +
1551 std::to_string(s.nservers) + " servers, so the job could never "
1552 "enter service");
1553 }
1554
1555 // ---- global (Whittle) dependence ----------------------------------------
1556 /**
1557 * `sn.gdscaling`: ONE handle phi(n), read at the FULL (nstations x nclasses)
1558 * population, that multiplies every station's service rate at once.
1559 *
1560 * It is not a station's own state dependence under another name. A job
1561 * moving ANYWHERE changes the rate EVERYWHERE, so the four advance and
1562 * reschedule calls fan out over the whole network instead of touching the
1563 * station that moved. That fan-out is cheap here precisely because this
1564 * engine rescales RESIDUAL WORK rather than resampling: repeating a
1565 * reschedule burns no variate and lands on the same completion instant, so
1566 * it is idempotent in a way the reference engine's cancel-and-redraw is not.
1567 */
1568 const bool has_gd = static_cast<bool>(sn.gdscaling);
1569 std::vector<std::vector<double> > gd_cache(M, std::vector<double>(K, 1.0));
1570 if (has_gd) {
1571 if (sn.gdscalingpeak.size() < M * K)
1572 throw InputError(
1573 "SolverLDES (native engine): a global dependence requires an explicit peak rate, "
1574 "because utilization under it is reported as T*E[S]/peak; pass one to "
1575 "setGlobalDependence");
1576 for (std::size_t i = 0; i < M; ++i) {
1577 StationState& s = S[i];
1578 const std::string& nm = sn.stations[i].name;
1579 if (s.bmsp)
1580 throw UnsupportedError(
1581 "SolverLDES (native engine): station '" + nm +
1582 "' is a bulk (BMSP) server under a global dependence, which holds only a "
1583 "station-level firing clock and no per-job residual work to rescale");
1584 if (s.pas)
1585 throw UnsupportedError(
1586 "SolverLDES (native engine): station '" + nm +
1587 "' is a pass-and-swap station under a global dependence, whose single "
1588 "aggregate clock is armed from its own list and carries no residual work "
1589 "to rescale");
1590 for (std::size_t r = 0; r < K; ++r) {
1591 const double pk = num_traits<T>::to_double(sn.gdscalingpeak[i * K + r]);
1592 if (!(pk > 0.0) || !std::isfinite(pk))
1593 throw InputError("SolverLDES (native engine): the global dependence declares "
1594 "a peak rate that is not finite and positive at station '" +
1595 nm + "'");
1596 s.gd_peak[r] = pk;
1597 }
1598 // Its rate is now a function of the whole population, so it has to
1599 // be re-timed at every change -- which is what `state_dependent`
1600 // means and is why a global dependence needs no flag of its own on
1601 // the buffered path.
1602 if (s.role == Role::Queue) s.state_dependent = true;
1603 }
1604 }
1605
1606 // ---- routing, resolved once per (station, class) -----------------------
1607 // A Router, a ClassSwitch and a Logger are not stations. `Peff` is a
1608 // NODE-level matrix and keeps them, so they appear as destinations here and
1609 // `deliver` walks through them in one instant; a destination that is none of
1610 // those and none of Source/Queue/Delay/Sink/Fork/Join/Cache/Place/Transition
1611 // is a node this engine must not guess at.
1612 const std::size_t nnodes = sn.nof_nodes();
1613 std::vector<std::size_t> node_to_station(nnodes + 1, M);
1614 for (std::size_t i = 0; i < M; ++i) node_to_station[sn.station_to_node[i]] = i;
1615
1616 /**
1617 * ROUTING IS RESOLVED OVER NODES, not over stations.
1618 *
1619 * A Router, a ClassSwitch and a Logger appear here as destinations: the
1620 * stochastic complement that removes them lives in `rt`, not in the
1621 * NODE-level `Peff` this table is read off. They hold nothing, so `deliver`
1622 * walks straight through them. A FORK and a JOIN are different again: they
1623 * are stateful, they change the NUMBER of jobs in flight, and no stochastic
1624 * complement can express that, so `deliver` replicates at a Fork and
1625 * synchronizes at a Join.
1626 */
1627 std::vector<std::vector<std::vector<RouteDest>>> nroute(
1628 nnodes + 1, std::vector<std::vector<RouteDest>>(K));
1629 /**
1630 * The dispatcher each (node, class) declares, and its round-robin pointer.
1631 *
1632 * The pointer is per (node, class) and NOT per destination: a round-robin
1633 * dispatcher hands out its outgoing links in turn, and one counter per link
1634 * would let two classes at the same node walk the cycle independently and
1635 * both start at the head.
1636 */
1637 std::vector<std::vector<lang::RoutingStrategy>> node_routing(
1638 nnodes + 1, std::vector<lang::RoutingStrategy>(K, lang::RoutingStrategy::PROB));
1639 std::vector<std::vector<std::size_t>> rr_counter(nnodes + 1, std::vector<std::size_t>(K, 0));
1640 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1641 for (std::size_t r = 0; r < K; ++r)
1642 if (sn.nodes[inode - 1].routing.size() > r)
1643 node_routing[inode][r] = sn.nodes[inode - 1].routing[r];
1644 for (std::size_t inode = 1; inode <= nnodes; ++inode) {
1645 // THE SINK HAS NO OUTGOING PATH. The refresh writes a Sink -> Source
1646 // closure into the effective routing so the visit ratios of an open
1647 // model have a cycle to solve on; it is bookkeeping, not a route a job
1648 // takes. Reading it as one sends every departing job back to the
1649 // Source. The station-level table never saw it because the Sink is not
1650 // a station.
1651 if (sn.nodes[inode - 1].nodetype == NodeType::Sink) continue;
1652 // A PETRI NET DOES NOT ROUTE. Its arcs declare the structure, but the
1653 // tokens move by the enabling and firing matrices of the transitions,
1654 // so walking the routing here would move them twice.
1655 if (sn.nodes[inode - 1].nodetype == NodeType::Place ||
1656 sn.nodes[inode - 1].nodetype == NodeType::Transition)
1657 continue;
1658 for (std::size_t r = 0; r < K; ++r) {
1659 // ASCENDING NODE INDEX in the outer loop: this is the order the
1660 // round-robin pointer walks, and it is the reference's order.
1661 for (std::size_t j = 1; j <= nnodes; ++j) {
1662 RouteDest d;
1663 d.node = j;
1664 d.sink = (sn.nodes[j - 1].nodetype == NodeType::Sink);
1665 d.station = node_to_station[j];
1666 for (std::size_t s2 = 0; s2 < K; ++s2) {
1667 const double p = num_traits<T>::to_double(sn.route_eff(r + 1, s2 + 1, inode, j));
1668 if (!(p > 0.0)) continue;
1669 d.mass += p;
1670 d.cls.push_back(std::make_pair(s2, p));
1671 }
1672 if (d.cls.empty()) continue;
1673 // A Fork, a Join and a Cache are STATEFUL and hold jobs; a
1674 // ClassSwitch, a Router and a Logger do not and are walked
1675 // through in one step by `deliver`. Neither kind is folded away
1676 // by the refresh -- `Peff` is a NODE-level matrix and keeps
1677 // every intermediate hop -- so both must be reachable here.
1678 // Anything else is a node this engine would have to guess at.
1679 if (!d.sink && d.station >= M && sn.nodes[j - 1].nodetype != NodeType::Fork &&
1680 sn.nodes[j - 1].nodetype != NodeType::Join &&
1681 sn.nodes[j - 1].nodetype != NodeType::Cache &&
1682 sn.nodes[j - 1].nodetype != NodeType::ClassSwitch &&
1683 sn.nodes[j - 1].nodetype != NodeType::Router &&
1684 sn.nodes[j - 1].nodetype != NodeType::Logger &&
1685 sn.nodes[j - 1].nodetype != NodeType::Place &&
1686 sn.nodes[j - 1].nodetype != NodeType::Transition)
1687 throw UnsupportedError(
1688 "SolverLDES (native engine): the routing crosses node '" +
1689 sn.nodes[j - 1].name +
1690 "', which the refresh did not fold into the station-to-station "
1691 "routing");
1692 if (d.station < M && S[d.station].role == Role::Source)
1693 throw InputError("SolverLDES (native engine): the routing sends a "
1694 "job back into the Source");
1695 nroute[inode][r].push_back(d);
1696 }
1697 }
1698 }
1699
1700 // ---- fork and join --------------------------------------------------------
1701 // A Fork replicates the arriving job onto every outgoing edge; a Join holds
1702 // the siblings until the strategy is satisfied and releases ONE job. The
1703 // pair is matched by `sn.fj`, so a Fork with no Join is a model whose
1704 // siblings never recombine and is refused rather than run.
1705 std::vector<int> join_of_fork(nnodes + 1, -1);
1706 std::vector<bool> is_fork(nnodes + 1, false), is_join(nnodes + 1, false);
1707 for (const auto& fjp : sn.fj) {
1708 if (fjp.first > nnodes || fjp.second > nnodes) continue;
1709 is_fork[fjp.first] = true;
1710 is_join[fjp.second] = true;
1711 join_of_fork[fjp.first] = static_cast<int>(fjp.second);
1712 }
1713 // Whether a class routes into a Join ANYWHERE: it decides whether a phase-2
1714 // continuation of that class inherits the trigger's fork identity.
1715 std::vector<bool> spawn_joins_at(K, false);
1716 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1717 for (std::size_t r = 0; r < K; ++r)
1718 for (const RouteDest& d : nroute[inode][r])
1719 if (d.node >= 1 && d.node <= nnodes &&
1720 sn.nodes[d.node - 1].nodetype == NodeType::Join)
1721 for (std::size_t c = 0; c < d.cls.size(); ++c)
1722 spawn_joins_at[d.cls[c].first] = true;
1723 for (std::size_t nd = 1; nd <= nnodes; ++nd) {
1724 if (sn.nodes[nd - 1].nodetype == NodeType::Fork && !is_fork[nd])
1725 throw InputError("SolverLDES (native engine): Fork node '" + sn.nodes[nd - 1].name +
1726 "' has no matching Join");
1727 if (sn.nodes[nd - 1].nodetype == NodeType::Join && !is_join[nd])
1728 throw InputError("SolverLDES (native engine): Join node '" + sn.nodes[nd - 1].name +
1729 "' closes no Fork");
1730 }
1731
1732 /**
1733 * One forked parent awaiting its siblings.
1734 *
1735 * `required` is fixed at FORK time, not at join time: the quorum is a
1736 * fraction of the siblings THIS fork produced, and a Join shared by two
1737 * forks of different fan-out would otherwise apply one fork's count to the
1738 * other's siblings.
1739 */
1740 struct ForkSync {
1741 std::size_t cls = 0;
1742 double t_sys = 0.0;
1743 int total = 0;
1744 int required = 0;
1745 /**
1746 * (class, instant) of every sibling this parent has parked at the Join.
1747 *
1748 * Held PER PARENT, not per Join: the Join's queue length must fall by
1749 * the siblings THIS synchronization releases, and a node-wide tally
1750 * would clear the siblings of every other parent still waiting. It also
1751 * dates each sibling's own wait, which is `forkedJobJoinArrivalTimes`.
1752 */
1753 std::vector<std::pair<std::size_t, double>> siblings;
1754 };
1755 std::map<std::uint64_t, ForkSync> fork_sync;
1756 std::uint64_t next_parent = 0;
1757
1758
1759 // ---- cache nodes ----------------------------------------------------------
1760 // A Cache is a ROUTING node with state: it reads an item, decides hit or
1761 // miss on its real list contents, and switches the job onto the hit or the
1762 // miss class. That makes the hit an exact sample-path event rather than a
1763 // rate, which is the reason to ask a simulator about a cache at all.
1764 std::map<std::size_t, CacheState> caches;
1765 for (const auto& np : sn.nodeparam) {
1766 const std::size_t nd = np.first;
1767 if (nd == 0 || nd > nnodes) continue;
1768 if (sn.nodes[nd - 1].nodetype != NodeType::Cache) continue;
1769 const auto& cp = np.second;
1770 CacheState cs;
1771 cs.node = nd;
1772 cs.nitems = cp.nitems;
1773 cs.policy = cp.replacestrat;
1774 for (int c : cp.itemcap)
1775 if (c > 0) cs.capacity.push_back(static_cast<std::size_t>(c));
1776 if (cs.capacity.empty()) cs.capacity.push_back(1);
1777 cs.lists.assign(cs.capacity.size(), std::list<std::size_t>());
1778 for (int v : cp.itemsize) cs.item_size.push_back(v);
1779 for (int v : cp.costcap) cs.cost_cap.push_back(v);
1780 cs.hits.assign(K, 0.0);
1781 cs.misses.assign(K, 0.0);
1782 cs.hit_class.assign(K, -1);
1783 cs.miss_class.assign(K, -1);
1784 for (std::size_t r = 0; r < K && r < cp.hitclass.size(); ++r)
1785 if (cp.hitclass[r] > 0) cs.hit_class[r] = static_cast<int>(cp.hitclass[r] - 1);
1786 for (std::size_t r = 0; r < K && r < cp.missclass.size(); ++r)
1787 if (cp.missclass[r] > 0) cs.miss_class[r] = static_cast<int>(cp.missclass[r] - 1);
1788 // The popularity is stored as a pmf per class and used as a CUMULATIVE
1789 // law here, so one uniform draw picks an item.
1790 cs.popularity.assign(K, std::vector<double>());
1791 for (std::size_t r = 0; r < K && r < cp.pread.size(); ++r) {
1792 double acc = 0.0;
1793 for (const T& v : cp.pread[r]) {
1794 acc += num_traits<T>::to_double(v);
1795 cs.popularity[r].push_back(acc);
1796 }
1797 if (!cs.popularity[r].empty()) cs.popularity[r].back() = 1.0;
1798 }
1799 // Delayed-hit retrieval: one class per (item, read class) carries the
1800 // fetch through the retrieval stations and back here.
1801 if (cp.retrieval_capacity > 0 && !cp.retrieval_classes.empty()) {
1802 cs.has_retrieval = true;
1803 cs.retrieval_class = cp.retrieval_classes;
1804 cs.retrieval_class_to_item.assign(K, -1);
1805 for (std::size_t it = 0; it < cs.retrieval_class.size(); ++it)
1806 for (std::size_t r = 0; r < cs.retrieval_class[it].size(); ++r) {
1807 const std::size_t rc = cs.retrieval_class[it][r];
1808 if (rc > 0 && rc <= K) cs.retrieval_class_to_item[rc - 1] = static_cast<int>(it);
1809 }
1810 cs.in_flight.assign(cs.nitems, 0);
1811 cs.fetch_start.assign(cs.nitems, 0.0);
1812 cs.held.assign(cs.nitems, std::vector<CacheState::HeldRequest>());
1813 cs.delayed.assign(K, 0.0);
1814 }
1815 caches[nd] = cs;
1816 }
1817
1818
1819 // ---- Petri net: places and transitions --------------------------------------
1820 // A Place is a STATION holding tokens per class; a Transition is a node that
1821 // moves them. The marking is the places' token counts, and it is the only
1822 // state the net has.
1823 std::vector<std::size_t> place_of(nnodes + 1, M);
1824 std::vector<std::size_t> place_nodes;
1825 for (std::size_t nd = 1; nd <= nnodes; ++nd)
1826 if (sn.nodes[nd - 1].nodetype == NodeType::Place) {
1827 place_of[nd] = place_nodes.size();
1828 place_nodes.push_back(nd);
1829 }
1830 std::vector<std::size_t> place_station(place_nodes.size(), M);
1831 for (std::size_t p = 0; p < place_nodes.size(); ++p)
1832 place_station[p] = node_to_station[place_nodes[p]];
1833 /**
1834 * The initial marking: what the model DECLARED, widened by the closed
1835 * populations whose reference station is this place.
1836 *
1837 * The widening is not a convenience. A closed class in a Petri net has its
1838 * jobs in the marking and nowhere else, and `Network.initDefault` is what
1839 * puts them there -- which the JSON document only records once someone has
1840 * called `setState`, so a net that never did would start EMPTY and never
1841 * fire. `default_init_state` takes the same maximum for the CTMC, and
1842 * `initClosedClassPopulations` skips its own injection on a Place for the
1843 * same reason, so all three agree on the state the model starts in.
1844 */
1845 std::vector<std::vector<double>> marking(place_nodes.size(), std::vector<double>(K, 0.0));
1846 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
1847 auto im = sn.initmarking.find(place_nodes[p]);
1848 if (im != sn.initmarking.end())
1849 for (std::size_t r = 0; r < K && r < im->second.size(); ++r)
1850 marking[p][r] = num_traits<T>::to_double(im->second[r]);
1851 for (std::size_t r = 0; r < K; ++r) {
1852 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
1853 const double n = sn.classes[r].population;
1854 if (!std::isfinite(n) || !(n > 0.0)) continue;
1855 if (sn.classes[r].refstat != place_station[p] + 1) continue;
1856 marking[p][r] = std::max(marking[p][r], n);
1857 }
1858 }
1859
1860 // ---- queueing places -----------------------------------------------------
1861 /**
1862 * A QUEUEING PLACE holds its tokens in three compartments, and only the last
1863 * of them is what a Petri net normally calls the marking.
1864 *
1865 * waiting tokens queued for one of the place's own servers
1866 * in service tokens a server is working on
1867 * depository tokens whose service is finished
1868 *
1869 * THE OUTPUT ARCS SEE THE DEPOSITORY ALONE. That is the entire construct: a
1870 * token deposited by an input transition has to be SERVED before it can
1871 * enable an output one, which is how a queueing Petri net puts a scheduling
1872 * station inside a place. `marking` stays the sum of the three, because that
1873 * is what the place's token count means and what QN reports.
1874 *
1875 * An ORDINARY place keeps `avail == marking` and never touches the rest, so
1876 * every existing net is unmoved.
1877 */
1878 std::vector<std::vector<double>> avail(place_nodes.size(), std::vector<double>(K, 0.0));
1879 std::vector<bool> qplace(place_nodes.size(), false);
1880 std::vector<std::size_t> place_servers(place_nodes.size(), 1);
1881 std::vector<std::deque<std::size_t>> place_wait(place_nodes.size());
1882 std::vector<std::size_t> place_busy(place_nodes.size(), 0);
1883 std::vector<std::vector<double>> place_insvc(place_nodes.size(), std::vector<double>(K, 0.0));
1884 std::vector<std::vector<Sampler>> place_svc(place_nodes.size(), std::vector<Sampler>(K));
1885 /**
1886 * One stream per (place, class), on the reference's own band.
1887 *
1888 * `Solver_ssj` seeds a queueing place's generators at
1889 * `((numSources + numServiceNodes + placeIdx) * K + k) * 10 + 5000`, and
1890 * this is that expression: `svc_index` already numbers the non-Source
1891 * stations the way `numServiceNodes` counts them, and a place IS one of
1892 * them here. The transition clocks sit at the bare +5000 of `g_spn`, which
1893 * the expression never reaches because an SPN has at least one place and so
1894 * at least one full stride of K*10 above it.
1895 */
1896 std::vector<std::vector<Rng>> g_place(place_nodes.size());
1897 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
1898 const std::size_t ist = place_station[p];
1899 g_place[p].reserve(K);
1900 for (std::size_t k = 0; k < K; ++k) {
1901 const long long off =
1902 ((nsrc + nsvc + static_cast<long long>(p)) * Kll + static_cast<long long>(k)) *
1903 10 + 5000;
1904 g_place[p].push_back(Rng(seed_ll, off));
1905 }
1906 if (ist >= M) continue;
1907 qplace[p] = sn.is_queueing_place(ist);
1908 if (!qplace[p]) {
1909 for (std::size_t r = 0; r < K; ++r) avail[p][r] = marking[p][r];
1910 continue;
1911 }
1912 const auto& pst = sn.stations[ist];
1913 // The embedded queue's discipline. FCFS, LCFS and SIRO differ only in
1914 // WHICH waiting token is taken next; INF takes every one of them at
1915 // once and so has no order to choose. Anything else is refused by name:
1916 // sharing a place's server among its tokens, or ordering them by a size
1917 // the place never sampled, is a queue this construct does not describe.
1918 if (pst.sched != SchedStrategy::FCFS && pst.sched != SchedStrategy::LCFS &&
1919 pst.sched != SchedStrategy::SIRO && pst.sched != SchedStrategy::INF)
1920 throw UnsupportedError(
1921 "SolverLDES (native engine): queueing place '" + pst.name +
1922 "' is scheduled " + lang::sched_to_text(pst.sched) +
1923 ", which the embedded-queue algorithm does not support "
1924 "(supported: FCFS, LCFS, SIRO, INF)");
1925 place_servers[p] = (pst.sched == SchedStrategy::INF || !std::isfinite(pst.nservers))
1926 ? std::numeric_limits<std::size_t>::max()
1927 : static_cast<std::size_t>(pst.nservers + 0.5);
1928 for (std::size_t r = 0; r < K; ++r) {
1929 if (sn.service[ist][r].disabled) continue;
1930 place_svc[p][r] =
1931 Sampler(sn.service[ist][r],
1932 "queueing place '" + pst.name + "', class '" + sn.classes[r].name + "'");
1933 }
1934 // The initial tokens are QUEUED, not deposited: a token the model puts
1935 // in a queueing place has not been served yet, so it must pass through
1936 // the embedded queue before an output arc may take it. ASCENDING class
1937 // order, which is the order `Solver_ssj` enqueues them in.
1938 for (std::size_t k = 0; k < K; ++k)
1939 for (double t = 0.0; t + 0.5 < marking[p][k]; t += 1.0) place_wait[p].push_back(k);
1940 }
1941
1942 std::vector<SpnTransition> transitions;
1943 for (const auto& tp : sn.transparam) {
1944 const std::size_t nd = tp.first;
1945 if (nd == 0 || nd > nnodes) continue;
1946 SpnTransition tr;
1947 tr.node = nd;
1948 tr.places = place_nodes;
1949 for (std::size_t m = 0; m < tp.second.nmodes; ++m) {
1950 SpnMode mode;
1951 // (place slot, class), flattened p*K + r, the layout `flat_marking`
1952 // produces and `spn_enabled`/`spn_fire` read.
1953 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
1954 const std::size_t pn = place_nodes[p];
1955 for (std::size_t r = 0; r < K; ++r) {
1956 mode.enabling.push_back(
1957 (m < tp.second.enabling.size() && pn - 1 < tp.second.enabling[m].rows() &&
1958 r < tp.second.enabling[m].cols())
1959 ? num_traits<T>::to_double(tp.second.enabling[m](pn - 1, r))
1960 : 0.0);
1961 mode.inhibiting.push_back(
1962 (m < tp.second.inhibiting.size() &&
1963 pn - 1 < tp.second.inhibiting[m].rows() &&
1964 r < tp.second.inhibiting[m].cols())
1965 ? num_traits<T>::to_double(tp.second.inhibiting[m](pn - 1, r))
1966 : std::numeric_limits<double>::infinity());
1967 mode.firing.push_back(
1968 (m < tp.second.firing.size() && pn - 1 < tp.second.firing[m].rows() &&
1969 r < tp.second.firing[m].cols())
1970 ? num_traits<T>::to_double(tp.second.firing[m](pn - 1, r))
1971 : 0.0);
1972 }
1973 }
1974 if (m < tp.second.nmodeservers.size()) mode.servers = tp.second.nmodeservers[m];
1975 if (m < tp.second.firingprio.size()) mode.priority = tp.second.firingprio[m];
1976 if (m < tp.second.fireweight.size())
1977 mode.weight = num_traits<T>::to_double(tp.second.fireweight[m]);
1978 mode.immediate = (m < tp.second.timing.size() &&
1979 tp.second.timing[m] == lang::TimingStrategy::IMMEDIATE);
1980 if (m < tp.second.firingproc.size() && !tp.second.firingproc[m].disabled) {
1981 const double mean = num_traits<T>::to_double(tp.second.firingproc[m].mean);
1982 if (mean > 0.0) mode.rate = 1.0 / mean;
1983 }
1984 if (m < tp.second.firingdep.size() && tp.second.firingdep[m]) {
1985 const auto f = tp.second.firingdep[m];
1986 const std::vector<std::size_t> pl = place_nodes;
1987 const std::size_t nn = nnodes, KK = K;
1988 // g(marking) IS NODE-INDEXED AND CLASS-SUMMED, the vector
1989 // `state_events.h` builds and the reader's slot lattice is keyed
1990 // by. Handing it the (place, class) vector instead read the
1991 // wrong slot whenever a place was not the node of the same
1992 // ordinal, and every slot once the class dimension existed.
1993 mode.dep = [f, pl, nn, KK](const std::vector<double>& tok) {
1994 std::vector<T> mk(nn, num_traits<T>::from_int(0));
1995 for (std::size_t p = 0; p < pl.size(); ++p) {
1996 double s = 0.0;
1997 for (std::size_t r = 0; r < KK; ++r)
1998 if (p * KK + r < tok.size()) s += tok[p * KK + r];
1999 if (pl[p] >= 1 && pl[p] - 1 < nn)
2000 mk[pl[p] - 1] = num_traits<T>::from_double(s);
2001 }
2002 return num_traits<T>::to_double(f(mk));
2003 };
2004 }
2005 tr.modes.push_back(mode);
2006 }
2007 tr.fired.assign(tr.modes.size(), 0.0);
2008 transitions.push_back(tr);
2009 }
2010
2011 // ---- statistics --------------------------------------------------------
2012 Accum acc(M, K);
2013 for (std::size_t i = 0; i < M; ++i) acc.util_peak[i] = S[i].util_peak;
2014 // A PLACE'S QUEUE LENGTH IS ITS MARKING, and it starts at the initial one.
2015 // The integral is lazy, so the value has to be in `acc.qlen` from t = 0 and
2016 // every later write has to go through `update_qlen` first, exactly as a
2017 // station's does.
2018 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
2019 if (place_station[p] >= M) continue;
2020 for (std::size_t r = 0; r < K; ++r) acc.qlen[place_station[p]][r] = marking[p][r];
2021 }
2022 // Declared here and populated below: the admission lambda tracks into it,
2023 // and a lambda cannot capture a name introduced after it.
2024 BusyPeriods bp;
2025 std::vector<double> sys_resp_sum(K, 0.0), sys_resp_cnt(K, 0.0), sys_completed(K, 0.0);
2026 // Per (station, class): a model can drop at one station and balk at
2027 // another, and a per-class total would not say which.
2028 std::vector<std::vector<double>> dropped(M, std::vector<double>(K, 0.0));
2029 std::vector<std::vector<double>> balked(M, std::vector<double>(K, 0.0));
2030 std::vector<std::vector<double>> reneged(M, std::vector<double>(K, 0.0));
2031 std::vector<std::vector<double>> blocked_count(M, std::vector<double>(K, 0.0));
2032 /**
2033 * Every per-visit response time, when either flag asked for them.
2034 *
2035 * --trajectory counts as asking: the reference NESTS the same samples inside
2036 * its `transient` block, and MATLAB's `sample()` and `sampleSys()` read them
2037 * from there. Recording only under --respt-samples left those two returning
2038 * nothing on the transient path.
2039 */
2040 const bool want_respt = o.export_respt || o.export_trajectory;
2041 std::vector<std::vector<std::vector<double>>> resp_samples(
2042 want_respt ? M : 0, std::vector<std::vector<double>>(K));
2043 std::uint64_t job_id = 0;
2044 double now = 0.0;
2045
2046 /**
2047 * G-NETWORK REMOVAL SIGNALS, resolved once per class.
2048 *
2049 * A removal signal is a class like any other while it is in transit and
2050 * stops being one the instant it reaches a station: it never joins, it
2051 * removes jobs already there and it is annihilated. `is_removal_signal`
2052 * is the whole gate, and everything the removal costs -- the registry of
2053 * jobs held at a Delay station, the extra draws -- is paid only where it
2054 * is true, so a model without signals runs exactly as before.
2055 */
2056 std::vector<bool> is_removal_signal(K, false);
2057 bool has_removal_signal = false;
2058 for (std::size_t r = 0; r < K; ++r) {
2059 if (r >= sn.issignal.size() || !sn.issignal[r]) continue;
2060 const lang::SignalType kind =
2061 (r < sn.signaltype.size()) ? sn.signaltype[r] : lang::SignalType::NEGATIVE;
2063 is_removal_signal[r] = true;
2064 has_removal_signal = true;
2065 }
2066 }
2067 /**
2068 * The jobs a Delay station currently holds, in arrival order.
2069 *
2070 * A Delay keeps no buffer and no server: its occupants live only in the
2071 * departure events already scheduled for them, and the event list has no
2072 * handle to cancel one. The registry is that handle -- a job removed by a
2073 * signal is erased from it, and the departure that arrives later finds no
2074 * entry and is discarded, which is the same tag trick the buffered
2075 * stations use against a preempted departure.
2076 */
2077 /**
2078 * The Delay job registry is kept for a GLOBAL DEPENDENCE as well as for a
2079 * removal signal: phi re-times the think times with everything else, and
2080 * the registry is the only handle on them (an infinite server has no slot
2081 * to hang a generation off).
2082 */
2083 const bool track_delay_jobs = has_removal_signal || has_gd;
2084 std::vector<std::vector<Job>> delay_live(track_delay_jobs ? M : 0);
2085
2086 /**
2087 * SYNCHRONOUS CALLS, `sn.syncreply`: the class a caller expects its reply in.
2088 *
2089 * A caller that expects one KEEPS ITS SERVER when it leaves for the callee
2090 * and gets it back only when the matching REPLY class returns, which is the
2091 * whole difference between a synchronous call and an ordinary hop. The
2092 * pending calls are keyed by an identity that rides on the job, because the
2093 * reply comes back from a station the caller never names.
2094 */
2095 std::vector<std::size_t> sync_reply(K, K);
2096 std::vector<bool> is_reply_signal(K, false);
2097 bool has_sync_call = false;
2098 for (std::size_t r = 0; r < K; ++r) {
2099 if (r < sn.syncreply.size() && sn.syncreply[r] >= 1 && sn.syncreply[r] <= K) {
2100 sync_reply[r] = sn.syncreply[r] - 1;
2101 has_sync_call = true;
2102 }
2103 if (r < sn.issignal.size() && sn.issignal[r] && r < sn.signaltype.size() &&
2104 sn.signaltype[r] == lang::SignalType::REPLY)
2105 is_reply_signal[r] = true;
2106 }
2107 /** One outstanding call: the slot it holds and the class that holds it. */
2108 struct PendingCall {
2109 std::size_t station = 0;
2110 std::size_t slot = 0;
2111 std::size_t cls = 0;
2112 double since = 0.0;
2113 };
2114 std::map<std::uint64_t, PendingCall> pending_reply;
2115 std::uint64_t next_call = 0;
2116
2117 /** Whether any (station, class) declares immediate feedback at all. */
2118 bool has_immfeed = false;
2119 for (std::size_t i = 0; i < M && i < sn.immfeed.size(); ++i)
2120 for (std::size_t r = 0; r < sn.immfeed[i].size(); ++r)
2121 if (sn.immfeed[i][r]) has_immfeed = true;
2122
2123 /**
2124 * SPAWN ON COMPLETION, `sn.classspawn`: completing a job of class r injects
2125 * a FRESH job of the mapped class at the same station.
2126 *
2127 * It is an LQN phase-2 continuation: the work the server owes after the
2128 * reply has gone back. The continuation is a new passage, so it starts its
2129 * own response-time clock and is not the completing job under another name.
2130 */
2131 std::vector<std::size_t> spawn_of(K, K);
2132 bool has_spawn = false;
2133 for (std::size_t r = 0; r < K; ++r)
2134 if (sn.classes[r].spawn >= 1 && sn.classes[r].spawn <= K) {
2135 spawn_of[r] = sn.classes[r].spawn - 1;
2136 has_spawn = true;
2137 }
2138
2139 // ---- event list --------------------------------------------------------
2140 std::priority_queue<Event, std::vector<Event>, EventLater> evq;
2141 std::uint64_t seq = 0, ps_tag = 0;
2142
2143 auto push = [&](Event e) {
2144 e.seq = seq++;
2145 evq.push(e);
2146 };
2147
2148 // ---- the embedded queue of a queueing place ------------------------------
2149 /**
2150 * Take the next waiting token into service, as often as a server is free.
2151 *
2152 * THE DISCIPLINE CHOOSES WHICH TOKEN, and nothing else about the place:
2153 * FCFS takes the token that has waited longest, LCFS the one that has waited
2154 * least, SIRO one at random, and INF takes all of them because it never runs
2155 * out of servers. The reference's engine accepts the same four names but
2156 * polls the head of one deque for all of them, so LCFS and SIRO are served
2157 * FIFO there; the mean token count is the same under any work-conserving
2158 * order at exponential service, and the two engines part company only in the
2159 * sojourn-time DISTRIBUTION they report.
2160 */
2161 std::function<void(std::size_t)> place_try_start = [&](std::size_t p) {
2162 const std::size_t ist = place_station[p];
2163 if (ist >= M) return;
2164 while (place_busy[p] < place_servers[p] && !place_wait[p].empty()) {
2165 std::size_t r = 0;
2166 if (S[ist].sched == SchedStrategy::LCFS) {
2167 r = place_wait[p].back();
2168 place_wait[p].pop_back();
2169 } else if (S[ist].sched == SchedStrategy::SIRO) {
2170 const std::size_t at = std::min(
2171 place_wait[p].size() - 1,
2172 static_cast<std::size_t>(uniform01(g_qplace) *
2173 static_cast<double>(place_wait[p].size())));
2174 r = place_wait[p][at];
2175 place_wait[p].erase(place_wait[p].begin() + static_cast<std::ptrdiff_t>(at));
2176 } else {
2177 r = place_wait[p].front();
2178 place_wait[p].pop_front();
2179 }
2180 // A COLOUR THE PLACE CANNOT SERVE IS A MODEL DEFECT, not a token to
2181 // pass through: a queueing place is queueing for every colour at
2182 // once, so a token arriving in a colour `setService` never named has
2183 // no service time to draw and would otherwise sit in the queue
2184 // forever, silently draining the net.
2185 if (place_svc[p][r].disabled())
2186 throw InputError("SolverLDES (native engine): a token of class '" +
2187 sn.classes[r].name + "' entered queueing place '" +
2188 sn.stations[ist].name +
2189 "', which has no service process for that class; call "
2190 "setService for every token colour of a queueing place");
2191 acc.update_busy(ist, r, now);
2192 ++place_busy[p];
2193 place_insvc[p][r] += 1.0;
2194 acc.busy[ist][r] = place_insvc[p][r];
2195 Event e;
2196 e.t = now + place_svc[p][r].next(g_place[p][r]);
2197 e.kind = EV_PLACE_SVC;
2198 e.station = p;
2199 e.cls = r;
2200 push(e);
2201 }
2202 };
2203
2204 /** Tokens an input arc puts at place `p`: into the queue, or straight to the arcs. */
2205 auto place_deposit = [&](std::size_t p, std::size_t r, double n) {
2206 const std::size_t ist = place_station[p];
2207 if (ist < M) {
2208 acc.update_qlen(ist, r, now);
2209 marking[p][r] += n;
2210 acc.qlen[ist][r] = marking[p][r];
2211 acc.arrived[ist][r] += n;
2212 } else {
2213 marking[p][r] += n;
2214 }
2215 if (!qplace[p]) {
2216 avail[p][r] += n;
2217 return;
2218 }
2219 for (double t = 0.0; t + 0.5 < n; t += 1.0) place_wait[p].push_back(r);
2220 place_try_start(p);
2221 };
2222
2223 /**
2224 * Tokens an output arc takes from place `p`, which are its COMPLETIONS.
2225 *
2226 * TN at a place is the rate at which tokens leave it, which is
2227 * `placeCompletions` in the reference and what Little's law turns back into
2228 * the mean token sojourn reported as RespT. A queueing place takes them from
2229 * the depository, the only compartment an arc can see.
2230 */
2231 auto place_take = [&](std::size_t p, std::size_t r, double n) {
2232 const std::size_t ist = place_station[p];
2233 marking[p][r] -= n;
2234 avail[p][r] -= n;
2235 if (ist < M) {
2236 acc.update_qlen(ist, r, now);
2237 acc.qlen[ist][r] = marking[p][r];
2238 acc.completed[ist][r] += n;
2239 }
2240 };
2241
2242
2243 /**
2244 * Push onto, and pop from, a station's waiting room.
2245 *
2246 * FSP takes the linear branch: its key is recomputed against the residual
2247 * work present at the station RIGHT NOW, over the waiting jobs and the
2248 * ones in service alike, so no stamped ordering can stand in for it. Every
2249 * other discipline has a per-job constant key and rides the heap.
2250 */
2251 auto buffer_push = [&](std::size_t i, const Job& job) {
2252 StationState& s = S[i];
2253 s.buffer.push_back(job);
2254 if (s.sched != SchedStrategy::FSP)
2255 std::push_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
2256 };
2257 auto buffer_pop = [&](std::size_t i) -> Job {
2258 StationState& s = S[i];
2259 if (s.sched != SchedStrategy::FSP) {
2260 std::pop_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
2261 Job job = s.buffer.back();
2262 s.buffer.pop_back();
2263 return job;
2264 }
2265 std::vector<double> works;
2266 for (const Job& j : s.buffer) works.push_back(j.remaining);
2267 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2268 if (s.server_busy[sl])
2269 works.push_back(std::max(0.0, s.server[sl].remaining -
2270 (now - s.server_start[sl])));
2271 std::size_t best = 0;
2272 double best_vft = std::numeric_limits<double>::infinity();
2273 for (std::size_t j = 0; j < s.buffer.size(); ++j) {
2274 const double v = fsp_virtual_finish(works, s.buffer[j].remaining,
2275 static_cast<double>(s.nservers), now);
2276 if (v < best_vft) {
2277 best_vft = v;
2278 best = j;
2279 }
2280 }
2281 Job job = s.buffer[best];
2282 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(best));
2283 return job;
2284 };
2285
2286 // ---- heterogeneous server pools: slot choice and buffer matching ---------
2287 //
2288 // Two questions the flat server bank never had to ask. WHICH SLOT can take
2289 // this job, and WHICH WAITING JOB can this slot take. Everything else in the
2290 // engine addresses a server by its slot and is untouched.
2291 //
2292 // On a station with no pools both reduce to what was there before -- the
2293 // first free slot, and the head of the waiting room -- so the pool-free path
2294 // keeps its exact draw sequence and a seeded homogeneous run is unchanged.
2295
2296 /** Free, unblocked, unheld, and its pool accepts `cls`. */
2297 auto slot_ok_for = [&](const StationState& s, std::size_t sl, std::size_t cls) -> bool {
2298 if (s.server_busy[sl] || s.server_blocked[sl] || s.server_held[sl]) return false;
2299 if (!s.has_pools) return true;
2300 return s.type_compat[s.server_type[sl]][cls];
2301 };
2302
2303 /** Idle, unblocked, unheld slots: the pool a job seizing n of them draws from. */
2304 auto free_slot_count = [&](std::size_t i) -> std::size_t {
2305 const StationState& s = S[i];
2306 std::size_t n = 0;
2307 for (std::size_t sl = 0; sl < s.server_busy.size(); ++sl)
2308 if (!s.server_busy[sl] && !s.server_blocked[sl] && !s.server_held[sl]) ++n;
2309 return n;
2310 };
2311
2312 /**
2313 * The free slot a job of class `cls` may enter, or `s.nservers` for none.
2314 *
2315 * The POOL is chosen first, by the station's heterogeneous scheduling
2316 * policy, and the slot is then the first free one inside it. That is the
2317 * reference's order and it matters: picking the globally-first free slot
2318 * would make every policy behave as ORDER.
2319 */
2320 auto free_slot_for = [&](std::size_t i, std::size_t cls) -> std::size_t {
2321 StationState& s = S[i];
2322 // A JOB DECLARING SERVER PARALLELISM NEEDS ITS WHOLE SET AT ONCE: a
2323 // station with fewer than n idle slots cannot start it even though one
2324 // of them is free, which is the entire content of the feature.
2325 if (s.has_parallelism && free_slot_count(i) < s.parallelism[cls]) return s.nservers;
2326 if (!s.has_pools) {
2327 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2328 if (slot_ok_for(s, sl, cls)) return sl;
2329 return s.nservers;
2330 }
2331 // The pools that accept the class AND still hold a free slot.
2332 std::vector<std::size_t> cand;
2333 for (std::size_t t = 0; t < s.type_count.size(); ++t) {
2334 if (!s.type_compat[t][cls]) continue;
2335 for (std::size_t j = 0; j < s.type_count[t]; ++j)
2336 if (slot_ok_for(s, s.type_first[t] + j, cls)) {
2337 cand.push_back(t);
2338 break;
2339 }
2340 }
2341 if (cand.empty()) return s.nservers;
2342 std::size_t chosen = cand[0];
2343 if (cand.size() > 1) {
2344 switch (s.hetero_policy) {
2347 // Round robin over the pools: the one just used goes to the
2348 // back, so a steady stream spreads over the compatible
2349 // pools instead of filling the first one.
2350 for (std::size_t k = 0; k < s.type_order.size(); ++k) {
2351 const std::size_t t = s.type_order[k];
2352 if (std::find(cand.begin(), cand.end(), t) == cand.end()) continue;
2353 chosen = t;
2354 s.type_order.erase(s.type_order.begin() +
2355 static_cast<std::ptrdiff_t>(k));
2356 s.type_order.push_back(t);
2357 break;
2358 }
2359 break;
2360 }
2362 // Least flexible first: spend the pool that fewest classes
2363 // can use before the one that many can.
2364 for (std::size_t k = 0; k < s.alfs_order.size(); ++k)
2365 if (std::find(cand.begin(), cand.end(), s.alfs_order[k]) != cand.end()) {
2366 chosen = s.alfs_order[k];
2367 break;
2368 }
2369 break;
2370 }
2372 // Fastest server first, by this class's rate at the pool.
2373 double best = -1.0;
2374 for (std::size_t k = 0; k < cand.size(); ++k) {
2375 const double rt = s.type_rate[cand[k]][cls];
2376 if (rt > best) {
2377 best = rt;
2378 chosen = cand[k];
2379 }
2380 }
2381 break;
2382 }
2384 // A uniform draw over the candidate pools, off the ROUTING
2385 // stream as the reference draws it.
2386 std::size_t at = static_cast<std::size_t>(
2387 uniform01(g_routing) * static_cast<double>(cand.size()));
2388 if (at >= cand.size()) at = cand.size() - 1;
2389 chosen = cand[at];
2390 break;
2391 }
2393 default: {
2394 // Declaration order, which `cand` is already built in.
2395 chosen = cand[0];
2396 break;
2397 }
2398 }
2399 }
2400 for (std::size_t j = 0; j < s.type_count[chosen]; ++j) {
2401 const std::size_t sl = s.type_first[chosen] + j;
2402 if (slot_ok_for(s, sl, cls)) return sl;
2403 }
2404 return s.nservers;
2405 };
2406
2407 /** Is any waiting job servable by slot `sl`? */
2408 auto buffer_has_for_slot = [&](std::size_t i, std::size_t sl) -> bool {
2409 StationState& s = S[i];
2410 if (s.buffer.empty()) return false;
2411 if (!s.has_pools) return true;
2412 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
2413 for (std::size_t j = 0; j < s.buffer.size(); ++j)
2414 if (ok[s.buffer[j].cls]) return true;
2415 return false;
2416 };
2417
2418 /**
2419 * Pop the job slot `sl` should serve next.
2420 *
2421 * Off a pooled station this IS `buffer_pop`. On one it is the job the
2422 * station's own order would have taken FIRST AMONG THOSE THE SLOT CAN
2423 * SERVE, which is what a pool means: a server does not skip the queue, it
2424 * skips the jobs it is not compatible with. The heap is drained and rebuilt
2425 * because the discipline's order lives in the comparator, not in the
2426 * sequence, so scanning the vector would answer in heap order rather than
2427 * in service order.
2428 */
2429 auto buffer_pop_for_slot = [&](std::size_t i, std::size_t sl) -> Job {
2430 StationState& s = S[i];
2431 if (!s.has_pools) return buffer_pop(i);
2432 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
2433 std::vector<Job> skipped;
2434 Job job;
2435 bool got = false;
2436 while (!s.buffer.empty()) {
2437 Job cand = buffer_pop(i);
2438 if (ok[cand.cls]) {
2439 job = cand;
2440 got = true;
2441 break;
2442 }
2443 skipped.push_back(cand);
2444 }
2445 for (std::size_t j = 0; j < skipped.size(); ++j) buffer_push(i, skipped[j]);
2446 if (!got)
2447 throw InputError("SolverLDES (native engine): buffer_pop_for_slot was asked for a "
2448 "job no pool of this slot can serve; guard with buffer_has_for_slot");
2449 return job;
2450 };
2451
2452
2453 /**
2454 * The RATE multiplier a station applies to a class at its current
2455 * population: load dependence times class dependence.
2456 *
2457 * A sampled requirement is DIVIDED by this, so a multiplier above one is a
2458 * faster station. `lldscaling` is indexed by the total population minus
2459 * one, saturating at the table's end, which is the reference's convention
2460 * and not a guard: the table is declared up to the station's capacity and a
2461 * closed model can hold exactly that many.
2462 *
2463 * A SHARING station does NOT take the load-dependent factor here. Its
2464 * `lldscaling` is read instead as the effective SERVER COUNT in
2465 * `ps_shares`, because c servers and a c-fold rate are the same thing only
2466 * when the capacity is shared -- applying both would scale it twice.
2467 */
2468 // The load-dependent speed alone, which is both a factor of the service
2469 // rate below and the scale the busy integral is measured in. Sharing one
2470 // definition is what keeps `U = T*E[S]/peak` exact on the sample path.
2471 auto lld_factor = [&](std::size_t i) -> double {
2472 StationState& s = S[i];
2473 if (s.lld.empty() || s.ps) return 1.0;
2474 double total = 0.0;
2475 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
2476 if (!(total > 0.0)) return 1.0;
2477 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
2478 return s.lld[idx] > 0.0 ? s.lld[idx] : 1.0;
2479 };
2480 // The class-dependent speed alone. It is a factor of the RATE, so every
2481 // path that turns a population into a departure time has to apply it -- the
2482 // sharing disciplines included, which is what `ps_advance`/`ps_reschedule`
2483 // below missed: they built their shares from `ps_shares` and the breakdown
2484 // clock and never read the handle, so a PS station carrying a `cdscaling`
2485 // ran its whole sample path at the UNSCALED rates. On a closed
2486 // Delay+Queue(PS), N=3, beta(n)=min(n,2), that was Tput 0.935 against the
2487 // exact 1.412, and utilization inherited the error through T*E[S]/peak.
2488 auto cd_factor = [&](std::size_t i, std::size_t r) -> double {
2489 StationState& s = S[i];
2490 if (!s.has_cd) return 1.0;
2491 std::vector<double> nvec(K, 0.0);
2492 for (std::size_t k = 0; k < K; ++k) nvec[k] = acc.qlen[i][k];
2493 const std::vector<double> beta = s.cd(nvec);
2494 const double b = (beta.size() > 1) ? beta[r] : (beta.empty() ? 1.0 : beta[0]);
2495 return (b > 1e-10) ? b : 1.0;
2496 };
2497 /**
2498 * Read phi at the LIVE population and install it in the cache.
2499 *
2500 * It is called from the RESCHEDULE fan-out and from nowhere else, because
2501 * that is the instant the new speed takes effect: an advance has to credit
2502 * the interval that just ended at the speed which held THROUGH it, and the
2503 * cache still holds that one. Same flush-then-install order as `ps_cd` and
2504 * `Accum::set_busy_scale`.
2505 */
2506 auto gd_refresh = [&]() {
2507 std::vector<T> nvec(M * K);
2508 for (std::size_t i = 0; i < M; ++i)
2509 for (std::size_t r = 0; r < K; ++r)
2510 nvec[i * K + r] = num_traits<T>::from_double(acc.qlen[i][r]);
2511 const std::vector<T> v = sn.gdscaling(nvec);
2512 if (v.empty())
2513 throw InputError("SolverLDES (native engine): the global dependence handle returned "
2514 "no scaling");
2515 for (std::size_t i = 0; i < M; ++i)
2516 for (std::size_t r = 0; r < K; ++r) {
2517 // Scalar, one per station, or one per (station, class): the same
2518 // broadcast `set_global_dependence` validates at declaration.
2519 const double f = num_traits<T>::to_double(
2520 (v.size() == 1) ? v[0] : ((v.size() == M) ? v[i] : v[i * K + r]));
2521 if (!std::isfinite(f) || f < 0.0)
2522 throw InputError("SolverLDES (native engine): the global dependence handle "
2523 "returned a scaling that is not finite and nonnegative at "
2524 "station '" + sn.stations[i].name + "'");
2525 if (!(f > 0.0) && acc.qlen[i][r] > 0.0)
2526 throw InputError(
2527 "SolverLDES (native engine): the global dependence handle returned a "
2528 "zero scaling at station '" + sn.stations[i].name +
2529 "', which holds jobs of class '" + sn.classes[r].name +
2530 "'. A station that stops while it holds work has no completion left to "
2531 "schedule; use Queue.setBreakdown for a server that genuinely stops");
2532 gd_cache[i][r] = f;
2533 }
2534 };
2535
2536 auto rate_scaling = [&](std::size_t i, std::size_t r) -> double {
2537 StationState& s = S[i];
2538 double factor = lld_factor(i) * cd_factor(i, r);
2539 // A GLOBAL DEPENDENCE IS A FACTOR OF THE SAME RATE, read out of the
2540 // cache rather than the handle for the reason `gd_refresh` gives.
2541 if (has_gd) factor *= gd_cache[i][r];
2542 // A DOWN SERVER IS A ZERO FACTOR, not a skipped station: the job in
2543 // service keeps its residual and simply stops accruing, which is what
2544 // makes the outage an interruption rather than an eviction. A positive
2545 // `down_scale` is the degraded server of `downServiceRates`.
2546 if (s.has_breakdown && !s.up)
2547 factor *= (r < s.down_scale.size() ? s.down_scale[r] : 0.0);
2548 return factor;
2549 };
2550
2551 /**
2552 * The effective server count of a SHARING station, which is where its
2553 * load-dependence table is read. Transcribes `getEffectivePSServerCount`:
2554 * with no jobs present the count is 1, not the table's first entry, so the
2555 * very first arrival gets a whole server.
2556 */
2557 auto ps_servers = [&](std::size_t i) -> double {
2558 StationState& s = S[i];
2559 if (s.lld.empty()) return static_cast<double>(s.nservers);
2560 double total = 0.0;
2561 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
2562 if (!(total > 0.0)) return 1.0;
2563 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
2564 return s.lld[idx];
2565 };
2566
2567 // ---- the sharing disciplines -------------------------------------------
2568 // Every population change at a PS station invalidates every departure it
2569 // has scheduled, so the pair below is always called together: integrate the
2570 // work delivered since the last change, then reschedule everything. A
2571 // stale event is not cancelled (the event list has no handle); it is
2572 // ignored on arrival by comparing its tag against the job's current one.
2573 auto ps_advance_one = [&](std::size_t i) {
2574 StationState& s = S[i];
2575 const double dt = now - s.ps_last_update;
2576 s.ps_last_update = now;
2577 // THE BUSY TIME OF A SHARING STATION IS THE INTEGRAL OF THE SHARES, not
2578 // of the head count. Every job at a PS station is "in service", so
2579 // counting heads makes the busy time n*dt and reports a utilization of
2580 // n/c -- above one as soon as the station queues at all. The reference
2581 // accumulates `rate * elapsed` per job for exactly this reason, which
2582 // also makes U = T*E[S] hold automatically, since the shares sum to the
2583 // work actually delivered.
2584 for (std::size_t r = 0; r < K; ++r) acc.last_busy[i][r] = now;
2585 if (!(dt > 0.0) || s.ps_jobs.empty()) return;
2586 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
2587 if (s.has_cd)
2588 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2589 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
2590 if (has_gd)
2591 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2592 rates[j] *= gd_cache[i][s.ps_jobs[j].cls];
2593 if (s.has_breakdown && !s.up)
2594 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2595 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
2596 : 0.0);
2597 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
2598 if (rates[j] > 0.0) {
2599 acc.tot_busy[i][s.ps_jobs[j].cls] += rates[j] * dt;
2600 s.ps_jobs[j].remaining = std::max(0.0, s.ps_jobs[j].remaining - rates[j] * dt);
2601 }
2602 };
2603 auto ps_reschedule_one = [&](std::size_t i) {
2604 StationState& s = S[i];
2605 // The new speed takes effect HERE, on the population the caller has just
2606 // written, and the departures re-timed below are what it applies to.
2607 if (s.has_cd)
2608 for (std::size_t r = 0; r < K; ++r) s.ps_cd[r] = cd_factor(i, r);
2609 if (s.ps_jobs.empty()) return;
2610 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
2611 if (s.has_cd)
2612 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2613 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
2614 if (has_gd)
2615 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2616 rates[j] *= gd_cache[i][s.ps_jobs[j].cls];
2617 if (s.has_breakdown && !s.up)
2618 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
2619 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
2620 : 0.0);
2621 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j) {
2622 PsJob& pj = s.ps_jobs[j];
2623 // Retag the STARVED jobs too: the tag is what invalidates a departure already in the list.
2624 pj.tag = ++ps_tag;
2625 const double rate = rates[j];
2626 if (!(rate > 0.0)) continue;
2627 Event e;
2628 e.t = now + ((pj.remaining <= 1e-12) ? 1e-12 : pj.remaining / rate);
2629 e.kind = EV_DEPARTURE;
2630 e.station = i;
2631 e.cls = pj.cls;
2632 e.tag = pj.tag;
2633 push(e);
2634 }
2635 };
2636
2637
2638 /**
2639 * Integrate the work delivered at a state-dependent station since its last
2640 * change, then re-time every completion it holds.
2641 *
2642 * `sd_advance` must run BEFORE the population is written and `sd_reschedule`
2643 * after, because the rate in force over the elapsed interval is the one the
2644 * OLD population implied. The stale departures are neutralised by the tag,
2645 * exactly as a preemption's are.
2646 */
2647 auto sd_advance_one = [&](std::size_t i) {
2648 StationState& s = S[i];
2649 if (!s.state_dependent || s.ps) return;
2650 const double dt = now - s.sd_last_update;
2651 s.sd_last_update = now;
2652 if (!(dt > 0.0)) return;
2653 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2654 if (s.server_busy[sl]) {
2655 const double scale = rate_scaling(i, s.server[sl].cls);
2656 s.server[sl].remaining = std::max(0.0, s.server[sl].remaining - dt * scale);
2657 s.server[sl].elapsed += dt * scale;
2658 s.server_start[sl] = now;
2659 }
2660 };
2661 auto sd_reschedule_one = [&](std::size_t i) {
2662 StationState& s = S[i];
2663 if (!s.state_dependent || s.ps) return;
2664 // The population has just been written, so this is where the new speed
2665 // takes effect -- for the busy integral exactly as for the departures
2666 // re-timed below. `set_busy_scale` credits the interval that just ended
2667 // at the OLD speed before installing the new one.
2668 acc.set_busy_scale(i, lld_factor(i), now);
2669 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2670 if (s.server_busy[sl]) {
2671 const double scale = rate_scaling(i, s.server[sl].cls);
2672 // RETAG BEFORE THE ZERO TEST, as ps_reschedule does. The tag is
2673 // the only thing that invalidates a departure already in the
2674 // list, so a slot whose rate has fallen to zero must be retagged
2675 // too -- otherwise the departure scheduled at the OLD rate stays
2676 // valid and the job leaves in the middle of the outage that was
2677 // supposed to stop it. Measured on M/M/1/8 with a failing
2678 // server: mean queue 1.486 against the exact chain's 1.922, with
2679 // throughput 1.6% HIGH, because the stalled work was delivered
2680 // anyway.
2681 s.server_tag[sl] = ++ps_tag;
2682 if (!(scale > 0.0)) continue;
2683 Event e;
2684 e.t = now + s.server[sl].remaining / scale;
2685 e.kind = EV_DEPARTURE;
2686 e.station = i;
2687 e.cls = s.server[sl].cls;
2688 e.slot = sl;
2689 e.tag = s.server_tag[sl];
2690 push(e);
2691 }
2692 };
2693
2694
2695 /**
2696 * A GLOBAL DEPENDENCE REACHES THE INFINITE SERVERS TOO.
2697 *
2698 * phi multiplies every station's rate, an INF station's included, so its
2699 * think times are integrated forward and re-timed along with everything
2700 * else. They are reached through the job registry because a Delay has no
2701 * slot to hang a residual or a generation off, which is why
2702 * `track_delay_jobs` keeps the registry for a global dependence as well.
2703 */
2704 auto gd_delay_advance = [&](std::size_t i) {
2705 StationState& s = S[i];
2706 const double dt = now - s.sd_last_update;
2707 s.sd_last_update = now;
2708 if (!(dt > 0.0)) return;
2709 for (std::size_t j = 0; j < delay_live[i].size(); ++j) {
2710 Job& d = delay_live[i][j];
2711 const double scale = gd_cache[i][d.cls];
2712 d.remaining = std::max(0.0, d.remaining - dt * scale);
2713 d.elapsed += dt * scale;
2714 }
2715 };
2716 auto gd_delay_reschedule = [&](std::size_t i) {
2717 for (std::size_t j = 0; j < delay_live[i].size(); ++j) {
2718 Job& d = delay_live[i][j];
2719 // Retag before the zero test, as `sd_reschedule_one` does: the tag
2720 // is the only thing that invalidates the departure already listed.
2721 d.tag = ++ps_tag;
2722 const double scale = gd_cache[i][d.cls];
2723 if (!(scale > 0.0)) continue;
2724 Event e;
2725 e.t = now + ((d.remaining <= 1e-12) ? 1e-12 : d.remaining / scale);
2726 e.kind = EV_DEPARTURE;
2727 e.station = i;
2728 e.cls = d.cls;
2729 e.slot = std::numeric_limits<std::size_t>::max();
2730 e.tag = d.tag;
2731 e.job = d;
2732 push(e);
2733 }
2734 };
2735
2736 /**
2737 * The four calls above, fanned out over the whole network when a global
2738 * dependence couples the stations.
2739 *
2740 * A job moving anywhere changes every station's rate at once, so an advance
2741 * has to book the work EVERY station delivered over the interval that just
2742 * ended, and a reschedule has to re-time every completion in flight against
2743 * the refreshed phi. Repeating either is free: an advance over a zero
2744 * interval books nothing, and a reschedule rescales residual work rather
2745 * than resampling, so it consumes no variate and lands on the instant one
2746 * call would have produced. Without a global dependence each call touches
2747 * exactly the station it names, as before.
2748 */
2749 auto gd_advance_all = [&]() {
2750 for (std::size_t a = 0; a < M; ++a) {
2751 if (S[a].role == Role::Delay)
2752 gd_delay_advance(a);
2753 else if (S[a].role != Role::Queue)
2754 continue;
2755 else if (S[a].ps)
2756 ps_advance_one(a);
2757 else
2758 sd_advance_one(a);
2759 }
2760 };
2761 auto gd_reschedule_all = [&]() {
2762 gd_refresh();
2763 for (std::size_t a = 0; a < M; ++a) {
2764 if (S[a].role == Role::Delay)
2765 gd_delay_reschedule(a);
2766 else if (S[a].role != Role::Queue)
2767 continue;
2768 else if (S[a].ps)
2769 ps_reschedule_one(a);
2770 else
2771 sd_reschedule_one(a);
2772 }
2773 };
2774 auto ps_advance = [&](std::size_t i) {
2775 if (has_gd) gd_advance_all(); else ps_advance_one(i);
2776 };
2777 auto ps_reschedule = [&](std::size_t i) {
2778 if (has_gd) gd_reschedule_all(); else ps_reschedule_one(i);
2779 };
2780 auto sd_advance = [&](std::size_t i) {
2781 if (has_gd) gd_advance_all(); else sd_advance_one(i);
2782 };
2783 auto sd_reschedule = [&](std::size_t i) {
2784 if (has_gd) gd_reschedule_all(); else sd_reschedule_one(i);
2785 };
2786
2787 /**
2788 * Whether station `j` can admit one more job of class `r` RIGHT NOW,
2789 * counting the jobs already blocked in front of it.
2790 *
2791 * THE CAPACITY TEST EXCLUDES THE BLOCKED JOBS, and reporting includes
2792 * them. `acc.qlen` carries both, because the reference's
2793 * effectiveQueueLength adds basBlockedAtDest/bbsBlockedAtDest for the
2794 * statistics, while `destinationHasCapacity` reads
2795 * getTotalCustomersAtStation -- the jobs actually AT the station. Testing
2796 * against the inclusive count DEADLOCKS a station of capacity one: the job
2797 * blocked in front of it occupies the very slot it is waiting for, so the
2798 * station can never make room and the upstream server is never released.
2799 */
2800 auto dest_has_room = [&](std::size_t j, std::size_t r) -> bool {
2801 StationState& d = S[j];
2802 if (d.role != Role::Queue) return true;
2803 double total = 0.0;
2804 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[j][k] - d.blocked_at[k];
2805 return (total + 1.0 <= d.cap) &&
2806 (acc.qlen[j][r] - d.blocked_at[r] + 1.0 <= d.classcap[r]);
2807 };
2808
2809 /** The blocking policy the DESTINATION declares for an arriving class. */
2810 auto dest_policy = [&](std::size_t j, std::size_t r) -> lang::DropStrategy {
2811 return (S[j].role == Role::Queue && r < S[j].droprule.size()) ? S[j].droprule[r]
2813 };
2814
2815 // ---- finite capacity regions --------------------------------------------
2816 // At most ONE region per station, which is the reference's own restriction:
2817 // `fcRegionIndices` is a single index per station, and overlapping regions
2818 // would need a join over their constraints that no codebase implements.
2819 std::vector<Region> regions;
2820 std::vector<int> region_of(M, -1);
2821 for (const auto& rg : sn.regions) {
2822 Region R;
2823 R.name = rg.name;
2824 R.members.assign(M, false);
2825 R.class_cap.assign(K, -1.0);
2826 R.class_size.assign(K, 1.0);
2827 R.class_weight.assign(K, 1.0);
2828 R.rule.assign(K, lang::DropStrategy::DROP);
2829 R.jobs.assign(K, 0.0);
2830 R.blocked.assign(K, 0.0);
2831 R.dropped.assign(K, 0.0);
2832 R.tot_jobs.assign(K, 0.0);
2833 R.tot_weight.assign(K, 0.0);
2834 R.tot_mem.assign(K, 0.0);
2835 R.completed.assign(K, 0.0);
2836 R.resp_sum.assign(K, 0.0);
2837 R.resp_cnt.assign(K, 0.0);
2838 for (std::size_t i = 0; i < M && i < rg.members.size(); ++i)
2839 if (rg.members[i]) {
2840 R.members[i] = true;
2841 if (region_of[i] >= 0)
2842 throw UnsupportedError(
2843 "SolverLDES (native engine): station '" + sn.stations[i].name +
2844 "' belongs to more than one finite capacity region");
2845 region_of[i] = static_cast<int>(regions.size());
2846 }
2847 // `cap(i,r)` is the class-r bound at member i and `cap(i,K)` the global
2848 // one; -1 is the reference's unbounded sentinel and is kept, because it
2849 // is compared with != -1 to test MEMBERSHIP as well as boundedness.
2850 for (std::size_t i = 0; i < rg.cap.size(); ++i) {
2851 if (i >= M || !R.members[i]) continue;
2852 for (std::size_t r = 0; r < K && r < rg.cap[i].size(); ++r)
2853 if (rg.cap[i][r] >= 0.0)
2854 R.class_cap[r] = (R.class_cap[r] < 0.0) ? rg.cap[i][r]
2855 : std::min(R.class_cap[r], rg.cap[i][r]);
2856 if (rg.cap[i].size() > K && rg.cap[i][K] >= 0.0)
2857 R.global_cap = (R.global_cap < 0.0) ? rg.cap[i][K]
2858 : std::min(R.global_cap, rg.cap[i][K]);
2859 }
2860 for (double mm : rg.maxmem)
2861 if (mm >= 0.0) R.max_mem = (R.max_mem < 0.0) ? mm : std::min(R.max_mem, mm);
2862 for (std::size_t r = 0; r < K && r < rg.rule.size(); ++r) R.rule[r] = rg.rule[r];
2863 for (std::size_t r = 0; r < K && r < rg.size.size(); ++r)
2864 R.class_size[r] = num_traits<T>::to_double(rg.size[r]);
2865 for (std::size_t r = 0; r < K && r < rg.weight.size(); ++r)
2866 R.class_weight[r] = num_traits<T>::to_double(rg.weight[r]);
2867 for (std::size_t c = 0; c < rg.lincon_A.rows(); ++c) {
2868 std::vector<double> row;
2869 for (std::size_t r = 0; r < rg.lincon_A.cols(); ++r)
2870 row.push_back(num_traits<T>::to_double(rg.lincon_A(c, r)));
2871 R.lincon_A.push_back(row);
2872 R.lincon_b.push_back(c < rg.lincon_b.size() ? num_traits<T>::to_double(rg.lincon_b[c])
2873 : 0.0);
2874 }
2875 regions.push_back(R);
2876 }
2877
2878 /**
2879 * Jobs parked by a WAITQ region: they have NOT entered the region, so they
2880 * are outside every station's buffer, but they are still in the system and
2881 * are charged to the station they were trying to enter.
2882 */
2883 struct RegionWaiter {
2884 std::size_t station = 0;
2885 Job job;
2886 };
2887 std::vector<std::vector<RegionWaiter>> region_wait(regions.size());
2888
2889 /**
2890 * Whether the service requirement of (station, class) is drawn when the job
2891 * ARRIVES rather than when it reaches a server.
2892 *
2893 * Every time-invariant law is: the two instants give the same distribution,
2894 * and drawing once on arrival is what the reference engine does. A
2895 * TIME-INHOMOGENEOUS law is not, because its rate is a function of absolute
2896 * time and a job that queues is served under a later part of the schedule
2897 * than the one in force when it joined -- so it waits for `start_service`.
2898 * Three station kinds cannot wait: a Delay, where the two instants coincide
2899 * anyway; a processor-sharing station, which never calls `start_service` and
2900 * holds the requirement itself; and one whose waiting room is RANKED by the
2901 * sampled size, which must have a size to rank by. All three draw on arrival.
2902 */
2903 auto draw_at_arrival = [&](std::size_t i, std::size_t r) -> bool {
2904 const StationState& s = S[i];
2905 // A BULK SERVER has no per-job service time at all, only a station-level
2906 // firing clock, so nothing may be drawn from its process on arrival: one
2907 // draw per arrival would advance the batch sampler once per JOB instead of
2908 // once per FIRING and consume the stream the clock reads from.
2909 if (s.bmsp) return false;
2910 if (s.role != Role::Queue || s.ps || s.size_ordered) return true;
2911 return !s.svc[r].time_varying();
2912 };
2913
2914 /**
2915 * Arm a BULK SERVER's firing clock and record how many jobs the firing will
2916 * complete.
2917 *
2918 * The interval comes from the station's own process, drawn at `now` because a
2919 * BMMAPt clock is a wall-clock SCHEDULE and a BMAP one simply ignores the
2920 * instant. The size is whatever block fired, read off the sampler beside the
2921 * interval and at no extra draw; a MARK the walk may also have returned is
2922 * DISCARDED, because a completion drives no class switch.
2923 *
2924 * A non-cyclic schedule past its horizon yields no interval: the clock is left
2925 * UNARMED rather than re-armed at zero delay, which would spin at one instant.
2926 * The jobs present stay present, which is what "the process has stopped" means
2927 * for a bulk server.
2928 */
2929 auto bmsp_arm = [&](std::size_t i) {
2930 StationState& s = S[i];
2931 const std::size_t r = s.bmsp_cls;
2932 const double gap = slot_snap(s.svc[r].next_at(g_svc[i][r], now), "firing interval");
2933 const int b = s.svc[r].last_batch();
2934 s.bmsp_pending = (b > 0) ? static_cast<std::size_t>(b) : 1;
2935 if (!(gap > 0.0) && s.svc[r].time_varying()) return;
2936 Event e;
2937 e.t = now + gap;
2938 e.kind = EV_DEPARTURE;
2939 e.station = i;
2940 e.cls = r;
2941 e.slot = 0;
2942 e.tag = ++ps_tag;
2943 s.bmsp_tag = e.tag;
2944 push(e);
2945 };
2946
2947 auto start_service = [&](std::size_t i, std::size_t slot, const Job& job_in) {
2948 StationState& s = S[i];
2949 Job job = job_in;
2950 // A TIME-INHOMOGENEOUS law is drawn HERE, at the instant service begins,
2951 // because its rate is a function of absolute time: a duration drawn when
2952 // the job ARRIVED would be governed by the schedule in force while the
2953 // job was still waiting, which is not the schedule that serves it. The
2954 // arrival path leaves the draw to this one (see `draw_at_arrival`), so
2955 // there is exactly one draw per service and the modulating phase a MAPt
2956 // carries is never advanced by a discarded sample.
2957 bool drew = false;
2958 if (!draw_at_arrival(i, job.cls)) {
2959 job.service = slot_snap(s.svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
2960 job.remaining = job.service;
2961 job.elapsed = 0.0;
2962 drew = true;
2963 }
2964 // A RESUMED job owes its residual; a RESTARTED one draws afresh and
2965 // discards what it had done, which is the whole of the PR/PI split.
2966 if (!drew && s.preemptive && !s.resume && job.elapsed > 0.0) {
2967 job.service = slot_snap(s.svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
2968 job.remaining = job.service;
2969 job.elapsed = 0.0;
2970 }
2971 // A POOLED STATION DRAWS THE SERVICE FROM THE POOL, and it can only be
2972 // drawn here: the law depends on which server takes the job, and that
2973 // is not known until the slot is assigned. The draw made on arrival is
2974 // the station's class-level default and stands as the NOMINAL size the
2975 // size-based disciplines order the waiting room by; the duration
2976 // actually served is this one. A pool that declares no law of its own
2977 // for the class keeps that nominal draw, so it costs no extra variate.
2978 if (s.has_pools && !(s.preemptive && s.resume && job.elapsed > 0.0)) {
2979 const std::size_t ty = s.server_type[slot];
2980 if (s.type_has_svc[ty][job.cls]) {
2981 job.service = slot_snap(
2982 s.type_svc[ty][job.cls].next_at(g_hsvc[i][ty][job.cls], now),
2983 "service time");
2984 job.remaining = job.service;
2985 job.elapsed = 0.0;
2986 }
2987 }
2988 s.server[slot] = job;
2989 s.server_busy[slot] = true;
2990 s.server_start[slot] = now;
2991 s.server_tag[slot] = ++ps_tag;
2992 // A JOB DECLARING SERVER PARALLELISM SEIZES ITS WHOLE SET HERE, and the
2993 // extras are recorded against the primary so that releasing that one
2994 // releases them together. `free_slot_for` has already refused a station
2995 // with fewer than n idle slots, so coming up short is an inconsistency
2996 // in this engine rather than a busy station, and is reported as one.
2997 double occupied = 1.0;
2998 if (s.has_parallelism && s.parallelism[job.cls] > 1) {
2999 const std::size_t need = s.parallelism[job.cls];
3000 std::vector<std::size_t> extras;
3001 for (std::size_t sl = 0; sl < s.nservers && extras.size() + 1 < need; ++sl) {
3002 if (sl == slot || s.server_busy[sl] || s.server_blocked[sl] || s.server_held[sl])
3003 continue;
3004 s.server_busy[sl] = true;
3005 extras.push_back(sl);
3006 }
3007 if (extras.size() + 1 < need)
3008 throw InputError("SolverLDES (native engine): station '" + sn.stations[i].name +
3009 "' started a job holding " + std::to_string(need) +
3010 " servers with only " + std::to_string(extras.size() + 1) +
3011 " free");
3012 s.held_extra[slot] = extras;
3013 occupied = static_cast<double>(need);
3014 }
3015 acc.update_busy(i, job.cls, now);
3016 // SERVERS OCCUPIED, not jobs in service: a job holding n slots for the
3017 // whole of its service contributes n/c to the utilization, not 1/c.
3018 acc.busy[i][job.cls] += occupied;
3019 // A ZERO RATE SCHEDULES NOTHING, it does not schedule a departure at
3020 // t = infinity. A job that enters service while the server is down
3021 // holds the slot with its residual intact and gets its departure back
3022 // from the repair's reschedule; pushing `now + remaining/0` instead
3023 // puts an infinite timestamp in the event list, which the run then
3024 // pops and processes with now = inf.
3025 const double scale0 = rate_scaling(i, job.cls);
3026 if (!(scale0 > 0.0)) return;
3027 Event e;
3028 e.t = now + job.remaining / scale0;
3029 e.kind = EV_DEPARTURE;
3030 e.station = i;
3031 e.cls = job.cls;
3032 e.slot = slot;
3033 e.tag = s.server_tag[slot];
3034 push(e);
3035 };
3036
3037 /**
3038 * Free the slots a job held BESIDE its primary one, and answer how many
3039 * servers it occupied in all.
3040 *
3041 * One at a station that declares no parallelism, which is every caller's
3042 * previous behaviour; calling it twice on the same slot is harmless, which
3043 * matters because a blocked slot is released once at the completion that
3044 * blocked it and again when the handoff finally goes through.
3045 */
3046 auto release_slots = [&](std::size_t i, std::size_t slot) -> double {
3047 StationState& s = S[i];
3048 if (!s.has_parallelism || slot >= s.held_extra.size()) return 1.0;
3049 std::vector<std::size_t>& extras = s.held_extra[slot];
3050 if (extras.empty()) return 1.0;
3051 for (std::size_t j = 0; j < extras.size(); ++j) s.server_busy[extras[j]] = false;
3052 const double n = static_cast<double>(extras.size() + 1);
3053 extras.clear();
3054 return n;
3055 };
3056
3057 /**
3058 * Start the next waiting job on the slot just freed, or leave the slot idle.
3059 *
3060 * SERVER PARALLELISM IS HEAD-OF-LINE: the job the station's own order takes
3061 * next needs n slots free at once, and when the station cannot offer them it
3062 * WAITS rather than serving whoever behind it would fit. Skipping ahead to a
3063 * job that fits is a different discipline, not the same one under load.
3064 */
3065 auto serve_next_on_slot = [&](std::size_t i, std::size_t sl) {
3066 if (!buffer_has_for_slot(i, sl)) return;
3067 Job nextjob = buffer_pop_for_slot(i, sl);
3068 if (S[i].has_parallelism && free_slot_count(i) < S[i].parallelism[nextjob.cls]) {
3069 buffer_push(i, nextjob);
3070 return;
3071 }
3072 start_service(i, sl, nextjob);
3073 };
3074
3075 /**
3076 * Interrupt the job in `slot` and return it to the waiting room.
3077 *
3078 * Its residual work and attained service are carried on the job, so a
3079 * resume needs no side table; the tag is left stale, which is how the
3080 * departure event already in the list is neutralised.
3081 */
3082 auto preempt = [&](std::size_t i, std::size_t slot) {
3083 StationState& s = S[i];
3084 Job job = s.server[slot];
3085 const double served = now - s.server_start[slot];
3086 job.remaining = std::max(0.0, job.remaining - served);
3087 job.elapsed += served;
3088 s.server_busy[slot] = false;
3089 s.server_tag[slot] = 0;
3090 const double freed = release_slots(i, slot);
3091 acc.update_busy(i, job.cls, now);
3092 acc.busy[i][job.cls] -= freed;
3093 // The job stays in the system: its queue length is unchanged, and it
3094 // re-enters the waiting room under the station's own order.
3095 buffer_push(i, job);
3096 };
3097
3098 /**
3099 * Advance a polling server one leg, or park it.
3100 *
3101 * `poll_serve` starts the next job at the buffer the server stands at;
3102 * `poll_advance` walks it to the next buffer, paying that leg's switchover.
3103 * The walk stops after a full lap of zero-time legs that found no work, or
3104 * the server would spin forever at the same simulated instant.
3105 */
3106 std::function<void(std::size_t)> poll_serve;
3107 std::function<void(std::size_t)> poll_advance;
3108
3109 /**
3110 * Open a visit at the buffer the server has just polled.
3111 *
3112 * The budget is fixed AT THE POLLING INSTANT: EXHAUSTIVE serves until the
3113 * buffer empties, K-LIMITED at most K, GATED exactly the jobs found there
3114 * (later arrivals wait for the next visit), and DECREMENTING until the
3115 * buffer is one shorter than it was found. GATED and DECREMENTING are NOT
3116 * one-job visits; serving one job per visit is 1-limited, a different and
3117 * slower discipline.
3118 */
3119 auto poll_open_visit = [&](StationState& s) {
3120 std::size_t found = 0;
3121 for (const Job& j : s.buffer)
3122 if (j.cls == s.poll_at) ++found;
3123 switch (s.poll_type) {
3124 case lang::PollingType::KLIMITED: s.poll_budget = s.poll_k; break;
3125 case lang::PollingType::GATED: s.poll_budget = found; break;
3127 s.poll_budget = std::numeric_limits<std::size_t>::max();
3128 s.poll_gate = found;
3129 break;
3131 default: s.poll_budget = std::numeric_limits<std::size_t>::max(); break;
3132 }
3133 };
3134
3135 poll_serve = [&](std::size_t i) {
3136 StationState& s = S[i];
3137 if (s.server_busy[0] || s.server_blocked[0] || s.server_held[0]) return;
3138 // Only the class the server currently stands at may be served.
3139 std::size_t at = s.buffer.size();
3140 std::size_t waiting = 0;
3141 for (std::size_t j = 0; j < s.buffer.size(); ++j)
3142 if (s.buffer[j].cls == s.poll_at) {
3143 ++waiting;
3144 if (at == s.buffer.size() || s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
3145 }
3146 // DECREMENTING ends the visit once the buffer has fallen to one below
3147 // the count found at the polling instant.
3148 const bool decremented =
3149 s.poll_type == lang::PollingType::DECREMENTING && waiting < s.poll_gate;
3150 if (at == s.buffer.size() || s.poll_budget == 0 || decremented) {
3151 poll_advance(i);
3152 return;
3153 }
3154 Job job = s.buffer[at];
3155 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
3156 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
3157 if (s.poll_type == lang::PollingType::GATED || s.poll_type == lang::PollingType::KLIMITED)
3158 --s.poll_budget;
3159 start_service(i, 0, job);
3160 };
3161
3162 poll_advance = [&](std::size_t i) {
3163 StationState& s = S[i];
3164 for (std::size_t step = 0; step < K; ++step) {
3165 const std::size_t from = s.poll_at;
3166 const std::size_t next = (from + 1) % K;
3167 // Takagi's r_i: the leg OUT of `from` is charged the switchover
3168 // stored against `from`, not against the buffer being entered.
3169 if (s.has_switchover[from]) {
3170 s.poll_switching = true;
3171 Event e;
3172 e.t = now + s.switchover[from].next(g_aux[i]);
3173 e.kind = EV_SWITCHOVER;
3174 e.station = i;
3175 e.cls = next;
3176 push(e);
3177 return;
3178 }
3179 s.poll_at = next;
3180 poll_open_visit(s);
3181 bool has_work = false;
3182 for (const Job& j : s.buffer)
3183 if (j.cls == next) {
3184 has_work = true;
3185 break;
3186 }
3187 if (has_work) {
3188 poll_serve(i);
3189 return;
3190 }
3191 }
3192 // A whole lap of zero-time legs found nothing. Parking is the only
3193 // alternative to spinning at this instant, and it is unobservable: the
3194 // next arrival restarts the server.
3195 s.poll_parked = true;
3196 };
3197
3198 // ---- admission ---------------------------------------------------------
3199
3200 /**
3201 * Reschedule the aggregate completion of a pass-and-swap station.
3202 *
3203 * ONE CLOCK FOR THE WHOLE STATION, at the total rate mu(c) of its ordered
3204 * list, and the position that completes is drawn from the per-position
3205 * INCREMENTS. Scheduling one clock per job instead would need each job's
3206 * marginal rate to be constant, which is exactly what an order-independent
3207 * station does not have: every arrival and departure changes every other
3208 * job's rate.
3209 */
3210 std::function<void(std::size_t)> pas_reschedule = [&](std::size_t i) {
3211 StationState& s = S[i];
3212 if (s.pas_list.empty()) return;
3213 std::vector<std::size_t> seq;
3214 for (const Job& j : s.pas_list) seq.push_back(j.cls);
3215 const double total = s.pas_rate(seq);
3216 if (!(total > 0.0)) return;
3217 s.pas_tag = ++ps_tag;
3218 Event e;
3219 e.t = now + (-std::log(uniform01(g_aux[i])) / total);
3220 e.kind = EV_DEPARTURE;
3221 e.station = i;
3222 e.cls = 0;
3223 e.tag = s.pas_tag;
3224 e.slot = std::numeric_limits<std::size_t>::max();
3225 push(e);
3226 };
3227
3228 /**
3229 * The position that completes, drawn from the per-position rate increments,
3230 * and the job that actually departs after the swap chain.
3231 */
3232 auto pas_complete = [&](std::size_t i) -> Job {
3233 StationState& s = S[i];
3234 std::vector<std::size_t> seq;
3235 for (const Job& j : s.pas_list) seq.push_back(j.cls);
3236 std::vector<double> inc(seq.size(), 0.0);
3237 double prev = 0.0, total = 0.0;
3238 for (std::size_t p = 0; p < seq.size(); ++p) {
3239 std::vector<std::size_t> pre(seq.begin(),
3240 seq.begin() + static_cast<std::ptrdiff_t>(p + 1));
3241 const double cur = s.pas_rate(pre);
3242 inc[p] = std::max(0.0, cur - prev);
3243 prev = cur;
3244 total += inc[p];
3245 }
3246 std::size_t pos = 0;
3247 if (total > 0.0) {
3248 const double u = uniform01(g_routing) * total;
3249 double acc2 = 0.0;
3250 for (std::size_t p = 0; p < inc.size(); ++p) {
3251 acc2 += inc[p];
3252 if (u < acc2) {
3253 pos = p;
3254 break;
3255 }
3256 }
3257 }
3258 // The swap chain: from `pos`, walk forward to the last position the swap
3259 // graph can reach. That job departs and the chain shifts back into the
3260 // hole at `pos`. An empty graph makes the chain trivial, which is the
3261 // plain order-independent station.
3262 std::vector<std::size_t> chain(1, pos);
3263 std::size_t moving = seq[pos], cur = pos;
3264 while (true) {
3265 std::size_t nxt = seq.size();
3266 for (std::size_t j = cur + 1; j < seq.size(); ++j) {
3267 const bool swappable =
3268 s.pas_swap.empty() ||
3269 (moving < s.pas_swap.size() && seq[j] < s.pas_swap[moving].size() &&
3270 s.pas_swap[moving][seq[j]]);
3271 if (swappable) {
3272 nxt = j;
3273 break;
3274 }
3275 }
3276 if (nxt >= seq.size()) break;
3277 chain.push_back(nxt);
3278 moving = seq[nxt];
3279 cur = nxt;
3280 }
3281 Job departing = s.pas_list[chain.back()];
3282 std::vector<Job> arr = s.pas_list;
3283 for (std::size_t k = 0; k + 1 < chain.size(); ++k)
3284 arr[chain[k + 1]] = s.pas_list[chain[k]];
3285 std::vector<Job> survivors;
3286 for (std::size_t k = 0; k < arr.size(); ++k)
3287 if (k != chain.front()) survivors.push_back(arr[k]);
3288 s.pas_list = survivors;
3289 return departing;
3290 };
3291 /**
3292 * Admit a job at station `i`, or drop it when the station is full.
3293 *
3294 * `from` is the station the job comes from, or M for an arrival that enters
3295 * from outside the network. IT IS NOT BOOKKEEPING: a hop BETWEEN TWO
3296 * MEMBERS of the same finite capacity region does not cross the region
3297 * boundary, so the job must neither be re-tested against the region's caps
3298 * nor counted into it again. Without that test an intra-region hop adds one
3299 * to the region on arrival and removes nothing on departure, so occupancy
3300 * grows by one per hop until every arrival is refused and the run never
3301 * completes another job.
3302 */
3303 // The main departure site leaves the region BEFORE routing and sets `hop_old_cls` to the departing class. A hop
3304 // into the same region is then re-gated like an arrival; one that also SWITCHES class crosses the border, as in
3305 // JMT and the Java engine (the ClassSwitch node sits outside the region), so it first completes and releases the
3306 // waiters, and a freed per-class or linear slot is never left idle.
3307 std::size_t hop_old_cls = K;
3308 std::function<void(std::size_t)> release_region; ///< defined below, called by admit on a class-switching hop
3309 /// Region wait of the waiter release_region is re-admitting; admit takes it onto the job and clears it.
3310 double pending_region_wait = 0.0;
3311 bool hop_internal = false; ///< set by admit when the hop stayed inside a region, read by the departure site
3312 /// Set by the retrial site for the next admit only: a job re-offering itself from the orbit decided to join
3313 /// already, so it is not asked to balk again (Java `arriveAtQueueFromRetrial` skips the test likewise).
3314 bool pending_retry = false;
3315 std::function<bool(std::size_t, Job, std::size_t)> admit =
3316 [&](std::size_t i, Job job, std::size_t from) -> bool {
3317 StationState& s = S[i];
3318 const double rwait = pending_region_wait;
3319 pending_region_wait = 0.0;
3320 const bool is_retry = pending_retry;
3321 pending_retry = false;
3322 if (s.off[job.cls])
3323 throw InputError("SolverLDES (native engine): a job of class '" +
3324 sn.classes[job.cls].name + "' reached station '" +
3325 sn.stations[i].name + "', which does not serve it");
3326 // THE REGION IS CONSULTED FIRST. Its cap spans stations, so a job may
3327 // be refused entry to a station that has room of its own; testing the
3328 // station first and the region second would admit into the buffer and
3329 // then have to unwind it. The test is on MEMBERSHIP ALONE, exactly as
3330 // on the departure side: a Delay is capped by the region like any other
3331 // member, and gating entry on Role::Queue while the exit is ungated
3332 // drains the occupancy below zero, after which nothing is ever refused.
3333 int rgi = region_of[i];
3334 if (rgi >= 0 && from < M && region_of[from] == rgi) {
3335 const std::size_t oc = hop_old_cls;
3336 hop_old_cls = K;
3337 if (oc < K) {
3338 // The main departure site has already left the region: the job is gated like an arrival, and a
3339 // class-switching hop first completes and releases the waiters, the ClassSwitch being outside
3340 hop_internal = true;
3341 if (oc != job.cls) {
3342 const std::size_t rg = static_cast<std::size_t>(rgi);
3343 regions[rg].completed[oc] += 1.0;
3344 release_region(rg);
3345 }
3346 } else {
3347 rgi = -1; // any other departure site keeps the job counted inside: it stays inside
3348 }
3349 }
3350 if (rgi >= 0) {
3351 Region& R = regions[static_cast<std::size_t>(rgi)];
3352 if (R.would_exceed(job.cls)) {
3353 R.update(now);
3354 if (R.drops(job.cls)) {
3355 R.dropped[job.cls] += 1.0;
3356 dropped[i][job.cls] += 1.0;
3357 return false;
3358 }
3359 // WAITQ: parked outside the region, still in the system.
3360 RegionWaiter w;
3361 w.station = i;
3362 w.job = job;
3363 w.job.t_arr = now;
3364 w.job.id = ++job_id;
3365 region_wait[static_cast<std::size_t>(rgi)].push_back(w);
3366 R.blocked[job.cls] += 1.0;
3367 return true;
3368 }
3369 }
3370
3371 // Kendall's K counts the jobs HELD by the station, waiting and in
3372 // service alike; a per-class buffer caps its own class on top of that.
3373 if (s.role == Role::Queue) {
3374 double total = 0.0;
3375 for (std::size_t r = 0; r < K; ++r) total += acc.qlen[i][r] - s.blocked_at[r];
3376 if (total + 1.0 > s.cap ||
3377 acc.qlen[i][job.cls] - s.blocked_at[job.cls] + 1.0 > s.classcap[job.cls]) {
3378 // A RETRIAL class does not drop: it joins the orbit and
3379 // re-offers itself later. The orbit is a separate population --
3380 // not at the station, not in its capacity, not in its queue
3381 // length -- so nothing here is charged to the station.
3382 if (s.has_retrial[job.cls] &&
3383 (s.max_attempts[job.cls] <= 0 ||
3384 job.attempts < s.max_attempts[job.cls])) {
3385 const double dt = now - s.orbit_last;
3386 if (dt > 0.0)
3387 for (std::size_t r = 0; r < K; ++r) s.tot_orbit[r] += s.orbit_size[r] * dt;
3388 s.orbit_last = now;
3389 s.orbit_size[job.cls] += 1.0;
3390 Event e;
3391 e.t = now + s.retrial[job.cls].next(g_svc[i][job.cls]);
3392 e.kind = EV_RETRIAL;
3393 e.station = i;
3394 e.cls = job.cls;
3395 e.job = job;
3396 e.job.attempts = job.attempts + 1;
3397 push(e);
3398 return true;
3399 }
3400 if (s.has_retrial[job.cls]) s.retrial_lost[job.cls] += 1.0;
3401 dropped[i][job.cls] += 1.0;
3402 return false;
3403 }
3404 }
3405 // BALKING is a refusal to JOIN, decided on what the arrival sees.
3406 // It is not a drop for want of room: the station may have plenty,
3407 // and the job declines anyway. Counted separately for that reason.
3408 // The FIRST range holding the pre-arrival population decides, as in
3409 // MATLAB afterEventStation and Java Solver_ssj.shouldBalk, and a match
3410 // costs one routing draw even at probability 0, because Java's
3411 // `routingRng.nextDouble() < p` does. A Delay balks too: both
3412 // references apply the rule at any station that declares it.
3413 if (!is_retry && !s.balk[job.cls].empty()) {
3414 double total = 0.0;
3415 for (std::size_t r = 0; r < K; ++r)
3416 total += acc.qlen[i][r] - (s.blocked_at.empty() ? 0.0 : s.blocked_at[r]);
3417 for (const StationState::BalkRule& br : s.balk[job.cls])
3418 if (total >= br.min_jobs && (br.max_jobs < 0.0 || total <= br.max_jobs)) {
3419 if (uniform01(g_routing) < br.probability) {
3420 balked[i][job.cls] += 1.0;
3421 return false;
3422 }
3423 break;
3424 }
3425 }
3426 job.t_arr = now;
3427 job.region_wait = rwait;
3428 job.priority = classprio[job.cls];
3429 // A TIME-INHOMOGENEOUS service is NOT drawn here: it is drawn when the
3430 // job reaches a server, because its rate depends on absolute time and
3431 // the job may wait first. The exception is a station that ranks its
3432 // waiting room by the sampled size, which has to have one now. Every
3433 // other family is time-invariant and is drawn once, here, as before.
3434 if (draw_at_arrival(i, job.cls)) {
3435 job.service = slot_snap(S[i].svc[job.cls].next_at(g_svc[i][job.cls], now),
3436 "service time");
3437 job.remaining = job.service;
3438 job.elapsed = 0.0;
3439 }
3440 job.rank = g_siro.aux.next_double();
3441 job.deadline = now + classdeadline[job.cls];
3442 job.id = ++job_id;
3443 if (rgi >= 0) {
3444 Region& R = regions[static_cast<std::size_t>(rgi)];
3445 R.update(now);
3446 R.enter(job.cls);
3447 }
3448 sd_advance(i);
3449 acc.update_qlen(i, job.cls, now);
3450 acc.qlen[i][job.cls] += 1.0;
3451 // Counted where the job JOINS the station, so a drop, a balk or a
3452 // reneging leaves AN above TN, which is the difference AN exists to show.
3453 acc.arrived[i][job.cls] += 1.0;
3454 bp.track(i, job.cls, +1, now);
3455
3456 if (s.role == Role::Delay) {
3457 if (track_delay_jobs) {
3458 job.tag = ++ps_tag;
3459 delay_live[i].push_back(job);
3460 }
3461 Event e;
3462 e.t = now + job.service;
3463 e.kind = EV_DEPARTURE;
3464 e.station = i;
3465 e.cls = job.cls;
3466 e.slot = std::numeric_limits<std::size_t>::max();
3467 e.tag = job.tag;
3468 e.job = job;
3469 push(e);
3470 // The scaling this think time actually runs at is installed by the
3471 // flush, which re-times the event just pushed along with every other
3472 // completion in flight. A model with no global dependence never gets
3473 // here, and the departure stands as scheduled.
3474 if (has_gd) sd_reschedule(i);
3475 return true;
3476 }
3477 if (s.ps) {
3478 ps_advance(i);
3479 // LPS admits at most `limit` jobs into the shared server; the rest
3480 // wait in the buffer under the same order a PS station would use.
3481 if (s.lps_limit > 0 && s.ps_jobs.size() >= s.lps_limit) {
3482 buffer_push(i, job);
3483 return true;
3484 }
3485 PsJob pj;
3486 pj.cls = job.cls;
3487 pj.priority = job.priority;
3488 pj.t_arr = job.t_arr;
3489 pj.region_wait = job.region_wait;
3490 pj.t_sys = job.t_sys;
3491 pj.total = job.service;
3492 pj.remaining = job.service;
3493 pj.parent = job.parent;
3494 s.ps_jobs.push_back(pj);
3495 ps_reschedule(i);
3496 return true;
3497 }
3498 // A station that powers down: the first arrival to a cold server pays
3499 // the setup before any service starts, and one arriving inside the
3500 // delay-off window pays nothing because the server never shut down.
3501 if (s.has_setup && !s.setup_on) {
3502 buffer_push(i, job);
3503 if (!s.setup_running) {
3504 s.setup_running = true;
3505 Event e;
3506 e.t = now + s.setup_time.next(g_aux[i]);
3507 e.kind = EV_SETUP;
3508 e.station = i;
3509 e.cls = job.cls;
3510 push(e);
3511 }
3512 return true;
3513 }
3514 if (s.pas) {
3515 s.pas_list.push_back(job);
3516 acc.update_busy(i, job.cls, now);
3517 acc.busy[i][job.cls] += 1.0;
3518 pas_reschedule(i);
3519 return true;
3520 }
3521 if (s.polling) {
3522 buffer_push(i, job);
3523 if (s.poll_parked && !s.poll_switching) {
3524 s.poll_parked = false;
3525 // A parked server stands at a polling instant: the arrival
3526 // opens a fresh visit if it joins the buffer the server is at.
3527 if (job.cls == s.poll_at) {
3528 poll_open_visit(s);
3529 poll_serve(i);
3530 } else {
3531 poll_advance(i);
3532 }
3533 } else if (!s.server_busy[0] && !s.poll_switching) {
3534 poll_serve(i);
3535 }
3536 return true;
3537 }
3538 // A BULK SERVER holds no per-job service time: the arrival joins the queue
3539 // and, if the station was EMPTY, arms the station-level firing clock. It is
3540 // never given a slot, so the ordinary free-slot path below must not see it.
3541 if (s.bmsp) {
3542 buffer_push(i, job);
3543 if (!s.server_busy[0]) {
3544 s.server_busy[0] = true;
3545 s.server_start[0] = now;
3546 acc.update_busy(i, job.cls, now);
3547 acc.busy[i][job.cls] += 1.0;
3548 bmsp_arm(i);
3549 }
3550 return true;
3551 }
3552 {
3553 const std::size_t sl = free_slot_for(i, job.cls);
3554 if (sl < s.nservers) {
3555 start_service(i, sl, job);
3556 sd_reschedule(i);
3557 return true;
3558 }
3559 }
3560 // Every server is busy, or on a pooled station every server THIS CLASS
3561 // MAY USE is. A preemptive station now asks whether this arrival
3562 // displaces one of them; the freed slot is taken by the arrival itself,
3563 // not by the head of the queue, because the victim has just been pushed
3564 // there and could otherwise be picked straight back.
3565 // A job seizing n slots displaces ONE job and still needs n-1 idle, so
3566 // a station that cannot offer them gains nothing by preempting: the
3567 // victim would be sent back to the waiting room for a service that
3568 // still could not start.
3569 if (s.preemptive &&
3570 (!s.has_parallelism || free_slot_count(i) + 1 >= s.parallelism[job.cls])) {
3571 std::vector<Job> held;
3572 std::vector<std::size_t> slot_of;
3573 for (std::size_t sl = 0; sl < s.nservers; ++sl)
3574 if (s.server_busy[sl] && !s.server_blocked[sl]) {
3575 // ONLY A SLOT THE ARRIVAL COULD THEN OCCUPY is a candidate
3576 // victim: preempting a job on a pool that does not accept
3577 // this class frees a server the arrival cannot use, so the
3578 // victim would be displaced for nothing.
3579 if (s.has_pools && !s.type_compat[s.server_type[sl]][job.cls]) continue;
3580 Job h = s.server[sl];
3581 // The residual as of NOW, which is what every size-based
3582 // rule compares against; the stored value is as of the
3583 // instant service started.
3584 h.remaining = std::max(0.0, h.remaining - (now - s.server_start[sl]));
3585 h.elapsed += now - s.server_start[sl];
3586 held.push_back(h);
3587 slot_of.push_back(sl);
3588 }
3589 const std::size_t v = preemption_victim(s.sched, held, job,
3590 static_cast<double>(s.nservers), now);
3591 if (v < held.size()) {
3592 const std::size_t sl = slot_of[v];
3593 preempt(i, sl);
3594 start_service(i, sl, job);
3595 sd_reschedule(i);
3596 return true;
3597 }
3598 }
3599 buffer_push(i, job);
3600 // RENEGING times out a job that is WAITING, not one in service: a job
3601 // that reached a server has been served and no longer abandons. The
3602 // timer is therefore armed here and nowhere else, and it is re-armed
3603 // if a preemption sends the job back to the waiting room.
3604 if (s.has_patience[job.cls]) {
3605 Event e;
3606 e.t = now + s.patience[job.cls].next(g_svc[i][job.cls]);
3607 e.kind = EV_RENEGE;
3608 e.station = i;
3609 e.cls = job.cls;
3610 e.tag = job.id;
3611 push(e);
3612 }
3613 sd_reschedule(i);
3614 return true;
3615 };
3616
3617 const std::map<std::size_t, double> kNoWeights;
3618 /** Every index of `tab`, so JSQ can be told to consider all of them. */
3619 std::vector<std::size_t> tab_all;
3620
3621 /**
3622 * PROB: one draw against the static row.
3623 *
3624 * The mass is NOT renormalized before the draw. The reference scales the
3625 * uniform by the row total instead, so a row summing to one only up to
3626 * rounding still lands, and the two engines consume the routing stream
3627 * identically.
3628 */
3629 auto draw_prob = [&](const std::vector<RouteDest>& tab) -> std::size_t {
3630 double total = 0.0;
3631 for (std::size_t k = 0; k < tab.size(); ++k) total += tab[k].mass;
3632 const double u = uniform01(g_routing) * total;
3633 double cum = 0.0;
3634 std::size_t pick = 0;
3635 for (pick = 0; pick + 1 < tab.size(); ++pick) {
3636 cum += tab[pick].mass;
3637 if (u <= cum) break;
3638 }
3639 return pick;
3640 };
3641
3642 // ---- state-dependent routing (Krzesinski 1987) ----------------------------
3643 // The coefficients are derived ONCE: `pfqn_sdrcoeff` validates the
3644 // declaration and its population bounds, and repeating that at every
3645 // departure would make the validation the cost of the run.
3646 //
3647 // INDEX SPACE. The probabilities are read off the STATION-indexed structure
3648 // against station populations, and the destination split is mapped back
3649 // through the NODE-indexed twin, exactly as `state.h`'s `rt_state` does. The
3650 // two spaces must not be mixed: `pfqn_sdrprob` uses `sdr.branch[b][k]` as
3651 // indices into the population vector, so the structure and the vector have
3652 // to agree, while `entryOf`/`departure` are compared against node indices.
3653 const bool has_sdr = !sn.sdr.empty() && !sn.sdr_nodes.empty();
3654 pfqn::SdrCoeff sdr_coeff;
3655 if (has_sdr) sdr_coeff = pfqn::pfqn_sdrcoeff(sn.sdr);
3656 std::vector<double> sdr_pop, sdr_mass;
3657
3658 /**
3659 * Eq. (10) of Krzesinski (1987) at the CURRENT marking: the split out of the
3660 * entry centre into the branch entries, plus the residual that returns the
3661 * customer to the departure centre.
3662 *
3663 * A Source is reported as zero rather than as the infinite reservoir its
3664 * encoding carries, which is the `station_populations` rule and the one the
3665 * enumerating solvers use, so the sample path here has an exact counterpart
3666 * in SolverNC rather than merely a similar one.
3667 */
3668 auto draw_sdr = [&](const std::vector<RouteDest>& tab) -> std::size_t {
3669 sdr_pop.assign(M, 0.0);
3670 for (std::size_t i = 0; i < M; ++i) {
3671 if (S[i].role == Role::Source) continue;
3672 double held = 0.0;
3673 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[i][q];
3674 sdr_pop[i] = held;
3675 }
3676 const std::vector<double> Pb = pfqn::pfqn_sdrprob(sdr_coeff, sdr_pop);
3677 const double Ped = pfqn::pfqn_sdrped(Pb);
3678
3679 sdr_mass.assign(tab.size(), 0.0);
3680 double total = 0.0;
3681 for (std::size_t i = 0; i < tab.size(); ++i) {
3682 const std::size_t dnode = tab[i].node - 1; // 0-based, as sdr_nodes is
3683 double p = 0.0;
3684 for (std::size_t b = 1; b < sn.sdr_nodes.branch.size(); ++b)
3685 if (sn.sdr_nodes.entryOf[b] == dnode) p += Pb[b];
3686 if (sn.sdr_nodes.departure == dnode) p += Ped;
3687 // Eq. (10) returns zero at a branch closed to new arrivals; the
3688 // clamp only guards the residual against round-off.
3689 if (p < 0.0) p = 0.0;
3690 sdr_mass[i] = p;
3691 total += p;
3692 }
3693 // The branch probabilities and the residual sum to one over the branch
3694 // entries and the departure centre, so this normalization is an identity
3695 // whenever the declaration and the links agree. It is kept so the draw
3696 // stays a distribution if they do not, and the fallback below keeps a
3697 // model whose links reach neither from dividing by zero.
3698 if (!(total > 0.0)) return draw_prob(tab);
3699 const double u = uniform01(g_routing) * total;
3700 double cum = 0.0;
3701 std::size_t pick = 0;
3702 for (pick = 0; pick + 1 < tab.size(); ++pick) {
3703 cum += sdr_mass[pick];
3704 if (u <= cum) break;
3705 }
3706 return pick;
3707 };
3708
3709 /**
3710 * The candidate holding the FEWEST jobs, ties broken uniformly.
3711 *
3712 * A destination that is not a service station holds nothing to compare, so
3713 * it is skipped rather than counted as empty -- a Sink would otherwise win
3714 * every comparison and absorb the whole stream. When no candidate is a
3715 * station the first one is taken, which is the reference's fallback.
3716 */
3717 auto shortest_queue = [&](const std::vector<RouteDest>& tab,
3718 const std::vector<std::size_t>& cand) -> std::size_t {
3719 double best = std::numeric_limits<double>::infinity();
3720 std::vector<std::size_t> tied;
3721 for (std::size_t c = 0; c < cand.size(); ++c) {
3722 const std::size_t k = cand[c];
3723 if (tab[k].station >= M || S[tab[k].station].role == Role::Source) continue;
3724 double held = 0.0;
3725 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[tab[k].station][q];
3726 if (held < best) {
3727 best = held;
3728 tied.clear();
3729 tied.push_back(k);
3730 } else if (held == best) {
3731 tied.push_back(k);
3732 }
3733 }
3734 if (tied.empty()) return cand.empty() ? 0 : cand[0];
3735 if (tied.size() == 1) return tied[0];
3736 std::size_t at = static_cast<std::size_t>(uniform01(g_routing) *
3737 static_cast<double>(tied.size()));
3738 if (at >= tied.size()) at = tied.size() - 1;
3739 return tied[at];
3740 };
3741
3742 /**
3743 * One hop out of node `inode` in class `r`: the DISPATCHER picks the node,
3744 * the class-switch row picks the class.
3745 *
3746 * NEITHER STAGE DRAWS WHEN ITS OUTCOME IS DETERMINED. A single destination
3747 * and a single arriving class each cost nothing, which is the reference's
3748 * rule and not an optimization: a routing stream that advanced on a forced
3749 * hop would put every subsequent draw out of step with the Java engine and
3750 * make a seeded run of a tandem irreproducible across the two.
3751 */
3752 auto draw_node_route = [&](std::size_t inode, std::size_t r) -> RouteEntry {
3753 const std::vector<RouteDest>& tab = nroute[inode][r];
3754 if (tab.empty())
3755 throw InputError("SolverLDES (native engine): node '" + sn.nodes[inode - 1].name +
3756 "' has no routing for class '" + sn.classes[r].name + "'");
3757 std::size_t pick = 0;
3758 if (tab.size() > 1) {
3759 if (tab_all.size() != tab.size()) {
3760 tab_all.resize(tab.size());
3761 for (std::size_t k = 0; k < tab.size(); ++k) tab_all[k] = k;
3762 }
3763 switch (node_routing[inode][r]) {
3765 pick = static_cast<std::size_t>(uniform01(g_routing) *
3766 static_cast<double>(tab.size()));
3767 if (pick >= tab.size()) pick = tab.size() - 1;
3768 break;
3769 }
3771 pick = rr_counter[inode][r] % tab.size();
3772 ++rr_counter[inode][r];
3773 break;
3774 }
3776 // The weights become an INTEGER schedule and the counter
3777 // walks it, so a 3:1 split really is three then one and not
3778 // a 0.75 coin. A fractional weight is scaled by 1000 and a
3779 // positive weight never rounds to zero, or a link declared
3780 // open would never be offered a job.
3781 const std::map<std::size_t, double>& w =
3782 (sn.nodes[inode - 1].routing_weights.size() > r)
3783 ? sn.nodes[inode - 1].routing_weights[r]
3784 : kNoWeights;
3785 std::vector<double> raw(tab.size(), 0.0);
3786 bool integral = true;
3787 for (std::size_t k = 0; k < tab.size(); ++k) {
3788 const std::map<std::size_t, double>::const_iterator it = w.find(tab[k].node);
3789 raw[k] = (it == w.end()) ? 0.0 : it->second;
3790 if (raw[k] < 0.0 || raw[k] != std::floor(raw[k])) integral = false;
3791 }
3792 std::vector<std::size_t> quota(tab.size(), 0);
3793 std::size_t total = 0;
3794 for (std::size_t k = 0; k < tab.size(); ++k) {
3795 double v = integral ? raw[k] : raw[k] * 1000.0;
3796 std::size_t q = (v > 0.0) ? static_cast<std::size_t>(v) : 0;
3797 if (raw[k] > 0.0 && q < 1) q = 1;
3798 quota[k] = q;
3799 total += q;
3800 }
3801 if (total == 0) {
3802 // No usable weights: the dispatcher degenerates to plain
3803 // round robin, which is the reference's fallback.
3804 pick = rr_counter[inode][r] % tab.size();
3805 ++rr_counter[inode][r];
3806 break;
3807 }
3808 std::size_t counter = rr_counter[inode][r] % total;
3809 ++rr_counter[inode][r];
3810 std::size_t cum = 0;
3811 for (pick = 0; pick + 1 < tab.size(); ++pick) {
3812 cum += quota[pick];
3813 if (counter < cum) break;
3814 }
3815 break;
3816 }
3818 // Unlike JSQ and SQ(d) this routing keeps a product form of
3819 // its own, so this sample path has an exact counterpart in
3820 // solver_nc_sdr. The two agree only if both read the same
3821 // populations; see draw_sdr. A routing named SDR with no
3822 // declaration behind it is a malformed model every reader
3823 // refuses, and draw_prob is what it falls back to rather
3824 // than dividing by an absent structure.
3825 pick = has_sdr ? draw_sdr(tab) : draw_prob(tab);
3826 break;
3827 }
3829 pick = shortest_queue(tab, tab_all);
3830 break;
3831 }
3833 // POWER OF d: sample d candidates WITHOUT replacement, then
3834 // take the shortest among them. With d at least the number
3835 // of destinations the sampling is vacuous and it IS JSQ.
3836 std::size_t d = (sn.nodes[inode - 1].routing_param.size() > r &&
3837 sn.nodes[inode - 1].routing_param[r] > 0)
3838 ? static_cast<std::size_t>(
3839 sn.nodes[inode - 1].routing_param[r])
3840 : 2;
3841 if (tab.size() <= d) {
3842 pick = shortest_queue(tab, tab_all);
3843 break;
3844 }
3845 std::vector<std::size_t> avail(tab.size());
3846 for (std::size_t k = 0; k < tab.size(); ++k) avail[k] = k;
3847 std::vector<std::size_t> cand;
3848 for (std::size_t k = 0; k < d && !avail.empty(); ++k) {
3849 std::size_t at = static_cast<std::size_t>(
3850 uniform01(g_routing) * static_cast<double>(avail.size()));
3851 if (at >= avail.size()) at = avail.size() - 1;
3852 cand.push_back(avail[at]);
3853 avail.erase(avail.begin() + static_cast<std::ptrdiff_t>(at));
3854 }
3855 pick = shortest_queue(tab, cand);
3856 break;
3857 }
3858 default: {
3859 pick = draw_prob(tab);
3860 break;
3861 }
3862 }
3863 }
3864 const RouteDest& d = tab[pick];
3865 RouteEntry e;
3866 e.node = d.node;
3867 e.station = d.station;
3868 e.sink = d.sink;
3869 e.cls = d.cls[0].first;
3870 if (d.cls.size() > 1) {
3871 double total = 0.0;
3872 for (std::size_t k = 0; k < d.cls.size(); ++k) total += d.cls[k].second;
3873 const double u = uniform01(g_routing) * total;
3874 double cum = 0.0;
3875 for (std::size_t k = 0; k < d.cls.size(); ++k) {
3876 cum += d.cls[k].second;
3877 if (u <= cum || k + 1 == d.cls.size()) {
3878 e.cls = d.cls[k].first;
3879 break;
3880 }
3881 }
3882 }
3883 return e;
3884 };
3885
3886 /**
3887 * The class a job routed out of `node` under `cls` will ARRIVE the true
3888 * destination station under, resolving through any ClassSwitch/Router/
3889 * Logger chain `draw_node_route` itself stops one hop short of.
3890 *
3891 * `draw_node_route(i, r).cls` names the class on the FIRST edge out of a
3892 * station, and when `link()` synthesized a ClassSwitch node for that edge
3893 * (any route whose class differs from station to station), that first
3894 * edge's class is the PRE-switch one -- the switch happens INSIDE the
3895 * synthesized node, one hop later. The synchronous-call park check below
3896 * needs the class the job actually lands under, or a callee's own reply
3897 * (its edge into the synthesized node still reads as the CALL class) is
3898 * misread as a brand-new call: the reply's `job.call` is then overwritten
3899 * with a fresh id, the caller's real call is never answered, and its
3900 * server stays held while the buffer behind it grows without bound.
3901 *
3902 * Consumes no extra draw when every hop is forced, which a link()-
3903 * synthesized pass-through node always is (`draw_node_route` never spends
3904 * a draw on a single-destination table), so the routing stream stays in
3905 * step with a caller that already resolved through this same node.
3906 */
3907 auto resolve_final_cls = [&](std::size_t node, std::size_t cls) -> std::size_t {
3908 std::size_t at_node = node, at_cls = cls;
3909 for (std::size_t hop = 0; hop <= nnodes && at_node != 0; ++hop) {
3910 const NodeType dt = sn.nodes[at_node - 1].nodetype;
3911 if (dt != NodeType::ClassSwitch && dt != NodeType::Router && dt != NodeType::Logger)
3912 break;
3913 const RouteEntry e = draw_node_route(at_node, at_cls);
3914 at_cls = e.cls;
3915 at_node = e.node;
3916 }
3917 return at_cls;
3918 };
3919
3920 /**
3921 * The station and class a job routed onto `re` ACTUALLY lands at, walking
3922 * the ClassSwitch/Router/Logger chain `draw_node_route` stops one hop short
3923 * of, and only while every hop is FORCED.
3924 *
3925 * WHY IT EXISTS: `link()` synthesizes `CS_<i>_to_<j>` for any route whose
3926 * class changes, so a CLASS-SWITCHING SELF-LOOP leaves the station as an
3927 * edge into that synthesized node. `re.station` is then not the station at
3928 * all and `re.cls` is still the PRE-switch class, so an immediate-feedback
3929 * test written on the raw entry reads the self-loop as a departure to
3930 * elsewhere and the feature goes silently inert on exactly the models that
3931 * need it -- measured against the Java engine and SolverSSA, which both
3932 * move the held job between classes.
3933 *
3934 * WHY FORCED HOPS ONLY: this is a LOOK-AHEAD. A job that turns out not to
3935 * be fed back is handed to `deliver`, which makes the same hops again, so
3936 * the walk must not spend a routing draw or every ordinary departure would
3937 * consume the stream twice and put the run out of step with the Java
3938 * engine. A hop with one destination and one arriving class costs nothing
3939 * (`draw_node_route` draws only where the outcome is undetermined), and a
3940 * link()-synthesized pass-through is always of that shape. Anything else
3941 * stops the walk and leaves the entry as it was, which reads as "not a
3942 * self-loop". A Logger is walked with the other two because this engine
3943 * gives it no side effect of its own -- `deliver` routes straight through
3944 * it -- so skipping the delivery records nothing less.
3945 */
3946 auto resolve_forced_dest = [&](const RouteEntry& re) -> RouteEntry {
3947 RouteEntry at = re;
3948 for (std::size_t hop = 0; hop <= nnodes; ++hop) {
3949 if (at.node == 0 || at.sink) break;
3950 const NodeType dt = sn.nodes[at.node - 1].nodetype;
3951 if (dt != NodeType::ClassSwitch && dt != NodeType::Router && dt != NodeType::Logger)
3952 break;
3953 const std::vector<RouteDest>& tab = nroute[at.node][at.cls];
3954 if (tab.size() != 1 || tab[0].cls.size() != 1) break;
3955 RouteEntry nxt;
3956 nxt.node = tab[0].node;
3957 nxt.station = tab[0].station;
3958 nxt.sink = tab[0].sink;
3959 nxt.cls = tab[0].cls[0].first;
3960 at = nxt;
3961 }
3962 return at;
3963 };
3964
3965 // ---- busy periods -------------------------------------------------------
3966 // Every station is a target set of its own, every declared subnetwork is a
3967 // set, and each is measured aggregated and then per class, which is the
3968 // order `initializeBusyPeriods` builds them in and the order the result
3969 // rows carry.
3970 if (o.busy_period_orders > 0) {
3971 std::vector<std::vector<std::size_t>> sets;
3972 std::vector<std::string> set_names;
3973 for (std::size_t i = 0; i < M; ++i) {
3974 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) continue;
3975 sets.push_back(std::vector<std::size_t>(1, i));
3976 set_names.push_back(sn.stations[i].name);
3977 }
3978 for (const std::vector<std::size_t>& sub : o.busy_period_subnets) {
3979 std::string nm;
3980 for (std::size_t s2 : sub) {
3981 if (s2 >= M || S[s2].role == Role::Source)
3982 throw InputError("SolverLDES (native engine): station " +
3983 std::to_string(s2) +
3984 " of a busy period subnetwork is not a service station");
3985 nm += (nm.empty() ? "" : "+") + sn.stations[s2].name;
3986 }
3987 sets.push_back(sub);
3988 set_names.push_back(nm);
3989 }
3990 std::vector<BpTarget> targets;
3991 for (std::size_t si = 0; si < sets.size(); ++si) {
3992 BpTarget t;
3993 t.stations = sets[si];
3994 t.job_class = -1;
3995 t.name = set_names[si];
3996 targets.push_back(t);
3997 for (std::size_t r = 0; r < K; ++r) {
3998 BpTarget tc;
3999 tc.stations = sets[si];
4000 tc.job_class = static_cast<int>(r);
4001 tc.name = set_names[si] + ":" + sn.classes[r].name;
4002 targets.push_back(tc);
4003 }
4004 }
4005 bp.init(targets, o.busy_period_orders, M);
4006 }
4007
4008
4009 /**
4010 * Hand on the jobs blocked in front of `j`, oldest first, now that it has
4011 * room.
4012 *
4013 * OLDEST FIRST is the reference's rule and it matters: releasing the newest
4014 * would starve a server that has been blocked longest, which no blocking
4015 * discipline does and which shows up as an unbounded blocked time at one
4016 * station while another cycles freely.
4017 */
4018 std::function<void(std::size_t)> release_blocked = [&](std::size_t j) {
4019 bool progress = true;
4020 while (progress) {
4021 progress = false;
4022 std::size_t best_i = M, best_sl = 0;
4023 double oldest = std::numeric_limits<double>::infinity();
4024 for (std::size_t a = 0; a < M; ++a) {
4025 // A SHARING station has no per-server slots at all, so it has
4026 // no slot to block and none of these vectors is allocated for
4027 // it. Indexing them by `nservers` reads off the end.
4028 if (S[a].role != Role::Queue || S[a].ps || S[a].server_blocked.empty()) continue;
4029 for (std::size_t sl = 0; sl < S[a].nservers; ++sl)
4030 if (S[a].server_blocked[sl] && S[a].blocked_dest[sl] == j &&
4031 S[a].blocked_job[sl].t_arr < oldest) {
4032 oldest = S[a].blocked_job[sl].t_arr;
4033 best_i = a;
4034 best_sl = sl;
4035 }
4036 }
4037 if (best_i >= M) break;
4038 const std::size_t dcls = S[best_i].blocked_dest_cls[best_sl];
4039 if (!dest_has_room(j, dcls)) break;
4040
4041 StationState& src = S[best_i];
4042 Job moved = src.blocked_job[best_sl];
4043 src.server_blocked[best_sl] = false;
4044 src.server_busy[best_sl] = false;
4045 src.server_tag[best_sl] = 0;
4046 acc.update_qlen(j, dcls, now);
4047 acc.qlen[j][dcls] -= 1.0;
4048 S[j].blocked_at[dcls] -= 1.0;
4049
4050 // The job now leaves the upstream station for good.
4051 acc.update_busy(best_i, moved.cls, now);
4052 if (acc.busy[best_i][moved.cls] > 0.0) acc.busy[best_i][moved.cls] -= 1.0;
4053
4054 Job fresh;
4055 fresh.cls = dcls;
4056 fresh.t_sys = moved.t_sys;
4057 fresh.parent = moved.parent;
4058 admit(j, fresh, best_i);
4059
4060 // The freed server takes its own next waiting job -- on a pooled
4061 // station, the first one in service order that its pool accepts.
4062 serve_next_on_slot(best_i, best_sl);
4063 progress = true;
4064 }
4065 };
4066
4067
4068 /**
4069 * Let the jobs parked by region `rg` in, oldest first, now that it has
4070 * room.
4071 *
4072 * THE WAIT QUEUE IS STRICT FIFO: a head that still does not fit stops the release, and no younger waiter
4073 * overtakes it, as in JMT and the Java engine (`tryReleaseBlockedCustomers`). Letting a waiter of another class
4074 * jump the head starves the head whenever a per-class or linear cap keeps binding for it.
4075 *
4076 * A waiter re-enters through `admit`, which re-tests every constraint: the
4077 * region may have freed one slot while the waiter's own class cap or the
4078 * linear constraints still bind, and admitting on the global count alone
4079 * would breach them.
4080 */
4081 release_region = [&](std::size_t rg) {
4082 std::vector<RegionWaiter>& q = region_wait[rg];
4083 while (!q.empty()) {
4084 {
4085 const std::size_t w = 0;
4086 Region& R = regions[rg];
4087 if (R.would_exceed(q[w].job.cls)) break;
4088 RegionWaiter taken = q[w];
4089 R.update(now);
4090 R.blocked[taken.job.cls] -= 1.0;
4091 q.erase(q.begin() + static_cast<std::ptrdiff_t>(w));
4092 Job fresh;
4093 fresh.cls = taken.job.cls;
4094 fresh.t_sys = taken.job.t_sys;
4095 // A parked job keeps its fork and call identities: without them a Join discards it as an orphan and
4096 // the reply to a parked call finds no pending call, so the caller's server is held forever
4097 fresh.parent = taken.job.parent;
4098 fresh.call = taken.job.call;
4099 // The wait outside the region is charged to the visit's response time, as JMT and
4100 // Solver_ssj charge it (the released customer keeps its queueArrivalTime); QLen
4101 // still counts the waiter nowhere, which is the JMT convention the reference keeps.
4102 pending_region_wait = now - taken.job.t_arr;
4103 admit(taken.station, fresh, M);
4104 }
4105 }
4106 };
4107
4108 // ---- G-network signals --------------------------------------------------
4109
4110 /** The jobs station `i` actually HOLDS, waiting and in service alike. */
4111 auto signal_held = [&](std::size_t i) -> std::size_t {
4112 const StationState& s = S[i];
4113 if (s.role == Role::Delay) return delay_live[i].size();
4114 if (s.pas) return s.pas_list.size();
4115 std::size_t n = s.buffer.size();
4116 if (s.ps) return n + s.ps_jobs.size();
4117 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4118 if (s.server_busy[sl]) ++n;
4119 return n;
4120 };
4121
4122 /**
4123 * Remove ONE job from station `i` under `policy`, returning its class or
4124 * `K` when the station holds nothing.
4125 *
4126 * THE POLICY IS TWO-TIER, as in the reference: FCFS and LCFS rank the
4127 * WAITING line by age and only reach into the servers once nobody waits,
4128 * while RANDOM draws uniformly over waiting and in-service jobs together.
4129 * A station whose occupants are all in service -- Delay, PS, PAS -- has no
4130 * waiting line for the age tiers to rank, so there the three policies
4131 * differ only in which of the equally-served jobs is taken.
4132 *
4133 * The victim is drawn WITHOUT REGARD TO CLASS. That is Gelenbe's negative
4134 * customer and what the Java engine and SolverMAM both do; `sn.signaltarget`
4135 * narrows the eligible class only in the state-space solvers, whose state
4136 * carries the per-class composition this one draws over directly.
4137 */
4138 auto signal_remove_one = [&](std::size_t i, lang::RemovalPolicy policy) -> std::size_t {
4139 StationState& s = S[i];
4140
4141 // A DELAY holds every occupant in service, in the registry.
4142 if (s.role == Role::Delay) {
4143 std::vector<Job>& live = delay_live[i];
4144 if (live.empty()) return K;
4145 std::size_t at = 0;
4146 if (policy == lang::RemovalPolicy::FCFS) {
4147 for (std::size_t j = 1; j < live.size(); ++j)
4148 if (live[j].t_arr < live[at].t_arr) at = j;
4149 } else if (policy == lang::RemovalPolicy::LCFS) {
4150 for (std::size_t j = 1; j < live.size(); ++j)
4151 if (live[j].t_arr > live[at].t_arr) at = j;
4152 } else {
4153 at = static_cast<std::size_t>(uniform01(g_routing) *
4154 static_cast<double>(live.size()));
4155 if (at >= live.size()) at = live.size() - 1;
4156 }
4157 const std::size_t rc = live[at].cls;
4158 live.erase(live.begin() + static_cast<std::ptrdiff_t>(at));
4159 return rc;
4160 }
4161
4162 // A PASS-AND-SWAP list: one aggregate clock covers the whole station,
4163 // so the survivors must be re-timed rather than left on a rate the
4164 // list no longer has.
4165 if (s.pas) {
4166 if (s.pas_list.empty()) return K;
4167 std::size_t at = 0;
4168 if (policy == lang::RemovalPolicy::FCFS) {
4169 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
4170 if (s.pas_list[j].t_arr < s.pas_list[at].t_arr) at = j;
4171 } else if (policy == lang::RemovalPolicy::LCFS) {
4172 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
4173 if (s.pas_list[j].t_arr > s.pas_list[at].t_arr) at = j;
4174 } else {
4175 at = static_cast<std::size_t>(uniform01(g_routing) *
4176 static_cast<double>(s.pas_list.size()));
4177 if (at >= s.pas_list.size()) at = s.pas_list.size() - 1;
4178 }
4179 const std::size_t rc = s.pas_list[at].cls;
4180 s.pas_list.erase(s.pas_list.begin() + static_cast<std::ptrdiff_t>(at));
4181 acc.update_busy(i, rc, now);
4182 if (acc.busy[i][rc] > 0.0) acc.busy[i][rc] -= 1.0;
4183 pas_reschedule(i);
4184 return rc;
4185 }
4186
4187 const std::size_t waiting = s.buffer.size();
4188
4189 // A SHARING station: `ps_jobs` are in service, `buffer` holds only the
4190 // jobs an LPS admission cap has left outside.
4191 if (s.ps) {
4192 const std::size_t inservice = s.ps_jobs.size();
4193 if (waiting + inservice == 0) return K;
4194 bool from_wait;
4195 if (policy == lang::RemovalPolicy::RANDOM) {
4196 const std::size_t pick = static_cast<std::size_t>(
4197 uniform01(g_routing) * static_cast<double>(waiting + inservice));
4198 from_wait = pick < waiting;
4199 } else {
4200 from_wait = waiting > 0;
4201 }
4202 if (from_wait) {
4203 std::size_t at = 0;
4204 if (policy == lang::RemovalPolicy::FCFS) {
4205 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4206 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
4207 } else if (policy == lang::RemovalPolicy::LCFS) {
4208 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4209 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
4210 } else {
4211 at = std::min(waiting - 1,
4212 static_cast<std::size_t>(uniform01(g_routing) *
4213 static_cast<double>(waiting)));
4214 }
4215 const std::size_t rc = s.buffer[at].cls;
4216 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
4217 if (s.sched != SchedStrategy::FSP)
4218 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
4219 return rc;
4220 }
4221 ps_advance(i);
4222 std::size_t at = 0;
4223 if (policy == lang::RemovalPolicy::FCFS) {
4224 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
4225 if (s.ps_jobs[j].t_arr < s.ps_jobs[at].t_arr) at = j;
4226 } else if (policy == lang::RemovalPolicy::LCFS) {
4227 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
4228 if (s.ps_jobs[j].t_arr > s.ps_jobs[at].t_arr) at = j;
4229 } else {
4230 at = std::min(inservice - 1,
4231 static_cast<std::size_t>(uniform01(g_routing) *
4232 static_cast<double>(inservice)));
4233 }
4234 const std::size_t rc = s.ps_jobs[at].cls;
4235 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(at));
4236 // The freed share is taken by whatever an LPS cap was holding out.
4237 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
4238 Job nextjob = buffer_pop(i);
4239 PsJob pj;
4240 pj.cls = nextjob.cls;
4241 pj.priority = nextjob.priority;
4242 pj.t_arr = nextjob.t_arr;
4243 pj.region_wait = nextjob.region_wait;
4244 pj.t_sys = nextjob.t_sys;
4245 pj.total = nextjob.service;
4246 pj.remaining = nextjob.service;
4247 pj.parent = nextjob.parent;
4248 s.ps_jobs.push_back(pj);
4249 }
4250 ps_reschedule(i);
4251 return rc;
4252 }
4253
4254 // A BUFFERED station. A blocked slot is NOT a candidate: the job it
4255 // holds has already completed here and is charged to the destination
4256 // it is queueing for, so removing it would decrement the wrong station.
4257 std::size_t inservice = 0;
4258 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4259 if (s.server_busy[sl]) ++inservice;
4260 if (waiting + inservice == 0) return K;
4261 bool from_wait;
4262 if (policy == lang::RemovalPolicy::RANDOM) {
4263 const std::size_t pick = static_cast<std::size_t>(
4264 uniform01(g_routing) * static_cast<double>(waiting + inservice));
4265 from_wait = pick < waiting;
4266 } else {
4267 from_wait = waiting > 0;
4268 }
4269
4270 if (from_wait) {
4271 std::size_t at = 0;
4272 if (policy == lang::RemovalPolicy::FCFS) {
4273 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4274 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
4275 } else if (policy == lang::RemovalPolicy::LCFS) {
4276 for (std::size_t j = 1; j < s.buffer.size(); ++j)
4277 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
4278 } else {
4279 at = std::min(waiting - 1,
4280 static_cast<std::size_t>(uniform01(g_routing) *
4281 static_cast<double>(waiting)));
4282 }
4283 const std::size_t rc = s.buffer[at].cls;
4284 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
4285 if (s.sched != SchedStrategy::FSP)
4286 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
4287 return rc;
4288 }
4289
4290 std::size_t slot = s.nservers;
4291 for (std::size_t sl = 0; sl < s.nservers; ++sl) {
4292 if (!s.server_busy[sl]) continue;
4293 if (slot == s.nservers) {
4294 slot = sl;
4295 continue;
4296 }
4297 if (policy == lang::RemovalPolicy::FCFS) {
4298 if (s.server[sl].t_arr < s.server[slot].t_arr) slot = sl;
4299 } else if (policy == lang::RemovalPolicy::LCFS) {
4300 if (s.server[sl].t_arr > s.server[slot].t_arr) slot = sl;
4301 }
4302 }
4303 if (policy == lang::RemovalPolicy::RANDOM) {
4304 std::size_t pick = std::min(inservice - 1,
4305 static_cast<std::size_t>(uniform01(g_routing) *
4306 static_cast<double>(inservice)));
4307 for (std::size_t sl = 0; sl < s.nservers; ++sl)
4308 if (s.server_busy[sl]) {
4309 if (pick == 0) {
4310 slot = sl;
4311 break;
4312 }
4313 --pick;
4314 }
4315 }
4316 if (slot == s.nservers) return K;
4317 sd_advance(i);
4318 const std::size_t rc = s.server[slot].cls;
4319 // Freeing the slot and clearing its tag is what neutralises the
4320 // departure already in the list; there is no handle to cancel it with.
4321 s.server_busy[slot] = false;
4322 s.server_tag[slot] = 0;
4323 const double freed = release_slots(i, slot);
4324 acc.update_busy(i, rc, now);
4325 acc.busy[i][rc] -= freed;
4326 // The vacated server takes the next job, under the station's own order:
4327 // the polling controller decides for itself, everyone else pops.
4328 if (s.polling) {
4329 poll_serve(i);
4330 } else if (!s.server_blocked[slot] && !s.server_held[slot]) {
4331 serve_next_on_slot(i, slot);
4332 }
4333 sd_reschedule(i);
4334 return rc;
4335 };
4336
4337 /**
4338 * A G-NETWORK REMOVAL SIGNAL arriving at station `i`, and annihilated there.
4339 *
4340 * The signal NEVER JOINS the station and never continues along its routing
4341 * chain: it removes a batch of the jobs it finds, and one that finds the
4342 * station empty is simply lost. That is Gelenbe's semantics and the one
4343 * `State.afterEventStationSignal` and the Java engine both implement;
4344 * routing the signal onward instead would make a single signal fire once
4345 * per downstream station, which is what the tandem regression measures.
4346 *
4347 * A CATASTROPHE removes EVERY job held, in-service ones included, and
4348 * ignores the batch-size law entirely -- removing all of them is what the
4349 * word means. A NEGATIVE signal draws its batch size from `signalremdist`
4350 * (absent: exactly one) and CLIPS it at what the station holds, so an
4351 * oversized batch drains the station instead of driving it negative.
4352 */
4353 auto apply_signal = [&](std::size_t i, const Job& sig) {
4354 StationState& s = S[i];
4355 const std::size_t held = signal_held(i);
4356 if (held > 0) {
4357 std::size_t to_remove = 1;
4358 if (sig.cls < sn.signaltype.size() &&
4359 sn.signaltype[sig.cls] == lang::SignalType::CATASTROPHE) {
4360 to_remove = held;
4361 } else if (sig.cls < sn.signalremdist.size() && !sn.signalremdist[sig.cls].empty()) {
4362 const std::vector<T>& pmf = sn.signalremdist[sig.cls];
4363 const double u = uniform01(g_routing);
4364 double cum = 0.0;
4365 std::size_t b = 0;
4366 for (; b + 1 < pmf.size(); ++b) {
4367 cum += static_cast<double>(pmf[b]);
4368 if (u < cum) break;
4369 }
4370 to_remove = std::min(b, held);
4371 } else {
4372 to_remove = std::min<std::size_t>(1, held);
4373 }
4375 if (sig.cls < sn.signalrempolicy.size()) policy = sn.signalrempolicy[sig.cls];
4376 for (std::size_t rep = 0; rep < to_remove; ++rep) {
4377 if (signal_held(i) == 0) break;
4378 const std::size_t rc = signal_remove_one(i, policy);
4379 if (rc >= K) break;
4380 sd_advance(i);
4381 acc.update_qlen(i, rc, now);
4382 acc.qlen[i][rc] -= 1.0;
4383 bp.track(i, rc, -1, now);
4384 // A job destroyed inside a finite capacity region frees the
4385 // slot it occupied; leaving it charged would shrink the region
4386 // by one for the rest of the run.
4387 if (region_of[i] >= 0) {
4388 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
4389 regions[rg].update(now);
4390 regions[rg].leave(rc);
4391 release_region(rg);
4392 }
4393 }
4394 release_blocked(i);
4395 }
4396 // The signal is annihilated here: it leaves the system without ever
4397 // having been at a station, so it is a system completion and nothing
4398 // else -- no arrival, no queue length and no station throughput.
4399 sys_resp_sum[sig.cls] += now - sig.t_sys;
4400 sys_resp_cnt[sig.cls] += 1.0;
4401 sys_completed[sig.cls] += 1.0;
4402 };
4403
4404
4405 /**
4406 * The completion budget, spent by anything the model actually DOES.
4407 *
4408 * Declared ahead of `deliver` because a service completion is not the only
4409 * thing that spends it: a Petri net has no services of its own and counts a
4410 * FIRING, and a cache-only model (Source -> Cache -> Sink) has none either
4411 * and counts the cache's delivery to the Sink. Without the latter the loop
4412 * at the bottom of this function never terminates on such a model, since
4413 * the Source keeps the event queue non-empty forever. `Solver_ssj` spends
4414 * the budget at the same point (`deliverCacheCompletion` calls
4415 * `checkEventCountStop` on the sink branch), so this is the reference's
4416 * rule and not a local convention.
4417 */
4418 std::uint64_t total_completions = 0;
4419
4420 /**
4421 * Free every request parked while `item` was being fetched.
4422 *
4423 * Declared ahead of `deliver` and assigned after it because the two are
4424 * mutually recursive: a release routes each freed request onward, and a
4425 * delivery to a cache is what triggers a release. Each freed request reads
4426 * the now-cached item and completes at once, counted as a DELAYED HIT and
4427 * leaving under its own hit class; its original `t_sys` is preserved so the
4428 * response time still spans the wait it actually served.
4429 */
4430 std::function<void(CacheState&, std::size_t, std::size_t)> release_delayed_hits;
4431
4432 /**
4433 * Hand a job to a NODE, replicating at a Fork and synchronizing at a Join.
4434 *
4435 * A FORK sends a sibling down EVERY outgoing edge, not one drawn from the
4436 * routing probabilities: the edges of a fork are branches taken together,
4437 * and drawing among them would turn a fork into a probabilistic split with
4438 * the same picture and a completely different response time. `tasksPerLink`
4439 * multiplies each branch.
4440 *
4441 * A JOIN releases ONE job once its strategy is satisfied and DISCARDS the
4442 * siblings that arrive afterwards -- under a quorum they are the losers of
4443 * the race, and counting them as completions would inflate the throughput
4444 * of everything downstream.
4445 */
4446 std::function<void(std::size_t, Job, std::size_t)> deliver =
4447 [&](std::size_t node, Job job, std::size_t from) {
4448 const NodeType nt = sn.nodes[node - 1].nodetype;
4449
4450 if (nt == NodeType::Sink) {
4451 sys_resp_sum[job.cls] += now - job.t_sys;
4452 sys_resp_cnt[job.cls] += 1.0;
4453 sys_completed[job.cls] += 1.0;
4454 return;
4455 }
4456
4457 if (nt == NodeType::Cache) {
4458 auto ci = caches.find(node);
4459 if (ci == caches.end())
4460 throw InputError("SolverLDES (native engine): Cache node '" +
4461 sn.nodes[node - 1].name + "' carries no parameters");
4462 CacheState& cs = ci->second;
4463 Job out = job;
4464
4465 // A RETURNING FETCH, not a read. The job wearing a retrieval class
4466 // is the fetch this cache sent out on an earlier miss: it inserts
4467 // the item, clears the in-flight flag and frees everyone parked
4468 // behind it. The miss was counted when the fetch was TRIGGERED, so
4469 // counting it again here would double every miss.
4470 if (cs.has_retrieval) {
4471 const int fetched = cs.fetches_item(job.cls);
4472 if (fetched >= 0) {
4473 const std::size_t fi = static_cast<std::size_t>(fetched);
4474 cache_miss(cs, fi, uniform01(g_routing), uniform01(g_routing));
4475 cs.in_flight[fi] = 0;
4476 cs.total_fetch_time += now - cs.fetch_start[fi];
4477 cs.completed_fetches += 1.0;
4478 release_delayed_hits(cs, fi, node);
4479 if (cs.miss_class[job.cls] >= 0)
4480 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
4481 const RouteEntry re = draw_node_route(node, out.cls);
4482 out.cls = re.cls;
4483 if (re.node != 0 && sn.nodes[re.node - 1].nodetype == NodeType::Sink)
4484 ++total_completions;
4485 deliver(re.node, out, M);
4486 return;
4487 }
4488 }
4489
4490 const std::vector<double>& pop = cs.popularity[job.cls];
4491 if (pop.empty())
4492 throw InputError("SolverLDES (native engine): class '" +
4493 sn.classes[job.cls].name + "' reads cache '" +
4494 sn.nodes[node - 1].name + "' with no item popularity");
4495 const double u = uniform01(g_routing);
4496 std::size_t item = 0;
4497 while (item + 1 < pop.size() && u >= pop[item]) ++item;
4498
4499 const int at = cs.find(item);
4500 if (at >= 0) {
4501 cs.hits[job.cls] += 1.0;
4502 cache_hit(cs, item, static_cast<std::size_t>(at), uniform01(g_routing));
4503 if (cs.hit_class[job.cls] >= 0)
4504 out.cls = static_cast<std::size_t>(cs.hit_class[job.cls]);
4505 } else if (cs.has_retrieval && cs.retrieval_of(item, job.cls) > 0) {
4506 if (cs.in_flight[item]) {
4507 // A fetch for this item is already out: PARK the request. It
4508 // leaves the event stream entirely and is released when the
4509 // fetch returns, so it is neither a hit nor a miss yet.
4510 CacheState::HeldRequest hr;
4511 hr.cls = job.cls;
4512 hr.hold_time = now;
4513 hr.t_sys = job.t_sys;
4514 cs.held[item].push_back(hr);
4515 return;
4516 }
4517 // Trigger the fetch: count the miss now and leave wearing the
4518 // per-item retrieval class, which the routing carries through
4519 // the retrieval stations and back to this cache.
4520 cs.misses[job.cls] += 1.0;
4521 cs.in_flight[item] = 1;
4522 cs.fetch_start[item] = now;
4523 out.cls = cs.retrieval_of(item, job.cls) - 1;
4524 } else {
4525 cs.misses[job.cls] += 1.0;
4526 cache_miss(cs, item, uniform01(g_routing), uniform01(g_routing));
4527 if (cs.miss_class[job.cls] >= 0)
4528 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
4529 }
4530 // The hit and miss classes are what the POST_CACHE branch routes on,
4531 // so the job leaves the cache under its new class.
4532 const RouteEntry e = draw_node_route(node, out.cls);
4533 out.cls = e.cls;
4534 // A cache delivering to a Sink is this model's only completion when
4535 // it has no service station, so it spends the budget (see above).
4536 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
4537 ++total_completions;
4538 deliver(e.node, out, M);
4539 return;
4540 }
4541
4542 if (nt == NodeType::ClassSwitch || nt == NodeType::Router || nt == NodeType::Logger) {
4543 // A PASS-THROUGH NODE HOLDS NOTHING. It changes the class (a
4544 // ClassSwitch), picks a destination (a Router) or records the
4545 // crossing (a Logger) and the job continues in the SAME instant, so
4546 // there is no queue, no service and no row to fill.
4547 //
4548 // `link()` synthesizes `CS_<i>_to_<j>` for every link that switches
4549 // class, so this branch carries every class switch the routing
4550 // matrix used to hold as an edge attribute. Such a node has ONE
4551 // outgoing destination, so `draw_node_route` spends no draw on it
4552 // and the sample path stays in step with the Java engine.
4553 //
4554 // The walk is iterative, not recursive, and bounded: a routing that
4555 // sends a job round a cycle of pass-through nodes never reaches a
4556 // station and would otherwise hang the run.
4557 Job out = job;
4558 std::size_t at = node;
4559 for (std::size_t hop = 0; hop <= nnodes; ++hop) {
4560 const RouteEntry e = draw_node_route(at, out.cls);
4561 out.cls = e.cls;
4562 if (e.node == 0) return;
4563 const NodeType dt = sn.nodes[e.node - 1].nodetype;
4564 if (dt == NodeType::ClassSwitch || dt == NodeType::Router ||
4565 dt == NodeType::Logger) {
4566 at = e.node;
4567 continue;
4568 }
4569 deliver(e.node, out, from);
4570 return;
4571 }
4572 throw InputError("SolverLDES (native engine): the routing out of node '" +
4573 sn.nodes[node - 1].name +
4574 "' cycles through pass-through nodes without reaching a station");
4575 }
4576
4577 if (nt == NodeType::Fork) {
4578 // A FORK TAKES EVERY EDGE, so it flattens the two-stage table back
4579 // into one sibling per (destination, arriving class): there is no
4580 // dispatcher decision to make and no draw to spend.
4581 const std::vector<RouteDest>& tab = nroute[node][job.cls];
4582 if (tab.empty()) return;
4583 std::vector<RouteEntry> links;
4584 for (std::size_t di = 0; di < tab.size(); ++di)
4585 for (std::size_t ci = 0; ci < tab[di].cls.size(); ++ci) {
4586 RouteEntry e;
4587 e.node = tab[di].node;
4588 e.station = tab[di].station;
4589 e.sink = tab[di].sink;
4590 e.cls = tab[di].cls[ci].first;
4591 links.push_back(e);
4592 }
4593 const qn::NodeDef& fk = sn.nodes[node - 1];
4594 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
4595 const int per_link = std::max(1, static_cast<int>(fk.tasks_per_link + 0.5));
4596
4597 // How many tasks each branch emits on THIS firing. A plain fork
4598 // takes `per_link` on every link and draws NOTHING -- which is what
4599 // keeps every seeded golden and the bit-exact agreement with the
4600 // Java engine intact. A variable one draws the branch activation
4601 // and the degree per link, so the count the Join waits for is known
4602 // only after the draws.
4603 std::vector<int> per_branch(links.size(), per_link);
4604 int total = 0;
4605 const bool variable = fork_is_variable(node);
4606 for (std::size_t li = 0; li < links.size(); ++li) {
4607 if (variable) {
4608 const std::size_t k0 = links[li].node - 1, r0 = job.cls;
4609 const double p = fp->fan_out_prob(k0, r0);
4610 if (p < 1.0 && g_fork.aux.next_double() >= p) {
4611 per_branch[li] = 0;
4612 } else if (!fp->fan_out_dist[k0][r0].disabled) {
4613 per_branch[li] = sample_fork_degree(fp->fan_out_dist[k0][r0]);
4614 } else {
4615 per_branch[li] = static_cast<int>(fp->fan_out_link(k0, r0) + 0.5);
4616 }
4617 }
4618 total += per_branch[li];
4619 }
4620 if (total <= 0)
4621 throw InputError("SolverLDES (native engine): Fork '" + fk.name +
4622 "' emitted no sibling; at least one branch must be certain "
4623 "to emit at least one task");
4624
4625 ForkSync fs;
4626 fs.cls = job.cls;
4627 fs.t_sys = job.t_sys;
4628 fs.total = total;
4629 fs.required = fs.total;
4630 const int jn = join_of_fork[node];
4631 if (jn > 0) {
4632 auto jd = sn.joindecl.find(static_cast<std::size_t>(jn));
4633 if (jd != sn.joindecl.end() &&
4634 jd->second.strategy != lang::JoinStrategy::STD && jd->second.quorum > 0.0) {
4635 // The quorum is a count of THIS fork's siblings, fixed here
4636 // rather than at the Join: a Join shared by two forks of
4637 // different fan-out would otherwise apply one fork's count
4638 // to the other's siblings.
4639 fs.required = std::min(fs.total,
4640 std::max(1, static_cast<int>(jd->second.quorum + 0.5)));
4641 }
4642 }
4643 const std::uint64_t pid = ++next_parent;
4644 fork_sync[pid] = fs;
4645 for (std::size_t li = 0; li < links.size(); ++li)
4646 for (int t = 0; t < per_branch[li]; ++t) {
4647 Job sib;
4648 sib.cls = links[li].cls;
4649 sib.t_sys = job.t_sys;
4650 sib.parent = pid;
4651 deliver(links[li].node, sib, M);
4652 }
4653 return;
4654 }
4655
4656 if (nt == NodeType::Join) {
4657 auto it = fork_sync.find(job.parent);
4658 if (it == fork_sync.end()) {
4659 // Its parent already synchronized: this sibling lost the quorum
4660 // race and is discarded rather than released a second time. It
4661 // goes into the SAME accumulators every other station uses, so
4662 // it is warmup-truncated with them; see Accum::join_dropped.
4663 //
4664 // IT STILL ARRIVED. `arrived` is the OFFERED rate, and the whole
4665 // point of a quorum is that N siblings are offered and only K
4666 // consumed, so leaving the straggler out reports AN = K*TN and
4667 // hides the very loss the measurement exists for. The reference
4668 // counts it the same way: its AN at the join is exactly N times
4669 // the fork rate under a quorum, not K times.
4670 const std::size_t jd = node_to_station[node];
4671 if (jd < M) {
4672 acc.arrived[jd][job.cls] += 1.0;
4673 acc.join_dropped[jd][job.cls] += 1.0;
4674 }
4675 return;
4676 }
4677 ForkSync& fs = it->second;
4678 // A JOIN IS A STATION LIKE ANY OTHER. Its siblings are its queue
4679 // length and its synchronizations are its completions, so they go
4680 // into the same accumulators every other station uses and are
4681 // warmup-truncated with them rather than measured on a private
4682 // tally the result assembly never reads.
4683 // `js == M` is a Join the refresh registered as a node and not as a
4684 // station, which has no row to fill; `handleJoinArrival` gates on
4685 // the same bound. The synchronization itself runs either way.
4686 const std::size_t js = node_to_station[node];
4687 if (js < M) {
4688 acc.update_qlen(js, job.cls, now);
4689 acc.qlen[js][job.cls] += 1.0;
4690 acc.arrived[js][job.cls] += 1.0;
4691 }
4692 fs.siblings.push_back(std::make_pair(job.cls, now));
4693 if (static_cast<int>(fs.siblings.size()) < fs.required) return;
4694
4695 // Satisfied: the siblings leave the Join together and one job goes on.
4696 if (js < M) {
4697 for (std::size_t si = 0; si < fs.siblings.size(); ++si) {
4698 const std::size_t sc = fs.siblings[si].first;
4699 acc.update_qlen(js, sc, now);
4700 acc.qlen[js][sc] -= 1.0;
4701 // Each sibling waited from ITS OWN arrival, not the first:
4702 // the Join charges the delay it imposed on each.
4703 acc.resp_sum[js][sc] += now - fs.siblings[si].second;
4704 acc.resp_cnt[js][sc] += 1.0;
4705 }
4706 acc.completed[js][fs.cls] += 1.0;
4707 }
4708 Job merged;
4709 merged.cls = fs.cls;
4710 merged.t_sys = fs.t_sys;
4711 fork_sync.erase(it);
4712 const RouteEntry e = draw_node_route(node, merged.cls);
4713 merged.cls = e.cls;
4714 deliver(e.node, merged, M);
4715 return;
4716 }
4717
4718 // An ordinary station.
4719 const std::size_t j = node_to_station[node];
4720 // A REMOVAL SIGNAL acts on the station and is annihilated: it is
4721 // intercepted here, before any of the accounting an arrival gets,
4722 // because it never becomes one.
4723 if (has_removal_signal && j < M && is_removal_signal[job.cls]) {
4724 apply_signal(j, job);
4725 return;
4726 }
4727 /**
4728 * A REPLY ANSWERS ITS CALL AND TRAVELS ON. It releases the server the
4729 * caller has been holding, lets that station take its next waiting job,
4730 * and then CONTINUES along its own routing -- which is what separates it
4731 * from a removal signal, annihilated where it lands.
4732 *
4733 * A reply with no matching call is not an error: the call may have been
4734 * answered already, or the class may be routed as a reply without any
4735 * caller having parked. It routes on, having released nothing.
4736 */
4737 if (has_sync_call && j < M && is_reply_signal[job.cls]) {
4738 acc.completed[j][job.cls] += 1.0;
4739 typename std::map<std::uint64_t, PendingCall>::iterator pc =
4740 pending_reply.find(job.call);
4741 if (pc != pending_reply.end()) {
4742 const std::size_t bi = pc->second.station, bs = pc->second.slot;
4743 const std::size_t bc = pc->second.cls;
4744 pending_reply.erase(pc);
4745 StationState& bst = S[bi];
4746 bst.server_held[bs] = false;
4747 acc.update_busy(bi, bc, now);
4748 if (acc.busy[bi][bc] > 0.0) acc.busy[bi][bc] -= 1.0;
4749 acc.update_qlen(bi, bc, now);
4750 acc.held[bi][bc] -= 1.0;
4751 if (!bst.server_blocked[bs]) {
4752 serve_next_on_slot(bi, bs);
4753 sd_reschedule(bi);
4754 }
4755 release_blocked(bi);
4756 }
4757 const RouteEntry re2 = draw_node_route(node, job.cls);
4758 Job onward;
4759 onward.cls = re2.cls;
4760 onward.t_sys = job.t_sys;
4761 onward.parent = job.parent;
4762 deliver(re2.node, onward, M);
4763 return;
4764 }
4765 Job moved = job;
4766 // A closed class completes its passage on reaching its reference
4767 // station: that crossing IS the system completion, and the elapsed time
4768 // is the cycle time.
4769 if (j < M && sn.classes[job.cls].type != lang::JobClassType::OPEN &&
4770 j + 1 == sn.classes[job.cls].refstat) {
4771 sys_resp_sum[job.cls] += now - job.t_sys;
4772 sys_resp_cnt[job.cls] += 1.0;
4773 sys_completed[job.cls] += 1.0;
4774 moved.t_sys = now;
4775 }
4776 if (!admit(j, moved, from) && sn.classes[job.cls].type != lang::JobClassType::OPEN)
4777 throw InputError("SolverLDES (native engine): a closed job was dropped at "
4778 "station '" + sn.stations[j].name +
4779 "'; a closed class cannot lose population");
4780 };
4781
4782 release_delayed_hits = [&](CacheState& cs, std::size_t item, std::size_t node) {
4783 if (item >= cs.held.size() || cs.held[item].empty()) return;
4784 std::vector<CacheState::HeldRequest> freed;
4785 freed.swap(cs.held[item]);
4786 for (std::size_t i = 0; i < freed.size(); ++i) {
4787 const CacheState::HeldRequest& hr = freed[i];
4788 cs.delayed[hr.cls] += 1.0;
4789 cs.delayed_wait += now - hr.hold_time;
4790 Job out;
4791 out.cls = (cs.hit_class[hr.cls] >= 0) ? static_cast<std::size_t>(cs.hit_class[hr.cls])
4792 : hr.cls;
4793 out.t_sys = hr.t_sys;
4794 const RouteEntry e = draw_node_route(node, out.cls);
4795 out.cls = e.cls;
4796 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
4797 ++total_completions;
4798 deliver(e.node, out, M);
4799 }
4800 };
4801
4802
4803 /**
4804 * The marking as one flat vector over (place slot, class), `p * K + r`,
4805 * which is what a mode's arcs are indexed by.
4806 *
4807 * SUMMED OVER CLASSES UNTIL 2026-08-12, which made every arc colourless and
4808 * let a token of one class satisfy another's pre-arc.
4809 */
4810 auto flat_marking = [&]() {
4811 std::vector<double> tok(place_nodes.size() * K, 0.0);
4812 for (std::size_t p = 0; p < place_nodes.size(); ++p)
4813 for (std::size_t r = 0; r < K; ++r) tok[p * K + r] = marking[p][r];
4814 return tok;
4815 };
4816
4817 /**
4818 * The tokens the ARCS can see, in the same layout.
4819 *
4820 * Identical to the marking at an ordinary place and the DEPOSITORY at a
4821 * queueing one, which is the whole difference between the two: enabling,
4822 * inhibiting and the immediate pick are decided on this vector, while a
4823 * MARKING-DEPENDENT FIRING RATE is still a function of the marking, because
4824 * `Transition.setFiringRateDependence` is declared over token counts and a
4825 * place's token count includes the ones it is still serving.
4826 */
4827 auto flat_avail = [&]() {
4828 std::vector<double> tok(place_nodes.size() * K, 0.0);
4829 for (std::size_t p = 0; p < place_nodes.size(); ++p)
4830 for (std::size_t r = 0; r < K; ++r) tok[p * K + r] = avail[p][r];
4831 return tok;
4832 };
4833
4834 /**
4835 * Move the tokens of one firing of `m`.
4836 *
4837 * CONSUME FIRST AND DEPOSIT SECOND, which is what a self-loop arc needs: a
4838 * mode that takes a token from a queueing place and puts one back takes the
4839 * SERVED one out of the depository and queues the new one for service again.
4840 * Netting the two against each other would leave the token where it was and
4841 * the place would never serve anything.
4842 */
4843 auto spn_apply = [&](const SpnMode& m) {
4844 for (std::size_t p = 0; p < place_nodes.size(); ++p)
4845 for (std::size_t r = 0; r < K; ++r) {
4846 const double e = m.enabling[p * K + r];
4847 if (e > 0.0) place_take(p, r, e);
4848 }
4849 for (std::size_t p = 0; p < place_nodes.size(); ++p)
4850 for (std::size_t r = 0; r < K; ++r) {
4851 const double f = m.firing[p * K + r];
4852 if (f > 0.0) place_deposit(p, r, f);
4853 }
4854 };
4855
4856 /**
4857 * Fire every enabled IMMEDIATE mode, then arm the timed ones.
4858 *
4859 * The immediate modes are exhausted FIRST and to a fixed point: a marking
4860 * that enables one is not a marking the net rests in, so letting a timed
4861 * mode fire from it would visit a state the model does not have. The guard
4862 * bounds a net whose immediate modes form a cycle, which is a modelling
4863 * error rather than something to simulate forever.
4864 */
4865 std::uint64_t fire_tag = 0;
4866 std::vector<std::uint64_t> live_fire(transitions.size(), 0);
4867 std::function<void()> spn_settle = [&]() {
4868 if (transitions.empty()) return;
4869 for (int guard = 0; guard < 1000000; ++guard) {
4870 bool fired_any = false;
4871 std::vector<double> tok = flat_avail();
4872 for (std::size_t t = 0; t < transitions.size(); ++t) {
4873 const int k = spn_pick_immediate(transitions[t].modes, tok, uniform01(g_routing));
4874 if (k < 0) continue;
4875 const SpnMode& m = transitions[t].modes[static_cast<std::size_t>(k)];
4876 // The tokens move in THEIR OWN CLASS: an arc names a (place,
4877 // class) pair, so a Class2 pre-arc consumes Class2 tokens.
4878 spn_apply(m);
4879 transitions[t].fired[static_cast<std::size_t>(k)] += 1.0;
4880 ++total_completions;
4881 fired_any = true;
4882 break;
4883 }
4884 if (!fired_any) break;
4885 }
4886 // Arm one clock per transition on its fastest enabled timed mode. The
4887 // tag invalidates it as soon as the marking moves, because a
4888 // marking-dependent rate must be RESAMPLED and not merely rescaled.
4889 const std::vector<double> tok = flat_avail();
4890 const std::vector<double> mk = flat_marking();
4891 for (std::size_t t = 0; t < transitions.size(); ++t) {
4892 double best = std::numeric_limits<double>::infinity();
4893 int best_mode = -1;
4894 for (std::size_t k = 0; k < transitions[t].modes.size(); ++k) {
4895 const SpnMode& m = transitions[t].modes[k];
4896 if (m.immediate || !spn_enabled(m, tok)) continue;
4897 const double rate = spn_rate(m, mk);
4898 if (!(rate > 0.0)) continue;
4899 const double d = -std::log(uniform01(g_spn)) / rate;
4900 if (d < best) {
4901 best = d;
4902 best_mode = static_cast<int>(k);
4903 }
4904 }
4905 live_fire[t] = ++fire_tag;
4906 if (best_mode < 0) continue;
4907 Event e;
4908 e.t = now + best;
4909 e.kind = EV_FIRING;
4910 e.station = t;
4911 e.cls = static_cast<std::size_t>(best_mode);
4912 e.tag = live_fire[t];
4913 push(e);
4914 }
4915 };
4916
4917 // ---- initial state -----------------------------------------------------
4918 /**
4919 * WARM START, `--initsol`: the placement a companion solver's steady state
4920 * implies, as a STATION-MAJOR vector [st0_cl0, ..., stM-1_clK-1].
4921 *
4922 * It REPLACES the reference-station placement of the closed classes; the
4923 * open arrival streams are armed either way. A Petri net is exempt: its
4924 * tokens live in Places and not in the service queues this vector
4925 * describes, so a station-based placement would put nothing anywhere and
4926 * then fail the conservation check.
4927 *
4928 * CLASSES DESCEND WITHIN A STATION. Every initial job arrives at time 0, so
4929 * under an order-preserving discipline the LAST one injected holds the
4930 * server; ascending order puts the highest-index class in service, which is
4931 * the mirror of what `State.initDefault` encodes and selects the other
4932 * closed communicating class on a chain made reducible by non-overtaking
4933 * routing -- a different stationary distribution, not a different tie-break.
4934 */
4935 const bool warm_start = !o.init_sol.empty() && place_nodes.empty();
4936 if (warm_start) {
4937 for (std::size_t i = 0; i < M; ++i) {
4938 if (S[i].role != Role::Queue && S[i].role != Role::Delay) continue;
4939 for (std::size_t k = K; k-- > 0;) {
4940 const std::size_t idx = i * K + k;
4941 if (idx >= o.init_sol.size()) continue;
4942 const double v = o.init_sol[idx];
4943 if (!(v > 0.0)) continue;
4944 const std::size_t count = static_cast<std::size_t>(v);
4945 for (std::size_t j = 0; j < count; ++j) {
4946 Job job;
4947 job.cls = k;
4948 job.t_sys = 0.0;
4949 if (!admit(i, job, M))
4950 throw InputError("SolverLDES (native engine): the warm-start placement "
4951 "of class '" + sn.classes[k].name + "' does not fit "
4952 "station '" + sn.stations[i].name + "'");
4953 }
4954 }
4955 }
4956 // A closed class must be placed in full: a warm start that loses jobs is
4957 // a different model, and the loss is invisible in every mean it reports.
4958 for (std::size_t r = 0; r < K; ++r) {
4959 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
4960 double held = 0.0;
4961 for (std::size_t i = 0; i < M; ++i) held += acc.qlen[i][r];
4962 const double want = sn.classes[r].population;
4963 if (std::fabs(held - want) > 0.5)
4964 throw InputError("SolverLDES (native engine): the warm-start placement holds " +
4965 std::to_string(static_cast<long>(held + 0.5)) + " jobs of class '" +
4966 sn.classes[r].name + "' against a population of " +
4967 std::to_string(static_cast<long>(want + 0.5)));
4968 }
4969 }
4970 // Every breakdown-carrying station starts UP with its first failure armed,
4971 // which is the reference's initial condition: a model that started down
4972 // would report a transient nobody asked for.
4973 for (std::size_t i = 0; i < M; ++i) {
4974 if (!S[i].has_breakdown) continue;
4975 Event e;
4976 e.t = S[i].failure_time.next_at(g_aux[i], 0.0);
4977 e.kind = EV_BREAKDOWN;
4978 e.station = i;
4979 push(e);
4980 }
4981
4982 std::vector<Sampler> arrival(K);
4983 std::vector<double> lambda(K, 0.0);
4984 // `Source.setArrivalBatch`: a batch-size law bolted onto a renewal stream, drawn
4985 // alongside the interarrival time and consumed by the release loop below. It is
4986 // the SEPARATE mechanism from a process that carries its own sizes -- the two are
4987 // refused together above -- and it rides a stream of its own on the reference's
4988 // own `seed + 991*(srcIdx+1) + 97*(k+1)` band, so adding a batch law to a model
4989 // does not shift any other draw. The LAW matches the reference; the sample path
4990 // does not, because the reference inverts its discrete families off
4991 // java.util.Random and these invert off the MRG stream.
4992 std::vector<Sampler> arr_batch(K);
4993 std::vector<bool> has_arr_batch(K, false);
4994 std::vector<Rng> g_arr_batch;
4995 g_arr_batch.reserve(K);
4996 for (std::size_t k = 0; k < K; ++k)
4997 g_arr_batch.push_back(Rng(seed_ll, 991LL + 97LL * (static_cast<long long>(k) + 1LL)));
4998 if (source_st < M) {
4999 const auto& sb = sn.stations[source_st].arrival_batch;
5000 for (std::size_t r = 0; r < K && r < sb.size(); ++r) {
5001 if (sb[r].disabled) continue;
5002 arr_batch[r] = Sampler(sb[r], "the arrival batch of class '" + sn.classes[r].name +
5003 "' at station '" + sn.stations[source_st].name + "'");
5004 has_arr_batch[r] = true;
5005 }
5006 }
5007
5008 // ---- the Source's mark -> class binding -------------------------------
5009 //
5010 // `marked_classes` is 1-based and ordered by mark, so mark k names class
5011 // `mark_class[k-1]` in the 0-based form used here. The FIRST marked class is the
5012 // CARRIER: only its stream is armed, and every arrival it produces is routed to
5013 // the class its mark names. The other marked classes share the one modulating
5014 // chain and must NOT arm streams of their own, or the model would run K
5015 // independent copies of the process instead of one marked process. This mirrors
5016 // `Solver_ssj`'s markedGroupClass / markedCarrierClass.
5017 //
5018 // Their SOURCE THROUGHPUT is the per-mark rate pie*D1k*e, not the aggregate: the
5019 // whole binding shares one Distrib, so `1/mean()` would report the total stream
5020 // K times over.
5021 std::vector<std::size_t> mark_class;
5022 std::vector<double> mark_rate;
5023 std::size_t mark_carrier = K;
5024 if (source_st < M) {
5025 for (std::size_t c : sn.stations[source_st].marked_classes)
5026 if (c >= 1 && c <= K) mark_class.push_back(c - 1);
5027 }
5028 /**
5029 * The width-weighted time average of one segment list, i.e. the nominal block.
5030 *
5031 * `sched_dist` builds the nominal D0/D1 this way and `mmapt` the nominal per-mark
5032 * blocks; this is the same average applied to a (mark, batch) list, which nothing
5033 * stores because `Dmark` carries the MARK reading for a BMMAPt.
5034 */
5035 auto nominal_of = [](const std::vector<T>& bp, const std::vector<Matrix<T>>& segs) {
5036 const T zero = num_traits<T>::from_int(0);
5037 Matrix<T> acc(segs[0].rows(), segs[0].cols(), zero);
5038 T total = zero;
5039 for (std::size_t k = 0; k + 1 < bp.size(); ++k) total += T(bp[k + 1] - bp[k]);
5040 for (std::size_t k = 0; k < segs.size(); ++k) {
5041 const T w = T(T(bp[k + 1] - bp[k]) / total);
5042 for (std::size_t a = 0; a < acc.rows(); ++a)
5043 for (std::size_t b = 0; b < acc.cols(); ++b) acc(a, b) += w * segs[k](a, b);
5044 }
5045 return acc;
5046 };
5047 /**
5048 * JOBS PER EPOCH of an arrival process that carries its own batch sizes, and 1.0
5049 * for one that does not.
5050 *
5051 * A batch epoch is ONE EVENT and `b` JOBS, so the Source row of TN -- which is
5052 * analytic here, not counted -- has to report the JOB rate or it will not balance
5053 * against the counted throughput of the stations downstream. Getting this wrong
5054 * reads an M[3]/M/2 as an M/M/2 at a third of the task rate, which is exactly the
5055 * failure the old `arrival_batch` refusal existed to prevent. An unbatched
5056 * process returns 1.0, so every existing model's Source row is untouched.
5057 */
5058 auto mean_batch_of = [&](const lang::Distrib<T>& cd) -> double {
5059 std::vector<Matrix<T>> blocks;
5060 std::vector<std::size_t> sizes;
5061 if (cd.type == lang::ProcessType::BMAP) {
5062 for (std::size_t b = 0; b < cd.Dmark.size(); ++b) {
5063 blocks.push_back(cd.Dmark[b]);
5064 sizes.push_back(b + 1);
5065 }
5066 } else if (cd.has_batch_schedule()) {
5067 for (std::size_t c = 0; c < cd.sched_Dbatch.size(); ++c)
5068 for (std::size_t b = 0; b < cd.sched_Dbatch[c].size(); ++b) {
5069 blocks.push_back(nominal_of(cd.sched_bp, cd.sched_Dbatch[c][b]));
5070 sizes.push_back(b + 1);
5071 }
5072 }
5073 if (blocks.empty()) return 1.0;
5074 mam::Mmap<T> mm;
5075 mm.D0 = cd.D0;
5076 mm.D1 = cd.D1;
5077 mm.Dc = blocks;
5078 const std::vector<T> lam = mam::mmap_lambda(mm);
5079 double epochs = 0.0, jobs = 0.0;
5080 for (std::size_t b = 0; b < blocks.size(); ++b) {
5081 const double v = num_traits<T>::to_double(lam[b]);
5082 epochs += v;
5083 jobs += static_cast<double>(sizes[b]) * v;
5084 }
5085 return (epochs > 0.0) ? (jobs / epochs) : 1.0;
5086 };
5087 if (!mark_class.empty()) {
5088 mark_carrier = mark_class[0];
5089 const lang::Distrib<T>& cd = sn.service[source_st][mark_carrier];
5090 if (cd.Dmark.size() < mark_class.size())
5091 throw InputError("SolverLDES (native engine): the Source binds " +
5092 std::to_string(mark_class.size()) +
5093 " marks but its arrival process carries only " +
5094 std::to_string(cd.Dmark.size()) + " marked blocks");
5095 if (cd.has_batch_schedule()) {
5096 // A BMMAPt's per-mark JOB rate, sum_b b * theta * D_(c,b) * e. Through the
5097 // same `mmap_lambda` as the unbatched case, on the (mark, batch) nominals
5098 // flattened mark-major: the weighting is by the STATIONARY phase
5099 // distribution and must not be re-implemented per axis.
5100 const std::size_t B = cd.sched_Dbatch[0].size();
5101 std::vector<Matrix<T>> blocks;
5102 for (std::size_t c = 0; c < cd.sched_Dbatch.size(); ++c)
5103 for (std::size_t b = 0; b < B; ++b)
5104 blocks.push_back(nominal_of(cd.sched_bp, cd.sched_Dbatch[c][b]));
5105 mam::Mmap<T> mm;
5106 mm.D0 = cd.D0;
5107 mm.D1 = cd.D1;
5108 mm.Dc = blocks;
5109 const std::vector<T> lam = mam::mmap_lambda(mm);
5110 for (std::size_t k = 0; k < mark_class.size(); ++k) {
5111 double jobs = 0.0;
5112 for (std::size_t b = 0; b < B; ++b)
5113 jobs += static_cast<double>(b + 1) *
5114 num_traits<T>::to_double(lam[k * B + b]);
5115 mark_rate.push_back(jobs);
5116 }
5117 } else {
5118 mam::Mmap<T> mm;
5119 mm.D0 = cd.D0;
5120 mm.D1 = cd.D1;
5121 mm.Dc = cd.Dmark;
5122 const std::vector<T> lam = mam::mmap_lambda(mm);
5123 for (std::size_t k = 0; k < mark_class.size(); ++k)
5124 mark_rate.push_back(num_traits<T>::to_double(lam[k]));
5125 }
5126 }
5127 // True for a marked class that is not the carrier.
5128 const std::vector<std::size_t>& mkc = mark_class;
5129 auto marked_non_carrier = [&mkc, &mark_carrier](std::size_t r) {
5130 if (mkc.empty() || r == mark_carrier) return false;
5131 for (std::size_t c : mkc)
5132 if (c == r) return true;
5133 return false;
5134 };
5135
5136 for (std::size_t r = 0; r < K; ++r) {
5137 if (sn.classes[r].type == lang::JobClassType::OPEN) {
5138 if (source_st >= M)
5139 throw InputError("SolverLDES (native engine): an open class with no Source");
5140 if (S[source_st].off[r]) continue;
5141 arrival[r] = S[source_st].svc[r];
5142 // The EPOCH rate, scaled to a JOB rate by the mean batch each epoch
5143 // releases: its own, for a BMAP or BMMAPt, or the separate
5144 // `Source.setArrivalBatch` law. Both are 1 for an unbatched stream.
5145 lambda[r] = mean_batch_of(sn.service[source_st][r]) / arrival[r].mean();
5146 if (has_arr_batch[r]) lambda[r] = arr_batch[r].mean() / arrival[r].mean();
5147 for (std::size_t k = 0; k < mark_class.size(); ++k)
5148 if (mark_class[k] == r) lambda[r] = mark_rate[k];
5149 // A non-carrier receives its jobs through the carrier's arrivals.
5150 if (marked_non_carrier(r)) continue;
5151 Event e;
5152 e.t = slot_snap(arrival[r].next_at(g_arr[r], 0.0), "interarrival time");
5153 e.kind = EV_ARRIVAL;
5154 e.station = source_st;
5155 e.cls = r;
5156 push(e);
5157 } else {
5158 if (warm_start) continue; // already placed from the warm start
5159 const double n = sn.classes[r].population;
5160 if (!(n >= 0.0) || !std::isfinite(n))
5161 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
5162 "' has a population that is not a finite count");
5163 const std::size_t refst = sn.classes[r].refstat;
5164 if (refst == 0 || refst > M)
5165 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
5166 "' has no reference station");
5167 // A CLOSED CLASS OF A PETRI NET IS ALREADY PLACED: its jobs are
5168 // TOKENS and they live in the initial marking, which the SPN section
5169 // above widened by exactly this population. Admitting them here as
5170 // well would both double the population and fail outright, since a
5171 // place serves no job through `admit` -- which is how every Petri
5172 // net reaching this engine used to die, queueing or not, under a
5173 // message about a station that "does not serve" the class.
5174 // `initClosedClassPopulations` skips the injection on a Place for
5175 // the same reason.
5176 if (sn.stations[refst - 1].nodetype == NodeType::Place) continue;
5177 const std::size_t count = static_cast<std::size_t>(n + 0.5);
5178 for (std::size_t j = 0; j < count; ++j) {
5179 Job job;
5180 job.cls = r;
5181 job.t_sys = 0.0;
5182 if (!admit(refst - 1, job, M))
5183 throw InputError("SolverLDES (native engine): the initial population of "
5184 "class '" + sn.classes[r].name +
5185 "' does not fit its reference station's capacity");
5186 }
5187 }
5188 }
5189
5190 // ---- convergence-based stopping -----------------------------------------
5191 Convergence cnvg;
5192 cnvg.init(M, K, o, max_events);
5193 std::vector<std::size_t> servers_of(M, 1);
5194 std::vector<std::vector<bool>> off_of(M, std::vector<bool>(K, true));
5195 for (std::size_t i = 0; i < M; ++i) {
5196 servers_of[i] = S[i].nservers;
5197 for (std::size_t r = 0; r < K; ++r)
5198 off_of[i][r] = S[i].off[r] || S[i].role == Role::Source ||
5199 S[i].role == Role::Synchronization;
5200 }
5201 std::uint64_t last_cnvg_events = 0;
5202 bool converged = false;
5203
5204 // The initial tokens of a queueing place are queued, not deposited, so the
5205 // embedded queues have to be started before the net can settle: until a
5206 // token has been served it is in no depository and no output arc sees it.
5207 for (std::size_t p = 0; p < place_nodes.size(); ++p)
5208 if (qplace[p]) place_try_start(p);
5209 // The immediate modes settle the initial marking before any clock runs.
5210 spn_settle();
5211
5212 // ---- MSER-5 and batch-means bookkeeping --------------------------------
5213 Observations obs(M, K, o.tranfilter == "mser5", o.mserbatch > 0 ? o.mserbatch : 5);
5214 for (std::size_t i = 0; i < M; ++i)
5215 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) obs.in_mser[i] = 0;
5216 std::uint64_t mser_interval = max_events / 1000ULL;
5217 if (mser_interval < 1) mser_interval = 1;
5218 std::uint64_t last_mser_events = 0;
5219
5220
5221 // ---- transient sampling and the joint-state histogram ---------------------
5222 /**
5223 * A TRANSIENT run is bounded by SIMULATED TIME, not by completions.
5224 *
5225 * `options.timespan = [t0, t1]` sets the horizon and the completion budget
5226 * is then ignored, exactly as the reference does: a transient trajectory is
5227 * a function of time, so stopping on an event count would end it at a
5228 * different instant on every path and make the series incomparable across
5229 * seeds.
5230 */
5231 const bool transient_run = o.has_timespan && std::isfinite(o.t1) && o.t1 > o.t0;
5232 const double horizon = transient_run ? o.t1 : std::numeric_limits<double>::infinity();
5233 // o.tranobs points when given, else ~1000: enough to draw and few enough that
5234 // the series is not itself a memory problem on a long horizon. Same default as the JAR.
5235 const double tran_points = (o.tranobs > 0) ? static_cast<double>(o.tranobs) : 1000.0;
5236 const double tran_interval = transient_run ? (o.t1 - o.t0) / tran_points : 0.0;
5237 double next_tran_sample = transient_run ? o.t0 + tran_interval : 0.0;
5238 double last_tran_time = transient_run ? o.t0 : 0.0;
5239 std::vector<double> tran_times;
5240 std::vector<std::vector<std::vector<double>>> tran_q(M, std::vector<std::vector<double>>(K));
5241 std::vector<std::vector<std::vector<double>>> tran_u(M, std::vector<std::vector<double>>(K));
5242 std::vector<std::vector<std::vector<double>>> tran_t(M, std::vector<std::vector<double>>(K));
5243 std::vector<std::vector<double>> last_tran_q(M, std::vector<double>(K, 0.0));
5244 std::vector<std::vector<double>> last_tran_b(M, std::vector<double>(K, 0.0));
5245 std::vector<std::vector<double>> last_tran_c(M, std::vector<double>(K, 0.0));
5246
5247 /**
5248 * The EXACT joint-state residence time, keyed by the aggregate state row.
5249 *
5250 * `histogram_space` plus `histogram_time` let a caller evaluate its own
5251 * reward on the empirical distribution, INCLUDING a nonlinear one:
5252 * E[r] = sum_s (t_s / sum t) r(state_s) is exact only because the residence
5253 * time of each distinct state is kept, which a trajectory of means cannot
5254 * reconstruct.
5255 */
5256 std::map<std::vector<int>, double> histogram;
5257 std::vector<std::pair<double, std::vector<int>>> trajectory;
5258 double hist_last = 0.0;
5259 // A transient run needs the state walk anyway; a steady-state one pays for
5260 // it only when asked.
5261 const bool want_traj = transient_run || o.export_trajectory;
5262 const bool want_hist = transient_run || o.export_histogram || want_traj;
5263
5264 auto joint_state = [&]() {
5265 std::vector<int> row(M * K, 0);
5266 for (std::size_t i = 0; i < M; ++i)
5267 for (std::size_t r = 0; r < K; ++r)
5268 row[i * K + r] = static_cast<int>(acc.qlen[i][r] + acc.held[i][r] + 0.5);
5269 return row;
5270 };
5271 auto hist_accumulate = [&]() {
5272 if (!want_hist) return;
5273 const double dt = now - hist_last;
5274 if (dt > 0.0) {
5275 const std::vector<int> row = joint_state();
5276 histogram[row] += dt;
5277 if (want_traj) trajectory.push_back(std::make_pair(hist_last, row));
5278 }
5279 hist_last = now;
5280 };
5281
5282 auto tran_sample = [&]() {
5283 const double dt = now - last_tran_time;
5284 tran_times.push_back(now);
5285 for (std::size_t i = 0; i < M; ++i)
5286 for (std::size_t r = 0; r < K; ++r) {
5287 if (dt > 0.0) {
5288 tran_q[i][r].push_back((acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt);
5289 const double c = S[i].util_peak;
5290 tran_u[i][r].push_back(
5291 (S[i].role == Role::Delay)
5292 ? (acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt
5293 : (acc.tot_busy[i][r] - last_tran_b[i][r]) / (dt * c));
5294 tran_t[i][r].push_back((acc.completed[i][r] - last_tran_c[i][r]) / dt);
5295 } else {
5296 tran_q[i][r].push_back(acc.qlen[i][r] + acc.held[i][r]);
5297 tran_u[i][r].push_back(0.0);
5298 tran_t[i][r].push_back(0.0);
5299 }
5300 last_tran_q[i][r] = acc.tot_qlen[i][r];
5301 last_tran_b[i][r] = acc.tot_busy[i][r];
5302 last_tran_c[i][r] = acc.completed[i][r];
5303 }
5304 last_tran_time = now;
5305 };
5306
5307 // ---- the event loop ----------------------------------------------------
5308 while (!evq.empty() && (transient_run || total_completions < max_events)) {
5309 const Event ev = evq.top();
5310 if (transient_run && ev.t > horizon) break;
5311 evq.pop();
5312 now = ev.t;
5313
5314 // THE STATE IS WALKED BEFORE THE EVENT IS APPLIED, so the interval just
5315 // ended is charged to the state that held during it. A steady-state run
5316 // asked for the histogram takes the same walk and nothing else.
5317 if (want_hist) hist_accumulate();
5318 if (transient_run) {
5319 while (now >= next_tran_sample && next_tran_sample <= horizon) {
5320 const double save = now;
5321 now = next_tran_sample;
5322 for (std::size_t a = 0; a < M; ++a) {
5323 if (S[a].ps) ps_advance(a);
5324 for (std::size_t r = 0; r < K; ++r) {
5325 acc.update_qlen(a, r, now);
5326 acc.update_busy(a, r, now);
5327 }
5328 }
5329 tran_sample();
5330 next_tran_sample += tran_interval;
5331 now = save;
5332 }
5333 }
5334
5335 if (ev.kind == EV_BREAKDOWN) {
5336 // FREEZE AT THE OLD RATE, FLIP, RE-TIME AT THE NEW, in that order,
5337 // for the same reason sd_advance runs before a population change:
5338 // the rate in force over the elapsed interval is the one the OLD
5339 // state implied. The stale departures are neutralised by the tag.
5340 StationState& s = S[ev.station];
5341 if (s.ps)
5342 ps_advance(ev.station);
5343 else
5344 sd_advance(ev.station);
5345 s.up = !s.up;
5346 if (s.ps)
5347 ps_reschedule(ev.station);
5348 else
5349 sd_reschedule(ev.station);
5350 // The next transition is drawn from the clock now in force. A job
5351 // stalled by the outage has no departure event at all; the repair's
5352 // reschedule is what gives it one back, with its residual intact.
5353 // `next_at` returns a DURATION, not an instant: it takes `from`
5354 // only so a time-inhomogeneous family can read its own schedule.
5355 // Assigning it straight to `nxt.t` puts the next flip in the PAST,
5356 // which the loop pops immediately and re-arms -- an infinite
5357 // cascade at one simulated instant.
5358 Event nxt;
5359 nxt.t = now + (s.up ? s.failure_time.next_at(g_aux[ev.station], now)
5360 : s.repair_time.next_at(g_aux[ev.station], now));
5361 nxt.kind = EV_BREAKDOWN;
5362 nxt.station = ev.station;
5363 push(nxt);
5364 continue;
5365 }
5366
5367 if (ev.kind == EV_FIRING) {
5368 // A stale clock: the marking moved after this one was armed, so its
5369 // rate no longer describes the net.
5370 if (ev.station >= transitions.size() || live_fire[ev.station] != ev.tag) continue;
5371 SpnTransition& tr = transitions[ev.station];
5372 const std::vector<double> tok = flat_avail();
5373 const SpnMode& m = tr.modes[ev.cls];
5374 if (!spn_enabled(m, tok)) continue;
5375 spn_apply(m);
5376 tr.fired[ev.cls] += 1.0;
5377 ++total_completions;
5378 spn_settle();
5379 continue;
5380 }
5381
5382 /**
5383 * A token finished its embedded service: it leaves the place's servers
5384 * for its depository, where the output arcs can take it.
5385 *
5386 * THE MARKING DOES NOT MOVE. The token is still at the place -- it has
5387 * only changed compartment -- so QN is untouched and only UN and the
5388 * arcs' view of the place change. The next waiting token is taken into
5389 * service before the net settles, which is `placeDepartureActions`.
5390 */
5391 if (ev.kind == EV_PLACE_SVC) {
5392 const std::size_t p = ev.station;
5393 const std::size_t r = ev.cls;
5394 const std::size_t ist = place_station[p];
5395 if (ist < M) {
5396 acc.update_busy(ist, r, now);
5397 place_insvc[p][r] -= 1.0;
5398 acc.busy[ist][r] = place_insvc[p][r];
5399 }
5400 --place_busy[p];
5401 avail[p][r] += 1.0;
5402 place_try_start(p);
5403 spn_settle();
5404 continue;
5405 }
5406
5407 if (ev.kind == EV_SWITCHOVER) {
5408 StationState& sp = S[ev.station];
5409 sp.poll_switching = false;
5410 sp.poll_at = ev.cls;
5411 poll_open_visit(sp);
5412 bool work = false;
5413 for (const Job& j : sp.buffer)
5414 if (j.cls == sp.poll_at) {
5415 work = true;
5416 break;
5417 }
5418 if (work)
5419 poll_serve(ev.station);
5420 else
5421 poll_advance(ev.station);
5422 continue;
5423 }
5424
5425 if (ev.kind == EV_RETRIAL) {
5426 StationState& sr = S[ev.station];
5427 const double dt = now - sr.orbit_last;
5428 if (dt > 0.0)
5429 for (std::size_t r = 0; r < K; ++r) sr.tot_orbit[r] += sr.orbit_size[r] * dt;
5430 sr.orbit_last = now;
5431 sr.orbit_size[ev.cls] -= 1.0;
5432 sr.retried[ev.cls] += 1.0;
5433 // The retry is an ordinary admission: it succeeds if the station has
5434 // room by then and rejoins the orbit if it does not. It does not balk.
5435 pending_retry = true;
5436 admit(ev.station, ev.job, M);
5437 continue;
5438 }
5439
5440 if (ev.kind == EV_SETUP) {
5441 StationState& ss = S[ev.station];
5442 if (ev.slot == 1) {
5443 // A power-down. It is CANCELLED if a job arrived meanwhile: the
5444 // server never went cold, so that job must not pay a setup.
5445 double held = 0.0;
5446 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[ev.station][r];
5447 if (held > 0.0 || now + 1e-12 < ss.delayoff_at) continue;
5448 ss.setup_on = false;
5449 ss.delayoff_at = std::numeric_limits<double>::infinity();
5450 continue;
5451 }
5452 if (ss.setup_on) continue; // superseded
5453 ss.setup_running = false;
5454 ss.setup_on = true;
5455 ss.delayoff_at = std::numeric_limits<double>::infinity();
5456 for (std::size_t sl = 0; sl < ss.nservers && !ss.buffer.empty(); ++sl)
5457 if (!ss.server_busy[sl] && !ss.server_blocked[sl] && !ss.server_held[sl] &&
5458 buffer_has_for_slot(ev.station, sl)) {
5459 Job nextjob = buffer_pop_for_slot(ev.station, sl);
5460 start_service(ev.station, sl, nextjob);
5461 }
5462 sd_reschedule(ev.station);
5463 continue;
5464 }
5465
5466 if (ev.kind == EV_RENEGE) {
5467 // The job abandons only if it is STILL WAITING. Having entered
5468 // service, departed, or been dropped all leave the timer with
5469 // nothing to find, which is the ordinary case and not an error.
5470 StationState& sr = S[ev.station];
5471 std::size_t at = sr.buffer.size();
5472 for (std::size_t j = 0; j < sr.buffer.size(); ++j)
5473 if (sr.buffer[j].id == ev.tag) {
5474 at = j;
5475 break;
5476 }
5477 if (at == sr.buffer.size()) continue;
5478 const std::size_t rc = sr.buffer[at].cls;
5479 sd_advance(ev.station);
5480 acc.update_qlen(ev.station, rc, now);
5481 acc.qlen[ev.station][rc] -= 1.0;
5482 bp.track(ev.station, rc, -1, now);
5483 sr.buffer.erase(sr.buffer.begin() + static_cast<std::ptrdiff_t>(at));
5484 if (sr.sched != SchedStrategy::FSP)
5485 std::make_heap(sr.buffer.begin(), sr.buffer.end(), sr.cmp);
5486 reneged[ev.station][rc] += 1.0;
5487 sd_reschedule(ev.station);
5488 continue;
5489 }
5490
5491 if (ev.kind == EV_ARRIVAL) {
5492 // The mark of THIS arrival, read BEFORE the draw below overwrites it with
5493 // the next one's. Same ordering as `Solver_ssj`'s arrivalPendingMark, and
5494 // it is not cosmetic: reading it after the draw would label every job with
5495 // its successor's mark.
5496 const int mark = arrival[ev.cls].last_mark();
5497 // ... and the BATCH SIZE of this arrival, for the same reason and from
5498 // the same two sources: a BMAP or BMMAPt carries its own sizes in the
5499 // blocks that fired, while `Source.setArrivalBatch` is a separate law
5500 // drawn on its own stream. They are mutually exclusive, checked above.
5501 std::size_t batch = 1;
5502 if (arrival[ev.cls].batched()) {
5503 const int b = arrival[ev.cls].last_batch();
5504 if (b > 0) batch = static_cast<std::size_t>(b);
5505 } else if (has_arr_batch[ev.cls]) {
5506 const double sampled = arr_batch[ev.cls].next(g_arr_batch[ev.cls]);
5507 const double rounded = std::floor(sampled + 0.5);
5508 if (!(rounded >= 1.0) || std::fabs(sampled - rounded) > 1e-9)
5509 throw InputError(
5510 "SolverLDES (native engine): the arrival batch of class '" +
5511 sn.classes[ev.cls].name + "' sampled " + std::to_string(sampled) +
5512 ", which is not a positive integer; a batch-size law must be "
5513 "supported on {1,2,...}");
5514 batch = static_cast<std::size_t>(rounded);
5515 }
5516 Event nxt;
5517 const double gap = slot_snap(arrival[ev.cls].next_at(g_arr[ev.cls], now),
5518 "interarrival time");
5519 // A non-cyclic schedule past its horizon yields NO further arrival:
5520 // the stream has ended, and re-arming it at rate zero would loop.
5521 if (!(gap > 0.0) && arrival[ev.cls].time_varying()) continue;
5522 nxt.t = now + gap;
5523 nxt.kind = EV_ARRIVAL;
5524 nxt.station = ev.station;
5525 nxt.cls = ev.cls;
5526 push(nxt);
5527
5528 // A marked Source routes on the mark: the event stays on the carrier
5529 // class, and the job that arrives belongs to the class the mark names.
5530 std::size_t arv_cls = ev.cls;
5531 if (mark > 0 && static_cast<std::size_t>(mark) <= mark_class.size())
5532 arv_cls = mark_class[static_cast<std::size_t>(mark) - 1];
5533
5534 // ONE EPOCH, `batch` JOBS, ALL OF THE SAME CLASS. The route is redrawn
5535 // per member, matching the reference's `selectDestination` inside its own
5536 // release loop: a batch that took one route for all of its members would
5537 // correlate their destinations, which is a different model wherever the
5538 // Source feeds more than one station.
5539 for (std::size_t b = 0; b < batch; ++b) {
5540 const RouteEntry e = draw_node_route(sn.station_to_node[ev.station], arv_cls);
5541 Job job;
5542 job.cls = e.cls;
5543 job.t_sys = now;
5544 deliver(e.node, job, M);
5545 }
5546 continue;
5547 }
5548
5549 // ---- a service completion ------------------------------------------
5550 const std::size_t i = ev.station;
5551 StationState& s = S[i];
5552 Job job;
5553
5554 /**
5555 * A BULK SERVER'S FIRING. One event, min(b, present) completions.
5556 *
5557 * TWO PHASES, and the order is load-bearing: every served job is removed
5558 * and recorded BEFORE any of them is routed, because a job routed back to
5559 * this same station would otherwise re-arm the clock while the remaining
5560 * count is still unsettled and the station would fire twice for one batch.
5561 * The Java engine's `batchServiceDepartureActions` has the same split for
5562 * the same reason.
5563 */
5564 if (s.bmsp) {
5565 if (s.bmsp_tag != ev.tag) continue; // a superseded firing
5566 const std::size_t r = s.bmsp_cls;
5567 const std::size_t want = s.bmsp_pending;
5568 std::vector<Job> served;
5569 for (std::size_t d = 0; d < want && !s.buffer.empty(); ++d)
5570 served.push_back(buffer_pop(i));
5571 for (std::size_t d = 0; d < served.size(); ++d) {
5572 const Job& sj = served[d];
5573 acc.update_qlen(i, sj.cls, now);
5574 acc.qlen[i][sj.cls] -= 1.0;
5575 bp.track(i, sj.cls, -1, now);
5576 acc.completed[i][sj.cls] += 1.0;
5577 acc.resp_sum[i][sj.cls] += now - sj.t_arr + sj.region_wait;
5578 if (want_respt) resp_samples[i][sj.cls].push_back(now - sj.t_arr + sj.region_wait);
5579 acc.resp_cnt[i][sj.cls] += 1.0;
5580 ++total_completions;
5581 }
5582 // Re-arm while work remains, or idle the single server. The clock is
5583 // armed from THIS instant, which is what makes the schedule walk of a
5584 // BMMAPt clock exact: a bulk server runs continuously while non-empty.
5585 if (!s.buffer.empty()) {
5586 bmsp_arm(i);
5587 } else {
5588 acc.update_busy(i, r, now);
5589 if (acc.busy[i][r] > 0.0) acc.busy[i][r] -= 1.0;
5590 s.server_busy[0] = false;
5591 s.bmsp_tag = 0;
5592 }
5593 // Phase B: route the served jobs, now that the station's own state is
5594 // settled.
5595 for (std::size_t d = 0; d < served.size(); ++d) {
5596 const Job& sj = served[d];
5597 const RouteEntry re2 = draw_node_route(sn.station_to_node[i], sj.cls);
5598 if (region_of[i] >= 0) {
5599 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
5600 const bool inside = !re2.sink && re2.station < M &&
5601 region_of[re2.station] == region_of[i];
5602 if (!inside) {
5603 regions[rg].update(now);
5604 regions[rg].leave(sj.cls);
5605 regions[rg].completed[sj.cls] += 1.0;
5606 release_region(rg);
5607 }
5608 }
5609 Job moved;
5610 moved.cls = re2.cls;
5611 moved.t_sys = sj.t_sys;
5612 moved.parent = sj.parent;
5613 moved.call = sj.call;
5614 deliver(re2.node, moved, i);
5615 }
5616 if (total_completions >= max_events) break;
5617 continue;
5618 }
5619
5620 if (s.role == Role::Delay) {
5621 job = ev.job;
5622 if (track_delay_jobs) {
5623 // The registry is the Delay's cancellation handle: no entry
5624 // means a signal already destroyed this job, and a generation
5625 // that no longer matches means a global dependence has re-timed
5626 // it. Serving the departure in either case would put a job back
5627 // into circulation that has already left, or left twice.
5628 std::size_t at = delay_live[i].size();
5629 for (std::size_t j = 0; j < delay_live[i].size(); ++j)
5630 if (delay_live[i][j].id == job.id) {
5631 at = j;
5632 break;
5633 }
5634 if (at == delay_live[i].size()) continue;
5635 if (delay_live[i][at].tag != ev.tag) continue;
5636 delay_live[i].erase(delay_live[i].begin() + static_cast<std::ptrdiff_t>(at));
5637 }
5638 } else if (s.ps) {
5639 ps_advance(i);
5640 std::size_t idx = s.ps_jobs.size();
5641 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
5642 if (s.ps_jobs[j].tag == ev.tag) {
5643 idx = j;
5644 break;
5645 }
5646 // A superseded departure: the job it named has since been
5647 // rescheduled by a population change. Dropping it here is the
5648 // engine's substitute for cancelling the event.
5649 if (idx == s.ps_jobs.size()) continue;
5650 const PsJob& pj = s.ps_jobs[idx];
5651 job.cls = pj.cls;
5652 job.t_arr = pj.t_arr;
5653 job.region_wait = pj.region_wait;
5654 job.t_sys = pj.t_sys;
5655 job.parent = pj.parent;
5656 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(idx));
5657 } else if (s.pas) {
5658 // A stale aggregate clock: the list changed after it was armed, so
5659 // its total rate no longer describes the station.
5660 if (s.pas_tag != ev.tag || s.pas_list.empty()) continue;
5661 job = pas_complete(i);
5662 acc.update_busy(i, job.cls, now);
5663 if (acc.busy[i][job.cls] > 0.0) acc.busy[i][job.cls] -= 1.0;
5664 pas_reschedule(i);
5665 } else {
5666 // A preemption leaves its victim's departure in the list with no
5667 // way to cancel it; the tag mismatch is what neutralises it.
5668 if (!s.server_busy[ev.slot] || s.server_tag[ev.slot] != ev.tag) continue;
5669 sd_advance(i);
5670 job = s.server[ev.slot];
5671 s.server_busy[ev.slot] = false;
5672 s.server_tag[ev.slot] = 0;
5673 const double freed = release_slots(i, ev.slot);
5674 acc.update_busy(i, job.cls, now);
5675 acc.busy[i][job.cls] -= freed;
5676 }
5677
5678 sd_advance(i);
5679 acc.update_qlen(i, job.cls, now);
5680 acc.qlen[i][job.cls] -= 1.0;
5681 bp.track(i, job.cls, -1, now);
5682 acc.completed[i][job.cls] += 1.0;
5683 acc.resp_sum[i][job.cls] += now - job.t_arr + job.region_wait;
5684 if (want_respt) resp_samples[i][job.cls].push_back(now - job.t_arr + job.region_wait);
5685 acc.resp_cnt[i][job.cls] += 1.0;
5686 ++total_completions;
5687
5688 if (s.ps) {
5689 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
5690 Job nextjob = buffer_pop(i);
5691 PsJob pj;
5692 pj.cls = nextjob.cls;
5693 pj.priority = nextjob.priority;
5694 pj.t_arr = nextjob.t_arr;
5695 pj.region_wait = nextjob.region_wait;
5696 pj.t_sys = nextjob.t_sys;
5697 pj.total = nextjob.service;
5698 pj.remaining = nextjob.service;
5699 pj.parent = nextjob.parent;
5700 s.ps_jobs.push_back(pj);
5701 }
5702 ps_reschedule(i);
5703 }
5704
5705 const RouteEntry re = draw_node_route(sn.station_to_node[i], job.cls);
5706 const bool to_sink = re.sink;
5707 const std::size_t dst = re.station, dcls = re.cls;
5708
5709 /**
5710 * IMMEDIATE FEEDBACK: the completing job goes straight back into THE
5711 * SAME SERVER under the destination class, holding the slot instead of
5712 * re-queueing behind whoever is waiting.
5713 *
5714 * That is the whole content of the feature and it is not the same model
5715 * as a self-loop: a self-loop puts the job at the tail and lets the head
5716 * of the queue in, while feedback lets one job hold the server for as
5717 * many services as its routing keeps returning it. It also never leaves
5718 * the station, so it exits no region, releases no blocked upstream
5719 * server and is not a system completion.
5720 */
5721 const RouteEntry fb = has_immfeed ? resolve_forced_dest(re) : re;
5722 const std::size_t fcls = fb.cls;
5723 if (has_immfeed && !fb.sink && fb.station == i && s.role == Role::Queue && !s.ps &&
5724 !s.pas && ev.slot < s.nservers && fcls < sn.immfeed[i].size() && sn.immfeed[i][fcls]) {
5725 Job fed;
5726 fed.cls = fcls;
5727 fed.t_sys = job.t_sys;
5728 fed.parent = job.parent;
5729 fed.t_arr = now;
5730 fed.priority = classprio[fcls];
5731 if (draw_at_arrival(i, fcls)) {
5732 fed.service = slot_snap(s.svc[fcls].next_at(g_svc[i][fcls], now), "service time");
5733 fed.remaining = fed.service;
5734 fed.elapsed = 0.0;
5735 }
5736 fed.rank = g_siro.aux.next_double();
5737 fed.deadline = now + classdeadline[fcls];
5738 fed.id = ++job_id;
5739 sd_advance(i);
5740 acc.update_qlen(i, fcls, now);
5741 acc.qlen[i][fcls] += 1.0;
5742 // THE FED-BACK JOB IS AN ARRIVAL. It joins the station under its new
5743 // class, which is where `station_arrival` counts one, and both the
5744 // Java engine and SolverSSA report it: leaving it out halved AN on a
5745 // 50% self-loop (0.8036 -> 0.4006 against a reference 0.8001) and
5746 // turned AN < TN, which no station can do.
5747 acc.arrived[i][fcls] += 1.0;
5748 bp.track(i, fcls, +1, now);
5749 start_service(i, ev.slot, fed);
5750 sd_reschedule(i);
5751 if (total_completions >= max_events) break;
5752 continue;
5753 }
5754
5755 /**
5756 * A SYNCHRONOUS CALL PARKS THE SERVER. The completing job leaves for the
5757 * callee and its slot stays held until the matching reply comes back:
5758 * no waiter is promoted into it, and the time it spends held counts as
5759 * BUSY, because the server is unavailable and reporting it idle would
5760 * credit the station with capacity it does not have. That is the same
5761 * rule the BAS branch below applies for the same reason.
5762 *
5763 * A hop that ALREADY switched into the reply class is not a call: it is
5764 * the answer, and parking on it would wait for a reply to a reply.
5765 * That switch may not show on `dcls` yet -- see `resolve_final_cls` --
5766 * when the routing matrix moves this edge through a synthesized
5767 * ClassSwitch node, so the check resolves through it rather than
5768 * reading `dcls` directly.
5769 */
5770 if (has_sync_call && sync_reply[job.cls] < K &&
5771 !is_reply_signal[resolve_final_cls(re.node, dcls)] &&
5772 s.role == Role::Queue && !s.ps && !s.pas && ev.slot < s.nservers) {
5773 const std::uint64_t call = ++next_call;
5774 PendingCall pc;
5775 pc.station = i;
5776 pc.slot = ev.slot;
5777 pc.cls = job.cls;
5778 pc.since = now;
5779 pending_reply[call] = pc;
5780 s.server_held[ev.slot] = true;
5781 s.held_cls[ev.slot] = job.cls;
5782 acc.update_busy(i, job.cls, now);
5783 acc.busy[i][job.cls] += 1.0;
5784 // The parked caller is still a job AT THIS STATION for QLen (the reference's
5785 // currentBlockedServers), but not in `qlen`, which the admission and rate logic reads.
5786 acc.update_qlen(i, job.cls, now);
5787 acc.held[i][job.cls] += 1.0;
5788 Job moved;
5789 moved.cls = re.cls;
5790 moved.t_sys = job.t_sys;
5791 moved.parent = job.parent;
5792 moved.call = call;
5793 deliver(re.node, moved, i);
5794 if (total_completions >= max_events) break;
5795 continue;
5796 }
5797
5798 // BLOCKING IS DECIDED BEFORE THE SLOT IS REUSED. If the destination is
5799 // full and declares BAS or BBS, this server keeps the completed job and
5800 // stops serving; taking the next waiting job first would let the station
5801 // run at full rate while its output is stopped, which is precisely the
5802 // behaviour blocking removes.
5803 if (!to_sink && dst < M && s.role == Role::Queue && !s.ps && ev.slot < s.nservers &&
5804 !dest_has_room(dst, dcls)) {
5805 const lang::DropStrategy rule = dest_policy(dst, dcls);
5806 if (rule == lang::DropStrategy::BAS || rule == lang::DropStrategy::BBS) {
5807 Job held = job;
5808 held.cls = dcls;
5809 s.server_blocked[ev.slot] = true;
5810 s.blocked_job[ev.slot] = held;
5811 s.blocked_dest[ev.slot] = dst;
5812 s.blocked_dest_cls[ev.slot] = dcls;
5813 // Charged to the DESTINATION it is queueing for, not to the
5814 // station whose server it occupies.
5815 // The blocked job IS part of the destination's queue length --
5816 // the reference's effectiveQueueLength adds basBlockedAtDest and
5817 // bbsBlockedAtDest to it -- so it is carried in `qlen` and the
5818 // counter beside it is bookkeeping, not a second population.
5819 acc.update_qlen(dst, dcls, now);
5820 acc.qlen[dst][dcls] += 1.0;
5821 S[dst].blocked_at[dcls] += 1.0;
5822 // The upstream server is BLOCKED, not idle. Utilization counts
5823 // blocking time as busy time (the reference divides
5824 // busy + blocking by the horizon), because the server is
5825 // unavailable either way and reporting it idle would credit the
5826 // station with capacity it does not have.
5827 acc.update_busy(i, job.cls, now);
5828 acc.busy[i][job.cls] += 1.0;
5829 blocked_count[i][job.cls] += 1.0;
5830 continue;
5831 }
5832 }
5833
5834 // ONLY NOW may the freed slot take the next waiting job: a slot that
5835 // blocked above must not also start serving, or the station holds two
5836 // jobs in one server and the blocked one is duplicated on release.
5837 if (s.polling) {
5838 // The controller decides what happens next, not the buffer order:
5839 // it may keep serving this buffer, or walk on under its budget.
5840 poll_serve(i);
5841 } else if (s.role == Role::Queue && !s.ps && !s.pas && !s.server_blocked.empty() &&
5842 ev.slot < s.nservers && !s.server_blocked[ev.slot] &&
5843 !s.server_held[ev.slot]) {
5844 serve_next_on_slot(i, ev.slot);
5845 sd_reschedule(i);
5846 }
5847 // THE FORK IDENTITY IS HANDED OVER, NOT COPIED. A continuation that
5848 // stands in for the trigger at a Join must take its sibling slot; if
5849 // the trigger kept it too, one fork would present two siblings and the
5850 // Join would release early and discard the second.
5851 std::uint64_t spawn_parent = 0;
5852 if (has_spawn && spawn_of[job.cls] < K && spawn_joins_at[spawn_of[job.cls]]) {
5853 spawn_parent = job.parent;
5854 job.parent = 0;
5855 }
5856 // THE REGION IS LEFT FIRST, AS IN THE JAVA ENGINE: the completing job gives up its slot, the phase-2
5857 // continuation (if any) is admitted into it, and only then is the job routed. A hop to another member of the
5858 // same region under the SAME class completes nothing and releases no waiter: admit re-gates it into the
5859 // slot it just freed. Any other hop completes, and the waiters parked on the region are released.
5860 const int dep_rg = region_of[i];
5861 if (dep_rg >= 0) {
5862 regions[static_cast<std::size_t>(dep_rg)].update(now);
5863 regions[static_cast<std::size_t>(dep_rg)].leave(job.cls);
5864 }
5865 // THE PHASE-2 CONTINUATION IS INJECTED HERE, after the completing job's release of its region slot and
5866 // before the job is routed or anything parked on the region is released: the continuation takes over the
5867 // slot the trigger just freed, ahead of the blocked queue, which is the order `maybeSpawnOnCompletion` is
5868 // called in.
5869 if (has_spawn && spawn_of[job.cls] < K) {
5870 const std::size_t scls = spawn_of[job.cls];
5871 Job spawned;
5872 spawned.cls = scls;
5873 spawned.t_sys = now;
5874 // A continuation aimed at a Join stands in for the trigger at the
5875 // fork it belongs to (phase 2 at an AND-join branch tail), so it
5876 // inherits the fork identity rather than arriving as an orphan the
5877 // Join would discard.
5878 spawned.parent = spawn_parent;
5879 admit(i, spawned, M);
5880 }
5881 {
5882 Job moved;
5883 moved.cls = re.cls;
5884 moved.t_sys = job.t_sys;
5885 moved.parent = job.parent;
5886 // The identity travels WITH the job, through the callee and back on
5887 // the reply: a call answered two stations downstream is still this
5888 // caller's call, and dropping the tag here would strand its server.
5889 moved.call = job.call;
5890 hop_old_cls = job.cls;
5891 hop_internal = false;
5892 deliver(re.node, moved, i);
5893 hop_old_cls = K;
5894 }
5895 // The verdict on the hop is admit's, which sees the station it really reaches: `re.station` is M when the
5896 // edge runs through a ClassSwitch node. A Fork, Join, Sink or signal destination has no region: a crossing.
5897 if (dep_rg >= 0) {
5898 const bool stays_inside = hop_internal;
5899 hop_internal = false;
5900 if (!stays_inside) {
5901 regions[static_cast<std::size_t>(dep_rg)].completed[job.cls] += 1.0;
5902 release_region(static_cast<std::size_t>(dep_rg));
5903 }
5904 }
5905 // The station has emptied: arm the delay-off timer, after which the
5906 // server shuts down and the next arrival pays a setup.
5907 if (s.has_setup && s.setup_on) {
5908 double held = 0.0;
5909 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[i][r];
5910 if (!(held > 0.0)) {
5911 const double idle =
5912 s.delayoff_time.disabled() ? 0.0 : s.delayoff_time.next(g_aux[i]);
5913 s.delayoff_at = now + idle;
5914 Event e;
5915 e.t = s.delayoff_at;
5916 e.kind = EV_SETUP; // reuse the tag; the handler below powers down
5917 e.station = i;
5918 e.cls = job.cls;
5919 e.slot = 1; // marks a power-down rather than a setup completion
5920 push(e);
5921 }
5922 }
5923 release_blocked(i);
5924
5925 if (total_completions >= max_events) break;
5926 if (cnvg.enabled() && (total_completions - last_cnvg_events) >= cnvg.interval()) {
5927 for (std::size_t a = 0; a < M; ++a) {
5928 if (S[a].ps) ps_advance(a);
5929 for (std::size_t r = 0; r < K; ++r) {
5930 acc.update_qlen(a, r, now);
5931 acc.update_busy(a, r, now);
5932 }
5933 }
5934 cnvg.finalize_batch(acc, servers_of, now);
5935 last_cnvg_events = total_completions;
5936 if (cnvg.converged(off_of)) {
5937 converged = true;
5938 break;
5939 }
5940 }
5941 if ((total_completions - last_mser_events) >= mser_interval) {
5942 for (std::size_t a = 0; a < M; ++a) {
5943 if (S[a].ps) ps_advance(a);
5944 for (std::size_t r = 0; r < K; ++r) {
5945 acc.update_qlen(a, r, now);
5946 acc.update_busy(a, r, now);
5947 }
5948 }
5949 obs.collect(acc, now);
5950 last_mser_events = total_completions;
5951 }
5952 }
5953
5954 // Close every open integral at the final instant.
5955 for (std::size_t i = 0; i < M; ++i) {
5956 if (S[i].ps) ps_advance(i);
5957 for (std::size_t r = 0; r < K; ++r) {
5958 acc.update_qlen(i, r, now);
5959 acc.update_busy(i, r, now);
5960 }
5961 }
5962
5963 bp.commit();
5964 const Truncation tr = obs.truncate();
5965 const double sim_time = now - tr.warmup_end;
5966 const double elapsed = tr.applied ? (now - obs.time[tr.index]) : sim_time;
5967
5968 // ---- result -------------------------------------------------------------
5969 LdesResult res;
5970 res.nstations = M;
5971 res.nclasses = K;
5972 res.nchains = sn.nchains;
5973 for (std::size_t i = 0; i < M; ++i) res.station_names.push_back(sn.stations[i].name);
5974 for (std::size_t r = 0; r < K; ++r) res.class_names.push_back(sn.classes[r].name);
5975 res.QN = Matrix<double>(M, K, 0.0);
5976 res.UN = Matrix<double>(M, K, 0.0);
5977 res.RN = Matrix<double>(M, K, 0.0);
5978 res.TN = Matrix<double>(M, K, 0.0);
5979 res.CN = Matrix<double>(1, K, 0.0);
5980 res.XN = Matrix<double>(1, K, 0.0);
5981 res.AN = Matrix<double>(M, K, 0.0);
5982 res.WN = Matrix<double>(M, K, 0.0);
5983
5984 for (std::size_t i = 0; i < M; ++i) {
5985 for (std::size_t r = 0; r < K; ++r) {
5986 if (S[i].role == Role::Source) {
5987 res.TN(i, r) = lambda[r];
5988 continue;
5989 }
5990 if (S[i].off[r]) continue;
5991 if (elapsed > 0.0) {
5992 const double q0 = tr.applied ? obs.qt[i][r][tr.index] : 0.0;
5993 const double b0 = tr.applied ? obs.bt[i][r][tr.index] : 0.0;
5994 const double c0 = tr.applied ? obs.cmp[i][r][tr.index] : 0.0;
5995 res.QN(i, r) = (acc.tot_qlen[i][r] - q0) / elapsed;
5996 res.TN(i, r) = (acc.completed[i][r] - c0) / elapsed;
5997 if (S[i].role == Role::Delay) {
5998 // An infinite server has no capacity to be busy against: the
5999 // reference reports the traffic intensity T/mu, which is what
6000 // SolverNC and SolverMVA return for a Delay. A GLOBAL
6001 // DEPENDENCE RE-TIMES THE THINK TIME TOO, so its declared
6002 // peak normalizes this row as well -- `solver_ctmc_analyzer`
6003 // runs the T/(mu*peak) division over EVERY station once a
6004 // handle is present, and there is no other peak a delay can
6005 // carry.
6006 res.UN(i, r) = res.TN(i, r) * S[i].class_mean[r] / S[i].gd_peak[r];
6007 } else if (S[i].has_cd || has_gd) {
6008 // A CLASS-, JOINT- OR GLOBALLY DEPENDENT STATION IS READ
6009 // THROUGH THE LAW, not the busy integral, which is what the
6010 // Java engine does (`getUtilization` returns
6011 // T/(mu*peakScaling) here). beta_r(n) and phi(n) scale the
6012 // RATE, so a busy server delivers beta*phi times the work;
6013 // the head-count integral would report the fraction of TIME
6014 // occupied and divide it by a peak that counts work, mixing
6015 // the two conventions in one number. The load-dependent case
6016 // keeps the integral because `Accum::busy_scale` already
6017 // weights it by alpha(n).
6018 //
6019 // THE PEAKS MULTIPLY, and the server count drops out as soon
6020 // as any of them is declared: that is `getPeakScaling` and
6021 // `solver_ssa_analyzer_serial`, which take the product of
6022 // whichever peaks the model declares. With only a global
6023 // dependence the peak is phi's alone; with a class or load
6024 // dependence beside it, `util_peak` already holds that
6025 // factor.
6026 const double base =
6027 (S[i].has_cd || !S[i].lld.empty()) ? S[i].util_peak : 1.0;
6028 const double peak = base * S[i].gd_peak[r];
6029 res.UN(i, r) =
6030 (peak > 0.0) ? res.TN(i, r) * S[i].class_mean[r] / peak : 0.0;
6031 } else {
6032 res.UN(i, r) = (acc.tot_busy[i][r] - b0) / (elapsed * S[i].util_peak);
6033 }
6034 }
6035 if (acc.resp_cnt[i][r] > 0.0) {
6036 res.RN(i, r) = acc.resp_sum[i][r] / acc.resp_cnt[i][r];
6037 } else if (sn.stations[i].nodetype == NodeType::Place && res.TN(i, r) > 0.0) {
6038 // A PLACE MEASURES NO PER-TOKEN SOJOURN, so its response time is
6039 // read off Little's law, E[N]/X, which is what the reference
6040 // engine reports for a place and what JMT calls a place's
6041 // residence time. There is no per-token stamp to average: a
6042 // token is not a job passing through a service station, and at a
6043 // queueing place it may sit in the depository for as long as the
6044 // output transitions leave it there.
6045 res.RN(i, r) = res.QN(i, r) / res.TN(i, r);
6046 }
6047 // AN is the OFFERED rate, arrivedCustomers / simTime, as
6048 // `getArrivalRate` measures it; WN is the residence time, which for
6049 // this engine's single-visit accounting is the response time. Both
6050 // are part of the result contract every client reads, and leaving
6051 // them at zero reads as "no arrivals" rather than "not measured".
6052 if (elapsed > 0.0) res.AN(i, r) = acc.arrived[i][r] / elapsed;
6053 res.WN(i, r) = res.RN(i, r);
6054 }
6055 }
6056 // Sibling drops at a Join, on the Join rows only. Emitted whenever the model
6057 // HAS a fork-join, drops or none: a MEASURED zero is what a standard join
6058 // loses, and it is a better answer than the client's derived AN - K*TN,
6059 // which carries the finite-sample gap between the two rates. An absent
6060 // matrix means "this engine does not count", not "nothing was lost".
6061 if (!sn.fj.empty()) {
6062 res.DropRateJoin = Matrix<double>(M, K, 0.0);
6063 for (std::size_t i = 0; i < M; ++i)
6064 for (std::size_t r = 0; r < K; ++r) {
6065 const double d0 = tr.applied ? obs.drp[i][r][tr.index] : 0.0;
6066 if (elapsed > 0.0)
6067 res.DropRateJoin(i, r) = (acc.join_dropped[i][r] - d0) / elapsed;
6068 }
6069 }
6070 for (std::size_t r = 0; r < K; ++r) {
6071 if (sim_time > 0.0) res.XN(0, r) = sys_completed[r] / sim_time;
6072 if (sys_resp_cnt[r] > 0.0) res.CN(0, r) = sys_resp_sum[r] / sys_resp_cnt[r];
6073 // A PETRI NET COMPLETES NOTHING AT A SINK AND CROSSES NO REFERENCE
6074 // STATION, because its tokens move by the firing rules and never pass
6075 // through `deliver`, so `sys_completed` stays at zero and the system
6076 // throughput of a closed net would be reported as none at all. It is the
6077 // token rate out of the class's reference PLACE, which is that row of TN
6078 // and is what the reference engine reports. CN is left alone: a token
6079 // carries no arrival stamp -- the marking is a count, not a queue of
6080 // identified jobs -- so there is no cycle time to average, and the
6081 // reference reports none either.
6082 const std::size_t rs = sn.classes[r].refstat;
6083 if (res.XN(0, r) == 0.0 && rs != 0 && rs <= M &&
6084 sn.stations[rs - 1].nodetype == NodeType::Place)
6085 res.XN(0, r) = res.TN(rs - 1, r);
6086 }
6087
6088 // ---- busy periods --------------------------------------------------------
6089 for (std::size_t ti = 0; ti < bp.targets().size(); ++ti) {
6091 out.name = bp.targets()[ti].name;
6092 out.stations = bp.targets()[ti].stations;
6093 out.job_class = bp.targets()[ti].job_class;
6094 for (std::size_t oi = 0; oi < static_cast<std::size_t>(bp.orders()); ++oi) {
6095 out.mean.push_back(bp.mean(ti, oi));
6096 out.count.push_back(bp.count(ti, oi));
6097 }
6098 res.busy_periods.push_back(out);
6099 }
6100
6101 // ---- transient trajectory and histogram -----------------------------------
6102 if (transient_run) {
6103 now = std::min(now, horizon);
6104 hist_accumulate();
6105 res.t = tran_times;
6106 res.QNt.assign(M, std::vector<Matrix<double>>(K));
6107 res.UNt.assign(M, std::vector<Matrix<double>>(K));
6108 res.TNt.assign(M, std::vector<Matrix<double>>(K));
6109 for (std::size_t i = 0; i < M; ++i)
6110 for (std::size_t r = 0; r < K; ++r) {
6111 const std::size_t n = tran_times.size();
6112 // Columns are [value, time], which is the layout the reference
6113 // emits and the clients read.
6114 Matrix<double> q(n, 2, 0.0), u(n, 2, 0.0), t(n, 2, 0.0);
6115 for (std::size_t k = 0; k < n; ++k) {
6116 q(k, 0) = tran_q[i][r][k];
6117 q(k, 1) = tran_times[k];
6118 u(k, 0) = tran_u[i][r][k];
6119 u(k, 1) = tran_times[k];
6120 t(k, 0) = tran_t[i][r][k];
6121 t(k, 1) = tran_times[k];
6122 }
6123 res.QNt[i][r] = q;
6124 res.UNt[i][r] = u;
6125 res.TNt[i][r] = t;
6126 }
6127 res.stopping_reason = "max_time";
6128 }
6129
6130 if (want_respt) {
6131 res.respTimeSamples.assign(M, std::vector<std::vector<double>>(K));
6132 for (std::size_t i = 0; i < M; ++i)
6133 for (std::size_t r = 0; r < K; ++r) res.respTimeSamples[i][r] = resp_samples[i][r];
6134 }
6135
6136 // ---- the joint-state histogram and the state path -------------------------
6137 // OUTSIDE the transient branch: a steady-state run that asked for either
6138 // fills it too, which is what `--export-histogram` and `--trajectory` mean.
6139 // The last interval is closed here, or the state the run ended in would
6140 // carry no residence time at all.
6141 if (want_hist && !transient_run) hist_accumulate();
6142 if (!histogram.empty()) {
6143 res.histogram_space = Matrix<double>(histogram.size(), M * K, 0.0);
6144 res.histogram_time = Matrix<double>(histogram.size(), 1, 0.0);
6145 std::size_t row = 0;
6146 for (const auto& kv : histogram) {
6147 for (std::size_t c = 0; c < kv.first.size(); ++c)
6148 res.histogram_space(row, c) = kv.first[c];
6149 res.histogram_time(row, 0) = kv.second;
6150 ++row;
6151 }
6152 }
6153 if (!trajectory.empty()) {
6154 res.traj_space = Matrix<double>(trajectory.size(), M * K, 0.0);
6155 res.traj_time = Matrix<double>(trajectory.size(), 1, 0.0);
6156 for (std::size_t k = 0; k < trajectory.size(); ++k) {
6157 for (std::size_t c = 0; c < trajectory[k].second.size(); ++c)
6158 res.traj_space(k, c) = trajectory[k].second[c];
6159 res.traj_time(k, 0) = trajectory[k].first;
6160 }
6161 }
6162
6163 // ---- cache metrics --------------------------------------------------------
6164 for (const auto& kv : caches) {
6165 const CacheState& cs = kv.second;
6167 cm.hit = Matrix<double>(1, K, 0.0);
6168 cm.miss = Matrix<double>(1, K, 0.0);
6169 // A DELAYED HIT IS ITS OWN OUTCOME, so it enters the denominator beside
6170 // the other two: with a retrieval system the three fractions partition
6171 // the reads, and dividing by hits + misses alone would report fractions
6172 // that sum above one.
6173 if (cs.has_retrieval) cm.delayed = Matrix<double>(1, K, 0.0);
6174 for (std::size_t r = 0; r < K; ++r) {
6175 const double dl = cs.has_retrieval ? cs.delayed[r] : 0.0;
6176 const double tot = cs.hits[r] + cs.misses[r] + dl;
6177 if (tot > 0.0) {
6178 cm.hit(0, r) = cs.hits[r] / tot;
6179 cm.miss(0, r) = cs.misses[r] / tot;
6180 if (cs.has_retrieval) cm.delayed(0, r) = dl / tot;
6181 }
6182 }
6183 // The measured retrieval latency: the mean time a request WAITED on the
6184 // retrieval system, averaged over BOTH populations that wait -- the one
6185 // that triggered the fetch and every request parked behind it. A parked
6186 // request waits the RESIDUAL of the fetch period, not the whole of it,
6187 // so averaging the fetch sojourns alone answers a different question:
6188 // it agrees only where the fetch period is memoryless, and reads 6.5%
6189 // high on `retrieval_chain`, whose fetch is a two-stage hypoexponential.
6190 //
6191 // BOTH POPULATIONS IS WHAT MAKES THE PAIR (ArvR, ResidT) MEAN ANYTHING.
6192 // `getAvgCacheTable` reports ArvR = lambda*(miss + delayed), the rate
6193 // INTO the retrieval system, so the residence time beside it must be
6194 // Little-consistent with that rate: E[N] = miss*W_fetch + delayed*W_parked
6195 // over the same arrivals. That is also what the analytic reference
6196 // computes -- `retrieval_fpi_latency`'s Z = sum(phi + d) / sum(lambda *
6197 // (phi + pi0)) is Little's law over the same two populations -- and what
6198 // `Solver_ssj.exportCacheResults` measures. Reported only where measured.
6199 const double released = std::accumulate(cs.delayed.begin(), cs.delayed.end(), 0.0);
6200 if (cs.has_retrieval && cs.completed_fetches + released > 0.0) {
6201 cm.latency = Matrix<double>(1, K, 0.0);
6202 const double mean_wait = (cs.total_fetch_time + cs.delayed_wait)
6203 / (cs.completed_fetches + released);
6204 for (std::size_t r = 0; r < K; ++r)
6205 cm.latency(0, r) = (cs.hits[r] + cs.misses[r] + cs.delayed[r] > 0.0)
6206 ? mean_wait
6207 : std::numeric_limits<double>::quiet_NaN();
6208 }
6209 res.cache_metrics[sn.nodes[cs.node - 1].name] = cm;
6210 }
6211
6212 // ---- finite capacity regions ---------------------------------------------
6213 if (!regions.empty()) {
6214 const std::size_t NR = regions.size();
6215 res.nregions = NR;
6216 res.QNfcr = Matrix<double>(NR, K, 0.0);
6217 res.TNfcr = Matrix<double>(NR, K, 0.0);
6218 res.WeightNfcr = Matrix<double>(NR, K, 0.0);
6219 res.MemOccNfcr = Matrix<double>(NR, K, 0.0);
6220 res.DropRateNfcr = Matrix<double>(NR, K, 0.0);
6221 for (std::size_t g = 0; g < NR; ++g) {
6222 regions[g].update(now);
6223 for (std::size_t r = 0; r < K; ++r) {
6224 if (sim_time > 0.0) {
6225 // JMT's convention: the region's queue length counts only
6226 // the jobs INSIDE it. A WAITQ job parked outside has not
6227 // entered and is not part of the region's occupancy.
6228 res.QNfcr(g, r) = regions[g].tot_jobs[r] / sim_time;
6229 res.WeightNfcr(g, r) = regions[g].tot_weight[r] / sim_time;
6230 res.MemOccNfcr(g, r) = regions[g].tot_mem[r] / sim_time;
6231 res.TNfcr(g, r) = regions[g].completed[r] / sim_time;
6232 res.DropRateNfcr(g, r) = regions[g].dropped[r] / sim_time;
6233 }
6234 }
6235 }
6236 }
6237
6238 // ---- confidence intervals ------------------------------------------------
6239 // The event count is set BEFORE the intervals, not after: `batch_means_ci`
6240 // plans a run length from it when the caller asked for one, and reading a
6241 // zero there would silently fall back to the BUDGET, which an early
6242 // convergence stop never spent.
6243 res.total_simulated_events = static_cast<long long>(total_completions);
6244 batch_means_ci(obs, tr, o, res);
6245
6246 // The impatience block is reported only when the model declares one: an
6247 // all-zero matrix would read as "measured, and none happened".
6248 double any_balk = 0.0, any_renege = 0.0;
6249 for (std::size_t i = 0; i < M; ++i)
6250 for (std::size_t r = 0; r < K; ++r) {
6251 any_balk += balked[i][r];
6252 any_renege += reneged[i][r];
6253 }
6254 double any_orbit = 0.0;
6255 for (std::size_t i = 0; i < M; ++i)
6256 for (std::size_t r = 0; r < K; ++r) any_orbit += S[i].retried[r];
6257 if (any_orbit > 0.0) {
6258 res.retriedCustomers = Matrix<double>(M, K, 0.0);
6259 res.retrialDropped = Matrix<double>(M, K, 0.0);
6260 res.avgOrbitSize = Matrix<double>(M, K, 0.0);
6261 for (std::size_t i = 0; i < M; ++i) {
6262 const double dt = now - S[i].orbit_last;
6263 for (std::size_t r = 0; r < K; ++r) {
6264 double tot = S[i].tot_orbit[r];
6265 if (dt > 0.0) tot += S[i].orbit_size[r] * dt;
6266 res.retriedCustomers(i, r) = S[i].retried[r];
6267 res.retrialDropped(i, r) = S[i].retrial_lost[r];
6268 if (sim_time > 0.0) res.avgOrbitSize(i, r) = tot / sim_time;
6269 }
6270 }
6271 }
6272
6273 if (any_balk > 0.0 || any_renege > 0.0) {
6274 res.balkedCustomers = Matrix<double>(M, K, 0.0);
6275 res.renegedCustomers = Matrix<double>(M, K, 0.0);
6276 res.renegingRate = Matrix<double>(M, K, 0.0);
6277 for (std::size_t i = 0; i < M; ++i)
6278 for (std::size_t r = 0; r < K; ++r) {
6279 res.balkedCustomers(i, r) = balked[i][r];
6280 res.renegedCustomers(i, r) = reneged[i][r];
6281 if (sim_time > 0.0) res.renegingRate(i, r) = reneged[i][r] / sim_time;
6282 }
6283 }
6284
6285 res.converged = converged;
6286 res.convergence_batches = cnvg.batches();
6287 if (!transient_run) res.stopping_reason = converged ? "convergence" : "max_events";
6288 res.engine = "native";
6289 res.method = o.method;
6290 return res;
6291}
6292
6293/**
6294 * Simulate `sn`, over independent REPLICATIONS when `options.replications > 1`.
6295 *
6296 * Each replication is one path with its OWN seed, `seed + r`, exactly as
6297 * `Solver_ldes_analyzer_parallel` derives them. That is what makes the paths
6298 * independent while keeping the whole run reproducible; sharing a seed would
6299 * make every replication identical and collapse the interval to zero, and
6300 * drawing them randomly would make the run irreproducible.
6301 *
6302 * THE INTERVAL COMES FROM THE SPREAD BETWEEN REPLICATIONS, not from within a
6303 * path, and it is the honest one: independent paths need no assumption about
6304 * the correlation structure that batch means has to model. The cost is that
6305 * `replications - 1` degrees of freedom is few, so the t critical value is
6306 * large and the interval wide -- which is the estimate, not a defect of it.
6307 *
6308 * A single replication yields NO interval rather than a zero-width one: with
6309 * one path there is no spread to measure, and reporting zero would read as
6310 * perfect precision.
6311 */
6312template <class T>
6314 using namespace engine;
6315 const int R = o.replications;
6316 if (R <= 1) return ldes_engine_solve_one(sn, o);
6317
6318 std::vector<LdesResult> reps;
6319 reps.reserve(static_cast<std::size_t>(R));
6320 for (int r = 0; r < R; ++r) {
6321 LdesOptions ro = o;
6322 ro.replications = 0;
6323 // A replication measures its OWN interval nowhere: the spread across
6324 // paths is the estimate, so the within-path machinery is switched off.
6325 ro.cimethod = "none";
6326 ro.seed = (o.seed >= 0) ? (o.seed + r) : o.seed;
6327 reps.push_back(ldes_engine_solve_one(sn, ro));
6328 }
6329
6330 LdesResult res = reps[0];
6331 res.QNCI = Matrix<double>();
6332 res.UNCI = Matrix<double>();
6333 res.RNCI = Matrix<double>();
6334 res.TNCI = Matrix<double>();
6335
6336 const std::size_t M = res.nstations, K = res.nclasses;
6337 Matrix<double>* means[4] = {&res.QN, &res.UN, &res.RN, &res.TN};
6338 Matrix<double>* cis[4] = {&res.QNCI, &res.UNCI, &res.RNCI, &res.TNCI};
6339 const Matrix<double>* per_rep[4];
6340
6341 for (int q = 0; q < 4; ++q) {
6342 *means[q] = Matrix<double>(M, K, 0.0);
6343 *cis[q] = Matrix<double>(M, K, 0.0);
6344 }
6345 const double tcrit = t_critical(o.confint > 0.0 ? o.confint : 0.95, R - 1);
6346
6347 for (std::size_t i = 0; i < M; ++i)
6348 for (std::size_t k = 0; k < K; ++k)
6349 for (int q = 0; q < 4; ++q) {
6350 double sum = 0.0;
6351 for (int r = 0; r < R; ++r) {
6352 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
6353 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
6354 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
6355 : &reps[static_cast<std::size_t>(r)].TN;
6356 sum += (*per_rep[q])(i, k);
6357 }
6358 const double mean = sum / R;
6359 (*means[q])(i, k) = mean;
6360 double ss = 0.0;
6361 for (int r = 0; r < R; ++r) {
6362 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
6363 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
6364 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
6365 : &reps[static_cast<std::size_t>(r)].TN;
6366 const double d = (*per_rep[q])(i, k) - mean;
6367 ss += d * d;
6368 }
6369 (*cis[q])(i, k) = tcrit * std::sqrt((ss / (R - 1)) / R);
6370 }
6371
6372 // The system-level rows average the same way; they carry no interval,
6373 // matching what the single-path engine reports for them.
6374 res.XN = Matrix<double>(1, K, 0.0);
6375 res.CN = Matrix<double>(1, K, 0.0);
6376 for (std::size_t k = 0; k < K; ++k) {
6377 double sx = 0.0, sc = 0.0;
6378 for (int r = 0; r < R; ++r) {
6379 sx += reps[static_cast<std::size_t>(r)].XN(0, k);
6380 sc += reps[static_cast<std::size_t>(r)].CN(0, k);
6381 }
6382 res.XN(0, k) = sx / R;
6383 res.CN(0, k) = sc / R;
6384 }
6385
6386 long long total = 0;
6387 for (int r = 0; r < R; ++r) total += reps[static_cast<std::size_t>(r)].total_simulated_events;
6388 res.total_simulated_events = total;
6389 return res;
6390}
6391
6392} // namespace ldes
6393} // namespace line
6394
6395#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:39
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:51
One variate generator, holding whatever state its family needs.
A network plus its refreshed NetworkStruct.
The exception types the port throws.
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:181
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:426
@ IMMEDIATE
fires with zero delay, resolved by weight and priority
Definition lang_types.h:365
SignalType
G-network signal classes, with the values of MATLAB SignalType.
Definition lang_types.h:167
@ REPLY
completes a synchronous call, releasing a held server
Definition lang_types.h:168
@ NEGATIVE
removes a batch of jobs (Gelenbe's negative customer)
Definition lang_types.h:169
@ CATASTROPHE
removes EVERY job at the station
Definition lang_types.h:170
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:708
@ SDR
Krzesinski (1987) product-form state-dependent routing.
Definition lang_types.h:400
DepartureDiscipline
When a Place releases a served token, MATLAB DepartureDiscipline.
Definition lang_types.h:460
RemovalPolicy
Which job a negative signal removes, with the values of MATLAB RemovalPolicy.
Definition lang_types.h:174
@ FCFS
the oldest waiting job; servers only once nobody waits
Definition lang_types.h:176
@ LCFS
the newest waiting job; servers only once nobody waits
Definition lang_types.h:177
@ RANDOM
uniform over waiting AND in-service jobs
Definition lang_types.h:175
HeteroSchedPolicy
How a heterogeneous station picks among its server types, MATLAB HeteroSchedPolicy.
Definition lang_types.h:453
PollingType
Polling service disciplines, with the values of MATLAB PollingType.
Definition lang_types.h:372
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:375
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:374
@ GATED
serve exactly the jobs present at the polling instant
Definition lang_types.h:373
@ DECREMENTING
serve until the queue is one shorter than at arrival
Definition lang_types.h:376
const char * node_type_to_text(NodeType t)
Name of a node kind, for diagnostics.
Definition lang_types.h:343
ProcessType
Distribution kinds, with the values of MATLAB ProcessType.
Definition lang_types.h:485
@ NHPP
The time-INHOMOGENEOUS families of Ko and Pender (ORL 45, 2017): an NHPP is a rate schedule lambda(t)...
Definition lang_types.h:538
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
const char * process_to_text(ProcessType p)
The MATLAB ProcessType name, as sn.procid prints it.
Definition lang_types.h:596
std::function< std::vector< T >(const std::vector< T > &)> CdScaling
A class-dependent scaling map, sn.cdscaling.
Definition lang_types.h:731
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:326
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 ...
double spn_rate(const SpnMode &m, const std::vector< double > &tok)
The rate of a timed mode under the current marking.
Definition ldes_spn.h:88
void batch_means_ci(const Observations &obs, const Truncation &tr, const LdesOptions &o, LdesResult &res)
Fill the half-width matrices of res from the post-warmup observation series.
Definition ldes_stats.h:657
void ldes_engine_reject(const qn::NetworkStruct< T > &sn, const LdesOptions &o)
Refuse, by name, every model outside this engine's scope.
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.
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:109
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:137
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.
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:78
std::size_t preemption_victim(lang::SchedStrategy sched, const std::vector< Job > &in_service, const Job &arriving, double c, double now)
Which job in service arriving displaces, or in_service.size() for none.
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 ...
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
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.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Per-cache hit/miss/latency, as the cacheMetrics block carries them.
Matrix< double > hit
(1 x nclasses) hit probability
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
std::size_t samples
-s, service-completion budget
One measured busy period target (–busyperiod): a station, a station-class pair, or a declared station...
One ldes-result document, parsed.
Matrix< double > TNCI
Matrix< double > XN
(1 x nclasses), per-class visits and system tput
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 > RNCI
Matrix< double > QN
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< 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
bool setup_running
a setup is in progress
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< 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 job refused admission joins an ORBIT and tries again later, instead of being dropped or qu...
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< 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.
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.
Variable forking levels, the twin of MATLAB sn.nodeparam{f}.fanOutLink / .fanOutProb / ....
std::vector< std::vector< lang::Distrib< T > > > fan_out_dist
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