Skip to content

Commit b2656e1

Browse files
committed
feat: per-turn checkpoint graders (closes #358)
Add an additive `checkpoints:` field to task YAML so multi-turn evals can grade conversation state at specific turn boundaries instead of only the final output. - New `Checkpoint` model with after_turn, graders, on_failure (continue/stop) - New `CheckpointOutcome` recorded per task on results.json - Per-turn hook in runner (initial + follow_ups + responder loop) - on_failure: stop aborts remaining turns and flips status to error - Bumped schemaVersion to 1.1 (additive, MINOR bump per #382 policy) - Reuses existing grader plumbing (graders.RunAll + buildGraderContext) - Honors --skip-graders by short-circuiting checkpoint evaluation - Full unit + integration tests; docs (guide + schema + changelog) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3458ced commit b2656e1

12 files changed

Lines changed: 747 additions & 16 deletions

File tree

cmd/waza/cmd_migrate_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestMigrateCommandNoOpForCurrentMajor(t *testing.T) {
1818
err := runMigrate(&out, path)
1919

2020
require.NoError(t, err)
21-
require.Contains(t, out.String(), "already compatible with schemaVersion 1.0")
21+
require.Contains(t, out.String(), "compatible with waza schemaVersion 1.1")
2222
}
2323

2424
func TestMigrateCommandRejectsIncompatibleMajor(t *testing.T) {
@@ -43,7 +43,7 @@ func TestMigrateCommandDefaultsMissingSchemaVersion(t *testing.T) {
4343
err := runMigrate(&out, path)
4444

4545
require.NoError(t, err)
46-
require.Contains(t, out.String(), "already compatible with schemaVersion 1.0")
46+
require.Contains(t, out.String(), "already compatible with schemaVersion 1.1")
4747
}
4848

4949
func TestMigrateCommandRejectsUnknownJSONShape(t *testing.T) {
@@ -69,7 +69,7 @@ func TestMigrateCommandDetectsResultsJSONByShape(t *testing.T) {
6969
err := runMigrate(&out, path)
7070

7171
require.NoError(t, err)
72-
require.Contains(t, out.String(), "results.json is already compatible")
72+
require.Contains(t, out.String(), "results.json uses schemaVersion 1.0")
7373
}
7474

7575
func TestMigrateCommandMissingFile(t *testing.T) {

internal/models/outcome.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,28 @@ type RunResult struct {
192192
WorkspaceDir string `json:"workspace_dir,omitempty"`
193193
FailureArtifacts *FailureArtifacts `json:"failure_artifacts,omitempty"`
194194
Responder *ResponderInfo `json:"responder,omitempty"`
195+
// Checkpoints captures per-turn checkpoint grader results, one entry per
196+
// configured TestCase.Checkpoint that actually ran (i.e., turn index was
197+
// reached). Empty / omitted when the task defines no checkpoints.
198+
Checkpoints []CheckpointOutcome `json:"checkpoints,omitempty"`
199+
}
200+
201+
// CheckpointOutcome captures the results of a single TestCase.Checkpoint that
202+
// ran during a multi-turn run. It mirrors the shape of the final-grader
203+
// validations map so dashboards can present per-turn pass/fail uniformly.
204+
type CheckpointOutcome struct {
205+
// AfterTurn echoes Checkpoint.AfterTurn so consumers can sort/group.
206+
AfterTurn int `json:"after_turn"`
207+
// Status is StatusPassed when every grader in this checkpoint passed,
208+
// StatusFailed when at least one grader failed.
209+
Status Status `json:"status"`
210+
// Validations maps grader identifier to result, identical to
211+
// RunResult.Validations.
212+
Validations map[string]GraderResults `json:"validations"`
213+
// Stopped is true when this checkpoint had `on_failure: stop` and at
214+
// least one grader failed, terminating the multi-turn loop after this
215+
// turn.
216+
Stopped bool `json:"stopped,omitempty"`
195217
}
196218

197219
// FailureArtifacts captures diagnostic information when a run fails

internal/models/outcome_schema_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestEvaluationOutcomeMarshalDefaultsSchemaVersion(t *testing.T) {
8484
if err != nil {
8585
t.Fatalf("Marshal() error = %v", err)
8686
}
87-
if !strings.Contains(string(data), `"schemaVersion":"1.0"`) {
87+
if !strings.Contains(string(data), `"schemaVersion":"1.1"`) {
8888
t.Fatalf("marshaled outcome missing default schemaVersion: %s", data)
8989
}
9090
}

internal/models/schema_version.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import (
1515

1616
const (
1717
// CurrentSchemaVersion is the current MAJOR.MINOR schema version for public artifacts.
18-
CurrentSchemaVersion = "1.0"
18+
//
19+
// 1.1 adds per-turn checkpoints (TestCase.Checkpoints / RunResult.Checkpoints) — purely
20+
// additive over 1.0, so 1.0 artifacts continue to load without migration.
21+
CurrentSchemaVersion = "1.1"
1922
)
2023

2124
func defaultSchemaVersion(version string) string {

internal/models/testcase.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,74 @@ type TestCase struct {
3131
// nil inherits the eval-level value; 0 disables the check for this task.
3232
FirstEventTimeoutSec *int `yaml:"first_event_timeout_seconds,omitempty" json:"first_event_timeout_sec,omitempty"`
3333
Validators []ValidatorInline `yaml:"graders,omitempty" json:"validators,omitempty"`
34+
// Checkpoints attach graders to intermediate turn boundaries in a
35+
// multi-turn run. Each checkpoint specifies `after_turn: N` (1-based,
36+
// where turn 1 is the initial prompt) and a list of graders that run
37+
// against the cumulative conversation state at the end of that turn.
38+
// Checkpoints are additive — task-level `graders:` still run against
39+
// the final state after all turns complete.
40+
Checkpoints []Checkpoint `yaml:"checkpoints,omitempty" json:"checkpoints,omitempty"`
41+
}
42+
43+
// CheckpointOnFailure controls multi-turn behavior when a checkpoint fails.
44+
type CheckpointOnFailure string
45+
46+
const (
47+
// CheckpointContinue (default) records the failure and continues with the
48+
// remaining turns and the final grader pass.
49+
CheckpointContinue CheckpointOnFailure = "continue"
50+
// CheckpointStop records the failure, sets the run's error message, and
51+
// short-circuits the multi-turn loop so no further turns execute.
52+
CheckpointStop CheckpointOnFailure = "stop"
53+
)
54+
55+
// Checkpoint runs a slice of graders against the conversation state at the
56+
// end of a specific turn. See TestCase.Checkpoints for details.
57+
type Checkpoint struct {
58+
// AfterTurn is the 1-based turn index at whose boundary the graders run.
59+
// Turn 1 is the initial prompt; turns 2..N are follow-ups or responder
60+
// replies. Must be >= 1.
61+
AfterTurn int `yaml:"after_turn" json:"after_turn"`
62+
// Graders is the slice of grader configurations to run at this turn
63+
// boundary. Required. Reuses the same ValidatorInline shape as task-level
64+
// `graders:` so any existing grader type is supported.
65+
Graders []ValidatorInline `yaml:"graders" json:"graders"`
66+
// OnFailure controls multi-turn behavior when any grader in this
67+
// checkpoint fails. "continue" (default) records the failure and keeps
68+
// going. "stop" short-circuits the multi-turn loop after the failure.
69+
OnFailure CheckpointOnFailure `yaml:"on_failure,omitempty" json:"on_failure,omitempty"`
70+
}
71+
72+
// EffectiveOnFailure returns the OnFailure value to use, defaulting to
73+
// CheckpointContinue when unset.
74+
func (c *Checkpoint) EffectiveOnFailure() CheckpointOnFailure {
75+
if c.OnFailure == "" {
76+
return CheckpointContinue
77+
}
78+
return c.OnFailure
79+
}
80+
81+
// Validate checks the checkpoint has the required fields. Reuses
82+
// ValidatorInline.Validate for inner grader validation.
83+
func (c *Checkpoint) Validate() error {
84+
if c.AfterTurn < 1 {
85+
return fmt.Errorf("after_turn must be at least 1, got %d", c.AfterTurn)
86+
}
87+
if len(c.Graders) == 0 {
88+
return fmt.Errorf("after_turn %d: graders is required and must contain at least one grader", c.AfterTurn)
89+
}
90+
switch c.OnFailure {
91+
case "", CheckpointContinue, CheckpointStop:
92+
default:
93+
return fmt.Errorf("after_turn %d: on_failure must be %q or %q, got %q",
94+
c.AfterTurn, CheckpointContinue, CheckpointStop, c.OnFailure)
95+
}
96+
for i := range c.Graders {
97+
if err := c.Graders[i].Validate(); err != nil {
98+
return fmt.Errorf("after_turn %d: grader[%d]: %w", c.AfterTurn, i, err)
99+
}
100+
}
101+
return nil
34102
}
35103

36104
// TaskStimulus defines the input for a task.
@@ -375,6 +443,43 @@ func (tc *TestCase) Validate() error {
375443
}
376444
}
377445

446+
// Validate checkpoints. When follow-up prompts (not a responder) drive
447+
// the multi-turn run, the turn count is known statically so we can also
448+
// reject checkpoints that exceed it.
449+
if len(tc.Checkpoints) > 0 {
450+
name := tc.TestID
451+
if name == "" {
452+
name = tc.DisplayName
453+
}
454+
prefix := "test case"
455+
if name != "" {
456+
prefix = fmt.Sprintf("test case %q", name)
457+
}
458+
459+
// Static turn count: 1 initial + len(FollowUps) follow-ups when no
460+
// responder is configured. Responder is dynamic — skip the upper bound.
461+
maxTurns := 0
462+
if tc.Stimulus.Responder == nil {
463+
maxTurns = 1 + len(tc.Stimulus.FollowUps)
464+
}
465+
466+
seen := make(map[int]bool, len(tc.Checkpoints))
467+
for i := range tc.Checkpoints {
468+
if err := tc.Checkpoints[i].Validate(); err != nil {
469+
return fmt.Errorf("%s: checkpoints[%d]: %w", prefix, i, err)
470+
}
471+
at := tc.Checkpoints[i].AfterTurn
472+
if seen[at] {
473+
return fmt.Errorf("%s: checkpoints[%d]: duplicate after_turn %d (each turn may have at most one checkpoint)", prefix, i, at)
474+
}
475+
seen[at] = true
476+
if maxTurns > 0 && at > maxTurns {
477+
return fmt.Errorf("%s: checkpoints[%d].after_turn %d exceeds total turns (1 initial + %d follow_up_prompts = %d turns)",
478+
prefix, i, at, len(tc.Stimulus.FollowUps), maxTurns)
479+
}
480+
}
481+
}
482+
378483
return nil
379484
}
380485

internal/models/testcase_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,114 @@ func TestResponderValidationAcceptsValidConfig(t *testing.T) {
340340
}
341341
require.NoError(t, tc.Validate())
342342
}
343+
344+
// -- Checkpoint validation tests --
345+
346+
func TestLoadTestCase_CheckpointsParsed(t *testing.T) {
347+
yamlData := `id: tc-cp
348+
name: Checkpoint loader
349+
inputs:
350+
prompt: do work
351+
follow_up_prompts:
352+
- keep going
353+
checkpoints:
354+
- after_turn: 1
355+
graders:
356+
- name: mid-check
357+
type: text
358+
config:
359+
contains:
360+
- hello
361+
- after_turn: 2
362+
on_failure: stop
363+
graders:
364+
- name: end-check
365+
type: text
366+
config:
367+
contains:
368+
- bye
369+
`
370+
dir := t.TempDir()
371+
p := filepath.Join(dir, "tc.yaml")
372+
require.NoError(t, os.WriteFile(p, []byte(yamlData), 0o644))
373+
374+
tc, err := LoadTestCase(p)
375+
require.NoError(t, err)
376+
require.Len(t, tc.Checkpoints, 2)
377+
require.Equal(t, 1, tc.Checkpoints[0].AfterTurn)
378+
require.Equal(t, CheckpointContinue, tc.Checkpoints[0].EffectiveOnFailure())
379+
require.Equal(t, CheckpointStop, tc.Checkpoints[1].EffectiveOnFailure())
380+
require.Len(t, tc.Checkpoints[0].Graders, 1)
381+
require.Equal(t, "mid-check", tc.Checkpoints[0].Graders[0].Identifier)
382+
}
383+
384+
func TestCheckpointValidation(t *testing.T) {
385+
makeTC := func(cps []Checkpoint) *TestCase {
386+
return &TestCase{
387+
TestID: "t",
388+
DisplayName: "t",
389+
Stimulus: TaskStimulus{
390+
Message: "go",
391+
FollowUps: []string{"again", "more"},
392+
},
393+
Checkpoints: cps,
394+
}
395+
}
396+
validGrader := ValidatorInline{
397+
Identifier: "g",
398+
Kind: GraderKindText,
399+
Parameters: TextGraderParameters{Contains: []string{"hi"}},
400+
}
401+
402+
t.Run("after_turn < 1 rejected", func(t *testing.T) {
403+
tc := makeTC([]Checkpoint{{AfterTurn: 0, Graders: []ValidatorInline{validGrader}}})
404+
require.ErrorContains(t, tc.Validate(), "after_turn")
405+
})
406+
407+
t.Run("missing graders rejected", func(t *testing.T) {
408+
tc := makeTC([]Checkpoint{{AfterTurn: 1}})
409+
require.ErrorContains(t, tc.Validate(), "graders")
410+
})
411+
412+
t.Run("duplicate after_turn rejected", func(t *testing.T) {
413+
tc := makeTC([]Checkpoint{
414+
{AfterTurn: 1, Graders: []ValidatorInline{validGrader}},
415+
{AfterTurn: 1, Graders: []ValidatorInline{validGrader}},
416+
})
417+
require.ErrorContains(t, tc.Validate(), "duplicate")
418+
})
419+
420+
t.Run("after_turn exceeds turns rejected", func(t *testing.T) {
421+
// 1 initial + 2 follow-ups = 3 turns; 4 is out of range.
422+
tc := makeTC([]Checkpoint{{AfterTurn: 4, Graders: []ValidatorInline{validGrader}}})
423+
require.ErrorContains(t, tc.Validate(), "exceeds")
424+
})
425+
426+
t.Run("invalid on_failure rejected", func(t *testing.T) {
427+
tc := makeTC([]Checkpoint{{AfterTurn: 1, OnFailure: "maybe", Graders: []ValidatorInline{validGrader}}})
428+
require.ErrorContains(t, tc.Validate(), "on_failure")
429+
})
430+
431+
t.Run("valid case accepted", func(t *testing.T) {
432+
tc := makeTC([]Checkpoint{
433+
{AfterTurn: 1, Graders: []ValidatorInline{validGrader}},
434+
{AfterTurn: 3, OnFailure: CheckpointStop, Graders: []ValidatorInline{validGrader}},
435+
})
436+
require.NoError(t, tc.Validate())
437+
})
438+
439+
t.Run("upper bound skipped for responder", func(t *testing.T) {
440+
// With responder, total turn count is unknown at validation time;
441+
// only a structural sanity check applies.
442+
tc := &TestCase{
443+
TestID: "t",
444+
DisplayName: "t",
445+
Stimulus: TaskStimulus{
446+
Message: "go",
447+
Responder: &ResponderConfig{Instructions: "x", MaxFollowups: 4},
448+
},
449+
Checkpoints: []Checkpoint{{AfterTurn: 5, Graders: []ValidatorInline{validGrader}}},
450+
}
451+
require.NoError(t, tc.Validate())
452+
})
453+
}

0 commit comments

Comments
 (0)