diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 77b1f3e..5081a46 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 + // 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 diff --git a/internal/store/routingtraces.go b/internal/store/routingtraces.go new file mode 100644 index 0000000..ac7f922 --- /dev/null +++ b/internal/store/routingtraces.go @@ -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() +} diff --git a/internal/store/routingtraces_test.go b/internal/store/routingtraces_test.go new file mode 100644 index 0000000..34a058b --- /dev/null +++ b/internal/store/routingtraces_test.go @@ -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)) + } +}