2026-08-05 19:13:37 +00:00
// Package locationregistry normalizes merged D&D location-registry candidates conservatively.
package locationregistry
2026-08-04 00:07:47 +00:00
import (
"context"
"errors"
"fmt"
"reflect"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const (
2026-08-05 19:13:37 +00:00
Key = "dnd/location-registry"
PromptID = "dnd.location_registry.normalize"
2026-08-08 15:05:35 +00:00
normalizationPolicy = "dnd.location_registry.normalize.v2"
2026-08-04 00:07:47 +00:00
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2
NormalizationPolicy = normalizationPolicy
ReasonCodeLocationFieldsNormalized = "location_fields_normalized"
ReasonCodeLocationIDRecomputed = "location_id_recomputed"
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
ReasonCodeDuplicateLocationCollapsed = "duplicate_location_collapsed"
ReasonCodeLocationSemanticProposalInvalid = "location_semantic_proposal_invalid"
ReasonCodeLocationSemanticReconciliationExhausted = "location_semantic_reconciliation_exhausted"
ReasonCodeLocationNormalizationWarningsOmitted = "location_normalization_warnings_omitted"
)
var requiredCapabilities = [ ] string { "merged" }
var providedCapabilities = [ ] string { "normalized" }
2026-08-05 19:05:42 +00:00
var _ contracts . Normalizer [ dnd . LocationRegistry ] = ( * Normalizer ) ( nil )
2026-08-04 00:07:47 +00:00
var _ contracts . ManifestMetadataProvider = ( * Normalizer ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Normalizer ) ( nil )
type Options struct { }
type Normalizer struct {
llm contracts . StructuredLLMClient
promptSHA string
responseSchemaSHA string
}
func New ( llmClient contracts . StructuredLLMClient , _ Options ) ( * Normalizer , error ) {
if llmClient == nil {
return nil , normalizerErrorf ( "LLM client must not be nil" )
}
promptSHA , err := promptAssetMetadata ( )
if err != nil {
return nil , normalizerErrorf ( "load prompt metadata: %w" , err )
}
responseSchema , err := entityreconcile . LoadResponseSchema ( )
if err != nil {
return nil , normalizerErrorf ( "load response schema: %w" , err )
}
return & Normalizer { llm : llmClient , promptSHA : promptSHA , responseSchemaSHA : responseSchema . SHA256 } , nil
}
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 {
"prompt_id" : PromptID , "prompt_version" : entityreconcile . SchemaVersion , "prompt_sha256" : n . promptSHA ,
"response_schema_key" : string ( entityreconcile . ResponseSchemaKey ) , "response_schema_id" : entityreconcile . ResponseSchemaID ,
"response_schema_name" : entityreconcile . ResponseSchemaName , "response_schema_version" : entityreconcile . SchemaVersion ,
"response_schema_sha256" : n . responseSchemaSHA , "identity_policy" : identity . Policy ,
"normalization_policy" : normalizationPolicy , "semantic_context_policy" : semanticContextPolicy , "semantic_context_radius" : semanticContextRadius ,
}
}
func ( n * Normalizer ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
if n == nil {
return nil
}
return [ ] pipeline . CheckpointFingerprint {
{ Name : "prompt" , Value : n . promptSHA } , { Name : "response_schema" , Value : n . responseSchemaSHA } ,
{ Name : "identity_policy" , Value : identity . Policy } , { Name : "normalization_policy" , Value : normalizationPolicy } ,
{ Name : "semantic_context_policy" , Value : fmt . Sprintf ( "%s:%d" , semanticContextPolicy , semanticContextRadius ) } ,
}
}
2026-08-05 19:05:42 +00:00
func ( n * Normalizer ) Normalize ( ctx context . Context , req contracts . TypedNormalizeRequest [ dnd . LocationRegistry ] ) ( contracts . TypedNormalizeResult [ dnd . LocationRegistry ] , error ) {
2026-08-04 00:07:47 +00:00
if n == nil {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "normalizer must not be nil" )
2026-08-04 00:07:47 +00:00
}
if n . llm == nil {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "LLM client must not be nil" )
2026-08-04 00:07:47 +00:00
}
if ctx == nil {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "context must not be nil" )
2026-08-04 00:07:47 +00:00
}
if err := ctx . Err ( ) ; err != nil {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "context error before normalize: %w" , err )
2026-08-04 00:07:47 +00:00
}
order := shared . NewSourceRefOrder ( req . Source )
records , warnings := preprocessRecords ( req . MergeOutput . Value , order )
deterministic := recordList ( records )
materials , ready , err := entityreconcile . BuildContext ( req . Source , reconciliationCandidates ( records ) , semanticContextRadius )
if err != nil {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "build semantic context: %w" , err )
2026-08-04 00:07:47 +00:00
}
if ! ready {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { Value : deterministic , Warnings : limitWarnings ( warnings ) } , nil
2026-08-04 00:07:47 +00:00
}
var response entityreconcile . ProposalResponse
if _ , err := n . llm . CompleteStructured ( ctx , contracts . StructuredCompletionRequest {
StageName : Key , PromptID : PromptID , PromptVersion : entityreconcile . SchemaVersion ,
ProfileID : req . LLMProfile , SessionID : req . SessionID ,
Inputs : contracts . LLMInputSet { "candidates" : materials . Candidates , "transcript" : materials . Transcript } ,
} , & response ) ; err != nil {
if errors . Is ( err , contracts . ErrInvalidStructuredOutput ) {
return n . invalidStructuredResult ( deterministic , warnings ) , nil
}
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { } , normalizerErrorf ( "complete structured output: %w" , err )
2026-08-04 00:07:47 +00:00
}
assessment := materials . Assess ( response )
applied , semanticWarnings := applySafeGroups ( records , reconciliationGroups ( assessment , materials . CandidateKeys ( ) ) , order )
warnings = append ( warnings , semanticWarnings ... )
if assessment . DiscardedGroups ( ) == 0 {
2026-08-05 19:05:42 +00:00
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { Value : recordList ( applied ) , Warnings : limitWarnings ( warnings ) } , nil
2026-08-04 00:07:47 +00:00
}
return retryResult ( recordList ( applied ) , warnings , assessment ) , nil
}
2026-08-05 19:05:42 +00:00
func ( n * Normalizer ) invalidStructuredResult ( value dnd . LocationRegistry , warnings [ ] contracts . Warning ) contracts . TypedNormalizeResult [ dnd . LocationRegistry ] {
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { Value : value , Warnings : limitWarningsForRetry ( warnings ) , Retry : & contracts . NormalizeRetry {
2026-08-04 00:07:47 +00:00
ReasonCode : ReasonCodeLocationSemanticProposalInvalid , Message : "semantic proposal requires retry: invalid structured output" ,
FallbackWarnings : [ ] contracts . Warning { semanticFallbackWarning ( - 1 ) } ,
} }
}
2026-08-05 19:05:42 +00:00
func retryResult ( value dnd . LocationRegistry , warnings [ ] contracts . Warning , assessment entityreconcile . Assessment ) contracts . TypedNormalizeResult [ dnd . LocationRegistry ] {
return contracts . TypedNormalizeResult [ dnd . LocationRegistry ] { Value : value , Warnings : limitWarningsForRetry ( warnings ) , Retry : & contracts . NormalizeRetry {
2026-08-04 00:07:47 +00:00
ReasonCode : ReasonCodeLocationSemanticProposalInvalid , Message : diagnostics . Aggregate ( "semantic proposal requires retry" , reconciliationIssues ( assessment ) ) ,
FallbackWarnings : [ ] contracts . Warning { semanticFallbackWarning ( assessment . DiscardedGroups ( ) ) } ,
} }
}
func semanticFallbackWarning ( discarded int ) contracts . Warning {
message := "semantic proposal could not be applied"
if discarded >= 0 {
message = fmt . Sprintf ( "%d proposal group(s) omitted after semantic proposal retry exhaustion" , discarded )
}
return contracts . Warning { Scope : "locations" , ReasonCode : ReasonCodeLocationSemanticReconciliationExhausted , Message : message }
}
func limitWarnings ( warnings [ ] contracts . Warning ) [ ] contracts . Warning {
return diagnostics . LimitWarnings ( warnings , "locations" , ReasonCodeLocationNormalizationWarningsOmitted )
}
func limitWarningsForRetry ( warnings [ ] contracts . Warning ) [ ] contracts . Warning {
if warnings == nil {
return nil
}
if len ( warnings ) < diagnostics . MaxWarnings {
return append ( [ ] contracts . Warning ( nil ) , warnings ... )
}
displayed := diagnostics . MaxWarnings - 2
bounded := append ( [ ] contracts . Warning ( nil ) , warnings [ : displayed ] ... )
return append ( bounded , contracts . Warning { Scope : "locations" , ReasonCode : ReasonCodeLocationNormalizationWarningsOmitted , Message : fmt . Sprintf ( "%d additional warning(s) omitted" , len ( warnings ) - displayed ) } )
}
type normalizedRecord struct {
location dnd . Location
inputIndexes [ ] int
earliest int
}
2026-08-05 19:05:42 +00:00
func preprocessRecords ( input dnd . LocationRegistry , order shared . SourceRefOrder ) ( [ ] normalizedRecord , [ ] contracts . Warning ) {
2026-08-04 00:07:47 +00:00
if input . Locations == nil {
return nil , nil
}
records := make ( [ ] normalizedRecord , len ( input . Locations ) )
warnings := make ( [ ] contracts . Warning , 0 )
for index , inputLocation := range input . Locations {
location , fieldsChanged , refsChanged := normalizeRecord ( inputLocation , order )
records [ index ] = normalizedRecord { location : location , inputIndexes : [ ] int { index } , earliest : index }
if fieldsChanged {
warnings = append ( warnings , contracts . Warning { Scope : locationScope ( index ) , ReasonCode : ReasonCodeLocationFieldsNormalized , Message : fmt . Sprintf ( "input index %d: location name normalized for %s" , index , diagnostics . Quote ( inputLocation . Name ) ) } )
}
if refsChanged {
warnings = append ( warnings , contracts . Warning { Scope : locationScope ( index ) , ReasonCode : ReasonCodeSourceReferencesNormalized , Message : fmt . Sprintf ( "input index %d: source references normalized (original count %d, final count %d)" , index , len ( inputLocation . SourceRefs ) , len ( location . SourceRefs ) ) } )
}
if inputLocation . ID != location . ID {
warnings = append ( warnings , contracts . Warning { Scope : locationScope ( index ) , ReasonCode : ReasonCodeLocationIDRecomputed , Message : fmt . Sprintf ( "input index %d: location ID recomputed from %s" , index , diagnostics . Quote ( location . Name ) ) } )
}
}
groups := exactDuplicateGroups ( records )
output := make ( [ ] normalizedRecord , 0 , len ( groups ) )
for _ , members := range groups {
retained := cloneRecord ( records [ members [ 0 ] ] )
for _ , member := range members [ 1 : ] {
retained . inputIndexes = append ( retained . inputIndexes , records [ member ] . inputIndexes ... )
}
retained . inputIndexes = sortedUniqueIndexes ( retained . inputIndexes )
output = append ( output , retained )
if len ( members ) > 1 {
warnings = append ( warnings , duplicateWarning ( retained . earliest , memberInputIndexes ( records , members [ 1 : ] ) ) )
}
}
return output , warnings
}
func normalizeRecord ( input dnd . Location , order shared . SourceRefOrder ) ( dnd . Location , bool , bool ) {
output := cloneLocation ( input )
output . Name = identity . NormalizeDisplay ( input . Name )
output . SourceRefs = order . Canonicalize ( input . SourceRefs )
output . ID = identity . DeriveID ( output . Name , output . SourceRefs )
return output , input . Name != output . Name , ! reflect . DeepEqual ( input . SourceRefs , output . SourceRefs )
}
func exactDuplicateGroups ( records [ ] normalizedRecord ) [ ] [ ] int {
groups := make ( [ ] [ ] int , 0 , len ( records ) )
for index , record := range records {
key := identity . ComparisonKey ( record . location . Name )
found := false
for groupIndex , members := range groups {
first := records [ members [ 0 ] ]
if identity . ComparisonKey ( first . location . Name ) == key && reflect . DeepEqual ( first . location . SourceRefs , record . location . SourceRefs ) {
groups [ groupIndex ] = append ( groups [ groupIndex ] , index )
found = true
break
}
}
if ! found {
groups = append ( groups , [ ] int { index } )
}
}
return groups
}
func cloneLocation ( input dnd . Location ) dnd . Location {
input . SourceRefs = cloneSourceRefs ( input . SourceRefs )
return input
}
func cloneRecord ( input normalizedRecord ) normalizedRecord {
input . location = cloneLocation ( input . location )
input . inputIndexes = append ( [ ] int ( nil ) , input . inputIndexes ... )
return input
}
func cloneSourceRefs ( input [ ] source . SourceRef ) [ ] source . SourceRef {
if input == nil {
return nil
}
return append ( [ ] source . SourceRef ( nil ) , input ... )
}
func sortedUniqueIndexes ( indexes [ ] int ) [ ] int {
if len ( indexes ) == 0 {
return nil
}
out := append ( [ ] int ( nil ) , indexes ... )
sort . Ints ( out )
write := 1
for _ , index := range out [ 1 : ] {
if index != out [ write - 1 ] {
out [ write ] = index
write ++
}
}
return out [ : write ]
}
func memberInputIndexes ( records [ ] normalizedRecord , members [ ] int ) [ ] int {
indexes := make ( [ ] int , 0 , len ( members ) )
for _ , member := range members {
indexes = append ( indexes , records [ member ] . inputIndexes ... )
}
return sortedUniqueIndexes ( indexes )
}
func recordValues ( records [ ] normalizedRecord ) [ ] dnd . Location {
if records == nil {
return nil
}
values := make ( [ ] dnd . Location , len ( records ) )
for index , record := range records {
values [ index ] = cloneLocation ( record . location )
}
return values
}
2026-08-05 19:05:42 +00:00
func recordList ( records [ ] normalizedRecord ) dnd . LocationRegistry {
2026-08-04 00:07:47 +00:00
if records == nil {
2026-08-05 19:05:42 +00:00
return dnd . LocationRegistry { }
2026-08-04 00:07:47 +00:00
}
2026-08-05 19:05:42 +00:00
return dnd . LocationRegistry { Locations : recordValues ( records ) }
2026-08-04 00:07:47 +00:00
}
func duplicateWarning ( retainedIndex int , removed [ ] int ) contracts . Warning {
const maxDisplayedIndices = 20
displayed := removed
if len ( displayed ) > maxDisplayedIndices {
displayed = displayed [ : maxDisplayedIndices ]
}
indices := make ( [ ] string , len ( displayed ) )
for index , removedIndex := range displayed {
indices [ index ] = strconv . Itoa ( removedIndex )
}
message := fmt . Sprintf ( "retained input index %d; removed input indices [%s]" , retainedIndex , strings . Join ( indices , ", " ) )
if omitted := len ( removed ) - len ( displayed ) ; omitted > 0 {
message += fmt . Sprintf ( "; %d additional removed input indices omitted" , omitted )
}
return contracts . Warning { Scope : locationScope ( retainedIndex ) , ReasonCode : ReasonCodeDuplicateLocationCollapsed , Message : message }
}
func locationScope ( index int ) string { return fmt . Sprintf ( "locations[%d]" , index ) }
func ModuleSpec ( ) pipeline . ModuleSpec {
2026-08-05 19:05:42 +00:00
return pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , ExecutionClass : contracts . ExecutionClassLLMBacked , Requires : append ( [ ] string ( nil ) , requiredCapabilities ... ) , Provides : append ( [ ] string ( nil ) , providedCapabilities ... ) , ArtifactKind : dnd . LocationRegistryKind }
2026-08-04 00:07:47 +00:00
}
func Register ( registry * pipeline . NormalizerRegistry ) error {
2026-08-05 19:05:42 +00:00
return pipeline . RegisterNormalizerBuilder ( registry , ModuleSpec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . Normalizer [ dnd . LocationRegistry ] , error ) {
2026-08-04 00:07:47 +00:00
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( request . Dependencies . LLM , options )
} )
}
func validateOptions ( options map [ string ] any ) error { _ , err := DecodeOptions ( options ) ; return err }
func DecodeOptions ( options map [ string ] any ) ( Options , error ) {
if err := pipeline . RejectUnknownOptions ( options ) ; err != nil {
return Options { } , normalizerErrorf ( "%w" , err )
}
return Options { } , nil
}
func normalizerErrorf ( format string , args ... any ) error {
2026-08-05 19:13:37 +00:00
return fmt . Errorf ( "dnd location registry normalizer: " + format , args ... )
2026-08-04 00:07:47 +00:00
}