Preserve append history across tiers

This commit is contained in:
2026-10-16 15:36:46 +00:00
parent bb9bde799a
commit b1b5163cc3
4 changed files with 293 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
# Implementation Plan: Sequence Placement and Composition
Status: ready for implementation; no stages completed.
Status: implementation in progress; stages 1–4 completed.
## Purpose and authority
@@ -333,6 +333,21 @@ Scope: private history operations, replay, and precedence handoff.
Validation: history/explanation/overlay tests and `./scripts/check.sh`.
Completion: strict accumulation is distinguishable from precedence replacement.
Completion evidence: history applications now carry owned per-collection
contribution ranges and distinguish each strict tier's first sequence
contribution (replacement) from later appends. Replay preserves source
applications through underlay handoff, maps appended record queries back to
source-local indexes, and supports semantic indexed helper paths. Tests cover
appended and independent collections, origin attribution, detached paths,
explicit empty-list replacement, ordinary whole-list replacement, and tier
handoff. These checks passed with Go `go1.26.9 linux/amd64`:
- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'History|Explain|Overlay' -count=1`
- `GOTOOLCHAIN=local ./scripts/check.sh`
- `git diff --check`
No environmental limitation affected validation; checks ran on Linux/amd64.
## Stage 5 — Integrate projected paths with inspection and sensitivity
Scope: metadata, retained history traversal, and safe inspection.

View File

@@ -16,6 +16,20 @@ type composedDocument struct {
inputNodes int
}
type sequenceHistoryMode uint8
const (
sequenceHistoryReplace sequenceHistoryMode = iota + 1
sequenceHistoryAppend
)
type sequenceHistoryContribution struct {
path Path
mode sequenceHistoryMode
offset int
length int
}
// assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct {
@@ -113,8 +127,24 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
}
applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs {
applications = append(applications, historyApplication{layer: LayerBase, value: input})
seenSequences := make(map[string]bool)
for sourceIndex, input := range inputs {
application := historyApplication{layer: LayerBase, value: input}
for _, assigned := range ranges {
if assigned.sourceIndex != sourceIndex {
continue
}
pointer := assigned.path.String()
mode := sequenceHistoryAppend
if !seenSequences[pointer] {
mode = sequenceHistoryReplace
}
seenSequences[pointer] = true
application.sequences = append(application.sequences, sequenceHistoryContribution{
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
})
}
applications = append(applications, application)
}
origins := projectedCompositionOrigins(inputs, ranges)
completeCompositionOrigins(root, Path{}, origins)

View File

@@ -45,10 +45,11 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
// Replay reconciles with canonical decoding before domain resolution so both
// contracts agree on the effective result without retaining a tree per field.
type historyApplication struct {
layer Layer
path Path
value *document.Node
origin Origin
layer Layer
path Path
value *document.Node
origin Origin
sequences []sequenceHistoryContribution
}
func cloneHistoryApplications(values []historyApplication) []historyApplication {
@@ -59,7 +60,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
for index, value := range values {
cloned[index] = historyApplication{
layer: value.layer, path: value.path.clone(), value: value.value,
origin: cloneOrigin(value.origin),
origin: cloneOrigin(value.origin), sequences: cloneSequenceHistoryContributions(value.sequences),
}
}
return cloned
@@ -75,6 +76,24 @@ func applicationOperationPath(application historyApplication, query Path, lookup
}
return Path{}, false
}
sequencePathMatched := false
for _, contribution := range application.sequences {
if !pathContains(contribution.path, query) && !pathContains(query, contribution.path) {
continue
}
sequencePathMatched = true
if pathContains(contribution.path, query) && len(query.segments) > len(contribution.path.segments) {
segment := query.segments[len(contribution.path.segments)]
itemIndex, ok := schemaIndex(segment)
if !ok || itemIndex < contribution.offset || itemIndex >= contribution.offset+contribution.length {
continue
}
}
return query.clone(), true
}
if sequencePathMatched {
return Path{}, false
}
if len(query.segments) == 0 {
return Path{}, true
}
@@ -100,6 +119,22 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
if !ok {
return origin
}
for _, contribution := range application.sequences {
if !pathContains(contribution.path, operationPath) {
continue
}
relative, ok = relativeHistoryPath(Path{}, operationPath)
if !ok || len(relative.segments) < len(contribution.path.segments)+1 {
break
}
itemPosition := len(contribution.path.segments)
itemIndex, valid := schemaIndex(relative.segments[itemPosition])
if !valid || itemIndex < contribution.offset || itemIndex >= contribution.offset+contribution.length {
break
}
relative.segments[itemPosition] = pathSegment{kind: pathSegmentIndex, index: itemIndex - contribution.offset}
break
}
if node, present := documentAtPathWithLookup(application.value, relative, lookup); present {
origin.Locations = []Location{publicLocation(node.Location)}
}
@@ -119,13 +154,17 @@ func pathsIntersect(left, right Path) bool {
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value, index)
mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequences)
return
}
mergeHistoryAtPath(root, application.path, application.value, index)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
}
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences []sequenceHistoryContribution) {
if incoming == nil {
return
}
@@ -141,10 +180,49 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index
index.append(*target, cloned)
continue
}
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index)
entryPath := path.Key(entry.Key)
if contribution, found := sequenceHistoryAt(sequences, entryPath); found {
applySequenceHistory(&(*target).Mapping[position].Value, entry.Value, contribution)
continue
}
mergeHistoryDocumentAtPath(&(*target).Mapping[position].Value, entry.Value, entryPath, index, sequences)
}
}
func sequenceHistoryAt(contributions []sequenceHistoryContribution, path Path) (sequenceHistoryContribution, bool) {
for _, contribution := range contributions {
if contribution.path.String() == path.String() {
return contribution, true
}
}
return sequenceHistoryContribution{}, false
}
func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContribution) {
if incoming == nil || incoming.Kind != document.Sequence {
return
}
if contribution.mode == sequenceHistoryReplace || *target == nil || (*target).Kind != document.Sequence {
*target = document.Clone(incoming)
return
}
for _, item := range incoming.Sequence {
(*target).Sequence = append((*target).Sequence, document.Clone(item))
}
}
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
if values == nil {
return nil
}
cloned := make([]sequenceHistoryContribution, len(values))
for index, value := range values {
cloned[index] = value
cloned[index].path = value.path.clone()
}
return cloned
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming, index)
@@ -152,7 +230,19 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
}
current := *root
for segmentIndex, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
if segment.kind == pathSegmentIndex {
position, ok := schemaIndex(segment)
if current.Kind != document.Sequence || !ok || position >= len(current.Sequence) {
return
}
if segmentIndex == len(path.segments)-1 {
mergeHistoryDocument(&current.Sequence[position], incoming, index)
return
}
current = current.Sequence[position]
continue
}
if current.Kind != document.Mapping {
return
}
position := index.find(current, segment.key)

View File

@@ -0,0 +1,147 @@
package engine
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}}
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics)
}
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n")
index := new(mappingIndex)
for _, application := range composed.applications {
applyHistoryApplication(&working, application, index)
}
for path, want := range map[string][]string{
"/records": {"a", "b"},
"/other": {"x", "y"},
} {
sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
t.Fatalf("replayed %s = %#v", path, sequence)
}
for itemIndex, id := range want {
field := sequence.Sequence[itemIndex].Mapping[0].Value.Text
if field != id {
t.Errorf("replayed %s[%d].id = %q, want %q", path, itemIndex, field, id)
}
}
}
application := composed.applications[1]
cloned := cloneHistoryApplications(composed.applications)
cloned[1].sequences[0].path.segments[0].key = "mutated"
if composed.applications[1].sequences[0].path.String() != "/records" {
t.Fatal("cloned sequence history path aliases its source")
}
addedPath := Keys("records").Index(1).Key("value")
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
}
if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant {
t.Fatal("later contribution was reported as changing an earlier record")
}
origin := historyApplicationOrigin(application, addedPath, nil)
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
t.Fatalf("appended value origin = %#v", origin)
}
overlay := historyApplication{
layer: LayerOverlay,
value: parseCompositionDocument(t, "overlay", "records: []\n"),
}
applyHistoryApplication(&working, overlay, index)
replaced, present := documentAtPath(working, Keys("records"))
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
}
}
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}}
first, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("first tier diagnostics = %#v", diagnostics)
}
for index := range first.applications {
first.applications[index].layer = LayerUnderlay
}
second, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-2", "records: [{id: shared}]\nindependent: []\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("second tier diagnostics = %#v", diagnostics)
}
main, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "main", "records: []\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("main diagnostics = %#v", diagnostics)
}
assembly := newAssemblyAccumulator(first)
if diagnostics := assembly.applyOverlay(second.root, second.origins, second.applications, LayerUnderlay); len(diagnostics) != 0 {
t.Fatalf("second tier handoff diagnostics = %#v", diagnostics)
}
if diagnostics := assembly.applyOverlay(main.root, main.origins, main.applications, LayerBase); len(diagnostics) != 0 {
t.Fatalf("main handoff diagnostics = %#v", diagnostics)
}
if len(assembly.current.applications) != 4 {
t.Fatalf("retained source applications = %d, want 4", len(assembly.current.applications))
}
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nindependent: [{id: default}]\n")
index := new(mappingIndex)
for _, application := range assembly.current.applications {
applyHistoryApplication(&working, application, index)
}
for path, want := range map[string][]string{
"/records": nil,
"/independent": nil,
} {
sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
t.Errorf("replayed replacement %s = %#v, want an explicit empty sequence", path, sequence)
}
}
// The same identity may appear in separate tiers, while each tier still
// retains its individual source applications for explanation replay.
if got := []Layer{assembly.current.applications[0].layer, assembly.current.applications[1].layer,
assembly.current.applications[2].layer, assembly.current.applications[3].layer}; !reflect.DeepEqual(got, []Layer{LayerUnderlay, LayerUnderlay, LayerUnderlay, LayerBase}) {
t.Fatalf("application layers = %#v", got)
}
}
func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
root := parseCompositionDocument(t, "base", "records: [{id: one, helper: {value: old}}]\n")
application := historyApplication{
path: Keys("records").Index(0).Key("helper"),
value: parseCompositionDocument(t, "helper", "value: new\n"),
}
applyHistoryApplication(&root, application, new(mappingIndex))
value, present := documentAtPath(root, Keys("records").Index(0).Key("helper").Key("value"))
if !present || value.Text != "new" {
t.Fatalf("indexed helper replay = %#v, %v", value, present)
}
}
func mustParsePath(t *testing.T, value string) Path {
t.Helper()
path, err := ParsePath(value)
if err != nil {
t.Fatal(err)
}
return path
}