2026-08-05 19:13:37 +00:00
package locationregistry
2026-08-04 00:07:47 +00:00
import (
"context"
2026-08-09 16:51:29 +00:00
"encoding/json"
2026-08-04 00:07:47 +00:00
"errors"
2026-08-09 16:51:29 +00:00
"fmt"
2026-08-04 00:07:47 +00:00
"reflect"
2026-08-09 02:26:49 +00:00
"strconv"
2026-08-04 00:07:47 +00:00
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-08-09 16:51:29 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
2026-08-04 00:07:47 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
)
func TestModuleContractAndMetadata ( t * testing . T ) {
2026-08-27 00:08:05 +00:00
want := pipeline . ModuleSpec { Key : Key , Stage : pipeline . StageNormalize , ExecutionClass : contracts . ExecutionClassLLMBacked , CorrectionProtocol : contracts . CorrectionProtocolSingleResponseV1 , Requires : [ ] string { "merged" } , Provides : [ ] string { "normalized" } , ArtifactKind : dnd . LocationRegistryKind }
2026-08-04 00:07:47 +00:00
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" )
}
normalizer := newNormalizer ( t , & recordingLocationNormalizerClient { } )
metadata := normalizer . ManifestMetadata ( )
2026-08-09 16:51:29 +00:00
limits , ok := metadata [ "semantic_reconciliation_limits" ] . ( map [ string ] any )
if ! ok || metadata [ "identity_policy" ] != identity . Policy || metadata [ "normalization_policy" ] != normalizationPolicy || metadata [ "prompt_id" ] != PromptID || metadata [ "prompt_version" ] != PromptVersion || metadata [ "response_schema_key" ] != string ( semanticreconcile . ResponseSchemaKey ) || metadata [ "response_schema_id" ] != semanticreconcile . ResponseSchemaID || metadata [ "response_schema_name" ] != semanticreconcile . ResponseSchemaName || metadata [ "semantic_reconciliation_policy" ] != semanticreconcile . Policy || len ( limits ) != 3 {
2026-08-04 00:07:47 +00:00
t . Fatalf ( "metadata = %#v" , metadata )
}
2026-08-09 16:51:29 +00:00
for _ , name := range [ ] string { "prompt" , "response_schema" , "semantic_reconciliation_policy" , "semantic_reconciliation_limits" , "identity_policy" , "normalization_policy" } {
if ! hasFingerprint ( normalizer . CheckpointFingerprints ( ) , name ) {
t . Fatalf ( "fingerprints missing %q" , name )
}
2026-08-04 00:07:47 +00:00
}
}
2026-08-09 18:30:32 +00:00
func TestNormalizeRejectsNilSourceDocument ( t * testing . T ) {
_ , err := newNormalizer ( t , & recordingLocationNormalizerClient { } ) . Normalize (
context . Background ( ) ,
contracts . TypedNormalizeRequest [ dnd . LocationRegistry ] { } ,
)
if err == nil || ! strings . Contains ( err . Error ( ) , "source document must not be nil" ) {
t . Fatalf ( "Normalize() error = %v, want nil source rejection" , err )
}
}
2026-08-04 00:07:47 +00:00
func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces ( t * testing . T ) {
2026-08-05 19:05:42 +00:00
input := dnd . LocationRegistry { Locations : [ ] dnd . Location {
2026-08-04 00:07:47 +00:00
{ Name : " The Tavern " , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "the tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "The Tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 2 , EndUnitID : 2 } } } ,
{ Name : "The Tavern Cellar" , SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : 2 , EndUnitID : 2 } } } ,
} }
2026-08-05 19:05:42 +00:00
before := dnd . LocationRegistry { Locations : append ( [ ] dnd . Location ( nil ) , input . Locations ... ) }
2026-08-09 16:51:29 +00:00
client := & recordingLocationNormalizerClient { }
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
2026-08-04 00:07:47 +00:00
if err != nil || len ( result . Value . Locations ) != 3 {
t . Fatalf ( "Normalize() = %#v, %v; want one exact duplicate removed" , result , err )
}
if ! reflect . DeepEqual ( input , before ) {
t . Fatalf ( "Normalize() mutated input: %#v" , input )
}
if got := [ ] string { result . Value . Locations [ 0 ] . Name , result . Value . Locations [ 1 ] . Name , result . Value . Locations [ 2 ] . Name } ; ! reflect . DeepEqual ( got , [ ] string { "The Tavern" , "The Tavern" , "The Tavern Cellar" } ) {
t . Fatalf ( "locations = %#v, want same names and nested place retained" , got )
}
2026-08-27 15:57:29 +00:00
if result . Value . Locations [ 0 ] . ID == result . Value . Locations [ 1 ] . ID || ! hasDiagnostic ( result . Diagnostics , ReasonCodeDuplicateLocationCollapsed , contracts . DiagnosticDispositionObservation ) || len ( client . requests ) != 0 {
2026-08-27 18:41:59 +00:00
t . Fatalf ( "result = %#v, want evidence-anchored IDs and exact duplicate finding" , result )
2026-08-04 00:07:47 +00:00
}
}
2026-08-09 02:26:49 +00:00
func BenchmarkExactDuplicateGroupsManyDistinct ( b * testing . B ) {
records := make ( [ ] normalizedRecord , 1_000 )
for index := range records {
records [ index ] = normalizedRecord { location : dnd . Location {
Name : "Location " + strconv . Itoa ( index ) ,
SourceRefs : [ ] source . SourceRef { { SourceID : "session" , StartUnitID : index + 1 , EndUnitID : index + 1 } } ,
} }
}
b . ReportAllocs ( )
b . ResetTimer ( )
for iteration := 0 ; iteration < b . N ; iteration ++ {
if groups := exactDuplicateGroups ( records ) ; len ( groups ) != len ( records ) {
b . Fatalf ( "group count = %d, want %d" , len ( groups ) , len ( records ) )
}
}
}
2026-08-08 15:05:35 +00:00
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs ( t * testing . T ) {
2026-08-29 03:16:24 +00:00
client := & recordingLocationNormalizerClient { response : ` { "duplicate_groups":[ { "candidate_numbers":[1,2],"canonical_candidate_number":2}]} ` }
2026-08-04 00:07:47 +00:00
doc := semanticDocument ( )
2026-08-05 19:05:42 +00:00
input := dnd . LocationRegistry { Locations : [ ] dnd . Location {
2026-08-04 00:07:47 +00:00
{ Name : "Old Mill" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } ,
{ Name : "the Greencloak's refuge" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } ,
{ Name : "The Tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } } } ,
} }
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || result . Retry != nil || len ( result . Value . Locations ) != 2 {
t . Fatalf ( "Normalize() = %#v, %v" , result , err )
}
merged := result . Value . Locations [ 0 ]
2026-08-27 15:57:29 +00:00
if merged . Name != "the Greencloak's refuge" || merged . ID != identity . DeriveID ( merged . Name , merged . SourceRefs ) || len ( merged . SourceRefs ) != 2 || ! hasDiagnostic ( result . Diagnostics , ReasonCodeDuplicateLocationCollapsed , contracts . DiagnosticDispositionObservation ) {
t . Fatalf ( "merged location = %#v, diagnostics = %#v" , merged , result . Diagnostics )
2026-08-04 00:07:47 +00:00
}
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-04 00:07:47 +00:00
t . Fatalf ( "private inputs = %s" , encoded )
}
}
func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates ( t * testing . T ) {
doc := semanticDocument ( )
2026-08-05 19:05:42 +00:00
input := dnd . LocationRegistry { Locations : [ ] dnd . Location {
2026-08-04 00:07:47 +00:00
{ Name : "Old Mill" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } ,
{ Name : "Mill" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } ,
{ Name : "Tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } } } ,
} }
2026-08-29 03:16:24 +00:00
client := & recordingLocationNormalizerClient { response : ` { "duplicate_groups":[ { "candidate_numbers":[1,2],"canonical_candidate_number":1}, { "candidate_numbers":[2,3],"canonical_candidate_number":3}]} ` }
2026-08-04 00:07:47 +00:00
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || result . Retry == nil || len ( result . Value . Locations ) != 3 || ! strings . Contains ( result . Retry . Message , "overlapping_member" ) {
t . Fatalf ( "Normalize() = %#v, %v; want safe retry fallback" , result , err )
}
2026-08-28 18:25:00 +00:00
if ! strings . Contains ( result . Retry . CorrectionGuidance , "Duplicate groups 1, 2" ) || ! strings . Contains ( result . Retry . CorrectionGuidance , "at most one duplicate group" ) || strings . Contains ( result . Retry . CorrectionGuidance , "overlapping_member" ) {
t . Fatalf ( "correction guidance = %q, want model-facing overlap explanation" , result . Retry . CorrectionGuidance )
}
if len ( result . Retry . FallbackDiagnostics ) != 1 || ! strings . Contains ( result . Retry . FallbackDiagnostics [ 0 ] . Samples [ 0 ] . Message , "2 proposal group" ) || ! strings . Contains ( result . Retry . FallbackDiagnostics [ 0 ] . Samples [ 0 ] . Message , "preserved separately" ) {
2026-08-27 15:57:29 +00:00
t . Fatalf ( "fallback diagnostics = %#v" , result . Retry . FallbackDiagnostics )
2026-08-04 00:07:47 +00:00
}
}
2026-08-09 16:51:29 +00:00
func TestReconciliationCandidatesKeepSameNameEvidenceDistinct ( t * testing . T ) {
doc := semanticDocument ( )
records := [ ] normalizedRecord {
{ location : dnd . Location { Name : "The Tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } , inputIndexes : [ ] int { 0 } , earliest : 0 } ,
{ location : dnd . Location { Name : "The Tavern" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } } } , inputIndexes : [ ] int { 1 } , earliest : 1 } ,
}
candidates , _ , err := reconciliationInputs ( records )
if err != nil {
t . Fatalf ( "reconciliationInputs() error = %v" , err )
}
preparation , err := semanticreconcile . Prepare ( doc , candidates , semanticreconcile . DefaultLimits ( ) )
if err != nil || preparation . Disposition ( ) != semanticreconcile . Ready {
t . Fatalf ( "Prepare() = %#v, %v; want ready candidates" , preparation , err )
}
var candidateInput struct {
Candidates [ ] struct {
2026-08-29 03:16:24 +00:00
CandidateNumber int ` json:"candidate_number" `
Label string ` json:"label" `
2026-08-09 16:51:29 +00:00
} ` json:"candidates" `
}
if err := json . Unmarshal ( preparation . Materials ( ) [ "candidates" ] . Content , & candidateInput ) ; err != nil {
t . Fatal ( err )
}
2026-08-29 03:16:24 +00:00
if len ( candidateInput . Candidates ) != 2 || candidateInput . Candidates [ 0 ] . CandidateNumber != 1 || candidateInput . Candidates [ 1 ] . CandidateNumber != 2 || candidateInput . Candidates [ 0 ] . Label != "The Tavern" || candidateInput . Candidates [ 1 ] . Label != "The Tavern" {
2026-08-09 16:51:29 +00:00
t . Fatalf ( "candidate input = %#v, want distinct integer handles for equal names" , candidateInput )
}
}
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning ( t * testing . T ) {
client := & recordingLocationNormalizerClient { }
doc := & source . SourceDocument { ID : "session" , Units : [ ] source . SourceUnit { { ID : 1 , Kind : "narration" , Text : "A sprawling city" } } }
limit := semanticreconcile . DefaultLimits ( ) . MaximumCandidates
input := dnd . LocationRegistry { Locations : make ( [ ] dnd . Location , limit + 1 ) }
for index := range input . Locations {
input . Locations [ index ] = dnd . Location { Name : fmt . Sprintf ( "Place %d" , index ) , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 1 , EndUnitID : 1 } } }
}
result , err := newNormalizer ( t , client ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || result . Retry != nil {
t . Fatalf ( "Normalize() = %#v, %v; want deterministic limit fallback" , result , err )
}
if len ( client . requests ) != 0 || len ( result . Value . Locations ) != limit + 1 {
t . Fatalf ( "completion calls = %d, locations = %d; want no call and all records" , len ( client . requests ) , len ( result . Value . Locations ) )
}
2026-08-27 15:57:29 +00:00
if ! hasDiagnostic ( result . Diagnostics , ReasonCodeLocationSemanticReconciliationExhausted , contracts . DiagnosticDispositionWarning ) {
t . Fatalf ( "diagnostics = %#v, want reconciliation fallback warning" , result . Diagnostics )
2026-08-09 16:51:29 +00:00
}
}
2026-08-04 00:07:47 +00:00
func TestNormalizeHandlesRetryFallbackAndErrors ( t * testing . T ) {
doc := semanticDocument ( )
2026-08-05 19:05:42 +00:00
input := dnd . LocationRegistry { Locations : [ ] dnd . Location { { Name : "Old Mill" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } } } , { Name : "Mill" , SourceRefs : [ ] source . SourceRef { { SourceID : doc . ID , StartUnitID : 20 , EndUnitID : 20 } } } } }
2026-08-04 00:07:47 +00:00
invalid , err := newNormalizer ( t , & recordingLocationNormalizerClient { err : contracts . ErrInvalidStructuredOutput } ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || invalid . Retry == nil || invalid . Retry . ReasonCode != ReasonCodeLocationSemanticProposalInvalid {
t . Fatalf ( "invalid result = %#v, %v" , invalid , err )
}
2026-08-28 18:25:00 +00:00
if invalid . Retry . CorrectionGuidance != "" || ! strings . Contains ( invalid . Retry . FallbackDiagnostics [ 0 ] . Samples [ 0 ] . Message , "structured-output contract" ) || ! strings . Contains ( invalid . Retry . FallbackDiagnostics [ 0 ] . Samples [ 0 ] . Message , "preserved separately" ) {
t . Fatalf ( "invalid structured retry = %#v, want feedback-free retry and informative fallback" , invalid . Retry )
}
2026-08-04 00:07:47 +00:00
_ , err = newNormalizer ( t , & recordingLocationNormalizerClient { 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 )
}
}
func TestNormalizeOrdersEvidenceAndIsIdempotent ( t * testing . T ) {
doc := & source . SourceDocument { ID : "ordered" , Units : [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } } }
2026-08-05 19:05:42 +00:00
input := dnd . LocationRegistry { Locations : [ ] dnd . Location { { Name : "Old Mill" , SourceRefs : [ ] source . SourceRef {
2026-08-04 00:07:47 +00:00
{ SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } ,
{ SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } ,
{ SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } ,
} } } }
normalizer := newNormalizer ( t , & recordingLocationNormalizerClient { } )
first , err := normalizer . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil || ! reflect . DeepEqual ( [ ] int { first . Value . Locations [ 0 ] . SourceRefs [ 0 ] . StartUnitID , first . Value . Locations [ 0 ] . SourceRefs [ 1 ] . StartUnitID } , [ ] int { 30 , 10 } ) {
t . Fatalf ( "first Normalize() = %#v, %v; want document-ordered evidence" , first , err )
}
second , err := normalizer . Normalize ( context . Background ( ) , normalizeRequestWithSource ( first . Value , doc ) )
if err != nil || ! reflect . DeepEqual ( second . Value , first . Value ) {
t . Fatalf ( "second Normalize() = %#v, %v; want idempotent value %#v" , second , err , first . Value )
}
}
2026-08-09 16:51:29 +00:00
func hasFingerprint ( fingerprints [ ] pipeline . CheckpointFingerprint , name string ) bool {
for _ , fingerprint := range fingerprints {
if fingerprint . Name == name && fingerprint . Value != "" {
return true
}
}
return false
}