184 lines
8.1 KiB
Go
184 lines
8.1 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"textmachine/backend/internal/config"
|
|
)
|
|
|
|
// snapshotdiff.go: naming WHAT moved when a snapshot moves, instead of asserting why.
|
|
//
|
|
// THE DEFECT THIS CLOSES. The re-pin guard used to tell an operator «the config/prompts changed». On the
|
|
// cold run of 31.08 nothing of the sort had happened: config and prompts were byte-identical and what had
|
|
// moved was the auto-bank, which the engine had itself WARNed about one purchase earlier. The message sent
|
|
// its reader hunting for an edit that did not exist. Under the disclosure law that is §2.2 — a message may
|
|
// name only the field it actually COMPARED — and the comparison is free, because the engine stores the
|
|
// full payload of every snapshot it ever built (store.SnapshotPayload) and buildSnapshotID hands back the
|
|
// current one.
|
|
//
|
|
// ⛔ DETERMINISTIC BY CONSTRUCTION, and that is a requirement rather than a nicety. The neighbouring
|
|
// classifySnapshotMove ranges over a decoded map and returns on the FIRST key that differs: it answers a
|
|
// yes/no question, so which key it happens to see first does not matter to it. It matters here — a message
|
|
// whose text depends on Go's map iteration order would read differently on two runs over the same two
|
|
// snapshots, and an operator comparing two logs would be told two different stories. So every level sorts
|
|
// its keys before descending, and the output is a sorted, capped list of dotted paths.
|
|
//
|
|
// It reports PATHS and never values: a payload carries prompt hashes, model names and embedded-data
|
|
// versions, and the guard's job is to say which axis moved, not to print the axis.
|
|
|
|
// snapshotDiffMax bounds the paths a guard message will list. A move that touches more than a handful of
|
|
// axes is a wholesale config change, and naming the first few plus a count is as actionable as naming
|
|
// thirty — while an unbounded list would put a screenful of dotted paths inside one error string.
|
|
const snapshotDiffMax = 6
|
|
|
|
// snapshotFieldDiff returns the sorted dotted paths at which the two payloads differ, and whether the
|
|
// comparison could be made at all.
|
|
//
|
|
// ok=false means exactly one thing: the engine could not compare (a payload is missing — an id written by
|
|
// an older schema, or a row never upserted — or one of them is not the JSON object this format promises).
|
|
// The caller must then say it cannot name the cause. It must NOT fall back to a guess: an unverified cause
|
|
// is what this file exists to remove.
|
|
func snapshotFieldDiff(storedPayload, currentPayload string) (paths []string, ok bool) {
|
|
if strings.TrimSpace(storedPayload) == "" || strings.TrimSpace(currentPayload) == "" {
|
|
return nil, false
|
|
}
|
|
var was, now any
|
|
if json.Unmarshal([]byte(storedPayload), &was) != nil || json.Unmarshal([]byte(currentPayload), &now) != nil {
|
|
return nil, false
|
|
}
|
|
var out []string
|
|
diffJSONPaths("", was, now, &out)
|
|
sort.Strings(out)
|
|
return out, true
|
|
}
|
|
|
|
// diffJSONPaths walks two decoded JSON values in lockstep and appends the dotted path of every leaf that
|
|
// differs. Objects descend by SORTED key and arrays by index, so the walk order — and therefore the
|
|
// output — is a function of the payloads alone.
|
|
//
|
|
// A value that changes SHAPE (an object where there was a string, an array that grew) is reported at its
|
|
// own path rather than descended into: the axis that moved is the field itself, and enumerating the
|
|
// contents of a struct that did not exist before would bury it.
|
|
func diffJSONPaths(prefix string, was, now any, out *[]string) {
|
|
switch w := was.(type) {
|
|
case map[string]any:
|
|
n, same := now.(map[string]any)
|
|
if !same {
|
|
*out = append(*out, pathOr(prefix))
|
|
return
|
|
}
|
|
keys := make([]string, 0, len(w)+len(n))
|
|
seen := make(map[string]bool, len(w)+len(n))
|
|
for k := range w {
|
|
keys, seen[k] = append(keys, k), true
|
|
}
|
|
for k := range n {
|
|
if !seen[k] {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
wv, hadW := w[k]
|
|
nv, hadN := n[k]
|
|
if !hadW || !hadN { // a component that appeared or was dropped IS the move
|
|
*out = append(*out, join(prefix, k))
|
|
continue
|
|
}
|
|
diffJSONPaths(join(prefix, k), wv, nv, out)
|
|
}
|
|
case []any:
|
|
n, same := now.([]any)
|
|
if !same || len(w) != len(n) {
|
|
*out = append(*out, pathOr(prefix))
|
|
return
|
|
}
|
|
for i := range w {
|
|
diffJSONPaths(fmt.Sprintf("%s[%d]", pathOr(prefix), i), w[i], n[i], out)
|
|
}
|
|
default:
|
|
if !jsonScalarEqual(was, now) {
|
|
*out = append(*out, pathOr(prefix))
|
|
}
|
|
}
|
|
}
|
|
|
|
func join(prefix, key string) string {
|
|
if prefix == "" {
|
|
return key
|
|
}
|
|
return prefix + "." + key
|
|
}
|
|
|
|
// pathOr names the ROOT when a difference is found at the very top — a payload that is not an object at
|
|
// all. Without it such a move would be reported as an empty string.
|
|
func pathOr(prefix string) string {
|
|
if prefix == "" {
|
|
return "<whole payload>"
|
|
}
|
|
return prefix
|
|
}
|
|
|
|
// jsonScalarEqual compares two decoded JSON scalars. encoding/json decodes every number to float64, so ==
|
|
// is exact for the integers and small decimals a snapshot payload carries; nil compares equal only to nil.
|
|
func jsonScalarEqual(a, b any) bool { return a == b }
|
|
|
|
// describeSnapshotMove renders the guard's WHAT-MOVED clause from a diff.
|
|
//
|
|
// It is a separate function from the guard so the text has ONE definition and can be asserted directly by
|
|
// a test, rather than fished out of a formatted error — the D39.171 trap, where an assertion on a
|
|
// substring of a shared log buffer stays green while the message it claims to pin has changed.
|
|
func describeSnapshotMove(paths []string, ok bool) string {
|
|
if !ok {
|
|
// §2.2: no comparison, no cause. The reader is told what the engine does NOT know, which is
|
|
// actionable (it says «do not go looking for a config edit on my word»), unlike a guess.
|
|
return "the stored snapshot's payload is not available, so WHAT moved cannot be named — do not assume a config or prompt edit"
|
|
}
|
|
if len(paths) == 0 {
|
|
// Two different ids over identical payloads: impossible by construction, which is exactly why it
|
|
// must be said rather than smoothed into a plausible sentence.
|
|
return "the ids differ but no payload field does — the snapshot format and the id are out of step; this is an engine bug, not a config edit"
|
|
}
|
|
shown := paths
|
|
extra := 0
|
|
if len(shown) > snapshotDiffMax {
|
|
shown, extra = shown[:snapshotDiffMax], len(paths)-snapshotDiffMax
|
|
}
|
|
s := "what moved: " + strings.Join(shown, ", ")
|
|
if extra > 0 {
|
|
s += fmt.Sprintf(" and %d more field(s)", extra)
|
|
}
|
|
// The one hint worth carrying, because it changes what the operator DOES: a bank-only move is not an
|
|
// edit anybody made to a file, it is the auto-bank growing between purchases, and the engine warns
|
|
// about it a purchase earlier (mining.go). Naming it here closes the loop between the two messages.
|
|
if len(paths) == 1 && paths[0] == memoryVersionField {
|
|
s += " — that is the BANK, not a config or prompt edit: the auto-bank grew between purchases"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// describeSnapshotMoveFor is the guard's own accessor: it fetches the stored payload, renders the current
|
|
// one for the stage's wave, and describes the difference.
|
|
//
|
|
// EVERY failure degrades to «cannot name it» rather than to a guess, and each degradation is logged with
|
|
// its reason. That asymmetry is the whole point: a guard that cannot compare must say so, because the
|
|
// alternative — the sentence this function replaced — is a cause the engine never verified, and it costs
|
|
// the next session a hunt through an unedited config.
|
|
func (r *Runner) describeSnapshotMoveFor(storedID string, st config.Stage) string {
|
|
stored, err := r.Store.SnapshotPayload(storedID)
|
|
if err != nil {
|
|
r.Log.Warn("snapshot guard: the stored payload could not be read, so the moved field cannot be named",
|
|
"book", r.Book.BookID, "stored", storedID, "err", err)
|
|
return describeSnapshotMove(nil, false)
|
|
}
|
|
_, current, err := r.snapshotIDForWave(waveOfStage(r.Pipeline.Stages, st.Name))
|
|
if err != nil {
|
|
r.Log.Warn("snapshot guard: the current payload could not be rendered, so the moved field cannot be named",
|
|
"book", r.Book.BookID, "stage", st.Name, "err", err)
|
|
return describeSnapshotMove(nil, false)
|
|
}
|
|
return describeSnapshotMove(snapshotFieldDiff(stored, current))
|
|
}
|