Merge pull request #34 from jacisjake/task/27-trust-gated-speaking
(#27) Trust-gated speaking + directed assignment
This commit was merged in pull request #34.
This commit is contained in:
5 files changed
+183
-7
No files matched your search
+4
-2
@@ -32,8 +32,9 @@ func newRecordServer(t *testing.T) (*httptest.Server, *Server, *recordBrain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConspiracyInvitesDistinctLenses(t *testing.T) {
|
func TestConspiracyInvitesDistinctLenses(t *testing.T) {
|
||||||
ts, _, rb := newRecordServer(t)
|
ts, srv, rb := newRecordServer(t)
|
||||||
id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`)
|
id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`)
|
||||||
|
promote(srv, "Hugin", "Munin", "Soot") // earned the right to take open work
|
||||||
code, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
code, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
`{"author":"owner","kind":"request","mode":"conspiracy","text":"is this launch risky"}`)
|
`{"author":"owner","kind":"request","mode":"conspiracy","text":"is this launch risky"}`)
|
||||||
if code != http.StatusCreated || req["mode"] != "conspiracy" {
|
if code != http.StatusCreated || req["mode"] != "conspiracy" {
|
||||||
@@ -68,8 +69,9 @@ func TestConspiracyInvitesDistinctLenses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConspiracyInviteCapped(t *testing.T) {
|
func TestConspiracyInviteCapped(t *testing.T) {
|
||||||
ts, _, rb := newRecordServer(t)
|
ts, srv, rb := newRecordServer(t)
|
||||||
id := newNest(t, ts, "Wide", strings.Join(quoteAll(roster), ","))
|
id := newNest(t, ts, "Wide", strings.Join(quoteAll(roster), ","))
|
||||||
|
promote(srv, roster...)
|
||||||
code, out := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
code, out := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
`{"author":"owner","kind":"request","mode":"conspiracy","invited":4,"text":"risk"}`)
|
`{"author":"owner","kind":"request","mode":"conspiracy","invited":4,"text":"risk"}`)
|
||||||
if code != http.StatusCreated {
|
if code != http.StatusCreated {
|
||||||
|
|||||||
+36
-5
@@ -146,9 +146,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
|
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
|
||||||
// conspiracy, which expects multiple claims.
|
// conspiracy, which expects multiple claims. Directed assignment holds:
|
||||||
|
// while the asked crow has not answered, only it may claim.
|
||||||
if kind == "claim" && body.RefersTo != "" {
|
if kind == "claim" && body.RefersTo != "" {
|
||||||
if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" {
|
if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" {
|
||||||
|
if slices.Contains(nest.Crows, origin.To) && origin.To != body.Author && !s.msgs.Answered(origin.NestID, origin.MsgID(), origin.To) {
|
||||||
|
httpError(w, http.StatusConflict, origin.To+" was asked to take this; it answers or declines first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed {
|
if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed {
|
||||||
httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.")
|
httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.")
|
||||||
return
|
return
|
||||||
@@ -188,6 +193,8 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
go s.respondElder(nest, record)
|
go s.respondElder(nest, record)
|
||||||
case slices.Contains(nest.Crows, record.To):
|
case slices.Contains(nest.Crows, record.To):
|
||||||
go s.respondToRequest(nest, record)
|
go s.respondToRequest(nest, record)
|
||||||
|
default:
|
||||||
|
go s.respondOpenRequest(nest, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,15 +264,39 @@ func (s *Server) inviteCount(nest Nest, requested int) int {
|
|||||||
return cap
|
return cap
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondConspiracy invites N crows; each answers from its own persona lens so
|
// respondConspiracy invites N eligible crows; each answers from its own
|
||||||
// distinct strengths produce distinct readings.
|
// persona lens so distinct strengths produce distinct readings. Strangers are
|
||||||
|
// skipped: unprompted speech is earned (trust ladder, section 10).
|
||||||
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
|
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
|
||||||
crows := s.p.Flock.pick(nest, invited)
|
n := 0
|
||||||
for _, crow := range crows {
|
for _, crow := range nest.Crows {
|
||||||
|
if n >= invited {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !s.maySpeakUnprompted(crow) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
go s.respondOne(nest, req, crow, true)
|
go s.respondOne(nest, req, crow, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// respondOpenRequest surfaces an unaddressed request to one regular-and-up
|
||||||
|
// crow as a claim opportunity (section 5: a regular may claim open requests).
|
||||||
|
func (s *Server) respondOpenRequest(nest Nest, req Message) {
|
||||||
|
for _, crow := range nest.Crows {
|
||||||
|
if s.maySpeakUnprompted(crow) {
|
||||||
|
go s.respondOne(nest, req, crow, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maySpeakUnprompted is the trust gate for open (unaddressed) work.
|
||||||
|
func (s *Server) maySpeakUnprompted(crow string) bool {
|
||||||
|
return s.p.Ledger.Level(crow, "other") > Stranger
|
||||||
|
}
|
||||||
|
|
||||||
// conspiracyLine is the lens directive added to every invited crow's prompt.
|
// 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."
|
const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus."
|
||||||
|
|
||||||
|
|||||||
@@ -206,3 +206,11 @@ func TestDeliverAddressesRequester(t *testing.T) {
|
|||||||
t.Fatalf("deliver must go back to the requester, got to=%v", d["to"])
|
t.Fatalf("deliver must go back to the requester, got to=%v", d["to"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promote earns crows the right to speak unprompted (trust ladder): their own
|
||||||
|
// record on task type "other" reaches Regular.
|
||||||
|
func promote(s *Server, crows ...string) {
|
||||||
|
for _, c := range crows {
|
||||||
|
s.p.Ledger.Crows[c+"|other"] = &Record{Approved: 10, Streak: 10, Level: Regular}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -119,6 +119,18 @@ func (s *MessageStore) ByMsgID(msgID string) (Message, bool) {
|
|||||||
return Message{}, false
|
return Message{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Answered reports whether crow has claimed, delivered or declined the referent.
|
||||||
|
func (s *MessageStore) Answered(nestID, referentID, crow string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, m := range s.log[nestID] {
|
||||||
|
if m.Author == crow && m.RefersTo == referentID && responsiveKind[m.Kind] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FirstClaim returns the author of the first claim against a message, and
|
// FirstClaim returns the author of the first claim against a message, and
|
||||||
// whether any claim exists.
|
// whether any claim exists.
|
||||||
func (s *MessageStore) FirstClaim(nestID, referentID string) (string, bool) {
|
func (s *MessageStore) FirstClaim(nestID, referentID string) (string, bool) {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gateBrain struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *gateBrain) Name() string { return "gate" }
|
||||||
|
func (b *gateBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.calls = append(b.calls, system+"\n"+prompt)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return "KIND: claim\nOn it.", 10, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGateServer(t *testing.T) (*httptest.Server, *Server, *gateBrain) {
|
||||||
|
t.Helper()
|
||||||
|
ts, s := newTestNestServer(t)
|
||||||
|
gb := &gateBrain{}
|
||||||
|
s.p.Flock.brain = gb
|
||||||
|
return ts, s, gb
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrangerOnlyWhenAddressed(t *testing.T) {
|
||||||
|
ts, s, gb := newGateServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`)
|
||||||
|
promote(s, "Munin", "Soot") // Hugin stays a stranger
|
||||||
|
code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","mode":"conspiracy","text":"is this risky"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("post: %d", code)
|
||||||
|
}
|
||||||
|
awaitMessages(t, ts, id, 3) // request + 2 claims (Munin, Soot)
|
||||||
|
|
||||||
|
gb.mu.Lock()
|
||||||
|
defer gb.mu.Unlock()
|
||||||
|
for _, call := range gb.calls {
|
||||||
|
if strings.Contains(call, "You are Hugin,") {
|
||||||
|
t.Fatal("a stranger must not be invited for open work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := 0
|
||||||
|
for _, n := range []string{"You are Munin,", "You are Soot,"} {
|
||||||
|
for _, call := range gb.calls {
|
||||||
|
if strings.Contains(call, n) {
|
||||||
|
names++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names != 2 {
|
||||||
|
t.Fatalf("both regular crows should be invited, calls: %v", gb.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegularMayClaimOpen(t *testing.T) {
|
||||||
|
ts, s, gb := newGateServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
promote(s, "Hugin") // only Hugin is a regular
|
||||||
|
post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"tidy the branch notes"}`) // no to, no mode header
|
||||||
|
awaitMessages(t, ts, id, 2)
|
||||||
|
|
||||||
|
gb.mu.Lock()
|
||||||
|
defer gb.mu.Unlock()
|
||||||
|
if len(gb.calls) != 1 {
|
||||||
|
t.Fatalf("an open request invites exactly one eligible crow, calls: %v", gb.calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gb.calls[0], "You are Hugin,") {
|
||||||
|
t.Fatalf("the eligible crow should be invited, got %q", gb.calls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllStrangersStayOpen(t *testing.T) {
|
||||||
|
ts, _, gb := newGateServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
code, out := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","text":"do the thing"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("post: %d %v", code, out)
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
gb.mu.Lock()
|
||||||
|
defer gb.mu.Unlock()
|
||||||
|
if len(gb.calls) != 0 {
|
||||||
|
t.Fatalf("a nest of only strangers must not be summoned, calls: %v", gb.calls)
|
||||||
|
}
|
||||||
|
if len(nestMessages(t, ts, id)) != 1 {
|
||||||
|
t.Fatal("the request must stay open until addressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimAuthorHolds(t *testing.T) {
|
||||||
|
ts, s := newScriptedServer(t, "hmm idle chatter", "still not an answer") // addressee stays silent
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
||||||
|
code, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","to":"Hugin","text":"count the eggs"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("request: %d", code)
|
||||||
|
}
|
||||||
|
// Munin cannot claim what owner asked Hugin.
|
||||||
|
c2, out := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"Munin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"mine"}`)
|
||||||
|
// give the addressee's must-respond (and its flag) time to settle first
|
||||||
|
awaitMessages(t, ts, id, 2)
|
||||||
|
if c2 != http.StatusConflict {
|
||||||
|
t.Fatalf("directed assignment must refuse a non-addressee claim, got %d %v", c2, out)
|
||||||
|
}
|
||||||
|
if msg, _ := out["message"].(string); !strings.Contains(msg, "Hugin") {
|
||||||
|
t.Fatalf("refusal must name the asked crow, got %q", msg)
|
||||||
|
}
|
||||||
|
_ = s
|
||||||
|
}
|
||||||
Reference in new issue
Block a user