diff --git a/internal/modules/dnd/scenedescriptions/registry/registry.go b/internal/modules/dnd/scenedescriptions/registry/registry.go new file mode 100644 index 0000000..ffe0a6e --- /dev/null +++ b/internal/modules/dnd/scenedescriptions/registry/registry.go @@ -0,0 +1,250 @@ +// Package registry resolves scene-description artifacts into immutable combat +// eligibility data. +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "mime" + "sort" + "strings" + "sync" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" +) + +const ( + ReferenceSlot = "scene_descriptions" + MaxBytes = 1048576 + emptyProjection = `{"scenes":[]}` +) + +// MatchState identifies how a chunk relates to the resolved scene records. +type MatchState string + +const ( + MatchExact MatchState = "exact" + MatchMissing MatchState = "missing" + MatchMismatched MatchState = "mismatched" +) + +// ChunkMatch describes whether a scene record exactly covers a chunk. Kind is +// populated only for an exact match. +type ChunkMatch struct { + State MatchState + Kind dnd.SceneKind +} + +type sceneEligibility struct { + sourceID string + startUnitID int + endUnitID int + kind dnd.SceneKind +} + +// Registry is an immutable, validated eligibility view. It retains no scene +// prose, raw reference bytes, or reference provenance. +type Registry struct { + bound bool + digest string + scenesByID map[string]sceneEligibility +} + +// Resolver holds an immutable construction-time view and memoizes immutable +// operation-time views by eligibility digest. +type Resolver struct { + seeded *Registry + + mu sync.Mutex + cache map[string]*Registry +} + +// NewResolver validates a materialized construction-time scene reference. An +// empty slot is permitted because a generated reference is supplied only at +// operation time. +func NewResolver(references contracts.ReferenceSet) (*Resolver, error) { + seeded, err := Resolve(constructionReferences(references)) + if err != nil { + return nil, err + } + return &Resolver{seeded: seeded, cache: make(map[string]*Registry)}, nil +} + +func constructionReferences(references contracts.ReferenceSet) contracts.ReferenceSet { + slot, ok := references.Slots[ReferenceSlot] + if !ok || len(slot.Items) > 0 { + return references + } + cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(references.Slots))} + for name, value := range references.Slots { + cloned.Slots[name] = value + } + delete(cloned.Slots, ReferenceSlot) + return cloned +} + +// Seeded returns the construction-time eligibility view. +func (r *Resolver) Seeded() *Registry { + if r == nil { + return nil + } + return r.seeded +} + +// Resolve returns the generated operation-time view when the scene slot is +// present, otherwise it returns the construction-time view. +func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error) { + if r == nil { + return Resolve(references) + } + if _, ok := references.Slots[ReferenceSlot]; !ok { + return r.seeded, nil + } + + resolved, err := Resolve(references) + if err != nil { + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + if sameEligibility(r.seeded, resolved) { + return r.seeded, nil + } + if cached, ok := r.cache[resolved.EligibilityDigest()]; ok { + return cached, nil + } + r.cache[resolved.EligibilityDigest()] = resolved + return resolved, nil +} + +func sameEligibility(first, second *Registry) bool { + if first == nil || second == nil { + return first == second + } + return first.bound == second.bound && first.digest == second.digest +} + +// Resolve validates one optional scene-description reference into an +// eligibility-only view. An absent slot is represented by the canonical empty +// projection so required generated bindings have a stable sentinel identity. +func Resolve(references contracts.ReferenceSet) (*Registry, error) { + slot, ok := references.Slots[ReferenceSlot] + if !ok { + return &Registry{ + digest: semanticDigest([]byte(emptyProjection)), + scenesByID: map[string]sceneEligibility{}, + }, nil + } + if len(slot.Items) != 1 { + return nil, fmt.Errorf("reference slot %q must contain exactly one item", ReferenceSlot) + } + + item := slot.Items[0] + mediaType, _, err := mime.ParseMediaType(item.MediaType) + if err != nil { + return nil, fmt.Errorf("reference slot %q item media type is invalid", ReferenceSlot) + } + if !strings.EqualFold(mediaType, scenecodec.MediaType) { + return nil, fmt.Errorf("reference slot %q item media type must be %s", ReferenceSlot, scenecodec.MediaType) + } + if len(item.Content) > MaxBytes { + return nil, fmt.Errorf("reference slot %q item is %d bytes, limit %d", ReferenceSlot, len(item.Content), MaxBytes) + } + + value, err := scenecodec.New().Decode(item.Content) + if err != nil { + return nil, fmt.Errorf("decode scene eligibility: invalid approved scene JSON") + } + + scenesByID := make(map[string]sceneEligibility, len(value.Scenes)) + projection := make([]projectedScene, 0, len(value.Scenes)) + for _, scene := range value.Scenes { + if _, exists := scenesByID[scene.ID]; exists { + return nil, fmt.Errorf("scene eligibility contains duplicate scene ID %q", scene.ID) + } + eligibility := sceneEligibility{ + sourceID: scene.SourceRef.SourceID, + startUnitID: scene.SourceRef.StartUnitID, + endUnitID: scene.SourceRef.EndUnitID, + kind: scene.Kind, + } + scenesByID[scene.ID] = eligibility + projection = append(projection, projectedScene{ + ID: scene.ID, + SourceRef: source.SourceRef{SourceID: eligibility.sourceID, StartUnitID: eligibility.startUnitID, EndUnitID: eligibility.endUnitID}, + Kind: eligibility.kind, + }) + } + + content, err := eligibilityProjection(projection) + if err != nil { + return nil, fmt.Errorf("encode scene eligibility projection: %w", err) + } + return &Registry{ + bound: true, + digest: semanticDigest(content), + scenesByID: scenesByID, + }, nil +} + +// Bound reports whether an approved scene-description reference was supplied. +func (r *Registry) Bound() bool { return r != nil && r.bound } + +// Count reports the number of scene records in the eligibility view. +func (r *Registry) Count() int { + if r == nil { + return 0 + } + return len(r.scenesByID) +} + +// EligibilityDigest returns the semantic SHA-256 digest of the canonical +// eligibility projection, including the canonical unbound projection. +func (r *Registry) EligibilityDigest() string { + if r == nil { + return "" + } + return r.digest +} + +// Match classifies a chunk against its scene record. The kind is intentionally +// unavailable unless the ID and complete source range match exactly. +func (r *Registry) Match(chunk *source.Chunk) ChunkMatch { + if r == nil || chunk == nil { + return ChunkMatch{State: MatchMissing} + } + scene, ok := r.scenesByID[chunk.ID] + if !ok { + return ChunkMatch{State: MatchMissing} + } + if scene.sourceID != chunk.Ref.SourceID || scene.startUnitID != chunk.Ref.StartUnitID || scene.endUnitID != chunk.Ref.EndUnitID { + return ChunkMatch{State: MatchMismatched} + } + return ChunkMatch{State: MatchExact, Kind: scene.kind} +} + +type projectedScene struct { + ID string `json:"id"` + SourceRef source.SourceRef `json:"source_ref"` + Kind dnd.SceneKind `json:"kind"` +} + +type projectedSceneList struct { + Scenes []projectedScene `json:"scenes"` +} + +func eligibilityProjection(scenes []projectedScene) ([]byte, error) { + sort.Slice(scenes, func(i, j int) bool { return scenes[i].ID < scenes[j].ID }) + return json.Marshal(projectedSceneList{Scenes: scenes}) +} + +func semanticDigest(content []byte) string { + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/internal/modules/dnd/scenedescriptions/registry/registry_test.go b/internal/modules/dnd/scenedescriptions/registry/registry_test.go new file mode 100644 index 0000000..9b0db8c --- /dev/null +++ b/internal/modules/dnd/scenedescriptions/registry/registry_test.go @@ -0,0 +1,221 @@ +package registry + +import ( + "fmt" + "sync" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" +) + +func TestResolveClassifiesExactMissingAndMismatchedChunks(t *testing.T) { + registry := resolveList(t, sceneList( + scene("chunk-combat", "session-alpha", 1, 2, dnd.SceneKindCombat, "Combat title", "Combat summary"), + )) + + for _, test := range []struct { + name string + chunk *source.Chunk + want ChunkMatch + }{ + {"exact", chunk("chunk-combat", "session-alpha", 1, 2), ChunkMatch{State: MatchExact, Kind: dnd.SceneKindCombat}}, + {"missing ID", chunk("chunk-missing", "session-alpha", 1, 2), ChunkMatch{State: MatchMissing}}, + {"source differs", chunk("chunk-combat", "other-source", 1, 2), ChunkMatch{State: MatchMismatched}}, + {"start differs", chunk("chunk-combat", "session-alpha", 2, 2), ChunkMatch{State: MatchMismatched}}, + {"end differs", chunk("chunk-combat", "session-alpha", 1, 3), ChunkMatch{State: MatchMismatched}}, + } { + t.Run(test.name, func(t *testing.T) { + if got := registry.Match(test.chunk); got != test.want { + t.Fatalf("Match() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestResolveRejectsInvalidSceneReferences(t *testing.T) { + valid := encodeList(t, sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindNarrative, "Arrival", "The party arrives."))) + oversized := make([]byte, MaxBytes+1) + for _, test := range []struct { + name string + set contracts.ReferenceSet + }{ + {"multiple items", referenceSet(referenceItem(valid), referenceItem(valid))}, + {"invalid media type", referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: "text/plain", Content: valid})}, + {"invalid media type syntax", referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: "not a media type", Content: valid})}, + {"oversized", referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: scenecodec.MediaType, Content: oversized})}, + {"invalid approved JSON", referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: scenecodec.MediaType, Content: []byte(`{"scenes":[]}`)})}, + {"duplicate IDs", referenceSet(referenceItem(encodeList(t, sceneList( + scene("chunk-1", "session-alpha", 1, 1, dnd.SceneKindNarrative, "First", "First scene."), + scene("chunk-1", "session-alpha", 2, 2, dnd.SceneKindCombat, "Second", "Second scene."), + ))))}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := Resolve(test.set); err == nil { + t.Fatal("Resolve() error = nil") + } + }) + } +} + +func TestEligibilityDigestTracksOnlyGatingFields(t *testing.T) { + base := sceneList( + scene("chunk-a", "session-alpha", 1, 2, dnd.SceneKindNarrative, "Arrival", "The party arrives."), + scene("chunk-b", "session-alpha", 3, 4, dnd.SceneKindCombat, "Combat", "Combat begins."), + ) + baseDigest := resolveList(t, base).EligibilityDigest() + + equivalent := sceneList( + scene("chunk-b", "session-alpha", 3, 4, dnd.SceneKindCombat, "Different title", "Different summary."), + scene("chunk-a", "session-alpha", 1, 2, dnd.SceneKindNarrative, "Another title", "Another summary."), + ) + if got := resolveList(t, equivalent).EligibilityDigest(); got != baseDigest { + t.Fatalf("equivalent eligibility digest = %q, want %q", got, baseDigest) + } + + changes := []struct { + name string + change func(*dnd.SceneDescription) + }{ + {"ID", func(value *dnd.SceneDescription) { value.ID = "chunk-other" }}, + {"source ID", func(value *dnd.SceneDescription) { value.SourceRef.SourceID = "session-other" }}, + {"start unit ID", func(value *dnd.SceneDescription) { value.SourceRef.StartUnitID = 2 }}, + {"end unit ID", func(value *dnd.SceneDescription) { value.SourceRef.EndUnitID = 3 }}, + {"kind", func(value *dnd.SceneDescription) { value.Kind = dnd.SceneKindRecap }}, + } + for _, test := range changes { + t.Run(test.name, func(t *testing.T) { + changed := cloneList(base) + test.change(&changed.Scenes[0]) + if got := resolveList(t, changed).EligibilityDigest(); got == baseDigest { + t.Fatal("eligibility digest did not change") + } + }) + } +} + +func TestResolverUsesGeneratedReferenceWhenPresent(t *testing.T) { + external := sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindNarrative, "External", "External scene.")) + generated := sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Generated", "Generated scene.")) + resolver, err := NewResolver(referenceSet(referenceItem(encodeList(t, external)))) + if err != nil { + t.Fatal(err) + } + if seeded := resolver.Seeded(); !seeded.Bound() || seeded.Count() != 1 || seeded.Match(chunk("chunk-1", "session-alpha", 1, 2)).Kind != dnd.SceneKindNarrative { + t.Fatalf("Seeded() = %#v", seeded) + } + if got, err := resolver.Resolve(contracts.ReferenceSet{}); err != nil || got != resolver.Seeded() { + t.Fatalf("Resolve() = %p, %v; want seeded %p", got, err, resolver.Seeded()) + } + resolved, err := resolver.Resolve(referenceSet(referenceItem(encodeList(t, generated)))) + if err != nil { + t.Fatal(err) + } + if got := resolved.Match(chunk("chunk-1", "session-alpha", 1, 2)); got != (ChunkMatch{State: MatchExact, Kind: dnd.SceneKindCombat}) { + t.Fatalf("generated Match() = %#v", got) + } + if resolved == resolver.Seeded() { + t.Fatal("generated reference reused a different seeded eligibility view") + } + + unbound, err := NewResolver(contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {}}}) + if err != nil { + t.Fatal(err) + } + if seeded := unbound.Seeded(); seeded.Bound() || seeded.Count() != 0 || seeded.EligibilityDigest() != semanticDigest([]byte(emptyProjection)) { + t.Fatalf("unbound Seeded() = %#v", seeded) + } +} + +func TestRegistryIsImmutableAndResolverIsSafeForConcurrentReuse(t *testing.T) { + content := encodeList(t, sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Title", "Summary."))) + references := referenceSet(referenceItem(content)) + registry := resolveReferences(t, references) + content[0] = '[' + if got := registry.Match(chunk("chunk-1", "session-alpha", 1, 2)); got != (ChunkMatch{State: MatchExact, Kind: dnd.SceneKindCombat}) { + t.Fatalf("Match() after input mutation = %#v", got) + } + + resolver, err := NewResolver(contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {}}}) + if err != nil { + t.Fatal(err) + } + const workers = 32 + var group sync.WaitGroup + errs := make(chan error, workers) + for range workers { + group.Add(1) + go func() { + defer group.Done() + resolved, err := resolver.Resolve(references) + if err != nil { + errs <- err + return + } + if got := resolved.Match(chunk("chunk-1", "session-alpha", 1, 2)); got != (ChunkMatch{State: MatchExact, Kind: dnd.SceneKindCombat}) { + errs <- fmt.Errorf("Match() = %#v", got) + } + }() + } + group.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + +func resolveList(t *testing.T, value dnd.SceneDescriptionList) *Registry { + t.Helper() + return resolveReferences(t, referenceSet(referenceItem(encodeList(t, value)))) +} + +func resolveReferences(t *testing.T, references contracts.ReferenceSet) *Registry { + t.Helper() + registry, err := Resolve(references) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return registry +} + +func encodeList(t *testing.T, value dnd.SceneDescriptionList) []byte { + t.Helper() + content, err := scenecodec.New().Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + return content +} + +func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet { + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}} +} + +func referenceItem(content []byte) contracts.ReferenceItem { + return contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: scenecodec.MediaType, Content: append([]byte(nil), content...)} +} + +func scene(id string, sourceID string, startUnitID int, endUnitID int, kind dnd.SceneKind, title string, summary string) dnd.SceneDescription { + return dnd.SceneDescription{ + ID: id, + SourceRef: source.SourceRef{SourceID: sourceID, StartUnitID: startUnitID, EndUnitID: endUnitID}, + Kind: kind, + Title: title, + Summary: summary, + } +} + +func sceneList(scenes ...dnd.SceneDescription) dnd.SceneDescriptionList { + return dnd.SceneDescriptionList{Scenes: scenes} +} + +func chunk(id string, sourceID string, startUnitID int, endUnitID int) *source.Chunk { + return &source.Chunk{ID: id, Ref: source.SourceRef{SourceID: sourceID, StartUnitID: startUnitID, EndUnitID: endUnitID}} +} + +func cloneList(value dnd.SceneDescriptionList) dnd.SceneDescriptionList { + cloned := dnd.SceneDescriptionList{Scenes: append([]dnd.SceneDescription(nil), value.Scenes...)} + return cloned +}