evals-viewer-io 0.0.3__tar.gz
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.
- evals_viewer_io-0.0.3/.gitignore +7 -0
- evals_viewer_io-0.0.3/LICENSE +21 -0
- evals_viewer_io-0.0.3/PKG-INFO +216 -0
- evals_viewer_io-0.0.3/README.md +189 -0
- evals_viewer_io-0.0.3/pyproject.toml +42 -0
- evals_viewer_io-0.0.3/src/evals_viewer_io/__init__.py +23 -0
- evals_viewer_io-0.0.3/src/evals_viewer_io/aggregates.py +88 -0
- evals_viewer_io-0.0.3/src/evals_viewer_io/pytest.py +39 -0
- evals_viewer_io-0.0.3/src/evals_viewer_io/schema.py +141 -0
- evals_viewer_io-0.0.3/src/evals_viewer_io/writer.py +64 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Lester
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evals-viewer-io
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Pydantic schemas and a writer for the evals-viewer on-disk format — the Python writer side of the evals-viewer framework.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ideonate/evals-viewer
|
|
6
|
+
Project-URL: Repository, https://github.com/ideonate/evals-viewer
|
|
7
|
+
Project-URL: Issues, https://github.com/ideonate/evals-viewer/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md
|
|
9
|
+
Author: Dan Lester
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: evals,evaluation,llm,pydantic,viewer
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Provides-Extra: pytest
|
|
25
|
+
Requires-Dist: pytest>=7; extra == 'pytest'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# evals-viewer-io
|
|
29
|
+
|
|
30
|
+
Pydantic schemas and a writer for the [**evals-viewer**](https://github.com/ideonate/evals-viewer) on-disk format. This is the Python writer side of the framework — it produces the JSON tree that [`@ideonate/evals-viewer-server`](https://www.npmjs.com/package/@ideonate/evals-viewer-server) reads and the Vue frontend [`@ideonate/evals-viewer-core`](https://www.npmjs.com/package/@ideonate/evals-viewer-core) renders.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pip install evals-viewer-io
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+ and Pydantic 2.
|
|
39
|
+
|
|
40
|
+
## What's in the box
|
|
41
|
+
|
|
42
|
+
| Symbol | Purpose |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `RunMetadata`, `EvalSummary`, `CaseSummary`, `AggregateStats` | Pydantic models matching the on-disk format |
|
|
45
|
+
| `TokenUsage` | Token / cost model with addition, `from_pydantic_ai` adapter, per-model breakdown |
|
|
46
|
+
| `save_run_metadata`, `save_eval_results` | Filesystem writers — given models and dicts, write JSON in the layout the viewer expects |
|
|
47
|
+
| `compute_aggregates(cases)` | Group `case.scores[evaluator]` across cases → `{evaluator: {mean, min, max}}` |
|
|
48
|
+
| `compute_token_totals(cases)` | Sum token usage / cost / per-model breakdown across cases |
|
|
49
|
+
| `eval_run_dir` (pytest fixture) | Optional fixture creating a fresh run directory under `EVALS_RESULTS_DIR` |
|
|
50
|
+
|
|
51
|
+
## Quickstart: minimal end-to-end
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from evals_viewer_io import (
|
|
55
|
+
RunMetadata, EvalSummary, CaseSummary, TokenUsage,
|
|
56
|
+
compute_aggregates, compute_token_totals,
|
|
57
|
+
save_eval_results,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 1. Build per-case rows. The output_summary dict is a free-form bag of
|
|
61
|
+
# fields the viewer can show in the eval-detail table; token fields
|
|
62
|
+
# use the canonical input_tokens / output_tokens / cost_usd / usage_by_model.
|
|
63
|
+
cases = [
|
|
64
|
+
CaseSummary(
|
|
65
|
+
name="case_001",
|
|
66
|
+
scores={"Accuracy": 0.9, "Coverage": 0.8},
|
|
67
|
+
judge_reasons={"Accuracy": "All key facts present."},
|
|
68
|
+
output_summary={
|
|
69
|
+
"input_tokens": 1234,
|
|
70
|
+
"output_tokens": 567,
|
|
71
|
+
"cost_usd": 0.012,
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
CaseSummary(
|
|
75
|
+
name="case_002",
|
|
76
|
+
scores={"Accuracy": 0.7, "Coverage": 0.9},
|
|
77
|
+
output_summary={"input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
|
|
78
|
+
),
|
|
79
|
+
CaseSummary(name="case_003", success=False, error="Timeout"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# 2. Compute the per-eval aggregates and write the run.
|
|
83
|
+
summary = EvalSummary(
|
|
84
|
+
timestamp="2026-04-07T10:30:00Z",
|
|
85
|
+
aggregates=compute_aggregates(cases),
|
|
86
|
+
cases=cases,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
save_eval_results(
|
|
90
|
+
results_dir="./tests/test-results/evals",
|
|
91
|
+
run_id="2026-04-07_103000",
|
|
92
|
+
eval_name="my_eval",
|
|
93
|
+
summary=summary,
|
|
94
|
+
outputs={
|
|
95
|
+
"case_001": {"answer": "...", "input_tokens": 1234, "output_tokens": 567, "cost_usd": 0.012},
|
|
96
|
+
"case_002": {"answer": "...", "input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
|
|
97
|
+
},
|
|
98
|
+
run=RunMetadata(timestamp="2026-04-07T10:30:00Z", git_commit="abc1234"),
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That writes:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
tests/test-results/evals/2026-04-07_103000/
|
|
106
|
+
├── run.json
|
|
107
|
+
└── my_eval/
|
|
108
|
+
├── summary.json
|
|
109
|
+
└── outputs/
|
|
110
|
+
├── case_001.json
|
|
111
|
+
└── case_002.json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Open the viewer and the run shows up.
|
|
115
|
+
|
|
116
|
+
## Token usage
|
|
117
|
+
|
|
118
|
+
`TokenUsage` is a normal Pydantic model with `__add__` so you can sum across cases or across model calls:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from evals_viewer_io import TokenUsage
|
|
122
|
+
|
|
123
|
+
opus_call = TokenUsage(input_tokens=1200, output_tokens=300, cost_usd=0.018)
|
|
124
|
+
haiku_call = TokenUsage(input_tokens=800, output_tokens=200, cost_usd=0.0009)
|
|
125
|
+
|
|
126
|
+
# Per-model breakdown for one case
|
|
127
|
+
case_total = TokenUsage(
|
|
128
|
+
input_tokens=opus_call.input_tokens + haiku_call.input_tokens,
|
|
129
|
+
output_tokens=opus_call.output_tokens + haiku_call.output_tokens,
|
|
130
|
+
cost_usd=(opus_call.cost_usd or 0) + (haiku_call.cost_usd or 0),
|
|
131
|
+
usage_by_model={"opus": opus_call, "haiku": haiku_call},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Or just use sum() across multiple cases:
|
|
135
|
+
total = sum([case1_usage, case2_usage, case3_usage])
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The viewer reads `input_tokens`, `output_tokens`, `cost_usd`, and `usage_by_model` from both each case's full output JSON and from the per-case row in `summary.json`'s `output_summary`.
|
|
139
|
+
|
|
140
|
+
### Pydantic-AI adapter
|
|
141
|
+
|
|
142
|
+
If you use [pydantic-ai](https://ai.pydantic.dev), there's a one-liner to convert its `Usage` / `RunUsage` objects (which use `request_tokens` / `response_tokens` rather than `input` / `output`):
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from evals_viewer_io import TokenUsage
|
|
146
|
+
|
|
147
|
+
usage = TokenUsage.from_pydantic_ai(result.usage(), cost_usd=my_cost_calc(result))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The adapter uses `getattr` so this package never imports pydantic-ai itself. Other frameworks (OpenAI SDK, Anthropic SDK, …) can be mapped just as easily — `TokenUsage(input_tokens=resp.usage.prompt_tokens, output_tokens=resp.usage.completion_tokens)` etc.
|
|
151
|
+
|
|
152
|
+
Cost is the caller's responsibility. Pricing tables go stale fast and don't belong in this package.
|
|
153
|
+
|
|
154
|
+
## Aggregating tokens across cases
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from evals_viewer_io import compute_token_totals
|
|
158
|
+
|
|
159
|
+
totals = compute_token_totals(cases)
|
|
160
|
+
print(totals.input_tokens, totals.output_tokens, totals.cost_usd)
|
|
161
|
+
print(totals.usage_by_model) # per-model breakdown summed across all cases
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The function reads `input_tokens` / `output_tokens` / `cost_usd` / `usage_by_model` from each case's `output_summary`. Cases that don't have those fields contribute zero.
|
|
165
|
+
|
|
166
|
+
## pytest fixture
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# tests/conftest.py
|
|
170
|
+
from evals_viewer_io.pytest import eval_run_dir # noqa: F401
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# tests/test_my_eval.py
|
|
175
|
+
def test_my_eval(eval_run_dir):
|
|
176
|
+
# eval_run_dir is a pathlib.Path under EVALS_RESULTS_DIR (or a tmp dir),
|
|
177
|
+
# and run.json has already been written.
|
|
178
|
+
...
|
|
179
|
+
save_eval_results(
|
|
180
|
+
results_dir=eval_run_dir.parent,
|
|
181
|
+
run_id=eval_run_dir.name,
|
|
182
|
+
eval_name="my_eval",
|
|
183
|
+
summary=summary,
|
|
184
|
+
outputs=outputs,
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Set `EVALS_RESULTS_DIR=tests/test-results/evals` (or wherever your project keeps them) so the run lands somewhere the viewer can find.
|
|
189
|
+
|
|
190
|
+
## What this package deliberately does *not* do
|
|
191
|
+
|
|
192
|
+
This is intentionally a small package — schemas plus the smallest set of helpers that every consumer would need to write themselves. It does **not** include:
|
|
193
|
+
|
|
194
|
+
- **Token field extraction from arbitrary model outputs.** Different LLM SDKs name fields differently; the caller knows their own output schema.
|
|
195
|
+
- **A pricing table.** Costs are pricing × tokens; pricing changes weekly. You compute it, you pass it in via `cost_usd`.
|
|
196
|
+
- **Pydantic→dict serialization.** If your case output is a Pydantic model, call `.model_dump()` yourself before passing it to `save_eval_results`. Hiding that behind a wrapper would just suppress errors.
|
|
197
|
+
- **Coupling to a specific eval framework** like `pydantic-evals` or `inspect_ai`. The writer takes plain dicts. Frameworks can be added as adapters when there's demand.
|
|
198
|
+
- **Schema versioning.** The on-disk format is forward-compatible by design (`extra="allow"` everywhere). If a breaking change ever lands, that's the time for a `schema_version` field, not now.
|
|
199
|
+
|
|
200
|
+
## On-disk contract
|
|
201
|
+
|
|
202
|
+
See [`docs/data-layout.md`](https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md) in the monorepo for the full directory tree and per-file schemas. The TL;DR:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
{results_dir}/{run_id}/
|
|
206
|
+
├── run.json (RunMetadata)
|
|
207
|
+
└── {eval_name}/
|
|
208
|
+
├── summary.json (EvalSummary: aggregates + per-case rows)
|
|
209
|
+
├── outputs/{case_name}.json (full per-case output)
|
|
210
|
+
├── inputs/{case_name}.json (optional; saved input fixture)
|
|
211
|
+
└── case-scores/{case_name}.json (optional; per-question scores)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# evals-viewer-io
|
|
2
|
+
|
|
3
|
+
Pydantic schemas and a writer for the [**evals-viewer**](https://github.com/ideonate/evals-viewer) on-disk format. This is the Python writer side of the framework — it produces the JSON tree that [`@ideonate/evals-viewer-server`](https://www.npmjs.com/package/@ideonate/evals-viewer-server) reads and the Vue frontend [`@ideonate/evals-viewer-core`](https://www.npmjs.com/package/@ideonate/evals-viewer-core) renders.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install evals-viewer-io
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10+ and Pydantic 2.
|
|
12
|
+
|
|
13
|
+
## What's in the box
|
|
14
|
+
|
|
15
|
+
| Symbol | Purpose |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `RunMetadata`, `EvalSummary`, `CaseSummary`, `AggregateStats` | Pydantic models matching the on-disk format |
|
|
18
|
+
| `TokenUsage` | Token / cost model with addition, `from_pydantic_ai` adapter, per-model breakdown |
|
|
19
|
+
| `save_run_metadata`, `save_eval_results` | Filesystem writers — given models and dicts, write JSON in the layout the viewer expects |
|
|
20
|
+
| `compute_aggregates(cases)` | Group `case.scores[evaluator]` across cases → `{evaluator: {mean, min, max}}` |
|
|
21
|
+
| `compute_token_totals(cases)` | Sum token usage / cost / per-model breakdown across cases |
|
|
22
|
+
| `eval_run_dir` (pytest fixture) | Optional fixture creating a fresh run directory under `EVALS_RESULTS_DIR` |
|
|
23
|
+
|
|
24
|
+
## Quickstart: minimal end-to-end
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from evals_viewer_io import (
|
|
28
|
+
RunMetadata, EvalSummary, CaseSummary, TokenUsage,
|
|
29
|
+
compute_aggregates, compute_token_totals,
|
|
30
|
+
save_eval_results,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 1. Build per-case rows. The output_summary dict is a free-form bag of
|
|
34
|
+
# fields the viewer can show in the eval-detail table; token fields
|
|
35
|
+
# use the canonical input_tokens / output_tokens / cost_usd / usage_by_model.
|
|
36
|
+
cases = [
|
|
37
|
+
CaseSummary(
|
|
38
|
+
name="case_001",
|
|
39
|
+
scores={"Accuracy": 0.9, "Coverage": 0.8},
|
|
40
|
+
judge_reasons={"Accuracy": "All key facts present."},
|
|
41
|
+
output_summary={
|
|
42
|
+
"input_tokens": 1234,
|
|
43
|
+
"output_tokens": 567,
|
|
44
|
+
"cost_usd": 0.012,
|
|
45
|
+
},
|
|
46
|
+
),
|
|
47
|
+
CaseSummary(
|
|
48
|
+
name="case_002",
|
|
49
|
+
scores={"Accuracy": 0.7, "Coverage": 0.9},
|
|
50
|
+
output_summary={"input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
|
|
51
|
+
),
|
|
52
|
+
CaseSummary(name="case_003", success=False, error="Timeout"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# 2. Compute the per-eval aggregates and write the run.
|
|
56
|
+
summary = EvalSummary(
|
|
57
|
+
timestamp="2026-04-07T10:30:00Z",
|
|
58
|
+
aggregates=compute_aggregates(cases),
|
|
59
|
+
cases=cases,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
save_eval_results(
|
|
63
|
+
results_dir="./tests/test-results/evals",
|
|
64
|
+
run_id="2026-04-07_103000",
|
|
65
|
+
eval_name="my_eval",
|
|
66
|
+
summary=summary,
|
|
67
|
+
outputs={
|
|
68
|
+
"case_001": {"answer": "...", "input_tokens": 1234, "output_tokens": 567, "cost_usd": 0.012},
|
|
69
|
+
"case_002": {"answer": "...", "input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
|
|
70
|
+
},
|
|
71
|
+
run=RunMetadata(timestamp="2026-04-07T10:30:00Z", git_commit="abc1234"),
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
That writes:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
tests/test-results/evals/2026-04-07_103000/
|
|
79
|
+
├── run.json
|
|
80
|
+
└── my_eval/
|
|
81
|
+
├── summary.json
|
|
82
|
+
└── outputs/
|
|
83
|
+
├── case_001.json
|
|
84
|
+
└── case_002.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Open the viewer and the run shows up.
|
|
88
|
+
|
|
89
|
+
## Token usage
|
|
90
|
+
|
|
91
|
+
`TokenUsage` is a normal Pydantic model with `__add__` so you can sum across cases or across model calls:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from evals_viewer_io import TokenUsage
|
|
95
|
+
|
|
96
|
+
opus_call = TokenUsage(input_tokens=1200, output_tokens=300, cost_usd=0.018)
|
|
97
|
+
haiku_call = TokenUsage(input_tokens=800, output_tokens=200, cost_usd=0.0009)
|
|
98
|
+
|
|
99
|
+
# Per-model breakdown for one case
|
|
100
|
+
case_total = TokenUsage(
|
|
101
|
+
input_tokens=opus_call.input_tokens + haiku_call.input_tokens,
|
|
102
|
+
output_tokens=opus_call.output_tokens + haiku_call.output_tokens,
|
|
103
|
+
cost_usd=(opus_call.cost_usd or 0) + (haiku_call.cost_usd or 0),
|
|
104
|
+
usage_by_model={"opus": opus_call, "haiku": haiku_call},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Or just use sum() across multiple cases:
|
|
108
|
+
total = sum([case1_usage, case2_usage, case3_usage])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The viewer reads `input_tokens`, `output_tokens`, `cost_usd`, and `usage_by_model` from both each case's full output JSON and from the per-case row in `summary.json`'s `output_summary`.
|
|
112
|
+
|
|
113
|
+
### Pydantic-AI adapter
|
|
114
|
+
|
|
115
|
+
If you use [pydantic-ai](https://ai.pydantic.dev), there's a one-liner to convert its `Usage` / `RunUsage` objects (which use `request_tokens` / `response_tokens` rather than `input` / `output`):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from evals_viewer_io import TokenUsage
|
|
119
|
+
|
|
120
|
+
usage = TokenUsage.from_pydantic_ai(result.usage(), cost_usd=my_cost_calc(result))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The adapter uses `getattr` so this package never imports pydantic-ai itself. Other frameworks (OpenAI SDK, Anthropic SDK, …) can be mapped just as easily — `TokenUsage(input_tokens=resp.usage.prompt_tokens, output_tokens=resp.usage.completion_tokens)` etc.
|
|
124
|
+
|
|
125
|
+
Cost is the caller's responsibility. Pricing tables go stale fast and don't belong in this package.
|
|
126
|
+
|
|
127
|
+
## Aggregating tokens across cases
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from evals_viewer_io import compute_token_totals
|
|
131
|
+
|
|
132
|
+
totals = compute_token_totals(cases)
|
|
133
|
+
print(totals.input_tokens, totals.output_tokens, totals.cost_usd)
|
|
134
|
+
print(totals.usage_by_model) # per-model breakdown summed across all cases
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The function reads `input_tokens` / `output_tokens` / `cost_usd` / `usage_by_model` from each case's `output_summary`. Cases that don't have those fields contribute zero.
|
|
138
|
+
|
|
139
|
+
## pytest fixture
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# tests/conftest.py
|
|
143
|
+
from evals_viewer_io.pytest import eval_run_dir # noqa: F401
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# tests/test_my_eval.py
|
|
148
|
+
def test_my_eval(eval_run_dir):
|
|
149
|
+
# eval_run_dir is a pathlib.Path under EVALS_RESULTS_DIR (or a tmp dir),
|
|
150
|
+
# and run.json has already been written.
|
|
151
|
+
...
|
|
152
|
+
save_eval_results(
|
|
153
|
+
results_dir=eval_run_dir.parent,
|
|
154
|
+
run_id=eval_run_dir.name,
|
|
155
|
+
eval_name="my_eval",
|
|
156
|
+
summary=summary,
|
|
157
|
+
outputs=outputs,
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Set `EVALS_RESULTS_DIR=tests/test-results/evals` (or wherever your project keeps them) so the run lands somewhere the viewer can find.
|
|
162
|
+
|
|
163
|
+
## What this package deliberately does *not* do
|
|
164
|
+
|
|
165
|
+
This is intentionally a small package — schemas plus the smallest set of helpers that every consumer would need to write themselves. It does **not** include:
|
|
166
|
+
|
|
167
|
+
- **Token field extraction from arbitrary model outputs.** Different LLM SDKs name fields differently; the caller knows their own output schema.
|
|
168
|
+
- **A pricing table.** Costs are pricing × tokens; pricing changes weekly. You compute it, you pass it in via `cost_usd`.
|
|
169
|
+
- **Pydantic→dict serialization.** If your case output is a Pydantic model, call `.model_dump()` yourself before passing it to `save_eval_results`. Hiding that behind a wrapper would just suppress errors.
|
|
170
|
+
- **Coupling to a specific eval framework** like `pydantic-evals` or `inspect_ai`. The writer takes plain dicts. Frameworks can be added as adapters when there's demand.
|
|
171
|
+
- **Schema versioning.** The on-disk format is forward-compatible by design (`extra="allow"` everywhere). If a breaking change ever lands, that's the time for a `schema_version` field, not now.
|
|
172
|
+
|
|
173
|
+
## On-disk contract
|
|
174
|
+
|
|
175
|
+
See [`docs/data-layout.md`](https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md) in the monorepo for the full directory tree and per-file schemas. The TL;DR:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
{results_dir}/{run_id}/
|
|
179
|
+
├── run.json (RunMetadata)
|
|
180
|
+
└── {eval_name}/
|
|
181
|
+
├── summary.json (EvalSummary: aggregates + per-case rows)
|
|
182
|
+
├── outputs/{case_name}.json (full per-case output)
|
|
183
|
+
├── inputs/{case_name}.json (optional; saved input fixture)
|
|
184
|
+
└── case-scores/{case_name}.json (optional; per-question scores)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evals-viewer-io"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "Pydantic schemas and a writer for the evals-viewer on-disk format — the Python writer side of the evals-viewer framework."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Dan Lester" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["evals", "llm", "pydantic", "evaluation", "viewer"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Testing",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"pydantic>=2.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
pytest = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/ideonate/evals-viewer"
|
|
37
|
+
Repository = "https://github.com/ideonate/evals-viewer"
|
|
38
|
+
Issues = "https://github.com/ideonate/evals-viewer/issues"
|
|
39
|
+
Documentation = "https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/evals_viewer_io"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pydantic schemas and writer for the evals-viewer on-disk format."""
|
|
2
|
+
|
|
3
|
+
from .aggregates import compute_aggregates, compute_token_totals
|
|
4
|
+
from .schema import (
|
|
5
|
+
AggregateStats,
|
|
6
|
+
CaseSummary,
|
|
7
|
+
EvalSummary,
|
|
8
|
+
RunMetadata,
|
|
9
|
+
TokenUsage,
|
|
10
|
+
)
|
|
11
|
+
from .writer import save_eval_results, save_run_metadata
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AggregateStats",
|
|
15
|
+
"CaseSummary",
|
|
16
|
+
"EvalSummary",
|
|
17
|
+
"RunMetadata",
|
|
18
|
+
"TokenUsage",
|
|
19
|
+
"compute_aggregates",
|
|
20
|
+
"compute_token_totals",
|
|
21
|
+
"save_eval_results",
|
|
22
|
+
"save_run_metadata",
|
|
23
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Helpers for computing per-eval aggregates from a list of cases.
|
|
2
|
+
|
|
3
|
+
These are deliberately small and unambiguous: any consumer with multiple
|
|
4
|
+
cases per eval will need them, and the math has no domain knowledge.
|
|
5
|
+
Anything fancier (token cost tables, framework-specific extraction) lives
|
|
6
|
+
in caller code, not here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Iterable
|
|
12
|
+
|
|
13
|
+
from .schema import AggregateStats, CaseSummary, TokenUsage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute_aggregates(cases: Iterable[CaseSummary]) -> dict[str, AggregateStats]:
|
|
17
|
+
"""Group ``case.scores[evaluator]`` across cases and return mean/min/max.
|
|
18
|
+
|
|
19
|
+
Failed cases (``success=False``) and cases with no scores are skipped.
|
|
20
|
+
Evaluators are listed in first-seen order across the input.
|
|
21
|
+
"""
|
|
22
|
+
grouped: dict[str, list[float]] = {}
|
|
23
|
+
for case in cases:
|
|
24
|
+
if case.success is False or not case.scores:
|
|
25
|
+
continue
|
|
26
|
+
for evaluator, value in case.scores.items():
|
|
27
|
+
if value is None:
|
|
28
|
+
continue
|
|
29
|
+
grouped.setdefault(evaluator, []).append(float(value))
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
evaluator: AggregateStats(
|
|
33
|
+
mean=sum(values) / len(values),
|
|
34
|
+
min=min(values),
|
|
35
|
+
max=max(values),
|
|
36
|
+
)
|
|
37
|
+
for evaluator, values in grouped.items()
|
|
38
|
+
if values
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_token_totals(cases: Iterable[CaseSummary]) -> TokenUsage:
|
|
43
|
+
"""Sum token usage across cases.
|
|
44
|
+
|
|
45
|
+
Reads ``input_tokens``, ``output_tokens``, ``cost_usd``, and
|
|
46
|
+
``usage_by_model`` from each case's ``output_summary``. Cases without
|
|
47
|
+
those fields contribute zero. The returned ``TokenUsage.usage_by_model``
|
|
48
|
+
is the per-model breakdown summed across all cases that had one.
|
|
49
|
+
"""
|
|
50
|
+
total = TokenUsage()
|
|
51
|
+
for case in cases:
|
|
52
|
+
summary = case.output_summary or {}
|
|
53
|
+
contribution = _token_usage_from_dict(summary)
|
|
54
|
+
if contribution is not None:
|
|
55
|
+
total = total + contribution
|
|
56
|
+
return total
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _token_usage_from_dict(d: dict[str, Any]) -> TokenUsage | None:
|
|
60
|
+
"""Best-effort extraction from a free-form output_summary dict."""
|
|
61
|
+
if not any(k in d for k in ("input_tokens", "output_tokens", "cost_usd", "usage_by_model")):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
raw_by_model = d.get("usage_by_model")
|
|
65
|
+
by_model: dict[str, TokenUsage] | None
|
|
66
|
+
if raw_by_model:
|
|
67
|
+
by_model = {}
|
|
68
|
+
for model, usage in raw_by_model.items():
|
|
69
|
+
if isinstance(usage, TokenUsage):
|
|
70
|
+
entry = usage
|
|
71
|
+
elif isinstance(usage, dict):
|
|
72
|
+
entry = TokenUsage(
|
|
73
|
+
input_tokens=int(usage.get("input_tokens", 0) or 0),
|
|
74
|
+
output_tokens=int(usage.get("output_tokens", 0) or 0),
|
|
75
|
+
cost_usd=usage.get("cost_usd"),
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
continue
|
|
79
|
+
by_model[model] = entry
|
|
80
|
+
else:
|
|
81
|
+
by_model = None
|
|
82
|
+
|
|
83
|
+
return TokenUsage(
|
|
84
|
+
input_tokens=int(d.get("input_tokens", 0) or 0),
|
|
85
|
+
output_tokens=int(d.get("output_tokens", 0) or 0),
|
|
86
|
+
cost_usd=d.get("cost_usd"),
|
|
87
|
+
usage_by_model=by_model,
|
|
88
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Optional pytest fixture helpers for evals-viewer-io.
|
|
2
|
+
|
|
3
|
+
Apps can write their own fixtures, but this provides a sensible default:
|
|
4
|
+
|
|
5
|
+
# conftest.py
|
|
6
|
+
from evals_viewer_io.pytest import eval_run_dir # noqa: F401
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from .schema import RunMetadata
|
|
18
|
+
from .writer import save_run_metadata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now_run_id() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def eval_run_dir(tmp_path_factory) -> Path:
|
|
27
|
+
"""Create a fresh run directory for the test session and return its path.
|
|
28
|
+
|
|
29
|
+
Honours ``EVALS_RESULTS_DIR`` if set, otherwise uses a tmp dir. Writes a
|
|
30
|
+
minimal ``run.json`` so the viewer will list it.
|
|
31
|
+
"""
|
|
32
|
+
base = Path(os.environ.get("EVALS_RESULTS_DIR", tmp_path_factory.mktemp("evals")))
|
|
33
|
+
run_id = _now_run_id()
|
|
34
|
+
save_run_metadata(
|
|
35
|
+
base,
|
|
36
|
+
run_id,
|
|
37
|
+
RunMetadata(timestamp=datetime.now(timezone.utc).isoformat()),
|
|
38
|
+
)
|
|
39
|
+
return base / run_id
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Pydantic models matching the evals-viewer on-disk format.
|
|
2
|
+
|
|
3
|
+
See ``docs/data-layout.md`` in the monorepo for the canonical contract.
|
|
4
|
+
These models are intentionally permissive (extra fields allowed) so writers
|
|
5
|
+
can include domain-specific extras without coordinating schema bumps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Permissive(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="allow")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AggregateStats(_Permissive):
|
|
20
|
+
"""Aggregate stats for one evaluator across all cases in an eval."""
|
|
21
|
+
|
|
22
|
+
mean: float
|
|
23
|
+
min: float
|
|
24
|
+
max: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenUsage(_Permissive):
|
|
28
|
+
"""Token usage and cost for one model call, one case, or a whole eval.
|
|
29
|
+
|
|
30
|
+
Field names follow the Anthropic / viewer convention (input/output rather
|
|
31
|
+
than request/response). Adapter classmethods like ``from_pydantic_ai``
|
|
32
|
+
rename other frameworks' fields onto these.
|
|
33
|
+
|
|
34
|
+
Cost is the caller's responsibility — pricing tables go stale fast and
|
|
35
|
+
don't belong in this package.
|
|
36
|
+
|
|
37
|
+
The ``usage_by_model`` map is one level deep: entries inside it should
|
|
38
|
+
leave their own ``usage_by_model`` as None.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
input_tokens: int = 0
|
|
42
|
+
output_tokens: int = 0
|
|
43
|
+
cost_usd: float | None = None
|
|
44
|
+
usage_by_model: dict[str, "TokenUsage"] | None = None
|
|
45
|
+
|
|
46
|
+
def __add__(self, other: TokenUsage) -> TokenUsage:
|
|
47
|
+
if not isinstance(other, TokenUsage):
|
|
48
|
+
return NotImplemented
|
|
49
|
+
|
|
50
|
+
# Sum cost only if at least one side reports it; preserve None when
|
|
51
|
+
# nobody knows the cost.
|
|
52
|
+
if self.cost_usd is None and other.cost_usd is None:
|
|
53
|
+
cost = None
|
|
54
|
+
else:
|
|
55
|
+
cost = (self.cost_usd or 0.0) + (other.cost_usd or 0.0)
|
|
56
|
+
|
|
57
|
+
# Merge per-model breakdowns. Only one level deep — don't recurse.
|
|
58
|
+
merged: dict[str, TokenUsage] | None
|
|
59
|
+
if self.usage_by_model is None and other.usage_by_model is None:
|
|
60
|
+
merged = None
|
|
61
|
+
else:
|
|
62
|
+
merged = {}
|
|
63
|
+
for source in (self.usage_by_model or {}, other.usage_by_model or {}):
|
|
64
|
+
for model, usage in source.items():
|
|
65
|
+
if model in merged:
|
|
66
|
+
merged[model] = TokenUsage(
|
|
67
|
+
input_tokens=merged[model].input_tokens + usage.input_tokens,
|
|
68
|
+
output_tokens=merged[model].output_tokens + usage.output_tokens,
|
|
69
|
+
cost_usd=_sum_optional(merged[model].cost_usd, usage.cost_usd),
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
merged[model] = TokenUsage(
|
|
73
|
+
input_tokens=usage.input_tokens,
|
|
74
|
+
output_tokens=usage.output_tokens,
|
|
75
|
+
cost_usd=usage.cost_usd,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return TokenUsage(
|
|
79
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
80
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
81
|
+
cost_usd=cost,
|
|
82
|
+
usage_by_model=merged,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __radd__(self, other: Any) -> TokenUsage:
|
|
86
|
+
# Lets `sum([usage1, usage2, ...])` work without an explicit start value.
|
|
87
|
+
if other == 0:
|
|
88
|
+
return self
|
|
89
|
+
return NotImplemented
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_pydantic_ai(cls, usage: Any, *, cost_usd: float | None = None) -> TokenUsage:
|
|
93
|
+
"""Build a TokenUsage from a pydantic-ai Usage or RunUsage object.
|
|
94
|
+
|
|
95
|
+
Maps ``request_tokens`` → ``input_tokens`` and ``response_tokens`` →
|
|
96
|
+
``output_tokens``. Tolerates None values (treats them as zero).
|
|
97
|
+
Cost is not derived from tokens — pass it in if you have it.
|
|
98
|
+
|
|
99
|
+
Uses ``getattr`` so this package never imports pydantic-ai itself.
|
|
100
|
+
"""
|
|
101
|
+
return cls(
|
|
102
|
+
input_tokens=getattr(usage, "request_tokens", 0) or 0,
|
|
103
|
+
output_tokens=getattr(usage, "response_tokens", 0) or 0,
|
|
104
|
+
cost_usd=cost_usd,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sum_optional(a: float | None, b: float | None) -> float | None:
|
|
109
|
+
if a is None and b is None:
|
|
110
|
+
return None
|
|
111
|
+
return (a or 0.0) + (b or 0.0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CaseSummary(_Permissive):
|
|
115
|
+
"""One row in ``summary.json``'s ``cases`` array."""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
success: bool = True
|
|
119
|
+
error: str | None = None
|
|
120
|
+
scores: dict[str, float] | None = None
|
|
121
|
+
judge_reasons: dict[str, str] | None = None
|
|
122
|
+
integrity: dict[str, Any] | None = None
|
|
123
|
+
output_summary: dict[str, Any] | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EvalSummary(_Permissive):
|
|
127
|
+
"""Contents of ``summary.json`` for one eval inside one run."""
|
|
128
|
+
|
|
129
|
+
timestamp: str | None = None
|
|
130
|
+
aggregates: dict[str, AggregateStats] = Field(default_factory=dict)
|
|
131
|
+
cases: list[CaseSummary] = Field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RunMetadata(_Permissive):
|
|
135
|
+
"""Contents of ``run.json`` at the run-id directory root."""
|
|
136
|
+
|
|
137
|
+
timestamp: str
|
|
138
|
+
git_commit: str | None = None
|
|
139
|
+
git_branch: str | None = None
|
|
140
|
+
git_dirty: bool = False
|
|
141
|
+
tags: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Filesystem writer for the evals-viewer on-disk format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .schema import EvalSummary, RunMetadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _dump(path: Path, data: Any) -> None:
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_run_metadata(
|
|
18
|
+
results_dir: str | Path,
|
|
19
|
+
run_id: str,
|
|
20
|
+
run: RunMetadata,
|
|
21
|
+
) -> Path:
|
|
22
|
+
"""Write ``{results_dir}/{run_id}/run.json`` and return its path."""
|
|
23
|
+
run_dir = Path(results_dir) / run_id
|
|
24
|
+
path = run_dir / "run.json"
|
|
25
|
+
_dump(path, run.model_dump(mode="json", exclude_none=True))
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_eval_results(
|
|
30
|
+
results_dir: str | Path,
|
|
31
|
+
run_id: str,
|
|
32
|
+
eval_name: str,
|
|
33
|
+
summary: EvalSummary,
|
|
34
|
+
outputs: Mapping[str, Any],
|
|
35
|
+
*,
|
|
36
|
+
inputs: Mapping[str, Any] | None = None,
|
|
37
|
+
case_scores: Mapping[str, Any] | None = None,
|
|
38
|
+
run: RunMetadata | None = None,
|
|
39
|
+
) -> Path:
|
|
40
|
+
"""Write a complete eval result tree under ``{results_dir}/{run_id}/{eval_name}/``.
|
|
41
|
+
|
|
42
|
+
If ``run`` is supplied and ``run.json`` does not yet exist, it is written too.
|
|
43
|
+
"""
|
|
44
|
+
base = Path(results_dir) / run_id / eval_name
|
|
45
|
+
|
|
46
|
+
_dump(base / "summary.json", summary.model_dump(mode="json", exclude_none=True))
|
|
47
|
+
|
|
48
|
+
for case_name, output in outputs.items():
|
|
49
|
+
_dump(base / "outputs" / f"{case_name}.json", output)
|
|
50
|
+
|
|
51
|
+
if inputs:
|
|
52
|
+
for case_name, value in inputs.items():
|
|
53
|
+
_dump(base / "inputs" / f"{case_name}.json", value)
|
|
54
|
+
|
|
55
|
+
if case_scores:
|
|
56
|
+
for case_name, value in case_scores.items():
|
|
57
|
+
_dump(base / "case-scores" / f"{case_name}.json", value)
|
|
58
|
+
|
|
59
|
+
if run is not None:
|
|
60
|
+
run_path = Path(results_dir) / run_id / "run.json"
|
|
61
|
+
if not run_path.exists():
|
|
62
|
+
save_run_metadata(results_dir, run_id, run)
|
|
63
|
+
|
|
64
|
+
return base
|