from __future__ import annotations from dataclasses import dataclass from .model import Outcome @dataclass(frozen=True) class CapacitySample: consumer: str latency_ms: float error_rate: float throughput_per_second: float @dataclass(frozen=True) class CapacityResult: outcome: Outcome governor_bound: bool aggressor_peak: float aggressor_ceiling: float neighbour_degradation: dict[str, dict[str, float]] reasons: list[str] def characterize(*, baseline: list[CapacitySample], loaded: list[CapacitySample], governor_bound: bool, aggressor_peak: float, aggressor_ceiling: float) -> CapacityResult: before = {sample.consumer: sample for sample in baseline} after = {sample.consumer: sample for sample in loaded} reasons: list[str] = [] if not governor_bound: reasons.append("declared governor did not bind") if aggressor_peak > aggressor_ceiling: reasons.append("probe exceeded its authorized allowance") missing = sorted(before.keys() - after.keys()) if missing: reasons.append("loaded samples missing consumers: " + ", ".join(missing)) degradation: dict[str, dict[str, float]] = {} for consumer in sorted(before.keys() & after.keys()): old, new = before[consumer], after[consumer] degradation[consumer] = { "latency_increase_percent": _change(old.latency_ms, new.latency_ms), "error_rate_increase_points": new.error_rate - old.error_rate, "throughput_decrease_percent": _decrease(old.throughput_per_second, new.throughput_per_second), } outcome: Outcome = "pass" if aggressor_peak > aggressor_ceiling: outcome = "aborted" elif reasons: outcome = "finding" return CapacityResult( outcome=outcome, governor_bound=governor_bound, aggressor_peak=aggressor_peak, aggressor_ceiling=aggressor_ceiling, neighbour_degradation=degradation, reasons=reasons, ) def _change(before: float, after: float) -> float: if before == 0: return 0.0 if after == 0 else float("inf") return round((after - before) / before * 100, 3) def _decrease(before: float, after: float) -> float: if before == 0: return 0.0 return round((before - after) / before * 100, 3)