diff --git a/cmd/mavend/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go index b4c7533..23187ba 100644 --- a/cmd/mavend/dialogue_contract_test.go +++ b/cmd/mavend/dialogue_contract_test.go @@ -114,8 +114,11 @@ type turn struct { 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 + 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 contains []string notContain []string // noQuestion — the reply must not be any clarify question. Used where the @@ -157,11 +160,29 @@ type trace struct { 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) 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) + } + 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. @@ -217,7 +238,7 @@ func runTrace(t *testing.T, tr trace) { fail(i, "reply %q announced an expiry nothing asked for", reply) } if tn.question != "" { - want, ok := clarifyQuestionFor(tn.question, tn.attempt) + want, ok := wantedQuestion(tn, h.now()) if !ok { fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt) } @@ -248,12 +269,16 @@ func runTrace(t *testing.T, tr trace) { func isAnyClarifyQuestion(reply string) bool { for _, variants := range clarifyQuestionVariants { for _, v := range variants { - if reply == v { + // 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 } } } - return false + // 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) { @@ -294,6 +319,17 @@ func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHan 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) @@ -355,11 +391,18 @@ func dialogueTraces() []trace { // from: she asks for the time, he gives it, the reminder lands with the // subject he said in the FIRST turn. { - name: "reminder completed over two turns", + // 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", contains: []string{"11:00"}, notContain: []string{"?"}}, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + 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"}}}, }, @@ -438,8 +481,147 @@ func dialogueTraces() []trace { 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: "врачу"}}, + {say: "я выпил воды", contains: []string{"напоминание?"}, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + }, + 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, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + {say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, + 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 @@ -452,26 +634,35 @@ func dialogueTraces() []trace { // still standing, on the same attempt — a side query is not a failed // answer and must not spend a retry. // - // Unskipping this needs more than V-561, and V-561 landing did not change - // that. The suspend and resume it asked for is done — the row below is - // the same shape in words the floor can parse and is green. What is left - // here is the parser: StubDateTimeParser does not read "на 9" or "на - // завтра", so turn 3 lands as an answer that filled nothing and spends a - // retry, which is what this row now fails on. V-562 and V-543 own the - // ambiguous hour and the day correction behind those two words. + // 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", - skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»", 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: "а, да, прости - на 9.", - parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "на завтра."}, + // 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. + {say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + {say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, }, - end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}}, + end: endState{}, }, // The same shape said in words StubDateTimeParser reads. GREEN since // V-561. Same three claims: Rome is answered, the question survives the @@ -488,7 +679,9 @@ func dialogueTraces() []trace { parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, {say: "какая сейчас погода в Риме?", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "в 11:00", contains: []string{"11:00"}}, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}}, + {say: "сегодня", contains: []string{"11:00"}}, }, end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}}, },