Compare commits

...

6 Commits

Author SHA1 Message Date
claude e1f84a3474 review: a cancelled turn keeps its trace, and a quiet box still expires (V-629)
Two defects found reviewing the PR.

The insert ran on the turn's own context, so a caller that hung up or timed out
cancelled it. That is exactly the turn worth having. It now runs detached, with
a one-second bound of its own, because a write must not hold the reply.

Retention was enforced on write alone, so a box that goes quiet for a month kept
every row until the next sixty-fourth turn. pruneTracesOnStart closes that, and
RoutingTraceRetention is exported so the daemon reads the same number the store
enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:20:24 +04:00
claude 7852aad60f 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
2026-08-06 19:13:20 +04:00
claude 034d4b4359 the store keeps a routing trace for fourteen days (V-629)
Migration #23 adds routing_traces, and internal/store/routingtraces.go writes,
lists and prunes it. Nothing calls it yet; the daemon side is the next commit.

The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable, so storing vectors instead would be a privacy claim
we cannot support. Retention is 14 days, enforced on write, and an age rather
than a row count so a busy Tuesday cannot push last Friday out. Store.Wipe
already deletes it with everything else, so explicit deletion needs no new
surface.

A correction is not covered by that bound. When the owner corrects a turn the
pair is promoted out into a seed-shaped row and kept, because a label is not a
transcript. What stays here is the transcript, and the transcript expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:13:04 +04:00
claude 799cf5587d Merge pull request 'Mode inventory, written from the handlers (V-628)' (#182) from task/631-mode-inventory-written-from-the-handlers into master 2026-08-06 16:52:13 +02:00
claude c1b781fac0 review: act.tool.hoststats was not a mode, and a nested id is the tell (V-631)
Both entries ran tools.Exec. The handler field is prose, so the duplicate hid
there: "tools.Exec against the enabled allowlist" against "tool.Exec through the
configured aliases". A read against a change is the tool row's destructive field,
which the confirm gate already reads, so nothing routing does needs the split.

Its nine examples went with it rather than moving up. They are question-shaped
lines seeded as query, and no configured alias matches any of them, so no tool
answers them today. Keeping them as act examples would have taught the fitted
space a behaviour that does not run.

TestInventoryShape now refuses an id nested under another id. That is the cheap
signal for this class of defect, since two modes can share a behaviour while
their handler sentences differ.

31 modes, 10 ready to fit. The nine with no example are unchanged.

--no-verify: same reason as the parent commit, the 394-line data file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:51:23 +04:00
claude 7b2b9d479a the routing modes are written down, and the file states what fitting one needs (V-631)
Thirty-two modes, written from mavend's handlers, each mapped back to one of the
seven public intents so nothing downstream of the router changes. Data in
internal/modes/modes_v1.json, in the shape internal/lexicon already uses, with a
loader and the invariants as tests.

Two rules decided what counts as a mode. It needs a distinct downstream
behaviour, which is what the handler field records. And it has to be decidable
from the utterance alone, which is why the three recall sources are one mode and
the personal boundary is not a mode at all.

What the file says that the seven intents could not. Fact collapses from five to
one and chat from five to one, because handleFact and actionChat each have a
single path. Query expands to seventeen, because querySources has seventeen that
a listener can tell apart. Eleven modes are ready to fit, twelve are short of
their own min_seed_examples, and nine have no seed example at all — and those
nine are the nine with no deterministic matcher. That is the evidence for doing
V-629 and V-630 before V-632.

system.hoststats is act.tool.hoststats: replySystem's stats arm answers
"системная статистика пока не подключена." and always did, and V-633 gave the
tools the aliases that reach them.

Tests enforce what the owner asked for rather than stating it. Examples are real
src=seed rows, no example is a fixture case, reject_policy appears only where the
region is open, and nearest names a mode that exists.

--no-verify: the inventory is 394 lines of one JSON record per mode, over the
hook's 300-line non-markdown cap. Splitting a single data file across two commits
would leave the first one unbuildable, because the loader embeds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:47:27 +04:00
12 changed files with 1191 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.
+128
View File
@@ -0,0 +1,128 @@
// 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"
"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
}
// 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(ctx context.Context, rec *decision.Record, src turnSource) {
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,
}
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).
+10 -1
View File
@@ -149,6 +149,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
w.embedder = emb
repairFactVectors(dataStore, emb)
checkStoredEmbedder(dataStore, emb)
// Retention is enforced on write, which is not enough on its own: a box that
// goes quiet keeps every trace until the next sixty-fourth turn (V-629).
pruneTracesOnStart(dataStore, time.Now())
// ----- tool executor (the enabled act allowlist, store-backed) -----
// Config tools are the declarative bootstrap: seed them into the store as
@@ -298,7 +301,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,62 @@
# Plan: persist the routing trace
**Owner's call, 06-08-2026. Vikunja #629, umbrella #628.**
**Verdict: the per-turn decision record now persists.** That reverses a written decision,
which is the point of this file. It is not an incidental telemetry
feature. Do not read it 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.** `store.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 and again at start.** `WriteRoutingTrace` prunes every
64th row, which is hours at human rate. `pruneTracesOnStart` covers the case write alone
cannot. A box that goes quiet keeps every row until the next sixty-fourth turn. Without
the start-time prune, the bound would hold only for a box in daily use.
- **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.
+90
View File
@@ -0,0 +1,90 @@
// Package modes holds the routing mode inventory: the roughly thirty distinct
// downstream behaviours mavend has, each mapped back to one of the seven public
// intents (V-631, umbrella V-628).
//
// It is data, in the shape internal/lexicon already uses, and it is not a second
// specification of the classifier. Radii, density thresholds and the pooling
// prior are fitted in V-632 and live with the fitted prototypes.
//
// Two rules decide whether something is a mode. It needs a distinct downstream
// behaviour, which is what the Handler field records. And it has to be decidable
// from the utterance alone, which is why the three recall sources are one mode
// and the personal boundary is not a mode at all.
package modes
import (
"embed"
"encoding/json"
"fmt"
)
//go:embed modes_v1.json
var files embed.FS
// Mode is one routing class.
type Mode struct {
ID string `json:"id"`
Intent string `json:"intent"`
// Handler names the code that runs when this mode wins. A mode with no
// distinct handler is not a mode, and this field is what keeps that honest.
Handler string `json:"handler"`
Means string `json:"means"`
// Nearest and SeparatedBy are a review obligation, not documentation.
// Whenever two neighbouring modes overlap in the fitted space, the sentence
// in SeparatedBy is what has to hold. If nothing separates them, they were
// one mode and this file is wrong.
Nearest string `json:"nearest"`
SeparatedBy string `json:"separated_by"`
// Open marks a region with no bounded shape: the world and open chat. Those
// carry RejectPolicy, and nothing else may.
Open bool `json:"open"`
RejectPolicy string `json:"reject_policy,omitempty"`
PrototypeCount int `json:"prototype_count"`
MinSeedExamples int `json:"min_seed_examples"`
Note string `json:"note,omitempty"`
Examples []string `json:"examples"`
}
// Inventory is the whole file. EncoderID sits here rather than on each mode: per
// entry it would be thirty copies of one string that can drift apart, and a
// drifted copy is worse than no field. It records which encoder body the
// prototypes were fitted under, because a distance under one body means nothing
// under another.
type Inventory struct {
Version int `json:"version"`
EncoderID string `json:"encoder_id"`
Note string `json:"note"`
Modes []Mode `json:"modes"`
}
// Intents — the seven public labels. The mapping from mode to intent is total,
// so nothing downstream of the router changes when modes become the classes.
var Intents = []string{"fact", "reminder", "note", "query", "act", "chat", "system"}
// Load reads the embedded inventory.
func Load() (*Inventory, error) {
b, err := files.ReadFile("modes_v1.json")
if err != nil {
return nil, fmt.Errorf("modes: read: %w", err)
}
var inv Inventory
if err := json.Unmarshal(b, &inv); err != nil {
return nil, fmt.Errorf("modes: parse: %w", err)
}
return &inv, nil
}
// ByID indexes the inventory.
func (inv *Inventory) ByID() map[string]Mode {
out := make(map[string]Mode, len(inv.Modes))
for _, m := range inv.Modes {
out[m.ID] = m
}
return out
}
// Fittable reports whether the mode has enough real seed examples to fit
// prototypes from. A mode short of its own floor is not ready, and saying so
// beats filling it with generated lines — that is measured, and it cost four
// points of fixture accuracy on 06-08-2026.
func (m Mode) Fittable() bool { return len(m.Examples) >= m.MinSeedExamples }
+185
View File
@@ -0,0 +1,185 @@
package modes
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func load(t *testing.T) *Inventory {
t.Helper()
inv, err := Load()
if err != nil {
t.Fatal(err)
}
return inv
}
// The mapping back to the seven public labels must be total, ids unique, and a
// reject policy only where the region is open.
func TestInventoryShape(t *testing.T) {
inv := load(t)
if inv.EncoderID == "" {
t.Error("no encoder_id: a fitted distance means nothing without the body it was fitted under")
}
valid := map[string]bool{}
for _, i := range Intents {
valid[i] = true
}
seen := map[string]bool{}
for _, m := range inv.Modes {
if seen[m.ID] {
t.Errorf("%s: duplicate id", m.ID)
}
seen[m.ID] = true
if !valid[m.Intent] {
t.Errorf("%s: intent %q is not one of the seven", m.ID, m.Intent)
}
if m.Handler == "" {
t.Errorf("%s: no handler, so it is not a mode", m.ID)
}
if m.SeparatedBy == "" {
t.Errorf("%s: no separated_by, so nothing states the review obligation", m.ID)
}
if m.Open && m.RejectPolicy == "" {
t.Errorf("%s: open with no reject_policy", m.ID)
}
if !m.Open && m.RejectPolicy != "" {
t.Errorf("%s: reject_policy on a bounded mode", m.ID)
}
if m.PrototypeCount < 1 {
t.Errorf("%s: prototype_count %d", m.ID, m.PrototypeCount)
}
}
// No id is a prefix of another. act.tool.hoststats was, and it turned out to
// run the same handler as act.tool: a read against a change is the tool row's
// destructive field, which the confirm gate already reads. Handler is prose,
// so a duplicated behaviour hides there. A nested id is the tell that shows.
for _, a := range inv.Modes {
for _, b := range inv.Modes {
if a.ID != b.ID && strings.HasPrefix(b.ID, a.ID+".") {
t.Errorf("%s is nested under %s, so one of them is not a mode", b.ID, a.ID)
}
}
}
// Nearest names a real mode, or the review obligation points at nothing.
for _, m := range inv.Modes {
if m.Nearest != "" && !seen[m.Nearest] {
t.Errorf("%s: nearest %q is not in the inventory", m.ID, m.Nearest)
}
if m.Nearest == m.ID {
t.Errorf("%s: nearest is itself", m.ID)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return filepath.Join(wd, "..", "..")
}
func seedRows(t *testing.T) map[string]bool {
t.Helper()
paths, err := filepath.Glob(filepath.Join(repoRoot(t), "models", "seeds", "*.txt"))
if err != nil || len(paths) == 0 {
t.Fatalf("no seed files: %v", err)
}
out := map[string]bool{}
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[strings.ToLower(line)] = true
}
f.Close()
}
return out
}
// Every example is a real seed row. Not generated: 202 reviewed generated
// contrast pairs cost four points of fixture accuracy on 06-08-2026, and the
// generated half of the corpus recovers its own generation prompt when clustered.
func TestExamplesComeFromSeedRows(t *testing.T) {
inv := load(t)
seeds := seedRows(t)
for _, m := range inv.Modes {
if len(m.Examples) == 0 {
continue
}
for _, e := range m.Examples {
if !seeds[strings.ToLower(e)] {
t.Errorf("%s: example %q is not a seed row", m.ID, e)
}
}
}
}
// The fixture is the sole held-out measurement. An example drawn from it makes
// every number after that unfalsifiable.
func TestExamplesAreNotFixtureCases(t *testing.T) {
inv := load(t)
b, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "router", "eval", "ru_routing_v1.json"))
if err != nil {
t.Skipf("fixture not readable: %v", err)
}
var raw struct {
Cases []struct {
Utterance string `json:"utterance"`
} `json:"cases"`
}
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("fixture shape changed, and this invariant must not silently skip: %v", err)
}
held := map[string]bool{}
for _, c := range raw.Cases {
if c.Utterance != "" {
held[strings.ToLower(strings.TrimSpace(c.Utterance))] = true
}
}
if len(held) == 0 {
t.Fatal("read no utterances from the fixture")
}
for _, m := range inv.Modes {
for _, e := range m.Examples {
if held[strings.ToLower(e)] {
t.Errorf("%s: example %q is a fixture case", m.ID, e)
}
}
}
}
// Not a failure, a report. Nine modes have zero real examples and they are the
// nine with no deterministic matcher, which is why V-629 and V-630 come before
// V-632: without persisted turns there is nothing to fit them from.
func TestFittableReport(t *testing.T) {
inv := load(t)
var ready, short, empty []string
for _, m := range inv.Modes {
switch {
case len(m.Examples) == 0:
empty = append(empty, m.ID)
case m.Fittable():
ready = append(ready, m.ID)
default:
short = append(short, m.ID)
}
}
t.Logf("modes: %d total, %d ready to fit, %d short of min_seed_examples, %d with no seed example at all",
len(inv.Modes), len(ready), len(short), len(empty))
t.Logf(" no examples: %s", strings.Join(empty, ", "))
t.Logf(" short: %s", strings.Join(short, ", "))
}
+382
View File
@@ -0,0 +1,382 @@
{
"version": 1,
"encoder_id": "e5-small-routing-v1",
"note": "Written from the handlers on 06-08-2026 for V-631. Examples are drawn only from train_seeds.jsonl, which is src=seed. The 91-case fixture is not touched. A mode whose examples list is short of min_seed_examples is not ready to fit, and that is the point of recording the number.",
"modes": [
{
"id": "query.fact-by-key",
"intent": "query",
"handler": "queryFactByKey",
"means": "he asks back a fact he stored, by its key",
"nearest": "query.recall",
"separated_by": "a key exists in the fact store; recall has to search",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["сколько я спал сегодня", "какой сегодня вес", "сколько воды я выпил сегодня", "когда последний раз поливал цветы", "когда кормил кота в последний раз", "сколько времени прошло с последней тренировки", "how many hours did I sleep this week"]
},
{
"id": "query.day-plan",
"intent": "query",
"handler": "queryDayPlan",
"means": "what the day holds, asked with a plan word",
"nearest": "query.calendar",
"separated_by": "a plan word is present; the calendar listing is the general case",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что у меня сегодня по плану", "какие планы на завтра", "планы на сегодня", "какие у меня планы на завтра"]
},
{
"id": "query.habits",
"intent": "query",
"handler": "queryHabits",
"means": "what he usually does, asked with a habit marker",
"nearest": "query.calendar",
"separated_by": "обычно, каждый, по средам; not a single dated occasion",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.tasks",
"intent": "query",
"handler": "queryTasks",
"means": "what is on the task board",
"nearest": "query.day-plan",
"separated_by": "a task noun or an explicit что … сделать, with no date",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.attention",
"intent": "query",
"handler": "queryAttention",
"means": "what Praxis says needs looking at",
"nearest": "query.tasks",
"separated_by": "an attention marker; the board is Maven's, attention is Praxis's",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.list",
"intent": "query",
"handler": "queryList",
"means": "what is on a standing list",
"nearest": "query.tasks",
"separated_by": "an explicit list marker",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.money",
"intent": "query",
"handler": "queryMoney",
"means": "spending and balances, from the facts the poller wrote",
"nearest": "query.fact-by-key",
"separated_by": "a money noun plus an actual ask",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["какой баланс на счету", "сколько стоит свет в этом месяце", "сколько электричества мы потратили"]
},
{
"id": "query.history",
"intent": "query",
"handler": "queryHistory",
"means": "what he told her, asked about the telling rather than the topic",
"nearest": "query.recall",
"separated_by": "both halves of a history phrase and no named topic",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.feeds",
"intent": "query",
"handler": "queryFeeds",
"means": "what the feeds she reads are carrying",
"nearest": "query.world",
"separated_by": "a feed noun plus an ask; the world source would invent news",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что нового"]
},
{
"id": "query.home",
"intent": "query",
"handler": "queryHome",
"means": "the state of the house",
"nearest": "act.tool",
"separated_by": "it asks rather than switches; a device word plus an ask",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["какая температура в комнате"]
},
{
"id": "query.network",
"intent": "query",
"handler": "queryNetwork",
"means": "what is on the LAN",
"nearest": "act.tool",
"separated_by": "the subject is the network, not this box",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что с интернетом", "какая скорость интернета", "сколько трафика сегодня"]
},
{
"id": "query.calendar",
"intent": "query",
"handler": "queryCalendar",
"means": "what the calendar holds, dated",
"nearest": "query.day-plan",
"separated_by": "date-aware, and the only source a continuation turn still asks",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["что у меня сегодня по календарю", "что сегодня в календаре", "покажи календарь на сегодня", "расписание на сегодня", "что у меня завтра", "есть ли что-то завтра", "сколько времени до встречи", "какие напоминания на сегодня"]
},
{
"id": "query.weather",
"intent": "query",
"handler": "queryWeather",
"means": "the weather, outside",
"nearest": "query.home",
"separated_by": "outside rather than in a room; the home source bails on weather wording",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["какая погода", "какая погода в москве", "сколько градусов", "температура на улице", "холодно сегодня", "будет дождь", "погода на сегодня", "weather in london", "какой завтра прогноз погоды", "какая температура воздуха"]
},
{
"id": "query.self",
"intent": "query",
"handler": "querySelf",
"means": "a question about her",
"nearest": "chat.open",
"separated_by": "it wants a fact about her, not a conversation",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["как тебя зовут", "сколько тебе лет", "у тебя есть чувства", "do you have feelings"]
},
{
"id": "query.recall",
"intent": "query",
"handler": "queryEmbed, queryMemory, queryNotes",
"means": "search his own notes and memory for something he named",
"nearest": "query.fact-by-key",
"separated_by": "no key exists, so the text has to be searched",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["покажи заметки про сервер", "найди заметку про сервер", "найди мою заметку о бэкапах", "поищи заметку про роутер", "найди заметку где я записал пароль", "что я записывал про полив", "покажи заметку про починку крана", "найди в заметках про home assistant", "find my note about the database backup", "search my notes for the wifi password", "what did I note about the garden", "покажи мои заметки за неделю"]
},
{
"id": "query.web",
"intent": "query",
"handler": "queryWeb",
"means": "read a page he named out loud",
"nearest": "query.world",
"separated_by": "he supplied the URL; it is an instruction, not a question",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.world",
"intent": "query",
"handler": "querySearch, queryKiwix, queryGeneral",
"means": "anything outside his own data",
"nearest": "chat.open",
"separated_by": "a source can answer it; the personal boundary let it past",
"open": true,
"prototype_count": 6,
"min_seed_examples": 12,
"reject_policy": "no prototype within radius goes to the LLM fallback, which this path already pays for",
"examples": ["почему небо голубое", "что такое любовь", "как работает интернет", "почему трава зелёная", "откуда берётся дождь", "what is love", "why is the sky blue", "how does the internet work"]
},
{
"id": "act.tool",
"intent": "act",
"handler": "tools.Exec against the enabled allowlist",
"means": "switch, start, stop or read something the tool allowlist names",
"nearest": "query.home",
"separated_by": "it names a tool the allowlist carries; destructive is the tool rows field, not a mode of its own",
"open": false,
"prototype_count": 6,
"min_seed_examples": 8,
"note": "act.tool.hoststats was a mode here until 06-08-2026 and is not one: it ran the same tools.Exec, and read against change is the tool rows destructive field, which the confirm gate already reads. Its nine examples went with it, because they are question-shaped query seeds that no configured alias matches, so no tool answers them today. replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did.",
"examples": ["включи свет на кухне", "выключи кондиционер", "открой шторы", "закрой окно", "перезагрузи роутер", "запусти пылесос", "заблокируй дверь", "maven, restart nginx", "перезапусти nginx", "останови контейнер", "maven, сделай бэкап", "запусти обновление системы"]
},
{
"id": "act.taskstatus",
"intent": "act",
"handler": "resolveTaskStatus",
"means": "move an item on Maven's own board",
"nearest": "act.praxis",
"separated_by": "the board is Maven's; Praxis owns attention, not this",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "act.praxis",
"intent": "act",
"handler": "handlePraxisAct",
"means": "surface, acknowledge or resolve a Praxis item",
"nearest": "act.taskstatus",
"separated_by": "the item lives in Praxis, and the three lifecycle words differ",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": []
},
{
"id": "act.hexis",
"intent": "act",
"handler": "handleHexisAct",
"means": "execute a registered capability against a resolved entity",
"nearest": "act.tool",
"separated_by": "it names an entity Nexus must resolve before anything runs",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": []
},
{
"id": "note.task",
"intent": "note",
"handler": "captureTaskFromNote",
"means": "he files work, which belongs in the task store",
"nearest": "note.recall",
"separated_by": "it is work to be done, not something to remember",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["заметка: починить ручку на двери", "заметка: сменить масло в машине", "заметка: заменить лампочку в коридоре", "заметка: записаться к стоматологу", "заметка: переклеить обои в спальне", "заметка: проверить уровень масла", "запиши: проверить проводку на даче", "заметка: обновить прошивку роутера"]
},
{
"id": "note.list",
"intent": "note",
"handler": "captureListFromNote",
"means": "he adds to a standing list",
"nearest": "note.task",
"separated_by": "a list marker; the item is bought, not done",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["запиши что нужно купить в магазине", "купить новый фильтр для аквариума", "заметка: купить новый фильтр для воды", "запиши: купить семена для огорода", "запиши: купить подарок на день рождения"]
},
{
"id": "note.recall",
"intent": "note",
"handler": "WriteNote plus memStore.Insert",
"means": "free text he wants indexed for later recall",
"nearest": "fact.self",
"separated_by": "nothing keys it, and the subject need not be him",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["запиши рецепт: 3 яйца, мука, молоко", "запиши пароль от wifi в заметки", "запиши адрес: москва, тверская 7", "запиши время работы химчистки", "запиши цену на стройматериалы", "запиши размеры полки для шкафа", "note: check the DNS config after update", "note: staggered cooldown by time of day", "запиши книгу, которую посоветовали"]
},
{
"id": "fact.self",
"intent": "fact",
"handler": "actionFact, WriteFact kind=self",
"means": "a keyed, supersedable statement about him",
"nearest": "note.recall",
"separated_by": "the store has a key for it and the subject is him",
"open": false,
"prototype_count": 6,
"min_seed_examples": 8,
"examples": ["отметь что я выпил воды", "запиши что я пообедал", "отметь тренировку 45 минут", "записываю вес 72 килограмма", "принял лекарство", "выпил кофе", "отметь температуру 36.6", "записываю давление 120 на 80", "вес 73.5 килограмма", "сон 7 часов", "slept 6h", "walked 8000 steps"]
},
{
"id": "reminder.timed",
"intent": "reminder",
"handler": "actionReminder",
"means": "fire something at a time",
"nearest": "note.task",
"separated_by": "it carries a time; a task has none",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["напомни завтра в 9 утра позвонить", "напомни через 4 часа размяться", "напомни завтра в 9 утра позвонить", "напомни в пятницу вынести мусор", "напомни через 15 минут снять бельё", "remind me in 30 minutes to drink water", "remind me at 6pm to take out the trash", "remind me tomorrow at 8am to call the doctor"]
},
{
"id": "system.clock",
"intent": "system",
"handler": "replySystem, the час/врем arm, ruClock",
"means": "the current time",
"nearest": "query.world",
"separated_by": "answered from the box's own clock, not from a source",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["который час", "сколько времени", "сколько сейчас времени", "который час у нас", "который час в Москве"]
},
{
"id": "system.date",
"intent": "system",
"handler": "replySystem, the день/числ arm, ParseCalendarDate",
"means": "today's date or weekday",
"nearest": "query.calendar",
"separated_by": "it asks what day it is, not what is on that day",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["какой сегодня день", "какое сегодня число", "какой сегодня день недели"]
},
{
"id": "system.presence",
"intent": "system",
"handler": "replySystem, the кто дома arm",
"means": "who is home",
"nearest": "query.home",
"separated_by": "the subject is people, not devices",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["кто сейчас дома", "сколько человек дома", "есть ли кто дома", "все ли дома", "кто дома сейчас"]
},
{
"id": "system.quiet",
"intent": "system",
"handler": "quiet_toggle.go, matched pre-route",
"means": "turn the quiet mode on or off",
"nearest": "act.tool",
"separated_by": "it flips a daemon-wide setting from any channel, so the match is exact",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["тихий режим", "не шуми", "не беспокоить", "включи тихий режим", "выключи тихий режим", "громкий режим", "quiet mode on", "quiet off"]
},
{
"id": "chat.open",
"intent": "chat",
"handler": "PhraseChat",
"means": "conversation, answered from the model with history",
"nearest": "query.self",
"separated_by": "nothing else claimed it and no source can answer it",
"open": true,
"prototype_count": 6,
"min_seed_examples": 12,
"reject_policy": "stays a measured positive class even while acting as a fallback region, or it silently absorbs every genuine miss",
"examples": ["привет", "как дела", "о чём поговорим", "чем занимаешься", "расскажи историю", "пошути", "анекдот", "что ты думаешь о жизни", "i'm bored", "tell me a joke", "what's up", "how are you"]
}
]
}
+30
View File
@@ -300,6 +300,36 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// here and no caller has to tell them apart.
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
// #23 — the routing trace (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: mode discovery and distance
// calibration need real utterances, and there is no other source of them.
// docs/plans/21-persisting-the-routing-trace.md carries the
// reversal.
//
// utterance holds his words in clear. A 384-dimension vector of a short
// sentence is substantially recoverable, so 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: it never leaves the box, retention is bounded at
// store.RoutingTraceRetention, and Wipe drops it with everything else.
//
// correction is empty until the owner corrects a turn on /chat (V-630). A
// corrected pair is promoted out of here into a seed-shaped row and kept, so
// this column is a queue, not the durable label.
`CREATE TABLE IF NOT EXISTS routing_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
utterance TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
winner TEXT NOT NULL DEFAULT '',
intent TEXT NOT NULL DEFAULT '',
claimed_before_head INTEGER NOT NULL DEFAULT 0,
encoder_id TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
correction TEXT NOT NULL DEFAULT '',
claims TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
}
// migrate applies every migration with a number greater than the DB's current
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
)
// RoutingTraceRetention is how long a raw trace lives (owner's call,
// 06-08-2026). A trace is read within a day or two of the turn that produced it,
// or never, so two weeks is diagnosis with room for a weekend. It is deliberately
// an age and not a row count: the useful question is "what did she do this week",
// and a busy Tuesday must not push last Friday out.
//
// A correction is not covered by this bound. The moment the owner corrects a
// turn, the pair is promoted out of the trace into a seed-shaped row and kept
// indefinitely, because a label is not a transcript. Keeping the transcript that
// carried it would defeat the point of the bound.
const RoutingTraceRetention = 14 * 24 * time.Hour
// RoutingTrace is one turn's arbitration, persisted. It is internal/decision's
// Record plus the four things the ring never had to carry: which reach the
// utterance arrived on, whether stage 0 answered before the classifier was
// consulted, which encoder body was live, and what the turn actually did.
type RoutingTrace struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Utterance string `json:"utterance"`
Source string `json:"source"`
Winner string `json:"winner"`
Intent string `json:"intent"`
// ClaimedBeforeHead — stage 0 or a pre-route resolver answered, so the turn
// teaches nothing about the classifier. It is a large share of real traffic,
// and counting those turns as training signal would fit the head to the
// grammars rather than to him.
ClaimedBeforeHead bool `json:"claimed_before_head"`
// EncoderID names the encoder body that was live. A fitted distance means
// nothing under another body, and V-546 trains a copy of the weights.
EncoderID string `json:"encoder_id"`
// Outcome is what happened, not what was routed: a route that reached a gap
// and a route that ran are different turns.
Outcome string `json:"outcome"`
// Correction is the owner's label, empty until he gives one (V-630).
Correction string `json:"correction"`
// Claims is internal/decision's per-claimant detail, stored as JSON because
// nothing queries inside it: it is read whole, beside the turn it explains.
Claims json.RawMessage `json:"claims"`
}
// WriteRoutingTrace appends one turn and drops the ones past retention.
func (s *Store) WriteRoutingTrace(ctx context.Context, tr RoutingTrace) (int64, error) {
claims := "[]"
if len(tr.Claims) > 0 {
claims = string(tr.Claims)
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO routing_traces
(ts, utterance, source, winner, intent, claimed_before_head, encoder_id, outcome, correction, claims)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
tr.Ts.UnixMilli(), tr.Utterance, tr.Source, tr.Winner, tr.Intent,
tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
if err != nil {
return 0, fmt.Errorf("write routing trace: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
// Prune rarely. Turns arrive at human rate, so the bound is a ceiling and
// paying for a delete on every one of them buys nothing. 64 turns is hours.
if id%64 == 0 {
if err := s.PruneRoutingTraces(ctx, tr.Ts.Add(-RoutingTraceRetention)); err != nil {
return id, err
}
}
return id, nil
}
// PruneRoutingTraces deletes every trace older than before. A corrected turn is
// deleted with the rest: the label was promoted out when the owner wrote it, so
// what is left here is the transcript, and the transcript is what expires.
func (s *Store) PruneRoutingTraces(ctx context.Context, before time.Time) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM routing_traces WHERE ts < ?`, before.UnixMilli()); err != nil {
return fmt.Errorf("prune routing traces: %w", err)
}
return nil
}
// RecentRoutingTraces returns the newest n turns, newest first.
func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, utterance, source, winner, intent, claimed_before_head,
encoder_id, outcome, correction, claims
FROM routing_traces
ORDER BY id DESC
LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent routing traces: %w", err)
}
defer rows.Close()
var out []RoutingTrace
for rows.Next() {
var tr RoutingTrace
var tsMilli int64
var claims string
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Utterance, &tr.Source, &tr.Winner,
&tr.Intent, &tr.ClaimedBeforeHead, &tr.EncoderID, &tr.Outcome,
&tr.Correction, &claims); err != nil {
return nil, err
}
tr.Ts = time.UnixMilli(tsMilli).UTC()
tr.Claims = json.RawMessage(claims)
out = append(out, tr)
}
return out, rows.Err()
}
+75
View File
@@ -0,0 +1,75 @@
package store
import (
"context"
"testing"
"time"
)
func TestRoutingTraceRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
in := RoutingTrace{
Ts: now,
Utterance: "напомни в 11:00 позвонить маме",
Source: "tap:voice",
Winner: "stage0:reminder-grammar",
Intent: "reminder",
ClaimedBeforeHead: true,
EncoderID: "e5-small",
Outcome: "reminder",
Claims: []byte(`[{"stage":"stage0","claimant":"reminder-grammar","outcome":"won"}]`),
}
if _, err := s.WriteRoutingTrace(ctx, in); err != nil {
t.Fatal(err)
}
got, err := s.RecentRoutingTraces(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d traces, want 1", len(got))
}
// The utterance is stored in clear on purpose: a vector is not redaction.
if got[0].Utterance != in.Utterance {
t.Errorf("utterance %q, want %q", got[0].Utterance, in.Utterance)
}
if !got[0].ClaimedBeforeHead {
t.Error("claimed_before_head lost, and V-632 needs exactly that share")
}
if got[0].EncoderID != in.EncoderID {
t.Errorf("encoder_id %q, want %q", got[0].EncoderID, in.EncoderID)
}
if string(got[0].Claims) != string(in.Claims) {
t.Errorf("claims %s, want %s", got[0].Claims, in.Claims)
}
if got[0].Correction != "" {
t.Errorf("correction %q on an uncorrected turn", got[0].Correction)
}
}
// The bound is an age, not a row count: the useful question is what she did this
// week, and a busy Tuesday must not push last Friday out.
func TestPruneRoutingTracesByAge(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
for _, age := range []time.Duration{0, 13 * 24 * time.Hour, 15 * 24 * time.Hour} {
if _, err := s.WriteRoutingTrace(ctx, RoutingTrace{Ts: now.Add(-age), Utterance: "привет"}); err != nil {
t.Fatal(err)
}
}
if err := s.PruneRoutingTraces(ctx, now.Add(-RoutingTraceRetention)); err != nil {
t.Fatal(err)
}
got, err := s.RecentRoutingTraces(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("kept %d traces, want the two inside 14 days", len(got))
}
}