329 lines
14 KiB
Go
329 lines
14 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"textmachine/backend/internal/chunk"
|
|
"textmachine/backend/internal/config"
|
|
"textmachine/backend/internal/lang"
|
|
"textmachine/backend/internal/membank"
|
|
)
|
|
|
|
// repin.go: POINTWISE re-edit by key (pack-20 point 5, D39.42 п.5) — the machinery that makes "the bank
|
|
// changed" cost only the units the change actually reaches.
|
|
//
|
|
// THE PROBLEM. The bank is folded into the wave snapshot, so signing one term moves the edit-wave
|
|
// snapshot, and the resume fast-path demands an exact snapshot match. Every already-paid unit of that
|
|
// wave therefore re-translates — a whole edit wave bought again for one word. Owner, 26.07: «если
|
|
// меняется банк — перезапускается редактура; вопрос насколько широко, на какие главы».
|
|
//
|
|
// THE OBSERVATION. A snapshot is a hash over many things, and only ONE of them is the bank. If the ONLY
|
|
// component that moved is the memory version, then every other input to the request — model, prompt SHA,
|
|
// temperature, reasoning, capability, budgets, gates — is bit-for-bit what it was. What the bank change
|
|
// does to a given unit is then fully observable in ONE place: the injected message. And the injected
|
|
// message is already hashed per unit, in chunk_status.content_hash, by the resume fast-path.
|
|
//
|
|
// So: bank-only snapshot move + unchanged content hash ⇒ the request this run would issue is byte-identical
|
|
// to the one already paid for, except for the snapshot id inside its address. Serving the stored result is
|
|
// then exactly as sound as an ordinary resume, and the unit is RE-PINNED for $0 instead of re-bought.
|
|
// A unit whose injected bytes DID change is the one where the term actually occurs — and that one is
|
|
// re-translated, which is the whole point of signing it.
|
|
//
|
|
// The same predicate drives the ESTIMATE: projectRebill uses it, so the "N units, ~$X" an operator is
|
|
// asked to consent to counts the units that will really be re-paid, not the whole wave.
|
|
|
|
// snapshotMoveKind classifies a difference between a stored snapshot and the current one.
|
|
type snapshotMoveKind int
|
|
|
|
const (
|
|
// moveUnknown — the stored payload is unavailable or unreadable, so nothing can be concluded and the
|
|
// conservative answer holds (a full re-payment).
|
|
moveUnknown snapshotMoveKind = iota
|
|
// moveBankOnly — the ONLY differing component is the memory version. Every wire-shaping input is
|
|
// unchanged, so a unit whose injected bytes are unchanged is re-pinnable for $0.
|
|
moveBankOnly
|
|
// moveOther — something besides the bank moved (a prompt, a model, a gate). Nothing is re-pinnable:
|
|
// the change can alter what is sent or how the result is judged, and this predicate does not model that.
|
|
moveOther
|
|
)
|
|
|
|
// memoryVersionField is the payload key holding the bank's content hash — the one component a signature,
|
|
// a terminologist consolidation or any other bank edit moves.
|
|
const memoryVersionField = "memory_version"
|
|
|
|
// classifySnapshotMove compares the stored snapshot's payload with the current one and reports whether the
|
|
// difference is confined to the bank. Both payloads are the same fixed-field-order JSON object, so the
|
|
// comparison is a plain key-by-key equality over the decoded maps — no knowledge of the struct is needed,
|
|
// which is what keeps this from silently going stale when a new component is folded: an unrecognised new
|
|
// field simply counts as a difference, i.e. the conservative answer.
|
|
func classifySnapshotMove(storedPayload, currentPayload string) snapshotMoveKind {
|
|
if storedPayload == "" || currentPayload == "" {
|
|
return moveUnknown
|
|
}
|
|
var was, now map[string]json.RawMessage
|
|
if json.Unmarshal([]byte(storedPayload), &was) != nil || json.Unmarshal([]byte(currentPayload), &now) != nil {
|
|
return moveUnknown
|
|
}
|
|
bankMoved := false
|
|
for k, v := range now {
|
|
w, had := was[k]
|
|
if k == memoryVersionField {
|
|
if !had || !jsonEqual(w, v) {
|
|
bankMoved = true
|
|
}
|
|
continue
|
|
}
|
|
if !had || !jsonEqual(w, v) {
|
|
return moveOther
|
|
}
|
|
}
|
|
for k := range was {
|
|
if _, still := now[k]; !still {
|
|
return moveOther // a component that was folded and no longer is — not a bank move
|
|
}
|
|
}
|
|
if !bankMoved {
|
|
return moveOther // the ids differ but no component does: unexplained, so never re-pinnable
|
|
}
|
|
return moveBankOnly
|
|
}
|
|
|
|
func jsonEqual(a, b json.RawMessage) bool {
|
|
if string(a) == string(b) {
|
|
return true
|
|
}
|
|
var x, y any
|
|
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
|
return false
|
|
}
|
|
xb, xe := json.Marshal(x)
|
|
yb, ye := json.Marshal(y)
|
|
return xe == nil && ye == nil && string(xb) == string(yb)
|
|
}
|
|
|
|
// cachedRenderedContentHashes is renderedContentHashes memoized for the life of ONE materialized bank.
|
|
//
|
|
// The reproduction walks every stage of every position and re-renders it — the expensive half of every
|
|
// $0 projection in this engine. Since the volume ceiling landed, a single bounded run could pay for it
|
|
// THREE times: once in planVolume, and once in each of the consent gate's two projections (the book's and
|
|
// the run's). Nothing changed between them, so two of the three were pure waste, and the cost scales with
|
|
// the book — exactly where it is least affordable.
|
|
//
|
|
// ⚠ THE CACHE'S LIFETIME IS THE BANK'S, and that is what makes it safe rather than merely fast. Every
|
|
// hash here is rendered against the materialized memory, so the memo is only valid while that memory is;
|
|
// materializeBanks clears it, which is the ONE place the bank can change (a mid-run re-seed at the
|
|
// bank-mining stop goes through it like everything else). A memo that outlived its bank would hand a
|
|
// caller hashes for a bank the run no longer has — the precise class of silent wrongness the free
|
|
// estimate exists to avoid.
|
|
func (r *Runner) cachedRenderedContentHashes(chunks []chunk.Chunk, stickySel []membank.Selection) map[chunkKey]map[string]string {
|
|
if r.contentHashes != nil {
|
|
return r.contentHashes
|
|
}
|
|
r.contentHashes = r.renderedContentHashes(chunks, stickySel)
|
|
return r.contentHashes
|
|
}
|
|
|
|
// renderedContentHashes reproduces, for every live position of every stage, the content hash the run
|
|
// WOULD compute — the same msgsContentHash the resume fast-path compares against. It is the $0 half of
|
|
// the honest estimate: with it, "the bank moved" can be answered per unit instead of per wave.
|
|
//
|
|
// The reproduction is deliberately faithful rather than approximate: the draft wave renders from the
|
|
// precomputed BASE-bank selection (what runDraftChunk uses), and the edit wave does a fresh Select over
|
|
// the unit's clean source against the ENRICHED bank with the unit's stored member drafts as {{draft}}
|
|
// (what runEditUnit uses). Where a needed input is missing — a member draft not yet resolved, a template
|
|
// absent — the position is simply OMITTED, and the caller treats an absent hash as "cannot conclude",
|
|
// i.e. the conservative answer.
|
|
func (r *Runner) renderedContentHashes(chunks []chunk.Chunk, stickySel []membank.Selection) map[chunkKey]map[string]string {
|
|
out := map[chunkKey]map[string]string{}
|
|
put := func(k chunkKey, stage, hash string) {
|
|
if out[k] == nil {
|
|
out[k] = map[string]string{}
|
|
}
|
|
out[k][stage] = hash
|
|
}
|
|
tx := lang.InjectionTextsFor(r.Book.TargetLang)
|
|
|
|
// --- draft wave: one sequence per chunk, over the BASE bank selection ---
|
|
draftStages := waveStages(r.Pipeline.Stages, waveDraft)
|
|
for i, ch := range chunks {
|
|
injection := map[string]string{}
|
|
if r.baseMemory != nil && i < len(stickySel) {
|
|
for role, render := range roleInjectionRenderers {
|
|
injection[role] = render(stickySel[i].Injected, tx)
|
|
}
|
|
}
|
|
prev := ""
|
|
for _, st := range draftStages {
|
|
tpl := r.templates[st.Name]
|
|
if tpl == nil {
|
|
break
|
|
}
|
|
msgs, err := MessagesWithInjection(tpl, RenderVars{Book: r.Book, Text: ch.Text, Draft: prev}, injection[st.Role])
|
|
if err != nil {
|
|
break
|
|
}
|
|
put(chunkKey{ch.Chapter, ch.ChunkIdx}, st.Name, msgsContentHash(msgs))
|
|
txt, ok := r.storedStageText(ch.Chapter, ch.ChunkIdx, st.Name)
|
|
if !ok {
|
|
break // the next stage's {{draft}} is unknown → stop this chain
|
|
}
|
|
prev = txt
|
|
}
|
|
}
|
|
|
|
// --- edit wave: one sequence per unit, over the ENRICHED bank ---
|
|
editStages := waveStages(r.Pipeline.Stages, waveEdit)
|
|
if len(editStages) == 0 || len(draftStages) == 0 {
|
|
return out
|
|
}
|
|
lastDraft := draftStages[len(draftStages)-1].Name
|
|
for _, u := range buildEditUnits(chunks) {
|
|
var cleanSources, draftParts []string
|
|
complete := true
|
|
for _, m := range u.Members {
|
|
cs, err := r.Store.GetChunkStatus(r.Book.BookID, m.Chapter, m.ChunkIdx, lastDraft)
|
|
if err != nil || cs == nil {
|
|
complete = false
|
|
break
|
|
}
|
|
if cs.Disposition == string(DispFlagged) {
|
|
continue // c-lite: a flagged member is DROPPED from the edit, exactly as runEditUnit does
|
|
}
|
|
txt, ok := r.storedStageText(m.Chapter, m.ChunkIdx, lastDraft)
|
|
if !ok {
|
|
complete = false
|
|
break
|
|
}
|
|
cleanSources = append(cleanSources, m.Text)
|
|
draftParts = append(draftParts, txt)
|
|
}
|
|
if !complete || len(draftParts) == 0 {
|
|
continue
|
|
}
|
|
leader := chunk.Chunk{Chapter: u.Chapter, ChunkIdx: u.FirstChunkIdx, Text: strings.Join(cleanSources, unitJoinSeparator)}
|
|
injection := map[string]string{}
|
|
if r.memory != nil {
|
|
sel := r.memory.Select(leader.Text, u.Chapter, nil, r.Pipeline.Context.GlossaryTokenBudget)
|
|
for role, render := range roleInjectionRenderers {
|
|
injection[role] = render(sel.Injected, tx)
|
|
}
|
|
}
|
|
prev := strings.Join(draftParts, unitJoinSeparator)
|
|
for _, st := range editStages {
|
|
tpl := r.templates[st.Name]
|
|
if tpl == nil {
|
|
break
|
|
}
|
|
msgs, err := MessagesWithInjection(tpl, RenderVars{Book: r.Book, Text: leader.Text, Draft: prev}, injection[st.Role])
|
|
if err != nil {
|
|
break
|
|
}
|
|
put(chunkKey{u.Chapter, u.FirstChunkIdx}, st.Name, msgsContentHash(msgs))
|
|
txt, ok := r.storedStageText(u.Chapter, u.FirstChunkIdx, st.Name)
|
|
if !ok {
|
|
break
|
|
}
|
|
prev = txt
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// storedStageText returns a resolved stage's text from its chunk_status → checkpoint, as the run would
|
|
// feed it to the next stage. It follows final_hash, so a banknote-cleaned or cosmetically-stripped draft
|
|
// yields the SHIPPED bytes rather than the raw completion — otherwise the reproduction would diverge from
|
|
// the run precisely on the units where a derived export exists.
|
|
func (r *Runner) storedStageText(chapter, chunkIdx int, stage string) (string, bool) {
|
|
cs, err := r.Store.GetChunkStatus(r.Book.BookID, chapter, chunkIdx, stage)
|
|
if err != nil || cs == nil || cs.FinalHash == "" {
|
|
return "", false
|
|
}
|
|
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
|
|
if err != nil || cp == nil {
|
|
return "", false
|
|
}
|
|
return cp.ResponseText, true
|
|
}
|
|
|
|
// repinDecider answers "is this stored row re-pinnable at $0?" for one wave, caching the current payload
|
|
// and the per-stored-snapshot classification so a book-sized projection does a handful of store reads
|
|
// rather than one per row.
|
|
type repinDecider struct {
|
|
r *Runner
|
|
payloads map[wave]string // current payload per wave (rendered lazily)
|
|
kinds map[string]snapshotMoveKind // stored snapshot id → classification against the current one
|
|
}
|
|
|
|
func newRepinDecider(r *Runner) *repinDecider {
|
|
return &repinDecider{r: r, payloads: map[wave]string{}, kinds: map[string]snapshotMoveKind{}}
|
|
}
|
|
|
|
// bankOnlyMove reports whether the stored snapshot differs from the current one ONLY in the bank.
|
|
func (d *repinDecider) bankOnlyMove(storedID string, w wave) (bool, error) {
|
|
if k, done := d.kinds[storedID]; done {
|
|
return k == moveBankOnly, nil
|
|
}
|
|
cur, ok := d.payloads[w]
|
|
if !ok {
|
|
_, payload, err := d.r.snapshotIDForWave(w)
|
|
if err != nil {
|
|
return false, fmt.Errorf("pipeline: render the current snapshot payload for the re-pin check: %w", err)
|
|
}
|
|
d.payloads[w] = payload
|
|
cur = payload
|
|
}
|
|
stored, err := d.r.Store.SnapshotPayload(storedID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("pipeline: read the stored snapshot payload %.12s: %w", storedID, err)
|
|
}
|
|
k := classifySnapshotMove(stored, cur)
|
|
d.kinds[storedID] = k
|
|
return k == moveBankOnly, nil
|
|
}
|
|
|
|
// waveOfStage reports which wave owns a stage by name (the same partition waveStages makes). An unknown
|
|
// name defaults to the edit wave, which is the conservative side: an unknown stage's stored payload will
|
|
// not match the edit-wave render, so the re-pin predicate answers "no".
|
|
func waveOfStage(stages []config.Stage, name string) wave {
|
|
for _, st := range stages {
|
|
if st.Name == name {
|
|
if st.Role == roleTranslator {
|
|
return waveDraft
|
|
}
|
|
return waveEdit
|
|
}
|
|
}
|
|
return waveEdit
|
|
}
|
|
|
|
// repinnable is the runner-level predicate behind the $0 re-pin: the stored snapshot differs from the
|
|
// current one ONLY in the bank. It caches per (stored id, wave) for the run, so the wave executor's
|
|
// parallel workers do not each re-read the payload — the cache is written under a mutex because the draft
|
|
// wave calls this from N goroutines.
|
|
func (r *Runner) repinnable(storedID, currentID string, w wave) bool {
|
|
if storedID == "" || storedID == currentID {
|
|
return false
|
|
}
|
|
r.repinMu.Lock()
|
|
defer r.repinMu.Unlock()
|
|
if r.repinCache == nil {
|
|
r.repinCache = map[string]bool{}
|
|
}
|
|
key := storedID + "\x00" + currentID
|
|
if v, done := r.repinCache[key]; done {
|
|
return v
|
|
}
|
|
ok, err := newRepinDecider(r).bankOnlyMove(storedID, w)
|
|
if err != nil {
|
|
// A read failure is never a licence to serve a stale result — but it is also the difference between
|
|
// "this book has nothing re-pinnable" and "the store could not answer", and the operator pays the
|
|
// second one in re-translated chunks. The negative is cached for the run, so this logs once.
|
|
r.Log.Warn("re-pin check could not read the stored snapshot; this unit will be re-translated rather than re-pinned",
|
|
"stored_snapshot", storedID, "err", err)
|
|
ok = false
|
|
}
|
|
r.repinCache[key] = ok
|
|
return ok
|
|
}
|