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() }