package pipeline import ( "context" "fmt" "os" "sort" "strings" "gopkg.in/yaml.v3" "textmachine/backend/internal/chunk" "textmachine/backend/internal/lang" "textmachine/backend/internal/membank" "textmachine/backend/internal/miner" "textmachine/backend/internal/store" "textmachine/backend/internal/terminology" "textmachine/backend/internal/text" ) // mining.go: bank-mining stop boundary (WS3 wired live, R1). Between the drafts done and the edit wave, the // miner scores WHICH-candidates over the 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 and STOPS before the edit wave. The owner // reviews it, promotes terms into the mined-delta file (approved + dst), and re-runs: seedGlossary loads // those as Source:mined (moving only edit-wave snapshot), the draft wave resumes at $0, the bank-mining stop re-mines (the now-seeded terms are // excluded → the delta empties), and the edit wave runs. 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 r.Book.ProjectDB + ".mined-signature.yaml" } // runBankMiningStop executes the bank-mining stop. Returns stopped=true (with the signature map written and // r.lastMinedCount set) when the miner proposes a non-empty delta; stopped=false (auto-continue) when mining // is unconfigured or the delta is empty. $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, so it never re-fires the stop. The // stop therefore clears once EVERY proposed term is EITHER promoted (into mined_delta → a seed surface) // OR rejected (mined_rejects) — the two owner verbs that empty the delta. 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. 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) } 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 whose only consumer is the // stop's own table, and the emitted delta is byte-identical to before. 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, tres, err := r.runTerminologist(ctx, draftSnapshot, cands) if err != nil { return false, err } r.lastTerminology = tres mined = attachClassifiedType(mined, classified) mined = attachConsolidatedDst(mined, consolidated) // Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave. proposals := proposalsFromObserved(observed) withDst := 0 for _, m := range mined { if m.Dst != "" || len(proposals[text.NormalizeSourceKey(m.Src)]) > 0 { withDst++ } } yamlDelta, err := miner.DeltaYAML(mined, proposals) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop marshal mined delta: %w", err) } if err := os.WriteFile(r.signatureMapPath(), []byte(yamlDelta), 0o644); 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) 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, owner's words: «можно запустить перевод так, чтоб сессия не останавливалась и // не запрашивала верификацию банка, а просто как намайнит и закончит — шла в редактуру»). Default is // auto-continue; the operator asks for the pause with --verify-bank. 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). if !r.VerifyBank { // 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, // marked ⟨проверить⟩ in its own section, 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(mined, proposals); 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) } r.Log.InfoContext(ctx, "bank-mining: auto-continuing with an UNSIGNED bank (pass --verify-bank to stop and review it)", 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 } if !editWave { 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()) return false, nil } r.lastBankStopRows = rows r.Log.WarnContext(ctx, "bank-mining: new terms await owner signature; run STOPPED before the edit wave (review the signature map, then for EACH term either promote it into the mined-delta file OR decline it in the mined-rejects file, then resume — the stop clears once every proposed term is promoted or rejected)", append([]any{"book", r.Book.BookID, "terms", len(mined), "terms_with_proposed_dst", withDst, "signature_map", r.signatureMapPath()}, emissionArgs(emission, cands)...)...) return true, nil } // 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 []string // "rendering ×N", best-ranked first Contexts []string // source KWIC Evidence []string } // 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) []BankStopRow { out := make([]BankStopRow, 0, len(cands)) for _, c := range cands { row := BankStopRow{ Src: c.Src, Dst: consolidated[c.Key], Origin: string(c.Origin), Type: c.Type, Freq: c.Freq, Spread: c.Spread(), Contexts: c.KWIC, Evidence: c.Evidence, } for _, v := range c.Variants { row.Variants = append(row.Variants, fmt.Sprintf("%s ×%d", v.Dst, v.Chunks)) } out = append(out, row) } return out } // 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 · 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\n", r.Origin, dashIfEmpty(r.Type), r.Freq, r.Spread) if len(r.Variants) > 0 { fmt.Fprintf(&b, " drafts: %s\n", strings.Join(r.Variants, " | ")) } 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 } // proposalsFromObserved re-shapes the folded draft-side view into the signature-map join's input. One // definition of the fold (bankObservedByKey) feeds both consumers, so the map the owner signs and the // table the terminologist read can never disagree about what a chunk proposed. func proposalsFromObserved(obs []terminology.Observed) map[string][]miner.DstProposal { out := make(map[string][]miner.DstProposal, len(obs)) for _, o := range obs { list := make([]miner.DstProposal, 0, len(o.Proposals)) for _, p := range o.Proposals { list = append(list, miner.DstProposal{Dst: p.Dst, Type: p.Type, Chunks: p.Chunks}) } out[o.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 SIGNED row is dropped // (risk 4). The flat INSERT in ReplaceGlossary would otherwise crash on the constraint and abort a // paid run — and the right resolution is never "the engine's guess replaces the signature". // - 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(signed []store.GlossaryEntry) (rows []store.GlossaryEntry, dropped []string, err error) { if _, serr := os.Stat(r.autoBankPath()); serr != nil { return nil, nil, nil // absent → the auto mode has not run yet (or the book never uses it) } entries, err := membank.LoadGlossarySeed(r.autoBankPath()) 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 signed { 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 signed %q→%q)", e.Src, e.Dst, 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 `--verify-bank` silently stops // firing on terms nobody ever reviewed. Filtering them keeps the 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 are supposed to be the only way the stop clears. func unsignedEngineSurfaces(rows []store.GlossaryEntry) []store.GlossaryEntry { out := rows[:0:0] for _, e := range rows { if e.Source == "mined" && e.Status != "approved" { 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. func (r *Runner) writeAutoBank(mined []miner.Term, proposals map[string][]miner.DstProposal) error { body, err := miner.DeltaYAML(mined, proposals) if err != nil { return fmt.Errorf("pipeline: marshal auto-bank: %w", err) } if err := os.WriteFile(r.autoBankPath(), []byte(body), 0o644); err != nil { return fmt.Errorf("pipeline: write auto-bank %s: %w", r.autoBankPath(), err) } return nil } // minedRejectFile is the owner's mined-term reject list (Book.MinedRejects, R1-FL-B): the src surfaces the // owner reviewed and DECLINED. It is a PROPOSAL filter only — rejects never enter the bank content, so this // file is deliberately NOT folded into the snapshot (a reject affects the next mining PROPOSAL, not any // checkpoint's wire/verdict). type minedRejectFile struct { Rejects []minedReject `yaml:"rejects"` } // minedReject is one declined mined term. Note is the owner's optional reason, ignored by the miner but // kept so a reject list stays self-documenting (six months on, "why was this declined"). type minedReject struct { Src string `yaml:"src"` Note string `yaml:"note,omitempty"` } // loadMinedRejects reads Book.MinedRejects and returns the normalized src set the emission excludes (like // the seed surfaces). Empty path → nil (no rejects). 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). func (r *Runner) loadMinedRejects() (map[string]bool, error) { if r.Book.MinedRejects == "" { return nil, nil } raw, err := os.ReadFile(r.Book.MinedRejects) if err != nil { return nil, fmt.Errorf("pipeline: read mined-rejects %s: %w", r.Book.MinedRejects, err) } var f minedRejectFile if err := yaml.Unmarshal(raw, &f); err != nil { return nil, fmt.Errorf("pipeline: parse mined-rejects %s: %w", r.Book.MinedRejects, err) } rejects := map[string]bool{} for _, rj := range f.Rejects { if nk := text.NormalizeSourceKey(rj.Src); nk != "" { rejects[nk] = true } } return rejects, nil } // 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) { if r.Book.MinedDelta == "" { return nil, nil } entries, err := membank.LoadGlossarySeed(r.Book.MinedDelta) if err != nil { return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err) } for i := range entries { entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded) } return entries, nil }