diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8d63297..ed032ac 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -198,7 +198,7 @@ Scope: one helper plus tests, no caller migration yet. Validate: focused helper tests and the everyday check. Storage follows indexed token material/payloads; tests do not fix representation or allocation counts. -## Stage 7: Index resolution coverage and record reads +## Stage 7: Index resolution coverage and record reads — Complete Roadmap candidate 3, first integration. Owner: `internal/engine/resolution.go`. diff --git a/internal/engine/document_lookup.go b/internal/engine/document_lookup.go new file mode 100644 index 0000000..7036c2b --- /dev/null +++ b/internal/engine/document_lookup.go @@ -0,0 +1,52 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +// documentLookup contains read-only mapping positions for a completed document. +type documentLookup struct { + byNode map[*document.Node]map[string]int +} + +func buildDocumentLookup(root *document.Node) *documentLookup { + lookup := &documentLookup{byNode: make(map[*document.Node]map[string]int)} + visited := make(map[*document.Node]struct{}) + var visit func(*document.Node) + visit = func(node *document.Node) { + if node == nil { + return + } + if _, ok := visited[node]; ok { + return + } + visited[node] = struct{}{} + switch node.Kind { + case document.Mapping: + entries := make(map[string]int, len(node.Mapping)) + for index, entry := range node.Mapping { + if _, exists := entries[entry.Key]; !exists { + entries[entry.Key] = index + } + visit(entry.Value) + } + lookup.byNode[node] = entries + case document.Sequence: + for _, child := range node.Sequence { + visit(child) + } + } + } + visit(root) + return lookup +} + +func (lookup *documentLookup) find(node *document.Node, key string) int { + if lookup == nil { + return -1 + } + if entries := lookup.byNode[node]; entries != nil { + if index, ok := entries[key]; ok { + return index + } + } + return -1 +} diff --git a/internal/engine/document_walk.go b/internal/engine/document_walk.go index b2c93c9..deb6a32 100644 --- a/internal/engine/document_walk.go +++ b/internal/engine/document_walk.go @@ -31,6 +31,10 @@ func indexDocument(node *document.Node, path Path, index map[string]*document.No } func documentAtPath(root *document.Node, path Path) (*document.Node, bool) { + return documentAtPathWithLookup(root, path, nil) +} + +func documentAtPathWithLookup(root *document.Node, path Path, lookup *documentLookup) (*document.Node, bool) { if root == nil || !path.valid() { return nil, false } @@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) { if segment.kind == pathSegmentIndex { return nil, false } - found := false - for _, entry := range current.Mapping { - if entry.Key == segment.key { - current = entry.Value - found = true - break + entryIndex := -1 + if lookup != nil { + entryIndex = lookup.find(current, segment.key) + } else { + for index, entry := range current.Mapping { + if entry.Key == segment.key { + entryIndex = index + break + } } } - if !found { + if entryIndex < 0 || entryIndex >= len(current.Mapping) { return nil, false } + current = current.Mapping[entryIndex].Value case document.Sequence: index, ok := schemaIndex(segment) if !ok || index >= len(current.Sequence) { diff --git a/internal/engine/resolution.go b/internal/engine/resolution.go index c96de3d..78f9a65 100644 --- a/internal/engine/resolution.go +++ b/internal/engine/resolution.go @@ -272,14 +272,17 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error } deltas := compareConfigurationValues(decoded.final, after) ordered := orderedResolutionRecords(state.records) + coverage := newResolutionCoverageIndexFromRecords(ordered) + beforeLookup := buildDocumentLookup(decoded.final) + afterLookup := buildDocumentLookup(after) for _, delta := range deltas { - if coveringResolutionRecord(ordered, delta.path) == nil { + if _, ok := coverage.deepestCoveringID(delta.path); !ok { state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record") } } for _, record := range ordered { - _, beforePresent := documentAtPath(decoded.final, record.path) - afterNode, afterPresent := documentAtPath(after, record.path) + _, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup) + afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup) if !beforePresent && !afterPresent { state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value") continue @@ -294,8 +297,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error snapshots := make([]resolutionSnapshot, 0, len(ordered)) for _, record := range ordered { - beforeNode, _ := documentAtPath(decoded.final, record.path) - afterNode, _ := documentAtPath(after, record.path) + beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup) + afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup) snapshots = append(snapshots, resolutionSnapshot{ change: Change{ Path: record.path.clone(), @@ -331,14 +334,45 @@ func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutio return result } -func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord { - var covering *resolutionRecord - for _, record := range records { - if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) { - covering = record +type resolutionCoverageIndex struct { + prefix pathPrefixIndex + depths []int +} + +func newResolutionCoverageIndex(paths []Path) *resolutionCoverageIndex { + index := &resolutionCoverageIndex{depths: make([]int, len(paths))} + for id, path := range paths { + index.depths[id] = len(path.segments) + if path.valid() { + index.prefix.insert(path.String(), id) } } - return covering + return index +} + +func newResolutionCoverageIndexFromRecords(records []*resolutionRecord) *resolutionCoverageIndex { + index := &resolutionCoverageIndex{depths: make([]int, len(records))} + for id, record := range records { + index.depths[id] = len(record.path.segments) + if record.path.valid() { + index.prefix.insert(record.path.String(), id) + } + } + return index +} + +func (index *resolutionCoverageIndex) deepestCoveringID(path Path) (int, bool) { + if !path.valid() { + return 0, false + } + bestID, bestDepth, found := 0, -1, false + index.prefix.visitAncestors(path.String(), func(id int) { + depth := index.depths[id] + if !found || depth > bestDepth || (depth == bestDepth && id < bestID) { + bestID, bestDepth, found = id, depth, true + } + }) + return bestID, found } func pathContains(container, descendant Path) bool { diff --git a/internal/engine/resolution_index_test.go b/internal/engine/resolution_index_test.go new file mode 100644 index 0000000..bca5d6e --- /dev/null +++ b/internal/engine/resolution_index_test.go @@ -0,0 +1,73 @@ +package engine + +import ( + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestResolutionCoverageIndexMatchesOrderedScan(t *testing.T) { + paths := []Path{ + Keys("group"), + Keys("group", "field"), + Keys("group", "field"), + Keys("group", "field", "nested"), + Keys("other"), + } + index := newResolutionCoverageIndex(paths) + queries := []Path{ + Path{}, + Keys("group"), + Keys("group", "field"), + Keys("group", "field", "nested", "leaf"), + Keys("group", "missing"), + Keys("other", "child"), + Keys("group").Index(-1), + } + for _, query := range queries { + wantID, wantDepth, wantFound := 0, -1, false + for id, candidate := range paths { + if pathContains(candidate, query) && (!wantFound || len(candidate.segments) > wantDepth) { + wantID, wantDepth, wantFound = id, len(candidate.segments), true + } + } + gotID, gotFound := index.deepestCoveringID(query) + if gotID != wantID || gotFound != wantFound { + t.Errorf("deepestCoveringID(%s) = %d, %v; want %d, %v", query.String(), gotID, gotFound, wantID, wantFound) + } + } +} + +func TestDocumentLookupMatchesPathScan(t *testing.T) { + name := &document.Node{Kind: document.String, Text: "item"} + sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{ + {Kind: document.String, Text: "zero"}, + {Kind: document.Mapping, Mapping: []document.Entry{{Key: "name", Value: name}}}, + }} + root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{ + {Key: "0", Value: &document.Node{Kind: document.String, Text: "numeric key"}}, + {Key: "01", Value: &document.Node{Kind: document.String, Text: "leading zero key"}}, + {Key: "items", Value: sequence}, + }} + lookup := buildDocumentLookup(root) + paths := []Path{ + Path{}, + Keys("0"), + Keys("01"), + Keys("items"), + Keys("items").Index(0), + Keys("items").Index(1).Key("name"), + Keys("items").Index(2), + Keys("items").Key("name"), + Keys("missing"), + Keys("items").Index(-1), + (Path{}).Index(0), + } + for _, path := range paths { + wantNode, wantFound := documentAtPath(root, path) + gotNode, gotFound := documentAtPathWithLookup(root, path, lookup) + if gotNode != wantNode || gotFound != wantFound { + t.Errorf("indexed lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound) + } + } +}