textmachine/backend/cmd/tmctl/render.go

696 lines
35 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.
// 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.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)
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)
// 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.
fmt.Fprintf(w, "# export %s — total units=%d, exported=%d, pending=%d",
exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits, 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.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))
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 + ")"
}
// 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 {
drift += fmt.Sprintf(" ⚠ RE-PAYMENT: %d chunk×stage unit(s) already billed under a superseded snapshot would be paid for again, ~$%.6f (translate needs --accept-rebill above the book's consent threshold)", rep.RebillUnits, rep.RebillUSD)
}
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
}