// 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" "sync" "time" "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 } // The trace id rides the context, the same seam querysource.go uses and for the // same reason: handleText answers every reach through one string, and threading // a second value through the whole action dispatch would change a signature the // mic, telegram and the web all share. A caller that wants the id asks for a // sink; the mic path does not, and pays nothing. type traceIDKey struct{} type traceIDSink struct { mu sync.Mutex id int64 } func (s *traceIDSink) note(id int64) { s.mu.Lock() defer s.mu.Unlock() s.id = id } // ID is the persisted trace for the turn, or 0 when nothing was persisted. func (s *traceIDSink) ID() int64 { s.mu.Lock() defer s.mu.Unlock() return s.id } // withTraceIDSink returns a context that collects the persisted trace id, and // the sink to read after the turn has answered. func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) { sink := &traceIDSink{} return context.WithValue(ctx, traceIDKey{}, sink), sink } func noteTraceID(ctx context.Context, id int64) { if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok { sink.note(id) } } // pruneTracesOnStart enforces retention once at wiring time. Pruning on write // alone is not enough: a box that goes quiet for a month keeps every row until // the next sixty-fourth turn, and "kept for fourteen days" would then be true // only of a box in daily use. Called for its effect and never blocks a start. func pruneTracesOnStart(s *store.Store, now time.Time) { if s == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil { log.Printf("routing trace: prune on start: %v", err) } } // 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(turnCtx context.Context, rec *decision.Record, src turnSource) { ctx := turnCtx if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" { return } // Detached from the turn's context, and bounded on its own. Two reasons, and // the first is the one that matters: the turn is over by the time this runs, // so a caller that hung up or timed out would cancel the insert, and the turn // he abandoned halfway is exactly the one worth having. The second is that a // write must not hold the reply, so it gets a second and no more. ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second) defer cancel() 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, } id, err := h.traces.WriteRoutingTrace(ctx, tr) if err != nil { log.Printf("routing trace: write: %v", err) return } // The id goes back to whoever asked for it, so /chat can offer a correction // on the turn it is already showing (V-630). Noted on the ORIGINAL context, // not the detached one above: the sink belongs to the caller's turn. noteTraceID(turnCtx, id) // And the spoken path, which has no reply to hang a badge on: a correction // said out loud points at the previous turn, so it needs that turn's row // (V-636, repair.go). h.stampLastTurn(rec.Utterance, id) } // 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 }