618 lines
38 KiB
Go
618 lines
38 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"textmachine/backend/internal/checks"
|
|
"textmachine/backend/internal/store"
|
|
)
|
|
|
|
// quality.go: the DETERMINISTIC per-run quality-report (D39 layer 5, H5-no-in-loop-quality-signal) —
|
|
// the online/offline quality telemetry the owner asked for "from day one" (п.25/п.31), which
|
|
// research/18 §C1 #10 flagged as a NOW lever but was silently deferred to Ф2. It AGGREGATES signals
|
|
// that are ALREADY computed and stored (retrieval_state: glossary post-check misses, cheap style
|
|
// flaggers, trust-gated suppressions; chunk_status: echo/CJK/sanitizer flags) plus ONE cheap
|
|
// deterministic structural KPI (sentences per narrative paragraph — the "choppy paragraphs" claim-1
|
|
// signal) recomputed from the exported final text. It is a PURE READ-ONLY projection like Status: $0,
|
|
// no LLM, no snapshot touch, no checkpoint replay — only OBSERVABILITY, never a gate. The semantic
|
|
// span-judge (inversion/omission backstop for claim-2) is NOT here — it is research-dependent (pack-2).
|
|
|
|
// QualityReport is the whole-book per-run quality projection. The shipping granularity is the OUTPUT UNIT
|
|
// (edit unit for an edit pipeline, draft chunk for a draft-only one), so the whole-book counters are named
|
|
// *_units (naming-debt fix D39.18-follow-up: pre-c-lite they said "chunks" but count units).
|
|
type QualityReport struct {
|
|
BookID string `json:"book_id"`
|
|
TotalUnits int `json:"total_units"`
|
|
// TextUnits is the number of units whose exported final text was available for the structural KPI
|
|
// (done or cosmetically-stripped); a flagged-empty unit contributes no prose.
|
|
TextUnits int `json:"text_units"`
|
|
// ProcessedUnits is the number of units carrying a final-stage row of ANY kind: ok, cosmetic-strip,
|
|
// skipped-because-a-member-flagged, or a STOP MARK a run left on the position (`cancelled`,
|
|
// `retry_unaffordable` — paid for, not finished). It is the strip-rate denominator, so that rate stays a
|
|
// bounded [0,1] fraction. «Units that REACHED the final stage» stood here and is too strong for the
|
|
// fourth kind; why the mark is counted anyway is argued at the counter itself (search ProcessedUnits++).
|
|
ProcessedUnits int `json:"processed_units"`
|
|
|
|
// Claim-1 structural KPI (choppy paragraphs). MeanSentPerNarrPara ≈ 1 is choppy (one sentence per
|
|
// paragraph — the owner's exact complaint); higher is merged discourse prose. Aggregated as
|
|
// total sentences / total narrative paragraphs across the book, so it is a true book-wide mean.
|
|
NarrativeSentences int `json:"narrative_sentences"`
|
|
NarrativeParagraphs int `json:"narrative_paragraphs"`
|
|
MeanSentPerNarrPara float64 `json:"mean_sentences_per_narrative_paragraph"`
|
|
|
|
// Deterministic signal aggregates (all observability, never a disposition).
|
|
DialogueDashFlags int `json:"dialogue_dash_flags"` // Rosenthal dialogue-dash inconsistencies
|
|
GlossaryMisses int `json:"glossary_misses"` // CONFIRMED post-check misses (D10 consistency)
|
|
NumberDriftFlags int `json:"number_drift_flags"` // reflow number drift + 万/億 magnitude drift
|
|
TrustGated int `json:"trust_gated"` // lower-trust suppressions refused (seed hygiene)
|
|
CosmeticStripUnits int `json:"cosmetic_strip_units"` // units the sanitizer auto-stripped (markdown header OR CJK leak — F6)
|
|
|
|
// Echo is SPLIT by stage (owner decision, D39.18-follow-up): a translator echo (draft) and an editor
|
|
// echo (edit) measure different things and were conflated by the old single echo_rate + a c-lite
|
|
// re-derive hack. echo_draft = the DRAFT quality (fraction of draft-stage rows flagged cjk_artifact,
|
|
// INCLUDING a c-lite dropped member — the translator echoed even if the editor recovered the unit),
|
|
// computed DIRECTLY from the draft rows (no per-unit re-derivation). echo_edit = the EDITOR's own
|
|
// quality (fraction of edit-stage rows whose EDITOR output echoed — a skipped edit row is a draft echo,
|
|
// not an editor one, so it is excluded from the numerator).
|
|
//
|
|
// ⚠ «THE DELIVERED QUALITY» STOOD HERE AND IS NO LONGER WHAT THIS NUMBER MEANS. It counted an editor
|
|
// echo only while the echo was still the row's VERDICT, so a book that recovered one read zero — and
|
|
// once a stop mark gave a refused re-attack a row, the rate FELL on the run that had paid for the echo.
|
|
// The numerator now counts the editor's echo whatever happened to it next, exactly as the draft side
|
|
// does and for the same ratified reason (the metric watches the MODEL, not our success at papering
|
|
// over it). Consequence to read deliberately: `echo_edit_rate` on a book measured BEFORE this change is
|
|
// not comparable with one measured after, and `EchoEditRecovered` below is what keeps the headline from
|
|
// reading as fresh breakage on a book that shipped clean.
|
|
EchoDraftChunks int `json:"echo_draft_chunks"` // draft-stage rows whose translator echoed (survived OR recovered)
|
|
// EchoDraftRecovered is the SUBSET of EchoDraftChunks the escalation hop (or a regenerate) fixed, so
|
|
// the chunk shipped clean. It keeps the headline number honest in BOTH directions: the rate measures
|
|
// the translator (an echo happened), this says what it cost us (nothing, except the wasted primary
|
|
// call). Without the split, surfacing recovered echoes would read as new breakage on a clean book.
|
|
//
|
|
// ⚠ THIS NUMERATOR WAS NARROWED by the same pack that widened the edit one, so that the two sides ask
|
|
// one question. It used to count every echo the row's verdict no longer was — including a re-roll that
|
|
// came back with a DIFFERENT failure and a re-attack a ceiling refused, neither of which produced
|
|
// anything — and it now asks shippedText. Consequence: `echo_draft_recovered` measured before this
|
|
// change reads HIGHER than the same rows read now, so the two are not comparable across it.
|
|
EchoDraftRecovered int `json:"echo_draft_recovered,omitempty"`
|
|
EchoDraftRate float64 `json:"echo_draft_rate"` // over live draft rows
|
|
// EchoEditUnits counts edit-stage ROWS whose editor output echoed. ⚠ THE NAME SAYS UNITS AND THE COUNTER
|
|
// SAYS ROWS, and the two are the same number only while the edit wave has one stage — which every
|
|
// EXECUTABLE shipping config has today, so no published number moves. The pin on a two-stage edit wave
|
|
// makes the difference visible (rate 0.50 over two rows of ONE unit); renaming a key the report already
|
|
// publishes is a question for the owner, asked in this pack's report rather than answered here.
|
|
EchoEditUnits int `json:"echo_edit_units"`
|
|
// EchoEditRecovered is the same split the draft side carries, for the same reason: the subset of
|
|
// EchoEditUnits whose ROW produced text that went on, so the echo itself cost the book nothing beyond
|
|
// the call it wasted. ROW and not unit, exactly as on the draft side — where the edit wave declares a
|
|
// second stage, a later stage of it can flag and the unit ship nothing, and the echo was cured all the
|
|
// same (shippedText says why this is the deliberate reading). Taking the numerator from the draft side
|
|
// without taking this guard is what left an operator reading «echo edit=1 (100.0%)» over a book that
|
|
// had delivered clean prose.
|
|
EchoEditRecovered int `json:"echo_edit_recovered,omitempty"`
|
|
EchoEditRate float64 `json:"echo_edit_rate"` // over live edit rows
|
|
|
|
// CosmeticStripRate is over ProcessedUnits (0..1). It covers BOTH strip classes (a markdown-only strip
|
|
// is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate counted every sanitizer_stripped unit).
|
|
CosmeticStripRate float64 `json:"cosmetic_strip_rate"`
|
|
|
|
// RepairCandidates is the ADDRESSABLE-defect residual (pack-16, D39.24): how many defects of the
|
|
// anchored classes survive in the SHIPPED text of this run, counted after the uniqueness and
|
|
// disjointness guards — i.e. how many a repair loop would actually attack. It is the measurement that
|
|
// gates whether the paid loop is worth enabling at all, and it costs $0: the same read-only projection
|
|
// that already recomputes the structural KPI re-runs the deterministic detectors over the exported text
|
|
// and the manifest source. Zero for a book whose pair/target ships no checker data.
|
|
//
|
|
// CAVEAT (stated, not silent): the scan measures the EXPORT-normalised text, while the in-loop detector
|
|
// would see the raw completion. The two differ only by the recoverable-glyph fold, so a candidate count
|
|
// can differ by the rare defect that the export contract itself repairs.
|
|
RepairCandidates int `json:"repair_candidates,omitempty"`
|
|
// RepairCandidatesByClass breaks the residual down per class (json.Marshal sorts the keys, so the
|
|
// rendering is deterministic). nil when nothing fired, so a clean book's report is byte-identical to
|
|
// what it was before this field existed.
|
|
RepairCandidatesByClass map[string]int `json:"repair_candidates_by_class,omitempty"`
|
|
// RepairCalls / RepairApplied / RepairDeclined / RepairRejected are the loop's OUTCOME counters, DERIVED
|
|
// from durable artifacts rather than stored (§15.2 B): a counter column on retrieval_state would be wiped
|
|
// by the draft wave's unconditional row rewrite on every resumed run, while checkpoints and the derived
|
|
// final_hash namespace survive. Declined = the model answered "no change", i.e. OUR flag was the false
|
|
// positive — the loop's own precision measurement. All omitempty: a book that never repaired is
|
|
// byte-identical to before these fields existed.
|
|
RepairCalls int `json:"repair_calls,omitempty"`
|
|
RepairApplied int `json:"repair_applied,omitempty"`
|
|
RepairDeclined int `json:"repair_declined,omitempty"`
|
|
RepairRejected int `json:"repair_rejected,omitempty"`
|
|
|
|
// DegenerateLoopRuns counts runs of ≥ segmentLoopMinRun consecutive units whose exported MODEL text is
|
|
// byte-identical after whitespace normalization — a degenerate translation loop (pack-13 point-10,
|
|
// research/21; complements echoMineViolation on the degenerate path research/15). Observability only,
|
|
// never a disposition or a wire touch; 0 on a healthy book (every unit's source, and so its translation,
|
|
// differs). The signature is over the MODEL output (checks.ExportNormalize, BEFORE the deterministic title is
|
|
// prepended), so a per-chapter «Глава N» never masks a body loop. omitempty keeps a loop-free run's
|
|
// report byte-identical to before this field existed.
|
|
DegenerateLoopRuns int `json:"degenerate_loop_runs,omitempty"`
|
|
|
|
// The UNSIGNED-BANK channel (pack-20 / D39.42 п.4). In the auto mode the bank carries renderings nobody
|
|
// signed, and the run needs to say so out loud rather than let them pass for canon:
|
|
// • UnsignedBankTerms — how many rows of the bank are unsigned right now (the exposure);
|
|
// • UnverifiedShown — how many times such a row could be JUDGED in a unit, i.e. its key fired in
|
|
// that unit's source (the denominator). NOT the count of rows the model was shown: a sticky carry is
|
|
// shown and deliberately not judged, since its src is back in the previous chunk;
|
|
// • UnverifiedFollowed— of those, how often the model went along with the proposed rendering.
|
|
// None of them is a verdict: an unsigned row is a candidate the model is entitled to reject, so a low
|
|
// follow rate is information about the CHANNEL, not a defect in the text. All omitempty, so a book with
|
|
// a fully signed bank reports byte-identically to before these fields existed.
|
|
UnsignedBankTerms int `json:"unsigned_bank_terms,omitempty"`
|
|
UnverifiedShown int `json:"unverified_shown,omitempty"`
|
|
UnverifiedFollowed int `json:"unverified_followed,omitempty"`
|
|
|
|
// The pack-19 flaggers (D39.55). VoiceFlags is axes A-C — the T/V contradictions, flattened
|
|
// self-designations and forbidden lexemes the run found in ATTRIBUTED replies; VoiceReplies /
|
|
// VoiceAttributed are its denominators, without which the count cannot be read. VoicePairRegister is
|
|
// axis D, the registry check, reported apart from the count because its addressee comes from a
|
|
// heuristic rather than from attribution. SpoilerLeaks is the reveal half of D21 п.3: renderings the
|
|
// spoiler window rejected for their chapter that reached the shipped text anyway — the only one of
|
|
// the four that is a safety signal rather than a style measurement. None gates anything; all
|
|
// omitempty, so a book without voice content reports byte-identically to before.
|
|
VoiceFlags int `json:"voice_flags,omitempty"`
|
|
VoiceReplies int `json:"voice_replies,omitempty"`
|
|
VoiceAttributed int `json:"voice_attributed,omitempty"`
|
|
VoicePairRegister int `json:"voice_pair_register,omitempty"`
|
|
SpoilerLeaks int `json:"spoiler_leaks,omitempty"`
|
|
// VoiceCheckVersion names the rules that produced those counts. The voice gate is deliberately not
|
|
// snapshot-folded (config.VoiceGate), so the version travels with the numbers instead — the same
|
|
// mitigation the terminologist uses for the same trade-off.
|
|
VoiceCheckVersion string `json:"voice_check_version,omitempty"`
|
|
|
|
// EscalationHops / SpendByModel are the money-side content-label provenance (B6): how many fallback
|
|
// CALLS the book actually paid for (the per-unit `escalated` boolean cannot count them) and how the
|
|
// spend splits across model slugs — which is what answers "what did the label-routed endpoint cost"
|
|
// without a schema migration and without inventing a synthetic call class (a new Role would be a new
|
|
// request-hash axis, i.e. a fresh PAID call). Both derived from durable checkpoints; both omitempty, so
|
|
// a book that never escalated and never ran is byte-identical to before these fields existed.
|
|
// PaidTail decomposes the book's committed spend by WHAT IT BOUGHT — shipped text, work a later call
|
|
// replaced, and work that produced nothing shippable. The money was always visible as a TOTAL and never
|
|
// as this split, and on the first paid run the part that bought nothing was the largest single item.
|
|
// See paidtail.go for why it is derived from checkpoints and not from `request_log.ok`.
|
|
PaidTail *PaidTail `json:"paid_tail,omitempty"`
|
|
EscalationHops int `json:"escalation_hops,omitempty"`
|
|
SpendByModel map[string]float64 `json:"spend_by_model,omitempty"`
|
|
// ContentLabels / Routing repeat the status projection here so a quality report read on its own still
|
|
// says which endpoints produced the text it judges.
|
|
ContentLabels []string `json:"content_labels,omitempty"`
|
|
Routing []string `json:"routing,omitempty"`
|
|
// Consistency answers the owner's priority #1 over the SHIPPED text of the whole book — did one term
|
|
// reach the reader as one rendering, and was it the bank's (backlog row 406). Every other terminology
|
|
// signal in this struct is per chunk and in flight; this one is the only cross-chapter question the
|
|
// engine asks. nil when the book has no bank to judge against, so a bankless run's report is
|
|
// byte-identical to what it was before the field existed.
|
|
Consistency *BookConsistency `json:"consistency,omitempty"`
|
|
// Waves is what each wave was SHOWN, per wave (schema v17 / backlog row 417). The draft and the editor
|
|
// select over different banks, and until this table existed only the draft's numbers survived — the unit
|
|
// merge overwrote the leader row with the editor's post-check while leaving the draft's injection counts
|
|
// in place. Empty for a run made before the table, which is why an empty slice prints nothing at all
|
|
// rather than a row of zeroes.
|
|
Waves []WaveInjection `json:"waves,omitempty"`
|
|
|
|
Chunks []ChunkQuality `json:"chunks,omitempty"`
|
|
}
|
|
|
|
// ChunkQuality is one chunk's per-chunk quality row (the "where did quality slip" signal).
|
|
type ChunkQuality struct {
|
|
Chapter int `json:"chapter"`
|
|
ChunkIdx int `json:"chunk_idx"`
|
|
NarrativeSentences int `json:"narrative_sentences"`
|
|
NarrativeParagraphs int `json:"narrative_paragraphs"`
|
|
DialogueDashFlags int `json:"dialogue_dash_flags"`
|
|
GlossaryMisses int `json:"glossary_misses"`
|
|
NumberDriftFlags int `json:"number_drift_flags"`
|
|
TrustGated int `json:"trust_gated"`
|
|
// RepairCandidates is this unit's addressable-defect residual (pack-16); omitted when zero so a clean
|
|
// unit's row is byte-identical to what it was before the field existed.
|
|
RepairCandidates int `json:"repair_candidates,omitempty"`
|
|
}
|
|
|
|
// shippedText says this ROW produced text that went on — its `final_hash` points at a checkpoint. It does
|
|
// NOT say the unit shipped, and that holds on BOTH arms of the echo split below.
|
|
//
|
|
// On the DRAFT arm the two never coincide wherever an editor follows: the draft row can carry a hash and
|
|
// the editor withhold the unit afterwards (measured: `echo_draft_recovered=1` beside `text_units=0` and a
|
|
// `withheld` hole), and all four shipping configs put the final stage after the draft.
|
|
//
|
|
// On the EDIT arm they coincide only while the edit WAVE has ONE stage — `waveStages` puts every
|
|
// non-translator stage in that wave (snapshot.go), so a second stage there makes the first one a row that
|
|
// is not the unit's last word, and its cured echo is then counted while the unit ships nothing (measured
|
|
// on that shape: `echo_edit_recovered=1` beside `text_units=0`). ⚠ WHAT IS AND IS NOT REACHABLE TODAY,
|
|
// because the near miss is easy to write down wrong: of the four shipping configs three declare a
|
|
// one-stage edit wave, and the fourth — c2, with `select`+`edit` — the engine REFUSES to run at all
|
|
// (`CheckRunnable`: core C2, and `role: judge`, are Phase-0 unexecutable; runner.go calls it before the
|
|
// store is even opened). So the shape is unreachable on every EXECUTABLE shipping config, and c2 is no
|
|
// evidence of anything — the repo has already withdrawn one counterexample resting on it
|
|
// (docs/architecture/13-tech-debt-anchors.md, «c2 неисполняем CheckRunnable»). What makes the shape live
|
|
// is data, not code: a SECOND `editor` stage in an executable config passes CheckRunnable and runs, which
|
|
// is exactly what the pin for this builds. ⚠ And one stage in the wave is necessary, not sufficient: a
|
|
// unit whose source moved under it (`HoleStale`) or that lost a member still parts company with its row.
|
|
// volume.go states the same trap about the same column in its own words (⛔ THE SHIPPING ROW AND NOT ANY ROW).
|
|
//
|
|
// ⛔ AND THE ECHO COUNTERS READ THE ROW DELIBERATELY, both of them: the metric asks what the MODEL did and
|
|
// what curing it cost (D39.18 — it watches the model, not our success at papering over it). An echo the hop
|
|
// fixed cost the book nothing whether or not a LATER stage then flagged the unit for its own reasons, and
|
|
// the unit's absence is what the hole counters report. ⚠ THIS IS THE READING THE CODE TAKES, NOT A
|
|
// RATIFIED ONE: the axis «echo counted over shipped text only» is an OPEN question left to the owner in
|
|
// D39.18 itself (tech-debt anchor «echo только в шипнутом тексте»), and if it is decided the other way the
|
|
// change is a different counter beside this one, not a quiet re-pointing of a name the report publishes.
|
|
//
|
|
// It is asked of `final_hash` rather than of the disposition because that is the fact it needs: a row
|
|
// without a pointer produced nothing whatever it is called, and a row with one did — an `ok` verdict, or
|
|
// the one flagged verdict whose cleaned remainder IS the export (a cosmetic sanitizer strip, which
|
|
// stagerun.go calls recovered in its own words).
|
|
func shippedText(cs store.ChunkStatus) bool { return cs.FinalHash != "" }
|
|
|
|
// QualityReport builds the read-only per-run quality projection. It opens no jobs, reserves nothing,
|
|
// makes no LLM call — it reads the persisted chunk_status / retrieval_state and, for each chunk with
|
|
// an exported final text, the $0 final checkpoint to recompute the structural KPI. Safe to run
|
|
// whenever `report`/`status` are (the same exclusive-lock rule). Deterministic over the store.
|
|
// CAVEAT (same class as Status's config-drift): the exported-text signals key on the CURRENT config's
|
|
// final-stage NAME; if a config edit renamed the final stage since the run, the stored rows use the
|
|
// old name and the structural KPI / echo / CJK rates read 0 (the underlying spend/verdict rows are
|
|
// untouched — surface `status` shows the drift). A run under the same config reads correctly.
|
|
func (r *Runner) QualityReport() (*QualityReport, error) {
|
|
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Total = the SHIPPING units (the manifest re-chunk, $0), matching status/export + the per-unit
|
|
// BookResult (R1): under the wave model the editor's final text is per EDIT UNIT, so ProcessedUnits
|
|
// (the lastStage rows, one per unit leader) and TotalUnits agree at unit granularity. The per-unit
|
|
// KPI/strip signals land on the leader's edit row; a non-leader member's ChunkQuality row carries
|
|
// only its draft-side signals (trust-gated) with a 0 structural KPI — observability, never a gate.
|
|
chunks, err := r.bookChunks()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
units := r.outputUnits(chunks)
|
|
// GHOST guard (parity with Export/Status): a stored row whose unit-leader key is NOT in the current
|
|
// manifest (source shrank since the run) is a ghost — dropping it keeps ProcessedUnits ≤ TotalUnits.
|
|
inManifest := map[chunkKey]bool{}
|
|
for _, u := range units {
|
|
inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true
|
|
}
|
|
// A retrieval_state row / a draft echo is keyed per DRAFT chunk, so it is live iff its chunk is still in
|
|
// the manifest; a chunk that left the source is a ghost.
|
|
liveChunks := map[chunkKey]bool{}
|
|
for _, ch := range chunks {
|
|
liveChunks[chunkKey{ch.Chapter, ch.ChunkIdx}] = true
|
|
}
|
|
// Unit source, keyed by the leader row the final-stage verdict lives on — the src side the comparative
|
|
// detectors need. It is the $0 manifest re-chunk (never a stored or billed artifact), joined exactly as
|
|
// the editor's input is (wave.go sourceText).
|
|
unitSource := make(map[chunkKey]string, len(units))
|
|
for _, u := range units {
|
|
unitSource[chunkKey{u.Chapter, u.FirstChunkIdx}] = u.sourceText()
|
|
}
|
|
repairCfg := r.cheapGateConfig()
|
|
// Same target gate the in-loop path applies: the Latin-residue class cannot be made inert by data, so on
|
|
// a Latin-script target it would report every word as an addressable defect and make the residual
|
|
// meaningless. Gates on the target's DECLARED word script (data): non-Latin only, inert by absence.
|
|
repairLatinOK := r.checkers.TargetScriptNonLatin()
|
|
// Wave stage-name sets classify a stored chunk_status row by wave for the split echo metric (D39.18
|
|
// owner decision): echo_draft is counted DIRECTLY from the draft rows (no c-lite per-unit re-derivation
|
|
// — a dropped member's own draft row already carries cjk_artifact), echo_edit from the edit rows.
|
|
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
|
|
editStageNames := stageNameSet(r.waveStagesIndexed(waveEdit))
|
|
|
|
rep := &QualityReport{BookID: r.Book.BookID, TotalUnits: len(units)}
|
|
// The unsigned-bank exposure is a property of the BANK, not of any chunk row: how much of what the
|
|
// model is being shown carries a rendering nobody approved.
|
|
bankRows, gerr := r.Store.GlossaryForBook(r.Book.BookID)
|
|
if gerr != nil {
|
|
// A read failure would otherwise report ZERO unsigned terms — indistinguishable from a fully signed
|
|
// bank, which is the reassuring answer. Say the number is unknown instead of implying it is zero.
|
|
r.Log.Warn("quality: could not read the bank; the UNSIGNED BANK count is unknown, not zero", "err", gerr)
|
|
bankRows = nil
|
|
}
|
|
for _, e := range bankRows {
|
|
if e.Status != "approved" && strings.TrimSpace(e.Dst) != "" {
|
|
rep.UnsignedBankTerms++
|
|
}
|
|
}
|
|
// The book-consistency scan (row 406) judges the SHIPPED text against the same rows the unsigned count
|
|
// above was drawn from, so one report can never describe two banks. A read-only surface arrives with no
|
|
// bank materialized, so the stored glossary is folded here; a live runner already carries the bank it
|
|
// translated with and is left alone — re-materializing would drop its rendered-hash memo and make the
|
|
// next wire render pay again.
|
|
var cons *consistencyScan
|
|
if len(bankRows) > 0 {
|
|
if r.memory == nil {
|
|
if perr := r.projectStoredMemory(); perr != nil {
|
|
// Same discipline as the read failure above: a bank that could not be materialized makes the
|
|
// measure UNKNOWN. Reporting a clean book instead would be the reassuring answer again.
|
|
r.Log.Warn("quality: could not materialize the bank; the BOOK CONSISTENCY measure is unknown, not clean", "err", perr)
|
|
}
|
|
}
|
|
cons = newConsistencyScan(r.memory)
|
|
}
|
|
byChunk := map[chunkKey]*ChunkQuality{}
|
|
order := []chunkKey{}
|
|
chunkOf := func(k chunkKey) *ChunkQuality {
|
|
if q := byChunk[k]; q != nil {
|
|
return q
|
|
}
|
|
q := &ChunkQuality{Chapter: k.chapter, ChunkIdx: k.chunkIdx}
|
|
byChunk[k] = q
|
|
order = append(order, k)
|
|
return q
|
|
}
|
|
|
|
// The glossary post-check GATE flips a chunk to withheld (flagged glossary_miss, empty export) at
|
|
// the CHUNK level without a chunk_status row (like Export/Status re-derive it). F5 (D39.4): the
|
|
// structural KPI must EXCLUDE these — their export is "", so counting their (withheld) text in
|
|
// TextUnits/KPI diverges from what `tmctl export` and `translate` actually ship.
|
|
gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
|
|
withheld := map[chunkKey]bool{}
|
|
|
|
// Aggregate the stored retrieval-state signals (glossary consistency, style breakdown, trust-gated).
|
|
// A retrieval_state row is keyed per DRAFT chunk (live iff its chunk is still in the manifest); a leader
|
|
// row also carries the unit's post-check, so if the leader survives, so does the unit.
|
|
for _, rs := range states {
|
|
if !liveChunks[chunkKey{rs.Chapter, rs.ChunkIdx}] {
|
|
continue // ghost retrieval_state row (chunk dropped from source)
|
|
}
|
|
q := chunkOf(chunkKey{rs.Chapter, rs.ChunkIdx})
|
|
q.GlossaryMisses += rs.NPostcheckMiss
|
|
q.TrustGated += rs.NTrustGatedSuppress
|
|
rep.GlossaryMisses += rs.NPostcheckMiss
|
|
rep.TrustGated += rs.NTrustGatedSuppress
|
|
rep.UnverifiedShown += rs.NUnverifiedShown
|
|
rep.UnverifiedFollowed += rs.NUnverifiedFollowed
|
|
rep.VoiceFlags += rs.NVoiceFlags
|
|
rep.SpoilerLeaks += rs.NSpoilerLeaks
|
|
if rs.VoiceDetail != "" {
|
|
var v checks.VoiceResult
|
|
if json.Unmarshal([]byte(rs.VoiceDetail), &v) == nil {
|
|
rep.VoiceReplies += v.Replies
|
|
rep.VoiceAttributed += v.Attributed
|
|
rep.VoicePairRegister += v.PairRegister
|
|
}
|
|
}
|
|
if gateOn && rs.NPostcheckMiss > 0 {
|
|
withheld[chunkKey{rs.Chapter, rs.ChunkIdx}] = true
|
|
}
|
|
if rs.NStyleFlags > 0 && rs.StyleDetail != "" {
|
|
var cg checks.CheapGateResult
|
|
if json.Unmarshal([]byte(rs.StyleDetail), &cg) == nil {
|
|
dash := cg.DialogueDash
|
|
drift := cg.NumberDrift + cg.NumberMagnitude
|
|
q.DialogueDashFlags += dash
|
|
q.NumberDriftFlags += drift
|
|
rep.DialogueDashFlags += dash
|
|
rep.NumberDriftFlags += drift
|
|
}
|
|
}
|
|
}
|
|
|
|
// Split echo by stage (D39.18 owner decision): echo_draft over the DRAFT-stage rows (translator quality,
|
|
// INCLUDING a c-lite dropped member — its own draft row carries cjk_artifact, so no per-unit re-derivation
|
|
// is needed), echo_edit over the EDIT-stage rows (the EDITOR's own quality WHATEVER HAPPENED TO THE ECHO
|
|
// NEXT — only the editor's own echo counts, and a skipped edit row is a draft echo not an editor one).
|
|
// «The delivered quality» stood here, and the field's own caveat says why it stopped being true.
|
|
// Ghost-guarded like the rest (live chunks / units).
|
|
var draftRows, draftEcho, draftEchoRecovered, editRows, editEcho, editEchoRecovered int
|
|
for _, cs := range statuses {
|
|
k := chunkKey{cs.Chapter, cs.ChunkIdx}
|
|
switch {
|
|
case draftStageNames[cs.Stage] && liveChunks[k]:
|
|
draftRows++
|
|
// The translator echoed CJK. BOTH columns count, and the difference between them is the
|
|
// whole point: `flag_reason` is an echo that SURVIVED (the chunk shipped flagged, incl. a
|
|
// c-lite dropped member), `first_flag_reason` is an echo the row's own verdict is no longer.
|
|
// Reading only the verdict column measured "echoes we failed to fix" and called it the echo
|
|
// rate: the mini-run of 25.07 escalated its one echoed draft, and the report said 0.0% of 20.
|
|
// The metric watches the TRANSLATOR (D18/D19 echo mine), not our success at papering over it.
|
|
//
|
|
// ⛔ «NO LONGER THE VERDICT» IS NOT «RECOVERED», and counting it as one was a lie the column
|
|
// could tell by itself. A superseded echo reaches this branch FIVE ways and only two are a
|
|
// recovery: a later attempt or the escalation hop translated the unit properly and the row was
|
|
// written `ok`; the hop's answer was a COSMETIC strip, whose cleaned text ships and which the
|
|
// executor itself calls recovered (stagerun.go); the re-roll came back with a DIFFERENT failure;
|
|
// a USD ceiling refused to buy the re-roll at all and the row carries `retry_unaffordable`; or a
|
|
// person cut the run mid-call and it carries `cancelled` — THIS PACK gave that mark the first-flag
|
|
// column too (cutcall.go), which is what makes the fifth way reachable at all. The last three
|
|
// ship nothing, and `regenerate_echo_before_escalate: 1` stands in all four shipping pipelines,
|
|
// so the re-roll that produces them is live rather than theoretical.
|
|
//
|
|
// ⚠ SO THE RECOVERY COUNT ASKS WHETHER THE ROW PRODUCED TEXT, not which disposition it wears:
|
|
// keyed on `ok` alone it loses the stripped recovery — a real cure, text on disk, reported as
|
|
// none. ⚠ On THIS arm that shape needs a draft-only pipeline: the sanitizer runs on the final
|
|
// stage only (chunkrun.go), so wherever an editor follows, a draft row is never
|
|
// `sanitizer_stripped` and the stripped recovery arrives on the edit arm below — where the gate
|
|
// is on in three of the four shipping pipelines. The echo count asks neither question, because
|
|
// the model echoed either way.
|
|
switch {
|
|
case cs.FlagReason == string(FlagCJKArtifact):
|
|
draftEcho++
|
|
case cs.FirstFlagReason == string(FlagCJKArtifact):
|
|
draftEcho++
|
|
if shippedText(cs) {
|
|
draftEchoRecovered++
|
|
}
|
|
}
|
|
case editStageNames[cs.Stage] && inManifest[k]:
|
|
editRows++
|
|
// The EDITOR's OWN output echoed — and, exactly as on the draft side above, BOTH columns say so.
|
|
// A skipped edit row means the drafts echoed and not the editor, and it carries no first flag,
|
|
// so it is counted by neither arm.
|
|
//
|
|
// ⛔ THE SECOND ARM USED TO BE MISSING, AND THIS PACK MADE THE GAP COST SOMETHING. Without it the
|
|
// counter sees an editor echo only while it is still the row's VERDICT: an echo a re-roll
|
|
// recovered read as zero (pre-existing), and — once the stop mark gave the position a row — an
|
|
// echo whose re-roll a ceiling REFUSED left the numerator while staying in `editRows`, so the
|
|
// edit echo RATE FELL on the run that had just paid for an echo. Measured on that shape by an
|
|
// adversarial pass: `cost_usd=0.001820` on the editor's own echo, `echo_edit_units=0`. It is the
|
|
// same rule the draft counter states in its own words — the metric watches the MODEL, not our
|
|
// success at papering over it (D39.18) — applied to the stage that was missing it.
|
|
switch {
|
|
case cs.Disposition == string(DispFlagged) && cs.FlagReason == string(FlagCJKArtifact):
|
|
editEcho++
|
|
case cs.FirstFlagReason == string(FlagCJKArtifact):
|
|
editEcho++
|
|
if shippedText(cs) {
|
|
editEchoRecovered++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
rep.EchoDraftChunks, rep.EchoDraftRecovered = draftEcho, draftEchoRecovered
|
|
rep.EchoEditUnits, rep.EchoEditRecovered = editEcho, editEchoRecovered
|
|
if draftRows > 0 {
|
|
rep.EchoDraftRate = float64(draftEcho) / float64(draftRows)
|
|
}
|
|
if editRows > 0 {
|
|
rep.EchoEditRate = float64(editEcho) / float64(editRows)
|
|
}
|
|
|
|
// The exported-text units (the FINAL stage's row): the cosmetic-strip rate + the structural KPI.
|
|
lastStage := r.finalStageName()
|
|
loopText := map[chunkKey]string{} // per-unit exported MODEL text (no title), for the pack-13 point-10 loop scan
|
|
for _, cs := range statuses {
|
|
k := chunkKey{cs.Chapter, cs.ChunkIdx}
|
|
if cs.Stage != lastStage || !inManifest[k] {
|
|
continue // the final verdict lives on the final stage's row (per unit); drop ghost leader rows
|
|
}
|
|
// Every unit with a lastStage row counts once in the strip-rate denominator (ok, cosmetic-strip, or
|
|
// skipped-because-a-member-flagged).
|
|
//
|
|
// ⚠ «REACHED THE FINAL STAGE» USED TO STAND HERE AND IS TOO STRONG NOW, though less so than it
|
|
// looks: a stop mark IS written on a stage the run was executing and did pay for (an attempt on
|
|
// `retry_unaffordable`, a call on the wire for `cancelled`) — what it did not do is finish. So the
|
|
// row belongs in a «positions this book has spent on» denominator and not in a «units that have a
|
|
// final answer» one. Left alone deliberately: the alternative is a denominator that moves between
|
|
// two runs of the same book, which is worse for a rate somebody compares across runs.
|
|
rep.ProcessedUnits++
|
|
if cs.FlagReason == string(FlagSanitizerStripped) {
|
|
rep.CosmeticStripUnits++ // a stripped unit carried a markdown OR CJK cosmetic leak (F6)
|
|
}
|
|
// Structural KPI: recompute over the exported final text (ok, or the cosmetic-stripped export).
|
|
if cs.FinalHash == "" {
|
|
continue
|
|
}
|
|
if cs.Disposition != string(DispOK) && cs.FlagReason != string(FlagSanitizerStripped) {
|
|
continue // a dropped unit exported nothing
|
|
}
|
|
if withheld[k] {
|
|
continue // F5: the glossary gate withheld this unit's text — it exports nothing
|
|
}
|
|
cp, cperr := r.Store.GetCheckpoint(cs.FinalHash)
|
|
if cperr != nil {
|
|
return nil, cperr
|
|
}
|
|
if cp == nil || strings.TrimSpace(cp.ResponseText) == "" {
|
|
continue
|
|
}
|
|
normText := r.checkers.ExportNormalize(cp.ResponseText)
|
|
loopText[k] = normText
|
|
// The consistency scan reads the pair this loop already holds and nothing else: the $0 re-chunked
|
|
// SOURCE of the unit and the text that unit actually shipped. Inert when the book has no bank.
|
|
cons.add(k.chapter, unitSource[k], normText)
|
|
// Addressable-defect residual (pack-16): the same guards the repair sub-step applies — uniqueness
|
|
// inside RepairCandidates, then sentence-expansion + disjointness — so the number is «how many
|
|
// repairs would actually be attempted», not «how many flags exist».
|
|
scanned := checks.RepairCandidates(unitSource[k], normText, repairCfg)
|
|
if !repairLatinOK {
|
|
kept := scanned[:0]
|
|
for _, c := range scanned {
|
|
if c.Class != checks.RepairLatinResidue {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
scanned = kept
|
|
}
|
|
if cands := checks.DisjointCandidates(normText, scanned); len(cands) > 0 {
|
|
if rep.RepairCandidatesByClass == nil {
|
|
rep.RepairCandidatesByClass = map[string]int{}
|
|
}
|
|
for _, c := range cands {
|
|
rep.RepairCandidatesByClass[string(c.Class)]++
|
|
}
|
|
rep.RepairCandidates += len(cands)
|
|
chunkOf(k).RepairCandidates += len(cands)
|
|
}
|
|
sent, para := checks.NarrativeStructure(normText)
|
|
q := chunkOf(k)
|
|
q.NarrativeSentences, q.NarrativeParagraphs = sent, para
|
|
rep.NarrativeSentences += sent
|
|
rep.NarrativeParagraphs += para
|
|
rep.TextUnits++
|
|
}
|
|
|
|
rep.Consistency = cons.finish(bankRows)
|
|
if waves, werr := r.Store.WaveSelectionsForBook(r.Book.BookID); werr != nil {
|
|
// Same discipline as the bank read above: a failure here makes the per-wave picture UNKNOWN, and
|
|
// saying so beats printing an empty one that reads as "no wave injected anything".
|
|
r.Log.Warn("quality: could not read the per-wave selection; the WAVE INJECTION lines are unknown, not empty", "err", werr)
|
|
} else {
|
|
rep.Waves = waveInjections(waves)
|
|
}
|
|
|
|
// Degenerate-loop guard (pack-13 point-10): scan the exported MODEL texts in reading (manifest/unit)
|
|
// order for runs of identical consecutive units — observability, never a gate. Deterministic.
|
|
ordered := make([]string, 0, len(units))
|
|
for _, u := range units {
|
|
ordered = append(ordered, loopText[chunkKey{u.Chapter, u.FirstChunkIdx}])
|
|
}
|
|
rep.DegenerateLoopRuns = len(segmentLoopRuns(ordered, segmentLoopMinRun))
|
|
|
|
// Repair outcomes, derived (never stored) — see the field docs.
|
|
if calls, declined, applied, rerr := r.Store.RepairStats(r.Book.BookID, repairNoChange, repairDerivedNS+":"); rerr == nil {
|
|
rep.RepairCalls, rep.RepairDeclined, rep.RepairApplied = calls, declined, applied
|
|
if n := calls - declined - applied; n > 0 {
|
|
rep.RepairRejected = n // paid, neither declined nor applied ⇒ a guard or the re-gate refused it
|
|
}
|
|
} else {
|
|
return nil, rerr
|
|
}
|
|
if rep.NarrativeParagraphs > 0 {
|
|
rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs)
|
|
}
|
|
if rep.ProcessedUnits > 0 {
|
|
rep.CosmeticStripRate = float64(rep.CosmeticStripUnits) / float64(rep.ProcessedUnits)
|
|
}
|
|
// Money provenance, derived (never stored) — see the field docs. Both aggregates are read-only and
|
|
// migration-free; a read failure is an infra fault, not a silently zeroed counter.
|
|
hops, herr := r.Store.EscalationHops(r.Book.BookID)
|
|
if herr != nil {
|
|
return nil, herr
|
|
}
|
|
rep.EscalationHops = hops
|
|
byModel, merr := r.Store.SpendByModel(r.Book.BookID)
|
|
if merr != nil {
|
|
return nil, merr
|
|
}
|
|
rep.SpendByModel = byModel
|
|
// The decomposition of that same money by what it bought. Both reads are $0 and already made
|
|
// elsewhere in this function's neighbourhood; a failure DEGRADES (the section is simply absent) rather
|
|
// than failing a read-only report — but it is logged, because an absent section must not be readable
|
|
// as «nothing was lost».
|
|
if usage, uerr := r.Store.CheckpointUsageForBook(r.Book.BookID); uerr != nil {
|
|
r.Log.Warn("report: the paid-tail decomposition could not be read; what the money BOUGHT is unknown, not zero", "err", uerr)
|
|
} else if st, serr := r.Store.ChunkStatusesForBook(r.Book.BookID); serr != nil {
|
|
r.Log.Warn("report: the paid-tail decomposition could not be read; what the money BOUGHT is unknown, not zero", "err", serr)
|
|
} else if t := paidTail(usage, st); t.TotalUSD > 0 {
|
|
rep.PaidTail = &t
|
|
}
|
|
if r.Pipeline.Gates.Voice.Enabled {
|
|
rep.VoiceCheckVersion = checks.VoiceCheckVersion
|
|
}
|
|
if len(r.Book.ContentLabels) > 0 {
|
|
rep.ContentLabels = r.Book.ContentLabels
|
|
rep.Routing = r.contentRoutingRows()
|
|
}
|
|
for _, k := range order {
|
|
rep.Chunks = append(rep.Chunks, *byChunk[k])
|
|
}
|
|
return rep, nil
|
|
}
|