textmachine/backend/cmd/tmctl/render.go

829 lines
44 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 main
import (
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"unicode/utf8"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
)
// render.go: the human/JSON output renderers of the commands, extracted from main.go into
// pure functions over the pipeline/store structs (package #4). Every output byte is a
// FROZEN contract (stable enums in --json — D12; the human tables are an audit
// surface D20.4): fmt.Printf was replaced with fmt.Fprintf(w, …) with
// w=os.Stdout — byte-for-byte; every deliberate change is listed in
// PROGRESS (one in this package: report carries ch/chunk/model_requested/err —
// the post-mortem of a failed call used to be an "empty string", the pain of a smoke run).
// The ledger callbacks preserve the EXACT order "print → read SpentUSD → print"
// of the original code: hoisting the read above the render would change the partial output on error.
// gapMarker is the IN-TEXT marker for a unit that ships text with a piece MISSING — the c-lite
// member drop, where the editor edited the unit's clean members and left a flagged member's text
// out entirely. Without it the reader gets a seamless concatenation with a chunk-sized hole in it
// and no way to know: the hole has no seam, because the editor rewrote the remainder around it.
//
// WHY IT LIVES IN THE RENDERER and not in the export projection, where the deterministic chapter
// title is applied. The title is part of the BOOK and must reach the platform; this marker is
// metadata ABOUT the text, and the wire decides `translated` vs `withheld` by asking whether the
// unit's text is EMPTY (runevents UnitDone.Shipped ← oc.FinalText != ""). A marker written into
// that text would turn every withheld unit into a translated one and break the very distinction
// the contract promises. So it is applied one layer further out, where nothing but a human reads.
//
// WHY IT IS NOT TARGET-LANGUAGE TEXT. Every banner on this surface is English operator vocabulary
// («leak cleaned, verify», «not translated, flagged for a human»), and this surface is an AUDIT
// concatenation — it interleaves per-unit banners with prose, so it is read by the operator, not
// sold to a reader. Keeping the marker in that same vocabulary makes it pair-independent by
// construction: a target language with no langpack at all gets byte-identical output, and there is
// no per-pair string to forget. A target-language marker would belong to the reader-facing artifact
// the PLATFORM builds, and that is not this file.
//
// ⚠ THE GUARD AT BOTH CALL SITES (FinalText != "") IS LOAD-BEARING. Members are counted whenever they
// drop, INCLUDING when every member of the unit drops — and then the unit ships nothing at all. Saying
// «a fragment is missing from the text below» over an empty body would be a second false statement,
// pointing at text that does not exist; the honest banner there is the one that already existed,
// «not translated, flagged for a human». The marker is for a unit that ships SOME of its text.
// ⚠ THE REASON IS THE DROP'S OWN, never the unit's FlagReason. A unit whose edit flagged for a
// cosmetic sanitizer strip AND also lost a member carries the STRIP as its FlagReason; printing that
// as the cause of the hole tells the reader a clean-up ate a chunk of the book — the same false claim
// the banner used to make, moved into the prose. The banner still shows the unit's verdict; the marker
// shows the hole. They are different facts and are now carried by different fields.
func gapMarker(dropped int, reason string) string {
frag, verb := "fragment", "is"
if dropped != 1 {
frag, verb = "fragments", "are"
}
m := fmt.Sprintf("[⚠ TEXT MISSING — %d source %s of this unit could not be translated and %s NOT in the text below",
dropped, frag, verb)
if reason != "" {
m += ": " + reason
}
return m + "]"
}
// renderTranslate prints the per-chunk translation report and returns the
// CompletedWithFlags sentinel when chunks were flagged (exit 2).
func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
for _, ch := range res.Chunks {
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
switch {
case ch.Disposition == pipeline.DispOK:
fmt.Fprintln(w, ch.FinalText)
case ch.DroppedMembers > 0 && ch.FinalText != "":
// c-lite member drop: the editor shipped the CLEAN members and left a flagged member's
// text out. The text below is real and paid for, but it is INCOMPLETE, and saying
// «leak cleaned» here — which this branch used to do for every flagged-with-text unit,
// whatever the reason — told the reader the opposite of what happened.
fmt.Fprintf(w, "[FLAG %s] ↓\n", ch.FlagReason)
fmt.Fprintln(w, gapMarker(ch.DroppedMembers, string(ch.DroppedReason)))
fmt.Fprintln(w, ch.FinalText)
case ch.FlagReason == pipeline.FlagSanitizerStripped && ch.FinalText != "":
// Cosmetic sanitizer strip (D35.4a): the leak was removed and the remainder exported,
// but the chunk stays flagged for a human to verify the auto-clean — not lost to an
// empty placeholder (ch5/ch20 chapter openers used to drop whole for a leading «###»).
fmt.Fprintf(w, "[FLAG %s — leak cleaned, exported cleaned, verify] ↓\n", ch.FlagReason)
fmt.Fprintln(w, ch.FinalText)
case ch.FinalText != "":
// Flagged, with text, and neither of the two known causes. The engine produces no such
// unit today; printing a neutral banner keeps an unforeseen one from inheriting either
// of the specific claims above.
fmt.Fprintf(w, "[FLAG %s — verify] ↓\n", ch.FlagReason)
fmt.Fprintln(w, ch.FinalText)
default:
fmt.Fprintf(w, "[FLAG %s] chunk not translated — draft/edit unusable, flagged for a human\n", ch.FlagReason)
}
for _, st := range ch.Stages {
how := "call"
switch {
case st.Disposition == pipeline.DispSkipped:
how = "skipped"
case st.FromResume:
how = "resume"
}
fmt.Fprintf(w, " %-8s %-22s %-8s %-8s%s $%.6f (cum $%.6f) in=%d (cached=%d) out=%d+%d att=%d %dms finish=%s\n",
st.Stage, st.Model, how, st.Disposition, flagSuffix(st.FlagReason),
st.CostUSD, st.CumCostUSD,
st.Usage.PromptTokens, st.Usage.CachedTokens,
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.Attempts, st.LatencyMS, st.FinishReason)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "TOTAL (this run): $%.6f — chunks %d, flags %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
committed, reserved, err := ledger()
if err != nil {
return err
}
fmt.Fprintf(w, "Book ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
// The stop NAMES its ceiling. A run bounded by --max-units ends with exit 0 like any other completed
// run, so without this line the operator sees "TOTAL … chunks 3" on a hundred-unit book and has no way
// to tell "the volume I bought is done" from "something went quietly wrong" — the silent-limit
// complaint D39.165 §1б took from OpenHands. It names the ceiling, what was delivered as against
// re-made, and what remains of each kind, so the next action is readable off the line itself.
if v := res.Volume; v != nil {
fmt.Fprintf(w, "VOLUME CEILING: %s\n", v)
fmt.Fprintf(w, " (this is a COMPLETION, not a pause — the run did what it was granted; the money ceiling is a separate stop with its own exit code.)\n")
// ⚠ The invitation to buy more is spoken ONLY about units that were never delivered. The first
// version said "run again to buy more" unconditionally, which on a fully-translated book with a
// moved bank offered the reader chapters they already own — re-payment presented as stock.
//
// Both remainders are reported when both exist. An if/else here would have printed the fresh half
// and silently dropped the other, leaving an operator who has BOTH kinds pending believing the
// first number was all of it.
if v.LeftFresh > 0 {
fmt.Fprintf(w, " Next: %d output unit(s) of this book have never been delivered — `--max-units` again to buy them (units, NOT chapters: a chapter can be several units; the manifest is what converts).\n", v.LeftFresh)
}
if v.LeftRework > 0 {
if v.LeftFresh > 0 {
fmt.Fprintf(w, " Also pending, and NOT a purchase: %d already-delivered unit(s) carry a superseded snapshot and would be RE-MADE. Buying them delivers no new chapter.\n", v.LeftRework)
} else {
fmt.Fprintf(w, " Next: every unit of this book HAS been delivered; the %d left carry a superseded snapshot and would be RE-MADE, not delivered. That is a re-pass, not a purchase of new book.\n", v.LeftRework)
}
}
if v.Delivered == 0 && v.Reworked > 0 {
fmt.Fprintf(w, " ⚠ This run delivered NO new unit: all %d paid unit(s) were re-made under a moved snapshot.\n", v.Reworked)
}
}
// Exit code 2 is a typed sentinel, not an infra error: the report above is
// already on stdout; main() maps this to a non-zero exit for the operator.
if res.Flagged > 0 {
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
}
return nil
}
// renderSignatureStop prints the operator-facing bank-mining signature-stop banner (R1-FL-A): the run
// paused before the edit wave because the delta holds terms no earlier stop presented (D39.144 flag
// model). It surfaces the term count + the signature-map path + the resume path (decide whatever you
// wish via `tmctl bank-apply`, then re-run — the stop will not re-fire on what it just showed).
// Distinct from a flag/crash — main() maps the sentinel to exit 3.
func renderSignatureStop(w io.Writer, s *pipeline.WaveSignatureStop) {
// s.Terms is the size of the WHOLE map — new and previously-presented-but-undecided together. Under
// the flag model the map deliberately mixes the two, so the count must not be labelled «new»
// (review-workflow finding: run 2 of the flag chain stops with 2 terms of which 1 is new).
fmt.Fprintf(w, "=== BANK-MINING: STOP FOR SIGNATURE (%d term(s) in the map) ===\n", s.Terms)
fmt.Fprintf(w, "The run STOPPED before the edit wave: the mined delta holds term(s) no stop has shown you before.\n")
renderBankStopRows(w, s.Rows)
fmt.Fprintf(w, "Signature map: %s\n", s.SignaturePath)
if s.TablePath != "" {
fmt.Fprintf(w, "Full table: %s\n", s.TablePath)
}
fmt.Fprintln(w, "Next: review the map, decide as much or as little as you wish (`tmctl bank-apply` — one act over the")
fmt.Fprintln(w, "whole bank, one term, or nothing), then re-run `tmctl translate`. The run will NOT stop again on the")
fmt.Fprintln(w, "terms above — only on new ones — and whatever you leave undecided rides to the editor marked ⟨проверить⟩.")
// The ⟨проверить⟩ mentions here and in renderStatus's unsigned-bank line NAME the ru target's unverified marker for the OPERATOR — not a
// model-facing wire string (that canon is injection.txt / embedded.UnverifiedMarker). Bounded row-89 leak: a
// non-ru target would still print the ru glyph in this help text; not worth plumbing the per-target marker in.
fmt.Fprintln(w, "To run WITHOUT this pause, drop --verify-bank: the run then carries the unsigned bank forward marked ⟨проверить⟩.")
}
// bankStopStdoutCap bounds the stdout table. The emission cap is 200 terms (miner.emitRankCap), and 200
// blocks of source contexts is a scroll, not a review surface — the FULL table always lands in the
// sidecar, and the banner says so, so the cap can never read as "that was all of it".
const bankStopStdoutCap = 20
// renderBankStopRows prints the bank verification table D39.36 specified — src · proposed dst · frequency ·
// variant spread · evidence — capped, with the omitted count stated out loud.
func renderBankStopRows(w io.Writer, rows []pipeline.BankStopRow) {
if len(rows) == 0 {
return
}
// THE ONLY use the confidence is allowed to have (D39.102): the capped stdout view is the REVIEW surface,
// so it is ordered least-sure-first — an ordinal inside one model's reply and nothing more. It is not a
// weight, not a threshold, not comparable between models, and it never decides which rendering wins; the
// full sidecar keeps the source-key order, because that is the reference document.
shown := append([]pipeline.BankStopRow(nil), rows...)
sort.SliceStable(shown, func(i, j int) bool { return reviewRank(shown[i]) < reviewRank(shown[j]) })
if len(shown) > bankStopStdoutCap {
shown = shown[:bankStopStdoutCap]
}
fmt.Fprintln(w, "")
fmt.Fprintln(w, " (least-confident first — the role's own ordering of what to read)")
fmt.Fprintln(w, " src proposed dst origin freq spread conv conf")
for _, r := range shown {
fmt.Fprintf(w, " %-20s %-25s %-9s %5d %6d %5d %5s\n", trunc(r.Src, 20), trunc(dashIfEmpty(r.Dst), 25),
r.Origin, r.Freq, r.Spread, r.Conventions, confCell(r.Conf))
// WHY this row has this dst — the question the artifacts could not answer before the fix-pack. The
// signals are the ranking factors that fired for the winning variant; "invented" says the rendering is
// the role's own, not one the drafts proposed (legitimate — it saw the whole book — and worth a look).
if len(r.Signals) > 0 || r.Invented {
why := strings.Join(r.Signals, ", ")
if r.Invented {
why = strings.TrimPrefix(why+" · invented (no draft proposed it)", " · ")
}
fmt.Fprintf(w, " why: %s\n", trunc(why, 90))
}
if len(r.Contradicts) > 0 {
// A contradiction against this run's OWN consolidations: the compound dropped the rendering the
// same reply gave its part. Loudest line of the row — it is a canon breaking inside one call.
fmt.Fprintf(w, " ⚠ contradicts: %s\n", trunc(strings.Join(r.Contradicts, "; "), 80))
}
if len(r.Variants) > 1 {
// The disagreement is the reason to sign: one term coming back several ways is exactly what a
// canon exists to close, and hiding it behind one winner throws that reason away.
fmt.Fprintf(w, " drafts: %s\n", trunc(strings.Join(r.VariantLabels(), " | "), 90))
}
if len(r.Contexts) > 0 {
fmt.Fprintf(w, " ctx: %s\n", trunc(r.Contexts[0], 90))
}
}
if len(rows) > len(shown) {
fmt.Fprintf(w, " … %d more term(s) — the full table is in the sidecar below\n", len(rows)-len(shown))
}
fmt.Fprintln(w, "")
}
// reviewRank orders the review surface: rows the role was least sure of first, rows it said nothing about
// after them (silence is not a low score), and rows it never consolidated last.
func reviewRank(r pipeline.BankStopRow) int {
switch {
case r.Dst == "":
return 1000
case r.Conf < 0:
return 200
default:
return r.Conf
}
}
// confCell renders the role's stated confidence, or "—" when the reply carried none (a negative). Printing
// a bare 0 for both would hide the single most important row on the sheet — the one the role itself said it
// was least sure of — behind the rows it never mentioned.
func confCell(conf int) string {
if conf < 0 {
return "—"
}
return strconv.Itoa(conf)
}
// trunc shortens s to n runes with an ellipsis (rune-safe, so a CJK surface is never split mid-character).
func trunc(s string, n int) string {
rs := []rune(s)
if len(rs) <= n {
return s
}
if n <= 1 {
return "…"
}
return string(rs[:n-1]) + "…"
}
// flagSuffix renders a non-empty flag reason as "(reason)".
func flagSuffix(r pipeline.FlagReason) string {
if r == "" {
return ""
}
return "(" + string(r) + ")"
}
// renderReport prints the request_log table, the flag/memory/style sections and
// the ledger line. Deliberate package-#4 change: the ch/chunk columns, the model with
// a fallback to the REQUESTED one (a failed call has an empty model_actual — the row was
// anonymous) and the degraded/err tail — the DB has stored these columns since Milestone 2, the printer
// dropped them, and the post-mortem needed digging into sqlite3.
//
// All three store reads are CALLBACKS, like ledger: the historical report read and
// printed interleaved, and a read failure mid-audit left the already-printed part on stdout;
// hoisting the reads above the render would silently change that partial
// output to empty (a self-review finding — the same principle documented
// below for the ledger line).
func renderReport(w io.Writer,
fetchRows func() ([]store.RequestLogView, error),
fetchFlags func() ([]store.ChunkStatus, error),
fetchStates func() ([]store.RetrievalState, error),
ledger func() (committed, reserved float64, err error)) error {
rows, err := fetchRows()
if err != nil {
return err
}
fmt.Fprintf(w, "%-20s %-4s %-5s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s %s\n",
"ts", "ch", "chunk", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok", "err")
for _, row := range rows {
model := row.ModelActual
if model == "" && row.ModelRequested != "" {
model = row.ModelRequested + "(req)"
}
fmt.Fprintf(w, "%-20s %-4d %-5d %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d %s\n",
row.TS, row.Chapter, row.ChunkIdx, row.Stage, row.Role, model, row.PromptTokens, row.CachedTokens,
row.CacheCreationTokens, row.CompletionTokens, row.ReasoningTokens, row.CostUSD,
row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err))
}
// Estimated-spend legend (pack-13 point-9, research/21 §1.10): rows whose cost_usd is a reservation
// ESTIMATE (a billed decode failure, or a paid 2xx with zero usage), not a provider-reported cost.
// Surfacing the share keeps an estimate from looking like a real zero-token call; est_tokens is the
// display-only fertility output estimate. Prints only when there is at least one such row.
var estRows, estTokens int
var estUSD float64
for _, row := range rows {
if row.Estimated == 1 {
estRows++
estUSD += row.CostUSD
estTokens += row.EstTokens
}
}
if estRows > 0 {
fmt.Fprintf(w, "estimated-cost rows: %d ($%.6f settled at the reservation estimate, not provider-reported; ~%d est. output tokens via fertility, display-only)\n", estRows, estUSD, estTokens)
}
// Flag section (Milestone 2): every chunk×stage whose disposition ≠ ok — the
// "flag for the editor" the plan requires (02-mvp Phase-1 acceptance allows N).
flags, err := fetchFlags()
if err != nil {
return err
}
headerPrinted := false
for _, f := range flags {
if f.Disposition == "ok" {
continue
}
if !headerPrinted {
fmt.Fprintf(w, "\n=== FLAGS (disposition ≠ ok) ===\n")
fmt.Fprintf(w, "%-4s %-6s %-8s %-9s %-16s %5s %10s %s\n",
"ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail")
headerPrinted = true
}
fmt.Fprintf(w, "%-4d %-6d %-8s %-9s %-16s %5d %10.6f %s\n",
f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
}
// Memory section (step 4): the per-chunk retrieval-state, surfaced so silent glossary
// degradation is LOUD. The aggregate line always prints when a glossary is in use;
// each chunk with a post-check miss is listed (the flagger-mode signal — the model
// ignored an approved term, or we injected the wrong dst) with the offending src→dst.
states, err := fetchStates()
if err != nil {
return err
}
if len(states) > 0 {
var injected, sticky, ambiguous, spoiler, evicted, misses, style int
for _, rs := range states {
injected += rs.NExactHits
sticky += rs.NSticky
ambiguous += rs.NAmbiguousFlagged
spoiler += rs.NSpoilerBlocked
evicted += rs.NEvicted
misses += rs.NPostcheckMiss
style += rs.NStyleFlags
}
fmt.Fprintf(w, "\n=== MEMORY (retrieval-state) ===\n")
fmt.Fprintf(w, "injections(exact)=%d sticky=%d ambiguous=%d spoiler-blocked=%d evicted=%d post-check-misses=%d\n",
injected, sticky, ambiguous, spoiler, evicted, misses)
printed := false
for _, rs := range states {
if rs.NPostcheckMiss == 0 {
continue
}
if !printed {
fmt.Fprintf(w, "%-4s %-6s %6s %s\n", "ch", "chunk", "misses", "detail (src→dst not found in output)")
printed = true
}
fmt.Fprintf(w, "%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
}
// Banknote channel (pack-20 / D39.42 п.4): the draft-side WHAT proposals are counted per chunk in
// retrieval_state, but until now NO surface printed them — the cold run of 31.07 had to read them
// out of the DB by hand to answer "did the channel produce anything, and what did the parser
// reject". Chunk coverage matters as much as the total: 0 lines on most chunks is a channel
// failure that a healthy grand total can hide. Printed only when the channel produced or rejected
// something, so a banknote-off book's report stays byte-identical.
var bankLines, bankChunks, bankParseFail, bankTruncated int
for _, rs := range states {
bankLines += rs.NBanknoteLines
if rs.NBanknoteLines > 0 {
bankChunks++
}
if rs.BanknoteParseFail != 0 {
bankParseFail++
}
if rs.BanknoteTruncated != 0 {
bankTruncated++
}
}
if bankLines > 0 || bankParseFail > 0 || bankTruncated > 0 {
fmt.Fprintf(w, "\n=== BANKNOTE (draft-side WHAT channel, observability) === lines=%d over %d/%d chunk(s) · parse-fail chunks=%d · truncated chunks=%d\n",
bankLines, bankChunks, len(states), bankParseFail, bankTruncated)
}
// Cheap style/number flaggers (observability, not gates): total + per-chunk detail.
fmt.Fprintf(w, "\n=== STYLE GATES (observability: dialogue-dashes, ё, translit-interjections, 万/億 magnitudes, reflow-regression) — total %d ===\n", style)
stylePrinted := false
for _, rs := range states {
if rs.NStyleFlags == 0 {
continue
}
if !stylePrinted {
fmt.Fprintf(w, "%-4s %-6s %6s %s\n", "ch", "chunk", "flags", "detail")
stylePrinted = true
}
fmt.Fprintf(w, "%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NStyleFlags, rs.StyleDetail)
}
}
committed, reserved, err := ledger()
if err != nil {
return err
}
fmt.Fprintf(w, "\nBook ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
return nil
}
// errTail collapses the degraded/err columns into one bounded table tail (empty
// when the call was clean). Truncation is on a rune boundary: err carries raw chunks
// of provider bodies (CJK/Cyrillic), a byte slice would print broken UTF-8
// (a self-review finding).
func errTail(degraded, errText string) string {
s := degraded
if errText != "" {
if s != "" {
s += " "
}
s += errText
}
if len(s) > 120 {
cut := 120
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
s = s[:cut] + "…"
}
return s
}
// renderQuality prints the deterministic per-run quality-report (D39 layer 5): the claim-1 structural
// KPI (mean sentences per narrative paragraph) plus the aggregated deterministic signals
// (dialogue-dash, glossary consistency, number drift, CJK-leak / echo rates, trust-gated). Pure
// observability — a summary section, never an exit code. Kept SEPARATE from renderReport so that
// function's frozen callback contract (render_test.go) is untouched.
func renderQuality(w io.Writer, q *pipeline.QualityReport) error {
fmt.Fprintf(w, "\n=== QUALITY (per-run, deterministic signals — observability, not a gate) ===\n")
fmt.Fprintf(w, "total units=%d · reached final=%d · with export text=%d\n", q.TotalUnits, q.ProcessedUnits, q.TextUnits)
// Claim-1: choppy paragraphs. ≈1.0 sentences/paragraph = choppy (the owner's complaint); higher = merged prose.
fmt.Fprintf(w, "STRUCTURE (claim-1 «choppy paragraphs»): sentences/narrative-paragraph=%.2f (sentences=%d / narrative-paragraphs=%d)\n",
q.MeanSentPerNarrPara, q.NarrativeSentences, q.NarrativeParagraphs)
fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d · degenerate-loops=%d\n",
q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated, q.DegenerateLoopRuns)
// Echo split (D39.18): draft = the translator echoed (incl. dropped c-lite members) / edit = the editor
// echoed in what was delivered. Cosmetic-strip is per unit (the sanitizer runs on the final stage).
// The recovered share is spelled out rather than hidden: an echo the escalation fixed still measures
// the translator (the D18 mine is live), but it cost the book nothing beyond the wasted primary call.
recovered := ""
if q.EchoDraftRecovered > 0 {
recovered = fmt.Sprintf(", %d recovered by escalation", q.EchoDraftRecovered)
}
fmt.Fprintf(w, "STRIPS/ECHO: cosmetic-strip units=%d (%.1f%%, markdown+CJK) · echo draft=%d (%.1f%%%s) · echo edit=%d (%.1f%%)\n",
q.CosmeticStripUnits, 100*q.CosmeticStripRate, q.EchoDraftChunks, 100*q.EchoDraftRate, recovered, q.EchoEditUnits, 100*q.EchoEditRate)
// The UNSIGNED-BANK line (pack-20 / D39.42 п.4): in the auto mode part of the bank the model is shown
// carries renderings nobody approved, and that has to be visible rather than implied. The follow rate
// is NOT a quality verdict — the unverified section explicitly grants the model the right to translate
// otherwise — it is a measurement of the channel. Printed only when there is an unsigned bank at all,
// so a fully signed book's report is byte-identical to before.
if q.UnsignedBankTerms > 0 || q.UnverifiedShown > 0 {
followed := ""
if q.UnverifiedShown > 0 {
followed = fmt.Sprintf(" (%.0f%%)", 100*float64(q.UnverifiedFollowed)/float64(q.UnverifiedShown))
}
// "row-showings", not "units": the counter increments once per unsigned ROW put in front of the
// model, and one unit can show several — labelling it a unit count overstates the exposure.
fmt.Fprintf(w, "UNSIGNED BANK: terms=%d · row-showings=%d · model followed=%d%s — proposals, not canon; review the signature map beside the DB and decide via `tmctl bank-apply`\n",
q.UnsignedBankTerms, q.UnverifiedShown, q.UnverifiedFollowed, followed)
}
// The pack-19 flaggers (D39.55). They were aggregated into the report struct from the start but no
// surface ever PRINTED them, so the only run that had them on could not read its own axes. Counts are
// meaningless without their denominators (2 flags out of 3 attributed replies is a different fact from
// 2 out of 300), so the line always carries them. Axis D is reported apart from the AC total because
// its addressee comes from a heuristic, and the rule version travels with the numbers (the voice gate
// is deliberately not snapshot-folded). Spoiler leaks sit here too: the one safety signal of the four.
// Printed only when the flagger actually ran, so a book without voice content is byte-identical.
if q.VoiceCheckVersion != "" || q.VoiceFlags > 0 || q.SpoilerLeaks > 0 {
fmt.Fprintf(w, "VOICE (axes AC, observability): flags=%d over attributed=%d of replies=%d · axis-D pair-register=%d · spoiler-leaks=%d · rules=%s\n",
q.VoiceFlags, q.VoiceAttributed, q.VoiceReplies, q.VoicePairRegister, q.SpoilerLeaks, dashIfEmpty(q.VoiceCheckVersion))
}
// Addressable-defect RESIDUAL (pack-16): how many defects a repair loop could actually attack in what
// this run shipped — the $0 measurement that decides whether enabling the paid loop is worth it. The
// line is printed only when something fired, so a clean book's report is unchanged.
// Money-side label provenance: hop COUNT (the per-unit boolean cannot count them) and the per-model
// spend split. Printed only when there is something to say, so a clean report is unchanged.
if q.EscalationHops > 0 || len(q.SpendByModel) > 0 || len(q.ContentLabels) > 0 {
parts := make([]string, 0, len(q.SpendByModel))
for _, m := range sortedFloatKeys(q.SpendByModel) {
parts = append(parts, fmt.Sprintf("%s=$%.6f", m, q.SpendByModel[m]))
}
line := fmt.Sprintf("ROUTING/MONEY: escalation hops=%d · spend by model: %s", q.EscalationHops, strings.Join(parts, " · "))
if len(q.ContentLabels) > 0 {
line += fmt.Sprintf(" · labels=%s · routing=%s", strings.Join(q.ContentLabels, ","), strings.Join(q.Routing, " · "))
}
fmt.Fprintln(w, line)
}
if q.RepairCandidates > 0 {
parts := make([]string, 0, len(q.RepairCandidatesByClass))
for _, cls := range sortedKeys(q.RepairCandidatesByClass) {
parts = append(parts, fmt.Sprintf("%s=%d", cls, q.RepairCandidatesByClass[cls]))
}
fmt.Fprintf(w, "REPAIR RESIDUAL: addressable defects=%d (%s) — deterministic candidates in the shipped text\n",
q.RepairCandidates, strings.Join(parts, " · "))
}
if q.RepairCalls > 0 {
fmt.Fprintf(w, "REPAIR LOOP: calls=%d · applied=%d · declined=%d (our flag was wrong) · rejected=%d (guard/re-gate refused)\n",
q.RepairCalls, q.RepairApplied, q.RepairDeclined, q.RepairRejected)
}
return nil
}
// sortedKeys renders a map's keys in a stable order (a report must not depend on map iteration).
// sortedFloatKeys is sortedKeys for a money map — one helper per value type keeps the render
// deterministic without reaching for generics (least mechanism; both maps are tiny).
func sortedFloatKeys(m map[string]float64) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedKeys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// renderExport prints the read-only export projection (D39 layer 6 / D39.2-open №1): every chunk's
// FINAL export text EXACTLY as `tmctl translate` ships it (export-normalised), for the polygon
// extractor / reader-samples to read the same bytes the backend does instead of the raw checkpoint.
// The DEFAULT is stable JSON (the machine surface the extractor consumes); --plaintext emits the human
// concatenation (a per-chunk header banner + the text, flagged chunks marked). Pure $0 read — never an
// exit-2 sentinel (unlike translate/status): export is an audit projection, not a run verdict.
func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error {
if asPlaintext {
// Manifest/drift summary first (F3/F4): a partial or drifted book is EXPLICIT, not silently
// exported as complete.
// The header counts WITHHELD units separately. It used to fold them into `exported`
// (exported = total pending), so a book that shipped nothing for two units still
// announced them as exported — the same lie as the mislabelled banner below, told in
// numbers: a reader who trusts the summary never learns to look.
// The predicate is pipeline.UnitHole — ONE definition shared with the book writer, so the
// numbers this header prints and the holes `tmctl build` marks can never disagree.
withheld, incomplete := pipeline.HoleCounts(exp)
// `incomplete` is a SUBSET of `exported`, not a fourth part of the total: those units DID ship
// text, with a piece of it missing. Spelled that way so a reader adding the numbers up is not
// misled into thinking they partition the book.
fmt.Fprintf(w, "# export %s — total units=%d, exported=%d (of them incomplete=%d), withheld=%d, pending=%d",
exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits-withheld, incomplete, withheld, exp.PendingUnits)
if exp.GhostRows > 0 {
fmt.Fprintf(w, ", ghost-rows-dropped=%d", exp.GhostRows)
}
if exp.ConfigDrift {
fmt.Fprintf(w, " ⚠ CONFIG-DRIFT (the current config renders a different snapshot — a gate/stage may have changed since the run; %.12s)", exp.CurrentSnapshot)
}
fmt.Fprintln(w)
for _, ce := range exp.Chunks {
switch {
case ce.Disposition == "pending":
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — pending (not yet translated) ===\n", ce.Chapter, ce.ChunkIdx)
case ce.Disposition == string(pipeline.DispOK):
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d ===\n", ce.Chapter, ce.ChunkIdx)
case ce.DroppedMembers > 0 && ce.FinalText != "":
// c-lite member drop: real text, but a member chunk's worth of it is MISSING. The
// banner says so and the marker repeats it INSIDE the text stream, because a reader
// scrolling prose passes the banner once and the hole has no seam of its own.
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (INCOMPLETE) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
fmt.Fprintln(w, gapMarker(ce.DroppedMembers, ce.DroppedReason))
case ce.FlagReason == string(pipeline.FlagSanitizerStripped) && ce.FinalText != "":
// Cosmetic sanitizer strip (D35.4a): auto-cleaned remainder, flagged for a human.
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (leak cleaned, verify) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
case ce.FinalText != "":
// Flagged, with text, neither known cause — a neutral banner rather than an
// inherited claim (see renderTranslate for the same reasoning).
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (verify) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
default:
// Substantive flag / upstream skip: no export text (D2 — contaminated output never ships).
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (not translated, flagged for a human) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
}
fmt.Fprintln(w, ce.FinalText)
}
return nil
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(exp)
}
// flagParen renders a non-empty flag reason as " (reason)" for the plaintext export banner.
func flagParen(r string) string {
if r == "" {
return ""
}
return " (" + r + ")"
}
// renderBuild prints the build report as indented JSON: the versioned envelope every JSON output of the
// engine carries (17-seam-inbound-law п.3), and the surface the platform's export door will read the
// file paths from. JSON only: the consumer is a caller that opens the files, and the human reads paths
// out of JSON as well as out of prose.
func renderBuild(w io.Writer, rep *pipeline.BuildReport) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(rep)
}
// renderManifest prints the persisted chapter/chunk manifest (row 100). The default is a human summary —
// a 2283-chapter tree is not something anyone reads in a terminal — and --json emits the document itself,
// byte-identical to the sidecar, so a consumer can pipe it instead of knowing where the file lives.
func renderManifest(w io.Writer, m *pipeline.BookManifest, path string, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(m)
}
fmt.Fprintf(w, "=== MANIFEST: %s ===\n", m.BookID)
fmt.Fprintf(w, "chapters=%d units=%d chunks=%d\n", m.ChaptersTotal, m.UnitsTotal, m.ChunksTotal)
fmt.Fprintf(w, "source: %d bytes (sha256 %.12s) · encoding %s · %s→%s\n",
m.SourceBytes, m.SourceSHA256, m.Encoding, m.SourceLang, m.TargetLang)
fmt.Fprintf(w, "cut by: %s · validity key %.12s\n", m.ChunkerVersion, m.Key)
fmt.Fprintf(w, "written: %s\n", path)
return nil
}
// renderStatusJSON emits the StatusReport as indented JSON (stable enums — the
// ratified CI/IDE contract, D12/D15.3) and maps flagged>0 to the exit-2 sentinel.
func renderStatusJSON(w io.Writer, rep *pipeline.StatusReport) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
return err
}
// Symmetric with the human mode (minor 1d): flagged>0 is exit 2 (completed-with-flags), not
// exit 0. The full JSON is already on stdout; the sentinel only sets the shell disposition
// (its message goes to stderr in main), so a CI/IDE consumer both parses the projection AND
// sees "attention needed" in the exit code instead of silently reading 0.
if rep.Flagged > 0 {
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalUnits}
}
return nil
}
// renderStatusHuman prints the operator dashboard (unit counts, per-chapter
// passports, money, secondary ETA) and maps flagged>0 to the exit-2 sentinel.
// cfgPath goes into the "what to do" hints (redrive/translate command lines).
func renderStatusHuman(w io.Writer, rep *pipeline.StatusReport, cfgPath string) error {
snap := rep.Snapshot
if snap == "" {
snap = "—"
} else if len(snap) > 12 {
snap = snap[:12]
}
drift := ""
if rep.SnapshotDrift {
drift += " ⚠ SNAPSHOT-DRIFT (several snapshots in chunk_status — the config changed; --resnapshot)"
}
if rep.ConfigDrift {
drift += " ⚠ CONFIG-DRIFT (the current config renders a different snapshot — an edit since the run; translate will require --resnapshot = re-paying for the book)"
}
// The drift flags say THAT the config moved; this says what continuing would COST (spec D15.2 §9).
// It is the same projection `translate` refuses on, so the operator reads the decision number here
// instead of discovering it in a refusal.
if rep.RebillUnits > 0 {
// BOTH denominations, always, and labelled. The chunk×stage count is what the money is billed in;
// the output-unit count is what a purchase is sized in. Printing only the first is how a reader
// hands `--max-units` a number several times too large.
drift += fmt.Sprintf(" ⚠ RE-PAYMENT: %d chunk×stage unit(s) already billed under a superseded snapshot would be paid for again — that is %d OUTPUT unit(s) of the book, the granularity --max-units counts in — ~$%.6f (translate needs --accept-rebill above the book's consent threshold). ⚠ Sizing --max-units from that number does NOT buy those units back: a grant goes to NEVER-DELIVERED units first and only reaches re-making when none are left, so on a book that still has undelivered units it will deliver new ones instead",
rep.RebillUnits, rep.RebillOutputUnits, rep.RebillUSD)
}
// A zero is not self-explanatory, and printing it as though it were is the mistake the `rebill_basis`
// field exists to stop: "nothing to re-pay" and "we could not work out what a re-pass would cost" are
// the same two zeroes. Only the two states that are NOT an answer speak here — a computed figure needs
// no caveat, and a book with nothing stored has nothing to say about re-payment at all.
switch rep.RebillBasis {
case pipeline.RebillBasisFailed:
drift += " ⚠ RE-PAYMENT UNKNOWN (the projection failed — the figures below are zero because they could not be computed, NOT because continuing is free; see the log)"
case pipeline.RebillBasisStored:
drift += " ⚠ RE-PAYMENT FIGURES ARE STALE (the book's bank could not be folded, so they describe the glossary the LAST run stored, not what the next run would build — fix the bank and re-read; see the log)"
}
fmt.Fprintf(w, "=== STATUS: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift)
fmt.Fprintf(w, "Progress: %d/%d units (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n",
rep.Done, rep.TotalUnits, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending)
// Per-wave progress (row 99). The line above needs BOTH waves to have resolved a unit ok, and the edit
// wave does not start before the bank stop — so it sits at 0/N for the whole draft wave and says nothing
// about the drafting that IS happening. This line is the split; "resolved" there includes flagged units
// (see PhaseProgress), which is why it can exceed the done count above.
fmt.Fprintf(w, "Phases (units resolved by wave): draft %s · edit %s\n",
waveCell(rep.Progress.Draft), waveCell(rep.Progress.Edit))
fmt.Fprintf(w, "Escalations: %d · post-check misses (confirmed): %d · style-flags (observability): %d\n",
rep.Escalations, rep.PostcheckMisses, rep.StyleFlags)
// Printed only when the bank actually holds unsigned rows, so a book translating entirely against signed
// canon keeps its exact prior bytes.
if rep.UnsignedBankTerms > 0 {
fmt.Fprintf(w, "Unsigned bank terms: %d — the engine's own proposals, shown to the model as ⟨проверить⟩; review the signature map beside the DB and decide via `tmctl bank-apply`\n", rep.UnsignedBankTerms)
}
ceil := ""
if rep.BookCeilingUSD > 0 {
ceil = fmt.Sprintf(" (ceiling $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
}
fmt.Fprintf(w, "Money: committed=$%.6f reserved=$%.6f%s · book forecast ~$%.6f\n",
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
if rep.ETASeconds > 0 {
fmt.Fprintf(w, "ETA: ~%.0fs (mean throughput of fresh calls, NOT EWMA — D12-deviation; secondary)\n", rep.ETASeconds)
}
// Content-label provenance (B6): printed only for a labelled book, so an ordinary run's output is
// unchanged. It answers "which endpoint received this book" — the routing decision, not a hash input.
if len(rep.ContentLabels) > 0 {
fmt.Fprintf(w, "CONTENT LABELS: %s · routing: %s\n", strings.Join(rep.ContentLabels, ", "), strings.Join(rep.Routing, " · "))
for _, p := range rep.ContentRoutingProblems {
fmt.Fprintf(w, " ⚠ ROUTING NOT RUNNABLE: %s\n", p)
}
}
if len(rep.Chapters) > 0 {
// style = cheap deterministic style/number flags (observability, not a disposition) — a per-
// chapter glance count so a human sees where the linters fired without opening `report` (D20.4).
fmt.Fprintf(w, "\n%-5s %-9s %-10s %-6s %-4s %-6s %-16s %10s\n", "ch", "done/all", "verdict", "flag", "esc", "style", "worst_flag", "cost_usd")
for _, p := range rep.Chapters {
fmt.Fprintf(w, "%-5d %d/%-7d %-10s %-6d %-4d %-6d %-16s %10.6f\n",
p.Chapter, p.UnitsDone, p.UnitsTotal, p.Verdict, p.UnitsFlagged, p.Escalations, p.StyleFlags,
dashIfEmpty(p.WorstFlagReason), p.CostUSD)
}
}
// flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a "clean run,
// attention needed" signal. Two kinds need DIFFERENT actions (minor 1d): a chunk_status flag
// (soft_refusal, length, …) is re-drivable with `tmctl redrive`; a gate-promoted glossary_miss
// is NOT (redrive is a no-op — the miss re-derives from the unchanged glossary), so it needs a
// seed fix + `translate --resnapshot`. Advise each set separately instead of one blanket hint.
if rep.Flagged > 0 {
if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 {
fmt.Fprintf(w, "\n%d flagged chunk(s) need attention — re-attack: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
redrivable, cfgPath)
}
if rep.GlossaryMissFlagged > 0 {
fmt.Fprintf(w, "\n%d chunk(s) flagged by the post-check gate (glossary_miss) — redrive is a no-op for them: fix the seed glossary (term/dst) and re-run `tmctl translate --config %s --resnapshot` (re-pays the affected chunks)\n",
rep.GlossaryMissFlagged, cfgPath)
}
return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalUnits}
}
return nil
}
// waveCell renders one wave counter, or "—" for a wave this pipeline does not have (a draft-only
// pipeline has no edit wave, and printing "0/0" for it would read as work that never starts).
func waveCell(c pipeline.WaveCounter) string {
if c.Total == 0 {
return "—"
}
return fmt.Sprintf("%d/%d", c.Done, c.Total)
}
// dashIfEmpty renders "" as "—" for a table cell.
// dashIfEmpty renders an empty (or blank) cell as an em dash. ONE definition: pack-20 briefly added a
// second, near-identical `dash` 400 lines above, and the two disagreed on whitespace — the kind of split
// where two tables in the same report start describing the same missing value differently.
func dashIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
// renderRedrive prints the redrive plan and (after a live re-run) the re-run
// totals. ledger may return an error — it is DELIBERATELY swallowed without the ledger
// line (original behaviour: flaky reads must not change the redrive exit code).
func renderRedrive(w io.Writer, bookID string, summary *pipeline.RedriveSummary, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
fmt.Fprintf(w, "=== REDRIVE: %s ===\n", bookID)
if len(summary.Targets) == 0 {
fmt.Fprintln(w, "No flagged chunks match the selector — nothing to re-attack.")
return nil
}
fmt.Fprintf(w, "Targets: %d flagged chunk(s) — reset the terminal flag + a fresh retry/escalation budget (DispOK untouched):\n", len(summary.Targets))
for _, t := range summary.Targets {
fmt.Fprintf(w, " ch%d/chunk%d (%s) → stages: %s\n", t.Chapter, t.ChunkIdx, t.FlagReason, strings.Join(t.Stages, ", "))
}
if summary.DryRun {
fmt.Fprintln(w, "[dry-run: nothing reset, no calls made]")
return nil
}
fmt.Fprintln(w, "[reset; re-running the durable loop — DispOK chunks resume at $0, reset ones re-attack]")
fmt.Fprintln(w)
if res != nil {
fmt.Fprintf(w, "TOTAL re-run (this run): $%.6f — chunks %d, flags %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
committed, reserved, serr := ledger()
if serr == nil {
fmt.Fprintf(w, "Book ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
}
if res.Flagged > 0 {
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
}
}
return nil
}