package main import ( "context" "log" "math" "sync" "github.com/kami/maven/internal/router" ) // The personal boundary decides one thing: is this question about him. It used // to decide it by matching possession words, and that was the whole defect // behind Vikunja #495. "что я говорил про бэкапы?" is his data by definition — // nothing outside the box has ever heard him say anything — and it carried no // possession word, so it walked past the boundary into SearXNG and came back // answered out of a Habr article about somebody else's backups. // // The first fix was one more marker class, `я говорил|сказал|писал|…`, plus a // carve-out so "как я говорил, почему небо синее" stayed a world question. Both // halves are a lexicon, and a lexicon is the wrong instrument here: Russian // gives every verb a dozen surface forms, the preamble list has no end, and // 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. // // 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 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. // 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. var personalSeeds = []string{ "что я говорил про это", "я тебе рассказывал об этом?", "что я записал про врача", "я упоминал эту тему?", "что у меня сегодня", "когда моя встреча", "what did i say about this", "did i mention this to you", } // 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{ "почему небо синее", "какая столица франции", "как сварить борщ", "кто написал эту книгу", "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", "что я могу посмотреть вечером", "что мне почитать про историю", "что я должен знать про питон", "what can i watch tonight", // A third shape that looks personal and is not: asking when something // happens (Vikunja #553). "во сколько закат сегодня" scored personal, // because "что у меня сегодня" and "когда моя встреча" put that frame on // the personal side and nothing here answered it. The sunset is the one // thing on his list that is the same for everybody standing outside. // "сегодня" is carried on purpose. Without it these caught nothing: the // day word is most of what pulls the frame personal, because "что у меня // сегодня" is a personal seed and the day word is the half it shares. "во сколько сегодня открывается магазин", "когда сегодня начинается матч", "во сколько сегодня восход солнца", // The other frame a day word carries, and the same story: "что у меня // сегодня" is a personal seed, so "какой сегодня праздник" and "что // интересного произошло сегодня в мире" were refused as his after the // topic seeds had already let them past the weather source. "какой сегодня курс валют", "что сегодня происходит в мире", // The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as // his by 0.0052, and nothing here was phrased as an order rather than a // question: every world seed above opens with an interrogative. So a world // question that names its subject and asks for prose landed nearer "я тебе // рассказывал об этом?", which is the same verb about his own words. "расскажи про байкал", "расскажи про древний рим", "объясни как работает двигатель", "tell me about the roman empire", } // 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. type personalBoundary struct { once sync.Once personal [][]float32 world [][]float32 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. func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) { b.once.Do(func() { if emb == nil { return } embedAll := func(ss []string) [][]float32 { out := make([][]float32, 0, len(ss)) for _, s := range ss { v, err := router.EmbedQuery(ctx, emb, s) if err != nil { log.Printf("voice: personal boundary seeds unavailable (%v); falling back to possession markers", err) return nil } out = append(out, v) } return out } p, w := embedAll(personalSeeds), embedAll(worldSeeds) if p == nil || w == nil { return } b.personal, b.world, b.loaded = p, w, 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 } best := func(seeds [][]float32) float64 { m := -1.0 for _, s := range seeds { if c := cosine(vec, s); c > m { m = c } } return m } return best(b.personal), best(b.world), true } // cosine — same math as internal/router and internal/memory, small enough that // importing one of them for it would be the larger coupling. func cosine(a, b []float32) float64 { if len(a) != len(b) { return 0 } var dot, na, nb float64 for i := range a { dot += float64(a[i]) * float64(b[i]) na += float64(a[i]) * float64(a[i]) nb += float64(b[i]) * float64(b[i]) } if na == 0 || nb == 0 { return 0 } return dot / (math.Sqrt(na) * math.Sqrt(nb)) }