2026-07-20 19:38:57 +00:00
package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
2026-07-20 15:14:41 -05:00
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
2026-07-20 19:38:57 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
2026-07-20 21:16:08 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
2026-07-20 19:38:57 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
2026-07-20 21:06:30 +00:00
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
2026-07-20 19:38:57 +00:00
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)
func TestSpellCatalogBytesAffectCheckpointIdentityButNotSemanticDigest ( t * testing . T ) {
components := productionTestComponents ( t )
2026-07-25 15:48:48 +00:00
configPath := writeProductionSpellCatalogContractConfig ( t )
2026-07-20 19:38:57 +00:00
effective , err := loadMaintainedExample ( t , configPath ) . Resolve ( resolveInputForMaintainedExample ( components , "dnd-session" ) )
if err != nil {
t . Fatalf ( "resolve production configuration: %v" , err )
}
overlayPath := filepath . Join ( t . TempDir ( ) , "catalog.json" )
resolved := effective . ResolvedPipeline
2026-07-21 21:05:28 +00:00
bindings := resolved . Steps [ 0 ] . ArtifactLanes [ 0 ] . ExtractReferences . Bindings
2026-07-20 19:38:57 +00:00
catalogBindingIndex := - 1
for index , binding := range bindings {
if binding . SlotName == spellcatalog . SpellCatalogReferenceSlot {
catalogBindingIndex = index
break
}
}
if catalogBindingIndex < 0 {
t . Fatalf ( "spell catalog bindings = %#v, want catalog binding" , bindings )
}
2026-07-21 21:05:28 +00:00
resolved . Steps [ 0 ] . ArtifactLanes [ 0 ] . ExtractReferences . Bindings [ catalogBindingIndex ] . Source = overlayPath
normalizeBindings := resolved . Steps [ 0 ] . ArtifactLanes [ 0 ] . NormalizeReferences . Bindings
2026-07-20 21:16:08 +00:00
normalizeCatalogBindingIndex := - 1
for index , binding := range normalizeBindings {
if binding . SlotName == spellcatalog . SpellCatalogReferenceSlot {
normalizeCatalogBindingIndex = index
break
}
}
if normalizeCatalogBindingIndex < 0 {
t . Fatalf ( "normalize spell catalog bindings = %#v, want catalog binding" , normalizeBindings )
}
2026-07-21 21:05:28 +00:00
resolved . Steps [ 0 ] . ArtifactLanes [ 0 ] . NormalizeReferences . Bindings [ normalizeCatalogBindingIndex ] . Source = overlayPath
2026-07-20 19:38:57 +00:00
if err := os . WriteFile ( overlayPath , [ ] byte ( reorderedOverlayA ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
materializedA , _ , err := pipeline . MaterializeReferences ( resolved , catalogFromRegistries ( components . registries ) , pipeline . ReferenceMaterializationOptions { ConfigPath : configPath , WorkingDir : filepath . Dir ( configPath ) } )
if err != nil {
t . Fatalf ( "materialize first catalog: %v" , err )
}
identityA := catalogCheckpointIdentity ( t , materializedA )
metadataA := catalogExtractorMetadata ( t , materializedA )
2026-07-20 21:16:08 +00:00
normalizerMetadataA := catalogNormalizerMetadata ( t , materializedA )
2026-07-20 19:38:57 +00:00
referenceA := catalogReference ( t , materializedA )
if err := os . WriteFile ( overlayPath , [ ] byte ( reorderedOverlayB ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
materializedB , _ , err := pipeline . MaterializeReferences ( resolved , catalogFromRegistries ( components . registries ) , pipeline . ReferenceMaterializationOptions { ConfigPath : configPath , WorkingDir : filepath . Dir ( configPath ) } )
if err != nil {
t . Fatalf ( "materialize reordered catalog: %v" , err )
}
identityB := catalogCheckpointIdentity ( t , materializedB )
metadataB := catalogExtractorMetadata ( t , materializedB )
2026-07-20 21:16:08 +00:00
normalizerMetadataB := catalogNormalizerMetadata ( t , materializedB )
2026-07-20 19:38:57 +00:00
referenceB := catalogReference ( t , materializedB )
if identityA . Digest == identityB . Digest {
t . Fatalf ( "checkpoint identity digest = %q for both raw catalog files, want invalidation" , identityA . Digest )
}
if referenceA . Digest == referenceB . Digest || referenceA . OriginURI != referenceB . OriginURI {
t . Fatalf ( "catalog reference provenance changed from %#v to %#v, want same origin and different raw digest" , referenceA , referenceB )
}
digestA , ok := metadataA [ "catalog_digest" ] . ( string )
if ! ok {
t . Fatalf ( "first extractor catalog metadata = %#v, want digest" , metadataA )
}
digestB , ok := metadataB [ "catalog_digest" ] . ( string )
if ! ok || digestA != digestB {
t . Fatalf ( "extractor catalog digests = %q and %q, want same semantic digest" , digestA , digestB )
}
if got , want := metadataA [ "catalog_overlay_ids" ] , [ ] string { "campaign.a" , "campaign.b" } ; ! reflect . DeepEqual ( got , want ) || ! reflect . DeepEqual ( metadataB [ "catalog_overlay_ids" ] , want ) {
t . Fatalf ( "extractor overlay IDs = %#v and %#v, want %#v" , got , metadataB [ "catalog_overlay_ids" ] , want )
}
2026-07-20 21:16:08 +00:00
normalizerDigestA , ok := normalizerMetadataA [ "catalog_digest" ] . ( string )
normalizerDigestB , okB := normalizerMetadataB [ "catalog_digest" ] . ( string )
if ! ok || ! okB || normalizerDigestA != digestA || normalizerDigestB != digestB {
t . Fatalf ( "normalizer catalog digests = %#v and %#v, want extractor semantic digests %q and %q" , normalizerMetadataA [ "catalog_digest" ] , normalizerMetadataB [ "catalog_digest" ] , digestA , digestB )
}
if got , want := normalizerMetadataA [ "catalog_overlay_ids" ] , [ ] string { "campaign.a" , "campaign.b" } ; ! reflect . DeepEqual ( got , want ) || ! reflect . DeepEqual ( normalizerMetadataB [ "catalog_overlay_ids" ] , want ) {
t . Fatalf ( "normalizer overlay IDs = %#v and %#v, want %#v" , got , normalizerMetadataB [ "catalog_overlay_ids" ] , want )
}
2026-07-20 19:38:57 +00:00
}
func TestConfiguredSpellCatalogBindingChangesResolvedPipelineIdentity ( t * testing . T ) {
2026-07-25 15:48:48 +00:00
base := productionSpellCatalogContractConfig ( t )
changed := strings . Replace ( base , repositoryPath ( "examples" , "dnd-spell-catalog.json" ) , filepath . Join ( t . TempDir ( ) , "alternate-spell-catalog.json" ) , 1 )
2026-07-20 19:38:57 +00:00
if changed == base {
t . Fatal ( "production configuration did not contain the maintained catalog binding" )
}
root := t . TempDir ( )
firstPath := filepath . Join ( root , "first.yml" )
secondPath := filepath . Join ( root , "second.yml" )
if err := os . WriteFile ( firstPath , [ ] byte ( base ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
if err := os . WriteFile ( secondPath , [ ] byte ( changed ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
components := productionTestComponents ( t )
first , err := loadMaintainedExample ( t , firstPath ) . Resolve ( resolveInputForMaintainedExample ( components , "dnd-session" ) )
if err != nil {
t . Fatalf ( "resolve first configuration: %v" , err )
}
second , err := loadMaintainedExample ( t , secondPath ) . Resolve ( resolveInputForMaintainedExample ( components , "dnd-session" ) )
if err != nil {
t . Fatalf ( "resolve changed configuration: %v" , err )
}
if first . ResolvedPipeline . Digest == second . ResolvedPipeline . Digest {
t . Fatalf ( "resolved pipeline digest = %q for different catalog bindings, want change" , first . ResolvedPipeline . Digest )
}
}
2026-07-20 15:14:41 -05:00
func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenceChange ( t * testing . T ) {
components := productionTestComponents ( t )
2026-07-25 15:48:48 +00:00
configPath := writeProductionSpellCatalogContractConfig ( t )
2026-07-20 15:14:41 -05:00
effective , err := loadMaintainedExample ( t , configPath ) . Resolve ( resolveInputForMaintainedExample ( components , "dnd-session" ) )
if err != nil {
t . Fatal ( err )
}
materialized , _ , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalogFromRegistries ( components . registries ) , pipeline . ReferenceMaterializationOptions { ConfigPath : configPath , WorkingDir : filepath . Dir ( configPath ) } )
if err != nil {
t . Fatal ( err )
}
prepared , err := pipeline . Prepare ( materialized , components . registries , pipeline . ModuleDependencies { LLM : & productionFakeLLMClient { } } )
if err != nil {
t . Fatal ( err )
}
fingerprints := prepared . CheckpointFingerprints ( )
2026-07-20 21:06:30 +00:00
wantNames := map [ string ] struct { } {
"extract:spells:" + spells . Key + ":effective_catalog" : { } ,
2026-07-22 14:30:20 +00:00
"extract:spells:" + spells . Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog" : { } ,
2026-07-20 21:06:30 +00:00
"normalize:spells:" + spellnormalize . Key + ":effective_catalog" : { } ,
2026-07-22 14:30:20 +00:00
"normalize:spells:" + spellnormalize . Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog" : { } ,
2026-07-20 21:06:30 +00:00
}
seen := make ( map [ string ] string , len ( fingerprints ) )
for _ , fingerprint := range fingerprints {
if _ , ok := wantNames [ fingerprint . Name ] ; ok {
seen [ fingerprint . Name ] = fingerprint . Value
}
}
if len ( seen ) != len ( wantNames ) {
t . Fatalf ( "prepared fingerprints = %#v, want scoped extractor and normalize catalog identities" , fingerprints )
}
var catalogDigest string
for name , value := range seen {
if catalogDigest == "" {
catalogDigest = value
} else if value != catalogDigest {
t . Fatalf ( "prepared fingerprint %q = %q, want shared semantic catalog digest %q" , name , value , catalogDigest )
}
2026-07-20 15:14:41 -05:00
}
identityFor := func ( values [ ] pipeline . CheckpointFingerprint ) checkpoint . Identity {
identity , identityErr := checkpoint . NewIdentity ( checkpoint . IdentityInput {
Pipeline : materialized ,
InputKey : materialized . Input . Module ,
RawInputDigest : "sha256:unchanged-input" ,
References : pipeline . ReferenceProvenance ( materialized ) ,
ProvenanceFingerprints : checkpointIdentityFingerprints ( values ) ,
} )
if identityErr != nil {
t . Fatal ( identityErr )
}
return identity
}
first := identityFor ( fingerprints )
2026-07-20 19:44:35 -05:00
changed := replaceCheckpointFingerprintValue ( t , fingerprints , normalizeSpellCatalogFingerprintName ( ) , "sha256:changed-effective-catalog" )
assertOnlyCheckpointFingerprintChanged ( t , fingerprints , changed , normalizeSpellCatalogFingerprintName ( ) )
2026-07-20 15:14:41 -05:00
second := identityFor ( changed )
if first . Digest == second . Digest || reflect . DeepEqual ( first . ReferenceDigests , nil ) || ! reflect . DeepEqual ( first . ReferenceDigests , second . ReferenceDigests ) {
t . Fatalf ( "identities = %#v / %#v, want semantic invalidation with unchanged reference provenance" , first , second )
}
}
func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint ( t * testing . T ) {
components := productionTestComponents ( t )
2026-07-25 15:48:48 +00:00
configPath := writeProductionSpellCatalogContractConfig ( t )
2026-07-20 15:14:41 -05:00
effective , err := loadMaintainedExample ( t , configPath ) . Resolve ( resolveInputForMaintainedExample ( components , "dnd-session" ) )
if err != nil {
t . Fatal ( err )
}
materialized , _ , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalogFromRegistries ( components . registries ) , pipeline . ReferenceMaterializationOptions { ConfigPath : configPath , WorkingDir : filepath . Dir ( configPath ) } )
if err != nil {
t . Fatal ( err )
}
prepared , err := pipeline . Prepare ( materialized , components . registries , pipeline . ModuleDependencies { LLM : & productionFakeLLMClient { } } )
if err != nil {
t . Fatal ( err )
}
fingerprints := prepared . CheckpointFingerprints ( )
2026-07-28 13:36:01 -05:00
llmFingerprints := [ ] checkpoint . Fingerprint { { Name : "promptkit_profile_source" , Value : "sha256:profile-source-one" } }
2026-07-20 15:14:41 -05:00
settings := config . CheckpointCacheConfig { Enabled : true , Directory : t . TempDir ( ) }
2026-07-30 02:11:35 +00:00
recorder , _ , err := checkpointHandlersForRun ( settings , Options { } , materialized , fingerprints , llmFingerprints , [ ] byte ( "same input" ) , nil , nil , "" , "" , LLMRuntimeOverrides { } , false )
2026-07-20 15:14:41 -05:00
if err != nil {
t . Fatal ( err )
}
doc := source . SourceDocument { ID : "source" , Kind : "transcript" , Format : "application/json" }
doc . Units = [ ] source . SourceUnit { { ID : 1 , Kind : "turn" , Text : "Aria casts Cure Wounds." , Ref : source . SourceRef { SourceID : doc . ID , StartUnitID : 1 , EndUnitID : 1 } } }
doc . Digest , err = source . DigestDocument ( & doc )
if err != nil {
t . Fatal ( err )
}
if err := recorder . SourceSucceeded ( materialized . Input . Module , & doc ) ; err != nil {
t . Fatal ( err )
}
2026-07-20 21:16:08 +00:00
normalizeDependencies := [ ] pipeline . CheckpointFingerprint { { Name : "artifact[0]" , Value : "sha256:merged-artifact" } }
normalizeSchema := contracts . ArtifactSchema { ID : "notarius.dnd.spells" , Name : "notarius_dnd_spells" , Version : "v1" }
normalizeArtifact := pipeline . CheckpointArtifact {
LaneID : "spells" , ModuleKey : spellnormalize . Key , SourceID : doc . ID ,
SchemaDigest : contracts . DigestArtifactSchema ( normalizeSchema ) ,
Artifact : contracts . SerializedArtifact {
Kind : dnd . SpellListKind , Schema : normalizeSchema , MediaType : "application/json" , Content : [ ] byte ( ` { "spell_casts":[]} ` ) ,
} ,
}
2026-08-27 16:40:17 +00:00
if err := recorder . NormalizeSucceeded ( "spells" , spellnormalize . Key , normalizeDependencies , normalizeArtifact ) ; err != nil {
2026-07-20 21:16:08 +00:00
t . Fatal ( err )
}
2026-07-20 15:14:41 -05:00
2026-07-30 02:11:35 +00:00
_ , sameLoader , err := checkpointHandlersForRun ( settings , Options { } , materialized , fingerprints , llmFingerprints , [ ] byte ( "same input" ) , nil , nil , "" , "" , LLMRuntimeOverrides { } , true )
2026-07-20 15:14:41 -05:00
if err != nil {
t . Fatal ( err )
}
if _ , decision := sameLoader . Source ( materialized . Input . Module ) ; ! decision . Reused {
t . Fatalf ( "same fingerprint decision = %#v, want reuse" , decision )
}
2026-07-20 21:16:08 +00:00
if restored , decision := sameLoader . Normalize ( "spells" , spellnormalize . Key , normalizeDependencies ) ; ! decision . Reused || string ( restored . Output . Artifact . Content ) != ` { "spell_casts":[]} ` {
t . Fatalf ( "same normalize checkpoint = %#v, decision=%#v, want reuse" , restored , decision )
}
2026-07-20 19:44:35 -05:00
changed := replaceCheckpointFingerprintValue ( t , fingerprints , normalizeSpellCatalogFingerprintName ( ) , "sha256:changed-effective-catalog" )
assertOnlyCheckpointFingerprintChanged ( t , fingerprints , changed , normalizeSpellCatalogFingerprintName ( ) )
2026-07-30 02:11:35 +00:00
_ , changedLoader , err := checkpointHandlersForRun ( settings , Options { } , materialized , changed , llmFingerprints , [ ] byte ( "same input" ) , nil , nil , "" , "" , LLMRuntimeOverrides { } , true )
2026-07-20 15:14:41 -05:00
if err != nil {
t . Fatal ( err )
}
if _ , decision := changedLoader . Source ( materialized . Input . Module ) ; decision . Reused {
t . Fatalf ( "changed fingerprint decision = %#v, want cold miss" , decision )
}
2026-07-20 21:16:08 +00:00
if _ , decision := changedLoader . Normalize ( "spells" , spellnormalize . Key , normalizeDependencies ) ; decision . Reused {
t . Fatalf ( "changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss" , decision )
}
2026-08-28 13:45:21 +00:00
changedMapping := replaceCheckpointFingerprintValue ( t , fingerprints , extractSpellMappingFingerprintName ( ) , "dnd.spells.extract_mapping.changed" )
2026-07-24 14:23:35 +00:00
assertOnlyCheckpointFingerprintChanged ( t , fingerprints , changedMapping , extractSpellMappingFingerprintName ( ) )
2026-07-30 02:11:35 +00:00
_ , mappingLoader , err := checkpointHandlersForRun ( settings , Options { } , materialized , changedMapping , llmFingerprints , [ ] byte ( "same input" ) , nil , nil , "" , "" , LLMRuntimeOverrides { } , true )
2026-07-24 14:23:35 +00:00
if err != nil {
t . Fatal ( err )
}
if _ , decision := mappingLoader . Source ( materialized . Input . Module ) ; decision . Reused {
t . Fatalf ( "changed mapping policy decision = %#v, want cold miss" , decision )
}
2026-07-28 13:36:01 -05:00
changedLLMFingerprints := [ ] checkpoint . Fingerprint { { Name : "promptkit_profile_source" , Value : "sha256:profile-source-two" } }
2026-07-30 02:11:35 +00:00
_ , profileLoader , err := checkpointHandlersForRun ( settings , Options { } , materialized , fingerprints , changedLLMFingerprints , [ ] byte ( "same input" ) , nil , nil , "" , "" , LLMRuntimeOverrides { } , true )
2026-07-28 13:36:01 -05:00
if err != nil {
t . Fatal ( err )
}
if _ , decision := profileLoader . Source ( materialized . Input . Module ) ; decision . Reused {
t . Fatalf ( "changed PromptKit profile source decision = %#v, want cold miss" , decision )
}
if _ , decision := profileLoader . Normalize ( "spells" , spellnormalize . Key , normalizeDependencies ) ; decision . Reused {
t . Fatalf ( "changed PromptKit profile normalize decision = %#v, want cold miss" , decision )
}
2026-07-20 15:14:41 -05:00
}
2026-07-20 19:44:35 -05:00
func normalizeSpellCatalogFingerprintName ( ) string {
return "normalize:spells:" + spellnormalize . Key + ":effective_catalog"
}
2026-07-24 14:23:35 +00:00
func extractSpellMappingFingerprintName ( ) string {
return "extract:spells:" + spells . Key + ":mapping_policy"
}
2026-07-20 19:44:35 -05:00
func replaceCheckpointFingerprintValue ( t * testing . T , fingerprints [ ] pipeline . CheckpointFingerprint , name , value string ) [ ] pipeline . CheckpointFingerprint {
t . Helper ( )
changed := append ( [ ] pipeline . CheckpointFingerprint ( nil ) , fingerprints ... )
matches := 0
for index := range changed {
if changed [ index ] . Name == name {
changed [ index ] . Value = value
matches ++
}
}
if matches != 1 {
t . Fatalf ( "checkpoint fingerprints = %#v, want exactly one fingerprint named %q" , fingerprints , name )
}
return changed
}
func assertOnlyCheckpointFingerprintChanged ( t * testing . T , before , after [ ] pipeline . CheckpointFingerprint , changedName string ) {
t . Helper ( )
if len ( before ) != len ( after ) {
t . Fatalf ( "fingerprint lengths = %d and %d, want equal" , len ( before ) , len ( after ) )
}
changes := 0
for index := range before {
if before [ index ] . Name != after [ index ] . Name {
t . Fatalf ( "fingerprint[%d] name changed from %q to %q" , index , before [ index ] . Name , after [ index ] . Name )
}
if before [ index ] . Value == after [ index ] . Value {
continue
}
changes ++
if before [ index ] . Name != changedName {
t . Fatalf ( "fingerprint %q changed unexpectedly" , before [ index ] . Name )
}
}
if changes != 1 {
t . Fatalf ( "fingerprints changed %d values, want exactly %q" , changes , changedName )
}
}
2026-07-20 19:38:57 +00:00
func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance ( t * testing . T ) {
outputRoot := filepath . Join ( t . TempDir ( ) , "output" )
fake := & productionFakeLLMClient { spellResponse : productionSpellResponse ( "Aegis of Emberfall" ) }
options := productionRunOptions ( t , fake )
var stdout , stderr strings . Builder
code := RunWithOptions ( [ ] string {
"run" , "dnd-session" ,
2026-07-25 15:48:48 +00:00
"--config" , writeProductionSpellCatalogContractConfig ( t ) ,
2026-07-20 19:38:57 +00:00
"--input" , repositoryPath ( "examples" , "seriatim-minimal-transcript.json" ) ,
"--only" , "spells" , "--chunk_cache" , "bypass" , "--output-dir" , outputRoot ,
} , & stdout , & stderr , options )
if code != 0 {
t . Fatalf ( "code=%d stdout=%q stderr=%q" , code , stdout . String ( ) , stderr . String ( ) )
}
runRoot := filepath . Join ( outputRoot , productionRunID )
manifest := readProductionJSON [ artifacts . RunManifest ] ( t , filepath . Join ( runRoot , "manifest.json" ) )
if manifest . ValidationStatus != "approved" || len ( manifest . References ) == 0 || len ( manifest . ArtifactLanes ) != 1 {
t . Fatalf ( "manifest = %#v, want approved overlay run with one lane and references" , manifest )
}
lane := manifest . ArtifactLanes [ 0 ]
extractorMetadata , ok := lane . Metadata [ "extractor" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "lane metadata = %#v, want extractor metadata" , lane . Metadata )
}
if extractorMetadata [ "catalog_base_id" ] != spellcatalog . SRD5E2014ID || ! strings . HasPrefix ( stringValue ( extractorMetadata [ "catalog_digest" ] ) , "sha256:" ) {
t . Fatalf ( "extractor catalog metadata = %#v, want base ID and semantic digest" , extractorMetadata )
}
if got := stringValues ( extractorMetadata [ "catalog_overlay_ids" ] ) ; ! reflect . DeepEqual ( got , [ ] string { "notarius.example-campaign" } ) {
t . Fatalf ( "catalog overlay IDs = %#v, want maintained overlay" , got )
}
2026-07-20 21:16:08 +00:00
normalizerMetadata , ok := lane . Metadata [ "normalizer" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "lane metadata = %#v, want normalizer metadata" , lane . Metadata )
}
if normalizerMetadata [ "catalog_base_id" ] != spellcatalog . SRD5E2014ID || ! strings . HasPrefix ( stringValue ( normalizerMetadata [ "catalog_digest" ] ) , "sha256:" ) || ! reflect . DeepEqual ( stringValues ( normalizerMetadata [ "catalog_overlay_ids" ] ) , [ ] string { "notarius.example-campaign" } ) {
t . Fatalf ( "normalizer catalog metadata = %#v, want base ID, semantic digest, and overlay IDs" , normalizerMetadata )
}
if normalizerMetadata [ "catalog_digest" ] != extractorMetadata [ "catalog_digest" ] || ! reflect . DeepEqual ( stringValues ( normalizerMetadata [ "catalog_overlay_ids" ] ) , stringValues ( extractorMetadata [ "catalog_overlay_ids" ] ) ) {
t . Fatalf ( "extractor metadata = %#v, normalizer metadata = %#v, want shared catalog identity" , extractorMetadata , normalizerMetadata )
}
2026-07-20 19:38:57 +00:00
2026-07-20 21:16:08 +00:00
var catalogProvenances [ ] artifacts . ReferenceProvenance
2026-07-20 19:38:57 +00:00
for index := range manifest . References {
reference := & manifest . References [ index ]
if reference . SlotName == spellcatalog . SpellCatalogReferenceSlot {
2026-07-20 21:16:08 +00:00
catalogProvenances = append ( catalogProvenances , * reference )
2026-07-20 19:38:57 +00:00
}
}
2026-07-20 21:16:08 +00:00
if len ( catalogProvenances ) != 2 {
t . Fatalf ( "manifest references = %#v, want independently materialized extract and normalize catalog provenance" , manifest . References )
2026-07-20 19:38:57 +00:00
}
2026-07-25 15:48:48 +00:00
overlayBytes := readRepositoryFile ( t , "examples" , "dnd-spell-catalog.json" )
2026-07-20 21:16:08 +00:00
for _ , catalogProvenance := range catalogProvenances {
if catalogProvenance . Stage != "extract" && catalogProvenance . Stage != "normalize" {
t . Fatalf ( "catalog provenance = %#v, want extract or normalize scope" , catalogProvenance )
}
2026-07-25 15:48:48 +00:00
if catalogProvenance . LaneID != "spells" || catalogProvenance . OriginType != "file" || catalogProvenance . MediaType != "application/json" || catalogProvenance . SizeBytes != int64 ( len ( overlayBytes ) ) || catalogProvenance . Digest != digestBytes ( overlayBytes ) || ! strings . Contains ( catalogProvenance . OriginURI , "dnd-spell-catalog.json" ) {
2026-07-20 21:16:08 +00:00
t . Fatalf ( "catalog provenance = %#v, want raw overlay provenance in both scopes" , catalogProvenance )
}
2026-07-20 19:38:57 +00:00
}
manifestBytes , err := json . Marshal ( manifest )
if err != nil {
t . Fatal ( err )
}
for _ , leaked := range [ ] string { "Aegis of Emberfall" , "Emberfall Aegis" , "Notarius example campaign spell names" } {
if strings . Contains ( string ( manifestBytes ) , leaked ) {
t . Fatalf ( "manifest leaked overlay content %q" , leaked )
}
}
requests := fake . requestsFor ( spells . PromptID )
if len ( requests ) != 1 {
t . Fatalf ( "spell requests = %d, want one" , len ( requests ) )
}
catalogInput , ok := requests [ 0 ] . Inputs [ spellcatalog . SpellCatalogReferenceSlot ]
2026-08-09 02:17:15 +00:00
if ! ok || ! strings . Contains ( string ( catalogInput . Content ) , ` "canonical_name":"Aegis of Emberfall" ` ) || ! strings . Contains ( string ( catalogInput . Content ) , ` "aliases":["Emberfall Aegis"] ` ) {
t . Fatalf ( "spell catalog prompt input = %#v, want canonical overlay name and recognition alias" , catalogInput )
2026-07-20 19:38:57 +00:00
}
artifact := readProductionJSON [ dnd . SpellList ] ( t , filepath . Join ( runRoot , "lanes" , "spells.json" ) )
if len ( artifact . SpellCasts ) != 1 || artifact . SpellCasts [ 0 ] . Spell != "Aegis of Emberfall" {
t . Fatalf ( "artifact = %#v, want accepted overlay-only canonical spell" , artifact )
}
rejected := readProductionJSON [ struct {
Rejected [ ] json . RawMessage ` json:"rejected" `
} ] ( t , filepath . Join ( runRoot , "rejected.json" ) )
if len ( rejected . Rejected ) != 0 {
t . Fatalf ( "rejected = %#v, want no rejected output" , rejected . Rejected )
}
}
func catalogCheckpointIdentity ( t * testing . T , resolved pipeline . ResolvedPipeline ) checkpoint . Identity {
t . Helper ( )
identity , err := checkpoint . NewIdentity ( checkpoint . IdentityInput {
Pipeline : resolved ,
InputKey : resolved . Input . Module ,
RawInputDigest : "sha256:catalog-test-input" ,
References : pipeline . ReferenceProvenance ( resolved ) ,
} )
if err != nil {
t . Fatalf ( "create checkpoint identity: %v" , err )
}
return identity
}
func catalogExtractorMetadata ( t * testing . T , resolved pipeline . ResolvedPipeline ) map [ string ] any {
t . Helper ( )
2026-07-21 21:05:28 +00:00
lane := resolved . Steps [ 0 ] . ArtifactLanes [ 0 ]
2026-07-20 19:38:57 +00:00
extractor , err := spells . New ( & productionFakeLLMClient { } , spells . Options { } , lane . ExtractReferences . ReferenceSet )
if err != nil {
t . Fatalf ( "construct extractor: %v" , err )
}
return extractor . ManifestMetadata ( )
}
2026-07-20 21:16:08 +00:00
func catalogNormalizerMetadata ( t * testing . T , resolved pipeline . ResolvedPipeline ) map [ string ] any {
t . Helper ( )
2026-07-21 21:05:28 +00:00
lane := resolved . Steps [ 0 ] . ArtifactLanes [ 0 ]
2026-07-20 21:16:08 +00:00
normalizer , err := spellnormalize . New ( spellnormalize . Options { } , lane . NormalizeReferences . ReferenceSet )
if err != nil {
t . Fatalf ( "construct normalizer: %v" , err )
}
return normalizer . ManifestMetadata ( )
}
2026-07-20 19:38:57 +00:00
func catalogReference ( t * testing . T , resolved pipeline . ResolvedPipeline ) artifacts . ReferenceProvenance {
t . Helper ( )
for _ , reference := range pipeline . ReferenceProvenance ( resolved ) {
if reference . SlotName == spellcatalog . SpellCatalogReferenceSlot && reference . Stage == "extract" && reference . LaneID == "spells" {
return reference
}
}
t . Fatalf ( "resolved references = %#v, want spell catalog provenance" , pipeline . ReferenceProvenance ( resolved ) )
return artifacts . ReferenceProvenance { }
}
func stringValue ( value any ) string {
result , _ := value . ( string )
return result
}
func stringValues ( value any ) [ ] string {
raw , err := json . Marshal ( value )
if err != nil {
return nil
}
var values [ ] string
if err := json . Unmarshal ( raw , & values ) ; err != nil {
return nil
}
return values
}
func digestBytes ( value [ ] byte ) string {
sum := sha256 . Sum256 ( value )
return "sha256:" + hex . EncodeToString ( sum [ : ] )
}
const reorderedOverlayA = ` {
"schema_version" : "notarius.dnd.spell-catalog-overlay.v1" ,
"catalogs" : [
{ "id" : "campaign.a" , "ruleset" : "dnd-5e-2014" , "source" : { "title" : "Campaign A" } , "spells" : [ { "name" : "Aegis of Emberfall" , "aliases" : [ "Emberfall Aegis" ] } ] } ,
{ "id" : "campaign.b" , "ruleset" : "dnd-5e-2014" , "source" : { "title" : "Campaign B" } , "spells" : [ { "name" : "Cinder Veil" , "aliases" : [ "Veil of Cinder" , "Cinder Shroud" ] } ] }
]
} `
const reorderedOverlayB = ` { "catalogs":[ { "spells":[ { "aliases":["Cinder Shroud","Veil of Cinder"],"name":"Cinder Veil"}],"source": { "title":"Campaign B"},"ruleset":"dnd-5e-2014","id":"campaign.b"}, { "spells":[ { "aliases":["Emberfall Aegis"],"name":"Aegis of Emberfall"}],"source": { "title":"Campaign A"},"ruleset":"dnd-5e-2014","id":"campaign.a"}],"schema_version":"notarius.dnd.spell-catalog-overlay.v1"} `