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.
@@ -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)