161 lines
7.9 KiB
Go
161 lines
7.9 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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. Signing still goes through
|
|
// the ratified stop mechanics (promote into the mined-delta file / decline in the mined-rejects file),
|
|
// because the pipeline REPLACES a book's whole glossary from its deterministic inputs on every run and a
|
|
// row edit 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"`
|
|
Terms []BankExportTerm `json:"terms"`
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// The four parts are LENGTH-PREFIXED rather than separator-joined. The first version used U+001F and
|
|
// asserted in a comment that it "cannot occur in any of them" — an assumption about data the engine never
|
|
// validates: `src` and `sense` are free text copied from a seed YAML, and nothing on the load path
|
|
// rejects a control character. Length-prefixing makes the encoding injective by construction, so the
|
|
// claim does not have to be true (and does not have to be re-checked when a new seed source appears).
|
|
// 16 hex chars of SHA-256 is 64 bits, which for a bank of thousands of rows makes a collision an
|
|
// irrelevance rather than a risk taken on purpose.
|
|
func bankTermID(src, sense string, since, until int) string {
|
|
var b strings.Builder
|
|
for _, part := range []string{src, sense, strconv.Itoa(since), strconv.Itoa(until)} {
|
|
fmt.Fprintf(&b, "%d:%s", len(part), part)
|
|
}
|
|
sum := sha256.Sum256([]byte(b.String()))
|
|
return hex.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
r.Log.InfoContext(ctx, "bank export refreshed", "at", at, "path", r.bankExportPath(),
|
|
"terms", exp.Total, "approved", exp.Signed)
|
|
}
|