Work plan: stages B–D¶
Who builds what, in which order, and what "done" means for each task. Every card below becomes a GitHub issue. Each card is self-contained, so you can hand it to Claude Code as-is.
Working rules: the maintainer agreement and the engineering standards. Definition of done for every card: tests pass, ruff, pyright and import-linter pass, docstrings written, the other maintainer approved the PR, and a dev-log entry is written.
The big picture¶
┌──────────────────────┐
Week 1 │ J1 Shared contract │ (pair: both of you)
│ core/results.py │
└──────────┬───────────┘
┌───────────────┴────────────────┐
▼ ▼
TRACK 1: STATISTICS ENGINE TRACK 2: DATA BACKBONE
Owner: Krishna Owner: Karthik
S1 Wilson interval D1 Content IDs ← D1 can start
S2 Judge agreement D2 Data models before J1
S3 Judge correction (RG) D3 Split assignment
S4 v1 vs v2 comparison D4 Protocols
S5 PPI correction D5 SQLite store
S6 Clustered errors + power D6 JSONL / CSV import
S7 ⭐ Coverage simulation D7 ⭐ Fakes for testing
└───────────────┬────────────────┘
▼
STAGE D: SWAP (each works in the other's world)
X1 Karthik: store → stats glue (services/estimate.py)
X2 Krishna: import recorded verdicts + DeepEval results
X3 Pair: `evalhawk import` + `evalhawk report` ← first demo
- The tracks don't block each other:
stats/takes NumPy arrays and never touches models or the database. - The only shared piece is J1. Track 1 needs it before S1; Track 2 doesn't need it until much later, so Karthik can start D1 on day one.
- To swap tracks, change the owner names on this page in a PR. Both of you approve.
How to start any card with Claude Code¶
Read
CLAUDE.mdand card S1 indocs/development/workplan.md. First explain the concept to me simply, with an example. Then help me write the tests, and only then the code. Don't commit or push.
J1 · Shared contract: Estimate and errors¶
Owner: both (pair) · Depends on: nothing · Files: src/evalhawk/core/results.py, src/evalhawk/core/errors.py
Learn: value objects, frozen dataclasses, designing an interface two people depend on.
Build:
Estimate: a frozen dataclass (slots=True) withpoint,low,high,n,method,confidence(default 0.95) andunknown_rate(default 0.0). Validated in__post_init__;widthproperty; readable__str__such as0.820 [0.733, 0.883] (wilson, n=100, unknown=0.0%).errors.py:EvalhawkError, plusDataError,ConfigError,StoreError.
Decide together (and write the answer in the docstring): percentile bootstrap intervals
can, rarely, fail to contain the point estimate. Should Estimate reject that, or only
require low ≤ high?
Done when:
- Invalid values raise
ValueError(e.g.low > high,n < 0,confidenceoutside (0, 1)) Estimatecan't be modified after creation (test it)__str__output is tested exactly- pyright strict passes
Track 1: statistics engine (owner: Krishna)¶
All functions live in src/evalhawk/stats/, are pure (arrays in, Estimate out), take
randomness as rng: np.random.Generator, and use NumPy and the standard library only.
SciPy may be used in tests as a reference (pending ADR-0009).
S1 · Wilson interval¶
Depends on: J1 · File: stats/intervals.py
Learn: confidence intervals for a proportion; why the simple "Wald" formula fails for small samples.
Build: wilson_interval(k: int, n: int, *, confidence: float = 0.95) -> Estimate.
Use statistics.NormalDist().inv_cdf for the z-value.
Done when (unit tests with these exact values, 4 decimals):
| k | n | low | high |
|---|---|---|---|
| 82 | 100 | 0.7333 | 0.8830 |
| 0 | 10 | 0.0000 | 0.2775 |
| 10 | 10 | 0.7225 | 1.0000 |
| 500 | 1000 | 0.4691 | 0.5309 |
n = 0,k < 0ork > nraiseValueError- Property tests (Hypothesis):
0 ≤ low ≤ point ≤ high ≤ 1; symmetric (kandn−kmirror each other); the interval narrows asngrows at a fixed rate
Read: Wikipedia, Binomial proportion confidence interval (Wilson section); Brown, Cai & DasGupta (2001), pass 1.
S2 · Judge agreement¶
Depends on: S1 · File: stats/agreement.py
Learn: confusion matrices; sensitivity and specificity; why raw agreement misleads; Cohen's kappa. (Remember: in EvalHawk PASS is the positive class.)
Build:
confusion(human, judge) -> Confusion(countstp, fn, fp, tnfrom two 0/1 arrays)sensitivity(c) -> Estimate,specificity(c) -> Estimate(Wilson intervals)cohen_kappa(human, judge, *, rng, n_boot=2000) -> Estimate(bootstrap interval)
Done when: a hand-computed example matches; mismatched lengths or non-0/1 values raise
ValueError; a judge that always says PASS gets specificity 0; a kappa property test: a perfect
judge gives kappa = 1.
Read: Wikipedia, Sensitivity and specificity and Cohen's kappa; Hamel Husain, Using LLM-as-a-Judge.
S3 · Judge correction (Rogan-Gladen)¶
Depends on: S2 · File: stats/correction.py
Learn: correcting a measured rate for a known error rate; bootstrap intervals; the fast count-based bootstrap (resample counts, not rows).
Build:
rogan_gladen(q, sensitivity, specificity) -> float:(q + c − 1) / (s + c − 1), clipped to [0, 1]corrected_pass_rate(judge_test, judge_cal, human_cal, *, rng, n_boot=2000) -> Estimate: a two-sample bootstrap that usesrng.multinomialfor the four calibration cells andrng.binomialfor the test pass count- Refuse when
s + c − 1 ≤ 0.1: raise a newJudgeTooWeakError(EvalhawkError)whose message explains why
Done when: rogan_gladen(0.82, 0.95, 0.60) = 0.7636; the refusal is tested;
2,000 resamples on 100k test items take under 50 ms (a benchmark test, marked slow).
Read: architecture §13.3; How to Correctly Report LLM-as-a-Judge Evaluations (arXiv 2511.21140), method section.
S4 · Version comparison (v1 vs v2)¶
Depends on: S1 · File: stats/compare.py (+ Comparison in core/results.py)
Learn: paired designs; McNemar's test; why only disagreements matter.
Build:
mcnemar_exact(b, c) -> float: the log-space algorithm (see the benchmark in the 2026-09-28 design discussion). No SciPy, no big integers.paired_bootstrap(a, b, *, rng, n_boot=2000) -> Estimate: interval onmean(b) − mean(a), resampling example indicesComparisonvalue object anddecide(diff) -> "BETTER" | "WORSE" | "INCONCLUSIVE"
Done when: mcnemar_exact(12, 30) ≈ 0.00792; matches scipy.stats.binomtest to a
relative error below 1e-9 on 5,000 random cases (a test-only SciPy check); b = c gives 1.0.
Read: Wikipedia, McNemar's test; Miller (2024), section on paired differences.
S5 · PPI correction¶
Depends on: S1 · File: stats/correction.py
Learn: prediction-powered inference: correcting with the judge's average error measured on labeled data.
Build: ppi_mean(y_labeled, yhat_labeled, yhat_unlabeled, *, confidence=0.95) -> Estimate
(classical PPI with a normal-approximation interval).
Done when: matches the ppi-python reference implementation within 1e-6 on fixed data
(ppi-python added as a dev-only dependency; check its current version first).
Read: Angelopoulos et al. (2023), pass 1; the mean-estimation example in the ppi_py repo.
S6 · Clustered errors and "need N more"¶
Depends on: S1, S4 · Files: stats/clustered.py, stats/power.py
Learn: why related questions widen error bars; how sample size is planned.
Build:
cluster_robust_se(scores, clusters) -> floatand aclusters=option for the bootstraps (resample whole clusters)required_n_paired(p_discordant, delta, *, alpha=0.05, power=0.8) -> int
Done when: with every item in its own cluster, the clustered SE equals the ordinary SE; with duplicated clusters the SE grows; the power formula reproduces a hand-computed example.
Read: Miller (2024), clustered standard errors and power analysis.
S7 · ⭐ Coverage simulation (the proof)¶
Depends on: S1–S6 · Files: tests/validation/test_coverage.py, experiments/scripts/coverage_naive_vs_corrected.py
Learn: what a confidence interval promises, and how to test that promise.
Build: simulate a judge with known sensitivity and specificity at a known true pass rate. Over 2,000 runs, check how often each method's 95% interval contains the truth: naive, Rogan-Gladen, PPI, and clustered. Produce a chart of naive vs corrected coverage.
Done when: corrected methods cover 93.5%–96.5%; the naive method visibly fails;
tests are marked slow; the chart is saved to experiments/figures/. This is the first
portfolio artefact.
Track 2: data backbone (owner: Karthik)¶
D2-D7 implementation status (2026-10-11): built on feat/track2-data-backbone
in the pair session; maintainer review and acceptance are pending. The implementation
clarifications in ADR-0010 take precedence
over the original card sketches below for this PR:
- D2 includes TargetOutput, optional tags, normalized identities and explicit repeats.
- D3 hashes structured keys and supports immutable named split sets with diagnostics.
- D4 uses a synchronous Store; TraceSource yields
(Example, TargetOutput | None). - D5 uses the shipped indexed JSON-record schema, immutable split-set/group snapshots, audited paired reads and the active implemented-adapter import contract.
- D6 leaves run identity to import services, accepts pipe-separated CSV tags, and keeps unknown columns as metadata. Streaming memory and insertion workloads are tested.
- D7 uses a separate truth mapping rather than a test-only Trace field; known accuracy means generating probabilities, not exact finite-sample counts. Both stores share tests.
- X1-X3 are not implemented: UNKNOWN handling, label adjudication, calibration status, report rendering, recorded verdict imports and Store-to-stats integration remain there.
Files live in src/evalhawk/core/, storage/, sources/ and testing/. core/ must stay
pure: no I/O, no vendor SDKs (import-linter enforces this).
D1 · Content IDs¶
Depends on: nothing (start here) · File: core/ids.py
Learn: hashing; canonical JSON; content addressing; why this is a one-way door.
Build: content_id(obj) -> str: SHA-256 hex of canonical JSON (UTF-8, sorted keys,
separators=(",", ":"), NaN and Infinity rejected). Plus normalize_text(s), which converts
CRLF/CR line endings to LF (used for prompt templates).
Done when:
- Golden tests pin the exact hash of three fixed objects. These must never change (ADR-0006).
- Property test: key order doesn't change the ID
- NaN raises
ValueError
Read: ADR-0006; Python json and hashlib docs.
D2 · Data models¶
Depends on: D1 · File: core/models.py
Learn: Pydantic v2; immutable domain models; validation at the boundary.
Build: Outcome (PASS, FAIL, UNKNOWN) and Split (train, dev, test) enums; frozen
Pydantic models Example, Run, Trace, Criterion, Verdict, Label, Evaluation,
Calibration with the fields in architecture §4.1. IDs are computed with content_id, and
extra="forbid".
Done when: each model's ID is stable (golden test); unknown fields are rejected; models
can't be modified; an Evaluation of kind rag rejects data without contexts, with a
message naming the field.
Read: architecture §4.1; the Pydantic docs, "Models".
D3 · Split assignment¶
Depends on: D1 · File: core/splits.py
Learn: deterministic hashing into buckets; group-aware splitting; why test-set leakage matters.
Build: assign_split(key, *, salt, ratios=(0.2, 0.4, 0.4)) -> Split, using
u = int(sha256(salt + key)[:8], 16) / 16**8 mapped onto the cumulative ratios. The key is
group_id if set, else example_id.
Done when: golden tests pin the splits of fixed keys; 10,000 random keys land within
±2 points of each ratio; the same group_id always gives the same split; ratios that don't
sum to 1 raise ValueError.
Read: ADR-0006; architecture §13.11; Hamel Husain's FAQ on train/dev/test.
D4 · Protocols (the plug-in contracts)¶
Depends on: D2 · File: core/protocols.py
Learn: structural typing with typing.Protocol; designing plug-in points.
Build: Judge, MultiCriterionJudge, Target, TraceSource, Store, following
architecture §5 and ADR-0004.
Done when: a tiny test class satisfies each protocol without inheriting from anything
(checked by pyright and by isinstance for the runtime_checkable ones).
Read: PEP 544; Architecture Patterns with Python, chapters 1–2.
D5 · SQLite store¶
Depends on: D2, D3, D4 · Files: storage/sqlite.py, storage/migrations/0001_init.sql
Learn: SQLite WAL, transactions, triggers, migrations; the repository pattern.
Build: SQLiteStore implementing Store:
- On connect:
journal_mode=WAL,synchronous=NORMAL,busy_timeout=5000,foreign_keys=ON - A migration runner based on
PRAGMA user_version(forward only, one transaction each) - The schema from architecture §4.2, including the split-immutability triggers and
test_access_log - Batched inserts with
executemanyinside one transaction - Switch on import-linter contract 3 (adapters) in
pyproject.toml
Done when: a test proves an UPDATE or DELETE on splits is refused; data
round-trips; re-inserting the same example creates no duplicate; two connections can read
while one writes; 20,000 inserts take under 1 s.
Read: ADR-0001; SQLite docs on WAL and CREATE TRIGGER; Python sqlite3 docs.
D6 · JSONL and CSV import¶
Depends on: D2 · Files: sources/jsonl.py, sources/csv.py
Learn: streaming with iterators (constant memory); error messages users can act on.
Build: JSONLSource(path) and CSVSource(path) implementing TraceSource, yielding
(Example, Trace | None) one row at a time.
Done when: a bad row raises DataError naming the file, row number and field; a
100k-row file imports without loading everything into memory; blank lines are skipped.
D7 · ⭐ Fakes for testing¶
Depends on: D4 · File: src/evalhawk/testing/fakes.py
Learn: test doubles; simulation with known ground truth; async code.
Build:
FakeJudge(sensitivity, specificity, *, rng): returns PASS/FAIL with exactly known error rates against the truth stored on the traceFakeTarget: returns canned outputsInMemoryStore: a dict-basedStore, which proves the protocol is implementable without SQL
Done when: over 10,000 traces, the fake judge's measured sensitivity is within ±1 point
of its setting; InMemoryStore passes the same round-trip tests as SQLiteStore
(a shared test suite run against both).
Stage D: swap and first demo¶
| Card | Owner | What | Why this owner |
|---|---|---|---|
| X1 | Karthik | services/estimate.py: load arrays from the Store, call stats, return an Estimate. Switch on import-linter contract 2 |
Karthik learns the statistics API |
| X2 | Krishna | sources/verdicts.py: import recorded judge verdicts and human labels (JSONL, and DeepEval result files: score ≥ threshold → PASS) |
Krishna learns the data layer |
| X3 | Pair | evalhawk import and evalhawk report (Rich table): corrected pass rate, error range, judge report card |
The first end-to-end demo, with zero LLM calls |
Stage D is done when: on a public dataset with human labels, evalhawk report prints a
judge report card and a corrected pass rate with an error range.
Local implementation (pairing, pending review): X1-X3 now connect recorded outcomes to rate reports, paired proxy comparison and audit through a usable offline CLI. The first demonstration is synthetic by explicit session agreement; the original public-human-labelled-dataset acceptance gate is not yet met. DeepEval mapping is deferred pending verified format examples. Proposed ADRs 0011/0012 document representative-case inference, adjudication, memory limits and Rich. Both tracks were touched: core/store ports, statistical bootstrap batching, sources and services.
Suggested first week¶
| Karthik | Krishna | |
|---|---|---|
| Day 1 | Accept the org invite as Owner, clone, set up, first PR (approve the agreement + ADRs 0007, 0009) | Phase 0 wrap-up: repo, settings, PyPI |
| Day 2 | J1 together | J1 together |
| Days 3–7 | D1 → D2 (D1 can start on day 1) | S1 → S2 |