Files
Maven/internal/config/config_test.go
T
kami dc7c72a3d7 Add background memory evaluation, off unless configured (#248)
Ships the real, local, testable part of the memory-evaluation plan
(docs/plans/03-memory-evaluation.md): Maven reads back her own recent
memory on a slow ticker, asks the resident model what it notices, and
records the confident answers as notes.

internal/memeval — not internal/memory/eval.go as the plan says, because
internal/store imports internal/memory for the vector backend and an
evaluator has to read store.Fact/Note/Nudge, which would close the
cycle. Evaluate() gathers RecentFacts/RecentNotes/RecentNudges, prompts
under a GBNF grammar bounded to three {observation, confidence,
suggested_action} objects, drops anything under min_confidence,
deduplicates against what earlier runs wrote, and writes the rest as
notes with source infer:memory-eval. /dash already renders notes with
their source, so the output is visible with no UI change.

cmd/mavend/memoryeval.go drives it on its own goroutine and ticker, not
on the 60s tick: an evaluation is a multi-second round-trip on the same
llama-server that answers voice turns, and it runs hourly at most. The
memory_eval config block is absent by default and absence means the
goroutine does not exist. No llama-server phraser also means no loop —
there is no template fallback, because a "memory evaluation" assembled
from templates is a fixed sentence pretending to be an observation.

What it deliberately cannot do, since this is the feature most likely to
turn Maven into a nag:

  - It cannot speak. No dispatcher reference, no channel, no nudge. An
    observation is a thought she wrote down and he reads on /dash.
    Announcing them is a separate decision with its own opt-in.
  - It cannot act. suggested_action is recorded as text and interpreted
    by nobody — no reminder, routine or fact is created from it.
  - It says nothing about an empty store: no memory means no LLM call,
    so there are no observations invented out of two facts.
  - Its own notes are excluded from the next evaluation's input, and are
    written with a nil embedding so they stay out of the recall pool.

The plan's remaining items (dispatching observations, an /eval IPC
method and trace view, RecentEvents) and the fact that output quality is
entirely unmeasured are written up at the bottom of the plan doc.
2026-08-01 01:45:49 +04:00

296 lines
8.8 KiB
Go

package config
import (
"path/filepath"
"testing"
"time"
)
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "mavend.json")
if err := writeFile(t, p, body); err != nil {
t.Fatalf("write config: %v", err)
}
return p
}
func TestLoadDefaults(t *testing.T) {
p := writeConfig(t, `{}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if time.Duration(c.TickInterval) != DefaultTickInterval {
t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval)
}
if time.Duration(c.RepeatInterval) != DefaultRepeatInterval {
t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval)
}
if c.DBPath == "" {
t.Error("DBPath default not applied")
}
if c.SocketPath == "" {
t.Error("SocketPath default not applied")
}
}
// Nudges come from templates unless the config says otherwise.
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Phraser.LLMNudges {
t.Error("llm_nudges defaults on; templates must be the default")
}
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
c, err = Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Phraser.LLMNudges {
t.Error("llm_nudges:true did not parse")
}
}
func TestLoadDurationsParse(t *testing.T) {
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if time.Duration(c.TickInterval) != 90*time.Second {
t.Errorf("TickInterval = %v, want 90s", c.TickInterval)
}
if time.Duration(c.RepeatInterval) != 10*time.Minute {
t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval)
}
}
func TestLoadBadDurationRejected(t *testing.T) {
p := writeConfig(t, `{"tick_interval":"not-a-duration"}`)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for a bad duration; want error")
}
}
func TestLoadMissingFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "nonexistent.json")
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for a missing file; want error")
}
}
func TestVoiceEnabledRequiresBind(t *testing.T) {
// enabled=true without bind is refused — the voice surface can't
// default a bind (127.0.0.1 too relaxed for production, a wg addr is
// the user's). surfacing the gap explicitly beats an idle listener.
p := writeConfig(t, `{"voice":{"enabled":true}}`)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for voice.enabled=true with no bind; want error")
}
}
func TestVoiceEnabledWithBindOK(t *testing.T) {
// voice surface fully configured — accepted (the daemon wires Stub tts +
// voicesink; no models on disk required).
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestEmbedderAllPathsSetOK(t *testing.T) {
p := writeConfig(t, `{
"voice": {
"enabled": true, "bind": "127.0.0.1:9100",
"embedder": {
"model_path": "m.onnx",
"tokenizer_path": "t.json",
"lib_path": "l.so"
}
}
}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestEmbedderPartialConfigRejected(t *testing.T) {
tests := []struct {
name string
json string
}{
{"missing model_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"tokenizer_path":"t.json","lib_path":"l.so"}}}`},
{"missing tokenizer_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","lib_path":"l.so"}}}`},
{"missing lib_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","tokenizer_path":"t.json"}}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := writeConfig(t, tt.json)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for partial embedder; want error")
}
})
}
}
func TestEmbedderNilOK(t *testing.T) {
// voice block without embedder → ok (HashEmbedder floor)
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestVoicePresentDisabledOK(t *testing.T) {
// voice block present but not enabled — acceptable (the listener stays
// down; the routing table's ChannelVoice selections drop).
p := writeConfig(t, `{"voice":{"enabled":false}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestVoiceSttTtsConfigParsed(t *testing.T) {
// the stt/tts worker sub-blocks parse + remember socket/lang.
p := writeConfig(t, `{
"voice": {
"enabled": true, "bind": "127.0.0.1:9100",
"stt": {"socket": "/tmp/stt.sock", "lang": "ru"},
"tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"}
}
}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" {
t.Fatalf("Stt config not parsed: %+v", c.Voice)
}
if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" {
t.Fatalf("Tts config not parsed: %+v", c.Voice)
}
}
func TestWeatherConfig(t *testing.T) {
// Weather block with provider + default location → OK
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load with weather config: %v", err)
}
}
func TestWeatherConfigNilOK(t *testing.T) {
// No weather block → OK (stub)
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load without weather config: %v", err)
}
}
func TestLLMRouterDefaultsOn(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Voice.UseLLMRouter() {
t.Error("voice.llm_router absent should mean on")
}
}
// Missing and explicitly false must not mean the same thing.
func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Voice.UseLLMRouter() {
t.Error("voice.llm_router false should turn it off")
}
}
func TestLLMRouterRead(t *testing.T) {
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`)
c, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.Voice.UseLLMRouter() {
t.Error("voice.llm_router true was not read")
}
}
func TestDurationRoundTrip(t *testing.T) {
d := Duration(15 * time.Minute)
b, err := d.MarshalJSON()
if err != nil {
t.Fatalf("MarshalJSON: %v", err)
}
if got, want := string(b), `"15m0s"`; got != want {
t.Errorf("MarshalJSON = %s, want %s", got, want)
}
var d2 Duration
if err := d2.UnmarshalJSON(b); err != nil {
t.Fatalf("UnmarshalJSON: %v", err)
}
if d2 != d {
t.Errorf("round-trip = %v, want %v", d2, d)
}
}
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
// valid and gets the defaults filled in.
func TestOptInBlocksAbsentStayNil(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals != nil {
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("AnnounceProposals() true with no config block")
}
if c.MemoryEval != nil {
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
}
}
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.PatternProposals.AnnounceProposals() {
t.Error("notify:true did not enable announcements")
}
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
}
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
}
}
// Notify is off even when the block exists — the block is where you tune it,
// notify:true is the act that lets her speak.
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.PatternProposals.AnnounceProposals() {
t.Error("notify defaulted to on")
}
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
}
}