Files
Bo 6658c2bdd5 feat(eval): stale-rubric rejection guard via judge_content_hash (ag-hdqu0 #stale-reject) (#604)
## What
Adds `evalsubstrate.Rubric.FreshAgainst(currentJudgeHash)` — the
**drift-parity guard** for ag-hdqu0 (SCHEMA gate-#2 parity). An Outcomes
rubric is a derived artifact content-addressed off the judge;
`FreshAgainst` refuses any rubric whose carried `judge_content_hash` ≠
the current judge, so a divergent/stale cloud rubric
**self-invalidates** rather than grading against an outdated bar. Empty
hash on either side is unverifiable → rejected.

Pure-Go internal guard — **no CLI surface**, so no canary/allowlist
involvement.

## Tests (TDD)
- `TestRubric_FreshAgainst` — match→fresh · mismatch→error naming both
hashes · empty-rubric-hash→reject · empty-current→reject.
- 72 pass in evalsubstrate, vet/build clean.

Closes-scenario: ag-hdqu0.4#stale-reject
Bounded-context: BC1-Corpus
Evidence: cli/internal/evalsubstrate/rubric_freshness_test.go
2026-05-29 16:29:20 -04:00

40 lines
1.5 KiB
Go

package evalsubstrate
import (
"strings"
"testing"
)
// TestRubric_FreshAgainst is the stale-rubric rejection guard (ag-hdqu0.4 / SCHEMA
// gate-#2 drift parity): an Outcomes rubric must be refused when its carried
// judge_content_hash no longer matches the current judge, exactly like a stale
// local judge — so a divergent cloud rubric self-invalidates instead of grading
// against an outdated bar.
func TestRubric_FreshAgainst(t *testing.T) {
base := ProjectRubric(Task{ID: "t1"}, []Criterion{{ID: "c1", Weight: 1}}, "sha256:current")
if err := base.FreshAgainst("sha256:current"); err != nil {
t.Errorf("matching hash should be fresh, got error: %v", err)
}
err := base.FreshAgainst("sha256:changed")
if err == nil {
t.Fatal("mismatched judge hash must be rejected, got nil")
}
// The error must name both hashes so the drift is diagnosable.
if !strings.Contains(err.Error(), "sha256:current") || !strings.Contains(err.Error(), "sha256:changed") {
t.Errorf("error should name both rubric and current hash, got: %v", err)
}
// A rubric with no carried hash cannot be proven fresh → reject.
noHash := ProjectRubric(Task{ID: "t2"}, nil, "")
if err := noHash.FreshAgainst("sha256:current"); err == nil {
t.Error("rubric with empty judge_content_hash must be rejected")
}
// An empty current hash means we cannot verify → reject.
if err := base.FreshAgainst(""); err == nil {
t.Error("empty current judge hash must be rejected (cannot verify freshness)")
}
}