|
| 1 | +"""Shared helpers for agentic aggregate generation.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import math |
| 6 | +import statistics |
| 7 | +from collections.abc import Callable |
| 8 | +from typing import Any |
| 9 | + |
| 10 | + |
| 11 | +def percentile(data: list[float], p: float) -> float: |
| 12 | + if not data: |
| 13 | + return 0.0 |
| 14 | + sorted_data = sorted(data) |
| 15 | + k = (len(sorted_data) - 1) * (p / 100) |
| 16 | + f = int(k) |
| 17 | + c = f + 1 |
| 18 | + if c >= len(sorted_data): |
| 19 | + return sorted_data[f] |
| 20 | + return sorted_data[f] + (k - f) * (sorted_data[c] - sorted_data[f]) |
| 21 | + |
| 22 | + |
| 23 | +def stats_for(prefix: str, values: list[float]) -> dict[str, float]: |
| 24 | + if not values: |
| 25 | + return {} |
| 26 | + return { |
| 27 | + f"mean_{prefix}": statistics.mean(values), |
| 28 | + f"p50_{prefix}": percentile(values, 50), |
| 29 | + f"p75_{prefix}": percentile(values, 75), |
| 30 | + f"p90_{prefix}": percentile(values, 90), |
| 31 | + f"p95_{prefix}": percentile(values, 95), |
| 32 | + f"std_{prefix}": statistics.pstdev(values) if len(values) > 1 else 0.0, |
| 33 | + } |
| 34 | + |
| 35 | + |
| 36 | +def to_float(value: Any) -> float | None: |
| 37 | + if value is None: |
| 38 | + return None |
| 39 | + try: |
| 40 | + return float(value) |
| 41 | + except (TypeError, ValueError): |
| 42 | + return None |
| 43 | + |
| 44 | + |
| 45 | +def to_int(value: Any) -> int | None: |
| 46 | + if value is None: |
| 47 | + return None |
| 48 | + try: |
| 49 | + return int(value) |
| 50 | + except (TypeError, ValueError): |
| 51 | + return None |
| 52 | + |
| 53 | + |
| 54 | +def rate(numerator: float | int | None, denominator: float | int | None) -> float | None: |
| 55 | + if numerator is None or denominator is None: |
| 56 | + return None |
| 57 | + if denominator <= 0: |
| 58 | + return None |
| 59 | + return float(numerator) / float(denominator) |
| 60 | + |
| 61 | + |
| 62 | +def normalize_fraction(value: float | None) -> float | None: |
| 63 | + """Normalize gauges that may be exported as 0..1 or 0..100.""" |
| 64 | + if value is None: |
| 65 | + return None |
| 66 | + if value > 1.5: |
| 67 | + return value / 100.0 |
| 68 | + return value |
| 69 | + |
| 70 | + |
| 71 | +def round_floats(obj: Any, decimal_places: int = 5) -> Any: |
| 72 | + """Round every finite float in a nested JSON-like object.""" |
| 73 | + if isinstance(obj, float): |
| 74 | + if not math.isfinite(obj): |
| 75 | + return obj |
| 76 | + rounded = round(obj, decimal_places) |
| 77 | + if abs(rounded) >= 1 and rounded.is_integer(): |
| 78 | + return int(rounded) |
| 79 | + return rounded |
| 80 | + if isinstance(obj, dict): |
| 81 | + return {key: round_floats(value, decimal_places) for key, value in obj.items()} |
| 82 | + if isinstance(obj, list): |
| 83 | + return [round_floats(value, decimal_places) for value in obj] |
| 84 | + return obj |
| 85 | + |
| 86 | + |
| 87 | +def index_server_metrics(server_metrics: dict[str, Any]) -> dict[str, dict[str, Any]]: |
| 88 | + """Return the metrics dict from aiperf's server_metrics_export.json.""" |
| 89 | + if not isinstance(server_metrics, dict): |
| 90 | + return {} |
| 91 | + metrics = server_metrics.get("metrics") |
| 92 | + if isinstance(metrics, dict): |
| 93 | + return metrics |
| 94 | + return {} |
| 95 | + |
| 96 | + |
| 97 | +def metric_series( |
| 98 | + metrics: dict[str, dict[str, Any]], |
| 99 | + metric_names: str | list[str], |
| 100 | +) -> list[dict[str, Any]]: |
| 101 | + names = [metric_names] if isinstance(metric_names, str) else metric_names |
| 102 | + out: list[dict[str, Any]] = [] |
| 103 | + for name in names: |
| 104 | + entry = metrics.get(name) |
| 105 | + if not isinstance(entry, dict): |
| 106 | + continue |
| 107 | + series = entry.get("series") |
| 108 | + if not isinstance(series, list): |
| 109 | + continue |
| 110 | + out.extend(s for s in series if isinstance(s, dict)) |
| 111 | + return out |
| 112 | + |
| 113 | + |
| 114 | +def series_stat( |
| 115 | + series: dict[str, Any], |
| 116 | + preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"), |
| 117 | +) -> float | None: |
| 118 | + stats = series.get("stats") |
| 119 | + if not isinstance(stats, dict): |
| 120 | + return None |
| 121 | + for key in preferred_keys: |
| 122 | + value = to_float(stats.get(key)) |
| 123 | + if value is not None: |
| 124 | + return value |
| 125 | + return None |
| 126 | + |
| 127 | + |
| 128 | +def sum_stat( |
| 129 | + metrics: dict[str, dict[str, Any]], |
| 130 | + metric_names: str | list[str], |
| 131 | + *, |
| 132 | + preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"), |
| 133 | + series_filter: Callable[[dict[str, Any]], bool] | None = None, |
| 134 | +) -> float | None: |
| 135 | + total = 0.0 |
| 136 | + found = False |
| 137 | + for series in metric_series(metrics, metric_names): |
| 138 | + if series_filter is not None and not series_filter(series): |
| 139 | + continue |
| 140 | + value = series_stat(series, preferred_keys) |
| 141 | + if value is None: |
| 142 | + continue |
| 143 | + total += value |
| 144 | + found = True |
| 145 | + return total if found else None |
| 146 | + |
| 147 | + |
| 148 | +def gauge_stat( |
| 149 | + metrics: dict[str, dict[str, Any]], |
| 150 | + metric_names: str | list[str], |
| 151 | + *, |
| 152 | + preferred_keys: tuple[str, ...] = ("max", "avg", "total"), |
| 153 | + combine: str = "max", |
| 154 | + series_filter: Callable[[dict[str, Any]], bool] | None = None, |
| 155 | +) -> float | None: |
| 156 | + values: list[float] = [] |
| 157 | + for series in metric_series(metrics, metric_names): |
| 158 | + if series_filter is not None and not series_filter(series): |
| 159 | + continue |
| 160 | + value = series_stat(series, preferred_keys) |
| 161 | + if value is not None: |
| 162 | + values.append(value) |
| 163 | + if not values: |
| 164 | + return None |
| 165 | + if combine == "avg": |
| 166 | + return statistics.mean(values) |
| 167 | + if combine == "sum": |
| 168 | + return sum(values) |
| 169 | + return max(values) |
| 170 | + |
| 171 | + |
| 172 | +def label_value(series: dict[str, Any], key: str) -> str | None: |
| 173 | + labels = series.get("labels") |
| 174 | + if not isinstance(labels, dict): |
| 175 | + return None |
| 176 | + value = labels.get(key) |
| 177 | + if value is None: |
| 178 | + return None |
| 179 | + return str(value) |
| 180 | + |
| 181 | + |
| 182 | +def label_equals(key: str, value: str) -> Callable[[dict[str, Any]], bool]: |
| 183 | + return lambda series: label_value(series, key) == value |
| 184 | + |
| 185 | + |
| 186 | +def sum_by_label( |
| 187 | + metrics: dict[str, dict[str, Any]], |
| 188 | + metric_names: str | list[str], |
| 189 | + label_keys: str | list[str], |
| 190 | + *, |
| 191 | + preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"), |
| 192 | +) -> dict[str, float]: |
| 193 | + keys = [label_keys] if isinstance(label_keys, str) else label_keys |
| 194 | + out: dict[str, float] = {} |
| 195 | + for series in metric_series(metrics, metric_names): |
| 196 | + labels = series.get("labels") |
| 197 | + if not isinstance(labels, dict): |
| 198 | + continue |
| 199 | + label = None |
| 200 | + for key in keys: |
| 201 | + raw = labels.get(key) |
| 202 | + if raw is not None: |
| 203 | + label = str(raw) |
| 204 | + break |
| 205 | + if label is None: |
| 206 | + continue |
| 207 | + value = series_stat(series, preferred_keys) |
| 208 | + if value is None: |
| 209 | + continue |
| 210 | + out[label] = out.get(label, 0.0) + value |
| 211 | + return out |
0 commit comments