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 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