2026-08-04 00:22:25 +00:00
// Package locationoccurrences normalizes merged D&D location occurrence candidates.
package locationoccurrences
import (
"context"
"fmt"
"sort"
"strconv"
"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"
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
Key = "dnd/location-occurrences"
normalizationPolicy = "dnd.location_occurrences.normalize.v1"
NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "location_occurrence_name_canonicalized"
ReasonCodeUnknownLocationID = "location_occurrence_unknown_location_id"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeOccurrencesReordered = "location_occurrences_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_location_occurrence_collapsed"
)
const (
LocationRegistryReferenceSlot = locationregistry . ReferenceSlot
LocationRegistryMaxBytes = locationregistry . MaxBytes
)
var requiredCapabilities = [ ] string { "merged" }
var providedCapabilities = [ ] string { "normalized" }
var _ contracts . Normalizer [ dnd . LocationOccurrenceList ] = ( * Normalizer ) ( nil )
var _ contracts . ManifestMetadataProvider = ( * Normalizer ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Normalizer ) ( nil )
type Options struct { }
type Normalizer struct {
locationResolver * locationregistry . Resolver
}
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 := locationregistry . NewResolver ( referenceSet )
if err != nil {
return nil , normalizerErrorf ( "prepare location registry: %w" , err )
}
return & Normalizer { locationResolver : resolver } , nil
}
func ( n * Normalizer ) Key ( ) string { return Key }
func ( n * Normalizer ) ReferenceSlots ( ) [ ] contracts . ReferenceSlot { return referenceSlots ( ) }
func ( n * Normalizer ) ManifestMetadata ( ) map [ string ] any {
if n == nil || n . locationResolver == nil {
return nil
}
metadata := map [ string ] any { "normalization_policy" : normalizationPolicy }
seeded := n . locationResolver . Seeded ( )
if seeded . Bound ( ) {
metadata [ "location_registry_digest" ] = seeded . Digest ( )
metadata [ "location_count" ] = seeded . Count ( )
}
return metadata
}
func ( n * Normalizer ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
if n == nil || n . locationResolver == nil {
return nil
}
return [ ] pipeline . CheckpointFingerprint {
{ Name : "normalization_policy" , Value : normalizationPolicy } ,
2026-08-08 14:49:08 +00:00
{ Name : "location_registry" , Value : n . locationResolver . Seeded ( ) . IdentityDigest ( ) } ,
2026-08-04 00:22:25 +00:00
}
}
func ( n * Normalizer ) Normalize ( ctx context . Context , req contracts . TypedNormalizeRequest [ dnd . LocationOccurrenceList ] ) ( contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] , error ) {
if n == nil || n . locationResolver == nil {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "normalizer must not be nil" )
}
if ctx == nil {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "context must not be nil" )
}
if err := ctx . Err ( ) ; err != nil {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "context error before normalize: %w" , err )
}
registry , err := n . locationResolver . Resolve ( req . References )
if err != nil {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "resolve location registry: %w" , err )
}
if ! registry . Bound ( ) {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "location registry reference is required" )
}
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 , ReasonCodeUnknownLocationID )
2026-08-27 16:04:32 +00:00
if err != nil {
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { } , normalizerErrorf ( "collect diagnostics: %w" , err )
}
return contracts . TypedNormalizeResult [ dnd . LocationOccurrenceList ] { Value : value , Diagnostics : diagnosticGroups } , nil
2026-08-04 00:22:25 +00:00
}
type normalizedRecord struct {
occurrence dnd . LocationOccurrence
inputIndex int
}
type nameCanonicalization struct { from , to string }
2026-08-27 16:40:17 +00:00
func normalizeList ( input dnd . LocationOccurrenceList , documentIndex source . DocumentIndex , order shared . SourceRefOrder , registry * locationregistry . Registry ) ( dnd . LocationOccurrenceList , [ ] diagnostics . Finding ) {
2026-08-04 00:22:25 +00:00
if input . Occurrences == nil {
return dnd . LocationOccurrenceList { } , nil
}
records := make ( [ ] normalizedRecord , len ( input . Occurrences ) )
2026-08-27 18:41:59 +00:00
findings := make ( [ ] diagnostics . Finding , 0 )
2026-08-04 00:22:25 +00:00
for index , inputOccurrence := range input . Occurrences {
occurrence , change , found , refsChanged := normalizeOccurrence ( inputOccurrence , order , registry )
records [ index ] = normalizedRecord { occurrence : occurrence , inputIndex : index }
if change != nil {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeNameCanonicalized ,
2026-08-04 00:22:25 +00:00
Message : fmt . Sprintf ( "input index %d: location name canonicalized from %s to %s" , index , diagnostics . Quote ( change . from ) , diagnostics . Quote ( change . to ) ) } )
}
if ! found {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeUnknownLocationID ,
2026-08-04 00:22:25 +00:00
Message : fmt . Sprintf ( "input index %d: location ID %s is not in the supplied registry" , index , diagnostics . Quote ( inputOccurrence . LocationID ) ) } )
}
if refsChanged {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( index ) , ReasonCode : ReasonCodeSourceRefsNormalized ,
2026-08-04 00:22:25 +00:00
Message : fmt . Sprintf ( "input index %d: source references normalized (original count %d, final count %d)" , index , len ( inputOccurrence . SourceRefs ) , len ( occurrence . SourceRefs ) ) } )
}
}
sort . SliceStable ( records , func ( left , right int ) bool {
return lessOccurrence ( order , records [ left ] . occurrence , records [ right ] . occurrence )
} )
for position , record := range records {
if position != record . inputIndex {
2026-08-27 18:41:59 +00:00
findings = append ( findings , diagnostics . Finding { Scope : occurrenceScope ( record . inputIndex ) , ReasonCode : ReasonCodeOccurrencesReordered ,
2026-08-04 00:22:25 +00:00
Message : fmt . Sprintf ( "input index %d moved to normalized position %d by canonical occurrence order" , record . inputIndex , position ) } )
}
}
2026-08-27 18:41:59 +00:00
output , duplicateFindings := collapseDuplicates ( records , documentIndex )
findings = append ( findings , duplicateFindings ... )
return dnd . LocationOccurrenceList { Occurrences : output } , findings
2026-08-04 00:22:25 +00:00
}
func normalizeOccurrence ( input dnd . LocationOccurrence , order shared . SourceRefOrder , registry * locationregistry . Registry ) ( dnd . LocationOccurrence , * nameCanonicalization , bool , bool ) {
output := cloneOccurrence ( input )
canonical , found := registry . Lookup ( input . LocationID )
if found {
output . Name = canonical . Name
}
var change * nameCanonicalization
if input . Name != output . Name {
change = & nameCanonicalization { from : input . Name , to : output . Name }
}
output . SourceRefs = order . Canonicalize ( input . SourceRefs )
return output , change , found , ! sourceRefsEqual ( input . SourceRefs , output . SourceRefs )
}
func cloneOccurrence ( input dnd . LocationOccurrence ) dnd . LocationOccurrence {
input . SourceRefs = append ( [ ] source . SourceRef ( nil ) , input . SourceRefs ... )
return input
}
func sourceRefsEqual ( left , right [ ] source . SourceRef ) bool {
if ( left == nil ) != ( right == nil ) || len ( left ) != len ( right ) {
return false
}
for index := range left {
if left [ index ] != right [ index ] {
return false
}
}
return true
}
type duplicateGroup struct {
retainedIndex int
removed [ ] int
}
2026-08-27 16:40:17 +00:00
func collapseDuplicates ( records [ ] normalizedRecord , documentIndex source . DocumentIndex ) ( [ ] dnd . LocationOccurrence , [ ] diagnostics . Finding ) {
2026-08-04 00:22:25 +00:00
if len ( records ) == 0 {
return make ( [ ] dnd . LocationOccurrence , 0 ) , nil
}
keep := make ( [ ] bool , len ( records ) )
groups := make ( [ ] duplicateGroup , 0 )
groupByKey := make ( map [ string ] int )
for index , record := range records {
if ! validSourceRefs ( documentIndex , record . occurrence . SourceRefs ) {
keep [ index ] = true
continue
}
key := exactIdentity ( record . occurrence )
groupIndex , exists := groupByKey [ key ]
if ! exists {
groupByKey [ key ] = len ( groups )
groups = append ( groups , duplicateGroup { retainedIndex : record . inputIndex } )
keep [ index ] = true
continue
}
groups [ groupIndex ] . removed = append ( groups [ groupIndex ] . removed , record . inputIndex )
}
output := make ( [ ] dnd . LocationOccurrence , 0 , len ( records ) )
for index , record := range records {
if keep [ index ] {
output = append ( output , cloneOccurrence ( record . occurrence ) )
}
}
2026-08-27 18:41:59 +00:00
findings := make ( [ ] diagnostics . Finding , 0 )
2026-08-04 00:22:25 +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-08-04 00:22:25 +00:00
}
}
2026-08-27 18:41:59 +00:00
return output , findings
2026-08-04 00:22:25 +00:00
}
func validSourceRefs ( index source . DocumentIndex , refs [ ] source . SourceRef ) bool {
if len ( refs ) == 0 {
return false
}
for _ , ref := range refs {
if index . ValidateRef ( ref ) != nil {
return false
}
}
return true
}
func exactIdentity ( occurrence dnd . LocationOccurrence ) string {
var key strings . Builder
writeKeyString ( & key , occurrence . LocationID )
writeKeyString ( & key , occurrence . Name )
writeKeyString ( & key , string ( occurrence . Kind ) )
for _ , ref := range occurrence . SourceRefs {
writeKeyString ( & key , ref . SourceID )
writeKeyInt ( & key , ref . StartUnitID )
writeKeyInt ( & key , ref . EndUnitID )
}
return key . String ( )
}
func writeKeyString ( builder * strings . Builder , value string ) {
builder . WriteString ( strconv . Itoa ( len ( value ) ) )
builder . WriteByte ( ':' )
builder . WriteString ( value )
}
func writeKeyInt ( builder * strings . Builder , value int ) {
builder . WriteString ( strconv . Itoa ( value ) )
builder . WriteByte ( ';' )
}
func lessOccurrence ( order shared . SourceRefOrder , left , right dnd . LocationOccurrence ) bool {
leftPosition , leftHasEvidence := order . EarliestValid ( left . SourceRefs )
rightPosition , rightHasEvidence := order . EarliestValid ( right . SourceRefs )
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
if left . LocationID != right . LocationID {
return left . LocationID < right . LocationID
}
if left . Name != right . Name {
return left . Name < right . Name
}
if kindOrder ( left . Kind ) != kindOrder ( right . Kind ) {
return kindOrder ( left . Kind ) < kindOrder ( right . Kind )
}
if left . Kind != right . Kind {
return left . Kind < right . Kind
}
return sourceRefsLess ( order , left . SourceRefs , right . SourceRefs )
}
func kindOrder ( kind dnd . LocationOccurrenceKind ) int {
switch kind {
case dnd . LocationOccurrenceKindVisited :
return 0
case dnd . LocationOccurrenceKindPlanned :
return 1
case dnd . LocationOccurrenceKindRecalled :
return 2
case dnd . LocationOccurrenceKindMentioned :
return 3
default :
return 4
}
}
func sourceRefsLess ( order shared . SourceRefOrder , left , right [ ] source . SourceRef ) bool {
for index := 0 ; index < len ( left ) && index < len ( right ) ; index ++ {
if left [ index ] == right [ index ] {
continue
}
return order . Less ( left [ index ] , right [ index ] )
}
return len ( left ) < len ( right )
}
2026-08-27 18:41:59 +00:00
func duplicateFinding ( retainedIndex int , removed [ ] int ) diagnostics . Finding {
2026-08-04 00:22:25 +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 { Scope : occurrenceScope ( retainedIndex ) , ReasonCode : ReasonCodeDuplicateCollapsed ,
2026-08-04 00:22:25 +00:00
Message : diagnostics . Aggregate ( fmt . Sprintf ( "duplicate location occurrence collapsed; retained input index %d" , retainedIndex ) , issues ) }
}
func occurrenceScope ( index int ) string { return fmt . Sprintf ( "occurrences[%d]" , index ) }
func referenceSlots ( ) [ ] contracts . ReferenceSlot {
2026-08-04 13:12:46 +00:00
return [ ] contracts . ReferenceSlot { {
2026-08-04 00:39:00 +00:00
Name : LocationRegistryReferenceSlot , Description : "Required normalized location registry used only for location identity grounding, never as occurrence evidence." ,
2026-08-05 19:05:42 +00:00
Required : true , AcceptedMediaTypes : [ ] string { "application/json" } , AcceptedArtifactKinds : [ ] contracts . ArtifactKind { dnd . LocationRegistryKind } , MaxBytes : LocationRegistryMaxBytes ,
2026-08-04 13:12:46 +00:00
} }
2026-08-04 00:22:25 +00:00
}
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 . LocationOccurrenceListKind , ReferenceSlots : referenceSlots ( ) }
}
func Register ( registry * pipeline . NormalizerRegistry ) error {
return pipeline . RegisterNormalizerBuilder ( registry , ModuleSpec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . Normalizer [ dnd . LocationOccurrenceList ] , error ) {
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( options , request . References )
} )
}
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 location occurrences normalizer: " + format , args ... )
}