Harden semantic boundaries and repair dialogue state

Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
This commit is contained in:
2026-08-13 03:00:31 +04:00
parent 35c6ff5a71
commit 8015fdbb79
24 changed files with 2644 additions and 172 deletions
+31
View File
@@ -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 {
+16
View File
@@ -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) {
+6 -9
View File
@@ -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)
+112
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+4 -5
View File
@@ -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{}
+25
View File
@@ -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
+5
View File
@@ -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
}