LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
pfqn_jointmarg.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_PFQN_JOINTMARG_H
6#define LINE_API_PFQN_JOINTMARG_H
7
8/**
9 * @file
10 * @ingroup api_pfqn
11 * Joint probability of the per-station TOTAL queue lengths.
12 *
13 * Templated port of matlab/src/api/pfqn/pfqn_jointmarg.m, of
14 * jline.api.pfqn.Pfqn_jointmarg and of python pfqn_jointmarg.
15 *
16 * Joint probability that station i holds n(i) jobs IN TOTAL, all classes summed
17 * out, in a closed multiclass product-form network:
18 *
19 * P(n_1,...,n_M) = perm(A) / ( prod_r N_r! * prod_{j in INFSET} n_j! * G(N) )
20 *
21 * with A the demand matrix whose column r is repeated N_r times and whose row i
22 * is repeated n_i times, so A is square of order sum(N).
23 *
24 * HOW THIS DIFFERS FROM pfqn_joint_total, which is the same identity with the
25 * delay taken as ONE aggregated row: here every infinite-server station keeps
26 * its own row and contributes its own 1/n_j!. The queueing stations contribute
27 * the n_i! that the permanent identity supplies; the infinite servers do not.
28 * Dividing once, as a single aggregated delay row would, leaves the law
29 * unnormalized as soon as the model has two delays.
30 *
31 * The identity holds for load-independent single-server queues plus infinite
32 * servers. Multiserver and load-dependent stations break the n_i! factor and
33 * are the caller's responsibility to exclude (see solver_nc_jointmarg).
34 *
35 * ZERO ELEMENTS are safe under the exact engine and only under it: a station
36 * holding no jobs contributes no row, a class with no jobs contributes no
37 * column, a zero demand is an ordinary zero entry of A, and the permanent of
38 * the empty matrix is 1. The approximate engines are REFUSED on a matrix with a
39 * structural zero rather than having it floored at eps: Sinkhorn scaling needs
40 * full support, and the Bethe gap is a state-dependent lower bound that does
41 * not cancel when the estimates are normalized against each other.
42 *
43 * Arithmetic: EXACT-CAPABLE on the "exact" engine, which reaches pfqn_perm and
44 * uses additions, multiplications and exact binomials only. The four
45 * approximate engines are double-precision Monte Carlo or message passing, so
46 * they collapse an exact T to double before running.
47 *
48 * Reference:
49 * H. J. Ryser, "Combinatorial Mathematics", Carus Mathematical Monographs 14,
50 * Mathematical Association of America, 1963.
51 */
52
53#include <algorithm>
54#include <cstddef>
55#include <string>
56#include <vector>
57
62#include "line/num/number.h"
63#include "line/util/error.h"
64#include "line/util/matrix.h"
65
66namespace line {
67namespace pfqn {
68
69namespace jointmargdetail {
70
71/** Lowercase copy, so the engine name is matched case-insensitively. */
72inline std::string lower(const std::string& s) {
73 std::string out = s;
74 for (std::size_t i = 0; i < out.size(); ++i)
75 out[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(out[i])));
76 return out;
77}
78
79/**
80 * Row i of L repeated n(i) times, kept in the (rows x R) form pfqn_perm takes,
81 * whose column r stands for N_r identical columns.
82 *
83 * A station holding no jobs drops out here, which is what makes a zero entry of
84 * the occupancy vector free of any special case: the expanded matrix stays
85 * square of order sum(N).
86 */
87template <class T>
88Matrix<T> replicate_rows(const Matrix<T>& L, const std::vector<int>& n) {
89 const std::size_t M = L.rows(), R = L.cols();
90 std::vector<std::size_t> rowOf;
91 for (std::size_t i = 0; i < M; ++i)
92 for (int c = 0; c < n[i]; ++c) rowOf.push_back(i);
93 Matrix<T> A(rowOf.size(), R);
94 for (std::size_t a = 0; a < rowOf.size(); ++a)
95 for (std::size_t r = 0; r < R; ++r) A(a, r) = L(rowOf[a], r);
96 return A;
97}
98
99/** The fully expanded square matrix, which the approximate engines need. */
100template <class T>
101Matrix<double> expand_to_double(const Matrix<T>& A, const std::vector<int>& N) {
102 std::vector<std::size_t> colOf;
103 for (std::size_t r = 0; r < N.size(); ++r)
104 for (int c = 0; c < N[r]; ++c) colOf.push_back(r);
105 Matrix<double> out(A.rows(), colOf.size(), 0.0);
106 for (std::size_t i = 0; i < A.rows(); ++i)
107 for (std::size_t b = 0; b < colOf.size(); ++b)
108 out(i, b) = num_traits<T>::to_double(A(i, colOf[b]));
109 return out;
110}
111
112/**
113 * The row-replicated matrix in double, keeping only the classes that hold jobs,
114 * with their populations returned as the column multiplicities.
115 *
116 * This is what `expand_to_double` expands, one step earlier: perm(Ar, mult)
117 * equals perm of the expansion, and the saddle point wants the unexpanded form
118 * because its expansion is asymptotic in mult. A class with no jobs is dropped
119 * rather than passed with multiplicity zero, so a zero demand in such a column
120 * cannot trip the full-support check.
121 */
122template <class T>
123Matrix<double> rows_to_double(const Matrix<T>& A, const std::vector<int>& N,
124 std::vector<std::size_t>* mult) {
125 std::vector<std::size_t> keep;
126 mult->clear();
127 for (std::size_t r = 0; r < N.size(); ++r)
128 if (N[r] > 0) {
129 keep.push_back(r);
130 mult->push_back(static_cast<std::size_t>(N[r]));
131 }
132 Matrix<double> out(A.rows(), keep.size(), 0.0);
133 for (std::size_t i = 0; i < A.rows(); ++i)
134 for (std::size_t l = 0; l < keep.size(); ++l)
135 out(i, l) = num_traits<T>::to_double(A(i, keep[l]));
136 return out;
137}
138
139/**
140 * First (station, class) whose zero demand actually reaches the replicated
141 * matrix, 1-based, or (0,0) when there is none.
142 *
143 * A class with no jobs or a station with no jobs contributes nothing, so its
144 * zeros are irrelevant.
145 */
146template <class T>
147std::pair<std::size_t, std::size_t> first_zero(const Matrix<T>& L, const std::vector<int>& N,
148 const std::vector<int>& n) {
149 const T zero = num_traits<T>::from_int(0);
150 for (std::size_t i = 0; i < L.rows(); ++i) {
151 if (n[i] == 0) continue;
152 for (std::size_t r = 0; r < L.cols(); ++r)
153 if (N[r] > 0 && !(L(i, r) > zero)) return std::make_pair(i + 1, r + 1);
154 }
155 return std::make_pair(static_cast<std::size_t>(0), static_cast<std::size_t>(0));
156}
157
158} // namespace jointmargdetail
159
160/**
161 * @brief Joint probability of the per-station TOTAL queue lengths.
162 *
163 * @param n (M) per-station total queue lengths, infinite servers included
164 * @param L (M x R) demand matrix, infinite-server rows included
165 * @param N (R) per-class populations
166 * @param infset rows of L that are infinite-server stations, 0-based
167 * @param G the normalizing constant G(N)
168 * @param engine "exact" (default), "spm", "bethe", "heur", "huberlaw" or
169 * "adapart". "spm" is the only engine that does NOT expand the
170 * matrix to order sum(N): it takes the row-replicated matrix with
171 * the class populations as column multiplicities, which is the
172 * regime its saddle-point expansion is asymptotically exact in, so
173 * its cost does not grow with the population and its relative error
174 * is O((R-1)/min(N)). Measured on a 3-station 2-class model, 12.8%
175 * at N = (1,1), 4.2% at (3,3), 2.1% at (6,6); it degrades the other
176 * way round, when the CLASS COUNT grows at fixed population (2.7%
177 * at R = 2, 21% at R = 7, both at N_r = 3), because R-1 is the
178 * dimension being expanded in. The bias is nearly constant across
179 * the lattice, so a caller that renormalizes a full sweep keeps far
180 * less of it: total variation distance 5.0e-3 at N = (1,1), 8.4e-4
181 * at (3,3), 4.3e-4 at (5,5), better than "bethe" and "heur" at
182 * every population measured.
183 * @param seed seed of the two sampling engines
184 */
185template <class T>
186T pfqn_jointmarg(const std::vector<int>& n, const Matrix<T>& L, const std::vector<int>& N,
187 const std::vector<std::size_t>& infset, const T& G,
188 const std::string& engine = "exact", std::uint64_t seed = 0) {
189 const std::size_t M = L.rows(), R = L.cols();
190 if (N.size() != R) throw InputError("pfqn_jointmarg: L and N disagree on the class count");
191 if (n.size() != M) throw InputError("pfqn_jointmarg: the occupancy vector has the wrong length");
192 const T zero = num_traits<T>::from_int(0);
193 if (G == zero) throw NumericError("pfqn_jointmarg: the normalizing constant is zero");
194 for (std::size_t k = 0; k < infset.size(); ++k)
195 if (infset[k] >= M) throw InputError("pfqn_jointmarg: infset indexes a station outside L");
196
197 long Ntot = 0, ntot = 0;
198 for (int v : N) {
199 if (v < 0) throw InputError("pfqn_jointmarg: negative population");
200 Ntot += v;
201 }
202 for (int v : n) {
203 if (v < 0) throw InputError("pfqn_jointmarg: negative occupancy");
204 ntot += v;
205 }
206 // Infeasible occupancies are not an error: the caller sweeps a lattice.
207 if (ntot != Ntot) return zero;
208 if (Ntot == 0) return num_traits<T>::from_int(1) / G;
209
210 const std::string eng = jointmargdetail::lower(engine);
211 const Matrix<T> A = jointmargdetail::replicate_rows(L, n);
212
213 T F;
214 if (eng == "exact") {
215 F = pfqn_perm(A, N);
216 } else {
217 const std::pair<std::size_t, std::size_t> z = jointmargdetail::first_zero(L, N, n);
218 if (z.first != 0)
219 throw InputError("pfqn_jointmarg: the '" + eng +
220 "' permanent engine cannot be applied: the demand of class " +
221 std::to_string(z.second) + " at station " + std::to_string(z.first) +
222 " is zero, so the replicated matrix has no full support. "
223 "Use engine 'exact'.");
224 double f;
225 if (eng == "spm") {
226 // Never the expanded matrix: the saddle point is asymptotic in the
227 // column multiplicities, which are the class populations themselves.
228 std::vector<std::size_t> mult;
229 const Matrix<double> Ar = jointmargdetail::rows_to_double(A, N, &mult);
230 f = perm::perm_spm(Ar, mult);
231 } else {
232 const Matrix<double> Ad = jointmargdetail::expand_to_double(A, N);
233 if (eng == "bethe") {
234 f = perm::perm_bethe(Ad);
235 } else if (eng == "heur") {
236 f = perm::perm_heur(Ad);
237 } else if (eng == "huberlaw") {
238 f = perm::perm_huberlaw(Ad, seed);
239 } else if (eng == "adapart") {
240 f = perm::perm_adapart(Ad, seed);
241 } else {
242 throw InputError("pfqn_jointmarg: unrecognized permanent engine '" + engine +
243 "'. Use exact, spm, bethe, heur, huberlaw or adapart.");
244 }
245 }
247 }
248
249 for (int v : N) F /= num_factorial<T>(static_cast<unsigned>(v));
250 // Every infinite server keeps its own row, so every one of them divides by
251 // its own n_j!; the queueing stations do not.
252 for (std::size_t k = 0; k < infset.size(); ++k)
253 F /= num_factorial<T>(static_cast<unsigned>(n[infset[k]]));
254 return F / G;
255}
256
257/** Overload computing G with pfqn_ca first, matching the reference's default. */
258template <class T>
259T pfqn_jointmarg(const std::vector<int>& n, const Matrix<T>& L, const std::vector<int>& N,
260 const std::vector<std::size_t>& infset, const std::string& engine = "exact",
261 std::uint64_t seed = 0) {
262 const std::size_t M = L.rows(), R = L.cols();
263 // G does not depend on how the delay stations are split: they aggregate by
264 // the multinomial theorem, so the constant may be taken with the
265 // infinite-server rows summed into the think time.
266 std::vector<bool> isinf(M, false);
267 for (std::size_t k = 0; k < infset.size(); ++k)
268 if (infset[k] < M) isinf[infset[k]] = true;
269 std::size_t nq = 0;
270 for (std::size_t i = 0; i < M; ++i)
271 if (!isinf[i]) ++nq;
272 Matrix<T> Lq(nq, R);
273 std::size_t a = 0;
274 for (std::size_t i = 0; i < M; ++i) {
275 if (isinf[i]) continue;
276 for (std::size_t r = 0; r < R; ++r) Lq(a, r) = L(i, r);
277 ++a;
278 }
279 Matrix<T> Z;
280 if (!infset.empty()) {
282 for (std::size_t k = 0; k < infset.size(); ++k)
283 for (std::size_t r = 0; r < R; ++r) Z(0, r) += L(infset[k], r);
284 }
285 return pfqn_jointmarg(n, L, N, infset, pfqn_ca(Lq, N, Z).G, engine, seed);
286}
287
288} // namespace pfqn
289} // namespace line
290
291#endif // LINE_API_PFQN_JOINTMARG_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
NumericError(const std::string &what)
Definition error.h:45
The exception types the port throws.
Dense matrix and non-owning view.
double perm_spm(const Matrix< double > &a, double tolerance=1e-11, std::size_t max_iterations=10000)
The saddle-point estimate of the permanent of a square strictly positive matrix.
double perm_heur(const Matrix< double > &m, double tolerance=1e-10, std::size_t max_iterations=1000)
The Sinkhorn heuristic.
double perm_bethe(const Matrix< double > &m, double epsilon=0.001, std::size_t max_iteration=200000)
The Bethe permanent, by sum-product message passing.
double perm_adapart(const Matrix< double > &m, std::uint64_t seed=0)
AdaPart estimate of the permanent, with the reference defaults.
double perm_huberlaw(const Matrix< double > &m, std::uint64_t seed=0)
Huber-Law estimate of the permanent, with the reference defaults.
NcResult< T > pfqn_ca(const Matrix< T > &L, const std::vector< int > &N, const Matrix< T > &Z)
Convolution algorithm for the exact normalizing constant of a closed product-form network (Buzen 1973...
Definition pfqn_ca.h:120
T pfqn_jointmarg(const std::vector< int > &n, const Matrix< T > &L, const std::vector< int > &N, const std::vector< std::size_t > &infset, const T &G, const std::string &engine="exact", std::uint64_t seed=0)
Joint probability of the per-station TOTAL queue lengths.
T pfqn_perm(const Matrix< T > &A, const std::vector< int > &m)
Permanent of a matrix with repeated columns, by Ryser's formula.
Definition pfqn_perm.h:52
T num_factorial(unsigned n)
Factorial as a value of T.
Definition number.h:184
Number-type abstraction for the templated API port.
APPROXIMATE permanents: the Sinkhorn heuristic, the Bethe estimate and the saddle-point expansion.
RANDOMIZED permanents: the AdaPart rejection sampler and the Huber-Law acceptance-rejection importanc...
Convolution algorithm for the exact normalizing constant of a closed product-form network (Buzen 1973...
Permanent of a matrix with repeated columns, by Ryser's formula.