From 5e2ccffc0fdb976cc350cf5376f51d91a7721433 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 5 Aug 2026 18:55:58 +0000 Subject: [PATCH] Adopt registry-backed NPC occurrence artifacts --- .../npc-interactions/prompts/instructions.md | 3 +- .../dnd/npc-interactions/prompts/prompt.yaml | 2 +- ...1.json => dnd_npc_occurrences_llm.v1.json} | 11 +- .../dnd-npc-interaction-artifacts.md | 26 +++-- .../cli/dnd_enemy_events_contract_test.go | 15 ++- .../cli/dnd_interactions_contract_test.go | 4 +- ...ns.v1.json => dnd_npc_occurrences.v1.json} | 12 +- .../dnd/codec/npcinteractions/codec.go | 71 ++++++------ .../dnd/codec/npcinteractions/codec_test.go | 73 ++++++------ ...ns.v1.json => dnd_npc_occurrences.v1.json} | 4 +- .../dnd/extract/enemyevents/grounding.go | 18 +-- .../dnd/extract/enemyevents/grounding_test.go | 8 +- .../extract/npcinteractions/canonicalize.go | 47 ++++---- .../dnd/extract/npcinteractions/extractor.go | 43 ++++--- .../extract/npcinteractions/extractor_test.go | 101 +++++++++------- .../dnd/extract/npcinteractions/model.go | 5 +- .../dnd/extract/npcinteractions/model_test.go | 8 +- .../npcinteractions/prompt_assets_test.go | 8 +- .../dnd/extract/npcinteractions/schema.go | 8 +- .../extract/npcinteractions/schema_test.go | 9 +- .../normalize/npcinteractions/normalizer.go | 109 ++++++++---------- .../npcinteractions/normalizer_test.go | 86 +++++++------- .../modules/dnd/npcinteractions/canonical.go | 22 ++-- .../modules/dnd/npcs/registry/registry.go | 51 ++++++++ .../dnd/npcs/registry/registry_test.go | 17 ++- internal/modules/dnd/register/evidence.go | 6 +- internal/modules/dnd/register/merge.go | 16 +-- internal/modules/dnd/register/modules.go | 4 +- .../modules/dnd/register/register_test.go | 38 +++--- internal/modules/dnd/register/validators.go | 4 +- internal/modules/dnd/types.go | 25 ++-- .../npcinteractions/invariants/validator.go | 45 ++++---- .../invariants/validator_test.go | 66 +++++------ .../npcinteractions/registry/validator.go | 19 +-- .../registry/validator_test.go | 28 +++-- .../npcinteractions/shape/validator.go | 45 ++++---- .../npcinteractions/shape/validator_test.go | 30 ++--- .../npcinteractions/source_refs/validator.go | 16 +-- .../source_refs/validator_test.go | 24 ++-- .../source_relatedness/validator.go | 20 ++-- .../source_relatedness/validator_test.go | 33 +++--- .../dnd_npc_interactions_runner_test.go | 24 ++-- 42 files changed, 676 insertions(+), 528 deletions(-) rename assets/dnd/npc-interactions/schemas/{dnd_npc_interactions_llm.v1.json => dnd_npc_occurrences_llm.v1.json} (79%) rename internal/modules/dnd/codec/npcinteractions/assets/schemas/{dnd_npc_interactions.v1.json => dnd_npc_occurrences.v1.json} (83%) rename internal/modules/dnd/codec/npcinteractions/testdata/{dnd_npc_interactions.v1.json => dnd_npc_occurrences.v1.json} (80%) diff --git a/assets/dnd/npc-interactions/prompts/instructions.md b/assets/dnd/npc-interactions/prompts/instructions.md index 56cea7a..9750909 100644 --- a/assets/dnd/npc-interactions/prompts/instructions.md +++ b/assets/dnd/npc-interactions/prompts/instructions.md @@ -1,7 +1,8 @@ Extract Dungeons & Dragons NPC interaction occurrences from the supplied transcript. Include an occurrence only when the transcript establishes one supplied NPC, one interaction kind, and a coherent passage supporting both. -Use only names from the supplied NPC registry. +Use the exact `npc_id` and matching `name` pair from the supplied NPC registry; +never invent an ID or substitute a similar name. Do not summarize, infer relationships, sentiment, factions, motives, aliases, or persistent state. Do not identify player characters, anonymous groups, or diff --git a/assets/dnd/npc-interactions/prompts/prompt.yaml b/assets/dnd/npc-interactions/prompts/prompt.yaml index aa14f6d..3b0bc86 100644 --- a/assets/dnd/npc-interactions/prompts/prompt.yaml +++ b/assets/dnd/npc-interactions/prompts/prompt.yaml @@ -41,5 +41,5 @@ messages: output: format: json validation_mode: json_schema - schema_path: dnd_npc_interactions_llm.v1.json + schema_path: dnd_npc_occurrences_llm.v1.json repair_attempts: 0 diff --git a/assets/dnd/npc-interactions/schemas/dnd_npc_interactions_llm.v1.json b/assets/dnd/npc-interactions/schemas/dnd_npc_occurrences_llm.v1.json similarity index 79% rename from assets/dnd/npc-interactions/schemas/dnd_npc_interactions_llm.v1.json rename to assets/dnd/npc-interactions/schemas/dnd_npc_occurrences_llm.v1.json index 1b78e5e..8ee5c38 100644 --- a/assets/dnd/npc-interactions/schemas/dnd_npc_interactions_llm.v1.json +++ b/assets/dnd/npc-interactions/schemas/dnd_npc_occurrences_llm.v1.json @@ -1,17 +1,20 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "notarius.dnd.npc_interactions.llm", + "$id": "notarius.dnd.npc_occurrences.llm", "type": "object", "additionalProperties": false, - "required": ["interactions"], + "required": ["occurrences"], "properties": { - "interactions": { + "occurrences": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["name", "kind", "source_refs"], + "required": ["npc_id", "name", "kind", "source_refs"], "properties": { + "npc_id": { + "type": "string" + }, "name": { "type": "string" }, diff --git a/docs/integrations/dnd-npc-interaction-artifacts.md b/docs/integrations/dnd-npc-interaction-artifacts.md index 13ce3cd..118089a 100644 --- a/docs/integrations/dnd-npc-interaction-artifacts.md +++ b/docs/integrations/dnd-npc-interaction-artifacts.md @@ -9,22 +9,23 @@ or summarize the session. | Property | Value | | --- | --- | -| Artifact kind | `dnd/npc-interaction-list` | -| Schema ID | `notarius.dnd.npc_interactions` | -| Schema name | `notarius_dnd_npc_interactions_v1` | +| Artifact kind | `dnd/npc-occurrence-list` | +| Schema ID | `notarius.dnd.npc_occurrences` | +| Schema name | `notarius_dnd_npc_occurrences_v1` | | Schema version | `v1` | | Media type | `application/json` | -`v1` is a strict JSON object with required `interactions`; the array may be -empty. Interaction and source-reference objects reject unknown fields. An +`v1` is a strict JSON object with required `occurrences`; the array may be +empty. Occurrence and source-reference objects reject unknown fields. An incompatible shape change requires a new schema version. ## Wire shape -Each interaction has these required fields: +Each occurrence has these required fields: | Field | Contract | | --- | --- | +| `npc_id` | Exact durable ID from the required NPC registry. | | `name` | Non-empty canonical display name from the required NPC registry. | | `kind` | One of the interaction categories below. | | `source_refs` | One or more transcript evidence ranges. | @@ -36,8 +37,9 @@ for an interaction is confined to its accepted chunk. ```json { - "interactions": [ + "occurrences": [ { + "npc_id": "npc:sha256:example", "name": "Mira Thorn", "kind": "dialogue", "source_refs": [ @@ -65,12 +67,14 @@ for uncertain classification. ## Identity, evidence, and order -The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves `name`. -Registry references are provenance only and never replace an interaction's own -evidence. Normalization canonicalizes recognized registry names, orders and +The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves +the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their +ID are rejected; normalization does not repair names by similarity. Registry +references are provenance only and never replace an interaction's own evidence. +Normalization validates the exact pair, orders and deduplicates exact source references, then orders interactions by valid source chronology, NPC comparison identity, display name, kind, and reference sequence. -Only entries with the same canonical name, kind, and complete valid evidence +Only entries with the same NPC ID, canonical name, kind, and complete valid evidence sequence are collapsed; distinct categories or evidence remain separate. See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index ab7d098..640a184 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -259,9 +259,20 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) case npcinteractions.PromptID: if combatScene { - content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`) + var registry struct { + NPCs []struct { + ID string `json:"id"` + } `json:"npcs"` + } + if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err) + } + if len(registry.NPCs) == 0 { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs") + } + content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID)) } else { - content = []byte(`{"interactions":[]}`) + content = []byte(`{"occurrences":[]}`) } case locationoccurrences.PromptID: var registry struct { diff --git a/internal/cli/dnd_interactions_contract_test.go b/internal/cli/dnd_interactions_contract_test.go index 7afa64d..aa0a3c4 100644 --- a/internal/cli/dnd_interactions_contract_test.go +++ b/internal/cli/dnd_interactions_contract_test.go @@ -25,7 +25,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) { t.Fatalf("resolved pipeline = %#v", resolved) } lane := resolved.Steps[1].ArtifactLanes[0] - if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key { + if lane.ArtifactKind != dnd.NPCOccurrenceListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key { t.Fatalf("interaction lane = %#v", lane) } for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} { @@ -38,7 +38,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) { } catalog := catalogFromRegistries(components.registries) - codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind) + codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCOccurrenceListKind) if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion { t.Fatalf("NPC interaction codec spec = %#v", codecSpec) } diff --git a/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json b/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_occurrences.v1.json similarity index 83% rename from internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json rename to internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_occurrences.v1.json index 76bd6a7..7177450 100644 --- a/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json +++ b/internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_occurrences.v1.json @@ -1,17 +1,21 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "notarius.dnd.npc_interactions", + "$id": "notarius.dnd.npc_occurrences", "type": "object", "additionalProperties": false, - "required": ["interactions"], + "required": ["occurrences"], "properties": { - "interactions": { + "occurrences": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["name", "kind", "source_refs"], + "required": ["npc_id", "name", "kind", "source_refs"], "properties": { + "npc_id": { + "type": "string", + "minLength": 1 + }, "name": { "type": "string", "minLength": 1 diff --git a/internal/modules/dnd/codec/npcinteractions/codec.go b/internal/modules/dnd/codec/npcinteractions/codec.go index d72528e..0f3d301 100644 --- a/internal/modules/dnd/codec/npcinteractions/codec.go +++ b/internal/modules/dnd/codec/npcinteractions/codec.go @@ -1,4 +1,4 @@ -// Package npcinteractions encodes durable D&D NPC interaction artifacts. +// Package npcinteractions encodes durable D&D NPC occurrence artifacts. package npcinteractions import ( @@ -12,25 +12,25 @@ import ( ) const ( - SchemaID = "notarius.dnd.npc_interactions" - SchemaName = "notarius_dnd_npc_interactions_v1" + SchemaID = "notarius.dnd.npc_occurrences" + SchemaName = "notarius_dnd_npc_occurrences_v1" SchemaVersion = "v1" MediaType = "application/json" ) -//go:embed assets/schemas/dnd_npc_interactions.v1.json +//go:embed assets/schemas/dnd_npc_occurrences.v1.json var schemaAssets embed.FS -var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil) +var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil) type Codec struct{} func New() *Codec { return &Codec{} } -func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind } +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCOccurrenceListKind } func (c *Codec) Schema() contracts.ArtifactSchema { - raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json") + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_occurrences.v1.json") if err != nil { return contracts.ArtifactSchema{} } @@ -44,56 +44,59 @@ func (c *Codec) Schema() contracts.ArtifactSchema { func (c *Codec) MediaType() string { return MediaType } -func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any { - return map[string]any{"interaction_count": len(value.Interactions)} +func (c *Codec) Metadata(value dnd.NPCOccurrenceList) map[string]any { + return map[string]any{"occurrence_count": len(value.Occurrences)} } -func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) { +func (c *Codec) Encode(value dnd.NPCOccurrenceList) ([]byte, error) { if err := validate(value); err != nil { - return nil, fmt.Errorf("encode dnd npc interaction list: %w", err) + return nil, fmt.Errorf("encode dnd npc occurrence list: %w", err) } return c.EncodeCandidate(value) } // EncodeCandidate provides the durable representation before semantic // validators have approved a value. -func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) { - return candidatejson.EncodeCandidate("dnd npc interaction list", value) +func (c *Codec) EncodeCandidate(value dnd.NPCOccurrenceList) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd npc occurrence list", value) } -func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) { +func (c *Codec) Decode(content []byte) (dnd.NPCOccurrenceList, error) { value, err := c.DecodeCandidate(content) if err != nil { - return dnd.NPCInteractionList{}, err + return dnd.NPCOccurrenceList{}, err } if err := validate(value); err != nil { - return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err) + return dnd.NPCOccurrenceList{}, fmt.Errorf("decode dnd npc occurrence list: %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.NPCInteractionList, error) { - return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content) +func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCOccurrenceList, error) { + return candidatejson.DecodeCandidate[dnd.NPCOccurrenceList]("dnd npc occurrence list", content) } -func validate(value dnd.NPCInteractionList) error { - if value.Interactions == nil { - return fmt.Errorf("interactions must be present") +func validate(value dnd.NPCOccurrenceList) error { + if value.Occurrences == nil { + return fmt.Errorf("occurrences must be present") } - for index, interaction := range value.Interactions { - prefix := fmt.Sprintf("interactions[%d]", index) - if strings.TrimSpace(interaction.Name) == "" { + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if strings.TrimSpace(occurrence.NPCID) == "" { + return fmt.Errorf("%s.npc_id must not be empty", prefix) + } + if strings.TrimSpace(occurrence.Name) == "" { return fmt.Errorf("%s.name must not be empty", prefix) } - if !validInteractionKind(interaction.Kind) { + if !validOccurrenceKind(occurrence.Kind) { return fmt.Errorf("%s.kind must be supported", prefix) } - if len(interaction.SourceRefs) == 0 { + if len(occurrence.SourceRefs) == 0 { return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) } - for refIndex, ref := range interaction.SourceRefs { + for refIndex, ref := range occurrence.SourceRefs { refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) @@ -109,14 +112,14 @@ func validate(value dnd.NPCInteractionList) error { return nil } -func validInteractionKind(value dnd.NPCInteractionKind) bool { +func validOccurrenceKind(value dnd.NPCOccurrenceKind) bool { switch value { - case dnd.NPCInteractionKindMentioned, - dnd.NPCInteractionKindNoncombatPresence, - dnd.NPCInteractionKindDialogue, - dnd.NPCInteractionKindCombatAlly, - dnd.NPCInteractionKindCombatOpponent, - dnd.NPCInteractionKindOther: + case dnd.NPCOccurrenceKindMentioned, + dnd.NPCOccurrenceKindNoncombatPresence, + dnd.NPCOccurrenceKindDialogue, + dnd.NPCOccurrenceKindCombatAlly, + dnd.NPCOccurrenceKindCombatOpponent, + dnd.NPCOccurrenceKindOther: return true default: return false diff --git a/internal/modules/dnd/codec/npcinteractions/codec_test.go b/internal/modules/dnd/codec/npcinteractions/codec_test.go index 51e0c65..85bcf26 100644 --- a/internal/modules/dnd/codec/npcinteractions/codec_test.go +++ b/internal/modules/dnd/codec/npcinteractions/codec_test.go @@ -15,21 +15,21 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) -func validList() dnd.NPCInteractionList { - return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ +func validList() dnd.NPCOccurrenceList { + return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ { - Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, + NPCID: "npc:test-mira", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, }, { - Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent, + NPCID: "npc:test-guard", Name: "Hooded Guard", Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}, }, }} } func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { - raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json") + raw, err := os.ReadFile("testdata/dnd_npc_occurrences.v1.json") if err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { codec := New() schema := codec.Schema() - if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType { + if codec.Kind() != dnd.NPCOccurrenceListKind || 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,11 +67,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { t.Fatal(err) } - spec, ok := registry.Spec(dnd.NPCInteractionListKind) + spec, ok := registry.Spec(dnd.NPCOccurrenceListKind) if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } - if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCRegistry{}); err == nil { + if _, err := registry.Encode(dnd.NPCOccurrenceListKind, dnd.NPCRegistry{}); err == nil { t.Fatal("Encode() error = nil, want exact type rejection") } else { var typeErr *pipeline.ArtifactCodecTypeError @@ -83,17 +83,17 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) { codec := New() - empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}} + empty := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}} content, err := codec.Encode(empty) - if err != nil || string(content) != `{"interactions":[]}` { + if err != nil || string(content) != `{"occurrences":[]}` { t.Fatalf("Encode() = %s, %v", content, err) } - for _, candidate := range []dnd.NPCInteractionList{ + for _, candidate := range []dnd.NPCOccurrenceList{ {}, empty, - {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}}, - {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}}, - {Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}}, + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: nil}}}, + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}}, + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}}, } { content, err := codec.EncodeCandidate(candidate) if err != nil || !json.Valid(content) { @@ -107,13 +107,13 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes } func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { - validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` + validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ - {"malformed", `{`, "decode dnd npc interaction list"}, - {"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"}, + {"malformed", `{`, "decode dnd npc occurrence list"}, + {"unknown top-level", `{"occurrences":[],"unexpected":true}`, "unknown field"}, {"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"}, {"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, - {"trailing", `{"interactions":[]} {}`, "multiple JSON values"}, + {"trailing", `{"occurrences":[]} {}`, "multiple JSON values"}, } { t.Run(test.name, func(t *testing.T) { if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { @@ -126,16 +126,17 @@ func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) { tests := []struct { name string - value dnd.NPCInteractionList + value dnd.NPCOccurrenceList want string }{ - {"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"}, - {"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"}, - {"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"}, - {"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"}, - {"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"}, - {"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"}, - {"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"}, + {"nil occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"}, + {"blank NPC ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].NPCID = " " }), "npc_id must not be empty"}, + {"blank name", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Name = " " }), "name must not be empty"}, + {"unsupported kind", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Kind = "unsupported" }), "kind must be supported"}, + {"nil source refs", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs = nil }), "source_refs must contain"}, + {"empty source ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"}, + {"non-positive start", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"}, + {"non-positive end", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -147,16 +148,16 @@ func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) { } func TestCodecAcceptsEveryInteractionKind(t *testing.T) { - for _, kind := range []dnd.NPCInteractionKind{ - dnd.NPCInteractionKindMentioned, - dnd.NPCInteractionKindNoncombatPresence, - dnd.NPCInteractionKindDialogue, - dnd.NPCInteractionKindCombatAlly, - dnd.NPCInteractionKindCombatOpponent, - dnd.NPCInteractionKindOther, + for _, kind := range []dnd.NPCOccurrenceKind{ + dnd.NPCOccurrenceKindMentioned, + dnd.NPCOccurrenceKindNoncombatPresence, + dnd.NPCOccurrenceKindDialogue, + dnd.NPCOccurrenceKindCombatAlly, + dnd.NPCOccurrenceKindCombatOpponent, + dnd.NPCOccurrenceKindOther, } { value := validList() - value.Interactions[0].Kind = kind + value.Occurrences[0].Kind = kind if _, err := New().Encode(value); err != nil { t.Fatalf("Encode(%q) error = %v", kind, err) } @@ -172,12 +173,12 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { } metadata := codec.Metadata(validList()) metadata["other"] = true - if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 { + if next := codec.Metadata(validList()); len(next) != 1 || next["occurrence_count"] != 2 { t.Fatalf("Metadata() = %#v", next) } } -func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList { +func mutate(value dnd.NPCOccurrenceList, change func(*dnd.NPCOccurrenceList)) dnd.NPCOccurrenceList { change(&value) return value } diff --git a/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json b/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_occurrences.v1.json similarity index 80% rename from internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json rename to internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_occurrences.v1.json index 6e12074..e9d541b 100644 --- a/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json +++ b/internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_occurrences.v1.json @@ -1,6 +1,7 @@ { - "interactions": [ + "occurrences": [ { + "npc_id": "npc:test-mira", "name": "Mira Thorn", "kind": "dialogue", "source_refs": [ @@ -8,6 +9,7 @@ ] }, { + "npc_id": "npc:test-guard", "name": "Hooded Guard", "kind": "combat_opponent", "source_refs": [ diff --git a/internal/modules/dnd/extract/enemyevents/grounding.go b/internal/modules/dnd/extract/enemyevents/grounding.go index a0907a0..ec202be 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding.go +++ b/internal/modules/dnd/extract/enemyevents/grounding.go @@ -71,7 +71,7 @@ func referenceSlots() []contracts.ReferenceSlot { Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.", Required: true, AcceptedMediaTypes: []string{interactioncodec.MediaType}, - AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}, MaxBytes: ReferenceMaxBytes, }, ) @@ -223,7 +223,7 @@ func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.L } content, err := json.Marshal(struct { Interactions []npcInteractionProjection `json:"npc_interactions"` - }{Interactions: projectNPCInteractions(value.Interactions)}) + }{Interactions: projectNPCInteractions(value.Occurrences)}) if err != nil { return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err) } @@ -272,15 +272,15 @@ func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection { } type npcInteractionProjection struct { - Name string `json:"name"` - Kind dnd.NPCInteractionKind `json:"kind"` + Name string `json:"name"` + Kind dnd.NPCOccurrenceKind `json:"kind"` } -func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection { - projection := make([]npcInteractionProjection, 0, len(interactions)) - for _, interaction := range interactions { - if interaction.Kind == dnd.NPCInteractionKindCombatOpponent { - projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind}) +func projectNPCInteractions(occurrences []dnd.NPCOccurrence) []npcInteractionProjection { + projection := make([]npcInteractionProjection, 0, len(occurrences)) + for _, occurrence := range occurrences { + if occurrence.Kind == dnd.NPCOccurrenceKindCombatOpponent { + projection = append(projection, npcInteractionProjection{Name: occurrence.Name, Kind: occurrence.Kind}) } } return projection diff --git a/internal/modules/dnd/extract/enemyevents/grounding_test.go b/internal/modules/dnd/extract/enemyevents/grounding_test.go index 237d093..e72df92 100644 --- a/internal/modules/dnd/extract/enemyevents/grounding_test.go +++ b/internal/modules/dnd/extract/enemyevents/grounding_test.go @@ -36,7 +36,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) { {NPCRegistryReferenceSlot, dnd.NPCRegistryKind}, {SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind}, {CombatTurnReferenceSlot, dnd.CombatTurnListKind}, - {NPCInteractionReferenceSlot, dnd.NPCInteractionListKind}, + {NPCInteractionReferenceSlot, dnd.NPCOccurrenceListKind}, } { slot, ok := byName[want.name] if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind { @@ -220,9 +220,9 @@ func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) co if err != nil { t.Fatal(err) } - interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ - {Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}}, - {Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}}, + interactionContent, err := interactioncodec.New().Encode(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ + {NPCID: identity.DeriveID(enemy), Name: enemy, Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}}, + {NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}}, }}) if err != nil { t.Fatal(err) diff --git a/internal/modules/dnd/extract/npcinteractions/canonicalize.go b/internal/modules/dnd/extract/npcinteractions/canonicalize.go index 8a12301..a461833 100644 --- a/internal/modules/dnd/extract/npcinteractions/canonicalize.go +++ b/internal/modules/dnd/extract/npcinteractions/canonicalize.go @@ -8,8 +8,8 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) -type orderedInteractionResponse struct { - value interactionResponse +type orderedOccurrenceResponse struct { + value occurrenceResponse earliest int hasEvidence bool } @@ -18,11 +18,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr if response == nil { return } - ordered := make([]orderedInteractionResponse, len(response.Interactions)) - for index := range response.Interactions { - earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID) - ordered[index] = orderedInteractionResponse{ - value: response.Interactions[index], + ordered := make([]orderedOccurrenceResponse, len(response.Occurrences)) + for index := range response.Occurrences { + earliest, hasEvidence := canonicalizeOccurrence(&response.Occurrences[index], order, sourceID) + ordered[index] = orderedOccurrenceResponse{ + value: response.Occurrences[index], earliest: earliest, hasEvidence: hasEvidence, } @@ -37,32 +37,33 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr return ordered[i].earliest < ordered[j].earliest }) for index := range ordered { - response.Interactions[index] = ordered[index].value + response.Occurrences[index] = ordered[index].value } } -func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { - if interaction == nil { +func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { + if occurrence == nil { return 0, false } - refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID)) - interaction.SourceRefs = interactionResponseRefs(refs) + refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID)) + occurrence.SourceRefs = occurrenceResponseRefs(refs) return order.EarliestValid(refs) } -func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList { - interactions := make([]dnd.NPCInteraction, len(response.Interactions)) - for index, interaction := range response.Interactions { - interactions[index] = dnd.NPCInteraction{ - Name: interaction.Name, - Kind: dnd.NPCInteractionKind(interaction.Kind), - SourceRefs: canonicalSourceRefs(interaction.SourceRefs, sourceID), +func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList { + occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences)) + for index, occurrence := range response.Occurrences { + occurrences[index] = dnd.NPCOccurrence{ + NPCID: occurrence.NPCID, + Name: occurrence.Name, + Kind: dnd.NPCOccurrenceKind(occurrence.Kind), + SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID), } } - if response.Interactions == nil { - interactions = nil + if response.Occurrences == nil { + occurrences = nil } - return dnd.NPCInteractionList{Interactions: interactions} + return dnd.NPCOccurrenceList{Occurrences: occurrences} } func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef { @@ -76,7 +77,7 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [ return out } -func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse { +func occurrenceResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse { if refs == nil { return nil } diff --git a/internal/modules/dnd/extract/npcinteractions/extractor.go b/internal/modules/dnd/extract/npcinteractions/extractor.go index 410b18c..fcbdabf 100644 --- a/internal/modules/dnd/extract/npcinteractions/extractor.go +++ b/internal/modules/dnd/extract/npcinteractions/extractor.go @@ -52,7 +52,7 @@ func referenceSlots() []contracts.ReferenceSlot { return slots } -var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil) +var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) @@ -132,33 +132,33 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, - {Name: "npc_registry", Value: seeded.ProjectionDigest()}, + {Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest}, } } -func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCInteractionList], error) { +func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCOccurrenceList], error) { if e == nil { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("extractor must not be nil") + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("extractor must not be nil") } if e.llm == nil { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil") + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("LLM client must not be nil") } sourceInput, err := shared.PrepareChunkExtraction(ctx, req) if err != nil { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err) + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err) } order := shared.NewSourceRefOrder(req.Source) npcRegistry, err := e.npcResolver.Resolve(req.References) if err != nil { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err) + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC registry: %w", err) } if !npcRegistry.Bound() { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("NPC registry reference is required") + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("NPC registry reference is required") } var response extractionResponse inputs := shared.PromptInputs(sourceInput, req.References) - inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput() + inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput() if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, @@ -167,10 +167,27 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe SessionID: req.SessionID, Inputs: inputs, }, &response); err != nil { - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err) + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil + value := canonicalOccurrenceList(response, req.Source.ID) + if err := validateRegistryPairs(value, npcRegistry); err != nil { + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err) + } + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil +} + +func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error { + for index, occurrence := range value.Occurrences { + canonical, ok := registry.LookupID(occurrence.NPCID) + if !ok { + return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index) + } + if occurrence.Name != canonical.Name { + return fmt.Errorf("occurrences[%d].name does not match npc_id", index) + } + } + return nil } func ModuleSpec() pipeline.ModuleSpec { @@ -180,13 +197,13 @@ func ModuleSpec() pipeline.ModuleSpec { ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), - ArtifactKind: dnd.NPCInteractionListKind, + ArtifactKind: dnd.NPCOccurrenceListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.ExtractorRegistry) error { - return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCInteractionList], error) { + return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/extract/npcinteractions/extractor_test.go b/internal/modules/dnd/extract/npcinteractions/extractor_test.go index 7904e4e..64220e1 100644 --- a/internal/modules/dnd/extract/npcinteractions/extractor_test.go +++ b/internal/modules/dnd/extract/npcinteractions/extractor_test.go @@ -17,14 +17,14 @@ import ( ) func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { - client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{ - {Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)}, - {Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)}, - {Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)}, - {Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)}, - {Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)}, - {Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)}, - {Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)}, + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ + {NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)}, + {NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)}, + {NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)}, + {NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)}, + {NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)}, + {NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)}, + {NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)}, }}} references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid") req := extractionRequest() @@ -37,21 +37,21 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) { t.Fatalf("interaction order = %#v", got) } - if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{ - dnd.NPCInteractionKindMentioned, - dnd.NPCInteractionKindDialogue, - dnd.NPCInteractionKindNoncombatPresence, - dnd.NPCInteractionKindCombatAlly, - dnd.NPCInteractionKindCombatOpponent, - dnd.NPCInteractionKindOther, + if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCOccurrenceKind{ + dnd.NPCOccurrenceKindMentioned, + dnd.NPCOccurrenceKindDialogue, + dnd.NPCOccurrenceKindNoncombatPresence, + dnd.NPCOccurrenceKindCombatAlly, + dnd.NPCOccurrenceKindCombatOpponent, + dnd.NPCOccurrenceKindOther, "unsupported", }) { t.Fatalf("interaction kinds = %#v", got) } - if refs := result.Value.Interactions[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { + if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { t.Fatalf("canonical source refs = %#v", refs) } - if invalid := result.Value.Interactions[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) { + if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) { t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid) } if len(client.requests) != 1 { @@ -60,15 +60,15 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { } func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) { - client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{ - {Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, - {Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{ + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ + {NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, + {NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{ {StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 999, EndUnitID: 0}, }}, - {Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)}, + {NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)}, }}} references := requiredRegistryReferences(t, "Later", "First", "Second") req := extractionRequest() @@ -84,12 +84,12 @@ func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) { if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) { t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got) } - refs := result.Value.Interactions[0].SourceRefs + refs := result.Value.Occurrences[0].SourceRefs if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) { t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs) } refs[0].StartUnitID = 777 - for _, interaction := range client.response.Interactions { + for _, interaction := range client.response.Occurrences { for _, ref := range interaction.SourceRefs { if ref.StartUnitID == 777 { t.Fatal("result source references alias the model response") @@ -107,9 +107,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) { } } -func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) { - client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{ - Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10), +func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) { + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ + NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10), }}}} references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard") req := extractionRequest() @@ -119,10 +119,10 @@ func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) } request := client.requests[0] registry := request.Inputs[NPCRegistryReferenceSlot] - if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` { - t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry) + if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` { + t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry) } - for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} { + for _, forbidden := range []string{"other-session", "start_unit_id"} { if strings.Contains(string(registry.Content), forbidden) { t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content) } @@ -162,8 +162,29 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) { } } +func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) { + references := requiredRegistryReferences(t, "Mira Thorn") + for _, test := range []struct { + name string + occurrence occurrenceResponse + want string + }{ + {"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "npc_id is not in the NPC registry"}, + {"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "name does not match npc_id"}, + } { + t.Run(test.name, func(t *testing.T) { + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}} + req := extractionRequest() + req.References = references + if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Extract() error = %v, want %q", err, test.want) + } + }) + } +} + func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) { - client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}} + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}} references := requiredRegistryReferences(t, "Mira Thorn") req := extractionRequest() req.References = references @@ -171,7 +192,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) { if _, err := extractor.Extract(context.Background(), req); err != nil { t.Fatalf("Extract() error = %v", err) } - if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" { + if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" { t.Fatalf("generated registry prompt input = %#v", input) } metadata := extractor.ManifestMetadata() @@ -191,11 +212,11 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) { Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, }, }} - client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}} + client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}} req := extractionRequest() req.References = references result, err := newExtractor(t, client, references).Extract(context.Background(), req) - if err != nil || result.Value.Interactions == nil || len(result.Value.Interactions) != 0 { + if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 { t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err) } } @@ -230,7 +251,7 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) { func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) { got := ModuleSpec() - if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCInteractionListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) { + if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCOccurrenceListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) { t.Fatalf("ModuleSpec() = %#v", got) } var registrySlot contracts.ReferenceSlot @@ -342,17 +363,17 @@ func interactionRefs(start, end int) []interactionSourceRefResponse { return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}} } -func interactionNames(value dnd.NPCInteractionList) []string { - names := make([]string, len(value.Interactions)) - for index, interaction := range value.Interactions { +func interactionNames(value dnd.NPCOccurrenceList) []string { + names := make([]string, len(value.Occurrences)) + for index, interaction := range value.Occurrences { names[index] = interaction.Name } return names } -func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind { - kinds := make([]dnd.NPCInteractionKind, len(value.Interactions)) - for index, interaction := range value.Interactions { +func interactionKinds(value dnd.NPCOccurrenceList) []dnd.NPCOccurrenceKind { + kinds := make([]dnd.NPCOccurrenceKind, len(value.Occurrences)) + for index, interaction := range value.Occurrences { kinds[index] = interaction.Kind } return kinds diff --git a/internal/modules/dnd/extract/npcinteractions/model.go b/internal/modules/dnd/extract/npcinteractions/model.go index 2e33b86..9b11474 100644 --- a/internal/modules/dnd/extract/npcinteractions/model.go +++ b/internal/modules/dnd/extract/npcinteractions/model.go @@ -1,10 +1,11 @@ package npcinteractions type extractionResponse struct { - Interactions []interactionResponse `json:"interactions"` + Occurrences []occurrenceResponse `json:"occurrences"` } -type interactionResponse struct { +type occurrenceResponse struct { + NPCID string `json:"npc_id"` Name string `json:"name"` Kind string `json:"kind"` SourceRefs []interactionSourceRefResponse `json:"source_refs"` diff --git a/internal/modules/dnd/extract/npcinteractions/model_test.go b/internal/modules/dnd/extract/npcinteractions/model_test.go index 3968fae..49f4376 100644 --- a/internal/modules/dnd/extract/npcinteractions/model_test.go +++ b/internal/modules/dnd/extract/npcinteractions/model_test.go @@ -6,13 +6,13 @@ import ( ) func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) { - content := []byte(`{"interactions":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) + content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) var response extractionResponse if err := json.Unmarshal(content, &response); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } - interaction := response.Interactions[0] - if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { - t.Fatalf("decoded response = %#v", interaction) + occurrence := response.Occurrences[0] + if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { + t.Fatalf("decoded response = %#v", occurrence) } } diff --git a/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go index 7058227..7fc0539 100644 --- a/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go +++ b/internal/modules/dnd/extract/npcinteractions/prompt_assets_test.go @@ -20,7 +20,7 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := fs.ReadFile(schemaFS, "dnd_npc_interactions_llm.v1.json"); err != nil { + if _, err := fs.ReadFile(schemaFS, "dnd_npc_occurrences_llm.v1.json"); err != nil { t.Fatalf("response schema asset: %v", err) } options, err := registry.PromptKitOptions() @@ -42,13 +42,13 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) { "players": promptkit.Inline("interaction-player"), "party": promptkit.Inline("Mira: ranger"), "glossary": promptkit.Inline("Greencloak: title"), - "npc_registry": promptkit.Inline(`{"npcs":[{"name":"interaction-npc"}]}`), + "npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"interaction-npc"}]}`), }, }) if err != nil { t.Fatal(err) } - if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" { + if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_occurrences_llm.v1.json" { t.Fatalf("prepared prompt = %#v", prepared) } } @@ -59,7 +59,7 @@ func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) { t.Fatalf("promptAssetMetadata() = %q, %v", hash, err) } metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata() - for _, forbidden := range []string{"common-dnd-system", "dnd_npc_interactions_llm.v1.json"} { + for _, forbidden := range []string{"common-dnd-system", "dnd_npc_occurrences_llm.v1.json"} { if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) { t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata) } diff --git a/internal/modules/dnd/extract/npcinteractions/schema.go b/internal/modules/dnd/extract/npcinteractions/schema.go index 4210c40..a0d4c3b 100644 --- a/internal/modules/dnd/extract/npcinteractions/schema.go +++ b/internal/modules/dnd/extract/npcinteractions/schema.go @@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" const ( PromptID = "dnd.npc_interactions" - ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm") - ResponseSchemaID = "notarius.dnd.npc_interactions.llm" - ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1" + ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_occurrences_llm") + ResponseSchemaID = "notarius.dnd.npc_occurrences.llm" + ResponseSchemaName = "notarius_dnd_npc_occurrences_llm_v1" SchemaVersion = "v1" ) @@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) { ID: ResponseSchemaID, Version: SchemaVersion, Name: ResponseSchemaName, - AssetPath: "schemas/dnd_npc_interactions_llm.v1.json", + AssetPath: "schemas/dnd_npc_occurrences_llm.v1.json", }) } diff --git a/internal/modules/dnd/extract/npcinteractions/schema_test.go b/internal/modules/dnd/extract/npcinteractions/schema_test.go index e524555..fe1ada2 100644 --- a/internal/modules/dnd/extract/npcinteractions/schema_test.go +++ b/internal/modules/dnd/extract/npcinteractions/schema_test.go @@ -27,7 +27,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) { } semanticCandidate := validInteractionResponse() - interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any) + interaction := semanticCandidate["occurrences"].([]any)[0].(map[string]any) interaction["name"] = "" interaction["kind"] = "unsupported" ref := interaction["source_refs"].([]any)[0].(map[string]any) @@ -42,6 +42,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) { } for _, mutate := range []func(map[string]any){ + func(record map[string]any) { delete(record, "npc_id") }, func(record map[string]any) { delete(record, "name") }, func(record map[string]any) { record["kind"] = 1 }, func(record map[string]any) { record["unexpected"] = true }, @@ -50,7 +51,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) { }, } { candidate := validInteractionResponse() - mutate(candidate["interactions"].([]any)[0].(map[string]any)) + mutate(candidate["occurrences"].([]any)[0].(map[string]any)) content, err := json.Marshal(candidate) if err != nil { t.Fatal(err) @@ -79,8 +80,8 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) { } func validInteractionResponse() map[string]any { - return map[string]any{"interactions": []any{map[string]any{ - "name": "Mira Thorn", "kind": "dialogue", + return map[string]any{"occurrences": []any{map[string]any{ + "npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, }}} } diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer.go b/internal/modules/dnd/normalize/npcinteractions/normalizer.go index e6b3070..98a0d1f 100644 --- a/internal/modules/dnd/normalize/npcinteractions/normalizer.go +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer.go @@ -11,7 +11,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" @@ -22,7 +21,6 @@ const ( normalizationPolicy = "dnd.npc_interactions.normalize.v2" NormalizationPolicy = normalizationPolicy - ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized" ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeInteractionsReordered = "npc_interactions_reordered" ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed" @@ -44,7 +42,7 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.", } -var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil) +var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) @@ -79,7 +77,6 @@ func (n *Normalizer) ManifestMetadata() map[string]any { } metadata := map[string]any{ "normalization_policy": normalizationPolicy, - "identity_policy": identity.Policy, } seeded := n.npcResolver.Seeded() if seeded.Bound() { @@ -95,82 +92,74 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "normalization_policy", Value: normalizationPolicy}, - {Name: "identity_policy", Value: identity.Policy}, - {Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()}, + {Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest}, } } -func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) { +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]) (contracts.TypedNormalizeResult[dnd.NPCOccurrenceList], error) { if n == nil || n.npcResolver == nil { - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil") + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil") + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err) + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err) } registry, err := n.npcResolver.Resolve(req.References) if err != nil { - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err) + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("resolve NPC registry: %w", err) } if !registry.Bound() { - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required") + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("NPC registry reference is required") } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) - value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry) - return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil + value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry) + if err != nil { + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err) + } + return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { - interaction dnd.NPCInteraction - inputIndex int + occurrence dnd.NPCOccurrence + inputIndex int } -type nameCanonicalization struct { - from string - to string -} - -func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) { - if input.Interactions == nil { - return dnd.NPCInteractionList{}, nil +func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) { + if input.Occurrences == nil { + return dnd.NPCOccurrenceList{}, nil, nil } - records := make([]normalizedRecord, len(input.Interactions)) + records := make([]normalizedRecord, len(input.Occurrences)) warnings := make([]contracts.Warning, 0) - for index, inputInteraction := range input.Interactions { - interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry) - records[index] = normalizedRecord{interaction: interaction, inputIndex: index} - if nameChange != nil { - warnings = append(warnings, contracts.Warning{ - Scope: interactionScope(index), - ReasonCode: ReasonCodeNameCanonicalized, - Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s", - index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)), - }) + for index, inputOccurrence := range input.Occurrences { + occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry) + if err != nil { + return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err) } + records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if refsChanged { warnings = append(warnings, contracts.Warning{ - Scope: interactionScope(index), + Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", - index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)), + index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs)), }) } } sort.SliceStable(records, func(left, right int) bool { - return interactionmodel.Less(order, records[left].interaction, records[right].interaction) + return interactionmodel.Less(order, records[left].occurrence, records[right].occurrence) }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ - Scope: interactionScope(record.inputIndex), + Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeInteractionsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), }) @@ -178,24 +167,24 @@ func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIn output, duplicateWarnings := collapseDuplicates(records, documentIndex) warnings = append(warnings, duplicateWarnings...) - return dnd.NPCInteractionList{Interactions: output}, - diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted) + return dnd.NPCOccurrenceList{Occurrences: output}, + diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted), nil } -func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) { - output := cloneInteraction(input) - if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok { - output.Name = canonical.Name +func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) { + canonical, ok := registry.LookupID(input.NPCID) + if !ok { + return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry") } - var nameChange *nameCanonicalization - if input.Name != output.Name { - nameChange = &nameCanonicalization{from: input.Name, to: output.Name} + if input.Name != canonical.Name { + return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id") } + output := cloneOccurrence(input) output.SourceRefs = order.Canonicalize(input.SourceRefs) - return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) + return output, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs), nil } -func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction { +func cloneOccurrence(input dnd.NPCOccurrence) dnd.NPCOccurrence { output := input if input.SourceRefs != nil { output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) @@ -208,19 +197,19 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []contracts.Warning) { if len(records) == 0 { - return make([]dnd.NPCInteraction, 0), nil + return make([]dnd.NPCOccurrence, 0), nil } keep := make([]bool, len(records)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for index, record := range records { - if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) { + if !interactionmodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) { keep[index] = true continue } - key := interactionmodel.ExactIdentity(record.interaction) + key := interactionmodel.ExactIdentity(record.occurrence) groupIndex, exists := groupByKey[key] if !exists { groupByKey[key] = len(groups) @@ -230,10 +219,10 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen } groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) } - output := make([]dnd.NPCInteraction, 0, len(records)) + output := make([]dnd.NPCOccurrence, 0, len(records)) for index, record := range records { if keep[index] { - output = append(output, cloneInteraction(record.interaction)) + output = append(output, cloneOccurrence(record.occurrence)) } } warnings := make([]contracts.Warning, 0) @@ -251,14 +240,14 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } return contracts.Warning{ - Scope: interactionScope(retainedIndex), + Scope: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues), } } -func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) } +func occurrenceScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) } func referenceSlots() []contracts.ReferenceSlot { slots := shared.ReferenceSlots(referenceSlotDescriptions) @@ -281,13 +270,13 @@ func ModuleSpec() pipeline.ModuleSpec { ExecutionClass: contracts.ExecutionClassDeterministic, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), - ArtifactKind: dnd.NPCInteractionListKind, + ArtifactKind: dnd.NPCOccurrenceListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { - return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCInteractionList], error) { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go index ee1e5c3..8ea9722 100644 --- a/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go @@ -14,37 +14,37 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) -func TestNormalizeCanonicalizesAndClones(t *testing.T) { +func TestNormalizeValidatesPairsAndClones(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatalf("New() error = %v", err) } - input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ - Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue, + input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{ + NPCID: identity.DeriveID("Ária"), Name: "Ária", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, }}} - original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...) - result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) + original := append([]source.SourceRef(nil), input.Occurrences[0].SourceRefs...) + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}}) if err != nil { t.Fatalf("Normalize() error = %v", err) } - got := result.Value.Interactions[0] - if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { + got := result.Value.Occurrences[0] + if got.NPCID != identity.DeriveID("Ária") || got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { t.Fatalf("normalized interaction = %#v", got) } - if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { + if !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { t.Fatalf("warnings = %#v", result.Warnings) } - if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) { + if !reflect.DeepEqual(input.Occurrences[0].SourceRefs, original) { t.Fatalf("Normalize() mutated input: %#v", input) } - second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}}) + second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}}) if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err) } - result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999 - if input.Interactions[0].SourceRefs[0].StartUnitID == 999 { + result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999 + if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("normalized source refs share input storage") } } @@ -54,14 +54,14 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te if err != nil { t.Fatalf("New() error = %v", err) } - if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil { + if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{}); err == nil { t.Fatal("Normalize() accepted an unbound NPC registry") } - for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} { - result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ - MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t), + for _, input := range []dnd.NPCOccurrenceList{{}, {Occurrences: []dnd.NPCOccurrence{}}} { + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{ + MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}, References: npcReferences(t), }) - if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) { + if err != nil || (result.Value.Occurrences == nil) != (input.Occurrences == nil) { t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input) } } @@ -70,16 +70,20 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te } } -func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) { +func TestNormalizeRejectsUnknownIDsAndMismatchedNames(t *testing.T) { doc := testDocument() normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } - input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}} - result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) - if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) { - t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err) + for _, occurrence := range []dnd.NPCOccurrence{ + interaction("Unknown NPC", dnd.NPCOccurrenceKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}), + {NPCID: identity.DeriveID("Ária"), Name: "Borin", Kind: dnd.NPCOccurrenceKindOther, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, + } { + input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{occurrence}} + if result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}}); err == nil { + t.Fatalf("Normalize() = %#v, %v; want registry pair rejection", result, err) + } } } @@ -88,28 +92,28 @@ func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) { ref := func(unit int) source.SourceRef { return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} } - first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50)) - input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ - interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)), + first := interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(50)) + input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ + interaction("Borin", dnd.NPCOccurrenceKindMentioned, ref(90)), first, first, - interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)), - interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)), - interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)), + interaction("Ária", dnd.NPCOccurrenceKindCombatAlly, ref(50)), + interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(10)), + interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(999)), }} normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } - result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}}) if err != nil { t.Fatal(err) } - got := result.Value.Interactions + got := result.Value.Occurrences if len(got) != 5 { t.Fatalf("interaction count = %d, want 5: %#v", len(got), got) } - if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 { + if got[0].Kind != dnd.NPCOccurrenceKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 { t.Fatalf("canonical order = %#v", got) } if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { @@ -122,13 +126,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { if err != nil { t.Fatal(err) } - if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 { + if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCOccurrenceListKind || len(spec.ReferenceSlots) == 0 { t.Fatalf("ModuleSpec() = %#v", spec) } - if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { + if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { t.Fatalf("metadata = %#v", metadata) } - if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" { + if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 2 || fingerprints[1].Name != "npc_registry" || fingerprints[1].Value == "" { t.Fatalf("fingerprints = %#v", fingerprints) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { @@ -139,13 +143,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { func TestNormalizeBoundsWarnings(t *testing.T) { count := diagnostics.MaxWarnings + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} - input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)} + input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)} for index := range doc.Units { doc.Units[index].ID = index + 1 unitID := count - index - input.Interactions[index] = interaction( + input.Occurrences[index] = interaction( "Ária", - dnd.NPCInteractionKindDialogue, + dnd.NPCOccurrenceKindDialogue, source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}, ) } @@ -153,8 +157,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ - Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{ + Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}, }) if err != nil { t.Fatal(err) @@ -165,8 +169,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) { } } -func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction { - return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} +func interaction(name string, kind dnd.NPCOccurrenceKind, ref source.SourceRef) dnd.NPCOccurrence { + return dnd.NPCOccurrence{NPCID: identity.DeriveID(name), Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} } func testDocument() *source.SourceDocument { diff --git a/internal/modules/dnd/npcinteractions/canonical.go b/internal/modules/dnd/npcinteractions/canonical.go index 8c12831..431418d 100644 --- a/internal/modules/dnd/npcinteractions/canonical.go +++ b/internal/modules/dnd/npcinteractions/canonical.go @@ -8,7 +8,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) @@ -26,8 +25,8 @@ func SourceRefsEqual(left, right []source.SourceRef) bool { return true } -// Less defines the canonical order for NPC interaction occurrences. -func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool { +// Less defines the canonical order for NPC occurrences. +func Less(order shared.SourceRefOrder, left, right dnd.NPCOccurrence) bool { leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) if leftHasEvidence != rightHasEvidence { @@ -36,8 +35,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool { if leftHasEvidence && leftPosition != rightPosition { return leftPosition < rightPosition } - leftKey := identity.ComparisonKey(left.Name) - rightKey := identity.ComparisonKey(right.Name) + leftKey := left.NPCID + rightKey := right.NPCID if leftKey != rightKey { return leftKey < rightKey } @@ -50,7 +49,7 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool { return sourceRefsLess(order, left.SourceRefs, right.SourceRefs) } -// ValidSourceRefs reports whether an interaction has non-empty, valid +// ValidSourceRefs reports whether an occurrence has non-empty, valid // current-document evidence. func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { if len(refs) == 0 { @@ -64,13 +63,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { return true } -// ExactIdentity returns a collision-safe key over every durable interaction +// ExactIdentity returns a collision-safe key over every durable occurrence // field. Callers decide whether the record is eligible for duplicate handling. -func ExactIdentity(interaction dnd.NPCInteraction) string { +func ExactIdentity(occurrence dnd.NPCOccurrence) string { var key strings.Builder - writeKeyString(&key, interaction.Name) - writeKeyString(&key, string(interaction.Kind)) - for _, ref := range interaction.SourceRefs { + writeKeyString(&key, occurrence.NPCID) + writeKeyString(&key, occurrence.Name) + writeKeyString(&key, string(occurrence.Kind)) + for _, ref := range occurrence.SourceRefs { writeKeyString(&key, ref.SourceID) writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.EndUnitID) diff --git a/internal/modules/dnd/npcs/registry/registry.go b/internal/modules/dnd/npcs/registry/registry.go index 88fd8cd..a8fd6c4 100644 --- a/internal/modules/dnd/npcs/registry/registry.go +++ b/internal/modules/dnd/npcs/registry/registry.go @@ -32,7 +32,9 @@ type Registry struct { digest string projectionDigest string promptInput contracts.LLMInputMaterial + identityInput contracts.LLMInputMaterial lookupByKey map[string]int + lookupByID map[string]int } // Resolver selects and memoizes immutable NPC registry views. @@ -109,7 +111,9 @@ func emptyRegistry() *Registry { canonical: append([]byte(nil), content...), projectionDigest: projectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), + identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), lookupByKey: map[string]int{}, + lookupByID: map[string]int{}, } } @@ -129,8 +133,10 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { list := cloneNPCRegistry(value) lookupByKey := make(map[string]int, len(list.NPCs)) + lookupByID := make(map[string]int, len(list.NPCs)) for index, npc := range list.NPCs { lookupByKey[identity.ComparisonKey(npc.Name)] = index + lookupByID[npc.ID] = index } digest := semanticDigest(content) projection, err := nameProjection(list) @@ -138,6 +144,11 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { return nil, fmt.Errorf("encode NPC name projection: %w", err) } projectionDigest := semanticDigest(projection) + identityProjection, err := identityProjection(list) + if err != nil { + return nil, fmt.Errorf("encode NPC identity projection: %w", err) + } + identityProjectionDigest := semanticDigest(identityProjection) return &Registry{ bound: true, list: list, @@ -146,6 +157,8 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { projectionDigest: projectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""), lookupByKey: lookupByKey, + identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""), + lookupByID: lookupByID, }, nil } @@ -211,6 +224,15 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial { return r.promptInput.Clone() } +// IdentityPromptInput returns the ordered ID/name projection for consumers +// that must bind output records to exact registry identities. +func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial { + if r == nil { + return contracts.LLMInputMaterial{} + } + return r.identityInput.Clone() +} + // Lookup returns the canonical NPC for an exact canonical-name match under the // NPC identity comparison policy. func (r *Registry) Lookup(value string) (dnd.NPC, bool) { @@ -224,6 +246,18 @@ func (r *Registry) Lookup(value string) (dnd.NPC, bool) { return cloneNPC(r.list.NPCs[index]), true } +// LookupID returns the canonical NPC for an exact durable ID. +func (r *Registry) LookupID(value string) (dnd.NPC, bool) { + if r == nil { + return dnd.NPC{}, false + } + index, ok := r.lookupByID[value] + if !ok { + return dnd.NPC{}, false + } + return cloneNPC(r.list.NPCs[index]), true +} + func semanticDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) @@ -233,10 +267,19 @@ type projectedNPC struct { Name string `json:"name"` } +type identityProjectedNPC struct { + ID string `json:"id"` + Name string `json:"name"` +} + type projectedNPCRegistry struct { NPCs []projectedNPC `json:"npcs"` } +type identityProjectedNPCRegistry struct { + NPCs []identityProjectedNPC `json:"npcs"` +} + func nameProjection(list dnd.NPCRegistry) ([]byte, error) { projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))} for index, npc := range list.NPCs { @@ -245,6 +288,14 @@ func nameProjection(list dnd.NPCRegistry) ([]byte, error) { return json.Marshal(projection) } +func identityProjection(list dnd.NPCRegistry) ([]byte, error) { + projection := identityProjectedNPCRegistry{NPCs: make([]identityProjectedNPC, len(list.NPCs))} + for index, npc := range list.NPCs { + projection.NPCs[index] = identityProjectedNPC{ID: npc.ID, Name: npc.Name} + } + return json.Marshal(projection) +} + func formatIdentityIssues(issues []identity.Issue) string { parts := make([]string, len(issues)) for index, issue := range issues { diff --git a/internal/modules/dnd/npcs/registry/registry_test.go b/internal/modules/dnd/npcs/registry/registry_test.go index 58a7246..23ec396 100644 --- a/internal/modules/dnd/npcs/registry/registry_test.go +++ b/internal/modules/dnd/npcs/registry/registry_test.go @@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) { t.Fatalf("Resolve() error = %v", err) } input := registry.PromptInput() - if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { + if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt { t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input) } if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { @@ -46,6 +46,21 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T) } } +func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) { + registry := resolveList(t, registryFixture()) + input := registry.IdentityPromptInput() + want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}` + if string(input.Content) != want || input.Digest == registry.ProjectionDigest() { + t.Fatalf("identity projection = %#v, want %s", input, want) + } + if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" { + t.Fatalf("LookupID() = %#v, %t", npc, ok) + } + if _, ok := registry.LookupID("npc:unknown"); ok { + t.Fatal("LookupID() accepted an unknown ID") + } +} + func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) { base := registryFixture() evidenceChanged := registryFixture() diff --git a/internal/modules/dnd/register/evidence.go b/internal/modules/dnd/register/evidence.go index f58ace0..33455c3 100644 --- a/internal/modules/dnd/register/evidence.go +++ b/internal/modules/dnd/register/evidence.go @@ -20,7 +20,7 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error { return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence) }}, {name: "npc interactions evidence", register: func() error { - return pipeline.RegisterArtifactEvidence(registry, dnd.NPCInteractionListKind, npcInteractionEvidence) + return pipeline.RegisterArtifactEvidence(registry, dnd.NPCOccurrenceListKind, npcInteractionEvidence) }}, {name: "scene descriptions evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence) @@ -74,9 +74,9 @@ func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef { return append([]source.SourceRef(nil), refs...) } -func npcInteractionEvidence(value dnd.NPCInteractionList) []source.SourceRef { +func npcInteractionEvidence(value dnd.NPCOccurrenceList) []source.SourceRef { var refs []source.SourceRef - for _, record := range value.Interactions { + for _, record := range value.Occurrences { refs = append(refs, record.SourceRefs...) } return append([]source.SourceRef(nil), refs...) diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index b5fbea8..78d9e1d 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -112,22 +112,22 @@ func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) return combined, nil } -func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) { +func appendNPCInteractionLists(values []dnd.NPCOccurrenceList) (dnd.NPCOccurrenceList, error) { count := 0 present := false for _, value := range values { - if value.Interactions != nil { + if value.Occurrences != nil { present = true } - count += len(value.Interactions) + count += len(value.Occurrences) } if !present { - return dnd.NPCInteractionList{}, nil + return dnd.NPCOccurrenceList{}, nil } - combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)} + combined := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 0, count)} for _, value := range values { - for _, interaction := range value.Interactions { - combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction)) + for _, occurrence := range value.Occurrences { + combined.Occurrences = append(combined.Occurrences, cloneNPCOccurrence(occurrence)) } } return combined, nil @@ -228,7 +228,7 @@ func cloneNPC(value dnd.NPC) dnd.NPC { return clone } -func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction { +func cloneNPCOccurrence(value dnd.NPCOccurrence) dnd.NPCOccurrence { clone := value clone.SourceRefs = cloneSourceRefs(value.SourceRefs) return clone diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 73125e0..2b2a1bf 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -79,7 +79,7 @@ func registerModules(registries pipeline.Registries) error { return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists) }}, {name: "npc-interaction-list appendorder merger", register: func() error { - return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists) + return appendorder.RegisterTyped(registries.Mergers, dnd.NPCOccurrenceListKind, appendNPCInteractionLists) }}, {name: "scene-description-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists) @@ -115,7 +115,7 @@ func registerModules(registries pipeline.Registries) error { return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind) }}, {name: "npc-interaction-list noop normalizer", register: func() error { - return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind) + return noop.RegisterTyped[dnd.NPCOccurrenceList](registries.Normalizers, dnd.NPCOccurrenceListKind) }}, {name: "scene-description-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind) diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 45fbbab..3dab85b 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -84,15 +84,15 @@ 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.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.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, 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.NPCOccurrenceListKind, 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.NPCOccurrenceListKind, 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}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness", @@ -302,7 +302,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd_combat_turns_llm.v1.json", "dnd_enemy_events_llm.v1.json", "dnd_item_events_llm.v1.json", - "dnd_npc_interactions_llm.v1.json", + "dnd_npc_occurrences_llm.v1.json", "dnd_scene_descriptions_llm.v1.json", "dnd_locations_llm.v1.json", "dnd_location_occurrences_llm.v1.json", @@ -353,7 +353,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { } interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key) interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key) - if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { + if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCOccurrenceListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCOccurrenceListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK) } locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key) @@ -419,7 +419,7 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "npc interactions", project: func() []source.SourceRef { - return npcInteractionEvidence(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{first, second}}}}) + return npcInteractionEvidence(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "scene descriptions", project: func() []source.SourceRef { return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}}) @@ -561,26 +561,26 @@ func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) { func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}} - input := []dnd.NPCInteractionList{ + input := []dnd.NPCOccurrenceList{ {}, - {Interactions: []dnd.NPCInteraction{}}, - {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}}, - {Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, + {Occurrences: []dnd.NPCOccurrence{}}, + {Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}}, + {Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, } got, err := appendNPCInteractionLists(input) if err != nil { t.Fatalf("appendNPCInteractionLists() error = %v", err) } - if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) { + if got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"Aria", "Borin"}) { t.Fatalf("combined interactions = %#v", got) } - got.Interactions[0].SourceRefs[0].StartUnitID = 999 - if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 { + got.Occurrences[0].SourceRefs[0].StartUnitID = 999 + if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("merged interactions share source reference storage") } - for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} { + for _, values := range [][]dnd.NPCOccurrenceList{nil, []dnd.NPCOccurrenceList{{}, {}}} { result, err := appendNPCInteractionLists(values) - if err != nil || result.Interactions != nil { + if err != nil || result.Occurrences != nil { t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err) } } @@ -711,8 +711,8 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err) } - interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}}) - if err != nil || interactions.Interactions[0].SourceRefs == nil { + interactions, err := appendNPCInteractionLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}}) + if err != nil || interactions.Occurrences[0].SourceRefs == nil { t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err) } diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index fa1912a..f6468f7 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -118,10 +118,10 @@ func registerValidators(registries pipeline.Registries) error { return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) }}, {name: "npc-interaction-list always accept validator", register: func() error { - return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) + return alwaysaccept.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind) }}, {name: "npc-interaction-list always reject validator", register: func() error { - return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) + return alwaysreject.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind) }}, {name: "scene-description-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind) diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 58059fb..3d34849 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -12,7 +12,7 @@ const NPCRegistryKind contracts.ArtifactKind = "dnd/npc-registry" const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list" -const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list" +const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list" const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list" @@ -64,24 +64,25 @@ type CombatTurn struct { SourceRefs []source.SourceRef `json:"source_refs"` } -type NPCInteractionKind string +type NPCOccurrenceKind string const ( - NPCInteractionKindMentioned NPCInteractionKind = "mentioned" - NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence" - NPCInteractionKindDialogue NPCInteractionKind = "dialogue" - NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally" - NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent" - NPCInteractionKindOther NPCInteractionKind = "other" + NPCOccurrenceKindMentioned NPCOccurrenceKind = "mentioned" + NPCOccurrenceKindNoncombatPresence NPCOccurrenceKind = "noncombat_presence" + NPCOccurrenceKindDialogue NPCOccurrenceKind = "dialogue" + NPCOccurrenceKindCombatAlly NPCOccurrenceKind = "combat_ally" + NPCOccurrenceKindCombatOpponent NPCOccurrenceKind = "combat_opponent" + NPCOccurrenceKindOther NPCOccurrenceKind = "other" ) -type NPCInteractionList struct { - Interactions []NPCInteraction `json:"interactions"` +type NPCOccurrenceList struct { + Occurrences []NPCOccurrence `json:"occurrences"` } -type NPCInteraction struct { +type NPCOccurrence struct { + NPCID string `json:"npc_id"` Name string `json:"name"` - Kind NPCInteractionKind `json:"kind"` + Kind NPCOccurrenceKind `json:"kind"` SourceRefs []source.SourceRef `json:"source_refs"` } diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go index f12d544..a012555 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go @@ -29,7 +29,7 @@ type Validator struct { npcResolver *npcregistry.Resolver } -var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil) var _ contracts.ManifestMetadataProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) @@ -72,11 +72,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "policy", Value: policy}, - {Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()}, + {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest}, } } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) { if interactionshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -105,25 +105,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq }, nil } -func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool { - for _, interaction := range value.Interactions { - if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) { +func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCOccurrenceList) bool { + for _, occurrence := range value.Occurrences { + if !interactionmodel.ValidSourceRefs(index, occurrence.SourceRefs) { return false } } return true } -func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string { +func issuesFor(order shared.SourceRefOrder, value dnd.NPCOccurrenceList, npcRegistry *npcregistry.Registry) []string { issues := make([]string, 0) - for index, interaction := range value.Interactions { - prefix := fmt.Sprintf("interactions[%d]", index) - if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name { - issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name)) + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + canonical, ok := npcRegistry.LookupID(occurrence.NPCID) + if !ok { + issues = append(issues, prefix+".npc_id is not in the NPC registry: "+diagnostics.Quote(occurrence.NPCID)) + } else if occurrence.Name != canonical.Name { + issues = append(issues, prefix+".name does not match npc_id: "+diagnostics.Quote(occurrence.Name)) } - for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ { - previous := interaction.SourceRefs[refIndex-1] - current := interaction.SourceRefs[refIndex] + for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ { + previous := occurrence.SourceRefs[refIndex-1] + current := occurrence.SourceRefs[refIndex] if order.Less(current, previous) { issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex)) } else if current == previous { @@ -132,16 +135,16 @@ func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcReg } } - if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool { - return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right]) + if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool { + return interactionmodel.Less(order, value.Occurrences[left], value.Occurrences[right]) }) { - issues = append(issues, "interactions are not in canonical order") + issues = append(issues, "occurrences are not in canonical order") } seen := make(map[string]int) - for index, interaction := range value.Interactions { - key := interactionmodel.ExactIdentity(interaction) + for index, occurrence := range value.Occurrences { + key := interactionmodel.ExactIdentity(occurrence) if previous, ok := seen[key]; ok { - issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous)) + issues = append(issues, fmt.Sprintf("occurrences[%d] duplicates occurrence %d", index, previous)) continue } seen[key] = index @@ -154,7 +157,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go index 79ab547..b632c26 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go @@ -28,26 +28,26 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates( references := registryReferences(t, "Aria", "Borin") for _, test := range []struct { name string - mutate func(*dnd.NPCInteractionList) + mutate func(*dnd.NPCOccurrenceList) want string }{ - {"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"}, - {"reference order", func(value *dnd.NPCInteractionList) { - value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + {"mismatched name", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].Name = " aria " }, "does not match npc_id"}, + {"reference order", func(value *dnd.NPCOccurrenceList) { + value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} }, "not in canonical order"}, - {"duplicate reference", func(value *dnd.NPCInteractionList) { - value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0]) + {"duplicate reference", func(value *dnd.NPCOccurrenceList) { + value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0]) }, "duplicates the previous reference"}, - {"list order", func(value *dnd.NPCInteractionList) { - value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0] - }, "interactions are not in canonical order"}, - {"name tie breaker", func(value *dnd.NPCInteractionList) { - value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} - value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} - }, "interactions are not in canonical order"}, - {"duplicate record", func(value *dnd.NPCInteractionList) { - value.Interactions = append(value.Interactions, value.Interactions[0]) - }, "duplicates interaction"}, + {"list order", func(value *dnd.NPCOccurrenceList) { + value.Occurrences[0], value.Occurrences[1] = value.Occurrences[1], value.Occurrences[0] + }, "occurrences are not in canonical order"}, + {"NPC ID tie breaker", func(value *dnd.NPCOccurrenceList) { + value.Occurrences[0] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} + value.Occurrences[1] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} + }, "occurrences are not in canonical order"}, + {"duplicate record", func(value *dnd.NPCOccurrenceList) { + value.Occurrences = append(value.Occurrences, value.Occurrences[0]) + }, "duplicates occurrence"}, } { t.Run(test.name, func(t *testing.T) { value := normalizedList() @@ -62,9 +62,9 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates( func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) { references := registryReferences(t, "Aria", "Borin") - value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ - {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, - {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, + value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ + {NPCID: "npc:test", Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, + {NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, }} result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) if err != nil || !result.Approved { @@ -74,9 +74,9 @@ func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) { func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) { references := registryReferences(t, "Aria", "Borin") - for _, value := range []dnd.NPCInteractionList{ - {Interactions: []dnd.NPCInteraction{{Name: "Aria"}}}, - {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, + for _, value := range []dnd.NPCOccurrenceList{ + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria"}}}, + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, } { result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) if err != nil || !result.Approved { @@ -126,26 +126,26 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator return validator } -func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { - return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value} +func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] { + return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: document(), References: references, Value: value} } func document() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} } -func normalizedList() dnd.NPCInteractionList { - return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ - {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, - {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, +func normalizedList() dnd.NPCOccurrenceList { + return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ + {NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + {NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, }} } -func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList { - copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))} - for index, interaction := range value.Interactions { - copyValue.Interactions[index] = interaction - copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...) +func cloneList(value dnd.NPCOccurrenceList) dnd.NPCOccurrenceList { + copyValue := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, len(value.Occurrences))} + for index, interaction := range value.Occurrences { + copyValue.Occurrences[index] = interaction + copyValue.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...) } return copyValue } diff --git a/internal/modules/dnd/validate/npcinteractions/registry/validator.go b/internal/modules/dnd/validate/npcinteractions/registry/validator.go index 51fa0c1..9d17e31 100644 --- a/internal/modules/dnd/validate/npcinteractions/registry/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/registry/validator.go @@ -25,7 +25,7 @@ type Validator struct { npcResolver *npcregistry.Resolver } -var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil) var _ contracts.ManifestMetadataProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) @@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "policy", Value: policy}, - {Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()}, + {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest}, } } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) { if interactionshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -87,9 +87,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return rejection([]string{"NPC registry reference is required"}), nil } issues := make([]string, 0) - for index, interaction := range req.Value.Interactions { - if _, ok := npcRegistry.Lookup(interaction.Name); !ok { - issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name))) + for index, occurrence := range req.Value.Occurrences { + canonical, ok := npcRegistry.LookupID(occurrence.NPCID) + if !ok { + issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID))) + continue + } + if occurrence.Name != canonical.Name { + issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name))) } } if len(issues) == 0 { @@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go index 8673571..4555aa5 100644 --- a/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go @@ -16,19 +16,25 @@ import ( npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" ) -func TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) { +func TestValidatorRecognizesExactRegistryPairsAndRejectsUnknownIDs(t *testing.T) { references := registryReferences(t, "Mira Thorn") validator := newValidator(t, references) - value := validList(" mira thorn ") + value := validList("Mira Thorn") result, err := validator.Validate(context.Background(), request(references, value)) if err != nil || !result.Approved { t.Fatalf("recognized result = %#v, %v", result, err) } - value.Interactions[0].Name = "Unknown NPC" + value.Occurrences[0].NPCID = "npc:unknown" result, err = validator.Validate(context.Background(), request(references, value)) - if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") { + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].npc_id") { t.Fatalf("unknown result = %#v, %v", result, err) } + value = validList("Mira Thorn") + value.Occurrences[0].Name = "Hooded Guard" + result, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match npc_id") { + t.Fatalf("mismatched result = %#v, %v", result, err) + } } func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) { @@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t } empty := registryReferences(t) - result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}})) + result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}})) if err != nil || !result.Approved { t.Fatalf("empty registry result = %#v, %v", result, err) } @@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t * func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) { references := registryReferences(t, "Mira Thorn") validator := newValidator(t, references) - malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} + malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}} result, err := validator.Validate(context.Background(), request(references, malformed)) if err != nil || !result.Approved { t.Fatalf("shape deferral = %#v, %v", result, err) @@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator return validator } -func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { - return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value} +func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] { + return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value} } -func validList(name string) dnd.NPCInteractionList { - return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ - Name: name, Kind: dnd.NPCInteractionKindDialogue, +func validList(name string) dnd.NPCOccurrenceList { + return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{ + NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, }}} } diff --git a/internal/modules/dnd/validate/npcinteractions/shape/validator.go b/internal/modules/dnd/validate/npcinteractions/shape/validator.go index 9b29d0f..ef685bf 100644 --- a/internal/modules/dnd/validate/npcinteractions/shape/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/shape/validator.go @@ -21,7 +21,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -33,14 +33,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) { if err := Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil } return contracts.ValidationResult{Approved: true}, nil } -func Validate(value dnd.NPCInteractionList) error { +func Validate(value dnd.NPCOccurrenceList) error { issues := issuesFor(value) if len(issues) == 0 { return nil @@ -48,34 +48,37 @@ func Validate(value dnd.NPCInteractionList) error { return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues)) } -func issuesFor(value dnd.NPCInteractionList) []string { - if value.Interactions == nil { - return []string{"interactions must be present"} +func issuesFor(value dnd.NPCOccurrenceList) []string { + if value.Occurrences == nil { + return []string{"occurrences must be present"} } issues := make([]string, 0) - for index, interaction := range value.Interactions { - prefix := fmt.Sprintf("interactions[%d]", index) - if strings.TrimSpace(interaction.Name) == "" { - issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name)) + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if strings.TrimSpace(occurrence.NPCID) == "" { + issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID)) } - if !validKind(interaction.Kind) { - issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind))) + if strings.TrimSpace(occurrence.Name) == "" { + issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name)) } - if len(interaction.SourceRefs) == 0 { + if !validKind(occurrence.Kind) { + issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind))) + } + if len(occurrence.SourceRefs) == 0 { issues = append(issues, prefix+".source_refs must contain at least one reference") } } return issues } -func validKind(value dnd.NPCInteractionKind) bool { +func validKind(value dnd.NPCOccurrenceKind) bool { switch value { - case dnd.NPCInteractionKindMentioned, - dnd.NPCInteractionKindNoncombatPresence, - dnd.NPCInteractionKindDialogue, - dnd.NPCInteractionKindCombatAlly, - dnd.NPCInteractionKindCombatOpponent, - dnd.NPCInteractionKindOther: + case dnd.NPCOccurrenceKindMentioned, + dnd.NPCOccurrenceKindNoncombatPresence, + dnd.NPCOccurrenceKindDialogue, + dnd.NPCOccurrenceKindCombatAlly, + dnd.NPCOccurrenceKindCombatOpponent, + dnd.NPCOccurrenceKindOther: return true default: return false @@ -87,7 +90,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go b/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go index 208bbf7..34d2731 100644 --- a/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go @@ -15,22 +15,22 @@ import ( func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) { valid := validList() - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: valid}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: valid}) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } for _, test := range []struct { name string - value dnd.NPCInteractionList + value dnd.NPCOccurrenceList want string }{ - {"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"}, - {"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[0].SourceRefs}}}, "name must not be empty"}, - {"unsupported kind", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Interactions[0].SourceRefs}}}, "kind is unsupported"}, - {"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"}, + {"missing occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"}, + {"blank name", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: valid.Occurrences[0].SourceRefs}}}, "name must not be empty"}, + {"unsupported kind", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Occurrences[0].SourceRefs}}}, "kind is unsupported"}, + {"missing evidence", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue}}}, "source_refs must contain"}, } { t.Run(test.name, func(t *testing.T) { - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: test.value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: test.value}) if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want) } @@ -39,15 +39,15 @@ func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) { } func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) { - value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)} - for index := range value.Interactions { - value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"} + value := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 24)} + for index := range value.Occurrences { + value.Occurrences[index] = dnd.NPCOccurrence{NPCID: "npc:test", Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"} } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: value}) if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") { t.Fatalf("Validate() = %#v, %v", result, err) } - if value.Interactions[0].Name != strings.Repeat("火", 220)+"\n" { + if value.Occurrences[0].Name != strings.Repeat("火", 220)+"\n" { t.Fatal("Validate() mutated input") } if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { @@ -62,9 +62,9 @@ func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *te } } -func validList() dnd.NPCInteractionList { - return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ - Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, +func validList() dnd.NPCOccurrenceList { + return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{ + NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, }}} } diff --git a/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go b/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go index 8195efb..d74d342 100644 --- a/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go @@ -22,7 +22,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) { if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { return contracts.ValidationResult{}, fmt.Errorf("NPC interaction source-reference validator requires the current extraction chunk") } @@ -43,16 +43,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } index := source.NewDocumentIndex(req.Source) issues := make([]string, 0) - for interactionIndex, interaction := range req.Value.Interactions { - for refIndex, ref := range interaction.SourceRefs { + for occurrenceIndex, occurrence := range req.Value.Occurrences { + for refIndex, ref := range occurrence.SourceRefs { if err := index.ValidateRef(ref); err != nil { - issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error()))) + issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error()))) continue } if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) { issues = append(issues, fmt.Sprintf( - "interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk", - interactionIndex, refIndex, + "occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", + occurrenceIndex, refIndex, )) } } @@ -85,7 +85,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go b/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go index 6851826..a18e293 100644 --- a/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go @@ -18,13 +18,13 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) { if err != nil || !result.Approved { t.Fatalf("valid result = %#v, %v", result, err) } - value.Interactions[0].SourceRefs = []source.SourceRef{ + value.Occurrences[0].SourceRefs = []source.SourceRef{ {SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 99, EndUnitID: 99}, {SourceID: "session", StartUnitID: 2, EndUnitID: 1}, } result, err = New(Options{}).Validate(context.Background(), request(document(), value)) - if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].source_refs[0]") { + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") { t.Fatalf("invalid result = %#v, %v", result, err) } } @@ -37,7 +37,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t Units: append([]source.SourceUnit(nil), doc.Units[:2]...), } value := validList() - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{ Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, @@ -47,10 +47,10 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t t.Fatalf("contained evidence = %#v, %v", result, err) } - value.Interactions[0].SourceRefs = []source.SourceRef{{ + value.Occurrences[0].SourceRefs = []source.SourceRef{{ SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3, }} - result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{ Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, @@ -62,7 +62,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t } func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) { - _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{ Stage: string(pipeline.StageExtract), Source: document(), Value: validList(), @@ -73,7 +73,7 @@ func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) { } func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) { - malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} + malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}} result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) if err != nil || !result.Approved { t.Fatalf("shape deferral = %#v, %v", result, err) @@ -93,17 +93,17 @@ func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) { } } -func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { - return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value} +func request(doc *source.SourceDocument, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] { + return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, Value: value} } func document() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}} } -func validList() dnd.NPCInteractionList { - return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ - Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, +func validList() dnd.NPCOccurrenceList { + return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{ + NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}, }}} } diff --git a/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go index a5b8465..cdaf7cf 100644 --- a/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go @@ -23,7 +23,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) { if interactionshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -43,23 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err != nil { return contracts.ValidationResult{Approved: true}, nil } - citedTexts := make([]string, len(req.Value.Interactions)) - for index, interaction := range req.Value.Interactions { - citedText, err := resolver.CitedText(interaction.SourceRefs) + citedTexts := make([]string, len(req.Value.Occurrences)) + for index, occurrence := range req.Value.Occurrences { + citedText, err := resolver.CitedText(occurrence.SourceRefs) if err != nil { return contracts.ValidationResult{Approved: true}, nil } citedTexts[index] = citedText } warnings := make([]contracts.Warning, 0) - for index, interaction := range req.Value.Interactions { - if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) { + for index, occurrence := range req.Value.Occurrences { + if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { continue } warnings = append(warnings, contracts.Warning{ - Scope: fmt.Sprintf("interactions[%d]", index), + Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: WarningReasonCode, - Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(interaction.Name)), + Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)), }) } return contracts.ValidationResult{ @@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go index 9eb812f..df26895 100644 --- a/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go @@ -14,34 +14,34 @@ import ( ) func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) { - value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ - {Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, - {Name: "Missing\nNPC", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, + value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ + {NPCID: "npc:test", Name: "O'Rin Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, + {NPCID: "npc:test", Name: "Missing\nNPC", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, }} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "O’Rin Thorn speaks."}, {ID: 2, Text: "The party waits."}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, References: references, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, References: references, Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 1 { t.Fatalf("Validate() = %#v, %v", result, err) } - if warning := result.Warnings[0]; warning.Scope != "interactions[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) { + if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) { t.Fatalf("warning = %#v", warning) } } func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { - for _, value := range []dnd.NPCInteractionList{ - {Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}, - {Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, + for _, value := range []dnd.NPCOccurrenceList{ + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}, + {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, } { - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("deferral = %#v, %v", result, err) } } - value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} + value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} before := value - _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value}) + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value}) if err != nil || !reflect.DeepEqual(value, before) { t.Fatalf("Validate() mutated value: %#v", value) } @@ -53,17 +53,18 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { func TestValidatorBoundsWarnings(t *testing.T) { count := diagnostics.MaxWarnings + 5 - interactions := make([]dnd.NPCInteraction, count) + interactions := make([]dnd.NPCOccurrence, count) for index := range interactions { - interactions[index] = dnd.NPCInteraction{ + interactions[index] = dnd.NPCOccurrence{ + NPCID: "npc:test", Name: "Missing NPC", - Kind: dnd.NPCInteractionKindMentioned, + Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, } } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{ Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}}, - Value: dnd.NPCInteractionList{Interactions: interactions}, + Value: dnd.NPCOccurrenceList{Occurrences: interactions}, }) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) diff --git a/internal/modules/integration/dnd_npc_interactions_runner_test.go b/internal/modules/integration/dnd_npc_interactions_runner_test.go index 95186f6..d16ff91 100644 --- a/internal/modules/integration/dnd_npc_interactions_runner_test.go +++ b/internal/modules/integration/dnd_npc_interactions_runner_test.go @@ -36,7 +36,7 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing } request := client.requestFor(t, interactionextract.PromptID) - wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` + wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}` if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry { t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry) } @@ -52,25 +52,25 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing if err != nil { t.Fatalf("Decode(interaction output) error = %v", err) } - if len(interactions.Interactions) != 2 { + if len(interactions.Occurrences) != 2 { t.Fatalf("interactions = %#v, want two occurrences", interactions) } - first, second := interactions.Interactions[0], interactions.Interactions[1] + first, second := interactions.Occurrences[0], interactions.Occurrences[1] if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" { t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions) } assertInteractionEvidence(t, first.SourceRefs) assertInteractionEvidence(t, second.SourceRefs) if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID { - t.Fatalf("interaction chronology = %#v, want source order", interactions.Interactions) + t.Fatalf("interaction chronology = %#v, want source order", interactions.Occurrences) } var durable map[string]json.RawMessage if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil { t.Fatalf("unmarshal durable interaction payload: %v", err) } - if len(durable) != 1 || durable["interactions"] == nil { - t.Fatalf("durable interaction payload = %#v, want only interactions", durable) + if len(durable) != 1 || durable["occurrences"] == nil { + t.Fatalf("durable interaction payload = %#v, want only occurrences", durable) } } @@ -113,8 +113,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) { t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID) } registryRequest := client.requestFor(t, interactionextract.PromptID) - if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` { - t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got) + if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` { + t.Fatalf("downstream registry = %s, want one canonical NPC identity", got) } manifestContent, err := json.Marshal(output.Manifest) if err != nil { @@ -228,9 +228,9 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r case npcnormalize.PromptID: payload = map[string]any{"duplicate_groups": []any{}} case interactionextract.PromptID: - payload = map[string]any{"interactions": []any{ - map[string]any{"name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}, - map[string]any{"name": " mira thorn ", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}}, + payload = map[string]any{"occurrences": []any{ + map[string]any{"npc_id": identity.DeriveID("Hooded Guard"), "name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}, + map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}}, }} default: return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID) @@ -291,7 +291,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context case npcnormalize.PromptID: payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}} case interactionextract.PromptID: - payload = map[string]any{"interactions": []any{map[string]any{"name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}} + payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}} default: return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID) }