Files
Maven/cmd/mavend/turnrole_test.go
T
claude de9884e063 Count the rides a question takes, without the reset (V-663)
MaxSuspends did not move the number it was written for. Twenty-six of 140
turns carried a parked clarify tail before it landed and twenty-six after.

Two bounds rearm each other. An aside spends no attempt, so MaxAttempts
never reaches it. A turn reading as a failed answer zeroes Suspends, so
MaxSuspends never reaches the asides. Alternating them restores each bound
with the other's traffic. Measured on 2026-08-08: one question about a
reminder's day rode turns 7 to 13.

PendingQuestion.Rides is the same event counted without the resets. Set
once, incremented only in noteSuspended, carried across the re-park in
askRemainingGap, read by nothing that could lower it. MaxRides is 4, one
looser than MaxSuspends so the tighter statement about a run stays
reachable.

It ends the measured ride one turn early and no more. Most of that ride is
attempts, spent because classifyTurnRole reads "спасибо" and "привет" as
failed answers. Said so in the constant and in the design doc rather than
claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:38:32 +04:00

440 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role
// classifier rests on: after the frame, the numbers and the closed time sets
// come out, does anything of his own remain? A hedged time leaves nothing. A
// question about the weather leaves the weather.
func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) {
cases := []struct {
text string
own bool
}{
{"в 11:00", false},
{"в семь вечера", false},
{"нет, в 15:00", false},
{"а что если в 11:00", false},
{"на 9", false},
{"а, да, прости — на 9", false},
{"завтра", false},
{"в половине восьмого", false},
{"какая сейчас погода в Риме?", true},
{"кто изобрёл телефон", true},
{"напомни в 11:00", true},
{"позвонить маме", true},
{"запиши что я пил воду", true},
}
for _, tc := range cases {
if got := len(ownContent(tc.text)) > 0; got != tc.own {
t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own)
}
}
}
// TestCancelIsTheWholeUtterance — a call-off calls the request off, and a
// sentence that merely contains the word does not.
func TestCancelIsTheWholeUtterance(t *testing.T) {
for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} {
if !isCancel(yes) {
t.Errorf("isCancel(%q) = false, want true", yes)
}
}
for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} {
if isCancel(no) {
t.Errorf("isCancel(%q) = true, want false", no)
}
}
}
// TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance
// gets a different role depending on what the router made of it, which is the
// evidence the old guard never had.
func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(router.IntentReminder),
Missing: []dialogue.Slot{dialogue.SlotTime},
}
dec := func(in router.Intent, s router.Slots) router.Decision {
return router.Decision{Intent: in, Slots: s}
}
cases := []struct {
name string
text string
routed router.Decision
ok bool
answer dialogue.Slots
want turnRole
}{
{
// The measured defect. The extractor finds "сейчас" and would have
// closed the gap with it; the route says this is a question of its
// own, and the question wins.
name: "a world question mid-flow is a side query",
text: "какая сейчас погода в Риме?",
routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}),
ok: true,
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
want: roleSideQuery,
},
{
name: "a hedged time is an answer even routed as a query",
text: "а что если в 11:00",
routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}),
ok: true,
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
want: roleAnswer,
},
{
name: "a fresh reminder is a new request",
text: "напомни завтра позвонить маме",
routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}),
ok: true,
want: roleNewRequest,
},
{
name: "a capture is a new request",
text: "запиши что я пил воду",
routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}),
ok: true,
want: roleNewRequest,
},
{
name: "an act that resolved to a capability is a new request",
text: "выключи свет в спальне",
routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}),
ok: true,
want: roleNewRequest,
},
{
// She could not route it. An utterance she did not understand does
// not outrank the question in front of it.
name: "a clarify decision is not a request of its own",
text: "выключи свет",
routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true},
ok: true,
want: roleAnswer,
},
{
// V-577 shape 1. Every token is frame, so the content gate called
// this an answer and the reminder took "сегодня" for its time. It
// fills nothing she asked about, so the route decides, and the route
// says the calendar answers it.
name: "an agenda question of pure frame words is a side query",
text: "что у меня сегодня?",
routed: dec(router.IntentQuery, router.Slots{}),
ok: true,
want: roleSideQuery,
},
{
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
// It was dropped in silence; it is an aside, and an aside is stored
// and re-asked.
name: "a fact stated mid-flow is an aside",
text: "у меня новый ноутбук",
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
ok: true,
want: roleAside,
},
{
// A route she is not sure of is not evidence that he stated
// anything, and "позвонить маме" is the answer to the other half of
// a reminder.
name: "an unsure note is not an aside",
text: "позвонить маме",
routed: router.Decision{Intent: router.IntentNote, Clarify: true},
ok: true,
want: roleAnswer,
},
{
name: "a bare noun that answers nothing is still an answer",
text: "ага",
ok: false,
want: roleAnswer,
},
{
name: "no route to read falls back to the shape",
text: "кто изобрёл телефон",
ok: false,
want: roleSideQuery,
},
{
name: "a call-off needs no route at all",
text: "отмена",
ok: false,
want: roleCancel,
},
}
for _, tc := range cases {
if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want {
t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want)
}
}
}
// TestTurnRoleNamesACorrection — the answer overwrites a slot she was not
// asking about. Handled like an answer, named as what it is.
func TestTurnRoleNamesACorrection(t *testing.T) {
nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(router.IntentReminder),
Missing: []dialogue.Slot{dialogue.SlotText},
Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)},
}
got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false)
if got != roleCorrection {
t.Fatalf("role = %s, want %s", got, roleCorrection)
}
}
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
// parks the time question; the weather question that follows must not become
// its answer and must not create a reminder for a time nobody asked for.
//
// V-560 got that far by DROPPING the parked request and saying so, and the
// owner rejected the notice on sight: he did not ask to lose the reminder. So
// the contract here is V-561's — the flow is suspended, this turn's reply ends
// with the question coming back, and nothing says anything was let go.
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?")
if strings.Contains(reply, "напомню") {
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
}
if strings.Contains(reply, clarifyDropped) {
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
}
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
}
// Still parked, and still on its first attempt: he answered the side query,
// not this question, so no retry may have been spent on it.
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
if q == nil {
t.Fatal("the parked question was dropped instead of suspended")
}
if q.Attempts != 1 {
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
}
if !strings.Contains(q.Utterance, "маме") {
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
}
}
// TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the
// half-built request off, out loud, and creates nothing.
func TestClarifyCancelEndsTheExchange(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled {
t.Fatalf("reply = %q, want %q", reply, clarifyCancelled)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
t.Fatal("a cancelled exchange must leave nothing parked")
}
}
// TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays
// for one extra route and not two: the clarify resolver and the pipeline read
// the same memo.
func TestTheTurnIsRoutedOnce(t *testing.T) {
h, _ := newRoutingClarifyHandler(t)
rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now())
ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt)
first, ok := h.routeForRole(ctx, rt.text)
if !ok {
t.Fatal("the cascade must produce a decision to classify against")
}
second, _, _, err := rt.resolve(ctx)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if second.Intent != first.Intent || second.Utterance != first.Utterance {
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
}
}
// TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of
// 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58).
//
// A side query suspends the parked question, spends no attempt and restarts the
// TTL. Nothing else bounded it, so one unfilled time slot came back on the end
// of six consecutive unrelated replies and stopped only when a seventh turn
// happened to read as a failed answer. Three step-asides, then she lets it go
// and says so.
func TestASuspendedQuestionDoesNotRideForever(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
// Three questions of his own. Each one is answered as itself and each one
// brings the open question back, exactly as V-561 asks.
asides := []string{
"о чём мы вчера говорили?",
"какие у меня напоминания?",
"сколько времени?",
}
for i, text := range asides {
reply := h.handleText(ctx, "web", text)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("side query %d: the question must come back, got %q", i+1, reply)
}
if strings.Contains(reply, clarifyDropped) {
t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply)
}
q := h.clarifyStore.Get(id, h.now())
if q == nil {
t.Fatalf("side query %d: the question was dropped early", i+1)
}
if q.Attempts != 1 {
t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts)
}
if q.Suspends != i+1 {
t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1)
}
}
// The fourth. She has stepped aside as often as she is willing to, so the
// request goes — out loud, and without the question on the tail.
reply := h.handleText(ctx, "web", "что у меня сегодня?")
if !strings.Contains(reply, clarifyDropped) {
t.Fatalf("the request was let go in silence: %q", reply)
}
if strings.HasSuffix(reply, resumed) {
t.Fatalf("a question she has let go must not be asked again: %q", reply)
}
if h.clarifyStore.Get(id, h.now()) != nil {
t.Fatal("the question must be gone once she has said she let it go")
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err)
}
}
// TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE
// step-asides. He filled a gap, so the run is broken and the next question
// starts with its full allowance: a long exchange he is engaged with must not
// run out of patience on his behalf.
func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
ctx := context.Background()
h, _ := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
// A bare "напомни" is missing both halves, so answering the subject re-parks
// the request with a question about the time.
if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") {
t.Fatalf("expected a question, got %q", reply)
}
if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" {
t.Fatal("the side query must be answered as itself")
}
if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 {
t.Fatalf("the side query was not counted: %+v", q)
}
if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" {
t.Fatal("the answer must be consumed")
}
q := h.clarifyStore.Get(id, h.now())
if q == nil {
t.Fatal("a reminder still needs its time, so a question must be parked")
}
if q.Suspends != 0 {
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
}
// The ride it already took is carried across the re-park (V-663). Resetting
// both counters here is what let one question ride twenty-six replies.
if q.Rides != 1 {
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
}
}
// TestTwoBoundsCannotRearmEachOther — V-663.
//
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
// carried a tail before it and twenty-six after. This is the shape it misses,
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
//
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
// asides either. Alternating the two rearms each bound with the other's
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
ctx := context.Background()
h, _ := newRoutingClarifyHandler(t)
id := dialogueIDFor(sourceText, "web")
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
// Two asides. Each one rides and neither spends an attempt.
for i := 0; i < 2; i++ {
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
}
}
q := h.clarifyStore.Get(id, h.now())
if q == nil || q.Rides != 2 || q.Suspends != 2 {
t.Fatalf("after two asides: %+v", q)
}
// A failed answer. It spends an attempt and puts Suspends back to zero, so
// the old bound starts over. Rides does not.
h.handleText(ctx, "web", "спасибо")
if q = h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 0 {
t.Fatalf("the failed answer did not rearm the old bound: %+v", q)
}
if q.Rides != 2 {
t.Fatalf("the rides it already took were forgotten: %+v", q)
}
// Three more asides. Suspends only reaches three, one short of firing, and
// would go back to zero on the next pleasantry. Rides passes MaxRides and
// the request goes, out loud.
h.handleText(ctx, "web", "какие у меня напоминания?")
h.handleText(ctx, "web", "какие у меня напоминания?")
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
if !strings.Contains(reply, clarifyDropped) {
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
}
if strings.HasSuffix(reply, resumed) {
t.Fatalf("a question she has let go must not be asked again: %q", reply)
}
if h.clarifyStore.Get(id, h.now()) != nil {
t.Fatal("the question must be gone once she has said she let it go")
}
}