Simplify D&D combat turn extraction contract

This commit is contained in:
2026-07-22 19:14:14 +00:00
parent 2cbaf20e55
commit 90481a0e4b
36 changed files with 176 additions and 1070 deletions

View File

@@ -66,31 +66,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
if turn.Actor != identity.NormalizeDisplay(turn.Actor) {
issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor))
}
if turn.Summary != identity.NormalizeDisplay(turn.Summary) {
issues = append(issues, prefix+".summary is not display-normalized: "+diagnostics.Quote(turn.Summary))
}
for actionIndex, action := range turn.Actions {
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
if action.Declaration != identity.NormalizeDisplay(action.Declaration) {
issues = append(issues, actionPrefix+".declaration is not display-normalized: "+diagnostics.Quote(action.Declaration))
}
seenTargets := make(map[string]int, len(action.Targets))
for targetIndex, target := range action.Targets {
if target != identity.NormalizeDisplay(target) {
issues = append(issues, fmt.Sprintf("%s.targets[%d] is not display-normalized: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
}
key := identity.ComparisonKey(target)
if previous, exists := seenTargets[key]; exists {
issues = append(issues, fmt.Sprintf("%s.targets[%d] duplicates target %d under comparison identity", actionPrefix, targetIndex, previous))
} else {
seenTargets[key] = targetIndex
}
}
if action.Resolution != nil && *action.Resolution != identity.NormalizeDisplay(*action.Resolution) {
issues = append(issues, actionPrefix+".resolution is not display-normalized: "+diagnostics.Quote(*action.Resolution))
}
}
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
previous := turn.SourceRefs[refIndex-1]
current := turn.SourceRefs[refIndex]
@@ -169,12 +144,6 @@ func duplicateKey(turn dnd.CombatTurn) (string, bool) {
var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind))
if turn.Round == nil {
key.WriteByte('0')
} else {
key.WriteByte('1')
writeKeyInt(&key, *turn.Round)
}
for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)

View File

@@ -27,22 +27,6 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
want string
}{
{name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"},
{name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Summary = "Aria attacks"
}, want: "summary is not display-normalized"},
{name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Declaration = "Aria attacks"
}, want: "declaration is not display-normalized"},
{name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"}
}, want: "targets[0] is not display-normalized"},
{name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"}
}, want: "duplicates target"},
{name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
resolution := " hit "
value.CombatTurns[0].Actions[0].Resolution = &resolution
}, want: "resolution is not display-normalized"},
{name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, want: "not in canonical order"},
@@ -53,9 +37,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]}
}, want: "out of chronological order"},
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
duplicate := value.CombatTurns[0]
duplicate.Summary = "different prose"
value.CombatTurns = append(value.CombatTurns, duplicate)
value.CombatTurns = append(value.CombatTurns, value.CombatTurns[0])
}, want: "duplicates combat turn"},
}
for _, test := range tests {
@@ -121,11 +103,9 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
}
func normalizedList() dnd.CombatTurnList {
resolution := "hit"
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1),
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}},
Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
}}}
}
@@ -133,8 +113,6 @@ func invariantDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
}
func intPointer(value int) *int { return &value }
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
turn.SourceRefs = []source.SourceRef{ref}
return turn

View File

@@ -60,39 +60,9 @@ func issuesFor(value dnd.CombatTurnList) []string {
if !validTurnKind(turn.TurnKind) {
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
}
if turn.Round != nil && *turn.Round <= 0 {
issues = append(issues, prefix+".round must be positive or null")
}
if len(turn.Actions) == 0 {
issues = append(issues, prefix+".actions must contain at least one action")
}
if strings.TrimSpace(turn.Summary) == "" {
issues = append(issues, prefix+".summary must not be empty: "+diagnostics.Quote(turn.Summary))
}
if len(turn.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must contain at least one reference")
}
for actionIndex, action := range turn.Actions {
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
if !validActionCategory(action.Category) {
issues = append(issues, actionPrefix+".category is unsupported: "+diagnostics.Quote(string(action.Category)))
}
if strings.TrimSpace(action.Declaration) == "" {
issues = append(issues, actionPrefix+".declaration must not be empty: "+diagnostics.Quote(action.Declaration))
}
if action.Targets == nil {
issues = append(issues, actionPrefix+".targets must be present")
} else {
for targetIndex, target := range action.Targets {
if strings.TrimSpace(target) == "" {
issues = append(issues, fmt.Sprintf("%s.targets[%d] must not be empty: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
}
}
}
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
issues = append(issues, actionPrefix+".resolution must not be empty or null: "+diagnostics.Quote(*action.Resolution))
}
}
}
return issues
}
@@ -106,15 +76,6 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
}
}
func validActionCategory(value dnd.CombatActionCategory) bool {
switch value {
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
return true
default:
return false
}
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}

View File

@@ -28,18 +28,7 @@ func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
{name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"},
{name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"},
{name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"},
{name: "non-positive round", mutate: func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }, want: "round must be positive"},
{name: "missing actions", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }, want: "actions must contain"},
{name: "empty summary", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }, want: "summary must not be empty"},
{name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"},
{name: "unsupported category", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unknown" }, want: "category is unsupported"},
{name: "empty declaration", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }, want: "declaration must not be empty"},
{name: "missing targets", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }, want: "targets must be present"},
{name: "empty target", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }, want: "targets[0] must not be empty"},
{name: "empty resolution", mutate: func(value *dnd.CombatTurnList) {
resolution := " "
value.CombatTurns[0].Actions[0].Resolution = &resolution
}, want: "resolution must not be empty"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -85,11 +74,8 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
}
func validCombatTurnList() dnd.CombatTurnList {
round := 2
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}

View File

@@ -87,11 +87,9 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
}
func validCombatTurnList() dnd.CombatTurnList {
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}

View File

@@ -3,7 +3,6 @@ package sourcerelatedness
import (
"context"
"fmt"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -50,22 +49,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
warnings := make([]contracts.Warning, 0)
for turnIndex, turn := range req.Value.CombatTurns {
citedText := citedTexts[turnIndex]
issues := make([]string, 0)
if !actorAppearsInCitedText(citedText, turn.Actor) {
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
}
for actionIndex, action := range turn.Actions {
if !declarationAppearsInCitedText(citedText, action.Declaration) {
issues = append(issues, fmt.Sprintf("action %d declaration %s was not found in cited source text", actionIndex, diagnostics.Quote(action.Declaration)))
}
}
if len(issues) == 0 {
if actorAppearsInCitedText(citedText, turn.Actor) {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
ReasonCode: WarningReasonCode,
Message: diagnostics.Aggregate("combat turn not near source", issues),
Message: diagnostics.Aggregate("combat turn not near source", []string{
fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)),
}),
})
}
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
@@ -74,27 +66,6 @@ func actorAppearsInCitedText(citedText string, actor string) bool {
return shared.ContainsTokenSequence(citedText, actor)
}
func declarationAppearsInCitedText(citedText string, declaration string) bool {
citedTokens := tokenSet(citedText)
for _, token := range shared.NormalizedTokens(declaration) {
if utf8.RuneCountInString(token) >= 4 {
if _, ok := citedTokens[token]; ok {
return true
}
}
}
return false
}
func tokenSet(value string) map[string]struct{} {
tokens := shared.NormalizedTokens(value)
set := make(map[string]struct{}, len(tokens))
for _, token := range tokens {
set[token] = struct{}{}
}
return set
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}

View File

@@ -12,12 +12,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) {
resolution := "The goblin is hit."
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActor(t *testing.T) {
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}},
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
},
@@ -32,31 +30,25 @@ func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t
}
}
func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) {
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
Actions: []dnd.CombatAction{
{Category: dnd.CombatActionCategoryOther, Declaration: "hit", Targets: []string{}, Resolution: nil},
{Category: dnd.CombatActionCategoryOther, Declaration: "unseen monster", Targets: []string{}, Resolution: nil},
},
Summary: "An unrelated event.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
}
warning := result.Warnings[0]
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !strings.Contains(warning.Message, "action 0") || !strings.Contains(warning.Message, "action 1") || !utf8.ValidString(warning.Message) {
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !utf8.ValidString(warning.Message) {
t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning)
}
}
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
resolution := "The cart is struck."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
@@ -105,8 +97,7 @@ func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) {
func validCombatTurnList() dnd.CombatTurnList {
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}