Add deterministic D&D spell normalizer foundation
This commit is contained in:
238
internal/modules/dnd/normalize/spells/normalizer.go
Normal file
238
internal/modules/dnd/normalize/spells/normalizer.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package spells
|
||||
|
||||
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"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
|
||||
const (
|
||||
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
|
||||
ReasonCodeSpellNameUnresolved = "spell_name_unresolved"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
var providedCapabilities = []string{"normalized"}
|
||||
|
||||
var _ contracts.Normalizer[dnd.SpellList] = (*Normalizer)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Normalizer struct {
|
||||
effectiveCatalog spellcatalog.EffectiveCatalog
|
||||
}
|
||||
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, normalizerErrorf("at most one reference set may be supplied")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
effectiveCatalog, err := spellcatalog.ResolveEffectiveCatalog(referenceSet)
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("resolve effective spell catalog: %w", err)
|
||||
}
|
||||
return &Normalizer{effectiveCatalog: effectiveCatalog}, nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) Key() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return referenceSlots()
|
||||
}
|
||||
|
||||
func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"catalog_base_id": n.effectiveCatalog.BaseID(),
|
||||
"catalog_digest": n.effectiveCatalog.Digest(),
|
||||
"catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}}
|
||||
}
|
||||
|
||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) {
|
||||
if n == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
|
||||
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog)
|
||||
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
|
||||
var warnings []contracts.Warning
|
||||
if input.SpellCasts == nil {
|
||||
return dnd.SpellList{}, nil
|
||||
}
|
||||
|
||||
output := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, len(input.SpellCasts))}
|
||||
for index, inputCast := range input.SpellCasts {
|
||||
cast := cloneSpellCast(inputCast)
|
||||
if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok {
|
||||
if inputCast.Spell != canonicalName {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: spellCastScope(index),
|
||||
ReasonCode: ReasonCodeSpellNameCanonicalized,
|
||||
Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q",
|
||||
index, boundedName(inputCast.Spell), boundedName(canonicalName)),
|
||||
})
|
||||
}
|
||||
cast.Spell = canonicalName
|
||||
} else {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: spellCastScope(index),
|
||||
ReasonCode: ReasonCodeSpellNameUnresolved,
|
||||
Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog",
|
||||
index, boundedName(inputCast.Spell)),
|
||||
})
|
||||
}
|
||||
|
||||
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(inputCast.SourceRefs)
|
||||
cast.SourceRefs = canonicalRefs
|
||||
if orderChanged || duplicateCount > 0 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: spellCastScope(index),
|
||||
ReasonCode: ReasonCodeSourceReferencesNormalized,
|
||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)",
|
||||
index, len(inputCast.SourceRefs), len(canonicalRefs), orderChanged, duplicateCount),
|
||||
})
|
||||
}
|
||||
output.SpellCasts[index] = cast
|
||||
}
|
||||
return output, warnings
|
||||
}
|
||||
|
||||
func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
|
||||
output := input
|
||||
if input.SourceRefs != nil {
|
||||
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
|
||||
copy(output.SourceRefs, input.SourceRefs)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
||||
if input == nil {
|
||||
return nil, false, 0
|
||||
}
|
||||
|
||||
canonical := make([]source.SourceRef, len(input))
|
||||
copy(canonical, input)
|
||||
sort.SliceStable(canonical, func(left, right int) bool {
|
||||
return sourceRefLess(canonical[left], canonical[right])
|
||||
})
|
||||
|
||||
orderChanged := false
|
||||
for index := range input {
|
||||
if input[index] != canonical[index] {
|
||||
orderChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
unique := make([]source.SourceRef, 0, len(canonical))
|
||||
for _, ref := range canonical {
|
||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
||||
unique = append(unique, ref)
|
||||
}
|
||||
}
|
||||
return unique, orderChanged, len(input) - len(unique)
|
||||
}
|
||||
|
||||
func sourceRefLess(left, right source.SourceRef) bool {
|
||||
if left.SourceID != right.SourceID {
|
||||
return left.SourceID < right.SourceID
|
||||
}
|
||||
if left.StartUnitID != right.StartUnitID {
|
||||
return left.StartUnitID < right.StartUnitID
|
||||
}
|
||||
return left.EndUnitID < right.EndUnitID
|
||||
}
|
||||
|
||||
func boundedName(name string) string {
|
||||
runes := []rune(name)
|
||||
if len(runes) <= 128 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:127]) + "…"
|
||||
}
|
||||
|
||||
func spellCastScope(index int) string {
|
||||
return fmt.Sprintf("spell_casts[%d]", index)
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.SpellListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SpellList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
|
||||
func validateOptions(options map[string]any) error {
|
||||
_, err := DecodeOptions(options)
|
||||
return err
|
||||
}
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, normalizerErrorf("%w", err)
|
||||
}
|
||||
return Options{}, nil
|
||||
}
|
||||
|
||||
func referenceSlots() []contracts.ReferenceSlot {
|
||||
return []contracts.ReferenceSlot{{
|
||||
Name: spellcatalog.SpellCatalogReferenceSlot,
|
||||
Description: "Optional canonical spell-name catalog used for extraction grounding.",
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
MaxBytes: 1048576,
|
||||
}}
|
||||
}
|
||||
|
||||
func normalizerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd spells normalizer: "+format, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user