// Package scenedescriptions normalizes merged D&D scene descriptions. package scenedescriptions import ( "context" "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape" ) const ( Key = "dnd/scene-descriptions" normalizerPolicy = "dnd.scene_descriptions.normalizer.v1" ReasonCodeProseNormalized = "scene_description_prose_normalized" ReasonCodeOrderNormalized = "scene_description_order_normalized" ReasonCodeDuplicateCollapsed = "scene_description_duplicate_collapsed" ReasonCodeNormalizationWarningsOmitted = "scene_description_normalization_warnings_omitted" ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.SceneDescriptionList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct{} func New(Options) *Normalizer { return &Normalizer{} } func (n *Normalizer) Key() string { return Key } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ManifestMetadata() map[string]any { if n == nil { return nil } return map[string]any{"normalizer_policy": normalizerPolicy} } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } return []pipeline.CheckpointFingerprint{{Name: "normalizer_policy", Value: normalizerPolicy}} } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SceneDescriptionList]) (contracts.TypedNormalizeResult[dnd.SceneDescriptionList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err) } value, warnings, err := normalizeList(req.MergeOutput.Value, req.Source) if err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err) } return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{ Value: value, Warnings: diagnostics.LimitWarnings(warnings, "scenes", ReasonCodeNormalizationWarningsOmitted), }, nil } type normalizedScene struct { scene dnd.SceneDescription inputIndex int } func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (dnd.SceneDescriptionList, []contracts.Warning, error) { if doc == nil { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source document must not be nil") } if input.Scenes == nil { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes must be present") } if len(input.Scenes) == 0 { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes must not be empty") } documentIndex := source.NewDocumentIndex(doc) records := make([]normalizedScene, len(input.Scenes)) warnings := make([]contracts.Warning, 0) for sceneIndex, scene := range input.Scenes { originalTitle, originalSummary := scene.Title, scene.Summary scene.Title = strings.TrimSpace(scene.Title) scene.Summary = strings.TrimSpace(scene.Summary) if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes[%d]: %w", sceneIndex, err) } if err := documentIndex.ValidateRef(scene.SourceRef); err != nil { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error())) } if originalTitle != scene.Title || originalSummary != scene.Summary { warnings = append(warnings, contracts.Warning{ Scope: sceneScope(sceneIndex), ReasonCode: ReasonCodeProseNormalized, Message: fmt.Sprintf("input index %d: title and/or summary whitespace normalized", sceneIndex), }) } records[sceneIndex] = normalizedScene{scene: scene, inputIndex: sceneIndex} } sort.SliceStable(records, func(left, right int) bool { leftStart, _ := documentIndex.Position(records[left].scene.SourceRef.StartUnitID) rightStart, _ := documentIndex.Position(records[right].scene.SourceRef.StartUnitID) if leftStart != rightStart { return leftStart < rightStart } return records[left].scene.ID < records[right].scene.ID }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeOrderNormalized, Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical scene order", record.inputIndex, position), }) } unique := make([]dnd.SceneDescription, 0, len(records)) byID := make(map[string]dnd.SceneDescription, len(records)) byRange := make(map[source.SourceRef]dnd.SceneDescription, len(records)) seen := make(map[dnd.SceneDescription]int, len(records)) for _, record := range records { scene := record.scene if previous, ok := byID[scene.ID]; ok && !identical(previous, scene) { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scene ID %s has conflicting records", diagnostics.Quote(scene.ID)) } if previous, ok := byRange[scene.SourceRef]; ok && !sameModelContent(previous, scene) { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef)) } if retainedIndex, ok := seen[scene]; ok { warnings = append(warnings, contracts.Warning{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: fmt.Sprintf("input index %d: exact duplicate scene collapsed; retained input index %d", record.inputIndex, retainedIndex), }) continue } byID[scene.ID] = scene byRange[scene.SourceRef] = scene seen[scene] = record.inputIndex unique = append(unique, scene) } return dnd.SceneDescriptionList{Scenes: unique}, warnings, nil } func sceneScope(index int) string { return fmt.Sprintf("scenes[%d]", index) } func identical(left, right dnd.SceneDescription) bool { return left == right } func sameModelContent(left, right dnd.SceneDescription) bool { return left.Kind == right.Kind && left.Title == right.Title && left.Summary == right.Summary } func sourceRefLabel(ref source.SourceRef) string { return fmt.Sprintf("%s:%d-%d", diagnostics.Quote(ref.SourceID), ref.StartUnitID, ref.EndUnitID) } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.SceneDescriptionListKind, } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SceneDescriptionList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options), nil }) } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, normalizerErrorf("%w", err) } return Options{}, nil } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } func normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd scene descriptions normalizer: "+format, args...) }