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 `.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): `.book.`, 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 }