textmachine/backend/internal/store/reasoningcolumn_test.go

172 lines
6.7 KiB
Go

package store
import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"
)
// TestTheThinkingColumnKeepsSilenceApartFromZero is the durable half of the distinction the adapter
// makes: a row whose provider reported a thinking count of 0 and a row whose provider reported nothing
// must not read the same way out of the database. They did for the whole life of the project — the
// column that was supposed to answer «did thinking eat max_tokens» is NOT NULL DEFAULT 0, so every row
// said zero and no reader could tell a measured zero from a question nobody asked.
func TestTheThinkingColumnKeepsSilenceApartFromZero(t *testing.T) {
s, _ := openTemp(t)
zero, eightK := 0, 8496
for _, row := range []RequestLog{
{BookID: "b", Chapter: 1, ChunkIdx: 0, Stage: "draft", Role: "translator", CompletionTokens: 8496, ReasoningInCompletion: &eightK, OK: false},
{BookID: "b", Chapter: 1, ChunkIdx: 1, Stage: "draft", Role: "translator", CompletionTokens: 1617, ReasoningInCompletion: &zero, OK: true},
{BookID: "b", Chapter: 1, ChunkIdx: 2, Stage: "draft", Role: "translator", CompletionTokens: 1617, OK: true},
} {
if err := s.InsertRequestLog(row); err != nil {
t.Fatal(err)
}
}
rows, err := s.RequestLogRows("b")
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3", len(rows))
}
// say renders the column the way a reader has to read it: a number, or the absence of one. Printing
// the pointer itself put an ADDRESS in the failure text, which tells whoever broke this nothing
// about what the column now says.
say := func(n *int) string {
if n == nil {
return "no answer"
}
return fmt.Sprintf("%d", *n)
}
spent, measuredZero, unasked := rows[0], rows[1], rows[2]
if spent.ReasoningInCompletion == nil || *spent.ReasoningInCompletion != 8496 {
t.Fatalf("a call that spent its whole budget thinking must say so, got %s", say(spent.ReasoningInCompletion))
}
if measuredZero.ReasoningInCompletion == nil {
t.Fatal("a MEASURED zero must survive the round trip as an answer, not as silence")
}
if *measuredZero.ReasoningInCompletion != 0 {
t.Fatalf("a measured zero must stay zero, got %d", *measuredZero.ReasoningInCompletion)
}
if unasked.ReasoningInCompletion != nil {
t.Fatalf("a row whose provider reported nothing must stay unanswered, got %s", say(unasked.ReasoningInCompletion))
}
}
// TestRowsWrittenBeforeTheThinkingColumnReadAsUnanswered covers the books already on disk, and it does
// so by actually MIGRATING one: the database is built at the schema an older binary wrote, a row is
// inserted through that older INSERT, and only then is the column added. A `DEFAULT 0` would have made
// that row claim its call did not think — a claim nobody measured, about the very runs whose money this
// column exists to explain.
//
// ⚠ THE OLD VINTAGE IS BUILT, NOT SIMULATED. Inserting a nil pointer into a database that already has
// the column proves only that nil writes NULL — it never runs the ALTER over a populated table, which
// is the whole subject. Measured: the earlier version of this test passed with the migration removed.
func TestRowsWrittenBeforeTheThinkingColumnReadAsUnanswered(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
// The vintage is the one JUST BEFORE THIS COLUMN, located by looking for the migration that adds it —
// not `len(migrations)-1`.
//
// ⛔ IT USED TO BE «one back from the head», and that was correct only while this column happened to BE
// the head. The first migration to land after it broke the fixture: head-1 already carried the column,
// so the premise guard below fired and the test stopped being about anything. Worse is the version of
// that failure where the guard is absent — the fixture then silently measures «a column that already
// exists survives an ALTER», passes, and the class it was written for goes unwatched. A fixture must
// name its own subject, not borrow the tree's current shape to express it.
before := -1
for i, m := range migrations {
if strings.Contains(m, "reasoning_in_completion") {
before = i
break
}
}
if before < 0 {
t.Fatal("no migration adds reasoning_in_completion — this test names a column the schema no longer grows, and its subject is gone rather than green")
}
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
for v := 0; v < before; v++ {
if _, err := db.Exec(migrations[v]); err != nil {
t.Fatalf("build vintage %d: %v", v+1, err)
}
if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil {
t.Fatal(err)
}
}
// The column must NOT exist yet, or the fixture is not standing before it and the test is about
// nothing.
if columnExists(t, db, "request_log", "reasoning_in_completion") {
t.Fatalf("the fixture is meant to stand at the vintage BEFORE this column (migration %d of %d), and request_log already carries it", before+1, len(migrations))
}
// A row written by that older binary: the INSERT it had, without the column.
if _, err := db.Exec(`INSERT INTO request_log (book_id, chapter, chunk_idx, stage, role, completion_tokens, ok)
VALUES ('old', 1, 0, 'edit', 'editor', 16000, 0)`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// The real migrator, over a populated table.
s, err := Open(path)
if err != nil {
t.Fatalf("migrate the old project: %v", err)
}
defer s.Close()
rows, err := s.RequestLogRows("old")
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1 — the row written before the column must survive the migration", len(rows))
}
if n := rows[0].ReasoningInCompletion; n != nil {
t.Fatalf("a row from before the column must read as unanswered, got %d", *n)
}
if rows[0].CompletionTokens != 16000 {
t.Fatalf("the rest of the row must be untouched, got completion=%d", rows[0].CompletionTokens)
}
}
// columnExists asks the database itself, so the fixture's premise is checked against sqlite rather than
// against what the test author believed the migration list contained.
func columnExists(t *testing.T, db *sql.DB, table, column string) bool {
t.Helper()
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
seen := 0
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
seen++
if name == column {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if seen == 0 {
t.Fatalf("table %q has no columns at all — the fixture never built it", table)
}
return false
}