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 <functional>
95#include <limits>
96#include <map>
97#include <numeric>
98#include <queue>
99#include <random>
100#include <string>
101#include <vector>
102
112#include "line/util/error.h"
113#include "line/util/matrix.h"
114
115namespace line {
116namespace ldes {
117namespace engine {
118
119/** Station roles this engine simulates. */
120/**
121 * Station roles this engine simulates.
122 *
123 * A Fork and a Join appear in `sn.stations` because the refresh registers
124 * them, but they SERVE NOTHING: they are handled by the routing layer, which
125 * replicates at one and synchronizes at the other. Giving them a role of their
126 * own keeps them out of the service-station setup, which would otherwise
127 * demand a service law they do not have.
128 */
130
131/**
132 * Event tags, ordered so that a service completion precedes an arrival
133 * scheduled for the same instant.
134 *
135 * The order matters on any model whose rates produce exact ties (a Det
136 * service, a closed class placed at t=0), and it is the completions-then-
137 * arrivals phase order of the Java engine's `slotPhase`. Ties inside one tag
138 * break on the insertion sequence, so the event list is a total order and the
139 * path is reproducible from the seed.
140 */
151
152/** One scheduled event. */
153struct Event {
154 double t = 0.0;
156 std::size_t station = 0;
157 std::size_t cls = 0;
158 std::size_t slot = 0; ///< server slot at a buffered station
159 /**
160 * Identifies the job the event was scheduled for.
161 *
162 * A preempted or resharing job leaves a departure event behind that the
163 * event list gives no way to cancel, so every completion carries the tag
164 * its job held when it was scheduled and is DISCARDED if the two no longer
165 * agree. Without it a preempted job would depart twice: once on its stale
166 * event and once on the one its resumption schedules.
167 */
168 std::uint64_t tag = 0;
169 Job job; ///< Delay and PS carry the job in the event
170 std::uint64_t seq = 0;
171};
172
173/** Later-is-greater, so the priority queue pops the earliest event. */
175 bool operator()(const Event& a, const Event& b) const {
176 if (a.t != b.t) return a.t > b.t;
177 if (a.kind != b.kind) return a.kind > b.kind;
178 return a.seq > b.seq;
179 }
180};
181
182/** One routing alternative out of a (node, class) pair. */
184 std::size_t node = 0; ///< 1-based destination NODE
185 std::size_t station = 0; ///< its station index, or M when it is not one
186 std::size_t cls = 0;
187 bool sink = false;
188 double cum = 0.0;
189};
190
191/**
192 * One destination NODE of a (node, class) pair, with the class it switches to.
193 *
194 * THE STRATEGY CHOOSES THE NODE AND THE SWITCH ROW CHOOSES THE CLASS, in that
195 * order, which is `selectDestinationWithClassSwitch` and the reason the two are
196 * separated at all. A strategy applied to (node, class) PAIRS would resample
197 * the class as well -- uniformly under RAND, in turn under RROBIN -- and
198 * destroy the class-switch probabilities the pair was carrying.
199 *
200 * The list is built in ASCENDING NODE INDEX because a round-robin pointer walks
201 * it in order: any other order is a different, and equally arbitrary, cycle.
202 */
203struct RouteDest {
204 std::size_t node = 0; ///< 1-based destination NODE
205 std::size_t station = 0; ///< its station index, or M when it is not one
206 bool sink = false;
207 double mass = 0.0; ///< total probability into this node, over classes
208 std::vector<std::pair<std::size_t, double>> cls; ///< (class, probability)
209};
210
211/**
212 * Refuse, by name, every model outside this engine's scope.
213 *
214 * The refusal is the point. A PS station served FCFS, a preemptive-resume
215 * station served non-preemptively, or a blocking model run without blocking
216 * all finish and report plausible numbers; nothing downstream can tell them
217 * from correct ones, so the check has to happen here, before the run.
218 */
219template <class T>
221 using lang::NodeType;
223
224 for (const auto& nd : sn.nodes) {
225 switch (nd.nodetype) {
226 case NodeType::Source:
227 case NodeType::Sink:
228 case NodeType::Queue:
229 case NodeType::Delay:
230 case NodeType::Router:
231 case NodeType::ClassSwitch:
232 case NodeType::Logger:
233 case NodeType::Fork:
234 case NodeType::Join:
235 case NodeType::Cache:
236 case NodeType::Place:
237 case NodeType::Transition:
238 break;
239 default:
240 throw UnsupportedError(
241 std::string("SolverLDES (native engine): node kind ") +
242 lang::node_type_to_text(nd.nodetype) +
243 " is not ported yet; use the subprocess client");
244 }
245 }
246 // HETEROGENEOUS SERVER POOLS are simulated here as of 2026-09-04: each job
247 // occupies one server of one compatible pool and is served at that pool's
248 // own law, which is what makes a pooled model a different system from the
249 // multiserver it would otherwise look like. What is NOT ported is the pools
250 // under a SHARING discipline, where the reference has no per-pool treatment
251 // either -- `calculatePSRates` never consults the server types, so the JAR
252 // silently flattens them. Rather than reproduce that, the sharing case is
253 // refused by name; the max-min fair allocation it would need is the layered
254 // engine's `SnCompatShare`, and neither engine carries it for a flat model.
255 //
256 // A FIFO depository is still understood by no engine at all.
257 for (std::size_t si = 0; si < sn.stations.size(); ++si) {
258 const auto& st = sn.stations[si];
259 if (!st.server_types.empty()) {
260 const SchedStrategy sc = st.sched;
261 const bool sharing = (sc == SchedStrategy::PS || sc == SchedStrategy::DPS ||
262 sc == SchedStrategy::GPS || sc == SchedStrategy::LPS ||
263 sc == SchedStrategy::PSPRIO || sc == SchedStrategy::DPSPRIO ||
264 sc == SchedStrategy::GPSPRIO);
265 if (sharing)
266 throw UnsupportedError(
267 std::string("SolverLDES (native engine): station '") + st.name +
268 "' declares heterogeneous server pools under a sharing discipline, which "
269 "no engine allocates per pool; use a queueing discipline, or the "
270 "subprocess client, which flattens them");
271 if (sc == SchedStrategy::POLLING || sc == SchedStrategy::PAS)
272 throw UnsupportedError(
273 std::string("SolverLDES (native engine): station '") + st.name +
274 "' declares heterogeneous server pools under " +
275 std::string(lang::sched_to_text(sc)) +
276 ", whose controller picks the server itself and has no pool to pick from");
277 }
278 for (lang::DepartureDiscipline d : st.departure_discipline)
280 throw UnsupportedError(
281 std::string("SolverLDES (native engine): station '") + st.name +
282 "' sets a non-NORMAL depository departure discipline, which no "
283 "engine implements");
284 }
285 for (std::size_t i = 0; i < sn.nstations; ++i) {
286 const auto& st = sn.stations[i];
287 if (st.nodetype == NodeType::Source) continue;
288 switch (st.sched) {
289 case SchedStrategy::INF:
290 case SchedStrategy::FCFS:
291 case SchedStrategy::LCFS:
292 case SchedStrategy::SIRO:
293 case SchedStrategy::HOL:
294 case SchedStrategy::LCFSPRIO:
295 case SchedStrategy::SJF:
296 case SchedStrategy::LJF:
297 case SchedStrategy::SEPT:
298 case SchedStrategy::LEPT:
299 case SchedStrategy::PS:
300 case SchedStrategy::DPS:
301 case SchedStrategy::GPS:
302 case SchedStrategy::PSPRIO:
303 case SchedStrategy::DPSPRIO:
304 case SchedStrategy::GPSPRIO:
305 case SchedStrategy::LPS:
306 case SchedStrategy::LCFSPR:
307 case SchedStrategy::LCFSPI:
308 case SchedStrategy::LCFSPRPRIO:
309 case SchedStrategy::LCFSPIPRIO:
310 case SchedStrategy::FCFSPR:
311 case SchedStrategy::FCFSPI:
312 case SchedStrategy::FCFSPRPRIO:
313 case SchedStrategy::FCFSPIPRIO:
314 case SchedStrategy::SRPT:
315 case SchedStrategy::SRPTPRIO:
316 case SchedStrategy::PSJF:
317 case SchedStrategy::FB:
318 case SchedStrategy::LRPT:
319 case SchedStrategy::SETF:
320 case SchedStrategy::FSP:
321 case SchedStrategy::EDD:
322 case SchedStrategy::EDF:
323 case SchedStrategy::POLLING:
324 case SchedStrategy::PAS:
325 case SchedStrategy::OI:
326 break;
327 default:
328 // EXT IS NOT A SERVICE DISCIPLINE AND IS REFUSED ON PURPOSE.
329 // `Source.m` and `Sink.m` set it as their own marker, so it
330 // never reaches a served station by any supported route; a
331 // Queue explicitly built with it is refused by the reference
332 // too, at its model layer -- `Queue.<init>` throws "Routing
333 // Strategy is not supported in JLINE" before the Java engine
334 // sees the model. Serving it as INF, which is how `npfqn_sqd`
335 // and `sn_has_product_form` group it, would be a guess at a
336 // semantics neither reference implements.
337 throw UnsupportedError(
338 "SolverLDES (native engine): station '" + st.name +
339 "' uses a scheduling this engine does not simulate (EXT is the "
340 "Source and Sink marker, not a queueing discipline)");
341 }
342 }
343 // BAS and BBS are simulated; RSRD and the retrial rules are not. Only a
344 // rule on a CAPPED station can ever fire, and the refresh gives every
345 // station a default rule including the Source, so an uncapped station is
346 // not consulted -- rejecting on the default would refuse every model.
347 for (std::size_t i = 0; i < sn.nstations; ++i) {
348 if (sn.stations[i].nodetype == NodeType::Source) continue;
349 if (!std::isfinite(sn.stations[i].cap)) continue;
350 for (std::size_t r = 0; r < sn.nclasses; ++r) {
351 if (i >= sn.droprule.size() || r >= sn.droprule[i].size()) continue;
352 const lang::DropStrategy d = sn.droprule[i][r];
355 throw UnsupportedError(
356 "SolverLDES (native engine): station '" + sn.stations[i].name +
357 "' declares a blocking rule that is not DROP, BAS or BBS");
358 }
359 }
360 // AN ARRIVAL BATCH IS STORED BUT NOT SIMULATED HERE. `Station::arrival_batch`
361 // is read by the reader and written by the writer, and BatchArrival is in
362 // this engine's declared feature set, but nothing below draws a batch size:
363 // every epoch releases one job. That answered a DIFFERENT model in silence
364 // -- an M[3]/M/2 queue came back as the M/M/2 queue at a third of the task
365 // rate -- so it is refused by name instead. The Java engine implements it,
366 // and the wrappers try the runners in order, so a refusal here routes the
367 // model to the jar rather than losing the feature.
368 for (std::size_t i = 0; i < sn.nstations; ++i) {
369 const auto& stb = sn.stations[i];
370 for (std::size_t r = 0; r < stb.arrival_batch.size(); ++r)
371 if (!stb.arrival_batch[r].disabled)
372 throw UnsupportedError(
373 "SolverLDES (native engine): station '" + stb.name +
374 "' declares an arrival batch, which this engine does not draw yet");
375 }
376 // A REPLY SIGNAL completes a SYNCHRONOUS CALL: the caller keeps its server
377 // while the callee runs and gets it back only when the reply arrives. That
378 // is simulated -- see the `pending_reply` map below -- but only where a
379 // server EXISTS to hold. A sharing station divides one server continuously
380 // among everyone present and has no slot to park, so a caller there would
381 // be served as though its call were asynchronous.
382 for (std::size_t r = 0; r < sn.nclasses; ++r) {
383 if (r >= sn.syncreply.size() || sn.syncreply[r] == 0) continue;
384 for (std::size_t i = 0; i < sn.nstations; ++i) {
385 if (sn.stations[i].nodetype == NodeType::Source) continue;
386 if (i < sn.disabled.size() && r < sn.disabled[i].size() && sn.disabled[i][r]) continue;
387 if (is_ps_family(sn.stations[i].sched) || sn.stations[i].sched == SchedStrategy::INF)
388 throw UnsupportedError(
389 "SolverLDES (native engine): class '" + sn.classes[r].name +
390 "' makes a synchronous call at station '" + sn.stations[i].name +
391 "', which has no server slot to hold across it");
392 }
393 }
394 // SLOTTED TIME EXCLUDES TWO FAMILIES, and refuses rather than approximates.
395 // NHPP integrates a cumulative intensity over CONTINUOUS time, so its
396 // inverse transform does not land on the lattice; the PS family divides the
397 // server continuously among the jobs in service, which has no discrete-time
398 // counterpart at all. Serving either on a lattice would answer a different
399 // model.
400 if (o.slotted) {
401 for (std::size_t i = 0; i < sn.nstations; ++i) {
402 if (sn.stations[i].nodetype == NodeType::Source) continue;
403 if (is_ps_family(sn.stations[i].sched))
404 throw UnsupportedError(
405 "SolverLDES (native engine): station '" + sn.stations[i].name +
406 "' shares its server continuously, which slotted mode has no "
407 "counterpart for");
408 for (std::size_t r = 0; r < sn.nclasses; ++r)
409 if (!sn.disabled[i][r] && sn.procid(i + 1, r + 1) == lang::ProcessType::NHPP)
410 throw UnsupportedError(
411 "SolverLDES (native engine): station '" + sn.stations[i].name +
412 "' carries an NHPP, whose intensity is integrated over continuous "
413 "time and does not land on the slot lattice");
414 }
415 for (std::size_t i = 0; i < sn.nstations; ++i)
416 if (sn.breakdownparam.find(i + 1) != sn.breakdownparam.end())
417 throw UnsupportedError(
418 "SolverLDES (native engine): station '" + sn.stations[i].name +
419 "' declares a server breakdown, whose failure and repair epochs are drawn on "
420 "a continuous clock and do not land on the slot lattice");
421 }
422}
423
424/** One station's mutable state. */
428 bool ps = false;
429 bool preemptive = false;
430 bool resume = false; ///< PR (continue the residual) as against PI (redraw)
431 std::size_t nservers = 1;
432 std::size_t lps_limit = 0; ///< LPS admission cap; 0 = none
433 double cap = std::numeric_limits<double>::infinity();
434 std::vector<double> classcap;
435 /**
436 * `sn.droprule`, per class, declared on the DESTINATION station.
437 *
438 * JMT's convention and the reference's: the policy belongs to the station
439 * whose capacity is limited, not to the upstream one whose server ends up
440 * blocked. A model that declared it on the source would be read as having
441 * no policy at all.
442 */
443 std::vector<lang::DropStrategy> droprule;
444 /**
445 * Jobs held OUTSIDE this station's buffer but counted in its queue length:
446 * a BAS or BBS job whose destination is full occupies the upstream server
447 * and is charged to the destination, which is where it is queueing for.
448 * Charging it upstream would let a full station report a queue below its
449 * own capacity while jobs pile up in front of it.
450 */
451 std::vector<double> blocked_at;
452 std::vector<double> weight; ///< schedparam, the DPS/GPS weights
453 std::vector<double> class_mean; ///< mean service per class, for SEPT/LEPT
454 /**
455 * `sn.lldscaling`: the RATE multiplier when n jobs are present, indexed
456 * n-1. It divides the sampled service time at an ordinary station and IS
457 * the effective server count at a sharing one -- the same table read two
458 * ways, because a share of c servers and a c-fold rate are the same thing
459 * only for PS.
460 */
461 std::vector<double> lld;
462 /** `sn.cdscaling`: beta_r(n), a per-class RATE at the station's population. */
464 bool has_cd = false;
465 /**
466 * A PASS-AND-SWAP station serves an ORDERED LIST whose total rate is a
467 * function of the whole class sequence (Dorsman and Gardner 2024).
468 *
469 * The rate of the token at position p is the INCREMENT
470 * mu(c1..cp) - mu(c1..c_{p-1}), which is what makes the station
471 * order-independent. On a completion at position p the departing job is not
472 * the one at p: the SWAP GRAPH chains forward from p to the last position
473 * reachable through it, and that job departs while the chain shifts into
474 * the hole. An empty swap graph is the plain OI station, where p itself
475 * departs.
476 */
477 bool pas = false;
478 std::function<double(const std::vector<std::size_t>&)> pas_rate;
479 std::vector<std::vector<bool>> pas_swap;
480 std::vector<Job> pas_list;
481 std::uint64_t pas_tag = 0;
482
483 /**
484 * A POLLING SERVER visits the per-class buffers in a fixed cyclic order,
485 * paying a SWITCHOVER on each leg.
486 *
487 * It advances ONE STEP AT A TIME and pays that step's switchover whether or
488 * not the buffer it reaches holds work. Skipping ahead to the next
489 * non-empty buffer for a single switchover is a different and much faster
490 * discipline: it shortens the cycle and understates the waiting time by
491 * tens of percent against Takagi's exact formulas, which assume the walking
492 * server. The one exception is a lap made entirely of zero-time legs, which
493 * would spin forever at the same instant: there the server PARKS and the
494 * next arrival restarts it.
495 *
496 * The switchover charged to a leg is the one stored against the buffer
497 * being LEFT -- that is Takagi's r_i and what `Queue.setSwitchover` means --
498 * not the one against the buffer being entered.
499 */
500 bool polling = false;
501 std::size_t poll_at = 0; ///< the buffer the server is serving or heading to
502 bool poll_switching = false;
503 bool poll_parked = false;
504 std::size_t poll_budget = 0; ///< remaining visit budget; 0 = exhausted
506 std::size_t poll_k = 1;
507 std::vector<Sampler> switchover;
508 std::vector<bool> has_switchover;
509
510 /**
511 * SETUP AND DELAY-OFF: a station that powers down when it falls idle.
512 *
513 * The server runs a four-state machine. It starts OFF; an arrival puts it
514 * in SETUP for the setup time and only then does service begin; when it
515 * empties it enters DELAYOFF and shuts down when that timer expires, so a
516 * job arriving during the delay-off window pays NOTHING and one arriving
517 * after it pays the full setup. Collapsing the two idle states makes every
518 * arrival to an idle server pay the setup, which is the model of a server
519 * with no delay-off at all.
520 */
521 bool has_setup = false;
523 bool setup_on = false; ///< the server has completed its setup
524 bool setup_running = false; ///< a setup is in progress
525 double delayoff_at = std::numeric_limits<double>::infinity(); ///< when it shuts down
526
527 /**
528 * RETRIAL: a job refused admission joins an ORBIT and tries again later,
529 * instead of being dropped or queueing.
530 *
531 * The orbit is not a buffer. Its occupants are not at the station, do not
532 * occupy its capacity and are not in its queue length; they are a separate
533 * population that re-offers itself at the retrial rate. Treating a retrial
534 * job as queued makes the station hold more than its own capacity, which is
535 * exactly what the model says it cannot.
536 */
537 std::vector<Sampler> retrial;
538 std::vector<bool> has_retrial;
539 std::vector<int> max_attempts;
540 std::vector<double> orbit_size; ///< live, per class
541 std::vector<double> tot_orbit; ///< time integral of the orbit size
542 double orbit_last = 0.0;
543 std::vector<double> retried, retrial_lost;
544
545 /** Reneging: the abandonment timer of a WAITING job, per class. */
546 std::vector<Sampler> patience;
547 std::vector<bool> has_patience;
548 /** Balking: the (min, max, probability) triples an arrival consults. */
549 struct BalkRule {
550 double min_jobs = 0.0;
551 double max_jobs = -1.0; ///< -1 = unbounded above, the wire's spelling
552 double probability = 0.0;
553 };
554 std::vector<std::vector<BalkRule>> balk;
555 std::vector<Sampler> svc;
556 std::vector<bool> off;
557
558 /**
559 * HETEROGENEOUS SERVER POOLS, empty on a station whose servers are alike.
560 *
561 * A pool is `count` servers that accept only `compatible` classes and serve
562 * them at a law of their own, so a model that declares them is a DIFFERENT
563 * system from the multiserver it would otherwise look like: flattening the
564 * pools into `nservers` answers that other system, plausibly and silently.
565 *
566 * The slots are laid out POOL BY POOL in declaration order, so slot
567 * `type_first[t] .. type_first[t] + type_count[t] - 1` belongs to pool `t`
568 * and `server_type[slot]` is the inverse. Every other part of the engine
569 * addresses a server by its slot and needs to know nothing about this.
570 */
571 bool has_pools = false;
572 std::vector<std::size_t> server_type; ///< per slot, its pool
573 std::vector<std::size_t> type_first; ///< per pool, its first slot
574 std::vector<std::size_t> type_count; ///< per pool, its server count
575 std::vector<std::vector<bool>> type_compat; ///< [pool][class]
576 std::vector<std::vector<Sampler>> type_svc; ///< [pool][class]
577 std::vector<std::vector<bool>> type_has_svc; ///< [pool][class], a law of its own
578 std::vector<std::vector<double>> type_rate; ///< [pool][class], 1/mean, for FSF
580 /** ALIS/FAIRNESS rotate this order; a used pool goes to the back. */
581 std::vector<std::size_t> type_order;
582 /** ALFS walks the pools least-flexible first: fewest compatible classes. */
583 std::vector<std::size_t> alfs_order;
584
585 /**
586 * The waiting room, as an EXPLICIT HEAP rather than a `std::priority_queue`.
587 *
588 * FSP is why. Its order is the virtual finish time of a processor-sharing
589 * schedule over the residual work PRESENT AT THE STATION, so it changes
590 * whenever the station's composition does and cannot be stamped on a job
591 * when it arrives. A container that hides its storage cannot be reordered
592 * or rescanned; this one is scanned linearly for FSP and kept as a heap
593 * for every other discipline, whose keys are per-job constants.
594 */
595 std::vector<Job> buffer;
597 std::vector<Job> server;
598 std::vector<bool> server_busy;
599 std::vector<double> server_start; ///< instant the held job entered service
600 std::vector<std::uint64_t> server_tag;
601 /**
602 * A server holding a completed job it cannot hand on, because its
603 * destination is full. It is NOT free: refusing to start a new service is
604 * the whole content of blocking, and a blocked slot treated as idle turns
605 * a blocking network into a lossless one with the same topology.
606 */
607 std::vector<bool> server_blocked;
608 /**
609 * A server HELD ACROSS A SYNCHRONOUS CALL. Its job has left for the callee
610 * and the slot is NOT free: releasing it would let the caller serve someone
611 * else while its own call is outstanding, which is the asynchronous model
612 * under the synchronous one's name. Separate from `server_blocked`, which
613 * holds a COMPLETED job whose destination is full.
614 */
615 std::vector<bool> server_held;
616 std::vector<std::size_t> held_cls;
617 std::vector<Job> blocked_job;
618 std::vector<std::size_t> blocked_dest;
619 std::vector<std::size_t> blocked_dest_cls;
620
621 // sharing disciplines
622 std::vector<PsJob> ps_jobs;
623 double ps_last_update = 0.0;
624
625 /**
626 * True when the station's service RATE depends on its own population, i.e.
627 * it declares a load- or class-dependence table.
628 *
629 * Such a station cannot schedule a departure once and forget it: the rate
630 * in force changes every time the population does, so the residual work
631 * must be integrated forward and the completion re-timed at each change --
632 * the same discipline a sharing station needs, for a different reason.
633 * Applying the scaling only at service start would time every completion
634 * at the rate that happened to hold when it began.
635 */
636 bool state_dependent = false;
637 /** max(c, max(alpha)): the peak capacity the utilization is reported against. */
638 double util_peak = 1.0;
639 /**
640 * The class-dependent speed a SHARING station is running at right now, one
641 * entry per class.
642 *
643 * It is cached rather than recomputed because `ps_advance` integrates the
644 * interval that just ENDED while the caller has already written the new
645 * population: reading the handle there would credit that interval at the
646 * speed that only starts now. `ps_reschedule` refreshes it in the same
647 * breath as it re-times the departures, which is the moment the new speed
648 * actually takes effect -- the same flush-then-install order
649 * `Accum::set_busy_scale` uses for load dependence.
650 */
651 std::vector<double> ps_cd;
652 /**
653 * Server breakdown: the server alternates up and down on two clocks.
654 *
655 * `down_scale[r]` is the RATE MULTIPLIER while down, `down_rate(r) *
656 * E[S(i,r)]`, so the whole outage is expressed as a state-dependent rate
657 * change and needs no separate machinery: a zero multiplier stalls the job
658 * with its residual intact (nothing is rescheduled, so nothing departs
659 * until repair) and a positive one is the degraded server. That is exactly
660 * the reference's semantics -- a breakdown does not evict the job in
661 * service, and `downServiceRates` degrades rather than stops.
662 */
663 bool has_breakdown = false;
664 bool up = true;
665 std::vector<double> down_scale;
666 /** The declared absolute rate, kept so the scale can be formed once E[S] is known. */
667 std::vector<double> down_rate_raw;
669 double sd_last_update = 0.0;
670};
671
672} // namespace engine
673
674/**
675 * Simulate `sn` in process and return the same record the subprocess client
676 * parses out of an `ldes-result` document.
677 *
678 * A metric the run did not measure stays EMPTY rather than becoming a matrix
679 * of zeros: zero is a measurement and absence is not.
680 */
681template <class T>
683 using namespace engine;
684 using lang::NodeType;
686
687 ldes_engine_reject(sn, o);
688
689 const std::size_t M = sn.nstations, K = sn.nclasses;
690 if (M == 0 || K == 0) throw InputError("SolverLDES (native engine): empty model");
691
692 const std::uint64_t max_events =
693 static_cast<std::uint64_t>(o.events > 0 ? o.events : o.samples);
694 if (max_events == 0)
695 throw InputError("SolverLDES (native engine): the completion budget is zero");
696
697 // ---- random streams, one band per role ---------------------------------
698 const std::uint64_t base =
699 (o.seed >= 0) ? static_cast<std::uint64_t>(o.seed) : std::random_device{}();
700 /**
701 * Snap a sampled duration onto the slot lattice, or REFUSE it.
702 *
703 * A non-lattice sample is an ERROR, not something to round: rounding
704 * silently changes the distribution, and a model whose service law is not
705 * lattice-valued is not a discrete-time model. The reference refuses for
706 * the same reason. Only Geometric, Det with an integral slot count, and
707 * DMAP produce lattice values.
708 */
709 const bool slotted = o.slotted;
710 const double slot_len = o.slot_length;
711 auto slot_snap = [&](double v, const char* what) -> double {
712 if (!slotted || v == 0.0) return v;
713 const double slots = v / slot_len;
714 const double rounded = std::floor(slots + 0.5);
715 if (rounded < 1.0 || std::fabs(slots - rounded) > 1e-9 * std::max(1.0, slots))
716 throw InputError(std::string("SolverLDES (native engine): slotted mode sampled a ") +
717 what + " of " + std::to_string(v) +
718 ", which is not a positive multiple of the slot length " +
719 std::to_string(slot_len) +
720 "; a discrete-time model needs lattice-valued interarrival and "
721 "service times, e.g. Geometric or Det on an integral slot count");
722 return rounded * slot_len;
723 };
724
725 /**
726 * ONE STREAM PER (node, class), with the reference's own offsets.
727 *
728 * `Solver_ssj` never shares a stream between two generators: it builds one
729 * MRG32k3a and one java.util.Random per (node, class) and seeds them from
730 * `seed + offset`, with
731 *
732 * arrival (source s, class k) stream (s*K + k)*10, random the same + 2000
733 * service (svc j, class k) both ((numSources + j)*K + k)*10 + 1000
734 * routing ROUTING_SEED_BASE = 900000
735 *
736 * where `numSources` counts SOURCE nodes and `j` indexes the SERVICE nodes
737 * in order, neither of which is the station index. Sharing three run-wide
738 * streams instead, as this engine did, changes the interleaving of every
739 * draw: two stations that each consume their own stream in the reference
740 * would take turns from one here, so the paths diverge from the first event
741 * even with identical generators and identical quantiles.
742 *
743 * The station-level draws that are not per-class -- a polling switchover,
744 * a setup or delay-off, an SPN transition -- sit on `g_aux[station]` in a
745 * band of their own, mirroring the reference's own
746 * `(numSources + numServiceNodes + j)` family without claiming to match it
747 * draw for draw.
748 */
749 const long long seed_ll = static_cast<long long>(base);
750 const long long Kll = static_cast<long long>(K);
751 std::size_t num_sources = 0, num_service_nodes = 0;
752 std::vector<std::size_t> svc_index(M, 0);
753 for (std::size_t i = 0; i < M; ++i) {
754 if (sn.stations[i].nodetype == NodeType::Source) {
755 ++num_sources;
756 } else {
757 svc_index[i] = num_service_nodes++;
758 }
759 }
760 const long long nsrc = static_cast<long long>(num_sources);
761 const long long nsvc = static_cast<long long>(num_service_nodes);
762
763 std::vector<Rng> g_arr; ///< [class], at the (single) source
764 g_arr.reserve(K);
765 for (std::size_t k = 0; k < K; ++k) {
766 const long long off = (static_cast<long long>(k)) * 10; // srcIdx == 0
767 g_arr.push_back(Rng(seed_ll, off, off + 2000));
768 }
769 std::vector<std::vector<Rng>> g_svc; ///< [station][class]
770 g_svc.reserve(M);
771 for (std::size_t i = 0; i < M; ++i) {
772 std::vector<Rng> row;
773 row.reserve(K);
774 for (std::size_t k = 0; k < K; ++k) {
775 const long long off =
776 ((nsrc + static_cast<long long>(svc_index[i])) * Kll + static_cast<long long>(k)) *
777 10 + 1000;
778 row.push_back(Rng(seed_ll, off));
779 }
780 g_svc.push_back(row);
781 }
782 // [station][pool][class]: a heterogeneous pool's own service stream, banded
783 // past `g_svc` so a pooled station's draws never interleave with the
784 // class-level ones and a homogeneous model's sequence is untouched.
785 std::vector<std::vector<std::vector<Rng>>> g_hsvc(M);
786 for (std::size_t i = 0; i < M; ++i) {
787 const std::size_t nT = sn.stations[i].server_types.size();
788 g_hsvc[i].resize(nT);
789 for (std::size_t t = 0; t < nT; ++t) {
790 g_hsvc[i][t].reserve(K);
791 for (std::size_t k = 0; k < K; ++k) {
792 const long long off =
793 ((nsrc + static_cast<long long>(svc_index[i])) * Kll +
794 static_cast<long long>(k)) * 10 +
795 600000 + static_cast<long long>(t) * 137;
796 g_hsvc[i][t].push_back(Rng(seed_ll, off));
797 }
798 }
799 }
800 std::vector<Rng> g_aux; ///< [station], the draws that carry no class
801 g_aux.reserve(M);
802 for (std::size_t i = 0; i < M; ++i) {
803 const long long off = ((nsrc + nsvc + static_cast<long long>(svc_index[i])) * Kll) * 10;
804 g_aux.push_back(Rng(seed_ll, off));
805 }
806 Rng g_routing(seed_ll, 900000); // ROUTING_SEED_BASE
807 // The SPN transition clocks are a model-level draw, not a station's.
808 // The reference bands the place generators at +5000; this is that band.
809 Rng g_spn(seed_ll, 5000);
810 // The fork degree and branch activation draws, on the reference's own
811 // `new Random(seed + 88888)` band. DRAWN FROM ONLY BY A FORK THAT DECLARES
812 // AN OVERRIDE: a plain fork must consume nothing here, or every seeded
813 // golden and the bit-exact agreement with the Java engine would move.
814 Rng g_fork(seed_ll, 88888);
815
816 /**
817 * True when a fork's parameters actually vary: some branch is uncertain,
818 * some link carries a count other than the scalar, or some link draws its
819 * count. A fork that declares the matrices but leaves them uniform stays on
820 * the fixed path, so it keeps drawing nothing.
821 */
822 auto fork_is_variable = [&](std::size_t node) -> bool {
823 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
824 if (fp == 0) return false;
825 const double tpl = sn.nodes[node - 1].tasks_per_link;
826 for (std::size_t k = 0; k < fp->fan_out_link.rows(); ++k)
827 for (std::size_t r = 0; r < fp->fan_out_link.cols(); ++r) {
828 const double p = fp->fan_out_prob(k, r);
829 if (p == 0.0) continue; // link not taken
830 if (p != 1.0) return true;
831 if (fp->fan_out_link(k, r) != tpl) return true;
832 if (!fp->fan_out_dist[k][r].disabled) return true;
833 }
834 return false;
835 };
836
837 /** One draw from a jobs-per-link distribution, by inverse CDF on its pmf. */
838 auto sample_fork_degree = [&](const lang::Distrib<double>& d) -> int {
839 double tot = 0.0;
840 for (std::size_t e = 0; e < d.params.size(); ++e) tot += d.params[e];
841 const double u = g_fork.aux.next_double() * tot;
842 double accp = 0.0;
843 for (std::size_t e = 0; e < d.params.size(); ++e) {
844 accp += d.params[e];
845 if (u <= accp)
846 return static_cast<int>((d.trace.empty() ? static_cast<double>(e + 1) : d.trace[e]) +
847 0.5);
848 }
849 return static_cast<int>(
850 (d.trace.empty() ? static_cast<double>(d.params.size()) : d.trace.back()) + 0.5);
851 };
852
853 // ---- stations ----------------------------------------------------------
854 std::vector<StationState> S(M);
855 std::size_t source_st = M;
856 std::vector<int> classprio(K, 0);
857 std::vector<double> classdeadline(K, std::numeric_limits<double>::infinity());
858 for (std::size_t r = 0; r < K; ++r) {
859 classprio[r] = sn.classes[r].prio;
860 classdeadline[r] = sn.classes[r].deadline;
861 }
862
863 for (std::size_t i = 0; i < M; ++i) {
864 const auto& st = sn.stations[i];
865 StationState& s = S[i];
866 s.sched = st.sched;
867 s.off.assign(K, true);
868 s.class_mean.assign(K, 0.0);
869 s.weight.assign(K, 1.0);
870 for (std::size_t r = 0; r < K; ++r)
871 if (r < st.schedparam.size()) s.weight[r] = num_traits<T>::to_double(st.schedparam[r]);
872
873 if (st.nodetype == NodeType::Source) {
874 s.role = Role::Source;
875 source_st = i;
876 } else if (st.nodetype == NodeType::Fork || st.nodetype == NodeType::Join ||
877 st.nodetype == NodeType::Place || st.nodetype == NodeType::Transition) {
878 // A Place holds tokens and a Transition moves them; neither serves,
879 // so neither belongs in the service-station setup.
880 s.role = Role::Synchronization;
881 // A JOIN STILL REPORTS. It serves nothing, so it stays out of the
882 // setup below, but it holds siblings and releases parents and the
883 // result assembly must read those rows; leaving every class OFF is
884 // what emitted it as an all-zero station. `off` gates the report
885 // here, not admission: `admit` is never reached through a Join.
886 if (st.nodetype == NodeType::Join) s.off.assign(K, false);
887 } else if (st.nodetype == NodeType::Delay || st.sched == SchedStrategy::INF) {
888 s.role = Role::Delay;
889 } else {
890 s.role = Role::Queue;
891 s.ps = is_ps_family(st.sched);
892 s.preemptive = is_preemptive(st.sched);
893 s.resume = is_preemptive_resume(st.sched);
894 const double c = st.nservers;
895 if (!(c >= 1.0) || !std::isfinite(c))
896 throw InputError("SolverLDES (native engine): station '" + st.name +
897 "' has a server count that is neither finite nor at least one");
898 s.nservers = static_cast<std::size_t>(c + 0.5);
899 s.cap = st.cap;
900 s.classcap.assign(K, std::numeric_limits<double>::infinity());
901 s.blocked_at.assign(K, 0.0);
902 s.droprule.assign(K, lang::DropStrategy::DROP);
903 for (std::size_t r = 0; r < K; ++r)
904 if (i < sn.droprule.size() && r < sn.droprule[i].size())
905 s.droprule[r] = sn.droprule[i][r];
906 for (std::size_t r = 0; r < K; ++r)
907 if (i < sn.classcap.size() && r < sn.classcap[i].size())
908 s.classcap[r] = sn.classcap[i][r];
909 if (st.sched == SchedStrategy::LPS) s.lps_limit = s.nservers;
910 }
911
912 // One entry per class, neutral until a sharing station with a class
913 // dependence refreshes it in `ps_reschedule`.
914 s.ps_cd.assign(K, 1.0);
915 for (const T& v : st.lldscaling) s.lld.push_back(num_traits<T>::to_double(v));
916 // The peak capacity that normalizes this station's utilization is
917 // max(c, max(alpha)) -- CTMC's own ceff, which is what makes the two
918 // report the same number on a load-dependent station. Without load
919 // dependence it is just c, so nothing else moves.
920 s.util_peak = static_cast<double>(s.nservers);
921 for (double a : s.lld) s.util_peak = std::max(s.util_peak, a);
922 // CLASS DEPENDENCE beta_r(n) AND JOINT DEPENDENCE eta_i(n) ENTER THE
923 // SAMPLE-PATH RATE THE SAME WAY, and a station declaring both is scaled
924 // by their PRODUCT -- `initializeClassDependence` and `State.java` both
925 // fold them that way. They stay separate on the struct because they
926 // carry different modelling claims (beta keeps the product form, eta
927 // does not), but a simulator has no use for that distinction: it needs
928 // the rate in force, which is the product.
929 if (st.cdscaling || st.jdscaling) {
930 s.has_cd = true;
931 // THE DECLARED PEAK IS THE NORMALIZER, not a peak swept out of the
932 // handle. `solver_ssa_serial` and `solver_ssa_nrm_space` take the
933 // PRODUCT of whichever declared peaks are present, and drop the
934 // server count while doing so, because the rate in force is that
935 // same product; this engine must answer with their number. A handle
936 // with no declared peak is a model defect here exactly as it is
937 // there -- an open class has no lattice to sweep, so a swept peak
938 // would be an invented bound.
939 double dep_peak = 1.0;
940 const std::vector<T>* pks[2] = {&st.cdscalingpeak, &st.jdscalingpeak};
941 const bool on[2] = {static_cast<bool>(st.cdscaling), static_cast<bool>(st.jdscaling)};
942 const char* names[2] = {"setClassDependence", "setJointDependence"};
943 for (std::size_t h = 0; h < 2; ++h) {
944 if (!on[h]) continue;
945 if (pks[h]->empty())
946 throw InputError(
947 std::string("SolverLDES: station '") + st.name +
948 "' declares a dependent scaling with no declared peak rate. Utilization "
949 "there is T*E[S]/peak, so pass the peak to " + names[h]);
950 double m = 0.0;
951 for (const T& v : *pks[h]) m = std::max(m, num_traits<T>::to_double(v));
952 if (!(m > 0.0))
953 throw InputError(std::string("SolverLDES: station '") + st.name +
954 "' declares a non-positive peak rate for " + names[h]);
955 dep_peak *= m;
956 }
957 s.util_peak = dep_peak;
958 const auto& beta = st.cdscaling;
959 const auto& eta = st.jdscaling;
960 s.cd = [beta, eta](const std::vector<double>& n) {
961 std::vector<T> nt(n.size());
962 for (std::size_t k = 0; k < n.size(); ++k) nt[k] = num_traits<T>::from_double(n[k]);
963 std::vector<double> bd, ed;
964 if (beta) {
965 const std::vector<T> b = beta(nt);
966 bd.resize(b.size());
967 for (std::size_t k = 0; k < b.size(); ++k) bd[k] = num_traits<T>::to_double(b[k]);
968 }
969 if (eta) {
970 const std::vector<T> e = eta(nt);
971 ed.resize(e.size());
972 for (std::size_t k = 0; k < e.size(); ++k) ed[k] = num_traits<T>::to_double(e[k]);
973 }
974 if (bd.empty()) return ed;
975 if (ed.empty()) return bd;
976 // Elementwise, with scalar broadcasting: either factor may be a
977 // single value shared across the classes.
978 const std::size_t n2 = std::max(bd.size(), ed.size());
979 std::vector<double> out(n2, 1.0);
980 for (std::size_t k = 0; k < n2; ++k)
981 out[k] = bd[bd.size() > 1 ? k : 0] * ed[ed.size() > 1 ? k : 0];
982 return out;
983 };
984 }
985 if (st.sched == SchedStrategy::PAS || st.sched == SchedStrategy::OI) {
986 s.pas = true;
987 auto pp = sn.pasparam.find(i + 1);
988 if (pp != sn.pasparam.end()) {
989 if (pp->second.svc_rate_fun) {
990 const auto f = pp->second.svc_rate_fun;
991 // mu(c) TAKES 1-BASED CLASS TAGS -- it is the state
992 // encoding's own convention, the one `state.h` and
993 // `after_event_station_pas` hand it and the one
994 // `oi_rate_from_json` decodes (it drops any entry below 1).
995 // This engine's `Job::cls` is 0-based, so shift on the way
996 // in: unshifted, class 0 is invisible to mu, contributes no
997 // rate increment at any position, and its jobs pile up at
998 // the station and never complete.
999 s.pas_rate = [f](const std::vector<std::size_t>& seq) {
1000 std::vector<std::size_t> tags(seq.size());
1001 for (std::size_t k = 0; k < seq.size(); ++k) tags[k] = seq[k] + 1;
1002 return num_traits<T>::to_double(f(tags));
1003 };
1004 }
1005 s.pas_swap = pp->second.swap_graph;
1006 }
1007 if (!s.pas_rate)
1008 throw InputError("SolverLDES (native engine): station '" + st.name +
1009 "' is a pass-and-swap station with no service rate function");
1010 }
1011 if (st.sched == SchedStrategy::POLLING) {
1012 s.polling = true;
1013 const auto pp = sn.effective_polling(i + 1);
1014 s.poll_type = pp.ptype;
1015 s.poll_k = (pp.pk >= 1) ? pp.pk : 1;
1016 s.poll_budget = (s.poll_type == lang::PollingType::EXHAUSTIVE)
1017 ? std::numeric_limits<std::size_t>::max()
1018 : ((s.poll_type == lang::PollingType::KLIMITED) ? s.poll_k : 1);
1019 s.poll_parked = true;
1020 s.switchover.resize(K);
1021 s.has_switchover.assign(K, false);
1022 for (std::size_t r = 0; r < K && r < pp.switchover.size(); ++r)
1023 if (!pp.switchover[r].disabled &&
1024 num_traits<T>::to_double(pp.switchover[r].mean) > 0.0) {
1025 s.switchover[r] = Sampler(pp.switchover[r],
1026 "the switchover of station '" + st.name +
1027 "', class '" + sn.classes[r].name + "'");
1028 s.has_switchover[r] = true;
1029 }
1030 }
1031 s.retrial.resize(K);
1032 s.has_retrial.assign(K, false);
1033 s.max_attempts.assign(K, 0);
1034 s.orbit_size.assign(K, 0.0);
1035 s.tot_orbit.assign(K, 0.0);
1036 s.retried.assign(K, 0.0);
1037 s.retrial_lost.assign(K, 0.0);
1038 {
1039 auto rp = sn.retrialparam.find(i + 1);
1040 if (rp != sn.retrialparam.end())
1041 for (std::size_t r = 0; r < K && r < rp->second.retrial_proc.size(); ++r)
1042 if (!rp->second.retrial_proc[r].disabled) {
1043 s.retrial[r] = Sampler(rp->second.retrial_proc[r],
1044 "the retrial process of station '" + st.name +
1045 "', class '" + sn.classes[r].name + "'");
1046 s.has_retrial[r] = true;
1047 if (r < rp->second.max_attempts.size())
1048 s.max_attempts[r] = rp->second.max_attempts[r];
1049 }
1050 }
1051 s.patience.resize(K);
1052 s.has_patience.assign(K, false);
1053 s.balk.assign(K, std::vector<StationState::BalkRule>());
1054 for (std::size_t r = 0; r < K; ++r) {
1055 if (r < st.impatience.size() && st.impatience[r] == lang::ImpatienceType::RENEGING &&
1056 r < st.patience.size() && !st.patience[r].disabled) {
1057 s.patience[r] = Sampler(st.patience[r], "the patience of station '" + st.name +
1058 "', class '" + sn.classes[r].name + "'");
1059 s.has_patience[r] = true;
1060 }
1061 if (r < st.balking.size() &&
1062 st.balking[r].strategy != lang::BalkingStrategy::NONE) {
1063 if (st.balking[r].strategy != lang::BalkingStrategy::QUEUE_LENGTH)
1064 throw UnsupportedError(
1065 "SolverLDES (native engine): station '" + st.name +
1066 "' declares a balking rule that is not QUEUE_LENGTH; the "
1067 "expected-wait rules are not ported yet");
1068 for (const auto& th : st.balking[r].thresholds) {
1069 StationState::BalkRule br;
1070 br.min_jobs = th.min_jobs;
1071 br.max_jobs = th.max_jobs;
1072 br.probability = num_traits<T>::to_double(th.probability);
1073 s.balk[r].push_back(br);
1074 }
1075 }
1076 }
1077
1078 {
1079 auto sp = sn.setupparam.find(i + 1);
1080 if (sp != sn.setupparam.end()) {
1081 lang::Distrib<T> su, doff;
1082 if (sp->second.last(su, doff) && !su.disabled) {
1083 s.has_setup = true;
1084 s.setup_time = Sampler(su, "the setup time of station '" + st.name + "'");
1085 // A ZERO delay-off is not an error, it is the absence of the
1086 // window: the server shuts down the instant it empties, so
1087 // every busy period starts cold. Only a positive one gets a
1088 // sampler; a disabled or zero entry leaves it unset.
1089 if (!doff.disabled && num_traits<T>::to_double(doff.mean) > 0.0)
1090 s.delayoff_time =
1091 Sampler(doff, "the delay-off time of station '" + st.name + "'");
1092 else
1093 s.delayoff_time = Sampler();
1094 }
1095 }
1096 }
1097 {
1098 typename std::map<std::size_t, qn::BreakdownParam<T> >::const_iterator bp =
1099 sn.breakdownparam.find(i + 1);
1100 if (bp != sn.breakdownparam.end()) {
1101 s.has_breakdown = true;
1102 s.up = true;
1103 s.failure_time =
1104 Sampler(bp->second.failure, "the failure time of station '" + st.name + "'");
1105 s.repair_time =
1106 Sampler(bp->second.repair, "the repair time of station '" + st.name + "'");
1107 s.down_scale.assign(K, 0.0);
1108 s.down_rate_raw.assign(K, 0.0);
1109 for (std::size_t r = 0; r < K && r < bp->second.down_service_rates.size(); ++r)
1110 s.down_rate_raw[r] = num_traits<T>::to_double(bp->second.down_service_rates[r]);
1111 }
1112 }
1113 // A BREAKDOWN IS A STATE-DEPENDENT RATE, so it reuses sd_advance /
1114 // sd_reschedule rather than growing a second freeze-and-retime path.
1115 s.state_dependent = (!s.lld.empty() || s.has_cd || s.has_breakdown);
1116
1117 s.svc.resize(K);
1118 if (s.role == Role::Synchronization) continue;
1119 for (std::size_t r = 0; r < K; ++r) {
1120 s.off[r] = sn.disabled[i][r];
1121 if (s.off[r]) continue;
1122 const std::string where = "station '" + st.name + "', class '" + sn.classes[r].name + "'";
1123 s.svc[r] = Sampler(sn.service[i][r], where);
1124 s.class_mean[r] = s.svc[r].mean();
1125 if (!(s.class_mean[r] > 0.0))
1126 throw InputError("SolverLDES (native engine): " + where +
1127 " has a non-positive mean service time");
1128 }
1129
1130 // ---- heterogeneous server pools ---------------------------------------
1131 //
1132 // The pools REPLACE the flat server bank rather than sitting beside it:
1133 // `nservers` becomes their total, so every slot loop below is already
1134 // over the right range and only the CHOICE of slot has to learn about
1135 // compatibility. A pool that declares no law of its own for a class
1136 // serves it at the station's class-level default, which is what the
1137 // reference's `generateHeteroServiceTime` falls back to.
1138 if (!st.server_types.empty()) {
1139 const std::size_t nT = st.server_types.size();
1140 s.has_pools = true;
1141 s.hetero_policy = st.hetero_policy;
1142 s.type_count.assign(nT, 0);
1143 s.type_first.assign(nT, 0);
1144 s.type_compat.assign(nT, std::vector<bool>(K, true));
1145 s.type_svc.assign(nT, std::vector<Sampler>(K));
1146 s.type_has_svc.assign(nT, std::vector<bool>(K, false));
1147 s.type_rate.assign(nT, std::vector<double>(K, 0.0));
1148 std::size_t total = 0;
1149 for (std::size_t t = 0; t < nT; ++t) {
1150 const typename qn::Station<T>::ServerType& pt = st.server_types[t];
1151 const double c = pt.count;
1152 if (!(c >= 1.0))
1153 throw InputError("SolverLDES (native engine): station '" + st.name +
1154 "' declares server pool '" + pt.name +
1155 "' with fewer than one server");
1156 s.type_first[t] = total;
1157 s.type_count[t] = static_cast<std::size_t>(c + 0.5);
1158 total += s.type_count[t];
1159 for (std::size_t r = 0; r < K; ++r) {
1160 // An empty compatibility row means every class, as the
1161 // struct documents; a short one is padded the same way.
1162 s.type_compat[t][r] = pt.compatible.empty() || r >= pt.compatible.size()
1163 ? true
1164 : pt.compatible[r];
1165 if (s.off[r]) s.type_compat[t][r] = false;
1166 if (r < pt.service.size() && !pt.service[r].disabled) {
1167 const std::string where = "station '" + st.name + "', pool '" + pt.name +
1168 "', class '" + sn.classes[r].name + "'";
1169 s.type_svc[t][r] = Sampler(pt.service[r], where);
1170 s.type_has_svc[t][r] = true;
1171 const double mu = s.type_svc[t][r].mean();
1172 if (!(mu > 0.0))
1173 throw InputError("SolverLDES (native engine): " + where +
1174 " has a non-positive mean service time");
1175 s.type_rate[t][r] = 1.0 / mu;
1176 } else if (!s.off[r]) {
1177 s.type_rate[t][r] =
1178 (s.class_mean[r] > 0.0) ? 1.0 / s.class_mean[r] : 0.0;
1179 }
1180 }
1181 }
1182 for (std::size_t r = 0; r < K; ++r) {
1183 if (s.off[r]) continue;
1184 bool served = false;
1185 for (std::size_t t = 0; t < nT && !served; ++t) served = s.type_compat[t][r];
1186 if (!served)
1187 throw InputError("SolverLDES (native engine): station '" + st.name +
1188 "' declares no server pool compatible with class '" +
1189 sn.classes[r].name +
1190 "', so a job of that class would wait forever");
1191 }
1192 // The pools ARE the server bank. A declared `nservers` that
1193 // disagrees is the flattened view of the same pools, and taking it
1194 // instead would serve more or fewer jobs at once than declared.
1195 s.nservers = total;
1196 s.util_peak = static_cast<double>(total);
1197 if (s.sched == lang::SchedStrategy::LPS) s.lps_limit = total;
1198 s.server_type.assign(total, 0);
1199 for (std::size_t t = 0; t < nT; ++t)
1200 for (std::size_t j = 0; j < s.type_count[t]; ++j)
1201 s.server_type[s.type_first[t] + j] = t;
1202 s.type_order.resize(nT);
1203 for (std::size_t t = 0; t < nT; ++t) s.type_order[t] = t;
1204 // ALFS: fewest compatible classes first, ties by declaration order,
1205 // so the least flexible pool is spent before the more flexible one.
1206 s.alfs_order = s.type_order;
1207 std::stable_sort(s.alfs_order.begin(), s.alfs_order.end(),
1208 [&](std::size_t a, std::size_t b) {
1209 std::size_t ca = 0, cb = 0;
1210 for (std::size_t r = 0; r < K; ++r) {
1211 if (s.type_compat[a][r]) ++ca;
1212 if (s.type_compat[b][r]) ++cb;
1213 }
1214 return ca < cb;
1215 });
1216 }
1217 // THE DEGRADED RATE BECOMES A MULTIPLIER, and it can only be formed
1218 // here, once the mean service time of each class is known: the wire
1219 // carries an ABSOLUTE rate while the engine's state-dependent path
1220 // takes a factor on the nominal one.
1221 if (s.has_breakdown) {
1222 s.down_scale.assign(K, 0.0);
1223 for (std::size_t r = 0; r < K; ++r) {
1224 if (s.off[r] || !(s.class_mean[r] > 0.0)) continue;
1225 if (r < s.down_rate_raw.size() && s.down_rate_raw[r] > 0.0)
1226 s.down_scale[r] = s.down_rate_raw[r] * s.class_mean[r];
1227 }
1228 }
1229 if (!s.ps) {
1230 s.cmp.sched = s.sched;
1231 s.cmp.class_mean = &s.class_mean;
1232 if (s.role == Role::Queue) {
1233 s.server.assign(s.nservers, Job());
1234 s.server_busy.assign(s.nservers, false);
1235 s.server_start.assign(s.nservers, 0.0);
1236 s.server_tag.assign(s.nservers, 0);
1237 s.server_blocked.assign(s.nservers, false);
1238 s.server_held.assign(s.nservers, false);
1239 s.held_cls.assign(s.nservers, 0);
1240 s.blocked_job.assign(s.nservers, Job());
1241 s.blocked_dest.assign(s.nservers, 0);
1242 s.blocked_dest_cls.assign(s.nservers, 0);
1243 }
1244 }
1245 }
1246
1247 // ---- routing, resolved once per (station, class) -----------------------
1248 // A Router, a ClassSwitch and a Logger are not stations. `Peff` is a
1249 // NODE-level matrix and keeps them, so they appear as destinations here and
1250 // `deliver` walks through them in one instant; a destination that is none of
1251 // those and none of Source/Queue/Delay/Sink/Fork/Join/Cache/Place/Transition
1252 // is a node this engine must not guess at.
1253 const std::size_t nnodes = sn.nof_nodes();
1254 std::vector<std::size_t> node_to_station(nnodes + 1, M);
1255 for (std::size_t i = 0; i < M; ++i) node_to_station[sn.station_to_node[i]] = i;
1256
1257 /**
1258 * ROUTING IS RESOLVED OVER NODES, not over stations.
1259 *
1260 * A Router, a ClassSwitch and a Logger appear here as destinations: the
1261 * stochastic complement that removes them lives in `rt`, not in the
1262 * NODE-level `Peff` this table is read off. They hold nothing, so `deliver`
1263 * walks straight through them. A FORK and a JOIN are different again: they
1264 * are stateful, they change the NUMBER of jobs in flight, and no stochastic
1265 * complement can express that, so `deliver` replicates at a Fork and
1266 * synchronizes at a Join.
1267 */
1268 std::vector<std::vector<std::vector<RouteDest>>> nroute(
1269 nnodes + 1, std::vector<std::vector<RouteDest>>(K));
1270 /**
1271 * The dispatcher each (node, class) declares, and its round-robin pointer.
1272 *
1273 * The pointer is per (node, class) and NOT per destination: a round-robin
1274 * dispatcher hands out its outgoing links in turn, and one counter per link
1275 * would let two classes at the same node walk the cycle independently and
1276 * both start at the head.
1277 */
1278 std::vector<std::vector<lang::RoutingStrategy>> node_routing(
1279 nnodes + 1, std::vector<lang::RoutingStrategy>(K, lang::RoutingStrategy::PROB));
1280 std::vector<std::vector<std::size_t>> rr_counter(nnodes + 1, std::vector<std::size_t>(K, 0));
1281 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1282 for (std::size_t r = 0; r < K; ++r)
1283 if (sn.nodes[inode - 1].routing.size() > r)
1284 node_routing[inode][r] = sn.nodes[inode - 1].routing[r];
1285 for (std::size_t inode = 1; inode <= nnodes; ++inode) {
1286 // THE SINK HAS NO OUTGOING PATH. The refresh writes a Sink -> Source
1287 // closure into the effective routing so the visit ratios of an open
1288 // model have a cycle to solve on; it is bookkeeping, not a route a job
1289 // takes. Reading it as one sends every departing job back to the
1290 // Source. The station-level table never saw it because the Sink is not
1291 // a station.
1292 if (sn.nodes[inode - 1].nodetype == NodeType::Sink) continue;
1293 // A PETRI NET DOES NOT ROUTE. Its arcs declare the structure, but the
1294 // tokens move by the enabling and firing matrices of the transitions,
1295 // so walking the routing here would move them twice.
1296 if (sn.nodes[inode - 1].nodetype == NodeType::Place ||
1297 sn.nodes[inode - 1].nodetype == NodeType::Transition)
1298 continue;
1299 for (std::size_t r = 0; r < K; ++r) {
1300 // ASCENDING NODE INDEX in the outer loop: this is the order the
1301 // round-robin pointer walks, and it is the reference's order.
1302 for (std::size_t j = 1; j <= nnodes; ++j) {
1303 RouteDest d;
1304 d.node = j;
1305 d.sink = (sn.nodes[j - 1].nodetype == NodeType::Sink);
1306 d.station = node_to_station[j];
1307 for (std::size_t s2 = 0; s2 < K; ++s2) {
1308 const double p = num_traits<T>::to_double(sn.route_eff(r + 1, s2 + 1, inode, j));
1309 if (!(p > 0.0)) continue;
1310 d.mass += p;
1311 d.cls.push_back(std::make_pair(s2, p));
1312 }
1313 if (d.cls.empty()) continue;
1314 // A Fork, a Join and a Cache are STATEFUL and hold jobs; a
1315 // ClassSwitch, a Router and a Logger do not and are walked
1316 // through in one step by `deliver`. Neither kind is folded away
1317 // by the refresh -- `Peff` is a NODE-level matrix and keeps
1318 // every intermediate hop -- so both must be reachable here.
1319 // Anything else is a node this engine would have to guess at.
1320 if (!d.sink && d.station >= M && sn.nodes[j - 1].nodetype != NodeType::Fork &&
1321 sn.nodes[j - 1].nodetype != NodeType::Join &&
1322 sn.nodes[j - 1].nodetype != NodeType::Cache &&
1323 sn.nodes[j - 1].nodetype != NodeType::ClassSwitch &&
1324 sn.nodes[j - 1].nodetype != NodeType::Router &&
1325 sn.nodes[j - 1].nodetype != NodeType::Logger &&
1326 sn.nodes[j - 1].nodetype != NodeType::Place &&
1327 sn.nodes[j - 1].nodetype != NodeType::Transition)
1328 throw UnsupportedError(
1329 "SolverLDES (native engine): the routing crosses node '" +
1330 sn.nodes[j - 1].name +
1331 "', which the refresh did not fold into the station-to-station "
1332 "routing");
1333 if (d.station < M && S[d.station].role == Role::Source)
1334 throw InputError("SolverLDES (native engine): the routing sends a "
1335 "job back into the Source");
1336 nroute[inode][r].push_back(d);
1337 }
1338 }
1339 }
1340
1341 // ---- fork and join --------------------------------------------------------
1342 // A Fork replicates the arriving job onto every outgoing edge; a Join holds
1343 // the siblings until the strategy is satisfied and releases ONE job. The
1344 // pair is matched by `sn.fj`, so a Fork with no Join is a model whose
1345 // siblings never recombine and is refused rather than run.
1346 std::vector<int> join_of_fork(nnodes + 1, -1);
1347 std::vector<bool> is_fork(nnodes + 1, false), is_join(nnodes + 1, false);
1348 for (const auto& fjp : sn.fj) {
1349 if (fjp.first > nnodes || fjp.second > nnodes) continue;
1350 is_fork[fjp.first] = true;
1351 is_join[fjp.second] = true;
1352 join_of_fork[fjp.first] = static_cast<int>(fjp.second);
1353 }
1354 // Whether a class routes into a Join ANYWHERE: it decides whether a phase-2
1355 // continuation of that class inherits the trigger's fork identity.
1356 std::vector<bool> spawn_joins_at(K, false);
1357 for (std::size_t inode = 1; inode <= nnodes; ++inode)
1358 for (std::size_t r = 0; r < K; ++r)
1359 for (const RouteDest& d : nroute[inode][r])
1360 if (d.node >= 1 && d.node <= nnodes &&
1361 sn.nodes[d.node - 1].nodetype == NodeType::Join)
1362 for (std::size_t c = 0; c < d.cls.size(); ++c)
1363 spawn_joins_at[d.cls[c].first] = true;
1364 for (std::size_t nd = 1; nd <= nnodes; ++nd) {
1365 if (sn.nodes[nd - 1].nodetype == NodeType::Fork && !is_fork[nd])
1366 throw InputError("SolverLDES (native engine): Fork node '" + sn.nodes[nd - 1].name +
1367 "' has no matching Join");
1368 if (sn.nodes[nd - 1].nodetype == NodeType::Join && !is_join[nd])
1369 throw InputError("SolverLDES (native engine): Join node '" + sn.nodes[nd - 1].name +
1370 "' closes no Fork");
1371 }
1372
1373 /**
1374 * One forked parent awaiting its siblings.
1375 *
1376 * `required` is fixed at FORK time, not at join time: the quorum is a
1377 * fraction of the siblings THIS fork produced, and a Join shared by two
1378 * forks of different fan-out would otherwise apply one fork's count to the
1379 * other's siblings.
1380 */
1381 struct ForkSync {
1382 std::size_t cls = 0;
1383 double t_sys = 0.0;
1384 int total = 0;
1385 int required = 0;
1386 /**
1387 * (class, instant) of every sibling this parent has parked at the Join.
1388 *
1389 * Held PER PARENT, not per Join: the Join's queue length must fall by
1390 * the siblings THIS synchronization releases, and a node-wide tally
1391 * would clear the siblings of every other parent still waiting. It also
1392 * dates each sibling's own wait, which is `forkedJobJoinArrivalTimes`.
1393 */
1394 std::vector<std::pair<std::size_t, double>> siblings;
1395 };
1396 std::map<std::uint64_t, ForkSync> fork_sync;
1397 std::uint64_t next_parent = 0;
1398
1399
1400 // ---- cache nodes ----------------------------------------------------------
1401 // A Cache is a ROUTING node with state: it reads an item, decides hit or
1402 // miss on its real list contents, and switches the job onto the hit or the
1403 // miss class. That makes the hit an exact sample-path event rather than a
1404 // rate, which is the reason to ask a simulator about a cache at all.
1405 std::map<std::size_t, CacheState> caches;
1406 for (const auto& np : sn.nodeparam) {
1407 const std::size_t nd = np.first;
1408 if (nd == 0 || nd > nnodes) continue;
1409 if (sn.nodes[nd - 1].nodetype != NodeType::Cache) continue;
1410 const auto& cp = np.second;
1411 CacheState cs;
1412 cs.node = nd;
1413 cs.nitems = cp.nitems;
1414 cs.policy = cp.replacestrat;
1415 for (int c : cp.itemcap)
1416 if (c > 0) cs.capacity.push_back(static_cast<std::size_t>(c));
1417 if (cs.capacity.empty()) cs.capacity.push_back(1);
1418 cs.lists.assign(cs.capacity.size(), std::list<std::size_t>());
1419 for (int v : cp.itemsize) cs.item_size.push_back(v);
1420 for (int v : cp.costcap) cs.cost_cap.push_back(v);
1421 cs.hits.assign(K, 0.0);
1422 cs.misses.assign(K, 0.0);
1423 cs.hit_class.assign(K, -1);
1424 cs.miss_class.assign(K, -1);
1425 for (std::size_t r = 0; r < K && r < cp.hitclass.size(); ++r)
1426 if (cp.hitclass[r] > 0) cs.hit_class[r] = static_cast<int>(cp.hitclass[r] - 1);
1427 for (std::size_t r = 0; r < K && r < cp.missclass.size(); ++r)
1428 if (cp.missclass[r] > 0) cs.miss_class[r] = static_cast<int>(cp.missclass[r] - 1);
1429 // The popularity is stored as a pmf per class and used as a CUMULATIVE
1430 // law here, so one uniform draw picks an item.
1431 cs.popularity.assign(K, std::vector<double>());
1432 for (std::size_t r = 0; r < K && r < cp.pread.size(); ++r) {
1433 double acc = 0.0;
1434 for (const T& v : cp.pread[r]) {
1435 acc += num_traits<T>::to_double(v);
1436 cs.popularity[r].push_back(acc);
1437 }
1438 if (!cs.popularity[r].empty()) cs.popularity[r].back() = 1.0;
1439 }
1440 // Delayed-hit retrieval: one class per (item, read class) carries the
1441 // fetch through the retrieval stations and back here.
1442 if (cp.retrieval_capacity > 0 && !cp.retrieval_classes.empty()) {
1443 cs.has_retrieval = true;
1444 cs.retrieval_class = cp.retrieval_classes;
1445 cs.retrieval_class_to_item.assign(K, -1);
1446 for (std::size_t it = 0; it < cs.retrieval_class.size(); ++it)
1447 for (std::size_t r = 0; r < cs.retrieval_class[it].size(); ++r) {
1448 const std::size_t rc = cs.retrieval_class[it][r];
1449 if (rc > 0 && rc <= K) cs.retrieval_class_to_item[rc - 1] = static_cast<int>(it);
1450 }
1451 cs.in_flight.assign(cs.nitems, 0);
1452 cs.fetch_start.assign(cs.nitems, 0.0);
1453 cs.held.assign(cs.nitems, std::vector<CacheState::HeldRequest>());
1454 cs.delayed.assign(K, 0.0);
1455 }
1456 caches[nd] = cs;
1457 }
1458
1459
1460 // ---- Petri net: places and transitions --------------------------------------
1461 // A Place is a STATION holding tokens per class; a Transition is a node that
1462 // moves them. The marking is the places' token counts, and it is the only
1463 // state the net has.
1464 std::vector<std::size_t> place_of(nnodes + 1, M);
1465 std::vector<std::size_t> place_nodes;
1466 for (std::size_t nd = 1; nd <= nnodes; ++nd)
1467 if (sn.nodes[nd - 1].nodetype == NodeType::Place) {
1468 place_of[nd] = place_nodes.size();
1469 place_nodes.push_back(nd);
1470 }
1471 std::vector<std::vector<double>> marking(place_nodes.size(), std::vector<double>(K, 0.0));
1472 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
1473 auto im = sn.initmarking.find(place_nodes[p]);
1474 if (im != sn.initmarking.end())
1475 for (std::size_t r = 0; r < K && r < im->second.size(); ++r)
1476 marking[p][r] = num_traits<T>::to_double(im->second[r]);
1477 }
1478
1479 std::vector<SpnTransition> transitions;
1480 for (const auto& tp : sn.transparam) {
1481 const std::size_t nd = tp.first;
1482 if (nd == 0 || nd > nnodes) continue;
1483 SpnTransition tr;
1484 tr.node = nd;
1485 tr.places = place_nodes;
1486 for (std::size_t m = 0; m < tp.second.nmodes; ++m) {
1487 SpnMode mode;
1488 // (place slot, class), flattened p*K + r, the layout `flat_marking`
1489 // produces and `spn_enabled`/`spn_fire` read.
1490 for (std::size_t p = 0; p < place_nodes.size(); ++p) {
1491 const std::size_t pn = place_nodes[p];
1492 for (std::size_t r = 0; r < K; ++r) {
1493 mode.enabling.push_back(
1494 (m < tp.second.enabling.size() && pn - 1 < tp.second.enabling[m].rows() &&
1495 r < tp.second.enabling[m].cols())
1496 ? num_traits<T>::to_double(tp.second.enabling[m](pn - 1, r))
1497 : 0.0);
1498 mode.inhibiting.push_back(
1499 (m < tp.second.inhibiting.size() &&
1500 pn - 1 < tp.second.inhibiting[m].rows() &&
1501 r < tp.second.inhibiting[m].cols())
1502 ? num_traits<T>::to_double(tp.second.inhibiting[m](pn - 1, r))
1503 : std::numeric_limits<double>::infinity());
1504 mode.firing.push_back(
1505 (m < tp.second.firing.size() && pn - 1 < tp.second.firing[m].rows() &&
1506 r < tp.second.firing[m].cols())
1507 ? num_traits<T>::to_double(tp.second.firing[m](pn - 1, r))
1508 : 0.0);
1509 }
1510 }
1511 if (m < tp.second.nmodeservers.size()) mode.servers = tp.second.nmodeservers[m];
1512 if (m < tp.second.firingprio.size()) mode.priority = tp.second.firingprio[m];
1513 if (m < tp.second.fireweight.size())
1514 mode.weight = num_traits<T>::to_double(tp.second.fireweight[m]);
1515 mode.immediate = (m < tp.second.timing.size() &&
1516 tp.second.timing[m] == lang::TimingStrategy::IMMEDIATE);
1517 if (m < tp.second.firingproc.size() && !tp.second.firingproc[m].disabled) {
1518 const double mean = num_traits<T>::to_double(tp.second.firingproc[m].mean);
1519 if (mean > 0.0) mode.rate = 1.0 / mean;
1520 }
1521 if (m < tp.second.firingdep.size() && tp.second.firingdep[m]) {
1522 const auto f = tp.second.firingdep[m];
1523 const std::vector<std::size_t> pl = place_nodes;
1524 const std::size_t nn = nnodes, KK = K;
1525 // g(marking) IS NODE-INDEXED AND CLASS-SUMMED, the vector
1526 // `state_events.h` builds and the reader's slot lattice is keyed
1527 // by. Handing it the (place, class) vector instead read the
1528 // wrong slot whenever a place was not the node of the same
1529 // ordinal, and every slot once the class dimension existed.
1530 mode.dep = [f, pl, nn, KK](const std::vector<double>& tok) {
1531 std::vector<T> mk(nn, num_traits<T>::from_int(0));
1532 for (std::size_t p = 0; p < pl.size(); ++p) {
1533 double s = 0.0;
1534 for (std::size_t r = 0; r < KK; ++r)
1535 if (p * KK + r < tok.size()) s += tok[p * KK + r];
1536 if (pl[p] >= 1 && pl[p] - 1 < nn)
1537 mk[pl[p] - 1] = num_traits<T>::from_double(s);
1538 }
1539 return num_traits<T>::to_double(f(mk));
1540 };
1541 }
1542 tr.modes.push_back(mode);
1543 }
1544 tr.fired.assign(tr.modes.size(), 0.0);
1545 transitions.push_back(tr);
1546 }
1547
1548 // ---- statistics --------------------------------------------------------
1549 Accum acc(M, K);
1550 for (std::size_t i = 0; i < M; ++i) acc.util_peak[i] = S[i].util_peak;
1551 // Declared here and populated below: the admission lambda tracks into it,
1552 // and a lambda cannot capture a name introduced after it.
1553 BusyPeriods bp;
1554 std::vector<double> sys_resp_sum(K, 0.0), sys_resp_cnt(K, 0.0), sys_completed(K, 0.0);
1555 // Per (station, class): a model can drop at one station and balk at
1556 // another, and a per-class total would not say which.
1557 std::vector<std::vector<double>> dropped(M, std::vector<double>(K, 0.0));
1558 std::vector<std::vector<double>> balked(M, std::vector<double>(K, 0.0));
1559 std::vector<std::vector<double>> reneged(M, std::vector<double>(K, 0.0));
1560 std::vector<std::vector<double>> blocked_count(M, std::vector<double>(K, 0.0));
1561 /**
1562 * Every per-visit response time, when either flag asked for them.
1563 *
1564 * --trajectory counts as asking: the reference NESTS the same samples inside
1565 * its `transient` block, and MATLAB's `sample()` and `sampleSys()` read them
1566 * from there. Recording only under --respt-samples left those two returning
1567 * nothing on the transient path.
1568 */
1569 const bool want_respt = o.export_respt || o.export_trajectory;
1570 std::vector<std::vector<std::vector<double>>> resp_samples(
1571 want_respt ? M : 0, std::vector<std::vector<double>>(K));
1572 std::uint64_t job_id = 0;
1573 double now = 0.0;
1574
1575 /**
1576 * G-NETWORK REMOVAL SIGNALS, resolved once per class.
1577 *
1578 * A removal signal is a class like any other while it is in transit and
1579 * stops being one the instant it reaches a station: it never joins, it
1580 * removes jobs already there and it is annihilated. `is_removal_signal`
1581 * is the whole gate, and everything the removal costs -- the registry of
1582 * jobs held at a Delay station, the extra draws -- is paid only where it
1583 * is true, so a model without signals runs exactly as before.
1584 */
1585 std::vector<bool> is_removal_signal(K, false);
1586 bool has_removal_signal = false;
1587 for (std::size_t r = 0; r < K; ++r) {
1588 if (r >= sn.issignal.size() || !sn.issignal[r]) continue;
1589 const lang::SignalType kind =
1590 (r < sn.signaltype.size()) ? sn.signaltype[r] : lang::SignalType::NEGATIVE;
1592 is_removal_signal[r] = true;
1593 has_removal_signal = true;
1594 }
1595 }
1596 /**
1597 * The jobs a Delay station currently holds, in arrival order.
1598 *
1599 * A Delay keeps no buffer and no server: its occupants live only in the
1600 * departure events already scheduled for them, and the event list has no
1601 * handle to cancel one. The registry is that handle -- a job removed by a
1602 * signal is erased from it, and the departure that arrives later finds no
1603 * entry and is discarded, which is the same tag trick the buffered
1604 * stations use against a preempted departure.
1605 */
1606 std::vector<std::vector<Job>> delay_live(has_removal_signal ? M : 0);
1607
1608 /**
1609 * SYNCHRONOUS CALLS, `sn.syncreply`: the class a caller expects its reply in.
1610 *
1611 * A caller that expects one KEEPS ITS SERVER when it leaves for the callee
1612 * and gets it back only when the matching REPLY class returns, which is the
1613 * whole difference between a synchronous call and an ordinary hop. The
1614 * pending calls are keyed by an identity that rides on the job, because the
1615 * reply comes back from a station the caller never names.
1616 */
1617 std::vector<std::size_t> sync_reply(K, K);
1618 std::vector<bool> is_reply_signal(K, false);
1619 bool has_sync_call = false;
1620 for (std::size_t r = 0; r < K; ++r) {
1621 if (r < sn.syncreply.size() && sn.syncreply[r] >= 1 && sn.syncreply[r] <= K) {
1622 sync_reply[r] = sn.syncreply[r] - 1;
1623 has_sync_call = true;
1624 }
1625 if (r < sn.issignal.size() && sn.issignal[r] && r < sn.signaltype.size() &&
1626 sn.signaltype[r] == lang::SignalType::REPLY)
1627 is_reply_signal[r] = true;
1628 }
1629 /** One outstanding call: the slot it holds and the class that holds it. */
1630 struct PendingCall {
1631 std::size_t station = 0;
1632 std::size_t slot = 0;
1633 std::size_t cls = 0;
1634 double since = 0.0;
1635 };
1636 std::map<std::uint64_t, PendingCall> pending_reply;
1637 std::uint64_t next_call = 0;
1638
1639 /** Whether any (station, class) declares immediate feedback at all. */
1640 bool has_immfeed = false;
1641 for (std::size_t i = 0; i < M && i < sn.immfeed.size(); ++i)
1642 for (std::size_t r = 0; r < sn.immfeed[i].size(); ++r)
1643 if (sn.immfeed[i][r]) has_immfeed = true;
1644
1645 /**
1646 * SPAWN ON COMPLETION, `sn.classspawn`: completing a job of class r injects
1647 * a FRESH job of the mapped class at the same station.
1648 *
1649 * It is an LQN phase-2 continuation: the work the server owes after the
1650 * reply has gone back. The continuation is a new passage, so it starts its
1651 * own response-time clock and is not the completing job under another name.
1652 */
1653 std::vector<std::size_t> spawn_of(K, K);
1654 bool has_spawn = false;
1655 for (std::size_t r = 0; r < K; ++r)
1656 if (sn.classes[r].spawn >= 1 && sn.classes[r].spawn <= K) {
1657 spawn_of[r] = sn.classes[r].spawn - 1;
1658 has_spawn = true;
1659 }
1660
1661 // ---- event list --------------------------------------------------------
1662 std::priority_queue<Event, std::vector<Event>, EventLater> evq;
1663 std::uint64_t seq = 0, ps_tag = 0;
1664
1665 auto push = [&](Event e) {
1666 e.seq = seq++;
1667 evq.push(e);
1668 };
1669
1670
1671 /**
1672 * Push onto, and pop from, a station's waiting room.
1673 *
1674 * FSP takes the linear branch: its key is recomputed against the residual
1675 * work present at the station RIGHT NOW, over the waiting jobs and the
1676 * ones in service alike, so no stamped ordering can stand in for it. Every
1677 * other discipline has a per-job constant key and rides the heap.
1678 */
1679 auto buffer_push = [&](std::size_t i, const Job& job) {
1680 StationState& s = S[i];
1681 s.buffer.push_back(job);
1682 if (s.sched != SchedStrategy::FSP)
1683 std::push_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
1684 };
1685 auto buffer_pop = [&](std::size_t i) -> Job {
1686 StationState& s = S[i];
1687 if (s.sched != SchedStrategy::FSP) {
1688 std::pop_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
1689 Job job = s.buffer.back();
1690 s.buffer.pop_back();
1691 return job;
1692 }
1693 std::vector<double> works;
1694 for (const Job& j : s.buffer) works.push_back(j.remaining);
1695 for (std::size_t sl = 0; sl < s.nservers; ++sl)
1696 if (s.server_busy[sl])
1697 works.push_back(std::max(0.0, s.server[sl].remaining -
1698 (now - s.server_start[sl])));
1699 std::size_t best = 0;
1700 double best_vft = std::numeric_limits<double>::infinity();
1701 for (std::size_t j = 0; j < s.buffer.size(); ++j) {
1702 const double v = fsp_virtual_finish(works, s.buffer[j].remaining,
1703 static_cast<double>(s.nservers), now);
1704 if (v < best_vft) {
1705 best_vft = v;
1706 best = j;
1707 }
1708 }
1709 Job job = s.buffer[best];
1710 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(best));
1711 return job;
1712 };
1713
1714 // ---- heterogeneous server pools: slot choice and buffer matching ---------
1715 //
1716 // Two questions the flat server bank never had to ask. WHICH SLOT can take
1717 // this job, and WHICH WAITING JOB can this slot take. Everything else in the
1718 // engine addresses a server by its slot and is untouched.
1719 //
1720 // On a station with no pools both reduce to what was there before -- the
1721 // first free slot, and the head of the waiting room -- so the pool-free path
1722 // keeps its exact draw sequence and a seeded homogeneous run is unchanged.
1723
1724 /** Free, unblocked, unheld, and its pool accepts `cls`. */
1725 auto slot_ok_for = [&](const StationState& s, std::size_t sl, std::size_t cls) -> bool {
1726 if (s.server_busy[sl] || s.server_blocked[sl] || s.server_held[sl]) return false;
1727 if (!s.has_pools) return true;
1728 return s.type_compat[s.server_type[sl]][cls];
1729 };
1730
1731 /**
1732 * The free slot a job of class `cls` may enter, or `s.nservers` for none.
1733 *
1734 * The POOL is chosen first, by the station's heterogeneous scheduling
1735 * policy, and the slot is then the first free one inside it. That is the
1736 * reference's order and it matters: picking the globally-first free slot
1737 * would make every policy behave as ORDER.
1738 */
1739 auto free_slot_for = [&](std::size_t i, std::size_t cls) -> std::size_t {
1740 StationState& s = S[i];
1741 if (!s.has_pools) {
1742 for (std::size_t sl = 0; sl < s.nservers; ++sl)
1743 if (slot_ok_for(s, sl, cls)) return sl;
1744 return s.nservers;
1745 }
1746 // The pools that accept the class AND still hold a free slot.
1747 std::vector<std::size_t> cand;
1748 for (std::size_t t = 0; t < s.type_count.size(); ++t) {
1749 if (!s.type_compat[t][cls]) continue;
1750 for (std::size_t j = 0; j < s.type_count[t]; ++j)
1751 if (slot_ok_for(s, s.type_first[t] + j, cls)) {
1752 cand.push_back(t);
1753 break;
1754 }
1755 }
1756 if (cand.empty()) return s.nservers;
1757 std::size_t chosen = cand[0];
1758 if (cand.size() > 1) {
1759 switch (s.hetero_policy) {
1762 // Round robin over the pools: the one just used goes to the
1763 // back, so a steady stream spreads over the compatible
1764 // pools instead of filling the first one.
1765 for (std::size_t k = 0; k < s.type_order.size(); ++k) {
1766 const std::size_t t = s.type_order[k];
1767 if (std::find(cand.begin(), cand.end(), t) == cand.end()) continue;
1768 chosen = t;
1769 s.type_order.erase(s.type_order.begin() +
1770 static_cast<std::ptrdiff_t>(k));
1771 s.type_order.push_back(t);
1772 break;
1773 }
1774 break;
1775 }
1777 // Least flexible first: spend the pool that fewest classes
1778 // can use before the one that many can.
1779 for (std::size_t k = 0; k < s.alfs_order.size(); ++k)
1780 if (std::find(cand.begin(), cand.end(), s.alfs_order[k]) != cand.end()) {
1781 chosen = s.alfs_order[k];
1782 break;
1783 }
1784 break;
1785 }
1787 // Fastest server first, by this class's rate at the pool.
1788 double best = -1.0;
1789 for (std::size_t k = 0; k < cand.size(); ++k) {
1790 const double rt = s.type_rate[cand[k]][cls];
1791 if (rt > best) {
1792 best = rt;
1793 chosen = cand[k];
1794 }
1795 }
1796 break;
1797 }
1799 // A uniform draw over the candidate pools, off the ROUTING
1800 // stream as the reference draws it.
1801 std::size_t at = static_cast<std::size_t>(
1802 uniform01(g_routing) * static_cast<double>(cand.size()));
1803 if (at >= cand.size()) at = cand.size() - 1;
1804 chosen = cand[at];
1805 break;
1806 }
1808 default: {
1809 // Declaration order, which `cand` is already built in.
1810 chosen = cand[0];
1811 break;
1812 }
1813 }
1814 }
1815 for (std::size_t j = 0; j < s.type_count[chosen]; ++j) {
1816 const std::size_t sl = s.type_first[chosen] + j;
1817 if (slot_ok_for(s, sl, cls)) return sl;
1818 }
1819 return s.nservers;
1820 };
1821
1822 /** Is any waiting job servable by slot `sl`? */
1823 auto buffer_has_for_slot = [&](std::size_t i, std::size_t sl) -> bool {
1824 StationState& s = S[i];
1825 if (s.buffer.empty()) return false;
1826 if (!s.has_pools) return true;
1827 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
1828 for (std::size_t j = 0; j < s.buffer.size(); ++j)
1829 if (ok[s.buffer[j].cls]) return true;
1830 return false;
1831 };
1832
1833 /**
1834 * Pop the job slot `sl` should serve next.
1835 *
1836 * Off a pooled station this IS `buffer_pop`. On one it is the job the
1837 * station's own order would have taken FIRST AMONG THOSE THE SLOT CAN
1838 * SERVE, which is what a pool means: a server does not skip the queue, it
1839 * skips the jobs it is not compatible with. The heap is drained and rebuilt
1840 * because the discipline's order lives in the comparator, not in the
1841 * sequence, so scanning the vector would answer in heap order rather than
1842 * in service order.
1843 */
1844 auto buffer_pop_for_slot = [&](std::size_t i, std::size_t sl) -> Job {
1845 StationState& s = S[i];
1846 if (!s.has_pools) return buffer_pop(i);
1847 const std::vector<bool>& ok = s.type_compat[s.server_type[sl]];
1848 std::vector<Job> skipped;
1849 Job job;
1850 bool got = false;
1851 while (!s.buffer.empty()) {
1852 Job cand = buffer_pop(i);
1853 if (ok[cand.cls]) {
1854 job = cand;
1855 got = true;
1856 break;
1857 }
1858 skipped.push_back(cand);
1859 }
1860 for (std::size_t j = 0; j < skipped.size(); ++j) buffer_push(i, skipped[j]);
1861 if (!got)
1862 throw InputError("SolverLDES (native engine): buffer_pop_for_slot was asked for a "
1863 "job no pool of this slot can serve; guard with buffer_has_for_slot");
1864 return job;
1865 };
1866
1867
1868 /**
1869 * The RATE multiplier a station applies to a class at its current
1870 * population: load dependence times class dependence.
1871 *
1872 * A sampled requirement is DIVIDED by this, so a multiplier above one is a
1873 * faster station. `lldscaling` is indexed by the total population minus
1874 * one, saturating at the table's end, which is the reference's convention
1875 * and not a guard: the table is declared up to the station's capacity and a
1876 * closed model can hold exactly that many.
1877 *
1878 * A SHARING station does NOT take the load-dependent factor here. Its
1879 * `lldscaling` is read instead as the effective SERVER COUNT in
1880 * `ps_shares`, because c servers and a c-fold rate are the same thing only
1881 * when the capacity is shared -- applying both would scale it twice.
1882 */
1883 // The load-dependent speed alone, which is both a factor of the service
1884 // rate below and the scale the busy integral is measured in. Sharing one
1885 // definition is what keeps `U = T*E[S]/peak` exact on the sample path.
1886 auto lld_factor = [&](std::size_t i) -> double {
1887 StationState& s = S[i];
1888 if (s.lld.empty() || s.ps) return 1.0;
1889 double total = 0.0;
1890 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
1891 if (!(total > 0.0)) return 1.0;
1892 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
1893 return s.lld[idx] > 0.0 ? s.lld[idx] : 1.0;
1894 };
1895 // The class-dependent speed alone. It is a factor of the RATE, so every
1896 // path that turns a population into a departure time has to apply it -- the
1897 // sharing disciplines included, which is what `ps_advance`/`ps_reschedule`
1898 // below missed: they built their shares from `ps_shares` and the breakdown
1899 // clock and never read the handle, so a PS station carrying a `cdscaling`
1900 // ran its whole sample path at the UNSCALED rates. On a closed
1901 // Delay+Queue(PS), N=3, beta(n)=min(n,2), that was Tput 0.935 against the
1902 // exact 1.412, and utilization inherited the error through T*E[S]/peak.
1903 auto cd_factor = [&](std::size_t i, std::size_t r) -> double {
1904 StationState& s = S[i];
1905 if (!s.has_cd) return 1.0;
1906 std::vector<double> nvec(K, 0.0);
1907 for (std::size_t k = 0; k < K; ++k) nvec[k] = acc.qlen[i][k];
1908 const std::vector<double> beta = s.cd(nvec);
1909 const double b = (beta.size() > 1) ? beta[r] : (beta.empty() ? 1.0 : beta[0]);
1910 return (b > 1e-10) ? b : 1.0;
1911 };
1912 auto rate_scaling = [&](std::size_t i, std::size_t r) -> double {
1913 StationState& s = S[i];
1914 double factor = lld_factor(i) * cd_factor(i, r);
1915 // A DOWN SERVER IS A ZERO FACTOR, not a skipped station: the job in
1916 // service keeps its residual and simply stops accruing, which is what
1917 // makes the outage an interruption rather than an eviction. A positive
1918 // `down_scale` is the degraded server of `downServiceRates`.
1919 if (s.has_breakdown && !s.up)
1920 factor *= (r < s.down_scale.size() ? s.down_scale[r] : 0.0);
1921 return factor;
1922 };
1923
1924 /**
1925 * The effective server count of a SHARING station, which is where its
1926 * load-dependence table is read. Transcribes `getEffectivePSServerCount`:
1927 * with no jobs present the count is 1, not the table's first entry, so the
1928 * very first arrival gets a whole server.
1929 */
1930 auto ps_servers = [&](std::size_t i) -> double {
1931 StationState& s = S[i];
1932 if (s.lld.empty()) return static_cast<double>(s.nservers);
1933 double total = 0.0;
1934 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[i][k];
1935 if (!(total > 0.0)) return 1.0;
1936 const std::size_t idx = std::min(static_cast<std::size_t>(total) - 1, s.lld.size() - 1);
1937 return s.lld[idx];
1938 };
1939
1940 // ---- the sharing disciplines -------------------------------------------
1941 // Every population change at a PS station invalidates every departure it
1942 // has scheduled, so the pair below is always called together: integrate the
1943 // work delivered since the last change, then reschedule everything. A
1944 // stale event is not cancelled (the event list has no handle); it is
1945 // ignored on arrival by comparing its tag against the job's current one.
1946 auto ps_advance = [&](std::size_t i) {
1947 StationState& s = S[i];
1948 const double dt = now - s.ps_last_update;
1949 s.ps_last_update = now;
1950 // THE BUSY TIME OF A SHARING STATION IS THE INTEGRAL OF THE SHARES, not
1951 // of the head count. Every job at a PS station is "in service", so
1952 // counting heads makes the busy time n*dt and reports a utilization of
1953 // n/c -- above one as soon as the station queues at all. The reference
1954 // accumulates `rate * elapsed` per job for exactly this reason, which
1955 // also makes U = T*E[S] hold automatically, since the shares sum to the
1956 // work actually delivered.
1957 for (std::size_t r = 0; r < K; ++r) acc.last_busy[i][r] = now;
1958 if (!(dt > 0.0) || s.ps_jobs.empty()) return;
1959 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
1960 if (s.has_cd)
1961 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
1962 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
1963 if (s.has_breakdown && !s.up)
1964 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
1965 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
1966 : 0.0);
1967 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
1968 if (rates[j] > 0.0) {
1969 acc.tot_busy[i][s.ps_jobs[j].cls] += rates[j] * dt;
1970 s.ps_jobs[j].remaining = std::max(0.0, s.ps_jobs[j].remaining - rates[j] * dt);
1971 }
1972 };
1973 auto ps_reschedule = [&](std::size_t i) {
1974 StationState& s = S[i];
1975 // The new speed takes effect HERE, on the population the caller has just
1976 // written, and the departures re-timed below are what it applies to.
1977 if (s.has_cd)
1978 for (std::size_t r = 0; r < K; ++r) s.ps_cd[r] = cd_factor(i, r);
1979 if (s.ps_jobs.empty()) return;
1980 std::vector<double> rates = ps_shares(s.sched, s.ps_jobs, ps_servers(i), s.weight, K);
1981 if (s.has_cd)
1982 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
1983 rates[j] *= s.ps_cd[s.ps_jobs[j].cls];
1984 if (s.has_breakdown && !s.up)
1985 for (std::size_t j = 0; j < rates.size() && j < s.ps_jobs.size(); ++j)
1986 rates[j] *= (s.ps_jobs[j].cls < s.down_scale.size() ? s.down_scale[s.ps_jobs[j].cls]
1987 : 0.0);
1988 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j) {
1989 PsJob& pj = s.ps_jobs[j];
1990 // Retag the STARVED jobs too: the tag is what invalidates a departure already in the list.
1991 pj.tag = ++ps_tag;
1992 const double rate = rates[j];
1993 if (!(rate > 0.0)) continue;
1994 Event e;
1995 e.t = now + ((pj.remaining <= 1e-12) ? 1e-12 : pj.remaining / rate);
1996 e.kind = EV_DEPARTURE;
1997 e.station = i;
1998 e.cls = pj.cls;
1999 e.tag = pj.tag;
2000 push(e);
2001 }
2002 };
2003
2004
2005 /**
2006 * Integrate the work delivered at a state-dependent station since its last
2007 * change, then re-time every completion it holds.
2008 *
2009 * `sd_advance` must run BEFORE the population is written and `sd_reschedule`
2010 * after, because the rate in force over the elapsed interval is the one the
2011 * OLD population implied. The stale departures are neutralised by the tag,
2012 * exactly as a preemption's are.
2013 */
2014 auto sd_advance = [&](std::size_t i) {
2015 StationState& s = S[i];
2016 if (!s.state_dependent || s.ps) return;
2017 const double dt = now - s.sd_last_update;
2018 s.sd_last_update = now;
2019 if (!(dt > 0.0)) return;
2020 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2021 if (s.server_busy[sl]) {
2022 const double scale = rate_scaling(i, s.server[sl].cls);
2023 s.server[sl].remaining = std::max(0.0, s.server[sl].remaining - dt * scale);
2024 s.server[sl].elapsed += dt * scale;
2025 s.server_start[sl] = now;
2026 }
2027 };
2028 auto sd_reschedule = [&](std::size_t i) {
2029 StationState& s = S[i];
2030 if (!s.state_dependent || s.ps) return;
2031 // The population has just been written, so this is where the new speed
2032 // takes effect -- for the busy integral exactly as for the departures
2033 // re-timed below. `set_busy_scale` credits the interval that just ended
2034 // at the OLD speed before installing the new one.
2035 acc.set_busy_scale(i, lld_factor(i), now);
2036 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2037 if (s.server_busy[sl]) {
2038 const double scale = rate_scaling(i, s.server[sl].cls);
2039 // RETAG BEFORE THE ZERO TEST, as ps_reschedule does. The tag is
2040 // the only thing that invalidates a departure already in the
2041 // list, so a slot whose rate has fallen to zero must be retagged
2042 // too -- otherwise the departure scheduled at the OLD rate stays
2043 // valid and the job leaves in the middle of the outage that was
2044 // supposed to stop it. Measured on M/M/1/8 with a failing
2045 // server: mean queue 1.486 against the exact chain's 1.922, with
2046 // throughput 1.6% HIGH, because the stalled work was delivered
2047 // anyway.
2048 s.server_tag[sl] = ++ps_tag;
2049 if (!(scale > 0.0)) continue;
2050 Event e;
2051 e.t = now + s.server[sl].remaining / scale;
2052 e.kind = EV_DEPARTURE;
2053 e.station = i;
2054 e.cls = s.server[sl].cls;
2055 e.slot = sl;
2056 e.tag = s.server_tag[sl];
2057 push(e);
2058 }
2059 };
2060
2061
2062 /**
2063 * Whether station `j` can admit one more job of class `r` RIGHT NOW,
2064 * counting the jobs already blocked in front of it.
2065 *
2066 * THE CAPACITY TEST EXCLUDES THE BLOCKED JOBS, and reporting includes
2067 * them. `acc.qlen` carries both, because the reference's
2068 * effectiveQueueLength adds basBlockedAtDest/bbsBlockedAtDest for the
2069 * statistics, while `destinationHasCapacity` reads
2070 * getTotalCustomersAtStation -- the jobs actually AT the station. Testing
2071 * against the inclusive count DEADLOCKS a station of capacity one: the job
2072 * blocked in front of it occupies the very slot it is waiting for, so the
2073 * station can never make room and the upstream server is never released.
2074 */
2075 auto dest_has_room = [&](std::size_t j, std::size_t r) -> bool {
2076 StationState& d = S[j];
2077 if (d.role != Role::Queue) return true;
2078 double total = 0.0;
2079 for (std::size_t k = 0; k < K; ++k) total += acc.qlen[j][k] - d.blocked_at[k];
2080 return (total + 1.0 <= d.cap) &&
2081 (acc.qlen[j][r] - d.blocked_at[r] + 1.0 <= d.classcap[r]);
2082 };
2083
2084 /** The blocking policy the DESTINATION declares for an arriving class. */
2085 auto dest_policy = [&](std::size_t j, std::size_t r) -> lang::DropStrategy {
2086 return (S[j].role == Role::Queue && r < S[j].droprule.size()) ? S[j].droprule[r]
2088 };
2089
2090 // ---- finite capacity regions --------------------------------------------
2091 // At most ONE region per station, which is the reference's own restriction:
2092 // `fcRegionIndices` is a single index per station, and overlapping regions
2093 // would need a join over their constraints that no codebase implements.
2094 std::vector<Region> regions;
2095 std::vector<int> region_of(M, -1);
2096 for (const auto& rg : sn.regions) {
2097 Region R;
2098 R.name = rg.name;
2099 R.members.assign(M, false);
2100 R.class_cap.assign(K, -1.0);
2101 R.class_size.assign(K, 1.0);
2102 R.class_weight.assign(K, 1.0);
2103 R.rule.assign(K, lang::DropStrategy::DROP);
2104 R.jobs.assign(K, 0.0);
2105 R.blocked.assign(K, 0.0);
2106 R.dropped.assign(K, 0.0);
2107 R.tot_jobs.assign(K, 0.0);
2108 R.tot_weight.assign(K, 0.0);
2109 R.tot_mem.assign(K, 0.0);
2110 R.completed.assign(K, 0.0);
2111 R.resp_sum.assign(K, 0.0);
2112 R.resp_cnt.assign(K, 0.0);
2113 for (std::size_t i = 0; i < M && i < rg.members.size(); ++i)
2114 if (rg.members[i]) {
2115 R.members[i] = true;
2116 if (region_of[i] >= 0)
2117 throw UnsupportedError(
2118 "SolverLDES (native engine): station '" + sn.stations[i].name +
2119 "' belongs to more than one finite capacity region");
2120 region_of[i] = static_cast<int>(regions.size());
2121 }
2122 // `cap(i,r)` is the class-r bound at member i and `cap(i,K)` the global
2123 // one; -1 is the reference's unbounded sentinel and is kept, because it
2124 // is compared with != -1 to test MEMBERSHIP as well as boundedness.
2125 for (std::size_t i = 0; i < rg.cap.size(); ++i) {
2126 if (i >= M || !R.members[i]) continue;
2127 for (std::size_t r = 0; r < K && r < rg.cap[i].size(); ++r)
2128 if (rg.cap[i][r] >= 0.0)
2129 R.class_cap[r] = (R.class_cap[r] < 0.0) ? rg.cap[i][r]
2130 : std::min(R.class_cap[r], rg.cap[i][r]);
2131 if (rg.cap[i].size() > K && rg.cap[i][K] >= 0.0)
2132 R.global_cap = (R.global_cap < 0.0) ? rg.cap[i][K]
2133 : std::min(R.global_cap, rg.cap[i][K]);
2134 }
2135 for (double mm : rg.maxmem)
2136 if (mm >= 0.0) R.max_mem = (R.max_mem < 0.0) ? mm : std::min(R.max_mem, mm);
2137 for (std::size_t r = 0; r < K && r < rg.rule.size(); ++r) R.rule[r] = rg.rule[r];
2138 for (std::size_t r = 0; r < K && r < rg.size.size(); ++r)
2139 R.class_size[r] = num_traits<T>::to_double(rg.size[r]);
2140 for (std::size_t r = 0; r < K && r < rg.weight.size(); ++r)
2141 R.class_weight[r] = num_traits<T>::to_double(rg.weight[r]);
2142 for (std::size_t c = 0; c < rg.lincon_A.rows(); ++c) {
2143 std::vector<double> row;
2144 for (std::size_t r = 0; r < rg.lincon_A.cols(); ++r)
2145 row.push_back(num_traits<T>::to_double(rg.lincon_A(c, r)));
2146 R.lincon_A.push_back(row);
2147 R.lincon_b.push_back(c < rg.lincon_b.size() ? num_traits<T>::to_double(rg.lincon_b[c])
2148 : 0.0);
2149 }
2150 regions.push_back(R);
2151 }
2152
2153 /**
2154 * Jobs parked by a WAITQ region: they have NOT entered the region, so they
2155 * are outside every station's buffer, but they are still in the system and
2156 * are charged to the station they were trying to enter.
2157 */
2158 struct RegionWaiter {
2159 std::size_t station = 0;
2160 Job job;
2161 };
2162 std::vector<std::vector<RegionWaiter>> region_wait(regions.size());
2163
2164 auto start_service = [&](std::size_t i, std::size_t slot, const Job& job_in) {
2165 StationState& s = S[i];
2166 Job job = job_in;
2167 // A RESUMED job owes its residual; a RESTARTED one draws afresh and
2168 // discards what it had done, which is the whole of the PR/PI split.
2169 if (s.preemptive && !s.resume && job.elapsed > 0.0) {
2170 job.service = slot_snap(s.svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
2171 job.remaining = job.service;
2172 job.elapsed = 0.0;
2173 }
2174 // A POOLED STATION DRAWS THE SERVICE FROM THE POOL, and it can only be
2175 // drawn here: the law depends on which server takes the job, and that
2176 // is not known until the slot is assigned. The draw made on arrival is
2177 // the station's class-level default and stands as the NOMINAL size the
2178 // size-based disciplines order the waiting room by; the duration
2179 // actually served is this one. A pool that declares no law of its own
2180 // for the class keeps that nominal draw, so it costs no extra variate.
2181 if (s.has_pools && !(s.preemptive && s.resume && job.elapsed > 0.0)) {
2182 const std::size_t ty = s.server_type[slot];
2183 if (s.type_has_svc[ty][job.cls]) {
2184 job.service = slot_snap(
2185 s.type_svc[ty][job.cls].next_at(g_hsvc[i][ty][job.cls], now),
2186 "service time");
2187 job.remaining = job.service;
2188 job.elapsed = 0.0;
2189 }
2190 }
2191 s.server[slot] = job;
2192 s.server_busy[slot] = true;
2193 s.server_start[slot] = now;
2194 s.server_tag[slot] = ++ps_tag;
2195 acc.update_busy(i, job.cls, now);
2196 acc.busy[i][job.cls] += 1.0;
2197 // A ZERO RATE SCHEDULES NOTHING, it does not schedule a departure at
2198 // t = infinity. A job that enters service while the server is down
2199 // holds the slot with its residual intact and gets its departure back
2200 // from the repair's reschedule; pushing `now + remaining/0` instead
2201 // puts an infinite timestamp in the event list, which the run then
2202 // pops and processes with now = inf.
2203 const double scale0 = rate_scaling(i, job.cls);
2204 if (!(scale0 > 0.0)) return;
2205 Event e;
2206 e.t = now + job.remaining / scale0;
2207 e.kind = EV_DEPARTURE;
2208 e.station = i;
2209 e.cls = job.cls;
2210 e.slot = slot;
2211 e.tag = s.server_tag[slot];
2212 push(e);
2213 };
2214
2215 /**
2216 * Interrupt the job in `slot` and return it to the waiting room.
2217 *
2218 * Its residual work and attained service are carried on the job, so a
2219 * resume needs no side table; the tag is left stale, which is how the
2220 * departure event already in the list is neutralised.
2221 */
2222 auto preempt = [&](std::size_t i, std::size_t slot) {
2223 StationState& s = S[i];
2224 Job job = s.server[slot];
2225 const double served = now - s.server_start[slot];
2226 job.remaining = std::max(0.0, job.remaining - served);
2227 job.elapsed += served;
2228 s.server_busy[slot] = false;
2229 s.server_tag[slot] = 0;
2230 acc.update_busy(i, job.cls, now);
2231 acc.busy[i][job.cls] -= 1.0;
2232 // The job stays in the system: its queue length is unchanged, and it
2233 // re-enters the waiting room under the station's own order.
2234 buffer_push(i, job);
2235 };
2236
2237 /**
2238 * Advance a polling server one leg, or park it.
2239 *
2240 * `poll_serve` starts the next job at the buffer the server stands at;
2241 * `poll_advance` walks it to the next buffer, paying that leg's switchover.
2242 * The walk stops after a full lap of zero-time legs that found no work, or
2243 * the server would spin forever at the same simulated instant.
2244 */
2245 std::function<void(std::size_t)> poll_serve;
2246 std::function<void(std::size_t)> poll_advance;
2247
2248 poll_serve = [&](std::size_t i) {
2249 StationState& s = S[i];
2250 if (s.server_busy[0] || s.server_blocked[0] || s.server_held[0]) return;
2251 // Only the class the server currently stands at may be served.
2252 std::size_t at = s.buffer.size();
2253 for (std::size_t j = 0; j < s.buffer.size(); ++j)
2254 if (s.buffer[j].cls == s.poll_at &&
2255 (at == s.buffer.size() || s.buffer[j].t_arr < s.buffer[at].t_arr))
2256 at = j;
2257 if (at == s.buffer.size() || s.poll_budget == 0) {
2258 poll_advance(i);
2259 return;
2260 }
2261 Job job = s.buffer[at];
2262 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
2263 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
2264 if (s.poll_type != lang::PollingType::EXHAUSTIVE) --s.poll_budget;
2265 start_service(i, 0, job);
2266 };
2267
2268 poll_advance = [&](std::size_t i) {
2269 StationState& s = S[i];
2270 for (std::size_t step = 0; step < K; ++step) {
2271 const std::size_t from = s.poll_at;
2272 const std::size_t next = (from + 1) % K;
2273 // Takagi's r_i: the leg OUT of `from` is charged the switchover
2274 // stored against `from`, not against the buffer being entered.
2275 if (s.has_switchover[from]) {
2276 s.poll_switching = true;
2277 Event e;
2278 e.t = now + s.switchover[from].next(g_aux[i]);
2279 e.kind = EV_SWITCHOVER;
2280 e.station = i;
2281 e.cls = next;
2282 push(e);
2283 return;
2284 }
2285 s.poll_at = next;
2286 s.poll_budget = (s.poll_type == lang::PollingType::EXHAUSTIVE)
2287 ? std::numeric_limits<std::size_t>::max()
2288 : ((s.poll_type == lang::PollingType::KLIMITED) ? s.poll_k : 1);
2289 bool has_work = false;
2290 for (const Job& j : s.buffer)
2291 if (j.cls == next) {
2292 has_work = true;
2293 break;
2294 }
2295 if (has_work) {
2296 poll_serve(i);
2297 return;
2298 }
2299 }
2300 // A whole lap of zero-time legs found nothing. Parking is the only
2301 // alternative to spinning at this instant, and it is unobservable: the
2302 // next arrival restarts the server.
2303 s.poll_parked = true;
2304 };
2305
2306 // ---- admission ---------------------------------------------------------
2307
2308 /**
2309 * Reschedule the aggregate completion of a pass-and-swap station.
2310 *
2311 * ONE CLOCK FOR THE WHOLE STATION, at the total rate mu(c) of its ordered
2312 * list, and the position that completes is drawn from the per-position
2313 * INCREMENTS. Scheduling one clock per job instead would need each job's
2314 * marginal rate to be constant, which is exactly what an order-independent
2315 * station does not have: every arrival and departure changes every other
2316 * job's rate.
2317 */
2318 std::function<void(std::size_t)> pas_reschedule = [&](std::size_t i) {
2319 StationState& s = S[i];
2320 if (s.pas_list.empty()) return;
2321 std::vector<std::size_t> seq;
2322 for (const Job& j : s.pas_list) seq.push_back(j.cls);
2323 const double total = s.pas_rate(seq);
2324 if (!(total > 0.0)) return;
2325 s.pas_tag = ++ps_tag;
2326 Event e;
2327 e.t = now + (-std::log(uniform01(g_aux[i])) / total);
2328 e.kind = EV_DEPARTURE;
2329 e.station = i;
2330 e.cls = 0;
2331 e.tag = s.pas_tag;
2332 e.slot = std::numeric_limits<std::size_t>::max();
2333 push(e);
2334 };
2335
2336 /**
2337 * The position that completes, drawn from the per-position rate increments,
2338 * and the job that actually departs after the swap chain.
2339 */
2340 auto pas_complete = [&](std::size_t i) -> Job {
2341 StationState& s = S[i];
2342 std::vector<std::size_t> seq;
2343 for (const Job& j : s.pas_list) seq.push_back(j.cls);
2344 std::vector<double> inc(seq.size(), 0.0);
2345 double prev = 0.0, total = 0.0;
2346 for (std::size_t p = 0; p < seq.size(); ++p) {
2347 std::vector<std::size_t> pre(seq.begin(),
2348 seq.begin() + static_cast<std::ptrdiff_t>(p + 1));
2349 const double cur = s.pas_rate(pre);
2350 inc[p] = std::max(0.0, cur - prev);
2351 prev = cur;
2352 total += inc[p];
2353 }
2354 std::size_t pos = 0;
2355 if (total > 0.0) {
2356 const double u = uniform01(g_routing) * total;
2357 double acc2 = 0.0;
2358 for (std::size_t p = 0; p < inc.size(); ++p) {
2359 acc2 += inc[p];
2360 if (u < acc2) {
2361 pos = p;
2362 break;
2363 }
2364 }
2365 }
2366 // The swap chain: from `pos`, walk forward to the last position the swap
2367 // graph can reach. That job departs and the chain shifts back into the
2368 // hole at `pos`. An empty graph makes the chain trivial, which is the
2369 // plain order-independent station.
2370 std::vector<std::size_t> chain(1, pos);
2371 std::size_t moving = seq[pos], cur = pos;
2372 while (true) {
2373 std::size_t nxt = seq.size();
2374 for (std::size_t j = cur + 1; j < seq.size(); ++j) {
2375 const bool swappable =
2376 s.pas_swap.empty() ||
2377 (moving < s.pas_swap.size() && seq[j] < s.pas_swap[moving].size() &&
2378 s.pas_swap[moving][seq[j]]);
2379 if (swappable) {
2380 nxt = j;
2381 break;
2382 }
2383 }
2384 if (nxt >= seq.size()) break;
2385 chain.push_back(nxt);
2386 moving = seq[nxt];
2387 cur = nxt;
2388 }
2389 Job departing = s.pas_list[chain.back()];
2390 std::vector<Job> arr = s.pas_list;
2391 for (std::size_t k = 0; k + 1 < chain.size(); ++k)
2392 arr[chain[k + 1]] = s.pas_list[chain[k]];
2393 std::vector<Job> survivors;
2394 for (std::size_t k = 0; k < arr.size(); ++k)
2395 if (k != chain.front()) survivors.push_back(arr[k]);
2396 s.pas_list = survivors;
2397 return departing;
2398 };
2399 /**
2400 * Admit a job at station `i`, or drop it when the station is full.
2401 *
2402 * `from` is the station the job comes from, or M for an arrival that enters
2403 * from outside the network. IT IS NOT BOOKKEEPING: a hop BETWEEN TWO
2404 * MEMBERS of the same finite capacity region does not cross the region
2405 * boundary, so the job must neither be re-tested against the region's caps
2406 * nor counted into it again. Without that test an intra-region hop adds one
2407 * to the region on arrival and removes nothing on departure, so occupancy
2408 * grows by one per hop until every arrival is refused and the run never
2409 * completes another job.
2410 */
2411 std::function<bool(std::size_t, Job, std::size_t)> admit =
2412 [&](std::size_t i, Job job, std::size_t from) -> bool {
2413 StationState& s = S[i];
2414 if (s.off[job.cls])
2415 throw InputError("SolverLDES (native engine): a job of class '" +
2416 sn.classes[job.cls].name + "' reached station '" +
2417 sn.stations[i].name + "', which does not serve it");
2418 // THE REGION IS CONSULTED FIRST. Its cap spans stations, so a job may
2419 // be refused entry to a station that has room of its own; testing the
2420 // station first and the region second would admit into the buffer and
2421 // then have to unwind it. The test is on MEMBERSHIP ALONE, exactly as
2422 // on the departure side: a Delay is capped by the region like any other
2423 // member, and gating entry on Role::Queue while the exit is ungated
2424 // drains the occupancy below zero, after which nothing is ever refused.
2425 int rgi = region_of[i];
2426 if (rgi >= 0 && from < M && region_of[from] == rgi) rgi = -1; // stays inside
2427 if (rgi >= 0) {
2428 Region& R = regions[static_cast<std::size_t>(rgi)];
2429 if (R.would_exceed(job.cls)) {
2430 R.update(now);
2431 if (R.drops(job.cls)) {
2432 R.dropped[job.cls] += 1.0;
2433 dropped[i][job.cls] += 1.0;
2434 return false;
2435 }
2436 // WAITQ: parked outside the region, still in the system.
2437 RegionWaiter w;
2438 w.station = i;
2439 w.job = job;
2440 w.job.t_arr = now;
2441 w.job.id = ++job_id;
2442 region_wait[static_cast<std::size_t>(rgi)].push_back(w);
2443 R.blocked[job.cls] += 1.0;
2444 return true;
2445 }
2446 }
2447
2448 // Kendall's K counts the jobs HELD by the station, waiting and in
2449 // service alike; a per-class buffer caps its own class on top of that.
2450 if (s.role == Role::Queue) {
2451 double total = 0.0;
2452 for (std::size_t r = 0; r < K; ++r) total += acc.qlen[i][r] - s.blocked_at[r];
2453 if (total + 1.0 > s.cap ||
2454 acc.qlen[i][job.cls] - s.blocked_at[job.cls] + 1.0 > s.classcap[job.cls]) {
2455 // A RETRIAL class does not drop: it joins the orbit and
2456 // re-offers itself later. The orbit is a separate population --
2457 // not at the station, not in its capacity, not in its queue
2458 // length -- so nothing here is charged to the station.
2459 if (s.has_retrial[job.cls] &&
2460 (s.max_attempts[job.cls] <= 0 ||
2461 job.attempts < s.max_attempts[job.cls])) {
2462 const double dt = now - s.orbit_last;
2463 if (dt > 0.0)
2464 for (std::size_t r = 0; r < K; ++r) s.tot_orbit[r] += s.orbit_size[r] * dt;
2465 s.orbit_last = now;
2466 s.orbit_size[job.cls] += 1.0;
2467 Event e;
2468 e.t = now + s.retrial[job.cls].next(g_svc[i][job.cls]);
2469 e.kind = EV_RETRIAL;
2470 e.station = i;
2471 e.cls = job.cls;
2472 e.job = job;
2473 e.job.attempts = job.attempts + 1;
2474 push(e);
2475 return true;
2476 }
2477 if (s.has_retrial[job.cls]) s.retrial_lost[job.cls] += 1.0;
2478 dropped[i][job.cls] += 1.0;
2479 return false;
2480 }
2481 // BALKING is a refusal to JOIN, decided on what the arrival sees.
2482 // It is not a drop for want of room: the station may have plenty,
2483 // and the job declines anyway. Counted separately for that reason.
2484 if (!s.balk[job.cls].empty()) {
2485 double p = 0.0;
2486 for (const StationState::BalkRule& br : s.balk[job.cls])
2487 if (total >= br.min_jobs && (br.max_jobs < 0.0 || total <= br.max_jobs))
2488 p = std::max(p, br.probability);
2489 if (p > 0.0 && uniform01(g_routing) < p) {
2490 balked[i][job.cls] += 1.0;
2491 return false;
2492 }
2493 }
2494 }
2495 job.t_arr = now;
2496 job.priority = classprio[job.cls];
2497 // A TIME-INHOMOGENEOUS service is drawn FROM THE CURRENT INSTANT: its
2498 // rate depends on absolute time, so a duration alone does not
2499 // determine it. Every other family ignores the argument.
2500 job.service = slot_snap(S[i].svc[job.cls].next_at(g_svc[i][job.cls], now), "service time");
2501 job.remaining = job.service;
2502 job.elapsed = 0.0;
2503 job.rank = uniform01(g_routing);
2504 job.deadline = now + classdeadline[job.cls];
2505 job.id = ++job_id;
2506 if (rgi >= 0) {
2507 Region& R = regions[static_cast<std::size_t>(rgi)];
2508 R.update(now);
2509 R.enter(job.cls);
2510 }
2511 sd_advance(i);
2512 acc.update_qlen(i, job.cls, now);
2513 acc.qlen[i][job.cls] += 1.0;
2514 // Counted where the job JOINS the station, so a drop, a balk or a
2515 // reneging leaves AN above TN, which is the difference AN exists to show.
2516 acc.arrived[i][job.cls] += 1.0;
2517 bp.track(i, job.cls, +1, now);
2518
2519 if (s.role == Role::Delay) {
2520 if (has_removal_signal) delay_live[i].push_back(job);
2521 Event e;
2522 e.t = now + job.service;
2523 e.kind = EV_DEPARTURE;
2524 e.station = i;
2525 e.cls = job.cls;
2526 e.slot = std::numeric_limits<std::size_t>::max();
2527 e.job = job;
2528 push(e);
2529 return true;
2530 }
2531 if (s.ps) {
2532 ps_advance(i);
2533 // LPS admits at most `limit` jobs into the shared server; the rest
2534 // wait in the buffer under the same order a PS station would use.
2535 if (s.lps_limit > 0 && s.ps_jobs.size() >= s.lps_limit) {
2536 buffer_push(i, job);
2537 return true;
2538 }
2539 PsJob pj;
2540 pj.cls = job.cls;
2541 pj.priority = job.priority;
2542 pj.t_arr = job.t_arr;
2543 pj.t_sys = job.t_sys;
2544 pj.total = job.service;
2545 pj.remaining = job.service;
2546 pj.parent = job.parent;
2547 s.ps_jobs.push_back(pj);
2548 ps_reschedule(i);
2549 return true;
2550 }
2551 // A station that powers down: the first arrival to a cold server pays
2552 // the setup before any service starts, and one arriving inside the
2553 // delay-off window pays nothing because the server never shut down.
2554 if (s.has_setup && !s.setup_on) {
2555 buffer_push(i, job);
2556 if (!s.setup_running) {
2557 s.setup_running = true;
2558 Event e;
2559 e.t = now + s.setup_time.next(g_aux[i]);
2560 e.kind = EV_SETUP;
2561 e.station = i;
2562 e.cls = job.cls;
2563 push(e);
2564 }
2565 return true;
2566 }
2567 if (s.pas) {
2568 s.pas_list.push_back(job);
2569 acc.update_busy(i, job.cls, now);
2570 acc.busy[i][job.cls] += 1.0;
2571 pas_reschedule(i);
2572 return true;
2573 }
2574 if (s.polling) {
2575 buffer_push(i, job);
2576 if (s.poll_parked && !s.poll_switching) {
2577 s.poll_parked = false;
2578 if (job.cls == s.poll_at)
2579 poll_serve(i);
2580 else
2581 poll_advance(i);
2582 } else if (!s.server_busy[0] && !s.poll_switching) {
2583 poll_serve(i);
2584 }
2585 return true;
2586 }
2587 {
2588 const std::size_t sl = free_slot_for(i, job.cls);
2589 if (sl < s.nservers) {
2590 start_service(i, sl, job);
2591 sd_reschedule(i);
2592 return true;
2593 }
2594 }
2595 // Every server is busy, or on a pooled station every server THIS CLASS
2596 // MAY USE is. A preemptive station now asks whether this arrival
2597 // displaces one of them; the freed slot is taken by the arrival itself,
2598 // not by the head of the queue, because the victim has just been pushed
2599 // there and could otherwise be picked straight back.
2600 if (s.preemptive) {
2601 std::vector<Job> held;
2602 std::vector<std::size_t> slot_of;
2603 for (std::size_t sl = 0; sl < s.nservers; ++sl)
2604 if (s.server_busy[sl] && !s.server_blocked[sl]) {
2605 // ONLY A SLOT THE ARRIVAL COULD THEN OCCUPY is a candidate
2606 // victim: preempting a job on a pool that does not accept
2607 // this class frees a server the arrival cannot use, so the
2608 // victim would be displaced for nothing.
2609 if (s.has_pools && !s.type_compat[s.server_type[sl]][job.cls]) continue;
2610 Job h = s.server[sl];
2611 // The residual as of NOW, which is what every size-based
2612 // rule compares against; the stored value is as of the
2613 // instant service started.
2614 h.remaining = std::max(0.0, h.remaining - (now - s.server_start[sl]));
2615 h.elapsed += now - s.server_start[sl];
2616 held.push_back(h);
2617 slot_of.push_back(sl);
2618 }
2619 const std::size_t v = preemption_victim(s.sched, held, job,
2620 static_cast<double>(s.nservers), now);
2621 if (v < held.size()) {
2622 const std::size_t sl = slot_of[v];
2623 preempt(i, sl);
2624 start_service(i, sl, job);
2625 sd_reschedule(i);
2626 return true;
2627 }
2628 }
2629 buffer_push(i, job);
2630 // RENEGING times out a job that is WAITING, not one in service: a job
2631 // that reached a server has been served and no longer abandons. The
2632 // timer is therefore armed here and nowhere else, and it is re-armed
2633 // if a preemption sends the job back to the waiting room.
2634 if (s.has_patience[job.cls]) {
2635 Event e;
2636 e.t = now + s.patience[job.cls].next(g_svc[i][job.cls]);
2637 e.kind = EV_RENEGE;
2638 e.station = i;
2639 e.cls = job.cls;
2640 e.tag = job.id;
2641 push(e);
2642 }
2643 sd_reschedule(i);
2644 return true;
2645 };
2646
2647 const std::map<std::size_t, double> kNoWeights;
2648 /** Every index of `tab`, so JSQ can be told to consider all of them. */
2649 std::vector<std::size_t> tab_all;
2650
2651 /**
2652 * PROB: one draw against the static row.
2653 *
2654 * The mass is NOT renormalized before the draw. The reference scales the
2655 * uniform by the row total instead, so a row summing to one only up to
2656 * rounding still lands, and the two engines consume the routing stream
2657 * identically.
2658 */
2659 auto draw_prob = [&](const std::vector<RouteDest>& tab) -> std::size_t {
2660 double total = 0.0;
2661 for (std::size_t k = 0; k < tab.size(); ++k) total += tab[k].mass;
2662 const double u = uniform01(g_routing) * total;
2663 double cum = 0.0;
2664 std::size_t pick = 0;
2665 for (pick = 0; pick + 1 < tab.size(); ++pick) {
2666 cum += tab[pick].mass;
2667 if (u <= cum) break;
2668 }
2669 return pick;
2670 };
2671
2672 // ---- state-dependent routing (Krzesinski 1987) ----------------------------
2673 // The coefficients are derived ONCE: `pfqn_sdrcoeff` validates the
2674 // declaration and its population bounds, and repeating that at every
2675 // departure would make the validation the cost of the run.
2676 //
2677 // INDEX SPACE. The probabilities are read off the STATION-indexed structure
2678 // against station populations, and the destination split is mapped back
2679 // through the NODE-indexed twin, exactly as `state.h`'s `rt_state` does. The
2680 // two spaces must not be mixed: `pfqn_sdrprob` uses `sdr.branch[b][k]` as
2681 // indices into the population vector, so the structure and the vector have
2682 // to agree, while `entryOf`/`departure` are compared against node indices.
2683 const bool has_sdr = !sn.sdr.empty() && !sn.sdr_nodes.empty();
2684 pfqn::SdrCoeff sdr_coeff;
2685 if (has_sdr) sdr_coeff = pfqn::pfqn_sdrcoeff(sn.sdr);
2686 std::vector<double> sdr_pop, sdr_mass;
2687
2688 /**
2689 * Eq. (10) of Krzesinski (1987) at the CURRENT marking: the split out of the
2690 * entry centre into the branch entries, plus the residual that returns the
2691 * customer to the departure centre.
2692 *
2693 * A Source is reported as zero rather than as the infinite reservoir its
2694 * encoding carries, which is the `station_populations` rule and the one the
2695 * enumerating solvers use, so the sample path here has an exact counterpart
2696 * in SolverNC rather than merely a similar one.
2697 */
2698 auto draw_sdr = [&](const std::vector<RouteDest>& tab) -> std::size_t {
2699 sdr_pop.assign(M, 0.0);
2700 for (std::size_t i = 0; i < M; ++i) {
2701 if (S[i].role == Role::Source) continue;
2702 double held = 0.0;
2703 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[i][q];
2704 sdr_pop[i] = held;
2705 }
2706 const std::vector<double> Pb = pfqn::pfqn_sdrprob(sdr_coeff, sdr_pop);
2707 const double Ped = pfqn::pfqn_sdrped(Pb);
2708
2709 sdr_mass.assign(tab.size(), 0.0);
2710 double total = 0.0;
2711 for (std::size_t i = 0; i < tab.size(); ++i) {
2712 const std::size_t dnode = tab[i].node - 1; // 0-based, as sdr_nodes is
2713 double p = 0.0;
2714 for (std::size_t b = 1; b < sn.sdr_nodes.branch.size(); ++b)
2715 if (sn.sdr_nodes.entryOf[b] == dnode) p += Pb[b];
2716 if (sn.sdr_nodes.departure == dnode) p += Ped;
2717 // Eq. (10) returns zero at a branch closed to new arrivals; the
2718 // clamp only guards the residual against round-off.
2719 if (p < 0.0) p = 0.0;
2720 sdr_mass[i] = p;
2721 total += p;
2722 }
2723 // The branch probabilities and the residual sum to one over the branch
2724 // entries and the departure centre, so this normalization is an identity
2725 // whenever the declaration and the links agree. It is kept so the draw
2726 // stays a distribution if they do not, and the fallback below keeps a
2727 // model whose links reach neither from dividing by zero.
2728 if (!(total > 0.0)) return draw_prob(tab);
2729 const double u = uniform01(g_routing) * total;
2730 double cum = 0.0;
2731 std::size_t pick = 0;
2732 for (pick = 0; pick + 1 < tab.size(); ++pick) {
2733 cum += sdr_mass[pick];
2734 if (u <= cum) break;
2735 }
2736 return pick;
2737 };
2738
2739 /**
2740 * The candidate holding the FEWEST jobs, ties broken uniformly.
2741 *
2742 * A destination that is not a service station holds nothing to compare, so
2743 * it is skipped rather than counted as empty -- a Sink would otherwise win
2744 * every comparison and absorb the whole stream. When no candidate is a
2745 * station the first one is taken, which is the reference's fallback.
2746 */
2747 auto shortest_queue = [&](const std::vector<RouteDest>& tab,
2748 const std::vector<std::size_t>& cand) -> std::size_t {
2749 double best = std::numeric_limits<double>::infinity();
2750 std::vector<std::size_t> tied;
2751 for (std::size_t c = 0; c < cand.size(); ++c) {
2752 const std::size_t k = cand[c];
2753 if (tab[k].station >= M || S[tab[k].station].role == Role::Source) continue;
2754 double held = 0.0;
2755 for (std::size_t q = 0; q < K; ++q) held += acc.qlen[tab[k].station][q];
2756 if (held < best) {
2757 best = held;
2758 tied.clear();
2759 tied.push_back(k);
2760 } else if (held == best) {
2761 tied.push_back(k);
2762 }
2763 }
2764 if (tied.empty()) return cand.empty() ? 0 : cand[0];
2765 if (tied.size() == 1) return tied[0];
2766 std::size_t at = static_cast<std::size_t>(uniform01(g_routing) *
2767 static_cast<double>(tied.size()));
2768 if (at >= tied.size()) at = tied.size() - 1;
2769 return tied[at];
2770 };
2771
2772 /**
2773 * One hop out of node `inode` in class `r`: the DISPATCHER picks the node,
2774 * the class-switch row picks the class.
2775 *
2776 * NEITHER STAGE DRAWS WHEN ITS OUTCOME IS DETERMINED. A single destination
2777 * and a single arriving class each cost nothing, which is the reference's
2778 * rule and not an optimization: a routing stream that advanced on a forced
2779 * hop would put every subsequent draw out of step with the Java engine and
2780 * make a seeded run of a tandem irreproducible across the two.
2781 */
2782 auto draw_node_route = [&](std::size_t inode, std::size_t r) -> RouteEntry {
2783 const std::vector<RouteDest>& tab = nroute[inode][r];
2784 if (tab.empty())
2785 throw InputError("SolverLDES (native engine): node '" + sn.nodes[inode - 1].name +
2786 "' has no routing for class '" + sn.classes[r].name + "'");
2787 std::size_t pick = 0;
2788 if (tab.size() > 1) {
2789 if (tab_all.size() != tab.size()) {
2790 tab_all.resize(tab.size());
2791 for (std::size_t k = 0; k < tab.size(); ++k) tab_all[k] = k;
2792 }
2793 switch (node_routing[inode][r]) {
2795 pick = static_cast<std::size_t>(uniform01(g_routing) *
2796 static_cast<double>(tab.size()));
2797 if (pick >= tab.size()) pick = tab.size() - 1;
2798 break;
2799 }
2801 pick = rr_counter[inode][r] % tab.size();
2802 ++rr_counter[inode][r];
2803 break;
2804 }
2806 // The weights become an INTEGER schedule and the counter
2807 // walks it, so a 3:1 split really is three then one and not
2808 // a 0.75 coin. A fractional weight is scaled by 1000 and a
2809 // positive weight never rounds to zero, or a link declared
2810 // open would never be offered a job.
2811 const std::map<std::size_t, double>& w =
2812 (sn.nodes[inode - 1].routing_weights.size() > r)
2813 ? sn.nodes[inode - 1].routing_weights[r]
2814 : kNoWeights;
2815 std::vector<double> raw(tab.size(), 0.0);
2816 bool integral = true;
2817 for (std::size_t k = 0; k < tab.size(); ++k) {
2818 const std::map<std::size_t, double>::const_iterator it = w.find(tab[k].node);
2819 raw[k] = (it == w.end()) ? 0.0 : it->second;
2820 if (raw[k] < 0.0 || raw[k] != std::floor(raw[k])) integral = false;
2821 }
2822 std::vector<std::size_t> quota(tab.size(), 0);
2823 std::size_t total = 0;
2824 for (std::size_t k = 0; k < tab.size(); ++k) {
2825 double v = integral ? raw[k] : raw[k] * 1000.0;
2826 std::size_t q = (v > 0.0) ? static_cast<std::size_t>(v) : 0;
2827 if (raw[k] > 0.0 && q < 1) q = 1;
2828 quota[k] = q;
2829 total += q;
2830 }
2831 if (total == 0) {
2832 // No usable weights: the dispatcher degenerates to plain
2833 // round robin, which is the reference's fallback.
2834 pick = rr_counter[inode][r] % tab.size();
2835 ++rr_counter[inode][r];
2836 break;
2837 }
2838 std::size_t counter = rr_counter[inode][r] % total;
2839 ++rr_counter[inode][r];
2840 std::size_t cum = 0;
2841 for (pick = 0; pick + 1 < tab.size(); ++pick) {
2842 cum += quota[pick];
2843 if (counter < cum) break;
2844 }
2845 break;
2846 }
2848 // Unlike JSQ and SQ(d) this routing keeps a product form of
2849 // its own, so this sample path has an exact counterpart in
2850 // solver_nc_sdr. The two agree only if both read the same
2851 // populations; see draw_sdr. A routing named SDR with no
2852 // declaration behind it is a malformed model every reader
2853 // refuses, and draw_prob is what it falls back to rather
2854 // than dividing by an absent structure.
2855 pick = has_sdr ? draw_sdr(tab) : draw_prob(tab);
2856 break;
2857 }
2859 pick = shortest_queue(tab, tab_all);
2860 break;
2861 }
2863 // POWER OF d: sample d candidates WITHOUT replacement, then
2864 // take the shortest among them. With d at least the number
2865 // of destinations the sampling is vacuous and it IS JSQ.
2866 std::size_t d = (sn.nodes[inode - 1].routing_param.size() > r &&
2867 sn.nodes[inode - 1].routing_param[r] > 0)
2868 ? static_cast<std::size_t>(
2869 sn.nodes[inode - 1].routing_param[r])
2870 : 2;
2871 if (tab.size() <= d) {
2872 pick = shortest_queue(tab, tab_all);
2873 break;
2874 }
2875 std::vector<std::size_t> avail(tab.size());
2876 for (std::size_t k = 0; k < tab.size(); ++k) avail[k] = k;
2877 std::vector<std::size_t> cand;
2878 for (std::size_t k = 0; k < d && !avail.empty(); ++k) {
2879 std::size_t at = static_cast<std::size_t>(
2880 uniform01(g_routing) * static_cast<double>(avail.size()));
2881 if (at >= avail.size()) at = avail.size() - 1;
2882 cand.push_back(avail[at]);
2883 avail.erase(avail.begin() + static_cast<std::ptrdiff_t>(at));
2884 }
2885 pick = shortest_queue(tab, cand);
2886 break;
2887 }
2888 default: {
2889 pick = draw_prob(tab);
2890 break;
2891 }
2892 }
2893 }
2894 const RouteDest& d = tab[pick];
2895 RouteEntry e;
2896 e.node = d.node;
2897 e.station = d.station;
2898 e.sink = d.sink;
2899 e.cls = d.cls[0].first;
2900 if (d.cls.size() > 1) {
2901 double total = 0.0;
2902 for (std::size_t k = 0; k < d.cls.size(); ++k) total += d.cls[k].second;
2903 const double u = uniform01(g_routing) * total;
2904 double cum = 0.0;
2905 for (std::size_t k = 0; k < d.cls.size(); ++k) {
2906 cum += d.cls[k].second;
2907 if (u <= cum || k + 1 == d.cls.size()) {
2908 e.cls = d.cls[k].first;
2909 break;
2910 }
2911 }
2912 }
2913 return e;
2914 };
2915
2916 /**
2917 * The class a job routed out of `node` under `cls` will ARRIVE the true
2918 * destination station under, resolving through any ClassSwitch/Router/
2919 * Logger chain `draw_node_route` itself stops one hop short of.
2920 *
2921 * `draw_node_route(i, r).cls` names the class on the FIRST edge out of a
2922 * station, and when `link()` synthesized a ClassSwitch node for that edge
2923 * (any route whose class differs from station to station), that first
2924 * edge's class is the PRE-switch one -- the switch happens INSIDE the
2925 * synthesized node, one hop later. The synchronous-call park check below
2926 * needs the class the job actually lands under, or a callee's own reply
2927 * (its edge into the synthesized node still reads as the CALL class) is
2928 * misread as a brand-new call: the reply's `job.call` is then overwritten
2929 * with a fresh id, the caller's real call is never answered, and its
2930 * server stays held while the buffer behind it grows without bound.
2931 *
2932 * Consumes no extra draw when every hop is forced, which a link()-
2933 * synthesized pass-through node always is (`draw_node_route` never spends
2934 * a draw on a single-destination table), so the routing stream stays in
2935 * step with a caller that already resolved through this same node.
2936 */
2937 auto resolve_final_cls = [&](std::size_t node, std::size_t cls) -> std::size_t {
2938 std::size_t at_node = node, at_cls = cls;
2939 for (std::size_t hop = 0; hop <= nnodes && at_node != 0; ++hop) {
2940 const NodeType dt = sn.nodes[at_node - 1].nodetype;
2941 if (dt != NodeType::ClassSwitch && dt != NodeType::Router && dt != NodeType::Logger)
2942 break;
2943 const RouteEntry e = draw_node_route(at_node, at_cls);
2944 at_cls = e.cls;
2945 at_node = e.node;
2946 }
2947 return at_cls;
2948 };
2949
2950 // ---- busy periods -------------------------------------------------------
2951 // Every station is a target set of its own, every declared subnetwork is a
2952 // set, and each is measured aggregated and then per class, which is the
2953 // order `initializeBusyPeriods` builds them in and the order the result
2954 // rows carry.
2955 if (o.busy_period_orders > 0) {
2956 std::vector<std::vector<std::size_t>> sets;
2957 std::vector<std::string> set_names;
2958 for (std::size_t i = 0; i < M; ++i) {
2959 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) continue;
2960 sets.push_back(std::vector<std::size_t>(1, i));
2961 set_names.push_back(sn.stations[i].name);
2962 }
2963 for (const std::vector<std::size_t>& sub : o.busy_period_subnets) {
2964 std::string nm;
2965 for (std::size_t s2 : sub) {
2966 if (s2 >= M || S[s2].role == Role::Source)
2967 throw InputError("SolverLDES (native engine): station " +
2968 std::to_string(s2) +
2969 " of a busy period subnetwork is not a service station");
2970 nm += (nm.empty() ? "" : "+") + sn.stations[s2].name;
2971 }
2972 sets.push_back(sub);
2973 set_names.push_back(nm);
2974 }
2975 std::vector<BpTarget> targets;
2976 for (std::size_t si = 0; si < sets.size(); ++si) {
2977 BpTarget t;
2978 t.stations = sets[si];
2979 t.job_class = -1;
2980 t.name = set_names[si];
2981 targets.push_back(t);
2982 for (std::size_t r = 0; r < K; ++r) {
2983 BpTarget tc;
2984 tc.stations = sets[si];
2985 tc.job_class = static_cast<int>(r);
2986 tc.name = set_names[si] + ":" + sn.classes[r].name;
2987 targets.push_back(tc);
2988 }
2989 }
2990 bp.init(targets, o.busy_period_orders, M);
2991 }
2992
2993
2994 /**
2995 * Hand on the jobs blocked in front of `j`, oldest first, now that it has
2996 * room.
2997 *
2998 * OLDEST FIRST is the reference's rule and it matters: releasing the newest
2999 * would starve a server that has been blocked longest, which no blocking
3000 * discipline does and which shows up as an unbounded blocked time at one
3001 * station while another cycles freely.
3002 */
3003 std::function<void(std::size_t)> release_blocked = [&](std::size_t j) {
3004 bool progress = true;
3005 while (progress) {
3006 progress = false;
3007 std::size_t best_i = M, best_sl = 0;
3008 double oldest = std::numeric_limits<double>::infinity();
3009 for (std::size_t a = 0; a < M; ++a) {
3010 // A SHARING station has no per-server slots at all, so it has
3011 // no slot to block and none of these vectors is allocated for
3012 // it. Indexing them by `nservers` reads off the end.
3013 if (S[a].role != Role::Queue || S[a].ps || S[a].server_blocked.empty()) continue;
3014 for (std::size_t sl = 0; sl < S[a].nservers; ++sl)
3015 if (S[a].server_blocked[sl] && S[a].blocked_dest[sl] == j &&
3016 S[a].blocked_job[sl].t_arr < oldest) {
3017 oldest = S[a].blocked_job[sl].t_arr;
3018 best_i = a;
3019 best_sl = sl;
3020 }
3021 }
3022 if (best_i >= M) break;
3023 const std::size_t dcls = S[best_i].blocked_dest_cls[best_sl];
3024 if (!dest_has_room(j, dcls)) break;
3025
3026 StationState& src = S[best_i];
3027 Job moved = src.blocked_job[best_sl];
3028 src.server_blocked[best_sl] = false;
3029 src.server_busy[best_sl] = false;
3030 src.server_tag[best_sl] = 0;
3031 acc.update_qlen(j, dcls, now);
3032 acc.qlen[j][dcls] -= 1.0;
3033 S[j].blocked_at[dcls] -= 1.0;
3034
3035 // The job now leaves the upstream station for good.
3036 acc.update_busy(best_i, moved.cls, now);
3037 if (acc.busy[best_i][moved.cls] > 0.0) acc.busy[best_i][moved.cls] -= 1.0;
3038
3039 Job fresh;
3040 fresh.cls = dcls;
3041 fresh.t_sys = moved.t_sys;
3042 fresh.parent = moved.parent;
3043 admit(j, fresh, best_i);
3044
3045 // The freed server takes its own next waiting job -- on a pooled
3046 // station, the first one in service order that its pool accepts.
3047 if (buffer_has_for_slot(best_i, best_sl)) {
3048 Job nextjob = buffer_pop_for_slot(best_i, best_sl);
3049 start_service(best_i, best_sl, nextjob);
3050 }
3051 progress = true;
3052 }
3053 };
3054
3055
3056 /**
3057 * Let the jobs parked by region `rg` in, oldest first, now that it has
3058 * room.
3059 *
3060 * A waiter re-enters through `admit`, which re-tests every constraint: the
3061 * region may have freed one slot while the waiter's own class cap or the
3062 * linear constraints still bind, and admitting on the global count alone
3063 * would breach them.
3064 */
3065 std::function<void(std::size_t)> release_region = [&](std::size_t rg) {
3066 std::vector<RegionWaiter>& q = region_wait[rg];
3067 bool progress = true;
3068 while (progress && !q.empty()) {
3069 progress = false;
3070 for (std::size_t w = 0; w < q.size(); ++w) {
3071 Region& R = regions[rg];
3072 if (R.would_exceed(q[w].job.cls)) continue;
3073 RegionWaiter taken = q[w];
3074 R.update(now);
3075 R.blocked[taken.job.cls] -= 1.0;
3076 q.erase(q.begin() + static_cast<std::ptrdiff_t>(w));
3077 Job fresh;
3078 fresh.cls = taken.job.cls;
3079 fresh.t_sys = taken.job.t_sys;
3080 admit(taken.station, fresh, M);
3081 progress = true;
3082 break;
3083 }
3084 }
3085 };
3086
3087 // ---- G-network signals --------------------------------------------------
3088
3089 /** The jobs station `i` actually HOLDS, waiting and in service alike. */
3090 auto signal_held = [&](std::size_t i) -> std::size_t {
3091 const StationState& s = S[i];
3092 if (s.role == Role::Delay) return delay_live[i].size();
3093 if (s.pas) return s.pas_list.size();
3094 std::size_t n = s.buffer.size();
3095 if (s.ps) return n + s.ps_jobs.size();
3096 for (std::size_t sl = 0; sl < s.nservers; ++sl)
3097 if (s.server_busy[sl]) ++n;
3098 return n;
3099 };
3100
3101 /**
3102 * Remove ONE job from station `i` under `policy`, returning its class or
3103 * `K` when the station holds nothing.
3104 *
3105 * THE POLICY IS TWO-TIER, as in the reference: FCFS and LCFS rank the
3106 * WAITING line by age and only reach into the servers once nobody waits,
3107 * while RANDOM draws uniformly over waiting and in-service jobs together.
3108 * A station whose occupants are all in service -- Delay, PS, PAS -- has no
3109 * waiting line for the age tiers to rank, so there the three policies
3110 * differ only in which of the equally-served jobs is taken.
3111 *
3112 * The victim is drawn WITHOUT REGARD TO CLASS. That is Gelenbe's negative
3113 * customer and what the Java engine and SolverMAM both do; `sn.signaltarget`
3114 * narrows the eligible class only in the state-space solvers, whose state
3115 * carries the per-class composition this one draws over directly.
3116 */
3117 auto signal_remove_one = [&](std::size_t i, lang::RemovalPolicy policy) -> std::size_t {
3118 StationState& s = S[i];
3119
3120 // A DELAY holds every occupant in service, in the registry.
3121 if (s.role == Role::Delay) {
3122 std::vector<Job>& live = delay_live[i];
3123 if (live.empty()) return K;
3124 std::size_t at = 0;
3125 if (policy == lang::RemovalPolicy::FCFS) {
3126 for (std::size_t j = 1; j < live.size(); ++j)
3127 if (live[j].t_arr < live[at].t_arr) at = j;
3128 } else if (policy == lang::RemovalPolicy::LCFS) {
3129 for (std::size_t j = 1; j < live.size(); ++j)
3130 if (live[j].t_arr > live[at].t_arr) at = j;
3131 } else {
3132 at = static_cast<std::size_t>(uniform01(g_routing) *
3133 static_cast<double>(live.size()));
3134 if (at >= live.size()) at = live.size() - 1;
3135 }
3136 const std::size_t rc = live[at].cls;
3137 live.erase(live.begin() + static_cast<std::ptrdiff_t>(at));
3138 return rc;
3139 }
3140
3141 // A PASS-AND-SWAP list: one aggregate clock covers the whole station,
3142 // so the survivors must be re-timed rather than left on a rate the
3143 // list no longer has.
3144 if (s.pas) {
3145 if (s.pas_list.empty()) return K;
3146 std::size_t at = 0;
3147 if (policy == lang::RemovalPolicy::FCFS) {
3148 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
3149 if (s.pas_list[j].t_arr < s.pas_list[at].t_arr) at = j;
3150 } else if (policy == lang::RemovalPolicy::LCFS) {
3151 for (std::size_t j = 1; j < s.pas_list.size(); ++j)
3152 if (s.pas_list[j].t_arr > s.pas_list[at].t_arr) at = j;
3153 } else {
3154 at = static_cast<std::size_t>(uniform01(g_routing) *
3155 static_cast<double>(s.pas_list.size()));
3156 if (at >= s.pas_list.size()) at = s.pas_list.size() - 1;
3157 }
3158 const std::size_t rc = s.pas_list[at].cls;
3159 s.pas_list.erase(s.pas_list.begin() + static_cast<std::ptrdiff_t>(at));
3160 acc.update_busy(i, rc, now);
3161 if (acc.busy[i][rc] > 0.0) acc.busy[i][rc] -= 1.0;
3162 pas_reschedule(i);
3163 return rc;
3164 }
3165
3166 const std::size_t waiting = s.buffer.size();
3167
3168 // A SHARING station: `ps_jobs` are in service, `buffer` holds only the
3169 // jobs an LPS admission cap has left outside.
3170 if (s.ps) {
3171 const std::size_t inservice = s.ps_jobs.size();
3172 if (waiting + inservice == 0) return K;
3173 bool from_wait;
3174 if (policy == lang::RemovalPolicy::RANDOM) {
3175 const std::size_t pick = static_cast<std::size_t>(
3176 uniform01(g_routing) * static_cast<double>(waiting + inservice));
3177 from_wait = pick < waiting;
3178 } else {
3179 from_wait = waiting > 0;
3180 }
3181 if (from_wait) {
3182 std::size_t at = 0;
3183 if (policy == lang::RemovalPolicy::FCFS) {
3184 for (std::size_t j = 1; j < s.buffer.size(); ++j)
3185 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
3186 } else if (policy == lang::RemovalPolicy::LCFS) {
3187 for (std::size_t j = 1; j < s.buffer.size(); ++j)
3188 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
3189 } else {
3190 at = std::min(waiting - 1,
3191 static_cast<std::size_t>(uniform01(g_routing) *
3192 static_cast<double>(waiting)));
3193 }
3194 const std::size_t rc = s.buffer[at].cls;
3195 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
3196 if (s.sched != SchedStrategy::FSP)
3197 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
3198 return rc;
3199 }
3200 ps_advance(i);
3201 std::size_t at = 0;
3202 if (policy == lang::RemovalPolicy::FCFS) {
3203 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
3204 if (s.ps_jobs[j].t_arr < s.ps_jobs[at].t_arr) at = j;
3205 } else if (policy == lang::RemovalPolicy::LCFS) {
3206 for (std::size_t j = 1; j < s.ps_jobs.size(); ++j)
3207 if (s.ps_jobs[j].t_arr > s.ps_jobs[at].t_arr) at = j;
3208 } else {
3209 at = std::min(inservice - 1,
3210 static_cast<std::size_t>(uniform01(g_routing) *
3211 static_cast<double>(inservice)));
3212 }
3213 const std::size_t rc = s.ps_jobs[at].cls;
3214 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(at));
3215 // The freed share is taken by whatever an LPS cap was holding out.
3216 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
3217 Job nextjob = buffer_pop(i);
3218 PsJob pj;
3219 pj.cls = nextjob.cls;
3220 pj.priority = nextjob.priority;
3221 pj.t_arr = nextjob.t_arr;
3222 pj.t_sys = nextjob.t_sys;
3223 pj.total = nextjob.service;
3224 pj.remaining = nextjob.service;
3225 pj.parent = nextjob.parent;
3226 s.ps_jobs.push_back(pj);
3227 }
3228 ps_reschedule(i);
3229 return rc;
3230 }
3231
3232 // A BUFFERED station. A blocked slot is NOT a candidate: the job it
3233 // holds has already completed here and is charged to the destination
3234 // it is queueing for, so removing it would decrement the wrong station.
3235 std::size_t inservice = 0;
3236 for (std::size_t sl = 0; sl < s.nservers; ++sl)
3237 if (s.server_busy[sl]) ++inservice;
3238 if (waiting + inservice == 0) return K;
3239 bool from_wait;
3240 if (policy == lang::RemovalPolicy::RANDOM) {
3241 const std::size_t pick = static_cast<std::size_t>(
3242 uniform01(g_routing) * static_cast<double>(waiting + inservice));
3243 from_wait = pick < waiting;
3244 } else {
3245 from_wait = waiting > 0;
3246 }
3247
3248 if (from_wait) {
3249 std::size_t at = 0;
3250 if (policy == lang::RemovalPolicy::FCFS) {
3251 for (std::size_t j = 1; j < s.buffer.size(); ++j)
3252 if (s.buffer[j].t_arr < s.buffer[at].t_arr) at = j;
3253 } else if (policy == lang::RemovalPolicy::LCFS) {
3254 for (std::size_t j = 1; j < s.buffer.size(); ++j)
3255 if (s.buffer[j].t_arr > s.buffer[at].t_arr) at = j;
3256 } else {
3257 at = std::min(waiting - 1,
3258 static_cast<std::size_t>(uniform01(g_routing) *
3259 static_cast<double>(waiting)));
3260 }
3261 const std::size_t rc = s.buffer[at].cls;
3262 s.buffer.erase(s.buffer.begin() + static_cast<std::ptrdiff_t>(at));
3263 if (s.sched != SchedStrategy::FSP)
3264 std::make_heap(s.buffer.begin(), s.buffer.end(), s.cmp);
3265 return rc;
3266 }
3267
3268 std::size_t slot = s.nservers;
3269 for (std::size_t sl = 0; sl < s.nservers; ++sl) {
3270 if (!s.server_busy[sl]) continue;
3271 if (slot == s.nservers) {
3272 slot = sl;
3273 continue;
3274 }
3275 if (policy == lang::RemovalPolicy::FCFS) {
3276 if (s.server[sl].t_arr < s.server[slot].t_arr) slot = sl;
3277 } else if (policy == lang::RemovalPolicy::LCFS) {
3278 if (s.server[sl].t_arr > s.server[slot].t_arr) slot = sl;
3279 }
3280 }
3281 if (policy == lang::RemovalPolicy::RANDOM) {
3282 std::size_t pick = std::min(inservice - 1,
3283 static_cast<std::size_t>(uniform01(g_routing) *
3284 static_cast<double>(inservice)));
3285 for (std::size_t sl = 0; sl < s.nservers; ++sl)
3286 if (s.server_busy[sl]) {
3287 if (pick == 0) {
3288 slot = sl;
3289 break;
3290 }
3291 --pick;
3292 }
3293 }
3294 if (slot == s.nservers) return K;
3295 sd_advance(i);
3296 const std::size_t rc = s.server[slot].cls;
3297 // Freeing the slot and clearing its tag is what neutralises the
3298 // departure already in the list; there is no handle to cancel it with.
3299 s.server_busy[slot] = false;
3300 s.server_tag[slot] = 0;
3301 acc.update_busy(i, rc, now);
3302 acc.busy[i][rc] -= 1.0;
3303 // The vacated server takes the next job, under the station's own order:
3304 // the polling controller decides for itself, everyone else pops.
3305 if (s.polling) {
3306 poll_serve(i);
3307 } else if (!s.server_blocked[slot] && !s.server_held[slot] &&
3308 buffer_has_for_slot(i, slot)) {
3309 Job nextjob = buffer_pop_for_slot(i, slot);
3310 start_service(i, slot, nextjob);
3311 }
3312 sd_reschedule(i);
3313 return rc;
3314 };
3315
3316 /**
3317 * A G-NETWORK REMOVAL SIGNAL arriving at station `i`, and annihilated there.
3318 *
3319 * The signal NEVER JOINS the station and never continues along its routing
3320 * chain: it removes a batch of the jobs it finds, and one that finds the
3321 * station empty is simply lost. That is Gelenbe's semantics and the one
3322 * `State.afterEventStationSignal` and the Java engine both implement;
3323 * routing the signal onward instead would make a single signal fire once
3324 * per downstream station, which is what the tandem regression measures.
3325 *
3326 * A CATASTROPHE removes EVERY job held, in-service ones included, and
3327 * ignores the batch-size law entirely -- removing all of them is what the
3328 * word means. A NEGATIVE signal draws its batch size from `signalremdist`
3329 * (absent: exactly one) and CLIPS it at what the station holds, so an
3330 * oversized batch drains the station instead of driving it negative.
3331 */
3332 auto apply_signal = [&](std::size_t i, const Job& sig) {
3333 StationState& s = S[i];
3334 const std::size_t held = signal_held(i);
3335 if (held > 0) {
3336 std::size_t to_remove = 1;
3337 if (sig.cls < sn.signaltype.size() &&
3338 sn.signaltype[sig.cls] == lang::SignalType::CATASTROPHE) {
3339 to_remove = held;
3340 } else if (sig.cls < sn.signalremdist.size() && !sn.signalremdist[sig.cls].empty()) {
3341 const std::vector<T>& pmf = sn.signalremdist[sig.cls];
3342 const double u = uniform01(g_routing);
3343 double cum = 0.0;
3344 std::size_t b = 0;
3345 for (; b + 1 < pmf.size(); ++b) {
3346 cum += static_cast<double>(pmf[b]);
3347 if (u < cum) break;
3348 }
3349 to_remove = std::min(b, held);
3350 } else {
3351 to_remove = std::min<std::size_t>(1, held);
3352 }
3354 if (sig.cls < sn.signalrempolicy.size()) policy = sn.signalrempolicy[sig.cls];
3355 for (std::size_t rep = 0; rep < to_remove; ++rep) {
3356 if (signal_held(i) == 0) break;
3357 const std::size_t rc = signal_remove_one(i, policy);
3358 if (rc >= K) break;
3359 sd_advance(i);
3360 acc.update_qlen(i, rc, now);
3361 acc.qlen[i][rc] -= 1.0;
3362 bp.track(i, rc, -1, now);
3363 // A job destroyed inside a finite capacity region frees the
3364 // slot it occupied; leaving it charged would shrink the region
3365 // by one for the rest of the run.
3366 if (region_of[i] >= 0) {
3367 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
3368 regions[rg].update(now);
3369 regions[rg].leave(rc);
3370 release_region(rg);
3371 }
3372 }
3373 release_blocked(i);
3374 }
3375 // The signal is annihilated here: it leaves the system without ever
3376 // having been at a station, so it is a system completion and nothing
3377 // else -- no arrival, no queue length and no station throughput.
3378 sys_resp_sum[sig.cls] += now - sig.t_sys;
3379 sys_resp_cnt[sig.cls] += 1.0;
3380 sys_completed[sig.cls] += 1.0;
3381 };
3382
3383
3384 /**
3385 * The completion budget, spent by anything the model actually DOES.
3386 *
3387 * Declared ahead of `deliver` because a service completion is not the only
3388 * thing that spends it: a Petri net has no services of its own and counts a
3389 * FIRING, and a cache-only model (Source -> Cache -> Sink) has none either
3390 * and counts the cache's delivery to the Sink. Without the latter the loop
3391 * at the bottom of this function never terminates on such a model, since
3392 * the Source keeps the event queue non-empty forever. `Solver_ssj` spends
3393 * the budget at the same point (`deliverCacheCompletion` calls
3394 * `checkEventCountStop` on the sink branch), so this is the reference's
3395 * rule and not a local convention.
3396 */
3397 std::uint64_t total_completions = 0;
3398
3399 /**
3400 * Free every request parked while `item` was being fetched.
3401 *
3402 * Declared ahead of `deliver` and assigned after it because the two are
3403 * mutually recursive: a release routes each freed request onward, and a
3404 * delivery to a cache is what triggers a release. Each freed request reads
3405 * the now-cached item and completes at once, counted as a DELAYED HIT and
3406 * leaving under its own hit class; its original `t_sys` is preserved so the
3407 * response time still spans the wait it actually served.
3408 */
3409 std::function<void(CacheState&, std::size_t, std::size_t)> release_delayed_hits;
3410
3411 /**
3412 * Hand a job to a NODE, replicating at a Fork and synchronizing at a Join.
3413 *
3414 * A FORK sends a sibling down EVERY outgoing edge, not one drawn from the
3415 * routing probabilities: the edges of a fork are branches taken together,
3416 * and drawing among them would turn a fork into a probabilistic split with
3417 * the same picture and a completely different response time. `tasksPerLink`
3418 * multiplies each branch.
3419 *
3420 * A JOIN releases ONE job once its strategy is satisfied and DISCARDS the
3421 * siblings that arrive afterwards -- under a quorum they are the losers of
3422 * the race, and counting them as completions would inflate the throughput
3423 * of everything downstream.
3424 */
3425 std::function<void(std::size_t, Job, std::size_t)> deliver =
3426 [&](std::size_t node, Job job, std::size_t from) {
3427 const NodeType nt = sn.nodes[node - 1].nodetype;
3428
3429 if (nt == NodeType::Sink) {
3430 sys_resp_sum[job.cls] += now - job.t_sys;
3431 sys_resp_cnt[job.cls] += 1.0;
3432 sys_completed[job.cls] += 1.0;
3433 return;
3434 }
3435
3436 if (nt == NodeType::Cache) {
3437 auto ci = caches.find(node);
3438 if (ci == caches.end())
3439 throw InputError("SolverLDES (native engine): Cache node '" +
3440 sn.nodes[node - 1].name + "' carries no parameters");
3441 CacheState& cs = ci->second;
3442 Job out = job;
3443
3444 // A RETURNING FETCH, not a read. The job wearing a retrieval class
3445 // is the fetch this cache sent out on an earlier miss: it inserts
3446 // the item, clears the in-flight flag and frees everyone parked
3447 // behind it. The miss was counted when the fetch was TRIGGERED, so
3448 // counting it again here would double every miss.
3449 if (cs.has_retrieval) {
3450 const int fetched = cs.fetches_item(job.cls);
3451 if (fetched >= 0) {
3452 const std::size_t fi = static_cast<std::size_t>(fetched);
3453 cache_miss(cs, fi, uniform01(g_routing), uniform01(g_routing));
3454 cs.in_flight[fi] = 0;
3455 cs.total_fetch_time += now - cs.fetch_start[fi];
3456 cs.completed_fetches += 1.0;
3457 release_delayed_hits(cs, fi, node);
3458 if (cs.miss_class[job.cls] >= 0)
3459 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
3460 const RouteEntry re = draw_node_route(node, out.cls);
3461 out.cls = re.cls;
3462 if (re.node != 0 && sn.nodes[re.node - 1].nodetype == NodeType::Sink)
3463 ++total_completions;
3464 deliver(re.node, out, M);
3465 return;
3466 }
3467 }
3468
3469 const std::vector<double>& pop = cs.popularity[job.cls];
3470 if (pop.empty())
3471 throw InputError("SolverLDES (native engine): class '" +
3472 sn.classes[job.cls].name + "' reads cache '" +
3473 sn.nodes[node - 1].name + "' with no item popularity");
3474 const double u = uniform01(g_routing);
3475 std::size_t item = 0;
3476 while (item + 1 < pop.size() && u >= pop[item]) ++item;
3477
3478 const int at = cs.find(item);
3479 if (at >= 0) {
3480 cs.hits[job.cls] += 1.0;
3481 cache_hit(cs, item, static_cast<std::size_t>(at), uniform01(g_routing));
3482 if (cs.hit_class[job.cls] >= 0)
3483 out.cls = static_cast<std::size_t>(cs.hit_class[job.cls]);
3484 } else if (cs.has_retrieval && cs.retrieval_of(item, job.cls) > 0) {
3485 if (cs.in_flight[item]) {
3486 // A fetch for this item is already out: PARK the request. It
3487 // leaves the event stream entirely and is released when the
3488 // fetch returns, so it is neither a hit nor a miss yet.
3489 CacheState::HeldRequest hr;
3490 hr.cls = job.cls;
3491 hr.hold_time = now;
3492 hr.t_sys = job.t_sys;
3493 cs.held[item].push_back(hr);
3494 return;
3495 }
3496 // Trigger the fetch: count the miss now and leave wearing the
3497 // per-item retrieval class, which the routing carries through
3498 // the retrieval stations and back to this cache.
3499 cs.misses[job.cls] += 1.0;
3500 cs.in_flight[item] = 1;
3501 cs.fetch_start[item] = now;
3502 out.cls = cs.retrieval_of(item, job.cls) - 1;
3503 } else {
3504 cs.misses[job.cls] += 1.0;
3505 cache_miss(cs, item, uniform01(g_routing), uniform01(g_routing));
3506 if (cs.miss_class[job.cls] >= 0)
3507 out.cls = static_cast<std::size_t>(cs.miss_class[job.cls]);
3508 }
3509 // The hit and miss classes are what the POST_CACHE branch routes on,
3510 // so the job leaves the cache under its new class.
3511 const RouteEntry e = draw_node_route(node, out.cls);
3512 out.cls = e.cls;
3513 // A cache delivering to a Sink is this model's only completion when
3514 // it has no service station, so it spends the budget (see above).
3515 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
3516 ++total_completions;
3517 deliver(e.node, out, M);
3518 return;
3519 }
3520
3521 if (nt == NodeType::ClassSwitch || nt == NodeType::Router || nt == NodeType::Logger) {
3522 // A PASS-THROUGH NODE HOLDS NOTHING. It changes the class (a
3523 // ClassSwitch), picks a destination (a Router) or records the
3524 // crossing (a Logger) and the job continues in the SAME instant, so
3525 // there is no queue, no service and no row to fill.
3526 //
3527 // `link()` synthesizes `CS_<i>_to_<j>` for every link that switches
3528 // class, so this branch carries every class switch the routing
3529 // matrix used to hold as an edge attribute. Such a node has ONE
3530 // outgoing destination, so `draw_node_route` spends no draw on it
3531 // and the sample path stays in step with the Java engine.
3532 //
3533 // The walk is iterative, not recursive, and bounded: a routing that
3534 // sends a job round a cycle of pass-through nodes never reaches a
3535 // station and would otherwise hang the run.
3536 Job out = job;
3537 std::size_t at = node;
3538 for (std::size_t hop = 0; hop <= nnodes; ++hop) {
3539 const RouteEntry e = draw_node_route(at, out.cls);
3540 out.cls = e.cls;
3541 if (e.node == 0) return;
3542 const NodeType dt = sn.nodes[e.node - 1].nodetype;
3543 if (dt == NodeType::ClassSwitch || dt == NodeType::Router ||
3544 dt == NodeType::Logger) {
3545 at = e.node;
3546 continue;
3547 }
3548 deliver(e.node, out, from);
3549 return;
3550 }
3551 throw InputError("SolverLDES (native engine): the routing out of node '" +
3552 sn.nodes[node - 1].name +
3553 "' cycles through pass-through nodes without reaching a station");
3554 }
3555
3556 if (nt == NodeType::Fork) {
3557 // A FORK TAKES EVERY EDGE, so it flattens the two-stage table back
3558 // into one sibling per (destination, arriving class): there is no
3559 // dispatcher decision to make and no draw to spend.
3560 const std::vector<RouteDest>& tab = nroute[node][job.cls];
3561 if (tab.empty()) return;
3562 std::vector<RouteEntry> links;
3563 for (std::size_t di = 0; di < tab.size(); ++di)
3564 for (std::size_t ci = 0; ci < tab[di].cls.size(); ++ci) {
3565 RouteEntry e;
3566 e.node = tab[di].node;
3567 e.station = tab[di].station;
3568 e.sink = tab[di].sink;
3569 e.cls = tab[di].cls[ci].first;
3570 links.push_back(e);
3571 }
3572 const qn::NodeDef& fk = sn.nodes[node - 1];
3573 const qn::ForkParam<double>* fp = sn.fork_param_of(node);
3574 const int per_link = std::max(1, static_cast<int>(fk.tasks_per_link + 0.5));
3575
3576 // How many tasks each branch emits on THIS firing. A plain fork
3577 // takes `per_link` on every link and draws NOTHING -- which is what
3578 // keeps every seeded golden and the bit-exact agreement with the
3579 // Java engine intact. A variable one draws the branch activation
3580 // and the degree per link, so the count the Join waits for is known
3581 // only after the draws.
3582 std::vector<int> per_branch(links.size(), per_link);
3583 int total = 0;
3584 const bool variable = fork_is_variable(node);
3585 for (std::size_t li = 0; li < links.size(); ++li) {
3586 if (variable) {
3587 const std::size_t k0 = links[li].node - 1, r0 = job.cls;
3588 const double p = fp->fan_out_prob(k0, r0);
3589 if (p < 1.0 && g_fork.aux.next_double() >= p) {
3590 per_branch[li] = 0;
3591 } else if (!fp->fan_out_dist[k0][r0].disabled) {
3592 per_branch[li] = sample_fork_degree(fp->fan_out_dist[k0][r0]);
3593 } else {
3594 per_branch[li] = static_cast<int>(fp->fan_out_link(k0, r0) + 0.5);
3595 }
3596 }
3597 total += per_branch[li];
3598 }
3599 if (total <= 0)
3600 throw InputError("SolverLDES (native engine): Fork '" + fk.name +
3601 "' emitted no sibling; at least one branch must be certain "
3602 "to emit at least one task");
3603
3604 ForkSync fs;
3605 fs.cls = job.cls;
3606 fs.t_sys = job.t_sys;
3607 fs.total = total;
3608 fs.required = fs.total;
3609 const int jn = join_of_fork[node];
3610 if (jn > 0) {
3611 auto jd = sn.joindecl.find(static_cast<std::size_t>(jn));
3612 if (jd != sn.joindecl.end() &&
3613 jd->second.strategy != lang::JoinStrategy::STD && jd->second.quorum > 0.0) {
3614 // The quorum is a count of THIS fork's siblings, fixed here
3615 // rather than at the Join: a Join shared by two forks of
3616 // different fan-out would otherwise apply one fork's count
3617 // to the other's siblings.
3618 fs.required = std::min(fs.total,
3619 std::max(1, static_cast<int>(jd->second.quorum + 0.5)));
3620 }
3621 }
3622 const std::uint64_t pid = ++next_parent;
3623 fork_sync[pid] = fs;
3624 for (std::size_t li = 0; li < links.size(); ++li)
3625 for (int t = 0; t < per_branch[li]; ++t) {
3626 Job sib;
3627 sib.cls = links[li].cls;
3628 sib.t_sys = job.t_sys;
3629 sib.parent = pid;
3630 deliver(links[li].node, sib, M);
3631 }
3632 return;
3633 }
3634
3635 if (nt == NodeType::Join) {
3636 auto it = fork_sync.find(job.parent);
3637 if (it == fork_sync.end()) {
3638 // Its parent already synchronized: this sibling lost the quorum
3639 // race and is discarded rather than released a second time. It
3640 // goes into the SAME accumulators every other station uses, so
3641 // it is warmup-truncated with them; see Accum::join_dropped.
3642 //
3643 // IT STILL ARRIVED. `arrived` is the OFFERED rate, and the whole
3644 // point of a quorum is that N siblings are offered and only K
3645 // consumed, so leaving the straggler out reports AN = K*TN and
3646 // hides the very loss the measurement exists for. The reference
3647 // counts it the same way: its AN at the join is exactly N times
3648 // the fork rate under a quorum, not K times.
3649 const std::size_t jd = node_to_station[node];
3650 if (jd < M) {
3651 acc.arrived[jd][job.cls] += 1.0;
3652 acc.join_dropped[jd][job.cls] += 1.0;
3653 }
3654 return;
3655 }
3656 ForkSync& fs = it->second;
3657 // A JOIN IS A STATION LIKE ANY OTHER. Its siblings are its queue
3658 // length and its synchronizations are its completions, so they go
3659 // into the same accumulators every other station uses and are
3660 // warmup-truncated with them rather than measured on a private
3661 // tally the result assembly never reads.
3662 // `js == M` is a Join the refresh registered as a node and not as a
3663 // station, which has no row to fill; `handleJoinArrival` gates on
3664 // the same bound. The synchronization itself runs either way.
3665 const std::size_t js = node_to_station[node];
3666 if (js < M) {
3667 acc.update_qlen(js, job.cls, now);
3668 acc.qlen[js][job.cls] += 1.0;
3669 acc.arrived[js][job.cls] += 1.0;
3670 }
3671 fs.siblings.push_back(std::make_pair(job.cls, now));
3672 if (static_cast<int>(fs.siblings.size()) < fs.required) return;
3673
3674 // Satisfied: the siblings leave the Join together and one job goes on.
3675 if (js < M) {
3676 for (std::size_t si = 0; si < fs.siblings.size(); ++si) {
3677 const std::size_t sc = fs.siblings[si].first;
3678 acc.update_qlen(js, sc, now);
3679 acc.qlen[js][sc] -= 1.0;
3680 // Each sibling waited from ITS OWN arrival, not the first:
3681 // the Join charges the delay it imposed on each.
3682 acc.resp_sum[js][sc] += now - fs.siblings[si].second;
3683 acc.resp_cnt[js][sc] += 1.0;
3684 }
3685 acc.completed[js][fs.cls] += 1.0;
3686 }
3687 Job merged;
3688 merged.cls = fs.cls;
3689 merged.t_sys = fs.t_sys;
3690 fork_sync.erase(it);
3691 const RouteEntry e = draw_node_route(node, merged.cls);
3692 merged.cls = e.cls;
3693 deliver(e.node, merged, M);
3694 return;
3695 }
3696
3697 // An ordinary station.
3698 const std::size_t j = node_to_station[node];
3699 // A REMOVAL SIGNAL acts on the station and is annihilated: it is
3700 // intercepted here, before any of the accounting an arrival gets,
3701 // because it never becomes one.
3702 if (has_removal_signal && j < M && is_removal_signal[job.cls]) {
3703 apply_signal(j, job);
3704 return;
3705 }
3706 /**
3707 * A REPLY ANSWERS ITS CALL AND TRAVELS ON. It releases the server the
3708 * caller has been holding, lets that station take its next waiting job,
3709 * and then CONTINUES along its own routing -- which is what separates it
3710 * from a removal signal, annihilated where it lands.
3711 *
3712 * A reply with no matching call is not an error: the call may have been
3713 * answered already, or the class may be routed as a reply without any
3714 * caller having parked. It routes on, having released nothing.
3715 */
3716 if (has_sync_call && j < M && is_reply_signal[job.cls]) {
3717 acc.completed[j][job.cls] += 1.0;
3718 typename std::map<std::uint64_t, PendingCall>::iterator pc =
3719 pending_reply.find(job.call);
3720 if (pc != pending_reply.end()) {
3721 const std::size_t bi = pc->second.station, bs = pc->second.slot;
3722 const std::size_t bc = pc->second.cls;
3723 pending_reply.erase(pc);
3724 StationState& bst = S[bi];
3725 bst.server_held[bs] = false;
3726 acc.update_busy(bi, bc, now);
3727 if (acc.busy[bi][bc] > 0.0) acc.busy[bi][bc] -= 1.0;
3728 if (!bst.server_blocked[bs] && buffer_has_for_slot(bi, bs)) {
3729 Job nextjob = buffer_pop_for_slot(bi, bs);
3730 start_service(bi, bs, nextjob);
3731 sd_reschedule(bi);
3732 }
3733 release_blocked(bi);
3734 }
3735 const RouteEntry re2 = draw_node_route(node, job.cls);
3736 Job onward;
3737 onward.cls = re2.cls;
3738 onward.t_sys = job.t_sys;
3739 onward.parent = job.parent;
3740 deliver(re2.node, onward, M);
3741 return;
3742 }
3743 Job moved = job;
3744 // A closed class completes its passage on reaching its reference
3745 // station: that crossing IS the system completion, and the elapsed time
3746 // is the cycle time.
3747 if (j < M && sn.classes[job.cls].type != lang::JobClassType::OPEN &&
3748 j + 1 == sn.classes[job.cls].refstat) {
3749 sys_resp_sum[job.cls] += now - job.t_sys;
3750 sys_resp_cnt[job.cls] += 1.0;
3751 sys_completed[job.cls] += 1.0;
3752 moved.t_sys = now;
3753 }
3754 if (!admit(j, moved, from) && sn.classes[job.cls].type != lang::JobClassType::OPEN)
3755 throw InputError("SolverLDES (native engine): a closed job was dropped at "
3756 "station '" + sn.stations[j].name +
3757 "'; a closed class cannot lose population");
3758 };
3759
3760 release_delayed_hits = [&](CacheState& cs, std::size_t item, std::size_t node) {
3761 if (item >= cs.held.size() || cs.held[item].empty()) return;
3762 std::vector<CacheState::HeldRequest> freed;
3763 freed.swap(cs.held[item]);
3764 for (std::size_t i = 0; i < freed.size(); ++i) {
3765 const CacheState::HeldRequest& hr = freed[i];
3766 cs.delayed[hr.cls] += 1.0;
3767 cs.delayed_wait += now - hr.hold_time;
3768 Job out;
3769 out.cls = (cs.hit_class[hr.cls] >= 0) ? static_cast<std::size_t>(cs.hit_class[hr.cls])
3770 : hr.cls;
3771 out.t_sys = hr.t_sys;
3772 const RouteEntry e = draw_node_route(node, out.cls);
3773 out.cls = e.cls;
3774 if (e.node != 0 && sn.nodes[e.node - 1].nodetype == NodeType::Sink)
3775 ++total_completions;
3776 deliver(e.node, out, M);
3777 }
3778 };
3779
3780
3781 /**
3782 * The marking as one flat vector over (place slot, class), `p * K + r`,
3783 * which is what a mode's arcs are indexed by.
3784 *
3785 * SUMMED OVER CLASSES UNTIL 2026-08-12, which made every arc colourless and
3786 * let a token of one class satisfy another's pre-arc.
3787 */
3788 auto flat_marking = [&]() {
3789 std::vector<double> tok(place_nodes.size() * K, 0.0);
3790 for (std::size_t p = 0; p < place_nodes.size(); ++p)
3791 for (std::size_t r = 0; r < K; ++r) tok[p * K + r] = marking[p][r];
3792 return tok;
3793 };
3794
3795 /**
3796 * Fire every enabled IMMEDIATE mode, then arm the timed ones.
3797 *
3798 * The immediate modes are exhausted FIRST and to a fixed point: a marking
3799 * that enables one is not a marking the net rests in, so letting a timed
3800 * mode fire from it would visit a state the model does not have. The guard
3801 * bounds a net whose immediate modes form a cycle, which is a modelling
3802 * error rather than something to simulate forever.
3803 */
3804 std::uint64_t fire_tag = 0;
3805 std::vector<std::uint64_t> live_fire(transitions.size(), 0);
3806 std::function<void()> spn_settle = [&]() {
3807 if (transitions.empty()) return;
3808 for (int guard = 0; guard < 1000000; ++guard) {
3809 bool fired_any = false;
3810 std::vector<double> tok = flat_marking();
3811 for (std::size_t t = 0; t < transitions.size(); ++t) {
3812 const int k = spn_pick_immediate(transitions[t].modes, tok, uniform01(g_routing));
3813 if (k < 0) continue;
3814 const SpnMode& m = transitions[t].modes[static_cast<std::size_t>(k)];
3815 // The tokens move in THEIR OWN CLASS: an arc names a (place,
3816 // class) pair, so a Class2 pre-arc consumes Class2 tokens.
3817 for (std::size_t p = 0; p < place_nodes.size(); ++p)
3818 for (std::size_t r = 0; r < K; ++r) {
3819 marking[p][r] -= m.enabling[p * K + r];
3820 marking[p][r] += m.firing[p * K + r];
3821 }
3822 transitions[t].fired[static_cast<std::size_t>(k)] += 1.0;
3823 ++total_completions;
3824 fired_any = true;
3825 break;
3826 }
3827 if (!fired_any) break;
3828 }
3829 // Arm one clock per transition on its fastest enabled timed mode. The
3830 // tag invalidates it as soon as the marking moves, because a
3831 // marking-dependent rate must be RESAMPLED and not merely rescaled.
3832 const std::vector<double> tok = flat_marking();
3833 for (std::size_t t = 0; t < transitions.size(); ++t) {
3834 double best = std::numeric_limits<double>::infinity();
3835 int best_mode = -1;
3836 for (std::size_t k = 0; k < transitions[t].modes.size(); ++k) {
3837 const SpnMode& m = transitions[t].modes[k];
3838 if (m.immediate || !spn_enabled(m, tok)) continue;
3839 const double rate = spn_rate(m, tok);
3840 if (!(rate > 0.0)) continue;
3841 const double d = -std::log(uniform01(g_spn)) / rate;
3842 if (d < best) {
3843 best = d;
3844 best_mode = static_cast<int>(k);
3845 }
3846 }
3847 live_fire[t] = ++fire_tag;
3848 if (best_mode < 0) continue;
3849 Event e;
3850 e.t = now + best;
3851 e.kind = EV_FIRING;
3852 e.station = t;
3853 e.cls = static_cast<std::size_t>(best_mode);
3854 e.tag = live_fire[t];
3855 push(e);
3856 }
3857 };
3858
3859 // ---- initial state -----------------------------------------------------
3860 /**
3861 * WARM START, `--initsol`: the placement a companion solver's steady state
3862 * implies, as a STATION-MAJOR vector [st0_cl0, ..., stM-1_clK-1].
3863 *
3864 * It REPLACES the reference-station placement of the closed classes; the
3865 * open arrival streams are armed either way. A Petri net is exempt: its
3866 * tokens live in Places and not in the service queues this vector
3867 * describes, so a station-based placement would put nothing anywhere and
3868 * then fail the conservation check.
3869 *
3870 * CLASSES DESCEND WITHIN A STATION. Every initial job arrives at time 0, so
3871 * under an order-preserving discipline the LAST one injected holds the
3872 * server; ascending order puts the highest-index class in service, which is
3873 * the mirror of what `State.initDefault` encodes and selects the other
3874 * closed communicating class on a chain made reducible by non-overtaking
3875 * routing -- a different stationary distribution, not a different tie-break.
3876 */
3877 const bool warm_start = !o.init_sol.empty() && place_nodes.empty();
3878 if (warm_start) {
3879 for (std::size_t i = 0; i < M; ++i) {
3880 if (S[i].role != Role::Queue && S[i].role != Role::Delay) continue;
3881 for (std::size_t k = K; k-- > 0;) {
3882 const std::size_t idx = i * K + k;
3883 if (idx >= o.init_sol.size()) continue;
3884 const double v = o.init_sol[idx];
3885 if (!(v > 0.0)) continue;
3886 const std::size_t count = static_cast<std::size_t>(v);
3887 for (std::size_t j = 0; j < count; ++j) {
3888 Job job;
3889 job.cls = k;
3890 job.t_sys = 0.0;
3891 if (!admit(i, job, M))
3892 throw InputError("SolverLDES (native engine): the warm-start placement "
3893 "of class '" + sn.classes[k].name + "' does not fit "
3894 "station '" + sn.stations[i].name + "'");
3895 }
3896 }
3897 }
3898 // A closed class must be placed in full: a warm start that loses jobs is
3899 // a different model, and the loss is invisible in every mean it reports.
3900 for (std::size_t r = 0; r < K; ++r) {
3901 if (sn.classes[r].type == lang::JobClassType::OPEN) continue;
3902 double held = 0.0;
3903 for (std::size_t i = 0; i < M; ++i) held += acc.qlen[i][r];
3904 const double want = sn.classes[r].population;
3905 if (std::fabs(held - want) > 0.5)
3906 throw InputError("SolverLDES (native engine): the warm-start placement holds " +
3907 std::to_string(static_cast<long>(held + 0.5)) + " jobs of class '" +
3908 sn.classes[r].name + "' against a population of " +
3909 std::to_string(static_cast<long>(want + 0.5)));
3910 }
3911 }
3912 // Every breakdown-carrying station starts UP with its first failure armed,
3913 // which is the reference's initial condition: a model that started down
3914 // would report a transient nobody asked for.
3915 for (std::size_t i = 0; i < M; ++i) {
3916 if (!S[i].has_breakdown) continue;
3917 Event e;
3918 e.t = S[i].failure_time.next_at(g_aux[i], 0.0);
3919 e.kind = EV_BREAKDOWN;
3920 e.station = i;
3921 push(e);
3922 }
3923
3924 std::vector<Sampler> arrival(K);
3925 std::vector<double> lambda(K, 0.0);
3926 for (std::size_t r = 0; r < K; ++r) {
3927 if (sn.classes[r].type == lang::JobClassType::OPEN) {
3928 if (source_st >= M)
3929 throw InputError("SolverLDES (native engine): an open class with no Source");
3930 if (S[source_st].off[r]) continue;
3931 arrival[r] = S[source_st].svc[r];
3932 lambda[r] = 1.0 / arrival[r].mean();
3933 Event e;
3934 e.t = slot_snap(arrival[r].next_at(g_arr[r], 0.0), "interarrival time");
3935 e.kind = EV_ARRIVAL;
3936 e.station = source_st;
3937 e.cls = r;
3938 push(e);
3939 } else {
3940 if (warm_start) continue; // already placed from the warm start
3941 const double n = sn.classes[r].population;
3942 if (!(n >= 0.0) || !std::isfinite(n))
3943 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
3944 "' has a population that is not a finite count");
3945 const std::size_t refst = sn.classes[r].refstat;
3946 if (refst == 0 || refst > M)
3947 throw InputError("SolverLDES (native engine): class '" + sn.classes[r].name +
3948 "' has no reference station");
3949 const std::size_t count = static_cast<std::size_t>(n + 0.5);
3950 for (std::size_t j = 0; j < count; ++j) {
3951 Job job;
3952 job.cls = r;
3953 job.t_sys = 0.0;
3954 if (!admit(refst - 1, job, M))
3955 throw InputError("SolverLDES (native engine): the initial population of "
3956 "class '" + sn.classes[r].name +
3957 "' does not fit its reference station's capacity");
3958 }
3959 }
3960 }
3961
3962 // ---- convergence-based stopping -----------------------------------------
3963 Convergence cnvg;
3964 cnvg.init(M, K, o, max_events);
3965 std::vector<std::size_t> servers_of(M, 1);
3966 std::vector<std::vector<bool>> off_of(M, std::vector<bool>(K, true));
3967 for (std::size_t i = 0; i < M; ++i) {
3968 servers_of[i] = S[i].nservers;
3969 for (std::size_t r = 0; r < K; ++r)
3970 off_of[i][r] = S[i].off[r] || S[i].role == Role::Source ||
3971 S[i].role == Role::Synchronization;
3972 }
3973 std::uint64_t last_cnvg_events = 0;
3974 bool converged = false;
3975
3976 // The immediate modes settle the initial marking before any clock runs.
3977 spn_settle();
3978
3979 // ---- MSER-5 and batch-means bookkeeping --------------------------------
3980 Observations obs(M, K, o.tranfilter == "mser5", o.mserbatch > 0 ? o.mserbatch : 5);
3981 for (std::size_t i = 0; i < M; ++i)
3982 if (S[i].role == Role::Source || S[i].role == Role::Synchronization) obs.in_mser[i] = 0;
3983 std::uint64_t mser_interval = max_events / 1000ULL;
3984 if (mser_interval < 1) mser_interval = 1;
3985 std::uint64_t last_mser_events = 0;
3986
3987
3988 // ---- transient sampling and the joint-state histogram ---------------------
3989 /**
3990 * A TRANSIENT run is bounded by SIMULATED TIME, not by completions.
3991 *
3992 * `options.timespan = [t0, t1]` sets the horizon and the completion budget
3993 * is then ignored, exactly as the reference does: a transient trajectory is
3994 * a function of time, so stopping on an event count would end it at a
3995 * different instant on every path and make the series incomparable across
3996 * seeds.
3997 */
3998 const bool transient_run = o.has_timespan && std::isfinite(o.t1) && o.t1 > o.t0;
3999 const double horizon = transient_run ? o.t1 : std::numeric_limits<double>::infinity();
4000 // ~1000 points, the reference's target: enough to draw and few enough that
4001 // the series is not itself a memory problem on a long horizon.
4002 const double tran_interval = transient_run ? (o.t1 - o.t0) / 1000.0 : 0.0;
4003 double next_tran_sample = transient_run ? o.t0 + tran_interval : 0.0;
4004 double last_tran_time = transient_run ? o.t0 : 0.0;
4005 std::vector<double> tran_times;
4006 std::vector<std::vector<std::vector<double>>> tran_q(M, std::vector<std::vector<double>>(K));
4007 std::vector<std::vector<std::vector<double>>> tran_u(M, std::vector<std::vector<double>>(K));
4008 std::vector<std::vector<std::vector<double>>> tran_t(M, std::vector<std::vector<double>>(K));
4009 std::vector<std::vector<double>> last_tran_q(M, std::vector<double>(K, 0.0));
4010 std::vector<std::vector<double>> last_tran_b(M, std::vector<double>(K, 0.0));
4011 std::vector<std::vector<double>> last_tran_c(M, std::vector<double>(K, 0.0));
4012
4013 /**
4014 * The EXACT joint-state residence time, keyed by the aggregate state row.
4015 *
4016 * `histogram_space` plus `histogram_time` let a caller evaluate its own
4017 * reward on the empirical distribution, INCLUDING a nonlinear one:
4018 * E[r] = sum_s (t_s / sum t) r(state_s) is exact only because the residence
4019 * time of each distinct state is kept, which a trajectory of means cannot
4020 * reconstruct.
4021 */
4022 std::map<std::vector<int>, double> histogram;
4023 std::vector<std::pair<double, std::vector<int>>> trajectory;
4024 double hist_last = 0.0;
4025 // A transient run needs the state walk anyway; a steady-state one pays for
4026 // it only when asked.
4027 const bool want_traj = transient_run || o.export_trajectory;
4028 const bool want_hist = transient_run || o.export_histogram || want_traj;
4029
4030 auto joint_state = [&]() {
4031 std::vector<int> row(M * K, 0);
4032 for (std::size_t i = 0; i < M; ++i)
4033 for (std::size_t r = 0; r < K; ++r)
4034 row[i * K + r] = static_cast<int>(acc.qlen[i][r] + 0.5);
4035 return row;
4036 };
4037 auto hist_accumulate = [&]() {
4038 if (!want_hist) return;
4039 const double dt = now - hist_last;
4040 if (dt > 0.0) {
4041 const std::vector<int> row = joint_state();
4042 histogram[row] += dt;
4043 if (want_traj) trajectory.push_back(std::make_pair(hist_last, row));
4044 }
4045 hist_last = now;
4046 };
4047
4048 auto tran_sample = [&]() {
4049 const double dt = now - last_tran_time;
4050 tran_times.push_back(now);
4051 for (std::size_t i = 0; i < M; ++i)
4052 for (std::size_t r = 0; r < K; ++r) {
4053 if (dt > 0.0) {
4054 tran_q[i][r].push_back((acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt);
4055 const double c = S[i].util_peak;
4056 tran_u[i][r].push_back(
4057 (S[i].role == Role::Delay)
4058 ? (acc.tot_qlen[i][r] - last_tran_q[i][r]) / dt
4059 : (acc.tot_busy[i][r] - last_tran_b[i][r]) / (dt * c));
4060 tran_t[i][r].push_back((acc.completed[i][r] - last_tran_c[i][r]) / dt);
4061 } else {
4062 tran_q[i][r].push_back(acc.qlen[i][r]);
4063 tran_u[i][r].push_back(0.0);
4064 tran_t[i][r].push_back(0.0);
4065 }
4066 last_tran_q[i][r] = acc.tot_qlen[i][r];
4067 last_tran_b[i][r] = acc.tot_busy[i][r];
4068 last_tran_c[i][r] = acc.completed[i][r];
4069 }
4070 last_tran_time = now;
4071 };
4072
4073 // ---- the event loop ----------------------------------------------------
4074 while (!evq.empty() && (transient_run || total_completions < max_events)) {
4075 const Event ev = evq.top();
4076 if (transient_run && ev.t > horizon) break;
4077 evq.pop();
4078 now = ev.t;
4079
4080 // THE STATE IS WALKED BEFORE THE EVENT IS APPLIED, so the interval just
4081 // ended is charged to the state that held during it. A steady-state run
4082 // asked for the histogram takes the same walk and nothing else.
4083 if (want_hist) hist_accumulate();
4084 if (transient_run) {
4085 while (now >= next_tran_sample && next_tran_sample <= horizon) {
4086 const double save = now;
4087 now = next_tran_sample;
4088 for (std::size_t a = 0; a < M; ++a) {
4089 if (S[a].ps) ps_advance(a);
4090 for (std::size_t r = 0; r < K; ++r) {
4091 acc.update_qlen(a, r, now);
4092 acc.update_busy(a, r, now);
4093 }
4094 }
4095 tran_sample();
4096 next_tran_sample += tran_interval;
4097 now = save;
4098 }
4099 }
4100
4101 if (ev.kind == EV_BREAKDOWN) {
4102 // FREEZE AT THE OLD RATE, FLIP, RE-TIME AT THE NEW, in that order,
4103 // for the same reason sd_advance runs before a population change:
4104 // the rate in force over the elapsed interval is the one the OLD
4105 // state implied. The stale departures are neutralised by the tag.
4106 StationState& s = S[ev.station];
4107 if (s.ps)
4108 ps_advance(ev.station);
4109 else
4110 sd_advance(ev.station);
4111 s.up = !s.up;
4112 if (s.ps)
4113 ps_reschedule(ev.station);
4114 else
4115 sd_reschedule(ev.station);
4116 // The next transition is drawn from the clock now in force. A job
4117 // stalled by the outage has no departure event at all; the repair's
4118 // reschedule is what gives it one back, with its residual intact.
4119 // `next_at` returns a DURATION, not an instant: it takes `from`
4120 // only so a time-inhomogeneous family can read its own schedule.
4121 // Assigning it straight to `nxt.t` puts the next flip in the PAST,
4122 // which the loop pops immediately and re-arms -- an infinite
4123 // cascade at one simulated instant.
4124 Event nxt;
4125 nxt.t = now + (s.up ? s.failure_time.next_at(g_aux[ev.station], now)
4126 : s.repair_time.next_at(g_aux[ev.station], now));
4127 nxt.kind = EV_BREAKDOWN;
4128 nxt.station = ev.station;
4129 push(nxt);
4130 continue;
4131 }
4132
4133 if (ev.kind == EV_FIRING) {
4134 // A stale clock: the marking moved after this one was armed, so its
4135 // rate no longer describes the net.
4136 if (ev.station >= transitions.size() || live_fire[ev.station] != ev.tag) continue;
4137 SpnTransition& tr = transitions[ev.station];
4138 const std::vector<double> tok = flat_marking();
4139 const SpnMode& m = tr.modes[ev.cls];
4140 if (!spn_enabled(m, tok)) continue;
4141 for (std::size_t p = 0; p < place_nodes.size(); ++p)
4142 for (std::size_t r = 0; r < K; ++r) {
4143 marking[p][r] -= m.enabling[p * K + r];
4144 marking[p][r] += m.firing[p * K + r];
4145 }
4146 tr.fired[ev.cls] += 1.0;
4147 ++total_completions;
4148 spn_settle();
4149 continue;
4150 }
4151
4152 if (ev.kind == EV_SWITCHOVER) {
4153 StationState& sp = S[ev.station];
4154 sp.poll_switching = false;
4155 sp.poll_at = ev.cls;
4156 sp.poll_budget = (sp.poll_type == lang::PollingType::EXHAUSTIVE)
4157 ? std::numeric_limits<std::size_t>::max()
4158 : ((sp.poll_type == lang::PollingType::KLIMITED) ? sp.poll_k : 1);
4159 bool work = false;
4160 for (const Job& j : sp.buffer)
4161 if (j.cls == sp.poll_at) {
4162 work = true;
4163 break;
4164 }
4165 if (work)
4166 poll_serve(ev.station);
4167 else
4168 poll_advance(ev.station);
4169 continue;
4170 }
4171
4172 if (ev.kind == EV_RETRIAL) {
4173 StationState& sr = S[ev.station];
4174 const double dt = now - sr.orbit_last;
4175 if (dt > 0.0)
4176 for (std::size_t r = 0; r < K; ++r) sr.tot_orbit[r] += sr.orbit_size[r] * dt;
4177 sr.orbit_last = now;
4178 sr.orbit_size[ev.cls] -= 1.0;
4179 sr.retried[ev.cls] += 1.0;
4180 // The retry is an ordinary admission: it succeeds if the station has
4181 // room by then and rejoins the orbit if it does not.
4182 admit(ev.station, ev.job, M);
4183 continue;
4184 }
4185
4186 if (ev.kind == EV_SETUP) {
4187 StationState& ss = S[ev.station];
4188 if (ev.slot == 1) {
4189 // A power-down. It is CANCELLED if a job arrived meanwhile: the
4190 // server never went cold, so that job must not pay a setup.
4191 double held = 0.0;
4192 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[ev.station][r];
4193 if (held > 0.0 || now + 1e-12 < ss.delayoff_at) continue;
4194 ss.setup_on = false;
4195 ss.delayoff_at = std::numeric_limits<double>::infinity();
4196 continue;
4197 }
4198 if (ss.setup_on) continue; // superseded
4199 ss.setup_running = false;
4200 ss.setup_on = true;
4201 ss.delayoff_at = std::numeric_limits<double>::infinity();
4202 for (std::size_t sl = 0; sl < ss.nservers && !ss.buffer.empty(); ++sl)
4203 if (!ss.server_busy[sl] && !ss.server_blocked[sl] && !ss.server_held[sl] &&
4204 buffer_has_for_slot(ev.station, sl)) {
4205 Job nextjob = buffer_pop_for_slot(ev.station, sl);
4206 start_service(ev.station, sl, nextjob);
4207 }
4208 sd_reschedule(ev.station);
4209 continue;
4210 }
4211
4212 if (ev.kind == EV_RENEGE) {
4213 // The job abandons only if it is STILL WAITING. Having entered
4214 // service, departed, or been dropped all leave the timer with
4215 // nothing to find, which is the ordinary case and not an error.
4216 StationState& sr = S[ev.station];
4217 std::size_t at = sr.buffer.size();
4218 for (std::size_t j = 0; j < sr.buffer.size(); ++j)
4219 if (sr.buffer[j].id == ev.tag) {
4220 at = j;
4221 break;
4222 }
4223 if (at == sr.buffer.size()) continue;
4224 const std::size_t rc = sr.buffer[at].cls;
4225 sd_advance(ev.station);
4226 acc.update_qlen(ev.station, rc, now);
4227 acc.qlen[ev.station][rc] -= 1.0;
4228 bp.track(ev.station, rc, -1, now);
4229 sr.buffer.erase(sr.buffer.begin() + static_cast<std::ptrdiff_t>(at));
4230 if (sr.sched != SchedStrategy::FSP)
4231 std::make_heap(sr.buffer.begin(), sr.buffer.end(), sr.cmp);
4232 reneged[ev.station][rc] += 1.0;
4233 sd_reschedule(ev.station);
4234 continue;
4235 }
4236
4237 if (ev.kind == EV_ARRIVAL) {
4238 Event nxt;
4239 const double gap = slot_snap(arrival[ev.cls].next_at(g_arr[ev.cls], now),
4240 "interarrival time");
4241 // A non-cyclic schedule past its horizon yields NO further arrival:
4242 // the stream has ended, and re-arming it at rate zero would loop.
4243 if (!(gap > 0.0) && arrival[ev.cls].time_varying()) continue;
4244 nxt.t = now + gap;
4245 nxt.kind = EV_ARRIVAL;
4246 nxt.station = ev.station;
4247 nxt.cls = ev.cls;
4248 push(nxt);
4249
4250 const RouteEntry e = draw_node_route(sn.station_to_node[ev.station], ev.cls);
4251 Job job;
4252 job.cls = e.cls;
4253 job.t_sys = now;
4254 deliver(e.node, job, M);
4255 continue;
4256 }
4257
4258 // ---- a service completion ------------------------------------------
4259 const std::size_t i = ev.station;
4260 StationState& s = S[i];
4261 Job job;
4262
4263 if (s.role == Role::Delay) {
4264 job = ev.job;
4265 if (has_removal_signal) {
4266 // The registry is the Delay's cancellation handle: no entry
4267 // means a signal already destroyed this job, and serving the
4268 // departure anyway would put it back into circulation.
4269 std::size_t at = delay_live[i].size();
4270 for (std::size_t j = 0; j < delay_live[i].size(); ++j)
4271 if (delay_live[i][j].id == job.id) {
4272 at = j;
4273 break;
4274 }
4275 if (at == delay_live[i].size()) continue;
4276 delay_live[i].erase(delay_live[i].begin() + static_cast<std::ptrdiff_t>(at));
4277 }
4278 } else if (s.ps) {
4279 ps_advance(i);
4280 std::size_t idx = s.ps_jobs.size();
4281 for (std::size_t j = 0; j < s.ps_jobs.size(); ++j)
4282 if (s.ps_jobs[j].tag == ev.tag) {
4283 idx = j;
4284 break;
4285 }
4286 // A superseded departure: the job it named has since been
4287 // rescheduled by a population change. Dropping it here is the
4288 // engine's substitute for cancelling the event.
4289 if (idx == s.ps_jobs.size()) continue;
4290 const PsJob& pj = s.ps_jobs[idx];
4291 job.cls = pj.cls;
4292 job.t_arr = pj.t_arr;
4293 job.t_sys = pj.t_sys;
4294 job.parent = pj.parent;
4295 s.ps_jobs.erase(s.ps_jobs.begin() + static_cast<std::ptrdiff_t>(idx));
4296 } else if (s.pas) {
4297 // A stale aggregate clock: the list changed after it was armed, so
4298 // its total rate no longer describes the station.
4299 if (s.pas_tag != ev.tag || s.pas_list.empty()) continue;
4300 job = pas_complete(i);
4301 acc.update_busy(i, job.cls, now);
4302 if (acc.busy[i][job.cls] > 0.0) acc.busy[i][job.cls] -= 1.0;
4303 pas_reschedule(i);
4304 } else {
4305 // A preemption leaves its victim's departure in the list with no
4306 // way to cancel it; the tag mismatch is what neutralises it.
4307 if (!s.server_busy[ev.slot] || s.server_tag[ev.slot] != ev.tag) continue;
4308 sd_advance(i);
4309 job = s.server[ev.slot];
4310 s.server_busy[ev.slot] = false;
4311 s.server_tag[ev.slot] = 0;
4312 acc.update_busy(i, job.cls, now);
4313 acc.busy[i][job.cls] -= 1.0;
4314 }
4315
4316 sd_advance(i);
4317 acc.update_qlen(i, job.cls, now);
4318 acc.qlen[i][job.cls] -= 1.0;
4319 bp.track(i, job.cls, -1, now);
4320 acc.completed[i][job.cls] += 1.0;
4321 acc.resp_sum[i][job.cls] += now - job.t_arr;
4322 if (want_respt) resp_samples[i][job.cls].push_back(now - job.t_arr);
4323 acc.resp_cnt[i][job.cls] += 1.0;
4324 ++total_completions;
4325
4326 if (s.ps) {
4327 if (s.lps_limit > 0 && !s.buffer.empty() && s.ps_jobs.size() < s.lps_limit) {
4328 Job nextjob = buffer_pop(i);
4329 PsJob pj;
4330 pj.cls = nextjob.cls;
4331 pj.priority = nextjob.priority;
4332 pj.t_arr = nextjob.t_arr;
4333 pj.t_sys = nextjob.t_sys;
4334 pj.total = nextjob.service;
4335 pj.remaining = nextjob.service;
4336 pj.parent = nextjob.parent;
4337 s.ps_jobs.push_back(pj);
4338 }
4339 ps_reschedule(i);
4340 }
4341
4342 const RouteEntry re = draw_node_route(sn.station_to_node[i], job.cls);
4343 const bool to_sink = re.sink;
4344 const std::size_t dst = re.station, dcls = re.cls;
4345
4346 /**
4347 * IMMEDIATE FEEDBACK: the completing job goes straight back into THE
4348 * SAME SERVER under the destination class, holding the slot instead of
4349 * re-queueing behind whoever is waiting.
4350 *
4351 * That is the whole content of the feature and it is not the same model
4352 * as a self-loop: a self-loop puts the job at the tail and lets the head
4353 * of the queue in, while feedback lets one job hold the server for as
4354 * many services as its routing keeps returning it. It also never leaves
4355 * the station, so it exits no region, releases no blocked upstream
4356 * server and is not a system completion.
4357 */
4358 if (has_immfeed && !to_sink && dst == i && s.role == Role::Queue && !s.ps && !s.pas &&
4359 ev.slot < s.nservers && dcls < sn.immfeed[i].size() && sn.immfeed[i][dcls]) {
4360 Job fed;
4361 fed.cls = dcls;
4362 fed.t_sys = job.t_sys;
4363 fed.parent = job.parent;
4364 fed.t_arr = now;
4365 fed.priority = classprio[dcls];
4366 fed.service = slot_snap(s.svc[dcls].next_at(g_svc[i][dcls], now), "service time");
4367 fed.remaining = fed.service;
4368 fed.elapsed = 0.0;
4369 fed.rank = uniform01(g_routing);
4370 fed.deadline = now + classdeadline[dcls];
4371 fed.id = ++job_id;
4372 sd_advance(i);
4373 acc.update_qlen(i, dcls, now);
4374 acc.qlen[i][dcls] += 1.0;
4375 bp.track(i, dcls, +1, now);
4376 start_service(i, ev.slot, fed);
4377 sd_reschedule(i);
4378 if (total_completions >= max_events) break;
4379 continue;
4380 }
4381
4382 /**
4383 * A SYNCHRONOUS CALL PARKS THE SERVER. The completing job leaves for the
4384 * callee and its slot stays held until the matching reply comes back:
4385 * no waiter is promoted into it, and the time it spends held counts as
4386 * BUSY, because the server is unavailable and reporting it idle would
4387 * credit the station with capacity it does not have. That is the same
4388 * rule the BAS branch below applies for the same reason.
4389 *
4390 * A hop that ALREADY switched into the reply class is not a call: it is
4391 * the answer, and parking on it would wait for a reply to a reply.
4392 * That switch may not show on `dcls` yet -- see `resolve_final_cls` --
4393 * when the routing matrix moves this edge through a synthesized
4394 * ClassSwitch node, so the check resolves through it rather than
4395 * reading `dcls` directly.
4396 */
4397 if (has_sync_call && sync_reply[job.cls] < K &&
4398 !is_reply_signal[resolve_final_cls(re.node, dcls)] &&
4399 s.role == Role::Queue && !s.ps && !s.pas && ev.slot < s.nservers) {
4400 const std::uint64_t call = ++next_call;
4401 PendingCall pc;
4402 pc.station = i;
4403 pc.slot = ev.slot;
4404 pc.cls = job.cls;
4405 pc.since = now;
4406 pending_reply[call] = pc;
4407 s.server_held[ev.slot] = true;
4408 s.held_cls[ev.slot] = job.cls;
4409 acc.update_busy(i, job.cls, now);
4410 acc.busy[i][job.cls] += 1.0;
4411 Job moved;
4412 moved.cls = re.cls;
4413 moved.t_sys = job.t_sys;
4414 moved.parent = job.parent;
4415 moved.call = call;
4416 deliver(re.node, moved, i);
4417 if (total_completions >= max_events) break;
4418 continue;
4419 }
4420
4421 // BLOCKING IS DECIDED BEFORE THE SLOT IS REUSED. If the destination is
4422 // full and declares BAS or BBS, this server keeps the completed job and
4423 // stops serving; taking the next waiting job first would let the station
4424 // run at full rate while its output is stopped, which is precisely the
4425 // behaviour blocking removes.
4426 if (!to_sink && dst < M && s.role == Role::Queue && !s.ps && ev.slot < s.nservers &&
4427 !dest_has_room(dst, dcls)) {
4428 const lang::DropStrategy rule = dest_policy(dst, dcls);
4429 if (rule == lang::DropStrategy::BAS || rule == lang::DropStrategy::BBS) {
4430 Job held = job;
4431 held.cls = dcls;
4432 s.server_blocked[ev.slot] = true;
4433 s.blocked_job[ev.slot] = held;
4434 s.blocked_dest[ev.slot] = dst;
4435 s.blocked_dest_cls[ev.slot] = dcls;
4436 // Charged to the DESTINATION it is queueing for, not to the
4437 // station whose server it occupies.
4438 // The blocked job IS part of the destination's queue length --
4439 // the reference's effectiveQueueLength adds basBlockedAtDest and
4440 // bbsBlockedAtDest to it -- so it is carried in `qlen` and the
4441 // counter beside it is bookkeeping, not a second population.
4442 acc.update_qlen(dst, dcls, now);
4443 acc.qlen[dst][dcls] += 1.0;
4444 S[dst].blocked_at[dcls] += 1.0;
4445 // The upstream server is BLOCKED, not idle. Utilization counts
4446 // blocking time as busy time (the reference divides
4447 // busy + blocking by the horizon), because the server is
4448 // unavailable either way and reporting it idle would credit the
4449 // station with capacity it does not have.
4450 acc.update_busy(i, job.cls, now);
4451 acc.busy[i][job.cls] += 1.0;
4452 blocked_count[i][job.cls] += 1.0;
4453 continue;
4454 }
4455 }
4456
4457 // ONLY NOW may the freed slot take the next waiting job: a slot that
4458 // blocked above must not also start serving, or the station holds two
4459 // jobs in one server and the blocked one is duplicated on release.
4460 if (s.polling) {
4461 // The controller decides what happens next, not the buffer order:
4462 // it may keep serving this buffer, or walk on under its budget.
4463 poll_serve(i);
4464 } else if (s.role == Role::Queue && !s.ps && !s.pas && !s.server_blocked.empty() &&
4465 ev.slot < s.nservers && !s.server_blocked[ev.slot] &&
4466 !s.server_held[ev.slot] && buffer_has_for_slot(i, ev.slot)) {
4467 Job nextjob = buffer_pop_for_slot(i, ev.slot);
4468 start_service(i, ev.slot, nextjob);
4469 sd_reschedule(i);
4470 }
4471 // THE FORK IDENTITY IS HANDED OVER, NOT COPIED. A continuation that
4472 // stands in for the trigger at a Join must take its sibling slot; if
4473 // the trigger kept it too, one fork would present two siblings and the
4474 // Join would release early and discard the second.
4475 std::uint64_t spawn_parent = 0;
4476 if (has_spawn && spawn_of[job.cls] < K && spawn_joins_at[spawn_of[job.cls]]) {
4477 spawn_parent = job.parent;
4478 job.parent = 0;
4479 }
4480 {
4481 Job moved;
4482 moved.cls = re.cls;
4483 moved.t_sys = job.t_sys;
4484 moved.parent = job.parent;
4485 // The identity travels WITH the job, through the callee and back on
4486 // the reply: a call answered two stations downstream is still this
4487 // caller's call, and dropping the tag here would strand its server.
4488 moved.call = job.call;
4489 deliver(re.node, moved, i);
4490 }
4491
4492 // A job that left the region frees a slot for whatever is parked on it.
4493 bool region_to_release = false;
4494 std::size_t released_region = 0;
4495 if (region_of[i] >= 0) {
4496 const std::size_t rg = static_cast<std::size_t>(region_of[i]);
4497 // The hop is INSIDE the region when the destination is a member:
4498 // the job never crosses the boundary and the region's occupancy is
4499 // unchanged, so releasing a waiter there would over-admit.
4500 // A Fork or Join destination is not a station and has no region,
4501 // so it is a crossing; `dst` is M there and must not be indexed.
4502 const bool stays_inside = !to_sink && dst < M && region_of[dst] == region_of[i];
4503 if (!stays_inside) {
4504 regions[rg].update(now);
4505 regions[rg].leave(job.cls);
4506 regions[rg].completed[job.cls] += 1.0;
4507 region_to_release = true;
4508 released_region = rg;
4509 }
4510 }
4511 // THE PHASE-2 CONTINUATION IS INJECTED HERE, between the completing
4512 // job's release of its region slot and the release of whatever is
4513 // parked on that region: the continuation takes over the slot the
4514 // trigger just freed, ahead of the blocked queue, which is the order
4515 // `maybeSpawnOnCompletion` is called in.
4516 if (has_spawn && spawn_of[job.cls] < K) {
4517 const std::size_t scls = spawn_of[job.cls];
4518 Job spawned;
4519 spawned.cls = scls;
4520 spawned.t_sys = now;
4521 // A continuation aimed at a Join stands in for the trigger at the
4522 // fork it belongs to (phase 2 at an AND-join branch tail), so it
4523 // inherits the fork identity rather than arriving as an orphan the
4524 // Join would discard.
4525 spawned.parent = spawn_parent;
4526 admit(i, spawned, M);
4527 }
4528 if (region_to_release) release_region(released_region);
4529 // The station has emptied: arm the delay-off timer, after which the
4530 // server shuts down and the next arrival pays a setup.
4531 if (s.has_setup && s.setup_on) {
4532 double held = 0.0;
4533 for (std::size_t r = 0; r < K; ++r) held += acc.qlen[i][r];
4534 if (!(held > 0.0)) {
4535 const double idle =
4536 s.delayoff_time.disabled() ? 0.0 : s.delayoff_time.next(g_aux[i]);
4537 s.delayoff_at = now + idle;
4538 Event e;
4539 e.t = s.delayoff_at;
4540 e.kind = EV_SETUP; // reuse the tag; the handler below powers down
4541 e.station = i;
4542 e.cls = job.cls;
4543 e.slot = 1; // marks a power-down rather than a setup completion
4544 push(e);
4545 }
4546 }
4547 release_blocked(i);
4548
4549 if (total_completions >= max_events) break;
4550 if (cnvg.enabled() && (total_completions - last_cnvg_events) >= cnvg.interval()) {
4551 for (std::size_t a = 0; a < M; ++a) {
4552 if (S[a].ps) ps_advance(a);
4553 for (std::size_t r = 0; r < K; ++r) {
4554 acc.update_qlen(a, r, now);
4555 acc.update_busy(a, r, now);
4556 }
4557 }
4558 cnvg.finalize_batch(acc, servers_of, now);
4559 last_cnvg_events = total_completions;
4560 if (cnvg.converged(off_of)) {
4561 converged = true;
4562 break;
4563 }
4564 }
4565 if ((total_completions - last_mser_events) >= mser_interval) {
4566 for (std::size_t a = 0; a < M; ++a) {
4567 if (S[a].ps) ps_advance(a);
4568 for (std::size_t r = 0; r < K; ++r) {
4569 acc.update_qlen(a, r, now);
4570 acc.update_busy(a, r, now);
4571 }
4572 }
4573 obs.collect(acc, now);
4574 last_mser_events = total_completions;
4575 }
4576 }
4577
4578 // Close every open integral at the final instant.
4579 for (std::size_t i = 0; i < M; ++i) {
4580 if (S[i].ps) ps_advance(i);
4581 for (std::size_t r = 0; r < K; ++r) {
4582 acc.update_qlen(i, r, now);
4583 acc.update_busy(i, r, now);
4584 }
4585 }
4586
4587 bp.commit();
4588 const Truncation tr = obs.truncate();
4589 const double sim_time = now - tr.warmup_end;
4590 const double elapsed = tr.applied ? (now - obs.time[tr.index]) : sim_time;
4591
4592 // ---- result -------------------------------------------------------------
4593 LdesResult res;
4594 res.nstations = M;
4595 res.nclasses = K;
4596 res.nchains = sn.nchains;
4597 for (std::size_t i = 0; i < M; ++i) res.station_names.push_back(sn.stations[i].name);
4598 for (std::size_t r = 0; r < K; ++r) res.class_names.push_back(sn.classes[r].name);
4599 res.QN = Matrix<double>(M, K, 0.0);
4600 res.UN = Matrix<double>(M, K, 0.0);
4601 res.RN = Matrix<double>(M, K, 0.0);
4602 res.TN = Matrix<double>(M, K, 0.0);
4603 res.CN = Matrix<double>(1, K, 0.0);
4604 res.XN = Matrix<double>(1, K, 0.0);
4605 res.AN = Matrix<double>(M, K, 0.0);
4606 res.WN = Matrix<double>(M, K, 0.0);
4607
4608 for (std::size_t i = 0; i < M; ++i) {
4609 for (std::size_t r = 0; r < K; ++r) {
4610 if (S[i].role == Role::Source) {
4611 res.TN(i, r) = lambda[r];
4612 continue;
4613 }
4614 if (S[i].off[r]) continue;
4615 if (elapsed > 0.0) {
4616 const double q0 = tr.applied ? obs.qt[i][r][tr.index] : 0.0;
4617 const double b0 = tr.applied ? obs.bt[i][r][tr.index] : 0.0;
4618 const double c0 = tr.applied ? obs.cmp[i][r][tr.index] : 0.0;
4619 res.QN(i, r) = (acc.tot_qlen[i][r] - q0) / elapsed;
4620 res.TN(i, r) = (acc.completed[i][r] - c0) / elapsed;
4621 if (S[i].role == Role::Delay) {
4622 // An infinite server has no capacity to be busy against: the
4623 // reference reports the traffic intensity T/mu, which is what
4624 // SolverNC and SolverMVA return for a Delay.
4625 res.UN(i, r) = res.TN(i, r) * S[i].class_mean[r];
4626 } else if (S[i].has_cd) {
4627 // A CLASS- OR JOINT-DEPENDENT STATION IS READ THROUGH THE
4628 // LAW, not the busy integral, which is what the Java engine
4629 // does (`getUtilization` returns T/(mu*peakScaling) here).
4630 // beta_r(n) scales the RATE, so a busy server delivers
4631 // beta times the work; the head-count integral would report
4632 // the fraction of TIME occupied and divide it by a peak that
4633 // counts work, mixing the two conventions in one number. The
4634 // load-dependent case keeps the integral because
4635 // `Accum::busy_scale` already weights it by alpha(n).
4636 res.UN(i, r) = (S[i].util_peak > 0.0)
4637 ? res.TN(i, r) * S[i].class_mean[r] / S[i].util_peak
4638 : 0.0;
4639 } else {
4640 res.UN(i, r) = (acc.tot_busy[i][r] - b0) / (elapsed * S[i].util_peak);
4641 }
4642 }
4643 if (acc.resp_cnt[i][r] > 0.0) res.RN(i, r) = acc.resp_sum[i][r] / acc.resp_cnt[i][r];
4644 // AN is the OFFERED rate, arrivedCustomers / simTime, as
4645 // `getArrivalRate` measures it; WN is the residence time, which for
4646 // this engine's single-visit accounting is the response time. Both
4647 // are part of the result contract every client reads, and leaving
4648 // them at zero reads as "no arrivals" rather than "not measured".
4649 if (elapsed > 0.0) res.AN(i, r) = acc.arrived[i][r] / elapsed;
4650 res.WN(i, r) = res.RN(i, r);
4651 }
4652 }
4653 // Sibling drops at a Join, on the Join rows only. Emitted whenever the model
4654 // HAS a fork-join, drops or none: a MEASURED zero is what a standard join
4655 // loses, and it is a better answer than the client's derived AN - K*TN,
4656 // which carries the finite-sample gap between the two rates. An absent
4657 // matrix means "this engine does not count", not "nothing was lost".
4658 if (!sn.fj.empty()) {
4659 res.DropRateJoin = Matrix<double>(M, K, 0.0);
4660 for (std::size_t i = 0; i < M; ++i)
4661 for (std::size_t r = 0; r < K; ++r) {
4662 const double d0 = tr.applied ? obs.drp[i][r][tr.index] : 0.0;
4663 if (elapsed > 0.0)
4664 res.DropRateJoin(i, r) = (acc.join_dropped[i][r] - d0) / elapsed;
4665 }
4666 }
4667 for (std::size_t r = 0; r < K; ++r) {
4668 if (sim_time > 0.0) res.XN(0, r) = sys_completed[r] / sim_time;
4669 if (sys_resp_cnt[r] > 0.0) res.CN(0, r) = sys_resp_sum[r] / sys_resp_cnt[r];
4670 }
4671
4672 // ---- busy periods --------------------------------------------------------
4673 for (std::size_t ti = 0; ti < bp.targets().size(); ++ti) {
4675 out.name = bp.targets()[ti].name;
4676 out.stations = bp.targets()[ti].stations;
4677 out.job_class = bp.targets()[ti].job_class;
4678 for (std::size_t oi = 0; oi < static_cast<std::size_t>(bp.orders()); ++oi) {
4679 out.mean.push_back(bp.mean(ti, oi));
4680 out.count.push_back(bp.count(ti, oi));
4681 }
4682 res.busy_periods.push_back(out);
4683 }
4684
4685 // ---- transient trajectory and histogram -----------------------------------
4686 if (transient_run) {
4687 now = std::min(now, horizon);
4688 hist_accumulate();
4689 res.t = tran_times;
4690 res.QNt.assign(M, std::vector<Matrix<double>>(K));
4691 res.UNt.assign(M, std::vector<Matrix<double>>(K));
4692 res.TNt.assign(M, std::vector<Matrix<double>>(K));
4693 for (std::size_t i = 0; i < M; ++i)
4694 for (std::size_t r = 0; r < K; ++r) {
4695 const std::size_t n = tran_times.size();
4696 // Columns are [value, time], which is the layout the reference
4697 // emits and the clients read.
4698 Matrix<double> q(n, 2, 0.0), u(n, 2, 0.0), t(n, 2, 0.0);
4699 for (std::size_t k = 0; k < n; ++k) {
4700 q(k, 0) = tran_q[i][r][k];
4701 q(k, 1) = tran_times[k];
4702 u(k, 0) = tran_u[i][r][k];
4703 u(k, 1) = tran_times[k];
4704 t(k, 0) = tran_t[i][r][k];
4705 t(k, 1) = tran_times[k];
4706 }
4707 res.QNt[i][r] = q;
4708 res.UNt[i][r] = u;
4709 res.TNt[i][r] = t;
4710 }
4711 res.stopping_reason = "max_time";
4712 }
4713
4714 if (want_respt) {
4715 res.respTimeSamples.assign(M, std::vector<std::vector<double>>(K));
4716 for (std::size_t i = 0; i < M; ++i)
4717 for (std::size_t r = 0; r < K; ++r) res.respTimeSamples[i][r] = resp_samples[i][r];
4718 }
4719
4720 // ---- the joint-state histogram and the state path -------------------------
4721 // OUTSIDE the transient branch: a steady-state run that asked for either
4722 // fills it too, which is what `--export-histogram` and `--trajectory` mean.
4723 // The last interval is closed here, or the state the run ended in would
4724 // carry no residence time at all.
4725 if (want_hist && !transient_run) hist_accumulate();
4726 if (!histogram.empty()) {
4727 res.histogram_space = Matrix<double>(histogram.size(), M * K, 0.0);
4728 res.histogram_time = Matrix<double>(histogram.size(), 1, 0.0);
4729 std::size_t row = 0;
4730 for (const auto& kv : histogram) {
4731 for (std::size_t c = 0; c < kv.first.size(); ++c)
4732 res.histogram_space(row, c) = kv.first[c];
4733 res.histogram_time(row, 0) = kv.second;
4734 ++row;
4735 }
4736 }
4737 if (!trajectory.empty()) {
4738 res.traj_space = Matrix<double>(trajectory.size(), M * K, 0.0);
4739 res.traj_time = Matrix<double>(trajectory.size(), 1, 0.0);
4740 for (std::size_t k = 0; k < trajectory.size(); ++k) {
4741 for (std::size_t c = 0; c < trajectory[k].second.size(); ++c)
4742 res.traj_space(k, c) = trajectory[k].second[c];
4743 res.traj_time(k, 0) = trajectory[k].first;
4744 }
4745 }
4746
4747 // ---- cache metrics --------------------------------------------------------
4748 for (const auto& kv : caches) {
4749 const CacheState& cs = kv.second;
4751 cm.hit = Matrix<double>(1, K, 0.0);
4752 cm.miss = Matrix<double>(1, K, 0.0);
4753 // A DELAYED HIT IS ITS OWN OUTCOME, so it enters the denominator beside
4754 // the other two: with a retrieval system the three fractions partition
4755 // the reads, and dividing by hits + misses alone would report fractions
4756 // that sum above one.
4757 if (cs.has_retrieval) cm.delayed = Matrix<double>(1, K, 0.0);
4758 for (std::size_t r = 0; r < K; ++r) {
4759 const double dl = cs.has_retrieval ? cs.delayed[r] : 0.0;
4760 const double tot = cs.hits[r] + cs.misses[r] + dl;
4761 if (tot > 0.0) {
4762 cm.hit(0, r) = cs.hits[r] / tot;
4763 cm.miss(0, r) = cs.misses[r] / tot;
4764 if (cs.has_retrieval) cm.delayed(0, r) = dl / tot;
4765 }
4766 }
4767 // The measured retrieval latency: the mean time a request WAITED on the
4768 // retrieval system, averaged over BOTH populations that wait -- the one
4769 // that triggered the fetch and every request parked behind it. A parked
4770 // request waits the RESIDUAL of the fetch period, not the whole of it,
4771 // so averaging the fetch sojourns alone answers a different question:
4772 // it agrees only where the fetch period is memoryless, and reads 6.5%
4773 // high on `retrieval_chain`, whose fetch is a two-stage hypoexponential.
4774 //
4775 // BOTH POPULATIONS IS WHAT MAKES THE PAIR (ArvR, ResidT) MEAN ANYTHING.
4776 // `getAvgCacheTable` reports ArvR = lambda*(miss + delayed), the rate
4777 // INTO the retrieval system, so the residence time beside it must be
4778 // Little-consistent with that rate: E[N] = miss*W_fetch + delayed*W_parked
4779 // over the same arrivals. That is also what the analytic reference
4780 // computes -- `retrieval_fpi_latency`'s Z = sum(phi + d) / sum(lambda *
4781 // (phi + pi0)) is Little's law over the same two populations -- and what
4782 // `Solver_ssj.exportCacheResults` measures. Reported only where measured.
4783 const double released = std::accumulate(cs.delayed.begin(), cs.delayed.end(), 0.0);
4784 if (cs.has_retrieval && cs.completed_fetches + released > 0.0) {
4785 cm.latency = Matrix<double>(1, K, 0.0);
4786 const double mean_wait = (cs.total_fetch_time + cs.delayed_wait)
4787 / (cs.completed_fetches + released);
4788 for (std::size_t r = 0; r < K; ++r)
4789 cm.latency(0, r) = (cs.hits[r] + cs.misses[r] + cs.delayed[r] > 0.0)
4790 ? mean_wait
4791 : std::numeric_limits<double>::quiet_NaN();
4792 }
4793 res.cache_metrics[sn.nodes[cs.node - 1].name] = cm;
4794 }
4795
4796 // ---- finite capacity regions ---------------------------------------------
4797 if (!regions.empty()) {
4798 const std::size_t NR = regions.size();
4799 res.nregions = NR;
4800 res.QNfcr = Matrix<double>(NR, K, 0.0);
4801 res.TNfcr = Matrix<double>(NR, K, 0.0);
4802 res.WeightNfcr = Matrix<double>(NR, K, 0.0);
4803 res.MemOccNfcr = Matrix<double>(NR, K, 0.0);
4804 res.DropRateNfcr = Matrix<double>(NR, K, 0.0);
4805 for (std::size_t g = 0; g < NR; ++g) {
4806 regions[g].update(now);
4807 for (std::size_t r = 0; r < K; ++r) {
4808 if (sim_time > 0.0) {
4809 // JMT's convention: the region's queue length counts only
4810 // the jobs INSIDE it. A WAITQ job parked outside has not
4811 // entered and is not part of the region's occupancy.
4812 res.QNfcr(g, r) = regions[g].tot_jobs[r] / sim_time;
4813 res.WeightNfcr(g, r) = regions[g].tot_weight[r] / sim_time;
4814 res.MemOccNfcr(g, r) = regions[g].tot_mem[r] / sim_time;
4815 res.TNfcr(g, r) = regions[g].completed[r] / sim_time;
4816 res.DropRateNfcr(g, r) = regions[g].dropped[r] / sim_time;
4817 }
4818 }
4819 }
4820 }
4821
4822 // ---- confidence intervals ------------------------------------------------
4823 // The event count is set BEFORE the intervals, not after: `batch_means_ci`
4824 // plans a run length from it when the caller asked for one, and reading a
4825 // zero there would silently fall back to the BUDGET, which an early
4826 // convergence stop never spent.
4827 res.total_simulated_events = static_cast<long long>(total_completions);
4828 batch_means_ci(obs, tr, o, res);
4829
4830 // The impatience block is reported only when the model declares one: an
4831 // all-zero matrix would read as "measured, and none happened".
4832 double any_balk = 0.0, any_renege = 0.0;
4833 for (std::size_t i = 0; i < M; ++i)
4834 for (std::size_t r = 0; r < K; ++r) {
4835 any_balk += balked[i][r];
4836 any_renege += reneged[i][r];
4837 }
4838 double any_orbit = 0.0;
4839 for (std::size_t i = 0; i < M; ++i)
4840 for (std::size_t r = 0; r < K; ++r) any_orbit += S[i].retried[r];
4841 if (any_orbit > 0.0) {
4842 res.retriedCustomers = Matrix<double>(M, K, 0.0);
4843 res.retrialDropped = Matrix<double>(M, K, 0.0);
4844 res.avgOrbitSize = Matrix<double>(M, K, 0.0);
4845 for (std::size_t i = 0; i < M; ++i) {
4846 const double dt = now - S[i].orbit_last;
4847 for (std::size_t r = 0; r < K; ++r) {
4848 double tot = S[i].tot_orbit[r];
4849 if (dt > 0.0) tot += S[i].orbit_size[r] * dt;
4850 res.retriedCustomers(i, r) = S[i].retried[r];
4851 res.retrialDropped(i, r) = S[i].retrial_lost[r];
4852 if (sim_time > 0.0) res.avgOrbitSize(i, r) = tot / sim_time;
4853 }
4854 }
4855 }
4856
4857 if (any_balk > 0.0 || any_renege > 0.0) {
4858 res.balkedCustomers = Matrix<double>(M, K, 0.0);
4859 res.renegedCustomers = Matrix<double>(M, K, 0.0);
4860 res.renegingRate = Matrix<double>(M, K, 0.0);
4861 for (std::size_t i = 0; i < M; ++i)
4862 for (std::size_t r = 0; r < K; ++r) {
4863 res.balkedCustomers(i, r) = balked[i][r];
4864 res.renegedCustomers(i, r) = reneged[i][r];
4865 if (sim_time > 0.0) res.renegingRate(i, r) = reneged[i][r] / sim_time;
4866 }
4867 }
4868
4869 res.converged = converged;
4870 res.convergence_batches = cnvg.batches();
4871 if (!transient_run) res.stopping_reason = converged ? "convergence" : "max_events";
4872 res.engine = "native";
4873 res.method = o.method;
4874 return res;
4875}
4876
4877/**
4878 * Simulate `sn`, over independent REPLICATIONS when `options.replications > 1`.
4879 *
4880 * Each replication is one path with its OWN seed, `seed + r`, exactly as
4881 * `Solver_ldes_analyzer_parallel` derives them. That is what makes the paths
4882 * independent while keeping the whole run reproducible; sharing a seed would
4883 * make every replication identical and collapse the interval to zero, and
4884 * drawing them randomly would make the run irreproducible.
4885 *
4886 * THE INTERVAL COMES FROM THE SPREAD BETWEEN REPLICATIONS, not from within a
4887 * path, and it is the honest one: independent paths need no assumption about
4888 * the correlation structure that batch means has to model. The cost is that
4889 * `replications - 1` degrees of freedom is few, so the t critical value is
4890 * large and the interval wide -- which is the estimate, not a defect of it.
4891 *
4892 * A single replication yields NO interval rather than a zero-width one: with
4893 * one path there is no spread to measure, and reporting zero would read as
4894 * perfect precision.
4895 */
4896template <class T>
4898 using namespace engine;
4899 const int R = o.replications;
4900 if (R <= 1) return ldes_engine_solve_one(sn, o);
4901
4902 std::vector<LdesResult> reps;
4903 reps.reserve(static_cast<std::size_t>(R));
4904 for (int r = 0; r < R; ++r) {
4905 LdesOptions ro = o;
4906 ro.replications = 0;
4907 // A replication measures its OWN interval nowhere: the spread across
4908 // paths is the estimate, so the within-path machinery is switched off.
4909 ro.cimethod = "none";
4910 ro.seed = (o.seed >= 0) ? (o.seed + r) : o.seed;
4911 reps.push_back(ldes_engine_solve_one(sn, ro));
4912 }
4913
4914 LdesResult res = reps[0];
4915 res.QNCI = Matrix<double>();
4916 res.UNCI = Matrix<double>();
4917 res.RNCI = Matrix<double>();
4918 res.TNCI = Matrix<double>();
4919
4920 const std::size_t M = res.nstations, K = res.nclasses;
4921 Matrix<double>* means[4] = {&res.QN, &res.UN, &res.RN, &res.TN};
4922 Matrix<double>* cis[4] = {&res.QNCI, &res.UNCI, &res.RNCI, &res.TNCI};
4923 const Matrix<double>* per_rep[4];
4924
4925 for (int q = 0; q < 4; ++q) {
4926 *means[q] = Matrix<double>(M, K, 0.0);
4927 *cis[q] = Matrix<double>(M, K, 0.0);
4928 }
4929 const double tcrit = t_critical(o.confint > 0.0 ? o.confint : 0.95, R - 1);
4930
4931 for (std::size_t i = 0; i < M; ++i)
4932 for (std::size_t k = 0; k < K; ++k)
4933 for (int q = 0; q < 4; ++q) {
4934 double sum = 0.0;
4935 for (int r = 0; r < R; ++r) {
4936 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
4937 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
4938 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
4939 : &reps[static_cast<std::size_t>(r)].TN;
4940 sum += (*per_rep[q])(i, k);
4941 }
4942 const double mean = sum / R;
4943 (*means[q])(i, k) = mean;
4944 double ss = 0.0;
4945 for (int r = 0; r < R; ++r) {
4946 per_rep[q] = (q == 0) ? &reps[static_cast<std::size_t>(r)].QN
4947 : (q == 1) ? &reps[static_cast<std::size_t>(r)].UN
4948 : (q == 2) ? &reps[static_cast<std::size_t>(r)].RN
4949 : &reps[static_cast<std::size_t>(r)].TN;
4950 const double d = (*per_rep[q])(i, k) - mean;
4951 ss += d * d;
4952 }
4953 (*cis[q])(i, k) = tcrit * std::sqrt((ss / (R - 1)) / R);
4954 }
4955
4956 // The system-level rows average the same way; they carry no interval,
4957 // matching what the single-path engine reports for them.
4958 res.XN = Matrix<double>(1, K, 0.0);
4959 res.CN = Matrix<double>(1, K, 0.0);
4960 for (std::size_t k = 0; k < K; ++k) {
4961 double sx = 0.0, sc = 0.0;
4962 for (int r = 0; r < R; ++r) {
4963 sx += reps[static_cast<std::size_t>(r)].XN(0, k);
4964 sc += reps[static_cast<std::size_t>(r)].CN(0, k);
4965 }
4966 res.XN(0, k) = sx / R;
4967 res.CN(0, k) = sc / R;
4968 }
4969
4970 long long total = 0;
4971 for (int r = 0; r < R; ++r) total += reps[static_cast<std::size_t>(r)].total_simulated_events;
4972 res.total_simulated_events = total;
4973 return res;
4974}
4975
4976} // namespace ldes
4977} // namespace line
4978
4979#endif // LINE_SOLVERS_LDES_LDES_ENGINE_H
Delay(model, name): the infinite-server station.
Definition nodes.h:147
InputError(const std::string &what)
Definition error.h:39
Queue(model, name, strategy).
Definition nodes.h:139
Source(model, name): the external arrival station.
Definition nodes.h:153
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.
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:424
@ IMMEDIATE
fires with zero delay, resolved by weight and priority
Definition lang_types.h:363
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
@ SDR
Krzesinski (1987) product-form state-dependent routing.
Definition lang_types.h:398
DepartureDiscipline
When a Place releases a served token, MATLAB DepartureDiscipline.
Definition lang_types.h:458
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:451
PollingType
Polling service disciplines, with the values of MATLAB PollingType.
Definition lang_types.h:370
@ KLIMITED
serve at most K per visit (K in pollingPar)
Definition lang_types.h:373
@ EXHAUSTIVE
serve until the queue empties
Definition lang_types.h:372
const char * node_type_to_text(NodeType t)
Name of a node kind, for diagnostics.
Definition lang_types.h:341
@ NHPP
The time-INHOMOGENEOUS families of Ko and Pender (ORL 45, 2017): an NHPP is a rate schedule lambda(t)...
Definition lang_types.h:536
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
std::function< std::vector< T >(const std::vector< T > &)> CdScaling
A class-dependent scaling map, sn.cdscaling.
Definition lang_types.h:639
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
EventKind
Event tags, ordered so that a service completion precedes an arrival scheduled for the same instant.
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:646
void ldes_engine_reject(const qn::NetworkStruct< T > &sn, const LdesOptions &o)
Refuse, by name, every model outside this engine's scope.
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< 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.
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< 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
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::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< Sampler > svc
std::vector< std::size_t > blocked_dest
std::vector< double > classcap
bool has_breakdown
Server breakdown: the server alternates up and down on two clocks.
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
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