2026-07-21 05:22:12 +00:00
// Package invariants validates normalized D&D combat-turn artifacts.
package invariants
import (
"context"
"fmt"
"strconv"
"strings"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
2026-07-24 14:20:10 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
2026-07-21 05:22:12 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
)
const (
Key = "normalize/dnd/combat-turns/invariants"
ReasonCode = "invalid_combat_turn_normalization"
2026-07-24 14:20:10 +00:00
policy = "dnd.combat_turns.validator.normalized.v2"
2026-07-21 05:22:12 +00:00
)
type Options struct { }
type Validator struct { }
var _ contracts . TypedValidator [ dnd . CombatTurnList ] = ( * Validator ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Validator ) ( nil )
func New ( Options ) * Validator { return & Validator { } }
func ( v * Validator ) Name ( ) string { return Key }
func ( v * Validator ) ExecutionClass ( ) contracts . ExecutionClass {
return contracts . ExecutionClassDeterministic
}
func ( v * Validator ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
return [ ] pipeline . CheckpointFingerprint { { Name : "policy" , Value : policy } }
}
func ( v * Validator ) Validate ( _ context . Context , req contracts . TypedValidationRequest [ dnd . CombatTurnList ] ) ( contracts . ValidationResult , error ) {
if err := Validate ( req . Source , req . Value ) ; err != nil {
2026-08-27 11:50:37 +00:00
return contracts . ValidationResult { Approved : false , ReasonCode : ReasonCode , Message : err . Error ( ) , CorrectionGuidance : "Return combat turns in transcript order, without duplicate turns, using consistent contextual combatant names and evidence for each turn." } , nil
2026-07-21 05:22:12 +00:00
}
return contracts . ValidationResult { Approved : true } , nil
}
// Validate checks only invariants owned by normalized combat-turn output. A
// shape or source-reference failure is deliberately deferred to its owner.
func Validate ( doc * source . SourceDocument , value dnd . CombatTurnList ) error {
2026-07-25 12:57:31 +00:00
if combatshape . Validate ( value ) != nil {
2026-07-21 05:22:12 +00:00
return nil
}
2026-07-25 12:57:31 +00:00
index := source . NewDocumentIndex ( doc )
if ! sourceRefsValid ( index , value ) {
return nil
}
2026-07-25 13:30:23 +00:00
issues := issuesFor ( shared . NewSourceRefOrderFromIndex ( index ) , value )
2026-07-21 05:22:12 +00:00
if len ( issues ) == 0 {
return nil
}
return fmt . Errorf ( "%s" , diagnostics . Aggregate ( "invalid combat turn normalization" , issues ) )
}
2026-07-24 14:20:10 +00:00
func issuesFor ( order shared . SourceRefOrder , value dnd . CombatTurnList ) [ ] string {
2026-07-21 05:22:12 +00:00
issues := make ( [ ] string , 0 )
seenIdentity := make ( map [ string ] int )
previousPosition := - 1
for turnIndex , turn := range value . CombatTurns {
prefix := fmt . Sprintf ( "combat_turns[%d]" , turnIndex )
if turn . Actor != identity . NormalizeDisplay ( turn . Actor ) {
issues = append ( issues , prefix + ".actor is not display-normalized: " + diagnostics . Quote ( turn . Actor ) )
}
for refIndex := 1 ; refIndex < len ( turn . SourceRefs ) ; refIndex ++ {
previous := turn . SourceRefs [ refIndex - 1 ]
current := turn . SourceRefs [ refIndex ]
2026-07-24 14:20:10 +00:00
if order . Less ( current , previous ) {
2026-07-21 05:22:12 +00:00
issues = append ( issues , fmt . Sprintf ( "%s.source_refs are not in canonical order at index %d" , prefix , refIndex ) )
} else if current == previous {
issues = append ( issues , fmt . Sprintf ( "%s.source_refs[%d] duplicates the previous reference" , prefix , refIndex ) )
}
}
2026-07-24 14:20:10 +00:00
position , ok := order . EarliestValid ( turn . SourceRefs )
2026-07-21 05:22:12 +00:00
if ! ok {
continue
}
if previousPosition > position {
issues = append ( issues , fmt . Sprintf ( "%s is out of chronological order: evidence position %d follows %d" , prefix , position , previousPosition ) )
}
previousPosition = position
if key , ok := duplicateKey ( turn ) ; ok {
if previous , exists := seenIdentity [ key ] ; exists {
issues = append ( issues , fmt . Sprintf ( "%s duplicates combat turn %d under normalized identity" , prefix , previous ) )
} else {
seenIdentity [ key ] = turnIndex
}
}
}
return issues
}
2026-07-25 12:57:31 +00:00
func sourceRefsValid ( index source . DocumentIndex , value dnd . CombatTurnList ) bool {
2026-07-21 05:22:12 +00:00
for _ , turn := range value . CombatTurns {
for _ , ref := range turn . SourceRefs {
2026-07-25 12:57:31 +00:00
if index . ValidateRef ( ref ) != nil {
2026-07-21 05:22:12 +00:00
return false
}
}
}
return true
}
func duplicateKey ( turn dnd . CombatTurn ) ( string , bool ) {
if len ( turn . SourceRefs ) == 0 {
return "" , false
}
var key strings . Builder
writeKeyString ( & key , identity . ComparisonKey ( turn . Actor ) )
writeKeyString ( & key , string ( turn . TurnKind ) )
for _ , ref := range turn . SourceRefs {
writeKeyString ( & key , ref . SourceID )
writeKeyInt ( & key , ref . StartUnitID )
writeKeyInt ( & key , ref . EndUnitID )
}
return key . String ( ) , true
}
func writeKeyString ( builder * strings . Builder , value string ) {
builder . WriteString ( strconv . Itoa ( len ( value ) ) )
builder . WriteByte ( ':' )
builder . WriteString ( value )
}
func writeKeyInt ( builder * strings . Builder , value int ) {
builder . WriteString ( strconv . Itoa ( value ) )
builder . WriteByte ( ';' )
}
func Spec ( ) pipeline . ValidatorSpec {
return pipeline . ValidatorSpec { Key : Key , ExecutionClass : contracts . ExecutionClassDeterministic }
}
func Register ( registry * pipeline . ValidatorRegistry ) error {
return pipeline . RegisterTypedValidatorBuilder ( registry , dnd . CombatTurnListKind , Spec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . TypedValidator [ dnd . CombatTurnList ] , error ) {
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( options ) , nil
} )
}
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 }