textmachine/backend/internal/pipeline/live_reprobe_test.go

204 lines
11 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.

//go:build live
package pipeline
// live_reprobe_test.go: the PAID acceptance probe of the §2 type-classifier (D39.69 §2 / row 74).
// Build-tagged `live` like live_conformance_test.go, so `go test ./...` never compiles it.
//
// TM_LIVE=1 TM_CLASSIFY6_CONFIG=~/books/gu-zhenren/coldrun-b/reprobe/classify6/book.yaml \
// go test -tags live -run TestLiveClassifierHarmSet -v ./internal/pipeline/
//
// It drives the PRODUCTION classifier path end to end — the pair's own resolved prompt template, the
// production `classifierMessages` render, the production `bankCallBudget` sizing and, crucially, the
// production `runBankAttempt` money sequence (reserve → call → settle+checkpoint). It calls no provider
// itself: the single-egress guard (TestProviderEgressSeamIsSingle) is a real invariant, and a probe that
// bypassed the seam would also bypass the ledger — its cost would then be invisible to `tmctl report`,
// which is the opposite of what a paid probe owes. Everything it reports is therefore re-readable from
// the probe project's own store.
//
// The candidates are primed with the WRONG draft type on purpose: the harm this phase exists to fix is a
// realia mis-typed `name` and then transliterated into a meaningless syllable (元石 → «юаньши»), so a probe
// that primed the RIGHT type would also be passed by a model that merely echoes its input.
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// harmSet is the six terms of the ratified acceptance threshold (BANK_QUALITY_DESIGN §2): every one of
// them is a THING or a facility that must be translated by sense, and every one of them was mis-typed by
// the draft heuristic. Each carries a real ctx line from the stand book, because the classifier prompt
// tells the model to decide by context rather than by the `type:` guess.
//
// ⚠ HONEST LIMIT of the 6/6 number: 元石 and 灵泉 appear VERBATIM as `term` examples inside
// prompts/zh-ru/classifier.md, so two of the six are recall, not judgement. The discriminating subset is
// 元海 / 蛊室 / 池塘 / 酒肆 — read the result as 4 decided + 2 recalled.
var harmSet = []terminology.Candidate{
{Key: "元石", Src: "元石", Type: "name", Freq: 12, Origin: terminology.OriginBanknote,
KWIC: []string{"谁能拔得头筹,就会有二十块元石的丰厚奖励"}},
{Key: "元海", Src: "元海", Type: "name", Freq: 9, Origin: terminology.OriginBanknote,
KWIC: []string{"察看自身的空窍元海"}},
{Key: "蛊室", Src: "蛊室", Type: "place", Freq: 5, Origin: terminology.OriginBanknote,
KWIC: []string{"你们就去学堂里的蛊室,挑选蛊虫"}},
{Key: "池塘", Src: "池塘", Type: "place", Freq: 4, Origin: terminology.OriginBanknote,
KWIC: []string{"村外的池塘边上,长满了青草"}},
{Key: "灵泉", Src: "灵泉", Type: "name", Freq: 3, Origin: terminology.OriginBanknote,
KWIC: []string{"灵泉之水,可以洗练身躯"}},
{Key: "酒肆", Src: "酒肆", Type: "place", Freq: 3, Origin: terminology.OriginBanknote,
KWIC: []string{"镇上的酒肆里,说书人正讲着蛊师的故事"}},
}
func TestLiveClassifierHarmSet(t *testing.T) {
if os.Getenv("TM_LIVE") != "1" {
t.Skip("paid probe: set TM_LIVE=1")
}
cfg := os.Getenv("TM_CLASSIFY6_CONFIG")
if cfg == "" {
t.Skip("set TM_CLASSIFY6_CONFIG to a book.yaml whose gates.terminology.classify_types is on")
}
r, err := NewRunner(cfg, obs.NewLogger())
if err != nil {
t.Fatalf("build runner: %v", err)
}
defer r.Close()
if r.classifierTemplate == nil {
t.Fatalf("classify_types is off in %s — the probe would measure nothing", cfg)
}
// The eager client map is filled by the precompute pass inside TranslateBook, which this probe does
// not run; build it the same way rather than teaching clientFor about a probe.
if err := r.buildClients(); err != nil {
t.Fatalf("build clients: %v", err)
}
msgs, err := r.classifierMessages(harmSet)
if err != nil {
t.Fatalf("render production classifier messages: %v", err)
}
// The stage comes from the PRODUCTION derivation, not a literal: a probe that hand-built its stage would
// measure a shape the engine never sends — and once the effort knob landed, its green 6/6 would say
// nothing about the level the bank roles actually ride (D39.87 §0.2, the fourth synthetic-stage site).
st := r.bankStage(roleClassifier)
_, maxTokens := r.bankCallBudget(st.Model, msgs)
// N SAMPLES, not one. The verdict this probe produces is a judgement call by a stochastic model, and a
// single call cannot tell a level difference from ordinary variance — reading n=1 as a measurement is
// exactly the manufactured-convergence failure this repo has paid for before. Each run gets its OWN
// snapshot axis so it is a FRESH call; a repeat of the same index still replays that run for $0.
// Default 5, not 1. The comment above condemns n=1 and the first version of this rig then DEFAULTED to
// it — so the next person to run it would have got exactly the reading it warns against. A run costs
// ~$0.0002-0.0009, so five samples are free at the scale of any probe that would bother running this.
runs := classify6SampleN
if v := os.Getenv("TM_CLASSIFY6_N"); v != "" {
// A malformed override is REFUSED, not ignored: silently falling back to the ratified 5 would buy
// five paid calls for an operator who believes they asked for something else, one line above the
// guard that exists to stop exactly that.
n, cerr := strconv.Atoi(v)
if cerr != nil || n <= 0 {
t.Fatalf("TM_CLASSIFY6_N=%q is not a positive integer — refusing rather than quietly running the default N=%d and billing for it", v, classify6SampleN)
}
runs = n
}
// The size guard fires BEFORE the first paid call, not after: an override the threshold does not
// cover must cost nothing. The refusal is LOUD rather than a skip — a watch run that quietly did not
// measure is indistinguishable from one that passed.
if ok, why := classify6SampleRatified(runs); !ok {
t.Fatalf("TM_CLASSIFY6_N=%d: %s. Nothing was called and nothing was billed. Run the ratified N=%d, or ask the owner for a threshold at your N",
runs, why, classify6SampleN)
}
effort := st.Reasoning
if effort == "" {
effort = "unset"
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
type sample struct {
Run int `json:"run"`
Finish string `json:"finish"`
Verdict string `json:"verdict"`
Usage llm.Usage `json:"usage"`
CostUSD float64 `json:"cost_usd"`
LatencyMS int64 `json:"latency_ms"`
Reply string `json:"reply"`
Parsed map[string]string `json:"parsed"`
BadLines int `json:"bad_lines"`
Hits int `json:"hits_term"`
DiscriminatingHits int `json:"discriminating_hits"`
}
samples := make([]sample, 0, runs)
full, totalCost := 0, 0.0
for i := 0; i < runs; i++ {
snapID := fmt.Sprintf("reprobe-classifier-6of6-%s-%d", effort, i)
// jobs.snapshot_id is a FOREIGN KEY into snapshots — the production path upserts the wave snapshot
// before opening jobs, so the probe registers its own axis the same way instead of borrowing one.
if err := r.Store.UpsertSnapshot(snapID, "", `{"probe":"classifier-harm-set-6of6","effort":"`+effort+`"}`); err != nil {
t.Fatalf("register the probe snapshot axis: %v", err)
}
job, jerr := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
if jerr != nil {
t.Fatalf("ensure job: %v", jerr)
}
start := time.Now()
att, aerr := r.runBankAttempt(ctx, st, snapID, chunk.Chunk{Chapter: 0, ChunkIdx: 0}, job, msgs)
if aerr != nil {
t.Fatalf("live classifier call %d failed: %v", i, aerr)
}
got, stats := terminology.ParseTypes(att.text, candKeys(harmSet), text.NormalizeSourceKey)
hits, decided := 0, 0
for _, c := range harmSet {
if got[c.Key] != "term" {
continue
}
hits++
if c.Key != "元石" && c.Key != "灵泉" { // the two the prompt names verbatim
decided++
}
}
if hits == len(harmSet) {
full++
}
totalCost += att.runCost
samples = append(samples, sample{i, att.finish, string(att.cls.Reason), att.usage, att.runCost,
time.Since(start).Milliseconds(), att.text, got, stats.Bad, hits, decided})
t.Logf("CLASSIFIER 6/6 [effort=%s run=%d/%d]: model=%s max_tokens=%d finish=%q verdict=%q latency=%dms usage=%+v cost_usd=%.6f bad_lines=%d hits=%d/6 (discriminating %d/4) got=%v",
effort, i+1, runs, att.modelActual, maxTokens, att.finish, att.cls.Reason,
samples[i].LatencyMS, att.usage, att.runCost, stats.Bad, hits, decided, got)
}
t.Logf("CLASSIFIER 6/6 SUMMARY [effort=%s]: %d/%d runs reached the 6/6 threshold · total_cost_usd=%.6f", effort, full, runs, totalCost)
// Persist the raw wire evidence next to the probe project — "persist, not scratch": every number in
// the report must be re-readable from the artifact that produced it. The file is per-EFFORT, so a
// comparison between levels does not overwrite its own other half.
blob, _ := json.MarshalIndent(map[string]any{
"model": st.Model, "effort": effort, "max_tokens": maxTokens,
"runs": runs, "runs_at_threshold": full, "total_cost_usd": totalCost, "samples": samples,
}, "", " ")
if werr := os.WriteFile(filepath.Join(filepath.Dir(cfg), "classifier-6of6-"+effort+".json"), blob, 0o644); werr != nil {
t.Errorf("persist probe evidence: %v", werr)
}
// The threshold is the SAMPLED form ratified by D39.136 п.6б (row 116 closed by delegation): «≥4 of 5
// runs at N=5 reach 6/6». It replaced D39.69 §2's "6/6", which had been defined against a SINGLE call
// and which this rig used to apply per run — the open question the rig declared here is CLOSED, and
// the closure was the owner's, not a softening by whoever ran the probe. What the sampled form buys:
// the 02.08 measurement on live 0731 weights (effort `low` 4 of 5, `high` 5 of 5, a difference n=5
// cannot separate, `high` 2.30x per call) is a PASS for the level the engine actually recommends for
// the bank roles, instead of a red gate that says nothing about the model.
if ok, why := classify6Accepted(runs, full); !ok {
t.Fatalf("classifier acceptance FAILED: %s — 6/6 was reached on %d of %d runs at effort %q "+
"(ratified threshold D39.136 п.6б: ≥%d of %d). Raw evidence: classifier-6of6-%s.json",
why, full, runs, effort, classify6RunsAtFullOK, classify6SampleN, effort)
}
}