ragops 1.0.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.
- ragops/__init__.py +30 -0
- ragops/adapters/__init__.py +16 -0
- ragops/adapters/external_metrics.py +134 -0
- ragops/adapters/http.py +34 -0
- ragops/adapters/provider_metrics.py +150 -0
- ragops/adapters/repeated_runs.py +284 -0
- ragops/adapters/signing.py +124 -0
- ragops/baseline.py +250 -0
- ragops/benchmarks.py +25 -0
- ragops/cli.py +658 -0
- ragops/config.py +290 -0
- ragops/control_plane.py +125 -0
- ragops/demo.py +327 -0
- ragops/drift.py +158 -0
- ragops/engine.py +225 -0
- ragops/evaluators.py +70 -0
- ragops/loader.py +243 -0
- ragops/models.py +366 -0
- ragops/pilot.py +425 -0
- ragops/plugins.py +153 -0
- ragops/provenance.py +65 -0
- ragops/providers/__init__.py +5 -0
- ragops/providers/openai_responses.py +84 -0
- ragops/reporters.py +263 -0
- ragops/sequential.py +287 -0
- ragops/statistical.py +436 -0
- ragops/store.py +170 -0
- ragops/traces.py +60 -0
- ragops-1.0.0.dist-info/METADATA +265 -0
- ragops-1.0.0.dist-info/RECORD +33 -0
- ragops-1.0.0.dist-info/WHEEL +4 -0
- ragops-1.0.0.dist-info/entry_points.txt +2 -0
- ragops-1.0.0.dist-info/licenses/LICENSE +21 -0
ragops/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""RAGOps public API."""
|
|
2
|
+
|
|
3
|
+
from ragops.engine import compare, evaluate
|
|
4
|
+
from ragops.drift import detect_evaluator_drift
|
|
5
|
+
from ragops.loader import load_responses, load_scenario, responses_from_data, scenario_from_dict
|
|
6
|
+
from ragops.statistical import (
|
|
7
|
+
compare_replay_bundles,
|
|
8
|
+
load_replay_bundle,
|
|
9
|
+
replay_bundle_from_dict,
|
|
10
|
+
)
|
|
11
|
+
from ragops.provenance import diagnose_provenance
|
|
12
|
+
from ragops.sequential import compare_replay_bundles_sequentially
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"compare",
|
|
18
|
+
"compare_replay_bundles_sequentially",
|
|
19
|
+
"detect_evaluator_drift",
|
|
20
|
+
"diagnose_provenance",
|
|
21
|
+
"evaluate",
|
|
22
|
+
"compare_replay_bundles",
|
|
23
|
+
"load_replay_bundle",
|
|
24
|
+
"load_responses",
|
|
25
|
+
"load_scenario",
|
|
26
|
+
"responses_from_data",
|
|
27
|
+
"replay_bundle_from_dict",
|
|
28
|
+
"scenario_from_dict",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Optional dependency-free integration adapters."""
|
|
2
|
+
|
|
3
|
+
from .http import AdapterError, post_json
|
|
4
|
+
from .external_metrics import (
|
|
5
|
+
ExternalMetricEvaluator,
|
|
6
|
+
load_external_metric_evaluator,
|
|
7
|
+
validate_external_metric_pair,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AdapterError",
|
|
12
|
+
"ExternalMetricEvaluator",
|
|
13
|
+
"load_external_metric_evaluator",
|
|
14
|
+
"post_json",
|
|
15
|
+
"validate_external_metric_pair",
|
|
16
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ragops.loader import ContractError
|
|
10
|
+
from ragops.models import EvalCase, RecordedResponse, Scenario
|
|
11
|
+
from ragops.plugins import PluginResult
|
|
12
|
+
|
|
13
|
+
ALLOWED_PROVIDERS = {"ragas", "deepeval", "langfuse", "custom"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ExternalMetricEnvelope:
|
|
18
|
+
provider: str
|
|
19
|
+
metrics_by_case: dict[str, dict[str, float]]
|
|
20
|
+
metric_names: frozenset[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExternalMetricEvaluator:
|
|
24
|
+
"""Expose a portable external metric envelope through the evaluator protocol."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, envelope: ExternalMetricEnvelope) -> None:
|
|
27
|
+
self.name = envelope.provider
|
|
28
|
+
self._metrics_by_case = envelope.metrics_by_case
|
|
29
|
+
self.metric_names = envelope.metric_names
|
|
30
|
+
|
|
31
|
+
def evaluate(self, case: EvalCase, response: RecordedResponse) -> PluginResult:
|
|
32
|
+
try:
|
|
33
|
+
metrics = self._metrics_by_case[case.id]
|
|
34
|
+
except KeyError as exc:
|
|
35
|
+
raise ContractError(f"External metrics are missing case {case.id!r}") from exc
|
|
36
|
+
return PluginResult(metrics=metrics)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_external_metric_evaluator(
|
|
40
|
+
path: str | Path,
|
|
41
|
+
scenario: Scenario,
|
|
42
|
+
) -> ExternalMetricEvaluator:
|
|
43
|
+
try:
|
|
44
|
+
data: Any = json.loads(
|
|
45
|
+
Path(path).read_text(encoding="utf-8"),
|
|
46
|
+
parse_constant=lambda value: (_ for _ in ()).throw(
|
|
47
|
+
ValueError(f"non-finite JSON number {value}")
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
51
|
+
raise ContractError(f"Cannot load external metrics from {path}: {exc}") from exc
|
|
52
|
+
envelope = external_metric_envelope_from_dict(data)
|
|
53
|
+
expected = {case.id for case in scenario.cases}
|
|
54
|
+
supplied = set(envelope.metrics_by_case)
|
|
55
|
+
if supplied != expected:
|
|
56
|
+
missing = sorted(expected - supplied)
|
|
57
|
+
unknown = sorted(supplied - expected)
|
|
58
|
+
raise ContractError(
|
|
59
|
+
f"External metric coverage mismatch; missing={missing}, unknown={unknown}"
|
|
60
|
+
)
|
|
61
|
+
return ExternalMetricEvaluator(envelope)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def external_metric_envelope_from_dict(data: Any) -> ExternalMetricEnvelope:
|
|
65
|
+
if not isinstance(data, dict) or data.get("schema_version") != "0.1":
|
|
66
|
+
raise ContractError("External metrics must use schema version 0.1")
|
|
67
|
+
unknown_fields = set(data) - {"schema_version", "provider", "records"}
|
|
68
|
+
if unknown_fields:
|
|
69
|
+
raise ContractError(f"External metrics have unknown fields: {sorted(unknown_fields)}")
|
|
70
|
+
provider = data.get("provider")
|
|
71
|
+
if provider not in ALLOWED_PROVIDERS:
|
|
72
|
+
raise ContractError("External metric provider must be ragas, deepeval, langfuse, or custom")
|
|
73
|
+
records = data.get("records")
|
|
74
|
+
if not isinstance(records, list) or not records:
|
|
75
|
+
raise ContractError("External metrics need at least one record")
|
|
76
|
+
metrics_by_case: dict[str, dict[str, float]] = {}
|
|
77
|
+
expected_metric_names: frozenset[str] | None = None
|
|
78
|
+
for index, record in enumerate(records):
|
|
79
|
+
if not isinstance(record, dict):
|
|
80
|
+
raise ContractError(f"External metric record {index} must be an object")
|
|
81
|
+
unknown_record_fields = set(record) - {"case_id", "metrics"}
|
|
82
|
+
if unknown_record_fields:
|
|
83
|
+
raise ContractError(
|
|
84
|
+
f"External metric record {index} has unknown fields: "
|
|
85
|
+
f"{sorted(unknown_record_fields)}"
|
|
86
|
+
)
|
|
87
|
+
case_id = record.get("case_id")
|
|
88
|
+
if not isinstance(case_id, str) or not case_id:
|
|
89
|
+
raise ContractError(f"External metric record {index} needs a non-empty case_id")
|
|
90
|
+
if case_id in metrics_by_case:
|
|
91
|
+
raise ContractError(f"External metric case IDs must be unique: {case_id!r}")
|
|
92
|
+
raw_metrics = record.get("metrics")
|
|
93
|
+
if not isinstance(raw_metrics, dict) or not raw_metrics:
|
|
94
|
+
raise ContractError(f"External metric record {case_id!r} needs metrics")
|
|
95
|
+
metrics: dict[str, float] = {}
|
|
96
|
+
for name, value in raw_metrics.items():
|
|
97
|
+
if not isinstance(name, str) or not name:
|
|
98
|
+
raise ContractError(f"External metric names must be non-empty for case {case_id!r}")
|
|
99
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
100
|
+
raise ContractError(f"External metric {provider}.{name} must be numeric")
|
|
101
|
+
number = float(value)
|
|
102
|
+
if not math.isfinite(number):
|
|
103
|
+
raise ContractError(f"External metric {provider}.{name} must be finite")
|
|
104
|
+
metrics[name] = number
|
|
105
|
+
metric_names = frozenset(metrics)
|
|
106
|
+
if expected_metric_names is None:
|
|
107
|
+
expected_metric_names = metric_names
|
|
108
|
+
elif metric_names != expected_metric_names:
|
|
109
|
+
raise ContractError(
|
|
110
|
+
f"External metric names differ for case {case_id!r}; "
|
|
111
|
+
f"expected={sorted(expected_metric_names)}, actual={sorted(metric_names)}"
|
|
112
|
+
)
|
|
113
|
+
metrics_by_case[case_id] = metrics
|
|
114
|
+
return ExternalMetricEnvelope(
|
|
115
|
+
provider=provider,
|
|
116
|
+
metrics_by_case=metrics_by_case,
|
|
117
|
+
metric_names=expected_metric_names or frozenset(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_external_metric_pair(
|
|
122
|
+
baseline: ExternalMetricEvaluator,
|
|
123
|
+
candidate: ExternalMetricEvaluator,
|
|
124
|
+
) -> None:
|
|
125
|
+
if baseline.name != candidate.name:
|
|
126
|
+
raise ContractError(
|
|
127
|
+
f"External metric providers differ: {baseline.name!r} != {candidate.name!r}"
|
|
128
|
+
)
|
|
129
|
+
if baseline.metric_names != candidate.metric_names:
|
|
130
|
+
raise ContractError(
|
|
131
|
+
"External baseline and candidate metric names differ; "
|
|
132
|
+
f"baseline={sorted(baseline.metric_names)}, "
|
|
133
|
+
f"candidate={sorted(candidate.metric_names)}"
|
|
134
|
+
)
|
ragops/adapters/http.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdapterError(RuntimeError):
|
|
10
|
+
"""Raised when a remote application adapter cannot return valid JSON."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def post_json(
|
|
14
|
+
url: str,
|
|
15
|
+
payload: dict[str, Any],
|
|
16
|
+
*,
|
|
17
|
+
headers: dict[str, str] | None = None,
|
|
18
|
+
timeout: float = 30.0,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
request_headers = {"content-type": "application/json", **(headers or {})}
|
|
21
|
+
request = urllib.request.Request(
|
|
22
|
+
url,
|
|
23
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
24
|
+
headers=request_headers,
|
|
25
|
+
method="POST",
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
29
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
30
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
31
|
+
raise AdapterError(f"HTTP adapter failed for {url}: {exc}") from exc
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
raise AdapterError(f"HTTP adapter expected a JSON object from {url}")
|
|
34
|
+
return data
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from ragops.loader import ContractError
|
|
7
|
+
from ragops.models import MetricObservation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
OTEL_EVALUATION_NAME = "gen_ai.evaluation.name"
|
|
11
|
+
OTEL_EVALUATION_SCORE = "gen_ai.evaluation.score.value"
|
|
12
|
+
RAGOPS_CASE_ID = "ragops.case.id"
|
|
13
|
+
RAGOPS_REPEAT_ID = "ragops.repeat.id"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ragas_scores_to_observations(
|
|
17
|
+
scores: list[dict],
|
|
18
|
+
case_ids: list[str],
|
|
19
|
+
*,
|
|
20
|
+
repeat_id: str,
|
|
21
|
+
metric_names: tuple[str, ...],
|
|
22
|
+
) -> tuple[MetricObservation, ...]:
|
|
23
|
+
"""Map ordered Ragas EvaluationResult.scores rows into replay observations."""
|
|
24
|
+
_identifiers(case_ids, repeat_id, metric_names)
|
|
25
|
+
if len(scores) != len(case_ids):
|
|
26
|
+
raise ContractError("Ragas score rows must match the ordered case IDs")
|
|
27
|
+
observations = []
|
|
28
|
+
for index, (case_id, score_row) in enumerate(zip(case_ids, scores, strict=True)):
|
|
29
|
+
if not isinstance(score_row, dict):
|
|
30
|
+
raise ContractError(f"Ragas score row {index} must be an object")
|
|
31
|
+
observations.append(
|
|
32
|
+
MetricObservation(
|
|
33
|
+
case_id=case_id,
|
|
34
|
+
repeat_id=repeat_id,
|
|
35
|
+
metrics=_selected_metrics(score_row, metric_names, f"Ragas row {index}"),
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return tuple(observations)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def deepeval_scores_to_observations(
|
|
42
|
+
records: list[dict],
|
|
43
|
+
*,
|
|
44
|
+
metric_names: tuple[str, ...],
|
|
45
|
+
) -> tuple[MetricObservation, ...]:
|
|
46
|
+
"""Map recorded DeepEval metric.score values into replay observations."""
|
|
47
|
+
if not metric_names or len(set(metric_names)) != len(metric_names):
|
|
48
|
+
raise ContractError("DeepEval metric names must be non-empty and unique")
|
|
49
|
+
observations = []
|
|
50
|
+
identities = set()
|
|
51
|
+
for index, record in enumerate(records):
|
|
52
|
+
if not isinstance(record, dict) or set(record) != {"case_id", "repeat_id", "metrics"}:
|
|
53
|
+
raise ContractError(
|
|
54
|
+
f"DeepEval record {index} must contain case_id, repeat_id, and metrics"
|
|
55
|
+
)
|
|
56
|
+
case_id = record["case_id"]
|
|
57
|
+
repeat_id = record["repeat_id"]
|
|
58
|
+
_identifiers([case_id], repeat_id, metric_names)
|
|
59
|
+
identity = (case_id, repeat_id)
|
|
60
|
+
if identity in identities:
|
|
61
|
+
raise ContractError("DeepEval case/repeat identities must be unique")
|
|
62
|
+
identities.add(identity)
|
|
63
|
+
observations.append(
|
|
64
|
+
MetricObservation(
|
|
65
|
+
case_id=case_id,
|
|
66
|
+
repeat_id=repeat_id,
|
|
67
|
+
metrics=_selected_metrics(
|
|
68
|
+
record["metrics"], metric_names, f"DeepEval record {index}"
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if not observations:
|
|
73
|
+
raise ContractError("DeepEval records must not be empty")
|
|
74
|
+
return tuple(observations)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def otel_evaluation_events_to_observations(
|
|
78
|
+
events: list[dict],
|
|
79
|
+
) -> tuple[MetricObservation, ...]:
|
|
80
|
+
"""Group recorded OpenTelemetry GenAI evaluation events by replay identity."""
|
|
81
|
+
grouped: dict[tuple[str, str], dict[str, float]] = defaultdict(dict)
|
|
82
|
+
if not events:
|
|
83
|
+
raise ContractError("OpenTelemetry evaluation events must not be empty")
|
|
84
|
+
for index, event in enumerate(events):
|
|
85
|
+
if not isinstance(event, dict) or not isinstance(event.get("attributes"), dict):
|
|
86
|
+
raise ContractError(f"OpenTelemetry event {index} needs an attributes object")
|
|
87
|
+
attributes = event["attributes"]
|
|
88
|
+
try:
|
|
89
|
+
case_id = attributes[RAGOPS_CASE_ID]
|
|
90
|
+
repeat_id = attributes[RAGOPS_REPEAT_ID]
|
|
91
|
+
metric_name = attributes[OTEL_EVALUATION_NAME]
|
|
92
|
+
score = attributes[OTEL_EVALUATION_SCORE]
|
|
93
|
+
except KeyError as exc:
|
|
94
|
+
raise ContractError(
|
|
95
|
+
f"OpenTelemetry event {index} is missing required attribute {exc.args[0]}"
|
|
96
|
+
) from exc
|
|
97
|
+
_identifiers([case_id], repeat_id, (metric_name,))
|
|
98
|
+
numeric = _finite(score, f"OpenTelemetry event {index} score")
|
|
99
|
+
identity = (case_id, repeat_id)
|
|
100
|
+
if metric_name in grouped[identity]:
|
|
101
|
+
raise ContractError(
|
|
102
|
+
f"Duplicate OpenTelemetry evaluation metric {metric_name!r} for {identity}"
|
|
103
|
+
)
|
|
104
|
+
grouped[identity][metric_name] = numeric
|
|
105
|
+
metric_sets = {tuple(sorted(metrics)) for metrics in grouped.values()}
|
|
106
|
+
if len(metric_sets) != 1:
|
|
107
|
+
raise ContractError(
|
|
108
|
+
"Every OpenTelemetry replay identity must contain the same evaluation metrics"
|
|
109
|
+
)
|
|
110
|
+
return tuple(
|
|
111
|
+
MetricObservation(case_id, repeat_id, metrics)
|
|
112
|
+
for (case_id, repeat_id), metrics in sorted(grouped.items())
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _selected_metrics(
|
|
117
|
+
raw: object, metric_names: tuple[str, ...], label: str
|
|
118
|
+
) -> dict[str, float]:
|
|
119
|
+
if not isinstance(raw, dict):
|
|
120
|
+
raise ContractError(f"{label} metrics must be an object")
|
|
121
|
+
missing = sorted(set(metric_names) - set(raw))
|
|
122
|
+
if missing:
|
|
123
|
+
raise ContractError(f"{label} is missing metrics: {missing}")
|
|
124
|
+
return {name: _finite(raw[name], f"{label}.{name}") for name in metric_names}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _identifiers(
|
|
128
|
+
case_ids: list[str], repeat_id: str, metric_names: tuple[str, ...]
|
|
129
|
+
) -> None:
|
|
130
|
+
if not case_ids or any(not isinstance(case_id, str) or not case_id for case_id in case_ids):
|
|
131
|
+
raise ContractError("Case IDs must be non-empty strings")
|
|
132
|
+
if len(set(case_ids)) != len(case_ids):
|
|
133
|
+
raise ContractError("Case IDs must be unique")
|
|
134
|
+
if not isinstance(repeat_id, str) or not repeat_id:
|
|
135
|
+
raise ContractError("Repeat ID must be a non-empty string")
|
|
136
|
+
if (
|
|
137
|
+
not metric_names
|
|
138
|
+
or len(set(metric_names)) != len(metric_names)
|
|
139
|
+
or any(not isinstance(name, str) or not name for name in metric_names)
|
|
140
|
+
):
|
|
141
|
+
raise ContractError("Metric names must be non-empty and unique")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _finite(value: object, name: str) -> float:
|
|
145
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
146
|
+
raise ContractError(f"{name} must be numeric")
|
|
147
|
+
numeric = float(value)
|
|
148
|
+
if not math.isfinite(numeric):
|
|
149
|
+
raise ContractError(f"{name} must be finite")
|
|
150
|
+
return numeric
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Protocol
|
|
12
|
+
|
|
13
|
+
from ragops.loader import ContractError
|
|
14
|
+
from ragops.models import MetricObservation, ReplayBundle, ReplayProvenance
|
|
15
|
+
from ragops.statistical import load_replay_bundle
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MAX_CASES = 10_000
|
|
19
|
+
MAX_REPEATS = 1_000
|
|
20
|
+
MAX_OBSERVATIONS = 100_000
|
|
21
|
+
MAX_COMMAND_OUTPUT_BYTES = 1_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RepeatedRunPlan:
|
|
26
|
+
schema_version: str
|
|
27
|
+
scenario_id: str
|
|
28
|
+
scenario_digest: str
|
|
29
|
+
case_ids: tuple[str, ...]
|
|
30
|
+
repeats: int
|
|
31
|
+
provenance: ReplayProvenance
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MetricRunAdapter(Protocol):
|
|
35
|
+
def collect(self, case_id: str, repeat_id: str) -> dict[str, float]: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CommandMetricAdapter:
|
|
39
|
+
"""Run one explicit command per observation without invoking a shell."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, command: tuple[str, ...], *, timeout_seconds: float = 60.0) -> None:
|
|
42
|
+
if not command or any(not isinstance(part, str) or not part for part in command):
|
|
43
|
+
raise ValueError("Repeated-run command must contain non-empty arguments")
|
|
44
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
45
|
+
raise ValueError("Repeated-run timeout must be finite and positive")
|
|
46
|
+
self.command = command
|
|
47
|
+
self.timeout_seconds = timeout_seconds
|
|
48
|
+
|
|
49
|
+
def collect(self, case_id: str, repeat_id: str) -> dict[str, float]:
|
|
50
|
+
environment = os.environ.copy()
|
|
51
|
+
environment["RAGOPS_CASE_ID"] = case_id
|
|
52
|
+
environment["RAGOPS_REPEAT_ID"] = repeat_id
|
|
53
|
+
try:
|
|
54
|
+
completed = subprocess.run(
|
|
55
|
+
self.command,
|
|
56
|
+
check=False,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
env=environment,
|
|
59
|
+
timeout=self.timeout_seconds,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
62
|
+
raise ContractError(
|
|
63
|
+
f"Repeated-run command failed for {case_id}/{repeat_id}: {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
if len(completed.stdout) > MAX_COMMAND_OUTPUT_BYTES:
|
|
66
|
+
raise ContractError(
|
|
67
|
+
f"Repeated-run command output exceeds {MAX_COMMAND_OUTPUT_BYTES} bytes"
|
|
68
|
+
)
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
error = completed.stderr[:2000].decode("utf-8", errors="replace").strip()
|
|
71
|
+
raise ContractError(
|
|
72
|
+
f"Repeated-run command exited {completed.returncode} for "
|
|
73
|
+
f"{case_id}/{repeat_id}: {error}"
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(completed.stdout.decode("utf-8"))
|
|
77
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
78
|
+
raise ContractError(
|
|
79
|
+
f"Repeated-run command returned invalid JSON for {case_id}/{repeat_id}"
|
|
80
|
+
) from exc
|
|
81
|
+
if not isinstance(payload, dict) or set(payload) != {"metrics"}:
|
|
82
|
+
raise ContractError("Repeated-run command output must contain only a metrics object")
|
|
83
|
+
return _metric_map(payload["metrics"], f"command output {case_id}/{repeat_id}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_repeated_run_plan(path: str | Path) -> RepeatedRunPlan:
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(
|
|
89
|
+
Path(path).read_text(encoding="utf-8"),
|
|
90
|
+
parse_constant=lambda value: (_ for _ in ()).throw(
|
|
91
|
+
ValueError(f"non-finite JSON number {value}")
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
95
|
+
raise ContractError(f"Cannot load repeated-run plan from {path}: {exc}") from exc
|
|
96
|
+
if not isinstance(data, dict):
|
|
97
|
+
raise ContractError("Repeated-run plan must be an object")
|
|
98
|
+
unknown = set(data) - {
|
|
99
|
+
"schema_version",
|
|
100
|
+
"scenario_id",
|
|
101
|
+
"scenario_digest",
|
|
102
|
+
"case_ids",
|
|
103
|
+
"repeats",
|
|
104
|
+
"provenance",
|
|
105
|
+
}
|
|
106
|
+
if unknown:
|
|
107
|
+
raise ContractError(f"Unknown repeated-run plan fields: {sorted(unknown)}")
|
|
108
|
+
try:
|
|
109
|
+
provenance_data = data["provenance"]
|
|
110
|
+
if not isinstance(provenance_data, dict):
|
|
111
|
+
raise TypeError("provenance must be an object")
|
|
112
|
+
if set(provenance_data) != {
|
|
113
|
+
"dataset",
|
|
114
|
+
"evidence",
|
|
115
|
+
"evaluator",
|
|
116
|
+
"application",
|
|
117
|
+
"model",
|
|
118
|
+
"model_config",
|
|
119
|
+
"environment",
|
|
120
|
+
}:
|
|
121
|
+
raise ValueError("provenance fields must match the replay-bundle contract")
|
|
122
|
+
plan = RepeatedRunPlan(
|
|
123
|
+
schema_version=data["schema_version"],
|
|
124
|
+
scenario_id=data["scenario_id"],
|
|
125
|
+
scenario_digest=data["scenario_digest"],
|
|
126
|
+
case_ids=tuple(data["case_ids"]),
|
|
127
|
+
repeats=data["repeats"],
|
|
128
|
+
provenance=ReplayProvenance(**provenance_data),
|
|
129
|
+
)
|
|
130
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
131
|
+
raise ContractError(f"Invalid repeated-run plan contract: {exc}") from exc
|
|
132
|
+
_validate_plan(plan)
|
|
133
|
+
return plan
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def collect_repeated_runs(
|
|
137
|
+
plan: RepeatedRunPlan,
|
|
138
|
+
adapter: MetricRunAdapter,
|
|
139
|
+
*,
|
|
140
|
+
existing: ReplayBundle | None = None,
|
|
141
|
+
checkpoint: Callable[[ReplayBundle], None] | None = None,
|
|
142
|
+
stop_when: Callable[[ReplayBundle], bool] | None = None,
|
|
143
|
+
) -> ReplayBundle:
|
|
144
|
+
_validate_plan(plan)
|
|
145
|
+
records = list(existing.records) if existing is not None else []
|
|
146
|
+
if existing is not None:
|
|
147
|
+
_validate_resume(plan, existing)
|
|
148
|
+
completed = {(record.case_id, record.repeat_id) for record in records}
|
|
149
|
+
expected_metric_names = set(records[0].metrics) if records else None
|
|
150
|
+
for repeat_number in range(1, plan.repeats + 1):
|
|
151
|
+
for case_id in plan.case_ids:
|
|
152
|
+
repeat_id = f"run-{repeat_number:04d}"
|
|
153
|
+
if (case_id, repeat_id) in completed:
|
|
154
|
+
continue
|
|
155
|
+
metrics = _metric_map(
|
|
156
|
+
adapter.collect(case_id, repeat_id), f"adapter result {case_id}/{repeat_id}"
|
|
157
|
+
)
|
|
158
|
+
if expected_metric_names is None:
|
|
159
|
+
expected_metric_names = set(metrics)
|
|
160
|
+
elif set(metrics) != expected_metric_names:
|
|
161
|
+
raise ContractError("Every repeated-run observation must use the same metrics")
|
|
162
|
+
records.append(MetricObservation(case_id, repeat_id, metrics))
|
|
163
|
+
bundle = _bundle(plan, tuple(records))
|
|
164
|
+
if checkpoint is not None:
|
|
165
|
+
checkpoint(bundle)
|
|
166
|
+
if stop_when is not None and stop_when(bundle):
|
|
167
|
+
return bundle
|
|
168
|
+
return _bundle(plan, tuple(records))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_resume_bundle(path: str | Path, *, resume: bool) -> ReplayBundle | None:
|
|
172
|
+
destination = Path(path)
|
|
173
|
+
if not destination.exists():
|
|
174
|
+
return None
|
|
175
|
+
if destination.is_symlink():
|
|
176
|
+
raise ContractError("Repeated-run output must not be a symlink")
|
|
177
|
+
if not resume:
|
|
178
|
+
raise ContractError("Repeated-run output already exists; pass --resume to continue")
|
|
179
|
+
return load_replay_bundle(destination)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def write_replay_bundle(path: str | Path, bundle: ReplayBundle) -> None:
|
|
183
|
+
destination = Path(path)
|
|
184
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
if destination.is_symlink():
|
|
186
|
+
raise ContractError("Repeated-run output must not be a symlink")
|
|
187
|
+
payload = json.dumps(asdict(bundle), ensure_ascii=False, indent=2) + "\n"
|
|
188
|
+
temporary_name: str | None = None
|
|
189
|
+
try:
|
|
190
|
+
with tempfile.NamedTemporaryFile(
|
|
191
|
+
mode="w",
|
|
192
|
+
encoding="utf-8",
|
|
193
|
+
dir=destination.parent,
|
|
194
|
+
prefix=f".{destination.name}.",
|
|
195
|
+
delete=False,
|
|
196
|
+
) as temporary:
|
|
197
|
+
temporary_name = temporary.name
|
|
198
|
+
os.chmod(temporary_name, stat.S_IRUSR | stat.S_IWUSR)
|
|
199
|
+
temporary.write(payload)
|
|
200
|
+
temporary.flush()
|
|
201
|
+
os.fsync(temporary.fileno())
|
|
202
|
+
os.replace(temporary_name, destination)
|
|
203
|
+
finally:
|
|
204
|
+
if temporary_name and Path(temporary_name).exists():
|
|
205
|
+
Path(temporary_name).unlink()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _validate_plan(plan: RepeatedRunPlan) -> None:
|
|
209
|
+
if plan.schema_version != "0.1":
|
|
210
|
+
raise ContractError(f"Unsupported repeated-run plan schema: {plan.schema_version}")
|
|
211
|
+
for name, value in (
|
|
212
|
+
("scenario_id", plan.scenario_id),
|
|
213
|
+
("scenario_digest", plan.scenario_digest),
|
|
214
|
+
("provenance.dataset", plan.provenance.dataset),
|
|
215
|
+
("provenance.evidence", plan.provenance.evidence),
|
|
216
|
+
("provenance.evaluator", plan.provenance.evaluator),
|
|
217
|
+
("provenance.application", plan.provenance.application),
|
|
218
|
+
("provenance.model", plan.provenance.model),
|
|
219
|
+
("provenance.model_config", plan.provenance.model_config),
|
|
220
|
+
("provenance.environment", plan.provenance.environment),
|
|
221
|
+
):
|
|
222
|
+
if not isinstance(value, str) or not value:
|
|
223
|
+
raise ContractError(f"{name} must be a non-empty string")
|
|
224
|
+
if not plan.case_ids or len(plan.case_ids) > MAX_CASES:
|
|
225
|
+
raise ContractError(f"Repeated-run plan needs between 1 and {MAX_CASES} cases")
|
|
226
|
+
if any(not isinstance(case_id, str) or not case_id for case_id in plan.case_ids):
|
|
227
|
+
raise ContractError("Repeated-run case IDs must be non-empty strings")
|
|
228
|
+
if len(set(plan.case_ids)) != len(plan.case_ids):
|
|
229
|
+
raise ContractError("Repeated-run case IDs must be unique")
|
|
230
|
+
if isinstance(plan.repeats, bool) or not isinstance(plan.repeats, int):
|
|
231
|
+
raise ContractError("Repeated-run repeats must be an integer")
|
|
232
|
+
if not 1 <= plan.repeats <= MAX_REPEATS:
|
|
233
|
+
raise ContractError(f"Repeated-run repeats must be between 1 and {MAX_REPEATS}")
|
|
234
|
+
if len(plan.case_ids) * plan.repeats > MAX_OBSERVATIONS:
|
|
235
|
+
raise ContractError(
|
|
236
|
+
f"Repeated-run plan exceeds the {MAX_OBSERVATIONS} observation limit"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_resume(plan: RepeatedRunPlan, existing: ReplayBundle) -> None:
|
|
241
|
+
if (
|
|
242
|
+
existing.scenario_id != plan.scenario_id
|
|
243
|
+
or existing.scenario_digest != plan.scenario_digest
|
|
244
|
+
or existing.provenance != plan.provenance
|
|
245
|
+
):
|
|
246
|
+
raise ContractError("Resume bundle metadata does not match the repeated-run plan")
|
|
247
|
+
allowed_cases = set(plan.case_ids)
|
|
248
|
+
for record in existing.records:
|
|
249
|
+
if record.case_id not in allowed_cases:
|
|
250
|
+
raise ContractError(f"Resume bundle contains unknown case {record.case_id!r}")
|
|
251
|
+
if not record.repeat_id.startswith("run-"):
|
|
252
|
+
raise ContractError("Resume bundle repeat IDs must use run-NNNN format")
|
|
253
|
+
try:
|
|
254
|
+
repeat_number = int(record.repeat_id.removeprefix("run-"))
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
raise ContractError("Resume bundle repeat IDs must use run-NNNN format") from exc
|
|
257
|
+
if not 1 <= repeat_number <= plan.repeats:
|
|
258
|
+
raise ContractError("Resume bundle contains a repeat outside the plan")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _metric_map(value: object, name: str) -> dict[str, float]:
|
|
262
|
+
if not isinstance(value, dict) or not value:
|
|
263
|
+
raise ContractError(f"{name} must be a non-empty metric object")
|
|
264
|
+
metrics: dict[str, float] = {}
|
|
265
|
+
for metric_name, metric_value in value.items():
|
|
266
|
+
if not isinstance(metric_name, str) or not metric_name:
|
|
267
|
+
raise ContractError(f"{name} metric names must be non-empty strings")
|
|
268
|
+
if isinstance(metric_value, bool) or not isinstance(metric_value, (int, float)):
|
|
269
|
+
raise ContractError(f"{name}.{metric_name} must be numeric")
|
|
270
|
+
numeric = float(metric_value)
|
|
271
|
+
if not math.isfinite(numeric):
|
|
272
|
+
raise ContractError(f"{name}.{metric_name} must be finite")
|
|
273
|
+
metrics[metric_name] = numeric
|
|
274
|
+
return metrics
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _bundle(plan: RepeatedRunPlan, records: tuple[MetricObservation, ...]) -> ReplayBundle:
|
|
278
|
+
return ReplayBundle(
|
|
279
|
+
schema_version="0.1",
|
|
280
|
+
scenario_id=plan.scenario_id,
|
|
281
|
+
scenario_digest=plan.scenario_digest,
|
|
282
|
+
provenance=plan.provenance,
|
|
283
|
+
records=records,
|
|
284
|
+
)
|