textmachine/backend/internal/pipeline/status.go

1183 lines
64 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.

package pipeline
import (
"context"
"fmt"
"sort"
"strings"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/store"
)
// status.go: the READ-ONLY progress projection (`tmctl status`) and the targeted re-attack
// of flagged chunks (`tmctl redrive`) — the D12 "progress is the only real gap"
// tail, ratified by D15.3. Status makes ZERO LLM calls and ZERO checkpoint replays: it reads
// the book manifest (a deterministic, $0 re-chunk of the source) and projects it against the
// stored chunk_status / spend / retrieval_state. Redrive resets the flagged chunks' terminal
// state (their chunk_status + checkpoints) and re-runs the durable loop, so the DispOK work
// resumes at $0 and only the reset stages re-attack with a fresh retry/escalation budget.
// ChunkState is a chunk's resolved lifecycle state for the projection — a chunk-level view
// over its per-stage chunk_status rows. `skipped` is deliberately NOT a chunk state: it is a
// per-STAGE substate (the downstream stages of a flagged chunk), surfaced in the passport's
// stage counts, since the linear C1 core never skips a whole chunk.
type ChunkState string
const (
ChunkDone ChunkState = "done" // every pipeline stage resolved ok
ChunkFlagged ChunkState = "flagged" // a stage flagged (chunk not translated; ≠ infra failure)
ChunkInProgress ChunkState = "in_progress" // some stages resolved, not all, none flagged (an interrupted run)
ChunkPending ChunkState = "pending" // no stage attempted yet
)
// WaveCounter is one wave's done/total pair, in OUTPUT UNITS — the same denominator the whole
// projection counts in (TotalUnits), so the two phases and the unit totals can never be read against
// different scales. Total is 0 for a wave the pipeline does not have (a draft-only pipeline has no edit
// wave), which is how a consumer tells "no such phase" from "none of it is done yet".
type WaveCounter struct {
Done int `json:"done"`
Total int `json:"total"`
}
// PhaseProgress is progress PER WAVE (backlog row 99). The end-to-end unit counter (`Done`) requires
// BOTH every member draft AND the unit's edit to have resolved ok, and the edit wave does not start
// before the bank stop — so it reads 0 for the whole draft wave, which is exactly the indicator the row
// was raised about. These two counters split that single number by wave.
//
// DONE HERE MEANS RESOLVED, NOT OK. A unit counts for a wave once every chunk_status row that wave owes
// it exists — ok, flagged or skipped alike. Two reasons, both load-bearing:
//
// - a flagged unit is FINISHED as far as work goes (nothing re-attempts it without an explicit
// `tmctl redrive`), so excluding it would leave a progress bar permanently short of its own
// denominator on any book with a single flagged chunk;
// - the ok/flagged split is already carried, unconflated, by Done/Flagged/GlossaryMissFlagged below.
//
// Consequence to read deliberately, ON AN EDIT PIPELINE: Edit.Done ≥ Done, and the gap is every unit the
// edit wave RESOLVED but the unit-level verdict did not call done — a unit the post-check GATE flagged
// (its edit row is ok; the gate flips the UNIT after the stage loop), a c-lite unit whose edit shipped
// while a member draft flagged, and a unit whose edit was recorded skipped because every member flagged.
// All three are finished work, which is what these counters count; "attention needed" is the other
// counters' job. On a DRAFT-ONLY pipeline the relation does not hold in that direction at all — Edit is
// the zero counter and Draft is the one to compare against Done.
//
// A stage RENAME is the one state where the two readings disagree in the other direction: stored rows
// carry the old stage name, which is in neither wave's name set, so a wave counter stays short of its
// denominator while `Done` (which counts ok rows by disposition, not by name) can still read full. The
// same rename already makes `export` report every unit pending; `ConfigDrift` is what flags the condition.
type PhaseProgress struct {
Draft WaveCounter `json:"draft"`
Edit WaveCounter `json:"edit"`
}
// waveShape is the row arithmetic behind "this wave is done with this unit": which stage names belong to
// each wave and how many rows each owes a unit. A row exists only once the wave DECIDED (ok, flagged or
// skipped alike — see PhaseProgress), and chunk_status is keyed (book, chapter, chunk, stage), so
// counting the rows a wave wrote IS the resolution test.
//
// It is one definition because two readers need it: this projection, over stored rows, and the live event
// emitter (events.go), which counts the same units as it resolves them. A second copy of the arithmetic
// would put the event stream and the `status --json` resync on two different numbers, and the platform
// folds both into one column.
type waveShape struct {
draftNames map[string]bool
editNames map[string]bool
nDraft int
nEdit int
}
func (r *Runner) waveShape() waveShape {
d, e := r.waveStagesIndexed(waveDraft), r.waveStagesIndexed(waveEdit)
return waveShape{draftNames: stageNameSet(d), editNames: stageNameSet(e), nDraft: len(d), nEdit: len(e)}
}
// resolved reports, for one unit's stored rows, whether each wave has written every row it owes it. A
// wave the pipeline does not have answers false — its denominator is 0, not a total it can never reach.
func (w waveShape) resolved(u editUnit, rows []store.ChunkStatus) (draft, edit bool) {
draftRows, editRows := 0, 0
for _, cs := range rows {
switch {
case w.draftNames[cs.Stage]:
draftRows++
case w.editNames[cs.Stage]:
editRows++
}
}
return w.nDraft > 0 && draftRows >= len(u.Members)*w.nDraft, w.nEdit > 0 && editRows >= w.nEdit
}
// StatusArtifacts are the engine's file channels, by path.
//
// It is ADDITIVE and deliberately does not rename anything: the bank read-out's `<project_db>.bank.json`
// spelling stays as it is, because renaming it to a fixed name beside `events.jsonl` is a BREAKING change
// whose window is the chapter-structure re-cut (backlog row 161), not this one. What changes is that a
// consumer no longer has to know the spelling at all.
type StatusArtifacts struct {
// ProjectDB is the engine's private SQLite. Published as the ANCHOR the other sidecars are named
// after, not as an invitation to open it: it is the engine's store and another zone must not read it
// (D39.85). A consumer that has it can stop re-deriving it from `project_db` + `book_id`.
ProjectDB string `json:"project_db"`
// BankExport is the whole-bank read-out (row 125) — the only channel through which the bank leaves
// this process, and the file a signing screen is built from.
//
// Every path here names a PLACE, not a presence: a book that has never run has none of these files,
// and the field still says where the engine will put it. Publishing "does it exist" instead would be
// a fact about a moment that has passed by the time the consumer reads it, and would invite the
// check-then-open race; opening the file and handling the ordinary not-found is the answer.
BankExport string `json:"bank_export"`
// MinedDelta / MinedRejects are the two owner-decision files `tmctl bank-apply` writes. They are here
// so a caller can SEE where its decisions land — the engine remains their only writer, and a consumer
// that edits them directly is back to impersonating an operator with a text editor.
MinedDelta string `json:"mined_delta"`
MinedRejects string `json:"mined_rejects"`
// BookFiles are the READER's copies of the book that `tmctl build` writes, by format
// (bookfile.Formats): `<project_db>.book.<format>`, beside the database like every sidecar. A PLACE
// like the others — the map is complete whether or not a build has run, and a consumer opens the
// path and handles not-found. It is the channel the platform's export door reads instead of
// deriving the spelling (17-seam-inbound-law §1: the engine owns the paths of its own scheme).
BookFiles map[string]string `json:"book_files"`
}
// artifacts is the ONE producer of the envelope. Two surfaces publish it (`status --json` and
// `manifest --json`) and a second construction site is how the two would drift into disagreeing about
// where the engine keeps a file.
func (r *Runner) artifacts() StatusArtifacts {
return StatusArtifacts{
ProjectDB: absPath(r.Book.ProjectDB),
BankExport: absPath(r.bankExportPath()),
MinedDelta: absPath(r.Book.MinedDelta),
MinedRejects: absPath(r.Book.MinedRejects),
BookFiles: r.bookFilePaths(),
}
}
// statusVersion versions the SHAPE of the status document.
const statusVersion = "tm-status-v1"
// ChapterPassport is the per-chapter quality passport (D12): unit counts, the worst flag
// reason, a pass|attention|fail verdict (exp07 chapter rule) and the chapter's spend.
type ChapterPassport struct {
Chapter int `json:"chapter"`
UnitsTotal int `json:"units_total"`
// Progress is this chapter's per-wave split of UnitsTotal (row 99). It exists for the same reason the
// book-level one does, one level down: a chapter tree whose per-chapter counter is the end-to-end one
// shows every chapter at zero for the whole draft wave (contract companion §4, K-10).
Progress PhaseProgress `json:"progress"`
UnitsDone int `json:"units_done"`
UnitsFlagged int `json:"units_flagged"`
UnitsInProgress int `json:"units_in_progress"`
UnitsPending int `json:"units_pending"`
StagesSkipped int `json:"stages_skipped"` // per-stage skip count (downstream of a flag)
Escalations int `json:"escalations"`
PostcheckMisses int `json:"postcheck_misses"`
StyleFlags int `json:"style_flags"` // cheap style/number gate hits (observability, not a disposition)
// RepairApplied counts this chapter's units whose SHIPPED text carries an applied repair (pack-16). It is
// DERIVED from the durable final_hash namespace, never stored, so it survives a resume like the rest of
// the projection. omitempty: a chapter with no repairs renders exactly as before the field existed.
RepairApplied int `json:"repair_applied,omitempty"`
WorstFlagReason string `json:"worst_flag_reason,omitempty"`
Verdict string `json:"verdict"` // pass | attention | fail (exp07: 0/1/≥2 flagged units)
CostUSD float64 `json:"cost_usd"`
}
// StatusReport is the whole-book read-model.
type StatusReport struct {
// Version is the SHAPE of this document, not its content — the versioned envelope every JSON output
// of the engine carries (17-seam-inbound-law п.3). `status --json` was the one machine surface
// without one while the bank read-out and the manifest both had theirs, so a consumer that has to
// tolerate the field set changing had nothing to branch on for the largest of the three.
Version string `json:"status_version"`
BookID string `json:"book_id"`
// Artifacts are the engine-owned paths of the files a reader outside this process needs. They are
// REPORTED rather than derived, which is the whole of it: the platform was computing the project
// database's default path itself — a byte-for-byte copy of config.LoadBook's convention — and
// appending its own copy of the bank read-out's suffix, so changing either default here would have
// quietly pointed another zone's read at a file that does not exist (backlog row 213).
Artifacts StatusArtifacts `json:"artifacts"`
Snapshot string `json:"snapshot_id"` // the snapshot the stored rows were resolved under ("" = nothing run yet)
// SnapshotDrift is true when the stored rows carry more than one snapshot id (a config
// changed mid-book without a full --resnapshot re-pin) — a loud signal, not a silent skew.
SnapshotDrift bool `json:"snapshot_drift"`
// ConfigDrift is true when the CURRENT config renders a snapshot DIFFERENT from the one the
// stored rows were resolved under — a wire/verdict-affecting edit since the last run (e.g. a
// prompt-version bump, a gate flip) that `tmctl translate` would --resnapshot. Without this
// the operator sees "done/pass" and false confidence (finding #3). CurrentSnapshot is what
// the config renders now (empty when it could not be computed or matches).
ConfigDrift bool `json:"config_drift"`
// ConfigDriftBasis says what the boolean above is a verdict OF, because the boolean cannot carry it:
// `false` used to mean both «checked, and the rows match» and «could not check», and those are
// opposite instructions. none | drift | unknown — see driftbasis.go. Same discipline as RebillBasis,
// and for the same reason. (Disclosure law §2.3, ratified D39.181 п.3.)
ConfigDriftBasis string `json:"config_drift_basis"`
CurrentSnapshot string `json:"current_snapshot,omitempty"`
// RebillUnits/RebillUSD turn the drift BOOLEAN into the number the operator actually decides on
// (spec D15.2 §9, taken 25.07): "the config drifted" says nothing about whether continuing costs a
// cent or the whole book — these say "N chunk×stage units already billed under a superseded snapshot
// would be paid for again, ~$X" (row 181). It is the ONE projection the consent gate refuses on
// (projectRebill, through one repricer), so this document and `translate` cannot describe two
// different figures — what neither of them promises is ACCURACY. The figure is an estimate that errs
// UPWARD where the tokens are known: the stored tokens priced at the model each stage resolves to today,
// never below the answering model's price, while the token count a model that has never seen these
// chunks would spend is unknowable, and a row with no usable usage is carried at what it was billed
// (D39.150 п.1, reprice.go). RebillModelMovedRows and RebillHistoricalRows below say how much of it each
// caveat holds for.
//
// ⚠ NEITHER IS omitempty ANY MORE, and dropping it is the point rather than a tidy-up. Three states
// reach this line — "nothing has run, so there is nothing to re-pay", "computed, and the answer is
// zero" and "the computation failed" — and omitempty rendered all three as the field being ABSENT, so
// the wire could not tell "free" from "unknown". That is the D39.166 п.2 class, which this engine has
// already paid for once ("a $0 price walls the door up — units at zero price arrive with no price").
// RebillBasis below is what distinguishes them; the numbers are now always present so the basis has
// something to qualify. Safe to change: both fields are OFF the seam's allowlist until a consumer
// exists (backlog row 231), and the human renderer branches on the VALUE, not on presence.
RebillUnits int `json:"rebill_units"`
RebillUSD float64 `json:"rebill_usd"`
// RebillOutputUnits is the SAME re-payment counted in OUTPUT UNITS — the granularity every other
// "units" number in this document uses (total_units, progress totals, chapters[].units_total) and the
// one `--max-units` bounds and the platform sells chapters in.
//
// ⚠ IT EXISTS BECAUSE rebill_units IS A DIFFERENT UNIT WEARING THE SAME WORD: chunk×stage, the BILLING
// granularity. Measured on a three-chapter fixture, one document carried rebill_units:15 two lines
// under total_units:6, and the ratio is not something a consumer can divide out — it is
// len(Members)·nDraftStages + nEditStages, varying unit by unit inside one book, with its factors never
// crossing the seam. A consumer sizing a re-pass from rebill_units and handing that number to
// --max-units would buy several times the book it meant to: this pack's own defect, one layer down.
// rebill_units is NOT renamed — it is established and its meaning is unchanged; this is the number a
// caller should size a purchase from.
RebillOutputUnits int `json:"rebill_output_units"`
// RebillHistoricalRows counts, of RebillUnits, the units whose share of RebillUSD is wholly or partly
// the amount they were ORIGINALLY billed at rather than a re-pricing (reprice.go: no usable usage on
// file, calls that no longer account for the row's money, or newest calls provably not the row's). It
// is the wire form of the consent text's own disclosure: a reader of rebill_usd — the operator's
// tooling that reads this document rather than the refusal text — could not otherwise tell how much of
// the figure is last season's money (disclosure law §2.3/§2.4: the figure travels with its basis on the
// channel its consumer reads). ⚠ The platform is NOT that consumer today: its allowlisted decode takes
// none of the rebill_* fields (backlog row 231), so this field, like rebill_usd, is additive there and
// ignored. Qualified by RebillBasis like every other rebill_* figure: under `none`/`failed` it is zero
// because nothing was projected, not because every row re-priced.
RebillHistoricalRows int `json:"rebill_historical_rows"`
// RebillModelMovedRows counts, of RebillUnits, the units whose stage resolves today to a model other than
// the one their calls were SENT to. They are priced at the dearer of the two models (D39.150 п.1) for a
// token count only one of them produced, so the larger this count, the more rebill_usd is an upper
// estimate rather than a re-pricing of known tokens. Same basis discipline as above.
RebillModelMovedRows int `json:"rebill_model_moved_rows"`
// RebillBasis says WHAT the figures above are a projection of, because the number alone cannot
// carry that and a reader must never have to guess:
//
// pending — the fold of the book's decision FILES: the bank the NEXT `translate` will build, so the
// figures answer "what would a re-pass cost" for an edit that has been applied but not yet
// run. This is the ordinary answer.
// stored — the fold could not be built (a broken seed, a collision that would abort ReplaceBank),
// so the figures fall back to the glossary the LAST run stored. They are then a fact about
// the past, not a projection of the next run; the reason is on the WARN log.
// none — no stored row exists, so there is nothing already-billed to re-pay. Genuinely $0.
// failed — the projection itself errored. The figures are zero because they are UNKNOWN.
RebillBasis string `json:"rebill_basis"`
TotalUnits int `json:"total_units"`
Done int `json:"done"`
InProgress int `json:"in_progress"`
Flagged int `json:"flagged"`
Pending int `json:"pending"`
PercentDone float64 `json:"percent_done"` // 100·done/total, unit-count only (no synthetic time-bar)
// Progress is the PER-WAVE split of TotalUnits (backlog row 99) — see PhaseProgress for what "done"
// means there and why it is not the same predicate as Done above. No percentage is derived from it
// here on purpose: how two phases combine into one number a reader sees is a product decision, not a
// projection fact.
Progress PhaseProgress `json:"progress"`
// GlossaryMissFlagged counts chunks the post-check GATE promoted to flagged/glossary_miss (a
// CONFIRMED miss on an otherwise-ok chunk). A SUBSET of Flagged and NOT re-drivable: the miss
// re-derives from the unchanged glossary, so the fix is a seed edit + `translate --resnapshot`,
// never a redrive (a no-op for them). Flagged GlossaryMissFlagged is the re-drivable remainder,
// which is what the status CLI must advise `tmctl redrive` for (minor 1d: the old blanket hint
// sent glossary_miss chunks into a redrive dead-end).
GlossaryMissFlagged int `json:"glossary_miss_flagged"`
Escalations int `json:"escalations"`
PostcheckMisses int `json:"postcheck_misses"`
// UnsignedBankTerms is how many bank rows carry a rendering NOBODY approved — the engine's own auto/draft
// proposals, which the auto mode of the bank stop puts in front of the model as ordinary bank law (D39.104 п.2 took the ⟨проверить⟩ marker off the wire). Directive
// item 4 asks for it HERE and not only in `report`: status is the command an operator runs before deciding
// whether to keep paying, and "this book is translating against N unsigned terms" is that decision's input.
UnsignedBankTerms int `json:"unsigned_bank_terms,omitempty"`
// StyleFlags is the book-wide total of the cheap deterministic style/number gate hits
// (dialogue-dash, ё, translit-interjection, 万/億 magnitude). Observability, never a
// disposition — a nonzero count is "attention worth a human glance", not a failed chunk.
StyleFlags int `json:"style_flags"`
CommittedUSD float64 `json:"committed_usd"`
ReservedUSD float64 `json:"reserved_usd"`
BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
// ProjectedBookUSD extrapolates the per-processed-unit cost over the whole book at TODAY'S price
// table — deliberately NOT the book's committed spend, which is historical money and also carries
// partial spend on in-progress units (projectBookUSD).
ProjectedBookUSD float64 `json:"projected_book_usd"`
// Price is the A PRIORI projection — what the book is expected to cost and what ceiling it needs,
// derived from the text and the engine's own prices before a single call is made (priceprojection.go).
//
// ⚠ IT IS NOT ProjectedBookUSD AND MUST NOT BE READ AS ITS REPLACEMENT. That figure extrapolates from
// units this book has ALREADY PAID FOR and is therefore zero for a book that has never run, which is
// exactly the state a buyer asks the question in; this one is a projection from the source text and
// stays constant as the run proceeds. They answer «what is it costing» and «what will it cost», and a
// consumer that showed one where it meant the other would be wrong in the expensive direction on the
// day a book is bought.
//
// ⛔ PRESENT WITHOUT A SIDECAR TOO, and the sentence that used to stand here said the opposite. It
// read «absent when there is no current manifest — the fallback re-chunk path has the text but not
// the sidecar's guarantee that it describes THIS cut», which described the code for as long as it
// took the same commit to add the fallback and then not come back here (acceptance F7). A consumer
// building on it would have concluded «no sidecar ⇒ no price» and shown a buyer nothing on precisely
// the surface the buyer asks the question from.
//
// What actually holds: `status` and `manifest --json` cannot quote two different prices for one book
// because BOTH ends run the same derivation (readModelPrice → projectBook), not because one of them
// declines to answer. The sidecar is an ACCELERATOR; when it cannot answer — absent, stale, or
// current but older than this field — the price is computed from the cut this read has just made,
// which describes THIS cut by construction rather than by guarantee.
//
// ABSENT means the runner could not resolve prices at all: no text to cut, or no price table.
Price *BookPrice `json:"price,omitempty"`
// Structure says whether the chapter cut was declared by the format, detected in the prose, or absent
// — see BookManifest.Structure. Carried here because an order phrased in chapters is offered off this
// surface, and the offer needs to know whether the chapters are read or guessed.
Structure string `json:"structure,omitempty"`
ETASeconds float64 `json:"eta_seconds,omitempty"` // secondary: mean fresh-call throughput × remaining
// ContentLabels / Routing are the content-label PROVENANCE (B6): what the book declares and which
// model each stage resolves to under it. They are a $0 projection of the config — deliberately NOT a
// hash input (the labels' wire fate is the resolved model itself, D39.26 point 1), so this is where an
// operator reads "which endpoint received this book". Both omitempty: an unlabelled book's report is
// byte-identical to what it was before these fields existed.
ContentLabels []string `json:"content_labels,omitempty"`
// Routing is one "stage=model" entry per stage (plus "→hop" when a fallback is wired), in stage order.
// A string list rather than a struct: it is read by humans and diffed by CI, and a labelled run's whole
// routing decision fits on one line.
Routing []string `json:"routing,omitempty"`
// ContentRoutingProblems mirrors the read-path warning (D39.26 point 9): a book whose labels are not
// runnable stays inspectable, and the reason travels WITH the projection instead of only in a log line
// the operator may never see.
ContentRoutingProblems []string `json:"content_routing_problems,omitempty"`
Chapters []ChapterPassport `json:"chapters"`
}
// contentRoutingRows renders the per-stage routing projection ("stage=model" / "stage=model→hop") in
// stage order. Shared by the status and quality read models so the two can never disagree about which
// model a stage resolved to.
func (r *Runner) contentRoutingRows() []string {
rows := make([]string, 0, len(r.Pipeline.Stages))
for _, st := range r.Pipeline.Stages {
row := st.Name + "=" + st.ResolvedModel
if st.ResolvedHop != "" {
row += "→" + st.ResolvedHop
}
rows = append(rows, row)
}
return rows
}
// flagSeverity ranks flag reasons worst-first for the chapter's "worst flag" passport field.
// Lower = worse. Deterministic content-failures (refusal/echo/off-target/excision) outrank retryable
// budget symptoms (length/empty), which are the least alarming.
//
// ⚠ A MAP, AND EXHAUSTIVE BY TEST, because the switch this replaced had the failure mode a hand-written
// list always has: a reason added to the engine and not added here fell to the default and became the most
// BENIGN flag in the passport. Measured on `off_target_lang` — the flag an entire pack was built to raise
// ranked 8, below `length`(6), so a chapter that came back in the wrong language reported `length` as its
// worst problem. Nothing was wrong with the switch's code; the list was simply not tied to the constants.
// TestEveryFlagReasonIsRanked ties it: the constants are read out of disposition.go, not retyped here.
var flagSeverity = map[FlagReason]int{
FlagHardRefusal: 0,
FlagSoftRefusal: 0,
FlagContentFilter: 0,
FlagHardBlock: 0,
// The output is not a translation of this text into the asked-for language at all. `cjk_artifact` is
// the source echoed back; `off_target_lang` is an answer in some THIRD language — worse to a reader in
// one way (it can ship silently: it reads as prose, and a bilingual editor will happily relay-translate
// it), so it is ranked with the echo and not below it.
FlagCJKArtifact: 1,
FlagOffTargetLang: 1,
FlagExcisionSuspect: 1,
FlagCoverageFail: 1,
// A contaminated output (leaked preamble / notes) that was DROPPED is unreadable-as-shipped —
// ranked with the deterministic content failures, above a mere budget symptom.
FlagSanitizerDefect: 2,
FlagLoopDegenerate: 3,
FlagDecodeError: 4,
FlagGlossaryMiss: 5,
FlagLength: 6,
FlagEmpty: 6,
FlagUpstreamNotOK: 6,
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned, so it is
// the least alarming flag — an "auto-cleaned, glance to verify" signal, ranked below a budget symptom
// (the chunk is not lost; a human need only spot-check the auto-clean).
FlagSanitizerStripped: 7,
}
// severityUnknown is where a reason this build has never heard of lands — a row written by an older
// schema, or junk. Last on purpose: an unrecognised string must not out-rank a diagnosis the engine
// actually made. It is NOT a resting place for new flags; the exhaustiveness test is what keeps it empty.
const severityUnknown = 8
func flagReasonSeverity(reason string) int {
if s, ok := flagSeverity[FlagReason(reason)]; ok {
return s
}
return severityUnknown
}
// bookChunks re-derives the book's chunk manifest — Ingest + chunk.SplitChunks — for the honest N/M
// denominator. Pure and $0 (no LLM); unlike TranslateBook it does NOT persist ruby or seed the
// glossary, so status stays a pure read. A change to the chunk BOUNDARIES (added/removed text that
// shifts the manifest) or the chunker VERSION is reflected here.
//
// CAVEAT (minor 1d — narrowed overclaim): a source edit that leaves the boundaries unchanged (a
// typo fix inside a chunk) is INVISIBLE to status — it changes the chunk's content_hash but not the
// manifest and not the snapshot (which carries chunker_version, not the source bytes), so status
// still reads done/pass over a translation of the OLD text. Surfacing it would need a per-chunk
// content_hash re-render ($0) compared against the stored chunk_status.content_hash; deferred,
// because the resume fast-path already re-checks content_hash on the NEXT translate — which is
// where such an edit surfaces (as a re-bill of exactly the touched chunks), just not in status.
func (r *Runner) bookChunks() ([]chunk.Chunk, error) {
doc, err := r.ingestSource()
if err != nil {
return nil, err
}
// WHERE the chapter boundaries came from is a fact only the ingest holds, and the chunks it returns
// do not carry it. Recorded here so a read path that had to re-cut can still say whether its chapter
// numbers were read out of the file or inferred from the prose — the same answer the sidecar gives
// when there is one (readModelPrice).
r.cutStructure = doc.Structure
return chunk.SplitChunks(doc.Chapters, r.segBudget(), r.chapterRule(), r.sentenceAbbrevs()), nil
}
// chunkKey identifies a chunk positionally.
type chunkKey struct {
chapter, chunkIdx int
}
// chunkStateResolution is one output unit's resolved chunk-level state: what to report, why, what it
// cost and how it got there. It is a struct rather than a 5-tuple because every caller needs a
// different subset and a positional return made "which bool was escalated" a reading exercise.
type chunkStateResolution struct {
State ChunkState
Reason string // the deciding flag reason; "" unless State is ChunkFlagged
CostUSD float64 // the unit's spend across every stored row (all members, all stages)
Escalated bool // any row escalated to a fallback model
Skipped int // rows recorded as skipped (an upstream stage flagged)
}
// unitRows collects one output unit's stored rows in MEMBER order, leader first. The ORDER is
// load-bearing, not cosmetic: "the first flagged row decides the unit" only means "the earliest member
// that dropped" when the rows arrive in member order — that is what makes status, export and translate
// name the SAME reason on a unit where several things went wrong.
func unitRows(u editUnit, byChunk map[chunkKey][]store.ChunkStatus) []store.ChunkStatus {
var rows []store.ChunkStatus
for _, m := range u.Members {
rows = append(rows, byChunk[chunkKey{m.Chapter, m.ChunkIdx}]...)
}
return rows
}
// droppedMember is one member chunk whose DRAFT flagged, so the edit wave dropped it from the unit
// (c-lite, D39.17-fix): the editor still ran over the clean remainder and that text SHIPS, but the unit
// is flagged and carries this member's reason and detail.
type droppedMember struct {
Key chunkKey
Reason string
Detail string
}
// memberDrops is the SINGLE definition of the c-lite rule "a dropped member flags its unit", in member
// order. translate applies it live (runEditUnit); status applies it by folding the member rows through
// resolveChunkState below (a dropped member's flagged draft row is the first flagged row of the unit);
// export, which projects only the FINAL row, has no member rows to fold and calls this directly. Before
// this helper the three read models agreed only by a comment asking future edits to keep them in step.
//
// A member with several draft-stage rows contributes its LAST flagged one (the rows are in stored
// order), matching the map-overwrite the export projection has always used.
func memberDrops(u editUnit, byChunk map[chunkKey][]store.ChunkStatus, draftStages map[string]bool) []droppedMember {
var drops []droppedMember
for _, m := range u.Members {
key := chunkKey{m.Chapter, m.ChunkIdx}
var d *droppedMember
for _, cs := range byChunk[key] {
if draftStages[cs.Stage] && cs.Disposition == string(DispFlagged) {
d = &droppedMember{Key: key, Reason: cs.FlagReason, Detail: cs.Detail}
}
}
if d != nil {
drops = append(drops, *d)
}
}
return drops
}
// resolveChunkState maps a unit's per-stage rows (in unitRows order) to its chunk-level state, plus its
// cost, escalation flag and (when flagged) the flag reason. stagesTotal is the expected ok-row count.
func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) chunkStateResolution {
if len(rows) == 0 {
return chunkStateResolution{State: ChunkPending}
}
res := chunkStateResolution{}
ok := 0
for _, cs := range rows {
res.CostUSD += cs.CostUSD
if cs.Escalated {
res.Escalated = true
}
switch cs.Disposition {
case string(DispOK):
ok++
case string(DispFlagged):
// The FIRST flagged stage decides the unit; keep its reason (the rows arrive in member/stage order
// with the leader bucket first, so first-wins aligns status with translate/export, which both take
// the first flag — a member drop's reason, or the edit's own when the edit flagged). Without the
// guard the loop's last-flagged row would win, diverging from the other read-models on a multi-flag unit.
if res.State != ChunkFlagged {
res.State, res.Reason = ChunkFlagged, cs.FlagReason
}
case string(DispSkipped):
res.Skipped++
}
}
if res.State == ChunkFlagged {
return res
}
if ok == stagesTotal {
res.State = ChunkDone
return res
}
res.State = ChunkInProgress
return res
}
// Status builds the read-only progress projection. It opens no jobs, reserves nothing, makes
// no LLM call and replays no checkpoint — the D12 query-handler analogue. Safe to run whenever
// the project file is not held by a running tmctl (the same exclusive-lock rule as `report`).
func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
// The manifest fast path (row 100): status joins stored rows against POSITIONS, never against source
// text, so the persisted structure is a complete substitute for the re-chunk here. The one branch that
// does need text — the re-bill content check — takes the full split through the provider below, and
// only when a bank-only snapshot move actually put it on the path.
chunks, withText, err := r.readModelChunks()
if err != nil {
return nil, err
}
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return nil, err
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
// Post-check misses + style flags (retrieval_state) — the unit-level signal the the edit-wave editor merged onto
// its LEADER chunk's row (a non-leader member carries only its draft injection, post-check=0).
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
return nil, err
}
missByChunk := map[chunkKey]int{}
styleByChunk := map[chunkKey]int{}
for _, rs := range states {
missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss
styleByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NStyleFlags
}
// The shipping granularity is the OUTPUT UNIT (edit unit for an edit pipeline, draft chunk for a
// draft-only one) — status projects it like export + the per-unit BookResult. A unit is DONE when every
// member draft AND the unit's edit resolved ok, so its expected-ok count is len(members)·|draft stages| +
// |edit stages| (a non-leader member has draft rows only; the single edit row lives at the leader).
draftStages := r.waveStagesIndexed(waveDraft)
editStages := r.waveStagesIndexed(waveEdit)
draftStageNames, editStageNames := stageNameSet(draftStages), stageNameSet(editStages)
shape := r.waveShape()
units := r.outputUnits(chunks)
// The post-check GATE (opt-in) flags a unit at the CHUNK level AFTER the stage loop, so it is NEVER
// written as a chunk_status row (every stage stays DispOK). Without accounting for it here, status
// would report a gate-flagged unit as done/pass while `tmctl translate` exits 2 (finding #2).
gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
rep := &StatusReport{Version: statusVersion, BookID: r.Book.BookID, TotalUnits: len(units),
Artifacts: r.artifacts()}
// Per-wave denominators (row 99): a wave the pipeline does not run has a total of 0 rather than a
// total it can never reach — a draft-only pipeline must not show "edit 0/4276" forever.
if len(draftStages) > 0 {
rep.Progress.Draft.Total = len(units)
}
if len(editStages) > 0 {
rep.Progress.Edit.Total = len(units)
}
passports := map[int]*ChapterPassport{}
var chapterOrder []int
for _, u := range units {
p := passports[u.Chapter]
if p == nil {
p = &ChapterPassport{Chapter: u.Chapter, Verdict: "pass"}
passports[u.Chapter] = p
chapterOrder = append(chapterOrder, u.Chapter)
}
p.UnitsTotal++
if len(draftStages) > 0 {
p.Progress.Draft.Total++
}
if len(editStages) > 0 {
p.Progress.Edit.Total++
}
leader := chunkKey{u.Chapter, u.FirstChunkIdx}
expected := len(u.Members)*len(draftStages) + len(editStages)
// Collected once: three passes over the unit's rows follow, and on a 5000-unit book re-walking the
// member map for each of them is pure churn.
rows := unitRows(u, byChunk)
res := resolveChunkState(rows, expected)
state, reason := res.State, res.Reason
// Per-wave resolution (row 99), off the SAME rows the unit state is folded from — through the one
// definition the live emitter also counts by (waveShape).
draftDone, editDone := shape.resolved(u, rows)
if draftDone {
rep.Progress.Draft.Done++
p.Progress.Draft.Done++
}
if editDone {
rep.Progress.Edit.Done++
p.Progress.Edit.Done++
}
p.CostUSD += res.CostUSD
p.StagesSkipped += res.Skipped
miss := missByChunk[leader] // the unit's post-check ran once, at the leader row
p.PostcheckMisses += miss
rep.PostcheckMisses += miss
style := styleByChunk[leader]
p.StyleFlags += style
rep.StyleFlags += style
// Applied repairs (pack-16): the unit's shipping row points at a repair export. Read off the durable
// final_hash namespace — no counter column, so a resumed run reports the same number.
for _, row := range rows {
if strings.HasPrefix(row.FinalHash, repairDerivedNS+":") {
p.RepairApplied++
break
}
}
if gateOn && state == ChunkDone && miss > 0 {
// Matches the wave editor: the gate flags only an otherwise-ok unit. Not re-drivable (the miss
// re-derives from the unchanged glossary; a fix is a glossary edit = --resnapshot, not a
// redrive) — counted separately so the CLI advises the right action.
state, reason = ChunkFlagged, string(FlagGlossaryMiss)
rep.GlossaryMissFlagged++
}
if res.Escalated {
p.Escalations++
rep.Escalations++
}
switch state {
case ChunkDone:
rep.Done++
p.UnitsDone++
case ChunkFlagged:
rep.Flagged++
p.UnitsFlagged++
if p.WorstFlagReason == "" || flagReasonSeverity(reason) < flagReasonSeverity(p.WorstFlagReason) {
p.WorstFlagReason = reason
}
case ChunkInProgress:
rep.InProgress++
p.UnitsInProgress++
case ChunkPending:
rep.Pending++
p.UnitsPending++
}
}
// Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail — over UNITS: a
// flagged edit unit counts once, not per member chunk).
sort.Ints(chapterOrder)
for _, n := range chapterOrder {
p := passports[n]
switch {
case p.UnitsFlagged >= 2:
p.Verdict = "fail"
case p.UnitsFlagged == 1:
p.Verdict = "attention"
default:
p.Verdict = "pass"
}
rep.Chapters = append(rep.Chapters, *p)
}
if rep.TotalUnits > 0 {
rep.PercentDone = 100 * float64(rep.Done) / float64(rep.TotalUnits)
}
// Per-wave snapshot drift (finding #3, wave-aware): the rows carry draft-wave snapshot (draft) + edit-wave snapshot
// (edit), so a normal book has TWO snapshots — that is NOT drift. SnapshotDrift means a disagreement
// WITHIN a wave (a config changed mid-book without a full re-pin). ConfigDrift compares each wave's
// single stored snapshot against the CURRENT projection of THAT wave (materialize the stored glossary
// once, then project both). rep.Snapshot reports the SHIPPING wave's snapshot.
draftSnaps, editSnaps := map[string]bool{}, map[string]bool{}
for _, cs := range statuses {
if cs.SnapshotID == "" {
continue
}
switch {
case draftStageNames[cs.Stage]:
draftSnaps[cs.SnapshotID] = true
case editStageNames[cs.Stage]:
editSnaps[cs.SnapshotID] = true
}
}
rep.SnapshotDrift = len(draftSnaps) > 1 || len(editSnaps) > 1
finalSnaps := editSnaps
if r.finalStageWave() == waveDraft {
finalSnaps = draftSnaps
}
if len(finalSnaps) == 1 {
for s := range finalSnaps {
rep.Snapshot = s
}
}
// The bank BOTH projections below are computed against, materialized ONCE.
//
// It is hoisted out of the drift branch it used to sit inside, and that move is a fix rather than a
// tidy-up: the re-bill projection is computed unconditionally further down, and on a book with
// snapshot drift this branch never ran — leaving r.memory nil, so `current(w)` in projectRebill
// rendered the wave snapshots over an EMPTY bank (memoryVersion falls back to the hash of no rows,
// snapshot.go), every stored row then differed from it, and status reported the WHOLE book as a
// re-payment. Materializing before either consumer removes the state where one of them runs without it.
memBasis, memWarn := r.foldMemoryForRead()
if memWarn != nil {
r.Log.WarnContext(ctx, "the bank fold refused, so the projections below are computed against the glossary the LAST run stored — they are a fact about the past, not a projection of the next run", "basis", memBasis, "err", memWarn)
}
// The unsigned-bank exposure, same definition as the quality report's (a row with a rendering that is
// not approved), counted off THE BANK THIS DOCUMENT JUST FOLDED.
//
// ⚠ It used to be read straight from the stored glossary, and after the fold landed that made one
// status document describe TWO banks: the money figures spoke about the bank the next run will build
// while this counter spoke about the bank the last run stored. An operator asking "is there anything
// to sign before I keep paying?" was told zero while the very next translate would inject an unsigned
// proposal sitting in the auto-bank file and bill for the unit it changed. One document, one bank.
if r.bankRows == nil {
r.Log.WarnContext(ctx, "status: no bank could be materialized; the unsigned-term count is unknown, not zero")
} else {
for _, e := range r.bankRows {
if e.Status != "approved" && strings.TrimSpace(e.Dst) != "" {
rep.UnsignedBankTerms++
}
}
}
// The drift verdict travels with its BASIS: a `false` that means «could not check» is not an answer,
// and every path that produces one says so instead of leaving the boolean to be read as clean.
driftRan := false
if len(statuses) > 0 {
switch {
case memBasis == RebillBasisFailed:
r.Log.WarnContext(ctx, "config-drift not checked: the bank could not be folded, so drift is UNKNOWN, not none",
"book", r.Book.BookID)
case rep.SnapshotDrift:
// Rows split WITHIN a wave: SnapshotDrift already says the stronger thing, and comparing a
// single stored id against the current one is not defined here. Not an answer about config
// drift either, so it must not read as one.
r.Log.WarnContext(ctx, "config-drift not checked: the rows carry more than one snapshot within a wave (snapshot_drift), so config drift is UNKNOWN, not none",
"book", r.Book.BookID)
case !driftCheckable(statuses):
r.Log.WarnContext(ctx, "config-drift not checked: no stored row carries a snapshot id, so drift is UNKNOWN, not none",
"book", r.Book.BookID)
default:
driftRan = true
checkWave := func(snaps map[string]bool, w wave) {
if len(snaps) != 1 {
return
}
var stored string
for s := range snaps {
stored = s
}
cur, _, serr := r.snapshotIDForWave(w)
if serr != nil {
r.Log.WarnContext(ctx, "config-drift check failed for a wave; drift state is UNKNOWN, not none", "err", serr)
driftRan = false
return
}
if cur != stored {
rep.ConfigDrift = true
rep.CurrentSnapshot = cur
}
}
checkWave(draftSnaps, waveDraft)
checkWave(editSnaps, waveEdit)
// A stored row for a stage the current pipeline does not run — the rule `export` has had all
// along and this surface did not, reproduced verbatim on the cold run (backlog row 239). ONE
// definition, shared: orphanStageRows.
if stage, orphan := orphanStageRows(statuses, draftStageNames, editStageNames); orphan {
rep.ConfigDrift = true
if rep.CurrentSnapshot == "" {
if cur, _, serr := r.snapshotIDForWave(r.finalStageWave()); serr == nil {
rep.CurrentSnapshot = cur
}
}
r.Log.WarnContext(ctx, "CONFIG-DRIFT — stored rows carry a stage the current config does not run (renamed or removed since the run); the shipping rows are not the ones the run shipped",
"book", r.Book.BookID, "stage", stage)
}
}
}
rep.ConfigDriftBasis = driftBasisFor(driftRan, rep.ConfigDrift)
// One re-pricing read serves both money projections below (reprice.go): the re-payment amount and
// projected_book_usd, which is also the base of the consent threshold — computing them from two
// price tables is precisely how the gate would go half-historical.
rp, err := r.newRepricer()
if err != nil {
return nil, err
}
// Re-payment projection (spec §9): what the drift above would COST. Computed whenever rows exist —
// not only under ConfigDrift — because SnapshotDrift (rows split across snapshots within one wave)
// re-bills too, and that is precisely the case the boolean pair leaves unpriced. $0 and read-only:
// projectRebill re-renders the wave snapshots and re-prices stored usage; it reaches no provider.
var proj RebillProjection
var projErr error
if projectable(len(statuses) > 0, memBasis) {
proj, projErr = r.projectRebill(statuses, chunks, withText, rp)
if projErr != nil {
// Same discipline as the drift check above: a failed projection is reported, never
// silently rendered as "nothing to re-pay".
r.Log.WarnContext(ctx, "re-bill projection failed; the re-payment cost of the drift is unknown (reported as unknown, not as none)", "err", projErr)
}
}
rep.RebillBasis, proj = rebillOutcome(len(statuses) > 0, memBasis, proj, projErr)
rep.RebillUnits, rep.RebillUSD, rep.RebillOutputUnits = proj.Rows, proj.USD, proj.OutputUnits
rep.RebillHistoricalRows, rep.RebillModelMovedRows = proj.HistoricalRows, proj.ModelMovedRows
// Money.
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return nil, err
}
rep.CommittedUSD, rep.ReservedUSD = committed, reserved
// The ceiling IN FORCE (row 145): a read path never carries a run-scoped override, so this is the
// book's own number there — but reading it through the single definition means status can never quote
// a ceiling the ledger is not admitting against.
rep.BookCeilingUSD = r.bookCeilingUSD()
if rep.BookCeilingUSD > 0 {
rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD
}
// Projected book cost: extrapolate the per-PROCESSED-unit average over the whole book (done +
// flagged = a unit fully attempted), AT TODAY'S PRICES (row 181). NOT book committed: committed also
// carries partial spend on IN-PROGRESS units (excluded from the denominator), which would
// over-estimate (finding #7) — and committed is historical money besides, which is the other half of
// why this number is not it. The arithmetic lives in projectBookUSD (rebill.go) because the consent
// threshold is 5% of THIS number — one definition, so the threshold can never be computed from a
// drifted copy of it.
processed := rep.Done + rep.Flagged
// Same two terms as the consent gate's own base, from ONE derivation: the per-unit extrapolation plus
// the bank-role contour the unit walk cannot see (row 194). A projection that omitted a whole class of
// spend here while the gate included it would be the half-historical split rebill.go warns about.
rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages), rp) + r.bankRoleCommittedUSD()
rep.Price, rep.Structure = r.readModelPrice(withText)
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
// DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain
// arithmetic mean, not an exponentially-weighted one. For a batch book run throughput is
// ~stationary, so the mean and an EWMA converge; a recency-weighted EWMA (which would adapt to
// a mid-run provider slowdown) is deferred because it needs the ORDERED per-call latencies, not
// the sum+count FreshCallLatencyMS returns. Kept secondary/advisory in the output so it is never
// mistaken for a committed completion time.
totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID)
if err != nil {
return nil, err
}
remaining := rep.TotalUnits - processed
if processed > 0 && freshCalls > 0 && remaining > 0 {
meanMSPerChunk := float64(totalMS) / float64(processed)
rep.ETASeconds = meanMSPerChunk * float64(remaining) / 1000
}
// Content-label provenance (B6): only for a book that declares labels, so an unlabelled report keeps
// its exact prior bytes. The routing rows come from the shared projection, and the problems (if any)
// are the same ones the write path refuses on.
if len(r.Book.ContentLabels) > 0 {
rep.ContentLabels = r.Book.ContentLabels
rep.Routing = r.contentRoutingRows()
rep.ContentRoutingProblems = r.Pipeline.ContentProblems
}
return rep, nil
}
// projectable reports whether the re-payment projection is worth computing at all.
//
// It is FALSE when no bank could be materialized, and skipping it then is the point rather than an
// optimization. The projection compares every stored row's snapshot against what the CURRENT config
// renders — and with r.memory nil, memoryVersion falls back to the hash of an EMPTY bank (snapshot.go),
// so every row differs from it and the whole book comes back as a re-payment. Running it anyway would put
// the largest figure the field can hold beside a basis that says "unknown", and the CLI would print "the
// figures below are zero because they could not be computed" next to it.
func projectable(hasRows bool, memBasis string) bool {
return hasRows && memBasis != RebillBasisFailed
}
// rebillOutcome is the ONE decision about what the report says on the re-payment axis: the basis and the
// figures, always together, so a branch cannot set one and forget the other.
//
// It exists as a function rather than as a switch inside Status because the invariant it carries is not
// reachable from a fixture: "failed" needs BOTH the fold and the stored materialization to refuse, which
// no healthy store will do. The first version of this logic left the basis at "failed" while a
// successfully-computed whole-book figure sat beside it — a basis contradicting its own numbers, which is
// worse than having no basis at all. Here the rule is a value, and a table test can hold it to it.
//
// It returns the WHOLE projection rather than the fields the report happens to publish, so a figure added
// to the projection cannot survive an unknown answer by being forgotten here: under `none` and `failed`
// every field is zero, the disclosure counters included.
func rebillOutcome(hasRows bool, memBasis string, proj RebillProjection, projErr error) (basis string, published RebillProjection) {
switch {
case !hasRows:
return RebillBasisNone, RebillProjection{} // nothing already-billed exists, so nothing can be re-paid
case memBasis == RebillBasisFailed, projErr != nil:
// UNKNOWN, and zeroes that say so rather than figures that lie. EVERY figure, including the
// output-unit count: one number surviving here would be the same contradiction the basis exists to
// prevent, just in a different field.
return RebillBasisFailed, RebillProjection{}
default:
return memBasis, proj
}
}
// RebillFigureBasis is what the re-payment figures of this report are made of, in the words the consent
// gate's refusal uses for the same figure (projectionBasis) — so `tmctl status` and the refusal `translate`
// prints describe one number in one wording, and a reader who saw one recognises the other. Meaningful
// only when RebillUnits > 0; the basis of a zero is RebillBasis.
func (rep *StatusReport) RebillFigureBasis() string {
return projectionBasis(RebillProjection{Rows: rep.RebillUnits, HistoricalRows: rep.RebillHistoricalRows, ModelMovedRows: rep.RebillModelMovedRows})
}
// projectStoredMemory materializes r.memory from the STORED glossary (read-only: no re-seed, no write).
//
// ⚠ IT IS NO LONGER THE READ PATH'S FIRST ANSWER — it is foldMemoryForRead's FALLBACK, and the demotion
// is backlog row 231 (errata 28.08-к). What it materializes is LAST run's fold, and its own comment used
// to justify that with "a seed-FILE edit not yet re-run is NOT reflected here … exactly as it does for
// `translate` itself". The second half was false: TranslateBook calls seedGlossary BEFORE
// checkRebillConsent (bookrun.go), so `translate` re-seeds from the FILES and its consent gate sees the
// edit — while every read-only surface said "nothing moved" for exactly the work the next run would bill
// for. Right after a `bank-apply`, which writes decision FILES and no store row, that is the whole of the
// blind window: the platform had nothing to show a buyer before charging them.
//
// ⚠ IT NOW MATERIALIZES BOTH BANKS, and that is a fix this pack's own test caught rather than a
// generalization. It used to call membank.Materialize (rows alone) and set r.memory only, leaving
// r.baseMemory nil — and the re-bill projection's re-pin branch renders the DRAFT wave against exactly
// that bank (rebill.go → renderedContentHashes), where repin.go skips every position when it is nil. The
// positions then had no reproducible hash, "cannot conclude" took the conservative branch, and a bank move
// that touched BASE rows made status report the whole draft wave as a re-payment — while `translate`,
// which has both banks, charged for a fraction of it: the two surfaces describing two figures, which one
// shared derivation exists to make impossible.
//
// It reads the voice and address rows for the same reason: the run materializes with them, and a read that
// materializes without them is a second definition of the same bank. They do not move the version hash
// while InjectVoice is false (membank.ComputeVersionScopedIn), so nothing re-pays for this.
func (r *Runner) projectStoredMemory() error {
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
return err
}
voices, err := r.Store.VoiceProfilesForBook(r.Book.BookID)
if err != nil {
return err
}
pairs, err := r.Store.AddressPairsForBook(r.Book.BookID)
if err != nil {
return err
}
r.materializeBanks(rows, voices, pairs)
return nil
}
// RedriveSelector picks the flagged chunks to re-attack. -1 on Chapter/ChunkIdx means "any"
// (a valid chunk index is ≥0, a chapter ≥1, so -1 is an unambiguous "unset"); an empty Reason
// matches any flag_reason. DryRun reports what WOULD be reset without touching anything.
type RedriveSelector struct {
Chapter int
ChunkIdx int
Reason string
DryRun bool
}
func (s RedriveSelector) matches(cs store.ChunkStatus) bool {
if s.Chapter >= 0 && cs.Chapter != s.Chapter {
return false
}
if s.ChunkIdx >= 0 && cs.ChunkIdx != s.ChunkIdx {
return false
}
if s.Reason != "" && cs.FlagReason != s.Reason {
return false
}
return true
}
// RedriveTarget is one chunk selected for re-attack and the stages that were (or would be) reset.
type RedriveTarget struct {
Chapter int `json:"chapter"`
ChunkIdx int `json:"chunk_idx"`
FlagReason string `json:"flag_reason"`
Stages []string `json:"reset_stages"`
}
// RedriveSummary reports the plan/outcome of the reset half of a redrive.
type RedriveSummary struct {
Targets []RedriveTarget `json:"targets"`
DryRun bool `json:"dry_run"`
ResetRun bool `json:"reset_run"` // the re-translate ran (false on dry-run / no targets)
}
// Redrive re-attacks the FLAGGED chunks matching sel (D15.3 / D12 Step-Functions redrive). It
// NEVER touches DispOK work (D12): for each selected chunk it resets only the flagged stage and
// its downstream skipped stages — their chunk_status + checkpoints are deleted (ResetChunkStages),
// so the durable re-run re-derives them with a FRESH retry/escalation budget (a fresh provider
// call, not a deterministic replay of the flagged completion) while the upstream ok stages resume
// at $0. Money bills normally (reserve/settle+checkpoint); the previously-spent money on the
// discarded attempts stays committed (honest — it was really billed), so the ceilings remain
// accurate. Requires the CURRENT config to render the SAME snapshot the flagged rows carry (no
// --resnapshot): a config change makes the re-run fail loud with the resnapshot guidance instead
// of silently re-pricing the book. Returns the reset plan, and the re-run's BookResult (nil on
// dry-run or when nothing matched).
func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSummary, *BookResult, error) {
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return nil, nil, err
}
// Group by chunk and find the targets: a chunk with a FLAGGED stage matching the selector.
byChunk := map[chunkKey][]store.ChunkStatus{}
var order []chunkKey
for _, cs := range statuses {
k := chunkKey{cs.Chapter, cs.ChunkIdx}
if _, seen := byChunk[k]; !seen {
order = append(order, k)
}
byChunk[k] = append(byChunk[k], cs)
}
sort.Slice(order, func(i, j int) bool {
if order[i].chapter != order[j].chapter {
return order[i].chapter < order[j].chapter
}
return order[i].chunkIdx < order[j].chunkIdx
})
// Build the reset PLAN first — pure, no mutation — so the destructive reset happens only
// after the drift guard below clears (Task 1c). Each target resets its flagged stage AND its
// downstream skipped stages; upstream DispOK stages are never included (D12 — never re-pay ok
// work), which is the invariant the redrive tests mutation-lock.
summary := &RedriveSummary{DryRun: sel.DryRun}
for _, k := range order {
rows := byChunk[k]
targeted := false
reason := ""
for _, cs := range rows {
if cs.Disposition == string(DispFlagged) && sel.matches(cs) {
targeted, reason = true, cs.FlagReason
break
}
}
if !targeted {
continue
}
var stages []string
for _, cs := range rows {
if cs.Disposition == string(DispFlagged) || cs.Disposition == string(DispSkipped) {
stages = append(stages, cs.Stage)
}
}
sort.Strings(stages)
summary.Targets = append(summary.Targets, RedriveTarget{
Chapter: k.chapter, ChunkIdx: k.chunkIdx, FlagReason: reason, Stages: stages,
})
}
if sel.DryRun || len(summary.Targets) == 0 {
return summary, nil, nil
}
// Config/seed-drift guard BEFORE the destructive reset (external-review 1c). A redrive re-runs
// under the CURRENT config; if it drifted from the snapshot the stored rows carry, TranslateBook
// fails loud in runStage with the resnapshot guidance — but the OLD code had already deleted the
// flagged chunk_status rows + checkpoints (ResetChunkStages), so that fail-loud left the flag
// telemetry destroyed and a later `status` read the chunk as pending/pass.
//
// Re-seed the glossary from the seed FILE BEFORE the destructive reset — on BOTH paths, incl.
// --resnapshot (package №3 fix: the redrive-resnapshot wiring in cmd/tmctl exposed that the old code
// nested this seed inside `if !r.Resnapshot`, so under --resnapshot a seed-FILE edit that
// introduces a collision would fail loud only in TranslateBook's re-seed AFTER ResetChunkStages
// already deleted the flag telemetry — re-opening the external-review 1c torn-state class for the
// resnapshot path). seedGlossary is idempotent and touches only glossary rows (never
// chunk_status/checkpoints), so a seed error (e.g. a new shared-key collision) surfaces HERE,
// before any reset; TranslateBook re-seeds identically afterwards (no double cost). It also
// materializes r.memory so the snapshot comparison below matches what TranslateBook renders.
if err := r.seedGlossary(ctx); err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive seed glossary: %w", err)
}
// The bank read-out (row 125) follows the re-seed HERE, not only inside TranslateBook. Every guard
// below can abort the redrive after this point — the drift guard, the re-payment consent, the reset
// itself — and each of those exits leaves the bank re-seeded in the store while the exported file
// still described the previous one. A reader would then hold a bank the engine no longer has.
r.exportBank(ctx, "redrive/re-seeded")
// Config/seed-drift guard BEFORE the destructive reset (external-review 1c): if the current config
// drifted from the snapshot the stored rows carry, TranslateBook would fail loud in runStage — but
// the reset (ResetChunkStages) would already have deleted the flagged rows/checkpoints, so a later
// `status` reads the chunk as pending/pass. Refuse loud here instead. Skipped under --resnapshot
// (the operator explicitly accepts the re-pin/re-pay) — but ONLY the snapshot COMPARISON is skipped,
// never the seed-error-before-reset protection above.
if !r.Resnapshot {
// Per-wave drift (R1): a stored row carries its WAVE's snapshot (draft rows → draft-wave snapshot, edit
// rows → edit-wave snapshot), never the whole-pipeline one — so compare each row against the current
// projection of ITS wave (from the SEEDED r.memory seedGlossary just set, matching what the re-run
// will render). A whole-pipeline comparison would abort every redrive spuriously.
w1cur, _, e1 := r.snapshotIDForWave(waveDraft)
if e1 != nil {
return summary, nil, fmt.Errorf("pipeline: redrive draft-wave snapshot check: %w", e1)
}
w2cur, _, e2 := r.snapshotIDForWave(waveEdit)
if e2 != nil {
return summary, nil, fmt.Errorf("pipeline: redrive edit-wave snapshot check: %w", e2)
}
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
for _, cs := range statuses {
cur := w2cur
if draftStageNames[cs.Stage] {
cur = w1cur
}
if cs.SnapshotID != "" && cs.SnapshotID != cur {
return summary, nil, fmt.Errorf("pipeline: redrive aborted — the book rows are under snapshot %.12s but the current config/seed renders %.12s (the config/prompts/capabilities/seed-glossary changed): resetting now would re-pay for the book and destroy the flag telemetry; re-run `translate --resnapshot` to accept the re-payment explicitly, or revert the config/seed", cs.SnapshotID, cur)
}
}
}
// Consent to a RE-PAYMENT (D20.2-Q2, rebill.go) — BEFORE the destructive reset, for the same reason
// the drift guard is: a refusal must not leave the flag telemetry deleted (external-review 1c). It
// runs on BOTH paths, --resnapshot included: that flag skips the snapshot COMPARISON above, never
// the question of what the re-pin costs. The targets' own re-attack is not what this projects — that
// spend is the command's declared purpose — but the book-wide re-payment a snapshot move brings is.
rebillChunks, err := r.bookChunks()
if err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive re-bill projection: %w", err)
}
// nil scope: `redrive` does not take --max-units (invocation.go refuses it there), so there is no
// volume ceiling to narrow this projection by and the book-wide figure is the honest one.
if err := r.checkRebillConsent(ctx, rebillChunks, nil); err != nil {
return summary, nil, err
}
// Destructive reset — safe now that drift has been ruled out.
for _, t := range summary.Targets {
if err := r.Store.ResetChunkStages(r.Book.BookID, t.Chapter, t.ChunkIdx, t.Stages); err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", t.Chapter, t.ChunkIdx, err)
}
}
// Re-run the durable loop: reset chunks re-attack fresh; everything else resumes at $0.
res, err := r.TranslateBook(ctx)
summary.ResetRun = true
return summary, res, err
}