textmachine/backend/cmd/tmbankprobe/main.go

595 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command tmbankprobe is the $0 replay probe for the bank's «contested» predicates: it reads stop
// projections a PAID run already wrote, rebuilds the engine's own rows out of them, and measures how many
// candidates each threshold selects — before anybody builds a router that spends money on the answer.
//
// ⛔ WHY IT READS ARTIFACTS RATHER THAN RE-RUNNING THE ROLE. The question is «what did the population look
// like AT THE STOP», and the state of a project database moves after the stop: on the cold run A the
// glossary is EMPTY at the stop and holds 69 rows afterwards, so a naive replay would see a full bank and
// report as «already settled by the bank» rows nobody settled when the money was spent. The stop
// projection is the engine's own record of that moment, written by the same functions the router would
// read (bankStopRows → BankExportProposal), so it is the ONE source that cannot drift under the question.
//
// ⛔ AND IT NEVER WRITES. Every database it opens is opened read-only AND immutable, which is stronger
// than it sounds: a plain `mode=ro` connection still creates or updates the -shm sidecar beside the file
// it reads, and these files are bought evidence. Nothing here migrates, and a schema this binary does not
// match is REPORTED rather than repaired — the engine's own store refuses such a database on purpose, and
// a probe that quietly migrated an archived run would destroy the thing it came to measure.
//
// Usage:
//
// go run ./cmd/tmbankprobe -stop A=/path/a.bank.json -stop B=/path/b.bank.json
// go run ./cmd/tmbankprobe -stop B=/path/b.bank.json -db B=/path/project.db
// go run ./cmd/tmbankprobe -stop A=... -stop B=... -compare A,B
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
_ "modernc.org/sqlite"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
)
// source is one stop projection, rebuilt into the engine's types plus the controls that say what the file
// actually carried. The controls are printed beside every number: a zero from a field the file does not
// have and a zero from a field it has are the same zero on a screen.
type source struct {
name string
path string
rows []pipeline.BankStopRow
cands []terminology.Candidate
asOf string
runID string
withConv int // records that carried `conventions` (A's export predates the field)
withInv int
withConf int
withCtr int
withHold int
labels int // variant labels this binary could read back
badLabel []string
consol map[string]any
// blindToSettled marks a source whose artifact cannot say which rows the bank SETTLED. The JSON
// sidecar carries no such field (bankexport.go), so on a purchase that settled anything its rows look
// exactly like rows the role answered with nothing — and both denominators below would be wrong
// without anybody seeing it. The stop TABLE carries the mark; this flag is what makes the difference
// visible instead of silent.
blindToSettled bool
}
type bankJSON struct {
BookID string `json:"book_id"`
AsOf string `json:"as_of"`
RunID string `json:"run_id"`
Terms []json.RawMessage `json:"terms"`
Proposed []map[string]json.RawMessage `json:"proposed"`
Consolidation map[string]any `json:"consolidation"`
}
func main() {
var stops, sheets, dbs multiFlag
flag.Var(&stops, "stop", "NAME=PATH of a stop projection (project.db.bank.json); repeatable")
flag.Var(&sheets, "sheet", "NAME=PATH of a stop TABLE (project.db.bank-stop.txt) — the richer artifact: it carries the source contexts and the settled mark; repeatable")
flag.Var(&dbs, "db", "NAME=PATH of a project database to read CONTROL counts from, read-only; repeatable")
var passes multiFlag
flag.Var(&passes, "passes", "NAME=FIRST,SECOND — a book's two stop TABLES: what changed in a row's OWN input between its two purchases; repeatable")
compare := flag.String("compare", "", "NAME,NAME — join two stop projections by source surface and report what changed between the two purchases")
flag.Parse()
if len(stops)+len(sheets)+len(passes) == 0 {
fmt.Fprintln(os.Stderr, "tmbankprobe: give it something to read — at least one -stop, -sheet or -passes")
os.Exit(2)
}
byName := map[string]*source{}
var order []string
for _, spec := range sheets {
name, path, ok := strings.Cut(spec, "=")
if !ok {
fmt.Fprintf(os.Stderr, "tmbankprobe: -sheet wants NAME=PATH, got %q\n", spec)
os.Exit(2)
}
s, err := loadSheetSource(name, path)
if err != nil {
fmt.Fprintf(os.Stderr, "tmbankprobe: %v\n", err)
os.Exit(1)
}
byName[name] = s
order = append(order, name)
}
for _, spec := range stops {
name, path, ok := strings.Cut(spec, "=")
if !ok {
fmt.Fprintf(os.Stderr, "tmbankprobe: -stop wants NAME=PATH, got %q\n", spec)
os.Exit(2)
}
s, err := loadStop(name, path)
if err != nil {
fmt.Fprintf(os.Stderr, "tmbankprobe: %v\n", err)
os.Exit(1)
}
byName[name] = s
order = append(order, name)
}
for _, name := range order {
byName[name].printControls()
}
for _, spec := range dbs {
name, path, ok := strings.Cut(spec, "=")
if !ok {
fmt.Fprintf(os.Stderr, "tmbankprobe: -db wants NAME=PATH, got %q\n", spec)
os.Exit(2)
}
if err := printDBControls(name, path); err != nil {
fmt.Fprintf(os.Stderr, "tmbankprobe: %v\n", err)
os.Exit(1)
}
}
printPreCallGrid(order, byName)
printPostCallGrid(order, byName)
for _, spec := range passes {
name, paths, ok := strings.Cut(spec, "=")
first, second, ok2 := strings.Cut(paths, ",")
if !ok || !ok2 {
fmt.Fprintf(os.Stderr, "tmbankprobe: -passes wants NAME=FIRST,SECOND, got %q\n", spec)
os.Exit(2)
}
if err := printPassComparison(name, first, second); err != nil {
fmt.Fprintf(os.Stderr, "tmbankprobe: %v\n", err)
os.Exit(1)
}
}
if *compare != "" {
a, b, ok := strings.Cut(*compare, ",")
if !ok || byName[a] == nil || byName[b] == nil {
fmt.Fprintf(os.Stderr, "tmbankprobe: -compare wants two loaded -stop names, got %q\n", *compare)
os.Exit(2)
}
printComparison(byName[a], byName[b])
}
}
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
// loadSheetSource builds a source from the stop TABLE through the engine's own reader. It is the richer
// artifact — the table carries the source contexts and the «not asked» mark, which the JSON sidecar does
// not — and it doubles as the SECOND INSTRUMENT on a purchase whose sidecar is also present: two files
// written by two renderers from the same rows, compared with -compare.
func loadSheetSource(name, path string) (*source, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
rows, err := pipeline.ParseBankStopTable(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
s := &source{name: name, path: path, asOf: "(stop table)", runID: "(not carried by the table)"}
for _, r := range rows {
if r.Conventions > 0 {
s.withConv++
}
if r.Conf >= 0 {
s.withConf++
}
if r.Invented {
s.withInv++
}
if len(r.Contradicts) > 0 {
s.withCtr++
}
if len(r.BankHolds) > 0 {
s.withHold++
}
s.labels += len(r.Variants)
cand := terminology.Candidate{Key: r.Src, Src: r.Src, Type: r.Type, Freq: r.Freq, KWIC: r.Contexts}
for _, v := range r.Variants {
cand.Variants = append(cand.Variants, terminology.Variant{Dst: v.Dst, Chunks: v.Chunks, Forms: 1, Via: v.Via})
}
s.rows = append(s.rows, r)
s.cands = append(s.cands, cand)
}
return s, nil
}
func loadStop(name, path string) (*source, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc bankJSON
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
s := &source{name: name, path: path, asOf: doc.AsOf, runID: doc.RunID, consol: doc.Consolidation, blindToSettled: true}
for _, rec := range doc.Proposed {
row := pipeline.BankStopRow{}
getStr := func(k string) string {
if v, ok := rec[k]; ok {
var out string
if json.Unmarshal(v, &out) == nil {
return out
}
}
return ""
}
getInt := func(k string) (int, bool) {
if v, ok := rec[k]; ok {
var out int
if json.Unmarshal(v, &out) == nil {
return out, true
}
}
return 0, false
}
getStrs := func(k string) ([]string, bool) {
if v, ok := rec[k]; ok {
var out []string
if json.Unmarshal(v, &out) == nil {
return out, true
}
}
return nil, false
}
row.Src, row.Dst, row.Type, row.Origin = getStr("src"), getStr("dst"), getStr("kind"), getStr("channel")
row.Freq, _ = getInt("freq")
row.Spread, _ = getInt("spread")
if conv, ok := getInt("conventions"); ok {
row.Conventions = conv
s.withConv++
}
// The export always carries the field and writes -1 for «the reply stated none», so the control
// below counts rows that carry a CONFIDENCE — not rows that carry the field. The sheet loader counts
// the same thing, and two controls with one name and two meanings is how a comparison between two
// sources stops meaning anything.
row.Conf = -1
if conf, ok := getInt("conf"); ok {
row.Conf = conf
}
if row.Conf >= 0 {
s.withConf++
}
if inv, ok := rec["invented"]; ok {
var b bool
if json.Unmarshal(inv, &b) == nil && b {
row.Invented = true
s.withInv++
}
}
if ctr, ok := getStrs("contradicts"); ok && len(ctr) > 0 {
row.Contradicts = ctr
s.withCtr++
}
if h, ok := getStrs("bank_holds"); ok && len(h) > 0 {
row.BankHolds = h
s.withHold++
}
labels, _ := getStrs("variants")
cand := terminology.Candidate{Key: row.Src, Src: row.Src, Type: row.Type, Freq: row.Freq}
for _, l := range labels {
v, ok := pipeline.ParseBankStopVariantLabel(l)
if !ok {
s.badLabel = append(s.badLabel, l)
continue
}
s.labels++
row.Variants = append(row.Variants, v)
cand.Variants = append(cand.Variants, terminology.Variant{Dst: v.Dst, Chunks: v.Chunks, Forms: 1, Via: v.Via})
}
// ⚠ THE SCHEMA DRIFT, handled where it happens rather than in a comment elsewhere: the older export
// (cold run A) carries NO `conventions` field at all. Recomputing it from the variants is exactly
// what the newer export documents the field to be — «the length of Variants» — and a probe that
// counted the absent field as zero would report «nothing was contested» about a file that never
// held the answer.
if _, ok := getInt("conventions"); !ok {
row.Conventions = len(row.Variants)
}
s.rows = append(s.rows, row)
s.cands = append(s.cands, cand)
}
return s, nil
}
func (s *source) printControls() {
fmt.Printf("\n== %s %s\n", s.name, s.path)
fmt.Printf(" as_of=%s run_id=%s\n", s.asOf, s.runID)
fmt.Printf(" rows read: %d | variant labels parsed: %d | labels this binary could NOT read: %d %v\n",
len(s.rows), s.labels, len(s.badLabel), first(s.badLabel, 3))
fmt.Printf(" of %d rows: conventions carried=%d · a stated confidence=%d · invented=%d · contradicts=%d · bank_holds=%d\n",
len(s.rows), s.withConv, s.withConf, s.withInv, s.withCtr, s.withHold)
if s.withConv == 0 && len(s.rows) > 0 {
fmt.Printf(" ⚠ this export predates the `conventions` field; it is recomputed as len(variants), which is what the field is defined to be\n")
}
if s.withConv > 0 {
bad := 0
for _, r := range s.rows {
if r.Conventions != len(r.Variants) {
bad++
}
}
fmt.Printf(" control: records where conventions != len(variants): %d (the export defines them equal)\n", bad)
}
if s.consol != nil {
fmt.Printf(" consolidation block: %s\n", compactJSON(s.consol))
}
pre, ans, settled, unresolved := s.denominators()
fmt.Printf(" DENOMINATORS — pre-call (candidates at the role's input): %d | post-call (rows the role ANSWERED): %d\n", pre, ans)
fmt.Printf(" components: settled by the bank (never asked) %d · unresolved (no rendering came back) %d\n", settled, unresolved)
if s.blindToSettled {
fmt.Printf(" ⚠ THIS ARTIFACT CANNOT SAY WHICH ROWS THE BANK SETTLED — the JSON sidecar carries no «not asked»\n")
fmt.Printf(" field, so a settled row is counted above as «unresolved» and stays in the pre-call denominator.\n")
fmt.Printf(" On a first purchase that settles nothing the two readings agree; on any later one they do not.\n")
fmt.Printf(" Use the stop TABLE (-sheet) for those: it carries the mark.\n")
}
}
// denominators returns the two the measurement is ordered to use, plus the components that make them
// differ — printed, because on the first purchase of each run they happen to be EQUAL and a reader has to
// see why.
//
// ⛔ A ROW THE BANK SETTLED IS IN NEITHER, and it is the one correction this function needed: the settled
// filter runs BEFORE the first paid call, so such a row is not «a candidate at the role's input» any more
// than it is «a row the role answered». Counting it on the pre-call side alone would make the two
// denominators differ by a row that reached neither, and every share on that side would be quietly diluted.
func (s *source) denominators() (preCall, answered, settled, unresolved int) {
for _, r := range s.rows {
switch {
case r.SettledByBank:
settled++
case strings.TrimSpace(r.Dst) == "":
unresolved++
}
}
preCall = len(s.rows) - settled
return preCall, preCall - unresolved, settled, unresolved
}
func printPreCallGrid(order []string, by map[string]*source) {
fmt.Printf("\n== PRE-CALL «contested»: the drafts disagreed, measured before a single call\n")
fmt.Printf(" denominator: candidates at the role's input\n")
fmt.Printf(" %-22s %s\n", "threshold", strings.Join(order, " "))
for _, minConv := range []int{2, 3, 4} {
for _, share := range []float64{0, 0.75, 0.5} {
label := fmt.Sprintf("conv>=%d", minConv)
if share > 0 {
label += fmt.Sprintf(" lead<=%.2f", share)
} else {
label += " lead: off"
}
var cells []string
for _, name := range order {
s := by[name]
n := 0
for i, c := range s.cands {
if s.rows[i].SettledByBank {
continue // never reached the role: not in the denominator, not in the count
}
if c.Contest(terminology.ContestOpts{MinConventions: minConv, MaxLeaderShare: share}).Contested {
n++
}
}
pre, _, _, _ := s.denominators()
cells = append(cells, fmt.Sprintf("%3d/%3d = %5.1f%%", n, pre, pct(n, pre)))
}
fmt.Printf(" %-22s %s\n", label, strings.Join(cells, " "))
}
}
fmt.Printf(" unsupported (no draft rendered it at all — not a contest, and not agreement):\n")
for _, name := range order {
s := by[name]
n := 0
for i, c := range s.cands {
if s.rows[i].SettledByBank {
continue
}
if c.Contest(terminology.ContestOpts{MinConventions: 2}).Unsupported {
n++
}
}
pre, _, _, _ := s.denominators()
fmt.Printf(" %-10s %d of %d\n", name, n, pre)
}
}
func printPostCallGrid(order []string, by map[string]*source) {
fmt.Printf("\n== POST-CALL «contested»: the answer is worth a second opinion\n")
fmt.Printf(" denominator: rows the role ANSWERED\n")
type arm struct {
label string
opts pipeline.StopContestOpts
}
arms := []arm{
{"unresolved only", pipeline.StopContestOpts{CountUnresolved: true}},
{"conflicts only", pipeline.StopContestOpts{CountConflicts: true}},
{"invented only", pipeline.StopContestOpts{CountInvented: true}},
{"conf<=50", pipeline.StopContestOpts{UseConf: true, MaxConf: 50}},
{"conf<=60", pipeline.StopContestOpts{UseConf: true, MaxConf: 60}},
{"conf<=70", pipeline.StopContestOpts{UseConf: true, MaxConf: 70}},
{"conf<=80", pipeline.StopContestOpts{UseConf: true, MaxConf: 80}},
{"conf<=70 + invented", pipeline.StopContestOpts{UseConf: true, MaxConf: 70, CountInvented: true}},
{"conf<=70 + invented + conflicts + unresolved", pipeline.StopContestOpts{UseConf: true, MaxConf: 70, CountInvented: true, CountConflicts: true, CountUnresolved: true}},
{"conf<=70 + conv>=2", pipeline.StopContestOpts{UseConf: true, MaxConf: 70, CountPreCall: true, MinConventions: 2}},
}
fmt.Printf(" %-46s %s\n", "signals", strings.Join(order, " "))
for _, a := range arms {
var cells []string
for _, name := range order {
s := by[name]
_, ans, _, _ := s.denominators()
n := 0
for _, r := range s.rows {
if r.Contest(a.opts).Contested {
n++
}
}
cells = append(cells, fmt.Sprintf("%3d/%3d = %5.1f%%", n, ans, pct(n, ans)))
}
fmt.Printf(" %-46s %s\n", a.label, strings.Join(cells, " "))
}
fmt.Printf(" (the selected count IS the answer to «how many rows would go to a second call»)\n")
}
// printComparison is the §4.4 instrument: two purchases of the SAME material, joined by surface, asking
// what actually differed in the role's INPUT where its decision differed.
func printComparison(a, b *source) {
fmt.Printf("\n== TWO PURCHASES COMPARED: %s ↔ %s\n", a.name, b.name)
ai, bi := index(a.rows), index(b.rows)
var shared []string
for src := range ai {
if _, ok := bi[src]; ok {
shared = append(shared, src)
}
}
sort.Strings(shared)
fmt.Printf(" surfaces: %s=%d %s=%d shared=%d | only in %s=%d only in %s=%d\n",
a.name, len(ai), b.name, len(bi), len(shared), a.name, len(ai)-len(shared), b.name, len(bi)-len(shared))
var dstChanged, sameEvidence, evidenceChanged int
var examples []string
for _, src := range shared {
x, y := ai[src], bi[src]
if x.Dst == y.Dst {
continue
}
dstChanged++
same, diffs := sameInput(x, y)
if same {
sameEvidence++
if len(examples) < 8 {
examples = append(examples, fmt.Sprintf("%s: %q → %q (input identical in every field the artifact carries)", src, x.Dst, y.Dst))
}
continue
}
evidenceChanged++
if len(examples) < 8 {
examples = append(examples, fmt.Sprintf("%s: %q → %q (%s)", src, x.Dst, y.Dst, strings.Join(diffs, ", ")))
}
}
fmt.Printf(" rows whose rendering CHANGED between the purchases: %d of %d shared\n", dstChanged, len(shared))
fmt.Printf(" of those, the artifact's input fields were IDENTICAL: %d\n", sameEvidence)
fmt.Printf(" of those, some input field differed: %d\n", evidenceChanged)
for _, e := range examples {
fmt.Printf(" · %s\n", e)
}
fmt.Printf(" ⚠ LIMIT OF THIS INSTRUMENT, and it is not small: «input» here is what the STOP PROJECTION\n")
fmt.Printf(" carries — freq, type, the draft variants and their chunk counts. The request the role was\n")
fmt.Printf(" actually sent also carried the KWIC windows and the canon anchor, and neither is in any\n")
fmt.Printf(" artifact. «Identical here» therefore means «identical in the evidence that was kept», which\n")
fmt.Printf(" is a weaker statement than «the role saw the same request».\n")
}
func index(rows []pipeline.BankStopRow) map[string]pipeline.BankStopRow {
out := make(map[string]pipeline.BankStopRow, len(rows))
for _, r := range rows {
out[r.Src] = r
}
return out
}
// sameInput compares everything about a row that was an INPUT to the role — never the answer.
func sameInput(x, y pipeline.BankStopRow) (bool, []string) {
var diffs []string
if x.Freq != y.Freq {
diffs = append(diffs, fmt.Sprintf("freq %d→%d", x.Freq, y.Freq))
}
if x.Type != y.Type {
diffs = append(diffs, fmt.Sprintf("type %s→%s", x.Type, y.Type))
}
if x.Origin != y.Origin {
diffs = append(diffs, fmt.Sprintf("channel %s→%s", x.Origin, y.Origin))
}
if x.Conventions != y.Conventions {
diffs = append(diffs, fmt.Sprintf("conventions %d→%d", x.Conventions, y.Conventions))
}
if vx, vy := variantKey(x), variantKey(y); vx != vy {
diffs = append(diffs, fmt.Sprintf("draft variants %s → %s", vx, vy))
}
return len(diffs) == 0, diffs
}
func variantKey(r pipeline.BankStopRow) string {
parts := make([]string, 0, len(r.Variants))
for _, v := range r.Variants {
parts = append(parts, fmt.Sprintf("%s×%d", v.Dst, v.Chunks))
}
sort.Strings(parts)
return "[" + strings.Join(parts, " | ") + "]"
}
// printDBControls reads the counts that say what state the database was in — the ones that make a replay
// honest or misleading. Read-only AND immutable, so the evidence is not even touched by an -shm.
func printDBControls(name, path string) error {
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
if err != nil {
return err
}
defer db.Close()
fmt.Printf("\n== %s %s (read-only, immutable)\n", name, path)
var schema int
if err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_version`).Scan(&schema); err != nil {
fmt.Printf(" schema_version: unreadable (%v)\n", err)
} else {
// ⛔ PRINTED BESIDE THIS BINARY'S OWN HEAD, because the difference is not cosmetic: store.Open() on a
// database BELOW the head MIGRATES it (migrate.go, the beforeApply branch), and these files are bought
// evidence — a replay through the engine's writer would rewrite the schema of the very run it came to
// measure. store.OpenReadOnly refuses the mismatch outright, which is why this probe reads the file
// itself, immutably, instead of asking the engine for a reader it cannot give.
fmt.Printf(" schema_version: %d (this binary's head: %d — %s)\n", schema, store.SchemaHead(),
map[bool]string{true: "equal, the engine could read it", false: "DIFFERENT: the engine's read-only door refuses it, and a WRITE open would migrate the evidence"}[schema == store.SchemaHead()])
}
var tables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table'`).Scan(&tables); err == nil {
fmt.Printf(" tables: %d\n", tables)
}
for _, t := range []string{"glossary", "bank_stop_presented", "request_log", "checkpoints"} {
var n int
if err := db.QueryRow("SELECT COUNT(*) FROM " + t).Scan(&n); err != nil {
fmt.Printf(" %-20s NO SUCH TABLE IN THIS DATABASE (%v)\n", t, err)
continue
}
fmt.Printf(" %-20s %d\n", t, n)
}
rows, err := db.Query(`SELECT role, COUNT(*), SUM(prompt_tokens=0 AND completion_tokens=0) FROM request_log GROUP BY role ORDER BY role`)
if err != nil {
return nil
}
defer rows.Close()
for rows.Next() {
var role string
var n, replayed int
if err := rows.Scan(&role, &n, &replayed); err != nil {
return err
}
fmt.Printf(" request_log role=%-14s calls=%d of which recorded with zero tokens (replayed/unbilled)=%d\n", role, n, replayed)
}
return rows.Err()
}
func pct(n, d int) float64 {
if d == 0 {
return 0
}
return 100 * float64(n) / float64(d)
}
func first(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}
func compactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}