2026-07-21 22:12:01 +00:00
package integration_test
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
2026-07-25 19:29:15 +00:00
"os"
"path/filepath"
2026-07-27 18:23:48 +00:00
"reflect"
2026-07-22 19:23:10 +00:00
"strings"
2026-07-21 22:12:01 +00:00
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
2026-07-27 18:23:48 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
2026-07-21 22:12:01 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-08-09 16:38:43 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
2026-07-21 22:12:01 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
2026-08-05 18:38:22 +00:00
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
2026-07-25 19:23:19 +00:00
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
2026-07-22 19:23:10 +00:00
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
2026-07-21 22:12:01 +00:00
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
2026-08-05 18:38:22 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry"
2026-07-25 19:23:19 +00:00
sceneextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
2026-07-21 22:12:01 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
2026-08-05 18:38:22 +00:00
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
2026-08-28 00:22:56 +00:00
combatsemantics "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/combat_semantics"
2026-07-21 22:12:01 +00:00
)
func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation ( t * testing . T ) {
registries := productionNPCRegistries ( t )
catalog := moduleCatalog ( registries )
configValue := loadGroundedPipelineConfig ( t )
2026-07-25 19:29:15 +00:00
campaignInputs := configureGroundedCampaignReferences ( t , & configValue )
2026-07-21 22:12:01 +00:00
effective , err := configValue . Resolve ( config . ResolveInput { PipelineID : "dnd-npc-grounded" , Catalog : catalog } )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
materialized , warnings , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalog , pipeline . ReferenceMaterializationOptions { } )
if err != nil || len ( warnings ) != 0 {
t . Fatalf ( "MaterializeReferences() error = %v warnings = %#v" , err , warnings )
}
client := & groundedDNDLLMClient { }
2026-07-22 19:23:10 +00:00
prepared , err := pipeline . Prepare ( materialized , registries , pipeline . ModuleDependencies { LLM : client } )
if err != nil {
t . Fatalf ( "Prepare() error = %v" , err )
}
2026-07-24 14:23:35 +00:00
for name , value := range map [ string ] string {
2026-08-28 13:45:21 +00:00
"extract:npc_registry:dnd/npc-registry:mapping_policy" : "dnd.npc_registry.extract_mapping.v3" ,
2026-08-09 16:38:43 +00:00
"normalize:npc_registry:dnd/npc-registry:identity_policy" : "dnd.npc_registry.identity.v1" ,
"normalize:npc_registry:dnd/npc-registry:normalization_policy" : npcnormalize . NormalizationPolicy ,
"normalize:npc_registry:dnd/npc-registry:semantic_reconciliation_policy" : semanticreconcile . Policy ,
2026-08-28 13:45:21 +00:00
"extract:spells:dnd/spells:mapping_policy" : "dnd.spells.extract_mapping.v3" ,
2026-08-09 16:38:43 +00:00
"extract:combat:dnd/combat-turns:scene_gate_policy" : "dnd.combat_turns.scene_gate.v1" ,
2026-07-24 14:23:35 +00:00
} {
assertFingerprintValue ( t , prepared . CheckpointFingerprints ( ) , name , value )
}
2026-07-22 19:23:10 +00:00
for _ , name := range [ ] string {
2026-08-05 18:38:22 +00:00
"extract:npc_registry:dnd/npc-registry:prompt" ,
"extract:npc_registry:dnd/npc-registry:response_schema" ,
"normalize:npc_registry:dnd/npc-registry:prompt" ,
"normalize:npc_registry:dnd/npc-registry:response_schema" ,
2026-08-09 16:38:43 +00:00
"normalize:npc_registry:dnd/npc-registry:semantic_reconciliation_limits" ,
2026-07-22 19:23:10 +00:00
"extract:spells:dnd/spells:prompt" ,
"extract:spells:dnd/spells:response_schema" ,
"extract:spells:dnd/spells:npc_registry" ,
"extract:combat:dnd/combat-turns:prompt" ,
"extract:combat:dnd/combat-turns:response_schema" ,
2026-07-25 19:23:19 +00:00
"extract:combat:dnd/combat-turns:scene_eligibility" ,
2026-07-22 19:23:10 +00:00
"extract:combat:dnd/combat-turns:npc_registry" ,
"normalize:combat:dnd/combat-turns:npc_registry" ,
} {
assertCombatFingerprint ( t , prepared . CheckpointFingerprints ( ) , name )
}
2026-07-25 19:29:15 +00:00
checkpoint := newGeneratedReferenceCheckpointLoader ( )
2026-07-22 19:23:10 +00:00
output , err := pipeline . New ( ) . Run ( context . Background ( ) , pipeline . RunInput {
Prepared : prepared ,
2026-07-21 22:12:01 +00:00
RawInput : readNPCFixture ( t ) ,
ExtractWorkers : 1 ,
2026-07-25 19:29:15 +00:00
Checkpoint : checkpoint ,
2026-07-21 22:12:01 +00:00
} )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-07-25 19:23:19 +00:00
if len ( output . Rejected ) != 0 || len ( output . NormalizeOutputs ) != 4 {
t . Fatalf ( "run outputs = %#v rejected = %#v, want NPC, scene, spell, and combat outputs" , output . NormalizeOutputs , output . Rejected )
2026-07-21 22:12:01 +00:00
}
2026-08-05 18:38:22 +00:00
wantSchemas := map [ string ] string { "npc_registry" : npccodec . SchemaID , "scene-descriptions" : scenecodec . SchemaID , "spells" : spellcodec . SchemaID , "combat" : combatcodec . SchemaID }
2026-07-22 19:23:10 +00:00
for _ , serialized := range output . NormalizeOutputs {
if serialized . Artifact . Schema . ID != wantSchemas [ serialized . LaneID ] || serialized . Artifact . Schema . Version != "v1" {
t . Fatalf ( "%s artifact schema = %#v, want minimal v1 identity" , serialized . LaneID , serialized . Artifact . Schema )
}
}
wantExtractorIdentity := map [ string ] struct { promptID , schemaID string } {
2026-08-05 18:38:22 +00:00
"npc_registry" : { npcregistry . PromptID , npcregistry . ResponseSchemaID } ,
2026-07-25 19:23:19 +00:00
"scene-descriptions" : { sceneextract . PromptID , sceneextract . ResponseSchemaID } ,
"spells" : { spells . PromptID , spells . ResponseSchemaID } ,
"combat" : { combatextract . PromptID , combatextract . ResponseSchemaID } ,
2026-07-22 19:23:10 +00:00
}
for _ , lane := range output . Manifest . ArtifactLanes {
want , ok := wantExtractorIdentity [ lane . ID ]
metadata , metadataOK := lane . Metadata [ "extractor" ] . ( map [ string ] any )
if ! ok || ! metadataOK || metadata [ "prompt_id" ] != want . promptID || metadata [ "prompt_version" ] != "v1" || metadata [ "response_schema_id" ] != want . schemaID || metadata [ "response_schema_version" ] != "v1" {
t . Fatalf ( "%s extractor metadata = %#v, want v1 prompt/schema identity" , lane . ID , metadata )
}
}
2026-07-21 22:12:01 +00:00
2026-07-25 19:29:15 +00:00
var npcPayload , scenePayload [ ] byte
seenSteps := map [ string ] string { }
2026-07-21 22:12:01 +00:00
for _ , serialized := range output . NormalizeOutputs {
2026-07-25 19:29:15 +00:00
seenSteps [ serialized . LaneID ] = serialized . StepID
switch serialized . LaneID {
2026-08-05 18:38:22 +00:00
case "npc_registry" :
2026-07-21 22:12:01 +00:00
npcPayload = append ( [ ] byte ( nil ) , serialized . Artifact . Content ... )
2026-07-25 19:29:15 +00:00
case "scene-descriptions" :
scenePayload = append ( [ ] byte ( nil ) , serialized . Artifact . Content ... )
2026-07-21 22:12:01 +00:00
}
}
2026-08-05 18:38:22 +00:00
if seenSteps [ "npc_registry" ] != "identify-npcs" || seenSteps [ "scene-descriptions" ] != "identify-npcs" || seenSteps [ "spells" ] != "grounded-events" || seenSteps [ "combat" ] != "grounded-events" {
2026-07-25 19:29:15 +00:00
t . Fatalf ( "normalized output steps = %#v, want ordered producer and consumer steps" , seenSteps )
}
2026-07-21 22:12:01 +00:00
if len ( npcPayload ) == 0 {
t . Fatal ( "NPC producer did not publish a canonical payload" )
}
2026-07-25 19:29:15 +00:00
if len ( scenePayload ) == 0 {
t . Fatal ( "scene producer did not publish a canonical payload" )
}
2026-07-21 22:12:01 +00:00
npcValue , err := npccodec . New ( ) . Decode ( npcPayload )
if err != nil {
t . Fatalf ( "decode NPC producer payload: %v" , err )
}
npcPayload , err = npccodec . New ( ) . Encode ( npcValue )
if err != nil {
t . Fatalf ( "encode canonical NPC producer payload: %v" , err )
}
canonicalDigest := digestBytes ( npcPayload )
2026-07-25 19:29:15 +00:00
sceneValue , err := scenecodec . New ( ) . Decode ( scenePayload )
if err != nil {
t . Fatalf ( "decode scene producer payload: %v" , err )
}
scenePayload , err = scenecodec . New ( ) . Encode ( sceneValue )
if err != nil {
t . Fatalf ( "encode canonical scene producer payload: %v" , err )
}
sceneDigest := digestBytes ( scenePayload )
2026-07-22 18:51:26 +00:00
projection := [ ] byte ( ` { "npcs":[ { "name":"Mira Thorn"}, { "name":"Hooded Guard"}]} ` )
projectionDigest := digestBytes ( projection )
2026-07-21 22:12:01 +00:00
seenConsumers := map [ string ] bool { }
for _ , request := range client . requestsSnapshot ( ) {
if request . PromptID != spells . PromptID && request . PromptID != combatextract . PromptID {
continue
}
2026-08-05 18:38:22 +00:00
input := request . Inputs [ "npc_registry" ]
2026-07-22 18:51:26 +00:00
if input . MediaType != npccodec . MediaType || input . Digest != projectionDigest || string ( input . Content ) != string ( projection ) || input . OriginURI != "" {
t . Fatalf ( "%s NPC prompt input = %#v, want names-only generated registry" , request . PromptID , input )
2026-07-21 22:12:01 +00:00
}
2026-07-25 19:29:15 +00:00
if request . PromptID == combatextract . PromptID {
transcript , ok := request . Inputs [ "transcript" ]
if ! ok || ! strings . Contains ( string ( transcript . Content ) , "Mira Thorn asks the party" ) {
t . Fatalf ( "combat transcript input = %#v, want configured transcript content" , transcript )
}
for name , want := range campaignInputs {
input , ok := request . Inputs [ name ]
if ! ok || string ( input . Content ) != want {
t . Fatalf ( "combat %s input = %#v, want configured campaign reference" , name , input )
}
}
if _ , ok := request . Inputs [ "scene_descriptions" ] ; ok {
t . Fatalf ( "combat prompt inputs = %#v, must not contain scene descriptions" , request . Inputs )
}
}
2026-07-21 22:12:01 +00:00
seenConsumers [ request . PromptID ] = true
}
if ! seenConsumers [ spells . PromptID ] || ! seenConsumers [ combatextract . PromptID ] {
t . Fatalf ( "consumer prompt IDs = %#v, want spell and combat requests" , seenConsumers )
}
2026-07-25 19:29:15 +00:00
if combatCompletionCount ( client . requestsSnapshot ( ) ) != 1 {
t . Fatalf ( "combat completion requests = %#v, want exactly one for the exact combat scene" , client . requestsSnapshot ( ) )
}
2026-07-21 22:12:01 +00:00
provenanceCount := 0
for _ , reference := range output . Manifest . References {
2026-08-05 18:38:22 +00:00
if reference . SlotName != "npc_registry" {
2026-07-21 22:12:01 +00:00
continue
}
provenanceCount ++
if reference . Digest != canonicalDigest || reference . OriginType != "generated" {
t . Fatalf ( "NPC generated provenance = %#v, want canonical digest and generated origin" , reference )
}
}
if provenanceCount != 3 {
t . Fatalf ( "NPC generated provenance count = %d, want spell extract plus combat extract/normalize" , provenanceCount )
}
2026-07-25 19:23:19 +00:00
sceneProvenanceCount := 0
for _ , reference := range output . Manifest . References {
if reference . SlotName == "scene_descriptions" {
sceneProvenanceCount ++
2026-07-25 19:29:15 +00:00
if reference . Stage != string ( pipeline . StageExtract ) || reference . StepID != "grounded-events" || reference . LaneID != "combat" || reference . OriginType != "generated" || reference . Digest != sceneDigest || reference . ArtifactKind != string ( dnd . SceneDescriptionListKind ) || reference . SchemaID != scenecodec . SchemaID || reference . SchemaName != scenecodec . SchemaName || reference . SchemaVersion != scenecodec . SchemaVersion || reference . MediaType != scenecodec . MediaType || reference . ProducerPipeline != "dnd-npc-grounded" || reference . ProducerStep != "identify-npcs" || reference . ProducerLane != "scene-descriptions" || reference . ProducerModule != "dnd/scene-descriptions" {
t . Fatalf ( "scene generated provenance = %#v, want canonical combat handoff identity" , reference )
}
2026-07-25 19:23:19 +00:00
}
}
if sceneProvenanceCount != 1 {
t . Fatalf ( "scene generated provenance count = %d, want combat extractor handoff" , sceneProvenanceCount )
}
2026-07-25 19:29:15 +00:00
assertGeneratedSceneDependency ( t , checkpoint . extractDependencies ( "combat" ) )
2026-07-22 19:23:10 +00:00
manifestContent , err := json . Marshal ( output . Manifest )
if err != nil {
t . Fatal ( err )
}
2026-07-25 19:29:15 +00:00
for _ , forbidden := range [ ] string { "Mira Thorn" , "Hooded Guard" , "For every NPC record" , "Extract Dungeons & Dragons" , "A combat encounter" , "The party faces an active encounter." , "Mira Thorn asks the party" , ` "scenes": ` } {
2026-07-22 19:23:10 +00:00
if strings . Contains ( string ( manifestContent ) , forbidden ) {
t . Fatalf ( "manifest exposes prompt or reference payload content %q" , forbidden )
}
}
2026-07-21 22:12:01 +00:00
for _ , lane := range output . Manifest . ArtifactLanes {
2026-07-25 19:29:15 +00:00
componentContent , err := json . Marshal ( lane . Metadata )
if err != nil {
t . Fatalf ( "marshal %s component metadata: %v" , lane . ID , err )
}
for _ , forbidden := range [ ] string { "A combat encounter" , "The party faces an active encounter." , "Mira Thorn asks the party" , ` "scenes": ` } {
if strings . Contains ( string ( componentContent ) , forbidden ) {
t . Fatalf ( "%s component metadata exposes scene or transcript content %q" , lane . ID , forbidden )
}
}
2026-07-21 22:12:01 +00:00
for _ , component := range [ ] string { "extractor" , "normalizer" } {
metadata , ok := lane . Metadata [ component ] . ( map [ string ] any )
2026-07-25 19:23:19 +00:00
if ok && ( metadata [ "npc_registry_digest" ] != nil || metadata [ "scene_eligibility_digest" ] != nil ) {
2026-07-21 22:12:01 +00:00
t . Fatalf ( "%s %s metadata = %#v, want generated identity only in framework provenance" , lane . ID , component , metadata )
}
}
}
var combatValue dnd . CombatTurnList
for _ , serialized := range output . NormalizeOutputs {
switch serialized . LaneID {
case "spells" :
spellValue := decodeRunnerSpellResponse ( t , serialized . Artifact . Content )
2026-07-22 18:51:26 +00:00
if len ( spellValue . SpellCasts ) != 1 || spellValue . SpellCasts [ 0 ] . Caster != "Mira Thorn" {
2026-07-21 22:12:01 +00:00
t . Fatalf ( "spell output = %#v, want one registry-grounded-context spell" , spellValue )
}
2026-07-22 19:01:58 +00:00
assertCurrentEvidence ( t , spellValue . SpellCasts [ 0 ] . SourceRefs )
2026-07-21 22:12:01 +00:00
case "combat" :
decoded , decodeErr := combatcodec . New ( ) . Decode ( serialized . Artifact . Content )
if decodeErr != nil {
t . Fatalf ( "decode combat output: %v" , decodeErr )
}
combatValue = decoded
}
}
2026-07-22 19:14:14 +00:00
if len ( combatValue . CombatTurns ) != 1 || combatValue . CombatTurns [ 0 ] . Actor != "Mira Thorn" {
t . Fatalf ( "combat output = %#v, want registry-normalized actor" , combatValue )
2026-07-21 22:12:01 +00:00
}
assertCurrentEvidence ( t , combatValue . CombatTurns [ 0 ] . SourceRefs )
}
2026-08-28 00:22:56 +00:00
func TestSceneCombatSemanticsCorrectionRetriesAndRevalidates ( t * testing . T ) {
registries := productionNPCRegistries ( t )
configValue := loadGroundedPipelineConfig ( t )
profile := configValue . Pipelines [ "dnd-npc-grounded" ]
sceneLane := profile . Steps [ 0 ] . Artifacts [ "scene-descriptions" ]
sceneLane . Extract . Retries = 1
sceneLane . Extract . Validators = pipeline . ValidatorOverride { Set : true , Validators : [ ] pipeline . ModuleBinding {
pipeline . Binding ( "generic/valid_json" ) ,
pipeline . Binding ( "extract/dnd/scene-descriptions/shape" ) ,
pipeline . Binding ( "extract/dnd/scene-descriptions/source_refs" ) ,
pipeline . Binding ( "generic/valid_json_schema" ) ,
pipeline . Binding ( "extract/dnd/scene-descriptions/source_relatedness" ) ,
pipeline . Binding ( combatsemantics . Key ) ,
} }
profile . Steps [ 0 ] . Artifacts [ "scene-descriptions" ] = sceneLane
configValue . Pipelines [ "dnd-npc-grounded" ] = profile
2026-08-29 11:39:02 +00:00
client := & groundedDNDLLMClient { sceneKinds : [ ] dnd . SceneKind { dnd . SceneKindNarrative , dnd . SceneKindCombat } , combatSemanticsClassifications : [ ] string { "combat" , "combat" } }
2026-08-28 00:22:56 +00:00
output := runGroundedPipeline ( t , configValue , registries , client , nil )
if len ( output . Rejected ) != 0 {
t . Fatalf ( "rejected = %#v, want corrected acceptance" , output . Rejected )
}
for _ , group := range output . Diagnostics . Groups {
if group . Origin . ValidatorKey == combatsemantics . Key {
t . Fatalf ( "combat-semantics validator left a diagnostic after correction: %#v" , group )
}
}
var scenes dnd . SceneDescriptionList
for _ , serialized := range output . NormalizeOutputs {
if serialized . LaneID == "scene-descriptions" {
var err error
scenes , err = scenecodec . New ( ) . Decode ( serialized . Artifact . Content )
if err != nil {
t . Fatal ( err )
}
}
}
if len ( scenes . Scenes ) != 1 || scenes . Scenes [ 0 ] . Kind != dnd . SceneKindCombat {
t . Fatalf ( "accepted scenes = %#v, want corrected combat scene" , scenes )
}
requests := client . requestsSnapshot ( )
var sceneRequests , validatorRequests [ ] contracts . StructuredCompletionRequest
for _ , request := range requests {
switch request . PromptID {
case sceneextract . PromptID :
sceneRequests = append ( sceneRequests , request )
case combatsemantics . PromptID :
validatorRequests = append ( validatorRequests , request )
}
}
if len ( sceneRequests ) != 2 || len ( validatorRequests ) != 2 || sceneRequests [ 0 ] . Correction != nil || sceneRequests [ 1 ] . Correction == nil {
t . Fatalf ( "scene/validator requests = %d/%d; want correction and revalidation" , len ( sceneRequests ) , len ( validatorRequests ) )
}
correction := sceneRequests [ 1 ] . Correction
if ! strings . Contains ( string ( correction . AssistantResponse ) , ` "kind":"narrative" ` ) || ! strings . Contains ( correction . UserGuidance , "Return kind: combat" ) || ! strings . Contains ( correction . UserGuidance , "active encounter" ) {
t . Fatalf ( "correction = %#v, want latest candidate and useful guidance" , correction )
}
for _ , forbidden := range [ ] string { combatsemantics . Key , combatsemantics . ReasonCodeActiveCombatNotClassified , "session:chunk" } {
if strings . Contains ( correction . UserGuidance , forbidden ) {
t . Fatalf ( "correction guidance leaked internal detail %q: %q" , forbidden , correction . UserGuidance )
}
}
}
2026-07-27 18:23:48 +00:00
func TestProductionDNDOutputPublishesSelectedEvidenceContext ( t * testing . T ) {
registries := productionNPCRegistries ( t )
configValue := loadGroundedPipelineConfig ( t )
profile := configValue . Pipelines [ "dnd-npc-grounded" ]
profile . Output . Options = map [ string ] any { "evidence_context" : map [ string ] any {
2026-08-05 18:38:22 +00:00
"enabled" : true , "window_units" : 0 , "lanes" : [ ] any { "npc_registry" , "spells" , "combat" } ,
2026-07-27 18:23:48 +00:00
} }
configValue . Pipelines [ "dnd-npc-grounded" ] = profile
raw := strings . NewReplacer (
` "id": 1 ` , ` "id": 10 ` ,
` "id": 2 ` , ` "id": 30 ` ,
` "id": 3 ` , ` "id": 20 ` ,
` "id": 4 ` , ` "id": 50 ` ,
` "id": 5 ` , ` "id": 40 ` ,
) . Replace ( string ( readNPCFixture ( t ) ) )
output := runGroundedPipelineWithRaw ( t , configValue , registries , & groundedDNDLLMClient { firstUnitID : 10 , thirdUnitID : 20 } , nil , [ ] byte ( raw ) )
value , err := evidencecontext . New ( ) . Decode ( outputFileContent ( t , output . OutputFiles , "evidence-context.json" ) )
if err != nil {
t . Fatalf ( "Decode(evidence context) error = %v" , err )
}
2026-08-09 19:45:21 +00:00
if actual := evidenceUnitIDs ( value ) ; ! reflect . DeepEqual ( actual , [ ] int { 10 , 20 } ) {
t . Fatalf ( "evidence units = %#v, want deduplicated source-position union without scene descriptions" , actual )
2026-07-27 18:23:48 +00:00
}
}
func TestProductionDNDOutputCanExplicitlySelectSceneDescriptionEvidence ( t * testing . T ) {
registries := productionNPCRegistries ( t )
configValue := loadGroundedPipelineConfig ( t )
profile := configValue . Pipelines [ "dnd-npc-grounded" ]
profile . Output . Options = map [ string ] any { "evidence_context" : map [ string ] any {
"enabled" : true , "lanes" : [ ] any { "scene-descriptions" } ,
} }
configValue . Pipelines [ "dnd-npc-grounded" ] = profile
output := runGroundedPipeline ( t , configValue , registries , & groundedDNDLLMClient { } , nil )
value , err := evidencecontext . New ( ) . Decode ( outputFileContent ( t , output . OutputFiles , "evidence-context.json" ) )
if err != nil {
t . Fatalf ( "Decode(evidence context) error = %v" , err )
}
2026-08-09 19:45:21 +00:00
if len ( value ) == 0 {
t . Fatalf ( "evidence context = %#v, want explicitly selected scene-description source units" , value )
}
}
func evidenceUnitIDs ( value evidencecontext . Document ) [ ] int {
ids := make ( [ ] int , len ( value ) )
for index , unit := range value {
ids [ index ] = unit . ID
2026-07-27 18:23:48 +00:00
}
2026-08-09 19:45:21 +00:00
return ids
2026-07-27 18:23:48 +00:00
}
2026-07-25 19:29:15 +00:00
func TestGroundedPipelineSkipsCombatForExactNarrativeScene ( t * testing . T ) {
registries := productionNPCRegistries ( t )
configValue := loadGroundedPipelineConfig ( t )
profile := configValue . Pipelines [ "dnd-npc-grounded" ]
combat := profile . Steps [ 1 ] . Artifacts [ "combat" ]
combat . Extract . Retries = 2
profile . Steps [ 1 ] . Artifacts [ "combat" ] = combat
configValue . Pipelines [ "dnd-npc-grounded" ] = profile
client := & groundedDNDLLMClient { sceneKind : dnd . SceneKindNarrative }
output := runGroundedPipeline ( t , configValue , registries , client , nil )
if combatCompletionCount ( client . requestsSnapshot ( ) ) != 0 {
t . Fatalf ( "combat completion requests = %#v, want none for an exact narrative scene" , client . requestsSnapshot ( ) )
}
2026-08-27 16:40:17 +00:00
if len ( output . Rejected ) != 0 || hasCombatWarning ( output . Diagnostics , "scene_classification_unavailable" ) {
t . Fatalf ( "narrative output rejected/diagnostics = %#v / %#v, want accepted skip without unavailable advisory" , output . Rejected , output . Diagnostics )
2026-07-25 19:29:15 +00:00
}
combatValue := normalizedCombatOutput ( t , output )
if combatValue . CombatTurns == nil || len ( combatValue . CombatTurns ) != 0 {
t . Fatalf ( "narrative combat output = %#v, want an accepted empty combat list" , combatValue )
}
}
func TestGeneratedSceneReferenceChangesCombatCheckpointDependency ( t * testing . T ) {
registries := productionNPCRegistries ( t )
first := generatedSceneDependency ( t , registries , "A combat encounter" )
second := generatedSceneDependency ( t , registries , "A revised combat encounter" )
if first == second {
t . Fatalf ( "generated combat dependencies = %q and %q, want canonical scene artifact change to invalidate reuse" , first , second )
}
}
2026-07-24 14:23:35 +00:00
func assertFingerprintValue ( t * testing . T , fingerprints [ ] pipeline . CheckpointFingerprint , name , want string ) {
t . Helper ( )
for _ , fingerprint := range fingerprints {
if fingerprint . Name == name && fingerprint . Value == want {
return
}
}
t . Fatalf ( "fingerprints = %#v, want %q = %q" , fingerprints , name , want )
}
2026-07-21 22:12:01 +00:00
func assertCurrentEvidence ( t * testing . T , references [ ] source . SourceRef ) {
t . Helper ( )
for _ , reference := range references {
if reference . SourceID != "npc-session" {
t . Fatalf ( "evidence reference = %#v, want current source only" , reference )
}
}
}
func loadGroundedPipelineConfig ( t * testing . T ) config . Config {
t . Helper ( )
2026-09-13 17:23:00 +00:00
return loadIntegrationConfig ( t , repositoryPathForIntegration ( "internal" , "modules" , "integration" , "testdata" , "dnd_npc_grounded_pipeline.yml" ) )
2026-07-21 22:12:01 +00:00
}
2026-07-25 19:29:15 +00:00
func configureGroundedCampaignReferences ( t * testing . T , cfg * config . Config ) map [ string ] string {
t . Helper ( )
values := map [ string ] string {
"party" : "Mira Thorn: ally\n" ,
"glossary" : "Greencloak: Mira Thorn's title\n" ,
}
profile := cfg . Pipelines [ "dnd-npc-grounded" ]
profile . References = make ( map [ string ] pipeline . ReferenceSource , len ( values ) )
for name , content := range values {
path := filepath . Join ( t . TempDir ( ) , name + ".txt" )
if err := os . WriteFile ( path , [ ] byte ( content ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write %s reference: %v" , name , err )
}
profile . References [ name ] = pipeline . ExternalReference ( path )
}
cfg . Pipelines [ "dnd-npc-grounded" ] = profile
return values
}
func runGroundedPipeline ( t * testing . T , configValue config . Config , registries pipeline . Registries , client * groundedDNDLLMClient , checkpoint pipeline . CheckpointLoader ) pipeline . RunOutput {
2026-07-27 18:23:48 +00:00
return runGroundedPipelineWithRaw ( t , configValue , registries , client , checkpoint , readNPCFixture ( t ) )
}
func runGroundedPipelineWithRaw ( t * testing . T , configValue config . Config , registries pipeline . Registries , client * groundedDNDLLMClient , checkpoint pipeline . CheckpointLoader , raw [ ] byte ) pipeline . RunOutput {
2026-07-25 19:29:15 +00:00
t . Helper ( )
catalog := moduleCatalog ( registries )
effective , err := configValue . Resolve ( config . ResolveInput { PipelineID : "dnd-npc-grounded" , Catalog : catalog } )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
materialized , warnings , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalog , pipeline . ReferenceMaterializationOptions { } )
if err != nil || len ( warnings ) != 0 {
t . Fatalf ( "MaterializeReferences() error = %v warnings = %#v" , err , warnings )
}
prepared , err := pipeline . Prepare ( materialized , registries , pipeline . ModuleDependencies { LLM : client } )
if err != nil {
t . Fatalf ( "Prepare() error = %v" , err )
}
output , err := pipeline . New ( ) . Run ( context . Background ( ) , pipeline . RunInput {
Prepared : prepared ,
2026-07-27 18:23:48 +00:00
RawInput : append ( [ ] byte ( nil ) , raw ... ) ,
2026-07-25 19:29:15 +00:00
ExtractWorkers : 1 ,
Checkpoint : checkpoint ,
} )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
return output
}
2026-07-27 18:23:48 +00:00
func outputFileContent ( t * testing . T , files [ ] contracts . OutputFile , name string ) [ ] byte {
t . Helper ( )
for _ , file := range files {
if file . Name == name {
return append ( [ ] byte ( nil ) , file . Bytes ... )
}
}
t . Fatalf ( "output files = %#v, missing %q" , files , name )
return nil
}
2026-07-25 19:29:15 +00:00
func normalizedCombatOutput ( t * testing . T , output pipeline . RunOutput ) dnd . CombatTurnList {
t . Helper ( )
for _ , serialized := range output . NormalizeOutputs {
if serialized . LaneID != "combat" {
continue
}
value , err := combatcodec . New ( ) . Decode ( serialized . Artifact . Content )
if err != nil {
t . Fatalf ( "decode combat output: %v" , err )
}
return value
}
t . Fatalf ( "normalized outputs = %#v, want combat lane" , output . NormalizeOutputs )
return dnd . CombatTurnList { }
}
func combatCompletionCount ( requests [ ] contracts . StructuredCompletionRequest ) int {
count := 0
for _ , request := range requests {
if request . PromptID == combatextract . PromptID {
count ++
}
}
return count
}
func generatedSceneDependency ( t * testing . T , registries pipeline . Registries , sceneTitle string ) string {
t . Helper ( )
client := & groundedDNDLLMClient { sceneTitle : sceneTitle }
checkpoint := newGeneratedReferenceCheckpointLoader ( )
runGroundedPipeline ( t , loadGroundedPipelineConfig ( t ) , registries , client , checkpoint )
return assertGeneratedSceneDependency ( t , checkpoint . extractDependencies ( "combat" ) )
}
func assertGeneratedSceneDependency ( t * testing . T , dependencies [ ] pipeline . CheckpointFingerprint ) string {
t . Helper ( )
for _ , dependency := range dependencies {
if dependency . Name == "generated-reference:scene_descriptions:0" && strings . HasPrefix ( dependency . Value , "sha256:" ) {
return dependency . Value
}
}
t . Fatalf ( "combat extract checkpoint dependencies = %#v, want generated scene reference fingerprint" , dependencies )
return ""
}
type generatedReferenceCheckpointLoader struct {
pipeline . CheckpointLoader
extract map [ string ] [ ] [ ] pipeline . CheckpointFingerprint
}
func newGeneratedReferenceCheckpointLoader ( ) * generatedReferenceCheckpointLoader {
return & generatedReferenceCheckpointLoader {
CheckpointLoader : pipeline . NoopCheckpointLoader ( ) ,
extract : make ( map [ string ] [ ] [ ] pipeline . CheckpointFingerprint ) ,
}
}
func ( loader * generatedReferenceCheckpointLoader ) Enabled ( ) bool { return true }
func ( loader * generatedReferenceCheckpointLoader ) Extract ( laneID , _ string , dependencies [ ] pipeline . CheckpointFingerprint ) ( pipeline . ExtractCheckpoint , pipeline . CheckpointDecision ) {
loader . extract [ laneID ] = append ( loader . extract [ laneID ] , append ( [ ] pipeline . CheckpointFingerprint ( nil ) , dependencies ... ) )
return pipeline . ExtractCheckpoint { } , pipeline . NewCheckpointDecision ( pipeline . CheckpointDecisionExecuted , pipeline . CheckpointReasonMissing )
}
func ( loader * generatedReferenceCheckpointLoader ) extractDependencies ( laneID string ) [ ] pipeline . CheckpointFingerprint {
if len ( loader . extract [ laneID ] ) != 1 {
return nil
}
return loader . extract [ laneID ] [ 0 ]
}
2026-07-21 22:12:01 +00:00
type groundedDNDLLMClient struct {
2026-08-29 11:39:02 +00:00
mu sync . Mutex
requests [ ] contracts . StructuredCompletionRequest
sceneKind dnd . SceneKind
sceneTitle string
firstUnitID int
thirdUnitID int
sceneKinds [ ] dnd . SceneKind
combatSemanticsClassifications [ ] string
sceneCalls int
combatSemanticsCalls int
2026-07-21 22:12:01 +00:00
}
func ( client * groundedDNDLLMClient ) CompleteStructured ( ctx context . Context , request contracts . StructuredCompletionRequest , out any ) ( contracts . StructuredCompletionResponse , error ) {
if err := ctx . Err ( ) ; err != nil {
return contracts . StructuredCompletionResponse { } , err
}
client . mu . Lock ( )
client . requests = append ( client . requests , cloneStructuredCompletionRequest ( request ) )
client . mu . Unlock ( )
var payload any
2026-07-27 18:23:48 +00:00
firstUnitID := client . firstUnitID
if firstUnitID == 0 {
firstUnitID = 1
}
thirdUnitID := client . thirdUnitID
if thirdUnitID == 0 {
thirdUnitID = 3
}
2026-07-21 22:12:01 +00:00
switch request . PromptID {
2026-08-05 18:38:22 +00:00
case npcregistry . PromptID :
2026-07-21 22:12:01 +00:00
payload = map [ string ] any { "npcs" : [ ] any {
map [ string ] any {
2026-07-27 18:23:48 +00:00
"name" : "Mira Thorn" , "source_refs" : [ ] any { map [ string ] int { "start_unit_id" : firstUnitID , "end_unit_id" : firstUnitID } } ,
2026-07-21 22:12:01 +00:00
} ,
map [ string ] any {
2026-07-27 18:23:48 +00:00
"name" : "Hooded Guard" , "source_refs" : [ ] any { map [ string ] int { "start_unit_id" : thirdUnitID , "end_unit_id" : thirdUnitID } } ,
2026-07-21 22:12:01 +00:00
} ,
} }
2026-07-26 01:41:35 +00:00
case npcnormalize . PromptID :
payload = map [ string ] any { "duplicate_groups" : [ ] any { } }
2026-07-25 19:23:19 +00:00
case sceneextract . PromptID :
2026-07-25 19:29:15 +00:00
kind := client . sceneKind
2026-08-28 00:22:56 +00:00
if client . sceneCalls < len ( client . sceneKinds ) {
kind = client . sceneKinds [ client . sceneCalls ]
}
client . sceneCalls ++
2026-07-25 19:29:15 +00:00
if kind == "" {
kind = dnd . SceneKindCombat
}
title := client . sceneTitle
if title == "" {
title = "A combat encounter"
}
payload = map [ string ] any { "kind" : kind , "title" : title , "summary" : "The party faces an active encounter." }
2026-08-28 00:22:56 +00:00
case combatsemantics . PromptID :
2026-08-29 11:39:02 +00:00
classification := "combat"
if client . combatSemanticsCalls < len ( client . combatSemanticsClassifications ) {
classification = client . combatSemanticsClassifications [ client . combatSemanticsCalls ]
2026-08-28 00:22:56 +00:00
}
client . combatSemanticsCalls ++
2026-08-29 11:39:02 +00:00
payload = map [ string ] any { "classification" : classification , "explanation" : "The transcript shows an active encounter with hostile action." }
2026-07-21 22:12:01 +00:00
case spells . PromptID :
payload = map [ string ] any { "spell_casts" : [ ] any { map [ string ] any {
2026-07-22 19:01:58 +00:00
"caster" : "Mira Thorn" , "spell" : "Cure Wounds" ,
2026-07-27 18:23:48 +00:00
"source_refs" : [ ] any { map [ string ] int { "start_unit_id" : firstUnitID , "end_unit_id" : firstUnitID } } ,
2026-07-21 22:12:01 +00:00
} } }
case combatextract . PromptID :
payload = map [ string ] any { "combat_turns" : [ ] any { map [ string ] any {
2026-07-22 19:14:14 +00:00
"actor" : "Mira Thorn" ,
"turn_kind" : "turn" ,
2026-07-27 18:23:48 +00:00
"source_refs" : [ ] any { map [ string ] int { "start_unit_id" : firstUnitID , "end_unit_id" : firstUnitID } } ,
2026-07-21 22:12:01 +00:00
} } }
default :
return contracts . StructuredCompletionResponse { } , fmt . Errorf ( "unexpected grounded prompt %q" , request . PromptID )
}
content , err := json . Marshal ( payload )
if err != nil {
return contracts . StructuredCompletionResponse { } , err
}
if err := json . Unmarshal ( content , out ) ; err != nil {
return contracts . StructuredCompletionResponse { } , fmt . Errorf ( "populate grounded response: %w" , err )
}
return contracts . StructuredCompletionResponse { Content : content , Provider : "test" , Model : "grounded-fake" } , nil
}
func ( client * groundedDNDLLMClient ) requestsSnapshot ( ) [ ] contracts . StructuredCompletionRequest {
client . mu . Lock ( )
defer client . mu . Unlock ( )
return append ( [ ] contracts . StructuredCompletionRequest ( nil ) , client . requests ... )
}
func digestBytes ( content [ ] byte ) string {
sum := sha256 . Sum256 ( content )
return fmt . Sprintf ( "sha256:%x" , sum [ : ] )
}
var _ contracts . StructuredLLMClient = ( * groundedDNDLLMClient ) ( nil )