diff --git a/JOURNAL.md b/JOURNAL.md index 473fe3f..40ad829 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -186,3 +186,27 @@ showed that only the first initializes; later tests self-skip because the runtime is process-global. All V-702 figures were therefore rerun in separate processes. V-716 tracks fixing that harness gap rather than hiding it in this feature. + +### Forced dialogue and repair state + +V-573 closes all four repair seams exposed by the dialogue contract: a +correction wins before a parked clarify answer; a repaired decision is checked +for required slots before acting; a request completed through clarification is +correctable; and declined or stale repairs do not prematurely spend the repair +pointer. Same-intent corrections are handled explicitly without redoing the +action, so their prose cannot route fresh and overwrite the retained pointer. + +The independent state audit found two deeper stack losses. A handled repair +could leave an older question silently parked with its old TTL, and a repaired +request needing clarification could overwrite—or, on completion, delete—the +older flow. Repairs now suspend and audibly resume live questions, repaired +questions push onto the bounded dialogue stack, and completion/cancellation +pops only the active top before resuming the flow underneath. + +`MAVEN_DIALOGUE_NO_SKIP=1 go test -race ./cmd/mavend -run +'^TestDialogueTraces$' -count=1` passes all 22 traces. The complete forced +`cmd/mavend` race suite passes in 208.031s. The integrated race command over +`cmd/mavend`, `internal/dialogue`, and all `internal/router` packages also +passes (162.310s for mavend; every package green). Focused structural +possession, repair-pointer, nested-stack, and repaired-clarify tests pass under +the race detector. diff --git a/cmd/labelgen/main.go b/cmd/labelgen/main.go index 801013a..9bf23de 100644 --- a/cmd/labelgen/main.go +++ b/cmd/labelgen/main.go @@ -40,34 +40,19 @@ type label struct { Labeled bool `json:"labeled"` } -// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is -// load-bearing there and so it is here: the agenda rules must sit after the -// clock rules, Praxis before the capture marker, the narrative rules last. +// grammars is the daemon's canonical ordered stage-zero set. Label generation +// must not maintain a second copy: that drift was the defect fixed by V-693. func grammars() []router.Grammar { - var g []router.Grammar - g = append(g, router.SystemTimeDateGrammars()...) - g = append(g, router.AgendaQueryGrammars()...) - g = append(g, router.FeedQueryGrammar()) - g = append(g, router.TaskListGrammar()) - g = append(g, router.ListGrammars()...) - g = append(g, router.ReminderGrammar()) - g = append(g, router.PraxisGrammars()...) - g = append(g, router.TaskCaptureGrammar()) - g = append(g, router.NarrativeQueryGrammars()...) - return g + return router.StageZeroGrammars(router.DefaultActMatcher{}) } func match(gs []router.Grammar, utterance string) label { out := label{Utterance: utterance} for _, g := range gs { - m := g.Pattern.FindStringSubmatch(utterance) - if m == nil { + d, matched, ok := g.Evaluate(utterance) + if !matched || !ok { continue } - d, ok := g.Build(m) - if !ok { - continue // the rule saw its shape and declined it - } out.Intent = string(d.Intent) out.Grammar = g.Name out.Key = d.Slots.Key diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index de97854..ae40ad6 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -342,7 +342,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) switch role { case roleCancel: - h.clarifyStore.Delete(dialogueIDOf(ctx)) + h.completeClarifyTop(ctx) return clarifyCancelled, true case roleSideQuery: // He asked something of his own WITHOUT leaving the flow. The question @@ -419,8 +419,6 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) if stillOpen(q.Missing, whenTextOf(q), merged) { return h.reaskOrGiveUp(ctx, q, merged, text, taken), true } - h.clarifyStore.Delete(dialogueIDOf(ctx)) - // One gap filled is not the same as a complete request. askClarify parks // only the first gap, because one question per turn is the rule, but a // reminder wants both a subject and a time. "напомни" with neither used to @@ -431,6 +429,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked { return reply, true } + h.completeClarifyTop(ctx) // Rebuild the decision as if it had routed cleanly, then run it down the // normal path. Clarify is deliberately false and the intent is unchanged: @@ -446,6 +445,27 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) return h.finishClarified(ctx, dec), true } +// completeClarifyTop finishes only the active question. A nested question can +// sit above a flow that was suspended by a side request or repair; deleting the +// dialogue id here erased both. If one survives underneath, restart its clock +// from the moment it is spoken again and attach its question to this turn. +func (h *reactiveHandler) completeClarifyTop(ctx context.Context) { + if h.clarifyStore == nil { + return + } + _, resumed := h.clarifyStore.CompleteTop(dialogueIDOf(ctx), h.now()) + if resumed == nil || len(resumed.Missing) == 0 { + return + } + question, ok := clarifyResumedFor(resumed.Missing[0]) + if !ok { + return + } + if rt := turnRouteFrom(ctx); rt != nil { + rt.resume = question + } +} + // noteDropped records that the parked request was let go this turn, so runTurn // can say it in front of whatever these words are answered with. Nothing to // record outside runTurn — a unit test calling one resolver has no turn to glue @@ -487,7 +507,7 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending return } if !q.CanResume() { - h.clarifyStore.Delete(dialogueIDOf(ctx)) + h.completeClarifyTop(ctx) h.noteDropped(ctx) log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides) return @@ -576,7 +596,7 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken) } if question == "" || !q.CanAsk() { - h.clarifyStore.Delete(dialogueIDOf(ctx)) + h.completeClarifyTop(ctx) log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text) return clarifyGaveUp } @@ -590,15 +610,38 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending return question } -// finishClarified runs a completed decision through the same steps a freshly -// routed one takes: remember the turn, act, then phrase. +// finishClarified completes a decision whose parked gaps were already checked +// by resolveClarifyAnswer. It still records the turn for a later correction; +// the old path made anything completed through dialogue uncorrectable (V-573). func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string { + return h.finishRebuilt(ctx, dec, false) +} + +// finishRepaired validates a decision rebuilt from an older utterance. Unlike +// resolveClarifyAnswer, repair has not passed the current slot gate, so it must +// ask about any missing argument before acting (V-573). +func (h *reactiveHandler) finishRepaired(ctx context.Context, dec router.Decision) string { + return h.finishRebuilt(ctx, dec, true) +} + +func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec router.Decision, validate bool) string { + if validate && (dec.Clarify || len(missingFor(dec)) > 0) { + if reply := h.hexisBeforeClarify(ctx, dec); reply != "" { + return reply + } + if question, asked := h.askClarify(ctx, dec); asked { + return question + } + } if h.dialogueSessions != nil { now := h.now() prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now) dec = followUpMerge(prev, dec, now) h.rememberTurn(ctx, prev, dec, now) } + if !dec.Clarify { + h.recordTurn(dec.Utterance, dec.Intent) + } reply := h.applyAction(ctx, dec) if reply == "" { reply = h.replier.Reply(ctx, dec) diff --git a/cmd/mavend/decisiontrace.go b/cmd/mavend/decisiontrace.go index a25a086..e49e245 100644 --- a/cmd/mavend/decisiontrace.go +++ b/cmd/mavend/decisiontrace.go @@ -31,8 +31,8 @@ import ( // and nothing should: a missing name costs one line of the record, while a // check that walks the ladder would have to run the ladder. var preRouteLadder = []string{ - "confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", - "repair-negative", "ordinal", + "confirm", "repair", "repair-negative", "clarify-answer", "quiet-toggle", + "snooze", "ack", "ordinal", } // notePreRoute records one rung of that ladder and passes its verdict through diff --git a/cmd/mavend/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go index b4557d9..56774ac 100644 --- a/cmd/mavend/dialogue_contract_test.go +++ b/cmd/mavend/dialogue_contract_test.go @@ -651,7 +651,7 @@ func dialogueTraces() []trace { end: endState{}, }, - // ---- rows below carry the CORRECT expectation and fail today ---- + // ---- formerly failing interleavings; kept as permanent contracts ---- // 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 @@ -660,7 +660,6 @@ func dialogueTraces() []trace { // 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: "врачу"}}, @@ -747,7 +746,6 @@ func dialogueTraces() []trace { // 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}}, @@ -761,7 +759,6 @@ func dialogueTraces() []trace { // 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, @@ -771,21 +768,17 @@ func dialogueTraces() []trace { }, 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. + // Stage 0 has extracted the hour since V-572. The day remains genuinely + // absent, and V-579 deliberately refuses to invent it even when 11:00 is + // still ahead on today's clock. This stale skipped row used to expect a + // commit and contradicted every neighbouring time-contract row. { - 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", + name: "a stage-zero reminder keeps its hour and asks for the missing day", turns: []turn{ - {say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true}, + {say: "напомни в 11:00 позвонить маме", question: dialogue.SlotTime, attempt: 1, + gap: whenNoDay, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, }, - end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}}, + end: endState{}, }, // The same gap on the repair path. A correction redoes the request // through finishClarified, which goes straight to applyAction — it never @@ -795,7 +788,6 @@ func dialogueTraces() []trace { // 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{"поняла, это напоминание"}, diff --git a/cmd/mavend/personalboundary.go b/cmd/mavend/personalboundary.go index d239bdd..7f19b9b 100644 --- a/cmd/mavend/personalboundary.go +++ b/cmd/mavend/personalboundary.go @@ -2,6 +2,8 @@ package main import ( "context" + "encoding/base64" + "encoding/binary" "log" "math" "sync" @@ -23,31 +25,40 @@ import ( // every utterance the list misses is one that reaches the world. It also drifts // silently — a missing verb looks exactly like no bug. // -// So the boundary asks the embedder instead. Two frozen seed sets — questions -// about him, questions about the world — are embedded once, and the turn's own -// query vector, already computed by queryEmbed upstream, is scored against -// both. Nearest side wins. Word order, verb form and unseen phrasing stop -// mattering, which is exactly what a lexicon could not do. +// So the boundary asks the embedder instead. A frozen bilingual corpus is +// embedded at model-fit time, then a class-balanced logistic head is fitted +// over those vectors. The head learns a direction in semantic space instead +// of choosing whichever single example happens to share the most words. That +// matters for a public noun inside a private question and for advice about an +// owned object: nearest-neighbour scoring confuses both, while a trained head +// combines the evidence across the whole sentence. // -// Measured 03-08-2026 against multilingual-e5-small on 19 held-out utterances, -// none of them a seed: 19 right (TestONNXPersonalBoundary). A 20th, "as i said, -// what is the population of india", missed by +0.008 during the first pass and -// is a world seed now, which is why it is not in the held-out set. True -// positives clear the world side by +0.014 to +0.089 and the nearest true -// negative sits at -0.005, so the gate is the sign of the difference and -// nothing tighter: the margins are too thin to justify a threshold, and the -// asymmetry favours claiming anyway. A false claim costs one honest "не знаю"; -// a false pass sends his life to an upstream engine. +// The corpus covers six sentence shapes on both sides: remembered speech, +// possession, narrative, first-person preambles, current advice/information, +// and public proper nouns. Training weights each class equally, so the larger +// world corpus cannot move the prior merely by containing more examples. A +// small L2 term makes the solution stable; its value and the fixed optimiser +// are measured by model-backed cross-validation, not adjusted at runtime. // -// The embedder is the one model CLAUDE.md pins to homesrv permanently, and it -// is what makes this affordable: no llama-server call, no network, one cosine -// per seed against a vector the turn already has. +// This linear head measures 29/29 on the historical regression suite and +// 72/72 on the separate stratified fixture (V-702, 13-08-2026). The gate is +// still probability 0.5: a false claim costs one honest "не знаю", while a +// false pass can send his life to an upstream engine. +// +// The embedder is the one model CLAUDE.md pins to homesrv permanently. Its head +// is fitted and verified by the model-backed gate, then frozen into the binary; +// inference is one dot product against a vector the turn already has. Unknown +// embedding spaces fit their own head once per process instead of applying +// foreign weights. Neither path calls llama-server or the network. -// personalSeeds — questions about him. Frozen: they are scoring data, so -// editing one moves the boundary and must be re-measured, not eyeballed. Cover -// both classes the boundary owns, possession and first-person speech, in both -// languages. +// personalSeeds and worldSeeds are the frozen training corpus for the linear +// boundary head. Editing either changes a model, not a phrase list: every edit +// therefore needs the model-backed regression, stratified evaluation and +// training-corpus cross-validation. The examples describe where an answer can +// come from, in both languages. None is a special case copied from an eval. var personalSeeds = []string{ + // The original compact corpus. It remains here both as training signal and + // as provenance for the regressions that introduced the semantic boundary. "что я говорил про это", "я тебе рассказывал об этом?", "что я записал про врача", @@ -56,21 +67,79 @@ var personalSeeds = []string{ "когда моя встреча", "what did i say about this", "did i mention this to you", + + // Remembered speech. + "какой адрес я тебе сообщал?", + "что я говорил о своём самочувствии?", + "какое решение по ремонту я озвучил?", + "что я обещал сделать после отпуска?", + "what reason did I give for declining the offer?", + "did I tell you where I grew up?", + "which restaurant did I say I wanted to visit?", + "what explanation did I give for missing the meeting?", + + // Stored attributes of his possessions and records. + "где лежит мой договор аренды?", + "когда заканчивается моя подписка на спортзал?", + "какой размер у моей запасной куртки?", + "до какой даты действует мой пропуск?", + "какой размер у моего велосипедного шлема?", + "where is my vehicle registration document?", + "when is my museum membership renewal?", + "what number is on my travel insurance policy?", + "which shelf did I put my tax folder on?", + "what size is my waterproof coat?", + + // Narratives that only his memories or records can supply. + "собери по моим записям рассказ о поездке в Самару", + "напомни, как прошёл мой первый урок вождения", + "восстанови из дневника, как я искал первую квартиру", + "перескажи по моим словам, как прошла встреча выпускников", + "summarize my account of moving into this apartment", + "tell me what happened during my first week at the new job", + "recreate the story of my graduation from my journal", + "piece together my account of adopting the dog", + + // First-person framing around a private answer. + "возвращаясь к нашей беседе, какой банк я выбрал?", + "кажется, я уже говорил: на какую дату записался к врачу?", + "если мы это обсуждали, какую школу вождения я предпочёл?", + "напомню наш разговор: когда я решил менять работу?", + "as I mentioned before, which contractor did I hire?", + "coming back to our chat, what date did I book the inspection for?", + "if we covered this already, which course did I enroll in?", + "back to what I told you: where did I plan to stay in Oslo?", + + // Current information that lives only in his records. + "какой счёт мне нужно оплатить на этой неделе?", + "сколько часов я работал в прошлом месяце?", + "какую процедуру мастер советовал выполнить утром?", + "какая из моих заявок всё ещё не закрыта?", + "which appointment do I have tomorrow morning?", + "how many kilometres did I run last week?", + "what maintenance did the mechanic tell me to schedule?", + "which item on my project list is overdue?", + + // Public names inside questions that still require his records. + "какую цитату из Набокова я сохранил?", + "когда у меня созвон с Ириной Петровой?", + "что я думал о романе Умберто Эко?", + "какую оценку я дал выставке Айвазовского?", + "какую фотографию Эрмитажа я отметил для печати?", + "what did I note down after Margaret Hamilton's lecture?", + "when is my booking at the Royal Albert Hall?", + "which Nina Simone song did I call my favourite?", + "what opinion did I share about Zadie Smith's new novel?", + "what reminder did I attach to the Jira migration?", } -// worldSeeds — questions the world can answer, including the two shapes that -// look personal and are not: a first-person preamble on a world question ("как -// я говорил, ..."), and first person without possession ("что я могу -// посмотреть вечером"). Refusing those is the opposite mistake and the older -// comment on personalMarkers already named it. var worldSeeds = []string{ + // The original compact corpus, retained as above. "почему небо синее", "какая столица франции", "как сварить борщ", "кто написал эту книгу", "what is the capital of france", - "how do i boil an egg", - "как я говорил, почему небо синее", "as i said, why is the sky blue", "as i said, what is the population of india", "что я могу посмотреть вечером", @@ -103,21 +172,89 @@ var worldSeeds = []string{ "расскажи про древний рим", "объясни как работает двигатель", "tell me about the roman empire", + + // Speech and reports by somebody other than the owner. + "что Александр Пушкин писал о Москве?", + "как учёные объясняли исчезновение динозавров?", + "что Менделеев говорил о будущем химии?", + "какие выводы сделал Амундсен после экспедиции?", + "what did Virginia Woolf write about fiction?", + "how did researchers describe the Tunguska event?", + "what did witnesses report after the Lisbon earthquake?", + "which ideas did Ada Lovelace describe in her notes?", + + // General advice about an owned object. Ownership supplies context, but an + // outside source can still supply the answer. + "как починить мой скрипящий стул?", + "почему мой роутер теряет соединение?", + "чем очистить мой велосипед от ржавчины?", + "какой бензин подходит для моего генератора?", + "какой чехол подобрать для моего планшета?", + "как защитить мой деревянный стол от влаги?", + "how do I remove a stain from my jacket?", + "why is my freezer building up ice?", + "which oil should I use in my lawn mower?", + "what detergent is safe for my washing machine?", + "which replacement blade should I buy for my circular saw?", + "how can I keep my garden tools from rusting?", + + // Public narratives. + "расскажи историю строительства Транссибирской магистрали", + "опиши, как развивалась письменность", + "объясни, как появился периодический закон", + "опиши первую успешную зимовку в Антарктиде", + "tell the story of the discovery of penicillin", + "describe how the first transatlantic cable was laid", + "explain how the Olympic Games were revived", + "describe the expedition that first reached the South Pole", + + // First-person framing around a public answer. + "как я уже спрашивал, почему звёзды мерцают?", + "повторю свой вопрос: как образуются коралловые рифы?", + "возможно, я повторяюсь: когда возвели собор Святого Петра?", + "я мог уже спрашивать: из чего делают фарфор?", + "as I asked earlier, why do leaves change colour?", + "to repeat my question, how are fjords formed?", + "I might be asking twice, when was Angkor Wat constructed?", + "I may have asked before, what causes bioluminescence?", + + // Public current information and generally applicable advice. + "какие поезда сегодня идут из Москвы в Тверь?", + "как правильно хранить чугунную сковороду?", + "какие выставки проходят в Петербурге в этом месяце?", + "какой сейчас уровень воды в Волге?", + "what is the latest supported version of Ubuntu?", + "how should I prepare a wooden deck for winter?", + "which film festivals are taking place this season?", + "what is the current exchange rate for the Norwegian krone?", + + // Public facts about named people, places and organisations. + "кто такая Софья Ковалевская?", + "когда была основана компания Nintendo?", + "чем прославился архитектор Фрэнк Ллойд Райт?", + "где находится музей Прадо?", + "who was James Baldwin?", + "what is the city of Petra known for?", + "when was the composer Philip Glass born?", + "where is the Uffizi Gallery located?", } -// personalBoundary holds the embedded seeds. Zero value is usable and means -// "not loaded yet"; a handler built without an embedder never loads and the -// boundary falls back to personalMarkers. +// personalBoundary holds the frozen or locally fitted head and, when fitting +// was necessary, its embedded corpus. Zero value is usable and means "not +// loaded yet"; a handler built without an embedder never loads and the boundary +// falls back to personalMarkers. type personalBoundary struct { once sync.Once personal [][]float32 world [][]float32 + head personalBoundaryLinearHead loaded bool } -// load embeds both seed sets, once per process. Seeds are embedded on the QUERY -// side, like the utterance they are compared with — a question against a -// question. Mixing sides would measure the e5 prefix, not the meaning. +// load selects the pinned frozen head or embeds and fits the seed sets once per +// process for another embedding space. Seeds are embedded on the QUERY side, +// like the utterance they classify. Mixing sides would measure the e5 prefix, +// not the meaning. func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) { b.once.Do(func() { if emb == nil { @@ -135,30 +272,149 @@ func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) { } return out } + // The deployed e5-small head is fitted offline from the corpus below and + // checked back against it by TestONNXPersonalBoundaryFrozenHead. Loading + // it directly keeps the first personal query from embedding 132 examples. + if router.EmbedderID(emb) == personalBoundaryHeadModelID { + head, ok := frozenPersonalBoundaryHead() + if ok && len(head.weights) == emb.Dim() { + b.head, b.loaded = head, true + return + } + log.Printf("voice: frozen personal boundary head is corrupt; rebuilding from its corpus") + } + p, w := embedAll(personalSeeds), embedAll(worldSeeds) if p == nil || w == nil { return } - b.personal, b.world, b.loaded = p, w, true + epochs := personalBoundaryTrainingEpochs + if router.EmbedderID(emb) == personalBoundaryHashModelID { + epochs = personalBoundaryHashTrainingEpochs + } + head, ok := trainPersonalBoundaryLinearHeadEpochs(p, w, epochs) + if !ok { + log.Printf("voice: personal boundary training examples have inconsistent dimensions; falling back to possession markers") + return + } + b.personal, b.world, b.head, b.loaded = p, w, head, true }) } -// score returns the best similarity to each side. ok is false when the seeds -// are not loaded, which is the caller's signal to use the markers instead. -func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) { - if !b.loaded || len(vec) == 0 { - return 0, 0, false +const ( + personalBoundaryTrainingEpochs = 5000 + personalBoundaryLearningRate = 10.0 + personalBoundaryL2 = 0.0003 +) + +const personalBoundaryHeadModelID = "model_quantized@384/tok2" + +const personalBoundaryHashModelID = "hash@1024" + +const personalBoundaryHeadWeights = "a3q5vmod2L7msrs+1RE8Pp5HDEBlv609AC9cvzm4D0CL7Fc/FU9cvxLmAMA638c/BgDBP6Is1r7PzBO/6MVAPsmEWT6XowjAouT0v8jMN79d2Sk+7XLlPX2akD+lmKi/q922vvLSFcBb0ma/cN3QP27zBMDl45i/iuE0P4KIJb+7dua+gTePP5unVz9H3q29Sxsev7YJe7+SvoQ+r6jyPxW2DL8sMQc/+iExQM5y8D/qJSZAtFyKP3PbyD8OK0dAHD+0v056qj4AbOS+AFHzP1KPeT9+cqu/aMIQv9wCqL8WbYe/xED1vu7pHMCPlxe/ZUGLPqFoDb8GPQ6/XE6cvqPVi7xKdr0/CE1PP4dPrj6TxoK+KokGP7xxu73h6DW/Lw8APsjd1D43aci/ZBMoQPyy8D8G6w/AMT1tPSEUU7/Sp+c+sjpRvyfl2L4KDs8/q/Ibv3urHj/+7ls/yxjaP8WS8jy8cd6+BO+4P/IcJkBTEPo/q2VGvqvsUD9anuk8UiO/PSw707+5+oY+zBpHP6e+UT4qaEe/zqjGvypN1j45TFY+nZ36v9rP8L9bmyE/Rn8UwONI0D5Yhs6/InCYv4kGgz/LNXO/rhK+Pu2Qdz/W8ijAdi3hv5qT5D9383k8Ir2wP0MRD0AxCCQ/0CUDP5kWoz+TQjdAOxI0vSbxDb/xj54/N/G6v86Ixr932Lk/jQ2jvqn2nr9y3JC96jDDPsyPlj9q/OQ/cOcCQJ+15z9747s/8Zh8PoS4oL0GKma/lfuPv/Clgb9GPKW+2OR3vimzAUBVYxXARcw0vynpsr/IUqe/bsUhv5kwWcCZtnE/fr87vjvfdr4mHis/xMpzvn20HL4SHFu/1DFXvVgOg76GXEq/pB2QP2u6e71q7w0+7F3APlte1j9YKXK/1cljPkFx0L/CndS9b4CeP4BIvj/fP5Q99jbZvL1h778WhC0/pNhov4+x1r+lYeE/9Y6gP9gtqr75dIe/wGiKv4q56D10ckY+UuvDvoIUnz/3TVM/moHcP6FkUz6//pY+FYhcwFEkD8B2a2c9mC+UP/ZeTb5FgIq+rgEOvylj8D9dvx8/OngmPyiplT9oiLy/AJwswKOJdL+i8/m9GPNfvyyWk77jVPC/0u+IPpx/Fz/QdvG/Ag9gP41l2rxmXUo/hdL0vx1XX0BUp+w9hmYyPk21dT6UJmK/zajGP7gBSD0FqoXAkis4P7kehz94wNa//nfZvxA0Fz8b9ze/IETPv3xEb76BG8k/SpyVP9xkEUC2/jlAcv8/wKKxU75E0xM+9BItPzlQKr6S0wdAMa39v0GKA8AMB3G/IeKvvyTZkz+es62/UEYTP3j+lj4SRM+/Dbfgvupdsj/wcUbAbjqRv/WV/r5WRaO/iB67P3/UyD8AK5Q+LzvJPsjPPL/fwkS/atd9P56MHz9CIJu9ugjgvp7J2D8otQC/YYoowKGEFD4eMVC/xy3UP2UEND9nU0i/ol4GQJuwfb+xeaa/B3IjwDK6Gz8dVv8/2wbLPlUo6j+FDCk/4Q/VP/J8JkCYVd0/gMS/P9Bwhj9R94a9M0Mjv/hKdL8cl6Y/lD73vwgior9+56Q/YI+1v9Wd0j8ltAjAmD5dP56Hnb+rdrA+gn2jP0bFA7/lkZU/tK6VP63ItT5Oi7O+YjfUv5iUzT+n5H8/zXMpvjefvj67z66/GA71Pj2h2T5bXxW/EyfLP1LZxr/B758/iCd2v0jnoT8twoG/oAO9vjpYDr61q6I+AEVFv1OP2b1VQpO/5FYdP5vgaz/4Lbm9CMCjvhbWlL9pYQk/1l5hPjCTYj8dtiJATXjavb6SlL7rp0E/cMBgP9UIXLwVYXC+rFS2v9yeFUD88JBAbwWcvt7s1D/bsuU/BCv0PzSdQEA7l36/FULEvmxlo79jjzc+gFvav1vptb/YjkS/Zo76vqK+3j+qvqi/qyfpPj1BLj+ehSzA4Z8nPyS/1b8kz5a9NIuZv31beL/k0oXAXFO/P8cCh8BSPzS+N7agvhjPUD6/G24/GIP0PYlNOsAFe6q+" + +// HashEmbedder is a deterministic offline floor. Its 1024-dimensional head is +// trained on first use instead of embedded here because the binary form is +// still tiny but not meaningful as a production quality claim. The floor's +// optimizer uses fewer steps: the hash vectors are sparse and converge long +// before the semantic head, keeping an unconfigured box responsive. +const personalBoundaryHashTrainingEpochs = 400 + +type personalBoundaryLinearHead struct { + weights []float64 + bias float64 +} + +func frozenPersonalBoundaryHead() (personalBoundaryLinearHead, bool) { + raw, err := base64.StdEncoding.DecodeString(personalBoundaryHeadWeights) + if err != nil || len(raw)%4 != 0 { + return personalBoundaryLinearHead{}, false } - best := func(seeds [][]float32) float64 { - m := -1.0 - for _, s := range seeds { - if c := cosine(vec, s); c > m { - m = c + weights := make([]float64, len(raw)/4) + for i := range weights { + weights[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(raw[4*i:]))) + } + return personalBoundaryLinearHead{weights: weights, bias: -3.122734201373742}, true +} + +// trainPersonalBoundaryLinearHead fits binary logistic regression with full +// batch gradient descent. Each side contributes total weight 0.5 regardless +// of its number of examples. The optimiser is intentionally tiny and local: +// the embedder supplies all learned language knowledge; this only learns one +// separating hyperplane over its 384-dimensional vectors. +func trainPersonalBoundaryLinearHead(personal, world [][]float32) (personalBoundaryLinearHead, bool) { + return trainPersonalBoundaryLinearHeadEpochs(personal, world, personalBoundaryTrainingEpochs) +} + +func trainPersonalBoundaryLinearHeadEpochs(personal, world [][]float32, epochs int) (personalBoundaryLinearHead, bool) { + if len(personal) == 0 || len(world) == 0 || len(personal[0]) == 0 { + return personalBoundaryLinearHead{}, false + } + dim := len(personal[0]) + for _, vectors := range [][][]float32{personal, world} { + for _, vector := range vectors { + if len(vector) != dim { + return personalBoundaryLinearHead{}, false } } - return m } - return best(b.personal), best(b.world), true + + head := personalBoundaryLinearHead{weights: make([]float64, dim)} + personalWeight := 0.5 / float64(len(personal)) + worldWeight := 0.5 / float64(len(world)) + for epoch := 0; epoch < epochs; epoch++ { + gradient := make([]float64, dim) + biasGradient := 0.0 + accumulate := func(vectors [][]float32, target, sampleWeight float64) { + for _, vector := range vectors { + probability := logistic(head.logit(vector)) + error := (probability - target) * sampleWeight + biasGradient += error + for i, value := range vector { + gradient[i] += error * float64(value) + } + } + } + accumulate(personal, 1, personalWeight) + accumulate(world, 0, worldWeight) + + step := personalBoundaryLearningRate / (1 + float64(epoch)/1000) + for i := range head.weights { + head.weights[i] -= step * (gradient[i] + personalBoundaryL2*head.weights[i]) + } + head.bias -= step * biasGradient + } + return head, true +} + +func (h personalBoundaryLinearHead) logit(vec []float32) float64 { + if len(vec) != len(h.weights) { + return 0 + } + score := h.bias + for i, value := range vec { + score += h.weights[i] * float64(value) + } + return score +} + +func logistic(value float64) float64 { + if value >= 0 { + return 1 / (1 + math.Exp(-value)) + } + exp := math.Exp(value) + return exp / (1 + exp) +} + +// score returns complementary class probabilities. ok is false when the +// corpus is not loaded or the query vector belongs to another embedding +// space, which is the caller's signal to use the offline marker floor. +func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) { + if !b.loaded || len(vec) != len(b.head.weights) { + return 0, 0, false + } + personal = logistic(b.head.logit(vec)) + return personal, 1 - personal, true } // cosine — same math as internal/router and internal/memory, small enough that diff --git a/cmd/mavend/personalboundary_eval_test.go b/cmd/mavend/personalboundary_eval_test.go new file mode 100644 index 0000000..d7008a6 --- /dev/null +++ b/cmd/mavend/personalboundary_eval_test.go @@ -0,0 +1,405 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "math" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "unicode" + + "github.com/kami/maven/internal/router" +) + +// This fixture is intentionally separate from personalboundary_test.go. The +// small regression table there explains individual fixes; this matrix measures +// the boundary as a classifier and prevents a repaired sentence shape from +// standing in for language and subject coverage. +// +//go:embed testdata/personal_boundary_v1.json +var personalBoundaryFixtureJSON []byte + +type personalBoundaryEvalCase struct { + ID string `json:"id"` + Utterance string `json:"utterance"` + Lang string `json:"lang"` + Want string `json:"want"` + Stratum string `json:"stratum"` +} + +type personalBoundaryEvalFixture struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Cases []personalBoundaryEvalCase `json:"cases"` +} + +var personalBoundaryEvalStrata = []string{ + "remembered_speech", + "possession", + "narrative", + "first_person_preamble", + "advice_current_info", + "public_proper_nouns", +} + +func loadPersonalBoundaryEvalFixture(t *testing.T) personalBoundaryEvalFixture { + t.Helper() + var fixture personalBoundaryEvalFixture + if err := json.Unmarshal(personalBoundaryFixtureJSON, &fixture); err != nil { + t.Fatalf("parse personal boundary fixture: %v", err) + } + if fixture.SchemaVersion != 1 { + t.Fatalf("personal boundary fixture schema_version = %d, want 1", fixture.SchemaVersion) + } + if fixture.Name != "personal_boundary_v1" { + t.Fatalf("personal boundary fixture name = %q, want personal_boundary_v1", fixture.Name) + } + return fixture +} + +// TestPersonalBoundaryEvalFixture enforces the sampling contract separately +// from the model measurement. It runs in ordinary CI even when ONNX Runtime is +// absent, so a fixture edit cannot silently unbalance a language, side or +// sentence shape, or turn a production seed into a held-out case. +func TestPersonalBoundaryEvalFixture(t *testing.T) { + fixture := loadPersonalBoundaryEvalFixture(t) + const wantPerCell = 3 + const wantTotal = 6 * 2 * 2 * wantPerCell + if len(fixture.Cases) != wantTotal { + t.Errorf("fixture has %d cases, want %d", len(fixture.Cases), wantTotal) + } + + validStrata := make(map[string]bool, len(personalBoundaryEvalStrata)) + for _, stratum := range personalBoundaryEvalStrata { + validStrata[stratum] = true + } + seedSource := make(map[string]string, len(personalSeeds)+len(worldSeeds)) + for _, seed := range personalSeeds { + seedSource[normalizePersonalBoundaryEval(seed)] = "personalSeeds" + } + for _, seed := range worldSeeds { + seedSource[normalizePersonalBoundaryEval(seed)] = "worldSeeds" + } + + seenID := make(map[string]bool, len(fixture.Cases)) + seenUtterance := make(map[string]string, len(fixture.Cases)) + cells := make(map[string]int) + for _, c := range fixture.Cases { + if strings.TrimSpace(c.ID) == "" || seenID[c.ID] { + t.Errorf("case %q: empty or duplicate id", c.ID) + } + seenID[c.ID] = true + if c.Lang != "ru" && c.Lang != "en" { + t.Errorf("%s: lang = %q, want ru|en", c.ID, c.Lang) + } + if c.Want != "personal" && c.Want != "world" { + t.Errorf("%s: want = %q, want personal|world", c.ID, c.Want) + } + if !validStrata[c.Stratum] { + t.Errorf("%s: stratum = %q, not one of the six declared strata", c.ID, c.Stratum) + } + + normalized := normalizePersonalBoundaryEval(c.Utterance) + if normalized == "" { + t.Errorf("%s: empty utterance", c.ID) + } + if previous, ok := seenUtterance[normalized]; ok { + t.Errorf("%s: utterance duplicates %s after normalization", c.ID, previous) + } + seenUtterance[normalized] = c.ID + if source, ok := seedSource[normalized]; ok { + t.Errorf("%s: %q is verbatim in %s, so it is not held out", c.ID, c.Utterance, source) + } + // The original failure names Baikal. Replacing that sentence's verb or + // punctuation would measure an exception, not the boundary. This corpus + // instead varies people, places, products and events. + if strings.Contains(normalized, "байкал") || strings.Contains(normalized, "baikal") { + t.Errorf("%s: the stratified fixture must not copy the Baikal regression", c.ID) + } + cells[c.Stratum+"/"+c.Lang+"/"+c.Want]++ + } + + for _, stratum := range personalBoundaryEvalStrata { + for _, lang := range []string{"ru", "en"} { + for _, want := range []string{"personal", "world"} { + cell := stratum + "/" + lang + "/" + want + if got := cells[cell]; got != wantPerCell { + t.Errorf("fixture cell %s has %d cases, want %d", cell, got, wantPerCell) + } + } + } + } +} + +// normalizePersonalBoundaryEval compares content rather than typography: +// case, punctuation and repeated whitespace cannot disguise a copied seed or +// duplicate case. This is fixture hygiene only; it does not participate in the +// production boundary. +func normalizePersonalBoundaryEval(s string) string { + var b strings.Builder + space := true + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + b.WriteRune(r) + space = false + continue + } + if !space { + b.WriteByte(' ') + space = true + } + } + return strings.TrimSpace(b.String()) +} + +type personalBoundaryEvalStat struct { + Correct int + Total int +} + +type personalBoundaryEvalReport struct { + Name string + Correct int + Total int + MinimumMargin float64 + ByStratum map[string]personalBoundaryEvalStat + ByLanguage map[string]personalBoundaryEvalStat + ByExpectedClass map[string]personalBoundaryEvalStat + ByCell map[string]personalBoundaryEvalStat +} + +func newPersonalBoundaryEvalReport(name string) *personalBoundaryEvalReport { + return &personalBoundaryEvalReport{ + Name: name, + MinimumMargin: math.Inf(1), + ByStratum: make(map[string]personalBoundaryEvalStat), + ByLanguage: make(map[string]personalBoundaryEvalStat), + ByExpectedClass: make(map[string]personalBoundaryEvalStat), + ByCell: make(map[string]personalBoundaryEvalStat), + } +} + +func (r *personalBoundaryEvalReport) add(c personalBoundaryEvalCase, gotPersonal bool, personal, world float64) { + wantPersonal := c.Want == "personal" + correct := gotPersonal == wantPersonal + r.Total++ + if correct { + r.Correct++ + } + signedMargin := personal - world + if !wantPersonal { + signedMargin = -signedMargin + } + if signedMargin < r.MinimumMargin { + r.MinimumMargin = signedMargin + } + add := func(stats map[string]personalBoundaryEvalStat, key string) { + stat := stats[key] + stat.Total++ + if correct { + stat.Correct++ + } + stats[key] = stat + } + add(r.ByStratum, c.Stratum) + add(r.ByLanguage, c.Lang) + add(r.ByExpectedClass, c.Want) + add(r.ByCell, c.Stratum+"/"+c.Lang+"/"+c.Want) +} + +// TestONNXPersonalBoundaryStratified scores the model homesrv actually runs. +// Production is read from personalBoundary.score; top1, top2, top3 and a +// whole-class centroid are diagnostics over the same embedded seeds. Today +// production and top3 coincide, but keeping them separate means a later scoring +// experiment can be compared without rewriting this evaluation or putting its +// candidate math in runtime code. The privacy boundary is a hard contract, so +// every production miss is a test failure rather than an accuracy target to +// average away. +func TestONNXPersonalBoundaryStratified(t *testing.T) { + if os.Getenv("MAVEN_EVAL_PERSONAL_BOUNDARY") == "" { + t.Skip("set MAVEN_EVAL_PERSONAL_BOUNDARY=1 to run the deliberately strict V-702 matrix") + } + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + modelDir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + model := filepath.Join(modelDir, "model_quantized.onnx") + tokenizer := filepath.Join(modelDir, "tokenizer.json") + for _, path := range []string{lib, model, tokenizer} { + if _, err := os.Stat(path); err != nil { + t.Skipf("personal boundary eval dependency %s unavailable: %v", path, err) + } + } + + embedder, err := router.NewONNXEmbedder(model, tokenizer, lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer embedder.Close() + + ctx := context.Background() + boundary := &personalBoundary{} + boundary.load(ctx, embedder) + if !boundary.loaded { + t.Fatal("personal boundary seeds did not load with a working embedder") + } + // Production loads its model-ID-pinned frozen head and deliberately skips + // the 132 corpus embeddings on a user's first query. This test still needs + // those vectors for the historical top-k/centroid diagnostics, so build + // them here without putting that latency back in runtime code. + embedCorpus := func(values []string) [][]float32 { + vectors := make([][]float32, len(values)) + for i, value := range values { + vector, err := router.EmbedQuery(ctx, embedder, value) + if err != nil { + t.Fatalf("embed diagnostic corpus %q: %v", value, err) + } + vectors[i] = vector + } + return vectors + } + boundary.personal = embedCorpus(personalSeeds) + boundary.world = embedCorpus(worldSeeds) + personalCentroid := personalBoundaryEvalCentroid(boundary.personal) + worldCentroid := personalBoundaryEvalCentroid(boundary.world) + if len(personalCentroid) == 0 || len(worldCentroid) == 0 { + t.Fatal("personal boundary seed vectors do not share a dimension") + } + + type candidate struct { + name string + score func([]float32) (float64, float64) + } + candidates := []candidate{ + {name: "production", score: func(vec []float32) (float64, float64) { + personal, world, ok := boundary.score(vec) + if !ok { + t.Fatal("loaded personal boundary declined to score") + } + return personal, world + }}, + {name: "top1", score: func(vec []float32) (float64, float64) { + return meanNearest(vec, boundary.personal, 1), meanNearest(vec, boundary.world, 1) + }}, + {name: "top2", score: func(vec []float32) (float64, float64) { + return meanNearest(vec, boundary.personal, 2), meanNearest(vec, boundary.world, 2) + }}, + {name: "top3", score: func(vec []float32) (float64, float64) { + return meanNearest(vec, boundary.personal, 3), meanNearest(vec, boundary.world, 3) + }}, + {name: "centroid", score: func(vec []float32) (float64, float64) { + return cosine(vec, personalCentroid), cosine(vec, worldCentroid) + }}, + } + reports := make(map[string]*personalBoundaryEvalReport, len(candidates)) + for _, candidate := range candidates { + reports[candidate.name] = newPersonalBoundaryEvalReport(candidate.name) + } + + fixture := loadPersonalBoundaryEvalFixture(t) + for _, c := range fixture.Cases { + vec, err := router.EmbedQuery(ctx, embedder, c.Utterance) + if err != nil { + t.Fatalf("%s: embed %q: %v", c.ID, c.Utterance, err) + } + for _, candidate := range candidates { + personal, world := candidate.score(vec) + gotPersonal := personal > world + reports[candidate.name].add(c, gotPersonal, personal, world) + if candidate.name == "production" && gotPersonal != (c.Want == "personal") { + t.Errorf("%s [%s/%s]: got %s, want %s (personal %.4f world %.4f delta %+.4f): %q", + c.ID, c.Lang, c.Stratum, boundaryEvalSide(gotPersonal), c.Want, + personal, world, personal-world, c.Utterance) + } + } + } + + for _, candidate := range candidates { + report := reports[candidate.name] + t.Logf("candidate %-15s %2d/%d (%.1f%%), minimum signed margin %+.4f", + report.Name, report.Correct, report.Total, + 100*float64(report.Correct)/float64(report.Total), report.MinimumMargin) + } + production := reports["production"] + for _, lang := range []string{"ru", "en"} { + stat := production.ByLanguage[lang] + t.Logf("production language %-2s %2d/%d", lang, stat.Correct, stat.Total) + } + for _, side := range []string{"personal", "world"} { + stat := production.ByExpectedClass[side] + t.Logf("production expected %-8s %2d/%d", side, stat.Correct, stat.Total) + } + strata := append([]string(nil), personalBoundaryEvalStrata...) + sort.Strings(strata) + for _, stratum := range strata { + stat := production.ByStratum[stratum] + ruPersonal := production.ByCell[stratum+"/ru/personal"] + ruWorld := production.ByCell[stratum+"/ru/world"] + enPersonal := production.ByCell[stratum+"/en/personal"] + enWorld := production.ByCell[stratum+"/en/world"] + t.Logf("production stratum %-21s %2d/%d | ru personal %d/%d world %d/%d | en personal %d/%d world %d/%d", + stratum, stat.Correct, stat.Total, + ruPersonal.Correct, ruPersonal.Total, ruWorld.Correct, ruWorld.Total, + enPersonal.Correct, enPersonal.Total, enWorld.Correct, enWorld.Total) + } +} + +func personalBoundaryEvalCentroid(vectors [][]float32) []float32 { + if len(vectors) == 0 { + return nil + } + centroid := make([]float32, len(vectors[0])) + for _, vector := range vectors { + if len(vector) != len(centroid) { + return nil + } + for i, value := range vector { + centroid[i] += value + } + } + for i := range centroid { + centroid[i] /= float32(len(vectors)) + } + return centroid +} + +func boundaryEvalSide(personal bool) string { + if personal { + return "personal" + } + return "world" +} + +// meanNearest is an evaluation baseline retained beside the strict fixture; +// production uses the linear head in personalboundary.go. +func meanNearest(vec []float32, seeds [][]float32, k int) float64 { + if len(seeds) == 0 || k <= 0 { + return -1 + } + if k > len(seeds) { + k = len(seeds) + } + top := make([]float64, k) + for i := range top { + top[i] = -1 + } + for _, seed := range seeds { + candidate := cosine(vec, seed) + for i := range top { + if candidate > top[i] { + candidate, top[i] = top[i], candidate + } + } + } + var sum float64 + for _, similarity := range top { + sum += similarity + } + return sum / float64(k) +} diff --git a/cmd/mavend/personalboundary_test.go b/cmd/mavend/personalboundary_test.go index cc21295..e4e37f1 100644 --- a/cmd/mavend/personalboundary_test.go +++ b/cmd/mavend/personalboundary_test.go @@ -2,13 +2,213 @@ package main import ( "context" + "math" "os" "path/filepath" + "strings" "testing" + "time" "github.com/kami/maven/internal/router" ) +func TestPersonalBoundaryLinearHeadSeparatesSemanticDirections(t *testing.T) { + personal := [][]float32{{1, 0}, {0.9, 0.1}, {0.8, -0.1}} + world := [][]float32{{-1, 0}, {-0.9, 0.1}, {-0.8, -0.1}} + head, ok := trainPersonalBoundaryLinearHead(personal, world) + if !ok { + t.Fatal("valid training vectors were rejected") + } + b := personalBoundary{personal: personal, world: world, head: head, loaded: true} + for _, tc := range []struct { + vector []float32 + personal bool + }{ + {vector: []float32{0.75, 0.2}, personal: true}, + {vector: []float32{-0.75, 0.2}, personal: false}, + } { + personalScore, worldScore, ok := b.score(tc.vector) + if !ok { + t.Fatal("loaded boundary did not score") + } + if got := personalScore > worldScore; got != tc.personal { + t.Fatalf("vector %v classified personal=%v (scores %.4f/%.4f), want %v", + tc.vector, got, personalScore, worldScore, tc.personal) + } + if math.Abs(personalScore+worldScore-1) > 1e-12 { + t.Fatalf("scores %.8f and %.8f are not complementary probabilities", personalScore, worldScore) + } + } +} + +func TestPersonalBoundaryTrainingBalancesClasses(t *testing.T) { + personal := [][]float32{{1, 0}, {0.8, 0.2}} + world := [][]float32{{-1, 0}} + oneWorld, ok := trainPersonalBoundaryLinearHead(personal, world) + if !ok { + t.Fatal("valid training vectors were rejected") + } + repeatedWorld := make([][]float32, 12) + for i := range repeatedWorld { + repeatedWorld[i] = world[0] + } + twelveWorld, ok := trainPersonalBoundaryLinearHead(personal, repeatedWorld) + if !ok { + t.Fatal("valid repeated training vectors were rejected") + } + if math.Abs(oneWorld.bias-twelveWorld.bias) > 1e-10 { + t.Fatalf("duplicating one class moved bias from %.12f to %.12f", oneWorld.bias, twelveWorld.bias) + } + for i := range oneWorld.weights { + if math.Abs(oneWorld.weights[i]-twelveWorld.weights[i]) > 1e-10 { + t.Fatalf("duplicating one class moved weight %d from %.12f to %.12f", + i, oneWorld.weights[i], twelveWorld.weights[i]) + } + } +} + +func TestPersonalBoundaryTrainingRejectsMixedDimensions(t *testing.T) { + if _, ok := trainPersonalBoundaryLinearHead( + [][]float32{{1, 0}}, + [][]float32{{-1, 0, 0}}, + ); ok { + t.Fatal("mixed embedding dimensions were accepted") + } +} + +// The corpus is grouped by sentence shape in personalboundary.go. This test +// leaves one entire shape out of training at a time, then requires the linear +// head to classify the omitted examples from the semantics learned from the +// other shapes. It is ordinary deterministic CI: the small axis vectors stand +// in for frozen embedding directions, so the test proves the training code +// generalises across groups rather than memorising one row at a time. +func TestPersonalBoundaryLinearHeadLeaveOneShapeOut(t *testing.T) { + type example struct { + vector []float32 + shape int + want bool + } + const shapeCount = 6 + examples := make([]example, 0, shapeCount*4) + for shape := 0; shape < shapeCount; shape++ { + for variant := 0; variant < 2; variant++ { + personal := make([]float32, shapeCount+1) + world := make([]float32, shapeCount+1) + personal[0], world[0] = 1, -1 + personal[shape+1] = float32(0.1 * float64(variant+1)) + world[shape+1] = float32(-0.1 * float64(variant+1)) + examples = append(examples, + example{vector: personal, shape: shape, want: true}, + example{vector: world, shape: shape, want: false}, + ) + } + } + + for omitted := 0; omitted < shapeCount; omitted++ { + var personal, world [][]float32 + for _, example := range examples { + if example.shape == omitted { + continue + } + if example.want { + personal = append(personal, example.vector) + } else { + world = append(world, example.vector) + } + } + head, ok := trainPersonalBoundaryLinearHead(personal, world) + if !ok { + t.Fatalf("fold %d rejected valid vectors", omitted) + } + for _, example := range examples { + if example.shape != omitted { + continue + } + if got := head.logit(example.vector) > 0; got != example.want { + t.Errorf("fold %d classified %v as personal=%v, want %v", omitted, example.vector, got, example.want) + } + } + } +} + +func TestPersonalBoundaryTrainingCorpusIsIndependent(t *testing.T) { + // The strict stratified fixture already enforces this for its 72 rows. The + // historical regression table lives here, so protect it here too: a future + // seed addition must not copy a regression sentence into training. + training := make(map[string]bool, len(personalSeeds)+len(worldSeeds)) + for _, seed := range append(append([]string(nil), personalSeeds...), worldSeeds...) { + training[normalizePersonalBoundaryTraining(seed)] = true + } + for _, regression := range []string{ + "что я говорил про бэкапы?", + "что я сказал вчера про отпуск", + "я писал что-нибудь про сервер", + "я упоминал про конференцию?", + "что я отмечал по поводу переезда", + "я рассказывал тебе про новую работу?", + "во сколько у меня встреча", + "когда мой следующий отпуск", + "what did i say about backups", + "did i tell you about the doctor", + "как я говорил, почему небо синее", + "как уже я говорил, какая столица франции", + "почему трава зелёная", + "столица франции", + "как мне сварить борщ", + "что мне посмотреть вечером", + "я хочу узнать про рим", + "кто такой гагарин", + "how do i boil an egg", + "во сколько закат сегодня", + "когда сегодня заканчивается концерт", + "во сколько завтра открывается аптека", + "какой сегодня праздник", + "что интересного произошло сегодня в мире", + "кто выиграл вчера матч", + "расскажи про эверест", + "расскажи про войну 1812 года", + "объясни что такое инфляция", + "я рассказывал тебе про байкал?", + } { + if training[normalizePersonalBoundaryTraining(regression)] { + t.Errorf("regression utterance leaked into training: %q", regression) + } + } +} + +func TestPersonalBoundaryFrozenHeadDecodes(t *testing.T) { + head, ok := frozenPersonalBoundaryHead() + if !ok { + t.Fatal("frozen head did not decode") + } + if len(head.weights) != 384 { + t.Fatalf("frozen head has %d weights, want 384", len(head.weights)) + } +} + +func TestPersonalBoundaryHashFloorLatency(t *testing.T) { + b := &personalBoundary{} + embedder := router.NewHashEmbedder(1024) + query, err := router.EmbedQuery(context.Background(), embedder, "когда моя встреча") + if err != nil { + t.Fatal(err) + } + started := time.Now() + b.load(context.Background(), embedder) + if _, _, ok := b.score(query); !ok { + t.Fatal("hash-floor boundary declined to score") + } + elapsed := time.Since(started) + t.Logf("hash-floor corpus fit+score: %s", elapsed) + if elapsed > 2*time.Second { + t.Errorf("hash-floor boundary took %s, exceeds 2s local floor ceiling", elapsed) + } +} + +func normalizePersonalBoundaryTraining(value string) string { + return strings.Join(strings.Fields(strings.ToLower(value)), " ") +} + // A handler with no embedder never loads the seeds, so the boundary falls back // to the possession markers. That is the offline floor and it must keep working // — an embedder that fails to load must not open the boundary. @@ -113,3 +313,419 @@ func TestONNXPersonalBoundary(t *testing.T) { } t.Logf("personal boundary: %d/%d held-out utterances correct", len(cases)-wrong, len(cases)) } + +func TestONNXPersonalBoundaryFourFold(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + ctx := context.Background() + embedAll := func(values []string) [][]float32 { + vectors := make([][]float32, len(values)) + for i, value := range values { + vector, err := router.EmbedQuery(ctx, emb, value) + if err != nil { + t.Fatalf("embed %q: %v", value, err) + } + vectors[i] = vector + } + return vectors + } + personalVectors := embedAll(personalSeeds) + worldVectors := embedAll(worldSeeds) + + type group struct { + name string + personalStart, personalCount int + worldStart, worldCount int + } + groups := []group{ + {name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8}, + {name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12}, + {name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8}, + {name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8}, + {name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8}, + {name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8}, + } + + const foldCount = 4 + aggregateCorrect, aggregateTotal := 0, 0 + for omittedFold := 0; omittedFold < foldCount; omittedFold++ { + trainingPersonal := append([][]float32(nil), personalVectors[:8]...) + trainingWorld := append([][]float32(nil), worldVectors[:20]...) + var heldPersonal, heldWorld [][]float32 + partition := func(vectors [][]float32, start, count int, training, held *[][]float32) { + for relative, vector := range vectors[start : start+count] { + if relative%foldCount == omittedFold { + *held = append(*held, vector) + } else { + *training = append(*training, vector) + } + } + } + for _, group := range groups { + partition(personalVectors, group.personalStart, group.personalCount, &trainingPersonal, &heldPersonal) + partition(worldVectors, group.worldStart, group.worldCount, &trainingWorld, &heldWorld) + } + head, ok := trainPersonalBoundaryLinearHead( + trainingPersonal, + trainingWorld, + ) + if !ok { + t.Fatalf("fold %d: valid training fold rejected", omittedFold) + } + correct, total := 0, 0 + for _, vector := range heldPersonal { + total++ + if head.logit(vector) > 0 { + correct++ + } + } + for _, vector := range heldWorld { + total++ + if head.logit(vector) <= 0 { + correct++ + } + } + t.Logf("fold %d: %d/%d held-out training examples", omittedFold+1, correct, total) + aggregateCorrect += correct + aggregateTotal += total + } + t.Logf("four-fold aggregate: %d/%d", aggregateCorrect, aggregateTotal) + if aggregateCorrect < 99 { + t.Errorf("four-fold aggregate %d/%d, want at least 99/104", aggregateCorrect, aggregateTotal) + } +} + +func TestONNXPersonalBoundarySemanticGroupHoldout(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + ctx := context.Background() + embedAll := func(values []string) [][]float32 { + vectors := make([][]float32, len(values)) + for i, value := range values { + vector, err := router.EmbedQuery(ctx, emb, value) + if err != nil { + t.Fatalf("embed %q: %v", value, err) + } + vectors[i] = vector + } + return vectors + } + personalVectors := embedAll(personalSeeds) + worldVectors := embedAll(worldSeeds) + + type group struct { + name string + personalStart, personalCount int + worldStart, worldCount int + } + groups := []group{ + {name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8}, + {name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12}, + {name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8}, + {name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8}, + {name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8}, + {name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8}, + } + + aggregateCorrect, aggregateTotal := 0, 0 + for _, omitted := range groups { + excluding := func(vectors [][]float32, start, count int) [][]float32 { + result := make([][]float32, 0, len(vectors)-count) + result = append(result, vectors[:start]...) + return append(result, vectors[start+count:]...) + } + head, ok := trainPersonalBoundaryLinearHead( + excluding(personalVectors, omitted.personalStart, omitted.personalCount), + excluding(worldVectors, omitted.worldStart, omitted.worldCount), + ) + if !ok { + t.Fatalf("%s: valid training fold rejected", omitted.name) + } + correct, total := 0, 0 + for _, vector := range personalVectors[omitted.personalStart : omitted.personalStart+omitted.personalCount] { + total++ + if head.logit(vector) > 0 { + correct++ + } + } + for _, vector := range worldVectors[omitted.worldStart : omitted.worldStart+omitted.worldCount] { + total++ + if head.logit(vector) <= 0 { + correct++ + } + } + t.Logf("leave %-21s out: %d/%d", omitted.name, correct, total) + aggregateCorrect += correct + aggregateTotal += total + // Whole-shape holdout is an honest diagnostic, not a 100% release gate: + // some shapes (notably private-vs-general possession) define a distinct + // semantic ambiguity. The separately authored challenge set remains the + // strict generalisation gate. + } + if aggregateCorrect < 92 { + t.Errorf("whole-shape aggregate %d/%d, want at least 92/104", aggregateCorrect, aggregateTotal) + } +} + +// This challenge set was originally authored after the six-shape training +// corpus and the 72-case matrix were frozen. Its sole miss then informed the +// regularisation comparison, so it is now a strict regression gate rather than +// independent evidence. It remains outside the production corpus. +func TestONNXPersonalBoundaryChallenge(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + + cases := []struct { + utterance string + personal bool + }{ + {"какой пароль приложения я задал для почтового клиента?", true}, + {"на каком порту я решил поднять тестовый сервис?", true}, + {"какую причину я указал, когда отменил бронь?", true}, + {"где в гараже я сложил зимние шины?", true}, + {"какой сериал я бросил после второго сезона?", true}, + {"о чём мы договорились с Олегом на прошлой неделе?", true}, + {"почему мой монитор мерцает при частоте 144 герца?", false}, + {"подойдёт ли кабель Thunderbolt 3 к разъёму USB4?", false}, + {"как вывести запах дыма из моей куртки?", false}, + {"что означают кольца на флаге Олимпиады?", false}, + {"почему после дождя на асфальте видна радуга?", false}, + {"какой формат файлов поддерживает Kindle Paperwhite?", false}, + {"which SSH key did I install on the build server?", true}, + {"what spending limit did I set for the travel card?", true}, + {"where did I store the spare apartment fob?", true}, + {"which objection did I raise during the design review?", true}, + {"what route did I plan for the Sunday hike?", true}, + {"when did I promise Maya I would send the draft?", true}, + {"why does my mechanical keyboard sometimes chatter?", false}, + {"can my USB-C charger safely power a Steam Deck?", false}, + {"how do I stop condensation inside my camera lens?", false}, + {"what caused the Tacoma Narrows Bridge to collapse?", false}, + {"why are some auroras red instead of green?", false}, + {"which codecs does the current Firefox release support?", false}, + } + + b := &personalBoundary{} + b.load(context.Background(), emb) + correct := 0 + minimumMargin := math.Inf(1) + for _, testCase := range cases { + vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance) + if err != nil { + t.Fatalf("embed %q: %v", testCase.utterance, err) + } + personal, world, ok := b.score(vector) + if !ok { + t.Fatal("loaded boundary declined to score") + } + got := personal > world + signedMargin := personal - world + if !testCase.personal { + signedMargin = -signedMargin + } + if signedMargin < minimumMargin { + minimumMargin = signedMargin + } + if got == testCase.personal { + correct++ + } else { + t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world) + } + } + t.Logf("regularisation challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin) + if correct != len(cases) { + t.Errorf("regularisation challenge %d/%d, want every case correct", correct, len(cases)) + } +} + +// TestONNXPersonalBoundaryPostRetuneChallenge was authored only after the L2 +// coefficient and frozen head had been selected using corpus cross-validation. +// It deliberately returns to private configuration, commitments and stored +// choices with new objects, and contrasts them with public technical facts, +// compatibility and maintenance. No result from this table may be used to +// tune the current head; a miss is evidence for the next independently +// evaluated model revision. +func TestONNXPersonalBoundaryPostRetuneChallenge(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + + cases := []struct { + utterance string + personal bool + }{ + {"какое имя я выбрал для гостевой сети Wi-Fi?", true}, + {"на какой день я перенёс техосмотр машины?", true}, + {"какую сумму мы с Мариной согласовали за ремонт кухни?", true}, + {"где я сохранил резервные коды от GitHub?", true}, + {"какой из макетов визитки я одобрил?", true}, + {"что я решил делать со страховкой перед поездкой?", true}, + {"какой диапазон частот использует Wi-Fi 6E?", false}, + {"почему OLED-экраны со временем выгорают?", false}, + {"можно ли подключить монитор DisplayPort к Thunderbolt 4?", false}, + {"чем безопасно чистить замшевые ботинки?", false}, + {"когда появился протокол WebSocket?", false}, + {"почему соль ускоряет таяние льда?", false}, + {"which hostname did I assign to the home NAS?", true}, + {"what date did I move the annual checkup to?", true}, + {"where did I save the recovery phrase for the hardware wallet?", true}, + {"which catering quote did we accept for the party?", true}, + {"what did I decide about renewing the domain?", true}, + {"which paint sample did I approve for the hallway?", true}, + {"does Wi-Fi 7 work with older wireless clients?", false}, + {"why can an SSD slow down when it is nearly full?", false}, + {"how should suede shoes be cleaned?", false}, + {"when was the WebSocket protocol standardized?", false}, + {"what does a hardware-wallet recovery phrase do?", false}, + {"why does road salt damage concrete?", false}, + } + + b := &personalBoundary{} + b.load(context.Background(), emb) + correct := 0 + minimumMargin := math.Inf(1) + for _, testCase := range cases { + vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance) + if err != nil { + t.Fatalf("embed %q: %v", testCase.utterance, err) + } + personal, world, ok := b.score(vector) + if !ok { + t.Fatal("loaded boundary declined to score") + } + got := personal > world + signedMargin := personal - world + if !testCase.personal { + signedMargin = -signedMargin + } + if signedMargin < minimumMargin { + minimumMargin = signedMargin + } + if got == testCase.personal { + correct++ + } else { + t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world) + } + } + t.Logf("post-retune challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin) + if correct != len(cases) { + t.Errorf("post-retune challenge %d/%d, want every case correct", correct, len(cases)) + } +} + +func TestONNXPersonalBoundaryLatency(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + ctx := context.Background() + query, err := router.EmbedQuery(ctx, emb, "что я решил насчёт переезда?") + if err != nil { + t.Fatal(err) + } + + b := &personalBoundary{} + coldStart := time.Now() + b.load(ctx, emb) + if _, _, ok := b.score(query); !ok { + t.Fatal("loaded boundary declined to score") + } + cold := time.Since(coldStart) + + const iterations = 100000 + steadyStart := time.Now() + for i := 0; i < iterations; i++ { + if _, _, ok := b.score(query); !ok { + t.Fatal("loaded boundary declined to score") + } + } + steady := time.Since(steadyStart) / iterations + t.Logf("boundary cold load+train+score: %s; steady score: %s/op", cold, steady) + // This is a user-visible first-turn path. Keep a generous ceiling to avoid + // noisy CI while making an accidental per-turn training/load regression + // unmistakable. + if cold > 5*time.Second { + t.Errorf("cold boundary load %s exceeds 5s local usability ceiling", cold) + } + if steady > 100*time.Microsecond { + t.Errorf("steady boundary score %s exceeds 100µs ceiling", steady) + } +} + +func TestONNXPersonalBoundaryFrozenHeadMatchesCorpusFit(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + ctx := context.Background() + embedAll := func(values []string) [][]float32 { + vectors := make([][]float32, len(values)) + for i, value := range values { + vector, err := router.EmbedQuery(ctx, emb, value) + if err != nil { + t.Fatalf("embed %q: %v", value, err) + } + vectors[i] = vector + } + return vectors + } + fitted, ok := trainPersonalBoundaryLinearHead(embedAll(personalSeeds), embedAll(worldSeeds)) + if !ok { + t.Fatal("corpus fit failed") + } + frozen, ok := frozenPersonalBoundaryHead() + if !ok { + t.Fatal("frozen head did not decode") + } + if math.Abs(fitted.bias-frozen.bias) > 1e-9 { + t.Fatalf("frozen bias %.12f != fitted %.12f", frozen.bias, fitted.bias) + } + for i := range fitted.weights { + if math.Abs(fitted.weights[i]-frozen.weights[i]) > 5e-7 { + t.Fatalf("frozen weight %d %.12f != fitted %.12f", i, frozen.weights[i], fitted.weights[i]) + } + } +} diff --git a/cmd/mavend/repair.go b/cmd/mavend/repair.go index 57cfe1f..3207ee3 100644 --- a/cmd/mavend/repair.go +++ b/cmd/mavend/repair.go @@ -172,14 +172,57 @@ func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) { h.lastRouted.traceID = traceID } -func (h *reactiveHandler) takeLastTurn() *routedTurn { +// takeLastTurnIf atomically claims the previous acted turn only when the +// caller can actually handle it. A declined repair must not spend the pointer: +// "нет, это заметка" may name the intent Maven already chose and be followed +// immediately by the real correction. The older read-then-clear helper lost +// the original before checking either that case or the repair window (V-573). +func (h *reactiveHandler) takeLastTurnIf(accept func(*routedTurn) bool) *routedTurn { h.mu.Lock() defer h.mu.Unlock() - last := h.lastRouted - // Taken, not read: one utterance is corrected once. Saying "нет, не так" - // twice would otherwise redo the same request twice. + if h.lastRouted == nil || !accept(h.lastRouted) { + return nil + } + last := *h.lastRouted + // A handled correction is still spent once. Returning a copy prevents a + // later trace stamp from mutating the evidence after this resolver owns it. h.lastRouted = nil - return last + return &last +} + +// takeTargetedRepair atomically distinguishes the three outcomes a targeted +// correction needs. A recent, differently-routed turn is claimed and spent; a +// recent turn already carrying that intent is retained and reported as +// already-correct; everything else declines. Treating the second case as a +// generic decline lets runTurn route the correction words as a fresh turn and +// record them over the very pointer this helper was meant to preserve. +func (h *reactiveHandler) takeTargetedRepair(now time.Time, corrected router.Intent) (last *routedTurn, already bool) { + h.mu.Lock() + defer h.mu.Unlock() + if h.lastRouted == nil || now.Sub(h.lastRouted.at) > repairWindow { + return nil, false + } + if h.lastRouted.intent == corrected { + return nil, true + } + copy := *h.lastRouted + h.lastRouted = nil + return ©, false +} + +// suspendClarifyForRepair makes a correction an aside to any question already +// parked in this dialogue. It is called only after a repair has actually found +// a target, so an ordinary utterance that merely resembles one changes no +// dialogue state. If the redo itself needs a question, askClarify sees the +// suspended flag and pushes that question instead of overwriting the older +// request. +func (h *reactiveHandler) suspendClarifyForRepair(ctx context.Context) { + if h.clarifyStore == nil { + return + } + if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil { + h.noteSuspended(ctx, q) + } } // resolveUntargetedRepair handles the cheap half of a spoken correction: he says @@ -199,15 +242,16 @@ func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text stri if !isRepairNegative(text) { return "", false } - last := h.takeLastTurn() - if last == nil || h.now().Sub(last.at) > repairWindow { - return "", false - } - if last.traceID == 0 { + now := h.now() + last := h.takeLastTurnIf(func(last *routedTurn) bool { + return now.Sub(last.at) <= repairWindow && last.traceID != 0 + }) + if last == nil { // No row to point at, so there is no label to write and nothing this // resolver can do. Routing the words normally is the honest outcome. return "", false } + h.suspendClarifyForRepair(ctx) h.labelCorrection(ctx, last, "") log.Printf("voice: repair — %q marked wrong, no target given", last.utterance) return phraser.A(phraser.RepairNoted, nil), true @@ -239,16 +283,20 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin if !ok || h.router == nil { return "", false } - last := h.takeLastTurn() - if last == nil || h.now().Sub(last.at) > repairWindow { - return "", false - } - if last.intent == corrected { - // She already did what he is asking for. Correcting the classifier - // here would teach it the label it produced, and redoing the request - // would file it twice. + last, already := h.takeTargetedRepair(h.now(), corrected) + if already { + // This is still a correction turn, not slot material and not a fresh note. + // Say why nothing ran, retain the original pointer, and keep any parked + // question audible for the next breath. + h.suspendClarifyForRepair(ctx) + return "это уже " + say + " — ничего не переделываю.", true + } + if last == nil { + // Nothing recent to correct. Routing the words normally is the honest + // outcome; an expired pointer cannot become usable again. return "", false } + h.suspendClarifyForRepair(ctx) learned := true if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil { // The redo is still worth doing: he asked for something and it did not @@ -271,7 +319,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin if dec.Slots.Text == "" && corrected != router.IntentReminder { dec.Slots.Text = last.utterance } - return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true + return repairLine(say, learned) + " " + h.finishRepaired(ctx, dec), true } // repairLine — what she says before redoing it, so the correction is visible diff --git a/cmd/mavend/repair_test.go b/cmd/mavend/repair_test.go index fb7a69a..a030a4a 100644 --- a/cmd/mavend/repair_test.go +++ b/cmd/mavend/repair_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -93,7 +94,7 @@ func TestRepairNeedsARecentTurnToPointAt(t *testing.T) { } func TestRepairIsSpentOnce(t *testing.T) { - h, _, _ := newClarifyHandler(t) + h, st, _ := newClarifyHandler(t) emb := router.NewHashEmbedder(256) h.recall.embedder = emb h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor}) @@ -103,8 +104,17 @@ func TestRepairIsSpentOnce(t *testing.T) { if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled { t.Fatal("the first correction was not handled") } - if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled { - t.Error("the same turn was corrected twice") + before, err := st.RecentNotes(ctx, 10) + if err != nil || len(before) != 1 { + t.Fatalf("first repair notes=%+v err=%v", before, err) + } + reply, handled := h.resolveRepair(ctx, "нет, это заметка") + if !handled || !strings.Contains(reply, "уже") { + t.Fatalf("the repeated correction was not acknowledged as already applied: handled=%v reply=%q", handled, reply) + } + after, err := st.RecentNotes(ctx, 10) + if err != nil || len(after) != 1 { + t.Fatalf("the same turn was redone twice: notes=%+v err=%v", after, err) } } @@ -114,8 +124,119 @@ func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) { h, _, _ := newClarifyHandler(t) h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))}) h.recordTurn("купить хлеб", router.IntentNote) + reply, handled := h.resolveRepair(context.Background(), "нет, это заметка") + if !handled || !strings.Contains(reply, "уже") { + t.Fatalf("a redundant correction must be acknowledged without redoing it: handled=%v reply=%q", handled, reply) + } + // Acknowledging the redundant target must not spend the original. If this + // resolver declines instead, runTurn routes the correction as a fresh turn + // and recordTurn overwrites the pointer even though takeLastTurn retained it. + if _, handled := h.resolveRepair(context.Background(), "нет, это факт"); !handled { + t.Error("a redundant same-intent repair spent the original turn") + } +} + +func TestRepairResumesQuestionParkedAfterTheCorrectedTurn(t *testing.T) { + h, _, _ := newClarifyHandler(t) + emb := router.NewHashEmbedder(256) + h.recall.embedder = emb + h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor}) + ctx := context.Background() + + h.recordTurn("купить хлеб", router.IntentFact) + if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, + router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked { + t.Fatal("expected a parked reminder question") + } + + reply := h.runTurn(ctx, "нет, это был вопрос", sourceText) + resumed, _ := clarifyResumedFor(dialogue.SlotTime) + if !strings.HasSuffix(reply, resumed) { + t.Fatalf("the correction hid the still-live question: reply=%q want suffix=%q", reply, resumed) + } + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil { + t.Fatal("the correction dropped the parked question") + } + if q.Attempts != 1 || q.Suspends != 1 { + t.Fatalf("the correction spent a retry instead of suspending the question: %+v", q) + } +} + +func TestRepairedClarifyCompletesWithoutDroppingTheOlderQuestion(t *testing.T) { + h, st, _ := newClarifyHandler(t) + emb := router.NewHashEmbedder(256) + h.recall.embedder = emb + h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor}) + ctx := context.Background() + + h.recordTurn("купить хлеб", router.IntentFact) + if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, + router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked { + t.Fatal("expected the older reminder question") + } + + if reply := h.runTurn(ctx, "нет, это было напоминание", sourceText); !strings.Contains(reply, "Когда") { + t.Fatalf("the repaired reminder did not ask for its missing time: %q", reply) + } + if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 2 { + t.Fatalf("the repaired question overwrote the older one: depth=%d want=2", depth) + } + + reply := h.runTurn(ctx, "сегодня в 15:00", sourceText) + resumed, _ := clarifyResumedFor(dialogue.SlotTime) + if !strings.HasSuffix(reply, resumed) { + t.Fatalf("completing the repaired request did not resume the older one: reply=%q", reply) + } + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil || !strings.Contains(q.Utterance, "маме") { + t.Fatalf("the older question was lost after the top one completed: %+v", q) + } + reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) + if err != nil || len(reminders) != 1 || !strings.Contains(reminders[0].Payload, "хлеб") { + t.Fatalf("the repaired reminder did not land exactly once: reminders=%+v err=%v", reminders, err) + } +} + +func TestRepairedClarifyGiveUpKeepsTheOlderQuestion(t *testing.T) { + h, _, _ := newClarifyHandler(t) + ctx := context.Background() + older := &dialogue.PendingQuestion{ + Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime}, + Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL, + } + top := &dialogue.PendingQuestion{ + Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime}, + Utterance: "напомни купить хлеб", Asked: h.now(), TTL: clarifyTTL, + Attempts: dialogue.DefaultMaxAttempts, MaxAttempts: dialogue.DefaultMaxAttempts, + } + h.clarifyStore.Push(voiceDialogueID, older) + h.clarifyStore.Push(voiceDialogueID, top) + + if reply := h.reaskOrGiveUp(ctx, top, top.Slots, "не знаю", ""); reply != clarifyGaveUp { + t.Fatalf("reply=%q, want the explicit give-up line", reply) + } + if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 { + t.Fatalf("giving up on the top request erased the older flow: depth=%d", depth) + } + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q != older { + t.Fatalf("survivor=%+v, want the older parked question", q) + } +} + +func TestStaleRepairDoesNotSpendTheOriginal(t *testing.T) { + h, _, now := newClarifyHandler(t) + h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))}) + h.recordTurn("купить хлеб", router.IntentFact) + *now = now.Add(repairWindow + time.Minute) if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled { - t.Error("a correction to the intent she already used was handled") + t.Fatal("a stale correction was handled") + } + h.mu.Lock() + defer h.mu.Unlock() + if h.lastRouted == nil || h.lastRouted.utterance != "купить хлеб" { + t.Fatal("a stale declined correction spent the original turn") } } diff --git a/cmd/mavend/testdata/personal_boundary_v1.json b/cmd/mavend/testdata/personal_boundary_v1.json new file mode 100644 index 0000000..f6fe120 --- /dev/null +++ b/cmd/mavend/testdata/personal_boundary_v1.json @@ -0,0 +1,521 @@ +{ + "schema_version": 1, + "name": "personal_boundary_v1", + "notes": [ + "Held-out personal-boundary fixture for V-702. Cases are not production seeds and the fixture test enforces that after case folding and punctuation removal.", + "The matrix is balanced: three cases for every stratum × language × expected side cell (6 × 2 × 2 × 3 = 72).", + "Personal means that only the owner's local data can answer. World means that an outside knowledge source can answer even when the wording uses first person or possession.", + "Public subjects are deliberately varied. No case repeats the Baikal regression or changes only its place name." + ], + "cases": [ + { + "id": "pb-ru-remembered-personal-01", + "utterance": "что я раньше говорил насчёт бессонницы?", + "lang": "ru", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-ru-remembered-personal-02", + "utterance": "помнишь, какие причины увольнения я называл?", + "lang": "ru", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-ru-remembered-personal-03", + "utterance": "упоминал ли я, куда хочу переехать?", + "lang": "ru", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-ru-remembered-world-01", + "utterance": "что Чехов говорил о краткости?", + "lang": "ru", + "want": "world", + "stratum": "remembered_speech" + }, + { + "id": "pb-ru-remembered-world-02", + "utterance": "какие причины кризиса называли экономисты?", + "lang": "ru", + "want": "world", + "stratum": "remembered_speech" + }, + { + "id": "pb-ru-remembered-world-03", + "utterance": "что свидетели рассказывали об извержении Кракатау?", + "lang": "ru", + "want": "world", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-personal-01", + "utterance": "what was it I told you about changing careers?", + "lang": "en", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-personal-02", + "utterance": "have I ever mentioned where I studied?", + "lang": "en", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-personal-03", + "utterance": "do you remember which camera I said I preferred?", + "lang": "en", + "want": "personal", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-world-01", + "utterance": "what did Marie Curie write about radium?", + "lang": "en", + "want": "world", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-world-02", + "utterance": "which causes of inflation do economists usually mention?", + "lang": "en", + "want": "world", + "stratum": "remembered_speech" + }, + { + "id": "pb-en-remembered-world-03", + "utterance": "what did the Apollo astronauts report about lunar dust?", + "lang": "en", + "want": "world", + "stratum": "remembered_speech" + }, + + { + "id": "pb-ru-possession-personal-01", + "utterance": "какой номер у моего страхового полиса?", + "lang": "ru", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-ru-possession-personal-02", + "utterance": "где я оставил свои запасные ключи?", + "lang": "ru", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-ru-possession-personal-03", + "utterance": "до какого числа действует мой абонемент?", + "lang": "ru", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-ru-possession-world-01", + "utterance": "как убрать царапину с моего стола?", + "lang": "ru", + "want": "world", + "stratum": "possession" + }, + { + "id": "pb-ru-possession-world-02", + "utterance": "почему у меня запотевают окна зимой?", + "lang": "ru", + "want": "world", + "stratum": "possession" + }, + { + "id": "pb-ru-possession-world-03", + "utterance": "чем зарядить мой телефон в поездке?", + "lang": "ru", + "want": "world", + "stratum": "possession" + }, + { + "id": "pb-en-possession-personal-01", + "utterance": "when does my library card expire?", + "lang": "en", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-en-possession-personal-02", + "utterance": "where did I put my passport copy?", + "lang": "en", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-en-possession-personal-03", + "utterance": "what size are my hiking boots?", + "lang": "en", + "want": "personal", + "stratum": "possession" + }, + { + "id": "pb-en-possession-world-01", + "utterance": "how can I descale my kettle safely?", + "lang": "en", + "want": "world", + "stratum": "possession" + }, + { + "id": "pb-en-possession-world-02", + "utterance": "why does my laptop fan get loud under load?", + "lang": "en", + "want": "world", + "stratum": "possession" + }, + { + "id": "pb-en-possession-world-03", + "utterance": "which adapter should I use for my phone abroad?", + "lang": "en", + "want": "world", + "stratum": "possession" + }, + + { + "id": "pb-ru-narrative-personal-01", + "utterance": "напомни историю о том, как я познакомился с Антоном", + "lang": "ru", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-ru-narrative-personal-02", + "utterance": "расскажи, что со мной случилось в первый день на новой работе", + "lang": "ru", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-ru-narrative-personal-03", + "utterance": "восстанови по моим заметкам историю поездки в Казань", + "lang": "ru", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-ru-narrative-world-01", + "utterance": "опиши восхождение на Эверест", + "lang": "ru", + "want": "world", + "stratum": "narrative" + }, + { + "id": "pb-ru-narrative-world-02", + "utterance": "расскажи историю создания языка Rust", + "lang": "ru", + "want": "world", + "stratum": "narrative" + }, + { + "id": "pb-ru-narrative-world-03", + "utterance": "объясни, как возникли кольца Сатурна", + "lang": "ru", + "want": "world", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-personal-01", + "utterance": "retell the story of how I met Lena from what I told you", + "lang": "en", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-personal-02", + "utterance": "walk me through what happened on my first day at university", + "lang": "en", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-personal-03", + "utterance": "reconstruct my Prague trip from my notes", + "lang": "en", + "want": "personal", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-world-01", + "utterance": "tell me the story of the first Moon landing", + "lang": "en", + "want": "world", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-world-02", + "utterance": "describe how the printing press spread through Europe", + "lang": "en", + "want": "world", + "stratum": "narrative" + }, + { + "id": "pb-en-narrative-world-03", + "utterance": "explain how the Panama Canal was built", + "lang": "en", + "want": "world", + "stratum": "narrative" + }, + + { + "id": "pb-ru-preamble-personal-01", + "utterance": "если помнишь наш разговор, что я решил насчёт переезда?", + "lang": "ru", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-ru-preamble-personal-02", + "utterance": "как я уже упоминал, когда мне продлевать страховку?", + "lang": "ru", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-ru-preamble-personal-03", + "utterance": "возвращаясь к тому, что я рассказывал, какую модель велосипеда я выбрал?", + "lang": "ru", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-ru-preamble-world-01", + "utterance": "как я уже говорил, почему самолёты оставляют белый след?", + "lang": "ru", + "want": "world", + "stratum": "first_person_preamble" + }, + { + "id": "pb-ru-preamble-world-02", + "utterance": "возвращаясь к моему вопросу, из чего состоит базальт?", + "lang": "ru", + "want": "world", + "stratum": "first_person_preamble" + }, + { + "id": "pb-ru-preamble-world-03", + "utterance": "я, возможно, повторяюсь, но когда построили Колизей?", + "lang": "ru", + "want": "world", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-personal-01", + "utterance": "as I mentioned earlier, which dentist did I choose?", + "lang": "en", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-personal-02", + "utterance": "coming back to what I told you, when am I taking leave?", + "lang": "en", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-personal-03", + "utterance": "I may have said this already, which Linux distro did I settle on?", + "lang": "en", + "want": "personal", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-world-01", + "utterance": "as I was saying, why do tides happen?", + "lang": "en", + "want": "world", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-world-02", + "utterance": "coming back to my question, how are auroras formed?", + "lang": "en", + "want": "world", + "stratum": "first_person_preamble" + }, + { + "id": "pb-en-preamble-world-03", + "utterance": "I may be repeating myself, when was Machu Picchu built?", + "lang": "en", + "want": "world", + "stratum": "first_person_preamble" + }, + + { + "id": "pb-ru-advice-personal-01", + "utterance": "что из моих дел нужно закончить до пятницы?", + "lang": "ru", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-ru-advice-personal-02", + "utterance": "какое лекарство врач велел мне принимать утром?", + "lang": "ru", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-ru-advice-personal-03", + "utterance": "сколько денег я потратил на продукты в этом месяце?", + "lang": "ru", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-ru-advice-world-01", + "utterance": "как безопасно заменить розетку?", + "lang": "ru", + "want": "world", + "stratum": "advice_current_info" + }, + { + "id": "pb-ru-advice-world-02", + "utterance": "какая сейчас версия Debian stable?", + "lang": "ru", + "want": "world", + "stratum": "advice_current_info" + }, + { + "id": "pb-ru-advice-world-03", + "utterance": "что сегодня происходит на мировых рынках?", + "lang": "ru", + "want": "world", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-personal-01", + "utterance": "which of my tasks is due before Friday?", + "lang": "en", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-personal-02", + "utterance": "what dosage did my doctor tell me to take at breakfast?", + "lang": "en", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-personal-03", + "utterance": "how much did I spend on groceries this month?", + "lang": "en", + "want": "personal", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-world-01", + "utterance": "how should I clean a cast-iron pan?", + "lang": "en", + "want": "world", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-world-02", + "utterance": "what is the current stable release of PostgreSQL?", + "lang": "en", + "want": "world", + "stratum": "advice_current_info" + }, + { + "id": "pb-en-advice-world-03", + "utterance": "which major elections are happening this month?", + "lang": "en", + "want": "world", + "stratum": "advice_current_info" + }, + + { + "id": "pb-ru-proper-personal-01", + "utterance": "что я записал после доклада Линуса Торвальдса?", + "lang": "ru", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-ru-proper-personal-02", + "utterance": "какое мнение я высказал о фильмах Куросавы?", + "lang": "ru", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-ru-proper-personal-03", + "utterance": "когда у меня билеты на концерт Земфиры?", + "lang": "ru", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-ru-proper-world-01", + "utterance": "кто такой Алан Тьюринг?", + "lang": "ru", + "want": "world", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-ru-proper-world-02", + "utterance": "чем известна Фрида Кало?", + "lang": "ru", + "want": "world", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-ru-proper-world-03", + "utterance": "когда родился Юрий Гагарин?", + "lang": "ru", + "want": "world", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-personal-01", + "utterance": "what notes did I make after Grace Hopper's talk?", + "lang": "en", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-personal-02", + "utterance": "which David Bowie album did I say I liked most?", + "lang": "en", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-personal-03", + "utterance": "when are my tickets for the Radiohead show?", + "lang": "en", + "want": "personal", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-world-01", + "utterance": "who was Katherine Johnson?", + "lang": "en", + "want": "world", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-world-02", + "utterance": "what is Antoni Gaudí famous for?", + "lang": "en", + "want": "world", + "stratum": "public_proper_nouns" + }, + { + "id": "pb-en-proper-world-03", + "utterance": "when was Nelson Mandela born?", + "lang": "en", + "want": "world", + "stratum": "public_proper_nouns" + } + ] +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 6b84763..5ebf86f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -260,8 +260,9 @@ const ( ) // runTurn — the reactive turn pipeline shared by the voice and text entry -// points: expired-clarify notice → confirm answer → clarify answer → quiet -// toggle → route → dialogue merge → clarify question → action → replier. +// points: expired-clarify notice → confirm answer → explicit correction → +// clarify answer → quiet toggle → route → dialogue merge → clarify question → +// action → replier. // Takes the already-transcribed utterance, returns the reply text; the voice // path wraps it in stt/tts, the text path returns it as-is. // @@ -290,6 +291,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour now := h.now() rt := h.newTurnRoute(text, now) ctx = withTurnRoute(ctx, rt) + // A resolver may suspend an older clarify flow even when it handles this + // turn itself. Finalise that state at one choke point so early returns from + // confirm/repair/clarify cannot leave a live question parked without saying + // it again, or silently drop one when the suspension bound is reached. + defer func() { + reply = withNotice(rt.dropped, reply) + reply = withResumed(reply, rt.resume) + }() // 1. expired clarify — a question was parked but its TTL ran out, so the // request behind it is gone. Say that out loud (see clarify.go) and carry @@ -310,7 +319,23 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 3. clarify answer — if she asked a live question last turn, this + // 3. spoken correction — an explicit "нет, это был вопрос" names both the + // prior mistake and its replacement. It is narrower evidence than a parked + // question merely being present, so it gets first refusal. Otherwise the + // clarify resolver treats the correction as a bad slot value and spends a + // retry on a turn that was never an answer (V-573). + if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) { + return withNotice(expiredNotice, reply) + } + + // 3b. The same correction without a target — "нет, не так". It cannot redo + // the turn, but it can durably label the previous decision as wrong. Like a + // targeted repair, it is not an answer to a parked slot question. + if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) { + return withNotice(expiredNotice, reply) + } + + // 4. clarify answer — if she asked a live question last turn, this // utterance is its answer, not a fresh command. After the confirm check: a // y/n gate is armed by her own prompt and is the narrower claim on the // utterance. @@ -321,20 +346,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) { return withNotice(expiredNotice, reply) } - // It did not claim the turn. If it let a parked request go to get out of the - // way, that has to be said in front of whatever these words are answered - // with — carried on the same notice, so every exit below keeps it. - expiredNotice = withNotice(expiredNotice, rt.dropped) + // It did not claim the turn. Any drop notice or resumed question recorded on + // rt is attached by the turn finaliser above, including on an early return. - // 3b. and if it SUSPENDED a request instead of letting it go, the question - // comes back on the end of whatever these words are answered with (Vikunja - // #561). A deferred append rather than a call at each exit: there are eight - // returns between here and the replier, and the flow has to survive all of - // them — one that forgot would be a request parked for ever, waiting for an - // answer to a question he never heard asked. - defer func() { reply = withResumed(reply, rt.resume) }() - - // 4. quiet-hours toggle — keyword match, not classifier-dependent. + // 5. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it // before routing. Same pattern as the confirm turn above. @@ -342,7 +357,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 4b. spoken snooze — "не сейчас" / "потом" answers the nudge she just + // 5b. spoken snooze — "не сейчас" / "потом" answers the nudge she just // sent. Only handled when a pending nudge is actually inside the window // (snooze.go); otherwise the words route normally, because "потом" is an // ordinary word and eating every one of them would break real sentences. @@ -350,30 +365,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the + // 5c. spoken ack — "готово" closes that same nudge as `acted`. Only the // contentless form is intercepted here; "выпил воды" keeps routing and // closes the nudge after its fact lands (ackFromFact, step 8b). if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) { return withNotice(expiredNotice, reply) } - // 4d. spoken correction — "нет, это была заметка" points at the previous - // turn and names what it should have been (repair.go). Before routing, - // like the confirm and clarify turns: routing the correction as a fresh - // utterance files the correction itself instead of fixing anything. - if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) { - return withNotice(expiredNotice, reply) - } - - // 4d-ii. and the same correction without a target — "нет, не так" (V-636). - // After the targeted one, which is the narrower claim: an utterance that - // names an intent is answered by redoing the request, and this rung only - // gets the ones that name nothing. - if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) { - return withNotice(expiredNotice, reply) - } - - // 4e. ordinal selection — "второй", "первую сделал" pick from the list she + // 5d. ordinal selection — "второй", "первую сделал" pick from the list she // just read (ordinal.go). Before routing, and only when a list is actually // bound to the session: with nothing offered, "второй" is an ordinary word // and keeps routing. @@ -381,7 +380,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 5. route. An elliptical follow-up — "а завтра?" — is answered from the + // 6. route. An elliptical follow-up — "а завтра?" — is answered from the // previous turn instead (continuation.go): the intent is the part it is // missing, so no amount of routing recovers it, and the model's guess // costs seconds to obtain and is close to a coin flip. Everything else @@ -398,7 +397,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour } log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) - // 6. dialogue — fill this turn's missing slots from a prior same-intent + // 7. dialogue — fill this turn's missing slots from a prior same-intent // turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember // this turn for the next follow-up. Only same-intent, non-expired, non- // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. @@ -416,7 +415,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour } } - // 7. clarify — something she needs is missing. If one named thing is missing, + // 8. clarify — something she needs is missing. If one named thing is missing, // ask about it and park the request (clarify.go); otherwise the replier's // canned reply stands. // @@ -443,7 +442,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour h.recordTurn(text, dec.Intent) } - // 8. action — execute the decision's intent. errors here surface as + // 9. action — execute the decision's intent. errors here surface as // short reply text (the user wants to know the action didn't land); // the round-trip stays alive. replyText := h.applyAction(ctx, dec) @@ -452,11 +451,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // intent has no chain and no scoreboard, so the handler is the winner. noteTerminal(ctx, "action-handler", dec.Intent, "") - // 8b. a fact that answers a live nudge closes it as `acted` (ack.go). + // 9b. a fact that answers a live nudge closes it as `acted` (ack.go). // Silent: the fact reply stands, she does not congratulate him for it. h.ackFromFact(ctx, dec) - // 9. replier — phrase the reply across the router decision. + // 10. replier — phrase the reply across the router decision. if replyText == "" { replyText = h.replier.Reply(ctx, dec) } diff --git a/docs/evals/2026-08-13-personal-boundary-linear-head.md b/docs/evals/2026-08-13-personal-boundary-linear-head.md new file mode 100644 index 0000000..fafe8c5 --- /dev/null +++ b/docs/evals/2026-08-13-personal-boundary-linear-head.md @@ -0,0 +1,101 @@ +# A class-balanced linear head closes every held-out personal-boundary cell + +Measured 2026-08-13 on homesrv from `master` at `56254a5` plus the V-702 +working tree. Embedder: quantized multilingual-e5-small through ONNX Runtime +1.26.0. This supersedes +`2026-08-13-personal-boundary-neighbourhood.md`. + +Commands: + +```sh +MAVEN_ONNX_LIB="$PWD/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so.1.26.0" \ + go test ./cmd/mavend -run '^TestONNXPersonalBoundary$' -count=1 -v +MAVEN_ONNX_LIB="$PWD/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so.1.26.0" \ +MAVEN_EVAL_PERSONAL_BOUNDARY=1 \ + go test ./cmd/mavend -run '^TestONNXPersonalBoundaryStratified$' -count=1 -v +``` + +## Setup + +The prior three-neighbour scorer passed the 29-case regression table but +missed 11 of a new 72-case fixture. The fixture is balanced across Russian and +English, expected personal and world classes, and six sentence shapes: +remembered speech, possession, narrative, first-person preamble, current +advice/information and public proper nouns. Its hygiene test rejects copied +training phrases and repeated Baikal variants. + +Production uses a binary logistic head fitted over 132 bilingual examples. +Each class has total sample weight 0.5 even though the class sizes differ. The +optimiser is deterministic: 5,000 full-batch steps, decaying learning rate from +10, L2 coefficient 0.0003. The fitted 384 weights are frozen into the binary +and pinned to the embedder model ID; a model-backed test retrains from the text +corpus and rejects any drift. An unknown embedder ID falls back to fitting its +own corpus rather than applying weights from another vector space. + +No evaluation utterance is in the training corpus. Ordinary CI checks class +balancing, mixed dimensions, exact regression-table leakage, and generalisation +across synthetic semantic directions. The model-backed gate also runs +four-fold cross-validation over the expanded corpus, stratified inside each +sentence shape and class; the original compact corpus remains in every fold as +the historical floor. + +The six shapes of the training corpus were chosen after the first 72-case +matrix exposed their gaps, so 72/72 is a regression gate, not independent proof +of generalisation. The first independently authored 24-case challenge then +exposed one remaining miss, `на каком порту я решил поднять тестовый сервис?`, +at personal probability 0.4666. It became model-selection data at that point +and cannot honestly remain the independent result. + +A comparison rejected shrinkage LDA (24/24 challenge but 69/72 stratified) and +an LDA/logistic blend (24/24 but 71/72). Increasing only the balanced logistic +head's L2 coefficient from 0.0001 to 0.0003 kept the corpus and stratified gates +intact while improving evidence that did not contain that sentence: four-fold +cross-validation rose from 97/104 to 99/104 and whole-semantic-group holdout +rose from 91/104 to 92/104. It also moved the original challenge to 24/24, +although its +0.0001 minimum signed probability margin is correctly treated as +a regression, not fresh proof. + +Three checks now supply the honest evidence beyond the 72-case regression: + +- Four-fold within-shape cross-validation scores 99/104. +- Whole-group holdout scores 92/104 in aggregate. +- A second 24-case challenge was written after the coefficient and frozen head + were fixed. It returns to private configuration, commitments and stored + choices versus public technical facts, compatibility and maintenance with + new subjects. It scores 24/24 with minimum signed probability margin +0.1718. + Its rows remain outside the training corpus and no result from it was used to + retune this revision. + +Whole-semantic-group holdout is intentionally diagnostic rather than claimed +as perfect: remembered speech 14/16, possession 19/22, narrative 16/16, +first-person preamble 15/16, advice/current information 13/16, and public proper +nouns 15/18. This shows the embedder has a shared boundary direction but some +ambiguities genuinely need shape coverage. + +## Result + +| Gate | three-neighbour | linear head | +| --- | ---: | ---: | +| historical regression | 29/29 | **29/29** | +| stratified fixture | 61/72 | **72/72** | +| Russian | 30/36 | **36/36** | +| English | 31/36 | **36/36** | +| expected personal | 28/36 | **36/36** | +| expected world | 33/36 | **36/36** | + +Every one of the six strata is 12/12. The minimum signed probability margin is ++0.0522 after the independently supported regularisation change. The decision +remains personal probability greater than 0.5; neither a lexical exception nor +a shifted privacy prior was introduced. + +Cold first-boundary load plus score is 15.179µs; steady inference is 1.112µs per +score over 100,000 iterations. The previous runtime corpus fit took 5.618s on +the same box, which is why fitting moved to the model-backed build gate. +The unconfigured `HashEmbedder` floor still fits its own 1024-dimensional head +because these ONNX weights do not belong in that space; its sparse-vector fit +uses 400 steps and measures 93.818ms on first use. + +The ONNX Runtime wrapper is process-global today, so these model-backed tests +must be invoked in separate `go test` processes until V-716 repairs the harness; +otherwise only the first test runs and the rest self-skip. The figures above +were all collected as separate commands. diff --git a/docs/evals/2026-08-13-personal-boundary-neighbourhood.md b/docs/evals/2026-08-13-personal-boundary-neighbourhood.md new file mode 100644 index 0000000..a4b2442 --- /dev/null +++ b/docs/evals/2026-08-13-personal-boundary-neighbourhood.md @@ -0,0 +1,53 @@ +# Three neighbours keep a public noun from opening the personal boundary + +Measured 2026-08-13 on homesrv from `master` at `56254a5` plus the V-702 +working tree. Embedder: quantized multilingual-e5-small through ONNX Runtime +1.26.0. Command: + +```sh +make t PKG=./cmd/mavend/ RUN=TestONNXPersonalBoundary V=1 RACE=0 +make t PKG=./cmd/mavend/ RUN=TestONNXTopics V=1 RACE=0 +``` + +This supersedes `2026-08-03-personal-boundary.md` for the boundary score. That +file remains the measurement of why possession markers were replaced. + +## Defect + +The boundary used the best cosine on each side: one-nearest-neighbour +classification. Its world class correctly includes narrative questions such +as `расскажи про байкал`, while its personal class includes remembered-speech +questions. The held-out `я рассказывал тебе про байкал?` therefore scored +personal 0.9068 against world 0.9413. The public subject outweighed the question +about what the owner had previously told Maven, and the boundary opened toward +SearXNG. + +Adding two plausible personal paraphrases did not move either score. Candidate +similarities to the failing turn ranged from 0.8373 to 0.9022, still below the +world proper-noun neighbour. This ruled out a missing synonym and argued against +putting the held-out sentence or another Baikal-shaped exception into the seed +set. + +## Change + +Each class now scores as the mean of its three nearest seeds. The same `k=3` +applies on both sides, so the larger world class cannot win merely by having +more coverage. Only the local neighbourhood is averaged because the personal +class has two modes—possession and remembered speech—and a whole-class centroid +would dilute them into each other. + +This is a scoring change, not a seed or a phrase rule. The Baikal utterance +remains held out. + +## Result + +| Gate | one neighbour | three neighbours | +| --- | ---: | ---: | +| personal boundary | 28/29 | **29/29** | +| Baikal margin | -0.0345 | **+0.0147** | +| topic recognisers | 43/43 | **43/43** | + +The nearest personal true positive is `когда мой следующий отпуск` at +0.0110. +The nearest world true negative is `расскажи про эверест` at -0.0135. No extra +threshold is justified between them. The gate remains the sign of the class +difference, favouring local refusal over sending owner-related text outward. diff --git a/docs/evals/CLAUDE.md b/docs/evals/CLAUDE.md index 65e68c8..581e9fa 100644 --- a/docs/evals/CLAUDE.md +++ b/docs/evals/CLAUDE.md @@ -52,7 +52,10 @@ A pair in `docs/routing.md` went stale unnoticed. Its source predated the | measurement | state | | --- | --- | -| [Personal boundary, seed scoring vs possession markers](2026-08-03-personal-boundary.md) | live | +| [Five turns retain one referent across fact, query and chat routes](2026-08-13-conversation-continuity.md) | live | +| [Personal boundary, seed scoring vs possession markers](2026-08-03-personal-boundary.md) | superseded | +| [Three neighbours keep a public noun from opening the personal boundary](2026-08-13-personal-boundary-neighbourhood.md) | superseded | +| [A class-balanced linear head closes every held-out personal-boundary cell](2026-08-13-personal-boundary-linear-head.md) | live | | [Half-past and quarter-to hours](2026-08-05-half-past-hours.md) | live | | [Praxis reach at stage 0](2026-08-05-praxis-reach.md) | live | | [Alarm verbs reach stage 0](2026-08-06-alarm-verbs-reach-stage-0.md) | live | diff --git a/docs/routing.md b/docs/routing.md index 2d1f29f..36fd639 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -135,6 +135,13 @@ second getting it right. Every rule was added against a measurement. and "сколько будет 17 на 23". Wired after the agenda rules and **before** the feed and list rules. "что такое лента" is a definition question, and the feed rule would take it on the noun alone. +- **Russian possession statements** (`PossessionStatementGrammar`, V-577, + 2026-08-13). The closed grammatical frame `у меня …` anchors a plain + declaration as `IntentNote`; its remainder is open and contains no noun list. + Questions, captures, reminders and narrative requests keep their narrower + routes. Hyphenated indefinite pronouns such as `что-то`, `кто-нибудь` and + `кое-что` remain statements—the interrogative root inside that closed + morphology is not a question by itself. `calendar-query` and `event-time-query` name the calendar as the destination. The possessive agenda rules deliberately do not. "что у меня в списке покупок" @@ -357,6 +364,32 @@ at all. It was measured on the box 2026-08-07 TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered "какой у меня любимый язык?" with kernel headlines. +### How the personal boundary scores + +The boundary was fitted from a frozen bilingual training corpus for two classes: +questions that require the owner's records, and questions an outside knowledge +source can answer. The model-backed gate fits one class-balanced logistic head +over those vectors, verifies it, and production loads its model-ID-pinned frozen +weights. The head reads the whole semantic vector; it does not let the one +training sentence with the nearest public noun decide. + +The corpus covers remembered speech, possession, narrative, first-person +preambles, current advice/information and public proper nouns on both sides. +Each class contributes total training weight 0.5, so adding coverage to the +larger world class cannot shift the prior merely by class size. The corpus is +separate from both evaluation sets, and deterministic CI checks dimensions, +class balancing, leakage, and leave-one-shape-out generalisation. + +The measured result is 29/29 historical regressions, 72/72 on a balanced +Russian/English stratified fixture, 99/104 four-fold training-corpus +cross-validation, and 24/24 on a challenge authored after the final +regularisation was frozen (V-702, +`docs/evals/2026-08-13-personal-boundary-linear-head.md`). + +The decision threshold remains exactly personal > world. This boundary is +asymmetric: a false local claim costs an honest “не знаю”, while a false world +claim can send a question about the owner upstream. + ### Who may drop the personal boundary The personal boundary guesses, so naming `SourceWorld` drops it. That is what @@ -480,3 +513,24 @@ All three reaches offer it as of 2026-08-06: The turn source is still `tap:text` for both telegram and the web. So provenance cannot tell a chat turn from a typed one. + +### Repair and a parked question + +An explicit spoken correction gets first refusal before `clarify-answer` +(V-573). It is narrower evidence than the mere presence of a parked question, +so it must not be scored as bad slot material or spend that question's retry. +The question is suspended and repeated after the repair reply. If redoing the +corrected turn exposes a missing slot of its own, that new question is pushed +on the dialogue stack; completing or cancelling it pops only the top and makes +the older flow audible again. + +Completed clarifications and repairs deliberately enter different wrappers. +`finishClarified` acts on a decision whose parked gaps were already checked; +`finishRepaired` first validates the rebuilt decision against the current +required-slot schema. Both record the action as the next correctable turn. + +The repair pointer is claimed atomically only after its window and target are +valid. A stale correction changes no state. A correction naming the intent +already used is acknowledged without redoing the action, and retains the +pointer for a genuine correction that follows. This avoids routing correction +prose as a fresh note and overwriting the referent it was meant to preserve. diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index 84fd69b..45fc082 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -285,6 +285,37 @@ func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion { return q } +// CompleteTop removes the live question being completed and returns the flow +// that was suspended underneath it, if any. The returned question stays parked; +// callers use it only to make that surviving state audible again. A stale top +// expires the whole stack, matching Peek and Pop. +func (s *ClarifyStore) CompleteTop(id string, now time.Time) (completed, resumed *PendingQuestion) { + s.mu.Lock() + defer s.mu.Unlock() + stack := s.stacks[id] + if len(stack) == 0 { + return nil, nil + } + completed = stack[len(stack)-1] + if completed.IsExpired(now) { + delete(s.stacks, id) + return nil, nil + } + if len(stack) == 1 { + delete(s.stacks, id) + return completed, nil + } + stack = stack[:len(stack)-1] + s.stacks[id] = stack + resumed = stack[len(stack)-1] + // The surviving flow is spoken again now, so its answer window starts now. + // A completed nested request also ends the run of asides around it; Rides is + // deliberately retained as the lifetime bound for this flow. + resumed.Asked = now + resumed.Suspends = 0 + return completed, resumed +} + // Depth — how many questions are parked for this id, expired ones included. // Diagnostic; the arbiter in V-560 reads it to know it is inside a flow. func (s *ClarifyStore) Depth(id string) int { diff --git a/internal/dialogue/stack_test.go b/internal/dialogue/stack_test.go index ddc54a1..365eef6 100644 --- a/internal/dialogue/stack_test.go +++ b/internal/dialogue/stack_test.go @@ -62,6 +62,22 @@ func TestStackPoppedEntryIsGone(t *testing.T) { } } +func TestCompleteTopKeepsSuspendedFlow(t *testing.T) { + s := NewClarifyStore(time.Minute) + bottom := parked("напомни", pendingBase) + top := parked("погода", pendingBase) + s.Push("voice", bottom) + s.Push("voice", top) + + completed, resumed := s.CompleteTop("voice", pendingBase) + if completed != top || resumed != bottom { + t.Fatalf("completed=%p resumed=%p, want top=%p bottom=%p", completed, resumed, top, bottom) + } + if got := s.Depth("voice"); got != 1 { + t.Fatalf("depth=%d, want one surviving flow", got) + } +} + // Past MaxStackDepth the oldest entry comes back to the caller instead of // vanishing — it is the caller's job to say it was dropped. func TestStackDepthBoundReturnsTheDroppedEntry(t *testing.T) { diff --git a/internal/router/eval/claims_test.go b/internal/router/eval/claims_test.go index 29f53ad..8f170ae 100644 --- a/internal/router/eval/claims_test.go +++ b/internal/router/eval/claims_test.go @@ -53,20 +53,17 @@ func TestStage0Contention(t *testing.T) { } } -// matchingGrammars — every grammar whose pattern matches AND whose Build -// accepts, in the daemon's order. Route stops at the first; this does not. +// matchingGrammars — every regexp or structural grammar that accepts, in the +// daemon's order. Route stops at the first; this does not. func matchingGrammars(grammars []router.Grammar, utterance string) []string { stripped, hadWake := router.StripWakeToken(utterance) var out []string for _, g := range grammars { - m := g.Pattern.FindStringSubmatch(utterance) - if m == nil && hadWake { - m = g.Pattern.FindStringSubmatch(stripped) + _, matched, ok := g.Evaluate(utterance) + if !matched && hadWake { + _, matched, ok = g.Evaluate(stripped) } - if m == nil { - continue - } - if _, ok := g.Build(m); !ok { + if !matched || !ok { continue } out = append(out, g.Name) diff --git a/internal/router/possession.go b/internal/router/possession.go new file mode 100644 index 0000000..c97cdd8 --- /dev/null +++ b/internal/router/possession.go @@ -0,0 +1,112 @@ +package router + +import ( + "strings" + "unicode" + + "github.com/kami/maven/internal/lexicon" +) + +var possessionQuestionWords = func() map[string]bool { + words := make(map[string]bool) + for _, word := range append(lexicon.Interrogatives(), lexicon.NarrativeRequests()...) { + words[word] = true + } + return words +}() + +// PossessionStatementGrammar recognises the Russian possessive construction +// “у меня …” as a statement to remember. Russian has no present-tense “have”: +// the preposition and genitive pronoun are the grammatical predicate, so a +// sentence such as “у меня новый ноутбук” contains no verb for a generic +// sentence parser to anchor on. Both the hash floor and the deployed routing +// heads have measured this exact shape as a query and would try to answer it +// instead of recording it. +// +// This is a token grammar, not a phrase regexp or a noun list. The two-token +// frame is a closed grammatical construction and the remainder stays open. +// Questions and explicit capture/reminder/narrative requests retain their +// narrower routes; only a plain declaration is claimed. +func PossessionStatementGrammar() Grammar { + return Grammar{Name: "possession-statement", Decide: possessionStatementDecision} +} + +func possessionStatementDecision(utterance string) (Decision, bool) { + tokens := planTokens(utterance) + if len(tokens) < 3 || tokens[0] != "у" || tokens[1] != "меня" { + return Decision{}, false + } + if possessionQuestionShaped(utterance) || CarriesCaptureVerb(utterance) || carriesReminderVerbTokens(tokens) { + return Decision{}, false + } + return Decision{ + Stage: 0, + Intent: IntentNote, + Confidence: 1, + Slots: Slots{Text: utterance}, + }, true +} + +// possessionQuestionShaped is the question half of this grammar over Russian +// word structure. IsQuestionShaped intentionally tokenises punctuation away, +// which makes the interrogative root in indefinite pronouns look like a +// question: “что-то сломалось”, “кто-нибудь пришёл”, “когда-то работало”. Here +// that would defeat the open possession statement this grammar exists for. +// +// Hyphenated indefinite forms are a closed grammatical construction, not a +// phrase list: interrogative+{то, либо, нибудь}, or кое+interrogative. Every +// other exact interrogative/narrative token remains a question, as does a +// question mark. No noun or payload vocabulary is involved. +func possessionQuestionShaped(text string) bool { + if strings.HasSuffix(strings.TrimSpace(text), "?") { + return true + } + for _, lexeme := range possessionLexemes(text) { + if possessionQuestionWords[lexeme] { + return true + } + parts := strings.Split(lexeme, "-") + if indefiniteQuestionCompound(parts) { + continue + } + for _, part := range parts { + if possessionQuestionWords[part] { + return true + } + } + } + return false +} + +func possessionLexemes(text string) []string { + return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' + }) +} + +func indefiniteQuestionCompound(parts []string) bool { + if len(parts) != 2 { + return false + } + if parts[0] == "кое" && possessionQuestionWords[parts[1]] { + return true + } + if !possessionQuestionWords[parts[0]] { + return false + } + switch parts[1] { + case "то", "либо", "нибудь": + return true + default: + return false + } +} + +func carriesReminderVerbTokens(tokens []string) bool { + for _, verb := range lexicon.ReminderVerbs() { + if hasTok(tokens, verb) { + return true + } + } + return false +} diff --git a/internal/router/possession_test.go b/internal/router/possession_test.go new file mode 100644 index 0000000..a5b1f71 --- /dev/null +++ b/internal/router/possession_test.go @@ -0,0 +1,66 @@ +package router + +import ( + "context" + "testing" +) + +func TestPossessionStatementGrammarClaimsOpenRemainder(t *testing.T) { + g := PossessionStatementGrammar() + for _, utterance := range []string{ + "у меня новый ноутбук", + "У меня сломался велосипед.", + "у меня после отпуска другая работа", + "у меня что-то сломалось", + "у меня кто-нибудь дома", + "у меня когда-то был велосипед", + "у меня кое-что изменилось", + } { + d, matched, ok := g.Evaluate(utterance) + if !matched || !ok { + t.Errorf("%q was not claimed", utterance) + continue + } + if d.Intent != IntentNote || d.Slots.Text != utterance || d.Stage != 0 { + t.Errorf("%q => %+v, want an anchored note preserving the utterance", utterance, d) + } + } +} + +func TestPossessionStatementGrammarDefersNarrowerRequests(t *testing.T) { + g := PossessionStatementGrammar() + for _, utterance := range []string{ + "что у меня сегодня?", + "у меня когда встреча?", + "у меня когда встреча", + "у меня новый ноутбук?", + "у меня новый ноутбук, запиши это", + "у меня новый ноутбук, напомни настроить его", + "расскажи, что у меня в календаре", + "у тебя новый ноутбук", + } { + if _, _, ok := g.Evaluate(utterance); ok { + t.Errorf("%q was claimed as a plain possession statement", utterance) + } + } +} + +func TestPossessionStatementBeatsStatisticalQueryGuess(t *testing.T) { + emb := NewHashEmbedder(64) + classifier := NewClassifier(emb) + if err := classifier.AddExample(context.Background(), IntentQuery, "у меня новый ноутбук"); err != nil { + t.Fatal(err) + } + r := New(Config{ + Grammars: []Grammar{PossessionStatementGrammar()}, + Classifier: classifier, + Threshold: 0, + }) + d, err := r.Route(context.Background(), "у меня новый ноутбук", refNow()) + if err != nil { + t.Fatal(err) + } + if d.Intent != IntentNote || d.Stage != 0 { + t.Fatalf("route = %+v, want the structural statement grammar before the statistical query guess", d) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index d50b5f5..3dfa119 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -82,14 +82,13 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De // rule from one whose pattern never fired. var declinedBuild map[int]bool for i, g := range r.grammars { - m := g.Pattern.FindStringSubmatch(utterance) - if m == nil && hadWake { - m = g.Pattern.FindStringSubmatch(stripped) + d, matched, ok := g.Evaluate(utterance) + if !matched && hadWake { + d, matched, ok = g.Evaluate(stripped) } - if m == nil { + if !matched { continue } - d, ok := g.Build(m) if !ok { if declinedBuild == nil { declinedBuild = map[int]bool{} diff --git a/internal/router/stage0.go b/internal/router/stage0.go index ef4a36a..06bdc75 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -21,6 +21,31 @@ type Grammar struct { Name string Pattern *regexp.Regexp // matched against the raw utterance Build func(match []string) (Decision, bool) + // Decide is the non-regexp form for a grammar whose evidence is structural + // rather than textual. Exactly one of Decide or Pattern+Build is set. It + // keeps token/grammar parsers first-class instead of wrapping them in a + // catch-all regexp merely to fit this type. + Decide func(utterance string) (Decision, bool) +} + +// Evaluate applies one grammar. matched distinguishes a regexp whose outer +// shape matched but whose Build declined from a rule that never matched; the +// decision trace uses that distinction. A structural Decide has no weaker +// outer pattern, so accepted is also its matched result. +func (g Grammar) Evaluate(utterance string) (d Decision, matched, accepted bool) { + if g.Decide != nil { + d, accepted = g.Decide(utterance) + return d, accepted, accepted + } + if g.Pattern == nil || g.Build == nil { + return Decision{}, false, false + } + m := g.Pattern.FindStringSubmatch(utterance) + if m == nil { + return Decision{}, false, false + } + d, accepted = g.Build(m) + return d, true, accepted } // wakeWordAct — "maven, restart nginx" / "maven restart nginx" → the remainder diff --git a/internal/router/stagezero.go b/internal/router/stagezero.go index 3fad4b6..4cccf63 100644 --- a/internal/router/stagezero.go +++ b/internal/router/stagezero.go @@ -61,5 +61,10 @@ func StageZeroGrammars(acts ActMatcher) []Grammar { // last overall because it matches on the first word alone: "расскажи про // X" is a world question the model called a fact (Vikunja #498). grammars = append(grammars, NarrativeQueryGrammars()...) + // Last because it is deliberately broad over the open remainder of a + // grammatical declaration. Every explicit question, command, capture and + // narrative request above gets first refusal; this catches the Russian + // present-tense possession statement the statistical floors call a query. + grammars = append(grammars, PossessionStatementGrammar()) return grammars }