textmachine/backend/internal/pipeline/snapshotdiff.go

186 lines
8.3 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 means no
// file was edited, so the answer is in the bank's own history rather than in a diff of the configs.
// ⚠ WHICH bank change it was is NOT known here — an auto-bank that grew between purchases, a row the
// owner signed, a seed edit and a reject all move the same field, and this function sees one hash
// against another. The message used to name the first of them as fact.
if len(paths) == 1 && paths[0] == memoryVersionField {
s += " — that is the BANK, not a config or prompt edit (what changed in it is in the bank's own history)"
}
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))
}