LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
line_cli.cpp
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 *
5 * line-cli: multiprecision C++ front end, flag-compatible with
6 * jar/src/main/java/jline/cli/LineCLI.java.
7 *
8 * The flag surface is the Java one plus --arith and --list-api. Anything not
9 * yet ported is refused explicitly, naming what is missing; nothing is
10 * silently approximated or answered from a partial implementation.
11 *
12 * ONE BINARY FOR BOTH MODEL KINDS, as the Java reference is: a Network model
13 * (-i json) reaches solve_model_dispatch and a layered one (-i lqnx|xml)
14 * reaches solve_lqn_dispatch. They were two executables until the LQN path was
15 * folded in here; the split had no user-visible justification, since the Java
16 * CLI has always taken both from one entry point.
17 */
18#include <algorithm>
19#include <chrono>
20#include <cctype>
21#include <cmath>
22#include <cstdio>
23#include <cstdlib>
24#include <cstring>
25#include <fstream>
26#include <iostream>
27#include <iterator>
28#include <limits>
29#include <sstream>
30#include <string>
31#include <vector>
32
40#include "line/io/jmt_writer.h"
41#include "line/io/jsim_reader.h"
43#include "line/io/pnml.h"
49#include "line/num/number.h"
51#include "line/reg/registry.h"
93#include "line/cli/cli_run.h"
94#include "line/io/marshal.h"
95#include "line/util/error.h"
96#include "line/util/websocket.h"
97
98namespace {
99
100// THE PRODUCT VERSION, and it must track the other six sites. `-V`, the help banner
101// and `--install` all print it, so a stale value here makes the tree report two
102// different versions depending on which entry point is asked. It sat at 0.1.0
103// while everything else said 3.0.8, because `release/post-release.sh` bumped a
104// list this file was not on; it is on that list now, and on its straggler scan.
105const char* kVersion = "3.0.8";
106
107/**
108 * Whether `-a avg` prints its table as JSON, i.e. `-o json`.
109 *
110 * A FILE-SCOPE FLAG, and the one place in this port that has one. The library
111 * under `include/line/` keeps no global mutable state, deliberately, so a
112 * pybind11 or MEX host can call it reentrantly; `line_cli.cpp` is a `main()`
113 * translation unit and the output format is a property of ONE process
114 * invocation, decided before any solve begins and never changed after. The
115 * alternative is threading a bool through `solve_model_{mva,nc,mam,ba}`,
116 * `solve_ctmc_avg`, `solve_fluid_avg` and `solve_ssa_avg`, three of which do
117 * not take the `Knobs` struct at all, to carry a value none of them varies.
118 */
119bool g_json_output = false;
120
121/**
122 * Where a JSON document goes when a host is collecting them, and nothing when
123 * no host is.
124 *
125 * IT HOLDS STRINGS AND NOT PARSED VALUES, which is the whole reason the sink
126 * sits at the emission sites rather than around them. The eleven sites use
127 * three different indents (`dump()`, `dump(1)`, `dump(2)`) and print their
128 * numbers at full precision precisely so a cross-codebase diff is not capped;
129 * parsing here and re-dumping in the host would silently reformat both, and the
130 * reformatting would stay invisible until someone diffed a golden. Storing the
131 * bytes the CLI printed is also what makes the byte-identity test possible: a
132 * host's document must appear VERBATIM in the binary's stdout.
133 */
134std::vector<std::string>* g_json_sink = nullptr;
135
136/** A host asked, through the C ABI, for the run to stop. */
137struct InterruptRequested : line::Error {
138 explicit InterruptRequested(const std::string& what) : line::Error(what) {}
139};
140
141int (*g_interrupt_cb)(void*) = nullptr;
142void* g_interrupt_user = nullptr;
143
144/**
145 * Poll the host's interrupt, if one was installed.
146 *
147 * Called where the CLI is between two units of work and holds nothing that a
148 * throw would leak: after a document is emitted, and between the analyses of a
149 * comma-separated `-a` list. It deliberately does NOT reach inside a solve.
150 */
151void poll_interrupt() {
152 if (g_interrupt_cb != nullptr && g_interrupt_cb(g_interrupt_user) != 0)
153 throw InterruptRequested("the run was interrupted by the host");
154}
155
156/**
157 * Print one JSON document, and record it when a host is collecting.
158 *
159 * The printf is unchanged and unconditional, so `line-cli` prints exactly what
160 * it printed before this sink existed. That is the acceptance test for this
161 * whole change: with `g_json_sink == nullptr` the binary must be byte-identical.
162 */
163void emit_document(const std::string& text) {
164 if (g_json_sink != nullptr) g_json_sink->push_back(text);
165 std::printf("%s\n", text.c_str());
166 poll_interrupt();
167}
168
169/**
170 * Replace every non-finite number in a document with the spelling this wire
171 * gives one.
172 *
173 * JSON HAS NO INFINITE LITERAL, and nlohmann serialises any non-finite double
174 * as `null`. That is correct for a NaN -- `null` is how this wire says "no
175 * value", and it is what the readable table's `nan` means -- and wrong for an
176 * infinity, which is an ANSWER rather than the absence of one. A queue at or
177 * past its stability boundary has an infinite mean queue length and response
178 * time; a host that reads that `null` as "not computed", or as zero, inverts
179 * the conclusion it draws from it.
180 *
181 * The spelling is the JAR's. `LineCLI.appendDoubleArray` states it and says
182 * "line-cli emits the identical form", which until this function it did not:
183 * an infinity rides as the STRING "Infinity" / "-Infinity". Every other reader
184 * in the project already accepts that spelling -- MATLAB `linemodel_load`,
185 * Python `linemodel_io`, the R `from_wire_num` -- so this only stops line-cli
186 * from being the one writer that disagreed with all of them.
187 *
188 * It walks the document immediately before the dump rather than at each push
189 * site because there are dozens of the latter, spread across every analysis,
190 * and a rule applied at some of them is a rule that holds for some answers.
191 */
192void finitize_document(line::reg::Json& j) {
193 if (j.is_object() || j.is_array()) {
194 for (line::reg::Json& child : j) finitize_document(child);
195 return;
196 }
197 if (!j.is_number_float()) return;
198 const double v = j.get<double>();
199 if (std::isinf(v)) j = (v > 0.0 ? "Infinity" : "-Infinity");
200}
201
202/**
203 * `dump()` with the rule above applied. Takes its document BY VALUE: emission
204 * is not the end of a document's life here (the `-a` comma list emits one per
205 * analysis and several arms read their own back), so rewriting the caller's
206 * copy would change what it sees after printing.
207 */
208std::string dump_document(line::reg::Json j, int indent = -1) {
209 finitize_document(j);
210 return indent < 0 ? j.dump() : j.dump(indent);
211}
212
213/**
214 * Solve a Network model.json with SolverMVA and print its AvgTable.
215 *
216 * The columns and their order -- Station, JobClass, then QLen, Util, RespT,
217 * ResidT, ArvR, Tput -- are the ones `parity/compare_parity.py` parses, so the
218 * printed table is directly diffable against the MATLAB and Python rows. A
219 * (station, class) pair with no presence at all (every metric zero) is dropped,
220 * as `getAvgTable` drops its unvisited rows.
221 */
222/**
223 * The solver knobs the CLI exposes, gathered so the dispatcher can check them
224 * against the solver actually chosen.
225 *
226 * A sentinel means "not given" rather than a default, because the distinction
227 * is what lets an option be REFUSED for a solver that has no such setting
228 * instead of being accepted and dropped. That silent acceptance is the defect
229 * this struct exists to prevent: `--samples 1e6` was previously taken without
230 * complaint and the run still used 10000.
231 */
232struct Knobs {
233 std::string method;
234 double tol = -1.0; // < 0 = not given
235 double iter_tol = -1.0;
236 int iter_max = -1;
237 // --max-states, `options.config.maxStates`: the truncation level of an OPEN
238 // agent's queue-length dimension in SolverAG. < 0 = not given, so AgOptions
239 // keeps its own 100. It is a TRUNCATION and therefore part of the ANSWER,
240 // not a budget: a run truncated at 100 that the caller asked to truncate at
241 // 500 is a different number reported as theirs, which is why it travels
242 // rather than being dropped the way the execution backends are.
243 long long max_states = -1;
244 // --multiserver, the AMVA rule that decides WHICH algorithm serves a
245 // multiserver model. Empty = not given, so the solver keeps its default:
246 // a rule this CLI silently dropped made every delegated solve answer under
247 // 'default' while reporting the caller's choice.
248 std::string multiserver;
249 // --fork-join, `options.config.fork_join`: WHICH fork-join arm the shared
250 // fixed point takes on a model with a Fork. Empty = not given, so the solver
251 // keeps 'default' (the MMT transform); 'ht' is Heidelberger-Trivedi, which
252 // is a different answer to the same model rather than a faster one.
253 std::string fork_join;
254 // The solver console has NO knob of its own: it IS VerboseLevel::DEBUG,
255 // so `-v debug` is what asks for the running progress log.
256 std::size_t samples = 0; // 0 = not given
257 unsigned long seed = 0;
258 double cutoff = -1.0; // < 0 = not given
259 // --cutoff AS A MATRIX, `r1c1,r1c2;r2c1,r2c2`: the reference's
260 // `options.cutoff` is a (station x class) table wherever a model needs a
261 // different truncation per station, and reading only the scalar form left
262 // `atof` silently taking the first number, i.e. truncating every station at
263 // the first station's first class. Empty = not given.
264 std::vector<std::vector<std::size_t>> cutoff_mat;
265 /** True when `--cutoff` was given in EITHER spelling. */
266 bool has_cutoff() const { return cutoff >= 0.0 || !cutoff_mat.empty(); }
267 // --force, `options.force`: downgrade SolverCTMC's memory pre-gate from a
268 // refusal to a warning. The gate exists because the alternative to refusing
269 // is the OOM killer, so this is opt-in and never a default.
270 bool force = false;
271 // --fj-accuracy / --fj-tmode, `options.config.fj_accuracy` and
272 // `options.config.fj_tmode` of solver_mam_fj.m. The first is the FJ_codes
273 // truncation C of the queue-length difference between the two branches and
274 // is the accuracy knob of that approximation; the second picks the route to
275 // the T matrix. Kept apart from --tol/--iter_max for the same reason
276 // --mdd-tol is: C is a STATE-SPACE bound, not a convergence threshold.
277 int fj_accuracy = 0; // 0 = not given
278 std::string fj_tmode; // empty = not given
279 // --timescale, `options.config.timescale` of sn_is_discrete_time.m. "auto"
280 // lets the distributions decide whether the model is slotted; "discrete"
281 // and "continuous" force the reading, the first raising when the model
282 // mixes lattice and non-lattice laws rather than solving the wrong time
283 // scale. The slot itself comes from the SHARED `--slotlength` below, the
284 // same one SolverNC's discrete product form reads.
285 std::string timescale; // empty = not given
286 // --mdd-tol / --mdd-maxiter, the level iteration of `-s ctmc --method mdd`.
287 // They are DELIBERATELY not --tol / --iter_tol: that iteration is an INNER
288 // numerical solve whose fixed point is verified against the model's
289 // population invariant at 1e-6, so an AMVA-sized tolerance converges short
290 // of it and trips the guard. The reference keeps them apart for the same
291 // reason (options.config.mdd_tol, not options.iter_tol).
292 double mdd_tol = -1.0; // < 0 = not given
293 int mdd_maxiter = 0; // 0 = not given
294 // --level, the hierarchy level of the pbh/bjbh/cbh/sib families.
295 // 0 = not given, so BaOptions keeps its 2.
296 int level = 0;
297 // --busyperiod / --busyperiod-subnet, the orders and the subnetwork of
298 // `-a busyperiod`. The flag names are `ldes_cli`'s, so ONE spelling drives
299 // the transform (solver_nc_busyp) and the simulation. The subnetwork has no
300 // default -- a busy period is defined for a named set of stations and
301 // choosing one here would answer about a subnetwork the caller never
302 // named -- while the orders default to the ordinary busy period, 1.
303 std::vector<std::size_t> busy_orders;
304 std::vector<std::size_t> busy_subnet;
305 // --qrf-params / --qrf-alpha, the blocking parameterisation and the
306 // load-dependent scaling of the QRF arms of SolverBA. Both are JSON, given
307 // inline or as a path; empty = not given.
308 std::string qrf_params;
309 std::string qrf_alpha;
310 // --tspan, the horizon the transient CTMC analyses integrate over. There is
311 // no default: pi(t) on an unstated horizon is not a quantity, and picking
312 // one here would answer a question the caller did not ask.
313 double t0 = 0.0, t1 = -1.0; // t1 < 0 = not given
314 std::size_t node = 0; // --node, 1-based; 0 = not given
315 // --class and --marg-states, the second and third arguments of
316 // `@@SolverMVA/getProbMarg`. The class is 1-based and 0 = not given, i.e.
317 // every class; the state list is the reference's `state_m` and empty = not
318 // given, i.e. the default range each of the three laws picks for itself.
319 // They are NOT folded into --node: a marginal is indexed by a PAIR, and one
320 // flag carrying both would make "station 2" and "class 2" the same token.
321 std::size_t jobclass = 0; // --class, 1-based; 0 = not given
322 std::vector<long> marg_states; // --marg-states; empty = not given
323 // --warmupfrac, `options.config.warmupfrac`: the leading fraction of an SSA
324 // path discarded before the means are taken. The JAR CLI has carried it
325 // since the SSA branch existed and this port did not, so a delegated solve
326 // asking for a warmup discard silently kept the whole transient.
327 // < 0 = not given, so the engine keeps its own 0.
328 double warmupfrac = -1.0;
329 // --pstar, `options.config.pstar`: the exponent of the fluid p-norm
330 // smoothing (Ruuskanen et al., PEVA 151 (2021), eq. (26)). It selects the
331 // DRIFT the matrix method integrates, so a solve that never receives it
332 // returns the unsmoothed mean-field fixed point under the caller's choice.
333 // < 0 = not given.
334 double pstar = -1.0;
335 // --notation, which form of the exported ODE document is wanted; empty = not
336 // given, and only `-a odes` has one.
337 std::string notation;
338 // --cdf-algorithm, `options.config.algorithm` of `@@SolverNC/getCdfRespT`:
339 // 'exact' is the pfqn_stdf sojourn-time inversion, 'rd' the pfqn_stdf_heur
340 // reduction. Empty = not given, so the solver keeps the reference's 'exact'.
341 // It is NOT --method: the ladder that computes the constants and the
342 // algorithm that inverts the sojourn law are separate choices, and folding
343 // them would make one name silently select the other.
344 std::string cdf_algorithm;
345 // -s ctmc -a firstpasst: the two state sets of `getCdfFirstPassT(A, B)`.
346 // Each is either a 1-based index list into the state space ("3,5") or
347 // semicolon-separated state rows ("0,2;1,1"), resolved against the space
348 // the engine enumerates -- rows travel across the boundary because the two
349 // enumerations need not order (or even purge) states identically.
350 std::string passage_from;
351 std::string passage_into;
352 // "expm" (default) or "lt", `options.config.passage_method` of the reference
353 std::string passage_method;
354 // -s ctmc -a firstpasstmom: the highest moment order, `nmax` of
355 // `getFirstPassTMoments(A, B, nmax)`. 0 means the reference's default of 3.
356 std::size_t passage_orders = 0;
357 // --perm-engine, the permanent estimator of `@@SolverNC/getProbSysMarg.m`:
358 // 'exact' is Ryser's expansion with column multiplicities, and 'spm',
359 // 'bethe', 'heur', 'huberlaw', 'adapart' are the five approximations. It is
360 // NOT --method: the ladder that computes the normalizing constant and the
361 // estimator that evaluates the permanent are separate choices. The five
362 // approximations REFUSE a demand matrix with a structural zero rather than
363 // flooring it, since they need full support. 'spm' is the only one that does
364 // not expand the matrix to order sum(N), so it is the one whose cost does
365 // not grow with the population and whose error falls as it grows.
366 std::string method_perm = "exact";
367 // --symbolic, `options.config.symbolic` of `@@SolverFLD/getJacobian`: auto
368 // to search for a line-sage-rest backend, a URL, an image name, or none to
369 // stay with the locally differentiated Jacobian. Empty = not given, i.e.
370 // auto. --equilibria is the reference's fourth output, which is REQUESTED
371 // and not implied: solving f(x) = 0 needs the backend, so implying it would
372 // turn a Jacobian this port answers on its own into one that fails without
373 // a container.
374 std::string symbolic;
375 bool equilibria = false;
376 // ---- the layered path's own knobs, on the same not-given discipline ----
377 // They are refused on the Network path rather than dropped, exactly as the
378 // ones above are refused for a solver that has no such setting.
379 bool no_interlocking = false; // --no-interlocking was passed
380 // --interlock-method / --interlock-maxpaths / --interlock-refpath-scope, the
381 // config.interlock_* of SolverLN. Empty / negative = not given.
382 std::string interlock_method;
383 double interlock_maxpaths = -1.0;
384 std::string interlock_refpath_scope;
385 bool interlock_knobs_given() const {
386 return !interlock_method.empty() || interlock_maxpaths >= 0.0 ||
387 !interlock_refpath_scope.empty();
388 }
389 int repeat = 0; // --repeat; 0 = not given, i.e. one run
390 std::string layer_solver; // --layer-solver; empty = not given
391 /**
392 * `--stage-solver`, which solver runs each stage of an ENVIRONMENT.
393 *
394 * IT IS NOT `--layer-solver`, and folding the two would be wrong: a LAYER of
395 * an LQN is solved in STEADY STATE and a STAGE of a random environment is
396 * solved TRANSIENTLY, so their admissible solver sets are different sets for
397 * different reasons. Empty = not given, i.e. the coupling's own default
398 * (fluid for the mean-field one, ctmc for the state-vector one).
399 */
400 std::string stage_solver;
401 /*
402 * --map-env / --map-env-method / --map-env-maxstages: the random-environment
403 * FALLBACK for a model whose only unsupported feature is a non-renewal
404 * process. Unlike `--stage-solver` these are not `-s env` knobs: they apply
405 * to whichever solver was asked for, because the fallback is decided above
406 * the runner, in the `getAvg` funnel that `run_avg_engine` plays here.
407 * Empty / 0 = not given.
408 */
409 std::string map_env;
410 std::string map_env_method;
411 std::size_t map_env_maxstages = 0;
412 // --ln-transient / --ln-transient-channels, the coupling of the layered
413 // transient and which inter-layer channels it injects. Empty = not given.
414 std::string ln_transient;
415 std::string ln_transient_channels;
416 // --sens-method / --sens-scheme / --sens-step, the name-value contract of
417 // getSensitivityTable. They are NOT --method: --method names the LN update
418 // (default / moment3 / mw.*), and the branch that differentiates it is a
419 // separate choice. Empty / <= 0 = not given.
420 std::string sens_method;
421 std::string sens_scheme;
422 double sens_step = -1.0;
423 // --uq-solver, the engine SolverUQ runs at each design point. Empty is not
424 // a default here but a missing argument: UQ computes nothing itself, so
425 // there is no engine to fall back to, and `-s uq` without it is refused.
426 std::string uq_solver;
427 // --tran-points, the resolution of the uniform transient grid the ENV
428 // mean-field coupling sums its exit metrics over. It is that coupling's
429 // accuracy knob, not a cosmetic one: the sum is a Riemann-Stieltjes
430 // quadrature against the holding-time CDF, so the answer moves with the
431 // grid. 0 = not given, i.e. EnvOptions' own default.
432 std::size_t tran_points = 0;
433 // ---- the LQNS wrapper's own knobs -------------------------------------
434 // `options.keep` and `options.verbose` of the reference wrapper, plus the
435 // two that pick a REMOTE lqns. They apply to `-s lqns` alone and are
436 // refused elsewhere: nothing else in this CLI runs a child process whose
437 // working directory a caller might want to inspect.
438 bool keep = false;
439 bool verbose = false;
440 bool remote = false;
441 std::string remote_url; // empty = not given, i.e. the wrapper's default
442 int timeout_seconds = 0; // 0 = not given, i.e. no deadline
443 // ---- the simulator's own knobs, all `--ldes-*` -------------------------
444 // They are PREFIXED rather than folded into the shared names because they
445 // are an external engine's settings and not this port's: --ldes-tranfilter
446 // is the warmup filter of a simulation run and has nothing to do with
447 // --tol, and an unprefixed --warmupfrac would read as a knob every solver
448 // has. Same not-given discipline as the rest -- empty, <= 0 or false means
449 // not given -- so the engine keeps its own default and the command line
450 // stays minimal, which is what an older AOT native binary can still parse.
451 std::string ldes_tranfilter; // mser5 | fixed | none
452 double ldes_warmupfrac = -1.0;
453 std::string ldes_cimethod; // obm | bm | spectral | none
454 bool ldes_cnvgon = false;
455 double ldes_cnvgtol = -1.0;
456 bool ldes_slotted = false;
457 double ldes_slotlength = -1.0;
458 /**
459 * `--slotted` / `--slotlength`: the discrete time scale for the
460 * ANALYTICAL solvers, distinct from the `--ldes-*` pair above, which
461 * configures the simulator. SolverNC reads it and routes to the
462 * discrete-time product form.
463 */
464 bool slotted = false;
465 double slotlength = -1.0;
466 int ldes_replications = 0;
467 int ldes_numthreads = 0;
468 double ldes_maxtime = -1.0;
469 std::vector<double> ldes_initsol; // station-major warm-start placement
470 std::string ldes_rest_url;
471 /**
472 * `--jmt-replications`, SolverJMT's `options.config.replications`: the JSIM runs the transient
473 * ensemble of -a tran / -a tranprob averages over. 0 keeps the engine default of 10.
474 */
475 int jmt_replications = 0;
476 // ---- the JAR CLI's own five, ported so `line-cli` answers every question
477 // `jline.cli.LineCLI` answers ------------------------------------------
478 /**
479 * `--state`, the state vector `-a prob` asks about.
480 *
481 * WITHOUT IT THE QUERY IS ABOUT THE MODEL'S DEFAULT INITIAL STATE, which is
482 * what this CLI reported before and remains the default. The JAR takes an
483 * explicit one because `getProb(node, state)` is a different question from
484 * `getProb(node)`: the second names a state the model already holds, the
485 * first names any state of the node's own space, and a caller sweeping a
486 * marginal law needs the first. Empty = not given.
487 */
488 std::vector<long> state;
489 /**
490 * `--events`, the length of a sampled trajectory, `options.samples` of
491 * `@@SolverSSA/sample`. It is NOT `--samples`: the JAR keeps them apart
492 * because `--samples` is a simulation run length or a Monte Carlo draw
493 * count and reaches a solver's options, while this is the number of EVENTS
494 * one `sample` call walks. Folding them would make `-s ssa -a avg --events`
495 * silently lengthen the run. 0 = not given, i.e. the reference's 1000.
496 */
497 std::size_t events = 0;
498 /**
499 * `--timestep`, the fixed output step of a transient analysis. Without it
500 * the grid is whatever the integrator chose, which is the reference's
501 * adaptive default; with it the trajectory is resampled onto a uniform
502 * lattice of that step, which is what a caller diffing two transients needs.
503 * <= 0 = not given.
504 */
505 double timestep = -1.0;
506 /**
507 * `--transient-method`, `options.config.transient_method` of
508 * `solver_ctmc_transient_analyzer.m`: "ode" integrates the forward
509 * equation, "fau" marches fast adaptive uniformization over the output
510 * grid. Empty = not given, so the solver keeps the reference's "ode".
511 *
512 * It is NOT `--method`: the state-space path and the way the forward
513 * equation is advanced on it are separate choices, and the reference keeps
514 * this one out of its valid-method list because it changes no stationary
515 * answer.
516 */
517 std::string transient_method;
518 /**
519 * `--fau-epsilon` and `--fau-delta`, the two tolerances of the "fau"
520 * transient: the total probability mass the grid may discard, and the
521 * occupancy below which a state is dropped from the support. <= 0 = not
522 * given, i.e. the reference's 1e-6 and 1e-12.
523 */
524 double fau_epsilon = -1.0;
525 double fau_delta = -1.0;
526 /**
527 * `--rate-sched`, `options.config.rate_sched` of
528 * `solver_ctmc_transient_analyzer.m`: a JSON array (inline, or the path of a
529 * file holding it) of {station, class, tgrid, rates[, nominal]} objects,
530 * with station and class given by name or 1-based index. Empty = not given.
531 */
532 std::string rate_sched;
533 /// `--ctmc-tv-ngrid`, `options.config.ctmc_tv_ngrid`; 0 = not given (100).
534 std::size_t ctmc_tv_ngrid = 0;
535 /**
536 * `--percentiles`, the levels `getPerctRespT` is read at. Empty = not
537 * given, i.e. the reference's `pers_stored` {0.50, 0.90, 0.95, 0.99}.
538 * Accepted as fractions (0.9) or as percents (90), told apart by magnitude:
539 * a level above 1 cannot be a probability.
540 */
541 std::vector<double> percentiles;
542 /**
543 * `-v/--verbosity`: silent | standard | debug.
544 *
545 * IT WAS ACCEPTED AND DISCARDED, which is the silent-acceptance defect this
546 * struct exists to prevent one layer up: a caller who asked for `silent`
547 * still received every warning the arms print on stderr. It gates them now,
548 * and nothing else -- the tables on stdout are the answer and are printed
549 * whatever the level, exactly as the JAR prints them.
550 */
551 std::string verbosity;
552 /**
553 * `--reward-name`, which declared reward `-a reward-value` returns the
554 * value function of. Empty = not given, and `-a reward-value` without it is
555 * refused rather than defaulted to the first reward: the value functions of
556 * two rewards are different objects, and picking one silently would label
557 * the wrong matrix with the caller's question.
558 */
559 std::string reward_name;
560};
561
562/**
563 * The three `--map-env*` knobs, as the gate wants them.
564 *
565 * One helper rather than three assignments per arm, for the reason the Knobs
566 * struct itself exists: the fallback is decided in every `-a avg` ladder and in
567 * `run_avg_engine`, and an arm that read a different subset of the three would
568 * accept a different model for the same command line.
569 */
570inline line::solvers::MapEnvConfig map_env_config(const Knobs& k) {
572 if (!k.map_env.empty()) c.mode = k.map_env;
573 if (!k.map_env_method.empty()) c.method = k.map_env_method;
574 if (k.map_env_maxstages) c.max_stages = k.map_env_maxstages;
575 return c;
576}
577
578/**
579 * `--cutoff` written as a per-(station,class) matrix, `';'`-separated rows of
580 * `','`-separated non-negative counts. Empty on anything that is not one.
581 *
582 * The spelling is the JAR CLI's, so one example pins one string for both
583 * engines. A zero entry is legal and means the station may not hold that class
584 * at all, which is how the reference bounds a queue in the classes it does not
585 * serve.
586 */
587std::vector<std::vector<std::size_t>> parse_cutoff_matrix(const std::string& s) {
588 std::vector<std::vector<std::size_t>> out;
589 std::string::size_type pos = 0;
590 while (pos <= s.size()) {
591 const std::string::size_type semi = s.find(';', pos);
592 const std::string row = s.substr(pos, semi == std::string::npos ? std::string::npos
593 : semi - pos);
594 std::vector<std::size_t> cells;
595 std::string::size_type cp = 0;
596 while (cp <= row.size()) {
597 const std::string::size_type comma = row.find(',', cp);
598 const std::string cell = row.substr(cp, comma == std::string::npos ? std::string::npos
599 : comma - cp);
600 if (cell.empty()) return std::vector<std::vector<std::size_t>>();
601 for (std::string::size_type i = 0; i < cell.size(); ++i)
602 if (!std::isdigit(static_cast<unsigned char>(cell[i])))
603 return std::vector<std::vector<std::size_t>>();
604 cells.push_back(static_cast<std::size_t>(std::atol(cell.c_str())));
605 if (comma == std::string::npos) break;
606 cp = comma + 1;
607 }
608 if (cells.empty()) return std::vector<std::vector<std::size_t>>();
609 if (!out.empty() && cells.size() != out[0].size())
610 return std::vector<std::vector<std::size_t>>();
611 out.push_back(cells);
612 if (semi == std::string::npos) break;
613 pos = semi + 1;
614 }
615 return out;
616}
617
618/**
619 * The model text piped on stdin, drained ONCE and kept.
620 *
621 * `-s auto` parses the model twice: once to choose an engine and once for the
622 * engine to solve. A stream can only be drained once, so without this buffer the
623 * second parse would see nothing and the chooser would be unusable on a piped
624 * model. It is deliberately not a function template -- a static local inside one
625 * would be per-instantiation, and the two parses need not share an arithmetic.
626 */
627std::string g_stdin_buf;
628bool g_stdin_loaded = false;
629
630/**
631 * The model text a host supplied INSTEAD of the process's standard input.
632 *
633 * A library has no standard input worth reading: R's is the user's terminal,
634 * and draining it would hang the session. When a host passes a document it is
635 * installed here, and `stdin_model_text` returns it without touching std::cin.
636 */
637void set_stdin_override(const std::string& text) {
638 g_stdin_buf = text;
639 g_stdin_loaded = true;
640}
641
642const std::string& stdin_model_text() {
643 if (!g_stdin_loaded) {
644 g_stdin_buf.assign(std::istreambuf_iterator<char>(std::cin),
645 std::istreambuf_iterator<char>());
646 g_stdin_loaded = true;
647 }
648 return g_stdin_buf;
649}
650
651/**
652 * Whether `-i` selected a JMT document rather than a model.json.
653 *
654 * A FILE-SCOPE FLAG AND NOT A PARAMETER, because `read_model` is called from
655 * every solver arm and threading the format through all of them would touch
656 * fifty signatures to carry one bit that main already knows. It is set once,
657 * before any arm runs, and never again.
658 */
659bool g_jsim_input = false;
660
661/**
662 * Whether `-i` selected a PNML place/transition net.
663 *
664 * A second flag rather than a format string for the same reason `g_jsim_input`
665 * is one: `read_model` is called from every solver arm, and the three input
666 * kinds it can be handed are mutually exclusive, so two booleans say what a
667 * threaded enum would and touch no signature. Set once in main.
668 */
669bool g_pnml_input = false;
670
671/**
672 * `-v/--verbosity`, hoisted to file scope for the same reason as `g_jsim_input`.
673 *
674 * The knobs carry it too, but the priority warning below is raised where the
675 * model is READ rather than inside a solver arm, and that function receives no
676 * knobs. Set once in main, before any arm runs.
677 */
678std::string g_verbosity = "standard";
679
680/**
681 * Put every piece of process state back where a fresh process would have it.
682 *
683 * THESE ARE DEFECTS AND NOT HYGIENE, and one of them is live in shipped code:
684 * `run_invocation_captured` serves one request per connection on the same
685 * process, so a request carrying `-i jsimg` used to set `g_jsim_input` and
686 * every later request on that server took the JSIM reader for a model.json.
687 * The header on `g_jsim_input` states the assumption exactly -- "It is set
688 * once, before any arm runs, and never again" -- which is true of a process
689 * that runs one invocation and exits, and false of every other caller.
690 *
691 * The stdin buffer is reset only when no host override is pending, because a
692 * host that just supplied a document means it for the call about to run.
693 */
694void reset_invocation_state(bool keep_stdin_override) {
695 g_json_output = false;
696 g_jsim_input = false;
697 g_pnml_input = false;
698 g_verbosity = "standard";
699 if (!keep_stdin_override) {
700 g_stdin_buf.clear();
701 g_stdin_loaded = false;
702 }
703 // The console keeps the level of the LAST run, so a `-v debug` call would
704 // narrate the one after it. LineConsole::reset() drops any open run; the
705 // level is set again from g_verbosity by every entry point below.
707}
708
709/**
710 * Say so when class priorities were declared and no station will read them.
711 *
712 * The twin of the block at the tail of MATLAB's `@MNetwork/refreshStruct.m` and
713 * of `Network.refreshStruct` in the JAR. It is a WARNING and not a refusal: a
714 * priority at a station whose discipline ignores one is a legitimate model --
715 * `prio_hol_open` is built on it -- and only becomes a defect when NO station
716 * reads it, at which point the metrics are not the priority ones the caller is
717 * about to read them as. Priority-awareness is a property of the declared
718 * policy and is never inferred from the data; see network_struct.h.
719 */
720template <class T>
721void warn_priorities_ignored(line::qn::Network<T>& net) {
722 if (g_verbosity == "silent") return;
723 // `raw_struct` and not `get_struct`: the priorities and the disciplines are
724 // written when the classes and stations are added, and nothing in the
725 // refresh chain touches either, so reading them here costs no refresh.
726 if (!net.raw_struct().priorities_ignored()) return;
727 std::fprintf(stderr,
728 "Warning: Priority classes are specified but no priority-aware scheduling "
729 "policy (PSPRIO, DPSPRIO, GPSPRIO, HOL, FCFSPRIO, FCFSPRPRIO, FCFSPIPRIO, "
730 "LCFSPRIO, LCFSPRPRIO, LCFSPIPRIO, SRPTPRIO) is used in the model. "
731 "Priorities will be ignored.\n");
732}
733
734/** Read the model named by `file`, or stdin when it is empty. */
735template <class T>
736line::qn::Network<T> read_model(const std::string& file) {
737 if (g_pnml_input) {
738 // The PNML reader walks a DOM and has no stdin form, so a piped document
739 // is staged to a temporary file, as the JSIM arm below does.
740 if (!file.empty()) {
742 warn_priorities_ignored(net);
743 return net;
744 }
745 const std::string tmp = line::io::jsim_stage_stdin(stdin_model_text());
747 std::remove(tmp.c_str());
748 warn_priorities_ignored(net);
749 return net;
750 }
751 if (g_jsim_input) {
752 // The JSIM reader walks a DOM and has no stdin form, so a piped JMT
753 // document is staged to a temporary file rather than refused: `cat
754 // model.jsimg | line-cli -i jsimg` is how the JAR CLI is used and the
755 // Docker image documents it.
756 if (!file.empty()) {
758 warn_priorities_ignored(net);
759 return net;
760 }
761 const std::string tmp = line::io::jsim_stage_stdin(stdin_model_text());
763 std::remove(tmp.c_str());
764 warn_priorities_ignored(net);
765 return net;
766 }
767 if (!file.empty()) {
769 warn_priorities_ignored(net);
770 return net;
771 }
772 std::istringstream in(stdin_model_text());
773 line::io::detail::json root;
774 in >> root;
776 warn_priorities_ignored(net);
777 return net;
778}
779
780/** Read the Environment model.json named by `file`, or stdin when it is empty. */
781template <class T>
782line::env::Environment<T> read_env_model(const std::string& file) {
783 if (!file.empty()) return line::io::read_environment_json<T>(file);
784 std::istringstream in(stdin_model_text());
785 line::io::detail::json root;
786 in >> root;
788}
789
790/** One row of an average table, already reduced to double. */
791struct AvgRow {
792 double q, u, r, w, a, t;
793};
794
795/**
796 * Render an average table, as text or as the JSON the hosts parse.
797 *
798 * THE ONE PLACE `-o json` IS HONOURED, reached by every solver arm through a
799 * per-(station,class) accessor. The SSA and fluid arms used to hand-roll their
800 * own printer -- their solution types are `SsaSolution`/`FluidSolution`, not
801 * `mva::AvgResult<T>` -- and neither consulted `g_json_output`, so `-o json` was
802 * ACCEPTED AND IGNORED for `-s ssa` and `-s fluid`: the caller got the readable
803 * table with no brace in it, which is what the Python `lang='cpp'` bridge hit as
804 * "no JSON object found in solver output". Accepting a flag and not applying it
805 * is the silent-acceptance defect this CLI refuses everywhere else, so the
806 * rendering is shared rather than reimplemented per solution type.
807 *
808 * THE JSON FORM IS THE JAR's, key for key. `jline.cli.LineCLI -a avg -o json`
809 * emits {"avg": {"type": "AvgTable", "Station": [...], "JobClass": [...],
810 * "QLen": [...], ...}}, column-oriented, and the Python wrapper's
811 * `station_matrices_via_jar` parses exactly that shape. Emitting anything else
812 * would force a second parser into the wrapper for a table that is the same
813 * table, so the host can treat `lang='cpp'` and `lang='java'` as one transport
814 * with two binaries. The JAR's `data` key (the rendered text) is NOT reproduced:
815 * no caller reads it, and a second rendering of the same numbers is one more
816 * thing that can disagree with the first.
817 *
818 * The rows are also byte-identical across the solvers that share it, which
819 * matters more than it looks: `parity/compare_parity.py` diffs these tables
820 * column by column, and a solver whose printer drifted by a space would read as
821 * a parity failure in a harness that is supposed to be measuring the numbers.
822 */
823template <class Get>
824void emit_avg_table_named(const std::vector<std::string>& stations,
825 const std::vector<std::string>& classes, const char* arith,
826 const std::string& method, Get get, const line::reg::Json& extra,
827 const line::reg::Json& envelope) {
828 line::reg::Json rows = line::reg::Json::object();
829 for (const char* key : {"Station", "JobClass", "QLen", "Util", "RespT", "ResidT", "ArvR",
830 "Tput"})
831 rows[key] = line::reg::Json::array();
832 rows["type"] = "AvgTable";
833
834 if (!g_json_output)
835 std::printf("%-16s %-14s %12s %12s %12s %12s %12s %12s\n", "Station", "JobClass", "QLen",
836 "Util", "RespT", "ResidT", "ArvR", "Tput");
837 for (std::size_t i = 0; i < stations.size(); ++i) {
838 for (std::size_t c = 0; c < classes.size(); ++c) {
839 const AvgRow v = get(i, c);
840 if (v.q == 0.0 && v.u == 0.0 && v.r == 0.0 && v.w == 0.0 && v.a == 0.0 && v.t == 0.0)
841 continue;
842 if (!g_json_output) {
843 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g\n",
844 stations[i].c_str(), classes[c].c_str(), v.q, v.u, v.r, v.w, v.a, v.t);
845 continue;
846 }
847 // FULL PRECISION on the JSON path, against the readable path's
848 // %12.6g. The table is for a human to read; the JSON is for a host
849 // to compare against another codebase's answer, and six digits
850 // would cap any parity check at six digits.
851 rows["Station"].push_back(stations[i]);
852 rows["JobClass"].push_back(classes[c]);
853 rows["QLen"].push_back(v.q);
854 rows["Util"].push_back(v.u);
855 rows["RespT"].push_back(v.r);
856 rows["ResidT"].push_back(v.w);
857 rows["ArvR"].push_back(v.a);
858 rows["Tput"].push_back(v.t);
859 }
860 }
861 if (g_json_output) {
862 // `extra` NESTS INSIDE the "avg" payload, for emit_analysis's reason: a
863 // solver-specific key at envelope level can collide with an analysis's
864 // own name, and a per-list cost qualifies THIS answer.
865 for (line::reg::Json::const_iterator it = extra.begin(); it != extra.end(); ++it)
866 rows[it.key()] = it.value();
867 line::reg::Json out = line::reg::Json::object();
868 out["avg"] = rows;
869 out["arith"] = arith;
870 out["method"] = method;
871 // `envelope`, unlike `extra`, sits BESIDE "avg" rather than inside it.
872 // Provenance of the solve as a whole belongs there: the iteration count
873 // and the convergence flag qualify the answer, not any one row, and a
874 // host reads them where the JAR CLI puts them.
875 for (line::reg::Json::const_iterator it = envelope.begin(); it != envelope.end(); ++it)
876 out[it.key()] = it.value();
877 emit_document(dump_document(out));
878 }
879}
880
881/**
882 * The same table, labelled from a struct.
883 *
884 * THE NAMES, NOT THE STRUCT, are what the printer needs, and one solver has no
885 * struct to give it: `-s ldes` forwards the model document to an external engine
886 * and is labelled from the names that engine reports, precisely so a model this
887 * port cannot itself parse still prints the same table. Everything else calls
888 * this overload and nothing about those rows changes.
889 */
890template <class T, class Get>
891void emit_avg_table(const line::qn::NetworkStruct<T>& sn, const std::string& method, Get get,
892 const line::reg::Json& extra = line::reg::Json::object(),
893 const line::reg::Json& envelope = line::reg::Json::object()) {
894 std::vector<std::string> stations, classes;
895 stations.reserve(sn.nstations);
896 classes.reserve(sn.nclasses);
897 for (std::size_t i = 0; i < sn.nstations; ++i) stations.push_back(sn.stations[i].name);
898 for (std::size_t c = 0; c < sn.nclasses; ++c) classes.push_back(sn.classes[c].name);
899 emit_avg_table_named(stations, classes, line::num_traits<T>::name(), method, get, extra,
900 envelope);
901}
902
903/** NaN as this arithmetic spells it, the reference's "not computed" marker. */
904template <class T>
905T cache_nan() {
906 return line::num_traits<T>::from_double(std::numeric_limits<double>::quiet_NaN());
907}
908
909/** `v[r]` when the vector reaches r, NaN otherwise -- the reference's nanGetAt. */
910template <class T>
911double cache_at(const std::vector<T>& v, std::size_t r) {
912 if (r >= v.size()) return std::numeric_limits<double>::quiet_NaN();
914}
915
916/**
917 * The readable cache rows of a solve that reports beside the AvgTable.
918 *
919 * A Cache answers in (node, read class) and in (node, read class, list), which
920 * the AvgTable cannot carry, so a caller that only got the AvgTable was told
921 * nothing about the cache it asked about. `-a cache` has its own richer table
922 * built from the model's Source rates; this one reports the surface alone and
923 * is what the `-s env` arm uses, where there is no single model whose Source
924 * rate describes the blend.
925 *
926 * ABSENT PRINTS NaN, NEVER 0: an empty field is a coupling that did not measure
927 * the quantity, and a zero would read as a cache that never hits.
928 */
929template <class T>
930void print_cache_rows(const line::qn::NetworkStruct<T>& sn,
931 const line::solvers::CacheMetrics<T>& cache) {
932 if (cache.empty()) return;
933 const double dnan = std::numeric_limits<double>::quiet_NaN();
934 std::printf("\n%-12s %-12s %5s %12s %12s %12s\n", "Cache", "JobClass", "List", "HitProb",
935 "DelayedHitP", "MissProb");
936 for (std::size_t c = 0; c < cache.caches.size(); ++c) {
937 const line::solvers::CacheNodeMetrics<T>& m = cache.caches[c];
938 const typename std::map<std::size_t, line::qn::CacheParam<T> >::const_iterator it =
939 sn.nodeparam.find(m.node);
940 if (it == sn.nodeparam.end()) continue;
941 const std::vector<std::size_t>& hitclass = it->second.hitclass;
942 for (std::size_t cl = 0; cl < sn.nclasses; ++cl) {
943 // A READ CLASS IS ONE WITH A HIT CLASS DEFINED, the same rule the
944 // `-a cache` table applies; every other class has no ratio of its
945 // own and gets no row rather than a row of zeros.
946 if (cl >= hitclass.size() || hitclass[cl] == 0) continue;
947 const double ph = cache_at(m.hitprob, cl), pm = cache_at(m.missprob, cl),
948 pd = cache_at(m.delayedprob, cl);
949 std::printf("%-12s %-12s %5d %12.6g %12.6g %12.6g\n", m.name.c_str(),
950 sn.classes[cl].name.c_str(), 0, ph, pd, pm);
951 for (std::size_t l = 0; l < m.hitproblist.cols() && cl < m.hitproblist.rows(); ++l)
952 std::printf("%-12s %-12s %5d %12.6g %12.6g %12.6g\n", m.name.c_str(),
953 sn.classes[cl].name.c_str(), static_cast<int>(l + 1),
954 line::num_traits<T>::to_double(m.hitproblist(cl, l)), dnan, dnan);
955 }
956 }
957}
958
959/**
960 * Print one analysis that is NOT the average table, as the JSON a host parses.
961 *
962 * ONE ENVELOPE FOR EVERY ANALYSIS: the payload sits under a key named after the
963 * `-a` it answers, and the arithmetic and the resolved method sit beside it,
964 * exactly as `emit_avg_table` places them beside "avg". A host therefore reads
965 * the provenance the same way whatever it asked for, and a caller that asked for
966 * `-a cdf` and received a "states" key knows the answer is not its own instead of
967 * misreading the numbers as its own.
968 *
969 * `method` MAY BE EMPTY, in which case the key is omitted rather than filled
970 * with the requested name: `-a reward` returns rewards and no solved chain, so
971 * there is no resolved method to report, and echoing back "default" would claim
972 * a resolution that never happened.
973 *
974 * EVERY INDEX IN A PAYLOAD IS 0-BASED, against the readable tables' 1-based
975 * columns, and each payload carries `indexBase` so a host cannot get it wrong
976 * silently. The two conventions are deliberate: the table is read by a human
977 * diffing it against MATLAB, whose indices start at 1, while the JSON is
978 * consumed by code that will index a numpy array or a std::vector with it.
979 */
980template <class T>
981void emit_analysis(const char* key, const line::reg::Json& payload, const std::string& method,
982 const line::reg::Json& extra = line::reg::Json::object()) {
983 line::reg::Json body = payload;
984 // `extra` GOES INSIDE THE PAYLOAD, not beside it. At envelope level a
985 // solver-specific key can collide with the analysis's own name -- the CTMC
986 // state count is "states" and so is the `-a states` payload, and the merge
987 // silently replaced the whole answer with the integer 3. Nesting it makes
988 // that class of collision unrepresentable, and it is where the value belongs
989 // anyway: a cutoff qualifies THIS answer.
990 for (line::reg::Json::const_iterator it = extra.begin(); it != extra.end(); ++it)
991 body[it.key()] = it.value();
992 line::reg::Json out = line::reg::Json::object();
993 out[key] = body;
994 out["arith"] = line::num_traits<T>::name();
995 if (!method.empty()) out["method"] = method;
996 emit_document(dump_document(out));
997}
998
999/** A Matrix as a row-major array of arrays, each entry reduced to double. */
1000template <class T>
1001line::reg::Json matrix_json(const line::Matrix<T>& M) {
1002 line::reg::Json rows = line::reg::Json::array();
1003 for (std::size_t i = 0; i < M.rows(); ++i) {
1004 line::reg::Json row = line::reg::Json::array();
1005 for (std::size_t j = 0; j < M.cols(); ++j)
1006 row.push_back(line::num_traits<T>::to_double(M(i, j)));
1007 rows.push_back(row);
1008 }
1009 return rows;
1010}
1011
1012/** A vector of field elements as a JSON array of doubles. */
1013template <class T>
1014line::reg::Json vector_json(const std::vector<T>& v) {
1015 line::reg::Json a = line::reg::Json::array();
1016 for (std::size_t i = 0; i < v.size(); ++i) a.push_back(line::num_traits<T>::to_double(v[i]));
1017 return a;
1018}
1019
1020/** A vector of sizes as a JSON array, unchanged: a width is not a measurement. */
1021line::reg::Json index_json(const std::vector<std::size_t>& v) {
1022 line::reg::Json a = line::reg::Json::array();
1023 for (std::size_t i = 0; i < v.size(); ++i) a.push_back(v[i]);
1024 return a;
1025}
1026
1027/**
1028 * The per-Cache result block, as `-a avg` carries it inside the "avg" payload.
1029 *
1030 * PER-CACHE RESULTS RIDE WITH THE AVG TABLE, because a cache's hit, miss and
1031 * delayed-hit fractions are a SOLVER RESULT and a host that solves through this
1032 * CLI has no other way to get them back onto its own Cache node. Emitted per
1033 * node, and each vector is OMITTED when the solver computed none: absent must
1034 * CLEAR the host's copy, and a zero-filled vector would instead assert that
1035 * nothing hits.
1036 *
1037 * FACTORED OUT of `print_avg_table` because the SSA and Fluid arms build the
1038 * table themselves rather than through it, and so carried no block at all:
1039 * `CPPLINE.restoreCacheResults` then cleared MATLAB's Cache node, refreshed the
1040 * visits, and reported link()'s offered 1/2-1/2 for a split both engines had
1041 * measured. On cache_replc_rr that is hit 1.0 against 1.1246 (fluid) and
1042 * 1.1460 (ssa). A second copy of this serialization here would be free to drop
1043 * a field again, so there is one.
1044 */
1045template <class T>
1046line::reg::Json cache_extra_json(const line::solvers::CacheMetrics<T>& cache) {
1047 line::reg::Json caches = line::reg::Json::array();
1048 for (std::size_t c = 0; c < cache.caches.size(); ++c) {
1049 const line::solvers::CacheNodeMetrics<T>& m = cache.caches[c];
1050 line::reg::Json e = line::reg::Json::object();
1051 // NAME FIRST, because the index is not portable. `node` is an index
1052 // into THIS process's node order, which is not the model.json
1053 // declaration order: on retrieval_simple the JSON declares Source,
1054 // Cache, Queue, Sink and this struct holds Source, Queue, Sink,
1055 // Cache, so the Cache is 2 to the host and 4 here. A host matching
1056 // on the index wrote onto its Sink, found no Cache and silently kept
1057 // the PREVIOUS solver's numbers. `node` stays for provenance.
1058 e["name"] = m.name;
1059 e["node"] = m.node;
1060 if (!m.hitprob.empty()) e["HitProb"] = vector_json(m.hitprob);
1061 if (!m.missprob.empty()) e["MissProb"] = vector_json(m.missprob);
1062 if (!m.delayedprob.empty()) e["DelayedHitProb"] = vector_json(m.delayedprob);
1063 if (!m.latency.empty()) e["ResidT"] = vector_json(m.latency);
1064 if (!m.listcost.empty()) e["ListCost"] = vector_json(m.listcost);
1065 // The per-list breakdown, (K x h). Carried because it is an ANSWER a
1066 // host cannot rebuild from HitProb: only the row SUM is HitProb, and
1067 // which list served the hit is what a replacement policy is judged on.
1068 if (m.hitproblist.rows() > 0) e["HitProbList"] = matrix_json(m.hitproblist);
1069 if (!m.delayedhitqlen.empty()) {
1070 e["DelayedHitQLen"] = vector_json(m.delayedhitqlen);
1071 e["DelayedHitQLenFull"] = vector_json(m.delayedhitqlenfull);
1072 }
1073 caches.push_back(e);
1074 }
1075 return caches;
1076}
1077
1078/**
1079 * Print an AvgResult as the parity table.
1080 *
1081 * Shared by every solver that returns `mva::AvgResult` -- MVA, NC, MAM and BA
1082 * -- so the rows stay byte-identical across them.
1083 */
1084template <class T>
1085void print_avg_table(const line::qn::NetworkStruct<T>& sn, const line::mva::AvgResult<T>& r,
1086 const line::reg::Json& extra_in = line::reg::Json::object()) {
1087 // THE JSON FORM IS THE JAR's, key for key. `jline.cli.LineCLI -a avg -o json`
1088 // emits {"avg": {"type": "AvgTable", "Station": [...], "JobClass": [...],
1089 // "QLen": [...], ...}}, column-oriented, and the Python wrapper's
1090 // `station_matrices_via_jar` parses exactly that shape. Emitting anything
1091 // else here would force a second parser into the wrapper for a table that is
1092 // the same table, so the host can treat `lang='cpp'` and `lang='java'` as one
1093 // transport with two binaries. The JAR's `data` key (the rendered text) is
1094 // NOT reproduced: no caller reads it, and a second rendering of the same
1095 // numbers is one more thing that can disagree with the first.
1096 // A CARRIED WARNING IS ONLY A WARNING IF SOMEONE SEES IT. `AvgResult`
1097 // carries the reference's text verbatim for the cases where the answer is
1098 // usable but not one the reference stands behind (the SJN starvation cap,
1099 // immediate feedback approximated as re-queueing), and until now no CLI
1100 // path printed it -- so the table looked authoritative exactly where the
1101 // reference declines. On STDERR, not stdout: stdout is the table a parity
1102 // harness diffs and the JSON a wrapper parses, and a warning line in either
1103 // would be read as data. Python's `lang='cpp'` bridge re-raises anything on
1104 // stderr as a Python warning, so it reaches that caller too.
1105 if (!r.warning.empty())
1106 std::fprintf(stderr, "Warning: %s\n", r.warning.c_str());
1107
1108 // Mean per-list cache storage cost, the ListCost column of getAvgCacheTable.
1109 // Present only on a cache model carrying item sizes, and omitted entirely
1110 // otherwise rather than emitted empty, so a host can test for the key.
1111 line::reg::Json extra = line::reg::Json::object();
1112 if (!r.listcost.empty()) {
1113 line::reg::Json lc = line::reg::Json::array();
1114 for (std::size_t j = 0; j < r.listcost.size(); ++j)
1115 lc.push_back(line::num_traits<T>::to_double(r.listcost[j]));
1116 extra["ListCost"] = lc;
1117 }
1118
1119 // WHAT THE CALLER ADDS, merged rather than replaced. The JMT arm carries the
1120 // finite-capacity-region rows this way: they are metric rows past the last
1121 // station, and the station table drops them, so without a channel of their
1122 // own a host reading `-a avg` cannot see a region at all (MATLAB's
1123 // `getAvgNodeTable` then filtered the FCR node out for having no numbers).
1124 if (extra_in.is_object())
1125 for (line::reg::Json::const_iterator it = extra_in.begin(); it != extra_in.end(); ++it)
1126 extra[it.key()] = it.value();
1127
1128 // See `cache_extra_json`: the split is a solver result and the host has no
1129 // other channel back to its own Cache node.
1130 if (!r.cache.empty()) extra["Cache"] = cache_extra_json<T>(r.cache);
1131
1132 // Provenance of the solve, beside "avg" and keyed as the JAR CLI keys it, so
1133 // one host-side reduction reads both backends. `converged` is OMITTED when
1134 // the handler reports none: absent is not false, and there the count is the
1135 // signal a caller may fall back on.
1136 line::reg::Json envelope = line::reg::Json::object();
1137 envelope["iter"] = r.iter;
1138 if (r.converged.has_value()) envelope["converged"] = r.converged.value();
1139 // `@SolverNC/getProbNormConstAggr`, keyed as the reference names the field it
1140 // stores it in. OMITTED for every solver that computes no constant: log G = 0
1141 // is the constant of an empty network, so a zero here would be a claim.
1142 if (r.lognormconst.has_value()) envelope["logNormConstAggr"] = r.lognormconst.value();
1143
1144 emit_avg_table<T>(sn, r.actualmethod, [&](std::size_t i, std::size_t c) {
1145 AvgRow row;
1146 row.q = line::num_traits<T>::to_double(r.QN(i, c));
1147 row.u = line::num_traits<T>::to_double(r.UN(i, c));
1148 row.r = line::num_traits<T>::to_double(r.RN(i, c));
1149 row.w = line::num_traits<T>::to_double(r.WN(i, c));
1150 row.a = line::num_traits<T>::to_double(r.AN(i, c));
1151 row.t = line::num_traits<T>::to_double(r.TN(i, c));
1152 return row;
1153 }, extra, envelope);
1154}
1155
1156template <class T>
1157int solve_model_mva(const std::string& file, const Knobs& k) {
1158 line::qn::Network<T> net = read_model<T>(file);
1160 if (!k.method.empty() && k.method != "default") opt.method = k.method;
1161 if (k.tol >= 0.0) opt.tol = k.tol;
1162 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
1163 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
1164 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
1165 if (!k.fork_join.empty()) opt.fork_join = k.fork_join;
1166 line::Matrix<T> init;
1167 // The map_env funnel, the same one `run_avg_engine` applies to the node,
1168 // sys, chain and nodechain views: `-a avg` and `-a node` must accept the
1169 // same models for the same command line. Compile-time because the driver is
1170 // double-only -- see the note on the nc arm of `run_avg_engine`.
1172 if constexpr (std::is_same_v<T, double>) {
1174 net.get_struct(), "SolverMVA",
1176 net.get_struct()),
1177 map_env_config(k),
1178 [&opt, &init](const line::qn::NetworkStruct<double>& m) {
1179 return line::mva::solver_mva_run_analyzer(m, opt, init);
1180 },
1182 } else {
1183 r = line::mva::solver_mva_run_analyzer(net.get_struct(), opt, init);
1184 }
1185 const line::qn::NetworkStruct<T>& sn = net.get_struct();
1186
1187 std::printf("SolverMVA arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
1188 r.actualmethod.c_str(),
1189 line::util::method_type("MVA", r.actualmethod).c_str());
1190 print_avg_table<T>(sn, r);
1191 return 0;
1192}
1193
1194
1195/**
1196 * `-s jmt -a tran`: `getTranAvg`, the transient ensemble of `default` over a finite timespan.
1197 *
1198 * A SINGLE SAMPLE PATH IS NOT E[N](t). There is no time ergodicity at a fixed
1199 * t, so the mean is estimated over independent replications -- `--jmt-replications`
1200 * of them, which is what `options.config.replications` names in the reference --
1201 * replication k seeded `seed + k - 1` and resampled onto the union of their event
1202 * grids. An explicit `--method jsim` is ONE run. `jmt_transient_replications`
1203 * states the horizon and method rules itself, so they are not restated here.
1204 *
1205 * The payload is the SAME `TranAvgTable` the CTMC, fluid, MAM and LDES
1206 * transients emit, key for key, so one host reader serves every solver that
1207 * answers `-a tran`.
1208 */
1209int solve_model_jmt_tran(const line::qn::NetworkStruct<double>& sn,
1210 const line::jmt::JmtOptions& o, const Knobs& k) {
1212 const std::size_t reps = line::jmt::jmt_transient_replication_count(o, "getTranAvg");
1213 const std::size_t M = sn.nstations, K = sn.nclasses, nt = r.t.size();
1214
1215 if (g_json_output) {
1216 line::reg::Json p = line::reg::Json::object();
1217 p["type"] = "TranAvgTable";
1218 p["indexBase"] = 0;
1219 p["t0"] = k.t0 >= 0.0 ? k.t0 : 0.0;
1220 p["t1"] = o.max_simulated_time;
1221 // HOW MANY PATHS THE MEAN IS OVER is part of the answer on a simulated
1222 // transient: a replication that produced no log contributes nothing, so
1223 // the count asked for and the count averaged are not the same number.
1224 p["replications"] = r.valid;
1225 p["replicationsRequested"] = reps;
1226 line::reg::Json curves = line::reg::Json::array();
1227 for (std::size_t i = 0; i < M && i < r.QNt.size(); ++i)
1228 for (std::size_t c = 0; c < K && c < r.QNt[i].size(); ++c) {
1229 // A disabled pair is OMITTED, not sent as zeros, as on the CTMC
1230 // arm: the host turns an absent curve into the disabled
1231 // handle's NaN, where zeros would read as a genuinely idle
1232 // station.
1233 if (sn.disabled[i][c]) continue;
1234 line::reg::Json e = line::reg::Json::object();
1235 e["Station"] = sn.stations[i].name;
1236 e["JobClass"] = sn.classes[c].name;
1237 e["station"] = i;
1238 e["jobclass"] = c;
1239 line::reg::Json tt = line::reg::Json::array(), q = line::reg::Json::array(),
1240 u = line::reg::Json::array(), x = line::reg::Json::array();
1241 for (std::size_t j = 0; j < nt; ++j) {
1242 tt.push_back(r.t[j]);
1243 q.push_back(r.QNt[i][c][j]);
1244 u.push_back(r.UNt[i][c][j]);
1245 x.push_back(r.TNt[i][c][j]);
1246 }
1247 e["t"] = tt;
1248 e["QLen"] = q;
1249 e["Util"] = u;
1250 e["Tput"] = x;
1251 curves.push_back(e);
1252 }
1253 p["curves"] = curves;
1254 // ALWAYS jsim: every replication is a simulated path, and JMVA computes
1255 // means from a product form and logs no trajectory at all, so naming the
1256 // requested method would name an engine that never ran.
1257 emit_analysis<double>("tran", p, std::string("jsim"));
1258 return 0;
1259 }
1260 std::printf("SolverJMT arith=double method=jsim\n");
1261 std::printf("TranAvg times=%zu replications=%zu/%zu\n", nt, r.valid, reps);
1262 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "Time", "QLen", "Util",
1263 "Tput");
1264 for (std::size_t i = 0; i < M && i < r.QNt.size(); ++i)
1265 for (std::size_t c = 0; c < K && c < r.QNt[i].size(); ++c) {
1266 if (sn.disabled[i][c]) continue;
1267 for (std::size_t j = 0; j < nt; ++j)
1268 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n",
1269 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(), r.t[j],
1270 r.QNt[i][c][j], r.UNt[i][c][j], r.TNt[i][c][j]);
1271 }
1272 return 0;
1273}
1274
1275/**
1276 * `-s jmt -a tranprob`: `getTranProbAggr`, pi(t) over ONE station's aggregate
1277 * state, estimated over independent replications.
1278 *
1279 * THE AGGREGATE LAW ONLY, and refused rather than aliased for the detailed one:
1280 * a JMT log records per-class job counts at a node and nothing about the buffer
1281 * order or the service phase, so the encoding `getTranProb` is a law over is
1282 * not observed at all -- the same rule the `-a prob` arm states.
1283 *
1284 * `--node` names the station, defaulting to the first one: the estimate is
1285 * over one station's own state, not over the network's, so there is no system
1286 * view to fall back to here.
1287 */
1288int solve_model_jmt_tranprob(const line::qn::NetworkStruct<double>& sn,
1289 const line::jmt::JmtOptions& o, const Knobs& k) {
1290 std::size_t station = 1;
1291 if (k.node) {
1292 // OUT OF RANGE IS REFUSED, not silently folded onto station 1: falling
1293 // back to the default would answer a question about a different node
1294 // than the one named, which is the silent-wrong-answer failure the
1295 // whole Knobs struct exists to prevent.
1296 if (k.node > sn.nodes.size() || sn.nodes[k.node - 1].station == 0)
1297 throw line::InputError(
1298 "--node " + std::to_string(k.node) +
1299 " is not a station, so it holds no per-class job count to take a law over");
1300 station = sn.nodes[k.node - 1].station;
1301 }
1302 const std::size_t reps = line::jmt::jmt_transient_replication_count(o, "getTranProbAggr");
1303 std::vector<std::vector<double> > states;
1304 const std::pair<std::vector<double>, std::vector<std::vector<double> > > r =
1305 line::jmt::jmt_get_tran_prob_aggr(sn, station, o, states);
1306 const std::vector<double>& t = r.first;
1307 const std::vector<std::vector<double> >& pit = r.second;
1308
1309 if (g_json_output) {
1310 line::reg::Json p = line::reg::Json::object();
1311 p["type"] = "TranProb";
1312 p["indexBase"] = 0;
1313 p["scope"] = sn.stations[station - 1].name;
1314 p["node"] = sn.station_to_node[station - 1] - 1;
1315 p["station"] = station - 1;
1316 p["replications"] = reps;
1317 line::reg::Json span = line::reg::Json::array();
1318 // THE HORIZON IS PART OF THE ANSWER, as on the CTMC arm: pi(t) over an
1319 // unstated span is not a quantity, and here it is also what makes the
1320 // replications' grids commensurable in the first place.
1321 span.push_back(k.t0 >= 0.0 ? k.t0 : 0.0);
1322 span.push_back(o.max_simulated_time);
1323 p["tspan"] = span;
1324 p["t"] = vector_json(t);
1325 // ONLY THE AGGREGATE LABELS, and the key says so: a host reading
1326 // `labels` here would be reading the detailed law of another solver.
1327 line::reg::Json labels = line::reg::Json::array();
1328 for (std::size_t s = 0; s < states.size(); ++s) labels.push_back(vector_json(states[s]));
1329 p["labelsAggr"] = labels;
1330 line::reg::Json rows = line::reg::Json::array();
1331 for (std::size_t g = 0; g < pit.size(); ++g) rows.push_back(vector_json(pit[g]));
1332 p["pitAggr"] = rows;
1333 emit_analysis<double>("tranprob", p, std::string("jsim"));
1334 return 0;
1335 }
1336 std::printf("SolverJMT arith=double method=jsim\n");
1337 std::printf("TranProb times=%zu tspan=%g:%g scope=%s replications=%zu\n", t.size(),
1338 k.t0 >= 0.0 ? k.t0 : 0.0, o.max_simulated_time,
1339 sn.stations[station - 1].name.c_str(), reps);
1340 // The labels come FIRST and the occupancy after, as on the CTMC arm: pi(t)
1341 // is a row per time over columns that mean nothing until the state they
1342 // index is named.
1343 std::printf("%8s %s\n", "State", "Aggregate");
1344 for (std::size_t s = 0; s < states.size(); ++s) {
1345 std::printf("%8zu ", s + 1);
1346 for (std::size_t c = 0; c < states[s].size(); ++c) std::printf(" %g", states[s][c]);
1347 std::printf("\n");
1348 }
1349 std::printf("%14s", "Time");
1350 for (std::size_t s = 0; s < states.size(); ++s) std::printf(" %12zu", s + 1);
1351 std::printf("\n");
1352 for (std::size_t g = 0; g < t.size(); ++g) {
1353 std::printf("%14.8g", t[g]);
1354 for (std::size_t s = 0; s < states.size(); ++s)
1355 std::printf(" %12.6g", s < pit[g].size() ? pit[g][s] : 0.0);
1356 std::printf("\n");
1357 }
1358 return 0;
1359}
1360
1361/**
1362 * `-s jmt -a sample`: `sampleSysAggr`, one simulated trajectory; with `--node`,
1363 * that node's own block (`sampleAggr`) instead.
1364 *
1365 * `--events` NAMES THE TRUNCATION, and it is a truncation rather than a
1366 * stopping rule: JMT's `maxEvents` is global and cannot be asked for a count at
1367 * one node, so what comes back is a prefix of the run the engine produced --
1368 * exactly the caveat `jmt_sample_aggr` carries. `--samples` stands in when
1369 * `--events` is absent, as on the CTMC arm, and neither bounds the engine.
1370 */
1371int solve_model_jmt_sample(const line::qn::NetworkStruct<double>& sn,
1372 const line::jmt::JmtOptions& o, const Knobs& k) {
1373 const std::size_t nevents = k.events ? k.events : 0;
1374 const std::size_t K = sn.nclasses;
1375
1376 if (k.node) {
1377 if (k.node > sn.nodes.size() || sn.nodes[k.node - 1].station == 0)
1378 throw line::InputError(
1379 "--node " + std::to_string(k.node) +
1380 " is not a station, so no per-class job count is logged for it");
1382 line::jmt::jmt_sample_aggr(sn, k.node, nevents, o);
1383 if (g_json_output) {
1384 line::reg::Json p = line::reg::Json::object();
1385 p["type"] = "SamplePath";
1386 p["indexBase"] = 0;
1387 p["scope"] = sn.nodes[k.node - 1].name;
1388 p["node"] = k.node - 1;
1389 p["events"] = nevents;
1390 p["seed"] = o.seed;
1391 p["drawn"] = tr.t.size();
1392 p["t"] = vector_json(tr.t);
1393 line::reg::Json st = line::reg::Json::array();
1394 for (std::size_t j = 0; j < tr.qlen.size(); ++j) st.push_back(vector_json(tr.qlen[j]));
1395 p["state"] = st;
1396 line::reg::Json cols = line::reg::Json::array();
1397 for (std::size_t c = 0; c < K; ++c) cols.push_back(sn.classes[c].name);
1398 p["columns"] = cols;
1399 emit_analysis<double>("sample", p, std::string("jsim"));
1400 return 0;
1401 }
1402 std::printf("SolverJMT arith=double method=jsim\n");
1403 std::printf("%14s %s\n", "Time", ("NodeState (" + sn.nodes[k.node - 1].name +
1404 ", per class)").c_str());
1405 for (std::size_t j = 0; j < tr.t.size(); ++j) {
1406 std::printf("%14.8g ", tr.t[j]);
1407 for (std::size_t c = 0; c < K && c < tr.qlen[j].size(); ++c)
1408 std::printf(" %g", tr.qlen[j][c]);
1409 std::printf("\n");
1410 }
1411 return 0;
1412 }
1413
1415 const std::size_t M = sn.nstations, n = tr.t.size();
1416 if (g_json_output) {
1417 line::reg::Json p = line::reg::Json::object();
1418 p["type"] = "SamplePath";
1419 p["indexBase"] = 0;
1420 p["scope"] = "(system)";
1421 p["events"] = nevents;
1422 p["seed"] = o.seed;
1423 p["drawn"] = n;
1424 p["t"] = vector_json(tr.t);
1425 line::reg::Json st = line::reg::Json::array();
1426 for (std::size_t j = 0; j < n; ++j) {
1427 line::reg::Json row = line::reg::Json::array();
1428 for (std::size_t i = 0; i < M; ++i)
1429 for (std::size_t c = 0; c < K; ++c)
1430 row.push_back(i < tr.state.size() && j < tr.state[i].size() &&
1431 c < tr.state[i][j].size()
1432 ? tr.state[i][j][c]
1433 : 0.0);
1434 st.push_back(row);
1435 }
1436 p["state"] = st;
1437 line::reg::Json cols = line::reg::Json::array();
1438 for (std::size_t i = 0; i < M; ++i)
1439 for (std::size_t c = 0; c < K; ++c)
1440 cols.push_back(sn.stations[i].name + "," + sn.classes[c].name);
1441 p["columns"] = cols;
1442 emit_analysis<double>("sample", p, std::string("jsim"));
1443 return 0;
1444 }
1445 std::printf("SolverJMT arith=double method=jsim\n");
1446 std::printf("%14s %s\n", "Time", "SysState (station-major, per class)");
1447 for (std::size_t j = 0; j < n; ++j) {
1448 std::printf("%14.8g ", tr.t[j]);
1449 for (std::size_t i = 0; i < M; ++i)
1450 for (std::size_t c = 0; c < K; ++c)
1451 std::printf(" %g", i < tr.state.size() && j < tr.state[i].size() &&
1452 c < tr.state[i][j].size()
1453 ? tr.state[i][j][c]
1454 : 0.0);
1455 std::printf("\n");
1456 }
1457 return 0;
1458}
1459
1460/**
1461 * Solve a Network model.json with SolverJMT: write the JMT document, run the
1462 * external engine, print the same table every other solver prints.
1463 *
1464 * DOUBLE ONLY, and refused rather than relabelled under another arithmetic:
1465 * JMT simulates in double and reports in double, so an answer tagged
1466 * `real:128` would name a precision that never touched the computation. The
1467 * same rule the LDES arm applies, for the same reason.
1468 */
1469int solve_model_jmt(const std::string& file, const Knobs& k, const std::string& analysis) {
1470 line::qn::Network<double> net = read_model<double>(file);
1472
1474 if (!k.method.empty() && k.method != "default") o.method = k.method;
1475 if (k.samples > 0) o.samples = static_cast<double>(k.samples);
1476 if (k.seed != 0) o.seed = static_cast<long>(k.seed);
1477 o.keep = k.keep;
1478 if (k.t1 >= 0.0) o.max_simulated_time = k.t1;
1479 // `--jmt-replications` IS THE ENSEMBLE SIZE of the two arms that average over independent
1480 // runs, `options.config.replications`; the gate above accepts it for `-a tran` and `-a tranprob`.
1481 if (k.jmt_replications > 0) o.replications = k.jmt_replications;
1482 // AN EXPLICIT `jsim` IS ONE RUN, not the ensemble, so a count given with it would be dropped.
1483 if (k.jmt_replications > 0 && o.method == "jsim")
1485 "--jmt-replications sizes the transient ensemble of --method default; --method jsim "
1486 "is a single JSIM run, so the count would be ignored rather than honoured");
1487 o.verbose = k.verbose;
1488
1489 if (analysis == "tran") return solve_model_jmt_tran(sn, o, k);
1490 if (analysis == "tranprob") return solve_model_jmt_tranprob(sn, o, k);
1491 if (analysis == "sample") return solve_model_jmt_sample(sn, o, k);
1492
1493 // `-a prob`: `getProbAggr` per station and `getProbSysAggr`, weighed off ONE
1494 // instrumented run. The DETAILED pair -- `getProb` and `getProbSys` -- has
1495 // no counterpart here and is OMITTED rather than aliased to the aggregate:
1496 // a JMT log records per-class job counts at a node and nothing about the
1497 // buffer order or the service phase, so the encoding those two are
1498 // probabilities of is not observed at all.
1499 if (analysis == "prob") {
1500 std::size_t target = 0;
1501 if (k.node) {
1502 if (k.node <= sn.nodes.size()) target = sn.nodes[k.node - 1].station;
1503 if (target == 0)
1504 throw line::InputError(
1505 "--node " + std::to_string(k.node) +
1506 " is not a station, so it holds no per-class job count to ask about");
1507 }
1509 sn, o, target, std::vector<double>(k.state.begin(), k.state.end()));
1510 if (g_json_output) {
1511 line::reg::Json p = line::reg::Json::object();
1512 p["type"] = "ProbAggr";
1513 p["indexBase"] = 0;
1514 p["ProbSysAggr"] = r.sys;
1515 // WHETHER THE STATE OCCURRED AT ALL, beside the number. On an exact
1516 // solver a zero probability is a property of the model; on a
1517 // simulation it is far more often a property of the run length, and
1518 // a caller cannot tell the two apart from the zero alone.
1519 p["SysStateSeen"] = r.sys_seen;
1520 line::reg::Json st = line::reg::Json::array(), pa = line::reg::Json::array(),
1521 sv = line::reg::Json::array();
1522 for (std::size_t i = 0; i < sn.nstations; ++i) {
1523 st.push_back(sn.stations[i].name);
1524 pa.push_back(r.station[i]);
1525 sv.push_back(static_cast<bool>(r.station_seen[i]));
1526 }
1527 p["Station"] = st;
1528 p["ProbAggr"] = pa;
1529 p["StateSeen"] = sv;
1530 // ALWAYS jsim, whatever `--method` asked for: this answer is read
1531 // off a simulated trajectory, and JMVA computes means from a
1532 // product form and logs nothing, so labelling it with the
1533 // requested method would name an engine that never ran.
1534 emit_analysis<double>("prob", p, std::string("jsim"));
1535 return 0;
1536 }
1537 std::printf("SolverJMT arith=double method=jsim\n");
1538 std::printf("ProbSysAggr %.10g%s\n", r.sys, r.sys_seen ? "" : " (state never observed)");
1539 std::printf("%-16s %14s\n", "Station", "ProbAggr");
1540 for (std::size_t i = 0; i < sn.nstations; ++i)
1541 std::printf("%-16s %14.10g%s\n", sn.stations[i].name.c_str(), r.station[i],
1542 r.station_seen[i] ? "" : " (state never observed)");
1543 return 0;
1544 }
1545
1546 if (analysis == "cdf" || analysis == "trancdf" || analysis == "trancdfpasst") {
1547 // -a cdf preloads the rounded steady-state queue lengths, the seeded
1548 // getCdfRespT pipeline; the transient names start from the default
1549 // initial state, getTranCdfRespT's contract
1550 const std::map<std::pair<std::size_t, std::size_t>,
1551 std::vector<std::pair<double, double> > >
1552 rd = line::jmt::jmt_get_cdf_resp_t(sn, o, analysis == "cdf");
1553 line::reg::Json cdf = line::reg::Json::object();
1554 for (std::map<std::pair<std::size_t, std::size_t>,
1555 std::vector<std::pair<double, double> > >::const_iterator it = rd.begin();
1556 it != rd.end(); ++it) {
1557 line::reg::Json rows = line::reg::Json::array();
1558 for (std::size_t i = 0; i < it->second.size(); ++i) {
1559 line::reg::Json row = line::reg::Json::array();
1560 row.push_back(it->second[i].first);
1561 row.push_back(it->second[i].second);
1562 rows.push_back(row);
1563 }
1564 const std::string key =
1565 sn.nodes[sn.station_to_node[it->first.first - 1] - 1].name + "/" +
1566 sn.classes[it->first.second - 1].name;
1567 cdf[key] = rows;
1568 if (!g_json_output)
1569 std::printf("%-24s %8zu points respT(max)=%.6g\n", key.c_str(),
1570 it->second.size(), it->second.back().second);
1571 }
1572 if (g_json_output) {
1573 line::reg::Json out = line::reg::Json::object();
1574 out["cdf"] = cdf;
1575 emit_document(dump_document(out, 2));
1576 }
1577 return 0;
1578 }
1579
1581 std::printf("SolverJMT arith=double method=%s\n", r.avg.actualmethod.c_str());
1582
1583 // The metric matrices carry `nregions` EXTRA rows past the stations; the
1584 // station table takes the first `nstations` of them and the regions are
1585 // reported separately, as the LDES arm reports its own FCR block.
1587 if (table.QN.rows() > sn.nstations) {
1588 const std::size_t M = sn.nstations, K = sn.nclasses;
1589 line::Matrix<double>* dst[6] = {&table.QN, &table.UN, &table.RN,
1590 &table.TN, &table.AN, &table.WN};
1591 const line::Matrix<double>* src[6] = {&r.avg.QN, &r.avg.UN, &r.avg.RN,
1592 &r.avg.TN, &r.avg.AN, &r.avg.WN};
1593 for (int m = 0; m < 6; ++m) {
1594 line::Matrix<double> t(M, K, 0.0);
1595 for (std::size_t i = 0; i < M; ++i)
1596 for (std::size_t c = 0; c < K; ++c) t(i, c) = (*src[m])(i, c);
1597 *dst[m] = t;
1598 }
1599 }
1600 // THE REGION ROWS TRAVEL BESIDE THE STATION TABLE, in the same shape the
1601 // node view prints them: a host solving through `-a avg` has no other way to
1602 // reach them, and MATLAB's `getAvgNodeTable` needs `result.Avg` to carry
1603 // `nstations + nregions` rows or it filters the FCR node out for having no
1604 // numbers at all (fcr_mm1waitq[M2C], 'row FCR1 missing').
1605 line::reg::Json fcr_extra = line::reg::Json::object();
1606 if (r.avg.QN.rows() > sn.nstations && sn.regions.size() > 0) {
1607 const std::size_t M = sn.nstations, K = sn.nclasses;
1608 line::reg::Json names = line::reg::Json::array();
1609 line::reg::Json q = line::reg::Json::array(), u = line::reg::Json::array();
1610 line::reg::Json rt = line::reg::Json::array(), w = line::reg::Json::array();
1611 line::reg::Json a = line::reg::Json::array(), t = line::reg::Json::array();
1612 for (std::size_t f = 0; f < sn.regions.size() && M + f < r.avg.QN.rows(); ++f) {
1613 names.push_back(f < sn.regions.size() && !sn.regions[f].name.empty()
1614 ? sn.regions[f].name
1615 : "FCR" + std::to_string(f + 1));
1616 for (std::size_t c = 0; c < K; ++c) {
1617 q.push_back(r.avg.QN(M + f, c));
1618 u.push_back(r.avg.UN(M + f, c));
1619 rt.push_back(r.avg.RN(M + f, c));
1620 w.push_back(r.avg.WN(M + f, c));
1621 a.push_back(r.avg.AN(M + f, c));
1622 t.push_back(r.avg.TN(M + f, c));
1623 }
1624 }
1625 fcr_extra["Region"] = names;
1626 fcr_extra["QLen"] = q;
1627 fcr_extra["Util"] = u;
1628 fcr_extra["RespT"] = rt;
1629 fcr_extra["ResidT"] = w;
1630 fcr_extra["ArvR"] = a;
1631 fcr_extra["Tput"] = t;
1632 }
1633 line::reg::Json avg_extra = line::reg::Json::object();
1634 if (!fcr_extra.empty()) avg_extra["FCR"] = fcr_extra;
1635 // THE CONFIDENCE INTERVALS ARE PART OF A SIMULATED MEAN. JMT reports a
1636 // half-width per measure at `--confint`, `JmtResult` carries them, and both
1637 // consumers used to take `r.avg` and drop them -- so the one solver here
1638 // whose answer is an estimate was the one quoting a point estimate as if it
1639 // were exact. Same block, same keys and same reason as
1640 // `solve_model_ldes_avg`'s, so one host reader serves both simulators.
1641 line::reg::Json ci = line::reg::Json::object();
1642 if (!r.QCI.empty()) ci["QNCI"] = matrix_json<double>(r.QCI);
1643 if (!r.UCI.empty()) ci["UNCI"] = matrix_json<double>(r.UCI);
1644 if (!r.RCI.empty()) ci["RNCI"] = matrix_json<double>(r.RCI);
1645 if (!r.TCI.empty()) ci["TNCI"] = matrix_json<double>(r.TCI);
1646 if (!r.ACI.empty()) ci["ANCI"] = matrix_json<double>(r.ACI);
1647 if (!ci.empty()) avg_extra["CI"] = ci;
1648 print_avg_table<double>(sn, table, avg_extra);
1649
1650 // The readable rendering prints the half-widths beside the table rather
1651 // than inside it: the columns are the same six every solver prints, and a
1652 // JMVA run (which computes no interval) would leave them blank.
1653 if (!g_json_output && !r.QCI.empty()) {
1654 std::printf("%-16s %-14s %12s %12s %12s %12s %12s\n", "Station", "JobClass", "QLenCI",
1655 "UtilCI", "RespTCI", "TputCI", "ArvRCI");
1656 for (std::size_t i = 0; i < sn.nstations && i < r.QCI.rows(); ++i)
1657 for (std::size_t c = 0; c < sn.nclasses; ++c)
1658 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g\n",
1659 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(), r.QCI(i, c),
1660 r.UCI.empty() ? 0.0 : r.UCI(i, c), r.RCI.empty() ? 0.0 : r.RCI(i, c),
1661 r.TCI.empty() ? 0.0 : r.TCI(i, c),
1662 r.ACI.empty() ? 0.0 : r.ACI(i, c));
1663 }
1664
1665 if (!g_json_output && r.TNfcr.rows() > 0) {
1666 std::printf("%-16s %-14s %12s %12s\n", "Region", "JobClass", "Tput", "DropRate");
1667 for (std::size_t f = 0; f < r.TNfcr.rows(); ++f)
1668 for (std::size_t c = 0; c < sn.nclasses; ++c)
1669 // THE REGION'S DECLARED NAME, not the JSIM document's internal
1670 // `FCRegion<n>` label: that spelling is what the writer puts in
1671 // the exported model, and reporting it back renamed the user's
1672 // own region (`FCR1` in every other codebase's node table).
1673 std::printf("%-16s %-14s %12.6g %12.6g\n",
1674 (f < sn.regions.size() && !sn.regions[f].name.empty()
1675 ? sn.regions[f].name
1676 : "FCR" + std::to_string(f + 1))
1677 .c_str(),
1678 sn.classes[c].name.c_str(), r.TNfcr(f, c), r.DropRateNfcr(f, c));
1679 }
1680 if (!g_json_output && !r.cache_hit_prob.empty())
1681 for (std::map<std::size_t, std::vector<double> >::const_iterator it =
1682 r.cache_hit_prob.begin();
1683 it != r.cache_hit_prob.end(); ++it)
1684 for (std::size_t c = 0; c < it->second.size(); ++c)
1685 std::printf("%-16s %-14s hitProb=%12.6g\n", sn.nodes[it->first - 1].name.c_str(),
1686 sn.classes[c].name.c_str(), it->second[c]);
1687 return 0;
1688}
1689
1690/**
1691 * `-s nc -a avg` under `--method cftp` / `cftp.approx`: the AvgTable from
1692 * perfect sampling.
1693 *
1694 * THE BANNER NAMES THE RUN LENGTH because these numbers carry Monte Carlo error
1695 * and are not comparable to an exact solver's at solver tolerance. The mean
1696 * coalescence horizon travels with it: it is the cost the exact sampler paid,
1697 * has no a-priori bound, and is the one number that says whether the draw was
1698 * cheap or the model is nearly saturated. No `lognormconst=`: the sampler
1699 * yields no normalizing constant.
1700 */
1701template <class T>
1702int solve_nc_cftp_avg(const line::qn::NetworkStruct<T>& sn, const line::nc::NcSolverOptions& opt,
1703 const line::nc::NcCftpOptions& cftpopt) {
1706 "the cftp methods draw random states and form the station balance functions in the "
1707 "log domain, neither of which exists in exact rational arithmetic; rerun with --arith "
1708 "double or --arith real");
1709 } else {
1710 // The run's own gates, in the run's order: the structural predicate
1711 // first, then the declared feature set.
1712 const std::string why = line::nc::solver_nc_cftp_supports(sn);
1713 if (!why.empty()) throw line::UnsupportedError(why);
1715 const line::nc::NcCftpSolution<T> s = line::nc::solver_nc_cftp(sn, opt, cftpopt);
1716 const line::mva::AvgResult<T> r =
1718 double horizon = 0.0;
1719 for (std::size_t i = 0; i < s.horizon.size(); ++i)
1720 horizon += static_cast<double>(s.horizon[i]);
1721 if (!s.horizon.empty()) horizon /= static_cast<double>(s.horizon.size());
1722 std::printf(
1723 "SolverNC arith=%s method=%s type=%s samples=%zu seed=%lu distinct=%zu "
1724 "meanhorizon=%.6g\n",
1726 line::util::method_type("NC", s.actualmethod).c_str(), cftpopt.samples,
1727 cftpopt.seed, s.distinct_states.size(), horizon);
1728 print_avg_table<T>(sn, r);
1729 return 0;
1730 }
1731}
1732
1733/**
1734 * Solve a Network model.json with SolverNC and print the same table.
1735 *
1736 * `--samples` and `--seed` are wired because NC has stochastic methods that read
1737 * them ('mci', 'imci', 'ls', 'is', 'sampling' and 'mcmc'), and a run length nobody
1738 * can set is a method nobody can drive. `highvar` still has no flag and keeps its
1739 * SolverOptions('NC') default rather than being invented here.
1740 */
1741template <class T>
1742int solve_model_nc(const std::string& file, const Knobs& k) {
1743 line::qn::Network<T> net = read_model<T>(file);
1745 if (!k.method.empty() && k.method != "default") opt.method = k.method;
1746 if (k.tol >= 0.0) opt.tol = k.tol;
1747 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
1748 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
1749 if (k.samples) opt.samples = k.samples;
1750 if (k.seed) opt.seed = k.seed;
1751 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
1752 if (!k.fork_join.empty()) opt.fork_join = k.fork_join;
1753 if (k.slotted) opt.slotted = true;
1754 if (k.slotlength > 0.0) {
1755 opt.slotted = true;
1756 opt.slotlength = k.slotlength;
1757 }
1758 // The perfect sampler reports its run length beside the table, so it is
1759 // served by its own arm. The dispatcher has already refused every analysis
1760 // but `avg` for it.
1761 if (opt.method == "cftp" || opt.method == "cftp.approx") {
1763 cftpopt.samples = opt.samples;
1764 cftpopt.seed = opt.seed;
1765 return solve_nc_cftp_avg<T>(net.get_struct(), opt, cftpopt);
1766 }
1767 // The map_env funnel, as on the mva arm above and for the same reason.
1769 if constexpr (std::is_same_v<T, double>) {
1771 net.get_struct(), "SolverNC", line::qn::nc_feature_set(opt.method), map_env_config(k),
1772 [&opt](const line::qn::NetworkStruct<double>& m) {
1773 return line::nc::solver_nc_run_analyzer(m, opt);
1774 },
1776 } else {
1778 }
1779 const line::qn::NetworkStruct<T>& sn = net.get_struct();
1780
1781 // `lognormconst=` rides ON THE BANNER LINE rather than on one of its own,
1782 // as the fluid arm's `iters=` does: the banner is provenance and a new line
1783 // between it and the table is one more thing a table parser has to skip.
1784 // It is `getProbNormConstAggr`, which no other `-a` reports.
1785 std::printf("SolverNC arith=%s method=%s type=%s lognormconst=%.10g\n",
1787 line::util::method_type("NC", r.actualmethod).c_str(),
1788 r.lognormconst.has_value() ? r.lognormconst.value() : 0.0);
1789 print_avg_table<T>(sn, r);
1790 return 0;
1791}
1792
1793/**
1794 * Solve a Network model.json with SolverMAM and print the same table.
1795 *
1796 * DOUBLE ONLY, and refused by name otherwise in the dispatcher: the analyzer
1797 * fits phase-type representations (aph_fit), which static_asserts on
1798 * transcendental arithmetic, so an exact instantiation would fail to COMPILE
1799 * rather than refuse at run time. `MamOptions`' tol, iter_max, space_max and
1800 * preserveDet keep their SolverOptions('MAM') defaults.
1801 */
1802template <class T>
1803int solve_model_mam(const std::string& file, const Knobs& k) {
1804 line::qn::Network<T> net = read_model<T>(file);
1806 if (!k.method.empty() && k.method != "default") opt.method = k.method;
1807 if (k.tol >= 0.0) opt.tol = k.tol;
1808 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
1810 const line::qn::NetworkStruct<T>& sn = net.get_struct();
1811
1812 std::printf("SolverMAM arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
1813 r.actualmethod.c_str(),
1814 line::util::method_type("MAM", r.actualmethod).c_str());
1815 print_avg_table<T>(sn, r);
1816 return 0;
1817}
1818
1819/**
1820 * Solve a Network model.json with SolverAG and print the same table.
1821 *
1822 * The RCAT/INAP arm, reachable from the CLI rather than API-only. It was the
1823 * one engine `run_avg_engine` already served for `-a node` and its three
1824 * sibling views while `-a avg` -- the view every parity row and every wrapper
1825 * asks for first -- refused the token outright, so `-s ag` reported an argument
1826 * error where the solver was present and working.
1827 *
1828 * `--method` picks between inap, inaprc, inapinf and exact; `default`
1829 * resolves to inap inside the analyzer, as it does in every codebase. `--tol`
1830 * and `--iter_max` are the fixed point's, matching -s mam.
1831 */
1832/**
1833 * The AgOptions a command line asks for.
1834 *
1835 * ONE PLACE, because there are three call sites (-a avg, -a cdf and the
1836 * run_avg_engine views) and a knob added to only some of them is a knob that
1837 * works or not depending on which view was asked for.
1838 */
1839void apply_ag_knobs(const Knobs& k, line::ag::AgOptions& opt) {
1840 if (!k.method.empty() && k.method != "default") opt.method = k.method;
1841 if (k.tol >= 0.0) opt.tol = k.tol;
1842 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
1843 if (k.max_states > 0) opt.max_states = static_cast<std::size_t>(k.max_states);
1844}
1845
1846template <class T>
1847int solve_model_ag(const std::string& file, const Knobs& k) {
1848 line::qn::Network<T> net = read_model<T>(file);
1850 apply_ag_knobs(k, opt);
1852 const line::qn::NetworkStruct<T>& sn = net.get_struct();
1853
1854 std::printf("SolverAG arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
1855 r.actualmethod.c_str(),
1856 line::util::method_type("AG", r.actualmethod).c_str());
1857 print_avg_table<T>(sn, r);
1858 return 0;
1859}
1860
1861/**
1862 * The station a per-node MAM query is about: `--node` when given, and otherwise
1863 * the model's only Queue.
1864 *
1865 * Defaulting is legitimate here and nowhere else: `getProb`, `getProbMarg` and
1866 * `getMAMResult` all run `require_single_queue`, so a model that reaches them
1867 * has exactly ONE queue and there is nothing to choose. `--node` is still
1868 * accepted, because naming the node one means is clearer than relying on that.
1869 */
1870template <class T>
1871std::size_t mam_query_node(const line::qn::NetworkStruct<T>& sn, const Knobs& k) {
1872 if (k.node) return k.node;
1873 for (std::size_t a = 0; a < sn.nof_nodes(); ++a)
1874 if (sn.nodes[a].nodetype == line::qn::NodeType::Queue) return a + 1;
1875 throw line::InputError(
1876 "the MAM per-node analyses report a queue's internals and this model has no Queue node");
1877}
1878
1879/** The MamOptions every `-s mam` entry point builds from the CLI knobs. */
1880line::mam::MamOptions mam_options(const Knobs& k) {
1882 if (!k.method.empty() && k.method != "default") opt.method = k.method;
1883 if (k.tol >= 0.0) opt.tol = k.tol;
1884 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
1885 if (k.cutoff >= 0.0) opt.cutoff = static_cast<std::size_t>(k.cutoff);
1886 if (k.fj_accuracy > 0) opt.fj_accuracy = static_cast<std::size_t>(k.fj_accuracy);
1887 if (!k.fj_tmode.empty()) opt.fj_tmode = k.fj_tmode;
1888 if (!k.timescale.empty()) opt.timescale = k.timescale;
1889 if (k.slotlength > 0.0) opt.slotlength = k.slotlength;
1890 if (k.t1 >= 0.0) {
1891 opt.timespan_start = k.t0;
1892 opt.timespan_end = k.t1;
1893 }
1894 return opt;
1895}
1896
1897/**
1898 * `-s mam -a prob`: `@@SolverMAM/getProb` and `@@SolverMAM/getProbMarg`.
1899 *
1900 * BOTH, in one answer, because they are two views of the same queue-length law:
1901 * the joint (level, phase) table the first returns, and the per-class marginal
1902 * P(n jobs of class r) the second does. Reporting only one would leave the other
1903 * unreachable again, which is the state this wiring closes.
1904 *
1905 * `--cutoff` is the level truncation an OPEN model needs -- its queue length is
1906 * unbounded, so the table has to stop somewhere -- and is passed straight
1907 * through as `options.cutoff`; a closed model bounds itself by its population
1908 * and ignores it.
1909 */
1910template <class T>
1911int solve_model_mam_prob(const std::string& file, const Knobs& k) {
1912 line::qn::Network<T> net = read_model<T>(file);
1913 const line::qn::NetworkStruct<T>& sn = net.get_struct();
1914 const line::mam::MamOptions opt = mam_options(k);
1915 // The reference's getters read `self.getAvg()` first, so the solve comes
1916 // before the query and a caller cannot reach them on an unsolved model.
1918 const std::size_t node = mam_query_node<T>(sn, k);
1919 const std::size_t ist = sn.nodes[node - 1].station;
1920 const line::mam::ProbTable<T> P = line::mam::solver_mam_get_prob(sn, opt, node, avg);
1921 std::vector<std::vector<T> > marg(sn.nclasses);
1922 for (std::size_t r = 0; r < sn.nclasses; ++r)
1923 marg[r] = line::mam::solver_mam_get_prob_marg(sn, opt, ist, r + 1, avg);
1924
1925 if (g_json_output) {
1926 line::reg::Json p = line::reg::Json::object();
1927 p["type"] = "ProbTable";
1928 p["indexBase"] = 0;
1929 p["node"] = node - 1;
1930 p["Node"] = sn.nodes[node - 1].name;
1931 p["levels"] = P.P.rows();
1932 p["phases"] = P.P.cols();
1933 line::reg::Json joint = line::reg::Json::array();
1934 for (std::size_t n = 0; n < P.P.rows(); ++n) {
1935 line::reg::Json row = line::reg::Json::array();
1936 for (std::size_t j = 0; j < P.P.cols(); ++j)
1937 row.push_back(line::num_traits<T>::to_double(P.P(n, j)));
1938 joint.push_back(row);
1939 }
1940 p["joint"] = joint;
1941 line::reg::Json mj = line::reg::Json::array();
1942 for (std::size_t r = 0; r < sn.nclasses; ++r) {
1943 line::reg::Json e = line::reg::Json::object();
1944 e["JobClass"] = sn.classes[r].name;
1945 e["jobclass"] = r;
1946 e["P"] = vector_json(marg[r]);
1947 mj.push_back(e);
1948 }
1949 p["marginal"] = mj;
1950 emit_analysis<T>("prob", p, avg.actualmethod);
1951 return 0;
1952 }
1953 std::printf("SolverMAM arith=%s method=%s node=%s levels=%zu phases=%zu\n",
1955 sn.nodes[node - 1].name.c_str(), P.P.rows(), P.P.cols());
1956 std::printf("%-8s %-8s %16s\n", "Level", "Phase", "Prob");
1957 for (std::size_t n = 0; n < P.P.rows(); ++n)
1958 for (std::size_t j = 0; j < P.P.cols(); ++j)
1959 std::printf("%-8zu %-8zu %16.10g\n", n, j + 1,
1961 std::printf("%-14s %-8s %16s\n", "JobClass", "Jobs", "Prob");
1962 for (std::size_t r = 0; r < sn.nclasses; ++r)
1963 for (std::size_t n = 0; n < marg[r].size(); ++n)
1964 std::printf("%-14s %-8zu %16.10g\n", sn.classes[r].name.c_str(), n,
1965 line::num_traits<T>::to_double(marg[r][n]));
1966 return 0;
1967}
1968
1969/**
1970 * The levels `getPerctRespT` is read at: `--percentiles`, or the reference's
1971 * `pers_stored` when the caller named none.
1972 */
1973std::vector<double> percentile_levels(const Knobs& k) {
1974 if (!k.percentiles.empty()) return k.percentiles;
1975 std::vector<double> pcts;
1976 pcts.push_back(0.50);
1977 pcts.push_back(0.90);
1978 pcts.push_back(0.95);
1979 pcts.push_back(0.99);
1980 return pcts;
1981}
1982
1983/**
1984 * `-s mam -a cdf`: `@@SolverMAM/getCdfRespT` (and its aliases getSjrnT / sjrnT),
1985 * with `@@SolverMAM/getPerctRespT` beside it.
1986 *
1987 * THE PERCENTILE LEVELS COME FROM `--percentiles`, defaulting to the
1988 * {0.50, 0.90, 0.95, 0.99} that `solver_mam_fj.m` stores as `pers_stored`: the
1989 * reference takes them as an argument to `getPerctRespT`, and the flag is that
1990 * argument. On the CDF path they are a READING of the same curve --
1991 * `mam_percentiles_from_cdf` inverts the CDF that is printed above them -- so a
1992 * level the caller names costs nothing beyond the inversion.
1993 *
1994 * A FORK-JOIN MODEL HAS NO CDF HERE, and that is the reference's structure
1995 * rather than a gap in this port: `getPerctRespT.m` reads the table
1996 * `solver_mam_fj.m` stored, and its own fallback comment records that
1997 * `getCdfRespT` is "not available for this model". The two are separate methods
1998 * in MATLAB and only this CLI bundles them, so the fork-join case prints the
1999 * percentiles alone and says why. The condition is tested explicitly, not
2000 * discovered by catching the CDF's refusal: a caught exception cannot tell "no
2001 * fork-join route exists" from "the passage-time engine failed on this model".
2002 */
2003template <class T>
2004int solve_model_mam_cdf(const std::string& file, const Knobs& k, const char* key,
2005 const char* type) {
2006 line::qn::Network<T> net = read_model<T>(file);
2007 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2008 const line::mam::MamOptions opt = mam_options(k);
2009 const std::vector<double> pcts = percentile_levels(k);
2010
2011 if (line::mam::mam_has_fj_percentiles(sn, opt)) {
2012 const std::vector<std::vector<T> > perc =
2014 if (g_json_output) {
2015 line::reg::Json p = line::reg::Json::object();
2016 p["type"] = "PerctRespT";
2017 p["indexBase"] = 0;
2018 p["source"] = "fjcodes";
2019 line::reg::Json arr = line::reg::Json::array();
2020 for (std::size_t r = 0; r < perc.size(); ++r) {
2021 line::reg::Json e = line::reg::Json::object();
2022 e["JobClass"] = sn.classes[r].name;
2023 e["jobclass"] = r;
2024 e["percentileLevels"] = pcts;
2025 e["percentiles"] = vector_json(perc[r]);
2026 arr.push_back(e);
2027 }
2028 p["respt"] = arr;
2029 emit_analysis<T>(key, p, opt.method);
2030 return 0;
2031 }
2032 std::printf("SolverMAM arith=%s method=%s classes=%zu\n", line::num_traits<T>::name(),
2033 opt.method.c_str(), sn.nclasses);
2034 std::printf("# the response-time CDF has no fork-join route in the reference; these are "
2035 "the FJ_codes percentiles getPerctRespT reads\n");
2036 std::printf("%-14s %14s %14s\n", "JobClass", "Percentile", "RespT");
2037 for (std::size_t r = 0; r < perc.size(); ++r)
2038 for (std::size_t j = 0; j < perc[r].size(); ++j)
2039 std::printf("%-14s %14.4g %14.10g\n", sn.classes[r].name.c_str(), pcts[j],
2040 line::num_traits<T>::to_double(perc[r][j]));
2041 return 0;
2042 }
2043
2044 const std::vector<line::mam::RespTCdf<T> > rd = line::mam::solver_mam_get_cdf_respt(sn, opt);
2045 std::vector<std::vector<T> > perc;
2046 for (std::size_t r = 0; r < rd.size(); ++r)
2047 perc.push_back(line::mam::mam_percentiles_from_cdf(rd[r], pcts));
2048
2049 if (g_json_output) {
2050 line::reg::Json p = line::reg::Json::object();
2051 p["type"] = type;
2052 p["indexBase"] = 0;
2053 line::reg::Json arr = line::reg::Json::array();
2054 for (std::size_t r = 0; r < rd.size(); ++r) {
2055 // An empty curve is not a degenerate one: the class has no passage
2056 // through a queue here, and it is OMITTED rather than sent as a flat
2057 // zero law.
2058 if (rd[r].X.empty()) continue;
2059 line::reg::Json e = line::reg::Json::object();
2060 e["JobClass"] = sn.classes[r].name;
2061 e["jobclass"] = r;
2062 e["t"] = vector_json(rd[r].X);
2063 e["F"] = vector_json(rd[r].F);
2064 e["percentileLevels"] = pcts;
2065 e["percentiles"] = vector_json(perc[r]);
2066 arr.push_back(e);
2067 }
2068 p["respt"] = arr;
2069 emit_analysis<T>(key, p, opt.method);
2070 return 0;
2071 }
2072 std::printf("SolverMAM arith=%s method=%s classes=%zu\n", line::num_traits<T>::name(),
2073 opt.method.c_str(), sn.nclasses);
2074 std::printf("%-14s %14s %14s\n", "JobClass", "Time", "F(t)");
2075 for (std::size_t r = 0; r < rd.size(); ++r)
2076 for (std::size_t j = 0; j < rd[r].X.size(); ++j)
2077 std::printf("%-14s %14.8g %14.10g\n", sn.classes[r].name.c_str(),
2079 line::num_traits<T>::to_double(rd[r].F[j]));
2080 std::printf("%-14s %14s %14s\n", "JobClass", "Percentile", "RespT");
2081 for (std::size_t r = 0; r < perc.size(); ++r)
2082 for (std::size_t j = 0; j < perc[r].size(); ++j)
2083 std::printf("%-14s %14.4g %14.10g\n", sn.classes[r].name.c_str(), pcts[j],
2084 line::num_traits<T>::to_double(perc[r][j]));
2085 return 0;
2086}
2087
2088/**
2089 * `-s mam -a perct-respt`: `@@SolverMAM/getPerctRespT` ALONE.
2090 *
2091 * The percentiles ride beside the curve under `-a cdf` as well, and this arm
2092 * exists because they are a separate METHOD in the reference and a separate
2093 * answer to a caller: a service-level question ("what is the 99th percentile")
2094 * wants four numbers, not the whole law printed above them. On a fork-join
2095 * model it is the only route -- `getCdfRespT` has none there -- and on every
2096 * other model the levels are inverted from the CDF the other arm prints, so
2097 * the two never disagree.
2098 */
2099template <class T>
2100int solve_model_mam_perct(const std::string& file, const Knobs& k) {
2101 line::qn::Network<T> net = read_model<T>(file);
2102 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2103 const line::mam::MamOptions opt = mam_options(k);
2104 const std::vector<double> pcts = percentile_levels(k);
2105
2106 std::vector<std::vector<T> > perc;
2107 const bool fj = line::mam::mam_has_fj_percentiles(sn, opt);
2108 if (fj) {
2109 perc = line::mam::solver_mam_get_perct_respt(sn, opt, pcts);
2110 } else {
2111 const std::vector<line::mam::RespTCdf<T> > rd =
2113 for (std::size_t r = 0; r < rd.size(); ++r)
2114 perc.push_back(line::mam::mam_percentiles_from_cdf(rd[r], pcts));
2115 }
2116
2117 if (g_json_output) {
2118 line::reg::Json p = line::reg::Json::object();
2119 p["type"] = "PerctRespT";
2120 p["indexBase"] = 0;
2121 p["source"] = fj ? "fjcodes" : "cdf";
2122 line::reg::Json arr = line::reg::Json::array();
2123 for (std::size_t r = 0; r < perc.size(); ++r) {
2124 if (perc[r].empty()) continue;
2125 line::reg::Json e = line::reg::Json::object();
2126 e["JobClass"] = sn.classes[r].name;
2127 e["jobclass"] = r;
2128 e["percentileLevels"] = pcts;
2129 e["percentiles"] = vector_json(perc[r]);
2130 arr.push_back(e);
2131 }
2132 p["respt"] = arr;
2133 emit_analysis<T>("perct", p, opt.method);
2134 return 0;
2135 }
2136 std::printf("SolverMAM arith=%s method=%s classes=%zu source=%s\n",
2137 line::num_traits<T>::name(), opt.method.c_str(), sn.nclasses,
2138 fj ? "fjcodes" : "cdf");
2139 std::printf("%-14s %14s %14s\n", "JobClass", "Percentile", "RespT");
2140 for (std::size_t r = 0; r < perc.size(); ++r)
2141 for (std::size_t j = 0; j < perc[r].size(); ++j)
2142 std::printf("%-14s %14.4g %14.10g\n", sn.classes[r].name.c_str(), pcts[j],
2143 line::num_traits<T>::to_double(perc[r][j]));
2144 return 0;
2145}
2146
2147/**
2148 * `-s mam -a tran`: `@@SolverMAM/getTranAvg`.
2149 *
2150 * The reference FORCES `options.method = 'ldqbd'` before delegating, so the
2151 * transient path is not the caller's method and `--method` does not select it;
2152 * which engine runs -- the Laplace-domain transient QBD or the QBD fast path --
2153 * is decided from the model by `mam_transient_qbd_applicable`. `--tspan` is
2154 * required: a transient curve on an unstated horizon is not a quantity.
2155 */
2156template <class T>
2157int solve_model_mam_tran(const std::string& file, const Knobs& k) {
2158 line::qn::Network<T> net = read_model<T>(file);
2159 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2160 const line::mam::MamOptions opt = mam_options(k);
2162
2163 if (g_json_output) {
2164 line::reg::Json p = line::reg::Json::object();
2165 p["type"] = "TranAvgTable";
2166 p["indexBase"] = 0;
2167 p["t0"] = opt.timespan_start;
2168 p["t1"] = opt.timespan_end;
2169 line::reg::Json arr = line::reg::Json::array();
2170 for (std::size_t i = 0; i < tr.Qt.size(); ++i)
2171 for (std::size_t r = 0; r < tr.Qt[i].size(); ++r) {
2172 if (tr.Qt[i][r].times.empty()) continue;
2173 line::reg::Json e = line::reg::Json::object();
2174 e["Station"] = sn.stations[i].name;
2175 e["JobClass"] = sn.classes[r].name;
2176 e["station"] = i;
2177 e["jobclass"] = r;
2178 e["t"] = tr.Qt[i][r].times;
2179 e["QLen"] = vector_json(tr.Qt[i][r].values);
2180 e["Util"] = vector_json(tr.Ut[i][r].values);
2181 e["Tput"] = vector_json(tr.Tt[i][r].values);
2182 arr.push_back(e);
2183 }
2184 p["curves"] = arr;
2185 emit_analysis<T>("tran", p, "ldqbd");
2186 return 0;
2187 }
2188 std::printf("SolverMAM arith=%s method=ldqbd tspan=[%g,%g]\n", line::num_traits<T>::name(),
2189 opt.timespan_start, opt.timespan_end);
2190 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "Time", "QLen", "Util",
2191 "Tput");
2192 for (std::size_t i = 0; i < tr.Qt.size(); ++i)
2193 for (std::size_t r = 0; r < tr.Qt[i].size(); ++r)
2194 for (std::size_t j = 0; j < tr.Qt[i][r].times.size(); ++j)
2195 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n",
2196 sn.stations[i].name.c_str(), sn.classes[r].name.c_str(),
2197 tr.Qt[i][r].times[j],
2198 line::num_traits<T>::to_double(tr.Qt[i][r].values[j]),
2199 line::num_traits<T>::to_double(tr.Ut[i][r].values[j]),
2200 line::num_traits<T>::to_double(tr.Tt[i][r].values[j]));
2201 return 0;
2202}
2203
2204/**
2205 * `-s mam -a internals`: `@@SolverMAM/getMAMResult`, the M/G/1-type internals of
2206 * a single queue.
2207 *
2208 * It is NOT a metric table and is deliberately not folded into `-a avg`: the
2209 * answer is the matrix-analytic machinery itself -- the randomized blocks, the
2210 * G matrix, the drift, the decay rate, the level probabilities -- which is what
2211 * a caller checking a queue's stability or tail decay asks for, and what a
2212 * cross-codebase comparison of the QBD assembly needs to see.
2213 */
2214template <class T>
2215int solve_model_mam_internals(const std::string& file, const Knobs& k) {
2216 line::qn::Network<T> net = read_model<T>(file);
2217 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2218 (void)k;
2220 const double q = line::num_traits<T>::to_double(r.q);
2221
2222 if (g_json_output) {
2223 line::reg::Json p = line::reg::Json::object();
2224 p["type"] = "MAMResult";
2225 p["indexBase"] = 0;
2226 p["lambda"] = line::num_traits<T>::to_double(r.lambda);
2227 p["rho"] = line::num_traits<T>::to_double(r.rho);
2228 p["uniformization"] = q;
2229 p["drift"] = line::num_traits<T>::to_double(r.drift);
2230 p["decayRate"] = r.decayRate;
2231 p["pi0"] = line::num_traits<T>::to_double(r.pi0);
2235 p["truncLevel"] = r.truncLevel;
2236 p["truncError"] = r.truncError;
2237 p["gConverged"] = r.gConverged;
2238 p["theta"] = vector_json(r.theta);
2239 p["alpha"] = vector_json(r.alpha);
2240 line::reg::Json lv = line::reg::Json::array();
2241 for (std::size_t n = 0; n < r.levelProb.rows(); ++n) {
2242 line::reg::Json row = line::reg::Json::array();
2243 for (std::size_t j = 0; j < r.levelProb.cols(); ++j)
2244 row.push_back(line::num_traits<T>::to_double(r.levelProb(n, j)));
2245 lv.push_back(row);
2246 }
2247 p["levelProb"] = lv;
2248 emit_analysis<T>("internals", p, std::string());
2249 return 0;
2250 }
2251 std::printf("SolverMAM arith=%s stations=%zu\n", line::num_traits<T>::name(), sn.nstations);
2252 std::printf("lambda=%.10g rho=%.10g q=%.10g drift=%.10g decayRate=%.10g\n",
2255 std::printf("pi0=%.10g QLen=%.10g Util=%.10g Tput=%.10g truncLevel=%zu truncError=%.3g "
2256 "gConverged=%s\n",
2261 r.gConverged ? "yes" : "no");
2262 std::printf("%-8s %16s\n", "Level", "Prob");
2263 for (std::size_t n = 0; n < r.levelProb.rows(); ++n) {
2264 double s = 0.0;
2265 for (std::size_t j = 0; j < r.levelProb.cols(); ++j)
2267 std::printf("%-8zu %16.10g\n", n, s);
2268 }
2269 return 0;
2270}
2271
2272/**
2273 * Solve a Network model.json with SolverBA and print the same table.
2274 *
2275 * A BOUND, not an estimate: `--method` names which side of which hierarchy is
2276 * wanted (`aba.upper`, `gb.lower`, ...) and `default` resolves to `gb.upper`,
2277 * as in the reference. `options.level` keeps its default of 2. The table is
2278 * therefore not comparable with an exact solver's row except as a bracket,
2279 * which is why the parity row is separate.
2280 */
2281/** A JSON flag value, given inline or as the path of a file holding it. */
2282line::reg::Json read_json_arg(const std::string& spec, const char* flag) {
2283 std::string text = spec;
2284 const std::size_t at = spec.find_first_not_of(" \t\r\n");
2285 if (at == std::string::npos || (spec[at] != '{' && spec[at] != '[')) {
2286 std::ifstream in(spec.c_str());
2287 if (!in)
2288 throw line::InputError(std::string(flag) + " is neither inline JSON nor a readable "
2289 "file (got '" + spec + "')");
2290 text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
2291 }
2292 try {
2293 return line::reg::Json::parse(text);
2294 } catch (const line::reg::Json::parse_error& e) {
2295 throw line::InputError(std::string("malformed ") + flag + " JSON: " + e.what());
2296 }
2297}
2298
2299/** A JSON array of arrays as an integer table. */
2300std::vector<std::vector<int> > json_int_table(const line::reg::Json& j, const char* name) {
2301 if (!j.is_array())
2302 throw line::InputError(std::string("--qrf-params ") + name + " must be an array of rows");
2303 std::vector<std::vector<int> > out;
2304 for (std::size_t m = 0; m < j.size(); ++m) {
2305 if (!j[m].is_array())
2306 throw line::InputError(std::string("--qrf-params ") + name + " must be an array of "
2307 "rows");
2308 std::vector<int> row;
2309 for (std::size_t c = 0; c < j[m].size(); ++c) row.push_back(j[m][c].get<int>());
2310 out.push_back(row);
2311 }
2312 return out;
2313}
2314
2315/**
2316 * Decode `--qrf-params` into `BaOptions::qrf_params`.
2317 *
2318 * Required: f, MR, BB, MM, MM1, ZZ, exactly what `sn_to_qrf_params` demands.
2319 * F is optional and falls back to sn.cap. ZM is DERIVED from ZZ and a supplied
2320 * one is ignored: it is max(ZZ) by definition, and a larger one empties the
2321 * polytope through THM3I instead of failing cleanly.
2322 */
2323void decode_qrf_params(const std::string& spec, line::ba::BaOptions& opt) {
2324 const line::reg::Json j = read_json_arg(spec, "--qrf-params");
2325 if (!j.is_object()) throw line::InputError("--qrf-params must be a JSON object");
2326 const char* required[] = {"f", "MR", "BB", "MM", "MM1", "ZZ"};
2327 std::string missing;
2328 for (std::size_t i = 0; i < 6; ++i)
2329 if (!j.contains(required[i]))
2330 missing += (missing.empty() ? "" : ", ") + std::string(required[i]);
2331 if (!missing.empty())
2332 throw line::InputError("--qrf-params is missing the field(s) " + missing +
2333 "; required are f, MR, BB, MM, MM1, ZZ (F is optional, ZM is "
2334 "derived from ZZ)");
2336 p.supplied = true;
2337 p.f = j["f"].get<int>();
2338 p.MR = j["MR"].get<int>();
2339 p.BB = json_int_table(j["BB"], "BB");
2340 p.MM = json_int_table(j["MM"], "MM");
2341 p.MM1 = json_int_table(j["MM1"], "MM1");
2342 if (!j["ZZ"].is_array()) throw line::InputError("--qrf-params ZZ must be an array");
2343 for (std::size_t i = 0; i < j["ZZ"].size(); ++i) p.ZZ.push_back(j["ZZ"][i].get<int>());
2344 if (j.contains("F"))
2345 for (std::size_t i = 0; i < j["F"].size(); ++i) p.F.push_back(j["F"][i].get<int>());
2346 opt.qrf_params = p;
2347}
2348
2349/** Decode `--qrf-alpha`, the (nstations x N) load-dependent scaling. */
2350void decode_qrf_alpha(const std::string& spec, line::ba::BaOptions& opt) {
2351 const line::reg::Json j = read_json_arg(spec, "--qrf-alpha");
2352 if (!j.is_array() || j.empty() || !j[0].is_array())
2353 throw line::InputError("--qrf-alpha must be a JSON array of rows, one per station");
2354 line::Matrix<double> a(j.size(), j[0].size(), 0.0);
2355 for (std::size_t i = 0; i < j.size(); ++i) {
2356 if (!j[i].is_array() || j[i].size() != j[0].size())
2357 throw line::InputError("--qrf-alpha rows must all have the same length");
2358 for (std::size_t n = 0; n < j[i].size(); ++n) a(i, n) = j[i][n].get<double>();
2359 }
2360 opt.qrf_alpha = a;
2361}
2362
2363/** The knobs the two SolverBA arms share. */
2364void apply_ba_knobs(const Knobs& k, line::ba::BaOptions& opt) {
2365 if (!k.method.empty() && k.method != "default") opt.method = k.method;
2366 if (k.level > 0) opt.level = k.level;
2367 if (!k.qrf_params.empty()) decode_qrf_params(k.qrf_params, opt);
2368 if (!k.qrf_alpha.empty()) decode_qrf_alpha(k.qrf_alpha, opt);
2369}
2370
2371template <class T>
2372int solve_model_ba(const std::string& file, const Knobs& k) {
2373 line::qn::Network<T> net = read_model<T>(file);
2375 apply_ba_knobs(k, opt);
2377 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2378
2379 std::printf("SolverBA arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
2380 r.actualmethod.c_str(),
2381 line::util::method_type("BA", r.actualmethod).c_str());
2382 print_avg_table<T>(sn, r);
2383 return 0;
2384}
2385
2386/**
2387 * `-s ba -a bounds`: `SolverBA.getBounds` and its `getBoundsTable` row filter.
2388 *
2389 * NOT THE SAME ANSWER AS `-a avg`, which is why it is its own analysis. `-a avg`
2390 * reports ONE side of ONE family -- whichever `--method` named -- so a caller who
2391 * wants the bracket has to run the solver twice and know which two method names
2392 * pair up. `getBounds` takes the family (the method's prefix before the first
2393 * dot) and re-runs both sides under the caller's FULL option set, so a
2394 * hierarchical family tightens with `--level` as it should.
2395 *
2396 * A ONE-SIDED FAMILY REPORTS NaN ON THE SIDE IT LACKS, never zero: `cub` is
2397 * upper-only and `mbjb`/`ldac` are lower-only, and a zero there would read as
2398 * a lower bound of zero rather than as the absence of one.
2399 */
2400template <class T>
2401int solve_model_ba_bounds(const std::string& file, const Knobs& k) {
2402 line::qn::Network<T> net = read_model<T>(file);
2404 apply_ba_knobs(k, opt);
2405 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2406 const line::ba::BaBounds<T> b = line::ba::ba_bounds(sn, opt);
2407 const std::string am = line::ba::resolve_method(opt.method);
2408
2409 auto d = [](const T& v) { return line::num_traits<T>::to_double(v); };
2410 if (g_json_output) {
2411 line::reg::Json p = line::reg::Json::object();
2412 p["type"] = "BoundsTable";
2413 p["indexBase"] = 0;
2414 p["family"] = am.substr(0, am.find('.'));
2415 p["hasLower"] = b.has_lower;
2416 p["hasUpper"] = b.has_upper;
2417 for (const char* key : {"Station", "JobClass", "QLower", "QUpper", "TLower", "TUpper"})
2418 p[key] = line::reg::Json::array();
2419 for (std::size_t i = 0; i < sn.nstations; ++i)
2420 for (std::size_t c = 0; c < sn.nclasses; ++c) {
2421 if (!b.keep[i][c]) continue;
2422 p["Station"].push_back(sn.stations[i].name);
2423 p["JobClass"].push_back(sn.classes[c].name);
2424 p["QLower"].push_back(d(b.Qlower(i, c)));
2425 p["QUpper"].push_back(d(b.Qupper(i, c)));
2426 p["TLower"].push_back(d(b.Tlower(i, c)));
2427 p["TUpper"].push_back(d(b.Tupper(i, c)));
2428 }
2429 emit_analysis<T>("bounds", p, am);
2430 return 0;
2431 }
2432 std::printf("SolverBA arith=%s method=%s type=%s family=%s sides=%s\n",
2433 line::num_traits<T>::name(), am.c_str(),
2434 line::util::method_type("BA", am).c_str(), am.substr(0, am.find('.')).c_str(),
2435 b.has_lower ? (b.has_upper ? "lower,upper" : "lower") : "upper");
2436 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "QLower", "QUpper",
2437 "TLower", "TUpper");
2438 for (std::size_t i = 0; i < sn.nstations; ++i)
2439 for (std::size_t c = 0; c < sn.nclasses; ++c) {
2440 if (!b.keep[i][c]) continue;
2441 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n", sn.stations[i].name.c_str(),
2442 sn.classes[c].name.c_str(), d(b.Qlower(i, c)), d(b.Qupper(i, c)),
2443 d(b.Tlower(i, c)), d(b.Tupper(i, c)));
2444 }
2445 return 0;
2446}
2447
2448/**
2449 * `-s lqns` on a flat Network: its qns methods, the model handed to the external
2450 * `qnsolver` binary (or, closed and not product-form, through QN2LQN to lqns).
2451 *
2452 * The only wrapper on the model-solving path. The banner names it separately
2453 * from the table so a reader can tell an independent tool's numbers from the
2454 * port's own -- which is the whole reason the wrapper exists.
2455 */
2456template <class T>
2457int solve_model_lqns_network(const std::string& file, const Knobs& k) {
2458 line::qn::Network<T> net = read_model<T>(file);
2460 if (!k.method.empty()) opt.method = k.method;
2461 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
2462 // --samples is deliberately NOT forwarded: `options.samples` reaches the
2463 // JMVA document as `maxSamples`, which is JMT's Monte Carlo cap and which
2464 // qnsolver reads past. The shared knob ladder below refuses it for every
2465 // solver that draws nothing, and this path is one of them.
2466 opt.timeout = k.timeout_seconds;
2467 opt.keep = k.keep;
2469 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2470
2471 if (!g_json_output)
2472 std::printf("SolverLQNS arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
2473 r.actualmethod.c_str(),
2474 line::util::method_type("LQNS", r.actualmethod).c_str());
2475 print_avg_table<T>(sn, r);
2476 return 0;
2477}
2478
2479/**
2480 * Emit the base-class exponential response-time CDF fallback, the same
2481 * `CdfRespT` document every real distributional arm emits.
2482 *
2483 * This is `@@NetworkSolver/getCdfRespT.m`: the solvers without a
2484 * distributional result of their own (MVA, LQNS, BA, AG) inherit an exponential
2485 * law with the right mean in the reference, and refusing `-a cdf` for them
2486 * diverged from it. The curve says nothing about the tail; the banner names
2487 * the solver so the caller knows which mean it wraps.
2488 */
2489template <class T>
2490int emit_default_cdf(const char* solver_name, const line::qn::NetworkStruct<T>& sn,
2491 const line::mva::AvgResult<T>& r) {
2492 const std::vector<std::vector<line::solvers::DefaultCdfCurve>> RD =
2494 if (g_json_output) {
2495 line::reg::Json p = line::reg::Json::object();
2496 p["type"] = "CdfRespT";
2497 p["indexBase"] = 0;
2498 line::reg::Json arr = line::reg::Json::array();
2499 for (std::size_t i = 0; i < RD.size(); ++i)
2500 for (std::size_t c = 0; c < RD[i].size(); ++c) {
2501 if (RD[i][c].t.empty()) continue;
2502 line::reg::Json e = line::reg::Json::object();
2503 e["Station"] = sn.stations[i].name;
2504 e["JobClass"] = sn.classes[c].name;
2505 e["station"] = i;
2506 e["jobclass"] = c;
2507 e["t"] = line::reg::Json(RD[i][c].t);
2508 e["F"] = line::reg::Json(RD[i][c].F);
2509 arr.push_back(e);
2510 }
2511 p["respt"] = arr;
2512 emit_analysis<T>("cdf", p, std::string());
2513 return 0;
2514 }
2515 std::printf("%s arith=%s method=%s (exponential fallback with the solver's mean)\n",
2516 solver_name, line::num_traits<T>::name(), r.actualmethod.c_str());
2517 std::printf("%-16s %-14s %14s %14s\n", "Station", "JobClass", "Time", "F(t)");
2518 for (std::size_t i = 0; i < RD.size(); ++i)
2519 for (std::size_t c = 0; c < RD[i].size(); ++c)
2520 for (std::size_t j = 0; j < RD[i][c].t.size(); ++j)
2521 std::printf("%-16s %-14s %14.8g %14.10g\n", sn.stations[i].name.c_str(),
2522 sn.classes[c].name.c_str(), RD[i][c].t[j], RD[i][c].F[j]);
2523 return 0;
2524}
2525
2526/** `-s mva -a cdf`: the inherited exponential fallback over the MVA means. */
2527template <class T>
2528int solve_model_mva_cdf(const std::string& file, const Knobs& k) {
2529 line::qn::Network<T> net = read_model<T>(file);
2531 if (!k.method.empty() && k.method != "default") opt.method = k.method;
2532 if (k.tol >= 0.0) opt.tol = k.tol;
2533 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
2534 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
2535 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
2536 if (!k.fork_join.empty()) opt.fork_join = k.fork_join;
2537 line::Matrix<T> init;
2539 return emit_default_cdf<T>("SolverMVA", net.get_struct(), r);
2540}
2541
2542/** `-s ag -a cdf`: the inherited exponential fallback over the RCAT means. */
2543template <class T>
2544int solve_model_ag_cdf(const std::string& file, const Knobs& k) {
2545 line::qn::Network<T> net = read_model<T>(file);
2547 apply_ag_knobs(k, opt);
2549 return emit_default_cdf<T>("SolverAG", net.get_struct(), r);
2550}
2551
2552/** `-s ba -a cdf`: the inherited exponential fallback over the bound means. */
2553template <class T>
2554int solve_model_ba_cdf(const std::string& file, const Knobs& k) {
2555 line::qn::Network<T> net = read_model<T>(file);
2557 apply_ba_knobs(k, opt);
2559 return emit_default_cdf<T>("SolverBA", net.get_struct(), r);
2560}
2561
2562/** `-s lqns -a cdf` on a flat Network: the inherited exponential fallback over qnsolver's means. */
2563int solve_model_lqns_network_cdf(const std::string& file, const Knobs& k) {
2564 line::qn::Network<double> net = read_model<double>(file);
2566 if (!k.method.empty()) opt.method = k.method;
2567 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
2568 opt.timeout = k.timeout_seconds;
2569 opt.keep = k.keep;
2572 return emit_default_cdf<double>("SolverLQNS", net.get_struct(), r);
2573}
2574
2575
2576/**
2577 * `-a interval`: the support-only range, the reference's `getIntervalTable`.
2578 *
2579 * A SEPARATE FUNCTION because it is printed before the ensemble exists: on the
2580 * exact path there is no design to solve, so the interval arrives without a
2581 * `UqSolution` beside it.
2582 */
2583template <class T>
2584int print_uq_interval(const line::qn::NetworkStruct<T>& sn, const line::uq::UqInterval<T>& iv,
2585 const std::string& stage, std::size_t npriors) {
2586 auto v = [](const line::Matrix<T>& M, std::size_t i, std::size_t c) {
2587 return M.empty() ? 0.0 : line::num_traits<T>::to_double(M(i, c));
2588 };
2589 line::reg::Json p = line::reg::Json::object();
2590 p["type"] = "IntervalTable";
2591 p["indexBase"] = 0;
2592 p["exact"] = iv.exact;
2593 p["intervalMethod"] = iv.method;
2594 if (!iv.why.empty()) p["why"] = iv.why;
2595 if (iv.has_totals) {
2596 p["X"] = {line::num_traits<T>::to_double(iv.Xlo),
2598 p["Rtot"] = {line::num_traits<T>::to_double(iv.Rtot_lo),
2600 }
2601 for (const char* key : {"Station", "JobClass", "QLen_lo", "QLen_up", "Util_lo", "Util_up",
2602 "RespT_lo", "RespT_up", "Tput_lo", "Tput_up"})
2603 p[key] = line::reg::Json::array();
2604 if (!g_json_output) {
2605 std::printf("SolverUQ arith=%s interval=%s exact=%s priors=%zu stage=%s\n",
2606 line::num_traits<T>::name(), iv.method.c_str(), iv.exact ? "yes" : "no",
2607 npriors, stage.c_str());
2608 // A RANGE THAT IS NOT AN ENCLOSURE MUST SAY SO. The sampled path
2609 // spans the design points only, so on a continuous Prior it lies
2610 // strictly inside the true range; printing it beside an exact hull
2611 // without the reason would make the two indistinguishable.
2612 if (!iv.exact)
2613 std::fprintf(stderr,
2614 "Warning: exact interval MVA does not apply (%s); the range below is "
2615 "over the solved design points and is not an enclosure.\n",
2616 iv.why.c_str());
2617 std::printf("%-16s %-14s %12s %12s %12s %12s %12s %12s %12s %12s\n", "Station",
2618 "JobClass", "QLen_lo", "QLen_up", "Util_lo", "Util_up", "RespT_lo",
2619 "RespT_up", "Tput_lo", "Tput_up");
2620 }
2621 for (std::size_t i = 0; i < sn.nstations; ++i)
2622 for (std::size_t c = 0; c < sn.nclasses; ++c) {
2623 // The reference's row filter: an upper endpoint of zero on every
2624 // presence metric means the class never visits the station.
2625 if (v(iv.Qup, i, c) <= 0.0 && v(iv.Uup, i, c) <= 0.0 && v(iv.Tup, i, c) <= 0.0)
2626 continue;
2627 if (!g_json_output) {
2628 std::printf(
2629 "%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g\n",
2630 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(), v(iv.Qlo, i, c),
2631 v(iv.Qup, i, c), v(iv.Ulo, i, c), v(iv.Uup, i, c), v(iv.Rlo, i, c),
2632 v(iv.Rup, i, c), v(iv.Tlo, i, c), v(iv.Tup, i, c));
2633 continue;
2634 }
2635 p["Station"].push_back(sn.stations[i].name);
2636 p["JobClass"].push_back(sn.classes[c].name);
2637 p["QLen_lo"].push_back(v(iv.Qlo, i, c));
2638 p["QLen_up"].push_back(v(iv.Qup, i, c));
2639 p["Util_lo"].push_back(v(iv.Ulo, i, c));
2640 p["Util_up"].push_back(v(iv.Uup, i, c));
2641 p["RespT_lo"].push_back(v(iv.Rlo, i, c));
2642 p["RespT_up"].push_back(v(iv.Rup, i, c));
2643 p["Tput_lo"].push_back(v(iv.Tlo, i, c));
2644 p["Tput_up"].push_back(v(iv.Tup, i, c));
2645 }
2646 if (g_json_output) emit_analysis<T>("interval", p, iv.method);
2647 return 0;
2648}
2649
2650/**
2651 * Solve a Network model.json carrying a Prior with SolverUQ.
2652 *
2653 * TWO FLAGS MEAN SOMETHING ELSE HERE, and both are UQ's own rather than the
2654 * stage solver's: `--method` names the DESIGN (quad or mc, the
2655 * reference's `options.method`), and `--samples` the number of nodes per
2656 * continuous Prior (`options.samples`, 11 by default and not the simulation
2657 * default, since each node is a full solver run). The engine that runs at each
2658 * point is `--uq-solver`, and it keeps its own defaults for everything except
2659 * the convergence knobs, which UQ does not have and therefore passes through.
2660 * A caller who wants an SSA run length AND a UQ design cannot state both, so
2661 * the SSA stage keeps its default sample count; that is stated in --help rather
2662 * than resolved by giving one flag two meanings.
2663 *
2664 * `-a posterior` prints the design itself -- every point, its weight and its
2665 * metrics -- because the expectation alone hides whether it averaged two nearby
2666 * models or two wildly different ones, and that spread IS the answer to an
2667 * uncertainty question.
2668 */
2669template <class T>
2670int solve_model_uq(const std::string& file, const Knobs& k, const std::string& analysis) {
2671 line::qn::Network<T> net = read_model<T>(file);
2673 if (!k.method.empty()) opt.method = k.method;
2674 if (k.samples) opt.samples = k.samples;
2675 if (k.seed) opt.seed = k.seed;
2677 so.solver = k.uq_solver;
2678 so.tol = k.tol;
2679 so.iter_tol = k.iter_tol;
2680 so.iter_max = k.iter_max;
2681 so.cutoff = k.cutoff;
2682
2683 if (analysis == "interval") {
2684 // BEFORE THE ENSEMBLE, because the exact path does not need one: it is
2685 // 2*(m+2) MVA calls over the demand box, and the reference's
2686 // `getInterval` likewise reaches `intervalByMVA` without touching
2687 // `self.results`. Only the sampling fallback solves the design.
2688 const line::uq::UqInterval<T> iv =
2690 const line::qn::NetworkStruct<T>& isn = net.get_struct();
2691 return print_uq_interval<T>(isn, iv, so.solver, line::uq::uq_detect_priors(isn).size());
2692 }
2693
2694 const line::uq::UqSolution<T> r =
2696 const line::qn::NetworkStruct<T>& sn = net.get_struct();
2697
2698 if (analysis == "avg") {
2699 if (!g_json_output)
2700 std::printf("SolverUQ arith=%s design=%s points=%zu priors=%zu stage=%s method=%s\n",
2701 line::num_traits<T>::name(), r.method.c_str(), r.points.size(),
2702 r.sites.size(), so.solver.c_str(), r.avg.actualmethod.c_str());
2703 print_avg_table<T>(sn, r.avg);
2704 return 0;
2705 }
2706
2707 // -a posterior: the per-design-point table, the reference's getPosteriorTable.
2708 line::reg::Json p = line::reg::Json::object();
2709 p["type"] = "PosteriorTable";
2710 p["indexBase"] = 0;
2711 p["design"] = r.method;
2712 p["stage"] = so.solver;
2713 line::reg::Json sites = line::reg::Json::array();
2714 for (std::size_t l = 0; l < r.sites.size(); ++l) {
2715 line::reg::Json s = line::reg::Json::object();
2716 s["node"] = sn.nodes[r.sites[l].node - 1].name;
2717 s["class"] = sn.classes[r.sites[l].cls - 1].name;
2718 s["kind"] = r.sites[l].arrival ? "arrival" : "service";
2719 sites.push_back(s);
2720 }
2721 p["priors"] = sites;
2722 for (const char* key : {"Point", "Weight", "Station", "JobClass", "QLen", "Util", "RespT",
2723 "Tput"})
2724 p[key] = line::reg::Json::array();
2725 line::reg::Json means = line::reg::Json::array();
2726
2727 if (!g_json_output) {
2728 std::printf("SolverUQ arith=%s design=%s points=%zu priors=%zu stage=%s\n",
2729 line::num_traits<T>::name(), r.method.c_str(), r.points.size(), r.sites.size(),
2730 so.solver.c_str());
2731 // THE SUBSTITUTED MEANS ARE PROVENANCE, not decoration: a design point
2732 // is a model, and this line is what says which one.
2733 std::printf("%-6s %12s substituted means\n", "Point", "Weight");
2734 for (std::size_t e = 0; e < r.points.size(); ++e) {
2735 std::printf("%-6zu %12.6g ", e + 1, line::num_traits<T>::to_double(r.weights[e]));
2736 for (std::size_t l = 0; l < r.sites.size(); ++l)
2737 std::printf(" %s@%s=%.6g", sn.nodes[r.sites[l].node - 1].name.c_str(),
2738 sn.classes[r.sites[l].cls - 1].name.c_str(),
2739 line::num_traits<T>::to_double(r.design[e].dists[l].mean));
2740 std::printf("\n");
2741 }
2742 std::printf("%-6s %12s %-16s %-14s %12s %12s %12s %12s\n", "Point", "Weight", "Station",
2743 "JobClass", "QLen", "Util", "RespT", "Tput");
2744 }
2745 for (std::size_t e = 0; e < r.points.size(); ++e) {
2746 line::reg::Json row = line::reg::Json::array();
2747 for (std::size_t l = 0; l < r.sites.size(); ++l)
2748 row.push_back(line::num_traits<T>::to_double(r.design[e].dists[l].mean));
2749 means.push_back(row);
2750 const line::mva::AvgResult<T>& a = r.points[e];
2751 for (std::size_t i = 0; i < sn.nstations; ++i)
2752 for (std::size_t c = 0; c < sn.nclasses; ++c) {
2753 const double q = line::num_traits<T>::to_double(a.QN(i, c));
2754 const double u = line::num_traits<T>::to_double(a.UN(i, c));
2755 const double rr = line::num_traits<T>::to_double(a.RN(i, c));
2756 const double t = line::num_traits<T>::to_double(a.TN(i, c));
2757 // getPosteriorTable's row filter, the PRESENCE metrics only: a
2758 // response time alone does not put a class at a station, and
2759 // reading it as presence kept rows the reference drops.
2760 if (q <= 0.0 && u <= 0.0 && t <= 0.0) continue;
2761 if (!g_json_output) {
2762 std::printf("%-6zu %12.6g %-16s %-14s %12.6g %12.6g %12.6g %12.6g\n", e + 1,
2764 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(), q, u, rr,
2765 t);
2766 continue;
2767 }
2768 p["Point"].push_back(e);
2769 p["Weight"].push_back(line::num_traits<T>::to_double(r.weights[e]));
2770 p["Station"].push_back(sn.stations[i].name);
2771 p["JobClass"].push_back(sn.classes[c].name);
2772 p["QLen"].push_back(q);
2773 p["Util"].push_back(u);
2774 p["RespT"].push_back(rr);
2775 p["Tput"].push_back(t);
2776 }
2777 }
2778 if (g_json_output) {
2779 p["substitutedMean"] = means;
2780 emit_analysis<T>("posterior", p, r.avg.actualmethod);
2781 }
2782 return 0;
2783}
2784
2785/**
2786 * The banner every `-s ctmc` analysis opens with.
2787 *
2788 * It carries the state count and the cutoff because a CTMC number is not the
2789 * model's number without them: the space is what was enumerated, and on an open
2790 * model the cutoff is what truncated it.
2791 */
2792template <class T>
2793void print_ctmc_banner(const line::ctmc::CtmcSolution<T>& d) {
2794 std::size_t cut = 0;
2795 for (std::size_t i = 0; i < d.cutoff.size(); ++i) cut = std::max(cut, d.cutoff[i]);
2796 if (cut)
2797 std::printf("SolverCTMC arith=%s method=%s type=%s states=%zu cutoff=%zu\n",
2799 line::util::method_type("CTMC", d.actualmethod).c_str(), d.chain.space.size(),
2800 cut);
2801 else
2802 std::printf("SolverCTMC arith=%s method=%s type=%s states=%zu\n",
2804 line::util::method_type("CTMC", d.actualmethod).c_str(), d.chain.space.size());
2805 // The library never writes to stderr, so an unseeded reducible mixture is
2806 // reported here or not at all -- and it is the one case where the printed
2807 // distribution is not the model's.
2808 if (!d.warning.empty()) std::fprintf(stderr, "warning: %s\n", d.warning.c_str());
2809}
2810
2811/**
2812 * The banner's own content as JSON, added to every `-s ctmc` payload.
2813 *
2814 * A CTMC number is not the model's number without them, which is why the
2815 * readable banner carries them: the space is what was actually enumerated, and
2816 * on an open model the cutoff is what truncated it. A host that reported the
2817 * occupancy of a truncated chain as the model's would be reporting a different
2818 * model's answer, so the two travel with the payload rather than only above it.
2819 */
2820template <class T>
2821line::reg::Json ctmc_meta(const line::ctmc::CtmcSolution<T>& d) {
2822 line::reg::Json m = line::reg::Json::object();
2823 m["states"] = d.chain.space.size();
2824 std::size_t cut = 0;
2825 for (std::size_t i = 0; i < d.cutoff.size(); ++i) cut = std::max(cut, d.cutoff[i]);
2826 if (cut) m["cutoff"] = cut;
2827 return m;
2828}
2829
2830/**
2831 * `-a avg`: the AvgTable, on whichever path the model's regions require.
2832 *
2833 * `solver_ctmc_analyzer_any` and not `solver_ctmc_analyzer`, so a WAITQ region
2834 * reaches the augmented walk that carries its token FIFO instead of the
2835 * lattice analyzer, which refuses it. Every other region rule keeps refusing.
2836 */
2837template <class T>
2838int solve_ctmc_avg(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
2841 print_ctmc_banner<T>(a.sol);
2842 print_avg_table<T>(sn, r);
2843 // A PARKED JOB IS IN NO QLen COLUMN: it left its station and sits in the
2844 // region's FIFO, so the model's population balances only once this is read
2845 // alongside the table rather than instead of it.
2846 if (a.waitq) {
2847 std::printf("%-16s %-14s %12s\n", "Region", "JobClass", "Parked");
2848 for (std::size_t c = 0; c < sn.nclasses && c < a.parked.size(); ++c)
2849 std::printf("%-16s %-14s %12.6g\n", "(all)", sn.classes[c].name.c_str(),
2851 }
2852 return 0;
2853}
2854
2855/**
2856 * `-a avg` under `--method mdd`: the AvgTable from the level aggregation.
2857 *
2858 * A SEPARATE ARM, and not a branch inside `solve_ctmc_avg`, because the method
2859 * never forms the |S|-state generator: there is no state space to print a state
2860 * count from and no cutoff to report, so the banner is the diagram's own -- the
2861 * cardinality it counts symbolically, what the levels actually hold, and whether
2862 * the answer is certified exact. Reporting the generator banner here would name
2863 * a chain that was never built.
2864 */
2865template <class T>
2866int solve_ctmc_mdd_avg(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt,
2867 const line::mdd::MddMcdOptions& mcdopt) {
2868 // EVERY BACKEND, `exact` included: the level solve picks Householder QR in
2869 // floating point and `line::lstsq` under exact arithmetic, so nothing here
2870 // takes a square root that a rational field has no answer for.
2873 d.avg = s.avg;
2876 std::size_t held = 0;
2877 for (std::size_t k = 0; k < s.level_sizes.size(); ++k) held += s.level_sizes[k];
2878 std::printf(
2879 "SolverCTMC arith=%s method=%s type=%s states=%lld held=%zu levels=%zu iters=%d "
2880 "encoding=%s exact=%s\n",
2882 line::util::method_type("CTMC", s.actualmethod).c_str(), s.num_states, held,
2883 s.level_sizes.size(), s.iters, s.encoding.c_str(),
2884 // "certified" is not "exact": a product-form model is exact however
2885 // much its diagram shares, so the false case says only that the
2886 // STRUCTURAL test did not fire.
2887 s.no_aggregation ? "certified" : "product-form-only");
2888 print_avg_table<T>(sn, r);
2889 return 0;
2890}
2891
2892/**
2893 * `-a prob`: the four SolverCTMC probability queries over the model's default
2894 * initial state -- `getProbSys`, `getProbSysAggr`, and `getProb`/`getProbAggr`
2895 * per station.
2896 *
2897 * ALL FOUR AND NOT ONE, because the joint and the aggregate answer different
2898 * questions and the pair is what makes either readable: `getProbSys` is the
2899 * probability of exactly that state, phases and buffer arrangement included,
2900 * while `getProbSysAggr` sums over every arrangement realizing the same
2901 * per-class counts. On a single-phase model with no buffer ordering the two
2902 * coincide, and where they do not the ratio is what the encoding added.
2903 *
2904 * NOT THE SAME NUMBERS AS `-s mva -a prob` OR `-s nc -a prob`, and that is the
2905 * point of having all three: MVA fits a binomial to its own means and NC takes a
2906 * ratio of normalizing constants under the product form, whereas these are the
2907 * stationary law of the chain itself and are exact for any model the chain
2908 * represents, product-form or not.
2909 */
2910template <class T>
2911int solve_ctmc_prob(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt,
2912 const Knobs& k) {
2915 if (!line::ctmc::analyzer_detail::default_init_state(sn, init))
2917 "-a prob reports the probability of the model's DEFAULT INITIAL STATE, and this "
2918 "model's initial marking admits no state; check the class populations against their "
2919 "reference stations");
2920 // `--state` is `getProb(node, state)`'s second argument: the ENCODED ROW of
2921 // one stateful node's own state space, not a per-class job count. The
2922 // reference substitutes it into `sn.state{node}` and leaves every other
2923 // node at its default, which is what happens here -- so the station
2924 // marginals below are the requested state's, and the two system
2925 // probabilities are that state's joined with the rest of the default
2926 // marking, exactly as `setState` followed by `getProbSys` reports it.
2927 if (!k.state.empty()) {
2928 if (!k.node)
2929 throw line::InputError(
2930 "--state is the state of ONE node and needs --node to say which; a bare state "
2931 "vector cannot be matched against a network whose nodes have different widths");
2932 const std::size_t isf = sn.stateful_index(k.node);
2933 if (isf == 0)
2934 throw line::InputError("--node " + std::to_string(k.node) +
2935 " is not a stateful node, so it holds no state to ask about");
2936 // THE WIDTH MUST MATCH EXACTLY, and a short vector is refused rather
2937 // than padded. Every row of a node's block is stored at the node's
2938 // widest encoding, so a padded row IS a state -- just not the one the
2939 // caller named: on an FCFS queue holding two jobs the encoding is
2940 // (buffer, in-service phase count) = (1,1), and padding `--state 2` to
2941 // (0,2) names a state the chain never visits, which would answer 0
2942 // where the caller expected the marginal. The width is reported so the
2943 // next attempt can be right.
2944 const std::size_t w = d.chain.space.empty() ? k.state.size()
2945 : d.chain.space[0].local[isf - 1].size();
2946 if (k.state.size() != w)
2947 throw line::InputError(
2948 "--state has " + std::to_string(k.state.size()) + " entries but node " +
2949 std::to_string(k.node) + " encodes its state in " + std::to_string(w) +
2950 "; getProb(node, state) takes the node's whole encoded row, and a shorter one "
2951 "padded with zeros is a different state rather than a partial one (use -a marg "
2952 "for a per-class job-count marginal)");
2953 std::vector<T> row(w, line::num_traits<T>::from_int(0));
2954 for (std::size_t i = 0; i < k.state.size(); ++i)
2955 row[i] = line::num_traits<T>::from_int(k.state[i]);
2956 init.local[isf - 1] = row;
2957 }
2958 const T psys = line::ctmc::solver_ctmc_joint(sn, d, init);
2959 const T psysaggr = line::ctmc::solver_ctmc_jointaggr(sn, d, init);
2960 const std::vector<T> pmarg = line::ctmc::solver_ctmc_marg(sn, d, init);
2961 const std::vector<T> pmargaggr = line::ctmc::solver_ctmc_margaggr(sn, d, init);
2962
2963 if (g_json_output) {
2964 line::reg::Json p = line::reg::Json::object();
2965 p["type"] = "ProbAggr";
2966 p["indexBase"] = 0;
2967 p["ProbSys"] = line::num_traits<T>::to_double(psys);
2968 p["ProbSysAggr"] = line::num_traits<T>::to_double(psysaggr);
2969 line::reg::Json st = line::reg::Json::array(), pm = line::reg::Json::array(),
2970 pa = line::reg::Json::array();
2971 for (std::size_t i = 0; i < sn.nstations; ++i) {
2972 st.push_back(sn.stations[i].name);
2973 pm.push_back(line::num_traits<T>::to_double(pmarg[i]));
2974 pa.push_back(line::num_traits<T>::to_double(pmargaggr[i]));
2975 }
2976 p["Station"] = st;
2977 p["Prob"] = pm;
2978 p["ProbAggr"] = pa;
2979 emit_analysis<T>("prob", p, d.actualmethod, ctmc_meta<T>(d));
2980 return 0;
2981 }
2982 print_ctmc_banner<T>(d);
2983 std::printf("ProbSys %.10g\n", line::num_traits<T>::to_double(psys));
2984 std::printf("ProbSysAggr %.10g\n", line::num_traits<T>::to_double(psysaggr));
2985 std::printf("%-16s %14s %14s\n", "Station", "Prob", "ProbAggr");
2986 for (std::size_t i = 0; i < sn.nstations; ++i)
2987 std::printf("%-16s %14.10g %14.10g\n", sn.stations[i].name.c_str(),
2989 line::num_traits<T>::to_double(pmargaggr[i]));
2990 return 0;
2991}
2992
2993/**
2994 * `-a gen`: `getInfGen`, as Q in sparse triplets plus the synchronization list
2995 * its event filtration is indexed by.
2996 *
2997 * Q IS PRINTED SPARSELY. A generator's rows hold one entry per enabled
2998 * transition and the space can run to thousands of states, so the dense form
2999 * would be quadratic in a quantity that is linear in the model.
3000 */
3001/**
3002 * The derived START/PREEMPT filtration as one JSON block per (station, class),
3003 * each carrying its own 0-based Station and Class beside the usual From/To/Rate
3004 * triplets. An all-zero block is omitted: on a model with no preemption that is
3005 * every block of the PREEMPT filtration.
3006 */
3007template <class T>
3008line::reg::Json aux_filt_json(const std::vector<std::vector<line::Matrix<T> > >& filt,
3009 std::size_t n) {
3010 line::reg::Json blocks = line::reg::Json::array();
3011 for (std::size_t i = 0; i < filt.size(); ++i)
3012 for (std::size_t r = 0; r < filt[i].size(); ++r) {
3013 line::reg::Json bfrom = line::reg::Json::array(), bto = line::reg::Json::array(),
3014 brate = line::reg::Json::array();
3015 for (std::size_t a = 0; a < n; ++a)
3016 for (std::size_t b = 0; b < n; ++b) {
3017 const double q = line::num_traits<T>::to_double(filt[i][r](a, b));
3018 if (q == 0.0) continue;
3019 bfrom.push_back(a);
3020 bto.push_back(b);
3021 brate.push_back(q);
3022 }
3023 if (bfrom.empty()) continue;
3024 line::reg::Json e = line::reg::Json::object();
3025 e["Station"] = i;
3026 e["Class"] = r;
3027 e["From"] = bfrom;
3028 e["To"] = bto;
3029 e["Rate"] = brate;
3030 blocks.push_back(e);
3031 }
3032 return blocks;
3033}
3034
3035template <class T>
3036int solve_ctmc_gen(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
3038 o.keep_filtration = true; // the filtration is half of what getInfGen returns
3041 const std::size_t n = g.Q.rows();
3042 std::size_t nnz = 0;
3043 for (std::size_t i = 0; i < n; ++i)
3044 for (std::size_t j = 0; j < n; ++j)
3045 if (line::num_traits<T>::to_double(g.Q(i, j)) != 0.0) ++nnz;
3046 if (g_json_output) {
3047 line::reg::Json p = line::reg::Json::object();
3048 p["type"] = "InfGen";
3049 p["indexBase"] = 0;
3050 p["size"] = n;
3051 p["nnz"] = nnz;
3052 // SPARSE HERE TOO, for the reason the table is: a generator has one entry
3053 // per enabled transition, so the dense form is quadratic in a quantity
3054 // that is linear in the model. A host rebuilds Q with one scatter.
3055 line::reg::Json from = line::reg::Json::array(), to = line::reg::Json::array(),
3056 rate = line::reg::Json::array();
3057 for (std::size_t i = 0; i < n; ++i)
3058 for (std::size_t j = 0; j < n; ++j) {
3059 const double q = line::num_traits<T>::to_double(g.Q(i, j));
3060 if (q == 0.0) continue;
3061 from.push_back(i);
3062 to.push_back(j);
3063 rate.push_back(q);
3064 }
3065 p["From"] = from;
3066 p["To"] = to;
3067 p["Rate"] = rate;
3068 // The synchronization list is HALF of what getInfGen returns: without it
3069 // the filtration's index has no meaning, so it travels with Q. Its node
3070 // and class are the port's own 1-BASED indices, with 0 the LOCAL dummy
3071 // -- `indexBase` above governs the state indices, which is where a host
3072 // would otherwise be indexing a chain by an off-by-one.
3073 line::reg::Json sync = line::reg::Json::array();
3074 for (std::size_t a = 0; a < g.sync.size() && a < g.filt.size(); ++a) {
3075 // THE FILTER ITSELF, not only its nnz as the readable table reports:
3076 // `eventFilt` is half of what getInfGen returns, and a host handed
3077 // only Q cannot recover which synchronization contributed a rate --
3078 // Q's entries have already summed every one of them.
3079 std::size_t fnz = 0;
3080 line::reg::Json ffrom = line::reg::Json::array(), fto = line::reg::Json::array(),
3081 frate = line::reg::Json::array();
3082 for (std::size_t i = 0; i < n; ++i)
3083 for (std::size_t j = 0; j < n; ++j) {
3084 const double q = line::num_traits<T>::to_double(g.filt[a](i, j));
3085 if (q == 0.0) continue;
3086 ++fnz;
3087 ffrom.push_back(i);
3088 fto.push_back(j);
3089 frate.push_back(q);
3090 }
3091 line::reg::Json e = line::reg::Json::object();
3092 e["From"] = ffrom;
3093 e["To"] = fto;
3094 e["Rate"] = frate;
3095 e["activeEvent"] = line::lang::event_to_text(g.sync[a].active.event);
3096 e["activeNode"] = g.sync[a].active.node;
3097 e["activeClass"] = g.sync[a].active.cls;
3098 e["passiveEvent"] = line::lang::event_to_text(g.sync[a].passive.event);
3099 e["passiveNode"] = g.sync[a].passive.node;
3100 e["passiveClass"] = g.sync[a].passive.cls;
3101 e["nnz"] = fnz;
3102 sync.push_back(e);
3103 }
3104 p["sync"] = sync;
3105 // The DERIVED filtrations travel under their own keys, one block per
3106 // (station, class), because they are NOT synchronizations: a START
3107 // rides on an arc `sync` already carries, so folding them in would make
3108 // a host summing the filtration double-count the generator.
3109 p["startFilt"] = aux_filt_json<T>(g.start_filt, n);
3110 p["preemptFilt"] = aux_filt_json<T>(g.preempt_filt, n);
3111 emit_analysis<T>("gen", p, d.actualmethod, ctmc_meta<T>(d));
3112 return 0;
3113 }
3114 print_ctmc_banner<T>(d);
3115 std::printf("InfGen events=%zu nnz=%zu\n", g.sync.size(), nnz);
3116 std::printf("%8s %8s %16s\n", "From", "To", "Rate");
3117 for (std::size_t i = 0; i < n; ++i)
3118 for (std::size_t j = 0; j < n; ++j) {
3119 const double q = line::num_traits<T>::to_double(g.Q(i, j));
3120 if (q != 0.0) std::printf("%8zu %8zu %16.10g\n", i + 1, j + 1, q);
3121 }
3122 std::printf("%6s %-10s %6s %6s %-10s %6s %6s %8s\n", "Event", "ActEvent", "ActNode", "ActCls",
3123 "PasEvent", "PasNode", "PasCls", "Nnz");
3124 for (std::size_t a = 0; a < g.sync.size() && a < g.filt.size(); ++a) {
3125 std::size_t fnz = 0;
3126 for (std::size_t i = 0; i < n; ++i)
3127 for (std::size_t j = 0; j < n; ++j)
3128 if (line::num_traits<T>::to_double(g.filt[a](i, j)) != 0.0) ++fnz;
3129 std::printf("%6zu %-10s %6zu %6zu %-10s %6zu %6zu %8zu\n", a + 1,
3130 line::lang::event_to_text(g.sync[a].active.event), g.sync[a].active.node,
3131 g.sync[a].active.cls, line::lang::event_to_text(g.sync[a].passive.event),
3132 g.sync[a].passive.node, g.sync[a].passive.cls, fnz);
3133 }
3134 return 0;
3135}
3136
3137/** `-a states`: `getStateSpace` and `getStateSpaceAggr`, side by side. */
3138template <class T>
3139int solve_ctmc_states(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
3143 if (g_json_output) {
3144 line::reg::Json p = line::reg::Json::object();
3145 p["type"] = "StateSpace";
3146 p["indexBase"] = 0;
3147 // The per-node widths are the ONLY thing that makes a flat row decodable:
3148 // they are where one stateful node's block ends and the next begins.
3149 p["NodeWidths"] = index_json(s.node_width);
3150 p["space"] = matrix_json(s.flat);
3151 p["spaceAggr"] = matrix_json(A);
3152 // `localStateSpace` of `[stateSpace, localStateSpace] = getStateSpace`.
3153 // One entry per stateful node, in stateful-node order, holding that
3154 // node's distinct local rows. A host with only the flat space can
3155 // return the second output as a SINGLE cell holding the whole space,
3156 // which is what `getStateSpace.m` does under `lang='cpp'`, and a caller
3157 // indexing it per node then reads the global space for every node.
3158 line::reg::Json loc = line::reg::Json::array();
3159 for (std::size_t f = 0; f < s.local.size(); ++f) loc.push_back(matrix_json(s.local[f]));
3160 p["localSpace"] = loc;
3161 // pi travels with the space because a row of the space is not an answer:
3162 // the pair (state, probability) is, and reading them from two invocations
3163 // would risk pairing one solve's states with another solve's law.
3164 p["pi"] = vector_json(d.pi);
3165 emit_analysis<T>("states", p, d.actualmethod, ctmc_meta<T>(d));
3166 return 0;
3167 }
3168 print_ctmc_banner<T>(d);
3169 // The per-node widths are printed because the flat row is only decodable
3170 // with them: they are where one node's block ends and the next begins.
3171 std::printf("NodeWidths");
3172 for (std::size_t f = 0; f < s.node_width.size(); ++f) std::printf(" %zu", s.node_width[f]);
3173 std::printf("\n");
3174 std::printf("%8s %12s %s\n", "State", "Prob", "Detailed | Aggregate");
3175 for (std::size_t i = 0; i < s.flat.rows(); ++i) {
3176 std::printf("%8zu %12.6g ", i + 1, line::num_traits<T>::to_double(d.pi[i]));
3177 for (std::size_t c = 0; c < s.flat.cols(); ++c)
3178 std::printf(" %g", line::num_traits<T>::to_double(s.flat(i, c)));
3179 std::printf(" |");
3180 for (std::size_t c = 0; c < A.cols(); ++c)
3181 std::printf(" %g", line::num_traits<T>::to_double(A(i, c)));
3182 std::printf("\n");
3183 }
3184 return 0;
3185}
3186
3187/**
3188 * `-a sens`: `getSensitivityRanking` over the exponential service rates.
3189 *
3190 * THE REWARD IS STATED, NOT INFERRED. The reference makes the caller supply one,
3191 * and a model.json carries no reward function, so the CLI has to name the reward
3192 * it ranks against or the numbers mean nothing: it is the mean number of jobs at
3193 * the QUEUEING stations, which excludes the Source (whose column is the infinite
3194 * reservoir) and the Delay stations (whose population is think time, not work).
3195 *
3196 * Only an exponential service pair becomes a parameter. Perturbing any other
3197 * distribution would mean replacing it with an exponential of the new rate,
3198 * which changes the model's shape rather than one of its parameters; those pairs
3199 * are listed as skipped rather than silently differenced.
3200 */
3201template <class T>
3202int solve_ctmc_sens(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
3203 const std::size_t M = sn.nstations, K = sn.nclasses;
3206
3207 std::vector<bool> queueing(M, false);
3208 for (std::size_t i = 0; i < M; ++i)
3209 queueing[i] = sn.stations[i].nodetype != line::lang::NodeType::Source &&
3212
3213 std::vector<T> reward(d.chain.space.size(), line::num_traits<T>::from_int(0));
3214 for (std::size_t s = 0; s < reward.size(); ++s)
3215 for (std::size_t i = 0; i < M; ++i)
3216 if (queueing[i])
3217 for (std::size_t c = 0; c < K; ++c) reward[s] += A(s, i * K + c);
3218
3219 std::vector<line::ctmc::CtmcSensParam<T> > params;
3220 std::vector<std::string> skipped;
3221 for (std::size_t i = 0; i < M; ++i) {
3222 if (sn.stations[i].nodetype == line::lang::NodeType::Source) continue;
3223 for (std::size_t c = 0; c < K; ++c) {
3224 if (sn.disabled[i][c]) continue;
3225 const double mu = line::num_traits<T>::to_double(sn.rates(i, c));
3226 if (!(mu > 0.0)) continue;
3227 const std::string nm =
3228 "mu(" + sn.stations[i].name + "," + sn.classes[c].name + ")";
3229 if (sn.service[i][c].type != line::lang::ProcessType::EXP) {
3230 skipped.push_back(nm);
3231 continue;
3232 }
3234 p.name = nm;
3235 p.value = mu;
3236 p.set = [i, c](line::qn::NetworkStruct<T>& s, double v) {
3238 s.refresh_rates();
3239 };
3240 params.push_back(p);
3241 }
3242 }
3243 if (params.empty())
3245 "-a sens found no exponential service rate to differentiate: every enabled "
3246 "(station, class) pair carries a non-exponential distribution, and replacing one with "
3247 "an exponential of the perturbed rate would change the model rather than a parameter "
3248 "of it");
3249
3250 const std::vector<line::ctmc::CtmcSensRank<T> > rank =
3251 line::ctmc::solver_ctmc_sensitivity_ranking(sn, opt, params, reward);
3252 if (g_json_output) {
3253 line::reg::Json p = line::reg::Json::object();
3254 p["type"] = "SensRanking";
3255 // THE REWARD IS STATED, NOT INFERRED, on this path too: a ranking is a
3256 // ranking against something, and a host that read these numbers without
3257 // knowing what was differentiated would be reading a sensitivity of an
3258 // unnamed functional.
3259 p["reward"] = "mean number of jobs at the queueing stations";
3260 line::reg::Json par = line::reg::Json::array(), val = line::reg::Json::array(),
3261 S = line::reg::Json::array(), SS = line::reg::Json::array();
3262 for (std::size_t l = 0; l < rank.size(); ++l) {
3263 par.push_back(rank[l].parameter);
3264 val.push_back(rank[l].value);
3265 S.push_back(line::num_traits<T>::to_double(rank[l].S));
3266 // null, not NaN and not 0: a zero mean reward makes the scaled form
3267 // undefined, which is a property of the model, and JSON has no NaN.
3268 if (rank[l].scaled_valid)
3269 SS.push_back(line::num_traits<T>::to_double(rank[l].SS));
3270 else
3271 SS.push_back(line::reg::Json());
3272 }
3273 p["Parameter"] = par;
3274 p["Value"] = val;
3275 p["Sens"] = S;
3276 p["ScaledSens"] = SS;
3277 line::reg::Json sk = line::reg::Json::array();
3278 for (std::size_t l = 0; l < skipped.size(); ++l) sk.push_back(skipped[l]);
3279 p["Skipped"] = sk;
3280 emit_analysis<T>("sens", p, d.actualmethod, ctmc_meta<T>(d));
3281 return 0;
3282 }
3283 print_ctmc_banner<T>(d);
3284 std::printf("Reward mean number of jobs at the queueing stations\n");
3285 std::printf("%-28s %14s %16s %16s\n", "Parameter", "Value", "Sens", "ScaledSens");
3286 for (std::size_t l = 0; l < rank.size(); ++l) {
3287 if (rank[l].scaled_valid)
3288 std::printf("%-28s %14.6g %16.8g %16.8g\n", rank[l].parameter.c_str(), rank[l].value,
3290 line::num_traits<T>::to_double(rank[l].SS));
3291 else
3292 // MATLAB reports NaN here; the reason is printed instead, since a
3293 // zero mean reward is a property of the model and not a failure.
3294 std::printf("%-28s %14.6g %16.8g %16s\n", rank[l].parameter.c_str(), rank[l].value,
3295 line::num_traits<T>::to_double(rank[l].S), "undefined(E[r]=0)");
3296 }
3297 for (std::size_t l = 0; l < skipped.size(); ++l)
3298 std::printf("Skipped %s: service is not exponential\n", skipped[l].c_str());
3299 return 0;
3300}
3301
3302/** `-a reward`: `getAvgReward`, the steady-state expectation of each reward. */
3303template <class T>
3304int solve_ctmc_reward(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
3305 std::vector<std::string> names;
3306 const std::vector<T> r = line::ctmc::solver_ctmc_avg_reward(sn, opt, &names);
3307 if (g_json_output) {
3308 line::reg::Json p = line::reg::Json::object();
3309 p["type"] = "AvgReward";
3310 line::reg::Json nm = line::reg::Json::array();
3311 for (std::size_t l = 0; l < names.size(); ++l) nm.push_back(names[l]);
3312 p["Reward"] = nm;
3313 p["E"] = vector_json(r);
3314 // NO "method": this arm returns the expectations and no solved chain, so
3315 // there is no resolved method to report and none is invented.
3316 emit_analysis<T>("reward", p, std::string());
3317 return 0;
3318 }
3319 std::printf("SolverCTMC arith=%s rewards=%zu\n", line::num_traits<T>::name(), r.size());
3320 std::printf("%-28s %16s\n", "Reward", "E[r]");
3321 for (std::size_t l = 0; l < r.size(); ++l)
3322 std::printf("%-28s %16.10g\n", names[l].c_str(), line::num_traits<T>::to_double(r[l]));
3323 return 0;
3324}
3325
3326/**
3327 * `-a reward-value`: `@@SolverCTMC/getRewardValueFunction`, V^k(s).
3328 *
3329 * A DIFFERENT OBJECT FROM BOTH `-a reward` AND `-a tranreward`. `-a reward`
3330 * returns one number per reward, the steady-state E[r]; `-a tranreward` the
3331 * expected rate along a horizon; this returns the VALUE FUNCTION of ONE named
3332 * reward -- the reward accumulated over k uniformized steps, from every state
3333 * of the chain, as a (Tmax+1 x nstates) matrix. It is the object a policy
3334 * evaluation reads, and it is indexed by state, not by station.
3335 *
3336 * `--reward-name` IS REQUIRED and not defaulted to the first declared reward:
3337 * the value functions of two rewards are different matrices, and labelling one
3338 * with the caller's question would be a wrong answer rather than a missing one.
3339 */
3340template <class T>
3341int solve_ctmc_reward_value(const line::qn::NetworkStruct<T>& sn,
3342 const line::ctmc::CtmcOptions& opt, const Knobs& k) {
3343 if (k.reward_name.empty())
3344 throw line::InputError(
3345 "-a reward-value returns the value function of ONE reward and needs --reward-name to "
3346 "say which; -a reward returns the steady-state expectation of every declared reward");
3348 std::size_t which = rr.names.size();
3349 for (std::size_t l = 0; l < rr.names.size(); ++l)
3350 if (rr.names[l] == k.reward_name) which = l;
3351 if (which == rr.names.size()) {
3352 std::string avail;
3353 for (std::size_t l = 0; l < rr.names.size(); ++l)
3354 avail += (l ? ", " : "") + rr.names[l];
3355 throw line::InputError("--reward-name '" + k.reward_name +
3356 "' is not declared by this model; it declares: " +
3357 (avail.empty() ? std::string("(none)") : avail));
3358 }
3359 const line::Matrix<T>& V = rr.V[which];
3360
3361 if (g_json_output) {
3362 line::reg::Json p = line::reg::Json::object();
3363 p["type"] = "RewardValueFunction";
3364 p["indexBase"] = 0;
3365 p["Reward"] = rr.names[which];
3366 p["steps"] = V.rows();
3367 p["states"] = V.cols();
3368 p["t"] = vector_json(rr.t);
3369 p["V"] = matrix_json(V);
3370 p["stateSpaceAggr"] = matrix_json(rr.state_space_aggr);
3371 emit_analysis<T>("rewardvalue", p, std::string());
3372 return 0;
3373 }
3374 std::printf("SolverCTMC arith=%s reward=%s steps=%zu states=%zu\n",
3375 line::num_traits<T>::name(), rr.names[which].c_str(), V.rows(), V.cols());
3376 std::printf("%-10s %-10s %20s\n", "Step", "State", "V");
3377 for (std::size_t i = 0; i < V.rows(); ++i)
3378 for (std::size_t j = 0; j < V.cols(); ++j)
3379 std::printf("%-10zu %-10zu %20.10g\n", i, j, line::num_traits<T>::to_double(V(i, j)));
3380 return 0;
3381}
3382
3383/**
3384 * `-a tranreward`: `getTranReward`, E[r(X(t))] over the --tspan horizon.
3385 *
3386 * A DIFFERENT QUANTITY FROM `-a reward`, not a formatting of it. `-a reward`
3387 * returns the steady-state expectation, one number per reward; this returns the
3388 * expected reward RATE along the trajectory, which converges to that number but
3389 * is not it at any finite t. It is also not the accumulated reward `V`, which
3390 * the same header computes and which diverges -- the header calls that the
3391 * easiest mistake to make here, so the two are kept on separate flags.
3392 *
3393 * THE HORIZON IS REQUIRED, as it is for `-a tranprob`: E[r(X(t))] on an
3394 * unstated horizon is not a quantity, and the reference refuses an infinite one
3395 * rather than picking a bound.
3396 */
3397template <class T>
3398int solve_ctmc_tran_reward(const line::qn::NetworkStruct<T>& sn,
3399 const line::ctmc::CtmcOptions& opt, const Knobs& k) {
3401 (void)sn; (void)opt; (void)k;
3403 "-a tranreward integrates the forward equation, which needs transcendental "
3404 "arithmetic; rerun with --arith double or --arith real");
3405 } else {
3406 if (k.t1 < 0.0)
3407 throw line::InputError(
3408 "-a tranreward integrates E[r(X(t))] and needs a horizon: pass --tspan <t0>:<t1>");
3409 std::vector<std::string> names;
3410 std::vector<T> t;
3411 const std::vector<std::vector<T> > r = line::ctmc::solver_ctmc_tran_reward(
3412 sn, opt, line::num_traits<T>::from_double(k.t0),
3413 line::num_traits<T>::from_double(k.t1), &t, &names);
3414 if (g_json_output) {
3415 line::reg::Json p = line::reg::Json::object();
3416 p["type"] = "TranReward";
3417 p["indexBase"] = 0;
3418 line::reg::Json nm = line::reg::Json::array();
3419 for (std::size_t l = 0; l < names.size(); ++l) nm.push_back(names[l]);
3420 p["Reward"] = nm;
3421 p["t"] = vector_json<T>(t);
3422 line::reg::Json e = line::reg::Json::array();
3423 for (std::size_t l = 0; l < r.size(); ++l) e.push_back(vector_json<T>(r[l]));
3424 p["E"] = e;
3425 emit_analysis<T>("tranreward", p, std::string());
3426 return 0;
3427 }
3428 std::printf("SolverCTMC arith=%s rewards=%zu points=%zu tspan=[%g,%g]\n",
3429 line::num_traits<T>::name(), r.size(), t.size(), k.t0, k.t1);
3430 std::printf("%-16s", "t");
3431 for (std::size_t l = 0; l < names.size(); ++l) std::printf(" %16s", names[l].c_str());
3432 std::printf("\n");
3433 for (std::size_t i = 0; i < t.size(); ++i) {
3434 std::printf("%-16.10g", line::num_traits<T>::to_double(t[i]));
3435 for (std::size_t l = 0; l < r.size(); ++l)
3436 std::printf(" %16.10g", line::num_traits<T>::to_double(r[l][i]));
3437 std::printf("\n");
3438 }
3439 return 0;
3440 }
3441}
3442
3443/**
3444 * `-a tranprob`: pi(t) over the --tspan horizon, labelled by the whole network
3445 * (`getTranProbSys` / `getTranProbSysAggr`) or by one node when `--node` names
3446 * it (`getTranProb` / `getTranProbAggr`).
3447 *
3448 * The forward equation is integrated ONCE and both label sets are taken off the
3449 * same `CtmcTransient`. Calling the (sn, opt, ...) overloads twice would solve
3450 * and integrate the chain twice for two views of one answer.
3451 */
3452template <class T>
3453int solve_ctmc_tranprob(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt,
3454 const Knobs& k) {
3455 if (k.t1 < 0.0)
3456 throw line::InputError(
3457 "-a tranprob integrates pi(t) and needs a horizon: pass --tspan <t0>:<t1>");
3460 const line::ctmc::CtmcTranProb<T> det =
3461 k.node ? line::ctmc::ctmc_get_tran_prob(sn, tr, k.node)
3462 : line::ctmc::ctmc_get_tran_prob_sys(sn, tr);
3463 const line::ctmc::CtmcTranProb<T> agg =
3464 k.node ? line::ctmc::ctmc_get_tran_prob_aggr(sn, tr, k.node)
3465 : line::ctmc::ctmc_get_tran_prob_sys_aggr(sn, tr);
3466 if (g_json_output) {
3467 line::reg::Json p = line::reg::Json::object();
3468 p["type"] = "TranProb";
3469 p["indexBase"] = 0;
3470 p["scope"] = k.node ? sn.nodes[k.node - 1].name : std::string("(system)");
3471 if (k.node) p["node"] = k.node - 1;
3472 line::reg::Json span = line::reg::Json::array();
3473 span.push_back(k.t0);
3474 span.push_back(k.t1);
3475 // THE HORIZON IS PART OF THE ANSWER: pi(t) on an unstated span is not a
3476 // quantity, and a host that took the last row for "the" occupancy without
3477 // it would be quoting a time it does not know.
3478 p["tspan"] = span;
3479 p["t"] = vector_json(det.t);
3480 // BOTH VIEWS COME OFF ONE INTEGRATION, as on the readable path: the
3481 // detailed labels answer getTranProb(Sys) and the aggregate ones
3482 // getTranProb(Sys)Aggr, and a host asking for both would otherwise
3483 // integrate the same forward equation twice for one answer.
3484 p["labels"] = matrix_json(det.labels);
3485 p["labelsAggr"] = matrix_json(agg.labels);
3486 p["pit"] = matrix_json(det.pit);
3487 p["pitAggr"] = matrix_json(agg.pit);
3488 emit_analysis<T>("tranprob", p, tr.chain.actualmethod, ctmc_meta<T>(tr.chain));
3489 return 0;
3490 }
3491 print_ctmc_banner<T>(tr.chain);
3492 std::printf("TranProb times=%zu tspan=%g:%g scope=%s\n", det.t.size(), k.t0, k.t1,
3493 k.node ? sn.nodes[k.node - 1].name.c_str() : "(system)");
3494 // The labels come FIRST and the occupancy after, because pi(t) is a row per
3495 // time over columns that mean nothing until the state they index is named.
3496 std::printf("%8s %s\n", "State", "Detailed | Aggregate");
3497 for (std::size_t s = 0; s < det.labels.rows(); ++s) {
3498 std::printf("%8zu ", s + 1);
3499 for (std::size_t c = 0; c < det.labels.cols(); ++c)
3500 std::printf(" %g", line::num_traits<T>::to_double(det.labels(s, c)));
3501 std::printf(" |");
3502 for (std::size_t c = 0; c < agg.labels.cols(); ++c)
3503 std::printf(" %g", line::num_traits<T>::to_double(agg.labels(s, c)));
3504 std::printf("\n");
3505 }
3506 std::printf("%14s", "Time");
3507 for (std::size_t s = 0; s < det.pit.cols(); ++s) std::printf(" %12zu", s + 1);
3508 std::printf("\n");
3509 for (std::size_t i = 0; i < det.t.size(); ++i) {
3510 std::printf("%14.8g", line::num_traits<T>::to_double(det.t[i]));
3511 for (std::size_t s = 0; s < det.pit.cols(); ++s)
3512 std::printf(" %12.6g", line::num_traits<T>::to_double(det.pit(i, s)));
3513 std::printf("\n");
3514 }
3515 return 0;
3516}
3517
3518/**
3519 * `-s ctmc -a tran`: `getTranAvg`, the transient MEANS Q(t), U(t) and X(t) over
3520 * the --tspan horizon.
3521 *
3522 * NOT `-a tranprob`, AND THE TWO ARE NOT REDUCIBLE TO ONE ANOTHER FOR A CALLER.
3523 * `tranprob` sends pi(t) with the labels that index it, from which a host COULD
3524 * form these means -- and that is exactly the computation that must not happen
3525 * in a host: the utilization is not a linear functional of the labels (the PS
3526 * and DPS shares divide by the state's own total, and every other discipline
3527 * takes min(n_k, c)/c with the reference's warning attached), so a host folding
3528 * pi(t) itself would be reimplementing `solver_ctmc_transient_analyzer`'s
3529 * discipline switch and would diverge from it silently. The analyzer already
3530 * computes all three trajectories on the way to pi(t); this arm reports them.
3531 *
3532 * The payload is the SAME `TranAvgTable` the fluid and MAM transients emit, key
3533 * for key, so one host reader serves every solver that answers `-a tran`.
3534 */
3535template <class T>
3536int solve_ctmc_tran(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt,
3537 const Knobs& k) {
3538 if (k.t1 < 0.0)
3539 throw line::InputError(
3540 "-a tran integrates the forward equation and needs a horizon: pass --tspan <t0>:<t1>");
3543 const std::size_t M = sn.nstations, K = sn.nclasses, nt = tr.t.size();
3544
3545 if (g_json_output) {
3546 line::reg::Json p = line::reg::Json::object();
3547 p["type"] = "TranAvgTable";
3548 p["indexBase"] = 0;
3549 p["t0"] = k.t0;
3550 p["t1"] = k.t1;
3551 line::reg::Json ts = line::reg::Json::array();
3552 for (std::size_t j = 0; j < nt; ++j) ts.push_back(line::num_traits<T>::to_double(tr.t[j]));
3553 line::reg::Json arr = line::reg::Json::array();
3554 for (std::size_t i = 0; i < M; ++i)
3555 for (std::size_t c = 0; c < K; ++c) {
3556 // A DISABLED PAIR IS OMITTED, not sent as zeros: the reference
3557 // leaves its cell empty and the host turns an absent curve into
3558 // the disabled handle's NaN. Zeros would read as a station that
3559 // is genuinely idle for that class.
3560 if (sn.disabled[i][c]) continue;
3561 line::reg::Json e = line::reg::Json::object();
3562 e["Station"] = sn.stations[i].name;
3563 e["JobClass"] = sn.classes[c].name;
3564 e["station"] = i;
3565 e["jobclass"] = c;
3566 e["t"] = ts;
3567 line::reg::Json q = line::reg::Json::array(), u = line::reg::Json::array(),
3568 x = line::reg::Json::array();
3569 for (std::size_t j = 0; j < nt; ++j) {
3570 q.push_back(line::num_traits<T>::to_double(tr.QNt[i][c][j]));
3571 u.push_back(line::num_traits<T>::to_double(tr.UNt[i][c][j]));
3572 x.push_back(line::num_traits<T>::to_double(tr.TNt[i][c][j]));
3573 }
3574 e["QLen"] = q;
3575 e["Util"] = u;
3576 e["Tput"] = x;
3577 arr.push_back(e);
3578 }
3579 p["curves"] = arr;
3580 emit_analysis<T>("tran", p, tr.chain.actualmethod, ctmc_meta<T>(tr.chain));
3581 return 0;
3582 }
3583 print_ctmc_banner<T>(tr.chain);
3584 std::printf("TranAvg times=%zu tspan=%g:%g\n", nt, k.t0, k.t1);
3585 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "Time", "QLen", "Util",
3586 "Tput");
3587 for (std::size_t i = 0; i < M; ++i)
3588 for (std::size_t c = 0; c < K; ++c) {
3589 if (sn.disabled[i][c]) continue;
3590 for (std::size_t j = 0; j < nt; ++j)
3591 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n",
3592 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(),
3594 line::num_traits<T>::to_double(tr.QNt[i][c][j]),
3595 line::num_traits<T>::to_double(tr.UNt[i][c][j]),
3596 line::num_traits<T>::to_double(tr.TNt[i][c][j]));
3597 }
3598 return 0;
3599}
3600
3601/**
3602 * `-a sample`: `sampleSys` and `sampleSysAggr`, one marked trajectory; with
3603 * `--node`, also that node's own block (`sample`) and per-class counts
3604 * (`sampleAggr`), which is the view MATLAB's per-node sampler returns.
3605 */
3606template <class T>
3607int solve_ctmc_sample(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt,
3608 const Knobs& k) {
3609 // `--events` NAMES THIS NUMBER, `--samples` only stands in for it. The
3610 // JAR keeps the two apart because a sampled trajectory is walked for a
3611 // number of EVENTS while `--samples` is a solver-wide run length, so a
3612 // caller who set the run length and then asked for a trajectory would
3613 // silently get one of that length. Both are honoured, --events first,
3614 // and the reference default of 1000 stands when neither is given.
3615 const std::size_t nevents = k.events ? k.events : (k.samples ? k.samples : 1000);
3616 const unsigned long seed = k.seed ? k.seed : 23000;
3618 line::ctmc::solver_ctmc_sample_sys<T>(sn, opt, nevents, seed);
3620 line::Matrix<T> L, LA;
3621 if (k.node) {
3622 L = line::ctmc::solver_ctmc_sample(sn, path, k.node);
3623 LA = line::ctmc::solver_ctmc_sample_aggr(sn, path, k.node);
3624 }
3625 if (g_json_output) {
3626 line::reg::Json p = line::reg::Json::object();
3627 p["type"] = "SamplePath";
3628 p["indexBase"] = 0;
3629 // The seed and the requested length are part of the ANSWER, as on the SSA
3630 // path: two runs are the same trace only if both are stated.
3631 p["events"] = nevents;
3632 p["seed"] = seed;
3633 p["drawn"] = path.state.size();
3634 p["scope"] = k.node ? sn.nodes[k.node - 1].name : std::string("(system)");
3635 if (k.node) p["node"] = k.node - 1;
3636 p["t"] = vector_json(path.t);
3637 p["state"] = index_json(path.state);
3638 line::reg::Json ev = line::reg::Json::array();
3639 for (std::size_t i = 0; i < path.state.size(); ++i) {
3640 // null where the readable table prints "absorb": the walk left no
3641 // state, so there is no synchronization index, and any integer here
3642 // would name an event that did not fire.
3643 if (i < path.event.size() && path.event[i] != static_cast<std::size_t>(-1))
3644 ev.push_back(path.event[i]);
3645 else
3646 ev.push_back(line::reg::Json());
3647 }
3648 p["event"] = ev;
3649 p["sysAggr"] = matrix_json(A);
3650 // THE STATE SPACE TRAVELS WITH THE TRAJECTORY, so a host can turn the
3651 // visited indices into the states themselves without enumerating the
3652 // chain a second time in another process -- which would also be a second
3653 // chance for the two enumerations to disagree while looking paired.
3654 {
3657 p["space"] = matrix_json(s.flat);
3658 p["NodeWidths"] = index_json(s.node_width);
3659 }
3660 if (k.node) {
3661 p["nodeState"] = matrix_json(L);
3662 p["nodeAggr"] = matrix_json(LA);
3663 }
3664 emit_analysis<T>("sample", p, path.chain.actualmethod, ctmc_meta<T>(path.chain));
3665 return 0;
3666 }
3667 // The seed and the requested length are part of the ANSWER, as on the SSA
3668 // path: two runs are the same trace only if both are stated.
3669 std::printf("SolverCTMC arith=%s states=%zu events=%zu seed=%lu drawn=%zu scope=%s\n",
3670 line::num_traits<T>::name(), path.chain.chain.space.size(), nevents, seed,
3671 path.state.size(), k.node ? sn.nodes[k.node - 1].name.c_str() : "(system)");
3672 std::printf("%14s %8s %8s %s\n", "Time", "State", "Event",
3673 k.node ? "SysAggregate | NodeState | NodeAggregate" : "SysAggregate");
3674 for (std::size_t i = 0; i < path.state.size(); ++i) {
3675 const std::size_t ev = i < path.event.size() ? path.event[i] : static_cast<std::size_t>(-1);
3676 std::printf("%14.8g %8zu ", line::num_traits<T>::to_double(path.t[i]), path.state[i] + 1);
3677 if (ev == static_cast<std::size_t>(-1))
3678 std::printf("%8s ", "absorb");
3679 else
3680 std::printf("%8zu ", ev + 1);
3681 for (std::size_t c = 0; c < A.cols(); ++c)
3682 std::printf(" %g", line::num_traits<T>::to_double(A(i, c)));
3683 if (k.node) {
3684 std::printf(" |");
3685 for (std::size_t c = 0; c < L.cols(); ++c)
3686 std::printf(" %g", line::num_traits<T>::to_double(L(i, c)));
3687 std::printf(" |");
3688 for (std::size_t c = 0; c < LA.cols(); ++c)
3689 std::printf(" %g", line::num_traits<T>::to_double(LA(i, c)));
3690 }
3691 std::printf("\n");
3692 }
3693 return 0;
3694}
3695
3696/** `-a cdf`: `getCdfRespT` per (station, class), and `getCdfSysRespT` per chain. */
3697template <class T>
3698int solve_ctmc_cdf(const line::qn::NetworkStruct<T>& sn, const line::ctmc::CtmcOptions& opt) {
3699 const std::vector<std::vector<line::ctmc::CdfCurve<T> > > RD =
3701 const std::vector<line::ctmc::CdfCurve<T> > RS = line::ctmc::solver_ctmc_cdf_sys_respt(sn, opt);
3702 if (g_json_output) {
3703 line::reg::Json p = line::reg::Json::object();
3704 p["type"] = "CdfRespT";
3705 p["chains"] = sn.nchains;
3706 // ONE OBJECT PER CURVE rather than four parallel columns: the grids are
3707 // per-pair and of different lengths, so a column-oriented form would have
3708 // to repeat the station and the class on every sample and leave the host
3709 // to re-group them.
3710 line::reg::Json rd = line::reg::Json::array();
3711 for (std::size_t i = 0; i < RD.size(); ++i)
3712 for (std::size_t c = 0; c < RD[i].size(); ++c) {
3713 // An empty curve is not a degenerate one: the chain never visits
3714 // that pair, so there is no arrival event to condition on, and it
3715 // is OMITTED rather than sent as a flat zero law.
3716 if (RD[i][c].empty()) continue;
3717 line::reg::Json e = line::reg::Json::object();
3718 e["Station"] = sn.stations[i].name;
3719 e["JobClass"] = sn.classes[c].name;
3720 e["station"] = i;
3721 e["jobclass"] = c;
3722 e["t"] = vector_json(RD[i][c].t);
3723 e["F"] = vector_json(RD[i][c].F);
3724 rd.push_back(e);
3725 }
3726 p["respt"] = rd;
3727 line::reg::Json rs = line::reg::Json::array();
3728 for (std::size_t c = 0; c < RS.size(); ++c) {
3729 if (RS[c].empty()) continue;
3730 line::reg::Json e = line::reg::Json::object();
3731 e["chain"] = c;
3732 e["t"] = vector_json(RS[c].t);
3733 e["F"] = vector_json(RS[c].F);
3734 rs.push_back(e);
3735 }
3736 p["sysrespt"] = rs;
3737 p["indexBase"] = 0;
3738 // NO "method", as on the reward arm: the response-time laws are computed
3739 // from tagged chains this function does not return, so there is no
3740 // resolved method to report and the requested one is not it.
3741 emit_analysis<T>("cdf", p, std::string());
3742 return 0;
3743 }
3744 std::printf("SolverCTMC arith=%s chains=%zu\n", line::num_traits<T>::name(), sn.nchains);
3745 std::printf("%-16s %-14s %14s %14s\n", "Station", "JobClass", "Time", "F(t)");
3746 for (std::size_t i = 0; i < RD.size(); ++i)
3747 for (std::size_t c = 0; c < RD[i].size(); ++c) {
3748 // An empty curve is not a degenerate one: the chain never visits
3749 // that pair, so there is no arrival event to condition on.
3750 if (RD[i][c].empty()) continue;
3751 for (std::size_t j = 0; j < RD[i][c].t.size(); ++j)
3752 std::printf("%-16s %-14s %14.8g %14.10g\n", sn.stations[i].name.c_str(),
3753 sn.classes[c].name.c_str(),
3754 line::num_traits<T>::to_double(RD[i][c].t[j]),
3755 line::num_traits<T>::to_double(RD[i][c].F[j]));
3756 }
3757 std::printf("%-16s %-14s %14s %14s\n", "System", "Chain", "Time", "F(t)");
3758 for (std::size_t c = 0; c < RS.size(); ++c) {
3759 if (RS[c].empty()) continue;
3760 for (std::size_t j = 0; j < RS[c].t.size(); ++j)
3761 std::printf("%-16s %-14zu %14.8g %14.10g\n", "(system)", c + 1,
3763 line::num_traits<T>::to_double(RS[c].F[j]));
3764 }
3765 return 0;
3766}
3767
3768/**
3769 * A `--passage-from`/`--passage-into` spec as a Matrix<double>: either a flat
3770 * comma list ("3,5", one row the resolver reads as 1-based indices when every
3771 * entry is one) or semicolon-separated state rows ("0,2;1,1"), which resolve
3772 * against the enumerated space by content.
3773 */
3774inline line::Matrix<double> parse_passage_set(const std::string& spec, const char* flag) {
3775 if (spec.empty()) return line::Matrix<double>(0, 0);
3776 std::vector<std::vector<double>> rows;
3777 std::size_t pos = 0;
3778 while (pos <= spec.size()) {
3779 std::size_t semi = spec.find(';', pos);
3780 if (semi == std::string::npos) semi = spec.size();
3781 std::string rowtxt = spec.substr(pos, semi - pos);
3782 std::vector<double> row;
3783 std::size_t p2 = 0;
3784 while (p2 <= rowtxt.size()) {
3785 std::size_t comma = rowtxt.find(',', p2);
3786 if (comma == std::string::npos) comma = rowtxt.size();
3787 std::string cell = rowtxt.substr(p2, comma - p2);
3788 if (!cell.empty()) {
3789 char* endp = 0;
3790 const double v = std::strtod(cell.c_str(), &endp);
3791 if (endp == cell.c_str() || *endp != '\0')
3792 throw line::InputError(std::string(flag) + ": '" + cell +
3793 "' is not a number");
3794 row.push_back(v);
3795 }
3796 p2 = comma + 1;
3797 }
3798 if (!row.empty()) rows.push_back(row);
3799 pos = semi + 1;
3800 }
3801 if (rows.empty()) return line::Matrix<double>(0, 0);
3802 for (std::size_t i = 1; i < rows.size(); ++i)
3803 if (rows[i].size() != rows[0].size())
3804 throw line::InputError(std::string(flag) +
3805 ": every state row must have the same width");
3806 line::Matrix<double> out(rows.size(), rows[0].size());
3807 for (std::size_t i = 0; i < rows.size(); ++i)
3808 for (std::size_t j = 0; j < rows[i].size(); ++j) out(i, j) = rows[i][j];
3809 return out;
3810}
3811
3812/**
3813 * `-s ctmc -a firstpasst`: `@@SolverCTMC/getCdfFirstPassT(A, B)`, the first
3814 * passage time between two state sets the caller names. `--passage-into` is
3815 * required; an empty `--passage-from` starts from the conditional stationary
3816 * law on the complement of the target, as the reference does.
3817 */
3818template <class T>
3819int solve_ctmc_firstpasst(const line::qn::NetworkStruct<T>& sn,
3820 const line::ctmc::CtmcOptions& opt, const Knobs& k) {
3821 if (k.passage_into.empty())
3822 throw line::InputError(
3823 "-a firstpasst times the passage INTO a state set and needs --passage-into; name it "
3824 "as 1-based rows of the state space ('3,5') or as state rows ('0,2;1,1')");
3825 const line::Matrix<double> A = parse_passage_set(k.passage_from, "--passage-from");
3826 const line::Matrix<double> B = parse_passage_set(k.passage_into, "--passage-into");
3827 const std::string method = k.passage_method.empty() ? "expm" : k.passage_method;
3828
3830 line::ctmc::ctmc_cdf_firstpasst<T>(sn, opt, A, B, method);
3831
3832 if (g_json_output) {
3833 line::reg::Json p = line::reg::Json::object();
3834 p["type"] = "CdfFirstPassT";
3835 p["indexBase"] = 0;
3836 p["t"] = line::reg::Json(fp.t);
3837 p["F"] = line::reg::Json(fp.F);
3838 p["f"] = line::reg::Json(fp.f);
3839 line::reg::Json src = line::reg::Json::array(), tgt = line::reg::Json::array();
3840 for (std::size_t i : fp.source) src.push_back(static_cast<double>(i));
3841 for (std::size_t i : fp.target) tgt.push_back(static_cast<double>(i));
3842 p["source"] = src;
3843 p["target"] = tgt;
3844 emit_analysis<T>("firstpasst", p, method);
3845 return 0;
3846 }
3847 std::printf("SolverCTMC arith=%s method=%s getCdfFirstPassT\n",
3848 line::num_traits<T>::name(), method.c_str());
3849 std::printf("source states: %zu%s, target states: %zu\n", fp.source.size(),
3850 fp.source.empty() ? " (conditional stationary law)" : "", fp.target.size());
3851 std::printf("%14s %14s %14s\n", "Time", "F(t)", "f(t)");
3852 for (std::size_t j = 0; j < fp.t.size(); j += 111)
3853 std::printf("%14.8g %14.10g %14.10g\n", fp.t[j], fp.F[j], fp.f[j]);
3854 std::printf("%14.8g %14.10g %14.10g\n", fp.t.back(), fp.F.back(), fp.f.back());
3855 return 0;
3856}
3857
3858/**
3859 * `-s ctmc -a firstpasstmom`: `@@SolverCTMC/getFirstPassTMoments(A, B, nmax)`,
3860 * the moments of the same passage `firstpasst` gives the curve of.
3861 *
3862 * These are EXACT and cost one linear solve per order, so a caller who wants a
3863 * variance or a skewness should ask for them here rather than integrate the
3864 * truncated curve the other arm returns. `--passage-orders` is the nmax; it
3865 * defaults to the reference's 3.
3866 */
3867template <class T>
3868int solve_ctmc_firstpasst_moments(const line::qn::NetworkStruct<T>& sn,
3869 const line::ctmc::CtmcOptions& opt, const Knobs& k) {
3870 if (k.passage_into.empty())
3871 throw line::InputError(
3872 "-a firstpasstmom times the passage INTO a state set and needs --passage-into; name "
3873 "it as 1-based rows of the state space ('3,5') or as state rows ('0,2;1,1')");
3874 const line::Matrix<double> A = parse_passage_set(k.passage_from, "--passage-from");
3875 const line::Matrix<double> B = parse_passage_set(k.passage_into, "--passage-into");
3876 const std::size_t nmax = (k.passage_orders > 0) ? k.passage_orders : 3;
3877
3879 line::ctmc::ctmc_firstpasst_moments<T>(sn, opt, A, B, nmax);
3880
3881 if (g_json_output) {
3882 line::reg::Json p = line::reg::Json::object();
3883 p["type"] = "FirstPassTMoments";
3884 p["indexBase"] = 0;
3885 line::reg::Json m = line::reg::Json::array();
3886 for (std::size_t i = 0; i < fm.m.size(); ++i)
3887 m.push_back(line::num_traits<T>::to_double(fm.m[i]));
3888 p["m"] = m;
3889 line::reg::Json mall = line::reg::Json::array();
3890 for (std::size_t i = 0; i < fm.mall.rows(); ++i) {
3891 line::reg::Json row = line::reg::Json::array();
3892 for (std::size_t j = 0; j < fm.mall.cols(); ++j)
3893 row.push_back(line::num_traits<T>::to_double(fm.mall(i, j)));
3894 mall.push_back(row);
3895 }
3896 p["mall"] = mall;
3897 line::reg::Json src = line::reg::Json::array(), tgt = line::reg::Json::array();
3898 for (std::size_t i : fm.source) src.push_back(static_cast<double>(i));
3899 for (std::size_t i : fm.target) tgt.push_back(static_cast<double>(i));
3900 p["source"] = src;
3901 p["target"] = tgt;
3902 emit_analysis<T>("firstpasstmom", p, "moments");
3903 return 0;
3904 }
3905 std::printf("SolverCTMC arith=%s getFirstPassTMoments\n", line::num_traits<T>::name());
3906 std::printf("source states: %zu%s, target states: %zu\n", fm.source.size(),
3907 fm.source.empty() ? " (conditional stationary law)" : "", fm.target.size());
3908 std::printf("%8s %20s\n", "Order", "Moment");
3909 for (std::size_t i = 0; i < fm.m.size(); ++i)
3910 std::printf("%8zu %20.10g\n", i + 1, line::num_traits<T>::to_double(fm.m[i]));
3911 return 0;
3912}
3913
3914/**
3915 * Solve a Network model.json with SolverCTMC and print what `-a` asked for.
3916 *
3917 * EXACT, and the only ported solver that is exact on a non-product-form model:
3918 * it enumerates the state space and solves pi Q = 0, so the numbers are the
3919 * chain's own and not an approximation of them. The price is the state space,
3920 * which is why an OPEN model needs `--cutoff`: without a bound on the open
3921 * population the chain is infinite. The banner reports the cutoff that was used,
3922 * because a truncated chain's answer is not the model's answer without it.
3923 *
3924 * Every arithmetic backend runs the stationary analyses: the generator assembly
3925 * and the stationary solve are field operations throughout, so `--arith exact`
3926 * returns the exact rational stationary law of a chain with rational rates. The
3927 * transient ones refuse by name under exact, since a forward integration, an
3928 * exponential clock and a matrix exponential are all transcendental.
3929 */
3930/**
3931 * `--rate-sched`: the JSON schedule, inline or from a file, resolved against the
3932 * model so that a station or class may be named rather than numbered.
3933 */
3934template <class T>
3935std::vector<line::ctmc::CtmcRateSched> parse_rate_sched(const std::string& arg,
3936 const line::qn::NetworkStruct<T>& sn) {
3937 std::string text = arg;
3938 const std::size_t first = arg.find_first_not_of(" \t\r\n");
3939 if (first == std::string::npos || (arg[first] != '[' && arg[first] != '{')) {
3940 std::ifstream in(arg.c_str());
3941 if (!in)
3942 throw line::InputError("--rate-sched: '" + arg +
3943 "' is neither inline JSON nor a readable file");
3944 std::ostringstream ss;
3945 ss << in.rdbuf();
3946 text = ss.str();
3947 }
3948 line::io::detail::json root;
3949 try {
3950 root = line::io::detail::json::parse(text);
3951 } catch (const std::exception& e) {
3952 throw line::InputError(std::string("--rate-sched: not valid JSON: ") + e.what());
3953 }
3954 if (root.is_object()) root = line::io::detail::json::array({root});
3955 if (!root.is_array() || root.empty())
3956 throw line::InputError("--rate-sched: expected a non-empty array of schedule objects");
3957
3958 const auto resolve = [](const line::io::detail::json& v, const std::vector<std::string>& names,
3959 const char* what) -> std::size_t {
3960 if (v.is_number_integer()) {
3961 const long long i = v.get<long long>();
3962 if (i < 1 || static_cast<std::size_t>(i) > names.size())
3963 throw line::InputError(std::string("--rate-sched: ") + what + " index " +
3964 std::to_string(i) + " is out of range (1-based)");
3965 return static_cast<std::size_t>(i);
3966 }
3967 if (v.is_string()) {
3968 const std::string nm = v.get<std::string>();
3969 for (std::size_t i = 0; i < names.size(); ++i)
3970 if (names[i] == nm) return i + 1;
3971 throw line::InputError(std::string("--rate-sched: no ") + what + " named '" + nm + "'");
3972 }
3973 throw line::InputError(std::string("--rate-sched: ") + what +
3974 " must be a name or a 1-based index");
3975 };
3976 std::vector<std::string> stations, classes;
3977 for (std::size_t i = 0; i < sn.stations.size(); ++i)
3978 stations.push_back(sn.nodes[sn.station_to_node[i] - 1].name);
3979 for (const auto& c : sn.classes) classes.push_back(c.name);
3980
3981 std::vector<line::ctmc::CtmcRateSched> out;
3982 for (const auto& e : root) {
3983 if (!e.is_object() || !e.contains("station") || !e.contains("class") ||
3984 !e.contains("tgrid") || !e.contains("rates"))
3985 throw line::InputError(
3986 "--rate-sched: every entry needs station, class, tgrid and rates");
3988 rs.station = resolve(e.at("station"), stations, "station");
3989 rs.cls = resolve(e.at("class"), classes, "class");
3990 rs.tgrid = e.at("tgrid").get<std::vector<double>>();
3991 rs.rates = e.at("rates").get<std::vector<double>>();
3992 if (e.contains("nominal") && !e.at("nominal").is_null())
3993 rs.nominal = e.at("nominal").get<double>();
3994 out.push_back(rs);
3995 }
3996 return out;
3997}
3998
3999template <class T>
4000int solve_model_ctmc(const std::string& file, const Knobs& k, const std::string& analysis) {
4001 line::qn::Network<T> net = read_model<T>(file);
4003 if (!k.method.empty()) opt.method = k.method;
4004 if (k.cutoff >= 0.0) opt.cutoff = k.cutoff;
4005 opt.cutoff_mat = k.cutoff_mat;
4006 opt.force = k.force;
4007 if (k.timestep > 0.0) opt.timestep = k.timestep; // `--timestep`, the fixed output grid
4008 // `--transient-method` and its two tolerances, `options.config` of the
4009 // reference's transient analyzer. The analyzer validates the name.
4010 if (!k.transient_method.empty()) opt.transient_method = k.transient_method;
4011 if (k.fau_epsilon > 0.0) opt.fau_epsilon = k.fau_epsilon;
4012 if (k.fau_delta >= 0.0) opt.fau_delta = k.fau_delta;
4013 if (k.ctmc_tv_ngrid > 0) opt.ctmc_tv_ngrid = k.ctmc_tv_ngrid;
4014 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4015 if (!k.rate_sched.empty()) opt.rate_sched = parse_rate_sched(k.rate_sched, sn);
4016
4017 // The method that never builds the generator is served by its own analyzer,
4018 // ahead of every state-space path below. The dispatcher has already refused
4019 // every analysis but `avg` for it, since it produces no state space, no
4020 // filtration and no trajectory to answer one from.
4021 if (opt.method == "mdd") {
4023 if (k.mdd_tol > 0.0) mcdopt.tol = k.mdd_tol;
4024 if (k.mdd_maxiter > 0) mcdopt.maxiter = k.mdd_maxiter;
4025 return solve_ctmc_mdd_avg<T>(sn, opt, mcdopt);
4026 }
4027
4028 // The field-arithmetic analyses: every step from the generator to the answer
4029 // is a ring operation, so exact returns the exact rational quantity.
4030 if (analysis == "avg") return solve_ctmc_avg<T>(sn, opt);
4031 if (analysis == "prob") return solve_ctmc_prob<T>(sn, opt, k);
4032 if (analysis == "gen") return solve_ctmc_gen<T>(sn, opt);
4033 if (analysis == "states") return solve_ctmc_states<T>(sn, opt);
4034 if (analysis == "sens") return solve_ctmc_sens<T>(sn, opt);
4035 if (analysis == "reward") return solve_ctmc_reward<T>(sn, opt);
4036 if (analysis == "rewardvalue") return solve_ctmc_reward_value<T>(sn, opt, k);
4037
4038 // The rest integrate a forward equation, draw an exponential clock or take a
4039 // matrix exponential, none of which exists in a rational field. Their
4040 // static_asserts are behind if-constexpr so the refusal is a message rather
4041 // than a compile error in the exact instantiation.
4044 "the -a " + analysis +
4045 " analysis integrates the forward equation, draws exponential clocks or takes a matrix "
4046 "exponential, none of which exists in exact rational arithmetic; rerun with --arith "
4047 "double or --arith real");
4048 } else {
4049 if (analysis == "tran") return solve_ctmc_tran<T>(sn, opt, k);
4050 if (analysis == "tranprob") return solve_ctmc_tranprob<T>(sn, opt, k);
4051 if (analysis == "tranreward") return solve_ctmc_tran_reward<T>(sn, opt, k);
4052 if (analysis == "sample") return solve_ctmc_sample<T>(sn, opt, k);
4053 if (analysis == "firstpasst") return solve_ctmc_firstpasst<T>(sn, opt, k);
4054 if (analysis == "firstpasstmom") return solve_ctmc_firstpasst_moments<T>(sn, opt, k);
4055 return solve_ctmc_cdf<T>(sn, opt); // the dispatcher admitted no other name
4056 }
4057}
4058
4059/**
4060 * `-s ssa -a prob`: the four SolverSSA probability queries over the model's
4061 * DEFAULT INITIAL STATE, the same state `-s ctmc -a prob` reports on.
4062 *
4063 * THE PAIR IS THE POINT. The CTMC answer is the stationary law of the chain and
4064 * this one is a time average of a finite sample path, so running both on a model
4065 * small enough for the chain measures the simulation error directly instead of
4066 * inferring it. The banner therefore carries the run length and the seed, as
4067 * every simulated number on this CLI does.
4068 *
4069 * `seen` TRAVELS WITH EACH PROBABILITY because a zero here has two meanings: the
4070 * path visited the state and left immediately, or it never got there at all. The
4071 * reference warns on the second; a machine-readable answer has to carry the
4072 * distinction rather than print it.
4073 */
4074template <class T>
4075int solve_model_ssa_prob(const std::string& file, const Knobs& k) {
4076 line::qn::Network<T> net = read_model<T>(file);
4077 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4079 // `serial`, whatever `-m` said, and for the reference's own reason: the NRM
4080 // simulates per-(node, class, phase) counts rather than the state ENCODING,
4081 // so it has no row to compare a requested state against.
4082 // `@@SolverSSA/getProb.m` rewrites the method the same way.
4083 opt.method = "serial";
4084 if (k.samples) opt.samples = k.samples;
4085 if (k.seed) opt.seed = k.seed;
4086 if (k.warmupfrac >= 0.0) opt.warmupfrac = k.warmupfrac;
4087 if (k.cutoff >= 0.0) opt.cutoff = k.cutoff;
4089
4090 if (g_json_output) {
4091 line::reg::Json p = line::reg::Json::object();
4092 p["type"] = "ProbAggr";
4093 p["indexBase"] = 0;
4094 p["samples"] = r.samples;
4095 p["seed"] = r.seed;
4096 p["ProbSys"] = r.sys.prob;
4097 p["ProbSysAggr"] = r.sys_aggr.prob;
4098 p["ProbSysSeen"] = r.sys.seen;
4099 p["ProbSysAggrSeen"] = r.sys_aggr.seen;
4100 line::reg::Json st = line::reg::Json::array(), pm = line::reg::Json::array(),
4101 pa = line::reg::Json::array(), sm = line::reg::Json::array();
4102 for (std::size_t i = 0; i < sn.nstations; ++i) {
4103 st.push_back(sn.stations[i].name);
4104 pm.push_back(r.marg[i].prob);
4105 pa.push_back(r.aggr[i].prob);
4106 sm.push_back(r.marg[i].seen);
4107 }
4108 p["Station"] = st;
4109 p["Prob"] = pm;
4110 p["ProbAggr"] = pa;
4111 p["Seen"] = sm;
4112 emit_analysis<T>("prob", p, "serial");
4113 return 0;
4114 }
4115 std::printf("SolverSSA arith=%s method=serial samples=%zu seed=%lu time=%.6g\n",
4117 std::printf("ProbSys = %.8g%s\n", r.sys.prob, r.sys.seen ? "" : " (state never visited)");
4118 std::printf("ProbSysAggr = %.8g%s\n", r.sys_aggr.prob,
4119 r.sys_aggr.seen ? "" : " (state never visited)");
4120 std::printf("%-20s %14s %14s\n", "Station", "Prob", "ProbAggr");
4121 for (std::size_t i = 0; i < sn.nstations; ++i)
4122 std::printf("%-20s %14.8g %14.8g\n", sn.stations[i].name.c_str(), r.marg[i].prob,
4123 r.aggr[i].prob);
4124 return 0;
4125}
4126
4127/**
4128 * `-s ssa -a sample`: `sampleSys` and `sampleSysAggr`, one simulated trajectory;
4129 * with `--node`, also that node's own block (`sample`) and per-class counts
4130 * (`sampleAggr`).
4131 *
4132 * The SAME shape `-s ctmc -a sample` emits, deliberately: the CTMC sampler walks
4133 * the jump chain of an enumerated generator and this one walks the network's own
4134 * encoding, and a host that can read one trajectory should be able to read the
4135 * other. The event column indexes the synchronization list, so the two are
4136 * comparable only within a solver -- which is why it is printed and not
4137 * interpreted here.
4138 */
4139template <class T>
4140int solve_model_ssa_sample(const std::string& file, const Knobs& k) {
4141 line::qn::Network<T> net = read_model<T>(file);
4142 const line::qn::NetworkStruct<T>& sn_model = net.get_struct();
4144 opt.method = "serial";
4145 opt.samples = k.events ? k.events : (k.samples ? k.samples : 1000); // see -a sample above
4146 if (k.seed) opt.seed = k.seed;
4147 if (k.warmupfrac >= 0.0) opt.warmupfrac = k.warmupfrac;
4148 if (k.cutoff >= 0.0) opt.cutoff = k.cutoff;
4150 // the path's rows are in the layout of the struct it ran on (pair form, `ctmc_mapc`)
4151 const line::qn::NetworkStruct<T>& sn = sim.solved_sn ? *sim.solved_sn : sn_model;
4154 if (k.node) nodep = line::ssa::ssa_sample_node(sn, sim.run, k.node);
4155
4156 if (g_json_output) {
4157 line::reg::Json p = line::reg::Json::object();
4158 p["type"] = "SamplePath";
4159 p["indexBase"] = 0;
4160 p["events"] = opt.samples;
4161 p["seed"] = sys.seed;
4162 p["drawn"] = sys.t.size();
4163 p["scope"] = k.node ? sn.nodes[k.node - 1].name : std::string("(system)");
4164 if (k.node) p["node"] = k.node - 1;
4165 p["t"] = vector_json(sys.t);
4166 p["event"] = index_json(sys.event);
4167 p["state"] = matrix_json(sys.state);
4168 // THE BLOCK BOUNDARIES TRAVEL, as under `-s ctmc -a sample`. A row of
4169 // `state` is the stateful nodes' local encodings laid end to end, and
4170 // `sampleSys` reports them one node at a time; a host that had to guess
4171 // the widths would cut the row at the wrong columns on any model with a
4172 // phase-type service, and read a phase index as a job count.
4173 {
4174 line::reg::Json w = line::reg::Json::array();
4175 for (std::size_t f = 0; f < sn.stateful_nodes.size(); ++f)
4176 w.push_back(sim.run.space.empty() ? 0 : sim.run.space[0].local[f].size());
4177 p["NodeWidths"] = w;
4178 }
4179 p["sysAggr"] = matrix_json(sys.aggr);
4180 if (k.node) {
4181 p["nodeState"] = matrix_json(nodep.state);
4182 p["nodeAggr"] = matrix_json(nodep.aggr);
4183 }
4184 emit_analysis<T>("sample", p, "serial");
4185 return 0;
4186 }
4187 std::printf("SolverSSA arith=%s method=serial events=%zu seed=%lu drawn=%zu scope=%s\n",
4188 line::num_traits<T>::name(), opt.samples, sys.seed, sys.t.size(),
4189 k.node ? sn.nodes[k.node - 1].name.c_str() : "(system)");
4190 std::printf("%14s %8s %s\n", "Time", "Event",
4191 k.node ? "SysAggregate | NodeState | NodeAggregate" : "SysAggregate");
4192 for (std::size_t i = 0; i < sys.t.size(); ++i) {
4193 std::printf("%14.8g %8zu ", sys.t[i], sys.event[i]);
4194 for (std::size_t c = 0; c < sys.aggr.cols(); ++c)
4195 std::printf(" %g", line::num_traits<T>::to_double(sys.aggr(i, c)));
4196 if (k.node) {
4197 std::printf(" |");
4198 for (std::size_t c = 0; c < nodep.state.cols(); ++c)
4199 std::printf(" %g", line::num_traits<T>::to_double(nodep.state(i, c)));
4200 std::printf(" |");
4201 for (std::size_t c = 0; c < nodep.aggr.cols(); ++c)
4202 std::printf(" %g", line::num_traits<T>::to_double(nodep.aggr(i, c)));
4203 }
4204 std::printf("\n");
4205 }
4206 return 0;
4207}
4208
4209/**
4210 * A `Matrix<double>` read as this arithmetic's matrix.
4211 *
4212 * SSA and Fluid return plain-double solutions whatever `T` the CLI was asked
4213 * for, so every shared routine that takes a `Matrix<T>` -- the residence-time
4214 * conversion, the chain aggregation -- needs this one lift. Both are refused
4215 * outside `--arith double` anyway, so it is a type bridge and not a precision
4216 * claim.
4217 */
4218template <class T>
4219line::Matrix<T> to_matrix(const line::Matrix<double>& m) {
4221 for (std::size_t i = 0; i < m.rows(); ++i)
4222 for (std::size_t j = 0; j < m.cols(); ++j)
4223 out(i, j) = line::num_traits<T>::from_double(m(i, j));
4224 return out;
4225}
4226
4227/**
4228 * Solve a Network model.json with SolverSSA and print the same table.
4229 *
4230 * A SIMULATION: its numbers carry Monte Carlo error, so the row is compared
4231 * against the other codebases' SSA rows and not against an exact solver's.
4232 *
4233 * `--samples` and `--seed` set the run length and the stream; without them the
4234 * defaults are 10000 firings and seed 23000, which on a two-station model is
4235 * roughly 3300 job cycles and lands a few percent from the analytical answer.
4236 * Diffing THAT against an exact solver reads as a defect and is not one: a
4237 * measured -3.12% at 1e4 on an M/M/1 falls to +0.06% at 2.56e6. The banner
4238 * therefore carries both numbers, so a row quoting an SSA figure carries the
4239 * conditions that produced it. Double only, refused by name in the dispatcher.
4240 *
4241 * `-m` REACHES THE SOLVER'S OWN DISPATCHER, `ssa::solver_ssa`, and not one
4242 * engine's entry. Calling `solver_ssa_nrm_analyzer` here would run the NRM
4243 * whatever `-m` said, so `-m serial` would silently answer with a different
4244 * estimator than the one asked for -- and the three names the NRM entry cannot
4245 * serve (`serial`, `para`, `parallel`) are all methods the library honours.
4246 */
4247template <class T>
4248int solve_model_ssa(const std::string& file, const Knobs& k) {
4249 line::qn::Network<T> net = read_model<T>(file);
4251 if (!k.method.empty() && k.method != "default") opt.method = k.method;
4252 if (k.samples) opt.samples = k.samples;
4253 if (k.seed) opt.seed = k.seed;
4254 if (k.warmupfrac >= 0.0) opt.warmupfrac = k.warmupfrac;
4255 // The cache write-back is COLLECTED HERE, not only on the `-a node` path:
4256 // the realized hit and miss shares are what the sample path measured, and
4257 // `-a avg` is the arm MATLAB's lang='cpp' bridge calls. Without it
4258 // `CPPLINE.restoreCacheResults` found no block, cleared the Cache node and
4259 // refreshed the visits back to link()'s offered 1/2-1/2.
4260 std::vector<line::ssa::SsaCacheRatio> cacheratio;
4261 const line::ssa::SsaSolution r = line::ssa::solver_ssa(net.get_struct(), opt, &cacheratio);
4262 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4263
4264 // The seed and the sample count are part of the ANSWER, not of the
4265 // invocation: two runs of a simulation are the same measurement only if
4266 // both are stated, so the banner carries them and a parity row that quotes
4267 // an SSA number carries them with it.
4268 //
4269 // `r.method` is the engine that ACTUALLY ran, never `k.method`: `-m
4270 // parallel` on an NRM-eligible model reports `nrm`, because that is what
4271 // produced the numbers below and the banner may not claim otherwise.
4272 std::printf("SolverSSA arith=%s method=%s type=%s samples=%zu seed=%lu time=%.6g\n",
4273 line::num_traits<T>::name(), r.method.c_str(),
4274 line::util::method_type("SSA", r.method).c_str(), r.samples, opt.seed,
4275 r.simulated_time);
4276 // ResidT IS NOT RespT UNLESS EVERY STATION IS VISITED ONCE PER CYCLE.
4277 // `sn_get_residt_from_respt` is the reference's own per-visit -> per-job
4278 // conversion and a pure function of `sn` and RN, so a solver that reports no
4279 // residence time of its own still owes the caller this one: reporting RespT
4280 // in its place was a factor of 3 out on sdroute_closed and 17 on Queue1 of
4281 // init_state_ps, both multi-visit closed models.
4282 //
4283 // TAKEN ON THE MEASURED CACHE SPLIT, not on the offered one: the visits this
4284 // conversion divides by are a function of the routing, and a cache's routing
4285 // is a RESULT. On tut06_cache_lru_zipf the base struct still carried
4286 // `link()`'s even hit/miss share, so both classes came back at exactly half
4287 // their response time (0.1 and 0.5 against 0.16475 and 0.17625) -- a number
4288 // that is not the residence time of any model.
4290 const line::Matrix<T> WN = line::mva::sn_get_residt_from_respt<T>(snw, to_matrix<T>(r.RN));
4291 line::reg::Json extra = line::reg::Json::object();
4293 if (!cache.empty()) extra["Cache"] = cache_extra_json<T>(cache);
4294 // ArvR IS NOT Tput, and reading it off the throughput column was wrong
4295 // wherever the two differ -- most visibly at a JOIN, which takes in one
4296 // sibling per branch and fires once per parent, so its arrival rate is the
4297 // fork degree times its throughput. `ssa_fj_foldback` already divides the
4298 // Join's QLen by the DERIVED rate to get its response time, so reporting
4299 // Tput in the ArvR column left the printed row self-inconsistent
4300 // (0.62372/1.02229 is 0.610, not the 0.3038 beside it). Taken on the
4301 // measured-cache-split struct for the same reason ResidT is.
4302 const line::Matrix<T> AN = line::mva::sn_get_arvr_from_tput<T>(snw, to_matrix<T>(r.TN));
4303 emit_avg_table<T>(sn, r.method, [&](std::size_t i, std::size_t c) {
4304 AvgRow row;
4305 row.q = r.QN(i, c);
4306 row.u = r.UN(i, c);
4307 row.r = r.RN(i, c);
4308 row.w = line::num_traits<T>::to_double(WN(i, c));
4309 row.t = r.TN(i, c);
4310 // A Source has no arrivals TO ITSELF, so its ArvR is 0 while its Tput is
4311 // the arrival rate.
4312 row.a = sn.stations[i].sched == line::lang::SchedStrategy::EXT
4313 ? 0.0
4314 : line::num_traits<T>::to_double(AN(i, c));
4315 return row;
4316 }, extra);
4317 return 0;
4318}
4319
4320/** The knobs the fluid solver reads, in one place so every fluid arm reads the
4321 * same set: an arm that quietly dropped one would answer a different model. */
4322inline line::fluid::FluidOptions fluid_options(const Knobs& k) {
4324 if (!k.method.empty()) opt.method = k.method;
4325 if (k.tol >= 0.0) opt.tol = k.tol;
4326 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
4327 if (k.iter_max >= 0) opt.iter_max = static_cast<std::size_t>(k.iter_max);
4328 if (k.t1 >= 0.0) opt.timespan_end = k.t1;
4329 // `pstar_set` is what makes the smoothing active under a method that did
4330 // not ask for it by name, exactly as `options.config.pstar` does in the
4331 // reference: setting the exponent alone would leave `-a avg` integrating
4332 // the hard-min drift while reporting the caller's choice.
4333 if (k.pstar > 0.0) {
4334 opt.pstar = k.pstar;
4335 opt.pstar_set = true;
4336 }
4337 return opt;
4338}
4339
4340/**
4341 * Solve a Network model.json with the fluid solver and print the same table as
4342 * the MVA path, so the parity harness can diff the two rows unchanged.
4343 *
4344 * ResidT and ArvR are reported as the per-visit response time and the
4345 * throughput: the fluid analyzer works at station level and, unlike the MVA
4346 * runner, has no chain-visit conversion behind it. That is what MATLAB's
4347 * fluid `getAvgTable` shows for these columns on a single-visit model.
4348 *
4349 * Only `double` reaches here -- the drift is integrated by LSODA -- so the
4350 * other backends are refused by name in `solve_model_dispatch` rather than
4351 * being narrowed silently.
4352 */
4353template <class T>
4354int solve_model_fluid(const std::string& file, const Knobs& k) {
4355 line::qn::Network<T> net = read_model<T>(file);
4356 const line::fluid::FluidOptions opt = fluid_options(k);
4357 // THE map_env FUNNEL, asked directly rather than through `run_avg`: this
4358 // runner returns a `FluidSolution` and not an `AvgResult`, so the arm cannot
4359 // pass itself as the `Run` callable. The decision and the config are the
4360 // ones `run_avg` would have used, so `-a avg` and the four
4361 // `run_avg_engine` views accept the same models for the same command line.
4362 // No arithmetic guard is needed here: this arm is instantiated at `double`
4363 // alone, the dispatcher having refused the other backends by name.
4364 {
4365 const line::solvers::MapEnvConfig mecfg = map_env_config(k);
4367 net.get_struct(), mecfg)
4368 .needed) {
4370 net.get_struct(), "SolverFluid", mecfg, line::solvers::fluid_stage_fn(opt),
4371 opt.method);
4372 std::printf("SolverFluid arith=%s method=%s type=%s iters=%d\n",
4374 line::util::method_type("FLD", rme.actualmethod).c_str(), rme.iter);
4375 print_avg_table<T>(net.get_struct(), rme);
4376 return 0;
4377 }
4378 }
4379 // `solver_fluid_run_analyzer` is runAnalyzer's resolution over the analyzer, so a
4380 // Cache model reaches the rmf branch, a DPS model the closing drift, and
4381 // anything the moment closure accepts reaches `minnormal`.
4382 // The converged hit/miss split is COLLECTED, for the reason the SSA arm
4383 // above collects its own: `-a avg` is what MATLAB's lang='cpp' bridge calls,
4384 // and a missing block there CLEARS the host's Cache node.
4386 // THE REFRESHED STRUCT IS TAKEN, not dropped: on a cache model the routing
4387 // the analyzer converged to carries the ACTUAL hit/miss split, where
4388 // `net.get_struct()` still carries link()'s offered one. The arrival rates
4389 // below are read off that routing, so the offered split reported 0.5/0.5
4390 // where the model converged to 0.4/0.6 (cache_replc_routing).
4393 net.get_struct(), opt, static_cast<line::qn::NetworkStruct<T>*>(nullptr),
4394 &refreshed, &cache);
4395 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4396 const line::qn::NetworkStruct<T>& snflow =
4397 refreshed.nstations == sn.nstations && refreshed.nclasses == sn.nclasses ? refreshed : sn;
4398
4399 std::printf("SolverFluid arith=%s method=%s type=%s iters=%zu\n", line::num_traits<T>::name(),
4400 r.method.c_str(),
4401 line::util::method_type("FLD", r.method).c_str(), r.iters);
4402 // The residence-time conversion the SSA arm above applies, for the same
4403 // reason: neither analyzer produces a per-job residence time, and
4404 // `sn_get_residt_from_respt` derives one from RN and the visit ratios.
4405 const line::Matrix<T> WN = line::mva::sn_get_residt_from_respt<T>(sn, to_matrix<T>(r.RN));
4406 // THE ARRIVAL RATE IS A FLOW, NOT A COPY OF THE THROUGHPUT. `runAnalyzer`
4407 // takes it from `sn_get_arvr_from_tput`, i.e. from the class-expanded
4408 // routing, and the two agree only where every job a station serves it also
4409 // completes. They part on a station a job LEAVES by another route: on
4410 // cache_replc_routing the fluid solution puts zero throughput on the two
4411 // Delay stations while 0.4 and 0.6 arrive at them, so copying the
4412 // throughput made both rows all-zero and the table dropped them.
4413 const line::Matrix<T> AN = line::mva::sn_get_arvr_from_tput<T>(snflow, to_matrix<T>(r.TN));
4414 line::reg::Json extra = line::reg::Json::object();
4415 if (!cache.empty()) extra["Cache"] = cache_extra_json<T>(cache);
4416 emit_avg_table<T>(sn, r.method, [&](std::size_t i, std::size_t c) {
4417 AvgRow row;
4418 row.q = r.QN(i, c);
4419 row.u = r.UN(i, c);
4420 row.r = r.RN(i, c);
4421 row.w = line::num_traits<T>::to_double(WN(i, c));
4422 row.t = r.TN(i, c);
4423 // A Source has no arrivals TO ITSELF, so its ArvR is 0 while its Tput is
4424 // the arrival rate -- the same rule the SSA arm above applies. Without it
4425 // the two CLI paths disagreed on one column of the same model:
4426 // gallery_mm1 reported Source ArvR 0 under -s mva and 1 under -s fluid.
4427 row.a = sn.stations[i].sched == line::lang::SchedStrategy::EXT
4428 ? 0.0
4429 : line::num_traits<T>::to_double(AN(i, c));
4430 return row;
4431 }, extra);
4432 return 0;
4433}
4434
4435/**
4436 * `-s fluid -a statevec`: the converged FLUID STATE VECTOR, `result.odeStateVec`.
4437 *
4438 * NOT A METRIC AND NOT INDEXED LIKE ONE. The ODE state carries one coordinate
4439 * per (station, class, PHASE), so a two-phase Erlang service contributes two
4440 * entries where the AvgTable contributes one number, and the sum over a
4441 * station's phases is its mean queue length. It is what a caller needs to
4442 * restart an integration, to seed another solver, or to read the phase
4443 * occupancy the means average away -- which is why the JAR exposes it as its
4444 * own `-a statevec` rather than as a column.
4445 *
4446 * The index layout is `fluid_state_layout`'s and is emitted BESIDE the vector,
4447 * because a bare list of numbers cannot be related back to a station without it.
4448 */
4449template <class T>
4450int solve_model_fluid_statevec(const std::string& file, const Knobs& k) {
4451 line::qn::Network<T> net = read_model<T>(file);
4452 const line::fluid::FluidOptions opt = fluid_options(k);
4454 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4455 if (r.xvec.empty())
4457 "-a statevec reports the converged ODE state and this solve produced none; the rmf "
4458 "and closing branches integrate a drift and fill it, so a branch that returns means "
4459 "directly has no state vector to report");
4460
4461 // The (station, class, phase) each coordinate belongs to, in the order the
4462 // ODE state is laid out: station-major, then class, then phase.
4463 std::vector<std::size_t> ist, cls, phs;
4464 for (std::size_t i = 0; i < sn.nstations; ++i)
4465 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4466 const std::size_t np = sn.phases_of(i + 1, c + 1);
4467 for (std::size_t j = 0; j < np; ++j) {
4468 ist.push_back(i);
4469 cls.push_back(c);
4470 phs.push_back(j);
4471 }
4472 }
4473 // The layout is a CLAIM about the solver's state ordering, so it is checked
4474 // rather than asserted in a comment: a mismatch means the labels below would
4475 // name the wrong station, which is worse than no labels at all.
4476 const bool labelled = ist.size() == r.xvec.size();
4477
4478 if (g_json_output) {
4479 line::reg::Json p = line::reg::Json::object();
4480 p["type"] = "FluidStateVec";
4481 p["indexBase"] = 0;
4482 p["xvec"] = vector_json(r.xvec);
4483 p["labelled"] = labelled;
4484 if (labelled) {
4485 line::reg::Json st = line::reg::Json::array(), cl = line::reg::Json::array(),
4486 ph = line::reg::Json::array();
4487 for (std::size_t j = 0; j < ist.size(); ++j) {
4488 st.push_back(sn.stations[ist[j]].name);
4489 cl.push_back(sn.classes[cls[j]].name);
4490 ph.push_back(phs[j]);
4491 }
4492 p["Station"] = st;
4493 p["JobClass"] = cl;
4494 p["Phase"] = ph;
4495 }
4496 emit_analysis<T>("statevec", p, r.method);
4497 return 0;
4498 }
4499 std::printf("SolverFluid arith=%s method=%s coords=%zu\n", line::num_traits<T>::name(),
4500 r.method.c_str(), r.xvec.size());
4501 if (!labelled) {
4502 std::printf("# the ODE state is %zu wide and the (station, class, phase) layout accounts "
4503 "for %zu; the coordinates are printed unlabelled\n",
4504 r.xvec.size(), ist.size());
4505 std::printf("%-10s %20s\n", "Index", "x");
4506 for (std::size_t j = 0; j < r.xvec.size(); ++j)
4507 std::printf("%-10zu %20.10g\n", j, r.xvec[j]);
4508 return 0;
4509 }
4510 std::printf("%-16s %-14s %-8s %20s\n", "Station", "JobClass", "Phase", "x");
4511 for (std::size_t j = 0; j < r.xvec.size(); ++j)
4512 std::printf("%-16s %-14s %-8zu %20.10g\n", sn.stations[ist[j]].name.c_str(),
4513 sn.classes[cls[j]].name.c_str(), phs[j], r.xvec[j]);
4514 return 0;
4515}
4516
4517/**
4518 * Solve an ENVIRONMENT model.json with SolverENV and print the same average
4519 * table every other arm prints.
4520 *
4521 * THE COLUMNS ARE THE REFERENCE'S, INCLUDING THE TWO IT LEAVES EMPTY.
4522 * `@@SolverENV/getEnsembleAvg` returns Q, U and T from the coupling, sets
4523 * `WNclass = QNclass ./ TNclass` and returns `RNclass` and `ANclass` as NaN --
4524 * ENV blends per-stage metrics over the environment process and computes no
4525 * response time or arrival rate at all. Printing Q/T under RespT here would
4526 * invent a number the reference declines to give, so RespT and ArvR are NaN and
4527 * ResidT carries the Little's-law ratio, exactly as MATLAB's table does.
4528 *
4529 * The station and class names come from stage 1. The mean-field coupling
4530 * already refuses an environment whose stages disagree on the station or class
4531 * count, so any stage names the same rows.
4532 */
4533/**
4534 * Run the coupling `o.method` names, at the arithmetic the caller asked for.
4535 *
4536 * WHY THIS IS NOT JUST `env::solver_env`. That entry instantiates BOTH
4537 * couplings, and the mean-field one solves each stage with the fluid transient
4538 * -- LSODA, hence double. Calling it at `Rational` does not merely give a worse
4539 * answer, it does not compile (`sqrt` on a rational), so the template below
4540 * carries only the state-vector coupling and the double overload beside it
4541 * carries the full dispatch. The dispatcher has already refused `-s env
4542 * --arith exact` without `--method statevec`, so a non-double run reaching here HAS
4543 * asked for the state-vector coupling and gets it, banner included.
4544 */
4545template <class T>
4547 const line::env::EnvOptions& o) {
4549 out.statevec =
4550 line::env::solver_env_statevec(e, line::env::dispatch_detail::env_statevec_options<T>(o));
4551 line::env::dispatch_detail::env_take_statevec(out);
4552 return out;
4553}
4554
4555/** The double case, where both couplings are available. */
4557 const line::env::EnvOptions& o) {
4558 return line::env::solver_env(e, o);
4559}
4560
4561template <class T>
4562int solve_model_env(const std::string& file, const Knobs& k) {
4563 line::env::Environment<T> e = read_env_model<T>(file);
4565 if (!k.method.empty() && k.method != "default") opt.method = k.method;
4566 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
4567 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
4568 if (k.t1 >= 0.0) opt.timespan_end = k.t1;
4569 if (k.tran_points) opt.tran_points = k.tran_points;
4570 if (k.tol >= 0.0) opt.stage.tol = k.tol;
4571 // THE STAGE SOLVER FOLLOWS THE COUPLING, because each coupling has exactly
4572 // one. `EnvOptions::stage_solver` defaults to `fluid`, which is what the
4573 // mean-field coupling needs (a transient mean) and what the state-vector one
4574 // refuses (it needs an enumerated generator and state space, which only
4575 // SolverCTMC exposes). Leaving the default in place made every
4576 // `--method statevec` run die on "stage solver 'fluid' is not available",
4577 // with no flag to fix it. This is not a silent fallback: there is one
4578 // admissible stage solver per coupling, and picking the other would be the
4579 // error.
4580 if (opt.method == "statevec") opt.stage_solver = "ctmc";
4581 // `--stage-solver` OVERRIDES that default, and only the mean-field coupling
4582 // has a choice to make: it needs a transient mean, which both the fluid
4583 // analyzer and the enumerated CTMC produce, and the two are different
4584 // models rather than two routes to one answer (a chain holds whole jobs).
4585 // An ensemble built on SolverCTMC stages therefore has to say so, or the
4586 // engine answers the fluid ensemble under its name -- which is what the
4587 // hosts refused lang='cpp' for.
4588 if (!k.stage_solver.empty()) opt.stage_solver = k.stage_solver;
4589 if (k.cutoff >= 0.0) opt.stage_cutoff = k.cutoff;
4590
4591 // UNQUALIFIED, so the double overload above wins for T = double: naming the
4592 // template explicitly would send every arithmetic to the state-vector
4593 // coupling and quietly ignore `--method meanfield`.
4594 const line::env::EnvAnalyzerSolution<T> r = env_run(e, opt);
4595 const line::qn::NetworkStruct<T>& sn = e.stage(0).model;
4596
4597 // The horizon and the grid are part of the ANSWER on this path, for the
4598 // same reason the seed and the sample count are on the SSA one: the
4599 // mean-field exit metrics are a quadrature, and two runs are the same
4600 // measurement only if both knobs are stated.
4601 // `points` is the mean-field quadrature's and is printed only there: the
4602 // state-vector coupling carries the whole joint law across a switch and
4603 // never sums over that grid, so reporting a grid it did not use would
4604 // describe a computation that did not happen.
4605 // A closed-form limit reads neither knob: it solves each stage, or one
4606 // rate-averaged model, in STEADY STATE, so a horizon and an iteration count
4607 // would describe a transient and a fixed point that never ran.
4608 if (r.method == "avg" || r.method == "dec")
4609 std::printf("SolverENV arith=%s method=%s stages=%zu (closed-form limit)\n",
4610 line::num_traits<T>::name(), r.method.c_str(), e.nstages());
4611 else if (r.method == "statevec")
4612 std::printf("SolverENV arith=%s method=%s stages=%zu horizon=%.6g iters=%d%s\n",
4614 r.iterations, r.converged ? "" : " (NOT CONVERGED)");
4615 else
4616 // Every remaining method -- meanfield, and the smp and statedep runs of
4617 // the same analyzer -- sums the same quadrature, so all of them report
4618 // the grid it was summed over.
4619 std::printf("SolverENV arith=%s method=%s stages=%zu horizon=%.6g points=%zu iters=%d%s\n",
4621 opt.tran_points, r.iterations, r.converged ? "" : " (NOT CONVERGED)");
4622 // A Cache answers on the NODE and in three index spaces, none of them
4623 // (station, class), so the AvgTable cannot carry it and an environment
4624 // solve that computed one used to drop it here. It rides in the JSON
4625 // envelope for a host and prints as its own rows for a reader.
4626 line::reg::Json extra = line::reg::Json::object();
4627 if (!r.cache.empty()) extra["Cache"] = cache_extra_json<T>(r.cache);
4628 emit_avg_table<T>(
4629 sn, r.method,
4630 [&](std::size_t i, std::size_t c) {
4631 AvgRow row;
4632 row.q = line::num_traits<T>::to_double(r.QN(i, c));
4633 row.u = line::num_traits<T>::to_double(r.UN(i, c));
4634 row.t = line::num_traits<T>::to_double(r.TN(i, c));
4635 row.r = std::numeric_limits<double>::quiet_NaN();
4636 row.a = std::numeric_limits<double>::quiet_NaN();
4637 row.w = row.q / row.t;
4638 return row;
4639 },
4640 extra);
4641 if (!g_json_output) print_cache_rows<T>(sn, r.cache);
4642 return 0;
4643}
4644
4645/**
4646 * `-a var`: the SECOND moment of the queue length, which only the fluid solver
4647 * has and only through two of its methods.
4648 *
4649 * `minnormal` and `refined` report the STATIONARY covariance of the linear noise
4650 * approximation (`@@SolverFLD/getMoments`), `kp` the covariance integrated along
4651 * the trajectory (`@@SolverFLD/getTranAvgVar`); this prints the per-station,
4652 * per-class variance and its standard deviation, plus, on the JSON path, the full
4653 * state covariance so that cross-station terms survive rather than only the
4654 * per-block totals. Every other method carries a first moment only and is refused
4655 * by name -- a variance of zero would be a claim, not an absence.
4656 */
4657template <class T>
4658int solve_model_fluid_var(const std::string& file, const Knobs& k) {
4659 line::qn::Network<T> net = read_model<T>(file);
4660 const line::fluid::FluidOptions opt = fluid_options(k);
4661 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4663 if (!r.has_moments)
4665 "-a var needs a fluid method that computes a second moment: 'minnormal', 'refined' or "
4666 "'dae' for the stationary covariance, 'kp' for the covariance along the trajectory. "
4667 "The '" +
4668 r.method + "' method integrates the mean only");
4669
4670 if (g_json_output) {
4671 line::reg::Json p = line::reg::Json::object();
4672 p["type"] = "QueueLengthVariance";
4673 p["indexBase"] = 0;
4674 p["Station"] = line::reg::Json::array();
4675 p["JobClass"] = line::reg::Json::array();
4676 p["QVar"] = line::reg::Json::array();
4677 p["QStd"] = line::reg::Json::array();
4678 for (std::size_t i = 0; i < sn.nstations; ++i)
4679 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4680 if (r.moments.QVar(i, c) == 0.0) continue;
4681 p["Station"].push_back(sn.stations[i].name);
4682 p["JobClass"].push_back(sn.classes[c].name);
4683 p["QVar"].push_back(r.moments.QVar(i, c));
4684 p["QStd"].push_back(r.moments.QStd(i, c));
4685 }
4686 p["Sigma"] = matrix_json<double>(r.moments.Sigma);
4687 emit_analysis<T>("var", p, r.method);
4688 return 0;
4689 }
4690 std::printf("SolverFluid arith=%s method=%s second moment\n", line::num_traits<T>::name(),
4691 r.method.c_str());
4692 std::printf("%-16s %-14s %12s %12s\n", "Station", "JobClass", "QVar", "QStd");
4693 for (std::size_t i = 0; i < sn.nstations; ++i)
4694 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4695 if (r.moments.QVar(i, c) == 0.0) continue;
4696 std::printf("%-16s %-14s %12.6g %12.6g\n", sn.stations[i].name.c_str(),
4697 sn.classes[c].name.c_str(), r.moments.QVar(i, c), r.moments.QStd(i, c));
4698 }
4699 return 0;
4700}
4701
4702/**
4703 * `-a odes`: `@@SolverFLD/exportODEs`, the drift itself rather than its fixed
4704 * point.
4705 *
4706 * The output is the LaTeX document the reference writes, printed to stdout so
4707 * that it can be redirected. It carries a machine-readable comment header
4708 * naming every state variable and every event, which is what makes the document
4709 * diffable against MATLAB's rather than only readable.
4710 */
4711template <class T>
4712int solve_model_fluid_odes(const std::string& file, const Knobs& k) {
4713 line::qn::Network<T> net = read_model<T>(file);
4715 if (!k.method.empty()) opt.method = k.method;
4716 if (k.tol >= 0.0) opt.tol = k.tol;
4717 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4718 // `--notation` REACHES THE EXPORTER, and is not fixed at "scalar" here: the
4719 // matrix form is a different document of the same drift, and hardcoding one
4720 // while accepting a flag naming the other is the silent-acceptance defect
4721 // this CLI refuses everywhere else. An unrecognised name is refused by
4722 // `export_odes_latex` itself rather than defaulted.
4723 const std::string notation = k.notation.empty() ? "scalar" : k.notation;
4724 const std::string tex = line::fluid::solver_fluid_export_odes(sn, opt, notation, sn.name);
4725 if (g_json_output) {
4726 line::reg::Json p = line::reg::Json::object();
4727 p["type"] = "ODEs";
4728 p["notation"] = notation;
4729 // The document goes in a STRING VALUE, escaped by the JSON writer: it is
4730 // LaTeX, so it carries backslashes and newlines that a host reading raw
4731 // stdout would have to re-parse out of the surrounding table.
4732 p["latex"] = tex;
4733 emit_analysis<T>("odes", p,
4734 line::fluid::detail::fluid_resolve_method(sn, opt.method, opt));
4735 return 0;
4736 }
4737 std::printf("%s\n", tex.c_str());
4738 return 0;
4739}
4740
4741/**
4742 * `-a jacobian`: `@@SolverFLD/getJacobian`, d f_i / d x_j of the mean-field
4743 * drift, with the equilibria beside it when they are asked for.
4744 *
4745 * This is the fixed point's LOCAL BEHAVIOUR, which no integration reports: the
4746 * eigenvalues of J tell a stable fixed point from a limit cycle and give the
4747 * rate at which the fluid approximation converges to it.
4748 *
4749 * The method must be a smooth one. `fluid_symbolic_drift` refuses the min-scaled
4750 * drifts by the factor that carries the kink, before any backend is contacted.
4751 */
4752template <class T>
4753int solve_model_fluid_jacobian(const std::string& file, const Knobs& k) {
4754 line::qn::Network<T> net = read_model<T>(file);
4756 if (!k.method.empty()) opt.method = k.method;
4757 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4758 // pstar REACHES THE SYSTEM ONLY UNDER `pnorm`, which is the rule the
4759 // integrator and the exporter both follow: `matrix` and `default` leave it
4760 // at zero, selecting the hard min. That is why they have no Jacobian here
4761 // and `pnorm` does, and it is the reference's rule too -- MATLAB reads
4762 // options.config.pstar, which is unset unless asked for.
4763 std::string m = opt.method;
4764 if (m.compare(0, 4, "fld.") == 0) m = m.substr(4);
4766 sn, opt.method, (m == "pnorm" || opt.pstar_set) ? opt.pstar : 0.0, std::vector<double>());
4768 if (!k.symbolic.empty()) symopt.backend = k.symbolic;
4769 symopt.equilibria = k.equilibria;
4771
4772 if (g_json_output) {
4773 line::reg::Json p = line::reg::Json::object();
4774 p["type"] = "Jacobian";
4775 p["engine"] = jac.engine;
4776 p["vars"] = line::reg::Json(jac.vars);
4777 p["rhs"] = line::reg::Json(jac.rhs);
4778 line::reg::Json rows = line::reg::Json::array();
4779 for (std::size_t i = 0; i < jac.J.size(); ++i) rows.push_back(line::reg::Json(jac.J[i]));
4780 p["jacobian"] = rows;
4781 // `hasEquilibria` separates "asked and answered with none" from "never
4782 // asked"; an empty list alone would read as "this system has none".
4783 p["hasEquilibria"] = jac.has_equilibria;
4784 line::reg::Json eqs = line::reg::Json::array();
4785 for (std::size_t e = 0; e < jac.equilibria.size(); ++e) {
4786 line::reg::Json one = line::reg::Json::object();
4787 for (std::map<std::string, std::string>::const_iterator it = jac.equilibria[e].begin();
4788 it != jac.equilibria[e].end(); ++it)
4789 one[it->first] = it->second;
4790 eqs.push_back(one);
4791 }
4792 p["equilibria"] = eqs;
4793 emit_analysis<T>("jacobian", p,
4794 line::fluid::detail::fluid_resolve_method(sn, opt.method, opt));
4795 return 0;
4796 }
4797
4798 std::printf("engine=%s states=%zu\n", jac.engine.c_str(), jac.vars.size());
4799 for (std::size_t i = 0; i < jac.rhs.size(); ++i)
4800 std::printf("d%s/dt = %s\n", jac.vars[i].c_str(), jac.rhs[i].c_str());
4801 for (std::size_t i = 0; i < jac.J.size(); ++i)
4802 for (std::size_t j = 0; j < jac.J[i].size(); ++j) {
4803 // A structurally zero entry is printed, not skipped: a reader must be
4804 // able to tell a zero derivative from a row this port never emitted.
4805 std::printf("J[%s,%s] = %s\n", jac.vars[i].c_str(), jac.vars[j].c_str(),
4806 jac.J[i][j].c_str());
4807 }
4808 if (jac.has_equilibria) {
4809 if (jac.equilibria.empty())
4810 std::printf("equilibria: none in closed form (the solve found none, which is not a "
4811 "proof that none exist)\n");
4812 for (std::size_t e = 0; e < jac.equilibria.size(); ++e)
4813 for (std::map<std::string, std::string>::const_iterator it = jac.equilibria[e].begin();
4814 it != jac.equilibria[e].end(); ++it)
4815 std::printf("equilibrium %zu: %s = %s\n", e + 1, it->first.c_str(),
4816 it->second.c_str());
4817 }
4818 return 0;
4819}
4820
4821/**
4822 * `-s fluid -a tranvar`: `@@SolverFLD/getTranAvgVar`, the queue-length VARIANCE
4823 * along the trajectory, plus the full state covariance at each time point.
4824 *
4825 * NOT `-a var`, which reports the STATIONARY covariance of `minnormal` /
4826 * `refined` -- one number per (station, class) at the fixed point. This is the
4827 * diffusion limit of Ko and Pender integrated alongside the fluid limit, so it
4828 * has a value at every t, and only `--method kp` produces it. Asking any other
4829 * method for it is an error rather than a misleading zero, which is the header's
4830 * own rule and is left to the header to enforce so the two flags cannot drift.
4831 */
4832template <class T>
4833int solve_model_fluid_tranvar(const std::string& file, const Knobs& k) {
4834 line::qn::Network<T> net = read_model<T>(file);
4835 const line::fluid::FluidOptions opt = fluid_options(k);
4836 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4838 if (tr.t.empty())
4839 throw line::UnsupportedError("-a tranvar produced no trajectory points");
4840
4841 if (g_json_output) {
4842 line::reg::Json p = line::reg::Json::object();
4843 p["type"] = "TranAvgVarTable";
4844 p["indexBase"] = 0;
4845 p["t0"] = tr.t.front();
4846 p["t1"] = tr.t.back();
4847 line::reg::Json ts = line::reg::Json::array();
4848 for (std::size_t j = 0; j < tr.t.size(); ++j) ts.push_back(tr.t[j]);
4849 p["t"] = ts;
4850 line::reg::Json arr = line::reg::Json::array();
4851 for (std::size_t i = 0; i < sn.nstations; ++i)
4852 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4853 if (sn.disabled[i][c]) continue;
4854 line::reg::Json e = line::reg::Json::object();
4855 e["Station"] = sn.stations[i].name;
4856 e["JobClass"] = sn.classes[c].name;
4857 e["station"] = i;
4858 e["jobclass"] = c;
4859 line::reg::Json v = line::reg::Json::array();
4860 for (std::size_t j = 0; j < tr.QVar.size(); ++j) v.push_back(tr.QVar[j](i, c));
4861 e["QVar"] = v;
4862 arr.push_back(e);
4863 }
4864 p["curves"] = arr;
4865 // The full covariance is the answer's other half: the per-pair variances
4866 // are its diagonal, and a caller asking for the diffusion limit wants the
4867 // off-diagonal correlations the limit is about.
4868 line::reg::Json sig = line::reg::Json::array();
4869 for (std::size_t j = 0; j < tr.Sigma.size(); ++j)
4870 sig.push_back(matrix_json<double>(tr.Sigma[j]));
4871 p["Sigma"] = sig;
4872 emit_analysis<T>("tranvar", p, "kp");
4873 return 0;
4874 }
4875 std::printf("SolverFluid arith=%s method=kp tspan=[%g,%g] points=%zu dim=%zu\n",
4876 line::num_traits<T>::name(), tr.t.front(), tr.t.back(), tr.t.size(),
4877 tr.Sigma.empty() ? std::size_t(0) : tr.Sigma.front().rows());
4878 std::printf("%-16s %-14s %12s %12s %12s\n", "Station", "JobClass", "Time", "QVar", "QStd");
4879 for (std::size_t i = 0; i < sn.nstations; ++i)
4880 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4881 if (sn.disabled[i][c]) continue;
4882 for (std::size_t j = 0; j < tr.QVar.size(); ++j) {
4883 const double var = tr.QVar[j](i, c);
4884 std::printf("%-16s %-14s %12.6g %12.6g %12.6g\n", sn.stations[i].name.c_str(),
4885 sn.classes[c].name.c_str(), tr.t[j], var,
4886 var >= 0.0 ? std::sqrt(var) : std::numeric_limits<double>::quiet_NaN());
4887 }
4888 }
4889 return 0;
4890}
4891
4892/**
4893 * `-s fluid -a tran`: `@@SolverFLD/getTranAvg`, the metrics ALONG the
4894 * trajectory rather than at its fixed point.
4895 *
4896 * `--tspan` is optional here and required on the MAM arm, and the difference is
4897 * the reference's: `options.timespan` defaults to [0, Inf] for the fluid solver,
4898 * which does not mean "integrate forever" but "integrate until the state stops
4899 * moving" -- `solver_fluid_tran_avg` reproduces the horizon that adaptive loop
4900 * converges at. A caller that names a horizon gets exactly that one.
4901 *
4902 * The reference forces `closing` for a transient (the matrix and smoothed
4903 * variants are steady-state devices) and warns when it does; the port forces it
4904 * too, and the banner names the method that actually integrated. `dae` is the
4905 * one exception the reference itself makes -- it has a trajectory of its own,
4906 * with conservation carried as an algebraic equation -- so
4907 * `solver_fluid_run_transient` routes it rather than substituting the
4908 * first-order drift under its name.
4909 */
4910template <class T>
4911int solve_model_fluid_tran(const std::string& file, const Knobs& k) {
4912 line::qn::Network<T> net = read_model<T>(file);
4913 const line::fluid::FluidOptions opt = fluid_options(k);
4914 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4915 const std::vector<line::fluid::FluidTranPoint> tr =
4917 if (tr.empty()) throw line::UnsupportedError("-a tran produced no trajectory points");
4918
4919 if (g_json_output) {
4920 line::reg::Json p = line::reg::Json::object();
4921 p["type"] = "TranAvgTable";
4922 p["indexBase"] = 0;
4923 p["t0"] = 0.0;
4924 p["t1"] = tr.back().t;
4925 line::reg::Json ts = line::reg::Json::array();
4926 for (std::size_t j = 0; j < tr.size(); ++j) ts.push_back(tr[j].t);
4927 line::reg::Json arr = line::reg::Json::array();
4928 for (std::size_t i = 0; i < sn.nstations; ++i)
4929 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4930 if (sn.disabled[i][c]) continue;
4931 line::reg::Json e = line::reg::Json::object();
4932 e["Station"] = sn.stations[i].name;
4933 e["JobClass"] = sn.classes[c].name;
4934 e["station"] = i;
4935 e["jobclass"] = c;
4936 e["t"] = ts;
4937 line::reg::Json q = line::reg::Json::array(), u = line::reg::Json::array(),
4938 x = line::reg::Json::array();
4939 for (std::size_t j = 0; j < tr.size(); ++j) {
4940 q.push_back(tr[j].QN(i, c));
4941 u.push_back(tr[j].UN(i, c));
4942 x.push_back(tr[j].TN(i, c));
4943 }
4944 e["QLen"] = q;
4945 e["Util"] = u;
4946 e["Tput"] = x;
4947 arr.push_back(e);
4948 }
4949 p["curves"] = arr;
4950 emit_analysis<T>("tran", p, "closing");
4951 return 0;
4952 }
4953 std::printf("SolverFluid arith=%s method=closing tspan=[0,%g] points=%zu\n",
4954 line::num_traits<T>::name(), tr.back().t, tr.size());
4955 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "Time", "QLen", "Util",
4956 "Tput");
4957 for (std::size_t i = 0; i < sn.nstations; ++i)
4958 for (std::size_t c = 0; c < sn.nclasses; ++c) {
4959 if (sn.disabled[i][c]) continue;
4960 for (std::size_t j = 0; j < tr.size(); ++j)
4961 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n",
4962 sn.stations[i].name.c_str(), sn.classes[c].name.c_str(), tr[j].t,
4963 tr[j].QN(i, c), tr[j].UN(i, c), tr[j].TN(i, c));
4964 }
4965 return 0;
4966}
4967
4968/**
4969 * `-s fluid -a prob`: `@@SolverFLD/getProbAggr`, the probability that a station
4970 * holds the marginal population of the model's default state.
4971 *
4972 * IT IS NOT THE MVA ARM'S ANSWER AND IS NOT MEANT TO BE. The fluid solver has
4973 * no state space, so the law is fitted to the means it does produce -- a
4974 * binomial per closed class, the BCMP marginal per open one -- and under a
4975 * moment closure it is instead the multivariate normal the closure supplies,
4976 * correlation between the classes included. Two solvers disagreeing here is the
4977 * approximation showing, not a defect.
4978 *
4979 * The log-probability is reported beside it because the fitted law underflows
4980 * on a large population, where the linear value is 0 and the log one is not.
4981 */
4982template <class T>
4983int solve_model_fluid_prob(const std::string& file, const Knobs& k) {
4984 line::qn::Network<T> net = read_model<T>(file);
4985 const line::fluid::FluidOptions opt = fluid_options(k);
4986 const line::qn::NetworkStruct<T>& sn = net.get_struct();
4988
4989 std::vector<double> pr(sn.nstations, 0.0), lg(sn.nstations, 0.0);
4990 for (std::size_t i = 0; i < sn.nstations; ++i)
4991 pr[i] = line::fluid::fluid_prob_aggr(sn, r, i + 1, &lg[i]);
4992
4993 if (g_json_output) {
4994 line::reg::Json p = line::reg::Json::object();
4995 p["type"] = "ProbAggr";
4996 p["indexBase"] = 0;
4997 line::reg::Json st = line::reg::Json::array(), pa = line::reg::Json::array(),
4998 lp = line::reg::Json::array();
4999 for (std::size_t i = 0; i < sn.nstations; ++i) {
5000 st.push_back(sn.stations[i].name);
5001 pa.push_back(pr[i]);
5002 lp.push_back(lg[i]);
5003 }
5004 p["Station"] = st;
5005 p["ProbAggr"] = pa;
5006 p["logProbAggr"] = lp;
5007 emit_analysis<T>("prob", p, r.method);
5008 return 0;
5009 }
5010 std::printf("SolverFluid arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
5011 r.method.c_str(), line::util::method_type("FLD", r.method).c_str());
5012 std::printf("%-16s %14s %14s\n", "Station", "ProbAggr", "logProbAggr");
5013 for (std::size_t i = 0; i < sn.nstations; ++i)
5014 std::printf("%-16s %14.10g %14.10g\n", sn.stations[i].name.c_str(), pr[i], lg[i]);
5015 return 0;
5016}
5017
5018/**
5019 * `-s fluid -a cdf`: `@@SolverFLD/getCdfRespT`, the WHOLE response-time law per
5020 * (station, class) and not only its mean.
5021 *
5022 * The law is read off a second integration in which the jobs present at the
5023 * steady state are MARKED and followed to their departure, so it is the
5024 * stationary response-time distribution of the fluid model. The solve that
5025 * produces the state to mark in is run here, as the reference runs it: its
5026 * `getCdfRespT` clears the cached result and re-runs `getAvg` first.
5027 */
5028template <class T>
5029int solve_model_fluid_cdf(const std::string& file, const Knobs& k) {
5030 line::qn::Network<T> net = read_model<T>(file);
5031 const line::fluid::FluidOptions opt = fluid_options(k);
5032 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5033 const std::vector<std::vector<line::fluid::FluidPassage> > RD =
5035
5036 if (g_json_output) {
5037 line::reg::Json p = line::reg::Json::object();
5038 p["type"] = "CdfRespT";
5039 p["indexBase"] = 0;
5040 line::reg::Json arr = line::reg::Json::array();
5041 for (std::size_t i = 0; i < RD.size(); ++i)
5042 for (std::size_t c = 0; c < RD[i].size(); ++c) {
5043 // An empty curve is an ABSENT law -- a Source, or a class the
5044 // station does not serve -- and is omitted rather than sent as a
5045 // degenerate one.
5046 if (RD[i][c].t.empty()) continue;
5047 line::reg::Json e = line::reg::Json::object();
5048 e["Station"] = sn.stations[i].name;
5049 e["JobClass"] = sn.classes[c].name;
5050 e["station"] = i;
5051 e["jobclass"] = c;
5052 e["t"] = line::reg::Json(RD[i][c].t);
5053 e["F"] = line::reg::Json(RD[i][c].cdf);
5054 arr.push_back(e);
5055 }
5056 p["respt"] = arr;
5057 emit_analysis<T>("cdf", p, std::string());
5058 return 0;
5059 }
5060 std::printf("SolverFluid arith=%s\n", line::num_traits<T>::name());
5061 std::printf("%-16s %-14s %14s %14s\n", "Station", "JobClass", "Time", "F(t)");
5062 for (std::size_t i = 0; i < RD.size(); ++i)
5063 for (std::size_t c = 0; c < RD[i].size(); ++c)
5064 for (std::size_t j = 0; j < RD[i][c].t.size(); ++j)
5065 std::printf("%-16s %-14s %14.8g %14.10g\n", sn.stations[i].name.c_str(),
5066 sn.classes[c].name.c_str(), RD[i][c].t[j], RD[i][c].cdf[j]);
5067 return 0;
5068}
5069
5070/**
5071 * `-s fluid -a aoi`: `@@SolverFLD/getAvgAoI` and `getCdfAoI` in one answer, the
5072 * Age of Information and Peak AoI laws of a status-update system.
5073 *
5074 * ONLY THE `mfq` METHOD HAS THEM, and only on the topology the age laws are
5075 * defined for: one open class through Source -> Queue -> Sink, a single server,
5076 * capacity 1 (bufferless) or 2 (single buffer), FCFS/LCFS/LCFSPR. The topology
5077 * is tested first so a model that is not one is told WHICH condition it fails
5078 * rather than being handed a number computed for a different system.
5079 *
5080 * `--method` may only say `mfq` here: silently overriding a caller who asked for
5081 * another method would report the age laws under a method that does not produce
5082 * them.
5083 */
5084template <class T>
5085int solve_model_fluid_aoi(const std::string& file, const Knobs& k) {
5086 line::qn::Network<T> net = read_model<T>(file);
5087 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5089 if (!top.ok)
5091 "-a aoi reports the age of a status-update system and needs the topology the age laws "
5092 "are defined for: " +
5093 (top.error.empty() ? std::string("this model is not one") : top.error));
5094 line::fluid::FluidOptions opt = fluid_options(k);
5095 const std::string requested = line::fluid::detail::fluid_unqualify(opt.method);
5096 if (requested != "default" && requested != "mfq")
5098 "-a aoi is the AoI branch of the 'mfq' method; '" + requested +
5099 "' integrates the mean-field drift and carries no age process");
5100 opt.method = "mfq";
5102 if (!r.has_aoi)
5104 "-a aoi: the 'mfq' method did not take its AoI branch on this model");
5105
5106 // The grid `getCdfAoI` builds when the caller names no time points: five
5107 // mean ages, which covers the bulk of both laws.
5108 const double base = (std::isfinite(r.aoi.aoi.mean) && r.aoi.aoi.mean > 0.0) ? r.aoi.aoi.mean : 1.0;
5109 const std::size_t np = 200;
5110 std::vector<double> tv(np), fa(np), fp(np);
5111 for (std::size_t j = 0; j < np; ++j) {
5112 tv[j] = 5.0 * base * static_cast<double>(j) / static_cast<double>(np - 1);
5113 fa[j] = line::fluid::aoi_cdf(r.aoi.aoi, tv[j]);
5114 fp[j] = line::fluid::aoi_cdf(r.aoi.paoi, tv[j]);
5115 }
5116 const double asd = std::sqrt(std::max(0.0, r.aoi.aoi.var));
5117 const double psd = std::sqrt(std::max(0.0, r.aoi.paoi.var));
5118
5119 if (g_json_output) {
5120 line::reg::Json p = line::reg::Json::object();
5121 p["type"] = "AoI";
5122 p["systemType"] = r.aoi.system_type;
5123 p["preemption"] = r.aoi.preemption;
5124 p["AoIMean"] = r.aoi.aoi.mean;
5125 p["AoIVar"] = r.aoi.aoi.var;
5126 p["AoIStd"] = asd;
5127 p["PAoIMean"] = r.aoi.paoi.mean;
5128 p["PAoIVar"] = r.aoi.paoi.var;
5129 p["PAoIStd"] = psd;
5130 p["t"] = line::reg::Json(tv);
5131 p["AoICdf"] = line::reg::Json(fa);
5132 p["PAoICdf"] = line::reg::Json(fp);
5133 // The (g, A, h) DENSITY triples, not only the curve evaluated above.
5134 // `getCdfAoI` takes an optional t_values, and a caller who names their
5135 // own grid cannot be served from a fixed 200-point one; with the triple
5136 // they evaluate the same law at their own abscissae. `solve_mfq_aoi`
5137 // normalizes g so that g*expm(A t)*h is the density, so the survival
5138 // function carries an extra inv(A) -- the MATLAB getter's own note.
5139 p["AoI_g"] = line::reg::Json(r.aoi.aoi.g);
5140 p["AoI_A"] = matrix_json(r.aoi.aoi.A);
5141 p["AoI_h"] = line::reg::Json(r.aoi.aoi.h);
5142 p["PAoI_g"] = line::reg::Json(r.aoi.paoi.g);
5143 p["PAoI_A"] = matrix_json(r.aoi.paoi.A);
5144 p["PAoI_h"] = line::reg::Json(r.aoi.paoi.h);
5145 emit_analysis<T>("aoi", p, r.method);
5146 return 0;
5147 }
5148 std::printf("SolverFluid arith=%s method=mfq system=%s preemption=%.6g\n",
5150 std::printf("%-8s %14s %14s %14s\n", "Metric", "Mean", "Var", "Std");
5151 std::printf("%-8s %14.10g %14.10g %14.10g\n", "AoI", r.aoi.aoi.mean, r.aoi.aoi.var, asd);
5152 std::printf("%-8s %14.10g %14.10g %14.10g\n", "PAoI", r.aoi.paoi.mean, r.aoi.paoi.var, psd);
5153 std::printf("%14s %14s %14s\n", "Time", "F_AoI(t)", "F_PAoI(t)");
5154 for (std::size_t j = 0; j < np; ++j)
5155 std::printf("%14.8g %14.10g %14.10g\n", tv[j], fa[j], fp[j]);
5156 return 0;
5157}
5158
5159/**
5160 * Solve a Network model.json and print its aggregate state probabilities:
5161 * getProbSysAggr (the whole-system joint) and getProbAggr per station, over the
5162 * model's default initial state. These fit the MVA means and so need logarithms;
5163 * under exact/Rational they refuse by name.
5164 */
5165template <class T>
5166int solve_model_prob(const std::string& file, const Knobs& k) {
5169 "the -a prob analysis fits a binomial/product-form law and needs transcendental "
5170 "arithmetic; rerun with --arith double or --arith real");
5171 } else {
5172 line::qn::Network<T> net = read_model<T>(file);
5174 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5175 if (k.tol >= 0.0) opt.tol = k.tol;
5176 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5177 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5178 line::Matrix<T> init;
5180 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5181 const line::mva::AggrResult<T> ps =
5183 if (g_json_output) {
5184 line::reg::Json p = line::reg::Json::object();
5185 p["type"] = "ProbAggr";
5186 p["indexBase"] = 0;
5187 p["ProbSysAggr"] = line::num_traits<T>::to_double(ps.P);
5188 line::reg::Json st = line::reg::Json::array(), pa = line::reg::Json::array();
5189 for (std::size_t i = 0; i < sn.nstations; ++i) {
5190 st.push_back(sn.stations[i].name);
5191 pa.push_back(line::num_traits<T>::to_double(
5192 line::mva::solver_mva_get_prob_aggr(sn, r, i + 1, opt.method).P));
5193 }
5194 p["Station"] = st;
5195 p["ProbAggr"] = pa;
5196 emit_analysis<T>("prob", p, r.actualmethod);
5197 return 0;
5198 }
5199 std::printf("SolverMVA arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
5200 r.actualmethod.c_str(),
5201 line::util::method_type("MVA", r.actualmethod).c_str());
5202 std::printf("ProbSysAggr %.10g\n", line::num_traits<T>::to_double(ps.P));
5203 std::printf("%-16s %14s\n", "Station", "ProbAggr");
5204 for (std::size_t i = 0; i < sn.nstations; ++i) {
5205 const line::mva::AggrResult<T> pa =
5207 std::printf("%-16s %14.10g\n", sn.stations[i].name.c_str(),
5209 }
5210 return 0;
5211 }
5212}
5213
5214/**
5215 * `-s mva -a marg`: `@@SolverMVA/getProbMarg`, P(n jobs of class r at station i).
5216 *
5217 * THE WHOLE GRID BY DEFAULT, one curve per (station, class): the reference takes
5218 * the station and the class as arguments and this CLI has no notion of a
5219 * "current" pair, so reporting every pair is the only reading that answers the
5220 * method rather than a choice this file would be making on the caller's behalf.
5221 * `--node` and `--class` narrow it to one node's station and one class, and
5222 * `--marg-states` is the reference's third argument `state_m`: the n values to
5223 * report, in place of the default range each case picks for itself (0..N_r for a
5224 * closed class, mean + 5 sigma for a Poisson, the 1e-10 tail for a geometric).
5225 *
5226 * A NODE THAT IS NOT A STATION IS AN ERROR, not an empty answer: a queue-length
5227 * law at a ClassSwitch is not a quantity, and defaulting to the whole network
5228 * after the caller narrowed it would report more than was asked for.
5229 */
5230template <class T>
5231int solve_model_marg(const std::string& file, const Knobs& k) {
5234 "the -a marg analysis fits a binomial / Poisson / geometric law and needs "
5235 "transcendental arithmetic; rerun with --arith double or --arith real");
5236 } else {
5237 line::qn::Network<T> net = read_model<T>(file);
5239 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5240 if (k.tol >= 0.0) opt.tol = k.tol;
5241 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5242 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5243 line::Matrix<T> init;
5245 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5246
5247 std::vector<std::size_t> ists; // 1-based station indices to report
5248 if (k.node) {
5249 if (k.node > sn.nof_nodes())
5250 throw line::InputError("--node " + std::to_string(k.node) +
5251 " exceeds the number of nodes in the model (" +
5252 std::to_string(sn.nof_nodes()) + ")");
5253 const std::size_t ist = sn.nodes[k.node - 1].station;
5254 if (!ist)
5255 throw line::InputError("--node " + std::to_string(k.node) + " ('" +
5256 sn.nodes[k.node - 1].name +
5257 "') is not a station, and a queue-length distribution is "
5258 "reported per station");
5259 ists.push_back(ist);
5260 } else {
5261 for (std::size_t i = 0; i < sn.nstations; ++i) ists.push_back(i + 1);
5262 }
5263 std::vector<std::size_t> rs; // 1-based class indices to report
5264 if (k.jobclass) {
5265 if (k.jobclass > sn.nclasses)
5266 throw line::InputError("--class " + std::to_string(k.jobclass) +
5267 " exceeds the number of classes in the model");
5268 rs.push_back(k.jobclass);
5269 } else {
5270 for (std::size_t c = 0; c < sn.nclasses; ++c) rs.push_back(c + 1);
5271 }
5272
5273 // Every curve first: a pair the reference refuses must not leave a
5274 // banner and a column header standing above an answer that never came.
5275 std::vector<line::mva::MargResult<T> > curves;
5276 for (std::size_t a = 0; a < ists.size(); ++a)
5277 for (std::size_t b = 0; b < rs.size(); ++b)
5278 curves.push_back(line::mva::solver_mva_get_prob_marg(sn, r, ists[a], rs[b],
5279 k.marg_states, opt.method));
5280
5281 if (g_json_output) {
5282 line::reg::Json p = line::reg::Json::object();
5283 p["type"] = "ProbMarg";
5284 p["indexBase"] = 0;
5285 line::reg::Json arr = line::reg::Json::array();
5286 for (std::size_t a = 0, q = 0; a < ists.size(); ++a)
5287 for (std::size_t b = 0; b < rs.size(); ++b, ++q) {
5288 const line::mva::MargResult<T>& m = curves[q];
5289 line::reg::Json e = line::reg::Json::object();
5290 e["station"] = ists[a] - 1;
5291 e["Station"] = sn.stations[ists[a] - 1].name;
5292 e["jobclass"] = rs[b] - 1;
5293 e["JobClass"] = sn.classes[rs[b] - 1].name;
5294 line::reg::Json jobs = line::reg::Json::array();
5295 for (std::size_t n = 0; n < m.P.size(); ++n)
5296 jobs.push_back(k.marg_states.empty() ? static_cast<long>(n)
5297 : k.marg_states[n]);
5298 e["Jobs"] = jobs;
5299 e["P"] = vector_json(m.P);
5300 e["logP"] = vector_json(m.logP);
5301 arr.push_back(e);
5302 }
5303 p["marginal"] = arr;
5304 emit_analysis<T>("marg", p, r.actualmethod);
5305 return 0;
5306 }
5307 std::printf("SolverMVA arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
5308 r.actualmethod.c_str(),
5309 line::util::method_type("MVA", r.actualmethod).c_str());
5310 std::printf("%-16s %-14s %-8s %16s\n", "Station", "JobClass", "Jobs", "ProbMarg");
5311 for (std::size_t a = 0, q = 0; a < ists.size(); ++a)
5312 for (std::size_t b = 0; b < rs.size(); ++b, ++q) {
5313 const line::mva::MargResult<T>& m = curves[q];
5314 for (std::size_t n = 0; n < m.P.size(); ++n)
5315 std::printf("%-16s %-14s %-8ld %16.10g\n",
5316 sn.stations[ists[a] - 1].name.c_str(),
5317 sn.classes[rs[b] - 1].name.c_str(),
5318 k.marg_states.empty() ? static_cast<long>(n) : k.marg_states[n],
5320 }
5321 return 0;
5322 }
5323}
5324
5325/**
5326 * `-a normconst`: `@@SolverMVA/getProbNormConstAggr` and `@@SolverNC`'s.
5327 *
5328 * ONE ANALYSIS, TWO SOLVERS, AND THEY DO NOT COMPUTE IT THE SAME WAY. The NC
5329 * arm reads the constant its own solve already formed. The MVA arm RE-ENTERS the
5330 * analyzer at method='exact', as the reference does, because only the exact MVA
5331 * recursion carries a G: an AMVA solve has none, and reporting the requested
5332 * method's number would attribute the constant to an algorithm that never
5333 * produced one. That re-entry is why this is a separate `-a` and not a field on
5334 * the `-s mva` banner, where it would charge every average solve for a second
5335 * exact one.
5336 *
5337 * WHAT A MODEL WITH NO CONSTANT REPORTS IS THE ANALYZER'S OWN ANSWER, not a
5338 * substitution made here, and the two cases differ: the branches that form no G
5339 * at all -- MVAC, the LCFS chain -- set lG to NaN at the source and print nan,
5340 * while the open-queue closed forms report lG = 0 exactly as
5341 * solver_mva_qsys_analyzer.m:54,96,235 does. Neither is edited on the way out.
5342 */
5343/**
5344 * Mean busy period of a named subnetwork, Daduna (J. ACM 35(3), 1988).
5345 *
5346 * The transform `solver_nc_busyp` has been in the port since it landed, and
5347 * `ldes_cli` has answered `--busyperiod` all along, so the ONLY thing between
5348 * a caller and the analytical form was a `-a` token: asking this CLI for a busy
5349 * period meant simulating a quantity there is a closed form for.
5350 *
5351 * `--busyperiod-subnet` is 1-BASED, as every station index this CLI takes is,
5352 * and is required: a busy period is defined for a NAMED set of stations and
5353 * defaulting it would answer about a subnetwork the caller never chose. The
5354 * orders default to 1, the ordinary busy period.
5355 */
5356template <class T>
5357int solve_model_nc_busyp(const std::string& file, const Knobs& k) {
5358 if (k.busy_subnet.empty())
5359 throw line::InputError(
5360 "-a busyperiod needs --busyperiod-subnet: the busy period is defined for a named "
5361 "subnetwork of stations, and no default can choose one");
5362 line::qn::Network<T> net = read_model<T>(file);
5363 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5364 std::vector<std::size_t> subnet;
5365 for (std::size_t t = 0; t < k.busy_subnet.size(); ++t) {
5366 if (k.busy_subnet[t] > sn.nstations)
5367 throw line::InputError("--busyperiod-subnet names station " +
5368 std::to_string(k.busy_subnet[t]) + ", beyond the model's " +
5369 std::to_string(sn.nstations));
5370 subnet.push_back(k.busy_subnet[t] - 1);
5371 }
5372 std::vector<std::size_t> orders = k.busy_orders;
5373 if (orders.empty()) orders.push_back(1);
5374 const std::vector<double> b = line::nc::solver_nc_busyp(sn, subnet, orders);
5375
5376 if (g_json_output) {
5377 line::reg::Json p = line::reg::Json::object();
5378 p["type"] = "BusyPeriod";
5379 p["indexBase"] = 0;
5380 line::reg::Json sj = line::reg::Json::array();
5381 for (std::size_t t = 0; t < subnet.size(); ++t) sj.push_back(subnet[t]);
5382 line::reg::Json oj = line::reg::Json::array();
5383 for (std::size_t t = 0; t < orders.size(); ++t) oj.push_back(orders[t]);
5384 line::reg::Json bj = line::reg::Json::array();
5385 for (std::size_t t = 0; t < b.size(); ++t) bj.push_back(b[t]);
5386 p["subnet"] = sj;
5387 p["orders"] = oj;
5388 p["b"] = bj;
5389 emit_analysis<T>("busyperiod", p, "daduna");
5390 return 0;
5391 }
5392 std::printf("SolverNC arith=%s busy period, subnetwork {", line::num_traits<T>::name());
5393 for (std::size_t t = 0; t < k.busy_subnet.size(); ++t)
5394 std::printf("%s%zu", t ? "," : "", k.busy_subnet[t]);
5395 std::printf("}\n");
5396 for (std::size_t t = 0; t < orders.size(); ++t)
5397 std::printf(" order %zu %.10g\n", orders[t], b[t]);
5398 return 0;
5399}
5400
5401template <class T>
5402int solve_model_normconst(const std::string& file, const Knobs& k, const std::string& solver) {
5403 line::qn::Network<T> net = read_model<T>(file);
5404 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5405 double lG = 0.0;
5406 std::string method;
5407 if (solver == "nc") {
5409 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5410 if (k.tol >= 0.0) opt.tol = k.tol;
5411 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5412 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5414 lG = r.lognormconst.has_value() ? r.lognormconst.value()
5415 : std::numeric_limits<double>::quiet_NaN();
5416 method = r.actualmethod;
5417 } else {
5419 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5420 if (k.tol >= 0.0) opt.tol = k.tol;
5421 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5422 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5424 method = "exact";
5425 }
5426 if (g_json_output) {
5427 line::reg::Json p = line::reg::Json::object();
5428 p["type"] = "NormConst";
5429 p["indexBase"] = 0;
5430 // A NaN rides as JSON null, which is the table's nan on the wire.
5431 p["logNormConstAggr"] = lG;
5432 emit_analysis<T>("normconst", p, method);
5433 return 0;
5434 }
5435 std::printf("Solver%s arith=%s method=%s lognormconst=%.10g\n",
5436 solver == "nc" ? "NC" : "MVA", line::num_traits<T>::name(), method.c_str(), lG);
5437 return 0;
5438}
5439
5440/** The model's declared per-class placement, in `solver_nc_*`'s own container. */
5441template <class T>
5442line::nc::MarginalState nc_declared_marginal(const line::qn::NetworkStruct<T>& sn) {
5444 line::nc::MarginalState out(sn.nstations, std::vector<int>(sn.nclasses, 0));
5445 for (std::size_t i = 0; i < sn.nstations; ++i)
5446 for (std::size_t r = 0; r < sn.nclasses; ++r)
5447 out[i][r] = static_cast<int>(std::llround(line::num_traits<T>::to_double(nir(i, r))));
5448 return out;
5449}
5450
5451/**
5452 * The same two probabilities under SolverNC: `@@SolverNC/getProbSysAggr.m` and
5453 * `@@SolverNC/getProbAggr.m`, over the model's declared state.
5454 *
5455 * NOT THE SAME NUMBERS AS `-s mva -a prob`, and that is the point of having
5456 * both. SolverMVA fits a binomial to its own means (Schmidt 1997); these are a
5457 * ratio of normalizing constants and are the product-form model's own
5458 * probabilities exactly. A closed model therefore reports different figures
5459 * under the two solvers, and the NC ones are the reference.
5460 */
5461template <class T>
5462int solve_model_nc_prob(const std::string& file, const Knobs& k) {
5465 "the -s nc -a prob analysis exponentiates a difference of log normalizing constants "
5466 "and needs transcendental arithmetic; rerun with --arith double or --arith real");
5467 } else {
5468 line::qn::Network<T> net = read_model<T>(file);
5470 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5471 if (k.tol >= 0.0) opt.tol = k.tol;
5472 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5473 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5474 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5475
5476 // The state is the MODEL'S OWN, which `model.json` now carries: a
5477 // stateful node's declared row is decoded to the per-class counts the
5478 // reference reads with `State.toMarginal(sn, ist, state{isf})`. Where a
5479 // station declares none, the default marking is rebuilt for it -- every
5480 // closed class at its reference station -- which is what `initDefault`
5481 // would have put there.
5482 line::nc::MarginalState nir = nc_declared_marginal<T>(sn);
5483 // `--state` is `getProb(node, state)`'s second argument, decoded the way
5484 // the reference decodes it: it substitutes the row into `sn.state{isf}`
5485 // and takes `State.toMarginal` of the result, so what reaches the
5486 // probability is that node's PER-CLASS COUNTS and every other node's
5487 // declared ones. Passing the row through untouched would treat an
5488 // encoding as a job vector, and on a station with phase-type service the
5489 // two differ in both width and meaning.
5490 if (!k.state.empty()) {
5491 if (!k.node)
5492 throw line::InputError(
5493 "--state is the state of ONE node and needs --node to say which");
5494 const std::size_t ist =
5495 k.node <= sn.nodes.size() ? sn.nodes[k.node - 1].station : 0;
5496 if (ist == 0)
5497 throw line::InputError("--node " + std::to_string(k.node) +
5498 " is not a station, so it has no queue-length state");
5499 std::vector<std::size_t> ph(sn.nclasses, 1), shift(sn.nclasses, 0);
5500 std::size_t w = 0;
5501 for (std::size_t c = 0; c < sn.nclasses; ++c) {
5502 ph[c] = sn.phases_of(ist, c + 1);
5503 shift[c] = w;
5504 w += ph[c];
5505 }
5506 std::vector<T> row(k.state.size());
5507 for (std::size_t i = 0; i < k.state.size(); ++i)
5508 row[i] = line::num_traits<T>::from_int(k.state[i]);
5509 const line::qn::Marginal<T> m =
5510 line::qn::to_marginal(sn, ist, row, ph, shift, sn.nvars_of(k.node));
5511 for (std::size_t c = 0; c < sn.nclasses; ++c)
5512 nir[ist - 1][c] =
5513 static_cast<int>(std::llround(line::num_traits<T>::to_double(m.nir[c])));
5514 }
5515
5516 // THE SOLVE'S lG IS NOT THIS lG, and handing it over here was wrong.
5517 // `solver_nc_solve` normalizes the SEIDMANN-REDUCED model -- a
5518 // multiserver station enters as demand/c with the residual folded into
5519 // the delay -- while the probability identity F_i G_{-i} / G needs the
5520 // constant of the load-dependent lattice mu(n) = min(n, c) that F_i and
5521 // G_{-i} are themselves computed on. Mixing the two scaled every
5522 // probability of a model with a multiserver station by one common
5523 // factor: on the 2-job Delay -> PS -> PS(c=2) chain the three stations
5524 // came back 0.17225 / 0.68900 / 0.32536 against the exact 0.18 / 0.72 /
5525 // 0.34, and the error is invisible on a single-server model because
5526 // there the two constants coincide. `solver_nc_margaggr` computes its
5527 // own, once, for every station -- which is the reference's
5528 // `logNormConstAggr` caching, not a per-station resolve.
5529 // THREE OF THE FOUR VIEWS NEED A LOAD-DEPENDENT NORMALIZING CONSTANT
5530 // over the WHOLE network, and `pfqn_ncld` computes it with the caller's
5531 // own method (`solver_nc_margaggr.m:53`, `solver_nc_marg.m:35`,
5532 // `solver_nc_joint.m:32`). A method name with no load-dependent
5533 // algorithm behind it -- the stochastic estimators `ls`, `mci`, `imci`,
5534 // `mcmc`, and the load-INDEPENDENT exact ones such as `comom` -- has
5535 // nothing to run there, and MATLAB refuses `getProb`, `getProbSys` and
5536 // `getProbAggr` under such a name with `pfqn_ncld`'s own message
5537 // (`pfqn_ncld.m:210`). `solver_nc_jointaggr` is the one that escapes:
5538 // its lG comes from `solver_nc` and its per-station factors are
5539 // SINGLE-STATION, which `pfqn_ncld` answers from a degenerate return
5540 // before it ever looks at the name. So the three are OMITTED rather
5541 // than faked or allowed to fail the whole analysis, exactly as
5542 // `-s mva -a prob` omits the detailed pair it has no counterpart for;
5543 // `ProbSysAggr` is still reported, which is what the caller asked for.
5545 const bool has_ld = line::pfqn::ncld_method_try(opt.method, ldm);
5546
5548 T pjoint = line::num_traits<T>::from_int(0);
5549 if (has_ld) {
5550 mr = line::nc::solver_nc_margaggr(sn, opt, nir,
5551 std::numeric_limits<double>::quiet_NaN());
5552 // THE DETAILED PAIR TOO, because `getProb` and `getProbSys` are not
5553 // the aggregate ones with rounding: `solver_nc_marg` and
5554 // `solver_nc_joint` carry the class-within-chain split
5555 // `lg0_i - lG0_i` that the aggregate pair sums out, so on a
5556 // multichain model they are different numbers rather than the same
5557 // one to more places.
5558 mdet = line::nc::solver_nc_marg(sn, opt, nir,
5559 std::numeric_limits<double>::quiet_NaN());
5560 pjoint = line::nc::solver_nc_joint<T>(sn, opt, nir, nullptr);
5561 }
5562 const T ps = line::nc::solver_nc_getprob_sys_aggr(sn, opt, nir);
5563
5565 const std::string am = (opt.method == "default" && !d.actualmethod.empty() &&
5566 d.actualmethod != "default")
5567 ? "default/" + d.actualmethod
5568 : d.actualmethod;
5569 if (g_json_output) {
5570 line::reg::Json p = line::reg::Json::object();
5571 p["type"] = "ProbAggr";
5572 p["indexBase"] = 0;
5573 p["ProbSysAggr"] = line::num_traits<T>::to_double(ps);
5574 line::reg::Json st = line::reg::Json::array();
5575 for (std::size_t i = 0; i < sn.nstations; ++i) st.push_back(sn.stations[i].name);
5576 p["Station"] = st;
5577 if (has_ld) {
5578 p["ProbSys"] = line::num_traits<T>::to_double(pjoint);
5579 line::reg::Json pa = line::reg::Json::array(), pm = line::reg::Json::array();
5580 for (std::size_t i = 0; i < sn.nstations; ++i) {
5581 pa.push_back(line::num_traits<T>::to_double(mr.P[i]));
5582 pm.push_back(line::num_traits<T>::to_double(mdet.P[i]));
5583 }
5584 p["ProbAggr"] = pa;
5585 p["Prob"] = pm;
5586 }
5587 emit_analysis<T>("prob", p, am);
5588 return 0;
5589 }
5590 std::printf("SolverNC arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
5591 am.c_str(), line::util::method_type("NC", am).c_str());
5592 std::printf("ProbSysAggr %.10g\n", line::num_traits<T>::to_double(ps));
5593 if (!has_ld) {
5594 std::printf("ProbSys, Prob and ProbAggr need a load-dependent normalizing constant, "
5595 "which method '%s' does not compute\n",
5596 opt.method.c_str());
5597 return 0;
5598 }
5599 std::printf("ProbSys %.10g\n", line::num_traits<T>::to_double(pjoint));
5600 std::printf("%-16s %14s %14s\n", "Station", "Prob", "ProbAggr");
5601 for (std::size_t i = 0; i < sn.nstations; ++i)
5602 std::printf("%-16s %14.10g %14.10g\n", sn.stations[i].name.c_str(),
5605 return 0;
5606 }
5607}
5608
5609/**
5610 * `-s nc -a sysmarg`: `@@SolverNC/getProbSysMarg.m`, the JOINT law of the
5611 * per-station total queue lengths.
5612 *
5613 * NEITHER `-a prob` NOR `-a marg`, and the three are worth telling apart.
5614 * `-a prob` fixes the PER-CLASS population of every station and is a product
5615 * form; `-a marg` is this law marginalized down to ONE station; this arm is the
5616 * joint over all of them, with the classes summed out. Each value is the sum of
5617 * `-a prob` over the whole fibre of per-class tables with these row sums, and
5618 * that fibre grows combinatorially, so it is evaluated as a permanent of the
5619 * demand matrix replicated once per job (Ryser 1963) rather than enumerated.
5620 *
5621 * The whole lattice of total states is swept, so the printed column sums to one
5622 * and the sweep pays for the normalizing constant once.
5623 */
5624template <class T>
5625int solve_model_nc_sysmarg(const std::string& file, const Knobs& k) {
5626 line::qn::Network<T> net = read_model<T>(file);
5628 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5629 if (k.tol >= 0.0) opt.tol = k.tol;
5630 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5631
5632 double Ntot = 0.0;
5633 for (std::size_t r = 0; r < sn.nclasses; ++r) {
5634 const double pop = sn.classes[r].population;
5635 if (!std::isfinite(pop))
5637 "getProbSysMarg requires a closed model: the joint law of the total queue lengths "
5638 "is not defined when a class has an infinite population");
5639 Ntot += pop;
5640 }
5641 const std::vector<std::vector<int> > states = line::pfqn::multichoose_rows(
5642 static_cast<int>(sn.nstations), static_cast<int>(std::llround(Ntot)));
5643
5644 std::vector<double> P(states.size(), 0.0);
5645 for (std::size_t j = 0; j < states.size(); ++j)
5647 line::nc::solver_nc_getprob_sys_marg(sn, opt, states[j], k.method_perm));
5648
5649 if (g_json_output) {
5650 line::reg::Json p = line::reg::Json::object();
5651 p["type"] = "ProbSysMarg";
5652 p["indexBase"] = 0;
5653 p["engine"] = k.method_perm;
5654 line::reg::Json st = line::reg::Json::array(), arr = line::reg::Json::array();
5655 for (std::size_t i = 0; i < sn.nstations; ++i) st.push_back(sn.stations[i].name);
5656 for (std::size_t j = 0; j < states.size(); ++j) {
5657 line::reg::Json e = line::reg::Json::object();
5658 line::reg::Json n = line::reg::Json::array();
5659 for (std::size_t i = 0; i < sn.nstations; ++i) n.push_back(states[j][i]);
5660 e["n"] = n;
5661 e["P"] = P[j];
5662 arr.push_back(e);
5663 }
5664 p["Station"] = st;
5665 p["states"] = arr;
5666 emit_analysis<T>("sysmarg", p, opt.method);
5667 return 0;
5668 }
5669 std::printf("SolverNC arith=%s method=%s engine=%s\n", line::num_traits<T>::name(),
5670 opt.method.c_str(), k.method_perm.c_str());
5671 for (std::size_t i = 0; i < sn.nstations; ++i)
5672 std::printf("%10s", sn.stations[i].name.c_str());
5673 std::printf(" %14s\n", "ProbSysMarg");
5674 double total = 0.0;
5675 for (std::size_t j = 0; j < states.size(); ++j) {
5676 for (std::size_t i = 0; i < sn.nstations; ++i) std::printf("%10d", states[j][i]);
5677 std::printf(" %14.10g\n", P[j]);
5678 total += P[j];
5679 }
5680 std::printf("%*s %14.10g\n", static_cast<int>(10 * sn.nstations), "sum", total);
5681 return 0;
5682}
5683
5684/**
5685 * `-s nc -a marg`: `@@SolverNC/getProbMarg.m`, the TOTAL queue-length law.
5686 *
5687 * NOT THE SAME QUANTITY AS `-s mva -a marg`, although the reference gives both
5688 * methods the same name. SolverMVA's getProbMarg is per (station, CLASS) and is
5689 * a binomial / Poisson / geometric fitted to the solver's own means; SolverNC's
5690 * is the TOTAL number of jobs at a station, summed over classes, and is exact --
5691 * a ratio of normalizing constants, obtained either from one `pfqn_procomom`
5692 * solve (`--method comom`) or by summing the aggregate marginal over the
5693 * per-class partitions of n. `--class` and `--marg-states` are therefore refused
5694 * for it rather than ignored: this law has no class argument and its support is
5695 * 0..sum(N), which the model fixes.
5696 *
5697 * Both P and log P are reported. The log is not a formatting of the other: the
5698 * enumeration forms it first and a probability that underflows to 0 in double
5699 * still has a finite log, so dropping it would lose the only number left.
5700 */
5701template <class T>
5702int solve_model_nc_marg(const std::string& file, const Knobs& k) {
5703 line::qn::Network<T> net = read_model<T>(file);
5705 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5706 if (k.tol >= 0.0) opt.tol = k.tol;
5707 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5708 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5709 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5710
5711 std::vector<std::size_t> ists; // 1-based station indices to report
5712 if (k.node) {
5713 if (k.node > sn.nof_nodes())
5714 throw line::InputError("--node " + std::to_string(k.node) +
5715 " exceeds the number of nodes in the model (" +
5716 std::to_string(sn.nof_nodes()) + ")");
5717 const std::size_t ist = sn.nodes[k.node - 1].station;
5718 if (!ist)
5719 throw line::InputError("--node " + std::to_string(k.node) + " ('" +
5720 sn.nodes[k.node - 1].name +
5721 "') is not a station, and a queue-length distribution is "
5722 "reported per station");
5723 ists.push_back(ist);
5724 } else {
5725 for (std::size_t i = 0; i < sn.nstations; ++i) ists.push_back(i + 1);
5726 }
5727
5728 // Every curve first, for solve_model_marg's reason: a station the reference
5729 // refuses must not leave a header standing above an answer that never came.
5730 std::vector<line::nc::NcQueueLengthDist<T> > curves;
5731 for (std::size_t a = 0; a < ists.size(); ++a)
5732 curves.push_back(line::nc::solver_nc_getprob_marg(sn, opt, ists[a]));
5733
5734 if (g_json_output) {
5735 line::reg::Json p = line::reg::Json::object();
5736 p["type"] = "ProbMargAggr";
5737 p["indexBase"] = 0;
5738 line::reg::Json arr = line::reg::Json::array();
5739 for (std::size_t a = 0; a < ists.size(); ++a) {
5740 line::reg::Json e = line::reg::Json::object();
5741 e["station"] = ists[a] - 1;
5742 e["Station"] = sn.stations[ists[a] - 1].name;
5743 e["P"] = vector_json<T>(curves[a].P);
5744 e["logP"] = vector_json<T>(curves[a].logP);
5745 arr.push_back(e);
5746 }
5747 p["curves"] = arr;
5748 emit_analysis<T>("marg", p, opt.method);
5749 return 0;
5750 }
5751 std::printf("SolverNC arith=%s method=%s type=%s\n", line::num_traits<T>::name(),
5752 opt.method.c_str(), line::util::method_type("NC", opt.method).c_str());
5753 for (std::size_t a = 0; a < ists.size(); ++a) {
5754 std::printf("%-16s %-8s %14s %14s\n", "Station", "n", "P", "logP");
5755 for (std::size_t n = 0; n < curves[a].P.size(); ++n)
5756 std::printf("%-16s %-8zu %14.10g %14.10g\n", sn.stations[ists[a] - 1].name.c_str(), n,
5757 line::num_traits<T>::to_double(curves[a].P[n]),
5758 line::num_traits<T>::to_double(curves[a].logP[n]));
5759 }
5760 return 0;
5761}
5762
5763/**
5764 * `-s nc -a cdf`: `@@SolverNC/getCdfRespT.m` and its aliases `getSjrnT`/`sjrnT`.
5765 *
5766 * THE WHOLE LAW, NOT ITS MEAN. `-a avg` reports E[R]; this reports F(t) per
5767 * (station, class) on one shared logarithmic grid, so a percentile or a tail
5768 * probability can be read off it. The algorithm is `pfqn_stdf` (`--method-cdf
5769 * exact`, the default) or the `pfqn_stdf_heur` reduction (`rd`), selected
5770 * through `options.config.algorithm` exactly as in the reference.
5771 *
5772 * FCFS ONLY, and the reference says so by WARNING and returning an empty
5773 * result rather than raising: the sojourn law of a processor-sharing or
5774 * infinite-server station is not the one this inversion computes. That warning
5775 * is carried through to stderr here and the analysis reports no curve, which is
5776 * distinguishable from a curve that is flat.
5777 */
5778template <class T>
5779int solve_model_nc_cdf(const std::string& file, const Knobs& k) {
5782 "the -s nc -a cdf analysis evaluates the sojourn law on a logarithmic time grid and "
5783 "inverts a generating function; it needs transcendental arithmetic, so rerun with "
5784 "--arith double or --arith real");
5785 } else {
5786 line::qn::Network<T> net = read_model<T>(file);
5788 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5789 if (k.tol >= 0.0) opt.tol = k.tol;
5790 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5791 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5792 if (!k.cdf_algorithm.empty()) opt.cdf_algorithm = k.cdf_algorithm;
5793 const line::qn::NetworkStruct<T>& sn = net.get_struct();
5795 if (!r.warning.empty()) std::fprintf(stderr, "warning: %s\n", r.warning.c_str());
5796
5797 if (g_json_output) {
5798 line::reg::Json p = line::reg::Json::object();
5799 p["type"] = "CdfRespT";
5800 p["indexBase"] = 0;
5801 p["algorithm"] = opt.cdf_algorithm;
5802 // ONE OBJECT PER CURVE, as on the CTMC arm: the pairs that carry a
5803 // law are a subset of the grid, so a column form would leave the
5804 // host to re-group them.
5805 line::reg::Json rd = line::reg::Json::array();
5806 for (std::size_t i = 0; i < r.RD.size(); ++i)
5807 for (std::size_t c = 0; c < r.RD[i].size(); ++c) {
5808 // An empty entry is an ABSENT law -- the station is not FCFS
5809 // or does not serve the class -- and is omitted rather than
5810 // sent as a degenerate one.
5811 if (r.RD[i][c].empty()) continue;
5812 line::reg::Json e = line::reg::Json::object();
5813 e["Station"] = sn.stations[i].name;
5814 e["JobClass"] = sn.classes[c].name;
5815 e["station"] = i;
5816 e["jobclass"] = c;
5817 line::reg::Json tt = line::reg::Json::array(), ff = line::reg::Json::array();
5818 for (std::size_t j = 0; j < r.RD[i][c].rows(); ++j) {
5819 ff.push_back(line::num_traits<T>::to_double(r.RD[i][c](j, 0)));
5820 tt.push_back(line::num_traits<T>::to_double(r.RD[i][c](j, 1)));
5821 }
5822 e["t"] = tt;
5823 e["F"] = ff;
5824 rd.push_back(e);
5825 }
5826 p["respt"] = rd;
5827 p["tset"] = vector_json(r.tset);
5828 if (!r.warning.empty()) p["warning"] = r.warning;
5829 // NO "method": the law comes from the sojourn-time inversion and not
5830 // from the normalizing-constant ladder, so the requested method name
5831 // would not be the algorithm that produced these numbers.
5832 emit_analysis<T>("cdf", p, std::string());
5833 return 0;
5834 }
5835 std::printf("SolverNC arith=%s algorithm=%s grid=%zu\n", line::num_traits<T>::name(),
5836 opt.cdf_algorithm.c_str(), r.tset.size());
5837 std::printf("%-16s %-14s %14s %14s\n", "Station", "JobClass", "Time", "F(t)");
5838 for (std::size_t i = 0; i < r.RD.size(); ++i)
5839 for (std::size_t c = 0; c < r.RD[i].size(); ++c) {
5840 if (r.RD[i][c].empty()) continue;
5841 for (std::size_t j = 0; j < r.RD[i][c].rows(); ++j)
5842 std::printf("%-16s %-14s %14.8g %14.10g\n", sn.stations[i].name.c_str(),
5843 sn.classes[c].name.c_str(),
5844 line::num_traits<T>::to_double(r.RD[i][c](j, 1)),
5845 line::num_traits<T>::to_double(r.RD[i][c](j, 0)));
5846 }
5847 return 0;
5848 }
5849}
5850
5851/**
5852 * The clean-up a sensitivity table applies before printing, for a quantity that
5853 * may legitimately be NEGATIVE.
5854 *
5855 * `ln_sanitize` below tests `x <= FineTol`, which is right for a queue length or
5856 * a utilization -- every metric it was written for is nonnegative, so that test
5857 * reads as "negligible". A DERIVATIVE is not: raising a service rate lowers the
5858 * response time, the queue length and the utilization, so the whole sensitivity
5859 * table is negative by construction and the unsigned test would print an exact
5860 * zero for every one of those columns. Only the MAGNITUDE decides negligibility
5861 * here. The NC and the layered tables share it, which is why it sits above both.
5862 */
5863double sens_sanitize_signed(double x) {
5864 if (std::fabs(x) <= line::lang::GlobalConstants::FineTol) return 0.0;
5865 return x;
5866}
5867
5868/**
5869 * `-s nc -a sens`: `@@NetworkSolver/getSensitivityTable.m` under SolverNC.
5870 *
5871 * ONE ROW PER (station, class) carrying dTput/dRate, dRespT/dRate, dQLen/dRate
5872 * and dUtil/dRate, i.e. the derivative of that row's means with respect to that
5873 * row's service RATE. Two branches produce them and the banner names the one
5874 * that ran: `exact` differentiates the product-form recursion analytically
5875 * (`pfqn_sens` at chain level for a closed model, the closed-form BCMP
5876 * derivatives for an open one), `fd` re-solves rate-perturbed copies of the
5877 * model with THIS solver and forms the quotient.
5878 *
5879 * NC IS ONE OF THE TWO ENGINES THAT CAN TAKE THE EXACT BRANCH, which is what
5880 * `@@SolverNC/supportsExactSensitivity.m` returns true for, so `auto` resolves
5881 * to `exact` whenever the model is in its scope (single-server queues plus
5882 * delays, not mixed) and only falls back to differences outside it. Asking for
5883 * `--sens-method exact` outside that scope is refused by name rather than
5884 * silently downgraded: the two branches answer to different precision.
5885 *
5886 * The struct is COPIED rather than referenced because the fd branch writes a
5887 * scaled service process into it between solves; `net.get_struct()` hands out a
5888 * const reference to the model's own, which must not move under the caller.
5889 */
5890template <class T>
5891int solve_model_nc_sens(const std::string& file, const Knobs& k) {
5892 line::qn::Network<T> net = read_model<T>(file);
5894 if (!k.method.empty() && k.method != "default") opt.method = k.method;
5895 if (k.tol >= 0.0) opt.tol = k.tol;
5896 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
5897 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
5899
5901 if (!k.sens_method.empty()) so.method = k.sens_method;
5902 if (!k.sens_scheme.empty()) so.scheme = k.sens_scheme;
5903 if (k.sens_step > 0.0) so.step = k.sens_step;
5904 so.simulation = false; // the normalizing-constant path is deterministic
5905
5906 // `getAvg`, not the raw analyzer: the reference's difference quotient is
5907 // taken on the metrics the solver reports, which are the filtered ones.
5909 sn, so, /*exact_available=*/true, [&sn, &opt]() {
5912 s.Q = a.QN;
5913 s.U = a.UN;
5914 s.R = a.RN;
5915 s.Tp = a.TN;
5916 s.C = a.CN;
5917 s.X = a.XN;
5918 s.method = a.actualmethod;
5919 s.iter = a.iter;
5920 return s;
5921 });
5922
5923 if (g_json_output) {
5924 line::reg::Json p = line::reg::Json::object();
5925 p["type"] = "SensitivityTable";
5926 p["indexBase"] = 0;
5927 p["branch"] = tbl.method;
5928 line::reg::Json rows = line::reg::Json::array();
5929 for (const line::sens::SensRow<T>& r : tbl.rows) {
5930 line::reg::Json o = line::reg::Json::object();
5931 o["Station"] = r.station;
5932 o["JobClass"] = r.jobclass;
5933 o["dTput_dRate"] = sens_sanitize_signed(line::num_traits<T>::to_double(r.dTput));
5934 o["dRespT_dRate"] = sens_sanitize_signed(line::num_traits<T>::to_double(r.dRespT));
5935 o["dQLen_dRate"] = sens_sanitize_signed(line::num_traits<T>::to_double(r.dQLen));
5936 o["dUtil_dRate"] = sens_sanitize_signed(line::num_traits<T>::to_double(r.dUtil));
5937 rows.push_back(o);
5938 }
5939 p["rows"] = rows;
5940 // The branch rides in "branch" because it is not a normalizing-constant
5941 // method name. The method slot is EMPTY on the exact branch, which
5942 // differentiates the recursion in closed form and never runs a solve:
5943 // naming the NC method there would attribute the numbers to an
5944 // algorithm that did not produce them.
5945 emit_analysis<T>("sens", p, tbl.method == "fd" ? opt.method : std::string());
5946 return 0;
5947 }
5948
5949 std::printf("SolverNC arith=%s branch=%s method=%s rows=%zu\n", line::num_traits<T>::name(),
5950 tbl.method.c_str(), tbl.method == "fd" ? opt.method.c_str() : "-",
5951 tbl.rows.size());
5952 std::printf("%-16s %-14s %14s %14s %14s %14s\n", "Station", "JobClass", "dTput_dRate",
5953 "dRespT_dRate", "dQLen_dRate", "dUtil_dRate");
5954 for (const line::sens::SensRow<T>& r : tbl.rows)
5955 std::printf("%-16s %-14s %14.6g %14.6g %14.6g %14.6g\n", r.station.c_str(),
5956 r.jobclass.c_str(),
5957 sens_sanitize_signed(line::num_traits<T>::to_double(r.dTput)),
5958 sens_sanitize_signed(line::num_traits<T>::to_double(r.dRespT)),
5959 sens_sanitize_signed(line::num_traits<T>::to_double(r.dQLen)),
5960 sens_sanitize_signed(line::num_traits<T>::to_double(r.dUtil)));
5961 return 0;
5962}
5963
5964/** The @@Solver method each `-a` stands for, which is what the chooser keys on. */
5965// ===================== SolverLDES, the simulator ==========================
5966
5967/**
5968 * The knobs one `-s ldes` invocation resolves to.
5969 *
5970 * `--samples` is the SERVICE-COMPLETION budget the engine stops at, `--seed` the
5971 * stream, and both are part of the answer rather than of the invocation, which is
5972 * why the banner carries them. Everything else is `--ldes-*`.
5973 *
5974 * A `--ldes-initsol` placement is NOT accompanied by a forced `fixed` warmup
5975 * filter here, deliberately: `initFromSolver` sets `tranfilter='fixed'` with
5976 * `warmupfrac=0` because the placement it computes IS a steady state, while a
5977 * placement handed in on the command line may equally be the start of a
5978 * transient. Pass `--ldes-tranfilter fixed --ldes-warmupfrac 0` alongside it to
5979 * reproduce `initFromSolver` exactly.
5980 */
5981inline line::ldes::LdesOptions ldes_options(const Knobs& k) {
5983 if (k.samples) o.samples = k.samples;
5984 if (k.seed) o.seed = static_cast<long>(k.seed);
5985 if (!k.method.empty()) o.method = k.method;
5986 if (!k.ldes_tranfilter.empty()) o.tranfilter = k.ldes_tranfilter;
5987 if (k.ldes_warmupfrac >= 0.0) o.warmupfrac = k.ldes_warmupfrac;
5988 if (!k.ldes_cimethod.empty()) o.cimethod = k.ldes_cimethod;
5989 if (k.ldes_cnvgon) o.cnvgon = true;
5990 if (k.ldes_cnvgtol > 0.0) o.cnvgtol = k.ldes_cnvgtol;
5991 if (k.ldes_slotted) o.slotted = true;
5992 if (k.ldes_slotlength > 0.0) {
5993 o.slotted = true;
5994 o.slot_length = k.ldes_slotlength;
5995 }
5996 if (k.ldes_replications > 0) o.replications = k.ldes_replications;
5997 if (k.ldes_numthreads > 0) o.numthreads = k.ldes_numthreads;
5998 if (k.ldes_maxtime > 0.0) o.timeout = k.ldes_maxtime;
5999 if (!k.ldes_initsol.empty()) o.init_sol = k.ldes_initsol;
6000 if (!k.ldes_rest_url.empty()) o.rest_url = k.ldes_rest_url;
6001 o.verbose = k.verbose;
6002 return o;
6003}
6004
6005/**
6006 * The model.json text the engine is handed.
6007 *
6008 * FORWARDED BYTE FOR BYTE, and never through `read_model`: the reader is scoped
6009 * to the subset the analytical solvers need, and a round trip through it would
6010 * degrade exactly the models LDES exists for. `-a reward` is the one arm that
6011 * also parses the document, because a reward DECLARATION is what it needs.
6012 */
6013inline std::string ldes_document(const std::string& file) {
6014 return file.empty() ? stdin_model_text() : line::ldes::detail::read_file(file);
6015}
6016
6017/** A reported entry, or 0 where the engine reported no such row. */
6018inline double ldes_at(const line::Matrix<double>& M, std::size_t i, std::size_t j) {
6019 return i < M.rows() && j < M.cols() ? M(i, j) : 0.0;
6020}
6021
6022/**
6023 * One LDES run.
6024 *
6025 * A HARD TIMEOUT IS A REFUSAL HERE, not an empty result. The two other clients
6026 * return an empty result flagged `timedOut` and warn, which suits a caller that
6027 * can inspect the flag; a CLI's caller reads a table, and a table of zeros that
6028 * means "the run was killed" is the silent-wrong-number outcome this CLI refuses
6029 * everywhere else.
6030 */
6031inline line::ldes::LdesResult ldes_run(const std::string& file,
6032 const line::ldes::LdesOptions& o,
6033 const std::vector<std::string>& extra) {
6034 const line::ldes::LdesResult r = line::ldes::solver_ldes_text(ldes_document(file), o, extra);
6035 if (r.timed_out)
6036 throw line::NumericError(
6037 "SolverLDES exceeded its wall-clock budget (--ldes-maxtime) and was terminated before "
6038 "it wrote a result; raise the budget or lower --samples");
6039 if (r.station_names.empty())
6040 throw line::NumericError(
6041 "SolverLDES: the engine reported no station names, so its metrics cannot be labelled; "
6042 "the run produced no result document");
6043 return r;
6044}
6045
6046/**
6047 * The provenance line every LDES arm prints first.
6048 *
6049 * `engine=` is not decoration: the AOT native image and the jar are two builds of
6050 * one engine and the first can lag the sources, so a number quoted from an LDES
6051 * run has to say which produced it. `stopping=` is the reason the run ended --
6052 * a `max_events` stop at a low `--samples` is a wide confidence interval and a
6053 * `max_time` one is a truncated run, and neither is visible in the means.
6054 */
6055inline void ldes_banner(const line::ldes::LdesResult& r, const line::ldes::LdesOptions& o) {
6056 std::printf("SolverLDES arith=double method=%s type=%s engine=%s samples=%zu seed=%ld "
6057 "time=%.6g events=%lld stopping=%s\n",
6058 r.method.c_str(), line::util::method_type("LDES", r.method).c_str(),
6059 r.engine.c_str(), o.events ? o.events : o.samples, o.seed, r.runtime,
6061}
6062
6063/** The envelope keys that qualify an LDES solve as a whole. */
6064inline line::reg::Json ldes_envelope(const line::ldes::LdesResult& r,
6065 const line::ldes::LdesOptions& o) {
6066 line::reg::Json e = line::reg::Json::object();
6067 e["engine"] = r.engine;
6068 e["samples"] = o.events ? o.events : o.samples;
6069 e["seed"] = o.seed;
6070 e["converged"] = r.converged;
6071 e["stoppingReason"] = r.stopping_reason;
6072 e["totalSimulatedEvents"] = r.total_simulated_events;
6073 e["runtime"] = r.runtime;
6074 return e;
6075}
6076
6077/**
6078 * `-s ldes -a avg`: the steady-state table, the engine's `getAvg`.
6079 *
6080 * THE FINITE-CAPACITY-REGION ROWS DO NOT JOIN THE STATION TABLE, unlike MATLAB's
6081 * `getAvgTable`, which appends them after the stations. A region is not a station
6082 * and the "Station" column of this CLI's table is read by a parity harness that
6083 * pairs rows with another codebase's stations; a region row there would pair with
6084 * nothing. They are printed as their own table and carried under `avg.fcr`, which
6085 * is the same information without the collision.
6086 */
6087int solve_model_ldes_avg(const std::string& file, const Knobs& k) {
6088 const line::ldes::LdesOptions o = ldes_options(k);
6089 const line::ldes::LdesResult r = ldes_run(file, o, std::vector<std::string>());
6090 if (!g_json_output) ldes_banner(r, o);
6091
6092 line::reg::Json extra = line::reg::Json::object();
6093 // The confidence intervals are the half-widths the engine reports, one per
6094 // metric; a simulation that quoted a mean without them would be quoting a
6095 // point estimate as if it were exact.
6096 line::reg::Json ci = line::reg::Json::object();
6097 if (!r.QNCI.empty()) ci["QNCI"] = matrix_json<double>(r.QNCI);
6098 if (!r.UNCI.empty()) ci["UNCI"] = matrix_json<double>(r.UNCI);
6099 if (!r.RNCI.empty()) ci["RNCI"] = matrix_json<double>(r.RNCI);
6100 if (!r.TNCI.empty()) ci["TNCI"] = matrix_json<double>(r.TNCI);
6101 if (!r.ANCI.empty()) ci["ANCI"] = matrix_json<double>(r.ANCI);
6102 if (!r.WNCI.empty()) ci["WNCI"] = matrix_json<double>(r.WNCI);
6103 if (!ci.empty()) extra["CI"] = ci;
6104 if (!r.QNfcr.empty()) {
6105 line::reg::Json f = line::reg::Json::object();
6106 f["nregions"] = r.nregions;
6107 f["QNfcr"] = matrix_json<double>(r.QNfcr);
6108 f["RNfcr"] = matrix_json<double>(r.RNfcr);
6109 f["TNfcr"] = matrix_json<double>(r.TNfcr);
6110 f["WNfcr"] = matrix_json<double>(r.WNfcr);
6111 if (!r.WeightNfcr.empty()) f["WeightNfcr"] = matrix_json<double>(r.WeightNfcr);
6112 if (!r.MemOccNfcr.empty()) f["MemOccNfcr"] = matrix_json<double>(r.MemOccNfcr);
6113 if (!r.DropRateNfcr.empty()) f["DropRateNfcr"] = matrix_json<double>(r.DropRateNfcr);
6114 extra["fcr"] = f;
6115 }
6116 if (!r.DropRateJoin.empty()) extra["DropRateJoin"] = matrix_json<double>(r.DropRateJoin);
6117 if (!r.cache_metrics.empty()) {
6118 line::reg::Json cm = line::reg::Json::object();
6119 for (std::map<std::string, line::ldes::LdesCacheMetrics>::const_iterator it =
6120 r.cache_metrics.begin();
6121 it != r.cache_metrics.end(); ++it) {
6122 line::reg::Json c = line::reg::Json::object();
6123 if (!it->second.hit.empty()) c["hit"] = matrix_json<double>(it->second.hit);
6124 if (!it->second.delayed.empty()) c["delayed"] = matrix_json<double>(it->second.delayed);
6125 if (!it->second.miss.empty()) c["miss"] = matrix_json<double>(it->second.miss);
6126 if (!it->second.latency.empty()) c["latency"] = matrix_json<double>(it->second.latency);
6127 if (!it->second.hitList.empty()) c["hitList"] = matrix_json<double>(it->second.hitList);
6128 if (!it->second.itemProb.empty())
6129 c["itemProb"] = matrix_json<double>(it->second.itemProb);
6130 if (!it->second.listCost.empty())
6131 c["listCost"] = matrix_json<double>(it->second.listCost);
6132 cm[it->first] = c;
6133 }
6134 extra["cacheMetrics"] = cm;
6135 }
6136
6137 // THE RESIDENCE TIME IS DERIVED HERE, not taken from the engine. The engine
6138 // reports WN = RN because it counts one visit per station, which is only
6139 // true when every visit ratio is 1; `getAvg.m:204` therefore discards the
6140 // WN a solver returned and recomputes `sn_get_residt_from_respt(sn, RN)`,
6141 // and every other C++ solver already routes through the same helper. On
6142 // cqn_repairmen, whose Queue1 is visited 0.3 times per cycle, the engine's
6143 // WN came out 11.8136 against the reference's 3.5205 -- the response time
6144 // reported as if the station were visited once.
6145 //
6146 // MATCHED BY NAME. The engine's station order is its own; a Cache or a
6147 // Source can sit at a different index in the struct, and pairing the two
6148 // off positionally would scale one station's time by another's visits.
6149 line::Matrix<double> WNd(r.station_names.size(), r.class_names.size(), 0.0);
6150 {
6151 line::qn::Network<double> net = read_model<double>(file);
6153 std::vector<std::size_t> st_of(r.station_names.size(), 0); // 1-based, 0 = unmatched
6154 for (std::size_t i = 0; i < r.station_names.size(); ++i)
6155 for (std::size_t j = 0; j < sn.nstations; ++j)
6156 if (sn.stations[j].name == r.station_names[i]) { st_of[i] = j + 1; break; }
6157 std::vector<std::size_t> cl_of(r.class_names.size(), 0);
6158 for (std::size_t c = 0; c < r.class_names.size(); ++c)
6159 for (std::size_t k = 0; k < sn.nclasses; ++k)
6160 if (sn.classes[k].name == r.class_names[c]) { cl_of[c] = k + 1; break; }
6161 line::Matrix<double> RNs(sn.nstations, sn.nclasses, 0.0);
6162 for (std::size_t i = 0; i < r.station_names.size(); ++i)
6163 for (std::size_t c = 0; c < r.class_names.size(); ++c)
6164 if (st_of[i] && cl_of[c]) RNs(st_of[i] - 1, cl_of[c] - 1) = ldes_at(r.RN, i, c);
6166 for (std::size_t i = 0; i < r.station_names.size(); ++i)
6167 for (std::size_t c = 0; c < r.class_names.size(); ++c)
6168 WNd(i, c) = (st_of[i] && cl_of[c]) ? WNs(st_of[i] - 1, cl_of[c] - 1)
6169 : ldes_at(r.WN, i, c);
6170 }
6171
6172 emit_avg_table_named(r.station_names, r.class_names, "double", r.method,
6173 [&](std::size_t i, std::size_t c) {
6174 AvgRow v;
6175 v.q = ldes_at(r.QN, i, c);
6176 v.u = ldes_at(r.UN, i, c);
6177 v.r = ldes_at(r.RN, i, c);
6178 v.w = WNd(i, c);
6179 v.a = ldes_at(r.AN, i, c);
6180 v.t = ldes_at(r.TN, i, c);
6181 return v;
6182 },
6183 extra, ldes_envelope(r, o));
6184
6185 if (!g_json_output && !r.QNfcr.empty()) {
6186 std::printf("%-16s %-14s %12s %12s %12s %12s %12s\n", "Region", "JobClass", "QLen", "RespT",
6187 "Tput", "Weight", "MemOcc");
6188 for (std::size_t i = 0; i < r.QNfcr.rows(); ++i)
6189 for (std::size_t c = 0; c < r.class_names.size(); ++c)
6190 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g\n",
6191 ("Region" + std::to_string(i + 1)).c_str(), r.class_names[c].c_str(),
6192 ldes_at(r.QNfcr, i, c), ldes_at(r.RNfcr, i, c),
6193 ldes_at(r.TNfcr, i, c), ldes_at(r.WeightNfcr, i, c),
6194 ldes_at(r.MemOccNfcr, i, c));
6195 }
6196 if (!g_json_output && !r.cache_metrics.empty()) {
6197 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Cache", "JobClass", "Hit", "Delayed",
6198 "Miss", "Latency");
6199 for (std::map<std::string, line::ldes::LdesCacheMetrics>::const_iterator it =
6200 r.cache_metrics.begin();
6201 it != r.cache_metrics.end(); ++it)
6202 for (std::size_t c = 0; c < r.class_names.size(); ++c)
6203 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n", it->first.c_str(),
6204 r.class_names[c].c_str(), ldes_at(it->second.hit, 0, c),
6205 ldes_at(it->second.delayed, 0, c), ldes_at(it->second.miss, 0, c),
6206 ldes_at(it->second.latency, 0, c));
6207 }
6208 return 0;
6209}
6210
6211/**
6212 * `-s ldes -a tran`: `getTranAvg`, the per-bucket QNt / UNt / TNt series over
6213 * `--tspan`.
6214 *
6215 * THE HORIZON IS REQUIRED. `options.timespan` is what turns the engine's run into
6216 * a transient one, and there is no default: a trajectory over an unstated horizon
6217 * is not a quantity. A SINGLE PATH IS NOT E[N](t) either -- there is no time
6218 * ergodicity at fixed t -- so `--ldes-replications` is how an ensemble mean is
6219 * asked for, exactly as `runAnalyzer.m` passes `--replications` for the same
6220 * reason.
6221 *
6222 * The series are indexed by STATION, as `LDESResultIO` writes them
6223 * (`result.QNt = new Matrix[numStations][numClasses]`).
6224 */
6225int solve_model_ldes_tran(const std::string& file, const Knobs& k) {
6226 line::ldes::LdesOptions o = ldes_options(k);
6227 o.has_timespan = true;
6228 o.t0 = k.t0;
6229 o.t1 = k.t1;
6230 std::vector<std::string> extra;
6231 extra.push_back("--trajectory");
6232 const line::ldes::LdesResult r = ldes_run(file, o, extra);
6233 if (r.t.empty() || r.QNt.empty())
6234 throw line::NumericError(
6235 "SolverLDES -a tran produced no trajectory: the engine ran but recorded no bucket over "
6236 "[" + line::ldes::detail::shortest(k.t0) + "," +
6237 line::ldes::detail::shortest(k.t1) + "]");
6238
6239 if (g_json_output) {
6240 line::reg::Json p = line::reg::Json::object();
6241 p["type"] = "TranAvgTable";
6242 p["indexBase"] = 0;
6243 p["t0"] = k.t0;
6244 p["t1"] = k.t1;
6245 line::reg::Json curves = line::reg::Json::array();
6246 for (std::size_t i = 0; i < r.QNt.size(); ++i)
6247 for (std::size_t c = 0; c < r.QNt[i].size(); ++c) {
6248 // An empty series is an ABSENT one -- the class does not visit
6249 // the station -- and is omitted rather than sent as a flat zero.
6250 if (r.QNt[i][c].empty()) continue;
6251 line::reg::Json e = line::reg::Json::object();
6252 e["Station"] = i < r.station_names.size() ? r.station_names[i]
6253 : "Station" + std::to_string(i);
6254 e["JobClass"] =
6255 c < r.class_names.size() ? r.class_names[c] : "Class" + std::to_string(c);
6256 e["station"] = i;
6257 e["jobclass"] = c;
6258 line::reg::Json tt = line::reg::Json::array(), q = line::reg::Json::array(),
6259 u = line::reg::Json::array(), x = line::reg::Json::array();
6260 for (std::size_t j = 0; j < r.QNt[i][c].rows(); ++j) {
6261 tt.push_back(r.QNt[i][c](j, 1));
6262 q.push_back(r.QNt[i][c](j, 0));
6263 }
6264 if (i < r.UNt.size() && c < r.UNt[i].size())
6265 for (std::size_t j = 0; j < r.UNt[i][c].rows(); ++j)
6266 u.push_back(r.UNt[i][c](j, 0));
6267 if (i < r.TNt.size() && c < r.TNt[i].size())
6268 for (std::size_t j = 0; j < r.TNt[i][c].rows(); ++j)
6269 x.push_back(r.TNt[i][c](j, 0));
6270 e["t"] = tt;
6271 e["QLen"] = q;
6272 e["Util"] = u;
6273 e["Tput"] = x;
6274 curves.push_back(e);
6275 }
6276 p["curves"] = curves;
6277 p["tset"] = vector_json(r.t);
6278 // The envelope is built ONCE into a local: `begin()` and `end()` taken
6279 // from two different temporaries are iterators into two different
6280 // objects, which is undefined behaviour and not a style point.
6281 const line::reg::Json env = ldes_envelope(r, o);
6282 for (line::reg::Json::const_iterator it = env.begin(); it != env.end(); ++it)
6283 p[it.key()] = it.value();
6284 emit_analysis<double>("tran", p, r.method);
6285 return 0;
6286 }
6287 ldes_banner(r, o);
6288 std::printf("%-16s %-14s %12s %12s %12s %12s\n", "Station", "JobClass", "Time", "QLen", "Util",
6289 "Tput");
6290 for (std::size_t i = 0; i < r.QNt.size(); ++i)
6291 for (std::size_t c = 0; c < r.QNt[i].size(); ++c) {
6292 if (r.QNt[i][c].empty()) continue;
6293 for (std::size_t j = 0; j < r.QNt[i][c].rows(); ++j) {
6294 const bool hu = i < r.UNt.size() && c < r.UNt[i].size() &&
6295 j < r.UNt[i][c].rows();
6296 const bool hx = i < r.TNt.size() && c < r.TNt[i].size() &&
6297 j < r.TNt[i][c].rows();
6298 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g\n",
6299 (i < r.station_names.size() ? r.station_names[i].c_str() : "?"),
6300 (c < r.class_names.size() ? r.class_names[c].c_str() : "?"),
6301 r.QNt[i][c](j, 1), r.QNt[i][c](j, 0),
6302 hu ? r.UNt[i][c](j, 0) : 0.0, hx ? r.TNt[i][c](j, 0) : 0.0);
6303 }
6304 }
6305 return 0;
6306}
6307
6308/**
6309 * `-s ldes -a cdf`: `getCdfRespT`, the EMPIRICAL response-time law.
6310 *
6311 * A SIMULATOR MUST REPORT WHAT IT OBSERVED. The base solver's fallback fabricates
6312 * an exponential law with the right mean, which says nothing about the tail; the
6313 * engine records every per-job response time under `--respt-samples`, and the
6314 * curve here is the ecdf of those samples with repeated observations collapsed to
6315 * their largest F, exactly as `@@SolverLDES/getCdfRespT.m` builds it.
6316 *
6317 * `--respt-samples` POSTDATES the prebuilt AOT image, which is why the runner
6318 * order flips for it (see `ldes_runners`): on that image the flag is accepted and
6319 * ignored, and the arm would refuse for want of samples that were never asked for.
6320 */
6321int solve_model_ldes_cdf(const std::string& file, const Knobs& k, const char* key,
6322 const char* type) {
6323 const line::ldes::LdesOptions o = ldes_options(k);
6324 std::vector<std::string> extra;
6325 extra.push_back("--respt-samples");
6326 const line::ldes::LdesResult r = ldes_run(file, o, extra);
6327 if (r.respTimeSamples.empty())
6328 throw line::NumericError(
6329 "SolverLDES -a cdf needs the per-job response times the engine records under "
6330 "--respt-samples and the run returned none; raise --samples so completions are "
6331 "observed at all");
6332
6333 // The ecdf of each (station, class): sorted observations, F = i/n, and one
6334 // pair per DISTINCT value carrying the largest F at it.
6335 std::vector<std::vector<std::vector<double>>> tt(r.respTimeSamples.size()), ff(
6336 r.respTimeSamples.size());
6337 for (std::size_t i = 0; i < r.respTimeSamples.size(); ++i) {
6338 tt[i].resize(r.respTimeSamples[i].size());
6339 ff[i].resize(r.respTimeSamples[i].size());
6340 for (std::size_t c = 0; c < r.respTimeSamples[i].size(); ++c) {
6341 std::vector<double> x = r.respTimeSamples[i][c];
6342 if (x.empty()) continue;
6343 std::sort(x.begin(), x.end());
6344 const double n = static_cast<double>(x.size());
6345 for (std::size_t j = 0; j < x.size(); ++j) {
6346 if (j + 1 < x.size() && x[j + 1] == x[j]) continue;
6347 tt[i][c].push_back(x[j]);
6348 ff[i][c].push_back(static_cast<double>(j + 1) / n);
6349 }
6350 }
6351 }
6352
6353 if (g_json_output) {
6354 line::reg::Json p = line::reg::Json::object();
6355 p["type"] = type;
6356 p["indexBase"] = 0;
6357 p["algorithm"] = "empirical";
6358 line::reg::Json rd = line::reg::Json::array();
6359 for (std::size_t i = 0; i < tt.size(); ++i)
6360 for (std::size_t c = 0; c < tt[i].size(); ++c) {
6361 if (tt[i][c].empty()) continue;
6362 line::reg::Json e = line::reg::Json::object();
6363 e["Station"] = i < r.station_names.size() ? r.station_names[i]
6364 : "Station" + std::to_string(i);
6365 e["JobClass"] =
6366 c < r.class_names.size() ? r.class_names[c] : "Class" + std::to_string(c);
6367 e["station"] = i;
6368 e["jobclass"] = c;
6369 e["t"] = vector_json(tt[i][c]);
6370 e["F"] = vector_json(ff[i][c]);
6371 e["samples"] = r.respTimeSamples[i][c].size();
6372 rd.push_back(e);
6373 }
6374 p["respt"] = rd;
6375 emit_analysis<double>(key, p, std::string());
6376 return 0;
6377 }
6378 ldes_banner(r, o);
6379 std::printf("%-16s %-14s %14s %14s\n", "Station", "JobClass", "Time", "F(t)");
6380 for (std::size_t i = 0; i < tt.size(); ++i)
6381 for (std::size_t c = 0; c < tt[i].size(); ++c)
6382 for (std::size_t j = 0; j < tt[i][c].size(); ++j)
6383 std::printf("%-16s %-14s %14.8g %14.10g\n",
6384 (i < r.station_names.size() ? r.station_names[i].c_str() : "?"),
6385 (c < r.class_names.size() ? r.class_names[c].c_str() : "?"),
6386 tt[i][c][j], ff[i][c][j]);
6387 return 0;
6388}
6389
6390/**
6391 * `-s ldes -a sample`: `sampleSys` / `sampleSysAggr`, one simulated trajectory.
6392 *
6393 * The horizon is `[0, --samples]`, which is `runTransientJson`'s: the event budget
6394 * doubles as the transient horizon there because the engine ignores the budget in
6395 * transient mode, so the number the caller gave has to name the horizon or name
6396 * nothing. `--tspan` names a horizon in its own right and belongs to `-a tran`.
6397 *
6398 * The state is the per-class queue length at each station, which is why there is
6399 * no separate `sampleSysAggr` column: an LDES trajectory is ALREADY per class, so
6400 * the aggregate view is the row sum and the reference's two getters return the
6401 * same data with one flag flipped.
6402 */
6403int solve_model_ldes_sample(const std::string& file, const Knobs& k) {
6404 line::ldes::LdesOptions o = ldes_options(k);
6405 o.has_timespan = true;
6406 o.t0 = 0.0;
6407 o.t1 = static_cast<double>(o.events ? o.events : o.samples);
6408 std::vector<std::string> extra;
6409 extra.push_back("--trajectory");
6410 const line::ldes::LdesResult r = ldes_run(file, o, extra);
6411 if (r.t.empty() || r.QNt.empty())
6412 throw line::NumericError(
6413 "SolverLDES -a sample produced no trajectory over [0," +
6414 line::ldes::detail::shortest(o.t1) + "]");
6415
6416 const std::size_t M = r.QNt.size(), K = r.class_names.size(), n = r.t.size();
6417 if (g_json_output) {
6418 line::reg::Json p = line::reg::Json::object();
6419 p["type"] = "SamplePath";
6420 p["indexBase"] = 0;
6421 p["scope"] = "(system)";
6422 p["drawn"] = n;
6423 p["t"] = vector_json(r.t);
6424 line::reg::Json st = line::reg::Json::array();
6425 for (std::size_t j = 0; j < n; ++j) {
6426 line::reg::Json row = line::reg::Json::array();
6427 for (std::size_t i = 0; i < M; ++i)
6428 for (std::size_t c = 0; c < K; ++c)
6429 row.push_back(c < r.QNt[i].size() && j < r.QNt[i][c].rows()
6430 ? r.QNt[i][c](j, 0)
6431 : 0.0);
6432 st.push_back(row);
6433 }
6434 p["state"] = st;
6435 line::reg::Json cols = line::reg::Json::array();
6436 for (std::size_t i = 0; i < M; ++i)
6437 for (std::size_t c = 0; c < K; ++c)
6438 cols.push_back((i < r.station_names.size() ? r.station_names[i] : "?") + "," +
6439 (c < K ? r.class_names[c] : "?"));
6440 p["columns"] = cols;
6441 // The envelope is built ONCE into a local: `begin()` and `end()` taken
6442 // from two different temporaries are iterators into two different
6443 // objects, which is undefined behaviour and not a style point.
6444 const line::reg::Json env = ldes_envelope(r, o);
6445 for (line::reg::Json::const_iterator it = env.begin(); it != env.end(); ++it)
6446 p[it.key()] = it.value();
6447 emit_analysis<double>("sample", p, r.method);
6448 return 0;
6449 }
6450 ldes_banner(r, o);
6451 std::printf("%14s %s\n", "Time", "SysState (station-major, per class)");
6452 for (std::size_t j = 0; j < n; ++j) {
6453 std::printf("%14.8g ", r.t[j]);
6454 for (std::size_t i = 0; i < M; ++i)
6455 for (std::size_t c = 0; c < K; ++c)
6456 std::printf(" %g", c < r.QNt[i].size() && j < r.QNt[i][c].rows()
6457 ? r.QNt[i][c](j, 0)
6458 : 0.0);
6459 std::printf("\n");
6460 }
6461 return 0;
6462}
6463
6464/**
6465 * `-s ldes -a reward`: `getAvgReward`, E[r] on the EXACT joint-state histogram.
6466 *
6467 * The engine exports, under `--export-histogram`, the residence time of every
6468 * joint state it visited, in the aggregate layout `ctmc_state_space_aggr` builds.
6469 * The rewards are then evaluated here, state by state, so E[r] = sum_s (t_s /
6470 * sum t) r(state_s) is correct for a NONLINEAR reward too -- which is the whole
6471 * point of the histogram over the means: E[n^2] cannot be recovered from E[n].
6472 *
6473 * THIS IS THE ONE ARM THAT ALSO PARSES THE DOCUMENT, because a reward is a
6474 * DECLARATION and the declarations live in the model, not in the result. A model
6475 * outside this port's reader therefore reaches every other LDES arm and not this
6476 * one, and says so by the reader's own refusal.
6477 */
6478int solve_model_ldes_reward(const std::string& file, const Knobs& k) {
6479 line::qn::Network<double> net = read_model<double>(file);
6481 if (sn.reward.empty())
6482 throw line::InputError(
6483 "-s ldes -a reward needs a reward declared on the model (set_reward(name, fn), the "
6484 "`rewards` block of model.json); there is nothing to average");
6485
6486 const line::ldes::LdesOptions o = ldes_options(k);
6487 std::vector<std::string> extra;
6488 extra.push_back("--export-histogram");
6489 const line::ldes::LdesResult r = ldes_run(file, o, extra);
6491 throw line::NumericError(
6492 "SolverLDES -a reward needs the joint-state residence-time histogram the engine "
6493 "exports under --export-histogram and the run returned none");
6494
6495 double total = 0.0;
6496 for (std::size_t s = 0; s < r.histogram_time.rows(); ++s)
6497 for (std::size_t c = 0; c < r.histogram_time.cols(); ++c) total += r.histogram_time(s, c);
6498 if (!(total > 0.0))
6499 throw line::NumericError(
6500 "SolverLDES -a reward: the state histogram carries no residence time, so no state "
6501 "distribution can be formed from it");
6502
6503 const std::size_t ns = r.histogram_space.rows(), w = r.histogram_space.cols();
6504 std::vector<double> E(sn.reward.size(), 0.0);
6505 std::vector<std::string> names(sn.reward.size());
6506 for (std::size_t l = 0; l < sn.reward.size(); ++l) {
6507 names[l] = sn.reward[l].name;
6508 for (std::size_t s = 0; s < ns; ++s) {
6509 std::vector<double> row(w);
6510 for (std::size_t c = 0; c < w; ++c) row[c] = r.histogram_space(s, c);
6511 const double t = s < r.histogram_time.rows() && r.histogram_time.cols() > 0
6512 ? r.histogram_time(s, 0)
6513 : 0.0;
6514 E[l] += (t / total) * sn.reward[l].fn(row);
6515 }
6516 }
6517
6518 if (g_json_output) {
6519 line::reg::Json p = line::reg::Json::object();
6520 p["type"] = "AvgReward";
6521 line::reg::Json nm = line::reg::Json::array();
6522 for (std::size_t l = 0; l < names.size(); ++l) nm.push_back(names[l]);
6523 p["Reward"] = nm;
6524 p["E"] = vector_json(E);
6525 p["states"] = ns;
6526 // The envelope is built ONCE into a local: `begin()` and `end()` taken
6527 // from two different temporaries are iterators into two different
6528 // objects, which is undefined behaviour and not a style point.
6529 const line::reg::Json env = ldes_envelope(r, o);
6530 for (line::reg::Json::const_iterator it = env.begin(); it != env.end(); ++it)
6531 p[it.key()] = it.value();
6532 // NO "method": these are expectations over an empirical distribution and
6533 // no chain was solved, so there is no resolved method to report.
6534 emit_analysis<double>("reward", p, std::string());
6535 return 0;
6536 }
6537 ldes_banner(r, o);
6538 std::printf("%-28s %16s\n", "Reward", "E[r]");
6539 for (std::size_t l = 0; l < E.size(); ++l)
6540 std::printf("%-28s %16.10g\n", names[l].c_str(), E[l]);
6541 return 0;
6542}
6543
6544/**
6545 * `-s ldes -a prob`: `getProb`, `getProbAggr`, `getProbSys` and `getProbSysAggr`,
6546 * all four read off the joint-state histogram of ONE run, as
6547 * `@@SolverLDES/getProb*.m` does through `ldesHistProb`.
6548 *
6549 * THE STATE IS THE MODEL'S DECLARED ONE (`api::sn_declared_marginal`, the same
6550 * per-class counts `-s jmt -a prob` weighs against), and `--node` with `--state`
6551 * overrides one station's counts, which is `getProbAggr(node, state)`'s second
6552 * argument. THE DETAILED AND THE AGGREGATE QUERIES COINCIDE HERE, and that is the
6553 * reference's definition rather than a shortcut: the histogram records per-class
6554 * counts per station and nothing about buffer order or phase, so MATLAB's
6555 * `getProb` marginalizes its state with `State.toMarginal` before matching, and
6556 * `getProbSysAggr` is literally `getProbSys`. Each probability is the residence
6557 * time of the matching histogram rows over the total, and `seen` says whether any
6558 * row matched at all, since on a simulation a zero is usually the run length.
6559 *
6560 * A Source holds no jobs, so it takes no part in the system match (its histogram
6561 * columns are zero, as the reference's own match would find them).
6562 */
6563int solve_model_ldes_prob(const std::string& file, const Knobs& k) {
6564 line::qn::Network<double> net = read_model<double>(file);
6566 const std::size_t M = sn.nstations, R = sn.nclasses;
6567
6569 if (!k.state.empty()) {
6570 std::size_t ist = 0;
6571 if (k.node && k.node <= sn.nodes.size()) ist = sn.nodes[k.node - 1].station;
6572 if (ist == 0)
6573 throw line::InputError("--node " + std::to_string(k.node) +
6574 " is not a station; the LDES state histogram records station "
6575 "queue lengths only");
6576 if (k.state.size() != R)
6577 throw line::InputError("-s ldes -a prob takes --state as one job count per class (" +
6578 std::to_string(R) + "), got " + std::to_string(k.state.size()));
6579 for (std::size_t r = 0; r < R; ++r) nir(ist - 1, r) = static_cast<double>(k.state[r]);
6580 }
6581
6582 const line::ldes::LdesOptions o = ldes_options(k);
6583 std::vector<std::string> extra;
6584 extra.push_back("--export-histogram");
6585 const line::ldes::LdesResult res = ldes_run(file, o, extra);
6586 const line::Matrix<double>& space = res.histogram_space;
6587 const line::Matrix<double>& tm = res.histogram_time;
6588 if (space.empty() || tm.empty())
6589 throw line::NumericError(
6590 "SolverLDES -a prob needs the joint-state residence-time histogram the engine exports "
6591 "under --export-histogram and the run returned none");
6592 if (space.cols() < M * R)
6593 throw line::NumericError("SolverLDES: the state histogram holds " +
6594 std::to_string(space.cols()) + " columns, fewer than the " +
6595 std::to_string(M * R) + " of " + std::to_string(M) +
6596 " stations by " + std::to_string(R) + " classes");
6597
6598 std::vector<double> dwell(space.rows(), 0.0);
6599 double total = 0.0;
6600 for (std::size_t s = 0; s < space.rows() && s < tm.rows(); ++s)
6601 for (std::size_t c = 0; c < tm.cols(); ++c) dwell[s] += tm(s, c);
6602 for (double d : dwell) total += d;
6603 if (!(total > 0.0))
6604 throw line::NumericError(
6605 "SolverLDES -a prob: the state histogram carries no residence time, so no state "
6606 "probability can be formed from it");
6607
6608 // ldesHistProb's match: |space - target| < 1e-9 on the station's R columns.
6609 auto matches = [&](std::size_t s, std::size_t i) {
6610 for (std::size_t r = 0; r < R; ++r)
6611 if (!(std::abs(space(s, i * R + r) - nir(i, r)) < 1e-9)) return false;
6612 return true;
6613 };
6614 std::vector<double> prob(M, 0.0);
6615 std::vector<bool> seen(M, false);
6616 double psys = 0.0;
6617 bool sys_seen = false;
6618 for (std::size_t s = 0; s < space.rows(); ++s) {
6619 bool all = true;
6620 for (std::size_t i = 0; i < M; ++i) {
6621 const bool src = sn.nodes[sn.station_to_node[i] - 1].nodetype == line::lang::NodeType::Source;
6622 if (matches(s, i)) {
6623 prob[i] += dwell[s];
6624 seen[i] = true;
6625 } else if (!src) {
6626 all = false;
6627 }
6628 }
6629 if (all) {
6630 psys += dwell[s];
6631 sys_seen = true;
6632 }
6633 }
6634 for (double& p : prob) p /= total;
6635 psys /= total;
6636
6637 if (g_json_output) {
6638 line::reg::Json p = line::reg::Json::object();
6639 p["type"] = "ProbAggr";
6640 p["indexBase"] = 0;
6641 p["ProbSys"] = psys;
6642 p["ProbSysAggr"] = psys;
6643 p["SysStateSeen"] = sys_seen;
6644 p["states"] = space.rows();
6645 line::reg::Json st = line::reg::Json::array(), pa = line::reg::Json::array(),
6646 sv = line::reg::Json::array();
6647 for (std::size_t i = 0; i < M; ++i) {
6648 st.push_back(sn.stations[i].name);
6649 pa.push_back(prob[i]);
6650 sv.push_back(static_cast<bool>(seen[i]));
6651 }
6652 p["Station"] = st;
6653 p["Prob"] = pa;
6654 p["ProbAggr"] = pa;
6655 p["StateSeen"] = sv;
6656 emit_analysis<double>("prob", p, res.method);
6657 return 0;
6658 }
6659 ldes_banner(res, o);
6660 std::printf("ProbSys = ProbSysAggr %.10g%s\n", psys, sys_seen ? "" : " (state never observed)");
6661 std::printf("%-16s %14s\n", "Station", "Prob=ProbAggr");
6662 for (std::size_t i = 0; i < M; ++i)
6663 std::printf("%-16s %14.10g%s\n", sn.stations[i].name.c_str(), prob[i],
6664 seen[i] ? "" : " (state never observed)");
6665 return 0;
6666}
6667
6668/**
6669 * The `-s ldes` entry: one analysis per getter of the reference's surface.
6670 *
6671 * `-a prob` is `solve_model_ldes_prob`: the four getProb queries read off the
6672 * engine's joint-state histogram at the model's DECLARED state.
6673 */
6674int solve_model_ldes(const std::string& file, const Knobs& k, const std::string& analysis) {
6675 if (analysis == "avg") return solve_model_ldes_avg(file, k);
6676 if (analysis == "tran") return solve_model_ldes_tran(file, k);
6677 // THE SAME ECDF UNDER FOUR NAMES, which is the reference's own structure and
6678 // not a shortcut here: `@@SolverLDES/getCdfRespT`, `getTranCdfRespT` and
6679 // `getTranCdfPassT` all read `respTimeSamples`, and the JAR's
6680 // getTranCdfPassT is literally `return getTranCdfRespT(R)`. A simulator
6681 // observes one per-job passage and there is no second measurement to make;
6682 // emitting the curve under the key the caller asked for is what tells them
6683 // apart, and the payload's `type` says which question it answers.
6684 if (analysis == "cdf") return solve_model_ldes_cdf(file, k, "cdf", "CdfRespT");
6685 if (analysis == "cdfpasst") return solve_model_ldes_cdf(file, k, "cdfpasst", "CdfPassT");
6686 if (analysis == "trancdf") return solve_model_ldes_cdf(file, k, "trancdf", "TranCdfRespT");
6687 if (analysis == "trancdfpasst")
6688 return solve_model_ldes_cdf(file, k, "trancdfpasst", "TranCdfPassT");
6689 if (analysis == "sample") return solve_model_ldes_sample(file, k);
6690 if (analysis == "reward") return solve_model_ldes_reward(file, k);
6691 if (analysis == "prob") return solve_model_ldes_prob(file, k);
6693 "SolverLDES ports -a avg (getAvg) and its four views -a node, -a sys, -a chain and "
6694 "-a nodechain, -a tran (getTranAvg), -a cdf / cdf-passt / "
6695 "tran-cdf-respt / tran-cdf-passt (the empirical passage law, one measurement under the "
6696 "four names the reference gives it), -a sample (sampleSys), -a reward "
6697 "(getAvgReward) and -a prob (getProb, getProbAggr, getProbSys, getProbSysAggr); got '" +
6698 analysis + "'");
6699}
6700
6701std::string auto_getter_of_analysis(const std::string& analysis) {
6702 if (analysis == "prob") return "getProbSysAggr";
6703 if (analysis == "marg") return "getProbMarg";
6704 if (analysis == "sysmarg") return "getProbSysMarg";
6705 if (analysis == "normconst") return "getProbNormConstAggr";
6706 if (analysis == "tranprob") return "getTranProbSysAggr";
6707 if (analysis == "sample") return "sampleSys";
6708 if (analysis == "cdf") return "getCdfRespT";
6709 if (analysis == "gen") return "getInfGen";
6710 if (analysis == "states") return "getStateSpace";
6711 if (analysis == "reward") return "getAvgReward";
6712 if (analysis == "sens") return "getSensitivityTable";
6713 if (analysis == "tran") return "getTranAvg";
6714 if (analysis == "internals") return "getMAMResult";
6715 // `-a bounds` and `-a tranreward` deliberately keep the AvgTable getter:
6716 // `getBoundsTable` and `getTranReward` are absent from chooseSolverHeur's
6717 // own method lists, so naming them here would ask the chooser about a getter
6718 // it does not rank. Each is served by exactly one engine anyway (BA and
6719 // CTMC), which refuses by name when `-s auto` sends the run elsewhere.
6720 if (analysis == "node") return "getAvgNodeTable";
6721 return "getAvgTable";
6722}
6723
6724/**
6725 * What `-s auto` resolved to: the engines to try, in order, and the method the
6726 * first of them runs.
6727 */
6728struct AutoPlan {
6729 std::vector<std::string> order; ///< CLI solver method names, chosen first
6730 std::string method; ///< the method the chosen engine runs, "" for its default
6731 std::string note; ///< what the ranking preferred and this port cannot build
6732};
6733
6734/** The CLI method name of a method family, or a refusal naming what the family needs. */
6735std::string auto_cli_token_of_family(const std::string& fam) {
6736 if (fam == "mva" || fam == "nc" || fam == "ctmc" || fam == "mam" || fam == "ag" ||
6737 fam == "ssa" || fam == "ba" || fam == "uq" || fam == "env")
6738 return fam;
6739 if (fam == "fld") return "fluid";
6740 if (fam == "ldes") {
6741 // The engine is not built here, it is RUN here, so the family resolves
6742 // whenever the machine has one and refuses -- naming what is missing --
6743 // when it does not, rather than diverting to a different simulator.
6746 "--method ldes names the discrete-event engine, and no engine was found beside "
6747 "this binary (common/ldes or common/ldes.jar, or $LINE_LDES_DIR); -s ssa is the "
6748 "simulator this port builds in process");
6749 return "ldes";
6750 }
6751 if (fam == "jmt") {
6752 // IT IS WRAPPED NOW. This refused the token outright until the JMT
6753 // client landed, and stayed behind: `-s jmt` drives jsim and jmva
6754 // through `solver_jmt_run_analyzer`, so refusing `--method jmt` denied
6755 // under `-s auto` what the very same binary answers under `-s jmt`.
6756 // Availability is left to the wrapper, which names what is missing (a
6757 // JVM, common/JMT.jar or a REST endpoint) rather than guessing here.
6758 return "jmt";
6759 }
6760 // On a flat Network the lqns family's methods are the qns ones, served by
6761 // `qnsolver` through `solve_model_lqns_network`.
6762 if (fam == "lqns") return "lqns";
6763 if (fam == "ln")
6765 "--method ln names the layered solver, which takes a LayeredNetwork: pass the model "
6766 "as -i lqnx -s ln rather than as a Network");
6767 throw line::InputError("SolverAUTO: no engine stands behind method family '" + fam + "'");
6768}
6769
6770/**
6771 * Is this model.json an Environment envelope rather than a Network?
6772 *
6773 * `-s auto` has to know before it parses: the two readers take different
6774 * documents, and the reference's chooser has an Environment arm that is
6775 * unreachable if every auto run is assumed to hold a Network. A malformed
6776 * document answers false, so the real reader reports the parse error.
6777 */
6778bool model_is_environment(const std::string& file) {
6779 try {
6780 line::io::detail::json root;
6781 if (file.empty()) {
6782 std::istringstream in(stdin_model_text());
6783 in >> root;
6784 } else {
6785 std::ifstream in(file.c_str());
6786 if (!in) return false;
6787 in >> root;
6788 }
6789 // The envelope may wrap the model, exactly as build_environment_from_json
6790 // unwraps it; `type` is what that reader keys on, so this reads the same
6791 // field rather than a second convention of its own.
6792 if (!root.is_object()) return false;
6793 const line::io::detail::json& model = root.contains("model") ? root.at("model") : root;
6794 return model.is_object() && model.value("type", std::string()) == "Environment";
6795 } catch (...) {
6796 return false;
6797 }
6798}
6799
6800/**
6801 * `-s auto`: which engine answers, by `chooseSolver.m` through solver_auto.h.
6802 *
6803 * THREE THINGS DECIDE, in the reference's own order. `--method` is resolved
6804 * first, because a method FAMILY ('nc', 'nc.comom') names the engine outright
6805 * and bypasses every ranking, while a selection INTENT ('exact', 'sim', 'fast',
6806 * 'accurate') picks which ranking runs. Then the model class: an Environment
6807 * envelope takes the Environment arm, a Network the Network one. Then the
6808 * GETTER the caller asked for, since the reference keys the ranking on the
6809 * metric family and not on the model alone.
6810 *
6811 * THE ORDER IS A RETRY LIST, not a single name. `delegate.m` tries the chosen
6812 * solver and then every feasible candidate, so a refusal moves to the next
6813 * engine instead of ending the run; the caller sees which one answered.
6814 *
6815 * The choice reads structure only -- traits, feature sets, product form,
6816 * populations -- so it is made in the arithmetic the run will use and costs one
6817 * extra parse of the model, nothing more.
6818 */
6819template <class T>
6820AutoPlan choose_auto_plan(const std::string& file, const std::string& analysis,
6821 const std::string& method_token) {
6823 AutoPlan plan;
6824 if (!tok.is_intent) {
6825 plan.order.push_back(auto_cli_token_of_family(tok.family));
6826 if (tok.submethod != "default") plan.method = tok.submethod;
6827 return plan;
6828 }
6829
6830 const std::string getter = auto_getter_of_analysis(analysis);
6831 if (model_is_environment(file)) {
6834 plan.order.push_back("env");
6835 for (std::size_t i = 0; i < ec.skipped.size(); ++i)
6836 plan.note += std::string(i ? ", " : " (the ranking preferred ") +
6838 if (!ec.skipped.empty()) plan.note += ", which this port does not build)";
6839 return plan;
6840 }
6841
6842 line::qn::Network<T> net = read_model<T>(file);
6845 plan.method = c.method;
6846 const std::vector<line::autosolver::AutoSolver> proposed =
6848 for (std::size_t i = 0; i < proposed.size(); ++i)
6849 plan.order.push_back(line::autosolver::auto_solver_name(proposed[i]));
6850 for (std::size_t i = 0; i < c.skipped.size(); ++i)
6851 plan.note += std::string(i ? ", " : " (the ranking preferred ") +
6853 if (!c.skipped.empty()) plan.note += ", which this port does not build)";
6854 return plan;
6855}
6856
6857AutoPlan choose_auto_plan_dispatch(const std::string& arith, const std::string& file,
6858 const std::string& analysis, const std::string& method_token) {
6859 if (arith == "exact") return choose_auto_plan<line::Rational>(file, analysis, method_token);
6860 if (arith == "real:16") return choose_auto_plan<line::Real<16> >(file, analysis, method_token);
6861 if (arith == "real" || arith == "real:32")
6862 return choose_auto_plan<line::Real<32> >(file, analysis, method_token);
6863 if (arith == "real:64") return choose_auto_plan<line::Real<64> >(file, analysis, method_token);
6864 if (arith == "real:128")
6865 return choose_auto_plan<line::Real<128> >(file, analysis, method_token);
6866 if (arith == "real:256")
6867 return choose_auto_plan<line::Real<256> >(file, analysis, method_token);
6868 return choose_auto_plan<double>(file, analysis, method_token);
6869}
6870
6871/**
6872 * The numeric clean-up SolverLN.getAvgTable applies before printing.
6873 *
6874 * It is reproduced here because the reference's reported table IS the
6875 * sanitized one, so a row-by-row comparison against it has to compare like
6876 * with like. Two rules: snap a value to one decimal place when it is already
6877 * within CoarseTol of it relatively, and snap anything at or below FineTol to
6878 * zero. The second rule is what turns the residual queue length of a chain of
6879 * Immediate classes -- of order 1e-8 by construction, since Immediate has rate
6880 * 1e8 -- into the exact zero the reference prints.
6881 */
6882double ln_sanitize(double x) {
6883 const double r = std::round(x * 10.0);
6884 if (std::fabs(x * 10.0 - r) < line::lang::GlobalConstants::CoarseTol * x * 10.0) x = r / 10.0;
6885 if (x <= line::lang::GlobalConstants::FineTol) x = 0.0;
6886 return x;
6887}
6888
6889const char* ln_element_kind(const line::lqn::LqnStruct<double>& l, std::size_t i) {
6890 switch (l.type[i]) {
6891 case line::lang::LqnElement::HOST: return "Processor";
6892 case line::lang::LqnElement::TASK: return l.isref[i] ? "RefTask" : "Task";
6893 case line::lang::LqnElement::ENTRY: return "Entry";
6894 default: return "Activity";
6895 }
6896}
6897
6898/**
6899 * Print every layer's stations, classes and routing, in a form a MATLAB dump of
6900 * `solver.ensemble{k}` can be diffed against line by line.
6901 *
6902 * A layered result that is close but not equal across codebases is almost never
6903 * a difference in the MVA call; it is a layer that was BUILT differently -- a
6904 * class that is present in one and not the other, a population, a routing
6905 * probability. Comparing the final AvgTable cannot tell those apart, so the
6906 * structure has to be observable directly.
6907 */
6908template <class T>
6909void ln_dump_layers(const line::ln::SolverLN<T>& solver) {
6910 using namespace line;
6911 const std::vector<qn::Layer<T> >& ens = solver.layers();
6912 for (std::size_t k = 0; k < ens.size(); ++k) {
6913 const qn::Layer<T>& L = ens[k];
6914 std::printf("LAYER %zu %s nstations=%zu nclasses=%zu nchains=%zu\n", k + 1, L.name.c_str(),
6915 L.stations.size(), L.classes.size(), L.nchains);
6916 for (std::size_t i = 0; i < L.stations.size(); ++i)
6917 std::printf(" STATION %zu %s sched=%s nservers=%g\n", i + 1, L.stations[i].name.c_str(),
6918 lang::sched_to_text(L.stations[i].sched), L.stations[i].nservers);
6919 for (std::size_t r = 0; r < L.classes.size(); ++r)
6920 std::printf(" CLASS %zu %s pop=%.17g refstat=%zu completes=%d\n", r + 1,
6921 L.classes[r].name.c_str(), L.classes[r].population, L.classes[r].refstat,
6922 int(L.classes[r].completes));
6923 for (std::size_t i = 0; i < L.stations.size(); ++i)
6924 for (std::size_t r = 0; r < L.classes.size(); ++r) {
6925 if (L.disabled.empty() || L.disabled[i][r]) continue;
6926 std::printf(" RATE %s %s %.17g scv=%.17g\n", L.stations[i].name.c_str(),
6927 L.classes[r].name.c_str(), num_traits<T>::to_double(L.rates(i, r)),
6928 num_traits<T>::to_double(L.scv(i, r)));
6929 }
6930 for (const auto& kv : L.P) {
6931 const Matrix<T>& B = kv.second;
6932 for (std::size_t i = 0; i < B.rows(); ++i)
6933 for (std::size_t j = 0; j < B.cols(); ++j) {
6934 const double p = num_traits<T>::to_double(B(i, j));
6935 if (p == 0.0) continue;
6936 std::printf(" ROUTE %s->%s %s->%s %.17g\n",
6937 L.classes[kv.first.first - 1].name.c_str(),
6938 L.classes[kv.first.second - 1].name.c_str(),
6939 L.nodes[i].name.c_str(), L.nodes[j].name.c_str(), p);
6940 }
6941 }
6942 }
6943}
6944
6945/**
6946 * Solve a .lqnx layered queueing network with SolverLN and print its AvgTable.
6947 *
6948 * The output is one row per LQN element, with its queue length, utilization,
6949 * response time, residence time and throughput, in the same element order as
6950 * MATLAB's getAvgTable, so a row-by-row numeric comparison against the
6951 * reference is a plain diff.
6952 *
6953 * `--repeat` re-runs the whole solve K times and reports the best wall-clock
6954 * time, which is what the arithmetic-backend benchmark reads.
6955 */
6956/** The LnOptions a set of CLI knobs describes; arithmetic-independent. */
6957inline line::ln::LnOptions ln_options_from(const Knobs& k) {
6958 using namespace line;
6959 // The reference's own LnOptions defaults stand unless the caller overrode
6960 // them; the CLI does not restate them, so an untouched knob keeps whatever
6961 // SolverLN itself considers default.
6963 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
6964 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
6965 if (k.no_interlocking) opt.interlocking = false;
6966 if (!k.interlock_method.empty()) opt.interlock_method = k.interlock_method;
6967 if (k.interlock_maxpaths >= 0.0) opt.interlock_maxpaths = k.interlock_maxpaths;
6968 if (!k.interlock_refpath_scope.empty()) opt.interlock_refpath_scope = k.interlock_refpath_scope;
6969 if (!k.layer_solver.empty()) opt.layer_solver = k.layer_solver;
6970 if (!k.method.empty()) opt.method = k.method;
6971 if (k.samples) opt.layer_ssa.samples = k.samples;
6972 if (k.seed) opt.layer_ssa.seed = k.seed;
6973 if (k.t1 >= 0.0) opt.timespan_end = k.t1;
6974 if (k.tran_points) opt.tran_points = k.tran_points;
6975 if (!k.ln_transient.empty()) opt.ln_transient = k.ln_transient;
6976 if (!k.ln_transient_channels.empty()) opt.ln_transient_channels = k.ln_transient_channels;
6977 return opt;
6978}
6979
6980/** The engine name the banner reports, which is never a hardcoded MVA. */
6981inline const char* ln_layer_engine_name(const std::string& layer_solver) {
6982 if (layer_solver == "fluid") return "Fluid";
6983 if (layer_solver == "nc") return "NC";
6984 if (layer_solver == "ssa") return "SSA";
6985 return "MVA";
6986}
6987
6988/**
6989 * `-a tran`: the layered transient, one block of time series per layer.
6990 *
6991 * The blocks are printed rather than the block-diagonal cell array the
6992 * reference assembles, because the off-diagonal blocks of that array are empty
6993 * by construction and each layer keeps its own grid.
6994 */
6995template <class T>
6996int run_ln_tran(const std::string& file, const std::string& output, const Knobs& k) {
6997 using namespace line;
6998 const lqn::LqnStruct<T> model = io::read_layered_model<T>(file);
6999 ln::LnOptions opt = ln_options_from(k);
7000 ln::SolverLN<T> solver(model, opt);
7001 const ln::LnTranSolution tr = solver.get_tran_avg();
7002 const std::vector<qn::Layer<T>>& layers = solver.layers();
7003
7004 if (output == "json") {
7005 reg::Json j = reg::Json::object();
7006 j["model"] = file;
7007 j["arith"] = num_traits<T>::name();
7008 j["mode"] = tr.mode;
7009 j["iterations"] = tr.iterations;
7010 j["gap"] = ln_sanitize(tr.gap);
7011 reg::Json ls = reg::Json::array();
7012 for (std::size_t e = 0; e < tr.layers.size(); ++e) {
7013 reg::Json le = reg::Json::object();
7014 le["layer"] = layers[e].name;
7015 reg::Json tt = reg::Json::array();
7016 for (double t : tr.layers[e].t) tt.push_back(ln_sanitize(t));
7017 le["t"] = tt;
7018 reg::Json series = reg::Json::array();
7019 for (std::size_t i = 0; i < tr.layers[e].QN.size(); ++i)
7020 for (std::size_t r = 0; r < tr.layers[e].QN[i].size(); ++r) {
7021 reg::Json s = reg::Json::object();
7022 s["station"] = layers[e].stations[i].name;
7023 s["class"] = layers[e].classes[r].name;
7024 auto arr = [&](const std::vector<double>& v) {
7025 reg::Json a = reg::Json::array();
7026 for (double x : v) a.push_back(ln_sanitize(x));
7027 return a;
7028 };
7029 s["QLen"] = arr(tr.layers[e].QN[i][r]);
7030 s["Util"] = arr(tr.layers[e].UN[i][r]);
7031 s["Tput"] = arr(tr.layers[e].TN[i][r]);
7032 series.push_back(s);
7033 }
7034 le["series"] = series;
7035 ls.push_back(le);
7036 }
7037 j["layers"] = ls;
7038 emit_document(dump_document(j, 1));
7039 return 0;
7040 }
7041
7042 std::printf("SolverLN(Solver%s) getTranAvg arith=%s mode=%s layers=%zu iterations=%ld gap=%.3e\n",
7043 ln_layer_engine_name(opt.layer_solver), num_traits<T>::name(), tr.mode.c_str(),
7044 tr.layers.size(), tr.iterations, tr.gap);
7045 for (std::size_t e = 0; e < tr.layers.size(); ++e) {
7046 const std::vector<double>& t = tr.layers[e].t;
7047 if (t.empty()) continue;
7048 std::printf("\nLayer %s (%zu points on [%.6g, %.6g])\n", layers[e].name.c_str(), t.size(),
7049 t.front(), t.back());
7050 std::printf("%-30s %-24s %12s %12s %12s %12s\n", "Station", "JobClass", "QLen(0)",
7051 "QLen(end)", "Util(end)", "Tput(end)");
7052 for (std::size_t i = 0; i < tr.layers[e].QN.size(); ++i)
7053 for (std::size_t r = 0; r < tr.layers[e].QN[i].size(); ++r) {
7054 const std::vector<double>& q = tr.layers[e].QN[i][r];
7055 if (q.empty()) continue;
7056 std::printf("%-30s %-24s %12.6g %12.6g %12.6g %12.6g\n",
7057 layers[e].stations[i].name.c_str(), layers[e].classes[r].name.c_str(),
7058 ln_sanitize(q.front()), ln_sanitize(q.back()),
7059 ln_sanitize(tr.layers[e].UN[i][r].back()),
7060 ln_sanitize(tr.layers[e].TN[i][r].back()));
7061 }
7062 }
7063 return 0;
7064}
7065
7066/** `-a sens`: the layer sensitivity tables under a leading Layer column. */
7067template <class T>
7068int run_ln_sens(const std::string& file, const std::string& output, const Knobs& k) {
7069 using namespace line;
7070 const lqn::LqnStruct<T> model = io::read_layered_model<T>(file);
7071 ln::LnOptions opt = ln_options_from(k);
7072 ln::SolverLN<T> solver(model, opt);
7074 if (!k.sens_method.empty()) so.method = k.sens_method;
7075 if (!k.sens_scheme.empty()) so.scheme = k.sens_scheme;
7076 if (k.sens_step > 0.0) so.step = k.sens_step;
7077 const ln::LnSensTable<T> tbl = solver.get_sensitivity_table(so);
7078
7079 if (output == "json") {
7080 reg::Json j = reg::Json::object();
7081 j["model"] = file;
7082 j["arith"] = num_traits<T>::name();
7083 j["method"] = tbl.method;
7084 reg::Json rows = reg::Json::array();
7085 for (const auto& r : tbl.rows) {
7086 reg::Json o = reg::Json::object();
7087 o["Layer"] = r.layer;
7088 o["Station"] = r.station;
7089 o["JobClass"] = r.jobclass;
7090 o["dTput_dRate"] = sens_sanitize_signed(num_traits<T>::to_double(r.dTput));
7091 o["dRespT_dRate"] = sens_sanitize_signed(num_traits<T>::to_double(r.dRespT));
7092 o["dQLen_dRate"] = sens_sanitize_signed(num_traits<T>::to_double(r.dQLen));
7093 o["dUtil_dRate"] = sens_sanitize_signed(num_traits<T>::to_double(r.dUtil));
7094 rows.push_back(o);
7095 }
7096 j["rows"] = rows;
7097 emit_document(dump_document(j, 1));
7098 return 0;
7099 }
7100
7101 std::printf("SolverLN(Solver%s) getSensitivityTable arith=%s method=%s rows=%zu\n",
7102 ln_layer_engine_name(opt.layer_solver), num_traits<T>::name(), tbl.method.c_str(),
7103 tbl.rows.size());
7104 std::printf("%-28s %-28s %-20s %14s %14s %14s %14s\n", "Layer", "Station", "JobClass",
7105 "dTput_dRate", "dRespT_dRate", "dQLen_dRate", "dUtil_dRate");
7106 for (const auto& r : tbl.rows)
7107 std::printf("%-28s %-28s %-20s %14.6g %14.6g %14.6g %14.6g\n", r.layer.c_str(),
7108 r.station.c_str(), r.jobclass.c_str(),
7109 sens_sanitize_signed(num_traits<T>::to_double(r.dTput)),
7110 sens_sanitize_signed(num_traits<T>::to_double(r.dRespT)),
7111 sens_sanitize_signed(num_traits<T>::to_double(r.dQLen)),
7112 sens_sanitize_signed(num_traits<T>::to_double(r.dUtil)));
7113 return 0;
7114}
7115
7116/** `-a cdf`: the per-entry response-time distribution of the moment3 method. */
7117template <class T>
7118int run_ln_cdf(const std::string& file, const std::string& output, const Knobs& k) {
7119 using namespace line;
7120 const lqn::LqnStruct<T> model = io::read_layered_model<T>(file);
7121 ln::LnOptions opt = ln_options_from(k);
7122 // getCdfRespT.m runs the ensemble under moment3 whatever the caller asked
7123 // for, and restores the method afterwards: the mean-based update forms no
7124 // distribution at all, so there is nothing else to report.
7125 opt.method = "moment3";
7126 ln::SolverLN<T> solver(model, opt);
7127 const std::vector<ln::LnCdf> cdf = solver.get_cdf_respt();
7129
7130 if (output == "json") {
7131 reg::Json j = reg::Json::object();
7132 j["model"] = file;
7133 j["arith"] = num_traits<T>::name();
7134 reg::Json rows = reg::Json::array();
7135 for (std::size_t e = 1; e <= model.nentries && e < cdf.size(); ++e) {
7136 reg::Json o = reg::Json::object();
7137 o["entry"] = names.names[model.eshift + e];
7138 reg::Json tt = reg::Json::array(), ff = reg::Json::array();
7139 for (std::size_t p = 0; p < cdf[e].t.size(); ++p) {
7140 tt.push_back(ln_sanitize(cdf[e].t[p]));
7141 ff.push_back(ln_sanitize(cdf[e].cdf[p]));
7142 }
7143 o["t"] = tt;
7144 o["F"] = ff;
7145 rows.push_back(o);
7146 }
7147 j["entries"] = rows;
7148 emit_document(dump_document(j, 1));
7149 return 0;
7150 }
7151
7152 std::printf("SolverLN(Solver%s) getCdfRespT arith=%s method=moment3 entries=%zu\n",
7153 ln_layer_engine_name(opt.layer_solver), num_traits<T>::name(), model.nentries);
7154 for (std::size_t e = 1; e <= model.nentries && e < cdf.size(); ++e) {
7155 if (cdf[e].t.empty()) {
7156 std::printf("%-40s (no distribution: the entry has no fitted term)\n",
7157 names.names[model.eshift + e].c_str());
7158 continue;
7159 }
7160 // Quartiles, which is what a CDF is read for; the full grid goes to JSON.
7161 auto quantile = [&](double p) {
7162 for (std::size_t i = 0; i < cdf[e].cdf.size(); ++i)
7163 if (cdf[e].cdf[i] >= p) return cdf[e].t[i];
7164 return cdf[e].t.back();
7165 };
7166 std::printf("%-40s p25=%12.6g p50=%12.6g p75=%12.6g p95=%12.6g points=%zu\n",
7167 names.names[model.eshift + e].c_str(), ln_sanitize(quantile(0.25)),
7168 ln_sanitize(quantile(0.50)), ln_sanitize(quantile(0.75)),
7169 ln_sanitize(quantile(0.95)), cdf[e].t.size());
7170 }
7171 return 0;
7172}
7173
7174template <class T>
7175int run_ln(const std::string& file, const std::string& output, const Knobs& k) {
7176 using namespace line;
7177 const lqn::LqnStruct<T> model = io::read_layered_model<T>(file);
7178
7179 ln::LnOptions opt = ln_options_from(k);
7180 const int repeat = k.repeat > 0 ? k.repeat : 1;
7181
7182 double best = 1e300;
7184 std::size_t nlayers = 0;
7185 for (int rep = 0; rep < repeat; ++rep) {
7186 const auto t0 = std::chrono::steady_clock::now();
7187 ln::SolverLN<T> solver(model, opt);
7188 sol = solver.get_ensemble_avg();
7189 nlayers = solver.nlayers();
7190 if (output == "layers") {
7191 ln_dump_layers(solver);
7192 return 0;
7193 }
7194 const auto t1 = std::chrono::steady_clock::now();
7195 best = std::min(best, std::chrono::duration<double>(t1 - t0).count());
7196 }
7197
7198 // element names and kinds are arithmetic-independent, so read them once
7200
7201 if (output == "json") {
7202 reg::Json j = reg::Json::object();
7203 j["model"] = file;
7204 j["arith"] = num_traits<T>::name();
7205 j["layers"] = nlayers;
7206 j["iterations"] = sol.iterations;
7207 j["converged"] = sol.converged;
7208 j["seconds"] = best;
7209 reg::Json rows = reg::Json::array();
7210 for (std::size_t i = 1; i <= model.nidx; ++i) {
7211 reg::Json r = reg::Json::object();
7212 r["node"] = names.names[i];
7213 r["type"] = ln_element_kind(names, i);
7214 auto put = [&](const char* key, const std::vector<T>& v, const std::vector<bool>& d) {
7215 if (d[i]) r[key] = ln_sanitize(num_traits<T>::to_double(v[i]));
7216 else if (sol.is_bound) r[key] = 0.0; // see the note in the table below
7217 else r[key] = nullptr;
7218 };
7219 put("QLen", sol.QN, sol.defined_Q);
7220 put("Util", sol.UN, sol.defined_U);
7221 put("RespT", sol.RN, sol.defined_R);
7222 put("ResidT", sol.WN, sol.defined_W);
7223 put("Tput", sol.TN, sol.defined_T);
7224 rows.push_back(r);
7225 }
7226 j["rows"] = rows;
7227 emit_document(dump_document(j, 1));
7228 return 0;
7229 }
7230
7231 // The banner names the LAYER solver actually used, not a hardcoded MVA: the
7232 // two converge to different fixed points, so a reader (or a parity row) that
7233 // cannot tell them apart is reading numbers it cannot attribute.
7234 std::printf(
7235 "SolverLN(Solver%s) arith=%s type=%s layers=%zu iterations=%d converged=%d time=%.4fs\n",
7236 ln_layer_engine_name(opt.layer_solver), num_traits<T>::name(),
7237 line::util::method_type("LN", opt.method).c_str(), nlayers, sol.iterations,
7238 int(sol.converged), best);
7239 std::printf("%-62s %-10s %12s %12s %12s %12s %12s\n", "Node", "NodeType", "QLen", "Util",
7240 "RespT", "ResidT", "Tput");
7241 for (std::size_t i = 1; i <= model.nidx; ++i) {
7242 // NaN IS THE UNDEFINED MARKER EVERYWHERE EXCEPT UNDER A BOUND. MATLAB's
7243 // getAvgTable prints NaN for a measure the element does not have (a
7244 // processor has no queue length), and this reproduces that. A BOUND is
7245 // the one case where the reference prints 0 instead: `mw.*` defines
7246 // throughput and processor utilization only, and both MATLAB and the JAR
7247 // report the rest as zero rather than as absent (the JAR maps the NaN
7248 // explicitly, SolverLN.java:3294-3299). The `defined_*` flags still say
7249 // undefined to any caller of the API; only the printed table follows the
7250 // reference, so that a numeric parity row compares like with like.
7251 //
7252 // This table is for a human; a cross-codebase comparison must read the
7253 // `-o json` above, which carries the raw double, and quantize it itself.
7254 auto fmt = [&](const std::vector<T>& v, const std::vector<bool>& d, char* buf) {
7255 if (!d[i] && sol.is_bound) std::snprintf(buf, 24, "%12.6g", 0.0);
7256 else if (!d[i]) std::snprintf(buf, 24, "%12s", "NaN");
7257 else std::snprintf(buf, 24, "%12.6g", ln_sanitize(num_traits<T>::to_double(v[i])));
7258 };
7259 char q[24], u[24], rr[24], w[24], t[24];
7260 fmt(sol.QN, sol.defined_Q, q);
7261 fmt(sol.UN, sol.defined_U, u);
7262 fmt(sol.RN, sol.defined_R, rr);
7263 fmt(sol.WN, sol.defined_W, w);
7264 fmt(sol.TN, sol.defined_T, t);
7265 std::printf("%-62s %-10s %s %s %s %s %s\n", names.names[i].c_str(),
7266 ln_element_kind(names, i), q, u, rr, w, t);
7267 }
7268 return 0;
7269}
7270
7271/**
7272 * `-s ldes`: the layered model simulated directly, by the IN-PROCESS engine.
7273 *
7274 * IT IS NOT THE SUBPROCESS `-s ldes` OF THE FLAT PATH. That arm hands a
7275 * `model.json` to `common/ldes`, and the engine behind that wire refuses a
7276 * layered document outright ("LDES currently supports Network models only"), so
7277 * there is nothing to forward. This arm calls `ldes_ln_engine_solve` in process
7278 * -- the C++ twin of `Solver_ssj_ln.java` -- which simulates entries,
7279 * activities, task threads and synchronous calls directly instead of
7280 * decomposing the model into layers. So it is not a noisier route to `-s ln`:
7281 * SolverLN's decomposition is an APPROXIMATION and this is a sample path of the
7282 * model itself, which is what makes it the reference the layered solvers are
7283 * checked against.
7284 *
7285 * THE NaN MASK OF THE LAYERED TABLE IS PART OF THE ANSWER, and it belongs to
7286 * the table rather than to whichever solver filled it: a processor has no queue
7287 * length, a task no response time, an entry no residence, and `-s ln` and
7288 * `-s lqns` both print NaN there. This engine MEASURES more than that -- a
7289 * processor's completion rate is sitting in `LnResult::TLN` -- and printing it
7290 * under a column the other two arms leave empty would make one column mean
7291 * different things depending on who filled it. That is the divergence the JAR
7292 * removed from `getLNAvgTable` on 2026-08-21, and masking here rather than in
7293 * the engine keeps it removed on this side too. Nothing is discarded: the
7294 * unmasked measurements stay on `LnResult` for a programmatic caller, and only
7295 * the shared table is masked, so that it can be diffed row by row.
7296 *
7297 * THE NUMBERS ARE NOT `ln_sanitize`d, for the reason `run_lqns` states below:
7298 * that helper snaps to a tenth and floors at FineTol, which is right for a
7299 * fixed point this port computed and wrong for a measurement it made. A
7300 * simulated utilization of 1e-9 is a rare event that was observed, not a
7301 * negligible residue of an iteration, and the JAR's own layered LDES table
7302 * reports the raw estimate too -- so snapping here would put the two out of
7303 * step on exactly the models a parity row is read on.
7304 */
7305/**
7306 * `-i lqnx -s ldes -a cdf`: the SIMULATED response time distribution per entry.
7307 *
7308 * The measured counterpart of `run_ln_cdf`, which fits an APH to three moments of
7309 * a fluid passage time and convolves. Here the engine timed every invocation from
7310 * the instant the request reached the entry to its reply -- the interval `RLN`
7311 * averages -- so this law's mean reproduces that row and its tail is observed
7312 * rather than extrapolated.
7313 */
7314int run_ln_ldes_cdf(const std::string& file, const std::string& output, const Knobs& k) {
7315 using namespace line;
7318 if (k.samples) o.samples = k.samples;
7319 if (k.seed) o.seed = static_cast<long>(k.seed);
7320
7322
7323 // The per-entry ecdf, through the same API function the other codebases
7324 // call getCdfRespTLN.
7325 const std::vector<ldes::engine::LnEntryCdf> cdf =
7327 std::vector<std::vector<double> > tt(model.nentries), ff(model.nentries);
7328 for (std::size_t e = 0; e < model.nentries; ++e) {
7329 tt[e] = cdf[e].t;
7330 ff[e] = cdf[e].F;
7331 }
7332
7333 if (output == "json") {
7334 reg::Json j = reg::Json::object();
7335 j["model"] = file;
7336 j["engine"] = "native-ln";
7337 reg::Json rows = reg::Json::array();
7338 for (std::size_t e = 0; e < model.nentries; ++e) {
7339 reg::Json ob = reg::Json::object();
7340 ob["entry"] = model.names[model.eshift + e + 1];
7341 reg::Json at = reg::Json::array(), af = reg::Json::array();
7342 for (std::size_t i = 0; i < tt[e].size(); ++i) {
7343 at.push_back(tt[e][i]);
7344 af.push_back(ff[e][i]);
7345 }
7346 ob["t"] = at;
7347 ob["F"] = af;
7348 ob["observations"] = static_cast<double>(
7349 e < r.entry_resp_samples.size() ? r.entry_resp_samples[e].size() : 0);
7350 rows.push_back(ob);
7351 }
7352 j["entries"] = rows;
7353 emit_document(dump_document(j, 1));
7354 return 0;
7355 }
7356
7357 std::printf("SolverLDES(native LN engine) getCdfRespT entries=%zu\n", model.nentries);
7358 for (std::size_t e = 0; e < model.nentries; ++e) {
7359 if (tt[e].empty()) {
7360 std::printf("%-40s (no observation)\n", model.names[model.eshift + e + 1].c_str());
7361 continue;
7362 }
7363 // Quartiles plus p95, which is what a measured law is read for.
7364 struct Q {
7365 const std::vector<double>& t;
7366 const std::vector<double>& f;
7367 double operator()(double p) const {
7368 for (std::size_t i = 0; i < f.size(); ++i)
7369 if (f[i] >= p) return t[i];
7370 return t.back();
7371 }
7372 } q = {tt[e], ff[e]};
7373 std::printf("%-40s p25=%12.6g p50=%12.6g p75=%12.6g p95=%12.6g n=%zu\n",
7374 model.names[model.eshift + e + 1].c_str(), q(0.25), q(0.50), q(0.75),
7375 q(0.95), r.entry_resp_samples[e].size());
7376 }
7377 return 0;
7378}
7379
7380int run_ln_ldes(const std::string& file, const std::string& output, const Knobs& k) {
7381 using namespace line;
7383
7384 // The engine reads THREE settings and no more (`o.samples`, `o.events`,
7385 // `o.seed`); the dispatcher refuses the rest of the --ldes-* family rather
7386 // than letting this function drop them silently.
7388 if (k.samples) o.samples = k.samples;
7389 if (k.seed) o.seed = static_cast<long>(k.seed);
7390
7391 // --repeat is a TIMING loop and stays honest here only because the stream is
7392 // seeded: every replication of one command walks the same sample path, so
7393 // the table is that run's table and `time=` is the only thing that varies.
7394 // This is why -s lqns refuses the flag and this arm does not -- lqsim seeds
7395 // itself, so re-running it would report different numbers under one banner.
7396 const int repeat = k.repeat > 0 ? k.repeat : 1;
7397 double best = 1e300;
7399 for (int rep = 0; rep < repeat; ++rep) {
7400 const auto t0 = std::chrono::steady_clock::now();
7401 r = ldes::ldes_ln_engine_solve(model, o);
7402 const auto t1 = std::chrono::steady_clock::now();
7403 best = std::min(best, std::chrono::duration<double>(t1 - t0).count());
7404 }
7405
7406 // The mask lives beside the data it masks (`ldes::engine::ln_defined`), so
7407 // that the doctest suite can pin it against `LnSolution::defined_*` without
7408 // reaching into this file. Spelling it here instead would put the rule that
7409 // decides what the table MEANS inside a printer.
7410 typedef ldes::engine::LnColumn Col;
7411 const auto def = [&](std::size_t i, Col c) {
7412 return ldes::engine::ln_defined(model, r, i, c);
7413 };
7414
7415 if (output == "json") {
7416 reg::Json j = reg::Json::object();
7417 j["model"] = file;
7418 j["arith"] = "double";
7419 j["solver"] = "ldes";
7420 // WHICH ENGINE ANSWERED, on the same grounds the flat arm's `engine=` is
7421 // not decoration: `-s ldes` names two different simulators depending on
7422 // whether the model is layered, and a number quoted from one must not be
7423 // read as the other's.
7424 j["engine"] = "native-ln";
7425 j["samples"] = o.samples;
7426 j["seed"] = o.seed;
7427 j["simulatedTime"] = r.simulated_time;
7428 j["completions"] = r.completions;
7429 j["seconds"] = best;
7430 reg::Json rows = reg::Json::array();
7431 for (std::size_t i = 1; i <= model.nidx; ++i) {
7432 reg::Json row = reg::Json::object();
7433 row["node"] = model.names[i];
7434 row["type"] = ln_element_kind(model, i);
7435 auto put = [&](const char* key, double v, bool defined) {
7436 if (defined) row[key] = v;
7437 else row[key] = nullptr;
7438 };
7439 put("QLen", r.QLN(i, 0), def(i, Col::QLen));
7440 put("Util", r.ULN(i, 0), def(i, Col::Util));
7441 put("RespT", r.RLN(i, 0), def(i, Col::RespT));
7442 put("ResidT", r.WLN(i, 0), def(i, Col::ResidT));
7443 put("Tput", r.TLN(i, 0), def(i, Col::Tput));
7444 rows.push_back(row);
7445 }
7446 j["rows"] = rows;
7447 emit_document(dump_document(j, 1));
7448 return 0;
7449 }
7450
7451 std::printf("SolverLDES(native LN engine) arith=double type=%s samples=%zu seed=%ld "
7452 "simtime=%.6g completions=%lld time=%.4fs\n",
7453 line::util::method_type("LDES", o.method).c_str(), o.samples, o.seed,
7454 r.simulated_time, r.completions, best);
7455 std::printf("%-62s %-10s %12s %12s %12s %12s %12s\n", "Node", "NodeType", "QLen", "Util",
7456 "RespT", "ResidT", "Tput");
7457 for (std::size_t i = 1; i <= model.nidx; ++i) {
7458 // Six digits, as on the two tables around it, so the three arms diff.
7459 auto fmt = [&](double v, bool defined, char* buf) {
7460 if (!defined) std::snprintf(buf, 24, "%12s", "NaN");
7461 else std::snprintf(buf, 24, "%12.6g", v);
7462 };
7463 char q[24], u[24], rr[24], w[24], t[24];
7464 fmt(r.QLN(i, 0), def(i, Col::QLen), q);
7465 fmt(r.ULN(i, 0), def(i, Col::Util), u);
7466 fmt(r.RLN(i, 0), def(i, Col::RespT), rr);
7467 fmt(r.WLN(i, 0), def(i, Col::ResidT), w);
7468 fmt(r.TLN(i, 0), def(i, Col::Tput), t);
7469 std::printf("%-62s %-10s %s %s %s %s %s\n", model.names[i].c_str(),
7470 ln_element_kind(model, i), q, u, rr, w, t);
7471 }
7472 return 0;
7473}
7474
7475/**
7476 * `-s lqns`: the same layered model, solved by the external binary.
7477 *
7478 * The table has the SAME columns as run_ln's so that the two can be diffed row
7479 * by row, but the numbers are not sanitized the same way: `ln_sanitize` also
7480 * snaps anything at or below FineTol to zero, which is right for a fixed point
7481 * this port computed and wrong for a measurement it did not -- lqns reports no
7482 * residence time and no arrival rate at all, and a zero there would read as a
7483 * computed zero. Those two columns print NaN, and only the snap-to-tenth of the
7484 * reference's getAvgTable is applied.
7485 */
7486template <class T>
7487int run_lqns(const std::string& file, const std::string& output, const Knobs& k) {
7488 using namespace line;
7489 const lqn::LqnModel<T> model = lqn::read_lqnx_model<T>(file);
7490
7492 if (!k.method.empty()) opt.method = k.method;
7493 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
7494 if (k.samples) opt.samples = static_cast<double>(k.samples);
7495 opt.verbose = k.verbose;
7496 opt.keep = k.keep;
7497 opt.remote = k.remote;
7498 if (!k.remote_url.empty()) opt.remote_url = k.remote_url;
7499 opt.timeout_seconds = k.timeout_seconds;
7500
7501 lqns::SolverLQNS<T> solver(model, opt);
7502 const lqns::LqnsSolution<T> sol = solver.get_ensemble_avg();
7503 const lqn::LqnStruct<T>& sn = solver.get_struct();
7505
7506 if (output == "json") {
7507 reg::Json j = reg::Json::object();
7508 j["model"] = file;
7509 j["arith"] = num_traits<T>::name();
7510 j["solver"] = lqns::SolverLQNS<T>::is_stochastic_method(opt.method) ? "lqsim" : "lqns";
7511 j["method"] = opt.method;
7512 j["iterations"] = sol.iterations;
7513 j["seconds"] = solver.runtime();
7514 reg::Json rows = reg::Json::array();
7515 for (std::size_t i = 1; i <= sn.nidx; ++i) {
7516 reg::Json r = reg::Json::object();
7517 r["node"] = names.names[i];
7518 r["type"] = ln_element_kind(names, i);
7519 auto put = [&](const char* key, const std::vector<T>& v, const std::vector<bool>& d) {
7520 if (d[i]) r[key] = lqns::detail::snap_to_tenth(num_traits<T>::to_double(v[i]));
7521 else r[key] = nullptr;
7522 };
7523 put("QLen", sol.QN, sol.defined_Q);
7524 put("Util", sol.UN, sol.defined_U);
7525 put("RespT", sol.RN, sol.defined_R);
7526 put("ResidT", sol.WN, sol.defined_W);
7527 put("Tput", sol.TN, sol.defined_T);
7528 rows.push_back(r);
7529 }
7530 j["rows"] = rows;
7531 emit_document(dump_document(j, 1));
7532 return 0;
7533 }
7534
7535 std::printf("SolverLQNS(%s) arith=%s type=%s iterations=%d time=%.4fs\n",
7537 line::util::method_type("LQNS", opt.method).c_str(), sol.iterations,
7538 solver.runtime());
7539 std::printf("%-62s %-10s %12s %12s %12s %12s %12s\n", "Node", "NodeType", "QLen", "Util",
7540 "RespT", "ResidT", "Tput");
7541 for (std::size_t i = 1; i <= sn.nidx; ++i) {
7542 // Six digits, as on the SolverLN table above and for the same reason.
7543 auto fmt = [&](const std::vector<T>& v, const std::vector<bool>& d, char* buf) {
7544 if (!d[i]) std::snprintf(buf, 24, "%12s", "NaN");
7545 else
7546 std::snprintf(buf, 24, "%12.6g",
7547 lqns::detail::snap_to_tenth(num_traits<T>::to_double(v[i])));
7548 };
7549 char q[24], u[24], rr[24], w[24], t[24];
7550 fmt(sol.QN, sol.defined_Q, q);
7551 fmt(sol.UN, sol.defined_U, u);
7552 fmt(sol.RN, sol.defined_R, rr);
7553 fmt(sol.WN, sol.defined_W, w);
7554 fmt(sol.TN, sol.defined_T, t);
7555 std::printf("%-62s %-10s %s %s %s %s %s\n", names.names[i].c_str(),
7556 ln_element_kind(names, i), q, u, rr, w, t);
7557 }
7558 return 0;
7559}
7560
7561/**
7562 * `-i lqnx|xml`: the layered path, sibling to solve_model_dispatch.
7563 *
7564 * `-s auto` CONSULTS THE LAYERED ARM of the chooser, which is keyed on the
7565 * metric and on one trait of the model: a cache task promotes the NC layer
7566 * engine, because the cache layer is where NC beats MVA. LQNS leads two of
7567 * those rankings and IS wrapped, so `-s auto` selects it wherever the binary is
7568 * installed -- which makes the choice machine-dependent, exactly as
7569 * `chooseAvgSolverHeur.m` makes it, since LINE ships no LQNS binary. The banner
7570 * always names what answered.
7571 *
7572 * THE TOKENS NAME THE LAYER ENGINE, as the Java CLI's do: `ln.mva` runs the
7573 * layers under SolverMVA and `ln.comom` under SolverNC. The bare `ln` keeps the
7574 * MVA layers this port has always given it -- the Java CLI reads it as NC, but
7575 * changing it here would re-baseline every existing layered result under a token
7576 * whose meaning nothing in this tree states -- so `ln.comom` is the way to ask
7577 * for NC layers. `lqns` is not one of them: it does not solve LAYERS at all, it
7578 * hands the whole model to another program, so it takes the wrapper's own knobs
7579 * and none of SolverLN's.
7580 */
7581/** The solver method name as the console names it, e.g. "mva" -> "MVA". */
7582std::string upper_tag(const std::string& s) {
7583 std::string out = s;
7584 for (std::size_t i = 0; i < out.size(); ++i)
7585 out[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(out[i])));
7586 return out;
7587}
7588
7589int solve_lqn_dispatch(const std::string& arith, const std::string& solver,
7590 const std::string& analysis, const std::string& output,
7591 const std::string& file, const Knobs& k) {
7592 if (file.empty())
7593 throw line::InputError(
7594 "a layered model is read from a file: pass -f <model.lqnx> or -f <model.json> (neither "
7595 "layered reader has a stdin form)");
7596 if (analysis != "avg" && analysis != "tran" && analysis != "sens" && analysis != "cdf")
7598 "the layered path ports -a avg (getAvgTable), -a tran (getTranAvg), -a sens "
7599 "(getSensitivityTable) and -a cdf (getCdfRespT); got '" + analysis + "'");
7600 const std::string s = solver.empty() ? "auto" : solver;
7601 if (s != "auto" && s != "ln" && s != "ln.mva" && s != "ln.comom" && s != "lqns" &&
7602 s != "ldes")
7604 "the layered path takes -s ln, ln.mva, ln.comom, ldes, lqns or auto (got '" + s +
7605 "'); a Network solver cannot be applied to a LayeredNetwork directly");
7606
7607 // Solver console: the layered path has its own dispatcher and never
7608 // reaches solve_model_dispatch, so it opens the narrated run here. The
7609 // guard's destructor closes it, on an exception too.
7610 line::util::LineConsole::Run consoleRun(upper_tag(s), "", true);
7611
7612 Knobs kk = k;
7613 // What will actually answer: the token, or what `-s auto` resolves to.
7614 std::string engine = s;
7615 // `-s auto` with no explicit layer engine consults chooseLayeredSolver. An
7616 // explicit --layer-solver is a choice the caller already made, so the
7617 // chooser does not overrule it.
7618 if (s == "auto" && kk.layer_solver.empty()) {
7619 std::string getter = "getAvgTable";
7620 if (analysis == "tran") getter = "getTranAvg";
7621 else if (analysis == "cdf") getter = "getCdfRespT";
7622 else if (analysis == "sens") getter = "getSensitivityTable";
7623 // `iscache` is the one trait the ranking reads, and it costs one parse
7624 // of the .lqnx: the same document the solve parses again below.
7626 bool has_cache_task = false;
7627 for (std::size_t i = 0; i < probe.iscache.size(); ++i)
7628 if (probe.iscache[i]) has_cache_task = true;
7630 line::autosolver::auto_choose_layered_solver(getter, has_cache_task);
7631 const std::string token = line::autosolver::auto_layered_name(lc.solver);
7632 if (token == "lqns") engine = "lqns";
7633 else if (token == "ln.comom") kk.layer_solver = "nc";
7634 else if (token == "ln.fld") kk.layer_solver = "fluid";
7635 else kk.layer_solver = "mva";
7636 std::string note;
7637 for (std::size_t i = 0; i < lc.skipped.size(); ++i)
7638 note += std::string(i ? ", " : " (the ranking preferred ") +
7640 if (!lc.skipped.empty()) note += ", which is not available here)";
7641 std::printf("SolverAUTO selected %s%s\n", token.c_str(), note.c_str());
7642 }
7643
7644 // ---- the wrapper, BEFORE the SolverLN knob ladder ---------------------
7645 // It is a wrapper, not a layer engine: --samples is the lqsim run length
7646 // rather than a simulated LAYER's, and --keep, --remote and --timeout
7647 // describe a child process no SolverLN run has. Asking the ladder below
7648 // about them would answer for the wrong solver.
7649 if (engine == "lqns") {
7650 if (analysis != "avg")
7652 "SolverLQNS reports the mean table its binary computes; it has no transient, no "
7653 "sensitivity and no response-time distribution here, so it takes -a avg (got '" +
7654 analysis + "')");
7655 if (!kk.layer_solver.empty())
7656 throw line::InputError(
7657 "--layer-solver names the engine SolverLN runs on each layer; -s lqns solves no "
7658 "layers, it hands the whole model to the lqns binary");
7659 if (output == "layers")
7661 "-o layers dumps the stations and routing SolverLN BUILT from the model; lqns "
7662 "builds its own submodels inside another process and this port never sees them");
7663 if (kk.iter_tol >= 0.0 || kk.iter_max > 0)
7665 "--iter_tol and --iter_max are SolverLN's layer-iteration knobs; lqns runs its own "
7666 "iteration and takes neither (its --iteration-limit is unreliable as of 6.2.27, "
7667 "which is why the reference stopped passing it)");
7668 if (kk.seed)
7670 "--seed sets the stream of a simulator this port drives; lqsim seeds itself and "
7671 "the wrapper passes no seed, exactly as the reference does not");
7672 if (kk.repeat > 1)
7674 "--repeat times a solve by re-running it; re-running lqsim would report a "
7675 "different answer under the same banner");
7676 if (kk.no_interlocking || kk.interlock_knobs_given() || !kk.ln_transient.empty() || !kk.ln_transient_channels.empty() ||
7677 !kk.sens_method.empty() || !kk.sens_scheme.empty() || kk.sens_step > 0.0)
7679 "--no-interlocking, --interlock-*, --ln-transient*, and --sens-* are SolverLN options; -s lqns "
7680 "has none of them");
7681 if (arith != "double")
7683 "SolverLQNS reads a result file another program wrote in decimal double "
7684 "precision; there is no higher precision to carry, so rerun with --arith double "
7685 "(got '" + arith + "')");
7686 // The Network-only knobs the SolverLN ladder below refuses are refused
7687 // HERE TOO. This branch returns before that ladder runs, so a knob left
7688 // out of it is silently DROPPED rather than refused -- and which branch
7689 // a bare `.lqnx` path takes depends on whether an lqns binary is
7690 // installed, so the same command line would be refused on one machine
7691 // and quietly ignored on another.
7692 if (kk.has_cutoff())
7694 "--cutoff bounds the open population of a CTMC state space and applies to -s "
7695 "ctmc; the layered path enumerates no states");
7696 if (kk.node)
7698 "--node selects the stateful node a CTMC query is labelled by; the layered path "
7699 "reports every LQN element");
7700 return run_lqns<double>(file, output, kk);
7701 }
7702 if (k.keep || k.verbose || k.remote || !k.remote_url.empty() || k.timeout_seconds)
7704 "--keep, --verbose, --remote, --remote-url and --timeout describe the child process "
7705 "of an external solver and apply to -s lqns only");
7706 // ---- the native LN simulator, BEFORE the SolverLN knob ladder ----------
7707 // It solves no LAYERS, so the ladder below asks its questions of a
7708 // decomposition this arm never builds: --iter_tol and --iter_max bound a
7709 // fixed point it does not iterate, --layer-solver names an engine it does
7710 // not run, and --samples and --seed -- which the ladder refuses outright
7711 // unless a layer is simulated -- are precisely this arm's two settings.
7712 if (engine == "ldes") {
7713 if (analysis != "avg" && analysis != "cdf")
7715 "the native LN engine measures a sample path: it takes -a avg for the mean table "
7716 "and -a cdf for the per-entry response time distribution, and has no transient "
7717 "and no sensitivity here (got '" + analysis + "')");
7718 if (arith != "double")
7720 "the native LN engine accumulates its estimators in double, so there is no higher "
7721 "precision to carry; rerun with --arith double (got '" + arith + "')");
7722 if (output == "layers")
7724 "-o layers dumps the stations and routing SolverLN BUILT from the model; -s ldes "
7725 "simulates the layered semantics directly and builds no submodels");
7726 if (!kk.layer_solver.empty())
7727 throw line::InputError(
7728 "--layer-solver names the engine SolverLN runs on each layer; -s ldes solves no "
7729 "layers, it simulates entries, activities and calls directly");
7730 if (kk.iter_tol >= 0.0 || kk.iter_max > 0 || kk.no_interlocking || kk.interlock_knobs_given())
7732 "--iter_tol, --iter_max, --no-interlocking and --interlock-* are SolverLN's layer-iteration "
7733 "knobs; a simulated sample path converges by run length, which is --samples");
7734 if (!kk.ln_transient.empty() || !kk.ln_transient_channels.empty() ||
7735 !kk.sens_method.empty() || !kk.sens_scheme.empty() || kk.sens_step > 0.0)
7737 "--ln-transient* and --sens-* are SolverLN options; -s ldes has none of them");
7738 if (!kk.method.empty() && kk.method != "default")
7740 "--method on the layered path names the LN UPDATE (default, moment3, mw.*); "
7741 "-s ldes performs no update, it simulates the model (got '" + kk.method + "')");
7742 // The --ldes-* family is the SUBPROCESS engine's settings. The native LN
7743 // engine reads samples, events and seed and nothing else, so a warmup
7744 // filter or a CI estimator passed here would be DROPPED rather than
7745 // honoured -- and a dropped `--ldes-tranfilter none` reads as a run with
7746 // no warmup removal that in fact removed one.
7747 if (!kk.ldes_tranfilter.empty() || kk.ldes_warmupfrac >= 0.0 ||
7748 !kk.ldes_cimethod.empty() || kk.ldes_cnvgon || kk.ldes_cnvgtol > 0.0 ||
7749 kk.ldes_slotted || kk.ldes_slotlength > 0.0 || kk.ldes_replications > 0 ||
7750 kk.ldes_numthreads > 0 || kk.ldes_maxtime > 0.0 || !kk.ldes_initsol.empty() ||
7751 !kk.ldes_rest_url.empty())
7753 "the --ldes-* flags configure the SUBPROCESS engine that answers -s ldes on a "
7754 "Network (warmup filter, CI estimator, slot lattice, replications, warm-start "
7755 "placement); the native LN engine behind -i lqnx -s ldes reads --samples and "
7756 "--seed only");
7757 // The Network-only knobs the SolverLN ladder refuses below are refused
7758 // HERE TOO: this branch returns before that ladder runs, so a knob left
7759 // out of it would be silently dropped rather than refused.
7760 if (kk.has_cutoff())
7762 "--cutoff bounds the open population of a CTMC state space and applies to -s "
7763 "ctmc; the layered path enumerates no states");
7764 if (kk.node)
7766 "--node selects the stateful node a CTMC query is labelled by; the layered path "
7767 "reports every LQN element");
7768 if (kk.t1 >= 0.0)
7770 "--tspan sets the horizon of a transient analysis; the native LN engine runs to "
7771 "a completion budget, which is --samples");
7772 if (kk.tol >= 0.0)
7774 "--tol is not an LDES option; the run length is set with --samples");
7775 if (analysis == "cdf") return run_ln_ldes_cdf(file, output, kk);
7776 return run_ln_ldes(file, output, kk);
7777 }
7778 // The solver method name and --layer-solver name the same choice, so they may not
7779 // disagree: silently letting one win would report the other in the banner.
7780 if (s == "ln.comom") {
7781 if (!kk.layer_solver.empty() && kk.layer_solver != "nc")
7782 throw line::InputError("-s ln.comom already selects NC layers, but --layer-solver says '" +
7783 kk.layer_solver + "'");
7784 kk.layer_solver = "nc";
7785 } else if (s == "ln.mva") {
7786 if (!kk.layer_solver.empty() && kk.layer_solver != "mva")
7787 throw line::InputError("-s ln.mva already selects MVA layers, but --layer-solver says '" +
7788 kk.layer_solver + "'");
7789 kk.layer_solver = "mva";
7790 }
7791 // --layer-solver is this port's own flag, the C++ spelling of the
7792 // reference's solver FACTORY: `LN(model, @(m) MVA(m))` against
7793 // `LN(model, @(m) Fluid(m))`. They converge to DIFFERENT fixed points.
7794 if (!kk.layer_solver.empty() && kk.layer_solver != "mva" && kk.layer_solver != "nc" &&
7795 kk.layer_solver != "fluid" && kk.layer_solver != "ssa")
7796 throw line::InputError("--layer-solver takes mva, nc, fluid or ssa (got '" +
7797 kk.layer_solver + "')");
7798 // ---- knobs the layered path does not have are refused, not dropped ----
7799 if ((k.samples || k.seed) && kk.layer_solver != "ssa")
7801 "--samples and --seed set the run length and the stream of a SIMULATED layer; the "
7802 "layered path draws no random numbers unless --layer-solver ssa is in force");
7803 if (k.has_cutoff())
7805 "--cutoff bounds the open population of a CTMC state space and applies to -s ctmc; the "
7806 "layered path enumerates no states");
7807 if (k.t1 >= 0.0 && analysis != "tran")
7809 "--tspan sets the horizon of a transient analysis and applies to the layered path "
7810 "only with -a tran");
7811 if (analysis == "tran" && !(k.t1 >= 0.0))
7812 throw line::InputError(
7813 "-a tran integrates each layer's drift and needs a horizon: pass --tspan <t0>:<t1>");
7814 if (k.node)
7816 "--node selects the stateful node a CTMC query is labelled by; the layered path "
7817 "reports every LQN element");
7818 if (k.tol >= 0.0)
7820 "--tol is not a SolverLN option (LnOptions carries iter_tol and iter_max); "
7821 "use --iter_tol");
7822 // --method now names the LN UPDATE, which is a different question from the
7823 // layer engine: `moment3` reports a distribution the default never forms,
7824 // and the two bound requests report a bound instead of a fixed point.
7825 if (!k.method.empty() && k.method != "default" && k.method != "moment3" &&
7826 k.method != "mw.upper" && k.method != "mw.lower")
7828 "--method on the layered path takes default, moment3, mw.upper or mw.lower "
7829 "(got '" + k.method + "'); the per-layer engine is chosen with --layer-solver");
7830 if ((k.method == "mw.upper" || k.method == "mw.lower") && analysis != "avg")
7832 "--method mw.* reports a throughput and utilization BOUND and solves no layer, so "
7833 "it has no transient, no sensitivity and no response-time law; use -a avg");
7834 if (!k.sens_method.empty() && analysis != "sens")
7835 throw line::UnsupportedError("--sens-method applies to -a sens");
7836 if (!k.ln_transient.empty() && analysis != "tran")
7837 throw line::UnsupportedError("--ln-transient applies to -a tran");
7838
7839 if (analysis == "tran") {
7840 if (arith != "double")
7842 "the layered transient integrates each layer's drift with LSODA, which is double "
7843 "precision by construction; rerun with --arith double (got '" + arith + "')");
7844 return run_ln_tran<double>(file, output, kk);
7845 }
7846 if (analysis == "cdf") {
7847 if (arith != "double")
7849 "-a cdf fits an APH to a fluid passage time, integrated by LSODA in double "
7850 "precision; rerun with --arith double (got '" + arith + "')");
7851 return run_ln_cdf<double>(file, output, kk);
7852 }
7853 if (analysis == "sens") {
7854 if (arith == "double") return run_ln_sens<double>(file, output, kk);
7855 if (arith == "exact") return run_ln_sens<line::Rational>(file, output, kk);
7856 if (arith == "real:16") return run_ln_sens<line::Real<16> >(file, output, kk);
7857 if (arith == "real" || arith == "real:32")
7858 return run_ln_sens<line::Real<32> >(file, output, kk);
7859 if (arith == "real:64") return run_ln_sens<line::Real<64> >(file, output, kk);
7860 if (arith == "real:128") return run_ln_sens<line::Real<128> >(file, output, kk);
7861 if (arith == "real:256") return run_ln_sens<line::Real<256> >(file, output, kk);
7862 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
7863 }
7864
7865 if (arith == "double") return run_ln<double>(file, output, kk);
7866 if (arith == "exact") return run_ln<line::Rational>(file, output, kk);
7867 // precision-ladder rationale (real:16 rung): see _kb/14-cpp-multiprecision.md
7868 if (arith == "real:16") return run_ln<line::Real<16> >(file, output, kk);
7869 if (arith == "real" || arith == "real:32") return run_ln<line::Real<32> >(file, output, kk);
7870 if (arith == "real:64") return run_ln<line::Real<64> >(file, output, kk);
7871 if (arith == "real:128") return run_ln<line::Real<128> >(file, output, kk);
7872 if (arith == "real:256") return run_ln<line::Real<256> >(file, output, kk);
7873 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
7874}
7875
7876/**
7877 * `-a node`: `@@NetworkSolver/getAvgNodeTable`, the means per NODE.
7878 *
7879 * A DIFFERENT INDEX SPACE FROM `-a avg`, not a relabelling of it. The AvgTable
7880 * is indexed by STATION, so a ClassSwitch, a Router, a Fork, a Join and a Sink
7881 * are absent from it entirely -- they hold no jobs, so they have no row -- yet
7882 * jobs flow through them and the flow is what a caller sizing a link or an
7883 * interconnect needs. This table has one row per node and reports the arrival
7884 * rate and the throughput at every one of them, which is the only place those
7885 * two numbers exist for a non-station node.
7886 *
7887 * QLen, Util, RespT and ResidT ARE the station numbers, scattered to the node
7888 * indices and left at zero elsewhere; that is the reference's own construction
7889 * and not a gap, because a node that is not a station holds no jobs and serves
7890 * nothing. ArvR and Tput are the two the reference recomputes, through
7891 * `sn_get_node_arvr_from_tput` and `sn_get_node_tput_from_tput`.
7892 *
7893 * THE F REGION PSEUDO-NODE ROWS OF THE REFERENCE ARE NOT EMITTED. MATLAB
7894 * appends one row per finite-capacity region, filled from `result.Avg` rows
7895 * M+1..M+F; the C++ `AvgResult` carries no per-region queue length or
7896 * utilization, so those rows have no data source here and are omitted rather
7897 * than fabricated as zeros, which would read as an empty region.
7898 */
7899/* `avg_result_from_sim` MOVED to line/solvers/solver_node_tables.h, where the
7900 * example corpus can reach it too: four `cache_replc_*` twins print the NODE
7901 * table their references print, and it is the same view of the same numbers.
7902 * Named unqualified below, as it was when it was defined here. */
7904
7905/**
7906 * The LDES result document mapped onto the station AvgResult, BY NAME.
7907 *
7908 * The engine reports its own station and class order, and pairing the two off
7909 * positionally would put one station's numbers on another's row; `-a avg`
7910 * already matches by name for exactly that reason and this is the same match.
7911 * `WN` is recomputed rather than taken, for the reason the `-a avg` arm states:
7912 * the engine counts one visit per station, so its residence time is the response
7913 * time whenever a visit ratio is not 1.
7914 *
7915 * REGION ROWS RIDE PAST THE STATIONS, in the (M+F) layout `jmt_map_measures`
7916 * already uses, so a caller reads both engines' finite-capacity rows the same
7917 * way.
7918 */
7919inline line::mva::AvgResult<double> avg_result_from_ldes(
7921 const std::size_t M = sn.nstations, K = sn.nclasses;
7922 const std::size_t F = a.QNfcr.empty() ? 0 : a.QNfcr.rows();
7924 line::Matrix<double>* dst[6] = {&r.QN, &r.UN, &r.RN, &r.TN, &r.AN, &r.WN};
7925 const line::Matrix<double>* src[6] = {&a.QN, &a.UN, &a.RN, &a.TN, &a.AN, &a.WN};
7926 const line::Matrix<double>* fcr[6] = {&a.QNfcr, &a.UNfcr, &a.RNfcr,
7927 &a.TNfcr, &a.ANfcr, &a.WNfcr};
7928 std::vector<std::size_t> st_of(a.station_names.size(), 0); // 1-based, 0 = unmatched
7929 for (std::size_t i = 0; i < a.station_names.size(); ++i)
7930 for (std::size_t j = 0; j < M; ++j)
7931 if (sn.stations[j].name == a.station_names[i]) { st_of[i] = j + 1; break; }
7932 std::vector<std::size_t> cl_of(a.class_names.size(), 0);
7933 for (std::size_t c = 0; c < a.class_names.size(); ++c)
7934 for (std::size_t j = 0; j < K; ++j)
7935 if (sn.classes[j].name == a.class_names[c]) { cl_of[c] = j + 1; break; }
7936 for (int m = 0; m < 6; ++m) {
7937 *dst[m] = line::Matrix<double>(M + F, K, 0.0);
7938 for (std::size_t i = 0; i < a.station_names.size(); ++i)
7939 for (std::size_t c = 0; c < a.class_names.size(); ++c)
7940 if (st_of[i] && cl_of[c])
7941 (*dst[m])(st_of[i] - 1, cl_of[c] - 1) = ldes_at(*src[m], i, c);
7942 for (std::size_t f = 0; f < F; ++f)
7943 for (std::size_t c = 0; c < a.class_names.size(); ++c)
7944 if (cl_of[c]) (*dst[m])(M + f, cl_of[c] - 1) = ldes_at(*fcr[m], f, c);
7945 }
7946 line::Matrix<double> RNs(M, K, 0.0);
7947 for (std::size_t i = 0; i < M; ++i)
7948 for (std::size_t c = 0; c < K; ++c) RNs(i, c) = r.RN(i, c);
7950 for (std::size_t i = 0; i < M; ++i)
7951 for (std::size_t c = 0; c < K; ++c) r.WN(i, c) = WNs(i, c);
7952 for (std::size_t c = 0; c < K; ++c) {
7953 r.CN.push_back(ldes_at(a.CN, 0, c));
7954 r.XN.push_back(ldes_at(a.XN, 0, c));
7955 }
7956 r.method = a.method;
7957 r.actualmethod = a.method;
7958 return r;
7959}
7960
7961/**
7962 * Solve for the station AvgResult with the named engine.
7963 *
7964 * SHARED BY EVERY @@NetworkSolver TABLE THAT IS NOT THE AvgTable -- `-a node`,
7965 * `-a sys`, `-a chain`, `-a nodechain` -- because each of them is a VIEW of the
7966 * same solved result and differs only in how it is indexed and aggregated.
7967 * Giving each arm its own engine ladder would let the five drift apart in which
7968 * knobs they honour, which is exactly the silent divergence the Knobs struct
7969 * exists to prevent one layer up.
7970 *
7971 * SSA and Fluid reach it through `avg_result_from_sim`, and only under
7972 * `--arith double`: both integrate transcendental quantities (exponential
7973 * clocks, an LSODA drift), so the dispatcher refuses the other backends by name
7974 * before the ladder is entered, exactly as their own `-a avg` arms do.
7975 *
7976 * THE TWO WRAPPERS ARE HERE FOR THE SAME REASON THE TWO SIMULATORS ARE.
7977 * `getAvgNodeTable` is @@NetworkSolver's and is a VIEW of whatever AvgResult a
7978 * solver returned; JMT and LDES both return one (`JmtResult::avg`, the LDES
7979 * result document), so refusing them the four views said "no C++ simulation
7980 * engine" about engines this port drives. `file` is what LDES needs and the
7981 * others ignore: it hands the model DOCUMENT to an external process rather
7982 * than reading the struct.
7983 */
7984template <class T>
7985line::mva::AvgResult<T> run_avg_engine(const line::qn::NetworkStruct<T>& sn, const Knobs& k,
7986 const std::string& s, std::string& banner,
7987 std::string* suffix = nullptr,
7988 const std::string* file = nullptr) {
7990 if (s == "jmt" || s == "ldes") {
7991 // COMPILE-TIME, as for the two simulators: both wrappers report in
7992 // double and the dispatcher has already refused every other arithmetic
7993 // by name, so the discarded branch is unreachable rather than narrowed.
7994 if constexpr (std::is_same_v<T, double>) {
7995 if (s == "jmt") {
7997 if (!k.method.empty() && k.method != "default") o.method = k.method;
7998 if (k.samples > 0) o.samples = static_cast<double>(k.samples);
7999 if (k.seed != 0) o.seed = static_cast<long>(k.seed);
8000 o.keep = k.keep;
8001 if (k.t1 >= 0.0) o.max_simulated_time = k.t1;
8002 o.verbose = k.verbose;
8004 r = a.avg;
8005 banner = "SolverJMT";
8006 // THE SEED IS PART OF THE ANSWER, as it is for SSA: two runs of
8007 // a simulation are the same measurement only if both state it.
8008 if (suffix) {
8009 char buf[128];
8010 std::snprintf(buf, sizeof(buf), " samples=%g seed=%ld", o.samples, o.seed);
8011 *suffix = buf;
8012 }
8013 } else {
8014 const line::ldes::LdesOptions o = ldes_options(k);
8015 const line::ldes::LdesResult a =
8016 ldes_run(file ? *file : std::string(), o, std::vector<std::string>());
8017 r = avg_result_from_ldes(sn, a);
8018 banner = "SolverLDES";
8019 if (suffix) {
8020 char buf[160];
8021 std::snprintf(buf, sizeof(buf), " engine=%s samples=%zu seed=%ld",
8022 a.engine.empty() ? "?" : a.engine.c_str(), o.samples, o.seed);
8023 *suffix = buf;
8024 }
8025 }
8026 } else {
8027 throw line::UnsupportedError("-s " + s + " runs under --arith double only");
8028 }
8029 } else if (s == "ssa" || s == "fluid") {
8030 // COMPILE-TIME, not just run-time: both runners static_assert on
8031 // transcendental arithmetic inside (an exponential clock, a square root
8032 // in the Cox refit), so instantiating them at Rational is a hard error
8033 // and not a refusal. The dispatcher has already rejected every arith
8034 // but double by name, so the discarded branch is unreachable rather
8035 // than silently narrowed.
8036 if constexpr (std::is_same_v<T, double>) {
8037 if (s == "ssa") {
8039 if (!k.method.empty() && k.method != "default") opt.method = k.method;
8040 if (k.samples) opt.samples = k.samples;
8041 if (k.seed) opt.seed = k.seed;
8042 // The cache write-back rides beside the metric table, for the
8043 // reason `node_metrics` states: the realized hit and miss shares
8044 // are what the simulation MEASURED, and without them the node
8045 // table falls back to the split `link()` offered.
8046 std::vector<line::ssa::SsaCacheRatio> cache;
8048 // ResidT and ArvR are derived from the VISITS, and a cache's
8049 // split is routing, so both are taken on the struct carrying the
8050 // measured hit/miss shares rather than on `link()`'s even offer.
8051 r = avg_result_from_sim<T>(line::ssa::sn_with_ssa_cache_split<T>(sn, cache),
8052 a.QN, a.UN, a.RN, a.TN, a.CN, a.XN, a.method);
8054 // THE SEED AND THE SAMPLE COUNT ARE PART OF THE ANSWER, not of
8055 // the invocation, so they ride in the banner here as they do in
8056 // `-a avg`: two runs of a simulation are the same measurement
8057 // only if both are stated, and a parity row that quotes one of
8058 // these tables has to carry them with it.
8059 banner = "SolverSSA";
8060 // APPENDED, NOT PREFIXED. Every consumer recognises a banner by
8061 // `Solver<name> arith=`, so a fact wedged between the two makes
8062 // the table belong to no solver at all -- which is how the
8063 // parity harness lost the whole SSA section.
8064 if (suffix) {
8065 char buf[128];
8066 std::snprintf(buf, sizeof(buf), " samples=%zu seed=%lu time=%.6g", a.samples,
8067 static_cast<unsigned long>(opt.seed), a.simulated_time);
8068 *suffix = buf;
8069 }
8070 } else {
8071 const line::fluid::FluidOptions opt = fluid_options(k);
8072 // THE map_env FALLBACK IS ASKED FOR HERE RATHER THAN THROUGH
8073 // `run_avg`: the fluid runner returns a `FluidSolution` and not
8074 // an `AvgResult`, so this arm cannot pass itself as the `Run`
8075 // callable. It asks `needs_map_env` the same question with the
8076 // same config instead of carrying a second copy of the decision.
8077 const line::solvers::MapEnvConfig mecfg = map_env_config(k);
8079 mecfg)
8080 .needed) {
8081 r = line::solvers::map_env_approx<T>(sn, "SolverFluid", mecfg,
8083 opt.method);
8084 banner = "SolverFluid";
8085 if (suffix) *suffix = " map_env=" + r.actualmethod;
8086 return r;
8087 }
8088 // The cache decomposition renormalizes the self-switch at the
8089 // converged split; `-a node` needs that struct or it reports the
8090 // 1/2-1/2 `link()` left behind (see `node_metrics`).
8092 // The null must be typed: a bare `nullptr` is `std::nullptr_t`
8093 // and blocks deduction of T from the fourth argument.
8094 // THE SPLIT ITSELF IS TAKEN TOO, not only the struct it
8095 // renormalized: `node_metrics` prefers the stated split over the
8096 // visit ratios, and `-a cache` is built from nothing else.
8099 sn, opt, static_cast<line::qn::NetworkStruct<T>*>(nullptr), &refreshed, &cache);
8100 // Non-empty only where the cache branch ran, which is the same
8101 // test `node_metrics` makes on the pointer. IT IS ALSO THE
8102 // STRUCT THE ARRIVAL RATE IS READ FROM: that column is derived
8103 // from the class-expanded routing, and the base struct still
8104 // holds the 1/2-1/2 self-switch `link()` offered, so deriving it
8105 // there reports 0.5/0.5 where cache_replc_routing's Delay1 sees
8106 // 0.4/0.6. Every other column is indexed by station and is the
8107 // same in both structs.
8108 const bool has_ref = !refreshed.nodes.empty();
8109 r = avg_result_from_sim<T>(has_ref ? refreshed : sn, a.QN, a.UN, a.RN, a.TN, a.CN,
8110 a.XN, a.method);
8111 if (has_ref) r.refreshed_struct.reset(new line::qn::NetworkStruct<T>(refreshed));
8112 r.cache = cache;
8113 r.iter = static_cast<int>(a.iters);
8114 banner = "SolverFluid";
8115 if (suffix) {
8116 char buf[64];
8117 std::snprintf(buf, sizeof(buf), " iters=%zu", a.iters);
8118 *suffix = buf;
8119 }
8120 }
8121 } else {
8122 throw line::UnsupportedError("-s " + s + " runs under --arith double only");
8123 }
8124 } else if (s == "nc") {
8126 if (!k.method.empty() && k.method != "default") opt.method = k.method;
8127 if (k.tol >= 0.0) opt.tol = k.tol;
8128 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
8129 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
8130 if (!k.fork_join.empty()) opt.fork_join = k.fork_join;
8131 // The map_env fallback, wired here for the reason this engine exists at
8132 // all: these views ARE the `getAvg` funnel of this CLI, so a view that
8133 // refused a MAP model the `-a avg` arm accepts would answer a different
8134 // model for the same command line.
8135 // COMPILE-TIME because the driver is double-only: `map_env_approx`
8136 // refuses `T != double` at run time, but its body still has to compile,
8137 // and an `EnvStageAvgFn<double>` does not convert to an
8138 // `EnvStageAvgFn<Rational>`.
8139 if constexpr (std::is_same_v<T, double>) {
8141 sn, "SolverNC", line::qn::nc_feature_set(opt.method), map_env_config(k),
8144 },
8146 } else {
8148 }
8149 banner = "SolverNC";
8150 } else if (s == "mam") {
8152 if (!k.method.empty() && k.method != "default") opt.method = k.method;
8153 if (k.tol >= 0.0) opt.tol = k.tol;
8154 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
8156 banner = "SolverMAM";
8157 } else if (s == "ag") {
8159 apply_ag_knobs(k, opt);
8161 banner = "SolverAG";
8162 } else if (s == "ba") {
8164 if (!k.method.empty() && k.method != "default") opt.method = k.method;
8166 banner = "SolverBA";
8167 } else if (s == "ctmc") {
8169 if (!k.method.empty()) opt.method = k.method;
8170 if (k.cutoff >= 0.0) opt.cutoff = k.cutoff;
8171 opt.cutoff_mat = k.cutoff_mat;
8172 opt.force = k.force;
8175 banner = "SolverCTMC";
8176 } else {
8178 if (!k.method.empty() && k.method != "default") opt.method = k.method;
8179 if (k.tol >= 0.0) opt.tol = k.tol;
8180 if (k.iter_tol >= 0.0) opt.iter_tol = k.iter_tol;
8181 if (k.iter_max >= 0) opt.iter_max = k.iter_max;
8182 // `-a avg` has honoured --multiserver since the flag existed; these
8183 // views are the SAME solve indexed differently, so ignoring it here made
8184 // `-a chain` answer a different model than `-a avg` for the same command
8185 // line. Measured on cqn_repairmen_multi, where softmin and the default
8186 // rule differ by 39% on the Delay queue length.
8187 if (!k.multiserver.empty()) opt.multiserver = k.multiserver;
8188 if (!k.fork_join.empty()) opt.fork_join = k.fork_join;
8189 line::Matrix<T> init;
8190 // Same funnel, same compile-time guard as the nc arm above.
8191 if constexpr (std::is_same_v<T, double>) {
8193 sn, "SolverMVA",
8195 map_env_config(k),
8196 [&opt, &init](const line::qn::NetworkStruct<double>& m) {
8197 return line::mva::solver_mva_run_analyzer(m, opt, init);
8198 },
8200 } else {
8202 }
8203 banner = "SolverMVA";
8204 }
8205 return r;
8206}
8207
8208/* `NodeMetrics` / `node_metrics` MOVED to line/solvers/solver_node_tables.h;
8209 * see the note above `run_avg_engine`. Used unchanged by both arms below. */
8212
8213template <class T>
8214int solve_model_node(const std::string& file, const Knobs& k, const std::string& s) {
8215 line::qn::Network<T> net = read_model<T>(file);
8217 std::string banner, suffix;
8218 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8219
8220 const std::size_t I = sn.nodes.size(), R = sn.nclasses;
8221 const NodeMetrics<T> nm = node_metrics<T>(sn, r);
8222 const line::Matrix<T>&QNn = nm.QN, &UNn = nm.UN, &RNn = nm.RN, &WNn = nm.WN, &ANn = nm.AN,
8223 &TNn = nm.TN;
8224
8225 const std::vector<bool> hidden = line::solvers::node_table_hidden_classes<T>(sn);
8226 auto d = [](const T& v) { return line::num_traits<T>::to_double(v); };
8227 // A FINITE CAPACITY REGION IS NOT A NODE, and the reference still prints it
8228 // in this table: `getAvgNodeTable` appends one row per region past the
8229 // nodes, because a WAITQ region holds jobs that are in no station's QLen and
8230 // the model's population only balances once they are read. The rows ride
8231 // past the stations in the returned AvgResult -- the (M+F) layout both
8232 // wrappers report -- and no analytical solver fills them, so this block is
8233 // empty for every engine that does not measure a region.
8234 //
8235 // Util AND ArvR ARE NaN, NOT ZERO. A region has no server to be busy and no
8236 // arrival process of its own; the reference reports both as missing, and a
8237 // zero there would be a number the run never measured.
8238 const std::size_t F =
8239 r.QN.rows() > sn.nstations ? r.QN.rows() - sn.nstations : static_cast<std::size_t>(0);
8240 const double region_nan = std::numeric_limits<double>::quiet_NaN();
8241 auto region_name = [&](std::size_t f) {
8242 return (f < sn.regions.size() && !sn.regions[f].name.empty())
8243 ? sn.regions[f].name
8244 : "FCR" + std::to_string(f + 1);
8245 };
8246 // The reference's own filter, the region twin of the all-zero row test: a
8247 // region no job ever entered is absent rather than a row of zeros.
8248 auto region_empty = [&](std::size_t f, std::size_t c) {
8249 return !(d(r.QN(sn.nstations + f, c)) > 0.0 || d(r.RN(sn.nstations + f, c)) > 0.0 ||
8250 d(r.TN(sn.nstations + f, c)) > 0.0);
8251 };
8252 if (g_json_output) {
8253 // The banner under `-o json` too, for print_chain_table's reason: the
8254 // envelope names the arithmetic and the method but never the SOLVER.
8255 std::printf("%s arith=%s method=%s nodes=%zu%s\n", banner.c_str(), line::num_traits<T>::name(),
8256 r.actualmethod.c_str(), I, suffix.c_str());
8257 line::reg::Json p = line::reg::Json::object();
8258 p["type"] = "AvgNodeTable";
8259 p["indexBase"] = 0;
8260 for (const char* key : {"Node", "JobClass", "QLen", "Util", "RespT", "ResidT", "ArvR",
8261 "Tput"})
8262 p[key] = line::reg::Json::array();
8263 for (std::size_t i = 0; i < I; ++i)
8264 for (std::size_t c = 0; c < R; ++c) {
8265 if (hidden[c]) continue; // auxiliary retrieval class, see node_table_hidden_classes
8266 // The reference's own row filter: a node a class never reaches
8267 // is absent, not a row of zeros, exactly as in the AvgTable.
8268 if (d(QNn(i, c)) == 0.0 && d(UNn(i, c)) == 0.0 && d(RNn(i, c)) == 0.0 &&
8269 d(WNn(i, c)) == 0.0 && d(ANn(i, c)) == 0.0 && d(TNn(i, c)) == 0.0)
8270 continue;
8271 p["Node"].push_back(sn.nodes[i].name);
8272 p["JobClass"].push_back(sn.classes[c].name);
8273 p["QLen"].push_back(d(QNn(i, c)));
8274 p["Util"].push_back(d(UNn(i, c)));
8275 p["RespT"].push_back(d(RNn(i, c)));
8276 p["ResidT"].push_back(d(WNn(i, c)));
8277 p["ArvR"].push_back(d(ANn(i, c)));
8278 p["Tput"].push_back(d(TNn(i, c)));
8279 }
8280 for (std::size_t f = 0; f < F; ++f)
8281 for (std::size_t c = 0; c < R; ++c) {
8282 if (region_empty(f, c)) continue;
8283 p["Node"].push_back(region_name(f));
8284 p["JobClass"].push_back(sn.classes[c].name);
8285 p["QLen"].push_back(d(r.QN(sn.nstations + f, c)));
8286 p["Util"].push_back(region_nan);
8287 p["RespT"].push_back(d(r.RN(sn.nstations + f, c)));
8288 p["ResidT"].push_back(d(r.WN(sn.nstations + f, c)));
8289 p["ArvR"].push_back(region_nan);
8290 p["Tput"].push_back(d(r.TN(sn.nstations + f, c)));
8291 }
8292 emit_analysis<T>("node", p, r.actualmethod);
8293 return 0;
8294 }
8295 std::printf("%s arith=%s method=%s nodes=%zu%s\n", banner.c_str(), line::num_traits<T>::name(),
8296 r.actualmethod.c_str(), I, suffix.c_str());
8297 std::printf("%-16s %-14s %12s %12s %12s %12s %12s %12s\n", "Node", "JobClass", "QLen", "Util",
8298 "RespT", "ResidT", "ArvR", "Tput");
8299 for (std::size_t i = 0; i < I; ++i)
8300 for (std::size_t c = 0; c < R; ++c) {
8301 if (hidden[c]) continue; // auxiliary retrieval class, see node_table_hidden_classes
8302 if (d(QNn(i, c)) == 0.0 && d(UNn(i, c)) == 0.0 && d(RNn(i, c)) == 0.0 &&
8303 d(WNn(i, c)) == 0.0 && d(ANn(i, c)) == 0.0 && d(TNn(i, c)) == 0.0)
8304 continue;
8305 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g\n",
8306 sn.nodes[i].name.c_str(), sn.classes[c].name.c_str(), d(QNn(i, c)),
8307 d(UNn(i, c)), d(RNn(i, c)), d(WNn(i, c)), d(ANn(i, c)), d(TNn(i, c)));
8308 }
8309 for (std::size_t f = 0; f < F; ++f)
8310 for (std::size_t c = 0; c < R; ++c) {
8311 if (region_empty(f, c)) continue;
8312 std::printf("%-16s %-14s %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g\n",
8313 region_name(f).c_str(), sn.classes[c].name.c_str(),
8314 d(r.QN(sn.nstations + f, c)), region_nan, d(r.RN(sn.nstations + f, c)),
8315 d(r.WN(sn.nstations + f, c)), region_nan, d(r.TN(sn.nstations + f, c)));
8316 }
8317 return 0;
8318}
8319
8320/**
8321 * `-a cache`: `@@NetworkSolver/getAvgCacheTable`, per Cache node and READ class.
8322 *
8323 * ONE TOTAL ROW PER (node, read class), plus one row per cache list where the
8324 * solver reported a per-list breakdown and the cache has more than one list.
8325 * The `List` column tells them apart: 0 is the total over every list, l is
8326 * list l. Only the total row carries the delayed-hit and miss columns, because
8327 * a miss is a property of the cache and not of any one list.
8328 *
8329 * THE ArvR COLUMN IS THE RETRIEVAL FLOW, `arvr * (missprob + delayedprob)`, on
8330 * the total row and the raw read rate on a list row. That is the reference's
8331 * choice and it is Little-consistent with ResidT: the residence time reported
8332 * beside it is the expected retrieval latency, which only the requests that
8333 * actually retrieve wait for.
8334 *
8335 * A READ CLASS IS ONE WITH A HIT CLASS DEFINED. A class that never reads the
8336 * cache has no row at all rather than a row of zeros, which is the same rule
8337 * the AvgTable applies to a class that never visits a station.
8338 */
8339template <class T>
8340int solve_model_cache(const std::string& file, const Knobs& k, const std::string& s) {
8341 line::qn::Network<T> net = read_model<T>(file);
8343 std::string banner, suffix;
8344 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8345 if (r.cache.empty())
8347 "-a cache reports the per-Cache hit and miss table and this model has no Cache node, "
8348 "or the solver that ran analyzes none; SolverNC's cache branches are what fill it");
8349
8350 // The read-class arrival rate is that class's SOURCE throughput: every read
8351 // request enters the cache, so this holds across solvers, including the
8352 // simulators where a delayed hit is not folded into the hit throughput.
8353 const NodeMetrics<T> nm = node_metrics<T>(sn, r);
8354 std::size_t srcnode = 0;
8355 for (std::size_t i = 0; i < sn.nodes.size(); ++i)
8356 if (sn.nodes[i].nodetype == line::lang::NodeType::Source) { srcnode = i + 1; break; }
8357
8358 struct Row {
8359 std::string node, cls;
8360 double list, listcap, items, hitp, dhitp, missp, hitr, dhitr, missr, arvr, residt, cost;
8361 };
8362 std::vector<Row> rows;
8363 const double dnan = std::numeric_limits<double>::quiet_NaN();
8364
8365 for (std::size_t c = 0; c < r.cache.caches.size(); ++c) {
8366 const line::solvers::CacheNodeMetrics<T>& m = r.cache.caches[c];
8367 const typename std::map<std::size_t, line::qn::CacheParam<T> >::const_iterator it =
8368 sn.nodeparam.find(m.node);
8369 if (it == sn.nodeparam.end()) continue;
8370 const std::vector<std::size_t>& hitclass = it->second.hitclass;
8371 const std::size_t h = m.itemcap.size();
8372 double totcap = 0.0;
8373 for (std::size_t l = 0; l < h; ++l) totcap += m.itemcap[l];
8374 double totcost = dnan;
8375 if (!m.listcost.empty()) {
8376 totcost = 0.0;
8377 for (std::size_t l = 0; l < m.listcost.size(); ++l)
8379 }
8380
8381 for (std::size_t cl = 0; cl < sn.nclasses; ++cl) {
8382 if (cl >= hitclass.size() || hitclass[cl] == 0) continue; // not a read class
8383 double ph = cache_at(m.hitprob, cl), pm = cache_at(m.missprob, cl),
8384 pd = cache_at(m.delayedprob, cl);
8385 if (std::isnan(ph) && std::isnan(pm) && std::isnan(pd)) continue;
8386 if (std::isnan(ph)) ph = 0.0;
8387 if (std::isnan(pm)) pm = 0.0;
8388 if (std::isnan(pd)) pd = 0.0;
8389 const double arvr =
8390 srcnode ? line::num_traits<T>::to_double(nm.TN(srcnode - 1, cl)) : 0.0;
8391 const double lat = cache_at(m.latency, cl);
8392
8393 Row t;
8394 t.node = sn.nodes[m.node - 1].name;
8395 t.cls = sn.classes[cl].name;
8396 t.list = 0;
8397 t.listcap = totcap;
8398 t.items = static_cast<double>(m.nitems);
8399 t.hitp = ph;
8400 t.dhitp = pd;
8401 t.missp = pm;
8402 t.hitr = arvr * ph;
8403 t.dhitr = arvr * pd;
8404 t.missr = arvr * pm;
8405 t.arvr = arvr * (pm + pd);
8406 t.residt = lat;
8407 t.cost = totcost;
8408 rows.push_back(t);
8409
8410 // Per-list rows, only where a genuine multi-list breakdown exists.
8411 bool any = false;
8412 if (h > 1 && cl < m.hitproblist.rows())
8413 for (std::size_t l = 0; l < m.hitproblist.cols(); ++l)
8414 if (!std::isnan(line::num_traits<T>::to_double(m.hitproblist(cl, l))))
8415 any = true;
8416 if (!any) continue;
8417 for (std::size_t l = 0; l < h; ++l) {
8418 double phl = l < m.hitproblist.cols()
8420 : dnan;
8421 if (std::isnan(phl)) phl = 0.0;
8422 Row u;
8423 u.node = t.node;
8424 u.cls = t.cls;
8425 u.list = static_cast<double>(l + 1);
8426 u.listcap = m.itemcap[l];
8427 u.items = t.items;
8428 u.hitp = phl;
8429 u.dhitp = dnan;
8430 u.missp = dnan;
8431 u.hitr = arvr * phl;
8432 u.dhitr = dnan;
8433 u.missr = dnan;
8434 u.arvr = arvr;
8435 u.residt = dnan;
8436 u.cost = l < m.listcost.size()
8438 : dnan;
8439 rows.push_back(u);
8440 }
8441 }
8442 }
8443
8444 if (g_json_output) {
8445 line::reg::Json p = line::reg::Json::object();
8446 p["type"] = "AvgCacheTable";
8447 p["indexBase"] = 0;
8448 for (const char* key : {"Node", "JobClass", "List", "ListCap", "Items", "HitProb",
8449 "DelayedHitProb", "MissProb", "HitRate", "DelayedHitRate",
8450 "MissRate", "ArvR", "ResidT", "ListCost"})
8451 p[key] = line::reg::Json::array();
8452 for (std::size_t i = 0; i < rows.size(); ++i) {
8453 p["Node"].push_back(rows[i].node);
8454 p["JobClass"].push_back(rows[i].cls);
8455 p["List"].push_back(rows[i].list);
8456 p["ListCap"].push_back(rows[i].listcap);
8457 p["Items"].push_back(rows[i].items);
8458 p["HitProb"].push_back(rows[i].hitp);
8459 p["DelayedHitProb"].push_back(rows[i].dhitp);
8460 p["MissProb"].push_back(rows[i].missp);
8461 p["HitRate"].push_back(rows[i].hitr);
8462 p["DelayedHitRate"].push_back(rows[i].dhitr);
8463 p["MissRate"].push_back(rows[i].missr);
8464 p["ArvR"].push_back(rows[i].arvr);
8465 p["ResidT"].push_back(rows[i].residt);
8466 p["ListCost"].push_back(rows[i].cost);
8467 }
8468 emit_analysis<T>("cache", p, r.actualmethod);
8469 return 0;
8470 }
8471 std::printf("%s arith=%s method=%s caches=%zu\n", banner.c_str(), line::num_traits<T>::name(),
8472 r.actualmethod.c_str(), r.cache.caches.size());
8473 std::printf("%-14s %-12s %5s %8s %6s %10s %10s %10s %10s %10s %10s %10s %10s %10s\n", "Node",
8474 "JobClass", "List", "ListCap", "Items", "HitProb", "DHitProb", "MissProb",
8475 "HitRate", "DHitRate", "MissRate", "ArvR", "ResidT", "ListCost");
8476 for (std::size_t i = 0; i < rows.size(); ++i)
8477 std::printf(
8478 "%-14s %-12s %5g %8g %6g %10.6g %10.6g %10.6g %10.6g %10.6g %10.6g %10.6g %10.6g "
8479 "%10.6g\n",
8480 rows[i].node.c_str(), rows[i].cls.c_str(), rows[i].list, rows[i].listcap,
8481 rows[i].items, rows[i].hitp, rows[i].dhitp, rows[i].missp, rows[i].hitr,
8482 rows[i].dhitr, rows[i].missr, rows[i].arvr, rows[i].residt, rows[i].cost);
8483 return 0;
8484}
8485
8486/**
8487 * `-a item`: `@@NetworkSolver/getAvgItemTable`, one row per (Cache, item, list).
8488 *
8489 * THE PER-ITEM OCCUPANCY, which only a solver that computes a genuine per-item
8490 * distribution has: the exact NC cache recursions and the delayed-hit retrieval
8491 * algorithms. Every other branch measures the aggregate hit probability and
8492 * never forms the item law, and the arm refuses rather than filling the column
8493 * with the uniform guess that would reproduce the same aggregate.
8494 *
8495 * `Cost` is `Size * Prob`, so summing it over the items of a list reproduces
8496 * that list's ListCost in the cache table -- which is what makes the two tables
8497 * checkable against each other.
8498 */
8499template <class T>
8500int solve_model_item(const std::string& file, const Knobs& k, const std::string& s) {
8501 line::qn::Network<T> net = read_model<T>(file);
8503 std::string banner, suffix;
8504 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8505 if (r.cache.empty())
8507 "-a item reports the per-item cache occupancy and this model has no Cache node, or "
8508 "the solver that ran analyzes none");
8509
8510 const double dnan = std::numeric_limits<double>::quiet_NaN();
8511 struct Row {
8512 std::string node;
8513 double item, list, listcap, size, prob, cost, dhq, dhqf;
8514 };
8515 std::vector<Row> rows;
8516 for (std::size_t c = 0; c < r.cache.caches.size(); ++c) {
8517 const line::solvers::CacheNodeMetrics<T>& m = r.cache.caches[c];
8518 const std::size_t h = m.itemcap.size();
8519 // EITHER measurement earns the item its rows. A solver may form the
8520 // per-item occupancy (the NC and MVA cache recursions) or the per-item
8521 // delayed-hit queue length (the exact chain) and not the other, and
8522 // requiring both would drop the CTMC's whole table.
8523 if (h == 0 || (m.itemprob.rows() == 0 && m.delayedhitqlen.empty())) continue;
8524 const std::size_t nit =
8525 m.itemprob.rows() > 0 ? m.itemprob.rows() : m.delayedhitqlen.size();
8526 for (std::size_t i = 0; i < nit; ++i)
8527 for (std::size_t l = 0; l < h; ++l) {
8528 Row t;
8529 t.node = sn.nodes[m.node - 1].name;
8530 t.item = static_cast<double>(i + 1);
8531 t.list = static_cast<double>(l + 1);
8532 t.listcap = m.itemcap[l];
8533 t.size = i < m.itemsize.size() ? m.itemsize[i] : dnan;
8534 // Column 0 of `itemprob` is the MISS column, so list l is column
8535 // l+1; reading it as l would report every item one list too low.
8536 t.prob = (l + 1) < m.itemprob.cols()
8538 : dnan;
8539 t.cost = t.size * t.prob;
8540 t.dhq = i < m.delayedhitqlen.size()
8542 : dnan;
8543 t.dhqf = i < m.delayedhitqlenfull.size()
8545 : dnan;
8546 rows.push_back(t);
8547 }
8548 }
8549 if (rows.empty())
8551 "-a item needs a per-item occupancy law and this solve produced none; the NC/MVA "
8552 "cache recursions (isolated and integrated alike) and the delayed-hit retrieval "
8553 "algorithms compute the embedded one, SolverCTMC the time-weighted one, and the "
8554 "simulators none");
8555
8556 if (g_json_output) {
8557 line::reg::Json p = line::reg::Json::object();
8558 p["type"] = "AvgItemTable";
8559 p["indexBase"] = 0;
8560 for (const char* key : {"Node", "Item", "List", "ListCap", "Size", "Prob", "Cost",
8561 "DelayedHitQLen", "DelayedHitQLenFull"})
8562 p[key] = line::reg::Json::array();
8563 for (std::size_t i = 0; i < rows.size(); ++i) {
8564 p["Node"].push_back(rows[i].node);
8565 p["Item"].push_back(rows[i].item);
8566 p["List"].push_back(rows[i].list);
8567 p["ListCap"].push_back(rows[i].listcap);
8568 p["Size"].push_back(rows[i].size);
8569 p["Prob"].push_back(rows[i].prob);
8570 p["Cost"].push_back(rows[i].cost);
8571 p["DelayedHitQLen"].push_back(rows[i].dhq);
8572 p["DelayedHitQLenFull"].push_back(rows[i].dhqf);
8573 }
8574 emit_analysis<T>("item", p, r.actualmethod);
8575 return 0;
8576 }
8577 std::printf("%s arith=%s method=%s rows=%zu\n", banner.c_str(), line::num_traits<T>::name(),
8578 r.actualmethod.c_str(), rows.size());
8579 std::printf("%-14s %6s %6s %8s %10s %12s %12s %14s %18s\n", "Node", "Item", "List", "ListCap",
8580 "Size", "Prob", "Cost", "DelayedHitQLen", "DelayedHitQLenFull");
8581 for (std::size_t i = 0; i < rows.size(); ++i)
8582 std::printf("%-14s %6g %6g %8g %10g %12.8g %12.8g %14.8g %18.8g\n", rows[i].node.c_str(),
8583 rows[i].item, rows[i].list, rows[i].listcap, rows[i].size, rows[i].prob,
8584 rows[i].cost, rows[i].dhq, rows[i].dhqf);
8585 return 0;
8586}
8587
8588/**
8589 * `-a sys`: `@@NetworkSolver/getAvgSysTable`, one row per CHAIN.
8590 *
8591 * SysRespT is the chain's CYCLE TIME and SysTput the flow that completes it,
8592 * both measured at the chain's reference station -- not a column of the
8593 * AvgTable summed up. On a closed chain the two are tied by Little's law and
8594 * the table is the standard capacity-planning view: N = X * R.
8595 */
8596template <class T>
8597int solve_model_sys(const std::string& file, const Knobs& k, const std::string& s) {
8598 line::qn::Network<T> net = read_model<T>(file);
8600 std::string banner, suffix;
8601 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8603 const std::vector<std::string> cn = line::solvers::chain_names(sn.nchains);
8604 const std::vector<std::string> cc = line::solvers::chain_class_labels<T>(sn);
8605 auto d = [](const T& v) { return line::num_traits<T>::to_double(v); };
8606
8607 if (g_json_output) {
8608 // The banner under `-o json` too, for print_chain_table's reason: the
8609 // envelope names the arithmetic and the method but never the SOLVER.
8610 std::printf("%s arith=%s method=%s chains=%zu%s\n", banner.c_str(), line::num_traits<T>::name(),
8611 r.actualmethod.c_str(), cn.size(), suffix.c_str());
8612 line::reg::Json p = line::reg::Json::object();
8613 p["type"] = "AvgSysTable";
8614 p["indexBase"] = 0;
8615 for (const char* key : {"Chain", "JobClasses", "SysRespT", "SysTput"})
8616 p[key] = line::reg::Json::array();
8617 for (std::size_t c = 0; c < sn.nchains; ++c) {
8618 p["Chain"].push_back(cn[c]);
8619 p["JobClasses"].push_back(cc[c]);
8620 p["SysRespT"].push_back(d(sys.CN[c]));
8621 p["SysTput"].push_back(d(sys.XN[c]));
8622 }
8623 emit_analysis<T>("sys", p, r.actualmethod);
8624 return 0;
8625 }
8626 std::printf("%s arith=%s method=%s chains=%zu%s\n", banner.c_str(),
8627 line::num_traits<T>::name(), r.actualmethod.c_str(), sn.nchains, suffix.c_str());
8628 std::printf("%-10s %-24s %14s %14s\n", "Chain", "JobClasses", "SysRespT", "SysTput");
8629 for (std::size_t c = 0; c < sn.nchains; ++c)
8630 std::printf("%-10s %-24s %14.6g %14.6g\n", cn[c].c_str(), cc[c].c_str(), d(sys.CN[c]),
8631 d(sys.XN[c]));
8632 return 0;
8633}
8634
8635/** Render a station- or node-level chain table, as text or as the host's JSON. */
8636template <class T>
8637void print_chain_table(const char* key, const char* type, const char* rowlabel,
8638 const std::vector<std::string>& rows,
8639 const std::vector<std::string>& chains,
8640 const std::vector<std::string>& classes,
8641 const line::solvers::ChainResult<T>& t, const std::string& method,
8642 const char* banner, const char* arith, const char* suffix = "") {
8643 auto d = [](const T& v) { return line::num_traits<T>::to_double(v); };
8644 if (g_json_output) {
8645 // THE BANNER IS PRINTED UNDER `-o json` TOO, as the AvgTable path does:
8646 // the envelope names the arithmetic and the method but never the
8647 // SOLVER, so a host that pairs a table with another codebase's by the
8648 // solver in its banner cannot attribute a bannerless one at all. Its
8649 // absence here made `-o json` unusable for these four analyses.
8650 std::printf("%s arith=%s method=%s chains=%zu%s\n", banner, arith, method.c_str(),
8651 chains.size(), suffix);
8652 line::reg::Json p = line::reg::Json::object();
8653 p["type"] = type;
8654 p["indexBase"] = 0;
8655 for (const char* c : {rowlabel, "Chain", "JobClasses", "QLen", "Util", "RespT", "ResidT",
8656 "ArvR", "Tput"})
8657 p[c] = line::reg::Json::array();
8658 // ROW-MAJOR OVER (row, chain), the reference's `(ist-1)*C+c` ordering,
8659 // so a host reading the two codebases' tables side by side indexes them
8660 // the same way.
8661 for (std::size_t i = 0; i < rows.size(); ++i)
8662 for (std::size_t c = 0; c < chains.size(); ++c) {
8663 p[rowlabel].push_back(rows[i]);
8664 p["Chain"].push_back(chains[c]);
8665 p["JobClasses"].push_back(classes[c]);
8666 p["QLen"].push_back(d(t.QN(i, c)));
8667 p["Util"].push_back(d(t.UN(i, c)));
8668 p["RespT"].push_back(d(t.RN(i, c)));
8669 p["ResidT"].push_back(d(t.WN(i, c)));
8670 p["ArvR"].push_back(d(t.AN(i, c)));
8671 p["Tput"].push_back(d(t.TN(i, c)));
8672 }
8673 emit_analysis<T>(key, p, method);
8674 return;
8675 }
8676 std::printf("%s arith=%s method=%s chains=%zu%s\n", banner, arith, method.c_str(),
8677 chains.size(), suffix);
8678 std::printf("%-16s %-10s %-20s %12s %12s %12s %12s %12s %12s\n", rowlabel, "Chain",
8679 "JobClasses", "QLen", "Util", "RespT", "ResidT", "ArvR", "Tput");
8680 for (std::size_t i = 0; i < rows.size(); ++i)
8681 for (std::size_t c = 0; c < chains.size(); ++c)
8682 std::printf("%-16s %-10s %-20s %12.6g %12.6g %12.6g %12.6g %12.6g %12.6g\n",
8683 rows[i].c_str(), chains[c].c_str(), classes[c].c_str(), d(t.QN(i, c)),
8684 d(t.UN(i, c)), d(t.RN(i, c)), d(t.WN(i, c)), d(t.AN(i, c)),
8685 d(t.TN(i, c)));
8686}
8687
8688/**
8689 * `-a chain`: `@@NetworkSolver/getAvgChainTable`, the station table by CHAIN.
8690 *
8691 * EVERY ROW IS EMITTED, including the all-zero ones, unlike the AvgTable and the
8692 * AvgNodeTable. The reference builds this table with a full (M x C) grid and no
8693 * row filter, and a chain that is absent from a station is information -- it is
8694 * the shape of the routing -- where a class absent from a station in the
8695 * AvgTable is only the class's own scope.
8696 */
8697template <class T>
8698int solve_model_chain(const std::string& file, const Knobs& k, const std::string& s) {
8699 line::qn::Network<T> net = read_model<T>(file);
8701 std::string banner, suffix;
8702 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8704 std::vector<std::string> rows;
8705 for (std::size_t i = 0; i < sn.nstations; ++i) rows.push_back(sn.stations[i].name);
8706 print_chain_table<T>("chain", "AvgChainTable", "Station", rows,
8709 banner.c_str(), line::num_traits<T>::name(), suffix.c_str());
8710 return 0;
8711}
8712
8713/** `-a nodechain`: `@@NetworkSolver/getAvgNodeChainTable`, the node table by CHAIN. */
8714template <class T>
8715int solve_model_nodechain(const std::string& file, const Knobs& k, const std::string& s) {
8716 line::qn::Network<T> net = read_model<T>(file);
8718 std::string banner, suffix;
8719 const line::mva::AvgResult<T> r = run_avg_engine<T>(sn, k, s, banner, &suffix, &file);
8720 const NodeMetrics<T> nm = node_metrics<T>(sn, r);
8722 line::solvers::solver_get_avg_node_chain<T>(sn, nm.QN, nm.UN, nm.RN, nm.WN, nm.AN, nm.TN);
8723 std::vector<std::string> rows;
8724 for (std::size_t i = 0; i < sn.nodes.size(); ++i) rows.push_back(sn.nodes[i].name);
8725 print_chain_table<T>("nodechain", "AvgNodeChainTable", "Node", rows,
8728 banner.c_str(), line::num_traits<T>::name(), suffix.c_str());
8729 return 0;
8730}
8731
8732/** The four @@NetworkSolver tables that are VIEWS of one solved AvgResult. */
8733inline bool is_avg_view(const std::string& analysis) {
8734 return analysis == "node" || analysis == "sys" || analysis == "chain" ||
8735 analysis == "nodechain";
8736}
8737
8738/**
8739 * Dispatch one of those four views at a fixed arithmetic.
8740 *
8741 * The wrapper arms reach the views through this rather than through the shared
8742 * ladder further down: they return before it, having validated their own knobs
8743 * against an engine the ladder knows nothing about. The arithmetic is fixed
8744 * because both wrappers report in double and have already refused every other
8745 * backend by name.
8746 */
8747template <class T>
8748int solve_avg_view(const std::string& file, const Knobs& k, const std::string& s,
8749 const std::string& analysis) {
8750 if (analysis == "node") return solve_model_node<T>(file, k, s);
8751 if (analysis == "sys") return solve_model_sys<T>(file, k, s);
8752 if (analysis == "chain") return solve_model_chain<T>(file, k, s);
8753 return solve_model_nodechain<T>(file, k, s);
8754}
8755
8756int solve_model_dispatch(const std::string& arith, const std::string& solver,
8757 const std::string& analysis, const std::string& file, const Knobs& k) {
8758 std::string s = solver.empty() ? "auto" : solver;
8759 if (s != "mva" && s != "auto" && s != "fluid" && s != "fld" && s != "nc" && s != "mam" &&
8760 s != "ag" && s != "ba" && s != "ssa" && s != "ctmc" && s != "uq" && s != "env" &&
8761 s != "lqns" && s != "ldes" && s != "jmt")
8763 "the model-solving path ports -s mva, nc, ctmc, mam, ag, ba, ssa, fluid, ldes, jmt, "
8764 "uq, env and lqns (got '" + s + "'); other solvers remain API-only");
8765 // Every solver on this path but JMT and LQNS runs in-process, so the flags
8766 // that describe an external solver's child process have nothing to act on.
8767 // Those two do run one, and both write a scratch directory --keep names:
8768 // `solve_model_jmt` forwards it to JmtOptions.keep, which is what leaves
8769 // model.jsim behind, and refusing it here made the one document a parity
8770 // difference has to be read from unobtainable.
8771 if ((k.verbose && s != "ldes") || k.remote || !k.remote_url.empty() ||
8772 (k.keep && s != "lqns" && s != "jmt"))
8774 "--keep, --verbose, --remote and --remote-url describe the child process of an "
8775 "external solver; on this path -s jmt and -s lqns run one and take --keep, -s ldes "
8776 "runs one and takes --verbose (which echoes the resolved engine command line), and "
8777 "no path takes --remote or --remote-url");
8778 if (k.timeout_seconds && s != "lqns")
8780 "--timeout is the deadline of an external solver's child process; on this path only "
8781 "-s lqns runs one");
8782 // --fork-join names an arm of the fork-join FIXED POINT, which only the
8783 // mean-value arms drive: a simulator walks the fork on its sample path and
8784 // a CTMC enumerates it, so neither has a transform to choose. Refused by
8785 // name rather than ignored, which would report the default arm's numbers
8786 // under the caller's choice.
8787 if (!k.fork_join.empty() && s != "mva" && s != "nc")
8789 "--fork-join selects the fork-join transform of the shared mean-value fixed point "
8790 "and is read by -s mva and -s nc; -s " + s +
8791 " either simulates or enumerates the fork and applies no transform");
8792 // REFUSED BY NAME RATHER THAN IGNORED, on the same grounds as --fork-join
8793 // above: a knob silently dropped reports the DEFAULT arm's numbers under
8794 // the caller's choice, which is the one outcome stating the flag exists to
8795 // rule out.
8796 if (k.warmupfrac >= 0.0 && s != "ssa")
8798 "--warmupfrac discards a leading fraction of a SIMULATED path before the means are "
8799 "taken and is read by -s ssa; -s " + s +
8800 " has no path to discard (the LDES engine takes --ldes-warmupfrac)");
8801 if (k.pstar > 0.0 && s != "fluid" && s != "fld")
8803 "--pstar is the exponent of the fluid p-norm smoothing of the drift and is read by "
8804 "-s fluid; -s " + s + " integrates no drift");
8805 if ((!k.busy_orders.empty() || !k.busy_subnet.empty()) && analysis != "busyperiod")
8807 "--busyperiod and --busyperiod-subnet name the orders and the subnetwork of "
8808 "-a busyperiod; got -a " + analysis);
8809 // Solver console: this dispatcher is the single point every model-solving
8810 // arm passes through, so the narrated run is opened here and closed by the
8811 // guard's destructor -- on an exception too, so a failed analysis still
8812 // reports what it had reached. The model name is not known before the file
8813 // is read, so the header names the file's model once the struct compiles.
8814 line::util::LineConsole::Run consoleRun(upper_tag(s), "", true);
8815
8816 // ---- SolverUQ, BEFORE the per-solver knob ladder ----------------------
8817 // It is a wrapper, not an engine: `--method`, `--samples` and `--seed`
8818 // describe its DESIGN and the convergence knobs belong to whatever
8819 // `--uq-solver` names, so the ladder below -- which asks "does THIS solver
8820 // have a sample count" -- answers about the wrong solver here.
8821 if (s == "uq") {
8822 if (analysis != "avg" && analysis != "posterior" && analysis != "interval")
8824 "SolverUQ ports -a avg (the prior-weighted expectation), -a posterior (the "
8825 "per-design-point table) and -a interval (the support-only range); got '" +
8826 analysis + "'");
8827 if (k.uq_solver.empty())
8828 throw line::InputError(
8829 "-s uq needs --uq-solver: UQ computes nothing itself, it expands the Prior and "
8830 "runs another solver at each design point (the C++ spelling of UQ(model, "
8831 "@SolverMVA)). Naming one here by default would attribute the numbers to an "
8832 "engine the caller never chose");
8833 if (k.t1 >= 0.0 || k.node || !k.notation.empty())
8835 "--tspan, --node and --notation name a transient horizon, a stateful node and an "
8836 "ODE document; SolverUQ reports steady-state means over a design of models and "
8837 "has none of the three");
8838 if (k.no_interlocking || k.interlock_knobs_given() || k.repeat > 0 || !k.layer_solver.empty())
8840 "--no-interlocking, --interlock-*, --repeat and --layer-solver are options of the layered solver "
8841 "and apply to -i lqnx; a Network model has no layers to interlock");
8842 if (arith == "double") return solve_model_uq<double>(file, k, analysis);
8843 if (arith == "exact") return solve_model_uq<line::Rational>(file, k, analysis);
8844 if (arith == "real:16") return solve_model_uq<line::Real<16> >(file, k, analysis);
8845 if (arith == "real" || arith == "real:32")
8846 return solve_model_uq<line::Real<32> >(file, k, analysis);
8847 if (arith == "real:64") return solve_model_uq<line::Real<64> >(file, k, analysis);
8848 if (arith == "real:128") return solve_model_uq<line::Real<128> >(file, k, analysis);
8849 if (arith == "real:256") return solve_model_uq<line::Real<256> >(file, k, analysis);
8850 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
8851 }
8852 // ---- SolverENV, also BEFORE the per-solver knob ladder ----------------
8853 // It reads a DIFFERENT MODEL TYPE: an Environment envelope, whose stages
8854 // each hold a Network. The ladder below asks its questions of one Network
8855 // model -- `--cutoff` per open class, `--node` on a stateful node -- and
8856 // the model-level knobs it would validate belong to the STAGE solver here,
8857 // so ENV states its own refusals and routes around it.
8858 if (s == "env") {
8859 if (analysis != "avg")
8861 "SolverENV reports -a avg, the environment-blended means; getEnsembleAvg is its "
8862 "only metric entry in the reference too (got '" + analysis + "')");
8863 if (k.samples || k.seed)
8865 "--samples and --seed describe a simulation; SolverENV iterates a fixed point over "
8866 "transient stage solves and draws nothing");
8867 // `--cutoff` IS ADMITTED WITH `--stage-solver ctmc`, and only then: every
8868 // stage is then enumerated, and an open stage's chain has to be
8869 // truncated somewhere. It stays refused for a fluid ensemble, which
8870 // enumerates nothing.
8871 if (k.has_cutoff() && k.stage_solver != "ctmc")
8873 "--cutoff bounds the open population of an enumerated state space and applies to "
8874 "-s env only beside --stage-solver ctmc; the fluid stages of this ensemble "
8875 "enumerate no states");
8876 if (k.node || !k.notation.empty())
8878 "--node and --notation name a stateful node and an ODE document of ONE network; "
8879 "an Environment holds a network per stage and "
8880 "SolverENV reports the blend over them");
8881 if (k.no_interlocking || k.interlock_knobs_given() || k.repeat > 0 || !k.layer_solver.empty())
8883 "--no-interlocking, --interlock-*, --repeat and --layer-solver are options of the layered solver "
8884 "and apply to -i lqnx; an Environment has stages, not layers");
8885 if (k.t0 != 0.0)
8887 "--tspan on the ENV path states the transient HORIZON each stage solve integrates "
8888 "to, and every stage starts from its entry state at 0; a nonzero t0 would name a "
8889 "start the coupling has no state for");
8890 // The mean-field coupling integrates the stage drift with LSODA, which
8891 // is double; the state-vector one uniformizes a CTMC and carries the
8892 // whole ladder. Narrowing silently would report an `exact` banner over
8893 // a double solve, so the refusal names the coupling that decided it.
8894 // `blend` is a spelling of `default` since 2026-09-13 (it named the
8895 // state-vector coupling before that), so it normalizes to `meanfield`
8896 // here and every gate below reads it as such without repeating it.
8897 const std::string coupling =
8898 (k.method.empty() || k.method == "default" || k.method == "mean"
8899 || k.method == "blend" || k.method == "blending")
8900 ? "meanfield"
8901 : k.method;
8902 if (k.tran_points && coupling == "statevec")
8904 "--tran-points is the mean-field coupling's quadrature grid; the state-vector "
8905 "coupling carries the whole joint law across a switch and sums over no such grid, "
8906 "so the value would be accepted and never used");
8907 // `statedep` is a C++-API method and not a file one: it needs a rate
8908 // function PER ARC (`Environment::set_env_rate_reset`), which is a
8909 // function of the stage exit metrics and has no representation in
8910 // model.json -- the reference cannot serialize `resetEnvRatesFun`
8911 // either. Reaching it from a file would find no hook and refuse deeper
8912 // in, with a message about an environment the caller never wrote.
8913 if (coupling == "statedep")
8915 "--method statedep makes each environment transition depend on the state its "
8916 "stage is left in, through a rate function per arc that no model.json can carry "
8917 "(the reference cannot serialize resetEnvRatesFun either); it is reachable from "
8918 "the C++ API, through Environment::set_env_rate_reset");
8919 if ((k.tran_points || k.t1 >= 0.0) && (coupling == "avg" || coupling == "dec"))
8921 "--tran-points and --tspan state the grid and the horizon of a TRANSIENT stage "
8922 "solve; the closed-form limits --method avg and --method dec solve in steady "
8923 "state and carry nothing across a switch, so both would be accepted and never "
8924 "used");
8925 if (arith != "double" && coupling != "statevec")
8927 "SolverENV solves a stage with the fluid analyzer on every method but the "
8928 "state-vector one -- the mean-field coupling transiently, the avg and dec limits "
8929 "in steady state -- and that analyzer is LSODA's and therefore double; --arith " +
8930 arith +
8931 " reaches ENV only through the state-vector coupling (--method statevec)");
8932 if (arith == "double") return solve_model_env<double>(file, k);
8933 if (arith == "exact") return solve_model_env<line::Rational>(file, k);
8934 if (arith == "real:16") return solve_model_env<line::Real<16> >(file, k);
8935 if (arith == "real" || arith == "real:32")
8936 return solve_model_env<line::Real<32> >(file, k);
8937 if (arith == "real:64") return solve_model_env<line::Real<64> >(file, k);
8938 if (arith == "real:128") return solve_model_env<line::Real<128> >(file, k);
8939 if (arith == "real:256") return solve_model_env<line::Real<256> >(file, k);
8940 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
8941 }
8942 if (!k.uq_solver.empty())
8944 "--uq-solver names the engine SolverUQ runs at each design point and applies to -s uq; "
8945 "'" + s + "' solves one model, not a design of them");
8946 if (k.tran_points)
8948 "--tran-points is the resolution of the transient grid SolverENV sums its stage exit "
8949 "metrics over and applies to -s env; '" + s + "' has no such quadrature");
8950 // ---- SolverLDES, BEFORE the per-solver knob ladder --------------------
8951 // It hands the model DOCUMENT to an external engine instead of reading it,
8952 // so the ladder below -- which validates knobs against a parsed struct --
8953 // asks its questions of a model this path never parses. LDES states its own
8954 // refusals here for the same reason UQ and ENV state theirs.
8955 const bool ldes_knob = !k.ldes_tranfilter.empty() || k.ldes_warmupfrac >= 0.0 ||
8956 !k.ldes_cimethod.empty() || k.ldes_cnvgon || k.ldes_slotted ||
8957 k.ldes_replications > 0 || k.ldes_numthreads > 0 ||
8958 k.ldes_maxtime > 0.0 || !k.ldes_initsol.empty() ||
8959 !k.ldes_rest_url.empty();
8960 if (ldes_knob && s != "ldes" && s != "auto")
8962 "the --ldes-* flags are the discrete-event engine's own settings (warmup filter, "
8963 "confidence-interval estimator, slot lattice, replications, warm-start placement) and "
8964 "apply to -s ldes; '" + s + "' has none of them");
8965 if (k.jmt_replications > 0 && s != "jmt")
8967 "--jmt-replications is SolverJMT's transient ensemble size (-s jmt -a tran / -a "
8968 "tranprob); '" + s + "' has none (-s ldes takes --ldes-replications)");
8969 if (s == "jmt") {
8970 if (arith != "double")
8972 "SolverJMT is a client of the Java Modelling Tools engine, which simulates in "
8973 "double and reports in double; --arith " + arith +
8974 " would label a double answer with an arithmetic that never touched it");
8975 // THE LIST AND `auto_family_metrics("jmt")` ARE ONE ANSWER, and they
8976 // had drifted: the family table advertised tran, tranprob and sample
8977 // while this gate refused all three, so a caller following
8978 // `--find-solver` was refused by name. `jmt_logs.h` implements every
8979 // one of them (`jmt_transient_replications`, `jmt_get_tran_prob_aggr`,
8980 // `jmt_sample_aggr`, `jmt_sample_sys_aggr`); what was missing was the
8981 // way in.
8982 if (analysis != "avg" && analysis != "cdf" && analysis != "trancdf" &&
8983 analysis != "trancdfpasst" && analysis != "prob" && analysis != "tran" &&
8984 analysis != "tranprob" && analysis != "sample" && !is_avg_view(analysis))
8986 "-s jmt reports -a avg (the JSIM or JMVA mean table), its four views -a node, "
8987 "-a sys, -a chain and -a nodechain, -a cdf (the empirical response-time law read "
8988 "back from the JMT logs, preloaded at the rounded steady-state queue lengths), "
8989 "-a tran-cdf-respt and -a tran-cdf-passt (the same logged run from the default "
8990 "initial state, so the samples cover the transient), -a prob (the time each "
8991 "declared state is held for along the logged trajectory), -a tran (the transient "
8992 "means over --jmt-replications independent replications), -a tranprob (the same "
8993 "replications' aggregate state law at one station) and -a sample (one logged "
8994 "trajectory); the DETAILED laws -a states and getTranProb have no counterpart, "
8995 "since a JMT log records per-class job counts and nothing about the buffer order "
8996 "or the service phase");
8997 {
8998 const std::string removed = line::jmt::jmt_removed_method_refusal(k.method);
8999 if (!removed.empty()) throw line::UnsupportedError(removed);
9000 }
9001 const std::vector<std::string> valid = line::jmt::jmt_list_valid_methods();
9002 if (!k.method.empty() &&
9003 std::find(valid.begin(), valid.end(), k.method) == valid.end())
9005 "SolverJMT methods are default, jsim and the jmva family (jmva, jmva.amva, "
9006 "jmva.mva, jmva.recal, jmva.comom, jmva.chow, jmva.bs, jmva.aql, jmva.lin, "
9007 "jmva.dmlin); got '" + k.method + "'");
9008 // `--iter_max` MEANS NOTHING TO JMT. It used to be the replication count of `-a tran` and
9009 // `-a tranprob`; that count is now `options.config.replications`, spelt --jmt-replications.
9010 const bool jmt_replicated = analysis == "tran" || analysis == "tranprob";
9011 if (k.iter_max >= 0)
9013 "--iter_max is not a SolverJMT option: the number of independent replications -a "
9014 "tran and -a tranprob average over is --jmt-replications (options.config."
9015 "replications, default 10)");
9016 if (k.tol >= 0.0 || k.iter_tol >= 0.0 || !k.multiserver.empty())
9018 "--tol, --iter_tol and --multiserver configure a fixed-point iteration; JSIM "
9019 "simulates a sample path and JMVA takes its tolerance from the exported document. "
9020 "The simulation's stopping rule is --samples");
9021 if (k.jmt_replications > 0 && !jmt_replicated)
9023 "--jmt-replications sizes the transient ensemble of -a tran and -a tranprob; "
9024 "steady-state JSIM (-a " + analysis + ") is one run and reads no replication count");
9025 if (k.has_cutoff())
9027 "--cutoff truncates an enumerated state space; JMT enumerates none");
9028 // `--node` NAMES ONE STATION, on the three arms that answer about one.
9029 // On `-a prob` it says which station `--state` overrides, the second
9030 // argument of `getProbAggr(node, state_a)`; on `-a tranprob` it is the
9031 // station whose aggregate law is estimated; on `-a sample` it is the
9032 // node whose own trajectory is returned instead of the system's. Every
9033 // other JMT arm reports a table over all stations and classes.
9034 const bool jmt_node_arm =
9035 analysis == "prob" || analysis == "tranprob" || analysis == "sample";
9036 if ((k.node && !jmt_node_arm) || k.jobclass || !k.marg_states.empty())
9038 "--node, --class and --marg-states select the marginal law of one (node, class); "
9039 "the JMT arms report tables over every station and class, and --node applies to "
9040 "-a prob (which station --state overrides), -a tranprob (whose state law) and "
9041 "-a sample (whose trajectory)");
9042 if (!k.state.empty() && analysis != "prob")
9044 "--state names the state a probability is asked about and applies to -a prob");
9045 if (!k.state.empty() && !k.node)
9047 "--state is the per-class job count of ONE station and needs --node to say "
9048 "which; a bare count vector cannot be matched against a whole network");
9049 if (!k.notation.empty() || !k.symbolic.empty() || k.equilibria)
9051 "--notation, --symbolic and --equilibria describe an exported ODE document; JMT "
9052 "integrates no ODE");
9053 if (!k.cdf_algorithm.empty())
9055 "--cdf-algorithm selects between the two sojourn-time INVERSIONS of -s nc; the "
9056 "JMT response-time law is the ecdf of the passages its loggers recorded and is "
9057 "not computed from a transform");
9058 if (is_avg_view(analysis)) return solve_avg_view<double>(file, k, "jmt", analysis);
9059 return solve_model_jmt(file, k, analysis);
9060 }
9061 if (s == "ldes") {
9062 if (arith != "double")
9064 "SolverLDES is a client of the SSJ engine, which simulates in double and reports "
9065 "in double; --arith " + arith +
9066 " would label a double answer with an arithmetic that never touched it");
9067 // 'parallel' asks the engine for INDEPENDENT REPLICATIONS and the mean
9068 // over them, which is what its parallel analyzer is; it is not a second
9069 // engine. It resolves to a replication count here, taking --ldes-
9070 // replications when given and 8 otherwise -- the same default the SSA
9071 // parallel analyzer uses. Mirrors SolverLDES.listValidMethods in every
9072 // codebase, which advertises exactly {default, para, parallel}, 'para'
9073 // being the short spelling of 'parallel' as in SolverSSA.
9074 if (!k.method.empty() && k.method != "default" && k.method != "para" && k.method != "parallel")
9076 "SolverLDES has the methods 'default' and 'parallel' (alias 'para'; listValidMethods returns "
9077 "exactly those in every codebase); got '" + k.method + "'");
9078
9079 if (k.tol >= 0.0 || k.iter_tol >= 0.0 || k.iter_max >= 0 || !k.multiserver.empty())
9081 "--tol, --iter_tol, --iter_max and --multiserver are the knobs of a fixed-point "
9082 "iteration; LDES simulates a sample path and iterates nothing. Its stopping rule "
9083 "is --samples, or --ldes-cnvgon with --ldes-cnvgtol");
9084 if (k.has_cutoff())
9086 "--cutoff truncates an enumerated state space; a simulator visits the states the "
9087 "sample path reaches and enumerates none");
9088 if (k.jobclass || !k.marg_states.empty() || (k.node && analysis != "prob"))
9090 "--class and --marg-states select a marginal law of one (node, class), and --node "
9091 "applies to -a prob beside --state; the other LDES arms report tables over every "
9092 "station and class");
9093 if (!k.state.empty() && (analysis != "prob" || !k.node))
9095 "--state is getProbAggr(node, state)'s per-class job count of ONE station: it "
9096 "applies to -s ldes -a prob and needs --node to say which");
9097 if (!k.notation.empty() || !k.symbolic.empty() || k.equilibria)
9099 "--notation, --symbolic and --equilibria describe an exported ODE document; LDES "
9100 "integrates no ODE");
9101 if (!k.cdf_algorithm.empty())
9103 "--cdf-algorithm selects between the two sojourn-time INVERSIONS of -s nc; the "
9104 "LDES response-time law is the ecdf of the samples the engine recorded and is not "
9105 "computed from a transform");
9106 if (k.no_interlocking || k.interlock_knobs_given() || k.repeat > 0 || !k.layer_solver.empty())
9108 "--no-interlocking, --interlock-*, --repeat and --layer-solver are options of the layered solver "
9109 "and apply to -i lqnx; the LDES layered path runs in the JAR's own ensemble "
9110 "backend and has no JSON interface to reach from here");
9111 if (!k.sens_method.empty() || !k.sens_scheme.empty() || k.sens_step >= 0.0)
9113 "--sens-method, --sens-scheme and --sens-step configure the layered sensitivity "
9114 "table; LDES reports no sensitivity");
9115 if (analysis == "tran" && !(k.t1 >= 0.0))
9116 throw line::InputError(
9117 "-s ldes -a tran needs --tspan <t1> or --tspan <t0>:<t1>: a trajectory over an unstated "
9118 "horizon is not a quantity, and the engine only records buckets once a timespan "
9119 "makes the run transient");
9120 if (k.t1 >= 0.0 && analysis != "tran")
9122 "--tspan names the horizon of -a tran; -a sample runs over [0, --samples], which "
9123 "is the horizon runTransientJson uses, and the other arms are steady state");
9124 // `-a tran-cdf-*` NAMES THE TRANSIENT LAW AND IS THE STEADY-STATE ONE
9125 // HERE, because a simulator has only the samples it observed: the
9126 // reference's `getTranCdfRespT` reads the same `respTimeSamples` its
9127 // `getCdfRespT` reads, and its `getTranCdfPassT` is a one-line delegation
9128 // to `getTranCdfRespT`. Warned rather than refused, so a script written
9129 // against the JAR runs and its author is told what the curve is.
9130 if ((analysis == "trancdf" || analysis == "trancdfpasst") &&
9131 k.verbosity != "silent")
9132 std::fprintf(stderr,
9133 "Warning: -a %s is the ecdf of the per-job response times the run "
9134 "observed, the same curve -a cdf reports; the reference's LDES "
9135 "getTranCdfRespT reads the same samples\n",
9136 analysis.c_str());
9137 // method='parallel' resolved to its replication count, after every
9138 // other knob has been validated against the caller's own Knobs.
9139 Knobs kldes = k;
9140 if ((kldes.method == "parallel" || kldes.method == "para") && kldes.ldes_replications <= 1)
9141 kldes.ldes_replications = 8;
9142 if (is_avg_view(analysis)) return solve_avg_view<double>(file, kldes, "ldes", analysis);
9143 return solve_model_ldes(file, kldes, analysis);
9144 }
9145 // SolverAUTO resolves to a real engine BEFORE the knob checks below, so a
9146 // model the chooser sends to SSA accepts --samples and one it sends to CTMC
9147 // accepts --cutoff: the checks must see the solver that will actually run.
9148 if (s == "auto") {
9149 // `chooseSolverHeur` picks an engine from a GETTER, and the age laws are
9150 // not one of its getters: a model whose table it would send to MVA does
9151 // not thereby have an AoI answer. Refusing by name here beats letting
9152 // the chosen engine refuse an analysis it was never asked about.
9153 if (analysis == "aoi")
9155 "-a aoi is the AoI branch of the fluid 'mfq' method and no other engine reports "
9156 "it, so SolverAUTO does not choose for it: ask for it by name with -s fluid");
9157 const AutoPlan plan = choose_auto_plan_dispatch(arith, file, analysis, k.method);
9158 // `delegate.m` runs the chosen solver and, when it fails, every other
9159 // feasible candidate in slot order. Reproduced here by re-entering this
9160 // dispatch with a CONCRETE method name, so each attempt is validated against
9161 // the knobs of the solver that will actually run it. Only the first
9162 // attempt carries the method the ranking gated on ('exact'): a method
9163 // name is a solver's own vocabulary and does not travel to the next.
9164 if (plan.order.size() == 1) {
9165 // A forced method name (a method family, or the Environment envelope)
9166 // leaves ONE solver, and its own diagnostic is then the whole
9167 // story: the reference rethrows it rather than wrapping it.
9168 Knobs kk = k;
9169 kk.method = plan.method;
9170 std::printf("SolverAUTO selected %s%s\n", plan.order[0].c_str(), plan.note.c_str());
9171 return solve_model_dispatch(arith, plan.order[0], analysis, file, kk);
9172 }
9173 std::string first_error;
9174 for (std::size_t i = 0; i < plan.order.size(); ++i) {
9175 Knobs kk = k;
9176 kk.method = (i == 0) ? plan.method : std::string();
9177 if (i == 0)
9178 std::printf("SolverAUTO selected %s%s\n", plan.order[i].c_str(),
9179 plan.note.c_str());
9180 else
9181 std::printf("SolverAUTO retrying with %s\n", plan.order[i].c_str());
9182 try {
9183 return solve_model_dispatch(arith, plan.order[i], analysis, file, kk);
9184 } catch (const line::UnsupportedError& e) {
9185 if (first_error.empty()) first_error = plan.order[i] + ": " + e.what();
9186 std::printf("SolverAUTO: %s cannot serve this run (%s)\n", plan.order[i].c_str(),
9187 e.what());
9188 }
9189 }
9191 "SolverAUTO: every candidate refused this run. The chosen engine reported -- " +
9192 first_error);
9193 }
9194 // ---- knobs the CHOSEN solver does not have are refused, not dropped ----
9195 // Accepting an option and discarding it is the one place the port would
9196 // answer a question it was not asked: the caller believes a value the
9197 // solver never saw. Every refusal below names the option and the solver.
9198 const bool is_sim = (s == "ssa");
9199 // `-s ctmc -a sample` walks the chain with an exponential clock, so it has a
9200 // run length and a stream in the same sense a simulation does; every other
9201 // CTMC analysis is a solve and still refuses both.
9202 // The NC cftp methods draw iid stationary states, so they too have a run
9203 // length and a stream, the NC `options.samples` / `options.seed`. They are
9204 // named here rather than through is_stochastic_method, which classifies
9205 // them as SolverCTMC did and is deliberately unchanged by their move.
9206 const bool is_cftp =
9207 (s == "nc" && (k.method == "cftp" || k.method == "cftp.approx"));
9208 // NC IS NOT ONE SOLVER HERE: its Monte Carlo integrators, logistic sampler,
9209 // importance-sampling estimators and Chen-O'Cinneide MCMC all read a run
9210 // length and a stream, which is why solve_model_nc wires k.samples and
9211 // k.seed into NcSolverOptions and says so in its own doc comment. This
9212 // guard contradicted that file for every NC method, so a caller naming one
9213 // of those methods was told 'nc' has no sample count -- the refusal that
9214 // made the statepr_sys_aggr_large [M2C] parity row skip, its MATLAB source
9215 // being NC(model,'seed',23000,'method','ls','samples',10000).
9216 // is_stochastic_method is the port of SolverNC.isStochasticMethod and
9217 // tokenizes on '.' and '/', so 'nc.ls' and 'default/imci' classify alike.
9218 // A method nobody named stays refused: 'default' RESOLVES to a stochastic
9219 // method only at runtime, and accepting a knob on the strength of what a
9220 // name might become is the same discard this block exists to prevent.
9221 const bool nc_draws = (s == "nc" && line::nc::is_stochastic_method(k.method));
9222 const bool draws_samples =
9223 is_sim || (s == "ctmc" && analysis == "sample") || is_cftp || nc_draws;
9224 if (k.samples && !draws_samples)
9225 throw line::UnsupportedError("--samples applies to the simulation solver (-s ssa), to "
9226 "-s ctmc -a sample and to the stochastic -s nc methods "
9227 "(mci, imci, ls, is, sampling, mcmc, cftp, cftp.approx); '" +
9228 s + "' has no sample count");
9229 if (k.seed && !draws_samples)
9230 throw line::UnsupportedError("--seed applies to the simulation solver (-s ssa), to "
9231 "-s ctmc -a sample and to the stochastic -s nc methods "
9232 "(mci, imci, ls, is, sampling, mcmc, cftp, cftp.approx); '" +
9233 s + "' draws no random numbers");
9234 // --mdd-tol / --mdd-maxiter drive the level iteration, which only the mdd
9235 // method runs. Accepting them elsewhere would let a caller believe a
9236 // tolerance was applied to a solve that has no iteration in it.
9237 if ((k.mdd_tol > 0.0 || k.mdd_maxiter > 0) && !(s == "ctmc" && k.method == "mdd"))
9239 "--mdd-tol and --mdd-maxiter set the coupled level iteration of -s ctmc --method mdd; "
9240 "'" + s + " / " + (k.method.empty() ? std::string("default") : k.method) +
9241 "' iterates no levels");
9242 // --tspan names a transient horizon, which only the CTMC transient analyses
9243 // have; accepting it elsewhere would let a caller believe a horizon was used.
9244 // The fluid solver integrates a forward equation too, and its horizon is what
9245 // `kp` reports its covariance AT, so --tspan reaches it as well; every other
9246 // fluid method restarts from its own end state until the moved mass stops
9247 // changing, and a horizon there caps that iteration rather than naming a time.
9248 if (k.t1 >= 0.0 &&
9249 !(s == "ctmc" &&
9250 (analysis == "tran" || analysis == "tranprob" || analysis == "tranreward")) &&
9251 !(s == "fluid" || s == "fld") && !(s == "mam" && analysis == "tran"))
9253 "--tspan sets the horizon of a transient analysis and applies to -s ctmc -a tran, "
9254 "-a tranprob and -a tranreward, to -s mam -a tran and to -s fluid; '" + s + " / " +
9255 analysis + "' integrates no forward equation");
9256 // --node narrows an answer to one node's block of the state, which only the
9257 // per-node CTMC queries and the per-node MAM queue-length law have; every
9258 // other analysis reports the whole network.
9259 // `-s ctmc -a prob` takes it TOGETHER WITH --state and only then: the arm
9260 // reports every station's marginal, so a bare --node would narrow nothing,
9261 // while --state names a row of ONE node's own space and needs --node to say
9262 // whose.
9263 if (k.node && !(s == "ctmc" && (analysis == "tranprob" || analysis == "sample")) &&
9264 !(s == "ctmc" && analysis == "prob" && !k.state.empty()) &&
9265 !(s == "nc" && analysis == "prob" && !k.state.empty()) &&
9266 !(s == "ssa" && analysis == "sample") && !(s == "mam" && analysis == "prob") &&
9267 !((s == "mva" || s == "nc") && analysis == "marg"))
9269 "--node selects the stateful node a state query is labelled by and applies to -s ctmc "
9270 "-a tranprob and -a sample, to -s ctmc|nc -a prob beside --state, to -s ssa -a sample, "
9271 "to -s mam -a prob and to -s mva|nc -a marg; '" + s + " / " + analysis +
9272 "' reports the whole network");
9273 // --class and --marg-states are the remaining two arguments of getProbMarg,
9274 // and nothing else in the surface takes either: every other analysis reports
9275 // all classes, and no other law is evaluated at a caller-chosen job count.
9276 if ((k.jobclass || !k.marg_states.empty()) && !(s == "mva" && analysis == "marg"))
9278 "--class and --marg-states are the job class and the state list of getProbMarg and "
9279 "apply to -s mva -a marg; '" + s + " / " + analysis +
9280 "' reports every class over its own range");
9281 // --notation selects which document the ODE export writes, and only the
9282 // export writes one; every other analysis reports numbers, which have no
9283 // notation to choose.
9284 if (!k.notation.empty() && !((s == "fluid" || s == "fld") && analysis == "odes"))
9286 "--notation selects the form of the exported ODE document and applies to -s fluid -a "
9287 "odes; '" + s + " / " + analysis + "' exports no equations");
9288 // --symbolic and --equilibria select the computer-algebra backend and ask it
9289 // to solve f(x) = 0; only the Jacobian consults one.
9290 if ((!k.symbolic.empty() || k.equilibria) &&
9291 !((s == "fluid" || s == "fld") && analysis == "jacobian"))
9293 "--symbolic selects the computer-algebra backend and --equilibria asks it for the "
9294 "solutions of f(x) = 0; both apply to -s fluid -a jacobian, and '" + s + " / " +
9295 analysis + "' consults no backend");
9296 // ---- the five JAR knobs, each refused where it would be accepted and never
9297 // read. Same discipline as every knob above: a caller who passed one to an
9298 // arm that does not consult it would believe a setting had been applied.
9299 // `-s jmt -a prob` reads it too, and reads it DIFFERENTLY: an exact chain is
9300 // indexed by the ENCODED row of a node's own state space, while a JMT log
9301 // records per-class job counts and nothing else, so there the vector is
9302 // `getProbAggr(node, state_a)`'s per-class count. Both are "the state this
9303 // probability is about"; which encoding it is in follows the solver.
9304 if (!k.state.empty() &&
9305 !(analysis == "prob" && (s == "ctmc" || s == "auto" || s == "jmt" || s == "nc")))
9307 "--state names the state `getProb(node, state)` asks about and applies to -a prob "
9308 "under -s ctmc, -s nc and -s jmt, the arms whose answer is indexed by a state; '" + s +
9309 " / " + analysis + "' reports a mean or a law over all of them");
9310 if (k.events && analysis != "sample")
9312 "--events is the length of ONE sampled trajectory and applies to -a sample; use "
9313 "--samples for a solver's run length ('" + s + " / " + analysis + "')");
9314 if (!k.percentiles.empty() && !(s == "mam" && (analysis == "cdf" || analysis == "cdfpasst" ||
9315 analysis == "perct")))
9317 "--percentiles names the levels getPerctRespT is read at and applies to -s mam -a "
9318 "perct-respt (and to the percentiles printed beside -a cdf); '" + s + " / " +
9319 analysis + "' inverts no response-time law");
9320 if (!k.reward_name.empty() && analysis != "rewardvalue")
9322 "--reward-name selects which declared reward -a reward-value returns the value "
9323 "function of; -a reward returns every reward's steady-state expectation and needs no "
9324 "name ('" + s + " / " + analysis + "')");
9325 if (k.timestep > 0.0 &&
9326 !(s == "ctmc" &&
9327 (analysis == "tran" || analysis == "tranprob" || analysis == "tranreward")))
9329 "--timestep is the fixed output grid of a transient CTMC solve, `options.timestep` of "
9330 "ctmc_transient.m, and applies to -s ctmc -a tran, -a tranprob and -a tranreward; the "
9331 "fluid "
9332 "and simulated transients report the points their own integrator or engine produced "
9333 "('" + s + " / " + analysis + "')");
9334 if ((!k.rate_sched.empty() || k.ctmc_tv_ngrid > 0) && !(s == "ctmc" && analysis == "tran"))
9336 "--rate-sched (and --ctmc-tv-ngrid) makes the CTMC generator time-inhomogeneous, "
9337 "`options.config.rate_sched` of solver_ctmc_transient_analyzer.m, and applies to "
9338 "-s ctmc -a tran only ('" + s + " / " + analysis + "')");
9339 if ((!k.transient_method.empty() || k.fau_epsilon > 0.0 || k.fau_delta >= 0.0) &&
9340 !(s == "ctmc" &&
9341 (analysis == "tran" || analysis == "tranprob" || analysis == "tranreward")))
9343 "--transient-method (and --fau-epsilon / --fau-delta) selects how the CTMC forward "
9344 "equation is advanced, `options.config.transient_method` of "
9345 "solver_ctmc_transient_analyzer.m, and applies to -s ctmc -a tran, -a tranprob and "
9346 "-a tranreward; every other analysis solves no forward equation ('" +
9347 s + " / " + analysis + "')");
9348 // --cdf-algorithm selects how the sojourn law is inverted, which only the NC
9349 // response-time distribution does; the CTMC one is read off tagged chains
9350 // and has no such choice.
9351 if (!k.cdf_algorithm.empty() && !(s == "nc" && analysis == "cdf"))
9353 "--cdf-algorithm selects the sojourn-time inversion of the NC response-time "
9354 "distribution and applies to -s nc -a cdf; '" + s + " / " + analysis +
9355 "' inverts no generating function");
9356 // The passage flags name the two state sets of getCdfFirstPassT and of
9357 // getFirstPassTMoments, which are the only two arms that time a state-set
9358 // passage. --passage-method selects the inversion and so belongs to the
9359 // curve alone; the moments involve no inversion at all.
9360 const bool passage_arm =
9361 (s == "ctmc" && (analysis == "firstpasst" || analysis == "firstpasstmom"));
9362 if ((!k.passage_from.empty() || !k.passage_into.empty() || k.passage_orders > 0) &&
9363 !passage_arm)
9365 "--passage-from, --passage-into and --passage-orders name the state sets and the "
9366 "moment order of -s ctmc -a firstpasst / firstpasstmom; '" + s + " / " + analysis +
9367 "' times no state-set passage");
9368 if (!k.passage_method.empty() && !(s == "ctmc" && analysis == "firstpasst"))
9370 "--passage-method selects the transform inversion of -s ctmc -a firstpasst; '" + s +
9371 " / " + analysis + "' inverts none (the moments arm solves for them directly)");
9372 // --perm-engine selects the permanent estimator, which only the NC joint law
9373 // of the per-station totals uses; nothing else in the tree evaluates one.
9374 if (k.method_perm != "exact" && !(s == "nc" && analysis == "sysmarg"))
9376 "--perm-engine selects the permanent estimator of the NC joint total-queue-length "
9377 "law and applies to -s nc -a sysmarg; '" + s + " / " + analysis +
9378 "' evaluates no permanent");
9379 // The layered path's own knobs, refused here for the same reason every other
9380 // knob above is: a caller who passed one to a Network solve would believe a
9381 // setting was applied that no Network solver has. --sens-* is the exception:
9382 // getSensitivityTable is a @@NetworkSolver method, so it selects the branch
9383 // of `-s nc -a sens` as much as of the layered one.
9384 const bool nc_sens = s == "nc" && analysis == "sens";
9385 if (!nc_sens && (!k.sens_method.empty() || !k.sens_scheme.empty() || k.sens_step > 0.0))
9387 "--sens-method, --sens-scheme and --sens-step select the branch of a sensitivity "
9388 "table and apply to -i lqnx -a sens or to -s nc -a sens; '" + s + " / " + analysis +
9389 "' differentiates nothing");
9390 if (k.no_interlocking || k.interlock_knobs_given() || k.repeat > 0 || !k.layer_solver.empty() ||
9391 !k.ln_transient.empty() || !k.ln_transient_channels.empty())
9393 "--no-interlocking, --interlock-*, --repeat, --layer-solver and --ln-transient* are options "
9394 "of the layered solver and apply to -i lqnx; a Network model has no layers to "
9395 "interlock");
9396 if (s == "ba" && (k.tol >= 0.0 || k.iter_tol >= 0.0 || k.iter_max >= 0))
9398 "--tol, --iter_tol and --iter_max do not apply to -s ba: a bound is a closed form, "
9399 "with nothing to converge");
9400 // Silent acceptance is the defect these guard against: a caller who passed
9401 // a QRF table to another solver would believe a parameterisation was
9402 // applied that nothing read.
9403 if (s != "ba" && (!k.qrf_params.empty() || !k.qrf_alpha.empty()))
9405 "--qrf-params and --qrf-alpha parameterise the QRF reduction bounds and apply to "
9406 "-s ba; '" + s + "' solves no reduction program");
9407 if (s != "ba" && k.level > 0)
9409 "--level is the hierarchy level of the SolverBA bound families and applies to -s ba; "
9410 "'" + s + "' has no bound hierarchy");
9411 if (is_sim && (k.tol >= 0.0 || k.iter_tol >= 0.0 || k.iter_max >= 0))
9413 "--tol, --iter_tol and --iter_max do not apply to -s ssa: a sample path is not an "
9414 "iteration; use --samples to set its length");
9415 if (s == "mam" && k.iter_tol >= 0.0)
9417 "--iter_tol is not a SolverMAM option (MamOptions carries tol and iter_max); "
9418 "use --tol");
9419 if (s == "ag" && k.iter_tol >= 0.0)
9421 "--iter_tol is not a SolverAG option (AgOptions carries tol and iter_max, the "
9422 "tolerance and the sweep budget of the reversed-rate fixed point); use --tol");
9423 // --max-states BOUNDS AN OPEN AGENT'S QUEUE-LENGTH DIMENSION, and only the
9424 // RCAT agents have one: a closed class is bounded by its own population
9425 // instead, and 'inapinf' ignores the level entirely and solves the open
9426 // agents on the infinite state space. Accepting it elsewhere would be the
9427 // silent-acceptance defect these guards exist for -- a caller who passed it
9428 // to -s ctmc would believe a truncation applied that nothing truncated.
9429 if (k.max_states >= 0 && s != "ag")
9431 "--max-states truncates the queue-length dimension of a SolverAG agent and applies "
9432 "to -s ag; '" + s + "' truncates no agent (use --cutoff for a CTMC state space)");
9433 // The cutoff BOUNDS A STATE SPACE, and only SolverCTMC and the MAM
9434 // queue-length law have one -- the latter because an OPEN queue's level
9435 // process is unbounded and `getProb` has to stop somewhere. Accepting it
9436 // elsewhere would be the silent-acceptance defect: a caller who passed it to
9437 // -s mva would believe the answer was truncated when nothing truncated it.
9438 if (k.has_cutoff() && s != "ctmc" && !(s == "mam" && analysis == "prob") && s != "env")
9440 "--cutoff bounds the open population of a CTMC state space, and the level truncation "
9441 "of -s mam -a prob; '" + s + " / " + analysis + "' enumerates no states");
9442 // THE MATRIX SPELLING IS NARROWER THAN THE SCALAR ONE. Only the CTMC state
9443 // space is enumerated per station, so only it can honour a per-station
9444 // bound; the MAM level truncation and an environment's stage cutoff are one
9445 // number each. Refused rather than reduced to a maximum, because that
9446 // silently answers a LARGER chain than the caller asked for.
9447 if (!k.cutoff_mat.empty() && s != "ctmc")
9449 "--cutoff as a per-(station,class) matrix bounds an enumerated state space per "
9450 "station and applies to -s ctmc; '" + s + "' takes one number");
9451 // `--stage-solver` names the solver each STAGE of an environment is run
9452 // with, and nothing else has stages: a layer of an LQN takes
9453 // `--layer-solver`, which is a different set for a different reason (a
9454 // layer is solved in steady state, a stage transiently).
9455 if (!k.stage_solver.empty() && s != "env")
9457 "--stage-solver names the solver each stage of a random environment is run with and "
9458 "applies to -s env; '" + s + "' has no stages");
9459 if (!k.stage_solver.empty() && k.stage_solver != "fluid" && k.stage_solver != "ctmc" &&
9460 k.stage_solver != "mam")
9462 "--stage-solver '" + k.stage_solver +
9463 "' is not available: the environment coupling needs a TRANSIENT stage solve, and only "
9464 "the fluid analyzer, the enumerated CTMC and the flattened LD-QBD provide one in this "
9465 "port");
9466 // The three `--map-env` knobs. Refused beside `-s ba`, whose bounds the
9467 // environment image does not bracket, and beside `-s env`, which IS the
9468 // environment solver and has a model with stages already.
9469 if (!k.map_env.empty() && k.map_env != "default" && k.map_env != "off")
9470 throw line::UnsupportedError("--map-env '" + k.map_env +
9471 "' is not a value: use 'default' or 'off'");
9472 if (!k.map_env_method.empty() && k.map_env_method != "auto" && k.map_env_method != "dec" &&
9473 k.map_env_method != "avg" && k.map_env_method != "meanfield")
9475 "--map-env-method '" + k.map_env_method +
9476 "' is not an environment recombination: use 'auto', 'meanfield', 'dec' or 'avg'");
9477 if ((!k.map_env.empty() || !k.map_env_method.empty() || k.map_env_maxstages) &&
9478 (s == "ba" || s == "env"))
9480 "--map-env* asks a solver to fall back on a random-environment image of a non-renewal "
9481 "process; '-s " + s +
9482 "' takes no such fallback (a bound must not be computed on an approximation, and the "
9483 "environment solver already takes a model with stages)");
9484 // `mam` IS THE STATE-VECTOR COUPLING'S BACKEND ONLY. The mean-field one
9485 // carries marginal means and reads them off a transient mean the LD-QBD
9486 // reduction does not produce; accepting it there would run the CTMC
9487 // ensemble under the MAM name.
9488 if (k.stage_solver == "mam" && k.method != "statevec")
9490 "--stage-solver mam applies to -s env --method statevec: the LD-QBD backend flattens "
9491 "its blocks into a generator the state-vector coupling propagates a distribution "
9492 "across, and the mean-field coupling carries marginal MEANS instead");
9493 // The two FJ_codes knobs configure ONE analyzer, solver_mam_fj, which the
9494 // MAM dispatch reaches on a homogeneous fork-join model. Accepting them
9495 // anywhere else would let a caller believe an accuracy setting had been
9496 // honoured by a solver that never read it.
9497 if ((k.fj_accuracy > 0 || !k.fj_tmode.empty()) && s != "mam")
9499 "--fj-accuracy and --fj-tmode configure the FJ_codes fork-join approximation of "
9500 "solver_mam_fj.m and apply to -s mam; '" + s + "' does not run it");
9501 // --timescale gates the slotted branch of the MAM dispatch alone. SolverNC
9502 // has its own discrete product form and takes --slotted for it, so a
9503 // caller that names a time scale for any other solver is told rather than
9504 // silently answered on the continuous one.
9505 if (!k.timescale.empty() && s != "mam")
9507 "--timescale selects the time scale of the MAM discrete-time path and applies to "
9508 "-s mam; '" + s + "' does not read it (SolverNC takes --slotted)");
9509 // ---- `-a node`, ahead of the per-solver whitelists ---------------------
9510 // getAvgNodeTable is @@NetworkSolver's, not any one solver's: it is the
9511 // station table scattered to the node index space plus the two flow columns
9512 // recomputed from it, so every solver that produces an AvgResult can answer
9513 // it and none of them needs its own arm.
9514 // `-a node`, `-a sys`, `-a chain` and `-a nodechain` are the four
9515 // @@NetworkSolver tables that are VIEWS of one solved AvgResult -- scattered
9516 // to nodes, aggregated to chains, or reduced to the reference station -- so
9517 // they share the engine whitelist and the arithmetic ladder. Adding a
9518 // per-arm copy of either would let the four drift on which solvers and
9519 // which arithmetics they accept, for tables built from the same numbers.
9520 // `-s ssa` and `-s fluid` return their own solution types rather than an
9521 // AvgResult; `run_avg_engine` bridges them (`avg_result_from_sim`), so the
9522 // reference's rule holds here too -- a solver that reports an AvgTable
9523 // reports its four views. The two external wrappers obey the same rule and
9524 // are dispatched in their own arms above, which run before this one: they
9525 // validate knobs the ladder here knows nothing about, and LDES is handed the
9526 // model DOCUMENT rather than a parsed struct.
9527 if (analysis == "node" || analysis == "sys" || analysis == "chain" ||
9528 analysis == "nodechain" || analysis == "cache" || analysis == "item") {
9529 const bool is_sim_engine = (s == "ssa" || s == "fluid");
9530 // The two cache tables are read off the SAME solved result, so they
9531 // belong to the same group; SolverNC is the only engine here whose
9532 // branches fill `AvgResult::cache`, and the arms say so when it is
9533 // empty rather than being whitelisted to nc alone -- `-s auto` on a
9534 // cache model resolves to nc, and refusing the token would refuse the
9535 // model.
9536 //
9537 // BOTH SIMULATORS ARE ADMITTED TO `-a cache`, and only there.
9538 // `run_avg_engine` fills `r.cache` for `-s ssa` from
9539 // `cache_metrics_of_ssa` -- the realized hit, delayed-hit and miss
9540 // SHARES the sample path measured -- and for `-s fluid` from the
9541 // cacheqn decomposition's converged split, which is the same quantity
9542 // its refreshed struct is renormalized at. Both are how
9543 // `SSA(model).getAvgCacheTable()` and `Fluid(model).getAvgCacheTable()`
9544 // answer in the reference, which reads them off the node the analyzer
9545 // wrote. `-a item` stays refused for both: the per-item occupancy is a
9546 // recursion of the
9547 // NC/MVA cache branches and no simulator forms it, so admitting it
9548 // would report an empty table for a quantity that was never measured.
9549 const bool cache_table = (analysis == "cache" || analysis == "item");
9550 const bool sim_cache_ok = (analysis == "cache");
9551 // `-a cache` and `-a item` are the CACHE tables, which RCAT does not
9552 // form -- so `ag` joins the AvgResult views and not those two.
9553 const bool ag_view = (s == "ag" && !cache_table);
9554 if ((s != "mva" && s != "auto" && s != "nc" && s != "mam" && s != "ba" && s != "ctmc" &&
9555 !ag_view && !is_sim_engine) ||
9556 (cache_table && is_sim_engine && !sim_cache_ok))
9558 // `-a node`'s own wording is kept verbatim: it is the message a
9559 // caller has been reading since the arm existed, and the group
9560 // it now shares does not change what it says.
9561 (analysis == "node"
9562 ? std::string("-a node reports the per-node table of -s mva, nc, mam, ag, ba, "
9563 "ctmc, ssa, fluid, jmt, ldes and auto; '")
9564 : "-a " + analysis +
9565 " is a view of the station AvgResult and is reported by -s mva, nc, "
9566 "mam, " + (analysis == "item" ? "" : "ag, ") + "ba, ctmc" +
9567 (analysis == "item" ? "" : ", ssa, fluid, jmt, ldes") + " and auto; '") +
9568 s + "' does not return the station AvgResult it is built from");
9569 // The same refusal their own `-a avg` arms raise, for the same reason:
9570 // an SSA sample path is generated from exponential clocks and a fluid
9571 // trajectory is integrated by LSODA, so neither is carried by an exact
9572 // or an extended-precision backend. Refused BY NAME here rather than
9573 // narrowed silently in the ladder below.
9574 if (is_sim_engine && arith != "double")
9576 "-a " + analysis + " under -s " + s +
9577 " is read off a " + (s == "ssa" ? "sample path" : "fluid trajectory") +
9578 ", which is transcendental; rerun with --arith double (got '" + arith + "')");
9579 const std::string eng = (s == "auto") ? std::string("mva") : s;
9580#define LINE_CLI_TABLE_LADDER(FN) \
9581 do { \
9582 if (arith == "double") return FN<double>(file, k, eng); \
9583 if (arith == "exact") return FN<line::Rational>(file, k, eng); \
9584 if (arith == "real:16") return FN<line::Real<16> >(file, k, eng); \
9585 if (arith == "real" || arith == "real:32") return FN<line::Real<32> >(file, k, eng); \
9586 if (arith == "real:64") return FN<line::Real<64> >(file, k, eng); \
9587 if (arith == "real:128") return FN<line::Real<128> >(file, k, eng); \
9588 if (arith == "real:256") return FN<line::Real<256> >(file, k, eng); \
9589 } while (0)
9590 if (analysis == "node") LINE_CLI_TABLE_LADDER(solve_model_node);
9591 if (analysis == "sys") LINE_CLI_TABLE_LADDER(solve_model_sys);
9592 if (analysis == "chain") LINE_CLI_TABLE_LADDER(solve_model_chain);
9593 if (analysis == "nodechain") LINE_CLI_TABLE_LADDER(solve_model_nodechain);
9594 if (analysis == "cache") LINE_CLI_TABLE_LADDER(solve_model_cache);
9595 if (analysis == "item") LINE_CLI_TABLE_LADDER(solve_model_item);
9596#undef LINE_CLI_TABLE_LADDER
9597 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9598 }
9599 if (s == "ctmc") {
9600 // The CTMC surface the port reaches: the AvgTable, plus the @@SolverCTMC
9601 // methods that are not means -- the generator and the state space
9602 // (getInfGen, getStateSpace), the transient occupancy (getTranProbSysAggr),
9603 // a marked trajectory (sampleSys), the declared rewards (getAvgReward),
9604 // the response-time laws (getCdfRespT, getCdfSysRespT) and the parametric
9605 // sensitivity (getSensitivityRanking).
9606 if (analysis != "avg" && analysis != "prob" && analysis != "gen" && analysis != "states" &&
9607 analysis != "tran" && analysis != "tranprob" && analysis != "tranreward" &&
9608 analysis != "sample" && analysis != "reward" && analysis != "rewardvalue" &&
9609 analysis != "cdf" && analysis != "sens" && analysis != "firstpasst" &&
9610 analysis != "firstpasstmom")
9612 "the CTMC solver ports -a avg, node, sys, chain, nodechain, prob, gen, states, "
9613 "tran, tranprob, tranreward, sample, reward, reward-value, cdf, first-passt, "
9614 "first-passt-moments and sens (got '" + analysis + "')");
9615 // The generator-free method answers MEANS and nothing else: mdd holds
9616 // the reachable set in a diagram, so there is no state space to list, no
9617 // filtration to split and no trajectory to walk. Refused by name rather
9618 // than served from the enumerated chain, which would report an answer
9619 // under a method that did not produce it.
9620 if (k.method == "mdd" && analysis != "avg")
9622 "the '" + k.method +
9623 "' method never builds the explicit generator, so it serves -a avg only (got '" +
9624 analysis + "'); use --method default for the state-space analyses");
9625 if (k.tol >= 0.0 || k.iter_tol >= 0.0 || k.iter_max >= 0)
9627 "--tol, --iter_tol and --iter_max do not apply to -s ctmc: the stationary vector "
9628 "is obtained by a direct solve of pi Q = 0, with nothing to converge. The mdd "
9629 "method's level iteration has --mdd-tol and --mdd-maxiter of its own");
9630 // Every step from the generator to the means is a field operation, so
9631 // there is no arithmetic to refuse: exact returns the exact rational
9632 // stationary law. The transient analyses refuse inside, by name.
9633 if (arith == "double") return solve_model_ctmc<double>(file, k, analysis);
9634 if (arith == "exact") return solve_model_ctmc<line::Rational>(file, k, analysis);
9635 if (arith == "real:16") return solve_model_ctmc<line::Real<16> >(file, k, analysis);
9636 if (arith == "real" || arith == "real:32")
9637 return solve_model_ctmc<line::Real<32> >(file, k, analysis);
9638 if (arith == "real:64") return solve_model_ctmc<line::Real<64> >(file, k, analysis);
9639 if (arith == "real:128") return solve_model_ctmc<line::Real<128> >(file, k, analysis);
9640 if (arith == "real:256") return solve_model_ctmc<line::Real<256> >(file, k, analysis);
9641 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9642 }
9643
9644 // ---- the solvers that carry their own arithmetic restriction -----------
9645 // Each refuses BY NAME rather than being narrowed silently, and the reason
9646 // is the solver's own, not a limitation of the CLI.
9647 if (s == "mam") {
9648 // The @@SolverMAM surface the port reaches: the AvgTable, the
9649 // queue-length law (getProb / getProbMarg), the response-time law
9650 // (getCdfRespT and its getSjrnT / sjrnT aliases, plus getPerctRespT),
9651 // the transient means (getTranAvg) and the M/G/1-type internals of the
9652 // queue (getMAMResult).
9653 if (analysis != "avg" && analysis != "prob" && analysis != "cdf" &&
9654 analysis != "cdfpasst" && analysis != "perct" && analysis != "tran" &&
9655 analysis != "internals")
9657 "the MAM solver ports -a avg, node, prob, cdf, cdf-passt, perct-respt, tran and "
9658 "internals (got '" + analysis + "')");
9659 if (arith != "double")
9661 "the MAM solver fits phase-type representations, whose fitter requires "
9662 "transcendental arithmetic; rerun with --arith double (got '" + arith + "')");
9663 if (analysis == "tran" && k.t1 < 0.0)
9665 "-s mam -a tran integrates the transient queue length over a horizon and there is "
9666 "no default for it; pass --tspan t0 t1");
9667 if (analysis == "prob") return solve_model_mam_prob<double>(file, k);
9668 if (analysis == "cdf") return solve_model_mam_cdf<double>(file, k, "cdf", "CdfRespT");
9669 // `getCdfPassT` IS `getCdfRespT` here, and that is the reference's
9670 // construction rather than an alias invented in the CLI: SolverMAM.java
9671 // computes both from the SAME `solver_mam_passage_time(sn, sn.proc,
9672 // options)` call. It is emitted under its own key so a caller that
9673 // asked the passage-time question is answered it, and the payload's
9674 // `type` records which of the two names produced the curve.
9675 if (analysis == "cdfpasst")
9676 return solve_model_mam_cdf<double>(file, k, "cdfpasst", "CdfPassT");
9677 if (analysis == "perct") return solve_model_mam_perct<double>(file, k);
9678 if (analysis == "tran") return solve_model_mam_tran<double>(file, k);
9679 if (analysis == "internals") return solve_model_mam_internals<double>(file, k);
9680 return solve_model_mam<double>(file, k);
9681 }
9682 if (s == "ssa") {
9683 // `-a cdf` is refused BY NAME rather than falling into the generic
9684 // message, because the refusal is the reference's own answer and not a
9685 // port gap: `@@SolverSSA/getCdfRespT.m` raises the same error, since SSA
9686 // samples state trajectories and not per-job sojourn times.
9687 if (analysis == "cdf") line::ssa::ssa_cdf_respt_refuse();
9688 if (analysis != "avg" && analysis != "prob" && analysis != "sample")
9689 throw line::UnsupportedError("the SSA solver ports -a avg, -a prob and -a sample (got '" +
9690 analysis + "')");
9691 if (arith != "double")
9693 "an SSA sample path is generated from exponential clocks, which are "
9694 "transcendental; rerun with --arith double (got '" + arith + "')");
9695 if (analysis == "prob") return solve_model_ssa_prob<double>(file, k);
9696 if (analysis == "sample") return solve_model_ssa_sample<double>(file, k);
9697 return solve_model_ssa<double>(file, k);
9698 }
9699 if (s == "nc") {
9700 if (analysis != "avg" && analysis != "prob" && analysis != "marg" &&
9701 analysis != "sysmarg" && analysis != "cdf" && analysis != "sens" &&
9702 analysis != "normconst" && analysis != "busyperiod")
9704 "the NC solver ports -a avg, -a node, -a prob, -a marg, -a sysmarg, -a cdf, "
9705 "-a sens, -a normconst and -a busyperiod (got '" + analysis + "')");
9706 // The perfect sampler answers MEANS from iid stationary draws and yields
9707 // no normalizing constant, so none of the other NC analyses has anything
9708 // to be computed from. Refused by name rather than served by a different
9709 // NC route under the caller's method.
9710 if ((k.method == "cftp" || k.method == "cftp.approx") && analysis != "avg")
9712 "the '" + k.method +
9713 "' method draws stationary states and yields no normalizing constant, so it "
9714 "serves -a avg only (got '" + analysis + "'); use another --method for the "
9715 "other NC analyses");
9716 if (analysis == "busyperiod") {
9717 if (arith == "double") return solve_model_nc_busyp<double>(file, k);
9718 if (arith == "exact") return solve_model_nc_busyp<line::Rational>(file, k);
9719 if (arith == "real:16") return solve_model_nc_busyp<line::Real<16> >(file, k);
9720 if (arith == "real" || arith == "real:32")
9721 return solve_model_nc_busyp<line::Real<32> >(file, k);
9722 if (arith == "real:64") return solve_model_nc_busyp<line::Real<64> >(file, k);
9723 if (arith == "real:128") return solve_model_nc_busyp<line::Real<128> >(file, k);
9724 if (arith == "real:256") return solve_model_nc_busyp<line::Real<256> >(file, k);
9725 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9726 }
9727 if (analysis == "sysmarg") {
9728 if (arith == "double") return solve_model_nc_sysmarg<double>(file, k);
9729 if (arith == "exact") return solve_model_nc_sysmarg<line::Rational>(file, k);
9730 if (arith == "real:16") return solve_model_nc_sysmarg<line::Real<16> >(file, k);
9731 if (arith == "real" || arith == "real:32")
9732 return solve_model_nc_sysmarg<line::Real<32> >(file, k);
9733 if (arith == "real:64") return solve_model_nc_sysmarg<line::Real<64> >(file, k);
9734 if (arith == "real:128") return solve_model_nc_sysmarg<line::Real<128> >(file, k);
9735 if (arith == "real:256") return solve_model_nc_sysmarg<line::Real<256> >(file, k);
9736 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9737 }
9738 if (analysis == "marg") {
9739 if (arith == "double") return solve_model_nc_marg<double>(file, k);
9740 if (arith == "exact") return solve_model_nc_marg<line::Rational>(file, k);
9741 if (arith == "real:16") return solve_model_nc_marg<line::Real<16> >(file, k);
9742 if (arith == "real" || arith == "real:32")
9743 return solve_model_nc_marg<line::Real<32> >(file, k);
9744 if (arith == "real:64") return solve_model_nc_marg<line::Real<64> >(file, k);
9745 if (arith == "real:128") return solve_model_nc_marg<line::Real<128> >(file, k);
9746 if (arith == "real:256") return solve_model_nc_marg<line::Real<256> >(file, k);
9747 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9748 }
9749 // Served under NC too so `-s auto -a normconst`, which chooseSolverHeur
9750 // sends to NC on a product-form model, lands on an arm that answers.
9751 if (analysis == "normconst") {
9752 if (arith == "double") return solve_model_normconst<double>(file, k, s);
9753 if (arith == "exact") return solve_model_normconst<line::Rational>(file, k, s);
9754 if (arith == "real:16") return solve_model_normconst<line::Real<16> >(file, k, s);
9755 if (arith == "real" || arith == "real:32")
9756 return solve_model_normconst<line::Real<32> >(file, k, s);
9757 if (arith == "real:64") return solve_model_normconst<line::Real<64> >(file, k, s);
9758 if (arith == "real:128") return solve_model_normconst<line::Real<128> >(file, k, s);
9759 if (arith == "real:256") return solve_model_normconst<line::Real<256> >(file, k, s);
9760 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9761 }
9762 if (analysis == "sens") {
9763 if (arith == "double") return solve_model_nc_sens<double>(file, k);
9764 if (arith == "exact") return solve_model_nc_sens<line::Rational>(file, k);
9765 if (arith == "real:16") return solve_model_nc_sens<line::Real<16> >(file, k);
9766 if (arith == "real" || arith == "real:32")
9767 return solve_model_nc_sens<line::Real<32> >(file, k);
9768 if (arith == "real:64") return solve_model_nc_sens<line::Real<64> >(file, k);
9769 if (arith == "real:128") return solve_model_nc_sens<line::Real<128> >(file, k);
9770 if (arith == "real:256") return solve_model_nc_sens<line::Real<256> >(file, k);
9771 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9772 }
9773 if (analysis == "cdf") {
9774 if (arith == "double") return solve_model_nc_cdf<double>(file, k);
9775 if (arith == "exact") return solve_model_nc_cdf<line::Rational>(file, k);
9776 if (arith == "real:16") return solve_model_nc_cdf<line::Real<16> >(file, k);
9777 if (arith == "real" || arith == "real:32")
9778 return solve_model_nc_cdf<line::Real<32> >(file, k);
9779 if (arith == "real:64") return solve_model_nc_cdf<line::Real<64> >(file, k);
9780 if (arith == "real:128") return solve_model_nc_cdf<line::Real<128> >(file, k);
9781 if (arith == "real:256") return solve_model_nc_cdf<line::Real<256> >(file, k);
9782 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9783 }
9784 if (analysis == "prob") {
9785 if (arith == "double") return solve_model_nc_prob<double>(file, k);
9786 if (arith == "exact") return solve_model_nc_prob<line::Rational>(file, k);
9787 if (arith == "real:16") return solve_model_nc_prob<line::Real<16> >(file, k);
9788 if (arith == "real" || arith == "real:32")
9789 return solve_model_nc_prob<line::Real<32> >(file, k);
9790 if (arith == "real:64") return solve_model_nc_prob<line::Real<64> >(file, k);
9791 if (arith == "real:128") return solve_model_nc_prob<line::Real<128> >(file, k);
9792 if (arith == "real:256") return solve_model_nc_prob<line::Real<256> >(file, k);
9793 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9794 }
9795 if (arith == "double") return solve_model_nc<double>(file, k);
9796 if (arith == "exact") return solve_model_nc<line::Rational>(file, k);
9797 if (arith == "real:16") return solve_model_nc<line::Real<16> >(file, k);
9798 if (arith == "real" || arith == "real:32") return solve_model_nc<line::Real<32> >(file, k);
9799 if (arith == "real:64") return solve_model_nc<line::Real<64> >(file, k);
9800 if (arith == "real:128") return solve_model_nc<line::Real<128> >(file, k);
9801 if (arith == "real:256") return solve_model_nc<line::Real<256> >(file, k);
9802 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9803 }
9804 if (s == "ag") {
9805 // The @@SolverAG surface the port reaches: the AvgTable and its four
9806 // views, plus -a cdf as the inherited base-class exponential fallback.
9807 // RCAT converges a fixed point over the synchronization rates and
9808 // reports mean measures; it forms no state probability and no
9809 // transient, so there is nothing else to expose.
9810 if (analysis != "avg" && analysis != "cdf")
9812 "the AG solver ports -a avg (with its views -a node, -a sys, -a chain and "
9813 "-a nodechain) and -a cdf, the inherited exponential fallback: RCAT converges a "
9814 "fixed point over the synchronization rates and reports mean measures, forming no "
9815 "state probability or transient (got '" + analysis + "')");
9816 if (analysis == "cdf") {
9817 if (arith == "double") return solve_model_ag_cdf<double>(file, k);
9818 if (arith == "exact") return solve_model_ag_cdf<line::Rational>(file, k);
9819 if (arith == "real:16") return solve_model_ag_cdf<line::Real<16> >(file, k);
9820 if (arith == "real" || arith == "real:32")
9821 return solve_model_ag_cdf<line::Real<32> >(file, k);
9822 if (arith == "real:64") return solve_model_ag_cdf<line::Real<64> >(file, k);
9823 if (arith == "real:128") return solve_model_ag_cdf<line::Real<128> >(file, k);
9824 if (arith == "real:256") return solve_model_ag_cdf<line::Real<256> >(file, k);
9825 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9826 }
9827 if (arith == "double") return solve_model_ag<double>(file, k);
9828 if (arith == "exact") return solve_model_ag<line::Rational>(file, k);
9829 if (arith == "real:16") return solve_model_ag<line::Real<16> >(file, k);
9830 if (arith == "real" || arith == "real:32") return solve_model_ag<line::Real<32> >(file, k);
9831 if (arith == "real:64") return solve_model_ag<line::Real<64> >(file, k);
9832 if (arith == "real:128") return solve_model_ag<line::Real<128> >(file, k);
9833 if (arith == "real:256") return solve_model_ag<line::Real<256> >(file, k);
9834 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9835 }
9836 if (s == "ba") {
9837 if (analysis != "avg" && analysis != "bounds" && analysis != "cdf")
9838 throw line::UnsupportedError("the BA solver ports -a avg, -a node, -a bounds and "
9839 "-a cdf, the inherited exponential fallback (got '" +
9840 analysis + "')");
9841 if (analysis == "cdf") {
9842 if (arith == "double") return solve_model_ba_cdf<double>(file, k);
9843 if (arith == "exact") return solve_model_ba_cdf<line::Rational>(file, k);
9844 if (arith == "real:16") return solve_model_ba_cdf<line::Real<16> >(file, k);
9845 if (arith == "real" || arith == "real:32")
9846 return solve_model_ba_cdf<line::Real<32> >(file, k);
9847 if (arith == "real:64") return solve_model_ba_cdf<line::Real<64> >(file, k);
9848 if (arith == "real:128") return solve_model_ba_cdf<line::Real<128> >(file, k);
9849 if (arith == "real:256") return solve_model_ba_cdf<line::Real<256> >(file, k);
9850 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9851 }
9852 if (analysis == "bounds") {
9853 if (arith == "double") return solve_model_ba_bounds<double>(file, k);
9854 if (arith == "exact") return solve_model_ba_bounds<line::Rational>(file, k);
9855 if (arith == "real:16") return solve_model_ba_bounds<line::Real<16> >(file, k);
9856 if (arith == "real" || arith == "real:32")
9857 return solve_model_ba_bounds<line::Real<32> >(file, k);
9858 if (arith == "real:64") return solve_model_ba_bounds<line::Real<64> >(file, k);
9859 if (arith == "real:128") return solve_model_ba_bounds<line::Real<128> >(file, k);
9860 if (arith == "real:256") return solve_model_ba_bounds<line::Real<256> >(file, k);
9861 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9862 }
9863 if (arith == "double") return solve_model_ba<double>(file, k);
9864 if (arith == "exact") return solve_model_ba<line::Rational>(file, k);
9865 if (arith == "real:16") return solve_model_ba<line::Real<16> >(file, k);
9866 if (arith == "real" || arith == "real:32") return solve_model_ba<line::Real<32> >(file, k);
9867 if (arith == "real:64") return solve_model_ba<line::Real<64> >(file, k);
9868 if (arith == "real:128") return solve_model_ba<line::Real<128> >(file, k);
9869 if (arith == "real:256") return solve_model_ba<line::Real<256> >(file, k);
9870 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9871 }
9872 if (s == "lqns") {
9873 if (analysis != "avg" && analysis != "cdf")
9875 "SolverLQNS on a Network reports -a avg and -a cdf, the inherited exponential "
9876 "fallback: "
9877 "qnsolver returns one chain-level table of means and computes no state "
9878 "probability and no transient (got '" + analysis + "')");
9879 // The numbers arrive as the decimal text qnsolver printed, so every
9880 // digit past double is one this port invented; the ladder is refused
9881 // rather than run at a width the answer does not have.
9882 if (arith != "double")
9884 "SolverLQNS reads its results back as the decimal text an external binary printed, "
9885 "which is double at best; --arith " + arith +
9886 " would report a precision the tool never produced");
9887 if (analysis == "cdf") return solve_model_lqns_network_cdf(file, k);
9888 return solve_model_lqns_network<double>(file, k);
9889 }
9890 if (s == "fluid" || s == "fld") {
9891 if (analysis != "avg" && analysis != "odes" && analysis != "var" &&
9892 analysis != "tranvar" && analysis != "jacobian" && analysis != "tran" &&
9893 analysis != "prob" && analysis != "cdf" && analysis != "aoi" &&
9894 analysis != "statevec")
9896 "the fluid solver ports -a avg, -a tran, -a tranvar, -a prob, -a cdf, -a aoi, "
9897 "-a odes, -a statevec, -a var and -a jacobian (got '" + analysis + "')");
9898 // The drift is integrated by LSODA, whose coefficients assume double;
9899 // a higher-precision request is refused rather than quietly narrowed.
9900 if (arith != "double")
9902 "the fluid solver integrates its drift with LSODA, which is double precision by "
9903 "construction; rerun with --arith double (got '" + arith + "')");
9904 if (analysis == "odes") return solve_model_fluid_odes<double>(file, k);
9905 if (analysis == "statevec") return solve_model_fluid_statevec<double>(file, k);
9906 if (analysis == "jacobian") return solve_model_fluid_jacobian<double>(file, k);
9907 if (analysis == "var") return solve_model_fluid_var<double>(file, k);
9908 if (analysis == "tranvar") return solve_model_fluid_tranvar<double>(file, k);
9909 if (analysis == "tran") return solve_model_fluid_tran<double>(file, k);
9910 if (analysis == "prob") return solve_model_fluid_prob<double>(file, k);
9911 if (analysis == "cdf") return solve_model_fluid_cdf<double>(file, k);
9912 if (analysis == "aoi") return solve_model_fluid_aoi<double>(file, k);
9913 return solve_model_fluid<double>(file, k);
9914 }
9915 if (analysis == "prob") {
9916 if (arith == "double") return solve_model_prob<double>(file, k);
9917 if (arith == "exact") return solve_model_prob<line::Rational>(file, k);
9918 if (arith == "real:16") return solve_model_prob<line::Real<16> >(file, k);
9919 if (arith == "real" || arith == "real:32") return solve_model_prob<line::Real<32> >(file, k);
9920 if (arith == "real:64") return solve_model_prob<line::Real<64> >(file, k);
9921 if (arith == "real:128") return solve_model_prob<line::Real<128> >(file, k);
9922 if (arith == "real:256") return solve_model_prob<line::Real<256> >(file, k);
9923 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9924 }
9925 if (analysis == "marg") {
9926 if (arith == "double") return solve_model_marg<double>(file, k);
9927 if (arith == "exact") return solve_model_marg<line::Rational>(file, k);
9928 if (arith == "real:16") return solve_model_marg<line::Real<16> >(file, k);
9929 if (arith == "real" || arith == "real:32") return solve_model_marg<line::Real<32> >(file, k);
9930 if (arith == "real:64") return solve_model_marg<line::Real<64> >(file, k);
9931 if (arith == "real:128") return solve_model_marg<line::Real<128> >(file, k);
9932 if (arith == "real:256") return solve_model_marg<line::Real<256> >(file, k);
9933 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9934 }
9935 if (analysis == "normconst") {
9936 if (arith == "double") return solve_model_normconst<double>(file, k, s);
9937 if (arith == "exact") return solve_model_normconst<line::Rational>(file, k, s);
9938 if (arith == "real:16") return solve_model_normconst<line::Real<16> >(file, k, s);
9939 if (arith == "real" || arith == "real:32")
9940 return solve_model_normconst<line::Real<32> >(file, k, s);
9941 if (arith == "real:64") return solve_model_normconst<line::Real<64> >(file, k, s);
9942 if (arith == "real:128") return solve_model_normconst<line::Real<128> >(file, k, s);
9943 if (arith == "real:256") return solve_model_normconst<line::Real<256> >(file, k, s);
9944 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9945 }
9946 if (analysis == "cdf") {
9947 // The inherited base-class exponential fallback over the MVA means,
9948 // as @@NetworkSolver/getCdfRespT.m serves it for SolverMVA
9949 if (arith == "double") return solve_model_mva_cdf<double>(file, k);
9950 if (arith == "exact") return solve_model_mva_cdf<line::Rational>(file, k);
9951 if (arith == "real:16") return solve_model_mva_cdf<line::Real<16> >(file, k);
9952 if (arith == "real" || arith == "real:32")
9953 return solve_model_mva_cdf<line::Real<32> >(file, k);
9954 if (arith == "real:64") return solve_model_mva_cdf<line::Real<64> >(file, k);
9955 if (arith == "real:128") return solve_model_mva_cdf<line::Real<128> >(file, k);
9956 if (arith == "real:256") return solve_model_mva_cdf<line::Real<256> >(file, k);
9957 throw line::InputError("--arith '" + arith + "' is not a model-solve backend");
9958 }
9959 if (analysis != "avg")
9961 "the model-solving path ports -a avg, -a node, -a prob, -a marg, -a normconst and "
9962 "-a cdf (got '" +
9963 analysis + "')");
9964 // The mvaDispatch ladder's transcendental-only analyzers (open-queue closed
9965 // forms, DPS-exact, Marie, size-based) refuse by name under exact/real via
9966 // their if-constexpr guards, so the field-arithmetic branches (product-form
9967 // MVA, LD scaling) stay exact while a transcendental model is refused rather
9968 // than silently degraded.
9969 if (arith == "double") return solve_model_mva<double>(file, k);
9970 if (arith == "exact") return solve_model_mva<line::Rational>(file, k);
9971 if (arith == "real:16") return solve_model_mva<line::Real<16> >(file, k);
9972 if (arith == "real" || arith == "real:32") return solve_model_mva<line::Real<32> >(file, k);
9973 if (arith == "real:64") return solve_model_mva<line::Real<64> >(file, k);
9974 if (arith == "real:128") return solve_model_mva<line::Real<128> >(file, k);
9975 if (arith == "real:256") return solve_model_mva<line::Real<256> >(file, k);
9976 throw line::InputError(
9977 "--arith '" + arith +
9978 "' is not a model-solve backend; use double, exact or real:<16|32|64|128|256>");
9979}
9980
9981/**
9982 * The no-argument and `-h` message: the flags a first run actually needs.
9983 *
9984 * The full reference below is ~460 lines, which is not a thing a human reads at
9985 * a prompt; it stays one flag away under `--help-all` rather than being the
9986 * first thing the binary says.
9987 */
9988void print_brief_help() {
9989 std::printf(
9990 "LINE solver (C++), version %s\n"
9991 "\n"
9992 "Usage: line-cli -f <model> [-s <solver>] [-a <analysis>] [-o <format>]\n"
9993 " cat model.json | line-cli\n"
9994 "\n"
9995 "Common options:\n"
9996 " -f, --file <path> model file: .json (network), .lqnx (layered),\n"
9997 " .jsimg (JMT), .pnml (Petri net); stdin if omitted\n"
9998 " -s, --solver <name> auto (default), mva, nc, ctmc, mam, fluid, ssa,\n"
9999 " ldes, jmt, ag, ba, uq; ln for layered models, env\n"
10000 " for random environments\n"
10001 " -a, --analysis <type> avg (default), node, sys, chain, tran, prob, cdf,\n"
10002 " states, sample, normconst, bounds, ... (comma list)\n"
10003 " -o, --output <fmt> readable (default) | json | jsimg (export the\n"
10004 " model as a JMT simulation document)\n"
10005 " --method <name> algorithm within the chosen solver\n"
10006 " --samples <n> simulation run length (ssa, ldes); default 10000\n"
10007 " --seed <n> random seed; default 23000\n"
10008 " -v, --verbosity <lvl> silent | standard | debug; debug turns on\n"
10009 " the solver console, a running progress log\n"
10010 " --find-solver [m] which solvers and methods can analyze this model,\n"
10011 " optionally only those answering measure <m>\n"
10012 " (avg, tran, cdf, prob, sample, ...); reports and exits\n"
10013 " --find-solver-all [m] the same, keeping the refused pairs and the\n"
10014 " reason each was refused\n"
10015 " -h, --help this message\n"
10016 " --help-all every option, solver by solver\n"
10017 " -V, --version version string\n"
10018 " --install environment check: which optional backends\n"
10019 " (Java/JMT, LQNS, qnsolver, SageMath) are reachable\n"
10020 "\n"
10021 "Examples:\n"
10022 " line-cli -f model.json solve, letting auto pick the solver\n"
10023 " line-cli -f model.json -s mva -a avg mean queue lengths, MVA\n"
10024 " line-cli -f model.lqnx -s ln solve a layered model\n"
10025 " line-cli -f model.json -s ssa --samples 1e6 -o json\n"
10026 " line-cli -f model.json --find-solver what can solve this model\n"
10027 " line-cli -f model.json --find-solver cdf ... and return a passage-time law\n"
10028 "\n"
10029 "Each solver has flags of its own (tolerances, horizons, engine choices):\n"
10030 "run `line-cli --help-all` for the full reference.\n",
10031 kVersion);
10032}
10033
10034void print_help() {
10035 std::printf(
10036 "LINE multiprecision solver (C++), version %s\n"
10037 "\n"
10038 "Usage: line-cli [OPTIONS]\n"
10039 " cat model.json | line-cli -i json [OPTIONS]\n"
10040 " line-cli model.lqnx [OPTIONS]\n"
10041 "\n"
10042 "Options (flag-compatible with jline.cli.LineCLI):\n"
10043 " -f, --file <path> model file; stdin when omitted (json only)\n"
10044 " -i, --input <fmt> input format: json | jsim | jsimg | jsimw |\n"
10045 " lqnx | xml | pnml. Without it a .lqnx or .xml\n"
10046 " path is read as a layered model, a\n"
10047 " .jsim/.jsimg/.jsimw path as a JMT simulation\n"
10048 " document, a .pnml path as a place/transition\n"
10049 " net (ISO/IEC 15909-2), and anything else as a\n"
10050 " Network model.json. The three jsim spellings\n"
10051 " name ONE format, as they do in JMT\n"
10052 " -o, --output <fmt> output format: readable | json | jsimg\n"
10053 " (| layers, lqnx for layered models).\n"
10054 " jsimg exports the model as a JMT simulation\n"
10055 " document and solves nothing -- the write half\n"
10056 " of -i jsim|jsimg|jsimw, and the same three\n"
10057 " spellings are accepted. --seed, --samples and\n"
10058 " --tspan set the controls its header carries\n"
10059 " json is honoured by EVERY analysis, not only\n"
10060 " -a avg: each answers under a key named after\n"
10061 " its -a, with the arithmetic and the resolved\n"
10062 " method beside it, and every index inside a\n"
10063 " payload is 0-based against the tables' 1-based\n"
10064 " columns (each payload states its indexBase)\n"
10065 " -s, --solver <name> Network: auto, mva, nc, ctmc, mam, ba, ssa, fluid, uq,\n"
10066 " ldes (the SSJ discrete-event engine, run as a\n"
10067 " subprocess on common/ldes or common/ldes.jar),\n"
10068 " jmt (the Java Modelling Tools engine),\n"
10069 " lqns (its qns methods, the external qnsolver\n"
10070 " binary)\n"
10071 " layered: auto, ln, ln.mva, ln.comom, lqns,\n"
10072 " ldes (the native in-process LN simulator, a\n"
10073 " sample path of the layered model itself and\n"
10074 " not a decomposition into layers; it takes\n"
10075 " --samples and --seed, and NOT the --ldes-*\n"
10076 " family, which configures the subprocess\n"
10077 " engine that answers -s ldes on a Network)\n"
10078 " environment: env (an Environment model.json)\n"
10079 " -a, --analysis <type> analysis: avg, node, sys, chain, nodechain,\n"
10080 " stage, cache, item, prob, marg, cdf, cdf-passt,\n"
10081 " perct-respt, tran, tran-cdf-respt,\n"
10082 " tran-cdf-passt, tranprob, tranreward,\n"
10083 " reward-value, normconst, gen, states, sample,\n"
10084 " reward, sens, first-passt, odes, statevec,\n"
10085 " var, tranvar, busyperiod,\n"
10086 " jacobian, aoi, internals, bounds, posterior,\n"
10087 " interval. A COMMA LIST runs several in order\n"
10088 " (`-a avg,sys`), emitting one -o json envelope\n"
10089 " per analysis rather than one merged object.\n"
10090 " The JAR CLI's own spellings are accepted as\n"
10091 " aliases -- cdf-respt, prob-sys-aggr, tran-avg,\n"
10092 " generator, reward-steady, all -- and collapse\n"
10093 " onto the arm that already answers them whole:\n"
10094 " -a prob reports getProbSys, getProbSysAggr and\n"
10095 " the per-station pair together, -a sample walks\n"
10096 " all four samplers at once, and -a stage IS\n"
10097 " -a avg on a Network (one implicit stage).\n"
10098 " Which names a\n"
10099 " solver serves is stated by its own refusal;\n"
10100 " ssa serves avg, prob and sample (prob and\n"
10101 " sample run the SERIAL engine whatever -m\n"
10102 " said, since the NRM simulates counts rather\n"
10103 " than the state encoding)\n"
10104 " -v, --verbosity <lvl> silent | standard | debug; debug turns on\n"
10105 " the solver console, a running progress log\n"
10106 " of every solver run\n"
10107 " -d, --seed <n> random seed (SSA, nc --method cftp); default\n"
10108 " 23000. -d is the JAR CLI's spelling\n"
10109 " --warmupfrac <f> SSA: leading fraction of the path discarded\n"
10110 " before the means are taken, in [0,1)\n"
10111 " --pstar <p> fluid: exponent of the p-norm smoothing of the\n"
10112 " drift; without it the hard min() is integrated\n"
10113 " --busyperiod <n,..> -a busyperiod: the orders wanted; default 1\n"
10114 " --busyperiod-subnet <i,..>\n"
10115 " -a busyperiod: the 1-based stations forming the\n"
10116 " subnetwork. Required -- a busy period is defined\n"
10117 " for a NAMED set and no default can choose one\n"
10118 " --method <name> algorithm within the solver\n"
10119 " --samples <n> simulation run length (SSA); default 10000.\n"
10120 " Accepts 1e6 as well as 1000000. A simulation\n"
10121 " figure is only a measurement WITH this number,\n"
10122 " which is why the SSA banner reports it back.\n"
10123 " Also the draw count of nc --method cftp,\n"
10124 " default 1e5, where the draw is the answer.\n"
10125 " --mdd-tol <x> level-iteration tolerance of ctmc --method mdd;\n"
10126 " default 1e-12. NOT --tol: that iteration is an\n"
10127 " inner solve whose fixed point is checked\n"
10128 " against the population invariant at 1e-6, so a\n"
10129 " solver-sized tolerance stops short of it\n"
10130 " --mdd-maxiter <n> coupled sweeps before ctmc --method mdd is\n"
10131 " declared non-convergent; default 500\n"
10132 " --level <n> hierarchy level of the ba pbh/bjbh/cbh/sib\n"
10133 " families; default 2\n"
10134 " --qrf-params <j> JSON (inline or a path) with the QRF blocking\n"
10135 " tables of -s ba --method qrf.bas|qrf.rsrd:\n"
10136 " f, MR, BB, MM, MM1, ZZ and optionally F, the\n"
10137 " fields sn_to_qrf_params assembles. ZM is\n"
10138 " derived from ZZ. There is no default: assuming\n"
10139 " no blocking puts the bound ~31x farther from\n"
10140 " exact, so its absence is refused\n"
10141 " --qrf-alpha <j> JSON (nstations x N) load-dependent scaling of\n"
10142 " the ba qrf.mmi.ld, qrf.mmi.linear and qrf.rsrd\n"
10143 " arms; default all ones\n"
10144 " --tol <x> convergence tolerance (mva, nc, mam, ag, fluid)\n"
10145 " --iter_tol <x> outer-loop tolerance (mva, nc, fluid)\n"
10146 " --iter_max <n> iteration cap (mva, nc, mam, ag, fluid)\n"
10147 " --max-states <n> truncation level of an OPEN agent's queue-length\n"
10148 " dimension (ag), options.config.maxStates;\n"
10149 " default 100. A closed class is bounded by its\n"
10150 " own population instead, and --method inapinf\n"
10151 " ignores the level and solves the open agents on\n"
10152 " the infinite state space\n"
10153 " --fork-join <arm> which fork-join transform the mean-value fixed\n"
10154 " point takes (mva, nc): default|mmt|fjt is the\n"
10155 " MMT transform, ht|heidelberger-trivedi the\n"
10156 " Heidelberger-Trivedi one, which is CLOSED\n"
10157 " models only and a different answer to the same\n"
10158 " model rather than a faster route to one\n"
10159 " --cutoff <n|matrix> open jobs per class in the CTMC state space;\n"
10160 " a matrix is per (station,class), '1,1,0;3,3,0;0,0,3'\n"
10161 " (ctmc); without it the reference's\n"
10162 " ceil(6000^(1/(M*K))) is used and reported.\n"
10163 " Also the level truncation of mam -a prob,\n"
10164 " whose open queue has no bound of its own\n"
10165 " --fj-accuracy <n> FJ_codes truncation C of the queue-length\n"
10166 " difference between the two fork-join\n"
10167 " branches (mam, homogeneous fork-join);\n"
10168 " default 100, larger is more accurate\n"
10169 " --fj-tmode <mode> how that approximation solves for its T\n"
10170 " matrix: NARE (default) or Sylves\n"
10171 " --timescale <mode> auto (default), discrete or continuous: how\n"
10172 " -s mam reads the time scale. auto lets the\n"
10173 " distributions decide; discrete raises rather\n"
10174 " than solve a model that mixes lattice and\n"
10175 " non-lattice laws. The slot is --slotlength\n"
10176 " --tspan <t0>:<t1> transient horizon (ctmc -a tranprob,\n"
10177 " mam -a tran, fluid); a bare <t1> starts at 0.\n"
10178 " For the CTMC and MAM there is no default:\n"
10179 " pi(t) on an unstated horizon is not a\n"
10180 " quantity. For the fluid solver it bounds the\n"
10181 " integration, and is what -s fluid --method kp\n"
10182 " reports its covariance AT\n"
10183 " -n, --node <n> 1-based stateful node a state query is\n"
10184 " labelled by (ctmc -a tranprob, -a sample and\n"
10185 " ssa -a sample), the\n"
10186 " queue mam -a prob reports, or the station\n"
10187 " mva -a marg reports; without it the\n"
10188 " whole network is reported (the MAM queries\n"
10189 " take the model's only Queue)\n"
10190 " -c, --class <r> 1-based job class of mva -a marg; without it\n"
10191 " every class is reported\n"
10192 " NOTE ON THE INDEX BASE: -n and -c are 1-BASED\n"
10193 " here, as every station index this CLI takes\n"
10194 " is, and 0-BASED in jline.cli.LineCLI, which\n"
10195 " indexes as Java does. The short spellings are\n"
10196 " accepted so one command line parses in both,\n"
10197 " but the SAME number names a different node --\n"
10198 " the two bridges (cpp_dispatch, jar_dispatch)\n"
10199 " each convert for their own CLI\n"
10200 " --marg-states <ns> comma-separated job counts the mva -a marg\n"
10201 " curve is evaluated at, the reference's\n"
10202 " state_m; without it each law takes its own\n"
10203 " default range (0..N_r closed, mean + 5 sigma\n"
10204 " Poisson, the 1e-10 tail geometric)\n"
10205 " --notation <form> scalar (default) | matrix, the form the ODE\n"
10206 " export writes (fluid -a odes only)\n"
10207 " --symbolic <b> computer-algebra backend of fluid -a\n"
10208 " jacobian: auto (default, searches for a\n"
10209 " line-sage-rest service), a URL, an image\n"
10210 " name, or none to differentiate locally\n"
10211 " --equilibria also ask the backend for the solutions of\n"
10212 " f(x) = 0 (fluid -a jacobian). Needs a\n"
10213 " backend: solving is not differentiating\n"
10214 " --cdf-algorithm <a> exact (default, pfqn_stdf) | rd\n"
10215 " (pfqn_stdf_heur), how the sojourn law is\n"
10216 " inverted (nc -a cdf only)\n"
10217 " --perm-engine <e> exact (default, Ryser) | spm | bethe | heur |\n"
10218 " huberlaw | adapart, the permanent estimator\n"
10219 " of nc -a sysmarg. The five approximations\n"
10220 " refuse a demand matrix with a zero entry;\n"
10221 " spm is the saddle point, whose cost does not\n"
10222 " grow with the population\n"
10223 " --tran-points <n> points on the uniform transient grid the ENV\n"
10224 " mean-field coupling sums its stage exit\n"
10225 " metrics over (env only); default 1001\n"
10226 " --state <n,...> the ENCODED state row of the node named by\n"
10227 " --node that -a prob asks about, i.e.\n"
10228 " getProb(node, state)'s second argument; without\n"
10229 " it the query is about the model's default\n"
10230 " initial state\n"
10231 " --events <n> length of ONE sampled trajectory (-a sample);\n"
10232 " default 1000. NOT --samples, which is a\n"
10233 " solver's run length\n"
10234 " --timestep <dt> fixed output step of a transient CTMC solve\n"
10235 " (-a tranprob, -a tranreward), options.timestep\n"
10236 " of ctmc_transient.m; without it the grid is the\n"
10237 " integrator's own adaptive one. It changes WHERE\n"
10238 " the solution is reported, not how it is\n"
10239 " computed: the grid points are read off the same\n"
10240 " interpolant\n"
10241 " --transient-method <m> ode (default) or fau, how the CTMC forward\n"
10242 " equation is advanced over the output grid\n"
10243 " --fau-epsilon <e> fau: probability mass the grid may discard\n"
10244 " --fau-delta <d> fau: occupancy below which a state is dropped\n"
10245 " --rate-sched <j> -s ctmc -a tran: time-varying rates, a JSON array\n"
10246 " (inline or a file) of {station, class, tgrid,\n"
10247 " rates[, nominal]}; station and class by name or\n"
10248 " 1-based index. The rate of that pair follows the\n"
10249 " piecewise-linear schedule, held at its end values\n"
10250 " outside tgrid (options.config.rate_sched)\n"
10251 " --ctmc-tv-ngrid <n> grid size of the --rate-sched propagator (100)\n"
10252 " --percentiles <p,..> levels getPerctRespT is read at (-s mam),\n"
10253 " as fractions (0.9) or percents (90); default\n"
10254 " 0.50,0.90,0.95,0.99, the reference's pers_stored\n"
10255 " --reward-name <nm> which declared reward -a reward-value returns\n"
10256 " the value function of. REQUIRED there and never\n"
10257 " defaulted: two rewards have different value\n"
10258 " functions, and picking one would mislabel it\n"
10259 " --map-env <mode> default (on) | off, whether a model whose ONLY\n"
10260 " unsupported features are MAP/MMPP2/MMAP/MPH is\n"
10261 " solved through a random-environment image of the\n"
10262 " modulating chain instead of being refused. Not a\n"
10263 " -s env knob: it applies to mva, nc and fluid,\n"
10264 " which is where the refusal it replaces comes\n"
10265 " from. off restores that plain refusal\n"
10266 " --map-env-method <m> auto (default) | dec | avg | meanfield, the\n"
10267 " recombination the image is solved with. auto\n"
10268 " picks from the environment timescale; meanfield\n"
10269 " needs a stage backend and is refused by name\n"
10270 " where there is none\n"
10271 " --map-env-maxstages <n> cap on the stages map2renv draws from the\n"
10272 " modulating chain; 0, the default, takes every\n"
10273 " phase. Beyond the JAR, which has no such cap\n"
10274 " -p, --port <n> run as a solve SERVER on this port, speaking\n"
10275 " LineWebSocketServer's protocol: one WebSocket\n"
10276 " text message per connection, its first line the\n"
10277 " comma-separated argument list and its remainder\n"
10278 " the model document; the CLI's output comes back\n"
10279 " as one text message. Plaintext, one connection\n"
10280 " at a time, and -f is refused beside it\n"
10281 " -m, --maxreq <n> quit after serving n requests; without it the\n"
10282 " server runs until interrupted\n"
10283 " -h, --help the short message: the flags a first run needs\n"
10284 " --help-all this message, every option solver by solver\n"
10285 " -V, --version version string\n"
10286 " --install environment check: report which optional backends\n"
10287 " (Java/JMT, LQNS, qnsolver, SageMath) are reachable\n"
10288 "\n"
10289 "Layered models (-i lqnx|xml) additionally take:\n"
10290 " --layer-solver <s> solver run in each layer: mva (default)|nc|\n"
10291 " fluid|ssa, the C++ spelling of the reference's\n"
10292 " factory argument: LN(model, @(m) MVA(m))\n"
10293 " against LN(model, @(m) Fluid(m)). They converge\n"
10294 " to DIFFERENT fixed points, not to the same one\n"
10295 " by different routes, because each layer's\n"
10296 " results feed the next outer iteration's demands.\n"
10297 " fluid is double only and refuses a layer with a\n"
10298 " fork; ssa is NOISY, so the outer loop switches\n"
10299 " to the Robbins-Monro / Polyak-Ruppert controller\n"
10300 " --method <name> the LN update: default | moment3 | mw.upper |\n"
10301 " mw.lower. moment3 fits an APH to each layer's\n"
10302 " response-time CDF and convolves along the entry,\n"
10303 " which is what makes -a cdf possible; mw.*\n"
10304 " reports Majumdar-Woodside box BOUNDS on\n"
10305 " throughput and processor utilization and solves\n"
10306 " no layer at all (every other metric is NaN)\n"
10307 " --ln-transient <m> coupled (default) | decoupled, how -a tran\n"
10308 " couples the layers. decoupled freezes the\n"
10309 " inter-layer demands at the fixed point; coupled\n"
10310 " relaxes time-varying demands through the fluid\n"
10311 " rate schedule until the trajectories settle\n"
10312 " --ln-transient-channels <c> both (default) | thinkt | callservt,\n"
10313 " which inter-layer coupling the relaxation\n"
10314 " injects, for isolating one channel's share\n"
10315 " --sens-method <m> auto (default) | exact | fd, the branch each\n"
10316 " LAYER's sensitivity table takes (-a sens);\n"
10317 " the same three under -s nc -a sens\n"
10318 " --sens-scheme <s> forward (default) | central, the difference\n"
10319 " quotient of the fd branch\n"
10320 " --sens-step <h> relative rate perturbation of the fd branch,\n"
10321 " in (0,1); default 1e-4, or 1e-2 for ssa layers\n"
10322 " --no-interlocking disable the interlocking correction\n"
10323 " --interlock-method <m> ilrate (default) | refpath | none, how\n"
10324 " the interlock is tracked; refpath merges the\n"
10325 " callers that are one reference task's\n"
10326 " customers into one chain (srvn.cs only, it\n"
10327 " falls back to ilrate elsewhere, with a warning)\n"
10328 " --interlock-maxpaths <n> refpath refuses a layer carrying more\n"
10329 " reference routes into it; default 32\n"
10330 " --interlock-refpath-scope <s> merging (default) | all, whether\n"
10331 " refpath also rewrites a lone-caller layer\n"
10332 " --repeat <k> re-solve k times, report the best wall clock\n"
10333 " -o layers dump every layer's stations, classes, rates\n"
10334 " and routing instead of the AvgTable\n"
10335 " -a takes avg (getAvgTable), tran (getTranAvg, needs --tspan and fluid\n"
10336 " layers), sens (getSensitivityTable) and cdf (getCdfRespT, moment3).\n"
10337 " --iter_max and --iter_tol set the outer LN loop; --tol does not apply,\n"
10338 " and neither does any Network solver token.\n"
10339 "\n"
10340 "The external LQNS binary (-s lqns) additionally takes:\n"
10341 " --method <name> default | lqns | srvn | exactmva |\n"
10342 " srvn.exactmva | sim | lqsim | lqns.default.\n"
10343 " sim and lqsim run lqsim, the SIMULATOR;\n"
10344 " lqns.default is lqns with no pragma at all,\n"
10345 " which is a different fixed point and not a\n"
10346 " synonym for default\n"
10347 " --multiserver <p> conway|rolia|zhou|suri|reiser|schmidt|default\n"
10348 " (= rolia), the -Pmultiserver= pragma. Not\n"
10349 " passed to lqsim, which has no MVA to configure\n"
10350 " --samples <n> lqsim run length (-A); default 10000\n"
10351 " --timeout <s> kill the child after s seconds; without it the\n"
10352 " wrapper waits\n"
10353 " --keep keep the working directory with model.lqnx and\n"
10354 " model.lqxo instead of removing it\n"
10355 " --verbose echo the command line and the binary's output\n"
10356 " --remote[-url <u>] solve on a host running lqns-rest instead of\n"
10357 " locally; -url implies --remote. LINE ships no\n"
10358 " LQNS binary, so this is the other way to reach\n"
10359 " one\n"
10360 " -a takes avg only: lqns computes no transient, no sensitivity and no\n"
10361 " response-time distribution. QLen is the element utilization, Util its\n"
10362 " processor utilization per server, RespT its phase-1 service time;\n"
10363 " ResidT and ArvR print NaN because lqns reports neither.\n"
10364 "\n"
10365 "The external qnsolver binary (-s lqns on a Network) additionally takes:\n"
10366 " --method <name> default | qns (both = rolia) | qns.conway |\n"
10367 " qns.rolia | qns.zhou | qns.reiser | qns.suri |\n"
10368 " qns.schmidt. The multiserver approximation,\n"
10369 " passed as qnsolver -m and only\n"
10370 " when the model HAS a multiserver station. suri\n"
10371 " and schmidt are lqns approximations: they reach\n"
10372 " the tool only on the non-product-form closed\n"
10373 " branch below, and a multiserver model on the\n"
10374 " qnsolver branch refuses them by name\n"
10375 " --multiserver <p> the same choice under its config spelling;\n"
10376 " --method wins when it names one\n"
10377 " --timeout <s> kill the child after s seconds; without it the\n"
10378 " wrapper waits\n"
10379 " --keep keep the working directory with model.jmva and\n"
10380 " result.jmva instead of removing it\n"
10381 " -a takes avg only. The model is marshalled to the JMVA interchange\n"
10382 " format at CHAIN level and the chain results are de-aggregated back to\n"
10383 " classes, so only Queue, Delay and Source stations are expressible; any\n"
10384 " other station is refused rather than dropped. A closed model that is\n"
10385 " NOT product-form is converted with QN2LQN and delegated to lqns, as the\n"
10386 " reference does, so it also needs the lqns binary; class priorities are\n"
10387 " outside that conversion and are refused. --arith is double only, since\n"
10388 " the results arrive as the text an external binary printed.\n"
10389 "\n"
10390 "The Java Modelling Tools wrapper (-s jmt) additionally takes:\n"
10391 " --jmt-replications <n> independent JSIM runs the transient ensemble\n"
10392 " of -a tran and -a tranprob averages over\n"
10393 " (options.config.replications, default 10),\n"
10394 " replication k seeded seed+k-1. A single path\n"
10395 " is NOT E[N](t); --method jsim is one run\n"
10396 "\n"
10397 "Discrete-event simulation (-s ldes) additionally takes:\n"
10398 " --ldes-tranfilter <f> warmup filter: mser5 (default), fixed, none\n"
10399 " --ldes-warmupfrac <x> fraction the fixed filter discards (0.2)\n"
10400 " --ldes-cimethod <m> CI estimator: obm (default), bm, spectral,\n"
10401 " none\n"
10402 " --ldes-cnvgon stop on relative precision instead of on\n"
10403 " the --samples budget\n"
10404 " --ldes-cnvgtol <x> that precision target (0.05); implies\n"
10405 " --ldes-cnvgon\n"
10406 " --slotted run the analytical solver on a discrete\n"
10407 " (slotted) time scale; SolverNC routes to the\n"
10408 " discrete-time product form and refuses a model\n"
10409 " outside it\n"
10410 " --slotlength <x> the slot in model time units; implies --slotted\n"
10411 " --ldes-slotted run on a discrete (slotted) time scale; a\n"
10412 " sample off the lattice is an error, never\n"
10413 " rounded\n"
10414 " --ldes-slotlength <x> the slot (1.0); implies --ldes-slotted\n"
10415 " --ldes-replications <n> independent runs, averaged. A single path\n"
10416 " is NOT E[N](t): -a tran over an ensemble\n"
10417 " mean needs this\n"
10418 " --ldes-numthreads <n> workers for those replications\n"
10419 " --ldes-maxtime <s> wall-clock budget; the engine stops early\n"
10420 " and reports stopping=max_time\n"
10421 " --ldes-initsol <v,..> warm-start placement, station-major\n"
10422 " [st0_cl0, st0_cl1, ...]. Add --ldes-tranfilter\n"
10423 " fixed --ldes-warmupfrac 0 to reproduce\n"
10424 " initFromSolver, which assumes the placement\n"
10425 " is already a steady state\n"
10426 " --ldes-rest-url <u> solve on an LDES REST server instead of a\n"
10427 " local binary; same wire format, same numbers\n"
10428 " The model.json is forwarded to the engine BYTE FOR BYTE, so a model\n"
10429 " this port cannot itself parse (a cache with retrieval, an SPN, a\n"
10430 " polling server) is simulated exactly as the MATLAB and Python clients\n"
10431 " simulate it. -a avg, tran, cdf (the empirical response-time law),\n"
10432 " sample, reward and prob (the histogram's residence time at the declared\n"
10433 " per-class counts; --node with --state overrides one station) are\n"
10434 " served. --arith is double only.\n"
10435 "\n"
10436 "Uncertainty quantification (-s uq) additionally takes:\n"
10437 " --uq-solver <s> the engine run at each design point: mva, nc,\n"
10438 " mam, ba, ctmc, fluid or ssa. REQUIRED: UQ\n"
10439 " computes nothing itself, and defaulting it\n"
10440 " would attribute the numbers to an engine the\n"
10441 " caller never chose\n"
10442 " A model whose service or arrival process is a Prior is a FAMILY of\n"
10443 " models. UQ discretizes each Prior, solves the tensor product of the\n"
10444 " alternatives, and reports the prior-weighted expectation. Under -s uq\n"
10445 " three flags describe the DESIGN and not the engine: --method is\n"
10446 " quadrature (default, and the alias of discrete) or montecarlo,\n"
10447 " --samples the nodes per continuous Prior (11), --seed the Monte Carlo\n"
10448 " stream. The stage solver therefore keeps its own sample count and its\n"
10449 " own seed; --tol, --iter_tol, --iter_max and --cutoff pass through to\n"
10450 " it, since UQ has no convergence of its own. -a posterior prints every\n"
10451 " design point, its weight and the means it substituted, which is what\n"
10452 " says whether the expectation averaged two nearby models or two very\n"
10453 " different ones. Every other solver REFUSES a model carrying a Prior\n"
10454 " rather than lowering it to its mixture moments.\n"
10455 " -a interval answers the OTHER epistemic question, in which a\n"
10456 " parameter is bounded but not distributed: it drops the weights and\n"
10457 " keeps the endpoints. On a single-class closed model of LI\n"
10458 " single-server queues and delays it is the EXACT hull of MVA over the\n"
10459 " demand box (2*(m+2) MVA calls, no design solved at all); otherwise it\n"
10460 " falls back to the range over the solved design points, which for a\n"
10461 " continuous Prior lies strictly inside the true range. The table says\n"
10462 " which, and the fallback warns on stderr: a range that is not an\n"
10463 " enclosure must not read like one.\n"
10464 "\n"
10465 "Random environments (-s env) read an Environment model.json, whose\n"
10466 " stages each hold a Network and whose transitions carry the stage\n"
10467 " holding times. The stages are solved TRANSIENTLY and coupled: each\n"
10468 " stage starts from the queue lengths the previous one left, and the\n"
10469 " reported means are the per-stage sojourn averages blended by the\n"
10470 " environment probabilities. --method selects the coupling: meanfield\n"
10471 " (default, the reference's, carries the marginal means across a\n"
10472 " switch) or statevec|blend (carries the whole joint distribution).\n"
10473 " meanfield solves each stage with the fluid transient and is double\n"
10474 " only; statevec uniformizes a CTMC and takes the whole --arith ladder.\n"
10475 " --method avg|dec asks instead for a closed-form limit, which carries\n"
10476 " nothing across a switch and iterates nothing: avg solves ONE model\n"
10477 " whose modulated rates are their probEnv-weighted averages (exact as\n"
10478 " the environment gets fast), dec solves each stage in steady state and\n"
10479 " blends by probEnv (exact as it gets slow). A model with an\n"
10480 " environment-declared node breakdown is read from the nodeFailures\n"
10481 " block, in the expanded or the one-stage macro form.\n"
10482 " --tspan bounds the stage horizon and --tran-points its grid; --iter_tol\n"
10483 " and --iter_max drive the fixed point. RespT and ArvR print as nan\n"
10484 " because ENV computes neither -- the reference returns them as NaN too,\n"
10485 " and ResidT carries QLen/Tput.\n"
10486 "\n"
10487 "Additions specific to this port:\n"
10488 " --arith <mode> double (default) | exact | real:<digits>\n"
10489 " --list-api list the API functions ported so far\n"
10490 " --generate <spec> draw a random model and print it, solving\n"
10491 " nothing. <spec> is a JSON object inline, a\n"
10492 " path to one, or `-` for standard input.\n"
10493 " {\"kind\":\"network\"} draws a flat model and\n"
10494 " prints the line-model JSON document; its keys\n"
10495 " are seed, name, queues, delays, openClasses,\n"
10496 " closedClasses, schedStrat, routingStrat,\n"
10497 " distribution, cclassJobLoad,\n"
10498 " varyingServiceRates, multiServerQueues,\n"
10499 " randomCSNodes, multiChainCS and topology\n"
10500 " (rand|cyclic). A `delays` of -1, the default,\n"
10501 " lets the generator split the stations itself.\n"
10502 " {\"kind\":\"layered\"} draws an LQN and prints\n"
10503 " the .lqnx document; its keys are seed, name,\n"
10504 " clients, levels, tasks, processors,\n"
10505 " populationRange, thinkTimeRange,\n"
10506 " taskMultiRange, procMultiRange,\n"
10507 " hostDemandRange, synchCallRange (each a\n"
10508 " [lo,hi] pair), taskInfProbability and\n"
10509 " procInfProbability. Both draw from\n"
10510 " java.util.Random at `seed`, so a seeded spec\n"
10511 " reproduces; --arith is not read, since a draw\n"
10512 " is a double whatever the model is solved at.\n"
10513 " --api <name> invoke one API function directly\n"
10514 " --args <path> JSON arguments for --api; stdin when omitted\n"
10515 "\n"
10516 "Solvers and what they honour. Every model-solving solver reads -a avg,\n"
10517 "-f/-i and --method; nothing else is wired, so tolerances, iteration\n"
10518 "caps, seeds and sample counts keep their SolverOptions defaults rather\n"
10519 "than being invented here. mva and nc additionally read -a prob -- mva\n"
10520 "fits a binomial to its own means, nc returns the exact product-form\n"
10521 "probability, so the two disagree by construction. mva also reads -a\n"
10522 "marg, @SolverMVA's getProbMarg: P(n jobs of class r at station i) for\n"
10523 "every (station, class) pair, narrowed by --node / --class and evaluated\n"
10524 "at --marg-states. A closed class takes the Schmidt binomial fitted to\n"
10525 "Q(i,r); an open one takes the station's exact BCMP marginal (Poisson at\n"
10526 "an infinite server, multinomial-geometric at a queue). Both solvers read\n"
10527 "-a normconst, getProbNormConstAggr: nc reports the constant its solve\n"
10528 "already formed, mva RE-ENTERS its analyzer at method='exact', since only\n"
10529 "the exact recursion carries a G -- a model whose branch forms none, an\n"
10530 "open or mixed one above all, reports nan, as the reference does. nc also\n"
10531 "reads -a cdf,\n"
10532 "@SolverNC's getCdfRespT: the whole response-time law per (station,\n"
10533 "class) on one logarithmic grid, FCFS stations only, with\n"
10534 "--cdf-algorithm exact (pfqn_stdf, the default) or rd (pfqn_stdf_heur).\n"
10535 "nc reads -a sens as well, @NetworkSolver's getSensitivityTable: the\n"
10536 "derivative of each row's means with respect to its own service rate,\n"
10537 "selected with --sens-method / --sens-scheme / --sens-step. NC is one of\n"
10538 "the two engines whose exact branch differentiates the product-form\n"
10539 "recursion analytically, so auto takes it wherever the model is in its\n"
10540 "scope (single-server queues plus delays, not mixed) and falls back to\n"
10541 "finite differences elsewhere. NOT the -s ctmc -a sens analysis, which\n"
10542 "is getSensitivityRanking, a ranking of rate perturbations and not a\n"
10543 "table of derivatives.\n"
10544 "-a node is @NetworkSolver's getAvgNodeTable and is served by mva, nc,\n"
10545 "mam, ba and ctmc, the model solvers whose runner returns the station\n"
10546 "AvgResult it is built from. It is a DIFFERENT INDEX SPACE from -a avg,\n"
10547 "not a relabelling: the AvgTable has one row per STATION, so a\n"
10548 "ClassSwitch, Router, Fork, Join or Sink never appears in it, yet jobs\n"
10549 "flow through all of them. QLen, Util, RespT and ResidT are the station\n"
10550 "numbers scattered to their node indices and zero elsewhere -- a node\n"
10551 "that is not a station holds no jobs -- while ArvR and Tput are\n"
10552 "recomputed for every node by sn_get_node_arvr_from_tput and\n"
10553 "sn_get_node_tput_from_tput. The reference's finite-capacity-region\n"
10554 "pseudo-node rows are NOT emitted: this port's AvgResult carries no\n"
10555 "per-region queue length or utilization to fill them with.\n"
10556 "\n"
10557 "auto picks the engine\n"
10558 "with chooseSolverHeur and prints the name it picked; a branch selecting\n"
10559 "JMT or LDES refuses by name rather than substituting another. ctmc reads\n"
10560 "--cutoff and ports -a avg, prob, gen, states, tranprob, sample, reward,\n"
10561 "cdf, first-passt and sens, which are @SolverCTMC's\n"
10562 "getProbSys/getProbSysAggr and the per-station getProb/getProbAggr,\n"
10563 "getInfGen, getStateSpace, getTranProbSysAggr, sampleSys, getAvgReward,\n"
10564 "getCdfRespT, getCdfFirstPassT (state sets via --passage-from and\n"
10565 "--passage-into, as 1-based space rows '3,5' or state rows '0,2;1,1'),\n"
10566 "first-passt-moments (-a firstpasstmom, the same two sets plus\n"
10567 "--passage-orders: exact moments by one linear solve per order, so a\n"
10568 "variance or a skewness costs no truncated curve)\n"
10569 "and getSensitivityRanking. Its --method also takes mdd, which holds the\n"
10570 "reachable set in a decision diagram and solves K coupled level-CTMCs\n"
10571 "instead of the |S|-state generator; it serves -a avg only, having no\n"
10572 "explicit chain to answer the rest from. nc's --method also takes cftp /\n"
10573 "cftp.approx, which draw iid states from the exact stationary law of a\n"
10574 "closed single-class product-form network by coupling from the past;\n"
10575 "they serve -a avg only and their rows carry Monte Carlo error.\n"
10576 "mam ports -a avg,\n"
10577 "prob, cdf, tran and internals, which are @SolverMAM's getProb and\n"
10578 "getProbMarg (the joint (level, phase) law of the queue and its\n"
10579 "per-class marginals, truncated at --cutoff when the model is open),\n"
10580 "getCdfRespT with getPerctRespT beside it, getTranAvg over --tspan (the\n"
10581 "reference forces method ldqbd there, so --method does not select it),\n"
10582 "and getMAMResult, the M/G/1-type internals of a single queue. fluid\n"
10583 "additionally reads -a tran, -a prob, -a cdf, -a var and -a aoi, which\n"
10584 "are @SolverFLD's getTranAvg (the metrics along the trajectory, over\n"
10585 "--tspan or, without one, the horizon the reference's own adaptive loop\n"
10586 "converges at), getProbAggr (a law FITTED to the fluid means, so it does\n"
10587 "not agree with the mva or nc answer by construction), getCdfRespT (the\n"
10588 "response-time law per station and class, read off a marked-fluid\n"
10589 "integration started from the steady state), getMoments/getTranAvgVar\n"
10590 "and getAvgAoI with getCdfAoI beside it -- the last needing method mfq\n"
10591 "and the Source/Queue/Sink topology the age laws are defined for. It\n"
10592 "further reads -a odes, which is\n"
10593 "@SolverFLD/exportODEs, and --notation for the form it writes, and -a\n"
10594 "jacobian, which is @SolverFLD/getJacobian: d f_i / d x_j of the drift,\n"
10595 "differentiated locally over the structure of the system, with the\n"
10596 "equilibria beside it under --equilibria, which needs the\n"
10597 "line-sage-rest backend --symbolic names. Only the smooth methods have\n"
10598 "a Jacobian: min(n,S) has none where the regime switches, so the\n"
10599 "min-scaled drifts are refused by the factor that carries the kink.\n"
10600 "nc, ba and ctmc run\n"
10601 "under every --arith backend, nc's cftp method excepted: its sampler\n"
10602 "works in the log domain, so it refuses 'exact' by name rather than\n"
10603 "answering in a field it does not live in. mdd does run under 'exact'\n"
10604 "(its level solve drops Householder for a rational least squares there),\n"
10605 "but the LEVEL AGGREGATION is still an approximation away from product\n"
10606 "form: exact arithmetic pins the fixed point, not the model. mam is\n"
10607 "double only (its phase-type fitter\n"
10608 "needs transcendental arithmetic) and so is ssa (its sample path is\n"
10609 "generated from exponential clocks) and fluid (LSODA). ba reports a\n"
10610 "BOUND, not an estimate, and ssa a simulation carrying Monte Carlo\n"
10611 "error; neither is comparable with an exact solver except as such.\n"
10612 "\n"
10613 "Arithmetic: 'exact' computes in arbitrary-precision rationals and\n"
10614 "reports numerator and denominator alongside the double value; 'real'\n"
10615 "computes in fixed high-precision binary floating point, at 50, 100 or\n"
10616 "200 digits (a request in between is rounded up to the next tier).\n"
10617 "\n"
10618 "--api arguments are a JSON object keyed by the MATLAB parameter names,\n"
10619 "e.g. {\"L\": [[0.6,0.4]], \"N\": [2,1], \"Z\": [1,0.5]}: a 2-D array is a\n"
10620 "matrix (row-major), a 1-D array a row vector, a bare number a scalar.\n"
10621 "A JSON number is read as its shortest round-tripping decimal, so 0.6 is\n"
10622 "3/5 in exact arithmetic; pass a string such as \"1/3\" for anything else.\n",
10623 kVersion);
10624}
10625
10626/**
10627 * `--install`: the environment check, the C++ twin of MATLAB's `lineInstall`,
10628 * the JAR's `jline.cli.LineInstall` and Python's `line-install`.
10629 *
10630 * NOTHING IT LOOKS FOR IS REQUIRED. The C++ edition is header-only and its
10631 * native solvers stand alone, so every dependency probed here backs one
10632 * optional wrapper or backend. A miss is therefore a warning on stderr that
10633 * names the solvers it disables and how to install it, never an error: the
10634 * point of the command is to tell a fresh checkout which solvers it can
10635 * actually reach, not to refuse to run.
10636 *
10637 * @return true when nothing warned
10638 */
10639bool install_check() {
10640 bool has_warnings = false;
10641 // stdout is block-buffered when the check is piped or redirected while
10642 // stderr is not, so an unflushed progress line would surface AFTER the
10643 // warning it belongs to. Flush before every warning so the transcript reads
10644 // in the order the checks ran.
10645 const auto warn = [&](const std::string& text) {
10646 std::fflush(stdout);
10647 std::fprintf(stderr, "%s\n", text.c_str());
10648 std::fflush(stderr);
10649 has_warnings = true;
10650 };
10651
10652 std::printf("Checking LINE (C++)...\n");
10653 std::printf(" line-cli %s\n", kVersion);
10654
10655 std::printf("Checking Java runtime (JMT wrapper)...\n");
10656 const std::string java = line::jmt::detail::find_java();
10657 if (java.empty()) {
10658 warn("WARNING: no Java runtime was found in LINE_JAVA, JAVA_HOME or on PATH, so the "
10659 "JMT wrapper (-s jmt) cannot run. Install a JRE 8 or later.");
10660 } else {
10661 std::printf(" %s\n", java.c_str());
10662 }
10663
10664 std::printf("Checking JMT...\n");
10665 const std::string jmt_dir = line::jmt::detail::jmt_jar_path();
10666 if (jmt_dir.empty()) {
10667 // REPORTS, DOES NOT ACQUIRE: `jmt_jar_path` never downloads, and this
10668 // command must not either. An environment check that fetched 31 MiB as
10669 // a side effect of being asked would be changing the environment it is
10670 // reporting on, so the download is named here and performed by the
10671 // solver call that needs it.
10672 warn("WARNING: JMT.jar was not found, this is required by the JMT wrapper. Download it "
10673 "from https://line-solver.sourceforge.net/latest/JMT.jar into common/, or point "
10674 "LINE_JMT_DIR at the folder holding it, or set LINE_JMT_DOWNLOAD=1 to let the first "
10675 "JMT solve fetch and verify it.");
10676 } else {
10677 std::printf(" %s/JMT.jar\n", jmt_dir.c_str());
10678 }
10679
10680 std::printf("Checking LQNS...\n");
10682 const std::string banner = line::lqns::lqns_version();
10683 if (banner.empty())
10684 warn("WARNING: lqns is not installed, this is required by the LQNS wrapper (-s lqns) "
10685 "for layered models. Download it at: https://github.com/layeredqueuing/dist");
10686 else
10687 warn("WARNING: the installed lqns is too old for LINE, which needs release 6 or "
10688 "later; it reports '" + banner + "'. Upgrade it from: "
10689 "https://github.com/layeredqueuing/dist");
10690 } else {
10691 std::printf(" %s\n", line::lqns::lqns_version().c_str());
10692 }
10693
10694 std::printf("Checking qnsolver...\n");
10696 warn("WARNING: qnsolver is not installed, this is required by the qns methods of the "
10697 "LQNS wrapper (-s lqns on a Network). "
10698 "It ships with LQNS: https://github.com/layeredqueuing/dist");
10699
10700 std::printf("Checking symbolic backend (line-sage-rest)...\n");
10702 warn(std::string("WARNING: Docker is not available, so the SageMath symbolic backend "
10703 "cannot start. It is the only computer algebra system this edition "
10704 "reaches and is required by the symbolic methods of "
10705 "SolverCTMC/SolverFluid. Install Docker, then run: "
10706 "docker run -d -p 8080:8080 ") + line::sym::SYM_DOCKER_IMAGE);
10707 else if (line::sym::sym_find_image().empty())
10708 warn(std::string("WARNING: the line-sage-rest image is not present locally, this may be "
10709 "required by some LINE methods. Pull it with: "
10710 "docker run -d -p 8080:8080 ") + line::sym::SYM_DOCKER_IMAGE);
10711
10712 if (has_warnings)
10713 std::printf("Completed. LINE has warnings.\n");
10714 else
10715 std::printf("Success. LINE is ready to use.\n");
10716 return !has_warnings;
10717}
10718
10719void list_api() {
10720 const auto& reg = line::api_registry();
10721 std::printf("%-24s %-8s %-24s %s\n", "function", "domain", "arithmetic", "ported from");
10722 for (const auto& e : reg) {
10723 std::string modes;
10724 for (std::size_t k = 0; k < e.arith.size(); ++k) {
10725 if (k) modes += ",";
10726 modes += line::arith_name(e.arith[k]);
10727 }
10728 std::printf("%-24s %-8s %-24s %s\n", e.name.c_str(), e.domain.c_str(), modes.c_str(),
10729 e.reference.c_str());
10730 }
10731 std::printf("\n%zu of ~480 API functions ported.\n", reg.size());
10732}
10733
10734/**
10735 * Read the --api argument object: from the file named by --args, or from stdin
10736 * when --args is absent. A parse failure names the source and the position, so
10737 * a malformed file is a legible error rather than an empty argument set.
10738 */
10739line::reg::Json read_api_args(const std::string& path) {
10740 std::string text;
10741 std::string source;
10742 if (path.empty()) {
10743 source = "standard input";
10744 text.assign(std::istreambuf_iterator<char>(std::cin), std::istreambuf_iterator<char>());
10745 } else {
10746 source = "'" + path + "'";
10747 std::ifstream in(path.c_str());
10748 if (!in) throw line::InputError("cannot open the --args file " + source);
10749 text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
10750 }
10751 // No --args and nothing on stdin means the caller forgot the arguments.
10752 if (text.find_first_not_of(" \t\r\n") == std::string::npos)
10753 throw line::InputError("no --api arguments given: pass --args <path> or a JSON object on "
10754 "standard input (read from " +
10755 source + ")");
10756 try {
10757 return line::reg::Json::parse(text);
10758 } catch (const line::reg::Json::parse_error& e) {
10759 throw line::InputError("malformed --api arguments in " + source + ": " + e.what());
10760 }
10761}
10762
10763/**
10764 * Read the --generate specification: inline when it starts with `{`, from
10765 * standard input when it is `-` or absent, and from a file otherwise.
10766 *
10767 * INLINE IS THE POINT. A host that already holds the spec as a string (the R
10768 * binding does, and so does an MCP tool) would otherwise have to write a
10769 * temporary file for a dozen numbers, and a `line_capi_run` caller has no
10770 * standard input to pipe it through either.
10771 */
10772line::reg::Json read_generate_spec(const std::string& arg) {
10773 std::string text;
10774 std::string source;
10775 const std::string::size_type first = arg.find_first_not_of(" \t\r\n");
10776 if (first != std::string::npos && arg[first] == '{') {
10777 source = "the --generate argument";
10778 text = arg;
10779 } else if (arg.empty() || arg == "-") {
10780 source = "standard input";
10781 text.assign(std::istreambuf_iterator<char>(std::cin), std::istreambuf_iterator<char>());
10782 } else {
10783 source = "'" + arg + "'";
10784 std::ifstream in(arg.c_str());
10785 if (!in) throw line::InputError("cannot open the --generate spec file " + source);
10786 text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
10787 }
10788 if (text.find_first_not_of(" \t\r\n") == std::string::npos)
10789 throw line::InputError("no --generate specification given: pass a JSON object inline, a "
10790 "path to one, or `-` for standard input (read from " +
10791 source + ")");
10792 try {
10793 return line::reg::Json::parse(text);
10794 } catch (const line::reg::Json::parse_error& e) {
10795 throw line::InputError("malformed --generate specification in " + source + ": " + e.what());
10796 }
10797}
10798
10799/** Read one integer spec key, refusing a value that is not a whole number. */
10800long gen_int(const line::reg::Json& j, const char* key, long dflt) {
10801 if (!j.contains(key) || j.at(key).is_null()) return dflt;
10802 const line::reg::Json& v = j.at(key);
10803 if (!v.is_number())
10804 throw line::InputError(std::string("--generate: '") + key + "' must be a number");
10805 const double d = v.get<double>();
10806 if (d != std::floor(d))
10807 throw line::InputError(std::string("--generate: '") + key + "' must be a whole number");
10808 return static_cast<long>(d);
10809}
10810
10811/** Read one real spec key. */
10812double gen_real(const line::reg::Json& j, const char* key, double dflt) {
10813 if (!j.contains(key) || j.at(key).is_null()) return dflt;
10814 const line::reg::Json& v = j.at(key);
10815 if (!v.is_number())
10816 throw line::InputError(std::string("--generate: '") + key + "' must be a number");
10817 return v.get<double>();
10818}
10819
10820/** Read one boolean spec key. */
10821bool gen_bool(const line::reg::Json& j, const char* key, bool dflt) {
10822 if (!j.contains(key) || j.at(key).is_null()) return dflt;
10823 const line::reg::Json& v = j.at(key);
10824 if (!v.is_boolean())
10825 throw line::InputError(std::string("--generate: '") + key + "' must be true or false");
10826 return v.get<bool>();
10827}
10828
10829/** Read one string spec key. */
10830std::string gen_str(const line::reg::Json& j, const char* key, const std::string& dflt) {
10831 if (!j.contains(key) || j.at(key).is_null()) return dflt;
10832 const line::reg::Json& v = j.at(key);
10833 if (!v.is_string())
10834 throw line::InputError(std::string("--generate: '") + key + "' must be a string");
10835 return v.get<std::string>();
10836}
10837
10838/** Read a two-element `[lo, hi]` range key. */
10839line::gen::Range gen_range(const line::reg::Json& j, const char* key, const line::gen::Range& dflt) {
10840 if (!j.contains(key) || j.at(key).is_null()) return dflt;
10841 const line::reg::Json& v = j.at(key);
10842 if (!v.is_array() || v.size() != 2 || !v[0].is_number() || !v[1].is_number())
10843 throw line::InputError(std::string("--generate: '") + key +
10844 "' must be a two-element array of numbers");
10845 return line::gen::Range(v[0].get<double>(), v[1].get<double>());
10846}
10847
10848/**
10849 * Refuse a spec key that is not one this kind reads.
10850 *
10851 * A TYPO IS A SILENT WRONG ANSWER OTHERWISE: `"openClases": 2` would generate a
10852 * closed model and the caller would have no way to tell it was not asked for.
10853 */
10854void gen_check_keys(const line::reg::Json& j, const char* const* allowed, std::size_t n) {
10855 for (line::reg::Json::const_iterator it = j.begin(); it != j.end(); ++it) {
10856 bool ok = false;
10857 for (std::size_t k = 0; k < n && !ok; ++k) ok = it.key() == allowed[k];
10858 if (!ok) {
10859 std::string names;
10860 for (std::size_t k = 0; k < n; ++k) {
10861 if (k) names += ", ";
10862 names += allowed[k];
10863 }
10864 throw line::InputError("--generate: unknown key '" + it.key() +
10865 "'; this kind reads: " + names);
10866 }
10867 }
10868}
10869
10870/**
10871 * The JAR CLI's `-a` spelling, mapped onto this port's token.
10872 *
10873 * `jline.cli.LineCLI` names its analyses with hyphenated, getter-shaped tokens
10874 * (`cdf-respt`, `prob-sys-aggr`, `tran-avg`) and this port names them by the
10875 * question (`cdf`, `prob`, `tran`), because one arm here answers what the JAR
10876 * splits across several: `-a prob` reports `getProbSys`, `getProbSysAggr` and
10877 * the per-station `getProb`/`getProbAggr` in ONE table, and `-a sample` walks
10878 * `sampleSys`, `sampleSysAggr` and the per-node pair in one trajectory. Both
10879 * spellings are therefore accepted and the JAR's collapse onto the arm that
10880 * already contains the answer -- a script written against the JAR CLI keeps
10881 * working, and nothing here is renamed to make that true.
10882 *
10883 * AN UNKNOWN METHOD NAME IS RETURNED UNCHANGED, not rejected here: the per-solver
10884 * whitelists downstream refuse by name and say which analyses that solver
10885 * serves, which is a better message than a table of every token in the CLI.
10886 */
10887std::string normalize_analysis(const std::string& a) {
10888 // Same question, different spelling.
10889 if (a == "cdf-respt" || a == "cdfrespt") return "cdf";
10890 if (a == "cdf-passt" || a == "cdfpasst") return "cdfpasst";
10891 if (a == "first-passt" || a == "cdf-firstpasst" || a == "cdffirstpasst") return "firstpasst";
10892 if (a == "first-passt-moments" || a == "firstpasst-moments" || a == "firstpasstmoments")
10893 return "firstpasstmom";
10894 if (a == "perct-respt" || a == "perctrespt") return "perct";
10895 if (a == "tran-avg" || a == "tranavg") return "tran";
10896 if (a == "tran-cdf-respt" || a == "trancdfrespt") return "trancdf";
10897 if (a == "tran-cdf-passt" || a == "trancdfpasst") return "trancdfpasst";
10898 if (a == "tran-prob" || a == "tranprob-sys-aggr") return "tranprob";
10899 if (a == "generator") return "gen";
10900 if (a == "state-space" || a == "statespace") return "states";
10901 if (a == "reward-steady" || a == "rewardsteady") return "reward";
10902 if (a == "reward-value" || a == "rewardvalue") return "rewardvalue";
10903 if (a == "node-chain" || a == "node-chain-table") return "nodechain";
10904 // The JAR's four probability getters and its four samplers, each answered
10905 // whole by one arm here. Collapsing them is not a loss: the arm emits every
10906 // one of the four, so a caller asking for the aggregate receives it beside
10907 // the joint rather than instead of it.
10908 if (a == "prob-aggr" || a == "prob-sys" || a == "prob-sys-aggr") return "prob";
10909 if (a == "prob-marg" || a == "probmarg") return "marg";
10910 if (a == "prob-sys-marg" || a == "probsysmarg" || a == "sys-marg") return "sysmarg";
10911 if (a == "sample-aggr" || a == "sample-sys" || a == "sample-sys-aggr") return "sample";
10912 // `getStageTable` IS `getAvgTable` on a Network model, in the reference and
10913 // in the JAR both: a network has one implicit stage, and only an
10914 // Environment has several. Mapped here rather than given an arm of its own,
10915 // because an arm would be a second name for one table and free to drift
10916 // from it.
10917 if (a == "stage") return "avg";
10918 return a;
10919}
10920
10921/** `-a` split on commas, each token normalized; never empty. */
10922std::vector<std::string> analysis_list(const std::string& spec) {
10923 std::vector<std::string> out;
10924 std::size_t at = 0;
10925 while (at <= spec.size()) {
10926 const std::size_t comma = spec.find(',', at);
10927 std::string tok =
10928 spec.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
10929 // A stray space around a comma is a typo, not a different analysis.
10930 while (!tok.empty() && std::isspace(static_cast<unsigned char>(tok.front())))
10931 tok.erase(tok.begin());
10932 while (!tok.empty() && std::isspace(static_cast<unsigned char>(tok.back())))
10933 tok.pop_back();
10934 if (tok.empty())
10935 throw line::InputError("-a takes a comma-separated list of analyses and one entry of '" +
10936 spec + "' is empty");
10937 // `all` is the JAR's composite of the station table and the system one,
10938 // expanded HERE rather than inside a solver arm so every downstream
10939 // whitelist sees the two analyses it already knows.
10940 if (normalize_analysis(tok) == "all") {
10941 out.push_back("avg");
10942 out.push_back("sys");
10943 } else {
10944 out.push_back(normalize_analysis(tok));
10945 }
10946 if (comma == std::string::npos) break;
10947 at = comma + 1;
10948 }
10949 if (out.empty()) throw line::InputError("-a takes at least one analysis");
10950 return out;
10951}
10952
10953struct Options {
10954 std::string file, input = "json", output = "readable", solver = "auto", analysis = "avg";
10955 std::string arith = "double", api, args;
10956 bool help = false, help_all = false, version = false, list = false;
10957 /**
10958 * `--find-solver [metric]`: report which solvers and methods can analyze the
10959 * model named by -f, and exit without solving it.
10960 *
10961 * `find_solver_all` is `--find-solver-all`, which keeps the refused pairs
10962 * and the reason each was refused. The report is arithmetic-independent --
10963 * it asks feature sets and shapes, not numbers -- so it always reads the
10964 * model at double and ignores --arith.
10965 */
10966 bool find_solver = false, find_solver_all = false;
10967 std::string find_solver_metric;
10968 /** `--install`: run the environment check and exit, solving nothing. */
10969 bool install = false;
10970 /**
10971 * `--generate <spec>`: draw a random model from a JSON specification and
10972 * print it, solving nothing.
10973 *
10974 * The spec is taken INLINE when it starts with `{`, from standard input
10975 * when it is `-` or empty, and from a file otherwise, so a host that has
10976 * the spec in a string never has to stage a file for it. `generate_given`
10977 * rather than a non-empty test, because `--generate ""` means "read stdin"
10978 * and is not the same as not passing the flag.
10979 */
10980 std::string generate;
10981 bool generate_given = false;
10982 /**
10983 * Whether -i was actually passed.
10984 *
10985 * Without it a `.lqnx` path could not be recognised: `input` defaults to
10986 * json, and a defaulted json is indistinguishable from an explicit one, so
10987 * the extension sniff below would either never fire or would override a
10988 * caller who said `-i json` deliberately.
10989 */
10990 bool input_given = false;
10991 /**
10992 * `-p/--port` and `-m/--maxreq`: server mode.
10993 *
10994 * `port == 0` is "not given" and not "port 0": binding port 0 asks the
10995 * kernel for an ephemeral one, which a caller who typed no port did not
10996 * ask for. `maxreq == 0` is unbounded, matching the JAR's documented
10997 * "quit after this many requests" with no cap by default.
10998 */
10999 int port = 0;
11000 int maxreq = 0;
11001 Knobs knobs;
11002};
11003
11004/** Whether `file` ends in one of JMT's three simulation-document extensions. */
11005bool has_jsim_extension(const std::string& file) {
11006 const std::string::size_type dot = file.find_last_of('.');
11007 if (dot == std::string::npos) return false;
11008 std::string ext = file.substr(dot + 1);
11009 for (std::size_t i = 0; i < ext.size(); ++i)
11010 ext[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(ext[i])));
11011 return ext == "jsim" || ext == "jsimg" || ext == "jsimw";
11012}
11013
11014/** Whether `file` ends in the PNML extension. */
11015bool has_pnml_extension(const std::string& file) {
11016 const std::string::size_type dot = file.find_last_of('.');
11017 if (dot == std::string::npos) return false;
11018 std::string ext = file.substr(dot + 1);
11019 for (std::size_t i = 0; i < ext.size(); ++i)
11020 ext[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(ext[i])));
11021 return ext == "pnml";
11022}
11023
11024/** Whether `file` ends in one of the layered model's two extensions. */
11025bool has_lqn_extension(const std::string& file) {
11026 const std::string::size_type dot = file.find_last_of('.');
11027 if (dot == std::string::npos) return false;
11028 std::string ext = file.substr(dot + 1);
11029 for (std::size_t i = 0; i < ext.size(); ++i)
11030 ext[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(ext[i])));
11031 return ext == "lqnx" || ext == "xml";
11032}
11033
11034Options parse_args(int argc, char** argv) {
11035 Options o;
11036 for (int i = 1; i < argc; ++i) {
11037 std::string a = argv[i];
11038 auto next = [&](const char* what) -> std::string {
11039 if (i + 1 >= argc) throw line::InputError(std::string("missing value after ") + what);
11040 return argv[++i];
11041 };
11042 if (a == "-h" || a == "--help") o.help = true;
11043 else if (a == "--help-all" || a == "--help-full") o.help_all = true;
11044 else if (a == "-V" || a == "--version") o.version = true;
11045 else if (a == "--install") o.install = true;
11046 else if (a == "--list-api") o.list = true;
11047 else if (a == "--generate") {
11048 // The value is OPTIONAL: a bare --generate reads the spec from
11049 // standard input, as --api does with its --args.
11050 o.generate_given = true;
11051 // A lone `-` IS the value (it names standard input); anything
11052 // else beginning with `-` is the next flag.
11053 if (i + 1 < argc && (argv[i + 1][0] != '-' || argv[i + 1][1] == '\0'))
11054 o.generate = argv[++i];
11055 }
11056 else if (a == "--find-solver" || a == "--find-method" || a == "--help-model") {
11057 o.find_solver = true;
11058 // The metric is OPTIONAL, so it is taken only when the next token is
11059 // not itself a flag: `--find-solver -f m.json` must not swallow -f.
11060 if (i + 1 < argc && argv[i + 1][0] != '-') o.find_solver_metric = argv[++i];
11061 } else if (a == "--find-solver-all" || a == "--find-method-all") {
11062 o.find_solver = true;
11063 o.find_solver_all = true;
11064 if (i + 1 < argc && argv[i + 1][0] != '-') o.find_solver_metric = argv[++i];
11065 }
11066 else if (a == "-f" || a == "--file") o.file = next("-f");
11067 else if (a == "-i" || a == "--input") { o.input = next("-i"); o.input_given = true; }
11068 else if (a == "-o" || a == "--output") o.output = next("-o");
11069 else if (a == "-s" || a == "--solver") o.solver = next("-s");
11070 else if (a == "-a" || a == "--analysis") o.analysis = next("-a");
11071 else if (a == "--arith") o.arith = next("--arith");
11072 else if (a == "-p" || a == "--port") {
11073 const std::string v = next("-p");
11074 const long n = std::atol(v.c_str());
11075 if (n < 1 || n > 65535)
11076 throw line::InputError("-p takes a TCP port in 1..65535 (got '" + v + "')");
11077 o.port = static_cast<int>(n);
11078 } else if (a == "-m" || a == "--maxreq") {
11079 const std::string v = next("-m");
11080 const long n = std::atol(v.c_str());
11081 if (n < 1)
11082 throw line::InputError(
11083 "-m is the number of requests the server serves before quitting and must be "
11084 "positive; omit it to serve indefinitely (got '" + v + "')");
11085 o.maxreq = static_cast<int>(n);
11086 }
11087 else if (a == "--api") o.api = next("--api");
11088 else if (a == "--args") o.args = next("--args");
11089 else if (a == "--method") o.knobs.method = next("--method");
11090 else if (a == "--qrf-params") o.knobs.qrf_params = next("--qrf-params");
11091 else if (a == "--qrf-alpha") o.knobs.qrf_alpha = next("--qrf-alpha");
11092 else if (a == "--level") {
11093 const std::string v = next("--level");
11094 const int lv = std::atoi(v.c_str());
11095 if (lv < 1) throw line::InputError("--level must be a positive integer (got '" + v + "')");
11096 o.knobs.level = lv;
11097 }
11098 else if (a == "--samples") {
11099 const std::string v = next("--samples");
11100 const double d = std::atof(v.c_str()); // accepts 1e6 as well as 1000000
11101 if (!(d >= 1.0))
11102 throw line::InputError("--samples must be a positive count (got '" + v + "')");
11103 o.knobs.samples = static_cast<std::size_t>(d);
11104 } else if (a == "-d" || a == "--seed") {
11105 const std::string v = next("--seed");
11106 o.knobs.seed = std::strtoul(v.c_str(), nullptr, 10);
11107 if (o.knobs.seed == 0)
11108 throw line::InputError("--seed must be a positive integer (got '" + v + "')");
11109 } else if (a == "--warmupfrac") {
11110 const std::string v = next("--warmupfrac");
11111 const double f = std::atof(v.c_str());
11112 if (!(f >= 0.0 && f < 1.0))
11113 throw line::InputError(
11114 "--warmupfrac is the fraction of the path discarded before the means are "
11115 "taken and must lie in [0,1) (got '" + v + "')");
11116 o.knobs.warmupfrac = f;
11117 } else if (a == "--pstar") {
11118 const std::string v = next("--pstar");
11119 const double ps = std::atof(v.c_str());
11120 if (!(ps > 0.0))
11121 throw line::InputError(
11122 "--pstar is the exponent of the fluid p-norm smoothing and must be positive "
11123 "(got '" + v + "')");
11124 o.knobs.pstar = ps;
11125 } else if (a == "--busyperiod" || a == "--busyperiod-subnet") {
11126 // Same all-or-nothing parse as --marg-states: an entry dropped from
11127 // the list is a DIFFERENT report, not a shorter one.
11128 const bool orders = (a == "--busyperiod");
11129 const std::string v = next(orders ? "--busyperiod" : "--busyperiod-subnet");
11130 std::vector<std::size_t>& into = orders ? o.knobs.busy_orders : o.knobs.busy_subnet;
11131 std::size_t at = 0;
11132 while (at <= v.size()) {
11133 const std::size_t comma = v.find(',', at);
11134 const std::string tok =
11135 v.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
11136 if (tok.empty() || tok.find_first_not_of("0123456789") != std::string::npos ||
11137 std::atol(tok.c_str()) < 1)
11138 throw line::InputError(
11139 std::string(orders ? "--busyperiod takes a comma-separated list of "
11140 "positive orders"
11141 : "--busyperiod-subnet takes a comma-separated list of "
11142 "1-based station indexes") +
11143 " (got '" + v + "')");
11144 into.push_back(static_cast<std::size_t>(std::atol(tok.c_str())));
11145 if (comma == std::string::npos) break;
11146 at = comma + 1;
11147 }
11148 } else if (a == "--tol") o.knobs.tol = std::atof(next("--tol").c_str());
11149 else if (a == "--iter_tol") o.knobs.iter_tol = std::atof(next("--iter_tol").c_str());
11150 else if (a == "--iter_max") o.knobs.iter_max = std::atoi(next("--iter_max").c_str());
11151 else if (a == "--max-states") {
11152 const std::string v = next("--max-states");
11153 const long long n = std::atoll(v.c_str());
11154 if (n <= 0)
11155 throw line::InputError(
11156 "--max-states truncates an open agent's queue-length dimension and takes a "
11157 "positive state count (got '" + v + "')");
11158 o.knobs.max_states = n;
11159 }
11160 else if (a == "--multiserver") o.knobs.multiserver = next("--multiserver");
11161 else if (a == "--fork-join" || a == "--fork_join")
11162 o.knobs.fork_join = next("--fork-join");
11163 else if (a == "--tran-points" || a == "--tran_points") {
11164 const std::string v = next("--tran-points");
11165 const long n = std::atol(v.c_str());
11166 if (n < 2)
11167 throw line::InputError(
11168 "--tran-points is the number of points on the transient grid and needs at "
11169 "least two, a start and an end (got '" + v + "')");
11170 o.knobs.tran_points = static_cast<std::size_t>(n);
11171 }
11172 else if (a == "--mdd-tol" || a == "--mdd_tol") {
11173 const std::string v = next("--mdd-tol");
11174 const double d = std::atof(v.c_str());
11175 if (!(d > 0.0))
11176 throw line::InputError("--mdd-tol must be a positive tolerance (got '" + v + "')");
11177 o.knobs.mdd_tol = d;
11178 } else if (a == "--mdd-maxiter" || a == "--mdd_maxiter") {
11179 const std::string v = next("--mdd-maxiter");
11180 const long n = std::atol(v.c_str());
11181 if (n < 1)
11182 throw line::InputError(
11183 "--mdd-maxiter must be a positive sweep count (got '" + v + "')");
11184 o.knobs.mdd_maxiter = static_cast<int>(n);
11185 }
11186 else if (a == "--fj-accuracy") {
11187 const std::string v = next("--fj-accuracy");
11188 const long n = std::atol(v.c_str());
11189 if (n < 1)
11190 throw line::InputError(
11191 "--fj-accuracy is the FJ_codes truncation C of the queue-length difference "
11192 "between the two fork-join branches and must be at least 1 (got '" + v + "')");
11193 o.knobs.fj_accuracy = static_cast<int>(n);
11194 } else if (a == "--fj-tmode") {
11195 const std::string v = next("--fj-tmode");
11196 if (v != "NARE" && v != "Sylves")
11197 throw line::InputError(
11198 "--fj-tmode selects how computeT.m solves for the T matrix and is 'NARE' (the "
11199 "Riccati route, the default) or 'Sylves' (the fixed-point iteration); got '" +
11200 v + "'");
11201 o.knobs.fj_tmode = v;
11202 } else if (a == "--timescale") {
11203 const std::string v = next("--timescale");
11204 if (v != "auto" && v != "discrete" && v != "continuous")
11205 throw line::InputError(
11206 "--timescale decides whether the model is read on a slot lattice and is "
11207 "'auto' (the default), 'discrete' or 'continuous'; got '" + v + "'");
11208 o.knobs.timescale = v;
11209 }
11210 else if (a == "--force") {
11211 o.knobs.force = true;
11212 }
11213 else if (a == "--cutoff") {
11214 const std::string v = next("--cutoff");
11215 if (v.find(',') != std::string::npos || v.find(';') != std::string::npos) {
11216 o.knobs.cutoff_mat = parse_cutoff_matrix(v);
11217 if (o.knobs.cutoff_mat.empty())
11218 throw line::InputError(
11219 "--cutoff takes a number or a per-(station,class) matrix written "
11220 "'r1c1,r1c2;r2c1,r2c2' (got '" + v + "')");
11221 } else {
11222 const double d = std::atof(v.c_str());
11223 if (!(d >= 1.0))
11224 throw line::InputError(
11225 "--cutoff must be a positive job count per open class (got '" + v + "')");
11226 o.knobs.cutoff = d;
11227 }
11228 } else if (a == "--tspan" || a == "--timespan") {
11229 const std::string v = next("--tspan");
11230 // BOTH SEPARATORS. This port has always written the horizon
11231 // `t0:t1`, and `jline.cli.LineCLI --timespan` has always written it
11232 // `t0,t1`; accepting only one made a command line that names a
11233 // horizon unportable between the two CLIs even after the flag names
11234 // were reconciled.
11235 std::string::size_type sep = v.find(':');
11236 if (sep == std::string::npos) sep = v.find(',');
11237 // A bare value is the END of the horizon and starts at 0, which is
11238 // what a transient from the initial state means; `t0:t1` states both.
11239 const double lo = sep == std::string::npos ? 0.0 : std::atof(v.substr(0, sep).c_str());
11240 const double hi = std::atof(
11241 (sep == std::string::npos ? v : v.substr(sep + 1)).c_str());
11242 // An INFINITE horizon is refused here rather than integrated to:
11243 // pi(t) on [0, Inf) is the stationary vector, which -a avg reports.
11244 if (!(hi > lo) || !(lo >= 0.0) || !std::isfinite(hi))
11245 throw line::InputError(
11246 "--tspan must be a finite horizon 0 <= t0 < t1, given as <t1>, <t0>:<t1> or "
11247 "<t0>,<t1> (got '" + v + "')");
11248 o.knobs.t0 = lo;
11249 o.knobs.t1 = hi;
11250 } else if (a == "-n" || a == "--node") {
11251 const std::string v = next("--node");
11252 const long n = std::atol(v.c_str());
11253 if (n < 1)
11254 throw line::InputError("--node must be a positive 1-based node index (got '" + v +
11255 "')");
11256 o.knobs.node = static_cast<std::size_t>(n);
11257 } else if (a == "-c" || a == "--class") {
11258 const std::string v = next("--class");
11259 const long c = std::atol(v.c_str());
11260 if (c < 1)
11261 throw line::InputError("--class must be a positive 1-based class index (got '" + v +
11262 "')");
11263 o.knobs.jobclass = static_cast<std::size_t>(c);
11264 } else if (a == "--marg-states" || a == "--marg_states") {
11265 // Every entry must parse: a dropped one shortens the curve silently.
11266 const std::string v = next("--marg-states");
11267 std::size_t at = 0;
11268 while (at <= v.size()) {
11269 const std::size_t comma = v.find(',', at);
11270 const std::string tok =
11271 v.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
11272 if (tok.empty() || tok.find_first_not_of("0123456789") != std::string::npos)
11273 throw line::InputError(
11274 "--marg-states takes a comma-separated list of non-negative job counts "
11275 "(got '" + v + "')");
11276 o.knobs.marg_states.push_back(std::atol(tok.c_str()));
11277 if (comma == std::string::npos) break;
11278 at = comma + 1;
11279 }
11280 } else if (a == "--state") {
11281 // Same all-or-nothing discipline as --marg-states: a state vector
11282 // with one entry dropped is a DIFFERENT state, not a shorter one,
11283 // and the length is checked against the node's own space later.
11284 const std::string v = next("--state");
11285 std::size_t at = 0;
11286 while (at <= v.size()) {
11287 const std::size_t comma = v.find(',', at);
11288 const std::string tok =
11289 v.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
11290 if (tok.empty() || tok.find_first_not_of("0123456789") != std::string::npos)
11291 throw line::InputError(
11292 "--state is the state vector -a prob asks about and takes a "
11293 "comma-separated list of non-negative counts (got '" + v + "')");
11294 o.knobs.state.push_back(std::atol(tok.c_str()));
11295 if (comma == std::string::npos) break;
11296 at = comma + 1;
11297 }
11298 } else if (a == "--events") {
11299 const std::string v = next("--events");
11300 const double d = std::atof(v.c_str()); // accepts 5e3 as well as 5000
11301 if (!(d >= 1.0))
11302 throw line::InputError(
11303 "--events is the length of a sampled trajectory and must be a positive event "
11304 "count (got '" + v + "')");
11305 o.knobs.events = static_cast<std::size_t>(d);
11306 } else if (a == "--timestep") {
11307 const std::string v = next("--timestep");
11308 const double d = std::atof(v.c_str());
11309 if (!(d > 0.0) || !std::isfinite(d))
11310 throw line::InputError(
11311 "--timestep is the fixed output step of a transient analysis and must be a "
11312 "positive finite time (got '" + v + "')");
11313 o.knobs.timestep = d;
11314 } else if (a == "--percentiles") {
11315 const std::string v = next("--percentiles");
11316 std::size_t at = 0;
11317 while (at <= v.size()) {
11318 const std::size_t comma = v.find(',', at);
11319 const std::string tok =
11320 v.substr(at, comma == std::string::npos ? std::string::npos : comma - at);
11321 if (tok.empty())
11322 throw line::InputError(
11323 "--percentiles takes a comma-separated list of levels (got '" + v + "')");
11324 double p = std::atof(tok.c_str());
11325 // A LEVEL ABOVE 1 IS A PERCENT, below it a probability. The JAR
11326 // documents `--percentiles 50,90,95,99` and MATLAB stores
11327 // `pers_stored` as fractions, so both spellings reach this port
11328 // and the magnitude is what tells them apart. 1 itself is read
11329 // as the fraction: P(T <= t) = 1 is a level, 1% is not one
11330 // anybody asks for beside 50, 90 and 99.
11331 if (p > 1.0) p /= 100.0;
11332 if (!(p > 0.0) || !(p < 1.0))
11333 throw line::InputError(
11334 "--percentiles levels lie strictly inside (0,1) as fractions or (0,100) "
11335 "as percents; the 100th percentile of an unbounded law is not finite "
11336 "(got '" + tok + "')");
11337 o.knobs.percentiles.push_back(p);
11338 if (comma == std::string::npos) break;
11339 at = comma + 1;
11340 }
11341 } else if (a == "--reward-name" || a == "--reward_name") {
11342 o.knobs.reward_name = next("--reward-name");
11343 } else if (a == "--notation") {
11344 const std::string v = next("--notation");
11345 // The exporter validates the name; it is not defaulted here, so an
11346 // unrecognised notation is refused rather than answered with scalar.
11347 o.knobs.notation = v;
11348 } else if (a == "--symbolic") {
11349 // `sym_resolve` validates the name: auto, none, a URL or an image.
11350 // It is not defaulted here, so `--symbolic none` stays local rather
11351 // than being read as "not given" and searching anyway.
11352 o.knobs.symbolic = next("--symbolic");
11353 } else if (a == "--equilibria") o.knobs.equilibria = true;
11354 else if (a == "--perm-engine") {
11355 // The analyzer validates the name; an unrecognised engine is
11356 // refused rather than answered with exact.
11357 o.knobs.method_perm = next("--perm-engine");
11358 } else if (a == "--transient-method") {
11359 // The analyzer validates the name, so an unrecognised one is
11360 // refused rather than answered with the default.
11361 o.knobs.transient_method = next("--transient-method");
11362 } else if (a == "--rate-sched") {
11363 o.knobs.rate_sched = next("--rate-sched");
11364 } else if (a == "--ctmc-tv-ngrid") {
11365 const std::string v = next("--ctmc-tv-ngrid");
11366 const long n = std::atol(v.c_str());
11367 if (n < 2)
11368 throw line::InputError("--ctmc-tv-ngrid is the grid size of the rate_sched "
11369 "propagator and must be an integer >= 2 (got '" + v + "')");
11370 o.knobs.ctmc_tv_ngrid = static_cast<std::size_t>(n);
11371 } else if (a == "--fau-epsilon") {
11372 const std::string v = next("--fau-epsilon");
11373 const double d = std::atof(v.c_str());
11374 if (!(d > 0.0) || !std::isfinite(d))
11375 throw line::InputError(
11376 "--fau-epsilon is the probability mass the transient grid may discard and "
11377 "must be a positive finite number (got '" + v + "')");
11378 o.knobs.fau_epsilon = d;
11379 } else if (a == "--fau-delta") {
11380 const std::string v = next("--fau-delta");
11381 const double d = std::atof(v.c_str());
11382 if (!(d >= 0.0) || !std::isfinite(d))
11383 throw line::InputError(
11384 "--fau-delta is the occupancy below which a state is dropped and must be a "
11385 "nonnegative finite number (got '" + v + "')");
11386 o.knobs.fau_delta = d;
11387 } else if (a == "--cdf-algorithm") {
11388 // The analyzer validates the name; it is not defaulted here, so an
11389 // unrecognised algorithm is refused rather than answered with exact.
11390 o.knobs.cdf_algorithm = next("--cdf-algorithm");
11391 } else if (a == "--passage-from") o.knobs.passage_from = next("--passage-from");
11392 else if (a == "--passage-into") o.knobs.passage_into = next("--passage-into");
11393 else if (a == "--passage-method") o.knobs.passage_method = next("--passage-method");
11394 else if (a == "--passage-orders")
11395 o.knobs.passage_orders = static_cast<std::size_t>(std::stoul(next("--passage-orders")));
11396 else if (a == "--no-interlocking") o.knobs.no_interlocking = true;
11397 else if (a == "--interlock-method") o.knobs.interlock_method = next("--interlock-method");
11398 else if (a == "--interlock-maxpaths")
11399 o.knobs.interlock_maxpaths = std::stod(next("--interlock-maxpaths"));
11400 else if (a == "--interlock-refpath-scope")
11401 o.knobs.interlock_refpath_scope = next("--interlock-refpath-scope");
11402 else if (a == "--layer-solver") o.knobs.layer_solver = next("--layer-solver");
11403 else if (a == "--stage-solver") o.knobs.stage_solver = next("--stage-solver");
11404 else if (a == "--map-env") o.knobs.map_env = next("--map-env");
11405 else if (a == "--map-env-method") o.knobs.map_env_method = next("--map-env-method");
11406 else if (a == "--map-env-maxstages")
11407 o.knobs.map_env_maxstages = static_cast<std::size_t>(std::stoul(next("--map-env-maxstages")));
11408 else if (a == "--ln-transient") o.knobs.ln_transient = next("--ln-transient");
11409 else if (a == "--ln-transient-channels")
11410 o.knobs.ln_transient_channels = next("--ln-transient-channels");
11411 else if (a == "--sens-method") o.knobs.sens_method = next("--sens-method");
11412 else if (a == "--sens-scheme") o.knobs.sens_scheme = next("--sens-scheme");
11413 else if (a == "--sens-step") {
11414 const std::string v = next("--sens-step");
11415 const double h = std::atof(v.c_str());
11416 if (!(h > 0.0) || !(h < 1.0))
11417 throw line::InputError(
11418 "--sens-step is the RELATIVE rate perturbation and must lie in (0,1) (got '" +
11419 v + "')");
11420 o.knobs.sens_step = h;
11421 }
11422 else if (a == "--uq-solver") o.knobs.uq_solver = next("--uq-solver");
11423 else if (a == "--keep") o.knobs.keep = true;
11424 else if (a == "--verbose") o.knobs.verbose = true;
11425 else if (a == "--remote") o.knobs.remote = true;
11426 else if (a == "--remote-url") {
11427 // Implies --remote: a URL given and then ignored because the flag
11428 // was forgotten would solve LOCALLY and report nothing about it.
11429 o.knobs.remote_url = next("--remote-url");
11430 o.knobs.remote = true;
11431 }
11432 else if (a == "--timeout") {
11433 const std::string v = next("--timeout");
11434 const long s = std::atol(v.c_str());
11435 if (s < 1)
11436 throw line::InputError("--timeout is a deadline in seconds and must be positive "
11437 "(got '" + v + "')");
11438 o.knobs.timeout_seconds = static_cast<int>(s);
11439 }
11440 else if (a == "--repeat") {
11441 const std::string v = next("--repeat");
11442 const long n = std::atol(v.c_str());
11443 if (n < 1)
11444 throw line::InputError("--repeat must be a positive run count (got '" + v + "')");
11445 o.knobs.repeat = static_cast<int>(n);
11446 }
11447 // ---- the simulator's own knobs ------------------------------------
11448 else if (a == "--ldes-tranfilter") {
11449 const std::string v = next("--ldes-tranfilter");
11450 if (v != "mser5" && v != "fixed" && v != "none")
11451 throw line::InputError(
11452 "--ldes-tranfilter selects the warmup filter and is mser5, fixed or none (got '" +
11453 v + "')");
11454 o.knobs.ldes_tranfilter = v;
11455 }
11456 else if (a == "--ldes-warmupfrac") {
11457 const std::string v = next("--ldes-warmupfrac");
11458 const double d = std::atof(v.c_str());
11459 if (!(d >= 0.0 && d < 1.0))
11460 throw line::InputError(
11461 "--ldes-warmupfrac is the fraction of the run the fixed filter discards and "
11462 "lies in [0,1) (got '" + v + "')");
11463 o.knobs.ldes_warmupfrac = d;
11464 }
11465 else if (a == "--ldes-cimethod") {
11466 const std::string v = next("--ldes-cimethod");
11467 if (v != "obm" && v != "bm" && v != "spectral" && v != "none")
11468 throw line::InputError(
11469 "--ldes-cimethod selects the confidence-interval estimator and is obm, bm, "
11470 "spectral or none (got '" + v + "')");
11471 o.knobs.ldes_cimethod = v;
11472 }
11473 else if (a == "--ldes-cnvgon") o.knobs.ldes_cnvgon = true;
11474 else if (a == "--ldes-cnvgtol") {
11475 // Implies --ldes-cnvgon: a tolerance given and then ignored because
11476 // the switch was forgotten would run the full budget and say nothing.
11477 const std::string v = next("--ldes-cnvgtol");
11478 const double d = std::atof(v.c_str());
11479 if (!(d > 0.0 && d < 1.0))
11480 throw line::InputError(
11481 "--ldes-cnvgtol is a RELATIVE precision target and lies in (0,1) (got '" + v +
11482 "')");
11483 o.knobs.ldes_cnvgtol = d;
11484 o.knobs.ldes_cnvgon = true;
11485 }
11486 else if (a == "--ldes-slotted") o.knobs.ldes_slotted = true;
11487 else if (a == "--slotted") o.knobs.slotted = true;
11488 else if (a == "--slotlength") {
11489 // Implies --slotted, as --ldes-slotlength does for the simulator.
11490 const std::string v = next("--slotlength");
11491 const double d = std::atof(v.c_str());
11492 if (!(d > 0.0))
11493 throw line::InputError(
11494 "--slotlength is the slot of the discrete time scale and must be positive "
11495 "(got '" + v + "')");
11496 o.knobs.slotlength = d;
11497 o.knobs.slotted = true;
11498 }
11499 else if (a == "--ldes-slotlength") {
11500 // Implies --ldes-slotted, as --slotlength does on the engine's CLI.
11501 const std::string v = next("--ldes-slotlength");
11502 const double d = std::atof(v.c_str());
11503 if (!(d > 0.0))
11504 throw line::InputError(
11505 "--ldes-slotlength is the slot of the discrete time scale and must be positive "
11506 "(got '" + v + "')");
11507 o.knobs.ldes_slotlength = d;
11508 o.knobs.ldes_slotted = true;
11509 }
11510 else if (a == "--jmt-replications") {
11511 const std::string v = next("--jmt-replications");
11512 const long n = std::atol(v.c_str());
11513 if (n < 1)
11514 throw line::InputError(
11515 "--jmt-replications is a positive count of independent runs (got '" + v + "')");
11516 o.knobs.jmt_replications = static_cast<int>(n);
11517 }
11518 else if (a == "--ldes-replications") {
11519 const std::string v = next("--ldes-replications");
11520 const long n = std::atol(v.c_str());
11521 if (n < 1)
11522 throw line::InputError(
11523 "--ldes-replications is a positive count of independent runs (got '" + v + "')");
11524 o.knobs.ldes_replications = static_cast<int>(n);
11525 }
11526 else if (a == "--ldes-numthreads") {
11527 const std::string v = next("--ldes-numthreads");
11528 const long n = std::atol(v.c_str());
11529 if (n < 1)
11530 throw line::InputError(
11531 "--ldes-numthreads is a positive worker count (got '" + v + "')");
11532 o.knobs.ldes_numthreads = static_cast<int>(n);
11533 }
11534 else if (a == "--ldes-maxtime") {
11535 const std::string v = next("--ldes-maxtime");
11536 const double d = std::atof(v.c_str());
11537 if (!(d > 0.0))
11538 throw line::InputError(
11539 "--ldes-maxtime is a wall-clock budget in seconds and must be positive (got '" +
11540 v + "')");
11541 o.knobs.ldes_maxtime = d;
11542 }
11543 else if (a == "--ldes-initsol") {
11544 // A STATION-MAJOR placement, [st0_cl0, st0_cl1, ...]; the engine
11545 // reads it as the initial state and skips its default placement.
11546 const std::string v = next("--ldes-initsol");
11547 std::size_t b = 0;
11548 while (b <= v.size()) {
11549 const std::size_t e = v.find(',', b);
11550 const std::string tok =
11551 v.substr(b, e == std::string::npos ? std::string::npos : e - b);
11552 if (tok.empty())
11553 throw line::InputError(
11554 "--ldes-initsol is a comma-separated placement with no empty entry (got '" +
11555 v + "')");
11556 o.knobs.ldes_initsol.push_back(std::atof(tok.c_str()));
11557 if (e == std::string::npos) break;
11558 b = e + 1;
11559 }
11560 }
11561 else if (a == "--ldes-rest-url") o.knobs.ldes_rest_url = next("--ldes-rest-url");
11562 else if (a == "-v" || a == "--verbosity") {
11563 const std::string v = next(a.c_str());
11564 // The JAR names two levels and this port's help documents three;
11565 // all five spellings are accepted, and an unknown one is refused
11566 // rather than read as `standard`, which would silence nothing while
11567 // reporting that it had.
11568 if (v != "silent" && v != "standard" && v != "normal" && v != "debug" &&
11569 v != "verbose")
11570 throw line::InputError(
11571 "-v takes silent, standard (the JAR spells it normal) or debug; got '" + v +
11572 "'");
11573 o.knobs.verbosity = (v == "normal") ? "standard" : v;
11574 }
11575 else if (!a.empty() && a[0] == '-')
11576 throw line::InputError("unknown option: " + a);
11577 else
11578 o.file = a;
11579 }
11580 return o;
11581}
11582
11583} // namespace
11584
11585/**
11586 * Everything one invocation does once the arguments are in hand.
11587 *
11588 * SPLIT OUT OF `main` FOR SERVER MODE, which runs it once per request with a
11589 * different `-f` and a captured stdout. Keeping one body means a request served
11590 * over the socket takes exactly the path the same command line takes at the
11591 * shell -- the failure this avoids is a server that answers slightly differently
11592 * from the CLI it is supposed to BE.
11593 */
11594/**
11595 * `--find-solver`: which solvers and methods can analyze the model named by -f.
11596 *
11597 * It reports rather than solves, so it stops before the solver ladder in
11598 * `solve_model_dispatch` and before every knob that describes a run. The answer
11599 * is arithmetic-independent -- `auto_find_solver` asks feature sets, shapes and
11600 * gates, never numbers -- so the model is read at double whatever --arith says,
11601 * and a caller who passed one is told rather than silently obeyed.
11602 *
11603 * The layered path is not covered: `auto_find_solver` narrows the flat Network
11604 * families, and a LayeredNetwork's are `ln` and `lqns`, which this port reaches
11605 * through `solve_lqn_dispatch` and not through an AUTO of its own.
11606 */
11607int find_solver_report(const Options& o) {
11608 // A MODEL ON STDIN IS A MODEL. This used to refuse anything but `-f`, which
11609 // made the report the one part of the vocabulary a piped model could not
11610 // reach -- and `read_model` has read stdin on the empty path all along.
11611 if (o.file.empty() && !g_stdin_loaded)
11612 throw line::InputError("--find-solver reports on a model; name one with -f");
11613 const bool layered = o.file.empty()
11614 ? line::io::is_layered_json_text(stdin_model_text())
11615 : (has_lqn_extension(o.file) || line::io::is_layered_json(o.file));
11616 if (!o.input_given && layered)
11618 "--find-solver reports on a flat Network model; a layered one is solved by -s ln "
11619 "and -s lqns, which this port reaches through the -i lqnx path");
11620 if (o.output != "readable" && o.output != "json")
11621 throw line::InputError("unknown -o '" + o.output +
11622 "'; --find-solver reports as: readable, json");
11623 line::qn::Network<double> net = read_model<double>(o.file);
11624 const std::vector<line::autosolver::SolverCandidate> rows = line::autosolver::auto_find_solver(
11625 net.get_struct(), o.find_solver_metric, o.find_solver_all);
11626 if (o.output != "json") {
11627 std::printf("%s", line::autosolver::auto_find_solver_table(rows).c_str());
11628 return 0;
11629 }
11630 // THE STRUCTURED ROWS EXIST ALREADY AND HAD NO WAY OUT. auto_find_solver
11631 // returns solver, method, runnable, the method class, the metrics and the
11632 // reason for a refusal, and the fixed-width table was the only rendering,
11633 // so every caller that wanted the reason had to parse columns. All five
11634 // front ends and the MCP `find_solver` tool benefit from this arm.
11635 line::reg::Json j = line::reg::Json::object();
11636 line::reg::Json cands = line::reg::Json::array();
11637 for (std::size_t i = 0; i < rows.size(); ++i) {
11638 line::reg::Json c = line::reg::Json::object();
11639 c["solver"] = rows[i].solver;
11640 c["method"] = rows[i].method;
11641 c["runnable"] = rows[i].runnable;
11642 c["methodClass"] = rows[i].method_class;
11643 c["reason"] = rows[i].reason;
11644 line::reg::Json ms = line::reg::Json::array();
11645 for (std::size_t k = 0; k < rows[i].metrics.size(); ++k) ms.push_back(rows[i].metrics[k]);
11646 c["metrics"] = ms;
11647 cands.push_back(c);
11648 }
11649 line::reg::Json body = line::reg::Json::object();
11650 body["type"] = "FindSolver";
11651 body["candidates"] = cands;
11652 j["findSolver"] = body;
11653 emit_document(dump_document(j));
11654 return 0;
11655}
11656
11657int run_invocation(Options o) {
11658 // Validated on every path, not only --api: an unrecognised --arith is a
11659 // caller error whatever else the invocation asks for.
11660 line::reg::parse_arith(o.arith);
11661 if (!o.api.empty()) {
11662 if (o.output != "readable" && o.output != "json")
11663 throw line::InputError("unknown -o '" + o.output +
11664 "'; accepted forms are: readable, json");
11665 const line::reg::Json result =
11666 line::reg::api_invoke(o.api, o.arith, read_api_args(o.args));
11667 if (o.output == "json")
11668 emit_document(dump_document(result, 2));
11669 else
11670 std::printf("%s", line::reg::api_render_readable(result).c_str());
11671 return 0;
11672 }
11673 // A .lqnx/.xml path with no -i is taken as a layered model, so naming
11674 // the file is enough to solve it. An explicit -i always wins, so a
11675 // caller who says -i json about an oddly-named file still gets json.
11676 if (!o.input_given && has_lqn_extension(o.file)) o.input = "lqnx";
11677 // The same courtesy for a JMT document: naming the file is enough.
11678 if (!o.input_given && has_jsim_extension(o.file)) o.input = "jsimg";
11679 // And for a PNML document.
11680 if (!o.input_given && has_pnml_extension(o.file)) o.input = "pnml";
11681 // A model.json can carry EITHER model kind, and `linemodel_save` writes
11682 // `.json` for both, so the extension cannot decide it. The content can:
11683 // a `LayeredNetwork` type takes the layered path whatever `-i` says,
11684 // because handing it to the Network reader only produces "model type
11685 // 'LayeredNetwork' is not a Network" one frame further down.
11686 // THE SNIFF NOW COVERS A PIPED DOCUMENT. It took a PATH and was
11687 // therefore skipped entirely on stdin, so a LayeredNetwork arriving on
11688 // the standard input was handed to the Network reader and refused with
11689 // "the model-solving path ports -s mva, nc, ..." -- a message about the
11690 // solver, for a model the CLI could read perfectly well from a file.
11691 // Staging the text to a temporary file would have worked and would cost
11692 // a file per solve, which a host calling this in a sweep notices.
11693 const bool sniffable = o.input != "lqnx" && o.input != "xml" && o.input != "pnml" &&
11694 o.input.compare(0, 4, "jsim") != 0;
11695 if (sniffable && !o.file.empty() && !has_pnml_extension(o.file) &&
11696 !has_jsim_extension(o.file) && line::io::is_layered_json(o.file))
11697 o.input = "lqnx";
11698 else if (sniffable && o.file.empty() && g_stdin_loaded &&
11699 line::io::is_layered_json_text(stdin_model_text()))
11700 o.input = "lqnx";
11701 if (o.input == "lqnx" || o.input == "xml") {
11702 if (o.output != "readable" && o.output != "json" && o.output != "layers")
11703 throw line::InputError("unknown -o '" + o.output +
11704 "'; accepted forms on the layered path are: readable, "
11705 "json, layers");
11706 // ONE ENVELOPE PER ANALYSIS, in the order asked for. `-a avg,sens`
11707 // is the JAR's comma list, and the JAR merges the results into one
11708 // JSON object; here each arm prints as it computes, so the multi
11709 // form emits a sequence of the SAME envelopes a single `-a` emits
11710 // (JSON Lines). That keeps one parser for both spellings, where a
11711 // merged object would need a second one for the multi case alone.
11712 const std::vector<std::string> as = analysis_list(o.analysis);
11713 for (std::size_t i = 0; i + 1 < as.size(); ++i) {
11714 const int rc = solve_lqn_dispatch(o.arith, o.solver, as[i], o.output, o.file,
11715 o.knobs);
11716 if (rc != 0) return rc;
11717 poll_interrupt();
11718 }
11719 return solve_lqn_dispatch(o.arith, o.solver, as.back(), o.output, o.file, o.knobs);
11720 }
11721 // `-i jsim | jsimg | jsimw`: a JMT simulation document, read by
11722 // `read_jsim`. The three extensions name ONE format -- JMT writes the
11723 // same `<sim>` document under all three -- and are accepted separately
11724 // because the JAR CLI accepts them separately and a script naming the
11725 // wrong one is not describing a different model.
11726 if (o.input == "jsim" || o.input == "jsimg" || o.input == "jsimw")
11727 g_jsim_input = true;
11728 else if (o.input == "pnml")
11729 g_pnml_input = true;
11730 else if (o.input != "json")
11732 "the model-solving path reads -i json for a Network model, -i jsim|jsimg|jsimw "
11733 "for a JMT simulation document, -i pnml for a place/transition net and "
11734 "-i lqnx|xml for a layered one (got '" + o.input + "')");
11735 // `-o json` IS WIRED FOR EVERY ANALYSIS, not only `-a avg`. It was
11736 // avg-only, and the restriction was real rather than nominal: each other
11737 // arm printed its own readable shape and nothing else, so accepting
11738 // `json` for one would have handed a caller a table it asked to receive
11739 // as JSON -- the silent-acceptance failure the Knobs struct exists to
11740 // prevent, one layer up. The refusal is therefore removed only now that
11741 // every arm emits a payload (`emit_analysis`), and each answers under a
11742 // key named after its own `-a` so a caller can tell which question was
11743 // answered. On `-a avg` the solver BANNER still precedes the object on
11744 // stdout, as the JAR's does; it contains no brace, so the wrapper's
11745 // first-`{` scan is unaffected. The other arms print no banner: what it
11746 // carried -- the arithmetic, the resolved method, the state count and the
11747 // cutoff -- is inside the payload, where a host reads it as data instead
11748 // of scraping it from a line above.
11749 // `-o jsimg`: EXPORT the model as a JMT simulation document instead of
11750 // solving it. It is the missing half of a pair -- `-i jsimg` has read
11751 // one back all along -- and the C++ twin of MATLAB's `JMTIO`, the JAR's
11752 // `QN2JSIMG` and Python's `qn2jsimg`. Until now `jmt_write_jsim` was
11753 // reachable only from inside the solver's own scratch directory, which
11754 // `--keep` leaves behind, so exporting a model meant solving it first.
11755 //
11756 // The three spellings name ONE format, as they do on `-i` and in JMT.
11757 if (o.output == "jsim" || o.output == "jsimg" || o.output == "jsimw") {
11758 line::qn::Network<double> net = read_model<double>(o.file);
11761 wopt.file_name = "model";
11762 wopt.log_path = sn.log_path;
11763 // The simulation controls the header carries are the ones a solve
11764 // would have written into it, so an exported document run through
11765 // JMT by hand reproduces `-s jmt` rather than the writer defaults.
11766 if (o.knobs.seed) wopt.seed = static_cast<long>(o.knobs.seed);
11767 if (o.knobs.samples) wopt.max_samples = static_cast<double>(o.knobs.samples);
11768 if (o.knobs.t1 >= 0.0) wopt.max_simulated_time = o.knobs.t1;
11769 emit_document(line::io::jmt_write_jsim<double>(sn, wopt));
11770 return 0;
11771 }
11772 if (o.output != "readable" && o.output != "json")
11773 throw line::InputError("unknown -o '" + o.output +
11774 "'; accepted forms are: readable, json, jsimg");
11775 g_json_output = (o.output == "json");
11776 // See the layered path above for why a comma list emits one envelope
11777 // per analysis rather than one merged object.
11778 const std::vector<std::string> as = analysis_list(o.analysis);
11779 for (std::size_t i = 0; i + 1 < as.size(); ++i) {
11780 const int rc = solve_model_dispatch(o.arith, o.solver, as[i], o.file, o.knobs);
11781 if (rc != 0) return rc;
11782 poll_interrupt();
11783 }
11784 return solve_model_dispatch(o.arith, o.solver, as.back(), o.file, o.knobs);
11785}
11786
11787/**
11788 * Redirect a file descriptor into a temporary file for a scope, and give back
11789 * what was written to it.
11790 *
11791 * A TEMPORARY FILE AND NOT A PIPE, which was already the choice here and is
11792 * worth keeping deliberately: a pipe holds 64 KB and nobody is draining it, so
11793 * a solve that prints more than that would deadlock against its own output.
11794 *
11795 * IT IS AN OBJECT so that the restore happens on the way out of the scope
11796 * whatever happens inside it. The server's version below did the same dance
11797 * inline and could not have survived a throw between the dup2 and the restore,
11798 * which is survivable in a one-shot process and is a leaked file descriptor
11799 * per call in a host that calls this in a loop.
11800 */
11802 public:
11803 explicit FdCapture(int fd) : fd_(fd), saved_(-1), tfd_(-1) {
11804 if (fd_ < 0) return; // "do not capture", so the object is inert
11805 const char* tmpdir = std::getenv("TMPDIR");
11806 std::string path =
11807 std::string(tmpdir && *tmpdir ? tmpdir : "/tmp") + "/line-cli-cap-XXXXXX";
11808 std::vector<char> buf(path.begin(), path.end());
11809 buf.push_back('\0');
11810 tfd_ = ::mkstemp(&buf[0]);
11811 if (tfd_ < 0) return;
11812 path_.assign(&buf[0]);
11813 std::fflush(fd_ == 1 ? stdout : stderr);
11814 saved_ = ::dup(fd_);
11815 ::dup2(tfd_, fd_);
11816 }
11817
11818 // restore() closes the descriptor; only take() removes the backing file, so a
11819 // capture abandoned by a throw would leave one behind. The server calls
11820 // run_invocation_captured once per request, so that is a leak per FAILED
11821 // request, which is the same defect the JAR CLI had in its model staging.
11822 // take() clears path_ after removing, so this is a no-op on the normal path.
11824 restore();
11825 if (!path_.empty()) {
11826 std::remove(path_.c_str());
11827 path_.clear();
11828 }
11829 }
11830
11831 /** Restore the descriptor and read back what was written. Idempotent. */
11832 std::string take() {
11833 restore();
11834 if (path_.empty()) return std::string();
11835 std::ifstream in(path_.c_str());
11836 std::string out((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
11837 in.close();
11838 std::remove(path_.c_str());
11839 path_.clear();
11840 return out;
11841 }
11842
11843 bool active() const { return saved_ >= 0; }
11844
11845 private:
11846 void restore() {
11847 if (saved_ < 0) return;
11848 std::fflush(fd_ == 1 ? stdout : stderr);
11849 ::dup2(saved_, fd_);
11850 ::close(saved_);
11851 saved_ = -1;
11852 if (tfd_ >= 0) {
11853 ::close(tfd_);
11854 tfd_ = -1;
11855 }
11856 }
11857 FdCapture(const FdCapture&);
11858 FdCapture& operator=(const FdCapture&);
11859
11860 int fd_;
11861 int saved_;
11862 int tfd_;
11863 std::string path_;
11864};
11865
11866/**
11867 * Run one invocation with stdout captured, and return what it printed.
11868 *
11869 * SERVER MODE'S ONE PIECE OF MACHINERY. Every arm of this CLI writes its answer
11870 * with `printf`, which is the right thing for a command-line tool and leaves a
11871 * server nothing to send. Redirecting fd 1 around the call means the arms need
11872 * no server-aware variant and cannot drift from the command-line behaviour;
11873 * stderr is deliberately NOT captured, so a warning still reaches the operator's
11874 * console rather than being folded into the client's answer.
11875 */
11876std::string run_invocation_captured(Options o, int& rc) {
11877 // EVERY REQUEST STARTS FROM A CLEAN SLATE. Without this a request carrying
11878 // `-i jsimg` left g_jsim_input true and poisoned every later request on the
11879 // same server, which is a live bug in shipped code rather than a
11880 // hypothetical one.
11881 reset_invocation_state(false);
11882 FdCapture cap(1);
11883 if (!cap.active()) {
11884 rc = 3;
11885 return "line-cli: cannot create a capture file for the response\n";
11886 }
11887 std::string err;
11888 try {
11889 rc = run_invocation(o);
11890 } catch (const line::Error& e) {
11891 rc = 2;
11892 err = std::string("line-cli: ") + e.what() + "\n";
11893 } catch (const std::exception& e) {
11894 rc = 3;
11895 err = std::string("line-cli: unexpected failure: ") + e.what() + "\n";
11896 }
11897 std::string out = cap.take();
11898 // THE ERROR IS THE ANSWER when the solve failed: a client that receives an
11899 // empty message cannot tell a refusal from a model with no rows.
11900 return err.empty() ? out : out + err;
11901}
11902
11903/**
11904 * `-p/--port`: serve solve requests over a WebSocket, as `LineWebSocketServer`
11905 * does.
11906 *
11907 * THE PROTOCOL IS THE JAR's, unchanged: one text message per connection, whose
11908 * FIRST LINE is the comma-separated argument list and whose remainder is the
11909 * model document. The JAR overwrites the first two arguments with `--file` and
11910 * the path it staged the document at, so the client's own first two tokens are
11911 * placeholders; the same substitution happens here, which is what lets an
11912 * existing client talk to this server without knowing which binary answered.
11913 */
11914int run_server(const Options& base) {
11915 line::ws::Server server(base.port);
11916 std::printf("--------------------------------------------------------------------\n");
11917 std::printf("LINE Solver - Command Line Interface (C++)\n");
11918 std::printf("Copyright (c) 2012-2026, QORE Lab, Imperial College London\n");
11919 std::printf("Version %s. All rights reserved.\n", kVersion);
11920 std::printf("--------------------------------------------------------------------\n");
11921 std::printf("Running in server mode on port %d.\n", base.port);
11922 if (base.maxreq)
11923 std::printf("Quitting after %d request(s).\n", base.maxreq);
11924 std::fflush(stdout);
11925
11926 int served = 0;
11927 while (base.maxreq == 0 || served < base.maxreq) {
11928 const bool ok = server.serve_one([&](const std::string& msg) -> std::string {
11929 const std::string::size_type nl = msg.find('\n');
11930 if (nl == std::string::npos)
11931 return "line-cli: the request's first line is the argument list and its "
11932 "remainder is the model document; this message has no newline\n";
11933 const std::string argline = msg.substr(0, nl);
11934 const std::string model = msg.substr(nl + 1);
11935
11936 const char* tmpdir = std::getenv("TMPDIR");
11937 std::string path =
11938 std::string(tmpdir && *tmpdir ? tmpdir : "/tmp") + "/line-cli-req-XXXXXX";
11939 std::vector<char> nb(path.begin(), path.end());
11940 nb.push_back('\0');
11941 const int mfd = ::mkstemp(&nb[0]);
11942 if (mfd < 0) return "line-cli: cannot stage the client model\n";
11943 ::close(mfd);
11944 path.assign(&nb[0]);
11945 {
11946 std::ofstream mf(path.c_str());
11947 mf << model;
11948 }
11949
11950 // The argument list, with the first two tokens replaced by the
11951 // staged path exactly as `LineWebSocketServer.onMessage` replaces
11952 // them. A list SHORTER than two is the client's error and is
11953 // reported rather than padded, since padding would solve the
11954 // default model instead of the one it sent.
11955 std::vector<std::string> toks;
11956 std::string::size_type at = 0;
11957 while (at <= argline.size()) {
11958 const std::string::size_type comma = argline.find(',', at);
11959 toks.push_back(argline.substr(
11960 at, comma == std::string::npos ? std::string::npos : comma - at));
11961 if (comma == std::string::npos) break;
11962 at = comma + 1;
11963 }
11964 std::string result;
11965 if (toks.size() < 2) {
11966 result = "line-cli: the argument list needs at least two tokens; the first two "
11967 "are replaced by --file and the staged model path\n";
11968 } else {
11969 toks[0] = "--file";
11970 toks[1] = path;
11971 std::vector<char*> argv;
11972 std::vector<std::string> store;
11973 store.push_back("line-cli");
11974 for (std::size_t i = 0; i < toks.size(); ++i) store.push_back(toks[i]);
11975 for (std::size_t i = 0; i < store.size(); ++i)
11976 argv.push_back(const_cast<char*>(store[i].c_str()));
11977 int rc = 0;
11978 try {
11979 Options ro = parse_args(static_cast<int>(argv.size()), &argv[0]);
11980 // The server's own flags never travel into a request: a
11981 // client that sent `-p` would otherwise make the server
11982 // recurse into a second listener on the same process.
11983 ro.port = 0;
11984 ro.maxreq = 0;
11985 result = run_invocation_captured(ro, rc);
11986 } catch (const line::Error& e) {
11987 result = std::string("line-cli: ") + e.what() + "\n";
11988 }
11989 }
11990 std::remove(path.c_str());
11991 return result;
11992 });
11993 // A dropped client is not a reason to stop serving, and it does not
11994 // count against --maxreq either: the JAR counts REQUESTS, and a peer
11995 // that vanished before sending one made none.
11996 if (ok) ++served;
11997 }
11998 return 0;
11999}
12000
12001/**
12002 * `--generate`: draw a random model and print it, solving nothing.
12003 *
12004 * `{"kind":"network", ...}` emits the `line-model` JSON document that every
12005 * reader in the project already takes, so the generated model round-trips
12006 * through `-f` and through `load_model` in R and Python without a second
12007 * format. `{"kind":"layered", ...}` emits the `.lqnx` document instead,
12008 * because that is the only interchange the layered readers all share; it is
12009 * plain text on standard output rather than a collected JSON document, since
12010 * an XML string is not one.
12011 *
12012 * THE ARITHMETIC IS ALWAYS double. Both generators draw from
12013 * `java.util.Random`, whose output is a double by definition, so generating at
12014 * `--arith exact` would fabricate an exactness the draw never had. A caller who
12015 * wants the model solved in another arithmetic reads it back with that --arith.
12016 */
12017int run_generate(const Options& o) {
12018 const line::reg::Json spec = read_generate_spec(o.generate);
12019 if (!spec.is_object())
12020 throw line::InputError("--generate takes a JSON object, e.g. "
12021 "{\"kind\":\"network\",\"queues\":3,\"closedClasses\":1}");
12022 const std::string kind = gen_str(spec, "kind", "network");
12023 if (kind == "network") {
12024 static const char* const keys[] = {
12025 "kind", "seed", "name", "queues", "delays", "openClasses", "closedClasses",
12026 "schedStrat", "routingStrat", "distribution", "cclassJobLoad", "varyingServiceRates",
12027 "multiServerQueues", "randomCSNodes", "multiChainCS", "topology"};
12028 gen_check_keys(spec, keys, sizeof(keys) / sizeof(keys[0]));
12030 if (spec.contains("seed")) g.set_seed(gen_int(spec, "seed", 0));
12031 g.set_model_name(gen_str(spec, "name", "nw"));
12032 g.set_sched_strat(gen_str(spec, "schedStrat", "randomize"));
12033 g.set_routing_strat(gen_str(spec, "routingStrat", "randomize"));
12034 g.set_distribution(gen_str(spec, "distribution", "randomize"));
12035 g.set_cclass_job_load(gen_str(spec, "cclassJobLoad", "randomize"));
12036 g.set_varying_service_rates(gen_bool(spec, "varyingServiceRates", false));
12037 g.set_multi_server_queues(gen_bool(spec, "multiServerQueues", false));
12038 g.set_random_cs_nodes(gen_bool(spec, "randomCSNodes", false));
12039 g.set_multi_chain_cs(gen_bool(spec, "multiChainCS", false));
12040 const std::string topo = gen_str(spec, "topology", "rand");
12041 if (topo == "rand")
12043 else if (topo == "cyclic")
12045 else
12046 throw line::InputError("--generate: unknown topology '" + topo +
12047 "'; accepted forms are: rand, cyclic");
12048 // `delays` defaults to -1, which is the generator's "decide for me":
12049 // it then draws the split between queues and delays itself.
12050 const long nq = gen_int(spec, "queues", 1);
12051 const long nd = gen_int(spec, "delays", -1);
12052 const long no = gen_int(spec, "openClasses", 0);
12053 const long nc = gen_int(spec, "closedClasses", 1);
12054 line::qn::Network<double> m = g.generate(static_cast<int>(nq), static_cast<int>(nd),
12055 static_cast<int>(no), static_cast<int>(nc));
12056 emit_document(line::io::network_json_envelope(m.get_struct()).dump(2));
12057 return 0;
12058 }
12059 if (kind == "layered") {
12060 static const char* const keys[] = {
12061 "kind", "seed", "name", "clients", "levels", "tasks", "processors", "populationRange",
12062 "thinkTimeRange", "taskMultiRange", "procMultiRange", "hostDemandRange",
12063 "synchCallRange", "taskInfProbability", "procInfProbability"};
12064 gen_check_keys(spec, keys, sizeof(keys) / sizeof(keys[0]));
12066 if (spec.contains("seed")) g.set_seed(gen_int(spec, "seed", 0));
12067 g.set_model_name(gen_str(spec, "name", "lnw"));
12068 g.set_population_range(gen_range(spec, "populationRange", line::gen::Range(1, 1)));
12069 g.set_think_time_range(gen_range(spec, "thinkTimeRange", line::gen::Range(1, 1)));
12070 g.set_task_multi_range(gen_range(spec, "taskMultiRange", line::gen::Range(1, 1)));
12071 g.set_proc_multi_range(gen_range(spec, "procMultiRange", line::gen::Range(1, 1)));
12072 g.set_host_demand_range(gen_range(spec, "hostDemandRange", line::gen::Range(1, 1)));
12073 g.set_synch_call_range(gen_range(spec, "synchCallRange", line::gen::Range(1, 1)));
12074 g.set_task_inf_probability(gen_real(spec, "taskInfProbability", 0.0));
12075 g.set_proc_inf_probability(gen_real(spec, "procInfProbability", 0.0));
12076 const long ncl = gen_int(spec, "clients", 1);
12077 const long nlv = gen_int(spec, "levels", 1);
12078 const long ntk = gen_int(spec, "tasks", 1);
12079 const long npr = gen_int(spec, "processors", 1);
12081 g.generate(static_cast<int>(ncl), static_cast<int>(nlv), static_cast<int>(ntk),
12082 static_cast<int>(npr));
12083 // BUILT BEFORE IT IS PRINTED, so a model the finalizer refuses is a
12084 // failure here rather than a document that only fails when read back.
12085 b.build();
12086 std::printf("%s\n", line::lqn::lqnx_to_string(b.model(), g.model_name()).c_str());
12087 return 0;
12088 }
12089 throw line::InputError("--generate: unknown kind '" + kind +
12090 "'; accepted forms are: network, layered");
12091}
12092
12093/**
12094 * Everything `main` did, with the arguments already in hand.
12095 *
12096 * SPLIT OUT SO A HOST CAN CALL IT. The binary's `main` is now ten lines in its
12097 * own translation unit, and this is the body both it and the C ABI run, so a
12098 * command line and an in-process call take the same path by construction
12099 * rather than by review.
12100 *
12101 * `no_args` replaces the `argc == 1` that decided the brief help, because an
12102 * in-process caller has no argv[0] to count and would otherwise never see it.
12103 */
12104int dispatch(const Options& in, bool allow_server, bool no_args) {
12105 {
12106 Options o = in;
12107 // Before any arm runs: `read_model` raises the priority warning and has
12108 // no knobs of its own to read the level from.
12109 if (!o.knobs.verbosity.empty()) g_verbosity = o.knobs.verbosity;
12110 // Solver console: set for the whole process, so that the model compile
12111 // narrates too -- LineConsole::writes() falls back to the session level
12112 // when no run is open, and reading the model happens before any solver
12113 // exists. `-v debug` (and its `verbose` spelling) is the ONLY way in.
12115 g_verbosity == "silent" ? line::util::VerboseLevel::SILENT
12116 : (g_verbosity == "debug" || g_verbosity == "verbose")
12119 if (o.help_all) {
12120 print_help();
12121 return 0;
12122 }
12123 if (o.help || no_args) {
12124 print_brief_help();
12125 return 0;
12126 }
12127 if (o.version) {
12128 std::printf("line-cli %s\n", kVersion);
12129 return 0;
12130 }
12131 // A warning is not a failure: every dependency the check probes is
12132 // optional, so the command exits 0 whenever the check itself ran.
12133 if (o.install) {
12134 install_check();
12135 return 0;
12136 }
12137 if (o.list) {
12138 list_api();
12139 return 0;
12140 }
12141 // BEFORE the model-reading path, and not inside it: --generate PRODUCES
12142 // a model rather than consuming one, so -f is neither required nor read.
12143 if (o.generate_given) return run_generate(o);
12144 if (o.find_solver) return find_solver_report(o);
12145 if (o.port) {
12146 // A HOST DOES NOT GET A SERVER BY ACCIDENT. `-p` blocks the calling
12147 // thread and opens a listening socket, so a host forwarding a
12148 // user's argument string would otherwise be handing that user both.
12149 if (!allow_server)
12150 throw line::InputError(
12151 "-p runs the solver as a server, which this caller did not permit");
12152 // `-f` and `-p` together would be two model sources for one run;
12153 // the request carries the model in server mode, so a file named on
12154 // the command line could only be ignored.
12155 if (!o.file.empty())
12156 throw line::InputError(
12157 "-p runs the solver as a server, where each request carries its own model; "
12158 "-f names a model on the command line and the two cannot both be the source");
12159 return run_server(o);
12160 }
12161 return run_invocation(o);
12162 }
12163}
12164
12165namespace line {
12166namespace cli {
12167
12168Response run(const Request& req) {
12169 Response res;
12170 reset_invocation_state(false);
12171 g_interrupt_cb = req.interrupt;
12172 g_interrupt_user = req.interrupt_user;
12173 if (req.has_stdin) set_stdin_override(req.stdin_text);
12174
12175 std::vector<std::string> docs;
12176 if (req.collect_json) g_json_sink = &docs;
12177
12178 // argv[0] IS SYNTHESISED HERE. parse_args skips it, as every argv parser
12179 // does, and a host that passed its own would have its first real argument
12180 // eaten. Requiring the host to prepend one would be requiring it to know
12181 // that, which is exactly the kind of knowledge a boundary should absorb.
12182 std::vector<std::string> store;
12183 store.push_back("line-cli");
12184 for (std::size_t i = 0; i < req.argv.size(); ++i) store.push_back(req.argv[i]);
12185 std::vector<char*> argv;
12186 for (std::size_t i = 0; i < store.size(); ++i) argv.push_back(&store[i][0]);
12187
12188 {
12189 // THE CAPTURES ENCLOSE THE try/catch, NOT JUST THE CALL. A failure
12190 // message the arms would have written to fd 2 is therefore captured
12191 // too, rather than escaping to the host's console -- which for R means
12192 // escaping to a terminal the user may not even be looking at. They are
12193 // plain scoped objects taking a sentinel fd for "do not capture",
12194 // because a std::optional here is the same thing with a longer name.
12195 FdCapture out(req.capture_stdout ? 1 : -1);
12196 FdCapture err(req.capture_stderr ? 2 : -1);
12197 try {
12198 res.exit_code = dispatch(parse_args(static_cast<int>(argv.size()), &argv[0]),
12199 req.allow_server, req.argv.empty());
12200 } catch (const InterruptRequested& e) {
12201 res.exit_code = 2;
12202 res.error = e.what();
12203 res.error_id = "line:interrupt";
12204 } catch (const line::Error& e) {
12205 res.exit_code = 2;
12206 res.error = e.what();
12208 } catch (const std::exception& e) {
12209 res.exit_code = 3;
12210 res.error = std::string("unexpected failure: ") + e.what();
12211 res.error_id = "line:internal";
12212 }
12213 if (req.capture_stdout) res.text = out.take();
12214 if (req.capture_stderr) res.diagnostics = err.take();
12215 }
12216
12217 g_json_sink = nullptr;
12218 g_interrupt_cb = nullptr;
12219 g_interrupt_user = nullptr;
12220 res.documents = docs;
12221 reset_invocation_state(false);
12222 return res;
12223}
12224
12225int main_body(int argc, char** argv) {
12226 try {
12227 return dispatch(parse_args(argc, argv), true, argc == 1);
12228 } catch (const line::Error& e) {
12229 std::fprintf(stderr, "line-cli: %s\n", e.what());
12230 return 2;
12231 } catch (const std::exception& e) {
12232 std::fprintf(stderr, "line-cli: unexpected failure: %s\n", e.what());
12233 return 3;
12234 }
12235}
12236
12237} // namespace cli
12238} // namespace line
The -s ag entry point: gates, fixed point, mean measures.
Direct invocation of a single API function from named JSON arguments.
SolverAUTO.listValidMethods: the method names THIS MODEL can actually run.
Redirect a file descriptor into a temporary file for a scope, and give back what was written to it.
FdCapture(int fd)
bool active() const
std::string take()
Restore the descriptor and read back what was written.
Base error for the multiprecision C++ port.
Definition error.h:31
Malformed or inconsistent input (dimensions, negative populations, ...).
Definition error.h:37
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
bool empty() const
Definition matrix.h:92
The algorithm cannot proceed on this instance (singular matrix, ...).
Definition error.h:43
Requested feature or arithmetic mode is not ported yet.
Definition error.h:49
const EnvStage< T > & stage(std::size_t e) const
std::size_t nstages() const
A random lqn::LqnBuilder<T> source, configured once and then drawn from.
void set_host_demand_range(const Range &r)
Activity host demands.
lqn::LqnBuilder< T > generate(int num_clients, int num_levels, int num_tasks, int num_processors)
void set_proc_inf_probability(double p)
Probability that a processor is infinite-server rather than PS.
void set_synch_call_range(const Range &r)
Mean number of synchronous calls on a call arc.
void set_model_name(const std::string &nm)
The name of the generated model.
void set_population_range(const Range &r)
Reference-task populations.
void set_think_time_range(const Range &r)
Client think times.
void set_proc_multi_range(const Range &r)
Processor multiplicities, for the processors that are not infinite-server.
void set_task_multi_range(const Range &r)
Task multiplicities, for the tasks that are not infinite-server.
void set_task_inf_probability(double p)
Probability that a server task is infinite-server rather than FCFS.
void set_seed(long long seed)
Reseed the single stream every draw comes from.
A random qn::Network<T> source, configured once and then drawn from.
void set_cclass_job_load(const std::string &load)
high, medium, low, or randomize: the population band of a closed class.
void set_model_name(const std::string &nm)
The name given to the generated model.
void set_seed(long long seed)
Reseed the single stream every draw comes from.
void set_topology(TopologyKind kind)
Pick one of the two shipped topology generators.
void set_distribution(const std::string &d)
Exp, Erlang, HyperExp, or randomize.
void set_multi_chain_cs(bool v)
Classes are partitioned into random chains and switching is confined to a chain, instead of the defau...
void set_sched_strat(const std::string &strat)
fcfs, ps, inf, lcfs, lcfspr, siro, sjf, ljf, sept, lept, or randomize.
void set_random_cs_nodes(bool v)
Each link gets a ClassSwitch node inserted on a coin flip.
qn::Network< T > generate(int num_queues, int num_delays, int num_oclass, int num_cclass)
The full form.
void set_routing_strat(const std::string &strat)
Probabilities, Random, or randomize.
void set_varying_service_rates(bool v)
Service means spread over 2^-6 .
void set_multi_server_queues(bool v)
Queues get 1..40 servers instead of exactly one.
LnSensTable< T > get_sensitivity_table(const sens::SensOptions &sopt)
Port of @SolverLN/getSensitivityTable: solve the ensemble, then concatenate each layer solver's own t...
Definition solver_ln.h:610
LnTranSolution get_tran_avg()
Port of @SolverLN/getTranAvg: the block-diagonal aggregate transient.
Definition solver_ln.h:591
std::size_t nlayers() const
Definition solver_ln.h:688
std::vector< LnCdf > get_cdf_respt()
Port of @SolverLN/getCdfRespT: the per-entry response-time distribution.
Definition solver_ln.h:552
LnSolution< T > get_ensemble_avg()
Port of getEnsembleAvg: run the iteration and aggregate onto LQN elements.
Definition solver_ln.h:528
const std::vector< qn::Layer< T > > & layers() const
Definition solver_ln.h:689
LqnStruct< T > build() const
Flatten into the struct SolverLN consumes.
const LqnModel< T > & model() const
The layered model solved by lqns or lqsim.
static bool is_stochastic_method(const std::string &method)
Only the lqsim methods draw random numbers.
static std::string version()
The version banner of the local binary, empty when there is none.
A layer network: everything a NetworkStruct holds, plus the LQN back-mapping.
Definition qn_layer.h:52
A network plus its refreshed NetworkStruct.
std::size_t nvars_of(std::size_t ind) const
Total local-variable width of node ind (1-based).
std::size_t stateful_index(std::size_t ind) const
1-based stateful index of node ind, 0 when the node is not stateful.
std::size_t nof_nodes() const
std::map< std::pair< std::size_t, std::size_t >, Matrix< T > > P
P[(r,s)] is an (nnodes x nnodes) block; absent means all zero.
std::size_t phases_of(std::size_t ist, std::size_t r) const
sn.phases(i,r): the order of the process representation.
std::vector< Reward > reward
std::vector< std::size_t > stateful_nodes
1-based node indices, ascending
std::vector< std::vector< Distrib< T > > > service
service[i][r], 0-based station and class; a disabled entry marks a pair never visited.
std::vector< std::vector< bool > > disabled
std::map< std::size_t, CacheParam< T > > nodeparam
Cache parameters by 1-based NODE index; only Cache nodes have an entry.
std::vector< JobClass > classes
std::vector< Station< T > > stations
stations[k-1] is the k-th station
Matrix< T > rates
(nstations x nclasses) service rates and SCVs, with a PARALLEL disabled flag instead of MATLAB's NaN ...
std::vector< NodeDef > nodes
every node, in creation order
std::vector< std::size_t > station_to_node
(nstations) 1-based node index
std::vector< Region > regions
A queueing network under construction.
const NetworkStruct< T > & get_struct()
The refreshed struct, MATLAB's model.getStruct().
NetworkStruct< T > & raw_struct()
The struct WITHOUT refreshing it, for a caller that is still building.
RAII guard: opens a run on construction, closes it on scope exit.
static void reset()
Forget any open run (used after an interrupted solve).
static void set_verbose(VerboseLevel level)
Set the session verbosity.
A listening socket; one connection is served at a time.
Definition websocket.h:189
bool serve_one(Fn serve)
Accept one connection, complete the handshake, read ONE text message and hand it to serve; send what ...
Definition websocket.h:225
The CLI's argument vector, reachable in-process.
Docker primitives for the backends that legitimately ship an image.
The SolverENV entry surface: a port of the analyzer selection that @@SolverENV/SolverENV....
Reader for the LINE model.json interchange of an ENVIRONMENT model into an env::Environment<T>.
The exception types the port throws.
Port of @@SolverFLD/getJacobian, all four of its outputs.
The fluid solver's outermost entry point: @@SolverFLD/runAnalyzer.m's method resolution over solver_f...
The log-driven half of SolverJMT: linkAndLog, parseLogs, parseTranState, parseTranRespT,...
Port of @@JMTIO: a refreshed NetworkStruct written out as a JMT .jsimg simulation model.
Read a JMT .jsim / .jsimg / .jsimw model into a qn::Network.
Random layered-queueing-network generation: the C++ twin of MATLAB @LayeredNetworkGenerator,...
The NATIVE LDES engine for LAYERED (LQN) models, the C++ twin of jline/solvers/ldes/handlers/Solver_s...
int dispatch(const Options &in, bool allow_server, bool no_args)
Everything main did, with the arguments already in hand.
int run_invocation(Options o)
#define LINE_CLI_TABLE_LADDER(FN)
int find_solver_report(const Options &o)
Everything one invocation does once the arguments are in hand.
int run_generate(const Options &o)
--generate: draw a random model and print it, solving nothing.
std::string run_invocation_captured(Options o, int &rc)
Run one invocation with stdout captured, and return what it printed.
int run_server(const Options &base)
-p/--port: serve solve requests over a WebSocket, as LineWebSocketServer does.
Running progress log of a LINE solver run (the "solver console").
model.json with type: "LayeredNetwork" -> LqnStruct, via LqnBuilder.
.lqnx -> LqnStruct, a port of matlab/src/lang/layered/@LayeredNetwork/parseXML.m followed by ....
LqnModel -> .lqnx, a port of matlab/src/lang/layered/@LayeredNetwork/writeXML.m.
The flat-Network path of @@SolverLQNS: its qns methods, served by qnsolver of the RADS/LQNS distribut...
Port of @NetworkSolver/mapEnvApprox.m: the solver-agnostic random-environment approximation of a netw...
The stage solvers map_env_approx injects, one per runner that can be a caller.
Boundary marshalling for host bindings (MATLAB MEX, pybind11, the JSON CLI).
Classification of a solution method, as printed in the solver banner: "<accuracy>,...
mva::AvgResult< T > solver_ag_run_analyzer(const qn::NetworkStruct< T > &L, const AgOptions &opt)
SolverAG.runAnalyzer: the converged agents as mean measures.
Definition ag_dispatch.h:34
Matrix< T > sn_declared_marginal(const qn::NetworkStruct< T > &sn)
The (nstations x nclasses) per-class job counts of the model's OWN state.
Definition sn_state.h:85
AutoEnvChoice auto_choose_env_solver(const std::string &method, AutoMode mode=AutoMode::HEUR)
The Environment arm of chooseSolverHeur / chooseSolverExact / chooseSolverSim.
std::string auto_find_solver_table(const std::vector< SolverCandidate > &rows)
The rows as an aligned text table, the form the CLI and a console caller want.
AutoLayeredChoice auto_choose_layered_solver(const std::string &method, bool has_cache_task, AutoMode mode=AutoMode::HEUR)
chooseLayeredSolver plus the LayeredNetwork arm of chooseAvgSolverHeur.
const char * auto_solver_name(AutoSolver s)
std::vector< AutoSolver > auto_proposed_solvers(const qn::NetworkStruct< T > &sn, const std::string &method, AutoMode mode)
delegate's proposed order: the chosen solver, then every feasible candidate in slot order.
AutoChoice auto_choose_solver_mode(const qn::NetworkStruct< T > &sn, const std::string &method, AutoMode mode)
chooseSolver: the selection mode picks the ranking, and every mode but the two learned ones keeps the...
std::vector< SolverCandidate > auto_find_solver(const qn::NetworkStruct< T > &sn, const std::string &metric=std::string(), bool show_all=false)
SolverAUTO.findSolver: which solvers and solver methods can analyze this model, and for the ones that...
const char * auto_env_name(AutoEnv s)
AutoToken auto_resolve_token(const std::string &raw)
const char * auto_layered_name(AutoLayered s)
The layered names are the CLI's own tokens, because that is what the choice is spent on: ln....
BaBounds< T > ba_bounds(const qn::NetworkStruct< T > &L, const BaOptions &opt)
Port of SolverBA.getBounds.
mva::AvgResult< T > solver_ba_run_analyzer(const qn::NetworkStruct< T > &L, const BaOptions &opt_in)
Port of @@SolverBA/runAnalyzer.m for the lang='matlab' path.
std::string resolve_method(const std::string &method)
Port of runAnalyzer's method aliases: default is the geometric upper bound, bare auto is the AUTO com...
int main_body(int argc, char **argv)
The binary's main, so line_cli_main.cpp stays ten lines.
Response run(const Request &req)
Run one invocation and return what it produced.
Matrix< T > solver_ctmc_sample(const NetworkStruct< T > &sn, const CtmcSamplePath< T > &path, std::size_t ind)
Port of @@SolverCTMC/sample: the walk restricted to ONE stateful node's local block.
Matrix< T > solver_ctmc_sample_aggr(const NetworkStruct< T > &sn, const CtmcSamplePath< T > &path, std::size_t ind)
Port of @@SolverCTMC/sampleAggr: one node's per-class counts over time.
std::vector< CdfCurve< T > > solver_ctmc_cdf_sys_respt(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Port of @@SolverCTMC/getCdfSysRespT.m: the per-chain SYSTEM response-time CDF, indexed by chain.
T solver_ctmc_jointaggr(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const NetState< T > &state)
Port of solver_ctmc_jointaggr: P(the network holds exactly these per-class counts),...
CtmcTranProb< T > ctmc_get_tran_prob_sys(const NetworkStruct< T > &sn, const CtmcTransient< T > &tr)
Port of @@SolverCTMC/getTranProbSys.m: pi(t), labelled by the whole network state with its phases.
CtmcSamplePath< T > solver_ctmc_sample_sys(const NetworkStruct< T > &sn, const CtmcOptions &opt_in, std::size_t nevents, unsigned long seed=23000)
Port of @@SolverCTMC/sampleSys: a marked walk on the whole network state.
CtmcTranProb< T > ctmc_get_tran_prob_sys_aggr(const NetworkStruct< T > &sn, const CtmcTransient< T > &tr)
Port of @@SolverCTMC/getTranProbSysAggr.m: pi(t), labelled by the network's per-(station,...
mva::AvgResult< T > solver_ctmc_avg_table(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const std::string &method)
Port of @@SolverCTMC/runAnalyzer.m's result assembly: solve, then apply the metric filter @@NetworkSo...
CtmcMddSolution< T > solver_ctmc_mdd_analyzer(const NetworkStruct< T > &sn, const CtmcOptions &opt, const mdd::MddMcdOptions &mcdopt=mdd::MddMcdOptions())
Solve with the mdd method.
CtmcGenerator< T > ctmc_get_infgen(const NetworkStruct< T > &sn, const CtmcSolution< T > &d)
@@SolverCTMC/getInfGen.m, a pure alias of getGenerator in the reference.
std::vector< T > solver_ctmc_margaggr(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const NetState< T > &state)
Port of solver_ctmc_margaggr: per STATION, P(that station holds exactly these per-class counts).
std::vector< CtmcSensRank< T > > solver_ctmc_sensitivity_ranking(const NetworkStruct< T > &sn, const CtmcOptions &opt, const std::vector< CtmcSensParam< T > > &params, const std::vector< T > &reward)
Port of @@SolverCTMC/getSensitivityRanking: rank parameters by influence.
std::vector< std::vector< CdfCurve< T > > > solver_ctmc_cdf_respt(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Port of @@SolverCTMC/getCdfRespT.m: the per-(station, class) response-time CDF, indexed [ist-1][r-1].
std::vector< T > solver_ctmc_marg(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const NetState< T > &state)
Port of solver_ctmc_marg: per STATION, P(that station is in exactly its local slice of state),...
std::vector< T > solver_ctmc_avg_reward(const NetworkStruct< T > &sn, const CtmcOptions &opt, std::vector< std::string > *names=nullptr)
Port of @@SolverCTMC/getAvgReward: the steady-state expected rewards.
CtmcStateSpace< T > ctmc_get_state_space(const NetworkStruct< T > &, const CtmcSolution< T > &d)
Port of @@SolverCTMC/getStateSpace.m.
CtmcTransient< T > solver_ctmc_transient_analyzer(const NetworkStruct< T > &sn_arg, const CtmcOptions &opt, const T &t0, const T &t1, const std::vector< T > &grid=std::vector< T >())
Port of solver_ctmc_transient_analyzer.m.
T solver_ctmc_joint(const NetworkStruct< T > &sn, const CtmcSolution< T > &d, const NetState< T > &state)
Port of solver_ctmc_joint: P(the network is in exactly state).
Matrix< T > ctmc_get_state_space_aggr(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Port of @@SolverCTMC/getStateSpaceAggr.m: the per-(station, class) job counts of every state,...
CtmcSolution< T > solver_ctmc_analyzer(const NetworkStruct< T > &sn_in, const CtmcOptions &opt)
Port of solver_ctmc_analyzer.m plus the fork-join wrapper of @@SolverCTMC/runAnalyzer....
CtmcAnySolution< T > solver_ctmc_analyzer_any(const NetworkStruct< T > &sn, const CtmcOptions &opt)
The entry point a caller who does not know which path a model needs should use: pick the WAITQ walk w...
CtmcTranProb< T > ctmc_get_tran_prob(const NetworkStruct< T > &sn, const CtmcTransient< T > &tr, std::size_t ind)
Port of @@SolverCTMC/getTranProb.m: pi(t) over the whole chain, labelled by one node's local state.
CtmcFirstPassage ctmc_cdf_firstpasst(const NetworkStruct< T > &, const CtmcSolution< T > &d, const Matrix< double > &A, const Matrix< double > &B, const std::string &method="expm")
Port of @@SolverCTMC/getCdfFirstPassT.m: the distribution of the FIRST PASSAGE TIME from state set A ...
Matrix< T > ctmc_state_space_aggr(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
Port of StateSpaceAggr: the per-(station, class) job counts of every state, as an (nstates x nstation...
CtmcTranProb< T > ctmc_get_tran_prob_aggr(const NetworkStruct< T > &sn, const CtmcTransient< T > &tr, std::size_t ind)
Port of @@SolverCTMC/getTranProbAggr.m: pi(t), labelled by one node's per-class job counts.
std::vector< std::vector< T > > solver_ctmc_tran_reward(const NetworkStruct< T > &sn, const CtmcOptions &opt, const T &t0, const T &t1, std::vector< T > *tout=nullptr, std::vector< std::string > *names=nullptr)
Port of @@SolverCTMC/getTranReward: E[r(X(t))] = sum_s pi_t(s) r(s).
CtmcReward< T > solver_ctmc_reward(const NetworkStruct< T > &sn, const CtmcOptions &opt, std::size_t tmax=1000)
Port of solver_ctmc_reward.m.
CtmcFirstPassageMoments< T > ctmc_firstpasst_moments(const NetworkStruct< T > &, const CtmcSolution< T > &d, const Matrix< double > &A, const Matrix< double > &B, std::size_t nmax=3)
Port of @@SolverCTMC/getFirstPassTMoments.m: moments of order 1..nmax of the first passage time from ...
Matrix< T > solver_ctmc_sample_sys_aggr(const NetworkStruct< T > &sn, const CtmcSamplePath< T > &path)
Port of @@SolverCTMC/sampleSysAggr: the same walk, reported as per-(station, class) job counts rather...
EnvStatevecSolution< T > solver_env_statevec(Environment< T > &e, const EnvStatevecOptions< T > &o)
Solve in one call, for a caller with no use for the solver object.
EnvAnalyzerSolution< T > solver_env(Environment< T > &e, const EnvOptions &o)
SolverENV.init's analyzer selection: solve the environment with the coupling o.method names.
FluidKpTransient solver_fluid_tran_avg_var(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
Port of @@SolverFLD/getTranAvgVar: the queue-length VARIANCE along the trajectory,...
Definition fluid_kp.h:1070
double aoi_cdf(const AoiMe &me, double t)
getCdfAoI: F(t) = 1 - S(t) with S the survival function of the age law.
Definition fluid_aoi.h:677
double fluid_prob_aggr(const qn::NetworkStruct< T > &sn, const FluidSolution &sol, std::size_t ist, double *logp_out=nullptr)
Port of @@SolverFLD/getProbAggr: the probability that station ist holds the marginal population of th...
AoiTopology aoi_is_aoi(const qn::NetworkStruct< T > &sn)
Port of aoi_is_aoi.m.
Definition fluid_aoi.h:102
std::string solver_fluid_export_odes(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, const std::string &notation="scalar", const std::string &model_name="model")
Port of @@SolverFLD/exportODEs.m at the runner's own method resolution, so the exported system is the...
FluidJacobian fluid_jacobian(const FluidSymSystem &sys, const FluidSymbolicOptions &opt=FluidSymbolicOptions())
Jacobian, drift and equilibria of the mean-field vector field.
FluidSolution solver_fluid_run_analyzer(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, qn::NetworkStruct< T > *sn_out=nullptr, qn::NetworkStruct< T > *refreshed_out=nullptr, solvers::CacheMetrics< T > *cache_out=nullptr)
Port of @@SolverFLD/runAnalyzer.m: resolve the method, route to the function the reference routes to,...
std::vector< std::vector< FluidPassage > > solver_fluid_cdf_respt(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, std::size_t points=201)
Port of @@SolverFLD/getCdfRespT: the response-time law of every (station, class) pair,...
FluidSymSystem fluid_symodes(const qn::NetworkStruct< T > &sn, const std::string &method_in, double pstar, const std::vector< double > &init_sol)
Build the symbolic system of sn under opt.method.
std::vector< FluidTranPoint > solver_fluid_run_transient(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, std::size_t points=101)
-a tran / @@SolverFLD/getTranAvg with the method HONOURED, which is the one place the reference does ...
Response get(const std::string &url, int timeoutMillis)
GET a URL.
Definition http.h:349
bool is_layered_json(const std::string &path)
True when a file is a LayeredNetwork model.json rather than an .lqnx.
std::string jsim_stage_stdin(const std::string &text)
Write a piped XML model document to a temporary file and return its path.
qn::Network< T > read_network_json(const std::string &path)
Parse a model.json file into a qn::Network<T>.
qn::Network< T > read_jsim(const std::string &path, const std::string &name=std::string())
Read a JSIM document into a Network.
lqn::LqnStruct< T > read_layered_model(const std::string &path)
Read a layered model from either interchange: the LINE model.json or the LQNS .lqnx.
std::string jmt_write_jsim(const qn::NetworkStruct< T > &sn, const JmtWriteOptions &opt)
Port of @@JMTIO/writeJSIM.m: serialize sn as a JMT .jsimg document.
env::Environment< T > build_environment_from_json(const detail::json &root)
Build an env::Environment<T> from a parsed model.json envelope.
env::Environment< T > read_environment_json(const std::string &path)
Parse a model.json file into an env::Environment<T>.
qn::Network< T > build_network_from_json(const detail::json &root)
Build a qn::Network<T> from a parsed model.json envelope.
bool docker_daemon_available()
True if the Docker daemon is reachable.
detail::json network_json_envelope(const qn::NetworkStruct< T > &sn)
The complete model.json envelope: {format, version, model}.
const char * error_id(const Error &e)
Stable identifier for an error, for mexErrMsgIdAndTxt and for mapping to a host exception class.
Definition marshal.h:146
bool is_layered_json_text(const std::string &text)
True when a DOCUMENT ALREADY IN MEMORY is a LayeredNetwork model.json.
qn::Network< T > pnml_load(const std::string &path, const std::string &net_id=std::string())
Read one net of a PNML place/transition document into a Network.
Definition pnml.h:490
JmtReplication< T > jmt_transient_replications(const qn::NetworkStruct< T > &sn, const JmtOptions &opt)
Port of the transient ensemble of @@SolverJMT/runAnalyzer.m (default over a finite timespan).
Definition jmt_logs.h:906
JmtSysTrace< T > jmt_sample_sys_aggr(const qn::NetworkStruct< T > &sn, std::size_t num_events, const JmtOptions &opt)
Port of sampleSysAggr: every station's trajectory on one time grid.
Definition jmt_logs.h:498
JmtNodeTrace< T > jmt_sample_aggr(const qn::NetworkStruct< T > &sn, std::size_t node, std::size_t num_events, const JmtOptions &opt)
Port of sampleAggr: the queue-length trajectory of one node.
Definition jmt_logs.h:450
std::vector< std::string > jmt_list_valid_methods()
Port of SolverJMT.listValidMethods.
std::size_t jmt_transient_replication_count(const JmtOptions &opt, const std::string &what)
How many JSIM runs a transient estimate is over: the gate the two transient arms share.
Definition jmt_logs.h:671
JmtResult< T > solver_jmt_run_analyzer(const qn::NetworkStruct< T > &sn, const JmtOptions &opt_in)
Port of @@SolverJMT/runAnalyzer.m, the jsim and jmva arms.
std::map< std::pair< std::size_t, std::size_t >, std::vector< std::pair< double, double > > > jmt_get_cdf_resp_t(const qn::NetworkStruct< T > &sn, const JmtOptions &opt, bool seed_from_steady=true)
Port of getCdfRespT: the empirical response-time distribution per (station, class),...
Definition jmt_logs.h:567
std::string jmt_removed_method_refusal(const std::string &method)
The migration sentence for a method name SolverJMT no longer has; empty for any other name.
std::pair< std::vector< double >, std::vector< std::vector< double > > > jmt_get_tran_prob_aggr(const qn::NetworkStruct< T > &sn, std::size_t station, const JmtOptions &opt, std::vector< std::vector< double > > &states_out)
Port of getTranProbAggr: the transient distribution of one station's aggregate state,...
Definition jmt_logs.h:700
JmtProbAggr jmt_prob_aggr(const qn::NetworkStruct< T > &sn, const JmtOptions &opt, std::size_t target_station=0, const std::vector< double > &target=std::vector< double >())
Port of getProbAggr and getProbSysAggr, both off ONE instrumented run.
Definition jmt_logs.h:810
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
const char * event_to_text(EventType e)
Definition lang_types.h:137
LnColumn
A column of the shared layered average table, as line-cli prints it.
bool ln_defined(const lqn::LqnStruct< T > &lsn, const LnResult &r, std::size_t i, LnColumn c)
Does the element at i HAVE the quantity in column c?
std::vector< LnEntryCdf > ldes_ln_cdf_respt(const LnResult &r, std::size_t nentries)
The empirical response time CDF of every ENTRY, the getCdfRespTLN of the other codebases: one [F(t),...
engine::LnResult ldes_ln_engine_solve(const lqn::LqnStruct< T > &lsn, const LdesOptions &o)
Simulate a layered model in process.
LdesResult solver_ldes_text(const std::string &doc, const LdesOptions &o, const std::vector< std::string > &extra_flags=std::vector< std::string >())
Runs one LDES simulation on a model.json DOCUMENT and parses its result.
bool ldes_is_available()
True when this machine can run the engine at all, by either image.
Definition ldes_probe.h:245
LqnModel< T > read_lqnx_model(const std::string &path)
std::string lqnx_to_string(const LqnModel< T > &m, const std::string &model_name=std::string("LQN"), bool use_abstract_names=false)
The .lqnx document as a STRING, for a caller with no file to write to – the CLI's model-generation mo...
Definition lqn_writer.h:550
mva::AvgResult< T > solve_network_run_analyzer(const qn::NetworkStruct< T > &L, const QnsOptions &opt)
Port of @@SolverLQNS/runAnalyzerNetwork.m and solver_qns.m: SolverLQNS on a flat Network.
const std::string & lqns_version()
The version banner of the local lqns, empty when there is none.
Definition lqns_probe.h:49
bool lqns_is_available()
True when lqns is installed AND is a release this port speaks.
Definition lqns_probe.h:69
bool qnsolver_is_available()
Port of SolverLQNS.hasQnsolver: a native qnsolver binary on the PATH.
std::vector< T > mam_percentiles_from_cdf(const RespTCdf< T > &cdf, const std::vector< double > &pcts)
Port of the CDF path of @@SolverMAM/getPerctRespT.m: linear interpolation of the response-time CDF at...
TranResult< T > solver_mam_get_tran_avg(const qn::NetworkStruct< T > &L, const MamOptions &opt_in)
Port of @@SolverMAM/getTranAvg.m: transient queue length, utilization and throughput.
std::vector< RespTCdf< T > > solver_mam_get_cdf_respt(const qn::NetworkStruct< T > &L, const MamOptions &opt)
@@SolverMAM/getCdfRespT.m: the response-time CDF per class.
mva::AvgResult< T > solver_mam_run_analyzer(const qn::NetworkStruct< T > &L, const MamOptions &opt)
Port of @@SolverMAM/runAnalyzer.m for the lang='matlab' path: solve, then apply the metric filter @@N...
ProbTable< T > solver_mam_get_prob(const qn::NetworkStruct< T > &L, const MamOptions &opt, std::size_t node, const mva::AvgResult< T > &avg)
@@SolverMAM/getProb.m: the joint (level, phase) table at a node.
std::vector< std::vector< T > > solver_mam_get_perct_respt(const qn::NetworkStruct< T > &L, const MamOptions &opt, const std::vector< double > &percentiles)
@@SolverMAM/getPerctRespT.m: response-time percentiles per class.
bool mam_has_fj_percentiles(const qn::NetworkStruct< T > &L, const MamOptions &opt)
Whether getPerctRespT reads the FJ_codes table rather than inverting a CDF.
std::vector< T > solver_mam_get_prob_marg(const qn::NetworkStruct< T > &L, const MamOptions &opt, std::size_t ist, std::size_t jobclass, const mva::AvgResult< T > &avg)
@@SolverMAM/getProbMarg.m: P(n jobs of one class) at a station.
qsys::BmapM1Result< T > solver_mam_get_mam_result(const qn::NetworkStruct< T > &L)
@@SolverMAM/getMAMResult.m: the M/G/1-type internals of a single queue.
Matrix< T > sn_get_residt_from_respt(const qn::NetworkStruct< T > &L, const Matrix< T > &RN)
Port of sn_get_residt_from_respt: the per-JOB residence time.
AggrResult< T > solver_mva_get_prob_aggr(const qn::NetworkStruct< T > &L, const AvgResult< T > &avg, std::size_t ist, const std::string &method="default")
T solver_mva_get_prob_norm_const_aggr(const qn::NetworkStruct< T > &L, const MvaOptions &opt)
Port of @@SolverMVA/getProbNormConstAggr.m: log G.
std::string resolve_method(const qn::NetworkStruct< T > &L, const std::string &method)
Port of SolverMVA.resolveMethod: the feature-driven default -> rqna upgrade for a bursty single-class...
MargResult< T > solver_mva_get_prob_marg(const qn::NetworkStruct< T > &L, const AvgResult< T > &avg, std::size_t ist, std::size_t r, const std::vector< long > &states, const std::string &method="default")
Port of @@SolverMVA/getProbMarg.m: P(n jobs of class r at station i) for the states in states (or the...
AggrResult< T > solver_mva_get_prob_sys_aggr(const qn::NetworkStruct< T > &L, const AvgResult< T > &avg, const std::string &method="default")
Port of @@SolverMVA/getProbSysAggr.m: the joint probability of the model's whole state across all sta...
Matrix< T > sn_get_arvr_from_tput(const qn::NetworkStruct< T > &L, const Matrix< T > &TN)
AvgResult< T > solver_mva_run_analyzer(const qn::NetworkStruct< T > &L, const MvaOptions &opt_in, const Matrix< T > &init_sol)
Port of @@SolverMVA/runAnalyzer.m for the lang='matlab' path: gate, solve, convert,...
NcCftpSolution< T > solver_nc_cftp(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const NcCftpOptions &cftpopt)
Solve with the cftp / cftp.approx method.
mva::AvgResult< T > solver_nc_run_analyzer(const qn::NetworkStruct< T > &L_in, const NcSolverOptions &opt_in)
T solver_nc_getprob_sys_marg(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const std::vector< int > &nvec, const std::string &engine="exact")
Port of @@SolverNC/getProbSysMarg.m.
T solver_nc_getprob_sys_aggr(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const MarginalState &nir)
Port of @@SolverNC/getProbSysAggr.m.
NcSolution< T > solver_nc_cftp_solution(const NcCftpSolution< T > &s)
A cftp solve in the shape every other SolverNC analyzer returns, so the runner's metric filter applie...
T solver_nc_joint(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const MarginalState &nir, double *lG_out)
Port of solver_nc_joint.m: the probability of the WHOLE system state.
std::vector< double > solver_nc_busyp(const qn::NetworkStruct< T > &sn, const std::vector< std::size_t > &subnet, const std::vector< std::size_t > &orders)
Mean busy period of order n for a set of stations.
NcQueueLengthDist< T > solver_nc_getprob_marg(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, std::size_t ist)
Port of @@SolverNC/getProbMarg.m: the TOTAL queue-length distribution.
NcMargResult< T > solver_nc_margaggr(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const MarginalState &nir, double lG)
Port of solver_nc_margaggr.m.
bool is_stochastic_method(const std::string &method)
Port of SolverNC.isStochasticMethod.
NcSolution< T > solver_nc_solve(const qn::NetworkStruct< T > &L_in, const NcSolverOptions &opt_in)
The gates, the multiserver conversion and the dispatch of @@SolverNC/runAnalyzer.m,...
CdfRespTResult< T > solver_nc_cdf_respt(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt)
Port of @@SolverNC/getCdfRespT.m.
NcMargResult< T > solver_nc_marg(const qn::NetworkStruct< T > &sn, const NcSolverOptions &opt, const MarginalState &nir, double lG)
Port of solver_nc_marg.m: the DETAILED marginal, which weighs the station's internal arrangement and ...
std::vector< std::vector< int > > MarginalState
A state, as this port expresses it: nir[i][r] jobs of class r at station i.
mva::AvgResult< T > solver_nc_avg_table(const qn::NetworkStruct< T > &L_in, const NcSolution< T > &d, const std::string &origmethod)
Port of @@SolverNC/runAnalyzer.m for the lang='matlab' path: solve, then apply the metric filter @@Ne...
std::string solver_nc_cftp_supports(const qn::NetworkStruct< T > &sn)
The cftp model-class gate as a public predicate.
NcldMethod
The load-dependent methods this port dispatches.
Definition pfqn_ncld.h:87
std::vector< std::vector< int > > multichoose_rows(int n, int k)
All n-vectors of nonnegative integers summing to k, in MATLAB multichoose(n,k) order.
bool ncld_method_try(const std::string &s, NcldMethod &out)
Map a method name to its enum; false when the name is not one of them.
Definition pfqn_ncld.h:109
FeatureSet fluid_feature_set(const std::string &method)
SolverFLD.getFeatureSet, transcribed, MINUS what the requested method cannot evaluate – the port of @...
Marginal< T > to_marginal(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &state_i, const std::vector< std::size_t > &phasesz, const std::vector< std::size_t > &phaseshift, std::size_t nvar=0)
Port of State.toMarginal for a STATION, one state row at a time.
Definition state.h:130
FeatureSet nc_feature_set(const std::string &method)
SolverNC.getFeatureSet, 48 names, transcribed unchanged.
FeatureSet mva_feature_set(const std::string &raw_method)
void feature_gate(const std::string &solver, const FeatureSet &declared, const NetworkStruct< T > &sn, const std::string &requested_method="", const std::string &resolved_method="")
runAnalyzerChecks: refuse a model the solver does not declare, by name.
nlohmann::json Json
Definition api_json.h:63
std::string api_render_readable(const Json &result)
Human-readable rendering of the object api_invoke returns, for -o readable.
ArithSpec parse_arith(const std::string &text)
Parse –arith.
Json api_invoke(const std::string &name, const std::string &arith, const Json &args)
Invoke one API function.
SensTable< T > solver_sensitivity_table(qn::NetworkStruct< T > &sn, const SensOptions &opt, bool exact_available, const std::function< mva::MvaSolution< T >()> &solve)
Build the sensitivity table of sn under solve.
void put(Matrix< double > &A, std::size_t r0, std::size_t c0, const Matrix< double > &S)
A(r0:, c0:) = S.
Definition mg1.h:927
double dot(const std::vector< double > &a, const std::vector< double > &b)
The inner product of a row vector with a column held as a vector.
Definition mg1.h:240
MapEnvDecision needs_map_env(const qn::FeatureSet &declared, const qn::NetworkStruct< T > &sn, const MapEnvConfig &cfg=MapEnvConfig())
needsMapEnv: does this model need the environment image, and would the image make it solvable?
std::vector< std::string > chain_class_labels(const qn::NetworkStruct< T > &sn)
(ClassA ClassB), the JobClasses column: which classes a chain holds.
std::vector< bool > node_table_hidden_classes(const line::qn::NetworkStruct< T > &sn)
The classes getAvgNodeTable omits: the auxiliary retrieval classes that Cache.setRetrievalSystem mint...
env::EnvStageAvgFn< double > nc_stage_fn(const nc::NcSolverOptions &opt)
NC stages, bound to the caller's own NcSolverOptions.
SysResult< T > solver_get_avg_sys(const qn::NetworkStruct< T > &sn, const mva::AvgResult< T > &r)
Port of @@NetworkSolver/getAvgSys.m.
line::mva::AvgResult< T > avg_result_from_sim(const line::qn::NetworkStruct< T > &sn, const line::Matrix< double > &QN, const line::Matrix< double > &UN, const line::Matrix< double > &RN, const line::Matrix< double > &TN, const std::vector< double > &CN, const std::vector< double > &XN, const std::string &method)
The station AvgResult of a solver whose runner returns its own solution type, i.e.
NodeMetrics< T > node_metrics(const line::qn::NetworkStruct< T > &sn, const line::mva::AvgResult< T > &r)
mva::AvgResult< T > run_avg(const qn::NetworkStruct< T > &sn, const std::string &solver, const qn::FeatureSet &declared, const MapEnvConfig &cfg, Run run, StageFn stage_fn, const std::string &requested_method="default")
The getAvg funnel: run the model, or its environment image when the ONLY thing in the way is a non-re...
Definition map_env.h:327
ChainResult< T > solver_get_avg_node_chain(const qn::NetworkStruct< T > &sn, const Matrix< T > &QNn, const Matrix< T > &UNn, const Matrix< T > &RNn, const Matrix< T > &WNn, const Matrix< T > &ANn, const Matrix< T > &TNn)
Port of @@NetworkSolver/getAvgNodeChain.m: the NODE table aggregated by chain.
ChainResult< T > solver_get_avg_chain(const qn::NetworkStruct< T > &sn, const mva::AvgResult< T > &r)
Port of @@NetworkSolver/getAvgChain.m: the station table aggregated by chain.
mva::AvgResult< T > map_env_approx(const qn::NetworkStruct< T > &sn, const std::string &solver, const MapEnvConfig &cfg, StageFn stage_fn, const std::string &requested_method="default")
mapEnvApprox: solve the model through the random-environment image of its non-renewal processes.
Definition map_env.h:186
std::vector< std::vector< DefaultCdfCurve > > solver_default_cdf_respt(const qn::NetworkStruct< T > &sn, const Matrix< T > &RN)
The NetworkSolver base-class response-time CDF: an exponential law with the right mean per (station,...
std::vector< std::string > chain_names(std::size_t nchains)
Chain1, Chain2, ... – the reference's own chain labels.
env::EnvStageAvgFn< double > fluid_stage_fn(const fluid::FluidOptions &opt)
Fluid stages, bound to the caller's own FluidOptions.
env::EnvStageAvgFn< double > mva_stage_fn(const mva::MvaOptions &opt)
MVA stages, bound to the caller's own MvaOptions.
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....
SsaSamplePath< T > ssa_sample_node(const qn::NetworkStruct< T > &sn, const SsaSerialRun< T > &r, std::size_t ind)
sample(node) and sampleAggr(node): the same trajectory, one node's block.
SsaSamplePath< T > ssa_sample_sys(const qn::NetworkStruct< T > &sn, const SsaSerialRun< T > &r)
sampleSys and sampleSysAggr: the trajectory itself.
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...
SsaProbReport solver_ssa_prob(const qn::NetworkStruct< T > &sn_arg, const SsaSerialOptions &opt)
The whole -a prob report over the model's DEFAULT INITIAL STATE, which is the state SolverCTMC's own ...
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.
void ssa_cdf_respt_refuse()
getCdfRespT: refused, and the refusal is the ANSWER rather than a gap.
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.
std::string sym_find_image()
const char *const SYM_DOCKER_IMAGE
Image serving the symbolic REST API.
Definition sym_engines.h:72
UqSolution< T > solver_uq_run_analyzer(qn::Network< T > &net, const UqStageSolver< T > &stage, const UqOptions &opt=UqOptions())
UQ.runAnalyzer as a free call: expand, solve every design point, aggregate.
Definition solver_uq.h:574
UqInterval< T > uq_interval_run(qn::Network< T > &net, const UqStageSolver< T > &stage, const UqOptions &opt=UqOptions())
getInterval from the model, running the ensemble ONLY when it is needed.
Definition solver_uq.h:974
std::vector< PriorSite< T > > uq_detect_priors(const qn::NetworkStruct< T > &sn)
UQ.detectPriors: find every Prior, in node order and then class order.
Definition solver_uq.h:196
UqStageSolver< T > uq_stage_solver(const UqStageOptions &o)
The stage solver named by o.solver.
std::string method_type(const std::string &solvername, const std::string &method)
Banner classification of a solution method.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
const char * arith_name(Arith a)
Definition registry.h:28
const std::vector< ApiEntry > & api_registry()
The registry is a function-local static, not a global object, so there is no static-initialization or...
Definition registry.h:49
Random queueing-network generation: the C++ twin of MATLAB @NetworkGenerator, the JAR jline....
Reader for the LINE model.json interchange (a Network model) into a qn::Network<T> built through the ...
Number-type abstraction for the templated API port.
PNML (ISO/IEC 15909-2) place/transition nets, read and written.
Coverage registry of the C++ port.
Ports of matlab/src/api/sn/sn_get_node_arvr_from_tput.m and sn_get_node_tput_from_tput....
Ports of matlab/src/api/sn/sn_get_state_aggr.m and sn_is_state_valid.m.
The SolverAUTO chooser: which solver a model is handed to.
The SolverBA class surface: @@SolverBA/runAnalyzer.m, listValidMethods, getBounds and getBoundsTable.
The CHAIN-level and SYSTEM-level views of a solved model.
Port of solver_ctmc_analyzer.m and the parts of @@SolverCTMC/runAnalyzer.m that surround one solve: t...
Port of @@SolverCTMC/getCdfRespT.m and @@SolverCTMC/getCdfSysRespT.m: the exact distribution of the r...
The remaining @@SolverCTMC accessors: getGenerator / getInfGen, getStateSpace / getStateSpaceAggr and...
The mdd method of SolverCTMC: stationary analysis of a closed single-class network whose state space ...
The SolverCTMC probability family: solver_ctmc_joint, _jointaggr, _marg, _margaggr,...
Port of solver_ctmc_reward.m and the @@SolverCTMC reward surface (runRewardAnalyzer,...
Port of the @@SolverCTMC sampling surface: sample, sampleAggr, sampleSys, sampleSysAggr.
Port of @@SolverCTMC/getSensitivity and getSensitivityRanking: the parametric sensitivity of a steady...
Port of solver_ctmc_fcr_waitq.m: the reachability-built generator of a model whose finite capacity re...
The base-class fallback for a response-time CDF.
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
Port of SolverJMT, the Java Modelling Tools client.
Port of SolverLDES, the discrete-event simulator, as its C++ client.
SolverLN: layered decomposition of a layered queueing network.
SolverLQNS: the layered model solved by the external lqns / lqsim binaries.
The SolverMAM class surface: @@SolverMAM/runAnalyzer.m and the gates around it.
The state-probability half of the SolverMVA class surface.
The SolverMVA class surface: @@SolverMVA/runAnalyzer.m and the gates around it.
Port of @SolverNC/getAvgBusyPeriod.m and of the Python-native SolverNC.getAvgBusyPeriod: the mean bus...
Port of @@SolverNC/getCdfRespT.m, and of its two aliases getSjrnT and sjrnT.
The cftp and cftp.approx methods of SolverNC: stationary analysis of a closed single-class product-fo...
The state-probability half of the SolverNC class surface: ports of solver_nc_marg....
The SolverNC class surface: @@SolverNC/runAnalyzer.m and the gates around it.
The NODE-indexed view of a station result, behind getAvgNodeTable.
Performance sensitivities with respect to service rates.
The SolverSSA queries that are not the average table: getProb, getProbAggr, getProbSys,...
The SolverSSA entry surface: a port of @@SolverSSA/runAnalyzer.m's method whitelist,...
std::string method
'default', 'inap', 'inaprc', 'inapinf' or the vestigial 'exact'.
Definition ag_types.h:51
int iter_max
SolverOptions('AG') lowers this from the global 1000 to 100.
Definition ag_types.h:57
double tol
Convergence tolerance of the reversed-rate fixed point.
Definition ag_types.h:54
std::size_t max_states
Truncation level of an OPEN agent's queue-length dimension.
Definition ag_types.h:65
What a ranking resolved to, and what it had to skip to get there.
std::string method
The method the choice was gated on: "" for the default, "exact".
std::vector< AutoSolver > skipped
Slots that outranked solver and have no engine in this port.
std::vector< AutoEnv > skipped
std::vector< AutoLayered > skipped
resolveMethodToken, minus the unqualified-algorithm-name arm.
std::string submethod
the method handed to the family, "default" when bare
std::string family
empty when is_intent
Port of SolverBA.getBounds: the {lower,upper} bracket of a family.
std::vector< std::vector< bool > > keep
getBoundsTable's row filter, (M x K): whether the (station, class) pair earns a row.
Matrix< T > Qupper
(M x K), all-NaN on a side the family lacks
options.config.qrf_params, the blocking tables the BAS and RS-RD arms need.
std::vector< std::vector< int > > MM1
(MR x M) extended order
std::vector< std::vector< int > > MM
(MR x 2) blocking order
std::vector< std::vector< int > > BB
(MR x M) blocking state
int MR
number of blocking configurations
int f
finite-capacity queue, 1-based as in the reference
std::vector< int > ZZ
(MR) blocked count per config
std::vector< int > F
(M) capacity; empty takes sn.cap
The options SolverBA reads.
int level
options.level: the hierarchy level of pbh/bjbh/cbh/sib.
std::string method
Bound method; default resolves to gb.upper in the runner.
Matrix< double > qrf_alpha
options.config.qrf_alpha, the (nstations x N) load-dependent scaling of the two load-dependent QRF ar...
One invocation: the argument vector, plus what a pipe would have carried.
Definition cli_run.h:49
bool collect_json
Collect the JSON documents as they are emitted.
Definition cli_run.h:68
void * interrupt_user
Definition cli_run.h:100
int(*) interrupt(void *user)
Polled between analyses and at each document emission; non-zero aborts.
Definition cli_run.h:99
std::vector< std::string > argv
The argument vector WITHOUT argv[0], e.g.
Definition cli_run.h:55
std::string stdin_text
The model document, as if piped to the binary's stdin.
Definition cli_run.h:64
bool capture_stderr
Redirect fd 2 for the duration of the call into Response::diagnostics.
Definition cli_run.h:72
bool capture_stdout
Redirect fd 1 for the duration of the call into Response::text.
Definition cli_run.h:70
bool allow_server
Permit -p, which serves until its request budget is exhausted.
Definition cli_run.h:81
What one invocation produced.
Definition cli_run.h:104
int exit_code
The CLI's own exit status: 0 ok, 2 line::Error, 3 other.
Definition cli_run.h:106
std::string error_id
line::io::error_id's stable identifier for error.
Definition cli_run.h:140
std::vector< std::string > documents
Each JSON document the run emitted, in emission order, AS EMITTED.
Definition cli_run.h:124
std::string error
Empty on success; the what() of the exception otherwise.
Definition cli_run.h:132
std::string text
Everything written to fd 1, with the collected documents still in it.
Definition cli_run.h:127
std::string diagnostics
Everything written to fd 2: warnings, and the failure message.
Definition cli_run.h:129
A CTMC solve routed to whichever path the model's region rules require.
std::vector< T > parked
mean parked jobs per class; empty off the WAITQ path
The answer of @@SolverCTMC/getFirstPassTMoments.m.
Matrix< T > mall
(nstates x nmax), one row per starting state
std::vector< std::size_t > source
resolved 0-based rows; empty = conditional stationary
std::vector< std::size_t > target
resolved 0-based rows
std::vector< T > m
(nmax) moments for a passage started uniformly in A
The answer of @@SolverCTMC/getCdfFirstPassT.m: the [F(t), t] curve with its grid, density and resolve...
std::vector< double > F
CDF at t, clamped to [0, 1].
std::vector< double > t
the grid, 1000 points to the horizon
std::vector< std::size_t > target
resolved 0-based rows
std::vector< std::size_t > source
resolved 0-based rows; empty = conditional stationary
std::vector< double > f
density at t
[infGen, eventFilt, ev] of @@SolverCTMC/getGenerator.m.
std::vector< std::vector< Matrix< T > > > preempt_filt
std::vector< Matrix< T > > filt
eventFilt: filt[a] holds only what synchronization sync[a] contributed, so sum_a filt[a] is the off-d...
std::vector< Sync< T > > sync
ev, the reference's sn.sync
std::vector< std::vector< Matrix< T > > > start_filt
The DERIVED START/PREEMPT filtrations, indexed [station-1][class-1].
Matrix< T > Q
the infinitesimal generator
What one mdd solve produces beside the means, i.e.
long long num_states
|S|, counted in the diagram without ever listing a state.
int iters
Coupled fixed-point sweeps performed.
bool no_aggregation
True certifies the answer is exact structurally; see the file header.
std::string encoding
Which local encoding was picked, "np" or "ps".
std::vector< std::size_t > level_sizes
|M_k| per paper level; their sum is what the diagram actually holds.
The SolverCTMC knobs this port honours.
bool force
options.force: downgrade the memory pre-gate's refusal to a warning.
std::vector< std::vector< std::size_t > > cutoff_mat
options.cutoff AS A (station x class) MATRIX, or empty.
double fau_delta
options.config.fau_delta: occupancy below which a state is dropped.
double cutoff
< 0 = not given
double timestep
options.timestep: the FIXED OUTPUT STEP of a transient analysis.
std::string transient_method
options.config.transient_method: "ode" (the default) integrates the forward equation,...
std::vector< CtmcRateSched > rate_sched
options.config.rate_sched: the TIME-INHOMOGENEOUS transient.
std::size_t ctmc_tv_ngrid
options.config.ctmc_tv_ngrid: uniform grid size of the rate_sched propagator.
double fau_epsilon
options.config.fau_epsilon: total probability mass the whole grid may discard under "fau".
bool keep_filtration
Keep the per-synchronization EVENT FILTRATION alongside Q.
One entry of options.config.rate_sched: the rate of (station, class) follows the piecewise-linear sch...
std::vector< double > rates
std::vector< double > tgrid
What the reward analyzer produces, per declared reward.
std::vector< Matrix< T > > V
V[r] is (Tmax+1 x nstates).
std::vector< T > t
iteration index / q
Matrix< T > state_space_aggr
the rows the reward saw
std::vector< std::string > names
One sampled trajectory of the chain.
std::vector< std::size_t > event
synchronization that fired to LEAVE it
std::vector< std::size_t > state
0-based index into chain.space
std::vector< T > t
time at which each state was ENTERED
The scalar parameter a sensitivity is taken with respect to.
std::function< void(NetworkStruct< T > &, double)> set
Apply theta to a COPY of the struct; the original is never mutated.
Everything one CTMC solve produces.
std::string warning
Set when the chain is a reducible mixture solved from an invented seed.
std::vector< std::size_t > cutoff
the per-class cutoff actually used
std::vector< T > pi
stationary distribution over chain.space
[stateSpace, localStateSpace] of @@SolverCTMC/getStateSpace.m.
std::vector< std::size_t > node_width
column width of each stateful node's block
std::vector< Matrix< T > > local
localStateSpace{f}: one matrix per stateful node, its DISTINCT local rows in first-appearance order.
Matrix< T > flat
the blocks concatenated, as MATLAB returns them
The time-dependent answer of one getTranProb* query.
Matrix< T > pit
(ntimes x nstates) occupancy over the solved chain
std::vector< T > t
The reference returns Pi_t = [t, pi_t], one matrix with time glued on as column 1.
Matrix< T > labels
(nstates x width) the state descriptor the query asked for
What one transient CTMC solve produces.
What the ENV entry reports: the environment-blended metrics, and the whole result of whichever coupli...
std::string method
What ran: meanfield, statevec, or the limit avg / dec.
EnvStatevecSolution< T > statevec
Populated on the state-vector path.
bool converged
True on the limit path: a closed form has converged by construction.
solvers::CacheMetrics< T > cache
The environment-blended cache surface, whichever coupling produced it.
int iterations
ZERO ON THE LIMIT PATH, and that is the answer rather than a gap: a limit reads the environment once ...
Options of SolverENV.
Definition solver_env.h:113
std::string method
The inter-stage coupling: meanfield is the reference's default.
Definition solver_env.h:125
std::string stage_solver
Which solver runs each FLAT stage: the fluid transient or the enumerated CTMC.
Definition solver_env.h:123
fluid::FluidOptions stage
Options handed to each stage solver.
Definition solver_env.h:129
double stage_cutoff
options.cutoff of a CTMC stage, read only when stage_solver is ctmc.
Definition solver_env.h:134
double timespan_end
options.timespan(2) of the inner solver: the transient horizon.
Definition solver_env.h:136
std::size_t tran_points
Points on a UNIFORM transient grid, used only where stage_grid declines to build one (a stage whose h...
Definition solver_env.h:149
std::vector< double > g
Definition fluid_aoi.h:255
std::vector< double > h
Definition fluid_aoi.h:257
Matrix< double > A
Definition fluid_aoi.h:256
std::string system_type
"bufferless" or "singlebuffer"
Definition fluid_aoi.h:265
What the AoI gate found, when it matches.
Definition fluid_aoi.h:84
The four outputs of @@SolverFLD/getJacobian.
std::string engine
sage or local, whichever produced J
std::vector< std::string > rhs
the drift, one expression per variable
std::vector< std::vector< std::string > > J
J[i][j] = d f_i / d x_j.
bool has_equilibria
the backend answered the equilibria request
std::vector< std::map< std::string, std::string > > equilibria
Solutions of f(x) = 0, each a variable -> expression map.
std::vector< std::string > vars
state variable names
The transient the covariance equation produces, i.e.
Definition fluid_kp.h:120
Matrix< double > Sigma
state-level covariance, on range(D)
Matrix< double > QStd
per station and class queue-length variance
Controls, defaulting to SolverOptions('Fluid') in the reference.
double pstar
exponent of the 'pnorm' smoothing
double iter_tol
>0 stops early when the moved-mass ratio falls below it; 0 runs to iter_max, as the reference does
double tol
absolute and relative tolerance handed to the integrator
std::size_t iter_max
cap on outer integrations
bool pstar_set
Opt in to the p-norm under matrix/default too, which is what options.config.pstar does in MATLAB,...
What the analyzer returns, in the same shape as the MVA solver's result.
bool has_moments
result.solverSpecific.moments: set only by minnormal and refined.
std::vector< double > XN
bool has_aoi
result.solverSpecific.aoiResults: set only by the AoI branch of mfq, where the age laws,...
FluidMomentReport moments
std::vector< double > xvec
the converged fluid state
std::vector< double > CN
The symbolic system, in whichever of the two forms the method implies.
Backend selection, mirroring options.config.symbolic and its timeout.
bool equilibria
The reference's nargout >= 4: ask the backend to solve f(x) = 0.
std::string backend
auto to search, a URL, an image name, or none to stay local.
A closed interval the generator samples from.
The simulation controls the JSIM header carries, MATLAB's JMTIO properties.
Definition jmt_writer.h:68
std::string log_path
model.getLogPath, the logPath attribute
Definition jmt_writer.h:70
std::string file_name
base name; the header echoes it plus .jsimg
Definition jmt_writer.h:69
The per-class queue-length trajectory of one node, plus its event stream.
Definition jmt_logs.h:227
The options of one JMT solve, SolverOptions('JMT') restricted to what is read.
Definition solver_jmt.h:119
int replications
options.config.replications: the independent JSIM runs the transient ensemble of default averages ove...
Definition solver_jmt.h:134
std::string method
default | jsim | jmva | jmva.<alg>
Definition solver_jmt.h:120
bool keep
keep the scratch directory after the solve
Definition solver_jmt.h:123
double samples
samples per measure; raised to 5000 below
Definition solver_jmt.h:121
What jmt_prob_aggr reports: the system probability and the per-station ones.
Definition jmt_logs.h:773
std::vector< bool > station_seen
the same, per station
Definition jmt_logs.h:777
bool sys_seen
whether the joint state occurred at all
Definition jmt_logs.h:776
double sys
P(the whole network is in the declared state).
Definition jmt_logs.h:774
std::vector< double > station
P(station i holds its declared per-class counts).
Definition jmt_logs.h:775
Transient averages over independent replications, on one time grid.
Definition jmt_logs.h:874
std::vector< double > t
Definition jmt_logs.h:875
std::vector< std::vector< std::vector< double > > > UNt
Definition jmt_logs.h:878
std::size_t valid
Replications that produced a usable trajectory.
Definition jmt_logs.h:881
std::vector< std::vector< std::vector< double > > > TNt
Definition jmt_logs.h:879
std::vector< std::vector< std::vector< double > > > QNt
QNt[ist-1][r] over t.
Definition jmt_logs.h:877
The result of a JMT solve: the shared AvgResult plus what only JMT reports.
Definition solver_jmt.h:749
std::map< std::size_t, std::vector< T > > cache_hit_prob
Per Cache node (1-based node index), the per-class hit probability.
Definition solver_jmt.h:755
mva::AvgResult< T > avg
Definition solver_jmt.h:750
Matrix< T > ACI
confidence half-widths, empty when disabled
Definition solver_jmt.h:751
Matrix< T > DropRateNfcr
(nregions x nclasses) carried and lost rate
Definition solver_jmt.h:753
The system trajectory: one per-class block per station, on a common grid.
Definition jmt_logs.h:480
static Distrib exp_rate(const T &r)
Definition lang_types.h:945
static constexpr double FineTol
Definition lang_types.h:760
static constexpr double CoarseTol
Definition lang_types.h:761
The knobs of one LDES run.
double warmupfrac
–warmupfrac, only for tranfilter=fixed
bool verbose
echo the resolved command line before running it
double cnvgtol
–cnvgtol
std::string rest_url
Base URL of an LDES REST server (the imperialqore/ldes container).
long seed
–seed; -1 requests a random stream
bool slotted
–slotted, run on the slot lattice
bool cnvgon
–cnvgon
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::string tranfilter
–tranfilter: mser5 | fixed | none
std::vector< double > init_sol
–initsol, the warm-start placement as a STATION-MAJOR vector [st0_cl0, st0_cl1, .....
int numthreads
–numthreads; 0 = not given
std::size_t samples
-s, service-completion budget
double timeout
–maxtime, a COOPERATIVE wall-clock budget the event loop polls.
bool has_timespan
true when [t0,t1] was set: a TRANSIENT run
One ldes-result document, parsed.
std::map< std::string, LdesCacheMetrics > cache_metrics
Per-cache metrics, keyed by the Cache NODE name.
Matrix< double > TNCI
Matrix< double > WNfcr
std::vector< std::vector< Matrix< double > > > QNt
[STATION][class] -> (npoints x 2), columns [value, time].
Matrix< double > XN
(1 x nclasses), per-class visits and system tput
std::vector< std::vector< std::vector< double > > > respTimeSamples
[station][class] -> the per-job response times the engine recorded.
Matrix< double > UNfcr
Matrix< double > WeightNfcr
Matrix< double > QNfcr
Matrix< double > UN
Matrix< double > DropRateNfcr
Matrix< double > histogram_space
The exact joint-state residence-time histogram (–export-histogram).
std::string stopping_reason
convergence | max_events | max_sim_events | max_time.
std::vector< std::vector< Matrix< double > > > UNt
std::vector< std::string > class_names
Matrix< double > TN
Matrix< double > UNCI
Matrix< double > RN
std::string engine
"native" or "jar": which runner produced these numbers.
Matrix< double > QNCI
Matrix< double > CN
Matrix< double > RNfcr
Matrix< double > WN
Matrix< double > AN
long long total_simulated_events
Matrix< double > MemOccNfcr
Matrix< double > TNfcr
std::vector< std::string > station_names
Matrix< double > DropRateJoin
quorum-Join sibling drops, Join rows only
bool timed_out
True when the HARD subprocess bound fired, not the cooperative one.
Matrix< double > histogram_time
Matrix< double > RNCI
std::vector< double > t
the time vector, empty on a steady-state run
Matrix< double > WNCI
Matrix< double > QN
std::vector< std::vector< Matrix< double > > > TNt
Matrix< double > ANfcr
Matrix< double > ANCI
The layered result: per element, the mean measures.
Matrix< double > WLN
Residence time per element, the ResidT column: the time an activity holds ITS HOST PROCESSOR per visi...
std::vector< std::vector< double > > entry_resp_samples
Every per-request ENTRY response time observed, one vector per entry in LOCAL index space (0....
double simulated_time
Length of the MEASURED span, from the end of warmup to the end of the run.
long long completions
Reference-task cycles completed in the measured span.
Options of SolverLN.
Definition solver_ln.h:266
getSensitivityTable of the ensemble: the layer tables under a Layer column.
Definition solver_ln.h:434
std::vector< Row > rows
Definition solver_ln.h:439
std::string method
The summary label: the common branch, or "mixed" when they differ.
Definition solver_ln.h:443
The LQN-level answer, indexed by element 1..nidx.
Definition solver_ln.h:371
std::vector< T > WN
Definition solver_ln.h:372
std::vector< bool > defined_W
Definition solver_ln.h:373
std::vector< bool > defined_U
Definition solver_ln.h:373
std::vector< T > QN
Definition solver_ln.h:372
std::vector< bool > defined_R
Definition solver_ln.h:373
std::vector< T > RN
Definition solver_ln.h:372
std::vector< bool > defined_Q
Definition solver_ln.h:373
bool is_bound
True when the numbers are a BOUND (method = mw.upper / mw.lower) rather than the fixed point.
Definition solver_ln.h:382
std::vector< T > UN
Definition solver_ln.h:372
std::vector< bool > defined_T
Definition solver_ln.h:373
std::vector< T > TN
Definition solver_ln.h:372
The layered transient: one block per layer, plus how it was produced.
Definition solver_ln.h:425
The intermediate model, and the second stage that flattens it.
Definition lqn_reader.h:409
std::vector< LqnElement > type
(nidx+1)
Definition lqn_struct.h:214
std::vector< bool > iscache
(tshift+ntasks+1)
Definition lqn_struct.h:267
std::size_t nentries
Definition lqn_struct.h:209
std::vector< std::string > names
(nidx+1) declared name
Definition lqn_struct.h:212
std::vector< bool > isref
(tshift+ntasks+1)
Definition lqn_struct.h:266
Knobs of the wrapper, the subset of SolverOptions that reaches lqns.
Definition solver_lqns.h:80
The six measures, on the element index space, with a defined mask each.
std::vector< bool > defined_U
std::vector< T > UN
std::vector< bool > defined_W
std::vector< bool > defined_Q
std::vector< T > RN
std::vector< bool > defined_R
std::vector< T > TN
std::vector< T > QN
std::vector< bool > defined_T
std::vector< T > WN
SolverLQNS.defaultOptions on a flat Network plus the knobs the JMVA document carries.
int timeout
Seconds before a hung qnsolver is killed; not positive waits forever.
bool keep
options.keep: leave the scratch directory behind, to inspect what was sent.
std::string multiserver
options.config.multiserver.
std::string method
default, qns or qns.NAME; see qns_methods().
The options SolverMAM reads.
Definition mam_types.h:30
int iter_max
SolverOptions('MAM') lowers this from the global 1000 to 100.
Definition mam_types.h:34
double timespan_start
options.timespan: the transient horizon.
Definition mam_types.h:124
std::string timescale
options.config.timescale: "auto", "discrete" or "continuous".
Definition mam_types.h:116
std::size_t fj_accuracy
options.config.fj_accuracy: the FJ_codes truncation C of the queue-length DIFFERENCE between the two ...
Definition mam_types.h:104
std::string method
Definition mam_types.h:31
std::size_t cutoff
options.cutoff: the level truncation getProb / getProbMarg use for an OPEN model, where the queue len...
Definition mam_types.h:76
std::string fj_tmode
options.config.fj_tmode: which route computeT.m takes to the T matrix, 'NARE' (Riccati,...
Definition mam_types.h:110
double slotlength
options.config.slotlength: the slot in model time units.
Definition mam_types.h:118
The joint (level, phase) table getProb returns: rows levels, cols phases.
What getTranAvg returns: queue length, utilization and throughput curves.
Knobs of the level iteration in mdd_mcd.
Definition mdd_types.h:212
double tol
Convergence tolerance on the level marginals.
Definition mdd_types.h:214
int maxiter
Maximum coupled sweeps before the iteration is declared non-convergent.
Definition mdd_types.h:216
Port of @@SolverMVA/getProbAggr.m: P(n1 jobs of class 1, n2 of class 2, ...) at station ist for the m...
The metrics getAvg returns, after filtering.
std::shared_ptr< qn::NetworkStruct< T > > refreshed_struct
The struct whose cache self-switch carries the CONVERGED hit/miss split, filled by the cacheqn branch...
Matrix< T > TN
throughput
Matrix< T > RN
response time, per visit
std::string warning
The reference's own warning text, verbatim, empty when it did not warn.
Matrix< T > UN
utilization
std::vector< T > listcost
(h) mean storage cost held by each cache list, K_j = sum_i sigma_i pi_ij, filled only by the NC cache...
std::optional< double > lognormconst
@@SolverNC/getProbNormConstAggr, i.e.
std::optional< bool > converged
Whether the fixed point met its tolerance, empty when the handler reports none.
Matrix< T > WN
residence time, per job
std::string method
the method asked for
std::string actualmethod
the algorithm that ran
Matrix< T > QN
queue length
std::vector< T > CN
system response time per class
std::vector< T > XN
system throughput per class
solvers::CacheMetrics< T > cache
What the cache branches observed, EMPTY on a model with no Cache node and on every solver that does n...
Matrix< T > AN
arrival rate
A marginal distribution and its logarithm, over the states asked for.
std::vector< T > logP
The options SolverMVA reads.
Definition mva_types.h:31
std::string multiserver
Definition mva_types.h:36
std::string method
Definition mva_types.h:32
std::string fork_join
options.config.fork_join: which fork-join arm the fixed point takes.
Definition mva_types.h:51
Class-level results, the [Q,U,R,T,C,X] of the MATLAB analyzers.
Definition mva_types.h:96
std::vector< T > X
Definition mva_types.h:98
std::vector< T > C
Definition mva_types.h:98
The response-time distributions, station by class.
std::vector< std::vector< Matrix< T > > > RD
std::string warning
Non-empty when the reference WARNS AND RETURNS EMPTY rather than computing: today only "applies only ...
std::vector< T > tset
the shared evaluation grid
The knobs of one perfect-sampling run.
std::size_t samples
Number of iid stationary draws, SolverOptions('NC').samples by default.
unsigned long seed
Stream seed, so a row is reproducible within this port.
What one cftp solve produces beside the means.
std::vector< long > horizon
(samples) per-draw coalescence horizon, or the mixing steps of M_A.
std::vector< std::vector< int > > distinct_states
The distinct sampled states, aligned with paggr.
What the marginal analyzers return: one probability per station.
std::vector< T > P
(M) probability that station i holds its given vector
The [Q,U,R,T,C,X,lG] of the reference, plus the algorithm that ran.
Definition nc_types.h:113
std::string actualmethod
Definition nc_types.h:115
Controls, defaulting to SolverOptions('NC') in the reference.
Definition nc_types.h:33
double slotlength
options.config.slotlength, the slot length in model time units.
Definition nc_types.h:108
std::string multiserver
options.config.multiserver: how a finite multiserver station is represented.
Definition nc_types.h:63
std::string fork_join
options.config.fork_join: which fork-join arm the shared fixed point takes on a model with a Fork.
Definition nc_types.h:51
double tol
options.tol
Definition nc_types.h:35
std::size_t samples
options.samples, read by the estimators
Definition nc_types.h:64
unsigned long seed
options.seed, read by the estimators
Definition nc_types.h:65
bool slotted
options.config.slotted.
Definition nc_types.h:106
std::string cdf_algorithm
options.config.algorithm for getCdfRespT: 'exact' selects pfqn_stdf, 'rd' the heuristic pfqn_stdf_heu...
Definition nc_types.h:90
double iter_tol
options.iter_tol, the eta stopping test
Definition nc_types.h:36
What State.toMarginal returns for one station and one state row.
Definition state.h:51
std::vector< T > nir
jobs per class
Definition state.h:53
One network state: the per-stateful-node local rows it is composed of.
Definition state.h:2347
std::vector< std::vector< T > > local
local[isf] is that node's state row
Definition state.h:2348
Everything qsys_bmapm1 returns, mirroring the MATLAB result struct.
Definition qsys_bmapm1.h:62
T q
uniformization constant actually used
Definition qsys_bmapm1.h:66
T drift
stable iff strictly negative
Definition qsys_bmapm1.h:72
T pi0
probability the system is empty
Definition qsys_bmapm1.h:75
Matrix< T > levelProb
level probabilities, row n = pi_n
Definition qsys_bmapm1.h:74
T lambda
mean arrival rate, theta (sum_k k D_k) e
Definition qsys_bmapm1.h:64
std::vector< T > theta
stationary vector of the BMAP phase process
Definition qsys_bmapm1.h:63
T rho
offered load lambda/mu
Definition qsys_bmapm1.h:65
double decayRate
measured pi_(n+1)/pi_n; NaN when unmeasurable
Definition qsys_bmapm1.h:73
std::vector< T > alpha
stationary vector of A
Definition qsys_bmapm1.h:70
The name-value contract of getSensitivityTable.
bool simulation
True when the callback is a simulator, which widens the default step.
double step
Relative step of the rate perturbation; negative selects the default.
std::string scheme
forward | central
std::string method
auto | exact | fd
One (station, class) row of the table.
What the table carries, plus the branch that produced it.
std::vector< SensRow< T > > rows
std::string method
"exact" or "fd", the branch actually taken
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 > delayedhitqlen
(n) mean secondary requests waiting on the in-flight fetch of each item, and the same including the r...
std::vector< double > itemcap
(h) capacity of each list
std::vector< double > itemsize
(n) storage cost per item, EMPTY without setItemSizes
std::vector< T > hitprob
(K) TRUE hit fraction, EMPTY = not computed
std::vector< T > delayedprob
(K) delayed-hit fraction, EMPTY off a retrieval system
Matrix< T > hitproblist
(K x h) per-list hit fraction, EMPTY = not computed
std::size_t node
1-based node index of the Cache
std::vector< T > latency
(K) expected retrieval latency, EMPTY = not computed
std::vector< T > listcost
(h) mean storage cost held by each list
Matrix< T > itemprob
(n x h+1), column 0 = miss; EMPTY = not computed
std::vector< T > delayedhitqlenfull
std::string name
The Cache node's NAME, which is what a cross-language payload must key on.
The station- or node-level table aggregated by chain.
Matrix< T > TN
(rows x nchains), rows = stations or nodes
The caller-facing map_env knobs, options.config.map_env and friends.
std::size_t max_stages
0 = the transform's own default cap
The station table scattered to the NODE index space, plus the two flow columns the reference recomput...
@@NetworkSolver/getAvgSys: one response time and one throughput per chain.
std::vector< T > XN
(nchains) system throughput at the reference station
std::vector< T > CN
(nchains) system response time, i.e. the cycle time
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:69
std::size_t samples
Reaction firings to simulate; options.samples in the reference.
Definition ssa_types.h:73
double warmupfrac
options.config.warmupfrac: the leading fraction of the path discarded before the means are taken.
Definition ssa_types.h:88
std::string method
default and nrm both select the Next Reaction Method here.
Definition ssa_types.h:71
unsigned long seed
options.seed; LINE's own default is 23000.
Definition ssa_types.h:75
The four probabilities -a prob reports, over one requested state.
SsaProbResult sys_aggr
getProbSys, getProbSysAggr
std::vector< SsaProbResult > aggr
getProb, getProbAggr, per station
std::vector< SsaProbResult > marg
One trajectory, in the shape the reference's sampleSys returns it.
std::vector< double > t
the event times, increasing
Matrix< T > aggr
the same, as per-(stateful, class) counts
std::vector< std::size_t > event
which synchronization fired
Matrix< T > state
per event: the state OCCUPIED until then
The serial engine's knobs: SsaOptions plus the three the serial path reads and the NRM has no use for...
double cutoff
< 0 = the reference's automatic value
The serial analyzer's return: the metric table, the path, and the stream.
std::shared_ptr< const qn::NetworkStruct< T > > solved_sn
The struct the path was simulated on when it differs from the caller's (ctmc_mapc widens a multiserve...
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:101
std::vector< double > XN
Definition ssa_types.h:103
std::vector< double > CN
Definition ssa_types.h:103
Matrix< double > UN
Definition ssa_types.h:102
Matrix< double > RN
Definition ssa_types.h:102
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:115
Matrix< double > TN
Definition ssa_types.h:102
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:117
Matrix< double > QN
Definition ssa_types.h:102
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:113
UQ.getInterval: the RANGE of every metric over the support of the Priors.
Definition solver_uq.h:726
std::string method
mvainterval or sampled.
Definition solver_uq.h:736
bool exact
True when the interval is the attained hull rather than a sampled range.
Definition solver_uq.h:734
Matrix< T > Qlo
(nstations x nclasses) lower and upper endpoints of each metric.
Definition solver_uq.h:728
std::string why
On the sampled path, the condition that disqualified the exact one.
Definition solver_uq.h:738
T Xlo
System throughput and total response time; the EXACT path only.
Definition solver_uq.h:730
UQ.defaultOptions plus the stream the Monte Carlo design draws from.
Definition solver_uq.h:85
std::string method
default | dd | quad | mc, MATLAB UQ.listValidMethods.
Definition solver_uq.h:92
std::size_t samples
Nodes per continuous Prior, or design points under mc; the reference's options.samples,...
Definition solver_uq.h:99
unsigned long seed
The Monte Carlo stream; unread by a quadrature design, which draws nothing.
Definition solver_uq.h:101
What solver_uq_run_analyzer returns.
Definition solver_uq.h:125
std::vector< T > weights
The design weights, summing to 1.
Definition solver_uq.h:131
std::string method
The RESOLVED discretization method: quad or mc.
Definition solver_uq.h:137
std::vector< mva::AvgResult< T > > points
The result at each design point, in design order.
Definition solver_uq.h:129
std::vector< PriorSite< T > > sites
Where the Priors were found.
Definition solver_uq.h:135
std::vector< UqDesignPoint< T > > design
The alternatives each point substituted, one per site.
Definition solver_uq.h:133
mva::AvgResult< T > avg
The prior-weighted expectation of every metric, (nstations x nclasses).
Definition solver_uq.h:127
The inner solver's knobs, carried through untranslated.
Definition uq_dispatch.h:65
double tol
< 0 = not given
Definition uq_dispatch.h:70
double cutoff
ctmc open-population cutoff; < 0 = not given
Definition uq_dispatch.h:75
std::string solver
mva | nc | mam | ba | ctmc | fluid | ssa.
Definition uq_dispatch.h:67
Resolves the symbolic backend to use, and owns the container that serves it.
The stage solver of SolverUQ, named rather than passed.
Minimal RFC 6455 WebSocket server, enough to serve LineWebSocketServer's protocol.