1function [stateSpace,stateSpaceAggr,stateSpaceHashed,Dfilt,sn,basBlockQ] = solver_ctmc_fcr_waitq(sn, options)
2% [SS,SSA,SSH,DFILT,SN,BASBLOCKQ]=SOLVER_CTMC_FCR_WAITQ(SN,OPTIONS)
3% Reachability-based state space and per-action rate filters
for models with
4% a finite capacity region (FCR) whose drop rule
is WAITQ (waiting queue).
6% JMT WAITQ semantics (reference, mirrored by LDES): a job refused entry to a
7% full region leaves the upstream station and waits in a per-region FIFO of
8% (class, destination) tokens outside the region; after every transition that
9% frees region capacity, tokens are released strictly in FIFO order (head-of-
10% line: a stuck head blocks the queue) as long as the admission constraints
11% (global cap, per-class caps, memory budget, linear constraints A*x<=b)
12% permit; a fresh arrival that satisfies the constraints
is admitted even
if
13% the FIFO
is non-empty (it overtakes a head stuck on a different constraint).
14% Blocked jobs are counted neither in the region occupancy nor in any station
15% state, so station QLen excludes them, matching the JMT report convention.
17% True-BAS blocking between two stations
is orthogonal to the region rule and
18%
is handled here as it
is in the
default generator: when a service completion
19% at a BAS blocking station finds its destination unable to admit the job, the
20% job
is HELD AT THE SERVER (blocked marker set) rather than the departure
21% being voided. Voiding it instead frees the server to re-serve the same job,
22% which
for exponential service
is NOT equivalent to BAS: on release the held
23% job enters the destination immediately, whereas a re-serving server must
24% first draw a fresh completion, and throughput
is understated. Those
25% become-blocked arcs change the chain but are not departures, so they are
26% accumulated separately in BASBLOCKQ and never enter DFILT.
28% The CTMC state
is augmented as [h(1:nstateful), buf_1, ..., buf_F] where h
29% are the per-node hashed states and buf_f
is the token FIFO of region f,
30% padded with zeros to its maximum length. Classes whose region rule
is DROP
31% keep the transition-censoring behavior of the default generator (exact for
32% memoryless sources, cross-validated against JMT).
34% Copyright (c) 2012-2026, Imperial College London
37nstateful = sn.nstateful;
44%% feature gates: combinations that would need semantics not defined here
45if isfield(sn,
'gsync') && ~isempty(sn.gsync)
46 line_error(mfilename,'WAITQ finite capacity regions are not supported together with stochastic Petri net transitions in SolverCTMC.');
48if isfield(sn,'fjsync') && ~isempty(sn.fjsync)
49 line_error(mfilename,'WAITQ finite capacity regions are not supported together with fork-join in SolverCTMC.');
51if any(sn.isstatedep(:,3))
52 line_error(mfilename,'WAITQ finite capacity regions are not supported together with state-dependent routing in SolverCTMC.');
57memberMask = false(F, sn.nstations); % (f,ist) true if station ist in region f
58ccap = inf(F, K); % per-class caps
59gcap = inf(F, 1); % global job cap
60memcap = inf(F, 1); % global memory budget
61szrow = ones(F, K); % per-class memory footprint
62linA = cell(F,1); linb = cell(F,1);
63iswaitq = false(F, K); % rule per (region, class): true=WAITQ-like, false=DROP
65 Rmat = sn.region{f}; % M x (K+1)
66 % membership: a station
is a member
if any job-count cap OR the region
67 % memory budget
is set on its row (a memory-only region has all job-count
69 memvec = -ones(sn.nstations,1);
70 if isfield(sn,'regionmaxmem') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
71 memvec = sn.regionmaxmem{f}(:);
73 members = find(any(Rmat ~= -1, 2) | memvec ~= -1)
';
74 memberMask(f, members) = true;
76 cv = Rmat(members, r); cv = cv(cv ~= -1);
77 if ~isempty(cv); ccap(f,r) = min(cv); end
78 iswaitq(f,r) = (sn.regionrule(f,r) ~= DropStrategy.DROP);
80 gv = Rmat(members, K+1); gv = gv(gv ~= -1);
81 if ~isempty(gv); gcap(f) = min(gv); end
82 if isfield(sn,'regionmaxmem
') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
83 mv = sn.regionmaxmem{f}(members); mv = mv(mv ~= -1);
84 if ~isempty(mv); memcap(f) = min(mv); end
86 if isfield(sn,'regionsz
') && ~isempty(sn.regionsz)
87 szrow(f,:) = sn.regionsz(f,:);
89 if isfield(sn,'regionlincon
') && size(sn.regionlincon,1) >= f && ~isempty(sn.regionlincon{f,1})
90 linA{f} = sn.regionlincon{f,1};
91 linb{f} = sn.regionlincon{f,2};
95% token FIFO length bound per region: at most all closed jobs of WAITQ classes
96% plus, per open WAITQ class, the state-space cutoff of that class
97if isfield(options,'cutoff
') && ~isempty(options.cutoff)
98 cutoffMat = options.cutoff;
99 if isscalar(cutoffMat)
100 cutoffMat = cutoffMat * ones(sn.nstations, K);
103 cutoffMat = zeros(sn.nstations, K);
105tokbound = zeros(1,K);
108 c_ = find(sn.chains(:,r), 1); % chain of class r
109 chainpop = sum(sn.njobs(sn.chains(c_,:)));
110 if isfinite(chainpop)
111 % closed chain: jobs may switch into class r, so bound by the
112 % whole chain population rather than njobs(r)
113 tokbound(r) = chainpop;
115 tokbound(r) = max(cutoffMat(:,r));
121 Lmax(f) = sum(tokbound(iswaitq(f,:)));
123bufoff = nstateful + [0; cumsum(Lmax(1:end-1))]; % column offset of buf_f
124width = nstateful + sum(Lmax);
126%% initial augmented state (buffers empty)
127h0 = zeros(1, nstateful);
129 if sn.isstateful(ind)
130 isf = sn.nodeToStateful(ind);
131 if sn.nodetype(ind) == NodeType.Source
132 % canonical Source state (spaceGenerator convention); the stored
133 % sn.state row may omit the Inf pool marker or the arrival phase
134 hh_ = State.getHash(sn, ind, State.fromMarginal(sn, ind, []));
137 h0(isf) = State.getHash(sn, ind, sn.state{isf}(1,:));
140 line_error(mfilename, sprintf('Initial state of node %s not found in its local state space.
', sn.nodenames{ind}));
144row0 = zeros(1, width);
145row0(1:nstateful) = h0;
147 if any(regionAggr(h0, f) > ccap(f,:)) || violates(f, regionAggr(h0, f))
148 line_error(mfilename,'The initial state violates the finite capacity region constraints.
');
152%% breadth-first construction of the reachable augmented space
154SSH = zeros(capRows, width);
157keymap = containers.Map(rowkey(row0), 1);
159% transition triplets per action
161ta = zeros(capTrip,1); ti = zeros(capTrip,1); tj = zeros(capTrip,1); tv = zeros(capTrip,1);
164while ~isempty(frontier)
165 s = frontier(1); frontier(1) = [];
167 h = row(1:nstateful);
170 bf = row(bufoff(f)+1:bufoff(f)+Lmax(f));
171 bufs{f} = bf(bf > 0);
173 % current per-region aggregate populations
176 xf(f,:) = regionAggr(h, f);
179 node_a = sync{a}.active{1}.node;
180 isf_a = sn.nodeToStateful(node_a);
181 class_a = sync{a}.active{1}.class;
182 event_a = sync{a}.active{1}.event;
183 [new_state_a, rate_a] = State.afterEventHashed(sn, node_a, h(isf_a), event_a, class_a);
184 if isequal(new_state_a, -1)
187 for ia = 1:length(new_state_a)
188 if isnan(rate_a(ia)) || rate_a(ia) <= 0 || new_state_a(ia) == -1
191 node_p = sync{a}.passive{1}.node;
194 newh(isf_a) = new_state_a(ia);
195 emit(a, s, newh, bufs, rate_a(ia));
197 class_p = sync{a}.passive{1}.class;
198 event_p = sync{a}.passive{1}.event;
199 isf_p = sn.nodeToStateful(node_p);
200 % region-entry detection: passive station inside region f,
201 % active node outside it, and the passive event is an arrival
203 if node_a <= sn.nnodes && sn.isstation(node_a)
204 stat_a = sn.nodeToStation(node_a);
207 if sn.isstation(node_p)
208 stat_p = sn.nodeToStation(node_p);
212 if event_p == EventType.ARV && stat_p > 0
214 if memberMask(f, stat_p) && (stat_a <= 0 || ~memberMask(f, stat_a))
216 xn(class_p) = xn(class_p) + 1;
218 if ~iswaitq(f, class_p)
219 droppedf = f; % DROP rule: the job is destroyed
229 % DROP rule (JMT semantics): the refused job is destroyed;
230 % only the active (departing) part of the transition applies
232 newh(isf_a) = new_state_a(ia);
233 emit(a, s, newh, bufs, rate_a(ia) * sync{a}.passive{1}.prob);
236 % see _kb/06-solver-catalog.md (CTMC section) for rationale
238 if blockedf == 0 && event_p == EventType.ARV && class_p ~= class_a ...
239 && stat_a > 0 && stat_p > 0
241 if memberMask(f, stat_a) && memberMask(f, stat_p)
249 newh(isf_a) = new_state_a(ia);
250 emit(a, s, newh, bufs, rate_a(ia) * sync{a}.passive{1}.prob, ...
251 [switchf, class_p, node_p, iswaitq(switchf, class_p)]);
255 if numel(bufs{blockedf}) >= Lmax(blockedf)
256 continue % FIFO truncation boundary (open-class cutoff)
259 newh(isf_a) = new_state_a(ia);
261 newbufs{blockedf}(end+1) = (node_p-1)*K + class_p;
262 emit(a, s, newh, newbufs, rate_a(ia) * sync{a}.passive{1}.prob);
264 if node_p == node_a % self-loop
265 [new_state_p, ~, outprob_p] = State.afterEventHashed(sn, node_p, new_state_a(ia), event_p, class_p);
267 [new_state_p, ~, outprob_p] = State.afterEventHashed(sn, node_p, h(isf_p), event_p, class_p);
269 if isempty(new_state_p) || isequal(new_state_p, -1)
270 % see _kb/06-solver-catalog.md (True BAS blocking) for rationale
271 if event_a == EventType.DEP && ~isempty(sn.isbasblocking) ...
272 && numel(sn.isbasblocking) >= node_a && sn.isbasblocking(node_a) == 1
273 curVecA = sn.space{isf_a}(h(isf_a),:);
275 blockedVec = curVecA;
277 blockedIdx = matchrow(sn.space{isf_a}, blockedVec);
280 newh(isf_a) = blockedIdx;
281 emit(0, s, newh, bufs, rate_a(ia) * sync{a}.passive{1}.prob);
287 for ip = 1:size(new_state_p,1)
288 if new_state_p(ip) == -1
291 prob_sync_p = sync{a}.passive{1}.prob * outprob_p(ip);
295 if node_p < local && ~csmask(class_a, class_p) && rate_a(ia) * prob_sync_p > 0 && (sn.nodetype(node_p) ~= NodeType.Source)
296 line_error(mfilename, sprintf('Error: routing at node %d (%s) violates the
class switching mask (class %s ->
class %s).
', node_a, sn.nodenames{node_a}, sn.classnames{class_a}, sn.classnames{class_p}));
299 newh(isf_a) = new_state_a(ia);
300 newh(isf_p) = new_state_p(ip);
301 emit(a, s, newh, bufs, rate_a(ia) * prob_sync_p);
314 sel = (ta(1:ntrip) == a);
315 Dfilt{a} = sparse(ti(sel), tj(sel), tv(sel), nrows, nrows);
317% Sentinel action 0 collects the true-BAS become-blocked arcs: part of the
318% generator, but not a departure of any action, so kept out of Dfilt.
319selBas = (ta(1:ntrip) == 0);
320basBlockQ = sparse(ti(selBas), tj(selBas), tv(selBas), nrows, nrows);
321stateSpaceHashed = SSH;
322% full state matrix: concatenated per-node states plus the token buffers
325 cols = cols + size(sn.space{isf},2);
327stateSpace = zeros(nrows, cols + sum(Lmax));
328stateSpaceAggr = zeros(nrows, sn.nstations * K);
331 for ind = 1:sn.nnodes
332 if sn.isstateful(ind)
333 isf = sn.nodeToStateful(ind);
334 srow = sn.space{isf}(SSH(s,isf),:);
335 stateSpace(s, pos+1:pos+length(srow)) = srow;
336 pos = pos + size(sn.space{isf},2);
338 ist = sn.nodeToStation(ind);
339 [~, nir] = State.toMarginal(sn, ind, srow);
340 stateSpaceAggr(s, ((ist-1)*K+1):ist*K) = nir;
344 stateSpace(s, cols+1:end) = SSH(s, nstateful+1:end);
347 function tf = violates(f, x)
348 % TF=VIOLATES(F,X) true if per-class population vector x breaks any
349 % admission constraint of region f
350 tf = any(x > ccap(f,:)) || sum(x) > gcap(f) || (x * szrow(f,:)') > memcap(f);
351 if ~tf && ~isempty(linA{f})
352 tf = any(linA{f} * x(:) > linb{f}(:));
356 function x = regionAggr(hvec, f)
357 % X=REGIONAGGR(HVEC,F) per-
class population of region f under hashed
358 % station states hvec
360 for ist_ = find(memberMask(f,:))
361 ind_ = sn.stationToNode(ist_);
362 isf_ = sn.nodeToStateful(ind_);
363 [~, nir_] = State.toMarginalAggr(sn, ind_, sn.space{isf_}(hvec(isf_),:));
368 function emit(a, src, newh, newbufs, w, pend)
369 % EMIT(A,SRC,NEWH,NEWBUFS,W,PEND) applies the FIFO release cascade to
370 % the tentative augmented state and records the transitions of action
371 % a. PEND = [f cls destNode] is a pending gated re-entry (a class-
372 % switching hop between members of region f): once the cascade
373 % settles, the job of class cls is admitted at destNode if region f
374 % has capacity, else parked at the tail of the FIFO.
381 % work items: {h, bufs, prob, pend}
382 work = {{newh, newbufs, 1.0, pend}};
384 it = work{1}; work(1) = [];
385 hh = it{1}; bb = it{2}; pw = it{3}; pd = it{4};
391 x_ = regionAggr(hh, f_);
393 dest = floor((tok-1)/K) + 1;
394 r_ = mod(tok-1, K) + 1;
396 xn_(r_) = xn_(r_) + 1;
398 continue % head-of-line: this region's FIFO stays blocked
400 isf_d = sn.nodeToStateful(dest);
401 [hd, ~, opd] = State.afterEventHashed(sn, dest, hh(isf_d), EventType.ARV, r_);
402 if isempty(hd) || isequal(hd, -1)
405 for
id = 1:length(hd)
406 if hd(
id) == -1 || opd(
id) <= 0
413 work{end+1} = {hh2, bb2, pw * opd(
id), pd}; %#ok<AGROW>
418 if ~progressed && ~isempty(pd)
419 % cascade settled: resolve the pending gated re-entry
420 f_ = pd(1); cls_ = pd(2); dest_ = pd(3);
421 x_ = regionAggr(hh, f_);
423 xn_(cls_) = xn_(cls_) + 1;
425 if numel(pd) >= 4 && ~pd(4)
426 % DROP rule: the switching job
is destroyed
427 work{end+1} = {hh, bb, pw, []}; %#ok<AGROW>
429 % no capacity: park at the tail of the region FIFO
431 bb2{f_}(end+1) = (dest_-1)*K + cls_;
432 work{end+1} = {hh, bb2, pw, []}; %#ok<AGROW>
435 isf_d = sn.nodeToStateful(dest_);
436 [hd, ~, opd] = State.afterEventHashed(sn, dest_, hh(isf_d), EventType.ARV, cls_);
438 if ~isempty(hd) && ~isequal(hd, -1)
439 for
id = 1:length(hd)
440 if hd(
id) == -1 || opd(
id) <= 0
445 work{end+1} = {hh2, bb, pw * opd(
id), []}; %#ok<AGROW>
450 % destination local state missing (e.g. station cap):
451 % park in the FIFO instead
453 bb2{f_}(end+1) = (dest_-1)*K + cls_;
454 work{end+1} = {hh, bb2, pw, []}; %#ok<AGROW>
460 % settled:
register the augmented state and the transition
461 rr = zeros(1, width);
462 rr(1:nstateful) = hh;
464 rr(bufoff(f_)+1:bufoff(f_)+numel(bb{f_})) = bb{f_};
471 if nrows > size(SSH,1)
472 SSH = [SSH; zeros(size(SSH,1), width)]; %
#ok<AGROW>
477 frontier(end+1) = nrows; %#ok<AGROW>
481 ta = [ta; zeros(numel(ta),1)]; %#ok<AGROW>
482 ti = [ti; zeros(numel(ti),1)]; %#ok<AGROW>
483 tj = [tj; zeros(numel(tj),1)]; %#ok<AGROW>
484 tv = [tv; zeros(numel(tv),1)]; %#ok<AGROW>
486 ta(ntrip) = a; ti(ntrip) = src; tj(ntrip) = dst; tv(ntrip) = w * pw;
493function k = rowkey(v)
494% K=ROWKEY(V) character key
for an augmented state row
495k = sprintf(
'%d,', v);