Files
Maven/cmd/mavend/decisiontrace_test.go
T

164 lines
5.3 KiB
Go

package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
// traceHandler — a handler with the decision ring wired, the same shape the
// daemon builds in wireVoice.
func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
return &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
decisions: ring,
}
}
// findClaim — the first claim for a claimant, or nil.
func findClaim(rec *decision.Record, claimant string) *decision.Claim {
for i := range rec.Claims {
if rec.Claims[i].Claimant == claimant {
return &rec.Claims[i]
}
}
return nil
}
// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
// grammar claims must leave a record naming that grammar as the winner, naming
// a pre-route resolver that declined, and naming the routing engines that were
// never reached at all. The last of those is the fact the hardcoded ordering
// hides: "classifier" absent from the record and "classifier" never asked read
// the same to a human, and only one of them is the truth.
func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
if reply == "" {
t.Fatal("turn produced no reply")
}
recs := ring.Recent(5)
if len(recs) != 1 {
t.Fatalf("want 1 record, got %d", len(recs))
}
rec := recs[0]
if rec.Utterance != "сколько сейчас времени" {
t.Errorf("utterance = %q", rec.Utterance)
}
if !strings.HasPrefix(rec.Winner, "stage0:") {
t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
}
// A loser that examined the turn: the confirm resolver ran first and had
// nothing pending.
confirm := findClaim(rec, "confirm")
if confirm == nil || confirm.Outcome != decision.Declined {
t.Errorf("confirm claim = %+v, want a decline", confirm)
}
// A loser that never looked: stage 0 answered, so neither routing engine
// was reached.
for _, name := range []string{"llm-router", "classifier"} {
c := findClaim(rec, name)
if c != nil && c.Outcome == decision.Won {
t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
}
}
// And every rung of the ladder below the winner is named, not omitted.
for _, name := range preRouteLadder {
if findClaim(rec, name) == nil {
t.Errorf("ladder rung %q is missing from the record", name)
}
}
}
// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
// wired and the same turn without it must answer identically. If this ever
// fails, a claim site is doing more than noting.
func TestRecordingDoesNotChangeTheReply(t *testing.T) {
for _, utt := range []string{
"сколько сейчас времени",
"запиши что я пил воду",
"что у меня сегодня",
} {
withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
if withRing != without {
t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
}
}
}
// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
// never consulted, and the record must say so rather than leave it out. This is
// the arm that would have explained the Rome misroute in one read.
func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
h.handleText(context.Background(), "web", "что у меня сегодня")
rec := ring.Recent(1)[0]
var asked, never int
for _, c := range rec.Claims {
if c.Stage != decision.StageQuery {
continue
}
if c.Outcome == decision.NeverAsked {
never++
} else {
asked++
}
}
if asked == 0 {
t.Fatal("no query source reported at all")
}
if never == 0 {
t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
}
if got := len(querySourceNames()); asked+never != got {
t.Errorf("record covers %d of %d query sources", asked+never, got)
}
}
// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
// never wired, because a box with no turns is an empty page and not an error.
func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
if fn := turnDecisionsFn(nil); fn != nil {
t.Error("no wiring should mean no reader")
}
ring := decision.NewRing()
h := traceHandler(t, ring)
h.handleText(context.Background(), "web", "сколько сейчас времени")
fn := turnDecisionsFn(&voiceWiring{handler: h})
if fn == nil {
t.Fatal("wired handler produced no reader")
}
out := fn(10)
if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
t.Fatalf("conversion lost the record: %+v", out)
}
}