513 lines
27 KiB
Go
513 lines
27 KiB
Go
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
|
||
}
|