package pipeline import ( "encoding/json" "strings" ) // quality.go: the DETERMINISTIC per-run quality-report (D39 слой 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 "рубленые абзацы" 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 (пак-2). // QualityReport is the whole-book per-run quality projection. type QualityReport struct { BookID string `json:"book_id"` TotalChunks int `json:"total_chunks"` // TextChunks is the number of chunks whose exported final text was available for the structural // KPI (done or cosmetically-stripped); a flagged-empty chunk contributes no prose. TextChunks int `json:"text_chunks"` // ProcessedChunks is the number of chunks that REACHED the final stage (a final-stage row exists: // ok, cosmetic-strip, or skipped-because-upstream-flagged) — the rate denominator, so echo/CJK // rates are a bounded [0,1] fraction of processed chunks (an echo chunk is a skipped final row, // disjoint from the exported-text set, which is why dividing by TextChunks alone could exceed 1). ProcessedChunks int `json:"processed_chunks"` // Claim-1 structural KPI (рубленые абзацы). 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) CosmeticStripChunks int `json:"cosmetic_strip_chunks"` // chunks the sanitizer auto-stripped (markdown header OR CJK leak — F6: was mislabeled cjk_leak) EchoChunks int `json:"echo_chunks"` // chunks flagged cjk_artifact (untranslated echo) // Rates over ProcessedChunks (0..1) for the two "instant unreadability" families, so the numbers // read as a bounded fraction of processed chunks rather than a bare count. CosmeticStripRate 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 chunk and read as false CJK leakage). CosmeticStripRate float64 `json:"cosmetic_strip_rate"` EchoRate float64 `json:"echo_rate"` 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"` } // narrativeStructure computes the claim-1 structural KPI over a FINAL Russian text: the count of // NARRATIVE paragraphs (non-empty lines that are NOT a dialogue turn opened with a dash «—»/«–»/«-») // and the total sentences within them (the oracle-parity splitSentences). Dialogue turns are excluded // (they are legitimately one short line). Deterministic and pure — the same signal the reflow lever // is supposed to move (exp14 mean-sentences-per-paragraph), reused here as in-loop observability. func narrativeStructure(final string) (sentences, paragraphs int) { for _, line := range strings.Split(final, "\n") { t := strings.TrimSpace(line) if t == "" { continue } if rs := []rune(t); rs[0] == '—' || rs[0] == '–' || rs[0] == '-' { continue // a dialogue turn — not a narrative paragraph } paragraphs++ sentences += len(splitSentences(t)) } return sentences, paragraphs } // 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 ProcessedChunks // (the lastStage rows, one per unit leader) and TotalChunks agree at unit granularity. The per-unit // KPI/echo/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 ProcessedChunks ≤ TotalChunks // and the echo/strip rates over live units only, instead of mixing in stale leader rows. inManifest := map[chunkKey]bool{} for _, u := range units { inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true } rep := &QualityReport{BookID: r.Book.BookID, TotalChunks: len(units)} 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 // TextChunks/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, so it is live iff its chunk is still in the manifest; // a chunk that left the source is a ghost. (A leader row also carries the unit's post-check; if the // leader survives, so does the unit.) liveChunks := map[chunkKey]bool{} for _, ch := range chunks { liveChunks[chunkKey{ch.Chapter, ch.ChunkIdx}] = true } 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 if gateOn && rs.NPostcheckMiss > 0 { withheld[chunkKey{rs.Chapter, rs.ChunkIdx}] = true } if rs.NStyleFlags > 0 && rs.StyleDetail != "" { var cg 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 } } } // The exported-text chunks (the FINAL stage's row): echo/CJK flag rates + the structural KPI. lastStage := "" if n := len(r.Pipeline.Stages); n > 0 { lastStage = r.Pipeline.Stages[n-1].Name } 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 chunk that REACHED the final stage has exactly one lastStage row (ok, cosmetic-strip, // or skipped-because-an-upstream-stage-flagged). This is the common rate denominator so the // echo/CJK rates are a bounded fraction of processed chunks (an echo chunk is a skipped final // row, disjoint from the exported-text set — dividing by TextChunks alone could exceed 100%). rep.ProcessedChunks++ if cs.FlagReason == string(FlagCJKArtifact) { rep.EchoChunks++ chunkOf(k) // ensure the chunk row exists even if it has no retrieval-state signals } if cs.FlagReason == string(FlagSanitizerStripped) { rep.CosmeticStripChunks++ // a stripped chunk 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 chunk exported nothing } if withheld[k] { continue // F5: the glossary gate withheld this chunk'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 } sent, para := narrativeStructure(exportNormalize(cp.ResponseText)) q := chunkOf(k) q.NarrativeSentences, q.NarrativeParagraphs = sent, para rep.NarrativeSentences += sent rep.NarrativeParagraphs += para rep.TextChunks++ } if rep.NarrativeParagraphs > 0 { rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs) } if rep.ProcessedChunks > 0 { rep.CosmeticStripRate = float64(rep.CosmeticStripChunks) / float64(rep.ProcessedChunks) rep.EchoRate = float64(rep.EchoChunks) / float64(rep.ProcessedChunks) } for _, k := range order { rep.Chunks = append(rep.Chunks, *byChunk[k]) } return rep, nil }