a corrected turn is promoted to a label that outlives the trace (V-630)
Migration #24 adds routing_labels, and CorrectTurn writes it. Nothing calls it yet; the wire and the surface are the next commits. Separate table, and that is the whole retention argument. A trace is a transcript and expires in 14 days. A correction is a label the owner wrote by hand, and it is the only supervised signal this box will ever get, so it is promoted out at the moment he writes it and kept. should_be may be empty. "That was wrong" with no target is a usable negative and must not cost more to give than the full answer. UNIQUE(utterance) so a second correction replaces the first, because his second answer is the one he meant. The label and the trace stamp go in one transaction: a stamp with no label loses the signal when the trace expires. ErrNoSuchTrace is held apart from a write failure. Correcting a turn older than the bound is the expected case, and the surface should say so rather than report a broken database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
This commit is contained in:
@@ -330,6 +330,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
claims TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
|
||||
// #24 — the corrected pairs (V-630). Separate from routing_traces on
|
||||
// purpose, and this is the whole retention argument: a trace is a transcript
|
||||
// and expires in 14 days, while a correction is a label the owner wrote by
|
||||
// hand and is the only supervised signal the box will ever get. Promoting it
|
||||
// out at the moment he writes it means the label survives the transcript
|
||||
// that carried it.
|
||||
//
|
||||
// should_be may be empty. "That was wrong" with no target is a usable
|
||||
// negative and must not cost more to give than the full answer would.
|
||||
//
|
||||
// UNIQUE(utterance) so correcting the same sentence twice replaces the
|
||||
// label rather than stacking two. His second answer is the one he meant.
|
||||
`CREATE TABLE IF NOT EXISTS routing_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
utterance TEXT NOT NULL UNIQUE,
|
||||
was TEXT NOT NULL DEFAULT '',
|
||||
should_be TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
encoder_id TEXT NOT NULL DEFAULT ''
|
||||
);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoSuchTrace — the trace the correction names is gone or never existed.
|
||||
// Held apart from a write failure because it is the expected outcome of
|
||||
// correcting a turn older than the 14-day bound, and the surface should say that
|
||||
// rather than report a broken database.
|
||||
var ErrNoSuchTrace = errors.New("no such routing trace")
|
||||
|
||||
// RoutingLabel is one correction: what he said, what she made of it, and what it
|
||||
// should have been. It is the only supervised signal in the box, so it outlives
|
||||
// the trace it came from (V-630, docs/plans/22-correcting-a-turn.md).
|
||||
type RoutingLabel struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Utterance string `json:"utterance"`
|
||||
// Was is the intent the cascade chose. Kept beside the target because the
|
||||
// pair is what names the confusion, and a label with no "was" cannot say
|
||||
// which boundary moved.
|
||||
Was string `json:"was"`
|
||||
// ShouldBe is the owner's target, and may be empty. "That was wrong, I am
|
||||
// not going to tell you what it was" is a usable negative, and requiring the
|
||||
// target would cost the cheap half of the gesture.
|
||||
ShouldBe string `json:"should_be"`
|
||||
Source string `json:"source"`
|
||||
EncoderID string `json:"encoder_id"`
|
||||
}
|
||||
|
||||
// CorrectTurn records the owner's correction of one persisted turn. It promotes
|
||||
// the pair into routing_labels and stamps the trace, both in one transaction:
|
||||
// a stamped trace with no label would lose the signal when the trace expires,
|
||||
// and a label with no stamp would let the same turn be corrected twice.
|
||||
//
|
||||
// shouldBe empty is allowed and means "wrong, target unstated".
|
||||
func (s *Store) CorrectTurn(ctx context.Context, traceID int64, shouldBe string, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("correct turn: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var utterance, was, source, encoderID string
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT utterance, intent, source, encoder_id FROM routing_traces WHERE id = ?`,
|
||||
traceID).Scan(&utterance, &was, &source, &encoderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNoSuchTrace
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("correct turn: read trace: %w", err)
|
||||
}
|
||||
|
||||
shouldBe = strings.TrimSpace(shouldBe)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO routing_labels (ts, utterance, was, should_be, source, encoder_id)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(utterance) DO UPDATE SET
|
||||
ts = excluded.ts, was = excluded.was, should_be = excluded.should_be,
|
||||
source = excluded.source, encoder_id = excluded.encoder_id`,
|
||||
now.UnixMilli(), utterance, was, shouldBe, source, encoderID); err != nil {
|
||||
return fmt.Errorf("correct turn: write label: %w", err)
|
||||
}
|
||||
// The stamp is what the trace itself carries: "corrected", or the target he
|
||||
// gave. It expires with the trace, and that is fine — the label above is the
|
||||
// durable half.
|
||||
stamp := shouldBe
|
||||
if stamp == "" {
|
||||
stamp = "wrong"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE routing_traces SET correction = ? WHERE id = ?`, stamp, traceID); err != nil {
|
||||
return fmt.Errorf("correct turn: stamp trace: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("correct turn: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RoutingLabels returns the newest n corrections, newest first. Nothing prunes
|
||||
// them: 31 modes and 9 of them with no example at all is the problem this table
|
||||
// exists to solve, and a label is a few dozen bytes.
|
||||
func (s *Store) RoutingLabels(ctx context.Context, n int) ([]RoutingLabel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, utterance, was, should_be, source, encoder_id
|
||||
FROM routing_labels ORDER BY id DESC LIMIT ?`, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("routing labels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RoutingLabel
|
||||
for rows.Next() {
|
||||
var l RoutingLabel
|
||||
var tsMilli int64
|
||||
if err := rows.Scan(&l.ID, &tsMilli, &l.Utterance, &l.Was, &l.ShouldBe,
|
||||
&l.Source, &l.EncoderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Ts = time.UnixMilli(tsMilli).UTC()
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedTrace(t *testing.T, s *Store, utterance, intent string, now time.Time) int64 {
|
||||
t.Helper()
|
||||
id, err := s.WriteRoutingTrace(context.Background(), RoutingTrace{
|
||||
Ts: now, Utterance: utterance, Intent: intent, Source: "tap:text", EncoderID: "e5-small",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// The label carries the pair, and it is what survives the transcript.
|
||||
func TestCorrectTurnPromotesTheLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
id := seedTrace(t, s, "поужинал", "query", now)
|
||||
|
||||
if err := s.CorrectTurn(ctx, id, "fact", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("got %d labels, want 1", len(labels))
|
||||
}
|
||||
l := labels[0]
|
||||
if l.Utterance != "поужинал" || l.Was != "query" || l.ShouldBe != "fact" {
|
||||
t.Errorf("label %+v: the pair is what names the confusion", l)
|
||||
}
|
||||
if l.EncoderID != "e5-small" {
|
||||
t.Errorf("encoder_id %q: a fitted distance means nothing without the body", l.EncoderID)
|
||||
}
|
||||
// The trace is stamped too, so the same turn cannot be corrected twice into
|
||||
// two labels without the surface knowing.
|
||||
traces, err := s.RecentRoutingTraces(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if traces[0].Correction != "fact" {
|
||||
t.Errorf("trace correction %q, want fact", traces[0].Correction)
|
||||
}
|
||||
}
|
||||
|
||||
// "Wrong, and I am not telling you what it was" is the cheap half of the
|
||||
// gesture, and it must not cost more than the full answer.
|
||||
func TestCorrectTurnWithNoTarget(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
id := seedTrace(t, s, "закрывай", "act", now)
|
||||
|
||||
if err := s.CorrectTurn(ctx, id, " ", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].ShouldBe != "" {
|
||||
t.Fatalf("labels %+v: an untargeted negative is still a label", labels)
|
||||
}
|
||||
traces, _ := s.RecentRoutingTraces(ctx, 10)
|
||||
if traces[0].Correction != "wrong" {
|
||||
t.Errorf("trace correction %q, want wrong", traces[0].Correction)
|
||||
}
|
||||
}
|
||||
|
||||
// His second answer is the one he meant, so a re-correction replaces.
|
||||
func TestCorrectTurnTwiceReplaces(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
first := seedTrace(t, s, "поужинал", "query", now)
|
||||
second := seedTrace(t, s, "поужинал", "chat", now.Add(time.Minute))
|
||||
|
||||
if err := s.CorrectTurn(ctx, first, "note", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CorrectTurn(ctx, second, "fact", now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("got %d labels for one sentence, want 1", len(labels))
|
||||
}
|
||||
if labels[0].ShouldBe != "fact" || labels[0].Was != "chat" {
|
||||
t.Errorf("label %+v, want the second correction", labels[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A turn past the 14-day bound cannot be corrected, and the surface has to be
|
||||
// able to say that rather than report a broken database.
|
||||
func TestCorrectTurnUnknownTrace(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
err := s.CorrectTurn(context.Background(), 999, "fact", time.Now())
|
||||
if !errors.Is(err, ErrNoSuchTrace) {
|
||||
t.Fatalf("err %v, want ErrNoSuchTrace", err)
|
||||
}
|
||||
labels, _ := s.RoutingLabels(context.Background(), 10)
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("wrote %d labels for a trace that does not exist", len(labels))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user