1183 lines
64 KiB
Go
1183 lines
64 KiB
Go
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
|
||
}
|