mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package eval
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/boshu2/agentops/cli/internal/evalsubstrate"
|
|
)
|
|
|
|
type TaskRunWriter interface {
|
|
Transition(evalsubstrate.RunStatus, func(*evalsubstrate.Manifest)) error
|
|
Manifest() evalsubstrate.Manifest
|
|
Path() string
|
|
}
|
|
|
|
type TaskRuntime interface {
|
|
Root() string
|
|
ReadFile(string) ([]byte, error)
|
|
WriteAtomic(string, []byte) error
|
|
ListDirectories(string) ([]string, error)
|
|
UserHome() (string, error)
|
|
SnapshotHarness(string, string, string) (*evalsubstrate.Harness, *evalsubstrate.HarnessLock, error)
|
|
LoadModelSpec(string, string) (*evalsubstrate.ModelSpec, error)
|
|
GenerateRunID(string) string
|
|
OpenRun(string, string, evalsubstrate.Manifest) (TaskRunWriter, error)
|
|
Now() time.Time
|
|
}
|
|
|
|
type TaskService struct{ Runtime TaskRuntime }
|
|
type TaskAddRequest struct{ SourcePath string }
|
|
type TaskAddResult struct {
|
|
Task evalsubstrate.Task
|
|
Destination string
|
|
}
|
|
type TaskSummary struct {
|
|
Task *evalsubstrate.Task `json:"task,omitempty"`
|
|
ID, Error string
|
|
}
|
|
type TaskListResult struct {
|
|
Tasks []TaskSummary `json:"tasks"`
|
|
Root string `json:"root"`
|
|
}
|
|
type TaskRunRequest struct {
|
|
TaskID, SuiteRef, RigID, Seeds, HarnessRef, HarnessDir, ModelSpecID, GroundTruthRef string
|
|
SampleSplit string
|
|
NSamples int
|
|
InspectVersion, InspectCommand string
|
|
CrossSpec, AllowWeak, QuickSession, DryRun bool
|
|
}
|
|
type TaskRunResult struct {
|
|
DryRun bool
|
|
Manifest evalsubstrate.Manifest
|
|
Path string
|
|
}
|
|
|
|
func (service TaskService) Add(_ context.Context, request TaskAddRequest) (TaskAddResult, error) {
|
|
raw, err := service.Runtime.ReadFile(request.SourcePath)
|
|
if err != nil {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: read %q: %w", request.SourcePath, err)
|
|
}
|
|
var task evalsubstrate.Task
|
|
if err := yaml.Unmarshal(raw, &task); err != nil {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: parse: %w", err)
|
|
}
|
|
if task.ID == "" {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: missing required field id")
|
|
}
|
|
taskID, err := evalsubstrate.ParseIdentifier(evalsubstrate.IdentifierTask, task.ID)
|
|
if err != nil {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: %w", err)
|
|
}
|
|
if task.Stats.MinNSamples <= 0 {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: task %q has no stats.min_n_samples (gate #6 cannot enforce)", task.ID)
|
|
}
|
|
canonical, err := evalsubstrate.CanonicalizeYAML(raw)
|
|
if err != nil {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: canonicalize: %w", err)
|
|
}
|
|
destination := filepath.Join(service.Runtime.Root(), "tasks", taskID.StorageName(), "task.yaml")
|
|
if err := service.Runtime.WriteAtomic(destination, canonical); err != nil {
|
|
return TaskAddResult{}, fmt.Errorf("eval task add: write: %w", err)
|
|
}
|
|
return TaskAddResult{Task: task, Destination: destination}, nil
|
|
}
|
|
|
|
func (service TaskService) List(_ context.Context) (TaskListResult, error) {
|
|
root := filepath.Join(service.Runtime.Root(), "tasks")
|
|
ids, err := service.Runtime.ListDirectories(root)
|
|
if err != nil {
|
|
return TaskListResult{}, fmt.Errorf("eval task list: %w", err)
|
|
}
|
|
sort.Strings(ids)
|
|
result := TaskListResult{Root: root, Tasks: make([]TaskSummary, 0, len(ids))}
|
|
for _, storageID := range ids {
|
|
id, parseErr := evalsubstrate.ParseStorageIdentifier(evalsubstrate.IdentifierTask, storageID)
|
|
if parseErr != nil {
|
|
result.Tasks = append(result.Tasks, TaskSummary{ID: storageID, Error: parseErr.Error()})
|
|
continue
|
|
}
|
|
task, err := service.loadTask(id.String())
|
|
if err != nil {
|
|
result.Tasks = append(result.Tasks, TaskSummary{ID: id.String(), Error: err.Error()})
|
|
continue
|
|
}
|
|
result.Tasks = append(result.Tasks, TaskSummary{ID: id.String(), Task: task})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (service TaskService) Show(_ context.Context, id string) (*evalsubstrate.Task, error) {
|
|
return service.loadTask(id)
|
|
}
|
|
|
|
func (service TaskService) Run(_ context.Context, request TaskRunRequest) (TaskRunResult, error) {
|
|
task, err := service.loadTask(request.TaskID)
|
|
if err != nil {
|
|
return TaskRunResult{}, err
|
|
}
|
|
if request.SuiteRef == "" {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: --suite is required")
|
|
}
|
|
suite, err := service.loadSuite(request.SuiteRef)
|
|
if err != nil {
|
|
return TaskRunResult{}, err
|
|
}
|
|
if request.ModelSpecID != "" {
|
|
if _, err := evalsubstrate.ParseIdentifier(evalsubstrate.IdentifierModel, request.ModelSpecID); err != nil {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: --model-spec: %w", err)
|
|
}
|
|
}
|
|
seeds, err := parseTaskSeeds(request.Seeds)
|
|
if err != nil {
|
|
return TaskRunResult{}, err
|
|
}
|
|
if len(seeds) < 3 {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: --seeds requires >=3 values, got %d (per §4 manifest required field)", len(seeds))
|
|
}
|
|
gateInputs := evalsubstrate.GateInputs{Suite: suite, Task: task, AllowWeak: request.AllowWeak, GTRequested: request.GroundTruthRef}
|
|
if request.HarnessDir != "" {
|
|
dir := request.HarnessDir
|
|
if strings.HasPrefix(dir, "~") {
|
|
if home, homeErr := service.Runtime.UserHome(); homeErr == nil {
|
|
dir = filepath.Join(home, strings.TrimPrefix(dir, "~"))
|
|
}
|
|
}
|
|
harness, lock, err := service.Runtime.SnapshotHarness(dir, request.HarnessRef, "ao eval task run")
|
|
if err != nil {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: harness snapshot: %w", err)
|
|
}
|
|
gateInputs.Harness, gateInputs.HarnessLock, gateInputs.HarnessDir = harness, lock, dir
|
|
}
|
|
var groundTruth []evalsubstrate.GroundTruthRow
|
|
if request.GroundTruthRef != "" {
|
|
groundTruth, _ = service.loadGroundTruth()
|
|
gateInputs.GroundTruth = groundTruth
|
|
}
|
|
refusals := evalsubstrate.RunGates(gateInputs)
|
|
if !refusals.Empty() {
|
|
return TaskRunResult{}, &TaskGateError{Message: refusals.Format(), Count: len(refusals)}
|
|
}
|
|
if request.DryRun {
|
|
return TaskRunResult{DryRun: true}, nil
|
|
}
|
|
rigID := request.RigID
|
|
if rigID == "" {
|
|
rigID = "unknown-rig"
|
|
}
|
|
manifest, err := service.taskManifest(request, task, suite, seeds, rigID, gateInputs, groundTruth)
|
|
if err != nil {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: build manifest: %w", err)
|
|
}
|
|
runID := service.Runtime.GenerateRunID(rigID)
|
|
writer, err := service.Runtime.OpenRun(service.Runtime.Root(), runID, manifest)
|
|
if err != nil {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: open run: %w", err)
|
|
}
|
|
if err := writer.Transition(evalsubstrate.StatusRunning, func(manifest *evalsubstrate.Manifest) {
|
|
manifest.ValidityGatesPassed = []string{"held_constant_declared", "min_n_samples", "ground_truth_immutable", "harness_lock_match", "multi_comparison_correction"}
|
|
}); err != nil {
|
|
return TaskRunResult{}, fmt.Errorf("eval task run: transition->running: %w", err)
|
|
}
|
|
return TaskRunResult{Manifest: writer.Manifest(), Path: writer.Path()}, nil
|
|
}
|
|
|
|
type TaskGateError struct {
|
|
Message string
|
|
Count int
|
|
}
|
|
|
|
func (failure *TaskGateError) Error() string {
|
|
return fmt.Sprintf("eval task run: %d gate refusal(s)", failure.Count)
|
|
}
|
|
|
|
func (service TaskService) loadTask(id string) (*evalsubstrate.Task, error) {
|
|
taskID, err := evalsubstrate.ParseIdentifier(evalsubstrate.IdentifierTask, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loadTask %q: %w", id, err)
|
|
}
|
|
path := filepath.Join(service.Runtime.Root(), "tasks", taskID.StorageName(), "task.yaml")
|
|
raw, err := service.Runtime.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loadTask %q: %w", id, err)
|
|
}
|
|
var task evalsubstrate.Task
|
|
if err := yaml.Unmarshal(raw, &task); err != nil {
|
|
return nil, fmt.Errorf("loadTask %q: parse: %w", id, err)
|
|
}
|
|
if task.ID != taskID.String() {
|
|
return nil, fmt.Errorf("loadTask %q: task id %q does not match requested id", id, task.ID)
|
|
}
|
|
return &task, nil
|
|
}
|
|
|
|
func (service TaskService) loadSuite(ref string) (*evalsubstrate.Suite, error) {
|
|
path := ref
|
|
if !strings.HasSuffix(ref, ".yaml") && !strings.HasSuffix(ref, ".yml") {
|
|
suiteID, err := evalsubstrate.ParseIdentifier(evalsubstrate.IdentifierSuite, ref)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loadSuite %q: %w", ref, err)
|
|
}
|
|
path = filepath.Join(service.Runtime.Root(), "suites", suiteID.StorageName(), "suite.yaml")
|
|
}
|
|
raw, err := service.Runtime.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loadSuite %q: %w", ref, err)
|
|
}
|
|
var suite evalsubstrate.Suite
|
|
if err := yaml.Unmarshal(raw, &suite); err != nil {
|
|
return nil, fmt.Errorf("loadSuite %q: parse: %w", ref, err)
|
|
}
|
|
if !strings.HasSuffix(ref, ".yaml") && !strings.HasSuffix(ref, ".yml") && suite.ID != ref {
|
|
return nil, fmt.Errorf("loadSuite %q: suite id %q does not match requested id", ref, suite.ID)
|
|
}
|
|
return &suite, nil
|
|
}
|
|
|
|
func (service TaskService) loadGroundTruth() ([]evalsubstrate.GroundTruthRow, error) {
|
|
raw, err := service.Runtime.ReadFile(filepath.Join(service.Runtime.Root(), "ground-truth", "ground-truth.jsonl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
var rows []evalsubstrate.GroundTruthRow
|
|
for decoder.More() {
|
|
var row evalsubstrate.GroundTruthRow
|
|
if err := decoder.Decode(&row); err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (service TaskService) taskManifest(request TaskRunRequest, task *evalsubstrate.Task, suite *evalsubstrate.Suite, seeds []int, rigID string, gates evalsubstrate.GateInputs, rows []evalsubstrate.GroundTruthRow) (evalsubstrate.Manifest, error) {
|
|
manifest := evalsubstrate.Manifest{TaskRef: task.ID, SuiteRef: suite.ID, HarnessRef: request.HarnessRef, ModelSpecRef: request.ModelSpecID, GroundTruthRef: request.GroundTruthRef, SampleSplit: pickTaskSplit(suite, request.SampleSplit), NSamples: pickTaskSamples(suite, request.NSamples), Seeds: seeds, RigID: rigID, InspectCommand: request.InspectCommand, InspectVersion: request.InspectVersion, QuickSession: request.QuickSession}
|
|
if gates.Harness != nil {
|
|
manifest.HarnessContentHash = gates.Harness.ContentHash
|
|
}
|
|
if request.ModelSpecID != "" {
|
|
if _, err := evalsubstrate.ParseIdentifier(evalsubstrate.IdentifierModel, request.ModelSpecID); err != nil {
|
|
return evalsubstrate.Manifest{}, fmt.Errorf("model spec: %w", err)
|
|
}
|
|
if spec, err := service.Runtime.LoadModelSpec(service.Runtime.Root(), request.ModelSpecID); err == nil {
|
|
manifest.ModelSpecHash = spec.ContentHash
|
|
}
|
|
}
|
|
groundTruthHash, err := taskGroundTruthHash(rows, request.GroundTruthRef)
|
|
if err != nil {
|
|
return evalsubstrate.Manifest{}, err
|
|
}
|
|
manifest.GroundTruthHash = groundTruthHash
|
|
if count := len(suite.VariedAxis.Values); count > 2 {
|
|
manifest.MultiComparisonMethod, manifest.ComparisonFamily, manifest.ReferenceArm, manifest.FamilySizeK = suite.Stats.MultiComparisonMethod, suite.Stats.ComparisonFamily, suite.Stats.ReferenceArm, evalsubstrate.FamilySizeK(suite.Stats.ComparisonFamily, count)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func parseTaskSeeds(value string) ([]int, error) {
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
parts := strings.Split(value, ",")
|
|
seeds := make([]int, 0, len(parts))
|
|
for _, part := range parts {
|
|
var seed int
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(part), "%d", &seed); err != nil {
|
|
return nil, fmt.Errorf("parseSeeds: bad seed %q: %w", part, err)
|
|
}
|
|
seeds = append(seeds, seed)
|
|
}
|
|
return seeds, nil
|
|
}
|
|
func pickTaskSplit(suite *evalsubstrate.Suite, override string) string {
|
|
if override != "" {
|
|
return override
|
|
}
|
|
if suite != nil && suite.SampleSplit != "" {
|
|
return suite.SampleSplit
|
|
}
|
|
return "dev"
|
|
}
|
|
func pickTaskSamples(suite *evalsubstrate.Suite, override int) int {
|
|
if override > 0 {
|
|
return override
|
|
}
|
|
if suite != nil {
|
|
return suite.NSamples
|
|
}
|
|
return 0
|
|
}
|
|
func taskGroundTruthHash(rows []evalsubstrate.GroundTruthRow, ref string) (string, error) {
|
|
if ref == "" || len(rows) == 0 {
|
|
return "", nil
|
|
}
|
|
var data []byte
|
|
for _, row := range rows {
|
|
if row.ID == ref || row.Supersedes == ref {
|
|
encoded, err := json.Marshal(row)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal ground truth row %q: %w", row.ID, err)
|
|
}
|
|
canonical, err := evalsubstrate.CanonicalizeJSON(encoded)
|
|
if err != nil {
|
|
canonical = encoded
|
|
}
|
|
data = append(data, canonical...)
|
|
}
|
|
}
|
|
if len(data) == 0 {
|
|
return "", nil
|
|
}
|
|
return evalsubstrate.ContentHash(data), nil
|
|
}
|