2026-08-05 20:00:20 +00:00
// Package itemoccurrences normalizes merged D&D item-occurrence candidates.
package itemoccurrences
2026-07-25 22:00:32 +00:00
import (
"context"
"fmt"
"sort"
"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"
2026-08-05 20:00:20 +00:00
itemoccurrencemodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences"
2026-08-05 19:55:34 +00:00
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
2026-07-25 22:00:32 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
2026-08-05 20:00:20 +00:00
Key = "dnd/item-occurrences"
normalizationPolicy = "dnd.item_occurrences.normalize.v1"
2026-07-25 22:00:32 +00:00
NormalizationPolicy = normalizationPolicy
2026-08-05 19:55:34 +00:00
ReasonCodeNameCanonicalized = "item_occurrence_name_canonicalized"
ReasonCodeUnknownItemID = "item_occurrence_unknown_item_id"
2026-07-25 22:00:32 +00:00
ReasonCodeSourceRefsNormalized = "source_references_normalized"
2026-08-05 19:55:34 +00:00
ReasonCodeOccurrencesReordered = "item_occurrences_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_item_occurrence_collapsed"
)
const (
ItemRegistryReferenceSlot = itemregistry . ReferenceSlot
ItemRegistryMaxBytes = itemregistry . MaxBytes
2026-07-25 22:00:32 +00:00
)
var requiredCapabilities = [ ] string { "merged" }
var providedCapabilities = [ ] string { "normalized" }
2026-08-05 19:55:34 +00:00
var _ contracts . Normalizer [ dnd . ItemOccurrenceList ] = ( * Normalizer ) ( nil )
2026-07-25 22:00:32 +00:00
var _ contracts . ManifestMetadataProvider = ( * Normalizer ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Normalizer ) ( nil )
type Options struct { }
2026-08-05 19:55:34 +00:00
type Normalizer struct { itemResolver * itemregistry . Resolver }
2026-07-25 22:00:32 +00:00
2026-08-05 19:55:34 +00:00
func New ( _ Options , references ... contracts . ReferenceSet ) ( * Normalizer , error ) {
if len ( references ) > 1 {
return nil , normalizerErrorf ( "at most one reference set may be supplied" )
}
var referenceSet contracts . ReferenceSet
if len ( references ) == 1 {
referenceSet = references [ 0 ]
}
resolver , err := itemregistry . NewResolver ( referenceSet )
if err != nil {
return nil , normalizerErrorf ( "prepare item registry: %w" , err )
}
return & Normalizer { itemResolver : resolver } , nil
}
2026-07-25 22:00:32 +00:00
func ( n * Normalizer ) Key ( ) string { return Key }
2026-08-05 19:55:34 +00:00
func ( n * Normalizer ) ReferenceSlots ( ) [ ] contracts . ReferenceSlot { return referenceSlots ( ) }
2026-07-25 22:00:32 +00:00
func ( n * Normalizer ) ManifestMetadata ( ) map [ string ] any {
2026-08-05 19:55:34 +00:00
if n == nil || n . itemResolver == nil {
2026-07-25 22:00:32 +00:00
return nil
}
2026-08-06 13:37:21 +00:00
metadata := map [ string ] any { "normalization_policy" : normalizationPolicy , "comparison_policy" : shared . TextComparisonPolicy }
2026-08-05 19:55:34 +00:00
seeded := n . itemResolver . Seeded ( )
if seeded . Bound ( ) {
metadata [ "item_registry_digest" ] = seeded . Digest ( )
metadata [ "item_count" ] = seeded . Count ( )
}
return metadata
2026-07-25 22:00:32 +00:00
}
func ( n * Normalizer ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
2026-08-05 19:55:34 +00:00
if n == nil || n . itemResolver == nil {
2026-07-25 22:00:32 +00:00
return nil
}
2026-08-05 19:55:34 +00:00
return [ ] pipeline . CheckpointFingerprint {
{ Name : "normalization_policy" , Value : normalizationPolicy } ,
2026-08-06 13:37:21 +00:00
{ Name : "comparison_policy" , Value : shared . TextComparisonPolicy } ,
2026-08-05 19:55:34 +00:00
{ Name : "item_registry" , Value : n . itemResolver . Seeded ( ) . ProjectionDigest ( ) } ,
}
2026-07-25 22:00:32 +00:00
}
2026-08-05 19:55:34 +00:00
func ( n * Normalizer ) Normalize ( ctx context . Context , req contracts . TypedNormalizeRequest [ dnd . ItemOccurrenceList ] ) ( contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] , error ) {
if n == nil || n . itemResolver == nil {
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "normalizer must not be nil" )
2026-07-25 22:00:32 +00:00
}
if ctx == nil {
2026-08-05 19:55:34 +00:00
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "context must not be nil" )
2026-07-25 22:00:32 +00:00
}
if err := ctx . Err ( ) ; err != nil {
2026-08-05 19:55:34 +00:00
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "context error before normalize: %w" , err )
2026-07-25 22:00:32 +00:00
}
2026-08-05 19:55:34 +00:00
registry , err := n . itemResolver . Resolve ( req . References )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "resolve item registry: %w" , err )
}
if ! registry . Bound ( ) {
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "item registry reference is required" )
}
2026-07-25 22:00:32 +00:00
index := source . NewDocumentIndex ( req . Source )
2026-08-27 18:41:59 +00:00
value , findings := normalizeList ( req . MergeOutput . Value , index , shared . NewSourceRefOrderFromIndex ( index ) , registry )
diagnosticGroups , err := diagnostics . NormalizationDiagnostics ( findings , ReasonCodeUnknownItemID )
2026-08-27 16:04:32 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { } , normalizerErrorf ( "collect diagnostics: %w" , err )
}
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { Value : value , Diagnostics : diagnosticGroups } , nil
2026-07-25 22:00:32 +00:00
}
type normalizedRecord struct {
2026-08-05 20:00:20 +00:00
occurrence dnd . ItemOccurrence
2026-08-09 02:36:53 +00:00
identity string
2026-07-25 22:00:32 +00:00
inputIndex int
}
2026-08-27 16:40:17 +00:00
func normalizeList ( input dnd . ItemOccurrenceList , index source . DocumentIndex , order shared . SourceRefOrder , registry * itemregistry . Registry ) ( dnd . ItemOccurrenceList , [ ] diagnostics . Finding ) {
2026-08-05 19:55:34 +00:00
if input . Occurrences == nil {
return dnd . ItemOccurrenceList { } , nil
2026-07-25 22:00:32 +00:00
}
2026-08-05 19:55:34 +00:00
records := make ( [ ] normalizedRecord , len ( input . Occurrences ) )
2026-08-27 18:41:59 +00:00
findings := make ( [ ] diagnostics . Finding , 0 )
2026-08-05 20:00:20 +00:00
for index , inputOccurrence := range input . Occurrences {
occurrence , changedFields , found , refsChanged := normalizeOccurrence ( inputOccurrence , order , registry )
2026-08-09 02:36:53 +00:00
records [ index ] = normalizedRecord { occurrence : occurrence , identity : itemoccurrencemodel . CanonicalExactIdentity ( occurrence ) , inputIndex : index }
2026-07-25 22:00:32 +00:00
if len ( changedFields ) != 0 {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding {
2026-08-05 20:00:20 +00:00
Scope : occurrenceScope ( index ) ,
2026-08-05 19:55:34 +00:00
ReasonCode : ReasonCodeNameCanonicalized ,
2026-07-25 22:00:32 +00:00
Message : fmt . Sprintf ( "input index %d: normalized display whitespace in %s" , index ,
diagnostics . Aggregate ( "fields" , changedFields ) ) ,
} )
}
2026-08-05 20:00:20 +00:00
if found && inputOccurrence . Name != occurrence . Name {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeNameCanonicalized ,
2026-08-05 20:00:20 +00:00
Message : fmt . Sprintf ( "input index %d: item name canonicalized from %s to %s" , index , diagnostics . Quote ( inputOccurrence . Name ) , diagnostics . Quote ( occurrence . Name ) ) } )
2026-08-05 19:55:34 +00:00
}
if ! found {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeUnknownItemID ,
2026-08-05 20:00:20 +00:00
Message : fmt . Sprintf ( "input index %d: item ID %s is not in the supplied registry" , index , diagnostics . Quote ( inputOccurrence . ItemID ) ) } )
2026-08-05 19:55:34 +00:00
}
2026-07-25 22:00:32 +00:00
if refsChanged {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding {
2026-08-05 20:00:20 +00:00
Scope : occurrenceScope ( index ) ,
2026-07-25 22:00:32 +00:00
ReasonCode : ReasonCodeSourceRefsNormalized ,
Message : fmt . Sprintf ( "input index %d: source references normalized (original count %d, final count %d)" ,
2026-08-05 20:00:20 +00:00
index , len ( inputOccurrence . SourceRefs ) , len ( occurrence . SourceRefs ) ) ,
2026-07-25 22:00:32 +00:00
} )
}
}
sort . SliceStable ( records , func ( left , right int ) bool {
2026-08-09 02:36:53 +00:00
return itemoccurrencemodel . CanonicalLess ( order , records [ left ] . occurrence , records [ right ] . occurrence )
2026-07-25 22:00:32 +00:00
} )
for position , record := range records {
if position == record . inputIndex {
continue
}
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding {
2026-08-05 20:00:20 +00:00
Scope : occurrenceScope ( record . inputIndex ) ,
2026-08-05 19:55:34 +00:00
ReasonCode : ReasonCodeOccurrencesReordered ,
2026-07-25 22:00:32 +00:00
Message : fmt . Sprintf ( "input index %d moved to normalized position %d" , record . inputIndex , position ) ,
} )
}
2026-08-27 18:41:59 +00:00
output , duplicateFindings := collapseDuplicates ( records , index )
findings = append ( findings , duplicateFindings ... )
return dnd . ItemOccurrenceList { Occurrences : output } , findings
2026-07-25 22:00:32 +00:00
}
2026-08-05 20:00:20 +00:00
func normalizeOccurrence ( input dnd . ItemOccurrence , order shared . SourceRefOrder , registry * itemregistry . Registry ) ( dnd . ItemOccurrence , [ ] string , bool , bool ) {
output := cloneOccurrence ( input )
2026-08-05 19:55:34 +00:00
canonical , found := registry . LookupID ( input . ItemID )
if found {
output . Name = canonical . Name
}
changedFields := make ( [ ] string , 0 , 2 )
2026-07-25 22:00:32 +00:00
for _ , field := range [ ] struct {
name string
value * string
} {
{ name : "from" , value : & output . From } ,
{ name : "to" , value : & output . To } ,
} {
2026-08-05 20:00:20 +00:00
trimmed := itemoccurrencemodel . DisplayValue ( * field . value )
2026-07-25 22:00:32 +00:00
if * field . value != trimmed {
* field . value = trimmed
changedFields = append ( changedFields , field . name )
}
}
output . SourceRefs = order . Canonicalize ( input . SourceRefs )
2026-08-05 20:00:20 +00:00
return output , changedFields , found , ! itemoccurrencemodel . SourceRefsEqual ( input . SourceRefs , output . SourceRefs )
2026-07-25 22:00:32 +00:00
}
2026-08-05 20:00:20 +00:00
func cloneOccurrence ( input dnd . ItemOccurrence ) dnd . ItemOccurrence {
2026-07-25 22:00:32 +00:00
output := input
if input . Quantity != nil {
quantity := * input . Quantity
output . Quantity = & quantity
}
if input . SourceRefs != nil {
output . SourceRefs = append ( [ ] source . SourceRef ( nil ) , input . SourceRefs ... )
}
return output
}
type duplicateGroup struct {
2026-08-09 02:36:53 +00:00
retainedIndex int
retainedRecord int
removed [ ] int
2026-07-25 22:00:32 +00:00
}
2026-08-27 16:40:17 +00:00
func collapseDuplicates ( records [ ] normalizedRecord , index source . DocumentIndex ) ( [ ] dnd . ItemOccurrence , [ ] diagnostics . Finding ) {
2026-07-25 22:00:32 +00:00
if len ( records ) == 0 {
2026-08-05 19:55:34 +00:00
return make ( [ ] dnd . ItemOccurrence , 0 ) , nil
2026-07-25 22:00:32 +00:00
}
keep := make ( [ ] bool , len ( records ) )
groups := make ( [ ] duplicateGroup , 0 )
2026-08-09 02:36:53 +00:00
groupByKey := make ( map [ string ] [ ] int )
2026-07-25 22:00:32 +00:00
for recordIndex , record := range records {
2026-08-05 20:00:20 +00:00
if ! itemoccurrencemodel . ValidSourceRefs ( index , record . occurrence . SourceRefs ) {
2026-07-25 22:00:32 +00:00
keep [ recordIndex ] = true
continue
}
2026-08-09 02:36:53 +00:00
groupIndex := - 1
for _ , candidate := range groupByKey [ record . identity ] {
if itemoccurrencemodel . CanonicalExactEqual ( records [ groups [ candidate ] . retainedRecord ] . occurrence , record . occurrence ) {
groupIndex = candidate
break
}
}
if groupIndex < 0 {
groupIndex = len ( groups )
groupByKey [ record . identity ] = append ( groupByKey [ record . identity ] , groupIndex )
groups = append ( groups , duplicateGroup { retainedIndex : record . inputIndex , retainedRecord : recordIndex } )
2026-07-25 22:00:32 +00:00
keep [ recordIndex ] = true
continue
}
groups [ groupIndex ] . removed = append ( groups [ groupIndex ] . removed , record . inputIndex )
}
2026-08-05 19:55:34 +00:00
output := make ( [ ] dnd . ItemOccurrence , 0 , len ( records ) )
2026-07-25 22:00:32 +00:00
for recordIndex , record := range records {
if keep [ recordIndex ] {
2026-08-05 20:00:20 +00:00
output = append ( output , cloneOccurrence ( record . occurrence ) )
2026-07-25 22:00:32 +00:00
}
}
2026-08-27 18:41:59 +00:00
findings := make ( [ ] diagnostics . Finding , 0 )
2026-07-25 22:00:32 +00:00
for _ , group := range groups {
if len ( group . removed ) != 0 {
2026-08-27 18:41:59 +00:00
findings = append ( findings , duplicateFinding ( group . retainedIndex , group . removed ) )
2026-07-25 22:00:32 +00:00
}
}
2026-08-27 18:41:59 +00:00
return output , findings
2026-07-25 22:00:32 +00:00
}
2026-08-27 18:41:59 +00:00
func duplicateFinding ( retainedIndex int , removed [ ] int ) diagnostics . Finding {
2026-07-25 22:00:32 +00:00
issues := make ( [ ] string , len ( removed ) )
for index , removedIndex := range removed {
issues [ index ] = fmt . Sprintf ( "removed input index %d" , removedIndex )
}
2026-08-27 16:40:17 +00:00
return diagnostics . Finding {
2026-08-05 20:00:20 +00:00
Scope : occurrenceScope ( retainedIndex ) ,
2026-07-25 22:00:32 +00:00
ReasonCode : ReasonCodeDuplicateCollapsed ,
Message : diagnostics . Aggregate (
2026-08-05 19:55:34 +00:00
fmt . Sprintf ( "duplicate item occurrence collapsed; retained input index %d" , retainedIndex ) , issues ) ,
2026-07-25 22:00:32 +00:00
}
}
2026-08-05 20:00:20 +00:00
func occurrenceScope ( index int ) string { return fmt . Sprintf ( "occurrences[%d]" , index ) }
2026-07-25 22:00:32 +00:00
func ModuleSpec ( ) pipeline . ModuleSpec {
return pipeline . ModuleSpec {
2026-08-03 16:49:51 +00:00
Key : Key ,
Stage : pipeline . StageNormalize ,
ExecutionClass : contracts . ExecutionClassDeterministic ,
Requires : append ( [ ] string ( nil ) , requiredCapabilities ... ) ,
Provides : append ( [ ] string ( nil ) , providedCapabilities ... ) ,
2026-08-05 19:55:34 +00:00
ArtifactKind : dnd . ItemOccurrenceListKind ,
ReferenceSlots : referenceSlots ( ) ,
2026-07-25 22:00:32 +00:00
}
}
func Register ( registry * pipeline . NormalizerRegistry ) error {
2026-08-05 19:55:34 +00:00
return pipeline . RegisterNormalizerBuilder ( registry , ModuleSpec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . Normalizer [ dnd . ItemOccurrenceList ] , error ) {
2026-07-25 22:00:32 +00:00
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
2026-08-05 19:55:34 +00:00
return New ( options , request . References )
2026-07-25 22:00:32 +00:00
} )
}
2026-08-05 19:55:34 +00:00
func referenceSlots ( ) [ ] contracts . ReferenceSlot {
return [ ] contracts . ReferenceSlot { {
Name : ItemRegistryReferenceSlot ,
Description : "Required normalized item registry used only for item identity grounding." ,
Required : true ,
AcceptedMediaTypes : [ ] string { "application/json" } ,
AcceptedArtifactKinds : [ ] contracts . ArtifactKind { dnd . ItemRegistryKind } ,
MaxBytes : ItemRegistryMaxBytes ,
} }
}
2026-07-25 22:00:32 +00:00
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 {
2026-08-05 19:55:34 +00:00
return fmt . Errorf ( "dnd item occurrences normalizer: " + format , args ... )
2026-07-25 22:00:32 +00:00
}