diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cb4237c..0ecb6a6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -331,7 +331,7 @@ Roadmap candidate 4. Owner: engine `override.go`. Validate: `go test ./internal/engine -run 'Override|CLI|Provenance|Diagnostic'` and the everyday check. No policy changes. -## Stage 13: Coalesce resolution replay once with indexed ancestors +## Stage 13: Coalesce resolution replay once with indexed ancestors — Complete Roadmap candidate 7. Owners: engine `history.go`, `load.go`, `load_result.go`. diff --git a/internal/engine/explain.go b/internal/engine/explain.go index b78720a..282a0cf 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -91,7 +91,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { } working = document.Clone(r.input) - for _, snapshot := range coalescedResolutionSnapshots(r.resolution) { + for _, snapshot := range r.resolutionReplay { relevant := pathsIntersect(snapshot.change.Path, path) var before ValueSnapshot if relevant { diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index a53a89d..4cd9c27 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -520,3 +520,49 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) }) } } + +func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) { + type config struct { + Count int `yaml:"count"` + } + result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{ + Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { + value.Count = 2 + resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count")) + return nil + }}, + }) + if err != nil { + t.Fatal(err) + } + first, ok := result.Explain(Keys("count")) + if !ok { + t.Fatal("missing explanation") + } + var firstResolution *ValueChange + for index := range first.History { + if first.History[index].Origin.Layer == LayerResolution { + firstResolution = &first.History[index] + break + } + } + if firstResolution == nil || len(firstResolution.Reasons) != 1 || len(firstResolution.Dependencies) != 1 { + t.Fatalf("resolution history = %#v", first.History) + } + firstResolution.Reasons[0] = "mutated" + firstResolution.Dependencies[0].segments[0].key = "mutated" + + second, ok := result.Explain(Keys("count")) + if !ok { + t.Fatal("missing repeated explanation") + } + for _, change := range second.History { + if change.Origin.Layer == LayerResolution { + if !reflect.DeepEqual(change.Reasons, []string{"normalized"}) || len(change.Dependencies) != 1 || change.Dependencies[0].String() != "/count" { + t.Fatalf("repeated explanation observed caller mutation: %#v", change) + } + return + } + } + t.Fatalf("resolution change missing from repeated explanation: %#v", second.History) +} diff --git a/internal/engine/history.go b/internal/engine/history.go index 73e30d7..a72c526 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -179,28 +179,50 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node // intent into that event rather than replay a child assignment a second time. func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot { groups := make([]resolutionSnapshot, 0, len(snapshots)) + reasons := make([]map[string]struct{}, 0, len(snapshots)) + dependencies := make([]map[string]struct{}, 0, len(snapshots)) + var coverage pathPrefixIndex for _, snapshot := range snapshots { - merged := false - for i := range groups { - if !pathContains(groups[i].change.Path, snapshot.change.Path) { - continue + pointer := snapshot.change.Path.String() + groupIndex := -1 + coverage.visitAncestors(pointer, func(id int) { + if groupIndex < 0 || id < groupIndex { + groupIndex = id } + }) + if groupIndex >= 0 { + group := &groups[groupIndex] for _, reason := range snapshot.change.Reasons { - groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason) + if _, found := reasons[groupIndex][reason]; !found { + reasons[groupIndex][reason] = struct{}{} + group.change.Reasons = append(group.change.Reasons, reason) + } } for _, dependency := range snapshot.change.Dependencies { - groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency) + dependencyPointer := dependency.String() + if _, found := dependencies[groupIndex][dependencyPointer]; !found { + dependencies[groupIndex][dependencyPointer] = struct{}{} + group.change.Dependencies = append(group.change.Dependencies, dependency.clone()) + } } if snapshot.change.Origin.Operation == ChangeNormalization { - groups[i].change.Origin.Operation = ChangeNormalization + group.change.Origin.Operation = ChangeNormalization } - merged = true - break + continue } - if !merged { - snapshot.change = cloneChange(snapshot.change) - groups = append(groups, snapshot) + snapshot.change = cloneChange(snapshot.change) + groups = append(groups, snapshot) + reasonSet := make(map[string]struct{}, len(snapshot.change.Reasons)) + for _, reason := range snapshot.change.Reasons { + reasonSet[reason] = struct{}{} } + reasons = append(reasons, reasonSet) + dependencySet := make(map[string]struct{}, len(snapshot.change.Dependencies)) + for _, dependency := range snapshot.change.Dependencies { + dependencySet[dependency.String()] = struct{}{} + } + dependencies = append(dependencies, dependencySet) + coverage.insert(pointer, len(groups)-1) } return groups } diff --git a/internal/engine/inheritance_history_test.go b/internal/engine/inheritance_history_test.go index cadf98b..e81d52f 100644 --- a/internal/engine/inheritance_history_test.go +++ b/internal/engine/inheritance_history_test.go @@ -324,6 +324,7 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) { replaceHistoryPath(&final, path, replacement) inspection.final = final inspection.resolution = []resolutionSnapshot{snapshot} + inspection.resolutionReplay = coalescedResolutionSnapshots(inspection.resolution) inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change) inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin} history := inspection.Metadata().History(path) diff --git a/internal/engine/load.go b/internal/engine/load.go index 839944f..9c2fa0d 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -85,6 +85,7 @@ type Inspection struct { inputOrigins map[string]Origin final *document.Node resolution []resolutionSnapshot + resolutionReplay []resolutionSnapshot applications []historyApplication inheritance []inheritanceOperation secrets map[string]bool diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index 72d2e76..091a697 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -108,6 +108,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] inputOrigins: cloneOriginIndex(decoded.origins), final: final, resolution: resolutionSnapshots, + resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots), applications: applications, inheritance: inheritanceOperations(resolved.inheritance), secrets: secrets, diff --git a/internal/engine/resolution_index_test.go b/internal/engine/resolution_index_test.go index fb6f4fe..a205e6e 100644 --- a/internal/engine/resolution_index_test.go +++ b/internal/engine/resolution_index_test.go @@ -1,11 +1,64 @@ package engine import ( + "reflect" "testing" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) +func TestCoalescedResolutionSnapshotsMatchOrderedScan(t *testing.T) { + parsedDependency := parseOverrideTestPath(t, "/dependencies/item") + snapshots := []resolutionSnapshot{ + {change: Change{Path: Keys("a", "b"), Reasons: []string{"child", "shared", "child"}, Dependencies: []Path{Keys("dependencies", "item")}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Keys("a", "c"), Reasons: []string{"other"}, Dependencies: []Path{Keys("other")}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Keys("a"), Reasons: []string{"parent"}, Dependencies: []Path{Keys("parent")}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Keys("a", "b", "grand"), Reasons: []string{"deep", "shared"}, Dependencies: []Path{parsedDependency, Keys("deep")}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Path{}, Reasons: []string{"root"}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Keys("outside"), Reasons: []string{"root child"}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + {change: Change{Path: Keys("a", "b", "normalization"), Reasons: []string{"normalized"}, Origin: Origin{Operation: ChangeNormalization}}}, + {change: Change{Path: Keys("a", "none"), Reasons: []string{"parent child"}, Origin: Origin{Operation: ChangeDerivedDefault}}}, + } + want := coalescedResolutionSnapshotsByScan(snapshots) + got := coalescedResolutionSnapshots(snapshots) + if !reflect.DeepEqual(got, want) { + t.Fatalf("coalesced snapshots differ from ordered scan:\n got: %#v\nwant: %#v", got, want) + } + if len(got) != 4 || got[0].change.Origin.Operation != ChangeNormalization || + !reflect.DeepEqual(got[0].change.Reasons, []string{"child", "shared", "child", "deep", "normalized"}) || + len(got[0].change.Dependencies) != 2 || got[3].change.Path.String() != "" { + t.Fatalf("coalesced ordering or unions changed: %#v", got) + } +} + +func coalescedResolutionSnapshotsByScan(snapshots []resolutionSnapshot) []resolutionSnapshot { + groups := make([]resolutionSnapshot, 0, len(snapshots)) + for _, snapshot := range snapshots { + merged := false + for i := range groups { + if !pathContains(groups[i].change.Path, snapshot.change.Path) { + continue + } + for _, reason := range snapshot.change.Reasons { + groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason) + } + for _, dependency := range snapshot.change.Dependencies { + groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency) + } + if snapshot.change.Origin.Operation == ChangeNormalization { + groups[i].change.Origin.Operation = ChangeNormalization + } + merged = true + break + } + if !merged { + snapshot.change = cloneChange(snapshot.change) + groups = append(groups, snapshot) + } + } + return groups +} + func TestResolutionCoverageIndexMatchesOrderedScan(t *testing.T) { paths := []Path{ Keys("group"),