7852aad60f
internal/decision kept a 25-turn ring and persisted nothing, on the argument that a turn record is read minutes later or never. The owner reversed that on 06-08-2026: the routing heads cannot be fitted or calibrated without real utterances, and V-631 measured that 9 of the 31 modes have no seed example at all. docs/plans/21-persisting-the-routing-trace.md carries the reversal, and CLAUDE.md now says which of its own sentences stopped being true. cmd/mavend/routingtrace.go is a second sink beside the ring, which did not move: the ring is still what /trace reads and still what a test with no store gets. A failed insert is logged and swallowed, because a trace must never change what he hears. traceSink keeps a nil store out of the interface, since a typed nil pointer there would pass the nil check and die on the first turn. Four fields the ring never carried: which reach the turn arrived on, whether stage 0 answered before the classifier was consulted, which encoder body was live (the same EmbedderID string the vector marker uses), and what the action stage actually did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
80 lines
2.5 KiB
Go
80 lines
2.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/decision"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// A real turn leaves a persisted trace, not only a ring entry. This is the whole
|
|
// of V-629: without one there is nothing to fit the routing heads from.
|
|
func TestTurnPersistsTrace(t *testing.T) {
|
|
ring := decision.NewRing()
|
|
h := traceHandler(t, ring)
|
|
h.traces = traceSink(h.dataStore)
|
|
h.encoderID = "hash-1024"
|
|
|
|
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
|
t.Fatal("turn produced no reply")
|
|
}
|
|
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("persisted %d traces, want 1", len(got))
|
|
}
|
|
tr := got[0]
|
|
if tr.Utterance != "сколько сейчас времени" {
|
|
t.Errorf("utterance %q", tr.Utterance)
|
|
}
|
|
if tr.Source != string(sourceText) {
|
|
t.Errorf("source %q, want %q", tr.Source, sourceText)
|
|
}
|
|
// A stage-0 clock rule answers this one, so the turn teaches the classifier
|
|
// nothing and the trace has to say so.
|
|
if !tr.ClaimedBeforeHead {
|
|
t.Errorf("claimed_before_head false on winner %q", tr.Winner)
|
|
}
|
|
if tr.EncoderID != "hash-1024" {
|
|
t.Errorf("encoder_id %q", tr.EncoderID)
|
|
}
|
|
if len(tr.Claims) < 3 {
|
|
t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims)
|
|
}
|
|
}
|
|
|
|
// No store, no trace, and no panic. A typed nil pointer in the interface would
|
|
// pass the nil check and die on the first turn.
|
|
func TestNoStoreNoTrace(t *testing.T) {
|
|
ring := decision.NewRing()
|
|
h := traceHandler(t, ring)
|
|
h.traces = traceSink(nil)
|
|
|
|
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
|
t.Fatal("turn produced no reply")
|
|
}
|
|
if len(ring.Recent(5)) != 1 {
|
|
t.Error("the ring is still the first sink and must still hold the turn")
|
|
}
|
|
}
|
|
|
|
// An empty utterance writes nothing. A blank row carries no label and no
|
|
// diagnosis, and it is his words the retention bound exists for.
|
|
func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
|
|
h := traceHandler(t, decision.NewRing())
|
|
h.traces = traceSink(h.dataStore)
|
|
h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText)
|
|
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("persisted %d traces for a blank utterance", len(got))
|
|
}
|
|
}
|
|
|
|
var _ traceWriter = (*store.Store)(nil)
|