diff --git a/internal/modules/dnd/codec/itemregistry/assets/schemas/dnd_item_registry.v1.json b/internal/modules/dnd/codec/itemregistry/assets/schemas/dnd_item_registry.v1.json new file mode 100644 index 0000000..7a2d2bb --- /dev/null +++ b/internal/modules/dnd/codec/itemregistry/assets/schemas/dnd_item_registry.v1.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.item_registry", + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "source_refs"], + "properties": { + "id": { + "type": "string", + "pattern": "^item:sha256:[0-9a-f]{64}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/codec/itemregistry/codec.go b/internal/modules/dnd/codec/itemregistry/codec.go new file mode 100644 index 0000000..31cfdec --- /dev/null +++ b/internal/modules/dnd/codec/itemregistry/codec.go @@ -0,0 +1,111 @@ +// Package itemregistry encodes durable D&D item-registry artifacts. +package itemregistry + +import ( + "embed" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" +) + +const ( + SchemaID = "notarius.dnd.item_registry" + SchemaName = "notarius_dnd_item_registry_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +//go:embed assets/schemas/dnd_item_registry.v1.json +var schemaAssets embed.FS + +var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil) + +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemRegistryKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_registry.v1.json") + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ + ID: SchemaID, + Name: SchemaName, + Version: SchemaVersion, + JSONSchema: append([]byte(nil), raw...), + } +} + +func (c *Codec) MediaType() string { return MediaType } + +func (c *Codec) Metadata(value dnd.ItemRegistry) map[string]any { + return map[string]any{"item_count": len(value.Items)} +} + +func (c *Codec) Encode(value dnd.ItemRegistry) ([]byte, error) { + if err := validate(value); err != nil { + return nil, fmt.Errorf("encode dnd item registry: %w", err) + } + return c.EncodeCandidate(value) +} + +// EncodeCandidate provides the durable representation before semantic +// validators have approved a value. +func (c *Codec) EncodeCandidate(value dnd.ItemRegistry) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd item registry", value) +} + +func (c *Codec) Decode(content []byte) (dnd.ItemRegistry, error) { + value, err := c.DecodeCandidate(content) + if err != nil { + return dnd.ItemRegistry{}, err + } + if err := validate(value); err != nil { + return dnd.ItemRegistry{}, fmt.Errorf("decode dnd item registry: %w", err) + } + return value, nil +} + +// DecodeCandidate reads one strict durable JSON value before semantic +// validators have approved it. +func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemRegistry, error) { + return candidatejson.DecodeCandidate[dnd.ItemRegistry]("dnd item registry", content) +} + +func validate(value dnd.ItemRegistry) error { + if value.Items == nil { + return fmt.Errorf("items must be present") + } + for index, item := range value.Items { + prefix := fmt.Sprintf("items[%d]", index) + if !identity.IsValidID(item.ID) { + return fmt.Errorf("%s.id must match item ID pattern", prefix) + } + if strings.TrimSpace(item.Name) == "" { + return fmt.Errorf("%s.name must not be empty", prefix) + } + if len(item.SourceRefs) == 0 { + return fmt.Errorf("%s.source_refs must not be empty", prefix) + } + for refIndex, ref := range item.SourceRefs { + refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) + if strings.TrimSpace(ref.SourceID) == "" { + return fmt.Errorf("%s.source_id must not be empty", refPrefix) + } + if ref.StartUnitID <= 0 { + return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + } + } + } + return nil +} diff --git a/internal/modules/dnd/codec/itemregistry/codec_test.go b/internal/modules/dnd/codec/itemregistry/codec_test.go new file mode 100644 index 0000000..5084040 --- /dev/null +++ b/internal/modules/dnd/codec/itemregistry/codec_test.go @@ -0,0 +1,97 @@ +package itemregistry + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "strings" + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" +) + +func validRegistry() dnd.ItemRegistry { + return dnd.ItemRegistry{Items: []dnd.Item{{ + ID: identity.DeriveID("Silver Key"), + Name: "Silver Key", + SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, + }}} +} + +func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/dnd_item_registry.v1.json") + if err != nil { + t.Fatalf("read durable fixture: %v", err) + } + codec := New() + value, err := codec.Decode(raw) + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + if want := validRegistry(); !reflect.DeepEqual(value, want) { + t.Fatalf("Decode() = %#v, want %#v", value, want) + } + encoded, err := codec.Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + t.Fatal(err) + } + if !bytes.Equal(encoded, compact.Bytes()) { + t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes()) + } +} + +func TestCodecOwnsDurableSchemaAndExactType(t *testing.T) { + codec := New() + schema := codec.Schema() + if codec.Kind() != dnd.ItemRegistryKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { + t.Fatalf("codec contract = %#v / %#v", codec, schema) + } +} + +func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) { + codec := New() + candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}} + if content, err := codec.EncodeCandidate(candidate); err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` { + t.Fatalf("EncodeCandidate() = %s, %v", content, err) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") { + t.Fatalf("Encode() error = %v, want durable validation", err) + } + metadata := codec.Metadata(validRegistry()) + if metadata["item_count"] != 1 { + t.Fatalf("Metadata() = %#v", metadata) + } + for _, content := range []string{ + `{"items":[],"unexpected":true}`, + `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key","source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1,"unexpected":true}]}]}`, + `{"items":[]} {}`, + } { + if _, err := codec.Decode([]byte(content)); err == nil { + t.Fatalf("Decode(%s) error = nil", content) + } + } +} + +func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { + codec := New() + first := codec.Schema() + first.JSONSchema[0] = '[' + if next := codec.Schema(); !json.Valid(next.JSONSchema) || next.JSONSchema[0] == '[' { + t.Fatal("Schema() returned shared bytes") + } + metadata := codec.Metadata(validRegistry()) + metadata["other"] = true + if next := codec.Metadata(validRegistry()); len(next) != 1 || next["item_count"] != 1 { + t.Fatalf("Metadata() = %#v", next) + } +} + +var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil) diff --git a/internal/modules/dnd/codec/itemregistry/testdata/dnd_item_registry.v1.json b/internal/modules/dnd/codec/itemregistry/testdata/dnd_item_registry.v1.json new file mode 100644 index 0000000..b1a138f --- /dev/null +++ b/internal/modules/dnd/codec/itemregistry/testdata/dnd_item_registry.v1.json @@ -0,0 +1,11 @@ +{ + "items": [ + { + "id": "item:sha256:2f211c60b9bdcf3a6dd64086cc4c05df5b3357ae02cb462f7ef8988d9bd2fa4f", + "name": "Silver Key", + "source_refs": [ + {"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2} + ] + } + ] +} diff --git a/internal/modules/dnd/items/identity/identity.go b/internal/modules/dnd/items/identity/identity.go new file mode 100644 index 0000000..c4c7929 --- /dev/null +++ b/internal/modules/dnd/items/identity/identity.go @@ -0,0 +1,149 @@ +// Package identity owns the stable identity policy for D&D item types. +package identity + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +const ( + // Policy identifies the complete item comparison and ID derivation policy. + // A future semantic change must use a new value. + Policy = "dnd.item_registry.identity.v1" + // IdentityPolicy is an explicit alias for callers recording policy + // fingerprints. + IdentityPolicy = Policy +) + +const idPrefix = "item:sha256:" + +// IssueCode identifies one deterministic registry identity problem. +type IssueCode string + +const ( + IssueEmptyCanonicalName IssueCode = "empty_canonical_name" + IssueInvalidID IssueCode = "invalid_id" + IssueIDMismatch IssueCode = "id_mismatch" + IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity" + IssueDuplicateID IssueCode = "duplicate_id" +) + +// Issue is an inspectable identity validation problem. +type Issue struct { + Code IssueCode + RecordIndex int + Value string +} + +// NormalizeDisplay trims and collapses Unicode whitespace while retaining all +// other observed spelling and punctuation. +func NormalizeDisplay(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +// ComparisonKey returns the stable key used for item-type identity comparisons. +func ComparisonKey(value string) string { + value = norm.NFKC.String(value) + value = strings.Map(func(r rune) rune { + switch r { + case '\u2018', '\u2019', '\u02bc': + return '\'' + default: + return r + } + }, value) + value = strings.Join(strings.Fields(value), " ") + return cases.Fold().String(value) +} + +// DeriveID returns the deterministic ID for an item type. Empty identity keys +// intentionally produce an empty ID so shape validation can report the missing +// identity instead of manufacturing one. +func DeriveID(name string) string { + key := ComparisonKey(name) + if key == "" { + return "" + } + identity, err := json.Marshal([]string{Policy, key}) + if err != nil { + return "" + } + digest := sha256.Sum256(identity) + return idPrefix + hex.EncodeToString(digest[:]) +} + +// IDFor is a concise alias for DeriveID for callers that work with IDs as +// values rather than derivation operations. +func IDFor(name string) string { return DeriveID(name) } + +// IsValidID reports whether value has the exact durable item ID syntax. +func IsValidID(value string) bool { + if len(value) != len(idPrefix)+sha256.Size*2 || !strings.HasPrefix(value, idPrefix) { + return false + } + for _, r := range value[len(idPrefix):] { + if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +// ValidID is an alias for IsValidID. +func ValidID(value string) bool { return IsValidID(value) } + +// ValidateRegistry checks item-type identity invariants without changing the +// input. Items with the same comparison name describe the same item type and +// are rejected as duplicate registry records. +func ValidateRegistry(items []dnd.Item) []Issue { + issues := make([]Issue, 0) + canonicalOwners := make(map[string][]int) + idOwners := make(map[string][]int) + + for recordIndex, item := range items { + canonical := ComparisonKey(item.Name) + if canonical == "" { + issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, Value: item.Name}) + } else { + canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex) + } + + if !IsValidID(item.ID) { + issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, Value: item.ID}) + } else if expected := DeriveID(item.Name); item.ID != expected { + issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, Value: item.ID}) + } + if item.ID != "" { + idOwners[item.ID] = append(idOwners[item.ID], recordIndex) + } + } + + for recordIndex, item := range items { + canonical := ComparisonKey(item.Name) + if canonical != "" && len(canonicalOwners[canonical]) > 1 && canonicalOwners[canonical][0] != recordIndex { + issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, Value: item.Name}) + } + if item.ID != "" && len(idOwners[item.ID]) > 1 && idOwners[item.ID][0] != recordIndex { + issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, Value: item.ID}) + } + } + + return issues +} + +// ValidateList validates the identity members of list. +func ValidateList(list dnd.ItemRegistry) []Issue { return ValidateRegistry(list.Items) } + +// Error makes an issue useful in simple callers while preserving its +// structured fields for aggregate diagnostics. +func (i Issue) Error() string { + return fmt.Sprintf("%s at record %d", i.Code, i.RecordIndex) +} diff --git a/internal/modules/dnd/items/identity/identity_test.go b/internal/modules/dnd/items/identity/identity_test.go new file mode 100644 index 0000000..4470905 --- /dev/null +++ b/internal/modules/dnd/items/identity/identity_test.go @@ -0,0 +1,89 @@ +package identity + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func TestComparisonKeyNormalizesSupportedEquivalences(t *testing.T) { + tests := []struct { + name string + left string + right string + }{ + {name: "case", left: "Silver Key", right: "sILVER kEY"}, + {name: "compatibility", left: "Silver Key", right: "Silver Key"}, + {name: "whitespace", left: " Silver\u2003Key ", right: "Silver Key"}, + {name: "apostrophe", left: "Healer’s Kit", right: "healer's kit"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if ComparisonKey(test.left) != ComparisonKey(test.right) { + t.Fatalf("ComparisonKey(%q) = %q, ComparisonKey(%q) = %q", test.left, ComparisonKey(test.left), test.right, ComparisonKey(test.right)) + } + }) + } + if ComparisonKey("Silver Key") == ComparisonKey("Gold Key") { + t.Fatal("different item types received the same comparison key") + } +} + +func TestDeriveIDUsesExactCompactIdentityBytes(t *testing.T) { + const wantID = "item:sha256:2f211c60b9bdcf3a6dd64086cc4c05df5b3357ae02cb462f7ef8988d9bd2fa4f" + const wantIdentity = `["dnd.item_registry.identity.v1","silver key"]` + + encoded, err := json.Marshal([]string{Policy, ComparisonKey("Silver Key")}) + if err != nil || string(encoded) != wantIdentity { + t.Fatalf("identity bytes = %q, %v; want %s", encoded, err, wantIdentity) + } + if Policy != "dnd.item_registry.identity.v1" || IdentityPolicy != Policy { + t.Fatalf("identity policy = %q/%q", Policy, IdentityPolicy) + } + if got := DeriveID(" SILVER\u2003KEY "); got != wantID || !IsValidID(got) || got != IDFor("Silver Key") { + t.Fatalf("DeriveID() = %q, want %q", got, wantID) + } + if DeriveID(" \u2003 ") != "" { + t.Fatal("empty identity produced an ID") + } + for _, invalid := range []string{"", "item:sha256:", "item:sha256:ABC", "item:sha256:" + strings.Repeat("0", 63), "item:sha256:" + strings.Repeat("0", 65)} { + if IsValidID(invalid) { + t.Fatalf("IsValidID(%q) = true, want false", invalid) + } + } +} + +func TestValidateRegistryReportsDeterministicIdentityProblemsWithoutMutation(t *testing.T) { + items := []dnd.Item{ + {ID: DeriveID("Silver Key"), Name: "Silver Key"}, + {ID: DeriveID("Silver Key"), Name: " silver key "}, + {ID: "bad", Name: "Gold Key"}, + {ID: DeriveID("Silver Key"), Name: "Healer's Kit"}, + {ID: "", Name: " "}, + } + before := append([]dnd.Item(nil), items...) + issues := ValidateRegistry(items) + if !reflect.DeepEqual(items, before) { + t.Fatal("ValidateRegistry() mutated its input") + } + + want := []IssueCode{ + IssueDuplicateCanonical, + IssueDuplicateID, + IssueInvalidID, + IssueIDMismatch, + IssueEmptyCanonicalName, + } + seen := make(map[IssueCode]bool) + for _, issue := range issues { + seen[issue.Code] = true + } + for _, code := range want { + if !seen[code] { + t.Errorf("ValidateRegistry() issues = %#v, missing %s", issues, code) + } + } +} diff --git a/internal/modules/dnd/items/registry/registry.go b/internal/modules/dnd/items/registry/registry.go new file mode 100644 index 0000000..2792617 --- /dev/null +++ b/internal/modules/dnd/items/registry/registry.go @@ -0,0 +1,287 @@ +// Package registry resolves normalized item artifacts into immutable grounding +// data for future D&D consumers. +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/registryresolver" +) + +const ( + ReferenceSlot = "item_registry" + MaxBytes = 1048576 + emptyPrompt = `{"items":[]}` +) + +// Registry is an immutable, validated item registry prepared for grounding. +// All accessors return defensive copies. +type Registry struct { + bound bool + list dnd.ItemRegistry + canonical []byte + digest string + projectionDigest string + promptInput contracts.LLMInputMaterial + lookupByKey map[string]int + lookupByID map[string]int +} + +// Resolver selects and memoizes immutable item registry views. +type Resolver struct { + resolver *registryresolver.Resolver[*Registry] +} + +// NewResolver validates the optional construction-time item reference and +// prepares the operation-time registry cache. +func NewResolver(references contracts.ReferenceSet) (*Resolver, error) { + resolver, err := registryresolver.New(registryResolverConfig(), references) + if err != nil { + return nil, err + } + return &Resolver{resolver: resolver}, nil +} + +// Seeded returns the immutable construction-time registry. +func (r *Resolver) Seeded() *Registry { + if r == nil { + return nil + } + return r.resolver.Seeded() +} + +// Resolve returns the generated operation-time registry when supplied, +// otherwise it returns the construction-time registry. +func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error) { + if r == nil || r.resolver == nil { + return Resolve(references) + } + return r.resolver.Resolve(references) +} + +// Resolve prepares the optional item registry reference. An absent slot uses +// the exact empty prompt input and has no durable registry identity. +func Resolve(references contracts.ReferenceSet) (*Registry, error) { + item, present, err := registryresolver.ResolveOptionalSingleItem(references, itemReferenceSpec()) + if err != nil { + return nil, err + } + if !present { + return emptyRegistry(), nil + } + return loadRegistry(item.Content) +} + +func registryResolverConfig() registryresolver.Config[*Registry] { + return registryresolver.Config[*Registry]{ + Reference: itemReferenceSpec(), + Absent: func() (*Registry, error) { + return emptyRegistry(), nil + }, + Load: loadRegistry, + SemanticIdentity: func(registry *Registry) string { + return registry.Digest() + }, + } +} + +func itemReferenceSpec() registryresolver.ReferenceSpec { + return registryresolver.ReferenceSpec{SlotName: ReferenceSlot, AcceptedMediaType: itemcodec.MediaType, MaxBytes: MaxBytes} +} + +func emptyRegistry() *Registry { + content := []byte(emptyPrompt) + projectionDigest := semanticDigest(content) + return &Registry{ + list: dnd.ItemRegistry{Items: []dnd.Item{}}, + canonical: append([]byte(nil), content...), + projectionDigest: projectionDigest, + promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, itemcodec.MediaType, content, projectionDigest, ""), + lookupByKey: map[string]int{}, + lookupByID: map[string]int{}, + } +} + +func loadRegistry(referenceContent []byte) (*Registry, error) { + codec := itemcodec.New() + value, err := codec.Decode(referenceContent) + if err != nil { + return nil, fmt.Errorf("decode item registry: invalid approved item JSON") + } + if issues := identity.ValidateList(value); len(issues) > 0 { + return nil, fmt.Errorf("%s", formatIdentityIssues(issues)) + } + content, err := codec.Encode(value) + if err != nil { + return nil, fmt.Errorf("encode canonical item registry: approved item value could not be encoded") + } + + list := cloneItemRegistry(value) + lookupByKey := make(map[string]int, len(list.Items)) + lookupByID := make(map[string]int, len(list.Items)) + for index, item := range list.Items { + lookupByKey[identity.ComparisonKey(item.Name)] = index + lookupByID[item.ID] = index + } + projection, err := promptProjection(list) + if err != nil { + return nil, fmt.Errorf("encode item registry projection: %w", err) + } + projectionDigest := semanticDigest(projection) + return &Registry{ + bound: true, + list: list, + canonical: append([]byte(nil), content...), + digest: semanticDigest(content), + projectionDigest: projectionDigest, + promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, itemcodec.MediaType, projection, projectionDigest, ""), + lookupByKey: lookupByKey, + lookupByID: lookupByID, + }, nil +} + +// Bound reports whether an item reference was supplied and validated. +func (r *Registry) Bound() bool { return r != nil && r.bound } + +// Items returns a defensive copy of the validated item records. +func (r *Registry) Items() []dnd.Item { + if r == nil { + return nil + } + return cloneItems(r.list.Items) +} + +// List returns a defensive copy of the validated item registry. +func (r *Registry) List() dnd.ItemRegistry { + if r == nil { + return dnd.ItemRegistry{} + } + return cloneItemRegistry(r.list) +} + +// CanonicalBytes returns a defensive copy of the canonical durable JSON. +func (r *Registry) CanonicalBytes() []byte { + if r == nil { + return nil + } + return append([]byte(nil), r.canonical...) +} + +// Digest returns the semantic SHA-256 digest of the canonical JSON, or an +// empty string when the registry is unbound. +func (r *Registry) Digest() string { + if r == nil { + return "" + } + return r.digest +} + +// ProjectionDigest returns the SHA-256 digest of the exact ID/name prompt +// projection, including for an unbound or empty registry. +func (r *Registry) ProjectionDigest() string { + if r == nil { + return "" + } + return r.projectionDigest +} + +// Count returns the number of validated item records. +func (r *Registry) Count() int { + if r == nil { + return 0 + } + return len(r.list.Items) +} + +// PromptInput returns the ordered ID/name registry projection as a content-safe +// prompt input. Evidence and reference provenance are omitted. +func (r *Registry) PromptInput() contracts.LLMInputMaterial { + if r == nil { + return contracts.LLMInputMaterial{} + } + return r.promptInput.Clone() +} + +// Lookup returns the canonical item for an exact canonical-name match under +// the item identity comparison policy. +func (r *Registry) Lookup(value string) (dnd.Item, bool) { + if r == nil { + return dnd.Item{}, false + } + index, ok := r.lookupByKey[identity.ComparisonKey(value)] + if !ok { + return dnd.Item{}, false + } + return cloneItem(r.list.Items[index]), true +} + +// LookupID returns the canonical item for an exact durable ID. +func (r *Registry) LookupID(value string) (dnd.Item, bool) { + if r == nil { + return dnd.Item{}, false + } + index, ok := r.lookupByID[value] + if !ok { + return dnd.Item{}, false + } + return cloneItem(r.list.Items[index]), true +} + +func semanticDigest(content []byte) string { + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +type projectedItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type projectedItemRegistry struct { + Items []projectedItem `json:"items"` +} + +func promptProjection(list dnd.ItemRegistry) ([]byte, error) { + projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))} + for index, item := range list.Items { + projection.Items[index] = projectedItem{ID: item.ID, Name: item.Name} + } + return json.Marshal(projection) +} + +func formatIdentityIssues(issues []identity.Issue) string { + parts := make([]string, len(issues)) + for index, issue := range issues { + parts[index] = fmt.Sprintf("%s at record %d", issue.Code, issue.RecordIndex) + } + return diagnostics.Aggregate("validate item registry identity", parts) +} + +func cloneItemRegistry(value dnd.ItemRegistry) dnd.ItemRegistry { + return dnd.ItemRegistry{Items: cloneItems(value.Items)} +} + +func cloneItems(values []dnd.Item) []dnd.Item { + if values == nil { + return nil + } + cloned := make([]dnd.Item, len(values)) + for index, value := range values { + cloned[index] = cloneItem(value) + } + return cloned +} + +func cloneItem(value dnd.Item) dnd.Item { + value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) + return value +} diff --git a/internal/modules/dnd/items/registry/registry_test.go b/internal/modules/dnd/items/registry/registry_test.go new file mode 100644 index 0000000..7947ba9 --- /dev/null +++ b/internal/modules/dnd/items/registry/registry_test.go @@ -0,0 +1,132 @@ +package registry + +import ( + "strings" + "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" + itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" +) + +func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) { + registry, err := Resolve(contracts.ReferenceSet{}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + input := registry.PromptInput() + if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { + t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input) + } + if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { + t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input) + } +} + +func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) { + registry := resolveRegistry(t, fixture()) + if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 { + t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count()) + } + want := `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key"},{"id":"` + identity.DeriveID("Healer's Kit") + `","name":"Healer's Kit"}]}` + if got := string(registry.PromptInput().Content); got != want { + t.Fatalf("prompt projection = %s, want %s", got, want) + } + for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} { + if strings.Contains(string(registry.PromptInput().Content), forbidden) { + t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content) + } + } + if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() { + t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest()) + } +} + +func TestRegistryAccessorsAndLookupsAreDefensive(t *testing.T) { + registry := resolveRegistry(t, fixture()) + if item, ok := registry.Lookup(" SILVER\u2003key "); !ok || item.Name != "Silver Key" { + t.Fatalf("Lookup() = %#v, %t", item, ok) + } + if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" { + t.Fatalf("LookupID() = %#v, %t", item, ok) + } + + items := registry.Items() + items[0].Name = "changed" + items[0].SourceRefs[0].SourceID = "changed" + list := registry.List() + list.Items[1].Name = "changed" + content := registry.CanonicalBytes() + content[0] = '[' + input := registry.PromptInput() + input.Content[0] = '[' + if next := registry.Items()[0]; next.Name != "Silver Key" || next.SourceRefs[0].SourceID != "session-alpha" { + t.Fatalf("registry mutated through accessor: %#v", next) + } + if registry.List().Items[1].Name != "Healer's Kit" || registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' { + t.Fatal("registry bytes or records mutated through accessor") + } +} + +func TestResolveRejectsInvalidIdentityAndMalformedInput(t *testing.T) { + duplicate := fixture() + duplicate.Items = append(duplicate.Items, duplicate.Items[0]) + for _, references := range []contracts.ReferenceSet{ + referenceSet(contracts.ReferenceItem{MediaType: itemcodec.MediaType, Content: []byte(`{"items":[`)}), + referenceSet(contracts.ReferenceItem{MediaType: "text/plain", Content: []byte(`{"items":[]}`)}), + listReferenceSet(t, duplicate), + } { + if _, err := Resolve(references); err == nil { + t.Fatalf("Resolve(%#v) error = nil", references) + } + } +} + +func TestResolverKeepsConstructionAndOperationReferencesIndependent(t *testing.T) { + resolver, err := NewResolver(contracts.ReferenceSet{}) + if err != nil || resolver.Seeded().Bound() { + t.Fatalf("NewResolver() = %#v, %v", resolver, err) + } + + valid := listReferenceSet(t, fixture()) + resolved, err := resolver.Resolve(valid) + if err != nil || resolved.Count() != 2 { + t.Fatalf("Resolve() = %#v, %v", resolved, err) + } + content := valid.Slots[ReferenceSlot].Items[0].Content + content[0] = '[' + if resolved.CanonicalBytes()[0] != '{' { + t.Fatal("registry retained mutable operation reference content") + } +} + +func fixture() dnd.ItemRegistry { + return dnd.ItemRegistry{Items: []dnd.Item{ + {ID: identity.DeriveID("Silver Key"), Name: "Silver Key", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}}, + {ID: identity.DeriveID("Healer's Kit"), Name: "Healer's Kit", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, + }} +} + +func resolveRegistry(t *testing.T, value dnd.ItemRegistry) *Registry { + t.Helper() + registry, err := Resolve(listReferenceSet(t, value)) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return registry +} + +func listReferenceSet(t *testing.T, value dnd.ItemRegistry) contracts.ReferenceSet { + t.Helper() + content, err := itemcodec.New().Encode(value) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + return referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: itemcodec.MediaType, Content: content}) +} + +func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet { + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}} +} diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 674fa91..ccce378 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -18,6 +18,8 @@ const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-l const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list" +const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry" + const EnemyEventListKind contracts.ArtifactKind = "dnd/enemy-event-list" const LocationRegistryKind contracts.ArtifactKind = "dnd/location-registry" @@ -130,6 +132,16 @@ type ItemEvent struct { SourceRefs []source.SourceRef `json:"source_refs"` } +type ItemRegistry struct { + Items []Item `json:"items"` +} + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + type EnemyEventKind string const (