textmachine/backend/internal/pipeline/mining.go

898 lines
47 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

package pipeline
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"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 marked ⟨проверить⟩. 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, 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 := 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,
// 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(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 rides to the editor marked ⟨проверить⟩)",
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).
Conventions int
Signals []string
Invented bool
Conf int
Contradicts []string
}
// 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
}
// 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 {
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,
Conf: confOrAbsent(tres.Conf, c.Key), Contradicts: tres.Contradictions[c.Src],
}
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
}
// confOrAbsent reads the role's stated confidence for a key, 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.
func confOrAbsent(conf map[string]int, key string) int {
if v, ok := conf[key]; ok {
return v
}
return -1
}
// 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 · 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)")
}
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.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 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) {
// 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, 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 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 {
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.
//
// 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 {
entries, err := membank.LoadGlossarySeed(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)
}
}
// 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, err := membank.LoadGlossarySeed(r.Book.MinedDelta)
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
}