de4c47459a
"расскажи про Байкал" was refused as his by 0.0052. Every world seed opened with an interrogative, so a world question phrased as an order landed nearer "я тебе рассказывал об этом?" — the same verb about his own words. Four narrative seeds on the world side. TestONNXPersonalBoundary 25/25 -> 29/29 on held-out utterances, and the control "я рассказывал тебе про байкал?" is still his. TestONNXTopics unchanged at 34/34.
181 lines
7.8 KiB
Go
181 lines
7.8 KiB
Go
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))
|
||
}
|