LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_conservation_guard.h
Go to the documentation of this file.
1#pragma once
2/**
3 * @file fluid_conservation_guard.h
4 * @ingroup line_solvers
5 * @brief Detects a moment-closure trajectory that has left the model.
6 *
7 * WHY IT EXISTS. The moment-closure drift can leave the simplex: on a station
8 * where min(n,c) is not the identity the Gaussian correction to the per-class
9 * share can drive a coordinate negative, and since the drift is conservative
10 * another grows to match. In MATLAB `odeset('NonNegative')` projects the
11 * ACCEPTED step, so the excursion is CLAMPED rather than reported -- which
12 * injects mass, collapses the step size, and leaves the window never returning.
13 * One MATLAB suite run sat in `test_CQN_Cox_CS_9` for 3h16m, and the 2026-08-27
14 * run was killed after `test11_interlock_lqnx` had held the suite for 100
15 * minutes, taking every block after it down with it.
16 *
17 * THIS PORT CANNOT HANG THE SAME WAY, and the difference is worth stating
18 * rather than papering over: `solver_fluid.h` clamps the state AFTER each
19 * window, not inside the integration, so the same divergence surfaces as a
20 * finished window holding a state that is not a solution -- silently, with
21 * every later window integrated from it. That is what this makes loud. Python
22 * DOES reproduce the reference's semantics (`_integrate_nonnegative` clips and
23 * restarts), so its guard runs per accepted step and halts the window.
24 *
25 * THE TEST IS AN EXACT INVARIANT, not a heuristic bound on time or magnitude.
26 * The drift conserves the population of every CLOSED CHAIN exactly, so any
27 * deviation is a divergence and nothing else. The tolerance is a generous
28 * fraction of that population rather than a numerical tolerance: the
29 * integrator's own error is ~1e-4 relative, while the documented excursion
30 * reaches 5.2e4 against a true population of 0.05. A closed model whose
31 * population has moved by TOL is no longer solving the model, whatever it is
32 * converging to.
33 *
34 * THE CHAIN IS THE CONSERVED UNIT, NOT THE CLASS, and the difference is the
35 * whole correctness of this check. A class population is what that class
36 * STARTS with; class switching then moves jobs between the classes of one
37 * chain, so only the chain total is invariant. Watching classes instead
38 * condemns every class-switching model out of hand -- measured on
39 * `cqn_twoclass_hyperl` (313 of 447 accepted states), on `init_state_ps` (286
40 * of 310) and on every one of the 162 fluid layers an LQN builds under the
41 * `srvn.cs` encoding, where the chain sum never moved at all. A cache model is
42 * the same story with the hit/miss classes.
43 *
44 * A wall-clock budget would have caught the same thing and was rejected: it
45 * makes the answer depend on how busy the host is, so the same model would fall
46 * back on one machine and not on another. This invariant is deterministic.
47 */
48
49#include <cmath>
50#include <cstddef>
51#include <limits>
52#include <vector>
53
57
58namespace line {
59namespace fluid {
60
61/** Relative population drift that counts as having left the model. */
62inline constexpr double kFluidConservationTol = 0.1;
63
64// The "is the closure active" predicate is FluidClosure::gaussian(), which
65// already exists: the first pass of the closure runs at sigma2 = 0 -- that pass
66// IS the first-order solve -- and only the later ones can diverge. Gating on it
67// keeps the guard off `closing` and `matrix`, which are also the fallback
68// ladder's own rungs and must not be sent down a fallback by their own
69// watchdog.
70
71/**
72 * The classes of each chain, as 0-based column indices.
73 *
74 * `sn.inchain` holds them 1-based, one vector per chain. A struct declaring no
75 * chains degrades to one chain per class, which is the safe reading rather
76 * than a guess: with no chain map there is no class switching to merge
77 * classes, so each class IS its own conserved unit.
78 */
79template <typename T>
80inline std::vector<std::vector<std::size_t>> fluid_chain_partition(
81 const qn::NetworkStruct<T>& sn, std::size_t K) {
82 std::vector<std::vector<std::size_t>> out;
83 if (sn.inchain.empty()) {
84 out.reserve(K);
85 for (std::size_t r = 0; r < K; ++r) out.push_back({r});
86 return out;
87 }
88 out.reserve(sn.inchain.size());
89 for (const std::vector<std::size_t>& chain : sn.inchain) {
90 std::vector<std::size_t> members;
91 members.reserve(chain.size());
92 for (std::size_t r1 : chain)
93 if (r1 >= 1 && r1 - 1 < K) members.push_back(r1 - 1);
94 out.push_back(members);
95 }
96 return out;
97}
98
99/**
100 * The closed chain whose conserved population has drifted past `tol`, or -1.
101 *
102 * @param sn the network struct, for the chain membership and populations
103 * @param L the state layout, which fixes each (station,class) block
104 * @param x a state vector of length `L.nstates`
105 * @param tol relative deviation that counts as having left the model
106 */
107template <typename T>
109 const FluidLayout& L,
110 const std::vector<double>& x,
111 double tol = kFluidConservationTol) {
112 const std::size_t M = L.qidx.size();
113 if (M == 0) return -1;
114 const std::size_t K = L.qidx[0].size();
115 const std::vector<std::vector<std::size_t>> chains = fluid_chain_partition(sn, K);
116 for (std::size_t c = 0; c < chains.size(); ++c) {
117 double target = 0.0;
118 for (std::size_t r : chains[c])
119 target += static_cast<double>(sn.classes[r].population);
120 if (!std::isfinite(target) || target <= 0.0) {
121 continue; // open, or absent: no conserved population to check
122 }
123 double mass = 0.0;
124 bool any = false;
125 for (std::size_t r : chains[c]) {
126 for (std::size_t i = 0; i < M; ++i) {
127 const std::size_t n = L.kic[i][r];
128 if (n == 0) continue;
129 const std::size_t base = L.qidx[i][r];
130 if (base + n > x.size()) continue; // a different state vector
131 for (std::size_t k = 0; k < n; ++k) mass += x[base + k];
132 any = true;
133 }
134 }
135 if (!any) continue;
136 if (std::fabs(mass - target) > tol * std::max(1.0, target)) {
137 return static_cast<int>(c);
138 }
139 }
140 return -1;
141}
142
143} // namespace fluid
144} // namespace line
A network plus its refreshed NetworkStruct.
The one exception the fluid fallback ladder catches.
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
std::vector< std::vector< std::size_t > > fluid_chain_partition(const qn::NetworkStruct< T > &sn, std::size_t K)
The classes of each chain, as 0-based column indices.
constexpr double kFluidConservationTol
Relative population drift that counts as having left the model.
int fluid_conservation_violation(const qn::NetworkStruct< T > &sn, const FluidLayout &L, const std::vector< double > &x, double tol=kFluidConservationTol)
The closed chain whose conserved population has drifted past tol, or -1.
A queueing network and its refreshed NetworkStruct.
Where each (station, class) block sits in the state vector.
Definition fluid_odes.h:86
std::vector< std::vector< std::size_t > > qidx
0-based first index of (i,r)
Definition fluid_odes.h:88
std::vector< std::vector< std::size_t > > kic
phases held by (i,r); 0 when disabled
Definition fluid_odes.h:89