textmachine/backend/internal/pipeline/mining.go

510 lines
26 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"
"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 (D39.36 fix): the banknote proposals the draft wave collected are read off the durable
// per-chunk rows. They arrive as EVIDENCE — nothing proposed enters the bank without a signature. A read
// failure degrades to the previous WHICH-only map rather than blocking the stop: the map is what the
// owner needs, the dst is a bonus.
var observed []terminology.Observed
if states, serr := r.Store.RetrievalStatesForBook(r.Book.BookID); serr != nil {
r.Log.WarnContext(ctx, "bank-mining: could not read the banknote proposals; the signature map falls back to WHICH-only (bare terms)", "err", serr)
} else {
observed = bankObservedByKey(states)
}
// 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, tres, err := r.runTerminologist(ctx, draftSnapshot, cands)
if err != nil {
return false, err
}
r.lastTerminology = tres
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
}