265 lines
16 KiB
Go
265 lines
16 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"textmachine/backend/internal/chunk"
|
||
"textmachine/backend/internal/llm"
|
||
)
|
||
|
||
// bookrun.go: the BOOK-level loop (Milestone 2) — ingest → memory seed/materialization →
|
||
// snapshot → sequential walk over chunks with D2 discipline (a flag does not drop the run)
|
||
// and a sticky scene window (A5). Plus the run-result types and the CLI exit contract.
|
||
|
||
// StageResult reports one executed stage of a chunk.
|
||
type StageResult struct {
|
||
Stage string
|
||
Role string
|
||
Model string // the model that actually answered
|
||
FromResume bool // no provider call was made THIS run (fully served from checkpoints)
|
||
Usage llm.Usage
|
||
CostUSD float64 // THIS run's spend (0 when fully served from checkpoints)
|
||
CumCostUSD float64 // sum across ALL attempts of this chunk×stage (F3-honest, incl. retries)
|
||
LatencyMS int
|
||
FinishReason string
|
||
Text string // the usable output (only on an ok disposition)
|
||
// RecoveredText is the cosmetic-stripped export text (D35.4a): non-empty ONLY for a
|
||
// FlagSanitizerStripped final stage, where the sanitizer removed a leading markdown header /
|
||
// CJK-leak and committed the cleaned remainder. It carries the export forward on BOTH the
|
||
// fresh path (runStage) and resume (resumeFromChunkStatus reads the derived checkpoint), so
|
||
// translateChunk assembles the identical FinalText either way. "" for every other disposition.
|
||
RecoveredText string
|
||
Disposition Disposition
|
||
FlagReason FlagReason // "" when ok
|
||
Detail string
|
||
Attempts int
|
||
// Escalated — a single-hop fallback draft was ATTEMPTED this stage (D12); when the
|
||
// fallback passed the re-gate, Model above is the fallback (it answered).
|
||
Escalated bool
|
||
// EscalationModel is the fallback model whose output became AUTHORITATIVE ("" when no hop ran, and
|
||
// "" when the hop ran but also failed — then the primary's flag stands and nothing of the hop's
|
||
// output is used; `Escalated` alone carries the fact of the attempt). See stagerun.go.
|
||
EscalationModel string
|
||
// BankFlags carries the translator draft's banknote telemetry (WS4 point 10): accepted line count +
|
||
// parse-fail / truncation flags. Zero-valued on every non-translator stage and every channel-off run.
|
||
BankFlags bankFlags
|
||
// BankProposals is the JSON of the same draft's PARSED banknote entries (the WHAT channel). It rides
|
||
// beside the counters because D39.36 showed counters alone are useless to the person who has to sign:
|
||
// they say a proposal existed, not what it was.
|
||
BankProposals string
|
||
// Repair is the addressable-defect repair outcome of this stage (pack-16): zero for every stage that is
|
||
// not the shipping one, and for every run with the gate off. Counters are DERIVED state — they are never
|
||
// stored in their own row, because the durable artifacts (the role='repair' checkpoints and the
|
||
// tm-repair-v1 final_hash namespace) already record what happened and survive a resume, whereas a column
|
||
// on retrieval_state would be zeroed by the draft wave's unconditional rewrite on every resumed run.
|
||
Repair repairResult
|
||
}
|
||
|
||
// ChunkOutcome is one chunk's result across the stage list.
|
||
type ChunkOutcome struct {
|
||
Chapter int
|
||
ChunkIdx int
|
||
Stages []StageResult
|
||
// FinalText is the exported text: the last stage's output on ok; the cosmetic-stripped
|
||
// remainder on a FlagSanitizerStripped flag (D35.4a — the chunk is exported flagged, not lost
|
||
// to an empty placeholder); "" on any other flag (the contaminated output never exports).
|
||
FinalText string
|
||
Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state)
|
||
FlagReason FlagReason // the flagging stage's reason ("" when ok)
|
||
CostUSD float64 // THIS run's spend on this chunk
|
||
// DroppedMembers counts the unit's member chunks whose draft flagged and which the c-lite editor
|
||
// therefore left OUT of the edit (waverun.go runEditUnit). It is what makes "the shipped text is
|
||
// INCOMPLETE" a fact the renderers can state instead of infer: FlagReason cannot carry it, because
|
||
// a unit can be flagged for the EDIT's own reason (a cosmetic sanitizer strip) while a member was
|
||
// dropped as well, and then the reason names the strip and says nothing about the missing member.
|
||
// 0 for every unit that lost nothing.
|
||
DroppedMembers int
|
||
// DroppedReason is why the FIRST dropped member's draft flagged — the cause of the HOLE, which is
|
||
// not the same fact as FlagReason. When the edit ALSO flags on its own account (a cosmetic
|
||
// sanitizer strip), FlagReason is the strip's and says nothing about the missing text; printing it
|
||
// as the cause of the loss tells the reader a clean-up ate a chunk of the book, which is the very
|
||
// false claim this pack exists to remove. "" when nothing dropped.
|
||
DroppedReason FlagReason
|
||
}
|
||
|
||
// BookResult aggregates a whole run over every chapter×chunk.
|
||
type BookResult struct {
|
||
BookID string
|
||
Chunks []ChunkOutcome
|
||
TotalUSD float64 // THIS run's spend across all chunks
|
||
Flagged int // number of flagged chunks (acceptance allows N)
|
||
// Volume is non-nil when a VOLUME grant was in force (--max-units, volume.go) and the run has something
|
||
// to report about it: it stopped having done the work it was granted, or it reached the end of the book
|
||
// while finishing units an earlier run began — work outside the grant, which the operator line explains
|
||
// and no other field does. It is a RESULT and never an error: a volume stop is a completion (exit 0,
|
||
// the frozen exit-code dictionary untouched), so the fact that this completion is not the end of the
|
||
// book — or that it delivered more than was granted — has to travel HERE, in the report, or the
|
||
// operator gets a stop that does not say which ceiling produced it.
|
||
Volume *VolumeStop
|
||
}
|
||
|
||
// ExitCode is the run's shell disposition: 0 clean, 2 completed-with-flags
|
||
// (acceptance allows N flags — acceptance permits this). An infra failure is
|
||
// an error from TranslateBook, which the CLI maps to 1.
|
||
func (b *BookResult) ExitCode() int {
|
||
if b.Flagged > 0 {
|
||
return 2
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// CompletedWithFlags is the typed sentinel the CLI maps to exit code 2: the book
|
||
// finished end-to-end but N chunks were flagged for a human. It is NOT an infra
|
||
// failure (a plain error → exit 1); it is a "clean run, attention needed" signal
|
||
// carried up through `func run() error` in the idiomatic Go way.
|
||
type CompletedWithFlags struct {
|
||
Flagged int
|
||
Total int
|
||
}
|
||
|
||
func (e *CompletedWithFlags) Error() string {
|
||
return fmt.Sprintf("completed with %d/%d chunk(s) flagged for review", e.Flagged, e.Total)
|
||
}
|
||
|
||
// TranslateBook runs the whole book: split the normalized source into
|
||
// chapter/chunk units (chunker.go) and drive each chunk through the stage list.
|
||
// A bad chunk is flagged and the loop CONTINUES (D2); only an infra failure
|
||
// (ceiling, config change without --resnapshot, unbilled call failure, store
|
||
// error) aborts with an error, from which resume continues.
|
||
//
|
||
// It is also the boundary of the run-event seam (row 103): the journal is opened here rather than with
|
||
// the runner, because a run's identity IS the call's trace id and that lives on the context — and
|
||
// because a command that never runs a book (a dry-run redrive, a read-only projection) must leave no
|
||
// trace in a stream that describes runs. Every exit of the run passes through terminal() below, so the
|
||
// stream's last line says WHY it ended and a reader never has to do exit-code archaeology.
|
||
func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
|
||
r.openEvents(ctx)
|
||
res, err := r.translateBook(ctx)
|
||
r.events.terminal(res, err)
|
||
return res, err
|
||
}
|
||
|
||
func (r *Runner) translateBook(ctx context.Context) (*BookResult, error) {
|
||
// Ingest reads + normalizes the source (txt/epub) into ordered per-chapter text
|
||
// and captures ruby readings (ingest.go), decoding the txt source per book.encoding
|
||
// (auto/utf8/gb18030). Offline and deterministic ($0, no LLM).
|
||
// Fingerprint the source BEFORE reading it (row 100): the manifest written below must be stamped with
|
||
// the identity of the bytes it was actually cut from, and the cut takes seconds on a large book.
|
||
// nil = it could not be taken, which costs this run its manifest and nothing else.
|
||
srcBefore := r.sourceFingerprintBeforeIngest(ctx)
|
||
doc, err := r.ingestSource()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// the precompute pass: eager-build every reachable client BEFORE any wave goroutine, so the clients map is
|
||
// read-only in the waves (r.client is lock-free, a miss is loud — closes the D12 lazy-init race).
|
||
if err := r.buildClients(); err != nil {
|
||
return nil, err
|
||
}
|
||
// the precompute pass: build the per-model rate-guards (transport axis, read-only in the waves; a no-op until a
|
||
// model configures rate_limit — WS1 §1б, the mistral-arm precondition WS6).
|
||
r.buildRateGuards()
|
||
|
||
// Persist captured ruby readings (idempotent; consumed by seedGlossary below into
|
||
// auto glossary candidates — step 4). Never injected into a prompt here (§7d); a
|
||
// resume re-persists the same rows at $0.
|
||
if err := r.persistRuby(doc.Ruby); err != nil {
|
||
return nil, fmt.Errorf("pipeline: persist ruby readings: %w", err)
|
||
}
|
||
|
||
// Seed + materialize the glossary BEFORE the snapshot: the frozen approved rows feed
|
||
// memoryVersion() → the snapshot (F1), so the snapshot must be computed with the bank
|
||
// already materialized. Deterministic and $0 (seed file + classified ruby, no LLM).
|
||
if err := r.seedGlossary(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
// The bank read-out (backlog row 125), at the FIRST boundary where a bank exists. Doing it here rather
|
||
// than only at the stops means a book that never mines anything — or a run that dies before the first
|
||
// wave — still leaves a readable bank behind, which is the state a reader has to be able to see.
|
||
r.exportBank(ctx, "run-start/seeded")
|
||
|
||
chunks, _, keptIdx := chunk.SplitChunksWithChapters(doc.Chapters, r.segBudget(), r.chapterRule(), r.sentenceAbbrevs())
|
||
if len(chunks) == 0 {
|
||
return nil, sourceHasNoContent(fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile))
|
||
}
|
||
// The chapter/chunk manifest (backlog row 100): persisted HERE, where the split that every paid byte
|
||
// is addressed against was just computed, so the artifact and the run can never describe different
|
||
// books. Loud but not fatal — see persistManifest.
|
||
// ⛔ BEFORE the sidecar is rewritten: freeze «did the source move under the rows already stored».
|
||
// persistManifest replaces the very document the probe reads, so asked afterwards it would compare the
|
||
// new source against itself and answer «nothing moved» — silently disabling the re-payment consent
|
||
// gate for an in-place source edit, which is the one path where money is authorised (backlog row 238).
|
||
r.noteSourceVintage()
|
||
r.persistManifest(ctx, srcBefore, denseFrom(doc, keptIdx), chunks, doc.Structure, doc.TOCUnreadable)
|
||
// the precompute pass: the banknote parser's source index (backlog 19). Built here, in the composite
|
||
// root, because the rule "a bank line must name something the book contains" needs the whole chunk
|
||
// manifest, and building it anywhere else would let a call path exist without it.
|
||
r.bankSrc = newBankSourceIndex(chunks)
|
||
|
||
// the precompute pass: pre-compute the sticky-chain memory selection for EVERY chunk (WS1 §1б, precomputeSticky). The
|
||
// sticky window (A5 scene-inertia — the recent chunks' exact matches in the current chapter, reset at
|
||
// a chapter boundary) is a CROSS-CHUNK sequential dependency, so it is computed once here in the precompute pass and
|
||
// consumed by the parallel draft-wave workers (it cannot be recomputed inside a wave). Byte-identical to the
|
||
// retired inline computation (Select is $0 and pure — the golden test proves the injected bytes hold).
|
||
// The draft wave selects over the BASE bank (mined-excluded) so its injection is byte-identical across
|
||
// a bank-mining enrichment (the review-confirmed «re-paid ONCE» fix); the editor uses the enriched bank.
|
||
stickySel := precomputeSticky(chunks, r.baseMemory, r.Pipeline.Context.GlossaryTokenBudget)
|
||
|
||
// The VOLUME ceiling's scope (volume.go): decided before either wave, so the ceiling is checked before
|
||
// an item begins rather than after it has been paid for. It needs stickySel, because the "would this
|
||
// unit resolve for free" predicate renders the same wire bytes the draft wave will.
|
||
//
|
||
// ⚠ IT IS DELIBERATELY COMPUTED BEFORE THE CONSENT GATE BELOW, not after. The gate asks the operator to
|
||
// consent to a CONCRETE spend (Р6), and a run bounded to ten units is not going to re-pay the whole
|
||
// book — quoting the book's figure at it would refuse work that was never going to be done, and ask for
|
||
// consent to money nobody will be charged. rebill.go says out loud why that is corrosive: "a number the
|
||
// operator is asked to approve and then not charged is exactly what makes such a number stop being
|
||
// read." Both steps are $0 and pure, so the reordering costs nothing.
|
||
scope, err := r.planVolume(ctx, chunks, stickySel)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Consent to a RE-PAYMENT (D20.2-Q2, rebill.go) — BEFORE the waves, hence before the first Reserve:
|
||
// if this run would pay again for units already billed under a superseded snapshot, and the amount
|
||
// is over the book's threshold, it stops here with the sum instead of quietly re-buying the book.
|
||
// It needs the materialized memory (the per-wave snapshots fold it), so it sits after seedGlossary,
|
||
// and the chunk manifest (the projected book cost is per output unit), so it sits after the split.
|
||
if err := r.checkRebillConsent(ctx, chunks, scope); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// The run scale — one line to stderr (the smoke-run pain: N/M and the progress
|
||
// denominator never appeared in the logs at all, only in stdout/status).
|
||
r.Log.InfoContext(ctx, "book run started", "book", r.Book.BookID,
|
||
"chapters", len(doc.Chapters), "chunks", len(chunks), "stages", len(r.Pipeline.Stages))
|
||
|
||
// The wave executor (R1, waverun.go): the draft wave (draft ∥) → the bank-mining stop → the edit wave (edit ∥). It computes +
|
||
// upserts the per-wave snapshots (draft-wave snapshot base-bank / edit-wave snapshot enriched) itself and pins each
|
||
// wave's jobs to its own — a the bank-mining stop enrichment moves only edit-wave snapshot, keeping draft-wave checkpoints valid
|
||
// («re-paid ONCE»). Returns a *WaveSignatureStop when the bank-mining stop stops for owner sign.
|
||
res, err := r.translateBookWaves(ctx, chunks, stickySel, scope)
|
||
if err != nil && scope.bound() {
|
||
// A run can be stopped by the OTHER ceiling — money — and then the volume report never reaches the
|
||
// operator, because a failed run returns no result to carry it. Without this line they are told the
|
||
// run halted on money and nothing about what it had been granted or how far that got, which is the
|
||
// first thing anyone asks. The durable progress is in the store either way; this makes the stderr
|
||
// account of the run complete rather than half.
|
||
r.Log.WarnContext(ctx, "the run ended before its VOLUME grant was used up — the stop below is NOT the volume ceiling",
|
||
"book", r.Book.BookID, "max_units", scope.stop.MaxUnits,
|
||
"granted_new", scope.stop.Delivered, "granted_re_made", scope.stop.Reworked,
|
||
"left_never_delivered", scope.stop.LeftFresh, "err", err)
|
||
}
|
||
if err == nil {
|
||
// End of the run (row 125). The stop paths refresh the read-out themselves, so this is the boundary
|
||
// they do not cover: a run that finished, whose last bank change was the auto-continue re-seed or a
|
||
// signed mined-delta loaded at start.
|
||
r.exportBank(ctx, "run-finished")
|
||
// The settled basis at the run's OTHER output boundary. Two are needed and neither is redundant:
|
||
// this one is under `err == nil`, and the signature stop returns as an error VALUE, so a run that
|
||
// stops for the owner's signature never arrives here — its basis is written at the stop itself.
|
||
r.writeBankBasis(ctx, "run-finished")
|
||
}
|
||
return res, err
|
||
}
|