every turn persists its decision record, and the reversal is written down (V-629)

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
This commit is contained in:
2026-08-06 19:13:20 +04:00
parent 034d4b4359
commit 7852aad60f
6 changed files with 283 additions and 4 deletions
+16 -2
View File
@@ -285,8 +285,22 @@ site cannot change a route and a context with no record costs nothing. It is
installed in `runTurn`, so the mic, telegram and the web all leave the same
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
Nothing persists: a turn record is read minutes later or never, and his words do
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
**It also persists, since 06-08-2026, and that reverses what this section used to
say** (V-629, `docs/plans/21-persisting-the-routing-trace.md`). The old rule was
that nothing persists, because a turn record is read minutes later or never. The
owner reversed it: the routing heads (V-546) cannot be fitted or calibrated
without real utterances, and 9 of the 31 modes in `internal/modes` have no seed
example at all. The ring did not move. It is still what `/trace` reads and still
what a test with no store gets. `cmd/mavend/routingtrace.go` is a second sink
beside it, writing `routing_traces` (migration #23). The utterance is stored in
clear, because a 384-dimension vector of a short sentence is substantially
recoverable and storing vectors instead would be a privacy claim we cannot
support. What makes it safe is the same thing that makes the fact store safe.
Retention is 14 days, enforced on write. Nothing reads it outward, and the rule
that his notes and facts are never search input covers this table. `Store.Wipe`
deletes it with everything else. A correction (V-630) is promoted out into a
seed-shaped row and kept, because a label is not a transcript. The transcript
still expires. Adding a rung to the ladder
in `runTurn` means adding its name to `preRouteLadder` in
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
+105
View File
@@ -0,0 +1,105 @@
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
//
// internal/decision keeps a 25-turn in-memory 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, because the routing heads (V-546) cannot be fitted or
// calibrated without real utterances and there is no other source of them. The
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
//
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
// wired no store still gets. This file is the second sink beside it, and it is
// nil unless the daemon has a database — no store, no trace, no error.
package main
import (
"context"
"encoding/json"
"log"
"strings"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/store"
)
// traceWriter is the seam the handler persists through. store.Store satisfies
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
type traceWriter interface {
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
}
// traceSink wraps the store, or returns nil when there is none. A typed nil
// pointer assigned straight into the interface would be non-nil and would panic
// on the first turn, which is the classic shape of this bug.
func traceSink(s *store.Store) traceWriter {
if s == nil {
return nil
}
return s
}
// persistDecision writes one finished record. It takes the same *decision.Record
// the ring takes, so the two sinks cannot disagree about what the turn did.
//
// Errors are logged and swallowed. A trace is diagnostic and training data, and
// a failed insert must never change what the owner hears.
func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Record, src turnSource) {
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
return
}
claims, err := json.Marshal(rec.Claims)
if err != nil {
log.Printf("routing trace: marshal claims: %v", err)
return
}
tr := store.RoutingTrace{
Ts: rec.Ts,
Utterance: rec.Utterance,
Source: string(src),
Winner: rec.Winner,
Intent: wonIntent(rec),
ClaimedBeforeHead: claimedBeforeHead(rec),
EncoderID: h.encoderID,
Outcome: wonAt(rec, decision.StageAction),
Claims: claims,
}
if _, err := h.traces.WriteRoutingTrace(ctx, tr); err != nil {
log.Printf("routing trace: write: %v", err)
}
}
// wonIntent — what the winning claimant made the turn. Read from the claim
// rather than from the route, because a pre-route resolver wins without routing
// and its intent is the honest answer to "what was this turn".
func wonIntent(rec *decision.Record) string {
for _, c := range rec.Claims {
if c.Outcome == decision.Won && c.Intent != "" {
return c.Intent
}
}
return ""
}
// wonAt — the claimant that won at one stage. The action stage is what actually
// produced the reply, which is a different question from what was routed: a
// route that reached a gap and a route that ran are not the same turn.
func wonAt(rec *decision.Record, stage string) string {
for _, c := range rec.Claims {
if c.Stage == stage && c.Outcome == decision.Won {
return c.Claimant
}
}
return ""
}
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
// turn teaches nothing about the classifier. Those are a large share of real
// traffic, and fitting a head on them would fit it to the grammars rather than
// to him. Recorded per turn rather than filtered on write, because which share
// that is happens to be the number V-632 needs to know.
func claimedBeforeHead(rec *decision.Record) bool {
stage, _, ok := strings.Cut(rec.Winner, ":")
if !ok {
return false
}
return stage == decision.StagePreRoute || stage == decision.StageZero
}
+79
View File
@@ -0,0 +1,79 @@
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)
+16 -1
View File
@@ -145,6 +145,17 @@ type reactiveHandler struct {
// is recorded, which is what a test that did not ask for one gets.
decisions *decision.Ring
// traces persists those same records (V-629, routingtrace.go). The ring is
// still what /trace reads; this is the second sink, and it exists because the
// routing heads cannot be fitted without real utterances. nil ⇒ the ring
// alone, which is the behaviour every box had before 06-08-2026.
traces traceWriter
// encoderID names the encoder body live on this box, stored beside each
// trace: a fitted distance means nothing under another body. Empty ⇒ no
// embedder, so the classifier was the keyword floor.
encoderID string
// clarifyStore parks the request behind an open question she asked (see
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
clarifyStore *dialogue.ClarifyStore
@@ -265,7 +276,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
var rec *decision.Record
ctx, rec = decision.With(ctx, text)
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
defer func() {
done := rec.Finish(h.now())
h.decisions.Push(done)
h.persistDecision(ctx, done, src)
}()
}
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
+7 -1
View File
@@ -298,7 +298,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// Always on (V-564). The record is the instrument the rest of V-558 is
// measured with, and one that only runs when a flag is set is not there
// on the night the misroute happens.
decisions: decision.NewRing(),
decisions: decision.NewRing(),
// The second sink (V-629). Same records, persisted, because the routing
// heads cannot be fitted from a 25-turn ring. Nil store ⇒ ring only, and
// EmbedderID is the same string the vector marker uses, so a trace and a
// stored vector name their body the same way.
traces: traceSink(dataStore),
encoderID: router.EmbedderID(emb),
clarifyStore: clarifyStore,
// 0 here (unset config) ⇒ the dialogue default.
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
@@ -0,0 +1,60 @@
# Plan: persist the routing trace
**Owner's call, 06-08-2026. Vikunja #629, umbrella #628.**
**Verdict: the per-turn decision record is now written to the database.** That reverses a
written decision, which is the point of this file. It is not an incidental telemetry
feature and must not be read as one.
Last verified: 06-08-2026 @ 799cf55
## What the old decision said
`internal/decision` kept a 25-turn in-memory ring and persisted nothing. The argument was
in `CLAUDE.md` and it was a good one. A turn record is read minutes after the turn or
never, so a table that outlives the diagnosis buys nothing. His words did not belong in it.
## Why it reversed
V-546 replaces the generative router with classification heads on e5-small. Fitting
prototypes and calibrating a distance both need real utterances. V-631 measured how few
there are. Nine of the 31 modes in `internal/modes` have no seed example at all, and they
are exactly the nine with no deterministic matcher. The seed corpus cannot supply them. A
seed row is a phrase someone wrote for a matcher, not a thing he said. The 202 generated
contrast pairs were tried and cost four points of fixture accuracy.
So the choice was between no routing heads and a persisted trace. The owner chose the trace.
## Retention, and why it is two answers
**Raw trace: 14 days.** `routingTraceRetention` in `internal/store/routingtraces.go`. That
is the life of a diagnosis with room for a weekend. The bound is an age and not a row
count. The useful question is what she did this week, and a busy Tuesday must not push last
Friday out.
**A correction: indefinite.** The owner corrects a turn on `/chat` (V-630). The pair is then
promoted out of the trace into a seed-shaped row and kept, because a label is not a
transcript. What stays in `routing_traces` is the transcript. It expires on the same 14
days as every other row, corrected or not.
## What keeps it safe
The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable. Storing vectors instead would be a privacy claim we cannot
support, and making it would be worse than staying silent.
- **Nothing here leaves the box.** The rule that the owner's notes and facts are never
search input covers this table too. No query source reads it, and no upstream engine can.
- **Retention is enforced on write.** `WriteRoutingTrace` prunes every 64th row, which is
hours at human rate.
- **Deletion already exists.** `Store.Wipe` drops every table the database reports, so
`mavend -wipe -confirm-wipe` covers this one with no list to edit.
- **The ring did not move.** It is still what `/trace` reads and still what a test with no
store gets. The table is a second sink beside it. A failed insert is logged and swallowed,
because a trace must never change what he hears.
## What is not decided
Whether some utterances must never be promoted into a durable label, no matter how badly
they routed. That is a content rule and it belongs beside the personal boundary, not in the trace
writer. Recorded here, left to the owner.