634 lines
32 KiB
Go
634 lines
32 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"textmachine/backend/internal/checks"
|
|
"textmachine/backend/internal/chunk"
|
|
"textmachine/backend/internal/config"
|
|
"textmachine/backend/internal/llm"
|
|
"textmachine/backend/internal/membank"
|
|
"textmachine/backend/internal/store"
|
|
)
|
|
|
|
// repair.go: the addressable-defect repair sub-step (pack-16, ratified D39.24) — the loop that closes
|
|
// «checker → typed error → targeted repair of the minimal span → deterministic re-gate → otherwise the
|
|
// previous behaviour». It is OPT-IN and OFF by default; with the gate off every function here returns
|
|
// immediately and the runner's bytes, snapshot and golden capture are unchanged.
|
|
//
|
|
// WHERE IT SITS. Inside runStage, after the attempt loop and the escalation resolve, BEFORE chunk_status is
|
|
// written — a sub-step of the FINAL stage rather than a stage of its own. A new stage would join the edit
|
|
// wave, steal isFinal from the editor, move the edit-wave snapshot (re-billing every existing book), add a
|
|
// chunk_status row per unit and wedge status's `expected == ok` arithmetic forever. The sub-step disturbs
|
|
// none of that.
|
|
//
|
|
// WHAT IT MAY DO. Replace a BOUNDED span of a text the stage already resolved OK. It never changes a
|
|
// disposition — not to flagged, not from it. A failure is not a new failure mode: the text stays exactly as
|
|
// it was and the defect stays exactly as visible as it is today (observability). That is what keeps D2
|
|
// intact: the loop cannot make garbage ship, because it only ever runs on output the gates already passed.
|
|
//
|
|
// WHAT PROTECTS IT. Six guards on the reply (see repairGuardsVersion) and a re-gate over the ASSEMBLED text.
|
|
// The re-gate is deliberately POSITIVE where it can be and conservative everywhere else: the shipped
|
|
// checkers all go silent when the offending text is simply DELETED, so "the class stopped firing" is not on
|
|
// its own evidence of repair — deletion must be refused by a content guard, not rewarded by a green re-gate.
|
|
|
|
// roleRepair is the synthetic stage role every repair call is addressed under. It keeps the repair call on
|
|
// its own request-hash axis (Role is a hash field), gives its checkpoints and telemetry rows their own
|
|
// queryable class — which is the cost marker, so no migration is needed to answer "what did repair spend" —
|
|
// and keeps it out of every role-gated path (banknote and the coverage gate are translator-only).
|
|
const roleRepair = "repair"
|
|
|
|
// repairVersion versions the repair ALGORITHM (candidate selection → span expansion → call → apply). It is
|
|
// folded into the snapshot only when the gate is enabled, so a change re-pins repaired books loudly instead
|
|
// of silently re-resolving them, and costs a disabled book nothing.
|
|
const repairVersion = "repair-v1-span-replace"
|
|
|
|
// repairGuardsVersion versions the ACCEPTANCE rules (the reply guards + the re-gate). It is separate from
|
|
// repairVersion because a guard can be tightened without touching the selection algorithm, and either
|
|
// change alters which repairs survive — i.e. the resolved bytes.
|
|
const repairGuardsVersion = "repair-guards-v1-nochange+corridor+nodel+regate"
|
|
|
|
// repairNoChange is the ENGINE sentinel a repair prompt instructs the model to return when the flagged text
|
|
// is in fact correct. It is deliberately a bracketed engine token (the ⟦TM-BANK-v1⟧ idiom) rather than a
|
|
// natural-language phrase: a phrase would be pair data leaking into Go, and any wording would collide with
|
|
// prose. A first-class "no edits" answer is load-bearing, not a nicety — our own research records that on a
|
|
// strong draft no APE system beat doing nothing, and that LLM editors edit correct input when not offered
|
|
// the option. A decline is therefore a SUCCESS of the protocol and is counted separately.
|
|
const repairNoChange = "⟦TM-NOCHANGE⟧"
|
|
|
|
// Reply guards (versioned by repairGuardsVersion).
|
|
const (
|
|
// repairMinSpanRatio / repairMaxSpanRatio bound the replacement against the original span in RUNES. The
|
|
// lower bound is an anti-deletion floor: every shipped checker goes quiet when the offending phrase is
|
|
// deleted, so without it the cheapest possible "repair" — dropping the sentence's content — would pass
|
|
// the re-gate. It does NOT catch a deletion small relative to the span, which is why the token and digit
|
|
// guards below exist as well.
|
|
repairMinSpanRatio = 0.6
|
|
repairMaxSpanRatio = 2.0
|
|
// repairMinWordKeepRatio requires the replacement to keep nearly all of the span's word tokens. The span
|
|
// judged here is the SENTENCE-expanded one, so a permissive ratio would license deleting a whole clause
|
|
// while "repairing" a two-word unit expression: at 0.6 a 21-token sentence could come back as 13 tokens.
|
|
// The design's rule is «tokens(reply) >= tokens(original) - 1» — one token of slack for the conversion
|
|
// itself (e.g. «полчаса» → «час» merges nothing, «три часа» → «шесть часов» keeps the count).
|
|
repairMaxTokenLoss = 1
|
|
// repairMaxSpanBytes bounds a single repair span absolutely: sentence expansion degenerates to the whole
|
|
// text on a target whose terminators are not in the data, and a whole-unit rewrite is not a repair.
|
|
repairMaxSpanBytes = 1200
|
|
)
|
|
|
|
// repairResult is one unit's repair outcome, derived entirely from what actually happened (never stored as
|
|
// its own row): counters for the report and the run's spend on this class.
|
|
type repairResult struct {
|
|
Calls int
|
|
Applied int
|
|
Declined int // the model answered ⟦TM-NOCHANGE⟧ — our flag was a false positive
|
|
Rejected int // a guard or the re-gate refused the reply
|
|
// CostUSD is what THIS run paid; CumUSD is what the calls cost in total (a replayed checkpoint costs $0
|
|
// this run but itsoriginal cost still belongs to the unit's honest total, exactly as retries do).
|
|
CostUSD float64
|
|
CumUSD float64
|
|
// Fresh marks that at least one repair call actually reached the provider this run (a replayed
|
|
// checkpoint is $0 and must not make a resumed stage look like a fresh one).
|
|
Fresh bool
|
|
}
|
|
|
|
func (rr repairResult) touched() bool { return rr.Calls > 0 }
|
|
|
|
// defaultRepairClasses is the engine's ratified default ACTUATOR set — deliberately the smallest one.
|
|
//
|
|
// ABSENT, and why (design §16.1/§16.2, ratified): DC2 (千万/数十万) and 成-percent fire on whole-unit substring
|
|
// probes with no positional relation to the source match, so a span derived from them can point at innocent
|
|
// prose. BOTH DC1 classes are absent for a different reason: their positive post-invariant cannot be
|
|
// expressed on the pair data as it stands (the hours table carries 9 forms, so a CORRECT repair rendered
|
|
// «шести часов» does not parse), and the fractional probe's target side is an unanchored substring scan —
|
|
// so a unit whose 半个时辰 was translated CORRECTLY, but which mentions «полчаса» elsewhere, would have that
|
|
// unrelated sentence rewritten. They remain DETECTORS (the residual scan counts them) and become actuators
|
|
// only when an operator names them explicitly, having accepted that trade.
|
|
var defaultRepairClasses = []checks.RepairClass{
|
|
checks.RepairLatinResidue,
|
|
checks.RepairBrokenWord,
|
|
}
|
|
|
|
// knownRepairClasses is the validation surface for gates.repair.classes.
|
|
var knownRepairClasses = map[string]checks.RepairClass{
|
|
string(checks.RepairDC1TimeUnits): checks.RepairDC1TimeUnits,
|
|
string(checks.RepairDC1Fractional): checks.RepairDC1Fractional,
|
|
string(checks.RepairLatinResidue): checks.RepairLatinResidue,
|
|
string(checks.RepairBrokenWord): checks.RepairBrokenWord,
|
|
}
|
|
|
|
// repairClasses resolves the enabled class set: the configured subset, else the ratified default. Sorted, so
|
|
// the snapshot fold is deterministic.
|
|
func (r *Runner) repairClasses() []string {
|
|
var out []string
|
|
if len(r.Pipeline.Gates.Repair.Classes) > 0 {
|
|
out = append(out, r.Pipeline.Gates.Repair.Classes...)
|
|
} else {
|
|
for _, c := range defaultRepairClasses {
|
|
out = append(out, string(c))
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// loadRepairTemplates resolves one prompt per enabled class by CONVENTION — `<prompts root>/<pair>/repair/
|
|
// <class>.md` — and fails LOUD naming the path when it is missing, exactly as the stage convention does.
|
|
// The prompt carries the typed error: which file is chosen IS the error type, so the class-specific wording
|
|
// («1 时辰 = 2 часа») lives in the pair's own language as data, and Go passes only spans. An unknown class
|
|
// name is a config error here rather than in config/, which cannot import the class vocabulary without an
|
|
// import cycle (internal/checks imports internal/config).
|
|
func (r *Runner) loadRepairTemplates() error {
|
|
if !r.Pipeline.Gates.Repair.Enabled {
|
|
return nil
|
|
}
|
|
dir := r.Pipeline.Gates.Repair.PromptsDir
|
|
r.repairTemplates = map[checks.RepairClass]*PromptTemplate{}
|
|
for _, name := range r.repairClasses() {
|
|
cls, ok := knownRepairClasses[name]
|
|
if !ok {
|
|
return fmt.Errorf("pipeline: gates.repair.classes lists unknown class %q — known: %s", name, strings.Join(sortedClassNames(), ", "))
|
|
}
|
|
// A class whose POSITIVE post-condition cannot be asserted on this pair's data must not be enabled:
|
|
// otherwise every repair of that class is paid for and then rejected, silently burning money. The
|
|
// fractional-unit class needs the pair's bare hour word to verify that the replacement still states a
|
|
// duration (rather than deleting it), so its absence is a loud load-time stop naming the missing key.
|
|
if cls == checks.RepairDC1Fractional && !checks.HasHourWordProbe(r.checkers) {
|
|
return fmt.Errorf("pipeline: gates.repair enables class %q, but the pair pack ships no `hour_word_re` — without it the repair's positive post-condition (the replacement must still state a duration) cannot be asserted and every repair of this class would be paid for and rejected; add the key to the pair's dc-checkers data or drop the class", name)
|
|
}
|
|
path := filepath.Join(dir, name+".md")
|
|
if _, err := os.Stat(path); err != nil {
|
|
return fmt.Errorf("pipeline: gates.repair is enabled for class %q but its prompt is missing — expected %s (a repair prompt is authored in the pair's own language, like the role prompts); add that file or drop the class from gates.repair.classes", name, path)
|
|
}
|
|
tpl, err := LoadPromptTemplate(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.repairTemplates[cls] = tpl
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sortedClassNames() []string {
|
|
out := make([]string, 0, len(knownRepairClasses))
|
|
for k := range knownRepairClasses {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// maybeRepair is the sub-step. It returns the (possibly repaired) final text plus the outcome counters; the
|
|
// original text is returned unchanged on every path that is not a fully-verified repair. It returns an error
|
|
// ONLY on an infra failure that is not this optional step's business to swallow — a USD ceiling denial is
|
|
// explicitly NOT one of those (see below).
|
|
func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk, job *store.Job,
|
|
draft, finalText string, injected []membank.PickedEntry) (string, repairResult, error) {
|
|
|
|
var res repairResult
|
|
gate := r.Pipeline.Gates.Repair
|
|
if !gate.Enabled || strings.TrimSpace(finalText) == "" {
|
|
return finalText, res, nil
|
|
}
|
|
cfg := r.cheapGateConfig()
|
|
enabled := map[checks.RepairClass]bool{}
|
|
// The Latin-residue class is pure Go (no pair data can make it inert), so on a LATIN-script target every
|
|
// ordinary word reads as a leak. It gates on the target's DECLARED word script (data): meaningful only for
|
|
// a NON-Latin target, inert by absence for a Latin-written or dataless target — no language predicate.
|
|
latinOK := r.checkers.TargetScriptNonLatin()
|
|
for _, n := range r.repairClasses() {
|
|
enabled[knownRepairClasses[n]] = true
|
|
}
|
|
var cands []checks.RepairCandidate
|
|
for _, c := range checks.RepairCandidates(ch.Text, finalText, cfg) {
|
|
if !enabled[c.Class] || (c.Class == checks.RepairLatinResidue && !latinOK) {
|
|
continue
|
|
}
|
|
cands = append(cands, c)
|
|
}
|
|
// Expansion + disjointness happen BEFORE any call: an overlapping candidate can never be applied (two
|
|
// splices into one range corrupt the text), so paying for its reply would be paying for nothing.
|
|
cands = checks.DisjointCandidates(finalText, cands)
|
|
// ABSOLUTE SPAN CAP: sentence expansion falls back to the WHOLE text when the target's terminators are
|
|
// absent from the data, or when the unit is one long unpunctuated line — and a span of several thousand
|
|
// bytes is a whole-unit rewrite wearing a repair's clothes. The bound is absolute rather than relative to
|
|
// the unit: a legitimately SHORT unit may well be a single sentence, and rewriting that one sentence is
|
|
// exactly the intended behaviour. Dropped BEFORE the call, so an unusable candidate is never paid for.
|
|
kept := cands[:0]
|
|
for _, c := range cands {
|
|
if c.DstSpan[1]-c.DstSpan[0] > repairMaxSpanBytes {
|
|
r.Log.InfoContext(ctx, "repair candidate dropped: span too large to be a targeted repair",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "class", string(c.Class), "span_bytes", c.DstSpan[1]-c.DstSpan[0])
|
|
continue
|
|
}
|
|
kept = append(kept, c)
|
|
}
|
|
cands = kept
|
|
if len(cands) > gate.MaxCallsPerUnit {
|
|
cands = cands[:gate.MaxCallsPerUnit]
|
|
}
|
|
if len(cands) == 0 {
|
|
return finalText, res, nil
|
|
}
|
|
// PRE-CALL soft budget cap, read WITHOUT serialisation: parallel wave workers may overshoot it by up to
|
|
// (workers-1) calls. That is a deliberate deviation from the escalation cap's mutex — escalation is rare
|
|
// (its own doc says so) while repair is common-path, and a lock spanning the provider call (which is what
|
|
// makes the escalation bound exact) would put every worker behind one slow transport retry.
|
|
//
|
|
// The cap is applied PER CANDIDATE and only to a FRESH call (see the loop below): an ALREADY-PAID repair
|
|
// must replay from its checkpoint regardless of the remaining budget, or a crash between the paid call
|
|
// and the chunk_status write would make the resumed run ship DIFFERENT bytes than the run that paid —
|
|
// exactly the idempotency escalation.go:99-114 exists to protect.
|
|
budgetExhausted := false
|
|
if spent, err := r.Store.RepairSpentUSD(r.Book.BookID); err != nil {
|
|
return finalText, res, err
|
|
} else if spent >= gate.BudgetUSD {
|
|
budgetExhausted = true
|
|
r.Log.InfoContext(ctx, "repair budget exhausted; only already-paid repairs will replay",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "spent_usd", fmt.Sprintf("%.6f", spent), "budget_usd", gate.BudgetUSD)
|
|
}
|
|
|
|
type accepted struct {
|
|
span [2]int
|
|
replacement string
|
|
}
|
|
var applied []accepted
|
|
for i, c := range cands {
|
|
tpl := r.repairTemplates[c.Class]
|
|
if tpl == nil {
|
|
continue // not enabled / no prompt — cannot happen after loadRepairTemplates, defensive
|
|
}
|
|
srcSpan := ""
|
|
if c.SrcSpan[1] > c.SrcSpan[0] && c.SrcSpan[1] <= len(ch.Text) {
|
|
s := checks.ExpandToSentence(ch.Text, c.SrcSpan)
|
|
srcSpan = ch.Text[s[0]:s[1]]
|
|
}
|
|
dstSpan := finalText[c.DstSpan[0]:c.DstSpan[1]]
|
|
msgs, err := Messages(tpl, RenderVars{Book: r.Book, Text: srcSpan, Draft: dstSpan})
|
|
if err != nil {
|
|
return finalText, res, err
|
|
}
|
|
// Checkpoint FIRST, budget second (escalation.go's idempotency ordering): a paid reply replays free.
|
|
paid, perr := r.repairCheckpointExists(st, snapID, ch, i, msgs)
|
|
if perr != nil {
|
|
return finalText, res, perr
|
|
}
|
|
if budgetExhausted && !paid {
|
|
continue
|
|
}
|
|
att, err := r.runRepairAttempt(ctx, st, snapID, ch, job, i, msgs)
|
|
if err != nil {
|
|
// A ceiling denial must NOT abort the book: this step is optional and sits BEFORE the
|
|
// chunk_status write, so propagating would discard the row of an already-paid, successful
|
|
// editor stage — and would do it again on every resume. Mirrors escalation.go's degrade.
|
|
if errors.Is(err, errReserveCeiling) {
|
|
r.Log.WarnContext(ctx, "repair call denied by a USD ceiling; leaving the defect flagged",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "class", string(c.Class))
|
|
break
|
|
}
|
|
return finalText, res, err
|
|
}
|
|
res.Calls++
|
|
res.CostUSD += att.runCost
|
|
res.CumUSD += att.cumCost
|
|
res.Fresh = res.Fresh || att.freshCall
|
|
reply, verdict := repairReplyVerdict(att, dstSpan, c.Class, cfg.Checkers)
|
|
switch verdict {
|
|
case repairDeclined:
|
|
res.Declined++
|
|
r.Log.InfoContext(ctx, "repair declined by the model (the flagged text is correct)",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "class", string(c.Class))
|
|
case repairAccepted:
|
|
applied = append(applied, accepted{span: c.DstSpan, replacement: reply})
|
|
default:
|
|
res.Rejected++
|
|
r.Log.WarnContext(ctx, "repair reply rejected by a guard",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "class", string(c.Class), "reason", string(verdict))
|
|
}
|
|
}
|
|
if len(applied) == 0 {
|
|
return finalText, res, nil
|
|
}
|
|
// Apply in DESCENDING offset order so earlier spans keep their offsets. The spans are provably disjoint
|
|
// (DisjointCandidates), so this is a pure splice — asserted rather than assumed.
|
|
sort.Slice(applied, func(i, j int) bool { return applied[i].span[0] > applied[j].span[0] })
|
|
out := finalText
|
|
for _, a := range applied {
|
|
if a.span[0] < 0 || a.span[1] > len(out) || a.span[0] >= a.span[1] {
|
|
res.Rejected += len(applied)
|
|
return finalText, res, nil // never splice a range the text cannot hold
|
|
}
|
|
out = out[:a.span[0]] + a.replacement + out[a.span[1]:]
|
|
}
|
|
if !utf8.ValidString(out) || !r.repairReGate(ch.Text, draft, finalText, out, injected) {
|
|
res.Rejected += len(applied)
|
|
r.Log.WarnContext(ctx, "repair discarded: the re-gate refused the assembled text (original kept)",
|
|
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "repairs", len(applied))
|
|
return finalText, res, nil
|
|
}
|
|
res.Applied = len(applied)
|
|
r.Log.InfoContext(ctx, "repair applied", "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
|
|
"repairs", res.Applied, "declined", res.Declined, "rejected", res.Rejected,
|
|
"cost_usd", fmt.Sprintf("%.6f", res.CostUSD))
|
|
return out, res, nil
|
|
}
|
|
|
|
// repairCheckpointExists reports whether THIS repair request was already paid for in an earlier run. It
|
|
// mirrors runRepairAttempt's request identity BY CONSTRUCTION — same derived stage, same helper — because
|
|
// the two must address the same checkpoint; the doccomment used to promise that mirroring while the two
|
|
// field lists were maintained apart, which held only while the dropped fields were zero.
|
|
func (r *Runner) repairCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, ordinal int, msgs []llm.Message) (bool, error) {
|
|
model, maxTokens := r.repairCallBudget(msgs)
|
|
rst := r.repairStage(st, model)
|
|
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(rst, model, snapID, ch, ordinal, maxTokens, msgs)))
|
|
return cp != nil, err
|
|
}
|
|
|
|
// repairStage derives the stage of a repair call from the stage whose output is being repaired. It takes
|
|
// the NAME from that stage (the accounting axis) and everything else from the repair gate.
|
|
//
|
|
// The effort is the GATE's, NOT inherited from the parent stage — see RepairGate.Reasoning for why: the
|
|
// value's meaning is per-control, the model is a different one, and inheriting would have moved the
|
|
// request hash of every already-paid repair call.
|
|
func (r *Runner) repairStage(st config.Stage, model string) config.Stage {
|
|
return config.InternalCall{
|
|
Name: st.Name, Role: roleRepair, Model: model,
|
|
Reasoning: r.Pipeline.Gates.Repair.Reasoning,
|
|
}.Stage()
|
|
}
|
|
|
|
// repairCallBudget resolves the model and the max_tokens of a repair call — ONE definition, so the
|
|
// checkpoint probe and the call itself can never address different request hashes.
|
|
func (r *Runner) repairCallBudget(msgs []llm.Message) (string, int) {
|
|
model := r.Pipeline.Gates.Repair.Model
|
|
est := 0
|
|
for _, m := range msgs {
|
|
est += EstimateTokens(m.Content)
|
|
}
|
|
maxTokens := int(float64(est) * r.Pipeline.Defaults.MaxOutputRatio)
|
|
if maxTokens < r.Pipeline.Defaults.MinMaxTokens {
|
|
maxTokens = r.Pipeline.Defaults.MinMaxTokens
|
|
}
|
|
return model, r.applyModelFloor(maxTokens, model)
|
|
}
|
|
|
|
// runRepairAttempt performs ONE repair call on the shared money path: reserve → call → settle+checkpoint,
|
|
// with a checkpoint hit replayed for free. It reuses runAttempt rather than re-implementing the money
|
|
// sequence — a second copy of that sequence is exactly the drift the snapshot fold helper was extracted to
|
|
// prevent. The derived stage carries the repair ROLE and MODEL (both request-hash fields, so a repair call
|
|
// can never collide with the stage's own attempts) and INHERITS the repaired stage's effort.
|
|
//
|
|
// It used to carry no reasoning setting at all, on a doccomment that read "the provider default holds — on
|
|
// DeepSeek that means thinking stays ON and the echo mine is not armed". The premise was falsified on the
|
|
// wire (D39.86): riding the vendor default is not a safe resting place, because the vendor MOVED it — to
|
|
// effort `high`, which walls a dense-Han call at max_tokens with an empty body. Thinking staying on was
|
|
// never the whole question; how much of the budget it eats is the other half.
|
|
func (r *Runner) runRepairAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk,
|
|
job *store.Job, ordinal int, msgs []llm.Message) (stageAttempt, error) {
|
|
|
|
model, maxTokens := r.repairCallBudget(msgs)
|
|
rst := r.repairStage(st, model)
|
|
// isFinal=false: the reply is a SPAN, not a shipping text, so the output sanitizer (which reasons about
|
|
// whole chunks — preambles, trailing note blocks) must not judge it. The intrinsic classifier still runs
|
|
// inside runAttempt and is a real guard: a model answering «Не могу помочь…» would otherwise pass every
|
|
// content guard below and be spliced into the prose.
|
|
return r.runAttempt(ctx, rst, model, snapID, ch, job, ordinal, maxTokens, msgs, false, false)
|
|
}
|
|
|
|
// repairVerdict is the reply-guard outcome.
|
|
type repairVerdict string
|
|
|
|
const (
|
|
repairAccepted repairVerdict = "accepted"
|
|
repairDeclined repairVerdict = "declined"
|
|
repairBadVerdict repairVerdict = "classifier_rejected"
|
|
repairBadShape repairVerdict = "shape_rejected"
|
|
repairBadContent repairVerdict = "content_rejected"
|
|
)
|
|
|
|
// repairReplyVerdict applies the reply guards to one repair answer against the span it would replace. The
|
|
// guards exist because the re-gate CANNOT see the damage a bad replacement does: every deterministic checker
|
|
// goes silent when the defective text is deleted, so acceptance has to be earned on the reply's own shape.
|
|
func repairReplyVerdict(att stageAttempt, original string, cls checks.RepairClass, c *checks.Checkers) (string, repairVerdict) {
|
|
if !att.cls.ok() {
|
|
return "", repairBadVerdict // empty, truncated, refusal or echo — the intrinsic classifier caught it
|
|
}
|
|
reply := strings.TrimSpace(checks.StripThink(att.text))
|
|
if reply == "" {
|
|
return "", repairBadShape
|
|
}
|
|
if reply == repairNoChange {
|
|
return "", repairDeclined
|
|
}
|
|
if strings.Contains(reply, repairNoChange) {
|
|
return "", repairBadShape // the sentinel mixed with prose is an unparseable answer, not a repair
|
|
}
|
|
origRunes, replyRunes := []rune(original), []rune(reply)
|
|
if len(replyRunes) < int(float64(len(origRunes))*repairMinSpanRatio) ||
|
|
len(replyRunes) > int(float64(len(origRunes))*repairMaxSpanRatio) {
|
|
return "", repairBadShape // a rewrite far outside the span's size is not a targeted repair
|
|
}
|
|
if !strings.Contains(original, "\n") && strings.Contains(reply, "\n") {
|
|
return "", repairBadShape // a repair must not re-paragraph the unit
|
|
}
|
|
// Script guard: the reply may not introduce SOURCE-script runes the span did not have. A few source
|
|
// glyphs inside one sentence sit far below the intrinsic echo threshold, so nothing downstream would catch
|
|
// them. The script set is the run's DECLARED source (c.SourceScripts()) — the same notion the echo
|
|
// detector uses, so ko/en are covered, not only Han+kana. Empty (unknown source) → the guard is inert.
|
|
srcScripts := c.SourceScripts()
|
|
had := map[rune]bool{}
|
|
for _, ru := range original {
|
|
had[ru] = true
|
|
}
|
|
for _, ru := range reply {
|
|
if len(srcScripts) > 0 && unicode.In(ru, srcScripts...) && !had[ru] {
|
|
return "", repairBadContent
|
|
}
|
|
}
|
|
// Anti-deletion: the replacement must keep the span's word tokens, minus at most one for the conversion
|
|
// itself. Deleting content satisfies every class re-check, so preservation is asserted here rather than
|
|
// inferred from a checker that has gone quiet.
|
|
if ow, rw := countWordTokens(original), countWordTokens(reply); ow > 0 && rw < ow-repairMaxTokenLoss {
|
|
return "", repairBadContent
|
|
}
|
|
// PER-CLASS POSITIVE POST-CONDITION: the class invariant must be RESTORED, not merely silenced. Every
|
|
// shipped checker goes quiet when the offending text is deleted or mangled, so "the class stopped firing"
|
|
// is not evidence of a repair — the replacement has to positively satisfy what the class is about.
|
|
if !classInvariantRestored(cls, c, original, reply) {
|
|
return "", repairBadContent
|
|
}
|
|
// The span's FIGURES must line up one-for-one, with at most ONE value changed — the substitution a
|
|
// unit/scale repair legitimately makes. Anything else is the addition/omission class the loop exists to
|
|
// reduce rather than to create: a dropped figure is silent data loss, and an INVENTED one is fabrication
|
|
// that no downstream check would catch (the reflow number-drift guard is opt-in and enabled in no shipping
|
|
// config, so it is structurally zero here).
|
|
if !digitsPreserved(original, reply) {
|
|
return "", repairBadContent
|
|
}
|
|
return reply, repairAccepted
|
|
}
|
|
|
|
// classInvariantRestored is the POSITIVE half of the acceptance rule (design §15.2 A): for the class that
|
|
// fired, the replacement must positively satisfy the class's own invariant, not merely stop matching its
|
|
// detector. Deletion, mangling and "changed to a different wrong value" all silence a detector; only these
|
|
// assertions distinguish a repair from a silencing.
|
|
//
|
|
// The assertions are expressed through the SAME pair/target data the detector uses, so a new pair inherits
|
|
// them for free. Where an assertion is not expressible on today's data (the counted-hours class — see the
|
|
// class's exclusion from defaultRepairClasses), the conservative answer is to REJECT rather than to accept:
|
|
// a rejected correct repair costs one wasted call, an accepted wrong one costs the reader.
|
|
func classInvariantRestored(cls checks.RepairClass, c *checks.Checkers, original, reply string) bool {
|
|
switch cls {
|
|
case checks.RepairLatinResidue:
|
|
// The leaked Latin token must be gone, and no NEW Latin token may take its place.
|
|
before, _ := lintLatinResidueCount(original)
|
|
after, _ := lintLatinResidueCount(reply)
|
|
return after < before
|
|
case checks.RepairBrokenWord:
|
|
// The malformed form must be gone and none introduced.
|
|
return checks.BrokenWordCount(c, reply) < checks.BrokenWordCount(c, original)
|
|
case checks.RepairDC1Fractional:
|
|
// The halved duration must be gone AND the replacement must still state a duration: a reply that
|
|
// simply drops the time expression is a deletion, not a conversion.
|
|
return checks.FractionalUnitPresent(c, reply) == false && checks.MentionsHourWord(c, reply)
|
|
case checks.RepairDC1TimeUnits:
|
|
// The counted-hours class: assert the hours count POSITIVELY equals twice the source count. On the
|
|
// pair data as it stands this also rejects a correct repair phrased in a case form the hours table
|
|
// does not carry — accepted, because the alternative is accepting «три часа» → «пять часов».
|
|
return checks.HoursCountDoubled(c, original, reply)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// lintLatinResidueCount is the package-local view of the Latin-residue count (the lint lives in checks and
|
|
// takes an allowlist; the guard compares before/after on the same allowlist-free basis).
|
|
func lintLatinResidueCount(s string) (int, []string) { return checks.LatinResidueCount(s), nil }
|
|
|
|
// countWordTokens counts maximal letter runs (any script) — the coarse content measure the anti-deletion
|
|
// guard uses. Language-general: it makes no assumption about which alphabet the target uses.
|
|
func countWordTokens(s string) int {
|
|
n, in := 0, false
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) {
|
|
if !in {
|
|
n++
|
|
in = true
|
|
}
|
|
continue
|
|
}
|
|
in = false
|
|
}
|
|
return n
|
|
}
|
|
|
|
// digitRuns returns the maximal ASCII digit runs of s.
|
|
func digitRuns(s string) []string {
|
|
var out []string
|
|
cur := strings.Builder{}
|
|
for _, r := range s {
|
|
if r >= '0' && r <= '9' {
|
|
cur.WriteRune(r)
|
|
continue
|
|
}
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// digitsPreserved reports whether the reply keeps the span's figures one-for-one, changing at most one
|
|
// VALUE. Counting positionally (rather than asking "did each original figure survive somewhere") is what
|
|
// catches an INVENTED figure: a reply may not carry more numbers than the text it replaces.
|
|
func digitsPreserved(original, reply string) bool {
|
|
o, r := digitRuns(original), digitRuns(reply)
|
|
if len(o) != len(r) {
|
|
return false // a figure appeared or disappeared — never a targeted repair
|
|
}
|
|
diff := 0
|
|
for i := range o {
|
|
if o[i] != r[i] {
|
|
diff++
|
|
}
|
|
}
|
|
return diff <= 1
|
|
}
|
|
|
|
// repairReGate re-runs the deterministic verdicts over the ASSEMBLED text and reports whether the repair may
|
|
// stand. It is conservative by construction: every check must be no worse than before, and the repaired
|
|
// classes must actually be gone. It deliberately does NOT treat "the class stopped firing" as sufficient —
|
|
// that condition is satisfiable by deletion, which the reply guards refuse first.
|
|
func (r *Runner) repairReGate(source, draft, before, after string, injected []membank.PickedEntry) bool {
|
|
cfg := r.cheapGateConfig()
|
|
// 1. The addressable defects must not have grown, and the classes we attacked must be gone.
|
|
beforeCands := len(checks.RepairCandidates(source, before, cfg))
|
|
afterCands := len(checks.RepairCandidates(source, after, cfg))
|
|
if afterCands >= beforeCands {
|
|
return false
|
|
}
|
|
// 2. The cheap observability suite must not have gained anything.
|
|
if checks.RunCheapGates(source, draft, after, cfg).Total() > checks.RunCheapGates(source, draft, before, cfg).Total() {
|
|
return false
|
|
}
|
|
// 3. The intrinsic classifier must still accept the whole text.
|
|
if !classify(classifyInput{Source: source, Output: after, Finish: llm.FinishStop, TargetLang: r.Book.TargetLang, SourceScripts: r.checkers.SourceScripts()}).ok() {
|
|
return false
|
|
}
|
|
// 4. The output sanitizer must not have been made dirty (only when the gate is on — it is the gate that
|
|
// decides those classes are verdict-bearing for this book).
|
|
if r.Pipeline.Gates.Sanitizer.Enabled && r.checkers.TargetActive() {
|
|
if r.checkers.SanitizeOutput(after).Total() > r.checkers.SanitizeOutput(before).Total() {
|
|
return false
|
|
}
|
|
}
|
|
// 5. The glossary post-check must not have gained a CONFIRMED miss: a repair that rewrites a canonical
|
|
// term would otherwise be invisible here and, with the post-check gate on, would flip the unit to
|
|
// flagged and ship an EMPTY export — the repair destroying the chapter it was meant to improve.
|
|
if r.memory != nil && len(injected) > 0 {
|
|
if r.memory.Postcheck(injected, after).ConfirmedCount() >
|
|
r.memory.Postcheck(injected, before).ConfirmedCount() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// repairDerivedNS is the derived-checkpoint id namespace for a repaired final text, mirroring the sanitized
|
|
// and banknote exports: content-addressed, prefixed so it can never collide with a real hex request_hash,
|
|
// and re-derived identically on resume for free.
|
|
const repairDerivedNS = "tm-repair-v1"
|
|
|
|
// repairDerivedHash is the EXACT id formula: sha256(namespace \0 terminal-attempt hash \0 repaired text).
|
|
func repairDerivedHash(reqHash, repaired string) string {
|
|
sum := sha256.Sum256([]byte(repairDerivedNS + "\x00" + reqHash + "\x00" + repaired))
|
|
return repairDerivedNS + ":" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// commitRepairExport persists the repaired final text as a $0 derived checkpoint and returns its id, so
|
|
// chunk_status.final_hash points at the bytes that actually ship and a resume re-serves them without
|
|
// re-running (or re-paying for) the repair. Written BEFORE chunk_status references it, like its siblings.
|
|
func (r *Runner) commitRepairExport(st config.Stage, ch chunk.Chunk, job *store.Job, att stageAttempt, repaired string) (string, error) {
|
|
derived := repairDerivedHash(att.reqHash, repaired)
|
|
if err := r.Store.PutDerivedCheckpoint(store.Checkpoint{
|
|
RequestHash: derived, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: att.attempt,
|
|
Stage: st.Name, Role: st.Role, ModelRequested: att.modelActual, ModelActual: att.modelActual,
|
|
ResponseText: repaired, UsageJSON: "{}", FinishReason: "repair_export",
|
|
}); err != nil {
|
|
return "", fmt.Errorf("pipeline: commit repair export ch%d/chunk%d/%s: %w", ch.Chapter, ch.ChunkIdx, st.Name, err)
|
|
}
|
|
return derived, nil
|
|
}
|