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 +8 -0
- evalgate/_version.py +2 -0
- evalgate/api.py +31 -0
- evalgate/cli.py +116 -0
- evalgate/comparison/__init__.py +5 -0
- evalgate/comparison/engine.py +108 -0
- evalgate/engine.py +67 -0
- evalgate/io/__init__.py +19 -0
- evalgate/io/reader.py +110 -0
- evalgate/io/writer.py +139 -0
- evalgate/models/__init__.py +35 -0
- evalgate/models/policy.py +100 -0
- evalgate/models/report.py +113 -0
- evalgate/models/run.py +61 -0
- evalgate/policies/__init__.py +5 -0
- evalgate/policies/engine.py +93 -0
- evalgate/reporting/__init__.py +1 -0
- evalgate/reporting/html.py +234 -0
- evalgate/reporting/terminal.py +68 -0
- evalgate/validation.py +118 -0
- evalgate_ai-0.1.0.dist-info/METADATA +194 -0
- evalgate_ai-0.1.0.dist-info/RECORD +25 -0
- evalgate_ai-0.1.0.dist-info/WHEEL +4 -0
- evalgate_ai-0.1.0.dist-info/entry_points.txt +2 -0
- evalgate_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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
|
+
)
|
evalgate/io/__init__.py
ADDED
|
@@ -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
|
+
]
|