evalgate-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evalgate/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """EvalGate: AI regression testing and release decision engine."""
2
+
3
+ from evalgate._version import __version__
4
+ from evalgate.api import compare
5
+ from evalgate.io import EvalRun, InputError
6
+ from evalgate.models import Report
7
+
8
+ __all__ = ["EvalRun", "InputError", "Report", "__version__", "compare"]
evalgate/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ # Single source of the version: pyproject.toml reads it via [tool.hatch.version].
2
+ __version__ = "0.1.0"
evalgate/api.py ADDED
@@ -0,0 +1,31 @@
1
+ """Path-based entry point shared by the CLI and the SDK, so both produce identical reports."""
2
+
3
+ from pathlib import Path
4
+
5
+ from evalgate.engine import evaluate, invalid_report
6
+ from evalgate.io import InputError, load_policy, load_run
7
+ from evalgate.models import Diagnostic, Policy, Report, Run
8
+
9
+
10
+ def compare(
11
+ baseline: str | Path,
12
+ candidate: str | Path,
13
+ policy: str | Path = "evalgate.yaml",
14
+ ) -> Report:
15
+ """Load two run directories and a policy, then decide. Bad input yields an INVALID report."""
16
+ diagnostics: list[Diagnostic] = []
17
+ runs: list[Run] = []
18
+ loaded_policy: Policy | None = None
19
+ for run_dir in (baseline, candidate):
20
+ try:
21
+ runs.append(load_run(Path(run_dir)))
22
+ except InputError as exc:
23
+ diagnostics += exc.diagnostics
24
+ try:
25
+ loaded_policy = load_policy(Path(policy))
26
+ except InputError as exc:
27
+ diagnostics += exc.diagnostics
28
+
29
+ if diagnostics or loaded_policy is None:
30
+ return invalid_report(diagnostics)
31
+ return evaluate(runs[0], runs[1], loaded_policy)
evalgate/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """EvalGate command-line interface.
2
+
3
+ Exit codes: 0 = PASS, 1 = FAIL (policy violated), 2 = INVALID input or tool error.
4
+ """
5
+
6
+ import functools
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Annotated, ParamSpec
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from evalgate._version import __version__
15
+ from evalgate.api import compare as run_compare
16
+ from evalgate.io import (
17
+ InputError,
18
+ load_run,
19
+ write_html_report,
20
+ write_json_report,
21
+ write_policy_template,
22
+ )
23
+ from evalgate.reporting.terminal import render, render_diagnostics
24
+ from evalgate.validation import validate_run
25
+
26
+ EXIT_PASS, EXIT_FAIL, EXIT_INVALID = 0, 1, 2
27
+ EXIT_CODES = {"PASS": EXIT_PASS, "FAIL": EXIT_FAIL, "INVALID": EXIT_INVALID}
28
+
29
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
30
+ console = Console()
31
+
32
+ P = ParamSpec("P")
33
+
34
+
35
+ def _fail_closed(command: Callable[P, None]) -> Callable[P, None]:
36
+ """Unexpected errors exit 2, never 1, so a crash can't be mistaken for a policy FAIL."""
37
+
38
+ @functools.wraps(command)
39
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
40
+ try:
41
+ command(*args, **kwargs)
42
+ except (typer.Exit, typer.Abort):
43
+ raise
44
+ except Exception as exc:
45
+ typer.echo(f"internal error: {exc!r}", err=True)
46
+ raise typer.Exit(EXIT_INVALID) from exc
47
+
48
+ return wrapper
49
+
50
+
51
+ def _version(value: bool) -> None:
52
+ if value:
53
+ typer.echo(f"evalgate {__version__}")
54
+ raise typer.Exit()
55
+
56
+
57
+ @app.callback()
58
+ def main(
59
+ version: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True)] = False,
60
+ ) -> None:
61
+ """Compare evaluation runs and decide whether a candidate is releasable."""
62
+
63
+
64
+ @app.command()
65
+ @_fail_closed
66
+ def validate(run_dir: Path) -> None:
67
+ """Check a run directory's structure, completeness and metric validity."""
68
+ try:
69
+ run = load_run(run_dir)
70
+ diagnostics = validate_run(run, str(run_dir))
71
+ except InputError as exc:
72
+ diagnostics = exc.diagnostics
73
+ render_diagnostics(diagnostics, console)
74
+ if any(d.level == "error" for d in diagnostics):
75
+ console.print("[bold yellow]INVALID[/]")
76
+ raise typer.Exit(EXIT_INVALID)
77
+ console.print(f"[bold green]valid[/]: {run.manifest.run_id}, {len(run.cases)} cases")
78
+
79
+
80
+ @app.command()
81
+ @_fail_closed
82
+ def compare(
83
+ baseline: Path,
84
+ candidate: Path,
85
+ policy: Annotated[Path, typer.Option("--policy")] = Path("evalgate.yaml"),
86
+ json_out: Annotated[
87
+ Path | None, typer.Option("--json", help="Write the machine-readable report here.")
88
+ ] = None,
89
+ html_out: Annotated[
90
+ Path | None, typer.Option("--report", help="Write a standalone HTML report here.")
91
+ ] = None,
92
+ ) -> None:
93
+ """Compare a candidate run against a baseline under a release policy."""
94
+ report = run_compare(baseline, candidate, policy)
95
+ # Artifacts first: a terminal rendering problem must not lose the machine-readable result.
96
+ if json_out:
97
+ write_json_report(report, json_out)
98
+ if html_out:
99
+ write_html_report(report, html_out)
100
+ render(report, console)
101
+ raise typer.Exit(EXIT_CODES[report.status])
102
+
103
+
104
+ @app.command()
105
+ @_fail_closed
106
+ def init(
107
+ path: Annotated[Path, typer.Argument()] = Path("evalgate.yaml"),
108
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
109
+ ) -> None:
110
+ """Write a documented example release policy."""
111
+ try:
112
+ write_policy_template(path, force)
113
+ except FileExistsError as exc:
114
+ typer.echo(str(exc), err=True)
115
+ raise typer.Exit(EXIT_INVALID) from exc
116
+ console.print(f"wrote {path}: edit metrics and checks to match your cases.jsonl")
@@ -0,0 +1,5 @@
1
+ """Pure baseline-vs-candidate comparison: case matching and metric deltas."""
2
+
3
+ from evalgate.comparison.engine import compare
4
+
5
+ __all__ = ["compare"]
@@ -0,0 +1,108 @@
1
+ """Paired baseline-vs-candidate comparison. Assumes inputs passed `validate_pair`."""
2
+
3
+ from collections import defaultdict
4
+ from statistics import fmean
5
+
6
+ from evalgate.models import (
7
+ CaseDelta,
8
+ CaseResult,
9
+ CategoryDelta,
10
+ CheckChange,
11
+ Comparison,
12
+ MetricDelta,
13
+ MetricRule,
14
+ Run,
15
+ )
16
+
17
+
18
+ def _worse(delta: float, rule: MetricRule) -> bool:
19
+ return delta < 0 if rule.direction == "maximize" else delta > 0
20
+
21
+
22
+ def _value(case: CaseResult, rule: MetricRule) -> float:
23
+ value = case.value(rule.path)
24
+ if value is None: # validate_pair guarantees presence
25
+ raise ValueError(f"{case.case_id}: missing {rule.path}")
26
+ return value
27
+
28
+
29
+ def compare(baseline: Run, candidate: Run, metrics: dict[str, MetricRule]) -> Comparison:
30
+ """Compare matched cases. Means and deltas use only cases `ok` in both runs."""
31
+ base = {c.case_id: c for c in baseline.cases}
32
+ cand = {c.case_id: c for c in candidate.cases}
33
+ matched = sorted(base.keys() & cand.keys())
34
+ pairs = [(base[i], cand[i]) for i in matched if base[i].status == cand[i].status == "ok"]
35
+
36
+ metric_deltas: list[MetricDelta] = []
37
+ regressed: list[CaseDelta] = []
38
+ improved: list[CaseDelta] = []
39
+ by_category: dict[tuple[str, str], list[tuple[float, float]]] = defaultdict(list)
40
+ for name, rule in sorted(metrics.items()):
41
+ values = [(_value(b, rule), _value(c, rule)) for b, c in pairs]
42
+ base_mean = fmean(b for b, _ in values)
43
+ cand_mean = fmean(c for _, c in values)
44
+ delta = cand_mean - base_mean
45
+ metric_deltas.append(
46
+ MetricDelta(
47
+ name=name,
48
+ path=rule.path,
49
+ direction=rule.direction,
50
+ cases=len(values),
51
+ baseline_mean=base_mean,
52
+ candidate_mean=cand_mean,
53
+ delta_abs=delta,
54
+ delta_pct=delta / abs(base_mean) * 100 if base_mean != 0 else None,
55
+ )
56
+ )
57
+ for (b_case, _), (b, c) in zip(pairs, values, strict=True):
58
+ if c != b:
59
+ change = CaseDelta(
60
+ case_id=b_case.case_id, metric=name, baseline=b, candidate=c, delta_abs=c - b
61
+ )
62
+ (regressed if _worse(c - b, rule) else improved).append(change)
63
+ category = b_case.metadata.get("category")
64
+ if isinstance(category, str):
65
+ by_category[(category, name)].append((b, c))
66
+
67
+ # Largest change first within each metric; case_id breaks ties deterministically.
68
+ def order(d: CaseDelta) -> tuple[str, float, str]:
69
+ return (d.metric, -abs(d.delta_abs), d.case_id)
70
+
71
+ newly_failing: list[CheckChange] = []
72
+ newly_passing: list[CheckChange] = []
73
+ for b_case, c_case in pairs:
74
+ for check in sorted(b_case.checks.keys() & c_case.checks.keys()):
75
+ was, now = b_case.checks[check], c_case.checks[check]
76
+ if was != now:
77
+ flip = CheckChange(case_id=b_case.case_id, check=check)
78
+ (newly_failing if was else newly_passing).append(flip)
79
+
80
+ return Comparison(
81
+ matched_cases=len(matched),
82
+ compared_cases=len(pairs),
83
+ baseline_errors=[i for i in matched if base[i].status == "error"],
84
+ candidate_errors=[i for i in sorted(cand) if cand[i].status == "error"],
85
+ metrics=metric_deltas,
86
+ regressed_cases=sorted(regressed, key=order),
87
+ improved_cases=sorted(improved, key=order),
88
+ newly_failing_checks=newly_failing,
89
+ newly_passing_checks=newly_passing,
90
+ candidate_failing_checks=[
91
+ CheckChange(case_id=i, check=check)
92
+ for i in sorted(cand)
93
+ if cand[i].status == "ok"
94
+ for check, passed in sorted(cand[i].checks.items())
95
+ if not passed
96
+ ],
97
+ categories=[
98
+ CategoryDelta(
99
+ category=category,
100
+ metric=metric,
101
+ cases=len(values),
102
+ baseline_mean=fmean(b for b, _ in values),
103
+ candidate_mean=fmean(c for _, c in values),
104
+ delta_abs=fmean(c for _, c in values) - fmean(b for b, _ in values),
105
+ )
106
+ for (category, metric), values in sorted(by_category.items())
107
+ ],
108
+ )
evalgate/engine.py ADDED
@@ -0,0 +1,67 @@
1
+ """The one decision pipeline shared by CLI and SDK: validate -> compare -> apply policy."""
2
+
3
+ import math
4
+
5
+ from evalgate._version import __version__
6
+ from evalgate.comparison import compare
7
+ from evalgate.models import Comparison, Diagnostic, Policy, Report, Run
8
+ from evalgate.policies import evaluate_policy
9
+ from evalgate.validation import validate_pair, validate_run
10
+
11
+
12
+ def _has_errors(diagnostics: list[Diagnostic]) -> bool:
13
+ return any(d.level == "error" for d in diagnostics)
14
+
15
+
16
+ def _all_finite(c: Comparison) -> bool:
17
+ values = [x for m in c.metrics for x in (m.baseline_mean, m.candidate_mean, m.delta_abs)]
18
+ values += [m.delta_pct for m in c.metrics if m.delta_pct is not None]
19
+ values += [d.delta_abs for d in (*c.regressed_cases, *c.improved_cases)]
20
+ values += [x for g in c.categories for x in (g.baseline_mean, g.candidate_mean, g.delta_abs)]
21
+ return all(math.isfinite(x) for x in values)
22
+
23
+
24
+ def invalid_report(diagnostics: list[Diagnostic], **identity: object) -> Report:
25
+ return Report.model_validate(
26
+ {"evalgate_version": __version__, "status": "INVALID", "diagnostics": diagnostics}
27
+ | identity
28
+ )
29
+
30
+
31
+ def evaluate(baseline: Run, candidate: Run, policy: Policy) -> Report:
32
+ """Decide PASS / FAIL / INVALID. Never PASS on input that failed validation."""
33
+ identity = {"baseline": baseline.manifest, "candidate": candidate.manifest, "policy": policy}
34
+ diagnostics = validate_run(baseline, "baseline") + validate_run(candidate, "candidate")
35
+ if not _has_errors(diagnostics):
36
+ diagnostics += validate_pair(baseline, candidate, policy)
37
+ if _has_errors(diagnostics):
38
+ return invalid_report(diagnostics, **identity)
39
+
40
+ try:
41
+ comparison = compare(baseline, candidate, policy.metrics)
42
+ except OverflowError:
43
+ comparison = None
44
+ if comparison is None or not _all_finite(comparison):
45
+ overflow = Diagnostic(
46
+ level="error",
47
+ code="numeric_overflow",
48
+ message="metric values too large to average or subtract (result is not finite)",
49
+ )
50
+ return invalid_report([*diagnostics, overflow], **identity)
51
+
52
+ rules, rule_diagnostics = evaluate_policy(comparison, policy)
53
+ diagnostics += rule_diagnostics
54
+ if _has_errors(diagnostics):
55
+ status = "INVALID"
56
+ else:
57
+ status = "PASS" if all(r.passed for r in rules) else "FAIL"
58
+ return Report.model_validate(
59
+ {
60
+ "evalgate_version": __version__,
61
+ "status": status,
62
+ "diagnostics": diagnostics,
63
+ "comparison": comparison,
64
+ "rules": rules,
65
+ }
66
+ | identity
67
+ )
@@ -0,0 +1,19 @@
1
+ """Reading and writing run artifacts (run.json + cases.jsonl). Only place that touches files."""
2
+
3
+ from evalgate.io.reader import InputError, load_policy, load_run
4
+ from evalgate.io.writer import (
5
+ EvalRun,
6
+ write_html_report,
7
+ write_json_report,
8
+ write_policy_template,
9
+ )
10
+
11
+ __all__ = [
12
+ "EvalRun",
13
+ "InputError",
14
+ "load_policy",
15
+ "load_run",
16
+ "write_html_report",
17
+ "write_json_report",
18
+ "write_policy_template",
19
+ ]
evalgate/io/reader.py ADDED
@@ -0,0 +1,110 @@
1
+ """Load run directories and policy files. Problems become diagnostics, not tracebacks."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, TypeVar
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, ValidationError
8
+
9
+ from evalgate.models import CaseResult, Diagnostic, Policy, Run, RunManifest
10
+
11
+
12
+ class InputError(Exception):
13
+ """Input could not be loaded. Carries every diagnostic found, not just the first."""
14
+
15
+ def __init__(self, diagnostics: list[Diagnostic]) -> None:
16
+ super().__init__("; ".join(d.message for d in diagnostics))
17
+ self.diagnostics = diagnostics
18
+
19
+
20
+ def _error(code: str, message: str) -> Diagnostic:
21
+ return Diagnostic(level="error", code=code, message=message)
22
+
23
+
24
+ def _schema_errors(source: str, exc: ValidationError) -> list[Diagnostic]:
25
+ return [
26
+ _error("schema", f"{source}: {'.'.join(map(str, e['loc'])) or '<root>'}: {e['msg']}")
27
+ for e in exc.errors()
28
+ ]
29
+
30
+
31
+ M = TypeVar("M", bound=BaseModel)
32
+
33
+
34
+ def _parse(model: type[M], data: str | Any, source: str) -> M:
35
+ try:
36
+ if isinstance(data, str):
37
+ return model.model_validate_json(data)
38
+ return model.model_validate(data)
39
+ except ValidationError as exc:
40
+ raise InputError(_schema_errors(source, exc)) from exc
41
+
42
+
43
+ def _read_text(path: Path) -> str:
44
+ try:
45
+ return path.read_text(encoding="utf-8")
46
+ except FileNotFoundError as exc:
47
+ raise InputError([_error("file_missing", f"{path}: file not found")]) from exc
48
+ except (OSError, UnicodeDecodeError) as exc:
49
+ raise InputError([_error("file_unreadable", f"{path}: {exc}")]) from exc
50
+
51
+
52
+ def load_run(run_dir: Path) -> Run:
53
+ """Read `run.json` + `cases.jsonl`. Raises InputError listing every bad file and line."""
54
+ if not run_dir.is_dir():
55
+ raise InputError([_error("run_dir_missing", f"{run_dir}: not a directory")])
56
+
57
+ diagnostics: list[Diagnostic] = []
58
+ manifest: RunManifest | None = None
59
+ cases: list[CaseResult] = []
60
+
61
+ manifest_path = run_dir / "run.json"
62
+ try:
63
+ manifest = _parse(RunManifest, _read_text(manifest_path), str(manifest_path))
64
+ except InputError as exc:
65
+ diagnostics += exc.diagnostics
66
+
67
+ cases_path = run_dir / "cases.jsonl"
68
+ try:
69
+ lines = _read_text(cases_path).splitlines()
70
+ except InputError as exc:
71
+ diagnostics += exc.diagnostics
72
+ else:
73
+ bad_lines = 0
74
+ for lineno, line in enumerate(lines, start=1):
75
+ if not line.strip():
76
+ continue
77
+ try:
78
+ cases.append(_parse(CaseResult, line, f"{cases_path}:{lineno}"))
79
+ except InputError as exc:
80
+ diagnostics += exc.diagnostics
81
+ bad_lines += 1
82
+ if not cases and not bad_lines:
83
+ diagnostics.append(_error("no_cases", f"{cases_path}: contains no cases"))
84
+
85
+ if diagnostics or manifest is None:
86
+ raise InputError(diagnostics)
87
+ return Run(manifest=manifest, cases=cases)
88
+
89
+
90
+ class _UniqueKeyLoader(yaml.SafeLoader):
91
+ """SafeLoader that rejects duplicate keys; PyYAML keeps the last one, silently."""
92
+
93
+ def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
94
+ seen = set()
95
+ for key_node, _ in node.value:
96
+ key = self.construct_object(key_node, deep=deep)
97
+ if key in seen:
98
+ raise yaml.constructor.ConstructorError(
99
+ None, None, f"duplicate key {key!r}", key_node.start_mark
100
+ )
101
+ seen.add(key)
102
+ return super().construct_mapping(node, deep=deep)
103
+
104
+
105
+ def load_policy(path: Path) -> Policy:
106
+ try:
107
+ data = yaml.load(_read_text(path), Loader=_UniqueKeyLoader) # SafeLoader subclass
108
+ except yaml.YAMLError as exc:
109
+ raise InputError([_error("policy_yaml", f"{path}: invalid YAML: {exc}")]) from exc
110
+ return _parse(Policy, data, str(path))
evalgate/io/writer.py ADDED
@@ -0,0 +1,139 @@
1
+ import os
2
+ from pathlib import Path
3
+ from types import TracebackType
4
+ from typing import IO, Any, Self
5
+
6
+ from evalgate.models import SCHEMA_VERSION, CaseResult, Report, RunManifest
7
+ from evalgate.models.policy import POLICY_TEMPLATE
8
+
9
+
10
+ def _atomic_write(path: Path, text: str) -> None:
11
+ tmp = path.with_name(f".{path.name}.tmp")
12
+ tmp.write_text(text, encoding="utf-8")
13
+ os.replace(tmp, path)
14
+
15
+
16
+ def write_json_report(report: Report, path: Path) -> None:
17
+ _atomic_write(path, report.model_dump_json(indent=2) + "\n")
18
+
19
+
20
+ def write_html_report(report: Report, path: Path) -> None:
21
+ from evalgate.reporting.html import render_html
22
+
23
+ _atomic_write(path, render_html(report))
24
+
25
+
26
+ def write_policy_template(path: Path, force: bool = False) -> None:
27
+ if path.exists() and not force:
28
+ raise FileExistsError(f"{path} already exists; pass --force to overwrite")
29
+ _atomic_write(path, POLICY_TEMPLATE)
30
+
31
+
32
+ class EvalRun:
33
+ """Record case results into a run directory (`run.json` + `cases.jsonl`).
34
+
35
+ Cases stream to a partial file; only a clean exit from the `with` block publishes
36
+ them and writes `run.json` last. An interrupted run therefore never loads as complete,
37
+ and with `overwrite=True` a crash leaves the previous run untouched.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ run_id: str,
43
+ *,
44
+ dataset: str,
45
+ dataset_version: str,
46
+ evaluator_version: str,
47
+ path: str | Path | None = None,
48
+ application: str | None = None,
49
+ application_version: str | None = None,
50
+ overwrite: bool = False,
51
+ **metadata: Any,
52
+ ) -> None:
53
+ clash = sorted(metadata.keys() & RunManifest.model_fields.keys())
54
+ if clash: # e.g. dataset_id= would silently override dataset=
55
+ raise ValueError(f"metadata keys collide with manifest fields: {', '.join(clash)}")
56
+ self.manifest = RunManifest.model_validate(
57
+ {
58
+ "schema_version": SCHEMA_VERSION,
59
+ "run_id": run_id,
60
+ "dataset_id": dataset,
61
+ "dataset_version": dataset_version,
62
+ "evaluator_version": evaluator_version,
63
+ "application": application,
64
+ "application_version": application_version,
65
+ }
66
+ | metadata
67
+ )
68
+ self.path = Path(path) if path is not None else Path("evals") / run_id
69
+ self.overwrite = overwrite
70
+ self._partial = self.path / ".cases.jsonl.partial"
71
+ self._file: IO[str] | None = None
72
+ self._ids: set[str] = set()
73
+
74
+ def __enter__(self) -> Self:
75
+ exists = (self.path / "run.json").exists() or (self.path / "cases.jsonl").exists()
76
+ if exists and not self.overwrite:
77
+ raise FileExistsError(f"{self.path} already holds a run; pass overwrite=True")
78
+ self.path.mkdir(parents=True, exist_ok=True)
79
+ self._ids = set()
80
+ self._file = self._partial.open("w", encoding="utf-8")
81
+ return self
82
+
83
+ def __exit__(
84
+ self,
85
+ exc_type: type[BaseException] | None,
86
+ exc: BaseException | None,
87
+ tb: TracebackType | None,
88
+ ) -> None:
89
+ assert self._file is not None
90
+ self._file.close()
91
+ self._file = None
92
+ if exc_type is not None:
93
+ return # the partial file is never loaded; any previous run stays intact
94
+ # Old run.json goes first so old manifest + new cases can never load together.
95
+ (self.path / "run.json").unlink(missing_ok=True)
96
+ os.replace(self._partial, self.path / "cases.jsonl")
97
+ _atomic_write(self.path / "run.json", self.manifest.model_dump_json(indent=2) + "\n")
98
+
99
+ def _write(self, case: CaseResult) -> None:
100
+ if self._file is None:
101
+ raise RuntimeError("EvalRun.record() must be called inside `with EvalRun(...)`")
102
+ if case.case_id in self._ids:
103
+ raise ValueError(f"duplicate case_id: {case.case_id}")
104
+ self._ids.add(case.case_id)
105
+ self._file.write(case.model_dump_json(exclude_defaults=True) + "\n")
106
+ self._file.flush()
107
+
108
+ def record(
109
+ self,
110
+ case_id: str,
111
+ *,
112
+ scores: dict[str, float] | None = None,
113
+ checks: dict[str, bool] | None = None,
114
+ metrics: dict[str, float] | None = None,
115
+ metadata: dict[str, Any] | None = None,
116
+ ) -> None:
117
+ """Record a successful case. Values are validated now, not at compare time."""
118
+ self._write(
119
+ CaseResult.model_validate(
120
+ {
121
+ "case_id": case_id,
122
+ "status": "ok",
123
+ "scores": scores or {},
124
+ "checks": checks or {},
125
+ "metrics": metrics or {},
126
+ "metadata": metadata or {},
127
+ }
128
+ )
129
+ )
130
+
131
+ def record_error(
132
+ self, case_id: str, error: str, *, metadata: dict[str, Any] | None = None
133
+ ) -> None:
134
+ """Record that the pipeline failed on a case; never invent placeholder scores."""
135
+ self._write(
136
+ CaseResult.model_validate(
137
+ {"case_id": case_id, "status": "error", "error": error, "metadata": metadata or {}}
138
+ )
139
+ )
@@ -0,0 +1,35 @@
1
+ """Pydantic models: run manifest, case result, release policy, report."""
2
+
3
+ from evalgate.models.policy import CheckRules, ComparisonRules, MetricRule, Policy
4
+ from evalgate.models.report import (
5
+ CaseDelta,
6
+ CategoryDelta,
7
+ CheckChange,
8
+ Comparison,
9
+ Diagnostic,
10
+ MetricDelta,
11
+ Report,
12
+ RuleResult,
13
+ Status,
14
+ )
15
+ from evalgate.models.run import SCHEMA_VERSION, CaseResult, Run, RunManifest
16
+
17
+ __all__ = [
18
+ "SCHEMA_VERSION",
19
+ "CaseDelta",
20
+ "CaseResult",
21
+ "CategoryDelta",
22
+ "CheckChange",
23
+ "CheckRules",
24
+ "Comparison",
25
+ "ComparisonRules",
26
+ "Diagnostic",
27
+ "MetricDelta",
28
+ "MetricRule",
29
+ "Policy",
30
+ "Report",
31
+ "RuleResult",
32
+ "Run",
33
+ "RunManifest",
34
+ "Status",
35
+ ]