trace2eval-cli 0.2.1__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.
- trace2eval/__init__.py +65 -0
- trace2eval/__main__.py +10 -0
- trace2eval/checks.py +149 -0
- trace2eval/cli.py +307 -0
- trace2eval/matchers.py +101 -0
- trace2eval/report.py +221 -0
- trace2eval/runner.py +251 -0
- trace2eval/schema.py +233 -0
- trace2eval/select.py +709 -0
- trace2eval/signals.py +207 -0
- trace2eval_cli-0.2.1.dist-info/METADATA +227 -0
- trace2eval_cli-0.2.1.dist-info/RECORD +16 -0
- trace2eval_cli-0.2.1.dist-info/WHEEL +5 -0
- trace2eval_cli-0.2.1.dist-info/entry_points.txt +2 -0
- trace2eval_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- trace2eval_cli-0.2.1.dist-info/top_level.txt +1 -0
trace2eval/report.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Markdown rendering for the generated case set and for regression output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .runner import RunMetrics, Violation
|
|
8
|
+
from .select import SelectionResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cell(value: Any, limit: int = 60) -> str:
|
|
12
|
+
"""Make a value safe to drop into a Markdown table cell."""
|
|
13
|
+
text = str(value).replace("|", "\\|").replace("\n", " ").strip()
|
|
14
|
+
if len(text) > limit:
|
|
15
|
+
text = text[: limit - 1] + "…"
|
|
16
|
+
return text or "—"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _number(value: Any, digits: int = 4) -> str:
|
|
20
|
+
if value is None:
|
|
21
|
+
return "—"
|
|
22
|
+
if isinstance(value, float):
|
|
23
|
+
if value == 0:
|
|
24
|
+
return "0"
|
|
25
|
+
if abs(value) < 0.001:
|
|
26
|
+
return f"{value:.8f}".rstrip("0")
|
|
27
|
+
return f"{value:.{digits}f}"
|
|
28
|
+
return str(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_report(result: SelectionResult, source_name: str) -> str:
|
|
32
|
+
"""Human-readable account of what was selected and, more importantly, why."""
|
|
33
|
+
stats = result.stats()
|
|
34
|
+
context = result.context
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
|
|
37
|
+
lines.append("# trace2eval build report")
|
|
38
|
+
lines.append("")
|
|
39
|
+
lines.append(f"Source log: `{source_name}`")
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
lines.append("## Summary")
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("| Metric | Value |")
|
|
45
|
+
lines.append("| --- | --- |")
|
|
46
|
+
lines.append(f"| Traces read | {stats['total_traces']} |")
|
|
47
|
+
lines.append(f"| Distinct questions | {stats['distinct_questions']} |")
|
|
48
|
+
lines.append(f"| Questions with no signal | {stats['questions_without_signals']} |")
|
|
49
|
+
lines.append(f"| Cases generated | {stats['cases']} |")
|
|
50
|
+
lines.append(f"| Log lines collapsed into a case | {stats['dropped_as_duplicate']} |")
|
|
51
|
+
lines.append(f"| Questions below the minimum score | {stats['dropped_below_min_score']} |")
|
|
52
|
+
lines.append(f"| Questions beyond the case limit | {stats['dropped_beyond_limit']} |")
|
|
53
|
+
lines.append(
|
|
54
|
+
f"| Cases whose checks cannot catch their own failure "
|
|
55
|
+
f"| {stats['cases_with_weak_checks']} |"
|
|
56
|
+
)
|
|
57
|
+
lines.append(
|
|
58
|
+
f"| Trusted references failing their own checks "
|
|
59
|
+
f"| {stats['cases_whose_reference_fails']} |"
|
|
60
|
+
)
|
|
61
|
+
lines.append("")
|
|
62
|
+
|
|
63
|
+
lines.append("## Log-wide baselines")
|
|
64
|
+
lines.append("")
|
|
65
|
+
lines.append("| Statistic | Value |")
|
|
66
|
+
lines.append("| --- | --- |")
|
|
67
|
+
lines.append(f"| p95 latency | {_number(context.p95_latency_ms, 2)} ms |")
|
|
68
|
+
lines.append(f"| p95 cost | ${_number(context.p95_cost_usd, 8)} |")
|
|
69
|
+
lines.append(f"| Median output length | {_number(context.median_output_chars, 1)} chars |")
|
|
70
|
+
lines.append("")
|
|
71
|
+
|
|
72
|
+
failures = sum(1 for case in result.cases if not case["reference_is_trusted"])
|
|
73
|
+
lines.append("## Case mix")
|
|
74
|
+
lines.append("")
|
|
75
|
+
lines.append(
|
|
76
|
+
f"- **{failures}** regression seeds (the reference output is the output that "
|
|
77
|
+
f"went wrong — the case exists so it never ships twice)"
|
|
78
|
+
)
|
|
79
|
+
lines.append(
|
|
80
|
+
f"- **{len(result.cases) - failures}** quality anchors (clean trace, so an "
|
|
81
|
+
f"expected shape was inferred from it)"
|
|
82
|
+
)
|
|
83
|
+
lines.append("")
|
|
84
|
+
|
|
85
|
+
lines.append("## Cases")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append("| Case | Score | In log | Trusted | Shape from | Self-check | Signals | Input |")
|
|
88
|
+
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
89
|
+
for case in result.cases:
|
|
90
|
+
signal_names = ", ".join(signal["name"] for signal in case["signals"])
|
|
91
|
+
lines.append(
|
|
92
|
+
"| {id} | {score} | {occurs} | {trusted} | {shape} | {check} | {signals} | {input} |".format(
|
|
93
|
+
id=_cell(case["id"], 12),
|
|
94
|
+
score=_cell(case["score"], 8),
|
|
95
|
+
occurs=_cell(case["occurrences_in_log"], 8),
|
|
96
|
+
trusted="yes" if case["reference_is_trusted"] else "no",
|
|
97
|
+
shape=_cell(case["shape_source"], 30),
|
|
98
|
+
check=_cell(case["self_check"]["verdict"], 30),
|
|
99
|
+
signals=_cell(signal_names, 42),
|
|
100
|
+
input=_cell(case["input"], 40),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
lines.append("")
|
|
104
|
+
|
|
105
|
+
flagged = result.cases_whose_reference_fails + result.cases_with_weak_checks
|
|
106
|
+
if flagged:
|
|
107
|
+
lines.append("## Cases that need a human eye")
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append(
|
|
110
|
+
"Generated cases are a proposal. These are the ones where the proposal "
|
|
111
|
+
"is weakest, in order of how much they should worry you."
|
|
112
|
+
)
|
|
113
|
+
lines.append("")
|
|
114
|
+
for case in result.cases_whose_reference_fails:
|
|
115
|
+
lines.append(
|
|
116
|
+
f"- **{case['id']}** — a trusted reference does not satisfy its own "
|
|
117
|
+
f"checks (`{', '.join(case['self_check']['failed_checks'])}`). Either "
|
|
118
|
+
f"the reference is wrong or the checks are."
|
|
119
|
+
)
|
|
120
|
+
for case in result.cases_with_weak_checks:
|
|
121
|
+
if case["failure_kind"] == "behaviour":
|
|
122
|
+
lines.append(
|
|
123
|
+
f"- {case['id']} — the failure here was behavioural "
|
|
124
|
+
f"(`{', '.join(signal['name'] for signal in case['signals'])}`): the "
|
|
125
|
+
f"call itself produced a perfectly acceptable answer, and what went "
|
|
126
|
+
f"wrong happened around it. No check on the output text can "
|
|
127
|
+
f"reproduce that. Keep the case as a pinned input, but it needs a "
|
|
128
|
+
f"labelled expected answer before it can gate anything."
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
lines.append(
|
|
132
|
+
f"- {case['id']} — the failure was visible in the output "
|
|
133
|
+
f"(`{', '.join(signal['name'] for signal in case['signals'])}`), yet "
|
|
134
|
+
f"the generated checks pass on it. That is a gap in the checks, not "
|
|
135
|
+
f"a limit of the approach -- worth investigating."
|
|
136
|
+
)
|
|
137
|
+
lines.append("")
|
|
138
|
+
|
|
139
|
+
if result.cases:
|
|
140
|
+
lines.append("## Why the top case was chosen")
|
|
141
|
+
lines.append("")
|
|
142
|
+
top = result.cases[0]
|
|
143
|
+
lines.append(f"`{top['id']}` scored **{top['score']}**.")
|
|
144
|
+
lines.append("")
|
|
145
|
+
for signal in top["signals"]:
|
|
146
|
+
lines.append(f"- `{signal['name']}` (+{signal['weight']}) — {signal['detail']}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
lines.append(f"The expected shape came from {top['shape_source']}.")
|
|
149
|
+
lines.append("")
|
|
150
|
+
check = top["self_check"]
|
|
151
|
+
if top["reference_is_trusted"]:
|
|
152
|
+
lines.append(
|
|
153
|
+
"Self-check: this is a trusted reference, and it "
|
|
154
|
+
+ ("satisfies its own checks." if check["passed"] else "does NOT satisfy them.")
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
if not check["passed"]:
|
|
158
|
+
detail = (
|
|
159
|
+
f"fail on it — they do, on `{', '.join(check['failed_checks'])}`. "
|
|
160
|
+
"The case reproduces the failure it came from."
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
detail = (
|
|
164
|
+
"fail on it — they do not. The case cannot detect the failure it "
|
|
165
|
+
"came from, which is why it appears in the list above."
|
|
166
|
+
)
|
|
167
|
+
lines.append(
|
|
168
|
+
"Self-check: this is a failure seed, so the checks are *supposed* to "
|
|
169
|
+
+ detail
|
|
170
|
+
)
|
|
171
|
+
lines.append("")
|
|
172
|
+
for note in top["notes"]:
|
|
173
|
+
lines.append(f"> {note}")
|
|
174
|
+
lines.append("")
|
|
175
|
+
|
|
176
|
+
lines.append("---")
|
|
177
|
+
lines.append("")
|
|
178
|
+
lines.append(
|
|
179
|
+
"Generated by [trace2eval](https://github.com/rfioly/trace2eval). "
|
|
180
|
+
"Review this file before committing the case set — the selection is a "
|
|
181
|
+
"proposal, not a verdict."
|
|
182
|
+
)
|
|
183
|
+
lines.append("")
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_metrics(metrics: RunMetrics) -> str:
|
|
188
|
+
lines: list[str] = []
|
|
189
|
+
lines.append("| Metric | Value |")
|
|
190
|
+
lines.append("| --- | --- |")
|
|
191
|
+
lines.append(f"| Cases | {metrics.total} |")
|
|
192
|
+
lines.append(f"| Passed | {metrics.passed} |")
|
|
193
|
+
lines.append(f"| Failed | {metrics.failed} |")
|
|
194
|
+
if metrics.missing:
|
|
195
|
+
lines.append(f"| Missing outputs | {metrics.missing} |")
|
|
196
|
+
lines.append(f"| Pass rate | {metrics.pass_rate:.2%} |")
|
|
197
|
+
lines.append(f"| Fallback rate | {metrics.fallback_rate:.2%} |")
|
|
198
|
+
if metrics.format_compliance_rate is not None:
|
|
199
|
+
lines.append(f"| Format compliance | {metrics.format_compliance_rate:.2%} |")
|
|
200
|
+
lines.append(f"| Avg latency | {_number(metrics.avg_latency_ms, 2)} ms |")
|
|
201
|
+
lines.append(f"| p95 latency | {_number(metrics.p95_latency_ms, 2)} ms |")
|
|
202
|
+
lines.append(f"| Avg cost | ${_number(metrics.avg_cost_usd, 8)} |")
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def render_violations(violations: list[Violation]) -> str:
|
|
207
|
+
if not violations:
|
|
208
|
+
return "No regressions detected."
|
|
209
|
+
lines: list[str] = []
|
|
210
|
+
lines.append("| Metric | Baseline | Current | Rule |")
|
|
211
|
+
lines.append("| --- | --- | --- | --- |")
|
|
212
|
+
for violation in violations:
|
|
213
|
+
lines.append(
|
|
214
|
+
"| {metric} | {baseline} | {current} | {rule} |".format(
|
|
215
|
+
metric=_cell(violation.metric, 24),
|
|
216
|
+
baseline=_cell(_number(violation.baseline), 16),
|
|
217
|
+
current=_cell(_number(violation.current), 16),
|
|
218
|
+
rule=_cell(violation.rule, 40),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return "\n".join(lines)
|
trace2eval/runner.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Running a case set and comparing two runs.
|
|
2
|
+
|
|
3
|
+
``run`` turns a case set plus a batch of outputs into a metrics document.
|
|
4
|
+
``check`` compares two metrics documents and reports regressions.
|
|
5
|
+
|
|
6
|
+
The split matters: metrics are a plain JSON file that can be committed, diffed,
|
|
7
|
+
and read by any CI system. A gate that only exists inside a dashboard is not a
|
|
8
|
+
gate.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterable
|
|
17
|
+
|
|
18
|
+
from .checks import CheckResult, run_checks
|
|
19
|
+
from .signals import percentile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputFormatError(ValueError):
|
|
23
|
+
"""Raised when an outputs file cannot be read at all."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CaseOutcome:
|
|
28
|
+
case_id: str
|
|
29
|
+
passed: bool
|
|
30
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
31
|
+
output_chars: int = 0
|
|
32
|
+
latency_ms: float | None = None
|
|
33
|
+
cost_usd: float | None = None
|
|
34
|
+
|
|
35
|
+
def failed_check_types(self) -> list[str]:
|
|
36
|
+
return [result.type for result in self.results if not result.passed]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_outputs(path: str | Path) -> dict[str, dict[str, Any]]:
|
|
40
|
+
"""Read a JSONL file of ``{"id": ..., "output": ...}`` records."""
|
|
41
|
+
output_path = Path(path)
|
|
42
|
+
if not output_path.exists():
|
|
43
|
+
raise OutputFormatError(f"outputs file not found: {output_path}")
|
|
44
|
+
|
|
45
|
+
records: dict[str, dict[str, Any]] = {}
|
|
46
|
+
with output_path.open("r", encoding="utf-8") as handle:
|
|
47
|
+
for line_no, line in enumerate(handle, start=1):
|
|
48
|
+
stripped = line.strip()
|
|
49
|
+
if not stripped:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(stripped)
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
continue
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
continue
|
|
57
|
+
record_id = data.get("id") or data.get("trace_id")
|
|
58
|
+
if record_id is None:
|
|
59
|
+
record_id = f"trace-{line_no:05d}"
|
|
60
|
+
records[str(record_id)] = data
|
|
61
|
+
return records
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class RunMetrics:
|
|
66
|
+
total: int = 0
|
|
67
|
+
passed: int = 0
|
|
68
|
+
failed: int = 0
|
|
69
|
+
missing: int = 0
|
|
70
|
+
pass_rate: float = 0.0
|
|
71
|
+
fallback_count: int = 0
|
|
72
|
+
fallback_rate: float = 0.0
|
|
73
|
+
json_cases: int = 0
|
|
74
|
+
json_passed: int = 0
|
|
75
|
+
format_compliance_rate: float | None = None
|
|
76
|
+
avg_latency_ms: float | None = None
|
|
77
|
+
p95_latency_ms: float | None = None
|
|
78
|
+
avg_cost_usd: float | None = None
|
|
79
|
+
total_cost_usd: float | None = None
|
|
80
|
+
avg_output_chars: float | None = None
|
|
81
|
+
failures: list[dict[str, Any]] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
return asdict(self)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, data: dict[str, Any]) -> RunMetrics:
|
|
88
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
89
|
+
return cls(**{key: value for key, value in data.items() if key in known})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _round(value: float | None, digits: int = 4) -> float | None:
|
|
93
|
+
return None if value is None else round(value, digits)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_cases(
|
|
97
|
+
cases: Iterable[dict[str, Any]],
|
|
98
|
+
outputs: dict[str, dict[str, Any]],
|
|
99
|
+
) -> RunMetrics:
|
|
100
|
+
"""Score every case against the matching output."""
|
|
101
|
+
case_list = list(cases)
|
|
102
|
+
metrics = RunMetrics(total=len(case_list))
|
|
103
|
+
latency_values: list[float] = []
|
|
104
|
+
cost_values: list[float] = []
|
|
105
|
+
char_values: list[int] = []
|
|
106
|
+
|
|
107
|
+
for case in case_list:
|
|
108
|
+
case_id = str(case.get("id", ""))
|
|
109
|
+
source_id = str(case.get("source_trace_id", ""))
|
|
110
|
+
record = outputs.get(case_id) or outputs.get(source_id)
|
|
111
|
+
|
|
112
|
+
if record is None:
|
|
113
|
+
metrics.failed += 1
|
|
114
|
+
metrics.missing += 1
|
|
115
|
+
metrics.failures.append(
|
|
116
|
+
{
|
|
117
|
+
"case_id": case_id,
|
|
118
|
+
"reason": "missing_output",
|
|
119
|
+
"failed_checks": [],
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
output = record.get("output") or record.get("response") or ""
|
|
125
|
+
output = str(output)
|
|
126
|
+
checks = case.get("checks", [])
|
|
127
|
+
results = run_checks(checks, output)
|
|
128
|
+
passed = all(result.passed for result in results)
|
|
129
|
+
|
|
130
|
+
outcome = CaseOutcome(case_id=case_id, passed=passed, results=results)
|
|
131
|
+
outcome.output_chars = len(output.strip())
|
|
132
|
+
|
|
133
|
+
latency = record.get("latency_ms")
|
|
134
|
+
if isinstance(latency, (int, float)):
|
|
135
|
+
outcome.latency_ms = float(latency)
|
|
136
|
+
latency_values.append(float(latency))
|
|
137
|
+
cost = record.get("cost_usd")
|
|
138
|
+
if isinstance(cost, (int, float)):
|
|
139
|
+
outcome.cost_usd = float(cost)
|
|
140
|
+
cost_values.append(float(cost))
|
|
141
|
+
char_values.append(outcome.output_chars)
|
|
142
|
+
|
|
143
|
+
if passed:
|
|
144
|
+
metrics.passed += 1
|
|
145
|
+
else:
|
|
146
|
+
metrics.failed += 1
|
|
147
|
+
metrics.failures.append(
|
|
148
|
+
{
|
|
149
|
+
"case_id": case_id,
|
|
150
|
+
"reason": "check_failed",
|
|
151
|
+
"failed_checks": outcome.failed_check_types(),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if any(check.get("type") == "not_fallback" for check in checks):
|
|
156
|
+
if not passed and "not_fallback" in outcome.failed_check_types():
|
|
157
|
+
metrics.fallback_count += 1
|
|
158
|
+
|
|
159
|
+
if any(check.get("type") == "json" for check in checks):
|
|
160
|
+
metrics.json_cases += 1
|
|
161
|
+
if passed:
|
|
162
|
+
metrics.json_passed += 1
|
|
163
|
+
|
|
164
|
+
denominator = metrics.total or 1
|
|
165
|
+
metrics.pass_rate = _round(metrics.passed / denominator, 4) or 0.0
|
|
166
|
+
metrics.fallback_rate = _round(metrics.fallback_count / denominator, 4) or 0.0
|
|
167
|
+
|
|
168
|
+
if metrics.json_cases:
|
|
169
|
+
metrics.format_compliance_rate = _round(
|
|
170
|
+
metrics.json_passed / metrics.json_cases, 4
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if latency_values:
|
|
174
|
+
metrics.avg_latency_ms = _round(sum(latency_values) / len(latency_values), 2)
|
|
175
|
+
metrics.p95_latency_ms = _round(percentile(latency_values, 0.95), 2)
|
|
176
|
+
if cost_values:
|
|
177
|
+
metrics.avg_cost_usd = _round(sum(cost_values) / len(cost_values), 8)
|
|
178
|
+
metrics.total_cost_usd = _round(sum(cost_values), 6)
|
|
179
|
+
if char_values:
|
|
180
|
+
metrics.avg_output_chars = _round(sum(char_values) / len(char_values), 1)
|
|
181
|
+
|
|
182
|
+
return metrics
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class Violation:
|
|
187
|
+
metric: str
|
|
188
|
+
baseline: float | None
|
|
189
|
+
current: float | None
|
|
190
|
+
rule: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
#: (metric, direction, tolerance). "higher_is_better" fails when it drops,
|
|
194
|
+
#: "lower_is_better" fails when it rises.
|
|
195
|
+
REGRESSION_RULES: tuple[tuple[str, str, float], ...] = (
|
|
196
|
+
("pass_rate", "higher_is_better", 0.02),
|
|
197
|
+
("format_compliance_rate", "higher_is_better", 0.02),
|
|
198
|
+
("fallback_rate", "lower_is_better", 0.02),
|
|
199
|
+
("p95_latency_ms", "lower_is_better", 0.20),
|
|
200
|
+
("avg_cost_usd", "lower_is_better", 0.20),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
#: Some metrics tolerate movement, so the baseline is nudged by this fraction
|
|
204
|
+
#: before comparing. Pass rate is judged on absolute points instead.
|
|
205
|
+
_RELATIVE_METRICS = {"p95_latency_ms", "avg_cost_usd"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def compare_runs(
|
|
209
|
+
baseline: RunMetrics,
|
|
210
|
+
current: RunMetrics,
|
|
211
|
+
tolerance_scale: float = 1.0,
|
|
212
|
+
) -> list[Violation]:
|
|
213
|
+
"""Return every metric that regressed beyond its tolerance."""
|
|
214
|
+
violations: list[Violation] = []
|
|
215
|
+
baseline_data = baseline.to_dict()
|
|
216
|
+
current_data = current.to_dict()
|
|
217
|
+
|
|
218
|
+
for metric, direction, tolerance in REGRESSION_RULES:
|
|
219
|
+
base = baseline_data.get(metric)
|
|
220
|
+
now = current_data.get(metric)
|
|
221
|
+
if base is None or now is None:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
tolerance = tolerance * tolerance_scale
|
|
225
|
+
if metric in _RELATIVE_METRICS:
|
|
226
|
+
allowed = base * tolerance
|
|
227
|
+
else:
|
|
228
|
+
allowed = tolerance
|
|
229
|
+
|
|
230
|
+
if direction == "higher_is_better":
|
|
231
|
+
if now < base - allowed:
|
|
232
|
+
violations.append(
|
|
233
|
+
Violation(
|
|
234
|
+
metric,
|
|
235
|
+
base,
|
|
236
|
+
now,
|
|
237
|
+
f"dropped by {base - now:.4f} (allowed {allowed:.4f})",
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
if now > base + allowed:
|
|
242
|
+
violations.append(
|
|
243
|
+
Violation(
|
|
244
|
+
metric,
|
|
245
|
+
base,
|
|
246
|
+
now,
|
|
247
|
+
f"rose by {now - base:.4f} (allowed {allowed:.4f})",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return violations
|
trace2eval/schema.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Trace loading and normalisation.
|
|
2
|
+
|
|
3
|
+
The reader is deliberately forgiving: real production logs are messy and field
|
|
4
|
+
names differ between frameworks. We accept a handful of common aliases for the
|
|
5
|
+
input/output pair, and we skip malformed lines instead of aborting the whole
|
|
6
|
+
run -- a single bad line in a 200k-line log must not cost you the batch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
INPUT_ALIASES = ("input", "prompt", "question", "query", "user_message")
|
|
17
|
+
OUTPUT_ALIASES = ("output", "response", "completion", "answer", "assistant_message")
|
|
18
|
+
ID_ALIASES = ("id", "trace_id", "traceId", "request_id", "requestId")
|
|
19
|
+
|
|
20
|
+
_NEGATIVE_FEEDBACK = {
|
|
21
|
+
"negative",
|
|
22
|
+
"neg",
|
|
23
|
+
"bad",
|
|
24
|
+
"down",
|
|
25
|
+
"thumbs_down",
|
|
26
|
+
"thumbsdown",
|
|
27
|
+
"差评",
|
|
28
|
+
"不满意",
|
|
29
|
+
"无帮助",
|
|
30
|
+
}
|
|
31
|
+
_POSITIVE_FEEDBACK = {
|
|
32
|
+
"positive",
|
|
33
|
+
"pos",
|
|
34
|
+
"good",
|
|
35
|
+
"up",
|
|
36
|
+
"thumbs_up",
|
|
37
|
+
"thumbsup",
|
|
38
|
+
"好评",
|
|
39
|
+
"满意",
|
|
40
|
+
"有帮助",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TraceFormatError(ValueError):
|
|
45
|
+
"""Raised when the input file cannot be read at all."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Trace:
|
|
50
|
+
"""One recorded call.
|
|
51
|
+
|
|
52
|
+
Only ``input`` and ``output`` are required. Everything else is optional and
|
|
53
|
+
simply widens the set of signals we can compute for this row.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
input: str
|
|
58
|
+
output: str
|
|
59
|
+
latency_ms: float | None = None
|
|
60
|
+
cost_usd: float | None = None
|
|
61
|
+
feedback: str | None = None
|
|
62
|
+
retried: bool = False
|
|
63
|
+
expect: dict[str, Any] | None = None
|
|
64
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def output_chars(self) -> int:
|
|
68
|
+
return len(self.output.strip())
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_negative(self) -> bool:
|
|
72
|
+
return self.feedback == "negative"
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"id": self.id,
|
|
77
|
+
"input": self.input,
|
|
78
|
+
"output": self.output,
|
|
79
|
+
"latency_ms": self.latency_ms,
|
|
80
|
+
"cost_usd": self.cost_usd,
|
|
81
|
+
"feedback": self.feedback,
|
|
82
|
+
"retried": self.retried,
|
|
83
|
+
"expect": self.expect,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class LoadResult:
|
|
89
|
+
traces: list[Trace] = field(default_factory=list)
|
|
90
|
+
skipped: list[tuple[int, str]] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def skipped_count(self) -> int:
|
|
94
|
+
return len(self.skipped)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pick(data: dict[str, Any], keys: tuple[str, ...], allow_empty: bool = False) -> Any:
|
|
98
|
+
"""Return the first alias that is present and usable.
|
|
99
|
+
|
|
100
|
+
``allow_empty`` exists because an empty string means different things for the
|
|
101
|
+
two sides of a call. An empty *input* is an unusable row -- there was nothing
|
|
102
|
+
to answer. An empty *output* is a perfectly readable row and one of the most
|
|
103
|
+
valuable ones in the log: it means the model returned nothing at all.
|
|
104
|
+
"""
|
|
105
|
+
for key in keys:
|
|
106
|
+
if key in data:
|
|
107
|
+
value = data[key]
|
|
108
|
+
if value is None:
|
|
109
|
+
continue
|
|
110
|
+
if isinstance(value, str) and not value.strip() and not allow_empty:
|
|
111
|
+
continue
|
|
112
|
+
return value
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _as_float(value: Any) -> float | None:
|
|
117
|
+
if value is None or isinstance(value, bool):
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return float(value)
|
|
121
|
+
except (TypeError, ValueError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _as_bool(value: Any) -> bool:
|
|
126
|
+
if isinstance(value, bool):
|
|
127
|
+
return value
|
|
128
|
+
if isinstance(value, (int, float)):
|
|
129
|
+
return value > 0
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
return value.strip().lower() in {"1", "true", "yes", "y"}
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalise_feedback(value: Any) -> str | None:
|
|
136
|
+
"""Collapse the many spellings of a thumbs-up/down into one of two values."""
|
|
137
|
+
if value is None:
|
|
138
|
+
return None
|
|
139
|
+
if isinstance(value, bool):
|
|
140
|
+
return "positive" if value else "negative"
|
|
141
|
+
text = str(value).strip().lower()
|
|
142
|
+
if not text or text in {"none", "null", "unknown", "-"}:
|
|
143
|
+
return None
|
|
144
|
+
if text in _NEGATIVE_FEEDBACK:
|
|
145
|
+
return "negative"
|
|
146
|
+
if text in _POSITIVE_FEEDBACK:
|
|
147
|
+
return "positive"
|
|
148
|
+
# Numeric ratings: 1-2 is a thumbs-down, 4-5 is a thumbs-up.
|
|
149
|
+
try:
|
|
150
|
+
score = float(text)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
if score <= 2:
|
|
154
|
+
return "negative"
|
|
155
|
+
if score >= 4:
|
|
156
|
+
return "positive"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_trace(data: dict[str, Any], fallback_id: str) -> Trace | None:
|
|
161
|
+
"""Build a :class:`Trace` from a raw log record, or ``None`` if unusable."""
|
|
162
|
+
raw_input = _pick(data, INPUT_ALIASES)
|
|
163
|
+
raw_output = _pick(data, OUTPUT_ALIASES, allow_empty=True)
|
|
164
|
+
if raw_input is None or raw_output is None:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
trace_id = _pick(data, ID_ALIASES) or fallback_id
|
|
168
|
+
retried = _as_bool(_pick(data, ("retried", "is_retry", "was_retried")))
|
|
169
|
+
retry_count = _as_float(_pick(data, ("retry_count", "retries")))
|
|
170
|
+
if retry_count:
|
|
171
|
+
retried = True
|
|
172
|
+
|
|
173
|
+
latency = _as_float(
|
|
174
|
+
_pick(data, ("latency_ms", "latency", "duration_ms", "elapsed_ms"))
|
|
175
|
+
)
|
|
176
|
+
if latency is None:
|
|
177
|
+
maybe_seconds = _as_float(_pick(data, ("latency_s", "duration_s")))
|
|
178
|
+
if maybe_seconds is not None:
|
|
179
|
+
latency = maybe_seconds * 1000.0
|
|
180
|
+
|
|
181
|
+
cost = _as_float(_pick(data, ("cost_usd", "cost", "total_cost", "price_usd")))
|
|
182
|
+
|
|
183
|
+
expect = data.get("expect") or data.get("assert") or data.get("checks")
|
|
184
|
+
if not isinstance(expect, dict):
|
|
185
|
+
expect = None
|
|
186
|
+
|
|
187
|
+
return Trace(
|
|
188
|
+
id=str(trace_id),
|
|
189
|
+
input=str(raw_input),
|
|
190
|
+
output=str(raw_output),
|
|
191
|
+
latency_ms=latency,
|
|
192
|
+
cost_usd=cost,
|
|
193
|
+
feedback=normalise_feedback(
|
|
194
|
+
_pick(data, ("feedback", "rating", "score", "user_feedback", "thumbs"))
|
|
195
|
+
),
|
|
196
|
+
retried=retried,
|
|
197
|
+
expect=expect,
|
|
198
|
+
raw=data,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_traces(path: str | Path) -> LoadResult:
|
|
203
|
+
"""Read a JSONL trace log.
|
|
204
|
+
|
|
205
|
+
Blank lines are ignored. A line that is not valid JSON, or that lacks an
|
|
206
|
+
input/output pair, is recorded in ``skipped`` and everything else still
|
|
207
|
+
loads.
|
|
208
|
+
"""
|
|
209
|
+
log_path = Path(path)
|
|
210
|
+
if not log_path.exists():
|
|
211
|
+
raise TraceFormatError(f"trace file not found: {log_path}")
|
|
212
|
+
|
|
213
|
+
result = LoadResult()
|
|
214
|
+
with log_path.open("r", encoding="utf-8") as handle:
|
|
215
|
+
for line_no, line in enumerate(handle, start=1):
|
|
216
|
+
stripped = line.strip()
|
|
217
|
+
if not stripped:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
data = json.loads(stripped)
|
|
221
|
+
except json.JSONDecodeError as exc:
|
|
222
|
+
result.skipped.append((line_no, f"invalid JSON: {exc.msg}"))
|
|
223
|
+
continue
|
|
224
|
+
if not isinstance(data, dict):
|
|
225
|
+
result.skipped.append((line_no, "not a JSON object"))
|
|
226
|
+
continue
|
|
227
|
+
trace = parse_trace(data, fallback_id=f"trace-{line_no:05d}")
|
|
228
|
+
if trace is None:
|
|
229
|
+
result.skipped.append((line_no, "missing input/output field"))
|
|
230
|
+
continue
|
|
231
|
+
result.traces.append(trace)
|
|
232
|
+
|
|
233
|
+
return result
|