Files
Maven/cmd/mavend/dialogue_contract_test.go
T
claude a4abcdefa3 Give the daemon a heads_path and a fixture arm (V-664)
embedder.heads_path is empty by default and deploy/mavend.json sets
it. A missing or broken weights file logs and leaves the heads nil,
because refusing to start over a routing accelerator would trade a
working box for a better one.

TestONNXRoutingHeads is the same cascade TestONNXBaseline scores with
one arm added, so the two are directly comparable. It also checks the
Go tokenizer against the Python one, since the heads were trained
through transformers and are read through a hand-written tokenizer: a
mismatch shows up here as a score below what Python measured on the
same weights, and nowhere else. That is how the reversed word pieces
were found.

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

808 lines
37 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 (
"bytes"
"context"
"log"
"os"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Dialogue contract tests (V-563, child of V-558).
//
// Every other clarify test is single-shot: one ask, one answer, one assertion.
// Three bugs of the same family shipped in two days that way — V-554 (a parked
// question ate the three turns after it), V-557 (a confidently routed but
// incomplete reminder parked nothing, so the answer was web-searched) and the
// Rome case in V-558 (a side question was eaten as the time answer). None of
// them is visible in one turn. The dialogue path is a state machine, so it can
// be enumerated instead: whole traces, each with a per-turn expectation and an
// expected END state — what was written to the store, and what is still parked.
//
// Two rules for the rows below.
//
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
// carries the CORRECT expectation and is skipped with the Vikunja id that will
// unskip it. A weakened expectation would be worse than no row: it would pin the
// bug as the contract.
//
// Everything runs on the offline floor — hash embedder, no llama-server, no
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
// sits.
// claim — which claimant consumed an utterance. Not asserted: it is derived from
// the log lines the daemon already emits and printed on every failure, because
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
// and that distinction is the whole point of V-558.
type claim struct {
utterance string
steps []string
}
func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }
// claimMarkers — log fragment to claimant name, in the order runTurn checks
// them. The fragments are the daemon's own words (clarify.go, repair.go,
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
// silent mislabel.
var claimMarkers = []struct{ fragment, name string }{
{"parked question expired", "clarify:expired"},
{"is its own request", "clarify:stepped-aside"},
{"gave up on", "clarify:gave-up"},
{"did not fill", "clarify:re-ask"},
{"one gap filled", "clarify:ask-second-gap"},
{"asked about", "clarify:ask"},
{"repair —", "repair"},
{"route result: intent=", "route"},
}
// claimsOf reads the turn's log output and names the claimants that touched it.
func claimsOf(utterance, logged string) claim {
c := claim{utterance: utterance}
for _, line := range strings.Split(logged, "\n") {
for _, m := range claimMarkers {
if strings.Contains(line, m.fragment) {
name := m.name
if m.name == "route" {
name = "route:" + intentInLine(line)
}
c.steps = append(c.steps, name)
break
}
}
}
if len(c.steps) == 0 {
c.steps = []string{"unclaimed"}
}
return c
}
func intentInLine(line string) string {
_, rest, ok := strings.Cut(line, "intent=")
if !ok {
return "?"
}
intent, _, _ := strings.Cut(rest, " ")
return intent
}
// parkedWant — the question that must be armed after a turn. Attempt matters:
// a claimant that spends a retry on an utterance that was never an answer is
// exactly the V-554 shape, and the count is the only place it shows.
type parkedWant struct {
slot dialogue.Slot
attempt int
// carries — a substring the parked utterance must still hold, so a re-park
// that lost the answered subject fails here rather than three turns later.
carries string
}
// turn — one utterance and everything that must be true right after it.
type turn struct {
say string
// wait — the clock moves this far BEFORE the utterance. The only way to
// reach the TTL without sleeping.
wait time.Duration
// question — the reply must be exactly this clarify question, worded for
// this attempt. Zero slot ⇒ not checked.
question dialogue.Slot
attempt int
// gap — which part of the time she is asking about, for a SlotTime question
// (V-579). Zero value is the missing hour, which is what she asks first.
gap whenGap
// took — the words of the PREVIOUS turn that this ask must acknowledge
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
// which is right for a first ask and for an answer that moved nothing.
took string
// differs — this reply must not be byte-identical to the one before it. Set
// on a re-ask whose turn moved the request forward (V-593).
differs bool
contains []string
notContain []string
// noQuestion — the reply must not be any clarify question. Used where the
// correct behaviour is known but her wording for it is not written yet: a
// cancel must not be answered with another question, whatever it does say.
noQuestion bool
expired bool // the reply must open with the TTL notice
// parked — what is armed after the turn. nil ⇒ nothing may be armed.
parked *parkedWant
}
// endState — what the store holds once the trace is over. Counts and
// substrings, not rows: a trace is about who claimed what, and a payload
// substring is enough to catch a request landing under the wrong words.
type endState struct {
reminders []reminderWant
factKeys []string
notes int
tasks []string
}
type reminderWant struct {
payload string // substring of the stored payload
fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
}
// trace — a named conversation, its turns, and the end state.
type trace struct {
name string
skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
turns []turn
end endState
}
// newDialogueHandler — the offline floor with the real cascade and a movable
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
// pointer so a turn can carry a wait.
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
t.Helper()
h, st, now := newClarifyHandler(t)
// A minute no trace ever says, so "fires at the current clock" is a defect
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st, now
}
// wantedQuestion builds the question a turn must be answered with, from the
// same code the daemon asks through. A time question is built from the gap,
// because she names the clock and asks about the part he left out (V-579).
func wantedQuestion(tn turn, now time.Time) (string, bool) {
if tn.question == dialogue.SlotTime {
gap := tn.gap
if gap == whenComplete {
gap = whenNoHour
}
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
}
return clarifyQuestionFor(tn.question, tn.attempt)
}
// runTrace drives one trace through handleText and checks every turn, then the
// end state. Every failure carries the decision trace so far, so a wrong
// claimant reads differently from wrong copy.
func runTrace(t *testing.T, tr trace) {
t.Helper()
// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
// whoever lands V-560, V-561 or V-562 sees their row go green before
// deleting its skip, and it is also the check that a skip is still earned:
// a row that passes with the skip in place is a fix nobody noticed.
if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
t.Skip(tr.skip)
}
ctx := context.Background()
h, st, now := newDialogueHandler(t)
const conversation = "web"
id := dialogueIDFor(sourceText, conversation)
var claims []claim
var previous string
fail := func(turnIdx int, format string, args ...any) {
t.Helper()
lines := make([]string, 0, len(claims))
for _, c := range claims {
lines = append(lines, " "+c.String())
}
t.Fatalf("turn %d: "+format+"\n who claimed what:\n%s",
append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
}
for i, tn := range tr.turns {
if tn.wait > 0 {
*now = now.Add(tn.wait)
}
var logged bytes.Buffer
prev := log.Writer()
log.SetOutput(&logged)
reply := h.handleText(ctx, conversation, tn.say)
log.SetOutput(prev)
claims = append(claims, claimsOf(tn.say, logged.String()))
body := reply
if tn.expired {
if !isClarifyExpired(reply) {
fail(i, "reply %q must open with the expiry notice", reply)
}
body = trimClarifyExpired(reply)
// The notice is glued in front of this turn's reply, and both halves
// have to survive: the words he just said are routed fresh, and
// answering only "I let the old one go" drops them.
if body == "" {
fail(i, "the notice was the whole reply; the fresh words were never answered")
}
} else if isClarifyExpired(reply) {
fail(i, "reply %q announced an expiry nothing asked for", reply)
}
if tn.question != "" {
want, ok := wantedQuestion(tn, h.now())
if !ok {
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
}
if body != want {
fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
}
}
if tn.noQuestion && isAnyClarifyQuestion(body) {
fail(i, "reply %q is another question; this turn is not something to ask about", body)
}
for _, want := range tn.contains {
if !strings.Contains(body, want) {
fail(i, "reply %q does not carry %q", body, want)
}
}
for _, unwanted := range tn.notContain {
if strings.Contains(body, unwanted) {
fail(i, "reply %q carries %q and must not", body, unwanted)
}
}
if tn.differs && reply == previous {
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
}
previous = reply
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
}
checkEnd(t, ctx, st, h, tr.end, claims)
}
// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
// attempt wording? Reads the templates rather than a list of its own.
func isAnyClarifyQuestion(reply string) bool {
for _, variants := range clarifyQuestionVariants {
for _, v := range variants {
// HasSuffix, not equality: a question about the time opens with the
// clock she is reasoning from (V-579).
if strings.HasSuffix(reply, v) {
return true
}
}
}
// The two questions with no deck behind them, asked when the hour is said
// and its half of the day or its day is not.
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
}
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
t.Helper()
if want == nil {
if got != nil {
fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
}
return
}
if got == nil {
fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
return
}
if len(got.Missing) != 1 || got.Missing[0] != want.slot {
fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
}
if got.Attempts != want.attempt {
fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
}
if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
}
}
func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
t.Helper()
lines := make([]string, 0, len(claims))
for _, c := range claims {
lines = append(lines, " "+c.String())
}
trace := "\n who claimed what:\n" + strings.Join(lines, "\n")
reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
if err != nil {
t.Fatalf("DueReminders: %v", err)
}
if len(reminders) != len(want.reminders) {
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
}
// No trace may leave a reminder at the current clock, whatever else it
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
// only checked the payload would have passed both.
for _, r := range reminders {
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
r.Payload, r.FireTs.Format("15:04"), trace)
}
}
for i, w := range want.reminders {
if !strings.Contains(reminders[i].Payload, w.payload) {
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
}
if w.fireAt != "" {
if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
}
}
}
facts, err := st.RecentFacts(ctx, 20)
if err != nil {
t.Fatalf("RecentFacts: %v", err)
}
if len(facts) != len(want.factKeys) {
t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
}
for i, key := range want.factKeys {
if facts[i].Key != key {
t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
}
}
notes, err := st.RecentNotes(ctx, 20)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != want.notes {
t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
}
tasks, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil {
t.Fatalf("ListTasks: %v", err)
}
if len(tasks) != len(want.tasks) {
t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
}
for i, text := range want.tasks {
if !strings.Contains(tasks[i].Text, text) {
t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
}
}
}
func TestDialogueTraces(t *testing.T) {
for _, tr := range dialogueTraces() {
tr := tr
t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
}
}
// dialogueTraces — the fixture. Order is the order the shapes were found, not a
// dependency: each trace builds its own handler and store.
func dialogueTraces() []trace {
return []trace{
// The plain two-turn shape, and the one every other row is a deviation
// from: she asks for the time, he gives it, the reminder lands with the
// subject he said in the FIRST turn.
{
// Three turns since V-579, not two. An hour with no day named is
// not an answer she can act on: 11:00 today has passed as often as
// not, and picking one for him is the invention the whole rule is
// against. So she says the clock she is reasoning from and asks
// which day.
name: "reminder completed over three turns",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// The same shape on the fact path, where the answer carries both halves
// of what was missing — the key and the value — in one breath.
{
name: "fact completed over two turns",
turns: []turn{
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
// His words back, not the key the parser filed them under
// (V-592). "water" is machine vocabulary and he never said it.
{say: "пил воду", contains: []string{"пил воду"}},
},
end: endState{factKeys: []string{"water"}},
},
// An answer past the TTL is a new request, not an answer (V-385). She
// says the old one is gone and routes the words fresh. A bare time on
// its own carries no request, so the fresh routing lands on the canned
// reply — the point of the row is that NOTHING is created: a reminder
// here would fire with the subject of a request she had already let go.
{
name: "answer arrives after the TTL",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
},
end: endState{},
},
// Three questions is the budget, and running out is SPOKEN: a mute
// give-up reads as "done" and he would wait for a reminder that was
// never set. The wording changes with the attempt (V-457).
{
name: "three unclear answers then the give-up line",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
},
end: endState{},
},
// A correction points at the previous ACTED turn (repair.go): she redoes
// it under the intent he names and says so out loud, because a
// correction he cannot see is indistinguishable from one that was
// dropped. The task she filed first stays filed — repair redoes, it does
// not retract, and V-455 decided that deliberately.
//
// The corrected-to intent has to differ from the one she used, or repair
// declines: teaching the classifier the label it already produced is
// worse than doing nothing.
{
name: "correction of the previous turn",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
},
end: endState{tasks: []string{"купить молоко"}},
},
// He walks away from his own request: a question is parked, the next
// utterance is an unrelated request of its own, and nothing follows.
// V-554's fix is what makes this row pass — the question steps aside
// rather than scoring "добавь в задачи" as the time. The reminder is
// dropped in silence and that is the decision: if he meant it he says it
// again, and a question left armed eats the turn after next.
{
name: "abandoned flow: parked, then an unrelated request",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "спасибо"},
},
end: endState{tasks: []string{"купить молоко"}},
},
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
// Every token of "что у меня сегодня?" is frame — an interrogative, a
// preposition, a particle and a day word — so the role classifier never
// looked at the route, the parked reminder read "сегодня" as its time,
// and the hour came from the clock. He got a reminder he never asked for
// at a minute he never said, and his question was answered nowhere.
//
// Two claims: the calendar answers, and nothing is written. The flow
// survives underneath, because a question of his own is not a request to
// abandon the one he was making.
{
name: "an agenda question mid-flow is answered, not eaten",
turns: []turn{
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
},
end: endState{},
},
// V-577 shape 2. He states something in the middle of the flow. It is
// neither a slot value nor a cancel, and it was scored as a failed
// answer and dropped in silence: alone the same sentence is stored.
// Silence is the one option that is wrong, so it is stored, no retry is
// spent, and the question comes back on the end of the same reply.
//
// The words are a fact and not the owner's note, because the fact parser
// is deterministic and the offline floor marks every classifier route
// Clarify. The row below carries his own sentence and needs the model.
//
// What this floor can prove is the arbitration: no retry is spent, the
// flow survives on the same attempt, and the words are answered as
// themselves with the question coming back after them. Whether the fact
// is then WRITTEN is the routing engine's business — the hash embedder
// is unsure of every sentence it sees, and an unsure fact has never been
// stored.
{
name: "a fact stated mid-flow steps aside without spending a retry",
turns: []turn{
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
// Nothing she says about it may be a word he did not say
// (V-592). On the box this sentence came back as "Проверила, что
// ты выпел стакан воды": a non-word for the verb, a glass copied
// out of the example in ReplySystemPrompt, and a claim to have
// checked something. The store held key=water value="drank"
// throughout, so all of it was generated from two tokens.
//
// The positive half of the contract — the confirmation IS his
// sentence — is asserted by "fact completed over two turns"
// above. It cannot be asserted here: the hash embedder marks
// this route Clarify, and an unsure fact is answered with the
// canned line rather than a confirmation of anything.
{say: "я выпил воды", contains: []string{"напоминание?"},
notContain: []string{"стакан", "выпел", "Проверила", "water"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
},
end: endState{},
},
// V-593: two asks about the same half of the day, with a turn between
// them that answered the DAY. Asking again is right and asking in the
// same bytes is not — from his side it is indistinguishable from not
// having been heard, which is what the whole V-558 family is about.
//
// The clock still opens every ask (the owner's rule, V-579); the
// acknowledgement goes after it and before the question.
{
name: "a re-ask names what the answer before it gave her",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
contains: []string{"Сейчас "},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
contains: []string{"Сейчас ", "завтра"}, differs: true,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
},
end: endState{},
},
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
// set the reminder and "на 9" was not read at all, on the same build and
// with the same cardinal.
{
// It is read, and being read is not the same as being enough: nine is
// either half of the day, so she asks which and then which day
// (V-579). Both answers are frame words and neither carries an hour
// of its own, so this row is also the proof that an answer is read
// against the whole request rather than alone.
name: "на 9 answers the time question like в 9",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
{say: "завтра", contains: []string{"09:00"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
},
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
// when what, what time and what day are all answered, and every ask
// states the clock she is reasoning from.
{
name: "his first example: a bare 3 is asked about",
turns: []turn{
{say: "напомни завтра в 3 заказать цветы",
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
},
end: endState{},
},
{
// The hour is unambiguous and the day is still missing, so she asks.
// Today being a valid reading is not the same as him saying it.
name: "his second example: nine in the evening of which day",
turns: []turn{
{say: "напомни в 9 вечера разгрузить стиралку",
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
{say: "завтра", contains: []string{"21:00"}},
},
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
},
{
// All three answered in one breath, so she does not ask at all.
name: "his third example: a full time commits",
turns: []turn{
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
},
{
// An interval is one instant, so it answers the hour and the day
// together. Confirmed by the owner: "через час is fine as is".
name: "an interval commits without a question",
turns: []turn{
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
},
// V-579 turn 4: he named a day and no hour, and got the day at the
// current minute. She has to ask instead, and the global check in
// checkEnd refuses the invented minute for every row at once.
{
name: "a day with no hour is asked about, not taken from the clock",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
},
end: endState{},
},
// ---- rows below carry the CORRECT expectation and fail today ----
// The owner's own sentence from V-577 shape 2, in his words. It needs
// an engine that can route it: the hash embedder marks it note with
// Clarify set, and a route she is not sure of is not evidence that he
// stated anything. The row above is the same contract in words the
// floor's deterministic fact parser reads.
{
name: "a note stated mid-flow is stored, not dropped",
skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
turns: []turn{
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
{say: "у меня новый ноутбук",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
},
end: endState{notes: 1},
},
// The owner's target transcript, V-561. He asks for a reminder, she asks
// when, he asks something else entirely, and then comes back to her
// question. On the box this created a reminder at 00:12 and never
// answered Rome; on the offline floor the side question is recognised as
// its own request and the flow is dropped instead, so the wrong reminder
// is not made and the right one is not either.
//
// Both are the same defect: there is no suspend and resume. The correct
// shape is the middle turn answered on its own and the parked question
// still standing, on the same attempt — a side query is not a failed
// answer and must not spend a retry.
//
// The skip came off with V-579. What held it was the parser, not the
// arbitration: neither the stub nor the production one read "на 9",
// because only "в" framed a spoken hour, and "на завтра" was completed
// from the clock.
//
// Turn 3 now closes the flow, where the transcript has one more exchange
// in it. That is the 12-hour question — the owner's turn 4 answers "на
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
// its own, not one to invent here. Nine o'clock is read as nine and, at
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
// Turn 4 then has nothing to answer and must not write anything.
{
name: "the owner's transcript from V-561",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
// His words, unchanged. What changed under V-579 is that "на 9"
// is a question and not a commit: nine could be either half of
// the day, so she says the clock she is reading from and asks.
// "на завтра." then answers the day and leaves the half open, so
// she asks that one again.
// Each ask names what the turn before it gave her (V-593). The
// two asks about the half of the day are the same question and
// must not be the same sentence: he answered between them, and a
// reply with no trace of that reads as not having been heard.
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
},
end: endState{},
},
// The same shape said in words StubDateTimeParser reads. GREEN since
// V-561. Same three claims: Rome is answered, the question survives the
// side query on the same attempt, and the answer after it completes the
// reminder he actually asked for.
//
// It sits under the "fail today" header because the row above it still
// does. Do not re-skip it to tidy that up: this is the owner's
// acceptance test in the only words the offline floor can read.
{
name: "nested question: a parked question, then one of his own",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
{say: "сегодня", contains: []string{"11:00"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// A cancel is one of the five turn roles V-560 names, and today it is
// none of them: "неважно" fills no slot and carries no request of its
// own, so it reads as a failed answer and spends a retry. Two turns
// later she is still asking about a reminder he called off.
//
// The row asserts what is knowable — nothing armed, nothing written, and
// not another question — rather than her wording for it, which is not
// written yet and is not this task's to invent.
{
name: "cancel: a parked question, then never mind",
skip: "V-560: a cancel is scored as a failed answer, not as a cancel",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "неважно", noQuestion: true},
},
end: endState{},
},
// Order in runTurn is the whole arbitration (V-558), and this is what it
// costs: the clarify answer is checked at step 3 and the repair marker at
// step 4d, so while a question is parked no correction can be made. She
// scores "нет, это была заметка" as a bad time answer and asks again.
{
name: "correction while a question is parked",
skip: "V-560: clarify pre-empts the repair marker, so a correction cannot be spoken mid-flow",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
},
end: endState{tasks: []string{"купить молоко"}},
},
// A reminder said whole, in one breath, with the hour in it — and she
// asks when. ReminderGrammar (stage0.go) builds its slots by hand and
// never runs the extractor, so a stage-0 reminder carries no time
// whatever the sentence says, and the clarify gate reads the gap as
// real. It costs a turn on the commonest reminder shape there is.
//
// Hermetic despite the date parser: stage 0 calls no parser at all, so
// this fails the same way with or without python dateparser installed.
{
name: "a reminder said whole is not asked about",
skip: "V-562: a stage-0 decision never meets the extractor, so its slots are never validated",
turns: []turn{
{say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// The same gap on the repair path. A correction redoes the request
// through finishClarified, which goes straight to applyAction — it never
// passes the clarify gate — so a redo that lands short answers with the
// parse error V-557 removed from the routing path: "не поняла, на когда
// напомнить." She should ask, exactly as she does for a fresh reminder
// with no time.
{
name: "a correction that lands short asks rather than failing",
skip: "V-562: finishClarified skips the clarify gate, so a repaired decision is never checked for gaps",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
},
end: endState{tasks: []string{"купить молоко"}},
},
}
}