Source code for fastrag.benchmark.results
"""What one benchmark run produced, and how it is saved and reloaded."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastrag.benchmark.metrics.base import DistributionSummary
from fastrag.exceptions import ConfigurationError
FORMAT_VERSION = 1
[docs]
@dataclass(frozen=True)
class StatusCounts:
"""How a run's samples broke down — never collapsed into one pass/fail count.
A question that errored is never scored as zero: it is tallied in
``errored``, not folded into ``incomplete`` or ``successful``.
"""
successful: int
"""Ran, and every metric produced a score."""
errored: int
"""The pipeline raised."""
incomplete: int
"""Ran, but at least one metric returned ``None``."""
[docs]
@dataclass(frozen=True)
class SampleResult:
"""One sample's outcome: its scores, and enough detail to diagnose them."""
question: str
status: Literal["successful", "errored", "incomplete"]
scores: dict[str, float | None]
answer: str | None = None
retrieved_ids: list[str] = field(default_factory=list)
retrieved_uris: list[str] = field(default_factory=list)
latency_ms: float = 0.0
error: str | None = None
[docs]
@dataclass(frozen=True)
class BenchmarkResult:
"""The outcome of one :meth:`~fastrag.benchmark.benchmark.Benchmark.run`.
``name`` is the only identity a result carries — deliberately no serialized
pipeline configuration, since the benchmark script is already the
readable, version-controlled record of what was run. Keep the script and
its ``results/`` directory together, and name results after their
configuration.
"""
name: str
created_at: datetime
status: StatusCounts
summary: dict[str, DistributionSummary]
samples: list[SampleResult]
duration_s: float
higher_is_better: dict[str, bool] = field(default_factory=dict)
"""Per metric name, whether a higher score is better. Read by
:meth:`~fastrag.benchmark.comparison.Comparison.best`, which is the
only place a result file's *meaning* — not just its numbers — is needed
after the :class:`~fastrag.benchmark.metrics.base.Metric` objects that
ran are gone."""
[docs]
def save(self, path: str | Path) -> None:
"""Write this result as JSON, with ``created_at`` as ISO-8601."""
payload = {
"v": FORMAT_VERSION,
"name": self.name,
"created_at": self.created_at.isoformat(),
"status": asdict(self.status),
"summary": {name: asdict(dist) for name, dist in self.summary.items()},
"samples": [asdict(sample) for sample in self.samples],
"duration_s": self.duration_s,
"higher_is_better": self.higher_is_better,
}
Path(path).write_text(json.dumps(payload, indent=2))
[docs]
@classmethod
def load(cls, path: str | Path) -> BenchmarkResult:
"""Read a result written by :meth:`save`.
Raises:
ConfigurationError: ``path`` is not a result file this version of
the package can read.
"""
payload: dict[str, Any] = json.loads(Path(path).read_text())
if payload.get("v") != FORMAT_VERSION:
raise ConfigurationError(
f"{path}: unsupported benchmark result version {payload.get('v')!r}"
)
return cls(
name=payload["name"],
created_at=datetime.fromisoformat(payload["created_at"]),
status=StatusCounts(**payload["status"]),
summary={
name: DistributionSummary(**dist)
for name, dist in payload["summary"].items()
},
samples=[SampleResult(**sample) for sample in payload["samples"]],
duration_s=payload["duration_s"],
higher_is_better=payload.get("higher_is_better") or {},
)