dialogue traces cover the owner's four reminder cases (V-579)

His two asks and his two commits, plus the check that no trace anywhere ends
with a reminder firing at the current clock. The transcript row from V-561 keeps
his verbatim words and loses its skip: "на 9" is read now, and under the commit
rule it is a question rather than a reminder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 02:20:33 +04:00
parent 173531be8c
commit 0d49745a17
+213 -20
View File
@@ -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"}}},
},