Merge pull request #32 from jacisjake/task/25-divergence-nudge
(#25) Divergence check -> review nudge
This commit was merged in pull request #32.
This commit is contained in:
4 files changed
+252
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -252,6 +252,61 @@ func (j *JevClient) ClassifyKind(ctx context.Context, text string) (string, map[
|
|||||||
return a.Choice, a.Probabilities, true
|
return a.Choice, a.Probabilities, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// divergenceQuestions asks whether a message restates an earlier point. It is
|
||||||
|
// a noul: JEV_STRATEGIES strategy 3 (crisp yes/no) and noul has no confidence,
|
||||||
|
// so the probability itself is thresholded (band = threshold).
|
||||||
|
var divergenceQuestions = map[string]any{
|
||||||
|
"restates_earlier": map[string]any{
|
||||||
|
"type": "noul",
|
||||||
|
"instructions": "Does `this` make a point already made in `earlier`, adding no new angle?",
|
||||||
|
"criteria": map[string]string{
|
||||||
|
"true": "It repeats an earlier point without a genuinely new angle.",
|
||||||
|
"false": "It adds something different from what `earlier` already said.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyDivergence returns the probability that a message restates earlier
|
||||||
|
// ones, and ok when a value came back (noul always carries one; ok is false
|
||||||
|
// only on transport error or a missing answer).
|
||||||
|
func (j *JevClient) ClassifyDivergence(ctx context.Context, state any) (float64, bool) {
|
||||||
|
if j == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
answers, _, _, err := j.SystemOne(ctx, state, divergenceQuestions)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
a := answers["restates_earlier"]
|
||||||
|
if a.Noul == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *a.Noul, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyDivergence runs the restatement check under the flock guardrails
|
||||||
|
// (cap, timeout, flight log with the probability recorded).
|
||||||
|
func (f *Flock) ClassifyDivergence(ctx context.Context, state any, budgets ...*Budget) (float64, bool) {
|
||||||
|
if f.jev == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, b := range budgets {
|
||||||
|
if err := b.Check(); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.sem <- struct{}{}
|
||||||
|
defer func() { <-f.sem }()
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
p, ok := f.jev.ClassifyDivergence(cctx, state)
|
||||||
|
if f.log != nil {
|
||||||
|
f.log.Write(Event{Kind: "divergence", Crow: "Sentry", Model: f.jev.model, Ms: time.Since(start).Milliseconds(), Prob: map[string]float64{"restates_earlier": p}, Note: fmt.Sprint(ok && p >= f.jev.threshold)})
|
||||||
|
}
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
// ClassifyKind tags a nest message under the same guardrails as any call:
|
// ClassifyKind tags a nest message under the same guardrails as any call:
|
||||||
// flock cap, timeout and flight log, with the probabilities recorded.
|
// flock cap, timeout and flight log, with the probabilities recorded.
|
||||||
func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) {
|
func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) {
|
||||||
|
|||||||
+40
@@ -184,6 +184,10 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if kind == "deliver" {
|
||||||
|
go s.checkDivergence(nest, record)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, nestRecord(record))
|
writeJSON(w, http.StatusCreated, nestRecord(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +302,42 @@ func (s *Server) respondOne(nest Nest, req Message, crow string, lens bool) {
|
|||||||
}
|
}
|
||||||
if record, err := s.msgs.Append(reply); err == nil {
|
if record, err := s.msgs.Append(reply); err == nil {
|
||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
|
if record.Kind == "deliver" {
|
||||||
|
s.checkDivergence(nest, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDivergence asks Jev whether this deliver restates earlier delivers that
|
||||||
|
// answer the same request. Above the band the crow gets a review nudge; the
|
||||||
|
// deliver itself stays in the log (nudges never silence).
|
||||||
|
func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) {
|
||||||
|
if m.Kind != "deliver" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
earlier := s.msgs.EarlierDelivers(nest.ID, m.RefersTo, m.ID)
|
||||||
|
if len(earlier) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, nudged := s.msgs.NudgeFor(nest.ID, m.MsgID()); nudged {
|
||||||
|
return // one nudge per deliver; no loop
|
||||||
|
}
|
||||||
|
prob, ok := s.p.Flock.ClassifyDivergence(s.ctx, map[string]any{"this": m.Text, "earlier": earlier}, budgets...)
|
||||||
|
if !ok || prob < s.p.Flock.jev.threshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nudge := Message{
|
||||||
|
NestID: nest.ID,
|
||||||
|
Kind: "nudge",
|
||||||
|
Author: "harness",
|
||||||
|
Text: "This reads like a point already made in the thread; review the earlier delivers and add your distinct angle or let it rest.",
|
||||||
|
To: m.Author,
|
||||||
|
RefersTo: m.MsgID(),
|
||||||
|
TaggedBy: "harness",
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if record, err := s.msgs.Append(nudge); err == nil {
|
||||||
|
s.emitNestMessage(record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -132,6 +132,32 @@ func (s *MessageStore) FirstClaim(nestID, referentID string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EarlierDelivers returns the texts of deliver messages answering the same
|
||||||
|
// referent, oldest first, excluding the one with id excludeID.
|
||||||
|
func (s *MessageStore) EarlierDelivers(nestID, referentID string, excludeID int) []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, m := range s.log[nestID] {
|
||||||
|
if m.Kind == "deliver" && m.RefersTo == referentID && m.ID != excludeID {
|
||||||
|
out = append(out, m.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgeFor reports whether a nudge already references a given message.
|
||||||
|
func (s *MessageStore) NudgeFor(nestID, targetMsgID string) (Message, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, m := range s.log[nestID] {
|
||||||
|
if m.Kind == "nudge" && m.RefersTo == targetMsgID {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Message{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MessageStore) save(nestID string) error {
|
func (s *MessageStore) save(nestID string) error {
|
||||||
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in new issue
Block a user