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/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