From 92a7ff35d06cfc5175b6f025e5a3cded6aa5678d Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:33:52 -0600 Subject: [PATCH 1/2] =?UTF-8?q?test(conspiracy):=20red=20for=20#24=20?= =?UTF-8?q?=E2=80=94=20distinct=20lens=20invites,=20capped=20to=20MurderSi?= =?UTF-8?q?ze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- conspiracy_test.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 conspiracy_test.go diff --git a/conspiracy_test.go b/conspiracy_test.go new file mode 100644 index 0000000..20f1609 --- /dev/null +++ b/conspiracy_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// recordBrain stores every (system,prompt) it is asked; replies as a claim. +type recordBrain struct { + mu sync.Mutex + pairs [][2]string +} + +func (b *recordBrain) Name() string { return "recorder" } +func (b *recordBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + b.pairs = append(b.pairs, [2]string{system, prompt}) + b.mu.Unlock() + return "KIND: claim\nOn it.", 10, nil +} + +func newRecordServer(t *testing.T) (*httptest.Server, *Server, *recordBrain) { + t.Helper() + ts, s := newTestNestServer(t) + rb := &recordBrain{} + s.p.Flock.brain = rb + return ts, s, rb +} + +func TestConspiracyInvitesDistinctLenses(t *testing.T) { + ts, _, rb := newRecordServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`) + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","mode":"conspiracy","text":"is this launch risky"}`) + if code != http.StatusCreated || req["mode"] != "conspiracy" { + t.Fatalf("post: %d %v", code, req) + } + awaitMessages(t, ts, id, 4) // request + 3 lens claims + + rb.mu.Lock() + defer rb.mu.Unlock() + if len(rb.pairs) != 3 { + t.Fatalf("want exactly 3 invited prompts, got %d", len(rb.pairs)) + } + seen := map[string]bool{} + for _, pr := range rb.pairs { + if !strings.Contains(pr[1], conspiracyLine) { + t.Fatalf("every invited prompt must carry the lens line: %q", pr[1]) + } + hits := 0 + for _, name := range []string{"Hugin", "Munin", "Soot"} { + if strings.Contains(pr[1], "("+name+")") { + hits++ + } + } + if hits != 1 { + t.Fatalf("prompt should name exactly one crow's lens, got %d in %q", hits, pr[1]) + } + if seen[pr[1]] { + t.Fatal("two invited prompts were identical") + } + seen[pr[1]] = true + } +} + +func TestConspiracyInviteCapped(t *testing.T) { + ts, _, rb := newRecordServer(t) + id := newNest(t, ts, "Wide", strings.Join(quoteAll(roster), ",")) + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","mode":"conspiracy","invited":4,"text":"risk"}`) + if code != http.StatusCreated { + t.Fatalf("post: %d %v", code, out) + } + awaitMessages(t, ts, id, 4) + rb.mu.Lock() + n := len(rb.pairs) + rb.mu.Unlock() + if n != 3 { // min(invited 4, MurderSize 3) + t.Fatalf("want invite capped to MurderSize=3, got %d", n) + } +} + +func quoteAll(xs []string) []string { + out := make([]string, len(xs)) + for i, x := range xs { + out[i] = `"` + x + `"` + } + return out +} -- 2.54.0 From d45fb64eff04fb075a43e547fee0eb0a75d8e769 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:33:52 -0600 Subject: [PATCH 2/2] feat(conspiracy): invite N crows, each from its own persona lens (#24) --- nestapi.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/nestapi.go b/nestapi.go index 5af1c3c..8affd54 100644 --- a/nestapi.go +++ b/nestapi.go @@ -82,6 +82,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { Text string `json:"text"` Kind string `json:"kind"` Mode string `json:"mode"` + Invited int `json:"invited"` To string `json:"to"` RefersTo string `json:"refers_to"` } @@ -132,6 +133,13 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { // Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure. mode, modeBy := s.tagMode(kind, body.Mode, body.Text) + // A conspiracy expects several crows on the one task; the invite count is + // capped by MurderSize and the flock's in-flight limit. + invited := 0 + if mode == "conspiracy" { + invited = s.inviteCount(nest, body.Invited) + } + // Claim arbitration (#5, Epic C): first claim wins unless the referent is a // conspiracy, which expects multiple claims. if kind == "claim" && body.RefersTo != "" { @@ -153,6 +161,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { TaggedBy: taggedBy, Mode: mode, ModeTagged: modeBy, + Invited: invited, Timestamp: time.Now().UTC().Format(time.RFC3339), } record, err := s.msgs.Append(msg) @@ -163,10 +172,16 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { s.emitNestMessage(record) - // Event-driven trigger: a request addressed to a member crow gets one - // response (must-respond) or a Sentry flag. - if kind == "request" && slices.Contains(nest.Crows, record.To) { - go s.respondToRequest(nest, record) + // Event-driven trigger (no idle loops): + // conspiracy -> several invited crows, each from its own lens + // any other request addressed to a member crow -> one must-respond reply + if kind == "request" { + switch { + case mode == "conspiracy" && invited > 0: + go s.respondConspiracy(nest, record, invited) + case slices.Contains(nest.Crows, record.To): + go s.respondToRequest(nest, record) + } } writeJSON(w, http.StatusCreated, nestRecord(record)) @@ -215,15 +230,52 @@ func (s *Server) emitNestMessage(m Message) { s.hub.PublishNest(m.NestID, "nest.message", rec) } -// respondToRequest is the must-respond rule: the addressed crow answers with -// claim, deliver or decline. A reply that isn't one of those is retried once -// with a stricter ask; still nothing responsive and the crow gets a flag from -// the Sentry (no crow reply is posted). +// inviteCount is how many crows a conspiracy asks to take the one task, capped +// by MurderSize, the flock's in-flight limit, and the nest's own size. +func (s *Server) inviteCount(nest Nest, requested int) int { + cap := s.p.G.MurderSize + if s.p.G.MaxCrowsInFlight < cap { + cap = s.p.G.MaxCrowsInFlight + } + if requested > 0 && requested < cap { + cap = requested + } + if len(nest.Crows) < cap { + cap = len(nest.Crows) + } + return cap +} + +// respondConspiracy invites N crows; each answers from its own persona lens so +// distinct strengths produce distinct readings. +func (s *Server) respondConspiracy(nest Nest, req Message, invited int) { + crows := s.p.Flock.pick(nest, invited) + for _, crow := range crows { + go s.respondOne(nest, req, crow, true) + } +} + +// conspiracyLine is the lens directive added to every invited crow's prompt. +const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus." + func (s *Server) respondToRequest(nest Nest, req Message) { - crow := req.To - budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem} + s.respondOne(nest, req, req.To, false) +} + +// respondOne is the must-respond rule for a single crow: answer with claim, +// deliver or decline. A non-responsive reply is retried once with a stricter +// ask; still nothing responsive and the crow gets a Sentry flag (no reply). +func (s *Server) respondOne(nest Nest, req Message, crow string, lens bool) { + budget := &Budget{name: "nest-reply", limit: s.p.G.TokenBudgetPerItem} system := systemFor(crow, nestReplySystem, false) prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nRespond to " + req.Author + ".\nTASK: " + req.Text + if lens { + strength := "" + if p, ok := personaOf(crow); ok { + strength = p.Strength + } + prompt += "\n" + conspiracyLine + "\nYour strength: " + strength + " (" + crow + ")." + } kind, body, ok := s.nestReply(system, prompt, crow, budget) if !ok { -- 2.54.0