LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
infer_fmlps.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_API_INFER_INFER_FMLPS_H
6#define LINE_API_INFER_INFER_FMLPS_H
7
8/**
9 * @file
10 * @ingroup api_infer
11 * Fluid response-time likelihood, and the FMLPS demand estimator built on it.
12 *
13 * Port of matlab/src/api/infer/infer_fluid_ps_rt_likelihood.m and
14 * infer_fmlps.m. Both are MATLAB-ONLY: no JAR, no native-Python twin.
15 *
16 * Reference: Casale et al., "Fluid Analysis of Queueing in Processor Sharing
17 * Systems".
18 *
19 * WHAT THE LIKELIHOOD IS. MLPS builds an exact CTMC per observation and reads a
20 * phase-type density; that is exact and costs a state space. FMLPS replaces it
21 * with the FLUID limit: mark one unit of fluid at the reference station in the
22 * tagged class, let the deterministic drift carry it, and the passage-time
23 * density at the observed response time is `-d/dt (marked mass) / mass0`. The
24 * marked mass is nonincreasing, so that derivative is the density and the
25 * likelihood needs no separate normalization.
26 *
27 * THE AUGMENTATION IS THE SAME ONE `fluid_passage_time` PERFORMS, and this file
28 * deliberately reuses it rather than re-deriving the drift. The reference
29 * expands `sn.mu`, `sn.phi`, `sn.proc` and the routing table from K to K+1
30 * classes by hand and hands the loose arrays to `solver_fluid_odes`; the C++
31 * fluid solver takes a `NetworkStruct`, and the marked-fluid construction it
32 * already carries is exactly the tagged class K+1 -- a departure of the marked
33 * block UNMARKS the fluid and delivers it wherever the class routes, which is
34 * precisely the reference's "absorption at refIdx: the tagged class switches
35 * back to the original classes". Re-transcribing the routing expansion would
36 * duplicate the one part of the reference where the index arithmetic
37 * (`l:Kc:end` against `l:K:end`) is easiest to get wrong.
38 *
39 * TWO DIFFERENCES FROM THE CTMC ESTIMATOR THAT A CALLER MUST KNOW:
40 *
41 * - the fluid density is a LIMIT, so on a small population it is an
42 * approximation where MLPS is exact. It is what buys a model whose state
43 * space MLPS cannot enumerate;
44 * - the reference returns a likelihood of exactly ZERO when the integration
45 * terminates before the observed time (the marked fluid is gone). That is
46 * kept: a zero likelihood is information, and replacing it with a floor
47 * would make an impossible observation look merely unlikely.
48 *
49 * ARITHMETIC: double, following the ODE.
50 */
51
52#include <algorithm>
53#include <cmath>
54#include <cstddef>
55#include <limits>
56#include <vector>
57
60#include "line/num/number.h"
62#include "line/util/error.h"
63#include "line/util/matrix.h"
65#include "line/util/lsoda.h"
66#include "line/util/ode.h"
67
68namespace line {
69namespace api {
70
71/** What the fluid likelihood call reports. */
73 double like = 0.0; ///< the passage-time density at the observed time
74 double marked0 = 0.0; ///< the marked mass placed at t = 0
75 double markedT = 0.0; ///< what is left of it at the observed time
76 std::size_t nstates = 0; ///< size of the augmented system
77};
78
79namespace fmlpsdetail {
80
81/**
82 * The augmented drift: the untagged system plus one marked block at (i, c).
83 *
84 * This is `fluid_passage_time`'s construction. The station's service share is
85 * computed on the FOLDED state -- marked mass added back into the block it came
86 * from -- because a processor-sharing server does not know which of its jobs is
87 * tagged, and computing the share without folding would give the marked job a
88 * larger share than it has.
89 */
90struct TaggedDrift {
91 const fluid::FluidOdeSystem* sys;
92 std::size_t base, tag0, P, n, nt;
93 struct TagEvent {
94 std::size_t minus, plus, event_idx;
95 double rate_base;
96 };
97 std::vector<TagEvent> tev;
98};
99
100inline TaggedDrift build_tagged(const fluid::FluidOdeSystem& sys, std::size_t i, std::size_t c) {
101 TaggedDrift td;
102 td.sys = &sys;
103 td.P = sys.layout.kic[i][c];
104 td.base = sys.layout.qidx[i][c];
105 td.n = sys.layout.nstates;
106 td.tag0 = td.n;
107 td.nt = td.n + td.P;
108 for (std::size_t e = 0; e < sys.events.size(); ++e) {
109 const fluid::FluidEvent& ev = sys.events[e];
110 if (ev.event_idx < td.base || ev.event_idx >= td.base + td.P) continue;
111 const std::size_t k = ev.event_idx - td.base;
112 TaggedDrift::TagEvent t2;
113 t2.event_idx = td.tag0 + k;
114 t2.rate_base = ev.rate_base;
115 t2.minus = td.tag0 + k;
116 if (e < sys.n_departures) {
117 // A departure UNMARKS the fluid: it leaves the measured block and
118 // arrives untagged wherever the class routes. That is the
119 // reference's absorption.
120 t2.plus = ev.plus;
121 } else {
122 t2.plus = td.tag0 + (ev.plus - td.base); // a phase change stays marked
123 }
124 td.tev.push_back(t2);
125 }
126 return td;
127}
128
129} // namespace fmlpsdetail
130
131/**
132 * The fluid passage-time density at one observed response time.
133 *
134 * @param sn the network
135 * @param ist 1-based reference station where the tagged job sits
136 * @param cls 1-based tagged class
137 * @param levels (nstates) initial fluid state of the UNAUGMENTED system
138 * @param rsampled the observed response time
139 * @param marked the marked mass, the reference's `newFluid`; one job
140 */
141template <class T>
143 std::size_t cls, const std::vector<double>& levels,
144 double rsampled, double marked = 1.0) {
145 const std::size_t M = sn.nstations, K = sn.nclasses;
146 if (ist == 0 || ist > M) throw InputError("infer_fluid_ps_rt_likelihood: station out of range");
147 if (cls == 0 || cls > K) throw InputError("infer_fluid_ps_rt_likelihood: class out of range");
148 if (!(rsampled > 0.0))
149 throw InputError("infer_fluid_ps_rt_likelihood: the response time must be positive");
150 if (!(marked > 0.0))
151 throw InputError("infer_fluid_ps_rt_likelihood: the marked mass must be positive");
152
153 const std::size_t i = ist - 1, c = cls - 1;
155 const fluid::FluidLayout& L = sys.layout;
156 if (levels.size() != L.nstates)
157 throw InputError(
158 "infer_fluid_ps_rt_likelihood: the initial fluid state has the wrong length for this "
159 "model");
160
161 const fmlpsdetail::TaggedDrift td = fmlpsdetail::build_tagged(sys, i, c);
163 out.nstates = td.nt;
164 out.marked0 = marked;
165 if (td.P == 0) {
166 // The class is not served at this station, so there is no passage to
167 // measure and no density to report.
168 out.like = 0.0;
169 return out;
170 }
171
172 // The observed job is MOVED out of its class into the marked block, so the
173 // total fluid is unchanged and the state the job found is what the drift
174 // starts from.
175 std::vector<double> y0(td.nt, 0.0);
176 for (std::size_t s = 0; s < td.n; ++s) y0[s] = levels[s];
177 double avail = 0.0;
178 for (std::size_t k = 0; k < td.P; ++k) avail += y0[td.base + k];
179 if (avail + 1e-12 < marked)
180 throw InputError(
181 "infer_fluid_ps_rt_likelihood: the initial state holds less fluid in the tagged block "
182 "than the observation marks, so the observed job is not in the state it arrived to");
183 // Remove the marked mass proportionally over the block's phases, which is
184 // what leaves the rest of the block undisturbed.
185 for (std::size_t k = 0; k < td.P; ++k) y0[td.base + k] -= marked * (y0[td.base + k] / avail);
186 y0[td.tag0] = marked; // all of it enters in phase one
187
188 const std::size_t base = td.base, tag0 = td.tag0, P = td.P, n = td.n, nt = td.nt;
189 const std::vector<fmlpsdetail::TaggedDrift::TagEvent> tev = td.tev;
190 auto drift = [&sys, &tev, base, tag0, P, n, nt](double, const double* x, double* dx) {
191 std::vector<double> xb(x, x + n);
192 std::vector<double> g(xb);
193 for (std::size_t k = 0; k < P; ++k) g[base + k] += x[tag0 + k];
194 std::vector<double> gg(g);
195 fluid::fluid_rates_closing(sys, g.data(), gg);
196 double blk = 0.0, gblk = 0.0;
197 for (std::size_t k = 0; k < P; ++k) {
198 blk += g[base + k];
199 gblk += gg[base + k];
200 }
201 const double share = (blk > 0.0) ? gblk / blk : 1.0;
202
203 for (std::size_t s = 0; s < nt; ++s) dx[s] = 0.0;
204 for (std::size_t e = 0; e < sys.events.size(); ++e) {
205 const fluid::FluidEvent& ev = sys.events[e];
206 double drive = gg[ev.event_idx];
207 if (ev.event_idx >= base && ev.event_idx < base + P)
208 drive = xb[base + (ev.event_idx - base)] * share;
209 const double r = ev.rate_base * drive;
210 if (r == 0.0) continue;
211 dx[ev.minus] -= r;
212 dx[ev.plus] += r;
213 }
214 for (std::size_t e = 0; e < tev.size(); ++e) {
215 const double r = tev[e].rate_base * x[tev[e].event_idx] * share;
216 if (r == 0.0) continue;
217 dx[tev[e].minus] -= r;
218 dx[tev[e].plus] += r;
219 }
220 };
221
222 std::vector<double> grid;
223 grid.push_back(0.0);
224 grid.push_back(rsampled);
225 LsodaOptions lopt;
226 lopt.rtol = 1e-5;
227 lopt.atol = 1e-8;
228 const LsodaSolution sol = fluid::fluid_integrate_grid(drift, y0, grid, lopt);
229 if (sol.y.empty()) throw InputError("infer_fluid_ps_rt_likelihood: the ODE returned no state");
230
231 const std::vector<double>& yT = sol.y.back();
232 double left = 0.0;
233 for (std::size_t k = 0; k < P; ++k) left += std::max(0.0, yT[tag0 + k]);
234 out.markedT = left;
235
236 // The density is the rate at which the marked mass is leaving, normalized
237 // by the mass placed. Reading the derivative rather than differencing the
238 // trajectory is what the reference does and is what stays accurate when the
239 // curve is nearly flat.
240 std::vector<double> dy(nt, 0.0);
241 drift(rsampled, yT.data(), dy.data());
242 double ddt = 0.0;
243 for (std::size_t k = 0; k < P; ++k) ddt += dy[tag0 + k];
244 out.like = -ddt / marked;
245 // The marked mass is nonincreasing, so a negative density is round-off at
246 // an exhausted block, not a model result.
247 if (out.like < 0.0) out.like = 0.0;
248 return out;
249}
250
251/**
252 * FMLPS: the fluid analogue of MLPS, the reference's infer_fmlps(model, node,
253 * rt, class, ql, W).
254 *
255 * Same objective as `infer_mlps` -- the negative log-likelihood of the observed
256 * response times -- with the exact phase-type density replaced by the fluid
257 * passage-time density above. The initial fluid state of each observation is
258 * the reference's: the per-class queue length it found at the reference (the
259 * last non-delay) station, and the remaining W - sum(ql) jobs at the delay
260 * (INF) station split across classes in proportion to the delay's first-phase
261 * rates, so that arrivals during the tagged sojourn are modelled; each class's
262 * mass is placed at the first phase of its block, as the reference's
263 * y0(q_indices(i,k)) = y0_levels(i,k) does. The box is [min(rt)/W, max(rt)]
264 * per class and a class with no observation starts at its lower bound. The
265 * optimizer is a bounded Nelder-Mead in place of fmincon's interior point.
266 *
267 * @param sn a template network whose station `ist` carries the demands
268 * being estimated; only its service RATES are varied
269 * @param ist 1-based PS station
270 * @param samples the observations
271 * @param W the reference's W: the number of jobs in the closed network
272 */
273template <class T>
274std::vector<double> infer_fmlps(const qn::NetworkStruct<T>& sn, std::size_t ist,
275 const std::vector<MlpsSample>& samples, double W) {
276 const std::size_t M = sn.nstations, K = sn.nclasses;
277 if (ist == 0 || ist > M) throw InputError("infer_fmlps: station out of range");
278 if (samples.empty()) throw InputError("infer_fmlps: no observations");
279 for (std::size_t s = 0; s < samples.size(); ++s) {
280 if (samples[s].cls < 1 || samples[s].cls > K)
281 throw InputError("infer_fmlps: a sample names a class outside 1..K");
282 if (samples[s].ql.size() != K)
283 throw InputError("infer_fmlps: a sample's queue length has the wrong width");
284 if (!(samples[s].rt > 0.0))
285 throw InputError("infer_fmlps: a response time must be positive");
286 }
287
288 if (!(W > 0.0)) throw InputError("infer_fmlps: W must be positive");
289
290 // The reference's starting point and box: xLB = min(rt)/W, xUB = max(rt).
291 double meanQL = 0.0, rtmax = 0.0, rtmin = std::numeric_limits<double>::infinity();
292 for (std::size_t s = 0; s < samples.size(); ++s) {
293 for (std::size_t r = 0; r < K; ++r) meanQL += samples[s].ql[r];
294 rtmax = std::max(rtmax, samples[s].rt);
295 rtmin = std::min(rtmin, samples[s].rt);
296 }
297 meanQL /= static_cast<double>(samples.size());
298 const double xLB = rtmin / W;
299 const double nCores = sn.stations[ist - 1].nservers;
300 const double Vtilde = std::min(meanQL, std::isinf(nCores) ? meanQL : nCores);
301 std::vector<double> x0(K, xLB);
302 for (std::size_t r = 0; r < K; ++r) {
303 double sum = 0.0;
304 std::size_t cnt = 0;
305 for (std::size_t s = 0; s < samples.size(); ++s)
306 if (samples[s].cls == r + 1) {
307 sum += samples[s].rt;
308 ++cnt;
309 }
310 if (cnt > 0 && meanQL > 0.0) x0[r] = Vtilde * (sum / static_cast<double>(cnt)) / meanQL;
311 }
312
313 // delayIdx: the last INF station; refIdx: the last other station (the
314 // reference's loop keeps the last of each).
315 std::size_t delayIdx = 0, refIdx = 0;
316 for (std::size_t i = 1; i <= M; ++i) {
317 if (sn.stations[i - 1].sched == lang::SchedStrategy::INF)
318 delayIdx = i;
319 else
320 refIdx = i;
321 }
322 if (refIdx == 0) refIdx = ist;
323 // delayRates(k) = sn.mu{delayIdx}{k}(1), the first-phase rate of the delay's class k law
324 std::vector<double> delayRates(K, 0.0);
325 double delaySum = 0.0;
326 if (delayIdx > 0)
327 for (std::size_t r = 0; r < K; ++r) {
328 const lang::Distrib<T>& d = sn.service[delayIdx - 1][r];
329 if (d.disabled) continue;
330 const mam::Map<T> rep = lang::dist_to_map(d);
331 if (rep.D0.rows() == 0) continue;
332 delayRates[r] = -num_traits<T>::to_double(rep.D0(0, 0));
333 if (std::isfinite(delayRates[r])) delaySum += delayRates[r];
334 else delayRates[r] = 0.0;
335 }
336
337 const double TOL = 1e-6;
338 auto objective = [&](const std::vector<double>& x) {
339 for (std::size_t r = 0; r < K; ++r)
340 if (!(x[r] > 0.0)) return std::numeric_limits<double>::infinity();
341
343 for (std::size_t r = 0; r < K; ++r)
344 mod.set_service(ist, r + 1,
346 mod.refresh_rates();
347
349 double f = 0.0;
350 for (std::size_t s = 0; s < samples.size(); ++s) {
351 // y0_levels(refIdx,:) = aQueue and y0_levels(delayIdx,:) =
352 // (W - sum(aQueue)) delayRates / sum(delayRates), each class at the
353 // first phase of its block (the reference's q_indices(i,k)).
354 std::vector<double> lev(sysx.layout.nstates, 0.0);
355 double qsum = 0.0;
356 for (std::size_t r = 0; r < K; ++r) qsum += samples[s].ql[r];
357 for (std::size_t r = 0; r < K; ++r) {
358 if (sysx.layout.kic[refIdx - 1][r] == 0) continue;
359 lev[sysx.layout.qidx[refIdx - 1][r]] = samples[s].ql[r];
360 }
361 if (delayIdx > 0 && delaySum > 0.0)
362 for (std::size_t r = 0; r < K; ++r) {
363 if (sysx.layout.kic[delayIdx - 1][r] == 0) continue;
364 lev[sysx.layout.qidx[delayIdx - 1][r]] =
365 (W - qsum) * delayRates[r] / delaySum;
366 }
367 double like = 0.0;
368 try {
369 like = infer_fluid_ps_rt_likelihood(mod, refIdx, samples[s].cls, lev,
370 samples[s].rt)
371 .like;
372 } catch (const Error&) {
373 // An observation the model cannot host contributes the floor
374 // rather than aborting the whole fit.
375 like = 0.0;
376 }
377 f -= std::log(TOL + std::max(0.0, like));
378 }
379 return f;
380 };
381
382 std::vector<Bound<double>> bounds(K);
383 for (std::size_t r = 0; r < K; ++r) {
384 bounds[r].has_lo = true;
385 bounds[r].has_hi = true;
386 bounds[r].lo = xLB;
387 bounds[r].hi = rtmax;
388 }
389 return nelder_mead_box(objective, x0, bounds).x;
390}
391
392/**
393 * infer_fmlps with W taken as the closed population of `sn`
394 * (sn.nclosedjobs()), which is what the reference's W stands for when the
395 * network is the closed equivalent the estimator builds.
396 */
397template <class T>
398std::vector<double> infer_fmlps(const qn::NetworkStruct<T>& sn, std::size_t ist,
399 const std::vector<MlpsSample>& samples) {
400 return infer_fmlps(sn, ist, samples, sn.nclosedjobs());
401}
402
403} // namespace api
404} // namespace line
405
406#endif // LINE_API_INFER_INFER_FMLPS_H
Base error for the multiprecision C++ port.
Definition error.h:73
InputError(const std::string &what)
Definition error.h:105
A network plus its refreshed NetworkStruct.
void refresh_rates()
Port of MNetwork.refreshRates: lower each service process onto a rate and an SCV.
void set_service(std::size_t station, std::size_t cls, const Distrib< T > &d)
The exception types the port throws.
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
Port of ode_eliminate_immediate.m, eliminate_immediate_matrix.m and ode_solve_stiff....
Maximum-likelihood service-demand estimation at a processor-sharing queue.
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
Dense matrix and non-owning view.
std::vector< double > infer_fmlps(const qn::NetworkStruct< T > &sn, std::size_t ist, const std::vector< MlpsSample > &samples, double W)
FMLPS: the fluid analogue of MLPS, the reference's infer_fmlps(model, node,rt, class,...
FluidRtLikelihood infer_fluid_ps_rt_likelihood(const qn::NetworkStruct< T > &sn, std::size_t ist, std::size_t cls, const std::vector< double > &levels, double rsampled, double marked=1.0)
The fluid passage-time density at one observed response time.
LsodaSolution fluid_integrate_grid(const std::function< void(double, const double *, double *)> &f, const std::vector< double > &y0, const std::vector< double > &grid, const LsodaOptions &lopt)
The same retry over a whole output grid, for the callers that ask LSODA for a trajectory rather than ...
FluidOdeSystem fluid_ode_system(const qn::NetworkStruct< T > &sn)
Build the drift of sn: the port of ode_jumps_new and ode_rate_base fused into one pass.
Definition fluid_odes.h:322
void fluid_rates_closing(const FluidOdeSystem &sys, const double *x, std::vector< double > &g)
The reference's ode_rates_closing name, kept for the first-order callers.
Definition fluid_odes.h:646
mam::Map< T > dist_to_map(const Distrib< T > &d)
The (D0,D1) pair that reaches sn.proc.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
NelderMeadResult< T > nelder_mead_box(F f, const std::vector< T > &x0, const std::vector< Bound< T > > &bounds, const NelderMeadOptions< T > &opt)
Box-constrained simplex minimization by the transformation described in the header comment.
Definition neldermead.h:370
A queueing network and its refreshed NetworkStruct.
Number-type abstraction for the templated API port.
Adaptive stiff ODE integrator: a four-stage Rosenbrock method of order four with an embedded order-th...
Integration controls.
Definition lsoda.h:64
double atol
absolute tolerance, applied to every component
Definition lsoda.h:66
double rtol
relative tolerance, applied to every component
Definition lsoda.h:65
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
std::vector< std::vector< double > > y
y[i] is the state at t[i]
Definition lsoda.h:128
What the fluid likelihood call reports.
Definition infer_fmlps.h:72
double markedT
what is left of it at the observed time
Definition infer_fmlps.h:75
std::size_t nstates
size of the augmented system
Definition infer_fmlps.h:76
double like
the passage-time density at the observed time
Definition infer_fmlps.h:73
double marked0
the marked mass placed at t = 0
Definition infer_fmlps.h:74
One event of the drift.
Definition fluid_odes.h:101
std::size_t event_idx
state entry whose g(x) drives this rate
Definition fluid_odes.h:104
double rate_base
the model-fixed part of the rate
Definition fluid_odes.h:105
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::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::vector< std::vector< std::size_t > > kic
phases held by (i,r); 0 when disabled
Definition fluid_odes.h:89
The assembled drift: the layout, the events, and the per-station schedule.
Definition fluid_odes.h:195
std::size_t n_departures
How many leading entries of events are DEPARTURES (a job completing at one block and starting at anot...
Definition fluid_odes.h:206
std::vector< FluidEvent > events
Definition fluid_odes.h:197
static Distrib exp_rate(const T &r)
Definition lang_types.h:955
A MAP as the pair of matrices (D0, D1).
Definition map_moment.h:53
Matrix< T > D0
Definition map_moment.h:54