LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ldes.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_WRAPPERS_LDES_SOLVER_LDES_H
6#define LINE_SOLVERS_WRAPPERS_LDES_SOLVER_LDES_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `SolverLDES`, the discrete-event simulator, as its C++ client.
12 *
13 * WHAT IS PORTED, AND WHY IT IS A CLIENT AND NOT AN ENGINE. LDES has ONE
14 * implementation, `jar/src/main/java/jline/solvers/wrappers/ldes/` (~25 kLOC on SSJ),
15 * and every codebase that is not the JAR reaches it the same way: MATLAB
16 * (`@@SolverLDES/solveCli.m`) and native Python
17 * (`wrappers/solver_ldes/solver_ldes.py`) serialize the model to `model.json`,
18 * run `solve model.json -o result.json` as a subprocess and parse the result
19 * document back. Nothing is marshalled in process. This file is the third such
20 * client, and it is the port of SolverLDES for the same reason those two are:
21 * a second simulator would answer with different numbers under the same name,
22 * and "LDES agrees across the codebases" -- which the parity harness checks and
23 * `CLAUDE.md` states as an invariant -- is a property of there being ONE engine.
24 *
25 * THE PRIMARY ENTRY IS A DOCUMENT, not a model object, and that is a deliberate
26 * difference from the two other clients. They hold a live model and serialize
27 * it; `solver_ldes_text` / `solver_ldes_file` are given the `model.json`
28 * DOCUMENT and forward its bytes UNCHANGED. There is a writer
29 * (`io/network_writer.h`), so a round trip through `qn::NetworkStruct<T>` is
30 * possible -- `solver_ldes` below is exactly that, for a caller who built the
31 * model in C++ -- but the reader that would have to parse the document first is
32 * deliberately scoped to the subset the analytical solvers read, so making the
33 * trip MANDATORY would degrade exactly the models LDES exists for (a cache with
34 * retrieval, an SPN, a polling server, a G-network signal). Forwarding the bytes
35 * keeps the client lossless on every model the engine accepts, including those
36 * this port cannot itself represent.
37 *
38 * WHICH BINARY RUNS. `ldes_runners` reproduces `getLdesRunners` and
39 * `_build_cli_runners`: the AOT GraalVM binary `common/ldes` first, for its
40 * startup time, then `<java> -jar common/ldes.jar`, and each is tried in turn
41 * because the AOT image lacks reflective features the JVM has (the fork-join
42 * MMT transform deep-copies the model by Java serialization). THE ORDER IS
43 * REVERSED, not merely extended, when the run uses something the prebuilt image
44 * predates -- `--respt-samples`, `--initsol`, a cache cost cap, a marked class,
45 * a load/class-dependent scaling -- because on that image the flag is not
46 * refused but IGNORED, and an ignored cost cap silently simulates the uncapped
47 * cache. The two other clients inspect the live model object for those; this
48 * one inspects the document, which is the same information at the point where
49 * it actually crosses.
50 *
51 * STATE PROBABILITIES ARE READ FROM THE HISTOGRAM, not from the trajectory.
52 * `ldes_prob_aggr` / `ldes_prob_sys_aggr` take the target state as an ARGUMENT
53 * and weigh it against `stateHistogram`, the exact residence time of every
54 * integer joint state the run visited. The no-argument form the reference also
55 * offers -- "the probability of the model's CURRENT state" -- has no counterpart
56 * here because `qn::NetworkStruct` carries no current state row (only a Cache's
57 * `initstate`, a Place's `initmarking` and the `stateprior`/`statespace` pair).
58 * The DEFAULT INITIAL state is a different thing and is derivable, by the same
59 * `ctmc::analyzer_detail::default_init_state` the `-a prob` arms of CTMC, MVA
60 * and NC already answer over; the LDES arm of the CLI still refuses `-a prob`
61 * on the older premise that no state exists at all, which is a reachability gap
62 * in `cpp/src/cli/line_cli.cpp` rather than a gap in this client.
63 * The three other clients answer BOTH forms off the transient `QNt` series,
64 * which is a trajectory of interval MEANS, and so report a near-zero
65 * probability for a state the chain mostly occupies; see BUG-96 and the note on
66 * `LdesResult::QNt`.
67 *
68 * WHAT THIS CLIENT DOES NOT PORT, and why it refuses instead of approximating:
69 * LayeredNetwork MATLAB simulates an LQN through the Java ensemble
70 * backend (`self.obj`), an in-process path with no JSON
71 * interface. Both `common/ldes` (since 2026-10-31) and
72 * `LdesCLI` now solve a LayeredNetwork document, so this
73 * is a client gap, not an engine one: `line-cli -s ldes`
74 * reaches the C++ LN engine in process instead.
75 * server breakdowns `linemodel_save` does not serialize `setBreakdown` at
76 * all, so a breakdown cannot reach this client: the
77 * refusal the Python client performs is on the OBJECT,
78 * before serialization, and has no wire counterpart.
79 *
80 * WHERE THE ENGINE IS, and whether there is one, lives in `ldes_probe.h`:
81 * SolverAUTO needs that answer -- LDES leads several of the reference's
82 * rankings, and a chooser that named it on a host without the engine would be
83 * proposing something that cannot run -- and must not take on this file's HTTP
84 * transport and result parser to get it.
85 */
86
88#include <algorithm>
89#include <cmath>
90#include <cstddef>
91#include <cstdio>
92#include <cstdlib>
93#include <cstring>
94#include <fstream>
95#include <limits>
96#include <sstream>
97#include <string>
98#include <utility>
99#include <vector>
100
101#include <sys/stat.h>
102#include <sys/utsname.h>
103#include <unistd.h>
104
105#include "json.hpp"
110#include "line/util/error.h"
111#include "line/util/http.h"
112#include "line/util/matrix.h"
113#include "line/util/subprocess.h"
114#include "line/util/tempdir.h"
115
116namespace line {
117namespace ldes {
118
119namespace detail {
120
121using Json = nlohmann::json;
122
123/**
124 * The shortest decimal that reads back as `v`, which is what Python's `repr`
125 * emits on the same flag. A fixed `%.10g`, as `solveCli.m` uses, would round a
126 * tolerance the caller set exactly, and the engine parses whatever it is given.
127 */
128inline std::string shortest(double v) {
129 char buf[64];
130 for (int prec = 15; prec <= 17; ++prec) {
131 std::snprintf(buf, sizeof(buf), "%.*g", prec, v);
132 if (std::strtod(buf, nullptr) == v) return std::string(buf);
133 }
134 return std::string(buf);
135}
136
137/** A JSON array of arrays as a matrix, with `null` read back as NaN. */
138inline Matrix<double> mat(const Json& v) {
139 if (v.is_null() || !v.is_array() || v.empty()) return Matrix<double>();
140 if (!v[0].is_array()) {
141 Matrix<double> m(1, v.size());
142 for (std::size_t j = 0; j < v.size(); ++j)
143 m(0, j) = v[j].is_null() ? std::numeric_limits<double>::quiet_NaN()
144 : v[j].get<double>();
145 return m;
146 }
147 std::size_t cols = 0;
148 for (std::size_t i = 0; i < v.size(); ++i)
149 if (v[i].is_array() && v[i].size() > cols) cols = v[i].size();
150 Matrix<double> m(v.size(), cols, std::numeric_limits<double>::quiet_NaN());
151 for (std::size_t i = 0; i < v.size(); ++i) {
152 if (!v[i].is_array()) continue;
153 for (std::size_t j = 0; j < v[i].size(); ++j)
154 if (!v[i][j].is_null()) m(i, j) = v[i][j].get<double>();
155 }
156 return m;
157}
158
159/** A JSON array as a vector of doubles, `null` read back as NaN. */
160inline std::vector<double> vec(const Json& v) {
161 std::vector<double> out;
162 if (!v.is_array()) return out;
163 out.reserve(v.size());
164 for (std::size_t i = 0; i < v.size(); ++i)
165 out.push_back(v[i].is_null() ? std::numeric_limits<double>::quiet_NaN()
166 : v[i].get<double>());
167 return out;
168}
169
170inline const Json& field(const Json& o, const char* key) {
171 static const Json null_value;
172 return o.is_object() && o.contains(key) ? o[key] : null_value;
173}
174
175/** Reads a whole file, or throws naming it. */
176inline std::string read_file(const std::string& path) {
177 std::ifstream in(path.c_str(), std::ios::binary);
178 if (!in) throw InputError("SolverLDES: cannot open the model document '" + path + "'");
179 std::ostringstream ss;
180 ss << in.rdbuf();
181 return ss.str();
182}
183
184inline void write_file(const std::string& path, const std::string& text) {
185 std::ofstream out(path.c_str(), std::ios::binary);
186 if (!out) throw InputError("SolverLDES: cannot write '" + path + "'");
187 out << text;
188}
189
190/**
191 * True when the DOCUMENT uses something the native engine would MIS-SERVE.
192 *
193 * The scan is on the bytes that actually cross rather than on a live model, and
194 * a substring test is deliberate: every key is unique enough that a false
195 * positive only reorders the runners, which costs startup time and changes no
196 * number, while a false negative would silently simulate a different model.
197 *
198 * THE BAR IS "SILENTLY IGNORED", NOT "DIFFERS". A key belongs here only while
199 * the native engine would accept it and then not honour it, because that is the
200 * failure this guard exists to prevent: an ignored cost cap simulates the
201 * uncapped cache and says nothing. A feature the native engine REFUSES by name
202 * needs no entry -- the runner list falls through to the jar on failure -- and
203 * neither does one it honours.
204 *
205 * `markedClasses` was removed on 2026-09-12, for three reasons together. The
206 * native engine has routed marks since 2026-09-11 and released batches since
207 * 2026-09-12, so it no longer ignores them. `common/ldes` is no longer the
208 * retired GraalVM image of the jar but a C++ binary built from this same tree
209 * (`add_executable(ldes src/cli/ldes_cli.cpp)`), so it cannot predate a document
210 * this tree emits. And neither other client ever carried the entry -- MATLAB's
211 * `getLdesRunners` reverses for nothing and python's `_build_cli_runners` only
212 * for `--respt-samples` -- so keeping it meant
213 * one marked model ran on TWO different engines depending on which codebase's
214 * client was asked, which is the one thing "there is one LDES" is supposed to
215 * rule out.
216 *
217 * `costCaps` was removed on 2026-10-31: the native engine enforces the caps and
218 * now also reports `hitList`, `itemProb` and `listCost` for them.
219 *
220 * Note that digit-for-digit agreement is NOT the bar, and could not be: the two
221 * engines part company on any model whose routing consumes extra draws, which
222 * `CLAUDE.md` states outright. `--initsol` and `--busyperiod` happened to clear
223 * that higher bar when they were pruned; marks do not have to.
224 */
225inline bool document_postdates_native(const std::string& doc) {
226 static const char* keys[] = {"\"classDependence\"",
227 "\"loadDependence\"", "\"jointDependence\""};
228 for (std::size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i)
229 if (doc.find(keys[i]) != std::string::npos) return true;
230 return false;
231}
232
233} // namespace detail
234
235/**
236 * The engine flags of one run, after `solve <model> -o <result>`.
237 *
238 * ONE MAPPING for the subprocess and the REST paths, as in both other clients:
239 * the server takes the same long-form flags verbatim, and deriving them twice
240 * is how the two transports start disagreeing. A knob is emitted only when it
241 * differs from the engine default, so a default run yields the minimal command
242 * line an older AOT image still parses -- except `-s` and `--seed`, which are
243 * always emitted: the CLI's seed default is -1 (random), so a silent seed makes
244 * the run irreproducible and unlike the MATLAB and Python clients.
245 */
246inline std::vector<std::string> ldes_flags(const LdesOptions& o,
247 const std::vector<std::string>& extra) {
248 std::vector<std::string> f;
249 const std::size_t budget = o.events ? o.events : o.samples;
250 f.push_back("-s");
251 f.push_back(std::to_string(budget));
252 f.push_back("--seed");
253 f.push_back(std::to_string(o.seed));
254 if (!o.method.empty() && o.method != "default") {
255 f.push_back("--method");
256 f.push_back(o.method);
257 }
258 if (o.cnvgon) {
259 f.push_back("--cnvgon");
260 if (o.cnvgtol != 0.05) {
261 f.push_back("--cnvgtol");
262 f.push_back(detail::shortest(o.cnvgtol));
263 }
264 if (o.cnvgbatch != 20) {
265 f.push_back("--cnvgbatch");
266 f.push_back(std::to_string(o.cnvgbatch));
267 }
268 if (o.cnvgchk != 0) {
269 f.push_back("--cnvgchk");
270 f.push_back(std::to_string(o.cnvgchk));
271 }
272 }
273 if (o.tranfilter != "mser5") {
274 f.push_back("--tranfilter");
275 f.push_back(o.tranfilter);
276 }
277 if (o.warmupfrac != 0.2) {
278 f.push_back("--warmupfrac");
279 f.push_back(detail::shortest(o.warmupfrac));
280 }
281 if (o.tranobs > 0) {
282 f.push_back("--tranobs");
283 f.push_back(std::to_string(o.tranobs));
284 }
285 if (o.mserbatch != 5) {
286 f.push_back("--mserbatch");
287 f.push_back(std::to_string(o.mserbatch));
288 }
289 if (o.cimethod != "obm") {
290 f.push_back("--cimethod");
291 f.push_back(o.cimethod);
292 }
293 if (o.obmoverlap != 0.5) {
294 f.push_back("--obmoverlap");
295 f.push_back(detail::shortest(o.obmoverlap));
296 }
297 if (o.ciminbatch != 10) {
298 f.push_back("--ciminbatch");
299 f.push_back(std::to_string(o.ciminbatch));
300 }
301 if (o.ciminobs != 100) {
302 f.push_back("--ciminobs");
303 f.push_back(std::to_string(o.ciminobs));
304 }
305 if (o.spectral_low_freq_frac != 0.25) {
306 f.push_back("--spectrallowfreqfrac");
307 f.push_back(detail::shortest(o.spectral_low_freq_frac));
308 }
309 // --slotlength implies --slotted on the CLI side; both are emitted when the
310 // length is non-default so the command line states the intent explicitly.
311 if (o.slotted) {
312 f.push_back("--slotted");
313 if (o.slot_length != 1.0) {
314 f.push_back("--slotlength");
315 f.push_back(detail::shortest(o.slot_length));
316 }
317 }
318 // method='parallel' (alias 'para') is a replication count, not a second engine: the caller's
319 // count when set, 8 otherwise, as solveCli.m resolves it. The native engine reads only --replications.
320 int reps = o.replications;
321 if ((o.method == "parallel" || o.method == "para") && reps <= 1) reps = 8;
322 if (reps > 1) {
323 f.push_back("--replications");
324 f.push_back(std::to_string(reps));
325 if (o.numthreads > 0) {
326 f.push_back("--numthreads");
327 f.push_back(std::to_string(o.numthreads));
328 }
329 }
330 if (o.has_timespan) {
331 f.push_back("--timespan");
332 f.push_back(detail::shortest(o.t0) + "," + detail::shortest(o.t1));
333 }
334 if (o.timeout > 0.0 && std::isfinite(o.timeout)) {
335 f.push_back("--maxtime");
336 f.push_back(detail::shortest(o.timeout));
337 }
338 if (o.busy_period_orders > 0) {
339 f.push_back("--busyperiod");
340 f.push_back(std::to_string(o.busy_period_orders));
341 for (std::size_t i = 0; i < o.busy_period_subnets.size(); ++i) {
342 // station indexes cross the wire zero-based, as the engine holds them
343 std::string v;
344 for (std::size_t t = 0; t < o.busy_period_subnets[i].size(); ++t)
345 v += (t ? "," : "") + std::to_string(o.busy_period_subnets[i][t]);
346 f.push_back("--busyperiod-subnet");
347 f.push_back(v);
348 }
349 }
350 if (!o.init_sol.empty()) {
351 std::string v;
352 for (std::size_t i = 0; i < o.init_sol.size(); ++i)
353 v += (i ? "," : "") + detail::shortest(o.init_sol[i]);
354 f.push_back("--initsol");
355 f.push_back(v);
356 }
357 if (o.init_jobs.rows() > 0) {
358 std::string v;
359 for (std::size_t r = 0; r < o.init_jobs.rows(); ++r)
360 for (std::size_t c = 0; c < o.init_jobs.cols(); ++c)
361 v += (r && !c ? ";" : (c ? "," : "")) + detail::shortest(o.init_jobs(r, c));
362 f.push_back("--initjobs");
363 f.push_back(v);
364 }
365 if (o.track_jobs) f.push_back("--trackjobs");
366 for (std::size_t i = 0; i < extra.size(); ++i) f.push_back(extra[i]);
367 return f;
368}
369
370/** One runner: the argv prefix that runs the engine, and the name of its image. */
372 std::vector<std::string> argv; ///< up to and including "solve"
373 std::string engine; ///< "native" or "jar"
374};
375
376/**
377 * The runners to try, in order (see the file header for why there are two and
378 * when the order flips).
379 *
380 * @param doc the model document, scanned for features the AOT image predates
381 * @param flags the resolved flags, scanned for the same reason
382 * @throws UnsupportedError when no engine can run on this host
383 */
384inline std::vector<LdesRunner> ldes_runners(const std::string& doc,
385 const std::vector<std::string>& flags) {
386 const std::string& dir = ldes_engine_dir();
387 std::vector<LdesRunner> out;
388 const std::string native = detail::native_ldes_path(dir);
389 if (!native.empty()) {
390 LdesRunner r;
391 r.argv.push_back(native);
392 r.argv.push_back("solve");
393 r.engine = "native";
394 out.push_back(r);
395 }
396 const std::string java = detail::find_java();
397 const std::string jar = dir.empty() ? std::string() : dir + "/ldes.jar";
398 if (!java.empty() && detail::is_file(jar)) {
399 LdesRunner r;
400 r.argv.push_back(java);
401 r.argv.push_back("-jar");
402 r.argv.push_back(jar);
403 r.argv.push_back("solve");
404 r.engine = "jar";
405 out.push_back(r);
406 }
407 if (out.empty()) throw ExternalError(ErrorKind{"LDES"}, detail::no_backend_message(dir));
408 if (out.size() > 1) {
409 // --initsol and --busyperiod used to be here, because the retired
410 // GraalVM image predated them; the C++ engine honours both and agrees
411 // with the jar to the last digit, so only --respt-samples remains.
412 bool prefer_jar = detail::document_postdates_native(doc);
413 for (std::size_t i = 0; i < flags.size() && !prefer_jar; ++i)
414 prefer_jar = flags[i] == "--respt-samples";
415 if (prefer_jar) std::swap(out[0], out[1]);
416 }
417 return out;
418}
419
420/**
421 * Solves through an LDES REST server and returns its `result` document.
422 *
423 * The payload is the model text plus the same flag vector the subprocess would
424 * have been given, so a fixed seed gives the same numbers on both transports.
425 */
426inline detail::Json ldes_solve_rest(const std::string& base_url, const std::string& doc,
427 const std::vector<std::string>& flags, double timeout) {
428 std::string url = base_url;
429 while (!url.empty() && url[url.size() - 1] == '/') url.erase(url.size() - 1);
430 if (url.size() < 6 || url.compare(url.size() - 6, 6, "/solve") != 0) url += "/api/v1/solve";
431
432 detail::Json payload = detail::Json::object();
433 detail::Json model = detail::Json::object();
434 model["content"] = doc;
435 model["base64"] = false;
436 payload["model"] = model;
437 detail::Json fl = detail::Json::array();
438 for (std::size_t i = 0; i < flags.size(); ++i) fl.push_back(flags[i]);
439 payload["flags"] = fl;
440
441 const int millis = (timeout > 0.0 && std::isfinite(timeout))
442 ? static_cast<int>((timeout + 30.0) * 1000.0)
443 : 600000;
444 const http::Response resp = http::post_json(url, payload.dump(), millis);
445 if (resp.body.empty())
446 throw ExternalError(ErrorKind{"LDES"}, "SolverLDES: the REST server at " + url + " returned HTTP " +
447 std::to_string(resp.status) + " with no body");
448 detail::Json out = detail::Json::parse(resp.body, nullptr, false);
449 if (out.is_discarded() || !out.is_object())
450 throw ExternalError(ErrorKind{"LDES"}, "SolverLDES: the REST server at " + url +
451 " returned a non-JSON body: " + resp.body);
452 if (!out.contains("status") || out["status"].get<std::string>() != "ok") {
453 const std::string msg = out.contains("message") ? out["message"].get<std::string>()
454 : std::string("unspecified error");
455 const std::string err = out.contains("stderr") ? out["stderr"].get<std::string>()
456 : std::string();
457 throw ExternalError(ErrorKind{"LDES"}, "SolverLDES: the REST solve failed: " + msg +
458 (err.empty() ? "" : " (engine stderr: " + err + ")"));
459 }
460 return out["result"];
461}
462
463/**
464 * Parses one `ldes-result` document.
465 *
466 * `format` is CHECKED, not assumed: the runner falls through on failure, and a
467 * runner that wrote some other JSON to the output path would otherwise be read
468 * as a result full of absent metrics rather than as the failure it is.
469 */
470inline LdesResult parse_ldes_result(const detail::Json& d) {
471 if (d.is_object() && d.contains("error"))
472 throw NumericError(ErrorKind{"InvalidResult"}, "SolverLDES: the engine reported an error: " +
473 d["error"].get<std::string>());
474 if (!d.is_object() || !d.contains("format") || d["format"].get<std::string>() != "ldes-result")
475 throw NumericError(ErrorKind{"InvalidResult"},
476 "SolverLDES: the engine wrote a document that is not an ldes-result; the run did not "
477 "produce metrics");
478
479 LdesResult r;
480 r.method = d.contains("method") && !d["method"].is_null() ? d["method"].get<std::string>()
481 : std::string("default");
482 if (d.contains("runtime")) r.runtime = d["runtime"].get<double>();
483 if (d.contains("converged")) r.converged = d["converged"].get<bool>();
484 if (d.contains("stoppingReason") && !d["stoppingReason"].is_null())
485 r.stopping_reason = d["stoppingReason"].get<std::string>();
486 // The engine's console is not shown, so its warnings are re-raised here.
487 if (d.contains("warnings") && d["warnings"].is_array())
488 for (const auto& w : d["warnings"]) {
489 r.warnings.push_back(w.get<std::string>());
490 std::fprintf(stderr, "[SolverLDES] Warning: %s\n", r.warnings.back().c_str());
491 }
492 if (d.contains("convergenceBatches")) r.convergence_batches = d["convergenceBatches"].get<long>();
493 if (d.contains("totalSimulatedEvents"))
494 r.total_simulated_events = d["totalSimulatedEvents"].get<long long>();
495
496 const detail::Json& dim = detail::field(d, "dimensions");
497 if (dim.is_object()) {
498 if (dim.contains("nstations")) r.nstations = dim["nstations"].get<std::size_t>();
499 if (dim.contains("nclasses")) r.nclasses = dim["nclasses"].get<std::size_t>();
500 if (dim.contains("nchains")) r.nchains = dim["nchains"].get<std::size_t>();
501 if (dim.contains("stationNames"))
502 for (std::size_t i = 0; i < dim["stationNames"].size(); ++i)
503 r.station_names.push_back(dim["stationNames"][i].get<std::string>());
504 if (dim.contains("classNames"))
505 for (std::size_t i = 0; i < dim["classNames"].size(); ++i)
506 r.class_names.push_back(dim["classNames"][i].get<std::string>());
507 }
508
509 const detail::Json& m = detail::field(d, "metrics");
510 r.QN = detail::mat(detail::field(m, "QN"));
511 r.UN = detail::mat(detail::field(m, "UN"));
512 r.RN = detail::mat(detail::field(m, "RN"));
513 r.TN = detail::mat(detail::field(m, "TN"));
514 r.AN = detail::mat(detail::field(m, "AN"));
515 r.WN = detail::mat(detail::field(m, "WN"));
516 r.CN = detail::mat(detail::field(m, "CN"));
517 r.XN = detail::mat(detail::field(m, "XN"));
518 r.DropRateJoin = detail::mat(detail::field(m, "DropRateJoin"));
519
520 const detail::Json& ci = detail::field(d, "confidenceIntervals");
521 r.QNCI = detail::mat(detail::field(ci, "QNCI"));
522 r.UNCI = detail::mat(detail::field(ci, "UNCI"));
523 r.RNCI = detail::mat(detail::field(ci, "RNCI"));
524 r.TNCI = detail::mat(detail::field(ci, "TNCI"));
525 r.ANCI = detail::mat(detail::field(ci, "ANCI"));
526 r.WNCI = detail::mat(detail::field(ci, "WNCI"));
527
528 const detail::Json& rp = detail::field(d, "relativePrecision");
529 r.QNRelPrec = detail::mat(detail::field(rp, "QNRelPrec"));
530 r.UNRelPrec = detail::mat(detail::field(rp, "UNRelPrec"));
531 r.RNRelPrec = detail::mat(detail::field(rp, "RNRelPrec"));
532 r.TNRelPrec = detail::mat(detail::field(rp, "TNRelPrec"));
533
534 const detail::Json& sc = detail::field(d, "sampleCounts");
535 r.QNSamples = detail::mat(detail::field(sc, "QNSamples"));
536 r.UNSamples = detail::mat(detail::field(sc, "UNSamples"));
537 r.RNSamples = detail::mat(detail::field(sc, "RNSamples"));
538 r.TNSamples = detail::mat(detail::field(sc, "TNSamples"));
539
540 const detail::Json& fcr = detail::field(d, "fcr");
541 if (fcr.is_object()) {
542 if (fcr.contains("nregions")) r.nregions = fcr["nregions"].get<std::size_t>();
543 r.QNfcr = detail::mat(detail::field(fcr, "QNfcr"));
544 r.UNfcr = detail::mat(detail::field(fcr, "UNfcr"));
545 r.RNfcr = detail::mat(detail::field(fcr, "RNfcr"));
546 r.TNfcr = detail::mat(detail::field(fcr, "TNfcr"));
547 r.ANfcr = detail::mat(detail::field(fcr, "ANfcr"));
548 r.WNfcr = detail::mat(detail::field(fcr, "WNfcr"));
549 r.WeightNfcr = detail::mat(detail::field(fcr, "WeightNfcr"));
550 r.MemOccNfcr = detail::mat(detail::field(fcr, "MemOccNfcr"));
551 r.DropRateNfcr = detail::mat(detail::field(fcr, "DropRateNfcr"));
552 }
553
554 // Fork-join section metrics: rows named by "forks"/"joins", mapped onto the model's pairs by ldes_fj_metrics.
555 const detail::Json& fjb = detail::field(d, "forkJoin");
556 if (fjb.is_object()) {
557 const detail::Json& fk = detail::field(fjb, "forks");
558 const detail::Json& jn = detail::field(fjb, "joins");
559 for (std::size_t p = 0; fk.is_array() && p < fk.size(); ++p)
560 r.fj_forks.push_back(fk[p].is_string() ? fk[p].get<std::string>() : std::string());
561 for (std::size_t p = 0; jn.is_array() && p < jn.size(); ++p)
562 r.fj_joins.push_back(jn[p].is_string() ? jn[p].get<std::string>() : std::string());
563 r.FJQLen = detail::mat(detail::field(fjb, "FJQLen"));
564 r.FJRespT = detail::mat(detail::field(fjb, "FJRespT"));
565 r.FJTput = detail::mat(detail::field(fjb, "FJTput"));
566 }
567
568 const detail::Json& imp = detail::field(d, "impatience");
569 if (imp.is_object()) {
570 r.renegedCustomers = detail::mat(detail::field(imp, "renegedCustomers"));
571 r.avgRenegingWaitTime = detail::mat(detail::field(imp, "avgRenegingWaitTime"));
572 r.renegingRate = detail::mat(detail::field(imp, "renegingRate"));
573 r.balkedCustomers = detail::mat(detail::field(imp, "balkedCustomers"));
574 r.balkingProbability = detail::mat(detail::field(imp, "balkingProbability"));
575 r.retriedCustomers = detail::mat(detail::field(imp, "retriedCustomers"));
576 r.retrialDropped = detail::mat(detail::field(imp, "retrialDropped"));
577 r.avgOrbitSize = detail::mat(detail::field(imp, "avgOrbitSize"));
578 }
579
580 const detail::Json& cm = detail::field(d, "cacheMetrics");
581 if (cm.is_object())
582 for (detail::Json::const_iterator it = cm.begin(); it != cm.end(); ++it) {
584 c.hit = detail::mat(detail::field(it.value(), "hit"));
585 c.delayed = detail::mat(detail::field(it.value(), "delayed"));
586 c.miss = detail::mat(detail::field(it.value(), "miss"));
587 c.latency = detail::mat(detail::field(it.value(), "latency"));
588 c.hitList = detail::mat(detail::field(it.value(), "hitList"));
589 c.itemProb = detail::mat(detail::field(it.value(), "itemProb"));
590 c.listCost = detail::mat(detail::field(it.value(), "listCost"));
591 r.cache_metrics[it.key()] = c;
592 }
593
594 const detail::Json& bp = detail::field(d, "busyPeriods");
595 if (bp.is_object()) {
596 const detail::Json& targets = detail::field(bp, "targets");
597 for (std::size_t i = 0; i < targets.size(); ++i) {
599 const detail::Json& tj = targets[i];
600 if (tj.contains("name") && tj["name"].is_string())
601 t.name = tj["name"].get<std::string>();
602 const detail::Json& st = detail::field(tj, "stations");
603 for (std::size_t k = 0; k < st.size(); ++k)
604 t.stations.push_back(static_cast<std::size_t>(st[k].get<double>()));
605 if (tj.contains("class") && tj["class"].is_number())
606 t.job_class = static_cast<int>(tj["class"].get<double>());
607 t.mean = detail::vec(detail::field(tj, "mean"));
608 t.count = detail::vec(detail::field(tj, "count"));
609 r.busy_periods.push_back(t);
610 }
611 }
612
613 const detail::Json& js = detail::field(d, "jobState");
614 if (js.is_object()) {
615 r.finalJobs = detail::mat(detail::field(js, "finalJobs"));
616 r.jobDepartures = detail::mat(detail::field(js, "jobDepartures"));
617 }
618
619 const detail::Json& hist = detail::field(d, "stateHistogram");
620 if (hist.is_object()) {
621 r.histogram_space = detail::mat(detail::field(hist, "space"));
622 r.histogram_time = detail::mat(detail::field(hist, "time"));
623 r.traj_space = detail::mat(detail::field(hist, "trajSpace"));
624 r.traj_time = detail::mat(detail::field(hist, "trajTime"));
625 }
626
627 // `respTimeSamples` appears at TOP LEVEL under --respt-samples and inside
628 // `transient` under --trajectory; the two are the same measurement and
629 // whichever is present is read.
630 const detail::Json& tran = detail::field(d, "transient");
631 if (tran.is_object()) {
632 r.t = detail::vec(detail::field(tran, "t"));
633 const char* keys[3] = {"QNt", "UNt", "TNt"};
634 std::vector<std::vector<Matrix<double>>>* dst[3] = {&r.QNt, &r.UNt, &r.TNt};
635 for (int q = 0; q < 3; ++q) {
636 const detail::Json& blk = detail::field(tran, keys[q]);
637 if (!blk.is_array()) continue;
638 for (std::size_t i = 0; i < blk.size(); ++i) {
639 std::vector<Matrix<double>> row;
640 for (std::size_t k = 0; k < blk[i].size(); ++k)
641 row.push_back(detail::mat(blk[i][k]));
642 dst[q]->push_back(row);
643 }
644 }
645 }
646 const detail::Json& rts = tran.is_object() && tran.contains("respTimeSamples")
647 ? tran["respTimeSamples"]
648 : detail::field(d, "respTimeSamples");
649 if (rts.is_array())
650 for (std::size_t i = 0; i < rts.size(); ++i) {
651 std::vector<std::vector<double>> row;
652 for (std::size_t k = 0; k < rts[i].size(); ++k) row.push_back(detail::vec(rts[i][k]));
653 r.respTimeSamples.push_back(row);
654 }
655 return r;
656}
657
658/**
659 * Runs one LDES simulation on a `model.json` DOCUMENT and parses its result.
660 *
661 * The document is written into a private scratch directory, the engine is run
662 * there, and the directory is removed on both the success and the failure path
663 * (`TempDir`'s destructor), because a wrapper that leaks one directory per
664 * failed solve leaks silently.
665 *
666 * THE RUNNERS ARE TRIED IN ORDER and the LAST failure is reported in full,
667 * merged stdout and stderr, because the engine states why it refused there and
668 * an exit code alone is not a diagnosis.
669 *
670 * @param doc the model.json text, forwarded byte for byte
671 * @param o the run's knobs
672 * @param extra_flags flags an analysis adds (--trajectory, --export-histogram,
673 * --respt-samples)
674 * @return the parsed result, carrying which image produced it
675 */
676inline LdesResult solver_ldes_text(const std::string& doc, const LdesOptions& o,
677 const std::vector<std::string>& extra_flags =
678 std::vector<std::string>()) {
679 const std::vector<std::string> flags = ldes_flags(o, extra_flags);
680 util::TempDir tmp("ldes");
681 const std::string model_path = tmp.file("model.json");
682 const std::string result_path = tmp.file("result.json");
683 line::util::LineConsole::step("serializing the model to JSON for the LDES engine");
684 detail::write_file(model_path, doc);
685
686 if (!o.rest_url.empty()) {
688 r.engine = "rest";
689 return r;
690 }
691
692 const std::vector<LdesRunner> runners = ldes_runners(doc, flags);
693 // The cooperative --maxtime stops the event loop; this is the HARD bound and
694 // leaves the engine 30 s of grace to write its result and exit, else no
695 // result file is produced at all. An infinite budget falls back to 600 s.
696 const int hard = (o.timeout > 0.0 && std::isfinite(o.timeout))
697 ? static_cast<int>(o.timeout + 30.0)
698 : 600;
699 std::string last_err;
700 for (std::size_t i = 0; i < runners.size(); ++i) {
701 std::vector<std::string> argv = runners[i].argv;
702 argv.push_back(model_path);
703 argv.push_back("-o");
704 argv.push_back(result_path);
705 for (std::size_t j = 0; j < flags.size(); ++j) argv.push_back(flags[j]);
706 std::remove(result_path.c_str());
707 if (o.verbose) {
708 std::string line;
709 for (std::size_t j = 0; j < argv.size(); ++j) line += (j ? " " : "") + argv[j];
710 std::fprintf(stderr, "SolverLDES command: %s\n", line.c_str());
711 }
712 line::util::LineConsole::step("running the LDES engine (%s) as a subprocess",
713 runners[i].engine.c_str());
714 const util::ProcResult p = util::capture(argv, hard, true);
715 if (p.timedOut) {
716 LdesResult r;
717 r.timed_out = true;
718 r.stopping_reason = "max_time";
719 r.engine = runners[i].engine;
720 return r;
721 }
722 if (p.exitCode == 0 && detail::is_file(result_path)) {
723 line::util::LineConsole::step("parsing the LDES result document");
724 const std::string text = detail::read_file(result_path);
725 detail::Json parsed = detail::Json::parse(text, nullptr, false);
726 if (parsed.is_discarded())
727 throw ExternalError(ErrorKind{"LDES"}, "SolverLDES: the engine wrote a result that is not JSON");
728 LdesResult r = parse_ldes_result(parsed);
729 r.engine = runners[i].engine;
730 return r;
731 }
732 last_err = "exit code " + std::to_string(p.exitCode) + ": " + util::trim(p.out);
733 }
734 throw ExternalError(ErrorKind{"LDES"}, "SolverLDES: the engine failed on all " +
735 std::to_string(runners.size()) + " runner(s); " + last_err);
736}
737
738/** The same, reading the document from a file. */
739inline LdesResult solver_ldes_file(const std::string& path, const LdesOptions& o,
740 const std::vector<std::string>& extra_flags =
741 std::vector<std::string>()) {
742 return solver_ldes_text(detail::read_file(path), o, extra_flags);
743}
744
745/**
746 * One constraint of a joint-state query: a station and the per-class job counts
747 * it is required to hold.
748 *
749 * `station` is 1-BASED, matching `sn.nodeToStation`. `counts` is read up to
750 * `nclasses` entries; a shorter vector constrains only the classes it names,
751 * which is how a caller asks for "two class-1 jobs here, any number of the
752 * rest" without enumerating the remainder.
753 */
755 std::size_t station = 0;
756 std::vector<double> counts;
757};
758
759/**
760 * Residence-time probability of an aggregate joint state, from a parsed result.
761 *
762 * WHY THE HISTOGRAM AND NOT THE TRAJECTORY. `stateHistogram` is the exact
763 * residence time of every integer joint state the run visited, keyed on the
764 * state itself (`Solver_ssj.updateRewardStats` accumulates it per marking), so
765 * P(state) = t(state) / sum(t) is an unbiased estimate that is exact on the
766 * sampled path. The transient `QNt` series is a sequence of INTERVAL MEANS of
767 * the queue length; comparing one against an integer state matches only where a
768 * bucket mean happens to land on an integer, which is why the three clients
769 * that estimated a probability that way reported a near-zero number for a state
770 * the chain occupies most of the time (BUG-96).
771 *
772 * The layout is the aggregated station-major, class-minor one
773 * `ctmc_state_space_aggr` builds: column `(i-1)*nclasses + k` is the number of
774 * class-k jobs at station i. THE RESOLUTION IS PER CLASS, not per phase: the
775 * engine records the integer queue lengths, so a phase-resolved query cannot be
776 * answered from this document and the caller must aggregate first.
777 *
778 * @param r a result produced with `--export-histogram`
779 * @param nclasses the model's class count, which fixes the column stride
780 * @param query the stations to constrain, and to what
781 * @return the residence-time fraction, 0 when the state was never visited
782 * @throws NumericError when the run carried no histogram (the flag was omitted)
783 */
784inline double ldes_prob_from_histogram(const LdesResult& r, std::size_t nclasses,
785 const std::vector<LdesStateQuery>& query) {
786 if (r.histogram_space.rows() == 0 || r.histogram_time.rows() == 0)
787 throw NumericError(ErrorKind{"Degenerate"},
788 "SolverLDES: this result carries no state histogram, so no state probability can be "
789 "read from it; the run has to pass --export-histogram");
790 if (nclasses == 0)
791 throw InputError("SolverLDES: a state probability needs the model's class count");
792 const std::size_t nstates = r.histogram_space.rows();
793 double total = 0.0;
794 for (std::size_t s = 0; s < nstates; ++s) total += r.histogram_time(s, 0);
795 if (!(total > 0.0)) return 0.0;
796
797 const std::size_t ncols = r.histogram_space.cols();
798 for (std::size_t q = 0; q < query.size(); ++q) {
799 if (query[q].station == 0)
800 throw InputError("SolverLDES: a state query names station 0; stations are 1-based");
801 const std::size_t last = (query[q].station - 1) * nclasses + nclasses;
802 if (last > ncols)
803 throw InputError(
804 "SolverLDES: station " + std::to_string(query[q].station) +
805 " is past the end of the state histogram, which holds " + std::to_string(ncols) +
806 " columns for " + std::to_string(nclasses) + " classes");
807 }
808
809 double matched = 0.0;
810 for (std::size_t s = 0; s < nstates; ++s) {
811 bool ok = true;
812 for (std::size_t q = 0; q < query.size() && ok; ++q) {
813 const std::size_t base = (query[q].station - 1) * nclasses;
814 const std::size_t n = std::min(nclasses, query[q].counts.size());
815 for (std::size_t k = 0; k < n; ++k)
816 if (std::fabs(r.histogram_space(s, base + k) - query[q].counts[k]) > 1e-9) {
817 ok = false;
818 break;
819 }
820 }
821 if (ok) matched += r.histogram_time(s, 0);
822 }
823 return matched / total;
824}
825
826/**
827 * Port of `getCdfRespT`: the EMPIRICAL response time CDF of one (station,
828 * class) pair, built from the per-job samples the engine exports under
829 * `--respt-samples`.
830 *
831 * The returned matrix is (n x 2) with columns [F(t), t], NOT [t, F(t)]. That
832 * order is the convention every `getCdfRespT` follows across the codebases
833 * (MATLAB `@SolverLDES/getCdfRespT.m`, Python `SolverJMT.getCdfRespT`), and it
834 * is the REVERSE of the transient `getTranCdfRespT`, which is a different
835 * method with a different contract. Swapping them yields a CDF that reads as a
836 * time axis and vice versa, with no error anywhere.
837 *
838 * A pair the run observed nothing at returns an EMPTY matrix rather than a
839 * fabricated law: an analytical fallback carrying the right mean says nothing
840 * about the tail, which is the whole reason to ask a simulator for a CDF.
841 * Repeated observations are collapsed, keeping the largest CDF value at each
842 * distinct time, or the ecdf is multivalued and an interpolating consumer
843 * reads a quantile off whichever duplicate it happens to hit.
844 */
845inline Matrix<double> ldes_cdf_respt(const LdesResult& r, std::size_t station,
846 std::size_t job_class) {
847 if (station >= r.respTimeSamples.size() ||
848 job_class >= r.respTimeSamples[station].size())
849 return Matrix<double>();
850 std::vector<double> x = r.respTimeSamples[station][job_class];
851 if (x.empty()) return Matrix<double>();
852 std::sort(x.begin(), x.end());
853 const std::size_t n = x.size();
854 std::vector<double> tu, fu;
855 for (std::size_t i = 0; i < n; ++i) {
856 const double f = static_cast<double>(i + 1) / static_cast<double>(n);
857 if (i + 1 < n && x[i + 1] == x[i]) continue; // keep the LAST of a tie
858 tu.push_back(x[i]);
859 fu.push_back(f);
860 }
861 Matrix<double> out(tu.size(), 2, 0.0);
862 for (std::size_t i = 0; i < tu.size(); ++i) {
863 out(i, 0) = fu[i];
864 out(i, 1) = tu[i];
865 }
866 return out;
867}
868
869/**
870 * Port of `getProbAggr`: the marginal probability of a per-class job count at
871 * one station.
872 *
873 * The run is a plain steady-state solve with `--export-histogram` added, which
874 * is what `getAvgReward` already does, so the estimate is over the same
875 * post-warmup path the mean metrics come from.
876 *
877 * @param doc the model.json document, forwarded byte for byte
878 * @param o the run's knobs
879 * @param station 1-based station index
880 * @param counts per-class job counts to test at that station
881 * @param nclasses the model's class count
882 */
883inline double ldes_prob_aggr(const std::string& doc, const LdesOptions& o, std::size_t station,
884 const std::vector<double>& counts, std::size_t nclasses) {
885 std::vector<std::string> flags;
886 flags.push_back("--export-histogram");
887 const LdesResult r = solver_ldes_text(doc, o, flags);
888 std::vector<LdesStateQuery> q(1);
889 q[0].station = station;
890 q[0].counts = counts;
891 return ldes_prob_from_histogram(r, nclasses, q);
892}
893
894/**
895 * Port of `getProbSysAggr`: the joint probability of a whole aggregate state.
896 *
897 * @param target (nstations x nclasses) job counts, EVERY station constrained
898 */
899inline double ldes_prob_sys_aggr(const std::string& doc, const LdesOptions& o,
900 const Matrix<double>& target) {
901 if (target.rows() == 0 || target.cols() == 0)
902 throw InputError("SolverLDES: getProbSysAggr needs an (nstations x nclasses) target state");
903 std::vector<std::string> flags;
904 flags.push_back("--export-histogram");
905 const LdesResult r = solver_ldes_text(doc, o, flags);
906 const std::size_t K = target.cols();
907 std::vector<LdesStateQuery> q(target.rows());
908 for (std::size_t i = 0; i < target.rows(); ++i) {
909 q[i].station = i + 1;
910 q[i].counts.resize(K);
911 for (std::size_t k = 0; k < K; ++k) q[i].counts[k] = target(i, k);
912 }
913 return ldes_prob_from_histogram(r, K, q);
914}
915
916/**
917 * `ldesParseForkJoin`: the engine's "forkJoin" rows mapped onto the Fork/Join pairs of `sn` by (fork name,
918 * join name), in fork-then-join node order. A pair the engine did not report, and a non-finite entry, read
919 * zero. EMPTY when the model has no pair or the engine wrote no block.
920 */
921template <class T>
923 if (solvers::fj_sorted_pairs(sn).empty() || r.fj_forks.empty()) return solvers::FjMetrics<double>();
924 const std::size_t R = sn.nclasses;
926 const Matrix<double>* src[3] = {&r.FJQLen, &r.FJRespT, &r.FJTput};
927 Matrix<double>* dst[3] = {&out.QLen, &out.RespT, &out.Tput};
928 for (std::size_t p = 0; p < out.npairs(); ++p) {
929 const std::string& fname = sn.nodes[out.forks[p] - 1].name;
930 const std::string& jname = sn.nodes[out.joins[p] - 1].name;
931 std::size_t e = r.fj_forks.size();
932 for (std::size_t k = 0; k < r.fj_forks.size() && k < r.fj_joins.size(); ++k)
933 if (r.fj_forks[k] == fname && r.fj_joins[k] == jname) {
934 e = k;
935 break;
936 }
937 if (e == r.fj_forks.size()) continue;
938 for (int m = 0; m < 3; ++m)
939 for (std::size_t c = 0; c < R; ++c) {
940 std::size_t cc = c; // the engine's column of class c, matched by name when it reports names
941 for (std::size_t k = 0; k < r.class_names.size(); ++k)
942 if (r.class_names[k] == sn.classes[c].name) {
943 cc = k;
944 break;
945 }
946 const double v =
947 (e < src[m]->rows() && cc < src[m]->cols()) ? (*src[m])(e, cc) : 0.0;
948 (*dst[m])(p, c) = std::isfinite(v) ? v : 0.0;
949 }
950 }
951 return out;
952}
953
954/**
955 * The same, for a model built through the C++ API.
956 *
957 * The struct is serialized with `io::network_json_envelope`, the writer whose
958 * output the reference readers consume, and the document then takes the same
959 * path as any other. WHAT THE STRUCT CANNOT CARRY DOES NOT CROSS: a reward built
960 * from a lambda and any construct outside the writer's schema are dropped by the
961 * writer, with its own warning, which is why a caller holding a `model.json`
962 * should pass the DOCUMENT rather than a struct parsed from it -- the round trip
963 * can only lose.
964 *
965 * An OI/PAS station with no finite buffer is refused here, by name, before the
966 * document is written: its mu(c) crosses as a table and the engine needs a
967 * finite buffer to bound the state (`io::require_finite_pas_buffers`, the
968 * Python `save_model` refusal). The in-process engine, which holds mu itself,
969 * is not gated.
970 */
971/**
972 * Closed-chain population conservation check on a steady-state QN, ported from MATLAB's parseSteady.
973 *
974 * A closed chain must conserve its population in the reported queue lengths. Engine builds that drop
975 * blocked closed jobs at a finite-capacity station (pre-guard ldes.jar) leak population silently, and
976 * the reported means then describe a different model. FCR models are excluded (a WAITQ waiter is
977 * parked outside the station rows), and so are fork-join models (a forked job is counted once per
978 * sibling task, so the rows sum to more than the population by construction). A chain is flagged when
979 * |sum(QN) - N| > max(0.05*N, 0.05), NaN entries being omitted from the sum.
980 *
981 * @return one message per failing chain, empty when none fails
982 */
983template <class T>
985 const Matrix<double>& QN) {
986 std::vector<std::string> out;
987 if (QN.empty() || !sn.regions.empty()) return out;
988 for (std::size_t i = 0; i < sn.nodes.size(); ++i)
989 if (sn.nodes[i].nodetype == qn::NodeType::Fork) return out;
990 const std::vector<double> njobs = sn.njobs();
991 for (std::size_t nc = 0; nc < sn.nchains && nc < sn.chains.size(); ++nc) {
992 double njobs_chain = 0.0;
993 std::vector<std::size_t> inchain;
994 for (std::size_t r = 0; r < sn.chains[nc].size() && r < njobs.size(); ++r)
995 if (sn.chains[nc][r]) {
996 inchain.push_back(r);
997 njobs_chain += njobs[r];
998 }
999 if (!(std::isfinite(njobs_chain) && njobs_chain > 0.0)) continue;
1000 double qtot = 0.0;
1001 for (std::size_t i = 0; i < QN.rows(); ++i)
1002 for (std::size_t k = 0; k < inchain.size(); ++k)
1003 if (inchain[k] < QN.cols() && !std::isnan(QN(i, inchain[k]))) qtot += QN(i, inchain[k]);
1004 if (std::fabs(qtot - njobs_chain) > std::max(0.05 * njobs_chain, 0.05)) {
1005 char buf[512];
1006 std::snprintf(buf, sizeof(buf),
1007 "LDES returned %.3f jobs for closed chain %zu, whose population is %g: the engine "
1008 "dropped blocked jobs at a finite-capacity station, so these averages describe a "
1009 "different model. Use SolverCTMC/SolverSSA for capped closed networks, or rebuild "
1010 "common/ldes.jar.",
1011 qtot, nc + 1, njobs_chain);
1012 out.push_back(buf);
1013 }
1014 }
1015 return out;
1016}
1017
1018template <class T>
1020 const std::vector<std::string>& extra_flags =
1021 std::vector<std::string>()) {
1023 LdesResult r = solver_ldes_text(io::network_json_envelope(sn).dump(), o, extra_flags);
1024 // Steady state only, as MATLAB's parseSteady runs it; SILENT mutes it as line_warning does.
1025 if (!o.has_timespan && !r.timed_out &&
1027 const std::vector<std::string> w = ldes_closed_population_warnings(sn, r.QN);
1028 for (std::size_t k = 0; k < w.size(); ++k)
1029 std::fprintf(stderr, "[SolverLDES] Warning: %s\n", w[k].c_str());
1030 }
1031 return r;
1032}
1033
1034} // namespace ldes
1035} // namespace line
1036
1037#endif // LINE_SOLVERS_WRAPPERS_LDES_SOLVER_LDES_H
ExternalError(const std::string &what)
Definition error.h:126
InputError(const std::string &what)
Definition error.h:105
std::size_t size() const
Definition matrix.h:91
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:112
A network plus its refreshed NetworkStruct.
static VerboseLevel get_verbose()
The session verbosity.
static void step(const char *fmt,...)
Write one progress line.
std::string file(const std::string &name) const
A file inside it.
Definition tempdir.h:127
The exception types the port throws.
Fork-join section metrics: FJQLen, FJRespT and FJTput of every Fork/Join pair.
Minimal HTTP/1.1 client, enough to talk to a line-*-rest service.
The option and result records of SolverLDES, the discrete-event simulator.
Where the LDES engine is, and whether this machine can run it.
Running progress log of a LINE solver run (the "solver console").
Dense matrix and non-owning view.
Response post_json(const std::string &url, const std::string &json, int timeoutMillis)
POST a JSON document.
Definition http.h:360
void require_finite_pas_buffers(const qn::NetworkStruct< T > &sn)
Refuse an OI/PAS station with no finite buffer before it reaches LDES.
detail::json network_json_envelope(const qn::NetworkStruct< T > &sn)
The complete model.json envelope: {format, version, model}.
solvers::FjMetrics< double > ldes_fj_metrics(const qn::NetworkStruct< T > &sn, const LdesResult &r)
ldesParseForkJoin: the engine's "forkJoin" rows mapped onto the Fork/Join pairs of sn by (fork name,...
double ldes_prob_from_histogram(const LdesResult &r, std::size_t nclasses, const std::vector< LdesStateQuery > &query)
Residence-time probability of an aggregate joint state, from a parsed result.
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.
double ldes_prob_sys_aggr(const std::string &doc, const LdesOptions &o, const Matrix< double > &target)
Port of getProbSysAggr: the joint probability of a whole aggregate state.
LdesResult solver_ldes_file(const std::string &path, const LdesOptions &o, const std::vector< std::string > &extra_flags=std::vector< std::string >())
The same, reading the document from a file.
const std::string & ldes_engine_dir()
The directory holding the engine, or empty when there is none.
Definition ldes_probe.h:247
std::vector< std::string > ldes_flags(const LdesOptions &o, const std::vector< std::string > &extra)
The engine flags of one run, after solve <model> -o <result>.
Matrix< double > ldes_cdf_respt(const LdesResult &r, std::size_t station, std::size_t job_class)
Port of getCdfRespT: the EMPIRICAL response time CDF of one (station, class) pair,...
LdesResult parse_ldes_result(const detail::Json &d)
Parses one ldes-result document.
double ldes_prob_aggr(const std::string &doc, const LdesOptions &o, std::size_t station, const std::vector< double > &counts, std::size_t nclasses)
Port of getProbAggr: the marginal probability of a per-class job count at one station.
detail::Json ldes_solve_rest(const std::string &base_url, const std::string &doc, const std::vector< std::string > &flags, double timeout)
Solves through an LDES REST server and returns its result document.
LdesResult solver_ldes(const qn::NetworkStruct< T > &sn, const LdesOptions &o, const std::vector< std::string > &extra_flags=std::vector< std::string >())
std::vector< LdesRunner > ldes_runners(const std::string &doc, const std::vector< std::string > &flags)
The runners to try, in order (see the file header for why there are two and when the order flips).
std::vector< std::string > ldes_closed_population_warnings(const qn::NetworkStruct< T > &sn, const Matrix< double > &QN)
The same, for a model built through the C++ API.
bool in(const std::string &s, std::initializer_list< const char * > set)
const std::string * get(const Element *el, const std::string &key)
el.get(key), with Python's None as a null pointer.
Definition srvn_writer.h:62
std::vector< std::pair< std::size_t, std::size_t > > fj_sorted_pairs(const S &sn)
The Fork/Join pairs of a struct, sortrows([forks joins]) over find(sn.fj): ascending in fork node,...
Definition fj_metrics.h:62
FjMetrics< T > fj_metrics_zero(const S &sn, std::size_t nclasses)
An empty-but-shaped result: every pair of sn, all three matrices zero.
Definition fj_metrics.h:73
ProcResult capture(const std::vector< std::string > &argv, int timeoutSeconds, bool mergeStderr=false)
Runs a command, capturing stdout and discarding stderr.
Definition subprocess.h:82
std::string trim(const std::string &s)
Trims ASCII whitespace from both ends, as Java's String.trim() does.
Definition subprocess.h:181
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
qn::NetworkStruct -> model.json, the inverse of network_reader.h.
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
An HTTP response, with the body already de-chunked.
Definition http.h:60
std::string body
Response body, decoded.
Definition http.h:62
int status
HTTP status code.
Definition http.h:61
Per-cache hit/miss/latency, as the cacheMetrics block carries them.
Matrix< double > listCost
(1 x nlists) mean storage cost held per list
Matrix< double > latency
(1 x nclasses) expected retrieval latency
Matrix< double > hitList
(nclasses x nlists) hit probability by list
Matrix< double > itemProb
(nitems x nlists+1) item position law
Matrix< double > delayed
(1 x nclasses) delayed-hit probability
Matrix< double > miss
(1 x nclasses) miss probability
Matrix< double > hit
(1 x nclasses) hit probability
The knobs of one LDES run.
double warmupfrac
–warmupfrac, only for tranfilter=fixed
int ciminbatch
–ciminbatch
double spectral_low_freq_frac
–spectrallowfreqfrac
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).
double obmoverlap
–obmoverlap; 0 reduces OBM to plain batch means
std::vector< std::vector< std::size_t > > busy_period_subnets
–busyperiod-subnet, zero-based station indexes, one flag per set.
long seed
–seed; -1 requests a random stream
bool slotted
–slotted, run on the slot lattice
int mserbatch
–mserbatch, MSER batch size
std::string cimethod
–cimethod: obm | bm | spectral | none
double slot_length
–slotlength
Matrix< double > init_jobs
–initjobs, the JOB-LEVEL initial state, one row per job: [station, class, remaining,...
int tranobs
–tranobs, equally spaced transient observation points over [t0,t1]; fixes the time grid only.
int cnvgchk
–cnvgchk, events between checks; 0 = samples/50
int replications
–replications; 0 = not given (one path)
bool track_jobs
–trackjobs: fill finalJobs and jobDepartures; implied by init_jobs.
int ciminobs
–ciminobs, below which no CI is reported
std::size_t events
0 = not given; overrides samples when set
int cnvgbatch
–cnvgbatch, batches before the first check
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
int busy_period_orders
–busyperiod: highest busy period order measured, 0 disabling the measurement.
One measured busy period target (–busyperiod): a station, a station-class pair, or a declared station...
std::vector< std::size_t > stations
station indexes, zero-based
One ldes-result document, parsed.
std::vector< std::string > fj_forks
Fork and Join node NAMES of each pair, ordered by fork node then join node; empty without a pair.
Matrix< double > FJTput
getAvgFJ, FJQLen = FJTput * FJRespT
std::vector< std::vector< std::vector< double > > > respTimeSamples
[station][class] -> the per-job response times the engine recorded.
Matrix< double > FJQLen
Matrix< double > FJRespT
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::string > class_names
std::string engine
"native" or "jar": which runner produced these numbers.
std::vector< std::string > fj_joins
bool timed_out
True when the HARD subprocess bound fired, not the cooperative one.
Matrix< double > histogram_time
Matrix< double > QN
One runner: the argv prefix that runs the engine, and the name of its image.
std::vector< std::string > argv
up to and including "solve"
std::string engine
"native" or "jar"
One constraint of a joint-state query: a station and the per-class job counts it is required to hold.
std::vector< double > counts
self.result.FJ: one row per Fork/Join pair, one column per class.
Definition fj_metrics.h:43
std::vector< std::size_t > forks
1-based fork node of each pair
Definition fj_metrics.h:44
Matrix< T > Tput
FJTput, P x R.
Definition fj_metrics.h:48
Matrix< T > QLen
FJQLen, P x R.
Definition fj_metrics.h:46
std::size_t npairs() const
Definition fj_metrics.h:53
std::vector< std::size_t > joins
1-based join node of each pair
Definition fj_metrics.h:45
Matrix< T > RespT
FJRespT, P x R.
Definition fj_metrics.h:47
Outcome of a captured command.
Definition subprocess.h:42
int exitCode
Exit status, or -1 when the command could not run.
Definition subprocess.h:43
bool timedOut
True when the deadline expired and the child was killed.
Definition subprocess.h:45
std::string out
Everything the command wrote to stdout.
Definition subprocess.h:44
Running an external command and capturing its output, with a deadline.
A scratch directory for the subprocess wrappers, the port's lineTempName.