1139 lines
67 KiB
Go
1139 lines
67 KiB
Go
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).
|
||
// renderStoppedRun is what a person is told after they stop a run, and it exists because until backlog
|
||
// row 389 they were told NOTHING: a stopped run returns no result, every renderer here takes one, and the
|
||
// operator was left with a single line of error text about a run that may have translated half a book.
|
||
//
|
||
// ⛔ IT REPORTS THE STORE, NOT A RESULT, and that is the whole of its honesty. The run was cut; there is
|
||
// no assembled BookResult to print and inventing one would mean describing work nobody verified. What the
|
||
// store holds is durable and true whatever the run did next — the ledger it committed, the positions that
|
||
// reached a verdict, and the ones a cut left marked for re-doing. Those are the two questions a person
|
||
// has after pressing the button: what do I have, and what will be bought again.
|
||
//
|
||
// A read that fails is SAID, not swallowed: the alternative is a confident zero, and «nothing was bought»
|
||
// is the most expensive wrong sentence this surface can print.
|
||
func renderStoppedRun(w io.Writer, bookID string, ledger func() (committed, reserved float64, err error),
|
||
rows func() ([]store.ChunkStatus, error)) {
|
||
fmt.Fprintf(w, "=== RUN STOPPED — %s ===\n", bookID)
|
||
if committed, reserved, err := ledger(); err != nil {
|
||
fmt.Fprintf(w, " the book's ledger could not be read, so what this run spent is UNKNOWN here (it is in the project database): %v\n", err)
|
||
} else {
|
||
fmt.Fprintf(w, " book ledger: committed=$%.6f reserved=$%.6f\n", committed, reserved)
|
||
}
|
||
cs, err := rows()
|
||
if err != nil {
|
||
fmt.Fprintf(w, " the stored dispositions could not be read, so how far the book got is UNKNOWN here: %v\n", err)
|
||
return
|
||
}
|
||
resolved, stopped := 0, 0
|
||
for _, row := range cs {
|
||
if pipeline.FlagReason(row.FlagReason) == pipeline.FlagCancelled {
|
||
stopped++
|
||
continue
|
||
}
|
||
resolved++
|
||
}
|
||
fmt.Fprintf(w, " positions with a verdict: %d; positions the stop cut mid-call: %d (the resume re-does those and serves the rest for $0)\n",
|
||
resolved, stopped)
|
||
fmt.Fprintln(w, " `tmctl status --json` reports the same numbers in full, and `tmctl export` ships what is finished.")
|
||
}
|
||
|
||
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")
|
||
renderBankConsolidation(w, s.Consolidation)
|
||
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 still goes to the model, as part of the same\n"+
|
||
"law block as the terms you signed (D39.104: the bank on the wire is law for every row; an unsigned rendering\n"+
|
||
"is used consistently and corrected one level up, by a bank edit and a re-edit).")
|
||
// ⚠ These lines used to name the ⟨проверить⟩ marker, which row 134 removed from the wire (D39.104 п.2). They
|
||
// now describe what actually reaches the model: the same block, no mark. The engine has NOT stopped telling
|
||
// the two apart — `Unsigned bank terms` counts them, the signature map lists them, and the post-check reports
|
||
// them apart — it has stopped telling the MODEL apart, which is what the doctrine changed.
|
||
fmt.Fprintln(w, "To run WITHOUT this pause, drop --verify-bank: the run then carries the unsigned bank forward and uses it.")
|
||
}
|
||
|
||
// renderBankConsolidation states how complete the bank being signed is, ABOVE the table and never inside
|
||
// it (backlog row 253б).
|
||
//
|
||
// ⛔ THE POSITION IS THE POINT, not layout. The table below is capped at 20 rows and ordered least-sure
|
||
// first, which puts a row with no rendering — every row a cut budget left unbought, and every row the bank
|
||
// already settled — at rank 1000, i.e. LAST. On a book with twenty candidates the whole class the owner
|
||
// most needs to see is exactly the class that falls off the bottom, so a per-row mark alone would be a
|
||
// green fixture and a blind screen. This line is outside the cap and cannot be lost.
|
||
//
|
||
// It always says something. A screen that is silent when the bank is whole makes its own silence carry
|
||
// meaning, which no reader can distinguish from the screen not knowing — and «did not measure» is a third
|
||
// state here, not a shade of «complete».
|
||
func renderBankConsolidation(w io.Writer, c *pipeline.BankConsolidation) {
|
||
if c == nil {
|
||
fmt.Fprintln(w, "Bank completeness: NOT MEASURED — the paid terminology pass did not run for this stop, "+
|
||
"so nothing here says whether the renderings below are all the book has.")
|
||
return
|
||
}
|
||
switch {
|
||
case c.Complete && c.Consolidated+c.Declined+c.Unanswered == 0:
|
||
// ⛔ THE PAID ROLE WAS ASKED NOTHING — every candidate was already settled by the bank, so no batch
|
||
// was ever planned. «Every render batch was bought» is technically true of zero batches and reads as
|
||
// a report on work that happened, which is the exact shape this surface exists to remove: an
|
||
// instrument answering its own question (D39.202). Reachable whenever the already-banked filter
|
||
// empties the paid set.
|
||
fmt.Fprintln(w, "Bank completeness: WHOLE — the paid role was asked for nothing, because the bank "+
|
||
"already renders every candidate this run found. No batch was planned and none was bought.")
|
||
case c.Complete:
|
||
fmt.Fprintf(w, "Bank completeness: WHOLE — every render batch was bought (%d consolidated, %d declined, %d unanswered).\n",
|
||
c.Consolidated, c.Declined, c.Unanswered)
|
||
default:
|
||
// The money, said as money: these terms are not undecided by the role, they were never offered to it.
|
||
fmt.Fprintf(w, "⚠ Bank completeness: PARTIAL — a budget cut the render pass and %d batch(es) were never "+
|
||
"bought, so some terms were never offered to the role at all (%d consolidated, %d declined, %d "+
|
||
"unanswered — the last figure counts the unbought terms together with the ones the role saw and "+
|
||
"skipped). Raise `gates.terminology.budget_usd` and re-run to finish the bank.\n",
|
||
c.RenderBatchesDropped, c.Consolidated, c.Declined, c.Unanswered)
|
||
}
|
||
if c.ClassifyBatchesDropped > 0 {
|
||
// A DIFFERENT fact from the one above, on a different budget: the bank can be whole and this still
|
||
// non-zero. Merging the two would raise a false alarm about the bank on every classify-only cut.
|
||
fmt.Fprintf(w, " Term TYPES are unrefined: the classifier's budget cut %d batch(es) — the renderings "+
|
||
"above are unaffected; raise `gates.terminology.classify_budget_usd`.\n", c.ClassifyBatchesDropped)
|
||
}
|
||
if c.NeverAsked > 0 {
|
||
// The saving, and it is stated because an empty rendering in the table below is the same glyph as
|
||
// «the role declined» and «the budget did not reach it» — three facts, one of which is good news.
|
||
fmt.Fprintf(w, " %d term(s) were NOT ASKED about: the bank already renders those surfaces and every "+
|
||
"draft agreed — nothing to decide, and they are marked in the table and the full sidecar.\n", c.NeverAsked)
|
||
}
|
||
}
|
||
|
||
// 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 r.SettledByBank {
|
||
// The row prints an empty dst, which on this sheet is the glyph of three other facts — the role
|
||
// declined, no reply covered it, the budget never reached it — and this one is «nothing to
|
||
// decide». The same sentence the text sidecar carries, so the two cannot describe a row
|
||
// differently. The count above is what survives the cap; this is what a shown row says.
|
||
fmt.Fprintln(w, " NOT ASKED: the bank already renders this surface and every draft agreed — nothing to decide")
|
||
}
|
||
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.BankHolds) > 0 {
|
||
// The other disagreement, and a different decision: the book already renders this surface some
|
||
// other way. It rides the same screen as its sibling above, or the operator's first view of the
|
||
// stop shows one and hides the other.
|
||
fmt.Fprintf(w, " ⚠ bank holds: %s\n", trunc(strings.Join(r.BankHolds, "; "), 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 with the offending src→dst. Two limits: a
|
||
// chunk is listed on its CONFIRMED miss count while the detail beside it carries every
|
||
// miss including the unsigned ones, so a listed line is not necessarily an approved
|
||
// term; and a miss means the rendering is ABSENT — a wrong dst the model OBEYED is
|
||
// present in the output and appears in no line here (mempostcheck.go).
|
||
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)
|
||
// The line is printed for any chunk that RECORDED a miss, not only for one whose CONFIRMED count is
|
||
// non-zero. The two are different populations and the gap between them was the whole listing: a miss
|
||
// on an unsigned row is deliberately not counted (mempostcheck.go — counting it would invert the
|
||
// contract), so a book whose bank is entirely unsigned showed `post-check-misses=0` above and not one
|
||
// line below, while the detail blob beside each row already named every deviation. On the paid run of
|
||
// 11.09 that hid EIGHTEEN named terms behind a zero. The count column keeps meaning what it meant —
|
||
// confirmed misses only — and the detail is what gains the audience it was already written for.
|
||
printed := false
|
||
for _, rs := range states {
|
||
if rs.NPostcheckMiss == 0 && rs.PostcheckDetail == "" {
|
||
continue
|
||
}
|
||
if !printed {
|
||
fmt.Fprintf(w, "%-4s %-6s %8s %s\n", "ch", "chunk", "confirmed", "every deviation recorded here (src→dst absent from the output; disp says whether the row was signed)")
|
||
printed = true
|
||
}
|
||
fmt.Fprintf(w, "%-4d %-6d %8d %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
|
||
}
|
||
// ⛔ ONE ROW IS ONE LINE. `errors.Join` separates its members with a newline, and an engine error
|
||
// that carries both a cut and what ended the chain is exactly such a join — so a table row printed
|
||
// its tail across two lines, breaking the column alignment of everything under it and pushing the
|
||
// second half where no reader looks for it.
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
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))
|
||
}
|
||
// A ROW count, not a unit count: one unit can carry several, and labelling it a unit count would
|
||
// understate the exposure. "checked", not "shown": the counter increments once per unsigned row whose
|
||
// key FIRED in that unit, and a sticky carry is put in front of the model without being counted —
|
||
// its src is in the previous chunk, so the output is not expected to answer for it
|
||
// (membank.PostcheckResult). The follow percentage is a fraction OF THIS, so calling it a count of
|
||
// showings would have the operator read a ratio other than the one printed.
|
||
fmt.Fprintf(w, "UNSIGNED BANK: terms=%d · rows-checked=%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)
|
||
}
|
||
// WAVE INJECTION (backlog row 417): what each wave was actually shown. The draft selects over the BASE
|
||
// bank and the editor over the ENRICHED one, so these two lines are not expected to agree — and until
|
||
// the v17 table existed the editor's line could not be printed at all, because its numbers were
|
||
// overwritten onto the draft's row. A run made before that table prints nothing here rather than zeroes.
|
||
for _, wv := range q.Waves {
|
||
fmt.Fprintf(w, "WAVE INJECTION [%-5s]: positions=%d · exact=%d · sticky=%d · ambiguous=%d · spoiler-blocked=%d · evicted-by-budget=%d\n",
|
||
wv.Wave, wv.Positions, wv.ExactHits, wv.Sticky, wv.Ambiguous, wv.Blocked, wv.Evicted)
|
||
}
|
||
// BOOK CONSISTENCY (backlog row 406): the owner's priority #1, asked of the text a READER gets rather
|
||
// than of a chunk in flight. It prints whenever there is a bank to judge against — INCLUDING when
|
||
// nothing failed — because a silent section and a clean book would otherwise be the same output, and a
|
||
// zero here means nothing without the population it came out of. Every line therefore carries its
|
||
// denominator: «never fired in the source» is what separates a term the engine mis-rendered from a term
|
||
// this book does not contain, and those two were one number in every measure before this one.
|
||
if c := q.Consistency; c != nil {
|
||
fmt.Fprintf(w, "\n=== BOOK CONSISTENCY (the shipped text against the bank — observability, not a gate) ===\n")
|
||
fmt.Fprintf(w, "population: bank rows=%d · judgeable (a dst to look for)=%d · no dst=%d · shipped units read=%d\n",
|
||
c.BankRows, c.Judged, c.NoDst, c.UnitsJudged)
|
||
// ⛔ THIS LINE IS A PARTITION AND IT MUST CLOSE. The four numbers are every judgeable row, once each;
|
||
// if they stop adding up, one of them has quietly stopped meaning what it says — which is exactly
|
||
// what happened while «eaten by a longer key» was a fifth, overlapping bucket printed as if it were
|
||
// part of the split. It is now orthogonal and printed on its own line below.
|
||
fmt.Fprintf(w, "of the judgeable: key never fired in the source=%d · spoiler-window blocked=%d · fired and judged=%d (sum %d, must equal the %d judgeable)\n",
|
||
c.NeverFired, c.BlockedOnly, c.Covered+c.Absent+c.Split, c.NeverFired+c.BlockedOnly+c.Covered+c.Absent+c.Split, c.Judged)
|
||
// The OCCURRENCE denominators, beside the term ones. «5 terms absent» is a different fact against 40
|
||
// firings than against 4000, and the term counts alone cannot tell those apart.
|
||
fmt.Fprintf(w, "occurrences: bank keys fired and judged=%d · accepted renderings found in the text=%d\n",
|
||
c.FiredTotal, c.ShippedTotal)
|
||
// The book's signing state, said ONCE and about the BOOK. There is no signed TERM to count: a bank is
|
||
// signed as a whole or the book ships with an unsigned one (D39.144), and the law on the wire is the
|
||
// same law for every row either way (D39.104). Cutting the term counts by a per-row status would
|
||
// report on a unit the product does not have.
|
||
if c.UnsignedRows > 0 {
|
||
fmt.Fprintf(w, "THIS BANK IS NOT SIGNED: %d of %d judgeable rows carry a rendering nobody approved — the run auto-continued rather than stopping for signature (`--verify-bank` is the stop)\n",
|
||
c.UnsignedRows, c.Judged)
|
||
} else {
|
||
fmt.Fprintf(w, "THIS BANK IS SIGNED: all %d judgeable rows carry an approved rendering\n", c.Judged)
|
||
}
|
||
fmt.Fprintf(w, "I1 ONE FORM PER TERM: more than one rendering=%d · a single rendering=%d\n", c.Split, c.Covered)
|
||
fmt.Fprintf(w, "I2 THE BANK'S FORM IS THE ONE SHIPPED (D39.104): the bank's rendering never reached the reader=%d\n", c.Absent)
|
||
// The silent half of row 407, printed for the first time. A row eaten by a longer key is the bank
|
||
// working as designed; a row eaten by a longer key whose rendering then failed to carry its canon is
|
||
// a term nobody checked and nobody could have seen.
|
||
fmt.Fprintf(w, "NESTED ROWS EATEN BY A LONGER KEY whose own rendering did not reach the text=%d\n", c.SuppressedSilent)
|
||
// ⛔ THIS REPORT'S OWN BIAS, PRINTED. The target side counts a rendering anywhere in the unit, so a
|
||
// common-noun rendering occurring away from its term inflates the count — and an inflated count can
|
||
// cover a real absence. The bias therefore runs ONE WAY, towards reassurance, and I2 above is a
|
||
// LOWER bound because of it. The line names the population where that can happen at all rather than
|
||
// leaving the reader to assume the number is flat.
|
||
fmt.Fprintf(w, "⚠ I2 IS A LOWER BOUND: a rendering is counted anywhere in the unit, so a common word standing away from its term can cover a real absence — terms found MORE often than their key fired=%d (the population where that is possible; a plural or a repeated mention makes the same shape)\n",
|
||
c.MaskCandidates)
|
||
// ⚠ THE SECOND COLUMN IS THE COST OF THE MATCHER, and it is printed rather than described. The
|
||
// verdicts above relax the post-check's equality in two named ways — a stored decl form is stemmed
|
||
// rather than matched literally, and a stem may run one rune past the stem it is compared with when
|
||
// another word of the same rendering anchors it. Where the two columns disagree, the strict side is
|
||
// calling a rendering missing that IS in the text, inflected: eleven of the eighteen misses recorded
|
||
// on the paid run of 11.09, and all six residual false flags of the labelled corpus. An operator
|
||
// reading one column could not tell drift from the matcher.
|
||
if c.AbsentStrict != c.Absent || c.SplitStrict != c.Split {
|
||
fmt.Fprintf(w, " ⚠ under the post-check's OWN equality the same book reads: absent=%d · more than one rendering=%d · single=%d — the gap is what the shipping path is blind to, not drift the editor introduced (this bank stores no decl forms, so stemming them changes nothing here)\n",
|
||
c.AbsentStrict, c.SplitStrict, c.CoveredStrict)
|
||
}
|
||
// The middle column — the post-check's rule plus stemming of STORED decl forms — is printed only when
|
||
// it differs from the strict one. On a bank with no decl forms at all, which is every MINED bank in
|
||
// this tree, the two are identical by construction and a second line saying the same numbers would
|
||
// read as a second measurement rather than as the same one.
|
||
if (c.AbsentSafe != c.Absent || c.SplitSafe != c.Split) && (c.AbsentSafe != c.AbsentStrict || c.SplitSafe != c.SplitStrict) {
|
||
fmt.Fprintf(w, " ⚠ with the stored decl forms stemmed but WITHOUT the one-rune tolerance: absent=%d · more than one rendering=%d · single=%d\n",
|
||
c.AbsentSafe, c.SplitSafe, c.CoveredSafe)
|
||
}
|
||
if len(c.Terms) > 0 {
|
||
// ⚠ The legend is not decoration. «fired» counts SOURCE occurrences and «shipped» counts target
|
||
// ones, and a target language may legitimately answer several source occurrences with one word —
|
||
// a Russian plural, or a pronoun after the first mention. So a split is an UPPER bound on the
|
||
// number of renderings, never a proof of two, and it is labelled here rather than in a doc
|
||
// nobody reads beside the number.
|
||
fmt.Fprintf(w, " (fired = the key's occurrences in the source · shipped = the bank rendering's in the text; fewer shipped than fired is an upper bound on renderings, not a proof of two)\n")
|
||
fmt.Fprintf(w, " %-9s %-9s %-6s %-14s %s\n", "verdict", "status", "fired", "shipped", "term · chapters")
|
||
for _, t := range c.Terms {
|
||
// shipped prints BOTH counts when they differ, so a line never hides the fact that the
|
||
// stricter matcher would have judged this term otherwise.
|
||
shipped := strconv.Itoa(t.Shipped)
|
||
if t.ShippedStrict != t.Shipped {
|
||
shipped += " (strict " + strconv.Itoa(t.ShippedStrict) + ")"
|
||
}
|
||
line := fmt.Sprintf(" %-9s %-9s %-6d %-14s %s → %q · with the bank's form: %s · without it: %s · split inside: %s",
|
||
t.Verdict, t.Status, t.Fired, shipped, t.Src, t.Dst,
|
||
chapterList(t.WithForm), chapterList(t.WithoutForm), chapterList(t.SplitIn))
|
||
// The chapters where a longer key ate this row and its canon did not come out. Printed only
|
||
// when there are any, because for most terms the column is the em dash of a question nobody
|
||
// asked — and printed at all because a count without its chapters is not actionable.
|
||
if len(t.EatenIn) > 0 {
|
||
line += " · eaten by a longer key in: " + chapterList(t.EatenIn)
|
||
}
|
||
fmt.Fprintln(w, line)
|
||
}
|
||
}
|
||
}
|
||
// 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 A–C 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 A–C, 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.
|
||
// WHAT THE MONEY BOUGHT. The total has always been printed; this is the SPLIT, and it leads with the
|
||
// part that did not become text because that is the number an operator can act on. Every word is
|
||
// literally true of the rows it sums: «superseded» means a later paid call for the same position
|
||
// replaced this one — not «wasted», which would be a verdict this report has no standing to reach.
|
||
if t := q.PaidTail; t != nil {
|
||
fmt.Fprintf(w, "MONEY BY WHAT IT BOUGHT: shipped text $%.6f (%d call(s)) · the book's TERMINOLOGY $%.6f (%d) · superseded-by-a-later-call $%.6f (%d) · bought-nothing-shippable $%.6f (%d) — total $%.6f\n",
|
||
t.ShippedUSD, t.ShippedCalls, t.BankUSD, t.BankCalls,
|
||
t.SupersededUSD, t.SupersededCalls, t.WithheldUSD, t.WithheldCalls, t.TotalUSD)
|
||
if lost := t.LostUSD(); lost > 0 {
|
||
pct := 0.0
|
||
if t.TotalUSD > 0 {
|
||
pct = 100 * lost / t.TotalUSD
|
||
}
|
||
// ⚠ «Bought nothing» and NOT «did not become shipped text»: the bank roles buy the book's
|
||
// terminology and never a chunk of its text, so the older wording called a glossary pass that
|
||
// worked perfectly a total loss — the false-cause defect this section was built to remove,
|
||
// committed by the section itself (found by the acceptance).
|
||
fmt.Fprintf(w, " ⚠ $%.6f of that (%.1f%%) bought NOTHING — neither text nor terminology", lost, pct)
|
||
if t.WorstPosition != "" {
|
||
fmt.Fprintf(w, "; the largest single loss is %s at $%.6f", t.WorstPosition, t.WorstUSD)
|
||
}
|
||
fmt.Fprintln(w)
|
||
}
|
||
}
|
||
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)
|
||
// WHERE the chapter boundaries came from, in the human view too: an operator deciding whether to
|
||
// offer an order «through chapter N» has to know whether N names something the file said or something
|
||
// this engine inferred from prose (BookManifest.Structure).
|
||
if m.Structure != "" {
|
||
fmt.Fprintf(w, "chapter structure: %s\n", m.Structure)
|
||
}
|
||
// The a-priori price. `step_max_usd` is on the same line as the total on purpose: it is the number a
|
||
// ceiling has to clear before the run can move AT ALL, and an operator reading only the total would
|
||
// set a ceiling that admits nothing.
|
||
if p := m.Price; p != nil {
|
||
// ⚠ THE BOOK-LEVEL PART IS NAMED A CEILING, NOT A FORECAST, and on a short book that is the whole
|
||
// of the honesty: the terminology passes are bounded by their configured budgets and their input
|
||
// does not exist until the drafts do, so the figure is what the engine will NOT exceed. On a book
|
||
// of a few chapters it can dominate the total — an operator reading «expected cost» as a bill
|
||
// would think a short book dearer than a long one per chapter, which is backwards (acceptance F3).
|
||
fmt.Fprintf(w, "expected cost: $%.6f for the whole book (%d source chars)\n", p.ExpectedUSD, p.SourceChars)
|
||
if p.BookOnceUSD > 0 {
|
||
fmt.Fprintf(w, " …of which $%.6f is the book-level CEILING of the terminology passes — a bound the run will not exceed, not a forecast; it is charged once per book, so it weighs most on a short one\n", p.BookOnceUSD)
|
||
}
|
||
fmt.Fprintf(w, "smallest workable ceiling: $%.6f — the largest single reservation; under it NOTHING is admitted\n", p.StepMaxUSD)
|
||
}
|
||
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 {
|
||
// ⛔ THIS LINE NO LONGER ASSERTS THE MONEY, and that is the point. It used to end «translate will
|
||
// require --resnapshot = re-paying for the book», which is a claim about SPEND made by a flag that
|
||
// never computed any. The two come apart the moment status learned to see a DROPPED stage:
|
||
// projectRebill deliberately skips rows whose stage the current pipeline does not run («a stage the
|
||
// current pipeline does not run is never re-billed»), so that drift is real and its re-payment is
|
||
// genuinely zero — and the old sentence would have invented a cost. The drift line now says what
|
||
// drifted; the RE-PAYMENT lines below say what it costs, from the projection that actually ran.
|
||
drift += " ⚠ CONFIG-DRIFT (the current config renders a different snapshot than the stored rows — an edit since the run; translate needs --resnapshot to proceed)"
|
||
}
|
||
// The basis behind the boolean: `false` means two different things and only one of them is an answer.
|
||
if rep.ConfigDriftBasis == pipeline.DriftBasisUnknown {
|
||
drift += " ⚠ CONFIG-DRIFT UNKNOWN (the check could not run — the flag above is false because nothing was established, NOT because the config matches; see the log)"
|
||
}
|
||
// 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.
|
||
// And the figure's BASIS, in the consent gate's own words (RebillFigureBasis): what the number is
|
||
// made of, that it errs upward, and how much of it is priced across a model move or carried at last
|
||
// season's money. A figure printed without it invites the reader to take an estimate for a quote.
|
||
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 (%s; 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, rep.RebillFigureBasis())
|
||
}
|
||
// Drift with nothing to re-pay is a real and non-obvious state — a stage dropped from the config puts
|
||
// every book in it — and leaving the operator to infer it from the ABSENCE of a re-payment line is how
|
||
// «no line» gets read as «no drift consequence».
|
||
if rep.ConfigDrift && rep.RebillUnits == 0 && rep.RebillBasis != pipeline.RebillBasisFailed {
|
||
drift += " ⚠ …and NOTHING already billed is re-paid by it: the drift is in rows the current config no longer runs, so a re-run buys those units anew rather than re-buying them"
|
||
}
|
||
// 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, used on the wire like any other bank row and not marked apart for the model; 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)
|
||
// The estimated share of that committed figure, on the surface an operator reads before deciding
|
||
// whether to keep paying. The same pair rides in --json for the platform; printing it here too is
|
||
// what makes «committed» readable as a range rather than as a measurement.
|
||
if rep.EstimatedRows > 0 {
|
||
fmt.Fprintf(w, " of which ESTIMATED: $%.6f over %d call(s) — booked at the reservation estimate because the provider never reported usage for them (a body that would not decode, a call our own deadline or a stop cut short, a 2xx with no usage)\n",
|
||
rep.EstimatedUSD, rep.EstimatedRows)
|
||
}
|
||
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
|
||
}
|
||
|
||
// chapterList renders a chapter set for the consistency detail. An empty set prints an em dash rather than
|
||
// nothing: «without it:» followed by blank reads as a truncated line, and the whole value of the column is
|
||
// that a reader can tell "no chapters" from "the field was not filled in".
|
||
func chapterList(chs []int) string {
|
||
if len(chs) == 0 {
|
||
return "—"
|
||
}
|
||
parts := make([]string, 0, len(chs))
|
||
for _, ch := range chs {
|
||
parts = append(parts, strconv.Itoa(ch))
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|