diff --git a/divergence_test.go b/divergence_test.go new file mode 100644 index 0000000..2f0f7be --- /dev/null +++ b/divergence_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// fakeNoulJev answers every question in a request with a fixed noul probability. +func fakeNoulJev(t *testing.T, noul float64) *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 { + out[q] = map[string]any{"type": "noul", "noul": noul} + } + json.NewEncoder(w).Encode(map[string]any{"model": "jev-1.14.0", "answers": out, + "usage": map[string]int{"input_tokens": 40, "output_tokens": 0}}) + })) + t.Cleanup(srv.Close) + return &JevClient{url: srv.URL, key: "tk", model: "jev-latest", threshold: 0.7, client: srv.Client()} +} + +func postDeliver(t *testing.T, ts *httptest.Server, id, author, refersTo, text string) string { + t.Helper() + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"`+author+`","kind":"deliver","refers_to":"`+refersTo+`","text":"`+text+`"}`) + if code != http.StatusCreated { + t.Fatalf("deliver %s: %d %v", author, code, out) + } + return out["msg_id"].(string) +} + +func findNudge(ts *httptest.Server, id, target string) map[string]any { + for _, m := range nestMessagesNoT(ts, id) { + if m["kind"] == "nudge" && m["refers_to"] == target { + return m + } + } + return nil +} + +func nestMessagesNoT(ts *httptest.Server, id string) []map[string]any { + resp, err := http.Get(ts.URL + "/api/nests/" + id + "/messages") + if err != nil { + return nil + } + defer resp.Body.Close() + var msgs []map[string]any + json.NewDecoder(resp.Body).Decode(&msgs) + return msgs +} + +func waitForNudge(ts *httptest.Server, id, target string) map[string]any { + deadline := time.Now().Add(2 * time.Second) + for { + if n := findNudge(ts, id, target); n != nil { + return n + } + if time.Now().After(deadline) { + return nil + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestRestatementGetsNudge(t *testing.T) { + ts, s := newTestNestServer(t) + s.p.Flock.SetClassifier(fakeNoulJev(t, 0.8)) // above band 0.7 + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "conspiracy", "is this risky") + postDeliver(t, ts, id, "Hugin", r, "ship it") + d2 := postDeliver(t, ts, id, "Munin", r, "also ship it") + + n := waitForNudge(ts, id, d2) + if n == nil { + t.Fatalf("restated deliver must get a nudge, log: %+v", s.msgs.All(id)) + } + if n["to"] != "Munin" || n["author"] != "harness" { + t.Fatalf("nudge must address the restating crow from the harness, got %v", n) + } + if !containsMsg(s.msgs.All(id), d2) { + t.Fatal("the restated deliver itself must stay in the log") + } +} + +func TestDistinctDeliversNoNudge(t *testing.T) { + ts, s := newTestNestServer(t) + s.p.Flock.SetClassifier(fakeNoulJev(t, 0.2)) // below band + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "conspiracy", "is this risky") + postDeliver(t, ts, id, "Hugin", r, "ship it") + d2 := postDeliver(t, ts, id, "Munin", r, "wait for the audit") + time.Sleep(300 * time.Millisecond) + if n := findNudge(ts, id, d2); n != nil { + t.Fatalf("a distinct deliver must not be nudged, got %v", n) + } +} + +func TestNudgeSpendsNoModelTokens(t *testing.T) { + ts, s := newTestNestServer(t) + s.p.Flock.SetClassifier(fakeNoulJev(t, 0.8)) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "conspiracy", "is this risky") + postDeliver(t, ts, id, "Hugin", r, "ship it") + + d2, err := s.msgs.Append(Message{NestID: id, Kind: "deliver", Author: "Munin", Text: "ship", RefersTo: r, Timestamp: time.Now().UTC().Format(time.RFC3339)}) + if err != nil { + t.Fatal(err) + } + b := &Budget{name: "nest", limit: 1} + s.checkDivergence(nestOf(s, id), d2, b) + if b.Spent() != 0 { + t.Fatalf("a review nudge must spend no model tokens, spent=%d", b.Spent()) + } +} + +func containsMsg(msgs []Message, msgID string) bool { + for _, m := range msgs { + if m.MsgID() == msgID { + return true + } + } + return false +}