textmachine/platform/internal/ingest/manifest.go

392 lines
23 KiB
Go

package ingest
import (
"encoding/hex"
"encoding/json"
"fmt"
"textmachine/platform/internal/money"
)
// Manifest is the ALLOWLISTED reading of the engine's chapter manifest (`tmctl manifest --json`,
// unified backlog row 100, form ratified with D39.122 as `tm-manifest-v2`).
//
// It carries what intake decides with — how many chapters the book has, and which cut of which bytes
// produced that number — and, since the reading surface landed (P7), the TREE: the engine's stable
// chapter identities, its dense ordinals and the units inside each chapter.
//
// ⚠ `heading` is deliberately NOT read, and that is the allowlist doing its job rather than an
// omission. The engine's field is a deterministic render of the pair's heading rule («Глава N») and
// its own comment forbids presenting it as a label carried by the book; the contract forbids a
// deployment to put a rendered ordinal in `Chapter.heading` (canon §Chapter). A reader that decoded
// it would have somewhere to put it, which is the whole distance between an allowlist and a habit.
type Manifest struct {
// Version is `manifest_version`. Its VALUE is recorded and logged rather than gated — the fields
// below are counts and identities whose meaning is stable, and pinning the value here would make
// every engine release a platform release. Its PRESENCE is required (DecodeManifest), which is a
// different question: not "which manifest is this" but "is this a manifest at all".
Version string `json:"manifest_version"`
ChaptersTotal int `json:"chapters_total"`
UnitsTotal int `json:"units_total"`
// Key is the engine's validity key: everything whose change moves a chunk boundary or the chapter
// set is hashed into it. The platform stores it and compares one string to answer "was this book
// cut again", which is what moves `structure_version` on the wire.
Key string `json:"key"`
// SourceSHA256 is the digest of the ingested source, hex. It answers "is the file on disk still
// the one that was cut" without the platform reading the file again.
SourceSHA256 string `json:"source_sha256"`
SourceBytes int64 `json:"source_bytes"`
// ChunkerVersion is one of the inputs of the manifest's validity key: a change re-numbers
// chapters, so a stored tree is only comparable within one of these (register row PD-166).
ChunkerVersion string `json:"chunker_version"`
// Structure says WHICH PATH drew the chapter boundaries: `declared` (the FORMAT drew them — an
// EPUB spine, a form feed), `detected` (the engine matched header-shaped lines in the prose) or
// `none` (the whole book is one chapter).
//
// ⚠ IT IS PROVENANCE AND NOT TRUST, and the distinction is worth a line because the engine's own
// file gets it wrong in its opening sentence and right two lines later
// (backend/internal/chunk/ingest.go, the provenance comment). What the field states is which
// mechanism produced the boundaries; how much any of those mechanisms is worth is a decision this
// side makes on top of it — see ChapterOrdersOffered, which trusts `detected` alone and says why.
// Inheriting the word «trust» from the publisher is how the same error travels a third time.
//
// An unknown value is read as untrusted by ChapterOrdersOffered rather than refused: the
// vocabulary is the engine's and is ratified to GROW, so a word this build has never seen costs a
// character slider and an honest sentence, never a failure.
Structure string `json:"structure"`
// Price is the engine's A PRIORI projection of what this book costs and what ceiling it needs —
// derived from the text, the pair's own calibration and the engine's own prices, before a single
// call is made (backend/internal/pipeline/priceprojection.go, landing 81a89e9, act D39.206).
//
// A POINTER, and absent is not zero (PD-40): a book the engine could not price at all has no
// projection, and the platform REFUSES to sell it rather than invent a per-chapter constant. That
// refusal is the whole reason the constant could be deleted — see pricing.Model.
Price *BookPrice `json:"price"`
Chapters []ManifestChapter `json:"chapters"`
// Artifacts is the engine's own answer to where its file channels live (row 213). Taking the
// paths from here is what lets the platform stop re-deriving them from the book's configuration
// — knowing the `<project_db>.bank.json` spelling was a copy of the engine's convention that a
// changed default would have silently walked away from.
Artifacts StatusArtifacts `json:"artifacts"`
}
// StatusArtifacts is the allowlisted half of the engine's artifact envelope, published by both
// `manifest --json` and `status --json` (backend/internal/pipeline/status.go, StatusArtifacts).
// Only the channel the platform reads is taken: the project database is the engine's own and must
// not be opened here (D39.85), and the two decision files have exactly one writer, `tmctl
// bank-apply`.
type StatusArtifacts struct {
// BankExport is the whole-bank read-out — the one channel through which the bank leaves the
// engine. The path names a PLACE, not a presence: a book that has never run has no file there.
BankExport string `json:"bank_export"`
}
// BookPrice is the book-level half of the engine's price projection, converted to whole micro-USD
// AT THE SEAM — the same rule the committed figure follows (resync.go, PD-15): a JSON decimal bound
// to a float64 would put drift one step before the integer column that exists to prevent drift.
//
// ⚠ THE TWO SUMS ARE DIFFERENT ARITHMETICS AND MUST NOT BE ADDED CASUALLY. `ExpectedUSD` is what the
// book is expected to be BILLED and it ALREADY INCLUDES `BookOnceUSD`; `StepMaxUSD` is the largest
// single INDIVISIBLE reservation and it is worst-case by construction. The engine says so itself and
// says why: publishing one number for both is what produced «this book cannot be translated at any
// price» (priceprojection.go, the two-numbers comment).
type BookPrice struct {
// ExpectedUSD is the whole book's expected bill, book-level passes INCLUDED.
ExpectedUSD money.MicroUSD `json:"expected_usd"`
// BookOnceUSD is the part of ExpectedUSD that belongs to the BOOK rather than to any chapter: the
// terminology consolidation and its classifier read the whole book's drafts once.
//
// ⚠ IT IS A BOUND AND NOT A FORECAST — the engine's own words — and on the shipped arm it is a
// FLAT $2.00 (gates.terminology.budget_usd $1.00 + classify_budget_usd $1.00, configs/pipeline-c1.yaml)
// whatever the book's length. That is why the platform prices an ORDER from the chapters' own
// expected bills and not from this sum: folding a flat two dollars into the hold would make a
// five-chapter book unbuyable again, which is the PD-440 wall in new clothes. See pricing.Hold.
BookOnceUSD money.MicroUSD `json:"book_once_usd"`
// StepMaxUSD is the largest single reservation any one call of this book can ask for — «the number
// the wall was made of». A ceiling below it admits NOTHING, so it is the floor of every hold and
// the minimum of the money slider (D39.196 §1, unified backlog row 276).
StepMaxUSD money.MicroUSD `json:"step_max_usd"`
// SourceChars is the whole book's ingested text in RUNES, spaces included — a figure a person can
// reproduce in an editor. It is the honest replacement for the intake's write-stream count, which
// for an EPUB counts the runes of a ZIP archive (unified backlog row 282).
SourceChars int64 `json:"source_chars"`
}
// UnitPrice is the size and expected bill of one chapter (the engine rolls its units up) or of one
// unit. Only the two members the platform prices an order with are taken: the dense/sparse split is
// the engine's token-sizing taxonomy and nothing here converts characters to money directly.
type UnitPrice struct {
SourceChars int64 `json:"source_chars"`
ExpectedUSD money.MicroUSD `json:"expected_usd"`
}
// ManifestChapter is one chapter of the tree.
type ManifestChapter struct {
// ID is the engine's content-derived identity: stable across a re-cut and across edits to OTHER
// chapters, which is exactly the stability the contract promises for a chapter id.
ID string `json:"id"`
// Number is the DISPLAY ordinal, dense and 1-based. Not a key — editing the source shifts every
// later one — and it is also the ordinal `unit_done` events address a chapter by.
Number int `json:"number"`
UnitsTotal int `json:"units_total"`
Units []ManifestUnit `json:"units"`
// Price is the chapter's rolled-up size and expected bill. It is read at CHAPTER granularity and
// not at unit granularity because that is the granularity an order is phrased in (D39.196 §1), and
// because the engine already did the roll-up — re-deriving it here would be a second arithmetic
// over one fact.
Price *UnitPrice `json:"price"`
}
// ManifestUnit is one output unit — the pair the client reads.
type ManifestUnit struct {
// ID carries the cut it belongs to, so it dies when the book is cut differently. That is the
// contract's promise about a pair id, and it is the engine's property rather than ours.
ID string `json:"id"`
// FirstChunkIdx is the LEADER chunk index: the join key for both other channels — the `unit` of
// every `unit_done` event and the `chunk_idx` of every export record.
FirstChunkIdx int `json:"first_chunk_idx"`
// Price is this unit's own size and expected bill. Read at UNIT granularity as well as at chapter
// granularity because a book whose structure was not recognised is ONE chapter, and the only
// partial order such a book can carry is a number of CHARACTERS — which resolves to a prefix of
// units and needs each unit's own size to resolve at all.
Price *UnitPrice `json:"price"`
}
// KnownManifestVersion is the shape of the engine's manifest this build was written against. It
// MIRRORS `manifestVersion` in backend/internal/pipeline/manifest.go and moves when that moves.
//
// It is a shape gate and not a value pin, which is the distinction the Version field's own comment
// draws: the counts and identities below are stable, so pinning the value everywhere would make
// every engine release a platform release. What it guards is the ONE place where reading a manifest
// wrong is destructive — see Readable.
const KnownManifestVersion = "tm-manifest-v2"
// Readable is the floor the INTAKE stands on: is this a manifest this build can read at all.
//
// Two questions, and they are one floor because the answer to both is the same non-destructive class.
//
// The version, which is register row PD-213 and a latent mine: `DecodeManifest` does not gate the
// value, `json.Unmarshal` silently ignores unknown fields and leaves missing ones zero — so a shape
// change on the engine's side (`chapters_total` renamed, say) decodes into a perfectly valid Manifest
// with ChaptersTotal 0 and UnitsTotal 0. Zero chapters and zero units is exactly what the intake
// reads as "the engine read the source and there is no book in it", which is the ONE verdict that
// DELETES the user's file after the attempt budget. The engine would have parsed the book perfectly.
//
// The self-consistency, which is register row PD-367: `Whole()` already guarded the materialiser, and
// the intake accepted the very documents the materialiser would refuse — `{chapters_total: 120,
// units_total: 400}` with an empty chapter list founded a book with `chapter_count = 120` and an
// empty tree, over which a run could be STARTED AND PAID FOR. The floor is symmetric now: one
// document, one answer, on both sides of the intake.
//
// The class is the law of the seam's: unknown or inconsistent is answered NON-DESTRUCTIVELY and
// LOUDLY. The caller maps this to `parser_unavailable`, which spends the attempt budget and keeps the
// file — never `source_unreadable`, which is a statement about the USER's text and is the one that
// deletes it.
func (m Manifest) Readable() error {
if m.Version != KnownManifestVersion {
return fmt.Errorf("ingest: the manifest identifies as %q and this build reads %s",
m.Version, KnownManifestVersion)
}
return m.Whole()
}
// Whole reports whether the document DESCRIBES ITSELF: every list is as long as the count printed
// beside it.
//
// It is the engine's own rule, mirrored rather than invented — `BookManifest.selfConsistent`
// (backend/internal/pipeline/manifest.go) refuses a sidecar whose counters do not describe its
// contents and falls back to a full re-chunk. What it protects HERE is different and is why it is
// checked again on this side: the reader above is an ALLOWLIST, so a field the engine renames
// decodes into a zero-valued list while the counts beside it, read from other keys, still say how
// long that list should have been. The counts are the only independent witness this build has that
// it read the document at all.
//
// The bound the engine also carries (a chunk count no book could produce) has no counterpart here:
// this reader does not take chunk counts, so there is nothing for it to over-allocate from.
func (m Manifest) Whole() error {
if m.ChaptersTotal != len(m.Chapters) {
return fmt.Errorf("ingest: the manifest counts %d chapters and carries %d", m.ChaptersTotal, len(m.Chapters))
}
units := 0
for _, c := range m.Chapters {
// ⚠ THE IDENTITIES HAVE NO COUNTER BESIDE THEM, so they need a witness of their own — and they
// are the fields with the most to lose. A reader keyed on them derives the stored row's id from
// the string verbatim, so a renamed key decodes to "" for every chapter or every pair, every
// row collapses onto one derived id, and the replacement write then deletes the rest of the
// book. Measured: a three-pair chapter came out as one row, with the document passing every
// count it declares. Refusing an empty id costs nothing — the engine never emits one
// (backend/internal/pipeline/manifest.go, buildManifest) — and it is the only check here that
// the counts cannot make for us.
if c.ID == "" {
return fmt.Errorf("ingest: chapter %d carries no id, so this build is not reading the manifest's identities", c.Number)
}
for _, u := range c.Units {
if u.ID == "" {
return fmt.Errorf("ingest: a pair of chapter %d carries no id, so this build is not reading the manifest's identities", c.Number)
}
}
if c.Number < 1 {
return fmt.Errorf("ingest: the manifest carries a chapter numbered %d, and chapter numbering is 1-based", c.Number)
}
if c.UnitsTotal != len(c.Units) {
return fmt.Errorf("ingest: chapter %d counts %d pairs and carries %d", c.Number, c.UnitsTotal, len(c.Units))
}
units += len(c.Units)
}
if m.UnitsTotal != units {
return fmt.Errorf("ingest: the manifest counts %d pairs and its chapters carry %d", m.UnitsTotal, units)
}
return nil
}
// SourceSHA256Bytes is the digest as the column stores it. An unparsable value is stored as nothing
// rather than as garbage: the field is evidence, and evidence that cannot be decoded is absence.
func (m Manifest) SourceSHA256Bytes() []byte {
b, err := hex.DecodeString(m.SourceSHA256)
if err != nil {
return nil
}
return b
}
// DecodeManifest parses a manifest document, and refuses anything that does not identify itself as
// one.
//
// ⚠ The identity check is not decoration, and what it protects is the one path in this zone that
// DESTROYS a user's file. `{}`, `null` and any JSON object of fields this build has never heard of
// all decode into a Manifest of zeros, and intake reads a zero chapter count as "the engine read the
// source and there is no book in it" — the single verdict that removes the upload (books.Parse,
// books.reject). So a document that is not a manifest must not be able to arrive as an EMPTY one:
// the two are opposite facts, and only one of them is destructive. Raised by the seam lens of the
// dofix review.
func DecodeManifest(b []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(b, &m); err != nil {
return Manifest{}, fmt.Errorf("ingest: decode manifest: %w", err)
}
if m.Version == "" {
return Manifest{}, fmt.Errorf("ingest: decode manifest: the document carries no manifest_version, so it is not a manifest")
}
return m, nil
}
// StructureDeclared, StructureDetected and StructureNone are the engine's whole vocabulary for where
// a chapter cut came from (backend/internal/pipeline/manifest.go, BookManifest.Structure).
const (
StructureDeclared = "declared"
StructureDetected = "detected"
StructureNone = "none"
)
// ChapterOrdersOffered answers the only question the order form asks of the cut: may an order be
// phrased in CHAPTERS, or must it be phrased in CHARACTERS with the fact said out loud (D39.196 §1)?
//
// ⛔ ONLY `detected` IS TRUSTED, AND `declared` IS DELIBERATELY NOT — which is the opposite of what
// the word suggests, so the reason is written here rather than remembered. For an EPUB the engine
// cuts the book by SPINE DOCUMENTS — one document, one «chapter» — and honestly labels that
// `declared`, because the format did draw those boundaries. But the spine is the READING ORDER, not
// the table of contents: the real chapter boundaries live in `nav`/NCX, which the engine does not
// read yet (unified backlog rows 160-162, and row 283 is the pack that will). So `declared` today
// means «this many documents», not «this many chapters»: a book shipped as one large document has
// one, a book split by scene has dozens. Offering «through chapter 12» against it sells a buyer
// twelve DOCUMENTS under the name of twelve chapters.
//
// Ratified 05.09 by the orchestrator, on the structure pack's own reading. The vocabulary itself
// stays three-valued and the engine's structure pack will EXTEND it additively rather than replace
// it, so this decision is about trust and not about shape.
//
// An EMPTY or UNKNOWN value is not trusted either, and for the mirror reason: the field is additive,
// so a manifest written before the landing carries no `structure` at all and a later engine may
// carry a word this build has never read. Both decode to something this function does not know, and
// the safe answer to «is this cut trustworthy enough to sell chapters against» is no — the order is
// then offered in characters and the client says so, which is a worse offer and never a wrong one.
func (m Manifest) ChapterOrdersOffered() bool {
return m.Structure == StructureDetected
}
// Priced returns the book's projection, and FALSE when this build did not read a whole and
// self-consistent one.
//
// ⚠ ABSENT IS NOT ZERO, AND HALF-READ IS NOT ABSENT — the two failures need one answer and it is
// this one. `Price` is `omitempty` on the engine's side, so a book the runner could not price at all
// legitimately carries none; but `json.Unmarshal` also leaves a RENAMED key at its zero value, and a
// projection whose `step_max_usd` decoded as zero is the exact shape of the PD-440 wall coming back
// silently: the hold loses its floor, the run is admitted with a ceiling no single call can clear,
// and it dies at once having spent nothing and moved nothing. So a zero in a load-bearing member is
// read as «not read» and the sale is refused, never as «this book is free».
//
// THE CHAPTERS ARE THE INDEPENDENT WITNESS, for the same reason the counts are the witness of the
// tree (see Whole): the platform prices an ORDER from the chapters' own expected bills, and a
// per-chapter key the engine renamed would decode into a book where every chapter is free while the
// book-level figure, read from another key, still looks like money. The engine emits both halves
// from ONE derivation or neither (priceprojection.go, projectBook), so a document carrying one
// without the other was not read correctly whatever else is true of it.
func (m Manifest) Priced() (BookPrice, bool) {
p, why := m.priced()
return p, why == ""
}
// PriceRefusal is Priced's answer in words: "" when the book is priced, and otherwise WHICH of the
// five things was missing.
//
// ⛔ IT EXISTS BECAUSE THE REFUSAL WAS MUTE, and a mute refusal here is a deployment that never
// sells. A book whose projection this build cannot read is materialized normally — its tree, its
// text, its reading surface all land — and only the SALE is refused; nothing logged, no metric, no
// listing. On a deployment whose engine predates the projection every book on the host would arrive
// in exactly that state, and an operator would have no line to grep for. The five reasons are
// distinguished because they mean opposite things: «this engine does not publish prices at all» is a
// deploy to fix, and «one chapter lost its price» is a document this build read wrong.
func (m Manifest) PriceRefusal() string {
_, why := m.priced()
return why
}
// priced is the one walk both of the above read, so the answer and its reason cannot disagree.
func (m Manifest) priced() (BookPrice, string) {
switch {
case m.Price == nil:
return BookPrice{}, "the manifest carries no price projection at all"
case m.Price.StepMaxUSD <= 0:
return BookPrice{}, "step_max_usd did not arrive, so no hold would have a floor"
case m.Price.ExpectedUSD <= 0:
return BookPrice{}, "expected_usd did not arrive, so an order has nothing to be priced from"
case m.Price.SourceChars <= 0:
return BookPrice{}, "source_chars did not arrive"
}
sum := money.MicroUSD(0)
for _, c := range m.Chapters {
if c.Price == nil || c.Price.ExpectedUSD <= 0 {
return BookPrice{}, fmt.Sprintf("chapter %d carries no price, so no partial order can be quoted", c.Number)
}
sum += c.Price.ExpectedUSD
// ⚠ EVERY UNIT IS WITNESSED, ON BOTH AXES, and neither is redundant with the chapter above it.
//
// The SIZE: a renamed `source_chars` decodes to zero on every unit, and a CHARACTER order
// resolved against zeroes reaches the end of the book without ever meeting the figure it was
// asked for — a partial order silently becoming a total one.
//
// The PRICE: a character order is quoted from the UNITS' own bills, so zeroes there sell a
// prefix of the book for nothing and hold nothing against it — the chapter roll-up would not
// notice, because it is read from a different key and stays money. Both are the same
// «half-read is not absent» class as the guard above, on the two axes a reader of this
// function does not think to check: one because it is not money, and one because the
// chapter's figure looks like it already covered it.
for _, u := range c.Units {
if u.Price == nil || u.Price.SourceChars <= 0 || u.Price.ExpectedUSD <= 0 {
return BookPrice{}, fmt.Sprintf("a unit of chapter %d carries no size or no price, "+
"so a character order over this book cannot be resolved", c.Number)
}
}
}
// The cross-witness: the chapters' bills plus the book-level pass ARE the book's expected bill
// (projectBook adds BookOnceUSD to the sum of the units and nothing else). The slack is the
// conversion's own: every amount is rounded UP to whole micro-USD at this seam, independently, so
// the chapters can sum one micro-dollar per chapter above their own share. Anything outside that
// is two numbers from two documents.
slack := money.MicroUSD(len(m.Chapters) + 1)
units := m.Price.ExpectedUSD - m.Price.BookOnceUSD
if sum < units-slack || sum > units+slack {
return BookPrice{}, "the chapters' own bills and the book's figure describe different books"
}
return *m.Price, ""
}