170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeDispatchJev answers kind/mode as choice and restates_earlier as noul.
|
|
func fakeDispatchJev(t *testing.T) *JevClient {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Questions map[string]any `json:"questions"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
out := map[string]any{}
|
|
for q := range body.Questions {
|
|
switch q {
|
|
case "kind":
|
|
out[q] = map[string]any{"type": "choice", "choice": "say", "confidence": 0.5}
|
|
case "mode":
|
|
out[q] = map[string]any{"type": "choice", "choice": "conspiracy", "confidence": 0.9,
|
|
"probabilities": map[string]float64{"conspiracy": 0.9, "solo": 0.1}}
|
|
case "restates_earlier":
|
|
out[q] = map[string]any{"type": "noul", "noul": 0.85}
|
|
}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"model": "jev-fake", "answers": out,
|
|
"usage": map[string]int{"input_tokens": 50, "output_tokens": 0}})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return &JevClient{url: srv.URL, key: "tk", model: "jev-fake", threshold: 0.7, client: srv.Client()}
|
|
}
|
|
|
|
// peakBrain tracks concurrent model calls.
|
|
type peakBrain struct {
|
|
mu sync.Mutex
|
|
peak, now int32
|
|
}
|
|
|
|
func (b *peakBrain) Name() string { return "peak" }
|
|
func (b *peakBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
|
|
c := atomic.AddInt32(&b.now, 1)
|
|
if c > atomic.LoadInt32(&b.peak) {
|
|
atomic.StoreInt32(&b.peak, c)
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
atomic.AddInt32(&b.now, -1)
|
|
return "KIND: deliver\n[a] and [b] matter", 10, nil
|
|
}
|
|
|
|
func newDispatchServer(t *testing.T, g Guardrails) (*Server, string, *peakBrain) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
ledger, _ := LoadLedger(filepath.Join(dir, "ledger.json"))
|
|
flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { flog.Close() })
|
|
nests, _ := LoadNests(filepath.Join(dir, "nests.json"))
|
|
msgs, _ := LoadMessageStore(filepath.Join(dir, "messages"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
pb := &peakBrain{}
|
|
appr := NewWebApprover(time.Minute)
|
|
p := &Pipeline{Flock: NewFlock(pb, g, flog), Ledger: ledger, Log: flog, G: g, Approver: appr}
|
|
p.Flock.SetClassifier(fakeDispatchJev(t))
|
|
s := NewServer(ctx, p, appr, nests).WithMessages(msgs)
|
|
return s, filepath.Join(dir, "flight.jsonl"), pb
|
|
}
|
|
|
|
func TestDispatchCallsGoThroughFlock(t *testing.T) {
|
|
g := DefaultGuardrails()
|
|
flog := ""
|
|
dir := t.TempDir()
|
|
lg, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lg.Close()
|
|
flog = filepath.Join(dir, "flight.jsonl")
|
|
f := NewFlock(MockBrain{}, g, lg)
|
|
f.SetClassifier(fakeDispatchJev(t))
|
|
|
|
mode, ok := f.ClassifyMode(context.Background(), "is this risky")
|
|
if !ok || mode != "conspiracy" {
|
|
t.Fatalf("want conspiracy from the fake, got %q ok=%v", mode, ok)
|
|
}
|
|
evs, _ := ReadEvents(flog)
|
|
probLogged := false
|
|
for _, e := range evs {
|
|
if e.Kind == "mode" && e.Prob != nil {
|
|
probLogged = true
|
|
}
|
|
}
|
|
if !probLogged {
|
|
t.Fatal("ClassifyMode must write a flight-log event with probabilities")
|
|
}
|
|
|
|
// Saturate the flock semaphore: a wrapper call must wait for a slot,
|
|
// proving it goes through Flock (never a direct client call).
|
|
for i := 0; i < g.MaxCrowsInFlight; i++ {
|
|
f.sem <- struct{}{}
|
|
}
|
|
cctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
|
defer cancel()
|
|
_, blockedOk := f.ClassifyMode(cctx, "risky?")
|
|
if blockedOk {
|
|
t.Fatal("a saturated flock must not answer (semaphore not enforced)")
|
|
}
|
|
for i := 0; i < g.MaxCrowsInFlight; i++ {
|
|
<-f.sem
|
|
}
|
|
}
|
|
|
|
func TestConspiracyInviteCapInFlight(t *testing.T) {
|
|
g := DefaultGuardrails()
|
|
g.MaxCrowsInFlight = 1
|
|
s, _, _ := newDispatchServer(t, g)
|
|
id := newNest(t, serve(s), "Wide", `"Hugin","Munin","Soot"`)
|
|
promote(s, "Hugin", "Munin", "Soot")
|
|
// inviteCount caps at MaxCrowsInFlight=1 -> at most one invited
|
|
if n := s.inviteCount(mustNest(s, id), 4); n != 1 {
|
|
t.Fatalf("want invite capped to 1 by in-flight limit, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestDispatchGuardrailsEndToEnd(t *testing.T) {
|
|
g := DefaultGuardrails()
|
|
g.MaxCrowsInFlight = 2
|
|
s, logPath, pb := newDispatchServer(t, g)
|
|
ts := serve(s)
|
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
|
promote(s, "Hugin", "Munin")
|
|
code, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
|
`{"author":"owner","kind":"request","text":"is this launch risky"}`)
|
|
if code != http.StatusCreated || req["mode"] != "conspiracy" || req["tagged_by"] != "header" {
|
|
t.Fatalf("post: %d %v", code, req)
|
|
}
|
|
awaitMessages(t, ts, id, 3) // request + 2 lens claims
|
|
|
|
evs, err := ReadEvents(logPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kinds := map[string]int{}
|
|
for _, e := range evs {
|
|
kinds[e.Kind]++
|
|
}
|
|
if kinds["mode"] == 0 || kinds["tag"] == 0 || kinds["flight"] == 0 {
|
|
t.Fatalf("dispatch decisions must be logged (mode/tag/flight), got %v", kinds)
|
|
}
|
|
if pb.peak > 2 {
|
|
t.Fatalf("flock cap violated: peak concurrent calls %d > 2", pb.peak)
|
|
}
|
|
}
|
|
|
|
func mustNest(s *Server, id string) Nest {
|
|
n, _ := s.nests.Get(id)
|
|
return n
|
|
}
|