agent-reliability 1.0.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.
- agent_reliability/__init__.py +14 -0
- agent_reliability/adapters/__init__.py +31 -0
- agent_reliability/adapters/event_sinks.py +82 -0
- agent_reliability/adapters/otel/__init__.py +13 -0
- agent_reliability/adapters/otel/run_context.py +109 -0
- agent_reliability/adapters/system_clock.py +14 -0
- agent_reliability/adapters/uuid_run_id_generator.py +15 -0
- agent_reliability/application/__init__.py +3 -0
- agent_reliability/domain/__init__.py +66 -0
- agent_reliability/domain/error_budget.py +153 -0
- agent_reliability/domain/evaluation.py +36 -0
- agent_reliability/domain/identity.py +43 -0
- agent_reliability/domain/runs.py +111 -0
- agent_reliability/domain/sli.py +221 -0
- agent_reliability/domain/slo.py +152 -0
- agent_reliability/evaluation/__init__.py +35 -0
- agent_reliability/evaluation/_validation.py +48 -0
- agent_reliability/evaluation/builtins.py +68 -0
- agent_reliability/evaluation/identity.py +32 -0
- agent_reliability/evaluation/protocols.py +46 -0
- agent_reliability/evaluation/result.py +110 -0
- agent_reliability/experimental/__init__.py +9 -0
- agent_reliability/ports/__init__.py +43 -0
- agent_reliability/ports/clock.py +26 -0
- agent_reliability/ports/event_sink.py +36 -0
- agent_reliability/ports/events.py +101 -0
- agent_reliability/ports/id_generator.py +24 -0
- agent_reliability/ports/run_context.py +28 -0
- agent_reliability/py.typed +1 -0
- agent_reliability/reliability/__init__.py +21 -0
- agent_reliability/reliability/engine.py +156 -0
- agent_reliability/reliability/model.py +191 -0
- agent_reliability/sdk/__init__.py +57 -0
- agent_reliability/sdk/client.py +476 -0
- agent_reliability/sdk/context.py +151 -0
- agent_reliability/sdk/diagnostics.py +85 -0
- agent_reliability/sdk/evaluator_runner.py +165 -0
- agent_reliability-1.0.0.dist-info/METADATA +210 -0
- agent_reliability-1.0.0.dist-info/RECORD +41 -0
- agent_reliability-1.0.0.dist-info/WHEEL +4 -0
- agent_reliability-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Agent Reliability SDK.
|
|
2
|
+
|
|
3
|
+
Vendor-neutral reliability primitives for operating autonomous AI agents.
|
|
4
|
+
|
|
5
|
+
See docs/GA_CONTRACT.md and docs/COMPATIBILITY.md for the stability policy.
|
|
6
|
+
Public APIs live in focused subpackages; the root package intentionally
|
|
7
|
+
exposes only the distribution version.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Adapters: concrete implementations of ports.
|
|
2
|
+
|
|
3
|
+
The default clock, id generator, and event sinks arrived in M2. M3's optional
|
|
4
|
+
OpenTelemetry adapter lives in ``agent_reliability.adapters.otel`` and is not
|
|
5
|
+
imported here, preserving base-install import safety.
|
|
6
|
+
|
|
7
|
+
This is the only layer permitted to depend on a specific vendor SDK,
|
|
8
|
+
transport, or agent framework. Adapters implement ports; they are never
|
|
9
|
+
imported by ``domain`` or ``application``.
|
|
10
|
+
|
|
11
|
+
The exports of this subpackage are part of the stable 1.0 contract documented
|
|
12
|
+
in docs/GA_CONTRACT.md.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from agent_reliability.adapters.event_sinks import (
|
|
18
|
+
CompositeEventSink,
|
|
19
|
+
InMemoryEventSink,
|
|
20
|
+
NoOpEventSink,
|
|
21
|
+
)
|
|
22
|
+
from agent_reliability.adapters.system_clock import SystemClock
|
|
23
|
+
from agent_reliability.adapters.uuid_run_id_generator import UuidRunIdGenerator
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CompositeEventSink",
|
|
27
|
+
"InMemoryEventSink",
|
|
28
|
+
"NoOpEventSink",
|
|
29
|
+
"SystemClock",
|
|
30
|
+
"UuidRunIdGenerator",
|
|
31
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Default ``EventSink`` implementations.
|
|
2
|
+
|
|
3
|
+
``NoOpEventSink`` is what ``AgentReliability()`` uses when no sink is
|
|
4
|
+
given — a library should not silently print to the console by default,
|
|
5
|
+
and doing nothing observable until a caller opts in is the least
|
|
6
|
+
surprising default for production embedding (docs/SDK_DESIGN.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from agent_reliability.ports.event_sink import EventSink
|
|
14
|
+
from agent_reliability.ports.events import InstrumentationEvent
|
|
15
|
+
|
|
16
|
+
__all__ = ["CompositeEventSink", "InMemoryEventSink", "NoOpEventSink"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoOpEventSink:
|
|
20
|
+
"""Discards every event. The default sink."""
|
|
21
|
+
|
|
22
|
+
def emit(self, event: InstrumentationEvent) -> None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InMemoryEventSink:
|
|
27
|
+
"""Appends every event to a list, held in memory for the sink's
|
|
28
|
+
lifetime.
|
|
29
|
+
|
|
30
|
+
Intended for tests and local examples — **not** a production
|
|
31
|
+
default. Unbounded accumulation of every event for the life of the
|
|
32
|
+
process is exactly the kind of memory growth this project's
|
|
33
|
+
engineering principles warn against as a default (docs/SDK_DESIGN.md,
|
|
34
|
+
"Memory safety"). ``events`` is safe to read/append from multiple
|
|
35
|
+
threads (protected by an internal lock); it is still an unbounded,
|
|
36
|
+
ever-growing list, by design, for this sink.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._events: list[InstrumentationEvent] = []
|
|
42
|
+
|
|
43
|
+
def emit(self, event: InstrumentationEvent) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._events.append(event)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def events(self) -> list[InstrumentationEvent]:
|
|
49
|
+
"""A snapshot copy of the events received so far."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
return list(self._events)
|
|
52
|
+
|
|
53
|
+
def clear(self) -> None:
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._events.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CompositeEventSink:
|
|
59
|
+
"""Fans one event out to multiple sinks.
|
|
60
|
+
|
|
61
|
+
Each child's ``emit`` is called independently; one child raising
|
|
62
|
+
does not stop delivery to the others (the exception is allowed to
|
|
63
|
+
propagate to the SDK's own failure-isolation wrapper around this
|
|
64
|
+
composite's ``emit`` call, which reports it via diagnostics exactly
|
|
65
|
+
as it would for a single misbehaving sink — see
|
|
66
|
+
docs/adr/0004-instrumentation-failure-isolation.md). The *first*
|
|
67
|
+
child to raise is what gets reported; later children still run.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, sinks: list[EventSink]) -> None:
|
|
71
|
+
self._sinks = list(sinks)
|
|
72
|
+
|
|
73
|
+
def emit(self, event: InstrumentationEvent) -> None:
|
|
74
|
+
first_error: Exception | None = None
|
|
75
|
+
for sink in self._sinks:
|
|
76
|
+
try:
|
|
77
|
+
sink.emit(event)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
if first_error is None:
|
|
80
|
+
first_error = exc
|
|
81
|
+
if first_error is not None:
|
|
82
|
+
raise first_error
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Optional OpenTelemetry interoperability adapter.
|
|
2
|
+
|
|
3
|
+
Install ``agent-reliability[otel]`` before importing this module. The adapter
|
|
4
|
+
uses the OpenTelemetry API but never configures a provider or exporter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from agent_reliability.adapters.otel.run_context import (
|
|
10
|
+
OpenTelemetryRunContextBridge,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["OpenTelemetryRunContextBridge"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""OpenTelemetry implementation of the run-context bridge port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import AbstractContextManager, suppress
|
|
6
|
+
|
|
7
|
+
from opentelemetry import trace
|
|
8
|
+
from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer
|
|
9
|
+
|
|
10
|
+
from agent_reliability import __version__
|
|
11
|
+
from agent_reliability.domain import AgentRun, RunStatus
|
|
12
|
+
from agent_reliability.ports.run_context import RunContextScope
|
|
13
|
+
|
|
14
|
+
__all__ = ["OpenTelemetryRunContextBridge"]
|
|
15
|
+
|
|
16
|
+
_INSTRUMENTATION_SCOPE_NAME = "agent_reliability"
|
|
17
|
+
_SPAN_NAME = "invoke_agent"
|
|
18
|
+
_SCHEMA_VERSION = "1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenTelemetryRunContextBridge:
|
|
22
|
+
"""Creates a current internal span for each initialized SDK run.
|
|
23
|
+
|
|
24
|
+
Passing a tracer is useful for explicit provider ownership and tests. If
|
|
25
|
+
omitted, the bridge obtains a tracer from the host-configured global
|
|
26
|
+
provider; it never installs or changes that provider.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, tracer: Tracer | None = None) -> None:
|
|
30
|
+
if tracer is not None and not isinstance(tracer, Tracer):
|
|
31
|
+
raise TypeError("tracer must implement opentelemetry.trace.Tracer")
|
|
32
|
+
self._tracer = (
|
|
33
|
+
tracer
|
|
34
|
+
if tracer is not None
|
|
35
|
+
else trace.get_tracer(_INSTRUMENTATION_SCOPE_NAME, __version__)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def start(self, run: AgentRun) -> RunContextScope:
|
|
39
|
+
attributes: dict[str, str] = {
|
|
40
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
41
|
+
"gen_ai.agent.name": run.agent.name,
|
|
42
|
+
"agent_reliability.schema.version": _SCHEMA_VERSION,
|
|
43
|
+
"agent_reliability.agent.id": run.agent.agent_id,
|
|
44
|
+
"agent_reliability.agent.version": run.agent.version,
|
|
45
|
+
"agent_reliability.run.id": run.run_id,
|
|
46
|
+
}
|
|
47
|
+
if run.agent.environment is not None:
|
|
48
|
+
attributes["agent_reliability.agent.environment"] = run.agent.environment
|
|
49
|
+
if run.parent_run_id is not None:
|
|
50
|
+
attributes["agent_reliability.run.parent_id"] = run.parent_run_id
|
|
51
|
+
|
|
52
|
+
span = self._tracer.start_span(
|
|
53
|
+
_SPAN_NAME, kind=SpanKind.INTERNAL, attributes=attributes
|
|
54
|
+
)
|
|
55
|
+
activation = trace.use_span(
|
|
56
|
+
span,
|
|
57
|
+
end_on_exit=False,
|
|
58
|
+
record_exception=False,
|
|
59
|
+
set_status_on_exception=False,
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
activation.__enter__()
|
|
63
|
+
except BaseException:
|
|
64
|
+
# Preserve the activation failure. There is no active scope for
|
|
65
|
+
# the SDK to finish, and the host runtime owns any additional
|
|
66
|
+
# OpenTelemetry diagnostics.
|
|
67
|
+
with suppress(BaseException):
|
|
68
|
+
span.end()
|
|
69
|
+
raise
|
|
70
|
+
return _OpenTelemetryRunContextScope(span=span, activation=activation)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _OpenTelemetryRunContextScope:
|
|
74
|
+
def __init__(self, *, span: Span, activation: AbstractContextManager[Span]) -> None:
|
|
75
|
+
self._span = span
|
|
76
|
+
self._activation = activation
|
|
77
|
+
self._closed = False
|
|
78
|
+
|
|
79
|
+
def finish(self, *, status: RunStatus, exception_type: str | None) -> None:
|
|
80
|
+
if self._closed:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
first_error: BaseException | None = None
|
|
84
|
+
try:
|
|
85
|
+
try:
|
|
86
|
+
self._span.set_attribute("agent_reliability.run.status", status.value)
|
|
87
|
+
if status is RunStatus.FAILED:
|
|
88
|
+
if exception_type is not None:
|
|
89
|
+
self._span.set_attribute("error.type", exception_type)
|
|
90
|
+
self._span.set_status(Status(StatusCode.ERROR))
|
|
91
|
+
except BaseException as exc:
|
|
92
|
+
first_error = exc
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
self._activation.__exit__(None, None, None)
|
|
96
|
+
except BaseException as exc:
|
|
97
|
+
if first_error is None:
|
|
98
|
+
first_error = exc
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
self._span.end()
|
|
102
|
+
except BaseException as exc:
|
|
103
|
+
if first_error is None:
|
|
104
|
+
first_error = exc
|
|
105
|
+
finally:
|
|
106
|
+
self._closed = True
|
|
107
|
+
|
|
108
|
+
if first_error is not None:
|
|
109
|
+
raise first_error
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The default ``Clock`` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
__all__ = ["SystemClock"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SystemClock:
|
|
11
|
+
"""Reads the real system clock, normalized to UTC."""
|
|
12
|
+
|
|
13
|
+
def now(self) -> datetime:
|
|
14
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The default ``RunIdGenerator`` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
__all__ = ["UuidRunIdGenerator"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UuidRunIdGenerator:
|
|
11
|
+
"""Generates run ids via ``uuid.uuid4()`` — random, not sequential,
|
|
12
|
+
globally-unique-compatible with no coordination required."""
|
|
13
|
+
|
|
14
|
+
def generate(self) -> str:
|
|
15
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Domain layer: pure reliability concepts (Run, Evaluation, SLI, SLO,
|
|
2
|
+
Error Budget). See docs/DOMAIN_MODEL.md, docs/SLO_SEMANTICS.md, and
|
|
3
|
+
ADR-0002 for the specification these types implement.
|
|
4
|
+
|
|
5
|
+
Rules for this package, enforced by review (not yet by tooling):
|
|
6
|
+
|
|
7
|
+
- No imports from ``agent_reliability.adapters``.
|
|
8
|
+
- No imports of network, filesystem, or database libraries.
|
|
9
|
+
- No dependency on any specific LLM provider or agent framework.
|
|
10
|
+
- Values are immutable.
|
|
11
|
+
|
|
12
|
+
The exports of this subpackage are part of the stable 1.0 contract documented
|
|
13
|
+
in docs/GA_CONTRACT.md. They are not re-exported from the
|
|
14
|
+
``agent_reliability`` package root, which exposes only ``__version__``.
|
|
15
|
+
|
|
16
|
+
M4 evaluator execution/provenance lives in the separate
|
|
17
|
+
``agent_reliability.evaluation`` namespace and depends on this domain, never
|
|
18
|
+
the reverse; see docs/ARCHITECTURE.md.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from agent_reliability.domain.error_budget import (
|
|
24
|
+
BudgetStatus,
|
|
25
|
+
BurnRate,
|
|
26
|
+
ErrorBudget,
|
|
27
|
+
compute_burn_rate,
|
|
28
|
+
compute_error_budget,
|
|
29
|
+
)
|
|
30
|
+
from agent_reliability.domain.evaluation import EvaluationOutcome
|
|
31
|
+
from agent_reliability.domain.identity import AgentIdentity
|
|
32
|
+
from agent_reliability.domain.runs import AgentRun, RunStatus
|
|
33
|
+
from agent_reliability.domain.sli import (
|
|
34
|
+
ObservationCounts,
|
|
35
|
+
RatioResult,
|
|
36
|
+
UnknownPolicy,
|
|
37
|
+
compute_ratio,
|
|
38
|
+
)
|
|
39
|
+
from agent_reliability.domain.slo import (
|
|
40
|
+
ObjectiveDirection,
|
|
41
|
+
Slo,
|
|
42
|
+
SloEvaluation,
|
|
43
|
+
SloStatus,
|
|
44
|
+
evaluate_slo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AgentIdentity",
|
|
49
|
+
"AgentRun",
|
|
50
|
+
"BudgetStatus",
|
|
51
|
+
"BurnRate",
|
|
52
|
+
"ErrorBudget",
|
|
53
|
+
"EvaluationOutcome",
|
|
54
|
+
"ObjectiveDirection",
|
|
55
|
+
"ObservationCounts",
|
|
56
|
+
"RatioResult",
|
|
57
|
+
"RunStatus",
|
|
58
|
+
"Slo",
|
|
59
|
+
"SloEvaluation",
|
|
60
|
+
"SloStatus",
|
|
61
|
+
"UnknownPolicy",
|
|
62
|
+
"compute_burn_rate",
|
|
63
|
+
"compute_error_budget",
|
|
64
|
+
"compute_ratio",
|
|
65
|
+
"evaluate_slo",
|
|
66
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Error budget and burn rate.
|
|
2
|
+
|
|
3
|
+
Both are, mechanically, the same computation —
|
|
4
|
+
``observed_bad_fraction / allowed_bad_fraction`` — applied to different
|
|
5
|
+
observation windows: a full window for the cumulative error budget, an
|
|
6
|
+
arbitrary shorter lookback for burn rate (docs/SLO_SEMANTICS.md,
|
|
7
|
+
ADR-0002). ``_consumption`` below is the single shared implementation;
|
|
8
|
+
``compute_error_budget`` and ``compute_burn_rate`` are thin, differently
|
|
9
|
+
-shaped public wrappers around it, so the two can never silently drift
|
|
10
|
+
apart.
|
|
11
|
+
|
|
12
|
+
Three states are distinguished (``BudgetStatus``), because two
|
|
13
|
+
different situations both prevent an ordinary finite number from being
|
|
14
|
+
produced, and they are not the same situation:
|
|
15
|
+
|
|
16
|
+
- ``NO_DATA``: no considered observations at all (division is
|
|
17
|
+
undefined because there is nothing to divide).
|
|
18
|
+
- ``ZERO_TOLERANCE_INTACT`` / ``ZERO_TOLERANCE_EXCEEDED``: data exists,
|
|
19
|
+
but the SLO's target is 100% (``AT_LEAST``) or 0% (``AT_MOST``), so
|
|
20
|
+
``allowed_bad_fraction == 0``. Zero observed bad events against a
|
|
21
|
+
zero-tolerance budget is well-defined (fully intact); any observed
|
|
22
|
+
bad event makes the true consumption unbounded, which
|
|
23
|
+
``fractions.Fraction`` cannot represent (it has no infinity) — this
|
|
24
|
+
is reported as its own status with the numeric value left ``None``,
|
|
25
|
+
rather than smuggled in as a ``float('inf')``.
|
|
26
|
+
|
|
27
|
+
See ADR-0002 for the full reasoning and alternatives considered.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import enum
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from fractions import Fraction
|
|
35
|
+
|
|
36
|
+
from agent_reliability.domain.sli import RatioResult
|
|
37
|
+
from agent_reliability.domain.slo import Slo
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"BudgetStatus",
|
|
41
|
+
"BurnRate",
|
|
42
|
+
"ErrorBudget",
|
|
43
|
+
"compute_burn_rate",
|
|
44
|
+
"compute_error_budget",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BudgetStatus(enum.Enum):
|
|
49
|
+
"""Shared status for any bad-fraction-over-allowed-fraction division.
|
|
50
|
+
|
|
51
|
+
Used by both ``ErrorBudget`` (cumulative, full-window) and
|
|
52
|
+
``BurnRate`` (a lookback window) because they are the same
|
|
53
|
+
underlying division — see module docstring.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
MEASURED = "measured"
|
|
57
|
+
NO_DATA = "no_data"
|
|
58
|
+
ZERO_TOLERANCE_INTACT = "zero_tolerance_intact"
|
|
59
|
+
ZERO_TOLERANCE_EXCEEDED = "zero_tolerance_exceeded"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _consumption(
|
|
63
|
+
allowed_bad_fraction: Fraction, ratio: RatioResult
|
|
64
|
+
) -> tuple[BudgetStatus, Fraction | None]:
|
|
65
|
+
"""The single shared computation behind both public functions below.
|
|
66
|
+
|
|
67
|
+
Returns ``(status, value)`` where ``value`` is
|
|
68
|
+
``observed_bad_fraction / allowed_bad_fraction`` when that division
|
|
69
|
+
is well-defined, and ``None`` otherwise (with ``status`` explaining
|
|
70
|
+
why — see module docstring).
|
|
71
|
+
"""
|
|
72
|
+
observed_bad_fraction = ratio.fail_ratio
|
|
73
|
+
if observed_bad_fraction is None:
|
|
74
|
+
return BudgetStatus.NO_DATA, None
|
|
75
|
+
if allowed_bad_fraction == 0:
|
|
76
|
+
if observed_bad_fraction == 0:
|
|
77
|
+
return BudgetStatus.ZERO_TOLERANCE_INTACT, Fraction(0)
|
|
78
|
+
return BudgetStatus.ZERO_TOLERANCE_EXCEEDED, None
|
|
79
|
+
return BudgetStatus.MEASURED, observed_bad_fraction / allowed_bad_fraction
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ErrorBudget:
|
|
84
|
+
"""The error budget implied by an ``Slo`` over one observation window.
|
|
85
|
+
|
|
86
|
+
``allowed_bad_events`` may be non-integral (e.g. ``4.995`` for a
|
|
87
|
+
0.5% allowance over 999 considered events) — this is kept exact via
|
|
88
|
+
``Fraction`` rather than rounded, per docs/SLO_SEMANTICS.md.
|
|
89
|
+
``observed_bad_events`` is always a plain, exact integer count (it is
|
|
90
|
+
never reconstructed by multiplying a ratio back out).
|
|
91
|
+
|
|
92
|
+
``consumption_ratio`` and ``remaining_fraction`` are ``None`` iff
|
|
93
|
+
``status`` is ``NO_DATA`` or ``ZERO_TOLERANCE_EXCEEDED``.
|
|
94
|
+
``remaining_fraction`` is not clamped to ``[0, 1]``: a negative value
|
|
95
|
+
means the budget is exhausted and exceeded, and the magnitude is the
|
|
96
|
+
size of the breach (docs/SLO_SEMANTICS.md).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
slo: Slo
|
|
100
|
+
ratio: RatioResult
|
|
101
|
+
status: BudgetStatus
|
|
102
|
+
allowed_bad_fraction: Fraction
|
|
103
|
+
allowed_bad_events: Fraction
|
|
104
|
+
observed_bad_events: int
|
|
105
|
+
consumption_ratio: Fraction | None
|
|
106
|
+
remaining_fraction: Fraction | None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def compute_error_budget(slo: Slo, ratio: RatioResult) -> ErrorBudget:
|
|
110
|
+
"""Compute the error budget implied by ``slo`` over ``ratio``'s window."""
|
|
111
|
+
allowed_bad_fraction = slo.allowed_bad_fraction
|
|
112
|
+
status, consumption_ratio = _consumption(allowed_bad_fraction, ratio)
|
|
113
|
+
remaining_fraction = (
|
|
114
|
+
None if consumption_ratio is None else Fraction(1) - consumption_ratio
|
|
115
|
+
)
|
|
116
|
+
return ErrorBudget(
|
|
117
|
+
slo=slo,
|
|
118
|
+
ratio=ratio,
|
|
119
|
+
status=status,
|
|
120
|
+
allowed_bad_fraction=allowed_bad_fraction,
|
|
121
|
+
allowed_bad_events=allowed_bad_fraction * ratio.considered_count,
|
|
122
|
+
observed_bad_events=ratio.considered_fail_count,
|
|
123
|
+
consumption_ratio=consumption_ratio,
|
|
124
|
+
remaining_fraction=remaining_fraction,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class BurnRate:
|
|
130
|
+
"""How fast a budget is being consumed over one (typically short)
|
|
131
|
+
lookback window, relative to the rate that would exactly exhaust it
|
|
132
|
+
over the SLO's full observation window.
|
|
133
|
+
|
|
134
|
+
``value`` of ``1`` means consuming budget at exactly the sustainable
|
|
135
|
+
rate; ``> 1`` means faster than sustainable. ``None`` iff ``status``
|
|
136
|
+
is ``NO_DATA`` or ``ZERO_TOLERANCE_EXCEEDED`` — see module docstring.
|
|
137
|
+
|
|
138
|
+
``ratio`` represents whatever lookback period the caller chose; this
|
|
139
|
+
function has no notion of window length or time itself (see
|
|
140
|
+
docs/SLO_SEMANTICS.md — multi-window/time-aware alerting is deferred).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
slo: Slo
|
|
144
|
+
ratio: RatioResult
|
|
145
|
+
status: BudgetStatus
|
|
146
|
+
value: Fraction | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def compute_burn_rate(slo: Slo, ratio: RatioResult) -> BurnRate:
|
|
150
|
+
"""Compute the burn rate implied by ``slo`` over ``ratio``'s (lookback)
|
|
151
|
+
window."""
|
|
152
|
+
status, value = _consumption(slo.allowed_bad_fraction, ratio)
|
|
153
|
+
return BurnRate(slo=slo, ratio=ratio, status=status, value=value)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Evaluation outcomes.
|
|
2
|
+
|
|
3
|
+
An ``EvaluationOutcome`` is the categorical result of assessing some
|
|
4
|
+
property of an agent run (task success, correctness, policy compliance,
|
|
5
|
+
...). It is deliberately a three-valued enum, not a ``bool``: missing or
|
|
6
|
+
inconclusive evidence (``UNKNOWN``) is a distinct, first-class outcome,
|
|
7
|
+
never collapsed into ``PASS`` or ``FAIL`` and never represented as
|
|
8
|
+
``None`` (see docs/DOMAIN_MODEL.md).
|
|
9
|
+
|
|
10
|
+
This module intentionally does not define the richer M4 ``EvaluationResult``
|
|
11
|
+
or a separate "reliability observation" wrapper type. For the ratio
|
|
12
|
+
mathematics in ``sli.py``, an eligible observation *is* its outcome — see
|
|
13
|
+
ADR-0002. Evaluator execution and provenance live one layer above in
|
|
14
|
+
``agent_reliability.evaluation``; the mathematical domain does not depend on
|
|
15
|
+
them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import enum
|
|
21
|
+
|
|
22
|
+
__all__ = ["EvaluationOutcome"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EvaluationOutcome(enum.Enum):
|
|
26
|
+
"""The categorical result of one evaluation.
|
|
27
|
+
|
|
28
|
+
``PASS`` and ``FAIL`` are never inferred from a quantitative score;
|
|
29
|
+
thresholding a score into one of these outcomes is evaluator/policy
|
|
30
|
+
behavior that lives outside this domain kernel (see
|
|
31
|
+
docs/DOMAIN_MODEL.md, "Evaluation score" vs. "Evaluation result").
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
PASS = "pass"
|
|
35
|
+
FAIL = "fail"
|
|
36
|
+
UNKNOWN = "unknown"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Agent identity.
|
|
2
|
+
|
|
3
|
+
Deliberately minimal: models only what M1 needs, per docs/DOMAIN_MODEL.md.
|
|
4
|
+
The library intentionally makes no cross-version identity inference:
|
|
5
|
+
``AgentIdentity`` has ordinary structural (dataclass) equality only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
__all__ = ["AgentIdentity"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AgentIdentity:
|
|
17
|
+
"""Identifies what agent, at what version, produced a run.
|
|
18
|
+
|
|
19
|
+
``agent_id``, ``name``, and ``version`` are required: without a
|
|
20
|
+
version an agent's runs cannot be attributed to a specific build for
|
|
21
|
+
reliability comparison, and without a stable ``agent_id`` there is
|
|
22
|
+
no key to compare versions of "the same" agent against later.
|
|
23
|
+
|
|
24
|
+
``environment`` is optional metadata (e.g. "production", "staging").
|
|
25
|
+
It intentionally has no default — a missing value stays missing
|
|
26
|
+
rather than silently defaulting to a guess like "production", which
|
|
27
|
+
could mislabel data whose environment the caller genuinely did not
|
|
28
|
+
specify. Whether/how ``environment`` should scope or filter an SLI's
|
|
29
|
+
population of runs is not decided at M1.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
agent_id: str
|
|
33
|
+
name: str
|
|
34
|
+
version: str
|
|
35
|
+
environment: str | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.agent_id:
|
|
39
|
+
raise ValueError("AgentIdentity.agent_id must not be empty")
|
|
40
|
+
if not self.name:
|
|
41
|
+
raise ValueError("AgentIdentity.name must not be empty")
|
|
42
|
+
if not self.version:
|
|
43
|
+
raise ValueError("AgentIdentity.version must not be empty")
|