LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ssa_dispatch.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_SSA_SSA_DISPATCH_H
6#define LINE_SOLVERS_SSA_SSA_DISPATCH_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The SolverSSA entry surface: a port of `@@SolverSSA/runAnalyzer.m`'s method
12 * whitelist, of `solver_ssa_analyzer.m`'s eligibility gate (`isNrmEligible` and
13 * the per-feature `*NrmOK` predicates) and of `solver_ssa_analyzer_nrm.m`'s own
14 * scheduling validation.
15 *
16 * EVERY METHOD THE REFERENCE OFFERS IS REACHED FROM HERE. `SolverSSA.m` line 55
17 * lists `default`, `ssa`, `serial`, `para`/`parallel` and `nrm`; the JAR's
18 * `SolverSSA.listValidMethods` and native Python's list the same set. All five
19 * resolve to one of the three ported engines: the NRM of `solver_ssa_nrm.h`,
20 * the event-driven engine of `solver_ssa_serial.h`, and the replica mean of
21 * `solver_ssa_parallel.h`. `para`/`parallel` prefers the NRM whenever the model
22 * is eligible and replicates the serial engine otherwise, exactly as the
23 * reference does; `ssa` is the reference's alias for `serial`, NOT for the NRM;
24 * `default` is the reference's ladder -- the NRM when eligible, the serial
25 * engine otherwise -- as `solver_ssa` sets out below.
26 * `solver_ssa` returns the metric table alone, so a caller who needs the sample
27 * path, the per-replica tables or their standard errors calls the engine's own
28 * entry.
29 *
30 * WITHIN `nrm` THERE ARE TWO ENGINES, and both are reached from here. The
31 * reference picks between them on `options.config.state_space_gen`
32 * (`solver_ssa_analyzer_nrm.m` lines 52-68): `none` and `default` take the
33 * plain engine of `solver_ssa_nrm.m`, which integrates the metrics along the
34 * sample path, and any other value takes the tabulating engine of
35 * `solver_ssa_nrm_space.m`, which records the distinct states visited and forms
36 * the means as `pi * A`. `SsaOptions::state_space_gen` is that switch, and the
37 * eligibility gate below runs before it, exactly as the reference's does: it is
38 * the NRM's gate, not one engine's. The space engine then applies its OWN
39 * further refusals (an open model, a phase-type service, a space above the cap),
40 * each by name, in `NrmSpaceEngine::check`.
41 *
42 * WHY THE GATE IS SO LONG, AND WHAT IT IS FOR. It is `isNrmEligible`
43 * (`solver_ssa_analyzer.m` lines 365-386) and it answers ONE question: can the
44 * NRM run this model? Under `method='nrm'` a no is a refusal BY NAME, because a
45 * caller who spelled out the estimator asked for that estimator; under
46 * `default` and `parallel` the same no is a fallback to the serial engine, as
47 * the reference's is. The two share a body (`raise`), so they cannot drift.
48 *
49 * WHAT THE GATE MEANS SINCE THE FALLBACK WAS RESTORED. It is the NRM's reach and
50 * NOT SolverSSA's: everything it rejects, the serial engine runs (a Fork, a
51 * BLOCKING region rule, global dependence), so a rejection under `default`
52 * costs the caller the NRM's SPEED and not the answer. Under `method='nrm'` it
53 * is still a refusal by name, because an estimator that was asked for by name
54 * must be the one that runs. What the NRM lacks falls in two groups:
55 *
56 * NOT REPRESENTABLE the struct has no field at all, so a model using the
57 * feature cannot be built (balking, reneging patience,
58 * SelfLoopingClass -- `JobClassType` is OPEN or CLOSED
59 * only). WRROBIN / JSQ / SQ / RL routing belongs here too
60 * by a different route: the enumerators exist, but
61 * `NetworkStruct::refresh_routing` refuses those
62 * state-dependent strategies when the struct is built, so
63 * the gate's own test is reached only by a caller who
64 * filled `routing` by hand. RROBIN is the exception: the
65 * refresh now EXPANDS it uniformly, for QNA/MNA, which
66 * recover the determinism from the split degree. An
67 * RROBIN model therefore reaches this gate for real, and
68 * the refusal below is what keeps the NRM from simulating
69 * it as random routing.
70 * REFUSED IN EVERY CODEBASE'S NRM Fork/Join, the blocking region rules
71 * BAS / BBS / RSRD, and global (Whittle) dependence; the
72 * reference's `isNrmEligible` excludes the same set.
73 * Retrial orbits (`retrialparam`) and G-network signals
74 * (`issignal` and friends) are refused downstream, by the
75 * engines that would have to simulate them.
76 *
77 * The sub-engines that have LEFT the refusal list, all in `solver_ssa_nrm.h`
78 * unless named: the *PRIO shares on 2026-08-15; the stochastic Petri net path
79 * on 2026-09-13 (`solver_ssa_nrm_spn.h`, which still refuses the four firing
80 * laws `spn_nrm_supported` names); and on 2026-09-24 class- and joint-dependent
81 * scaling (`cdfac`), finite capacity regions under DROP and WAITQ
82 * (`fcr_outcome` / `fcr_release_cascade`), PAS / OI (`pas_rate` /
83 * `pas_depart`), POLLING (`PollCtrl` and one switchover reaction per station)
84 * and the Cache (`cache_fire`, drawing each READ from `after_event_cache`). The
85 * tabulating engine of `solver_ssa_nrm_space.h` does not carry those five and
86 * refuses them itself, by name.
87 *
88 * Nothing falls through silently: a model with a construct the reaction table
89 * does not model would run to completion and return numbers that are simply
90 * not the model's, which is why the gate stays even where the serial engine can
91 * answer.
92 */
93
94#include <string>
95#include <vector>
96
106#include "line/util/error.h"
107
108namespace line {
109namespace ssa {
110
111namespace detail {
112
113/**
114 * The scheduling whitelist of `solver_ssa_analyzer_nrm.m` lines 15-26,
115 * restricted to what this port's rate laws cover.
116 *
117 * The three priority-weighted shares PSPRIO / DPSPRIO / GPSPRIO joined the
118 * whitelist on 2026-08-15, and PAS / OI and POLLING on 2026-09-24, when their
119 * sub-engines (the pass-and-swap list and the polling controller) were ported.
120 *
121 * `raise` is what makes this both the REFUSAL and the ELIGIBILITY test, as the
122 * reference's single `isNrmEligible` is: `raise=true` names the offending
123 * station and throws, `raise=false` answers false. One body, so the message a
124 * caller of `method='nrm'` reads and the predicate `default` and `parallel`
125 * branch on can never disagree.
126 */
127template <class T>
128bool ssa_check_scheduling(const qn::NetworkStruct<T>& sn, bool raise = true) {
130 for (std::size_t i = 0; i < sn.nstations; ++i) {
131 const SchedStrategy s = sn.stations[i].sched;
132 switch (s) {
149 // Pass-and-swap and order-independent: served since 2026-09-24 through
150 // the ordered-list sub-engine (`pas_rate` / `pas_depart`).
153 // Polling: served since 2026-09-24 through the controller sub-engine
154 // (`PollCtrl`, one switchover reaction per polling station).
156 continue;
157 default:
158 if (!raise) return false;
159 throw UnsupportedError("SolverSSA(method='nrm'): the scheduling policy '" +
160 std::string(lang::sched_to_text(s)) + "' at station '" +
161 sn.stations[i].name +
162 "' is not supported by the NRM in any codebase");
163 }
164 }
165 return true;
166}
167
168/**
169 * Node kinds the NRM reaction grid cannot express in this port.
170 *
171 * A Place and a Transition are NOT among them since 2026-09-13:
172 * `solver_ssa_nrm_spn.h` ports the reference's SPN builder and run loop, so a
173 * net carrying them is routed to that engine instead of refused. What it can
174 * and cannot read is `spn_nrm_supported`, asked separately below, because the
175 * reference asks it separately too -- its four conditions are about the FIRING
176 * LAWS of the net, not about the presence of a node kind.
177 */
178template <class T>
179bool ssa_check_nodes(const qn::NetworkStruct<T>& sn, bool raise = true) {
180 for (const qn::NodeDef& nd : sn.nodes) {
181 switch (nd.nodetype) {
182 // Cache: served since 2026-09-24 through the cache-read reaction
183 // (`NrmEngine::cache_fire`).
184 case qn::NodeType::Fork:
185 case qn::NodeType::Join:
186 if (!raise) return false;
187 throw UnsupportedError("SolverSSA(method='nrm'): node '" + nd.name +
188 "' makes this a fork-join model, which the NRM does not "
189 "handle in any codebase (isNrmEligible excludes it); the "
190 "reference falls back to the serial engine, whose own "
191 "fork handler (sn.fjsync / State.afterFJEvent) is not "
192 "ported either");
193 default:
194 break;
195 }
196 }
197 return true;
198}
199
200/**
201 * Routing strategies this port resolves, plus what the model layer itself
202 * cannot represent.
203 *
204 * RROBIN, WRROBIN, JSQ and SQ are resolved AT FIRING TIME by the NRM engine
205 * (`build_state_dependent_dest` / `resolve_state_dependent_dest`) from the
206 * declared out-arcs and the live population, which is what the reference does
207 * and what makes a round-robin dispatcher a dispatcher rather than a coin.
208 * `refresh_routing` still expands them into a probability split, because a
209 * matrix solver has nothing else to read; the engine ignores that split for
210 * these nodes and walks the arcs itself.
211 *
212 * SDR is the one that stays refused: its routing is a function the struct does
213 * not carry, so there is nothing here to evaluate.
214 */
215template <class T>
216bool ssa_check_routing(const qn::NetworkStruct<T>& sn, bool raise = true) {
217 for (const qn::NodeDef& nd : sn.nodes)
218 for (std::size_t r = 0; r < nd.routing.size(); ++r) {
219 const qn::RoutingStrategy rs = nd.routing[r];
220 if (rs == qn::RoutingStrategy::PROB || rs == qn::RoutingStrategy::RAND ||
221 rs == qn::RoutingStrategy::DISABLED || rs == qn::RoutingStrategy::RROBIN ||
222 rs == qn::RoutingStrategy::WRROBIN || rs == qn::RoutingStrategy::JSQ ||
223 rs == qn::RoutingStrategy::SQ)
224 continue;
225 if (!raise) return false;
226 throw UnsupportedError(
227 "SolverSSA(method='nrm'): node '" + nd.name + "' routes class '" +
228 sn.classes[r].name + "' by '" + std::string(lang::routing_to_text(rs)) +
229 "'. Its destination is a function the C++ NetworkStruct does not carry, so "
230 "there is nothing to resolve at firing time");
231 }
232 return true;
233}
234
235/**
236 * `phaseNrmOK` (solver_ssa_analyzer.m lines 407-...): where a non-exponential
237 * service process may sit.
238 *
239 * The INF / PS family expands exactly, because every job present is in service
240 * and the class share splits across the phases in the ratio kir/nir. The
241 * non-preemptive buffered family expands through the auxiliary in-service
242 * multiset. EXT is deliberately excluded and the reason is worth repeating: a
243 * phase-type ARRIVAL process is not a service law, and expanding it fires one
244 * arrival per PHASE instead of one per RENEWAL, so an Erlang-2 source doubles
245 * lambda. LCFSPR is excluded because preempt-resume would have to remember the
246 * preempted job's phase.
247 */
248template <class T>
249bool ssa_check_phases(const qn::NetworkStruct<T>& sn, bool raise = true) {
251 for (std::size_t i = 0; i < sn.nstations; ++i) {
252 const SchedStrategy s = sn.stations[i].sched;
253 const bool exact = s == SchedStrategy::INF || s == SchedStrategy::PS ||
259 if (exact) continue;
260 for (std::size_t r = 0; r < sn.nclasses; ++r) {
261 if (sn.disabled[i][r]) continue;
262 const lang::ProcessType pt = sn.service[i][r].type;
265 continue;
266 if (!raise) return false;
267 throw UnsupportedError(
268 "SolverSSA(method='nrm'): class '" + sn.classes[r].name + "' has non-exponential "
269 "service at station '" + sn.stations[i].name + "', whose '" +
270 std::string(lang::sched_to_text(s)) +
271 "' discipline the NRM phase expansion does not cover; ask for 'default', which "
272 "falls back to the serial engine as the reference does");
273 }
274 }
275 return true;
276}
277
278/**
279 * Global (Whittle) dependence, refused because the NRM does not SCALE by it.
280 *
281 * Class- and joint-dependent scaling are served since 2026-09-24: `cdfac` in
282 * `solver_ssa_nrm.h` evaluates eta_i(n) .* beta_{i,r}(n) on the station's
283 * per-class population at firing time, as the reference does, and utilization
284 * is normalized by the declared peaks. The reference's gate (`ssa_nrm_guards.gd`)
285 * excludes only the global handle, and so does this one.
286 */
287template <class T>
288bool ssa_check_cdscaling(const qn::NetworkStruct<T>& sn, bool raise = true) {
289 // The NRM's propensities receive the per-station population slice, not the
290 // whole population matrix a global handle reads.
291 if (sn.gdscaling) {
292 if (!raise) return false;
293 throw UnsupportedError(
294 "SolverSSA(method='nrm'): the model declares a global dependence "
295 "(setGlobalDependence). The NRM builds its propensities from the per-station "
296 "population slice and never sees the whole population matrix the handle reads, so "
297 "the sample path would run at the unscaled rates; ask for method='serial', which "
298 "applies it");
299 }
300 return true;
301}
302
303/**
304 * Finite capacity regions: served since 2026-09-24, under DROP and WAITQ.
305 *
306 * `solver_ssa_nrm.h` ports `fcrPrecompute` / `fcrRefusingRegion` /
307 * `fcrReleaseCascade`: an admission gate on every arrival into a region and a
308 * head-of-line release cascade from a per-region FIFO after every firing. What
309 * stays refused is a BLOCKING region rule (BAS, BBS, RSRD), which no codebase's
310 * NRM carries.
311 */
312template <class T>
313bool ssa_check_regions(const qn::NetworkStruct<T>& sn, bool raise = true) {
314 for (const typename qn::NetworkStruct<T>::Region& rg : sn.regions)
315 for (std::size_t r = 0; r < rg.rule.size(); ++r) {
316 const qn::DropStrategy d = rg.rule[r];
317 if (d != qn::DropStrategy::BAS && d != qn::DropStrategy::BBS &&
318 d != qn::DropStrategy::RSRD)
319 continue;
320 if (!raise) return false;
321 throw UnsupportedError(
322 "SolverSSA(method='nrm'): finite capacity region '" + rg.name +
323 "' declares a blocking rule for class '" + sn.classes[r].name +
324 "'. The NRM region gate implements DROP and WAITQ only, as the reference's does");
325 }
326 return true;
327}
328
329/**
330 * Synchronous calls (REPLY signals), `ssa_nrm_guards.reply`: refused by the NRM in every codebase.
331 *
332 * A calling job leaves its caller and keeps the server there until the reply returns. The
333 * serial engine carries that hold in the reply block of the local row (`after_event`), while
334 * the NRM's reactions see per-class counts only and have no held-server counter, so the caller
335 * would free its server at once. `default` and `parallel` therefore take the serial engine,
336 * and an explicit `nrm` falls back to it with a warning, as the reference does.
337 */
338template <class T>
339bool ssa_check_reply(const qn::NetworkStruct<T>& sn, bool raise = true) {
340 for (std::size_t ind = 0; ind < sn.replyblock.size(); ++ind)
341 for (std::size_t k = 0; k < sn.replyblock[ind].size(); ++k) {
342 if (!sn.replyblock[ind][k]) continue;
343 if (!raise) return false;
344 throw UnsupportedError(
345 "SolverSSA(method='nrm'): class '" + sn.classes[k].name + "' makes a synchronous "
346 "call (REPLY signal) from node '" + sn.nodes[ind].name + "'. The NRM has no "
347 "held-server counter for a pending reply; use method='serial'");
348 }
349 return true;
350}
351
352/**
353 * `isNrmEligible` (`solver_ssa_analyzer.m` lines 365-386): can the NRM run this
354 * model at all?
355 *
356 * It is the SAME tests the refusal above takes, asked with `raise=false`,
357 * so the predicate cannot drift from the message. It is deliberately the C++
358 * NRM's reach and not the reference's: this port's NRM covers less, and a
359 * `default` that consulted MATLAB's wider predicate would send a model to a
360 * refusal the serial engine can actually answer.
361 */
362template <class T>
363bool ssa_nrm_eligible(const qn::NetworkStruct<T>& sn) {
364 // Immediate feedback keeps the fed-back job on the server it just used, which
365 // the serial engine expresses through `after_event`'s `no_promote` arc. The
366 // NRM has no such arc: its reactions see per-class counts, not which job holds
367 // the server, so a self-loop there is a class switch with re-queueing. An
368 // explicit `method='nrm'` still RUNS the model with that approximation,
369 // warning in as many words (`ssa_nrm_supports`, the GATE, deliberately does
370 // not test this); this only keeps `default` and `parallel` from PREFERRING an
371 // approximation over the exact serial engine.
372 if (sn.has_immediate_feedback()) return false;
373 return ssa_check_nodes(sn, false) && ssa_check_scheduling(sn, false) &&
374 ssa_check_routing(sn, false) && ssa_check_phases(sn, false) &&
375 ssa_check_cdscaling(sn, false) && ssa_check_regions(sn, false) &&
376 ssa_check_reply(sn, false) && spn_nrm_supported(sn, false);
377}
378
379/**
380 * The same tests as a SENTENCE, so a report can say why `nrm` is withheld.
381 *
382 * `ssa_nrm_eligible` above answers the dispatch's question (may I PREFER the
383 * NRM?) and nothing answered the gate's (may I OFFER it?), so `ssa.nrm` was
384 * reported runnable on every model and an explicit request then raised. The
385 * catch is the ADAPTER between those two callers and not error suppression: the
386 * checks already carry the exact wording a user should see, and re-deriving it
387 * here is how the message and the predicate drift apart.
388 */
389template <class T>
390std::string ssa_nrm_supports(const qn::NetworkStruct<T>& sn) {
391 try {
392 ssa_check_nodes(sn, true);
393 ssa_check_scheduling(sn, true);
394 ssa_check_routing(sn, true);
395 ssa_check_phases(sn, true);
396 ssa_check_cdscaling(sn, true);
397 ssa_check_regions(sn, true);
398 ssa_check_reply(sn, true);
399 spn_nrm_supported(sn, true);
400 } catch (const UnsupportedError& e) {
401 return e.what();
402 }
403 return "";
404}
405
406/**
407 * The tabulating engine's knobs from the caller's.
408 *
409 * `state_max` keeps its own default because `SsaOptions` carries no cap: a
410 * caller who wants a different one calls `solver_ssa_nrm_space_analyzer`
411 * directly, which is also where the tabulated path and the propensity table
412 * survive rather than being reduced to the metrics.
413 */
414inline SsaNrmSpaceOptions ssa_space_options(const SsaOptions& o) {
415 SsaNrmSpaceOptions s;
416 static_cast<SsaOptions&>(s) = o;
417 return s;
418}
419
420/**
421 * The serial and replicated engines' knobs from the caller's.
422 *
423 * `cutoff`, `state_max`, `nreplicas` and `eventcache` keep their
424 * own defaults for the same reason `state_max` does above: `SsaOptions` has no
425 * field for them, and `nreplicas = 8` is `SolverOptions('SSA')`'s own default
426 * rather than a number chosen here. A caller who wants a different R calls
427 * `solver_ssa_parallel` directly, which is also where the R per-replica tables
428 * and their standard errors survive rather than being reduced to the mean.
429 */
430inline SsaSerialOptions ssa_serial_options(const SsaOptions& o) {
431 SsaSerialOptions s;
432 static_cast<SsaOptions&>(s) = o;
433 return s;
434}
435
436inline SsaParallelOptions ssa_parallel_options(const SsaOptions& o) {
437 SsaParallelOptions p;
438 static_cast<SsaOptions&>(p) = o;
439 return p;
440}
441
442/**
443 * One serial run, keeping the cache write-back the metric table does not carry.
444 *
445 * Written once because `solver_ssa` reaches the serial engine from two arms
446 * (the `default` fallback and the named method), and a caller that got its
447 * cache shares from one arm and not the other would report the offered split
448 * for the same model under a different spelling of the same request.
449 */
450template <class T>
451SsaSolution ssa_serial_avg(const qn::NetworkStruct<T>& sn, const SsaOptions& opt,
452 std::vector<SsaCacheRatio>* cache) {
453 SsaSerialSolution<T> s = solver_ssa_serial_analyzer(sn, ssa_serial_options(opt));
454 if (cache) *cache = s.cache;
455 return s.avg;
456}
457
458/**
459 * `@@SolverSSA/runAnalyzer.m:227`: a Place counts TOKENS, an engine counts
460 * FIRING EVENTS, and the two are the same number only where every arc has
461 * multiplicity one and no immediate transition moves the tokens.
462 *
463 * WHAT THE ENGINES HAND OVER. Every SPN engine accumulates a Place's
464 * throughput as the summed propensity of the TIMED modes consuming from it, so
465 * an arc of weight 2 counts one departure where two tokens left, and an
466 * IMMEDIATE consumer counts none at all -- a firing that takes zero time is not
467 * a timed event and no engine, here or in the reference, ever sees it as one.
468 * A Place drained only by an immediate transition therefore arrives here with
469 * TN = 0, which then makes RespT 0 and leaves the neighbouring Place's ArvR 0
470 * once the caller derives it from this table.
471 *
472 * `sn_pn_avg_rates` is the reference's repair and recovers the missing rates
473 * from the flow-balance equations, which is why it runs at the SOLVER level and
474 * on the ORIGINAL struct: both engines fold a fork-join tagging back before
475 * returning, so the table is already in the caller's own coordinates here, as
476 * it is after `solver_tr_fjtag_analyzer`'s lift in the reference. A model with
477 * no Place, or one whose firing rates cannot be recovered, is returned
478 * untouched.
479 *
480 * AN IS LEFT TO THE CALLER, exactly as `[TN,~,RN] = sn_pn_avg_rates(...)` leaves
481 * it in the reference: `SsaSolution` carries no arrival-rate table, and every
482 * consumer derives one with `sn_get_arvr_from_tput` from the TN repaired here.
483 */
484template <class T>
485SsaSolution ssa_pn_token_rates(const qn::NetworkStruct<T>& sn, const SsaSolution& in) {
486 bool has_place = false;
487 for (std::size_t a = 0; a < sn.nodes.size() && !has_place; ++a)
488 if (sn.nodes[a].nodetype == qn::NodeType::Place) has_place = true;
489 if (!has_place || in.TN.rows() == 0) return in;
490
491 const std::size_t M = in.TN.rows(), K = in.TN.cols();
492 Matrix<T> QN(M, K), TN(M, K), RN(M, K);
493 for (std::size_t i = 0; i < M; ++i)
494 for (std::size_t k = 0; k < K; ++k) {
495 QN(i, k) = num_traits<T>::from_double(in.QN(i, k));
496 TN(i, k) = num_traits<T>::from_double(in.TN(i, k));
497 RN(i, k) = num_traits<T>::from_double(in.RN(i, k));
498 }
499 const api::SnPnAvgRates<T> pn = api::sn_pn_avg_rates(sn, QN, TN, Matrix<T>(), RN);
500
501 SsaSolution out = in;
502 for (std::size_t i = 0; i < M; ++i)
503 for (std::size_t k = 0; k < K; ++k) {
504 out.TN(i, k) = num_traits<T>::to_double(pn.TN(i, k));
505 out.RN(i, k) = num_traits<T>::to_double(pn.RN(i, k));
506 }
507 return out;
508}
509
510} // namespace detail
511
512/**
513 * Port of `SolverSSA.listValidMethods`.
514 *
515 * Six names for three engines, because the reference spells the same engine
516 * more than one way: 'ssa' and 'serial' are the serial trajectory, 'para' and
517 * 'parallel' the replicated one, 'nrm' the next-reaction method, and 'default'
518 * is the ladder `solver_ssa` walks -- the NRM when it can run the model and the
519 * serial engine when it cannot. Every name here is dispatched by `solver_ssa`
520 * below, which refuses anything else by name.
521 */
522inline std::vector<std::string> list_valid_methods() {
523 return {"default", "ssa", "serial", "para", "pana", "parallel", "nrm"};
524}
525
526/**
527 * `solver_ssa_analyzer_nrm.m`: run the NRM and return the metric table.
528 *
529 * The reference's post-processing (`QN(isnan(QN)) = 0` and the rest) is inside
530 * the engine already: it never produces a NaN, because every division is
531 * guarded at the point it is taken.
532 */
533template <class T>
535 std::vector<SsaCacheRatio>* cache = nullptr) {
536 // `if constexpr`, not a run-time test: the engine reaches `dist_to_map`,
537 // whose APH fit static_asserts on transcendental arithmetic, so a Rational
538 // instantiation would fail to COMPILE rather than refuse. The gate has to
539 // keep the body from being instantiated at all.
540 if constexpr (!std::is_same<T, double>::value) {
541 (void)sn;
542 (void)opt;
543 (void)cache;
544 throw UnsupportedError(
545 "solver_ssa_nrm: an SSA sample path is generated from exponential clocks, which are "
546 "logarithms of uniform draws; there is no exact value to compute and a wider float "
547 "carries no information the Monte Carlo error does not swamp. Rerun with --arith "
548 "double");
549 } else {
550 detail::ssa_check_nodes(sn);
551 detail::ssa_check_scheduling(sn);
552 detail::ssa_check_routing(sn);
553 detail::ssa_check_phases(sn);
554 detail::ssa_check_cdscaling(sn);
555 detail::ssa_check_regions(sn);
556 detail::ssa_check_reply(sn);
557 detail::spn_nrm_supported(sn);
558 // `solver_ssa_nrm.m:174`: a net carrying a Transition is a STOCHASTIC
559 // PETRI NET and goes to the SPN builder and run loop, which reads a
560 // marking of tokens rather than a grid of jobs in service. The branch is
561 // taken before the space/plain switch below because the tabulating
562 // engine tabulates the queueing state vector, which an SPN does not
563 // have; the reference branches at the same point and for the same
564 // reason.
565 for (const qn::NodeDef& nd : sn.nodes)
566 if (nd.nodetype == qn::NodeType::Transition) {
568 return spn.run();
569 }
570 // The reference's engine switch, taken AFTER the gate above because the
571 // gate is the NRM's and not one engine's.
572 if (opt.state_space_gen != "none" && opt.state_space_gen != "default")
573 return solver_ssa_nrm_space_analyzer(sn, detail::ssa_space_options(opt)).avg;
574 NrmEngine<T> eng(sn, opt);
575 SsaSolution out = eng.run();
576 if (cache) *cache = eng.cache();
577 return out;
578 }
579}
580
581/**
582 * `solver_ssa_analyzer.m`: choose the method.
583 *
584 * The ladder is the reference's, in its order:
585 *
586 * `default` the NRM when it is eligible, ELSE the serial engine, and only a
587 * model neither engine runs is refused -- by the NRM's message,
588 * which names the missing sub-engine.
589 * `nrm` the NRM alone, reached by naming the estimator: a model it
590 * cannot run is refused rather than silently answered by the
591 * other engine, because a caller who spelled out an estimator
592 * asked for that estimator's variance as well as its mean.
593 * `ssa` the reference's alias for `serial` (line 128), not for the NRM.
594 * `serial` one run of the event-driven engine.
595 * `para` the NRM when eligible (the reference prefers one fast run over
596 * `parallel` R replicated ones, lines 143-157), else the replica mean of
597 * `nreplicas` independent serial runs.
598 *
599 * `default`'s FALLBACK IS THE REFERENCE'S (lines 66-78) and was restored on
600 * 2026-07-31, when the serial engine gained fork-join and the finite capacity
601 * regions. It had been held back while the serial engine covered less than the
602 * NRM gate rejects, on the ground that a second failure further downstream is
603 * less informative than the NRM's own message. That ground is gone for every
604 * construct the serial engine now runs, and where it still holds -- a model
605 * NEITHER engine covers -- the NRM's message is what the caller reads, because
606 * `serial_can_run` is asked BEFORE the fallback is taken rather than after it
607 * has failed.
608 *
609 * `cache`, when given, receives the serial engine's cache write-back -- the
610 * realized hit and miss shares of every Cache node, which are a SOLVER RESULT
611 * and the only thing that tells a node table apart from the 1/2-1/2 `link()`
612 * offers. It is an out-parameter rather than a field of `SsaSolution` because
613 * that struct is the metric table the three engines share, and only two of them
614 * have a cache to report, the serial engine and (since 2026-09-24) the NRM; the
615 * parallel engine averages replicas that carry none.
616 */
617template <class T>
619 std::vector<SsaCacheRatio>* cache) {
620 // A fed-back job keeps its server, which the reference encodes in
621 // `State.afterEvent`'s immfeed self-loop, and only the SERIAL engine has that
622 // arc -- here as in the reference. `solver_ssa_analyzer_nrm.m:29` warns in as
623 // many words that the NRM "does not model immediate feedback (immfeed);
624 // self-loops are treated as class-switching with re-queueing" and then runs,
625 // so the name stays offerable and only the approximation is announced.
626 // `ssa_nrm_eligible` excludes immediate feedback, so `default` and `parallel`
627 // reach the exact serial engine and only an EXPLICIT `nrm` warns.
628 if (sn.has_immediate_feedback()) {
629 const std::string& mm = opt.method;
630 const bool runs_nrm =
631 mm == "nrm" || ((mm == "default" || mm == "para" || mm == "pana" || mm == "parallel") &&
632 detail::ssa_nrm_eligible(sn));
633 if (runs_nrm)
634 std::cerr << "[LINE] Warning: SolverSSA(method=nrm) does not model immediate feedback "
635 "(immfeed); self-loops are treated as class-switching with re-queueing. "
636 "Use method='serial' for immediate feedback."
637 << std::endl;
638 }
639 const std::string& m = opt.method;
640 if (m == "default") {
641 // The reference's ladder: the NRM when it can run the model, the serial
642 // engine when it cannot, and the NRM's own refusal -- which names the
643 // sub-engine that is missing -- when neither can. Asking
644 // `serial_can_run` first is what keeps that message: falling through to
645 // the serial engine and letting IT fail would report whichever guard it
646 // hit, about a model the caller never asked it to run.
647 if (detail::ssa_nrm_eligible(sn)) return solver_ssa_nrm_analyzer(sn, opt, cache);
648 if (serial_detail::serial_can_run(sn))
649 return detail::ssa_serial_avg(sn, opt, cache);
650 return solver_ssa_nrm_analyzer(sn, opt, cache); // raises, by name
651 }
652 if (m == "nrm") {
653 // `solver_ssa_analyzer.m`'s explicit 'nrm' arm: a synchronous call falls back to the
654 // serial engine with a warning rather than refusing, in every codebase.
655 if (!detail::ssa_check_reply(sn, false)) {
656 std::cerr << "[LINE] Warning: SolverSSA: NRM does not support synchronous calls "
657 "(REPLY signals); falling back to the serial method."
658 << std::endl;
659 return detail::ssa_serial_avg(sn, opt, cache);
660 }
662 }
663 if (m == "ssa" || m == "serial") return detail::ssa_serial_avg(sn, opt, cache);
664 if (m == "para" || m == "pana" || m == "parallel") {
665 // The reference's own preference, `solver_ssa_analyzer.m` lines 143-157:
666 // an NRM-eligible model runs once on the NRM rather than R times here.
667 if (detail::ssa_nrm_eligible(sn)) return solver_ssa_nrm_analyzer(sn, opt, cache);
668 return solver_ssa_parallel_analyzer(sn, detail::ssa_parallel_options(opt)).avg;
669 }
670 throw UnsupportedError("SolverSSA: '" + m +
671 "' is not a valid method; the reference offers 'default', 'ssa', "
672 "'serial', 'para', 'parallel' and 'nrm', all of which this port "
673 "implements");
674}
675
676/**
677 * `@@SolverSSA/runAnalyzer` itself: the engine the method selects, then the
678 * result assembly the reference does before it hands the table to a caller.
679 *
680 * Only one step of that assembly belongs here rather than to the caller. A
681 * Place is measured in TOKENS and every engine counts FIRING EVENTS, so
682 * `ssa_pn_token_rates` rewrites the Place rows of TN and RN exactly where
683 * `runAnalyzer.m:227` does; the arrival rates and residence times each consumer
684 * still derives for itself, as before. A model with no Place is untouched, so
685 * this costs an ordinary queueing network one scan of the node list.
686 */
687template <class T>
689 std::vector<SsaCacheRatio>* cache = nullptr) {
690 return detail::ssa_pn_token_rates(sn, solver_ssa_engine(sn, opt, cache));
691}
692
693/**
694 * `CacheMetrics` from the serial engine's cache write-back.
695 *
696 * The shares are matched to their Cache node BY INDEX, not by position: the
697 * base struct walks every Cache the model declares while the engine reports
698 * only the nodes it simulated, so pairing the two off in order would write one
699 * cache's measurement onto another on any model holding more than one.
700 *
701 * `residt` becomes `latency` unchanged, NaN included. The reference warns that
702 * retrieval latency is not implemented and reports NaN in every codebase, so
703 * carrying the NaN IS parity; dropping the field would report "not computed"
704 * about a quantity the engine did state.
705 */
706template <class T>
708 const std::vector<SsaCacheRatio>& cache) {
710 sn, std::vector<T>(), std::vector<T>(), std::vector<T>(), std::vector<T>(), Matrix<T>(),
711 Matrix<T>(), std::vector<T>());
712 for (std::size_t c = 0; c < out.caches.size(); ++c) {
713 for (std::size_t j = 0; j < cache.size(); ++j) {
714 if (cache[j].node != out.caches[c].node) continue;
716 m.hitprob.clear();
717 m.missprob.clear();
718 m.delayedprob.clear();
719 m.latency.clear();
720 for (std::size_t r = 0; r < cache[j].hitprob.size(); ++r)
721 m.hitprob.push_back(num_traits<T>::from_double(cache[j].hitprob[r]));
722 for (std::size_t r = 0; r < cache[j].missprob.size(); ++r)
723 m.missprob.push_back(num_traits<T>::from_double(cache[j].missprob[r]));
724 for (std::size_t r = 0; r < cache[j].delayedprob.size(); ++r)
725 m.delayedprob.push_back(num_traits<T>::from_double(cache[j].delayedprob[r]));
726 for (std::size_t r = 0; r < cache[j].residt.size(); ++r)
727 m.latency.push_back(num_traits<T>::from_double(cache[j].residt[r]));
728 break;
729 }
730 }
731 return out;
732}
733
734/**
735 * The struct with the cache split the SIMULATION MEASURED, visits rebuilt.
736 *
737 * A cache splits the read stream into a hit stream and a miss stream, and that
738 * split IS routing: the visit ratios of everything downstream depend on it. The
739 * base struct carries only what `link()` offered, an even share over the hit and
740 * miss classes, because the split is a RESULT and cannot be known before the
741 * solve. Anything derived from visits after the solve therefore has to be taken
742 * on the rewritten struct, not on the base one -- `sn_get_residt_from_respt`
743 * reported ResidT = RespT/2 for both classes of tut06_cache_lru_zipf (0.1 and
744 * 0.5 against the reference's 0.16475 and 0.17625), which is the even split
745 * showing through, not a residence time.
746 *
747 * The rewrite is the one `da_cacheqn` performs between passes, with the measured
748 * shares in place of the analytical ones: the cache row of `rtnodes` is cleared
749 * and the hit and miss mass sent to every connected successor, after which
750 * `da_recompute_visits_from_rtnodes` rebuilds `visits` and `nodevisits`. A share
751 * the engine left undefined (NaN, a class that does not read this cache) leaves
752 * that row alone rather than zeroing a routing the model does have.
753 */
754template <class T>
756 const std::vector<SsaCacheRatio>& cache) {
757 if (cache.empty()) return base;
759 const std::size_t I = sn.nodes.size(), K = sn.nclasses;
760 if (I == 0 || K == 0 || sn.rtnodes.rows() < I * K) return base;
761 const T zero = num_traits<T>::from_int(0);
762 // The connectivity is read BEFORE any row is rewritten, as in `da_cacheqn`:
763 // a cache's own read self-switch is not a downstream node.
764 std::vector<std::vector<bool> > conn(I, std::vector<bool>(I, false));
765 for (std::size_t a = 0; a < I; ++a)
766 for (std::size_t b = 0; b < I; ++b) {
767 if (a == b) continue;
768 for (std::size_t r = 0; r < K && !conn[a][b]; ++r)
769 for (std::size_t s = 0; s < K && !conn[a][b]; ++s)
770 if (sn.rtnodes(a * K + r, b * K + s) > zero) conn[a][b] = true;
771 }
772 bool rewrote = false;
773 for (std::size_t j = 0; j < cache.size(); ++j) {
774 const std::size_t ind = cache[j].node; // 1-based node index
775 if (ind == 0 || ind > I) continue;
776 typename std::map<std::size_t, qn::CacheParam<T> >::const_iterator ci =
777 sn.nodeparam.find(ind);
778 if (ci == sn.nodeparam.end()) continue;
779 const std::size_t nd = ind - 1;
780 for (std::size_t r = 0; r < K; ++r) {
781 if (ci->second.hitclass.size() <= r || ci->second.missclass.size() <= r) continue;
782 const std::size_t hc = ci->second.hitclass[r], mc = ci->second.missclass[r];
783 if (hc == 0 || mc == 0 || hc > K || mc > K) continue;
784 if (cache[j].hitprob.size() <= r || cache[j].missprob.size() <= r) continue;
785 const double hp = cache[j].hitprob[r], mp = cache[j].missprob[r];
786 if (!(hp == hp) || !(mp == mp)) continue; // NaN: the engine measured none
787 for (std::size_t col = 0; col < I * K; ++col) sn.rtnodes(nd * K + r, col) = zero;
788 for (std::size_t jnd = 0; jnd < I; ++jnd) {
789 if (!conn[nd][jnd]) continue;
790 sn.rtnodes(nd * K + r, jnd * K + (hc - 1)) = num_traits<T>::from_double(hp);
791 sn.rtnodes(nd * K + r, jnd * K + (mc - 1)) = num_traits<T>::from_double(mp);
792 }
793 rewrote = true;
794 }
795 }
796 if (!rewrote) return base;
797 sn.da_recompute_visits_from_rtnodes();
798 return sn;
799}
800
801} // namespace ssa
802} // namespace line
803
804#endif // LINE_SOLVERS_SSA_SSA_DISPATCH_H
What a solver observed about the Cache nodes of a model.
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
The NRM engine: the reaction network built from sn, and the sample path.
const std::vector< SsaCacheRatio > & cache() const
The cache write-back of the last run(), one entry per Cache node.
SsaSolution run()
Run opt.samples firings and return the time-averaged metrics.
The SPN Next-Reaction-Method engine, solver_ssa_nrm_spn of the reference.
The exception types the port throws.
SnPnAvgRates< T > sn_pn_avg_rates(const qn::NetworkStruct< T > &sn, const Matrix< T > &QN, const Matrix< T > &TN, const Matrix< T > &AN, const Matrix< T > &RN)
Port of sn_pn_avg_rates: rewrite the place rows of TN, AN and RN so that they agree with the recovere...
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:426
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:391
ProcessType
Distribution kinds, with the values of MATLAB ProcessType.
Definition lang_types.h:485
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
const char * routing_to_text(RoutingStrategy r)
Definition lang_types.h:404
CacheMetrics< T > cache_metrics_of(const qn::NetworkStruct< T > &sn, const std::vector< T > &hitprob, const std::vector< T > &missprob, const std::vector< T > &delayedprob, const std::vector< T > &latency, const Matrix< T > &hitproblist, const Matrix< T > &itemprob, const std::vector< T > &listcost)
Assemble CacheMetrics from what a cache analyzer returned.
SsaSerialSolution< T > solver_ssa_serial_analyzer(const qn::NetworkStruct< T > &sn, const SsaSerialOptions &opt)
Port of solver_ssa_analyzer_serial.m plus the fork-join wrapper @@SolverSSA/runAnalyzer....
SsaSolution solver_ssa_nrm_analyzer(const qn::NetworkStruct< T > &sn, const SsaOptions &opt, std::vector< SsaCacheRatio > *cache=nullptr)
solver_ssa_analyzer_nrm.m: run the NRM and return the metric table.
SsaSolution solver_ssa_engine(const qn::NetworkStruct< T > &sn, const SsaOptions &opt, std::vector< SsaCacheRatio > *cache)
solver_ssa_analyzer.m: choose the method.
SsaParallelSolution< T > solver_ssa_parallel_analyzer(const qn::NetworkStruct< T > &sn, const SsaParallelOptions &opt)
solver_ssa_analyzer_parallel.m: run R replicas of the serial engine and combine their estimates.
SsaNrmSpaceSolution< T > solver_ssa_nrm_space_analyzer(const qn::NetworkStruct< T > &sn, const SsaNrmSpaceOptions &opt)
Port of the else branch of solver_ssa_analyzer_nrm.m, the one state_space_gen selects: the means as p...
std::vector< std::string > list_valid_methods()
Port of SolverSSA.listValidMethods.
SsaSolution solver_ssa(const qn::NetworkStruct< T > &sn, const SsaOptions &opt, std::vector< SsaCacheRatio > *cache=nullptr)
@@SolverSSA/runAnalyzer itself: the engine the method selects, then the result assembly the reference...
qn::NetworkStruct< T > sn_with_ssa_cache_split(const qn::NetworkStruct< T > &base, const std::vector< SsaCacheRatio > &cache)
The struct with the cache split the SIMULATION MEASURED, visits rebuilt.
solvers::CacheMetrics< T > cache_metrics_of_ssa(const qn::NetworkStruct< T > &sn, const std::vector< SsaCacheRatio > &cache)
CacheMetrics from the serial engine's cache write-back.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Ports of matlab/src/api/sn/sn_pn_firing_rates.m and sn_pn_avg_rates.m.
SolverSSA, the nrm method: a port of solver_ssa_nrm.m and its analyzer solver_ssa_analyzer_nrm....
SolverSSA, the EXPLICIT STATE SPACE variant of the Next Reaction Method: a port of solver_ssa_nrm_spa...
SolverSSA on a STOCHASTIC PETRI NET: the port of solver_ssa_nrm_spn, the sub-engine solver_ssa_nrm....
SolverSSA, the para / parallel method: a port of solver_ssa_analyzer_parallel.m.
SolverSSA, the serial method: a port of solver_ssa_reachability.m, of the run loop of solver_ssa....
Controls, results and the random source of SolverSSA.
A node of the network.
Every Cache node of the model, in node order; empty on a model with none.
std::vector< CacheNodeMetrics< T > > caches
One Cache node's measured behaviour.
std::vector< T > hitprob
(K) TRUE hit fraction, EMPTY = not computed
std::vector< T > delayedprob
(K) delayed-hit fraction, EMPTY off a retrieval system
std::vector< T > latency
(K) expected retrieval latency, EMPTY = not computed
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:69
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:101