8015fdbb79
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.
437 lines
22 KiB
Go
437 lines
22 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"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. 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.
|
||
//
|
||
// 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.
|
||
//
|
||
// 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 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.
|
||
"что я говорил про это",
|
||
"я тебе рассказывал об этом?",
|
||
"что я записал про врача",
|
||
"я упоминал эту тему?",
|
||
"что у меня сегодня",
|
||
"когда моя встреча",
|
||
"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?",
|
||
}
|
||
|
||
var worldSeeds = []string{
|
||
// The original compact corpus, retained as above.
|
||
"почему небо синее",
|
||
"какая столица франции",
|
||
"как сварить борщ",
|
||
"кто написал эту книгу",
|
||
"what is the capital of france",
|
||
"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",
|
||
|
||
// 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 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 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 {
|
||
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
|
||
}
|
||
// 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
|
||
}
|
||
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
|
||
})
|
||
}
|
||
|
||
const (
|
||
personalBoundaryTrainingEpochs = 5000
|
||
personalBoundaryLearningRate = 10.0
|
||
personalBoundaryL2 = 0.0003
|
||
)
|
||
|
||
const personalBoundaryHeadModelID = "model_quantized@384/tok2"
|
||
|
||
const personalBoundaryHashModelID = "hash@1024"
|
||
|
||
const personalBoundaryHeadWeights = "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"
|
||
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
// 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))
|
||
}
|