2026-08-03 20:56:36 +00:00
// Package invariants validates normalized D&D enemy-event artifacts.
package invariants
import (
"context"
"fmt"
"sort"
"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"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
)
const (
Key = "normalize/dnd/enemy-events/invariants"
ReasonCode = "invalid_enemy_event_normalization"
policy = "dnd.enemy_events.validator.normalized.v1"
)
type Options struct { }
type Validator struct {
npcResolver * npcregistry . Resolver
}
var _ contracts . TypedValidator [ dnd . EnemyEventList ] = ( * Validator ) ( nil )
var _ contracts . ManifestMetadataProvider = ( * Validator ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Validator ) ( nil )
func New ( _ Options , references ... contracts . ReferenceSet ) ( * Validator , error ) {
if len ( references ) > 1 {
return nil , fmt . Errorf ( "enemy event invariants validator accepts at most one reference set" )
}
var referenceSet contracts . ReferenceSet
if len ( references ) == 1 {
referenceSet = references [ 0 ]
}
resolver , err := npcregistry . NewResolver ( referenceSet )
if err != nil {
return nil , fmt . Errorf ( "prepare NPC registry: %w" , err )
}
return & Validator { npcResolver : resolver } , nil
}
func ( v * Validator ) Name ( ) string { return Key }
func ( v * Validator ) ExecutionClass ( ) contracts . ExecutionClass {
return contracts . ExecutionClassDeterministic
}
func ( v * Validator ) ManifestMetadata ( ) map [ string ] any {
if v == nil || v . npcResolver == nil {
return nil
}
metadata := map [ string ] any { "policy" : policy }
if seeded := v . npcResolver . Seeded ( ) ; seeded . Bound ( ) {
metadata [ "npc_registry_digest" ] = seeded . Digest ( )
}
return metadata
}
func ( v * Validator ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
if v == nil || v . npcResolver == nil {
return nil
}
return [ ] pipeline . CheckpointFingerprint {
{ Name : "policy" , Value : policy } ,
{ Name : "npc_registry" , Value : v . npcResolver . Seeded ( ) . ProjectionDigest ( ) } ,
}
}
func ( v * Validator ) Validate ( _ context . Context , req contracts . TypedValidationRequest [ dnd . EnemyEventList ] ) ( contracts . ValidationResult , error ) {
if enemyeventshape . Validate ( req . Value ) != nil {
return contracts . ValidationResult { Approved : true } , nil
}
index := source . NewDocumentIndex ( req . Source )
if ! allSourceRefsValid ( index , req . Value ) {
return contracts . ValidationResult { Approved : true } , nil
}
if v == nil || v . npcResolver == nil {
return contracts . ValidationResult { } , fmt . Errorf ( "enemy event invariants validator must not be nil" )
}
registry , err := v . npcResolver . Resolve ( req . References )
if err != nil {
return contracts . ValidationResult { } , fmt . Errorf ( "resolve NPC registry: %w" , err )
}
if ! registry . Bound ( ) {
2026-08-27 11:50:37 +00:00
return contracts . ValidationResult { Approved : false , ReasonCode : ReasonCode , Message : "invalid enemy event normalization: NPC registry reference is required" , CorrectionGuidance : "Return enemy events using contextual NPC names that match the supplied NPC registry." } , nil
2026-08-03 20:56:36 +00:00
}
issues := issuesFor ( shared . NewSourceRefOrderFromIndex ( index ) , req . Value , registry )
if len ( issues ) == 0 {
return contracts . ValidationResult { Approved : true } , nil
}
2026-08-27 11:50:37 +00:00
return contracts . ValidationResult { Approved : false , ReasonCode : ReasonCode , Message : diagnostics . Aggregate ( "invalid enemy event normalization" , issues ) , CorrectionGuidance : "Return enemy events in canonical transcript order, without duplicate events, using contextual NPC names that match the supplied registry and evidence." } , nil
2026-08-03 20:56:36 +00:00
}
func allSourceRefsValid ( index source . DocumentIndex , value dnd . EnemyEventList ) bool {
for _ , event := range value . Events {
for _ , ref := range event . SourceRefs {
if index . ValidateRef ( ref ) != nil {
return false
}
}
}
return true
}
func issuesFor ( order shared . SourceRefOrder , value dnd . EnemyEventList , registry * npcregistry . Registry ) [ ] string {
issues := make ( [ ] string , 0 )
2026-08-09 02:31:28 +00:00
canonicalEvidence := make ( [ ] bool , len ( value . Events ) )
2026-08-03 20:56:36 +00:00
for eventIndex , event := range value . Events {
prefix := fmt . Sprintf ( "events[%d]" , eventIndex )
2026-08-09 02:31:28 +00:00
canonicalEvidence [ eventIndex ] = true
2026-08-03 20:56:36 +00:00
if normalized := enemyeventmodel . NormalizeDisplay ( event . Name ) ; event . Name != normalized {
issues = append ( issues , prefix + ".name is not whitespace-normalized: " + diagnostics . Quote ( event . Name ) )
}
if canonical , ok := registry . Lookup ( event . Name ) ; ok && event . Name != canonical . Name {
issues = append ( issues , prefix + ".name is not the canonical NPC display name: " + diagnostics . Quote ( event . Name ) )
}
for refIndex := 1 ; refIndex < len ( event . SourceRefs ) ; refIndex ++ {
previous := event . SourceRefs [ refIndex - 1 ]
current := event . SourceRefs [ refIndex ]
if order . Less ( current , previous ) {
issues = append ( issues , fmt . Sprintf ( "%s.source_refs are not in canonical order at index %d" , prefix , refIndex ) )
2026-08-09 02:31:28 +00:00
canonicalEvidence [ eventIndex ] = false
2026-08-03 20:56:36 +00:00
} else if current == previous {
issues = append ( issues , fmt . Sprintf ( "%s.source_refs[%d] duplicates the previous reference" , prefix , refIndex ) )
2026-08-09 02:31:28 +00:00
canonicalEvidence [ eventIndex ] = false
2026-08-03 20:56:36 +00:00
}
}
}
if ! sort . SliceIsSorted ( value . Events , func ( left , right int ) bool {
return enemyeventmodel . Less ( order , value . Events [ left ] , value . Events [ right ] )
} ) {
issues = append ( issues , "events are not in canonical order" )
}
2026-08-09 02:31:28 +00:00
firstByIdentity := make ( map [ string ] [ ] int , len ( value . Events ) )
2026-08-03 20:56:36 +00:00
for eventIndex , event := range value . Events {
2026-08-09 02:31:28 +00:00
if ! canonicalEvidence [ eventIndex ] {
continue
}
identity := enemyeventmodel . CanonicalIdentity ( event )
for _ , previousIndex := range firstByIdentity [ identity ] {
if enemyeventmodel . CanonicalExactEqual ( value . Events [ previousIndex ] , event ) {
2026-08-03 20:56:36 +00:00
issues = append ( issues , fmt . Sprintf ( "events[%d] duplicates event %d" , eventIndex , previousIndex ) )
break
}
}
2026-08-09 02:31:28 +00:00
firstByIdentity [ identity ] = append ( firstByIdentity [ identity ] , eventIndex )
2026-08-03 20:56:36 +00:00
}
return issues
}
func Spec ( ) pipeline . ValidatorSpec {
return pipeline . ValidatorSpec { Key : Key , ExecutionClass : contracts . ExecutionClassDeterministic }
}
func Register ( registry * pipeline . ValidatorRegistry ) error {
return pipeline . RegisterTypedValidatorBuilder ( registry , dnd . EnemyEventListKind , Spec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . TypedValidator [ dnd . EnemyEventList ] , error ) {
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( options , request . References )
} )
}
func DecodeOptions ( options map [ string ] any ) ( Options , error ) {
if err := pipeline . RejectUnknownOptions ( options ) ; err != nil {
return Options { } , err
}
return Options { } , nil
}
func validateOptions ( options map [ string ] any ) error { _ , err := DecodeOptions ( options ) ; return err }