textmachine/backend/internal/pipeline/status.go

430 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"fmt"
"sort"
"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 "прогресс — единственный реальный пробел"
// 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
)
// 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"`
ChunksTotal int `json:"chunks_total"`
ChunksDone int `json:"chunks_done"`
ChunksFlagged int `json:"chunks_flagged"`
ChunksInFlight int `json:"chunks_in_progress"`
ChunksPending int `json:"chunks_pending"`
StagesSkipped int `json:"stages_skipped"` // per-stage skip count (downstream of a flag)
Escalations int `json:"escalations"`
PostcheckMisses int `json:"postcheck_misses"` // confirmed post-check misses (retrieval_state)
WorstFlagReason string `json:"worst_flag_reason,omitempty"`
Verdict string `json:"verdict"` // pass | attention | fail (exp07: 0/1/≥2 flagged chunks)
CostUSD float64 `json:"cost_usd"`
}
// StatusReport is the whole-book read-model.
type StatusReport struct {
BookID string `json:"book_id"`
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"`
CurrentSnapshot string `json:"current_snapshot,omitempty"`
TotalChunks int `json:"total_chunks"`
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)
Escalations int `json:"escalations"`
PostcheckMisses int `json:"postcheck_misses"`
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 float64 `json:"projected_book_usd"` // committed extrapolated over the whole book
ETASeconds float64 `json:"eta_seconds,omitempty"` // secondary: mean fresh-call throughput × remaining
Chapters []ChapterPassport `json:"chapters"`
}
// flagReasonSeverity ranks flag reasons worst-first for the chapter's "worst flag" passport
// field. Lower = worse. Deterministic content-failures (refusal/echo/excision) outrank
// retryable budget symptoms (length/empty), which are the least alarming.
func flagReasonSeverity(reason string) int {
switch FlagReason(reason) {
case FlagHardRefusal, FlagSoftRefusal, FlagContentFilter, FlagHardBlock:
return 0
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
return 1
case FlagLoopDegenerate:
return 2
case FlagDecodeError:
return 3
case FlagGlossaryMiss:
return 4
case FlagLength, FlagEmpty:
return 5
}
return 6
}
// bookChunks re-derives the book's chunk manifest — Ingest + 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 source edit or a chunker-version change is reflected
// here (the manifest is CURRENT), which is exactly what makes a snapshot drift visible.
func (r *Runner) bookChunks() ([]Chunk, error) {
doc, err := Ingest(r.Book.SourceFile)
if err != nil {
return nil, err
}
return SplitChunks(doc.Chapters), nil
}
// chunkKey identifies a chunk positionally.
type chunkKey struct {
chapter, chunkIdx int
}
// resolveChunkState maps a chunk's per-stage rows to its chunk-level state, plus its cost,
// escalation flag and (when flagged) the flag reason. stagesTotal is the pipeline stage count.
func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) (st ChunkState, reason string, cost float64, escalated bool, skipped int) {
if len(rows) == 0 {
return ChunkPending, "", 0, false, 0
}
ok := 0
for _, cs := range rows {
cost += cs.CostUSD
if cs.Escalated {
escalated = true
}
switch cs.Disposition {
case string(DispOK):
ok++
case string(DispFlagged):
// The first (only) flagged stage decides the chunk; keep its reason.
st, reason = ChunkFlagged, cs.FlagReason
case string(DispSkipped):
skipped++
}
}
if st == ChunkFlagged {
return st, reason, cost, escalated, skipped
}
if ok == stagesTotal {
return ChunkDone, "", cost, escalated, skipped
}
return ChunkInProgress, "", cost, escalated, skipped
}
// 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) {
chunks, err := r.bookChunks()
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{}
snapSeen := map[string]bool{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
if cs.SnapshotID != "" {
snapSeen[cs.SnapshotID] = true
}
}
// Per-chunk post-check misses (retrieval_state) — the glossary-consistency signal that is
// re-derived each run and NOT a chunk_status disposition (default flagger mode).
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
return nil, err
}
missByChunk := map[chunkKey]int{}
for _, rs := range states {
missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss
}
stagesTotal := len(r.Pipeline.Stages)
// The post-check GATE (opt-in) flags a chunk 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 chunk as done/pass while `tmctl translate`
// exits 2 (finding #2). When the gate is ON, promote an otherwise-done chunk with a CONFIRMED
// post-check miss to flagged/glossary_miss so status matches the run.
gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
rep := &StatusReport{BookID: r.Book.BookID, TotalChunks: len(chunks)}
passports := map[int]*ChapterPassport{}
var chapterOrder []int
var processedCost float64 // spend of PROCESSED chunks (done+flagged) — the projection base (finding #7)
for _, ch := range chunks {
p := passports[ch.Chapter]
if p == nil {
p = &ChapterPassport{Chapter: ch.Chapter, Verdict: "pass"}
passports[ch.Chapter] = p
chapterOrder = append(chapterOrder, ch.Chapter)
}
p.ChunksTotal++
key := chunkKey{ch.Chapter, ch.ChunkIdx}
state, reason, cost, escalated, skipped := resolveChunkState(byChunk[key], stagesTotal)
p.CostUSD += cost
p.StagesSkipped += skipped
miss := missByChunk[key]
p.PostcheckMisses += miss
rep.PostcheckMisses += miss
if gateOn && state == ChunkDone && miss > 0 {
// Matches translateChunk: the gate flags only an otherwise-ok chunk. Not re-drivable
// (the miss re-derives from the unchanged glossary; a fix is a glossary edit =
// --resnapshot, not a redrive).
state, reason = ChunkFlagged, string(FlagGlossaryMiss)
}
if escalated {
p.Escalations++
rep.Escalations++
}
switch state {
case ChunkDone:
rep.Done++
p.ChunksDone++
case ChunkFlagged:
rep.Flagged++
p.ChunksFlagged++
if p.WorstFlagReason == "" || flagReasonSeverity(reason) < flagReasonSeverity(p.WorstFlagReason) {
p.WorstFlagReason = reason
}
case ChunkInProgress:
rep.InProgress++
p.ChunksInFlight++
case ChunkPending:
rep.Pending++
p.ChunksPending++
}
if state == ChunkDone || state == ChunkFlagged {
processedCost += cost // a fully-attempted chunk's cost feeds the projection
}
}
// Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail).
sort.Ints(chapterOrder)
for _, n := range chapterOrder {
p := passports[n]
switch {
case p.ChunksFlagged >= 2:
p.Verdict = "fail"
case p.ChunksFlagged == 1:
p.Verdict = "attention"
default:
p.Verdict = "pass"
}
rep.Chapters = append(rep.Chapters, *p)
}
if rep.TotalChunks > 0 {
rep.PercentDone = 100 * float64(rep.Done) / float64(rep.TotalChunks)
}
if len(snapSeen) == 1 {
for s := range snapSeen {
rep.Snapshot = s
}
} else if len(snapSeen) > 1 {
rep.SnapshotDrift = true
}
// Config-drift (finding #3): compute the CURRENT config's snapshot READ-ONLY (materialize
// the STORED glossary — no re-seed, no write) and compare to the single snapshot the stored
// rows carry. A wire/verdict-affecting config edit since the last run (a prompt bump, a gate
// flip — this very package bumps the editor prompt_version) is otherwise hidden behind
// "done/pass" until translate forces a --resnapshot. Only meaningful when a single snapshot
// is on record (multi-snapshot is already flagged as SnapshotDrift). Note: a change to the
// seed FILE that was not re-run is NOT detected here (the stored glossary is what status
// projects); it surfaces on the next translate's re-seed.
if rep.Snapshot != "" {
if rows, gerr := r.Store.GlossaryForBook(r.Book.BookID); gerr == nil {
r.memory = materializeMemory(rows, gateOn)
if curSnap, _, serr := r.snapshotID(); serr == nil && curSnap != rep.Snapshot {
rep.ConfigDrift = true
rep.CurrentSnapshot = curSnap
}
}
}
// Money.
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return nil, err
}
rep.CommittedUSD, rep.ReservedUSD = committed, reserved
rep.BookCeilingUSD = r.Book.Ceilings.BookUSD
if rep.BookCeilingUSD > 0 {
rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD
}
// Projected book cost: extrapolate the per-PROCESSED-chunk average over the whole book
// (done + flagged = a chunk fully attempted). Uses processedCost, NOT book committed:
// committed also carries partial spend on IN-PROGRESS chunks (excluded from the denominator),
// which would over-estimate (finding #7). The clean average × total is the honest estimate.
processed := rep.Done + rep.Flagged
if processed > 0 {
rep.ProjectedBookUSD = processedCost / float64(processed) * float64(rep.TotalChunks)
}
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID)
if err != nil {
return nil, err
}
remaining := rep.TotalChunks - processed
if processed > 0 && freshCalls > 0 && remaining > 0 {
meanMSPerChunk := float64(totalMS) / float64(processed)
rep.ETASeconds = meanMSPerChunk * float64(remaining) / 1000
}
return rep, 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
})
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
}
// Reset the flagged stage AND its downstream skipped stages; keep upstream DispOK.
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 {
if err := r.Store.ResetChunkStages(r.Book.BookID, k.chapter, k.chunkIdx, stages); err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", k.chapter, k.chunkIdx, err)
}
}
}
if sel.DryRun || len(summary.Targets) == 0 {
return summary, nil, nil
}
// 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
}