7.0 KiB
7.0 KiB
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)
#[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<Vec<Score>>;
}
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<ModelId>,
/// 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<Vec<Score>>;
}
#[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<Verdict>;
/// 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<Vec<CoreViolation>>;
}
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<CoreViolation> },
Ungraded { reason: UngradedReason },
}
comparereturnsVerdict, 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.Scoreis 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
- Define all types above verbatim.
Verdicthas exactly three variants. - Give
ScorenoInto<f64>, noas_number(), nounwrap_or(0.0)convenience. Aggregation code must match on the variant. - Keep
screenseparate and unary. It is the only source ofCoreViolation, and it runs before pairing. - At strategy registration, call T5.2's residency check against
resources().modelsplus the pinned set. Reject by strategy name with the shortfall. - Validate
resources().max_context_tokensagainst T5.2's derived ceiling at the same point. - Write the two
trybuildcompile-fail cases described in acceptance.
Acceptance
- Compile-fail test: a judge cannot return
f64. - Compile-fail test:
Score::CappedandScore::Ungradedcannot be coerced to a number — noInto<f64>, nounwrap_or(0.0)path through the aggregate. - A strategy whose
ResourceProfilenames 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/:
- Compile-fail: a
Judgeimpl whosecomparereturnsf64. Assert the stderr names theVerdictreturn type. - Compile-fail:
Into::<f64>::into(Score::Capped { .. }),score.unwrap_or(0.0), and anyas_number()call. One case each — a single case leaves the other routes open. - Audit test: enumerate every aggregation site and assert each matches on
Scorevariants exhaustively with no_arm. - Load-time rejection: a strategy whose
ResourceProfilenames a second model, under aCapacityLimitsthat admits only one. Assert rejection by strategy name, at registration, before any evaluation runs. - 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.
- Context check: a strategy declaring
max_context_tokensabove T5.2's derived ceiling is rejected with the computed limit in the error. screenseparation: assertCoreViolationcan only originate fromscreen— grep plus a test that acompareresult 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 onScore, not onVerdict— 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<f64>helper. Every call site then writes.unwrap_or(0.0)and the cap becomes a zero. - Folding
screenintocompare. A judge asked to express aCoreviolation 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 §11.1, §11.2, §14.2 · rust-agentic-task.md