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

@@ -88,14 +88,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, itemregistrynormalize.Key, occurrencenormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
@@ -230,26 +230,28 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) {
t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain)
}
itemEventExtractChain := []pipeline.ModuleBinding{
itemOccurrenceExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
}
itemEventNormalizeChain := []pipeline.ModuleBinding{
itemOccurrenceNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/registry"),
pipeline.Binding("normalize/dnd/item-events/invariants"),
pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) {
t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain)
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemOccurrenceExtractChain) {
t.Fatalf("item occurrence extract validator chain = %#v, want %#v", got, itemOccurrenceExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) {
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain)
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemOccurrenceNormalizeChain) {
t.Fatalf("item occurrence normalize validator chain = %#v, want %#v", got, itemOccurrenceNormalizeChain)
}
itemRegistryExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
@@ -328,7 +330,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd_npc_registry_llm.v1.json",
"dnd_combat_turns_llm.v1.json",
"dnd_enemy_events_llm.v1.json",
"dnd_item_events_llm.v1.json",
"dnd_item_occurrences_llm.v1.json",
"dnd_npc_occurrences_llm.v1.json",
"dnd_scene_descriptions_llm.v1.json",
"dnd_location_registry_llm.v1.json",
@@ -368,15 +370,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) {
t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
}
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key)
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 {
t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK)
itemOccurrenceExtractSpec, itemOccurrenceExtractOK := registries.Extractors.Spec(itemeventextract.Key)
itemOccurrenceNormalizeSpec, itemOccurrenceNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
if !itemOccurrenceExtractOK || itemOccurrenceExtractSpec.ArtifactKind != dnd.ItemOccurrenceListKind || !itemOccurrenceNormalizeOK || itemOccurrenceNormalizeSpec.ArtifactKind != dnd.ItemOccurrenceListKind || itemOccurrenceNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("item occurrence specs = %#v / %#v, present = %t / %t", itemOccurrenceExtractSpec, itemOccurrenceNormalizeSpec, itemOccurrenceExtractOK, itemOccurrenceNormalizeOK)
}
for _, slot := range itemEventExtractSpec.ReferenceSlots {
if slot.Required || len(slot.AcceptedArtifactKinds) != 0 {
t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot)
}
itemExtractRegistrySlot := referenceSlot(itemOccurrenceExtractSpec.ReferenceSlots, "item_registry")
itemNormalizeRegistrySlot := referenceSlot(itemOccurrenceNormalizeSpec.ReferenceSlots, "item_registry")
if !itemExtractRegistrySlot.Required || !itemNormalizeRegistrySlot.Required || itemExtractRegistrySlot.MaxBytes != itemNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedMediaTypes, itemNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedArtifactKinds, itemNormalizeRegistrySlot.AcceptedArtifactKinds) {
t.Fatalf("item occurrence registry slots disagree: %#v / %#v", itemOccurrenceExtractSpec.ReferenceSlots, itemOccurrenceNormalizeSpec.ReferenceSlots)
}
occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key)
occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key)
@@ -442,8 +444,8 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
{name: "enemy events", project: func() []source.SourceRef {
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}},
{name: "item events", project: func() []source.SourceRef {
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
{name: "item occurrences", project: func() []source.SourceRef {
return itemOccurrenceEvidence(dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}},
{name: "item registry", project: func() []source.SourceRef {
return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}})
@@ -694,27 +696,27 @@ func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
}
}
func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
func TestAppendItemOccurrenceListsPreservesOrderPresenceAndOwnership(t *testing.T) {
quantity := 3
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.ItemEventList{
input := []dnd.ItemOccurrenceList{
{},
{Events: []dnd.ItemEvent{}},
{Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}},
{Events: []dnd.ItemEvent{{Name: "second", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
{Occurrences: []dnd.ItemOccurrence{}},
{Occurrences: []dnd.ItemOccurrence{{ItemID: "first", Name: "first", Kind: dnd.ItemOccurrenceKindDiscovered, Quantity: &quantity, SourceRefs: refs}}},
{Occurrences: []dnd.ItemOccurrence{{ItemID: "second", Name: "second", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
}
got, err := appendItemEventLists(input)
if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) {
t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err)
got, err := appendItemOccurrenceLists(input)
if err != nil || got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"first", "second"}) {
t.Fatalf("appendItemOccurrenceLists() = %#v, error = %v", got, err)
}
*got.Events[0].Quantity = 99
got.Events[0].SourceRefs[0].StartUnitID = 999
if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 {
t.Fatal("merged item events share quantity or source-reference storage")
*got.Occurrences[0].Quantity = 99
got.Occurrences[0].SourceRefs[0].StartUnitID = 999
if quantity != 3 || input[2].Occurrences[0].SourceRefs[0].StartUnitID != 1 {
t.Fatal("merged item occurrences share quantity or source-reference storage")
}
for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} {
result, err := appendItemEventLists(values)
if err != nil || result.Events != nil {
for _, values := range [][]dnd.ItemOccurrenceList{nil, []dnd.ItemOccurrenceList{{}, {}}} {
result, err := appendItemOccurrenceLists(values)
if err != nil || result.Occurrences != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err)
}
}
@@ -765,9 +767,9 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err)
}
events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || events.Events[0].SourceRefs == nil {
t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, err)
events, err := appendItemOccurrenceLists([]dnd.ItemOccurrenceList{{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || events.Occurrences[0].SourceRefs == nil {
t.Fatalf("appendItemOccurrenceLists() = %#v, %v; want present-empty source refs", events, err)
}
items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}})