proofstep-types 0.1.0__tar.gz
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.
- proofstep_types-0.1.0/.gitignore +57 -0
- proofstep_types-0.1.0/PKG-INFO +37 -0
- proofstep_types-0.1.0/README.md +16 -0
- proofstep_types-0.1.0/pyproject.toml +32 -0
- proofstep_types-0.1.0/src/proofstep_types/__init__.py +61 -0
- proofstep_types-0.1.0/src/proofstep_types/common.py +136 -0
- proofstep_types-0.1.0/src/proofstep_types/dataset.py +57 -0
- proofstep_types-0.1.0/src/proofstep_types/gates.py +213 -0
- proofstep_types-0.1.0/src/proofstep_types/py.typed +0 -0
- proofstep_types-0.1.0/src/proofstep_types/results.py +64 -0
- proofstep_types-0.1.0/src/proofstep_types/score.py +150 -0
- proofstep_types-0.1.0/src/proofstep_types/trace.py +144 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
__pycache__/
|
|
3
|
+
.coverage
|
|
4
|
+
.coverage.*
|
|
5
|
+
.docker-data/
|
|
6
|
+
.DS_Store
|
|
7
|
+
.e2e-api.log
|
|
8
|
+
.env
|
|
9
|
+
.env.*
|
|
10
|
+
.env.prod
|
|
11
|
+
# Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
|
|
12
|
+
# negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
|
|
13
|
+
# line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
|
|
14
|
+
# `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
|
|
15
|
+
# anyone who cloned the repository. It was caught by CI running the same step.
|
|
16
|
+
!.env.example
|
|
17
|
+
!.env.prod.example
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.idea/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.next/
|
|
22
|
+
.proofstep/
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.turbo/
|
|
26
|
+
.venv/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
*.key
|
|
30
|
+
*.pem
|
|
31
|
+
*.proofstep.local.yaml
|
|
32
|
+
*.py[cod]
|
|
33
|
+
*.swp
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
# Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
|
|
36
|
+
# Docker volumes
|
|
37
|
+
# Editors / OS
|
|
38
|
+
# is a backup with no access control and no retention.
|
|
39
|
+
# Node
|
|
40
|
+
# Proofstep local state
|
|
41
|
+
# Python
|
|
42
|
+
# Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
|
|
43
|
+
# Secrets and local config — never commit these
|
|
44
|
+
# The e2e stack's server log, written next to the repo so a CI failure can print it.
|
|
45
|
+
# to cover all of them rather than the default filename only.
|
|
46
|
+
~/.proofstep/
|
|
47
|
+
backups/
|
|
48
|
+
build/
|
|
49
|
+
coverage.xml
|
|
50
|
+
credentials.json
|
|
51
|
+
dist/
|
|
52
|
+
htmlcov/
|
|
53
|
+
node_modules/
|
|
54
|
+
out/
|
|
55
|
+
proofstep-*.json
|
|
56
|
+
secrets/
|
|
57
|
+
venv/
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proofstep-types
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared Pydantic models for Proofstep — the wire and domain contract
|
|
5
|
+
Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
|
|
6
|
+
Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/IlaKhan17/proofstep
|
|
8
|
+
Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: pydantic>=2.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# proofstep-types
|
|
23
|
+
|
|
24
|
+
**Shared models** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
25
|
+
agents that knows the difference between a regression and a bad day.
|
|
26
|
+
|
|
27
|
+
Pydantic models shared by every Proofstep package: examples, scores, metrics, gates, spans, and
|
|
28
|
+
traces.
|
|
29
|
+
|
|
30
|
+
Depends on nothing of ours, by design — it is the bottom of the dependency graph, and an
|
|
31
|
+
import-linter contract keeps it there.
|
|
32
|
+
|
|
33
|
+
## Documentation
|
|
34
|
+
|
|
35
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
36
|
+
|
|
37
|
+
Apache-2.0.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# proofstep-types
|
|
2
|
+
|
|
3
|
+
**Shared models** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
4
|
+
agents that knows the difference between a regression and a bad day.
|
|
5
|
+
|
|
6
|
+
Pydantic models shared by every Proofstep package: examples, scores, metrics, gates, spans, and
|
|
7
|
+
traces.
|
|
8
|
+
|
|
9
|
+
Depends on nothing of ours, by design — it is the bottom of the dependency graph, and an
|
|
10
|
+
import-linter contract keeps it there.
|
|
11
|
+
|
|
12
|
+
## Documentation
|
|
13
|
+
|
|
14
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
15
|
+
|
|
16
|
+
Apache-2.0.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "proofstep-types"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared Pydantic models for Proofstep — the wire and domain contract"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 4 - Beta",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
dependencies = ["pydantic>=2.9"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/IlaKhan17/proofstep"
|
|
23
|
+
Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
|
|
24
|
+
Repository = "https://github.com/IlaKhan17/proofstep"
|
|
25
|
+
Issues = "https://github.com/IlaKhan17/proofstep/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/proofstep_types"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared Pydantic models for Proofstep — the wire and domain contract.
|
|
2
|
+
|
|
3
|
+
This package is a leaf: it depends on nothing else of ours, which is what lets the
|
|
4
|
+
SDK, the pure libraries, the API, and the generated TypeScript types all agree on one
|
|
5
|
+
definition. Enforced by the `types-are-leaf` contract in `.importlinter`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib import metadata as _metadata
|
|
9
|
+
|
|
10
|
+
from proofstep_types.common import (
|
|
11
|
+
CaptureMode,
|
|
12
|
+
ExitCode,
|
|
13
|
+
OutputKind,
|
|
14
|
+
ResultStatus,
|
|
15
|
+
Severity,
|
|
16
|
+
SpanType,
|
|
17
|
+
Status,
|
|
18
|
+
Verdict,
|
|
19
|
+
)
|
|
20
|
+
from proofstep_types.dataset import Example, content_hash
|
|
21
|
+
from proofstep_types.gates import (
|
|
22
|
+
CalibrationRequirementSpec,
|
|
23
|
+
CalibrationStatus,
|
|
24
|
+
GateRule,
|
|
25
|
+
GateSet,
|
|
26
|
+
)
|
|
27
|
+
from proofstep_types.results import ExampleResult, TaskError
|
|
28
|
+
from proofstep_types.score import GateResult, Metric, MetricDelta, Score
|
|
29
|
+
from proofstep_types.trace import Span, SpanEvent, TokenUsage, Trace
|
|
30
|
+
|
|
31
|
+
# Read from the installed distribution rather than written here twice. A hand-maintained
|
|
32
|
+
# copy drifts the first time a release bumps one and not the other — which it already did,
|
|
33
|
+
# reporting 0.1.0.dev0 from a 0.1.0 wheel.
|
|
34
|
+
__version__ = _metadata.version("proofstep-types")
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CalibrationRequirementSpec",
|
|
38
|
+
"CalibrationStatus",
|
|
39
|
+
"CaptureMode",
|
|
40
|
+
"Example",
|
|
41
|
+
"ExampleResult",
|
|
42
|
+
"ExitCode",
|
|
43
|
+
"GateResult",
|
|
44
|
+
"GateRule",
|
|
45
|
+
"GateSet",
|
|
46
|
+
"Metric",
|
|
47
|
+
"MetricDelta",
|
|
48
|
+
"OutputKind",
|
|
49
|
+
"ResultStatus",
|
|
50
|
+
"Score",
|
|
51
|
+
"Severity",
|
|
52
|
+
"Span",
|
|
53
|
+
"SpanEvent",
|
|
54
|
+
"SpanType",
|
|
55
|
+
"Status",
|
|
56
|
+
"TaskError",
|
|
57
|
+
"TokenUsage",
|
|
58
|
+
"Trace",
|
|
59
|
+
"Verdict",
|
|
60
|
+
"content_hash",
|
|
61
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Enumerations shared across the wire contract.
|
|
2
|
+
|
|
3
|
+
These are plain string enums rather than PostgreSQL enum types: adding a value to a
|
|
4
|
+
PG enum inside a transaction alongside other DDL is a recurring migration hazard, so
|
|
5
|
+
the database stores text with a CHECK constraint instead (docs/DATABASE_DESIGN.md §0).
|
|
6
|
+
|
|
7
|
+
Clients must tolerate unknown values. Deserializers degrade rather than raise — a
|
|
8
|
+
server that learns a new span type must not break older SDKs (docs/API_DESIGN.md §4).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpanType(StrEnum):
|
|
17
|
+
"""What kind of operation a span represents."""
|
|
18
|
+
|
|
19
|
+
AGENT = "agent"
|
|
20
|
+
WORKFLOW = "workflow"
|
|
21
|
+
LLM = "llm"
|
|
22
|
+
TOOL = "tool"
|
|
23
|
+
RETRIEVER = "retriever"
|
|
24
|
+
EMBEDDING = "embedding"
|
|
25
|
+
GUARDRAIL = "guardrail"
|
|
26
|
+
EVALUATOR = "evaluator"
|
|
27
|
+
CUSTOM = "custom"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def _missing_(cls, value: object) -> SpanType: # noqa: ARG003 — signature fixed by Enum
|
|
31
|
+
"""Degrade unknown span types to CUSTOM instead of raising."""
|
|
32
|
+
return cls.CUSTOM
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Status(StrEnum):
|
|
36
|
+
"""Terminal status of a span, trace, or task execution."""
|
|
37
|
+
|
|
38
|
+
OK = "ok"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
TIMEOUT = "timeout"
|
|
41
|
+
UNSET = "unset"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Severity(StrEnum):
|
|
45
|
+
"""How a rule or gate failure should be treated."""
|
|
46
|
+
|
|
47
|
+
BLOCK = "block"
|
|
48
|
+
WARN = "warn"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Verdict(StrEnum):
|
|
52
|
+
"""Outcome of a gate, a gate set, or a whole run.
|
|
53
|
+
|
|
54
|
+
ERROR is distinct from FAIL on purpose: a metric that could not be computed is
|
|
55
|
+
not the same as a metric that came out bad, and collapsing the two is how a
|
|
56
|
+
broken evaluator silently reports success (docs/EVALUATION_ENGINE.md §1).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
PASS = "pass" # noqa: S105 — a verdict, not a credential
|
|
60
|
+
WARN = "warn"
|
|
61
|
+
FAIL = "fail"
|
|
62
|
+
ERROR = "error"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_blocking(self) -> bool:
|
|
66
|
+
return self in (Verdict.FAIL, Verdict.ERROR)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CaptureMode(StrEnum):
|
|
70
|
+
"""How much payload data is retained.
|
|
71
|
+
|
|
72
|
+
Ordered most- to least-permissive; `resolve()` picks the most restrictive of
|
|
73
|
+
several settings, which is how project, environment, SDK, and per-span settings
|
|
74
|
+
combine (docs/SECURITY.md §8).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
FULL = "full"
|
|
78
|
+
REDACTED = "redacted"
|
|
79
|
+
METADATA_ONLY = "metadata_only"
|
|
80
|
+
DISABLED = "disabled"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def rank(self) -> int:
|
|
84
|
+
return _CAPTURE_RANK[self]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def stores_payloads(self) -> bool:
|
|
88
|
+
return self in (CaptureMode.FULL, CaptureMode.REDACTED)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def resolve(cls, *modes: CaptureMode | None) -> CaptureMode:
|
|
92
|
+
"""Return the most restrictive of the given modes.
|
|
93
|
+
|
|
94
|
+
Defaults to REDACTED when nothing is specified: the safe default is not
|
|
95
|
+
collecting, because data that is never stored cannot leak.
|
|
96
|
+
"""
|
|
97
|
+
present = [m for m in modes if m is not None]
|
|
98
|
+
if not present:
|
|
99
|
+
return cls.REDACTED
|
|
100
|
+
return max(present, key=lambda m: m.rank)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_CAPTURE_RANK: dict[CaptureMode, int] = {
|
|
104
|
+
CaptureMode.FULL: 0,
|
|
105
|
+
CaptureMode.REDACTED: 1,
|
|
106
|
+
CaptureMode.METADATA_ONLY: 2,
|
|
107
|
+
CaptureMode.DISABLED: 3,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OutputKind(StrEnum):
|
|
112
|
+
"""The shape of the value an evaluator produces."""
|
|
113
|
+
|
|
114
|
+
BINARY = "binary"
|
|
115
|
+
SCORE = "score"
|
|
116
|
+
CATEGORICAL = "categorical"
|
|
117
|
+
NUMERIC = "numeric"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ResultStatus(StrEnum):
|
|
121
|
+
"""Outcome of running the task against a single example."""
|
|
122
|
+
|
|
123
|
+
OK = "ok"
|
|
124
|
+
ERROR = "error"
|
|
125
|
+
TIMEOUT = "timeout"
|
|
126
|
+
SKIPPED = "skipped"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ExitCode:
|
|
130
|
+
"""Process exit codes for the CLI (docs/EVALUATION_ENGINE.md §7)."""
|
|
131
|
+
|
|
132
|
+
PASS = 0
|
|
133
|
+
BLOCKING_FAILURE = 1
|
|
134
|
+
EXECUTION_ERROR = 2
|
|
135
|
+
CONFIGURATION_ERROR = 3
|
|
136
|
+
CANCELLED = 130
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Dataset examples — the input side of an evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Example(BaseModel):
|
|
13
|
+
"""One evaluation case: an input, an optional expected result, and metadata.
|
|
14
|
+
|
|
15
|
+
``id`` is the *stable external identifier*. Comparison between experiments
|
|
16
|
+
matches on it rather than on position, because datasets gain and lose examples
|
|
17
|
+
between versions and ordinal matching would silently compare unrelated rows
|
|
18
|
+
(docs/EVALUATION_ENGINE.md §6).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(frozen=True)
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
input: dict[str, Any]
|
|
25
|
+
expected: dict[str, Any] | None = None
|
|
26
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
source_trace_id: str | None = None
|
|
28
|
+
source_span_id: str | None = None
|
|
29
|
+
|
|
30
|
+
def canonical_json(self) -> str:
|
|
31
|
+
"""Deterministic serialization used for dataset content hashing."""
|
|
32
|
+
return json.dumps(
|
|
33
|
+
{
|
|
34
|
+
"id": self.id,
|
|
35
|
+
"input": self.input,
|
|
36
|
+
"expected": self.expected,
|
|
37
|
+
"metadata": self.metadata,
|
|
38
|
+
},
|
|
39
|
+
sort_keys=True,
|
|
40
|
+
separators=(",", ":"),
|
|
41
|
+
ensure_ascii=False,
|
|
42
|
+
default=str,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def content_hash(examples: list[Example]) -> str:
|
|
47
|
+
"""SHA-256 over the canonically serialized examples, in order.
|
|
48
|
+
|
|
49
|
+
This is what makes an experiment provably reproducible: the hash is recorded
|
|
50
|
+
alongside the dataset version id, so a later run can prove it used identical
|
|
51
|
+
data rather than merely claiming the same version label (ADR-012).
|
|
52
|
+
"""
|
|
53
|
+
digest = hashlib.sha256()
|
|
54
|
+
for example in examples:
|
|
55
|
+
digest.update(example.canonical_json().encode("utf-8"))
|
|
56
|
+
digest.update(b"\n")
|
|
57
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Quality gate rules — the contract between a suite file and the gate engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from proofstep_types.common import Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GateRule(BaseModel):
|
|
13
|
+
"""One threshold applied to one metric.
|
|
14
|
+
|
|
15
|
+
``slice`` is what makes protected-class gating possible. A rule can target
|
|
16
|
+
``per_class_recall`` for ``unsubscribe`` specifically rather than the macro
|
|
17
|
+
average — which matters because a 3%-prevalence class can collapse from 0.99
|
|
18
|
+
to 0.20 recall while macro accuracy moves 0.3%, passing every aggregate gate
|
|
19
|
+
(docs/EVALUATION_ENGINE.md §7).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(frozen=True)
|
|
23
|
+
|
|
24
|
+
metric_key: str
|
|
25
|
+
minimum: float | None = None
|
|
26
|
+
maximum: float | None = None
|
|
27
|
+
max_absolute_regression: float | None = None
|
|
28
|
+
max_relative_regression: float | None = None
|
|
29
|
+
severity: Severity = Severity.BLOCK
|
|
30
|
+
slice: dict[str, str] | None = None
|
|
31
|
+
require_baseline: bool = Field(
|
|
32
|
+
default=False,
|
|
33
|
+
description="Fail when no baseline exists, rather than skipping regression checks",
|
|
34
|
+
)
|
|
35
|
+
max_error_rate: float = Field(
|
|
36
|
+
default=0.05,
|
|
37
|
+
description="Above this share of errored evaluations the gate reports ERROR, not PASS",
|
|
38
|
+
)
|
|
39
|
+
significance: float | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
ge=0.0,
|
|
42
|
+
le=1.0,
|
|
43
|
+
description=(
|
|
44
|
+
"Alpha for a paired significance test. When set, a regression must be both larger "
|
|
45
|
+
"than the threshold *and* distinguishable from noise before it fails the build. A "
|
|
46
|
+
"threshold alone says what size of change matters; it cannot say whether the change "
|
|
47
|
+
"is real, and at small sample sizes most measured 'regressions' are neither."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
require_power: bool = Field(
|
|
51
|
+
default=False,
|
|
52
|
+
description=(
|
|
53
|
+
"Report ERROR when the run was too small to have detected the regression this rule "
|
|
54
|
+
"guards against. A green check from a test that could never have failed is worse than "
|
|
55
|
+
"no check, because it is believed."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def needs_significance(self) -> bool:
|
|
61
|
+
"""Whether this rule wants a paired test computed for it.
|
|
62
|
+
|
|
63
|
+
`require_power` counts: reporting that a run could not have detected the regression it
|
|
64
|
+
guards needs the same paired data as testing whether one happened.
|
|
65
|
+
"""
|
|
66
|
+
return self.significance is not None or self.require_power
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def blocking(self) -> bool:
|
|
70
|
+
return self.severity is Severity.BLOCK
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def full_key(self) -> str:
|
|
74
|
+
if not self.slice:
|
|
75
|
+
return self.metric_key
|
|
76
|
+
inner = ",".join(f"{k}={v}" for k, v in sorted(self.slice.items()))
|
|
77
|
+
return f"{self.metric_key}[{inner}]"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def needs_baseline(self) -> bool:
|
|
81
|
+
return self.max_absolute_regression is not None or self.max_relative_regression is not None
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def _at_least_one_condition(self) -> GateRule:
|
|
85
|
+
if not any(
|
|
86
|
+
v is not None
|
|
87
|
+
for v in (
|
|
88
|
+
self.minimum,
|
|
89
|
+
self.maximum,
|
|
90
|
+
self.max_absolute_regression,
|
|
91
|
+
self.max_relative_regression,
|
|
92
|
+
)
|
|
93
|
+
):
|
|
94
|
+
msg = (
|
|
95
|
+
f"Gate on {self.metric_key!r} declares no condition; a gate that "
|
|
96
|
+
"cannot fail is a gate that gives false assurance"
|
|
97
|
+
)
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="after")
|
|
102
|
+
def _bounds_are_ordered(self) -> GateRule:
|
|
103
|
+
if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum:
|
|
104
|
+
msg = (
|
|
105
|
+
f"Gate on {self.metric_key!r} has minimum {self.minimum} above "
|
|
106
|
+
f"maximum {self.maximum}; no value can satisfy it"
|
|
107
|
+
)
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GateSet(BaseModel):
|
|
113
|
+
"""A named collection of gate rules, versioned with the suite that declares it."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(frozen=True)
|
|
116
|
+
|
|
117
|
+
name: str = "default"
|
|
118
|
+
rules: list[GateRule] = Field(default_factory=list)
|
|
119
|
+
require_dataset_match: bool = Field(
|
|
120
|
+
default=True,
|
|
121
|
+
description=(
|
|
122
|
+
"Refuse to gate when candidate and baseline used different dataset "
|
|
123
|
+
"content hashes. Comparing across datasets is a silent source of "
|
|
124
|
+
"confidently wrong conclusions."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
require_calibration: bool | CalibrationRequirementSpec = Field(
|
|
128
|
+
default=False,
|
|
129
|
+
description=(
|
|
130
|
+
"Turn the uncalibrated-judge warning into a hard error. `true` uses the "
|
|
131
|
+
"recommended thresholds; a mapping overrides them."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def calibration_requirement(self) -> CalibrationRequirementSpec | None:
|
|
137
|
+
"""Normalize the bool-or-mapping form into one thing the gate engine reads.
|
|
138
|
+
|
|
139
|
+
`None` means no requirement, so an uncalibrated judge warns rather than blocks.
|
|
140
|
+
Warning rather than silence matters: a gate on a judge nobody has checked is
|
|
141
|
+
the failure this whole subsystem exists to make visible.
|
|
142
|
+
"""
|
|
143
|
+
if self.require_calibration is False:
|
|
144
|
+
return None
|
|
145
|
+
if self.require_calibration is True:
|
|
146
|
+
return CalibrationRequirementSpec()
|
|
147
|
+
return self.require_calibration
|
|
148
|
+
|
|
149
|
+
def rules_for(self, metric_key: str) -> list[GateRule]:
|
|
150
|
+
return [r for r in self.rules if r.metric_key == metric_key]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CalibrationRequirementSpec(BaseModel):
|
|
154
|
+
"""What a gate set demands before it will trust a judge.
|
|
155
|
+
|
|
156
|
+
``require_calibration: true`` in YAML means "these defaults", and a mapping means
|
|
157
|
+
"these defaults with overrides". The defaults are the recommended values for a
|
|
158
|
+
safety-relevant metric (docs/EVALUATION_ENGINE.md §5).
|
|
159
|
+
|
|
160
|
+
``max_false_pass_rate`` is deliberately four times tighter than
|
|
161
|
+
``max_false_fail_rate``. A judge that passes work a human rejected ships a defect;
|
|
162
|
+
one that fails acceptable work annoys somebody. Treating those as the same error is
|
|
163
|
+
how a gate ends up either useless or bypassed.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(frozen=True)
|
|
167
|
+
|
|
168
|
+
required: bool = Field(
|
|
169
|
+
default=True,
|
|
170
|
+
description="Fail the run rather than warn when the requirement is unmet",
|
|
171
|
+
)
|
|
172
|
+
min_agreement: float = Field(default=0.8, ge=0, le=1)
|
|
173
|
+
min_kappa: float | None = Field(default=0.6, ge=-1, le=1)
|
|
174
|
+
max_false_pass_rate: float | None = Field(default=0.05, ge=0, le=1)
|
|
175
|
+
max_false_fail_rate: float | None = Field(default=0.20, ge=0, le=1)
|
|
176
|
+
min_examples: int = Field(default=100, ge=1)
|
|
177
|
+
min_per_class: int = Field(default=50, ge=1)
|
|
178
|
+
max_error_rate: float = Field(default=0.05, ge=0, le=1)
|
|
179
|
+
allow_position_bias: bool = False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class CalibrationStatus(BaseModel):
|
|
183
|
+
"""What is known about one judge metric's calibration at gate time.
|
|
184
|
+
|
|
185
|
+
``calibrated=False`` and "calibrated but failing" are different states with
|
|
186
|
+
different fixes — go and calibrate it, versus go and fix the judge — so they are
|
|
187
|
+
reported separately rather than folded into one boolean.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
model_config = ConfigDict(frozen=True)
|
|
191
|
+
|
|
192
|
+
metric_key: str
|
|
193
|
+
evaluator_name: str = ""
|
|
194
|
+
#: Config hash of the calibrated evaluator version. A judge whose rubric, model, or
|
|
195
|
+
#: parameters changed has a different hash, and an old calibration does not apply to
|
|
196
|
+
#: it — that is the whole point of versioning by config hash.
|
|
197
|
+
evaluator_version_hash: str | None = None
|
|
198
|
+
calibrated: bool = False
|
|
199
|
+
satisfied: bool | None = None
|
|
200
|
+
failures: list[str] = Field(default_factory=list)
|
|
201
|
+
warnings: list[str] = Field(default_factory=list)
|
|
202
|
+
n_examples: int = 0
|
|
203
|
+
agreement: float | None = None
|
|
204
|
+
kappa: float | None = None
|
|
205
|
+
false_pass_rate: float | None = None
|
|
206
|
+
at_human_ceiling: bool = False
|
|
207
|
+
calibrated_at: datetime | None = None
|
|
208
|
+
#: Set when a calibration exists but for a different version of the evaluator.
|
|
209
|
+
stale_for_version: str | None = None
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def is_stale(self) -> bool:
|
|
213
|
+
return self.stale_for_version is not None
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Per-example execution results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from proofstep_types.common import ResultStatus
|
|
12
|
+
from proofstep_types.score import Score
|
|
13
|
+
from proofstep_types.trace import Trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskError(BaseModel):
|
|
17
|
+
model_config = ConfigDict(frozen=True)
|
|
18
|
+
|
|
19
|
+
type: str
|
|
20
|
+
message: str
|
|
21
|
+
traceback: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExampleResult(BaseModel):
|
|
25
|
+
"""The outcome of running the task against one example, with its scores.
|
|
26
|
+
|
|
27
|
+
Serialized one-per-line into the run journal as each example completes, so a
|
|
28
|
+
crash at example 190/200 loses nothing and `--resume` can skip what finished
|
|
29
|
+
(docs/EVALUATION_ENGINE.md §4).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
example_id: str
|
|
33
|
+
status: ResultStatus = ResultStatus.OK
|
|
34
|
+
output: Any = None
|
|
35
|
+
scores: list[Score] = Field(default_factory=list)
|
|
36
|
+
trace: Trace | None = None
|
|
37
|
+
|
|
38
|
+
latency_ms: int = 0
|
|
39
|
+
cost: Decimal = Decimal(0)
|
|
40
|
+
tokens: int = 0
|
|
41
|
+
retry_count: int = 0
|
|
42
|
+
error: TaskError | None = None
|
|
43
|
+
started_at: datetime | None = None
|
|
44
|
+
ended_at: datetime | None = None
|
|
45
|
+
|
|
46
|
+
# Retained so a comparison can slice by any dimension the dataset carried.
|
|
47
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
expected: dict[str, Any] | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def ok(self) -> bool:
|
|
52
|
+
return self.status is ResultStatus.OK
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total_cost(self) -> Decimal:
|
|
56
|
+
"""Task cost plus the cost of evaluating it.
|
|
57
|
+
|
|
58
|
+
Judge spend is part of what a suite costs to run, and hiding it in a
|
|
59
|
+
separate number is how teams end up surprised by the bill.
|
|
60
|
+
"""
|
|
61
|
+
return self.cost + sum((s.cost for s in self.scores), Decimal(0))
|
|
62
|
+
|
|
63
|
+
def score_for(self, metric: str) -> Score | None:
|
|
64
|
+
return next((s for s in self.scores if s.metric == metric), None)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Scores and metrics — what an evaluator produces and what a gate consumes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
from proofstep_types.common import Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Score(BaseModel):
|
|
14
|
+
"""The result of one evaluator applied to one example.
|
|
15
|
+
|
|
16
|
+
The single most important invariant in this model: ``error`` is not ``value=0``.
|
|
17
|
+
A judge that timed out is not a failing example. Aggregation excludes errored
|
|
18
|
+
scores from the mean and counts them separately, and a metric with too high an
|
|
19
|
+
error rate gates as ERROR rather than PASS. Silently scoring infrastructure
|
|
20
|
+
failures as zero is the fastest way to make a gate untrustworthy
|
|
21
|
+
(docs/EVALUATION_ENGINE.md §1).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(frozen=True)
|
|
25
|
+
|
|
26
|
+
metric: str = Field(description="Metric key this score contributes to")
|
|
27
|
+
value: float | None = Field(
|
|
28
|
+
default=None, description="Normalized to [0,1] where meaningful; None if not scalar"
|
|
29
|
+
)
|
|
30
|
+
passed: bool | None = Field(default=None, description="Binary verdict, if applicable")
|
|
31
|
+
label: str | None = Field(default=None, description="Categorical evaluators")
|
|
32
|
+
raw: Any = Field(default=None, description="Non-scalar payload (matrix, list, …)")
|
|
33
|
+
reasoning: str | None = Field(default=None, description="Judge rationale, if any")
|
|
34
|
+
confidence: float | None = None
|
|
35
|
+
cost: Decimal = Decimal(0)
|
|
36
|
+
latency_ms: int = 0
|
|
37
|
+
error: str | None = Field(
|
|
38
|
+
default=None, description="The evaluator itself failed — never conflate with value=0"
|
|
39
|
+
)
|
|
40
|
+
slice: dict[str, str] | None = Field(
|
|
41
|
+
default=None, description="Slice this score belongs to, e.g. {'class': 'unsubscribe'}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def errored(self) -> bool:
|
|
46
|
+
return self.error is not None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def counts_toward_mean(self) -> bool:
|
|
50
|
+
return self.error is None and self.value is not None
|
|
51
|
+
|
|
52
|
+
@model_validator(mode="after")
|
|
53
|
+
def _check_error_has_no_value(self) -> Score:
|
|
54
|
+
if self.error is not None and self.value is not None:
|
|
55
|
+
msg = (
|
|
56
|
+
f"Score for {self.metric!r} has both error and value; an errored "
|
|
57
|
+
"evaluation must not contribute a score"
|
|
58
|
+
)
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def failure(cls, metric: str, error: str, *, latency_ms: int = 0) -> Score:
|
|
64
|
+
"""Construct an errored score. Prefer this over `Score(value=0.0)`."""
|
|
65
|
+
return cls(metric=metric, error=error, latency_ms=latency_ms)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def binary(cls, metric: str, passed: bool, **kw: Any) -> Score:
|
|
69
|
+
return cls(metric=metric, value=1.0 if passed else 0.0, passed=passed, **kw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Metric(BaseModel):
|
|
73
|
+
"""An aggregate over many scores — one row of a comparison table."""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(frozen=True)
|
|
76
|
+
|
|
77
|
+
key: str
|
|
78
|
+
value: float
|
|
79
|
+
count: int = Field(description="Number of scores that contributed")
|
|
80
|
+
error_count: int = Field(default=0, description="Scores excluded because they errored")
|
|
81
|
+
stddev: float | None = None
|
|
82
|
+
ci_low: float | None = None
|
|
83
|
+
ci_high: float | None = None
|
|
84
|
+
unit: str | None = Field(default=None, description="ms, usd, ratio, count, …")
|
|
85
|
+
slice: dict[str, str] | None = None
|
|
86
|
+
aggregation: str = "mean"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def full_key(self) -> str:
|
|
90
|
+
"""Key including its slice, e.g. ``per_class_recall[class=unsubscribe]``."""
|
|
91
|
+
if not self.slice:
|
|
92
|
+
return self.key
|
|
93
|
+
inner = ",".join(f"{k}={v}" for k, v in sorted(self.slice.items()))
|
|
94
|
+
return f"{self.key}[{inner}]"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def error_rate(self) -> float:
|
|
98
|
+
total = self.count + self.error_count
|
|
99
|
+
return self.error_count / total if total else 0.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MetricDelta(BaseModel):
|
|
103
|
+
"""A candidate metric compared against a baseline."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(frozen=True)
|
|
106
|
+
|
|
107
|
+
key: str
|
|
108
|
+
slice: dict[str, str] | None = None
|
|
109
|
+
baseline: float | None
|
|
110
|
+
candidate: float | None
|
|
111
|
+
absolute_delta: float | None = None
|
|
112
|
+
relative_delta: float | None = None
|
|
113
|
+
count: int = 0
|
|
114
|
+
ci_low: float | None = None
|
|
115
|
+
ci_high: float | None = None
|
|
116
|
+
significant: bool | None = Field(
|
|
117
|
+
default=None,
|
|
118
|
+
description=(
|
|
119
|
+
"Bootstrap CI on the delta excludes zero. Advisory only — gates use "
|
|
120
|
+
"thresholds, because at n=200 most real regressions are not significant "
|
|
121
|
+
"and gating on p-values would let them through."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def full_key(self) -> str:
|
|
127
|
+
if not self.slice:
|
|
128
|
+
return self.key
|
|
129
|
+
inner = ",".join(f"{k}={v}" for k, v in sorted(self.slice.items()))
|
|
130
|
+
return f"{self.key}[{inner}]"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GateResult(BaseModel):
|
|
134
|
+
"""The verdict of one gate rule against one metric."""
|
|
135
|
+
|
|
136
|
+
model_config = ConfigDict(frozen=True)
|
|
137
|
+
|
|
138
|
+
metric_key: str
|
|
139
|
+
slice: dict[str, str] | None = None
|
|
140
|
+
verdict: str
|
|
141
|
+
severity: Severity = Severity.BLOCK
|
|
142
|
+
rule: str | None = Field(default=None, description="Which clause fired: minimum, maximum, …")
|
|
143
|
+
threshold: float | None = None
|
|
144
|
+
actual: float | None = None
|
|
145
|
+
baseline: float | None = None
|
|
146
|
+
message: str = ""
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def blocking(self) -> bool:
|
|
150
|
+
return self.severity is Severity.BLOCK
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Traces and spans — the captured record of one workflow execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from proofstep_types.common import SpanType, Status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TokenUsage(BaseModel):
|
|
15
|
+
model_config = ConfigDict(frozen=True)
|
|
16
|
+
|
|
17
|
+
prompt: int = 0
|
|
18
|
+
completion: int = 0
|
|
19
|
+
total: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpanEvent(BaseModel):
|
|
23
|
+
"""A point-in-time occurrence inside a span: a retry, a guardrail trigger.
|
|
24
|
+
|
|
25
|
+
Kept as a distinct type rather than a JSON array on the span because retries and
|
|
26
|
+
guardrail triggers must be independently queryable for operational and trajectory
|
|
27
|
+
evaluators (docs/DATABASE_DESIGN.md §2.2).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(frozen=True)
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
timestamp: datetime
|
|
34
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Span(BaseModel):
|
|
38
|
+
"""One operation inside a trace."""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True)
|
|
41
|
+
|
|
42
|
+
span_id: str
|
|
43
|
+
trace_id: str
|
|
44
|
+
parent_span_id: str | None = None
|
|
45
|
+
name: str
|
|
46
|
+
span_type: SpanType = SpanType.CUSTOM
|
|
47
|
+
status: Status = Status.OK
|
|
48
|
+
status_message: str | None = None
|
|
49
|
+
started_at: datetime
|
|
50
|
+
ended_at: datetime | None = None
|
|
51
|
+
|
|
52
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
input: Any = None
|
|
54
|
+
output: Any = None
|
|
55
|
+
events: list[SpanEvent] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
# Denormalized hot-path fields — these are real columns in the database rather
|
|
58
|
+
# than JSONB keys because they are filtered and aggregated constantly.
|
|
59
|
+
model: str | None = None
|
|
60
|
+
provider: str | None = None
|
|
61
|
+
tokens: TokenUsage | None = None
|
|
62
|
+
cost: Decimal | None = None
|
|
63
|
+
tool_name: str | None = None
|
|
64
|
+
tool_args: dict[str, Any] | None = None
|
|
65
|
+
error_type: str | None = None
|
|
66
|
+
|
|
67
|
+
sequence_index: int = Field(
|
|
68
|
+
default=0,
|
|
69
|
+
description=(
|
|
70
|
+
"Monotonic counter from the SDK. Breaks ordering ties when two spans "
|
|
71
|
+
"share a start timestamp, which clock granularity makes common."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
redaction_count: int = 0
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def duration_ms(self) -> int | None:
|
|
78
|
+
if self.ended_at is None:
|
|
79
|
+
return None
|
|
80
|
+
return int((self.ended_at - self.started_at).total_seconds() * 1000)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_open(self) -> bool:
|
|
84
|
+
return self.ended_at is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Trace(BaseModel):
|
|
88
|
+
"""One complete AI workflow execution."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(frozen=True)
|
|
91
|
+
|
|
92
|
+
trace_id: str
|
|
93
|
+
name: str
|
|
94
|
+
status: Status = Status.OK
|
|
95
|
+
started_at: datetime
|
|
96
|
+
ended_at: datetime | None = None
|
|
97
|
+
spans: list[Span] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
100
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
101
|
+
state: dict[str, Any] = Field(
|
|
102
|
+
default_factory=dict,
|
|
103
|
+
description=(
|
|
104
|
+
"Explicit workflow state set via `set_state`. Gives `final_state` and "
|
|
105
|
+
"`conditional` policy rules a defined data source instead of scraping outputs."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
environment: str | None = None
|
|
110
|
+
git_commit: str | None = None
|
|
111
|
+
dropped_span_count: int = Field(
|
|
112
|
+
default=0,
|
|
113
|
+
description=(
|
|
114
|
+
"Spans the exporter dropped under backpressure. Non-zero makes the "
|
|
115
|
+
"trajectory `incomplete`, which turns `required_*` policy rules into "
|
|
116
|
+
"`inconclusive` rather than `fail` — asserting absence over incomplete "
|
|
117
|
+
"data is unsound (docs/TRAJECTORY_POLICIES.md §4)."
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def duration_ms(self) -> int | None:
|
|
123
|
+
if self.ended_at is None:
|
|
124
|
+
return None
|
|
125
|
+
return int((self.ended_at - self.started_at).total_seconds() * 1000)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_complete(self) -> bool:
|
|
129
|
+
return self.dropped_span_count == 0 and not any(s.is_open for s in self.spans)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def total_cost(self) -> Decimal:
|
|
133
|
+
return sum((s.cost for s in self.spans if s.cost is not None), Decimal(0))
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def total_tokens(self) -> int:
|
|
137
|
+
return sum(s.tokens.total for s in self.spans if s.tokens is not None)
|
|
138
|
+
|
|
139
|
+
def spans_by_type(self, *types: SpanType) -> list[Span]:
|
|
140
|
+
wanted = set(types)
|
|
141
|
+
return [s for s in self.spans if s.span_type in wanted]
|
|
142
|
+
|
|
143
|
+
def find_span(self, span_id: str) -> Span | None:
|
|
144
|
+
return next((s for s in self.spans if s.span_id == span_id), None)
|