118 lines
7.1 KiB
Go
118 lines
7.1 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"strings"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
)
|
||
|
||
// injectionpreflight.go: «a bank nobody can be told about» — the write-path guard for a run whose TARGET
|
||
// language authors no wire-text for the bank blocks.
|
||
//
|
||
// The two guards in openRunner beside it ask whether the bank can be BUILT (a langpack to mine with, a
|
||
// contrast artifact to mine against). This one asks whether anything built can be SAID. Both injection
|
||
// blocks are gated on the target's rows in lang/data/injection.txt — RenderGlossaryBlock and
|
||
// RenderEditorConstraintBlock return "" without them — and lang.InjectionTextsFor returns a ZERO VALUE for
|
||
// a target it has no rows for, with no error. So the failure is silent by construction: a book of a pair
|
||
// whose prompts and langpack are complete buys its bank roles, stops for a signature, and injects nothing
|
||
// into a single call. Nothing goes red, nothing is logged, and the money is spent.
|
||
//
|
||
// ⛔ WHY THE CONDITION IS WIDER THAN «THE GATE IS ON». The injection is not built by the gate. It is built
|
||
// whenever the run HAS bank rows (waverun.go, `if r.baseMemory != nil`), and rows reach a book through four
|
||
// doors: the terminologist consolidating them this run, the seed file, the owner's mined-delta, and the
|
||
// auto-bank a PREVIOUS run left in the store. A book with a seed and the terminology gate OFF injects
|
||
// exactly as silently as one with the gate on, and so does a book whose only bank is last run's — the gate
|
||
// is a run-local config knob (D39.126 п.2а), the bank in the store is not.
|
||
|
||
// bankRowsReachTheWire reports whether this run will build a memory injection at all, and names the door
|
||
// its rows come through. The name is not decoration: a refusal that says «your bank cannot be injected»
|
||
// without saying WHY this run counts as one with a bank reads as arbitrary, and the operator's next move
|
||
// (drop the gate, or author the rows) depends on which door it is.
|
||
//
|
||
// The doors are asked in cost order — two config fields, one stat, one store read — and the store is only
|
||
// touched when everything cheaper says no, so the ordinary book (gate on) costs nothing extra to open.
|
||
//
|
||
// ⚠ A read failure is NOT a door. It is logged and treated as «no rows», because a refusal invented from an
|
||
// error refuses a run for something that may not be true — the rule the paid-before probe beside it keeps.
|
||
func (r *Runner) bankRowsReachTheWire(logger *slog.Logger) (string, bool) {
|
||
switch {
|
||
case r.Pipeline.Gates.Terminology.Enabled:
|
||
return "gates.terminology is enabled, so the bank role consolidates rows on this run", true
|
||
case r.Pipeline.Gates.Banknote.Enabled:
|
||
return "gates.banknote is enabled, so every draft call is asked for term lines that feed the bank", true
|
||
case r.Book.GlossarySeed != "":
|
||
return "book.yaml `glossary_seed` (" + r.Book.GlossarySeed + ") seeds the bank", true
|
||
}
|
||
present, err := decisionFilePresent(r.Book.MinedDelta)
|
||
if err != nil {
|
||
logger.Warn("could not read whether this book has an owner mined-delta, so the injection preflight treats it as absent; nothing else is affected",
|
||
"book", r.Book.BookID, "mined_delta", r.Book.MinedDelta, "err", err)
|
||
} else if present {
|
||
return "the owner's mined-delta " + r.Book.MinedDelta + " holds signed rows", true
|
||
}
|
||
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
|
||
if err != nil {
|
||
logger.Warn("could not read this book's stored bank, so the injection preflight treats it as empty; nothing else is affected",
|
||
"book", r.Book.BookID, "err", err)
|
||
return "", false
|
||
}
|
||
if len(rows) > 0 {
|
||
return fmt.Sprintf("this book's store already holds %d bank row(s) an earlier run wrote", len(rows)), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// requiredInjectionKeys is the union of the injection keys the roles of THIS pipeline consume, sorted so a
|
||
// refusal lists them the same way twice. A pipeline whose stages consume no injection at all (no translator
|
||
// and no editor) returns none — there is then no block to be silent, and refusing would block a run that
|
||
// works.
|
||
func (r *Runner) requiredInjectionKeys() []string {
|
||
seen := map[string]bool{}
|
||
for _, st := range r.Pipeline.Stages {
|
||
for _, key := range roleInjectionKeys[st.Role] {
|
||
seen[key] = true
|
||
}
|
||
}
|
||
keys := make([]string, 0, len(seen))
|
||
for k := range seen {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
return keys
|
||
}
|
||
|
||
// checkInjectionTexts refuses a WRITE run whose target authors none of the wire-text its bank needs, and
|
||
// warns about a half-authored gender set. Called from openRunner, before the store is handed to a run and
|
||
// long before the first paid call — the same position, and for the same reason, as the contour refusals
|
||
// beside it: this costs nothing to verify now and a whole draft wave to discover later.
|
||
//
|
||
// It is a REFUSAL and not a warning because the line between them is stated next to those guards: a
|
||
// mechanism that cannot work at all is refused, one that merely costs money it did not mean to spend is
|
||
// warned about. A bank that reaches no request is the first kind — the consolidation is bought, the stop
|
||
// asks for a signature on it, and not one byte of it can reach a model.
|
||
func (r *Runner) checkInjectionTexts(logger *slog.Logger) error {
|
||
keys := r.requiredInjectionKeys()
|
||
if len(keys) == 0 {
|
||
return nil
|
||
}
|
||
tx := lang.InjectionTextsFor(r.Book.TargetLang)
|
||
missing := tx.MissingKeys(keys)
|
||
if len(missing) == 0 {
|
||
// The half-authored gender set: the bank DOES reach the model, and only the rows of an unauthored
|
||
// sex lose their directive — a wording loss on a working mechanism, which is a warning's shape.
|
||
if gaps := tx.GenderKeyGaps(); len(gaps) > 0 {
|
||
logger.Warn("the target authors SOME gender directives and not others, so bank rows of the missing sex(es) will render with no directive at all and the model is told nothing about how to inflect them (add the rows to internal/lang/data/injection.txt; a directive is target-language data, so a target with no grammatical gender authors none of them and is not warned)",
|
||
"book", r.Book.BookID, "target_lang", r.Book.TargetLang, "missing", strings.Join(gaps, ", "))
|
||
}
|
||
return nil
|
||
}
|
||
door, willInject := r.bankRowsReachTheWire(logger)
|
||
if !willInject {
|
||
return nil
|
||
}
|
||
return refuse(RefusalBadConfig, fmt.Errorf("this run builds a memory bank its TARGET cannot state: %s, but the target language %q authors no %s in the engine's injection text, so the bank blocks render into ZERO BYTES and no model ever sees a term of it (gated in membank.RenderGlossaryBlock/RenderEditorConstraintBlock on lang.InjectionTextsFor, which returns an empty value for an unknown target without an error — that is the silence this refusal replaces). Fix: add the missing row(s) for %q to backend/internal/lang/data/injection.txt (`target<TAB>key<TAB>value`, the value is VERBATIM) — that file is COMPILED IN, so the fix is an engine build and not a config edit. ⚠ That file is folded byte-for-byte into lang.EmbeddedVersion(), which rides inside every unit id, so adding a target re-mints the snapshot: the edit lands with an explicit --resnapshot, never quietly",
|
||
door, r.Book.TargetLang, strings.Join(missing, " and "), r.Book.TargetLang))
|
||
}
|