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 "" } 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)) }