2026-08-05 19:39:06 +00:00
package itemregistry
import (
"context"
"encoding/json"
"errors"
"reflect"
2026-08-08 15:05:35 +00:00
"strconv"
2026-08-05 19:39:06 +00:00
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
2026-08-06 13:28:12 +00:00
identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity"
2026-08-05 19:39:06 +00:00
"gitea.maximumdirect.net/eric/promptkit"
)
func TestModuleContractAndMetadata ( t * testing . T ) {
want := pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , ExecutionClass : contracts . ExecutionClassLLMBacked , Requires : [ ] string { "merged" } , Provides : [ ] string { "normalized" } , ArtifactKind : dnd . ItemRegistryKind }
if got := ModuleSpec ( ) ; ! reflect . DeepEqual ( got , want ) {
t . Fatalf ( "ModuleSpec() = %#v, want %#v" , got , want )
}
registry := pipeline . NewNormalizerRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatal ( err )
}
if _ , err := New ( nil , Options { } ) ; err == nil {
t . Fatal ( "New() accepted nil client" )
}
metadata := newNormalizer ( t , & recordingNormalizerClient { } ) . ManifestMetadata ( )
if metadata [ "identity_policy" ] != identity . Policy || metadata [ "response_schema_id" ] != entityreconcile . ResponseSchemaID || metadata [ "normalization_policy" ] != normalizationPolicy || metadata [ "semantic_context_radius" ] != semanticContextRadius {
t . Fatalf ( "metadata = %#v" , metadata )
}
}
2026-08-06 13:28:12 +00:00
func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation ( t * testing . T ) {
doc := & source . SourceDocument { ID : "session" , Units : [ ] source . SourceUnit {
{ ID : 1 , Text : "The rope is secured." } ,
{ ID : 2 , Text : "The party takes the rope." } ,
{ ID : 3 , Text : "The rope is packed away." } ,
{ ID : 4 , Text : "A lantern lights the path." } ,
} }
2026-08-05 19:39:06 +00:00
input := dnd . ItemRegistry { Items : [ ] dnd . Item {
2026-08-06 13:28:12 +00:00
{ Name : " Rope " , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 3 , EndUnitID : 3 } , { SourceID : "session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "rope" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 2 , EndUnitID : 2 } , { SourceID : "session" , StartUnitID : 3 , EndUnitID : 3 } } } ,
{ Name : "Lantern" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 4 , EndUnitID : 4 } } } ,
} }
before := dnd . ItemRegistry { Items : [ ] dnd . Item {
{ Name : " Rope " , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 3 , EndUnitID : 3 } , { SourceID : "session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "rope" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 2 , EndUnitID : 2 } , { SourceID : "session" , StartUnitID : 3 , EndUnitID : 3 } } } ,
{ Name : "Lantern" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 4 , EndUnitID : 4 } } } ,
2026-08-05 19:39:06 +00:00
} }
2026-08-06 13:28:12 +00:00
client := & recordingNormalizerClient { }
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || len ( result . Value . Items ) != 2 || ! reflect . DeepEqual ( input , before ) || len ( client . requests ) != 1 {
t . Fatalf ( "Normalize() = %#v, %v; want deterministic non-mutating consolidation" , result , err )
}
rope := result . Value . Items [ 0 ]
wantRefs := [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 1 , EndUnitID : 1 } , { SourceID : "session" , StartUnitID : 2 , EndUnitID : 2 } , { SourceID : "session" , StartUnitID : 3 , EndUnitID : 3 } }
if rope . Name != "Rope" || rope . ID != identity . DeriveID ( "Rope" ) || ! reflect . DeepEqual ( rope . SourceRefs , wantRefs ) || result . Value . Items [ 1 ] . Name != "Lantern" || ! hasWarning ( result . Warnings , ReasonCodeDuplicateItemCollapsed ) {
t . Fatalf ( "items = %#v, warnings = %#v; want earliest display name, canonical evidence union, and stable placement" , result . Value . Items , result . Warnings )
}
validation , validationErr := identityvalidator . New ( identityvalidator . Options { } ) . Validate ( context . Background ( ) , contracts . TypedValidationRequest [ dnd . ItemRegistry ] { Value : result . Value } )
if validationErr != nil || ! validation . Approved {
t . Fatalf ( "default item-registry identity validation = %#v, %v; want normalized cross-chunk candidates accepted" , validation , validationErr )
}
var candidates struct {
Candidates [ ] struct {
Name string ` json:"name" `
} ` json:"candidates" `
}
if err := json . Unmarshal ( client . requests [ 0 ] . Inputs [ "candidates" ] . Content , & candidates ) ; err != nil || len ( candidates . Candidates ) != 2 || candidates . Candidates [ 0 ] . Name != "Rope" || candidates . Candidates [ 1 ] . Name != "Lantern" {
t . Fatalf ( "semantic candidates = %#v, %v; want one candidate per comparison name" , candidates , err )
2026-08-05 19:39:06 +00:00
}
2026-08-06 13:28:12 +00:00
repeated , repeatErr := newNormalizer ( t , & recordingNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( result . Value , doc ) )
if repeatErr != nil || ! reflect . DeepEqual ( repeated . Value , result . Value ) {
t . Fatalf ( "repeated normalization = %#v, %v; want stable output" , repeated , repeatErr )
2026-08-05 19:39:06 +00:00
}
}
2026-08-06 13:31:15 +00:00
func TestNormalizeAppliesSafeAliasProposal ( t * testing . T ) {
2026-08-05 19:39:06 +00:00
doc := semanticDocument ( )
input := dnd . ItemRegistry { Items : [ ] dnd . Item {
{ Name : "Star Compass" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } ,
{ Name : "Compass of the Stars" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } ,
{ Name : "Gold Pieces" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } } } ,
} }
client := & recordingNormalizerClient { response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]} ` }
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || result . Retry != nil || len ( result . Value . Items ) != 2 {
t . Fatalf ( "Normalize() = %#v, %v" , result , err )
}
merged := result . Value . Items [ 0 ]
if merged . Name != "Compass of the Stars" || merged . ID != identity . DeriveID ( merged . Name ) || len ( merged . SourceRefs ) != 2 || ! hasWarning ( result . Warnings , ReasonCodeDuplicateItemCollapsed ) {
t . Fatalf ( "merged item = %#v, warnings = %#v" , merged , result . Warnings )
}
encoded := string ( client . requests [ 0 ] . Inputs [ "candidates" ] . Content ) + string ( client . requests [ 0 ] . Inputs [ "transcript" ] . Content )
2026-08-08 15:05:35 +00:00
if strings . Contains ( encoded , doc . ID ) || strings . Contains ( encoded , "candidate-" ) || strings . Contains ( encoded , merged . ID ) || ! strings . Contains ( encoded , ` "source_refs" ` ) {
2026-08-05 19:39:06 +00:00
t . Fatalf ( "private inputs = %s" , encoded )
}
2026-08-06 13:31:15 +00:00
}
func TestNormalizeAppliesCurrencyReconciliationSafely ( t * testing . T ) {
doc := semanticDocument ( )
ref := func ( unitID int ) [ ] source . SourceRef {
return [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : unitID , EndUnitID : unitID } }
}
for _ , test := range [ ] struct {
name string
items [ ] dnd . Item
response string
wantNames [ ] string
wantRefCounts [ ] int
wantRetry bool
warning string
} {
{
name : "same denomination aliases" ,
items : [ ] dnd . Item {
{ Name : "GP" , SourceRefs : ref ( 10 ) } ,
{ Name : "Gold Piece" , SourceRefs : ref ( 20 ) } ,
{ Name : "Gold Pieces" , SourceRefs : ref ( 30 ) } ,
} ,
response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002","candidate-000003"],"canonical":"candidate-000002"}]} ` ,
wantNames : [ ] string { "Gold Piece" } ,
wantRefCounts : [ ] int { 3 } ,
warning : ReasonCodeDuplicateItemCollapsed ,
} ,
{
name : "different denominations" ,
items : [ ] dnd . Item {
{ Name : "Gold Pieces" , SourceRefs : ref ( 10 ) } ,
{ Name : "Silver Pieces" , SourceRefs : ref ( 20 ) } ,
} ,
response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]} ` ,
wantNames : [ ] string { "Gold Pieces" , "Silver Pieces" } ,
wantRefCounts : [ ] int { 1 , 1 } ,
wantRetry : true ,
warning : ReasonCodeItemSemanticProposalInvalid ,
} ,
{
name : "currency plus ordinary item" ,
items : [ ] dnd . Item {
{ Name : "Gold Pieces" , SourceRefs : ref ( 10 ) } ,
{ Name : "Longsword" , SourceRefs : ref ( 20 ) } ,
} ,
response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]} ` ,
wantNames : [ ] string { "Gold Pieces" , "Longsword" } ,
wantRefCounts : [ ] int { 1 , 1 } ,
wantRetry : true ,
warning : ReasonCodeItemSemanticProposalInvalid ,
} ,
{
name : "ordinary items" ,
items : [ ] dnd . Item {
{ Name : "Star Compass" , SourceRefs : ref ( 10 ) } ,
{ Name : "Compass of the Stars" , SourceRefs : ref ( 20 ) } ,
} ,
response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]} ` ,
wantNames : [ ] string { "Compass of the Stars" } ,
wantRefCounts : [ ] int { 2 } ,
warning : ReasonCodeDuplicateItemCollapsed ,
} ,
{
name : "currency plus ordinary canonical item" ,
items : [ ] dnd . Item {
{ Name : "Gold Pieces" , SourceRefs : ref ( 10 ) } ,
{ Name : "Longsword" , SourceRefs : ref ( 20 ) } ,
} ,
response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]} ` ,
wantNames : [ ] string { "Gold Pieces" , "Longsword" } ,
wantRefCounts : [ ] int { 1 , 1 } ,
wantRetry : true ,
warning : ReasonCodeItemSemanticProposalInvalid ,
} ,
} {
t . Run ( test . name , func ( t * testing . T ) {
input := dnd . ItemRegistry { Items : make ( [ ] dnd . Item , len ( test . items ) ) }
for index , item := range test . items {
input . Items [ index ] = cloneItem ( item )
}
before := dnd . ItemRegistry { Items : make ( [ ] dnd . Item , len ( input . Items ) ) }
for index , item := range input . Items {
before . Items [ index ] = cloneItem ( item )
}
result , err := newNormalizer ( t , & recordingNormalizerClient { response : test . response } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || ( result . Retry != nil ) != test . wantRetry || ! reflect . DeepEqual ( input , before ) || ! hasWarning ( result . Warnings , test . warning ) {
t . Fatalf ( "Normalize() = %#v, %v" , result , err )
}
if len ( result . Value . Items ) != len ( test . wantNames ) {
t . Fatalf ( "items = %#v, want names %#v" , result . Value . Items , test . wantNames )
}
for index , item := range result . Value . Items {
if item . Name != test . wantNames [ index ] || len ( item . SourceRefs ) != test . wantRefCounts [ index ] {
t . Fatalf ( "item %d = %#v, want name %q with %d source refs" , index , item , test . wantNames [ index ] , test . wantRefCounts [ index ] )
}
}
if test . wantRetry && ( len ( result . Retry . FallbackWarnings ) != 1 || result . Retry . FallbackWarnings [ 0 ] . ReasonCode != ReasonCodeItemSemanticReconciliationExhausted ) {
t . Fatalf ( "retry = %#v, want preserved-group fallback" , result . Retry )
}
} )
2026-08-05 19:39:06 +00:00
}
}
func TestNormalizePreservesCandidatesForUnsafeProposalGroups ( t * testing . T ) {
doc := semanticDocument ( )
input := dnd . ItemRegistry { Items : [ ] dnd . Item {
{ Name : "Star Compass" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } ,
{ Name : "Compass" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } ,
{ Name : "Rope" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } } } ,
} }
client := & recordingNormalizerClient { response : ` { "duplicate_groups":[ { "members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}, { "members":["candidate-000002","candidate-000003"],"canonical":"candidate-000003"}, { "members":["candidate-000001","candidate-000003"],"canonical":"candidate-000099"}]} ` }
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || result . Retry == nil || len ( result . Value . Items ) != 3 || ! strings . Contains ( result . Retry . Message , "overlapping_member" ) || ! strings . Contains ( result . Retry . Message , "canonical_unknown" ) || strings . Contains ( result . Retry . Message , "Star Compass" ) || len ( result . Retry . Message ) > 4096 {
t . Fatalf ( "Normalize() = %#v, %v; want deterministic retry fallback" , result , err )
}
}
func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence ( t * testing . T ) {
doc := semanticDocument ( )
input := dnd . ItemRegistry { Items : [ ] dnd . Item { { Name : "Star Compass" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } , { Name : "Compass" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } } }
invalid , err := newNormalizer ( t , & recordingNormalizerClient { err : contracts . ErrInvalidStructuredOutput } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || invalid . Retry == nil || invalid . Retry . ReasonCode != ReasonCodeItemSemanticProposalInvalid {
t . Fatalf ( "invalid result = %#v, %v" , invalid , err )
}
_ , err = newNormalizer ( t , & recordingNormalizerClient { err : errors . New ( "provider unavailable" ) } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err == nil || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
t . Fatalf ( "provider error = %v" , err )
}
if bounded := limitWarningsForRetry ( make ( [ ] contracts . Warning , diagnostics . MaxWarnings + 5 ) ) ; len ( bounded ) != diagnostics . MaxWarnings - 1 || bounded [ len ( bounded ) - 1 ] . ReasonCode != ReasonCodeItemNormalizationWarningsOmitted {
t . Fatalf ( "retry warning limit = %#v" , bounded )
}
first , err := newNormalizer ( t , & recordingNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
second , secondErr := newNormalizer ( t , & recordingNormalizerClient { } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( first . Value , doc ) )
if err != nil || secondErr != nil || ! reflect . DeepEqual ( first . Value , second . Value ) {
t . Fatalf ( "normalization must be idempotent: first=%#v err=%v second=%#v err=%v" , first , err , second , secondErr )
}
}
func TestRegisterPromptAssetsPreparesItemNormalizationPrompt ( t * testing . T ) {
registry := llm . NewAssetRegistry ( )
if err := entityreconcile . RegisterSchemaAssets ( registry ) ; err != nil {
t . Fatal ( err )
}
if err := RegisterPromptAssets ( registry ) ; err != nil {
t . Fatal ( err )
}
options , err := registry . PromptKitOptions ( )
if err != nil {
t . Fatal ( err )
}
options = append ( options , promptkit . WithProfiles ( promptkit . OpenAICompatibleProfile ( promptkit . OpenAICompatibleProfileConfig { ID : "item-normalize-test" , Endpoint : "http://127.0.0.1:1/v1" , Model : "test" } ) ) )
engine , err := promptkit . NewEngine ( promptkit . Config { Timeout : time . Second } , options ... )
if err != nil {
t . Fatal ( err )
}
2026-08-08 15:05:35 +00:00
prepared , err := engine . Prepare ( context . Background ( ) , promptkit . RunRequest { PromptID : PromptID , PromptVersion : entityreconcile . SchemaVersion , ProfileID : "item-normalize-test" , Inputs : map [ string ] promptkit . ArtifactRef { "candidates" : promptkit . Inline ( ` { "candidates":[ { "name":"Rope","source_refs":[ { "start_unit_id":1,"end_unit_id":1}]}]} ` ) , "transcript" : promptkit . Inline ( ` { "windows":[ { "units":[]}]} ` ) } } )
2026-08-05 19:39:06 +00:00
if err != nil {
t . Fatal ( err )
}
if prepared . OutputContract . SchemaPath != "dnd_entity_reconcile_llm.v1.json" || ! strings . Contains ( prepared . Messages [ 1 ] . Content , "currency denominations" ) || ! strings . Contains ( prepared . Messages [ 1 ] . Content , "materially different item types" ) {
t . Fatalf ( "prepared prompt = %#v" , prepared )
}
}
type recordingNormalizerClient struct {
response string
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( c * recordingNormalizerClient ) CompleteStructured ( _ context . Context , request contracts . StructuredCompletionRequest , output any ) ( contracts . StructuredCompletionResponse , error ) {
c . requests = append ( c . requests , request )
if c . err != nil {
return contracts . StructuredCompletionResponse { } , c . err
}
response := c . response
if response == "" {
response = ` { "duplicate_groups":[]} `
}
2026-08-08 15:05:35 +00:00
content , err := contextualProposalResponse ( response , request . Inputs [ "candidates" ] . Content )
if err != nil {
return contracts . StructuredCompletionResponse { } , err
}
if err := json . Unmarshal ( content , output ) ; err != nil {
2026-08-05 19:39:06 +00:00
return contracts . StructuredCompletionResponse { } , err
}
2026-08-08 15:05:35 +00:00
return contracts . StructuredCompletionResponse { Content : content } , nil
}
func contextualProposalResponse ( response string , candidateContent [ ] byte ) ( [ ] byte , error ) {
if ! strings . Contains ( response , "candidate-" ) {
return [ ] byte ( response ) , nil
}
var selection struct {
DuplicateGroups [ ] struct {
Members [ ] string ` json:"members" `
Canonical string ` json:"canonical" `
} ` json:"duplicate_groups" `
}
if err := json . Unmarshal ( [ ] byte ( response ) , & selection ) ; err != nil {
return nil , err
}
var candidates struct {
Candidates [ ] entityreconcile . Selector ` json:"candidates" `
}
if err := json . Unmarshal ( candidateContent , & candidates ) ; err != nil {
return nil , err
}
selector := func ( key string ) entityreconcile . Selector {
index , err := strconv . Atoi ( strings . TrimPrefix ( key , "candidate-" ) )
if err != nil || index < 1 || index > len ( candidates . Candidates ) {
return entityreconcile . Selector { Name : key , SourceRefs : [ ] entityreconcile . SourceRange { } }
}
return candidates . Candidates [ index - 1 ] . Clone ( )
}
proposal := entityreconcile . ProposalResponse { DuplicateGroups : make ( [ ] entityreconcile . DuplicateGroup , len ( selection . DuplicateGroups ) ) }
for index , group := range selection . DuplicateGroups {
members := make ( [ ] entityreconcile . Selector , len ( group . Members ) )
for memberIndex , key := range group . Members {
members [ memberIndex ] = selector ( key )
}
proposal . DuplicateGroups [ index ] = entityreconcile . DuplicateGroup { Members : members , Canonical : selector ( group . Canonical ) }
}
return json . Marshal ( proposal )
2026-08-05 19:39:06 +00:00
}
func newNormalizer ( t * testing . T , client contracts . StructuredLLMClient ) * Normalizer {
t . Helper ( )
normalizer , err := New ( client , Options { } )
if err != nil {
t . Fatalf ( "New() error = %v" , err )
}
return normalizer
}
func normalizeRequest ( value dnd . ItemRegistry ) contracts . TypedNormalizeRequest [ dnd . ItemRegistry ] {
return contracts . TypedNormalizeRequest [ dnd . ItemRegistry ] { MergeOutput : contracts . MergeArtifact [ dnd . ItemRegistry ] { Value : value } }
}
func normalizeRequestWithSource ( value dnd . ItemRegistry , doc * source . SourceDocument ) contracts . TypedNormalizeRequest [ dnd . ItemRegistry ] {
request := normalizeRequest ( value )
request . Source = doc
return request
}
func semanticDocument ( ) * source . SourceDocument {
return & source . SourceDocument { ID : "item-session" , Units : [ ] source . SourceUnit { { ID : 10 , Text : "The Star Compass points north." } , { ID : 20 , Text : "The compass of the stars glows." } , { ID : 30 , Text : "The chest holds gold pieces." } } }
}
func hasWarning ( warnings [ ] contracts . Warning , reason string ) bool {
for _ , warning := range warnings {
if warning . ReasonCode == reason {
return true
}
}
return false
}