Let an honest character count survive a price this build could not read, so a book whose money key moved still shows the size it really has

This commit is contained in:
heaven 2026-09-06 02:06:58 +03:00
parent bb6f3f4841
commit 32bbadbc96
5 changed files with 123 additions and 9 deletions

View file

@ -816,7 +816,8 @@ type Run struct {
OrderedChapters int
// DeliveredChapters is NIL for a run whose order does not close whole chapters: such a run has
// delivered no CHAPTER, and reporting `0` would read as «nothing happened» rather than as «this
// is not the unit this run is measured in». DeliveredUnits carries its progress instead.
// is not the unit this run is measured in». Progress carries what it HAS done instead, and for
// such a run its `done`/`total` are counted in units rather than chapters.
DeliveredChapters *int
// BondFunded is whether this run's hold has room for the book-wide consistency pass on top of
// what it bought. It is the order form's promise, kept where it can still be read after the

View file

@ -194,3 +194,83 @@ func TestTheEnginesCharacterCountReachesTheLibraryRowBesideItsAccuracy(t *testin
t.Errorf("the cut's provenance did not land: %q", after.Structure)
}
}
// ⛔ THE HONEST CHARACTER COUNT SURVIVES A PRICE THIS BUILD COULD NOT READ, and it did not until an
// audit asked. `source_chars` was written only when the whole projection passed its witness — so a
// manifest that named the text perfectly and failed on a MONEY key cost the screen its character
// count too, and «Знаков» fell back to the intake's approximation, which for an EPUB counts the runes
// of a ZIP archive. The two facts are independent: one is about the text, the other about money.
//
// Mutation caught: writing `source_chars` off `in.Price` again.
func TestTheCharacterCountSurvivesAPriceThisBuildCouldNotRead(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
in := priced()
in.Price = nil // the money did not arrive…
in.SourceChars = 3000 // …and the engine's count of the text did
if err := s.SaveStructure(ctx, book, in); err != nil {
t.Fatal(err)
}
got, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if got.SourceChars == nil || *got.SourceChars != 3000 {
t.Fatalf("the engine's rune count was discarded with the price: %v", got.SourceChars)
}
// …and the book is still refused for SALE, which is the other half: the two facts are independent
// in both directions.
order, err := s.ReadBookForOrder(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if order.Priced {
t.Error("a book whose price did not arrive reads as priced")
}
}
// ⛔ WORK DELIVERED BEYOND THE ORDER'S BOUNDARY MUST NOT CANCEL WORK STILL OWED INSIDE IT. The
// allowance handed to the engine is «ordered minus delivered», and counting the delivered book-WIDE
// while counting the ordered only up to the boundary makes the two describe different sets: a run
// ordered through chapter 2 whose chapters 1 and 3 came back delivered fell to its floor of one unit
// and half-delivered what was bought. It costs no money — settlement is by fact — and it breaks «you
// bought N», which is the promise this pack exists to make true.
//
// Mutation caught: `deliveredWithinOrder` counting the book instead of the order.
func TestDeliveryBeyondTheOrderDoesNotCancelWorkOwedInsideIt(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveStructure(ctx, book, priced()); err != nil {
t.Fatal(err)
}
// The order runs through chapter ONE (two units). Chapter TWO — beyond it — comes back delivered.
var c1 string
if err := s.pool.QueryRow(ctx,
`select id from chapters where book_id = $1 and number = 1`, book).Scan(&c1); err != nil {
t.Fatal(err)
}
if _, err := s.pool.Exec(ctx, `
update books set ordered_at = now(), ordered_through_chapter_id = $2,
ordered_through_chapter_number = 1 where id = $1`, book, c1); err != nil {
t.Fatal(err)
}
if _, err := s.pool.Exec(ctx, `
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
values ($1, 2, 0, 'edit', true, false, now())`, book); err != nil {
t.Fatal(err)
}
var ordered, delivered int
var resolved bool
if err := s.pool.QueryRow(ctx, `
select `+orderedUnits+`, `+orderResolved+`, `+deliveredWithinOrder+`
from books b where b.id = $1`, book).Scan(&ordered, &resolved, &delivered); err != nil {
t.Fatal(err)
}
if !resolved || ordered != 2 {
t.Fatalf("the order through chapter one resolves to %d units (resolved %v), want 2", ordered, resolved)
}
if delivered != 0 {
t.Errorf("a chapter delivered BEYOND the order counted as %d units of it: the allowance would "+
"fall to its floor and half-deliver what was bought", delivered)
}
}

View file

@ -81,6 +81,15 @@ type Structure struct {
// engine did not say — a manifest older than the landing — and stored as such: the decision it
// feeds (ingest.ChapterOrdersOffered) treats what it does not know as untrusted.
Structure string
// SourceChars is the engine's count of the book's ingested text in runes.
//
// ⛔ CARRIED APART FROM Price, AND THAT IS THE POINT. It is a property of the CUT, not of the
// money: a manifest can name the text honestly and still fail the price witness (a renamed money
// key, a chapter that lost its bill). Read off `Price`, such a book lost its honest character
// count too and fell back to the intake's approximation — a number that for an EPUB counts the
// runes of a ZIP archive — for a reason that has nothing to do with counting. Zero means the
// engine did not say.
SourceChars int64
}
// StructureChapter is one chapter and its pairs, in reading order.
@ -161,11 +170,17 @@ func (s *Store) SaveStructure(ctx context.Context, bookID string, in Structure)
// Nulls are written through when the book is not priced. A stale projection kept beside a fresh
// tree would sell yesterday's book; an absent one refuses the sale, which is the honest half.
var expected, bookOnce, stepMax *int64
var sourceChars *int64
if in.Price != nil {
e, b, sm := int64(in.Price.ExpectedUSD), int64(in.Price.BookOnceUSD), int64(in.Price.StepMaxUSD)
sc := in.Price.SourceChars
expected, bookOnce, stepMax, sourceChars = &e, &b, &sm, &sc
expected, bookOnce, stepMax = &e, &b, &sm
}
// ⚠ INDEPENDENT OF THE PRICE — see Structure.SourceChars. A book the engine could not price is
// still a book whose text the engine counted, and the screen's «Знаков» has no business
// falling back to the intake's approximation because a MONEY key was renamed.
var sourceChars *int64
if in.SourceChars > 0 {
sc := in.SourceChars
sourceChars = &sc
}
var structureWord *string
if in.Structure != "" {
@ -632,9 +647,12 @@ const (
// top for the same reason the bar is: free and carried units ride outside the grant (volume.go),
// so a run can finish more than it was sold, and a figure above what was bought would read as an
// overdelivery rather than as the rounding it is. Zero for a re-pass, which buys no chapters.
// ⚠ AND WHAT SUCH A RUN HAS DELIVERED IS COUNTED IN UNITS TOO — reported to the client as
// `delivered_units` beside a null `delivered_chapters`, because a run that bought a prefix of a
// chapter has delivered no CHAPTER and saying «0» would read as «nothing happened».
// ⚠ NULL — NOT ZERO — FOR A UNIT-SHAPED ORDER, and there is no second figure beside it. A run
// that bought a prefix of a chapter has delivered no CHAPTER, and «0» would read as «nothing
// happened» when work was done and paid for. What such a run has delivered is carried by the
// BAR instead, whose `done`/`total` are counted in units for exactly this run (see runDone) —
// so the two figures a client gets are a null chapter count and a unit-shaped fraction, and
// nothing on the wire is called `delivered_units`.
runDelivered = `(case when r.ordered_units is not null then null
when r.ceiling_chapters = 0 then 0
else least(greatest((case when ` + editWave + ` then ` + editChapters + ` - r.chapters_before

View file

@ -316,7 +316,10 @@ func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string,
// the old rows are deleted rather than left alone and the text is gone for good.
in := pgstore.Structure{ManifestKey: manifest.Key, TextRead: textErr == nil,
Structure: manifest.Structure,
Chapters: make([]pgstore.StructureChapter, 0, len(manifest.Chapters))}
// The engine's own rune count, carried whether or not the book could be PRICED: it is a fact
// about the text, and the price witness is about money (pgstore.Structure.SourceChars).
SourceChars: manifestSourceChars(manifest),
Chapters: make([]pgstore.StructureChapter, 0, len(manifest.Chapters))}
// ⚠ ALL OR NOTHING, and the decision is Priced's rather than this loop's. A projection is written
// only when the whole of it was read — the book figure, a price on every chapter, and the two
// agreeing — because the half-read shape is the dangerous one: a `step_max_usd` that decoded as
@ -380,3 +383,15 @@ func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) er
}
return s.Store.SaveBank(ctx, bookID, bank.Terms)
}
// manifestSourceChars is the book's ingested text in runes as the engine counted it, or 0.
//
// It reads the figure off the price block because that is where the engine publishes it — but it
// deliberately does NOT ask whether the projection as a whole was readable. A renamed money key must
// not cost the screen its honest character count.
func manifestSourceChars(m ingest.Manifest) int64 {
if m.Price == nil {
return 0
}
return m.Price.SourceChars
}

View file

@ -92,7 +92,7 @@ func TestAWholeBookOrderCarriesNoVolumeBound(t *testing.T) {
// respawned after a restart would be handed its whole order a second time and the promise «you
// bought N units» would stop being about N.
//
// Mutation caught: maxUnitsFor ignoring DeliveredUnits.
// Mutation caught: maxUnitsFor ignoring SpawnOrder.Delivered.
func TestTheVolumeAllowanceIsWhatIsLeftOfTheOrderAtEverySpawn(t *testing.T) {
f := newFixture(t, "10", 500)
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)})