700 lines
33 KiB
Go
700 lines
33 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/ledger"
|
||
|
||
"textmachine/backend/internal/chunk"
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// priceprojection_test.go: the acceptance battery for backlog row 278 — the engine says what a book will
|
||
// cost and what ceiling it needs BEFORE anything is bought, and it says whether it believes its own
|
||
// chapter cut. $0 throughout: `manifest` makes no provider call at all.
|
||
|
||
// TestTheManifestPricesABookNobodyHasBought is the whole point of the projection: the question is asked
|
||
// in the state where the engine's OTHER price figure (projectBookUSD, extrapolated from units already
|
||
// paid for) can only answer zero.
|
||
func TestTheManifestPricesABookNobodyHasBought(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(3, 1400)})
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
m, err := r.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rec.count() != 0 {
|
||
t.Fatalf("the projection is a $0 verb and reached a provider %d time(s)", rec.count())
|
||
}
|
||
if m.Price == nil {
|
||
t.Fatal("a book with prices and stages must be priced")
|
||
}
|
||
if m.Price.ExpectedUSD <= 0 {
|
||
t.Fatalf("the whole book prices at %v", m.Price.ExpectedUSD)
|
||
}
|
||
// ⛔ THE FIGURE THE WALL IS MADE OF. A ceiling under this admits nothing at all, whatever else is
|
||
// true, and publishing it is what lets a platform stop offering orders that cannot move.
|
||
if m.Price.StepMaxUSD <= 0 {
|
||
t.Fatal("step_max_usd is the largest indivisible reservation and cannot be zero for a priced book")
|
||
}
|
||
perUnit := m.Chapters[0].Units[0].Price
|
||
if perUnit == nil {
|
||
t.Fatal("every unit must carry its own size and price")
|
||
}
|
||
// ⛔ THE PROJECTION IS CHECKED AGAINST THE EXECUTOR, not against itself. step_max_usd is the LARGEST
|
||
// reservation any call of this book can ask for, so it must be at least the reservation the executor
|
||
// computes for the book's very first call — a number derived here from the executor's own arithmetic
|
||
// (baseMaxTokensFor + ledger.EstimateUSD), which is the same pair the projection uses.
|
||
//
|
||
// ⚠ It is deliberately NOT compared with a unit's expected BILL, which was this test's first
|
||
// assertion and was simply wrong: a unit's bill sums EVERY stage of the pipeline while step_max is ONE
|
||
// call, so a two-stage unit legitimately costs more than the largest single step. The comparison that
|
||
// means something is against a call.
|
||
firstCall := callCeilingUSD(t, r, 1.0)
|
||
if m.Price.StepMaxUSD < firstCall {
|
||
t.Errorf("step_max_usd (%v) is under the reservation the executor makes for the first call (%v) — a ceiling set to it would admit nothing",
|
||
m.Price.StepMaxUSD, firstCall)
|
||
}
|
||
// Sizes: RUNES including spaces, split by the engine's one token-sizing taxonomy. A space is counted
|
||
// in SourceChars and in NEITHER class, which is the taxonomy — so the two classes bound the total from
|
||
// below rather than summing to it.
|
||
if perUnit.SourceChars <= 0 {
|
||
t.Errorf("a unit reports no source characters: %+v", perUnit)
|
||
}
|
||
if perUnit.SourceCharsDense+perUnit.SourceCharsSparse > perUnit.SourceChars {
|
||
t.Errorf("the dense/sparse split exceeds the character count it splits: %+v", perUnit)
|
||
}
|
||
if perUnit.SourceCharsDense == 0 {
|
||
t.Errorf("a Han unit reports no dense characters: %+v", perUnit)
|
||
}
|
||
// The chapter roll-up must be the sum of its units, and the book the sum of its chapters plus the
|
||
// once-per-book charge. A roll-up that drifts from its parts is worse than no roll-up.
|
||
var unitSum float64
|
||
var charSum int
|
||
for _, u := range m.Chapters[0].Units {
|
||
unitSum += u.Price.ExpectedUSD
|
||
charSum += u.Price.SourceChars
|
||
}
|
||
if diff := m.Chapters[0].Price.ExpectedUSD - unitSum; diff > 1e-12 || diff < -1e-12 {
|
||
t.Errorf("the chapter roll-up (%v) is not the sum of its units (%v)", m.Chapters[0].Price.ExpectedUSD, unitSum)
|
||
}
|
||
if m.Chapters[0].Price.SourceChars != charSum {
|
||
t.Errorf("the chapter's character count (%d) is not the sum of its units' (%d)", m.Chapters[0].Price.SourceChars, charSum)
|
||
}
|
||
var bookSum float64
|
||
for _, c := range m.Chapters {
|
||
bookSum += c.Price.ExpectedUSD
|
||
}
|
||
if diff := m.Price.ExpectedUSD - (bookSum + m.Price.BookOnceUSD); diff > 1e-12 || diff < -1e-12 {
|
||
t.Errorf("the book price (%v) is not its chapters (%v) plus the once-per-book charge (%v)",
|
||
m.Price.ExpectedUSD, bookSum, m.Price.BookOnceUSD)
|
||
}
|
||
}
|
||
|
||
// TestTheProjectionIsNotAPerChapterConstant is the row's own prohibition, asserted rather than promised:
|
||
// «считается из СВОИХ цен и фертильности, БЕЗ константы». A longer chapter must cost more, in proportion
|
||
// to what is actually in it.
|
||
func TestTheProjectionIsNotAPerChapterConstant(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
// Two chapters of very different length in the same book.
|
||
src := strings.Repeat("蛊", 400) + "\f" + strings.Repeat("蛊", 3200)
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
m, err := r.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(m.Chapters) != 2 {
|
||
t.Fatalf("fixture drifted: want 2 chapters, got %d", len(m.Chapters))
|
||
}
|
||
short, long := m.Chapters[0].Price, m.Chapters[1].Price
|
||
if short == nil || long == nil {
|
||
t.Fatal("both chapters must be priced")
|
||
}
|
||
if long.ExpectedUSD <= short.ExpectedUSD {
|
||
t.Fatalf("an eight-times-longer chapter is not dearer: short=%v long=%v — the projection is a constant", short.ExpectedUSD, long.ExpectedUSD)
|
||
}
|
||
if long.SourceChars <= short.SourceChars {
|
||
t.Fatalf("the character counts do not track the text: short=%d long=%d", short.SourceChars, long.SourceChars)
|
||
}
|
||
}
|
||
|
||
// TestTheCutSaysWhetherItWasReadOrGuessed pins `structure` on the three values a TXT source can produce,
|
||
// because the field exists to stop an order phrased in chapters being offered against a guess.
|
||
//
|
||
// The fourth value, `declared`, is NOT reachable from a txt and is not pinned here: it needs a format that
|
||
// states its own chapter structure, so it lives with the EPUB tables of contents
|
||
// (TestEPUBNavDrawsChaptersNotTheSpine, TestEPUB2NCXDrawsChaptersWhenThereIsNoNav).
|
||
func TestTheCutSaysWhetherItWasReadOrGuessed(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
source string
|
||
want string
|
||
}{
|
||
// A form feed is the ASCII PAGE break — a separator the file carries, not a statement that a chapter
|
||
// begins here. The engine cut on it because nothing better was found, and says so.
|
||
{"a form feed is a separator, not a chapter", "ГЛАВАОДИН\fГЛАВАДВА", chunk.StructureDelimited},
|
||
// CJK chapter headers are matched in the prose: the engine INFERRED these boundaries.
|
||
{"a matched header line is a guess", "第一章\n" + strings.Repeat("蛊", 200) + "\n第二章\n" + strings.Repeat("蛊", 200), chunk.StructureDetected},
|
||
// One span of text: any finer unit of ordering would be invented here.
|
||
{"one chapter is no structure at all", strings.Repeat("蛊", 300), chunk.StructureNone},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: tc.source})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
m, err := r.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m.Structure != tc.want {
|
||
t.Errorf("structure = %q, want %q (chapters=%d)", m.Structure, tc.want, m.ChaptersTotal)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTheProjectionReachesBothSurfacesAndTheyAgree is the reader/writer axis of this pack, nailed from
|
||
// both sides by one act.
|
||
//
|
||
// ⚠ THE CLASS IT CLOSES WAS FOUND THREE TIMES IN THE PACK BEFORE THIS ONE: a fix applied to the surface
|
||
// that READS a value while the surface that WRITES it kept its own copy, with every test still green.
|
||
// Here the manifest writes the price and `status` reads it, so the assertion is that they are the same
|
||
// bytes rather than two derivations that happen to agree today.
|
||
func TestTheProjectionReachesBothSurfacesAndTheyAgree(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(2, 1400)})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
m, err := r.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rep, err := r.Status(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep.Price == nil {
|
||
t.Fatal("`status` publishes no price: the surface a buyer's platform reads before deciding cannot answer the question")
|
||
}
|
||
if rep.Price.ExpectedUSD != m.Price.ExpectedUSD || rep.Price.StepMaxUSD != m.Price.StepMaxUSD || rep.Price.BookOnceUSD != m.Price.BookOnceUSD {
|
||
t.Errorf("status and manifest quote different prices for one book:\n status=%+v\n manifest=%+v", *rep.Price, *m.Price)
|
||
}
|
||
if rep.Structure != m.Structure {
|
||
t.Errorf("status says the cut is %q and the manifest says %q", rep.Structure, m.Structure)
|
||
}
|
||
// And the price must not be confused with the a-posteriori figure beside it: an unrun book has spent
|
||
// nothing, so that one is zero while this one is not.
|
||
if rep.ProjectedBookUSD != 0 {
|
||
t.Errorf("a book that has never run has no measured projection, got %v", rep.ProjectedBookUSD)
|
||
}
|
||
if rep.Price.ExpectedUSD == 0 {
|
||
t.Error("…and the a-priori projection must be the one that answers")
|
||
}
|
||
}
|
||
|
||
// TestTheManifestDocumentVersionDoesNotMoveForAnAdditiveField pins the seam rule the pack states and the
|
||
// document's own comment argues: a version bump makes every stored sidecar discard itself and re-chunk
|
||
// every book once, and it buys nothing for a field that cannot be half-read.
|
||
func TestTheManifestDocumentVersionDoesNotMoveForAnAdditiveField(t *testing.T) {
|
||
if manifestVersion != "tm-manifest-v2" {
|
||
t.Fatalf("the manifest document version moved to %q — adding derived fields is the ratified «engine-first» compatibility path and is not a shape break", manifestVersion)
|
||
}
|
||
}
|
||
|
||
// TestTheStoredManifestCarriesThePriceOnDisk reads the artifact as a CONSUMER does — bytes off the
|
||
// filesystem — rather than asking the engine's own struct what it holds.
|
||
func TestTheStoredManifestCarriesThePriceOnDisk(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(2, 1400)})
|
||
r := newRunner(t, bookPath)
|
||
if _, err := r.BuildAndPersistManifest(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
path := r.ManifestPath()
|
||
r.Close()
|
||
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var doc struct {
|
||
Structure string `json:"structure"`
|
||
Price *struct {
|
||
ExpectedUSD float64 `json:"expected_usd"`
|
||
BookOnceUSD float64 `json:"book_once_usd"`
|
||
StepMaxUSD float64 `json:"step_max_usd"`
|
||
SourceChars int `json:"source_chars"`
|
||
} `json:"price"`
|
||
Chapters []struct {
|
||
Units []struct {
|
||
Price *struct {
|
||
SourceChars int `json:"source_chars"`
|
||
SourceCharsDense int `json:"source_chars_dense"`
|
||
SourceCharsSparse int `json:"source_chars_sparse"`
|
||
ExpectedUSD float64 `json:"expected_usd"`
|
||
} `json:"price"`
|
||
} `json:"units"`
|
||
} `json:"chapters"`
|
||
}
|
||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
t.Fatalf("the persisted manifest is not readable: %v", err)
|
||
}
|
||
if doc.Structure == "" {
|
||
t.Error("the persisted document does not say where its chapter boundaries came from")
|
||
}
|
||
if doc.Price == nil || doc.Price.StepMaxUSD <= 0 || doc.Price.SourceChars <= 0 {
|
||
t.Fatalf("the persisted document carries no usable price: %+v", doc.Price)
|
||
}
|
||
if len(doc.Chapters) == 0 || len(doc.Chapters[0].Units) == 0 || doc.Chapters[0].Units[0].Price == nil {
|
||
t.Fatal("the persisted units carry no price")
|
||
}
|
||
u := doc.Chapters[0].Units[0].Price
|
||
if u.SourceChars <= 0 || u.SourceCharsDense <= 0 || u.ExpectedUSD <= 0 {
|
||
t.Errorf("a persisted unit's price is not usable: %+v", u)
|
||
}
|
||
}
|
||
|
||
// TestTheProjectionUsesTheExecutorsOwnSizing is the anti-drift pin. The projection and the executor share
|
||
// one definition of a stage's output budget (baseMaxTokensFor), and the point of sharing it is that a
|
||
// change to sizing cannot move one without the other.
|
||
func TestTheProjectionUsesTheExecutorsOwnSizing(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400), minMaxTokens: 4096})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
m, err := r.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
withFloor := m.Price.StepMaxUSD
|
||
r.Close()
|
||
|
||
// The SAME book with a much smaller floor must project a smaller largest step: the floor is part of
|
||
// the executor's sizing, so a projection that ignored it would return the same number twice.
|
||
bookPath2 := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400), minMaxTokens: 256})
|
||
r2 := newRunner(t, bookPath2)
|
||
defer r2.Close()
|
||
m2, err := r2.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m2.Price.StepMaxUSD >= withFloor {
|
||
t.Errorf("step_max_usd ignores the executor's own output-budget floor: floor 4096 → %v, floor 256 → %v",
|
||
withFloor, m2.Price.StepMaxUSD)
|
||
}
|
||
}
|
||
|
||
// TestARunRewritesTheProjectionToo: the price is not a `manifest`-command curiosity — a translate rebuilds
|
||
// the sidecar, and a run that dropped the price would leave `status` blind for exactly the books that
|
||
// have been paid for.
|
||
func TestARunRewritesTheProjectionToo(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(2, 1400)})
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m := r.loadManifest()
|
||
if m == nil {
|
||
t.Fatal("the run wrote no manifest")
|
||
}
|
||
if m.Price == nil || m.Price.StepMaxUSD <= 0 {
|
||
t.Fatalf("a run's own manifest carries no price: %+v", m.Price)
|
||
}
|
||
if m.Structure == "" {
|
||
t.Error("a run's own manifest does not say where its chapter boundaries came from")
|
||
}
|
||
}
|
||
|
||
// TestStepMaxCountsTheRetryThatDoesNotDegrade is the half of step_max_usd that a ceiling sized for
|
||
// attempt 0 gets wrong, and it is the expensive half.
|
||
//
|
||
// ⛔ maxTokensForAttempt DOUBLES the output budget on every regeneration, and a refused RETRY does not
|
||
// degrade — it halts the book. So a ceiling that admits a unit's first call and not its remedy leaves
|
||
// that unit unable to finish EVER: each resume replays the flagged attempt 0 for free and dies on
|
||
// attempt 1 again, while the run departs `paused` asking for money that would not help. Publishing a
|
||
// step_max that ignored retries would be publishing exactly that ceiling.
|
||
func TestStepMaxCountsTheRetryThatDoesNotDegrade(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
// The same book twice, differing ONLY in how many regenerations a stage is allowed.
|
||
noRetry := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400), regenerate: 0})
|
||
withRetry := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400), regenerate: 1})
|
||
|
||
r1 := newRunner(t, noRetry)
|
||
m1, err := r1.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
r2 := newRunner(t, withRetry)
|
||
defer r2.Close()
|
||
m2, err := r2.BuildAndPersistManifest()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m2.Price.StepMaxUSD <= m1.Price.StepMaxUSD {
|
||
t.Fatalf("step_max_usd ignores the regeneration budget: regenerate=0 → %v, regenerate=1 → %v. A ceiling set to the smaller number admits a unit's first call and refuses its remedy on every resume, forever",
|
||
m1.Price.StepMaxUSD, m2.Price.StepMaxUSD)
|
||
}
|
||
// And the EXPECTED bill must NOT move: a regeneration is a remedy, not a forecast, and folding it
|
||
// into the headline price would quote a bad run as the rule.
|
||
if m2.Price.ExpectedUSD != m1.Price.ExpectedUSD {
|
||
t.Errorf("the expected BILL moved with the retry budget (%v → %v): a conditional remedy is not part of what a book is expected to cost",
|
||
m1.Price.ExpectedUSD, m2.Price.ExpectedUSD)
|
||
}
|
||
}
|
||
|
||
// planFixture builds a Runner with hand-made templates and prices — the smallest thing that can answer
|
||
// «what does the projection put on the wire for ONE call», without a project directory or a server.
|
||
func planFixture(t *testing.T, tpl *PromptTemplate, fewShot *bool) *pricePlan {
|
||
t.Helper()
|
||
pricer, err := ledger.NewPricer(nil, ledger.ModelPrice{InputPerM: 1, OutputPerM: 1})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// ⚠ THROUGH THE SEAM, NOT BY HAND — the project's own vet analyzer refuses a hand-built config.Stage
|
||
// outside the config package (archguard.StageSeam, D39.87: a literal silently drops reasoning and
|
||
// temperature), and it caught this fixture's first version. There IS an exemption list and this file
|
||
// is not on it, deliberately: the rule is right, the seam costs nothing here, and an exemption added
|
||
// to make one's own test compile is a gate weakened for convenience. Only `few_shot` is set
|
||
// afterwards, because that is the very field under test and the seam leaves it zero on purpose.
|
||
st := config.InternalCall{Name: "s", Role: "editor"}.Stage()
|
||
st.FewShot = fewShot
|
||
r := &Runner{
|
||
Models: &config.Models{},
|
||
Pricer: pricer,
|
||
Pipeline: &config.Pipeline{Stages: []config.Stage{st}},
|
||
templates: map[string]*PromptTemplate{"s": tpl},
|
||
}
|
||
plan := r.pricePlanFor()
|
||
if plan == nil || len(plan.stages) != 1 {
|
||
t.Fatal("the fixture resolved no priceable stage")
|
||
}
|
||
return plan
|
||
}
|
||
|
||
// TestTheProjectionChargesTheFewShotBlockOnlyWhenTheStageSendsIt pins that the projection asks the
|
||
// EXECUTOR'S question about the few-shot block rather than adding the template's fields up.
|
||
//
|
||
// ⛔ WHAT IT PREVENTS IS A SILENT OVER-CHARGE ON EVERY CALL OF EVERY BOOK ON AN ARM. SystemFor appends the
|
||
// block only when the stage's `few_shot` toggle is on, and the shipped c1 editor has it OFF while its
|
||
// template still CARRIES the block — over a thousand bytes. A projection that summed System+FewShot+User
|
||
// would bill that block on every edit call of the projection, and it would do it invisibly, because a
|
||
// price that is 20 % too high looks exactly like a price.
|
||
func TestTheProjectionChargesTheFewShotBlockOnlyWhenTheStageSendsIt(t *testing.T) {
|
||
tpl := &PromptTemplate{System: "SYSTEM", FewShot: strings.Repeat("Ф", 3000), User: "USER"}
|
||
off := false
|
||
on := true
|
||
withBlock := planFixture(t, tpl, &on).stages[0].tplTokens
|
||
withoutBlock := planFixture(t, tpl, &off).stages[0].tplTokens
|
||
if withBlock <= withoutBlock {
|
||
t.Fatalf("the few-shot block is not priced when the stage sends it: on=%d off=%d", withBlock, withoutBlock)
|
||
}
|
||
// And the toggle's default is ON (absent means on — every existing config keeps its examples), so a
|
||
// nil toggle must price like an explicit true.
|
||
if def := planFixture(t, tpl, nil).stages[0].tplTokens; def != withBlock {
|
||
t.Errorf("an absent few_shot toggle must price as ON (the executor's default), got %d want %d", def, withBlock)
|
||
}
|
||
}
|
||
|
||
// TestTheProjectionChargesTheSourceToEveryStageThatAsksForIt pins the half that was MISSING and that bent
|
||
// the number in the dangerous direction.
|
||
//
|
||
// ⛔ The executor renders EVERY stage with `RenderVars{Text: ch.Text, Draft: prev}`, so a template holding
|
||
// `{{text}}` receives the source whatever its position — and the shipped editor is BILINGUAL by ratified
|
||
// design (D30.1), which is the whole reason it can bind a canon to its source term. A projection that
|
||
// gave the later stages only the draft dropped a source-sized block from the prompt of every edit call:
|
||
// roughly a sixth of a unit's expected bill, UNDER-stated — the direction that lets a platform sell a
|
||
// book for less than it costs.
|
||
func TestTheProjectionChargesTheSourceToEveryStageThatAsksForIt(t *testing.T) {
|
||
bilingual := planFixture(t, &PromptTemplate{System: "S", User: "translate " + sourcePlaceholder}, nil).stages[0]
|
||
mono := planFixture(t, &PromptTemplate{System: "S", User: "edit {{draft}}"}, nil).stages[0]
|
||
if !bilingual.carriesSource {
|
||
t.Fatal("a template holding {{text}} puts the source on the wire and the projection must know it")
|
||
}
|
||
if mono.carriesSource {
|
||
t.Fatal("a template with no {{text}} never receives the source; charging it would over-state the arm")
|
||
}
|
||
p := &pricePlan{inject: 100}
|
||
const source, draft = 500, 300
|
||
// A LATER stage reading a draft: the source rides too, and is charged.
|
||
if got, want := p.promptTokens(bilingual, source, draft, false), bilingual.tplTokens+draft+100+source; got != want {
|
||
t.Errorf("a bilingual later stage prices %d, want %d — the source is missing from the prompt", got, want)
|
||
}
|
||
// The MONOLINGUAL arm on the same shape is priced without it.
|
||
if got, want := p.promptTokens(mono, source, draft, false), mono.tplTokens+draft+100; got != want {
|
||
t.Errorf("a monolingual arm prices %d, want %d — it was charged for a source it never receives", got, want)
|
||
}
|
||
// ⚠ And the FIRST stage is not charged twice: its input IS the source.
|
||
if got, want := p.promptTokens(bilingual, source, source, true), bilingual.tplTokens+source+100; got != want {
|
||
t.Errorf("the first stage prices %d, want %d — the source was counted twice", got, want)
|
||
}
|
||
// ⛔ IDENTITY IS STATED, NOT COUNTED. A later stage whose draft happens to have exactly the source's
|
||
// token count — a pair whose fertility sits near 1.0 makes one — is still a LATER stage, and the
|
||
// source it receives must still be charged. Comparing the two numbers, as the first version did, made
|
||
// this case silently free (acceptance V2-8).
|
||
if got, want := p.promptTokens(bilingual, source, source, false), bilingual.tplTokens+source+100+source; got != want {
|
||
t.Errorf("a later stage whose draft coincides in size with the source prices %d, want %d — identity was inferred from equal numbers instead of being stated", got, want)
|
||
}
|
||
}
|
||
|
||
// TestTheDraftIsPricedPerMemberChunkAndTheEditPerUnit pins the GRANULARITY of the projection against the
|
||
// granularity of the executor.
|
||
//
|
||
// ⛔ THE WAVE DRIVER FANS THE DRAFT OVER CHUNKS AND THE EDIT OVER UNITS (runDraftChunk / runEditUnit), and
|
||
// collapsing that biases the number in both directions at once. The template and the injection ride EVERY
|
||
// member's draft call, so charging them once per unit UNDER-states the bill; and a draft call's output
|
||
// budget is sized from ONE chunk, so sizing it from the whole unit OVER-states the largest single
|
||
// reservation — the number a platform sets its minimum purchase by. On the shipped arm the second is
|
||
// hidden by the model floor and the first is a couple of percent; on an arm without a floor neither is.
|
||
func TestTheDraftIsPricedPerMemberChunkAndTheEditPerUnit(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400)})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
plan := r.pricePlanFor()
|
||
if plan == nil {
|
||
t.Fatal("the fixture resolves no priceable stage")
|
||
}
|
||
half := strings.Repeat("蛊", 700)
|
||
one := editUnit{Members: []chunk.Chunk{{Text: half + half}}}
|
||
two := editUnit{Members: []chunk.Chunk{{Text: half}, {Text: half}}}
|
||
|
||
pOne, pTwo := plan.projectUnit(one), plan.projectUnit(two)
|
||
// Same text up to the JOIN the edit wave puts between members — so the difference in the price below
|
||
// is about how many CALLS the draft takes, not about how much text there is.
|
||
if diff := pTwo.SourceChars - pOne.SourceChars; diff != len([]rune(unitJoinSeparator)) {
|
||
t.Fatalf("fixture drifted: the two units must hold the same text apart from one member join (%d vs %d chars, diff %d)",
|
||
pOne.SourceChars, pTwo.SourceChars, diff)
|
||
}
|
||
if pTwo.ExpectedUSD <= pOne.ExpectedUSD {
|
||
t.Errorf("a two-member unit costs %v and a one-member unit of the same text costs %v — the per-call template and injection are being charged once per UNIT instead of once per member draft call",
|
||
pTwo.ExpectedUSD, pOne.ExpectedUSD)
|
||
}
|
||
// …and the largest single RESERVATION goes the other way: a draft call is sized from one member, so
|
||
// splitting the text into two chunks makes the biggest draft step SMALLER, never larger.
|
||
stepOne := r.stepMaxForUnit(plan, one, pOne)
|
||
stepTwo := r.stepMaxForUnit(plan, two, pTwo)
|
||
if stepTwo > stepOne {
|
||
t.Errorf("the two-member unit reports a LARGER indivisible step (%v) than the one-member unit holding the same text (%v) — the draft step is being sized from the whole unit rather than from one call",
|
||
stepTwo, stepOne)
|
||
}
|
||
}
|
||
|
||
// TestTheBookLevelChargeAppearsOnlyWhenTheBookWillPayIt pins bookOnceUSD, which until now only ever
|
||
// asserted its own zero: terminology is off in every project fixture, so `return 0` unconditionally
|
||
// survived every test in the tree.
|
||
//
|
||
// ⛔ THE FIELD EXISTS SO A SHORT BOOK IS NOT UNDER-PRICED (row 278): the consolidation reads the whole
|
||
// book's drafts ONCE, so charging it per unit makes a three-chapter book look cheap exactly where the
|
||
// error hurts. And it is CONDITIONAL on the pair «the gate is on AND the contrast artifact is named» —
|
||
// the operator-facing trap the deployment runbook documents: lose the FILE and the run refuses loudly,
|
||
// lose the KEY in the config and the published price quietly drops.
|
||
func TestTheBookLevelChargeAppearsOnlyWhenTheBookWillPayIt(t *testing.T) {
|
||
priced := func(gate config.TerminologyGate, contrast string) *Runner {
|
||
pricer, err := ledger.NewPricer(nil, ledger.ModelPrice{InputPerM: 1, OutputPerM: 1})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st := config.InternalCall{Name: "s", Role: roleTranslator}.Stage()
|
||
return &Runner{
|
||
Models: &config.Models{},
|
||
Pricer: pricer,
|
||
Pipeline: &config.Pipeline{
|
||
Stages: []config.Stage{st},
|
||
Gates: config.Gates{Terminology: gate},
|
||
Mining: config.Mining{ContrastPath: contrast},
|
||
},
|
||
templates: map[string]*PromptTemplate{"s": {System: "S", User: "U " + sourcePlaceholder}},
|
||
}
|
||
}
|
||
// ⚠ `ClassifyTypes` IS SET HERE AND WAS NOT BEFORE, and that omission is why this test used to pin the
|
||
// WRONG number: with the phase off the classifier cannot run, so its budget is not part of what the
|
||
// book will pay — and the assertion below demanded that it be added anyway. A test defending the
|
||
// defect it is meant to guard survives every review that reads green (acceptance V2-5).
|
||
on := config.TerminologyGate{Enabled: true, ClassifyTypes: true, BudgetUSD: 0.25, ClassifyBudgetUSD: 0.75}
|
||
|
||
// BOTH halves of the condition, each on its own: the gate alone is not enough, and the artifact alone
|
||
// is not either — the pass does not run without both, so the book does not pay for it.
|
||
if usd := priced(on, "").bookOnceUSD(); usd != 0 {
|
||
t.Errorf("the gate is on but no contrast artifact is named: the pass cannot run and the book must not be charged, got %v", usd)
|
||
}
|
||
if usd := priced(config.TerminologyGate{}, "/corpus").bookOnceUSD(); usd != 0 {
|
||
t.Errorf("the artifact is named but the gate is off: got %v", usd)
|
||
}
|
||
|
||
// AND THE PHASE-OFF CASE, which is the one that was wrong: the classifier cannot run, so its budget is
|
||
// not part of what the book will pay.
|
||
phaseOff := on
|
||
phaseOff.ClassifyTypes = false
|
||
if usd := priced(phaseOff, "/corpus").bookOnceUSD(); usd != 0.25 {
|
||
t.Fatalf("with `classify_types` OFF the book is charged %v — the classifier phase cannot run, so its budget is not a bound on anything; want just the terminologist's 0.25", usd)
|
||
}
|
||
|
||
r := priced(on, "/corpus")
|
||
if usd := r.bookOnceUSD(); usd != 1.0 {
|
||
t.Fatalf("with the classifier phase ON the charge is the sum of the two budgets the plan is trimmed against, got %v want 1.0", usd)
|
||
}
|
||
// …and it is added to the book ONCE, not smeared over the units — which is the whole reason the field
|
||
// exists rather than being folded into expected_usd per unit.
|
||
units := []editUnit{
|
||
{Members: []chunk.Chunk{{Text: strings.Repeat("蛊", 300)}}},
|
||
{Members: []chunk.Chunk{{Text: strings.Repeat("蛊", 300)}}},
|
||
}
|
||
prices, bp := r.projectBook(r.pricePlanFor(), units)
|
||
if bp == nil || len(prices) != 2 {
|
||
t.Fatal("the projection produced nothing")
|
||
}
|
||
var unitSum float64
|
||
for _, p := range prices {
|
||
unitSum += p.ExpectedUSD
|
||
}
|
||
if bp.BookOnceUSD != 1.0 {
|
||
t.Errorf("book_once_usd = %v, want 1.0", bp.BookOnceUSD)
|
||
}
|
||
if diff := bp.ExpectedUSD - (unitSum + 1.0); diff > 1e-12 || diff < -1e-12 {
|
||
t.Errorf("the book price %v is not its units %v plus the once-per-book charge 1.0 — a second unit must not pay it again", bp.ExpectedUSD, unitSum)
|
||
}
|
||
}
|
||
|
||
// TestAPipelineWithNoTranslatorStagePricesItsFirstStageAsTheTranslation pins the fallback the projection
|
||
// takes when no stage carries the translator role — a shape the loader accepts (it validates stage ROLES
|
||
// and imposes no order or count) and no other test in the tree builds.
|
||
//
|
||
// ⛔ IT NEEDS TWO DIFFERENT FALLBACKS AND HAD ONE. runStageSequence starts such a wave with an empty
|
||
// `prev`, and the first stage's global index is 0, so the executor sizes it from the SOURCE — that is its
|
||
// INPUT, and using the source's token count there is right. Its OUTPUT is a different question: this
|
||
// stage is the one doing the translating, and what the engine believes a source of this shape produces is
|
||
// the PAIR'S FERTILITY. One number for both under-states that stage's completion by the whole
|
||
// coefficient — about a sixth on the shipped pair, in the under-stating direction.
|
||
//
|
||
// The assertion is that the fertility MATTERS: same text, same stage, two calibrations, two prices.
|
||
func TestAPipelineWithNoTranslatorStagePricesItsFirstStageAsTheTranslation(t *testing.T) {
|
||
priced := func(fertCJK float64) (*Runner, *pricePlan) {
|
||
pricer, err := ledger.NewPricer(nil, ledger.ModelPrice{InputPerM: 1, OutputPerM: 1})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// A stage that is NOT the translator role — so waveStagesIndexed puts nothing in the draft wave.
|
||
st := config.InternalCall{Name: "s", Role: "editor"}.Stage()
|
||
r := &Runner{
|
||
Models: &config.Models{},
|
||
Pricer: pricer,
|
||
Pipeline: &config.Pipeline{
|
||
Stages: []config.Stage{st},
|
||
Segmentation: config.Segmentation{Fertility: config.Fertility{CJK: fertCJK, Other: 0.3852}},
|
||
},
|
||
templates: map[string]*PromptTemplate{"s": {System: "S", User: "U " + sourcePlaceholder}},
|
||
}
|
||
return r, r.pricePlanFor()
|
||
}
|
||
u := editUnit{Members: []chunk.Chunk{{Text: strings.Repeat("蛊", 900)}}}
|
||
|
||
_, thin := priced(0.5)
|
||
_, fat := priced(2.0)
|
||
cheap, dear := thin.projectUnit(u), fat.projectUnit(u)
|
||
|
||
if cheap.ExpectedUSD >= dear.ExpectedUSD {
|
||
t.Fatalf("the pair's fertility does not reach the price of a translator-less pipeline (%v vs %v) — its only stage IS the translation, and its completion is being taken from the source's token count instead",
|
||
cheap.ExpectedUSD, dear.ExpectedUSD)
|
||
}
|
||
// And its INPUT is still the source: the stage reads `{{text}}`, not a draft that does not exist.
|
||
if dear.DraftTokens <= 0 {
|
||
t.Errorf("the unit reports no draft at all: %+v", dear)
|
||
}
|
||
}
|
||
|
||
// TestASidecarFromAnOlderBuildStillYieldsAPrice builds the state acceptance found (V2-3) and no test in
|
||
// the tree could reach: a manifest that is CURRENT by every check the loader makes, and that predates the
|
||
// price and the structure.
|
||
//
|
||
// ⛔ WHY IT WAS UNREACHABLE BEFORE. `price` and `structure` are ADDITIVE, so manifestVersion deliberately
|
||
// did not move for them — moving it would discard every stored sidecar and re-cut every book. A file
|
||
// written by the previous build therefore passes the version, passes selfConsistent, passes the validity
|
||
// key, and comes back as «current» carrying no price. The read path took it, returned nil, and the
|
||
// fallback that the code itself calls the invariant never ran: the book reported NO PRICE AT ALL,
|
||
// silently, on the surface a buyer's platform reads before deciding.
|
||
// TestManifestServesTheReadModelsIdentically cannot see this — it compares «this pack's sidecar» with
|
||
// «no sidecar» and never builds the older FORM.
|
||
func TestASidecarFromAnOlderBuildStillYieldsAPrice(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(2, 1400)})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.BuildAndPersistManifest(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// Age the sidecar: strip exactly the two additive fields and leave everything the loader validates
|
||
// untouched — the version, the counters and the key all still describe this book, cut this way.
|
||
path := r.ManifestPath()
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var doc map[string]any
|
||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
delete(doc, "price")
|
||
delete(doc, "structure")
|
||
aged, err := json.MarshalIndent(doc, "", " ")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, append(aged, '\n'), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// The loader must still consider it CURRENT — otherwise this test is about staleness, not about an
|
||
// older FORM, and it would pass for the wrong reason.
|
||
if m := r.loadManifest(); m == nil {
|
||
t.Fatal("fixture drifted: the aged sidecar must still validate, or this test is about a stale manifest instead of an old one")
|
||
} else if m.Price != nil || m.Structure != "" {
|
||
t.Fatalf("fixture drifted: the aged sidecar still carries the new fields (%+v / %q)", m.Price, m.Structure)
|
||
}
|
||
|
||
rep, err := r.Status(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep.Price == nil || rep.Price.StepMaxUSD <= 0 {
|
||
t.Fatalf("a sidecar from an older build silenced the price: %+v — the accelerator changed the ANSWER, which is the one thing the fallback exists to prevent", rep.Price)
|
||
}
|
||
if rep.Structure == "" {
|
||
t.Error("…and it silenced the cut's provenance too, so an order phrased in chapters would be offered against a cut nobody described")
|
||
}
|
||
}
|