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, 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) } }