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