textmachine/backend/internal/pipeline/terminologist.go

1597 lines
95 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/miner"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// terminologist.go: the TERMINOLOGIST role (pack-20, ratified D39.42 п.1) — the step that was missing
// between "which surfaces belong in the bank" and "what does this book call them".
//
// WHERE IT SITS. At the bank-mining stop, after the draft wave has produced the whole book and before the
// stop/auto decision. That position is not a convenience: it is the only moment at which BOTH the complete
// source and a complete draft of every chapter exist, which is exactly what translating a term correctly
// needs. Owner, 26.07: «намайнить в банк можем весь контекст, потому что есть черновые переводы всей книги
// сразу … дальше отрабатывает модель, которая видит весь контекст и переводит весь банк памяти сразу».
//
// WHAT IT MAY DO. Propose ONE consolidated rendering per candidate. It cannot approve anything: the term it
// touches emits `draft` (the §C2-7 mode that carries a rendering), never `approved` — the miner has no
// code path to that word and this role adds none. A term it declines emits `auto`, inert. So the worst a
// bad terminologist call can do is put a marked, unverified proposal in front of the editor and the owner;
// it can never make the book use a word nobody signed.
//
// WHAT IT COSTS. A handful of batched calls per BOOK (not per chunk), on a cheap model, over a block whose
// size is bounded by config. The estimate is logged BEFORE the first call, the calls run on the shared
// reserve→call→settle+checkpoint path so a resume replays them for $0, and the whole class is capped by
// gates.terminology.budget_usd.
// roleTerminologist is the synthetic stage role every terminologist call is addressed under. Like the
// repair role it keeps the call on its own request-hash axis and gives its checkpoints, request_log rows
// and ledger entries their own queryable class — the cost marker, so "what did terminology spend" is
// answerable with no migration.
const roleTerminologist = "terminologist"
// roleClassifier is the §2 type-classifier phase's cost axis. Its own role marker keeps its checkpoints,
// request_log rows and ledger entries on a queryable class of their own and on their own request-hash axis,
// separate from the render phase — "what did classification spend" is answerable with no migration.
const roleClassifier = "classifier"
// logKeyReconsolidated is the structured key the one-time re-consolidation warning carries, and it exists
// so that the warning and the test that guards it read ONE carrier.
//
// ⚠ WHY A KEY AND NOT THE SENTENCE. That guard has now been written twice against the message's PROSE, and
// the second time the prose had already been reworded: the test grepped a substring that occurs zero times
// in this module, so it could not fire at all and the defect it was written for went green. A key is short,
// is not rewritten when the sentence is improved, and — being a constant both sides share — cannot be
// renamed on one side only: the test would stop compiling rather than stop guarding.
const logKeyReconsolidated = "reconsolidated"
// terminologyStageName is the synthetic stage name the calls carry. It is NOT a pipeline stage (a stage
// would join a wave, take a snapshot axis and a chunk_status row per unit); it is an addressing label.
const terminologyStageName = "terminology"
// terminologyVersion versions the ASSEMBLY algorithm (merge → KWIC → §C2-3 ranking → batching → parse). It
// is deliberately NOT snapshot-folded — see config.TerminologyGate — but it is logged with the run so a
// signature map can be attributed to the algorithm that produced it.
const terminologyVersion = "terminology-v3-merge+kwic+c2-3+series+families+formfold"
// Engine defaults for the block sizing. They bound ONE call's input; the whole book is covered by batching.
const (
terminologyDefaultBatchRunes = 6000
terminologyDefaultKWICPer = 3
terminologyDefaultKWICWidth = 40
)
// terminologyResult is the run's outcome for the report and the logs. Derived from what happened; never
// stored as a row of its own.
type terminologyResult struct {
Candidates int // merged candidates handed to the role
Reverse int // of those, banknote-only (the coverage the miner structurally cannot see)
Batches int // calls attempted
// Gendered is how many candidates the CLASSIFIER gave a gender datum (backlog row 210). Reported
// because a zero here and a zero in the bank are the same number for two entirely different reasons —
// nobody asked (the classifier is off), or nobody answered — and the axis spent a whole era dead with
// every counter beside it reading clean.
Gendered int
// BatchesDropped / ClassifyBatchesDropped are how many batches the BUDGET left unbought in each pass —
// the difference between the pass a role planned and the pass it ran. Without them «consolidated=42
// unanswered=16» reads as a verdict about the TERMS when it is partly a verdict about the MONEY.
//
// ⚠ TWO FIELDS AND NOT ONE, because the two passes have SEPARATE budgets (`budget_usd` and
// `classify_budget_usd`) and the cold run's incident was on the CLASSIFIER: its $0.02 was exhausted
// while the terminologist's $0.05 was not, and three of four classify batches were never bought. The
// first version of this carrier reported only the render pass, so the very run that motivated it would
// still have shown zero. Found by acceptance.
BatchesDropped int
ClassifyBatchesDropped int
Consolidated int // terms that came back with a rendering → status:draft
Declined int // terms the role explicitly could not render → status:auto
Unanswered int // terms no reply line covered (also status:auto — silence is not a decision)
BadLines int // reply lines the parser refused
// OffLanguage counts refused lines whose rendering was not in the target's script — separate from
// BadLines because it means the model answered in another language, not that it broke the format.
OffLanguage int
// DeclinedByPhrase is the part of Declined the role wrote in WORDS instead of with the engine sentinel,
// and NoLetters the part of BadLines refused for carrying no letter at all («90»). Both are subsets of
// a counter beside them, and both are reported because the whole class has a MEASURED population of
// zero on everything this project has bought (0 of 69 and 0 of 66, research/35 §2.0 row Б-5): a
// mechanism whose population is zero is one nobody can tell from a mechanism that is not running, and
// these two counters are the difference.
DeclinedByPhrase int
NoLetters int
// CanonConflicts counts consolidated renderings that contradict a row the owner already signed. It is
// OBSERVABILITY, never a gate: the rows stay unverified either way, and this is what tells the owner
// which of them to look at first.
CanonConflicts int
// SelfConflicts counts consolidations that contradict ANOTHER consolidation of the same run (§G2) — the
// majority class on a live bank, and the one nothing looked at before this pack. SelfConflictRows
// carries the findings themselves, UNGROUPED, so the stop table can mark the rows instead of printing a
// bare total — and so the key they are matched by is decided in one place (bankStopRows' findingsFor)
// instead of once here and once on the far side of a map.
SelfConflicts int
SelfConflictRows []terminology.ConsolidationConflict
// BankConflicts counts consolidated renderings that disagree with a row the bank already holds for the
// same firing surface — the shape neither check above can see (membank.ConsolidationKeyConflicts).
// BankHoldRows carries the findings themselves, ungrouped: bankStopRows matches them to its rows, so
// the key they are looked up by is decided in one place instead of once on each side of a map. Kept
// apart from SelfConflictRows because the two are different decisions for the owner.
BankConflicts int
BankHoldRows []membank.BankKeyConflict
// BankSettled is how many candidates never reached the paid role because the bank had already settled
// them — the surface is seeded and every draft proposed the rendering the bank holds. It is the saving,
// and it is carried rather than only logged so the report can state it with its denominator
// (Candidates) instead of with a word.
BankSettled int
// BankSettledKeys are the surfaces behind that count, carried so the sheet can SAY the row was never
// asked about instead of printing an empty rendering — which reads as «undecided» beside three other
// facts that share the glyph.
BankSettledKeys []string
// BasisServed is how many candidates the SETTLED BASIS answered — surfaces this book had already
// decided, whose answer is handed back instead of bought again. It is a DIFFERENT fact from
// BankSettled beside it: that one means «the bank renders this surface and every draft agreed», this
// one means «the engine decided this and the evidence has not moved since». Both skip the paid role
// and the two must never be summed into one number, because the sheet says opposite things about them.
BasisServed int
BasisServedKeys []string
// BasisReAsked counts, by class, the candidates the basis did NOT answer — new surfaces, a signed
// relative, moved evidence, an unsettled row, an incomplete unit, the owner's own signature. Carried
// rather than only logged so a purchase can state WHY it cost money instead of only that it did.
BasisReAsked map[basisReason]int
// BasisRows is what this run has decided, ready for the boundary writer: every candidate that now
// carries a CONSOLIDATED rendering, with the fingerprint of the evidence it was decided on. Built here
// because this is the only place both halves exist at once, and carried rather than written here
// because writing it before the run reaches an output boundary is the one thing that must not happen —
// a basis present on a crash-resume shrinks the batch composition and the resume re-buys what was paid.
BasisRows []store.BankBasisRow
// Conf is the role's own stated confidence per key. It sorts the review list «least sure first» and does
// nothing else — never a weight, a threshold or a cross-model comparison (D39.102).
Conf map[string]int
CostUSD float64 // what THIS run's RENDER phase paid
CumUSD float64 // what the render calls cost in total (a replayed checkpoint is $0 now, not then)
Fresh bool // at least one call actually reached the provider this run
EstimateUSD float64 // the pre-call projection, logged before any money moves
// Reclassified is how many candidate types the §2 classifier phase actually changed; ClassifyCostUSD is
// what that phase paid this run. Both zero when classify_types is off.
Reclassified int
ClassifyCostUSD float64
// ClassifyAsked / ClassifyAnswered are the classifier's own answer share, summed over the batches it
// actually called.
//
// ⛔ THE SHARE EXISTED AND REACHED NOBODY, which is the whole defect here. It was computed per batch and
// written into a mid-pass INFO line, so the only carrier of «the classifier answered 4 of 22» was a log
// somebody had to be reading at the time; the run's summary printed `classify_batches_dropped=0`, which
// counts batches the BUDGET left unbought and reads to a person as «nobody was left unanswered». On the
// cold run of file B the summary said dropped=0, reclassified counted normally, and 42 of 66 terms had
// no machine type. These two fields are that per-batch number raised to the total — not a third counter
// beside the render pass's `Unanswered`, which is a fact about a DIFFERENT pass.
//
// ⚠ ASKED IS THE DENOMINATOR AND IT IS NOT `Candidates`: a batch the budget never bought asked nobody
// anything, and folding it in here would report the model as silent about terms it was never shown.
ClassifyAsked int
ClassifyAnswered int
// BankRegens / BankUnusable / BankStepsRefused are the LADDER's facts, summed over both phases: extra
// rungs bought, batches that stayed unusable to the last rung, and rungs a phase sub-budget refused.
// They are what tells «the model could not answer this» apart from «the phase ran out of money», which
// before the ladder existed were the same silence.
BankRegens int
BankUnusable int
BankStepsRefused int
// Families is how many family GROUPS §G1 detected; FamiliesRefused how many of their merges the member
// cap turned down, and FamiliesHeld how many a series held part of back. Both of the latter mean the same
// thing to the owner — a family met split across two calls — and both are zero when the source declares
// no family data.
Families int
FamiliesRefused int
FamiliesHeld int
}
// loadTerminologyTemplate loads the pair's terminologist prompt when the gate is on. Mirrors
// loadRepairTemplates: a gate that cannot fire is refused at LOAD time, before any billing.
func (r *Runner) loadTerminologyTemplate() error {
if !r.Pipeline.Gates.Terminology.Enabled {
return nil
}
tpl, err := LoadPromptTemplate(r.Pipeline.Gates.Terminology.PromptPath)
if err != nil {
return err
}
r.terminologyTemplate = tpl
if err := r.loadFamilyParams(); err != nil {
return err
}
return r.loadClassifierTemplate()
}
// loadFamilyParams resolves the §G1 family channel from the SOURCE language's declared morphology, once, at
// LOAD time — before any billing, like every other gate precondition. A source with no family data yields a
// disabled channel and the batcher behaves exactly as it did before the channel existed.
//
// It is also where a data typo dies: the file names engine TYPES, and lang cannot check them against the
// engine's closed vocabulary without depending on this layer. A rule for a type nothing emits would parse
// fine and leave the channel quietly half-off — the same silent-empty-table class the pack loader refuses.
func (r *Runner) loadFamilyParams() error {
fm := lang.FamilyMorphology(r.Book.SourceLang)
if !fm.Enabled() {
r.familyParams = terminology.FamilyParams{}
return nil
}
sEnabled, headFinal := lang.SeriesMorphology(r.Book.SourceLang)
p := terminology.FamilyParams{
Enabled: sEnabled, HeadFinal: headFinal,
Affix: make(map[string]terminology.FamilyAffix, len(fm.Affix)),
MinMembers: fm.MinMembers, MaxMembers: fm.MaxMembers, ContainmentRunes: fm.ContainmentRunes,
}
for typ, a := range fm.Affix {
if !terminology.CandidateTypes[typ] {
return fmt.Errorf("pipeline: the family morphology of source %q names type %q, which no candidate can carry (accepted: %s) — a typo here would parse fine and leave the family channel silently half-off",
r.Book.SourceLang, typ, strings.Join(terminology.TypeNames(terminology.CandidateTypes), "|"))
}
p.Affix[typ] = terminology.FamilyAffix{Suffix: a.Suffix, MinRunes: a.MinRunes}
}
r.familyParams = p
return nil
}
// loadClassifierTemplate loads the pair's §2 classifier prompt when classify_types is on. Off → nil, and the
// classifier phase is inert. Called from loadTerminologyTemplate: the classifier only exists as a phase of
// the terminology gate.
func (r *Runner) loadClassifierTemplate() error {
if !r.Pipeline.Gates.Terminology.ClassifyTypes {
return nil
}
tpl, err := LoadPromptTemplate(r.Pipeline.Gates.Terminology.ClassifyPromptPath)
if err != nil {
return err
}
r.classifierTemplate = tpl
return nil
}
// loadTargetScript resolves the answer-language screen's script once. config refuses an unknown name for
// an enabled gate, so an unresolved script here means the book simply declared none — inert, and said out
// loud when a channel that would have used it is on.
func (r *Runner) loadTargetScript() {
if name := r.Pipeline.Gates.Terminology.TargetScript; name != "" {
r.targetScript, _ = terminology.ScriptByName(name)
}
if r.targetScript == nil && r.Pipeline.Gates.Banknote.Enabled {
r.Log.Warn("the banknote channel is on but no gates.terminology.target_script is declared: draft-side proposals are NOT screened for the answer language, so a rendering in another script can enter the signature map and the auto-bank",
"book", r.Book.BookID)
}
}
// terminologyOpts resolves the sizing knobs in ONE place — explicit config, else the pair's own data,
// else the engine default — so batching and rendering can never disagree about what a batch is.
func (r *Runner) terminologyOpts() (batchRunes, kwicPer, kwicWidth int) {
g := r.Pipeline.Gates.Terminology
batchRunes, kwicPer, kwicWidth = g.BatchRunes, g.KWICPerTerm, g.KWICWidth
if pair := r.packTerminology(); pair != nil {
if kwicPer <= 0 {
kwicPer = pair.KWICPerTerm
}
if kwicWidth <= 0 {
kwicWidth = pair.KWICWidth
}
}
if batchRunes <= 0 {
batchRunes = terminologyDefaultBatchRunes
}
if kwicPer <= 0 {
kwicPer = terminologyDefaultKWICPer
}
if kwicWidth <= 0 {
kwicWidth = terminologyDefaultKWICWidth
}
return batchRunes, kwicPer, kwicWidth
}
func (r *Runner) packTerminology() *lang.TerminologySizing {
if r.pack == nil {
return nil
}
return r.pack.Terminology
}
// buildBankCandidates is the $0 half of the role: the two-way minerbanknote merge, the source contexts,
// and the §C2-3 ranking of whatever the drafts already produced. It runs whether or not the gate is on —
// with the gate off nothing consumes the ranking, but building it costs nothing and it is what the stop's
// table is rendered from.
func (r *Runner) buildBankCandidates(mined []miner.Term, observed []terminology.Observed, chunks []terminology.Chunk) []terminology.Candidate {
ms := make([]terminology.Mined, 0, len(mined))
for _, m := range mined {
ms = append(ms, terminology.Mined{
Key: text.NormalizeSourceKey(m.Src), Src: m.Src, Type: m.Type,
Freq: m.Freq, SinceCh: m.SinceCh, Aliases: m.Aliases, Evidence: m.Evidence,
})
}
// The vote is counted per target-form CONVENTION, not per byte string (§G5): «Море истинной ци» and
// «море истинной ци» are one decision, and splitting their evidence hands the §C2-3 frequency factor to
// whichever spelling a chunk happened to repeat. The normalizer is the SAME one the post-check matches
// against, so the fold cannot disagree with the check that reads the result.
cands := terminology.Merge(ms, observed, text.NormalizeTargetForm)
_, kwicPer, kwicWidth := r.terminologyOpts()
cands = terminology.AttachKWIC(cands, chunks, kwicPer, kwicWidth)
opts := r.scoreOpts()
for i := range cands {
terminology.ScoreVariants(&cands[i], opts)
}
return cands
}
// scoreOpts builds the §C2-3 scoring options — the approved-neighbour anchor and the pair's transliteration
// conformance, which routes on TYPE (only name/place answer to the transliteration convention). Extracted so
// the initial candidate build and the §2 post-classify re-score share ONE definition and can never disagree
// about how a variant is scored.
func (r *Runner) scoreOpts() terminology.ScoreOpts {
opts := terminology.ScoreOpts{Neighbours: approvedNeighbours(r.glossaryRows())}
if r.pack != nil {
pack := r.pack
opts.Conformance = func(dst, typ string) float64 {
if typ != "name" && typ != "place" {
return 0 // the convention only speaks about transliterated entities
}
return miner.PalladiusConformance(dst, pack)
}
}
return opts
}
// glossaryRows is the book's stored bank, every row with its status and window. The anchor below narrows
// it to the signed rows; ConsolidationKeyConflicts needs it whole, since an unsigned row owes one
// rendering too. A read failure degrades rather than aborts — everything built on it improves a
// consolidation, none of it is a precondition — but it is LOGGED: a degraded read and a clean bank
// produce the same zero in every counter downstream.
func (r *Runner) glossaryRows() []store.GlossaryEntry {
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
// Called from the scoring pass as well as the role, so it can repeat within one run; a repeated
// line is cheaper than a zero that reads as a clean bank.
r.Log.Warn("terminology: could not read the bank — whatever this call feeds goes silent (the canon anchor, the bank conflict check, or both), and its zero then means «not asked» rather than «nothing found»",
"book", r.Book.BookID, "err", err)
return nil
}
return rows
}
// approvedNeighbours is the already-signed bank, as the §C2-3 "agreement with approved siblings" anchor.
// Only APPROVED rows qualify: an unverified row agreeing with an unverified row is not evidence.
func approvedNeighbours(rows []store.GlossaryEntry) []terminology.Neighbour {
out := make([]terminology.Neighbour, 0, len(rows))
for _, e := range rows {
if e.Status == "approved" && e.Dst != "" {
out = append(out, terminology.Neighbour{Src: text.NormalizeSourceKey(e.Src), Dst: e.Dst})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Src < out[j].Src })
return out
}
// rescoreCandidates re-ranks EVERY candidate's renderings under the current scoring options, for the caller
// that stamps a BANKED type onto a candidate the settled basis answered. A type has just changed and the
// §C2-3 conformance factor routes on type, so a stale ranking would put the wrong rendering first on the
// sheet the owner reads.
//
// ⛔ OVER `cands` AND NEVER OVER A FILTERED COPY. ScoreVariants REPLACES a candidate's Variants slice, so
// re-scoring a copy leaves the caller's own slice — the one the signing sheet is rendered from — holding
// the ranking computed before the type was known. The operator then reads a ranking factor the
// classification has retired, and nothing says so.
//
// ⚠ IT DELIBERATELY DID NOT ABSORB THE TWO OLDER RE-SCORING SITES, and the restraint is the point rather
// than an oversight. Folding them in looked like the obvious tidy-up and was measured wrong: the money pin
// that guards exactly this defect plants into the older site's LITERAL THREE LINES, so absorbing them left
// its anchor unresolvable — and re-aiming the mutation at this function made it attack «no re-scoring at
// all», which is a different defect that nothing pins. A refactor that moves a pin's subject out from under
// its planting silently retires the pin. So the older sites keep their shape, and this one does not
// pretend to be them.
func (r *Runner) rescoreCandidates(cands []terminology.Candidate) {
opts := r.scoreOpts()
for i := range cands {
terminology.ScoreVariants(&cands[i], opts)
}
}
// runTerminologist calls the role over the candidate list and returns key → consolidated rendering. With
// the gate off it is a no-op returning an empty map and a zero result, so every existing book takes a
// byte-identical path and pays nothing.
//
// A term absent from the returned map keeps NO dst — the auto branch of §C2-7. That is the deliberate
// reading of silence: a role that did not answer has not decided, and an undecided term must not enter the
// wire carrying a guess.
//
// A NIL result means the pass did not run at all, which is a different fact from a pass that ran and
// consolidated nothing — the counters of the second are a verdict about the bank, the counters of the
// first are a verdict about nothing. The projections downstream publish that difference (bankexport.go).
func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []terminology.Candidate) (consolidated, classified, gendered map[string]string, res *terminologyResult, err error) {
if !r.Pipeline.Gates.Terminology.Enabled || r.terminologyTemplate == nil || len(cands) == 0 {
return nil, nil, nil, nil, nil
}
res = &terminologyResult{Candidates: len(cands)}
for _, c := range cands {
if c.Origin == terminology.OriginBanknote {
res.Reverse++
}
}
// ⚠ `paid` is a SEPARATE slice and `cands` is left whole on purpose. The caller renders the signature
// sheet and the delta from the candidates it passed in, and the two passes below MUTATE candidates in
// place — applyTypes stamps the classifier's type, ScoreVariants re-ranks the renderings. Filtering the
// caller's own slice would have copied the survivors into a new array, so those mutations would have
// stopped reaching the sheet: the operator would read heuristic types and stale rankings, and nothing
// would say so. Every fixture in the package runs with the classifier OFF, where neither mutation
// happens at all, so the tests written for this filter could not have seen it.
// ⛔ INDICES, not a filtered slice, and that is the fix for a defect this file has now grown three
// times. The two passes below MUTATE candidates in place (applyTypes stamps the classifier's type,
// ScoreVariants re-ranks), the caller renders the sheet from `cands`, and the ROLE's request is built
// from the paid subset — so a subset held as its own array carries the stamps to neither, or to only
// one of the two. Keeping positions lets the subset be REBUILT from `cands` after the mutations, so the
// sheet and the wire read the same candidates.
paidIdx, settled := r.dropBankSettled(ctx, cands)
res.BankSettled = settled
if settled > 0 {
kept := map[int]bool{}
for _, i := range paidIdx {
kept[i] = true
}
for i, c := range cands {
if !kept[i] {
res.BankSettledKeys = append(res.BankSettledKeys, c.Key)
}
}
}
// The bank is read once HERE and shared by everything below it — never per batch, since it is the same
// law for every batch. The signed rows are the anchor the model is shown; the whole set is what the
// conflict checks read, and, since the settled basis, what the predicate reads to learn which rows the
// book has decided. Moved above the basis pass so those three cannot come to disagree about what the
// bank held this run.
//
// ⚠ NOT the only read of the run: dropBankSettled above takes its own (glossaryRows again), as it did
// before this pack. That is pre-existing and harmless — the glossary's only writer is seedGlossary, and
// it does not run between the two — but the claim "read ONCE for the whole role" would be false, and a
// comment that overstates its own guarantee is one a later reader relies on.
bank := r.glossaryRows()
canon := approvedNeighbours(bank)
// THE SETTLED BASIS, and its position is load-bearing twice over. It stands BEFORE the first paid call,
// so a decision already taken never reaches a provider; and it stands BEFORE the classifier pass, which
// MUTATES Candidate.Type in place (applyTypes) — a fingerprint taken after it would hash the
// classifier's type, and then learning whether the terminologist may be skipped would require buying
// the classifier first, on every purchase, which is the cost this mechanism exists to remove.
consider := make(map[int]bool, len(paidIdx))
for _, i := range paidIdx {
consider[i] = true
}
basisFPs, basisAnswers, basisRows, basisAmbiguous := r.bankBasisPass(ctx, cands, consider, bank, res)
if len(basisAnswers) > 0 {
kept := paidIdx[:0]
for _, i := range paidIdx {
if _, served := basisAnswers[cands[i].Key]; !served {
kept = append(kept, i)
}
}
paidIdx = kept
}
if len(paidIdx) == 0 && len(basisAnswers) == 0 {
return nil, nil, nil, res, nil
}
// ⛔ THE BASIS'S TYPE IS STAMPED ONTO THE CANDIDATES HERE, and NOT through the classifier's own merge
// further down. The sheet, the stop table and the sidecar's `kind` all read Candidate.Type, and
// applyTypes — the only thing that writes it — has already run by the time the basis answers reach the
// maps it reads. Merged there, the banked type reaches the auto-bank and NOT the sheet: the row the
// owner signs by would keep saying `place` while the bank row for the same term says `term`, for as
// long as the basis keeps answering it. Measured on the tree by adversarial review, in the steady state.
//
// ⚠ It is a STAMP and not applyTypes, because applyTypes returns the number the run publishes as
// `reclassified` — «how many types the CLASSIFIER changed». A term the classifier never saw must not
// inflate it; a counter that counts two different events is one nobody can act on.
if n := stampBasisTypes(cands, basisAnswers); n > 0 {
r.rescoreCandidates(cands)
r.Log.InfoContext(ctx, "the settled basis restored the BANKED type of terms it answered, so the signing sheet and the bank row agree about what they are; the run's `reclassified` counter is untouched because the classifier did not see them",
"book", r.Book.BookID, "terms", n)
}
paid := pickCandidates(cands, paidIdx)
// ⛔ ASKED HERE, BEFORE THE FIRST PAID CALL, and the position is the whole point. The question is
// whether this book had bank-role checkpoints BEFORE this run — and after the passes below it is
// unanswerable, because they settle checkpoints of their own: a FRESH book that has just paid once
// answers "yes, it had paid" and the operator is told he paid twice. That is exactly what the previous
// version of this warning did, one viton after the version before it told every ordinary resume the
// same lie. A read failure leaves it false — a warning about money must not be invented from an error.
paidBefore, perr := r.Store.HasCheckpointForStage(r.Book.BookID, terminologyStageName)
if perr != nil {
r.Log.WarnContext(ctx, "terminology: could not read whether this book had paid for the bank role before this run, so a one-time re-consolidation would go unannounced; nothing else is affected",
"book", r.Book.BookID, "err", perr)
}
batchRunes, _, _ := r.terminologyOpts()
// §2 type re-derivation, BEFORE the render: a focused pass re-classifies every candidate, so a realia
// surface mistyped as a name (元石) is no longer FORCED to a transliteration. The corrected type routes
// conformance (re-scored here), primes the wire block, and is returned so the caller stamps it as the
// banked type. Off (or unanswered) → the heuristic draft type stands.
classified, gendered, crun, cerr := r.runClassifier(ctx, snapID, paid)
// ⛔ THE MONEY IS RECORDED BEFORE THE VERDICT, and the order is the same one runStage keeps for the
// same reason: what a pass BOUGHT is a fact about what happened, not about whether it succeeded. Read
// after the error check, these two fields reported ZERO for a pass that had paid for batches and then
// met a broken provider — a struct lying about money to whoever reads it next.
//
// ⚠ WHAT THIS IS AND IS NOT. Today nothing reads it on that path: an error here ends the run, the
// driver returns no result, and the money is on the LEDGER either way (the batch settles through
// SettleWithCheckpoint like any call, RoleSpentUSD sums it from the durable checkpoints, and
// TestKillMinus9LosesAtMostOneCall pins that a settled checkpoint's money survives even a SIGKILL).
// So this is insurance against the next reader, not the repair of a live loss — backlog row 388 claimed
// more than that, and the claim was mine.
res.ClassifyCostUSD = crun.costUSD
// The classify pass has its OWN budget (classify_budget_usd), so it has its own cut — and the cold
// run's incident was on THIS pass, not the render one.
res.ClassifyBatchesDropped = crun.dropped
// ⛔ THE LADDER'S COUNTS BELONG ON THIS SIDE OF THE CHECK TOO, and they were on the other one. The rule
// is stated ten lines above for the money — what a pass BOUGHT is a fact about what happened, not about
// whether it succeeded — and a rung bought is exactly that kind of fact. Read after the error check,
// these four reported ZERO for a phase that had climbed and then met a broken provider: the render half
// records the identical fields BEFORE its own check, so the two halves disagreed about when a count
// becomes true. Found by acceptance, on an asymmetry nothing pins.
res.ClassifyAsked, res.ClassifyAnswered = crun.asked, crun.answered
res.BankRegens += crun.regens
res.BankUnusable += crun.unusable
res.BankStepsRefused += crun.stepsRefused
if cerr != nil {
return nil, nil, nil, res, cerr
}
res.Gendered = len(gendered)
if len(classified) > 0 {
res.Reclassified = applyTypes(cands, classified)
opts := r.scoreOpts()
for i := range cands {
terminology.ScoreVariants(&cands[i], opts)
}
// REBUILT so the batches below carry the corrected type. The classifier is bought FOR that field
// and it travels to the role for the whole batch; without this the money is spent, the run prints
// `reclassified=N`, and the wire still says what the heuristic guessed.
paid = pickCandidates(cands, paidIdx)
}
// §1 + §G1: co-batch each grade/rank SERIES and each term FAMILY so the role picks ONE generic head, and
// ONE shared root, for the whole set. The pair-data — whether the source forms rune-morpheme series,
// where the head sits, and which side of a surface carries a family's root — comes from the language
// layer, so the batcher stays pair-agnostic and a source with neither takes a byte-identical path.
// Over `paid`: a series or a family the role is not asked about cannot be co-batched, and detecting it
// over the full set would only make the units disagree with the batches built from them.
sEnabled, headFinal := lang.SeriesMorphology(r.Book.SourceLang)
seriesID := terminology.DetectSeries(paid, terminology.SeriesParams{Enabled: sEnabled, HeadFinal: headFinal})
fp := r.familyParams
fams := terminology.DetectFamilies(paid, fp)
unitID, ustats := terminology.MergeUnits(seriesID, fams, fp)
batches := terminology.Batch(paid, batchRunes, unitID)
res.Batches = len(batches)
res.Families, res.FamiliesRefused, res.FamiliesHeld = len(fams), ustats.Refused, ustats.Held
if ustats.Refused > 0 || ustats.Held > 0 {
// Either guard leaves a family split across calls — the very defect this channel exists to close — so
// both are named rather than left to be inferred from a bank that disagrees with itself.
r.Log.WarnContext(ctx, "terminology: some families were NOT co-batched whole — the member cap refused the merge, or a series with an unrelated root kept its members; those families can still disagree with themselves across calls",
"book", r.Book.BookID, "refused_by_cap", ustats.Refused, "held_by_series", ustats.Held, "max_members", fp.MaxMembers)
}
// §1/§G1 keep a unit (or a lone candidate) WHOLE even past the budget — splitting one would defeat the
// co-batching it exists for — so a co-batched grade set or an evidence-heavy single term can render
// over the cap. That is deliberate but NOT silent: an over-cap unit strains the model's output ceiling (the
// cap-8000 mine), so name it while the run can still be watched.
for i, b := range batches {
if br := terminology.BatchRunes(b); br > batchRunes {
r.Log.WarnContext(ctx, "terminology: a batch renders OVER the size budget and is sent WHOLE (a series/family is co-batched by design, §1/§G1; a lone candidate cannot be split) — watch the model's output cap on this call",
"book", r.Book.BookID, "batch", i, "terms", len(b), "batch_runes", br, "budget_runes", batchRunes)
}
}
plan := bankRolePlan{
role: roleTerminologist, budgetUSD: r.Pipeline.Gates.Terminology.BudgetUSD,
messages: func(b []terminology.Candidate) ([]llm.Message, error) { return r.terminologyMessages(b, canon) },
}
run, rerr := r.runBankRoleBatches(ctx, snapID, plan, batches, "render")
// The CUT is a property of the pass, and it has to reach the report: a bank that is partially
// consolidated because the money ran out is a different object from one the role fully considered.
// The SPEND is recorded here for the same reason and in the same breath as the classifier's above: it
// is what happened, whatever the verdict turns out to be.
res.BatchesDropped = run.dropped
res.BankRegens += run.regens
res.BankUnusable += run.unusable
res.BankStepsRefused += run.stepsRefused
res.EstimateUSD, res.CostUSD, res.CumUSD, res.Fresh = run.estimateUSD, run.costUSD, run.cumUSD, run.fresh
if rerr != nil {
return nil, nil, nil, res, rerr
}
// ⚠ THE ONE-TIME COST OF THE ALREADY-BANKED FILTER. It needs BOTH halves and each is knowable at only
// one moment: that the book had paid BEFORE this run (paidBefore, taken above the passes) and that this
// run paid ANYWAY (run.fresh, knowable only now). Either half alone is a lie that has already been told
// here twice — on "the book has paid", which every ordinary resume of a filtered book satisfies, and on
// a probe taken after the passes, which a freshly-paid first run satisfies trivially.
if res.BankSettled > 0 && run.fresh && paidBefore {
// ⚠ THE CAUSE IS NAMED AS A PAIR, because the code cannot tell which of the two happened and a
// message that picks one is wrong half the time. The old wording asserted the composition change
// alone; a book whose only earlier bank row was BURNED — money settled, no result, unreplayable —
// gets the same warning with a reason that is simply false for it, and a message lying about its
// own cause is what D39.93 п.2 forbids. Both branches leave the operator with the same action, so
// naming both costs nothing and claiming one costs the truth.
r.Log.WarnContext(ctx, "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — the earlier calls' checkpoints could not be reused, either because the batch composition changed (what the bank and the settled basis answer is not what they answered last time) or because they recorded money with no result and cannot be replayed. It is bought ONCE per change, not once per book: re-running THIS purchase replays for $0 from here on, while the next purchase that settles a new row re-packs the remaining batches and pays for them again",
logKeyReconsolidated, true,
"book", r.Book.BookID, "skipped", res.BankSettled, "of_candidates", res.Candidates, "cost_usd", fmt.Sprintf("%.6f", run.costUSD))
}
out := map[string]string{}
res.Conf = map[string]int{}
for i, b := range batches {
if !run.ran[i] {
continue // this batch was never called; runBankRoleBatches already said why
}
if run.texts[i] == "" {
// An EMPTY completion on a call that was actually made. It used to `continue` in silence, which is
// how a paid batch that returned nothing became indistinguishable from «the role declined these
// terms» — a DECISION in §C2-7 — with the run exiting 0.
r.Log.WarnContext(ctx, "terminology: a paid batch came back with an EMPTY completion; its terms stay unconsolidated",
"book", r.Book.BookID, "batch", i, "asked", len(b), "answered", 0)
continue
}
got, conf, st := terminology.ParseReply(run.texts[i], candKeys(b), text.NormalizeSourceKey, r.targetScript,
r.declinedByPhrase)
res.BadLines += st.Bad
res.OffLanguage += st.OffLanguage
res.DeclinedByPhrase += st.DeclinedByPhrase
res.NoLetters += st.NoLetters
// Asked-vs-answered per batch: the one number that separates «the model skipped half the block» from
// «the parser refused half the lines», and neither is visible in a total.
r.Log.InfoContext(ctx, "terminology render batch", "book", r.Book.BookID,
"batch", i, "asked", len(b), "answered", len(got), "bad_lines", st.Bad, "reply_chars", len(run.texts[i]))
// A batch answered largely in another language is the measured cold-start failure, not noise: say it
// while the run is happening, naming the lines, because the terms themselves just stay auto.
if st.OffLanguage > 0 {
r.Log.WarnContext(ctx, "terminology: the model answered in another script; those lines are REFUSED (the terms stay unconsolidated) — a book with few signed rows gives the model no target-language anchor",
"book", r.Book.BookID, "batch", i, "terms", len(b), "off_language", st.OffLanguage,
"target_script", r.Pipeline.Gates.Terminology.TargetScript, "lines", strings.Join(st.OffLanguageSamples, "; "))
}
// A rendering with no letters at all — «90», «——». (A ONE-rune answer is refused a check earlier, on
// length, so it never reaches this counter.) Named rather than counted, for the same
// reason the off-language lines are: the population of this class on bought material has been zero
// so far, so the first live one is evidence about a model or about a truncated reply, and a bare
// count cannot tell those apart.
if st.NoLetters > 0 {
r.Log.WarnContext(ctx, "terminology: reply lines carried a rendering with NO LETTERS at all; they are REFUSED (the terms read as unanswered, not as declined) — a bank cannot hold «90» as a book's canon",
"book", r.Book.BookID, "batch", i, "terms", len(b), "no_letters", st.NoLetters,
"lines", strings.Join(st.NoLettersSamples, "; "))
}
// A decline the role wrote in WORDS instead of with the sentinel. The terms land where the sentinel
// would have put them; what the warning is about is the PROMPT — the pair asked for ⟦TM-NO-DST⟧ and
// got prose, and nothing else in the run would ever say so.
if st.DeclinedByPhrase > 0 {
r.Log.WarnContext(ctx, "terminology: the role declined term(s) in WORDS rather than with the engine sentinel; they are treated as declined (NOT banked), and the pair's prompt is what needs the look",
"book", r.Book.BookID, "batch", i, "terms", len(b), "declined_by_phrase", st.DeclinedByPhrase)
}
// A batch that came back with NOTHING usable is a paid call that bought no terminology. Silence here
// would spend the money, mark every term "the role declined" and exit 0. Say it out loud; the run still
// continues, because an unconsolidated bank is the state this step started from, not a broken one.
if len(got) == 0 {
r.Log.WarnContext(ctx, "terminology: a paid batch returned nothing the parser could use; its terms stay unconsolidated",
"book", r.Book.BookID, "batch", i, "terms", len(b), "bad_lines", st.Bad, "reply_chars", len(run.texts[i]))
}
for k, v := range got {
out[k] = v
}
for k, v := range conf {
res.Conf[k] = v
}
}
// ⛔ THE BASIS ANSWERS JOIN `out` HERE — after the batches, before every reader below — and the
// position is chosen against two opposite failures.
//
// Joining them LATER (or never) is the silent regression this mechanism is built against: `out` is what
// attachConsolidatedDst stamps, and a candidate absent from it keeps no rendering at all, so the
// emission falls back to the raw first-chunk banknote guess at status `auto`. The run would look
// cheaper and the bank would be poorer, with nothing red anywhere.
//
// Joining them EARLIER — before the count loop below — would change what a published number means:
// `consolidated` is the RENDER PASS's account of itself, and folding rows nobody asked about into it
// would report the role as having answered terms it never saw. The loop runs over `paid`, which the
// basis pass has already narrowed, so the counters stay a fact about the money spent this run.
//
// Judged by the dispute checks, deliberately: see bankBasisPass.
for k, a := range basisAnswers {
out[k] = a.Dst
}
// Over `paid`, not over every candidate: a candidate nobody was asked about has not gone UNANSWERED —
// that word means the role was given it and said nothing, which is a fact about the model.
for _, c := range paid {
v, answered := out[c.Key]
switch {
case !answered:
res.Unanswered++
case v == "":
res.Declined++
default:
res.Consolidated++
}
}
// The canon check is $0 and runs on EVERY consolidation, signed anchor or not: the anchor is a nudge to
// a model, and a nudge that is not verified is a hope. Named terms, not a bare count — a conflict is
// actionable only if the operator is told which rendering fights which signature.
if conflicts := terminology.CanonConflicts(cands, out, canon); len(conflicts) > 0 {
res.CanonConflicts = len(conflicts)
named := make([]string, 0, len(conflicts))
for _, cf := range conflicts {
named = append(named, fmt.Sprintf("%s→%q contradicts the signed %s→%q", cf.Src, cf.Dst, cf.CanonSrc, cf.CanonDst))
}
r.Log.WarnContext(ctx, "terminology: consolidated renderings contradict the SIGNED bank; they stay UNSIGNED — which no longer holds them back from the wire (D39.104) — and are the first rows to review at the stop",
"book", r.Book.BookID, "conflicts", len(conflicts), "terms", strings.Join(named, "; "))
}
// §G2: the same check with the run's OWN consolidations on the right-hand side. On a live bank this is
// the MAJORITY of the contradictions (18 of 149, research/24 §A4) and until now nobody looked: the canon
// check can only see rows the owner already signed, and a book being consolidated for the first time has
// almost none. $0, evidence-side, never a gate.
if self := terminology.ConsolidationConflicts(cands, out); len(self) > 0 {
res.SelfConflicts = len(self)
res.SelfConflictRows = self
r.Log.WarnContext(ctx, "terminology: consolidations of THIS run contradict each other — a compound's rendering drops the rendering the same reply gave its own part; they stay unverified and are review rows at the stop",
"book", r.Book.BookID, "conflicts", len(self), "terms", strings.Join(terminology.ConsolidationConflictMessages(self), "; "))
}
// The BANK side: a rendering this run consolidated for a surface the bank already renders differently,
// which neither check above reaches. Neither side is asked for a signature — one does not change what
// the model is shown (D39.104 п.2). $0, evidence-side, never a gate.
if cols := membank.ConsolidationKeyConflicts(consolidatedRows(cands, out), bank); len(cols) > 0 {
res.BankConflicts = len(cols)
res.BankHoldRows = cols
r.Log.WarnContext(ctx, "terminology: a consolidated rendering DISAGREES with a row the bank already holds for the same firing surface — the book calls this term something else, and the sheet at the stop is where that is decided (whether both renderings also reach a wire depends on the emission and the glossary UNIQUE key; this does not claim they will)",
"book", r.Book.BookID, "conflicts", len(cols), "terms", strings.Join(membank.ConflictMessages(cols), "; "))
}
// The $0 label screen (§2 warm-run hygiene): a name/place row whose rendering was clearly TRANSLATED is a
// label/rendering disagreement worth a human's eye. It is a review FLAG, never a gate, and explicitly not
// a safety net for the transliteration harm — the classifier phase is what prevents that (see
// terminology.TypeLabelMismatches). Named, not a bare count, so the operator knows which rows to open.
if flags := terminology.TypeLabelMismatches(labelRows(cands, out)); len(flags) > 0 {
named := make([]string, 0, len(flags))
for _, f := range flags {
named = append(named, fmt.Sprintf("%s [%s]→%q", f.Src, f.Type, f.Dst))
}
r.Log.WarnContext(ctx, "terminology: name/place rows carry a translated rendering — a label/rendering mismatch to review (hygiene flag, not a gate)",
"book", r.Book.BookID, "rows", len(flags), "terms", strings.Join(named, "; "))
}
// A pass ended SHORT, and the counters below cannot say so on their own: a term left unconsolidated
// because the budget ran out is not a term the role declined to render.
//
// ⛔ TWO WARNINGS AND NOT ONE, because they are two different facts on two different budgets and only
// the first is about the BANK. One warning fired by either counter said «this bank is PARTIALLY
// consolidated» on a run whose render pass was INTACT and whose classifier alone was cut — measured
// 08.09: `batches_dropped=0, classify_batches_dropped=1, consolidated=29`. That is a false alarm about
// the object an owner signs, and it also made the engine disagree with its own read-out, which reports
// the bank's completeness from the render pass alone (bankexport.go, BankConsolidation).
if res.BatchesDropped > 0 {
r.Log.WarnContext(ctx, "terminology: this bank is PARTIALLY consolidated — a budget cut the RENDER pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer",
"book", r.Book.BookID, "render_batches_dropped", res.BatchesDropped)
}
if res.ClassifyBatchesDropped > 0 {
r.Log.WarnContext(ctx, "terminology: the TYPE classifier ended short — its OWN budget cut a pass, so some candidates keep the draft heuristic type; the bank's renderings are unaffected and this alone does not make the bank partially consolidated",
"book", r.Book.BookID, "classify_batches_dropped", res.ClassifyBatchesDropped)
}
// ⛔ THE SHARE THE CLASSIFIER ACTUALLY ANSWERED, and it is a DIFFERENT fact from the line above. That
// one is about money the phase did not spend; this one is about terms the phase paid to ask and did not
// get an answer for. Read as one, they hid the cold run's whole finding: `classify_batches_dropped=0`
// with 42 of 66 terms left on the draft heuristic. A term with no answered type keeps that heuristic,
// and the heuristic is wrong 1222% of the time on a field that FORCES transliteration — so the silence
// is not cosmetic, it is a place-name shipped as a person's name in every chapter it occurs in.
if res.ClassifyAsked > res.ClassifyAnswered {
r.Log.WarnContext(ctx, "terminology: the TYPE classifier did not answer every term it was PAID to be asked about; those terms keep the draft heuristic type, which is what forces a transliteration on a mistyped surface",
"book", r.Book.BookID, "asked", res.ClassifyAsked, "answered", res.ClassifyAnswered,
"unanswered", res.ClassifyAsked-res.ClassifyAnswered)
}
r.Log.InfoContext(ctx, "terminology finished", "book", r.Book.BookID,
"bank_conflicts", res.BankConflicts,
"batches_dropped", res.BatchesDropped, "classify_batches_dropped", res.ClassifyBatchesDropped,
// The classifier's answer share, with its own denominator: «dropped» above is money never spent,
// these are terms bought and unanswered, and a reader given only the first infers the second wrongly.
"classify_asked", res.ClassifyAsked, "classify_answered", res.ClassifyAnswered,
// What the ladder did across both phases — extra rungs bought, batches unusable to the last rung,
// rungs a phase sub-budget refused.
"bank_regenerations", res.BankRegens, "bank_unusable_batches", res.BankUnusable,
"bank_steps_refused", res.BankStepsRefused,
"consolidated", res.Consolidated, "declined", res.Declined, "unanswered", res.Unanswered,
"reclassified", res.Reclassified, "bad_lines", res.BadLines, "off_language", res.OffLanguage,
"declined_by_phrase", res.DeclinedByPhrase, "no_letters", res.NoLetters,
"canon_conflicts", res.CanonConflicts, "self_conflicts", res.SelfConflicts,
"families", res.Families, "families_refused", res.FamiliesRefused, "families_held", res.FamiliesHeld,
"cost_usd", fmt.Sprintf("%.6f", res.CostUSD),
"classify_cost_usd", fmt.Sprintf("%.6f", res.ClassifyCostUSD))
// The basis's TYPE and GENDER reach their stampers the same way the classifier's do — and they are
// merged HERE, after `res.Reclassified` has been counted, on purpose: that counter answers «how many
// types did the classifier change», and a row the classifier never saw must not inflate it. A basis row
// whose purchase produced no type or no gender carries none now either, exactly as it did then.
for k, a := range basisAnswers {
if a.Type != "" {
if classified == nil {
classified = map[string]string{}
}
classified[k] = a.Type
}
if a.Gender != "" {
if gendered == nil {
gendered = map[string]string{}
}
gendered[k] = a.Gender
}
}
// WHAT THIS BOOK HAS DECIDED AS OF NOW — this run's fresh answers and the ones it was handed, together,
// each with the fingerprint of the evidence it stands on. Built here because this is the only moment
// both halves are in hand; WRITTEN at the run's output boundaries and nowhere else (see writeBankBasis).
res.BasisRows = basisRowsToStore(cands, out, classified, gendered, basisFPs, basisRows, basisAmbiguous, basisShape(r.basisWidth(), r.bankRoleProducer()))
return out, classified, gendered, res, nil
}
// terminologyMessages renders ONE batch into the wire messages: the pair's authored role prompt (system),
// the canon anchor as a code-assembled injection message, and the candidate block as the user turn. The
// block goes through {{text}} — the closed placeholder set stays closed, exactly as the memory injection
// did rather than growing a new placeholder.
//
// The anchor carries the book's own SIGNED rows (CANON — what the owner already decided, which a
// consolidation may not contradict) and nothing else: the pair/genre block that shipped beside it was
// removed by D39.47, because a market-wide default is exactly the thing this project has no authority to
// assert. It is DATA; every word explaining it lives in the pair's prompt, so a new pair needs no Go edit.
func (r *Runner) terminologyMessages(batch []terminology.Candidate, canon []terminology.Neighbour) ([]llm.Message, error) {
return MessagesWithInjection(r.terminologyTemplate,
RenderVars{Book: r.Book, Text: terminology.RenderBatch(batch)},
terminology.RenderCanonAnchor(terminology.CanonFor(batch, canon, terminologyCanonCap)))
}
// terminologyCanonCap bounds the signed rows one batch carries. The anchor is a REMINDER of the law that
// touches these terms, not a copy of the bank: an unbounded block would grow with the book and eventually
// cost more than the candidates it accompanies.
const terminologyCanonCap = 40
// runClassifier is the §2 type-classifier phase: a focused pass on the same candidate batches that returns
// key → corrected type AND key → gender. Off (or no template) → nil maps and a zero run, so the
// terminologist keeps the draft heuristic type and pays nothing. The classifier needs no series co-batching
// (it decides a class per term) and no canon anchor (a class is a property of the source, not of the
// signed bank).
//
// ⚠ WHY THE GENDER RIDES HERE (backlog row 210, the pack's choice among the three the brief named). The
// engine has PROMISED a character's gender on the wire since D39.21 and never produced one: the
// terminologist's prompt returns three fields and none of them is gender, and the miner reads `gender`
// out of the hand-written seed only — so on any book nobody seeded by hand the axis is dead by
// construction, and «плывущий род» is not a rendering defect but a missing input. Of the ways to produce
// it, this pass is the only one that costs NOTHING: it already runs over exactly these candidates with
// exactly these contexts, so the answer grows by one short word per line instead of by a whole pass of
// paid calls. It is also the right ROLE. Gender is a property of the entity, read off the source contexts
// — the same act as deciding `name` against `term` — while the terminologist's act is choosing a rendering;
// and the terminologist's third column is the CONFIDENCE, around which a delicate mis-split rule is written
// (replyColumns) after a live defect glued «95%» onto a rendering. This parser is positional over a closed
// vocabulary and cannot glue.
//
// ⚠ AND IT IS GATED ON classify_types. A book whose config leaves the classifier off produces no gender
// at all, by exactly the same mechanism that leaves its types on the draft heuristic.
func (r *Runner) runClassifier(ctx context.Context, snapID string, cands []terminology.Candidate) (types, genders map[string]string, run bankRoleRun, err error) {
g := r.Pipeline.Gates.Terminology
if !g.ClassifyTypes || r.classifierTemplate == nil || len(cands) == 0 {
return nil, nil, bankRoleRun{}, nil
}
batchRunes, _, _ := r.terminologyOpts()
batches := terminology.Batch(cands, batchRunes, nil)
plan := bankRolePlan{role: roleClassifier, budgetUSD: g.ClassifyBudgetUSD, messages: r.classifierMessages}
run, err = r.runBankRoleBatches(ctx, snapID, plan, batches, "classify")
if err != nil {
return nil, nil, run, err
}
types, genders = map[string]string{}, map[string]string{}
bad, badGender, noGender := 0, 0, 0
// asked counts the terms this phase actually PUT to the model — batches it called, whether or not they
// answered. A batch the budget never bought is not in it (see terminologyResult.ClassifyAsked).
asked, answered := 0, 0
for i, b := range batches {
if !run.ran[i] {
continue // this batch was never called, and the pass already said why
}
asked += len(b)
if run.texts[i] == "" {
// The same silence the render phase carried: a paid classify batch that returned NOTHING left every
// one of its terms on the draft heuristic type — the mistyping this phase exists to remove — and the
// only trace was that `bad` stayed 0, which reads as a clean pass.
r.Log.WarnContext(ctx, "terminology classify: a paid batch came back with an EMPTY completion; its terms keep the draft heuristic type and produce no gender",
"book", r.Book.BookID, "batch", i, "asked", len(b), "answered", 0)
continue
}
got, gots, st := terminology.ParseTypes(run.texts[i], candKeys(b), text.NormalizeSourceKey)
bad, badGender, noGender = bad+st.Bad, badGender+st.BadGender, noGender+st.NoGender
answered += len(got)
r.Log.InfoContext(ctx, "terminology classify batch", "book", r.Book.BookID,
"batch", i, "asked", len(b), "answered", len(got), "gendered", len(gots),
"bad_lines", st.Bad, "bad_gender", st.BadGender, "no_gender_column", st.NoGender)
for k, v := range got {
types[k] = v
}
for k, v := range gots {
genders[k] = v
}
}
if bad > 0 {
r.Log.WarnContext(ctx, "terminology classify: some reply lines were off-vocabulary or malformed; those terms keep their draft type",
"book", r.Book.BookID, "bad_lines", bad)
}
// The gender column has its own warning because it has its own failure: a model can classify every term
// correctly and answer the gender question in words nobody asked for, or stop answering it at all — and
// row 210's whole subject is an axis that was dead while every counter beside it read clean.
if badGender > 0 || noGender > 0 {
r.Log.WarnContext(ctx, "terminology classify: the GENDER column did not land on every line — those terms carry no gender datum, and a bank with no gender renders no gender directive (backlog row 210)",
"book", r.Book.BookID, "off_vocabulary", badGender, "column_absent", noGender,
"vocabulary", strings.Join(terminology.TypeNames(terminology.Genders), "|"))
}
run.asked, run.answered = asked, answered
return types, genders, run, nil
}
// classifierMessages renders ONE batch into the classifier's wire messages: the pair's authored classifier
// prompt (system) and the candidate block as the user turn, with no canon anchor. Engine-neutral, like the
// terminologist block — the class DEFINITIONS live in the pair's prompt, so a new pair needs no Go edit.
func (r *Runner) classifierMessages(batch []terminology.Candidate) ([]llm.Message, error) {
return MessagesWithInjection(r.classifierTemplate,
RenderVars{Book: r.Book, Text: terminology.RenderBatch(batch)}, "")
}
// applyTypes stamps the classifier's corrected types onto the candidates in place and returns how many it
// actually changed. A term the classifier did not answer keeps its draft type.
func applyTypes(cands []terminology.Candidate, classified map[string]string) int {
n := 0
for i := range cands {
if t, ok := classified[cands[i].Key]; ok && t != "" && t != cands[i].Type {
cands[i].Type = t
n++
}
}
return n
}
// candKeys is the batch's candidate keys, in order — what the reply parsers screen a reply against.
func candKeys(cands []terminology.Candidate) []string {
keys := make([]string, len(cands))
for i, c := range cands {
keys[i] = c.Key
}
return keys
}
// labelRows pairs each candidate that received a consolidated rendering with its (corrected) type, for the
// $0 label screen.
func labelRows(cands []terminology.Candidate, consolidated map[string]string) []terminology.LabelRow {
var rows []terminology.LabelRow
for _, c := range cands {
if dst := consolidated[c.Key]; dst != "" {
rows = append(rows, terminology.LabelRow{Src: c.Src, Type: c.Type, Dst: dst})
}
}
return rows
}
// bankCallBudget resolves the model and max_tokens of a bank-role call (terminologist OR classifier) — ONE
// definition, so the checkpoint probe and the call itself can never address different request hashes (the
// repair precedent).
func (r *Runner) bankCallBudget(model string, msgs []llm.Message) (string, int) {
est := 0
for _, m := range msgs {
est += EstimateTokens(m.Content)
}
// The reply is one short line per term, so it is a FRACTION of the input, not a multiple of it: sizing
// it by MaxOutputRatio (the translation ratio) would reserve — and on a ceiling, deny — many times what
// the call can possibly emit.
maxTokens := est/2 + terminologyReplyFloor
if maxTokens < r.Pipeline.Defaults.MinMaxTokens {
maxTokens = r.Pipeline.Defaults.MinMaxTokens
}
return model, r.applyModelFloor(maxTokens, model)
}
// terminologyReplyFloor is the headroom one batch's reply needs beyond the proportional estimate.
const terminologyReplyFloor = 256
// bankCallEstimateUSD projects ONE call's cost with the same price/estimate arithmetic the reservation uses,
// so the pre-call number and the reserved number are the same number.
func (r *Runner) bankCallEstimateUSD(st config.Stage, msgs []llm.Message) float64 {
_, maxTokens := r.bankCallBudget(st.Model, msgs)
// The buffer is read off the SAME stage the call will use rather than passed as a literal, so this line
// cannot drift from the attempt's. ⚠ It is structurally 0 on every path today and that is NOT because of
// the effort: AdditiveReasoningTokens ignores its effort argument entirely (D39.26 добор B) and returns 0
// whenever the declared buffer is 0, which InternalCall pins. The gate's additive-provider refusal
// (config.LoadPipeline) is what makes that safe rather than blind.
return r.callEstimateUSD(st, st.Model, msgs, maxTokens)
}
// bankCheckpointExists reports whether THIS batch was already paid for in an earlier run, on the role's own
// request-hash axis — the SAME identity runBankAttempt will address (attemptRequest), never a hand-rebuilt
// copy of it: this probe gates the role sub-budget, so an identity that drifts from the attempt's turns the
// gate off (see attemptRequest).
//
// ⛔ A BURNED CHECKPOINT IS NOT A PAID BATCH. One that records money and no result cannot be replayed,
// so runAttempt walks past it and buys the batch again — and a probe answering «already paid» would let
// that purchase escape the role sub-budget entirely, which is the one thing this gate exists to bound.
// The key can only hold such a row because cut calls are now settled there; before that, «a checkpoint
// exists» and «this batch is done» were the same statement.
func (r *Runner) bankCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
_, maxTokens := r.bankCallBudget(st.Model, msgs)
return r.paidAfterBurns(st, st.Model, snapID, ch, 0, maxTokens, msgs)
}
// runBankAttempt walks ONE bank-role batch up the attempt ladder — the SAME walk a chunk stage takes
// (attemptladder.go), on the same money path: reserve → call → settle+checkpoint, with every already-paid
// rung replayed for free.
//
// ⛔ THE LADDER IS THE POINT, and its absence here was the defect. This function used to make exactly one
// call and hand back its TEXT, dropping the classification runAttempt had already computed — so a batch the
// engine itself knew was truncated or empty was paid for, discarded, and counted as terms the role declined
// to render. Reaching the ladder is not a new mechanism; it is reading a verdict that was already there.
//
// isFinal=false: the reply is a term table, not shipping prose, so the output sanitizer must not judge it;
// the intrinsic classifier still runs and is a real guard (a refusal reply would otherwise be parsed as
// terminology).
// ⛔ THE PHASE'S POLICY ARRIVES AS ONE OBJECT, and that is deliberate rather than tidy. The re-ask COUNT
// and the money that bounds it are two halves of one decision, and holding them as two arguments made
// «no budget ⇒ no re-ask» an invariant kept by a comment: a later caller passing (nil, 1) would get an
// admission hook of nil, every rung admitted with no paid probe and no ceiling, and a bank batch doubling
// its way outside the phase budget in silence — the very hole §4.2 exists to close. As one object the
// pairing cannot come apart: a nil budget IS a single shot.
//
// ⚠ A nil budget is therefore a real caller, not a defensive branch: the reprobe rig measures what ONE
// classifier call comes back with, and its published numbers («4 of 5 runs reach 6/6 at low») are about
// single calls. Give it the pass's retry policy and it silently starts re-asking a truncated reply while
// still reporting per-call figures — a rig measuring one object and naming another.
func (r *Runner) runBankAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk,
job *store.Job, msgs []llm.Message, budget *roleBudget) (ladderRun, error) {
_, maxTokens := r.bankCallBudget(st.Model, msgs)
// The log axis, exactly as runStage sets it for a chunk stage: without it every bank-role line in a paid
// run reads "calling model deepseek-v4-flash" with no book, no role and no batch — unattributable in a
// multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch ordinal.
ri, _ := obs.ReqInfoFromContext(ctx)
ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, st.Role
ctx = obs.WithReqInfo(ctx, ri)
var afford func(ladderStep) bool
maxRegens := 0
if budget != nil {
afford, maxRegens = budget.admitStep, budget.regens
}
return r.walkAttemptLadder(ctx, ladderCall{
stage: st, model: st.Model, snapID: snapID, ch: ch, job: job, msgs: msgs,
baseMaxTokens: maxTokens,
maxRegens: maxRegens,
// echoRegens 0, and not because nobody configured it: the echo re-roll answers cjk_artifact, and
// that flag cannot be raised for a bank role at all — isBankRole exempts a term table from the
// source-echo share and the target-language screen, because a table of Chinese surfaces IS mostly
// source script. A budget for a remedy to an unreachable flag would be a key that reads as a
// decision and is none.
//
// ⚠ SO THE BANK HAS EXACTLY ONE REMEDY ON THIS LADDER, and the reader should not infer two from
// runStage. The other branch — answering an empty reply with LESS thinking at the same budget — is
// dead here for a second, independent reason: it needs Retries.LowerEffortOnEmpty (off in every
// shipping pipeline, pinned) AND a rung below the configured effort, and the shipping bank roles
// are configured at `low`, which is the bottom of Models.ReducedEffort's ladder. Either of those
// changing makes the branch live, which is why the admission below is written to be correct under
// a lowered effort rather than merely unreachable.
echoRegens: 0,
// NOT mandatory: the terminology pass degrades on a ceiling, and it runs between the waves with
// nothing in flight — there would be nothing to wait for even if it were allowed to.
mandatory: false,
isFinal: false,
// The phase's own sub-budget is what bounds a rung, and it is the SAME object the pre-flight
// planned the first rungs against — see roleBudget.
afford: afford,
})
}
// roleBudget is ONE bank role's ADMISSION POLICY — the phase ceiling
// (gates.terminology.budget_usd / classify_budget_usd) together with how many rungs a batch may buy under
// it — carried across the whole pass so that every purchase the pass makes is judged against one running
// number, and so that the money rule and the count rule cannot be held apart by a caller.
//
// ⛔ IT IS ONE OBJECT BECAUSE THE PRE-FLIGHT AND THE LADDER MUST BE TALKING ABOUT THE SAME MONEY. The
// pre-flight decides, before the first call, which batches fit; the ladder then buys rungs that pre-flight
// never saw. While the two had separate arithmetic the rungs were bounded by nothing but the BOOK ceiling —
// a phase told to spend at most $1.00 could double its way past it and no counter would say so.
//
// ⚠ IT NEVER GIVES AN EARMARK BACK, and the conservatism is deliberate rather than overlooked: a call is
// admitted at the reservation's own UPPER bound and usually settles far below it, so a long pass carries a
// stale-high figure. That is harmless while the budget is orders of magnitude above the pass — the shipping
// number is $1.00 against a measured $0.0297 — and it stops being harmless the day a budget is set near the
// estimate, where it would refuse a rung the phase could in fact afford. The refusal is loud
// (admitLadderStep), so that day arrives as a log line rather than as a quietly shorter bank.
type roleBudget struct {
limit float64
committed float64
// regens is how many rungs this phase may buy for ONE batch — the money rule and the count rule of the
// same policy, held together so that no caller can hold one without the other.
regens int
}
// admit books an estimate against the phase ceiling, reporting whether it fits.
func (b *roleBudget) admit(estimateUSD float64) bool {
if b.committed+estimateUSD > b.limit {
return false
}
b.committed += estimateUSD
return true
}
// admitStep is admit as the ladder asks it. The step carries its own price, from the one definition every
// gate prices a call with, so this cannot drift from what the reservation will book.
func (b *roleBudget) admitStep(step ladderStep) bool { return b.admit(step.estimateUSD) }
// bankRoleSettings resolves what a bank ROLE is called with. The classifier phase may run its OWN model
// (classification is cheaper than a render), but BOTH phases share ONE effort knob — deliberately, and on
// evidence: paired samples of the ratified 6/6 acceptance set put the classifier at 4/5 on `low` against
// 5/5 at `high`, which n=5 cannot separate, while `high` costs 2.3× per call. The render phase, by
// contrast, is FORCED to a low effort (at the vendor default it burns the whole budget thinking and
// returns an empty body). One level satisfies both, so a second key would be a split nothing measured.
func (r *Runner) bankRoleSettings(role string) (model, reasoning string) {
g := r.Pipeline.Gates.Terminology
switch role {
case roleClassifier:
return g.ClassifierModel(), g.Reasoning
case roleTerminologist:
return g.Model, g.Reasoning
}
// A future bank role (the annotator, the Ф2 judge this seam anticipates) must not silently inherit the
// terminologist's model and effort: that is the same "a default is indistinguishable from a decision"
// shape this pack exists to remove. Panic is right here — the role set is a compile-time constant of the
// engine, so reaching this is a programming error, not a config one, and it can only happen before any
// money moves.
panic("pipeline: bankRoleSettings has no settings for bank role " + role + " — add its resolution to the gate")
}
// isBankRole reports whether a role's completion is one of the engine's TERM TABLES rather than prose in
// the target language. It is the single place that question is answered, so the two verdict rules that
// depend on it — the source-echo share and the target-language screen — cannot come to disagree about
// which roles are exempt, which is exactly how the classifier ended up flagged on every healthy batch
// while the terminologist beside it was exempt (13-tech-debt-anchors §Б-105).
//
// It is keyed on the closed role set rather than sniffed from the reply, because the FORM is something the
// engine specified when it built the request; deriving it back out of the answer would let a malformed
// reply talk its way out of the screen. A future bank role joins by one line here, next to the line it
// already needs in bankRoleSettings.
func isBankRole(role string) bool {
return role == roleTerminologist || role == roleClassifier
}
// bankStage is the ONE place a bank-role call's stage is derived — for BOTH roles and for the live rig, so
// "the classifier probe measured the production shape" is structural rather than two literals kept in step
// by hand. The settings come from the gate that owns the call class (config.InternalCall).
func (r *Runner) bankStage(role string) config.Stage {
model, reasoning := r.bankRoleSettings(role)
return config.InternalCall{Name: terminologyStageName, Role: role, Model: model, Reasoning: reasoning}.Stage()
}
// bankRolePlan is one bank-level role's plan over a batch list: its cost axis (role/model), its own book-wide
// ceiling, and how it builds one batch's wire messages. The money sequence — estimate, budget-gate,
// checkpoint-or-call — is identical for the classifier and the terminologist; only the messages and the reply
// PARSING differ, and parsing is the caller's job on the returned texts.
// ⚠ NO `model` field: the model is derived from the role by bankRoleSettings, inside bankStage. It used to
// be carried here too and read only by a log line, which made the log a SECOND source of truth about what
// was billed — the exact shape this pack removed everywhere else.
type bankRolePlan struct {
role string
budgetUSD float64
messages func(batch []terminology.Candidate) ([]llm.Message, error)
}
// bankRoleRun is what one role's pass produced: each batch's reply text (in batch order, "" for a batch the
// budget cut or that failed soft), plus the cost accounting for the report.
type bankRoleRun struct {
texts []string
// ran says, PER BATCH, whether the pass actually reached it. Without it "" is ambiguous — an EMPTY
// completion the run paid for and a batch never called look the same — and the caller cannot warn
// about the first without crying wolf about the second.
//
// ⛔ IT CANNOT BE A COUNT. Admission is not a prefix: an already-paid batch costs nothing and is
// admitted whatever the budget says, so a batch the budget refuses can sit BEFORE batches that are
// free to serve. A count answers «how many from the start», which silently drops every paid batch
// behind the first unaffordable one — their replay was $0 and their result was already bought.
ran []bool
estimateUSD float64
costUSD float64
cumUSD float64
fresh bool
// planned / dropped record the CUT: how many batches the pass was built from, and how many the budget
// left unbought. `dropped > 0` is «this bank is partially consolidated», which is a fact about the
// book's terminology that nothing else in the run's output carries — and reporting a partial pass as a
// pass is the shape this whole edit exists to stop.
planned int
dropped int
// regens is how many EXTRA rungs the ladder bought across the pass, and unusable how many batches ended
// on a flagged reply anyway. They are separate from `dropped` because they answer a different question:
// `dropped` is money that was never spent, these two are money that was.
regens int
unusable int
// stepsRefused counts rungs the PHASE BUDGET turned down. Without it a bank cut short by its own
// sub-budget is indistinguishable from one the model simply could not answer — and the operator's
// action differs: raise the budget, or look at the model.
//
// ⚠ RUNGS AND BATCHES COINCIDE HERE, and only because the walk STOPS at its first refusal — one walk
// can contribute at most one. A ladder that went on trying cheaper rungs after a refusal would make
// this a count of rungs over a smaller number of batches, and the summary key would start meaning
// something else without changing its name.
stepsRefused int
// asked / answered are the PARSE side of the pass, and they are filled by the phase that owns the
// parser — runBankRoleBatches never touches them, because what counts as an answer is a property of
// the reply FORMAT, which differs per phase. One field, one writer, as everywhere else here.
asked int
answered int
}
// runBankRoleBatches runs plan over batches on the shared money path. The estimate is logged BEFORE any call;
// a budget ceiling or a soft denial stops the pass and leaves the remaining terms untouched — the run never
// aborts on this optional step. logKind names the phase (render|classify) in the logs.
func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) {
run := bankRoleRun{texts: make([]string, len(batches)), ran: make([]bool, len(batches))}
// ONE stage for the whole pass — the estimate, the checkpoint probe and the attempt all read it, so the
// three can never be sized against different knobs (the estimate is the reservation's own upper bound).
st := r.bankStage(plan.role)
msgsPer := make([][]llm.Message, len(batches))
for i, b := range batches {
m, err := plan.messages(b)
if err != nil {
return run, err
}
msgsPer[i] = m
run.estimateUSD += r.bankCallEstimateUSD(st, m)
}
r.Log.InfoContext(ctx, "terminology "+logKind+": estimate before any call",
"book", r.Book.BookID, "role", plan.role, "batches", len(batches), "model", st.Model, "reasoning", st.Reasoning,
"estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD), "budget_usd", plan.budgetUSD,
"version", terminologyVersion, "bank_data", lang.BankDataVersion())
spent, err := r.Store.RoleSpentUSD(r.Book.BookID, plan.role)
if err != nil {
return run, fmt.Errorf("pipeline: %s budget read: %w", plan.role, err)
}
job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID)
if err != nil {
return run, fmt.Errorf("pipeline: %s job: %w", plan.role, err)
}
// ⛔ THE PLAN IS CUT TO WHAT FITS **BEFORE THE FIRST CALL**, not discovered halfway through it.
//
// This pass used to print its estimate beside its budget, see that it did not fit, and start anyway —
// the very next statement after the log line was an error check on READING the budget, and no
// comparison stood between them. It then stopped mid-way on the per-batch gate below, leaving a
// half-consolidated bank and paid work whose report said nothing about being cut short. On the cold run
// that happened twice, and three batches of four were never bought.
//
// ⛔ AND IT IS A TRUNCATION, NOT A REFUSAL, deliberately. Refusing with a Refusal class would put this
// in the 1019 band, and that band promises the platform «nothing reached a provider, nothing was
// spent». This pass runs INSIDE the bank-mining stop — after a paid draft wave — so the promise would
// be false and the engine would be lying about money to the one consumer that acts on the band
// destructively. The estimate is also a deliberate UPPER bound, so «estimate > budget ⇒ refuse» would
// deny work that fits: the cold run has a counterexample of exactly that shape, a pass whose estimate
// ($0.024239) exceeded its budget ($0.02) and which completed for a fraction of it.
//
// So: decide up front, say what was cut, and run only that. Already-paid batches cost nothing and are
// admitted regardless — holding them back would save no money and lose their result.
// ⛔ ADMISSION IS PER BATCH, NOT A PREFIX. An already-paid batch costs nothing, so refusing one
// unaffordable batch must not take the paid batches BEHIND it: their replay is $0 and their result is
// already bought, and dropping them buys nothing while losing a consolidated bank. The loop used to
// `break` here and hand the consumer a prefix bound, which made the comment above («admitted
// regardless») false for every paid batch that happened to sit after a refused one.
// ⛔ ONE RUNNING NUMBER FOR THE WHOLE PASS, and it outlives this loop. The rungs the ladder buys later
// are purchases this plan never saw, so they are booked against this same object — see roleBudget.
budget := &roleBudget{limit: plan.budgetUSD, committed: spent, regens: r.Pipeline.Gates.Terminology.Regenerate}
fits, plannedUSD := 0, 0.0
admit := make([]bool, len(batches))
for i := range batches {
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
paid, perr := r.bankCheckpointExists(st, snapID, ch, msgsPer[i])
if perr != nil {
return run, perr
}
if paid {
admit[i] = true
fits++
continue
}
// The bound is what the call would COST (the reservation's own upper bound), so the cut lands on
// the conservative side and the config number is the bound it looks like.
want := r.bankCallEstimateUSD(st, msgsPer[i])
if !budget.admit(want) {
continue // this one cannot be afforded; the ones after it may still be free
}
admit[i] = true
plannedUSD += want
fits++
}
if fits < len(batches) {
// Said BEFORE the first call and naming every term of the decision, because the operator's action
// (raise the budget and re-run, or accept a partial bank) depends on all of them.
r.Log.WarnContext(ctx, "terminology "+logKind+": the plan does NOT fit the budget and is CUT TO WHAT DOES — the remaining terms are left unchanged, and this is decided BEFORE the first call rather than discovered part-way through",
"book", r.Book.BookID, "role", plan.role, "batches_planned", len(batches), "batches_running", fits,
"batches_dropped", len(batches)-fits, "spent_usd", fmt.Sprintf("%.6f", spent),
"this_pass_usd", fmt.Sprintf("%.6f", plannedUSD), "budget_usd", plan.budgetUSD,
"estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD))
}
run.planned, run.dropped = len(batches), len(batches)-fits
for i := range batches {
if !admit[i] {
continue
}
// The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the
// batch ordinal is the chunk index, so two batches can never collide on one checkpoint.
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
lr, aerr := r.runBankAttempt(ctx, st, snapID, ch, job, msgsPer[i], budget)
// ⛔ THE MONEY IS TAKEN BEFORE THE VERDICT, the order this file already keeps for the classifier
// pass and runStage for its hop: a walk that met a broken provider on its second rung still paid
// for its first, and reading these fields behind the error check reports a pass that spent as one
// that spent nothing.
run.costUSD += lr.runCost
run.cumUSD += lr.cumCost
run.fresh = run.fresh || lr.anyFresh
run.regens += lr.regens
if lr.refusedStep != nil {
run.stepsRefused++
}
if aerr != nil {
// A ceiling denial must not abort the book: this step is optional and the draft wave is already
// paid for. Degrade to "no change" exactly as the repair sub-step degrades.
if errors.Is(aerr, errReserveCeiling) {
// ⛔ AND THE RUNG ALREADY PAID FOR IS KEPT, which the ladder made possible and the single
// call before it could not. A ceiling refusing rung 0 buys nothing, so «leave the batch
// untouched» was the whole truth; a ceiling refusing rung 1 arrives with rung 0 BOUGHT,
// classified and holding whatever lines the model managed — on the cold run that shape was
// four terms of twenty-two. Dropping it here would settle the money and discard the answer,
// and the batch would read as one nobody called.
if lr.judged > 0 {
run.texts[i], run.ran[i] = lr.last.text, true
run.unusable++
}
r.Log.WarnContext(ctx, "terminology "+logKind+": a USD ceiling refused this call; the terms of every LATER batch are left unchanged, and whatever this batch had already bought is kept",
"book", r.Book.BookID, "role", plan.role, "batch", i,
"rungs_paid_for_this_batch", lr.judged, "kept_reply", lr.judged > 0)
break
}
return run, aerr
}
// A batch whose LAST rung still came back unusable is not the same thing as one the parser could
// make nothing of: the engine itself said this reply is truncated or empty, and the ladder had
// nothing left to try. Counted so the summary can tell the two apart (the parse counters below
// cannot: an empty reply and a reply full of unparsable lines both answer zero terms).
if !lr.last.cls.ok() {
run.unusable++
r.Log.WarnContext(ctx, "terminology "+logKind+": a batch is STILL unusable after every re-ask this phase could buy; its terms are left unchanged",
"book", r.Book.BookID, "role", plan.role, "batch", i, "terms", len(batches[i]),
"reason", string(lr.last.cls.Reason), "attempts", lr.attempts, "regenerations", lr.regens)
}
// The terminal rung's text is what the caller parses, INCLUDING an unusable one: a table cut at
// the ceiling still holds the lines it managed to emit, and those terms are better banked than
// thrown away. That is the behaviour this path always had — the ladder only adds the chance of a
// whole reply before it — and `unusable` above is what stops the salvage from reading as success.
run.texts[i] = lr.last.text
run.ran[i] = true
}
return run, nil
}
// consolidatedRows turns this run's consolidation into the rows those renderings would become, which is
// what ConsolidationKeyConflicts reads: the candidate's surface, its identity-cluster aliases and the
// window a mined row carries (first chapter of appearance, no end). An unanswered candidate carries no
// rendering, becomes no row, and contradicts nothing.
//
// Both extra fields are load-bearing for the check: the window decides whether an existing row is a
// replacement rather than a disagreement, and the aliases are the other surfaces a landed row fires on.
// Built for every answered candidate, not only those the emission keeps — a dropped candidate still
// reaches the review sheet (not the signature map, a different document), so its disagreement is read.
func consolidatedRows(cands []terminology.Candidate, consolidated map[string]string) []store.GlossaryEntry {
if len(consolidated) == 0 {
return nil
}
out := make([]store.GlossaryEntry, 0, len(consolidated))
for _, c := range cands {
dst := consolidated[c.Key]
if dst == "" {
continue
}
// c.Key, not c.Src, and it is the landing source on BOTH emission paths — the check compares the
// store's raw UNIQUE key, so it must be given the string the row will actually hold. A banknote
// candidate carries the surface as the draft wrote it and lands as the key
// (mining.reverseSectionTerms). A mined candidate lands with the MINER's surface, which is a
// substring of already-normalized chunk text and therefore equals its own key — that equality is
// text.NormalizeSourceKey's idempotence, pinned by TestNormalizeSourceKeyIsIdempotent, because
// without it this line would compare a landing row against a source it does not have.
e := store.GlossaryEntry{Src: c.Key, Dst: dst, SinceCh: c.SinceCh}
for _, a := range c.Aliases {
e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: a})
}
out = append(out, e)
}
return out
}
// attachConsolidatedDst stamps the terminologist's renderings onto the mined terms, which is what selects
// the emission MODE in miner.DeltaYAML (§C2-7). Terms the role did not answer are left untouched.
func attachConsolidatedDst(mined []miner.Term, consolidated map[string]string) []miner.Term {
if len(consolidated) == 0 {
return mined
}
out := make([]miner.Term, len(mined))
copy(out, mined)
for i := range out {
if dst := consolidated[text.NormalizeSourceKey(out[i].Src)]; dst != "" {
out[i].Dst = dst
}
}
return out
}
// attachClassifiedGender stamps the §2 classifier's gender datum onto the mined terms — the input backlog
// row 210 exists for, and the ONLY automatic producer of the field. It never changes which terms are in the
// delta and never overwrites with an empty value: a term the classifier answered `none` for, or did not
// answer at all, carries no gender, exactly as before.
//
// ⚠ THE SEED STILL WINS, and not by an ordering trick here. These rows go into the auto-bank, which the
// loader keeps OUT of any key a signed row already holds (loadAutoBank, mining.go): a term the owner
// entered by hand — with or without a gender — is the row the bank uses, and the engine's proposal for the
// same key is dropped, named, in the log. So «the model decided my character is male» is not a state the
// owner can be surprised by on a term they signed.
func attachClassifiedGender(mined []miner.Term, gendered map[string]string) []miner.Term {
if len(gendered) == 0 {
return mined
}
out := make([]miner.Term, len(mined))
copy(out, mined)
for i := range out {
if g := gendered[text.NormalizeSourceKey(out[i].Src)]; g != "" {
out[i].Gender = g
}
}
return out
}
// attachClassifiedType stamps the §2 classifier's corrected types onto the mined terms, so the BANKED type
// is the re-derived one, not the draft heuristic. It never changes which terms are in the delta — emission
// eligibility was already decided upstream by the miner — only the type recorded on the rows already there.
func attachClassifiedType(mined []miner.Term, classified map[string]string) []miner.Term {
if len(classified) == 0 {
return mined
}
out := make([]miner.Term, len(mined))
copy(out, mined)
for i := range out {
if t := classified[text.NormalizeSourceKey(out[i].Src)]; t != "" {
out[i].Type = t
}
}
return out
}
// dropBankSettled removes, BEFORE any money moves, the candidates the bank has already settled: the role
// and the classifier were being paid for surfaces whose renderings the emission then threw away.
//
// ⛔ WHY THE TEST IS TWO CONDITIONS AND NOT ONE. "The bank already holds this surface" is NOT sufficient,
// and a filter written that way would take back with one hand what the sheet is for. The role is asked
// about banked surfaces ON PURPOSE: its answer is what CanonConflicts and ConsolidationKeyConflicts read,
// and those are the marks that tell the owner, on the sheet he signs by, that the book already calls this
// term something else. Measured on the corpus (13 stop tables, 1365 candidates, against the owner's own
// signed seed): 439 candidates sit on a surface the seed holds, and in 366 of them at least one draft
// disagreed with the seed's rendering. A one-condition filter would have bought a saving by silencing 366
// disputes — which is the defect the neighbouring row of this same pack exists to close.
//
// So a candidate is dropped only when BOTH hold:
//
// - the bank holds its surface as a SEED SURFACE, decided by the emission's own predicate
// (unsignedEngineSurfaces → membank.IsEngineUnsigned) rather than by a second rule of this function's
// own, and it is banknote-ONLY, which is the exact population reverseSectionTerms drops. That pairing
// is what makes the drop output-equivalent FOR THE DELTA: the term was never going to reach it;
// - every rendering the drafts actually proposed for it equals the bank's, under the same target-form
// fold the vote is counted with. One disagreement and it is PAID for, because that disagreement is the
// finding. A candidate with no observed rendering at all is paid for too — banknote candidates always
// carry one, so this cannot fire, but "no evidence" must never read as "evidence of agreement".
//
// ⛔ IT IS NOT OUTPUT-EQUIVALENT FOR THE SIGNING SHEET, and saying otherwise would be the same class of
// false description this pack exists to remove. The second condition tests what the DRAFTS proposed, not
// what the ROLE would have answered. Where the drafts agree with the bank and the role would have
// disagreed, the candidate is dropped and the disagreement is never produced, so the sheet loses a
// bankHolds mark it would otherwise carry — the very thing the neighbouring row of this pack widens.
// The population is bounded and measured: on the corpus (13 stop tables, 1365 candidates, against the
// owner's signed seed) 439 candidates sit on a surface the seed holds and 56 of them have every observed
// draft agreeing, so 56 of 1365 is the UPPER bound of what this can silence — the subset of those whose
// role answer would in fact have differed, which is smaller and not measurable without paying for it. The
// remaining 17 of that 439 carry no observed draft at all and are paid for by the rule above.
//
// ⛔ THE RESUME TRAP, AND WHY THE CURE IS "ALWAYS", NOT "SOMETIMES". Batches are packed greedily by rune
// budget, so removing any candidate changes the composition of at least one surviving batch, and a
// bank-role batch is addressed by the HASH of its request (bankCheckpointExists): a run whose composition
// differs from the run that paid finds no checkpoint and buys the pass again. The first cure written here
// was «stand down for a book that has already paid», and running it showed the cure was the bug: run one
// filtered and paid under the FILTERED composition, run two then stood down, rebuilt the FULL composition
// and re-bought everything. A rule that decides differently on the second run is not a resume rule.
//
// So the filter is unconditional, which makes the composition a pure function of the candidates and the
// bank and therefore identical on every resume — checkpoints replay for $0, forever, which is the property
// that was actually wanted. The one book this cannot help is one consolidated BEFORE the filter existed:
// its checkpoints carry the old composition and cannot be found again. That is a single re-consolidation,
// bounded by gates.terminology.budget_usd, and it is announced rather than discovered — see the warning
// below. Making even that unnecessary would take a checkpoint identity that survives a change of batch
// composition, which is a change to the money path's own addressing and belongs in its own decision.
func (r *Runner) dropBankSettled(ctx context.Context, cands []terminology.Candidate) (keptIdx []int, dropped int) {
all := func() []int {
out := make([]int, len(cands))
for i := range cands {
out[i] = i
}
return out
}
settled := bankSettledSurfaces(unsignedEngineSurfaces(r.glossaryRows()))
if len(settled) == 0 {
return all(), 0
}
var names []string
for i, c := range cands {
if bankSettles(settled, c) {
dropped++
if len(names) < 20 {
names = append(names, c.Src)
}
continue
}
keptIdx = append(keptIdx, i)
}
if dropped == 0 {
return all(), 0
}
// The saving is a NUMBER with its denominator, not a claim: "12 skipped" is unreadable without "of
// 300", and the operator is the one who decides whether that is worth a look at his seed.
r.Log.InfoContext(ctx, "terminology: candidates the bank has already settled are NOT sent to the paid role — the bank holds the surface and every draft proposed the rendering it holds, so the emission would have dropped the answer",
"book", r.Book.BookID, "skipped", dropped, "of_candidates", len(cands), "still_paid", len(keptIdx),
"terms", strings.Join(names, ", "), "terms_listed", len(names))
return keptIdx, dropped
}
// pickCandidates rebuilds the paid subset from the CURRENT candidates. Called again after the passes that
// mutate them, so the request the role sees and the sheet the owner reads are built from one set of rows.
func pickCandidates(cands []terminology.Candidate, idx []int) []terminology.Candidate {
out := make([]terminology.Candidate, 0, len(idx))
for _, i := range idx {
out = append(out, cands[i])
}
return out
}
// bankSettledSurfaces indexes the seed surfaces by their firing key, aliases included: a banknote proposal
// can name a term by an alias, and the emission's own guard reads aliases too.
func bankSettledSurfaces(seed []store.GlossaryEntry) map[string]store.GlossaryEntry {
out := make(map[string]store.GlossaryEntry, len(seed))
for _, e := range seed {
if strings.TrimSpace(e.Dst) == "" {
continue // nothing to agree with
}
out[text.NormalizeSourceKey(e.Src)] = e
for _, a := range e.Aliases {
out[text.NormalizeSourceKey(a.Alias)] = e
}
}
return out
}
// bankSettles reports whether one candidate is settled by the bank — see dropBankSettled for why both
// halves are required.
func bankSettles(settled map[string]store.GlossaryEntry, c terminology.Candidate) bool {
if c.Origin != terminology.OriginBanknote || len(c.Variants) == 0 {
return false
}
e, held := settled[c.Key]
if !held {
return false
}
want := text.NormalizeTargetForm(e.Dst)
for _, v := range c.Variants {
if text.NormalizeTargetForm(v.Dst) != want {
return false // the disagreement IS the finding: pay, and let it reach the sheet
}
}
return true
}