diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cbb92dd..41252f0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–19 complete. +Status: implementation in progress; Stages 1–20 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -702,6 +702,10 @@ Validation: inheritance execution, scheduling, history, and budget tests plus the everyday check. This refactor does not introduce new parent-reference forms or change public loading semantics yet. +**Completion:** Implemented reusable mapping and identified-sequence scope +execution with focused inheritance/scheduling/history/budget tests, +`go test -race ./internal/engine`, and `./scripts/check.sh`. + ## Stage 21 — Resolve Record Inheritance With Ownership And Evidence Scope: integrate Stage 19's graphs and Stage 20's executor into the structural diff --git a/internal/engine/inheritance_identified_test.go b/internal/engine/inheritance_identified_test.go index ee2138a..520f3a1 100644 --- a/internal/engine/inheritance_identified_test.go +++ b/internal/engine/inheritance_identified_test.go @@ -61,6 +61,92 @@ func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing. } } +func TestIdentifiedInheritanceScopeAdapterReusesTemplateExecution(t *testing.T) { + sequence := parseIdentifiedInheritanceSequence(t, "identified-scope", `- id: child-a + tenant: team-a + extends: {id: parent-a, tenant: team-a} + settings: {child: a} +- id: parent-a + tenant: team-a + settings: {parent: a} +- id: child-b + tenant: team-b + extends: {id: parent-b, tenant: team-b} + settings: {child: b} +- id: parent-b + tenant: team-b + settings: {parent: b} +`) + path := Keys("jobs") + graph, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, path, []string{"tenant", "id"}) + if len(diagnostics) != 0 { + t.Fatalf("analyze identified graph: %#v", diagnostics) + } + policies := collectionPolicyScope{} + policies.set(path.Index(1).Key("steps"), collectionPolicy{ + identityFields: []string{"step-id"}, identityPresent: true, + }) + result, diagnostics := applyIdentifiedInheritanceTemplates(graph, defaultProjectionLimits(), nil, policies, sequencePolicy{}) + if len(diagnostics) != 0 { + t.Fatalf("apply identified templates: %#v", diagnostics) + } + if got := len(result.root.Sequence); got != 4 { + t.Fatalf("result sequence length = %d, want 4", got) + } + for index, want := range []string{"child-a", "parent-a", "child-b", "parent-b"} { + id, found := documentAtPath(result.root.Sequence[index], Path{}.Key("id")) + if !found || id.Text != want { + t.Fatalf("result identity at index %d = %#v, found %t; want %q", index, id, found, want) + } + } + for index, want := range []string{"a", "b"} { + value, found := documentAtPath(result.root.Sequence[index*2], Path{}.Key("settings").Key("parent")) + if !found || value.Text != want { + t.Fatalf("inherited value at record %d = %#v, found %t; want %q", index*2, value, found, want) + } + } + if len(result.transfers) != 2 || result.transfers[0].from.String() != "/jobs/1" || result.transfers[0].to.String() != "/jobs/0" || + result.transfers[1].from.String() != "/jobs/3" || result.transfers[1].to.String() != "/jobs/2" { + t.Fatalf("record transfers = %#v, want independent transfers at authored positions", result.transfers) + } + if len(result.controls) != 2 || result.controls[0].path.String() != "/jobs/0/extends" || result.controls[1].path.String() != "/jobs/2/extends" { + t.Fatalf("relationship controls = %#v, want paths from child positions", result.controls) + } + if len(result.operations) != 4 || result.operations[1].transfer == nil || result.operations[1].transfer.to.String() != "/jobs/0" || + result.operations[3].transfer == nil || result.operations[3].transfer.to.String() != "/jobs/2" { + t.Fatalf("relationship history operations = %#v, want transfer evidence at child positions", result.operations) + } + if _, found := result.origins[path.Index(0).Key("id").String()]; !found { + t.Fatal("resolved sequence output did not retain the child identity origin path") + } + projectedPolicy, found := result.collectionPolicies.at(path.Index(0).Key("steps")) + if !found || !projectedPolicy.identityPresent || len(projectedPolicy.identityFields) != 1 || projectedPolicy.identityFields[0] != "step-id" { + t.Fatalf("inherited nested collection policy = %#v, found %t", projectedPolicy, found) + } + adapter := identifiedSequenceInheritanceScope{graph: graph, policies: policies} + fields := adapter.templates()[0].identityFields + if len(fields) != 2 || fields[0] != "id" || fields[1] != "tenant" { + t.Fatalf("payload identity fields = %v, want [id tenant]", fields) + } + templates := adapter.templates() + parentPolicy, found := templates[1].collectionPolicies.at(Keys("steps")) + if !found || len(parentPolicy.identityFields) != 1 || parentPolicy.identityFields[0] != "step-id" { + t.Fatalf("parent nested policy = %#v, found %t", parentPolicy, found) + } + if _, found := templates[0].collectionPolicies.at(Keys("steps")); found { + t.Fatal("nested policy from one record leaked into a sibling template") + } + parentPolicy.identityFields[0] = "changed" + templates[1].collectionPolicies.set(Keys("steps"), parentPolicy) + reused, found := adapter.templates()[1].collectionPolicies.at(Keys("steps")) + if !found || len(reused.identityFields) != 1 || reused.identityFields[0] != "step-id" { + t.Fatal("reusing the adapter observed a mutation to a prior template snapshot") + } + if sequence.Sequence[0].Mapping[0].Value.Text != "child-a" { + t.Fatal("executing the sequence adapter mutated its source scope") + } +} + func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) { sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child extends: {id: parent, tenant: acme} diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go index a59d02d..fbc18ac 100644 --- a/internal/engine/inheritance_schedule.go +++ b/internal/engine/inheritance_schedule.go @@ -155,40 +155,45 @@ func expandSiblingDefinitionsWithCollectionRules(scope *document.Node, scopePath // dependency-ready order while retaining the existing origins, merge history, // and node-accounting evidence. The root keeps its authored mapping order. func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { - resolved := make([]*document.Node, len(graph.nodes)) - resolvedOrigins := make([]map[string]Origin, len(graph.nodes)) - resolvedPolicyTemplates := make([][]collectionPolicyScope, len(graph.nodes)) - resolvedPolicies := make([]collectionPolicyScope, len(graph.nodes)) + return applyInheritanceScopeTemplates(mappingInheritanceScope{graph: graph}, order, limits, inputOrigins, budget...) +} + +func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { + templates := adapter.templates() + resolved := make([]*document.Node, len(templates)) + resolvedOrigins := make([]map[string]Origin, len(templates)) + resolvedPolicyTemplates := make([][]collectionPolicyScope, len(templates)) + resolvedPolicies := make([]collectionPolicyScope, len(templates)) result := &inheritanceScope{} counter := newRetainedNodeCounter(historyNodeLimit) if len(budget) != 0 { counter = budget[0] } - retained := 1 // scope mapping node - for _, entry := range graph.scope.Mapping { - if entry.Value.Kind == document.Mapping { + retained := 1 // scope container node + for _, slot := range adapter.slots() { + if slot.hasTemplate || slot.value.Kind == document.Mapping { continue } remaining := limits.nodes - retained - 1 if limits.nodes >= 0 && remaining < 0 { - return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)} + return nil, []Diagnostic{inheritanceBudgetDiagnostic(slot.path, slot.value.Location)} } - count, within := inheritanceLowerBoundCount(entry.Value, remaining) + count, within := inheritanceLowerBoundCount(slot.value, remaining) if !within { - return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)} + return nil, []Diagnostic{inheritanceBudgetDiagnostic(slot.path, slot.value.Location)} } retained += 1 + count } for _, index := range order { - node := graph.nodes[index] + node := templates[index] templatePolicies := cloneCollectionPolicyScopeForInheritance(node.collectionPolicies) payload := withoutInheritanceDirective(node.entry.Value) operationStart, replacementStart := len(result.operations), len(result.replacements) childOrigins := directInheritanceOrigins(payload, node.path, inputOrigins) if node.hasDirective { control := inheritanceControl{ - path: node.path.Key(inheritanceKey), + path: node.directivePath, locations: []Location{publicLocation(node.directive.Value.Location)}, cancelled: node.cancelled, } @@ -199,7 +204,7 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj }) } if limits.nodes >= 0 { - remaining := limits.nodes - retained - 1 // the sibling key + remaining := limits.nodes - retained - adapter.slotOverhead() within := inheritanceLowerBoundWithin(payload, remaining) if node.hasParent { within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining) @@ -217,14 +222,14 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path) merged := document.Clone(parent) mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies( - parentPolicyScope, childPolicyScope, node.path, parent, graph.applicationPolicy, + parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(), ) if len(policyDiagnostics) != 0 { return nil, policyDiagnostics } transfer := inheritanceTransfer{ - from: graph.nodes[node.parent].path, to: node.path, - reference: node.path.Key(inheritanceKey), + from: templates[node.parent].path, to: node.path, + reference: node.directivePath, locations: []Location{publicLocation(node.directive.Value.Location)}, policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies), projectedPolicies: projectInheritancePolicyTemplates( @@ -253,7 +258,7 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj }, merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) { resolution, _, _, active, issues := inheritanceCollectionResolution( - childPath, parentPolicyScope, childPolicyScope, graph.applicationPolicy, + childPath, parentPolicyScope, childPolicyScope, adapter.applicationPolicy(), ) if len(issues) != 0 { combinationDiagnostics = append(combinationDiagnostics, issues...) @@ -293,10 +298,11 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj return combined.value, true }, }) + preserveInheritanceIdentityFields(merged, payload, node.identityFields) if len(combinationDiagnostics) != 0 { return nil, canonicalDiagnostics(combinationDiagnostics) } - if problems := validateRegisteredInheritanceSequences(merged, node.path, graph.applicationPolicy); len(problems) != 0 { + if problems := validateRegisteredInheritanceSequences(merged, node.path, adapter.applicationPolicy()); len(problems) != 0 { return nil, problems } merged.Location = node.entry.Value.Location @@ -320,7 +326,7 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj } count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes) - retained += 1 + count + retained += adapter.slotOverhead() + count for _, operation := range result.operations[operationStart:] { if !counter.add(operation.parent) || !counter.add(operation.effective) { return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)} @@ -333,30 +339,27 @@ func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits proj } } - root := *graph.scope - root.Mapping = make([]document.Entry, len(graph.scope.Mapping)) + root := adapter.rebuild(resolved) result.collectionPolicies = collectionPolicyScope{} - result.origins = directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: graph.scope.Location}, graph.scopePath, inputOrigins) - for i, entry := range graph.scope.Mapping { - root.Mapping[i] = entry - if index, found := graph.byName[entry.Key]; found { - root.Mapping[i].Value = resolved[index] - projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], graph.nodes[index].path) + result.origins = directInheritanceOrigins(&document.Node{Kind: root.Kind, Location: root.Location}, adapter.scopePath(), inputOrigins) + for _, slot := range adapter.slots() { + if slot.hasTemplate { + index := slot.template + projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], templates[index].path) for pointer, origin := range resolvedOrigins[index] { result.origins[pointer] = origin } continue } - root.Mapping[i].Value = document.Clone(entry.Value) - localPolicies := inheritancePolicyScopeAtPath(graph.collectionPolicies, graph.scopePath.Key(entry.Key)) - projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, graph.scopePath.Key(entry.Key)) - for pointer, origin := range directInheritanceOrigins(entry.Value, graph.scopePath.Key(entry.Key), inputOrigins) { + localPolicies := inheritancePolicyScopeAtPath(adapter.collectionPolicies(), slot.path) + projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, slot.path) + for pointer, origin := range directInheritanceOrigins(slot.value, slot.path, inputOrigins) { result.origins[pointer] = origin } } - result.root = &root + result.root = root if !inheritanceLowerBoundWithin(result.root, limits.nodes) { - return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath, graph.scope.Location)} + return nil, []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)} } return result, nil } diff --git a/internal/engine/inheritance_scope.go b/internal/engine/inheritance_scope.go new file mode 100644 index 0000000..f98a201 --- /dev/null +++ b/internal/engine/inheritance_scope.go @@ -0,0 +1,178 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +// inheritanceScopeAdapter keeps relationship execution independent from the +// container that owns the records. Adapters are private preparation machinery; +// public loading continues to use mapping scopes until record inheritance is +// activated separately. +type inheritanceScopeAdapter interface { + scope() *document.Node + scopePath() Path + collectionPolicies() collectionPolicyScope + applicationPolicy() sequencePolicy + templates() []inheritanceExecutionTemplate + slots() []inheritanceScopeSlot + slotOverhead() int + rebuild(resolved []*document.Node) *document.Node +} + +type inheritanceExecutionTemplate struct { + entry document.Entry + path Path + collectionPolicies collectionPolicyScope + parent int + hasParent bool + hasDirective bool + cancelled bool + directive document.Entry + directivePath Path + identityFields []string +} + +type inheritanceScopeSlot struct { + value *document.Node + path Path + template int + hasTemplate bool +} + +type mappingInheritanceScope struct{ graph *inheritanceGraph } + +func (scope mappingInheritanceScope) scope() *document.Node { return scope.graph.scope } +func (scope mappingInheritanceScope) scopePath() Path { return scope.graph.scopePath } +func (scope mappingInheritanceScope) collectionPolicies() collectionPolicyScope { + return scope.graph.collectionPolicies +} +func (scope mappingInheritanceScope) applicationPolicy() sequencePolicy { + return scope.graph.applicationPolicy +} +func (scope mappingInheritanceScope) slotOverhead() int { return 1 } +func (scope mappingInheritanceScope) templates() []inheritanceExecutionTemplate { + result := make([]inheritanceExecutionTemplate, len(scope.graph.nodes)) + for index, node := range scope.graph.nodes { + var directive document.Entry + if node.hasDirective { + directive = node.directive + } + result[index] = inheritanceExecutionTemplate{ + entry: node.entry, path: node.path, + collectionPolicies: node.collectionPolicies, parent: node.parent, + hasParent: node.hasParent, hasDirective: node.hasDirective, + cancelled: node.cancelled, directive: directive, + directivePath: node.path.Key(inheritanceKey), + } + } + return result +} +func (scope mappingInheritanceScope) slots() []inheritanceScopeSlot { + result := make([]inheritanceScopeSlot, len(scope.graph.scope.Mapping)) + for index, entry := range scope.graph.scope.Mapping { + slot := inheritanceScopeSlot{ + value: entry.Value, path: scope.graph.scopePath.Key(entry.Key), + } + if template, ok := scope.graph.byName[entry.Key]; ok { + slot.template, slot.hasTemplate = template, true + } + result[index] = slot + } + return result +} +func (scope mappingInheritanceScope) rebuild(resolved []*document.Node) *document.Node { + root := *scope.graph.scope + root.Mapping = make([]document.Entry, len(scope.graph.scope.Mapping)) + for index, entry := range scope.graph.scope.Mapping { + root.Mapping[index] = entry + if template, ok := scope.graph.byName[entry.Key]; ok { + root.Mapping[index].Value = resolved[template] + } else { + root.Mapping[index].Value = document.Clone(entry.Value) + } + } + return &root +} + +type identifiedSequenceInheritanceScope struct { + graph *identifiedInheritanceGraph + policies collectionPolicyScope + application sequencePolicy +} + +func (scope identifiedSequenceInheritanceScope) scope() *document.Node { return scope.graph.scope } +func (scope identifiedSequenceInheritanceScope) scopePath() Path { return scope.graph.scopePath } +func (scope identifiedSequenceInheritanceScope) collectionPolicies() collectionPolicyScope { + return scope.policies +} +func (scope identifiedSequenceInheritanceScope) applicationPolicy() sequencePolicy { + return scope.application +} +func (scope identifiedSequenceInheritanceScope) slotOverhead() int { return 0 } +func (scope identifiedSequenceInheritanceScope) templates() []inheritanceExecutionTemplate { + result := make([]inheritanceExecutionTemplate, len(scope.graph.nodes)) + for index, node := range scope.graph.nodes { + result[index] = inheritanceExecutionTemplate{ + entry: document.Entry{Value: node.item}, path: node.path, + collectionPolicies: inheritancePolicyScopeAtPath(scope.policies, node.path), + parent: node.parent, hasParent: node.hasParent, + hasDirective: node.directive != nil, cancelled: node.cancelled, + directive: document.Entry{Value: node.directive}, directivePath: node.directivePath, + identityFields: scope.graph.fields, + } + } + return result +} +func (scope identifiedSequenceInheritanceScope) slots() []inheritanceScopeSlot { + result := make([]inheritanceScopeSlot, len(scope.graph.nodes)) + for index, node := range scope.graph.nodes { + result[index] = inheritanceScopeSlot{ + value: node.item, path: node.path, template: index, hasTemplate: true, + } + } + return result +} +func (scope identifiedSequenceInheritanceScope) rebuild(resolved []*document.Node) *document.Node { + root := *scope.graph.scope + root.Sequence = make([]*document.Node, len(scope.graph.nodes)) + for index := range root.Sequence { + root.Sequence[index] = resolved[index] + } + return &root +} + +func preserveInheritanceIdentityFields(merged, child *document.Node, fields []string) { + if len(fields) == 0 || merged == nil || merged.Kind != document.Mapping || child == nil || child.Kind != document.Mapping { + return + } + for _, field := range fields { + for _, entry := range child.Mapping { + if entry.Key != field { + continue + } + for index := range merged.Mapping { + if merged.Mapping[index].Key == field { + merged.Mapping[index] = document.Entry{Key: field, Value: document.Clone(entry.Value)} + break + } + } + break + } + } +} + +func applyIdentifiedInheritanceTemplates( + graph *identifiedInheritanceGraph, + limits projectionLimits, + inputOrigins map[string]Origin, + policies collectionPolicyScope, + application sequencePolicy, + budget ...*retainedNodeCounter, +) (*inheritanceScope, []Diagnostic) { + order, diagnostics := graph.schedule() + if len(diagnostics) != 0 { + return nil, diagnostics + } + adapter := identifiedSequenceInheritanceScope{ + graph: graph, policies: policies, application: application, + } + return applyInheritanceScopeTemplates(adapter, order, limits, inputOrigins, budget...) +}