guidellm 0.3.1__py3-none-any.whl → 0.6.0a5__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 (141) hide show
  1. guidellm/__init__.py +5 -2
  2. guidellm/__main__.py +524 -255
  3. guidellm/backends/__init__.py +33 -0
  4. guidellm/backends/backend.py +109 -0
  5. guidellm/backends/openai.py +340 -0
  6. guidellm/backends/response_handlers.py +428 -0
  7. guidellm/benchmark/__init__.py +69 -39
  8. guidellm/benchmark/benchmarker.py +160 -316
  9. guidellm/benchmark/entrypoints.py +560 -127
  10. guidellm/benchmark/outputs/__init__.py +24 -0
  11. guidellm/benchmark/outputs/console.py +633 -0
  12. guidellm/benchmark/outputs/csv.py +721 -0
  13. guidellm/benchmark/outputs/html.py +473 -0
  14. guidellm/benchmark/outputs/output.py +169 -0
  15. guidellm/benchmark/outputs/serialized.py +69 -0
  16. guidellm/benchmark/profiles.py +718 -0
  17. guidellm/benchmark/progress.py +553 -556
  18. guidellm/benchmark/scenarios/__init__.py +40 -0
  19. guidellm/benchmark/scenarios/chat.json +6 -0
  20. guidellm/benchmark/scenarios/rag.json +6 -0
  21. guidellm/benchmark/schemas/__init__.py +66 -0
  22. guidellm/benchmark/schemas/base.py +402 -0
  23. guidellm/benchmark/schemas/generative/__init__.py +55 -0
  24. guidellm/benchmark/schemas/generative/accumulator.py +841 -0
  25. guidellm/benchmark/schemas/generative/benchmark.py +163 -0
  26. guidellm/benchmark/schemas/generative/entrypoints.py +381 -0
  27. guidellm/benchmark/schemas/generative/metrics.py +927 -0
  28. guidellm/benchmark/schemas/generative/report.py +158 -0
  29. guidellm/data/__init__.py +34 -4
  30. guidellm/data/builders.py +541 -0
  31. guidellm/data/collators.py +16 -0
  32. guidellm/data/config.py +120 -0
  33. guidellm/data/deserializers/__init__.py +49 -0
  34. guidellm/data/deserializers/deserializer.py +141 -0
  35. guidellm/data/deserializers/file.py +223 -0
  36. guidellm/data/deserializers/huggingface.py +94 -0
  37. guidellm/data/deserializers/memory.py +194 -0
  38. guidellm/data/deserializers/synthetic.py +246 -0
  39. guidellm/data/entrypoints.py +52 -0
  40. guidellm/data/loaders.py +190 -0
  41. guidellm/data/preprocessors/__init__.py +27 -0
  42. guidellm/data/preprocessors/formatters.py +410 -0
  43. guidellm/data/preprocessors/mappers.py +196 -0
  44. guidellm/data/preprocessors/preprocessor.py +30 -0
  45. guidellm/data/processor.py +29 -0
  46. guidellm/data/schemas.py +175 -0
  47. guidellm/data/utils/__init__.py +6 -0
  48. guidellm/data/utils/dataset.py +94 -0
  49. guidellm/extras/__init__.py +4 -0
  50. guidellm/extras/audio.py +220 -0
  51. guidellm/extras/vision.py +242 -0
  52. guidellm/logger.py +2 -2
  53. guidellm/mock_server/__init__.py +8 -0
  54. guidellm/mock_server/config.py +84 -0
  55. guidellm/mock_server/handlers/__init__.py +17 -0
  56. guidellm/mock_server/handlers/chat_completions.py +280 -0
  57. guidellm/mock_server/handlers/completions.py +280 -0
  58. guidellm/mock_server/handlers/tokenizer.py +142 -0
  59. guidellm/mock_server/models.py +510 -0
  60. guidellm/mock_server/server.py +238 -0
  61. guidellm/mock_server/utils.py +302 -0
  62. guidellm/scheduler/__init__.py +69 -26
  63. guidellm/scheduler/constraints/__init__.py +49 -0
  64. guidellm/scheduler/constraints/constraint.py +325 -0
  65. guidellm/scheduler/constraints/error.py +411 -0
  66. guidellm/scheduler/constraints/factory.py +182 -0
  67. guidellm/scheduler/constraints/request.py +312 -0
  68. guidellm/scheduler/constraints/saturation.py +722 -0
  69. guidellm/scheduler/environments.py +252 -0
  70. guidellm/scheduler/scheduler.py +137 -368
  71. guidellm/scheduler/schemas.py +358 -0
  72. guidellm/scheduler/strategies.py +617 -0
  73. guidellm/scheduler/worker.py +413 -419
  74. guidellm/scheduler/worker_group.py +712 -0
  75. guidellm/schemas/__init__.py +65 -0
  76. guidellm/schemas/base.py +417 -0
  77. guidellm/schemas/info.py +188 -0
  78. guidellm/schemas/request.py +235 -0
  79. guidellm/schemas/request_stats.py +349 -0
  80. guidellm/schemas/response.py +124 -0
  81. guidellm/schemas/statistics.py +1018 -0
  82. guidellm/{config.py → settings.py} +31 -24
  83. guidellm/utils/__init__.py +71 -8
  84. guidellm/utils/auto_importer.py +98 -0
  85. guidellm/utils/cli.py +132 -5
  86. guidellm/utils/console.py +566 -0
  87. guidellm/utils/encoding.py +778 -0
  88. guidellm/utils/functions.py +159 -0
  89. guidellm/utils/hf_datasets.py +1 -2
  90. guidellm/utils/hf_transformers.py +4 -4
  91. guidellm/utils/imports.py +9 -0
  92. guidellm/utils/messaging.py +1118 -0
  93. guidellm/utils/mixins.py +115 -0
  94. guidellm/utils/random.py +3 -4
  95. guidellm/utils/registry.py +220 -0
  96. guidellm/utils/singleton.py +133 -0
  97. guidellm/utils/synchronous.py +159 -0
  98. guidellm/utils/text.py +163 -50
  99. guidellm/utils/typing.py +41 -0
  100. guidellm/version.py +2 -2
  101. guidellm-0.6.0a5.dist-info/METADATA +364 -0
  102. guidellm-0.6.0a5.dist-info/RECORD +109 -0
  103. guidellm/backend/__init__.py +0 -23
  104. guidellm/backend/backend.py +0 -259
  105. guidellm/backend/openai.py +0 -708
  106. guidellm/backend/response.py +0 -136
  107. guidellm/benchmark/aggregator.py +0 -760
  108. guidellm/benchmark/benchmark.py +0 -837
  109. guidellm/benchmark/output.py +0 -997
  110. guidellm/benchmark/profile.py +0 -409
  111. guidellm/benchmark/scenario.py +0 -104
  112. guidellm/data/prideandprejudice.txt.gz +0 -0
  113. guidellm/dataset/__init__.py +0 -22
  114. guidellm/dataset/creator.py +0 -213
  115. guidellm/dataset/entrypoints.py +0 -42
  116. guidellm/dataset/file.py +0 -92
  117. guidellm/dataset/hf_datasets.py +0 -62
  118. guidellm/dataset/in_memory.py +0 -132
  119. guidellm/dataset/synthetic.py +0 -287
  120. guidellm/objects/__init__.py +0 -18
  121. guidellm/objects/pydantic.py +0 -89
  122. guidellm/objects/statistics.py +0 -953
  123. guidellm/preprocess/__init__.py +0 -3
  124. guidellm/preprocess/dataset.py +0 -374
  125. guidellm/presentation/__init__.py +0 -28
  126. guidellm/presentation/builder.py +0 -27
  127. guidellm/presentation/data_models.py +0 -232
  128. guidellm/presentation/injector.py +0 -66
  129. guidellm/request/__init__.py +0 -18
  130. guidellm/request/loader.py +0 -284
  131. guidellm/request/request.py +0 -79
  132. guidellm/request/types.py +0 -10
  133. guidellm/scheduler/queues.py +0 -25
  134. guidellm/scheduler/result.py +0 -155
  135. guidellm/scheduler/strategy.py +0 -495
  136. guidellm-0.3.1.dist-info/METADATA +0 -329
  137. guidellm-0.3.1.dist-info/RECORD +0 -62
  138. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/WHEEL +0 -0
  139. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/entry_points.txt +0 -0
  140. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/licenses/LICENSE +0 -0
  141. {guidellm-0.3.1.dist-info → guidellm-0.6.0a5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1018 @@
1
+ """
2
+ Statistical distribution analysis and summary calculations for benchmark metrics.
3
+
4
+ Provides comprehensive statistical analysis tools including percentile calculations,
5
+ summary statistics, and status-based distributions. Supports value distributions,
6
+ time-based rate and concurrency distributions with weighted sampling, and probability
7
+ density functions for analyzing benchmark performance metrics and request patterns
8
+ across different status categories (successful, incomplete, errored).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from collections.abc import Callable, Sequence
15
+ from typing import Literal, TypeVar
16
+
17
+ import numpy as np
18
+ from pydantic import Field
19
+
20
+ from guidellm.schemas.base import StandardBaseModel, StatusBreakdown
21
+
22
+ __all__ = [
23
+ "DistributionSummary",
24
+ "FunctionObjT",
25
+ "Percentiles",
26
+ "StatusDistributionSummary",
27
+ ]
28
+
29
+ FunctionObjT = TypeVar("FunctionObjT")
30
+
31
+
32
+ class Percentiles(StandardBaseModel):
33
+ """
34
+ Standard percentile values for probability distributions.
35
+
36
+ Captures key percentile points from 0.1th to 99.9th percentile for comprehensive
37
+ distribution analysis, enabling assessment of central tendency, spread, and tail
38
+ behavior in benchmark metrics.
39
+ """
40
+
41
+ p001: float = Field(description="0.1th percentile value")
42
+ p01: float = Field(description="1st percentile value")
43
+ p05: float = Field(description="5th percentile value")
44
+ p10: float = Field(description="10th percentile value")
45
+ p25: float = Field(description="25th percentile value")
46
+ p50: float = Field(description="50th percentile (median) value")
47
+ p75: float = Field(description="75th percentile value")
48
+ p90: float = Field(description="90th percentile value")
49
+ p95: float = Field(description="95th percentile value")
50
+ p99: float = Field(description="99th percentile value")
51
+ p999: float = Field(description="99.9th percentile value")
52
+
53
+ @classmethod
54
+ def from_pdf(
55
+ cls, pdf: np.ndarray, epsilon: float = 1e-6, validate: bool = True
56
+ ) -> Percentiles:
57
+ """
58
+ Create percentiles from a probability density function.
59
+
60
+ :param pdf: 2D array (N, 2) with values in column 0 and probabilities in
61
+ column 1
62
+ :param epsilon: Tolerance for probability sum validation
63
+ :param validate: Whether to validate probabilities sum to 1 and are
64
+ non-negative
65
+ :return: Percentiles object with computed values
66
+ :raises ValueError: If PDF shape is invalid, probabilities are negative,
67
+ or probabilities don't sum to 1
68
+ """
69
+ expected_shape = (None, 2)
70
+
71
+ if len(pdf.shape) != len(expected_shape) or pdf.shape[1] != expected_shape[1]:
72
+ raise ValueError(
73
+ "PDF must be a 2D array of shape (N, 2) where first column is values "
74
+ f"and second column is probabilities. Got {pdf.shape} instead."
75
+ )
76
+
77
+ percentile_probs = {
78
+ "p001": 0.001,
79
+ "p01": 0.01,
80
+ "p05": 0.05,
81
+ "p10": 0.1,
82
+ "p25": 0.25,
83
+ "p50": 0.5,
84
+ "p75": 0.75,
85
+ "p90": 0.9,
86
+ "p95": 0.95,
87
+ "p99": 0.99,
88
+ "p999": 0.999,
89
+ }
90
+
91
+ if pdf.shape[0] == 0:
92
+ return Percentiles(**dict.fromkeys(percentile_probs.keys(), 0.0))
93
+
94
+ probabilities = pdf[:, 1]
95
+
96
+ if validate:
97
+ if np.any(probabilities < 0):
98
+ raise ValueError("Probabilities must be non-negative.")
99
+
100
+ prob_sum = np.sum(probabilities)
101
+ if abs(prob_sum - 1.0) > epsilon:
102
+ raise ValueError(f"Probabilities must sum to 1, got {prob_sum}.")
103
+
104
+ cdf_probs = np.cumsum(probabilities)
105
+
106
+ return Percentiles(
107
+ **{
108
+ key: pdf[np.searchsorted(cdf_probs, value, side="left"), 0].item()
109
+ for key, value in percentile_probs.items()
110
+ }
111
+ )
112
+
113
+
114
+ class DistributionSummary(StandardBaseModel):
115
+ """
116
+ Comprehensive statistical summary of a probability distribution.
117
+
118
+ Captures central tendency (mean, median, mode), spread (variance, std_dev),
119
+ extrema (min, max), and percentile information with optional probability density
120
+ function. Supports creation from raw values, PDFs, or time-based event data for
121
+ rate and concurrency analysis in benchmark metrics.
122
+ """
123
+
124
+ mean: float = Field(description="Mean/average value")
125
+ median: float = Field(description="Median (50th percentile) value")
126
+ mode: float = Field(description="Mode (most probable) value")
127
+ variance: float = Field(description="Variance of the distribution")
128
+ std_dev: float = Field(description="Standard deviation")
129
+ min: float = Field(description="Minimum value")
130
+ max: float = Field(description="Maximum value")
131
+ count: int = Field(description="Number of observations")
132
+ total_sum: float = Field(description="Sum of all values")
133
+ percentiles: Percentiles = Field(description="Standard percentile values")
134
+ pdf: list[tuple[float, float]] | None = Field(
135
+ description="Probability density function as (value, probability) pairs",
136
+ default=None,
137
+ )
138
+
139
+ @classmethod
140
+ def from_pdf(
141
+ cls,
142
+ pdf: np.ndarray,
143
+ count: int | None = None,
144
+ include_pdf: bool | int = False,
145
+ epsilon: float = 1e-6,
146
+ validate: bool = True,
147
+ ) -> DistributionSummary:
148
+ """
149
+ Create distribution summary from a probability density function.
150
+
151
+ :param pdf: 2D array (N, 2) with values in column 0 and probabilities in
152
+ column 1
153
+ :param count: Number of original observations; defaults to PDF length
154
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
155
+ :param epsilon: Tolerance for probability validation
156
+ :param validate: Whether to validate probabilities sum to 1 and are non-negative
157
+ :return: Complete distribution summary with statistics
158
+ :raises ValueError: If PDF shape is invalid or probabilities are invalid
159
+ """
160
+ expected_shape = (None, 2)
161
+
162
+ if len(pdf.shape) != len(expected_shape) or pdf.shape[1] != expected_shape[1]:
163
+ raise ValueError(
164
+ "PDF must be a 2D array of shape (N, 2) where first column is values "
165
+ f"and second column is probabilities. Got {pdf.shape} instead."
166
+ )
167
+
168
+ if pdf.shape[0] == 0:
169
+ return DistributionSummary(
170
+ mean=0.0,
171
+ median=0.0,
172
+ mode=0.0,
173
+ variance=0.0,
174
+ std_dev=0.0,
175
+ min=0.0,
176
+ max=0.0,
177
+ count=0 if count is None else count,
178
+ total_sum=0.0,
179
+ percentiles=Percentiles.from_pdf(pdf, epsilon=epsilon),
180
+ pdf=None if include_pdf is False else [],
181
+ )
182
+
183
+ # Calculate stats
184
+ values = pdf[:, 0]
185
+ probabilities = pdf[:, 1]
186
+
187
+ if validate:
188
+ # Fail if probabilities don't sum to 1 or are negative
189
+ if np.any(probabilities < 0):
190
+ raise ValueError("Probabilities must be non-negative.")
191
+
192
+ prob_sum = np.sum(probabilities)
193
+ if not np.isclose(prob_sum, 1.0, atol=epsilon):
194
+ raise ValueError(f"Probabilities must sum to 1.0 (sum={prob_sum}).")
195
+
196
+ # Fail if values are not sorted
197
+ if not np.all(values[:-1] <= values[1:]):
198
+ raise ValueError("Values in PDF must be sorted in ascending order.")
199
+
200
+ percentiles = Percentiles.from_pdf(pdf, epsilon=epsilon, validate=False)
201
+ median = percentiles.p50
202
+ mean = np.sum(values * probabilities).item()
203
+ mode = values[np.argmax(probabilities)].item()
204
+ variance = np.sum((values - mean) ** 2 * probabilities).item()
205
+ std_dev = math.sqrt(variance)
206
+ minimum = values[0].item()
207
+ maximum = values[-1].item()
208
+
209
+ if count is None:
210
+ count = len(pdf)
211
+
212
+ total_sum = mean * count
213
+ sampled_pdf = cls._sample_pdf(pdf, include_pdf)
214
+
215
+ return DistributionSummary(
216
+ mean=mean,
217
+ median=median,
218
+ mode=mode,
219
+ variance=variance,
220
+ std_dev=std_dev,
221
+ min=minimum,
222
+ max=maximum,
223
+ count=count,
224
+ total_sum=total_sum,
225
+ percentiles=percentiles,
226
+ pdf=sampled_pdf,
227
+ )
228
+
229
+ @classmethod
230
+ def _sample_pdf(
231
+ cls, pdf: np.ndarray, include_pdf: bool | int
232
+ ) -> list[tuple[float, float]] | None:
233
+ """
234
+ Sample PDF based on include_pdf parameter.
235
+
236
+ :param pdf: PDF array to sample
237
+ :param include_pdf: False for None, True for full, int for sampled size
238
+ :return: Sampled PDF as list of tuples or None
239
+ """
240
+ if include_pdf is False:
241
+ return None
242
+ if include_pdf is True:
243
+ return pdf.tolist()
244
+ if isinstance(include_pdf, int) and include_pdf > 0:
245
+ if len(pdf) <= include_pdf:
246
+ return pdf.tolist()
247
+ sample_indices = np.linspace(0, len(pdf) - 1, include_pdf, dtype=int)
248
+ return pdf[sample_indices].tolist()
249
+ return []
250
+
251
+ @classmethod
252
+ def from_values(
253
+ cls,
254
+ values: Sequence[float | tuple[float, float]] | np.ndarray,
255
+ count: int | None = None,
256
+ include_pdf: bool | int = False,
257
+ epsilon: float = 1e-6,
258
+ ) -> DistributionSummary:
259
+ """
260
+ Create distribution summary from raw values with optional weights.
261
+
262
+ :param values: Values or (value, weight) tuples, or numpy array
263
+ :param count: Number of original observations; defaults to sum of weights
264
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
265
+ :param epsilon: Tolerance for probability validation
266
+ :return: Distribution summary computed from the values
267
+ :raises ValueError: If total weight is zero or invalid
268
+ """
269
+ np_values = cls._to_weighted_ndarray(values, num_values_per_item=2)
270
+
271
+ if np_values.shape[0] == 0:
272
+ return DistributionSummary.from_pdf(
273
+ pdf=np.empty((0, 2)), count=0, include_pdf=include_pdf, epsilon=epsilon
274
+ )
275
+
276
+ if count is None:
277
+ count = round(np.sum(np_values[:, 1]).item())
278
+
279
+ # Sort values and weights by values
280
+ sort_ind = np.argsort(np_values[:, 0])
281
+ sorted_values = np_values[sort_ind, 0]
282
+ sorted_weights = np_values[sort_ind, 1]
283
+
284
+ # Combine any duplicate values by summing their weights
285
+ unique_values, inverse_indices = np.unique(sorted_values, return_inverse=True)
286
+ combined_weights = np.zeros_like(unique_values, dtype=float)
287
+ np.add.at(combined_weights, inverse_indices, sorted_weights)
288
+
289
+ # Remove any values with zero weight
290
+ nonzero_mask = combined_weights > 0
291
+ final_values = unique_values[nonzero_mask]
292
+ final_weights = combined_weights[nonzero_mask]
293
+
294
+ # Create PDF by normalizing weights and stacking
295
+ total_weight = np.sum(final_weights)
296
+ if total_weight <= epsilon:
297
+ # No valid weights to create PDF, overwrite to uniform distribution
298
+ final_weights = np.ones_like(final_values)
299
+ total_weight = np.sum(final_weights)
300
+
301
+ probabilities = final_weights / total_weight
302
+ pdf = np.column_stack((final_values, probabilities))
303
+
304
+ return DistributionSummary.from_pdf(
305
+ pdf=pdf,
306
+ count=count,
307
+ include_pdf=include_pdf,
308
+ epsilon=epsilon,
309
+ validate=False,
310
+ )
311
+
312
+ @classmethod
313
+ def rate_distribution_from_timings(
314
+ cls,
315
+ event_times: Sequence[float | tuple[float, float]] | np.ndarray,
316
+ start_time: float | None = None,
317
+ end_time: float | None = None,
318
+ threshold: float | None = 1e-4, # 1/10th of a millisecond
319
+ include_pdf: bool | int = False,
320
+ epsilon: float = 1e-6,
321
+ ) -> DistributionSummary:
322
+ """
323
+ Create rate distribution from event timestamps.
324
+
325
+ Computes event rates over time intervals weighted by interval duration for
326
+ analyzing request throughput patterns.
327
+
328
+ :param event_times: Event timestamps or (timestamp, weight) tuples
329
+ :param start_time: Analysis window start; filters earlier events
330
+ :param end_time: Analysis window end; filters later events
331
+ :param threshold: Time threshold for merging nearby events; 1/10th millisecond
332
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
333
+ :param epsilon: Tolerance for probability validation
334
+ :return: Distribution summary of event rates over time
335
+ """
336
+ weighted_times = cls._to_weighted_ndarray(event_times, num_values_per_item=2)
337
+
338
+ if start_time is not None:
339
+ # Filter out any times before start, insert start time with 0 weight
340
+ weighted_times = np.insert(
341
+ weighted_times[weighted_times[:, 0] >= start_time],
342
+ 0,
343
+ [start_time, 0.0],
344
+ axis=0,
345
+ )
346
+
347
+ if end_time is not None:
348
+ # Filter out any times after end, insert end time with 0 weight
349
+ weighted_times = np.append(
350
+ weighted_times[weighted_times[:, 0] <= end_time],
351
+ [[end_time, 0.0]],
352
+ axis=0,
353
+ )
354
+
355
+ # Sort by time for merging, merge any times within threshold
356
+ sort_ind = np.argsort(weighted_times[:, 0])
357
+ weighted_times = weighted_times[sort_ind]
358
+ weighted_times = cls._merge_sorted_times_with_weights(weighted_times, threshold)
359
+
360
+ if len(weighted_times) <= 1:
361
+ # No data to calculate rates from (need at least two times)
362
+ return cls.from_values(
363
+ [],
364
+ count=len(weighted_times),
365
+ include_pdf=include_pdf,
366
+ epsilon=epsilon,
367
+ )
368
+
369
+ times = weighted_times[:, 0]
370
+ occurrences = weighted_times[:, 1]
371
+
372
+ # Calculate local duration for each event: ((times[i+1] - times[i-1])) / 2
373
+ midpoints = (times[1:] + times[:-1]) / 2
374
+ durations = np.empty_like(times)
375
+ durations[0] = midpoints[0] - times[0]
376
+ durations[1:-1] = midpoints[1:] - midpoints[:-1]
377
+ durations[-1] = np.clip(times[-1] - midpoints[-1], epsilon, None)
378
+
379
+ # Calculate rate at each interval: occurences[i] / duration[i]
380
+ rates = occurrences / durations
381
+ count = round(np.sum(occurrences).item())
382
+
383
+ return cls.from_values(
384
+ np.column_stack((rates, durations)),
385
+ count=count,
386
+ include_pdf=include_pdf,
387
+ epsilon=epsilon,
388
+ )
389
+
390
+ @classmethod
391
+ def concurrency_distribution_from_timings(
392
+ cls,
393
+ event_intervals: (
394
+ Sequence[tuple[float, float] | tuple[float, float, float]] | np.ndarray
395
+ ),
396
+ start_time: float | None = None,
397
+ end_time: float | None = None,
398
+ threshold: float | None = 1e-4, # 1/10th of a millisecond
399
+ include_pdf: bool | int = False,
400
+ epsilon: float = 1e-6,
401
+ ) -> DistributionSummary:
402
+ """
403
+ Create concurrency distribution from event time intervals.
404
+
405
+ Tracks overlapping events to compute concurrency levels over time for analyzing
406
+ request processing patterns and resource utilization.
407
+
408
+ :param event_intervals: Event (start, end) or (start, end, weight) tuples
409
+ :param start_time: Analysis window start
410
+ :param end_time: Analysis window end
411
+ :param threshold: Time threshold for merging nearby transitions;
412
+ 1/10th millisecond
413
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
414
+ :param epsilon: Tolerance for probability validation
415
+ :return: Distribution summary of concurrency levels over time
416
+ """
417
+ weighted_intervals = cls._to_weighted_ndarray(
418
+ event_intervals, num_values_per_item=3
419
+ )
420
+
421
+ # If start_time, filter any intervals that end before start_time
422
+ if start_time is not None:
423
+ keep_mask = weighted_intervals[:, 1] >= start_time
424
+ weighted_intervals = weighted_intervals[keep_mask]
425
+
426
+ # If end_time, filter any intervals that start after end_time
427
+ if end_time is not None:
428
+ keep_mask = weighted_intervals[:, 0] <= end_time
429
+ weighted_intervals = weighted_intervals[keep_mask]
430
+
431
+ count = len(weighted_intervals)
432
+
433
+ # Convert to concurrency changes at each time
434
+ add_occurences = (
435
+ np.stack(
436
+ (
437
+ weighted_intervals[:, 0],
438
+ weighted_intervals[:, 2],
439
+ ),
440
+ axis=1,
441
+ )
442
+ if len(weighted_intervals) > 0
443
+ else np.empty((0, 2))
444
+ )
445
+ remove_occurences = (
446
+ np.stack(
447
+ (
448
+ weighted_intervals[:, 1],
449
+ -1 * weighted_intervals[:, 2],
450
+ ),
451
+ axis=1,
452
+ )
453
+ if len(weighted_intervals) > 0
454
+ else np.empty((0, 2))
455
+ )
456
+
457
+ # Combine add and remove occurences into weighted times
458
+ weighted_times = np.vstack((add_occurences, remove_occurences))
459
+
460
+ # Sort by the times and merge any times within threshold
461
+ weighted_times = weighted_times[np.argsort(weighted_times[:, 0])]
462
+ weighted_times = cls._merge_sorted_times_with_weights(weighted_times, threshold)
463
+
464
+ # If start_time, ensure included (if any before, add final concurrency at start)
465
+ if start_time is not None and len(weighted_times) > 0:
466
+ start_ind = np.searchsorted(weighted_times[:, 0], start_time, side="left")
467
+ prior_delta = (
468
+ np.sum(weighted_times[:start_ind, 1]) if start_ind > 0 else 0.0
469
+ )
470
+ weighted_times = np.insert(
471
+ weighted_times[start_ind:], 0, [start_time, prior_delta], axis=0
472
+ )
473
+
474
+ # If end_time, ensure included (if any after, filter out)
475
+ if end_time is not None and len(weighted_times) > 0:
476
+ end_ind = np.searchsorted(weighted_times[:, 0], end_time, side="right")
477
+ weighted_times = np.append(
478
+ weighted_times[:end_ind], [[end_time, 0.0]], axis=0
479
+ )
480
+
481
+ # Calculate concurrency from cumulative sum of changes over time
482
+ concurrencies = np.clip(np.cumsum(weighted_times[:, 1]), 0, None)
483
+
484
+ if len(concurrencies) <= 1:
485
+ # No data to calculate concurrency from
486
+ return cls.from_values(
487
+ [] if count == 0 else [concurrencies[0].item()],
488
+ include_pdf=include_pdf,
489
+ epsilon=epsilon,
490
+ )
491
+
492
+ # Calculate durations equal to times[i+1] - times[i]
493
+ # The last concurrency level is not used since no following time point
494
+ durations = np.clip(np.diff(weighted_times[:, 0]), 0, None)
495
+ values = np.column_stack((concurrencies[:-1], durations))
496
+
497
+ return (
498
+ cls.from_values(
499
+ values,
500
+ count=count,
501
+ include_pdf=include_pdf,
502
+ epsilon=epsilon,
503
+ )
504
+ if np.any(durations > 0)
505
+ else cls.from_values(
506
+ [],
507
+ count=count,
508
+ include_pdf=include_pdf,
509
+ epsilon=epsilon,
510
+ )
511
+ )
512
+
513
+ @classmethod
514
+ def _to_weighted_ndarray(
515
+ cls,
516
+ inputs: (
517
+ Sequence[float | tuple[float, float] | tuple[float, float, float]]
518
+ | np.ndarray
519
+ ),
520
+ num_values_per_item: Literal[2, 3],
521
+ ) -> np.ndarray:
522
+ if not isinstance(inputs, np.ndarray):
523
+ # Convert list to structured numpy array with dims (N, num_dimensions)
524
+ # Fill in missing weights with 1.0
525
+ return cls._sequence_to_weighted_ndarray(inputs, num_values_per_item)
526
+
527
+ if len(inputs.shape) == 1:
528
+ # 1D array: reshape to (N, 1) and add weights column
529
+ inputs = inputs.reshape(-1, 1)
530
+ weights = np.ones((inputs.shape[0], 1), dtype=float)
531
+
532
+ return (
533
+ np.hstack((inputs, weights))
534
+ if num_values_per_item == 2 # noqa: PLR2004
535
+ else np.hstack((inputs, inputs, weights))
536
+ )
537
+
538
+ if len(inputs.shape) == 2 and inputs.shape[1] == num_values_per_item - 1: # noqa: PLR2004
539
+ # Add weights column of 1.0
540
+ weights = np.ones((inputs.shape[0], 1), dtype=float)
541
+
542
+ return np.hstack((inputs, weights))
543
+
544
+ if len(inputs.shape) == 2 and inputs.shape[1] == num_values_per_item: # noqa: PLR2004
545
+ return inputs
546
+
547
+ raise ValueError(
548
+ "inputs must be a numpy array of shape (N,), "
549
+ f"(N, {num_values_per_item - 1}), or (N, {num_values_per_item}). "
550
+ f"Got shape {inputs.shape}."
551
+ )
552
+
553
+ @classmethod
554
+ def _sequence_to_weighted_ndarray(
555
+ cls,
556
+ inputs: Sequence[float | tuple[float, float] | tuple[float, float, float]],
557
+ num_values_per_item: Literal[2, 3],
558
+ ) -> np.ndarray:
559
+ ndarray = np.empty((len(inputs), num_values_per_item), dtype=float)
560
+ scalar_types: tuple[type, ...] = (int, float, np.integer, np.floating)
561
+
562
+ for ind, val in enumerate(inputs):
563
+ if isinstance(val, scalar_types):
564
+ ndarray[ind, :] = (
565
+ (val, 1.0) if num_values_per_item == 2 else (val, val, 1.0) # noqa: PLR2004
566
+ )
567
+ elif isinstance(val, tuple) and len(val) == num_values_per_item:
568
+ ndarray[ind, :] = val
569
+ elif isinstance(val, tuple) and len(val) == num_values_per_item - 1:
570
+ ndarray[ind, :] = (
571
+ (val[0], 1.0) if num_values_per_item == 2 else (val[0], val[1], 1.0) # noqa: PLR2004
572
+ )
573
+ else:
574
+ raise ValueError(
575
+ "Each item must be a float or a tuple of "
576
+ f"{num_values_per_item} or {num_values_per_item - 1} "
577
+ "elements."
578
+ )
579
+
580
+ return ndarray
581
+
582
+ @classmethod
583
+ def _merge_sorted_times_with_weights(
584
+ cls, weighted_times: np.ndarray, threshold: float | None
585
+ ) -> np.ndarray:
586
+ # First remove any exact duplicate times and sum their weights
587
+ unique_times, inverse = np.unique(weighted_times[:, 0], return_inverse=True)
588
+ unique_weights = np.zeros_like(unique_times, dtype=float)
589
+ np.add.at(unique_weights, inverse, weighted_times[:, 1])
590
+ weighted_times = np.column_stack((unique_times, unique_weights))
591
+
592
+ if threshold is None or threshold <= 0.0:
593
+ return weighted_times
594
+
595
+ # Loop to merge times within threshold until no more merges possible
596
+ # (loop due to possible overlapping merge groups)
597
+ while weighted_times.shape[0] > 1:
598
+ times = weighted_times[:, 0]
599
+ weights = weighted_times[:, 1]
600
+
601
+ # Find diffs between consecutive times, create mask for within-threshold
602
+ diffs = np.diff(times)
603
+ within = diffs <= threshold
604
+ if not np.any(within):
605
+ break
606
+
607
+ # Start indices are marked by the transition from 0 to 1 in the mask
608
+ # End indices found by searching for last time within threshold from start
609
+ starts = np.where(np.diff(np.insert(within.astype(int), 0, 0)) == 1)[0]
610
+ start_end_times = times[starts] + threshold
611
+ ends = np.searchsorted(times, start_end_times, side="right") - 1
612
+
613
+ # Collapse overlapping or chained merge groups
614
+ if len(starts) > 1:
615
+ valid_mask = np.concatenate([[True], starts[1:] > ends[:-1]])
616
+ starts, ends = starts[valid_mask], ends[valid_mask]
617
+
618
+ # Update weights at start indices to sum of merged weights
619
+ cumsum = np.concatenate(([0.0], np.cumsum(weights)))
620
+ weighted_times[starts, 1] = cumsum[ends + 1] - cumsum[starts]
621
+
622
+ # Calculate vectorized mask for removing merged entries
623
+ merged_events = np.zeros(len(weighted_times) + 1, dtype=int)
624
+ np.add.at(merged_events, starts, 1)
625
+ np.add.at(merged_events, ends + 1, -1)
626
+ remove_mask = np.cumsum(merged_events[:-1]) > 0
627
+ remove_mask[starts] = False # Keep start indices
628
+
629
+ # Remove merged entries, update weighted_times
630
+ weights = weights[~remove_mask]
631
+ times = times[~remove_mask]
632
+ weighted_times = np.column_stack((times, weights))
633
+
634
+ return weighted_times
635
+
636
+
637
+ class StatusDistributionSummary(
638
+ StatusBreakdown[
639
+ DistributionSummary,
640
+ DistributionSummary,
641
+ DistributionSummary,
642
+ DistributionSummary,
643
+ ]
644
+ ):
645
+ """
646
+ Distribution summaries broken down by request status categories.
647
+
648
+ Provides separate statistical analysis for successful, incomplete, and errored
649
+ requests with total aggregate statistics. Enables status-aware performance analysis
650
+ and SLO validation across different request outcomes in benchmark results.
651
+ """
652
+
653
+ @property
654
+ def count(self) -> int:
655
+ """
656
+ :return: Total count of samples across all status categories
657
+ """
658
+ return self.total.count
659
+
660
+ @property
661
+ def total_sum(self) -> float:
662
+ """
663
+ :return: Total sum of values across all status categories
664
+ """
665
+ return self.total.total_sum
666
+
667
+ @classmethod
668
+ def from_values(
669
+ cls,
670
+ successful: Sequence[float | tuple[float, float]] | np.ndarray,
671
+ incomplete: Sequence[float | tuple[float, float]] | np.ndarray,
672
+ errored: Sequence[float | tuple[float, float]] | np.ndarray,
673
+ include_pdf: bool | int = False,
674
+ epsilon: float = 1e-6,
675
+ ) -> StatusDistributionSummary:
676
+ """
677
+ Create status-broken-down distribution from values by status category.
678
+
679
+ :param successful: Values or (value, weight) tuples for successful requests
680
+ :param incomplete: Values or (value, weight) tuples for incomplete requests
681
+ :param errored: Values or (value, weight) tuples for errored requests
682
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
683
+ :param epsilon: Tolerance for probability validation
684
+ :return: Status breakdown of distribution summaries
685
+ """
686
+ total, successful_arr, incomplete_arr, errored_arr = cls._combine_status_arrays(
687
+ successful, incomplete, errored, num_values_per_item=2
688
+ )
689
+
690
+ return StatusDistributionSummary(
691
+ total=DistributionSummary.from_values(
692
+ total, include_pdf=include_pdf, epsilon=epsilon
693
+ ),
694
+ successful=DistributionSummary.from_values(
695
+ successful_arr, include_pdf=include_pdf, epsilon=epsilon
696
+ ),
697
+ incomplete=DistributionSummary.from_values(
698
+ incomplete_arr, include_pdf=include_pdf, epsilon=epsilon
699
+ ),
700
+ errored=DistributionSummary.from_values(
701
+ errored_arr, include_pdf=include_pdf, epsilon=epsilon
702
+ ),
703
+ )
704
+
705
+ @classmethod
706
+ def from_values_function(
707
+ cls,
708
+ function: Callable[
709
+ [FunctionObjT],
710
+ float | tuple[float, float] | Sequence[float | tuple[float, float]] | None,
711
+ ],
712
+ successful: Sequence[FunctionObjT],
713
+ incomplete: Sequence[FunctionObjT],
714
+ errored: Sequence[FunctionObjT],
715
+ include_pdf: bool | int = False,
716
+ epsilon: float = 1e-6,
717
+ ) -> StatusDistributionSummary:
718
+ """
719
+ Create distribution summary by extracting values from objects via function.
720
+
721
+ :param function: Function to extract value(s) from each object
722
+ :param successful: Successful request objects
723
+ :param incomplete: Incomplete request objects
724
+ :param errored: Errored request objects
725
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
726
+ :param epsilon: Tolerance for probability validation
727
+ :return: Status breakdown of distribution summaries
728
+ """
729
+
730
+ def _extract_values(
731
+ _objs: Sequence[FunctionObjT],
732
+ ) -> Sequence[float | tuple[float, float]]:
733
+ _outputs: list[float | tuple[float, float]] = []
734
+ for _obj in _objs:
735
+ if (_result := function(_obj)) is None:
736
+ continue
737
+ if isinstance(_result, Sequence) and not isinstance(_result, tuple):
738
+ _outputs.extend(_result)
739
+ else:
740
+ _outputs.append(_result)
741
+ return _outputs
742
+
743
+ return cls.from_values(
744
+ successful=_extract_values(successful),
745
+ incomplete=_extract_values(incomplete),
746
+ errored=_extract_values(errored),
747
+ include_pdf=include_pdf,
748
+ epsilon=epsilon,
749
+ )
750
+
751
+ @classmethod
752
+ def rate_distribution_from_timings(
753
+ cls,
754
+ successful: Sequence[float | tuple[float, float]] | np.ndarray,
755
+ incomplete: Sequence[float | tuple[float, float]] | np.ndarray,
756
+ errored: Sequence[float | tuple[float, float]] | np.ndarray,
757
+ start_time: float | None = None,
758
+ end_time: float | None = None,
759
+ threshold: float | None = 1e-4,
760
+ include_pdf: bool | int = False,
761
+ epsilon: float = 1e-6,
762
+ ) -> StatusDistributionSummary:
763
+ """
764
+ Create status-broken-down rate distribution from event timestamps.
765
+
766
+ :param successful: Timestamps for successful request events
767
+ :param incomplete: Timestamps for incomplete request events
768
+ :param errored: Timestamps for errored request events
769
+ :param start_time: Analysis window start
770
+ :param end_time: Analysis window end
771
+ :param threshold: Time threshold for merging nearby events
772
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
773
+ :param epsilon: Tolerance for probability validation
774
+ :return: Status breakdown of rate distribution summaries
775
+ """
776
+ total, successful_arr, incomplete_arr, errored_arr = cls._combine_status_arrays(
777
+ successful, incomplete, errored, num_values_per_item=2
778
+ )
779
+
780
+ return StatusDistributionSummary(
781
+ total=DistributionSummary.rate_distribution_from_timings(
782
+ total,
783
+ start_time=start_time,
784
+ end_time=end_time,
785
+ threshold=threshold,
786
+ include_pdf=include_pdf,
787
+ epsilon=epsilon,
788
+ ),
789
+ successful=DistributionSummary.rate_distribution_from_timings(
790
+ successful_arr,
791
+ start_time=start_time,
792
+ end_time=end_time,
793
+ threshold=threshold,
794
+ include_pdf=include_pdf,
795
+ epsilon=epsilon,
796
+ ),
797
+ incomplete=DistributionSummary.rate_distribution_from_timings(
798
+ incomplete_arr,
799
+ start_time=start_time,
800
+ end_time=end_time,
801
+ threshold=threshold,
802
+ include_pdf=include_pdf,
803
+ epsilon=epsilon,
804
+ ),
805
+ errored=DistributionSummary.rate_distribution_from_timings(
806
+ errored_arr,
807
+ start_time=start_time,
808
+ end_time=end_time,
809
+ threshold=threshold,
810
+ include_pdf=include_pdf,
811
+ epsilon=epsilon,
812
+ ),
813
+ )
814
+
815
+ @classmethod
816
+ def rate_distribution_from_timings_function(
817
+ cls,
818
+ function: Callable[
819
+ [FunctionObjT],
820
+ float | tuple[float, float] | Sequence[float | tuple[float, float]] | None,
821
+ ],
822
+ successful: Sequence[FunctionObjT],
823
+ incomplete: Sequence[FunctionObjT],
824
+ errored: Sequence[FunctionObjT],
825
+ start_time: float | None = None,
826
+ end_time: float | None = None,
827
+ threshold: float | None = 1e-4,
828
+ include_pdf: bool | int = False,
829
+ epsilon: float = 1e-6,
830
+ ) -> StatusDistributionSummary:
831
+ """
832
+ Create rate distribution by extracting timestamps from objects via function.
833
+
834
+ :param function: Function to extract timestamp(s) from each object
835
+ :param successful: Successful request objects
836
+ :param incomplete: Incomplete request objects
837
+ :param errored: Errored request objects
838
+ :param start_time: Analysis window start
839
+ :param end_time: Analysis window end
840
+ :param threshold: Time threshold for merging nearby events
841
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
842
+ :param epsilon: Tolerance for probability validation
843
+ :return: Status breakdown of rate distribution summaries
844
+ """
845
+
846
+ def _extract_values(
847
+ _objs: Sequence[FunctionObjT],
848
+ ) -> Sequence[float | tuple[float, float]]:
849
+ _outputs: list[float | tuple[float, float]] = []
850
+ for _obj in _objs:
851
+ if (_result := function(_obj)) is None:
852
+ continue
853
+ if isinstance(_result, Sequence) and not isinstance(_result, tuple):
854
+ _outputs.extend(_result)
855
+ else:
856
+ _outputs.append(_result)
857
+ return _outputs
858
+
859
+ return cls.rate_distribution_from_timings(
860
+ successful=_extract_values(successful),
861
+ incomplete=_extract_values(incomplete),
862
+ errored=_extract_values(errored),
863
+ start_time=start_time,
864
+ end_time=end_time,
865
+ threshold=threshold,
866
+ include_pdf=include_pdf,
867
+ epsilon=epsilon,
868
+ )
869
+
870
+ @classmethod
871
+ def concurrency_distribution_from_timings(
872
+ cls,
873
+ successful: Sequence[tuple[float, float] | tuple[float, float, float]]
874
+ | np.ndarray,
875
+ incomplete: Sequence[tuple[float, float] | tuple[float, float, float]]
876
+ | np.ndarray,
877
+ errored: Sequence[tuple[float, float] | tuple[float, float, float]]
878
+ | np.ndarray,
879
+ start_time: float | None = None,
880
+ end_time: float | None = None,
881
+ threshold: float | None = 1e-4,
882
+ include_pdf: bool | int = False,
883
+ epsilon: float = 1e-6,
884
+ ) -> StatusDistributionSummary:
885
+ """
886
+ Create status-broken-down concurrency distribution from event intervals.
887
+
888
+ :param successful: Event intervals for successful requests
889
+ :param incomplete: Event intervals for incomplete requests
890
+ :param errored: Event intervals for errored requests
891
+ :param start_time: Analysis window start
892
+ :param end_time: Analysis window end
893
+ :param threshold: Time threshold for merging nearby transitions
894
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
895
+ :param epsilon: Tolerance for probability validation
896
+ :return: Status breakdown of concurrency distribution summaries
897
+ """
898
+ total, successful_arr, incomplete_arr, errored_arr = cls._combine_status_arrays(
899
+ successful, incomplete, errored, num_values_per_item=3
900
+ )
901
+
902
+ return StatusDistributionSummary(
903
+ total=DistributionSummary.concurrency_distribution_from_timings(
904
+ total,
905
+ start_time=start_time,
906
+ end_time=end_time,
907
+ threshold=threshold,
908
+ include_pdf=include_pdf,
909
+ epsilon=epsilon,
910
+ ),
911
+ successful=DistributionSummary.concurrency_distribution_from_timings(
912
+ successful_arr,
913
+ start_time=start_time,
914
+ end_time=end_time,
915
+ threshold=threshold,
916
+ include_pdf=include_pdf,
917
+ epsilon=epsilon,
918
+ ),
919
+ incomplete=DistributionSummary.concurrency_distribution_from_timings(
920
+ incomplete_arr,
921
+ start_time=start_time,
922
+ end_time=end_time,
923
+ threshold=threshold,
924
+ include_pdf=include_pdf,
925
+ epsilon=epsilon,
926
+ ),
927
+ errored=DistributionSummary.concurrency_distribution_from_timings(
928
+ errored_arr,
929
+ start_time=start_time,
930
+ end_time=end_time,
931
+ threshold=threshold,
932
+ include_pdf=include_pdf,
933
+ epsilon=epsilon,
934
+ ),
935
+ )
936
+
937
+ @classmethod
938
+ def concurrency_distribution_from_timings_function(
939
+ cls,
940
+ function: Callable[
941
+ [FunctionObjT],
942
+ tuple[float, float]
943
+ | tuple[float, float, float]
944
+ | Sequence[tuple[float, float] | tuple[float, float, float]]
945
+ | None,
946
+ ],
947
+ successful: Sequence[FunctionObjT],
948
+ incomplete: Sequence[FunctionObjT],
949
+ errored: Sequence[FunctionObjT],
950
+ start_time: float | None = None,
951
+ end_time: float | None = None,
952
+ threshold: float | None = 1e-4,
953
+ include_pdf: bool | int = False,
954
+ epsilon: float = 1e-6,
955
+ ) -> StatusDistributionSummary:
956
+ """
957
+ Create concurrency distribution by extracting intervals from objects.
958
+
959
+ :param function: Function to extract time interval(s) from each object
960
+ :param successful: Successful request objects
961
+ :param incomplete: Incomplete request objects
962
+ :param errored: Errored request objects
963
+ :param start_time: Analysis window start
964
+ :param end_time: Analysis window end
965
+ :param threshold: Time threshold for merging nearby transitions
966
+ :param include_pdf: Whether to include PDF; True for full, int for sampled size
967
+ :param epsilon: Tolerance for probability validation
968
+ :return: Status breakdown of concurrency distribution summaries
969
+ """
970
+
971
+ def _extract_values(
972
+ _objs: Sequence[FunctionObjT],
973
+ ) -> Sequence[tuple[float, float] | tuple[float, float, float]]:
974
+ _outputs: list[tuple[float, float] | tuple[float, float, float]] = []
975
+ for _obj in _objs:
976
+ if (_result := function(_obj)) is None:
977
+ continue
978
+ if isinstance(_result, Sequence) and not isinstance(_result, tuple):
979
+ _outputs.extend(_result)
980
+ else:
981
+ _outputs.append(_result)
982
+ return _outputs
983
+
984
+ return cls.concurrency_distribution_from_timings(
985
+ successful=_extract_values(successful),
986
+ incomplete=_extract_values(incomplete),
987
+ errored=_extract_values(errored),
988
+ start_time=start_time,
989
+ end_time=end_time,
990
+ threshold=threshold,
991
+ include_pdf=include_pdf,
992
+ epsilon=epsilon,
993
+ )
994
+
995
+ @classmethod
996
+ def _combine_status_arrays(
997
+ cls,
998
+ successful: Sequence[float | tuple[float, float] | tuple[float, float, float]]
999
+ | np.ndarray,
1000
+ incomplete: Sequence[float | tuple[float, float] | tuple[float, float, float]]
1001
+ | np.ndarray,
1002
+ errored: Sequence[float | tuple[float, float] | tuple[float, float, float]]
1003
+ | np.ndarray,
1004
+ num_values_per_item: Literal[2, 3],
1005
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
1006
+ successful_array = DistributionSummary._to_weighted_ndarray( # noqa: SLF001
1007
+ successful, num_values_per_item=num_values_per_item
1008
+ )
1009
+ incomplete_array = DistributionSummary._to_weighted_ndarray( # noqa: SLF001
1010
+ incomplete, num_values_per_item=num_values_per_item
1011
+ )
1012
+ errored_array = DistributionSummary._to_weighted_ndarray( # noqa: SLF001
1013
+ errored, num_values_per_item=num_values_per_item
1014
+ )
1015
+ total_array = np.concatenate(
1016
+ (successful_array, incomplete_array, errored_array), axis=0
1017
+ )
1018
+ return total_array, successful_array, incomplete_array, errored_array