LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fj_mmt.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_MVA_FJ_MMT_H
6#define LINE_SOLVERS_MVA_FJ_MMT_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The fork-join transform SolverMVA applies before solving a layer that
12 * contains a Fork.
13 *
14 * Port of matlab/src/io/@@ModelAdapter/mmt.m. The transform turns a layer with a
15 * fork into a plain queueing network with no fork at all, in three moves:
16 *
17 * 1. THE FORK BECOMES A ROUTER. Its outgoing routing is divided by its
18 * fan-out, so a circulating job takes ONE branch chosen at random rather
19 * than all of them, and the node stops being a Fork -- which also stops
20 * the fork corrections in `chain_visits` from firing, since the reference
21 * transform leaves no Fork in the model for them to key on.
22 * 2. THE JOIN BECOMES A DELAY whose per-class service time is the
23 * synchronisation delay `E[X_(k) over branches] * fanOut - mean(branch)`,
24 * re-set on every pass of the fixed point from the branch response times
25 * the previous pass measured. `k` is the branch count on a standard join,
26 * which makes X_(k) the maximum, and the declared quorum on a PARTIAL one;
27 * the floor at zero the quorum needs is in `fj_driver.h`.
28 * 3. THE BRANCHES THE JOB DID NOT TAKE ARE CARRIED BY AUXILIARY OPEN CLASSES.
29 * A Source and a Sink are added, and one auxiliary open class is minted
30 * per class of every chain that reaches the fork. Auxiliary tokens arrive
31 * at rate `(fanout - 1) * forkLambda`, enter at the fork, take one branch
32 * each by the same split, and leave at the Sink after the Join. With the
33 * circulating class contributing 1/fanout of a visit to each branch and
34 * the auxiliary stream the remaining (fanout-1)/fanout, every branch
35 * station sees the load of one branch traversal per fork event, which is
36 * what the fork actually generates.
37 *
38 * Moves 1 and 2 alone are exact only when every station of the layer is an
39 * infinite server, because auxiliary tokens then add no waiting. Move 3 is what
40 * makes the transform correct at a station that can QUEUE, and it is also what
41 * makes the layer MIXED open-closed -- so solver_amvald and solver_mva both
42 * have to accept open chains before any of this can be solved.
43 *
44 * WHAT IS DELIBERATELY REPRODUCED RATHER THAN IMPROVED:
45 *
46 * - An auxiliary class is minted for EVERY class of the forked chain, not
47 * only for the classes that reach the fork. The ones that do not get a
48 * disabled arrival and an inert Source -> Sink route; they exist so that
49 * the class indexing of the auxiliary block mirrors the original block
50 * one-for-one, which is what the merge-back keys on.
51 * - The auxiliary routing is CONFINED to the fork-join scope by a class-aware
52 * BFS from the fork that stops at the join. Without it the copied
53 * return-path cycles form recurrent components disconnected from the
54 * Source, which capture the whole stationary mass of the auxiliary chain
55 * and destroy the visit ratios (on lqn_bpmn the reference class switch is
56 * amplified to 27 instead of 1, saturating the layer beyond solvability).
57 *
58 * SEVERAL FORKS, AND NESTED ONES (2026-07-30). Every Fork of the layer gets its
59 * own record: its own join, its own per-class fan-out, and its own block of
60 * auxiliary classes, so `fjforkmap[s]` is needed alongside `fjclassmap[s]` to say
61 * WHICH fork an auxiliary class stands in for. Two properties then have to be
62 * derived rather than assumed, and `fj_sort_forks` derives them (the port of
63 * `ModelAdapter.sortForks`):
64 *
65 * outer_forks(f, r) fork f is the OUTERMOST fork on class r's path, i.e. no
66 * other fork encloses it. Only an outer fork writes a
67 * synchronisation delay onto the ORIGINAL class r: an inner
68 * fork's delay is charged while the outer fork's branch is
69 * being walked, by `fj_find_paths` itself, and writing it
70 * again on the original class would count it twice.
71 * parent_forks(f) the fork whose node visits measure how often f fires. For
72 * an outer fork that is f; for a nested one it is the
73 * enclosing fork, because a nested fork fires once per
74 * traversal of the enclosing branch, not once per class
75 * completion.
76 *
77 * A fork with NO join is admitted (the reference allows it): there is no
78 * synchronisation point, so no delay is computed and `forkLambda` is driven from
79 * the fork's own throughput instead of a join's.
80 */
81
82#include <algorithm>
83#include <cmath>
84#include <limits>
85#include <map>
86#include <string>
87#include <utility>
88#include <vector>
89
92#include "line/util/error.h"
93
94namespace line {
95namespace mva {
96
97using lang::Distrib;
98using lang::GlobalConstants;
100using lang::NodeType;
102
103/**
104 * The transformed layer and the bookkeeping the fixed point needs to drive it
105 * and to merge its results back.
106 *
107 * `fjclassmap[s]` is the ORIGINAL class an auxiliary class s stands for and 0
108 * when s is not auxiliary; `fjforkmap[s]` is the index into `forks` of the fork
109 * it stands in for; `fanout[s]` is that fork's fan-out for that original class,
110 * which is what scales the auxiliary arrival rate.
111 */
112template <class T>
113struct FjMmt {
114 /** One record per Fork of the base layer, ascending in node index. */
115 struct ForkRec {
116 std::size_t node = 0; ///< 1-based node index, shared with the base layer
117 std::size_t joinNode = 0; ///< 0 when the fork has no join
118 std::size_t joinStation = 0; ///< 0 when the fork has no join
119 /**
120 * `sn.nodeparam{f}.fanOut`, which MATLAB sets to the fork's tasksPerLink
121 * and NOT to its number of output links. A fork built from an LQN
122 * POST_AND emits one task per link, so this is 1 and the synchronisation
123 * delay is `E[max] - mean`. It is kept as a named quantity because the
124 * reference multiplies E[max] by it.
125 */
126 double fanOut = 1.0;
127 /** (nclasses+1) the fork's number of output links for each class. */
128 std::vector<std::size_t> origfanout;
129 /**
130 * (nclasses+1) `outer_forks(f, r)`: this fork is the outermost one on
131 * class r's path. See the header. Only then does the synchronisation
132 * delay reach the original class, and only then is one computed at all.
133 */
134 std::vector<bool> outer;
135 /**
136 * `parent_forks(f)` as an index into `forks`: the fork whose node visits
137 * measure how often this one fires. Equal to this fork's own index when
138 * it is not nested.
139 */
140 std::size_t parent = 0;
141 };
142
143 qn::NetworkStruct<T> V; ///< the transformed ("nonfj") layer
144 std::vector<ForkRec> forks;
145 std::size_t sourceStation = 0;
146 std::size_t sourceNode = 0;
147 std::size_t sinkNode = 0;
148 std::size_t norig = 0; ///< class count of the base layer
149 std::vector<std::size_t> fjclassmap; ///< (nclasses+1) auxiliary -> original, 0 if not auxiliary
150 std::vector<std::size_t> fjforkmap; ///< (nclasses+1) auxiliary -> index into `forks`
151 std::vector<double> fanout; ///< (nclasses+1)
152 std::vector<bool> auxdisabled; ///< (nclasses+1)
153 std::vector<std::size_t> auxclasses; ///< auxiliary class indices, ascending
154
155 /**
156 * Every station that was a Join and is now a zero-service Delay, whether or
157 * not a fork claims it. The passes that must hold Immediate at a join, and
158 * the merge-back that must keep a join's original-class throughput, iterate
159 * this rather than the fork records.
160 */
161 std::vector<std::size_t> joinStations;
162
163 /**
164 * The transform is Heidelberger-Trivedi (`options.config.fork_join='ht'`)
165 * rather than MMT. The two share this record because the fixed point that
166 * drives them is one function in the reference too (`fjFixedPoint.m`); what
167 * differs is the transform (fj_ht.h against fj_mmt.h), the per-pass update
168 * -- H-T has no auxiliary open stream whose arrival rate to re-set -- and
169 * the synchronisation delays and merge-back the driver applies afterwards.
170 */
172 /**
173 * H-T only: the join node -> the auxiliary Delay NODE that carries the time
174 * the ORIGINAL class spends outside the fork-join span. `ht.m` adds one such
175 * delay per Join, routes the Join into it and it back to the fork, so that an
176 * auxiliary token cycles over its own branch alone.
177 */
178 std::map<std::size_t, std::size_t> auxDelayNode;
179 /** H-T only: the same delay as a STATION index. */
180 std::map<std::size_t, std::size_t> auxDelayStation;
181 /** H-T only: the 1-based branch a given auxiliary class walks; 0 elsewhere. */
182 std::vector<std::size_t> auxbranch;
183
184 bool active() const { return !auxclasses.empty(); }
185
186 bool is_join_station(std::size_t st) const {
187 return std::find(joinStations.begin(), joinStations.end(), st) != joinStations.end();
188 }
189};
190
191/**
192 * The instant the join fires: E[X_(k)] of independent exponentials with the
193 * given means, by inclusion-exclusion. This is the `d0` of the reference,
194 * generalised from its maximum to the k-th order statistic so that a quorum
195 * join is charged the completion it actually waits for. `k = means.size()` is
196 * the ordinary AND-join and reproduces the reference term for term.
197 *
198 * A zero-length branch no longer degenerates the whole expression to zero, as
199 * this port used to have it: it completes instantly, so it neither delays the
200 * join nor suppresses the others, which is what the reference computes through
201 * its 1/Inf terms.
202 */
203template <class T>
204T fj_expected_ordstat(const std::vector<T>& means, std::size_t k) {
205 return fj::fj_ordstat_exp(means, k);
206}
207
208/** The AND-join case, i.e. E[max]. */
209template <class T>
210T fj_expected_max(const std::vector<T>& means) {
211 if (means.empty()) return num_traits<T>::from_int(0);
212 return fj_expected_ordstat(means, means.size());
213}
214
215/**
216 * The number of siblings the Join node fires on, out of `nbranches` forked.
217 * Port of matlab/src/api/fj/sn_join_quorum.m; a standard join, an absent
218 * declaration, a non-positive quorum and a quorum that is not smaller than the
219 * sibling count all return `nbranches`, the ordinary AND-join.
220 *
221 * NOTE the index space: `joindecl` is keyed by the BASE node index, and this
222 * port's `JoinDecl` carries ONE quorum per join rather than one per class, so a
223 * multiclass model with a different quorum per class cannot be expressed here
224 * (the JSON interchange has the same limitation, see network_writer.h).
225 */
226template <class T>
227std::size_t fj_join_quorum(const qn::NetworkStruct<T>& sn, std::size_t joinNode,
228 std::size_t nbranches) {
229 if (joinNode == 0) return nbranches;
230 typename std::map<std::size_t, typename qn::NetworkStruct<T>::JoinDecl>::const_iterator it =
231 sn.joindecl.find(joinNode);
232 if (it == sn.joindecl.end()) return nbranches;
233 if (it->second.strategy == lang::JoinStrategy::STD) return nbranches;
234 const double q = it->second.quorum;
235 if (q > 0.0) {
236 const std::size_t k = static_cast<std::size_t>(q + 0.5);
237 if (k > 0 && k < nbranches) return k;
238 }
239 return nbranches;
240}
241
242/**
243 * `Exp.fitMean(m)`, including its clamp.
244 *
245 * The reference builds every synchronisation delay through this factory, and
246 * the clamp is load-bearing at both ends: a zero delay would otherwise be an
247 * exponential of infinite rate, and it comes out as GlobalConstants.Immediate
248 * instead, which is exactly the rate the join carried before the fork loop
249 * touched it.
250 */
251template <class T>
253 const T zero = num_traits<T>::from_int(0);
256 T rate = hi;
257 if (mean > zero) rate = T(num_traits<T>::from_int(1) / mean);
258 if (rate < lo) rate = lo;
259 if (rate > hi) rate = hi;
260 return Distrib<T>::exp_rate(rate);
261}
262
263namespace detail {
264
265/**
266 * The class-switch-aware node routing r -> s over the arc (i, j).
267 *
268 * WHY NOT `get_route`. `link()` SYNTHESIZES a ClassSwitch node for every
269 * class-switching arc, and the synthesized node's `P` block is class-PRESERVING:
270 * the switch itself lives in `csmatrix`, exactly as `refresh_routing` assembles
271 * `Peff` (`rtnodes(r, (j-1)K+s) = Pcs(r,s) * P(s,s,i,j)`). A walk over `P` alone
272 * therefore stops dead at the first switch -- which is what made the fork
273 * reachability BFS below report `reach[fork][r] = false` on every fj_cs_*
274 * model, disabling the auxiliary stream and halving the branch load.
275 *
276 * `route_eff` is not usable here: the transform mutates `P` (the fan-out split,
277 * the auxiliary blocks) and `Peff` is only re-derived at the end, so mid-transform
278 * it still describes the untransformed layer. This reproduces the one rule that
279 * matters on the CURRENT `P`. The reference walks `sn.rtnodes`, which is the same
280 * product.
281 */
282template <class T>
283T fj_route_cs(const qn::NetworkStruct<T>& V, std::size_t r, std::size_t s, std::size_t i,
284 std::size_t j) {
285 const T zero = num_traits<T>::from_int(0);
286 const typename std::map<std::size_t, Matrix<T>>::const_iterator it = V.csmatrix.find(i);
287 if (it == V.csmatrix.end()) return V.get_route(r, s, i, j);
288 const Matrix<T>& C = it->second;
289 if (r > C.rows() || s > C.cols()) return zero;
290 if (!(C(r - 1, s - 1) > zero)) return zero;
291 return T(C(r - 1, s - 1) * V.get_route(s, s, i, j));
292}
293
294/** Zero the whole outgoing row of node `nd` in the (r,s) routing block. */
295template <class T>
296void fj_clear_row(qn::NetworkStruct<T>& V, std::size_t r, std::size_t s, std::size_t nd) {
297 auto it = V.P.find(std::make_pair(r, s));
298 if (it == V.P.end() || it->second.rows() < nd) return;
299 const T zero = num_traits<T>::from_int(0);
300 for (std::size_t j = 0; j < it->second.cols(); ++j) it->second(nd - 1, j) = zero;
301}
302
303/**
304 * Port of the local `nestedForks` of `ModelAdapter.sortForks`: walk the class-r
305 * routing of the transformed layer from `startNode` to `endNode`, clearing the
306 * flag of every Fork node met strictly along the way.
307 *
308 * `flags[nd]` enters true for a Fork and leaves false when this walk PASSED
309 * THROUGH it, which is what makes it an inner fork of the (startNode, endNode)
310 * span. The reference intersects the result of each branch (`forks & nested`), so
311 * a fork counts as inner as soon as ONE path reaches it.
312 *
313 * The `seen` set is this port's, not the reference's: `nestedForks` recurses on a
314 * routing matrix that may hold a cycle (a call whose mean exceeds one leaves a
315 * self-loop) and would not terminate on it, where MATLAB's recursion limit turns
316 * that into an error. Marking (node) visited per walk is sound here because the
317 * result is a pure reachability question -- revisiting a node cannot clear a flag
318 * that the first visit left set.
319 */
320template <class T>
321void fj_nested_forks(const qn::NetworkStruct<T>& V, const std::vector<bool>& isFork,
322 std::size_t startNode, std::size_t endNode, std::size_t cls,
323 std::vector<bool>& flags, std::vector<bool>& seen) {
324 if (startNode == endNode) return;
325 if (seen[startNode - 1]) return;
326 seen[startNode - 1] = true;
327 const T zero = num_traits<T>::from_int(0);
328 for (std::size_t j = 1; j <= V.nodes.size(); ++j) {
329 if (!(V.get_route(cls, cls, startNode, j) > zero)) continue;
330 // `isFork` and NOT `V.nodes[j].nodetype`: the reference asks the BASE
331 // struct for the node type while walking the TRANSFORMED routing, and by
332 // this point every Fork of the transformed struct is a Router, so a type
333 // test here would never fire and no fork would ever be found nested.
334 if (isFork[j - 1]) flags[j - 1] = false;
335 fj_nested_forks(V, isFork, j, endNode, cls, flags, seen);
336 }
337}
338
339} // namespace detail
340
341/**
342 * Port of `ModelAdapter.sortForks`: fill in `outer` and `parent` on every fork
343 * record.
344 *
345 * Runs on the TRANSFORMED layer, as the reference does
346 * (`nonfjmodel.getLinkedRoutingMatrix{r,r}`), and on the ORIGINAL class r, whose
347 * routing support the transform leaves alone -- the fork is a Router by now, but
348 * with the same out-edges.
349 *
350 * A fork is outer on class r until some other fork's span is found to pass
351 * through it. The parent of every fork found inside f's span becomes f's OWN
352 * parent, not f, so a three-deep nest collapses onto the outermost fork exactly
353 * as the reference's single assignment does. The reference also writes `parents`
354 * at non-fork nodes, where it is never read; this port keeps it per fork record.
355 */
356template <class T>
358 const qn::NetworkStruct<T>& V = tr.V;
359 const std::size_t nf = tr.forks.size();
360 std::vector<bool> isFork(V.nodes.size(), false);
361 for (std::size_t a = 0; a < nf; ++a) {
362 tr.forks[a].outer.assign(tr.norig + 1, true);
363 tr.forks[a].parent = a;
364 isFork[tr.forks[a].node - 1] = true;
365 }
366 for (std::size_t a = 0; a < nf; ++a) {
367 typename FjMmt<T>::ForkRec& F = tr.forks[a];
368 if (F.joinNode == 0) continue;
369 // The reference walks once per AUXILIARY class of this fork, which
370 // includes the classes whose fan-out is zero (they carry a disabled
371 // arrival but still have a span).
372 for (std::size_t s : tr.auxclasses) {
373 if (tr.fjforkmap[s] != a) continue;
374 const std::size_t r = tr.fjclassmap[s];
375 std::vector<bool> flags(V.nodes.size(), false);
376 for (std::size_t b = 0; b < nf; ++b) flags[tr.forks[b].node - 1] = true;
377 std::vector<bool> seen(V.nodes.size(), false);
378 detail::fj_nested_forks(V, isFork, F.node, F.joinNode, r, flags, seen);
379 for (std::size_t b = 0; b < nf; ++b) {
380 if (flags[tr.forks[b].node - 1]) continue;
381 tr.forks[b].outer[r] = false;
382 tr.forks[b].parent = F.parent;
383 }
384 }
385 }
386}
387
388/**
389 * Widen every explicit ClassSwitch matrix to the auxiliary-expanded class set.
390 *
391 * AN EXPLICIT ClassSwitch MATRIX IS (nclasses x nclasses), and a fork-join
392 * transform has just widened nclasses. `refresh_routing` checks that width and
393 * refuses the model outright when it no longer holds, which is what killed
394 * SolverMVA on every fork-join model carrying a switch -- and since `link()` now
395 * SYNTHESIZES a ClassSwitch node for any class-switching arc, that is every
396 * fj_cs_* example ("the class-switch matrix of node 'CS_Fork1_to_Queue1' is not
397 * (nclasses x nclasses)"). `fj_tag` and `tag_chain` already widen it at their
398 * own augmentation points.
399 *
400 * An auxiliary class stands for the original it was split from, so it must
401 * switch the way that original does, among auxiliaries: C(a,b) = old(x,y) where
402 * a,b are the auxiliaries of x,y. Any auxiliary block the map does not pair
403 * keeps the identity, which is the only admissible row for a class that never
404 * switches.
405 */
406template <class T>
408 const T zero = num_traits<T>::from_int(0);
409 const T one = num_traits<T>::from_int(1);
411 const std::size_t Kaug = V.classes.size();
412 for (typename std::map<std::size_t, Matrix<T>>::iterator it = V.csmatrix.begin();
413 it != V.csmatrix.end(); ++it) {
414 if (it->second.rows() == Kaug && it->second.cols() == Kaug) continue;
415 const Matrix<T> old = it->second;
416 Matrix<T> C(Kaug, Kaug, zero);
417 for (std::size_t x = 0; x < old.rows() && x < Kaug; ++x)
418 for (std::size_t y = 0; y < old.cols() && y < Kaug; ++y) C(x, y) = old(x, y);
419 for (std::size_t a = old.rows(); a < Kaug; ++a) C(a, a) = one;
420 for (std::size_t ai = 0; ai < tr.auxclasses.size(); ++ai)
421 for (std::size_t bi = 0; bi < tr.auxclasses.size(); ++bi) {
422 const std::size_t a = tr.auxclasses[ai], b = tr.auxclasses[bi];
423 if (a > Kaug || b > Kaug || a >= tr.fjclassmap.size() || b >= tr.fjclassmap.size())
424 continue;
425 const std::size_t x = tr.fjclassmap[a], y = tr.fjclassmap[b];
426 if (x == 0 || y == 0 || x > old.rows() || y > old.cols()) continue;
427 if (a == b || num_traits<T>::to_double(old(x - 1, y - 1)) != 0.0)
428 C(a - 1, b - 1) = old(x - 1, y - 1);
429 }
430 it->second = C;
431 }
432}
433
434/**
435 * Build the transformed layer. Returns an inactive FjMmt when the layer has no
436 * fork, in which case the caller solves the layer directly.
437 *
438 * The auxiliary arrivals are left at GlobalConstants.FineTol, which is what a
439 * cold `ModelAdapter.mmt` call sets and what `refreshServicesFromBase` restores
440 * at the start of every outer iteration; the fixed point overwrites them with
441 * `(fanout - 1) * forkLambda` from its second pass onwards.
442 */
443template <class T>
445 const T zero = num_traits<T>::from_int(0);
446 const T one = num_traits<T>::from_int(1);
447 FjMmt<T> tr;
448 tr.norig = L.nclasses;
449
450 std::vector<std::size_t> forkNodes;
451 for (std::size_t i = 0; i < L.nodes.size(); ++i)
452 if (L.nodes[i].nodetype == NodeType::Fork) forkNodes.push_back(i + 1);
453 if (forkNodes.empty()) return tr;
454
455 // ---- 1. one record per fork, each with its own join and fan-out --------
456 for (std::size_t node : forkNodes) {
457 typename FjMmt<T>::ForkRec F;
458 F.node = node;
459 // tasksPerLink (== sn.nodeparam{f}.fanOut) scales the auxiliary arrival
460 // rate and the sync delay; without this it stays 1 and any fork emitting
461 // more than one task per link is under-loaded (halved throughput at
462 // tasksPerLink=2).
463 F.fanOut = L.nodes[node - 1].tasks_per_link;
464 for (const std::pair<std::size_t, std::size_t>& p : L.fj) {
465 if (p.first != node) continue;
466 if (F.joinNode != 0)
467 throw UnsupportedError("fj_mmt: layer '" + L.name + "' pairs fork node " +
468 std::to_string(node) +
469 " with more than one join station; the reference supports "
470 "one join per fork");
471 F.joinNode = p.second;
472 }
473 // A fork with NO join is admitted, as in the reference: there is simply
474 // no synchronisation point, so no delay is charged and forkLambda is
475 // driven from the fork's own throughput instead of a join's.
476 // origfanout rationale: see _kb/06-solver-catalog.md (cpp port notes: fj_mmt.h)
477 F.origfanout.assign(L.nclasses + 1, 0);
478 for (std::size_t r = 1; r <= L.nclasses; ++r) {
479 std::vector<bool> dest(L.nodes.size(), false);
480 for (std::size_t s = 1; s <= L.nclasses; ++s)
481 for (std::size_t j = 1; j <= L.nodes.size(); ++j)
482 if (L.get_route(r, s, node, j) > zero) dest[j - 1] = true;
483 for (bool b : dest)
484 if (b) ++F.origfanout[r];
485 }
486 tr.forks.push_back(F);
487 }
488
489 tr.V = L;
491
492 // Every fork's out-edges are divided by ITS OWN fan-out before any auxiliary
493 // class is copied, so a second fork's split is already in place when the
494 // first fork's auxiliary block copies the routing. Forks are distinct nodes,
495 // so the rows they rewrite are disjoint and the order does not matter.
496 for (const typename FjMmt<T>::ForkRec& F : tr.forks) {
497 for (std::size_t r = 1; r <= L.nclasses; ++r) {
498 if (F.origfanout[r] == 0) continue;
499 const T f = num_traits<T>::from_int(static_cast<long>(F.origfanout[r]));
500 for (std::size_t s = 1; s <= L.nclasses; ++s)
501 for (std::size_t j = 1; j <= L.nodes.size(); ++j) {
502 const T p = V.get_route(r, s, F.node, j);
503 if (p > zero) V.set_route(r, s, F.node, j, T(p / f));
504 }
505 }
506 V.nodes[F.node - 1].nodetype = NodeType::Router;
507 }
508 V.fj.clear();
509
510 // ---- 2. EVERY Join becomes a zero-service Delay ------------------------
511 // The reference converts every Join node of the model, not only the ones
512 // paired with a fork, so an unpaired join does not survive as a Join that
513 // the inner solver would then reject.
514 for (std::size_t j = 1; j <= L.nodes.size(); ++j) {
515 if (L.nodes[j - 1].nodetype != NodeType::Join) continue;
516 const std::size_t st = V.nodes[j - 1].station;
517 if (st == 0)
518 throw InputError("fj_mmt: the join node '" + V.nodes[j - 1].name + "' of layer '" +
519 L.name + "' is not a station");
520 V.stations[st - 1].nodetype = NodeType::Delay;
521 V.stations[st - 1].sched = SchedStrategy::INF;
522 V.stations[st - 1].nservers = std::numeric_limits<double>::infinity();
523 V.nodes[j - 1].nodetype = NodeType::Delay;
524 for (std::size_t k = 1; k <= V.classes.size(); ++k)
526 tr.joinStations.push_back(st);
527 }
528 for (typename FjMmt<T>::ForkRec& F : tr.forks)
529 if (F.joinNode != 0) F.joinStation = V.nodes[F.joinNode - 1].station;
530
531 // ---- 3. Source and Sink ----------------------------------------------
532 // THE MODEL'S OWN SOURCE IS REUSED WHEN IT HAS ONE. `mmt.m` adds a pair only
533 // in the closed case (`if nonfjmodel.hasOpenClasses, source = getSource ...
534 // else Source(...) / Sink(...) end`). Adding a SECOND Source to an already
535 // open layer left the original one with no `sourceIdx`, so the base chain
536 // lost its arrivals altogether: on fj_cs_multi_visits every original-class
537 // throughput came back 0 while the auxiliary chain carried the whole flow.
538 std::size_t existing_src = 0, existing_sink = 0;
539 for (std::size_t i = 1; i <= V.stations.size(); ++i)
540 if (V.stations[i - 1].nodetype == NodeType::Source) existing_src = i;
541 for (std::size_t j = 1; j <= V.nodes.size(); ++j)
542 if (V.nodes[j - 1].nodetype == NodeType::Sink) existing_sink = j;
543 if (existing_src != 0 && existing_sink != 0) {
544 tr.sourceStation = existing_src;
545 tr.sourceNode = V.station_to_node[existing_src - 1];
546 tr.sinkNode = existing_sink;
547 } else {
548 qn::Station<T> src;
549 src.name = "Source";
550 src.nodetype = NodeType::Source;
551 src.sched = SchedStrategy::EXT;
552 src.nservers = 1.0;
553 tr.sourceStation = V.add_station(src);
554 tr.sourceNode = V.station_to_node[tr.sourceStation - 1];
555 tr.sinkNode = V.add_node("Sink", NodeType::Sink, false);
556 }
557 V.sourceIdx = tr.sourceStation;
558 V.sinkNode = tr.sinkNode;
559
560 // ---- 4. one auxiliary open class per class of every forked chain, PER
561 // FORK. `fjforkmap` is what tells the two blocks apart.
562 const std::size_t nnodes = V.nodes.size();
563 for (std::size_t fa = 0; fa < tr.forks.size(); ++fa) {
564 const typename FjMmt<T>::ForkRec& F = tr.forks[fa];
565 std::vector<bool> forked(L.nclasses + 1, false);
566 for (std::size_t c = 0; c < L.nchains; ++c)
567 for (std::size_t r = 1; r <= L.nclasses; ++r)
568 if (L.nodevisits[c](F.node - 1, r - 1) > zero) forked[r] = true;
569
570 for (std::size_t c = 0; c < L.nchains; ++c) {
571 bool any = false;
572 for (std::size_t r : L.inchain[c])
573 if (forked[r]) any = true;
574 if (!any) continue;
575 const std::vector<std::size_t>& ic = L.inchain[c];
576
577 // reachability BFS rationale: see _kb/06-solver-catalog.md (cpp port notes: fj_mmt.h)
578 const std::size_t refstat = L.classes[ic[0] - 1].refstat;
579 const std::size_t refnode = L.station_to_node[refstat - 1];
580 std::vector<std::vector<bool>> reach(L.nodes.size(),
581 std::vector<bool>(L.nclasses + 1, false));
582 std::vector<std::pair<std::size_t, std::size_t>> q;
583 for (std::size_t r : ic) {
584 reach[refnode - 1][r] = true;
585 q.emplace_back(refnode, r);
586 }
587 for (std::size_t h = 0; h < q.size(); ++h) {
588 const std::size_t nd = q[h].first, cl = q[h].second;
589 for (std::size_t j = 1; j <= L.nodes.size(); ++j)
590 for (std::size_t s : ic)
591 if (!reach[j - 1][s] && detail::fj_route_cs(L, cl, s, nd, j) > zero) {
592 reach[j - 1][s] = true;
593 q.emplace_back(j, s);
594 }
595 }
596 std::vector<std::size_t> aux(ic.size(), 0);
597 for (std::size_t a = 0; a < ic.size(); ++a) {
598 const std::size_t r = ic[a];
599 qn::JobClass xc;
600 xc.name = V.classes[r - 1].name + "." + V.nodes[F.node - 1].name;
601 xc.type = JobClassType::OPEN;
602 xc.population = std::numeric_limits<double>::infinity();
603 xc.refstat = tr.sourceStation;
604 xc.completes = false;
605 xc.is_ref_class = false;
606 xc.attr_kind = -1;
607 xc.attr_idx = 0;
608 xc.prio = V.classes[r - 1].prio;
609 aux[a] = V.add_class(xc);
610
611 for (std::size_t i = 1; i <= V.stations.size(); ++i) {
612 if (i == tr.sourceStation) continue;
613 // EVERY join station takes Immediate for the new class, not only
614 // this fork's: the reference's per-auxiliary-class loop keys on
615 // the node TYPE, so a second fork's join is Immediate here too.
616 if (tr.is_join_station(i)) {
617 V.set_service(i, aux[a], Distrib<T>::immediate());
618 continue;
619 }
620 V.set_service(i, aux[a], L.service[i - 1][r - 1]);
621 }
622 const bool disable = (F.origfanout[r] == 0) || !reach[F.node - 1][r];
623 V.set_service(tr.sourceStation, aux[a],
624 disable ? Distrib<T>::disabled_dist()
627
628 tr.fjclassmap.resize(V.classes.size() + 1, 0);
629 tr.fjforkmap.resize(V.classes.size() + 1, 0);
630 tr.fanout.resize(V.classes.size() + 1, 0.0);
631 tr.auxdisabled.resize(V.classes.size() + 1, false);
632 tr.fjclassmap[aux[a]] = r;
633 tr.fjforkmap[aux[a]] = fa;
634 tr.fanout[aux[a]] = static_cast<double>(F.origfanout[r]) * F.fanOut;
635 tr.auxdisabled[aux[a]] = disable;
636 tr.auxclasses.push_back(aux[a]);
637 }
638
639 // copy the (already fork-split) routing onto the auxiliary block
640 for (std::size_t x = 0; x < ic.size(); ++x)
641 for (std::size_t y = 0; y < ic.size(); ++y)
642 for (std::size_t i = 1; i <= nnodes; ++i)
643 for (std::size_t j = 1; j <= nnodes; ++j) {
644 const T p = V.get_route(ic[x], ic[y], i, j);
645 if (p > zero) V.set_route(aux[x], aux[y], i, j, p);
646 }
647
648 // The Source is the auxiliary entry and the Sink its exit. Only THIS
649 // fork's join row is cleared; another fork's join is an ordinary node of
650 // this span and keeps the routing that was copied onto it.
651 for (std::size_t x = 0; x < ic.size(); ++x) {
652 for (std::size_t y = 0; y < ic.size(); ++y) {
653 detail::fj_clear_row(V, aux[x], aux[y], tr.sourceNode);
654 if (F.joinNode != 0) detail::fj_clear_row(V, aux[x], aux[y], F.joinNode);
655 }
656 if (F.origfanout[ic[x]] > 0) {
657 V.set_route(aux[x], aux[x], tr.sourceNode, F.node, one);
658 if (F.joinNode != 0)
659 V.set_route(aux[x], aux[x], F.joinNode, tr.sinkNode, one);
660 }
661 }
662
663 // auxiliary-class scope BFS rationale: see _kb/06-solver-catalog.md (cpp port notes: fj_mmt.h)
664 std::vector<std::vector<bool>> vis(nnodes, std::vector<bool>(L.nclasses + 1, false));
665 std::vector<std::pair<std::size_t, std::size_t>> bq;
666 for (std::size_t x = 0; x < ic.size(); ++x) {
667 const std::size_t r = ic[x];
668 bool out = false;
669 for (std::size_t y = 0; y < ic.size() && !out; ++y)
670 for (std::size_t j = 1; j <= nnodes; ++j)
671 if (V.get_route(r, ic[y], F.node, j) > zero) {
672 out = true;
673 break;
674 }
675 if (out && !vis[F.node - 1][r]) {
676 vis[F.node - 1][r] = true;
677 bq.emplace_back(F.node, r);
678 }
679 }
680 for (std::size_t h = 0; h < bq.size(); ++h) {
681 const std::size_t cn = bq[h].first, cc = bq[h].second;
682 for (std::size_t y = 0; y < ic.size(); ++y) {
683 const std::size_t s = ic[y];
684 for (std::size_t nd = 1; nd <= nnodes; ++nd)
685 if (!vis[nd - 1][s] && detail::fj_route_cs(V, cc, s, cn, nd) > zero) {
686 vis[nd - 1][s] = true;
687 if (nd != F.joinNode) bq.emplace_back(nd, s);
688 }
689 }
690 }
691
692 // This fork, ITS join, the Source and the Sink are the transform's own
693 // infrastructure for this span: their auxiliary rows were set explicitly
694 // above and are never cleared. Another fork is NOT infrastructure here --
695 // it is an ordinary node that survives only if this span reaches it,
696 // which is what the reference's per-fork `infra_mask` says.
697 std::vector<bool> infra(nnodes, false);
698 infra[F.node - 1] = true;
699 if (F.joinNode != 0) infra[F.joinNode - 1] = true;
700 infra[tr.sourceNode - 1] = true;
701 infra[tr.sinkNode - 1] = true;
702 // per-(node,class) scope clearing rationale: see _kb/06-solver-catalog.md (cpp port notes: fj_mmt.h)
703 for (std::size_t nd = 1; nd <= nnodes; ++nd) {
704 if (infra[nd - 1]) continue;
705 for (std::size_t x = 0; x < ic.size(); ++x) {
706 if (vis[nd - 1][ic[x]]) continue;
707 for (std::size_t y = 0; y < ic.size(); ++y)
708 detail::fj_clear_row(V, aux[x], aux[y], nd);
709 }
710 }
711 // unreached-auxiliary-class rationale: see _kb/06-solver-catalog.md (cpp port notes: fj_mmt.h)
712 for (std::size_t x = 0; x < ic.size(); ++x) {
713 bool anyvis = false;
714 for (std::size_t nd = 1; nd <= nnodes && !anyvis; ++nd)
715 if (vis[nd - 1][ic[x]]) anyvis = true;
716 if (!anyvis) V.set_route(aux[x], aux[x], tr.sourceNode, tr.sinkNode, one);
717 }
718 }
719 }
720
721 tr.fjclassmap.resize(V.classes.size() + 1, 0);
722 tr.fjforkmap.resize(V.classes.size() + 1, 0);
723 tr.fanout.resize(V.classes.size() + 1, 0.0);
724 tr.auxdisabled.resize(V.classes.size() + 1, false);
725 // The nesting bookkeeping reads the finished auxiliary maps and the
726 // transformed routing, so it runs last, exactly where the reference calls
727 // sortForks (immediately after mmt returns).
729
731
732 // THE EFFECTIVE ROUTING MUST BE RE-DERIVED, not just the chains. Every edit
733 // above -- the fan-out division, the auxiliary blocks, the source and sink
734 // arcs -- is written through `set_route`, i.e. into `P`; but every consumer
735 // reads `route_eff`, which returns `Peff` whenever `Peff` is non-empty. This
736 // used to be moot: `refresh_routing` leaves `Peff` EMPTY on a model whose
737 // routing is all-PROB and which holds no ClassSwitch node, so `route_eff`
738 // fell through to `P` and the edits were visible by accident. A model that
739 // DOES hold a ClassSwitch -- now including every model where `link()`
740 // synthesizes one -- populates `Peff`, and the whole transform became
741 // invisible: on fj_cs_multi_visits the fork's rows kept their undivided
742 // fan-out of 2, and the Join's class-2 visit came out 2 instead of 1.
743 // `refresh_struct` runs refresh_routing immediately BEFORE refresh_chains;
744 // this restores that order for the transformed struct.
745 V.refresh_routing();
746 V.refresh_chains();
747 // AND THE CAPACITIES, which `refresh_struct` derives immediately after the
748 // chains for the same reason it derives them at all: a station's buffer is
749 // bounded by the population of the chains that reach it, and this transform
750 // has just added a Source station and a block of auxiliary OPEN classes. A
751 // copy of the layer's `cap`/`classcap` is then both too short and wrong, and
752 // on an LN layer it is EMPTY, because `build_layer` stops at refresh_chains
753 // and `solve_layer` refreshes the ensemble member rather than this copy.
754 // `buffer_size` indexes both vectors by station with no size test -- it is
755 // the implementation of Kendall's K, not a permissive default -- so
756 // `has_blocking`, and through it the BCMP gate `has_product_form`, read out
757 // of range. That was latent until solver_amva began asking a mixed model for
758 // `has_product_form` (2026-09-04): lqn_workflows' AND-join layer, whose
759 // auxiliary classes are what make it mixed, segfaulted on an empty `cap`.
761 // `rt` MUST be re-derived too, and not only the chains. MVA and NC read the
762 // per-chain `visits` that refresh_chains rebuilds, so a stale `rt` went
763 // unnoticed; the fluid drift reads `sn.rt` directly (stochastic complement
764 // over the stateful nodes, which is what absorbs the Router this transform
765 // puts where the fork was). Left stale it keeps the ORIGINAL class count, so
766 // `rt.rows() != nstateful*nclasses`, every route reads zero and the ODE
767 // returns its initial condition with every job parked at the reference
768 // station. SolverLN calls refresh_rt for the same reason after it edits a
769 // layer.
770 V.refresh_rt();
771 return tr;
772}
773
774} // namespace mva
775} // namespace line
776
777#endif // LINE_SOLVERS_MVA_FJ_MMT_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
void set_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j, const T &p)
P{r,s}(i,j) = p, with 1-based NODE and class indices.
std::size_t sourceIdx
1-based station index of the Source, 0 = none
std::size_t add_class(const JobClass &cl)
Add a class and grow the service table.
T get_route(std::size_t r, std::size_t s, std::size_t i, std::size_t j) const
P{r,s}(i,j), AS THE USER SET IT.
std::vector< Matrix< T > > nodevisits
(nchains) each (nnodes x nclasses)
std::vector< std::vector< Distrib< T > > > service
service[i][r], 0-based station and class; a disabled entry marks a pair never visited.
void refresh_chains()
Port of MNetwork.refreshChains followed by sn_refresh_visits.
std::size_t add_node(const std::string &nm, NodeType ty, bool stateful)
Add a non-station node (a Fork, a Router).
std::vector< JobClass > classes
std::size_t sinkNode
1-based NODE index of the Sink, 0 = none (it is not a station)
std::vector< Station< T > > stations
stations[k-1] is the k-th station
void set_service(std::size_t station, std::size_t cls, const Distrib< T > &d)
std::vector< std::vector< std::size_t > > inchain
1-based class indices per chain
std::vector< NodeDef > nodes
every node, in creation order
std::map< std::size_t, Matrix< T > > csmatrix
The class-switch matrix of a ClassSwitch node, by 1-based NODE index.
void refresh_rt()
sn.rt and sn.rtnodes: the class-expanded routing matrices.
std::vector< std::pair< std::size_t, std::size_t > > fj
fj(f,j): the Join node j that closes the Fork node f, 1-based.
std::size_t add_station(const Station< T > &st)
Add a station, which is also a node, and grow the service table.
std::vector< std::size_t > station_to_node
(nstations) 1-based node index
void refresh_routing()
Port of the part of MNetwork.refreshRoutingMatrix this port reaches: the expansion of a routing STRAT...
The exception types the port throws.
Mean of the k-th smallest of n independent EXPONENTIAL branch completion times, i....
T fj_ordstat_exp(const std::vector< T > &ri, std::size_t k)
Mean of the k-th smallest of n independent EXPONENTIAL branch completion times, i....
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
JobClassType
Job class kinds, with the values of MATLAB JobClassType.
Definition lang_types.h:367
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
Distrib< T > fj_exp_fit_mean(const T &mean)
Exp.fitMean(m), including its clamp.
Definition fj_mmt.h:252
T fj_expected_ordstat(const std::vector< T > &means, std::size_t k)
The instant the join fires: E[X_(k)] of independent exponentials with the given means,...
Definition fj_mmt.h:204
void fj_widen_csmatrix(FjMmt< T > &tr)
Widen every explicit ClassSwitch matrix to the auxiliary-expanded class set.
Definition fj_mmt.h:407
std::size_t fj_join_quorum(const qn::NetworkStruct< T > &sn, std::size_t joinNode, std::size_t nbranches)
The number of siblings the Join node fires on, out of nbranches forked.
Definition fj_mmt.h:227
T fj_expected_max(const std::vector< T > &means)
The AND-join case, i.e.
Definition fj_mmt.h:210
FjMmt< T > fj_mmt(const qn::NetworkStruct< T > &L)
Build the transformed layer.
Definition fj_mmt.h:444
void fj_sort_forks(FjMmt< T > &tr)
Port of ModelAdapter.sortForks: fill in outer and parent on every fork record.
Definition fj_mmt.h:357
A queueing network and its refreshed NetworkStruct.
static Distrib exp_rate(const T &r)
Definition lang_types.h:814
static Distrib disabled_dist()
Definition lang_types.h:857
static Distrib immediate()
The Immediate singleton.
Definition lang_types.h:846
One record per Fork of the base layer, ascending in node index.
Definition fj_mmt.h:115
std::size_t parent
parent_forks(f) as an index into forks: the fork whose node visits measure how often this one fires.
Definition fj_mmt.h:140
std::size_t joinStation
0 when the fork has no join
Definition fj_mmt.h:118
std::size_t node
1-based node index, shared with the base layer
Definition fj_mmt.h:116
std::size_t joinNode
0 when the fork has no join
Definition fj_mmt.h:117
std::vector< bool > outer
(nclasses+1) outer_forks(f, r): this fork is the outermost one on class r's path.
Definition fj_mmt.h:134
double fanOut
sn.nodeparam{f}.fanOut, which MATLAB sets to the fork's tasksPerLink and NOT to its number of output ...
Definition fj_mmt.h:126
std::vector< std::size_t > origfanout
(nclasses+1) the fork's number of output links for each class.
Definition fj_mmt.h:128
The transformed layer and the bookkeeping the fixed point needs to drive it and to merge its results ...
Definition fj_mmt.h:113
std::map< std::size_t, std::size_t > auxDelayNode
H-T only: the join node -> the auxiliary Delay NODE that carries the time the ORIGINAL class spends o...
Definition fj_mmt.h:178
std::size_t sinkNode
Definition fj_mmt.h:147
std::vector< ForkRec > forks
Definition fj_mmt.h:144
std::vector< std::size_t > fjclassmap
(nclasses+1) auxiliary -> original, 0 if not auxiliary
Definition fj_mmt.h:149
std::vector< std::size_t > auxclasses
auxiliary class indices, ascending
Definition fj_mmt.h:153
std::size_t sourceNode
Definition fj_mmt.h:146
std::size_t norig
class count of the base layer
Definition fj_mmt.h:148
qn::NetworkStruct< T > V
the transformed ("nonfj") layer
Definition fj_mmt.h:143
std::vector< std::size_t > auxbranch
H-T only: the 1-based branch a given auxiliary class walks; 0 elsewhere.
Definition fj_mmt.h:182
bool heidelberger_trivedi
The transform is Heidelberger-Trivedi (options.config.fork_join='ht') rather than MMT.
Definition fj_mmt.h:171
bool is_join_station(std::size_t st) const
Definition fj_mmt.h:186
bool active() const
Definition fj_mmt.h:184
std::size_t sourceStation
Definition fj_mmt.h:145
std::vector< bool > auxdisabled
(nclasses+1)
Definition fj_mmt.h:152
std::vector< std::size_t > joinStations
Every station that was a Join and is now a zero-service Delay, whether or not a fork claims it.
Definition fj_mmt.h:161
std::vector< double > fanout
(nclasses+1)
Definition fj_mmt.h:151
std::map< std::size_t, std::size_t > auxDelayStation
H-T only: the same delay as a STATION index.
Definition fj_mmt.h:180
std::vector< std::size_t > fjforkmap
(nclasses+1) auxiliary -> index into forks
Definition fj_mmt.h:150
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:674
static constexpr double FineTol
Definition lang_types.h:668
static constexpr double Zero
Definition lang_types.h:670
One job class of the network.
std::size_t refstat
1-based reference station
bool completes
Whether passage through the reference station is a COMPLETION.
int attr_kind
LayeredNetworkElement of the element it stands for.
std::size_t attr_idx
index of that element
double population
infinite for an open class
bool is_ref_class
marks the chain's reference class
One station of the network.
SchedStrategy sched
double nservers
may be infinite (a Delay, or an inf-scheduled task)