1016 lines
54 KiB
Go
1016 lines
54 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"textmachine/backend/internal/chunk"
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/miner"
|
||
"textmachine/backend/internal/seed"
|
||
"textmachine/backend/internal/store"
|
||
"textmachine/backend/internal/terminology"
|
||
"textmachine/backend/internal/text"
|
||
)
|
||
|
||
// mining.go: bank-mining stop boundary (WS3 wired live, R1; stop semantics — the D39.144 flag model).
|
||
// Between the drafts done and the edit wave, the miner scores WHICH-candidates over the draft wave
|
||
// source (the detector is offline — it reads the SOURCE, not the drafts; drafts only mark that a
|
||
// chapter was reached) against the general-zh contrast, emits an alias-clustered seed-delta (default B,
|
||
// WHICH-only — the dst is the owner's to attach via the banknote at sign time), and on a NON-EMPTY
|
||
// delta writes the owner SIGNATURE MAP.
|
||
//
|
||
// The stop itself is the OWNER'S FLAG (`--verify-bank`), and the flag is a tripwire on NOVELTY, not a
|
||
// gate on decisions: with the flag raised the run stops before the edit wave only when the map holds a
|
||
// cluster no earlier stop has presented (the presented memory, store v16). The owner reviews the map,
|
||
// decides as much or as little as they care to through `tmctl bank-apply` — one act over the whole
|
||
// bank, one term, or nothing at all — and resumes; the run then continues FULLY, with every undecided
|
||
// row riding to the editor as an unsigned auto-bank surface, indistinguishable there from a signed one since D39.104 п.2. Deciding every term is
|
||
// NOT what clears the stop, and leaving terms undecided never re-arms it; only new terms do. Mining is
|
||
// OFF (auto-continue) unless a langpack AND a contrast artifact are both configured — so every $0 test /
|
||
// the golden fixture (no contrast) auto-continues.
|
||
|
||
// signatureMapPath is where the bank-mining stop writes the mined-delta YAML for owner sign — beside the project DB, so it
|
||
// travels with the book state (never in git, like the DB). Deterministic (no time/rand).
|
||
func (r *Runner) signatureMapPath() string {
|
||
return signatureMapPath(r.Book.ProjectDB)
|
||
}
|
||
|
||
// signatureMapPath is the same path off a bare project-DB path, for the callers that have a config and
|
||
// no Runner (bank-apply builds none — see bankdecisions.go).
|
||
func signatureMapPath(projectDB string) string {
|
||
return projectDB + ".mined-signature.yaml"
|
||
}
|
||
|
||
// runBankMiningStop executes the bank-mining stop. Returns stopped=true (with the signature map written,
|
||
// the presented memory updated and r.lastMinedCount set) when `--verify-bank` is raised, an edit wave
|
||
// exists to stop before, AND the delta holds a cluster no earlier stop has presented; stopped=false —
|
||
// with the auto wire run on a non-empty delta — in every other case: mining unconfigured, delta empty,
|
||
// flag down, draft-only, or nothing new to the memory. $0 to providers (the detector is deterministic +
|
||
// offline).
|
||
func (r *Runner) runBankMiningStop(ctx context.Context, chunks []chunk.Chunk, draftSnapshot string, editWave bool) (stopped bool, err error) {
|
||
if r.pack == nil || r.Pipeline.Mining.ContrastPath == "" {
|
||
// FAIL LOUD when the operator ASKED to verify the bank and the book cannot mine one. Silently
|
||
// continuing would hand back a clean exit code for a verification that never happened — the worst
|
||
// possible answer to «остановись и покажи мне банк».
|
||
if r.VerifyBank {
|
||
return false, fmt.Errorf("pipeline: --verify-bank was passed but this book cannot mine a bank: it needs BOTH a langpack (book.yaml `langpack_root`, currently %s) and a mining contrast artifact (pipeline.yaml `mining.contrast_path`, currently %q); without them there is no candidate list to verify",
|
||
packStateLabel(r.pack), r.Pipeline.Mining.ContrastPath)
|
||
}
|
||
return false, nil // mining not configured → auto-continue to the edit wave
|
||
}
|
||
f, err := os.Open(r.Pipeline.Mining.ContrastPath)
|
||
if err != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop open mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err)
|
||
}
|
||
defer f.Close()
|
||
contrast, err := miner.LoadContrast(f)
|
||
if err != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop load mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err)
|
||
}
|
||
|
||
// The miner works over the memnorm-normalized SOURCE of every chunk (the candidate space matches the
|
||
// glossary/GT space). Deterministic; the annotation/blurb rule is handled inside miner.MineBank's filters.
|
||
minerChunks := make([]miner.Chunk, len(chunks))
|
||
for i, ch := range chunks {
|
||
minerChunks[i] = miner.Chunk{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, NSource: text.NormalizeSourceKey(ch.Text)}
|
||
}
|
||
seed, err := r.Store.GlossaryForBook(r.Book.BookID)
|
||
if err != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop read glossary for mining: %w", err)
|
||
}
|
||
// The owner's reject list excludes declined terms from the emission (R1-FL-B): a term the owner reviewed
|
||
// and rejected is dropped from the delta exactly like a seed surface — it never enters a map again. The
|
||
// exclusion is about the DELTA's content, not about the stop: whether the run stops is the presented
|
||
// memory's question alone (D39.144 — deciding terms is a right, not what the stop waits for).
|
||
rejects, err := r.loadMinedRejects()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
// Risk 2 (self-exclusion): the engine's OWN unsigned rows must not read as seed surfaces, or the auto
|
||
// mode silently switches the stop off after its first run — see unsignedEngineSurfaces.
|
||
mined, emission := miner.MineBankStats(minerChunks, contrast, unsignedEngineSurfaces(seed), rejects, miner.FrozenConfig(), r.pack)
|
||
|
||
// The WHAT side: the banknote proposals the draft waves collected, over every sampling of the book that
|
||
// exists. They arrive as EVIDENCE — nothing proposed enters the bank without a signature. A read failure
|
||
// degrades to the WHICH-only map rather than blocking the stop: the map is what the owner needs, the
|
||
// dst is a bonus.
|
||
// The parse rule is LOGGED with the run, which is the whole contract that lets it stay out of the
|
||
// snapshot: it cannot change a paid byte, but it decides which candidate lines became evidence, so a
|
||
// signature map has to be attributable to the rule that produced it. Until the fix-pack the constant was
|
||
// declared "logged with the run" and read by nothing — the tag was a comment, not a mechanism, and two
|
||
// artifacts produced by different rules were indistinguishable.
|
||
observed, offLanguage, oerr := r.bankObservedForBook()
|
||
if oerr != nil {
|
||
r.Log.WarnContext(ctx, "bank-mining: could not read the banknote proposals; the signature map falls back to WHICH-only (bare terms)", "err", oerr)
|
||
}
|
||
r.Log.InfoContext(ctx, "bank-mining: draft-side proposals folded", "book", r.Book.BookID,
|
||
"surfaces", len(observed), "parse_version", bankParseVersion, "slice_version", bankSliceVersion)
|
||
if offLanguage > 0 {
|
||
r.Log.WarnContext(ctx, "bank-mining: draft-side proposals were written in another script and are NOT offered for signature",
|
||
"book", r.Book.BookID, "dropped", offLanguage, "target_script", r.Pipeline.Gates.Terminology.TargetScript)
|
||
}
|
||
|
||
// The TERMINOLOGIST (pack-20, D39.42): merge both channels, gather each candidate's source contexts,
|
||
// rank the renderings the drafts produced (§C2-3), and — when the gate is on — consolidate the whole
|
||
// bank in a handful of batched calls. With the gate off this is $0 assembly, but NOT a no-op for the
|
||
// artifact: since the fix-pack the delta's dst comes from these ranked, target-form-folded candidates
|
||
// rather than from the raw draft-side order, so a term whose drafts disagreed can carry a different
|
||
// proposal than it did before — see proposalsFromCandidates. That is bank CONTENT, so it moves
|
||
// memory_version and the edit-wave snapshot with it (bank-only → $0 re-pin for every unit the term does
|
||
// not occur in). The draft wave is untouched: these rows are Source:"mined" and base-excluded.
|
||
tchunks := make([]terminology.Chunk, len(minerChunks))
|
||
for i, c := range minerChunks {
|
||
tchunks[i] = terminology.Chunk{Chapter: c.Chapter, ChunkIdx: c.ChunkIdx, NSource: c.NSource}
|
||
}
|
||
cands := r.buildBankCandidates(mined, observed, tchunks)
|
||
// The REVERSE section: surfaces only the draft side named. The miner cannot see them by construction —
|
||
// a cluster touching a seed surface is suppressed as an alias-of-existing — so this is where the
|
||
// measured 3% channel overlap actually leaks. They join the delta only when the role is on (an
|
||
// unconsolidated reverse row would be a bare surface with no evidence), and only when the SOURCE
|
||
// actually contains them: a term a draft invented is not a bank term.
|
||
if r.Pipeline.Gates.Terminology.Enabled {
|
||
// The SAME surface filter as the miner's (risk 2): the engine's own unsigned rows must not read as
|
||
// "already banked" here either, or the reverse section would empty itself after its first run — and,
|
||
// worse, the delta would differ between run 1 and run 2, moving the edit-wave snapshot and re-billing
|
||
// a wave over nothing. Determinism across runs is what makes the auto mode resumable at all.
|
||
reverse, eligible := reverseSectionTerms(cands, unsignedEngineSurfaces(seed), rejects)
|
||
if eligible > len(reverse) {
|
||
r.Log.WarnContext(ctx, "bank-mining: the reverse section is capped like the miner's own emission; the tail is NOT in this signature map and re-proposes on the next run once these are signed or declined",
|
||
"book", r.Book.BookID, "eligible", eligible, "kept", len(reverse))
|
||
}
|
||
mined = append(mined, reverse...)
|
||
sort.Slice(mined, func(i, j int) bool { return mined[i].Src < mined[j].Src })
|
||
}
|
||
r.lastMinedCount = len(mined)
|
||
if len(mined) == 0 {
|
||
// G10 (polygon package seven): an empty delta must never READ as "this book is clean" when it means
|
||
// "the alphabet was full and the emission layer cut all of it". The funnel is printed with the
|
||
// verdict so the two are distinguishable at a glance, and `--verify-bank` — the mode whose whole
|
||
// promise is «остановись и покажи мне банк» — says it out loud rather than at info level.
|
||
msg := "bank-mining: empty delta, auto-continuing to the edit wave"
|
||
args := append([]any{"book", r.Book.BookID}, emissionArgs(emission, cands)...)
|
||
if r.VerifyBank && emission.Ranked > 0 {
|
||
r.Log.WarnContext(ctx, msg+": the detector RANKED candidates and the emission layer cut every one of them — this is not the same as a book with no new terms", args...)
|
||
} else {
|
||
r.Log.InfoContext(ctx, msg, args...)
|
||
}
|
||
return false, nil
|
||
}
|
||
consolidated, classified, gendered, tres, err := r.runTerminologist(ctx, draftSnapshot, cands)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
r.lastTerminology = tres
|
||
mined = attachClassifiedType(mined, classified)
|
||
mined = attachClassifiedGender(mined, gendered)
|
||
mined = attachConsolidatedDst(mined, consolidated)
|
||
|
||
// Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave.
|
||
proposals := proposalsFromCandidates(cands)
|
||
withDst := 0
|
||
for _, m := range mined {
|
||
if m.Dst != "" || len(proposals[text.NormalizeSourceKey(m.Src)]) > 0 {
|
||
withDst++
|
||
}
|
||
}
|
||
// The map rides the seam envelope (seed.SignatureMap) and is REPLACED atomically: the other side
|
||
// reads this file, and the platform's operating rule for non-atomic sidecars is «на живом прогоне
|
||
// брать нельзя» — a rule the signing surface cannot live under. Written before the stop decision
|
||
// below, because the memory write depends on the map being on disk first (never the reverse: a
|
||
// remembered surface whose map was never written is a silent loss).
|
||
mapBytes, err := membank.RenderSignatureMap(miner.DeltaFile(mined, proposals))
|
||
if err != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop render signature map: %w", err)
|
||
}
|
||
if err := writeFileAtomic(r.signatureMapPath(), mapBytes); err != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop write signature map %s: %w", r.signatureMapPath(), err)
|
||
}
|
||
// The RICH table (D39.36's «стоп с таблицей»: src · dst · frequency · variant spread · evidence). It is
|
||
// written as a sidecar on EVERY run, signed or not, because it is also the auto mode's record of what
|
||
// the book decided on its own — and it is capped on stdout, never in the file (emitRankCap is 200).
|
||
rows := bankStopRows(cands, consolidated, tres)
|
||
if werr := os.WriteFile(r.bankStopTablePath(), []byte(renderBankStopTable(rows)), 0o644); werr != nil {
|
||
r.Log.WarnContext(ctx, "bank-mining: could not write the stop table sidecar (the signature map is unaffected)", "err", werr)
|
||
}
|
||
|
||
// THE FLAG (D39.42 п.5 · D39.144, owner's words 26.08: «стоп на банке памяти — это просто флажок»).
|
||
// Two decisions, and they are DIFFERENT decisions:
|
||
// - STOP OR NOT is the flag's question alone. Raised, it stops before the edit wave when the map
|
||
// holds a cluster no earlier stop has presented (the presented memory, store v16) — and only
|
||
// then: a flag that re-stopped on everything still undecided would turn «остановиться один раз»
|
||
// into a march over per-term decisions the product does not have. A DRAFT-ONLY pipeline never
|
||
// stops either way: there is no edit wave for the stop to sit before, and stopping there would
|
||
// discard the assembled BookResult of an already-paid draft wave (S16).
|
||
// - CARRY THE UNSIGNED ROWS FORWARD is every non-stopping run's duty, whatever the flag's
|
||
// position: the model's promise «неподписанные строки едут редактору с пометкой» has to hold
|
||
// exactly in the raised-flag runs that do not stop, or raising the flag would quietly switch
|
||
// the auto wire off for the life of the book.
|
||
stopping := false
|
||
if r.VerifyBank && editWave {
|
||
presented, merr := r.Store.StopPresentedSurfaces(r.Book.BookID)
|
||
if merr != nil {
|
||
return false, fmt.Errorf("pipeline: the bank-mining stop read the presented memory: %w", merr)
|
||
}
|
||
stopping = hasUnpresentedCluster(mined, presented)
|
||
if !stopping {
|
||
// The state that could not exist before the memory: a NON-EMPTY delta under a raised flag,
|
||
// with no stop. Said out loud, because to an operator who raised the flag and got no pause,
|
||
// silence is indistinguishable from mining being unconfigured.
|
||
r.Log.WarnContext(ctx, "bank-mining: --verify-bank is raised and the delta is non-empty, but every cluster in it was already presented by an earlier stop — continuing (the map is rewritten and stays decidable via `tmctl bank-apply` at any time)",
|
||
append([]any{"book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath()},
|
||
emissionArgs(emission, cands)...)...)
|
||
}
|
||
} else if r.VerifyBank {
|
||
r.Log.WarnContext(ctx, "bank-mining: --verify-bank has nothing to stop before in a draft-only pipeline (no edit wave); the signature map and table are written and the run continues",
|
||
"book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath())
|
||
}
|
||
if !stopping {
|
||
// THE AUTO WIRE (D39.42 п.3). The unsigned rows are persisted and folded back into the bank right
|
||
// here, before the edit-wave snapshot is computed — so this run's editor actually receives them, in
|
||
// the same law block as the signed rows since row 134, and the next run's drafts do too. Writing the file and
|
||
// re-seeding through the ordinary seedGlossary path (rather than a second, private write) is what
|
||
// keeps ONE definition of what the bank is: every guard the seed path owns — the collision checks,
|
||
// the reject filter, the fail-louds — applies to the engine's rows exactly as to the owner's.
|
||
if err := r.writeAutoBank(ctx, mined, proposals, ownerHandled(unsignedEngineSurfaces(seed), rejects)); err != nil {
|
||
return false, err
|
||
}
|
||
if err := r.seedGlossary(ctx); err != nil {
|
||
return false, fmt.Errorf("pipeline: re-seed the bank with the auto rows: %w", err)
|
||
}
|
||
// The bank just changed (row 125): the auto rows are IN it now, so the read-out has to say so
|
||
// before the edit wave starts translating against them.
|
||
r.exportBank(ctx, "bank-mining/auto-continue")
|
||
msg := "bank-mining: auto-continuing with an UNSIGNED bank (pass --verify-bank to stop and review it)"
|
||
switch {
|
||
case r.VerifyBank && editWave:
|
||
msg = "bank-mining: continuing with an UNSIGNED bank (--verify-bank is raised; nothing in the delta is new to it)"
|
||
case r.VerifyBank:
|
||
// Draft-only: the novelty predicate never ran (there is no edit wave to stop before), so the
|
||
// message must not claim it did.
|
||
msg = "bank-mining: continuing with an UNSIGNED bank (--verify-bank is raised, but a draft-only pipeline has no edit wave to stop before)"
|
||
}
|
||
r.Log.InfoContext(ctx, msg,
|
||
append([]any{"book", r.Book.BookID, "terms", len(mined), "terms_with_proposed_dst", withDst,
|
||
"auto_bank", r.autoBankPath(), "signature_map", r.signatureMapPath(),
|
||
"table", r.bankStopTablePath()},
|
||
emissionArgs(emission, cands)...)...)
|
||
return false, nil
|
||
}
|
||
r.lastBankStopRows = rows
|
||
// The signature stop is the boundary the signing screen reads at (row 125): refresh the bank read-out
|
||
// so the state behind the decisions is the state as of this stop, not as of the last run.
|
||
r.exportBank(ctx, "bank-mining/signature-stop")
|
||
// THE MEMORY, and strictly after the map: a crash between the two costs one benign re-stop, while
|
||
// the reverse order can mark surfaces presented whose map never reached disk — a silent loss with no
|
||
// retry. The same asymmetry decides the failure branch: the stop STANDS when the memory write fails
|
||
// (the map is on disk and the pause is real; the next run re-stops once more and re-writes), because
|
||
// failing the run here would turn a lawful pause into a crash over a bookkeeping row.
|
||
if merr := r.Store.MarkStopPresented(r.Book.BookID, presentedSurfaces(mined)); merr != nil {
|
||
r.Log.ErrorContext(ctx, "bank-mining: could not record the presented memory; the NEXT --verify-bank run will stop on this same map again (one extra stop, nothing lost)",
|
||
"book", r.Book.BookID, "err", merr)
|
||
}
|
||
r.Log.WarnContext(ctx, "bank-mining: the delta holds terms no stop has shown before; run STOPPED before the edit wave (review the signature map, decide what you wish via `tmctl bank-apply` — one act over the whole bank, one term, or nothing — then resume: the run continues and will not re-stop on these terms, and whatever you leave undecided is used on the wire like any signed row — D39.104, the bank is law for every row; a wrong one is corrected by a bank edit and a re-edit)",
|
||
append([]any{"book", r.Book.BookID, "terms", len(mined), "terms_with_proposed_dst", withDst,
|
||
"signature_map", r.signatureMapPath()}, emissionArgs(emission, cands)...)...)
|
||
return true, nil
|
||
}
|
||
|
||
// clusterSurfaces is a map term's cluster as the emission compares surfaces: the normalized src plus
|
||
// every normalized alias, blanks dropped.
|
||
func clusterSurfaces(t miner.Term) []string {
|
||
out := make([]string, 0, 1+len(t.Aliases))
|
||
if nk := text.NormalizeSourceKey(t.Src); nk != "" {
|
||
out = append(out, nk)
|
||
}
|
||
for _, a := range t.Aliases {
|
||
if nk := text.NormalizeSourceKey(a); nk != "" {
|
||
out = append(out, nk)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// hasUnpresentedCluster is the stop predicate of the D39.144 flag model: does the map hold a term whose
|
||
// WHOLE cluster is outside the presented memory?
|
||
//
|
||
// The domain is the FINAL map content — both sections, after every cap — never the pre-cap pool: a
|
||
// surface the cap keeps permanently invisible would otherwise hold «есть новое» true on every run
|
||
// forever, turning the flag into a stop that never goes out.
|
||
//
|
||
// Intersection over the cluster rather than a lookup of the representative, because the representative
|
||
// can change: as the book grows, the first-ranked member of a cluster may become an alias of the same
|
||
// entity, and a single-surface memory would re-stop on a term the owner has already seen. The residues
|
||
// are two-sided and both accepted (the report names them): an entity returning under a spelling the
|
||
// memory has never held costs one extra stop (benign); a genuinely NEW entity sharing an alias surface
|
||
// with a presented cluster reads as seen and is not presented — it still reaches the editor as an
|
||
// unsigned auto row, but the stop stays silent about it.
|
||
func hasUnpresentedCluster(mined []miner.Term, presented map[string]bool) bool {
|
||
for _, t := range mined {
|
||
seen := false
|
||
for _, s := range clusterSurfaces(t) {
|
||
if presented[s] {
|
||
seen = true
|
||
break
|
||
}
|
||
}
|
||
if !seen {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// presentedSurfaces flattens the map's clusters into the rows the memory stores — every surface of
|
||
// every term, both sections, exactly the set hasUnpresentedCluster will compare the next map against.
|
||
func presentedSurfaces(mined []miner.Term) []string {
|
||
var out []string
|
||
for _, t := range mined {
|
||
out = append(out, clusterSurfaces(t)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// emissionArgs renders the WHICH-funnel as log fields. It rides EVERY verdict of the stop, not only the
|
||
// empty one (G10 asks for the empty case; the same numbers answer "why so few?" on a non-empty delta, and
|
||
// they are the only place the top-200 keyhole is visible at all). Deterministic, $0 — the counters are read
|
||
// off the pass that already ran.
|
||
func emissionArgs(e miner.EmissionStats, cands []terminology.Candidate) []any {
|
||
draftSide := 0
|
||
for _, c := range cands {
|
||
if c.Origin != terminology.OriginMined {
|
||
draftSide++
|
||
}
|
||
}
|
||
return []any{
|
||
"ranked_alphabet", e.Ranked, "after_rank_cap", e.AfterCap, "eligible", e.Eligible,
|
||
"skipped_as_alias_of_seeded", e.SeedSkipped, "skipped_as_declined", e.Rejected,
|
||
"emitted_by_miner", e.Emitted, "draft_side_candidates", draftSide,
|
||
}
|
||
}
|
||
|
||
// packStateLabel describes the langpack state for the --verify-bank fail-loud, so the operator is told
|
||
// WHICH of the two preconditions is missing rather than "it did not work".
|
||
func packStateLabel(p *lang.Pack) string {
|
||
if p == nil {
|
||
return "absent"
|
||
}
|
||
return "loaded (" + p.Pair + ")"
|
||
}
|
||
|
||
// bankStopTablePath is the sidecar holding the FULL stop table. It sits beside the signature map (and the
|
||
// DB) so it travels with the book state and never enters git, and it is a separate artifact from the
|
||
// signature map because that map must stay a loadable seed YAML: evidence in it would be schema noise
|
||
// (§C2-7 — evidence belongs in the sign-map sidecar, not the seed schema).
|
||
func (r *Runner) bankStopTablePath() string {
|
||
return r.Book.ProjectDB + ".bank-stop.txt"
|
||
}
|
||
|
||
// BankStopRow is one row of the bank-verification table the stop shows the operator — the table D39.36
|
||
// specified and the CLI never had (it printed a term count and a path). Exported because the CLI renders it.
|
||
type BankStopRow struct {
|
||
Src string
|
||
Dst string // the consolidated rendering, or "" when nothing was consolidated
|
||
Origin string // mined | banknote | both — WHICH channel found it
|
||
Type string
|
||
Freq int // occurrences in the source
|
||
Spread int // how many DISTINCT renderings the drafts produced (the disagreement signal)
|
||
Variants []BankStopVariant // the renderings the drafts produced, best-ranked first
|
||
Contexts []string // source KWIC
|
||
Evidence []string
|
||
// The §G3 arbitration record: until this pack, «why does this term have THIS dst» was unanswerable from
|
||
// the artifacts (research/24 §A7). Every field below is read off work the ranking already did.
|
||
//
|
||
// Conventions is how many genuinely different DECISIONS the drafts made, once renderings differing only
|
||
// in target form are folded (Spread counts the raw forms). Signals are the §C2-3 factors that fired for
|
||
// the TOP-ranked variant — the winner's audit trail, printed for the row rather than per variant,
|
||
// because the row is what the owner signs. Invented says the consolidated rendering is NOT one the
|
||
// drafts proposed: legitimate (the role sees the whole book, the drafts saw fragments) and exactly the
|
||
// class to read first. Conf is the role's own stated confidence, which orders the review list and
|
||
// nothing else (D39.102) — NEGATIVE when the reply carried none, because «the role said it was 0% sure»
|
||
// is the most important row on the sheet and «the role said nothing» is not a row at all.
|
||
// Contradicts names THIS RUN's other consolidations the rendering breaks (§G2). BankHolds names rows
|
||
// the bank ALREADY carries for the same firing surface with a different rendering. Separate field and
|
||
// separate marker: "the run disagreed with itself" and "the book already calls it something else" are
|
||
// different decisions, and one shared line would make the sheet shorter but the decision harder.
|
||
Conventions int
|
||
Signals []string
|
||
Invented bool
|
||
Conf int
|
||
Contradicts []string
|
||
BankHolds []string
|
||
// SettledByBank marks a row the paid role was NEVER ASKED about, because the bank already renders the
|
||
// surface and every draft proposed that same rendering. Without it the row prints an empty dst, which
|
||
// on this sheet is the same glyph as «the role declined», «no reply line covered it» and «the budget
|
||
// did not reach it» — four different facts, one of which is «nothing to decide» and three of which are
|
||
// «undecided». Rendered by the text table only: the sidecar's proposal section is contract-pinned
|
||
// (backlog row 353) and is deliberately not touched.
|
||
SettledByBank bool
|
||
}
|
||
|
||
// BankStopVariant is ONE rendering the drafts produced, kept in its PARTS rather than as the sentence a
|
||
// table prints — the renderers format it (Label), so the capped stdout view and the text sidecar cannot
|
||
// describe a variant differently.
|
||
type BankStopVariant struct {
|
||
Dst string
|
||
Chunks int // how many draft chunks proposed it
|
||
// Via names the ALIAS this rendering was proposed for, "" for a direct proposal. Load-bearing rather
|
||
// than decoration: an alias-routed rendering must never silently become the cluster owner's dst
|
||
// (proposalsFromCandidates), so a surface that offers one has to be able to say whose it was.
|
||
Via string
|
||
}
|
||
|
||
// Label is the one rendering of a variant every human-facing table uses, so the capped stdout view and
|
||
// the text sidecar cannot describe a variant differently.
|
||
func (v BankStopVariant) Label() string {
|
||
s := fmt.Sprintf("%s ×%d", v.Dst, v.Chunks)
|
||
if v.Via != "" {
|
||
s += " (proposed for " + v.Via + ")"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ParseBankStopVariantLabel reads a label back into the variant that wrote it, and it lives HERE, against
|
||
// Label, because a format owned by two places is a format that drifts: the sidecar export carries variants
|
||
// as labels (BankExportProposal.Variants, contract-pinned by backlog row 353), so the chunk counts behind a
|
||
// candidate are recoverable from a bought artifact only through this shape. A reader that re-derived the
|
||
// format for itself would keep working until the day the label gains a field, and then it would report
|
||
// numbers rather than an error.
|
||
//
|
||
// Returns false for anything this engine did not write — the caller must be able to say «that file is not
|
||
// a stop projection» rather than silently read zero chunks behind every rendering.
|
||
func ParseBankStopVariantLabel(s string) (BankStopVariant, bool) {
|
||
var v BankStopVariant
|
||
s = strings.TrimSpace(s)
|
||
if i := strings.LastIndex(s, " (proposed for "); i >= 0 && strings.HasSuffix(s, ")") {
|
||
v.Via = s[i+len(" (proposed for ") : len(s)-1]
|
||
s = s[:i]
|
||
}
|
||
i := strings.LastIndex(s, " ×")
|
||
if i <= 0 {
|
||
return BankStopVariant{}, false
|
||
}
|
||
n, err := strconv.Atoi(s[i+len(" ×"):])
|
||
if err != nil || n < 0 {
|
||
return BankStopVariant{}, false
|
||
}
|
||
v.Dst, v.Chunks = s[:i], n
|
||
return v, true
|
||
}
|
||
|
||
// VariantLabels renders a row's variants for a text table.
|
||
func (r BankStopRow) VariantLabels() []string {
|
||
out := make([]string, 0, len(r.Variants))
|
||
for _, v := range r.Variants {
|
||
out = append(out, v.Label())
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bankStopRows projects the merged candidates into the operator table, best-ranked variants first.
|
||
// Deterministic: cands is key-ordered and nothing here iterates a map for output.
|
||
func bankStopRows(cands []terminology.Candidate, consolidated map[string]string, tres *terminologyResult) []BankStopRow {
|
||
// A stop can fire with the terminology gate OFF — mining is what decides the pause, the paid role is a
|
||
// separate switch — and then there are no findings to match. Reading them off a zero result keeps the
|
||
// matching in ONE place (findingsFor) instead of growing a second, nil-shaped copy of it here.
|
||
var findings terminologyResult
|
||
if tres != nil {
|
||
findings = *tres
|
||
}
|
||
out := make([]BankStopRow, 0, len(cands))
|
||
for _, c := range cands {
|
||
dst := consolidated[c.Key]
|
||
row := BankStopRow{
|
||
Src: c.Src, Dst: dst, Origin: string(c.Origin), Type: c.Type,
|
||
Freq: c.Freq, Spread: c.Spread(), Conventions: c.Conventions(),
|
||
Contexts: c.KWIC, Evidence: c.Evidence,
|
||
}
|
||
row.Conf, row.Contradicts, row.BankHolds, row.SettledByBank = findings.findingsFor(c)
|
||
for i, v := range c.Variants {
|
||
row.Variants = append(row.Variants, BankStopVariant{Dst: v.Dst, Chunks: v.Chunks, Via: v.Via})
|
||
if i == 0 {
|
||
row.Signals = v.Signals
|
||
}
|
||
}
|
||
row.Invented = dst != "" && !proposedByDrafts(dst, c.Variants)
|
||
out = append(out, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// findingsFor is THE ONE PLACE a finding of the terminology phase is matched to the candidate it belongs
|
||
// to, and the only place the matching key is chosen.
|
||
//
|
||
// ⚠ WHY IT IS ONE FUNCTION AND NOT THREE LOOKUPS AT THE CALL SITE. Every one of these three crossed the
|
||
// file boundary as a map keyed on the writer's side and read on the reader's, which means the key was
|
||
// chosen TWICE per finding, in two files, with nothing comparing the two choices. They agreed; that they
|
||
// agreed was luck, and no test could have said otherwise, because a candidate whose Key differs from its
|
||
// Src — every traditional or katakana spelling — appeared in no fixture. Mutating any of the three keys
|
||
// survived the whole of pipeline, terminology and tmctl. The same shape was found and removed once
|
||
// already on the BankHolds half; this is that fix finished rather than repeated a fourth time (D39.216).
|
||
//
|
||
// The key is the candidate KEY throughout: it is the surface consolidatedRows gives a proposal, the
|
||
// surface the role was asked about (candKeys), and the surface ConsolidationConflicts compares on. A
|
||
// banknote candidate's raw Src is a different string and its firing key may be an alias, so either of
|
||
// those would lose the whole banknote population silently.
|
||
func (t terminologyResult) findingsFor(c terminology.Candidate) (conf int, contradicts, bankHolds []string, settled bool) {
|
||
// The role's stated confidence, or -1 when the reply carried none. A plain zero would merge the two,
|
||
// and they are opposites: one is the first row to review, the other is silence.
|
||
conf = -1
|
||
if v, ok := t.Conf[c.Key]; ok {
|
||
conf = v
|
||
}
|
||
for _, cf := range t.SelfConflictRows {
|
||
if cf.Key == c.Key {
|
||
contradicts = append(contradicts, cf.PartLabel())
|
||
}
|
||
}
|
||
for _, cf := range t.BankHoldRows {
|
||
if cf.Src == c.Key {
|
||
bankHolds = append(bankHolds, cf.BankRowLabel())
|
||
}
|
||
}
|
||
for _, k := range t.BankSettledKeys {
|
||
if k == c.Key {
|
||
settled = true
|
||
}
|
||
}
|
||
return conf, contradicts, bankHolds, settled
|
||
}
|
||
|
||
// proposedByDrafts reports whether the consolidated rendering is one the drafts actually produced, compared
|
||
// under the same target-form fold the vote is counted with — so a case or ё difference is not reported as
|
||
// an invention.
|
||
func proposedByDrafts(dst string, vs []terminology.Variant) bool {
|
||
want := text.NormalizeTargetForm(dst)
|
||
for _, v := range vs {
|
||
if text.NormalizeTargetForm(v.Dst) == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// renderBankStopTable serializes the FULL table for the sidecar. One block per term, the same shape the
|
||
// stdout banner prints — so the capped view and the file cannot describe the bank differently.
|
||
func renderBankStopTable(rows []BankStopRow) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "BANK VERIFICATION TABLE — %d term(s)\n", len(rows))
|
||
b.WriteString("src · proposed dst · origin · type · freq · variant spread · conventions · confidence ·\n")
|
||
b.WriteString("why (the ranking factors that won) · contradictions (this run's own, and the bank's\n")
|
||
b.WriteString("existing rows) · drafts · evidence · source contexts\n\n")
|
||
for _, r := range rows {
|
||
fmt.Fprintf(&b, "%s\t%s\n", r.Src, dashIfEmpty(r.Dst))
|
||
fmt.Fprintf(&b, " origin=%s type=%s freq=%d spread=%d conventions=%d", r.Origin, dashIfEmpty(r.Type), r.Freq, r.Spread, r.Conventions)
|
||
if r.Conf >= 0 {
|
||
fmt.Fprintf(&b, " confidence=%d", r.Conf)
|
||
}
|
||
if r.Invented {
|
||
b.WriteString(" INVENTED(no draft proposed it)")
|
||
}
|
||
if r.SettledByBank {
|
||
b.WriteString(" NOT ASKED(the bank already renders this surface and every draft agreed — nothing to decide)")
|
||
}
|
||
b.WriteString("\n")
|
||
if len(r.Signals) > 0 {
|
||
fmt.Fprintf(&b, " why: %s\n", strings.Join(r.Signals, ", "))
|
||
}
|
||
if len(r.Contradicts) > 0 {
|
||
fmt.Fprintf(&b, " CONTRADICTS this run's own: %s\n", strings.Join(r.Contradicts, "; "))
|
||
}
|
||
if len(r.BankHolds) > 0 {
|
||
fmt.Fprintf(&b, " THE BANK ALREADY HOLDS: %s\n", strings.Join(r.BankHolds, "; "))
|
||
}
|
||
if len(r.Variants) > 0 {
|
||
fmt.Fprintf(&b, " drafts: %s\n", strings.Join(r.VariantLabels(), " | "))
|
||
}
|
||
if len(r.Evidence) > 0 {
|
||
fmt.Fprintf(&b, " evidence: %s\n", strings.Join(r.Evidence, ", "))
|
||
}
|
||
for _, k := range r.Contexts {
|
||
fmt.Fprintf(&b, " ctx: %s\n", k)
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func dashIfEmpty(s string) string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return "—"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// reverseSectionTerms turns the banknote-only candidates into emittable terms. Guards, each closing a way
|
||
// the reverse section could pollute the delta:
|
||
// - the surface must OCCUR in the source (a KWIC context exists) — a rendering a draft invented for a
|
||
// word that is not in the book is not a term of the book;
|
||
// - an existing seed surface is skipped (it is already in the bank);
|
||
// - a declined surface is skipped, so a reject stays declined through this door too (R1-FL-B).
|
||
//
|
||
// Deterministic: cands is key-ordered and nothing here iterates a map.
|
||
// Returns the capped list plus how many were ELIGIBLE before the cap, so a truncation is reported rather
|
||
// than silent.
|
||
func reverseSectionTerms(cands []terminology.Candidate, seed []store.GlossaryEntry, rejects map[string]bool) (out []miner.Term, eligible int) {
|
||
seedSurfaces := map[string]bool{}
|
||
for _, e := range seed {
|
||
seedSurfaces[text.NormalizeSourceKey(e.Src)] = true
|
||
for _, a := range e.Aliases {
|
||
seedSurfaces[text.NormalizeSourceKey(a.Alias)] = true
|
||
}
|
||
}
|
||
for _, c := range cands {
|
||
if c.Origin != terminology.OriginBanknote || len(c.KWIC) == 0 {
|
||
continue
|
||
}
|
||
if seedSurfaces[c.Key] || rejects[c.Key] {
|
||
continue
|
||
}
|
||
ev := []string{"banknote-only candidate (the miner did not surface it)"}
|
||
if len(c.Related) > 0 {
|
||
ev = append(ev, "related to mined "+strings.Join(c.Related, ", "))
|
||
}
|
||
out = append(out, miner.Term{Src: c.Key, Type: c.Type, Freq: c.Freq, SinceCh: 0, Evidence: ev})
|
||
}
|
||
// The SAME volume cap the miner's own emission applies (miner.EmitRankCap), ranked the same way — by
|
||
// frequency. Without it this door is uncapped: every banknote-only surface of the whole book enters the
|
||
// delta the owner is asked to sign, the auto-bank, and the terminologist's batches, while the miner's
|
||
// side of the same file stops at 200. The drop is reported by the caller, never silent.
|
||
eligible = len(out)
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Freq != out[j].Freq {
|
||
return out[i].Freq > out[j].Freq
|
||
}
|
||
return out[i].Src < out[j].Src
|
||
})
|
||
if cap := miner.EmitRankCap(); len(out) > cap {
|
||
out = out[:cap]
|
||
}
|
||
return out, eligible
|
||
}
|
||
|
||
// proposalsFromCandidates re-shapes the MERGED candidates into the signature-map join's input.
|
||
//
|
||
// It reads the candidates rather than the raw draft-side view, and that is the fix (§G3, acceptance
|
||
// finding): a proposal that arrived under an ALIAS of a cluster is routed to the cluster's owner by
|
||
// Merge — the stop table therefore showed it — while the signature map joined on the alias's own key and
|
||
// the owner's row never mentioned it. The map the owner signs then disagreed with the table he was reading
|
||
// it against, for exactly the terms the miner clustered. One source for both removes the divergence
|
||
// structurally instead of keeping two joins in step by hand.
|
||
//
|
||
// The list arrives §C2-3-ranked and target-form folded, so the note's alternatives are the ones the table
|
||
// shows, in the order it shows them.
|
||
func proposalsFromCandidates(cands []terminology.Candidate) map[string][]miner.DstProposal {
|
||
out := make(map[string][]miner.DstProposal, len(cands))
|
||
for _, c := range cands {
|
||
if len(c.Variants) == 0 {
|
||
continue
|
||
}
|
||
// DIRECT proposals first, alias-routed ones after — and DeltaYAML takes the term's dst from a DIRECT
|
||
// one only. Merge routes an alias's rendering to the cluster owner so the ranking sees all of the
|
||
// entity's evidence; letting that rendering become the OWNER's dst is a different act entirely. It
|
||
// would put «Малыш Фан» on 方源 with no model involved, on the $0 path, with the terminology gate OFF —
|
||
// the exact harm Variant.Via was introduced to prevent, arriving through the fix that introduced Via.
|
||
list := make([]miner.DstProposal, 0, len(c.Variants))
|
||
for _, v := range c.Variants {
|
||
if v.Via == "" {
|
||
list = append(list, miner.DstProposal{Dst: v.Dst, Type: c.Type, Chunks: v.Chunks})
|
||
}
|
||
}
|
||
for _, v := range c.Variants {
|
||
if v.Via != "" {
|
||
list = append(list, miner.DstProposal{Dst: v.Dst, Type: c.Type, Chunks: v.Chunks, Via: v.Via})
|
||
}
|
||
}
|
||
out[c.Key] = list
|
||
}
|
||
return out
|
||
}
|
||
|
||
// autoBankPath is where the AUTO mode records the bank it built for itself: the terminologist's
|
||
// consolidated rows, unsigned. It sits beside the project DB like the signature map — book state, never
|
||
// git — and is deliberately a SEPARATE file from the owner's mined_delta: that file is the owner's word,
|
||
// this one is the engine's, and merging the two would make it impossible to tell later which renderings
|
||
// a human actually approved.
|
||
func (r *Runner) autoBankPath() string { return r.Book.ProjectDB + ".auto-bank.yaml" }
|
||
|
||
// loadAutoBank reads the engine's unsigned rows and returns the ones that may enter the bank, plus the
|
||
// human-readable list of those dropped. Three filters, each closing a named risk of the phase-1 design:
|
||
//
|
||
// - the REJECT SET applies here too (risk 3). Until pack-20 rejects were consulted only at EMISSION, so
|
||
// a term the owner declined could survive in an accumulated file and re-enter the bank through the
|
||
// back door. «reject-set works in both modes» has to mean on the way IN, not only on the way out.
|
||
// - a row whose UNIQUE key (src, sense, since_ch, until_ch) is already held by a row gathered EARLIER is
|
||
// dropped (risk 4). The flat INSERT in ReplaceGlossary would otherwise crash on the constraint and
|
||
// abort a paid run. ⚠ THE HOLDER IS OF ANY STATUS, not only a signed one: what is passed in is the
|
||
// whole gathered set — seed, ruby aliases and the owner's mined-delta — and an unsigned row holds its
|
||
// tuple exactly as hard, because the store's constraint does not read statuses. Where the holder IS
|
||
// signed the resolution is never "the engine's guess replaces the signature"; where it is not, the
|
||
// drop is the constraint talking and nothing more, so each dropped row is reported WITH its holder's
|
||
// signature. Saying "the signed term wins" over an unsigned holder is the one thing this must not do:
|
||
// the operator signs the bank on the strength of these lines (D39.104 п.1).
|
||
// - nothing here can carry `approved`: the loader is the same seed loader, and the status it reads is
|
||
// whatever the emission wrote (auto/draft). A file hand-edited to say `approved` is refused loudly,
|
||
// because that would be a signature nobody gave.
|
||
func (r *Runner) loadAutoBank(gathered []store.GlossaryEntry) (rows []store.GlossaryEntry, dropped []string, err error) {
|
||
// Only ABSENT means "auto mode has not run"; an unreadable file must not drop the mined rows.
|
||
if _, serr := os.Stat(r.autoBankPath()); serr != nil {
|
||
if errors.Is(serr, fs.ErrNotExist) {
|
||
return nil, nil, nil // the auto mode has not run yet (or the book never uses it)
|
||
}
|
||
return nil, nil, fmt.Errorf("pipeline: stat auto-bank %s: %w", r.autoBankPath(), serr)
|
||
}
|
||
entries, dropped, err := membank.LoadEngineGlossarySeed(r.autoBankPath())
|
||
r.warnDroppedRows("auto-bank", r.autoBankPath(), dropped)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("pipeline: load auto-bank %s: %w", r.autoBankPath(), err)
|
||
}
|
||
rejects, err := r.loadMinedRejects()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
for _, e := range entries {
|
||
if e.Status == "approved" {
|
||
return nil, nil, fmt.Errorf("pipeline: auto-bank %s carries an `approved` row (%q → %q): this file is the ENGINE's unsigned proposals, and nothing in it may claim a signature — move the term into the owner's mined-delta file instead",
|
||
r.autoBankPath(), e.Src, e.Dst)
|
||
}
|
||
}
|
||
type ukey struct {
|
||
src, sense string
|
||
since, until int
|
||
}
|
||
held := map[ukey]store.GlossaryEntry{}
|
||
for _, e := range gathered {
|
||
held[ukey{e.Src, e.Sense, e.SinceCh, e.UntilCh}] = e
|
||
}
|
||
for _, e := range entries {
|
||
if rejects[text.NormalizeSourceKey(e.Src)] {
|
||
dropped = append(dropped, fmt.Sprintf("%q (declined in mined_rejects)", e.Src))
|
||
continue
|
||
}
|
||
if prior, clash := held[ukey{e.Src, e.Sense, e.SinceCh, e.UntilCh}]; clash {
|
||
dropped = append(dropped, fmt.Sprintf("%q→%q (key held by the %s %q→%q)", e.Src, e.Dst, membank.StatusLabel(prior.Status), prior.Src, prior.Dst))
|
||
continue
|
||
}
|
||
e.Source = "mined" // base-excluded: an unsigned row never moves the draft wave's snapshot
|
||
rows = append(rows, e)
|
||
}
|
||
return rows, dropped, nil
|
||
}
|
||
|
||
// unsignedEngineSurfaces reports the bank rows that must NOT count as mining seed surfaces: the engine's
|
||
// own unsigned proposals (Source:"mined" + not approved). Risk 2 of the phase-1 design — the
|
||
// self-exclusion trap. Once the auto mode writes its rows into the bank they would, on the next run,
|
||
// look to the miner exactly like a seeded term: the delta empties, and the signature map silently stops
|
||
// offering terms nobody ever reviewed. Filtering them keeps the map's proposal list stable until the
|
||
// owner PROMOTES a term (into mined_delta, as approved → a real seed surface) or DECLINES it
|
||
// (mined_rejects) — the two verbs that take a term out of the map. (They do not govern the STOP: that
|
||
// is the presented memory's question, D39.144.)
|
||
func unsignedEngineSurfaces(rows []store.GlossaryEntry) []store.GlossaryEntry {
|
||
out := rows[:0:0]
|
||
for _, e := range rows {
|
||
// membank.IsEngineUnsigned rather than the test spelled out here: the same question is asked by the
|
||
// bank's own tuple skip and by the paid path's filter, and three spellings of it would be three
|
||
// answers to "is this the engine's own word".
|
||
if membank.IsEngineUnsigned(e) {
|
||
continue
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// writeAutoBank persists the unsigned rows the auto mode decided to carry forward, as the same seed-YAML
|
||
// schema everything else in this pipeline speaks (so `tmctl seed-lint` reads it, and a row can be moved
|
||
// into the owner's delta by copy-paste). Deterministic: the mined list is already sorted by src.
|
||
//
|
||
// It DIFFS the file it is about to replace, and that is the $0 minimum of backlog row 130. The file is
|
||
// rewritten WHOLE from this run's delta, and the delta is capped at the miner's top-200 (emitRankCap,
|
||
// applied BEFORE the emission filters, so seed and declined terms do not free their slots). The two
|
||
// mechanisms were ratified separately and their INTERACTION never was: as a book grows, a term that was in
|
||
// the bank for twenty chapters silently vanishes from it mid-run, with nothing in the artifacts saying so.
|
||
// Naming the losers costs nothing and moves no bytes.
|
||
//
|
||
// The BOUNDARY is deliberate and is the owner's STOP: this reports, it does not accumulate. Merging the old
|
||
// file into the new one would change what the bank CONTAINS, which moves memory_version and re-prices the
|
||
// edit wave — a decision, not a hygiene fix.
|
||
func (r *Runner) writeAutoBank(ctx context.Context, mined []miner.Term, proposals map[string][]miner.DstProposal, signed map[string]bool) error {
|
||
before := r.autoBankSurfaces()
|
||
body, err := miner.DeltaYAML(mined, proposals)
|
||
if err != nil {
|
||
return fmt.Errorf("pipeline: marshal auto-bank: %w", err)
|
||
}
|
||
// ATOMIC, and the reason changed under this file's feet. Until the read-only surfaces began folding the
|
||
// bank themselves (bankmaterialize.go), the auto-bank was read only by the run that had just written
|
||
// it, so a plain truncating write was safe. Now `status` and `export` read it CONCURRENTLY with a
|
||
// running translate — the exact condition artifact.go names — and a truncating write gives a reader
|
||
// either a parse error (the buyer sees stale figures) or, worse, a valid PREFIX of the term list: a
|
||
// fold of a bank that never existed, and a rebill_units no run will ever charge. Write-then-rename
|
||
// makes every read see the previous document or the next one, whole.
|
||
if err := writeFileAtomic(r.autoBankPath(), []byte(body)); err != nil {
|
||
return fmt.Errorf("pipeline: write auto-bank %s: %w", r.autoBankPath(), err)
|
||
}
|
||
if len(before) == 0 {
|
||
return nil
|
||
}
|
||
now := make(map[string]bool, len(mined))
|
||
for _, m := range mined {
|
||
now[text.NormalizeSourceKey(m.Src)] = true
|
||
}
|
||
var gone []string
|
||
for _, src := range before {
|
||
key := text.NormalizeSourceKey(src)
|
||
if now[key] || signed[key] {
|
||
// signed[] is the owner's two verbs — PROMOTED into the mined-delta or DECLINED in mined-rejects.
|
||
// Both remove the term from this run's delta on purpose, and reporting them as a silent loss would
|
||
// fire a false alarm on the normal signature cycle — advising the owner to do what he just did.
|
||
continue
|
||
}
|
||
gone = append(gone, src)
|
||
}
|
||
if len(gone) > 0 {
|
||
sort.Strings(gone)
|
||
r.Log.WarnContext(ctx, "bank-mining: terms that were in the auto-bank are NOT in the one this run just wrote — the file is rewritten whole from a top-N-capped delta, so a term the book still uses can drop out of the bank mid-run; if one of these matters, promote it into the owner's mined-delta file (it is then a seed surface and cannot be cut again)",
|
||
"book", r.Book.BookID, "dropped", len(gone), "kept", len(mined), "rank_cap", miner.EmitRankCap(),
|
||
"terms", strings.Join(gone, ", "), "auto_bank", r.autoBankPath())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ownerHandled is the surface set the OWNER has already decided about: every signed seed surface (a promoted
|
||
// term is one) plus every declined one. A term leaving the delta through either door is not a loss.
|
||
//
|
||
// ⚠ The caller MUST pass it through unsignedEngineSurfaces — risk 2, the self-exclusion trap this file
|
||
// documents twice and works around in two other places. From the SECOND auto-mode run the stored glossary
|
||
// also holds the engine's OWN unsigned rows (seedGlossary re-seeds the auto-bank at start), so a raw seed
|
||
// makes every term the engine ever proposed look owner-decided: the diff empties, and the row-130 warning —
|
||
// whose entire purpose is to name terms the rewrite dropped — can never fire again in production.
|
||
func ownerHandled(seed []store.GlossaryEntry, rejects map[string]bool) map[string]bool {
|
||
out := make(map[string]bool, len(seed)+len(rejects))
|
||
for _, e := range seed {
|
||
out[text.NormalizeSourceKey(e.Src)] = true
|
||
for _, a := range e.Aliases {
|
||
out[text.NormalizeSourceKey(a.Alias)] = true
|
||
}
|
||
}
|
||
for k := range rejects {
|
||
out[k] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// autoBankSurfaces reads the src surfaces of the auto-bank file as it stands BEFORE this run rewrites it,
|
||
// in file order. Absent or unreadable → nil: the diff is observability, and failing a paid run because the
|
||
// PREVIOUS artifact cannot be parsed would be the tail wagging the dog.
|
||
func (r *Runner) autoBankSurfaces() []string {
|
||
// The drop is reported by loadAutoBank, which reads the same file on the paid path; saying it twice
|
||
// per run would make the warning noise instead of news.
|
||
entries, _, err := membank.LoadEngineGlossarySeed(r.autoBankPath())
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(entries))
|
||
for _, e := range entries {
|
||
if e.Src != "" {
|
||
out = append(out, e.Src)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// loadMinedRejects reads Book.MinedRejects and returns the normalized src set the emission excludes (like
|
||
// the seed surfaces). Each src is normalized via text.NormalizeSourceKey so a reject matches the miner's
|
||
// normalized candidate surface whichever orthographic form the owner pasted from the signature map; a
|
||
// blank src is skipped (a stray list entry must not silently match everything).
|
||
//
|
||
// An ABSENT file is "no rejects": the path is conventional and only conventional (config.Book.MinedRejects),
|
||
// so absence means nobody has declined anything yet (D39.156 п.3). Unreadable stays a loud error.
|
||
func (r *Runner) loadMinedRejects() (map[string]bool, error) {
|
||
present, err := decisionFilePresent(r.Book.MinedRejects)
|
||
if err != nil || !present {
|
||
return nil, err
|
||
}
|
||
raw, err := os.ReadFile(r.Book.MinedRejects)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pipeline: read mined-rejects %s: %w", r.Book.MinedRejects, err)
|
||
}
|
||
f, err := seed.DecodeRejects(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pipeline: parse mined-rejects %s: %w", r.Book.MinedRejects, err)
|
||
}
|
||
return rejectSurfaces(f), nil
|
||
}
|
||
|
||
// rejectSurfaces is the reject list as the emission reads it: normalized src keys, blanks dropped. One
|
||
// definition, because `bank-apply` asks the same question about the same file and two spellings of
|
||
// "which surfaces has the owner declined" would eventually answer differently.
|
||
func rejectSurfaces(f seed.RejectFile) map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, rj := range f.Rejects {
|
||
if nk := text.NormalizeSourceKey(rj.Src); nk != "" {
|
||
out[nk] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// decisionFilePresent reports whether one of the two owner-decision files is there to be read.
|
||
//
|
||
// The paths are conventional and only conventional (config.Book.MinedDelta), so an absent file is not
|
||
// present and not an error: "nobody has decided anything yet", the same standing an absent project
|
||
// database has before the first run (D39.156 п.3). Every other stat failure stays loud — an unreadable
|
||
// file is not an undecided one.
|
||
func decisionFilePresent(path string) (bool, error) {
|
||
_, err := os.Stat(path)
|
||
switch {
|
||
case err == nil:
|
||
return true, nil
|
||
case errors.Is(err, fs.ErrNotExist):
|
||
return false, nil
|
||
default:
|
||
return false, fmt.Errorf("pipeline: %s is not readable: %w", path, err)
|
||
}
|
||
}
|
||
|
||
// warnDroppedRows says out loud that the wire fence removed a row from a document THIS ENGINE WROTE.
|
||
//
|
||
// ⛔ A DROP THAT NOBODY CAN READ IS THE WORSE HALF OF THE FIX. Refusing an engine-written document killed
|
||
// a paid run over our own output, so the row is dropped instead (membank.ParseEngineBankSeed) — but a
|
||
// mined term that vanishes from every request with no trace leaves an operator asking why a term they
|
||
// can see in the file is not applied, and nothing to answer with. So the loader REPORTS what it removed
|
||
// and this is where the engine speaks, beside the DECLINED-term warning it is modelled on.
|
||
//
|
||
// Warn and not Error: the run is correct and complete without the row, and the fault is in a model's
|
||
// answer rather than in anything the operator did.
|
||
func (r *Runner) warnDroppedRows(kind, path string, dropped []string) {
|
||
if len(dropped) == 0 {
|
||
return
|
||
}
|
||
r.Log.Warn("wire fence removed term(s) from an engine-written bank document; they are NOT entering the bank and NOT reaching any request (the model's answer carried a rune that can write into a system message)",
|
||
"book", r.Book.BookID, "document", kind, "path", path,
|
||
"dropped", strings.Join(dropped, ", "), "count", len(dropped))
|
||
}
|
||
|
||
// loadMinedDelta reads the owner-curated mined-delta YAML (book.MinedDelta) and stamps every entry
|
||
// Source:"mined" — NOT via membank.LoadGlossarySeed (which hardcodes Source:"seed", memseed.go, moving the base
|
||
// bank / draft-wave snapshot). This is the mined-write path (plan §1(в), F2): the mined terms land in the ENRICHED
|
||
// bank version but NOT the base, so adding them moves ONLY edit-wave snapshot ("re-paying ONCE"). Reuses
|
||
// membank.LoadGlossarySeed's parser/validation, then re-stamps the Source. Empty path → nil (no mined terms).
|
||
func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) {
|
||
// The same absence rule the reject list follows.
|
||
present, err := decisionFilePresent(r.Book.MinedDelta)
|
||
if err != nil || !present {
|
||
return nil, err
|
||
}
|
||
entries, dropped, err := membank.LoadEngineGlossarySeed(r.Book.MinedDelta)
|
||
r.warnDroppedRows("mined-delta", r.Book.MinedDelta, dropped)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err)
|
||
}
|
||
// A DECLINED surface never enters the bank, even from the delta. The two decision documents are ONE
|
||
// state, and a surface on the reject list is decided-against; the delta is the only door through
|
||
// which a decided-against surface could still reach a paid run, so it is closed here.
|
||
//
|
||
// In ordinary operation the two documents cannot both hold a surface — applying a decline drops the
|
||
// delta row and records the reject in ONE act. The state exists anyway, by two routes, and both end
|
||
// in the editor's glossary if this filter is absent: a hand-edited delta, and the half-state a
|
||
// process killed between the two renames leaves behind. The second is why this sits beside the
|
||
// rejects-first rename order (writeDecisionFiles): that order is what lets an interrupted decline's
|
||
// re-send converge, and it does so by leaving the delta row on disk — so without this filter the
|
||
// convergence would have been bought with a window in which a paid run injects a term the owner
|
||
// explicitly declined. Loud, because a delta row and a reject for one surface means the two
|
||
// documents disagree and somebody should look.
|
||
rejects, rerr := r.loadMinedRejects()
|
||
if rerr != nil {
|
||
return nil, rerr
|
||
}
|
||
if len(rejects) > 0 {
|
||
kept := entries[:0:0]
|
||
var declined []string
|
||
for _, e := range entries {
|
||
if rejects[text.NormalizeSourceKey(e.Src)] {
|
||
declined = append(declined, e.Src)
|
||
continue
|
||
}
|
||
kept = append(kept, e)
|
||
}
|
||
if len(declined) > 0 {
|
||
r.Log.Warn("mined-delta holds term(s) the owner has DECLINED; they are NOT entering the bank (the two decision documents disagree — a hand edit, or a write interrupted between the two files)",
|
||
"book", r.Book.BookID, "declined", strings.Join(declined, ", "),
|
||
"mined_delta", r.Book.MinedDelta, "mined_rejects", r.Book.MinedRejects)
|
||
}
|
||
entries = kept
|
||
}
|
||
for i := range entries {
|
||
entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded)
|
||
}
|
||
return entries, nil
|
||
}
|