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"
ReasonCodeWarningsOmitted = "item_occurrence_normalization_warnings_omitted"
)
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-05 19:55:34 +00:00
value , warnings := normalizeList ( req . MergeOutput . Value , index , shared . NewSourceRefOrderFromIndex ( index ) , registry )
return contracts . TypedNormalizeResult [ dnd . ItemOccurrenceList ] { Value : value , Warnings : warnings } , nil
2026-07-25 22:00:32 +00:00
}
type normalizedRecord struct {
2026-08-05 20:00:20 +00:00
occurrence dnd . ItemOccurrence
2026-07-25 22:00:32 +00:00
inputIndex int
}
2026-08-05 19:55:34 +00:00
func normalizeList ( input dnd . ItemOccurrenceList , index source . DocumentIndex , order shared . SourceRefOrder , registry * itemregistry . Registry ) ( dnd . ItemOccurrenceList , [ ] contracts . Warning ) {
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-07-25 22:00:32 +00:00
warnings := make ( [ ] contracts . Warning , 0 )
2026-08-05 20:00:20 +00:00
for index , inputOccurrence := range input . Occurrences {
occurrence , changedFields , found , refsChanged := normalizeOccurrence ( inputOccurrence , order , registry )
records [ index ] = normalizedRecord { occurrence : occurrence , inputIndex : index }
2026-07-25 22:00:32 +00:00
if len ( changedFields ) != 0 {
warnings = append ( warnings , contracts . Warning {
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 {
warnings = append ( warnings , contracts . Warning { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeNameCanonicalized ,
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-05 20:00:20 +00:00
warnings = append ( warnings , contracts . Warning { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeUnknownItemID ,
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 {
warnings = append ( warnings , contracts . Warning {
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-05 20:00:20 +00:00
return itemoccurrencemodel . Less ( 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
}
warnings = append ( warnings , contracts . Warning {
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 ) ,
} )
}
output , duplicateWarnings := collapseDuplicates ( records , index , order )
warnings = append ( warnings , duplicateWarnings ... )
2026-08-05 19:55:34 +00:00
return dnd . ItemOccurrenceList { Occurrences : output } , diagnostics . LimitWarnings ( warnings , "item_occurrences" , ReasonCodeWarningsOmitted )
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 {
retainedIndex int
removed [ ] int
}
2026-08-05 19:55:34 +00:00
func collapseDuplicates ( records [ ] normalizedRecord , index source . DocumentIndex , order shared . SourceRefOrder ) ( [ ] dnd . ItemOccurrence , [ ] contracts . Warning ) {
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 )
groupByKey := make ( map [ string ] int )
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-05 20:00:20 +00:00
key := itemoccurrencemodel . ExactIdentity ( order , record . occurrence )
2026-07-25 22:00:32 +00:00
groupIndex , exists := groupByKey [ key ]
if ! exists {
groupByKey [ key ] = len ( groups )
groups = append ( groups , duplicateGroup { retainedIndex : record . inputIndex } )
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
}
}
warnings := make ( [ ] contracts . Warning , 0 )
for _ , group := range groups {
if len ( group . removed ) != 0 {
warnings = append ( warnings , duplicateWarning ( group . retainedIndex , group . removed ) )
}
}
return output , warnings
}
func duplicateWarning ( retainedIndex int , removed [ ] int ) contracts . Warning {
issues := make ( [ ] string , len ( removed ) )
for index , removedIndex := range removed {
issues [ index ] = fmt . Sprintf ( "removed input index %d" , removedIndex )
}
return contracts . Warning {
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
}