From 3828b117e85ccccb34dd3445995c27e21e1d00ca Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 15:01:54 +0000 Subject: [PATCH] Normalize baseline and source history evidence --- docs/roadmap/implementation.md | 7 +- internal/engine/history_model.go | 9 +- internal/engine/history_source.go | 227 +++++++++++++++++++++++++ internal/engine/history_source_test.go | 211 +++++++++++++++++++++++ internal/engine/load_metadata.go | 22 --- internal/engine/overlay.go | 27 ++- 6 files changed, 472 insertions(+), 31 deletions(-) create mode 100644 internal/engine/history_source.go create mode 100644 internal/engine/history_source_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d884301..347810d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Interface Contract Remediation Implementation Plan -Status: in progress. Stages 1–3 are complete; Stages 4–18 remain pending. +Status: in progress. Stages 1–4 are complete; Stages 5–18 remain pending. Baseline: `f6d54a094a2b5ada5748686620659399dce7ebf0`. Audience: a Luna coding agent executing the stages sequentially. @@ -366,7 +366,10 @@ to consume. Focused check once buildable: ## Stage 4 — Normalize Baseline And Ordinary Source Evidence Owners: new `history_source.go`, `load_metadata.go`, `overlay.go`, composition -evidence consumers. Status: pending. +evidence consumers. Status: complete. Validation: +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'History.*Source|Compose|Overlay|Provenance|Explain' -count=1`, +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`, +and `git diff --check`. 1. Register baseline definitions by walking the canonical baseline with concrete paths and default origins. Keep absent roots and explicit null distinct. diff --git a/internal/engine/history_model.go b/internal/engine/history_model.go index cff1a3b..f27c854 100644 --- a/internal/engine/history_model.go +++ b/internal/engine/history_model.go @@ -18,6 +18,7 @@ type historyRole uint8 const ( historyRoleDefinition historyRole = iota + historyRoleContribution historyRoleCompositeDefinition historyRoleOperation historyRoleControl @@ -54,8 +55,10 @@ type historyObservation struct { } type historyModel struct { - observations []historyObservation - attributions []historyAttribution + observations []historyObservation + attributions []historyAttribution + baseline *document.Node + sourcePayloads []historySourcePayload shapesByPointer map[string][]Path shapeOrder []Path @@ -116,7 +119,7 @@ func (builder *historyBuilder) addObservation(observation historyObservation) in builder.model.observations = append(builder.model.observations, observation) builder.addShape(observation.path) switch observation.role { - case historyRoleDefinition, historyRoleCompositeDefinition, historyRoleCheckpoint: + case historyRoleDefinition, historyRoleContribution, historyRoleCompositeDefinition, historyRoleCheckpoint: pointer := observation.path.String() builder.model.observationsByPointer[pointer] = append(builder.model.observationsByPointer[pointer], observationID) case historyRoleOperation, historyRoleControl: diff --git a/internal/engine/history_source.go b/internal/engine/history_source.go new file mode 100644 index 0000000..ba5e1b9 --- /dev/null +++ b/internal/engine/history_source.go @@ -0,0 +1,227 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +type historyPathOrigin struct { + path Path + origin Origin + sensitive bool +} + +type historySourcePayloadKind uint8 + +const ( + historySourceApplicationPayload historySourcePayloadKind = iota + historySourceOverlayPayload + historySourceSummaryPayload +) + +// historySourcePayload keeps source values and replacement facts under the +// shared model's ownership. Application documents remain shared when immutable. +type historySourcePayload struct { + kind historySourcePayloadKind + application historyApplication + overlay overlayRecord + path Path + origin Origin + contributions []historyPayloadRef +} + +func (builder *historyBuilder) observeBaseline(root *document.Node) { + builder.requireOpen() + if root == nil { + return + } + builder.model.baseline = root + payload := builder.nextPayload(historyPhaseBaseline) + defaultAttribution := builder.addAttribution(Origin{Layer: LayerDefault}, nil, nil) + walkDocument(root, Path{}, func(path Path, node *document.Node) { + builder.addObservation(historyObservation{ + path: path, phase: historyPhaseBaseline, payload: payload, + reportedPath: path, role: historyRoleDefinition, + afterPresent: true, sensitive: node.Sensitive, + attributionRef: defaultAttribution, + }) + }) +} + +// observeSourceEvidence registers raw source contributions, replacement sides, +// and the surviving structural definition summaries in producer order. +func (builder *historyBuilder) observeSourceEvidence(root *document.Node, origins map[string]Origin, applications []historyApplication, records []overlayRecord) { + builder.requireOpen() + if len(applications) == 0 && len(records) == 0 { + return + } + for index := range applications { + application := applications[index] + payload := builder.addSourcePayload(&application, nil) + walkHistoryApplication(application, func(_, effective Path, node *document.Node) { + origin := sourceApplicationOrigin(application, node) + attribution := builder.addAttribution(origin, nil, nil) + builder.addObservation(historyObservation{ + path: effective, phase: historyPhaseSources, payload: payload, + reportedPath: effective, role: historyRoleContribution, + afterPresent: true, sensitive: node.Sensitive, + attributionRef: attribution, + }) + }) + } + for index := range records { + record := records[index] + payload := builder.addSourcePayload(nil, &record) + attribution := builder.addAttribution(record.change.Origin, nil, nil) + for _, displaced := range record.displaced { + builder.addObservation(historyObservation{ + path: displaced.path, phase: historyPhaseSources, payload: payload, + reportedPath: record.change.Path, role: historyRoleOperation, + beforePresent: true, attributionRef: attribution, + }) + } + for _, introduced := range record.introduced { + builder.addObservation(historyObservation{ + path: introduced.path, phase: historyPhaseSources, payload: payload, + reportedPath: record.change.Path, role: historyRoleOperation, + afterPresent: true, sensitive: introduced.sensitive, + attributionRef: attribution, + }) + } + } + + if root == nil { + return + } + walkDocument(root, Path{}, func(path Path, node *document.Node) { + origin, found := origins[path.String()] + if !found || origin.Layer == "" || sourceOriginRepresentedByOperation(builder.model, path, origin) { + return + } + contributions := builder.sourceContributions(path, origin) + payload := builder.addSourceSummary(path, origin, contributions) + attribution := builder.addAttribution(origin, nil, nil) + builder.addObservation(historyObservation{ + path: path, phase: historyPhaseSources, payload: payload, + reportedPath: path, role: historyRoleCompositeDefinition, + afterPresent: true, sensitive: node.Sensitive, + attributionRef: attribution, + }) + }) +} + +func (builder *historyBuilder) addSourcePayload(application *historyApplication, overlay *overlayRecord) historyPayloadRef { + payload := builder.nextPayload(historyPhaseSources) + if payload.id != len(builder.model.sourcePayloads) { + panic("configkit: source payload identifiers are out of order") + } + owned := historySourcePayload{} + switch { + case application != nil: + owned.kind = historySourceApplicationPayload + owned.application = cloneHistoryApplications([]historyApplication{*application})[0] + case overlay != nil: + owned.kind = historySourceOverlayPayload + owned.overlay = cloneOverlayRecords([]overlayRecord{*overlay})[0] + default: + panic("configkit: source payload requires a producer") + } + builder.model.sourcePayloads = append(builder.model.sourcePayloads, owned) + return payload +} + +func (builder *historyBuilder) addSourceSummary(path Path, origin Origin, contributions []historyPayloadRef) historyPayloadRef { + payload := builder.nextPayload(historyPhaseSources) + if payload.id != len(builder.model.sourcePayloads) { + panic("configkit: source payload identifiers are out of order") + } + builder.model.sourcePayloads = append(builder.model.sourcePayloads, historySourcePayload{ + kind: historySourceSummaryPayload, path: path.clone(), origin: cloneOrigin(origin), + contributions: append([]historyPayloadRef(nil), contributions...), + }) + return payload +} + +func (builder *historyBuilder) sourceContributions(path Path, summary Origin) []historyPayloadRef { + var contributions []historyPayloadRef + seen := make(map[historyPayloadRef]struct{}) + for _, observationID := range builder.model.observationsByPointer[path.String()] { + observation := builder.model.observations[observationID] + if observation.phase != historyPhaseSources || observation.role != historyRoleContribution || !sameConcreteHistoryPath(path, observation.path) { + continue + } + attribution, ok := builder.model.resolveAttribution(observation.attributionRef) + if !ok || !sourceLocationsContainedBy(attribution.origin, summary) { + continue + } + if _, exists := seen[observation.payload]; exists { + continue + } + seen[observation.payload] = struct{}{} + contributions = append(contributions, observation.payload) + } + return contributions +} + +func sourceLocationsContainedBy(candidate, summary Origin) bool { + if len(candidate.Locations) == 0 { + return false + } + available := make(map[Location]struct{}, len(summary.Locations)) + for _, location := range summary.Locations { + available[location] = struct{}{} + } + for _, location := range candidate.Locations { + if _, found := available[location]; !found { + return false + } + } + return true +} + +func sourceApplicationOrigin(application historyApplication, node *document.Node) Origin { + origin := cloneOrigin(application.origin) + origin.Layer = application.layer + if node.Location.SourceID != "" { + origin.Locations = []Location{publicLocation(node.Location)} + } + return origin +} + +func sourceOriginRepresentedByOperation(model *historyModel, path Path, origin Origin) bool { + represented := false + model.reportedPathIndex.visitAncestors(path.String(), func(observationID int) { + observation := model.observations[observationID] + if observation.phase != historyPhaseSources || observation.role != historyRoleOperation { + return + } + operationOrigin, ok := model.resolveAttribution(observation.attributionRef) + if !ok { + return + } + if originRepresentedByChange(path, origin, Change{ + Path: observation.reportedPath, Origin: operationOrigin.origin, + }) { + represented = true + } + }) + return represented +} + +func originRepresentedByChange(path Path, origin Origin, change Change) bool { + if change.Path.String() == path.String() { + return originContainedBy(origin, change.Origin) + } + if !pathContains(change.Path, path) || origin.Layer != change.Origin.Layer || origin.Operation != change.Origin.Operation { + return false + } + // A subtree replacement records descendant contributions only when their + // source identities are present in the ancestor operation attribution. + recordedSources := make(map[string]struct{}, len(change.Origin.Locations)) + for _, recorded := range change.Origin.Locations { + recordedSources[recorded.SourceID] = struct{}{} + } + for _, location := range origin.Locations { + if _, found := recordedSources[location.SourceID]; !found { + return false + } + } + return true +} diff --git a/internal/engine/history_source_test.go b/internal/engine/history_source_test.go new file mode 100644 index 0000000..cfdf350 --- /dev/null +++ b/internal/engine/history_source_test.go @@ -0,0 +1,211 @@ +package engine + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestHistorySourceObservationsPreserveRawAndCompositeDefinitions(t *testing.T) { + t.Parallel() + + baseline := parseDocumentForDecode(t, "defaults: {nullable: null}\n") + base := parseDocumentForDecode(t, "section: {same: equal, empty: {}}\n") + overlay := parseDocumentForDecode(t, "section: {same: equal, empty: {}}\n") + setHistoryDocumentSource(base, "base") + setHistoryDocumentSource(overlay, "overlay") + + baseOrigins := make(map[string]Origin) + collectOverlayOrigins(baseOrigins, base, Path{}, LayerBase) + baseApplication := historyApplication{ + layer: LayerBase, value: base, + origin: overlayOrigin(LayerBase, base.Location), + } + composition := &composedDocument{ + root: document.Clone(base), origins: baseOrigins, + applications: []historyApplication{baseApplication}, + } + overlayOrigins := make(map[string]Origin) + collectOverlayOrigins(overlayOrigins, overlay, Path{}, LayerOverlay) + assembly := newAssemblyAccumulator(composition) + overlayApplication := historyApplication{ + layer: LayerOverlay, value: overlay, + origin: overlayOrigin(LayerOverlay, overlay.Location), + } + if diagnostics := assembly.applyOverlay(overlay, overlayOrigins, []historyApplication{overlayApplication}, LayerOverlay); len(diagnostics) != 0 { + t.Fatalf("apply overlay diagnostics = %#v", diagnostics) + } + + builder := newHistoryBuilder() + builder.observeBaseline(baseline) + builder.observeSourceEvidence(assembly.current.root, assembly.current.origins, assembly.current.applications, assembly.current.overlays) + model := builder.freeze() + + defaultPath := Keys("defaults", "nullable") + baselineSelections := model.selectHistory(defaultPath) + if len(baselineSelections) != 1 || baselineSelections[0].phase != historyPhaseBaseline || baselineSelections[0].attribution.origin.Layer != LayerDefault || !baselineSelections[0].after { + t.Fatalf("baseline definition selections = %#v", baselineSelections) + } + if selections := model.selectHistory(Keys("defaults", "absent")); len(selections) != 0 { + t.Fatalf("absent baseline path selections = %#v", selections) + } + + sameSelections := model.selectHistory(Keys("section", "same")) + var rawSources []string + var replacement bool + for _, selection := range sameSelections { + if selection.role == historyRoleContribution && selection.hasAttribution { + rawSources = append(rawSources, selection.attribution.origin.Locations[0].SourceID) + } + if selection.role == historyRoleOperation { + replacement = selection.before && selection.after + } + } + if !reflect.DeepEqual(rawSources, []string{"base", "overlay"}) || !replacement { + t.Fatalf("equal raw contributions and replacement = sources %#v, selections %#v", rawSources, sameSelections) + } + + emptySelections := model.selectHistory(Keys("section", "empty")) + var composite *historyAttribution + for index := range emptySelections { + if emptySelections[index].role == historyRoleCompositeDefinition { + composite = &emptySelections[index].attribution + } + } + if composite == nil || !reflect.DeepEqual(locationSourceIDs(composite.origin.Locations), []string{"base", "overlay"}) { + t.Fatalf("empty mapping composite attribution = %#v; selections %#v", composite, emptySelections) + } + var emptySummary *historySourcePayload + for index := range model.sourcePayloads { + payload := &model.sourcePayloads[index] + if payload.kind == historySourceSummaryPayload && sameConcreteHistoryPath(payload.path, Keys("section", "empty")) { + emptySummary = payload + break + } + } + if emptySummary == nil || len(emptySummary.contributions) != 2 || emptySummary.contributions[0].id != 0 || emptySummary.contributions[1].id != 1 { + t.Fatalf("empty mapping summary contribution references = %#v", emptySummary) + } +} + +func TestHistorySourceReplacementTracksConcreteBeforeAndAfterShapes(t *testing.T) { + t.Parallel() + + base := parseDocumentForDecode(t, "shape: {'0': {leaf: old}}\n") + overlay := parseDocumentForDecode(t, "shape: [{leaf: new}]\n") + setHistoryDocumentSource(base, "base") + setHistoryDocumentSource(overlay, "overlay") + baseOrigins := make(map[string]Origin) + collectOverlayOrigins(baseOrigins, base, Path{}, LayerBase) + overlayOrigins := make(map[string]Origin) + collectOverlayOrigins(overlayOrigins, overlay, Path{}, LayerOverlay) + composition := &composedDocument{ + root: document.Clone(base), origins: baseOrigins, + applications: []historyApplication{{layer: LayerBase, value: base, origin: overlayOrigin(LayerBase, base.Location)}}, + } + assembly := newAssemblyAccumulator(composition) + if diagnostics := assembly.applyOverlay(overlay, overlayOrigins, []historyApplication{{layer: LayerOverlay, value: overlay, origin: overlayOrigin(LayerOverlay, overlay.Location)}}, LayerOverlay); len(diagnostics) != 0 { + t.Fatalf("apply overlay diagnostics = %#v", diagnostics) + } + + if len(assembly.current.overlays) != 1 { + t.Fatalf("replacement records = %#v", assembly.current.overlays) + } + record := assembly.current.overlays[0] + if len(record.displaced) != 3 || len(record.introduced) != 3 { + t.Fatalf("displaced/introduction path counts = %d/%d", len(record.displaced), len(record.introduced)) + } + if got := record.introduced[2].path; !sameConcreteHistoryPath(got, Keys("shape").Index(0).Key("leaf")) { + t.Fatalf("introduced descendant path = %#v", got) + } + builder := newHistoryBuilder() + builder.observeSourceEvidence(assembly.current.root, assembly.current.origins, assembly.current.applications, assembly.current.overlays) + model := builder.freeze() + modelIntroduction := model.sourcePayloads[2].overlay.introduced[2] + + cloned := cloneOverlayRecords(assembly.current.overlays) + record.introduced[2].path.segments[2].key = "mutated" + record.introduced[2].origin.Locations[0].SourceID = "mutated" + if got := cloned[0].introduced[2]; !sameConcreteHistoryPath(got.path, Keys("shape").Index(0).Key("leaf")) || got.origin.Locations[0].SourceID != "overlay" { + t.Fatalf("cloned introduced evidence changed with producer: %#v", got) + } + if !sameConcreteHistoryPath(modelIntroduction.path, Keys("shape").Index(0).Key("leaf")) || modelIntroduction.origin.Locations[0].SourceID != "overlay" { + t.Fatalf("model-owned introduced evidence changed with producer: %#v", modelIntroduction) + } + + oldPath := Keys("shape", "0", "leaf") + newPath := Keys("shape").Index(0).Key("leaf") + oldSelections := model.selectHistory(oldPath) + newSelections := model.selectHistory(newPath) + if len(oldSelections) < 2 || oldSelections[len(oldSelections)-1].role != historyRoleOperation || !oldSelections[len(oldSelections)-1].before || oldSelections[len(oldSelections)-1].after { + t.Fatalf("removed key path selections = %#v", oldSelections) + } + if len(newSelections) < 2 || newSelections[len(newSelections)-1].role != historyRoleOperation || newSelections[len(newSelections)-1].before || !newSelections[len(newSelections)-1].after { + t.Fatalf("introduced index path selections = %#v", newSelections) + } + parsed, err := ParsePath("/shape/0/leaf") + if err != nil { + t.Fatal(err) + } + parsedSelections := model.selectHistory(parsed) + if len(parsedSelections) == 0 || !parsedSelections[len(parsedSelections)-1].before || !parsedSelections[len(parsedSelections)-1].after { + t.Fatalf("parsed path did not combine replacement sides: %#v", parsedSelections) + } +} + +func TestOverlayReplacementCapturesIntroducedPathOrigins(t *testing.T) { + t.Parallel() + + existing := parseDocumentForDecode(t, "old: value\n") + incoming := parseDocumentForDecode(t, "new: value\n") + setHistoryDocumentSource(existing, "base") + setHistoryDocumentSource(incoming, "overlay") + path := Keys("section") + origins := make(map[string]Origin) + collectOverlayOrigins(origins, existing, path, LayerBase) + incomingOrigins := make(map[string]Origin) + collectOverlayOrigins(incomingOrigins, incoming, path, LayerOverlay) + result := &composedDocument{ + origins: origins, + } + recordOverlayReplacement(result, path, existing, incoming, LayerOverlay, true, incomingOrigins) + if len(result.overlays) != 1 || len(result.overlays[0].introduced) != 2 { + t.Fatalf("replacement introduced evidence = %#v", result.overlays) + } + introduced := result.overlays[0].introduced[1] + if !sameConcreteHistoryPath(introduced.path, path.Key("new")) || introduced.origin.Layer != LayerOverlay || introduced.origin.Locations[0].SourceID != "overlay" { + t.Fatalf("introduced replacement evidence = %#v", introduced) + } +} + +func TestHistorySourceEvidenceLeavesEmptySourcePhaseUnpopulated(t *testing.T) { + t.Parallel() + + builder := newHistoryBuilder() + builder.observeBaseline(parseDocumentForDecode(t, "defaults: {enabled: true}\n")) + builder.observeSourceEvidence(nil, nil, nil, nil) + model := builder.freeze() + if len(model.sourcePayloads) != 0 { + t.Fatalf("empty source input retained payloads: %#v", model.sourcePayloads) + } + for _, observation := range model.observations { + if observation.phase == historyPhaseSources { + t.Fatalf("empty source phase has an observation: %#v", observation) + } + } +} + +func setHistoryDocumentSource(root *document.Node, sourceID string) { + walkDocument(root, Path{}, func(_ Path, node *document.Node) { + node.Location.SourceID = sourceID + }) +} + +func locationSourceIDs(locations []Location) []string { + ids := make([]string, len(locations)) + for index, location := range locations { + ids[index] = location.SourceID + } + return ids +} diff --git a/internal/engine/load_metadata.go b/internal/engine/load_metadata.go index 6e7b1d2..948339b 100644 --- a/internal/engine/load_metadata.go +++ b/internal/engine/load_metadata.go @@ -147,28 +147,6 @@ func appendInputOriginEvent(events map[string][]Change, baseline map[string][]Ch appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)}) } -func originRepresentedByChange(path Path, origin Origin, change Change) bool { - if change.Path.String() == path.String() { - return originContainedBy(origin, change.Origin) - } - if !pathContains(change.Path, path) || origin.Layer != change.Origin.Layer || origin.Operation != change.Origin.Operation { - return false - } - // A subtree replacement already records its descendants' contributions. - // Their coordinates differ from the container's, but their sources must be - // covered by the operation; matching only the precedence role is insufficient. - recordedSources := make(map[string]struct{}, len(change.Origin.Locations)) - for _, recorded := range change.Origin.Locations { - recordedSources[recorded.SourceID] = struct{}{} - } - for _, location := range origin.Locations { - if _, found := recordedSources[location.SourceID]; !found { - return false - } - } - return true -} - func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) { change := cloneChange(value) history := events[pointer] diff --git a/internal/engine/overlay.go b/internal/engine/overlay.go index e16020b..c3108a1 100644 --- a/internal/engine/overlay.go +++ b/internal/engine/overlay.go @@ -8,8 +8,9 @@ import ( ) type overlayRecord struct { - change Change - displaced []displacedDefinition + change Change + displaced []displacedDefinition + introduced []historyPathOrigin } type displacedDefinition struct { @@ -357,7 +358,8 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No Path: path.clone(), Origin: incomingOrigin(incomingOrigins, path, incoming, layer), Previous: previousOrigins(previous), }, - displaced: displaced, + displaced: displaced, + introduced: overlayPathOrigins(incoming, incomingOrigins, path, layer), }) } }, @@ -395,7 +397,7 @@ func recordOverlayReplacement( Path: path.clone(), Origin: incomingOrigin(incomingOrigins, path, incoming, layer), Previous: previousOrigins(previous), }, - displaced: displaced, + displaced: displaced, introduced: overlayPathOrigins(incoming, incomingOrigins, path, layer), }) } } @@ -518,6 +520,17 @@ func collectOverlayOriginsFrom(origins, incomingOrigins map[string]Origin, node collectOverlayOriginsAt(origins, incomingOrigins, node, path, path, layer) } +func overlayPathOrigins(node *document.Node, incomingOrigins map[string]Origin, path Path, layer Layer) []historyPathOrigin { + var introduced []historyPathOrigin + walkDocument(node, path, func(affected Path, value *document.Node) { + introduced = append(introduced, historyPathOrigin{ + path: affected.clone(), origin: incomingOrigin(incomingOrigins, affected, value, layer), + sensitive: value.Sensitive, + }) + }) + return introduced +} + func collectOverlayOriginsAt(origins, incomingOrigins map[string]Origin, node *document.Node, from, to Path, layer Layer) { walkDocument(node, Path{}, func(relative Path, value *document.Node) { source := appendDestination(from, relative) @@ -633,6 +646,12 @@ func cloneOverlayRecords(records []overlayRecord) []overlayRecord { path: displaced.path.clone(), origin: cloneOrigin(displaced.origin), } } + cloned[index].introduced = make([]historyPathOrigin, len(record.introduced)) + for introducedIndex, introduced := range record.introduced { + cloned[index].introduced[introducedIndex] = historyPathOrigin{ + path: introduced.path.clone(), origin: cloneOrigin(introduced.origin), sensitive: introduced.sensitive, + } + } } return cloned }