# T5.3 — `EvaluationStrategy` port + `ResourceProfile` | Field | Value | |---|---| | Phase | P5 — Grading | | Size | S — under 1 day | | Status | Not started | | Flags | — | | Spec | inlined below | | Blocks | T5.4, T5.5 | ## Goal The grading ports, and the type discipline that stops a capped or ungraded episode from being averaged into a promotion gate. ## Facts (inlined — no spec read needed) ```rust #[async_trait] pub trait EvaluationStrategy: Send + Sync { fn id(&self) -> StrategyId; /// Declared before any work is admitted. Validated against capacity limits /// at load; a strategy whose profile does not fit is rejected by name. fn resources(&self) -> ResourceProfile; async fn evaluate(&self, cx: &EvalCtx) -> Result>; } pub struct ResourceProfile { /// Models this strategy calls. One entry means it runs on the agent's /// already-resident model and forces no swap. pub models: Vec, /// Largest single-call context. A pairwise judge reads two episodes, so this /// is roughly twice an episode budget. pub max_context_tokens: u32, /// Model calls per episode evaluated, for capacity and spend projection. pub calls_per_episode: f32, } #[async_trait] pub trait Grader: Send + Sync { async fn grade(&self, group: &Group, rubric: &RubricDef) -> Result>; } #[async_trait] pub trait Judge: Send + Sync { /// Relative comparison only. Deliberately cannot return an absolute score. async fn compare(&self, a: &EpisodeView, b: &EpisodeView, r: &RubricDef) -> Result; /// Unary, separate from `compare`: a `Core` violation caps an episode on its /// own terms, not relative to an opponent. Runs before pairing. async fn screen(&self, e: &EpisodeView, r: &RubricDef) -> Result>; } pub enum Verdict { A, B, Draw } pub struct CoreViolation { pub criterion: RubricCriterionId, pub evidence: BlobRef } pub enum Score { Relative { against: RunId, verdict: Verdict, record: WinRecord }, Ranked { strength: f64, interval: (f64, f64), group_size: u32 }, Capped { violations: Vec }, Ungraded { reason: UngradedReason }, } ``` - **`compare` returns `Verdict`, never a number.** Absolute scores fail three ways here: calibration drift (same episode scores differently across weeks and model versions, and drift is indistinguishable from a variant trend); weak discrimination (four competent episodes all score 0.8); and saturation — as workflows improve, pass rate approaches 100% and absolute rubric scores saturate identically. A tournament cannot saturate; better candidates just make it harder. - **`Score` is a sum, not a number with flags.** A capped episode and an ungraded one are not low scores; they are different kinds of answer. A type that can represent them as numbers will eventually have them averaged into a promotion gate by code that meant no harm. - **The strategy declares what hardware it needs before it is allowed to run.** Strategies differ by more than an order of magnitude in model calls and VRAM; a deployment that cannot afford one must be told at load, not by an OOM at 3am. Strategy catalogue — cost per episode evaluated, group of eight: | Strategy | Calls / episode | Models resident | Produces | Default | |---|---|---|---|---| | `DeterministicGrader` | 0 | 0 | `Ranked` on a computed number | — | | **`PairwiseSequential`** | **1–2** | **1, the agent's** | `Relative` | **yes** | | `TournamentGrader` | 3–5 | 1 | `Ranked` with intervals | opt-in | | `ReplayTournament` | 3–5 **plus N full agent runs** | 1 | `Ranked` across variants | opt-in | ## Steps 1. Define all types above verbatim. `Verdict` has exactly three variants. 2. Give `Score` **no** `Into`, no `as_number()`, no `unwrap_or(0.0)` convenience. Aggregation code must match on the variant. 3. Keep `screen` separate and unary. It is the only source of `CoreViolation`, and it runs before pairing. 4. At strategy registration, call T5.2's residency check against `resources().models` plus the pinned set. Reject by strategy **name** with the shortfall. 5. Validate `resources().max_context_tokens` against T5.2's derived ceiling at the same point. 6. Write the two `trybuild` compile-fail cases described in acceptance. ## Acceptance - Compile-fail test: a judge cannot return `f64`. - Compile-fail test: `Score::Capped` and `Score::Ungraded` cannot be coerced to a number — no `Into`, no `unwrap_or(0.0)` path through the aggregate. - A strategy whose `ResourceProfile` names a second model is rejected at load when the residency invariant (T5.2) does not admit both. ## Verify **Harness:** `trybuild` for the type discipline; T5.2's capacity checker for the load-time rejection. **Integration test** — `tests/it_grading_ports.rs` + `tests/compile_fail/`: 1. **Compile-fail:** a `Judge` impl whose `compare` returns `f64`. Assert the stderr names the `Verdict` return type. 2. **Compile-fail:** `Into::::into(Score::Capped { .. })`, `score.unwrap_or(0.0)`, and any `as_number()` call. One case each — a single case leaves the other routes open. 3. **Audit test:** enumerate every aggregation site and assert each matches on `Score` variants exhaustively with no `_` arm. 4. Load-time rejection: a strategy whose `ResourceProfile` names a **second** model, under a `CapacityLimits` that admits only one. Assert rejection **by strategy name**, at registration, before any evaluation runs. 5. Positive: the same strategy under limits that admit both models is accepted — otherwise the check is just "reject two models", which is the wrong rule. 6. Context check: a strategy declaring `max_context_tokens` above T5.2's derived ceiling is rejected with the computed limit in the error. 7. `screen` separation: assert `CoreViolation` can only originate from `screen` — grep plus a test that a `compare` result cannot construct one. **Command:** `cargo test -p grading ports && cargo test -p grading --test compile_fail` **False pass:** - One compile-fail case for `f64`. The leak that matters is on `Score`, not on `Verdict` — a capped episode reaching an average is the failure this type discipline exists to prevent, and step 2 is where it is caught. - Step 5 omitted, so a naive "more than one model is illegal" implementation passes and blocks a legitimate two-resident-model deployment. - Step 3 omitted: the types can be sound while one aggregation site does `if let Some(n) = ...` and silently skips capped episodes instead of failing. ## Traps - A `Score::value() -> Option` helper. Every call site then writes `.unwrap_or(0.0)` and the cap becomes a zero. - Folding `screen` into `compare`. A judge asked to express a `Core` violation through a comparison can only rank the offender lower, which is not a cap. --- Background (not required to do this task): [rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.1, §11.2, §14.2 · [rust-agentic-task.md](../../../rust-agentic-task.md)