evalstand 0.0.0.dev0__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.
Files changed (45) hide show
  1. evalstand-0.0.0.dev0/.gitignore +28 -0
  2. evalstand-0.0.0.dev0/CHANGELOG.md +11 -0
  3. evalstand-0.0.0.dev0/LICENSE +21 -0
  4. evalstand-0.0.0.dev0/PKG-INFO +127 -0
  5. evalstand-0.0.0.dev0/PLAN.md +432 -0
  6. evalstand-0.0.0.dev0/README.md +77 -0
  7. evalstand-0.0.0.dev0/docs/adr/0000-adr-process.md +46 -0
  8. evalstand-0.0.0.dev0/docs/adr/0001-name.md +57 -0
  9. evalstand-0.0.0.dev0/docs/adr/0002-design-scope.md +36 -0
  10. evalstand-0.0.0.dev0/docs/ci.md +3 -0
  11. evalstand-0.0.0.dev0/docs/index.md +3 -0
  12. evalstand-0.0.0.dev0/docs/quickstart.md +3 -0
  13. evalstand-0.0.0.dev0/docs/scorers.md +3 -0
  14. evalstand-0.0.0.dev0/docs/traces.md +3 -0
  15. evalstand-0.0.0.dev0/docs/writing-evals.md +3 -0
  16. evalstand-0.0.0.dev0/pyproject.toml +90 -0
  17. evalstand-0.0.0.dev0/src/evalstand/__init__.py +12 -0
  18. evalstand-0.0.0.dev0/src/evalstand/api.py +1 -0
  19. evalstand-0.0.0.dev0/src/evalstand/cache.py +1 -0
  20. evalstand-0.0.0.dev0/src/evalstand/cli.py +1 -0
  21. evalstand-0.0.0.dev0/src/evalstand/config.py +1 -0
  22. evalstand-0.0.0.dev0/src/evalstand/llm.py +1 -0
  23. evalstand-0.0.0.dev0/src/evalstand/migrations/__init__.py +1 -0
  24. evalstand-0.0.0.dev0/src/evalstand/models.py +1 -0
  25. evalstand-0.0.0.dev0/src/evalstand/plugin.py +1 -0
  26. evalstand-0.0.0.dev0/src/evalstand/reporting/__init__.py +0 -0
  27. evalstand-0.0.0.dev0/src/evalstand/reporting/console.py +1 -0
  28. evalstand-0.0.0.dev0/src/evalstand/reporting/markdown.py +1 -0
  29. evalstand-0.0.0.dev0/src/evalstand/runner.py +1 -0
  30. evalstand-0.0.0.dev0/src/evalstand/scorers/__init__.py +0 -0
  31. evalstand-0.0.0.dev0/src/evalstand/scorers/base.py +1 -0
  32. evalstand-0.0.0.dev0/src/evalstand/scorers/fuzzy.py +1 -0
  33. evalstand-0.0.0.dev0/src/evalstand/scorers/json_field.py +1 -0
  34. evalstand-0.0.0.dev0/src/evalstand/scorers/llm.py +1 -0
  35. evalstand-0.0.0.dev0/src/evalstand/scorers/numeric.py +1 -0
  36. evalstand-0.0.0.dev0/src/evalstand/scorers/string.py +1 -0
  37. evalstand-0.0.0.dev0/src/evalstand/storage.py +1 -0
  38. evalstand-0.0.0.dev0/src/evalstand/tracing.py +1 -0
  39. evalstand-0.0.0.dev0/src/evalstand/tui/__init__.py +0 -0
  40. evalstand-0.0.0.dev0/src/evalstand/tui/app.py +1 -0
  41. evalstand-0.0.0.dev0/src/evalstand/tui/widgets/__init__.py +0 -0
  42. evalstand-0.0.0.dev0/tests/__init__.py +0 -0
  43. evalstand-0.0.0.dev0/tests/integration/__init__.py +0 -0
  44. evalstand-0.0.0.dev0/tests/unit/__init__.py +0 -0
  45. evalstand-0.0.0.dev0/tests/unit/test_package.py +7 -0
@@ -0,0 +1,28 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .Python
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ htmlcov/
15
+ coverage.xml
16
+
17
+ *.db
18
+ *.sqlite
19
+ *.sqlite3
20
+ .evalstand/
21
+
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+ - Phase 0: repository scaffold, tooling configuration, CI workflow, ADR process.
11
+ - Name chosen: `evalstand` (ADR 0001).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 evalstand contributors
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,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: evalstand
3
+ Version: 0.0.0.dev0
4
+ Summary: A local-first LLM evaluation tool for Python: write evals, run them like tests, watch results stream in.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 evalstand contributors
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Keywords: ai,eval,evaluation,llm,pytest,testing
28
+ Classifier: Development Status :: 2 - Pre-Alpha
29
+ Classifier: Intended Audience :: Developers
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Topic :: Software Development :: Testing
35
+ Requires-Python: >=3.11
36
+ Requires-Dist: litellm>=1.55
37
+ Requires-Dist: pydantic>=2.10
38
+ Requires-Dist: pytest>=8.3
39
+ Requires-Dist: rapidfuzz>=3.11
40
+ Requires-Dist: rich>=13.9
41
+ Requires-Dist: tenacity>=9.0
42
+ Requires-Dist: textual>=1.0
43
+ Requires-Dist: typer>=0.15
44
+ Requires-Dist: watchfiles>=1.0
45
+ Provides-Extra: examples
46
+ Requires-Dist: faker>=33.1; extra == 'examples'
47
+ Requires-Dist: pypdfium2>=4.30; extra == 'examples'
48
+ Requires-Dist: reportlab>=4.2; extra == 'examples'
49
+ Description-Content-Type: text/markdown
50
+
51
+ # evalstand
52
+
53
+ > **Status: in development.** Phase 0 of 7. This release is a **name
54
+ > reservation placeholder** and contains no working code. The example below
55
+ > shows the intended API, which is not implemented yet.
56
+
57
+ Evaluating an LLM application should feel like running a test suite.
58
+
59
+ `evalstand` is a local-first LLM evaluation tool for Python. You write an eval
60
+ file, run a watch command, and results stream into a live terminal UI — scores,
61
+ nested call traces, token counts, latency, and cost. Everything runs on your
62
+ machine and persists to a local SQLite database, so you can compare a run
63
+ against the one before it.
64
+
65
+ ```python
66
+ from evalstand import Case, evaluate
67
+ from evalstand.scorers import exact, levenshtein
68
+
69
+
70
+ def load_cases() -> list[Case]:
71
+ return [
72
+ Case(id="q1", input="What is the capital of France?", expected="Paris"),
73
+ Case(id="q2", input="What is 2 + 2?", expected="4"),
74
+ ]
75
+
76
+
77
+ async def answer(question: str) -> str:
78
+ resp = await llm.acall("gpt-4o-mini", [{"role": "user", "content": question}])
79
+ return resp.text
80
+
81
+
82
+ evaluate(
83
+ name="basic-qa",
84
+ cases=load_cases,
85
+ task=answer,
86
+ scorers=[exact, levenshtein],
87
+ )
88
+ ```
89
+
90
+ Save that as `qa_eval.py` and run it either way:
91
+
92
+ ```bash
93
+ evalstand run qa_eval.py # live TUI, watch mode, traces
94
+ pytest qa_eval.py # plain test runner, CI-friendly
95
+ ```
96
+
97
+ ## Why
98
+
99
+ Existing Python options are either heavyweight platforms that push you toward a
100
+ hosted service, or bare metric libraries with no runner, no persistence, and no
101
+ live feedback loop. `evalstand` is the middle: a real runner with a real UI that
102
+ stays on your machine.
103
+
104
+ ## Planned capabilities
105
+
106
+ See [PLAN.md](PLAN.md) for the full build plan and the capability checklist that
107
+ defines v1.
108
+
109
+ ## Limitations
110
+
111
+ Stated up front, and kept accurate as the project grows:
112
+
113
+ - Score differences between runs are reported as plain deltas. There is **no
114
+ statistical significance testing** in v1, so a delta is not evidence of a real
115
+ regression or improvement.
116
+ - LLM-as-judge scorers are **unvalidated** — they have not been calibrated
117
+ against human labels.
118
+
119
+ ## Licence
120
+
121
+ MIT. See [LICENSE](LICENSE).
122
+
123
+ ---
124
+
125
+ <sub>Inspired by [evalite](https://github.com/mattpocock/evalite) (MIT), which
126
+ showed that local LLM evals could feel like running tests. `evalstand` is an
127
+ independent Python implementation.</sub>
@@ -0,0 +1,432 @@
1
+ # Build Plan — `evalstand`
2
+
3
+ > **Name:** `evalstand` — verified free on PyPI (404) and GitHub (0 name matches) on 2026-09-01.
4
+ > **Audience:** an autonomous coding agent (Claude Code) executing this plan without access to the conversation that produced it.
5
+ > **Owner profile:** mid-level software engineer, 3–5 years production experience, strong in Python, Docker, and CI; new to AI engineering.
6
+ > **Budget:** ~15 hours per week. Total: 6–7 weeks for feature parity (Phases 0–7).
7
+ > **Scope:** this project is entirely self-contained. It has no dependencies on, and makes no references to, any other project. Everything needed is in this document.
8
+
9
+ ---
10
+
11
+ ## 1. What we are building and why
12
+
13
+ ### The goal
14
+
15
+ `evalstand` — a local-first LLM evaluation tool for Python.
16
+
17
+ The core idea: evaluating an LLM application should feel exactly like running a test suite: you write an eval file, run a watch command, and a live UI updates as results stream in, showing scores, traces, token counts, and cost. Nothing in the Python ecosystem offers that experience. The closest Python tools are either heavyweight platforms that push you toward a hosted service, or bare metric libraries with no runner, no persistence, and no live feedback loop.
18
+
19
+ The design is informed by `evalite` (TypeScript, MIT), which solved this problem well. `evalstand` is an independent Python implementation, not a transliteration: the capability set in Section 2 is the target, and every design decision is made for Python idiom on its own merits. Where the reference design does not carry into Python, we adopt the Python equivalent and record why in an ADR.
20
+
21
+ ### Explicit non-goals for this version
22
+
23
+ These are deliberately out of scope. They are listed so the agent does not drift into them, and revisited in Section 8.
24
+
25
+ - Statistical significance testing of run-over-run differences.
26
+ - Validation or calibration of LLM-as-judge scorers.
27
+ - Any hosted service, account system, or cloud sync.
28
+ - Distributed or multi-machine execution.
29
+ - Dataset generation or curation tooling.
30
+
31
+ ### Interface choice: TUI first
32
+
33
+ `evalstand` ships a **Textual TUI first**, with a web UI as an optional stretch phase. Rationale: the TUI reaches parity of *function* much faster, keeps the entire stack in Python, and demos well in a terminal recording. Record this in an ADR.
34
+
35
+ ---
36
+
37
+ ## 2. Feature parity checklist
38
+
39
+ This is the definition of "done" for v1. Every row must be satisfied or consciously waived with an ADR.
40
+
41
+ | # | Capability | `evalstand` implementation | Phase |
42
+ | --- | --- | --- | --- |
43
+ | 1 | Eval files auto-collected by the test runner | `*_eval.py` collected by a pytest plugin | 2 |
44
+ | 2 | One entry function for cases, task, scorers | `evaluate(cases=, task=, scorers=)` | 2 |
45
+ | 3 | Case loader returning input/expected pairs | Sync or async callable returning `Case` objects | 2 |
46
+ | 4 | Task receives input, returns output | Sync or async, auto-detected | 2 |
47
+ | 5 | Built-in scorer library | Native scorer set, plus an adapter interface | 4 |
48
+ | 6 | Custom scorers via a simple function signature | `@scorer` decorator | 4 |
49
+ | 7 | Traces — nested LLM calls captured inside a task | `trace()` context manager + auto-capture | 3 |
50
+ | 8 | Token and cost reporting per call and per run | Via LiteLLM cost lookup | 1, 3 |
51
+ | 9 | Model response caching | SQLite-backed cache | 1 |
52
+ | 10 | Run each case N times | `--repeat N` / `repeat=` argument | 3 |
53
+ | 11 | SQLite result storage | stdlib `sqlite3` with migrations | 5 |
54
+ | 12 | Score history across runs | `history` command and TUI history view | 5 |
55
+ | 13 | Live-updating UI during a run | Textual TUI | 6 |
56
+ | 14 | Watch mode with file-change re-runs | `watchfiles` | 6 |
57
+ | 15 | Case detail view — input, output, expected, trace | TUI detail pane | 6 |
58
+ | 16 | Custom result table columns | `columns=` argument on `evaluate()` | 6 |
59
+ | 17 | `--threshold` for CI pass/fail | `--threshold` with documented exit codes | 7 |
60
+ | 18 | Streaming task output | LiteLLM streaming, rendered live | 3 |
61
+ | 19 | Runs under the plain test runner too | Works under bare `pytest` | 2 |
62
+
63
+ ---
64
+
65
+ ## 3. Architecture
66
+
67
+ ```
68
+ evalstand/
69
+ ├── pyproject.toml
70
+ ├── README.md
71
+ ├── LICENSE # MIT
72
+ ├── CHANGELOG.md
73
+ ├── .github/workflows/
74
+ │ ├── ci.yml # lint, type-check, test — no API keys
75
+ │ └── release.yml # PyPI publish on tag
76
+ ├── docs/
77
+ │ ├── index.md
78
+ │ ├── quickstart.md
79
+ │ ├── writing-evals.md
80
+ │ ├── scorers.md
81
+ │ ├── traces.md
82
+ │ ├── ci.md
83
+ │ └── adr/
84
+ ├── src/evalstand/
85
+ │ ├── __init__.py # public API, kept under 10 exported names
86
+ │ ├── api.py # evaluate(), Case, Score, @scorer
87
+ │ ├── models.py # Case, Score, Trace, Result, Run
88
+ │ ├── plugin.py # pytest collection + reporting hooks
89
+ │ ├── runner.py # async execution, concurrency, repeats
90
+ │ ├── tracing.py # trace context manager + LiteLLM callback
91
+ │ ├── llm.py # LiteLLM wrapper: call, tokens, cost, retry
92
+ │ ├── cache.py # SQLite response cache, record/replay
93
+ │ ├── storage.py # SQLite run store + migrations
94
+ │ ├── config.py # settings resolution
95
+ │ ├── scorers/
96
+ │ │ ├── base.py # Scorer protocol, @scorer decorator
97
+ │ │ ├── string.py # exact, normalised, contains, regex
98
+ │ │ ├── fuzzy.py # levenshtein, ratio (rapidfuzz)
99
+ │ │ ├── numeric.py # tolerance-based
100
+ │ │ ├── json_field.py # per-field structured comparison
101
+ │ │ └── llm.py # judge-style scorers
102
+ │ ├── reporting/
103
+ │ │ ├── console.py # rich summary tables
104
+ │ │ └── markdown.py # CI summary body
105
+ │ ├── tui/
106
+ │ │ ├── app.py
107
+ │ │ └── widgets/
108
+ │ └── cli.py # typer entrypoint
109
+ ├── examples/
110
+ │ ├── toy/ # 3 cases, built in Phase 2, used throughout
111
+ │ └── pdf_extraction/ # showcase example, Phase 5
112
+ └── tests/
113
+ ├── cassettes/ # recorded responses, committed
114
+ ├── unit/
115
+ └── integration/
116
+ ```
117
+
118
+ ### Core abstractions
119
+
120
+ Four concepts. Resist adding a fifth.
121
+
122
+ - **Case** — one test input: `id`, `input`, `expected` (optional), `metadata`.
123
+ - **Task** — the user's function under test. Receives `Case.input`, returns any output. The LLM call happens here.
124
+ - **Scorer** — a callable `(output, expected, case) -> Score`. `Score` carries `value: float` in `[0, 1]`, optional `passed: bool`, and `metadata: dict`.
125
+ - **Run** — one execution of one eval. Persisted, immutable, comparable to previous runs.
126
+
127
+ **Trace** is a supporting concept, not a fifth abstraction: a record of one LLM call made inside a task, forming a tree when calls nest.
128
+
129
+ ### Target API shape
130
+
131
+ ```python
132
+ from evalstand import evaluate, Case, scorer
133
+ from evalstand.scorers import levenshtein, exact
134
+
135
+
136
+ def load_cases() -> list[Case]:
137
+ return [
138
+ Case(id="q1", input="What is the capital of France?", expected="Paris"),
139
+ Case(id="q2", input="What is 2 + 2?", expected="4"),
140
+ ]
141
+
142
+
143
+ async def answer(question: str) -> str:
144
+ resp = await llm.acall("gpt-4o-mini", [{"role": "user", "content": question}])
145
+ return resp.text
146
+
147
+
148
+ evaluate(
149
+ name="basic-qa",
150
+ cases=load_cases,
151
+ task=answer,
152
+ scorers=[exact, levenshtein],
153
+ )
154
+ ```
155
+
156
+ Saved as `qa_eval.py`, this runs under both `evalstand run` and bare `pytest`.
157
+
158
+ ---
159
+
160
+ ## 4. Approved dependencies
161
+
162
+ | Purpose | Package |
163
+ | --- | --- |
164
+ | Packaging | `uv` |
165
+ | Test runner base | `pytest` |
166
+ | LLM provider layer | `litellm` |
167
+ | CLI | `typer` |
168
+ | Console rendering | `rich` |
169
+ | TUI | `textual` |
170
+ | File watching | `watchfiles` |
171
+ | Data models | `pydantic` v2 |
172
+ | Fuzzy matching | `rapidfuzz` |
173
+ | Retry | `tenacity` |
174
+ | Docs | `mkdocs-material` |
175
+ | Dev | `ruff`, `mypy`, `pytest-cov`, `pre-commit` |
176
+ | Example only | `reportlab`, `faker`, `pypdfium2` (behind an optional extra) |
177
+
178
+ Storage is stdlib `sqlite3`. **Do not add an ORM.** Do not add a dependency outside this table without an ADR.
179
+
180
+ ---
181
+
182
+ ## 5. Hard guardrails
183
+
184
+ The agent must not violate these.
185
+
186
+ 1. **CI runs with zero API keys and zero API spend.** All tests use recorded cassettes or mocks. Live tests are marked `@pytest.mark.live` and excluded by default.
187
+ 2. **Never commit secrets.** Keys come from environment variables only. A secret-scanning pre-commit hook is installed in Phase 0.
188
+ 3. **Expected outputs are written by humans or produced programmatically — never generated by an LLM.** A golden answer produced by the system under test is not ground truth.
189
+ 4. **No dependency outside Section 4 without an ADR.**
190
+ 5. **A phase is not done until `ruff check`, `ruff format --check`, `mypy`, and `pytest` all pass locally and in CI.**
191
+ 6. **Every number in the README must be reproducible by a command in the repo.**
192
+ 7. **Write an ADR for every non-obvious decision** in `docs/adr/NNNN-title.md`: context, decision, consequences. Keep them short.
193
+
194
+ ---
195
+
196
+ ## Phase 0 — Foundations
197
+
198
+ **Goal:** a named, licensed, linted, CI-green empty package.
199
+ **Estimate:** 6 hours.
200
+
201
+ - [x] **0.1 Public name: `evalstand`.** Verified free on PyPI (`https://pypi.org/pypi/evalstand/json` -> 404) and GitHub (0 repositories matching the name) on 2026-09-01. Rejected because already taken on PyPI: `evalrig` (an AI-eval package), `proofmark` (an LLM-output testing package, uploaded 2026-08-17), `assay`, `evalbench`, `plumbline`, `rigor`, `tessera`, `proofbench`, `evalforge`, `evalloop`, `rubricon`.
202
+ - *Acceptance:* recorded in `docs/adr/0001-name.md`, applied consistently across `pyproject.toml`, `src/`, and docs.
203
+ - *Naming rule (binding):* the string `evalite` must not appear in any package name, module name, directory name, filename, class name, function name, or CLI command. A single attribution footnote at the bottom of the README is the only permitted occurrence in the shipped repo.
204
+ - [ ] **0.2 Initialise the repo:** `uv init --lib`, Python 3.11+, `src/` layout, MIT `LICENSE`, `.gitignore`.
205
+ - *Acceptance:* `uv sync` succeeds; `uv run python -c "import evalstand"` succeeds.
206
+ - [ ] **0.3 Configure tooling:** `ruff` (lint + format), `mypy` strict on `src/`, `pytest` with `--cov`, `pre-commit` with ruff plus a secret-scanning hook.
207
+ - *Acceptance:* `pre-commit run --all-files` passes on a clean tree.
208
+ - [ ] **0.4 Write `.github/workflows/ci.yml`.** Matrix over Python 3.11, 3.12, 3.13. Steps: checkout, install uv, sync, ruff check, ruff format --check, mypy, pytest.
209
+ - *Acceptance:* CI green on first push; the workflow file contains no `secrets.` reference.
210
+ - [ ] **0.5 Create `docs/adr/0000-adr-process.md`** and a template.
211
+ - [ ] **0.6 Study the reference implementation** before writing any code — `evalite` (TypeScript, MIT), specifically its entry function, CLI, SQLite layer, and web UI. Write `docs/adr/0002-design-scope.md` recording which behaviours `evalstand` adopts, which it does differently, and why (TUI instead of React, pytest instead of Vitest).
212
+ - *Acceptance:* the ADR names specific behaviours and specific decisions, not generalities. Read it for design understanding only — do not copy code. This is an independent implementation.
213
+ - [ ] **0.7 Placeholder README** with the problem statement, a "status: in development" banner, and the one-line attribution footnote. No marketing claims yet.
214
+
215
+ **Exit criteria:** CI green, pre-commit passes, name applied everywhere, ADRs 0001 and 0002 written.
216
+
217
+ ---
218
+
219
+ ## Phase 1 — LLM layer, caching, and cost tracking
220
+
221
+ **Goal:** a single model call works, is cached, and reports tokens, latency, and cost.
222
+ **Estimate:** 12 hours.
223
+
224
+ - [ ] **1.1 Define `models.py`.** `Case`, `Score`, `Trace`, `Result`, `Run` as Pydantic models or frozen dataclasses. `Score.value` is a float in `[0, 1]`. `Score.passed` is optional and derived from a threshold when absent. Every model is JSON-serialisable.
225
+ - *Acceptance:* round-trip serialisation tests pass for each model.
226
+ - [ ] **1.2 Build `llm.py` over LiteLLM.** `call(model, messages, **params) -> LLMResponse` and async `acall`, returning text, input and output token counts, latency in ms, `cost_usd` from `litellm.completion_cost()`, and the raw provider response.
227
+ - *Acceptance:* unit tests with LiteLLM mocked verify token and cost extraction. One `@pytest.mark.live` test hits a real cheap model and is excluded from CI.
228
+ - [ ] **1.3 Add streaming support.** `acall_stream` yields chunks and accumulates the final response with correct token and cost totals.
229
+ - *Acceptance:* a streamed call and a non-streamed call to the same prompt produce identical accumulated text and equivalent cost.
230
+ - [ ] **1.4 Add retry policy** with `tenacity`: retry on 429 and 5xx with exponential backoff and jitter, max 3 attempts. Never retry 4xx auth or content-policy errors. Log every retry.
231
+ - *Acceptance:* tests cover 429-then-success and 401-immediate-fail.
232
+ - [ ] **1.5 Build `cache.py`.** SQLite response cache keyed on the SHA-256 of canonical JSON of `(model, messages, temperature, top_p, max_tokens, seed, tools, response_format)`. Store the response, `created_at`, and `hit_count`. Support `--no-cache` and `--refresh-cache`.
233
+ - *Acceptance:* the same call twice yields one provider call and one cache hit; changing temperature yields a miss.
234
+ - [ ] **1.6 Add record/replay for tests.** A cassette mode writing responses to JSON on record and reading on replay.
235
+ - *Acceptance:* the full test suite passes with every provider API key unset.
236
+ - [ ] **1.7 ADR 0003:** why LiteLLM rather than provider SDKs.
237
+
238
+ **Exit criteria:** a throwaway script makes a cached model call and prints text, tokens, latency, and cost. CI green without keys.
239
+
240
+ ---
241
+
242
+ ## Phase 2 — The `evaluate()` API and pytest collection
243
+
244
+ **Goal:** an eval file is discovered and executed with a first-class authoring experience.
245
+ **Estimate:** 14 hours.
246
+
247
+ - [ ] **2.1 Design the public API** in `api.py` to the shape in Section 3. Keep the exported surface under 10 names. `cases` accepts a list, a callable returning a list, or an async callable.
248
+ - *Acceptance:* `docs/writing-evals.md` contains a complete working example under 25 lines.
249
+ - [ ] **2.2 Implement the pytest plugin** in `plugin.py`. Use `pytest_collect_file` to collect `*_eval.py`, generate one pytest item per case, and use `pytest_runtest_makereport` to capture outcomes. Register through the `pytest11` entry point.
250
+ - *Acceptance:* `pytest examples/toy` discovers and runs the eval; `pytest -k q1` selects a single case.
251
+ - [ ] **2.3 Support both sync and async tasks.** Detect with `inspect.iscoroutinefunction` and dispatch accordingly. The user should never have to think about it.
252
+ - *Acceptance:* two identical evals, one sync and one async, produce identical results.
253
+ - [ ] **2.4 Ensure bare `pytest` works.** Running `pytest` with no custom CLI must collect and execute evals and report pass/fail sensibly.
254
+ - *Acceptance:* documented in `docs/ci.md` with a working example.
255
+ - [ ] **2.5 Build `examples/toy/`** — three cases, one scorer, no external files. Every subsequent phase develops against this.
256
+ - [ ] **2.6 Console reporting** in `reporting/console.py` using rich: a summary table (per-scorer mean, pass count, total cost, wall time) and a failures table.
257
+ - *Acceptance:* the toy example prints a readable summary within one screen.
258
+
259
+ **Exit criteria:** `evalstand run examples/toy` and `pytest examples/toy` both work end to end.
260
+
261
+ ---
262
+
263
+ ## Phase 3 — Runner, traces, repeats, and streaming
264
+
265
+ **Goal:** concurrent execution with the nested-call visibility that makes the UI useful.
266
+ **Estimate:** 14 hours.
267
+
268
+ - [ ] **3.1 Build `runner.py`.** Async execution with an `asyncio.Semaphore` (default concurrency 8, `--concurrency` flag). Deterministic ordered result collection. Per-case timeout. Errors are captured on the result rather than aborting the run.
269
+ - *Acceptance:* a suite where one case raises still completes and records the error; changing concurrency measurably changes wall time.
270
+ - [ ] **3.2 Implement tracing** in `tracing.py`. This is the feature most worth porting carefully. Two mechanisms:
271
+ - A `trace(name)` context manager the user can wrap around any operation.
272
+ - **Automatic capture** of every call made through `llm.py` during a task, using a `contextvars.ContextVar` to associate calls with the currently executing case.
273
+ - Traces nest into a tree. Each node records name, start, duration, input, output, model, tokens, and cost.
274
+ - *Acceptance:* a task making three nested LLM calls produces a three-node trace tree with correct parent-child relationships and per-node cost, and the sum of node costs equals the case total.
275
+ - [ ] **3.3 Implement `--repeat N`** (the reference implementation calls this `trialCount`). Each case runs N times with `repeat_index` recorded on every result. **Bypass the cache across repeats when temperature > 0**, or repeats are meaningless — make this explicit and test it.
276
+ - *Acceptance:* `--repeat 5` on a temperature-0.7 task produces at least one case with 5 distinct outputs.
277
+ - [ ] **3.4 Wire streaming through the runner** so partial output is available to the reporting layer as it arrives.
278
+ - *Acceptance:* a streaming task shows incremental output in console reporting.
279
+ - [ ] **3.5 Aggregate per-run totals:** total cost, total tokens, cache hit rate, wall time, pass count.
280
+
281
+ **Exit criteria:** the toy example runs concurrently with repeats, and a trace tree is captured and printable.
282
+
283
+ ---
284
+
285
+ ## Phase 4 — Scorers
286
+
287
+ **Goal:** enough built-in scorers that a user never writes one on day one, plus a clean path when they do.
288
+ **Estimate:** 10 hours.
289
+
290
+ - [ ] **4.1 Define the `Scorer` protocol** in `scorers/base.py` and a `@scorer` decorator that adapts a plain function. Support sync and async scorers.
291
+ - *Acceptance:* a user-defined 3-line scorer works without importing any base class.
292
+ - [ ] **4.2 String scorers** in `string.py`: `exact`, `normalised_exact` (case, whitespace, and punctuation folding), `contains`, `regex_match`.
293
+ - [ ] **4.3 Fuzzy scorers** in `fuzzy.py` using `rapidfuzz`: `levenshtein` (normalised to `[0, 1]`; this is `evalstand`'s default scorer) and `ratio`.
294
+ - *Acceptance:* `levenshtein("kitten", "sitting")` returns the documented normalised value.
295
+ - [ ] **4.4 Numeric scorer** in `numeric.py`: absolute and relative tolerance, with sensible handling of `None` and unparseable output.
296
+ - [ ] **4.5 Structured scorer** in `json_field.py`: compare two dicts field by field, returning both a macro-average and a per-field breakdown in `Score.metadata`.
297
+ - *Acceptance:* a partial match on 3 of 5 fields returns 0.6 with the failing field names in metadata.
298
+ - [ ] **4.6 LLM scorers** in `llm.py`: a `judge` factory taking a rubric and returning a scorer, plus a `factuality`-style scorer comparing output against expected. Judge calls must go through `llm.py` so they are cached, traced, and costed like any other call.
299
+ - *Acceptance:* a judge scorer's LLM call appears in the case's trace tree.
300
+ - *Note:* these scorers are unvalidated by design in this version. `docs/scorers.md` must say so plainly.
301
+ - [ ] **4.7 Every scorer gets unit tests** covering the happy path, empty output, and `None` expected.
302
+ - [ ] **4.8 Write `docs/scorers.md`** documenting each built-in scorer and how to write a custom one.
303
+
304
+ **Exit criteria:** eight or more built-in scorers, all tested and documented.
305
+
306
+ ---
307
+
308
+ ## Phase 5 — Storage, history, and the showcase example
309
+
310
+ **Goal:** runs persist and can be compared; there is a realistic example to demo.
311
+ **Estimate:** 14 hours.
312
+
313
+ - [ ] **5.1 Design the SQLite schema** in `storage.py` with a `schema_version` table and sequential migrations under `src/evalstand/migrations/`:
314
+
315
+ ```sql
316
+ runs(id, name, git_sha, git_dirty, started_at, finished_at,
317
+ model_config_json, repeat_n, total_cases, total_cost_usd, status)
318
+ results(id, run_id, case_id, repeat_index, output_text, output_json,
319
+ latency_ms, input_tokens, output_tokens, cost_usd, error)
320
+ scores(id, result_id, scorer_name, value_float, passed, metadata_json)
321
+ traces(id, result_id, parent_id, name, started_at, duration_ms,
322
+ input_json, output_json, model, tokens_json, cost_usd)
323
+ case_snapshots(run_id, case_id, input_json, expected_json, metadata_json)
324
+ cache(key, model, response_json, created_at, hit_count)
325
+ ```
326
+ Snapshot cases per run so history stays valid when the dataset changes later.
327
+ - *Acceptance:* the migration applies to an empty database and is idempotent; a second run does not corrupt the first.
328
+ - [ ] **5.2 Record provenance** on every run: git SHA, dirty-tree flag, model config, and a hash of the task source. Refuse to persist without a SHA unless `--allow-dirty` is passed.
329
+ - [ ] **5.3 Build `evalstand history [name]`** listing runs with name, SHA, date, mean score, pass count, and cost.
330
+ - [ ] **5.4 Build `evalstand show <run_id>`** rendering a full run: summary, per-case scores, and trace trees.
331
+ - [ ] **5.5 Build `evalstand compare <run_a> <run_b>`.** Report per-scorer means for both runs, the delta, and the list of cases whose pass state flipped, with old and new output side by side.
332
+ - **Important:** report the delta as a plain difference. Do **not** label it a regression or an improvement — this version has no significance testing, and asserting a verdict without one would be a false claim. Say "changed" and show the flipped cases. `docs/ci.md` must state this limitation explicitly.
333
+ - *Acceptance:* comparing two runs shows the delta and flipped cases with no verdict language.
334
+ - [ ] **5.6 Build the showcase example** at `examples/pdf_extraction/`: extract `invoice_number`, `vendor_name`, `invoice_date`, `total`, and `line_items[]` from documents.
335
+ - Generate 30 synthetic invoices with `reportlab` + `faker` at a fixed seed. Ground truth is written at generation time and is therefore true by construction — programmatic, not LLM-generated.
336
+ - Vary deliberately: multi-page documents, two currencies, a missing due date, an ambiguous date format.
337
+ - Use `json_field` and `numeric_tolerance` scorers plus one judge scorer for line-item completeness.
338
+ - *Acceptance:* `python generate.py --seed 42` reproduces byte-identical PDFs and golden JSON; the eval runs end to end from a clean clone with one API key set.
339
+ - [ ] **5.7 Commit baseline results** as `examples/pdf_extraction/BASELINE.md` with per-field accuracy, cost per document, and observed failure modes.
340
+
341
+ **Exit criteria:** runs persist, history and comparison work, the showcase example runs from a clean clone.
342
+
343
+ ---
344
+
345
+ ## Phase 6 — TUI and watch mode
346
+
347
+ **Goal:** the live feedback loop that is the whole point of the tool.
348
+ **Estimate:** 16 hours.
349
+
350
+ - [ ] **6.1 Run view** in `tui/app.py`: header with eval name and model, progress bar, a streaming table of cases (id, status, score, latency, cost) updating as results land, and a footer with running totals.
351
+ - *Acceptance:* rows appear incrementally, not in one batch at the end.
352
+ - [ ] **6.2 Summary panel:** per-scorer mean, pass count, total cost, wall time, cache hit rate.
353
+ - [ ] **6.3 Case detail view:** press `enter` on a row for input, full output, expected, per-scorer breakdown with metadata, and the **trace tree** with per-node model, duration, tokens, and cost.
354
+ - *Acceptance:* a task with nested LLM calls renders an expandable, navigable trace tree.
355
+ - [ ] **6.4 History view:** browse past runs, select one to open, select two to render the Phase 5 comparison.
356
+ - [ ] **6.5 Custom columns.** Support a `columns=` argument on `evaluate()` letting the user add derived columns to the results table.
357
+ - *Acceptance:* the showcase example adds a "fields correct" column.
358
+ - [ ] **6.6 Watch mode** with `watchfiles`: re-run affected evals when an eval file, task file, or prompt file changes. Debounce 300ms. Preserve scroll position and show a "changed: <file>" indicator.
359
+ - *Acceptance:* editing a prompt triggers a re-run within one second without restarting the process.
360
+ - [ ] **6.7 Keybindings:** `q` quit, `r` re-run, `f` filter to failures, `c` compare with previous run, `/` search, `y` copy case id.
361
+ - [ ] **6.8 Record a demo GIF** with `vhs` or `asciinema` + `agg`, embedded at the top of the README.
362
+ - *Acceptance:* under 5 MB, showing a full run, a trace tree, and watch-mode re-run in under 30 seconds.
363
+
364
+ **Exit criteria:** the TUI satisfies every UI row in the Section 2 table. The README opens with a GIF that makes the value obvious in five seconds.
365
+
366
+ ---
367
+
368
+ ## Phase 7 — CI integration, docs, and release
369
+
370
+ **Goal:** installable, usable in a pipeline, and understandable.
371
+ **Estimate:** 12 hours.
372
+
373
+ - [ ] **7.1 CI flags:** `--threshold <float>` (fail when the mean score falls below it) and `--fail-on-error`. Documented exit codes: `0` pass, `1` below threshold, `2` execution error.
374
+ - *Acceptance:* an exit-code table in the docs, each code reproducible in a test.
375
+ - [ ] **7.2 Markdown summary output** in `reporting/markdown.py` — `--output markdown` produces a body suitable for a PR comment: summary table, failed cases, cost.
376
+ - [ ] **7.3 Document the GitHub Actions recipe** in `docs/ci.md`: a workflow that runs evals on pull requests, posts the markdown summary as a comment, and gates on the threshold. Ship it as a copyable YAML block rather than a published Action in this version.
377
+ - [ ] **7.4 Docs site** with mkdocs-material: quickstart, writing evals, scorers, traces, CI, architecture, ADR index. Deploy to GitHub Pages.
378
+ - [ ] **7.5 Rewrite the README:** one-sentence problem statement, demo GIF, 60-second quickstart, feature list mapped to the Section 2 parity table, reproducible numbers from the showcase example, an honest **Limitations** section, the attribution footnote, licence.
379
+ - The Limitations section is required. State plainly that score deltas are reported without significance testing and that LLM judge scorers are unvalidated in this version.
380
+ - [ ] **7.6 Publish to PyPI** via a tagged `release.yml` using trusted publishing. Tag `v1.0.0`.
381
+ - [ ] **7.7 Record a 3-minute demo video:** write an eval, run it in watch mode, edit the prompt, watch the re-run, open a trace tree, break something and see the threshold gate fail in CI.
382
+
383
+ **Exit criteria:** installable from PyPI, docs live, video recorded, parity table fully satisfied.
384
+
385
+ ---
386
+
387
+ ## Phase 8 — Optional stretch: web UI
388
+
389
+ Start only when Phases 0–7 are complete and polished. A shipped TUI beats a half-finished web app.
390
+
391
+ - [ ] **8.1** FastAPI backend exposing runs, results, scores, and traces as JSON.
392
+ - [ ] **8.2** HTMX plus server-sent events front end for the live run table, keeping the stack entirely Python.
393
+ - [ ] **8.3** Static HTML report export for CI artifacts. Worth doing even without the full web UI.
394
+ - [ ] **8.4** `evalstand serve` command.
395
+
396
+ ---
397
+
398
+ ## 8. Parked for a future version
399
+
400
+ Recorded here so the ideas are not lost, and so the agent does not build them now. **Do not start any of these until Phase 7 is complete and released.**
401
+
402
+ - **Statistical significance testing.** Bootstrap confidence intervals on aggregate scores and McNemar's exact test for paired run-over-run comparisons, so `compare` can distinguish a real change from sampling noise instead of reporting a bare delta. This is the single most valuable extension and the natural v2.
403
+ - **Judge calibration.** Measuring an LLM judge against human labels — Cohen's kappa, true and false positive rates reported separately, position bias, verbosity bias, self-preference bias — and refusing to run an uncalibrated judge.
404
+ - **Dataset hygiene.** Near-duplicate detection within an eval set and overlap detection against few-shot examples.
405
+ - **A published GitHub Action** rather than a copyable workflow.
406
+
407
+ ---
408
+
409
+ ## 9. Definition of done
410
+
411
+ A phase is complete only when all of the following hold:
412
+
413
+ 1. `ruff check`, `ruff format --check`, `mypy`, and `pytest` pass locally and in CI.
414
+ 2. Test coverage for new modules is at or above 80%.
415
+ 3. CI runs with no API keys set.
416
+ 4. Every new design decision has an ADR.
417
+ 5. Public API changes are reflected in the docs.
418
+ 6. `CHANGELOG.md` is updated.
419
+ 7. The Section 2 parity table is updated to reflect what now works.
420
+
421
+ ---
422
+
423
+ ## 10. Risk register
424
+
425
+ | Risk | Mitigation |
426
+ | --- | --- |
427
+ | API costs during development | Cache aggressively from Phase 1; develop against the cheapest available model; set a monthly budget alert. Expect $20–60 total for this project. |
428
+ | Tracing turns out to be the hard part | It is. Budget the full Phase 3 allocation for it and build it against the toy example before the showcase example exists. `contextvars` behaviour under `asyncio.gather` is the specific thing to test early. |
429
+ | pytest plugin fights the runner's async model | Prototype collection and async dispatch together in Phase 2 rather than sequentially. If the plugin proves intractable, fall back to a standalone runner and keep pytest compatibility as a stretch — but record it as an ADR, since parity item 19 would be waived. |
430
+ | Scope creep into the parked v2 features | Section 8 exists for exactly this. Anything in it goes back in the parking lot. |
431
+ | TUI consumes the whole budget | Timebox Phase 6 to 16 hours. If it overruns, ship console reporting only and move the TUI to a stretch phase. |
432
+ | The tool ends up a shallow imitation | Parity item 7 (traces) and item 10 (repeats) are the two features that are genuinely hard. If those work well, the tool is real. If they are skipped, it is a wrapper. |
@@ -0,0 +1,77 @@
1
+ # evalstand
2
+
3
+ > **Status: in development.** Phase 0 of 7. This release is a **name
4
+ > reservation placeholder** and contains no working code. The example below
5
+ > shows the intended API, which is not implemented yet.
6
+
7
+ Evaluating an LLM application should feel like running a test suite.
8
+
9
+ `evalstand` is a local-first LLM evaluation tool for Python. You write an eval
10
+ file, run a watch command, and results stream into a live terminal UI — scores,
11
+ nested call traces, token counts, latency, and cost. Everything runs on your
12
+ machine and persists to a local SQLite database, so you can compare a run
13
+ against the one before it.
14
+
15
+ ```python
16
+ from evalstand import Case, evaluate
17
+ from evalstand.scorers import exact, levenshtein
18
+
19
+
20
+ def load_cases() -> list[Case]:
21
+ return [
22
+ Case(id="q1", input="What is the capital of France?", expected="Paris"),
23
+ Case(id="q2", input="What is 2 + 2?", expected="4"),
24
+ ]
25
+
26
+
27
+ async def answer(question: str) -> str:
28
+ resp = await llm.acall("gpt-4o-mini", [{"role": "user", "content": question}])
29
+ return resp.text
30
+
31
+
32
+ evaluate(
33
+ name="basic-qa",
34
+ cases=load_cases,
35
+ task=answer,
36
+ scorers=[exact, levenshtein],
37
+ )
38
+ ```
39
+
40
+ Save that as `qa_eval.py` and run it either way:
41
+
42
+ ```bash
43
+ evalstand run qa_eval.py # live TUI, watch mode, traces
44
+ pytest qa_eval.py # plain test runner, CI-friendly
45
+ ```
46
+
47
+ ## Why
48
+
49
+ Existing Python options are either heavyweight platforms that push you toward a
50
+ hosted service, or bare metric libraries with no runner, no persistence, and no
51
+ live feedback loop. `evalstand` is the middle: a real runner with a real UI that
52
+ stays on your machine.
53
+
54
+ ## Planned capabilities
55
+
56
+ See [PLAN.md](PLAN.md) for the full build plan and the capability checklist that
57
+ defines v1.
58
+
59
+ ## Limitations
60
+
61
+ Stated up front, and kept accurate as the project grows:
62
+
63
+ - Score differences between runs are reported as plain deltas. There is **no
64
+ statistical significance testing** in v1, so a delta is not evidence of a real
65
+ regression or improvement.
66
+ - LLM-as-judge scorers are **unvalidated** — they have not been calibrated
67
+ against human labels.
68
+
69
+ ## Licence
70
+
71
+ MIT. See [LICENSE](LICENSE).
72
+
73
+ ---
74
+
75
+ <sub>Inspired by [evalite](https://github.com/mattpocock/evalite) (MIT), which
76
+ showed that local LLM evals could feel like running tests. `evalstand` is an
77
+ independent Python implementation.</sub>
@@ -0,0 +1,46 @@
1
+ # 0000 — ADR process
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-01
5
+
6
+ ## Context
7
+
8
+ This project makes design decisions that are not obvious from the code, and it
9
+ is built over several weeks. Without a record, the reasoning behind a decision
10
+ is lost and gets relitigated.
11
+
12
+ ## Decision
13
+
14
+ Every non-obvious decision gets an ADR in `docs/adr/NNNN-title.md`, numbered
15
+ sequentially. Each records **context**, **decision**, and **consequences**.
16
+ Keep them short — a screen or less. An ADR is never edited to change its
17
+ decision; it is superseded by a later ADR that references it.
18
+
19
+ An ADR is required for:
20
+
21
+ - Adding a dependency outside the approved list in `PLAN.md` Section 4.
22
+ - Waiving or changing a row in the Section 2 capability table.
23
+ - Any architectural choice a future contributor would reasonably question.
24
+
25
+ ## Consequences
26
+
27
+ Slight overhead per decision. In exchange, the reasoning survives, and the
28
+ "why is it like this?" question has an answer that is not a guess.
29
+
30
+ ## Template
31
+
32
+ ```markdown
33
+ # NNNN — Title
34
+
35
+ - **Status:** proposed | accepted | superseded by [NNNN](NNNN-title.md)
36
+ - **Date:** YYYY-MM-DD
37
+
38
+ ## Context
39
+ What forced the decision.
40
+
41
+ ## Decision
42
+ What we chose.
43
+
44
+ ## Consequences
45
+ What this makes easy, and what it costs.
46
+ ```
@@ -0,0 +1,57 @@
1
+ # 0001 — Project name: `evalstand`
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-01
5
+
6
+ ## Context
7
+
8
+ The project needed a public name before scaffolding, because the name
9
+ propagates into `pyproject.toml`, the `src/` layout, the CLI command, the
10
+ import path, and every document.
11
+
12
+ Two constraints applied. First, the name had to be genuinely free — on PyPI and
13
+ on GitHub — so the package could be published without a rename later. Second,
14
+ it had to be the project's own name. The working title `pyevalite` prefixed
15
+ another project's name, which reads as a fork and positions the work as
16
+ derivative rather than independent.
17
+
18
+ Availability was checked against the PyPI JSON API (`/pypi/<name>/json`,
19
+ where HTTP 404 means free) and the GitHub repository search API.
20
+
21
+ Candidates rejected because the name was already taken on PyPI:
22
+
23
+ | Name | Status |
24
+ | --- | --- |
25
+ | `evalrig` | taken — 0.0.1, "A test rig for AI evaluations" (2026-02-09) |
26
+ | `proofmark` | taken — 0.1.1, "Structured LLM output that has been tested" (2026-08-17) |
27
+ | `proofbench` | taken — config-driven eval harness |
28
+ | `evalforge` | taken — LLM agent evaluation harness |
29
+ | `evalloop` | taken — closed-loop eval monitoring |
30
+ | `rubricon` | taken — specification-first generation for LLMs |
31
+ | `evalbench`, `assay`, `plumbline`, `rigor`, `tessera` | taken — various |
32
+
33
+ The first six are in this project's own problem space, which makes them
34
+ unusable regardless of squatting status: users would confuse the packages.
35
+
36
+ ## Decision
37
+
38
+ The project is named **`evalstand`**.
39
+
40
+ Verified on 2026-09-01: PyPI returns 404, and GitHub repository search returns
41
+ 0 name matches.
42
+
43
+ The name refers to a *test stand* — the rig an engineer mounts an engine on to
44
+ measure it under controlled conditions. That is what this tool is for.
45
+
46
+ **Binding naming rule:** the string `evalite` must not appear in any package
47
+ name, module name, directory name, filename, class name, function name, or CLI
48
+ command. A single attribution footnote at the bottom of the README is the only
49
+ permitted occurrence in the shipped repository.
50
+
51
+ ## Consequences
52
+
53
+ - The package, CLI command, and import path are all `evalstand`.
54
+ - The name is unclaimed but unregistered — reserve it on PyPI early to avoid
55
+ losing it, since names in this space are being claimed quickly.
56
+ - The name does not signal "LLM" on its own; the description and README carry
57
+ that.
@@ -0,0 +1,36 @@
1
+ # 0002 — Design scope
2
+
3
+ - **Status:** proposed (blocked on task 0.6)
4
+ - **Date:** 2026-09-01
5
+
6
+ ## Context
7
+
8
+ `evalstand` is an independent Python implementation of an idea proven in
9
+ TypeScript by `evalite` (MIT): that local LLM evaluation should feel like
10
+ running a test suite.
11
+
12
+ This ADR records which behaviours `evalstand` adopts, which it does
13
+ differently, and why. **It cannot be completed until task 0.6 is done** —
14
+ studying the reference implementation's entry function, CLI, SQLite layer, and
15
+ web UI. Filling it in before that would be guesswork.
16
+
17
+ Two decisions are already settled and recorded here:
18
+
19
+ ## Decision (partial)
20
+
21
+ 1. **Textual TUI instead of a React web UI.** The reference ships a React web
22
+ app. `evalstand` ships a terminal UI first, with a web UI as an optional
23
+ stretch phase. Rationale: the TUI reaches functional parity faster, keeps
24
+ the entire stack in Python, and demos well in a terminal recording.
25
+
26
+ 2. **pytest instead of Vitest** as the collection and execution substrate,
27
+ since that is the Python equivalent and gives bare-`pytest` compatibility
28
+ for free.
29
+
30
+ 3. **Study for design understanding, not code.** The reference is read to
31
+ understand behaviour and interface decisions. No code is copied. Where
32
+ TypeScript idiom does not carry into Python, the Python equivalent wins.
33
+
34
+ ## Consequences
35
+
36
+ To be completed alongside the rest of this ADR after task 0.6.
@@ -0,0 +1,3 @@
1
+ # ci
2
+
3
+ To be written.
@@ -0,0 +1,3 @@
1
+ # index
2
+
3
+ To be written.
@@ -0,0 +1,3 @@
1
+ # quickstart
2
+
3
+ To be written.
@@ -0,0 +1,3 @@
1
+ # scorers
2
+
3
+ To be written.
@@ -0,0 +1,3 @@
1
+ # traces
2
+
3
+ To be written.
@@ -0,0 +1,3 @@
1
+ # writing-evals
2
+
3
+ To be written.
@@ -0,0 +1,90 @@
1
+ [project]
2
+ name = "evalstand"
3
+ version = "0.0.0.dev0"
4
+ description = "A local-first LLM evaluation tool for Python: write evals, run them like tests, watch results stream in."
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.11"
8
+ keywords = ["llm", "evaluation", "eval", "testing", "pytest", "ai"]
9
+ classifiers = [
10
+ "Development Status :: 2 - Pre-Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Topic :: Software Development :: Testing",
17
+ ]
18
+ dependencies = [
19
+ "litellm>=1.55",
20
+ "typer>=0.15",
21
+ "rich>=13.9",
22
+ "textual>=1.0",
23
+ "watchfiles>=1.0",
24
+ "pydantic>=2.10",
25
+ "rapidfuzz>=3.11",
26
+ "tenacity>=9.0",
27
+ "pytest>=8.3",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ examples = ["reportlab>=4.2", "faker>=33.1", "pypdfium2>=4.30"]
32
+
33
+ [project.scripts]
34
+ evalstand = "evalstand.cli:app"
35
+
36
+ [project.entry-points.pytest11]
37
+ evalstand = "evalstand.plugin"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "ruff>=0.9",
42
+ "mypy>=1.14",
43
+ "pytest-cov>=6.0",
44
+ "pre-commit>=4.0",
45
+ "mkdocs-material>=9.5",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/evalstand"]
54
+
55
+ [tool.hatch.build.targets.sdist]
56
+ # Ship source and docs; leave development scaffolding out of the distribution.
57
+ include = [
58
+ "/src",
59
+ "/tests",
60
+ "/docs",
61
+ "/examples",
62
+ "/README.md",
63
+ "/LICENSE",
64
+ "/CHANGELOG.md",
65
+ "/PLAN.md",
66
+ "/pyproject.toml",
67
+ ]
68
+
69
+ [tool.ruff]
70
+ line-length = 100
71
+ target-version = "py311"
72
+ src = ["src", "tests"]
73
+
74
+ [tool.ruff.lint]
75
+ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF"]
76
+
77
+ [tool.mypy]
78
+ python_version = "3.11"
79
+ strict = true
80
+ files = ["src"]
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ addopts = "--cov=evalstand --cov-report=term-missing"
85
+ markers = [
86
+ "live: hits a real provider API; excluded from CI (deselect with -m 'not live')",
87
+ ]
88
+
89
+ [tool.coverage.report]
90
+ exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
@@ -0,0 +1,12 @@
1
+ """evalstand — a local-first LLM evaluation tool for Python.
2
+
3
+ Evaluating an LLM application should feel like running a test suite: write an
4
+ eval file, run a watch command, and watch results stream in with scores,
5
+ traces, token counts, and cost.
6
+
7
+ The public API is deliberately small. Keep it under 10 exported names.
8
+ """
9
+
10
+ __version__ = "0.0.0.dev0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """evalstand.api — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.cache — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.cli — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.config — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.llm — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """Migrations for the evalstand SQLite store (Phase 5)."""
@@ -0,0 +1 @@
1
+ """evalstand.models — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.plugin — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.reporting.console — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.reporting.markdown — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.runner — implemented in a later phase."""
File without changes
@@ -0,0 +1 @@
1
+ """evalstand.scorers.base — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.scorers.fuzzy — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.scorers.json_field — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.scorers.llm — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.scorers.numeric — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.scorers.string — implemented in Phase 4."""
@@ -0,0 +1 @@
1
+ """evalstand.storage — implemented in a later phase."""
@@ -0,0 +1 @@
1
+ """evalstand.tracing — implemented in a later phase."""
File without changes
@@ -0,0 +1 @@
1
+ """evalstand.tui.app — implemented in Phase 6."""
File without changes
File without changes
File without changes
@@ -0,0 +1,7 @@
1
+ """Phase 0 smoke test: the package imports and reports a version."""
2
+
3
+ import evalstand
4
+
5
+
6
+ def test_package_imports() -> None:
7
+ assert evalstand.__version__