2026-07-23 13:26:27 +00:00
package npcinteractions
import (
"context"
"encoding/json"
"errors"
"reflect"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractMapsEveryKindAndOrdersBySourcePosition ( t * testing . T ) {
client := & fakeInteractionsLLMClient { response : extractionResponse { Interactions : [ ] interactionResponse {
{ Name : "Other" , Kind : "other" , SourceRefs : interactionRefs ( 30 , 30 ) } ,
{ Name : "Opponent" , Kind : "combat_opponent" , SourceRefs : interactionRefs ( 20 , 20 ) } ,
{ Name : "Ally" , Kind : "combat_ally" , SourceRefs : interactionRefs ( 5 , 5 ) } ,
{ Name : "Speaker" , Kind : "dialogue" , SourceRefs : append ( interactionRefs ( 2 , 2 ) , interactionRefs ( 2 , 2 ) ... ) } ,
{ Name : "Present" , Kind : "noncombat_presence" , SourceRefs : interactionRefs ( 7 , 7 ) } ,
{ Name : "Mentioned" , Kind : "mentioned" , SourceRefs : interactionRefs ( 10 , 10 ) } ,
{ Name : "Invalid" , Kind : "unsupported" , SourceRefs : interactionRefs ( 0 , 0 ) } ,
} } }
references := requiredRegistryReferences ( t , "Mentioned" , "Speaker" , "Present" , "Ally" , "Opponent" , "Other" , "Invalid" )
req := extractionRequest ( )
req . References = references
result , err := newExtractor ( t , client , references ) . Extract ( context . Background ( ) , req )
if err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if got := interactionNames ( result . Value ) ; ! reflect . DeepEqual ( got , [ ] string { "Mentioned" , "Speaker" , "Present" , "Ally" , "Opponent" , "Other" , "Invalid" } ) {
t . Fatalf ( "interaction order = %#v" , got )
}
if got := interactionKinds ( result . Value ) ; ! reflect . DeepEqual ( got , [ ] dnd . NPCInteractionKind {
dnd . NPCInteractionKindMentioned ,
dnd . NPCInteractionKindDialogue ,
dnd . NPCInteractionKindNoncombatPresence ,
dnd . NPCInteractionKindCombatAlly ,
dnd . NPCInteractionKindCombatOpponent ,
dnd . NPCInteractionKindOther ,
"unsupported" ,
} ) {
t . Fatalf ( "interaction kinds = %#v" , got )
}
if refs := result . Value . Interactions [ 1 ] . SourceRefs ; ! reflect . DeepEqual ( refs , [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 2 , EndUnitID : 2 } } ) {
t . Fatalf ( "canonical source refs = %#v" , refs )
}
if invalid := result . Value . Interactions [ 6 ] ; invalid . Name != "Invalid" || invalid . Kind != "unsupported" || ! reflect . DeepEqual ( invalid . SourceRefs , [ ] source . SourceRef { { SourceID : "session-alpha" } } ) {
t . Fatalf ( "invalid candidate = %#v, want preserved values with current source identity" , invalid )
}
if len ( client . requests ) != 1 {
t . Fatalf ( "LLM calls = %d, want 1" , len ( client . requests ) )
}
}
2026-07-24 14:33:34 +00:00
func TestExtractUsesDocumentOrderForReferencesAndInteractions ( t * testing . T ) {
client := & fakeInteractionsLLMClient { response : extractionResponse { Interactions : [ ] interactionResponse {
{ Name : "Later" , Kind : "dialogue" , SourceRefs : interactionRefs ( 10 , 10 ) } ,
{ Name : "First" , Kind : "mentioned" , SourceRefs : [ ] interactionSourceRefResponse {
{ StartUnitID : 10 , EndUnitID : 10 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 999 , EndUnitID : 0 } ,
} } ,
{ Name : "Second" , Kind : "other" , SourceRefs : interactionRefs ( 30 , 30 ) } ,
} } }
references := requiredRegistryReferences ( t , "Later" , "First" , "Second" )
req := extractionRequest ( )
req . References = references
req . Source . Units = [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } }
req . Chunk . Units = append ( [ ] source . SourceUnit ( nil ) , req . Source . Units ... )
req . Chunk . Ref = source . SourceRef { SourceID : req . Source . ID , StartUnitID : 30 , EndUnitID : 10 }
result , err := newExtractor ( t , client , references ) . Extract ( context . Background ( ) , req )
if err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if got := interactionNames ( result . Value ) ; ! reflect . DeepEqual ( got , [ ] string { "First" , "Second" , "Later" } ) {
t . Fatalf ( "interaction order = %#v, want document chronology with stable equal-evidence ties" , got )
}
refs := result . Value . Interactions [ 0 ] . SourceRefs
if got := [ ] int { refs [ 0 ] . StartUnitID , refs [ 1 ] . StartUnitID , refs [ 2 ] . StartUnitID } ; ! reflect . DeepEqual ( got , [ ] int { 30 , 10 , 999 } ) {
t . Fatalf ( "source refs = %#v, want document order with exact duplicate removed" , refs )
}
refs [ 0 ] . StartUnitID = 777
for _ , interaction := range client . response . Interactions {
for _ , ref := range interaction . SourceRefs {
if ref . StartUnitID == 777 {
t . Fatal ( "result source references alias the model response" )
}
}
}
}
2026-07-23 13:26:27 +00:00
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets ( t * testing . T ) {
if _ , err := New ( nil , Options { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "LLM client" ) {
t . Fatalf ( "New(nil) error = %v" , err )
}
if _ , err := New ( & fakeInteractionsLLMClient { } , Options { } , contracts . ReferenceSet { } , contracts . ReferenceSet { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "at most one reference set" ) {
t . Fatalf ( "New() error = %v" , err )
}
}
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence ( t * testing . T ) {
client := & fakeInteractionsLLMClient { response : extractionResponse { Interactions : [ ] interactionResponse { {
Name : "Mira Thorn" , Kind : "dialogue" , SourceRefs : interactionRefs ( 10 , 10 ) ,
} } } }
references := requiredRegistryReferences ( t , "Mira Thorn" , "Hooded Guard" )
req := extractionRequest ( )
req . References = references
if _ , err := newExtractor ( t , client , references ) . Extract ( context . Background ( ) , req ) ; err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
request := client . requests [ 0 ]
registry := request . Inputs [ NPCRegistryReferenceSlot ]
if registry . Name != NPCRegistryReferenceSlot || registry . MediaType != "application/json" || string ( registry . Content ) != ` { "npcs":[ { "name":"Mira Thorn"}, { "name":"Hooded Guard"}]} ` {
t . Fatalf ( "registry prompt input = %#v, want exact names-only projection" , registry )
}
for _ , forbidden := range [ ] string { "npc:sha256:" , "other-session" , "start_unit_id" } {
if strings . Contains ( string ( registry . Content ) , forbidden ) {
t . Fatalf ( "registry prompt input leaked %q: %s" , forbidden , registry . Content )
}
}
if strings . Contains ( string ( request . Inputs [ "transcript" ] . Content ) , "other-session" ) {
t . Fatal ( "transcript input contains registry evidence" )
}
metadata , err := json . Marshal ( newExtractor ( t , & fakeInteractionsLLMClient { } , references ) . ManifestMetadata ( ) )
if err != nil {
t . Fatal ( err )
}
if strings . Contains ( string ( metadata ) , "Mira Thorn" ) || strings . Contains ( string ( metadata ) , "other-session" ) {
t . Fatalf ( "manifest metadata leaked registry content: %s" , metadata )
}
}
func TestExtractRequiresBoundRegistryBeforeLLMCall ( t * testing . T ) {
client := & fakeInteractionsLLMClient { }
if _ , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , "NPC registry reference is required" ) {
t . Fatalf ( "Extract() error = %v, want required registry failure" , err )
}
if len ( client . requests ) != 0 {
t . Fatalf ( "LLM calls = %d, want no call" , len ( client . requests ) )
}
malformed := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
NPCRegistryReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : "application/json" , Content : [ ] byte ( ` { "secret":"private source"} ` ) } } ,
} ,
} }
if _ , err := New ( client , Options { } , malformed ) ; err == nil || ! strings . Contains ( err . Error ( ) , "prepare NPC registry" ) || strings . Contains ( err . Error ( ) , "private source" ) {
t . Fatalf ( "New() error = %v, want content-safe malformed registry error" , err )
}
if len ( client . requests ) != 0 {
t . Fatalf ( "LLM calls = %d, want no call" , len ( client . requests ) )
}
}
func TestExtractResolvesGeneratedRegistryAtOperationTime ( t * testing . T ) {
client := & fakeInteractionsLLMClient { response : extractionResponse { Interactions : [ ] interactionResponse { } } }
references := requiredRegistryReferences ( t , "Mira Thorn" )
req := extractionRequest ( )
req . References = references
extractor := newExtractor ( t , client )
if _ , err := extractor . Extract ( context . Background ( ) , req ) ; err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if input := client . requests [ 0 ] . Inputs [ NPCRegistryReferenceSlot ] ; string ( input . Content ) != ` { "npcs":[ { "name":"Mira Thorn"}]} ` || input . OriginURI != "" {
t . Fatalf ( "generated registry prompt input = %#v" , input )
}
metadata := extractor . ManifestMetadata ( )
if _ , ok := metadata [ "npc_registry_digest" ] ; ok {
t . Fatalf ( "operation-time registry leaked into static metadata: %#v" , metadata )
}
}
func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse ( t * testing . T ) {
content , err := npccodec . New ( ) . Encode ( dnd . NPCList { NPCs : [ ] dnd . NPC { } } )
if err != nil {
t . Fatal ( err )
}
references := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
NPCRegistryReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : npccodec . MediaType , Content : content } } ,
} ,
} }
client := & fakeInteractionsLLMClient { response : extractionResponse { Interactions : [ ] interactionResponse { } } }
req := extractionRequest ( )
req . References = references
result , err := newExtractor ( t , client , references ) . Extract ( context . Background ( ) , req )
if err != nil || result . Value . Interactions == nil || len ( result . Value . Interactions ) != 0 {
t . Fatalf ( "Extract() = %#v, %v; want present empty interactions" , result , err )
}
}
func TestExtractRejectsInvalidRequestsAndProviderFailures ( t * testing . T ) {
references := requiredRegistryReferences ( t , "Mira Thorn" )
valid := extractionRequest ( )
valid . References = references
extractor := newExtractor ( t , & fakeInteractionsLLMClient { } , references )
2026-07-24 14:38:42 +00:00
var nilExtractor * Extractor
2026-07-23 13:26:27 +00:00
for _ , test := range [ ] struct {
2026-07-24 14:38:42 +00:00
name string
extractor * Extractor
ctx context . Context
req contracts . TypedExtractionRequest
want string
2026-07-23 13:26:27 +00:00
} {
2026-07-24 14:38:42 +00:00
{ "nil extractor" , nilExtractor , context . Background ( ) , valid , "extractor" } ,
{ "nil LLM client" , & Extractor { } , context . Background ( ) , valid , "LLM client" } ,
{ "wrapped preflight failure" , extractor , context . Background ( ) , mismatchedSourceInputRequest ( valid ) , "must match chunk" } ,
2026-07-23 13:26:27 +00:00
} {
t . Run ( test . name , func ( t * testing . T ) {
2026-07-24 14:38:42 +00:00
if _ , err := test . extractor . Extract ( test . ctx , test . req ) ; err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
2026-07-23 13:26:27 +00:00
t . Fatalf ( "Extract() error = %v, want %q" , err , test . want )
}
} )
}
if _ , err := newExtractor ( t , & fakeInteractionsLLMClient { err : errors . New ( "provider unavailable" ) } , references ) . Extract ( context . Background ( ) , valid ) ; err == nil || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
t . Fatalf ( "provider error = %v" , err )
}
}
func TestModuleSpecRegistrationMetadataAndFingerprints ( t * testing . T ) {
got := ModuleSpec ( )
if got . Key != Key || got . Stage != pipeline . StageExtract || got . ArtifactKind != dnd . NPCInteractionListKind || ! reflect . DeepEqual ( got . Requires , [ ] string { "chunks" , "source.transcript" } ) || ! reflect . DeepEqual ( got . Provides , [ ] string { "dnd.npc_interactions" } ) {
t . Fatalf ( "ModuleSpec() = %#v" , got )
}
var registrySlot contracts . ReferenceSlot
for _ , slot := range got . ReferenceSlots {
if slot . Name == NPCRegistryReferenceSlot {
registrySlot = slot
}
}
if ! registrySlot . Required || ! reflect . DeepEqual ( registrySlot . AcceptedArtifactKinds , [ ] contracts . ArtifactKind { dnd . NPCListKind } ) || registrySlot . MaxBytes != NPCRegistryMaxBytes {
t . Fatalf ( "NPC registry slot = %#v" , registrySlot )
}
got . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
if ModuleSpec ( ) . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] == "changed" {
t . Fatal ( "ModuleSpec() returned mutable reference slots" )
}
extractorSlots := newExtractor ( t , & fakeInteractionsLLMClient { } ) . ReferenceSlots ( )
extractorSlots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
if newExtractor ( t , & fakeInteractionsLLMClient { } ) . ReferenceSlots ( ) [ 0 ] . AcceptedMediaTypes [ 0 ] == "changed" {
t . Fatal ( "ReferenceSlots() returned mutable reference slots" )
}
registry := pipeline . NewExtractorRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatal ( err )
}
if _ , ok := registry . Spec ( Key ) ; ! ok {
t . Fatalf ( "registry missing %q" , Key )
}
if _ , err := DecodeOptions ( map [ string ] any { "unexpected" : true } ) ; err == nil {
t . Fatal ( "DecodeOptions() accepted unknown option" )
}
references := requiredRegistryReferences ( t , "Mira Thorn" )
extractor := newExtractor ( t , & fakeInteractionsLLMClient { } , references )
metadata := extractor . ManifestMetadata ( )
for key , want := range map [ string ] string {
"prompt_id" : PromptID , "prompt_version" : SchemaVersion , "mapping_policy" : mappingPolicy ,
"response_schema_key" : string ( ResponseSchemaKey ) , "response_schema_id" : ResponseSchemaID ,
"response_schema_name" : ResponseSchemaName , "response_schema_version" : SchemaVersion ,
} {
if metadata [ key ] != want {
t . Fatalf ( "metadata[%q] = %#v, want %q" , key , metadata [ key ] , want )
}
}
for _ , key := range [ ] string { "prompt_sha256" , "response_schema_sha256" , "npc_registry_digest" } {
if value , ok := metadata [ key ] . ( string ) ; ! ok || ! strings . HasPrefix ( value , "sha256:" ) {
t . Fatalf ( "metadata[%q] = %#v, want digest" , key , metadata [ key ] )
}
}
wantFingerprints := map [ string ] struct { } { "prompt" : { } , "response_schema" : { } , "mapping_policy" : { } , "npc_registry" : { } }
for _ , fingerprint := range extractor . CheckpointFingerprints ( ) {
delete ( wantFingerprints , fingerprint . Name )
}
if len ( wantFingerprints ) != 0 {
t . Fatalf ( "missing fingerprints = %#v" , wantFingerprints )
}
}
func extractionRequest ( ) contracts . TypedExtractionRequest {
doc := sourceDocument ( )
chunk := & source . Chunk {
ID : "session-alpha:chunk:0" , SourceID : doc . ID , Index : 0 ,
Ref : source . SourceRef { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 30 } ,
Content : [ ] byte ( ` { "units":[10,2,7,5,20,30]} ` ) , MediaType : "application/json" ,
Units : append ( [ ] source . SourceUnit ( nil ) , doc . Units ... ) ,
}
return contracts . TypedExtractionRequest {
Source : doc , Chunk : chunk ,
SourceInput : contracts . NewLLMInputMaterial ( "source" , chunk . MediaType , chunk . Content , "sha256:chunk" , "file:///session-alpha.json" ) ,
SessionID : "session-123" , LLMProfile : "profile-npc-interactions" ,
}
}
func sourceDocument ( ) * source . SourceDocument {
return & source . SourceDocument {
ID : "session-alpha" , Kind : "transcript" , Format : "application/vnd.seriatim.minimal+json" , Digest : "sha256:source" ,
Units : [ ] source . SourceUnit {
{ ID : 10 , Kind : "transcript_segment" , Text : "Mira Thorn is mentioned." } ,
{ ID : 2 , Kind : "transcript_segment" , Text : "Mira Thorn speaks to the party." } ,
{ ID : 7 , Kind : "transcript_segment" , Text : "Mira Thorn watches nearby." } ,
{ ID : 5 , Kind : "transcript_segment" , Text : "Mira Thorn joins the party in combat." } ,
{ ID : 20 , Kind : "transcript_segment" , Text : "Mira Thorn attacks the party." } ,
{ ID : 30 , Kind : "transcript_segment" , Text : "Mira Thorn performs a ritual." } ,
} ,
}
}
func requiredRegistryReferences ( t * testing . T , names ... string ) contracts . ReferenceSet {
t . Helper ( )
npcs := make ( [ ] dnd . NPC , len ( names ) )
for index , name := range names {
npcs [ index ] = dnd . NPC {
ID : identity . DeriveID ( name ) , Name : name ,
SourceRefs : [ ] source . SourceRef { { SourceID : "other-session" , StartUnitID : index + 1 , EndUnitID : index + 1 } } ,
}
}
content , err := npccodec . New ( ) . Encode ( dnd . NPCList { NPCs : npcs } )
if err != nil {
t . Fatal ( err )
}
return contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
NPCRegistryReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : npccodec . MediaType , Content : content , Origin : contracts . ReferenceOrigin { Type : "generated" } } } ,
} ,
} }
}
func interactionRefs ( start , end int ) [ ] interactionSourceRefResponse {
return [ ] interactionSourceRefResponse { { StartUnitID : start , EndUnitID : end } }
}
func interactionNames ( value dnd . NPCInteractionList ) [ ] string {
names := make ( [ ] string , len ( value . Interactions ) )
for index , interaction := range value . Interactions {
names [ index ] = interaction . Name
}
return names
}
func interactionKinds ( value dnd . NPCInteractionList ) [ ] dnd . NPCInteractionKind {
kinds := make ( [ ] dnd . NPCInteractionKind , len ( value . Interactions ) )
for index , interaction := range value . Interactions {
kinds [ index ] = interaction . Kind
}
return kinds
}
func mismatchedSourceInputRequest ( req contracts . TypedExtractionRequest ) contracts . TypedExtractionRequest {
req . SourceInput = contracts . NewLLMInputMaterial ( "source" , "application/json" , [ ] byte ( ` { "different":true} ` ) , "sha256:other" , "" )
return req
}
func newExtractor ( t * testing . T , client contracts . StructuredLLMClient , references ... contracts . ReferenceSet ) * Extractor {
t . Helper ( )
extractor , err := New ( client , Options { } , references ... )
if err != nil {
t . Fatalf ( "New() error = %v" , err )
}
return extractor
}
type fakeInteractionsLLMClient struct {
response extractionResponse
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( client * fakeInteractionsLLMClient ) CompleteStructured ( _ context . Context , req contracts . StructuredCompletionRequest , out any ) ( contracts . StructuredCompletionResponse , error ) {
client . requests = append ( client . requests , cloneStructuredCompletionRequest ( req ) )
if client . err != nil {
return contracts . StructuredCompletionResponse { } , client . err
}
target , ok := out . ( * extractionResponse )
if ! ok {
return contracts . StructuredCompletionResponse { } , errors . New ( "unexpected output target" )
}
* target = client . response
content , err := json . Marshal ( client . response )
if err != nil {
return contracts . StructuredCompletionResponse { } , err
}
return contracts . StructuredCompletionResponse { Content : content } , nil
}
func cloneStructuredCompletionRequest ( req contracts . StructuredCompletionRequest ) contracts . StructuredCompletionRequest {
req . Inputs = req . Inputs . Clone ( )
return req
}