diff --git a/assets/dnd/item-events/prompts/instructions.md b/assets/dnd/item-events/prompts/instructions.md index 8d9c700..85537a3 100644 --- a/assets/dnd/item-events/prompts/instructions.md +++ b/assets/dnd/item-events/prompts/instructions.md @@ -1,9 +1,10 @@ -Extract meaningful Dungeons & Dragons item and currency discoveries and changes +Extract meaningful Dungeons & Dragons item and currency occurrences: discoveries and changes in party possession established by the transcript. This is an event history, not an inventory or ledger: do not calculate balances, resolve item identity across records, or infer ownership that the transcript does not establish. -Record a stated quantity as an integer and leave it null when the transcript +For every occurrence, copy the exact `item_id` and `name` pair from the supplied +item registry. Record a stated quantity as an integer and leave it null when the transcript does not state one. Use a concise observed item name and preserve the stated currency denomination. diff --git a/assets/dnd/item-events/prompts/item-registry.md b/assets/dnd/item-events/prompts/item-registry.md new file mode 100644 index 0000000..8ae3993 --- /dev/null +++ b/assets/dnd/item-events/prompts/item-registry.md @@ -0,0 +1,6 @@ +Use the supplied item registry only to ground each occurrence. Every record +must copy one registry item's exact `id` and exact `name`; do not invent, +rename, merge, or infer registry items. The registry is not transcript +evidence: cite only the current transcript chunk in `source_refs`. + +{{ input "item_registry" }} diff --git a/assets/dnd/item-events/prompts/prompt.yaml b/assets/dnd/item-events/prompts/prompt.yaml index f098a45..e9ff044 100644 --- a/assets/dnd/item-events/prompts/prompt.yaml +++ b/assets/dnd/item-events/prompts/prompt.yaml @@ -14,6 +14,9 @@ inputs: - name: glossary required: false content_type: text/plain + - name: item_registry + required: true + content_type: application/json messages: - role: system content_file: ./sharedassets/common-dnd-system.md @@ -29,6 +32,8 @@ messages: type: ephemeral - role: user content_file: ./sharedassets/common-dnd-extraction-evidence.md + - role: user + content_file: ./item-registry.md - role: user content_file: ./instructions.md cache_control: @@ -36,5 +41,5 @@ messages: output: format: json validation_mode: json_schema - schema_path: dnd_item_events_llm.v1.json + schema_path: dnd_item_occurrences_llm.v1.json repair_attempts: 0 diff --git a/assets/dnd/item-events/schemas/dnd_item_events_llm.v1.json b/assets/dnd/item-events/schemas/dnd_item_occurrences_llm.v1.json similarity index 79% rename from assets/dnd/item-events/schemas/dnd_item_events_llm.v1.json rename to assets/dnd/item-events/schemas/dnd_item_occurrences_llm.v1.json index 509d823..8ef3dfb 100644 --- a/assets/dnd/item-events/schemas/dnd_item_events_llm.v1.json +++ b/assets/dnd/item-events/schemas/dnd_item_occurrences_llm.v1.json @@ -1,17 +1,18 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "notarius.dnd.item_events.llm", + "$id": "notarius.dnd.item_occurrences.llm", "type": "object", "additionalProperties": false, - "required": ["events"], + "required": ["occurrences"], "properties": { - "events": { + "occurrences": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["name", "kind", "quantity", "from", "to", "source_refs"], + "required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"], "properties": { + "item_id": {"type": "string"}, "name": {"type": "string"}, "kind": {"type": "string"}, "quantity": {"type": ["integer", "null"]}, diff --git a/docs/config.md b/docs/config.md index 852411c..57c11a3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -411,9 +411,10 @@ generated bindings. `dnd/location-registry` and `dnd/item-registry` extraction and normalization are `llm_backed`; their normalizers may use the pipeline's selected LLM profile -for bounded duplicate proposals. `dnd/location-occurrences` extraction is -`llm_backed`, while its normalizer is `deterministic`. The complete example -binds the location registry in one step and the occurrence lane in the next. +for bounded duplicate proposals. `dnd/location-occurrences` and +`dnd/item-events` extraction are `llm_backed`, while their normalizers are +`deterministic`. The complete example binds each registry in an earlier step +before its occurrence consumer. The D&D artifact contracts define each emitted schema: [spells](integrations/dnd-spell-artifacts.md), @@ -437,7 +438,7 @@ Available validator keys are: | Spells | **extract/dnd/spells/shape**, **extract/dnd/spells/catalog**, **extract/dnd/spells/source_refs**, **extract/dnd/spells/source_relatedness** | | NPC registry | **extract/dnd/npc-registry/shape**, **extract/dnd/npc-registry/source_refs**, **extract/dnd/npc-registry/source_relatedness**, **normalize/dnd/npc-registry/identity** | | Combat turns | **extract/dnd/combat-turns/shape**, **extract/dnd/combat-turns/source_refs**, **extract/dnd/combat-turns/source_relatedness**, **normalize/dnd/combat-turns/invariants** | -| Item events | **extract/dnd/item-events/shape**, **extract/dnd/item-events/source_refs**, **extract/dnd/item-events/source_relatedness**, **normalize/dnd/item-events/invariants** | +| Item events | **extract/dnd/item-events/shape**, **extract/dnd/item-events/registry**, **extract/dnd/item-events/source_refs**, **extract/dnd/item-events/source_relatedness**, **normalize/dnd/item-events/invariants** | | Item registry | **extract/dnd/item-registry/shape**, **extract/dnd/item-registry/source_refs**, **extract/dnd/item-registry/source_relatedness**, **normalize/dnd/item-registry/identity** | | NPC occurrences | **extract/dnd/npc-occurrences/shape**, **extract/dnd/npc-occurrences/registry**, **extract/dnd/npc-occurrences/source_refs**, **extract/dnd/npc-occurrences/source_relatedness**, **normalize/dnd/npc-occurrences/invariants** | | Scene descriptions | **extract/dnd/scene-descriptions/shape**, **extract/dnd/scene-descriptions/source_refs**, **extract/dnd/scene-descriptions/source_relatedness**, **normalize/dnd/scene-descriptions/invariants** | @@ -454,7 +455,7 @@ same at both stages. | Spells | generic/valid_json, extract/dnd/spells/shape, extract/dnd/spells/catalog, extract/dnd/spells/source_refs, generic/valid_json_schema, extract/dnd/spells/source_relatedness | Same as extract | | NPC registry | generic/valid_json, extract/dnd/npc-registry/shape, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness | generic/valid_json, extract/dnd/npc-registry/shape, normalize/dnd/npc-registry/identity, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness | | Combat turns | generic/valid_json, extract/dnd/combat-turns/shape, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/source_relatedness | generic/valid_json, extract/dnd/combat-turns/shape, normalize/dnd/combat-turns/invariants, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/source_relatedness | -| Item events | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | generic/valid_json, extract/dnd/item-events/shape, normalize/dnd/item-events/invariants, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | +| Item events | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/registry, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/registry, normalize/dnd/item-events/invariants, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | | Item registry | generic/valid_json, extract/dnd/item-registry/shape, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness | generic/valid_json, extract/dnd/item-registry/shape, normalize/dnd/item-registry/identity, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness | | NPC occurrences | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/source_relatedness | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, normalize/dnd/npc-occurrences/invariants, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/source_relatedness | | Scene descriptions | generic/valid_json, extract/dnd/scene-descriptions/shape, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness | generic/valid_json, extract/dnd/scene-descriptions/shape, normalize/dnd/scene-descriptions/invariants, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness | diff --git a/docs/integrations/dnd-item-event-artifacts.md b/docs/integrations/dnd-item-event-artifacts.md index 8f9c75f..5993a73 100644 --- a/docs/integrations/dnd-item-event-artifacts.md +++ b/docs/integrations/dnd-item-event-artifacts.md @@ -1,45 +1,43 @@ -# D&D Item-Event Artifact +# D&D Item-Occurrence Artifact -This contract defines the durable item and currency occurrence list produced by -`dnd/item-events`. It records source-grounded discoveries and possession -changes; it does not maintain an inventory, balance, or ledger. +`dnd/item-events` currently produces this source-grounded item and currency +occurrence list. It records discoveries and possession changes, not an +inventory, balance, or ledger. ## Identity and compatibility | Property | Value | | --- | --- | -| Artifact kind | `dnd/item-event-list` | -| Schema ID | `notarius.dnd.item_events` | -| Schema name | `notarius_dnd_item_events_v1` | +| Artifact kind | `dnd/item-occurrence-list` | +| Schema ID | `notarius.dnd.item_occurrences` | +| Schema name | `notarius_dnd_item_occurrences_v1` | | Schema version | `v1` | | Media type | `application/json` | -`v1` is a strict JSON object with required `events`; the array may be empty. -Event and source-reference objects reject unknown fields. An incompatible -shape change requires a new schema version. +The strict root has required `occurrences`. Records require `item_id`, `name`, +`kind`, and `source_refs`; unknown fields are rejected. `quantity`, `from`, and +`to` appear only when their kind permits them. + +## Registry grounding + +Both extraction and normalization require an `item_registry` reference bound to +an earlier normalized `dnd/item-registry` artifact. The registry is immutable +for an operation and contributes only its ordered `{id,name}` projection after +the shared evidence message. It is never occurrence evidence. + +Each occurrence must use one exact registry ID/name pair. Extraction omits an +unknown ID or mismatched name rather than creating an item. Normalization +canonicalizes a recognized name by ID, preserves unknown values for the +registry validator, and the registry validator rejects unknown or mismatched +pairs. ## Wire shape -Every event has required `name`, `kind`, and `source_refs`. `quantity`, `from`, -and `to` are optional where the event kind permits them. - -| Field | Contract | -| --- | --- | -| `name` | Non-empty item or currency display name. | -| `kind` | `discovered`, `acquired`, `lost`, `consumed`, or `transferred`. | -| `quantity` | Optional positive integer; omit it when no count is established. | -| `from` | Optional non-empty losing holder, when allowed by `kind`. | -| `to` | Optional non-empty gaining holder, when allowed by `kind`. | -| `source_refs` | One or more transcript evidence ranges. | - -Each source reference has exactly `source_id`, `start_unit_id`, and -`end_unit_id`. It identifies an inclusive current-transcript range; unit IDs -are positive and the start may not follow the end. - ```json { - "events": [ + "occurrences": [ { + "item_id": "item:sha256:…", "name": "Silver Pieces", "kind": "acquired", "quantity": 20, @@ -52,27 +50,11 @@ are positive and the start may not follow the end. } ``` -## Holder rules and minimal extraction +The five kinds remain `discovered`, `acquired`, `lost`, `consumed`, and +`transferred`. Holder, quantity, currency, ordering, and exact-duplicate rules +are unchanged: discovered has no holder; acquired requires `to`; lost and +consumed require `from`; transferred requires distinct non-`party` holders. +Each source reference is an inclusive current-transcript range. -`discovered` has neither holder; `acquired` requires `to` and forbids `from`; -`lost` and `consumed` require `from` and forbid `to`; `transferred` requires -both holders. `party` denotes collective possession. A transfer cannot use -`party` for either holder and its two normalized holders must differ. - -Only an evidenced discovery or possession change belongs in this artifact. -It does not infer quantities or holders, convert currency denominations, -calculate balances, or merge nearby events. Campaign references may -disambiguate names but are never event evidence. Currency uses the ordinary -`name` field and an explicit `quantity` only when the transcript establishes -one; each denomination remains a separate event. - -Normalization trims display whitespace, orders and removes exact duplicate -source references, then orders events by valid source chronology, name identity -and display value, kind, holders, quantity, and reference sequence. It -collapses only entries with the same normalized durable fields and complete -valid evidence. - -The [JSON output contract](json-output.md) defines publication. See -[D&D module internals](../internal/dnd.md) for implementation details and the -[NPC-occurrence artifact](dnd-npc-occurrence-artifacts.md) for a distinct -kind of occurrence. +See [item registry](dnd-item-registry-artifacts.md) for the grounding artifact +and [D&D module internals](../internal/dnd.md) for implementation details. diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index 2bbd83c..b20c4a1 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -36,6 +36,7 @@ pipelines: window_units: 3 lanes: - item-events + - item-registry - locations - location-occurrences - npc_registry @@ -44,15 +45,15 @@ pipelines: - npc-occurrences - enemy-events steps: - # Establish session-wide reference artifacts alongside independent item events. + # Establish session-wide reference artifacts before their consumers. - id: describe-session artifacts: - item-events: + item-registry: extract: - module: dnd/item-events + module: dnd/item-registry retries: 2 merge: appendorder - normalize: dnd/item-events + normalize: dnd/item-registry npc_registry: extract: module: dnd/npc-registry @@ -91,7 +92,17 @@ pipelines: artifact: step: describe-session lane: scene-descriptions + item_registry: + artifact: + step: describe-session + lane: item-registry artifacts: + item-events: + extract: + module: dnd/item-events + retries: 2 + merge: appendorder + normalize: dnd/item-events spells: extract: module: dnd/spells diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index 54411fd..29691aa 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -25,6 +25,7 @@ import ( combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry" locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry" npcoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences" @@ -32,6 +33,7 @@ import ( scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" + itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry" locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" ) @@ -253,8 +255,12 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque content = []byte(`{"duplicate_groups":[]}`) case spells.PromptID: content = []byte(`{"spell_casts":[]}`) + case itemregistry.PromptID: + content = []byte(`{"items":[]}`) + case itemregistrynormalize.PromptID: + content = []byte(`{"duplicate_groups":[]}`) case itemevents.PromptID: - content = []byte(`{"events":[]}`) + content = []byte(`{"occurrences":[]}`) case combat.PromptID: content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) case npcoccurrences.PromptID: diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index faac559..9085e2b 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -58,7 +58,7 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err) } if example.name == "complete" { - if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,locations,npc_registry,scene-descriptions|extract-events:combat-turns,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" { + if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-registry,locations,npc_registry,scene-descriptions|extract-events:combat-turns,item-events,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" { t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got) } locationLane := referenceContractLane(t, materialized, "locations") diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 7eaaaad..ed130fc 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -34,10 +34,12 @@ import ( combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" + itemregistryextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" + itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/promptkit" @@ -76,12 +78,12 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "generic/valid_json", "generic/valid_json_schema", }) - assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) + assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) - assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) + assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemOccurrenceListKind}) assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) wantChain := []pipeline.ModuleBinding{ @@ -122,6 +124,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { itemEventExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/registry"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-events/source_relatedness"), @@ -129,6 +132,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { itemEventNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/registry"), pipeline.Binding("normalize/dnd/item-events/invariants"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), @@ -196,8 +200,8 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID { t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok) } - itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemEventListKind) - if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID { + itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemOccurrenceListKind) + if !ok || itemEventCodecSpec.Kind != dnd.ItemOccurrenceListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID { t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok) } enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind) @@ -916,11 +920,23 @@ pipelines: module: json options: include_chunk_map: true - artifacts: - spells: - extract: dnd/spells - item-events: - extract: dnd/item-events + steps: + - id: identify-items + artifacts: + item-registry: + extract: dnd/item-registry + normalize: dnd/item-registry + - id: extract-occurrences + references: + item_registry: + artifact: + step: identify-items + lane: item-registry + artifacts: + spells: + extract: dnd/spells + item-events: + extract: dnd/item-events `, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule) } @@ -998,7 +1014,11 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`) } case itemeventextract.PromptID: - content = []byte(`{"events":[{"name":"Cure Wounds","kind":"acquired","to":"party","source_refs":[{"start_segment":1,"end_segment":1}]}]}`) + content = []byte(`{"occurrences":[]}`) + case itemregistryextract.PromptID: + content = []byte(`{"items":[]}`) + case itemregistrynormalize.PromptID: + content = []byte(`{"duplicate_groups":[]}`) default: return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) } diff --git a/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json b/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_occurrences.v1.json similarity index 89% rename from internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json rename to internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_occurrences.v1.json index 2931c8e..9dc448e 100644 --- a/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_events.v1.json +++ b/internal/modules/dnd/codec/itemevents/assets/schemas/dnd_item_occurrences.v1.json @@ -1,17 +1,18 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "notarius.dnd.item_events", + "$id": "notarius.dnd.item_occurrences", "type": "object", "additionalProperties": false, - "required": ["events"], + "required": ["occurrences"], "properties": { - "events": { + "occurrences": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["name", "kind", "source_refs"], + "required": ["item_id", "name", "kind", "source_refs"], "properties": { + "item_id": {"type": "string", "minLength": 1}, "name": {"type": "string", "minLength": 1}, "kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]}, "quantity": {"type": "integer", "minimum": 1}, diff --git a/internal/modules/dnd/codec/itemevents/codec.go b/internal/modules/dnd/codec/itemevents/codec.go index 0779359..42ff39c 100644 --- a/internal/modules/dnd/codec/itemevents/codec.go +++ b/internal/modules/dnd/codec/itemevents/codec.go @@ -14,25 +14,25 @@ import ( ) const ( - SchemaID = "notarius.dnd.item_events" - SchemaName = "notarius_dnd_item_events_v1" + SchemaID = "notarius.dnd.item_occurrences" + SchemaName = "notarius_dnd_item_occurrences_v1" SchemaVersion = "v1" MediaType = "application/json" ) -//go:embed assets/schemas/dnd_item_events.v1.json +//go:embed assets/schemas/dnd_item_occurrences.v1.json var schemaAssets embed.FS -var _ contracts.ArtifactCodec[dnd.ItemEventList] = (*Codec)(nil) +var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil) type Codec struct{} func New() *Codec { return &Codec{} } -func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemEventListKind } +func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemOccurrenceListKind } func (c *Codec) Schema() contracts.ArtifactSchema { - raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_events.v1.json") + raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_occurrences.v1.json") if err != nil { return contracts.ArtifactSchema{} } @@ -46,50 +46,53 @@ func (c *Codec) Schema() contracts.ArtifactSchema { func (c *Codec) MediaType() string { return MediaType } -func (c *Codec) Metadata(value dnd.ItemEventList) map[string]any { - return map[string]any{"event_count": len(value.Events)} +func (c *Codec) Metadata(value dnd.ItemOccurrenceList) map[string]any { + return map[string]any{"occurrence_count": len(value.Occurrences)} } -func (c *Codec) Encode(value dnd.ItemEventList) ([]byte, error) { +func (c *Codec) Encode(value dnd.ItemOccurrenceList) ([]byte, error) { if err := validate(value); err != nil { - return nil, fmt.Errorf("encode dnd item event list: %w", err) + return nil, fmt.Errorf("encode dnd item 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.ItemEventList) ([]byte, error) { - return candidatejson.EncodeCandidate("dnd item event list", cloneList(value)) +func (c *Codec) EncodeCandidate(value dnd.ItemOccurrenceList) ([]byte, error) { + return candidatejson.EncodeCandidate("dnd item occurrence list", cloneList(value)) } -func (c *Codec) Decode(content []byte) (dnd.ItemEventList, error) { +func (c *Codec) Decode(content []byte) (dnd.ItemOccurrenceList, error) { value, err := c.DecodeCandidate(content) if err != nil { - return dnd.ItemEventList{}, err + return dnd.ItemOccurrenceList{}, err } if err := validate(value); err != nil { - return dnd.ItemEventList{}, fmt.Errorf("decode dnd item event list: %w", err) + return dnd.ItemOccurrenceList{}, fmt.Errorf("decode dnd item 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.ItemEventList, error) { - value, err := candidatejson.DecodeCandidate[dnd.ItemEventList]("dnd item event list", content) +func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemOccurrenceList, error) { + value, err := candidatejson.DecodeCandidate[dnd.ItemOccurrenceList]("dnd item occurrence list", content) if err != nil { - return dnd.ItemEventList{}, err + return dnd.ItemOccurrenceList{}, err } return cloneList(value), nil } -func validate(value dnd.ItemEventList) error { - if value.Events == nil { +func validate(value dnd.ItemOccurrenceList) error { + if value.Occurrences == nil { return fmt.Errorf("events must be present") } - for index, event := range value.Events { - prefix := fmt.Sprintf("events[%d]", index) + for index, event := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if strings.TrimSpace(event.ItemID) == "" { + return fmt.Errorf("%s.item_id must not be empty", prefix) + } if strings.TrimSpace(event.Name) == "" { return fmt.Errorf("%s.name must not be empty", prefix) } @@ -127,19 +130,19 @@ func validate(value dnd.ItemEventList) error { return nil } -func cloneList(value dnd.ItemEventList) dnd.ItemEventList { - if value.Events == nil { - return dnd.ItemEventList{} +func cloneList(value dnd.ItemOccurrenceList) dnd.ItemOccurrenceList { + if value.Occurrences == nil { + return dnd.ItemOccurrenceList{} } - cloned := dnd.ItemEventList{Events: make([]dnd.ItemEvent, len(value.Events))} - for index, event := range value.Events { - cloned.Events[index] = event + cloned := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, len(value.Occurrences))} + for index, event := range value.Occurrences { + cloned.Occurrences[index] = event if event.Quantity != nil { quantity := *event.Quantity - cloned.Events[index].Quantity = &quantity + cloned.Occurrences[index].Quantity = &quantity } if event.SourceRefs != nil { - cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...) + cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...) } } return cloned diff --git a/internal/modules/dnd/codec/itemevents/codec_test.go b/internal/modules/dnd/codec/itemevents/codec_test.go index b542ec4..7f146df 100644 --- a/internal/modules/dnd/codec/itemevents/codec_test.go +++ b/internal/modules/dnd/codec/itemevents/codec_test.go @@ -14,14 +14,13 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) -func validList() dnd.ItemEventList { +func validList() dnd.ItemOccurrenceList { quantity := 12 - return dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}, - {Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)}, - {Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)}, - {Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)}, - {Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, + return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Hidden Cache", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}, + {ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)}, + {ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: "party", SourceRefs: refs(3, 3)}, + {ItemID: "item", Name: "Healing Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: "party", SourceRefs: refs(4, 4)}, + {ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, }} } @@ -42,18 +41,18 @@ func TestCodecRoundTripAndIdentities(t *testing.T) { } schema := codec.Schema() - if codec.Kind() != dnd.ItemEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { + if codec.Kind() != dnd.ItemOccurrenceListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema) } registry := pipeline.NewArtifactCodecRegistry() if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { t.Fatal(err) } - spec, ok := registry.Spec(dnd.ItemEventListKind) + spec, ok := registry.Spec(dnd.ItemOccurrenceListKind) if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { t.Fatalf("registered spec = %#v, %t", spec, ok) } - if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCRegistry{}); err == nil { + if _, err := registry.Encode(dnd.ItemOccurrenceListKind, dnd.NPCRegistry{}); err == nil { t.Fatal("Encode() error = nil, want exact type rejection") } else { var typeErr *pipeline.ArtifactCodecTypeError @@ -65,13 +64,13 @@ func TestCodecRoundTripAndIdentities(t *testing.T) { func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { codec := New() - empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}} - if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` { + empty := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}} + if content, err := codec.Encode(empty); err != nil || string(content) != `{"occurrences":[]}` { t.Fatalf("Encode() = %s, %v", content, err) } zero := 0 - candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{ - Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party", + candidate := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ + Name: " ", Kind: dnd.ItemOccurrenceKindTransferred, Quantity: &zero, From: "party", To: "Party", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}}, }}} content, err := codec.EncodeCandidate(candidate) @@ -88,15 +87,15 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { } func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) { - validJSON := `{"events":[{"name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` + validJSON := `{"occurrences":[{"item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` tests := []struct { name, raw, want string }{ - {"malformed", `{`, "decode dnd item event list"}, - {"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"}, + {"malformed", `{`, "decode dnd item occurrence list"}, + {"unknown top level", `{"occurrences":[],"unexpected":true}`, "unknown field"}, {"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"}, {"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, - {"trailing", `{"events":[]} {}`, "multiple JSON values"}, + {"trailing", `{"occurrences":[]} {}`, "multiple JSON values"}, {"missing list", `{}`, "events must be present"}, {"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"}, {"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"}, @@ -125,13 +124,13 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) { if err != nil { t.Fatal(err) } - if decoded.Events[1].Quantity == value.Events[1].Quantity || &decoded.Events[1].SourceRefs[0] == &value.Events[1].SourceRefs[0] { + if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] { t.Fatal("DecodeCandidate() retained caller-owned event fields") } - *decoded.Events[1].Quantity = 99 - decoded.Events[1].SourceRefs[0].SourceID = "changed" - if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" { - t.Fatal("decoded item event aliases input") + *decoded.Occurrences[1].Quantity = 99 + decoded.Occurrences[1].SourceRefs[0].SourceID = "changed" + if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" { + t.Fatal("decoded item occurrence aliases input") } first := codec.Schema() @@ -141,7 +140,7 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) { } metadata := codec.Metadata(value) metadata["payload"] = bytes.Repeat([]byte("x"), 10) - if next := codec.Metadata(value); len(next) != 1 || next["event_count"] != len(value.Events) { + if next := codec.Metadata(value); len(next) != 1 || next["occurrence_count"] != len(value.Occurrences) { t.Fatalf("Metadata() = %#v", next) } } diff --git a/internal/modules/dnd/extract/itemevents/canonicalize.go b/internal/modules/dnd/extract/itemevents/canonicalize.go index 8f90109..67a5d8b 100644 --- a/internal/modules/dnd/extract/itemevents/canonicalize.go +++ b/internal/modules/dnd/extract/itemevents/canonicalize.go @@ -5,11 +5,12 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) -type orderedItemEventResponse struct { - value itemEventResponse +type orderedItemOccurrenceResponse struct { + value itemOccurrenceResponse earliest int hasEvidence bool } @@ -18,11 +19,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr if response == nil { return } - ordered := make([]orderedItemEventResponse, len(response.Events)) - for index := range response.Events { - earliest, hasEvidence := canonicalizeItemEvent(&response.Events[index], order, sourceID) - ordered[index] = orderedItemEventResponse{ - value: response.Events[index], + ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences)) + for index := range response.Occurrences { + earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID) + ordered[index] = orderedItemOccurrenceResponse{ + value: response.Occurrences[index], earliest: earliest, hasEvidence: hasEvidence, } @@ -37,38 +38,43 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr return ordered[left].earliest < ordered[right].earliest }) for index := range ordered { - response.Events[index] = ordered[index].value + response.Occurrences[index] = ordered[index].value } } -func canonicalizeItemEvent(event *itemEventResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { +func canonicalizeItemOccurrence(event *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { if event == nil { return 0, false } - refs := order.Canonicalize(itemEventSourceRefs(event.SourceRefs, sourceID)) - event.SourceRefs = itemEventResponseRefs(refs) + refs := order.Canonicalize(itemOccurrenceSourceRefs(event.SourceRefs, sourceID)) + event.SourceRefs = itemOccurrenceResponseRefs(refs) return order.EarliestValid(refs) } -func canonicalItemEventList(response extractionResponse, sourceID string) dnd.ItemEventList { - if response.Events == nil { - return dnd.ItemEventList{} +func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) dnd.ItemOccurrenceList { + if response.Occurrences == nil { + return dnd.ItemOccurrenceList{} } - events := make([]dnd.ItemEvent, len(response.Events)) - for index, event := range response.Events { - events[index] = dnd.ItemEvent{ + occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences)) + for _, event := range response.Occurrences { + item, found := registry.LookupID(event.ItemID) + if !found || item.Name != event.Name { + continue + } + occurrences = append(occurrences, dnd.ItemOccurrence{ + ItemID: event.ItemID, Name: event.Name, - Kind: dnd.ItemEventKind(event.Kind), + Kind: dnd.ItemOccurrenceKind(event.Kind), Quantity: cloneQuantity(event.Quantity), From: event.From, To: event.To, - SourceRefs: itemEventSourceRefs(event.SourceRefs, sourceID), - } + SourceRefs: itemOccurrenceSourceRefs(event.SourceRefs, sourceID), + }) } - return dnd.ItemEventList{Events: events} + return dnd.ItemOccurrenceList{Occurrences: occurrences} } -func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []source.SourceRef { +func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef { if refs == nil { return nil } @@ -79,13 +85,13 @@ func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []s return values } -func itemEventResponseRefs(refs []source.SourceRef) []itemEventSourceRefResponse { +func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse { if refs == nil { return nil } - values := make([]itemEventSourceRefResponse, len(refs)) + values := make([]itemOccurrenceSourceRefResponse, len(refs)) for index, ref := range refs { - values[index] = itemEventSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID} + values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID} } return values } diff --git a/internal/modules/dnd/extract/itemevents/extractor.go b/internal/modules/dnd/extract/itemevents/extractor.go index 03e42d1..16a04cb 100644 --- a/internal/modules/dnd/extract/itemevents/extractor.go +++ b/internal/modules/dnd/extract/itemevents/extractor.go @@ -1,18 +1,25 @@ -// Package itemevents extracts source-grounded D&D item events. +// Package itemevents extracts source-grounded D&D item occurrences. package itemevents import ( "context" "fmt" + "sort" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) const Key = "dnd/item-events" +const ( + ItemRegistryReferenceSlot = itemregistry.ReferenceSlot + ItemRegistryMaxBytes = itemregistry.MaxBytes +) + const mappingPolicy = "dnd.item_events.extract_mapping.v1" var requiredCapabilities = []string{ @@ -32,10 +39,20 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ } func referenceSlots() []contracts.ReferenceSlot { - return shared.ReferenceSlots(referenceSlotDescriptions) + slots := shared.ReferenceSlots(referenceSlotDescriptions) + slots = append(slots, contracts.ReferenceSlot{ + Name: ItemRegistryReferenceSlot, + Description: "Required normalized item registry used only for item identity grounding, never as occurrence evidence.", + Required: true, + AcceptedMediaTypes: []string{"application/json"}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind}, + MaxBytes: ItemRegistryMaxBytes, + }) + sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name }) + return slots } -var _ contracts.Extractor[dnd.ItemEventList] = (*Extractor)(nil) +var _ contracts.Extractor[dnd.ItemOccurrenceList] = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) @@ -43,6 +60,7 @@ type Options struct{} type Extractor struct { llm contracts.StructuredLLMClient + itemResolver *itemregistry.Resolver promptSHA string responseSchemaSHA string } @@ -54,6 +72,14 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr if len(references) > 1 { return nil, extractorErrorf("at most one reference set may be supplied") } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + itemResolver, err := itemregistry.NewResolver(referenceSet) + if err != nil { + return nil, extractorErrorf("prepare item registry prompt input: %w", err) + } promptSHA, err := promptAssetMetadata() if err != nil { return nil, extractorErrorf("load prompt metadata: %w", err) @@ -62,7 +88,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr if err != nil { return nil, extractorErrorf("load response schema: %w", err) } - return &Extractor{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil + return &Extractor{llm: llmClient, itemResolver: itemResolver, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil } func (e *Extractor) Key() string { return Key } @@ -73,7 +99,7 @@ func (e *Extractor) ManifestMetadata() map[string]any { if e == nil { return nil } - return map[string]any{ + metadata := map[string]any{ "prompt_id": PromptID, "prompt_version": SchemaVersion, "prompt_sha256": e.promptSHA, @@ -84,6 +110,12 @@ func (e *Extractor) ManifestMetadata() map[string]any { "response_schema_sha256": e.responseSchemaSHA, "mapping_policy": mappingPolicy, } + seeded := e.itemResolver.Seeded() + if seeded.Bound() { + metadata["item_registry_digest"] = seeded.Digest() + metadata["item_count"] = seeded.Count() + } + return metadata } func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { @@ -94,35 +126,41 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, + {Name: "item_registry", Value: e.itemResolver.Seeded().ProjectionDigest()}, } } -func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemEventList], error) { +func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemOccurrenceList], error) { if e == nil { - return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("extractor must not be nil") + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("extractor must not be nil") } if e.llm == nil { - return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("LLM client must not be nil") + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("LLM client must not be nil") } sourceInput, err := shared.PrepareChunkExtraction(ctx, req) if err != nil { - return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("%w", err) + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("%w", err) } order := shared.NewSourceRefOrder(req.Source) + registry, err := e.itemResolver.Resolve(req.References) + if err != nil { + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve item registry: %w", err) + } + if !registry.Bound() { + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("item registry reference is required") + } var response extractionResponse + inputs := shared.PromptInputs(sourceInput, req.References) + inputs[ItemRegistryReferenceSlot] = registry.PromptInput() if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ - StageName: Key, - PromptID: PromptID, - PromptVersion: SchemaVersion, - ProfileID: req.LLMProfile, - SessionID: req.SessionID, - Inputs: shared.PromptInputs(sourceInput, req.References), + StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, + ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs, }, &response); err != nil { - return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("complete structured output: %w", err) + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } canonicalizeResponse(&response, order, req.Source.ID) - return contracts.TypedExtractionResult[dnd.ItemEventList]{Value: canonicalItemEventList(response, req.Source.ID)}, nil + return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID, registry)}, nil } func ModuleSpec() pipeline.ModuleSpec { @@ -132,13 +170,13 @@ func ModuleSpec() pipeline.ModuleSpec { ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), - ArtifactKind: dnd.ItemEventListKind, + ArtifactKind: dnd.ItemOccurrenceListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.ExtractorRegistry) error { - return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemEventList], error) { + return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err @@ -160,5 +198,5 @@ func DecodeOptions(options map[string]any) (Options, error) { } func extractorErrorf(format string, args ...any) error { - return fmt.Errorf("dnd item events extractor: "+format, args...) + return fmt.Errorf("dnd item occurrences extractor: "+format, args...) } diff --git a/internal/modules/dnd/extract/itemevents/extractor_test.go b/internal/modules/dnd/extract/itemevents/extractor_test.go index 7d969c0..e4c6cbe 100644 --- a/internal/modules/dnd/extract/itemevents/extractor_test.go +++ b/internal/modules/dnd/extract/itemevents/extractor_test.go @@ -2,161 +2,61 @@ package itemevents import ( "context" - "errors" - "reflect" - "strings" "testing" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" ) -func TestExtractMapsAllEventKindsAndCanonicalizesEvidence(t *testing.T) { - quantity := 20 - client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{ - {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(5, 5)}, - {Name: "Moonblade", Kind: "transferred", From: "Aria", To: "Borin", SourceRefs: responseRefs(3, 3)}, - {Name: "Hidden Cache", Kind: "discovered", SourceRefs: []itemEventSourceRefResponse{{StartSegment: 1, EndSegment: 1}, {StartSegment: 1, EndSegment: 1}}}, - {Name: "Gold Pieces", Kind: "acquired", Quantity: &quantity, To: "party", SourceRefs: responseRefs(2, 2)}, - {Name: "Healing Potion", Kind: "consumed", From: "party", SourceRefs: responseRefs(4, 4)}, - }}} - - result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) - if err != nil { - t.Fatalf("Extract() error = %v", err) - } - if got := []dnd.ItemEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.ItemEventKind{ - dnd.ItemEventKindDiscovered, dnd.ItemEventKindAcquired, dnd.ItemEventKindTransferred, dnd.ItemEventKindConsumed, dnd.ItemEventKindLost, - }) { - t.Fatalf("event kinds = %#v, want source order", got) - } - acquired := result.Value.Events[1] - if acquired.Name != "Gold Pieces" || acquired.To != "party" || acquired.Quantity == nil || *acquired.Quantity != 20 { - t.Fatalf("acquired event = %#v", acquired) - } - if result.Value.Events[0].Quantity != nil || result.Value.Events[0].From != "" || result.Value.Events[0].To != "" { - t.Fatalf("discovered event = %#v, want omitted optional fields", result.Value.Events[0]) - } - if refs := result.Value.Events[0].SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}) { - t.Fatalf("source refs = %#v, want source ID attachment and deduplication", refs) - } - if acquired.Quantity == client.response.Events[3].Quantity { - t.Fatal("mapped quantity aliases private response") - } - if len(client.requests) != 1 { - t.Fatalf("LLM calls = %d, want 1", len(client.requests)) - } - request := client.requests[0] - if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "profile-item-events" || request.SessionID != "session-123" { - t.Fatalf("LLM request identity = %#v", request) - } - if transcript := request.Inputs["transcript"]; string(transcript.Content) != string(extractionRequest().Chunk.Content) || transcript.Name != "transcript" { - t.Fatalf("transcript input = %#v", transcript) - } - for _, name := range []string{"players", "party", "glossary"} { - if input := request.Inputs[name]; string(input.Content) != " " { - t.Fatalf("absent %s input = %#v, want retained empty prompt material", name, input) - } - } -} - -func TestExtractPreservesInvalidCandidatesAndEmptyResults(t *testing.T) { - client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"","kind":"transferred","quantity":0,"from":"party","to":"Party","source_refs":[{"start_segment":99,"end_segment":-1}]}]}`)} - result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) - if err != nil { - t.Fatalf("Extract() error = %v", err) - } - event := result.Value.Events[0] - if event.Name != "" || event.Kind != "transferred" || event.Quantity == nil || *event.Quantity != 0 || event.From != "party" || event.To != "Party" { - t.Fatalf("invalid candidate = %#v, want values preserved", event) - } - if refs := event.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99, EndUnitID: -1}) { - t.Fatalf("source refs = %#v, want invalid range preserved", refs) - } - - empty, err := newExtractor(t, &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}).Extract(context.Background(), extractionRequest()) - if err != nil || empty.Value.Events == nil || len(empty.Value.Events) != 0 { - t.Fatalf("empty provider result = %#v, %v; want valid empty list", empty.Value, err) - } -} - -func TestExtractMapsNullableResponseFieldsToAbsentArtifactFields(t *testing.T) { - client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"Hidden Cache","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} - result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) - if err != nil { - t.Fatal(err) - } - if len(result.Value.Events) != 1 { - t.Fatalf("events = %#v, want one", result.Value.Events) - } - event := result.Value.Events[0] - if event.Quantity != nil || event.From != "" || event.To != "" { - t.Fatalf("nullable response fields mapped to artifact values: %#v", event) - } -} - -func TestExtractUsesSourceDocumentOrderForCandidates(t *testing.T) { - client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{ - {Name: "Later", Kind: "discovered", SourceRefs: responseRefs(10, 10)}, - {Name: "Earlier", Kind: "discovered", SourceRefs: responseRefs(100, 100)}, +func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { + id := itemidentity.DeriveID("Torch") + client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ + {ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, + {ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)}, + {ItemID: "torch", Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(3, 3)}, }}} req := extractionRequest() - req.Source.Units = []source.SourceUnit{{ID: 100}, {ID: 10}} - req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) - req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 10} - result, err := newExtractor(t, client).Extract(context.Background(), req) + req.References = itemRegistryReferences(t) + result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) if err != nil { t.Fatal(err) } - if got := []string{result.Value.Events[0].Name, result.Value.Events[1].Name}; !reflect.DeepEqual(got, []string{"Earlier", "Later"}) { - t.Fatalf("event order = %#v, want source document order", got) + if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" { + t.Fatalf("occurrences = %#v", result.Value.Occurrences) + } + input := client.requests[0].Inputs[ItemRegistryReferenceSlot] + if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" { + t.Fatalf("registry prompt input = %#v", input) + } +} + +func TestExtractRequiresItemRegistry(t *testing.T) { + _, err := newExtractor(t, &fakeItemOccurrencesLLMClient{}).Extract(context.Background(), extractionRequest()) + if err == nil { + t.Fatal("Extract() error = nil, want required registry error") + } +} + +func TestExtractPreservesNullableFields(t *testing.T) { + id := itemidentity.DeriveID("Torch") + client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} + req := extractionRequest() + req.References = itemRegistryReferences(t) + result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) + if err != nil || len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].Quantity != nil || result.Value.Occurrences[0].From != "" || result.Value.Occurrences[0].To != "" { + t.Fatalf("result = %#v, %v", result, err) } } func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) { - client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}} + client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}} req := extractionRequest() - req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ - "players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Aria")}}}, - "party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: ranger")}}}, - "glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Moonblade: heirloom")}}}, - "npcs": {Slot: contracts.ReferenceSlot{Name: "npc_registry"}, Items: []contracts.ReferenceItem{{SlotName: "npc_registry", Content: []byte("must not be used")}}}, - }} - if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil { + req.References = itemRegistryReferences(t) + req.References.Slots["unrelated"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "unrelated"}, Items: []contracts.ReferenceItem{{SlotName: "unrelated", Content: []byte("ignored")}}} + if _, err := newExtractor(t, client, req.References).Extract(context.Background(), req); err != nil { t.Fatal(err) } - inputs := client.requests[0].Inputs - if string(inputs["players"].Content) != "Dana: Aria" || string(inputs["party"].Content) != "Aria: ranger" || string(inputs["glossary"].Content) != "Moonblade: heirloom" { - t.Fatalf("reference inputs = %#v", inputs) - } - if _, ok := inputs["npcs"]; ok { - t.Fatalf("unexpected generated-lane input: %#v", inputs) - } -} - -func TestExtractRetainsLocalErrorContext(t *testing.T) { - request := extractionRequest() - extractor := newExtractor(t, &fakeItemEventsLLMClient{}) - var nilExtractor *Extractor - request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json") - for _, test := range []struct { - name string - extractor *Extractor - req contracts.TypedExtractionRequest - want string - }{ - {"nil extractor", nilExtractor, extractionRequest(), "extractor"}, - {"nil client", &Extractor{}, extractionRequest(), "LLM client"}, - {"preflight", extractor, request, "must match chunk"}, - } { - t.Run(test.name, func(t *testing.T) { - if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd item events") || !strings.Contains(err.Error(), test.want) { - t.Fatalf("Extract() error = %v", err) - } - }) - } - if _, err := newExtractor(t, &fakeItemEventsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "provider unavailable") { - t.Fatalf("provider error = %v", err) + if _, ok := client.requests[0].Inputs["unrelated"]; ok { + t.Fatalf("unexpected prompt input: %#v", client.requests[0].Inputs) } } diff --git a/internal/modules/dnd/extract/itemevents/model.go b/internal/modules/dnd/extract/itemevents/model.go index c91e6e8..769b500 100644 --- a/internal/modules/dnd/extract/itemevents/model.go +++ b/internal/modules/dnd/extract/itemevents/model.go @@ -1,19 +1,20 @@ package itemevents type extractionResponse struct { - Events []itemEventResponse `json:"events"` + Occurrences []itemOccurrenceResponse `json:"occurrences"` } -type itemEventResponse struct { - Name string `json:"name"` - Kind string `json:"kind"` - Quantity *int `json:"quantity,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - SourceRefs []itemEventSourceRefResponse `json:"source_refs"` +type itemOccurrenceResponse struct { + ItemID string `json:"item_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Quantity *int `json:"quantity,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + SourceRefs []itemOccurrenceSourceRefResponse `json:"source_refs"` } -type itemEventSourceRefResponse struct { +type itemOccurrenceSourceRefResponse struct { StartSegment int `json:"start_segment"` EndSegment int `json:"end_segment"` } diff --git a/internal/modules/dnd/extract/itemevents/prompt_assets.go b/internal/modules/dnd/extract/itemevents/prompt_assets.go index b5cde98..c5602b7 100644 --- a/internal/modules/dnd/extract/itemevents/prompt_assets.go +++ b/internal/modules/dnd/extract/itemevents/prompt_assets.go @@ -18,6 +18,7 @@ var promptAssetManifest = shared.PromptAssetManifest{ ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, {Name: "instructions.md", Path: "prompts/instructions.md"}, + {Name: "item-registry.md", Path: "prompts/item-registry.md"}, }, SharedFiles: []string{ "common-dnd-system.md", @@ -43,7 +44,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { } promptFS, err := promptAssetManifest.PromptFS(assets) if err != nil { - return fmt.Errorf("prepare item event prompt assets: %w", err) + return fmt.Errorf("prepare item occurrence prompt assets: %w", err) } if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil { return err diff --git a/internal/modules/dnd/extract/itemevents/prompt_assets_test.go b/internal/modules/dnd/extract/itemevents/prompt_assets_test.go index 7cf7c7d..4f10e79 100644 --- a/internal/modules/dnd/extract/itemevents/prompt_assets_test.go +++ b/internal/modules/dnd/extract/itemevents/prompt_assets_test.go @@ -11,7 +11,7 @@ import ( "gitea.maximumdirect.net/eric/promptkit" ) -func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) { +func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) { registry := llm.NewAssetRegistry() if err := RegisterPromptAssets(registry); err != nil { t.Fatal(err) @@ -30,16 +30,17 @@ func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) { prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile", Inputs: map[string]promptkit.ArtifactRef{ - "transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`), - "players": promptkit.Inline("item-event-player"), - "party": promptkit.Inline(" "), - "glossary": promptkit.Inline(" "), + "transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`), + "players": promptkit.Inline("item-event-player"), + "party": promptkit.Inline(" "), + "glossary": promptkit.Inline(" "), + "item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`), }, }) if err != nil { t.Fatal(err) } - if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_events_llm.v1.json" { + if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" { t.Fatalf("prepared prompt = %#v", prepared) } } @@ -49,12 +50,12 @@ func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) { if err != nil || !strings.HasPrefix(hash, "sha256:") { t.Fatalf("promptAssetMetadata() = %q, %v", hash, err) } - metadata := newExtractor(t, &fakeItemEventsLLMClient{}).ManifestMetadata() + metadata := newExtractor(t, &fakeItemOccurrencesLLMClient{}).ManifestMetadata() payload, err := json.Marshal(metadata) if err != nil { t.Fatal(err) } - for _, forbidden := range []string{"common-dnd-system", "dnd_item_events_llm.v1.json"} { + for _, forbidden := range []string{"common-dnd-system", "dnd_item_occurrences_llm.v1.json"} { if strings.Contains(string(payload), forbidden) { t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload) } diff --git a/internal/modules/dnd/extract/itemevents/registry_test.go b/internal/modules/dnd/extract/itemevents/registry_test.go index 5aed4e3..e29e30f 100644 --- a/internal/modules/dnd/extract/itemevents/registry_test.go +++ b/internal/modules/dnd/extract/itemevents/registry_test.go @@ -2,64 +2,42 @@ package itemevents import ( "reflect" - "strings" "testing" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" ) -func TestConstructorSpecOptionsAndMetadata(t *testing.T) { - if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") { - t.Fatalf("New(nil) error = %v", err) +func itemRegistryReferences(t *testing.T) contracts.ReferenceSet { + t.Helper() + content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: testSourceRefs()}}}) + if err != nil { + t.Fatal(err) } - if _, err := New(&fakeItemEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") { - t.Fatalf("New() error = %v", err) + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} +} + +func testSourceRefs() []source.SourceRef { + return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}} +} + +func TestModuleSpecDeclaresRequiredRegistry(t *testing.T) { + spec := ModuleSpec() + var slot contracts.ReferenceSlot + for _, candidate := range spec.ReferenceSlots { + if candidate.Name == ItemRegistryReferenceSlot { + slot = candidate + } } - want := pipeline.ModuleSpec{ - Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_events"}, ArtifactKind: dnd.ItemEventListKind, - ReferenceSlots: []contracts.ReferenceSlot{ - {Name: "glossary", Description: referenceSlotDescriptions.Glossary, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, - {Name: "party", Description: referenceSlotDescriptions.Party, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, - {Name: "players", Description: referenceSlotDescriptions.Players, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, - {Name: "roster", Description: referenceSlotDescriptions.Roster, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, - }, - } - if got := ModuleSpec(); !reflect.DeepEqual(got, want) { - t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) - } - if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { - t.Fatalf("DecodeOptions() error = %v", err) + if !slot.Required || !reflect.DeepEqual(slot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.ItemRegistryKind}) || slot.MaxBytes != ItemRegistryMaxBytes { + t.Fatalf("registry slot = %#v", slot) } registry := pipeline.NewExtractorRegistry() if err := Register(registry); err != nil { t.Fatal(err) } - if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, want) { - t.Fatalf("registry spec = %#v, %t", got, ok) - } - - extractor := newExtractor(t, &fakeItemEventsLLMClient{}) - metadata := extractor.ManifestMetadata() - for key, want := range map[string]string{ - "prompt_id": PromptID, "prompt_version": SchemaVersion, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, - "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "mapping_policy": mappingPolicy, - } { - if metadata[key] != want { - t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want) - } - } - for _, key := range []string{"prompt_sha256", "response_schema_sha256"} { - if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") { - t.Fatalf("metadata[%q] = %#v", key, metadata[key]) - } - } - if got := extractor.CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{ - {Name: "prompt", Value: metadata["prompt_sha256"].(string)}, - {Name: "response_schema", Value: metadata["response_schema_sha256"].(string)}, - {Name: "mapping_policy", Value: mappingPolicy}, - }) { - t.Fatalf("CheckpointFingerprints() = %#v", got) - } } diff --git a/internal/modules/dnd/extract/itemevents/schema.go b/internal/modules/dnd/extract/itemevents/schema.go index 8dbe2ad..a02828e 100644 --- a/internal/modules/dnd/extract/itemevents/schema.go +++ b/internal/modules/dnd/extract/itemevents/schema.go @@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" const ( PromptID = "dnd.item_events" - ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_events_llm") - ResponseSchemaID = "notarius.dnd.item_events.llm" - ResponseSchemaName = "notarius_dnd_item_events_llm_v1" + ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_occurrences_llm") + ResponseSchemaID = "notarius.dnd.item_occurrences.llm" + ResponseSchemaName = "notarius_dnd_item_occurrences_llm_v1" SchemaVersion = "v1" ) @@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) { ID: ResponseSchemaID, Version: SchemaVersion, Name: ResponseSchemaName, - AssetPath: "schemas/dnd_item_events_llm.v1.json", + AssetPath: "schemas/dnd_item_occurrences_llm.v1.json", }) } diff --git a/internal/modules/dnd/extract/itemevents/schema_test.go b/internal/modules/dnd/extract/itemevents/schema_test.go index edf4645..e6ed93d 100644 --- a/internal/modules/dnd/extract/itemevents/schema_test.go +++ b/internal/modules/dnd/extract/itemevents/schema_test.go @@ -17,13 +17,13 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) { t.Fatalf("schema = %#v", schema) } - valid := map[string]any{"events": []any{ + valid := map[string]any{"occurrences": []any{ map[string]any{ - "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", + "item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}}, }, map[string]any{ - "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, + "item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, "source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}}, }, }} @@ -38,12 +38,12 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { name string value map[string]any }{ - {"missing events", map[string]any{}}, - {"missing event name", map[string]any{"events": []any{withoutField(responseEvent(), "name")}}}, - {"missing nullable field", map[string]any{"events": []any{withoutField(responseEvent(), "quantity")}}}, - {"unknown event field", map[string]any{"events": []any{withField(responseEvent(), "extra", true)}}}, - {"unknown reference field", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}}, - {"noninteger range", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, + {"missing occurrences", map[string]any{}}, + {"missing event name", map[string]any{"occurrences": []any{withoutField(responseEvent(), "name")}}}, + {"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseEvent(), "quantity")}}}, + {"unknown event field", map[string]any{"occurrences": []any{withField(responseEvent(), "extra", true)}}}, + {"unknown reference field", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}}, + {"noninteger range", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, } { t.Run(test.name, func(t *testing.T) { content, err := json.Marshal(test.value) @@ -68,7 +68,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) { func responseEvent() map[string]any { return map[string]any{ - "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{}, + "item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{}, } } diff --git a/internal/modules/dnd/extract/itemevents/test_helpers_test.go b/internal/modules/dnd/extract/itemevents/test_helpers_test.go index ebf0352..d491546 100644 --- a/internal/modules/dnd/extract/itemevents/test_helpers_test.go +++ b/internal/modules/dnd/extract/itemevents/test_helpers_test.go @@ -43,8 +43,8 @@ func sourceDocument() *source.SourceDocument { } } -func responseRefs(start, end int) []itemEventSourceRefResponse { - return []itemEventSourceRefResponse{{StartSegment: start, EndSegment: end}} +func responseRefs(start, end int) []itemOccurrenceSourceRefResponse { + return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}} } func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor { @@ -61,14 +61,14 @@ func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) return req } -type fakeItemEventsLLMClient struct { +type fakeItemOccurrencesLLMClient struct { response extractionResponse content []byte err error requests []contracts.StructuredCompletionRequest } -func (client *fakeItemEventsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { +func (client *fakeItemOccurrencesLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { client.requests = append(client.requests, cloneStructuredCompletionRequest(req)) if client.err != nil { return contracts.StructuredCompletionResponse{}, client.err diff --git a/internal/modules/dnd/itemevents/itemevents.go b/internal/modules/dnd/itemevents/itemevents.go index 71961f3..1936156 100644 --- a/internal/modules/dnd/itemevents/itemevents.go +++ b/internal/modules/dnd/itemevents/itemevents.go @@ -14,14 +14,14 @@ import ( const partyHolder = "party" -// SupportedKind reports whether kind is one of the durable item-event kinds. -func SupportedKind(kind dnd.ItemEventKind) bool { +// SupportedKind reports whether kind is one of the durable item occurrence kinds. +func SupportedKind(kind dnd.ItemOccurrenceKind) bool { switch kind { - case dnd.ItemEventKindDiscovered, - dnd.ItemEventKindAcquired, - dnd.ItemEventKindLost, - dnd.ItemEventKindConsumed, - dnd.ItemEventKindTransferred: + case dnd.ItemOccurrenceKindDiscovered, + dnd.ItemOccurrenceKindAcquired, + dnd.ItemOccurrenceKindLost, + dnd.ItemOccurrenceKindConsumed, + dnd.ItemOccurrenceKindTransferred: return true default: return false @@ -45,18 +45,18 @@ func IsPartyHolder(value string) bool { return ComparisonKey(value) == partyHold // ValidHolderCombination reports whether the optional holder fields satisfy // the durable rules for kind. Blank holders are treated as absent so callers // can preserve invalid extraction candidates for their owning validators. -func ValidHolderCombination(kind dnd.ItemEventKind, from, to string) bool { +func ValidHolderCombination(kind dnd.ItemOccurrenceKind, from, to string) bool { hasFrom := HolderPresent(from) hasTo := HolderPresent(to) switch kind { - case dnd.ItemEventKindDiscovered: + case dnd.ItemOccurrenceKindDiscovered: return !hasFrom && !hasTo - case dnd.ItemEventKindAcquired: + case dnd.ItemOccurrenceKindAcquired: return !hasFrom && hasTo - case dnd.ItemEventKindLost, dnd.ItemEventKindConsumed: + case dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed: return hasFrom && !hasTo - case dnd.ItemEventKindTransferred: + case dnd.ItemOccurrenceKindTransferred: return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to) default: return false @@ -93,7 +93,7 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { // Less defines the canonical event order. Invalid source references remain // comparable through SourceRefOrder's literal fallback so malformed candidates // are still safe to sort and diagnose. -func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { +func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) if leftHasEvidence != rightHasEvidence { @@ -102,6 +102,9 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { if leftHasEvidence && leftPosition != rightPosition { return leftPosition < rightPosition } + if left.ItemID != right.ItemID { + return left.ItemID < right.ItemID + } if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey { return leftKey < rightKey } @@ -125,8 +128,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { // ExactEqual reports whether events are exact duplicates after their display // fields and evidence have been canonicalized for the supplied source order. -func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { - if DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind || +func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { + if left.ItemID != right.ItemID || DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind || DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) || (left.Quantity == nil) != (right.Quantity == nil) { return false @@ -140,8 +143,9 @@ func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { // ExactIdentity returns a collision-safe duplicate key after display and // evidence canonicalization. It is intended for callers that have already // decided the event is eligible for duplicate handling. -func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemEvent) string { +func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemOccurrence) string { var key strings.Builder + writeKeyString(&key, event.ItemID) writeKeyString(&key, DisplayValue(event.Name)) writeKeyString(&key, string(event.Kind)) writeKeyString(&key, DisplayValue(event.From)) diff --git a/internal/modules/dnd/itemevents/itemevents_test.go b/internal/modules/dnd/itemevents/itemevents_test.go index 62bbfe8..bb1bd6c 100644 --- a/internal/modules/dnd/itemevents/itemevents_test.go +++ b/internal/modules/dnd/itemevents/itemevents_test.go @@ -11,14 +11,14 @@ import ( func TestValidHolderCombination(t *testing.T) { valid := []struct { - kind dnd.ItemEventKind + kind dnd.ItemOccurrenceKind from, to string }{ - {dnd.ItemEventKindDiscovered, "", ""}, - {dnd.ItemEventKindAcquired, "", "party"}, - {dnd.ItemEventKindLost, "party", ""}, - {dnd.ItemEventKindConsumed, "party", ""}, - {dnd.ItemEventKindTransferred, "Aria", "Borin"}, + {dnd.ItemOccurrenceKindDiscovered, "", ""}, + {dnd.ItemOccurrenceKindAcquired, "", "party"}, + {dnd.ItemOccurrenceKindLost, "party", ""}, + {dnd.ItemOccurrenceKindConsumed, "party", ""}, + {dnd.ItemOccurrenceKindTransferred, "Aria", "Borin"}, } for _, test := range valid { if !ValidHolderCombination(test.kind, test.from, test.to) { @@ -27,17 +27,17 @@ func TestValidHolderCombination(t *testing.T) { } invalid := []struct { - kind dnd.ItemEventKind + kind dnd.ItemOccurrenceKind from, to string }{ - {dnd.ItemEventKindDiscovered, "Aria", ""}, - {dnd.ItemEventKindAcquired, "", ""}, - {dnd.ItemEventKindLost, "", ""}, - {dnd.ItemEventKindConsumed, "", "Borin"}, - {dnd.ItemEventKindTransferred, "party", "Borin"}, - {dnd.ItemEventKindTransferred, "Aria", "Party"}, - {dnd.ItemEventKindTransferred, "Aria", "aria"}, - {dnd.ItemEventKindTransferred, "Åria", "Åria"}, + {dnd.ItemOccurrenceKindDiscovered, "Aria", ""}, + {dnd.ItemOccurrenceKindAcquired, "", ""}, + {dnd.ItemOccurrenceKindLost, "", ""}, + {dnd.ItemOccurrenceKindConsumed, "", "Borin"}, + {dnd.ItemOccurrenceKindTransferred, "party", "Borin"}, + {dnd.ItemOccurrenceKindTransferred, "Aria", "Party"}, + {dnd.ItemOccurrenceKindTransferred, "Aria", "aria"}, + {dnd.ItemOccurrenceKindTransferred, "Åria", "Åria"}, {"unsupported", "", ""}, } for _, test := range invalid { @@ -53,16 +53,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) { return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} } quantity := func(value int) *int { return &value } - base := dnd.ItemEvent{Name: "Amulet", Kind: dnd.ItemEventKindAcquired, To: "Borin", SourceRefs: ref(20, 20)} + base := dnd.ItemOccurrence{ItemID: "item", Name: "Amulet", Kind: dnd.ItemOccurrenceKindAcquired, To: "Borin", SourceRefs: ref(20, 20)} tests := []struct { name string - left, right dnd.ItemEvent + left, right dnd.ItemOccurrence }{ {"earlier evidence", withRefs(base, ref(10, 10)), base}, {"valid evidence before malformed", base, withRefs(base, ref(999, 999))}, {"normalized name", withName(base, "Amulet"), withName(base, "Blade")}, {"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")}, - {"kind", withKind(base, dnd.ItemEventKindAcquired), withKind(base, dnd.ItemEventKindLost)}, + {"kind", withKind(base, dnd.ItemOccurrenceKindAcquired), withKind(base, dnd.ItemOccurrenceKindLost)}, {"from presence", withFrom(base, ""), withFrom(base, "Aria")}, {"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")}, {"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")}, @@ -84,16 +84,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) { func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) { order := testOrder() - first := dnd.ItemEvent{ - Name: " Silver Coin ", Kind: dnd.ItemEventKindAcquired, To: " party ", + first := dnd.ItemOccurrence{ItemID: "item", + Name: " Silver Coin ", Kind: dnd.ItemOccurrenceKindAcquired, To: " party ", SourceRefs: []source.SourceRef{ {SourceID: "session", StartUnitID: 30, EndUnitID: 30}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, }, } - second := dnd.ItemEvent{ - Name: "Silver Coin", Kind: dnd.ItemEventKindAcquired, To: "party", + second := dnd.ItemOccurrence{ItemID: "item", + Name: "Silver Coin", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{ {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, {SourceID: "session", StartUnitID: 30, EndUnitID: 30}, @@ -136,10 +136,9 @@ func TestSourceReferenceHelpers(t *testing.T) { func TestLessSortsMalformedReferencesDeterministically(t *testing.T) { order := testOrder() - events := []dnd.ItemEvent{ - {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}}, - {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}}, - {Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + events := []dnd.ItemOccurrence{{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}}, + {ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}}, + {ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, } sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) }) if events[0].SourceRefs[0].StartUnitID != 10 || events[1].SourceRefs[0].SourceID != "other" || events[2].SourceRefs[0].StartUnitID != 999 { @@ -151,19 +150,28 @@ func testOrder() shared.SourceRefOrder { return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}) } -func withName(event dnd.ItemEvent, value string) dnd.ItemEvent { event.Name = value; return event } -func withKind(event dnd.ItemEvent, value dnd.ItemEventKind) dnd.ItemEvent { +func withName(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence { + event.Name = value + return event +} +func withKind(event dnd.ItemOccurrence, value dnd.ItemOccurrenceKind) dnd.ItemOccurrence { event.Kind = value return event } -func withFrom(event dnd.ItemEvent, value string) dnd.ItemEvent { event.From = value; return event } -func withTo(event dnd.ItemEvent, value string) dnd.ItemEvent { event.To = value; return event } -func withoutTo(event dnd.ItemEvent) dnd.ItemEvent { event.To = ""; return event } -func withQuantity(event dnd.ItemEvent, value *int) dnd.ItemEvent { +func withFrom(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence { + event.From = value + return event +} +func withTo(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence { + event.To = value + return event +} +func withoutTo(event dnd.ItemOccurrence) dnd.ItemOccurrence { event.To = ""; return event } +func withQuantity(event dnd.ItemOccurrence, value *int) dnd.ItemOccurrence { event.Quantity = value return event } -func withRefs(event dnd.ItemEvent, value []source.SourceRef) dnd.ItemEvent { +func withRefs(event dnd.ItemOccurrence, value []source.SourceRef) dnd.ItemOccurrence { event.SourceRefs = value return event } diff --git a/internal/modules/dnd/normalize/itemevents/normalizer.go b/internal/modules/dnd/normalize/itemevents/normalizer.go index 0524cbe..2a03802 100644 --- a/internal/modules/dnd/normalize/itemevents/normalizer.go +++ b/internal/modules/dnd/normalize/itemevents/normalizer.go @@ -11,6 +11,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) @@ -20,85 +21,126 @@ const ( normalizationPolicy = "dnd.item_events.normalize.v1" NormalizationPolicy = normalizationPolicy - ReasonCodeDisplayNormalized = "item_event_display_normalized" + ReasonCodeNameCanonicalized = "item_occurrence_name_canonicalized" + ReasonCodeUnknownItemID = "item_occurrence_unknown_item_id" ReasonCodeSourceRefsNormalized = "source_references_normalized" - ReasonCodeEventsReordered = "item_events_reordered" - ReasonCodeDuplicateCollapsed = "duplicate_item_event_collapsed" - ReasonCodeWarningsOmitted = "item_event_normalization_warnings_omitted" + ReasonCodeOccurrencesReordered = "item_occurrences_reordered" + ReasonCodeDuplicateCollapsed = "duplicate_item_occurrence_collapsed" + ReasonCodeWarningsOmitted = "item_occurrence_normalization_warnings_omitted" +) + +const ( + ItemRegistryReferenceSlot = itemregistry.ReferenceSlot + ItemRegistryMaxBytes = itemregistry.MaxBytes ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} -var _ contracts.Normalizer[dnd.ItemEventList] = (*Normalizer)(nil) +var _ contracts.Normalizer[dnd.ItemOccurrenceList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) -// Options deliberately has no fields: item-event normalization has no -// generated-reference dependency or configurable semantic behavior. type Options struct{} -type Normalizer struct{} +type Normalizer struct{ itemResolver *itemregistry.Resolver } -func New(Options) *Normalizer { return &Normalizer{} } +func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) { + if len(references) > 1 { + return nil, normalizerErrorf("at most one reference set may be supplied") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + resolver, err := itemregistry.NewResolver(referenceSet) + if err != nil { + return nil, normalizerErrorf("prepare item registry: %w", err) + } + return &Normalizer{itemResolver: resolver}, nil +} func (n *Normalizer) Key() string { return Key } -func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } +func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } func (n *Normalizer) ManifestMetadata() map[string]any { - if n == nil { + if n == nil || n.itemResolver == nil { return nil } - return map[string]any{"normalization_policy": normalizationPolicy} + metadata := map[string]any{"normalization_policy": normalizationPolicy} + seeded := n.itemResolver.Seeded() + if seeded.Bound() { + metadata["item_registry_digest"] = seeded.Digest() + metadata["item_count"] = seeded.Count() + } + return metadata } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { - if n == nil { + if n == nil || n.itemResolver == nil { return nil } - return []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}} + return []pipeline.CheckpointFingerprint{ + {Name: "normalization_policy", Value: normalizationPolicy}, + {Name: "item_registry", Value: n.itemResolver.Seeded().ProjectionDigest()}, + } } -func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemEventList]) (contracts.TypedNormalizeResult[dnd.ItemEventList], error) { - if n == nil { - return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("normalizer must not be nil") +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]) (contracts.TypedNormalizeResult[dnd.ItemOccurrenceList], error) { + if n == nil || n.itemResolver == nil { + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { - return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context must not be nil") + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { - return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context error before normalize: %w", err) + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err) } + registry, err := n.itemResolver.Resolve(req.References) + if err != nil { + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("resolve item registry: %w", err) + } + if !registry.Bound() { + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required") + } index := source.NewDocumentIndex(req.Source) - value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index)) - return contracts.TypedNormalizeResult[dnd.ItemEventList]{Value: value, Warnings: warnings}, nil + value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry) + return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { - event dnd.ItemEvent + event dnd.ItemOccurrence inputIndex int } -func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order shared.SourceRefOrder) (dnd.ItemEventList, []contracts.Warning) { - if input.Events == nil { - return dnd.ItemEventList{}, nil +func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []contracts.Warning) { + if input.Occurrences == nil { + return dnd.ItemOccurrenceList{}, nil } - records := make([]normalizedRecord, len(input.Events)) + records := make([]normalizedRecord, len(input.Occurrences)) warnings := make([]contracts.Warning, 0) - for index, inputEvent := range input.Events { - event, changedFields, refsChanged := normalizeEvent(inputEvent, order) + for index, inputEvent := range input.Occurrences { + event, changedFields, found, refsChanged := normalizeEvent(inputEvent, order, registry) records[index] = normalizedRecord{event: event, inputIndex: index} if len(changedFields) != 0 { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), - ReasonCode: ReasonCodeDisplayNormalized, + ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index, diagnostics.Aggregate("fields", changedFields)), }) } + if found && inputEvent.Name != event.Name { + warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized, + Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputEvent.Name), diagnostics.Quote(event.Name))}) + } + if !found { + warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeUnknownItemID, + Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputEvent.ItemID))}) + } if refsChanged { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), @@ -118,24 +160,27 @@ func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order sh } warnings = append(warnings, contracts.Warning{ Scope: eventScope(record.inputIndex), - ReasonCode: ReasonCodeEventsReordered, + ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), }) } output, duplicateWarnings := collapseDuplicates(records, index, order) warnings = append(warnings, duplicateWarnings...) - return dnd.ItemEventList{Events: output}, diagnostics.LimitWarnings(warnings, "item_events", ReasonCodeWarningsOmitted) + return dnd.ItemOccurrenceList{Occurrences: output}, diagnostics.LimitWarnings(warnings, "item_occurrences", ReasonCodeWarningsOmitted) } -func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemEvent, []string, bool) { +func normalizeEvent(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) { output := cloneEvent(input) - changedFields := make([]string, 0, 3) + canonical, found := registry.LookupID(input.ItemID) + if found { + output.Name = canonical.Name + } + changedFields := make([]string, 0, 2) for _, field := range []struct { name string value *string }{ - {name: "name", value: &output.Name}, {name: "from", value: &output.From}, {name: "to", value: &output.To}, } { @@ -146,10 +191,10 @@ func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemE } } output.SourceRefs = order.Canonicalize(input.SourceRefs) - return output, changedFields, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) + return output, changedFields, found, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) } -func cloneEvent(input dnd.ItemEvent) dnd.ItemEvent { +func cloneEvent(input dnd.ItemOccurrence) dnd.ItemOccurrence { output := input if input.Quantity != nil { quantity := *input.Quantity @@ -166,9 +211,9 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemEvent, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemOccurrence, []contracts.Warning) { if len(records) == 0 { - return make([]dnd.ItemEvent, 0), nil + return make([]dnd.ItemOccurrence, 0), nil } keep := make([]bool, len(records)) @@ -190,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) } - output := make([]dnd.ItemEvent, 0, len(records)) + output := make([]dnd.ItemOccurrence, 0, len(records)) for recordIndex, record := range records { if keep[recordIndex] { output = append(output, cloneEvent(record.event)) @@ -214,11 +259,11 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { Scope: eventScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( - fmt.Sprintf("duplicate item event collapsed; retained input index %d", retainedIndex), issues), + fmt.Sprintf("duplicate item occurrence collapsed; retained input index %d", retainedIndex), issues), } } -func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) } +func eventScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ @@ -227,20 +272,32 @@ func ModuleSpec() pipeline.ModuleSpec { ExecutionClass: contracts.ExecutionClassDeterministic, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), - ArtifactKind: dnd.ItemEventListKind, + ArtifactKind: dnd.ItemOccurrenceListKind, + ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { - return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemEventList], error) { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } - return New(options), nil + return New(options, request.References) }) } +func referenceSlots() []contracts.ReferenceSlot { + return []contracts.ReferenceSlot{{ + Name: ItemRegistryReferenceSlot, + Description: "Required normalized item registry used only for item identity grounding.", + Required: true, + AcceptedMediaTypes: []string{"application/json"}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind}, + MaxBytes: ItemRegistryMaxBytes, + }} +} + func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, normalizerErrorf("%w", err) @@ -251,5 +308,5 @@ func DecodeOptions(options map[string]any) (Options, error) { func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } func normalizerErrorf(format string, args ...any) error { - return fmt.Errorf("dnd item events normalizer: "+format, args...) + return fmt.Errorf("dnd item occurrences normalizer: "+format, args...) } diff --git a/internal/modules/dnd/normalize/itemevents/normalizer_test.go b/internal/modules/dnd/normalize/itemevents/normalizer_test.go index 671a2b7..29fb571 100644 --- a/internal/modules/dnd/normalize/itemevents/normalizer_test.go +++ b/internal/modules/dnd/normalize/itemevents/normalizer_test.go @@ -2,186 +2,65 @@ package itemevents import ( "context" - "reflect" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" ) -func TestNormalizeTrimsCanonicalizesAndClones(t *testing.T) { - doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} - quantity := 4 - input := dnd.ItemEventList{Events: []dnd.ItemEvent{{ - Name: " Gold Pieces ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Borin ", To: " Aria ", - SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, - }}} - result := normalize(t, doc, input) - got := result.Value.Events[0] - if got.Name != "Gold Pieces" || got.From != "Borin" || got.To != "Aria" || got.Quantity == input.Events[0].Quantity || - !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { - t.Fatalf("normalized event = %#v", got) - } - if !hasWarning(result.Warnings, ReasonCodeDisplayNormalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { - t.Fatalf("warnings = %#v", result.Warnings) - } - second := normalize(t, doc, result.Value) - if !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { - t.Fatalf("second normalization = %#v; want idempotent output without warnings", second) - } - *got.Quantity = 99 - got.SourceRefs[0].StartUnitID = 999 - if quantity != 4 || input.Events[0].SourceRefs[1].StartUnitID != 30 { - t.Fatalf("Normalize() aliased input: %#v", input) +func registryReferences(t *testing.T) contracts.ReferenceSet { + t.Helper() + content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}) + if err != nil { + t.Fatal(err) } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} } -func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) { - doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}} - ref := func(unit int) source.SourceRef { - return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} +func TestNormalizeCanonicalizesRegistryNameAndRetainsUnknownValues(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} + refs := registryReferences(t) + normalizer, err := New(Options{}, refs) + if err != nil { + t.Fatal(err) } - quantity := 2 - duplicate := dnd.ItemEvent{Name: " Potion ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " Aria ", SourceRefs: []source.SourceRef{ref(30)}} - input := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: "Later", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}}, - duplicate, - {Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{ref(30)}}, - {Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Borin", SourceRefs: []source.SourceRef{ref(30)}}, - {Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}}, + id := itemidentity.DeriveID("Torch") + input := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{ + {ItemID: id, Name: "torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}}, + {ItemID: "unknown", Name: "Unknown", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}, }} - result := normalize(t, doc, input) - got := result.Value.Events - if len(got) != 4 || got[0].Name != "Potion" || got[0].To != "Aria" || got[1].To != "Borin" || got[2].Name != "Later" || got[3].SourceRefs[0].StartUnitID != 999 { - t.Fatalf("normalized events = %#v", got) + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{Source: doc, References: refs, MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: input}}) + if err != nil { + t.Fatal(err) } - if !hasWarning(result.Warnings, ReasonCodeEventsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { + if result.Value.Occurrences[1].Name != "Torch" || result.Value.Occurrences[0].Name != "Unknown" { + t.Fatalf("occurrences = %#v", result.Value.Occurrences) + } + if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeUnknownItemID) { t.Fatalf("warnings = %#v", result.Warnings) } } -func TestNormalizeUsesEveryCanonicalTieBreaker(t *testing.T) { - doc := testDocument() - ref := func(unit int) source.SourceRef { - return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} +func TestNormalizeRequiresRegistryAndRegistersSlot(t *testing.T) { + normalizer, err := New(Options{}) + if err != nil { + t.Fatal(err) } - quantity := 1 - tests := []struct { - name string - earlier dnd.ItemEvent - later dnd.ItemEvent - }{ - {"evidence position", dnd.ItemEvent{Name: "Z", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}}}, - {"normalized name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "B", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}}, - {"exact name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "a", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}}, - {"kind", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindAcquired, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}}, - {"from presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: []source.SourceRef{ref(10)}}}, - {"from comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "B", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}}, - {"exact from", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "a", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}}, - {"to presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}}, - {"to comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "B", SourceRefs: []source.SourceRef{ref(10)}}}, - {"exact to", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "a", SourceRefs: []source.SourceRef{ref(10)}}}, - {"quantity", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{ref(10)}}}, - {"reference sequence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10), ref(20)}}}, + _, err = normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}}}) + if err == nil { + t.Fatal("Normalize() error = nil") } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - result := normalize(t, doc, dnd.ItemEventList{Events: []dnd.ItemEvent{test.later, test.earlier}}) - if len(result.Value.Events) != 2 || !reflect.DeepEqual(result.Value.Events[0], test.earlier) || !reflect.DeepEqual(result.Value.Events[1], test.later) { - t.Fatalf("normalized events = %#v; want %#v then %#v", result.Value.Events, test.earlier, test.later) - } - }) - } -} - -func TestNormalizePreservesEmptyAndMalformedCandidates(t *testing.T) { - for _, input := range []dnd.ItemEventList{{}, {Events: []dnd.ItemEvent{}}} { - result := normalize(t, nil, input) - if (result.Value.Events == nil) != (input.Events == nil) { - t.Fatalf("empty representation changed: %#v", result.Value) - } - } - malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, - {Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, - }} - result := normalize(t, testDocument(), malformed) - if len(result.Value.Events) != 2 || result.Value.Events[0].Name != "Coin" || result.Value.Events[1].Name != "Coin" { - t.Fatalf("malformed candidates were not safely retained: %#v", result.Value) - } -} - -func TestNormalizeDoesNotInferOrConvertInventoryMeaning(t *testing.T) { - doc := testDocument() - quantity := 25 - input := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: " gold pieces ", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, - {Name: " gp ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, - {Name: " potion of healing ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Aria ", To: " Borin ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, - }} - result := normalize(t, doc, input) - got := make(map[string]dnd.ItemEvent, len(result.Value.Events)) - for _, event := range result.Value.Events { - got[event.Name] = event - } - if event := got["gold pieces"]; event.Kind != dnd.ItemEventKindDiscovered || event.Quantity == nil || *event.Quantity != 25 { - t.Fatalf("discovery event was inferred or converted: %#v", event) - } - if event := got["gp"]; event.Kind != dnd.ItemEventKindAcquired || event.Quantity == nil || *event.Quantity != 25 || event.To != "party" { - t.Fatalf("acquisition event was inferred or denomination-converted: %#v", event) - } - if event := got["potion of healing"]; event.Kind != dnd.ItemEventKindTransferred || event.From != "Aria" || event.To != "Borin" || event.Quantity == nil || *event.Quantity != 25 { - t.Fatalf("transfer event was inferred, aliased, singularized, or ledger-adjusted: %#v", event) - } -} - -func TestNormalizerContractAndBoundedWarnings(t *testing.T) { - normalizer := New(Options{}) - if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.ItemEventListKind || len(spec.ReferenceSlots) != 0 || - !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) { - t.Fatalf("ModuleSpec() = %#v", spec) - } - if metadata := normalizer.ManifestMetadata(); !reflect.DeepEqual(metadata, map[string]any{"normalization_policy": normalizationPolicy}) { - t.Fatalf("ManifestMetadata() = %#v", metadata) - } - if fingerprints := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(fingerprints, []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}}) { - t.Fatalf("CheckpointFingerprints() = %#v", fingerprints) + if len(ModuleSpec().ReferenceSlots) != 1 || !ModuleSpec().ReferenceSlots[0].Required { + t.Fatalf("ModuleSpec() = %#v", ModuleSpec()) } registry := pipeline.NewNormalizerRegistry() if err := Register(registry); err != nil { - t.Fatalf("Register() error = %v", err) + t.Fatal(err) } - if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { - t.Fatal("DecodeOptions() accepted unknown option") - } - - count := diagnostics.MaxWarnings + 5 - doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} - input := dnd.ItemEventList{Events: make([]dnd.ItemEvent, count)} - for index := range input.Events { - doc.Units[index].ID = index + 1 - input.Events[index] = dnd.ItemEvent{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}} - } - result := normalize(t, doc, input) - if len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted { - t.Fatalf("warnings = %#v", result.Warnings) - } -} - -func normalize(t *testing.T, doc *source.SourceDocument, input dnd.ItemEventList) contracts.TypedNormalizeResult[dnd.ItemEventList] { - t.Helper() - result, err := New(Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}}) - if err != nil { - t.Fatalf("Normalize() error = %v", err) - } - return result -} - -func testDocument() *source.SourceDocument { - return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}} } func hasWarning(warnings []contracts.Warning, reason string) bool { diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index 5eae7aa..1a5d4ce 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -32,6 +32,7 @@ import ( enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness" itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants" + itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry" itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" @@ -181,26 +182,28 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { }, }) }}, - {name: "item events validator chain", register: func() error { + {name: "item occurrences validator chain", register: func() error { return registry.Register(pipeline.ValidatorChainMapping{ Stage: pipeline.StageExtract, Module: itemeventextract.Key, Validators: []pipeline.ModuleBinding{ pipeline.Binding(validjson.Key), pipeline.Binding(itemeventshape.Key), + pipeline.Binding(itemeventregistry.Key), pipeline.Binding(itemeventrefs.Key), pipeline.Binding(validjsonschema.Key), pipeline.Binding(itemeventrelatedness.Key), }, }) }}, - {name: "item events normalize validator chain", register: func() error { + {name: "item occurrences normalize validator chain", register: func() error { return registry.Register(pipeline.ValidatorChainMapping{ Stage: pipeline.StageNormalize, Module: itemeventnormalize.Key, Validators: []pipeline.ModuleBinding{ pipeline.Binding(validjson.Key), pipeline.Binding(itemeventshape.Key), + pipeline.Binding(itemeventregistry.Key), pipeline.Binding(itemeventinvariants.Key), pipeline.Binding(itemeventrefs.Key), pipeline.Binding(validjsonschema.Key), diff --git a/internal/modules/dnd/register/evidence.go b/internal/modules/dnd/register/evidence.go index 0165679..2f96265 100644 --- a/internal/modules/dnd/register/evidence.go +++ b/internal/modules/dnd/register/evidence.go @@ -16,8 +16,8 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error { {name: "enemy events evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence) }}, - {name: "item events evidence", register: func() error { - return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence) + {name: "item occurrences evidence", register: func() error { + return pipeline.RegisterArtifactEvidence(registry, dnd.ItemOccurrenceListKind, itemOccurrenceEvidence) }}, {name: "item registry evidence", register: func() error { return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence) @@ -69,9 +69,9 @@ func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef { return append([]source.SourceRef(nil), refs...) } -func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef { +func itemOccurrenceEvidence(value dnd.ItemOccurrenceList) []source.SourceRef { var refs []source.SourceRef - for _, record := range value.Events { + 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 2766a1e..6e228ba 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -91,22 +91,22 @@ func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, err return combined, nil } -func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) { +func appendItemOccurrenceLists(values []dnd.ItemOccurrenceList) (dnd.ItemOccurrenceList, error) { count := 0 present := false for _, value := range values { - if value.Events != nil { + if value.Occurrences != nil { present = true } - count += len(value.Events) + count += len(value.Occurrences) } if !present { - return dnd.ItemEventList{}, nil + return dnd.ItemOccurrenceList{}, nil } - combined := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 0, count)} + combined := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 0, count)} for _, value := range values { - for _, event := range value.Events { - combined.Events = append(combined.Events, cloneItemEvent(event)) + for _, occurrence := range value.Occurrences { + combined.Occurrences = append(combined.Occurrences, cloneItemOccurrence(occurrence)) } } return combined, nil @@ -227,7 +227,7 @@ func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent { return clone } -func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent { +func cloneItemOccurrence(value dnd.ItemOccurrence) dnd.ItemOccurrence { clone := value if value.Quantity != nil { quantity := *value.Quantity diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 846133d..66dd4e6 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -47,7 +47,7 @@ func registerModules(registries pipeline.Registries) error { {name: "npc registry codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }}, {name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }}, {name: "enemy events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, enemyeventcodec.New()) }}, - {name: "item events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }}, + {name: "item occurrences codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }}, {name: "item registry codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemregistrycodec.New()) }}, @@ -64,7 +64,7 @@ func registerModules(registries pipeline.Registries) error { {name: "npc registry extractor", register: func() error { return npcextract.Register(registries.Extractors) }}, {name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }}, {name: "enemy events extractor", register: func() error { return enemyeventextract.Register(registries.Extractors) }}, - {name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }}, + {name: "item occurrences extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }}, {name: "item registry extractor", register: func() error { return itemregistryextract.Register(registries.Extractors) }}, {name: "npc occurrences extractor", register: func() error { return occurrenceextract.Register(registries.Extractors) }}, {name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }}, @@ -82,8 +82,8 @@ func registerModules(registries pipeline.Registries) error { {name: "enemy-event-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists) }}, - {name: "item-event-list appendorder merger", register: func() error { - return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists) + {name: "item-occurrence-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.ItemOccurrenceListKind, appendItemOccurrenceLists) }}, {name: "item-registry appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.ItemRegistryKind, appendItemRegistries) @@ -104,7 +104,7 @@ func registerModules(registries pipeline.Registries) error { {name: "npc registry normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }}, {name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }}, {name: "enemy events normalizer", register: func() error { return enemyeventnormalize.Register(registries.Normalizers) }}, - {name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }}, + {name: "item occurrences normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }}, {name: "item registry normalizer", register: func() error { return itemregistrynormalize.Register(registries.Normalizers) }}, {name: "npc occurrences normalizer", register: func() error { return occurrencenormalize.Register(registries.Normalizers) }}, {name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }}, @@ -122,8 +122,8 @@ func registerModules(registries pipeline.Registries) error { {name: "enemy-event-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind) }}, - {name: "item-event-list noop normalizer", register: func() error { - return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind) + {name: "item-occurrence-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.ItemOccurrenceList](registries.Normalizers, dnd.ItemOccurrenceListKind) }}, {name: "item-registry noop normalizer", register: func() error { return noop.RegisterTyped[dnd.ItemRegistry](registries.Normalizers, dnd.ItemRegistryKind) @@ -152,7 +152,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error { {name: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }}, {name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }}, {name: "enemy events prompt assets", register: func() error { return enemyeventextract.RegisterPromptAssets(assets) }}, - {name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }}, + {name: "item occurrences prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }}, {name: "item registry prompt assets", register: func() error { return itemregistryextract.RegisterPromptAssets(assets) }}, {name: "item registry normalization prompt assets", register: func() error { return itemregistrynormalize.RegisterPromptAssets(assets) }}, {name: "npc occurrences prompt assets", register: func() error { return occurrenceextract.RegisterPromptAssets(assets) }}, diff --git a/internal/modules/dnd/register/prompt_cache_test.go b/internal/modules/dnd/register/prompt_cache_test.go index f1d1d1a..ee4826d 100644 --- a/internal/modules/dnd/register/prompt_cache_test.go +++ b/internal/modules/dnd/register/prompt_cache_test.go @@ -51,7 +51,9 @@ func TestExtractionPromptComposition(t *testing.T) { }{ {name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, {name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, - {name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, + {name: "item occurrences", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{ + "item_registry": promptkit.Inline(`{"items":[{"id":"item-registry-sentinel","name":"Torch"}]}`), + }), suffixGroups: [][]string{{evidenceSentinel}, {"item-registry-sentinel"}}}, {name: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, {name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs}, { diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index dd601e9..c8f62ce 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -88,14 +88,14 @@ 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, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key}) assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, itemregistrynormalize.Key, occurrencenormalize.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.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, 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(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) @@ -230,26 +230,28 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) { t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain) } - itemEventExtractChain := []pipeline.ModuleBinding{ + itemOccurrenceExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/registry"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-events/source_relatedness"), } - itemEventNormalizeChain := []pipeline.ModuleBinding{ + itemOccurrenceNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/registry"), pipeline.Binding("normalize/dnd/item-events/invariants"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-events/source_relatedness"), } - if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) { - t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain) + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemOccurrenceExtractChain) { + t.Fatalf("item occurrence extract validator chain = %#v, want %#v", got, itemOccurrenceExtractChain) } - if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) { - t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemOccurrenceNormalizeChain) { + t.Fatalf("item occurrence normalize validator chain = %#v, want %#v", got, itemOccurrenceNormalizeChain) } itemRegistryExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), @@ -328,7 +330,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd_npc_registry_llm.v1.json", "dnd_combat_turns_llm.v1.json", "dnd_enemy_events_llm.v1.json", - "dnd_item_events_llm.v1.json", + "dnd_item_occurrences_llm.v1.json", "dnd_npc_occurrences_llm.v1.json", "dnd_scene_descriptions_llm.v1.json", "dnd_location_registry_llm.v1.json", @@ -368,15 +370,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) { t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots) } - itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key) - itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key) - if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 { - t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK) + itemOccurrenceExtractSpec, itemOccurrenceExtractOK := registries.Extractors.Spec(itemeventextract.Key) + itemOccurrenceNormalizeSpec, itemOccurrenceNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key) + if !itemOccurrenceExtractOK || itemOccurrenceExtractSpec.ArtifactKind != dnd.ItemOccurrenceListKind || !itemOccurrenceNormalizeOK || itemOccurrenceNormalizeSpec.ArtifactKind != dnd.ItemOccurrenceListKind || itemOccurrenceNormalizeSpec.Stage != pipeline.StageNormalize { + t.Fatalf("item occurrence specs = %#v / %#v, present = %t / %t", itemOccurrenceExtractSpec, itemOccurrenceNormalizeSpec, itemOccurrenceExtractOK, itemOccurrenceNormalizeOK) } - for _, slot := range itemEventExtractSpec.ReferenceSlots { - if slot.Required || len(slot.AcceptedArtifactKinds) != 0 { - t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot) - } + itemExtractRegistrySlot := referenceSlot(itemOccurrenceExtractSpec.ReferenceSlots, "item_registry") + itemNormalizeRegistrySlot := referenceSlot(itemOccurrenceNormalizeSpec.ReferenceSlots, "item_registry") + if !itemExtractRegistrySlot.Required || !itemNormalizeRegistrySlot.Required || itemExtractRegistrySlot.MaxBytes != itemNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedMediaTypes, itemNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedArtifactKinds, itemNormalizeRegistrySlot.AcceptedArtifactKinds) { + t.Fatalf("item occurrence registry slots disagree: %#v / %#v", itemOccurrenceExtractSpec.ReferenceSlots, itemOccurrenceNormalizeSpec.ReferenceSlots) } occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key) occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key) @@ -442,8 +444,8 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes {name: "enemy events", project: func() []source.SourceRef { return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, - {name: "item events", project: func() []source.SourceRef { - return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}}) + {name: "item occurrences", project: func() []source.SourceRef { + return itemOccurrenceEvidence(dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "item registry", project: func() []source.SourceRef { return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}}) @@ -694,27 +696,27 @@ func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) { } } -func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) { +func TestAppendItemOccurrenceListsPreservesOrderPresenceAndOwnership(t *testing.T) { quantity := 3 refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} - input := []dnd.ItemEventList{ + input := []dnd.ItemOccurrenceList{ {}, - {Events: []dnd.ItemEvent{}}, - {Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, - {Events: []dnd.ItemEvent{{Name: "second", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, + {Occurrences: []dnd.ItemOccurrence{}}, + {Occurrences: []dnd.ItemOccurrence{{ItemID: "first", Name: "first", Kind: dnd.ItemOccurrenceKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, + {Occurrences: []dnd.ItemOccurrence{{ItemID: "second", Name: "second", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, } - got, err := appendItemEventLists(input) - if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) { - t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err) + got, err := appendItemOccurrenceLists(input) + if err != nil || got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"first", "second"}) { + t.Fatalf("appendItemOccurrenceLists() = %#v, error = %v", got, err) } - *got.Events[0].Quantity = 99 - got.Events[0].SourceRefs[0].StartUnitID = 999 - if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 { - t.Fatal("merged item events share quantity or source-reference storage") + *got.Occurrences[0].Quantity = 99 + got.Occurrences[0].SourceRefs[0].StartUnitID = 999 + if quantity != 3 || input[2].Occurrences[0].SourceRefs[0].StartUnitID != 1 { + t.Fatal("merged item occurrences share quantity or source-reference storage") } - for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} { - result, err := appendItemEventLists(values) - if err != nil || result.Events != nil { + for _, values := range [][]dnd.ItemOccurrenceList{nil, []dnd.ItemOccurrenceList{{}, {}}} { + result, err := appendItemOccurrenceLists(values) + if err != nil || result.Occurrences != nil { t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err) } } @@ -765,9 +767,9 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err) } - events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}}) - if err != nil || events.Events[0].SourceRefs == nil { - t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, err) + events, err := appendItemOccurrenceLists([]dnd.ItemOccurrenceList{{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{}}}}}) + if err != nil || events.Occurrences[0].SourceRefs == nil { + t.Fatalf("appendItemOccurrenceLists() = %#v, %v; want present-empty source refs", events, err) } items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}}) diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index 085ff4f..a216ded 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -13,6 +13,7 @@ import ( enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness" itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants" + itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry" itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" @@ -69,10 +70,11 @@ func registerValidators(registries pipeline.Registries) error { {name: "enemy event source references validator", register: func() error { return enemyeventrefs.Register(registries.Validators) }}, {name: "enemy event source relatedness validator", register: func() error { return enemyeventrelatedness.Register(registries.Validators) }}, {name: "enemy event normalized invariants validator", register: func() error { return enemyeventinvariants.Register(registries.Validators) }}, - {name: "item event shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }}, - {name: "item event source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }}, - {name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }}, - {name: "item event normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }}, + {name: "item occurrence shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }}, + {name: "item occurrence registry validator", register: func() error { return itemeventregistry.Register(registries.Validators) }}, + {name: "item occurrence source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }}, + {name: "item occurrence source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }}, + {name: "item occurrence normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }}, {name: "item shape validator", register: func() error { return itemshape.Register(registries.Validators) }}, {name: "item identity validator", register: func() error { return itemidentity.Register(registries.Validators) }}, {name: "item source references validator", register: func() error { return itemrefs.Register(registries.Validators) }}, @@ -119,11 +121,11 @@ func registerValidators(registries pipeline.Registries) error { {name: "enemy-event-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind) }}, - {name: "item-event-list always accept validator", register: func() error { - return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) + {name: "item-occurrence-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind) }}, - {name: "item-event-list always reject validator", register: func() error { - return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) + {name: "item-occurrence-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind) }}, {name: "item-registry always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind) diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index ccce378..767812a 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -16,7 +16,7 @@ const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list" const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list" -const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list" +const ItemOccurrenceListKind contracts.ArtifactKind = "dnd/item-occurrence-list" const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry" @@ -109,23 +109,24 @@ type SceneDescription struct { Summary string `json:"summary"` } -type ItemEventKind string +type ItemOccurrenceKind string const ( - ItemEventKindDiscovered ItemEventKind = "discovered" - ItemEventKindAcquired ItemEventKind = "acquired" - ItemEventKindLost ItemEventKind = "lost" - ItemEventKindConsumed ItemEventKind = "consumed" - ItemEventKindTransferred ItemEventKind = "transferred" + ItemOccurrenceKindDiscovered ItemOccurrenceKind = "discovered" + ItemOccurrenceKindAcquired ItemOccurrenceKind = "acquired" + ItemOccurrenceKindLost ItemOccurrenceKind = "lost" + ItemOccurrenceKindConsumed ItemOccurrenceKind = "consumed" + ItemOccurrenceKindTransferred ItemOccurrenceKind = "transferred" ) -type ItemEventList struct { - Events []ItemEvent `json:"events"` +type ItemOccurrenceList struct { + Occurrences []ItemOccurrence `json:"occurrences"` } -type ItemEvent struct { +type ItemOccurrence struct { + ItemID string `json:"item_id"` Name string `json:"name"` - Kind ItemEventKind `json:"kind"` + Kind ItemOccurrenceKind `json:"kind"` Quantity *int `json:"quantity,omitempty"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` diff --git a/internal/modules/dnd/validate/itemevents/invariants/validator.go b/internal/modules/dnd/validate/itemevents/invariants/validator.go index f66e759..92a98b0 100644 --- a/internal/modules/dnd/validate/itemevents/invariants/validator.go +++ b/internal/modules/dnd/validate/itemevents/invariants/validator.go @@ -24,7 +24,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) { if err := Validate(req.Source, req.Value); err != nil { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil } @@ -45,7 +45,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq // Validate checks only invariants introduced by item-event normalization. // Shape and source-reference failures remain owned by their earlier validators. -func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error { +func Validate(doc *source.SourceDocument, value dnd.ItemOccurrenceList) error { if itemeventshape.Validate(value) != nil { return nil } @@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error { if len(issues) == 0 { return nil } - return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item event invariants", issues)) + return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item occurrence invariants", issues)) } -func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string { +func issuesFor(order shared.SourceRefOrder, value dnd.ItemOccurrenceList) []string { issues := make([]string, 0) seenIdentity := make(map[string]int) - for eventIndex, event := range value.Events { - prefix := fmt.Sprintf("events[%d]", eventIndex) + for eventIndex, event := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", eventIndex) if event.Name != itemeventmodel.DisplayValue(event.Name) { issues = append(issues, prefix+".name is not display-normalized") } @@ -85,21 +85,21 @@ func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string { } key := itemeventmodel.ExactIdentity(order, event) if previous, exists := seenIdentity[key]; exists { - issues = append(issues, fmt.Sprintf("%s duplicates item event %d under normalized identity", prefix, previous)) + issues = append(issues, fmt.Sprintf("%s duplicates item occurrence %d under normalized identity", prefix, previous)) } else { seenIdentity[key] = eventIndex } } - for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ { - if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) { - issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex)) + for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ { + if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) { + issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex)) } } return issues } -func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool { - for _, event := range value.Events { +func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool { + for _, event := range value.Occurrences { if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) { return false } @@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/itemevents/invariants/validator_test.go b/internal/modules/dnd/validate/itemevents/invariants/validator_test.go index 01a9b9f..3b76afb 100644 --- a/internal/modules/dnd/validate/itemevents/invariants/validator_test.go +++ b/internal/modules/dnd/validate/itemevents/invariants/validator_test.go @@ -11,20 +11,11 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - normalizeitemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" ) -func TestValidatorApprovesNormalizerOutput(t *testing.T) { +func TestValidatorApprovesNormalizedOutput(t *testing.T) { doc := invariantDocument() - input := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, - {Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, - }} - normalized, err := normalizeitemevents.New(normalizeitemevents.Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}}) - if err != nil { - t.Fatal(err) - } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: normalized.Value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()}) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v; want approval", result, err) } @@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) { func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { tests := []struct { name string - mutate func(*dnd.ItemEventList) + mutate func(*dnd.ItemOccurrenceList) want string }{ - {name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"}, - {name: "from whitespace", mutate: func(value *dnd.ItemEventList) { - value.Events[0].Kind = dnd.ItemEventKindLost - value.Events[0].From = " Aria " + {name: "name whitespace", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].Name = " Coin " }, want: ".name is not display-normalized"}, + {name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost + value.Occurrences[0].From = " Aria " }, want: ".from is not display-normalized"}, - {name: "to whitespace", mutate: func(value *dnd.ItemEventList) { - value.Events[0].Kind = dnd.ItemEventKindAcquired - value.Events[0].To = " Aria " + {name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired + value.Occurrences[0].To = " Aria " }, want: ".to is not display-normalized"}, - {name: "reference order", mutate: func(value *dnd.ItemEventList) { - value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + {name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} }, want: "not in canonical order"}, - {name: "duplicate reference", mutate: func(value *dnd.ItemEventList) { - value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0]) + {name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0]) }, want: "duplicates the previous reference"}, - {name: "list order", mutate: func(value *dnd.ItemEventList) { value.Events = []dnd.ItemEvent{value.Events[1], value.Events[0]} }, want: "out of canonical order"}, - {name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"}, + {name: "list order", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences = []dnd.ItemOccurrence{value.Occurrences[1], value.Occurrences[0]} + }, want: "out of canonical order"}, + {name: "duplicate event", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences = append(value.Occurrences, value.Occurrences[0]) + }, want: "duplicates item occurrence"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -62,7 +57,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Validate() error = %v, want %q", err, test.want) } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value}) if err != nil || result.Approved || result.ReasonCode != ReasonCode { t.Fatalf("validator result = %#v, %v", result, err) } @@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} - value := dnd.ItemEventList{Events: []dnd.ItemEvent{{ - Name: "Coin", Kind: dnd.ItemEventKindDiscovered, + value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ + ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, }}} if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") { t.Fatalf("Validate() error = %v, want document-order rejection", err) } shapeInvalid := normalizedList() - shapeInvalid.Events[0].Name = " " + shapeInvalid.Occurrences[0].Name = " " if err := Validate(invariantDocument(), shapeInvalid); err != nil { t.Fatalf("shape failure must be deferred, got %v", err) } sourceInvalid := normalizedList() - sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999 + sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999 if err := Validate(invariantDocument(), sourceInvalid); err != nil { t.Fatalf("source-reference failure must be deferred, got %v", err) } @@ -93,12 +88,12 @@ func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) { func TestValidatorContractAndBoundedDiagnostics(t *testing.T) { value := normalizedList() - value.Events = make([]dnd.ItemEvent, 30) - for index := range value.Events { - value.Events[index] = normalizedList().Events[0] - value.Events[index].Name = strings.Repeat("火", 300) + value.Occurrences = make([]dnd.ItemOccurrence, 30) + for index := range value.Occurrences { + value.Occurrences[index] = normalizedList().Occurrences[0] + value.Occurrences[index].Name = strings.Repeat("火", 300) } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value}) if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") { t.Fatalf("bounded result = %#v, %v", result, err) } @@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) { } } -func normalizedList() dnd.ItemEventList { - return dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, - {Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, +func normalizedList() dnd.ItemOccurrenceList { + return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + {ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, }} } diff --git a/internal/modules/dnd/validate/itemevents/registry/validator.go b/internal/modules/dnd/validate/itemevents/registry/validator.go new file mode 100644 index 0000000..8f2c5cc --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/registry/validator.go @@ -0,0 +1,119 @@ +// Package registry validates item occurrence identity against item grounding. +package registry + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" +) + +const ( + Key = "extract/dnd/item-events/registry" + ReasonCode = "invalid_item_occurrence_registry" + policy = "dnd.item_occurrences.validator.registry.v1" +) + +type Options struct{} +type Validator struct{ itemResolver *itemregistry.Resolver } + +var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil) +var _ contracts.ManifestMetadataProvider = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) { + if len(references) > 1 { + return nil, fmt.Errorf("item occurrence registry validator accepts at most one reference set") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + resolver, err := itemregistry.NewResolver(referenceSet) + if err != nil { + return nil, fmt.Errorf("prepare item registry: %w", err) + } + return &Validator{itemResolver: resolver}, nil +} + +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) ManifestMetadata() map[string]any { + if v == nil || v.itemResolver == nil { + return nil + } + metadata := map[string]any{"policy": policy} + seeded := v.itemResolver.Seeded() + if seeded.Bound() { + metadata["item_registry_digest"] = seeded.Digest() + metadata["item_count"] = seeded.Count() + } + return metadata +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if v == nil || v.itemResolver == nil { + return nil + } + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "item_registry", Value: v.itemResolver.Seeded().ProjectionDigest()}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) { + if occurrenceshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + if v == nil || v.itemResolver == nil { + return contracts.ValidationResult{}, fmt.Errorf("item occurrence registry validator must not be nil") + } + registry, err := v.itemResolver.Resolve(req.References) + if err != nil { + return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err) + } + if !registry.Bound() { + return rejection([]string{"item registry reference is required"}), nil + } + issues := make([]string, 0) + for index, occurrence := range req.Value.Occurrences { + item, ok := registry.LookupID(occurrence.ItemID) + if !ok { + issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID))) + continue + } + if occurrence.Name != item.Name { + issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID))) + } + } + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return rejection(issues), nil +} + +func rejection(issues []string) contracts.ValidationResult { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)} +} +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options, request.References) + }) +} +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/itemevents/registry/validator_test.go b/internal/modules/dnd/validate/itemevents/registry/validator_test.go new file mode 100644 index 0000000..38994c5 --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/registry/validator_test.go @@ -0,0 +1,37 @@ +package registry + +import ( + "context" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" + itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry" +) + +func TestValidatorRejectsUnknownAndMismatchedItemPairs(t *testing.T) { + id := itemidentity.DeriveID("Torch") + content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: id, Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}) + if err != nil { + t.Fatal(err) + } + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{itemregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: itemregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: itemregistry.ReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} + validator, err := New(Options{}, references) + if err != nil { + t.Fatal(err) + } + base := dnd.ItemOccurrence{ItemID: id, Name: "Torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} + for _, occurrence := range []dnd.ItemOccurrence{{ItemID: "unknown", Name: "Unknown", Kind: base.Kind, SourceRefs: base.SourceRefs}, {ItemID: id, Name: "Lantern", Kind: base.Kind, SourceRefs: base.SourceRefs}} { + result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{occurrence}}}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("Validate() = %#v, %v", result, err) + } + } + result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{base}}}) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} diff --git a/internal/modules/dnd/validate/itemevents/shape/validator.go b/internal/modules/dnd/validate/itemevents/shape/validator.go index 8cafb1d..1c041f8 100644 --- a/internal/modules/dnd/validate/itemevents/shape/validator.go +++ b/internal/modules/dnd/validate/itemevents/shape/validator.go @@ -22,7 +22,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*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.ItemEventList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) { if err := Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil } @@ -42,21 +42,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } // Validate returns one bounded error for every owned item-event shape issue. -func Validate(value dnd.ItemEventList) error { +func Validate(value dnd.ItemOccurrenceList) error { issues := issuesFor(value) if len(issues) == 0 { return nil } - return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues)) + return fmt.Errorf("%s", diagnostics.Aggregate("invalid item occurrence shape", issues)) } -func issuesFor(value dnd.ItemEventList) []string { - if value.Events == nil { - return []string{"events must be present"} +func issuesFor(value dnd.ItemOccurrenceList) []string { + if value.Occurrences == nil { + return []string{"occurrences must be present"} } issues := make([]string, 0) - for index, event := range value.Events { - prefix := fmt.Sprintf("events[%d]", index) + for index, event := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if strings.TrimSpace(event.ItemID) == "" { + issues = append(issues, prefix+".item_id must not be empty: "+diagnostics.Quote(event.ItemID)) + } if strings.TrimSpace(event.Name) == "" { issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name)) } @@ -80,7 +83,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/itemevents/shape/validator_test.go b/internal/modules/dnd/validate/itemevents/shape/validator_test.go index 15b26c2..8277d5c 100644 --- a/internal/modules/dnd/validate/itemevents/shape/validator_test.go +++ b/internal/modules/dnd/validate/itemevents/shape/validator_test.go @@ -15,18 +15,17 @@ import ( func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) { quantity := 1 - value := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)}, - {Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)}, - {Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)}, - {Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)}, - {Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, + value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)}, + {ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)}, + {ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)}, + {ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)}, + {ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, }} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value}) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } - empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: dnd.ItemEventList{Events: []dnd.ItemEvent{}}}) + empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}}) if err != nil || !empty.Approved { t.Fatalf("empty list result = %#v, %v", empty, err) } @@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) { negative := -1 tests := []struct { name string - value dnd.ItemEventList + value dnd.ItemOccurrenceList want string }{ - {"missing events", dnd.ItemEventList{}, "events must be present"}, - {"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"}, - {"unsupported kind", listWith(dnd.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"}, - {"discovered holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"}, - {"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"}, - {"lost destination", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, - {"consumed without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"}, - {"party transfer from", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, - {"party transfer to", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"}, - {"case equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"}, - {"unicode equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"}, - {"zero quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, - {"negative quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, - {"missing source refs", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"}, + {"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"}, + {"blank name", listWith(dnd.ItemOccurrence{ItemID: "item", Name: " \t", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"}, + {"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"}, + {"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"acquired without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"}, + {"lost destination", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, + {"consumed without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"}, + {"party transfer from", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, + {"party transfer to", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"}, + {"case equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"unicode equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"zero quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, + {"negative quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, + {"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "source_refs must contain"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: test.value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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) } }) } - if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: valid}); err != nil || !result.Approved { + if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: valid}); err != nil || !result.Approved { t.Fatalf("valid result = %#v, %v", result, err) } } func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) { - value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} - for index := range value.Events { - value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"} + value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)} + for index := range value.Occurrences { + value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: " \n", Kind: "unsupported"} } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value}) - if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { t.Fatalf("Validate() = %#v, %v", result, err) } - if value.Events[0].Name != " \n" { + if value.Occurrences[0].Name != " \n" { t.Fatal("Validate() mutated input") } if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { @@ -96,12 +95,12 @@ func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing. } } -func validList() dnd.ItemEventList { - return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}} +func validList() dnd.ItemOccurrenceList { + return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: refs(1, 1)}}} } -func listWith(event dnd.ItemEvent) dnd.ItemEventList { - return dnd.ItemEventList{Events: []dnd.ItemEvent{event}} +func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList { + return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}} } func refs(start, end int) []source.SourceRef { diff --git a/internal/modules/dnd/validate/itemevents/source_refs/validator.go b/internal/modules/dnd/validate/itemevents/source_refs/validator.go index 6851856..96b8f7e 100644 --- a/internal/modules/dnd/validate/itemevents/source_refs/validator.go +++ b/internal/modules/dnd/validate/itemevents/source_refs/validator.go @@ -22,7 +22,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -34,9 +34,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) { if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { - return contracts.ValidationResult{}, fmt.Errorf("item event source-reference validator requires the current extraction chunk") + return contracts.ValidationResult{}, fmt.Errorf("item occurrence source-reference validator requires the current extraction chunk") } if itemeventshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil @@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq coverage = newChunkCoverage(req.Chunk) } issues := make([]string, 0) - for eventIndex, event := range req.Value.Events { + for eventIndex, event := range req.Value.Occurrences { for refIndex, ref := range event.SourceRefs { if err := index.ValidateRef(ref); err != nil { - issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error()))) + issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error()))) continue } if coverage != nil && !coverage.contains(req.Source, ref) { - issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex)) + issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex)) } } } if len(issues) == 0 { return contracts.ValidationResult{Approved: true}, nil } - return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item event source references", issues)}, nil + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil } type chunkCoverage struct { @@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go b/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go index 7435b0b..fb6f9a5 100644 --- a/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go @@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) { if err != nil || !result.Approved { t.Fatalf("valid result = %#v, %v", result, err) } - value.Events[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}, @@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) { if err != nil || result.Approved || result.ReasonCode != ReasonCode { t.Fatalf("invalid result = %#v, %v", result, err) } - for _, index := range []string{"events[0].source_refs[0]", "events[0].source_refs[1]", "events[0].source_refs[2]"} { + for _, index := range []string{"occurrences[0].source_refs[0]", "occurrences[0].source_refs[1]", "occurrences[0].source_refs[2]"} { if !strings.Contains(result.Message, index) { t.Fatalf("validation message = %q, missing %q", result.Message, index) } @@ -39,40 +39,40 @@ func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) { doc := document() chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)} contained := validList() - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained}) if err != nil || !result.Approved { t.Fatalf("contained evidence = %#v, %v", result, err) } crossesChunk := validList() - crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}} - result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk}) + crossesChunk.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk}) if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") { t.Fatalf("out-of-chunk evidence = %#v, %v", result, err) } noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}} spansMissingUnit := validList() - spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}} - result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit}) + spansMissingUnit.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit}) if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") { t.Fatalf("partially contained evidence = %#v, %v", result, err) } multiRange := validList() - multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}} - result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: multiRange}) + multiRange.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: multiRange}) if err != nil || !result.Approved { t.Fatalf("post-merge evidence = %#v, %v", result, err) } } func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) { - _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()}) + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()}) if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") { t.Fatalf("Validate() error = %v", err) } - malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring"}}} + malformed := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring"}}} result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) if err != nil || !result.Approved { t.Fatalf("shape deferral = %#v, %v", result, err) @@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) { } func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { - value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} - for index := range value.Events { - value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}} + value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)} + for index := range value.Occurrences { + value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}} } result, err := New(Options{}).Validate(context.Background(), request(document(), value)) - if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { t.Fatalf("Validate() = %#v, %v", result, err) } before := validList() @@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { } } -func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] { - return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value} +func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] { + return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value} } func document() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}} } -func validList() dnd.ItemEventList { - return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} +func validList() dnd.ItemOccurrenceList { + return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} } diff --git a/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go b/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go index dbe0ad3..dacbcd6 100644 --- a/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go @@ -23,7 +23,7 @@ const ( type Options struct{} type Validator struct{} -var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) +var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } @@ -37,7 +37,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { // Validate performs advisory source grounding only. Shape and range failures // are intentionally left to their blocking owners. -func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) { +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) { if itemeventshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -47,15 +47,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } warnings := make([]contracts.Warning, 0) - for index, event := range req.Value.Events { + for index, event := range req.Value.Occurrences { citedText, err := resolver.CitedText(event.SourceRefs) if err != nil || shared.ContainsTokenSequence(citedText, event.Name) { continue } warnings = append(warnings, contracts.Warning{ - Scope: fmt.Sprintf("events[%d]", index), + Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: WarningReasonCode, - Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)), + Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(event.Name)), }) } return contracts.ValidationResult{ @@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err diff --git a/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go index 7819dc2..cbc5bc5 100644 --- a/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go @@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) { {ID: 2, Text: "of healing in the chest."}, {ID: 3, Text: "The group rests."}, }} - value := dnd.ItemEventList{Events: []dnd.ItemEvent{ - {Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}}, - {Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}}, + value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "POTION OF HEALING", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}}, + {ItemID: "item", Name: "missing-item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}}, }} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ "glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}}, }} - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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 != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") { + if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") { t.Fatalf("warning = %#v", warning) } } func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}} - for _, value := range []dnd.ItemEventList{ - {Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}}, - {Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}}, + for _, value := range []dnd.ItemOccurrenceList{ + {Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}}, + {Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "potion", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}}, } { - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("deferred result = %#v, %v", result, err) } @@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) { func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) { count := diagnostics.MaxWarnings + 5 - events := make([]dnd.ItemEvent, count) + events := make([]dnd.ItemOccurrence, count) for index := range events { - events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} + events[index] = dnd.ItemOccurrence{ItemID: "item", Name: "missing item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} } - result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{ + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{ Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}}, - Value: dnd.ItemEventList{Events: events}, + Value: dnd.ItemOccurrenceList{Occurrences: events}, }) if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode { t.Fatalf("Validate() = %#v, %v", result, err) diff --git a/internal/modules/integration/dnd_itemevents_runner_test.go b/internal/modules/integration/dnd_itemevents_runner_test.go index b615cc7..0010307 100644 --- a/internal/modules/integration/dnd_itemevents_runner_test.go +++ b/internal/modules/integration/dnd_itemevents_runner_test.go @@ -1,170 +1,62 @@ package integration_test import ( - "context" - "encoding/json" - "fmt" - "os" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/config" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents" - itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" - itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" - itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" - itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" - "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" ) -func TestProductionItemEventPipelineProducesNormalizedDurableArtifact(t *testing.T) { - raw := readItemEventFixture(t) +func TestItemOccurrenceConfigurationRequiresEarlierItemRegistry(t *testing.T) { registries := productionNPCRegistries(t) - resolved := resolveItemEventPipeline(t, registries) - client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(`{"events":[ - {"name":"Healing Potion","kind":"transferred","quantity":1,"from":"Aria","to":"Borin","source_refs":[{"start_segment":3,"end_segment":3}]}, - {"name":" Gold Pieces ","kind":"acquired","quantity":25,"to":" party ","source_refs":[{"start_segment":2,"end_segment":2}]}, - {"name":"Missing Relic","kind":"discovered","source_refs":[{"start_segment":4,"end_segment":4}]}, - {"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]}, - {"name":"Gold Pieces","kind":"acquired","quantity":25,"to":"party","source_refs":[{"start_segment":2,"end_segment":2}]} - ]}`)}} - - output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw}) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { - t.Fatalf("run output = %#v, rejected = %#v", output.NormalizeOutputs, output.Rejected) - } - serialized := normalizedLane(t, output, "item-events") - if serialized.NormalizerKey != itemeventnormalize.Key || serialized.Artifact.Schema.ID != itemeventcodec.SchemaID || serialized.Artifact.Schema.Version != itemeventcodec.SchemaVersion { - t.Fatalf("serialized item events = %#v", serialized) - } - events, err := itemeventcodec.New().Decode(serialized.Artifact.Content) - if err != nil { - t.Fatalf("Decode(item event output) error = %v", err) - } - if len(events.Events) != 4 { - t.Fatalf("item events = %#v, want one exact duplicate removed", events) - } - first, second, third, fourth := events.Events[0], events.Events[1], events.Events[2], events.Events[3] - if first.Name != "Ancient Coin" || second.Name != "Gold Pieces" || second.Kind != dnd.ItemEventKindAcquired || second.Quantity == nil || *second.Quantity != 25 || second.To != "party" || third.Name != "Healing Potion" || third.Kind != dnd.ItemEventKindTransferred || third.From != "Aria" || third.To != "Borin" || fourth.Name != "Missing Relic" { - t.Fatalf("normalized item event sequence = %#v", events.Events) - } - for _, event := range events.Events { - for _, ref := range event.SourceRefs { - if ref.SourceID != "item-events-session" { - t.Fatalf("source reference = %#v, want current transcript source", ref) - } - } - } - if !hasItemEventWarning(output.Warnings, "item_event_source_unrelated") || !hasItemEventWarning(output.Warnings, "duplicate_item_event_collapsed") { - t.Fatalf("warnings = %#v, want advisory relatedness and duplicate-collapse warnings", output.Warnings) - } - if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 { - t.Fatalf("manifest = %#v", output.Manifest) - } - lane := output.Manifest.ArtifactLanes[0] - if lane.ID != "item-events" || lane.Extractor != itemeventextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != itemeventnormalize.Key { - t.Fatalf("manifest lane = %#v", lane) - } - if len(client.requests) != 1 || client.requests[0].PromptID != itemeventextract.PromptID { - t.Fatalf("LLM requests = %#v", client.requests) - } -} - -func TestProductionItemEventPipelineRetriesBlockingCandidates(t *testing.T) { - registries := productionNPCRegistries(t) - resolved := resolveItemEventPipeline(t, registries) - for _, test := range []struct { - name string - response string - reasonCode string - validatorName string - }{ - {name: "shape", response: `{"events":[{"name":"","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]}]}`, reasonCode: itemeventshape.ReasonCode, validatorName: itemeventshape.Key}, - {name: "source references", response: `{"events":[{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":99,"end_segment":99}]}]}`, reasonCode: itemeventrefs.ReasonCode, validatorName: itemeventrefs.Key}, - } { - t.Run(test.name, func(t *testing.T) { - client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(test.response), []byte(test.response), []byte(test.response)}} - output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readItemEventFixture(t)}) - if err != nil { - t.Fatalf("Run() error = %v; want rejected candidate outcome", err) - } - if len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 || len(client.requests) != 3 { - t.Fatalf("output=%#v rejected=%#v requests=%d", output.NormalizeOutputs, output.Rejected, len(client.requests)) - } - rejection := output.Rejected[0] - if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 { - t.Fatalf("rejection = %#v", rejection) - } - }) - } -} - -func resolveItemEventPipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline { - t.Helper() - configValue := config.Default() - configValue.Pipelines["dnd-item-events-fixture"] = pipeline.PipelineProfile{ - Input: pipeline.Binding(transcript.Key), - Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 100}}, - Artifacts: map[string]pipeline.ArtifactLaneProfile{ - "item-events": { - Extract: pipeline.ModuleBinding{Module: itemeventextract.Key, Retries: 2}, - Normalize: pipeline.Binding(itemeventnormalize.Key), - }, - }, - } - effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-item-events-fixture", Catalog: moduleCatalog(registries)}) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return effective.ResolvedPipeline -} - -func readItemEventFixture(t *testing.T) []byte { - t.Helper() - raw, err := os.ReadFile("testdata/seriatim_item_events_session.json") + cfg, err := configFromYAML(` +version: 4 +pipelines: + items: + input: seriatim + chunk: generic + steps: + - id: identify-items + artifacts: + registry: + extract: dnd/item-registry + merge: appendorder + normalize: dnd/item-registry + - id: record-occurrences + references: + item_registry: + artifact: + step: identify-items + lane: registry + artifacts: + occurrences: + extract: dnd/item-events + merge: appendorder + normalize: dnd/item-events +`) if err != nil { t.Fatal(err) } - return raw -} + if _, err := cfg.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err != nil { + t.Fatalf("Resolve() error = %v", err) + } -func hasItemEventWarning(warnings []contracts.Warning, reasonCode string) bool { - for _, warning := range warnings { - if warning.ReasonCode == reasonCode && strings.HasPrefix(warning.Scope, "events[") { - return true - } + broken, err := configFromYAML(strings.Replace(` +version: 4 +pipelines: + items: + input: seriatim + chunk: generic + steps: + - id: record-occurrences + artifacts: + occurrences: + extract: dnd/item-events +`, "record-occurrences", "record-occurrences", 1)) + if err != nil { + t.Fatal(err) + } + if _, err := broken.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err == nil { + t.Fatal("Resolve() error = nil, want required item registry") } - return false } - -type itemEventProductionLLMClient struct { - responses [][]byte - requests []contracts.StructuredCompletionRequest -} - -func (client *itemEventProductionLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { - if err := ctx.Err(); err != nil { - return contracts.StructuredCompletionResponse{}, err - } - if request.PromptID != itemeventextract.PromptID { - return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID) - } - index := len(client.requests) - if index >= len(client.responses) { - return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing item event response %d", index) - } - content := append([]byte(nil), client.responses[index]...) - if err := json.Unmarshal(content, out); err != nil { - return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate item event structured target: %w", err) - } - client.requests = append(client.requests, request) - return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "item-events-fake"}, nil -} - -var _ contracts.StructuredLLMClient = (*itemEventProductionLLMClient)(nil)