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 // A MAP/MMPP2 service at FCFS chains its draws through a memory phase (and, with c > 1,
260 // the landing phase of each draw, `ctmc_mapc`), which the in-service multiset has no
261 // column for: the NRM would restart every draw from the entry law.
262 if (s == SchedStrategy::FCFS)
263 for (std::size_t r = 0; r < sn.nclasses; ++r) {
264 if (sn.disabled[i][r]) continue;
265 const lang::ProcessType pt = sn.service[i][r].type;
266 if (pt != lang::ProcessType::MAP && pt != lang::ProcessType::MMPP2) continue;
267 if (!raise) return false;
268 throw UnsupportedError(
269 "SolverSSA(method='nrm'): class '" + sn.classes[r].name + "' has MAP service "
270 "at FCFS station '" + sn.stations[i].name + "', whose draws are chained "
271 "through a memory phase the NRM does not carry; ask for 'default', which "
272 "falls back to the serial engine");
273 }
274 if (exact) continue;
275 for (std::size_t r = 0; r < sn.nclasses; ++r) {
276 if (sn.disabled[i][r]) continue;
277 const lang::ProcessType pt = sn.service[i][r].type;
280 continue;
281 if (!raise) return false;
282 throw UnsupportedError(
283 "SolverSSA(method='nrm'): class '" + sn.classes[r].name + "' has non-exponential "
284 "service at station '" + sn.stations[i].name + "', whose '" +
285 std::string(lang::sched_to_text(s)) +
286 "' discipline the NRM phase expansion does not cover; ask for 'default', which "
287 "falls back to the serial engine as the reference does");
288 }
289 }
290 return true;
291}
292
293/**
294 * Global (Whittle) dependence, refused because the NRM does not SCALE by it.
295 *
296 * Class- and joint-dependent scaling are served since 2026-09-24: `cdfac` in
297 * `solver_ssa_nrm.h` evaluates eta_i(n) .* beta_{i,r}(n) on the station's
298 * per-class population at firing time, as the reference does, and utilization
299 * is normalized by the declared peaks. The reference's gate (`ssa_nrm_guards.gd`)
300 * excludes only the global handle, and so does this one.
301 */
302template <class T>
303bool ssa_check_cdscaling(const qn::NetworkStruct<T>& sn, bool raise = true) {
304 // The NRM's propensities receive the per-station population slice, not the
305 // whole population matrix a global handle reads.
306 if (sn.gdscaling) {
307 if (!raise) return false;
308 throw UnsupportedError(
309 "SolverSSA(method='nrm'): the model declares a global dependence "
310 "(setGlobalDependence). The NRM builds its propensities from the per-station "
311 "population slice and never sees the whole population matrix the handle reads, so "
312 "the sample path would run at the unscaled rates; ask for method='serial', which "
313 "applies it");
314 }
315 return true;
316}
317
318/**
319 * Finite capacity regions: served since 2026-09-24, under DROP and WAITQ.
320 *
321 * `solver_ssa_nrm.h` ports `fcrPrecompute` / `fcrRefusingRegion` /
322 * `fcrReleaseCascade`: an admission gate on every arrival into a region and a
323 * head-of-line release cascade from a per-region FIFO after every firing. What
324 * stays refused is a BLOCKING region rule (BAS, BBS, RSRD), which no codebase's
325 * NRM carries.
326 */
327template <class T>
328bool ssa_check_regions(const qn::NetworkStruct<T>& sn, bool raise = true) {
329 for (const typename qn::NetworkStruct<T>::Region& rg : sn.regions)
330 for (std::size_t r = 0; r < rg.rule.size(); ++r) {
331 const qn::DropStrategy d = rg.rule[r];
332 if (d != qn::DropStrategy::BAS && d != qn::DropStrategy::BBS &&
333 d != qn::DropStrategy::RSRD)
334 continue;
335 if (!raise) return false;
336 throw UnsupportedError(
337 "SolverSSA(method='nrm'): finite capacity region '" + rg.name +
338 "' declares a blocking rule for class '" + sn.classes[r].name +
339 "'. The NRM region gate implements DROP and WAITQ only, as the reference's does");
340 }
341 return true;
342}
343
344/**
345 * Synchronous calls (REPLY signals), `ssa_nrm_guards.reply`: refused by the NRM in every codebase.
346 *
347 * A calling job leaves its caller and keeps the server there until the reply returns. The
348 * serial engine carries that hold in the reply block of the local row (`after_event`), while
349 * the NRM's reactions see per-class counts only and have no held-server counter, so the caller
350 * would free its server at once. `default` and `parallel` therefore take the serial engine,
351 * and an explicit `nrm` falls back to it with a warning, as the reference does.
352 */
353template <class T>
354bool ssa_check_reply(const qn::NetworkStruct<T>& sn, bool raise = true) {
355 for (std::size_t ind = 0; ind < sn.replyblock.size(); ++ind)
356 for (std::size_t k = 0; k < sn.replyblock[ind].size(); ++k) {
357 if (!sn.replyblock[ind][k]) continue;
358 if (!raise) return false;
359 throw UnsupportedError(
360 "SolverSSA(method='nrm'): class '" + sn.classes[k].name + "' makes a synchronous "
361 "call (REPLY signal) from node '" + sn.nodes[ind].name + "'. The NRM has no "
362 "held-server counter for a pending reply; use method='serial'");
363 }
364 return true;
365}
366
367/**
368 * `isNrmEligible` (`solver_ssa_analyzer.m` lines 365-386): can the NRM run this
369 * model at all?
370 *
371 * It is the SAME tests the refusal above takes, asked with `raise=false`,
372 * so the predicate cannot drift from the message. It is deliberately the C++
373 * NRM's reach and not the reference's: this port's NRM covers less, and a
374 * `default` that consulted MATLAB's wider predicate would send a model to a
375 * refusal the serial engine can actually answer.
376 */
377template <class T>
378bool ssa_nrm_eligible(const qn::NetworkStruct<T>& sn) {
379 // Immediate feedback keeps the fed-back job on the server it just used, which
380 // the serial engine expresses through `after_event`'s `no_promote` arc. The
381 // NRM has no such arc: its reactions see per-class counts, not which job holds
382 // the server, so a self-loop there is a class switch with re-queueing. An
383 // explicit `method='nrm'` still RUNS the model with that approximation,
384 // warning in as many words (`ssa_nrm_supports`, the GATE, deliberately does
385 // not test this); this only keeps `default` and `parallel` from PREFERRING an
386 // approximation over the exact serial engine.
387 if (sn.has_immediate_feedback()) return false;
388 return ssa_check_nodes(sn, false) && ssa_check_scheduling(sn, false) &&
389 ssa_check_routing(sn, false) && ssa_check_phases(sn, false) &&
390 ssa_check_cdscaling(sn, false) && ssa_check_regions(sn, false) &&
391 ssa_check_reply(sn, false) && spn_nrm_supported(sn, false);
392}
393
394/**
395 * The same tests as a SENTENCE, so a report can say why `nrm` is withheld.
396 *
397 * `ssa_nrm_eligible` above answers the dispatch's question (may I PREFER the
398 * NRM?) and nothing answered the gate's (may I OFFER it?), so `ssa.nrm` was
399 * reported runnable on every model and an explicit request then raised. The
400 * catch is the ADAPTER between those two callers and not error suppression: the
401 * checks already carry the exact wording a user should see, and re-deriving it
402 * here is how the message and the predicate drift apart.
403 */
404template <class T>
405std::string ssa_nrm_supports(const qn::NetworkStruct<T>& sn) {
406 try {
407 ssa_check_nodes(sn, true);
408 ssa_check_scheduling(sn, true);
409 ssa_check_routing(sn, true);
410 ssa_check_phases(sn, true);
411 ssa_check_cdscaling(sn, true);
412 ssa_check_regions(sn, true);
413 ssa_check_reply(sn, true);
414 spn_nrm_supported(sn, true);
415 } catch (const UnsupportedError& e) {
416 return e.what();
417 }
418 return "";
419}
420
421/**
422 * The tabulating engine's knobs from the caller's.
423 *
424 * `state_max` keeps its own default because `SsaOptions` carries no cap: a
425 * caller who wants a different one calls `solver_ssa_nrm_space_analyzer`
426 * directly, which is also where the tabulated path and the propensity table
427 * survive rather than being reduced to the metrics.
428 */
429inline SsaNrmSpaceOptions ssa_space_options(const SsaOptions& o) {
430 SsaNrmSpaceOptions s;
431 static_cast<SsaOptions&>(s) = o;
432 return s;
433}
434
435/**
436 * The serial and replicated engines' knobs from the caller's.
437 *
438 * `cutoff`, `state_max`, `nreplicas` and `eventcache` keep their
439 * own defaults for the same reason `state_max` does above: `SsaOptions` has no
440 * field for them, and `nreplicas = 8` is `SolverOptions('SSA')`'s own default
441 * rather than a number chosen here. A caller who wants a different R calls
442 * `solver_ssa_parallel` directly, which is also where the R per-replica tables
443 * and their standard errors survive rather than being reduced to the mean.
444 */
445inline SsaSerialOptions ssa_serial_options(const SsaOptions& o) {
446 SsaSerialOptions s;
447 static_cast<SsaOptions&>(s) = o;
448 return s;
449}
450
451inline SsaParallelOptions ssa_parallel_options(const SsaOptions& o) {
452 SsaParallelOptions p;
453 static_cast<SsaOptions&>(p) = o;
454 return p;
455}
456
457/**
458 * One serial run, keeping the cache write-back the metric table does not carry.
459 *
460 * Written once because `solver_ssa` reaches the serial engine from two arms
461 * (the `default` fallback and the named method), and a caller that got its
462 * cache shares from one arm and not the other would report the offered split
463 * for the same model under a different spelling of the same request.
464 */
465template <class T>
466SsaSolution ssa_serial_avg(const qn::NetworkStruct<T>& sn, const SsaOptions& opt,
467 std::vector<SsaCacheRatio>* cache) {
468 SsaSerialSolution<T> s = solver_ssa_serial_analyzer(sn, ssa_serial_options(opt));
469 if (cache) *cache = s.cache;
470 return s.avg;
471}
472
473/**
474 * `@@SolverSSA/runAnalyzer.m:227`: a Place counts TOKENS, an engine counts
475 * FIRING EVENTS, and the two are the same number only where every arc has
476 * multiplicity one and no immediate transition moves the tokens.
477 *
478 * WHAT THE ENGINES HAND OVER. Every SPN engine accumulates a Place's
479 * throughput as the summed propensity of the TIMED modes consuming from it, so
480 * an arc of weight 2 counts one departure where two tokens left, and an
481 * IMMEDIATE consumer counts none at all -- a firing that takes zero time is not
482 * a timed event and no engine, here or in the reference, ever sees it as one.
483 * A Place drained only by an immediate transition therefore arrives here with
484 * TN = 0, which then makes RespT 0 and leaves the neighbouring Place's ArvR 0
485 * once the caller derives it from this table.
486 *
487 * `sn_pn_avg_rates` is the reference's repair and recovers the missing rates
488 * from the flow-balance equations, which is why it runs at the SOLVER level and
489 * on the ORIGINAL struct: both engines fold a fork-join tagging back before
490 * returning, so the table is already in the caller's own coordinates here, as
491 * it is after `solver_tr_fjtag_analyzer`'s lift in the reference. A model with
492 * no Place, or one whose firing rates cannot be recovered, is returned
493 * untouched.
494 *
495 * AN IS LEFT TO THE CALLER, exactly as `[TN,~,RN] = sn_pn_avg_rates(...)` leaves
496 * it in the reference: `SsaSolution` carries no arrival-rate table, and every
497 * consumer derives one with `sn_get_arvr_from_tput` from the TN repaired here.
498 */
499template <class T>
500SsaSolution ssa_pn_token_rates(const qn::NetworkStruct<T>& sn, const SsaSolution& in) {
501 bool has_place = false;
502 for (std::size_t a = 0; a < sn.nodes.size() && !has_place; ++a)
503 if (sn.nodes[a].nodetype == qn::NodeType::Place) has_place = true;
504 if (!has_place || in.TN.rows() == 0) return in;
505
506 const std::size_t M = in.TN.rows(), K = in.TN.cols();
507 Matrix<T> QN(M, K), TN(M, K), RN(M, K);
508 for (std::size_t i = 0; i < M; ++i)
509 for (std::size_t k = 0; k < K; ++k) {
510 QN(i, k) = num_traits<T>::from_double(in.QN(i, k));
511 TN(i, k) = num_traits<T>::from_double(in.TN(i, k));
512 RN(i, k) = num_traits<T>::from_double(in.RN(i, k));
513 }
514 const api::SnPnAvgRates<T> pn = api::sn_pn_avg_rates(sn, QN, TN, Matrix<T>(), RN);
515
516 SsaSolution out = in;
517 for (std::size_t i = 0; i < M; ++i)
518 for (std::size_t k = 0; k < K; ++k) {
519 out.TN(i, k) = num_traits<T>::to_double(pn.TN(i, k));
520 out.RN(i, k) = num_traits<T>::to_double(pn.RN(i, k));
521 }
522 return out;
523}
524
525} // namespace detail
526
527/**
528 * Port of `SolverSSA.listValidMethods`.
529 *
530 * Six names for three engines, because the reference spells the same engine
531 * more than one way: 'ssa' and 'serial' are the serial trajectory, 'para' and
532 * 'parallel' the replicated one, 'nrm' the next-reaction method, and 'default'
533 * is the ladder `solver_ssa` walks -- the NRM when it can run the model and the
534 * serial engine when it cannot. Every name here is dispatched by `solver_ssa`
535 * below, which refuses anything else by name.
536 */
537inline std::vector<std::string> list_valid_methods() {
538 return {"default", "ssa", "serial", "para", "pana", "parallel", "nrm"};
539}
540
541/**
542 * `solver_ssa_analyzer_nrm.m`: run the NRM and return the metric table.
543 *
544 * The reference's post-processing (`QN(isnan(QN)) = 0` and the rest) is inside
545 * the engine already: it never produces a NaN, because every division is
546 * guarded at the point it is taken.
547 */
548template <class T>
550 std::vector<SsaCacheRatio>* cache = nullptr) {
551 // `if constexpr`, not a run-time test: the engine reaches `dist_to_map`,
552 // whose APH fit static_asserts on transcendental arithmetic, so a Rational
553 // instantiation would fail to COMPILE rather than refuse. The gate has to
554 // keep the body from being instantiated at all.
555 if constexpr (!std::is_same<T, double>::value) {
556 (void)sn;
557 (void)opt;
558 (void)cache;
559 throw UnsupportedError(
560 "solver_ssa_nrm: an SSA sample path is generated from exponential clocks, which are "
561 "logarithms of uniform draws; there is no exact value to compute and a wider float "
562 "carries no information the Monte Carlo error does not swamp. Rerun with --arith "
563 "double");
564 } else {
565 detail::ssa_check_nodes(sn);
566 detail::ssa_check_scheduling(sn);
567 detail::ssa_check_routing(sn);
568 detail::ssa_check_phases(sn);
569 detail::ssa_check_cdscaling(sn);
570 detail::ssa_check_regions(sn);
571 detail::ssa_check_reply(sn);
572 detail::spn_nrm_supported(sn);
573 // `solver_ssa_nrm.m:174`: a net carrying a Transition is a STOCHASTIC
574 // PETRI NET and goes to the SPN builder and run loop, which reads a
575 // marking of tokens rather than a grid of jobs in service. The branch is
576 // taken before the space/plain switch below because the tabulating
577 // engine tabulates the queueing state vector, which an SPN does not
578 // have; the reference branches at the same point and for the same
579 // reason.
580 for (const qn::NodeDef& nd : sn.nodes)
581 if (nd.nodetype == qn::NodeType::Transition) {
583 return spn.run();
584 }
585 // The reference's engine switch, taken AFTER the gate above because the
586 // gate is the NRM's and not one engine's.
587 if (opt.state_space_gen != "none" && opt.state_space_gen != "default")
588 return solver_ssa_nrm_space_analyzer(sn, detail::ssa_space_options(opt)).avg;
589 NrmEngine<T> eng(sn, opt);
590 SsaSolution out = eng.run();
591 if (cache) *cache = eng.cache();
592 return out;
593 }
594}
595
596/**
597 * `solver_ssa_analyzer.m`: choose the method.
598 *
599 * The ladder is the reference's, in its order:
600 *
601 * `default` the NRM when it is eligible, ELSE the serial engine, and only a
602 * model neither engine runs is refused -- by the NRM's message,
603 * which names the missing sub-engine.
604 * `nrm` the NRM alone, reached by naming the estimator: a model it
605 * cannot run is refused rather than silently answered by the
606 * other engine, because a caller who spelled out an estimator
607 * asked for that estimator's variance as well as its mean.
608 * `ssa` the reference's alias for `serial` (line 128), not for the NRM.
609 * `serial` one run of the event-driven engine.
610 * `para` the NRM when eligible (the reference prefers one fast run over
611 * `parallel` R replicated ones, lines 143-157), else the replica mean of
612 * `nreplicas` independent serial runs.
613 *
614 * `default`'s FALLBACK IS THE REFERENCE'S (lines 66-78) and was restored on
615 * 2026-07-31, when the serial engine gained fork-join and the finite capacity
616 * regions. It had been held back while the serial engine covered less than the
617 * NRM gate rejects, on the ground that a second failure further downstream is
618 * less informative than the NRM's own message. That ground is gone for every
619 * construct the serial engine now runs, and where it still holds -- a model
620 * NEITHER engine covers -- the NRM's message is what the caller reads, because
621 * `serial_can_run` is asked BEFORE the fallback is taken rather than after it
622 * has failed.
623 *
624 * `cache`, when given, receives the serial engine's cache write-back -- the
625 * realized hit and miss shares of every Cache node, which are a SOLVER RESULT
626 * and the only thing that tells a node table apart from the 1/2-1/2 `link()`
627 * offers. It is an out-parameter rather than a field of `SsaSolution` because
628 * that struct is the metric table the three engines share, and only two of them
629 * have a cache to report, the serial engine and (since 2026-09-24) the NRM; the
630 * parallel engine averages replicas that carry none.
631 */
632template <class T>
634 std::vector<SsaCacheRatio>* cache) {
635 // A fed-back job keeps its server, which the reference encodes in
636 // `State.afterEvent`'s immfeed self-loop, and only the SERIAL engine has that
637 // arc -- here as in the reference. `solver_ssa_analyzer_nrm.m:29` warns in as
638 // many words that the NRM "does not model immediate feedback (immfeed);
639 // self-loops are treated as class-switching with re-queueing" and then runs,
640 // so the name stays offerable and only the approximation is announced.
641 // `ssa_nrm_eligible` excludes immediate feedback, so `default` and `parallel`
642 // reach the exact serial engine and only an EXPLICIT `nrm` warns.
643 if (sn.has_immediate_feedback()) {
644 const std::string& mm = opt.method;
645 const bool runs_nrm =
646 mm == "nrm" || ((mm == "default" || mm == "para" || mm == "pana" || mm == "parallel") &&
647 detail::ssa_nrm_eligible(sn));
648 if (runs_nrm)
649 std::cerr << "[LINE] Warning: SolverSSA(method=nrm) does not model immediate feedback "
650 "(immfeed); self-loops are treated as class-switching with re-queueing. "
651 "Use method='serial' for immediate feedback."
652 << std::endl;
653 }
654 const std::string& m = opt.method;
655 if (m == "default") {
656 // The reference's ladder: the NRM when it can run the model, the serial
657 // engine when it cannot, and the NRM's own refusal -- which names the
658 // sub-engine that is missing -- when neither can. Asking
659 // `serial_can_run` first is what keeps that message: falling through to
660 // the serial engine and letting IT fail would report whichever guard it
661 // hit, about a model the caller never asked it to run.
662 if (detail::ssa_nrm_eligible(sn)) return solver_ssa_nrm_analyzer(sn, opt, cache);
663 if (serial_detail::serial_can_run(sn))
664 return detail::ssa_serial_avg(sn, opt, cache);
665 return solver_ssa_nrm_analyzer(sn, opt, cache); // raises, by name
666 }
667 if (m == "nrm") {
668 // `solver_ssa_analyzer.m`'s explicit 'nrm' arm: a synchronous call falls back to the
669 // serial engine with a warning rather than refusing, in every codebase.
670 if (!detail::ssa_check_reply(sn, false)) {
671 std::cerr << "[LINE] Warning: SolverSSA: NRM does not support synchronous calls "
672 "(REPLY signals); falling back to the serial method."
673 << std::endl;
674 return detail::ssa_serial_avg(sn, opt, cache);
675 }
677 }
678 if (m == "ssa" || m == "serial") return detail::ssa_serial_avg(sn, opt, cache);
679 if (m == "para" || m == "pana" || m == "parallel") {
680 // The reference's own preference, `solver_ssa_analyzer.m` lines 143-157:
681 // an NRM-eligible model runs once on the NRM rather than R times here.
682 if (detail::ssa_nrm_eligible(sn)) return solver_ssa_nrm_analyzer(sn, opt, cache);
683 return solver_ssa_parallel_analyzer(sn, detail::ssa_parallel_options(opt)).avg;
684 }
685 throw UnsupportedError("SolverSSA: '" + m +
686 "' is not a valid method; the reference offers 'default', 'ssa', "
687 "'serial', 'para', 'parallel' and 'nrm', all of which this port "
688 "implements");
689}
690
691/**
692 * `@@SolverSSA/runAnalyzer` itself: the engine the method selects, then the
693 * result assembly the reference does before it hands the table to a caller.
694 *
695 * Only one step of that assembly belongs here rather than to the caller. A
696 * Place is measured in TOKENS and every engine counts FIRING EVENTS, so
697 * `ssa_pn_token_rates` rewrites the Place rows of TN and RN exactly where
698 * `runAnalyzer.m:227` does; the arrival rates and residence times each consumer
699 * still derives for itself, as before. A model with no Place is untouched, so
700 * this costs an ordinary queueing network one scan of the node list.
701 */
702template <class T>
704 std::vector<SsaCacheRatio>* cache = nullptr) {
705 return detail::ssa_pn_token_rates(sn, solver_ssa_engine(sn, opt, cache));
706}
707
708/**
709 * `CacheMetrics` from the serial engine's cache write-back.
710 *
711 * The shares are matched to their Cache node BY INDEX, not by position: the
712 * base struct walks every Cache the model declares while the engine reports
713 * only the nodes it simulated, so pairing the two off in order would write one
714 * cache's measurement onto another on any model holding more than one.
715 *
716 * `residt` becomes `latency` unchanged, NaN included. The reference warns that
717 * retrieval latency is not implemented and reports NaN in every codebase, so
718 * carrying the NaN IS parity; dropping the field would report "not computed"
719 * about a quantity the engine did state.
720 */
721template <class T>
723 const std::vector<SsaCacheRatio>& cache) {
725 sn, std::vector<T>(), std::vector<T>(), std::vector<T>(), std::vector<T>(), Matrix<T>(),
726 Matrix<T>(), std::vector<T>());
727 for (std::size_t c = 0; c < out.caches.size(); ++c) {
728 for (std::size_t j = 0; j < cache.size(); ++j) {
729 if (cache[j].node != out.caches[c].node) continue;
731 m.hitprob.clear();
732 m.missprob.clear();
733 m.delayedprob.clear();
734 m.latency.clear();
735 for (std::size_t r = 0; r < cache[j].hitprob.size(); ++r)
736 m.hitprob.push_back(num_traits<T>::from_double(cache[j].hitprob[r]));
737 for (std::size_t r = 0; r < cache[j].missprob.size(); ++r)
738 m.missprob.push_back(num_traits<T>::from_double(cache[j].missprob[r]));
739 for (std::size_t r = 0; r < cache[j].delayedprob.size(); ++r)
740 m.delayedprob.push_back(num_traits<T>::from_double(cache[j].delayedprob[r]));
741 for (std::size_t r = 0; r < cache[j].residt.size(); ++r)
742 m.latency.push_back(num_traits<T>::from_double(cache[j].residt[r]));
743 break;
744 }
745 }
746 return out;
747}
748
749/**
750 * The struct with the cache split the SIMULATION MEASURED, visits rebuilt.
751 *
752 * A cache splits the read stream into a hit stream and a miss stream, and that
753 * split IS routing: the visit ratios of everything downstream depend on it. The
754 * base struct carries only what `link()` offered, an even share over the hit and
755 * miss classes, because the split is a RESULT and cannot be known before the
756 * solve. Anything derived from visits after the solve therefore has to be taken
757 * on the rewritten struct, not on the base one -- `sn_get_residt_from_respt`
758 * reported ResidT = RespT/2 for both classes of tut06_cache_lru_zipf (0.1 and
759 * 0.5 against the reference's 0.16475 and 0.17625), which is the even split
760 * showing through, not a residence time.
761 *
762 * The rewrite is the one `da_cacheqn` performs between passes, with the measured
763 * shares in place of the analytical ones: the cache row of `rtnodes` is cleared
764 * and the hit and miss mass sent to every connected successor, after which
765 * `da_recompute_visits_from_rtnodes` rebuilds `visits` and `nodevisits`. A share
766 * the engine left undefined (NaN, a class that does not read this cache) leaves
767 * that row alone rather than zeroing a routing the model does have.
768 */
769template <class T>
771 const std::vector<SsaCacheRatio>& cache) {
772 if (cache.empty()) return base;
774 const std::size_t I = sn.nodes.size(), K = sn.nclasses;
775 if (I == 0 || K == 0 || sn.rtnodes.rows() < I * K) return base;
776 const T zero = num_traits<T>::from_int(0);
777 // The connectivity is read BEFORE any row is rewritten, as in `da_cacheqn`:
778 // a cache's own read self-switch is not a downstream node.
779 std::vector<std::vector<bool> > conn(I, std::vector<bool>(I, false));
780 for (std::size_t a = 0; a < I; ++a)
781 for (std::size_t b = 0; b < I; ++b) {
782 if (a == b) continue;
783 for (std::size_t r = 0; r < K && !conn[a][b]; ++r)
784 for (std::size_t s = 0; s < K && !conn[a][b]; ++s)
785 if (sn.rtnodes(a * K + r, b * K + s) > zero) conn[a][b] = true;
786 }
787 bool rewrote = false;
788 for (std::size_t j = 0; j < cache.size(); ++j) {
789 const std::size_t ind = cache[j].node; // 1-based node index
790 if (ind == 0 || ind > I) continue;
791 typename std::map<std::size_t, qn::CacheParam<T> >::const_iterator ci =
792 sn.nodeparam.find(ind);
793 if (ci == sn.nodeparam.end()) continue;
794 const std::size_t nd = ind - 1;
795 for (std::size_t r = 0; r < K; ++r) {
796 if (ci->second.hitclass.size() <= r || ci->second.missclass.size() <= r) continue;
797 const std::size_t hc = ci->second.hitclass[r], mc = ci->second.missclass[r];
798 if (hc == 0 || mc == 0 || hc > K || mc > K) continue;
799 if (cache[j].hitprob.size() <= r || cache[j].missprob.size() <= r) continue;
800 const double hp = cache[j].hitprob[r], mp = cache[j].missprob[r];
801 if (!(hp == hp) || !(mp == mp)) continue; // NaN: the engine measured none
802 for (std::size_t col = 0; col < I * K; ++col) sn.rtnodes(nd * K + r, col) = zero;
803 for (std::size_t jnd = 0; jnd < I; ++jnd) {
804 if (!conn[nd][jnd]) continue;
805 sn.rtnodes(nd * K + r, jnd * K + (hc - 1)) = num_traits<T>::from_double(hp);
806 sn.rtnodes(nd * K + r, jnd * K + (mc - 1)) = num_traits<T>::from_double(mp);
807 }
808 rewrote = true;
809 }
810 }
811 if (!rewrote) return base;
812 sn.da_recompute_visits_from_rtnodes();
813 return sn;
814}
815
816} // namespace ssa
817} // namespace line
818
819#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