From bcc2305cd08649c97a925029cb3f9f34402cb54f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:27:04 +0400 Subject: [PATCH 1/6] llm: let a caller name its sampling temperature (V-490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phraser's own transport has always sampled at 0.7 and this client has always been greedy. Routing a phrasing call through the client must not change how it decodes, so Req carries the temperature and 0 — the zero value, and what every existing caller wanted — is still greedy. --- internal/llm/client.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/llm/client.go b/internal/llm/client.go index e8293cc..c5a60c5 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -129,6 +129,11 @@ type Req struct { RepeatPenalty float64 // Stop — sequences that end generation early (e.g. newline for a one-liner). Stop []string + // Temperature — 0 (the zero value) is greedy decoding, and greedy is what + // every caller here wanted before this field existed. It is set only by the + // phraser, whose own transport has always sampled at 0.7: routing a phrasing + // call through this client must not quietly change how it decodes. + Temperature float64 } type msg struct { @@ -176,7 +181,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) { Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}}, MaxTokens: r.MaxTokens, Grammar: r.Grammar, - Temp: 0, + Temp: r.Temperature, RepeatPenalty: r.RepeatPenalty, Stop: r.Stop, }) -- 2.52.0 From 76481c2736039d43da116b2f10005d67de7042a5 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:27:30 +0400 Subject: [PATCH 2/6] phraser: a world model seam, so a gap can be named instead of invented (V-490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naming half of the degradation rule in docs/offload.md. PhraseWorld has three outcomes: no workstation configured means the resident model answers exactly as today, a workstation that is taking work answers, and one that is asleep returns ErrNoWorldModel so the caller can say so. Naming a gap requires a gap — on a box that never had a second model, refusing every world question would remove a capability he has now. Both prompts move into knowledgePrompt and evidencePrompt, shared by PhraseQuery and PhraseWorld, because prompt parity across two models stops holding the moment there are two copies of a prompt. The silent half comes with it: chatWithSystem and chatWithMessages prefer the workstation when it will take work, at the same 0.7 the resident transport samples at, and say nothing when it will not. That covers the digestion worker's nudge and reminder phrasing without touching tick.go. Only Available and CompleteRemote are in the Remote interface. Pair.Complete has its own floor and the phraser already owns one; two floors under a single call is one too many. --- internal/phraser/llmphraser.go | 55 ++++++++++--- internal/phraser/world.go | 137 +++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 internal/phraser/world.go diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index b1a3e16..67970c9 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -46,6 +46,12 @@ type LLMPhraser struct { launch func(ctx context.Context, cfg Config) (backend, error) probe func(ctx context.Context, base string) (string, error) + // remote — the workstation model, when one is configured. Set once at wiring + // time by UseRemote and read on every phrasing call. nil ⇒ every call goes to + // the resident llama-server this phraser owns, which is the whole deploy + // before a `workstation` block exists. See world.go. + remote Remote + // swapMu — single-flight around Swap. Held for the whole swap, including the // model load, so two concurrent swap requests can never both be loading. swapMu sync.Mutex @@ -363,10 +369,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] // prompt guaranteed to make a small model fill the gap from memory. notes = nonEmpty(notes) if len(notes) == 0 { - // General knowledge — no notes to ground the answer. The system - // prompt is the single tested source in router.KnowledgePrompt. - sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()) - prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) + sys, prompt := p.knowledgePrompt(utterance) resp, err := p.chatWithSystem(ctx, sys, prompt, 768) if err != nil || resp == "" { return "не знаю.", nil @@ -381,11 +384,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] } return resp, nil } - sys := p.querySystemPrompt() - prompt := fmt.Sprintf( - "Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.", - utterance, evidenceBlock(notes), - ) + sys, prompt := p.evidencePrompt(utterance, notes) resp, err := p.chatWithSystem(ctx, sys, prompt, 768) text, _, perr := parseResponseMood(resp) if err != nil || perr != nil { @@ -481,6 +480,17 @@ func chatSystemPrompt(block func() string) string { // the LLM completion endpoint. Like chatWithSystem but for an arbitrary message // slice — the caller owns the system prompt placement. func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) { + // Same silent preference as chatWithSystem, when the array is the shape + // llm.Req can carry: one system turn and one user turn. PhraseChat already + // folds the history into a single user message (some chat templates reject + // consecutive user turns), so today that is every call. A longer array goes + // to the resident model rather than get flattened here, because flattening a + // conversation is a decision its owner should make. + if len(msgs) == 2 && msgs[0].Role == "system" && msgs[1].Role == "user" { + if out, ok := p.remoteChat(ctx, msgs[0].Content, msgs[1].Content, maxTokens); ok { + return out, nil + } + } base, release, err := p.acquire() if err != nil { return "", err @@ -640,6 +650,12 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error } func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) { + // The workstation model first when it will take work, and silently: every + // caller of this helper is on the silent half of the degradation rule. It + // answering is not news, and it being asleep is not news either. + if out, ok := p.remoteChat(ctx, system, user, maxTokens); ok { + return out, nil + } base, release, err := p.acquire() if err != nil { return "", err @@ -733,6 +749,27 @@ func (p *LLMPhraser) systemPrompt() string { return persona.Prepend(p.cfg.ContextBlock, nudgeSystem) } +// knowledgePrompt — the no-sources branch: a world question, answered from +// weights alone. The system prompt is the single tested source in +// router.KnowledgePrompt. +// +// Split out of PhraseQuery so PhraseWorld sends the workstation model the same +// bytes the resident model gets. Prompt parity across two models is a stated +// constraint (CLAUDE.md), and two copies of a prompt is how it stops holding. +func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) { + return persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()), + fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) +} + +// evidencePrompt — the sources branch: read these, add nothing. Shared with +// PhraseWorld for the same reason as knowledgePrompt. +func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) { + return p.querySystemPrompt(), fmt.Sprintf( + "Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.", + utterance, evidenceBlock(notes), + ) +} + // querySystemPrompt returns the system prompt for the evidence branch of // PhraseQuery. Prepends the configured persona when set. // diff --git a/internal/phraser/world.go b/internal/phraser/world.go new file mode 100644 index 0000000..a021a4b --- /dev/null +++ b/internal/phraser/world.go @@ -0,0 +1,137 @@ +package phraser + +import ( + "context" + "errors" + "log" + + "github.com/kami/maven/internal/llm" +) + +// Remote — the workstation model, seen from the phraser. `*llm.Pair` satisfies +// it, and a test fake satisfies it in three lines. +// +// Only the refusing half of Pair is here on purpose. Pair.Complete falls back to +// its own floor client, and the phraser already owns a floor: the llama-server it +// spawned. Two floors under one call is one too many, so the phraser asks whether +// the remote will take work, uses it when it will, and otherwise does exactly +// what it did before this file existed. +type Remote interface { + // Available is an atomic read of a cached probe, so it is free to call per + // turn. See llm.Pair. + Available() bool + // CompleteRemote runs on the workstation or returns ErrRemoteUnavailable. It + // never falls back. + CompleteRemote(ctx context.Context, r llm.Req) (string, error) +} + +// ErrNoWorldModel — a world question was asked, a workstation model is +// configured to answer it, and that machine is not answering. The caller turns +// this into a gap he is told about ("не могу сейчас"), never into an answer from +// the resident model. +// +// This is the naming half of the degradation rule in docs/offload.md. The +// resident Qwen3-1.7B does not answer a world question worse than the 12B, it +// invents: measured, the workstation model scores knowledge 9/9 on the talk +// fixture against the resident model's confabulations +// (docs/evals/2026-08-02-workstation-gemma4-12b.md). +var ErrNoWorldModel = errors.New("phraser: no world model available") + +// chatTemperature — what the phraser's own transport has always sampled at. +// Named so the remote path cannot drift from it silently. Whether 0.7 is right +// at all is Vikunja #402, and answering that here would hide a phrasing change +// inside a routing change. +const chatTemperature = 0.7 + +// UseRemote points the phraser at the workstation model. Wiring time only, once, +// before anything phrases: the field is read without a lock on every call +// because a per-turn lock to answer a question that changes at deploy time is +// not worth paying for. +// +// A nil remote is the normal state of a box with no `workstation` block, and it +// must behave exactly as the box behaved before this seam existed. +func (p *LLMPhraser) UseRemote(r Remote) { + p.remote = r +} + +// PhraseWorld answers a question about the world — either from the model's own +// knowledge (no sources) or from a passage someone fetched (a live search, a ZIM +// article, a page he named). Three outcomes, and the middle one is the point: +// +// - No workstation configured. The resident model answers, exactly as it does +// today. Naming a gap needs a gap: on a box that never had a second model, +// refusing every world question would remove a capability he has now. +// - Workstation configured and taking work. It answers. +// - Workstation configured and down. ErrNoWorldModel, and the caller says so. +// +// The prompts are the ones PhraseQuery uses, built by the same two functions, so +// the two models are asked the same question in the same words. +func (p *LLMPhraser) PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error) { + sources = nonEmpty(sources) + if p.remote == nil { + return p.PhraseQuery(ctx, utterance, sources) + } + var sys, user string + if len(sources) == 0 { + sys, user = p.knowledgePrompt(utterance) + } else { + sys, user = p.evidencePrompt(utterance, sources) + } + if !p.remote.Available() { + return "", ErrNoWorldModel + } + resp, err := p.remote.CompleteRemote(ctx, llm.Req{ + System: sys, + User: user, + Grammar: p.grammar(), + MaxTokens: 768, + Temperature: chatTemperature, + }) + if err != nil { + // The cached probe was one interval stale, or the card went away + // mid-request. Either way this is the gap, not an error to log and + // paper over with the smaller model. + log.Printf("phraser: world model: %v", err) + return "", errors.Join(ErrNoWorldModel, err) + } + resp = stripThink(resp) + text, _, perr := parseResponseMood(resp) + if perr != nil { + log.Printf("phraser: PhraseWorld: %v", perr) + return "", errors.Join(ErrNoWorldModel, perr) + } + if text != "" { + return text, nil + } + if resp == "" { + return "", ErrNoWorldModel + } + return resp, nil +} + +// remoteChat is the silent half, for the phrasing paths where the workstation +// model is only better: a nudge, a reminder, a reply, a question answered from +// his own notes. It reports whether it answered; it never reports why not, +// because the caller's next move is the resident model either way. +// +// He is not told which of the two models phrased his reply. That is the rule. +func (p *LLMPhraser) remoteChat(ctx context.Context, system, user string, maxTokens int) (string, bool) { + if p.remote == nil || !p.remote.Available() { + return "", false + } + out, err := p.remote.CompleteRemote(ctx, llm.Req{ + System: system, + User: user, + Grammar: p.grammar(), + MaxTokens: maxTokens, + Temperature: chatTemperature, + }) + if err != nil { + log.Printf("phraser: workstation model declined, phrasing here instead: %v", err) + return "", false + } + if out = stripThink(out); out == "" { + return "", false + } + return out, true +} -- 2.52.0 From 51256c4c9ac0cf7c6b909f4fe7e00763d143a51f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:27:30 +0400 Subject: [PATCH 3/6] phraser: test the three outcomes of a world question, and prompt parity (V-490) The middle outcome is the whole task: a workstation that is configured and asleep produces a gap, and the resident model is never asked. The parity test compares the bytes PhraseWorld sends the workstation against the bytes PhraseQuery sends the resident model, so the fixtures and the daemon cannot measure two different prompts. The nudge tests cover the silent half from both sides, including the temperature, which is how the workstation would otherwise change how she sounds without anyone deciding to. --- internal/phraser/world_test.go | 164 +++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/phraser/world_test.go diff --git a/internal/phraser/world_test.go b/internal/phraser/world_test.go new file mode 100644 index 0000000..1cdb6ba --- /dev/null +++ b/internal/phraser/world_test.go @@ -0,0 +1,164 @@ +package phraser + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/loop" +) + +// fakeRemote — a workstation model that is up or down on command, and records +// what it was asked. +type fakeRemote struct { + up bool + reply string + err error + got []llm.Req +} + +func (f *fakeRemote) Available() bool { return f.up } + +func (f *fakeRemote) CompleteRemote(_ context.Context, r llm.Req) (string, error) { + f.got = append(f.got, r) + if f.err != nil { + return "", f.err + } + return f.reply, nil +} + +// The three outcomes of the naming half, in one place. The middle one is the +// whole task: a gap he is told about, not an answer from the smaller model. +func TestPhraseWorldNamesTheGapOnlyWhenThereIsOne(t *testing.T) { + answer := `{"response": "Небо голубое из-за рэлеевского рассеяния.", "mood": "neutral"}` + + t.Run("no workstation configured: the resident model answers as today", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if err != nil { + t.Fatalf("PhraseWorld: %v", err) + } + if got == "" { + t.Fatal("no reply from the resident model") + } + if len(spy.user) != 1 { + t.Fatalf("resident model saw %d requests, want 1", len(spy.user)) + } + }) + + t.Run("workstation up: it answers and the resident model is not asked", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + remote := &fakeRemote{up: true, reply: answer} + p.UseRemote(remote) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if err != nil { + t.Fatalf("PhraseWorld: %v", err) + } + if !strings.Contains(got, "рассеяния") { + t.Errorf("reply is not the workstation's: %q", got) + } + if len(spy.user) != 0 { + t.Errorf("the resident model was asked %d times, want 0", len(spy.user)) + } + }) + + t.Run("workstation down: the gap, and nothing invented", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + p.UseRemote(&fakeRemote{up: false}) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if !errors.Is(err, ErrNoWorldModel) { + t.Fatalf("err = %v, want ErrNoWorldModel", err) + } + if got != "" { + t.Errorf("got a reply %q with no world model", got) + } + if len(spy.user) != 0 { + t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) + } + }) + + t.Run("workstation errors mid-request: still the gap", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + p.UseRemote(&fakeRemote{up: true, err: errors.New("connection refused")}) + if _, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil); !errors.Is(err, ErrNoWorldModel) { + t.Fatalf("err = %v, want ErrNoWorldModel", err) + } + if len(spy.user) != 0 { + t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) + } + }) +} + +// Prompt parity: the workstation model is asked the same question in the same +// words, or the fixtures measure one thing and the daemon ships another. +func TestPhraseWorldSendsTheSamePromptsAsPhraseQuery(t *testing.T) { + spy := newPromptSpy(t) + resident := NewLLMPhraserAt(spy.srv.URL, Config{}) + if _, err := resident.PhraseQuery(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { + t.Fatal(err) + } + + remote := &fakeRemote{up: true, reply: `{"response": "Толстой.", "mood": "neutral"}`} + offloaded := NewLLMPhraserAt(spy.srv.URL, Config{}) + offloaded.UseRemote(remote) + if _, err := offloaded.PhraseWorld(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { + t.Fatal(err) + } + + if len(remote.got) != 1 { + t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) + } + if remote.got[0].System != spy.system[0] { + t.Errorf("system prompts differ:\nremote: %q\nresident: %q", remote.got[0].System, spy.system[0]) + } + if remote.got[0].User != spy.user[0] { + t.Errorf("user prompts differ:\nremote: %q\nresident: %q", remote.got[0].User, spy.user[0]) + } +} + +// The silent half. A nudge phrased on the workstation is not news, and one +// phrased here because the card is busy is not news either — but it must be +// sampled the same way, or the workstation quietly changes how she sounds. +func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) + remote := &fakeRemote{up: true, reply: `{"response": "Выпей воды.", "mood": "neutral"}`} + p.UseRemote(remote) + + pn, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}) + if err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if pn.Body != "Выпей воды." { + t.Errorf("body = %q, want the workstation's wording", pn.Body) + } + if len(remote.got) != 1 { + t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) + } + if remote.got[0].Temperature != chatTemperature { + t.Errorf("temperature = %v, want %v (what the resident transport samples at)", + remote.got[0].Temperature, chatTemperature) + } + if len(spy.user) != 0 { + t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user)) + } +} + +func TestNudgePhrasingFallsBackWhenTheCardIsBusy(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) + p.UseRemote(&fakeRemote{up: false}) + + if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if len(spy.user) != 1 { + t.Fatalf("the resident model phrased %d nudges, want 1", len(spy.user)) + } +} -- 2.52.0 From 12530c8a9590e9a3b20fd5ca083cc1d80ff69fe2 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:27:40 +0400 Subject: [PATCH 4/6] mavend: world questions ask the workstation, and name the gap when it is asleep (V-490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryGeneral has nothing fetched to fall back on, so it is the sharp case: with a workstation configured and asleep he is told that, rather than told something false in a confident voice. The 1.7B answering a world question is where "Война и мир" got Левитан as its author. The sources that already hold a passage — a live search, a ZIM article, a page he named — go through the world model too, but read the passage back when it is not there instead of naming a gap. A real quote beats "не могу сейчас", and nothing is invented on either path. The Stub and every test double keep the Phraser interface they have. PhraseWorld is reached by assertion, and a phraser without it is the no-workstation case. --- cmd/mavend/actions_query.go | 49 ++++++++++---------- cmd/mavend/voicewire.go | 7 +++ cmd/mavend/worldmodel.go | 60 +++++++++++++++++++++++++ cmd/mavend/worldmodel_test.go | 85 +++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 cmd/mavend/worldmodel.go create mode 100644 cmd/mavend/worldmodel_test.go diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9ce82f2..c8d8514 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -13,6 +13,7 @@ import ( "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/rss" "github.com/kami/maven/internal/store" @@ -523,10 +524,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b // the question he actually asked. She answers the question, she does not // recite the page. snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes) - reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) - if perr != nil { - log.Printf("voice: web: phrase: %v", perr) - } + reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. @@ -592,14 +590,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string // question he asked, not something to recite. The trim is one budget over the // joined block, so a long first snippet cannot crowd out the rest. evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes) - var reply string - if h.phraser != nil { - var perr error - reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{evidence}) - if perr != nil { - log.Printf("voice: search: phrase: %v", perr) - } - } + reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence}) if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. @@ -680,14 +671,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, // Handed over the same way a note or a page is: context for the question he // asked, not something to recite. snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes) - var reply string - if h.phraser != nil { - var perr error - reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) - if perr != nil { - log.Printf("voice: kiwix: phrase: %v", perr) - } - } + reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. @@ -755,11 +739,28 @@ func isPersonalQuery(utterance string) bool { return false } -// queryGeneral — general knowledge from the phraser, the last source before -// giving up. It always claims: either the model answers or Maven says she -// doesn't know. +// queryGeneral — general knowledge, the last source before giving up. It always +// claims: either a model answers, or Maven names the gap, or she says she does +// not know. +// +// This is the sharpest case for the naming half. Nothing has been fetched, so +// there is no passage to fall back on and no floor under the answer except the +// model's weights — and a 1.7B's weights are where the invented answers come +// from. With a workstation configured and asleep he is told that, rather than +// told something false in a confident voice. With no workstation configured at +// all the resident model answers exactly as it does today: naming a gap requires +// a gap, and on that box the 1.7B is the whole product. func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) { - reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil) + if h.phraser == nil { + // No model of any size. That is not the workstation being asleep, so it + // is not that gap: it is simply not knowing. + return "не знаю.", true + } + reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) + if errors.Is(err, phraser.ErrNoWorldModel) { + log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance) + return worldGap, true + } if err != nil || reply == "" { return "не знаю.", true } diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 41a6911..70cf136 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -200,6 +200,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // either the pair, or the resident client alone, or nothing at all. hot, pair := modelSeam(cfg, llmClient) w.pair = pair + // The phraser gets the same pair, which is what carries the workstation model + // into the paths that do not go through `hot`: world questions (the naming + // half), and the digestion worker's nudge and reminder phrasing (the silent + // half). Wiring, so it happens once and before the voice server listens. + if lp, ok := phr.(*phraser.LLMPhraser); ok && pair != nil { + lp.UseRemote(pair) + } // ----- router (the cascade; floor examples seed the classifier) ----- // The act matcher's allowlist is exactly the enabled tool names — the // router only matches acts the executor can run (one source of truth). diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go new file mode 100644 index 0000000..c6c712f --- /dev/null +++ b/cmd/mavend/worldmodel.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "log" + + "github.com/kami/maven/internal/phraser" +) + +// worldPhraser — the naming half of the degradation rule (docs/offload.md), as +// the query sources see it. Only *phraser.LLMPhraser implements it, so the +// Stub and every test double stay exactly as they are. +type worldPhraser interface { + PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error) +} + +// worldGap — what he hears when the question is about the world, the workstation +// model is the one configured to answer it, and that machine is not answering. +// +// It says the true thing. The resident 1.7B is not a worse answer here, it is an +// invented one: "Война и мир" came back with Левитан as its author, and a +// question about his meeting came back as a swimming competition in Nottingham. +// Naming the gap is the rule CLAUDE.md already applies to a sibling service +// being down. +const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." + +// phraseWorld asks the world model, or reports the gap. +// +// The three outcomes come straight from LLMPhraser.PhraseWorld: no workstation +// configured means the resident model answers as it always has, a workstation +// that is up answers, and a workstation that is down returns +// phraser.ErrNoWorldModel. A phraser that has no world seam at all — the Stub, +// and the doubles in the tests — is the first of those three. +func (h *reactiveHandler) phraseWorld(ctx context.Context, utterance string, sources []string) (string, error) { + if h.phraser == nil { + return "", phraser.ErrNoWorldModel + } + if w, ok := h.phraser.(worldPhraser); ok { + return w.PhraseWorld(ctx, utterance, sources) + } + return h.phraser.PhraseQuery(ctx, utterance, sources) +} + +// phraseSource asks the world model to answer from a passage someone already +// fetched — a live search result, a ZIM article, a page he named. It returns "" +// rather than the gap phrase, because these callers hold something better than a +// gap: the passage itself, which their own floor reads back to him. Nothing is +// invented either way, and a real quote beats "не могу сейчас". +func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance string, sources []string) string { + reply, err := h.phraseWorld(ctx, utterance, sources) + switch { + case errors.Is(err, phraser.ErrNoWorldModel): + log.Printf("voice: %s: no world model, reading the source back instead", name) + return "" + case err != nil: + log.Printf("voice: %s: phrase: %v", name, err) + } + return reply +} diff --git a/cmd/mavend/worldmodel_test.go b/cmd/mavend/worldmodel_test.go new file mode 100644 index 0000000..f8d0ad1 --- /dev/null +++ b/cmd/mavend/worldmodel_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" +) + +// gapPhraser — a phraser whose world model is configured and asleep, which is +// the state the naming half exists for. +type gapPhraser struct { + *phraser.Stub + worldCalls int +} + +func (g *gapPhraser) PhraseWorld(context.Context, string, []string) (string, error) { + g.worldCalls++ + return "", phraser.ErrNoWorldModel +} + +func worldTurn(utterance string) *queryTurn { + return &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: utterance}} +} + +// A world question with the workstation asleep says so. The resident model is +// not asked, because what it produces here is an invention with no signal that +// it is one. +func TestQueryGeneralNamesTheGap(t *testing.T) { + g := &gapPhraser{Stub: phraser.NewStub()} + h := &reactiveHandler{phraser: g} + reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое")) + if !ok { + t.Fatal("queryGeneral passed on the last source in the chain") + } + if reply != worldGap { + t.Fatalf("reply = %q, want the named gap", reply) + } + if g.worldCalls != 1 { + t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls) + } +} + +// A phraser with no world seam at all — the Stub, and every box with no +// `workstation` block — answers exactly as it did before this seam existed. +func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) { + h := &reactiveHandler{phraser: phraser.NewStub()} + reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое")) + if !ok { + t.Fatal("queryGeneral passed on the last source in the chain") + } + if reply != "не знаю." { + t.Fatalf("reply = %q, want the Stub's answer", reply) + } +} + +// The gap is spoken aloud by a Russian voice, so it is Russian, feminine and +// informal. "не хочу" and "не могу" are her own verbs; there is no "вы" and no +// English in it. +func TestWorldGapIsInPersona(t *testing.T) { + for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} { + if strings.Contains(worldGap, bad) { + t.Errorf("the gap phrase contains %q: %s", bad, worldGap) + } + } + if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") { + t.Errorf("the gap phrase has Latin letters in it: %s", worldGap) + } +} + +// The sources that hold a passage read it back rather than name a gap. He gets a +// real quote instead of "не могу сейчас", and nothing is invented either way. +func TestASourceWithAPassageReadsItBackInsteadOfNamingTheGap(t *testing.T) { + g := &gapPhraser{Stub: phraser.NewStub()} + h := &reactiveHandler{phraser: g} + if got := h.phraseSource(context.Background(), "search", "почему небо голубое", + []string{"Рэлеевское рассеяние."}); got != "" { + t.Fatalf("phraseSource = %q, want \"\" so the caller's own floor reads the passage back", got) + } + if g.worldCalls != 1 { + t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls) + } +} -- 2.52.0 From 9b124d919458955b7a8c7bde09101ea940c3fd4c Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:29:12 +0400 Subject: [PATCH 5/6] docs: both halves of the degradation rule are wired, and which caller is which (V-490) The offload inventory grows a column, because "seven callers of the resident model" stopped being the useful fact. Which of them is offloaded, and under which half of the rule, is. Three are resident-only on purpose and the table now says why rather than leaving it to be rediscovered. The three-outcome table is the part that was not obvious from the rule as written. A configured-and-asleep workstation names the gap; a box with no workstation block does not, because naming a gap requires a gap. --- CLAUDE.md | 7 ++++++ docs/offload.md | 61 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9bfc65..779e26b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,13 @@ stays on homesrv permanently, because it backs that floor. Read `docs/offload.md touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487 are the work. +Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently +through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A +world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not +free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented +answer. A box with no `workstation` block behaves exactly as it did before the seam: naming +a gap requires a gap. The offload table in `docs/offload.md` says which caller is which. + ## Build & test CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain diff --git a/docs/offload.md b/docs/offload.md index 034faa3..122ad86 100644 --- a/docs/offload.md +++ b/docs/offload.md @@ -1,6 +1,6 @@ # Offloading model work to the workstation -*Last verified: 2026-08-02 @ 5c05163. Living doc: correct it in place, do not append.* +*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.* Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the work, and this file holds the shape and the rules all four must obey. @@ -47,6 +47,24 @@ service being down. Nothing in between. A turn never breaks on the workstation being asleep. +Both halves are wired, 03-08-2026. `LLMPhraser.PhraseWorld` +(`internal/phraser/world.go`) is the naming half and has three outcomes, not two: + +| State | What he hears | +|---|---| +| no `workstation` block | the resident model answers, exactly as before the seam existed | +| configured, card free | the workstation answers | +| configured, asleep or busy | the gap, `worldGap` in `cmd/mavend/worldmodel.go` | + +The first row is the one worth stating. Naming a gap requires a gap. On a box with +no second model the 1.7B is the whole product. Refusing every world question there +would remove a capability the owner has today. + +A source holding a passage is on the naming half too: a live search, a ZIM +article, a page he named. None of them says "не могу сейчас". They read the +passage back, which is what `phraseSource` returning `""` selects. A real quote +beats a gap, and neither path invents. + ## Admission control, not a scheduler There is no GPU arbiter. That is a service with its own failure modes, and nothing @@ -104,17 +122,29 @@ it buys nothing. Four callers: ## Inventory: what runs a model on homesrv today -The **resident model** is one llama-server with seven callers: +The **resident model** is one llama-server with seven callers, and 03-08-2026 is +the date each of them stopped or did not stop being resident-only: -| Caller | What for | -|---|---| -| `cmd/mavend/voicewire.go` | routing | -| `cmd/mavend/replier_llm.go` | replies | -| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` | -| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) | -| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) | -| `cmd/mavend/kiwixwire.go` | answering from a Kiwix, search or crawl passage | -| `memoryeval.go`, `modelswap.go` | admin and evals | +| Caller | What for | Offloaded | +|---|---|---| +| `cmd/mavend/voicewire.go` | routing | silently, through `hot` | +| `cmd/mavend/replier_llm.go` | replies | silently, through `hot` | +| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` | silently, inside the phraser | +| `cmd/mavend/actions_query.go` | world questions, and any fetched passage | names the gap | +| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) | no, holds its own client | +| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) | no, holds its own client | +| `memoryeval.go`, `modelswap.go` | admin and evals | no, and deliberately | + +The last three rows are resident-only on purpose. `memoryeval.go` and +`modelswap.go` measure and swap the resident model, so sending their work +elsewhere would measure the wrong thing. `capture.go` and `mail.go` are +background jobs that hold a gated background client (`llmBackgroundClientFor`), +and that priority has no equivalent on the remote yet. Both are also unreachable +on this deploy, so wiring them would ship an untestable path. + +The `tick.go` row needs one caveat. `phraser.llm_nudges` is `false` in deploy, so +nudges come from templates and the seam under them changes nothing until that +flips. It is wired anyway: `PhraseReminder` is on the same transport and is on. Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`. `mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames. @@ -125,11 +155,10 @@ Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd `internal/netaddr` landed in PR #92. A seam address now carries its own scheme, and a scheme-less one is still unix. A tcp seam requires a shared token, because the filesystem permission that authenticated the unix socket is gone. -2. **The resident model** (#485). Half wired, 02-08-2026. A `workstation` block - builds an `llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), and routing - and replies complete through it. Both are the silent half of the rule. The - naming half is not wired. A world question still goes to the resident model - through `PhraseQuery`. That, and the four callers 485 did not reach, are #490. +2. **The resident model** (#485, #490). Wired. A `workstation` block builds an + `llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), routing and replies + complete through it, and the phraser holds the same pair (`UseRemote`). Both + halves of the rule are live: see the table above for which caller gets which. Measured, `docs/evals/2026-08-02-workstation-gemma4-12b.md`: gemma-4-12b through the cascade scores 84.4% full accuracy at p50 329ms. The resident model scores 72.7% at p50 0.80-1.04s. On the talk fixture it is 25/27 -- 2.52.0 From f10e0068dd7eb6db5032974e6108c3213253728a Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 12:42:37 +0400 Subject: [PATCH 6/6] config, deploy: the workstation is workpc, not bugmachine (V-490) Owner's correction. It is the same host CLAUDE.md already calls workpc, and two names for one machine read as two machines. The dated eval file keeps the old name: a measurement is never edited after the day it was taken. --- deploy/mavend.json | 2 +- internal/config/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/mavend.json b/deploy/mavend.json index 246b69f..3a3ee02 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -40,7 +40,7 @@ }, "//workstation": [ - "The big model on the desk PC (bugmachine, 7900 GRE 16GB), fronted by", + "The big model on the desk PC (workpc, 7900 GRE 16GB), fronted by", "mavgpud on port 8080. It runs gemma-4-12b and it is preferred over the", "resident Qwen3-1.7B for routing and replies whenever the card is free.", "The machine is never assumed up: it sleeps, and the card is often held by", diff --git a/internal/config/config.go b/internal/config/config.go index b152b14..c34d8c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1110,7 +1110,7 @@ const ( DefaultKiwixSnippetRunes = 1500 ) -// WorkstationConfig — the big model on the owner's desktop (bugmachine, a +// WorkstationConfig — the big model on the owner's desktop (workpc, a // 7900 GRE with 16GB), fronted by mavgpud. // // homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this -- 2.52.0