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, indistinguishable there from a signed one since D39.104 п.2. Deciding every term is // NOT what clears the stop, and leaving terms undecided never re-arms it; only new terms do. Mining is // OFF (auto-continue) unless a langpack AND a contrast artifact are both configured — so every $0 test / // the golden fixture (no contrast) auto-continues. // signatureMapPath is where the bank-mining stop writes the mined-delta YAML for owner sign — beside the project DB, so it // travels with the book state (never in git, like the DB). Deterministic (no time/rand). func (r *Runner) signatureMapPath() string { return signatureMapPath(r.Book.ProjectDB) } // signatureMapPath is the same path off a bare project-DB path, for the callers that have a config and // no Runner (bank-apply builds none — see bankdecisions.go). func signatureMapPath(projectDB string) string { return projectDB + ".mined-signature.yaml" } // runBankMiningStop executes the bank-mining stop. Returns stopped=true (with the signature map written, // the presented memory updated and r.lastMinedCount set) when `--verify-bank` is raised, an edit wave // exists to stop before, AND the delta holds a cluster no earlier stop has presented; stopped=false — // with the auto wire run on a non-empty delta — in every other case: mining unconfigured, delta empty, // flag down, draft-only, or nothing new to the memory. $0 to providers (the detector is deterministic + // offline). func (r *Runner) runBankMiningStop(ctx context.Context, chunks []chunk.Chunk, draftSnapshot string, editWave bool) (stopped bool, err error) { if r.pack == nil || r.Pipeline.Mining.ContrastPath == "" { // FAIL LOUD when the operator ASKED to verify the bank and the book cannot mine one. Silently // continuing would hand back a clean exit code for a verification that never happened — the worst // possible answer to «остановись и покажи мне банк». if r.VerifyBank { return false, fmt.Errorf("pipeline: --verify-bank was passed but this book cannot mine a bank: it needs BOTH a langpack (book.yaml `langpack_root`, currently %s) and a mining contrast artifact (pipeline.yaml `mining.contrast_path`, currently %q); without them there is no candidate list to verify", packStateLabel(r.pack), r.Pipeline.Mining.ContrastPath) } return false, nil // mining not configured → auto-continue to the edit wave } f, err := os.Open(r.Pipeline.Mining.ContrastPath) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop open mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err) } defer f.Close() contrast, err := miner.LoadContrast(f) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop load mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err) } // The miner works over the memnorm-normalized SOURCE of every chunk (the candidate space matches the // glossary/GT space). Deterministic; the annotation/blurb rule is handled inside miner.MineBank's filters. minerChunks := make([]miner.Chunk, len(chunks)) for i, ch := range chunks { minerChunks[i] = miner.Chunk{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, NSource: text.NormalizeSourceKey(ch.Text)} } seed, err := r.Store.GlossaryForBook(r.Book.BookID) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop read glossary for mining: %w", err) } // The owner's reject list excludes declined terms from the emission (R1-FL-B): a term the owner reviewed // and rejected is dropped from the delta exactly like a seed surface — it never enters a map again. The // exclusion is about the DELTA's content, not about the stop: whether the run stops is the presented // memory's question alone (D39.144 — deciding terms is a right, not what the stop waits for). rejects, err := r.loadMinedRejects() if err != nil { return false, err } // Risk 2 (self-exclusion): the engine's OWN unsigned rows must not read as seed surfaces, or the auto // mode silently switches the stop off after its first run — see unsignedEngineSurfaces. mined, emission := miner.MineBankStats(minerChunks, contrast, unsignedEngineSurfaces(seed), rejects, miner.FrozenConfig(), r.pack) // The WHAT side: the banknote proposals the draft waves collected, over every sampling of the book that // exists. They arrive as EVIDENCE — nothing proposed enters the bank without a signature. A read failure // degrades to the WHICH-only map rather than blocking the stop: the map is what the owner needs, the // dst is a bonus. // The parse rule is LOGGED with the run, which is the whole contract that lets it stay out of the // snapshot: it cannot change a paid byte, but it decides which candidate lines became evidence, so a // signature map has to be attributable to the rule that produced it. Until the fix-pack the constant was // declared "logged with the run" and read by nothing — the tag was a comment, not a mechanism, and two // artifacts produced by different rules were indistinguishable. observed, offLanguage, oerr := r.bankObservedForBook() if oerr != nil { r.Log.WarnContext(ctx, "bank-mining: could not read the banknote proposals; the signature map falls back to WHICH-only (bare terms)", "err", oerr) } r.Log.InfoContext(ctx, "bank-mining: draft-side proposals folded", "book", r.Book.BookID, "surfaces", len(observed), "parse_version", bankParseVersion, "slice_version", bankSliceVersion) if offLanguage > 0 { r.Log.WarnContext(ctx, "bank-mining: draft-side proposals were written in another script and are NOT offered for signature", "book", r.Book.BookID, "dropped", offLanguage, "target_script", r.Pipeline.Gates.Terminology.TargetScript) } // The TERMINOLOGIST (pack-20, D39.42): merge both channels, gather each candidate's source contexts, // rank the renderings the drafts produced (§C2-3), and — when the gate is on — consolidate the whole // bank in a handful of batched calls. With the gate off this is $0 assembly, but NOT a no-op for the // artifact: since the fix-pack the delta's dst comes from these ranked, target-form-folded candidates // rather than from the raw draft-side order, so a term whose drafts disagreed can carry a different // proposal than it did before — see proposalsFromCandidates. That is bank CONTENT, so it moves // memory_version and the edit-wave snapshot with it (bank-only → $0 re-pin for every unit the term does // not occur in). The draft wave is untouched: these rows are Source:"mined" and base-excluded. tchunks := make([]terminology.Chunk, len(minerChunks)) for i, c := range minerChunks { tchunks[i] = terminology.Chunk{Chapter: c.Chapter, ChunkIdx: c.ChunkIdx, NSource: c.NSource} } cands := r.buildBankCandidates(mined, observed, tchunks) // The REVERSE section: surfaces only the draft side named. The miner cannot see them by construction — // a cluster touching a seed surface is suppressed as an alias-of-existing — so this is where the // measured 3% channel overlap actually leaks. They join the delta only when the role is on (an // unconsolidated reverse row would be a bare surface with no evidence), and only when the SOURCE // actually contains them: a term a draft invented is not a bank term. if r.Pipeline.Gates.Terminology.Enabled { // The SAME surface filter as the miner's (risk 2): the engine's own unsigned rows must not read as // "already banked" here either, or the reverse section would empty itself after its first run — and, // worse, the delta would differ between run 1 and run 2, moving the edit-wave snapshot and re-billing // a wave over nothing. Determinism across runs is what makes the auto mode resumable at all. reverse, eligible := reverseSectionTerms(cands, unsignedEngineSurfaces(seed), rejects) if eligible > len(reverse) { r.Log.WarnContext(ctx, "bank-mining: the reverse section is capped like the miner's own emission; the tail is NOT in this signature map and re-proposes on the next run once these are signed or declined", "book", r.Book.BookID, "eligible", eligible, "kept", len(reverse)) } mined = append(mined, reverse...) sort.Slice(mined, func(i, j int) bool { return mined[i].Src < mined[j].Src }) } r.lastMinedCount = len(mined) if len(mined) == 0 { // G10 (polygon package seven): an empty delta must never READ as "this book is clean" when it means // "the alphabet was full and the emission layer cut all of it". The funnel is printed with the // verdict so the two are distinguishable at a glance, and `--verify-bank` — the mode whose whole // promise is «остановись и покажи мне банк» — says it out loud rather than at info level. msg := "bank-mining: empty delta, auto-continuing to the edit wave" args := append([]any{"book", r.Book.BookID}, emissionArgs(emission, cands)...) if r.VerifyBank && emission.Ranked > 0 { r.Log.WarnContext(ctx, msg+": the detector RANKED candidates and the emission layer cut every one of them — this is not the same as a book with no new terms", args...) } else { r.Log.InfoContext(ctx, msg, args...) } return false, nil } consolidated, classified, gendered, tres, err := r.runTerminologist(ctx, draftSnapshot, cands) if err != nil { return false, err } r.lastTerminology = tres mined = attachClassifiedType(mined, classified) mined = attachClassifiedGender(mined, gendered) mined = attachConsolidatedDst(mined, consolidated) // Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave. proposals := proposalsFromCandidates(cands) withDst := 0 for _, m := range mined { if m.Dst != "" || len(proposals[text.NormalizeSourceKey(m.Src)]) > 0 { withDst++ } } // The map rides the seam envelope (seed.SignatureMap) and is REPLACED atomically: the other side // reads this file, and the platform's operating rule for non-atomic sidecars is «на живом прогоне // брать нельзя» — a rule the signing surface cannot live under. Written before the stop decision // below, because the memory write depends on the map being on disk first (never the reverse: a // remembered surface whose map was never written is a silent loss). mapBytes, err := membank.RenderSignatureMap(miner.DeltaFile(mined, proposals)) if err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop render signature map: %w", err) } if err := writeFileAtomic(r.signatureMapPath(), mapBytes); err != nil { return false, fmt.Errorf("pipeline: the bank-mining stop write signature map %s: %w", r.signatureMapPath(), err) } // The RICH table (D39.36's «стоп с таблицей»: src · dst · frequency · variant spread · evidence). It is // written as a sidecar on EVERY run, signed or not, because it is also the auto mode's record of what // the book decided on its own — and it is capped on stdout, never in the file (emitRankCap is 200). rows := bankStopRows(cands, consolidated, tres) if werr := os.WriteFile(r.bankStopTablePath(), []byte(renderBankStopTable(rows)), 0o644); werr != nil { r.Log.WarnContext(ctx, "bank-mining: could not write the stop table sidecar (the signature map is unaffected)", "err", werr) } // THE FLAG (D39.42 п.5 · D39.144, owner's words 26.08: «стоп на банке памяти — это просто флажок»). // Two decisions, and they are DIFFERENT decisions: // - STOP OR NOT is the flag's question alone. Raised, it stops before the edit wave when the map // holds a cluster no earlier stop has presented (the presented memory, store v16) — and only // then: a flag that re-stopped on everything still undecided would turn «остановиться один раз» // into a march over per-term decisions the product does not have. A DRAFT-ONLY pipeline never // stops either way: there is no edit wave for the stop to sit before, and stopping there would // discard the assembled BookResult of an already-paid draft wave (S16). // - CARRY THE UNSIGNED ROWS FORWARD is every non-stopping run's duty, whatever the flag's // position: the model's promise «неподписанные строки едут редактору с пометкой» has to hold // exactly in the raised-flag runs that do not stop, or raising the flag would quietly switch // the auto wire off for the life of the book. stopping := false if r.VerifyBank && editWave { presented, merr := r.Store.StopPresentedSurfaces(r.Book.BookID) if merr != nil { return false, fmt.Errorf("pipeline: the bank-mining stop read the presented memory: %w", merr) } stopping = hasUnpresentedCluster(mined, presented) if !stopping { // The state that could not exist before the memory: a NON-EMPTY delta under a raised flag, // with no stop. Said out loud, because to an operator who raised the flag and got no pause, // silence is indistinguishable from mining being unconfigured. r.Log.WarnContext(ctx, "bank-mining: --verify-bank is raised and the delta is non-empty, but every cluster in it was already presented by an earlier stop — continuing (the map is rewritten and stays decidable via `tmctl bank-apply` at any time)", append([]any{"book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath()}, emissionArgs(emission, cands)...)...) } } else if r.VerifyBank { r.Log.WarnContext(ctx, "bank-mining: --verify-bank has nothing to stop before in a draft-only pipeline (no edit wave); the signature map and table are written and the run continues", "book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath()) } if !stopping { // THE AUTO WIRE (D39.42 п.3). The unsigned rows are persisted and folded back into the bank right // here, before the edit-wave snapshot is computed — so this run's editor actually receives them, in // the same law block as the signed rows since row 134, and the next run's drafts do too. Writing the file and // re-seeding through the ordinary seedGlossary path (rather than a second, private write) is what // keeps ONE definition of what the bank is: every guard the seed path owns — the collision checks, // the reject filter, the fail-louds — applies to the engine's rows exactly as to the owner's. if err := r.writeAutoBank(ctx, mined, proposals, ownerHandled(unsignedEngineSurfaces(seed), rejects)); err != nil { return false, err } if err := r.seedGlossary(ctx); err != nil { return false, fmt.Errorf("pipeline: re-seed the bank with the auto rows: %w", err) } // The bank just changed (row 125): the auto rows are IN it now, so the read-out has to say so // before the edit wave starts translating against them. r.exportBank(ctx, "bank-mining/auto-continue") msg := "bank-mining: auto-continuing with an UNSIGNED bank (pass --verify-bank to stop and review it)" switch { case r.VerifyBank && editWave: msg = "bank-mining: continuing with an UNSIGNED bank (--verify-bank is raised; nothing in the delta is new to it)" case r.VerifyBank: // Draft-only: the novelty predicate never ran (there is no edit wave to stop before), so the // message must not claim it did. msg = "bank-mining: continuing with an UNSIGNED bank (--verify-bank is raised, but a draft-only pipeline has no edit wave to stop before)" } r.Log.InfoContext(ctx, msg, append([]any{"book", r.Book.BookID, "terms", len(mined), "terms_with_proposed_dst", withDst, "auto_bank", r.autoBankPath(), "signature_map", r.signatureMapPath(), "table", r.bankStopTablePath()}, emissionArgs(emission, cands)...)...) return false, nil } r.lastBankStopRows = rows // The signature stop is the boundary the signing screen reads at (row 125): refresh the bank read-out // so the state behind the decisions is the state as of this stop, not as of the last run. r.exportBank(ctx, "bank-mining/signature-stop") // THE MEMORY, and strictly after the map: a crash between the two costs one benign re-stop, while // the reverse order can mark surfaces presented whose map never reached disk — a silent loss with no // retry. The same asymmetry decides the failure branch: the stop STANDS when the memory write fails // (the map is on disk and the pause is real; the next run re-stops once more and re-writes), because // failing the run here would turn a lawful pause into a crash over a bookkeeping row. if merr := r.Store.MarkStopPresented(r.Book.BookID, presentedSurfaces(mined)); merr != nil { r.Log.ErrorContext(ctx, "bank-mining: could not record the presented memory; the NEXT --verify-bank run will stop on this same map again (one extra stop, nothing lost)", "book", r.Book.BookID, "err", merr) } r.Log.WarnContext(ctx, "bank-mining: the delta holds terms no stop has shown before; run STOPPED before the edit wave (review the signature map, decide what you wish via `tmctl bank-apply` — one act over the whole bank, one term, or nothing — then resume: the run continues and will not re-stop on these terms, and whatever you leave undecided is used on the wire like any signed row — D39.104, the bank is law for every row; a wrong one is corrected by a bank edit and a re-edit)", append([]any{"book", r.Book.BookID, "terms", len(mined), "terms_with_proposed_dst", withDst, "signature_map", r.signatureMapPath()}, emissionArgs(emission, cands)...)...) return true, nil } // clusterSurfaces is a map term's cluster as the emission compares surfaces: the normalized src plus // every normalized alias, blanks dropped. func clusterSurfaces(t miner.Term) []string { out := make([]string, 0, 1+len(t.Aliases)) if nk := text.NormalizeSourceKey(t.Src); nk != "" { out = append(out, nk) } for _, a := range t.Aliases { if nk := text.NormalizeSourceKey(a); nk != "" { out = append(out, nk) } } return out } // hasUnpresentedCluster is the stop predicate of the D39.144 flag model: does the map hold a term whose // WHOLE cluster is outside the presented memory? // // The domain is the FINAL map content — both sections, after every cap — never the pre-cap pool: a // surface the cap keeps permanently invisible would otherwise hold «есть новое» true on every run // forever, turning the flag into a stop that never goes out. // // Intersection over the cluster rather than a lookup of the representative, because the representative // can change: as the book grows, the first-ranked member of a cluster may become an alias of the same // entity, and a single-surface memory would re-stop on a term the owner has already seen. The residues // are two-sided and both accepted (the report names them): an entity returning under a spelling the // memory has never held costs one extra stop (benign); a genuinely NEW entity sharing an alias surface // with a presented cluster reads as seen and is not presented — it still reaches the editor as an // unsigned auto row, but the stop stays silent about it. func hasUnpresentedCluster(mined []miner.Term, presented map[string]bool) bool { for _, t := range mined { seen := false for _, s := range clusterSurfaces(t) { if presented[s] { seen = true break } } if !seen { return true } } return false } // presentedSurfaces flattens the map's clusters into the rows the memory stores — every surface of // every term, both sections, exactly the set hasUnpresentedCluster will compare the next map against. func presentedSurfaces(mined []miner.Term) []string { var out []string for _, t := range mined { out = append(out, clusterSurfaces(t)...) } return out } // emissionArgs renders the WHICH-funnel as log fields. It rides EVERY verdict of the stop, not only the // empty one (G10 asks for the empty case; the same numbers answer "why so few?" on a non-empty delta, and // they are the only place the top-200 keyhole is visible at all). Deterministic, $0 — the counters are read // off the pass that already ran. func emissionArgs(e miner.EmissionStats, cands []terminology.Candidate) []any { draftSide := 0 for _, c := range cands { if c.Origin != terminology.OriginMined { draftSide++ } } return []any{ "ranked_alphabet", e.Ranked, "after_rank_cap", e.AfterCap, "eligible", e.Eligible, "skipped_as_alias_of_seeded", e.SeedSkipped, "skipped_as_declined", e.Rejected, "emitted_by_miner", e.Emitted, "draft_side_candidates", draftSide, } } // packStateLabel describes the langpack state for the --verify-bank fail-loud, so the operator is told // WHICH of the two preconditions is missing rather than "it did not work". func packStateLabel(p *lang.Pack) string { if p == nil { return "absent" } return "loaded (" + p.Pair + ")" } // bankStopTablePath is the sidecar holding the FULL stop table. It sits beside the signature map (and the // DB) so it travels with the book state and never enters git, and it is a separate artifact from the // signature map because that map must stay a loadable seed YAML: evidence in it would be schema noise // (§C2-7 — evidence belongs in the sign-map sidecar, not the seed schema). func (r *Runner) bankStopTablePath() string { return r.Book.ProjectDB + ".bank-stop.txt" } // BankStopRow is one row of the bank-verification table the stop shows the operator — the table D39.36 // specified and the CLI never had (it printed a term count and a path). Exported because the CLI renders it. type BankStopRow struct { Src string Dst string // the consolidated rendering, or "" when nothing was consolidated Origin string // mined | banknote | both — WHICH channel found it Type string Freq int // occurrences in the source Spread int // how many DISTINCT renderings the drafts produced (the disagreement signal) Variants []BankStopVariant // the renderings the drafts produced, best-ranked first Contexts []string // source KWIC Evidence []string // The §G3 arbitration record: until this pack, «why does this term have THIS dst» was unanswerable from // the artifacts (research/24 §A7). Every field below is read off work the ranking already did. // // Conventions is how many genuinely different DECISIONS the drafts made, once renderings differing only // in target form are folded (Spread counts the raw forms). Signals are the §C2-3 factors that fired for // the TOP-ranked variant — the winner's audit trail, printed for the row rather than per variant, // because the row is what the owner signs. Invented says the consolidated rendering is NOT one the // drafts proposed: legitimate (the role sees the whole book, the drafts saw fragments) and exactly the // class to read first. Conf is the role's own stated confidence, which orders the review list and // nothing else (D39.102) — NEGATIVE when the reply carried none, because «the role said it was 0% sure» // is the most important row on the sheet and «the role said nothing» is not a row at all. // Contradicts names THIS RUN's other consolidations the rendering breaks (§G2). 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, dropped, err := membank.LoadEngineGlossarySeed(r.autoBankPath()) r.warnDroppedRows("auto-bank", r.autoBankPath(), dropped) if err != nil { return nil, nil, fmt.Errorf("pipeline: load auto-bank %s: %w", r.autoBankPath(), err) } rejects, err := r.loadMinedRejects() if err != nil { return nil, nil, err } for _, e := range entries { if e.Status == "approved" { return nil, nil, fmt.Errorf("pipeline: auto-bank %s carries an `approved` row (%q → %q): this file is the ENGINE's unsigned proposals, and nothing in it may claim a signature — move the term into the owner's mined-delta file instead", r.autoBankPath(), e.Src, e.Dst) } } type ukey struct { src, sense string since, until int } held := map[ukey]store.GlossaryEntry{} for _, e := range 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 { // The drop is reported by loadAutoBank, which reads the same file on the paid path; saying it twice // per run would make the warning noise instead of news. entries, _, err := membank.LoadEngineGlossarySeed(r.autoBankPath()) if err != nil { return nil } out := make([]string, 0, len(entries)) for _, e := range entries { if e.Src != "" { out = append(out, e.Src) } } return out } // loadMinedRejects reads Book.MinedRejects and returns the normalized src set the emission excludes (like // the seed surfaces). Each src is normalized via text.NormalizeSourceKey so a reject matches the miner's // normalized candidate surface whichever orthographic form the owner pasted from the signature map; a // blank src is skipped (a stray list entry must not silently match everything). // // An ABSENT file is "no rejects": the path is conventional and only conventional (config.Book.MinedRejects), // so absence means nobody has declined anything yet (D39.156 п.3). Unreadable stays a loud error. func (r *Runner) loadMinedRejects() (map[string]bool, error) { present, err := decisionFilePresent(r.Book.MinedRejects) if err != nil || !present { return nil, err } raw, err := os.ReadFile(r.Book.MinedRejects) if err != nil { return nil, fmt.Errorf("pipeline: read mined-rejects %s: %w", r.Book.MinedRejects, err) } f, err := seed.DecodeRejects(raw) if err != nil { return nil, fmt.Errorf("pipeline: parse mined-rejects %s: %w", r.Book.MinedRejects, err) } return rejectSurfaces(f), nil } // rejectSurfaces is the reject list as the emission reads it: normalized src keys, blanks dropped. One // definition, because `bank-apply` asks the same question about the same file and two spellings of // "which surfaces has the owner declined" would eventually answer differently. func rejectSurfaces(f seed.RejectFile) map[string]bool { out := map[string]bool{} for _, rj := range f.Rejects { if nk := text.NormalizeSourceKey(rj.Src); nk != "" { out[nk] = true } } return out } // decisionFilePresent reports whether one of the two owner-decision files is there to be read. // // The paths are conventional and only conventional (config.Book.MinedDelta), so an absent file is not // present and not an error: "nobody has decided anything yet", the same standing an absent project // database has before the first run (D39.156 п.3). Every other stat failure stays loud — an unreadable // file is not an undecided one. func decisionFilePresent(path string) (bool, error) { _, err := os.Stat(path) switch { case err == nil: return true, nil case errors.Is(err, fs.ErrNotExist): return false, nil default: return false, fmt.Errorf("pipeline: %s is not readable: %w", path, err) } } // warnDroppedRows says out loud that the wire fence removed a row from a document THIS ENGINE WROTE. // // ⛔ A DROP THAT NOBODY CAN READ IS THE WORSE HALF OF THE FIX. Refusing an engine-written document killed // a paid run over our own output, so the row is dropped instead (membank.ParseEngineBankSeed) — but a // mined term that vanishes from every request with no trace leaves an operator asking why a term they // can see in the file is not applied, and nothing to answer with. So the loader REPORTS what it removed // and this is where the engine speaks, beside the DECLINED-term warning it is modelled on. // // Warn and not Error: the run is correct and complete without the row, and the fault is in a model's // answer rather than in anything the operator did. func (r *Runner) warnDroppedRows(kind, path string, dropped []string) { if len(dropped) == 0 { return } r.Log.Warn("wire fence removed term(s) from an engine-written bank document; they are NOT entering the bank and NOT reaching any request (the model's answer carried a rune that can write into a system message)", "book", r.Book.BookID, "document", kind, "path", path, "dropped", strings.Join(dropped, ", "), "count", len(dropped)) } // loadMinedDelta reads the owner-curated mined-delta YAML (book.MinedDelta) and stamps every entry // Source:"mined" — NOT via membank.LoadGlossarySeed (which hardcodes Source:"seed", memseed.go, moving the base // bank / draft-wave snapshot). This is the mined-write path (plan §1(в), F2): the mined terms land in the ENRICHED // bank version but NOT the base, so adding them moves ONLY edit-wave snapshot ("re-paying ONCE"). Reuses // membank.LoadGlossarySeed's parser/validation, then re-stamps the Source. Empty path → nil (no mined terms). func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) { // The same absence rule the reject list follows. present, err := decisionFilePresent(r.Book.MinedDelta) if err != nil || !present { return nil, err } entries, dropped, err := membank.LoadEngineGlossarySeed(r.Book.MinedDelta) r.warnDroppedRows("mined-delta", r.Book.MinedDelta, dropped) if err != nil { return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err) } // A DECLINED surface never enters the bank, even from the delta. The two decision documents are ONE // state, and a surface on the reject list is decided-against; the delta is the only door through // which a decided-against surface could still reach a paid run, so it is closed here. // // In ordinary operation the two documents cannot both hold a surface — applying a decline drops the // delta row and records the reject in ONE act. The state exists anyway, by two routes, and both end // in the editor's glossary if this filter is absent: a hand-edited delta, and the half-state a // process killed between the two renames leaves behind. The second is why this sits beside the // rejects-first rename order (writeDecisionFiles): that order is what lets an interrupted decline's // re-send converge, and it does so by leaving the delta row on disk — so without this filter the // convergence would have been bought with a window in which a paid run injects a term the owner // explicitly declined. Loud, because a delta row and a reject for one surface means the two // documents disagree and somebody should look. rejects, rerr := r.loadMinedRejects() if rerr != nil { return nil, rerr } if len(rejects) > 0 { kept := entries[:0:0] var declined []string for _, e := range entries { if rejects[text.NormalizeSourceKey(e.Src)] { declined = append(declined, e.Src) continue } kept = append(kept, e) } if len(declined) > 0 { r.Log.Warn("mined-delta holds term(s) the owner has DECLINED; they are NOT entering the bank (the two decision documents disagree — a hand edit, or a write interrupted between the two files)", "book", r.Book.BookID, "declined", strings.Join(declined, ", "), "mined_delta", r.Book.MinedDelta, "mined_rejects", r.Book.MinedRejects) } entries = kept } for i := range entries { entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded) } return entries, nil }