From 5b3701d6e2d99ba277bfe7321692b9cc62b95b78 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:26:30 -0600 Subject: [PATCH] =?UTF-8?q?test(mode):=20red=20for=20#22=20=E2=80=94=20mod?= =?UTF-8?q?eQuestions,=20ClassifyMode=20guardrail=20wrapper,=20record=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EVENTS.md | 3 ++ mode_test.go | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 mode_test.go diff --git a/EVENTS.md b/EVENTS.md index 2159c31..da99218 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -58,6 +58,9 @@ Every nest event carries `nest_id`, `ts` (RFC 3339) and one message record. The | `to` | string | The @-addressed member, or `""`. `deliver`/`claim` default to the requester they answer. | | `refers_to` | string | The `msg_id` this responds to (claim/deliver/nudge point at their request/expectation), or `""`. | | `tagged_by` | string | How `kind` was decided: `"jev"`, `"header"`, `"disagreement"` (Jev overrode the crow's header) or `"default"` (nothing fit, became `say`). | +| `mode` | string | On a `request` only: how it should be taken up — `solo`, `conspiracy` (several crows, one task, distinct readings), `split`, or `""` (not yet classified). Tagged Jev-first with a `mode` header escape (`jev_test.go`, `Flock.ClassifyMode`), same rule shape as `kind`. | +| `mode_tagged_by` | string | How `mode` was decided, same four sources as `tagged_by`. | +| `invited` | int | Conspiracy only: how many crows the request expects to take it up (default from the `MurderSize` cap). | ### SSE event diff --git a/mode_test.go b/mode_test.go new file mode 100644 index 0000000..8bf0cd4 --- /dev/null +++ b/mode_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func fakeModeJev(t *testing.T, choice string, conf float64) *JevClient { + t.Helper() + return fakeJevFor(t, map[string]struct { + choice string + conf float64 + }{"mode": {choice, conf}}) +} + +// fakeJevFor answers only the named question keys; any other question in the +// same request gets an empty answer (so callers fall back to headers). +func fakeJevFor(t *testing.T, answers map[string]struct { + choice string + conf 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 { + if a, ok := answers[q]; ok { + out[q] = map[string]any{"type": "choice", "choice": a.choice, "confidence": a.conf, + "probabilities": map[string]float64{a.choice: a.conf}} + } + } + json.NewEncoder(w).Encode(map[string]any{"model": "jev-1.14.0", "answers": out, + "usage": map[string]int{"input_tokens": 60, "output_tokens": 0}}) + })) + t.Cleanup(srv.Close) + return &JevClient{url: srv.URL, key: "tk", model: "jev-latest", threshold: 0.7, client: srv.Client()} +} + +func TestModeQuestionsExist(t *testing.T) { + m := modeQuestions["mode"].(map[string]any) + if m["type"] != "choice" { + t.Fatalf("mode must be a choice question, got %v", m["type"]) + } + crit := m["criteria"].(map[string]string) + for _, k := range []string{"solo", "conspiracy", "split", "other"} { + if _, ok := crit[k]; !ok { + t.Errorf("mode criteria missing %q escape/class: %v", k, crit) + } + } +} + +func TestClassifyMode(t *testing.T) { + dir := t.TempDir() + flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl")) + if err != nil { + t.Fatal(err) + } + defer flog.Close() + f := NewFlock(MockBrain{}, DefaultGuardrails(), flog) + f.SetClassifier(fakeModeJev(t, "conspiracy", 0.9)) + got, ok := f.ClassifyMode(context.Background(), "Should we launch a CLI or a web app first?") + if !ok || got != "conspiracy" { + t.Fatalf("want conspiracy ok, got %q ok=%v", got, ok) + } + evs, err := ReadEvents(filepath.Join(dir, "flight.jsonl")) + if err != nil { + t.Fatal(err) + } + modes := 0 + for _, e := range evs { + if e.Kind == "mode" && e.Mode == "conspiracy" && e.Prob != nil { + modes++ + } + } + if modes != 1 { + t.Fatalf("want one logged mode verdict with probs, events: %+v", evs) + } +} + +func TestClassifyModeUnsureNotOk(t *testing.T) { + f := NewFlock(MockBrain{}, DefaultGuardrails(), nil) + f.SetClassifier(fakeModeJev(t, "split", 0.4)) + if got, ok := f.ClassifyMode(context.Background(), "hi"); ok || got != "" { + t.Fatalf("below-band confidence must not be ok, got %q ok=%v", got, ok) + } +} + +func TestMessageModeFields(t *testing.T) { + m := Message{NestID: "lab", Kind: "request", Mode: "conspiracy", Invited: 3} + if m.Mode != "conspiracy" || m.Invited != 3 { + t.Fatalf("Message must carry mode + invited, got %+v", m) + } +} + +func TestPostRequestJevMode(t *testing.T) { + ts, s := newTestNestServer(t) + s.p.Flock.SetClassifier(fakeModeJev(t, "conspiracy", 0.9)) + id := newNest(t, ts, "Lab", `"Hugin"`) + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","text":"should we launch a CLI or web app"}`) + if code != http.StatusCreated || req["mode"] != "conspiracy" || req["mode_tagged_by"] != "jev" { + t.Fatalf("want conspiracy/jev, got %d %v", code, req) + } + stored := s.msgs.All(id)[0] + if stored.Mode != "conspiracy" || stored.ModeTagged != "jev" { + t.Fatalf("record not persisted with mode: %+v", stored) + } +} + +func TestPostRequestModeHeaderFallback(t *testing.T) { + ts, _ := newTestNestServer(t) // no Jev set + id := newNest(t, ts, "Lab", `"Hugin"`) + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","mode":"split","text":"do both parts"}`) + if code != http.StatusCreated || req["mode"] != "split" || req["mode_tagged_by"] != "header" { + t.Fatalf("want split/header, got %d %v", code, req) + } +} + +func TestSayHasNoMode(t *testing.T) { + ts, s := newTestNestServer(t) + s.p.Flock.SetClassifier(fakeModeJev(t, "conspiracy", 0.9)) + id := newNest(t, ts, "Lab", `"Hugin"`) + code, say := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"say","text":"nice weather"}`) + if code != http.StatusCreated { + t.Fatalf("say: %d", code) + } + if _, ok := say["mode"]; ok { + t.Fatalf("a say must not carry a mode: %v", say) + } +}