diff --git a/cmd/mavend/decisiontrace_test.go b/cmd/mavend/decisiontrace_test.go new file mode 100644 index 0000000..813d34f --- /dev/null +++ b/cmd/mavend/decisiontrace_test.go @@ -0,0 +1,163 @@ +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) + } +}