From 9653e0629774276741d4a222f9ee4e52561a403a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 5 Aug 2026 18:25:04 +0000 Subject: [PATCH] Migrate NPC registry durable contract --- docs/config.md | 2 +- docs/integrations/dnd-npc-artifacts.md | 22 +++++---- internal/cli/dnd_npc_contract_test.go | 10 ++-- internal/cli/production_contract_test.go | 6 +-- .../framework/contracts/contracts_test.go | 4 +- .../dnd/codec/itemevents/codec_test.go | 2 +- .../dnd/codec/npcinteractions/codec_test.go | 2 +- ..._npcs.v1.json => dnd_npc_registry.v1.json} | 2 +- internal/modules/dnd/codec/npcs/codec.go | 34 +++++++------- internal/modules/dnd/codec/npcs/codec_test.go | 18 ++++---- ..._npcs.v1.json => dnd_npc_registry.v1.json} | 2 +- .../dnd/extract/combatturns/extractor.go | 2 +- .../dnd/extract/combatturns/extractor_test.go | 2 +- .../dnd/extract/enemyevents/grounding.go | 2 +- .../dnd/extract/enemyevents/grounding_test.go | 4 +- .../dnd/extract/npcinteractions/extractor.go | 2 +- .../extract/npcinteractions/extractor_test.go | 6 +-- .../modules/dnd/extract/npcs/canonicalize.go | 6 +-- .../modules/dnd/extract/npcs/extractor.go | 18 ++++---- .../dnd/extract/npcs/extractor_test.go | 4 +- .../modules/dnd/extract/npcs/registry_test.go | 6 +-- .../modules/dnd/extract/spells/extractor.go | 2 +- .../spells/npc_registry_wiring_test.go | 4 +- .../dnd/extract/spells/registry_test.go | 2 +- .../dnd/normalize/combatturns/normalizer.go | 2 +- .../normalize/combatturns/normalizer_test.go | 2 +- .../dnd/normalize/enemyevents/normalizer.go | 2 +- .../normalize/enemyevents/normalizer_test.go | 2 +- .../normalize/npcinteractions/normalizer.go | 2 +- .../npcinteractions/normalizer_test.go | 4 +- .../modules/dnd/normalize/npcs/normalizer.go | 40 ++++++++-------- .../dnd/normalize/npcs/normalizer_test.go | 22 ++++----- .../npcs/semantic_normalizer_test.go | 20 ++++---- .../modules/dnd/npcs/identity/identity.go | 13 ++++-- .../dnd/npcs/identity/identity_test.go | 12 +++++ .../modules/dnd/npcs/registry/registry.go | 22 ++++----- .../dnd/npcs/registry/registry_test.go | 8 ++-- internal/modules/dnd/register/evidence.go | 4 +- internal/modules/dnd/register/merge.go | 6 +-- internal/modules/dnd/register/modules.go | 4 +- .../modules/dnd/register/register_test.go | 46 +++++++++---------- internal/modules/dnd/register/validators.go | 4 +- internal/modules/dnd/types.go | 4 +- .../enemyevents/invariants/validator_test.go | 2 +- .../invariants/validator_test.go | 2 +- .../registry/validator_test.go | 2 +- .../dnd/validate/npcs/identity/validator.go | 6 +-- .../validate/npcs/identity/validator_test.go | 14 +++--- .../dnd/validate/npcs/shape/validator.go | 10 ++-- .../dnd/validate/npcs/shape/validator_test.go | 18 ++++---- .../validate/npcs/source_refs/validator.go | 6 +-- .../npcs/source_refs/validator_test.go | 16 +++---- .../npcs/source_relatedness/validator.go | 6 +-- .../npcs/source_relatedness/validator_test.go | 24 +++++----- .../integration/dnd_combat_runner_test.go | 2 +- .../integration/dnd_npc_grounded_test.go | 2 +- 56 files changed, 256 insertions(+), 237 deletions(-) rename internal/modules/dnd/codec/npcs/assets/schemas/{dnd_npcs.v1.json => dnd_npc_registry.v1.json} (97%) rename internal/modules/dnd/codec/npcs/testdata/{dnd_npcs.v1.json => dnd_npc_registry.v1.json} (62%) diff --git a/docs/config.md b/docs/config.md index 15ef272..0eb3021 100644 --- a/docs/config.md +++ b/docs/config.md @@ -383,7 +383,7 @@ binding contracts. Durable semantics and wire shapes remain in their | Slot | Accepted artifact kind | Media type | Maximum size | Required stage | | --- | --- | --- | --- | --- | -| `npcs` | `dnd/npc-list` | `application/json` | 1,048,576 bytes | extract and normalize | +| `npcs` | `dnd/npc-registry` | `application/json` | 1,048,576 bytes | extract and normalize | | `scene_descriptions` | `dnd/scene-description-list` | `application/json` | 1,048,576 bytes | extract only | | `combat_turns` | `dnd/combat-turn-list` | `application/json` | 1,048,576 bytes | extract only | | `npc_interactions` | `dnd/npc-interaction-list` | `application/json` | 1,048,576 bytes | extract only | diff --git a/docs/integrations/dnd-npc-artifacts.md b/docs/integrations/dnd-npc-artifacts.md index 87f5d14..18aa1b8 100644 --- a/docs/integrations/dnd-npc-artifacts.md +++ b/docs/integrations/dnd-npc-artifacts.md @@ -1,4 +1,4 @@ -# D&D NPC Artifact +# D&D NPC Registry Artifact This contract defines the durable NPC registry produced by `dnd/npcs`. It is a minimal, source-grounded identity registry for other D&D artifacts, not a @@ -8,12 +8,12 @@ character sheet or a relationship summary. | Property | Value | | --- | --- | -| Artifact kind | `dnd/npc-list` | -| Schema ID | `notarius.dnd.npcs` | -| Schema name | `notarius_dnd_npcs_v1` | +| Artifact kind | `dnd/npc-registry` | +| Schema ID | `notarius.dnd.npc_registry` | +| Schema name | `notarius_dnd_npc_registry_v1` | | Schema version | `v1` | | Media type | `application/json` | -| Identity policy | `dnd.npcs.identity.v1` | +| Identity policy | `dnd.npc_registry.identity.v1` | `v1` accepts one strict JSON object with required `npcs`; the array may be empty. NPC and source-reference objects reject unknown fields. An incompatible @@ -37,7 +37,7 @@ identifiers, and the start may not follow the end. { "npcs": [ { - "id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7", + "id": "npc:sha256:35ba5f679aee69e07ae3bd65c44278f29539d5dc9bb5225db1c0060555b23221", "name": "Mira Thorn", "source_refs": [ {"source_id": "session-7", "start_unit_id": 4, "end_unit_id": 5} @@ -48,10 +48,12 @@ identifiers, and the start may not follow the end. ``` The ID is deterministic: normalize the name to Unicode NFKC, normalize the -supported apostrophe forms, collapse whitespace, case-fold it, SHA-256 the -result, then prefix the lowercase hexadecimal digest with `npc:sha256:`. Each -canonical identity and ID appears at most once. Normalization collapses records -with the same canonical identity, retains their earliest position, and merges +supported apostrophe forms, collapse whitespace, case-fold it, then serialize +`["dnd.npc_registry.identity.v1", comparison_name]` as compact JSON. SHA-256 +those UTF-8 bytes and prefix the lowercase hexadecimal digest with +`npc:sha256:`. Each canonical identity and ID appears at most once. +Normalization collapses records with the same canonical identity, retains their +earliest position, and merges their canonicalized evidence; it does not add aliases, roles, descriptions, or relationship fields. diff --git a/internal/cli/dnd_npc_contract_test.go b/internal/cli/dnd_npc_contract_test.go index 9437b7c..46f5d40 100644 --- a/internal/cli/dnd_npc_contract_test.go +++ b/internal/cli/dnd_npc_contract_test.go @@ -28,7 +28,7 @@ func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) { t.Fatalf("artifact lanes = %#v, want one NPC lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes) } lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0] - if lane.ID != "npcs" || lane.ArtifactKind != dnd.NPCListKind || lane.Extract.Module != npcextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != npcnormalize.Key { + if lane.ID != "npcs" || lane.ArtifactKind != dnd.NPCRegistryKind || lane.Extract.Module != npcextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != npcnormalize.Key { t.Fatalf("resolved NPC lane = %#v, want typed production composition", lane) } if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 { @@ -39,16 +39,16 @@ func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) { if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.npcs"}) { t.Fatalf("NPC extractor spec = %#v, want source and artifact capabilities", extractSpec) } - mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.NPCListKind) + mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.NPCRegistryKind) if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) { t.Fatalf("NPC merger spec = %#v, want merged capability", mergeSpec) } - normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(npcnormalize.Key, dnd.NPCListKind) + normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(npcnormalize.Key, dnd.NPCRegistryKind) if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) { t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec) } - codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind) - if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion { + codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCRegistryKind) + if !ok || codecSpec.Kind != dnd.NPCRegistryKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion { t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec) } diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index c3c62fa..cbe9a13 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -76,9 +76,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "generic/valid_json", "generic/valid_json_schema", }) - assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) diff --git a/internal/framework/contracts/contracts_test.go b/internal/framework/contracts/contracts_test.go index 04f592d..e525a14 100644 --- a/internal/framework/contracts/contracts_test.go +++ b/internal/framework/contracts/contracts_test.go @@ -90,10 +90,10 @@ func TestCloneReferenceSlotsCopiesAcceptedMediaTypes(t *testing.T) { } func TestCloneReferenceSlotsCopiesAcceptedArtifactKinds(t *testing.T) { - slots := []ReferenceSlot{{Name: "npcs", AcceptedArtifactKinds: []ArtifactKind{"dnd/npc-list"}}} + slots := []ReferenceSlot{{Name: "npcs", AcceptedArtifactKinds: []ArtifactKind{"dnd/npc-registry"}}} clone := CloneReferenceSlots(slots) clone[0].AcceptedArtifactKinds[0] = "changed" - if slots[0].AcceptedArtifactKinds[0] != "dnd/npc-list" { + if slots[0].AcceptedArtifactKinds[0] != "dnd/npc-registry" { t.Fatalf("source AcceptedArtifactKinds aliased clone: %#v", slots[0].AcceptedArtifactKinds) } } diff --git a/internal/modules/dnd/codec/itemevents/codec_test.go b/internal/modules/dnd/codec/itemevents/codec_test.go index 7066247..b542ec4 100644 --- a/internal/modules/dnd/codec/itemevents/codec_test.go +++ b/internal/modules/dnd/codec/itemevents/codec_test.go @@ -53,7 +53,7 @@ func TestCodecRoundTripAndIdentities(t *testing.T) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } - if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCList{}); err == nil { + if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCRegistry{}); err == nil { t.Fatal("Encode() error = nil, want exact type rejection") } else { var typeErr *pipeline.ArtifactCodecTypeError diff --git a/internal/modules/dnd/codec/npcinteractions/codec_test.go b/internal/modules/dnd/codec/npcinteractions/codec_test.go index 4577d76..51e0c65 100644 --- a/internal/modules/dnd/codec/npcinteractions/codec_test.go +++ b/internal/modules/dnd/codec/npcinteractions/codec_test.go @@ -71,7 +71,7 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } - if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCList{}); err == nil { + if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCRegistry{}); err == nil { t.Fatal("Encode() error = nil, want exact type rejection") } else { var typeErr *pipeline.ArtifactCodecTypeError diff --git a/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json b/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npc_registry.v1.json similarity index 97% rename from internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json rename to internal/modules/dnd/codec/npcs/assets/schemas/dnd_npc_registry.v1.json index b095116..0d18a91 100644 --- a/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npcs.v1.json +++ b/internal/modules/dnd/codec/npcs/assets/schemas/dnd_npc_registry.v1.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "notarius.dnd.npcs", + "$id": "notarius.dnd.npc_registry", "type": "object", "additionalProperties": false, "required": ["npcs"], diff --git a/internal/modules/dnd/codec/npcs/codec.go b/internal/modules/dnd/codec/npcs/codec.go index 0e66fbb..21854d6 100644 --- a/internal/modules/dnd/codec/npcs/codec.go +++ b/internal/modules/dnd/codec/npcs/codec.go @@ -12,25 +12,25 @@ import ( ) const ( - SchemaID = "notarius.dnd.npcs" - SchemaName = "notarius_dnd_npcs_v1" + SchemaID = "notarius.dnd.npc_registry" + SchemaName = "notarius_dnd_npc_registry_v1" SchemaVersion = "v1" MediaType = "application/json" ) -//go:embed assets/schemas/dnd_npcs.v1.json +//go:embed assets/schemas/dnd_npc_registry.v1.json var schemaAssets embed.FS -var _ contracts.ArtifactCodec[dnd.NPCList] = (*Codec)(nil) +var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil) type Codec struct{} func New() *Codec { return &Codec{} } -func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCListKind } +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCRegistryKind } func (c *Codec) Schema() contracts.ArtifactSchema { - raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npcs.v1.json") + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_registry.v1.json") if err != nil { return contracts.ArtifactSchema{} } @@ -44,41 +44,41 @@ func (c *Codec) Schema() contracts.ArtifactSchema { func (c *Codec) MediaType() string { return MediaType } -func (c *Codec) Metadata(value dnd.NPCList) map[string]any { +func (c *Codec) Metadata(value dnd.NPCRegistry) map[string]any { return map[string]any{"npc_count": len(value.NPCs)} } -func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) { +func (c *Codec) Encode(value dnd.NPCRegistry) ([]byte, error) { if err := validate(value); err != nil { - return nil, fmt.Errorf("encode dnd npc list: %w", err) + return nil, fmt.Errorf("encode dnd npc 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.NPCList) ([]byte, error) { - return candidatejson.EncodeCandidate("dnd npc list", value) +func (c *Codec) EncodeCandidate(value dnd.NPCRegistry) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd npc registry", value) } -func (c *Codec) Decode(content []byte) (dnd.NPCList, error) { +func (c *Codec) Decode(content []byte) (dnd.NPCRegistry, error) { value, err := c.DecodeCandidate(content) if err != nil { - return dnd.NPCList{}, err + return dnd.NPCRegistry{}, err } if err := validate(value); err != nil { - return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err) + return dnd.NPCRegistry{}, fmt.Errorf("decode dnd npc 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.NPCList, error) { - return candidatejson.DecodeCandidate[dnd.NPCList]("dnd npc list", content) +func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCRegistry, error) { + return candidatejson.DecodeCandidate[dnd.NPCRegistry]("dnd npc registry", content) } -func validate(value dnd.NPCList) error { +func validate(value dnd.NPCRegistry) error { if value.NPCs == nil { return fmt.Errorf("npcs must be present") } diff --git a/internal/modules/dnd/codec/npcs/codec_test.go b/internal/modules/dnd/codec/npcs/codec_test.go index 177578d..81d6ac6 100644 --- a/internal/modules/dnd/codec/npcs/codec_test.go +++ b/internal/modules/dnd/codec/npcs/codec_test.go @@ -15,8 +15,8 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) -func validList() dnd.NPCList { - return dnd.NPCList{NPCs: []dnd.NPC{{ +func validList() dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, @@ -24,7 +24,7 @@ func validList() dnd.NPCList { } func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { - raw, err := os.ReadFile("testdata/dnd_npcs.v1.json") + raw, err := os.ReadFile("testdata/dnd_npc_registry.v1.json") if err != nil { t.Fatalf("read durable fixture: %v", err) } @@ -53,7 +53,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { codec := New() schema := codec.Schema() - if codec.Kind() != dnd.NPCListKind || codec.MediaType() != MediaType { + if codec.Kind() != dnd.NPCRegistryKind || codec.MediaType() != MediaType { t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) } if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { @@ -67,7 +67,7 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v", err) } - spec, ok := registry.Spec(dnd.NPCListKind) + spec, ok := registry.Spec(dnd.NPCRegistryKind) if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } @@ -98,7 +98,7 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) { func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { codec := New() - candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{}}}} + candidate := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{}}}} content, err := codec.EncodeCandidate(candidate) if err != nil || !json.Valid(content) { t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) @@ -116,11 +116,11 @@ func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) { base := validList().NPCs[0] tests := []struct { name string - value dnd.NPCList + value dnd.NPCRegistry want string }{ - {name: "blank name", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, want: "name must not be empty"}, - {name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"}, + {name: "blank name", value: dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, want: "name must not be empty"}, + {name: "empty source refs", value: dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json b/internal/modules/dnd/codec/npcs/testdata/dnd_npc_registry.v1.json similarity index 62% rename from internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json rename to internal/modules/dnd/codec/npcs/testdata/dnd_npc_registry.v1.json index 7975106..b8972de 100644 --- a/internal/modules/dnd/codec/npcs/testdata/dnd_npcs.v1.json +++ b/internal/modules/dnd/codec/npcs/testdata/dnd_npc_registry.v1.json @@ -1,7 +1,7 @@ { "npcs": [ { - "id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7", + "id": "npc:sha256:35ba5f679aee69e07ae3bd65c44278f29539d5dc9bb5225db1c0060555b23221", "name": "Mira Thorn", "source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}] } diff --git a/internal/modules/dnd/extract/combatturns/extractor.go b/internal/modules/dnd/extract/combatturns/extractor.go index f64483c..ebe955a 100644 --- a/internal/modules/dnd/extract/combatturns/extractor.go +++ b/internal/modules/dnd/extract/combatturns/extractor.go @@ -48,7 +48,7 @@ func referenceSlots() []contracts.ReferenceSlot { Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical actor grounding.", AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }) slots = append(slots, contracts.ReferenceSlot{ diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go index fdae5c5..3840061 100644 --- a/internal/modules/dnd/extract/combatturns/extractor_test.go +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -519,7 +519,7 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references func npcRegistryJSON(t *testing.T) []byte { t.Helper() - value := dnd.NPCList{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}} + value := dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}} content, err := npccodec.New().Encode(value) if err != nil { t.Fatalf("encode NPC registry: %v", err) diff --git a/internal/modules/dnd/extract/enemyevents/grounding.go b/internal/modules/dnd/extract/enemyevents/grounding.go index dcdac9a..52871bf 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding.go +++ b/internal/modules/dnd/extract/enemyevents/grounding.go @@ -47,7 +47,7 @@ func referenceSlots() []contracts.ReferenceSlot { Description: "Required normalized NPC registry used only for enemy-subject grounding, never as event evidence.", Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: ReferenceMaxBytes, }, contracts.ReferenceSlot{ diff --git a/internal/modules/dnd/extract/enemyevents/grounding_test.go b/internal/modules/dnd/extract/enemyevents/grounding_test.go index b240e7e..ead573e 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding_test.go +++ b/internal/modules/dnd/extract/enemyevents/grounding_test.go @@ -33,7 +33,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) { name string kind contracts.ArtifactKind }{ - {NPCRegistryReferenceSlot, dnd.NPCListKind}, + {NPCRegistryReferenceSlot, dnd.NPCRegistryKind}, {SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind}, {CombatTurnReferenceSlot, dnd.CombatTurnListKind}, {NPCInteractionReferenceSlot, dnd.NPCInteractionListKind}, @@ -195,7 +195,7 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) { func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet { t.Helper() - npcContent, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{{ + npcContent, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: identity.DeriveID(enemy), Name: enemy, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}, diff --git a/internal/modules/dnd/extract/npcinteractions/extractor.go b/internal/modules/dnd/extract/npcinteractions/extractor.go index 5c01564..410b18c 100644 --- a/internal/modules/dnd/extract/npcinteractions/extractor.go +++ b/internal/modules/dnd/extract/npcinteractions/extractor.go @@ -45,7 +45,7 @@ func referenceSlots() []contracts.ReferenceSlot { Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }) sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name }) diff --git a/internal/modules/dnd/extract/npcinteractions/extractor_test.go b/internal/modules/dnd/extract/npcinteractions/extractor_test.go index 979e073..80a168d 100644 --- a/internal/modules/dnd/extract/npcinteractions/extractor_test.go +++ b/internal/modules/dnd/extract/npcinteractions/extractor_test.go @@ -181,7 +181,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) { } func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) { - content, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{}}) + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}}) if err != nil { t.Fatal(err) } @@ -239,7 +239,7 @@ func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) { registrySlot = slot } } - if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes { + if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCRegistryKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes { t.Fatalf("NPC registry slot = %#v", registrySlot) } got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed" @@ -326,7 +326,7 @@ func requiredRegistryReferences(t *testing.T, names ...string) contracts.Referen SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}, } } - content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs}) + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs}) if err != nil { t.Fatal(err) } diff --git a/internal/modules/dnd/extract/npcs/canonicalize.go b/internal/modules/dnd/extract/npcs/canonicalize.go index fc8e706..4d88d68 100644 --- a/internal/modules/dnd/extract/npcs/canonicalize.go +++ b/internal/modules/dnd/extract/npcs/canonicalize.go @@ -51,9 +51,9 @@ func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID str return order.EarliestValid(refs) } -func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList { +func canonicalNPCRegistry(response extractionResponse, sourceID string) dnd.NPCRegistry { if response.NPCs == nil { - return dnd.NPCList{NPCs: nil} + return dnd.NPCRegistry{NPCs: nil} } npcs := make([]dnd.NPC, len(response.NPCs)) for index, npc := range response.NPCs { @@ -63,7 +63,7 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID), } } - return dnd.NPCList{NPCs: npcs} + return dnd.NPCRegistry{NPCs: npcs} } func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef { diff --git a/internal/modules/dnd/extract/npcs/extractor.go b/internal/modules/dnd/extract/npcs/extractor.go index e6f02ea..6a67d0b 100644 --- a/internal/modules/dnd/extract/npcs/extractor.go +++ b/internal/modules/dnd/extract/npcs/extractor.go @@ -35,7 +35,7 @@ func referenceSlots() []contracts.ReferenceSlot { return shared.ReferenceSlots(referenceSlotDescriptions) } -var _ contracts.Extractor[dnd.NPCList] = (*Extractor)(nil) +var _ contracts.Extractor[dnd.NPCRegistry] = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) @@ -103,16 +103,16 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } } -func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCList], error) { +func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCRegistry], error) { if e == nil { - return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("extractor must not be nil") + return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("extractor must not be nil") } if e.llm == nil { - return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil") + return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("LLM client must not be nil") } sourceInput, err := shared.PrepareChunkExtraction(ctx, req) if err != nil { - return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err) + return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("%w", err) } order := shared.NewSourceRefOrder(req.Source) @@ -125,10 +125,10 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe SessionID: req.SessionID, Inputs: shared.PromptInputs(sourceInput, req.References), }, &response); err != nil { - return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("complete structured output: %w", err) + return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("complete structured output: %w", err) } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.NPCRegistry]{Value: canonicalNPCRegistry(response, req.Source.ID)}, nil } func ModuleSpec() pipeline.ModuleSpec { @@ -138,13 +138,13 @@ func ModuleSpec() pipeline.ModuleSpec { ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), - ArtifactKind: dnd.NPCListKind, + ArtifactKind: dnd.NPCRegistryKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.ExtractorRegistry) error { - return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCList], error) { + return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/extract/npcs/extractor_test.go b/internal/modules/dnd/extract/npcs/extractor_test.go index ef1a2be..35a199e 100644 --- a/internal/modules/dnd/extract/npcs/extractor_test.go +++ b/internal/modules/dnd/extract/npcs/extractor_test.go @@ -13,7 +13,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) -func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) { +func TestExtractReturnsCanonicalNPCRegistryFromPrivateResponse(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ { Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3), @@ -32,7 +32,7 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) { if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } - want := dnd.NPCList{NPCs: []dnd.NPC{ + want := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}}, {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, }} diff --git a/internal/modules/dnd/extract/npcs/registry_test.go b/internal/modules/dnd/extract/npcs/registry_test.go index bd81431..b12a1a6 100644 --- a/internal/modules/dnd/extract/npcs/registry_test.go +++ b/internal/modules/dnd/extract/npcs/registry_test.go @@ -30,7 +30,7 @@ func TestModuleSpecAndReferenceSlots(t *testing.T) { ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.npcs"}, - ArtifactKind: dnd.NPCListKind, + ArtifactKind: dnd.NPCRegistryKind, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "glossary", Description: "Optional campaign glossary reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "party", Description: "Optional party roster reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, @@ -77,7 +77,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) { "prompt_id": PromptID, "prompt_version": SchemaVersion, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, - "identity_policy": "dnd.npcs.identity.v1", + "identity_policy": "dnd.npc_registry.identity.v1", "mapping_policy": mappingPolicy, } { if metadata[key] != want { @@ -90,7 +90,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) { } } fingerprints := extractor.CheckpointFingerprints() - want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy} + want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npc_registry.identity.v1", "mapping_policy": mappingPolicy} if len(fingerprints) != len(want) { t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want)) } diff --git a/internal/modules/dnd/extract/spells/extractor.go b/internal/modules/dnd/extract/spells/extractor.go index db5cdf0..94f0dcf 100644 --- a/internal/modules/dnd/extract/spells/extractor.go +++ b/internal/modules/dnd/extract/spells/extractor.go @@ -50,7 +50,7 @@ func referenceSlots() []contracts.ReferenceSlot { Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical caster-name grounding.", AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }) sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name }) diff --git a/internal/modules/dnd/extract/spells/npc_registry_wiring_test.go b/internal/modules/dnd/extract/spells/npc_registry_wiring_test.go index 268b64e..ed2f2e8 100644 --- a/internal/modules/dnd/extract/spells/npc_registry_wiring_test.go +++ b/internal/modules/dnd/extract/spells/npc_registry_wiring_test.go @@ -107,8 +107,8 @@ func TestSpellExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *t } } -func registryFixture() dnd.NPCList { - return dnd.NPCList{NPCs: []dnd.NPC{{ +func registryFixture() dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}}, diff --git a/internal/modules/dnd/extract/spells/registry_test.go b/internal/modules/dnd/extract/spells/registry_test.go index 2e944ae..3f069a5 100644 --- a/internal/modules/dnd/extract/spells/registry_test.go +++ b/internal/modules/dnd/extract/spells/registry_test.go @@ -44,7 +44,7 @@ func TestModuleSpec(t *testing.T) { Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical caster-name grounding.", AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }, { diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index 36c4bd7..b9606a4 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -327,7 +327,7 @@ func referenceSlots() []contracts.ReferenceSlot { Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical actor grounding.", AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }} } diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go index 838f37a..6d323c2 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer_test.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -310,7 +310,7 @@ func testDocument() *source.SourceDocument { func npcReferences(t *testing.T) contracts.ReferenceSet { t.Helper() - npcs := dnd.NPCList{NPCs: []dnd.NPC{ + npcs := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Aria"), Name: "Aria", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Goblin"), Name: "Goblin", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, }} diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer.go b/internal/modules/dnd/normalize/enemyevents/normalizer.go index ca47fe1..b526bf8 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer.go @@ -256,7 +256,7 @@ func referenceSlots() []contracts.ReferenceSlot { Description: "Required normalized NPC registry used only to canonicalize enemy-subject names, never as event evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }}) } diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go index ad4e6f4..e358cdc 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go @@ -183,7 +183,7 @@ func testDocument() *source.SourceDocument { func npcReferences(t *testing.T) contracts.ReferenceSet { t.Helper() - value := dnd.NPCList{NPCs: []dnd.NPC{{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: identity.DeriveID("Ária"), Name: "Ária", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}, }}} diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer.go b/internal/modules/dnd/normalize/npcinteractions/normalizer.go index df2d821..e6b3070 100644 --- a/internal/modules/dnd/normalize/npcinteractions/normalizer.go +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer.go @@ -267,7 +267,7 @@ func referenceSlots() []contracts.ReferenceSlot { Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }) sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name }) diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go index a25eed5..2611fac 100644 --- a/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go @@ -175,7 +175,7 @@ func testDocument() *source.SourceDocument { func npcReferences(t *testing.T) contracts.ReferenceSet { t.Helper() - value := dnd.NPCList{NPCs: []dnd.NPC{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Ária"), Name: "Ária", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Borin"), Name: "Borin", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, }} @@ -184,7 +184,7 @@ func npcReferences(t *testing.T) contracts.ReferenceSet { t.Fatalf("encode registry: %v", err) } return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: { - Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes}, + Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, }}} } diff --git a/internal/modules/dnd/normalize/npcs/normalizer.go b/internal/modules/dnd/normalize/npcs/normalizer.go index 13e3ba9..33b8fc3 100644 --- a/internal/modules/dnd/normalize/npcs/normalizer.go +++ b/internal/modules/dnd/normalize/npcs/normalizer.go @@ -39,7 +39,7 @@ const ( var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} -var _ contracts.Normalizer[dnd.NPCList] = (*Normalizer)(nil) +var _ contracts.Normalizer[dnd.NPCRegistry] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) @@ -102,18 +102,18 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } } -func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCList]) (contracts.TypedNormalizeResult[dnd.NPCList], error) { +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCRegistry]) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { if n == nil { - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil") + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("normalizer must not be nil") } if n.llm == nil { - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("LLM client must not be nil") + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("LLM client must not be nil") } if ctx == nil { - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil") + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err) + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err) } order := shared.NewSourceRefOrder(req.Source) @@ -121,10 +121,10 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize deterministic := recordList(records) materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius) if err != nil { - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("build semantic context: %w", err) + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic context: %w", err) } if !ready { - return contracts.TypedNormalizeResult[dnd.NPCList]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil } var response entityreconcile.ProposalResponse @@ -136,20 +136,20 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize if errors.Is(err, contracts.ErrInvalidStructuredOutput) { return n.invalidStructuredResult(deterministic, warnings), nil } - return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("complete structured output: %w", err) + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("complete structured output: %w", err) } assessment := materials.Assess(response) applied, semanticWarnings := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order) warnings = append(warnings, semanticWarnings...) if assessment.DiscardedGroups() == 0 { - return contracts.TypedNormalizeResult[dnd.NPCList]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil } return retryResult(recordList(applied), warnings, assessment), nil } -func (n *Normalizer) invalidStructuredResult(value dnd.NPCList, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCList] { - return contracts.TypedNormalizeResult[dnd.NPCList]{ +func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCRegistry] { + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{ Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{ @@ -160,8 +160,8 @@ func (n *Normalizer) invalidStructuredResult(value dnd.NPCList, warnings []contr } } -func retryResult(value dnd.NPCList, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.NPCList] { - return contracts.TypedNormalizeResult[dnd.NPCList]{ +func retryResult(value dnd.NPCRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.NPCRegistry] { + return contracts.TypedNormalizeResult[dnd.NPCRegistry]{ Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{ @@ -205,7 +205,7 @@ type normalizedRecord struct { earliest int } -func preprocessRecords(input dnd.NPCList, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { +func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { if input.NPCs == nil { return nil, nil } @@ -328,11 +328,11 @@ func recordValues(records []normalizedRecord) []dnd.NPC { return values } -func recordList(records []normalizedRecord) dnd.NPCList { +func recordList(records []normalizedRecord) dnd.NPCRegistry { if records == nil { - return dnd.NPCList{} + return dnd.NPCRegistry{} } - return dnd.NPCList{NPCs: recordValues(records)} + return dnd.NPCRegistry{NPCs: recordValues(records)} } func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { @@ -362,11 +362,11 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) } func ModuleSpec() pipeline.ModuleSpec { - return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCListKind} + return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCRegistryKind} } func Register(registry *pipeline.NormalizerRegistry) error { - return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCList], error) { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/normalize/npcs/normalizer_test.go b/internal/modules/dnd/normalize/npcs/normalizer_test.go index a9fc6e0..09189ea 100644 --- a/internal/modules/dnd/normalize/npcs/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcs/normalizer_test.go @@ -22,7 +22,7 @@ func TestModuleContractAndIdentity(t *testing.T) { if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown option") } - want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCListKind} + want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCRegistryKind} if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } @@ -44,7 +44,7 @@ func TestModuleContractAndIdentity(t *testing.T) { } func TestNormalizeNamesEvidenceAndIDs(t *testing.T) { - input := dnd.NPCList{NPCs: []dnd.NPC{{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: "wrong", Name: " Lady\tAsh ", SourceRefs: []source.SourceRef{ {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "a", StartUnitID: 4, EndUnitID: 4}, @@ -68,7 +68,7 @@ func TestNormalizeNamesEvidenceAndIDs(t *testing.T) { func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) { doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} - input := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Lady Ash", SourceRefs: []source.SourceRef{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Lady Ash", SourceRefs: []source.SourceRef{ {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}, @@ -84,7 +84,7 @@ func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) { } func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) { - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}}, {Name: "captain vale", SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 2, EndUnitID: 2}}}, {Name: "The Captain", SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 3, EndUnitID: 3}}}, @@ -105,8 +105,8 @@ func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) } func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) { - input := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}} - before := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}} + input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}} + before := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}} result, err := newNormalizer(t, &recordingNPCNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input)) if err != nil || !reflect.DeepEqual(input, before) { t.Fatalf("Normalize() = %#v, %v; input mutated to %#v", result, err, input) @@ -122,13 +122,13 @@ func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) { func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { normalizer := newNormalizer(t, &recordingNPCNormalizerClient{}) - result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil})) + result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil})) if err != nil || result.Value.NPCs != nil { t.Fatalf("nil list result = %#v, error = %v", result.Value, err) } canceled, cancel := context.WithCancel(context.Background()) cancel() - if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") { + if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCRegistry{})); err == nil || !strings.Contains(err.Error(), "context error") { t.Fatalf("canceled Normalize() error = %v", err) } } @@ -171,11 +171,11 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz return normalizer } -func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { - return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}} +func normalizeRequest(value dnd.NPCRegistry) contracts.TypedNormalizeRequest[dnd.NPCRegistry] { + return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value}} } -func normalizeRequestWithSource(value dnd.NPCList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCList] { +func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] { request := normalizeRequest(value) request.Source = doc return request diff --git a/internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go b/internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go index b64f638..8bdbe8d 100644 --- a/internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go +++ b/internal/modules/dnd/normalize/npcs/semantic_normalizer_test.go @@ -19,7 +19,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing client := &recordingNPCNormalizerClient{} normalizer := newNormalizer(t, client) doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}}} + input := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}}} result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) if err != nil || len(client.requests) != 0 || result.Retry != nil { t.Fatalf("Normalize() = %#v, %v; calls = %d, want deterministic no-call result", result, err, len(client.requests)) @@ -33,12 +33,12 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) { client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: "npc:sha256:short", Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {ID: "npc:sha256:long", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {ID: "npc:sha256:captain", Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, }} - before := cloneNPCList(input) + before := cloneNPCRegistry(input) request := normalizeRequestWithSource(input, doc) request.LLMProfile = "normalizer-profile" request.SessionID = "normalizer-session" @@ -76,7 +76,7 @@ func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) { client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, @@ -98,7 +98,7 @@ func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) { func TestNormalizeInvalidStructuredOutputAndOperationalErrorsRemainDistinct(t *testing.T) { doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, }} @@ -129,7 +129,7 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) { {ID: 10, Kind: "speech", Text: transcript, Metadata: map[string]any{metadataKey: math.NaN(), "value": metadataValue}}, {ID: 20, Kind: "speech", Text: "other context"}, }} - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: firstName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: secondName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, }} @@ -156,7 +156,7 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) { }} normalizer := newNormalizer(t, client) doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, @@ -178,7 +178,7 @@ func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) { client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() - input := dnd.NPCList{NPCs: []dnd.NPC{ + input := dnd.NPCRegistry{NPCs: []dnd.NPC{ {Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Broken", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}}, {Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, @@ -235,8 +235,8 @@ func semanticDocument() *source.SourceDocument { }} } -func cloneNPCList(input dnd.NPCList) dnd.NPCList { - output := dnd.NPCList{NPCs: make([]dnd.NPC, len(input.NPCs))} +func cloneNPCRegistry(input dnd.NPCRegistry) dnd.NPCRegistry { + output := dnd.NPCRegistry{NPCs: make([]dnd.NPC, len(input.NPCs))} for index, npc := range input.NPCs { output.NPCs[index] = cloneNPC(npc) } diff --git a/internal/modules/dnd/npcs/identity/identity.go b/internal/modules/dnd/npcs/identity/identity.go index 006b07e..31c4b90 100644 --- a/internal/modules/dnd/npcs/identity/identity.go +++ b/internal/modules/dnd/npcs/identity/identity.go @@ -5,6 +5,7 @@ package identity import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "strings" @@ -17,7 +18,7 @@ import ( const ( // Policy identifies the complete identity comparison and ID derivation // policy. A future semantic change must use a new value. - Policy = "dnd.npcs.identity.v1" + Policy = "dnd.npc_registry.identity.v1" // IdentityPolicy is an explicit alias for callers recording policy // fingerprints. IdentityPolicy = Policy @@ -72,7 +73,11 @@ func DeriveID(name string) string { if key == "" { return "" } - digest := sha256.Sum256([]byte(key)) + identity, err := json.Marshal([]string{Policy, key}) + if err != nil { + return "" + } + digest := sha256.Sum256(identity) return idPrefix + hex.EncodeToString(digest[:]) } @@ -98,7 +103,7 @@ func ValidID(value string) bool { return IsValidID(value) } // ValidateRegistry checks all identity invariants without changing the input. // It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList -// when the enclosing NPCList is more convenient at the call site. +// when the enclosing NPCRegistry is more convenient at the call site. func ValidateRegistry(npcs []dnd.NPC) []Issue { issues := make([]Issue, 0) canonicalOwners := make(map[string][]int) @@ -136,7 +141,7 @@ func ValidateRegistry(npcs []dnd.NPC) []Issue { } // ValidateList validates the identity members of list. -func ValidateList(list dnd.NPCList) []Issue { return ValidateRegistry(list.NPCs) } +func ValidateList(list dnd.NPCRegistry) []Issue { return ValidateRegistry(list.NPCs) } // Error makes an issue useful in simple callers while preserving its // structured fields for aggregate diagnostics. diff --git a/internal/modules/dnd/npcs/identity/identity_test.go b/internal/modules/dnd/npcs/identity/identity_test.go index 17d5fed..342fdeb 100644 --- a/internal/modules/dnd/npcs/identity/identity_test.go +++ b/internal/modules/dnd/npcs/identity/identity_test.go @@ -1,6 +1,7 @@ package identity import ( + "reflect" "strings" "sync" "testing" @@ -40,9 +41,16 @@ func TestNormalizeDisplayOnlyChangesWhitespace(t *testing.T) { func TestDeriveIDAndIDSyntax(t *testing.T) { got := DeriveID(" Mira\u2003Thorn ") + const want = "npc:sha256:35ba5f679aee69e07ae3bd65c44278f29539d5dc9bb5225db1c0060555b23221" + if Policy != "dnd.npc_registry.identity.v1" || IdentityPolicy != Policy { + t.Fatalf("identity policy = %q/%q", Policy, IdentityPolicy) + } if len(got) != len("npc:sha256:")+64 || !strings.HasPrefix(got, "npc:sha256:") || !IsValidID(got) { t.Fatalf("DeriveID() = %q, want exact NPC ID syntax", got) } + if got != want { + t.Fatalf("DeriveID() = %q, want SHA-256 of compact JSON [policy, comparison_name]", got) + } if got != DeriveID("Mira Thorn") || got != IDFor("Mira Thorn") { t.Fatal("DeriveID() is not deterministic across equivalent names") } @@ -80,7 +88,11 @@ func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) { {ID: validID, Name: "Mira Thorn"}, {ID: validID, Name: "Mira Thorn"}, } + before := append([]dnd.NPC(nil), npcs...) issues := ValidateRegistry(npcs) + if !reflect.DeepEqual(npcs, before) { + t.Fatal("ValidateRegistry() mutated its input") + } want := map[IssueCode]bool{ IssueDuplicateCanonical: false, IssueDuplicateID: false, diff --git a/internal/modules/dnd/npcs/registry/registry.go b/internal/modules/dnd/npcs/registry/registry.go index e0ecc9e..2d51083 100644 --- a/internal/modules/dnd/npcs/registry/registry.go +++ b/internal/modules/dnd/npcs/registry/registry.go @@ -27,7 +27,7 @@ const ( // grounding. All accessors return defensive copies. type Registry struct { bound bool - list dnd.NPCList + list dnd.NPCRegistry canonical []byte digest string projectionDigest string @@ -105,7 +105,7 @@ func emptyRegistry() *Registry { content := []byte(emptyPrompt) projectionDigest := semanticDigest(content) return &Registry{ - list: dnd.NPCList{NPCs: []dnd.NPC{}}, + list: dnd.NPCRegistry{NPCs: []dnd.NPC{}}, canonical: append([]byte(nil), content...), projectionDigest: projectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), @@ -127,7 +127,7 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { return nil, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded") } - list := cloneNPCList(value) + list := cloneNPCRegistry(value) lookupByKey := make(map[string]int, len(list.NPCs)) for index, npc := range list.NPCs { lookupByKey[identity.ComparisonKey(npc.Name)] = index @@ -161,11 +161,11 @@ func (r *Registry) NPCs() []dnd.NPC { } // List returns a defensive copy of the validated NPC list. -func (r *Registry) List() dnd.NPCList { +func (r *Registry) List() dnd.NPCRegistry { if r == nil { - return dnd.NPCList{} + return dnd.NPCRegistry{} } - return cloneNPCList(r.list) + return cloneNPCRegistry(r.list) } // CanonicalBytes returns a defensive copy of the canonical durable JSON. @@ -233,12 +233,12 @@ type projectedNPC struct { Name string `json:"name"` } -type projectedNPCList struct { +type projectedNPCRegistry struct { NPCs []projectedNPC `json:"npcs"` } -func nameProjection(list dnd.NPCList) ([]byte, error) { - projection := projectedNPCList{NPCs: make([]projectedNPC, len(list.NPCs))} +func nameProjection(list dnd.NPCRegistry) ([]byte, error) { + projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))} for index, npc := range list.NPCs { projection.NPCs[index] = projectedNPC{Name: npc.Name} } @@ -254,8 +254,8 @@ func formatIdentityIssues(issues []identity.Issue) string { return diagnostics.Aggregate("validate NPC registry identity", parts) } -func cloneNPCList(value dnd.NPCList) dnd.NPCList { - return dnd.NPCList{NPCs: cloneNPCs(value.NPCs)} +func cloneNPCRegistry(value dnd.NPCRegistry) dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: cloneNPCs(value.NPCs)} } func cloneNPCs(values []dnd.NPC) []dnd.NPC { diff --git a/internal/modules/dnd/npcs/registry/registry_test.go b/internal/modules/dnd/npcs/registry/registry_test.go index 3be019e..4648501 100644 --- a/internal/modules/dnd/npcs/registry/registry_test.go +++ b/internal/modules/dnd/npcs/registry/registry_test.go @@ -164,14 +164,14 @@ func TestResolverHandlesConstructionAndOperationReferences(t *testing.T) { } } -func registryFixture() dnd.NPCList { - return dnd.NPCList{NPCs: []dnd.NPC{ +func registryFixture() dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}}, {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, }} } -func resolveList(t *testing.T, list dnd.NPCList) *Registry { +func resolveList(t *testing.T, list dnd.NPCRegistry) *Registry { t.Helper() registry, err := Resolve(listReferenceSet(t, list)) if err != nil { @@ -180,7 +180,7 @@ func resolveList(t *testing.T, list dnd.NPCList) *Registry { return registry } -func listReferenceSet(t *testing.T, list dnd.NPCList) contracts.ReferenceSet { +func listReferenceSet(t *testing.T, list dnd.NPCRegistry) contracts.ReferenceSet { t.Helper() content, err := npccodec.New().Encode(list) if err != nil { diff --git a/internal/modules/dnd/register/evidence.go b/internal/modules/dnd/register/evidence.go index ffceafa..c2656b4 100644 --- a/internal/modules/dnd/register/evidence.go +++ b/internal/modules/dnd/register/evidence.go @@ -9,7 +9,7 @@ import ( func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error { return runRegistrations([]registration{ {name: "spells evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.SpellListKind, spellEvidence) }}, - {name: "npcs evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.NPCListKind, npcEvidence) }}, + {name: "npcs evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.NPCRegistryKind, npcEvidence) }}, {name: "combat turns evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.CombatTurnListKind, combatTurnEvidence) }}, @@ -42,7 +42,7 @@ func spellEvidence(value dnd.SpellList) []source.SourceRef { return append([]source.SourceRef(nil), refs...) } -func npcEvidence(value dnd.NPCList) []source.SourceRef { +func npcEvidence(value dnd.NPCRegistry) []source.SourceRef { var refs []source.SourceRef for _, record := range value.NPCs { refs = append(refs, record.SourceRefs...) diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index 1475543..b5fbea8 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -28,7 +28,7 @@ func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) { return combined, nil } -func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) { +func appendNPCRegistries(values []dnd.NPCRegistry) (dnd.NPCRegistry, error) { count := 0 present := false for _, value := range values { @@ -38,9 +38,9 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) { count += len(value.NPCs) } if !present { - return dnd.NPCList{}, nil + return dnd.NPCRegistry{}, nil } - combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)} + combined := dnd.NPCRegistry{NPCs: make([]dnd.NPC, 0, count)} for _, value := range values { for _, npc := range value.NPCs { combined.NPCs = append(combined.NPCs, cloneNPC(npc)) diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index b7dd6d5..ec28e85 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -67,7 +67,7 @@ func registerModules(registries pipeline.Registries) error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, {name: "npc-list appendorder merger", register: func() error { - return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists) + return appendorder.RegisterTyped(registries.Mergers, dnd.NPCRegistryKind, appendNPCRegistries) }}, {name: "combat-turn-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists) @@ -103,7 +103,7 @@ func registerModules(registries pipeline.Registries) error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, {name: "npc-list noop normalizer", register: func() error { - return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) + return noop.RegisterTyped[dnd.NPCRegistry](registries.Normalizers, dnd.NPCRegistryKind) }}, {name: "combat-turn-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind) diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 052f674..e0476cc 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -84,11 +84,11 @@ func TestRegisterAddsDNDFamily(t *testing.T) { assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key}) assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) @@ -316,10 +316,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Normalizers.Spec(spellnormalize.Key); !ok || spec.ArtifactKind != dnd.SpellListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("spell normalizer spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) } - if spec, ok := registries.Extractors.Spec(npcextract.Key); !ok || spec.ArtifactKind != dnd.NPCListKind { + if spec, ok := registries.Extractors.Spec(npcextract.Key); !ok || spec.ArtifactKind != dnd.NPCRegistryKind { t.Fatalf("NPC extractor spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) } - if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize { + if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCRegistryKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) } if spec, ok := registries.Extractors.Spec(combatextract.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind { @@ -390,7 +390,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { } extractRegistrySlot := referenceSlot(interactionExtractSpec.ReferenceSlots, "npcs") normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs") - if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !sameReferenceSlotContract(extractRegistrySlot, normalizeRegistrySlot) { + if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCRegistryKind}) || !sameReferenceSlotContract(extractRegistrySlot, normalizeRegistrySlot) { t.Fatalf("NPC interaction registry slots disagree: %#v / %#v", interactionExtractSpec.ReferenceSlots, interactionNormalizeSpec.ReferenceSlots) } } @@ -407,7 +407,7 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes return spellEvidence(dnd.SpellList{SpellCasts: []dnd.SpellCast{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "npcs", project: func() []source.SourceRef { - return npcEvidence(dnd.NPCList{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{first, second}}}}) + return npcEvidence(dnd.NPCRegistry{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "combat turns", project: func() []source.SourceRef { return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}}) @@ -459,30 +459,30 @@ func sameReferenceSlotContract(first, second contracts.ReferenceSlot) bool { return reflect.DeepEqual(first, second) } -func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { +func TestAppendNPCRegistriesPreservesOrderAndArrayPresence(t *testing.T) { tests := []struct { name string - in []dnd.NPCList - want dnd.NPCList + in []dnd.NPCRegistry + want dnd.NPCRegistry }{ - {name: "no values", in: nil, want: dnd.NPCList{}}, - {name: "nil values", in: []dnd.NPCList{{}, {}}, want: dnd.NPCList{}}, - {name: "present empty", in: []dnd.NPCList{{NPCs: []dnd.NPC{}}}, want: dnd.NPCList{NPCs: []dnd.NPC{}}}, - {name: "ordered values", in: []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first"}}}, {NPCs: []dnd.NPC{{Name: "second"}}}}, want: dnd.NPCList{NPCs: []dnd.NPC{{Name: "first"}, {Name: "second"}}}}, + {name: "no values", in: nil, want: dnd.NPCRegistry{}}, + {name: "nil values", in: []dnd.NPCRegistry{{}, {}}, want: dnd.NPCRegistry{}}, + {name: "present empty", in: []dnd.NPCRegistry{{NPCs: []dnd.NPC{}}}, want: dnd.NPCRegistry{NPCs: []dnd.NPC{}}}, + {name: "ordered values", in: []dnd.NPCRegistry{{NPCs: []dnd.NPC{{Name: "first"}}}, {NPCs: []dnd.NPC{{Name: "second"}}}}, want: dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "first"}, {Name: "second"}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := appendNPCLists(tt.in) + got, err := appendNPCRegistries(tt.in) if err != nil || !reflect.DeepEqual(got, tt.want) { - t.Fatalf("appendNPCLists() = %#v, error = %v, want %#v", got, err, tt.want) + t.Fatalf("appendNPCRegistries() = %#v, error = %v, want %#v", got, err, tt.want) } }) } - input := []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}} - merged, err := appendNPCLists(input) + input := []dnd.NPCRegistry{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}} + merged, err := appendNPCRegistries(input) if err != nil { - t.Fatalf("appendNPCLists() error = %v", err) + t.Fatalf("appendNPCRegistries() error = %v", err) } merged.NPCs[0].SourceRefs[0].StartUnitID = 999 if input[0].NPCs[0].SourceRefs[0].StartUnitID == 999 { @@ -696,9 +696,9 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { t.Fatalf("appendSpellLists() = %#v, %v; want present-empty source refs", spells, err) } - npcs, err := appendNPCLists([]dnd.NPCList{{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{}}}}}) + npcs, err := appendNPCRegistries([]dnd.NPCRegistry{{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || npcs.NPCs[0].SourceRefs == nil { - t.Fatalf("appendNPCLists() = %#v, %v; want present-empty source refs", npcs, err) + t.Fatalf("appendNPCRegistries() = %#v, %v; want present-empty source refs", npcs, err) } turns, err := appendCombatTurnLists([]dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{}}}}}) diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index b5e833b..e8fd2fa 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -94,10 +94,10 @@ func registerValidators(registries pipeline.Registries) error { return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, {name: "npc-list always accept validator", register: func() error { - return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind) + return alwaysaccept.RegisterTyped[dnd.NPCRegistry](registries.Validators, dnd.NPCRegistryKind) }}, {name: "npc-list always reject validator", register: func() error { - return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind) + return alwaysreject.RegisterTyped[dnd.NPCRegistry](registries.Validators, dnd.NPCRegistryKind) }}, {name: "combat-turn-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind) diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 6d061c1..58059fb 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -8,7 +8,7 @@ import ( const SpellListKind contracts.ArtifactKind = "dnd/spell-list" -const NPCListKind contracts.ArtifactKind = "dnd/npc-list" +const NPCRegistryKind contracts.ArtifactKind = "dnd/npc-registry" const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list" @@ -34,7 +34,7 @@ type SpellCast struct { SourceRefs []source.SourceRef `json:"source_refs"` } -type NPCList struct { +type NPCRegistry struct { NPCs []NPC `json:"npcs"` } diff --git a/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go b/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go index f1e908d..b3e1f8d 100644 --- a/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go +++ b/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go @@ -137,7 +137,7 @@ func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet { for index, name := range names { npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: index + 1, EndUnitID: index + 1}}} } - content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs}) + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs}) if err != nil { t.Fatal(err) } diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go index 6c54989..19260f7 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go @@ -156,7 +156,7 @@ func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet { for index, name := range names { npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}} } - content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs}) + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs}) if err != nil { t.Fatal(err) } diff --git a/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go index 0af4861..17f63dd 100644 --- a/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go @@ -124,7 +124,7 @@ func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet { for index, name := range names { npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}} } - content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs}) + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs}) if err != nil { t.Fatal(err) } diff --git a/internal/modules/dnd/validate/npcs/identity/validator.go b/internal/modules/dnd/validate/npcs/identity/validator.go index 7883cd1..c303386 100644 --- a/internal/modules/dnd/validate/npcs/identity/validator.go +++ b/internal/modules/dnd/validate/npcs/identity/validator.go @@ -22,7 +22,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCRegistry] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) { if err := npcshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -62,7 +62,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCRegistryKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcs/identity/validator_test.go b/internal/modules/dnd/validate/npcs/identity/validator_test.go index 6d887cc..0fd85e1 100644 --- a/internal/modules/dnd/validate/npcs/identity/validator_test.go +++ b/internal/modules/dnd/validate/npcs/identity/validator_test.go @@ -43,13 +43,13 @@ func TestValidatorContractAndRegistration(t *testing.T) { func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) { validator := New(Options{}) - shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing evidence"}}} + shapeInvalid := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "missing evidence"}}} result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid)) if err != nil || !result.Approved { t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err) } - value := validNPCList(2) + value := validNPCRegistry(2) value.NPCs[0].ID = "not-an-id" value.NPCs[1].Name = value.NPCs[0].Name value.NPCs[1].ID = value.NPCs[0].ID @@ -65,7 +65,7 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) { } func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) { - value := validNPCList(30) + value := validNPCRegistry(30) for index := range value.NPCs { value.NPCs[index].ID = fmt.Sprintf("bad-%d", index) value.NPCs[index].Name = strings.Repeat("火", 140) + fmt.Sprintf("-%d", index) @@ -82,12 +82,12 @@ func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) { } } -func validationRequest(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NPCList] { - return contracts.TypedValidationRequest[dnd.NPCList]{Value: value} +func validationRequest(value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] { + return contracts.TypedValidationRequest[dnd.NPCRegistry]{Value: value} } -func validNPCList(count int) dnd.NPCList { - value := dnd.NPCList{NPCs: make([]dnd.NPC, count)} +func validNPCRegistry(count int) dnd.NPCRegistry { + value := dnd.NPCRegistry{NPCs: make([]dnd.NPC, count)} for index := range value.NPCs { name := fmt.Sprintf("NPC %d", index) value.NPCs[index] = dnd.NPC{ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000", Name: name, SourceRefs: []source.SourceRef{sourceRefForTest()}} diff --git a/internal/modules/dnd/validate/npcs/shape/validator.go b/internal/modules/dnd/validate/npcs/shape/validator.go index 0bf778a..c53cc6e 100644 --- a/internal/modules/dnd/validate/npcs/shape/validator.go +++ b/internal/modules/dnd/validate/npcs/shape/validator.go @@ -20,7 +20,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCRegistry] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -32,7 +32,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) { issues := issuesFor(req.Value) if len(issues) > 0 { return rejection(diagnostics.Aggregate("invalid NPC shape", issues)), nil @@ -40,7 +40,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return contracts.ValidationResult{Approved: true}, nil } -func Validate(value dnd.NPCList) error { +func Validate(value dnd.NPCRegistry) error { issues := issuesFor(value) if len(issues) == 0 { return nil @@ -48,7 +48,7 @@ func Validate(value dnd.NPCList) error { return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC shape", issues)) } -func issuesFor(value dnd.NPCList) []string { +func issuesFor(value dnd.NPCRegistry) []string { issues := make([]string, 0) if value.NPCs == nil { return []string{"npcs must be present"} @@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCRegistryKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcs/shape/validator_test.go b/internal/modules/dnd/validate/npcs/shape/validator_test.go index 96e6c5e..d33553a 100644 --- a/internal/modules/dnd/validate/npcs/shape/validator_test.go +++ b/internal/modules/dnd/validate/npcs/shape/validator_test.go @@ -13,21 +13,21 @@ import ( ) func TestValidatorApprovesWellFormedNPCPayload(t *testing.T) { - result, err := New(Options{}).Validate(context.Background(), requestWithValue(validNPCList())) + result, err := New(Options{}).Validate(context.Background(), requestWithValue(validNPCRegistry())) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v; want approval", result, err) } } func TestValidatorRejectsRequiredShapeValues(t *testing.T) { - value := validNPCList() + value := validNPCRegistry() value.NPCs[0].Name = "" result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "name must not be empty") { t.Fatalf("Validate() = %#v, %v; want bounded shape rejection", result, err) } - missing := dnd.NPCList{} + missing := dnd.NPCRegistry{} result, err = New(Options{}).Validate(context.Background(), requestWithValue(missing)) if err != nil || result.Approved || result.ReasonCode != ReasonCode { t.Fatalf("missing Validate() = %#v, %v; want shape rejection", result, err) @@ -35,7 +35,7 @@ func TestValidatorRejectsRequiredShapeValues(t *testing.T) { } func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) { - value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)} + value := dnd.NPCRegistry{NPCs: make([]dnd.NPC, 24)} long := strings.Repeat("火", 220) + "\n\t" for index := range value.NPCs { value.NPCs[index] = dnd.NPC{ID: "", Name: long, SourceRefs: []source.SourceRef{}} @@ -66,7 +66,7 @@ func TestValidatorSpecCheckpointAndRegistration(t *testing.T) { } func TestValidatorDoesNotMutateValue(t *testing.T) { - value := validNPCList() + value := validNPCRegistry() before := value _, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) if err != nil || value.NPCs[0].Name != before.NPCs[0].Name { @@ -74,12 +74,12 @@ func TestValidatorDoesNotMutateValue(t *testing.T) { } } -func requestWithValue(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NPCList] { - return contracts.TypedValidationRequest[dnd.NPCList]{Value: value} +func requestWithValue(value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] { + return contracts.TypedValidationRequest[dnd.NPCRegistry]{Value: value} } -func validNPCList() dnd.NPCList { - return dnd.NPCList{NPCs: []dnd.NPC{{ +func validNPCRegistry() dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: "candidate", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, }}} diff --git a/internal/modules/dnd/validate/npcs/source_refs/validator.go b/internal/modules/dnd/validate/npcs/source_refs/validator.go index 7adc1f4..7b87f7f 100644 --- a/internal/modules/dnd/validate/npcs/source_refs/validator.go +++ b/internal/modules/dnd/validate/npcs/source_refs/validator.go @@ -21,7 +21,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCRegistry] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -33,7 +33,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) { if err := npcshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -57,7 +57,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCRegistryKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcs/source_refs/validator_test.go b/internal/modules/dnd/validate/npcs/source_refs/validator_test.go index 926335d..5bd7a77 100644 --- a/internal/modules/dnd/validate/npcs/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/npcs/source_refs/validator_test.go @@ -14,14 +14,14 @@ import ( ) func TestValidatorApprovesValidSourceReferences(t *testing.T) { - result, err := New(Options{}).Validate(context.Background(), requestWithValue(validDocument(), validNPCList())) + result, err := New(Options{}).Validate(context.Background(), requestWithValue(validDocument(), validNPCRegistry())) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("Validate() = %#v, %v; want approval", result, err) } } func TestValidatorRejectsInvalidSourceReferences(t *testing.T) { - value := validNPCList() + value := validNPCRegistry() value.NPCs[0].SourceRefs = []source.SourceRef{ {SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 2, EndUnitID: 1}, @@ -34,7 +34,7 @@ func TestValidatorRejectsInvalidSourceReferences(t *testing.T) { } func TestValidatorDefersMalformedShape(t *testing.T) { - value := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} + value := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} result, err := New(Options{}).Validate(context.Background(), requestWithValue(validDocument(), value)) if err != nil || !result.Approved || result.ReasonCode != "" || result.Message != "" { t.Fatalf("Validate() = %#v, %v; want shape deferral", result, err) @@ -42,7 +42,7 @@ func TestValidatorDefersMalformedShape(t *testing.T) { } func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) { - value := validNPCList() + value := validNPCRegistry() value.NPCs[0].SourceRefs = make([]source.SourceRef, 24) for index := range value.NPCs[0].SourceRefs { value.NPCs[0].SourceRefs[index] = source.SourceRef{SourceID: strings.Repeat("火", 220) + "\n\t", StartUnitID: index + 1, EndUnitID: index + 1} @@ -72,8 +72,8 @@ func TestValidatorSpecCheckpointAndRegistration(t *testing.T) { } } -func requestWithValue(doc *source.SourceDocument, value dnd.NPCList) contracts.TypedValidationRequest[dnd.NPCList] { - return contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value} +func requestWithValue(doc *source.SourceDocument, value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] { + return contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: doc, Value: value} } func validDocument() *source.SourceDocument { @@ -83,6 +83,6 @@ func validDocument() *source.SourceDocument { }} } -func validNPCList() dnd.NPCList { - return dnd.NPCList{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} +func validNPCRegistry() dnd.NPCRegistry { + return dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} } diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go index 6ceb258..8f1cadb 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go @@ -21,7 +21,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCRegistry] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -33,7 +33,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) { if err := npcshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -72,7 +72,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCRegistryKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCRegistry], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go index 63181f7..71e117a 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go @@ -13,7 +13,7 @@ import ( ) func TestValidatorMatchesCanonicalNamesWithUnicodeVariants(t *testing.T) { - value := dnd.NPCList{NPCs: []dnd.NPC{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: "one", Name: "O'Rin Thorn", SourceRefs: []source.SourceRef{ {SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, @@ -25,17 +25,17 @@ func TestValidatorMatchesCanonicalNamesWithUnicodeVariants(t *testing.T) { {ID: 1, Kind: "message", Text: " o’rin\u2003thorn appears."}, {ID: 2, Kind: "message", Text: "The greencloak watches."}, }} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: doc, Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("Validate() = %#v, %v; want canonical-name relatedness approval", result, err) } } func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { - value := dnd.NPCList{NPCs: []dnd.NPC{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: "one", Name: "Missing\nName", 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.NPCList]{Source: relatednessDocument(), Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: relatednessDocument(), Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 1 { t.Fatalf("Validate() = %#v, %v; want one warning", result, err) } @@ -46,33 +46,33 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { } func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) { - value := dnd.NPCList{NPCs: []dnd.NPC{{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{{ ID: "one", Name: "Art", 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: "A cart rolls past."}}} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: doc, Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 1 { t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err) } } func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) { - invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape}) + invalidShape := dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: relatednessDocument(), Value: invalidShape}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err) } - invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} - result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) + invalidRange := dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: relatednessDocument(), Value: invalidRange}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err) } } func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) { - value := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} + value := dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Opaque NPC")}}}}} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: relatednessDocument(), References: references, Value: value}) if err != nil || len(result.Warnings) != 1 { t.Fatalf("reference-only relatedness = %#v, %v; want warning", result, err) } diff --git a/internal/modules/integration/dnd_combat_runner_test.go b/internal/modules/integration/dnd_combat_runner_test.go index bfbbfc4..c4b4136 100644 --- a/internal/modules/integration/dnd_combat_runner_test.go +++ b/internal/modules/integration/dnd_combat_runner_test.go @@ -312,7 +312,7 @@ type combatNPCPayload struct { func combatTestNPCPayload(t *testing.T) combatNPCPayload { t.Helper() - value := dnd.NPCList{NPCs: []dnd.NPC{ + value := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}}, }} diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index 08f549a..20b5dc6 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -50,7 +50,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T } for name, value := range map[string]string{ "extract:npcs:dnd/npcs:mapping_policy": "dnd.npcs.extract_mapping.v2", - "normalize:npcs:dnd/npcs:identity_policy": "dnd.npcs.identity.v1", + "normalize:npcs:dnd/npcs:identity_policy": "dnd.npc_registry.identity.v1", "normalize:npcs:dnd/npcs:normalization_policy": "dnd.npcs.normalize.v3", "normalize:npcs:dnd/npcs:semantic_context_policy": "dnd.entity_reconcile.context.v1:2", "extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",