Adopt registry-backed item occurrences

This commit is contained in:
2026-08-05 19:55:34 +00:00
parent f91237e9c0
commit 3dfefd0e14
46 changed files with 996 additions and 1034 deletions

View File

@@ -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, in party possession established by the transcript. This is an event history,
not an inventory or ledger: do not calculate balances, resolve item identity not an inventory or ledger: do not calculate balances, resolve item identity
across records, or infer ownership that the transcript does not establish. 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 does not state one. Use a concise observed item name and preserve the stated
currency denomination. currency denomination.

View File

@@ -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" }}

View File

@@ -14,6 +14,9 @@ inputs:
- name: glossary - name: glossary
required: false required: false
content_type: text/plain content_type: text/plain
- name: item_registry
required: true
content_type: application/json
messages: messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
@@ -29,6 +32,8 @@ messages:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./item-registry.md
- role: user - role: user
content_file: ./instructions.md content_file: ./instructions.md
cache_control: cache_control:
@@ -36,5 +41,5 @@ messages:
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_item_events_llm.v1.json schema_path: dnd_item_occurrences_llm.v1.json
repair_attempts: 0 repair_attempts: 0

View File

@@ -1,17 +1,18 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.item_events.llm", "$id": "notarius.dnd.item_occurrences.llm",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["events"], "required": ["occurrences"],
"properties": { "properties": {
"events": { "occurrences": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["name", "kind", "quantity", "from", "to", "source_refs"], "required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"],
"properties": { "properties": {
"item_id": {"type": "string"},
"name": {"type": "string"}, "name": {"type": "string"},
"kind": {"type": "string"}, "kind": {"type": "string"},
"quantity": {"type": ["integer", "null"]}, "quantity": {"type": ["integer", "null"]},

View File

@@ -411,9 +411,10 @@ generated bindings.
`dnd/location-registry` and `dnd/item-registry` extraction and normalization `dnd/location-registry` and `dnd/item-registry` extraction and normalization
are `llm_backed`; their normalizers may use the pipeline's selected LLM profile are `llm_backed`; their normalizers may use the pipeline's selected LLM profile
for bounded duplicate proposals. `dnd/location-occurrences` extraction is for bounded duplicate proposals. `dnd/location-occurrences` and
`llm_backed`, while its normalizer is `deterministic`. The complete example `dnd/item-events` extraction are `llm_backed`, while their normalizers are
binds the location registry in one step and the occurrence lane in the next. `deterministic`. The complete example binds each registry in an earlier step
before its occurrence consumer.
The D&D artifact contracts define each emitted schema: The D&D artifact contracts define each emitted schema:
[spells](integrations/dnd-spell-artifacts.md), [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** | | 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** | | 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** | | 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** | | 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** | | 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** | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 |

View File

@@ -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` currently produces this source-grounded item and currency
`dnd/item-events`. It records source-grounded discoveries and possession occurrence list. It records discoveries and possession changes, not an
changes; it does not maintain an inventory, balance, or ledger. inventory, balance, or ledger.
## Identity and compatibility ## Identity and compatibility
| Property | Value | | Property | Value |
| --- | --- | | --- | --- |
| Artifact kind | `dnd/item-event-list` | | Artifact kind | `dnd/item-occurrence-list` |
| Schema ID | `notarius.dnd.item_events` | | Schema ID | `notarius.dnd.item_occurrences` |
| Schema name | `notarius_dnd_item_events_v1` | | Schema name | `notarius_dnd_item_occurrences_v1` |
| Schema version | `v1` | | Schema version | `v1` |
| Media type | `application/json` | | Media type | `application/json` |
`v1` is a strict JSON object with required `events`; the array may be empty. The strict root has required `occurrences`. Records require `item_id`, `name`,
Event and source-reference objects reject unknown fields. An incompatible `kind`, and `source_refs`; unknown fields are rejected. `quantity`, `from`, and
shape change requires a new schema version. `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 ## 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 ```json
{ {
"events": [ "occurrences": [
{ {
"item_id": "item:sha256:…",
"name": "Silver Pieces", "name": "Silver Pieces",
"kind": "acquired", "kind": "acquired",
"quantity": 20, "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`; See [item registry](dnd-item-registry-artifacts.md) for the grounding artifact
`lost` and `consumed` require `from` and forbid `to`; `transferred` requires and [D&D module internals](../internal/dnd.md) for implementation details.
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.

View File

@@ -36,6 +36,7 @@ pipelines:
window_units: 3 window_units: 3
lanes: lanes:
- item-events - item-events
- item-registry
- locations - locations
- location-occurrences - location-occurrences
- npc_registry - npc_registry
@@ -44,15 +45,15 @@ pipelines:
- npc-occurrences - npc-occurrences
- enemy-events - enemy-events
steps: steps:
# Establish session-wide reference artifacts alongside independent item events. # Establish session-wide reference artifacts before their consumers.
- id: describe-session - id: describe-session
artifacts: artifacts:
item-events: item-registry:
extract: extract:
module: dnd/item-events module: dnd/item-registry
retries: 2 retries: 2
merge: appendorder merge: appendorder
normalize: dnd/item-events normalize: dnd/item-registry
npc_registry: npc_registry:
extract: extract:
module: dnd/npc-registry module: dnd/npc-registry
@@ -91,7 +92,17 @@ pipelines:
artifact: artifact:
step: describe-session step: describe-session
lane: scene-descriptions lane: scene-descriptions
item_registry:
artifact:
step: describe-session
lane: item-registry
artifacts: artifacts:
item-events:
extract:
module: dnd/item-events
retries: 2
merge: appendorder
normalize: dnd/item-events
spells: spells:
extract: extract:
module: dnd/spells module: dnd/spells

View File

@@ -25,6 +25,7 @@ import (
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" 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" locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry" locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry"
npcoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences" 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" scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" 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" locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" 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":[]}`) content = []byte(`{"duplicate_groups":[]}`)
case spells.PromptID: case spells.PromptID:
content = []byte(`{"spell_casts":[]}`) content = []byte(`{"spell_casts":[]}`)
case itemregistry.PromptID:
content = []byte(`{"items":[]}`)
case itemregistrynormalize.PromptID:
content = []byte(`{"duplicate_groups":[]}`)
case itemevents.PromptID: case itemevents.PromptID:
content = []byte(`{"events":[]}`) content = []byte(`{"occurrences":[]}`)
case combat.PromptID: case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
case npcoccurrences.PromptID: case npcoccurrences.PromptID:

View File

@@ -58,7 +58,7 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err) t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
} }
if example.name == "complete" { 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) t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
} }
locationLane := referenceContractLane(t, materialized, "locations") locationLane := referenceContractLane(t, materialized, "locations")

View File

@@ -34,10 +34,12 @@ import (
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" 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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" 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" 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/notarius/internal/modules/generic/normalize/noop"
"gitea.maximumdirect.net/eric/promptkit" "gitea.maximumdirect.net/eric/promptkit"
@@ -76,12 +78,12 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
"generic/valid_json", "generic/valid_json",
"generic/valid_json_schema", "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, "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.ItemEventListKind, 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.ItemEventListKind, 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, "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, "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}) assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
wantChain := []pipeline.ModuleBinding{ wantChain := []pipeline.ModuleBinding{
@@ -122,6 +124,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
itemEventExtractChain := []pipeline.ModuleBinding{ itemEventExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"), pipeline.Binding("extract/dnd/item-events/source_relatedness"),
@@ -129,6 +132,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
itemEventNormalizeChain := []pipeline.ModuleBinding{ itemEventNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("normalize/dnd/item-events/invariants"), pipeline.Binding("normalize/dnd/item-events/invariants"),
pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"), 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 { 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) t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
} }
itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemEventListKind) itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemOccurrenceListKind)
if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID { 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) 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) enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind)
@@ -916,11 +920,23 @@ pipelines:
module: json module: json
options: options:
include_chunk_map: true include_chunk_map: true
artifacts: steps:
spells: - id: identify-items
extract: dnd/spells artifacts:
item-events: item-registry:
extract: dnd/item-events 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) `, 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}]}]}`) content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
} }
case itemeventextract.PromptID: 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: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
} }

View File

@@ -1,17 +1,18 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.item_events", "$id": "notarius.dnd.item_occurrences",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["events"], "required": ["occurrences"],
"properties": { "properties": {
"events": { "occurrences": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["name", "kind", "source_refs"], "required": ["item_id", "name", "kind", "source_refs"],
"properties": { "properties": {
"item_id": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1}, "name": {"type": "string", "minLength": 1},
"kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]}, "kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]},
"quantity": {"type": "integer", "minimum": 1}, "quantity": {"type": "integer", "minimum": 1},

View File

@@ -14,25 +14,25 @@ import (
) )
const ( const (
SchemaID = "notarius.dnd.item_events" SchemaID = "notarius.dnd.item_occurrences"
SchemaName = "notarius_dnd_item_events_v1" SchemaName = "notarius_dnd_item_occurrences_v1"
SchemaVersion = "v1" SchemaVersion = "v1"
MediaType = "application/json" 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 schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.ItemEventList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
func New() *Codec { return &Codec{} } 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 { 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 { if err != nil {
return contracts.ArtifactSchema{} return contracts.ArtifactSchema{}
} }
@@ -46,50 +46,53 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
func (c *Codec) MediaType() string { return MediaType } func (c *Codec) MediaType() string { return MediaType }
func (c *Codec) Metadata(value dnd.ItemEventList) map[string]any { func (c *Codec) Metadata(value dnd.ItemOccurrenceList) map[string]any {
return map[string]any{"event_count": len(value.Events)} 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 { 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) return c.EncodeCandidate(value)
} }
// EncodeCandidate provides the durable representation before semantic // EncodeCandidate provides the durable representation before semantic
// validators have approved a value. // validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.ItemEventList) ([]byte, error) { func (c *Codec) EncodeCandidate(value dnd.ItemOccurrenceList) ([]byte, error) {
return candidatejson.EncodeCandidate("dnd item event list", cloneList(value)) 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) value, err := c.DecodeCandidate(content)
if err != nil { if err != nil {
return dnd.ItemEventList{}, err return dnd.ItemOccurrenceList{}, err
} }
if err := validate(value); err != nil { 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 return value, nil
} }
// DecodeCandidate reads one strict durable JSON value before semantic // DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it. // validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemEventList, error) { func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemOccurrenceList, error) {
value, err := candidatejson.DecodeCandidate[dnd.ItemEventList]("dnd item event list", content) value, err := candidatejson.DecodeCandidate[dnd.ItemOccurrenceList]("dnd item occurrence list", content)
if err != nil { if err != nil {
return dnd.ItemEventList{}, err return dnd.ItemOccurrenceList{}, err
} }
return cloneList(value), nil return cloneList(value), nil
} }
func validate(value dnd.ItemEventList) error { func validate(value dnd.ItemOccurrenceList) error {
if value.Events == nil { if value.Occurrences == nil {
return fmt.Errorf("events must be present") return fmt.Errorf("events must be present")
} }
for index, event := range value.Events { for index, event := range value.Occurrences {
prefix := fmt.Sprintf("events[%d]", index) 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) == "" { if strings.TrimSpace(event.Name) == "" {
return fmt.Errorf("%s.name must not be empty", prefix) return fmt.Errorf("%s.name must not be empty", prefix)
} }
@@ -127,19 +130,19 @@ func validate(value dnd.ItemEventList) error {
return nil return nil
} }
func cloneList(value dnd.ItemEventList) dnd.ItemEventList { func cloneList(value dnd.ItemOccurrenceList) dnd.ItemOccurrenceList {
if value.Events == nil { if value.Occurrences == nil {
return dnd.ItemEventList{} return dnd.ItemOccurrenceList{}
} }
cloned := dnd.ItemEventList{Events: make([]dnd.ItemEvent, len(value.Events))} cloned := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, len(value.Occurrences))}
for index, event := range value.Events { for index, event := range value.Occurrences {
cloned.Events[index] = event cloned.Occurrences[index] = event
if event.Quantity != nil { if event.Quantity != nil {
quantity := *event.Quantity quantity := *event.Quantity
cloned.Events[index].Quantity = &quantity cloned.Occurrences[index].Quantity = &quantity
} }
if event.SourceRefs != nil { 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 return cloned

View File

@@ -14,14 +14,13 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func validList() dnd.ItemEventList { func validList() dnd.ItemOccurrenceList {
quantity := 12 quantity := 12
return dnd.ItemEventList{Events: []dnd.ItemEvent{ return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Hidden Cache", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)},
{Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}, {ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)},
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)}, {ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: "party", SourceRefs: refs(3, 3)},
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)}, {ItemID: "item", Name: "Healing Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: "party", SourceRefs: refs(4, 4)},
{Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)}, {ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
}} }}
} }
@@ -42,18 +41,18 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
} }
schema := codec.Schema() 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) t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema)
} }
registry := pipeline.NewArtifactCodecRegistry() registry := pipeline.NewArtifactCodecRegistry()
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
spec, ok := registry.Spec(dnd.ItemEventListKind) spec, ok := registry.Spec(dnd.ItemOccurrenceListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
t.Fatalf("registered spec = %#v, %t", spec, ok) 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") t.Fatal("Encode() error = nil, want exact type rejection")
} else { } else {
var typeErr *pipeline.ArtifactCodecTypeError var typeErr *pipeline.ArtifactCodecTypeError
@@ -65,13 +64,13 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
codec := New() codec := New()
empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}} empty := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}
if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` { if content, err := codec.Encode(empty); err != nil || string(content) != `{"occurrences":[]}` {
t.Fatalf("Encode() = %s, %v", content, err) t.Fatalf("Encode() = %s, %v", content, err)
} }
zero := 0 zero := 0
candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{ candidate := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party", Name: " ", Kind: dnd.ItemOccurrenceKindTransferred, Quantity: &zero, From: "party", To: "Party",
SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}}, SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
}}} }}}
content, err := codec.EncodeCandidate(candidate) content, err := codec.EncodeCandidate(candidate)
@@ -88,15 +87,15 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
} }
func TestCodecRejectsStrictJSONAndApprovedBoundaries(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 { tests := []struct {
name, raw, want string name, raw, want string
}{ }{
{"malformed", `{`, "decode dnd item event list"}, {"malformed", `{`, "decode dnd item occurrence list"},
{"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"}, {"unknown top level", `{"occurrences":[],"unexpected":true}`, "unknown field"},
{"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "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"}, {"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"}, {"missing list", `{}`, "events must be present"},
{"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"}, {"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"}, {"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 { if err != nil {
t.Fatal(err) 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") t.Fatal("DecodeCandidate() retained caller-owned event fields")
} }
*decoded.Events[1].Quantity = 99 *decoded.Occurrences[1].Quantity = 99
decoded.Events[1].SourceRefs[0].SourceID = "changed" decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" { if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
t.Fatal("decoded item event aliases input") t.Fatal("decoded item occurrence aliases input")
} }
first := codec.Schema() first := codec.Schema()
@@ -141,7 +140,7 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
} }
metadata := codec.Metadata(value) metadata := codec.Metadata(value)
metadata["payload"] = bytes.Repeat([]byte("x"), 10) 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) t.Fatalf("Metadata() = %#v", next)
} }
} }

View File

@@ -5,11 +5,12 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type orderedItemEventResponse struct { type orderedItemOccurrenceResponse struct {
value itemEventResponse value itemOccurrenceResponse
earliest int earliest int
hasEvidence bool hasEvidence bool
} }
@@ -18,11 +19,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
if response == nil { if response == nil {
return return
} }
ordered := make([]orderedItemEventResponse, len(response.Events)) ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
for index := range response.Events { for index := range response.Occurrences {
earliest, hasEvidence := canonicalizeItemEvent(&response.Events[index], order, sourceID) earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID)
ordered[index] = orderedItemEventResponse{ ordered[index] = orderedItemOccurrenceResponse{
value: response.Events[index], value: response.Occurrences[index],
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, hasEvidence: hasEvidence,
} }
@@ -37,38 +38,43 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
return ordered[left].earliest < ordered[right].earliest return ordered[left].earliest < ordered[right].earliest
}) })
for index := range ordered { 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 { if event == nil {
return 0, false return 0, false
} }
refs := order.Canonicalize(itemEventSourceRefs(event.SourceRefs, sourceID)) refs := order.Canonicalize(itemOccurrenceSourceRefs(event.SourceRefs, sourceID))
event.SourceRefs = itemEventResponseRefs(refs) event.SourceRefs = itemOccurrenceResponseRefs(refs)
return order.EarliestValid(refs) return order.EarliestValid(refs)
} }
func canonicalItemEventList(response extractionResponse, sourceID string) dnd.ItemEventList { func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) dnd.ItemOccurrenceList {
if response.Events == nil { if response.Occurrences == nil {
return dnd.ItemEventList{} return dnd.ItemOccurrenceList{}
} }
events := make([]dnd.ItemEvent, len(response.Events)) occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
for index, event := range response.Events { for _, event := range response.Occurrences {
events[index] = dnd.ItemEvent{ item, found := registry.LookupID(event.ItemID)
if !found || item.Name != event.Name {
continue
}
occurrences = append(occurrences, dnd.ItemOccurrence{
ItemID: event.ItemID,
Name: event.Name, Name: event.Name,
Kind: dnd.ItemEventKind(event.Kind), Kind: dnd.ItemOccurrenceKind(event.Kind),
Quantity: cloneQuantity(event.Quantity), Quantity: cloneQuantity(event.Quantity),
From: event.From, From: event.From,
To: event.To, 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 { if refs == nil {
return nil return nil
} }
@@ -79,13 +85,13 @@ func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []s
return values return values
} }
func itemEventResponseRefs(refs []source.SourceRef) []itemEventSourceRefResponse { func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
if refs == nil { if refs == nil {
return nil return nil
} }
values := make([]itemEventSourceRefResponse, len(refs)) values := make([]itemOccurrenceSourceRefResponse, len(refs))
for index, ref := range 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 return values
} }

View File

@@ -1,18 +1,25 @@
// Package itemevents extracts source-grounded D&D item events. // Package itemevents extracts source-grounded D&D item occurrences.
package itemevents package itemevents
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const Key = "dnd/item-events" const Key = "dnd/item-events"
const (
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
ItemRegistryMaxBytes = itemregistry.MaxBytes
)
const mappingPolicy = "dnd.item_events.extract_mapping.v1" const mappingPolicy = "dnd.item_events.extract_mapping.v1"
var requiredCapabilities = []string{ var requiredCapabilities = []string{
@@ -32,10 +39,20 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
} }
func referenceSlots() []contracts.ReferenceSlot { 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 _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
@@ -43,6 +60,7 @@ type Options struct{}
type Extractor struct { type Extractor struct {
llm contracts.StructuredLLMClient llm contracts.StructuredLLMClient
itemResolver *itemregistry.Resolver
promptSHA string promptSHA string
responseSchemaSHA string responseSchemaSHA string
} }
@@ -54,6 +72,14 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if len(references) > 1 { if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied") 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() promptSHA, err := promptAssetMetadata()
if err != nil { if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err) return nil, extractorErrorf("load prompt metadata: %w", err)
@@ -62,7 +88,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if err != nil { if err != nil {
return nil, extractorErrorf("load response schema: %w", err) 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 } func (e *Extractor) Key() string { return Key }
@@ -73,7 +99,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil { if e == nil {
return nil return nil
} }
return map[string]any{ metadata := map[string]any{
"prompt_id": PromptID, "prompt_id": PromptID,
"prompt_version": SchemaVersion, "prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA, "prompt_sha256": e.promptSHA,
@@ -84,6 +110,12 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"response_schema_sha256": e.responseSchemaSHA, "response_schema_sha256": e.responseSchemaSHA,
"mapping_policy": mappingPolicy, "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 { func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
@@ -94,35 +126,41 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA}, {Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy}, {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 { 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 { 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) sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("%w", err) return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("%w", err)
} }
order := shared.NewSourceRefOrder(req.Source) 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 var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[ItemRegistryReferenceSlot] = registry.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
PromptID: PromptID, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: shared.PromptInputs(sourceInput, req.References),
}, &response); err != nil { }, &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) 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 { func ModuleSpec() pipeline.ModuleSpec {
@@ -132,13 +170,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassLLMBacked, ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.ItemEventListKind, ArtifactKind: dnd.ItemOccurrenceListKind,
ReferenceSlots: referenceSlots(), ReferenceSlots: referenceSlots(),
} }
} }
func Register(registry *pipeline.ExtractorRegistry) error { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -160,5 +198,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
} }
func extractorErrorf(format string, args ...any) 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...)
} }

View File

@@ -2,161 +2,61 @@ package itemevents
import ( import (
"context" "context"
"errors"
"reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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) { func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
quantity := 20 id := itemidentity.DeriveID("Torch")
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{ client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(5, 5)}, {ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
{Name: "Moonblade", Kind: "transferred", From: "Aria", To: "Borin", SourceRefs: responseRefs(3, 3)}, {ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
{Name: "Hidden Cache", Kind: "discovered", SourceRefs: []itemEventSourceRefResponse{{StartSegment: 1, EndSegment: 1}, {StartSegment: 1, EndSegment: 1}}}, {ItemID: "torch", Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(3, 3)},
{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)},
}}} }}}
req := extractionRequest() req := extractionRequest()
req.Source.Units = []source.SourceUnit{{ID: 100}, {ID: 10}} req.References = itemRegistryReferences(t)
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 10}
result, err := newExtractor(t, client).Extract(context.Background(), req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := []string{result.Value.Events[0].Name, result.Value.Events[1].Name}; !reflect.DeepEqual(got, []string{"Earlier", "Later"}) { if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" {
t.Fatalf("event order = %#v, want source document order", got) 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) { func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) {
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}} client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}}
req := extractionRequest() req := extractionRequest()
req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ req.References = itemRegistryReferences(t)
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Aria")}}}, req.References.Slots["unrelated"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "unrelated"}, Items: []contracts.ReferenceItem{{SlotName: "unrelated", Content: []byte("ignored")}}}
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: ranger")}}}, if _, err := newExtractor(t, client, req.References).Extract(context.Background(), req); err != nil {
"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 {
t.Fatal(err) t.Fatal(err)
} }
inputs := client.requests[0].Inputs if _, ok := client.requests[0].Inputs["unrelated"]; ok {
if string(inputs["players"].Content) != "Dana: Aria" || string(inputs["party"].Content) != "Aria: ranger" || string(inputs["glossary"].Content) != "Moonblade: heirloom" { t.Fatalf("unexpected prompt input: %#v", client.requests[0].Inputs)
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)
} }
} }

View File

@@ -1,19 +1,20 @@
package itemevents package itemevents
type extractionResponse struct { type extractionResponse struct {
Events []itemEventResponse `json:"events"` Occurrences []itemOccurrenceResponse `json:"occurrences"`
} }
type itemEventResponse struct { type itemOccurrenceResponse struct {
Name string `json:"name"` ItemID string `json:"item_id"`
Kind string `json:"kind"` Name string `json:"name"`
Quantity *int `json:"quantity,omitempty"` Kind string `json:"kind"`
From string `json:"from,omitempty"` Quantity *int `json:"quantity,omitempty"`
To string `json:"to,omitempty"` From string `json:"from,omitempty"`
SourceRefs []itemEventSourceRefResponse `json:"source_refs"` To string `json:"to,omitempty"`
SourceRefs []itemOccurrenceSourceRefResponse `json:"source_refs"`
} }
type itemEventSourceRefResponse struct { type itemOccurrenceSourceRefResponse struct {
StartSegment int `json:"start_segment"` StartSegment int `json:"start_segment"`
EndSegment int `json:"end_segment"` EndSegment int `json:"end_segment"`
} }

View File

@@ -18,6 +18,7 @@ var promptAssetManifest = shared.PromptAssetManifest{
ModuleFiles: []promptfs.ModulePromptFile{ ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, {Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"}, {Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "item-registry.md", Path: "prompts/item-registry.md"},
}, },
SharedFiles: []string{ SharedFiles: []string{
"common-dnd-system.md", "common-dnd-system.md",
@@ -43,7 +44,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
} }
promptFS, err := promptAssetManifest.PromptFS(assets) promptFS, err := promptAssetManifest.PromptFS(assets)
if err != nil { 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 { if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
return err return err

View File

@@ -11,7 +11,7 @@ import (
"gitea.maximumdirect.net/eric/promptkit" "gitea.maximumdirect.net/eric/promptkit"
) )
func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) { func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -30,16 +30,17 @@ func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile", PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile",
Inputs: map[string]promptkit.ArtifactRef{ Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`), "transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`),
"players": promptkit.Inline("item-event-player"), "players": promptkit.Inline("item-event-player"),
"party": promptkit.Inline(" "), "party": promptkit.Inline(" "),
"glossary": promptkit.Inline(" "), "glossary": promptkit.Inline(" "),
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`),
}, },
}) })
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("prepared prompt = %#v", prepared)
} }
} }
@@ -49,12 +50,12 @@ func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) {
if err != nil || !strings.HasPrefix(hash, "sha256:") { if err != nil || !strings.HasPrefix(hash, "sha256:") {
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err) t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
} }
metadata := newExtractor(t, &fakeItemEventsLLMClient{}).ManifestMetadata() metadata := newExtractor(t, &fakeItemOccurrencesLLMClient{}).ManifestMetadata()
payload, err := json.Marshal(metadata) payload, err := json.Marshal(metadata)
if err != nil { if err != nil {
t.Fatal(err) 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) { if strings.Contains(string(payload), forbidden) {
t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload) t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload)
} }

View File

@@ -2,64 +2,42 @@ package itemevents
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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) { func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") { t.Helper()
t.Fatalf("New(nil) error = %v", err) 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") { 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}}}}}
t.Fatalf("New() error = %v", err) }
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{ if !slot.Required || !reflect.DeepEqual(slot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.ItemRegistryKind}) || slot.MaxBytes != ItemRegistryMaxBytes {
Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_events"}, ArtifactKind: dnd.ItemEventListKind, t.Fatalf("registry slot = %#v", slot)
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)
} }
registry := pipeline.NewExtractorRegistry() registry := pipeline.NewExtractorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatal(err) 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)
}
} }

View File

@@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const ( const (
PromptID = "dnd.item_events" PromptID = "dnd.item_events"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_events_llm") ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_occurrences_llm")
ResponseSchemaID = "notarius.dnd.item_events.llm" ResponseSchemaID = "notarius.dnd.item_occurrences.llm"
ResponseSchemaName = "notarius_dnd_item_events_llm_v1" ResponseSchemaName = "notarius_dnd_item_occurrences_llm_v1"
SchemaVersion = "v1" SchemaVersion = "v1"
) )
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
ID: ResponseSchemaID, ID: ResponseSchemaID,
Version: SchemaVersion, Version: SchemaVersion,
Name: ResponseSchemaName, Name: ResponseSchemaName,
AssetPath: "schemas/dnd_item_events_llm.v1.json", AssetPath: "schemas/dnd_item_occurrences_llm.v1.json",
}) })
} }

View File

@@ -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) { 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) t.Fatalf("schema = %#v", schema)
} }
valid := map[string]any{"events": []any{ valid := map[string]any{"occurrences": []any{
map[string]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}}, "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
}, },
map[string]any{ 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}}, "source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
}, },
}} }}
@@ -38,12 +38,12 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
name string name string
value map[string]any value map[string]any
}{ }{
{"missing events", map[string]any{}}, {"missing occurrences", map[string]any{}},
{"missing event name", map[string]any{"events": []any{withoutField(responseEvent(), "name")}}}, {"missing event name", map[string]any{"occurrences": []any{withoutField(responseEvent(), "name")}}},
{"missing nullable field", map[string]any{"events": []any{withoutField(responseEvent(), "quantity")}}}, {"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseEvent(), "quantity")}}},
{"unknown event field", map[string]any{"events": []any{withField(responseEvent(), "extra", true)}}}, {"unknown event field", map[string]any{"occurrences": []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}})}}}, {"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{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, {"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) { t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value) content, err := json.Marshal(test.value)
@@ -68,7 +68,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
func responseEvent() map[string]any { func responseEvent() map[string]any {
return 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{},
} }
} }

View File

@@ -43,8 +43,8 @@ func sourceDocument() *source.SourceDocument {
} }
} }
func responseRefs(start, end int) []itemEventSourceRefResponse { func responseRefs(start, end int) []itemOccurrenceSourceRefResponse {
return []itemEventSourceRefResponse{{StartSegment: start, EndSegment: end}} return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}}
} }
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor { func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
@@ -61,14 +61,14 @@ func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest)
return req return req
} }
type fakeItemEventsLLMClient struct { type fakeItemOccurrencesLLMClient struct {
response extractionResponse response extractionResponse
content []byte content []byte
err error err error
requests []contracts.StructuredCompletionRequest 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)) client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
if client.err != nil { if client.err != nil {
return contracts.StructuredCompletionResponse{}, client.err return contracts.StructuredCompletionResponse{}, client.err

View File

@@ -14,14 +14,14 @@ import (
const partyHolder = "party" const partyHolder = "party"
// SupportedKind reports whether kind is one of the durable item-event kinds. // SupportedKind reports whether kind is one of the durable item occurrence kinds.
func SupportedKind(kind dnd.ItemEventKind) bool { func SupportedKind(kind dnd.ItemOccurrenceKind) bool {
switch kind { switch kind {
case dnd.ItemEventKindDiscovered, case dnd.ItemOccurrenceKindDiscovered,
dnd.ItemEventKindAcquired, dnd.ItemOccurrenceKindAcquired,
dnd.ItemEventKindLost, dnd.ItemOccurrenceKindLost,
dnd.ItemEventKindConsumed, dnd.ItemOccurrenceKindConsumed,
dnd.ItemEventKindTransferred: dnd.ItemOccurrenceKindTransferred:
return true return true
default: default:
return false return false
@@ -45,18 +45,18 @@ func IsPartyHolder(value string) bool { return ComparisonKey(value) == partyHold
// ValidHolderCombination reports whether the optional holder fields satisfy // ValidHolderCombination reports whether the optional holder fields satisfy
// the durable rules for kind. Blank holders are treated as absent so callers // the durable rules for kind. Blank holders are treated as absent so callers
// can preserve invalid extraction candidates for their owning validators. // 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) hasFrom := HolderPresent(from)
hasTo := HolderPresent(to) hasTo := HolderPresent(to)
switch kind { switch kind {
case dnd.ItemEventKindDiscovered: case dnd.ItemOccurrenceKindDiscovered:
return !hasFrom && !hasTo return !hasFrom && !hasTo
case dnd.ItemEventKindAcquired: case dnd.ItemOccurrenceKindAcquired:
return !hasFrom && hasTo return !hasFrom && hasTo
case dnd.ItemEventKindLost, dnd.ItemEventKindConsumed: case dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed:
return hasFrom && !hasTo return hasFrom && !hasTo
case dnd.ItemEventKindTransferred: case dnd.ItemOccurrenceKindTransferred:
return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to) return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to)
default: default:
return false 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 // Less defines the canonical event order. Invalid source references remain
// comparable through SourceRefOrder's literal fallback so malformed candidates // comparable through SourceRefOrder's literal fallback so malformed candidates
// are still safe to sort and diagnose. // 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) leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence { if leftHasEvidence != rightHasEvidence {
@@ -102,6 +102,9 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
if leftHasEvidence && leftPosition != rightPosition { if leftHasEvidence && leftPosition != rightPosition {
return 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 { if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
return 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 // ExactEqual reports whether events are exact duplicates after their display
// fields and evidence have been canonicalized for the supplied source order. // fields and evidence have been canonicalized for the supplied source order.
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool { func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
if DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind || 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) || DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
(left.Quantity == nil) != (right.Quantity == nil) { (left.Quantity == nil) != (right.Quantity == nil) {
return false 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 // ExactIdentity returns a collision-safe duplicate key after display and
// evidence canonicalization. It is intended for callers that have already // evidence canonicalization. It is intended for callers that have already
// decided the event is eligible for duplicate handling. // 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 var key strings.Builder
writeKeyString(&key, event.ItemID)
writeKeyString(&key, DisplayValue(event.Name)) writeKeyString(&key, DisplayValue(event.Name))
writeKeyString(&key, string(event.Kind)) writeKeyString(&key, string(event.Kind))
writeKeyString(&key, DisplayValue(event.From)) writeKeyString(&key, DisplayValue(event.From))

View File

@@ -11,14 +11,14 @@ import (
func TestValidHolderCombination(t *testing.T) { func TestValidHolderCombination(t *testing.T) {
valid := []struct { valid := []struct {
kind dnd.ItemEventKind kind dnd.ItemOccurrenceKind
from, to string from, to string
}{ }{
{dnd.ItemEventKindDiscovered, "", ""}, {dnd.ItemOccurrenceKindDiscovered, "", ""},
{dnd.ItemEventKindAcquired, "", "party"}, {dnd.ItemOccurrenceKindAcquired, "", "party"},
{dnd.ItemEventKindLost, "party", ""}, {dnd.ItemOccurrenceKindLost, "party", ""},
{dnd.ItemEventKindConsumed, "party", ""}, {dnd.ItemOccurrenceKindConsumed, "party", ""},
{dnd.ItemEventKindTransferred, "Aria", "Borin"}, {dnd.ItemOccurrenceKindTransferred, "Aria", "Borin"},
} }
for _, test := range valid { for _, test := range valid {
if !ValidHolderCombination(test.kind, test.from, test.to) { if !ValidHolderCombination(test.kind, test.from, test.to) {
@@ -27,17 +27,17 @@ func TestValidHolderCombination(t *testing.T) {
} }
invalid := []struct { invalid := []struct {
kind dnd.ItemEventKind kind dnd.ItemOccurrenceKind
from, to string from, to string
}{ }{
{dnd.ItemEventKindDiscovered, "Aria", ""}, {dnd.ItemOccurrenceKindDiscovered, "Aria", ""},
{dnd.ItemEventKindAcquired, "", ""}, {dnd.ItemOccurrenceKindAcquired, "", ""},
{dnd.ItemEventKindLost, "", ""}, {dnd.ItemOccurrenceKindLost, "", ""},
{dnd.ItemEventKindConsumed, "", "Borin"}, {dnd.ItemOccurrenceKindConsumed, "", "Borin"},
{dnd.ItemEventKindTransferred, "party", "Borin"}, {dnd.ItemOccurrenceKindTransferred, "party", "Borin"},
{dnd.ItemEventKindTransferred, "Aria", "Party"}, {dnd.ItemOccurrenceKindTransferred, "Aria", "Party"},
{dnd.ItemEventKindTransferred, "Aria", "aria"}, {dnd.ItemOccurrenceKindTransferred, "Aria", "aria"},
{dnd.ItemEventKindTransferred, "Åria", "Åria"}, {dnd.ItemOccurrenceKindTransferred, "Åria", "Åria"},
{"unsupported", "", ""}, {"unsupported", "", ""},
} }
for _, test := range invalid { for _, test := range invalid {
@@ -53,16 +53,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
} }
quantity := func(value int) *int { return &value } 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 { tests := []struct {
name string name string
left, right dnd.ItemEvent left, right dnd.ItemOccurrence
}{ }{
{"earlier evidence", withRefs(base, ref(10, 10)), base}, {"earlier evidence", withRefs(base, ref(10, 10)), base},
{"valid evidence before malformed", base, withRefs(base, ref(999, 999))}, {"valid evidence before malformed", base, withRefs(base, ref(999, 999))},
{"normalized name", withName(base, "Amulet"), withName(base, "Blade")}, {"normalized name", withName(base, "Amulet"), withName(base, "Blade")},
{"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")}, {"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 presence", withFrom(base, ""), withFrom(base, "Aria")},
{"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")}, {"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")},
{"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")}, {"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")},
@@ -84,16 +84,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) { func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) {
order := testOrder() order := testOrder()
first := dnd.ItemEvent{ first := dnd.ItemOccurrence{ItemID: "item",
Name: " Silver Coin ", Kind: dnd.ItemEventKindAcquired, To: " party ", Name: " Silver Coin ", Kind: dnd.ItemOccurrenceKindAcquired, To: " party ",
SourceRefs: []source.SourceRef{ SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 30, EndUnitID: 30}, {SourceID: "session", StartUnitID: 30, EndUnitID: 30},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10},
}, },
} }
second := dnd.ItemEvent{ second := dnd.ItemOccurrence{ItemID: "item",
Name: "Silver Coin", Kind: dnd.ItemEventKindAcquired, To: "party", Name: "Silver Coin", Kind: dnd.ItemOccurrenceKindAcquired, To: "party",
SourceRefs: []source.SourceRef{ SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 10}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10},
{SourceID: "session", StartUnitID: 30, EndUnitID: 30}, {SourceID: "session", StartUnitID: 30, EndUnitID: 30},
@@ -136,10 +136,9 @@ func TestSourceReferenceHelpers(t *testing.T) {
func TestLessSortsMalformedReferencesDeterministically(t *testing.T) { func TestLessSortsMalformedReferencesDeterministically(t *testing.T) {
order := testOrder() order := testOrder()
events := []dnd.ItemEvent{ events := []dnd.ItemOccurrence{{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}},
{Name: "A", Kind: dnd.ItemEventKindDiscovered, 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}}},
{Name: "A", Kind: dnd.ItemEventKindDiscovered, 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}}},
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
} }
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) }) 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 { 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}}}) 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 withName(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
func withKind(event dnd.ItemEvent, value dnd.ItemEventKind) dnd.ItemEvent { event.Name = value
return event
}
func withKind(event dnd.ItemOccurrence, value dnd.ItemOccurrenceKind) dnd.ItemOccurrence {
event.Kind = value event.Kind = value
return event return event
} }
func withFrom(event dnd.ItemEvent, value string) dnd.ItemEvent { event.From = value; return event } func withFrom(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
func withTo(event dnd.ItemEvent, value string) dnd.ItemEvent { event.To = value; return event } event.From = value
func withoutTo(event dnd.ItemEvent) dnd.ItemEvent { event.To = ""; return event } return event
func withQuantity(event dnd.ItemEvent, value *int) dnd.ItemEvent { }
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 event.Quantity = value
return event 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 event.SourceRefs = value
return event return event
} }

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents" 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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
@@ -20,85 +21,126 @@ const (
normalizationPolicy = "dnd.item_events.normalize.v1" normalizationPolicy = "dnd.item_events.normalize.v1"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeDisplayNormalized = "item_event_display_normalized" ReasonCodeNameCanonicalized = "item_occurrence_name_canonicalized"
ReasonCodeUnknownItemID = "item_occurrence_unknown_item_id"
ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeEventsReordered = "item_events_reordered" ReasonCodeOccurrencesReordered = "item_occurrences_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_item_event_collapsed" ReasonCodeDuplicateCollapsed = "duplicate_item_occurrence_collapsed"
ReasonCodeWarningsOmitted = "item_event_normalization_warnings_omitted" ReasonCodeWarningsOmitted = "item_occurrence_normalization_warnings_omitted"
)
const (
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
ItemRegistryMaxBytes = itemregistry.MaxBytes
) )
var requiredCapabilities = []string{"merged"} var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"} var providedCapabilities = []string{"normalized"}
var _ contracts.Normalizer[dnd.ItemEventList] = (*Normalizer)(nil) var _ contracts.Normalizer[dnd.ItemOccurrenceList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*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 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) 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 { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil || n.itemResolver == nil {
return 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 { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil { if n == nil || n.itemResolver == nil {
return 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) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]) (contracts.TypedNormalizeResult[dnd.ItemOccurrenceList], error) {
if n == nil { if n == nil || n.itemResolver == nil {
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("normalizer must not be nil")
} }
if ctx == 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 { 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) index := source.NewDocumentIndex(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index)) value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
return contracts.TypedNormalizeResult[dnd.ItemEventList]{Value: value, Warnings: warnings}, nil return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{Value: value, Warnings: warnings}, nil
} }
type normalizedRecord struct { type normalizedRecord struct {
event dnd.ItemEvent event dnd.ItemOccurrence
inputIndex int inputIndex int
} }
func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order shared.SourceRefOrder) (dnd.ItemEventList, []contracts.Warning) { func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []contracts.Warning) {
if input.Events == nil { if input.Occurrences == nil {
return dnd.ItemEventList{}, nil return dnd.ItemOccurrenceList{}, nil
} }
records := make([]normalizedRecord, len(input.Events)) records := make([]normalizedRecord, len(input.Occurrences))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputEvent := range input.Events { for index, inputEvent := range input.Occurrences {
event, changedFields, refsChanged := normalizeEvent(inputEvent, order) event, changedFields, found, refsChanged := normalizeEvent(inputEvent, order, registry)
records[index] = normalizedRecord{event: event, inputIndex: index} records[index] = normalizedRecord{event: event, inputIndex: index}
if len(changedFields) != 0 { if len(changedFields) != 0 {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: eventScope(index), Scope: eventScope(index),
ReasonCode: ReasonCodeDisplayNormalized, ReasonCode: ReasonCodeNameCanonicalized,
Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index, Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index,
diagnostics.Aggregate("fields", changedFields)), 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 { if refsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: eventScope(index), Scope: eventScope(index),
@@ -118,24 +160,27 @@ func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order sh
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: eventScope(record.inputIndex), Scope: eventScope(record.inputIndex),
ReasonCode: ReasonCodeEventsReordered, ReasonCode: ReasonCodeOccurrencesReordered,
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
}) })
} }
output, duplicateWarnings := collapseDuplicates(records, index, order) output, duplicateWarnings := collapseDuplicates(records, index, order)
warnings = append(warnings, duplicateWarnings...) 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) 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 { for _, field := range []struct {
name string name string
value *string value *string
}{ }{
{name: "name", value: &output.Name},
{name: "from", value: &output.From}, {name: "from", value: &output.From},
{name: "to", value: &output.To}, {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) 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 output := input
if input.Quantity != nil { if input.Quantity != nil {
quantity := *input.Quantity quantity := *input.Quantity
@@ -166,9 +211,9 @@ type duplicateGroup struct {
removed []int 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 { if len(records) == 0 {
return make([]dnd.ItemEvent, 0), nil return make([]dnd.ItemOccurrence, 0), nil
} }
keep := make([]bool, len(records)) 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) 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 { for recordIndex, record := range records {
if keep[recordIndex] { if keep[recordIndex] {
output = append(output, cloneEvent(record.event)) output = append(output, cloneEvent(record.event))
@@ -214,11 +259,11 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
Scope: eventScope(retainedIndex), Scope: eventScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed, ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Aggregate( 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 { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{
@@ -227,20 +272,32 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassDeterministic, ExecutionClass: contracts.ExecutionClassDeterministic,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.ItemEventListKind, ArtifactKind: dnd.ItemOccurrenceListKind,
ReferenceSlots: referenceSlots(),
} }
} }
func Register(registry *pipeline.NormalizerRegistry) error { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err 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) { func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil { if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, normalizerErrorf("%w", err) 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 validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func normalizerErrorf(format string, args ...any) error { 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...)
} }

View File

@@ -2,186 +2,65 @@ package itemevents
import ( import (
"context" "context"
"reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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) { func registryReferences(t *testing.T) contracts.ReferenceSet {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} t.Helper()
quantity := 4 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}}}}})
input := dnd.ItemEventList{Events: []dnd.ItemEvent{{ if err != nil {
Name: " Gold Pieces ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Borin ", To: " Aria ", t.Fatal(err)
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)
} }
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) { func TestNormalizeCanonicalizesRegistryNameAndRetainsUnknownValues(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
ref := func(unit int) source.SourceRef { refs := registryReferences(t)
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} normalizer, err := New(Options{}, refs)
if err != nil {
t.Fatal(err)
} }
quantity := 2 id := itemidentity.DeriveID("Torch")
duplicate := dnd.ItemEvent{Name: " Potion ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " Aria ", SourceRefs: []source.SourceRef{ref(30)}} input := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{
input := dnd.ItemEventList{Events: []dnd.ItemEvent{ {ItemID: id, Name: "torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
{Name: "Later", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}}, {ItemID: "unknown", Name: "Unknown", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
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}}},
}} }}
result := normalize(t, doc, input) result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{Source: doc, References: refs, MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: input}})
got := result.Value.Events if err != nil {
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.Fatal(err)
t.Fatalf("normalized events = %#v", got)
} }
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) t.Fatalf("warnings = %#v", result.Warnings)
} }
} }
func TestNormalizeUsesEveryCanonicalTieBreaker(t *testing.T) { func TestNormalizeRequiresRegistryAndRegistersSlot(t *testing.T) {
doc := testDocument() normalizer, err := New(Options{})
ref := func(unit int) source.SourceRef { if err != nil {
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} t.Fatal(err)
} }
quantity := 1 _, err = normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}}})
tests := []struct { if err == nil {
name string t.Fatal("Normalize() error = nil")
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)}}},
} }
for _, test := range tests { if len(ModuleSpec().ReferenceSlots) != 1 || !ModuleSpec().ReferenceSlots[0].Required {
t.Run(test.name, func(t *testing.T) { t.Fatalf("ModuleSpec() = %#v", ModuleSpec())
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)
} }
registry := pipeline.NewNormalizerRegistry() registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil { 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 { func hasWarning(warnings []contracts.Warning, reason string) bool {

View File

@@ -32,6 +32,7 @@ import (
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" 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" 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" 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" 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" 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" 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{ return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract, Stage: pipeline.StageExtract,
Module: itemeventextract.Key, Module: itemeventextract.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(itemeventshape.Key), pipeline.Binding(itemeventshape.Key),
pipeline.Binding(itemeventregistry.Key),
pipeline.Binding(itemeventrefs.Key), pipeline.Binding(itemeventrefs.Key),
pipeline.Binding(validjsonschema.Key), pipeline.Binding(validjsonschema.Key),
pipeline.Binding(itemeventrelatedness.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{ return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize, Stage: pipeline.StageNormalize,
Module: itemeventnormalize.Key, Module: itemeventnormalize.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(itemeventshape.Key), pipeline.Binding(itemeventshape.Key),
pipeline.Binding(itemeventregistry.Key),
pipeline.Binding(itemeventinvariants.Key), pipeline.Binding(itemeventinvariants.Key),
pipeline.Binding(itemeventrefs.Key), pipeline.Binding(itemeventrefs.Key),
pipeline.Binding(validjsonschema.Key), pipeline.Binding(validjsonschema.Key),

View File

@@ -16,8 +16,8 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
{name: "enemy events evidence", register: func() error { {name: "enemy events evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
}}, }},
{name: "item events evidence", register: func() error { {name: "item occurrences evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.ItemOccurrenceListKind, itemOccurrenceEvidence)
}}, }},
{name: "item registry evidence", register: func() error { {name: "item registry evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence)
@@ -69,9 +69,9 @@ func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef {
return append([]source.SourceRef(nil), refs...) return append([]source.SourceRef(nil), refs...)
} }
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef { func itemOccurrenceEvidence(value dnd.ItemOccurrenceList) []source.SourceRef {
var refs []source.SourceRef var refs []source.SourceRef
for _, record := range value.Events { for _, record := range value.Occurrences {
refs = append(refs, record.SourceRefs...) refs = append(refs, record.SourceRefs...)
} }
return append([]source.SourceRef(nil), refs...) return append([]source.SourceRef(nil), refs...)

View File

@@ -91,22 +91,22 @@ func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, err
return combined, nil return combined, nil
} }
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) { func appendItemOccurrenceLists(values []dnd.ItemOccurrenceList) (dnd.ItemOccurrenceList, error) {
count := 0 count := 0
present := false present := false
for _, value := range values { for _, value := range values {
if value.Events != nil { if value.Occurrences != nil {
present = true present = true
} }
count += len(value.Events) count += len(value.Occurrences)
} }
if !present { 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 _, value := range values {
for _, event := range value.Events { for _, occurrence := range value.Occurrences {
combined.Events = append(combined.Events, cloneItemEvent(event)) combined.Occurrences = append(combined.Occurrences, cloneItemOccurrence(occurrence))
} }
} }
return combined, nil return combined, nil
@@ -227,7 +227,7 @@ func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent {
return clone return clone
} }
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent { func cloneItemOccurrence(value dnd.ItemOccurrence) dnd.ItemOccurrence {
clone := value clone := value
if value.Quantity != nil { if value.Quantity != nil {
quantity := *value.Quantity quantity := *value.Quantity

View File

@@ -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: "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: "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: "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 { {name: "item registry codec", register: func() error {
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemregistrycodec.New()) 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: "npc registry extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
{name: "combat turns extractor", register: func() error { return combatextract.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: "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: "item registry extractor", register: func() error { return itemregistryextract.Register(registries.Extractors) }},
{name: "npc occurrences extractor", register: func() error { return occurrenceextract.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) }}, {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 { {name: "enemy-event-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists) return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
}}, }},
{name: "item-event-list appendorder merger", register: func() error { {name: "item-occurrence-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists) return appendorder.RegisterTyped(registries.Mergers, dnd.ItemOccurrenceListKind, appendItemOccurrenceLists)
}}, }},
{name: "item-registry appendorder merger", register: func() error { {name: "item-registry appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemRegistryKind, appendItemRegistries) 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: "npc registry normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
{name: "combat turns normalizer", register: func() error { return combatnormalize.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: "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: "item registry normalizer", register: func() error { return itemregistrynormalize.Register(registries.Normalizers) }},
{name: "npc occurrences normalizer", register: func() error { return occurrencenormalize.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) }}, {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 { {name: "enemy-event-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind) return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
}}, }},
{name: "item-event-list noop normalizer", register: func() error { {name: "item-occurrence-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind) return noop.RegisterTyped[dnd.ItemOccurrenceList](registries.Normalizers, dnd.ItemOccurrenceListKind)
}}, }},
{name: "item-registry noop normalizer", register: func() error { {name: "item-registry noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.ItemRegistry](registries.Normalizers, dnd.ItemRegistryKind) 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: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.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: "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 prompt assets", register: func() error { return itemregistryextract.RegisterPromptAssets(assets) }},
{name: "item registry normalization prompt assets", register: func() error { return itemregistrynormalize.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) }}, {name: "npc occurrences prompt assets", register: func() error { return occurrenceextract.RegisterPromptAssets(assets) }},

View File

@@ -51,7 +51,9 @@ func TestExtractionPromptComposition(t *testing.T) {
}{ }{
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}}, {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: "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: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
{name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs}, {name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs},
{ {

View File

@@ -88,14 +88,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) 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, "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}) 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.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.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.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.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.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.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.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(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) 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(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(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) 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) { 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) 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("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"), pipeline.Binding("extract/dnd/item-events/source_relatedness"),
} }
itemEventNormalizeChain := []pipeline.ModuleBinding{ itemOccurrenceNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("normalize/dnd/item-events/invariants"), pipeline.Binding("normalize/dnd/item-events/invariants"),
pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"), pipeline.Binding("extract/dnd/item-events/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemOccurrenceExtractChain) {
t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain) 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) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemOccurrenceNormalizeChain) {
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) t.Fatalf("item occurrence normalize validator chain = %#v, want %#v", got, itemOccurrenceNormalizeChain)
} }
itemRegistryExtractChain := []pipeline.ModuleBinding{ itemRegistryExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
@@ -328,7 +330,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd_npc_registry_llm.v1.json", "dnd_npc_registry_llm.v1.json",
"dnd_combat_turns_llm.v1.json", "dnd_combat_turns_llm.v1.json",
"dnd_enemy_events_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_npc_occurrences_llm.v1.json",
"dnd_scene_descriptions_llm.v1.json", "dnd_scene_descriptions_llm.v1.json",
"dnd_location_registry_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) { 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) t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
} }
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key) itemOccurrenceExtractSpec, itemOccurrenceExtractOK := registries.Extractors.Spec(itemeventextract.Key)
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key) itemOccurrenceNormalizeSpec, itemOccurrenceNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 { if !itemOccurrenceExtractOK || itemOccurrenceExtractSpec.ArtifactKind != dnd.ItemOccurrenceListKind || !itemOccurrenceNormalizeOK || itemOccurrenceNormalizeSpec.ArtifactKind != dnd.ItemOccurrenceListKind || itemOccurrenceNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK) t.Fatalf("item occurrence specs = %#v / %#v, present = %t / %t", itemOccurrenceExtractSpec, itemOccurrenceNormalizeSpec, itemOccurrenceExtractOK, itemOccurrenceNormalizeOK)
} }
for _, slot := range itemEventExtractSpec.ReferenceSlots { itemExtractRegistrySlot := referenceSlot(itemOccurrenceExtractSpec.ReferenceSlots, "item_registry")
if slot.Required || len(slot.AcceptedArtifactKinds) != 0 { itemNormalizeRegistrySlot := referenceSlot(itemOccurrenceNormalizeSpec.ReferenceSlots, "item_registry")
t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot) 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) occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key)
occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key) occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key)
@@ -442,8 +444,8 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
{name: "enemy events", project: func() []source.SourceRef { {name: "enemy events", project: func() []source.SourceRef {
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}}) return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}}, }, want: []source.SourceRef{first, second}},
{name: "item events", project: func() []source.SourceRef { {name: "item occurrences", project: func() []source.SourceRef {
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}}) return itemOccurrenceEvidence(dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}}, }, want: []source.SourceRef{first, second}},
{name: "item registry", project: func() []source.SourceRef { {name: "item registry", project: func() []source.SourceRef {
return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}}) 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 quantity := 3
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.ItemEventList{ input := []dnd.ItemOccurrenceList{
{}, {},
{Events: []dnd.ItemEvent{}}, {Occurrences: []dnd.ItemOccurrence{}},
{Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, {Occurrences: []dnd.ItemOccurrence{{ItemID: "first", Name: "first", Kind: dnd.ItemOccurrenceKindDiscovered, 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{{ItemID: "second", Name: "second", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
} }
got, err := appendItemEventLists(input) got, err := appendItemOccurrenceLists(input)
if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) { if err != nil || got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"first", "second"}) {
t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err) t.Fatalf("appendItemOccurrenceLists() = %#v, error = %v", got, err)
} }
*got.Events[0].Quantity = 99 *got.Occurrences[0].Quantity = 99
got.Events[0].SourceRefs[0].StartUnitID = 999 got.Occurrences[0].SourceRefs[0].StartUnitID = 999
if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 { if quantity != 3 || input[2].Occurrences[0].SourceRefs[0].StartUnitID != 1 {
t.Fatal("merged item events share quantity or source-reference storage") t.Fatal("merged item occurrences share quantity or source-reference storage")
} }
for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} { for _, values := range [][]dnd.ItemOccurrenceList{nil, []dnd.ItemOccurrenceList{{}, {}}} {
result, err := appendItemEventLists(values) result, err := appendItemOccurrenceLists(values)
if err != nil || result.Events != nil { if err != nil || result.Occurrences != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err) 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) t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err)
} }
events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}}) events, err := appendItemOccurrenceLists([]dnd.ItemOccurrenceList{{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || events.Events[0].SourceRefs == nil { if err != nil || events.Occurrences[0].SourceRefs == nil {
t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, err) t.Fatalf("appendItemOccurrenceLists() = %#v, %v; want present-empty source refs", events, err)
} }
items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}}) items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}})

View File

@@ -13,6 +13,7 @@ import (
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs" 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" 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" 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" 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" 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" 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 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 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: "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 occurrence 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 occurrence registry validator", register: func() error { return itemeventregistry.Register(registries.Validators) }},
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }}, {name: "item occurrence source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
{name: "item event normalized invariants validator", register: func() error { return itemeventinvariants.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 shape validator", register: func() error { return itemshape.Register(registries.Validators) }},
{name: "item identity validator", register: func() error { return itemidentity.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) }}, {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 { {name: "enemy-event-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind) return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
}}, }},
{name: "item-event-list always accept validator", register: func() error { {name: "item-occurrence-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) return alwaysaccept.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
}}, }},
{name: "item-event-list always reject validator", register: func() error { {name: "item-occurrence-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) return alwaysreject.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
}}, }},
{name: "item-registry always accept validator", register: func() error { {name: "item-registry always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind) return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind)

View File

@@ -16,7 +16,7 @@ const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-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" const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry"
@@ -109,23 +109,24 @@ type SceneDescription struct {
Summary string `json:"summary"` Summary string `json:"summary"`
} }
type ItemEventKind string type ItemOccurrenceKind string
const ( const (
ItemEventKindDiscovered ItemEventKind = "discovered" ItemOccurrenceKindDiscovered ItemOccurrenceKind = "discovered"
ItemEventKindAcquired ItemEventKind = "acquired" ItemOccurrenceKindAcquired ItemOccurrenceKind = "acquired"
ItemEventKindLost ItemEventKind = "lost" ItemOccurrenceKindLost ItemOccurrenceKind = "lost"
ItemEventKindConsumed ItemEventKind = "consumed" ItemOccurrenceKindConsumed ItemOccurrenceKind = "consumed"
ItemEventKindTransferred ItemEventKind = "transferred" ItemOccurrenceKindTransferred ItemOccurrenceKind = "transferred"
) )
type ItemEventList struct { type ItemOccurrenceList struct {
Events []ItemEvent `json:"events"` Occurrences []ItemOccurrence `json:"occurrences"`
} }
type ItemEvent struct { type ItemOccurrence struct {
ItemID string `json:"item_id"`
Name string `json:"name"` Name string `json:"name"`
Kind ItemEventKind `json:"kind"` Kind ItemOccurrenceKind `json:"kind"`
Quantity *int `json:"quantity,omitempty"` Quantity *int `json:"quantity,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`

View File

@@ -24,7 +24,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} 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 { if err := Validate(req.Source, req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, 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. // Validate checks only invariants introduced by item-event normalization.
// Shape and source-reference failures remain owned by their earlier validators. // 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 { if itemeventshape.Validate(value) != nil {
return nil return nil
} }
@@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
if len(issues) == 0 { if len(issues) == 0 {
return nil 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) issues := make([]string, 0)
seenIdentity := make(map[string]int) seenIdentity := make(map[string]int)
for eventIndex, event := range value.Events { for eventIndex, event := range value.Occurrences {
prefix := fmt.Sprintf("events[%d]", eventIndex) prefix := fmt.Sprintf("occurrences[%d]", eventIndex)
if event.Name != itemeventmodel.DisplayValue(event.Name) { if event.Name != itemeventmodel.DisplayValue(event.Name) {
issues = append(issues, prefix+".name is not display-normalized") 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) key := itemeventmodel.ExactIdentity(order, event)
if previous, exists := seenIdentity[key]; exists { 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 { } else {
seenIdentity[key] = eventIndex seenIdentity[key] = eventIndex
} }
} }
for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ { for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ {
if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) { if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) {
issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex)) issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex))
} }
} }
return issues return issues
} }
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool { func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool {
for _, event := range value.Events { for _, event := range value.Occurrences {
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) { if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
return false return false
} }
@@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -11,20 +11,11 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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() doc := invariantDocument()
input := dnd.ItemEventList{Events: []dnd.ItemEvent{ result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()})
{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})
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v; want approval", result, err) t.Fatalf("Validate() = %#v, %v; want approval", result, err)
} }
@@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) {
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
mutate func(*dnd.ItemEventList) mutate func(*dnd.ItemOccurrenceList)
want string want string
}{ }{
{name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"}, {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.ItemEventList) { {name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
value.Events[0].Kind = dnd.ItemEventKindLost value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost
value.Events[0].From = " Aria " value.Occurrences[0].From = " Aria "
}, want: ".from is not display-normalized"}, }, want: ".from is not display-normalized"},
{name: "to whitespace", mutate: func(value *dnd.ItemEventList) { {name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
value.Events[0].Kind = dnd.ItemEventKindAcquired value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired
value.Events[0].To = " Aria " value.Occurrences[0].To = " Aria "
}, want: ".to is not display-normalized"}, }, want: ".to is not display-normalized"},
{name: "reference order", mutate: func(value *dnd.ItemEventList) { {name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) {
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, want: "not in canonical order"}, }, want: "not in canonical order"},
{name: "duplicate reference", mutate: func(value *dnd.ItemEventList) { {name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) {
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0]) value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
}, want: "duplicates the previous reference"}, }, 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: "list order", mutate: func(value *dnd.ItemOccurrenceList) {
{name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"}, 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) { if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Validate() error = %v, want %q", err, 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 { if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("validator result = %#v, %v", result, err) t.Fatalf("validator result = %#v, %v", result, err)
} }
@@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) { func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
value := dnd.ItemEventList{Events: []dnd.ItemEvent{{ value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
Name: "Coin", Kind: dnd.ItemEventKindDiscovered, ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered,
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, 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") { 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) t.Fatalf("Validate() error = %v, want document-order rejection", err)
} }
shapeInvalid := normalizedList() shapeInvalid := normalizedList()
shapeInvalid.Events[0].Name = " " shapeInvalid.Occurrences[0].Name = " "
if err := Validate(invariantDocument(), shapeInvalid); err != nil { if err := Validate(invariantDocument(), shapeInvalid); err != nil {
t.Fatalf("shape failure must be deferred, got %v", err) t.Fatalf("shape failure must be deferred, got %v", err)
} }
sourceInvalid := normalizedList() sourceInvalid := normalizedList()
sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999 sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999
if err := Validate(invariantDocument(), sourceInvalid); err != nil { if err := Validate(invariantDocument(), sourceInvalid); err != nil {
t.Fatalf("source-reference failure must be deferred, got %v", err) 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) { func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
value := normalizedList() value := normalizedList()
value.Events = make([]dnd.ItemEvent, 30) value.Occurrences = make([]dnd.ItemOccurrence, 30)
for index := range value.Events { for index := range value.Occurrences {
value.Events[index] = normalizedList().Events[0] value.Occurrences[index] = normalizedList().Occurrences[0]
value.Events[index].Name = strings.Repeat("火", 300) 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") { 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) t.Fatalf("bounded result = %#v, %v", result, err)
} }
@@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
} }
} }
func normalizedList() dnd.ItemEventList { func normalizedList() dnd.ItemOccurrenceList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{ return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, 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}}},
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
}} }}
} }

View File

@@ -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 }

View File

@@ -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)
}
}

View File

@@ -22,7 +22,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} 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 { if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, 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. // 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) issues := issuesFor(value)
if len(issues) == 0 { if len(issues) == 0 {
return nil 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 { func issuesFor(value dnd.ItemOccurrenceList) []string {
if value.Events == nil { if value.Occurrences == nil {
return []string{"events must be present"} return []string{"occurrences must be present"}
} }
issues := make([]string, 0) issues := make([]string, 0)
for index, event := range value.Events { for index, event := range value.Occurrences {
prefix := fmt.Sprintf("events[%d]", index) 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) == "" { if strings.TrimSpace(event.Name) == "" {
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(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 { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -15,18 +15,17 @@ import (
func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) { func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) {
quantity := 1 quantity := 1
value := dnd.ItemEventList{Events: []dnd.ItemEvent{ value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
{Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)}, {ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)}, {ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)},
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)}, {ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
{Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)}, {ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, 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 { if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err) 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 { if err != nil || !empty.Approved {
t.Fatalf("empty list result = %#v, %v", empty, err) t.Fatalf("empty list result = %#v, %v", empty, err)
} }
@@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) {
negative := -1 negative := -1
tests := []struct { tests := []struct {
name string name string
value dnd.ItemEventList value dnd.ItemOccurrenceList
want string want string
}{ }{
{"missing events", dnd.ItemEventList{}, "events must be present"}, {"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"},
{"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"}, {"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.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"}, {"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", 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"}, {"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
{"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, {"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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, 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.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"}, {"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "source_refs must contain"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) { 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) 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) t.Fatalf("valid result = %#v, %v", result, err)
} }
} }
func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) { func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) {
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
for index := range value.Events { for index := range value.Occurrences {
value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"} value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: " \n", Kind: "unsupported"}
} }
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 || 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") { 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) t.Fatalf("Validate() = %#v, %v", result, err)
} }
if value.Events[0].Name != " \n" { if value.Occurrences[0].Name != " \n" {
t.Fatal("Validate() mutated input") t.Fatal("Validate() mutated input")
} }
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { 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 { func validList() dnd.ItemOccurrenceList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}} 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 { func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{event}} return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}}
} }
func refs(start, end int) []source.SourceRef { func refs(start, end int) []source.SourceRef {

View File

@@ -22,7 +22,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -34,9 +34,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} 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 { 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 { if itemeventshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
@@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
coverage = newChunkCoverage(req.Chunk) coverage = newChunkCoverage(req.Chunk)
} }
issues := make([]string, 0) issues := make([]string, 0)
for eventIndex, event := range req.Value.Events { for eventIndex, event := range req.Value.Occurrences {
for refIndex, ref := range event.SourceRefs { for refIndex, ref := range event.SourceRefs {
if err := index.ValidateRef(ref); err != nil { 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 continue
} }
if coverage != nil && !coverage.contains(req.Source, ref) { 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 { if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil 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 { type chunkCoverage struct {
@@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("valid result = %#v, %v", result, err) 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: "foreign", StartUnitID: 1, EndUnitID: 1},
{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, {SourceID: "session", StartUnitID: 99, EndUnitID: 99},
{SourceID: "session", StartUnitID: 2, EndUnitID: 1}, {SourceID: "session", StartUnitID: 2, EndUnitID: 1},
@@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
if err != nil || result.Approved || result.ReasonCode != ReasonCode { if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("invalid result = %#v, %v", result, err) 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) { if !strings.Contains(result.Message, index) {
t.Fatalf("validation message = %q, missing %q", result.Message, index) t.Fatalf("validation message = %q, missing %q", result.Message, index)
} }
@@ -39,40 +39,40 @@ func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) {
doc := document() doc := document()
chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)} chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)}
contained := validList() 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 { if err != nil || !result.Approved {
t.Fatalf("contained evidence = %#v, %v", result, err) t.Fatalf("contained evidence = %#v, %v", result, err)
} }
crossesChunk := validList() crossesChunk := validList()
crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}} crossesChunk.Occurrences[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}) 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") { if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err) 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]}} noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
spansMissingUnit := validList() spansMissingUnit := validList()
spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}} spansMissingUnit.Occurrences[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}) 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") { if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
t.Fatalf("partially contained evidence = %#v, %v", result, err) t.Fatalf("partially contained evidence = %#v, %v", result, err)
} }
multiRange := validList() multiRange := validList()
multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}} 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.ItemEventList]{Source: doc, Value: multiRange}) result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: multiRange})
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("post-merge evidence = %#v, %v", result, err) t.Fatalf("post-merge evidence = %#v, %v", result, err)
} }
} }
func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) { 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") { if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
t.Fatalf("Validate() error = %v", err) 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)) result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err) t.Fatalf("shape deferral = %#v, %v", result, err)
@@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
} }
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
for index := range value.Events { for index := range value.Occurrences {
value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}} 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)) 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) t.Fatalf("Validate() = %#v, %v", result, err)
} }
before := validList() before := validList()
@@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
} }
} }
func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] { func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] {
return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value} return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value}
} }
func document() *source.SourceDocument { func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}} return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
} }
func validList() dnd.ItemEventList { func validList() dnd.ItemOccurrenceList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
} }

View File

@@ -23,7 +23,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } 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 // Validate performs advisory source grounding only. Shape and range failures
// are intentionally left to their blocking owners. // 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 { if itemeventshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, 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) 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) citedText, err := resolver.CitedText(event.SourceRefs)
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) { if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("events[%d]", index), Scope: fmt.Sprintf("occurrences[%d]", index),
ReasonCode: WarningReasonCode, 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{ return contracts.ValidationResult{
@@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { 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) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) {
{ID: 2, Text: "of healing in the chest."}, {ID: 2, Text: "of healing in the chest."},
{ID: 3, Text: "The group rests."}, {ID: 3, Text: "The group rests."},
}} }}
value := dnd.ItemEventList{Events: []dnd.ItemEvent{ 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}}},
{Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, 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}}},
{Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
}} }}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}}, "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 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v", result, err) 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) t.Fatalf("warning = %#v", warning)
} }
} }
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) { func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
for _, value := range []dnd.ItemEventList{ for _, value := range []dnd.ItemOccurrenceList{
{Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}}, {Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, 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}}}}}, {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 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("deferred result = %#v, %v", result, err) t.Fatalf("deferred result = %#v, %v", result, err)
} }
@@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) { func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
count := diagnostics.MaxWarnings + 5 count := diagnostics.MaxWarnings + 5
events := make([]dnd.ItemEvent, count) events := make([]dnd.ItemOccurrence, count)
for index := range events { 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"}}}, 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 { 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) t.Fatalf("Validate() = %#v, %v", result, err)

View File

@@ -1,170 +1,62 @@
package integration_test package integration_test
import ( import (
"context"
"encoding/json"
"fmt"
"os"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "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) { func TestItemOccurrenceConfigurationRequiresEarlierItemRegistry(t *testing.T) {
raw := readItemEventFixture(t)
registries := productionNPCRegistries(t) registries := productionNPCRegistries(t)
resolved := resolveItemEventPipeline(t, registries) cfg, err := configFromYAML(`
client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(`{"events":[ version: 4
{"name":"Healing Potion","kind":"transferred","quantity":1,"from":"Aria","to":"Borin","source_refs":[{"start_segment":3,"end_segment":3}]}, pipelines:
{"name":" Gold Pieces ","kind":"acquired","quantity":25,"to":" party ","source_refs":[{"start_segment":2,"end_segment":2}]}, items:
{"name":"Missing Relic","kind":"discovered","source_refs":[{"start_segment":4,"end_segment":4}]}, input: seriatim
{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]}, chunk: generic
{"name":"Gold Pieces","kind":"acquired","quantity":25,"to":"party","source_refs":[{"start_segment":2,"end_segment":2}]} steps:
]}`)}} - id: identify-items
artifacts:
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw}) registry:
if err != nil { extract: dnd/item-registry
t.Fatalf("Run() error = %v", err) merge: appendorder
} normalize: dnd/item-registry
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { - id: record-occurrences
t.Fatalf("run output = %#v, rejected = %#v", output.NormalizeOutputs, output.Rejected) references:
} item_registry:
serialized := normalizedLane(t, output, "item-events") artifact:
if serialized.NormalizerKey != itemeventnormalize.Key || serialized.Artifact.Schema.ID != itemeventcodec.SchemaID || serialized.Artifact.Schema.Version != itemeventcodec.SchemaVersion { step: identify-items
t.Fatalf("serialized item events = %#v", serialized) lane: registry
} artifacts:
events, err := itemeventcodec.New().Decode(serialized.Artifact.Content) occurrences:
if err != nil { extract: dnd/item-events
t.Fatalf("Decode(item event output) error = %v", err) merge: appendorder
} normalize: dnd/item-events
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")
if err != nil { if err != nil {
t.Fatal(err) 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 { broken, err := configFromYAML(strings.Replace(`
for _, warning := range warnings { version: 4
if warning.ReasonCode == reasonCode && strings.HasPrefix(warning.Scope, "events[") { pipelines:
return true 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)