1039 lines
52 KiB
Go
1039 lines
52 KiB
Go
package membank
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"textmachine/backend/internal/seed"
|
|
"textmachine/backend/internal/store"
|
|
"textmachine/backend/internal/text"
|
|
)
|
|
|
|
// decisions.go: the OWNER'S DECISIONS about the memory bank, as data the engine applies rather than as
|
|
// two files somebody edits in a text editor (D39.156, 17-seam-inbound-law п.1).
|
|
//
|
|
// The bank is the product's central value — one glossary of names and terms held consistent across a
|
|
// whole book — and until this door existed there was no way for anyone outside the engine to correct a
|
|
// term: the two files that carry a decision (mined_delta, mined_rejects) had no writer on ANY side, so
|
|
// every external participant had to impersonate a human operator with an editor. This file is the pure
|
|
// half of that door: current bank + current files + decisions → the files as they should be, or a list
|
|
// of refusals naming each one. No I/O, no clock, no randomness — the wiring (pipeline/bankdecisions.go)
|
|
// owns the lock, the store and the writes.
|
|
//
|
|
// TWO VERBS AND NO THIRD. `approve` promotes a term into the mined delta with an explicit
|
|
// `status: approved`; `decline` puts its surface on the reject list so the bank-mining stop stops
|
|
// re-proposing it. There is deliberately no "un-decide": a decision is REPLACED by the opposite one, and
|
|
// returning a term to the undecided state is not something the signing model has a use for.
|
|
|
|
// DecisionsVersion / DecisionsReportVersion version the SHAPE of the two documents this door speaks, so
|
|
// a caller that must tolerate the field set changing has something to branch on (17-seam-inbound-law
|
|
// п.3). Two constants and not one: the request and the report are different documents and are free to
|
|
// move at different times.
|
|
//
|
|
// report-v2: the `mode` vocabulary grew (`refused`, `write_incomplete` beside `apply`/`projection`/
|
|
// `stopped`) and the report carries per-file write truth (`written_delta`/`written_rejects`). The
|
|
// envelope exists to move when the shape does — leaving it at v1 would make it decoration.
|
|
const (
|
|
DecisionsVersion = "tm-bank-decisions-v1"
|
|
DecisionsReportVersion = "tm-bank-decisions-report-v2"
|
|
)
|
|
|
|
// DecisionDepth names how far into the pipeline an accepted decision reaches, and it is a FIELD rather
|
|
// than a comment because the answer is not the one a user assumes. A decision travels through the mined
|
|
// delta, which is Source:"mined" and therefore excluded from the BASE bank the draft wave selects over
|
|
// (pipeline/seeding.go) — so it reaches the EDITOR and does not re-form the draft. Making the draft
|
|
// itself carry a corrected term means moving glossary_seed, which shifts the base snapshot and re-pays
|
|
// the whole draft wave; that is a different door and it is not designed yet (backlog row 192).
|
|
const DecisionDepth = "edit_wave"
|
|
|
|
// The two actions.
|
|
const (
|
|
ActionApprove = "approve"
|
|
ActionDecline = "decline"
|
|
)
|
|
|
|
// Decision is one owner decision about one term.
|
|
//
|
|
// Its vocabulary is the PUBLISHED IDENTITY AND RENDERING of a bank row, minus the axes that have no
|
|
// producer, plus the one self-documentation field. Spelled out, because the shorter slogan "the input
|
|
// vocabulary is the output vocabulary" is not true of it and a later session correcting the code to
|
|
// match that slogan would break the door in both directions:
|
|
//
|
|
// - a term that EXISTS is named by the read-out's stable `id`; a term that does not is named by the
|
|
// full tuple that id is derived from. Those are the published identity;
|
|
// - `dst` and `kind` are the published rendering and classification — the two things a decision is
|
|
// actually about;
|
|
// - `note` is accepted and NOT published (bankexport.go carries no note field). It is the durable
|
|
// channel for the owner's reasoning, and it is what survives the file being re-rendered;
|
|
// - `aliases` are published and NOT accepted, deliberately: the alias set is the miner's own cluster
|
|
// and a decision has no opinion about it, so a promotion CARRIES IT FORWARD unchanged rather than
|
|
// inviting a caller to re-state it (see termFromBank);
|
|
// - `gender`, `speech` and `decl` are neither. They are seed-only with no automatic producer at all
|
|
// (backlog row 210), and their production fork is reserved — a door for them would be a door onto
|
|
// an axis that does not work, and would pre-empt a decision that has not been taken.
|
|
//
|
|
// ⚠ ONE NARROWNESS OF v1, named rather than hidden: a term's chapter WINDOW cannot be corrected in one
|
|
// decision. Approving the new tuple adds a second row and leaves the old one standing, and declining
|
|
// removes every window of the surface. Widening or moving a window is a two-call operation for now.
|
|
type Decision struct {
|
|
Action string `json:"action"`
|
|
// ID is the bank read-out's stable id of an EXISTING row. Mutually exclusive with the tuple below:
|
|
// accepting both would let a caller name two different terms in one decision and never learn which
|
|
// one the engine picked.
|
|
ID string `json:"id,omitempty"`
|
|
// Src / Sense / SinceChapter / UntilChapter are the term's uniqueness key — the tuple the id is
|
|
// derived from, and the form in which a term the bank does not have yet is added.
|
|
Src string `json:"src,omitempty"`
|
|
Sense string `json:"sense,omitempty"`
|
|
SinceChapter int `json:"since_chapter,omitempty"`
|
|
UntilChapter int `json:"until_chapter,omitempty"`
|
|
// Dst is the rendering. Required by `approve` and forbidden on `decline`: an approved term with no
|
|
// dst is not a signature, it is a load failure of the next run (memseed.go fails loud on it), and a
|
|
// WHICH-only surface can therefore only ever be declined.
|
|
Dst string `json:"dst,omitempty"`
|
|
// Kind is the engine's own `type` column (name|place|…), as the read-out publishes it. Absent means
|
|
// "not decided" and keeps whatever the term already carries.
|
|
Kind string `json:"kind,omitempty"`
|
|
// Note is the owner's reason, carried into whichever file the decision lands in so a list stays
|
|
// self-documenting.
|
|
//
|
|
// ABSENT means "not decided", like `kind`: a repeat of a decision from a screen that does not carry
|
|
// notes keeps the note the owner wrote the first time, in BOTH files. The cost is named rather than
|
|
// hidden — a note cannot be EMPTIED through this door, only replaced with other words, which is the
|
|
// same narrowness an alias has (see termFromBank). Erasing provenance by omission is the worse of
|
|
// the two, and it is the one a caller does by accident.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// DecisionsDoc is the request document.
|
|
type DecisionsDoc struct {
|
|
Version string `json:"decisions_version"`
|
|
// BookID is REQUIRED and checked against the book being opened. Decisions are the one input that
|
|
// carries a user's own words into a book's canon, and applying a set computed for another book is
|
|
// not an error anything downstream could notice.
|
|
BookID string `json:"book_id"`
|
|
Decisions []Decision `json:"decisions"`
|
|
}
|
|
|
|
// DecodeDecisions parses a request document. Strict about unknown fields for the same reason the CLI is
|
|
// strict about unknown flags (17-seam-inbound-law п.4): a field the engine does not know is a caller
|
|
// believing it set something, and the quiet version of that is a decision half-applied.
|
|
func DecodeDecisions(raw []byte) (DecisionsDoc, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
var doc DecisionsDoc
|
|
if err := dec.Decode(&doc); err != nil {
|
|
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: %w", err)
|
|
}
|
|
if doc.Version != DecisionsVersion {
|
|
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: decisions_version is %q, this engine speaks %q", doc.Version, DecisionsVersion)
|
|
}
|
|
if doc.BookID == "" {
|
|
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: the document names no book_id")
|
|
}
|
|
if len(doc.Decisions) == 0 {
|
|
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: the document carries no decisions")
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// TermID is the stable id of a bank row, derived from its uniqueness key (src, sense, since_ch,
|
|
// until_ch) — NOT from glossary.id, which is a fresh autoincrement on every bank replace and would
|
|
// re-point under a consumer between two reads of the same unchanged term.
|
|
//
|
|
// 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 TermID(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]
|
|
}
|
|
|
|
// termKey is a term's uniqueness key — the store's UNIQUE(book_id, src, sense, since_ch, until_ch).
|
|
type termKey struct {
|
|
Src, Sense string
|
|
Since, Until int
|
|
}
|
|
|
|
func keyOf(e store.GlossaryEntry) termKey {
|
|
return termKey{e.Src, e.Sense, e.SinceCh, e.UntilCh}
|
|
}
|
|
|
|
func (k termKey) id() string { return TermID(k.Src, k.Sense, k.Since, k.Until) }
|
|
|
|
// ApplyInput is everything the decision pass reads.
|
|
type ApplyInput struct {
|
|
// Bank is the book's bank as it stands — every row, every status. It is what the caller's ids were
|
|
// derived from, and it is read from the store rather than from the published read-out on purpose:
|
|
// that artifact is a projection and never a source (bankexport.go).
|
|
Bank []store.GlossaryEntry
|
|
// Seed is the entries of the book's glossary_seed, the owner's SIGNED base. Nil when the book has
|
|
// none. A term of the seed cannot be decided here — see the conflict rule in applyOne.
|
|
Seed []store.GlossaryEntry
|
|
// Voices / Pairs are the seed's D21 records. They are here for ONE check and it is not cosmetic: a
|
|
// voice or address row naming a character the bank does not have STOPS the run (seeding.go —
|
|
// "a profile for a term that does not exist can never fire"), and a seed may legitimately name a
|
|
// character whose TERM lives in the delta. Declining that term is then an accepted decision that
|
|
// kills the next run — reproduced before this field existed.
|
|
Voices []store.VoiceProfile
|
|
Pairs []store.AddressPair
|
|
// Ruby are the book's captured ruby readings, for the same reason and the same failure. The run
|
|
// GRAFTS every name-shaped reading onto its seed term as a firing alias (AttachRubyAliasesToManual)
|
|
// BEFORE it runs the collision check, so a seed term is reachable through surfaces the seed file
|
|
// never spells. A door that judged the seed file alone would accept a mined term whose src IS one of
|
|
// those readings and hand the next run a livelock — reproduced end to end before this field existed.
|
|
//
|
|
// These three fields are one rule, not three: the entry set this door judges must be the entry set
|
|
// seedGlossary builds, or "it would load" here means something narrower than it means there.
|
|
Ruby []store.RubyReading
|
|
// Delta / Rejects are the two decision files as they stand (zero values when they do not exist yet).
|
|
Delta seed.File
|
|
Rejects seed.RejectFile
|
|
|
|
Decisions []Decision
|
|
}
|
|
|
|
// AcceptedDecision is one decision the engine took, and what it displaced doing so.
|
|
type AcceptedDecision struct {
|
|
Index int `json:"index"`
|
|
Action string `json:"action"`
|
|
ID string `json:"id"`
|
|
Src string `json:"src"`
|
|
Dst string `json:"dst,omitempty"`
|
|
// State is `applied` or `already_applied`. The second is the whole of the idempotency contract as a
|
|
// caller sees it: a retry of a decision that is already in the files changes nothing, writes nothing
|
|
// and still exits 0, because a worker that resumes and retries must not be able to split the state.
|
|
State string `json:"state"`
|
|
// Replaced names what this decision displaced — a previous rendering, a previous decline. A
|
|
// decision is a REPLACEMENT, never a return to the undecided state, so what it overwrote has to be
|
|
// visible or the owner cannot see that they overwrote anything.
|
|
Replaced []string `json:"replaced,omitempty"`
|
|
}
|
|
|
|
// RejectedDecision is one refusal, by name.
|
|
type RejectedDecision struct {
|
|
// Index is the decision's position in the request, or -1 for a refusal about the RESULT as a whole
|
|
// (a document that would not load, a collision the set introduces).
|
|
Index int `json:"index"`
|
|
Action string `json:"action,omitempty"`
|
|
Src string `json:"src,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// ApplyResult is the outcome of the pass.
|
|
type ApplyResult struct {
|
|
Delta seed.File
|
|
Rejects seed.RejectFile
|
|
// DeltaTouched / RejectsTouched say which DOCUMENT a decision actually changed. The caller writes
|
|
// only what was touched: a decline changes the reject list, and re-rendering the delta beside it
|
|
// would destroy an operator's formatting of a file this call had no opinion about.
|
|
DeltaTouched bool
|
|
RejectsTouched bool
|
|
// DeltaBytes / RejectBytes are the CANONICAL BYTES of the two resulting documents — the exact bytes
|
|
// the caller writes. Produced here rather than by the caller because they are also the bytes that
|
|
// were PROVEN readable (renderProved): a caller that rendered its own would be writing bytes nothing
|
|
// checked. Nil when the set was refused — there is then nothing to write — or when the document
|
|
// could not be rendered at all, which is itself one of the refusals.
|
|
DeltaBytes []byte
|
|
RejectBytes []byte
|
|
Accepted []AcceptedDecision
|
|
Rejected []RejectedDecision
|
|
// Preexisting are faults the CURRENT files already have and that this call did not cause. They never
|
|
// refuse anything — a book whose files already contradict each other has to stay repairable through
|
|
// this door, and charging a decision for a fault it did not cause would make the door useless exactly
|
|
// when it is needed. They are REPORTED because the alternative is a verb that succeeds cheerfully on
|
|
// a book whose next run is already going to die.
|
|
Preexisting []string
|
|
}
|
|
|
|
// The two values of AcceptedDecision.State. Exported because they are a published vocabulary: the
|
|
// wiring gates the write on them, and a consumer reads them off the report.
|
|
const (
|
|
StateApplied = "applied"
|
|
StateAlreadyApplied = "already_applied"
|
|
)
|
|
|
|
// resolvedDecision is one request entry joined to the term key it names. `ok` goes false the moment the
|
|
// entry is refused, so a later stage never folds a decision the earlier one turned down.
|
|
type resolvedDecision struct {
|
|
d Decision
|
|
key termKey
|
|
ok bool
|
|
}
|
|
|
|
// ApplyDecisions folds the decisions onto the two files and returns the result they should have.
|
|
//
|
|
// ALL OR NOTHING. Any refusal — an ill-formed decision, a conflict with the signed seed, a collision the
|
|
// SET would introduce — leaves Rejected non-empty, and the caller then writes nothing at all. Nine
|
|
// accepted decisions out of ten are not nine decisions to apply: the tenth is the one the owner has to
|
|
// see, and a partial write hides it behind work that appears to have succeeded.
|
|
//
|
|
// Pure: no clock, no randomness, no I/O. Deterministic in the decisions' own order.
|
|
func ApplyDecisions(in ApplyInput) ApplyResult {
|
|
in.Decisions = normalizedDecisions(in.Decisions)
|
|
res := ApplyResult{Delta: cloneFile(in.Delta), Rejects: cloneRejects(in.Rejects)}
|
|
|
|
// The door as a sequence of named judgements. Only ONE of them changes anything; the rest decide
|
|
// whether it may, and each has to run before the fold or after it for a reason its own comment gives.
|
|
rs := resolveAll(in, &res)
|
|
refuseSeedConflicts(in, rs, &res)
|
|
foldAccepted(in, rs, &res)
|
|
refuseInertDeclines(in, rs, &res)
|
|
refuseNewFaults(in, &res)
|
|
|
|
if len(res.Rejected) > 0 {
|
|
// All-or-nothing: the caller must not be handed a half-built document it might decide to write,
|
|
// and must not be handed BYTES of one either.
|
|
res.Delta, res.Rejects = cloneFile(in.Delta), cloneRejects(in.Rejects)
|
|
res.Accepted, res.DeltaBytes, res.RejectBytes = nil, nil, nil
|
|
}
|
|
sort.SliceStable(res.Rejected, func(i, j int) bool { return res.Rejected[i].Index < res.Rejected[j].Index })
|
|
return res
|
|
}
|
|
|
|
// resolveAll joins every decision to the term it names and refuses the ill-formed ones.
|
|
//
|
|
// BEFORE anything is touched, so a set that names one term twice is refused as ill-formed rather than
|
|
// applied in request order with the last one silently winning.
|
|
func resolveAll(in ApplyInput, res *ApplyResult) []resolvedDecision {
|
|
byID := make(map[string]termKey, len(in.Bank))
|
|
for _, e := range in.Bank {
|
|
byID[TermID(e.Src, e.Sense, e.SinceCh, e.UntilCh)] = keyOf(e)
|
|
}
|
|
rs := make([]resolvedDecision, len(in.Decisions))
|
|
keys := make([]termKey, len(in.Decisions))
|
|
for i, d := range in.Decisions {
|
|
key, err := resolveDecision(d, byID)
|
|
if err != nil {
|
|
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: d.Action, Src: d.Src, Reason: err.Error()})
|
|
continue
|
|
}
|
|
rs[i], keys[i] = resolvedDecision{d: d, key: key, ok: true}, key
|
|
}
|
|
res.Rejected = append(res.Rejected, duplicateDecisions(in.Decisions, keys)...)
|
|
return rs
|
|
}
|
|
|
|
// refuseSeedConflicts keeps the SIGNED seed off-limits from this door, in both directions.
|
|
func refuseSeedConflicts(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
|
|
seedByKey := make(map[termKey]store.GlossaryEntry, len(in.Seed))
|
|
seedSurfaces := make(map[string]store.GlossaryEntry, len(in.Seed))
|
|
for _, e := range in.Seed {
|
|
seedByKey[keyOf(e)] = e
|
|
if nk := text.NormalizeSourceKey(e.Src); nk != "" {
|
|
seedSurfaces[nk] = e
|
|
}
|
|
for _, a := range e.Aliases {
|
|
if nk := text.NormalizeSourceKey(a.Alias); nk != "" {
|
|
seedSurfaces[nk] = e
|
|
}
|
|
}
|
|
}
|
|
for i, r := range rs {
|
|
if !r.ok {
|
|
continue
|
|
}
|
|
// Scoped to APPROVE: it is the action that would write a delta row, and the reason below is about
|
|
// that row's key. A decline of the same term is refused just below, for its own (different) reason
|
|
// — a reject writes no row at all, so quoting the UNIQUE constraint at it would be false.
|
|
if prior, clash := seedByKey[r.key]; clash && r.d.Action == ActionApprove {
|
|
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
|
|
Reason: fmt.Sprintf("%q (sense %q, window [%d,%d]) is a SIGNED seed term (%q→%q): a delta row with that key would crash the bank replace on the glossary UNIQUE constraint. Editing a seed term means editing glossary_seed, which moves the BASE snapshot and re-pays the draft wave — a different decision, and not this one",
|
|
r.key.Src, r.key.Sense, r.key.Since, r.key.Until, prior.Src, prior.Dst)})
|
|
rs[i].ok = false
|
|
continue
|
|
}
|
|
if r.d.Action != ActionDecline {
|
|
continue
|
|
}
|
|
// ⚠ Scoped to the case where the refusal's own sentence is TRUE. «Declining it would change
|
|
// nothing» holds only while the DELTA has no row with that surface. When it does, the decline
|
|
// drops that row — a real effect, and usually the repair for the collision the same report is
|
|
// listing under preexisting_problems (a seed alias and a delta term sharing a firing key is
|
|
// exactly that livelock). Refusing there told the owner to «remove it from glossary_seed
|
|
// instead», which would not have removed the delta row either: the report's own instruction did
|
|
// not fix what the report complained about.
|
|
// ⚠ …AND the refusal's sentence is still true a SECOND time. «Declining it would change
|
|
// nothing while reading as a decision» is false the moment the reject list ALREADY holds
|
|
// the surface: the decision is not being read, it is on the record, and what the caller is
|
|
// doing is re-sending it. Without this clause the refusal fired on the state its own
|
|
// acceptance produced — the first call was lawful BECAUSE the delta held a row, applying it
|
|
// DROPPED that row (applyOne → dropTerms), and the identical document was then refused
|
|
// forever. That broke three promises at once: this file's own idempotency contract
|
|
// (already_applied, see AcceptedDecision.State) and the caller-facing "re-send the SAME
|
|
// document, the retry converges". The state needs no crash to arise: a COMPLETED decline
|
|
// produces exactly it, and the identical document was then bounced forever.
|
|
//
|
|
// ⚠ The cost of the narrowing, named rather than discovered later: the clause is keyed on the
|
|
// SURFACE being on the record, so a decline that is genuinely inert against the signed seed —
|
|
// and that also happens to be recorded — now answers already_applied instead of repeating the
|
|
// instruction "Remove it from glossary_seed instead". That instruction is the only one that
|
|
// works, and it is lost in that corner. Convergence was judged the heavier duty (a caller
|
|
// following the published retry has no other move), but the trade is real and a report field
|
|
// carrying the standing fact would close it.
|
|
//
|
|
// The refusal it DOES keep is the load-bearing one, and the direction guard below pins it:
|
|
// a decline of a seed surface with no delta row AND no reject on the record still refuses,
|
|
// with this same sentence, because there it is true.
|
|
if prior, held := seedSurfaces[text.NormalizeSourceKey(r.key.Src)]; held &&
|
|
!deltaHoldsSurface(in.Delta, r.key.Src) && !rejectsHoldSurface(in.Rejects, r.key.Src) {
|
|
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
|
|
Reason: fmt.Sprintf("%q is a surface of the SIGNED seed term %q→%q and this book's mined-delta has no row of its own for it: a reject only filters PROPOSALS, so declining it would change nothing while reading as a decision. Remove it from glossary_seed instead", r.key.Src, prior.Src, prior.Dst)})
|
|
rs[i].ok = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// foldAccepted is the ONE phase that changes a document. Everything before it decides whether it may run
|
|
// on a given decision, and everything after it judges what it produced.
|
|
func foldAccepted(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
|
|
bankByKey := make(map[termKey]store.GlossaryEntry, len(in.Bank))
|
|
for _, e := range in.Bank {
|
|
bankByKey[keyOf(e)] = e
|
|
}
|
|
for i, r := range rs {
|
|
if !r.ok {
|
|
continue
|
|
}
|
|
res.Accepted = append(res.Accepted, applyOne(res, r.d, r.key, i, bankByKey))
|
|
}
|
|
}
|
|
|
|
// refuseInertDeclines refuses a decline that would change nothing while reading as a decision — judged
|
|
// on the SET, and therefore only after the fold.
|
|
//
|
|
// A surface that is an ALIAS of an approved delta term is already excluded from proposals, and the term
|
|
// keeps rendering it, because removing an alias has no door in v1. Judged AFTER the fold because the
|
|
// term that owns the alias may be approved by ANOTHER decision of the same call: `approve 方源` +
|
|
// `decline 方小子` was accepted as two applied decisions while the nickname went on firing. It is the
|
|
// RESULT that is judged and not the input, which is also what keeps the mirror case legitimate: two
|
|
// declines, of a term and of its own alias, both stand — the first removes the row, so by the end
|
|
// nothing owns the alias.
|
|
//
|
|
// ⚠ NO ESCAPE FOR «it was a term of its own». An earlier version skipped this check when the surface had
|
|
// been a delta term before the call. Measured, that escape was a no-op in every input EXCEPT the one
|
|
// where it did harm: once dropTerms has removed the row nothing owns the alias anyway — unless ANOTHER
|
|
// term also carries the surface as an alias, which is exactly what the escape let through (delta holding
|
|
// «Nick→Nik» and «Hero→Nik2 (alias Nick)», decline Nick → accepted, and Hero went on firing Nick).
|
|
// ⚠ AND IT CONVERGES, for the same reason its sibling does: a decline ALREADY on the record is a
|
|
// decision being re-sent, not one being made. The state is reachable by accepted calls only — decline a
|
|
// surface that IS a delta term of its own (lawful), then approve a term that carries it as an alias
|
|
// (lawful) — and after those two the standing ledger holding both decisions was refused forever, taking
|
|
// the lawful approve down with it, because the layer is all-or-nothing. The check reads in.Rejects and
|
|
// NOT res.Rejects: the fold has already run by now and its own addReject would otherwise make every
|
|
// first-time decline look like a repeat, disabling the refusal outright.
|
|
func refuseInertDeclines(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
|
|
for i, r := range rs {
|
|
if !r.ok || r.d.Action != ActionDecline {
|
|
continue
|
|
}
|
|
if rejectsHoldSurface(in.Rejects, r.key.Src) {
|
|
continue
|
|
}
|
|
if owner, held := aliasOwner(res.Delta, r.key.Src); held {
|
|
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
|
|
Reason: fmt.Sprintf("%q is an ALIAS of the approved term %q→%q, not a term of its own: it is already excluded from proposals, and this door cannot remove an alias — decline %q itself to withdraw the whole term", r.key.Src, owner.Src, owner.Dst, owner.Src)})
|
|
}
|
|
}
|
|
}
|
|
|
|
// refuseNewFaults is the verdict on the RESULT: both documents have to be writable, the delta has to
|
|
// load, and it has to load without a collision the next run would die on.
|
|
//
|
|
// Only NEW faults refuse; the ones the files already had are reported instead, and they are told apart
|
|
// by SUBJECT (problems.go). A verdict that named every broken term in one string used to change entirely
|
|
// when one of them was fixed, so repairing a book one term at a time was refused with the text of a term
|
|
// the decision never touched.
|
|
func refuseNewFaults(in ApplyInput, res *ApplyResult) {
|
|
before := inspectDocuments(in, in.Delta, in.Rejects)
|
|
res.Preexisting = problemTexts(before.problems)
|
|
known := make(map[string]bool, len(before.problems))
|
|
for _, p := range before.problems {
|
|
known[p.Subject] = true
|
|
}
|
|
after := inspectDocuments(in, res.Delta, res.Rejects)
|
|
for _, p := range after.problems {
|
|
if !known[p.Subject] {
|
|
res.Rejected = append(res.Rejected, RejectedDecision{Index: -1, Reason: p.Text})
|
|
}
|
|
}
|
|
res.DeltaBytes, res.RejectBytes = after.delta, after.rejects
|
|
}
|
|
|
|
// resolveDecision turns one decision into the term key it names, refusing every shape that could name
|
|
// two things at once or nothing at all.
|
|
func resolveDecision(d Decision, byID map[string]termKey) (termKey, error) {
|
|
switch d.Action {
|
|
case ActionApprove, ActionDecline:
|
|
default:
|
|
return termKey{}, fmt.Errorf("action must be %s or %s, got %q", ActionApprove, ActionDecline, d.Action)
|
|
}
|
|
// The shape rules of each verb, checked HERE so the refusal carries the decision's own index. The
|
|
// resulting document is validated too (inspectDocuments), but that verdict is about the file as a
|
|
// whole and cannot tell a caller which of its ten decisions to fix.
|
|
switch d.Action {
|
|
case ActionApprove:
|
|
if strings.TrimSpace(d.Dst) == "" {
|
|
return termKey{}, fmt.Errorf("an approved term needs a non-empty `dst`: an approved row with no rendering is not a weak signature, it fails the load of the next run — a WHICH-only surface can only be declined")
|
|
}
|
|
case ActionDecline:
|
|
if d.Dst != "" || d.Kind != "" {
|
|
return termKey{}, fmt.Errorf("a decline carries only the surface and an optional note; `dst`/`kind` on it says the caller meant to edit the term, and half of that is not something to guess at")
|
|
}
|
|
}
|
|
tuple := d.Src != "" || d.Sense != "" || d.SinceChapter != 0 || d.UntilChapter != 0
|
|
switch {
|
|
case d.ID != "" && tuple:
|
|
return termKey{}, fmt.Errorf("a decision names a term by `id` OR by the (src, sense, window) tuple, never by both")
|
|
case d.ID != "":
|
|
key, ok := byID[d.ID]
|
|
if !ok {
|
|
return termKey{}, fmt.Errorf("no bank term has id %q — the read-out it came from is for another book or predates a re-cut of this one (the id carries the chapter window)", d.ID)
|
|
}
|
|
return key, nil
|
|
case strings.TrimSpace(d.Src) == "":
|
|
return termKey{}, fmt.Errorf("a decision needs either an `id` or a `src`")
|
|
}
|
|
key := termKey{strings.TrimSpace(d.Src), strings.TrimSpace(d.Sense), d.SinceChapter, d.UntilChapter}
|
|
if key.Since < 0 || key.Until < 0 {
|
|
return termKey{}, fmt.Errorf("chapter bounds are 1-based, 0 meaning «no bound»; got [%d,%d]", key.Since, key.Until)
|
|
}
|
|
// A window that ENDS before it begins is not a narrow window, it is an empty one: the row is written,
|
|
// loads, and matches in no chapter at all (windowsOverlap and the matcher both read it as a range).
|
|
// Nothing downstream would ever say so — the term simply never fires. Until = 0 is "no upper bound"
|
|
// and is not a reversal.
|
|
if key.Until != 0 && key.Since > key.Until {
|
|
return termKey{}, fmt.Errorf("the chapter window [%d,%d] ends before it begins: such a term is written and loaded but fires in no chapter, so it can only look like a decision that was taken", key.Since, key.Until)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// normalizedDecisions trims the free text of every decision, which is the SAME rule the bank loader and
|
|
// this door already apply to the three fields they key on (src, sense, dst) — stated over the request
|
|
// instead of at three call sites, so `note` stops being the exception that escaped it.
|
|
//
|
|
// ⚠ WHAT IT BUYS IS THE TRUTHFULNESS OF THE REPORT, not writability. An earlier version of this comment
|
|
// said it was what keeps a lawful note writable; that is checkably false — the document is normalized
|
|
// again on the way out (RenderSeedFile calls seed.File.Normalize), so an untrimmed note is written
|
|
// correctly either way. What breaks without this is the door's own IDEMPOTENCY comparison: a caller
|
|
// re-sending a decision whose note carries stray whitespace compares it against the trimmed value on
|
|
// disk, finds them different, and the call answers `state: applied` while the byte gate one layer up
|
|
// correctly answers `changed: false` — a report that contradicts itself about work that did not happen.
|
|
//
|
|
// `action` and `id` are deliberately NOT trimmed: they are MATCHED, never written, and trimming them
|
|
// would silently widen what the door accepts.
|
|
func normalizedDecisions(in []Decision) []Decision {
|
|
out := make([]Decision, len(in))
|
|
for i, d := range in {
|
|
d.Src, d.Sense, d.Dst = strings.TrimSpace(d.Src), strings.TrimSpace(d.Sense), strings.TrimSpace(d.Dst)
|
|
d.Kind, d.Note = strings.TrimSpace(d.Kind), strings.TrimSpace(d.Note)
|
|
out[i] = d
|
|
}
|
|
return out
|
|
}
|
|
|
|
// duplicateDecisions refuses a request that decides one term twice — and a request that approves and
|
|
// declines the same SURFACE, which is the same contradiction one level up: the reject list keys on the
|
|
// normalized src, so it would suppress proposals for a term the same call just approved.
|
|
func duplicateDecisions(ds []Decision, keys []termKey) []RejectedDecision {
|
|
var out []RejectedDecision
|
|
seenKey := map[termKey]int{}
|
|
seenSurface := map[string]int{}
|
|
for i, k := range keys {
|
|
if k == (termKey{}) {
|
|
continue // unresolved: already refused with its own reason
|
|
}
|
|
if first, dup := seenKey[k]; dup {
|
|
out = append(out, RejectedDecision{Index: i, Action: ds[i].Action, Src: k.Src,
|
|
Reason: fmt.Sprintf("decision %d already decides this term (sense %q, window [%d,%d]) — one call, one decision per term", first, k.Sense, k.Since, k.Until)})
|
|
continue
|
|
}
|
|
seenKey[k] = i
|
|
nk := text.NormalizeSourceKey(k.Src)
|
|
first, seen := seenSurface[nk]
|
|
if !seen {
|
|
seenSurface[nk] = i
|
|
continue
|
|
}
|
|
// A DECLINE is surface-scoped — the reject list holds a src and nothing else — so any second
|
|
// decision about the same surface collides with it, whichever way round. Two declines of one
|
|
// surface with different senses are not two terms: the second silently overwrote the first's
|
|
// note while BOTH reported applied. Two APPROVEs of one surface in different senses or windows
|
|
// stay legitimate: an approve is key-scoped and those are genuinely two terms.
|
|
if ds[first].Action == ActionDecline || ds[i].Action == ActionDecline {
|
|
out = append(out, RejectedDecision{Index: i, Action: ds[i].Action, Src: k.Src,
|
|
Reason: fmt.Sprintf("decision %d already %ss the surface %q, and a decline names a SURFACE rather than one sense or window — one call decides a surface once", first, ds[first].Action, k.Src)})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyOne folds one resolved decision into the working documents and reports what it did.
|
|
func applyOne(res *ApplyResult, d Decision, key termKey, index int, bank map[termKey]store.GlossaryEntry) AcceptedDecision {
|
|
acc := AcceptedDecision{Index: index, Action: d.Action, ID: key.id(), Src: key.Src, State: StateApplied}
|
|
changed := false
|
|
switch d.Action {
|
|
case ActionApprove:
|
|
acc.Dst = strings.TrimSpace(d.Dst)
|
|
prior, at := findTerm(res.Delta.Terms, key)
|
|
if at < 0 {
|
|
// Promoting a row the bank ALREADY HAS: start from what the engine itself proposed about the
|
|
// term's identity, not from a blank. Its ALIASES are the miner's cluster — the other surfaces
|
|
// that fire for this entity — and once a signed delta row holds the key, the auto-bank row
|
|
// carrying them is dropped as a key clash (loadAutoBank). Promotion would therefore SILENTLY
|
|
// narrow the term to its own src: exactly the "approved but inert on half its surfaces" hole
|
|
// this bank exists to close.
|
|
prior = termFromBank(bank[key])
|
|
}
|
|
next := mergeTerm(prior, d, key)
|
|
switch {
|
|
case at < 0:
|
|
res.Delta.Terms = append(res.Delta.Terms, next)
|
|
changed, res.DeltaTouched = true, true
|
|
case !sameTerm(prior, next):
|
|
// Replaced IN PLACE: the file's order is the owner's reading order and a decision is not a
|
|
// reason to reshuffle it.
|
|
res.Delta.Terms[at] = next
|
|
changed, res.DeltaTouched = true, true
|
|
if prior.Dst != next.Dst {
|
|
acc.Replaced = append(acc.Replaced, fmt.Sprintf("previous rendering %q", prior.Dst))
|
|
}
|
|
}
|
|
if dropped := dropReject(&res.Rejects, key.Src); dropped {
|
|
changed, res.RejectsTouched = true, true
|
|
acc.Replaced = append(acc.Replaced, "a previous decline of this surface")
|
|
}
|
|
case ActionDecline:
|
|
if gone := dropTerms(&res.Delta, key.Src); len(gone) > 0 {
|
|
changed, res.DeltaTouched = true, true
|
|
for _, g := range gone {
|
|
acc.Replaced = append(acc.Replaced, fmt.Sprintf("a previous approval %q→%q", g.Src, g.Dst))
|
|
}
|
|
}
|
|
if addReject(&res.Rejects, key.Src, d.Note) {
|
|
changed, res.RejectsTouched = true, true
|
|
}
|
|
}
|
|
if !changed {
|
|
acc.State = StateAlreadyApplied
|
|
}
|
|
return acc
|
|
}
|
|
|
|
// termFromBank carries a bank row's IDENTITY into the delta row that promotes it: the alias surfaces
|
|
// and the classified kind. Not its dst (the decision brings that), not its status (the promotion sets
|
|
// it), and not the seed-only metadata — a decidable row is never a seed row, so it has none.
|
|
func termFromBank(e store.GlossaryEntry) seed.Term {
|
|
t := seed.Term{Type: e.Type}
|
|
for _, a := range e.Aliases {
|
|
t.Aliases = append(t.Aliases, seed.Alias{Alias: a.Alias, Type: a.AliasType})
|
|
}
|
|
return t
|
|
}
|
|
|
|
// mergeTerm builds the delta row a decision produces, MERGING onto whatever the file already holds for
|
|
// that term rather than replacing it wholesale: a hand-authored `gender` or `decl` on the row is not
|
|
// something a decision about the rendering was asked to discard.
|
|
//
|
|
// `status: approved` is written EXPLICITLY and always. The seed loader defaults an ABSENT status to
|
|
// approved, so a file without the field would work — but a mechanically copied signature-map row carries
|
|
// `status: auto`, which the mining stop filters out as unsigned, and the stop then never clears. Writing
|
|
// the word is what makes the promotion survive being read back.
|
|
func mergeTerm(prior seed.Term, d Decision, key termKey) seed.Term {
|
|
t := prior
|
|
t.Src, t.Sense, t.SinceCh, t.UntilCh = key.Src, key.Sense, key.Since, key.Until
|
|
t.Dst = strings.TrimSpace(d.Dst)
|
|
t.Status = "approved"
|
|
if d.Kind != "" {
|
|
t.Type = d.Kind
|
|
}
|
|
if d.Note != "" {
|
|
t.Note = d.Note
|
|
}
|
|
return t
|
|
}
|
|
|
|
// aliasOwner finds the SIGNED delta term that owns `src` as an ALIAS rather than as its own surface.
|
|
// Keyed on the normalized form, like everything else that matches a surface rather than a row.
|
|
//
|
|
// ⚠ SIGNED, and the status is load-bearing rather than decorative. The rule this serves is "declining
|
|
// this surface would be inert, because it is already excluded from proposals" — and the exclusion is
|
|
// `unsignedEngineSurfaces` (pipeline/mining.go), which drops mined rows whose status is not `approved`.
|
|
// A delta row carrying `status: auto` — the state row 199's first format mine is entirely about — does
|
|
// NOT exclude its surfaces, so declining its alias is precisely what the signing screen asks for.
|
|
// Ignoring the status refused that decline, and told the owner it was declining an alias of "the
|
|
// approved term", which the row was not. An ABSENT status is approved, as the loader defaults it.
|
|
func aliasOwner(f seed.File, src string) (seed.Term, bool) {
|
|
nk := text.NormalizeSourceKey(src)
|
|
for _, t := range f.Terms {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
|
|
return seed.Term{}, false // its own surface: an ordinary decline of the term
|
|
}
|
|
if st := strings.TrimSpace(t.Status); st != "" && st != "approved" {
|
|
continue // unsigned: its surfaces are not excluded from proposals, so a decline is not inert
|
|
}
|
|
for _, a := range t.Aliases {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(a.Alias)) == nk {
|
|
return t, true
|
|
}
|
|
}
|
|
}
|
|
return seed.Term{}, false
|
|
}
|
|
|
|
// deltaHoldsSurface reports whether the document has a row whose OWN src is this surface — i.e. whether
|
|
// a decline of it would drop something. Not to be confused with aliasOwner, which asks the opposite
|
|
// question (does somebody ELSE keep firing this surface).
|
|
func deltaHoldsSurface(f seed.File, src string) bool {
|
|
nk := text.NormalizeSourceKey(src)
|
|
for _, t := range f.Terms {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rejectsHoldSurface reports whether this surface is ALREADY on the reject list — i.e. whether a
|
|
// decline of it is a decision being re-sent rather than a decision being made. It is the mirror of
|
|
// deltaHoldsSurface and is normalised the same way addReject normalises, so "already recorded" means
|
|
// here exactly what it means where the record is written.
|
|
func rejectsHoldSurface(f seed.RejectFile, src string) bool {
|
|
nk := text.NormalizeSourceKey(src)
|
|
for _, r := range f.Rejects {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) == nk {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findTerm(terms []seed.Term, key termKey) (seed.Term, int) {
|
|
for i, t := range terms {
|
|
if strings.TrimSpace(t.Src) == key.Src && strings.TrimSpace(t.Sense) == key.Sense &&
|
|
t.SinceCh == key.Since && t.UntilCh == key.Until {
|
|
return t, i
|
|
}
|
|
}
|
|
return seed.Term{}, -1
|
|
}
|
|
|
|
// sameTerm compares two rows through the BYTES they would be written as.
|
|
//
|
|
// Deliberately not reflect.DeepEqual, which is the ordinary Go answer for a struct holding slices and a
|
|
// pointer. Two reasons, in order of weight: the question this function is asked is "will the file
|
|
// change?", and the file is YAML, so comparing the rendering answers it directly and stays consistent
|
|
// with the byte-level write gate one layer up; and DeepEqual distinguishes a nil slice from an empty one
|
|
// (`aliases:` absent vs `aliases: []`), which is a difference the format does not have and which would
|
|
// report a spurious change. It is also total in the same way DeepEqual is — it cannot go stale the day
|
|
// the schema grows a field, which is exactly how an idempotency check silently starts reporting
|
|
// "already applied" for a decision it did not apply.
|
|
func sameTerm(a, b seed.Term) bool {
|
|
ab, aerr := yaml.Marshal(a)
|
|
bb, berr := yaml.Marshal(b)
|
|
return aerr == nil && berr == nil && bytes.Equal(ab, bb)
|
|
}
|
|
|
|
// dropTerms removes every delta row whose surface normalizes to src's, and returns them.
|
|
//
|
|
// Surface-scoped rather than key-scoped because the reject list itself is: it holds a src and nothing
|
|
// else, so "I decline this term" cannot mean one spoiler window of it. Removing the approvals it
|
|
// contradicts is the REPLACEMENT half of the decline; leaving them would inject a rendering the owner
|
|
// has just declined.
|
|
func dropTerms(f *seed.File, src string) []seed.Term {
|
|
nk := text.NormalizeSourceKey(src)
|
|
kept := f.Terms[:0:0]
|
|
var gone []seed.Term
|
|
for _, t := range f.Terms {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
|
|
gone = append(gone, t)
|
|
continue
|
|
}
|
|
kept = append(kept, t)
|
|
}
|
|
f.Terms = kept
|
|
return gone
|
|
}
|
|
|
|
// addReject records a decline, replacing an existing one for the same surface.
|
|
//
|
|
// An ABSENT note keeps whatever note the entry already carries — the same rule `kind` follows on the
|
|
// approve side ("absent means not decided"), and the same rule mergeTerm follows for the delta. The two
|
|
// halves of the door disagreed about this: a repeat approve kept the note and a repeat decline ERASED
|
|
// it, so a caller re-sending the same decision from a screen that does not carry notes silently threw
|
|
// away the owner's own words. One rule, stated once here and in Decision.Note.
|
|
func addReject(f *seed.RejectFile, src, note string) bool {
|
|
nk := text.NormalizeSourceKey(src)
|
|
for i, r := range f.Rejects {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) != nk {
|
|
continue
|
|
}
|
|
next := seed.Reject{Src: src, Note: note}
|
|
if note == "" {
|
|
next.Note = r.Note
|
|
}
|
|
if r == next {
|
|
return false
|
|
}
|
|
f.Rejects[i] = next
|
|
return true
|
|
}
|
|
f.Rejects = append(f.Rejects, seed.Reject{Src: src, Note: note})
|
|
return true
|
|
}
|
|
|
|
// dropReject withdraws a decline of this surface, which is what an approval of it means.
|
|
func dropReject(f *seed.RejectFile, src string) bool {
|
|
nk := text.NormalizeSourceKey(src)
|
|
kept := f.Rejects[:0:0]
|
|
for _, r := range f.Rejects {
|
|
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) == nk {
|
|
continue
|
|
}
|
|
kept = append(kept, r)
|
|
}
|
|
dropped := len(kept) != len(f.Rejects)
|
|
f.Rejects = kept
|
|
return dropped
|
|
}
|
|
|
|
// docVerdict is one state of the two documents as this door judges it: every fault, keyed by subject,
|
|
// and the canonical bytes each document would be written as (nil when it could not be rendered at all).
|
|
type docVerdict struct {
|
|
problems []Problem
|
|
delta []byte
|
|
rejects []byte
|
|
}
|
|
|
|
// inspectDocuments is the WRITABILITY + loadability + collision + coverage verdict on one
|
|
// (seed, delta, rejects) state. The load checks are the ones seedGlossary runs on the way into the bank,
|
|
// in the same order, over the same joined entry set — so "this document would load" here means the same
|
|
// thing it means there.
|
|
//
|
|
// Both documents are judged, not just the delta. The reject list carries no bank content, so it has no
|
|
// collisions to have — but the same call writes it, and a reject list the next run cannot parse refuses
|
|
// that run's CONFIG, which is how one lawful decline used to make a book unopenable through this door.
|
|
func inspectDocuments(in ApplyInput, delta seed.File, rejects seed.RejectFile) docVerdict {
|
|
var problems problemList
|
|
v := docVerdict{}
|
|
|
|
// Keyed by SUBJECT — the document — because the TEXT of these two carries a parser's line number,
|
|
// which moves when an unrelated part of the file is edited. That is the same instability problems.go
|
|
// exists to keep out of the before/after comparison.
|
|
rejectRaw, rerr := RenderRejectFile(rejects)
|
|
if rerr != nil {
|
|
problems.addKeyed("the mined-rejects document cannot be written",
|
|
fmt.Sprintf("the mined-rejects document cannot be written: %v", rerr))
|
|
}
|
|
v.rejects = rejectRaw
|
|
|
|
raw, err := RenderSeedFile(delta)
|
|
if err != nil {
|
|
problems.addKeyed("the mined-delta document cannot be written",
|
|
fmt.Sprintf("the mined-delta document cannot be written: %v", err))
|
|
v.problems = sortedProblems(problems)
|
|
return v
|
|
}
|
|
v.delta = raw
|
|
bs, err := ParseBankSeed("mined-delta", raw)
|
|
if err != nil {
|
|
// One entry PER SUBJECT, not one per verdict. The loader accumulates its faults and joins them
|
|
// for a human; joined, they are a single string that changes whenever any one of them is fixed.
|
|
var sp SeedProblems
|
|
if errors.As(err, &sp) {
|
|
problems = append(problems, sp.Problems...)
|
|
} else {
|
|
problems.addKeyed("the resulting mined-delta would not load",
|
|
fmt.Sprintf("the resulting mined-delta would not load: %v", err))
|
|
bs.Terms = nil // a document that did not even PARSE has no entries to judge
|
|
}
|
|
}
|
|
// ⚠ THE CHECKS BELOW RUN EVEN WHEN THE LOAD FAILED, over the entries the loader did build.
|
|
//
|
|
// They used to be skipped, and that is a blindness with teeth: a delta that does not load hides every
|
|
// collision, gender and voice fault in it, so the moment a decision REPAIRED loadability all of them
|
|
// appeared at once and read as introduced by that decision. The first fix for that excused them
|
|
// wholesale, which was worse — a livelock the call really did introduce was then filed as
|
|
// pre-existing and WRITTEN (reproduced: a set that repairs one row and adds a term colliding with an
|
|
// untouched one). Making the before-verdict COMPLETE instead is what lets the ordinary
|
|
// compare-by-subject rule answer both cases correctly, with no special case at all.
|
|
mined := bs.Terms
|
|
for i := range mined {
|
|
mined[i].Source = "mined" // as loadMinedDelta stamps them: base-excluded, edit-wave only
|
|
}
|
|
// The SEED as the run sees it — with the ruby readings grafted on as firing aliases — and only then
|
|
// joined to the delta. Same order as seedGlossary: attach, then join, then check.
|
|
joined := append(withRubyAliases(in.Seed, in.Ruby), mined...)
|
|
// The subject-carrying variants, NOT the exported []string ones: three of these messages name a
|
|
// term's RENDERING, which is precisely what a decision changes (problems.go, subjectOf).
|
|
problems = append(problems, minedDeltaSeedCollisions(in.Seed, mined)...)
|
|
problems = append(problems, approvedSharedKeyCollisions(joined)...)
|
|
problems = append(problems, genderVocabViolations(mined)...)
|
|
// The seed's voice/address rows are judged against the JOINED set for the same reason seedGlossary
|
|
// judges them there: a character may legitimately be signed in the delta rather than in the base
|
|
// seed, and removing that term leaves a profile that can never fire — which stops the run.
|
|
// This one's message is already identity-only — it names a character and a sense and nothing a
|
|
// decision can rewrite — so Subject == Text is a correct key for it.
|
|
for _, msg := range UnknownVoiceCharacters(joined, in.Voices, in.Pairs) {
|
|
problems.add(msg)
|
|
}
|
|
v.problems = sortedProblems(problems)
|
|
return v
|
|
}
|
|
|
|
// sortedProblems orders a verdict deterministically. Every one of these checks is order-deterministic on
|
|
// its own; the sort is what makes the JOIN of four of them one stable list.
|
|
func sortedProblems(in problemList) []Problem {
|
|
out := []Problem(in)
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Text < out[j].Text })
|
|
return out
|
|
}
|
|
|
|
// withRubyAliases returns a COPY of the seed entries with the book's name-shaped ruby readings attached
|
|
// as aliases, exactly as the run does before it judges collisions.
|
|
//
|
|
// A copy, and a deep one on the alias slices: AttachRubyAliasesToManual appends in place, and an append
|
|
// that happens to fit a shared backing array would reach back into the caller's rows — which here are
|
|
// the loaded seed, read again by the before/after comparison. The readings it REFUSES to graft (a
|
|
// homophone contested between two seeded terms) are the run's own diagnostic, not a fault of this
|
|
// document, so they are deliberately dropped here.
|
|
func withRubyAliases(seedEntries []store.GlossaryEntry, ruby []store.RubyReading) []store.GlossaryEntry {
|
|
out := make([]store.GlossaryEntry, len(seedEntries))
|
|
for i, e := range seedEntries {
|
|
e.Aliases = append([]store.GlossaryAlias(nil), e.Aliases...)
|
|
out[i] = e
|
|
}
|
|
if len(ruby) > 0 {
|
|
AttachRubyAliasesToManual(out, ruby)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RenderSeedFile is the canonical bytes of a seed document — the form the engine writes, and the form
|
|
// two identical decision sets have to produce for a retry to be a byte-level no-op.
|
|
//
|
|
// It PROVES its own output. See renderProved: the engine does not get to write a document it cannot
|
|
// read back, and "cannot read back" includes reading back a DIFFERENT one.
|
|
func RenderSeedFile(f seed.File) ([]byte, error) {
|
|
return renderProved(f.Normalize(), func(b []byte) (seed.File, error) {
|
|
back, err := seed.DecodeFile(b)
|
|
return back.Normalize(), err
|
|
}, "mined-delta document")
|
|
}
|
|
|
|
// RenderRejectFile is RenderSeedFile for the reject list — same gate, other schema.
|
|
func RenderRejectFile(f seed.RejectFile) ([]byte, error) {
|
|
return renderProved(f.Normalize(), func(b []byte) (seed.RejectFile, error) {
|
|
back, err := seed.DecodeRejects(b)
|
|
return back.Normalize(), err
|
|
}, "mined-rejects document")
|
|
}
|
|
|
|
// RenderSignatureMap renders the owner signature map under the seam envelope (seed.SignatureMap): the
|
|
// same proving gate as the two decision documents, because the map is read by the other side of the
|
|
// seam and a map the engine cannot read back is a signing screen that silently shows nothing.
|
|
func RenderSignatureMap(f seed.File) ([]byte, error) {
|
|
content := f.Normalize()
|
|
id, err := seed.SignatureMapID(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("membank: %w", err)
|
|
}
|
|
m := seed.SignatureMap{Version: seed.SignatureMapVersion, ID: id, File: content}
|
|
return renderProved(m, func(b []byte) (seed.SignatureMap, error) {
|
|
back, err := seed.DecodeSignatureMap(b)
|
|
back.File = back.Normalize()
|
|
return back, err
|
|
}, "signature map")
|
|
}
|
|
|
|
// renderProved marshals a NORMALIZED document and proves the bytes read back as that same document,
|
|
// through the real strict decoder the next run will use.
|
|
//
|
|
// The gate exists because the YAML library the engine writes with can emit a document it cannot itself
|
|
// parse, and can emit one that parses to a DIFFERENT value. Measured on gopkg.in/yaml.v3 v3.0.1: a
|
|
// string whose first byte is a newline becomes a block scalar whose indentation indicator disagrees
|
|
// with the indentation written inside a sequence item — both documents of this schema are sequences of
|
|
// mappings, so it reaches every text field of both. `seed.File.Normalize` removes the input that
|
|
// triggers it; this proves the result instead of assuming the trigger set is fully known. It is the
|
|
// difference between a door that refuses a lawful decline and a door that BRICKS the book: before this
|
|
// gate the bytes were written, and every later call on that book — including the projection the law
|
|
// calls a safe preview — died reading them.
|
|
//
|
|
// A document that fails the gate is an ERROR, never bytes: all-or-nothing means the caller writes
|
|
// nothing, and the reason travels as a refusal rather than as a corrupted file.
|
|
//
|
|
// ⚠ Precision about the route not taken: `(*yaml.Node).Encode` is closed to us (it round-trips
|
|
// internally and fails on the same input), but a HAND-BUILT node tree with an explicit quoted style is
|
|
// not — the emitter checks an explicit Style BEFORE the block-scalar branch (encode.go:557-570), so it
|
|
// renders such a value correctly. It is rejected on cost, not on possibility: it means a second,
|
|
// hand-written renderer for the whole schema, and it would not remove this gate.
|
|
//
|
|
// Cost is one extra parse per render. Measured in the report; the verb is $0 and capped either way.
|
|
func renderProved[T any](doc T, readBack func([]byte) (T, error), what string) ([]byte, error) {
|
|
b, err := yaml.Marshal(doc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("membank: marshal the %s: %w", what, err)
|
|
}
|
|
back, err := readBack(b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("membank: the %s the engine would write does not read back: %w", what, err)
|
|
}
|
|
if reflect.DeepEqual(back, doc) {
|
|
return b, nil
|
|
}
|
|
return nil, fmt.Errorf("membank: the %s the engine would write reads back as a DIFFERENT document%s",
|
|
what, firstDivergence(b, back))
|
|
}
|
|
|
|
// firstDivergence renders what came back and names the first line that differs from what would have
|
|
// been written, because "a different document" without a place to look is not actionable.
|
|
func firstDivergence[T any](written []byte, back T) string {
|
|
again, err := yaml.Marshal(back)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
w, r := strings.Split(string(written), "\n"), strings.Split(string(again), "\n")
|
|
for i := 0; i < len(w) || i < len(r); i++ {
|
|
lw, lr := "", ""
|
|
if i < len(w) {
|
|
lw = w[i]
|
|
}
|
|
if i < len(r) {
|
|
lr = r[i]
|
|
}
|
|
if lw != lr {
|
|
return fmt.Sprintf(" — line %d would be written as %q and reads back as %q", i+1, lw, lr)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func cloneFile(f seed.File) seed.File {
|
|
out := f
|
|
out.Terms = append([]seed.Term(nil), f.Terms...)
|
|
out.Voices = append([]seed.Voice(nil), f.Voices...)
|
|
out.Addresses = append([]seed.Address(nil), f.Addresses...)
|
|
return out
|
|
}
|
|
|
|
func cloneRejects(f seed.RejectFile) seed.RejectFile {
|
|
return seed.RejectFile{Rejects: append([]seed.Reject(nil), f.Rejects...)}
|
|
}
|