Files
Maven/cmd/mavend/eval_scenarios_test.go
T
kami 122d0bb55c Add daemon-side safety scenario tests driven by a JSON fixture
The review's verification plan needs an instrument to settle whether the
classifier or the LLM router handles RU queries better, but that comparison
only means something if the daemon's own safety invariants are pinned
independently first.

These scenarios are deliberately narrow. They consume already-normalized
router decisions and assert what the post-router daemon owns: that a decision
requiring confirmation cannot execute before it is confirmed, that an
unresolved entity is never guessed at, and that named capabilities stay
unexecuted. Model routing quality is a separate question, evaluated against a
held-out contract fixture — mixing the two would produce a suite that fails for
two unrelated reasons.

The fixture is versioned (schema_version) so scenarios can be added without
rewriting the loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:49:22 +04:00

162 lines
6.5 KiB
Go

package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
)
// safetyScenario is deliberately narrow: it records only invariants the
// post-router daemon owns. Model routing is evaluated separately against the
// held-out contract fixture; these tests consume already-normalized decisions
// and prove the daemon cannot turn an unsafe decision into an unsafe effect.
type safetyScenario struct {
ID string `json:"id"`
ConfirmationRequired bool `json:"confirmation_required"`
MustNotExecuteBeforeConfirmation bool `json:"must_not_execute_before_confirmation"`
MustNotGuessEntity bool `json:"must_not_guess_entity"`
MustNotExecute []string `json:"must_not_execute"`
ExpectedEffect string `json:"expected_effect"`
}
type safetyScenarioFile struct {
SchemaVersion int `json:"schema_version"`
Scenarios []safetyScenario `json:"scenarios"`
}
func loadSafetyScenarios(t *testing.T) []safetyScenario {
t.Helper()
b, err := os.ReadFile("testdata/system_safety_scenarios.json")
if err != nil {
t.Fatal(err)
}
var fixture safetyScenarioFile
if err := json.Unmarshal(b, &fixture); err != nil {
t.Fatal(err)
}
if fixture.SchemaVersion != 1 || len(fixture.Scenarios) == 0 {
t.Fatalf("invalid safety fixture: version=%d cases=%d", fixture.SchemaVersion, len(fixture.Scenarios))
}
return fixture.Scenarios
}
func newSafetyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
return &reactiveHandler{
api: api,
dataStore: st,
now: func() time.Time { return now },
tools: tool.NewExecutor(api, time.Second),
}, st
}
// TestSystemSafetyScenarios is the executable half of the evaluation-lab
// safety fixture. It is intentionally all local: temporary SQLite, fake
// Nexus/Hexis and a harmless `touch` command. No LLM, GPU or real service is
// used, so it can run in normal Go CI while model training is in progress.
func TestSystemSafetyScenarios(t *testing.T) {
ctx := context.Background()
for _, scenario := range loadSafetyScenarios(t) {
scenario := scenario
t.Run(scenario.ID, func(t *testing.T) {
switch scenario.ID {
case "safety-001":
if !scenario.ConfirmationRequired || !scenario.MustNotExecuteBeforeConfirmation {
t.Fatal("fixture must require confirmation before destructive execution")
}
h, st := newSafetyHandler(t)
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
t.Fatal(err)
}
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "delete_backups", HasFn: true}})
if !strings.Contains(reply, "да") || h.pending == nil {
t.Fatalf("destructive action must park a confirmation, reply=%q pending=%+v", reply, h.pending)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("destructive command ran before confirmation: %v", err)
}
if reply, handled := h.resolveConfirm(ctx, "да"); !handled || !strings.Contains(reply, "готово") {
t.Fatalf("confirmed action did not execute: handled=%v reply=%q", handled, reply)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("confirmed destructive command did not run: %v", err)
}
case "safety-002":
if !scenario.MustNotGuessEntity {
t.Fatal("fixture must prohibit guessing an ambiguous entity")
}
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
map[string]string{"entity_id": "ent_indexer_a", "display_name": "Indexer A"},
map[string]string{"entity_id": "ent_indexer_b", "display_name": "Indexer B"},
))
hexis := newFakeHexis(t, fixtureHexisCapabilities(map[string]any{"id": "restart", "name": "restart", "read_only": false}), fixtureHexisExecuted("exec_1", "succeeded"))
h, _ := newSafetyHandler(t)
h.ecosystem = stubEcosystem(nexus.URL, hexis.URL)
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "indexer"}})
if !strings.Contains(reply, "Indexer A") || !strings.Contains(reply, "Indexer B") {
t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply)
}
for _, request := range hexis.Requests() {
if request.Path == "/api/v1/execute" {
t.Fatal("ambiguous entity must not execute a Hexis capability")
}
}
case "safety-003":
if len(scenario.MustNotExecute) == 0 {
t.Fatal("fixture must name forbidden effects")
}
h, st := newSafetyHandler(t)
forbiddenMarker := filepath.Join(t.TempDir(), "forbidden-delete-ran")
if err := st.EnableTool(ctx, "delete", []string{"touch", forbiddenMarker}, true, "test", h.now()); err != nil {
t.Fatal(err)
}
if err := st.EnableTool(ctx, "check_disk", []string{"true"}, false, "test", h.now()); err != nil {
t.Fatal(err)
}
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "check_disk", HasFn: true}})
if !strings.Contains(reply, "готово") {
t.Fatalf("safe inspection should execute, got %q", reply)
}
if _, err := os.Stat(forbiddenMarker); !os.IsNotExist(err) {
t.Fatalf("negative constraint was violated: %v", err)
}
case "safety-004":
if scenario.ExpectedEffect != "routine_completion_evidence" {
t.Fatalf("unexpected fact scenario effect %q", scenario.ExpectedEffect)
}
h, st := newSafetyHandler(t)
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentFact, Slots: router.Slots{Key: "medicine", Value: "принял", HasKey: true}})
if reply != "" {
t.Fatalf("fact capture should leave phrasing to the replier, got %q", reply)
}
fact, err := st.LatestFact(ctx, "medicine")
if err != nil || fact.Value != "принял" {
t.Fatalf("expected completion evidence fact, fact=%+v err=%v", fact, err)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(24*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("fact must not create a duplicate reminder: reminders=%v err=%v", reminders, err)
}
default:
t.Fatalf("safety fixture case %q has no executable implementation", scenario.ID)
}
})
}
}