bront 0.1.0__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.
- bront/__init__.py +1 -0
- bront/artifacts.py +30 -0
- bront/checks.py +104 -0
- bront/cli.py +21 -0
- bront/compare_cli.py +81 -0
- bront/comparison_models.py +83 -0
- bront/comparisons.py +231 -0
- bront/evidence.py +297 -0
- bront/faults.py +66 -0
- bront/html_reports.py +474 -0
- bront/kubernetes_scenarios.py +225 -0
- bront/measurement.py +69 -0
- bront/provenance.py +71 -0
- bront/proxy_scenarios.py +81 -0
- bront/reference/__init__.py +1 -0
- bront/reference/adapters.py +95 -0
- bront/reference/backends.py +99 -0
- bront/reference/circuit_breaker.py +62 -0
- bront/reference/config.py +17 -0
- bront/reference/draining.py +30 -0
- bront/reference/gateway.py +78 -0
- bront/reference/metrics.py +78 -0
- bront/reference/mock_backend.py +44 -0
- bront/reference/schemas.py +21 -0
- bront/report_cli.py +32 -0
- bront/report_styles.py +48 -0
- bront/results.py +81 -0
- bront/run_cli.py +67 -0
- bront/runs.py +224 -0
- bront/scenario_models.py +92 -0
- bront/scenarios.py +181 -0
- bront/series.py +79 -0
- bront/suites.py +223 -0
- bront/targets.py +72 -0
- bront/validation.py +124 -0
- bront-0.1.0.dist-info/METADATA +171 -0
- bront-0.1.0.dist-info/RECORD +40 -0
- bront-0.1.0.dist-info/WHEEL +4 -0
- bront-0.1.0.dist-info/entry_points.txt +2 -0
- bront-0.1.0.dist-info/licenses/LICENSE +21 -0
bront/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bront: repeatable inference reliability experiments and release evidence."""
|
bront/artifacts.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Atomic UTF-8 artifact output shared by JSON evidence and HTML reports."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_artifact(report: BaseModel, path: Path) -> None:
|
|
12
|
+
write_text_artifact(
|
|
13
|
+
json.dumps(report.model_dump(mode="json"), indent=2, allow_nan=False) + "\n", path
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_text_artifact(content: str, path: Path) -> None:
|
|
18
|
+
"""Atomic replacement avoids presenting a partially written artifact as evidence."""
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
temporary = None
|
|
21
|
+
try:
|
|
22
|
+
with tempfile.NamedTemporaryFile(
|
|
23
|
+
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
|
24
|
+
) as stream:
|
|
25
|
+
temporary = Path(stream.name)
|
|
26
|
+
stream.write(content)
|
|
27
|
+
os.replace(temporary, path)
|
|
28
|
+
finally:
|
|
29
|
+
if temporary is not None:
|
|
30
|
+
temporary.unlink(missing_ok=True)
|
bront/checks.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Explicit release checks, also used to verify assertions when reading reports."""
|
|
2
|
+
|
|
3
|
+
from bront.results import AssertionResult, Phase
|
|
4
|
+
from bront.scenario_models import RecoveryEvidence, ScenarioEvent, ScenarioEvidence
|
|
5
|
+
from bront.suites import Assertions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def evaluate_measurement(phase: Phase, policy: Assertions) -> list[AssertionResult]:
|
|
9
|
+
values = [
|
|
10
|
+
("max_failed_requests", phase.failed, policy.max_failed_requests),
|
|
11
|
+
("max_p95_ms", phase.latency_ms.get("p95"), policy.max_p95_ms),
|
|
12
|
+
("max_p99_ms", phase.latency_ms.get("p99"), policy.max_p99_ms),
|
|
13
|
+
(
|
|
14
|
+
"max_error_rate",
|
|
15
|
+
phase.failed / phase.requests if phase.requests else None,
|
|
16
|
+
policy.max_error_rate,
|
|
17
|
+
),
|
|
18
|
+
(
|
|
19
|
+
"max_validation_failures",
|
|
20
|
+
phase.errors.get("validation", 0),
|
|
21
|
+
policy.max_validation_failures,
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
return [
|
|
25
|
+
AssertionResult(
|
|
26
|
+
name=name, actual=actual, limit=limit, passed=actual is not None and actual <= limit
|
|
27
|
+
)
|
|
28
|
+
for name, actual, limit in values
|
|
29
|
+
if limit is not None
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evaluate_recovery(recovery: RecoveryEvidence, policy: Assertions) -> list[AssertionResult]:
|
|
34
|
+
results = []
|
|
35
|
+
for name, observed, limit in (
|
|
36
|
+
(
|
|
37
|
+
"recovery_within_seconds",
|
|
38
|
+
recovery.first_valid_response_ms,
|
|
39
|
+
policy.recovery_within_seconds,
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"preferred_backend_within_seconds",
|
|
43
|
+
recovery.first_preferred_backend_ms,
|
|
44
|
+
policy.preferred_backend_within_seconds,
|
|
45
|
+
),
|
|
46
|
+
):
|
|
47
|
+
if limit is not None:
|
|
48
|
+
actual = observed / 1000 if observed is not None else None
|
|
49
|
+
results.append(
|
|
50
|
+
AssertionResult(
|
|
51
|
+
name=name,
|
|
52
|
+
actual=actual,
|
|
53
|
+
limit=limit,
|
|
54
|
+
passed=actual is not None and actual <= limit,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def observe_recovery(
|
|
61
|
+
phase: Phase, events: list[ScenarioEvent], policy: Assertions, has_identity: bool
|
|
62
|
+
) -> RecoveryEvidence | None:
|
|
63
|
+
restores = [e for e in events if e.action == "restore" and e.status == "completed"]
|
|
64
|
+
if not restores:
|
|
65
|
+
return None
|
|
66
|
+
start = restores[-1].finished_ms
|
|
67
|
+
if start is None:
|
|
68
|
+
return None
|
|
69
|
+
recovery = RecoveryEvidence(
|
|
70
|
+
from_ms=start,
|
|
71
|
+
backend_identity_available=has_identity,
|
|
72
|
+
preferred_backend=policy.preferred_backend,
|
|
73
|
+
)
|
|
74
|
+
valid = [s for s in phase.samples if s.error is None and s.started_ms >= start]
|
|
75
|
+
if valid:
|
|
76
|
+
recovery.first_valid_response_ms = min(s.started_ms + s.duration_ms for s in valid) - start
|
|
77
|
+
preferred = [
|
|
78
|
+
s
|
|
79
|
+
for s in valid
|
|
80
|
+
if policy.preferred_backend is not None and s.backend == policy.preferred_backend
|
|
81
|
+
]
|
|
82
|
+
if preferred:
|
|
83
|
+
recovery.first_preferred_backend_ms = (
|
|
84
|
+
min(s.started_ms + s.duration_ms for s in preferred) - start
|
|
85
|
+
)
|
|
86
|
+
return recovery
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def traffic_windows(phase: Phase, scenario: ScenarioEvidence) -> dict[str, int]:
|
|
90
|
+
events = {event.action: event for event in scenario.events}
|
|
91
|
+
applied = events["apply"]
|
|
92
|
+
reversible = scenario.kind != "kubernetes-restart"
|
|
93
|
+
terminal = events["restored" if reversible else "observed"]
|
|
94
|
+
end = terminal.finished_ms
|
|
95
|
+
during_start = events["observed"].finished_ms if reversible else applied.finished_ms
|
|
96
|
+
during_end = events["restore"].started_ms if reversible else end
|
|
97
|
+
if end is None or during_start is None or during_end is None:
|
|
98
|
+
raise ValueError("scenario_events_incomplete")
|
|
99
|
+
starts = [sample.started_ms for sample in phase.samples]
|
|
100
|
+
return {
|
|
101
|
+
"before": sum(start < applied.started_ms for start in starts),
|
|
102
|
+
"during": sum(during_start <= start <= during_end for start in starts),
|
|
103
|
+
"after": sum(start > end for start in starts),
|
|
104
|
+
}
|
bront/cli.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Bront: repeatable inference experiments, release comparisons, and reports."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from bront.compare_cli import compare
|
|
6
|
+
from bront.report_cli import report
|
|
7
|
+
from bront.run_cli import run
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
|
|
10
|
+
app.command()(run)
|
|
11
|
+
app.command()(compare)
|
|
12
|
+
app.command()(report)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.callback()
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""Validate inference deployments with repeatable experiments and release evidence."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
app()
|
bront/compare_cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CLI for reproducible baseline/candidate release decisions."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from bront.comparison_models import ComparisonPolicy
|
|
11
|
+
from bront.comparisons import compare_files
|
|
12
|
+
from bront.suites import SuiteError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compare(
|
|
16
|
+
baseline: Annotated[Path, typer.Argument(help="Baseline run or repeated-run manifest.")],
|
|
17
|
+
candidate: Annotated[Path, typer.Argument(help="Candidate run or repeated-run manifest.")],
|
|
18
|
+
policy: Annotated[Path, typer.Option(help="TOML evidence and regression policy.")],
|
|
19
|
+
baseline_run: Annotated[
|
|
20
|
+
list[Path] | None, typer.Option(help="Additional baseline run; repeatable.")
|
|
21
|
+
] = None,
|
|
22
|
+
candidate_run: Annotated[
|
|
23
|
+
list[Path] | None, typer.Option(help="Additional candidate run; repeatable.")
|
|
24
|
+
] = None,
|
|
25
|
+
output: Annotated[Path, typer.Option(help="Comparison JSON artifact.")] = Path(
|
|
26
|
+
"reports/comparison.json"
|
|
27
|
+
),
|
|
28
|
+
json_output: Annotated[
|
|
29
|
+
bool, typer.Option("--json", help="Print the comparison as JSON.")
|
|
30
|
+
] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Compare compatible repeated runs; exit 2 when evidence cannot support a verdict."""
|
|
33
|
+
try:
|
|
34
|
+
if output.resolve() == policy.resolve():
|
|
35
|
+
raise SuiteError("Comparison output must not overwrite its policy")
|
|
36
|
+
if policy.stat().st_size > 1_048_576:
|
|
37
|
+
raise SuiteError("Policy exceeds the 1 MiB limit")
|
|
38
|
+
settings = ComparisonPolicy.model_validate(tomllib.loads(policy.read_text()))
|
|
39
|
+
report = compare_files(
|
|
40
|
+
[baseline, *(baseline_run or [])], [candidate, *(candidate_run or [])], settings, output
|
|
41
|
+
)
|
|
42
|
+
except SuiteError as exc:
|
|
43
|
+
typer.echo(f"Configuration error: {exc}", err=True)
|
|
44
|
+
raise typer.Exit(2) from None
|
|
45
|
+
except (ValueError, OSError) as exc:
|
|
46
|
+
typer.echo(
|
|
47
|
+
f"Cannot compare artifacts ({type(exc).__name__}); check policy and output.", err=True
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(2) from None
|
|
50
|
+
if json_output:
|
|
51
|
+
typer.echo(report.model_dump_json(indent=2))
|
|
52
|
+
else:
|
|
53
|
+
console = Console(markup=False)
|
|
54
|
+
console.print(
|
|
55
|
+
f"{report.status.upper()}: {len(report.baseline_run_ids)} baseline runs, "
|
|
56
|
+
f"{len(report.candidate_run_ids)} candidate runs"
|
|
57
|
+
)
|
|
58
|
+
for difference in report.differences[:10]:
|
|
59
|
+
console.print(
|
|
60
|
+
f"{'Allowed' if difference.allowed else 'Incompatible'} change: {difference.field}"
|
|
61
|
+
)
|
|
62
|
+
for issue in report.issues[:10]:
|
|
63
|
+
console.print(f"Evidence: {issue}")
|
|
64
|
+
for name, metric in report.metrics.items():
|
|
65
|
+
console.print(
|
|
66
|
+
f"{name}: baseline median {metric.baseline.median:.3g} "
|
|
67
|
+
f"[{metric.baseline.minimum:.3g}, {metric.baseline.maximum:.3g}]; "
|
|
68
|
+
f"candidate median {metric.candidate.median:.3g} "
|
|
69
|
+
f"[{metric.candidate.minimum:.3g}, {metric.candidate.maximum:.3g}] {metric.unit}"
|
|
70
|
+
)
|
|
71
|
+
for check in report.checks:
|
|
72
|
+
console.print(
|
|
73
|
+
f"{check.name}: {check.status}; limit {check.limit:g}"
|
|
74
|
+
+ (f" ({check.reason})" if check.reason else "")
|
|
75
|
+
)
|
|
76
|
+
if any(run["status"] == "failed" for run in report.run_summaries):
|
|
77
|
+
console.print(
|
|
78
|
+
"One or more runs failed their own release assertions; see run_summaries."
|
|
79
|
+
)
|
|
80
|
+
console.print(f"Report: {output}")
|
|
81
|
+
raise typer.Exit({"passed": 0, "failed": 1}.get(report.status, 2))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Comparison policy and evidence models. No statistical significance claim is implied."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, JsonValue, model_validator
|
|
7
|
+
|
|
8
|
+
from bront.results import timestamp
|
|
9
|
+
from bront.validation import StrictModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ComparisonPolicy(StrictModel):
|
|
13
|
+
version: Literal[1]
|
|
14
|
+
minimum_runs: int = Field(default=3, ge=3, le=100)
|
|
15
|
+
minimum_requests: int = Field(default=100, ge=1, le=1_000_000)
|
|
16
|
+
minimum_duration_seconds: float = Field(default=0, ge=0, le=3600)
|
|
17
|
+
required_labels: list[str] = Field(default_factory=list, max_length=100)
|
|
18
|
+
allowed_changes: list[str] = Field(default_factory=list, max_length=100)
|
|
19
|
+
max_p95_increase_percent: float | None = Field(default=None, ge=0)
|
|
20
|
+
max_p99_increase_percent: float | None = Field(default=None, ge=0)
|
|
21
|
+
max_throughput_drop_percent: float | None = Field(default=None, ge=0, le=100)
|
|
22
|
+
max_error_rate_increase: float | None = Field(default=None, ge=0, le=1)
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def intentional_changes(self) -> Self:
|
|
26
|
+
permitted = (
|
|
27
|
+
r"deployment\.(model_revision|serving_revision|architecture|resources(\.[\w.-]+)?|labels\.[\w.-]+)"
|
|
28
|
+
r"|contract\.(route_sha256|scenario\.(context|namespace|deployment|proxy))"
|
|
29
|
+
)
|
|
30
|
+
if any(not re.fullmatch(permitted, path) for path in self.allowed_changes):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"Only deployment metadata, route, and scenario scope changes can be allowed"
|
|
33
|
+
)
|
|
34
|
+
if len(set(self.allowed_changes)) != len(self.allowed_changes):
|
|
35
|
+
raise ValueError("Duplicate allowed change")
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Distribution(StrictModel):
|
|
40
|
+
values: list[float]
|
|
41
|
+
minimum: float
|
|
42
|
+
median: float
|
|
43
|
+
maximum: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MetricComparison(StrictModel):
|
|
47
|
+
unit: str
|
|
48
|
+
baseline: Distribution
|
|
49
|
+
candidate: Distribution
|
|
50
|
+
median_change_percent: float | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ComparisonCheck(StrictModel):
|
|
54
|
+
name: str
|
|
55
|
+
status: Literal["passed", "failed", "inconclusive"]
|
|
56
|
+
limit: float
|
|
57
|
+
observed_lower: float | None
|
|
58
|
+
observed_upper: float | None
|
|
59
|
+
reason: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Difference(StrictModel):
|
|
63
|
+
field: str
|
|
64
|
+
baseline: JsonValue
|
|
65
|
+
candidate: JsonValue
|
|
66
|
+
allowed: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ComparisonReport(StrictModel):
|
|
70
|
+
report_type: Literal["bront.comparison"] = "bront.comparison"
|
|
71
|
+
schema_version: Literal[1] = 1
|
|
72
|
+
created_at: str = Field(default_factory=timestamp)
|
|
73
|
+
status: Literal["passed", "failed", "inconclusive", "incompatible"] = "inconclusive"
|
|
74
|
+
policy: ComparisonPolicy
|
|
75
|
+
baseline_run_ids: list[str] = Field(default_factory=list)
|
|
76
|
+
candidate_run_ids: list[str] = Field(default_factory=list)
|
|
77
|
+
issues: list[str] = Field(default_factory=list)
|
|
78
|
+
differences: list[Difference] = Field(default_factory=list)
|
|
79
|
+
baseline_context: dict[str, JsonValue] = Field(default_factory=dict)
|
|
80
|
+
candidate_context: dict[str, JsonValue] = Field(default_factory=dict)
|
|
81
|
+
run_summaries: list[dict[str, JsonValue]] = Field(default_factory=list)
|
|
82
|
+
metrics: dict[str, MetricComparison] = Field(default_factory=dict)
|
|
83
|
+
checks: list[ComparisonCheck] = Field(default_factory=list)
|
bront/comparisons.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Conservative release verdicts from compatible, completed, repeated experiments."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from statistics import median
|
|
6
|
+
|
|
7
|
+
from pydantic import JsonValue
|
|
8
|
+
|
|
9
|
+
from bront.artifacts import write_artifact
|
|
10
|
+
from bront.comparison_models import (
|
|
11
|
+
ComparisonCheck,
|
|
12
|
+
ComparisonPolicy,
|
|
13
|
+
ComparisonReport,
|
|
14
|
+
Difference,
|
|
15
|
+
Distribution,
|
|
16
|
+
MetricComparison,
|
|
17
|
+
)
|
|
18
|
+
from bront.evidence import Evidence, ReadBudget, load_evidence
|
|
19
|
+
from bront.results import RunReport
|
|
20
|
+
from bront.suites import SuiteError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def context(report: RunReport) -> dict[str, JsonValue]:
|
|
24
|
+
return {
|
|
25
|
+
"contract": report.provenance.contract,
|
|
26
|
+
"runner": {key: value for key, value in report.provenance.runner.items()},
|
|
27
|
+
"deployment": report.provenance.deployment.model_dump(mode="json"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def differences(left: JsonValue, right: JsonValue, path: str = "") -> list[Difference]:
|
|
32
|
+
if left == right:
|
|
33
|
+
return []
|
|
34
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
35
|
+
result = []
|
|
36
|
+
for key in sorted(left.keys() | right.keys()):
|
|
37
|
+
location = f"{path}.{key}" if path else key
|
|
38
|
+
if key not in left or key not in right:
|
|
39
|
+
result.append(
|
|
40
|
+
Difference(field=location, baseline=left.get(key), candidate=right.get(key))
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
result.extend(differences(left[key], right[key], location))
|
|
44
|
+
return result
|
|
45
|
+
return [Difference(field=path, baseline=left, candidate=right)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def distribution(values: list[float]) -> Distribution:
|
|
49
|
+
return Distribution(
|
|
50
|
+
values=values, minimum=min(values), median=median(values), maximum=max(values)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def percent_change(base: float, candidate: float) -> float | None:
|
|
55
|
+
if base == 0:
|
|
56
|
+
return 0 if candidate == 0 else None
|
|
57
|
+
result = (candidate / base - 1) * 100
|
|
58
|
+
return result if math.isfinite(result) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def metric_checks(
|
|
62
|
+
report: ComparisonReport,
|
|
63
|
+
baseline: list[RunReport],
|
|
64
|
+
candidate: list[RunReport],
|
|
65
|
+
*,
|
|
66
|
+
gate: bool = True,
|
|
67
|
+
) -> None:
|
|
68
|
+
policy = report.policy
|
|
69
|
+
metrics = (
|
|
70
|
+
(
|
|
71
|
+
"p95_ms",
|
|
72
|
+
"ms",
|
|
73
|
+
lambda r: r.measurement.latency_ms["p95"],
|
|
74
|
+
policy.max_p95_increase_percent,
|
|
75
|
+
False,
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
"p99_ms",
|
|
79
|
+
"ms",
|
|
80
|
+
lambda r: r.measurement.latency_ms["p99"],
|
|
81
|
+
policy.max_p99_increase_percent,
|
|
82
|
+
False,
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"requests_per_second",
|
|
86
|
+
"requests/s",
|
|
87
|
+
lambda r: r.measurement.requests_per_second,
|
|
88
|
+
policy.max_throughput_drop_percent,
|
|
89
|
+
True,
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"error_rate",
|
|
93
|
+
"fraction",
|
|
94
|
+
lambda r: r.measurement.failed / r.measurement.requests,
|
|
95
|
+
policy.max_error_rate_increase,
|
|
96
|
+
False,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
for name, unit, extract, limit, reverse in metrics:
|
|
100
|
+
left = distribution([extract(run) for run in baseline])
|
|
101
|
+
right = distribution([extract(run) for run in candidate])
|
|
102
|
+
report.metrics[name] = MetricComparison(
|
|
103
|
+
unit=unit,
|
|
104
|
+
baseline=left,
|
|
105
|
+
candidate=right,
|
|
106
|
+
median_change_percent=percent_change(left.median, right.median),
|
|
107
|
+
)
|
|
108
|
+
if limit is None or not gate:
|
|
109
|
+
continue
|
|
110
|
+
changes = []
|
|
111
|
+
for before in (left.minimum, left.maximum):
|
|
112
|
+
for after in (right.minimum, right.maximum):
|
|
113
|
+
value = after - before if name == "error_rate" else percent_change(before, after)
|
|
114
|
+
changes.append(-value if reverse and value is not None else value)
|
|
115
|
+
if any(value is None for value in changes):
|
|
116
|
+
report.checks.append(
|
|
117
|
+
ComparisonCheck(
|
|
118
|
+
name=name,
|
|
119
|
+
status="inconclusive",
|
|
120
|
+
limit=limit,
|
|
121
|
+
observed_lower=None,
|
|
122
|
+
observed_upper=None,
|
|
123
|
+
reason="relative_change_undefined",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
defined = [value for value in changes if value is not None]
|
|
128
|
+
lower, upper = min(defined), max(defined)
|
|
129
|
+
verdict = "passed" if upper <= limit else "failed" if lower > limit else "inconclusive"
|
|
130
|
+
report.checks.append(
|
|
131
|
+
ComparisonCheck(
|
|
132
|
+
name=name,
|
|
133
|
+
status=verdict,
|
|
134
|
+
limit=limit,
|
|
135
|
+
observed_lower=lower,
|
|
136
|
+
observed_upper=upper,
|
|
137
|
+
reason="variation_crosses_limit" if verdict == "inconclusive" else None,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compare_evidence(
|
|
143
|
+
baseline: Evidence, candidate: Evidence, policy: ComparisonPolicy
|
|
144
|
+
) -> ComparisonReport:
|
|
145
|
+
report = ComparisonReport(policy=policy, issues=[*baseline.issues, *candidate.issues])
|
|
146
|
+
report.baseline_run_ids = [r.provenance.run_id for r in baseline.runs]
|
|
147
|
+
report.candidate_run_ids = [r.provenance.run_id for r in candidate.runs]
|
|
148
|
+
ids = report.baseline_run_ids + report.candidate_run_ids
|
|
149
|
+
if len(set(ids)) != len(ids):
|
|
150
|
+
report.issues.append("duplicate_run_identity")
|
|
151
|
+
for side, evidence in (("baseline", baseline), ("candidate", candidate)):
|
|
152
|
+
if len(evidence.runs) < policy.minimum_runs:
|
|
153
|
+
report.issues.append(f"{side}:insufficient_repeats")
|
|
154
|
+
for index, run in enumerate(evidence.runs):
|
|
155
|
+
label = f"{side}[{index}]"
|
|
156
|
+
report.run_summaries.append(
|
|
157
|
+
{
|
|
158
|
+
"side": side,
|
|
159
|
+
"run_id": run.provenance.run_id,
|
|
160
|
+
"status": run.status,
|
|
161
|
+
"requests": run.measurement.requests,
|
|
162
|
+
"failed": run.measurement.failed,
|
|
163
|
+
"errors": {key: value for key, value in run.measurement.errors.items()},
|
|
164
|
+
"failed_assertions": [a.name for a in run.assertions if not a.passed],
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
if run.status not in ("passed", "failed") or run.reason is not None:
|
|
168
|
+
report.issues.append(f"{label}:run_incomplete")
|
|
169
|
+
for missing in run.provenance.deployment.missing():
|
|
170
|
+
report.issues.append(f"{label}:missing_metadata:{missing}")
|
|
171
|
+
for key in policy.required_labels:
|
|
172
|
+
if not run.provenance.deployment.labels.get(key):
|
|
173
|
+
report.issues.append(f"{label}:required_label_missing:{key}")
|
|
174
|
+
if run.measurement.requests < policy.minimum_requests:
|
|
175
|
+
report.issues.append(f"{label}:insufficient_requests")
|
|
176
|
+
if run.measurement.elapsed_seconds < policy.minimum_duration_seconds:
|
|
177
|
+
report.issues.append(f"{label}:insufficient_duration")
|
|
178
|
+
if side == "baseline" and run.status != "passed":
|
|
179
|
+
report.issues.append(f"{label}:baseline_assertions_not_passed")
|
|
180
|
+
if not baseline.runs or not candidate.runs:
|
|
181
|
+
return report
|
|
182
|
+
report.baseline_context = context(baseline.runs[0])
|
|
183
|
+
report.candidate_context = context(candidate.runs[0])
|
|
184
|
+
for side, evidence in (("baseline", baseline), ("candidate", candidate)):
|
|
185
|
+
for index, run in enumerate(evidence.runs[1:], 1):
|
|
186
|
+
for difference in differences(context(evidence.runs[0]), context(run)):
|
|
187
|
+
difference.field = f"{side}[{index}].{difference.field}"
|
|
188
|
+
report.differences.append(difference)
|
|
189
|
+
for difference in differences(report.baseline_context, report.candidate_context):
|
|
190
|
+
difference.allowed = any(
|
|
191
|
+
difference.field == path or difference.field.startswith(path + ".")
|
|
192
|
+
for path in policy.allowed_changes
|
|
193
|
+
)
|
|
194
|
+
report.differences.append(difference)
|
|
195
|
+
if any(not d.allowed for d in report.differences):
|
|
196
|
+
report.status = "incompatible"
|
|
197
|
+
return report
|
|
198
|
+
complete = all(
|
|
199
|
+
run.status in ("passed", "failed") and run.reason is None and run.measurement.requests > 0
|
|
200
|
+
for run in baseline.runs + candidate.runs
|
|
201
|
+
)
|
|
202
|
+
if complete:
|
|
203
|
+
metric_checks(report, baseline.runs, candidate.runs, gate=not report.issues)
|
|
204
|
+
if report.issues:
|
|
205
|
+
return report
|
|
206
|
+
if any(r.status == "failed" for r in candidate.runs) or any(
|
|
207
|
+
c.status == "failed" for c in report.checks
|
|
208
|
+
):
|
|
209
|
+
report.status = "failed"
|
|
210
|
+
elif any(c.status == "inconclusive" for c in report.checks):
|
|
211
|
+
report.status = "inconclusive"
|
|
212
|
+
else:
|
|
213
|
+
report.status = "passed"
|
|
214
|
+
return report
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def compare_files(
|
|
218
|
+
baseline: list[Path],
|
|
219
|
+
candidate: list[Path],
|
|
220
|
+
policy: ComparisonPolicy,
|
|
221
|
+
output: Path | None = None,
|
|
222
|
+
) -> ComparisonReport:
|
|
223
|
+
budget = ReadBudget()
|
|
224
|
+
left = load_evidence(baseline, "baseline", budget)
|
|
225
|
+
right = load_evidence(candidate, "candidate", budget)
|
|
226
|
+
report = compare_evidence(left, right, policy)
|
|
227
|
+
if output is not None:
|
|
228
|
+
if output.resolve() in budget.paths:
|
|
229
|
+
raise SuiteError("Comparison output must not overwrite an input artifact")
|
|
230
|
+
write_artifact(report, output)
|
|
231
|
+
return report
|