package pipeline import ( "context" "encoding/json" "sort" "textmachine/backend/internal/membank" "textmachine/backend/internal/obs" "textmachine/backend/internal/store" ) // bankexport.go: the BANK EXPORT artifact (backlog row 125) — the engine's read-out of the whole memory // bank, all three statuses, as a file beside the project DB. // // It exists because the bank lives in the engine's PRIVATE SQLite and the platform must not read that // store (D39.85), so a read channel for it had no carrier at all. It is deliberately NOT the same thing // as the bank-stop table (row 101): that one is the set of DECISIONS put in front of the owner at one // stop — proposals, evidence, why-this-rendering — and it exists only for a run that mined something. // This one is the STATE of the bank as it stands: every row the book has, including seed rows nobody // ever had to decide on, and it exists for every book with a bank whether anything was mined or not. // // It is a projection, never a source: nothing reads it back into the engine. Deciding about terms goes // through the `tmctl bank-apply` door into the two decision files (D39.156), because the pipeline // REPLACES a book's whole glossary from its deterministic inputs on every run and a row edit here would // be erased by the next one. // bankExportVersion versions the SHAPE of the document (not its content), so a consumer that has to // tolerate the field set changing has something to branch on. const bankExportVersion = "tm-bank-v1" // BankExport is the whole-bank read-out. type BankExport struct { Version string `json:"bank_version"` BookID string `json:"book_id"` // Total / Signed are whole-bank counters (not counters of some page): rows in the file, and rows of // those whose status is `approved` — the only status that is canon on the wire. Total int `json:"total"` Signed int `json:"signed"` // AsOf / RunID are the freshness anchor (fix3 §4.6). The write discipline below is loud-but-not-fatal, // so the artifact on disk MAY be a previous boundary's projection — and without these fields that // state was knowable only from a log line. AsOf names the boundary that produced THIS document // (run-start/seeded · bank-mining/auto-continue · bank-mining/signature-stop · run-finished · // redrive/re-seeded); RunID is the same id the run's event journal streams as engine_run_id, so a // consumer following events.jsonl tells «this run's projection» from «a stale one» by comparing ids. // Empty RunID means the write had no run identity (an in-process driver with no traced context) — // unknown, never «current». Additive on tm-bank-v1: absent in old documents, never invented. AsOf string `json:"as_of"` RunID string `json:"run_id"` Terms []BankExportTerm `json:"terms"` // Proposed is what a signature STOP is putting in front of the owner: rows this run mined and // consolidated that are NOT in the bank yet. Row 224 is the reason it exists — the stop refreshes this // artifact precisely so the signing screen can read it, and until now the refresh projected only the // glossary, whose mined rows are written in the auto-continue branch. So the one boundary opened FOR // these rows published a document guaranteed to be empty of them. // // ⚠ A SEPARATE SECTION, NEVER MERGED INTO Terms, and the separation is the money. Terms is the bank as // it stands; a proposal is not in it. Getting them into Terms would mean seeding them, and the seed // path injects unsigned rows as law (D39.104 §2 — both are injected, with no branch on status), which // moves memory_version and turns the stop into an auto-continue with a re-snapshot behind it. // // Empty at every boundary that is not a stop. Order is the stop's own RANKING, not a sort: which row // to read first is the information here, and the bank's diffable byte-stability belongs to Terms. Proposed []BankExportProposal `json:"proposed,omitempty"` // Consolidation is how COMPLETE this bank is — and ABSENT, never zeroed, when nothing measured it. // Backlog row 253(б): the engine has always known that a budget cut the pass and said so in its own // log, while the artifact the signing screen is built from carried no field for it, so an owner signed // a bank the engine itself calls partial. A pointer because the read-out is written at five boundaries // and the pass runs at one: a zeroed section would answer «consolidated 0, unanswered 0» — which reads // as «nothing is missing» — at the four boundaries that never asked. Consolidation *BankConsolidation `json:"consolidation,omitempty"` } // BankConsolidation is what the paid terminology pass MANAGED, in the fields a signing decision needs. It // is a projection of counters the pass already produced; nothing here re-derives completeness by a second // route, because two mechanisms answering one question is how they come to disagree. // // ⛔ THREE FACTS THAT LOOK ALIKE AND ARE NOT, which is why this is six fields and not one number: // - the RENDER pass was cut → terms have no consolidated rendering → the BANK is partial; // - the CLASSIFY pass was cut → the bank is whole and the term TYPES are unrefined. A different budget, // a different remedy, and reading it as an incomplete bank is a false alarm. On the run of 08.09 the // engine's own warning fired on exactly this: the render pass was intact and it still said «PARTIALLY // consolidated»; // - candidates the role was NEVER ASKED about because the bank already renders them — a saving, not a gap. type BankConsolidation struct { // Complete is the BANK's completeness and nothing else: every render batch the pass planned was bought. // It is published rather than left to the reader to derive, because deriving it means re-implementing // engine law on the far side of the seam — the thing the inbound law forbids (D39.156 п.6). Complete bool `json:"complete"` // RenderBatchesDropped is what makes Complete false. ClassifyBatchesDropped is the OTHER pass's cut, on // its OWN budget: two fields and never one sum, because an operator raises `budget_usd` or // `classify_budget_usd`, never «the» budget. RenderBatchesDropped int `json:"render_batches_dropped"` ClassifyBatchesDropped int `json:"classify_batches_dropped"` // Consolidated came back with a rendering; Declined the role explicitly could not render. Consolidated int `json:"consolidated"` Declined int `json:"declined"` // Unanswered is the role's silence AND the budget's cut TOGETHER — the engine's counter conflates them // and says so in the warning beside it: a term in a dropped batch was never offered, and lands here // looking exactly like one the role saw and skipped. It means «the role stayed silent» only where // Complete is true; where it is false, part of this number is the money and not the model. Unanswered int `json:"unanswered"` // NeverAsked is the opposite of a gap and must not be read as one: these candidates never reached the // paid role because the bank already renders the surface and every draft agreed with it. NeverAsked int `json:"never_asked"` // SettledEarlier is the OTHER way a candidate skips the paid role: this book decided its rendering in // an earlier purchase and the evidence has not moved since, so the settled basis answered it. // // ⛔ AN ADDITIVE FIELD RATHER THAN A WIDER `never_asked`, and the addition is the whole point. That // field's published meaning is «the seed holds the surface and every draft agreed» — a sentence a // consumer is entitled to act on — and both of its halves are FALSE for a row the basis answered. Adding // these rows to it would change what a shipped number means without changing its name, which is the // shape this engine refuses everywhere else. Consumers that do not know the new field keep reading the // old one correctly; that is what makes the change a minor one. SettledEarlier int `json:"settled_earlier"` } // projectBankConsolidation turns the pass's counters into the section. NIL IN, NIL OUT, and that is the // whole distinction the section exists to publish: a pass that did not run has not found the bank complete. func projectBankConsolidation(t *terminologyResult) *BankConsolidation { if t == nil { return nil } return &BankConsolidation{ Complete: t.BatchesDropped == 0, RenderBatchesDropped: t.BatchesDropped, ClassifyBatchesDropped: t.ClassifyBatchesDropped, Consolidated: t.Consolidated, Declined: t.Declined, Unanswered: t.Unanswered, NeverAsked: t.BankSettled, SettledEarlier: t.BasisServed, } } // BankExportProposal is one row of a stop's verification table in the fields a signing decision needs. // The KWIC contexts and the ranking evidence are NOT here: they are a review artifact of a thousand lines // and they already have a carrier of their own (the stop table beside the DB). type BankExportProposal struct { Src string `json:"src"` Dst string `json:"dst"` // the consolidated rendering, "" when nothing was consolidated Kind string `json:"kind"` // Channel is which detector found the row: mined | banknote | both. It is deliberately NOT called // `origin`: terms[].origin above is the row's PROVENANCE (seed|ruby|mined) and a consumer already maps // that vocabulary to its own. One word meaning two things inside one document is the exact confusion // the field comment on BankExportTerm.Origin refuses for `source`. Channel string `json:"channel"` Freq int `json:"freq"` Spread int `json:"spread"` // how many DISTINCT renderings the drafts produced // Conventions is how many of those are genuinely different DECISIONS, after renderings differing only in // case or spacing fold together — and it is the length of Variants below. // // ⛔ IT IS HERE BECAUSE ITS ABSENCE COST TWO READINGS. The printed stop table has always carried both // numbers on one line («spread=4 conventions=3»), but this JSON carried only `spread` beside a list whose // length is the OTHER number, with nothing naming it. Two sessions in a row therefore compared `spread` // to len(variants), found them unequal on 开窍大典, and filed it as two definitions of spread disagreeing. // They were reading one number against a different measurement, and the document gave them no way to see // that. A consumer now gets both, and the mismatch it used to look like is arithmetic it can check. Conventions int `json:"conventions"` // Conf is the role's stated confidence, NEGATIVE when the reply carried none: «the role said nothing» // and «the role said 0 %» are different rows on a signing sheet. Conf int `json:"conf"` // Invented says the rendering is not one the drafts proposed — legitimate, and the class to read first. // // ⚠ It is NOT set on a row the basis settled, and the absence there is a decision: the flag is a finding // about THIS run's drafts, and a rendering carried over from an earlier purchase was never offered to // them. Read `settled_earlier` beside it or the missing flag reads as «the drafts proposed this». Invented bool `json:"invented,omitempty"` // SettledEarlier says this book decided the rendering in an earlier purchase and the roles were not paid // to decide it again. It is NOT the same as the summary's `never_asked`, whose sentence — «the bank // renders the surface and every draft agreed» — is false of this row in both halves. SettledEarlier bool `json:"settled_earlier,omitempty"` // Contradicts names this run's OTHER consolidations the rendering breaks; BankHolds names rows the bank // already carries for the same firing surface with a different rendering. Two fields because "the run // disagreed with itself" and "the book already calls it something else" are different decisions. Contradicts []string `json:"contradicts,omitempty"` BankHolds []string `json:"bank_holds,omitempty"` // Variants are the drafts' renderings, best-ranked first, in the ONE labelling every human-facing table // uses (BankStopVariant.Label), so this section and the printed table cannot describe a variant // differently. Variants []string `json:"variants,omitempty"` } // BankExportTerm is one bank row. // // The field names follow the ENGINE's own vocabulary for the term's surfaces (`src`/`dst`) and call the // provenance column `origin`, because the engine's own column name for provenance is `source` and using // that word here would make `source` mean the term's text in one schema and its provenance in the other. type BankExportTerm struct { // ID is a STABLE key derived from the row's uniqueness key (src, sense, since_ch, until_ch) — NOT // glossary.id. That column is a fresh autoincrement on every bank replace (store/migrate.go v5), and // the bank is replaced on every run, so an exported autoincrement would re-point under a consumer // between two reads of the same unchanged term. Derived, it survives exactly as long as the term does. ID string `json:"id"` Src string `json:"src"` Dst string `json:"dst"` // "" for a candidate with no proposed rendering yet // Kind is the engine's `type` column and is LEGITIMATELY empty: a ruby candidate that is neither a // name nor a place carries none (membank/memseed.go). It is exported as the empty string rather than // omitted — "the engine did not decide" is a state the row still needs signing in, and a missing field // would invite a consumer to invent one. Kind string `json:"kind"` // Status is the three-valued signing state auto|draft|approved; only `approved` is injected as canon. Status string `json:"status"` // Origin is the provenance: which path created the row (seed|ruby|mined). It is an axis INDEPENDENT // of Status — a mined row can be approved, a seed row can be draft. Origin string `json:"origin"` Sense string `json:"sense"` SinceChapter int `json:"since_chapter"` UntilChapter int `json:"until_chapter"` Aliases []string `json:"aliases"` } // bankExportPath is where the read-out lands: beside the project DB, like the signature map, the stop // tables and the auto-bank. That is the book's state directory — it travels with the DB and never enters // git — and it is the one place a reader already has to know about. func (r *Runner) bankExportPath() string { return r.Book.ProjectDB + ".bank.json" } // bankTermID is the stable derived id (see BankExportTerm.ID). It lives in membank because the id is a // property of a BANK ROW, and two commands now have to agree on it byte for byte: the export that // publishes it and `bank-apply`, which resolves a caller's decision back to the row it names. Two copies // of the derivation would let a decision silently address a different term than the one on the screen. func bankTermID(src, sense string, since, until int) string { return membank.TermID(src, sense, since, until) } // projectBankExport turns stored rows into the document. Deterministic: rows are sorted by the same // uniqueness key the id is derived from, so two runs over an unchanged bank produce byte-identical files // (a consumer can diff them, and a no-op run is visibly a no-op). func projectBankExport(bookID string, rows []store.GlossaryEntry) BankExport { out := BankExport{Version: bankExportVersion, BookID: bookID, Total: len(rows), Terms: make([]BankExportTerm, 0, len(rows))} for _, e := range rows { if e.Status == "approved" { out.Signed++ } aliases := make([]string, 0, len(e.Aliases)) for _, a := range e.Aliases { aliases = append(aliases, a.Alias) } sort.Strings(aliases) // the store returns them in insert order; a read-out must not depend on it out.Terms = append(out.Terms, BankExportTerm{ ID: bankTermID(e.Src, e.Sense, e.SinceCh, e.UntilCh), Kind: e.Type, Src: e.Src, Dst: e.Dst, Status: e.Status, Origin: e.Source, Sense: e.Sense, SinceChapter: e.SinceCh, UntilChapter: e.UntilCh, Aliases: aliases, }) } sort.SliceStable(out.Terms, func(i, j int) bool { a, b := out.Terms[i], out.Terms[j] switch { case a.Src != b.Src: return a.Src < b.Src case a.Sense != b.Sense: return a.Sense < b.Sense case a.SinceChapter != b.SinceChapter: return a.SinceChapter < b.SinceChapter default: return a.UntilChapter < b.UntilChapter } }) return out } // projectBankProposals turns a stop's verification rows into the sidecar's proposed section. Order is // preserved, not sorted: the rows arrive best-ranked first and that ranking is what a reviewer reads down. func projectBankProposals(rows []BankStopRow) []BankExportProposal { if len(rows) == 0 { return nil } out := make([]BankExportProposal, 0, len(rows)) for _, row := range rows { p := BankExportProposal{ Src: row.Src, Dst: row.Dst, Kind: row.Type, Channel: row.Origin, Freq: row.Freq, Spread: row.Spread, Conventions: row.Conventions, Conf: row.Conf, Invented: row.Invented, Contradicts: row.Contradicts, BankHolds: row.BankHolds, SettledEarlier: row.SettledByBasis, } for _, v := range row.Variants { p.Variants = append(p.Variants, v.Label()) } out = append(out, p) } return out } // exportBank writes the read-out. `at` names the boundary that triggered it and rides the log line, so a // stale artifact can be traced to the moment it was last written rather than guessed at. // // FAILURE DISCIPLINE: a read-out that cannot be written is loud but never fatal. Killing a paid run // because a projection of state the run already holds could not be serialized would trade real money for // an artifact that the next boundary rewrites anyway. func (r *Runner) exportBank(ctx context.Context, at string) { rows, err := r.Store.GlossaryForBook(r.Book.BookID) if err != nil { r.Log.WarnContext(ctx, "bank export: could not read the bank; the export artifact was NOT refreshed and may be stale", "at", at, "path", r.bankExportPath(), "err", err) return } exp := projectBankExport(r.Book.BookID, rows) exp.AsOf, exp.RunID = at, r.exportRunID(ctx) // The stop's rows ride along (row 224). r.lastBankStopRows is set in the stopping branch and nowhere // else, and a stopped run returns before every later boundary, so this is non-empty exactly at a // signature stop. exp.Proposed = projectBankProposals(r.lastBankStopRows) // The completeness of what is being signed (row 253б). r.lastTerminology is nil until the paid pass has // run, so the section is absent at the boundaries that never measured it rather than zero there. exp.Consolidation = projectBankConsolidation(r.lastTerminology) body, err := json.MarshalIndent(exp, "", " ") if err != nil { r.Log.WarnContext(ctx, "bank export: could not marshal the bank; the export artifact was NOT refreshed and may be stale", "at", at, "path", r.bankExportPath(), "err", err) return } if err := writeFileAtomic(r.bankExportPath(), append(body, '\n')); err != nil { r.Log.WarnContext(ctx, "bank export: could not write the export artifact; it was NOT refreshed and may be stale", "at", at, "path", r.bankExportPath(), "err", err) return } // `approved` is a SUBSET of `terms`; `awaiting_signature` is disjoint from both by construction (a // proposal is not in the bank). Named apart so the three do not read as one proportion. r.Log.InfoContext(ctx, "bank export refreshed", "at", at, "path", r.bankExportPath(), "terms", exp.Total, "approved", exp.Signed, "awaiting_signature_outside_the_bank", len(exp.Proposed)) } // exportRunID is the identity the freshness anchor carries: the run-event stream's own id when the // journal is open (the id a reader of events.jsonl is already following — openEmitter may re-mint it, // so the emitter, not the context, is authoritative), else the context's trace id, else "" — a caller // with no traced context has no run identity, and the field must say so rather than invent one. func (r *Runner) exportRunID(ctx context.Context) string { if r.events != nil { return r.events.runID } if ri, ok := obs.ReqInfoFromContext(ctx); ok { return ri.TraceID } return "" }