most-queue 2.8__py3-none-any.whl → 2.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. most_queue/__init__.py +7 -1
  2. most_queue/io/serving.py +258 -0
  3. most_queue/random/feature_service.py +175 -0
  4. most_queue/random/map_fit.py +137 -0
  5. most_queue/random/map_ph.py +48 -0
  6. most_queue/random/marked_arrivals.py +127 -0
  7. most_queue/random/queue_selection.py +47 -0
  8. most_queue/random/service_calibration.py +91 -0
  9. most_queue/random/trace_resampling.py +145 -0
  10. most_queue/random/utils/fit.py +20 -7
  11. most_queue/sim/accumulating_priority.py +118 -0
  12. most_queue/sim/admission_control.py +90 -0
  13. most_queue/sim/aoi.py +133 -0
  14. most_queue/sim/base.py +4 -2
  15. most_queue/sim/base_core.py +40 -3
  16. most_queue/sim/bmap.py +130 -0
  17. most_queue/sim/bulk_map_finite.py +191 -0
  18. most_queue/sim/bulk_service.py +95 -0
  19. most_queue/sim/decaying_value.py +188 -0
  20. most_queue/sim/delay_dependent.py +115 -0
  21. most_queue/sim/dynamic_batching.py +236 -0
  22. most_queue/sim/edf.py +162 -0
  23. most_queue/sim/heterogeneous_bulk_service.py +237 -0
  24. most_queue/sim/imprecise.py +258 -0
  25. most_queue/sim/inventory.py +687 -0
  26. most_queue/sim/kv_cache_congestion.py +226 -0
  27. most_queue/sim/kv_cache_stability.py +219 -0
  28. most_queue/sim/load_balancing.py +98 -0
  29. most_queue/sim/msj.py +117 -0
  30. most_queue/sim/msj_checkpoint.py +273 -0
  31. most_queue/sim/msj_general.py +535 -0
  32. most_queue/sim/msj_lifecycle.py +355 -0
  33. most_queue/sim/negative.py +13 -16
  34. most_queue/sim/networks/closed_network.py +147 -0
  35. most_queue/sim/networks/fork_join_network.py +167 -0
  36. most_queue/sim/networks/network.py +12 -2
  37. most_queue/sim/networks/tandem_blocking.py +142 -0
  38. most_queue/sim/networks/time_varying_network.py +124 -0
  39. most_queue/sim/polling.py +87 -0
  40. most_queue/sim/prefill_decode.py +234 -0
  41. most_queue/sim/priority.py +35 -0
  42. most_queue/sim/priority_heterogeneous.py +89 -0
  43. most_queue/sim/priority_impatience.py +105 -0
  44. most_queue/sim/reliability.py +413 -0
  45. most_queue/sim/retrial_priority.py +104 -0
  46. most_queue/sim/smdp_batching.py +170 -0
  47. most_queue/sim/time_varying.py +69 -0
  48. most_queue/sim/token_batching.py +181 -0
  49. most_queue/sim/utils/acme_trace.py +204 -0
  50. most_queue/sim/utils/llm_trace.py +101 -0
  51. most_queue/sim/utils/msj_calendar.py +66 -0
  52. most_queue/sim/utils/msj_capacity_calendar.py +104 -0
  53. most_queue/sim/utils/msj_packing.py +98 -0
  54. most_queue/sim/utils/residual_runtime.py +145 -0
  55. most_queue/sim/utils/resource_envelope.py +135 -0
  56. most_queue/sim/utils/runtime_prediction.py +239 -0
  57. most_queue/sim/utils/workload_lifecycle.py +101 -0
  58. most_queue/sim/utils/workload_trace.py +172 -0
  59. most_queue/sim/value_scheduling.py +439 -0
  60. most_queue/structs.py +465 -1
  61. most_queue/theory/__init__.py +2 -0
  62. most_queue/theory/admission_control/__init__.py +14 -0
  63. most_queue/theory/admission_control/mm1_deadline_admission.py +204 -0
  64. most_queue/theory/aoi/__init__.py +5 -0
  65. most_queue/theory/aoi/calc.py +114 -0
  66. most_queue/theory/batch/__init__.py +9 -0
  67. most_queue/theory/batch/_idle_refill.py +240 -0
  68. most_queue/theory/batch/_tagged_batch.py +203 -0
  69. most_queue/theory/batch/_tagged_boundary.py +76 -0
  70. most_queue/theory/batch/bulk_service.py +381 -0
  71. most_queue/theory/batch/bulk_service_erlang.py +401 -0
  72. most_queue/theory/batch/bulk_service_general.py +79 -0
  73. most_queue/theory/batch/bulk_service_h2.py +546 -0
  74. most_queue/theory/batch/bulk_service_multiserver.py +697 -0
  75. most_queue/theory/batch/callback_batching.py +125 -0
  76. most_queue/theory/batch/dynamic_batching.py +396 -0
  77. most_queue/theory/batch/heterogeneous_bulk_service.py +369 -0
  78. most_queue/theory/batch/map_ph_finite_buffer.py +695 -0
  79. most_queue/theory/batch/mm1.py +3 -2
  80. most_queue/theory/batch/smdp_batching.py +252 -0
  81. most_queue/theory/batch/token_batching.py +321 -0
  82. most_queue/theory/batch/token_service.py +276 -0
  83. most_queue/theory/closed/__init__.py +7 -0
  84. most_queue/theory/continuous_batching/__init__.py +42 -0
  85. most_queue/theory/continuous_batching/core_aware_ttft.py +218 -0
  86. most_queue/theory/continuous_batching/infinite_stage_aware_ttft.py +255 -0
  87. most_queue/theory/continuous_batching/kv_cache_congestion.py +241 -0
  88. most_queue/theory/continuous_batching/kv_cache_stability.py +151 -0
  89. most_queue/theory/continuous_batching/occupancy_dependent.py +183 -0
  90. most_queue/theory/continuous_batching/occupancy_dependent_h2.py +272 -0
  91. most_queue/theory/continuous_batching/phase_aware_stability.py +261 -0
  92. most_queue/theory/continuous_batching/phase_aware_ttft.py +157 -0
  93. most_queue/theory/continuous_batching/prefill_decode.py +211 -0
  94. most_queue/theory/continuous_batching/reduced_stage_aware_ttft.py +182 -0
  95. most_queue/theory/continuous_batching/serving_calibration.py +147 -0
  96. most_queue/theory/continuous_batching/stage_aware_ttft.py +245 -0
  97. most_queue/theory/continuous_batching/transient_occupancy.py +204 -0
  98. most_queue/theory/decaying_value/__init__.py +22 -0
  99. most_queue/theory/decaying_value/service_rate_control.py +419 -0
  100. most_queue/theory/delay_dependent/__init__.py +13 -0
  101. most_queue/theory/delay_dependent/mmc_threshold_service.py +494 -0
  102. most_queue/theory/fifo/__init__.py +14 -0
  103. most_queue/theory/fifo/_ps_sojourn.py +244 -0
  104. most_queue/theory/fifo/gi_m2_state_dependent.py +307 -0
  105. most_queue/theory/fifo/gi_m_1.py +10 -13
  106. most_queue/theory/fifo/mg1_ps.py +188 -7
  107. most_queue/theory/fifo/mgn_ps.py +140 -0
  108. most_queue/theory/fifo/mgn_takahasi.py +34 -1
  109. most_queue/theory/fifo/mm1_threshold_rate.py +299 -0
  110. most_queue/theory/fork_join/__init__.py +13 -0
  111. most_queue/theory/fork_join/dag.py +135 -0
  112. most_queue/theory/fork_join/split_join.py +46 -9
  113. most_queue/theory/impatience/__init__.py +11 -0
  114. most_queue/theory/imprecise/__init__.py +26 -0
  115. most_queue/theory/imprecise/offline.py +345 -0
  116. most_queue/theory/inventory/__init__.py +26 -0
  117. most_queue/theory/inventory/_wait_phase.py +431 -0
  118. most_queue/theory/inventory/mm1_inventory.py +285 -0
  119. most_queue/theory/inventory/mm1_inventory_erlang_replenishment.py +272 -0
  120. most_queue/theory/inventory/mm2_heterogeneous_inventory.py +294 -0
  121. most_queue/theory/inventory/mmc_heterogeneous_erlang_inventory.py +399 -0
  122. most_queue/theory/inventory/mmc_heterogeneous_h2_inventory.py +394 -0
  123. most_queue/theory/inventory/mmc_heterogeneous_inventory.py +348 -0
  124. most_queue/theory/inventory/mmc_inventory.py +351 -0
  125. most_queue/theory/load_balancing.py +99 -0
  126. most_queue/theory/matrix/__init__.py +16 -1
  127. most_queue/theory/matrix/bmap_m1.py +163 -0
  128. most_queue/theory/matrix/bmap_ph1.py +180 -0
  129. most_queue/theory/matrix/map_mmc.py +190 -0
  130. most_queue/theory/matrix/map_phc.py +278 -0
  131. most_queue/theory/msj/__init__.py +8 -0
  132. most_queue/theory/msj/exact.py +123 -0
  133. most_queue/theory/msj/ph.py +285 -0
  134. most_queue/theory/msj/saturated.py +138 -0
  135. most_queue/theory/negative/__init__.py +15 -0
  136. most_queue/theory/negative/mgn_disaster.py +37 -58
  137. most_queue/theory/negative/mgn_rcs.py +36 -202
  138. most_queue/theory/networks/__init__.py +28 -2
  139. most_queue/theory/networks/bcmp_network.py +283 -0
  140. most_queue/theory/networks/blocking.py +153 -0
  141. most_queue/theory/networks/closed_network.py +270 -0
  142. most_queue/theory/networks/fork_join_network.py +115 -0
  143. most_queue/theory/networks/g_network.py +259 -0
  144. most_queue/theory/networks/jackson_network.py +116 -0
  145. most_queue/theory/networks/qna.py +185 -0
  146. most_queue/theory/networks/time_varying_network.py +94 -0
  147. most_queue/theory/networks/traffic.py +23 -0
  148. most_queue/theory/polling.py +114 -0
  149. most_queue/theory/priority/__init__.py +29 -2
  150. most_queue/theory/priority/accumulating.py +108 -0
  151. most_queue/theory/priority/impatience.py +140 -0
  152. most_queue/theory/priority/map_ph_priority.py +235 -0
  153. most_queue/theory/priority/preemptive/mg1_repeat.py +155 -0
  154. most_queue/theory/priority/preemptive/mm2_3cls_busy_approx.py +9 -12
  155. most_queue/theory/priority/preemptive/mm2_heterogeneous.py +240 -0
  156. most_queue/theory/priority/preemptive/mmk_prty_exact.py +315 -0
  157. most_queue/theory/priority/preemptive/mmn_2cls_pr_busy_approx.py +9 -12
  158. most_queue/theory/priority/preemptive/mph_ph_k_2class.py +227 -0
  159. most_queue/theory/priority/preemptive/rdr_a.py +185 -0
  160. most_queue/theory/priority/retrial_priority.py +132 -0
  161. most_queue/theory/reliability/__init__.py +21 -0
  162. most_queue/theory/reliability/machine_repair.py +104 -0
  163. most_queue/theory/reliability/machine_repair_heterogeneous.py +150 -0
  164. most_queue/theory/reliability/mm1_disaster_repair.py +102 -0
  165. most_queue/theory/reliability/mm1_working_breakdowns.py +108 -0
  166. most_queue/theory/reliability/mmc_breakdowns.py +119 -0
  167. most_queue/theory/reliability/retrial_unreliable.py +110 -0
  168. most_queue/theory/reliability/utils.py +47 -0
  169. most_queue/theory/srpt/__init__.py +3 -0
  170. most_queue/theory/srpt/degradation.py +71 -0
  171. most_queue/theory/srpt/utils/load_below.py +13 -34
  172. most_queue/theory/time_varying.py +123 -0
  173. most_queue/theory/utils/__init__.py +12 -0
  174. most_queue/theory/utils/max_dist.py +236 -0
  175. most_queue/theory/utils/passage_time.py +48 -37
  176. most_queue/theory/utils/restarts.py +90 -14
  177. most_queue/theory/utils/sla.py +146 -0
  178. most_queue/theory/utils/tail.py +46 -0
  179. most_queue/theory/vacations/__init__.py +20 -0
  180. most_queue/theory/value_scheduling/__init__.py +25 -0
  181. most_queue/theory/value_scheduling/offline.py +336 -0
  182. most_queue-2.11.dist-info/METADATA +635 -0
  183. most_queue-2.11.dist-info/RECORD +301 -0
  184. {most_queue-2.8.dist-info → most_queue-2.11.dist-info}/WHEEL +1 -1
  185. most_queue-2.8.dist-info/METADATA +0 -185
  186. most_queue-2.8.dist-info/RECORD +0 -154
  187. {most_queue-2.8.dist-info → most_queue-2.11.dist-info}/licenses/LICENSE +0 -0
most_queue/__init__.py CHANGED
@@ -5,12 +5,18 @@ This package provides both simulation and numerical calculation methods
5
5
  for various types of queueing systems and networks.
6
6
  """
7
7
 
8
- __version__ = "2.7"
8
+ from importlib.metadata import PackageNotFoundError, version
9
9
 
10
10
  # Import main simulation and theory classes for easy access
11
11
  from most_queue.sim.base import QsSim
12
12
  from most_queue.theory.base_queue import BaseQueue
13
13
 
14
+ try:
15
+ # single source of truth for the version: the installed package metadata
16
+ __version__ = version("most-queue")
17
+ except PackageNotFoundError: # running from a source checkout that is not installed
18
+ __version__ = "0.0.0.dev0"
19
+
14
20
  __all__ = [
15
21
  "QsSim",
16
22
  "BaseQueue",
@@ -0,0 +1,258 @@
1
+ """Audited request-level serving measurements and joint TTFT/TPOT SLOs.
2
+
3
+ Reference serializer: vLLM v0.33.0rc3, commit
4
+ 4ba787ec9959e566d3b587a796dff07673324da5, vllm/benchmarks/serve.py.
5
+ TTFT and latency are client measurements; queue_times is CLIENT semaphore
6
+ waiting. Server timings are separate optional measurements, never inferred.
7
+ The SGLang array reader explicitly uses the span of streamed output events
8
+ when full per-request latencies are absent. See docs/serving_measurements.md.
9
+ """
10
+
11
+ import json
12
+ import math
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ServingRequest:
21
+ """One offered request; all durations are seconds and counts are actual.
22
+
23
+ A pending request has an unknown final latency/TPOT. Failed and cancelled
24
+ requests remain in the offered denominator. An absent measurement is None.
25
+ """
26
+
27
+ input_tokens: int
28
+ output_tokens: int
29
+ status: str
30
+ ttft: float | None = None
31
+ latency: float | None = None
32
+ client_queue_time: float | None = None
33
+ server_queue_time: float | None = None
34
+ server_ttft: float | None = None
35
+
36
+ def __post_init__(self):
37
+ if self.status not in {"completed", "failed", "cancelled", "pending"}:
38
+ raise ValueError("unknown request status")
39
+ for count in (self.input_tokens, self.output_tokens):
40
+ if isinstance(count, bool) or not isinstance(count, (int, np.integer)) or count < 0:
41
+ raise ValueError("token counts must be nonnegative integers")
42
+ for value in (self.ttft, self.latency, self.client_queue_time, self.server_queue_time, self.server_ttft):
43
+ if value is not None and (not math.isfinite(value) or value < 0):
44
+ raise ValueError("measured durations must be finite and nonnegative, or None")
45
+ if self.status == "completed":
46
+ if min(self.input_tokens, self.output_tokens) < 1 or self.ttft is None or self.latency is None:
47
+ raise ValueError("completed text requests require positive lengths, TTFT and latency")
48
+ if self.latency < self.ttft:
49
+ raise ValueError("latency cannot precede the first token")
50
+ elif self.latency is not None:
51
+ raise ValueError("only completed requests have a final latency")
52
+
53
+ @property
54
+ def tpot(self):
55
+ """Per-request TPOT, excluding the first token; None for <=1 token."""
56
+ if self.status != "completed" or self.output_tokens <= 1:
57
+ return None
58
+ return (self.latency - self.ttft) / (self.output_tokens - 1)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ServingBenchmark:
63
+ """A finite offered cohort and its explicit measurement-window duration."""
64
+
65
+ requests: tuple[ServingRequest, ...]
66
+ duration: float
67
+ metadata: dict = field(default_factory=dict)
68
+
69
+ def __post_init__(self):
70
+ if not self.requests or not all(isinstance(row, ServingRequest) for row in self.requests):
71
+ raise ValueError("a benchmark requires a nonempty request cohort")
72
+ if not math.isfinite(self.duration) or self.duration <= 0:
73
+ raise ValueError("duration must be finite and positive")
74
+
75
+
76
+ def _read_arrays(source, schema):
77
+ data = json.loads(Path(source).read_text(encoding="utf-8")) if isinstance(source, (str, Path)) else source
78
+ if not isinstance(data, dict):
79
+ raise ValueError("expected a single benchmark JSON object")
80
+ required = ["input_lens", "output_lens", "ttfts", "errors"]
81
+ required += ["latencies"] if schema == "vllm-bench-serve" else ["itls"]
82
+ if any(not isinstance(data.get(key), list) for key in required):
83
+ raise ValueError("detailed per-request arrays are required; aggregate percentiles are insufficient")
84
+ count = len(data["input_lens"])
85
+ if not count or any(len(data[key]) != count for key in required):
86
+ raise ValueError("per-request arrays must be nonempty and aligned")
87
+ optional = ["queue_times", "server_queue_times", "server_ttfts"]
88
+ for key in optional:
89
+ if key in data and (not isinstance(data[key], list) or len(data[key]) != count):
90
+ raise ValueError(f"{key} must align with the request arrays")
91
+ rows = []
92
+ for index in range(count):
93
+ error = data["errors"][index]
94
+ if not isinstance(error, str):
95
+ raise ValueError("each error must be a string")
96
+ completed = error == "" and data["output_lens"][index] > 0
97
+ latency = None
98
+ if completed:
99
+ if schema == "vllm-bench-serve":
100
+ latency = data["latencies"][index]
101
+ else:
102
+ gaps = np.asarray(data["itls"][index], dtype=float)
103
+ if gaps.ndim != 1 or not np.all(np.isfinite(gaps)) or np.any(gaps < 0):
104
+ raise ValueError("stream gaps must be finite nonnegative vectors")
105
+ latency = float(data["ttfts"][index] + gaps.sum())
106
+ timings = {key: data[key][index] if completed and key in data else None for key in optional}
107
+ rows.append(
108
+ ServingRequest(
109
+ input_tokens=data["input_lens"][index],
110
+ output_tokens=data["output_lens"][index],
111
+ status="completed" if completed else "failed",
112
+ ttft=data["ttfts"][index] if completed else None,
113
+ latency=latency,
114
+ client_queue_time=timings["queue_times"],
115
+ server_queue_time=timings["server_queue_times"],
116
+ server_ttft=timings["server_ttfts"],
117
+ )
118
+ )
119
+ successes = sum(row.status == "completed" for row in rows)
120
+ if data.get("completed") != successes or ("failed" in data and data["failed"] != count - successes):
121
+ raise ValueError("aggregate completion/failure counts disagree with detailed outcomes")
122
+ if "num_prompts" in data and data["num_prompts"] != count:
123
+ raise ValueError("num_prompts disagrees with offered cohort; do not silently drop requests")
124
+ metadata = {
125
+ key: data[key]
126
+ for key in ["model_id", "backend", "max_concurrency", "request_rate", "dataset_name"]
127
+ if key in data
128
+ }
129
+ metadata.update(schema=schema, latency_basis="client_latency" if schema == "vllm-bench-serve" else "stream_span")
130
+ if metadata.get("request_rate") == math.inf:
131
+ metadata["request_rate"] = "inf"
132
+ return ServingBenchmark(tuple(rows), data["duration"], metadata)
133
+
134
+
135
+ def read_vllm_benchmark(source, *, measurement_profile=None):
136
+ """Read vLLM detailed JSON; preserve absent server metrics as None.
137
+
138
+ The caller must retain the producer version and hardware provenance. This
139
+ is a schema adapter, not detection of the engine/version from latency data.
140
+ """
141
+ result = _read_arrays(source, "vllm-bench-serve")
142
+ result.metadata["measurement_profile"] = measurement_profile
143
+ return result
144
+
145
+
146
+ def read_sglang_stream_benchmark(source, *, measurement_profile=None):
147
+ """Read detailed streaming arrays with TTFT + sum(ITL) as observed span.
148
+
149
+ The span ends at the last output event, not necessarily HTTP completion.
150
+ ITL entries can contain multiple tokens; TPOT uses actual output_tokens.
151
+ """
152
+ result = _read_arrays(source, "sglang-stream-arrays")
153
+ result.metadata["measurement_profile"] = measurement_profile
154
+ return result
155
+
156
+
157
+ def _quantiles(values):
158
+ if not values:
159
+ return None
160
+ return {
161
+ "count": len(values),
162
+ "mean": float(np.mean(values)),
163
+ **dict(zip(["p50", "p90", "p99"], np.percentile(values, [50, 90, 99]).tolist())),
164
+ }
165
+
166
+
167
+ def summarize_serving_slo(benchmark, *, ttft_limit, tpot_limit):
168
+ """Joint request SLO with all offered outcomes and pending-outcome bounds.
169
+
170
+ Completed one-token replies pass the TPOT condition by the vLLM goodput
171
+ convention but are excluded from TPOT means/quantiles. Pending requests
172
+ are unknown unless their already-observed TTFT violates its limit.
173
+ Counts/quantiles describe this cohort, without an iid confidence claim.
174
+ """
175
+ if any(not math.isfinite(value) or value < 0 for value in (ttft_limit, tpot_limit)):
176
+ raise ValueError("SLO limits must be finite nonnegative seconds")
177
+ counts = {status: 0 for status in ["completed", "failed", "cancelled", "pending"]}
178
+ passed, unknown, single_token = 0, 0, 0
179
+ ttfts, tpots = [], []
180
+ for row in benchmark.requests:
181
+ counts[row.status] += 1
182
+ if row.status == "completed":
183
+ ttfts.append(row.ttft)
184
+ if row.tpot is None:
185
+ single_token += 1
186
+ else:
187
+ tpots.append(row.tpot)
188
+ passed += row.ttft <= ttft_limit and (row.tpot is None or row.tpot <= tpot_limit)
189
+ elif row.status == "pending" and (row.ttft is None or row.ttft <= ttft_limit):
190
+ unknown += 1
191
+ offered = len(benchmark.requests)
192
+ return {
193
+ "offered": offered,
194
+ **counts,
195
+ "single_token_completed": single_token,
196
+ "joint_passed": passed,
197
+ "joint_unknown": unknown,
198
+ "joint_fraction_bounds": [passed / offered, (passed + unknown) / offered],
199
+ "observed_goodput": passed / benchmark.duration,
200
+ "duration": benchmark.duration,
201
+ "ttft_limit": ttft_limit,
202
+ "tpot_limit": tpot_limit,
203
+ "completed_ttft_seconds": _quantiles(ttfts),
204
+ "completed_multitoken_tpot_seconds": _quantiles(tpots),
205
+ "server_queue_observed": sum(row.server_queue_time is not None for row in benchmark.requests),
206
+ "server_ttft_observed": sum(row.server_ttft is not None for row in benchmark.requests),
207
+ "latency_basis": benchmark.metadata.get("latency_basis", "explicit_request_records"),
208
+ "uncertainty": "pending-outcome bounds; no iid confidence interval",
209
+ }
210
+
211
+
212
+ def from_prefill_decode(result, arrivals, inputs, outputs, *, horizon=None):
213
+ """Convert token DES timestamps; optionally censor at a fixed horizon.
214
+
215
+ Future requests are outside the offered cohort. Future completions and
216
+ tokens are hidden rather than counted as successful outcomes at the cut.
217
+ No steady-state or stationarity assertion is made for the drained cohort.
218
+ """
219
+ arrivals = np.asarray(arrivals, dtype=float)
220
+ if arrivals.ndim != 1 or not arrivals.size or not np.all(np.isfinite(arrivals)) or np.any(np.diff(arrivals) < 0):
221
+ raise ValueError("arrivals must be nonempty finite chronological times")
222
+ if any(len(values) != len(arrivals) for values in [inputs, outputs, result.output_times, result.completion_times]):
223
+ raise ValueError("DES arrays must align")
224
+ for lengths in (inputs, outputs):
225
+ values = np.asarray(lengths, dtype=float)
226
+ if not np.all(np.isfinite(values)) or np.any(values <= 0) or np.any(values != np.floor(values)):
227
+ raise ValueError("DES token lengths must be positive integers")
228
+ for arrival, count, stamps, completion in zip(arrivals, outputs, result.output_times, result.completion_times):
229
+ stamps = np.asarray(stamps, dtype=float)
230
+ if stamps.ndim != 1 or stamps.size != count or not np.all(np.isfinite(stamps)):
231
+ raise ValueError("DES token timestamps must align with output lengths and be finite")
232
+ if (
233
+ np.any(np.diff(stamps) < 0)
234
+ or stamps[0] < arrival
235
+ or not math.isfinite(completion)
236
+ or stamps[-1] != completion
237
+ ):
238
+ raise ValueError("DES token timestamps, output lengths and completion must agree")
239
+ end = max(result.completion_times) if horizon is None else horizon
240
+ if not math.isfinite(end) or end <= arrivals[0]:
241
+ raise ValueError("observation horizon must follow the first arrival")
242
+ rows = []
243
+ for index, arrival in enumerate(arrivals):
244
+ if arrival > end:
245
+ break
246
+ tokens = result.output_times[index]
247
+ observed = [stamp for stamp in tokens if stamp <= end]
248
+ completed = result.completion_times[index] <= end
249
+ rows.append(
250
+ ServingRequest(
251
+ input_tokens=int(inputs[index]),
252
+ output_tokens=int(outputs[index]) if completed else len(observed),
253
+ status="completed" if completed else "pending",
254
+ ttft=tokens[0] - arrival if observed else None,
255
+ latency=result.completion_times[index] - arrival if completed else None,
256
+ )
257
+ )
258
+ return ServingBenchmark(tuple(rows), float(end - arrivals[0]), {"latency_basis": "token_DES"})
@@ -0,0 +1,175 @@
1
+ """History-only conditional ECDFs with audited fallback and optional scale ratios.
2
+
3
+ CRPS uses the empirical energy identity, Gneiting & Raftery (2007),
4
+ https://doi.org/10.1198/016214506000001437. It scores a full predictive CDF,
5
+ not a random sample from it. Feature availability must be established by callers.
6
+ """
7
+
8
+ from collections import defaultdict
9
+ from dataclasses import dataclass
10
+ from numbers import Integral, Real
11
+
12
+ import numpy as np
13
+
14
+ from most_queue.random.service_calibration import ServiceCalibration
15
+ from most_queue.random.trace_resampling import ConditionalEmpirical
16
+
17
+
18
+ def _positive(value, name):
19
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real) or not np.isfinite(value) or value <= 0:
20
+ raise ValueError(f"{name} must be positive and finite")
21
+ return float(value)
22
+
23
+
24
+ def _need(value):
25
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral) or value < 1:
26
+ raise ValueError("need must be a positive integer")
27
+ return int(value)
28
+
29
+
30
+ def _context(value):
31
+ if value is not None and (not isinstance(value, str) or not value):
32
+ raise ValueError("context must be a nonempty string or None")
33
+ return value
34
+
35
+
36
+ def request_bucket(request):
37
+ """Fixed right-closed request bins in seconds; unknown is distinct from zero."""
38
+ if request is None:
39
+ return None
40
+ value = _positive(request, "request")
41
+ return str(int(np.searchsorted([300, 1800, 7200, 28800, 86400], value)))
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EmpiricalPrediction:
46
+ """An immutable selected ECDF, positive target scale and audited fit level."""
47
+
48
+ distribution: ServiceCalibration
49
+ scale: float = 1.0
50
+ level: str = "pooled"
51
+
52
+ def __post_init__(self):
53
+ _positive(self.scale, "scale")
54
+ # Check the support before sampling, not only the selected quantile.
55
+ _positive(self.scale * self.distribution.samples[-1], "scaled support")
56
+ _positive(self.scale * self.distribution.samples[0], "scaled support")
57
+
58
+ @property
59
+ def mean(self):
60
+ """Predictive mean in service-time units, not ratio units."""
61
+ return self.scale * self.distribution.mean
62
+
63
+ def quantile(self, probability):
64
+ """Inverse discrete ECDF; no interpolation or truncation at runtime budget."""
65
+ if isinstance(probability, (bool, np.bool_)) or not isinstance(probability, Real):
66
+ raise ValueError("probability must be a finite real in (0,1)")
67
+ return float(self.scale * self.distribution.quantiles("empirical", [probability])[0])
68
+
69
+ def probability_exceeding(self, threshold):
70
+ """Return P(S > threshold), with equality counted as within the budget."""
71
+ threshold = _positive(threshold, "threshold")
72
+ support = np.array(self.distribution.samples) * self.scale
73
+ return float(np.mean(support > threshold))
74
+
75
+ def crps(self, observed):
76
+ """Compute E|X-y| - .5 E|X-X'| exactly for a finite equal-weight ECDF.
77
+
78
+ For sorted support x_i the second term is sum((2i-n-1)x_i)/n².
79
+ Scaling before evaluation keeps score units in seconds for ratio fits.
80
+ """
81
+ observed = _positive(observed, "observed")
82
+ support = np.asarray(self.distribution.samples) * self.scale
83
+ n = len(support)
84
+ dispersion = np.dot((2 * np.arange(1, n + 1) - n - 1) / n, support) / n
85
+ score = float(np.mean(np.abs(support - observed)) - dispersion)
86
+ if not np.isfinite(score):
87
+ raise ValueError("CRPS overflow; rescale observations")
88
+ return max(0.0, score)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class FeatureConditionalEmpirical:
93
+ """Joint context/need cells, then coarse/pooled fallback; fit uses history only.
94
+
95
+ Optional scales fit service/scale on known positive scales, multiplying by
96
+ the supplied target scale at prediction. Unknown target scale or absent
97
+ ratio history falls back to absolute service. No outcome or test S is used.
98
+ """
99
+
100
+ absolute: ConditionalEmpirical
101
+ pooled: ServiceCalibration | None
102
+ coarse: dict
103
+ joint: dict
104
+ exact_joint: dict
105
+ ratio: bool
106
+ audit: dict
107
+
108
+ @classmethod
109
+ def fit(
110
+ cls, needs, services, contexts, *, scales=None, exact=False, minimum=20
111
+ ): # pylint: disable=too-many-arguments
112
+ """Fit context ECDFs with a fixed minimum count and explicit missing scales."""
113
+ needs, contexts = tuple(needs), tuple(contexts)
114
+ if len(contexts) != len(needs):
115
+ raise ValueError("contexts must match needs")
116
+ needs = tuple(_need(value) for value in needs)
117
+ contexts = tuple(_context(value) for value in contexts)
118
+ absolute = ConditionalEmpirical.fit(needs, services, minimum=minimum)
119
+ if not isinstance(exact, bool):
120
+ raise ValueError("exact must be boolean")
121
+ values = np.asarray(services, dtype=float)
122
+ ratio = scales is not None
123
+ scales = tuple(scales) if ratio else (1.0,) * len(needs)
124
+ if len(scales) != len(needs):
125
+ raise ValueError("scales must match needs")
126
+ pools = {"coarse": defaultdict(list), "joint": defaultdict(list), "exact": defaultdict(list)}
127
+ normalized = []
128
+ for need, service, context, scale in zip(needs, values, contexts, scales):
129
+ if scale is None:
130
+ continue
131
+ value = _positive(service / _positive(scale, "scale"), "scaled service")
132
+ group = int(np.searchsorted([1, 8, 32], need))
133
+ normalized.append(value)
134
+ pools["coarse"][group].append(value)
135
+ pools["joint"][group, context].append(value)
136
+ if exact:
137
+ pools["exact"][need, context].append(value)
138
+ fitted = {
139
+ name: {key: ServiceCalibration.fit(sample) for key, sample in cells.items() if len(sample) >= minimum}
140
+ for name, cells in pools.items()
141
+ }
142
+ pooled = ServiceCalibration.fit(normalized) if len(normalized) >= 2 else None
143
+ audit = {
144
+ "history_count": len(needs),
145
+ "scaled_count": len(normalized),
146
+ "missing_scale_count": sum(s is None for s in scales),
147
+ "minimum": int(minimum),
148
+ "exact": exact,
149
+ "ratio": ratio,
150
+ "fitted_cells": {name: len(cells) for name, cells in fitted.items()},
151
+ }
152
+ return cls(absolute, pooled, fitted["coarse"], fitted["joint"], fitted["exact"], ratio, audit)
153
+
154
+ def predict(self, need, context, *, scale=None):
155
+ """Return the complete predictive distribution and the fallback used."""
156
+ need, context = _need(need), _context(context)
157
+ if scale is not None:
158
+ _positive(scale, "scale")
159
+ if self.ratio and (scale is None or self.pooled is None):
160
+ distribution, level = self.absolute.distribution(need)
161
+ return EmpiricalPrediction(distribution, 1.0, f"absolute_{level}")
162
+ if not self.ratio and scale is not None:
163
+ raise ValueError("scale is only valid for a ratio fit")
164
+ factor = float(scale) if self.ratio else 1.0
165
+ group = int(np.searchsorted([1, 8, 32], need))
166
+ choices = (
167
+ (self.exact_joint.get((need, context)), "exact_context"),
168
+ (self.joint.get((group, context)), "coarse_context"),
169
+ (self.coarse.get(group), "coarse"),
170
+ (self.pooled, "pooled"),
171
+ )
172
+ for distribution, level in choices:
173
+ if distribution is not None:
174
+ return EmpiricalPrediction(distribution, factor, level)
175
+ raise ValueError("no predictive distribution available")
@@ -0,0 +1,137 @@
1
+ """
2
+ Fitting a Markovian Arrival Process (MAP) to data.
3
+
4
+ The workhorse is the two-state MMPP (MMPP-2): a Poisson process whose rate
5
+ switches between two levels driven by a two-state Markov chain. It is fitted
6
+ by matching three interarrival statistics — the rate, the squared coefficient
7
+ of variation (SCV) and the lag-1 autocorrelation — for which the MAP has
8
+ closed-form expressions (see ``most_queue.random.map_ph``).
9
+
10
+ An MMPP is always overdispersed and positively correlated: SCV >= 1 and
11
+ lag-1 correlation >= 0. Targets outside this region cannot be represented by
12
+ an MMPP-2 and raise a clear error.
13
+
14
+ References:
15
+ Fischer W., Meier-Hellstern K. The Markov-modulated Poisson process (MMPP)
16
+ cookbook. Performance Evaluation, 18(2), 1993.
17
+ doi:10.1016/0166-5316(93)90035-s.
18
+ Heffes H., Lucantoni D. A Markov modulated characterization of packetized
19
+ voice and data traffic. IEEE JSAC, 4(6), 1986. doi:10.1109/JSAC.1986.1146428.
20
+ """
21
+
22
+ import numpy as np
23
+ from scipy.optimize import least_squares
24
+
25
+ from most_queue.random.map_ph import MAP, MAPParams
26
+
27
+
28
+ def map_statistics(params: MAPParams) -> tuple[float, float, float]:
29
+ """
30
+ Return (rate, scv, lag1) of a MAP: fundamental arrival rate, squared
31
+ coefficient of variation and lag-1 autocorrelation of interarrival times.
32
+ """
33
+ rate = MAP.arrival_rate(params)
34
+ moments = MAP.calc_theory_moments(params, 2)
35
+ m1, m2 = moments[0], moments[1]
36
+ scv = m2 / (m1 * m1) - 1.0
37
+ lag1 = MAP.lag_correlation(params, 1)
38
+ return rate, scv, lag1
39
+
40
+
41
+ def _mmpp2_from_vector(x: np.ndarray) -> MAPParams:
42
+ """Build an MMPP-2 from log-parameters x = log([l1, l2, q12, q21])."""
43
+ l1, l2, q12, q21 = np.exp(x)
44
+ return MAP.mmpp([l1, l2], np.array([[-q12, q12], [q21, -q21]]))
45
+
46
+
47
+ def fit_mmpp2(
48
+ rate: float,
49
+ scv: float,
50
+ lag1: float,
51
+ n_restarts: int = 6,
52
+ tol: float = 1e-9,
53
+ ) -> MAPParams:
54
+ """
55
+ Fit an MMPP-2 matching the given interarrival rate, SCV and lag-1
56
+ autocorrelation.
57
+
58
+ :param rate: target fundamental arrival rate (> 0)
59
+ :param scv: target squared coefficient of variation (>= 1 for an MMPP)
60
+ :param lag1: target lag-1 autocorrelation (>= 0 for an MMPP)
61
+ :param n_restarts: number of random restarts for the local optimizer
62
+ :param tol: acceptable residual norm to stop early
63
+ :return: MAPParams of the fitted MMPP-2
64
+ :raises ValueError: if the target is outside the MMPP-representable region
65
+ """
66
+ if rate <= 0:
67
+ raise ValueError(f"rate must be positive, got {rate}")
68
+ if scv < 1.0 - 1e-6:
69
+ raise ValueError(f"an MMPP-2 requires SCV >= 1 (overdispersed), got {scv:.4f}")
70
+ if lag1 < -1e-6:
71
+ raise ValueError(f"an MMPP-2 requires non-negative lag-1 correlation, got {lag1:.4f}")
72
+
73
+ target = np.array([rate, scv, lag1])
74
+ weights = np.array([1.0 / max(rate, 1e-9), 1.0, 1.0])
75
+
76
+ def residuals(x: np.ndarray) -> np.ndarray:
77
+ try:
78
+ stats = np.array(map_statistics(_mmpp2_from_vector(x)))
79
+ except (np.linalg.LinAlgError, ValueError):
80
+ return np.full(3, 1e3)
81
+ return (stats - target) * weights
82
+
83
+ rng = np.random.default_rng(12345)
84
+ best = np.log([rate, rate, 0.1 * rate + 1e-3, 0.1 * rate + 1e-3])
85
+ best_cost = np.inf
86
+ for k in range(max(1, n_restarts)):
87
+ if k == 0:
88
+ # deterministic heuristic start: rates spread by the overdispersion,
89
+ # slow switching to create the correlation
90
+ spread = 1.0 + np.sqrt(max(scv - 1.0, 0.0))
91
+ x0 = np.log([rate * spread, rate / spread, 0.1 * rate + 1e-3, 0.1 * rate + 1e-3])
92
+ else:
93
+ x0 = np.log(rate) + rng.uniform(-2.0, 2.0, size=4)
94
+ # trf (not lm): the 4-parameter MMPP-2 is under-determined by the
95
+ # 3 target statistics, so residuals < variables is expected
96
+ sol = least_squares(residuals, x0, method="trf", max_nfev=2000)
97
+ if sol.cost < best_cost:
98
+ best, best_cost = sol.x, sol.cost
99
+ if np.linalg.norm(sol.fun) < tol:
100
+ break
101
+
102
+ fitted = _mmpp2_from_vector(best)
103
+ achieved = map_statistics(fitted)
104
+ err = np.abs(np.array(achieved) - target) * weights
105
+ if np.max(err) > 1e-2:
106
+ raise ValueError(
107
+ f"failed to fit MMPP-2 to (rate={rate:.4f}, scv={scv:.4f}, lag1={lag1:.4f}); "
108
+ f"achieved (rate={achieved[0]:.4f}, scv={achieved[1]:.4f}, lag1={achieved[2]:.4f}). "
109
+ "The target may be near the boundary of the MMPP-representable region."
110
+ )
111
+ return fitted
112
+
113
+
114
+ def trace_statistics(interarrivals) -> tuple[float, float, float]:
115
+ """
116
+ Empirical (rate, scv, lag1) of a sequence of interarrival times.
117
+ """
118
+ x = np.asarray(interarrivals, dtype=float)
119
+ if x.size < 3:
120
+ raise ValueError("need at least 3 interarrival samples")
121
+ mean = x.mean()
122
+ scv = x.var() / (mean * mean)
123
+ lag1 = float(np.corrcoef(x[:-1], x[1:])[0, 1])
124
+ return 1.0 / mean, scv, lag1
125
+
126
+
127
+ def fit_map_from_trace(interarrivals, **kwargs) -> MAPParams:
128
+ """
129
+ Fit an MMPP-2 to a trace of interarrival times by matching its empirical
130
+ rate, SCV and lag-1 autocorrelation.
131
+
132
+ :param interarrivals: sequence of interarrival times
133
+ :param kwargs: forwarded to :func:`fit_mmpp2`
134
+ :return: MAPParams of the fitted MMPP-2
135
+ """
136
+ rate, scv, lag1 = trace_statistics(interarrivals)
137
+ return fit_mmpp2(rate, max(scv, 1.0), max(lag1, 0.0), **kwargs)
@@ -43,6 +43,54 @@ class MAPParams:
43
43
  D1: np.ndarray # transitions generating an arrival, shape (m, m)
44
44
 
45
45
 
46
+ @dataclass
47
+ class BMAPParams:
48
+ """
49
+ Batch Markovian Arrival Process parameters: a list of matrices
50
+ D[0], D[1], D[2], ... where D[0] holds phase transitions without arrivals
51
+ and D[k] (k >= 1) holds transitions that generate a batch of k arrivals.
52
+ sum_k D[k] is the generator of the phase process.
53
+ """
54
+
55
+ D: list # [D0, D1, D2, ...], each an (m, m) matrix
56
+
57
+
58
+ def bmap_poisson_batch(rate: float, batch_probs: list) -> BMAPParams:
59
+ """
60
+ Single-phase BMAP: batches arrive as a Poisson process of intensity
61
+ ``rate`` with batch-size probabilities ``batch_probs`` (batch_probs[k-1] is
62
+ P(batch size = k)). Equivalent to the M^[X]/... batch arrival stream.
63
+ """
64
+ p = np.asarray(batch_probs, dtype=float)
65
+ if abs(p.sum() - 1.0) > 1e-9 or np.any(p < 0):
66
+ raise ValueError("batch_probs must be a probability vector")
67
+ d = [np.array([[-rate]])]
68
+ for pk in p:
69
+ d.append(np.array([[rate * pk]]))
70
+ return BMAPParams(D=d)
71
+
72
+
73
+ def bmap_from_map(params: MAPParams) -> BMAPParams:
74
+ """A MAP as a BMAP with only size-1 batches (D[1] = D1)."""
75
+ return BMAPParams(D=[np.asarray(params.D0, dtype=float), np.asarray(params.D1, dtype=float)])
76
+
77
+
78
+ def bmap_arrival_rate(params: BMAPParams) -> float:
79
+ """
80
+ Fundamental arrival rate of a BMAP: pi @ (sum_k k * D[k]) @ 1, where pi is
81
+ the stationary distribution of the phase generator sum_k D[k].
82
+ """
83
+ mats = [np.asarray(x, dtype=float) for x in params.D]
84
+ gen = np.sum(mats, axis=0)
85
+ m = gen.shape[0]
86
+ a = np.vstack([gen.T, np.ones(m)])
87
+ b = np.zeros(m + 1)
88
+ b[-1] = 1.0
89
+ pi, *_ = np.linalg.lstsq(a, b, rcond=None)
90
+ batch_rate = np.sum([k * mats[k] for k in range(1, len(mats))], axis=0)
91
+ return float(pi @ batch_rate @ np.ones(m))
92
+
93
+
46
94
  def _validate_ph(alpha: np.ndarray, T: np.ndarray) -> None:
47
95
  if T.ndim != 2 or T.shape[0] != T.shape[1]:
48
96
  raise ValueError("T must be a square matrix")